package trackers

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

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

// LST (lst.gg) adapter — added 2026-06-03 as the fourth tracker after
// MAM, TD, TL. LST is a UNIT3D v9.2.0-modified site with a proper JSON
// API and bearer-token auth — cleaner than the HTML-scrape pattern we
// use for TD/TL.
//
// AUTHORIZATION INVARIANT: bearer-token auth lives HERE in the adapter,
// NOT in *scrape.Hygiene. Hygiene is intentionally cookie-only — it
// attaches the canonical `tracker:<id>:cookie` to the Cookie header and
// never touches Authorization (verified hygiene.go:188-203, 128). If a
// future tracker also needs bearer auth, repeat this pattern in its own
// adapter file. Do NOT add an auth-mode branch to Hygiene; that
// contaminates the primitive for all trackers and risks regressing
// MAM/TD/TL.
//
// Empirical /api/user response shape (captured 2026-06-03):
//   {"username":"lstgeology","group":"Crab","uploaded":"50 GiB",
//    "downloaded":"1 GiB","ratio":"50","buffer":"124 GiB",
//    "seeding":0,"leeching":0,"seedbonus":"0.00","hit_and_runs":0}
//
// Field mapping → scrape.RatioSnapshot:
//   uploaded "50 GiB"     → DisplayedUploadedBytes + RealUploadedBytes (parseLSTSize)
//   downloaded "1 GiB"    → DisplayedDownloadedBytes + RealDownloadedBytes
//   ratio "50"            → DisplayedRatio; RealRatio recomputed from bytes
//                           when downloaded > 0 (mam.go:163-165 pattern).
//                           Inf/NaN → nil (SQLite REAL handling of Inf is
//                           driver-dependent).
//   seedbonus "0.00"      → BonusPoints (truncated to int64 via intPtr)
//   hit_and_runs 0        → UnsatCount  (RatioSnapshot has no HnR field;
//                           UnsatCount = "active unsatisfied obligations"
//                           is the semantic match)
//   group "Crab"          → ClassOrRank
//   buffer / seeding /
//   leeching / username   → RawJSON only (no dedicated snapshot columns).
//                           A future schema migration may add seeding/
//                           leeching/buffer columns; sanity-check the
//                           username field on each scrape to catch
//                           partial-body / wrong-account-after-rotation
//                           failures.

func init() {
	Register("lst", newLST)
}

// LSTConfig is the adapter-specific config parsed from the per-tracker
// `config:` map in trackers.yaml.
type LSTConfig struct {
	// StatsPath overrides the default `/api/user`. Empty = default.
	StatsPath string `json:"stats_path,omitempty"`
}

type lstAdapter struct {
	cfg      Config
	lst      LSTConfig
	statsURL string
	h        *scrape.Hygiene
}

// APITokenSecretKey returns the canonical vault key for an LST-style
// API token. Mirrors scrape.CookieSecretKey for cookie-based trackers.
// Operator places via:
//
//	echo -n '<100-char-token>' | cc-set-secret \
//	    -key tracker:lst:api_token \
//	    -db /var/lib/command-center/command-center.db \
//	    -age /etc/command-center/age.key
func APITokenSecretKey(trackerID string) string {
	return "tracker:" + trackerID + ":api_token"
}

func newLST(cfg Config, h *scrape.Hygiene) (scrape.Adapter, error) {
	if cfg.BaseURL == "" {
		return nil, errors.New("lst: base_url required")
	}
	if cfg.Secrets == nil {
		return nil, errors.New("lst: Config.Secrets required (bearer auth)")
	}
	l := LSTConfig{StatsPath: "/api/user"}
	if len(cfg.TypeConfigJSON) > 0 {
		if err := json.Unmarshal(cfg.TypeConfigJSON, &l); err != nil {
			return nil, fmt.Errorf("lst: parse type-config: %w", err)
		}
		if l.StatsPath == "" {
			l.StatsPath = "/api/user"
		}
	}
	base := strings.TrimRight(cfg.BaseURL, "/")
	return &lstAdapter{
		cfg:      cfg,
		lst:      l,
		statsURL: base + l.StatsPath,
		h:        h,
	}, nil
}

func (l *lstAdapter) Name() string { return l.cfg.ID }

// FetchRatio calls /api/user with `Authorization: Bearer <token>` and
// parses the JSON response. The token is fetched per-request from the
// secrets vault so a `cc-set-secret` rotation takes effect on the next
// scrape without a service restart (one decrypt every ~10 min — cheap).
func (l *lstAdapter) FetchRatio(ctx context.Context) (scrape.RatioSnapshot, error) {
	tokenBytes, err := l.cfg.Secrets.Get(ctx, APITokenSecretKey(l.cfg.ID))
	if err != nil {
		return scrape.RatioSnapshot{},
			fmt.Errorf("lst: API token not configured — run: cc-set-secret -key %s : %w",
				APITokenSecretKey(l.cfg.ID), err)
	}
	tok := strings.TrimSpace(string(tokenBytes))
	if tok == "" {
		return scrape.RatioSnapshot{},
			fmt.Errorf("lst: API token is empty — run: cc-set-secret -key %s",
				APITokenSecretKey(l.cfg.ID))
	}

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, l.statsURL, nil)
	if err != nil {
		return scrape.RatioSnapshot{}, err
	}
	req.Header.Set("Accept", "application/json")
	// Authorization survives Hygiene.Do — hygiene only mutates Cookie +
	// User-Agent (verified hygiene.go:188-203, 128).
	req.Header.Set("Authorization", "Bearer "+tok)

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

	if kind := scrape.Classify(resp, nil, body); kind != scrape.FailureNone {
		// 401/403 → FailureAuth (the common revoked/wrong-token case).
		return scrape.RatioSnapshot{RawJSON: string(body)},
			fmt.Errorf("lst: classified failure: %s (status %d) — if auth, run: cc-set-secret -key %s",
				kind, resp.StatusCode, APITokenSecretKey(l.cfg.ID))
	}

	return parseLSTResponse(body)
}

// Health does a bare HEAD on the marketing root to confirm the site is
// reachable. Verified `HEAD https://lst.gg/` returns 200 (probed
// 2026-06-03). This deliberately does NOT prove the API token works —
// that signal comes from the next scheduled FetchRatio. Doing it this
// way avoids a 405 trap if HEAD on /api/user ever gets disallowed by a
// Laravel middleware change.
func (l *lstAdapter) Health(ctx context.Context) error {
	req, err := http.NewRequestWithContext(ctx, http.MethodHead, l.cfg.BaseURL, nil)
	if err != nil {
		return err
	}
	resp, err := l.h.Do(ctx, req, l.cfg.ID)
	if err != nil {
		return err
	}
	_ = resp.Body.Close()
	if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
		return fmt.Errorf("lst: auth (status %d) — refresh API token via cc-set-secret -key %s",
			resp.StatusCode, APITokenSecretKey(l.cfg.ID))
	}
	if resp.StatusCode >= 500 {
		return fmt.Errorf("lst: upstream status %d", resp.StatusCode)
	}
	return nil
}

// lstRawResponse models /api/user. Every numeric field uses json.Number
// so the parser distinguishes "field absent" (== "") from "field is
// zero" — same defensive pattern as mam.go. UNIT3D forks vary between
// numeric and string formatting across versions; json.Number is the
// only safe shape.
type lstRawResponse struct {
	Username   string      `json:"username"`
	Group      string      `json:"group"`
	Uploaded   string      `json:"uploaded"`     // formatted: "50 GiB"
	Downloaded string      `json:"downloaded"`   // formatted: "1 GiB"
	Ratio      json.Number `json:"ratio"`        // "50" / "1.92" / number / "∞"
	Buffer     string      `json:"buffer"`       // dropped to RawJSON
	Seeding    json.Number `json:"seeding"`      // dropped to RawJSON
	Leeching   json.Number `json:"leeching"`     // dropped to RawJSON
	Seedbonus  json.Number `json:"seedbonus"`    // "0.00" / "1.5K" — truncate via intPtr
	HitAndRuns json.Number `json:"hit_and_runs"` // some forks emit 0.5; json.Number handles

	// Laravel error envelope. Classify catches HTTP 401 directly, but
	// some Laravel middleware emits {"message":"..."} with HTTP 200
	// (throttling under specific reverse-proxy configs). Defensively
	// check the body even on 2xx — mirrors mam.go:141-144.
	Message string `json:"message,omitempty"`
}

func parseLSTResponse(body []byte) (scrape.RatioSnapshot, error) {
	dec := json.NewDecoder(bytesReader(body))
	dec.UseNumber()
	var raw lstRawResponse
	if err := dec.Decode(&raw); err != nil {
		return scrape.RatioSnapshot{RawJSON: string(body)},
			fmt.Errorf("lst: decode json: %w", err)
	}

	// 200-OK auth/throttle envelope (Laravel quirk). Token-fragments are
	// NEVER included in error messages.
	if raw.Message != "" {
		return scrape.RatioSnapshot{RawJSON: string(body)},
			fmt.Errorf("lst: api error: %s", raw.Message)
	}

	// Sanity check: the canonical /api/user response always has a
	// username. If it's missing, the response shape changed or we got a
	// partial body — fail loudly rather than persist a zero-everything
	// snapshot that pollutes the timeseries.
	if raw.Username == "" {
		return scrape.RatioSnapshot{RawJSON: string(body)},
			errors.New("lst: response missing username field — possible auth/middleware envelope or API shape change")
	}

	snap := scrape.RatioSnapshot{RawJSON: string(body), ClassOrRank: raw.Group}

	if up := parseLSTSize(raw.Uploaded); up != nil {
		snap.DisplayedUploadedBytes = up
		snap.RealUploadedBytes = up
	}
	if dn := parseLSTSize(raw.Downloaded); dn != nil {
		snap.DisplayedDownloadedBytes = dn
		snap.RealDownloadedBytes = dn
	}

	if r := parseLSTRatio(raw.Ratio); r != nil {
		snap.DisplayedRatio = r
	}

	// Prefer computing RealRatio from real bytes when both sides are
	// finite and download > 0 — same pattern as mam.go:163-165. The
	// displayed ratio LST emits is integer-truncated for some accounts
	// ("50" rather than "50.00"); bytes give precision. If download is
	// 0 (true infinity), leave RealRatio nil — never let +Inf reach the
	// snapshot (SQLite REAL handling of Inf varies by driver).
	if snap.RealUploadedBytes != nil && snap.RealDownloadedBytes != nil && *snap.RealDownloadedBytes > 0 {
		ratio := float64(*snap.RealUploadedBytes) / float64(*snap.RealDownloadedBytes)
		if !math.IsInf(ratio, 0) && !math.IsNaN(ratio) {
			snap.RealRatio = &ratio
		}
	} else if snap.DisplayedRatio != nil {
		snap.RealRatio = snap.DisplayedRatio
	}

	snap.BonusPoints = intPtr(raw.Seedbonus)
	snap.UnsatCount = intPtr(raw.HitAndRuns)
	return snap, nil
}

// parseLSTSize splits "50 GiB" into ("50", "GiB") and calls the shared
// binary-multiplier parseByteSize (td.go). UNIT3D emits IEC binary
// units (GiB/TiB/MiB) consistently with a single ASCII space — verified
// empirically against lst.gg on 2026-06-03. parseByteSize accepts both
// SI and IEC suffixes and treats them identically as base-1024, which
// is correct for UNIT3D.
//
// Returns nil for empty, unparseable numbers, unrecognized units, or
// any shape that isn't exactly "<num> <unit>".
func parseLSTSize(s string) *int64 {
	s = strings.TrimSpace(s)
	if s == "" {
		return nil
	}
	parts := strings.Fields(s)
	if len(parts) != 2 {
		return nil
	}
	if bytes, ok := parseByteSize(parts[0], parts[1]); ok {
		return &bytes
	}
	return nil
}

// parseLSTRatio handles UNIT3D's ratio field, which can arrive as:
//
//	"50" / "1.92" / 50 — numeric forms (json.Number → Float64)
//	"∞" / "Inf" / "INF"  — infinity sentinels (downloaded=0)
//	"" / "--" / "-" / "N/A" — placeholder / new account
//
// Returns nil for any non-finite or placeholder case. The caller then
// computes ratio from real bytes via the mam.go fallback (which leaves
// nil when downloaded is 0 — correct "no ratio yet" semantics).
func parseLSTRatio(n json.Number) *float64 {
	s := strings.TrimSpace(string(n))
	switch strings.ToLower(s) {
	case "", "--", "-", "n/a", "∞", "inf", "infinity":
		return nil
	}
	r := floatPtr(n)
	if r == nil {
		return nil
	}
	if math.IsInf(*r, 0) || math.IsNaN(*r) {
		return nil
	}
	return r
}
