package trackers

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

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

// TorrentDay adapter — added 2026-05-19 as the second tracker after MAM.
//
// TorrentDay has no JSON stats endpoint analogous to MAM's /jsonLoad.php; the
// authenticated user's ratio lives inline in the HTML header of every page
// after login (`/torrents.php` or `/`). This adapter scrapes that header
// using a battery of regexes — TD's HTML structure has shifted across the
// years and forks, so the parser tries several known patterns and falls
// back to label-anchored byte/ratio extraction. Whatever it captures (or
// fails to capture) lands in `RawJSON` so the operator can iterate.
//
// Auth model: the operator pastes their browser's full Cookie header value
// (the whole `name1=v1; name2=v2; ...` string) into the secrets store via
// `cc-set-tracker-cookie -id td -cookie 'uid=…; pass=…'`. scrape.Hygiene
// attaches it on every request.
//
// Endpoint reference: https://www.torrentday.com/torrents.php (default
// post-login landing).

func init() {
	Register("td", newTD)
}

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

type tdAdapter struct {
	cfg      Config
	td       TDConfig
	statsURL string
	h        *scrape.Hygiene
}

func newTD(cfg Config, h *scrape.Hygiene) (scrape.Adapter, error) {
	if cfg.BaseURL == "" {
		return nil, errors.New("td: base_url required")
	}
	// /torrents.php was the legacy TBSource path; TD's current UI (as of
	// 2026-05-19 the site is on lighttpd + modern.css, banner cookies
	// include td_theme / td_textscale) serves the post-login landing at
	// /t and 404s /torrents.php even authenticated. Default to /t; the
	// operator can override via type-config if TD shifts again.
	t := TDConfig{StatsPath: "/t"}
	if len(cfg.TypeConfigJSON) > 0 {
		if err := json.Unmarshal(cfg.TypeConfigJSON, &t); err != nil {
			return nil, fmt.Errorf("td: parse type-config: %w", err)
		}
		if t.StatsPath == "" {
			t.StatsPath = "/t"
		}
	}
	base := strings.TrimRight(cfg.BaseURL, "/")
	return &tdAdapter{
		cfg:      cfg,
		td:       t,
		statsURL: base + t.StatsPath,
		h:        h,
	}, nil
}

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

// FetchRatio GETs the post-login landing page and scrapes the header stats
// block. The session cookie comes from the hygiene primitive (loaded from
// the secrets store under `tracker:<id>:cookie`).
func (t *tdAdapter) FetchRatio(ctx context.Context) (scrape.RatioSnapshot, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, t.statsURL, nil)
	if err != nil {
		return scrape.RatioSnapshot{}, err
	}
	// TD's app is HTML-only; advertise a realistic Accept so a future
	// CDN/WAF doesn't classify the request as a bot.
	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("td: request: %w", err)
	}
	body, err := t.h.ReadBody(resp)
	if err != nil {
		return scrape.RatioSnapshot{}, fmt.Errorf("td: read body: %w", err)
	}

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

	// Cheap sanity check: if the body looks like a login page (TD redirects
	// or shows the login form when unauthenticated), surface that as a
	// distinguishable error so the operator knows to refresh cookies.
	if looksLikeTDLoginPage(body) {
		return scrape.RatioSnapshot{RawJSON: string(body)},
			errors.New("td: auth — landed on login page; refresh td session cookies")
	}

	return parseTDResponse(body), nil
}

// Health issues a HEAD to the base URL. 401/403 → auth failure; other
// non-2xx → site down or proxy issue.
func (t *tdAdapter) 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("td: auth (status %d) — refresh td session cookies", resp.StatusCode)
	}
	if resp.StatusCode >= 500 {
		return fmt.Errorf("td: upstream status %d", resp.StatusCode)
	}
	return nil
}

// looksLikeTDLoginPage detects the unauthenticated-redirect case. TD's
// login form has a `name="login"` input and an action of `/takelogin.php`
// across known versions.
func looksLikeTDLoginPage(body []byte) bool {
	s := string(body)
	return strings.Contains(s, `takelogin.php`) ||
		strings.Contains(s, `name="username"`) && strings.Contains(s, `name="password"`) &&
			!strings.Contains(s, "userdetails.php")
}

// TD's header stats panel varies across UI revisions / forks. The
// patterns below cover the variants observed in the wild; the first one
// to match wins. RawJSON always contains the full body so unmatched
// payloads can be inspected and the pattern set extended.

var (
	// "Ratio: 1.234" / "Ratio: <a>1.234</a>" / "Ratio:</span> <span>1.234</span>"
	reRatio = regexp.MustCompile(`(?i)ratio[^0-9]{0,40}([0-9]+\.[0-9]+|inf(?:inite|inity)?|∞|—)`)

	// "Up: 1.23 TB" / "UL: 1.23 TB" / "Uploaded: 1.23 TB"
	reUp = regexp.MustCompile(`(?i)(?:Up(?:loaded)?|UL)[^0-9]{0,40}([0-9]+\.?[0-9]*)\s*(B|KB|KiB|MB|MiB|GB|GiB|TB|TiB|PB|PiB)`)

	// "Down: 567.89 GB"
	reDown = regexp.MustCompile(`(?i)(?:Down(?:loaded)?|DL)[^0-9]{0,40}([0-9]+\.?[0-9]*)\s*(B|KB|KiB|MB|MiB|GB|GiB|TB|TiB|PB|PiB)`)

	// "Bonus: 12,345" / "Bonus Points: 12,345.67"
	reBonus = regexp.MustCompile(`(?i)bonus(?:\s*points?)?[^0-9]{0,40}([0-9][0-9,\.]*)`)

	// Class / rank: "User Class: <a>Power User</a>" / "[Power User]"
	reClass = regexp.MustCompile(`(?i)(?:user\s*)?class[^A-Za-z]{0,10}<[^>]+>([^<]+)</`)
)

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

	if m := reRatio.FindStringSubmatch(s); len(m) == 2 {
		v := strings.ToLower(m[1])
		switch v {
		case "inf", "infinite", "infinity", "∞":
			// downloaded was zero — represent as a very large but bounded
			// float so the dashboard sorts correctly without overflowing.
			big := 1e9
			snap.DisplayedRatio = &big
		case "—":
			// no ratio yet (e.g., brand-new account) — leave unset.
		default:
			if r, err := strconv.ParseFloat(v, 64); err == nil {
				snap.DisplayedRatio = &r
			}
		}
	}

	if m := reUp.FindStringSubmatch(s); len(m) == 3 {
		if bytes, ok := parseByteSize(m[1], m[2]); ok {
			snap.DisplayedUploadedBytes = &bytes
			snap.RealUploadedBytes = &bytes
		}
	}
	if m := reDown.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 := reBonus.FindStringSubmatch(s); len(m) == 2 {
		cleaned := strings.ReplaceAll(m[1], ",", "")
		// Bonus on TD is integer points; truncate any decimal.
		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 := reClass.FindStringSubmatch(s); len(m) == 2 {
		snap.ClassOrRank = strings.TrimSpace(m[1])
	}

	return snap
}

// parseByteSize converts a "1.23" + "GB" pair into bytes. Accepts both
// SI (KB=10^3) and IEC (KiB=2^10) units; TD historically reported in
// binary prefixes despite using the SI suffix, so we use 1024 for both
// in the interest of matching what the operator sees in the UI.
func parseByteSize(num, unit string) (int64, bool) {
	f, err := strconv.ParseFloat(num, 64)
	if err != nil {
		return 0, false
	}
	var mul float64
	switch strings.ToUpper(unit) {
	case "B":
		mul = 1
	case "KB", "KIB":
		mul = 1024
	case "MB", "MIB":
		mul = 1024 * 1024
	case "GB", "GIB":
		mul = 1024 * 1024 * 1024
	case "TB", "TIB":
		mul = 1024 * 1024 * 1024 * 1024
	case "PB", "PIB":
		mul = 1024 * 1024 * 1024 * 1024 * 1024
	default:
		return 0, false
	}
	return int64(f * mul), true
}
