package api

import (
	"net/http"
	"sort"
	"strconv"
	"strings"

	"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
	"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)

const (
	recMetricsDefaultDays = 30
	recMetricsMaxDays     = 365

	// recMetricsLowVolume marks a family as low-confidence rather than
	// hiding it: with fewer plays than this a skip rate is anecdote, not
	// signal, but silently dropping the row would misread as "surface
	// unused". The web renders low-confidence rows dimmed.
	recMetricsLowVolume = 20
)

// Surface intents (milestone #127): each family is judged against its
// job, not one global bar — discovery mixes are EXPECTED to run higher
// skip rates than the go-to surfaces.
const (
	intentGoTo      = "go_to"
	intentDiscovery = "discovery"
	intentDirect    = "direct"
)

// surfaceMetric is one bucketed surface family's outcomes.
type surfaceMetric struct {
	Key           string  `json:"key"`            // stable family key ("for_you", "radio", …)
	Label         string  `json:"label"`          // display label
	Plays         int64   `json:"plays"`          // plays launched from this family
	Skips         int64   `json:"skips"`          // of those, marked skipped
	SkipRate      float64 `json:"skip_rate"`      // skips / plays, [0,1]
	AvgCompletion float64 `json:"avg_completion"` // mean completion ratio, [0,1]
	LowConfidence bool    `json:"low_confidence"` // plays < recMetricsLowVolume
	// SkipDelta / CompletionDelta are this row's difference from the manual
	// baseline WITH its margin of error (#2495). nil on the baseline row
	// itself, and whenever the samples are too thin for a margin to mean
	// anything. Computed server-side so both clients read the same arithmetic
	// instead of each re-deriving it — and so `low_confidence` is no longer
	// mistaken for a decision threshold, which it never was.
	SkipDelta       *metricDelta `json:"skip_delta,omitempty"`
	CompletionDelta *metricDelta `json:"completion_delta,omitempty"`
	// Breakdown splits the family into the pick-kind populations its
	// builder stamped (#1249, generalized #1270): For You's taste/fresh,
	// Discover's buckets, tier1-3 for tiered mixes — plus earlier plays
	// that predate attribution. Present only when the family has at
	// least one attributed play; the parent row remains the sum of its
	// breakdown.
	Breakdown []surfaceMetric `json:"breakdown,omitempty"`
}

// surfaceGroup is one intent band of surface families.
type surfaceGroup struct {
	Intent   string          `json:"intent"` // go_to | discovery | direct
	Label    string          `json:"label"`
	Surfaces []surfaceMetric `json:"surfaces"`
}

type recommendationMetricsResp struct {
	WindowDays int `json:"window_days"`
	// Baseline is the control group: plays the user picked manually
	// (source IS NULL). Surfaces are judged as deltas against it; nil
	// when the window holds no manual plays.
	Baseline *surfaceMetric `json:"baseline"`
	Groups   []surfaceGroup `json:"groups"`
}

// recFamily is the bucketing target for a raw play_events.source value.
type recFamily struct {
	key    string
	label  string
	intent string
}

// bucketRecSource maps a raw client-stamped source string to its stable
// family. One-off sources (album:<uuid>, radio:<uuid>) collapse into
// their family so the table stays readable at any library size.
func bucketRecSource(src string) recFamily {
	switch {
	case src == "for_you":
		return recFamily{"for_you", "For You", intentGoTo}
	case src == "songs_like_artist":
		return recFamily{"songs_like_artist", "Songs like…", intentGoTo}
	case src == "radio" || strings.HasPrefix(src, "radio:"):
		return recFamily{"radio", "Radio", intentGoTo}
	case src == "discover":
		return recFamily{"discover", "Discover", intentDiscovery}
	case src == "deep_cuts":
		return recFamily{"deep_cuts", "Deep cuts", intentDiscovery}
	case src == "rediscover":
		return recFamily{"rediscover", "Rediscover", intentDiscovery}
	case src == "new_for_you":
		return recFamily{"new_for_you", "New for you", intentDiscovery}
	case src == "on_this_day":
		return recFamily{"on_this_day", "On this day", intentDiscovery}
	case src == "first_listens":
		return recFamily{"first_listens", "First listens", intentDiscovery}
	case strings.HasPrefix(src, "album:"):
		return recFamily{"direct_album", "Album plays", intentDirect}
	case strings.HasPrefix(src, "artist:"):
		return recFamily{"direct_artist", "Artist plays", intentDirect}
	case strings.HasPrefix(src, "offline:"):
		return recFamily{"offline", "Offline pools", intentDirect}
	case strings.HasPrefix(src, "home:"):
		return recFamily{"home", "Home sections", intentDirect}
	case src == "history":
		return recFamily{"history", "History", intentDirect}
	default:
		return recFamily{"other", "Other", intentDirect}
	}
}

// familyAccum merges raw source rows into one family, carrying the
// completion sample count so the merged average stays play-weighted.
type familyAccum struct {
	fam         recFamily
	plays       int64
	skips       int64
	completionN int64
	// completionSum is avg*count re-expanded, so merging N raw rows
	// reduces to a single weighted division at the end.
	completionSum float64
	// completionSqSum is the sum of squared completion ratios, which is what
	// makes the variance mergeable across raw source rows (#2495). Standard
	// deviations cannot be combined; sums of squares add exactly.
	completionSqSum float64
}

func (a *familyAccum) add(row dbq.RecommendationSourceMetricsForUserRow) {
	a.plays += row.Plays
	a.skips += row.Skips
	a.completionN += row.CompletionN
	a.completionSum += row.AvgCompletion * float64(row.CompletionN)
	a.completionSqSum += row.CompletionSqsum
}

func (a *familyAccum) metric() surfaceMetric {
	m := surfaceMetric{
		Key:           a.fam.key,
		Label:         a.fam.label,
		Plays:         a.plays,
		Skips:         a.skips,
		LowConfidence: a.plays < recMetricsLowVolume,
	}
	if a.plays > 0 {
		m.SkipRate = float64(a.skips) / float64(a.plays)
	}
	if a.completionN > 0 {
		m.AvgCompletion = a.completionSum / float64(a.completionN)
	}
	return m
}

// completionVariance is the sample variance of this family's completion ratios.
func (a *familyAccum) completionVariance() float64 {
	return sampleVariance(a.completionSum, a.completionSqSum, a.completionN)
}

// applyDeltas attaches baseline-relative deltas + margins to a metric.
// Split out so every row — parent surfaces and breakdown rows alike — goes
// through the identical arithmetic; a breakdown arm is exactly where the old
// card was most misleading, because those are the thinnest samples on screen.
func applyDeltas(m *surfaceMetric, acc *familyAccum, baseline *familyAccum) {
	if baseline == nil || baseline.plays == 0 {
		return
	}
	m.SkipDelta = proportionDelta(
		m.SkipRate, acc.plays,
		float64(baseline.skips)/float64(baseline.plays), baseline.plays,
	)
	baseMean := 0.0
	if baseline.completionN > 0 {
		baseMean = baseline.completionSum / float64(baseline.completionN)
	}
	m.CompletionDelta = meanDelta(
		m.AvgCompletion, acc.completionVariance(), acc.completionN,
		baseMean, baseline.completionVariance(), baseline.completionN,
	)
}

// handleGetRecommendationMetrics implements GET /api/me/recommendation-metrics.
// Bucketed per-surface-family outcomes for the caller over the last `days`
// (default 30, capped at 365), grouped by surface intent and anchored by the
// manual-plays baseline so the numbers are judgeable, not just observable.
func (h *handlers) handleGetRecommendationMetrics(w http.ResponseWriter, r *http.Request) {
	caller, ok := requireUser(w, r)
	if !ok {
		return
	}
	days, ok := parseMetricsDays(w, r)
	if !ok {
		return
	}

	rows, err := dbq.New(h.pool).RecommendationSourceMetricsForUser(r.Context(),
		dbq.RecommendationSourceMetricsForUserParams{UserID: caller.ID, Column2: float64(days)})
	if err != nil {
		h.logger.Error("api: recommendation metrics", "err", err)
		writeErr(w, apierror.InternalMsg("lookup failed", err))
		return
	}

	writeJSON(w, http.StatusOK, bucketMetricsResponse(days, rows))
}

// pickKindLabels is the display vocabulary for play_events.pick_kind
// values (mirrors the CHECK in migration 0041). Every system mix that
// stamps provenance gets its breakdown from this one map — adding a
// stamping mix needs no metrics change.
var pickKindLabels = map[string]string{
	"taste":         "Taste picks",
	"fresh":         "Fresh picks",
	"dormant":       "Dormant artists",
	"taste_unheard": "Taste-matched",
	"cross_user":    "Liked by others",
	"random":        "Random unheard",
	"tier1":         "Tier 1 (exact)",
	"tier2":         "Tier 2 (relaxed)",
	"tier3":         "Tier 3 (stretched)",
}

// pickKindOrder fixes breakdown row order; unattributed ("", i.e. NULL
// pick_kind — plays recorded before the mix stamped provenance, or
// whose track had rotated out of the snapshot at ingestion) renders
// last, kept visible so the parent row's sums stay transparent instead
// of silently shrinking. The DB CHECK gates pick_kind to exactly this
// vocabulary, so iterating the list is exhaustive.
var pickKindOrder = []string{
	"taste", "fresh", "dormant", "taste_unheard", "cross_user", "random",
	"tier1", "tier2", "tier3", "",
}

// pickKindFamily derives the sub-family for one (family, pick_kind)
// population, e.g. ("for_you", "taste") → for_you_taste "Taste picks".
func pickKindFamily(parent recFamily, kind string) recFamily {
	if kind == "" {
		return recFamily{parent.key + "_unattributed", "Earlier plays", parent.intent}
	}
	label, ok := pickKindLabels[kind]
	if !ok {
		label = kind
	}
	return recFamily{parent.key + "_" + kind, label, parent.intent}
}

// pickKindBreakdown folds a family's per-pick-kind accums into its
// Breakdown rows. Attached only when at least one attributed play
// exists — an all-unattributed breakdown would just repeat the parent
// row, and families that never stamp (radio, direct plays) stay flat.
func pickKindBreakdown(picks map[string]*familyAccum, baseline *familyAccum) []surfaceMetric {
	attributed := int64(0)
	for kind, acc := range picks {
		if kind != "" {
			attributed += acc.plays
		}
	}
	if attributed == 0 {
		return nil
	}
	out := make([]surfaceMetric, 0, len(picks))
	for _, kind := range pickKindOrder {
		if acc, ok := picks[kind]; ok && acc.plays > 0 {
			m := acc.metric()
			applyDeltas(&m, acc, baseline)
			out = append(out, m)
		}
	}
	return out
}

// bucketMetricsResponse folds the raw per-source rows into the grouped,
// baseline-anchored response shape. Split from the handler for pure-unit
// testability.
func bucketMetricsResponse(
	days int, rows []dbq.RecommendationSourceMetricsForUserRow,
) recommendationMetricsResp {
	baseline := &familyAccum{fam: recFamily{"manual", "Manual library plays", ""}}
	families := map[string]*familyAccum{}
	picks := map[string]map[string]*familyAccum{}
	for _, row := range rows {
		if row.Source == nil || *row.Source == "" {
			baseline.add(row)
			continue
		}
		fam := bucketRecSource(*row.Source)
		acc, exists := families[fam.key]
		if !exists {
			acc = &familyAccum{fam: fam}
			families[fam.key] = acc
		}
		acc.add(row)
		// Accumulate the pick-kind population unconditionally; families
		// that never stamp end up all-unattributed and get no breakdown.
		kind := ""
		if row.PickKind != nil {
			kind = *row.PickKind
		}
		byKind, ok := picks[fam.key]
		if !ok {
			byKind = map[string]*familyAccum{}
			picks[fam.key] = byKind
		}
		pick, ok := byKind[kind]
		if !ok {
			pick = &familyAccum{fam: pickKindFamily(fam, kind)}
			byKind[kind] = pick
		}
		pick.add(row)
	}

	resp := recommendationMetricsResp{WindowDays: days, Groups: []surfaceGroup{}}
	if baseline.plays > 0 {
		m := baseline.metric()
		resp.Baseline = &m
	}
	for _, g := range []struct{ intent, label string }{
		{intentGoTo, "Go-to surfaces"},
		{intentDiscovery, "Discovery mixes"},
		{intentDirect, "Direct plays"},
	} {
		group := surfaceGroup{Intent: g.intent, Label: g.label}
		for _, acc := range families {
			if acc.fam.intent == g.intent {
				m := acc.metric()
				applyDeltas(&m, acc, baseline)
				m.Breakdown = pickKindBreakdown(picks[acc.fam.key], baseline)
				group.Surfaces = append(group.Surfaces, m)
			}
		}
		if len(group.Surfaces) == 0 {
			continue
		}
		sort.Slice(group.Surfaces, func(i, j int) bool {
			if group.Surfaces[i].Plays != group.Surfaces[j].Plays {
				return group.Surfaces[i].Plays > group.Surfaces[j].Plays
			}
			return group.Surfaces[i].Key < group.Surfaces[j].Key
		})
		resp.Groups = append(resp.Groups, group)
	}
	return resp
}

// parseMetricsDays reads the `days` query param (default 30, capped at 365).
// Writes a 400 and returns ok=false on a malformed value.
func parseMetricsDays(w http.ResponseWriter, r *http.Request) (int, bool) {
	v := r.URL.Query().Get("days")
	if v == "" {
		return recMetricsDefaultDays, true
	}
	n, err := strconv.Atoi(v)
	if err != nil || n < 1 {
		writeErr(w, apierror.BadRequest("bad_request", "invalid days"))
		return 0, false
	}
	if n > recMetricsMaxDays {
		n = recMetricsMaxDays
	}
	return n, true
}
