// Admin recommendation-trends endpoint (#1251): weekly per-surface
// outcome series with tuning-audit markers — the verify half of the
// tune→verify loop the tuning lab (#1250) opens. Aggregated across
// all users because the knobs are global; rows carry rates only.
package api

import (
	"encoding/json"
	"net/http"
	"sort"
	"strconv"
	"time"

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

const (
	trendsDefaultWeeks = 12
	trendsMaxWeeks     = 52
	// trendsAuditFetchCap bounds the audit fetch; markers older than
	// the window are dropped in Go. Far above any real knob-turn count
	// inside a year.
	trendsAuditFetchCap = 500
)

// trendPoint is one week of one surface family's outcomes.
type trendPoint struct {
	WeekStart     string  `json:"week_start"` // ISO date (Monday)
	Plays         int64   `json:"plays"`
	Skips         int64   `json:"skips"`
	SkipRate      float64 `json:"skip_rate"`
	AvgCompletion float64 `json:"avg_completion"`
	// TasteHitRate is the share of plays whose track's artist carries a
	// positive weight in the player's current taste profile — a drifted
	// but retroactive read on whether the surface feeds taste-fitting
	// tracks.
	TasteHitRate float64 `json:"taste_hit_rate"`
	// completionN carries the completion sample count through same-week
	// merges so avg_completion stays sample-weighted; not serialized.
	completionN int64
}

// trendSeries is one surface family's weekly series.
type trendSeries struct {
	Key    string       `json:"key"`
	Label  string       `json:"label"`
	Intent string       `json:"intent"` // go_to | discovery | direct; "" for the manual baseline
	Plays  int64        `json:"plays"`  // window total, for sorting/volume-gating
	Points []trendPoint `json:"points"`
}

// trendMarker is one tuning-audit event annotated on the timeline.
type trendMarker struct {
	ChangedAt string          `json:"changed_at"`
	Scope     string          `json:"scope"`
	Action    string          `json:"action"`
	Changes   json.RawMessage `json:"changes"`
}

type trendsResp struct {
	Weeks   int           `json:"weeks"`
	Series  []trendSeries `json:"series"`
	Markers []trendMarker `json:"markers"`
}

// handleGetRecommendationTrends implements
// GET /api/admin/recommendation-trends?weeks=N (default 12, cap 52).
func (h *handlers) handleGetRecommendationTrends(w http.ResponseWriter, r *http.Request) {
	weeks := trendsDefaultWeeks
	if v := r.URL.Query().Get("weeks"); v != "" {
		n, err := strconv.Atoi(v)
		if err != nil || n < 1 {
			writeErr(w, apierror.BadRequest("bad_request", "invalid weeks"))
			return
		}
		if n > trendsMaxWeeks {
			n = trendsMaxWeeks
		}
		weeks = n
	}

	q := dbq.New(h.pool)
	rows, err := q.RecommendationWeeklyTrends(r.Context(), int32(weeks))
	if err != nil {
		h.logger.Error("api: recommendation trends", "err", err)
		writeErr(w, apierror.InternalMsg("lookup failed", err))
		return
	}
	audits, err := q.ListTuningAudit(r.Context(), trendsAuditFetchCap)
	if err != nil {
		h.logger.Error("api: recommendation trends audit", "err", err)
		writeErr(w, apierror.InternalMsg("lookup failed", err))
		return
	}

	writeJSON(w, http.StatusOK, buildTrendsResponse(weeks, time.Now().UTC(), rows, audits))
}

// buildTrendsResponse folds weekly rows into per-family series and
// windows the audit markers. Split from the handler for pure-unit
// testability.
func buildTrendsResponse(
	weeks int, now time.Time,
	rows []dbq.RecommendationWeeklyTrendsRow,
	audits []dbq.RecommendationTuningAudit,
) trendsResp {
	type accum struct {
		fam    recFamily
		plays  int64
		points []trendPoint
	}
	families := map[string]*accum{}
	for _, row := range rows {
		fam := recFamily{key: "manual", label: "Manual library plays"}
		if row.Source != nil && *row.Source != "" {
			fam = bucketRecSource(*row.Source)
		}
		acc, ok := families[fam.key]
		if !ok {
			acc = &accum{fam: fam}
			families[fam.key] = acc
		}
		acc.plays += row.Plays
		p := trendPoint{
			WeekStart:     row.WeekStart.Time.Format("2006-01-02"),
			Plays:         row.Plays,
			Skips:         row.Skips,
			AvgCompletion: row.AvgCompletion,
			completionN:   row.CompletionN,
		}
		if row.Plays > 0 {
			p.SkipRate = float64(row.Skips) / float64(row.Plays)
			p.TasteHitRate = float64(row.TasteHits) / float64(row.Plays)
		}
		// Same family can arrive as several raw sources (radio:<uuid>);
		// merge same-week points play-weighted.
		if n := len(acc.points); n > 0 && acc.points[n-1].WeekStart == p.WeekStart {
			acc.points[n-1] = mergeTrendPoints(acc.points[n-1], p)
		} else {
			acc.points = append(acc.points, p)
		}
	}

	resp := trendsResp{Weeks: weeks, Series: []trendSeries{}, Markers: []trendMarker{}}
	for _, acc := range families {
		resp.Series = append(resp.Series, trendSeries{
			Key:    acc.fam.key,
			Label:  acc.fam.label,
			Intent: acc.fam.intent,
			Plays:  acc.plays,
			Points: acc.points,
		})
	}
	sort.Slice(resp.Series, func(i, j int) bool {
		if resp.Series[i].Plays != resp.Series[j].Plays {
			return resp.Series[i].Plays > resp.Series[j].Plays
		}
		return resp.Series[i].Key < resp.Series[j].Key
	})

	cutoff := now.Add(-time.Duration(weeks) * 7 * 24 * time.Hour)
	for _, a := range audits {
		if a.ChangedAt.Time.Before(cutoff) {
			continue
		}
		resp.Markers = append(resp.Markers, trendMarker{
			ChangedAt: a.ChangedAt.Time.UTC().Format(time.RFC3339),
			Scope:     a.Scope,
			Action:    a.Action,
			Changes:   json.RawMessage(a.Changes),
		})
	}
	// ListTuningAudit returns newest-first; the timeline reads better
	// oldest-first.
	sort.Slice(resp.Markers, func(i, j int) bool {
		return resp.Markers[i].ChangedAt < resp.Markers[j].ChangedAt
	})
	return resp
}

// mergeTrendPoints combines two same-week points of one family:
// counts add, skip/taste rates re-derive from the merged counts, and
// avg_completion is weighted by each side's completion sample count.
func mergeTrendPoints(a, b trendPoint) trendPoint {
	out := trendPoint{
		WeekStart:   a.WeekStart,
		Plays:       a.Plays + b.Plays,
		Skips:       a.Skips + b.Skips,
		completionN: a.completionN + b.completionN,
	}
	if out.Plays > 0 {
		out.SkipRate = float64(out.Skips) / float64(out.Plays)
		out.TasteHitRate = (a.TasteHitRate*float64(a.Plays) + b.TasteHitRate*float64(b.Plays)) /
			float64(out.Plays)
	}
	if out.completionN > 0 {
		out.AvgCompletion = (a.AvgCompletion*float64(a.completionN) + b.AvgCompletion*float64(b.completionN)) /
			float64(out.completionN)
	}
	return out
}
