package loop

import (
	"testing"
	"time"

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

// Direct tests for the five default rule predicates.
//
// A predicate is pure — (State) -> bool, no I/O — so these need no store and no
// daemon. They test the predicate ALONE: the restraint gate is tested in
// loop_test.go and gate_test.go, never here.
//
// Every rule gets the same three questions plus its own edges:
//   - does it fire when it should?
//   - does it stay quiet when it should?
//   - is it silent when the key it needs has no data at all?
//
// The last one is load-bearing. docs/design.md: "since(key)==null → don't fire.
// Silence on no-data is 'shuts up when uncertain'."

// stateWith builds a snapshot at refTime() holding just the given facts.
// Presence and the env flags are left zero — the predicate must not read them.
func stateWith(facts map[string]store.Fact) State {
	return State{Now: refTime(), Facts: facts}
}

// ago is a fact for key written `d` before refTime().
func ago(key, source, value string, d time.Duration) store.Fact {
	return factAt(key, source, value, refTime().Add(-d))
}

// ---------------------------- since-based care rules -------------------------

// The three care rules share one shape: "fire when it has been at least N since
// the last fact for key". One table drives all of them.
func TestCareRulePredicates(t *testing.T) {
	cases := []struct {
		name  string
		rule  Rule
		facts map[string]store.Fact
		want  bool
	}{
		// water — threshold 3h.
		{
			name:  "water fires at 4h",
			rule:  WaterRule(),
			facts: map[string]store.Fact{"water": ago("water", "tap:water", `"250ml"`, 4*time.Hour)},
			want:  true,
		},
		{
			name:  "water fires exactly at the 3h threshold",
			rule:  WaterRule(),
			facts: map[string]store.Fact{"water": ago("water", "tap:water", `"250ml"`, 3*time.Hour)},
			want:  true,
		},
		{
			name:  "water quiet just under 3h",
			rule:  WaterRule(),
			facts: map[string]store.Fact{"water": ago("water", "tap:water", `"250ml"`, 3*time.Hour-time.Minute)},
			want:  false,
		},
		{
			name:  "water quiet on no data",
			rule:  WaterRule(),
			facts: nil,
			want:  false,
		},
		{
			name:  "water quiet on a zero-timestamp fact",
			rule:  WaterRule(),
			facts: map[string]store.Fact{"water": {Key: "water", Source: "tap:water", Value: `"250ml"`}},
			want:  false,
		},
		{
			name:  "water quiet when the only fact is for another key",
			rule:  WaterRule(),
			facts: map[string]store.Fact{"meal": ago("meal", "voice", `"lunch"`, 9*time.Hour)},
			want:  false,
		},

		// meal — threshold 6h.
		{
			name:  "meal fires at 7h",
			rule:  MealRule(),
			facts: map[string]store.Fact{"meal": ago("meal", "voice", `"lunch"`, 7*time.Hour)},
			want:  true,
		},
		{
			name:  "meal fires exactly at the 6h threshold",
			rule:  MealRule(),
			facts: map[string]store.Fact{"meal": ago("meal", "voice", `"lunch"`, 6*time.Hour)},
			want:  true,
		},
		{
			name:  "meal quiet just under 6h",
			rule:  MealRule(),
			facts: map[string]store.Fact{"meal": ago("meal", "voice", `"lunch"`, 6*time.Hour-time.Minute)},
			want:  false,
		},
		{
			name:  "meal quiet on no data",
			rule:  MealRule(),
			facts: nil,
			want:  false,
		},

		// break — needs BOTH anchors: at the desk now, and no break for 90min.
		{
			name: "break fires when at desk and no break for 2h",
			rule: BreakRule(),
			facts: map[string]store.Fact{
				"desk_active": ago("desk_active", "infer:hyprland", "1", 30*time.Second),
				"break":       ago("break", "voice", `"walk"`, 2*time.Hour),
			},
			want: true,
		},
		{
			name: "break fires exactly at both thresholds",
			rule: BreakRule(),
			facts: map[string]store.Fact{
				"desk_active": ago("desk_active", "infer:hyprland", "1", 2*time.Minute),
				"break":       ago("break", "voice", `"walk"`, 90*time.Minute),
			},
			want: true,
		},
		{
			name: "break quiet when the desk signal is stale (user left)",
			rule: BreakRule(),
			facts: map[string]store.Fact{
				"desk_active": ago("desk_active", "infer:hyprland", "1", 10*time.Minute),
				"break":       ago("break", "voice", `"walk"`, 2*time.Hour),
			},
			want: false,
		},
		{
			name: "break quiet when the last break was recent",
			rule: BreakRule(),
			facts: map[string]store.Fact{
				"desk_active": ago("desk_active", "infer:hyprland", "1", 30*time.Second),
				"break":       ago("break", "voice", `"walk"`, 20*time.Minute),
			},
			want: false,
		},
		{
			name: "break quiet with only the desk anchor",
			rule: BreakRule(),
			facts: map[string]store.Fact{
				"desk_active": ago("desk_active", "infer:hyprland", "1", 30*time.Second),
			},
			want: false,
		},
		{
			name: "break quiet with only the break anchor",
			rule: BreakRule(),
			facts: map[string]store.Fact{
				"break": ago("break", "voice", `"walk"`, 2*time.Hour),
			},
			want: false,
		},
		{
			name:  "break quiet on no data",
			rule:  BreakRule(),
			facts: nil,
			want:  false,
		},
	}

	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			if got := c.rule.Predicate(stateWith(c.facts)); got != c.want {
				t.Fatalf("%s predicate: want %v, got %v", c.rule.Name, c.want, got)
			}
		})
	}
}

// ---------------------------- ops rules --------------------------------------

// The two ops rules match on a value AND on which poller wrote it. docs/design.md:
// "a compromised poller must not be able to forge a trigger." Half of this
// table is forgery attempts; all of them must be refused.
func TestOpsRulePredicates(t *testing.T) {
	cases := []struct {
		name  string
		rule  Rule
		facts map[string]store.Fact
		want  bool
	}{
		// service_down — only poll:uptimekuma may say a service is down.
		{
			name:  "service_down fires on a kuma down fact",
			rule:  ServiceDownRule(),
			facts: map[string]store.Fact{"service_down:db": ago("service_down:db", "poll:uptimekuma", `"down"`, 5*time.Minute)},
			want:  true,
		},
		{
			name:  "service_down quiet when kuma says up",
			rule:  ServiceDownRule(),
			facts: map[string]store.Fact{"service_down:db": ago("service_down:db", "poll:uptimekuma", `"up"`, 5*time.Minute)},
			want:  false,
		},
		{
			name:  "service_down quiet on no data",
			rule:  ServiceDownRule(),
			facts: nil,
			want:  false,
		},
		{
			name:  "service_down quiet on a zero-timestamp fact",
			rule:  ServiceDownRule(),
			facts: map[string]store.Fact{"service_down:db": {Key: "service_down:db", Source: "poll:uptimekuma", Value: `"down"`}},
			want:  false,
		},
		// forgery attempts — right value, wrong writer.
		{
			name:  "service_down refuses a forgery from the netdata poller",
			rule:  ServiceDownRule(),
			facts: map[string]store.Fact{"service_down:db": ago("service_down:db", "poll:netdata", `"down"`, 5*time.Minute)},
			want:  false,
		},
		{
			name:  "service_down refuses a forgery from ambient audio",
			rule:  ServiceDownRule(),
			facts: map[string]store.Fact{"service_down:db": ago("service_down:db", "ambient:other", `"down"`, 5*time.Minute)},
			want:  false,
		},
		{
			name:  "service_down refuses a forgery from the user's own voice",
			rule:  ServiceDownRule(),
			facts: map[string]store.Fact{"service_down:db": ago("service_down:db", "voice", `"down"`, 5*time.Minute)},
			want:  false,
		},
		{
			name:  "service_down refuses a source that only looks like kuma",
			rule:  ServiceDownRule(),
			facts: map[string]store.Fact{"service_down:db": ago("service_down:db", "poll:uptimekuma-staging", `"down"`, 5*time.Minute)},
			want:  false,
		},
		{
			name:  "service_down refuses an unquoted down value",
			rule:  ServiceDownRule(),
			facts: map[string]store.Fact{"service_down:db": ago("service_down:db", "poll:uptimekuma", `down`, 5*time.Minute)},
			want:  false,
		},

		// netdata_critical — only poll:netdata may raise a critical alarm.
		{
			name:  "netdata_critical fires on a netdata critical alarm",
			rule:  NetdataCriticalRule(),
			facts: map[string]store.Fact{"netdata_alarm": ago("netdata_alarm", "poll:netdata", `"critical"`, time.Minute)},
			want:  true,
		},
		{
			name:  "netdata_critical quiet on a warning alarm",
			rule:  NetdataCriticalRule(),
			facts: map[string]store.Fact{"netdata_alarm": ago("netdata_alarm", "poll:netdata", `"warning"`, time.Minute)},
			want:  false,
		},
		{
			name:  "netdata_critical quiet on a cleared alarm",
			rule:  NetdataCriticalRule(),
			facts: map[string]store.Fact{"netdata_alarm": ago("netdata_alarm", "poll:netdata", `"clear"`, time.Minute)},
			want:  false,
		},
		{
			name:  "netdata_critical quiet on no data",
			rule:  NetdataCriticalRule(),
			facts: nil,
			want:  false,
		},
		{
			name:  "netdata_critical quiet on a zero-timestamp fact",
			rule:  NetdataCriticalRule(),
			facts: map[string]store.Fact{"netdata_alarm": {Key: "netdata_alarm", Source: "poll:netdata", Value: `"critical"`}},
			want:  false,
		},
		{
			name:  "netdata_critical refuses a forgery from the kuma poller",
			rule:  NetdataCriticalRule(),
			facts: map[string]store.Fact{"netdata_alarm": ago("netdata_alarm", "poll:uptimekuma", `"critical"`, time.Minute)},
			want:  false,
		},
		{
			name:  "netdata_critical refuses a forgery from ambient audio",
			rule:  NetdataCriticalRule(),
			facts: map[string]store.Fact{"netdata_alarm": ago("netdata_alarm", "ambient:other", `"critical"`, time.Minute)},
			want:  false,
		},
		{
			name:  "netdata_critical reads netdata_alarm, not netdata_critical",
			rule:  NetdataCriticalRule(),
			facts: map[string]store.Fact{"netdata_critical": ago("netdata_critical", "poll:netdata", `"critical"`, time.Minute)},
			want:  false,
		},
	}

	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			if got := c.rule.Predicate(stateWith(c.facts)); got != c.want {
				t.Fatalf("%s predicate: want %v, got %v", c.rule.Name, c.want, got)
			}
		})
	}
}

// ---------------------------- rule metadata ----------------------------------

// Every default rule must declare the keys it needs. The gate uses that list as
// a second no-data backstop, so a rule that forgets it loses the safety net
// even if its predicate happens to check.
func TestDefaultRulesDeclareInertKeys(t *testing.T) {
	// A rule over a key set that only exists at read time declares a prefix
	// instead — the gatherer still needs to be told what to load.
	for _, r := range DefaultRules() {
		if len(r.InertWhenNoData) == 0 && len(r.WantPrefixes) == 0 {
			t.Errorf("rule %q declares neither InertWhenNoData keys nor WantPrefixes", r.Name)
		}
	}
}

// A no-data snapshot must make EVERY default rule quiet, predicate alone, with
// the gate out of the picture. This is the whole-set version of the per-rule
// no-data cases above.
func TestNoDefaultRuleFiresOnEmptyState(t *testing.T) {
	empty := stateWith(nil)
	for _, r := range DefaultRules() {
		if r.Predicate(empty) {
			t.Errorf("rule %q fires on an empty snapshot", r.Name)
		}
	}
}

// Severities are the delivery contract (docs/design.md § Delivery / channel
// routing): care is sev1-2 and drops when away, ops is sev3-4 and holds. Pin
// them so a change to a rule's insistence has to be deliberate.
func TestDefaultRuleSeverities(t *testing.T) {
	want := map[string]Severity{
		"water":            Sev1,
		"meal":             Sev1,
		"break":            Sev2,
		"service_down":     Sev4,
		"netdata_critical": Sev3,
	}
	got := map[string]Severity{}
	for _, r := range DefaultRules() {
		got[r.Name] = r.Severity
	}
	if len(got) != len(want) {
		t.Fatalf("rule count changed: want %d, got %d", len(want), len(got))
	}
	for name, sev := range want {
		if got[name] != sev {
			t.Errorf("rule %q severity: want %d, got %d", name, sev, got[name])
		}
	}
}

// Cooldown bounds keep the feedback tuner honest — docs/design.md wants
// `cooldown in [min,max]` "so a weird week can't mutate Maven silent or
// stalker". A base outside its own envelope would make that meaningless.
func TestDefaultRuleCooldownsAreBounded(t *testing.T) {
	for _, r := range DefaultRules() {
		c := r.Cooldown
		if c.Min <= 0 || c.Base <= 0 || c.Max <= 0 {
			t.Errorf("rule %q has a non-positive cooldown: %+v", r.Name, c)
			continue
		}
		if c.Base < c.Min || c.Base > c.Max {
			t.Errorf("rule %q base %v outside envelope [%v, %v]", r.Name, c.Base, c.Min, c.Max)
		}
	}
}

// A predicate must read only the snapshot it is handed. Same snapshot twice
// (and a snapshot shared between two rules) must give the same answer — no
// hidden state, no clock reads.
func TestPredicatesArePure(t *testing.T) {
	s := stateWith(map[string]store.Fact{
		"water":           ago("water", "tap:water", `"250ml"`, 4*time.Hour),
		"meal":            ago("meal", "voice", `"lunch"`, 7*time.Hour),
		"desk_active":     ago("desk_active", "infer:hyprland", "1", 30*time.Second),
		"break":           ago("break", "voice", `"walk"`, 2*time.Hour),
		"service_down:db": ago("service_down:db", "poll:uptimekuma", `"down"`, 5*time.Minute),
	})
	for _, r := range DefaultRules() {
		first := r.Predicate(s)
		for i := 0; i < 3; i++ {
			if again := r.Predicate(s); again != first {
				t.Fatalf("rule %q predicate is not pure: %v then %v", r.Name, first, again)
			}
		}
	}
}

func ruleNames(rs []Rule) []string {
	out := make([]string, len(rs))
	for i, r := range rs {
		out[i] = r.Name
	}
	return out
}

func TestRulesExceptDropsOnlyTheNamed(t *testing.T) {
	rules, dropped := RulesExcept([]string{"service_down"})
	if len(rules) != len(DefaultRules())-1 {
		t.Fatalf("got %v", ruleNames(rules))
	}
	for _, r := range rules {
		if r.Name == "service_down" {
			t.Error("a disabled rule was still wired")
		}
	}
	if len(dropped) != 1 || dropped[0] != "service_down" {
		t.Errorf("dropped = %v", dropped)
	}
}

// A config that names a rule nobody wrote must not stop the daemon booting,
// and must not quietly drop a real rule alongside it.
func TestRulesExceptIgnoresUnknownNames(t *testing.T) {
	rules, dropped := RulesExcept([]string{"no_such_rule", " ", ""})
	if len(rules) != len(DefaultRules()) {
		t.Errorf("an unknown name removed something: %v", ruleNames(rules))
	}
	if len(dropped) != 0 {
		t.Errorf("dropped = %v, want nothing", dropped)
	}
}

func TestRulesExceptEmptyKeepsEverything(t *testing.T) {
	rules, dropped := RulesExcept(nil)
	if len(rules) != len(DefaultRules()) || dropped != nil {
		t.Errorf("rules = %v, dropped = %v", ruleNames(rules), dropped)
	}
}

// ---------------------------- per-monitor service_down -----------------------

// The rule must name what fired on it, and the phraser reads the same helper,
// so a service that is up can never be spoken as down.
func TestDownServicesNamesOnlyTheDownOnes(t *testing.T) {
	s := State{
		Now: refTime(),
		Facts: map[string]store.Fact{
			"service_down:web":     ago("service_down:web", ServiceDownSource, `"up"`, 5*time.Minute),
			"service_down:db":      ago("service_down:db", ServiceDownSource, `"down"`, 5*time.Minute),
			"service_down:vault":   ago("service_down:vault", ServiceDownSource, `"down"`, 5*time.Minute),
			"service_down:paused":  ago("service_down:paused", ServiceDownSource, `"maintenance"`, 5*time.Minute),
			"service_down:forged":  ago("service_down:forged", "voice", `"down"`, 5*time.Minute),
			"service_down:missing": ago("service_down:missing", ServiceDownSource, `"unknown"`, 5*time.Minute),
		},
	}
	got := DownServices(s)
	want := []string{"db", "vault"} // key order, so speech is stable
	if len(got) != len(want) {
		t.Fatalf("DownServices = %v, want %v", got, want)
	}
	for i := range want {
		if got[i] != want[i] {
			t.Fatalf("DownServices = %v, want %v", got, want)
		}
	}
}

// A monitor paused in kuma must silence that monitor. Before per-monitor facts
// the aggregate stayed "down" and pausing achieved nothing.
func TestPausedMonitorSilencesOnlyItself(t *testing.T) {
	base := map[string]store.Fact{
		"service_down:db":  ago("service_down:db", ServiceDownSource, `"maintenance"`, 5*time.Minute),
		"service_down:web": ago("service_down:web", ServiceDownSource, `"down"`, 5*time.Minute),
	}
	if !ServiceDownRule().Predicate(State{Now: refTime(), Facts: base}) {
		t.Fatal("web is still down, the rule must fire")
	}
	delete(base, "service_down:web")
	if ServiceDownRule().Predicate(State{Now: refTime(), Facts: base}) {
		t.Fatal("only a paused monitor is left, the rule must be quiet")
	}
}

// Edge-triggered: he is told once per transition. A service that stays down
// for a day used to qualify on every tick, with cooldown as the only brake.
func TestServiceDownFiresOncePerTransition(t *testing.T) {
	down := ago("service_down:db", ServiceDownSource, `"down"`, time.Hour)
	s := State{Now: refTime(), Facts: map[string]store.Fact{"service_down:db": down}}
	if !ServiceDownRule().Predicate(s) {
		t.Fatal("first sight of the transition must fire")
	}
	s.LastNudge = map[string]store.Nudge{"service_down": {Ts: down.Ts.Add(time.Minute)}}
	if ServiceDownRule().Predicate(s) {
		t.Fatal("already told about this transition, must be quiet")
	}
	// A second service goes down after that nudge — a new edge, so it fires.
	s.Facts["service_down:web"] = ago("service_down:web", ServiceDownSource, `"down"`, 5*time.Minute)
	if !ServiceDownRule().Predicate(s) {
		t.Fatal("a later transition must fire again")
	}
}

// A monitor that reads down on one poll and up on the next raises nothing
// (Vikunja #536). Technitium did exactly that at 01:27:59 on 05-08-2026, and
// the alarm arrived after the service was already back.
func TestAFlappingMonitorRaisesNothing(t *testing.T) {
	fresh := ago("service_down:dns", ServiceDownSource, `"down"`, 10*time.Second)
	s := State{Now: refTime(), Facts: map[string]store.Fact{"service_down:dns": fresh}}
	if ServiceDownRule().Predicate(s) {
		t.Fatal("a monitor down for ten seconds must not wake him")
	}
	// It stays down. mavpoll writes only on change, so the fact does not move
	// and its age is how long the outage has run.
	s.Now = fresh.Ts.Add(MinDownAge)
	if !ServiceDownRule().Predicate(s) {
		t.Fatal("past MinDownAge this is a real outage and must fire")
	}
}

// The debounce bounds the alarm, not the truth. /dash reads DownServices and
// showing a fresh outage there is right even when phoning him about it is not.
func TestDownServicesReportsAFreshOutage(t *testing.T) {
	s := State{
		Now:   refTime(),
		Facts: map[string]store.Fact{"service_down:dns": ago("service_down:dns", ServiceDownSource, `"down"`, 10*time.Second)},
	}
	if got := DownServices(s); len(got) != 1 || got[0] != "dns" {
		t.Fatalf("DownServices = %v, want [dns]", got)
	}
}

// One monitor flapping must not hide another that has been down for an hour.
func TestAFlapDoesNotMaskARealOutage(t *testing.T) {
	s := State{Now: refTime(), Facts: map[string]store.Fact{
		"service_down:dns": ago("service_down:dns", ServiceDownSource, `"down"`, 10*time.Second),
		"service_down:db":  ago("service_down:db", ServiceDownSource, `"down"`, time.Hour),
	}}
	if !ServiceDownRule().Predicate(s) {
		t.Fatal("db has been down an hour, the rule must fire")
	}
}
