// Package loop is maven's proactive trigger engine.
//
// Loop contract (from spec):
//
//   - ticks ~60s. no llm. 99% of ticks evaluate a few predicates and die for free.
//   - a predicate is `(State) -> Bool`, PURE, no i/o → unit-testable with a fake State.
//   - since(key)==null → don't fire. silence on no-data = "shuts up when uncertain".
//   - the GATE is universal, applied by the loop, never per-rule. quiet-hours,
//     presence, cooldown, snooze, calendar-busy all live in ONE fires(). cross-
//     cutting restraint in one place or it drifts.
//   - ONE nudge per tick (max severity), never dogpile.
//   - rules as code, not a DSL. revisit at ~30 rules.
//
// Reminders are a SEPARATE class — reuses the loop, NOT a second scheduler:
//   - predicate: `fire_ts <= now AND pending`
//   - BYPASSES the restraint gate — "wake me 7" fires in quiet hours; that's the point
//   - snooze still applies; fires once (pending → fired)
//
// Architecture: the Gatherer is the only impure bit — it builds a State snapshot
// under the store lock. Everything from there on is PURE functions over State.
// Phrasing (the llm lane) and delivery are out of scope here — the loop emits
// Decisions; the daemon wires them to phrasing + delivery.
package loop

import (
	"sort"
	"strings"
	"time"

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

// Severity — higher = more insistent (harder to suppress).
//
// Per the spec's delivery table:
//
//	sev1–2: care nudges (water, meal, break). voice when present; DROP on away.
//	sev3:   ops soft (backup failed, cert soon). voice + once over ntfy when away.
//	sev4:   ops hard (disk critical, service down). voice + ntfy present;
//	        telegram, repeat til ack, when away.
//
// "Max severity" in one-nudge-per-tick therefore means: the loudest/most insistent
// candidate wins — a disk-full nudge (sev4) preempts a water nudge (sev1).
type Severity int

const (
	Sev1 Severity = 1 // care, lowest insistence — drops on away
	Sev2 Severity = 2
	Sev3 Severity = 3 // ops soft
	Sev4 Severity = 4 // ops hard, highest insistence
)

func (s Severity) IsCare() bool { return s <= Sev2 } // suppressed by quiet hours + away

// State — the loop's view of the world, gathered under the store lock at the
// start of each tick. From here on everything is pure — predicates and the gate
// read ONLY this struct and never touch the store.
//
// Keys in Facts/LastNudge/SnoozeUntil/CooldownUntil are rule-specific lookups
// the rules + gate have pre-arranged. Presence/PresenceScore/Now are global.
// The Gatherer decides what to populate; rules see what's in the snapshot.
type State struct {
	Now           time.Time
	Presence      store.Bucket
	PresenceScore float64

	// Latest non-voided fact per key the loop requested at gather time.
	// Missing key (or zero Value Fact with Ts.IsZero) ⇒ since(key)==null ⇒ don't fire.
	Facts map[string]store.Fact

	// Last nudge per rule, for cooldown enforcement (newest send).
	LastNudge map[string]store.Nudge

	// Per-rule snooze-until — explicit "don't bother me about this rule until X".
	// Overlays on top of cooldown; both must be clear to fire.
	SnoozeUntil map[string]time.Time

	// Per-rule cooldown-until — derived from LastNudge + cooldown duration
	// (the auto-tuner adjusts the duration via feedback outcomes).
	CooldownUntil map[string]time.Time

	// Cross-cutting env flags derived from facts at gather time:
	// QuietHours — the care gate suppresses sev1–2 when true. ops still surface.
	QuietHours bool
	// CalendarBusy — "don't nag mid-meeting". acts as an env predicate in the gate.
	CalendarBusy bool
}

// Fact returns the latest non-voided fact for key, or
// (store.Fact{}, false) — the predicate's "no data" case.
// since(key)==null → don't fire is implemented by checking the bool.
func (s State) Fact(key string) (store.Fact, bool) {
	f, ok := s.Facts[key]
	if !ok || f.Ts.IsZero() {
		return store.Fact{}, false
	}
	return f, true
}

// FactsUnder returns every gathered fact whose key starts with prefix, ordered
// by key so a caller that names them speaks them in a stable order. Facts with
// a zero Ts are skipped, the same "no data" rule Fact applies.
func (s State) FactsUnder(prefix string) []store.Fact {
	var out []store.Fact
	for k, f := range s.Facts {
		if strings.HasPrefix(k, prefix) && !f.Ts.IsZero() {
			out = append(out, f)
		}
	}
	sort.Slice(out, func(i, j int) bool { return out[i].Key < out[j].Key })
	return out
}

// NudgedSince reports whether rule already sent a nudge at or after ts.
//
// It is what makes a rule edge-triggered. A polled fact is written only when
// the value changes, so its Ts is the moment the service went down — but the
// predicate reads the current value, so a service that stays down keeps
// qualifying forever and cooldown alone only slows the repetition. Asking
// whether he was already told about THIS transition stops it.
func (s State) NudgedSince(rule string, ts time.Time) bool {
	n, ok := s.LastNudge[rule]
	return ok && !n.Ts.Before(ts)
}

// Since returns the duration since the latest fact for key, or (0,false).
// "false" ⇒ no data ⇒ shuts up when uncertain.
func (s State) Since(key string) (time.Duration, bool) {
	f, ok := s.Fact(key)
	if !ok {
		return 0, false
	}
	if s.Now.Before(f.Ts) {
		return 0, true
	}
	return s.Now.Sub(f.Ts), true
}
