// Package recsettings is the DB-backed home of the recommendation
// tuning knobs (#1250): the two scoring-weight profiles (radio /
// daily_mix) and the taste-profile build settings (engagement
// half-life + completion curve). It follows the coverart
// SettingsService pattern — boot reconcile seeds shipped defaults for
// missing rows, values are cached under a RWMutex, and every change
// takes effect live (the daily_mix profile + taste config are pushed
// into package playlists; radio reads the cache per request).
//
// Framing (decision #1247): this is the defaults-discovery lab. The
// operator turns knobs to FIND good values; found-good values get
// baked back into the Shipped* functions below as new shipped
// defaults. End users and other operators should never need the card.
package recsettings

import (
	"context"
	"encoding/json"
	"fmt"
	"log/slog"
	"sort"
	"sync"

	"github.com/jackc/pgx/v5/pgxpool"

	"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
	"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
	"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
	"git.fabledsword.com/bvandeusen/minstrel/internal/taste"
)

// Scope names — the three tunable groups. radio + daily_mix are weight
// profiles; taste is the profile-build settings singleton.
const (
	ScopeRadio    = "radio"
	ScopeDailyMix = "daily_mix"
	ScopeTaste    = "taste"
	// ScopeDiscover is the Discover request surface (#2377). Its own scope
	// rather than columns on taste: SnoozeDays lives here, and a snooze must
	// never be read as taste signal (#2374) — filing it under taste would put
	// it one careless join from the leak that design forbids.
	ScopeDiscover = "discover"
)

// TasteTuning is the tunable subset of taste.Config: the engagement
// half-life, the completion→engagement curve points, the enriched-tag
// weight (how much folksonomy tags count vs raw ID3 genre, #1520), and the
// era-facet weight (how strongly a decade-play imprints, #1530).
type TasteTuning struct {
	HalfLifeDays       float64
	EngagementHardSkip float64
	EngagementNeutral  float64
	EngagementFull     float64
	EnrichedTagScale   float64
	EraScale           float64
	MoodScale          float64
}

// ShippedRadioWeights are the shipped radio-profile defaults (moved
// here from config.RecommendationConfig — YAML is bootstrap-only,
// rule: config in UI). Radio is seed-directed (the user picked a
// direction), so taste is a lighter nudge than in the daily mixes.
// ContextTimeWeight starts uniform (1.0) across both profiles pending
// trend data (#1531); split them once the metrics view justifies it.
func ShippedRadioWeights() recommendation.ScoringWeights {
	return recommendation.ScoringWeights{
		BaseWeight:        1.0,
		LikeBoost:         2.0,
		RecencyWeight:     1.0,
		SkipPenalty:       1.0,
		JitterMagnitude:   0.1,
		ContextWeight:     2.0,
		SimilarityWeight:  2.0,
		TasteWeight:       1.0,
		ContextTimeWeight: 1.0,
	}
}

// ShippedDailyMixWeights are the shipped daily_mix-profile defaults.
// Must stay in sync with the pre-push literal in playlists/system.go.
func ShippedDailyMixWeights() recommendation.ScoringWeights {
	return recommendation.ScoringWeights{
		BaseWeight:        1.0,
		LikeBoost:         2.0,
		RecencyWeight:     1.0,
		SkipPenalty:       2.0,
		JitterMagnitude:   0.1,
		ContextWeight:     0.5,
		SimilarityWeight:  1.5,
		TasteWeight:       1.5,
		ContextTimeWeight: 1.0,
	}
}

// DiscoverTuning is the tunable set for the Discover request surface (#2377).
type DiscoverTuning struct {
	// TagOverlapWeight scales the taste-tag term: score × (1 + w × overlap).
	// 0 disables it and restores pure similarity ranking.
	TagOverlapWeight float64
	// SnoozeDays is the default "not right now" duration (#2374).
	SnoozeDays float64
}

// ShippedDiscoverTuning are the shipped Discover defaults.
//
// TagOverlapWeight 1.0 lets a perfect tag match at most double a candidate's
// similarity score — enough to reorder the deck meaningfully, not enough for a
// popular-tag coincidence to beat a genuinely strong similarity match. It is a
// starting point for the tuning lab, not a tuned value: the honest way to pick
// it is the metrics trend view after some real use.
//
// SnoozeDays 90 matches the operator's approved shape: long enough that a
// parked suggestion stops nagging, short enough that a taste shift brings it
// back on its own.
func ShippedDiscoverTuning() DiscoverTuning {
	return DiscoverTuning{
		TagOverlapWeight: 1.0,
		SnoozeDays:       90,
	}
}

// ShippedTasteTuning mirrors taste.DefaultConfig's tunable subset.
func ShippedTasteTuning() TasteTuning {
	d := taste.DefaultConfig()
	return TasteTuning{
		HalfLifeDays:       d.HalfLifeDays,
		EngagementHardSkip: d.Engagement.HardSkip,
		EngagementNeutral:  d.Engagement.NeutralCompletion,
		EngagementFull:     d.Engagement.FullCompletion,
		EnrichedTagScale:   d.EnrichedTagScale,
		EraScale:           d.EraScale,
		MoodScale:          d.MoodScale,
	}
}

// Service caches the tuning values and owns their DB persistence +
// audit trail. Construct with New at boot.
type Service struct {
	pool   *pgxpool.Pool
	logger *slog.Logger

	mu       sync.RWMutex
	profiles map[string]recommendation.ScoringWeights
	taste    TasteTuning
	discover DiscoverTuning
}

// New boots the service: seeds shipped defaults for missing rows,
// loads the current values, and pushes the daily_mix weights + taste
// config into package playlists so the daily builds pick them up.
func New(ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger) (*Service, error) {
	s := &Service{
		pool:     pool,
		logger:   logger,
		profiles: map[string]recommendation.ScoringWeights{},
	}
	if err := s.reconcile(ctx); err != nil {
		return nil, fmt.Errorf("recsettings boot: %w", err)
	}
	return s, nil
}

// reconcile seeds missing rows with shipped defaults, reads everything
// back into the cache, and pushes the playlist-side values.
func (s *Service) reconcile(ctx context.Context) error {
	q := dbq.New(s.pool)
	for profile, w := range map[string]recommendation.ScoringWeights{
		ScopeRadio:    ShippedRadioWeights(),
		ScopeDailyMix: ShippedDailyMixWeights(),
	} {
		if err := q.UpsertWeightProfileDefaults(ctx, upsertParams(profile, w)); err != nil {
			return fmt.Errorf("seed profile %q: %w", profile, err)
		}
	}
	st := ShippedTasteTuning()
	if err := q.UpsertTasteTuningDefaults(ctx, dbq.UpsertTasteTuningDefaultsParams{
		HalfLifeDays:       st.HalfLifeDays,
		EngagementHardSkip: st.EngagementHardSkip,
		EngagementNeutral:  st.EngagementNeutral,
		EngagementFull:     st.EngagementFull,
		EnrichedTagScale:   st.EnrichedTagScale,
		EraScale:           st.EraScale,
		MoodScale:          st.MoodScale,
	}); err != nil {
		return fmt.Errorf("seed taste tuning: %w", err)
	}
	sd := ShippedDiscoverTuning()
	if err := q.UpsertDiscoverTuningDefaults(ctx, dbq.UpsertDiscoverTuningDefaultsParams{
		TagOverlapWeight: sd.TagOverlapWeight,
		SnoozeDays:       sd.SnoozeDays,
	}); err != nil {
		return fmt.Errorf("seed discover tuning: %w", err)
	}

	rows, err := q.ListWeightProfiles(ctx)
	if err != nil {
		return fmt.Errorf("list weight profiles: %w", err)
	}
	tt, err := q.GetTasteTuning(ctx)
	if err != nil {
		return fmt.Errorf("get taste tuning: %w", err)
	}
	dt, err := q.GetDiscoverTuning(ctx)
	if err != nil {
		return fmt.Errorf("get discover tuning: %w", err)
	}

	s.mu.Lock()
	s.profiles = map[string]recommendation.ScoringWeights{}
	for _, r := range rows {
		s.profiles[r.Profile] = weightsFromRow(r)
	}
	s.taste = TasteTuning{
		HalfLifeDays:       tt.HalfLifeDays,
		EngagementHardSkip: tt.EngagementHardSkip,
		EngagementNeutral:  tt.EngagementNeutral,
		EngagementFull:     tt.EngagementFull,
		EnrichedTagScale:   tt.EnrichedTagScale,
		EraScale:           tt.EraScale,
		MoodScale:          tt.MoodScale,
	}
	s.discover = DiscoverTuning{
		TagOverlapWeight: dt.TagOverlapWeight,
		SnoozeDays:       dt.SnoozeDays,
	}
	s.mu.Unlock()

	s.push()
	return nil
}

// push installs the daily-build values into package playlists (the
// coverart Configure() pattern). Radio needs no push — its handler
// reads Weights(ScopeRadio) per request.
func (s *Service) push() {
	playlists.SetSystemMixWeights(s.Weights(ScopeDailyMix))
	playlists.SetTasteConfig(s.TasteConfig())
}

// Weights returns the cached weights for a profile scope. Unknown
// scopes return the shipped radio defaults (defensive; callers pass
// the Scope* constants).
func (s *Service) Weights(profile string) recommendation.ScoringWeights {
	s.mu.RLock()
	defer s.mu.RUnlock()
	if w, ok := s.profiles[profile]; ok {
		return w
	}
	return ShippedRadioWeights()
}

// Taste returns the cached taste-tuning values.
func (s *Service) Taste() TasteTuning {
	s.mu.RLock()
	defer s.mu.RUnlock()
	return s.taste
}

// Discover returns the cached Discover-tuning values. Read per request by the
// suggestions handler, so an admin change takes effect on the next refresh
// with no restart (rule #25).
func (s *Service) Discover() DiscoverTuning {
	s.mu.RLock()
	defer s.mu.RUnlock()
	return s.discover
}

// TasteConfig assembles the full taste.Config the profile builder
// consumes: shipped non-tunable knobs (like bonuses, floors, caps)
// plus the tuned half-life and curve. WindowDays scales with the
// half-life at the shipped ratio (270/75 = 3.6 half-lives) — the
// window is a query-cost bound, not an independent knob.
func (s *Service) TasteConfig() taste.Config {
	t := s.Taste()
	cfg := taste.DefaultConfig()
	cfg.HalfLifeDays = t.HalfLifeDays
	cfg.WindowDays = t.HalfLifeDays * 3.6
	cfg.Engagement = taste.EngagementParams{
		HardSkip:          t.EngagementHardSkip,
		NeutralCompletion: t.EngagementNeutral,
		FullCompletion:    t.EngagementFull,
	}
	cfg.EnrichedTagScale = t.EnrichedTagScale
	cfg.EraScale = t.EraScale
	cfg.MoodScale = t.MoodScale
	return cfg
}

// fieldChange is one entry of an audit row's changes array.
type fieldChange struct {
	Field string  `json:"field"`
	Old   float64 `json:"old"`
	New   float64 `json:"new"`
}

// UpdateProfile applies a partial update to one weight profile.
// Unknown fields and out-of-range values reject the whole patch. A
// no-op patch (all values equal to current) writes no audit row.
func (s *Service) UpdateProfile(ctx context.Context, profile string, patch map[string]float64) error {
	if profile != ScopeRadio && profile != ScopeDailyMix {
		return fmt.Errorf("%w: %q", ErrUnknownScope, profile)
	}
	current := s.Weights(profile)
	next, changes, err := applyWeightPatch(current, patch)
	if err != nil {
		return err
	}
	if len(changes) == 0 {
		return nil
	}
	return s.persistProfile(ctx, profile, next, "update", changes)
}

// UpdateTaste applies a partial update to the taste tuning singleton.
func (s *Service) UpdateTaste(ctx context.Context, patch map[string]float64) error {
	current := s.Taste()
	next, changes, err := applyTastePatch(current, patch)
	if err != nil {
		return err
	}
	if len(changes) == 0 {
		return nil
	}
	return s.persistTaste(ctx, next, "update", changes)
}

// UpdateDiscover applies a partial update to the Discover tuning singleton.
func (s *Service) UpdateDiscover(ctx context.Context, patch map[string]float64) error {
	current := s.Discover()
	next, changes, err := applyDiscoverPatch(current, patch)
	if err != nil {
		return err
	}
	if len(changes) == 0 {
		return nil
	}
	return s.persistDiscover(ctx, next, "update", changes)
}

// Reset restores a scope to its shipped defaults, with one audit row
// carrying the full diff. A scope already at defaults is a no-op.
func (s *Service) Reset(ctx context.Context, scope string) error {
	switch scope {
	case ScopeRadio, ScopeDailyMix:
		shipped := ShippedRadioWeights()
		if scope == ScopeDailyMix {
			shipped = ShippedDailyMixWeights()
		}
		changes := diffWeights(s.Weights(scope), shipped)
		if len(changes) == 0 {
			return nil
		}
		return s.persistProfile(ctx, scope, shipped, "reset", changes)
	case ScopeTaste:
		shipped := ShippedTasteTuning()
		changes := diffTaste(s.Taste(), shipped)
		if len(changes) == 0 {
			return nil
		}
		return s.persistTaste(ctx, shipped, "reset", changes)
	case ScopeDiscover:
		shipped := ShippedDiscoverTuning()
		changes := diffDiscover(s.Discover(), shipped)
		if len(changes) == 0 {
			return nil
		}
		return s.persistDiscover(ctx, shipped, "reset", changes)
	default:
		return fmt.Errorf("%w: %q", ErrUnknownScope, scope)
	}
}

// persistProfile writes the profile row + audit entry, refreshes the
// cache, and pushes daily-build values.
func (s *Service) persistProfile(
	ctx context.Context, profile string, w recommendation.ScoringWeights,
	action string, changes []fieldChange,
) error {
	q := dbq.New(s.pool)
	if _, err := q.UpdateWeightProfile(ctx, updateParams(profile, w)); err != nil {
		return fmt.Errorf("update profile %q: %w", profile, err)
	}
	if err := s.audit(ctx, q, profile, action, changes); err != nil {
		return err
	}
	s.mu.Lock()
	s.profiles[profile] = w
	s.mu.Unlock()
	s.push()
	return nil
}

func (s *Service) persistTaste(
	ctx context.Context, t TasteTuning, action string, changes []fieldChange,
) error {
	q := dbq.New(s.pool)
	if _, err := q.UpdateTasteTuning(ctx, dbq.UpdateTasteTuningParams{
		HalfLifeDays:       t.HalfLifeDays,
		EngagementHardSkip: t.EngagementHardSkip,
		EngagementNeutral:  t.EngagementNeutral,
		EngagementFull:     t.EngagementFull,
		EnrichedTagScale:   t.EnrichedTagScale,
		EraScale:           t.EraScale,
		MoodScale:          t.MoodScale,
	}); err != nil {
		return fmt.Errorf("update taste tuning: %w", err)
	}
	if err := s.audit(ctx, q, ScopeTaste, action, changes); err != nil {
		return err
	}
	s.mu.Lock()
	s.taste = t
	s.mu.Unlock()
	s.push()
	return nil
}

// persistDiscover writes the discover row + audit entry and refreshes the
// cache. No push(): unlike taste and daily_mix, nothing precomputes from these
// — the suggestions handler reads Discover() per request.
func (s *Service) persistDiscover(
	ctx context.Context, d DiscoverTuning, action string, changes []fieldChange,
) error {
	q := dbq.New(s.pool)
	if _, err := q.UpdateDiscoverTuning(ctx, dbq.UpdateDiscoverTuningParams{
		TagOverlapWeight: d.TagOverlapWeight,
		SnoozeDays:       d.SnoozeDays,
	}); err != nil {
		return fmt.Errorf("update discover tuning: %w", err)
	}
	if err := s.audit(ctx, q, ScopeDiscover, action, changes); err != nil {
		return err
	}
	s.mu.Lock()
	s.discover = d
	s.mu.Unlock()
	return nil
}

// audit writes one recommendation_tuning_audit row. Changes are
// sorted by field so rows are deterministic and diff-friendly.
func (s *Service) audit(
	ctx context.Context, q *dbq.Queries, scope, action string, changes []fieldChange,
) error {
	sort.Slice(changes, func(i, j int) bool { return changes[i].Field < changes[j].Field })
	payload, err := json.Marshal(changes)
	if err != nil {
		return fmt.Errorf("marshal audit changes: %w", err)
	}
	if err := q.InsertTuningAudit(ctx, dbq.InsertTuningAuditParams{
		Scope: scope, Action: action, Changes: payload,
	}); err != nil {
		return fmt.Errorf("insert audit row: %w", err)
	}
	return nil
}

func upsertParams(profile string, w recommendation.ScoringWeights) dbq.UpsertWeightProfileDefaultsParams {
	return dbq.UpsertWeightProfileDefaultsParams{
		Profile:           profile,
		BaseWeight:        w.BaseWeight,
		LikeBoost:         w.LikeBoost,
		RecencyWeight:     w.RecencyWeight,
		SkipPenalty:       w.SkipPenalty,
		JitterMagnitude:   w.JitterMagnitude,
		ContextWeight:     w.ContextWeight,
		SimilarityWeight:  w.SimilarityWeight,
		TasteWeight:       w.TasteWeight,
		ContextTimeWeight: w.ContextTimeWeight,
	}
}

func updateParams(profile string, w recommendation.ScoringWeights) dbq.UpdateWeightProfileParams {
	return dbq.UpdateWeightProfileParams{
		Profile:           profile,
		BaseWeight:        w.BaseWeight,
		LikeBoost:         w.LikeBoost,
		RecencyWeight:     w.RecencyWeight,
		SkipPenalty:       w.SkipPenalty,
		JitterMagnitude:   w.JitterMagnitude,
		ContextWeight:     w.ContextWeight,
		SimilarityWeight:  w.SimilarityWeight,
		TasteWeight:       w.TasteWeight,
		ContextTimeWeight: w.ContextTimeWeight,
	}
}

func weightsFromRow(r dbq.RecommendationWeightProfile) recommendation.ScoringWeights {
	return recommendation.ScoringWeights{
		BaseWeight:        r.BaseWeight,
		LikeBoost:         r.LikeBoost,
		RecencyWeight:     r.RecencyWeight,
		SkipPenalty:       r.SkipPenalty,
		JitterMagnitude:   r.JitterMagnitude,
		ContextWeight:     r.ContextWeight,
		SimilarityWeight:  r.SimilarityWeight,
		TasteWeight:       r.TasteWeight,
		ContextTimeWeight: r.ContextTimeWeight,
	}
}
