package server

import (
	"context"
	"database/sql"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"strconv"
	"strings"
	"time"

	"github.com/go-chi/chi/v5"

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

// torrentDTO is the JSON shape returned from /api/torrents.
type torrentDTO struct {
	InfoHash         string   `json:"info_hash"`
	Name             string   `json:"name"`
	State            string   `json:"state"`
	ClientID         string   `json:"client_id,omitempty"`
	Category         string   `json:"category,omitempty"`
	Tags             string   `json:"tags,omitempty"`
	SizeBytes        int64    `json:"size_bytes"`
	UploadedBytes    *int64   `json:"uploaded_bytes,omitempty"`
	DownloadedBytes  *int64   `json:"downloaded_bytes,omitempty"`
	Ratio            *float64 `json:"ratio,omitempty"`
	UploadSpeedBps   *int64   `json:"upload_speed_bps,omitempty"`
	DownloadSpeedBps *int64   `json:"download_speed_bps,omitempty"`
	Seeders          *int64   `json:"seeders,omitempty"`
	Leechers         *int64   `json:"leechers,omitempty"`
	LastSeenAt       int64    `json:"last_seen_at"`

	// Populated only by handleGetTorrent (the detail-page endpoint) via
	// a live qBit fetch — these aren't persisted in torrent_snapshots so
	// the list endpoint would always omit them. They're shown on the
	// detail page so the operator knows where the bytes actually live
	// on the seedbox.
	SavePath    string `json:"save_path,omitempty"`
	ContentPath string `json:"content_path,omitempty"`
}

// ClientsRegistry is the interface main.go satisfies to expose currently-
// running torrent client adapters to the server. Keeping it narrow avoids
// pulling internal/snapshot or internal/integrations/qbit into the server's
// public surface.
type ClientsRegistry interface {
	Get(id string) (qbit.TorrentClient, bool)
	IDs() []string
}

func (s *Server) handleListTorrents(w http.ResponseWriter, r *http.Request) {
	q := r.URL.Query()
	clientFilter := q.Get("client")
	stateFilter := q.Get("state")
	categoryFilter := q.Get("category")
	limit, _ := strconv.Atoi(q.Get("limit"))
	if limit <= 0 || limit > 5000 {
		limit = 500
	}

	// Latest snapshot per torrent, joined with the torrents table.
	//
	// Earlier this used a window function (ROW_NUMBER OVER PARTITION) over the
	// whole torrent_snapshots table, which ranked every row (~hundreds of
	// thousands at scale) on every call — 7-10s once the table grew. This
	// rewrite instead seeks the latest snapshot's rowid per torrent via the
	// (info_hash, timestamp DESC) index — ~N index seeks, not a full rank —
	// so it stays fast regardless of how many snapshots accumulate.
	rows, err := s.sqlite.QueryContext(r.Context(), `
		SELECT t.info_hash, t.name, t.size_bytes, t.category, t.tags, t.last_seen_at,
		       s.client_id, s.state, s.uploaded_bytes, s.downloaded_bytes, s.ratio,
		       s.upload_speed_bps, s.download_speed_bps, s.seeders, s.leechers
		FROM torrents t
		LEFT JOIN torrent_snapshots s ON s.rowid = (
		    SELECT s2.rowid FROM torrent_snapshots s2
		    WHERE s2.info_hash = t.info_hash AND s2.simulation_id IS NULL
		    ORDER BY s2.timestamp DESC LIMIT 1
		)
		WHERE t.deleted_at IS NULL
		ORDER BY t.last_seen_at DESC
		LIMIT ?
	`, limit)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	defer rows.Close()

	out := []torrentDTO{}
	for rows.Next() {
		var (
			d           torrentDTO
			cat, tags   sql.NullString
			clientID    sql.NullString
			state       sql.NullString
			up, dn      sql.NullInt64
			ratio       sql.NullFloat64
			us, ds      sql.NullInt64
			seed, leech sql.NullInt64
		)
		if err := rows.Scan(&d.InfoHash, &d.Name, &d.SizeBytes, &cat, &tags, &d.LastSeenAt,
			&clientID, &state, &up, &dn, &ratio, &us, &ds, &seed, &leech); err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}
		if cat.Valid {
			d.Category = cat.String
		}
		if tags.Valid {
			d.Tags = tags.String
		}
		if clientID.Valid {
			d.ClientID = clientID.String
		}
		if state.Valid {
			d.State = state.String
		}
		d.UploadedBytes = nullableInt(up)
		d.DownloadedBytes = nullableInt(dn)
		d.Ratio = nullableFloat(ratio)
		d.UploadSpeedBps = nullableInt(us)
		d.DownloadSpeedBps = nullableInt(ds)
		d.Seeders = nullableInt(seed)
		d.Leechers = nullableInt(leech)

		if clientFilter != "" && d.ClientID != clientFilter {
			continue
		}
		if stateFilter != "" && d.State != stateFilter {
			continue
		}
		if categoryFilter != "" && d.Category != categoryFilter {
			continue
		}
		out = append(out, d)
	}
	writeJSON(w, http.StatusOK, map[string]any{
		"count":    len(out),
		"torrents": out,
	})
}

func (s *Server) handleGetTorrent(w http.ResponseWriter, r *http.Request) {
	hash := chi.URLParam(r, "hash")
	if hash == "" {
		http.Error(w, "missing hash", http.StatusBadRequest)
		return
	}
	row := s.sqlite.QueryRowContext(r.Context(), `
		SELECT info_hash, name, size_bytes, category, tags, last_seen_at
		FROM torrents
		WHERE info_hash = ? AND deleted_at IS NULL
	`, hash)
	var d torrentDTO
	var cat, tags sql.NullString
	if err := row.Scan(&d.InfoHash, &d.Name, &d.SizeBytes, &cat, &tags, &d.LastSeenAt); err != nil {
		if err == sql.ErrNoRows {
			http.Error(w, "not found", http.StatusNotFound)
			return
		}
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	if cat.Valid {
		d.Category = cat.String
	}
	if tags.Valid {
		d.Tags = tags.String
	}
	// Latest snapshot.
	srow := s.sqlite.QueryRowContext(r.Context(), `
		SELECT client_id, state, uploaded_bytes, downloaded_bytes, ratio,
		       upload_speed_bps, download_speed_bps, seeders, leechers
		FROM torrent_snapshots
		WHERE info_hash = ? AND simulation_id IS NULL
		ORDER BY timestamp DESC LIMIT 1
	`, hash)
	var (
		cid, state sql.NullString
		up, dn     sql.NullInt64
		ratio      sql.NullFloat64
		us, ds     sql.NullInt64
		seed, lc   sql.NullInt64
	)
	if err := srow.Scan(&cid, &state, &up, &dn, &ratio, &us, &ds, &seed, &lc); err == nil {
		if cid.Valid {
			d.ClientID = cid.String
		}
		if state.Valid {
			d.State = state.String
		}
		d.UploadedBytes = nullableInt(up)
		d.DownloadedBytes = nullableInt(dn)
		d.Ratio = nullableFloat(ratio)
		d.UploadSpeedBps = nullableInt(us)
		d.DownloadSpeedBps = nullableInt(ds)
		d.Seeders = nullableInt(seed)
		d.Leechers = nullableInt(lc)
	}
	// Live-fetch save_path + content_path. These aren't persisted in
	// snapshots (would bloat rows with two long strings every poll for
	// data that almost never changes), so the detail-page handler is the
	// one place we cough up the qBit round-trip to surface them.
	if d.ClientID != "" && s.clients != nil {
		if client, ok := s.clients.Get(d.ClientID); ok {
			fetchCtx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
			t, err := client.Get(fetchCtx, hash)
			cancel()
			if err == nil {
				d.SavePath = t.SavePath
				d.ContentPath = t.ContentPath
			}
		}
	}
	writeJSON(w, http.StatusOK, d)
}

type torrentSnapDTO struct {
	Timestamp        int64    `json:"timestamp"`
	State            string   `json:"state,omitempty"`
	UploadedBytes    *int64   `json:"uploaded_bytes,omitempty"`
	DownloadedBytes  *int64   `json:"downloaded_bytes,omitempty"`
	Ratio            *float64 `json:"ratio,omitempty"`
	UploadSpeedBps   *int64   `json:"upload_speed_bps,omitempty"`
	DownloadSpeedBps *int64   `json:"download_speed_bps,omitempty"`
}

func (s *Server) handleTorrentHistory(w http.ResponseWriter, r *http.Request) {
	hash := chi.URLParam(r, "hash")
	if hash == "" {
		http.Error(w, "missing hash", http.StatusBadRequest)
		return
	}
	rangeArg := r.URL.Query().Get("range")
	cutoff := rangeCutoff(rangeArg)
	rows, err := s.sqlite.QueryContext(r.Context(), `
		SELECT timestamp, state, uploaded_bytes, downloaded_bytes, ratio,
		       upload_speed_bps, download_speed_bps
		FROM torrent_snapshots
		WHERE info_hash = ? AND simulation_id IS NULL AND timestamp >= ?
		ORDER BY timestamp ASC
	`, hash, cutoff)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	defer rows.Close()
	out := []torrentSnapDTO{}
	for rows.Next() {
		var (
			d      torrentSnapDTO
			state  sql.NullString
			up, dn sql.NullInt64
			ratio  sql.NullFloat64
			us, ds sql.NullInt64
		)
		if err := rows.Scan(&d.Timestamp, &state, &up, &dn, &ratio, &us, &ds); err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}
		if state.Valid {
			d.State = state.String
		}
		d.UploadedBytes = nullableInt(up)
		d.DownloadedBytes = nullableInt(dn)
		d.Ratio = nullableFloat(ratio)
		d.UploadSpeedBps = nullableInt(us)
		d.DownloadSpeedBps = nullableInt(ds)
		out = append(out, d)
	}
	writeJSON(w, http.StatusOK, map[string]any{
		"hash":      hash,
		"range":     rangeArg,
		"count":     len(out),
		"snapshots": out,
	})
}

func (s *Server) handleTorrentTrackers(w http.ResponseWriter, r *http.Request) {
	hash := chi.URLParam(r, "hash")
	clientID := r.URL.Query().Get("client")
	if hash == "" {
		http.Error(w, "missing hash", http.StatusBadRequest)
		return
	}
	if s.clients == nil {
		http.Error(w, "no torrent clients configured", http.StatusServiceUnavailable)
		return
	}
	client, ok := s.clientForHash(clientID)
	if !ok {
		http.Error(w, "client not found", http.StatusNotFound)
		return
	}
	trs, err := client.Trackers(r.Context(), hash)
	if err != nil {
		http.Error(w, err.Error(), http.StatusBadGateway)
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{
		"hash":     hash,
		"client":   client.ID(),
		"trackers": trs,
	})
}

// Mutation handlers all share the same shape: pick the client, dispatch.
type mutationReq struct {
	ClientID    string   `json:"client_id"`
	Hashes      []string `json:"hashes"`
	DeleteFiles bool     `json:"delete_files,omitempty"`
	Category    string   `json:"category,omitempty"`
	Tags        []string `json:"tags,omitempty"`
}

func (s *Server) decodeMutation(w http.ResponseWriter, r *http.Request) (mutationReq, qbit.TorrentClient, bool) {
	if s.clients == nil {
		http.Error(w, "no torrent clients configured", http.StatusServiceUnavailable)
		return mutationReq{}, nil, false
	}
	var req mutationReq
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, "bad json: "+err.Error(), http.StatusBadRequest)
		return req, nil, false
	}
	if len(req.Hashes) == 0 {
		// Allow /api/torrents/:hash/... routes by using the path param.
		if h := chi.URLParam(r, "hash"); h != "" {
			req.Hashes = []string{h}
		}
	}
	if len(req.Hashes) == 0 {
		http.Error(w, "hashes required", http.StatusBadRequest)
		return req, nil, false
	}
	client, ok := s.clientForHash(req.ClientID)
	if !ok {
		http.Error(w, "client not found", http.StatusNotFound)
		return req, nil, false
	}
	return req, client, true
}

func (s *Server) handlePauseTorrent(w http.ResponseWriter, r *http.Request) {
	req, client, ok := s.decodeMutation(w, r)
	if !ok {
		return
	}
	if err := client.Pause(r.Context(), req.Hashes); err != nil {
		http.Error(w, err.Error(), http.StatusBadGateway)
		return
	}
	writeJSON(w, http.StatusAccepted, map[string]any{"ok": true})
}

func (s *Server) handleResumeTorrent(w http.ResponseWriter, r *http.Request) {
	req, client, ok := s.decodeMutation(w, r)
	if !ok {
		return
	}
	if err := client.Resume(r.Context(), req.Hashes); err != nil {
		http.Error(w, err.Error(), http.StatusBadGateway)
		return
	}
	writeJSON(w, http.StatusAccepted, map[string]any{"ok": true})
}

func (s *Server) handleRecheckTorrent(w http.ResponseWriter, r *http.Request) {
	req, client, ok := s.decodeMutation(w, r)
	if !ok {
		return
	}
	if err := client.Recheck(r.Context(), req.Hashes); err != nil {
		http.Error(w, err.Error(), http.StatusBadGateway)
		return
	}
	writeJSON(w, http.StatusAccepted, map[string]any{"ok": true})
}

func (s *Server) handleDeleteTorrent(w http.ResponseWriter, r *http.Request) {
	req, client, ok := s.decodeMutation(w, r)
	if !ok {
		return
	}
	deleteFiles := req.DeleteFiles ||
		strings.EqualFold(r.URL.Query().Get("deleteFiles"), "true")
	if err := client.Delete(r.Context(), req.Hashes, deleteFiles); err != nil {
		http.Error(w, err.Error(), http.StatusBadGateway)
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}

// handleSetCategory POSTs /api/v2/torrents/setCategory upstream. Empty
// `category` clears the assignment ("(Uncategorized)" in qBit). The
// category must already exist in qBit unless qBit's "Create
// subcategories" preference is on — there's no API to create one ahead
// of time, so an unknown category silently no-ops on the qBit side.
func (s *Server) handleSetCategory(w http.ResponseWriter, r *http.Request) {
	req, client, ok := s.decodeMutation(w, r)
	if !ok {
		return
	}
	if err := client.SetCategory(r.Context(), req.Hashes, req.Category); err != nil {
		http.Error(w, err.Error(), http.StatusBadGateway)
		return
	}
	writeJSON(w, http.StatusAccepted, map[string]any{"ok": true, "category": req.Category})
}

// handleSetTags POSTs /api/v2/torrents/addTags upstream. Pass an empty
// Tags slice to leave tags unchanged; pass an explicit empty string in
// the slice to clear (qBit's API quirk).
func (s *Server) handleSetTags(w http.ResponseWriter, r *http.Request) {
	req, client, ok := s.decodeMutation(w, r)
	if !ok {
		return
	}
	if err := client.SetTags(r.Context(), req.Hashes, req.Tags); err != nil {
		http.Error(w, err.Error(), http.StatusBadGateway)
		return
	}
	writeJSON(w, http.StatusAccepted, map[string]any{"ok": true, "tags": req.Tags})
}

type addReq struct {
	ClientID string   `json:"client_id"`
	Magnet   string   `json:"magnet,omitempty"`
	Category string   `json:"category,omitempty"`
	Tags     []string `json:"tags,omitempty"`
	SavePath string   `json:"save_path,omitempty"`
	Paused   bool     `json:"paused,omitempty"`
}

func (s *Server) handleAddTorrent(w http.ResponseWriter, r *http.Request) {
	if s.clients == nil {
		http.Error(w, "no torrent clients configured", http.StatusServiceUnavailable)
		return
	}
	// Two body shapes: JSON {magnet, ...} or multipart with a .torrent file.
	ct := r.Header.Get("Content-Type")
	if strings.HasPrefix(ct, "multipart/form-data") {
		if err := r.ParseMultipartForm(32 << 20); err != nil {
			http.Error(w, "bad multipart: "+err.Error(), http.StatusBadRequest)
			return
		}
		clientID := r.FormValue("client_id")
		client, ok := s.clientForHash(clientID)
		if !ok {
			http.Error(w, "client not found", http.StatusNotFound)
			return
		}
		f, _, err := r.FormFile("torrent")
		if err != nil {
			http.Error(w, "missing torrent file: "+err.Error(), http.StatusBadRequest)
			return
		}
		defer f.Close()
		buf, err := io.ReadAll(io.LimitReader(f, 8<<20))
		if err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}
		req := qbit.AddRequest{
			TorrentFile: buf,
			Category:    r.FormValue("category"),
			SavePath:    r.FormValue("save_path"),
			Paused:      r.FormValue("paused") == "true",
		}
		if t := r.FormValue("tags"); t != "" {
			req.Tags = strings.Split(t, ",")
		}
		if err := client.Add(r.Context(), req); err != nil {
			http.Error(w, err.Error(), http.StatusBadGateway)
			return
		}
		writeJSON(w, http.StatusAccepted, map[string]any{"ok": true})
		return
	}

	var req addReq
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, "bad json: "+err.Error(), http.StatusBadRequest)
		return
	}
	client, ok := s.clientForHash(req.ClientID)
	if !ok {
		http.Error(w, "client not found", http.StatusNotFound)
		return
	}
	if err := client.Add(r.Context(), qbit.AddRequest{
		Magnet:   req.Magnet,
		Category: req.Category,
		Tags:     req.Tags,
		SavePath: req.SavePath,
		Paused:   req.Paused,
	}); err != nil {
		http.Error(w, err.Error(), http.StatusBadGateway)
		return
	}
	writeJSON(w, http.StatusAccepted, map[string]any{"ok": true})
}

// clientForHash picks a TorrentClient: the explicit clientID if provided,
// otherwise the only registered client (errors if there's ambiguity).
func (s *Server) clientForHash(clientID string) (qbit.TorrentClient, bool) {
	if s.clients == nil {
		return nil, false
	}
	if clientID != "" {
		return s.clients.Get(clientID)
	}
	ids := s.clients.IDs()
	if len(ids) == 1 {
		return s.clients.Get(ids[0])
	}
	return nil, false
}

// addFromURLReq is the JSON / query-param shape accepted by
// POST and GET /api/torrents/add-from-url. The endpoint is the
// programmatic way to push a torrent into the connected qBit when all
// the operator has is a URL — either a magnet link, a public .torrent
// URL, or a private tracker's cookie-gated .torrent download link.
type addFromURLReq struct {
	URL       string   `json:"url"`
	ClientID  string   `json:"client_id,omitempty"`
	TrackerID string   `json:"tracker_id,omitempty"` // explicit cookie source; empty = auto-detect from URL host
	Category  string   `json:"category,omitempty"`
	Tags      []string `json:"tags,omitempty"`
	SavePath  string   `json:"save_path,omitempty"`
	Paused    bool     `json:"paused,omitempty"`
}

// handleAddFromURL fetches a .torrent (or passes through a magnet) and
// hands the result to qBit. Auth flow:
//
//   - magnet:?xt=urn:btih:… → passed straight to qBit's /api/v2/torrents/add
//     urls= field; qBit handles the DHT/tracker fetch itself.
//   - http(s)://… on a known tracker host → fetched via the scrape.Hygiene
//     primitive so the operator's stored session cookie is attached, then
//     the .torrent body is posted to qBit as multipart bytes.
//   - http(s)://… on an unknown host → plain http.Get with the server's
//     default client, posted to qBit the same way.
//
// Tracker host detection: query the trackers table for an enabled tracker
// whose base_url host matches the URL's host suffix. Operator can override
// with `tracker_id` in the request.
//
// Accepts GET (query params) AND POST (JSON body). GET is for iOS
// Shortcuts and share-sheet integrations that don't want to deal with
// JSON; POST is for the in-UI form and bookmarklets.
func (s *Server) handleAddFromURL(w http.ResponseWriter, r *http.Request) {
	if s.clients == nil {
		http.Error(w, "no torrent clients configured", http.StatusServiceUnavailable)
		return
	}

	var req addFromURLReq
	if r.Method == http.MethodGet {
		q := r.URL.Query()
		req.URL = q.Get("url")
		req.ClientID = q.Get("client_id")
		req.TrackerID = q.Get("tracker_id")
		req.Category = q.Get("category")
		req.SavePath = q.Get("save_path")
		req.Paused = strings.EqualFold(q.Get("paused"), "true") || q.Get("paused") == "1"
		if t := q.Get("tags"); t != "" {
			req.Tags = strings.Split(t, ",")
		}
	} else {
		if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil {
			http.Error(w, "bad json: "+err.Error(), http.StatusBadRequest)
			return
		}
	}

	req.URL = strings.TrimSpace(req.URL)
	if req.URL == "" {
		http.Error(w, "url required", http.StatusBadRequest)
		return
	}

	client, ok := s.clientForHash(req.ClientID)
	if !ok {
		http.Error(w, "no torrent client available (set client_id or enable exactly one client)", http.StatusNotFound)
		return
	}

	// Magnet shortcut — pass through, qBit handles the actual DHT fetch.
	if strings.HasPrefix(strings.ToLower(req.URL), "magnet:") {
		addReq := qbit.AddRequest{
			Magnet:   req.URL,
			Category: req.Category,
			Tags:     req.Tags,
			SavePath: req.SavePath,
			Paused:   req.Paused,
		}
		if err := client.Add(r.Context(), addReq); err != nil {
			s.logger.Error().Err(err).Str("kind", "magnet").Str("client_id", req.ClientID).Msg("add-from-url: qbit add failed")
			// 500 (not 502) so Cloudflare passes the body through instead of
			// substituting its generic 502 page — operator sees the real error.
			http.Error(w, "qbit add (magnet): "+err.Error(), http.StatusInternalServerError)
			return
		}
		writeJSON(w, http.StatusAccepted, map[string]any{
			"ok":     true,
			"kind":   "magnet",
			"client": req.ClientID,
		})
		return
	}

	// HTTP(S) → fetch the .torrent ourselves (so we can attach tracker
	// session cookies the operator placed in the secrets store).
	parsed, err := url.Parse(req.URL)
	if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") {
		http.Error(w, "url must be magnet: / http:// / https://", http.StatusBadRequest)
		return
	}

	trackerID := req.TrackerID
	if trackerID == "" {
		trackerID = s.guessTrackerIDFromHost(r.Context(), parsed.Host)
	}

	fetchCtx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
	defer cancel()

	body, fetchErr := s.fetchTorrentBytes(fetchCtx, req.URL, trackerID)
	if fetchErr != nil {
		s.logger.Error().Err(fetchErr).Str("url", req.URL).Str("tracker_id", trackerID).Msg("add-from-url: fetch torrent failed")
		http.Error(w, "fetch torrent: "+fetchErr.Error(), http.StatusInternalServerError)
		return
	}

	// Sanity-check: bencoded torrent dictionaries always start with 'd'
	// followed by a length-prefixed key (the smallest valid prefix is
	// "d4:info" but in practice "d8:announce" or "d13:announce-list" is
	// what every tracker emits). If we got HTML back instead, the
	// operator probably hit an auth wall.
	if len(body) < 11 || body[0] != 'd' {
		preview := body
		if len(preview) > 200 {
			preview = preview[:200]
		}
		http.Error(w,
			fmt.Sprintf("URL did not return a .torrent file (%d bytes, starts with %q)", len(body), string(preview)),
			http.StatusBadRequest)
		return
	}

	addReq := qbit.AddRequest{
		TorrentFile: body,
		Category:    req.Category,
		Tags:        req.Tags,
		SavePath:    req.SavePath,
		Paused:      req.Paused,
	}
	if err := client.Add(r.Context(), addReq); err != nil {
		s.logger.Error().Err(err).Str("kind", "torrent_file").Str("tracker_id", trackerID).Int("bytes", len(body)).Msg("add-from-url: qbit add failed")
		http.Error(w, "qbit add (torrent file): "+err.Error(), http.StatusInternalServerError)
		return
	}
	writeJSON(w, http.StatusAccepted, map[string]any{
		"ok":         true,
		"kind":       "torrent_file",
		"tracker_id": trackerID,
		"bytes":      len(body),
		"client":     req.ClientID,
	})
}

// fetchTorrentBytes pulls the bytes of a .torrent from a URL, using the
// tracker's stored session cookie when trackerID is non-empty. Bounded to
// 32 MiB which is well above any realistic .torrent file size.
func (s *Server) fetchTorrentBytes(ctx context.Context, urlStr, trackerID string) ([]byte, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, urlStr, nil)
	if err != nil {
		return nil, err
	}
	req.Header.Set("Accept", "application/x-bittorrent, application/octet-stream, */*")
	// A realistic UA helps some trackers' WAFs not bounce the request.
	req.Header.Set("User-Agent", "command-center/1.0 (+https://adampowell.pro/command-center/)")

	var resp *http.Response
	if s.cookieFetcher != nil && trackerID != "" {
		resp, err = s.cookieFetcher.Do(ctx, req, trackerID)
	} else {
		resp, err = http.DefaultClient.Do(req)
	}
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		// Read a small preview of the body so the operator gets a hint
		// instead of just a status code.
		preview, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
		return nil, fmt.Errorf("status %d from %s: %q", resp.StatusCode, urlStr, string(preview))
	}
	return io.ReadAll(io.LimitReader(resp.Body, 32<<20))
}

// guessTrackerIDFromHost finds a configured tracker whose base_url host
// suffix-matches the requested URL's host. Common case: a download URL
// at "www.torrentday.com/download.php/..." matches a tracker entry whose
// base_url is "https://www.torrentday.com" → trackerID "td". Returns ""
// on no match (caller will fetch with no cookie attached).
//
// Note: we do NOT filter by enabled=1 here. The `enabled` flag governs
// background scraping (the operator's "no auto-scraping by default"
// preference); on-demand cookie use for one-shot fetches like
// /api/torrents/add-from-url and the feeds-page Send-to-qBit should
// work independently. A tracker with a stored cookie should fetch fine
// even when its scraper is paused.
func (s *Server) guessTrackerIDFromHost(ctx context.Context, host string) string {
	if s.sqlite == nil || host == "" {
		return ""
	}
	rows, err := s.sqlite.QueryContext(ctx,
		`SELECT id, base_url FROM trackers`)
	if err != nil {
		return ""
	}
	defer rows.Close()

	urlHost := strings.ToLower(strings.TrimPrefix(host, "www."))
	for rows.Next() {
		var id, baseURL string
		if err := rows.Scan(&id, &baseURL); err != nil {
			continue
		}
		u, err := url.Parse(baseURL)
		if err != nil || u.Host == "" {
			continue
		}
		baseHost := strings.ToLower(strings.TrimPrefix(u.Host, "www."))
		if urlHost == baseHost || strings.HasSuffix(urlHost, "."+baseHost) {
			return id
		}
	}
	return ""
}
