package loop

import (
	"fmt"
	"time"

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

// Gate — 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.
//
// The gate does NOT itself decide "should this rule run" — the Rule.Predicate
// does. The gate answers "is it ALLOWED to fire right NOW" given the snapshot.
// Suppression-context ("don't nag mid-meeting") moves INTO the gate as an env
// predicate, not the LLM's job — same boundary as "rules decide, llm phrases."
//
// Gate is pure. no I/O. reads State + Rule only.
func Gate(s State, r Rule) bool {
	now := s.Now

	// snooze — per-rule "leave me alone until X". overrides everything below.
	if until, ok := s.SnoozeUntil[r.Name]; ok && now.Before(until) {
		return false
	}

	// cooldown — most recent same-rule nudge + base/feedback-tuned duration.
	// Persisted as `facts (source=feedback)`; Gatherer rolls it into CooldownUntil.
	if until, ok := s.CooldownUntil[r.Name]; ok && now.Before(until) {
		return false
	}

	// quiet hours — care nudges (sev1–2) shut up. ops (sev ≥3) still surface
	// (a failed backup at 2am genuinely matters and maven routes to telegram).
	if s.QuietHours && r.Severity.IsCare() {
		return false
	}

	// calendar busy — "don't nag mid-meeting" lifted INTO the gate as an env
	// predicate; not the LLM's call.
	if s.CalendarBusy && r.Severity.IsCare() {
		return false
	}

	// presence — sev1–2 DROP on away (missed water nudge is noise).
	// sev ≥3 HOLDS — see the delivery channel-routing table in the spec.
	// (the loop doesn't pick the channel; it just decides whether to emit.)
	if s.Presence == store.Away && r.Severity.IsCare() {
		return false
	}

	// no-data inertness — since(key)==null → don't fire. shut up when uncertain.
	// The predicate MAY have encoded this itself; the gate enforces it for any
	// rule that declared InertWhenNoData keys.
	for _, k := range r.InertWhenNoData {
		if _, ok := s.Fact(k); !ok {
			return false
		}
	}

	return true
}

// Candidate — a rule that Wants (predicate true) AND Is Allowed (gate true).
// The loop picks one per tick (max severity).
type Candidate struct {
	Rule     Rule
	Severity Severity
	State    State // snapshot at evaluation time — for the phraser's context
}

// Tick — PURE. Evaluates the configured rules against the snapshot, returns
// AT MOST one proactive candidate (max severity, with a deterministic tie-break).
// Returns nil when nothing fires ("shuts up" is the default outcome of a tick).
//
// Phrasing + sending happen OUT of the loop — the daemon hands Candidate to
// the phraser (the resident model, Qwen3-1.7B) and delivery module. The loop
// just decides.
//
// Reminders are NOT handled here — they're a separate, gate-bypassing class.
// See DueReminders (gathered separately) and RemindDecisions (the loop output
// flag for the daemon).
func Tick(s State, rules []Rule) *Candidate {
	var fire *Candidate
	for _, r := range rules {
		if !r.Predicate(s) {
			continue // rule doesn't want to fire — skip gate entirely (cheap path)
		}
		if !Gate(s, r) {
			continue // wanted but suppressed this tick
		}
		c := Candidate{Rule: r, Severity: r.Severity, State: s}
		if fire == nil {
			fire = &c
			continue
		}
		// max severity wins; tie-break: severity desc, then name asc for determinism.
		if c.Severity > fire.Severity ||
			(c.Severity == fire.Severity && c.Rule.Name < fire.Rule.Name) {
			fire = &c
		}
	}
	return fire
}

// DigestEligible decides digest-vs-drop for a care candidate the gate
// suppressed this tick (see ExplainGate's blockedBy). Pure — no I/O, no
// state, just the two facts that matter: why it was suppressed, and how
// insistent it was.
//
// Only genuine RESTRAINT blocks are eligible at all — quiet_hours,
// calendar_busy, presence(away). cooldown and snooze are not suppression in
// this sense: cooldown means "you already heard this recently" (resurfacing
// it later would be an actual repeat, not a rescue) and snooze is the user
// explicitly saying "not this" (digesting it anyway would defeat the ask).
// Ops severities (Sev3/4) never reach here — the gate never blocks them for
// these reasons in the first place (see Gate), and even if a future rule
// dropped Sev3+ into "care", digest still refuses them: alarms bypass the
// gate on purpose and must never be silently delayed into a bundle.
//
// Within care (Sev1–2), the boundary is severity itself: Sev1 (water, meal —
// biological timers with no "still relevant later" property; a water nudge
// from 3 hours into quiet hours is just wrong by morning) drops. Sev2
// (break — "you worked through a long stretch without a break while I
// couldn't reach you") is information that stays true and useful after the
// fact, so it digests.
func DigestEligible(sev Severity, blockedBy string) bool {
	switch blockedBy {
	case "quiet_hours", "calendar_busy", "presence":
	default:
		return false
	}
	return sev == Sev2
}

// ReminderDecision — a due reminder the daemon should deliver now.
// NOT gated by the universal Gate (per spec: "wake me 7" fires in quiet hours;
// that's the point). Snooze is the one part of restraint that still applies.
type ReminderDecision struct {
	Reminder store.Reminder
	State    State
}

// ReminderSnoozeKey — the SnoozeUntil key that holds back every due reminder.
// Reminders have no rule name, so they share one key. A snooze aimed at a
// single reminder uses ReminderSnoozeKeyFor instead.
const ReminderSnoozeKey = "reminder"

// ReminderSnoozeKeyFor — the SnoozeUntil key for one reminder by id.
func ReminderSnoozeKeyFor(id int64) string {
	return fmt.Sprintf("%s:%d", ReminderSnoozeKey, id)
}

// RemindDecisions — returns the due reminders the daemon should deliver.
// Pure: accepts an already-filtered (due) list. The Gatherer produces that
// list from `fire_ts <= now AND pending`.
//
// Quiet hours, presence and cooldown are deliberately NOT consulted — a
// reminder must wake you at 7 even in the middle of quiet hours. Only snooze
// holds one back. A held reminder stays pending, so it comes back once the
// snooze runs out.
func RemindDecisions(s State, due []store.Reminder) []ReminderDecision {
	out := make([]ReminderDecision, 0, len(due))
	for _, r := range due {
		if reminderSnoozed(s, r) {
			continue
		}
		out = append(out, ReminderDecision{Reminder: r, State: s})
	}
	return out
}

// reminderSnoozed — true when a snooze on this reminder, or on reminders as a
// class, is still running.
func reminderSnoozed(s State, r store.Reminder) bool {
	keys := []string{ReminderSnoozeKey, ReminderSnoozeKeyFor(r.ID)}
	for _, k := range keys {
		if until, ok := s.SnoozeUntil[k]; ok && s.Now.Before(until) {
			return true
		}
	}
	return false
}

// CooldownFor — helper for the Gatherer: given the active cooldown base
// (the rule's static Base, OR the feedback tuner's persisted tuning) and the
// last send ts, compute the wall-clock "cooldown-until" the gate will check.
// Pure. The auto-tuner writes the base the gatherer reads as a feedback
// fact; this function just adds it to the last send.
func CooldownFor(base time.Duration, lastSend time.Time) time.Time {
	if lastSend.IsZero() {
		return time.Time{} // never sent → no cooldown active
	}
	return lastSend.Add(base)
}
