package phraser

import (
	"context"
	"math/rand"
	"strings"
	"testing"
	"time"

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

// downCand builds a service_down candidate the way a tick actually does it:
// one fact per kuma monitor under the prefix, carrying the source and value
// loop.DownServices checks. The old cand() shape wrote a single fact keyed
// plain "service_down", which mavpoll stopped producing, and that is why the
// tests passed through the whole of #534.
func downCand(names ...string) loop.Candidate {
	now := time.Date(2026, 7, 31, 21, 40, 0, 0, time.UTC)
	st := loop.State{Now: now, Facts: map[string]store.Fact{}}
	for _, n := range names {
		key := loop.ServiceDownPrefix + n
		st.Facts[key] = store.Fact{
			Key: key, Ts: now.Add(-3 * time.Minute),
			Source: loop.ServiceDownSource, Value: `"down"`,
		}
	}
	return loop.Candidate{
		Rule:     loop.Rule{Name: "service_down", Severity: loop.Sev4},
		Severity: loop.Sev4, State: st,
	}
}

// The nudge he reads on telegram must name what broke. It is a sev4 that
// reaches him away from the box, so "a service is down" costs him a trip to
// kuma to learn anything at all.
func TestNudgeNamesTheDownService(t *testing.T) {
	// Lowercased before matching: a name that opens the sentence is
	// capitalized by capitalizeFirst, which is wanted.
	nt := newTestTemplates(t, 5)
	for i := 0; i < 40; i++ {
		body, _ := nt.Nudge(downCand("paperless"))
		if !strings.Contains(strings.ToLower(body), "paperless") {
			t.Fatalf("body does not name the service: %q", body)
		}
	}
	// Two down: both named, in the key order the rule itself uses.
	for i := 0; i < 40; i++ {
		body, _ := nt.Nudge(downCand("nginx", "paperless"))
		low := strings.ToLower(body)
		if !strings.Contains(low, "nginx") || !strings.Contains(low, "paperless") {
			t.Fatalf("body drops a service: %q", body)
		}
	}
}

// Russian agrees the verb with the subject, so a list of services cannot go
// into the singular sentence. One down takes the singular set, two or more
// take service_down_many.
func TestNudgeAgreesWithTheServiceCount(t *testing.T) {
	nt := newTestTemplates(t, 9)
	// "упал " keeps its trailing space: "упали" starts with "упал", and the
	// plural must not read as the singular by prefix.
	singular := []string{"не отвечает", "недоступен", "лежит", "упал "}
	plural := []string{"не отвечают", "недоступны", "лежат", "упали"}

	for i := 0; i < 60; i++ {
		body, _ := nt.Nudge(downCand("paperless"))
		if !containsAny(body, singular) {
			t.Fatalf("one down, no singular verb: %q", body)
		}
		if containsAny(body, plural) {
			t.Fatalf("one down, plural wording: %q", body)
		}
	}
	for i := 0; i < 60; i++ {
		body, _ := nt.Nudge(downCand("nginx", "paperless"))
		if !containsAny(body, plural) {
			t.Fatalf("two down, no plural verb: %q", body)
		}
		if containsAny(body, singular) {
			t.Fatalf("two down, singular wording: %q", body)
		}
	}
}

func containsAny(s string, subs []string) bool {
	for _, sub := range subs {
		if strings.Contains(s, sub) {
			return true
		}
	}
	return false
}

// Nothing down means no template fits, and the fallback answers rather than
// the picker inventing a name.
func TestNudgeServiceDownWithoutFacts(t *testing.T) {
	nt := newTestTemplates(t, 5)
	body, mood := nt.Nudge(downCand())
	if body != "Сервис не отвечает." {
		t.Fatalf("fallback body %q", body)
	}
	if mood != "neutral" {
		t.Fatalf("mood %q", mood)
	}
}

// cand builds a candidate the way a tick would.
func cand(rule string, sinceMin int, factKey string) loop.Candidate {
	now := time.Date(2026, 7, 31, 21, 40, 0, 0, time.UTC)
	st := loop.State{Now: now, Facts: map[string]store.Fact{}}
	if sinceMin > 0 || factKey != "" {
		key := rule
		if factKey != "" {
			key = factKey
		}
		st.Facts[rule] = store.Fact{Key: key, Ts: now.Add(-time.Duration(sinceMin) * time.Minute)}
	}
	return loop.Candidate{Rule: loop.Rule{Name: rule, Severity: loop.Sev1}, Severity: loop.Sev1, State: st}
}

func newTestTemplates(t *testing.T, seed int64) *NudgeTemplates {
	t.Helper()
	nt, err := NewNudgeTemplates(rand.NewSource(seed))
	if err != nil {
		t.Fatalf("NewNudgeTemplates: %v", err)
	}
	return nt
}

func TestNudgeTemplatesLoad(t *testing.T) {
	nt := newTestTemplates(t, 1)
	for _, rule := range []string{"water", "meal", "break", "service_down", "service_down_many", "netdata_critical", "routine", "morning", "default"} {
		set, ok := nt.file.Rules[rule]
		if !ok {
			t.Errorf("no templates for %q", rule)
			continue
		}
		if len(set.Variants) < 5 {
			t.Errorf("%s: only %d variants", rule, len(set.Variants))
		}
		// Every rule needs one variant that needs no value, or a candidate
		// without context has nothing to say. routine, morning and
		// service_down are exempt: they always carry a name and must always
		// say it. service_down's predicate cannot fire without a down fact,
		// so loop.DownServices always has something to fill {service} with,
		// and the nameless variant it used to carry was the bug (#534) —
		// {service} never filled, so that variant was the only fillable one.
		plain := 0
		seen := map[string]bool{}
		for _, v := range set.Variants {
			if !placeholderRE.MatchString(v) {
				plain++
			}
			if seen[v] {
				t.Errorf("%s: duplicate variant %q", rule, v)
			}
			seen[v] = true
		}
		if plain == 0 && rule != "routine" && rule != "morning" && !strings.HasPrefix(rule, "service_down") {
			t.Errorf("%s: every variant needs a placeholder value", rule)
		}
	}
}

// The whole point of the picker: never the same words twice in a row.
func TestNudgeNoImmediateRepeat(t *testing.T) {
	nt := newTestTemplates(t, 7)
	prev := ""
	for i := 0; i < 200; i++ {
		body, _ := nt.Nudge(cand("water", 200, ""))
		if body == prev {
			t.Fatalf("repeat at %d: %q", i, body)
		}
		prev = body
	}
}

// Same seed, same sequence — otherwise the fixture score would drift run to run.
func TestNudgeDeterministicWithSeed(t *testing.T) {
	var runs [2][]string
	for r := range runs {
		nt := newTestTemplates(t, 42)
		for i := 0; i < 20; i++ {
			body, _ := nt.Nudge(cand("break", 100, ""))
			runs[r] = append(runs[r], body)
		}
	}
	for i := range runs[0] {
		if runs[0][i] != runs[1][i] {
			t.Fatalf("run %d differs: %q vs %q", i, runs[0][i], runs[1][i])
		}
	}
}

// A variant is only used when its value exists, and nothing half-filled ships.
func TestNudgeNoLeftoverPlaceholders(t *testing.T) {
	nt := newTestTemplates(t, 3)
	cases := []loop.Candidate{
		cand("water", 0, ""),   // no duration
		cand("water", 30, ""),  // under an hour
		cand("water", 200, ""), // hours
		downCand("vaultwarden"),
		downCand(), // nothing down: the fallback answers
		cand("routine:таблетки", 0, ""),
		cand("morning:утро", 0, ""),
		cand("unknown_rule", 0, ""),
	}
	for _, c := range cases {
		for i := 0; i < 40; i++ {
			body, mood := nt.Nudge(c)
			if body == "" {
				t.Fatalf("%s: empty body", c.Rule.Name)
			}
			if strings.ContainsAny(body, "{}%") {
				t.Fatalf("%s: unfilled template %q", c.Rule.Name, body)
			}
			if mood != "neutral" {
				t.Fatalf("%s: mood %q", c.Rule.Name, mood)
			}
		}
	}
}

// The routine name must actually land in the text.
func TestNudgeSubstitutesWhat(t *testing.T) {
	nt := newTestTemplates(t, 11)
	for i := 0; i < 40; i++ {
		body, _ := nt.Nudge(cand("routine:таблетки", 0, ""))
		if !strings.Contains(strings.ToLower(body), "таблетки") {
			t.Fatalf("routine text lost the name: %q", body)
		}
	}
}

func TestRuSinceWords(t *testing.T) {
	cases := []struct {
		min  int
		want string
	}{
		{30, ""},
		{60, "час"},
		{95, "полтора часа"},
		{150, "два с половиной часа"},
		{190, "три часа"},
		{240, "четыре часа"},
		{430, "семь часов"},
		{660, "одиннадцать часов"},
		{60 * 30, "больше суток"},
	}
	for _, c := range cases {
		got := ruSinceWords(time.Duration(c.min) * time.Minute)
		if got != c.want {
			t.Errorf("%d min: got %q want %q", c.min, got, c.want)
		}
	}
}

// Templates are the default: a nudge must not reach the model at all.
func TestLLMPhraserUsesTemplatesByDefault(t *testing.T) {
	spy := newGrammarSpy(t)
	p := NewLLMPhraserAt(spy.srv.URL, Config{})
	pn, err := p.PhraseNudge(context.Background(), cand("water", 200, ""))
	if err != nil {
		t.Fatalf("PhraseNudge: %v", err)
	}
	if len(spy.grammars) != 0 {
		t.Errorf("nudge hit the model %d times, want 0", len(spy.grammars))
	}
	if !strings.Contains(strings.ToLower(pn.Body), "вод") {
		t.Errorf("nudge is not the water template: %q", pn.Body)
	}
}

// ...and the flag brings the model back.
func TestLLMNudgesFlagRestoresTheModel(t *testing.T) {
	spy := newGrammarSpy(t)
	p := NewLLMPhraserAt(spy.srv.URL, Config{LLMNudges: true})
	pn, err := p.PhraseNudge(context.Background(), cand("water", 200, ""))
	if err != nil {
		t.Fatalf("PhraseNudge: %v", err)
	}
	if len(spy.grammars) != 1 {
		t.Fatalf("nudge hit the model %d times, want 1", len(spy.grammars))
	}
	if pn.Body != "ага" {
		t.Errorf("body = %q, want the model's reply", pn.Body)
	}
}

func TestNudgeTemplatesPhraseNudge(t *testing.T) {
	nt := newTestTemplates(t, 5)
	pn, err := nt.PhraseNudge(context.Background(), cand("water", 200, ""))
	if err != nil {
		t.Fatalf("PhraseNudge: %v", err)
	}
	if pn.Body == "" || pn.Summary != pn.Body || pn.Mood != "neutral" {
		t.Fatalf("bad nudge: %+v", pn)
	}
}
