package phraser

import (
	"context"
	"strings"
	"testing"
	"time"
	"unicode/utf8"

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

// ----------------------------- nudges --------------------------------------

func TestPhraseNudgeWaterMentionsDuration(t *testing.T) {
	now := time.Now().UTC()
	earlier := now.Add(-4 * time.Hour)
	st := loop.State{
		Now:   now,
		Facts: map[string]store.Fact{"water": {Key: "water", Ts: earlier, Source: "tap:water", Value: `"250ml"`}},
	}
	c := loop.Candidate{Rule: loop.WaterRule(), Severity: loop.Sev1, State: st}
	pn, err := NewStub().PhraseNudge(context.Background(), c)
	if err != nil {
		t.Fatalf("PhraseNudge: %v", err)
	}
	if pn.Candidate.Rule.Name != "water" {
		t.Fatalf("candidate rule: want water, got %s", pn.Candidate.Rule.Name)
	}
	if !strings.Contains(pn.Body, "water") || !strings.Contains(pn.Body, "4 hours") {
		t.Fatalf("body should mention water + 4 hours, got %q", pn.Body)
	}
	if pn.Summary != "drink water" {
		t.Fatalf("summary: want 'drink water', got %q", pn.Summary)
	}
	// Summary must be shorter than Body — the away-channel minimal-body rule.
	if len(pn.Summary) >= len(pn.Body) {
		t.Fatalf("summary should be shorter than body: body=%q (%d), summary=%q (%d)", pn.Body, len(pn.Body), pn.Summary, len(pn.Summary))
	}
}

func TestPhraseNudgeMealNoDataStillPhrases(t *testing.T) {
	// predicate wouldn't fire on no data (since==null), but the phraser is
	// still required to produce SOMETHING if called — never return empty body.
	st := loop.State{Now: time.Now().UTC(), Facts: map[string]store.Fact{}}
	c := loop.Candidate{Rule: loop.MealRule(), Severity: loop.Sev1, State: st}
	pn, err := NewStub().PhraseNudge(context.Background(), c)
	if err != nil {
		t.Fatalf("PhraseNudge: %v", err)
	}
	if pn.Body == "" {
		t.Fatal("body empty on no-data — should still phrase")
	}
	if pn.Summary == "" {
		t.Fatal("summary empty on no-data")
	}
}

func TestPhraseNudgeBreakDeskDuration(t *testing.T) {
	now := time.Now().UTC()
	st := loop.State{
		Now: now,
		Facts: map[string]store.Fact{
			"desk_active": {Key: "desk_active", Ts: now.Add(-30 * time.Second)},
			"break":       {Key: "break", Ts: now.Add(-95 * time.Minute)},
		},
	}
	c := loop.Candidate{Rule: loop.BreakRule(), Severity: loop.Sev2, State: st}
	pn, _ := NewStub().PhraseNudge(context.Background(), c)
	if !strings.Contains(pn.Body, "desk") || !strings.Contains(pn.Body, "an hour and a half") {
		t.Fatalf("break body should mention desk + duration, got %q", pn.Body)
	}
	if pn.Summary != "take a break" {
		t.Fatalf("summary: want 'take a break', got %q", pn.Summary)
	}
}

func TestPhraseNudgeServiceDownNamedService(t *testing.T) {
	// one fact per kuma monitor → the phrase names the monitor that is down.
	now := time.Now().UTC()
	st := loop.State{
		Now: now,
		Facts: map[string]store.Fact{
			"service_down:nginx": {Key: "service_down:nginx", Ts: now, Source: loop.ServiceDownSource, Value: `"down"`},
			"service_down:db":    {Key: "service_down:db", Ts: now, Source: loop.ServiceDownSource, Value: `"up"`},
		},
	}
	c := loop.Candidate{Rule: loop.ServiceDownRule(), Severity: loop.Sev4, State: st}
	pn, _ := NewStub().PhraseNudge(context.Background(), c)
	if !strings.Contains(pn.Body, "nginx") {
		t.Fatalf("body should name the service, got %q", pn.Body)
	}
	if !strings.Contains(pn.Summary, "nginx") {
		t.Fatalf("summary should name the service, got %q", pn.Summary)
	}
}

func TestPhraseNudgeServiceDownGenericKey(t *testing.T) {
	// the old aggregate key, still in the store from before the per-monitor
	// facts landed → the generic phrase, never a phantom "service_down down".
	now := time.Now().UTC()
	st := loop.State{
		Now:   now,
		Facts: map[string]store.Fact{"service_down": {Key: "service_down", Ts: now, Source: loop.ServiceDownSource, Value: `"down"`}},
	}
	c := loop.Candidate{Rule: loop.ServiceDownRule(), Severity: loop.Sev4, State: st}
	pn, _ := NewStub().PhraseNudge(context.Background(), c)
	if strings.Contains(pn.Body, "service_down down") {
		t.Fatalf("body shouldn't repeat the key verbatim: %q", pn.Body)
	}
}

// Two monitors down at once must both be named — he needs to know the blast
// radius, and "a service is down" was the whole defect being fixed here.
func TestPhraseNudgeServiceDownNamesEveryDownMonitor(t *testing.T) {
	now := time.Now().UTC()
	st := loop.State{
		Now: now,
		Facts: map[string]store.Fact{
			"service_down:nginx": {Key: "service_down:nginx", Ts: now, Source: loop.ServiceDownSource, Value: `"down"`},
			"service_down:db":    {Key: "service_down:db", Ts: now, Source: loop.ServiceDownSource, Value: `"down"`},
		},
	}
	c := loop.Candidate{Rule: loop.ServiceDownRule(), Severity: loop.Sev4, State: st}
	pn, _ := NewStub().PhraseNudge(context.Background(), c)
	for _, want := range []string{"nginx", "db"} {
		if !strings.Contains(pn.Body, want) {
			t.Fatalf("body should name %q, got %q", want, pn.Body)
		}
	}
}

func TestPhraseNudgeUnknownRuleFallsBack(t *testing.T) {
	// a rule without a dedicated template — generic fallback names the rule +
	// severity gist. never empty.
	unk := loop.Rule{Name: "custom_rule", Severity: loop.Sev3}
	st := loop.State{Now: time.Now().UTC(), Facts: map[string]store.Fact{}}
	c := loop.Candidate{Rule: unk, Severity: loop.Sev3, State: st}
	pn, _ := NewStub().PhraseNudge(context.Background(), c)
	if pn.Body == "" || pn.Summary == "" {
		t.Fatalf("fallback should produce both: body=%q summary=%q", pn.Body, pn.Summary)
	}
	if pn.Summary != "custom_rule" {
		t.Fatalf("fallback summary should be the rule name, got %q", pn.Summary)
	}
}

// ----------------------------- reminders ------------------------------------

func TestPhraseReminderExtractsText(t *testing.T) {
	// the router's reminder slot extraction produces {"text": "wake me"} — the
	// phraser unwraps it. the reminder's words are the user's; no editorializing.
	rd := loop.ReminderDecision{
		Reminder: store.Reminder{Payload: `{"text":"wake me at 7"}`},
		State:    loop.State{Now: time.Now().UTC()},
	}
	pr, err := NewStub().PhraseReminder(context.Background(), rd)
	if err != nil {
		t.Fatalf("PhraseReminder: %v", err)
	}
	if pr.Body != "wake me at 7" {
		t.Fatalf("body: want 'wake me at 7', got %q", pr.Body)
	}
	if pr.Summary != "wake me at 7" {
		t.Fatalf("summary: want 'wake me at 7', got %q", pr.Summary)
	}
}

func TestPhraseReminderTruncatesLongSummary(t *testing.T) {
	// away channels (ntfy/telegram) get Summary; a long reminder text →
	// truncated so the lock-screen preview isn't a paragraph.
	long := "remind me to do the thing where i need to walk all the way over there and back before the sun comes up"
	rd := loop.ReminderDecision{
		Reminder: store.Reminder{Payload: `{"text":"` + long + `"}`},
		State:    loop.State{Now: time.Now().UTC()},
	}
	pr, _ := NewStub().PhraseReminder(context.Background(), rd)
	if len(pr.Summary) > 63 {
		t.Fatalf("summary should be truncated to ~60, got %d: %q", len(pr.Summary), pr.Summary)
	}
	if pr.Body != long {
		t.Fatalf("body should be the full text, got %q", pr.Body)
	}
}

// The same truncation, in the language she actually speaks. The test above is
// ASCII, which is what let the byte arithmetic stand: `len(s) > 60` and `s[:57]`
// cut a Russian reminder at about 28 letters instead of 60, and landed inside a
// letter about half the time. Summary is what voicesink hands to piper and what
// the telegram sink posts, so half a rune was spoken and sent.
func TestPhraseReminderSummaryCountsRunesNotBytes(t *testing.T) {
	long := "позвонить маме и забрать посылку из пункта выдачи на соседней улице до восьми вечера"
	rd := loop.ReminderDecision{
		Reminder: store.Reminder{Payload: `{"text":"` + long + `"}`},
		State:    loop.State{Now: time.Now().UTC()},
	}
	pr, _ := NewStub().PhraseReminder(context.Background(), rd)
	if !utf8.ValidString(pr.Summary) {
		t.Fatalf("summary is not valid UTF-8, it was cut mid-letter: %q", pr.Summary)
	}
	if n := utf8.RuneCountInString(pr.Summary); n > summaryLimit {
		t.Fatalf("summary = %d runes, want at most %d: %q", n, summaryLimit, pr.Summary)
	}
	// The cut must be near the limit, not near half of it. A byte count would
	// stop at 28 letters here.
	if n := utf8.RuneCountInString(pr.Summary); n < summaryLimit-5 {
		t.Fatalf("summary = %d runes, cut far too early — counted in bytes? %q", n, pr.Summary)
	}
	if pr.Mood == "" {
		t.Error("Mood is empty; the Stub is a production fallback and owes the contract a mood")
	}
}

func TestPhraseReminderNonJSONPayload(t *testing.T) {
	// a payload that isn't JSON → the phraser falls back to the raw string.
	rd := loop.ReminderDecision{
		Reminder: store.Reminder{Payload: "just a plain string"},
		State:    loop.State{Now: time.Now().UTC()},
	}
	pr, _ := NewStub().PhraseReminder(context.Background(), rd)
	if pr.Body != "just a plain string" {
		t.Fatalf("non-json body: want the raw string, got %q", pr.Body)
	}
	if pr.Summary != "just a plain string" {
		t.Fatalf("non-json summary: want the raw string, got %q", pr.Summary)
	}
}

func TestPhraseReminderEmptyPayload(t *testing.T) {
	// don't ship an empty message — the reminder path must produce something.
	rd := loop.ReminderDecision{
		Reminder: store.Reminder{Payload: ""},
		State:    loop.State{Now: time.Now().UTC()},
	}
	pr, _ := NewStub().PhraseReminder(context.Background(), rd)
	if pr.Body == "" || pr.Summary == "" {
		t.Fatalf("empty payload should fall back to 'reminder': body=%q summary=%q", pr.Body, pr.Summary)
	}
	if pr.Body != "reminder" {
		t.Fatalf("empty payload body: want 'reminder', got %q", pr.Body)
	}
}

// ----------------------------- chat -----------------------------------------

func TestPhraseChatReturnsNonEmpty(t *testing.T) {
	p := NewStub()
	reply, err := p.PhraseChat(context.Background(), "как дела", nil)
	if err != nil {
		t.Fatalf("PhraseChat: %v", err)
	}
	if reply == "" {
		t.Fatal("PhraseChat returned empty reply")
	}
}

func TestPhraseChatWithHistory(t *testing.T) {
	p := NewStub()
	history := []dialogue.Turn{
		{Text: "привет", Intent: dialogue.IntentChat},
		{Text: "как тебя зовут", Intent: dialogue.IntentChat},
	}
	reply, err := p.PhraseChat(context.Background(), "расскажи о себе", history)
	if err != nil {
		t.Fatalf("PhraseChat with history: %v", err)
	}
	if reply == "" {
		t.Fatal("PhraseChat with history returned empty reply")
	}
}

// ----------------------------- interface guard ------------------------------

func TestStubSatisfiesPhraser(t *testing.T) {
	// compile-time guard: the Stub must satisfy the Phraser interface so the
	// daemon can wire it. the production LLM impl satisfies the same interface.
	var _ Phraser = (*Stub)(nil)
	// also exercise the methods once so the guard isn't the only assertion.
	p := NewStub()
	if _, err := p.PhraseNudge(context.Background(), loop.Candidate{Rule: loop.WaterRule(), Severity: loop.Sev1, State: loop.State{Now: time.Now().UTC(), Facts: map[string]store.Fact{}}}); err != nil {
		t.Fatalf("PhraseNudge: %v", err)
	}
	if _, err := p.PhraseReminder(context.Background(), loop.ReminderDecision{Reminder: store.Reminder{Payload: "{}"}, State: loop.State{Now: time.Now().UTC()}}); err != nil {
		t.Fatalf("PhraseReminder: %v", err)
	}
	if _, err := p.PhraseChat(context.Background(), "как дела", nil); err != nil {
		t.Fatalf("PhraseChat: %v", err)
	}
}

func TestStubProducesDeliveryTypes(t *testing.T) {
	// the output must be the delivery.Phrased* structs the dispatcher
	// consumes — the phraser owns the full output contract.
	pn, _ := NewStub().PhraseNudge(context.Background(), loop.Candidate{Rule: loop.WaterRule(), Severity: loop.Sev1, State: loop.State{Now: time.Now().UTC(), Facts: map[string]store.Fact{}}})
	if _, ok := any(pn).(delivery.PhrasedNudge); !ok {
		t.Fatalf("PhraseNudge must return delivery.PhrasedNudge, got %T", pn)
	}
}

// ----------------------------- helpers --------------------------------------
// (the per-rule templates change output shape; assert via strings.Contains
// against salient fragments, not exact strings — keeps the tests robust to
// tone tweaks in the Stub.)
