// loop/feedback.go — the cooldown auto-tuner: PURE math over resolved nudge
// outcomes.
//
// The feedback loop is data-flow-only at MVP — the daemon reads
// store.RecentOutcomes for a rule, calls TuneCooldown, writes the result back
// as a `facts (kind=config, source=feedback, key=cooldown:<rule>)` row. the
// Gatherer reads that row on the next tick and uses it as the active
// cooldown base (instead of the rule's static Base). misrouted tuning =
// constrained by the rule's Cooldown envelope (Min/Max), so a weird week
// can't mutate maven silent or stalker (per spec).
//
// All math here is PURE — no I/O. The daemon is the impure bit. Unit-testable
// with a fake Rule + outcomes slice.
package loop

import (
	"encoding/json"
	"time"

	"github.com/kami/maven/internal/store"
)

// FeedbackSource — the provenance fixed string for auto-tuned cooldown facts.
// Matches the `Source` enumeration in store.Fact ("feedback"); named here so
// the gatherer + daemon reference the same string and a typo can't slip.
const FeedbackSource = "feedback"

// FeedbackKey — the config-fact key a rule's tuned cooldown lands under.
// `cooldown:<rule>` keeps it namespaced off any predicate-read key (`water`,
// `meal`, ...) so the tuner can't collide with a rule's own substrate. the
// gatherer reads it back via LatestFactBySource(source=feedback) — the same
// trust-by-provenance shape as ServiceDownRule's poll:healthcheck read (a
// module that doesn't own the `feedback` source can't poison a rule's
// cooldown once the auth source-scope lands).
func FeedbackKey(r Rule) string { return "cooldown:" + r.Name }

// Tuner cadence + window. Named constants (not config) because they pin the
// *shape* of the feedback loop, not its schedule; the cadence is daemon-config
// (config.AutotuneInterval), the window is the-loop's own.
const (
	// TuneSampleN — how many recent resolved outcomes the tuner looks at.
	// Small enough to react to a real change of pattern inside a day; large
	// enough that one weird afternoon can't whipsaw the cooldown.
	TuneSampleN = 8

	// TuneMinOutcomes — below this many resolved outcomes there's NOT enough
	// signal to tune; leave Base alone. cold boot + sparse rules don't get
	// a dive on first sight.
	TuneMinOutcomes = 4
)

// TuneCooldown — PURE. dead-simple ratio over the last N resolved outcomes:
//
//	mostly ignored → lengthen (the nudge is noise at this cadence)
//	mostly acted   → shorten (the nudge is load-bearing; ring sooner)
//	snoozed is neutral — the user reacted, just deferred; treating it as
//	  ignored would over-lengthen, as acted would over-shorten. mvp
//	  neutrality > picking the wrong direction.
//
// Math: newBase = base × (1 + α·(ignoredRate − actedRate)).
// α=0.5 keeps the step tame: a fully-ignored week grows ~50%; fully-acted
// shrinks ~50%. Clamped to the rule's envelope (Min/Max) — the auto-tuner can
// never push the cooldown beyond the rule's designed bounds, so wrong math
// or a poisoned signal can't make maven silent (Min) or a stalker (Max).
//
// Empty outcomes ⇒ return Base unchanged (cold boot; sparse rule ⇒ no data,
// shut up when uncertain — same instinct as the gate's InertWhenNoData).
func TuneCooldown(r Rule, outcomes []string) time.Duration {
	base := r.Cooldown.Base
	if len(outcomes) == 0 {
		return base
	}
	var acted, ignored int
	for _, o := range outcomes {
		switch o {
		case store.NudgeActed:
			acted++
		case store.NudgeIgnored:
			ignored++
			// store.NudgeSnoozed: deliberately neutral (see comment).
		}
	}
	n := float64(len(outcomes))
	const alpha = 0.5
	factor := 1 + alpha*(float64(ignored)/n-float64(acted)/n)
	tuned := time.Duration(float64(base) * factor)
	if tuned < r.Cooldown.Min {
		tuned = r.Cooldown.Min
	}
	if tuned > r.Cooldown.Max {
		tuned = r.Cooldown.Max
	}
	return tuned
}

// ParseCooldownFact — read a feedback fact (from FeedbackKey) back into the
// active base duration. Returns (dur, false) when the row is missing, zero,
// stale (source mismatch), or malformed — the gatherer falls back to the
// rule's static Base in that case (a bad feedback row must NOT crash the
// loop; same shut-up instinct as the gate's missing-key path).
func ParseCooldownFact(f store.Fact) (time.Duration, bool) {
	if f.Ts.IsZero() || f.Source != FeedbackSource {
		return 0, false
	}
	var ns int64
	if err := json.Unmarshal([]byte(f.Value), &ns); err != nil {
		return 0, false
	}
	if ns <= 0 {
		return 0, false
	}
	return time.Duration(ns), true
}

// MarshalCooldown — inverse of ParseCooldownFact. Produces the JSON value the
// daemon hands to store.SetValue for a tuned cooldown (a nanosecond int64 —
// time.Duration's native JSON encoding). Callee owns the shape so a future
// richer payload (e.g. `{"base":..,"reason":"ignored"}`) is a one-place change.
func MarshalCooldown(d time.Duration) string {
	b, _ := json.Marshal(int64(d)) // int64 marshal never errors
	return string(b)
}
