package loop

import (
	"context"
	"testing"
	"time"

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

func refTime() time.Time { return time.Date(2026, 6, 30, 12, 0, 0, 0, time.UTC) }

func factAt(key, source, value string, ts time.Time) store.Fact {
	return store.Fact{Ts: ts, Key: key, Source: source, Value: value, Confidence: 1.0}
}

// ----------------------------- Tick + Gate -----------------------------------

func TestTickNothingFiresColdBoot(t *testing.T) {
	// cold boot — no facts at all. every rule's InertWhenNoData kicks in; gate
	// returns false; Tick returns nil. "shuts up when uncertain" is the default.
	s := State{Now: refTime(), Presence: store.Away}
	if got := Tick(s, DefaultRules()); got != nil {
		t.Fatalf("cold boot: want nil, got %+v", got)
	}
}

func TestTickWaterFiresWhenThirstyAndPresent(t *testing.T) {
	now := refTime()
	s := State{
		Now:      now,
		Presence: store.Present,
		Facts: map[string]store.Fact{
			"water": factAt("water", "tap:water", `"250ml"`, now.Add(-4*time.Hour)),
		},
	}
	got := Tick(s, DefaultRules())
	if got == nil || got.Rule.Name != "water" {
		t.Fatalf("want water candidate, got %+v", got)
	}
	if got.Severity != Sev1 {
		t.Fatalf("water sev mismatch: %d", got.Severity)
	}
}

func TestTickWaterSuppressedOnAwayCareDrops(t *testing.T) {
	// sev1 (care) → drops on away. spec: "a missed water nudge is noise."
	now := refTime()
	s := State{
		Now:      now,
		Presence: store.Away,
		Facts: map[string]store.Fact{
			"water": factAt("water", "tap:water", `"250ml"`, now.Add(-4*time.Hour)),
		},
	}
	if got := Tick(s, DefaultRules()); got != nil {
		t.Fatalf("care on away: want nil, got %+v", got)
	}
}

func TestTickWaterSuppressedInQuietHours(t *testing.T) {
	now := refTime()
	s := State{
		Now:        now,
		Presence:   store.Present,
		QuietHours: true,
		Facts: map[string]store.Fact{
			"water": factAt("water", "tap:water", `"250ml"`, now.Add(-4*time.Hour)),
		},
	}
	if got := Tick(s, DefaultRules()); got != nil {
		t.Fatalf("care in quiet hours: want nil, got %+v", got)
	}
}

func TestTickOpsHardSurvivesAwayAndQuiet(t *testing.T) {
	// sev4 ops hard — must survive both away AND quiet hours. the gate only
	// suppresses sev≤2 for either flag; sev4 is the disk-fire alarm.
	now := refTime()
	s := State{
		Now:        now,
		Presence:   store.Away,
		QuietHours: true,
		Facts: map[string]store.Fact{
			"service_down:db": factAt("service_down:db", "poll:uptimekuma", `"down"`, now.Add(-5*time.Minute)),
		},
	}
	got := Tick(s, DefaultRules())
	if got == nil || got.Rule.Name != "service_down" || got.Severity != Sev4 {
		t.Fatalf("ops hard survives: want service_down/sev4, got %+v", got)
	}
}

func TestTickServiceSourceTrustRefusesForgedTrigger(t *testing.T) {
	// a non-poll:uptimekuma source recording "down" must NOT fire the ops
	// rule — compromised poller / ambient can't forge a trigger.
	now := refTime()
	s := State{
		Now:      now,
		Presence: store.Present,
		Facts: map[string]store.Fact{
			"service_down:db": factAt("service_down:db", "ambient", `"down"`, now.Add(-5*time.Minute)),
		},
	}
	if got := Tick(s, DefaultRules()); got != nil {
		t.Fatalf("forged source: want nil, got %+v", got)
	}
}

func TestTickOneNudgePerTickMaxSeverityWins(t *testing.T) {
	// both water (sev1) and service_down (sev4) want to fire and clear the gate.
	// max severity wins — disk-fire preempts water. never dogpile.
	now := refTime()
	s := State{
		Now:      now,
		Presence: store.Present,
		Facts: map[string]store.Fact{
			"water":           factAt("water", "tap:water", `"250ml"`, now.Add(-4*time.Hour)),
			"service_down:db": factAt("service_down:db", "poll:uptimekuma", `"down"`, now.Add(-5*time.Minute)),
		},
	}
	got := Tick(s, DefaultRules())
	if got == nil || got.Rule.Name != "service_down" {
		t.Fatalf("max sev wins: want service_down, got %+v", got)
	}
}

func TestTickCooldownSuppresses(t *testing.T) {
	now := refTime()
	// 4h since water (would fire) — but cooldown until now+10min. Suppressed.
	s := State{
		Now:      now,
		Presence: store.Present,
		Facts: map[string]store.Fact{
			"water": factAt("water", "tap:water", `"250ml"`, now.Add(-4*time.Hour)),
		},
		CooldownUntil: map[string]time.Time{
			"water": now.Add(10 * time.Minute),
		},
	}
	if got := Tick(s, DefaultRules()); got != nil {
		t.Fatalf("cooldown: want nil, got %+v", got)
	}
}

func TestTickSnoozeSuppressesBeforeCooldown(t *testing.T) {
	now := refTime()
	s := State{
		Now:      now,
		Presence: store.Present,
		Facts: map[string]store.Fact{
			"water": factAt("water", "tap:water", `"250ml"`, now.Add(-4*time.Hour)),
		},
		SnoozeUntil: map[string]time.Time{
			"water": now.Add(time.Hour),
		},
	}
	if got := Tick(s, DefaultRules()); got != nil {
		t.Fatalf("snooze: want nil, got %+v", got)
	}
}

func TestTickCalendarBusySuppressesCare(t *testing.T) {
	// "don't nag mid-meeting" lives in the gate as an env predicate.
	now := refTime()
	s := State{
		Now:          now,
		Presence:     store.Present,
		CalendarBusy: true,
		Facts: map[string]store.Fact{
			"water": factAt("water", "tap:water", `"250ml"`, now.Add(-4*time.Hour)),
		},
	}
	if got := Tick(s, DefaultRules()); got != nil {
		t.Fatalf("busy care: want nil, got %+v", got)
	}
}

func TestTickPredicateFalseSkipsGateEntirely(t *testing.T) {
	// water since lastFact < 3h ⇒ predicate false ⇒ not a candidate at all,
	// regardless of any gate state. cheap path; gate never consulted.
	now := refTime()
	s := State{
		Now:      now,
		Presence: store.Present,
		Facts: map[string]store.Fact{
			"water": factAt("water", "tap:water", `"250ml"`, now.Add(-30*time.Minute)),
		},
	}
	if got := Tick(s, DefaultRules()); got != nil {
		t.Fatalf("predicate false: want nil, got %+v", got)
	}
}

func TestGateNoDataInertShutsUp(t *testing.T) {
	// predicate true (carelessly), but rule declared InertWhenNoData and key
	// is missing in the snapshot — gate must still return false.
	s := State{Now: refTime(), Presence: store.Present}
	iwantfire := Rule{
		Name:            "x",
		Severity:        Sev1,
		Predicate:       func(State) bool { return true },
		InertWhenNoData: []string{"missing_key"},
	}
	if Gate(s, iwantfire) {
		t.Fatalf("no-data rule should be inert, got fire")
	}
}

func TestRemindDecisionsDoesNotGate(t *testing.T) {
	// reminders bypass restraint — they pass through untouched even in quiet,
	// away, etc. this is the documented two-delivery-path split.
	now := refTime()
	s := State{Now: now, Presence: store.Away, QuietHours: true, CalendarBusy: true}
	due := []store.Reminder{{ID: 1, Payload: `{"text":"wake me"}`}}
	got := RemindDecisions(s, due)
	if len(got) != 1 || got[0].Reminder.ID != 1 {
		t.Fatalf("reminders must bypass gate, got %+v", got)
	}
}

// ----------------------------- Gatherer + real store -------------------------

func TestGathererEndToEndWaterFires(t *testing.T) {
	path := t.TempDir() + "/m.db"
	st, err := store.Open(context.Background(), path)
	if err != nil {
		t.Fatal(err)
	}
	defer st.Close()

	ctx := context.Background()
	// write a water fact 4h ago — drop into the past by direct INSERT.
	now := refTime()
	if _, err := st.SetValue(ctx, store.KindSelf, "water", "tap:water", "250ml", now.Add(-4*time.Hour)); err != nil {
		t.Fatal(err)
	}
	// (no `break` fact seeded — BreakRule's InertWhenNoData keeps it inert,
	// so water is the only care candidate.)
	// silence env flags so they don't accidentally suppress.
	if _, err := st.SetValue(ctx, store.KindConfig, "quiet_hours", "promote", "false", now); err != nil {
		t.Fatal(err)
	}
	if _, err := st.SetValue(ctx, store.KindConfig, "calendar_busy", "promote", "false", now); err != nil {
		t.Fatal(err)
	}

	g := NewGatherer(st, DefaultRules())
	snap, due, err := g.GatherState(ctx, now)
	if err != nil {
		t.Fatalf("GatherState: %v", err)
	}
	if len(due) != 0 {
		t.Fatalf("no reminders due, got %d", len(due))
	}
	// cold-boot presence (no signal facts) ⇒ away ⇒ care should drop.
	if snap.Presence != store.Away {
		t.Fatalf("cold presence: want Away, got %s (score %f)", snap.Presence, snap.PresenceScore)
	}
	// water predicate true but presence away ⇒ gate suppresses care ⇒ nil.
	if got := Tick(snap, DefaultRules()); got != nil {
		t.Fatalf("away cold presence should suppress care: want nil, got %+v", got)
	}

	// now create a fresh desk_active signal so presence flips to present.
	if _, err := st.SetValue(ctx, store.KindSelf, "desk_active", "infer:hyprland", "1", now.Add(-1*time.Second)); err != nil {
		t.Fatal(err)
	}
	snap2, _, err := g.GatherState(ctx, now)
	if err != nil {
		t.Fatal(err)
	}
	if snap2.Presence != store.Present {
		t.Fatalf("with desk signal: want Present, got %s (score %f)", snap2.Presence, snap2.PresenceScore)
	}
	got := Tick(snap2, DefaultRules())
	if got == nil || got.Rule.Name != "water" {
		t.Fatalf("present + thirsty should fire water, got %+v", got)
	}
}

// TestGathererUsesFeedbackTunedCooldown — write a `cooldown:water` feedback
// fact, then verify the gatherer's CooldownUntil for the rule uses the tuned
// base (last nudge ts + tuned base), not the rule's static Base. This is the
// end-to-end shape of the feedback loop: tuner writes fact → gatherer reads
// it next tick → gate consults the adjusted CooldownUntil.
func TestGathererUsesFeedbackTunedCooldown(t *testing.T) {
	path := t.TempDir() + "/m.db"
	st, err := store.Open(context.Background(), path)
	if err != nil {
		t.Fatal(err)
	}
	defer st.Close()

	ctx := context.Background()
	now := refTime()

	// seed water + a presence signal so the rule has a snapshot worth gating
	// against (we're not asserting Tick here — just the CooldownUntil field).
	if _, err := st.SetValue(ctx, store.KindSelf, "water", "tap:water", `"250ml"`, now.Add(-4*time.Hour)); err != nil {
		t.Fatal(err)
	}
	if _, err := st.SetValue(ctx, store.KindSelf, "desk_active", "infer:hyprland", "1", now.Add(-1*time.Second)); err != nil {
		t.Fatal(err)
	}
	if _, err := st.SetValue(ctx, store.KindConfig, "quiet_hours", "promote", "false", now); err != nil {
		t.Fatal(err)
	}
	if _, err := st.SetValue(ctx, store.KindConfig, "calendar_busy", "promote", "false", now); err != nil {
		t.Fatal(err)
	}

	// send a water nudge 10m ago so cooldown-until is lastSend+base.
	sendTs := now.Add(-10 * time.Minute)
	nudgeID, err := st.RecordNudge(ctx, "water", "voice", "drink water", sendTs)
	if err != nil {
		t.Fatal(err)
	}
	// leave it pending so LastNudge still surfaces it as the most recent send.

	g := NewGatherer(st, DefaultRules())

	// baseline: rule's static Base (30m) → CooldownUntil == sendTs + 30m.
	snap1, _, err := g.GatherState(ctx, now)
	if err != nil {
		t.Fatalf("gather baseline: %v", err)
	}
	wantStatic := sendTs.Add(30 * time.Minute)
	if got := snap1.CooldownUntil["water"]; got != wantStatic {
		t.Fatalf("baseline cooldown-until: want %v, got %v", wantStatic, got)
	}

	// the feedback tuner writes a tuned base as facts(kind=config,
	// source=feedback, key=cooldown:water). simulate the daemon: write a
	// 5h tuned cooldown fact.
	tuned := 5 * time.Hour
	water := WaterRule()
	if _, err := st.SetValue(ctx, store.KindConfig, FeedbackKey(water), FeedbackSource, tuned, now); err != nil {
		t.Fatal(err)
	}

	snap2, _, err := g.GatherState(ctx, now)
	if err != nil {
		t.Fatalf("gather tuned: %v", err)
	}
	wantTuned := sendTs.Add(tuned)
	if got := snap2.CooldownUntil["water"]; got != wantTuned {
		t.Fatalf("tuned cooldown-until: want %v, got %v", wantTuned, got)
	}

	// a poisoned row from a non-feedback source must be ignored (trust by
	// provenance — gatherer reads LatestFactBySource(source=feedback), so a
	// tap:water row at the same key doesn't reach the cooldown).
	if _, err := st.SetValue(ctx, store.KindConfig, FeedbackKey(water), "tap:water", 1*time.Minute, now); err != nil {
		t.Fatal(err)
	}
	snap3, _, err := g.GatherState(ctx, now)
	if err != nil {
		t.Fatalf("gather poisoned: %v", err)
	}
	// LatestFactBySource returns the latest NON-voided feedback row, which is
	// still the 5h one we wrote — the tap:water row is invisible to this read.
	if got := snap3.CooldownUntil["water"]; got != wantTuned {
		t.Fatalf("poisoned row leaked: want %v, got %v", wantTuned, got)
	}

	// sanity: the water nudge row is still findable (the gatherer's LastNudge
	// + gate's cooldown consult it).
	n, err := st.LastNudge(ctx, "water")
	if err != nil || n.ID != nudgeID {
		t.Fatalf("LastNudge: want id %d, got %+v err=%v", nudgeID, n, err)
	}

	// sanity: the feedback fact lookup round-trips via ParseCooldownFact too.
	fb, err := st.LatestFactBySource(ctx, FeedbackKey(water), FeedbackSource)
	if err != nil {
		t.Fatalf("LatestFactBySource feedback: %v", err)
	}
	if d, ok := ParseCooldownFact(fb); !ok || d != tuned {
		t.Fatalf("ParseCooldownFact round-trip: want %v ok, got %v ok=%v", tuned, d, ok)
	}
}
