package loop

import (
	"context"
	"testing"
	"time"

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

// Tests for the universal restraint gate.
//
// docs/design.md § Trigger model: "the gate is universal, applied by the loop, never
// per-rule — quiet-hours, presence, cooldown, snooze, calendar-busy all live in
// one fires()." These tests pin the CONSERVATIVE side of that: the cases where
// Maven must stay quiet. They exist so nobody loosens the gate by accident.
//
// Where the code does not yet do what docs/design.md promises, the test is written to
// show the gap and then skipped, with the file and line to fix. Behaviour is not
// changed to make a test pass.

// testRule — a rule at the given severity that always wants to fire, so the
// only thing under test is the gate.
func testRule(name string, sev Severity) Rule {
	return Rule{
		Name:      name,
		Severity:  sev,
		Cooldown:  Cooldown{Base: 30 * time.Minute, Min: time.Minute, Max: time.Hour},
		Predicate: func(State) bool { return true },
	}
}

// ---------------------------- quiet hours ------------------------------------

// Quiet hours silence care and leave ops alone. A failed backup at 2am matters;
// a water nudge at 2am does not.
func TestGateQuietHoursSuppressesCareOnly(t *testing.T) {
	cases := []struct {
		sev  Severity
		want bool
	}{
		{Sev1, false},
		{Sev2, false},
		{Sev3, true},
		{Sev4, true},
	}
	for _, c := range cases {
		s := State{Now: refTime(), Presence: store.Present, QuietHours: true}
		if got := Gate(s, testRule("r", c.sev)); got != c.want {
			t.Errorf("quiet hours sev%d: want fire=%v, got %v", c.sev, c.want, got)
		}
	}
}

// ---------------------------- presence ---------------------------------------

// docs/design.md § Delivery: "sev <= 2 drops on away, sev >= 3 holds: a missed water
// nudge is noise, a missed backup failure isn't."
func TestGateAwayDropsCareHoldsOps(t *testing.T) {
	cases := []struct {
		sev  Severity
		want bool
	}{
		{Sev1, false},
		{Sev2, false},
		{Sev3, true},
		{Sev4, true},
	}
	for _, c := range cases {
		s := State{Now: refTime(), Presence: store.Away}
		if got := Gate(s, testRule("r", c.sev)); got != c.want {
			t.Errorf("away sev%d: want fire=%v, got %v", c.sev, c.want, got)
		}
	}
}

// Care nudges are allowed through when the user is actually there and nothing
// else is suppressing. Without this the "quiet" tests above could pass on a
// gate that simply never fires.
func TestGateAllowsCareWhenPresentAndClear(t *testing.T) {
	s := State{Now: refTime(), Presence: store.Present}
	if !Gate(s, testRule("r", Sev1)) {
		t.Fatal("present and clear: care nudge should be allowed")
	}
}

// ---------------------------- calendar busy ----------------------------------

// "Don't nag mid-meeting" is an env predicate in the gate, not the LLM's call.
// Ops still gets through — a service being down mid-meeting is worth the
// interruption.
func TestGateCalendarBusySuppressesCareOnly(t *testing.T) {
	care := State{Now: refTime(), Presence: store.Present, CalendarBusy: true}
	if Gate(care, testRule("r", Sev2)) {
		t.Error("calendar busy: care nudge should be suppressed")
	}
	if !Gate(care, testRule("r", Sev4)) {
		t.Error("calendar busy: ops hard should still fire")
	}
}

// ---------------------------- cooldown ---------------------------------------

// Cooldown holds for every severity — it is the anti-nag knob, so ops cannot
// buy its way past it either.
func TestGateCooldownHoldsForAllSeverities(t *testing.T) {
	now := refTime()
	for _, sev := range []Severity{Sev1, Sev2, Sev3, Sev4} {
		s := State{
			Now:           now,
			Presence:      store.Present,
			CooldownUntil: map[string]time.Time{"r": now.Add(10 * time.Minute)},
		}
		if Gate(s, testRule("r", sev)) {
			t.Errorf("cooldown sev%d: should be suppressed", sev)
		}
	}
}

// Cooldown is per-rule: one rule cooling down must not mute another.
func TestGateCooldownIsPerRule(t *testing.T) {
	now := refTime()
	s := State{
		Now:           now,
		Presence:      store.Present,
		CooldownUntil: map[string]time.Time{"water": now.Add(10 * time.Minute)},
	}
	if Gate(s, testRule("water", Sev1)) {
		t.Error("water is cooling down and should be suppressed")
	}
	if !Gate(s, testRule("meal", Sev1)) {
		t.Error("meal has no cooldown and should be allowed")
	}
}

// The moment the cooldown expires the rule is free again — the gate compares
// with Before, so "until" itself is already clear.
func TestGateCooldownExpires(t *testing.T) {
	now := refTime()
	s := State{
		Now:           now,
		Presence:      store.Present,
		CooldownUntil: map[string]time.Time{"r": now},
	}
	if !Gate(s, testRule("r", Sev1)) {
		t.Fatal("cooldown at exactly now should already be clear")
	}
}

// ---------------------------- snooze -----------------------------------------

// Snooze is the user saying "not about this". It beats everything, including
// ops hard.
func TestGateSnoozeHoldsForAllSeverities(t *testing.T) {
	now := refTime()
	for _, sev := range []Severity{Sev1, Sev2, Sev3, Sev4} {
		s := State{
			Now:         now,
			Presence:    store.Present,
			SnoozeUntil: map[string]time.Time{"r": now.Add(time.Hour)},
		}
		if Gate(s, testRule("r", sev)) {
			t.Errorf("snooze sev%d: should be suppressed", sev)
		}
	}
}

// ---------------------------- no-data backstop -------------------------------

// The gate enforces no-data inertness a second time, for any rule that declared
// the keys it needs. A predicate that forgets the check still cannot fire.
func TestGateNoDataBackstopBeatsAnEagerPredicate(t *testing.T) {
	now := refTime()
	eager := Rule{
		Name:            "eager",
		Severity:        Sev4, // even ops hard does not get past missing data
		Predicate:       func(State) bool { return true },
		InertWhenNoData: []string{"water", "meal"},
	}
	// one of the two keys present is not enough.
	s := State{
		Now:      now,
		Presence: store.Present,
		Facts:    map[string]store.Fact{"water": ago("water", "tap:water", `"250ml"`, time.Hour)},
	}
	if Gate(s, eager) {
		t.Fatal("a rule missing one of its keys must stay inert")
	}
}

// ---------------------------- one nudge per tick -----------------------------

// All five default rules want to fire at once. The tick must still emit exactly
// one candidate, the loudest — never a dogpile.
func TestTickNeverDogpilesAndPicksLoudest(t *testing.T) {
	now := refTime()
	s := State{
		Now:      now,
		Presence: store.Present,
		Facts: map[string]store.Fact{
			"water":           ago("water", "tap:water", `"250ml"`, 5*time.Hour),
			"meal":            ago("meal", "voice", `"lunch"`, 8*time.Hour),
			"desk_active":     ago("desk_active", "infer:hyprland", "1", 30*time.Second),
			"break":           ago("break", "voice", `"walk"`, 3*time.Hour),
			"service_down:db": ago("service_down:db", "poll:uptimekuma", `"down"`, 5*time.Minute),
			"netdata_alarm":   ago("netdata_alarm", "poll:netdata", `"critical"`, time.Minute),
		},
	}
	// sanity: every rule really does want to fire, so the pick is a real choice.
	for _, r := range DefaultRules() {
		if !r.Predicate(s) {
			t.Fatalf("setup: rule %q does not want to fire", r.Name)
		}
	}
	got := Tick(s, DefaultRules())
	if got == nil {
		t.Fatal("all rules firing: want one candidate, got nil")
	}
	if got.Rule.Name != "service_down" || got.Severity != Sev4 {
		t.Fatalf("want the loudest (service_down/sev4), got %s/sev%d", got.Rule.Name, got.Severity)
	}
}

// Tick returns a single Candidate by type, so "one per tick" cannot be violated
// by count — what can drift is WHICH one. Equal severities tie-break by name so
// the choice is deterministic across ticks.
func TestTickTieBreaksByNameForDeterminism(t *testing.T) {
	s := State{Now: refTime(), Presence: store.Present}
	rules := []Rule{testRule("zebra", Sev2), testRule("apple", Sev2), testRule("mango", Sev2)}
	for i := 0; i < 5; i++ {
		got := Tick(s, rules)
		if got == nil || got.Rule.Name != "apple" {
			t.Fatalf("tie-break: want apple every time, got %+v", got)
		}
	}
}

// The loudest candidate wins even when the quiet one is listed first.
func TestTickOrderOfRulesDoesNotMatter(t *testing.T) {
	s := State{Now: refTime(), Presence: store.Present}
	first := Tick(s, []Rule{testRule("care", Sev1), testRule("ops", Sev4)})
	second := Tick(s, []Rule{testRule("ops", Sev4), testRule("care", Sev1)})
	if first == nil || second == nil {
		t.Fatal("want a candidate from both orderings")
	}
	if first.Rule.Name != "ops" || second.Rule.Name != "ops" {
		t.Fatalf("order changed the pick: %s then %s", first.Rule.Name, second.Rule.Name)
	}
}

// ---------------------------- reminders bypass the gate ----------------------

// docs/design.md § User reminders: "bypasses the restraint gate — 'wake me 7' fires
// in quiet hours; that's the point." Every suppressor set at once, and the
// reminder still comes through.
func TestRemindersBypassEverySuppressor(t *testing.T) {
	now := refTime()
	s := State{
		Now:           now,
		Presence:      store.Away,
		QuietHours:    true,
		CalendarBusy:  true,
		CooldownUntil: map[string]time.Time{"reminder": now.Add(time.Hour)},
	}
	due := []store.Reminder{{ID: 7, Payload: `{"text":"wake me"}`}}
	got := RemindDecisions(s, due)
	if len(got) != 1 || got[0].Reminder.ID != 7 {
		t.Fatalf("reminder must bypass the gate, got %+v", got)
	}
}

// GAP — docs/design.md § User reminders ends "Snooze still applies." RemindDecisions
// passes every due reminder straight through with no snooze check, so a snoozed
// reminder fires anyway. The test below is what the contract asks for.
func TestRemindersStillHonourSnooze(t *testing.T) {

	now := refTime()
	s := State{
		Now:         now,
		Presence:    store.Present,
		SnoozeUntil: map[string]time.Time{"reminder:7": now.Add(time.Hour)},
	}
	due := []store.Reminder{{ID: 7, Payload: `{"text":"wake me"}`}}
	if got := RemindDecisions(s, due); len(got) != 0 {
		t.Fatalf("snoozed reminder should not be delivered, got %+v", got)
	}
}

// ---------------------------- digest eligibility ------------------------------

// A Sev2 care candidate (break) suppressed for a genuine restraint reason is
// worth resurfacing later.
func TestDigestEligibleSev2SuppressedByRestraint(t *testing.T) {
	for _, reason := range []string{"quiet_hours", "calendar_busy", "presence"} {
		if !DigestEligible(Sev2, reason) {
			t.Errorf("sev2 blocked by %q: want digest-eligible", reason)
		}
	}
}

// A Sev1 care candidate (water/meal) never digests — a biological timer
// nudge is stale by the time anyone could resurface it, so it just drops.
func TestDigestEligibleSev1NeverDigests(t *testing.T) {
	for _, reason := range []string{"quiet_hours", "calendar_busy", "presence"} {
		if DigestEligible(Sev1, reason) {
			t.Errorf("sev1 blocked by %q: want drop, got digest-eligible", reason)
		}
	}
}

// Ops severities are never blocked by these reasons in practice (Gate only
// applies quiet_hours/calendar_busy/presence to care severities), but the
// boundary itself must refuse to digest a high severity even if asked —
// alarms bypass the gate and deliver now, unchanged, never delayed.
func TestDigestEligibleNeverDigestsHighSeverity(t *testing.T) {
	for _, sev := range []Severity{Sev3, Sev4} {
		for _, reason := range []string{"quiet_hours", "calendar_busy", "presence"} {
			if DigestEligible(sev, reason) {
				t.Errorf("sev%d blocked by %q: high severity must never digest", sev, reason)
			}
		}
	}
}

// cooldown and snooze are not "suppression" in the digest sense — cooldown
// means it was already said recently, snooze means the user asked to not
// hear about it. Neither should resurface later just because the severity
// matches.
func TestDigestEligibleExcludesCooldownAndSnooze(t *testing.T) {
	for _, reason := range []string{"cooldown", "snooze", "inert_no_data", "predicate", ""} {
		if DigestEligible(Sev2, reason) {
			t.Errorf("sev2 blocked by %q: should not be digest-eligible", reason)
		}
	}
}

// GAP — the gate reads State.SnoozeUntil, but the Gatherer hard-codes it to nil
// (internal/loop/gather.go:153), so snooze is dead in the running daemon: the
// unit tests above pass while nothing can ever populate the map. This asserts
// the Gatherer actually produces a snooze map.
func TestGathererPopulatesSnoozeUntil(t *testing.T) {

	ctx := context.Background()
	st, err := store.Open(ctx, t.TempDir()+"/m.db")
	if err != nil {
		t.Fatal(err)
	}
	defer st.Close()

	g := NewGatherer(st, DefaultRules())
	snap, _, err := g.GatherState(ctx, refTime())
	if err != nil {
		t.Fatal(err)
	}
	if snap.SnoozeUntil == nil {
		t.Fatal("Gatherer returned a nil SnoozeUntil map")
	}
}
