// Package scrape provides the scrape hygiene primitive, the failure
// classifier, and the periodic scheduler used by every tracker integration.
//
// Hygiene is non-negotiable per CLAUDE-phase1.md: every outbound HTTP request
// to a tracker site MUST pass through Hygiene.Do — never net/http directly.
// The primitive owns rate limiting, cookie loading, Retry-After handling,
// exponential backoff with jitter, and per-tracker failure tracking.
package scrape

import (
	"context"
	"fmt"
	"io"
	"math/rand/v2"
	"net/http"
	"strconv"
	"strings"
	"sync"
	"time"

	"github.com/rs/zerolog"

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

// CookieSecretKey returns the canonical secrets-store key under which a
// tracker's session cookie is stored. Adapters use this when prompting the
// operator for a cookie; the hygiene layer uses it on every outbound request.
func CookieSecretKey(trackerID string) string {
	return "tracker:" + trackerID + ":cookie"
}

// Options configures a Hygiene primitive at construction time.
type Options struct {
	// HTTPClient is the underlying client. If nil, a reasonable default is
	// constructed with a 30s overall timeout and no redirects (most tracker
	// stats endpoints don't redirect; a redirect usually means auth failure).
	HTTPClient *http.Client

	// UserAgent is the User-Agent header on every request. Phase 1 ships a
	// single value; PROJECT.md §8.9 calls for an operator-approved rotation
	// pool which later phases may add.
	UserAgent string

	// MaxBackoff caps the exponential backoff applied after consecutive
	// failures. Default 30 minutes.
	MaxBackoff time.Duration

	// MinRequestSpacing is the smallest gap between any two requests to the
	// same tracker, applied even when the per-tracker scrape interval would
	// allow faster. Default 2 seconds. This guards against accidental tight
	// loops (e.g. a buggy adapter that retries internally).
	MinRequestSpacing time.Duration
}

func (o *Options) applyDefaults() {
	if o.HTTPClient == nil {
		o.HTTPClient = &http.Client{
			Timeout: 30 * time.Second,
			CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
				return http.ErrUseLastResponse
			},
		}
	}
	if o.UserAgent == "" {
		o.UserAgent = "command-center/0.1 (+self-hosted)"
	}
	if o.MaxBackoff <= 0 {
		o.MaxBackoff = 30 * time.Minute
	}
	if o.MinRequestSpacing <= 0 {
		o.MinRequestSpacing = 2 * time.Second
	}
}

// Hygiene wraps net/http with the hygiene rules every tracker adapter must
// respect.
type Hygiene struct {
	opts    Options
	secrets *secrets.Store
	logger  zerolog.Logger

	mu          sync.Mutex
	lastReq     map[string]time.Time // tracker_id -> last request time
	failCount   map[string]int       // tracker_id -> consecutive failures
	retryAfter  map[string]time.Time // tracker_id -> earliest next-attempt time
	rng         *rand.Rand
}

// New constructs a Hygiene primitive. secretsStore may be nil during tests
// that don't exercise cookie loading.
func New(secretsStore *secrets.Store, opts Options, logger zerolog.Logger) *Hygiene {
	opts.applyDefaults()
	return &Hygiene{
		opts:       opts,
		secrets:    secretsStore,
		logger:     logger.With().Str("component", "scrape").Logger(),
		lastReq:    make(map[string]time.Time),
		failCount:  make(map[string]int),
		retryAfter: make(map[string]time.Time),
		rng:        rand.New(rand.NewPCG(uint64(time.Now().UnixNano()), 0)),
	}
}

// Do executes req with hygiene applied:
//   - Waits until any in-flight per-tracker spacing window has elapsed.
//   - Waits until any Retry-After / backoff hint from a prior response is up.
//   - Loads the tracker's session cookie from secrets and attaches it.
//   - Sets a stable User-Agent.
//   - On success (2xx), resets the failure counter.
//   - On failure, increments the failure counter and writes a backoff
//     hint based on consecutive count.
//   - Honors Retry-After response headers (seconds or HTTP-date).
//
// The caller is responsible for reading the response body and Close()ing it.
func (h *Hygiene) Do(ctx context.Context, req *http.Request, trackerID string) (*http.Response, error) {
	if trackerID == "" {
		return nil, fmt.Errorf("scrape: empty trackerID")
	}

	if err := h.waitForReady(ctx, trackerID); err != nil {
		return nil, err
	}

	if err := h.attachCookie(ctx, req, trackerID); err != nil {
		return nil, fmt.Errorf("scrape: load cookie for %s: %w", trackerID, err)
	}
	req.Header.Set("User-Agent", h.opts.UserAgent)

	h.markRequest(trackerID)
	resp, err := h.opts.HTTPClient.Do(req)
	if err != nil {
		h.recordFailure(trackerID, "")
		return nil, err
	}

	// Honor Retry-After regardless of status (some sites send it on 200 as a hint).
	if ra := resp.Header.Get("Retry-After"); ra != "" {
		if d, ok := parseRetryAfter(ra); ok {
			h.setRetryAfter(trackerID, d)
		}
	}

	if resp.StatusCode >= 200 && resp.StatusCode < 300 {
		h.resetFailure(trackerID)
	} else {
		h.recordFailure(trackerID, ra(resp))
	}
	return resp, nil
}

// ReadBody reads the response body with a sane size cap (8 MiB) and closes
// the body. Adapters should call this instead of io.ReadAll(resp.Body) so
// that a tracker returning an unbounded body cannot pin memory.
func (h *Hygiene) ReadBody(resp *http.Response) ([]byte, error) {
	defer resp.Body.Close()
	const maxBody = 8 << 20
	return io.ReadAll(io.LimitReader(resp.Body, maxBody))
}

func (h *Hygiene) waitForReady(ctx context.Context, trackerID string) error {
	h.mu.Lock()
	now := time.Now()
	earliest := now
	if last, ok := h.lastReq[trackerID]; ok {
		next := last.Add(h.opts.MinRequestSpacing)
		if next.After(earliest) {
			earliest = next
		}
	}
	if hint, ok := h.retryAfter[trackerID]; ok && hint.After(earliest) {
		earliest = hint
	}
	wait := earliest.Sub(now)
	h.mu.Unlock()

	if wait <= 0 {
		return nil
	}
	select {
	case <-time.After(wait):
		return nil
	case <-ctx.Done():
		return ctx.Err()
	}
}

func (h *Hygiene) attachCookie(ctx context.Context, req *http.Request, trackerID string) error {
	if h.secrets == nil {
		return nil
	}
	cookie, err := h.secrets.Get(ctx, CookieSecretKey(trackerID))
	if err != nil {
		// Missing cookie is not always fatal — some trackers are public.
		// Adapter authors who require auth check Get themselves before
		// calling Do. We do not surface an error here.
		return nil
	}
	if c := strings.TrimSpace(string(cookie)); c != "" {
		req.Header.Set("Cookie", c)
	}
	return nil
}

func (h *Hygiene) markRequest(trackerID string) {
	h.mu.Lock()
	defer h.mu.Unlock()
	h.lastReq[trackerID] = time.Now()
}

func (h *Hygiene) recordFailure(trackerID string, retryAfterHeader string) {
	h.mu.Lock()
	defer h.mu.Unlock()
	h.failCount[trackerID]++
	n := h.failCount[trackerID]
	// Exponential backoff with jitter, capped at MaxBackoff.
	// 1st fail: ~30s. 5th fail: ~8min. Capped at MaxBackoff.
	base := time.Duration(1<<min(n, 12)) * 15 * time.Second
	if base > h.opts.MaxBackoff {
		base = h.opts.MaxBackoff
	}
	jitter := time.Duration(h.rng.Int64N(int64(base / 4)))
	delay := base + jitter
	// A Retry-After header always wins if longer than the computed backoff.
	if retryAfterHeader != "" {
		if d, ok := parseRetryAfter(retryAfterHeader); ok && d > delay {
			delay = d
		}
	}
	h.retryAfter[trackerID] = time.Now().Add(delay)
	h.logger.Debug().
		Str("tracker", trackerID).
		Int("consecutive_failures", n).
		Dur("backoff", delay).
		Msg("scrape failure backoff")
}

func (h *Hygiene) resetFailure(trackerID string) {
	h.mu.Lock()
	defer h.mu.Unlock()
	delete(h.failCount, trackerID)
	delete(h.retryAfter, trackerID)
}

func (h *Hygiene) setRetryAfter(trackerID string, d time.Duration) {
	h.mu.Lock()
	defer h.mu.Unlock()
	hint := time.Now().Add(d)
	if existing, ok := h.retryAfter[trackerID]; !ok || hint.After(existing) {
		h.retryAfter[trackerID] = hint
	}
}

func ra(resp *http.Response) string {
	if resp == nil {
		return ""
	}
	return resp.Header.Get("Retry-After")
}

// parseRetryAfter handles both forms documented in RFC 7231: an integer
// number of seconds, or an HTTP-date. Returns the duration relative to now.
func parseRetryAfter(v string) (time.Duration, bool) {
	v = strings.TrimSpace(v)
	if v == "" {
		return 0, false
	}
	if n, err := strconv.Atoi(v); err == nil && n >= 0 {
		return time.Duration(n) * time.Second, true
	}
	if t, err := http.ParseTime(v); err == nil {
		d := time.Until(t)
		if d < 0 {
			d = 0
		}
		return d, true
	}
	return 0, false
}
