package trackers

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

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

// MyAnonamouse adapter — chosen as the Phase 1 first tracker per DECISIONS.md.
// MAM exposes a JSON stats endpoint that requires the mam_id session cookie;
// the cookie is stored in `secrets` under the canonical key
// `tracker:<id>:cookie` and attached automatically by *scrape.Hygiene.
//
// Endpoint reference: https://www.myanonamouse.net/jsonLoad.php
//   (Returns the authenticated user's stats as JSON.)
//
// The response shape this adapter parses against was captured from the
// publicly documented field names commonly used by community automations.
// If MAM renames or changes fields, this adapter logs the raw body via the
// `raw_json` snapshot column so the operator can inspect and the adapter can
// be updated.

func init() {
	Register("mam", newMAM)
}

// MAMConfig is the adapter-specific config parsed out of trackers.yaml's
// per-tracker `config:` map. All fields are optional with sensible defaults.
type MAMConfig struct {
	// StatsPath overrides the default `/jsonLoad.php` path. Empty = default.
	StatsPath string `json:"stats_path,omitempty"`
}

type mamAdapter struct {
	cfg      Config
	mam      MAMConfig
	statsURL string
	h        *scrape.Hygiene
}

func newMAM(cfg Config, h *scrape.Hygiene) (scrape.Adapter, error) {
	if cfg.BaseURL == "" {
		return nil, errors.New("mam: base_url required")
	}
	m := MAMConfig{StatsPath: "/jsonLoad.php"}
	if len(cfg.TypeConfigJSON) > 0 {
		if err := json.Unmarshal(cfg.TypeConfigJSON, &m); err != nil {
			return nil, fmt.Errorf("mam: parse type-config: %w", err)
		}
		if m.StatsPath == "" {
			m.StatsPath = "/jsonLoad.php"
		}
	}
	statsURL := cfg.BaseURL + m.StatsPath
	return &mamAdapter{cfg: cfg, mam: m, statsURL: statsURL, h: h}, nil
}

func (m *mamAdapter) Name() string { return m.cfg.ID }

// FetchRatio calls jsonLoad.php and parses the response. The cookie comes
// from the hygiene primitive automatically; the operator must have placed
// the mam_id cookie via the secrets store first.
func (m *mamAdapter) FetchRatio(ctx context.Context) (scrape.RatioSnapshot, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, m.statsURL, nil)
	if err != nil {
		return scrape.RatioSnapshot{}, err
	}
	req.Header.Set("Accept", "application/json")

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

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

	return parseMAMResponse(body)
}

// Health issues a tiny request to the stats endpoint and treats anything
// other than auth-failure as healthy enough to scrape. A 401/403 surfaces
// as an error because the operator needs to refresh the cookie.
func (m *mamAdapter) Health(ctx context.Context) error {
	req, err := http.NewRequestWithContext(ctx, http.MethodHead, m.cfg.BaseURL, nil)
	if err != nil {
		return err
	}
	resp, err := m.h.Do(ctx, req, m.cfg.ID)
	if err != nil {
		return err
	}
	_ = resp.Body.Close()
	if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
		return fmt.Errorf("mam: auth (status %d) — refresh mam_id cookie", resp.StatusCode)
	}
	return nil
}

// mamRawResponse models the documented fields of jsonLoad.php. Fields not
// present in a particular response are zero-valued; the parser uses
// pointer-valued outputs so "absent" is distinguishable from "zero".
type mamRawResponse struct {
	// Stat fields. Some are stringified numbers in MAM's actual responses;
	// json.Number handles both forms.
	Uploaded        json.Number `json:"uploaded"`
	Downloaded      json.Number `json:"downloaded"`
	RealUploaded    json.Number `json:"uploadedReal"`
	RealDownloaded  json.Number `json:"downloadedReal"`
	Ratio           json.Number `json:"ratio"`
	Bonus           json.Number `json:"seedbonus"`
	Unsat           json.Number `json:"unsat"`
	UnsatLimit      json.Number `json:"unsat_limit"`
	ClassText       string      `json:"class_text"`
	ClassLevel      json.Number `json:"class_level"`

	// Error path: MAM sometimes returns {"error": "..."} on auth failure
	// with a 200 status code.
	Error string `json:"error,omitempty"`
}

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

	snap := scrape.RatioSnapshot{RawJSON: string(body), ClassOrRank: raw.ClassText}
	snap.DisplayedUploadedBytes = intPtr(raw.Uploaded)
	snap.DisplayedDownloadedBytes = intPtr(raw.Downloaded)
	snap.DisplayedRatio = floatPtr(raw.Ratio)

	// MAM reports "Real" only for some users (those who have the relevant
	// site feature enabled). Fall back to displayed when absent.
	if v := intPtr(raw.RealUploaded); v != nil {
		snap.RealUploadedBytes = v
	} else {
		snap.RealUploadedBytes = snap.DisplayedUploadedBytes
	}
	if v := intPtr(raw.RealDownloaded); v != nil {
		snap.RealDownloadedBytes = v
	} else {
		snap.RealDownloadedBytes = snap.DisplayedDownloadedBytes
	}
	if snap.RealUploadedBytes != nil && snap.RealDownloadedBytes != nil && *snap.RealDownloadedBytes > 0 {
		ratio := float64(*snap.RealUploadedBytes) / float64(*snap.RealDownloadedBytes)
		snap.RealRatio = &ratio
	} else if snap.DisplayedRatio != nil {
		snap.RealRatio = snap.DisplayedRatio
	}

	snap.BonusPoints = intPtr(raw.Bonus)
	snap.UnsatCount = intPtr(raw.Unsat)
	snap.UnsatLimit = intPtr(raw.UnsatLimit)
	return snap, nil
}

func intPtr(n json.Number) *int64 {
	if n == "" {
		return nil
	}
	v, err := n.Int64()
	if err != nil {
		// Maybe a float-style number; try Float64 → Int64.
		f, ferr := n.Float64()
		if ferr != nil {
			return nil
		}
		v = int64(f)
	}
	return &v
}

func floatPtr(n json.Number) *float64 {
	if n == "" {
		return nil
	}
	v, err := n.Float64()
	if err != nil {
		i, ierr := n.Int64()
		if ierr != nil {
			// MAM sometimes formats ratio with a non-numeric suffix; try
			// stripping commas as a last resort.
			s := string(n)
			if f, perr := strconv.ParseFloat(s, 64); perr == nil {
				return &f
			}
			return nil
		}
		v = float64(i)
	}
	return &v
}

// bytesReader avoids pulling in bytes.NewReader for clarity.
type byteReader struct {
	data []byte
	off  int
}

func bytesReader(b []byte) *byteReader { return &byteReader{data: b} }
func (b *byteReader) Read(p []byte) (int, error) {
	if b.off >= len(b.data) {
		return 0, errEOF
	}
	n := copy(p, b.data[b.off:])
	b.off += n
	return n, nil
}

var errEOF = errors.New("EOF")
