package qbit

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"mime/multipart"
	"net/http"
	"net/http/cookiejar"
	"net/url"
	"strconv"
	"strings"
	"sync"
	"time"
)

func init() {
	Register("qbit", newQbitDirect)
	Register("qui", newQbitQui)
}

// qbitDirect is a qBittorrent WebUI API client. Talks to /api/v2/* with
// cookie-based session auth.
type qbitDirect struct {
	cfg     Config
	deps    Deps
	baseURL string
	client  *http.Client

	mu          sync.Mutex
	loggedIn    bool
	lastLoginAt time.Time

	// directConfig parsed out of cfg.TypeConfigJSON.
	username string
}

type qbitDirectConfig struct {
	Username string `json:"username,omitempty"`
}

func newQbitDirect(cfg Config, deps Deps) (TorrentClient, error) {
	if cfg.BaseURL == "" {
		return nil, errors.New("qbit: base_url required")
	}
	c := qbitDirectConfig{}
	if len(cfg.TypeConfigJSON) > 0 {
		if err := json.Unmarshal(cfg.TypeConfigJSON, &c); err != nil {
			return nil, fmt.Errorf("qbit: parse type-config: %w", err)
		}
	}
	jar, _ := cookiejar.New(nil)
	return &qbitDirect{
		cfg:      cfg,
		deps:     deps,
		baseURL:  strings.TrimRight(cfg.BaseURL, "/"),
		username: c.Username,
		client: &http.Client{
			Timeout: 30 * time.Second,
			Jar:     jar,
		},
	}, nil
}

// newQbitQui is the qui reverse-proxy variant. qui presents an authenticated
// reverse proxy over the qBittorrent WebUI; from the client's perspective
// it's the same API, just at a different URL path. Phase 3 reuses the
// direct adapter's HTTP layer entirely; the qui-specific path resolution
// (e.g., qui's app-key auth instead of qBittorrent's session cookie) is a
// future enhancement when operators actually deploy this combination.
func newQbitQui(cfg Config, deps Deps) (TorrentClient, error) {
	// Same shape as direct. The factory split exists per CLAUDE-phase3.md
	// so that operators can tag the client `type: qui` in YAML and so that
	// future per-mode behavior has a place to live without rewiring config.
	return newQbitDirect(cfg, deps)
}

func (q *qbitDirect) ID() string { return q.cfg.ID }

func (q *qbitDirect) Login(ctx context.Context) error {
	q.mu.Lock()
	defer q.mu.Unlock()
	if q.loggedIn && time.Since(q.lastLoginAt) < 50*time.Minute {
		return nil
	}

	if q.deps.Secrets == nil {
		return errors.New("qbit: secrets store required")
	}
	password, err := q.deps.Secrets.Get(ctx, CredentialSecretKey(q.cfg.ID))
	if err != nil {
		return fmt.Errorf("qbit: missing credentials for %s: %w", q.cfg.ID, err)
	}
	username := q.username
	if username == "" {
		username = "admin"
	}

	form := url.Values{}
	form.Set("username", username)
	form.Set("password", string(password))
	req, err := http.NewRequestWithContext(ctx, http.MethodPost,
		q.baseURL+"/api/v2/auth/login", strings.NewReader(form.Encode()))
	if err != nil {
		return err
	}
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	req.Header.Set("Referer", q.baseURL)

	resp, err := q.client.Do(req)
	if err != nil {
		return fmt.Errorf("qbit: login request: %w", err)
	}
	defer resp.Body.Close()
	body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
	// qBittorrent 4.x returns 200 + body "Ok." / "Fails."; 5.x returns
	// 204 No Content with an empty body + Set-Cookie on success. Treat
	// either 2xx as a candidate success and rely on the SID cookie + a
	// body-content check (no "Fails.") to decide. Feral-hosted seedboxes
	// in particular return 204 (their reverse-proxied qBit is current).
	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		return fmt.Errorf("qbit: login: status %d: %s", resp.StatusCode, body)
	}
	if bytes.Contains(body, []byte("Fails.")) {
		return fmt.Errorf("qbit: login rejected by qbittorrent (bad credentials)")
	}
	if !bytes.Contains(body, []byte("Ok.")) && !hasSIDCookie(resp.Cookies()) {
		return fmt.Errorf("qbit: login: 2xx but neither Ok. body nor SID cookie present: %d %q", resp.StatusCode, body)
	}
	q.loggedIn = true
	q.lastLoginAt = time.Now()
	return nil
}

// hasSIDCookie checks the Set-Cookie list returned by /api/v2/auth/login.
// qBittorrent 4.x sets a single cookie named exactly "SID". qBittorrent
// 5.x port-suffixes it as "QBT_SID_<listen_port>" so multiple instances
// behind a shared cookie domain don't collide (Feral-hosted seedboxes
// fronting qBit through a reverse proxy emit this form, e.g.
// "QBT_SID_9001"). Accept either naming.
func hasSIDCookie(cs []*http.Cookie) bool {
	for _, c := range cs {
		if c.Value == "" {
			continue
		}
		name := strings.ToUpper(c.Name)
		if name == "SID" || strings.HasPrefix(name, "QBT_SID") {
			return true
		}
	}
	return false
}

func (q *qbitDirect) Sync(ctx context.Context, rid int) (MainData, error) {
	if err := q.Login(ctx); err != nil {
		return MainData{}, err
	}
	u := q.baseURL + "/api/v2/sync/maindata?rid=" + strconv.Itoa(rid)
	var out MainData
	if err := q.getJSON(ctx, u, &out); err != nil {
		return MainData{}, err
	}
	return out, nil
}

func (q *qbitDirect) List(ctx context.Context, f ListFilter) ([]Torrent, error) {
	if err := q.Login(ctx); err != nil {
		return nil, err
	}
	v := url.Values{}
	if f.State != "" {
		v.Set("filter", f.State)
	}
	if f.Category != "" {
		v.Set("category", f.Category)
	}
	if f.Tag != "" {
		v.Set("tag", f.Tag)
	}
	if f.Sort != "" {
		v.Set("sort", f.Sort)
	}
	if f.Reverse {
		v.Set("reverse", "true")
	}
	if f.Limit > 0 {
		v.Set("limit", strconv.Itoa(f.Limit))
	}
	if f.Offset > 0 {
		v.Set("offset", strconv.Itoa(f.Offset))
	}
	u := q.baseURL + "/api/v2/torrents/info"
	if encoded := v.Encode(); encoded != "" {
		u += "?" + encoded
	}
	var out []Torrent
	if err := q.getJSON(ctx, u, &out); err != nil {
		return nil, err
	}
	return out, nil
}

func (q *qbitDirect) Get(ctx context.Context, hash string) (Torrent, error) {
	list, err := q.List(ctx, ListFilter{})
	if err != nil {
		return Torrent{}, err
	}
	for _, t := range list {
		if strings.EqualFold(t.Hash, hash) {
			return t, nil
		}
	}
	return Torrent{}, fmt.Errorf("qbit: torrent %s not found", hash)
}

func (q *qbitDirect) Trackers(ctx context.Context, hash string) ([]Tracker, error) {
	if err := q.Login(ctx); err != nil {
		return nil, err
	}
	u := q.baseURL + "/api/v2/torrents/trackers?hash=" + url.QueryEscape(hash)
	var out []Tracker
	if err := q.getJSON(ctx, u, &out); err != nil {
		return nil, err
	}
	return out, nil
}

func (q *qbitDirect) Pause(ctx context.Context, hashes []string) error {
	return q.postHashes(ctx, "/api/v2/torrents/pause", hashes, nil)
}
func (q *qbitDirect) Resume(ctx context.Context, hashes []string) error {
	return q.postHashes(ctx, "/api/v2/torrents/resume", hashes, nil)
}
func (q *qbitDirect) Recheck(ctx context.Context, hashes []string) error {
	return q.postHashes(ctx, "/api/v2/torrents/recheck", hashes, nil)
}
func (q *qbitDirect) Delete(ctx context.Context, hashes []string, deleteFiles bool) error {
	extra := url.Values{}
	extra.Set("deleteFiles", strconv.FormatBool(deleteFiles))
	return q.postHashes(ctx, "/api/v2/torrents/delete", hashes, extra)
}
func (q *qbitDirect) SetCategory(ctx context.Context, hashes []string, category string) error {
	extra := url.Values{}
	extra.Set("category", category)
	return q.postHashes(ctx, "/api/v2/torrents/setCategory", hashes, extra)
}
func (q *qbitDirect) SetTags(ctx context.Context, hashes []string, tags []string) error {
	extra := url.Values{}
	extra.Set("tags", strings.Join(tags, ","))
	return q.postHashes(ctx, "/api/v2/torrents/addTags", hashes, extra)
}

func (q *qbitDirect) Add(ctx context.Context, req AddRequest) error {
	if err := q.Login(ctx); err != nil {
		return err
	}
	if req.Magnet == "" && len(req.TorrentFile) == 0 {
		return errors.New("qbit: AddRequest needs Magnet or TorrentFile")
	}

	body := &bytes.Buffer{}
	mw := multipart.NewWriter(body)
	if req.Magnet != "" {
		_ = mw.WriteField("urls", req.Magnet)
	}
	if len(req.TorrentFile) > 0 {
		fw, err := mw.CreateFormFile("torrents", "upload.torrent")
		if err != nil {
			return err
		}
		if _, err := fw.Write(req.TorrentFile); err != nil {
			return err
		}
	}
	if req.Category != "" {
		_ = mw.WriteField("category", req.Category)
	}
	if len(req.Tags) > 0 {
		_ = mw.WriteField("tags", strings.Join(req.Tags, ","))
	}
	if req.SavePath != "" {
		_ = mw.WriteField("savepath", req.SavePath)
	}
	if req.Paused {
		_ = mw.WriteField("paused", "true")
	}
	if req.RootFolder != "" {
		_ = mw.WriteField("root_folder", req.RootFolder)
	}
	if req.Rename != "" {
		_ = mw.WriteField("rename", req.Rename)
	}
	_ = mw.Close()

	httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost,
		q.baseURL+"/api/v2/torrents/add", body)
	if err != nil {
		return err
	}
	httpReq.Header.Set("Content-Type", mw.FormDataContentType())
	resp, err := q.client.Do(httpReq)
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	// Accept any 2xx — qBittorrent 5.x returns 204 No Content where 4.x
	// returned 200 (same pattern as the login endpoint).
	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		b, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
		return fmt.Errorf("qbit: add: status %d: %s", resp.StatusCode, b)
	}
	return nil
}

func (q *qbitDirect) Health(ctx context.Context) error {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, q.baseURL+"/api/v2/app/version", nil)
	if err != nil {
		return err
	}
	resp, err := q.client.Do(req)
	if err != nil {
		return err
	}
	_ = resp.Body.Close()
	if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusForbidden {
		// 403 just means we're not logged in; the server is up.
		return nil
	}
	return fmt.Errorf("qbit: health: status %d", resp.StatusCode)
}

func (q *qbitDirect) postHashes(ctx context.Context, path string, hashes []string, extra url.Values) error {
	if err := q.Login(ctx); err != nil {
		return err
	}
	v := url.Values{}
	v.Set("hashes", strings.Join(hashes, "|"))
	for k, vs := range extra {
		for _, val := range vs {
			v.Add(k, val)
		}
	}
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, q.baseURL+path,
		strings.NewReader(v.Encode()))
	if err != nil {
		return err
	}
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	resp, err := q.client.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	// Accept any 2xx — qBittorrent 5.x returns 204 No Content on
	// pause/resume/recheck/delete/setCategory/setTags etc. where 4.x
	// returned 200 + empty body.
	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
		return fmt.Errorf("qbit: %s: status %d: %s", path, resp.StatusCode, body)
	}
	return nil
}

func (q *qbitDirect) getJSON(ctx context.Context, u string, out any) error {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
	if err != nil {
		return err
	}
	resp, err := q.client.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	if resp.StatusCode == http.StatusForbidden {
		// Session expired — reset and surface so the caller retries.
		q.mu.Lock()
		q.loggedIn = false
		q.mu.Unlock()
		return errors.New("qbit: 403 (session expired); retry")
	}
	if resp.StatusCode != http.StatusOK {
		body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
		return fmt.Errorf("qbit: GET %s: status %d: %s", u, resp.StatusCode, body)
	}
	return json.NewDecoder(io.LimitReader(resp.Body, 32<<20)).Decode(out)
}
