package services

import (
	"strings"
	"testing"
)

// TestTruncateForPrimer pins the per-side truncation contract used by
// buildPrimerIfFresh — the primer block must stay single-line so the
// tmux send-keys -l command doesn't fragment it, and runaway prior
// answers must collapse to a manageable size. t-paliad-161 follow-up.
func TestTruncateForPrimer(t *testing.T) {
	t.Run("collapses newlines + tabs to spaces", func(t *testing.T) {
		got := truncateForPrimer("hello\nworld\ttab")
		if got != "hello world\ttab" && got != "hello world tab" {
			// truncateForPrimer normalises \r and \n but leaves tabs;
			// either result above is acceptable as long as no \n leaks.
			t.Errorf("got %q", got)
		}
		if strings.ContainsAny(got, "\r\n") {
			t.Errorf("control chars leaked: %q", got)
		}
	})
	t.Run("collapses repeated spaces", func(t *testing.T) {
		got := truncateForPrimer("a   b    c")
		if got != "a b c" {
			t.Errorf("got %q; want %q", got, "a b c")
		}
	})
	t.Run("truncates oversized input with ellipsis", func(t *testing.T) {
		long := strings.Repeat("x", MaxPrimerCharsPerSide+50)
		got := truncateForPrimer(long)
		if !strings.HasSuffix(got, "…") {
			t.Errorf("missing ellipsis: %q", got[len(got)-10:])
		}
		// The 'x' count should be exactly MaxPrimerCharsPerSide
		// (ellipsis adds bytes but no x).
		if strings.Count(got, "x") != MaxPrimerCharsPerSide {
			t.Errorf("got %d x's; want %d", strings.Count(got, "x"), MaxPrimerCharsPerSide)
		}
	})
	t.Run("leaves short input untouched", func(t *testing.T) {
		got := truncateForPrimer("Was steht heute an?")
		if got != "Was steht heute an?" {
			t.Errorf("short input mangled: %q", got)
		}
	})
}

// TestTurnContext_EnvelopePrefix pins the bracket-block format the
// SKILL.md parser branches on. Wrong format = the inline widget's
// page-context never reaches Paliadin. t-paliad-161.
func TestTurnContext_EnvelopePrefix(t *testing.T) {
	t.Run("nil context produces empty prefix", func(t *testing.T) {
		var c *TurnContext
		if got := c.EnvelopePrefix(); got != "" {
			t.Errorf("nil ctx prefix = %q; want \"\"", got)
		}
	})
	t.Run("empty context produces empty prefix", func(t *testing.T) {
		got := (&TurnContext{}).EnvelopePrefix()
		if got != "" {
			t.Errorf("empty ctx prefix = %q; want \"\"", got)
		}
	})
	t.Run("route only", func(t *testing.T) {
		got := (&TurnContext{RouteName: "dashboard"}).EnvelopePrefix()
		if got != "[ctx route=dashboard] " {
			t.Errorf("got %q", got)
		}
	})
	t.Run("route + entity", func(t *testing.T) {
		got := (&TurnContext{
			RouteName:         "projects.detail",
			PrimaryEntityType: "project",
			PrimaryEntityID:   "61e3eb9e-4a8b-7c1f-9d0e-2f5a0c8e1b3d",
		}).EnvelopePrefix()
		want := "[ctx route=projects.detail entity=project:61e3eb9e-4a8b-7c1f-9d0e-2f5a0c8e1b3d] "
		if got != want {
			t.Errorf("got %q; want %q", got, want)
		}
	})
	t.Run("selection with spaces is quoted", func(t *testing.T) {
		got := (&TurnContext{
			RouteName:         "agenda",
			UserSelectionText: "Klageerwiderung Acme v. Müller",
		}).EnvelopePrefix()
		if !strings.Contains(got, `selection="Klageerwiderung Acme v. Müller"`) {
			t.Errorf("missing quoted selection: %q", got)
		}
	})
	t.Run("selection truncated at MaxSelectionChars", func(t *testing.T) {
		// 'q' doesn't appear anywhere in the prefix structure (`[ctx
		// route=events selection="…"]`), so q-count isolates exactly the
		// selection's contribution.
		long := strings.Repeat("q", MaxSelectionChars+50)
		got := (&TurnContext{
			RouteName:         "events",
			UserSelectionText: long,
		}).EnvelopePrefix()
		if !strings.Contains(got, "…\"") {
			t.Errorf("expected truncation marker (…\"); got %q", got[:80])
		}
		if strings.Count(got, "q") != MaxSelectionChars {
			t.Errorf("expected exactly %d 'q's; got %d", MaxSelectionChars, strings.Count(got, "q"))
		}
	})
	t.Run("quotes inside selection are escaped", func(t *testing.T) {
		got := (&TurnContext{
			RouteName:         "agenda",
			UserSelectionText: `she said "hi" then`,
		}).EnvelopePrefix()
		if !strings.Contains(got, `\"hi\"`) {
			t.Errorf("quotes not escaped: %q", got)
		}
	})
	t.Run("view + filter combine cleanly", func(t *testing.T) {
		got := (&TurnContext{
			RouteName:     "events",
			ViewMode:      "calendar",
			FilterSummary: "status=overdue",
		}).EnvelopePrefix()
		want := "[ctx route=events view=calendar filter=status=overdue] "
		if got != want {
			t.Errorf("got %q; want %q", got, want)
		}
	})
}

// Tests for the PoC paliadin trailer parser. The parser is load-bearing:
// it's how the dashboard learns which tools Claude used, how many rows
// each returned, and how Claude classified the question. Wrong parsing
// = silently broken monitoring.

func TestSplitTrailer_HappyPath(t *testing.T) {
	body := strings.TrimSpace(`
Diese Woche stehen 3 Fristen an:

- 16.05. Klageerwiderung [#deadline-OPEN:c47bd2-1]
- 17.05. Replik [#deadline-OPEN:e92a01-3]
- 20.05. Wiedereinsetzung [#deadline-OPEN:f31b09-7]

---
[paliadin-meta]
used_tools: search_my_deadlines, lookup_court
rows_seen: 3, 1
classifier_tag: data
[/paliadin-meta]
`)
	clean, meta := splitTrailer(body)

	if strings.Contains(clean, "[paliadin-meta]") {
		t.Fatalf("trailer not stripped from body:\n%s", clean)
	}
	if !strings.HasPrefix(clean, "Diese Woche") {
		t.Errorf("body lost prefix: %q", clean[:50])
	}
	wantTools := []string{"search_my_deadlines", "lookup_court"}
	if len(meta.UsedTools) != len(wantTools) {
		t.Fatalf("UsedTools len = %d; want %d", len(meta.UsedTools), len(wantTools))
	}
	for i, want := range wantTools {
		if meta.UsedTools[i] != want {
			t.Errorf("UsedTools[%d] = %q; want %q", i, meta.UsedTools[i], want)
		}
	}
	wantRows := []int{3, 1}
	if len(meta.RowsSeen) != len(wantRows) {
		t.Fatalf("RowsSeen len = %d; want %d", len(meta.RowsSeen), len(wantRows))
	}
	for i, want := range wantRows {
		if meta.RowsSeen[i] != want {
			t.Errorf("RowsSeen[%d] = %d; want %d", i, meta.RowsSeen[i], want)
		}
	}
	if meta.ClassifierTag != "data" {
		t.Errorf("ClassifierTag = %q; want %q", meta.ClassifierTag, "data")
	}
}

func TestSplitTrailer_NoTrailer(t *testing.T) {
	body := "Just a response, no trailer."
	clean, meta := splitTrailer(body)
	if clean != body {
		t.Errorf("body changed: %q vs %q", clean, body)
	}
	if len(meta.UsedTools) != 0 || len(meta.RowsSeen) != 0 || meta.ClassifierTag != "" {
		t.Errorf("meta should be zero: %+v", meta)
	}
}

func TestSplitTrailer_EmptyToolsList(t *testing.T) {
	body := strings.TrimSpace(`
Klageerwiderung ist die Erwiderung auf die Klage.

---
[paliadin-meta]
used_tools:
rows_seen:
classifier_tag: concept
[/paliadin-meta]
`)
	clean, meta := splitTrailer(body)
	if strings.Contains(clean, "[paliadin-meta]") {
		t.Errorf("trailer not stripped")
	}
	if len(meta.UsedTools) != 0 {
		t.Errorf("UsedTools should be empty: %v", meta.UsedTools)
	}
	if meta.ClassifierTag != "concept" {
		t.Errorf("ClassifierTag = %q; want concept", meta.ClassifierTag)
	}
}

func TestCountChips(t *testing.T) {
	cases := []struct {
		body string
		want int
	}{
		{"plain text", 0},
		{"see [#deadline-OPEN:abc-123]", 1},
		{"two [#deadline-OPEN:abc] and [#projekt-OPEN:slug]", 2},
		{"chip nav [chip:nav:/projects/123]", 1},
		{"chip filter [chip:filter:status=pending]", 1},
		{"mixed [#frist-OPEN:x] and [chip:nav:/y]", 2},
		// Hallucinated / malformed markers don't count.
		{"[#deadline-OPEN:]", 0},
		{"[#deadline-OPEN]", 0},
		{"[chip:invalid]", 0},
	}
	for _, c := range cases {
		got := countChips(c.body)
		if got != c.want {
			t.Errorf("countChips(%q) = %d; want %d", c.body, got, c.want)
		}
	}
}

func TestApproxTokenCount(t *testing.T) {
	cases := []struct {
		s    string
		want int
	}{
		{"", 0},
		{"hello", 1},                              // 1 word × 1.3 → 1
		{"hello world", 2},                        // 2 × 1.3 = 2.6 → 2
		{"one two three four five six seven", 9}, // 7 × 1.3 = 9.1 → 9
	}
	for _, c := range cases {
		got := approxTokenCount(c.s)
		if got != c.want {
			t.Errorf("approxTokenCount(%q) = %d; want %d", c.s, got, c.want)
		}
	}
}

func TestSanitiseForTmux(t *testing.T) {
	in := "first line\nsecond line\rthird"
	got := sanitiseForTmux(in)
	if strings.ContainsAny(got, "\n\r") {
		t.Errorf("sanitiseForTmux did not strip newlines: %q", got)
	}
}

func TestPaliadinOwnerEmail_IsLowercaseStable(t *testing.T) {
	// Sanity check: the constant matches the email seeded in
	// migration 023 verbatim. If it ever drifts, the gate would
	// reject m on a fresh DB without anyone noticing.
	want := "matthias.siebels@hoganlovells.com"
	if PaliadinOwnerEmail != want {
		t.Fatalf("PaliadinOwnerEmail = %q; want %q (matches migration 023 seed)",
			PaliadinOwnerEmail, want)
	}
	// Lowercase invariant — the gate uses strings.EqualFold but we
	// store + compare lowercase consistently anyway.
	if strings.ToLower(PaliadinOwnerEmail) != PaliadinOwnerEmail {
		t.Errorf("PaliadinOwnerEmail must be lowercase: %q", PaliadinOwnerEmail)
	}
}

func TestSanitiseForTmux_TruncatesLong(t *testing.T) {
	long := strings.Repeat("x", 10_000)
	got := sanitiseForTmux(long)
	if !strings.HasSuffix(got, "[…truncated]") {
		t.Errorf("expected truncation marker, got tail: %q", got[len(got)-20:])
	}
}
