package main

import (
	"context"
	"path/filepath"
	"testing"
	"time"

	"github.com/kami/maven/internal/config"
	"github.com/kami/maven/internal/delivery"
	"github.com/kami/maven/internal/loop"
	"github.com/kami/maven/internal/phraser"
	"github.com/kami/maven/internal/routine"
	"github.com/kami/maven/internal/store"
)

// fakeSink — captures every Send for assertion. implements delivery.Sink.
type fakeSink struct {
	sends []delivery.Sendable
}

func (f *fakeSink) Send(_ context.Context, s delivery.Sendable) error {
	f.sends = append(f.sends, s)
	return nil
}

func newTestStore(t *testing.T) *store.Store {
	t.Helper()
	path := filepath.Join(t.TempDir(), "mavend_test.db")
	st, err := store.Open(context.Background(), path)
	if err != nil {
		t.Fatalf("store.Open: %v", err)
	}
	t.Cleanup(func() { _ = st.Close() })
	return st
}

func newTestTickLoop(t *testing.T, st *store.Store, sink delivery.Sink, digestCfg *config.DigestConfig) *tickLoop {
	t.Helper()
	rules := loop.DefaultRules()
	g := loop.NewGatherer(st, rules)
	d := delivery.NewDispatcher(delivery.Config{
		Voice:     sink,
		Ntfy:      sink,
		Telegram:  sink,
		Nudges:    st,
		Reminders: st,
	})
	return newTickLoop(st, g, d, phraser.NewStub(), rules, time.Second, 5*time.Minute, 0, digestCfg, nil, nil, nil)
}

func TestTickFiresRoutineWhenScheduleCrosses(t *testing.T) {
	// A routine scheduled for 12:00 daily. On the first tick it seeds (cold-start
	// guard — no fire); on a tick past the next 12:00 crossing it fires through
	// the dispatcher with its literal body and severity.
	st := newTestStore(t)
	ctx := context.Background()
	now := refNow() // 2026-06-30 12:00 UTC
	markPresent(t, st, ctx, now)

	rules := loop.DefaultRules()
	g := loop.NewGatherer(st, rules)
	sink := &fakeSink{}
	d := delivery.NewDispatcher(delivery.Config{Voice: sink, Ntfy: sink, Telegram: sink, Nudges: st, Reminders: st})
	rs := []routine.Routine{{Name: "morning", Cron: "0 12 * * *", Body: "полдень, время воды", Severity: 1}}
	tl := newTickLoop(st, g, d, phraser.NewStub(), rules, time.Second, 5*time.Minute, 0, nil, rs, nil, nil)

	// first tick: seeds, does not fire the routine.
	tl.tick(ctx, now)
	for _, s := range sink.sends {
		if s.RuleName == "routine:morning" {
			t.Fatal("routine fired on the seeding tick (cold-start guard failed)")
		}
	}

	// next day, just past 12:00 — the schedule crossed.
	next := now.Add(24 * time.Hour).Add(time.Minute)
	markPresent(t, st, ctx, next)
	sink.sends = nil
	tl.tick(ctx, next)

	var got *delivery.Sendable
	for i := range sink.sends {
		if sink.sends[i].RuleName == "routine:morning" {
			got = &sink.sends[i]
		}
	}
	if got == nil {
		t.Fatalf("routine did not fire after its schedule crossed; sends=%+v", sink.sends)
	}
	if got.Body != "полдень, время воды" {
		t.Errorf("routine body = %q, want the literal config body", got.Body)
	}
	if got.Channel != delivery.ChannelVoice {
		t.Errorf("routine channel = %v, want voice (sev1 present)", got.Channel)
	}
}

// TestTickFiresAcceptedRoutineEveryInterval — Vikunja #366. An accepted routine
// with a 3-day interval must nudge every 3 days, not once. It also must not
// replay the occurrences it slept through: after a 30-day gap it nudges once.
func TestTickFiresAcceptedRoutineEveryInterval(t *testing.T) {
	st := newTestStore(t)
	ctx := context.Background()
	accepted := refNow()

	id, err := st.CreateProposedRoutine(ctx, "полить", "цветы", 3.0, accepted)
	if err != nil {
		t.Fatalf("CreateProposedRoutine: %v", err)
	}
	if err := st.AcceptProposedRoutine(ctx, id, accepted); err != nil {
		t.Fatalf("AcceptProposedRoutine: %v", err)
	}

	sink := &fakeSink{}
	tl := newTestTickLoop(t, st, sink, nil)
	const rule = "routine:полить цветы"

	// Same day as the accept: not due yet.
	markPresent(t, st, ctx, accepted)
	tl.tick(ctx, accepted.Add(time.Hour))
	if n := countSends(sink, rule); n != 0 {
		t.Fatalf("routine fired %d times before its first interval passed, want 0", n)
	}

	// Three days later: the first nudge.
	first := accepted.Add(3 * 24 * time.Hour)
	markPresent(t, st, ctx, first)
	tl.tick(ctx, first)
	if n := countSends(sink, rule); n != 1 {
		t.Fatalf("first interval: sends = %d, want 1", n)
	}

	// Next day: still inside the interval, silent.
	sink.sends = nil
	markPresent(t, st, ctx, first.Add(24*time.Hour))
	tl.tick(ctx, first.Add(24*time.Hour))
	if n := countSends(sink, rule); n != 0 {
		t.Fatalf("mid-interval: sends = %d, want 0", n)
	}

	// Three days after the first nudge: it fires again. This is the bug —
	// a one-shot reminder would never come back.
	second := first.Add(3 * 24 * time.Hour)
	markPresent(t, st, ctx, second)
	tl.tick(ctx, second)
	if n := countSends(sink, rule); n != 1 {
		t.Fatalf("second interval: sends = %d, want 1 (a routine repeats)", n)
	}

	// A long silence must not turn into a backlog of missed nudges.
	sink.sends = nil
	late := second.Add(30 * 24 * time.Hour)
	markPresent(t, st, ctx, late)
	tl.tick(ctx, late)
	if n := countSends(sink, rule); n != 1 {
		t.Fatalf("after a 30-day gap: sends = %d, want exactly 1 (no backlog)", n)
	}
}

// TestTickAcceptedRoutineRespectsQuietHours — routines are not reminders: they
// do not inherit the reminder gate bypass. Away presence drops a care-class
// nudge, and the routine stays due so it nudges once the user is back.
func TestTickAcceptedRoutineRespectsGate(t *testing.T) {
	st := newTestStore(t)
	ctx := context.Background()
	accepted := refNow()

	id, err := st.CreateProposedRoutine(ctx, "полить", "цветы", 3.0, accepted)
	if err != nil {
		t.Fatalf("CreateProposedRoutine: %v", err)
	}
	if err := st.AcceptProposedRoutine(ctx, id, accepted); err != nil {
		t.Fatalf("AcceptProposedRoutine: %v", err)
	}

	sink := &fakeSink{}
	tl := newTestTickLoop(t, st, sink, nil)
	const rule = "routine:полить цветы"

	// No presence probes at all ⇒ away ⇒ the care gate blocks the nudge.
	due := accepted.Add(3 * 24 * time.Hour)
	tl.tick(ctx, due)
	if n := countSends(sink, rule); n != 0 {
		t.Fatalf("away: sends = %d, want 0 (routine must not bypass the gate)", n)
	}

	// Back at the desk a minute later: the nudge that was held now goes out.
	back := due.Add(time.Minute)
	markPresent(t, st, ctx, back)
	tl.tick(ctx, back)
	if n := countSends(sink, rule); n != 1 {
		t.Fatalf("present again: sends = %d, want 1", n)
	}
}

// countSends counts captured sends for one rule name.
func countSends(sink *fakeSink, rule string) int {
	n := 0
	for _, s := range sink.sends {
		if s.RuleName == rule {
			n++
		}
	}
	return n
}

// refNow — fixed tick time so presence decay + since durations are deterministic.
func refNow() time.Time { return time.Date(2026, 6, 30, 12, 0, 0, 0, time.UTC) }

// markPresent seeds desk_active + page_heartbeat with fresh ts so presence
// resolves to Present for the given tick time (cold-start is away; ENTER at
// 0.55 — a fresh desk_active alone gives 0.90, well over).
func markPresent(t *testing.T, st *store.Store, ctx context.Context, now time.Time) {
	t.Helper()
	for _, key := range []string{"desk_active", "page_heartbeat"} {
		if _, err := st.SetValue(ctx, store.KindSelf, key, "tap:desk", map[string]bool{key: true}, now); err != nil {
			t.Fatalf("seed %s: %v", key, err)
		}
	}
}

func TestTickColdStoreSendsNothing(t *testing.T) {
	// The "shuts up when uncertain" floor: no facts ⇒ every rule's
	// InertWhenNoData keys are missing ⇒ gate skips them. the loop's silence
	// is the default outcome of a tick on an empty store.
	st := newTestStore(t)
	ctx := context.Background()
	sink := &fakeSink{}
	tl := newTestTickLoop(t, st, sink, nil)

	tl.tick(ctx, refNow())

	if len(sink.sends) != 0 {
		t.Fatalf("cold-store tick sent %d; want 0 (shuts up when no data)", len(sink.sends))
	}
}

func TestTickWaterFiresWhenDueAndPresent(t *testing.T) {
	// water fact 4h ago ⇒ since(water)=4h ≥ 3h ⇒ predicate true. presence
	// present ⇒ sev1 care gate holds (no quiet/cal/cooldown). routing for
	// sev1 present is [voice] — one send captured.
	st := newTestStore(t)
	ctx := context.Background()
	now := refNow()
	markPresent(t, st, ctx, now)
	if _, err := st.SetValue(ctx, store.KindSelf, "water", "tap:water", map[string]int{"ml": 0}, now.Add(-4*time.Hour)); err != nil {
		t.Fatalf("seed water: %v", err)
	}
	sink := &fakeSink{}
	tl := newTestTickLoop(t, st, sink, nil)

	tl.tick(ctx, now)

	if len(sink.sends) != 1 {
		t.Fatalf("tick sends = %d, want 1 (water, voice only)", len(sink.sends))
	}
	if got, want := sink.sends[0].RuleName, "water"; got != want {
		t.Errorf("send rule = %q, want %q", got, want)
	}
	if got, want := sink.sends[0].Channel, delivery.ChannelVoice; got != want {
		t.Errorf("send channel = %v, want voice (sev1 present)", got)
	}
	if sink.sends[0].Body == "" {
		t.Error("phraser Stub produced an empty body for the water nudge")
	}

	// one nudge row recorded with channel=voice — verify the dispatch path
	// wrote through to the store (the feedback loop's only input). RecentOutcomes
	// filters to resolved rows, so confirm the recorded nudge exists via LastNudge.
	n, err := st.LastNudge(ctx, "water")
	if err != nil {
		t.Fatalf("LastNudge: %v", err)
	}
	if n.Channel != string(delivery.ChannelVoice) {
		t.Errorf("recorded nudge channel = %q, want %q", n.Channel, delivery.ChannelVoice)
	}
}

func TestTickCooldownSuppressesSecondSend(t *testing.T) {
	// After a water nudge, the gate's cooldown (DefaultRules sets water
	// base cooldown = 30m) suppresses the same rule on the next tick.
	st := newTestStore(t)
	ctx := context.Background()
	now := refNow()
	markPresent(t, st, ctx, now)
	if _, err := st.SetValue(ctx, store.KindSelf, "water", "tap:water", map[string]int{"ml": 0}, now.Add(-4*time.Hour)); err != nil {
		t.Fatalf("seed water: %v", err)
		_ = err
	}
	sink := &fakeSink{}
	tl := newTestTickLoop(t, st, sink, nil)

	tl.tick(ctx, now)                  // fires
	tl.tick(ctx, now.Add(time.Minute)) // still within 30m cooldown ⇒ suppressed

	if len(sink.sends) != 1 {
		t.Fatalf("sends after second tick = %d, want 1 (cooldown should suppress)", len(sink.sends))
	}
}

func TestTickReminderFiresOnceAndMarkedFired(t *testing.T) {
	// A due reminder bypasses the gate. away (no presence probes ⇒ cold
	// start away) routes the reminder to [ntfy]. the dispatcher marks the
	// reminder fired only after at least one channel succeeded; verify by
	// re-ticking and confirming it isn't re-dispatched (DueReminders returns
	// only `status == pending AND fire_ts <= now`).
	st := newTestStore(t)
	ctx := context.Background()
	now := refNow()
	if _, err := st.CreateReminder(ctx, now.Add(-time.Minute), `{"text":"stand up"}`, ""); err != nil {
		t.Fatalf("CreateReminder: %v", err)
	}
	sink := &fakeSink{}
	tl := newTestTickLoop(t, st, sink, nil)

	tl.tick(ctx, now)
	if got, want := len(sink.sends), 1; got != want {
		t.Fatalf("reminder tick sends = %d, want 1 (away → ntfy)", got)
	}
	if sink.sends[0].Channel != delivery.ChannelNtfy {
		t.Errorf("reminder channel = %v, want ntfy (away)", sink.sends[0].Channel)
	}
	if sink.sends[0].Body != "stand up" {
		t.Errorf("reminder body = %q, want %q (router payload text)", sink.sends[0].Body, "stand up")
	}
	if sink.sends[0].Kind != delivery.KindReminder {
		t.Errorf("reminder kind = %v, want %v", sink.sends[0].Kind, delivery.KindReminder)
	}

	// re-tick: the reminder is no longer pending (marked fired) ⇒ not in
	// DueReminders ⇒ the reminder path is silent.
	sink.sends = nil
	tl.tick(ctx, now.Add(time.Minute))
	if len(sink.sends) != 0 {
		t.Fatalf("second reminder tick sends = %d, want 0 (fired once)", len(sink.sends))
	}
}

// TestTuneWritesFeedbackCooldownToStore — the daemon's impure tune() step end
// to end: seed a rule with ≥ TuneMinOutcomes resolved `ignored` outcomes,
// call tune(), assert it wrote a `cooldown:<rule>` (source=feedback) fact that
// the gatherer then reads back as the active cooldown base. This is the closed
// feedback loop: outcomes → tune → write → gather → gate sees the tuned base.
func TestTuneWritesFeedbackCooldownToStore(t *testing.T) {
	st := newTestStore(t)
	ctx := context.Background()
	now := refNow()

	sink := &fakeSink{}
	tl := newTestTickLoop(t, st, sink, nil)

	// seed enough resolved `ignored` nudge outcomes to trip the tuner (above
	// TuneMinOutcomes). all-ignored ⇒ factor 1.5 ⇒ base × 1.5; clamped to Max.
	r := loop.WaterRule()
	for i := 0; i < loop.TuneSampleN; i++ {
		id, err := st.RecordNudge(ctx, r.Name, "voice", "drink water", now.Add(-time.Hour))
		if err != nil {
			t.Fatalf("RecordNudge %d: %v", i, err)
		}
		if err := st.ResolveNudge(ctx, id, store.NudgeIgnored, now); err != nil {
			t.Fatalf("ResolveNudge %d: %v", i, err)
		}
	}

	tl.tune(ctx)

	// the gatherer should now use the tuned base (clamped to WaterRule.Max =
	// 6h) as the cooldown base for `water`. verify via the cooldown-until
	// field by seeding a water nudge + asserting cooldown = sendTs + Max.
	// (we read the persisted tuned base directly rather than going via gather
	// so the assertion isolates tune()'s write from the gatherer path.)
	fb, err := st.LatestFactBySource(ctx, loop.FeedbackKey(r), loop.FeedbackSource)
	if err != nil {
		t.Fatalf("LatestFactBySource: %v (no feedback fact written?)", err)
	}
	tuned, ok := loop.ParseCooldownFact(fb)
	if !ok {
		t.Fatalf("ParseCooldownFact: not ok (value %q)", fb.Value)
	}
	if tuned != 45*time.Minute {
		t.Fatalf("all-ignored: base 30m × 1.5 = 45m (no — under WaterRule.Max 6h so unclamped): want 45m, got %v", tuned)
	}

	// idempotent: a second tune() with the same outcomes writes nothing new
	// (RecentOutcomes is steady between resolves; the persisted value equals
	// the computed one ⇒ skip).
	fb1 := fb
	tl.tune(ctx)
	fb2, err := st.LatestFactBySource(ctx, loop.FeedbackKey(r), loop.FeedbackSource)
	if err != nil {
		t.Fatalf("LatestFactBySource second call: %v", err)
	}
	if fb2.ID != fb1.ID {
		t.Fatalf("tune() re-wrote identical value: fact id %d → %d (should skip when unchanged)",
			fb1.ID, fb2.ID)
	}

	// flip the outcomes pattern to `acted`: next tune() writes a new value,
	// shorter than Max. RecentOutcomes is sorted DESC ts,id, so re-seeding
	// newer-acted nudges makes them dominate the older-ignored set.
	for i := 0; i < loop.TuneSampleN; i++ {
		id, err := st.RecordNudge(ctx, r.Name, "voice", "drink water", now.Add(time.Minute+time.Duration(i)*time.Second))
		if err != nil {
			t.Fatalf("RecordNudge acted %d: %v", i, err)
		}
		if err := st.ResolveNudge(ctx, id, store.NudgeActed, now); err != nil {
			t.Fatalf("ResolveNudge acted %d: %v", i, err)
		}
	}
	tl.tune(ctx)
	fb3, err := st.LatestFactBySource(ctx, loop.FeedbackKey(r), loop.FeedbackSource)
	if err != nil {
		t.Fatalf("LatestFactBySource post-flip: %v", err)
	}
	tuned3, ok := loop.ParseCooldownFact(fb3)
	if !ok {
		t.Fatalf("ParseCooldownFact post-flip: not ok (value %q)", fb3.Value)
	}
	if tuned3 >= r.Cooldown.Max {
		t.Fatalf("acted-dominated outcomes should shrink cooldown below Max: got %v (Max %v)",
			tuned3, r.Cooldown.Max)
	}
	if tuned3 < r.Cooldown.Min {
		t.Fatalf("tuned cooldown below Min envelope: got %v (Min %v) — clamp broken",
			tuned3, r.Cooldown.Min)
	}

	// the gatherer actually reads it back: assert CooldownUntil for water is
	// derived from the tuned base (not the rule's static Base) when a real
	// nudge exists. seed a water nudge now and gather.
	nudgeTs := now.Add(2 * time.Minute)
	if _, err := st.RecordNudge(ctx, r.Name, "voice", "drink water", nudgeTs); err != nil {
		t.Fatalf("final RecordNudge: %v", err)
	}
	g := loop.NewGatherer(st, loop.DefaultRules())
	snap, _, err := g.GatherState(ctx, now.Add(3*time.Minute))
	if err != nil {
		t.Fatalf("GatherState: %v", err)
	}
	wantUntil := nudgeTs.Add(tuned3)
	if got := snap.CooldownUntil[r.Name]; got != wantUntil {
		t.Fatalf("gatherer used tuned base: CooldownUntil want %v, got %v",
			wantUntil, got)
	}
}

// ----------------------------- digest / batching ----------------------------

func TestDigestQueuesEligibleNudge(t *testing.T) {
	// sev1 (water) with digest enabled → queued, not dispatched.
	st := newTestStore(t)
	ctx := context.Background()
	now := refNow()
	markPresent(t, st, ctx, now)
	if _, err := st.SetValue(ctx, store.KindSelf, "water", "tap:water", map[string]int{"ml": 0}, now.Add(-4*time.Hour)); err != nil {
		t.Fatalf("seed water: %v", err)
	}
	sink := &fakeSink{}
	dc := &config.DigestConfig{
		Enabled:         true,
		Window:          config.Duration(30 * time.Minute),
		MaxItems:        5,
		SeverityCeiling: 2,
	}
	tl := newTestTickLoop(t, st, sink, dc)

	tl.tick(ctx, now)

	if len(sink.sends) != 0 {
		t.Fatalf("digest: eligible nudge should not dispatch immediately, got %d sends", len(sink.sends))
	}
	if len(tl.digestQ) != 1 {
		t.Fatalf("digestQ length = %d, want 1", len(tl.digestQ))
	}
	if tl.digestQ[0].Rule != "water" {
		t.Errorf("queued rule = %q, want %q", tl.digestQ[0].Rule, "water")
	}
}

func TestDigestFlushesAfterWindow(t *testing.T) {
	// Queue a sev1 nudge, advance past the window, tick again → flush.
	// Re-mark presence on the second tick so the flush dispatch has a
	// non-away presence route (care nudges drop on away).
	st := newTestStore(t)
	ctx := context.Background()
	now := refNow()
	markPresent(t, st, ctx, now)
	if _, err := st.SetValue(ctx, store.KindSelf, "water", "tap:water", map[string]int{"ml": 0}, now.Add(-4*time.Hour)); err != nil {
		t.Fatalf("seed water: %v", err)
	}
	sink := &fakeSink{}
	dc := &config.DigestConfig{
		Enabled:         true,
		Window:          config.Duration(30 * time.Minute),
		MaxItems:        5,
		SeverityCeiling: 2,
	}
	tl := newTestTickLoop(t, st, sink, dc)

	// Tick 1: water queues, nothing dispatched.
	tl.tick(ctx, now)
	if len(sink.sends) != 0 {
		t.Fatalf("tick 1: want 0 sends (queued), got %d", len(sink.sends))
	}
	if len(tl.digestQ) != 1 {
		t.Fatalf("tick 1: digestQ = %d, want 1", len(tl.digestQ))
	}

	// Tick 2: window elapsed → flush. Re-mark presence to keep routing
	// present (care nudge away → drop).
	sink.sends = nil
	later := now.Add(31 * time.Minute)
	markPresent(t, st, ctx, later)
	tl.tick(ctx, later)

	if len(sink.sends) != 1 {
		t.Fatalf("tick 2 (flush): sends = %d, want 1", len(sink.sends))
	}
	if sink.sends[0].RuleName != "digest" {
		t.Errorf("flush rule = %q, want %q", sink.sends[0].RuleName, "digest")
	}
	if sink.sends[0].Body == "" {
		t.Error("digest body must not be empty")
	}
	if len(tl.digestQ) != 0 {
		t.Errorf("digestQ should be empty after flush, got %d", len(tl.digestQ))
	}
}

func TestDigestFlushesAtMaxItems(t *testing.T) {
	// Queue water via tick, then manually push a second item, then tick again
	// so the before-candidate maybeFlush sees len >= MaxItems and flushes.
	st := newTestStore(t)
	ctx := context.Background()
	now := refNow()
	markPresent(t, st, ctx, now)

	if _, err := st.SetValue(ctx, store.KindSelf, "water", "tap:water", map[string]int{"ml": 0}, now.Add(-4*time.Hour)); err != nil {
		t.Fatalf("seed water: %v", err)
	}

	sink := &fakeSink{}
	dc := &config.DigestConfig{
		Enabled:         true,
		Window:          config.Duration(30 * time.Minute),
		MaxItems:        2,
		SeverityCeiling: 2,
	}
	tl := newTestTickLoop(t, st, sink, dc)

	// Tick 1: water queues (1 item, below MaxItems).
	tl.tick(ctx, now)
	if len(tl.digestQ) != 1 {
		t.Fatalf("tick 1: digestQ = %d, want 1", len(tl.digestQ))
	}
	if len(sink.sends) != 0 {
		t.Fatalf("tick 1: sends = %d, want 0", len(sink.sends))
	}

	// Manually push a second item to hit MaxItems.
	tl.digestQ = append(tl.digestQ, QueuedNudge{
		Rule: "meal", Severity: 1, Body: "eat something", Key: "meal", QueuedAt: now.Add(time.Second),
	})

	// Tick 2: after-candidate maybeFlush sees len=2 >= MaxItems=2 → flush.
	sink.sends = nil
	tl.tick(ctx, now.Add(2*time.Second))

	if len(sink.sends) != 1 {
		t.Fatalf("after flush tick: sends = %d, want 1", len(sink.sends))
	}
	if sink.sends[0].RuleName != "digest" {
		t.Errorf("flush rule = %q, want %q", sink.sends[0].RuleName, "digest")
	}
	if len(tl.digestQ) != 0 {
		t.Errorf("digestQ should be empty after flush, got %d", len(tl.digestQ))
	}
}

func TestDigestSev4BypassesQueue(t *testing.T) {
	// Sev4 (service_down) with digest enabled → dispatches immediately.
	st := newTestStore(t)
	ctx := context.Background()
	now := refNow()
	markPresent(t, st, ctx, now)
	// Older than loop.MinDownAge, so this tests the digest bypass and not the
	// flap debounce (Vikunja #536).
	if _, err := st.SetValue(ctx, store.KindSelf, "service_down:db", "poll:uptimekuma", "down", now.Add(-5*time.Minute)); err != nil {
		t.Fatalf("seed service_down: %v", err)
	}
	sink := &fakeSink{}
	dc := &config.DigestConfig{
		Enabled:         true,
		Window:          config.Duration(30 * time.Minute),
		MaxItems:        5,
		SeverityCeiling: 2,
	}
	tl := newTestTickLoop(t, st, sink, dc)

	tl.tick(ctx, now)

	if len(sink.sends) == 0 {
		t.Fatal("sev4 nudge must dispatch immediately, bypassing digest queue")
	}
	if len(tl.digestQ) != 0 {
		t.Errorf("digestQ should be empty (sev4 bypassed), got %d", len(tl.digestQ))
	}
}

func TestDigestDisabledSendsImmediately(t *testing.T) {
	// Digest disabled (nil config) → sev1 dispatches immediately.
	st := newTestStore(t)
	ctx := context.Background()
	now := refNow()
	markPresent(t, st, ctx, now)
	if _, err := st.SetValue(ctx, store.KindSelf, "water", "tap:water", map[string]int{"ml": 0}, now.Add(-4*time.Hour)); err != nil {
		t.Fatalf("seed water: %v", err)
	}
	sink := &fakeSink{}
	tl := newTestTickLoop(t, st, sink, nil)

	tl.tick(ctx, now)

	if len(sink.sends) != 1 {
		t.Fatalf("digest disabled: sends = %d, want 1", len(sink.sends))
	}
	if sink.sends[0].RuleName != "water" {
		t.Errorf("send rule = %q, want %q", sink.sends[0].RuleName, "water")
	}
}

func TestDigestDeduplicatesByRule(t *testing.T) {
	// Same rule (water) fires in two ticks; only one copy in the queue.
	st := newTestStore(t)
	ctx := context.Background()
	now := refNow()
	markPresent(t, st, ctx, now)
	if _, err := st.SetValue(ctx, store.KindSelf, "water", "tap:water", map[string]int{"ml": 0}, now.Add(-4*time.Hour)); err != nil {
		t.Fatalf("seed water: %v", err)
	}
	sink := &fakeSink{}
	dc := &config.DigestConfig{
		Enabled:         true,
		Window:          config.Duration(30 * time.Minute),
		MaxItems:        5,
		SeverityCeiling: 2,
	}
	tl := newTestTickLoop(t, st, sink, dc)

	// Tick 1: water queues.
	tl.tick(ctx, now)
	if len(tl.digestQ) != 1 {
		t.Fatalf("tick 1: digestQ = %d, want 1", len(tl.digestQ))
	}

	// Tick 2: water still in cooldown → gate suppresses, but if cooldown
	// weren't active the dedup check would prevent a second copy. We verify
	// by making a second direct call to queueNudge with the same rule name.
	// We also tick again with a different time to see if water fires again
	// through the normal path — but cooldown should suppress it. Instead,
	// directly verify dedup by calling queueNudge with a synthetic candidate.
	tl.queueNudge(ctx, &loop.Candidate{
		Rule:     loop.Rule{Name: "water", Severity: loop.Sev1},
		Severity: loop.Sev1,
	}, loop.State{}, now.Add(time.Minute))

	if len(tl.digestQ) != 1 {
		t.Fatalf("after duplicate queue attempt: digestQ = %d, want 1 (dedup)", len(tl.digestQ))
	}
}

// The repeat path reads the nudges table, not the rule set, so a rule turned
// off in `disabled_rules` used to keep re-sending its last un-acked telegram
// nudge every repeat_interval. Two arrived after the rule was off on
// 2026-08-01. A disabled rule must be unreachable on every path.
func TestRepeatableRulesDropsDisabledRules(t *testing.T) {
	tl := &tickLoop{rules: mustRules(t, []string{"service_down"})}
	got := tl.repeatableRules([]string{"service_down", "water"})
	if len(got) != 1 || got[0] != "water" {
		t.Fatalf("repeatableRules = %v, want [water]", got)
	}
}

// An orphan row for a rule that no longer exists in the code goes quiet too:
// nothing can ack what the UI cannot show.
func TestRepeatableRulesDropsUnknownRules(t *testing.T) {
	tl := &tickLoop{rules: loop.DefaultRules()}
	if got := tl.repeatableRules([]string{"rule_deleted_last_year"}); len(got) != 0 {
		t.Fatalf("repeatableRules = %v, want none", got)
	}
}

func TestRepeatableRulesKeepsWiredRules(t *testing.T) {
	tl := &tickLoop{rules: loop.DefaultRules()}
	got := tl.repeatableRules([]string{"service_down", "water"})
	if len(got) != 2 {
		t.Fatalf("repeatableRules = %v, want both", got)
	}
}

// mustRules returns DefaultRules minus the named ones, failing if a name
// matched nothing — a typo here would make the test pass for the wrong reason.
func mustRules(t *testing.T, disabled []string) []loop.Rule {
	t.Helper()
	rules, dropped := loop.RulesExcept(disabled)
	if len(dropped) != len(disabled) {
		t.Fatalf("dropped %v, want %v", dropped, disabled)
	}
	return rules
}
