package server

import (
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"path"
	"sort"
	"strings"

	"github.com/operator/command-center/internal/integrations/feralssh"
	"github.com/operator/command-center/internal/integrations/qbit"
)

// Tree feature — a hierarchical browser over the Feral seedbox content, with
// hardlink-aware "delete permanently" that purges both qBit (the torrent + its
// downloaded files) and any surviving hardlinks elsewhere on disk (Jellyfin
// keepers, manual hardlinks). Hybrid view: "By torrent" comes from the qBit
// /api/v2/torrents/info call; "By filesystem" walks the filesystem via SSH.

// ---------------------------------------------------------------------------
// Path safety guard. Every path that flows through an SSH command MUST be
// validated against this allowlist — otherwise a malformed/malicious request
// could `rm -rf /` on the seedbox. The allowlist matches the directories the
// user actually browses in the Tree UI; anything else is rejected.

var allowedRootPrefixes = []string{
	"/media/sdn/feralcovete",            // user's home (Feral mounts the slot here)
	"/media/sdo/feralcovete",            // additional spindles, if any
	"/media/sdp/feralcovete",
	"/media/sdq/feralcovete",
	"~",                                  // resolved server-side; safe because the SSH session is the user
}

// pathAllowed returns true only when p is inside one of the allowed roots
// AND contains no ".." escapes after Clean. The leading "~" form is permitted
// because $HOME on Feral always resolves to the user's slot dir.
func pathAllowed(p string) bool {
	if p == "" {
		return false
	}
	cleaned := path.Clean(p)
	if strings.Contains(cleaned, "..") {
		return false
	}
	for _, root := range allowedRootPrefixes {
		if cleaned == root || strings.HasPrefix(cleaned, root+"/") {
			return true
		}
	}
	return false
}

// ---------------------------------------------------------------------------
// "By torrent" view — list torrents, grouped by qBit category.

type treeTorrentDTO struct {
	Hash         string  `json:"hash"`
	Name         string  `json:"name"`
	Category     string  `json:"category"`
	State        string  `json:"state"`
	SizeBytes    int64   `json:"size_bytes"`
	Ratio        float64 `json:"ratio"`
	Progress     float64 `json:"progress"`
	ContentPath  string  `json:"content_path"`
	SavePath     string  `json:"save_path"`
	AddedOn      int64   `json:"added_on"`
	Tracker      string  `json:"tracker"`
	Seeders      int     `json:"seeders"`
	Leechers     int     `json:"leechers"`
}

type treeCategoryDTO struct {
	Name       string           `json:"name"`
	Count      int              `json:"count"`
	TotalBytes int64            `json:"total_bytes"`
	Torrents   []treeTorrentDTO `json:"torrents"`
}

type treeTorrentsResp struct {
	Categories []treeCategoryDTO `json:"categories"`
	TotalCount int               `json:"total_count"`
	TotalBytes int64             `json:"total_bytes"`
	ClientID   string            `json:"client_id"`
}

// handleTreeTorrents groups every torrent in the (first) qBit client by
// category and returns the result. The TS layer renders this as a collapsible
// tree, one branch per category, leaves being torrents.
func (s *Server) handleTreeTorrents(w http.ResponseWriter, r *http.Request) {
	client, ok := s.firstTorrentClient()
	if !ok {
		http.Error(w, "tree: no torrent client configured", http.StatusServiceUnavailable)
		return
	}
	torrents, err := client.List(r.Context(), qbit.ListFilter{})
	if err != nil {
		http.Error(w, "tree: qbit list: "+err.Error(), http.StatusBadGateway)
		return
	}
	byCat := map[string]*treeCategoryDTO{}
	totalBytes := int64(0)
	for _, t := range torrents {
		cat := t.Category
		if cat == "" {
			cat = "(uncategorized)"
		}
		c, ok := byCat[cat]
		if !ok {
			c = &treeCategoryDTO{Name: cat}
			byCat[cat] = c
		}
		c.Count++
		c.TotalBytes += t.Size
		totalBytes += t.Size
		c.Torrents = append(c.Torrents, treeTorrentDTO{
			Hash:        t.Hash,
			Name:        t.Name,
			Category:    cat,
			State:       t.State,
			SizeBytes:   t.Size,
			Ratio:       t.Ratio,
			Progress:    t.Progress,
			ContentPath: t.ContentPath,
			SavePath:    t.SavePath,
			AddedOn:     t.AddedOn,
			Tracker:     t.Tracker,
			Seeders:     t.NumSeeds,
			Leechers:    t.NumLeechs,
		})
	}
	resp := treeTorrentsResp{
		ClientID:   client.ID(),
		TotalCount: len(torrents),
		TotalBytes: totalBytes,
		Categories: []treeCategoryDTO{}, // never null in JSON
	}
	for _, c := range byCat {
		if c.Torrents == nil {
			c.Torrents = []treeTorrentDTO{}
		}
		sort.Slice(c.Torrents, func(i, j int) bool { return c.Torrents[i].Name < c.Torrents[j].Name })
		resp.Categories = append(resp.Categories, *c)
	}
	sort.Slice(resp.Categories, func(i, j int) bool {
		// Sort categories by descending size — heaviest first, since that's
		// what an operator looking to "free up space" cares about.
		return resp.Categories[i].TotalBytes > resp.Categories[j].TotalBytes
	})
	writeJSON(w, http.StatusOK, resp)
}

// ---------------------------------------------------------------------------
// "By filesystem" view — list a directory (one level) on the seedbox.

type treeFilesResp struct {
	Path    string                `json:"path"`
	Parent  string                `json:"parent"`
	Entries []feralssh.FileEntry  `json:"entries"`
	Roots   []string              `json:"roots,omitempty"` // only populated when path is empty
}

// handleTreeFiles returns the one-level listing of ?path=. If path is empty,
// returns the list of allowed root prefixes so the UI can prompt the user to
// pick a starting point.
func (s *Server) handleTreeFiles(w http.ResponseWriter, r *http.Request) {
	if s.feralSSH == nil {
		http.Error(w, "tree: feral SSH not configured", http.StatusServiceUnavailable)
		return
	}
	p := strings.TrimSpace(r.URL.Query().Get("path"))
	if p == "" || p == "~" {
		// Surface the user's home as the conventional starting point.
		p = "~"
	}
	if p != "~" && !pathAllowed(p) {
		http.Error(w, "tree: path outside the allowed roots", http.StatusForbidden)
		return
	}
	entries, err := s.feralSSH.ListDir(r.Context(), p)
	if err != nil {
		http.Error(w, "tree: ssh ls: "+err.Error(), http.StatusBadGateway)
		return
	}
	parent := path.Dir(strings.TrimRight(p, "/"))
	if p == "~" {
		parent = ""
	}
	if entries == nil {
		entries = []feralssh.FileEntry{}
	}
	writeJSON(w, http.StatusOK, treeFilesResp{
		Path:    p,
		Parent:  parent,
		Entries: entries,
	})
}

// ---------------------------------------------------------------------------
// "Inspect" — preview the impact of a delete BEFORE acting. The UI uses this
// to populate the confirmation modal: file count, total size, all hardlinks
// that would survive a qBit-only delete (so the operator can opt to cascade).

type treeInspectReq struct {
	Hash string `json:"hash,omitempty"` // either hash OR path, never both
	Path string `json:"path,omitempty"`
}

type treeInspectResp struct {
	DisplayName       string                 `json:"display_name"`
	SizeBytes         int64                  `json:"size_bytes"`
	IsDirectory       bool                   `json:"is_directory"`
	WillStopSeeding   bool                   `json:"will_stop_seeding"`
	Hardlinks         []treeHardlinkDTO      `json:"hardlinks"`
	HardlinksTotal    int                    `json:"hardlinks_total"`
	HardlinkBytesFreed int64                 `json:"hardlink_bytes_freed_estimate"`
	Targets           []string               `json:"targets"` // paths that would be rm'd if cascade=true
}

type treeHardlinkDTO struct {
	Path  string `json:"path"`
	Inode int64  `json:"inode"`
	Links int    `json:"link_count"`
	Size  int64  `json:"size_bytes"`
}

func (s *Server) handleTreeInspect(w http.ResponseWriter, r *http.Request) {
	var req treeInspectReq
	if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&req); err != nil {
		http.Error(w, "bad json: "+err.Error(), http.StatusBadRequest)
		return
	}
	if (req.Hash == "") == (req.Path == "") {
		http.Error(w, "tree: provide exactly one of hash or path", http.StatusBadRequest)
		return
	}
	if s.feralSSH == nil {
		http.Error(w, "tree: feral SSH not configured", http.StatusServiceUnavailable)
		return
	}

	ctx := r.Context()
	var inspectPath string
	var displayName string
	var willStopSeeding bool

	var knownSize int64 // size from qBit when available — avoids `du -sb`
	if req.Hash != "" {
		client, ok := s.firstTorrentClient()
		if !ok {
			http.Error(w, "tree: no torrent client", http.StatusServiceUnavailable)
			return
		}
		t, err := client.Get(ctx, req.Hash)
		if err != nil {
			s.logger.Warn().Err(err).Str("hash", req.Hash).Msg("tree/inspect: qbit get failed")
			http.Error(w, "tree: qbit get: "+err.Error(), http.StatusBadGateway)
			return
		}
		inspectPath = t.ContentPath
		if inspectPath == "" {
			inspectPath = t.SavePath
		}
		displayName = t.Name
		knownSize = t.Size
		willStopSeeding = strings.Contains(strings.ToLower(t.State), "up") ||
			strings.HasPrefix(strings.ToLower(t.State), "seed") ||
			strings.HasPrefix(strings.ToLower(t.State), "stalled")

		// Feral's qBit runs inside a container (`ns/containers/docs-qbittorrent`),
		// so t.ContentPath may report a container-internal path that doesn't
		// stat on the host (the wo6dgbpvu workflow showed inspect 502s in
		// ~1.1s — fast enough for one stat to fail and bail). When that
		// happens, fall back to a basename search under the known seedbox
		// roots so the operator can still proceed.
		if inspectPath != "" {
			st, statErr := s.feralSSH.Stat(ctx, inspectPath)
			if statErr != nil || !st.Exists {
				original := inspectPath
				roots := []string{
					"$HOME/private/qbittorrent/data",
					"$HOME/files",
					"$HOME/media",
				}
				if t.SavePath != "" && t.SavePath != t.ContentPath {
					// Also try the directory of SavePath as a hint root.
					roots = append(roots, t.SavePath)
				}
				resolved, fnErr := s.feralSSH.FindByName(ctx, path.Base(strings.TrimRight(inspectPath, "/")), roots)
				if fnErr == nil && resolved != "" {
					s.logger.Info().
						Str("qbit_path", original).
						Str("resolved_path", resolved).
						Msg("tree/inspect: resolved content_path via basename fallback")
					inspectPath = resolved
				} else {
					s.logger.Warn().
						Str("qbit_path", original).
						Str("name", path.Base(strings.TrimRight(inspectPath, "/"))).
						Bool("stat_exists", st.Exists).
						AnErr("stat_err", statErr).
						AnErr("find_err", fnErr).
						Msg("tree/inspect: content_path does not stat on host and basename fallback failed")
				}
			}
		}
	} else {
		if !pathAllowed(req.Path) {
			http.Error(w, "tree: path outside the allowed roots", http.StatusForbidden)
			return
		}
		inspectPath = req.Path
		displayName = path.Base(strings.TrimRight(req.Path, "/"))
	}

	resp, err := s.inspectPath(ctx, inspectPath, knownSize)
	if err != nil {
		// Log the real SSH error so future 502s aren't a black box (the
		// wo6dgbpvu workflow flagged that the 84-byte error body wasn't
		// landing in command-center.log).
		s.logger.Warn().Err(err).Str("path", inspectPath).Str("hash", req.Hash).Msg("tree/inspect: inspectPath failed")
		http.Error(w, "tree: inspect: "+err.Error(), http.StatusBadGateway)
		return
	}
	resp.DisplayName = displayName
	resp.WillStopSeeding = willStopSeeding
	if inspectPath != "" {
		resp.Targets = append(resp.Targets, inspectPath)
	}
	for _, hl := range resp.Hardlinks {
		resp.Targets = append(resp.Targets, hl.Path)
	}
	writeJSON(w, http.StatusOK, resp)
}

// inspectPath does the SSH legwork shared by the inspect + delete handlers.
// Returns the size + hardlink info for the given path. The Hardlinks slice
// EXCLUDES the path itself so the caller can show "elsewhere this also lives".
//
// knownSize, when > 0, is used as the size (e.g. from a qBit torrent.Size we
// already paid to fetch). Avoids `du -sb` on multi-GB dirs which can dominate
// the request latency.
func (s *Server) inspectPath(ctx context.Context, p string, knownSize int64) (treeInspectResp, error) {
	stat, err := s.feralSSH.Stat(ctx, p)
	if err != nil {
		return treeInspectResp{}, err
	}
	if !stat.Exists {
		return treeInspectResp{}, fmt.Errorf("path does not exist on host: %s", p)
	}

	var sizeBytes int64
	switch {
	case knownSize > 0:
		sizeBytes = knownSize
	case stat.IsDir:
		sizeBytes, _ = s.feralSSH.DiskUsage(ctx, p)
	default:
		sizeBytes = stat.Size
	}

	// For directories, scan files for any with link_count>1 and look up ALL
	// their hardlinks in ONE batched find call. For files, just check the
	// file itself.
	resp := treeInspectResp{
		SizeBytes:   sizeBytes,
		IsDirectory: stat.IsDir,
		Hardlinks:   []treeHardlinkDTO{}, // emit `[]` not `null` in JSON
		Targets:     []string{},
	}
	type fileInfo struct {
		inode int64
		links int
		size  int64
		path  string
	}
	var hardlinkCandidates []fileInfo

	if !stat.IsDir {
		if stat.Links > 1 {
			hardlinkCandidates = []fileInfo{{inode: stat.Inode, links: stat.Links, size: stat.Size, path: p}}
		}
	} else {
		// `| head -200` caps the sample; for typical torrent dirs this is plenty.
		// Audiobook/comic packs >200 files still get representative coverage.
		cmd := fmt.Sprintf(
			`find %s -type f -printf '%%i\t%%n\t%%s\t%%p\n' 2>/dev/null | head -200`,
			shellQuoteSingle(p),
		)
		out, err := s.feralSSH.Run(ctx, cmd)
		if err != nil {
			return treeInspectResp{}, err
		}
		for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
			if line == "" {
				continue
			}
			parts := strings.SplitN(line, "\t", 4)
			if len(parts) != 4 {
				continue
			}
			fi := fileInfo{
				inode: atoi64s(parts[0]),
				links: atois(parts[1]),
				size:  atoi64s(parts[2]),
				path:  parts[3],
			}
			if fi.links > 1 {
				hardlinkCandidates = append(hardlinkCandidates, fi)
			}
		}
	}

	// Batch ALL the inode lookups into ONE find call (workflow wo6dgbpvu
	// measured this at ~24x faster than per-inode finds for 8-file torrents).
	if len(hardlinkCandidates) > 0 {
		inodes := make([]int64, 0, len(hardlinkCandidates))
		seen := map[int64]bool{}
		for _, fi := range hardlinkCandidates {
			if seen[fi.inode] {
				continue
			}
			seen[fi.inode] = true
			inodes = append(inodes, fi.inode)
		}
		matches, ferr := s.feralSSH.FindByInodes(ctx, inodes)
		if ferr != nil {
			// Surface the find failure but don't bail — the operator can
			// still see the primary path's size and proceed; they just lose
			// the hardlink preview. Log so we know.
			s.logger.Warn().Err(ferr).Str("path", p).Int("inodes", len(inodes)).Msg("tree/inspect: hardlink batch find failed")
		} else {
			byInode := map[int64]fileInfo{}
			for _, fi := range hardlinkCandidates {
				if _, ok := byInode[fi.inode]; !ok {
					byInode[fi.inode] = fi
				}
			}
			for inode, paths := range matches {
				fi := byInode[inode]
				for _, o := range paths {
					if o == "" || o == fi.path || o == p ||
						strings.HasPrefix(o, p+"/") {
						continue
					}
					resp.Hardlinks = append(resp.Hardlinks, treeHardlinkDTO{
						Path: o, Inode: inode, Links: fi.links, Size: fi.size,
					})
				}
			}
		}
	}
	resp.HardlinksTotal = len(resp.Hardlinks)
	for _, hl := range resp.Hardlinks {
		// A hardlink contributes to disk reclamation only when removing all
		// links would drop the inode to zero — i.e. links==2 and we're
		// removing one of them. We can't know without removing both, so the
		// "estimate" is an upper bound: full file size IF this is the last
		// extra link.
		if hl.Links == 2 {
			resp.HardlinkBytesFreed += hl.Size
		}
	}
	return resp, nil
}

// ---------------------------------------------------------------------------
// "Delete" — actually purge. Calls qBit delete (with deleteFiles=true) and
// optionally SSH-rms surviving hardlinks elsewhere.

type treeDeleteReq struct {
	Hash             string `json:"hash,omitempty"`
	Path             string `json:"path,omitempty"`
	DeleteHardlinks  bool   `json:"delete_hardlinks,omitempty"`
}

type treeDeleteResp struct {
	OK              bool     `json:"ok"`
	QbitDeleted     bool     `json:"qbit_deleted"`
	PathsRemoved    []string `json:"paths_removed"`
	HardlinksFound  int      `json:"hardlinks_found"`
	HardlinksRemoved int     `json:"hardlinks_removed"`
	BytesFreedEstimate int64 `json:"bytes_freed_estimate"`
	Warnings        []string `json:"warnings,omitempty"`
}

func (s *Server) handleTreeDelete(w http.ResponseWriter, r *http.Request) {
	var req treeDeleteReq
	if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&req); err != nil {
		http.Error(w, "bad json: "+err.Error(), http.StatusBadRequest)
		return
	}
	if (req.Hash == "") == (req.Path == "") {
		http.Error(w, "tree: provide exactly one of hash or path", http.StatusBadRequest)
		return
	}
	if s.feralSSH == nil {
		http.Error(w, "tree: feral SSH not configured", http.StatusServiceUnavailable)
		return
	}

	ctx := r.Context()
	// Initialize empty slices so the JSON contract is always `[]`, never
	// `null` — Go marshals a nil slice as `null`, which crashes naive
	// frontend code doing `r.paths_removed.length`.
	resp := treeDeleteResp{OK: true, PathsRemoved: []string{}, Warnings: []string{}}

	if req.Hash != "" {
		client, ok := s.firstTorrentClient()
		if !ok {
			http.Error(w, "tree: no torrent client", http.StatusServiceUnavailable)
			return
		}
		t, err := client.Get(ctx, req.Hash)
		if err != nil {
			http.Error(w, "tree: qbit get: "+err.Error(), http.StatusBadGateway)
			return
		}
		// Inspect FIRST so we know the hardlinks before qBit removes its copy
		// (qBit-delete + deleteFiles=true changes link counts).
		inspect, err := s.inspectPath(ctx, firstNonEmpty(t.ContentPath, t.SavePath), t.Size)
		if err != nil {
			resp.Warnings = append(resp.Warnings, "inspect: "+err.Error())
		}

		if err := client.Delete(ctx, []string{req.Hash}, true); err != nil {
			http.Error(w, "tree: qbit delete: "+err.Error(), http.StatusBadGateway)
			return
		}
		resp.QbitDeleted = true
		resp.BytesFreedEstimate += inspect.SizeBytes

		if req.DeleteHardlinks {
			for _, hl := range inspect.Hardlinks {
				resp.HardlinksFound++
				if err := s.feralSSH.Remove(ctx, hl.Path); err != nil {
					resp.Warnings = append(resp.Warnings, "rm "+hl.Path+": "+err.Error())
					continue
				}
				resp.PathsRemoved = append(resp.PathsRemoved, hl.Path)
				resp.HardlinksRemoved++
				if hl.Links == 2 {
					resp.BytesFreedEstimate += hl.Size
				}
			}
		} else {
			resp.HardlinksFound = len(inspect.Hardlinks)
		}
		s.logger.Info().
			Str("hash", req.Hash).
			Str("name", t.Name).
			Int64("size_freed", resp.BytesFreedEstimate).
			Int("hardlinks_removed", resp.HardlinksRemoved).
			Msg("tree: torrent deleted")
		s.triggerJellyfinScan(ctx) // fire-and-forget; clears ghost entries from the library
		writeJSON(w, http.StatusOK, resp)
		return
	}

	// Path branch — direct filesystem delete, no qBit involvement.
	if !pathAllowed(req.Path) {
		http.Error(w, "tree: path outside the allowed roots", http.StatusForbidden)
		return
	}
	inspect, err := s.inspectPath(ctx, req.Path, 0)
	if err != nil {
		resp.Warnings = append(resp.Warnings, "inspect: "+err.Error())
	}
	if err := s.feralSSH.Remove(ctx, req.Path); err != nil {
		http.Error(w, "tree: rm: "+err.Error(), http.StatusBadGateway)
		return
	}
	resp.PathsRemoved = append(resp.PathsRemoved, req.Path)
	resp.BytesFreedEstimate += inspect.SizeBytes
	if req.DeleteHardlinks {
		for _, hl := range inspect.Hardlinks {
			resp.HardlinksFound++
			if err := s.feralSSH.Remove(ctx, hl.Path); err != nil {
				resp.Warnings = append(resp.Warnings, "rm "+hl.Path+": "+err.Error())
				continue
			}
			resp.PathsRemoved = append(resp.PathsRemoved, hl.Path)
			resp.HardlinksRemoved++
		}
	} else {
		resp.HardlinksFound = len(inspect.Hardlinks)
	}
	s.logger.Info().
		Str("path", req.Path).
		Int64("size_freed", resp.BytesFreedEstimate).
		Int("hardlinks_removed", resp.HardlinksRemoved).
		Msg("tree: path deleted")
	s.triggerJellyfinScan(ctx)
	writeJSON(w, http.StatusOK, resp)
}

// triggerJellyfinScan asks Jellyfin to rescan its libraries so torrents we
// just deleted stop appearing as ghost entries in the user's library. Best-
// effort: if Jellyfin isn't configured or the call fails, log and move on.
// Runs in the request context so it doesn't outlive the delete.
//
// Two passes:
//  1. POST /Library/Refresh — picks up any NEW files (cheap, async on Jellyfin's side).
//  2. Orphan sweep — Library/Refresh by design leaves "missing" items in the
//     library (default UX choice — they get a missing badge but stay). For
//     our use case (just deleted, want it gone) we additionally enumerate
//     Movie+Series items and DELETE the ones whose Path no longer stats on
//     the host. Stats go over the existing SSH transport so it's reasonably
//     fast for typical libraries (one SSH session per item).
func (s *Server) triggerJellyfinScan(ctx context.Context) {
	if !s.jellyfinReady() {
		return
	}
	// 1) Refresh — pick up new files.
	if resp, err := s.jellyfinRequest(ctx, http.MethodPost, "/Library/Refresh", nil); err != nil {
		s.logger.Warn().Err(err).Msg("tree: jellyfin library refresh failed")
	} else {
		_ = resp.Body.Close()
		if resp.StatusCode < 200 || resp.StatusCode >= 300 {
			s.logger.Warn().Int("status", resp.StatusCode).Msg("tree: jellyfin library refresh non-2xx")
		}
	}
	// 2) Orphan sweep — DELETE items whose Path no longer exists.
	if s.feralSSH == nil {
		return
	}
	resp, err := s.jellyfinRequest(ctx, http.MethodGet,
		"/Items?recursive=true&includeItemTypes=Movie,Series&fields=Path", nil)
	if err != nil {
		s.logger.Warn().Err(err).Msg("tree: jellyfin orphan-sweep list failed")
		return
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		s.logger.Warn().Int("status", resp.StatusCode).Msg("tree: jellyfin orphan-sweep list non-200")
		return
	}
	var data struct {
		Items []struct {
			ID   string `json:"Id"`
			Name string `json:"Name"`
			Path string `json:"Path"`
		} `json:"Items"`
	}
	if err := json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(&data); err != nil {
		s.logger.Warn().Err(err).Msg("tree: jellyfin orphan-sweep decode failed")
		return
	}
	removed := 0
	for _, item := range data.Items {
		if item.Path == "" || item.ID == "" {
			continue
		}
		st, err := s.feralSSH.Stat(ctx, item.Path)
		if err != nil || st.Exists {
			continue // exists (or transient SSH failure) — keep
		}
		delResp, err := s.jellyfinRequest(ctx, http.MethodDelete, "/Items/"+item.ID, nil)
		if err != nil {
			s.logger.Warn().Err(err).Str("name", item.Name).Msg("tree: jellyfin orphan delete failed")
			continue
		}
		_ = delResp.Body.Close()
		if delResp.StatusCode < 200 || delResp.StatusCode >= 300 {
			s.logger.Warn().Int("status", delResp.StatusCode).Str("name", item.Name).Msg("tree: jellyfin orphan delete non-2xx")
			continue
		}
		s.logger.Info().Str("name", item.Name).Str("path", item.Path).Msg("tree: jellyfin orphan removed")
		removed++
	}
	if removed > 0 {
		s.logger.Info().Int("removed", removed).Msg("tree: jellyfin orphan sweep complete")
	}
}

// ---------------------------------------------------------------------------
// Helpers

// firstTorrentClient returns one of the configured qBit clients. The CC
// currently has a single Feral qBit, so picking the first is fine — if a
// second client is ever added, this becomes a config knob.
func (s *Server) firstTorrentClient() (qbit.TorrentClient, bool) {
	if s.clients == nil {
		return nil, false
	}
	ids := s.clients.IDs()
	if len(ids) == 0 {
		return nil, false
	}
	return s.clients.Get(ids[0])
}

func firstNonEmpty(a, b string) string {
	if a != "" {
		return a
	}
	return b
}

// shellQuoteSingle mirrors the SSH client's quoting helper; duplicated here so
// the server package doesn't need to reach into feralssh's unexported funcs.
func shellQuoteSingle(s string) string {
	return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
}

func atois(s string) int {
	var n int
	_, _ = fmt.Sscanf(s, "%d", &n)
	return n
}
func atoi64s(s string) int64 {
	var n int64
	_, _ = fmt.Sscanf(s, "%d", &n)
	return n
}

