// patch.go — field-name mapping + validation for the tuning patches.
// Wire field names are the snake_case column names; the admin API and
// web card use them verbatim.
package recsettings

import (
	"errors"
	"fmt"

	"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
)

var (
	ErrUnknownScope = errors.New("unknown tuning scope")
	ErrUnknownField = errors.New("unknown tuning field")
	ErrOutOfRange   = errors.New("tuning value out of range")
)

// weightBound caps every scoring weight's magnitude. The scoring
// terms are all in [-1, 1] before weighting, so ±10 is far past any
// useful setting — the bound exists to catch typos (e.g. 100 for
// 1.00), not to constrain exploration.
const weightBound = 10.0

// weightField describes one patchable ScoringWeights field.
type weightField struct {
	get func(recommendation.ScoringWeights) float64
	set func(*recommendation.ScoringWeights, float64)
	// nonNegative marks fields where a negative value is meaningless
	// (a negative jitter magnitude or skip penalty inverts intent in a
	// way the score formula already expresses through its sign).
	nonNegative bool
}

var weightFields = map[string]weightField{
	"base_weight": {
		get: func(w recommendation.ScoringWeights) float64 { return w.BaseWeight },
		set: func(w *recommendation.ScoringWeights, v float64) { w.BaseWeight = v },
	},
	"like_boost": {
		get: func(w recommendation.ScoringWeights) float64 { return w.LikeBoost },
		set: func(w *recommendation.ScoringWeights, v float64) { w.LikeBoost = v },
	},
	"recency_weight": {
		get: func(w recommendation.ScoringWeights) float64 { return w.RecencyWeight },
		set: func(w *recommendation.ScoringWeights, v float64) { w.RecencyWeight = v },
	},
	"skip_penalty": {
		get:         func(w recommendation.ScoringWeights) float64 { return w.SkipPenalty },
		set:         func(w *recommendation.ScoringWeights, v float64) { w.SkipPenalty = v },
		nonNegative: true,
	},
	"jitter_magnitude": {
		get:         func(w recommendation.ScoringWeights) float64 { return w.JitterMagnitude },
		set:         func(w *recommendation.ScoringWeights, v float64) { w.JitterMagnitude = v },
		nonNegative: true,
	},
	"context_weight": {
		get: func(w recommendation.ScoringWeights) float64 { return w.ContextWeight },
		set: func(w *recommendation.ScoringWeights, v float64) { w.ContextWeight = v },
	},
	"similarity_weight": {
		get: func(w recommendation.ScoringWeights) float64 { return w.SimilarityWeight },
		set: func(w *recommendation.ScoringWeights, v float64) { w.SimilarityWeight = v },
	},
	"taste_weight": {
		get: func(w recommendation.ScoringWeights) float64 { return w.TasteWeight },
		set: func(w *recommendation.ScoringWeights, v float64) { w.TasteWeight = v },
	},
	"context_time_weight": {
		get: func(w recommendation.ScoringWeights) float64 { return w.ContextTimeWeight },
		set: func(w *recommendation.ScoringWeights, v float64) { w.ContextTimeWeight = v },
	},
}

// applyWeightPatch validates and applies a partial update, returning
// the new weights and the list of actual changes (values equal to the
// current setting are dropped, so a re-submitted form is a no-op).
func applyWeightPatch(
	current recommendation.ScoringWeights, patch map[string]float64,
) (recommendation.ScoringWeights, []fieldChange, error) {
	next := current
	var changes []fieldChange
	for field, v := range patch {
		f, ok := weightFields[field]
		if !ok {
			return current, nil, fmt.Errorf("%w: %q", ErrUnknownField, field)
		}
		if v < -weightBound || v > weightBound {
			return current, nil, fmt.Errorf("%w: %s = %v (|v| must be <= %v)",
				ErrOutOfRange, field, v, weightBound)
		}
		if f.nonNegative && v < 0 {
			return current, nil, fmt.Errorf("%w: %s = %v (must be >= 0)",
				ErrOutOfRange, field, v)
		}
		old := f.get(next)
		if old == v {
			continue
		}
		f.set(&next, v)
		changes = append(changes, fieldChange{Field: field, Old: old, New: v})
	}
	return next, changes, nil
}

// Taste tuning bounds. The half-life window is generous — from "taste
// is last week" to "taste is a decade" — and the curve points must
// stay ordered inside [0, 1] or the engagement ramps degenerate.
const (
	tasteHalfLifeMin = 1.0
	tasteHalfLifeMax = 3650.0
)

// applyTastePatch validates and applies a partial taste update. The
// curve-ordering invariant (hard_skip < neutral < full) is checked on
// the PATCHED result, so a patch may move several points at once.
func applyTastePatch(current TasteTuning, patch map[string]float64) (TasteTuning, []fieldChange, error) {
	next := current
	var changes []fieldChange
	for field, v := range patch {
		var target *float64
		switch field {
		case "half_life_days":
			if v < tasteHalfLifeMin || v > tasteHalfLifeMax {
				return current, nil, fmt.Errorf("%w: %s = %v (must be in [%v, %v])",
					ErrOutOfRange, field, v, tasteHalfLifeMin, tasteHalfLifeMax)
			}
			target = &next.HalfLifeDays
		case "engagement_hard_skip":
			target = &next.EngagementHardSkip
		case "engagement_neutral":
			target = &next.EngagementNeutral
		case "engagement_full":
			target = &next.EngagementFull
		case "enriched_tag_scale":
			target = &next.EnrichedTagScale
		case "era_scale":
			target = &next.EraScale
		case "mood_scale":
			target = &next.MoodScale
		default:
			return current, nil, fmt.Errorf("%w: %q", ErrUnknownField, field)
		}
		if field != "half_life_days" && (v < 0 || v > 1) {
			return current, nil, fmt.Errorf("%w: %s = %v (must be in [0, 1])",
				ErrOutOfRange, field, v)
		}
		if *target == v {
			continue
		}
		changes = append(changes, fieldChange{Field: field, Old: *target, New: v})
		*target = v
	}
	if !(next.EngagementHardSkip < next.EngagementNeutral &&
		next.EngagementNeutral < next.EngagementFull) {
		return current, nil, fmt.Errorf(
			"%w: engagement curve must satisfy hard_skip < neutral < full (got %v < %v < %v)",
			ErrOutOfRange, next.EngagementHardSkip, next.EngagementNeutral, next.EngagementFull)
	}
	return next, changes, nil
}

// Discover tuning bounds.
const (
	// A tag-overlap weight above this stops being a boost and becomes the
	// ranking — at 10, a perfect match multiplies similarity by 11, which lets
	// tag agreement swamp the similarity signal entirely. The bound is for
	// typos, not to constrain exploration; the multiplicative blend keeps even
	// the maximum from reordering an untagged candidate.
	tagOverlapWeightMax = 10.0
	// Snooze duration: at least a day (anything less isn't a snooze, it's a
	// flicker), at most a year — past that it's a permanent dismissal wearing a
	// snooze's clothes, which is exactly the shape rule #101 rules out.
	snoozeDaysMin = 1.0
	snoozeDaysMax = 365.0
)

// applyDiscoverPatch validates and applies a partial Discover update.
func applyDiscoverPatch(
	current DiscoverTuning, patch map[string]float64,
) (DiscoverTuning, []fieldChange, error) {
	next := current
	var changes []fieldChange
	for field, v := range patch {
		var target *float64
		switch field {
		case "tag_overlap_weight":
			if v < 0 || v > tagOverlapWeightMax {
				return current, nil, fmt.Errorf("%w: %s = %v (must be in [0, %v])",
					ErrOutOfRange, field, v, tagOverlapWeightMax)
			}
			target = &next.TagOverlapWeight
		case "snooze_days":
			if v < snoozeDaysMin || v > snoozeDaysMax {
				return current, nil, fmt.Errorf("%w: %s = %v (must be in [%v, %v])",
					ErrOutOfRange, field, v, snoozeDaysMin, snoozeDaysMax)
			}
			target = &next.SnoozeDays
		default:
			return current, nil, fmt.Errorf("%w: %q", ErrUnknownField, field)
		}
		if *target == v {
			continue
		}
		changes = append(changes, fieldChange{Field: field, Old: *target, New: v})
		*target = v
	}
	return next, changes, nil
}

// diffDiscover returns per-field changes from a to b (empty when equal).
func diffDiscover(a, b DiscoverTuning) []fieldChange {
	var out []fieldChange
	if a.TagOverlapWeight != b.TagOverlapWeight {
		out = append(out, fieldChange{
			Field: "tag_overlap_weight", Old: a.TagOverlapWeight, New: b.TagOverlapWeight,
		})
	}
	if a.SnoozeDays != b.SnoozeDays {
		out = append(out, fieldChange{
			Field: "snooze_days", Old: a.SnoozeDays, New: b.SnoozeDays,
		})
	}
	return out
}

// diffWeights returns per-field changes from a to b (empty when equal).
func diffWeights(a, b recommendation.ScoringWeights) []fieldChange {
	var out []fieldChange
	for field, f := range weightFields {
		if f.get(a) != f.get(b) {
			out = append(out, fieldChange{Field: field, Old: f.get(a), New: f.get(b)})
		}
	}
	return out
}

// diffTaste returns per-field changes from a to b (empty when equal).
func diffTaste(a, b TasteTuning) []fieldChange {
	var out []fieldChange
	add := func(field string, oldV, newV float64) {
		if oldV != newV {
			out = append(out, fieldChange{Field: field, Old: oldV, New: newV})
		}
	}
	add("half_life_days", a.HalfLifeDays, b.HalfLifeDays)
	add("engagement_hard_skip", a.EngagementHardSkip, b.EngagementHardSkip)
	add("engagement_neutral", a.EngagementNeutral, b.EngagementNeutral)
	add("engagement_full", a.EngagementFull, b.EngagementFull)
	add("enriched_tag_scale", a.EnrichedTagScale, b.EnrichedTagScale)
	add("era_scale", a.EraScale, b.EraScale)
	add("mood_scale", a.MoodScale, b.MoodScale)
	return out
}
