package trackers

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"net/http"
	"regexp"
	"strconv"
	"strings"

	"github.com/operator/command-center/internal/scrape"
)

// TorrentLeech adapter — added 2026-05-29 as the third tracker after MAM and TD.
//
// Like TorrentDay, TorrentLeech has no JSON stats endpoint; the authenticated
// user's ratio + upload + download + bonus are inline in the page header on
// every logged-in page. This adapter follows the same scrape-then-regex pattern
// as td.go — TL's HTML structure has shifted across UI revisions, so the
// parser tries label-anchored extraction (`Up:`, `Down:`, `Ratio:`, `Bonus:`)
// which has been stable across the redesigns.
//
// Auth model: the operator pastes the full Cookie header value from a logged-in
// browser into the secrets store under `tracker:tl:cookie` via the
// CC's Settings → Secrets page or the operator helper. scrape.Hygiene
// attaches it on every request.
//
// Default stats endpoint: /torrents/browse — the post-login landing for active
// users, which carries the full nav-header stats block. Override via the
// adapter's `stats_path` type-config if a future redesign shifts it.

func init() {
	Register("tl", newTL)
}

// TLConfig is the adapter-specific config parsed from the per-tracker
// `config:` map in trackers.yaml.
type TLConfig struct {
	StatsPath string `json:"stats_path,omitempty"`
}

type tlAdapter struct {
	cfg      Config
	tl       TLConfig
	statsURL string
	h        *scrape.Hygiene
}

func newTL(cfg Config, h *scrape.Hygiene) (scrape.Adapter, error) {
	if cfg.BaseURL == "" {
		return nil, errors.New("tl: base_url required")
	}
	t := TLConfig{StatsPath: "/torrents/browse"}
	if len(cfg.TypeConfigJSON) > 0 {
		if err := json.Unmarshal(cfg.TypeConfigJSON, &t); err != nil {
			return nil, fmt.Errorf("tl: parse type-config: %w", err)
		}
		if t.StatsPath == "" {
			t.StatsPath = "/torrents/browse"
		}
	}
	base := strings.TrimRight(cfg.BaseURL, "/")
	return &tlAdapter{
		cfg:      cfg,
		tl:       t,
		statsURL: base + t.StatsPath,
		h:        h,
	}, nil
}

func (t *tlAdapter) Name() string { return t.cfg.ID }

// FetchRatio GETs the configured stats page and scrapes the header. The
// session cookie comes from the hygiene primitive (loaded from the secrets
// store under `tracker:<id>:cookie`).
func (t *tlAdapter) FetchRatio(ctx context.Context) (scrape.RatioSnapshot, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, t.statsURL, nil)
	if err != nil {
		return scrape.RatioSnapshot{}, err
	}
	req.Header.Set("Accept", "text/html,application/xhtml+xml")

	resp, err := t.h.Do(ctx, req, t.cfg.ID)
	if err != nil {
		return scrape.RatioSnapshot{}, fmt.Errorf("tl: request: %w", err)
	}
	body, err := t.h.ReadBody(resp)
	if err != nil {
		return scrape.RatioSnapshot{}, fmt.Errorf("tl: read body: %w", err)
	}

	if kind := scrape.Classify(resp, nil, body); kind != scrape.FailureNone {
		return scrape.RatioSnapshot{RawJSON: string(body)},
			fmt.Errorf("tl: classified failure: %s (status %d)", kind, resp.StatusCode)
	}

	if looksLikeTLLoginPage(body) {
		return scrape.RatioSnapshot{RawJSON: string(body)},
			errors.New("tl: auth — landed on login page; refresh tl session cookie")
	}

	return parseTLResponse(body), nil
}

// Health issues a HEAD to the base URL. Same auth/server semantics as TD.
func (t *tlAdapter) Health(ctx context.Context) error {
	req, err := http.NewRequestWithContext(ctx, http.MethodHead, t.cfg.BaseURL, nil)
	if err != nil {
		return err
	}
	resp, err := t.h.Do(ctx, req, t.cfg.ID)
	if err != nil {
		return err
	}
	_ = resp.Body.Close()
	if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
		return fmt.Errorf("tl: auth (status %d) — refresh tl session cookie", resp.StatusCode)
	}
	if resp.StatusCode >= 500 {
		return fmt.Errorf("tl: upstream status %d", resp.StatusCode)
	}
	return nil
}

// looksLikeTLLoginPage detects the unauthenticated-redirect case. Modern TL
// (post-2022 redesign) routes login to /user/account/login; older revisions
// used /takelogin.php. Both forms expose a `name="username"` input.
func looksLikeTLLoginPage(body []byte) bool {
	s := string(body)
	return strings.Contains(s, `/user/account/login`) ||
		strings.Contains(s, `takelogin.php`) ||
		(strings.Contains(s, `name="username"`) &&
			strings.Contains(s, `name="password"`) &&
			!strings.Contains(s, `Logout`) &&
			!strings.Contains(s, `logout`))
}

// TL's stats appear in the top nav of every authenticated page in <div
// title="...">..</div> blocks. Anchor regexes on those title attributes —
// generic label patterns (the first cut of this file) false-matched against
// "Uploaded >= 100GB" / "Given 100 TL Points" achievement-notification text
// and reported the wrong numbers. The `(?s)` flag lets `.` span newlines
// (TL's div has the icon on a separate line from the value/<span>).
var (
	reTLRatio = regexp.MustCompile(`(?s)title="Ratio"[^>]*>.*?</i>\s*([0-9]+(?:\.[0-9]+)?|&infin;|∞|—|---)`)
	reTLUp    = regexp.MustCompile(`(?s)title="Uploaded[^"]*"[^>]*>.*?<span[^>]*>([0-9]+(?:\.[0-9]+)?)\s*(B|KB|KiB|MB|MiB|GB|GiB|TB|TiB|PB|PiB)\s*</span>`)
	reTLDown  = regexp.MustCompile(`(?s)title="Downloaded[^"]*"[^>]*>.*?<span[^>]*>([0-9]+(?:\.[0-9]+)?)\s*(B|KB|KiB|MB|MiB|GB|GiB|TB|TiB|PB|PiB)\s*</span>`)
	// TL Points lives in a dedicated <span class="total-TL-points">…</span>.
	// Round/truncate to int — TL shows decimals but BonusPoints is int64.
	reTLBonus = regexp.MustCompile(`class="total-TL-points"[^>]*>([0-9]+(?:\.[0-9]+)?)`)
	// User class isn't in the browse-page header; if a future page exposes it
	// in /profile, fall back to the older generic pattern.
	reTLClass = regexp.MustCompile(`(?i)(?:user\s*)?class[^A-Za-z]{0,10}<[^>]+>([^<]+)</`)
)

func parseTLResponse(body []byte) scrape.RatioSnapshot {
	snap := scrape.RatioSnapshot{RawJSON: string(body)}
	s := string(body)

	if m := reTLRatio.FindStringSubmatch(s); len(m) == 2 {
		v := strings.ToLower(m[1])
		switch v {
		case "inf", "infinite", "infinity", "∞", "&infin;":
			big := 1e9
			snap.DisplayedRatio = &big
		case "—", "---":
			// no ratio yet — leave unset.
		default:
			if r, err := strconv.ParseFloat(v, 64); err == nil {
				snap.DisplayedRatio = &r
			}
		}
	}

	if m := reTLUp.FindStringSubmatch(s); len(m) == 3 {
		if bytes, ok := parseByteSize(m[1], m[2]); ok {
			snap.DisplayedUploadedBytes = &bytes
			snap.RealUploadedBytes = &bytes
		}
	}
	if m := reTLDown.FindStringSubmatch(s); len(m) == 3 {
		if bytes, ok := parseByteSize(m[1], m[2]); ok {
			snap.DisplayedDownloadedBytes = &bytes
			snap.RealDownloadedBytes = &bytes
		}
	}
	if snap.DisplayedRatio != nil {
		snap.RealRatio = snap.DisplayedRatio
	} else if snap.RealUploadedBytes != nil && snap.RealDownloadedBytes != nil && *snap.RealDownloadedBytes > 0 {
		r := float64(*snap.RealUploadedBytes) / float64(*snap.RealDownloadedBytes)
		snap.RealRatio = &r
		snap.DisplayedRatio = &r
	}

	if m := reTLBonus.FindStringSubmatch(s); len(m) == 2 {
		cleaned := strings.ReplaceAll(m[1], ",", "")
		if dot := strings.IndexByte(cleaned, '.'); dot >= 0 {
			cleaned = cleaned[:dot]
		}
		if b, err := strconv.ParseInt(cleaned, 10, 64); err == nil {
			snap.BonusPoints = &b
		}
	}

	if m := reTLClass.FindStringSubmatch(s); len(m) == 2 {
		snap.ClassOrRank = strings.TrimSpace(m[1])
	}

	return snap
}
