package pipeline

import (
	"os"
	"path/filepath"
	"strings"
	"testing"

	"textmachine/backend/internal/config"
	"textmachine/backend/internal/llm"
)

func testBook() *config.Book {
	return &config.Book{
		BookID: "b", Title: "T", SourceLang: "ja", TargetLang: "ru",
		Genre: "ранобэ", Audience: "взрослые", Venuti: 0.6,
		Honorifics: "keep", Transcription: "polivanov", Footnotes: "minimal",
	}
}

func writeTemplate(t *testing.T, content string) *PromptTemplate {
	t.Helper()
	path := filepath.Join(t.TempDir(), "tpl.md")
	if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
		t.Fatal(err)
	}
	tpl, err := LoadPromptTemplate(path)
	if err != nil {
		t.Fatal(err)
	}
	return tpl
}

// Инвариант §3.1: рендер — чистая функция; два рендера байт-в-байт идентичны.
// Без этого request-hash плывёт между запусками, resume пере-переводит и
// пере-оплачивает главу, а byte-prefix кэш DeepSeek промахивается.
func TestRenderIsDeterministic(t *testing.T) {
	tpl := writeTemplate(t, "Перевод с {{source_lang}} на {{target_lang}}, жанр {{genre}}, venuti {{venuti}}.\n---USER---\nТекст: {{text}}\nЧерновик: {{draft}}")
	v := RenderVars{Book: testBook(), Text: "исходник", Draft: "черновик"}

	m1, err := Messages(tpl, v)
	if err != nil {
		t.Fatal(err)
	}
	for i := 0; i < 100; i++ {
		m2, err := Messages(tpl, v)
		if err != nil {
			t.Fatal(err)
		}
		if len(m1) != len(m2) {
			t.Fatal("length differs")
		}
		for j := range m1 {
			if m1[j] != m2[j] {
				t.Fatalf("render %d differs at message %d:\n%+v\nvs\n%+v", i, j, m1[j], m2[j])
			}
		}
	}
	h1 := RequestHash("b", 1, 0, 0, "draft", "translator", "m", 0.3, "off", false, 1024, "snap", m1)
	h2 := RequestHash("b", 1, 0, 0, "draft", "translator", "m", 0.3, "off", false, 1024, "snap", m1)
	if h1 != h2 {
		t.Fatal("request hash is not stable")
	}
}

func TestRequestHashSensitivity(t *testing.T) {
	msgs := []llm.Message{{Role: "system", Content: "s", CacheBoundary: true}, {Role: "user", Content: "u"}}
	base := RequestHash("b", 1, 0, 0, "draft", "translator", "m", 0.3, "off", false, 1024, "snap", msgs)

	variants := map[string]string{
		"model":     RequestHash("b", 1, 0, 0, "draft", "translator", "m2", 0.3, "off", false, 1024, "snap", msgs),
		"temp":      RequestHash("b", 1, 0, 0, "draft", "translator", "m", 0.4, "off", false, 1024, "snap", msgs),
		"snapshot":  RequestHash("b", 1, 0, 0, "draft", "translator", "m", 0.3, "off", false, 1024, "snap2", msgs),
		"maxTokens": RequestHash("b", 1, 0, 0, "draft", "translator", "m", 0.3, "off", false, 2048, "snap", msgs),
		"chunk":     RequestHash("b", 1, 1, 0, "draft", "translator", "m", 0.3, "off", false, 1024, "snap", msgs),
		"content":   RequestHash("b", 1, 0, 0, "draft", "translator", "m", 0.3, "off", false, 1024, "snap", []llm.Message{{Role: "system", Content: "s", CacheBoundary: true}, {Role: "user", Content: "u2"}}),
	}
	for name, h := range variants {
		if h == base {
			t.Errorf("hash must change when %s changes", name)
		}
	}

	// Конкатенация с разделителем не склеивается: ("ab","c") != ("a","bc").
	a := RequestHash("b", 1, 0, 0, "draft", "translator", "m", 0.3, "off", false, 1024, "snap",
		[]llm.Message{{Role: "user", Content: "ab"}, {Role: "user", Content: "c"}})
	b := RequestHash("b", 1, 0, 0, "draft", "translator", "m", 0.3, "off", false, 1024, "snap",
		[]llm.Message{{Role: "user", Content: "a"}, {Role: "user", Content: "bc"}})
	if a == b {
		t.Fatal("field separator is not injective")
	}
}

func TestRenderRejectsUnknownPlaceholder(t *testing.T) {
	tpl := writeTemplate(t, "Привет {{nonexistent}}\n---USER---\n{{text}}")
	_, err := Messages(tpl, RenderVars{Book: testBook(), Text: "x"})
	if err == nil {
		t.Fatal("unknown placeholder must fail loud, not render empty")
	}
}

// Литеральные «{{…}}» в ИСХОДНИКЕ/черновике — просто текст: значения
// вставляются verbatim и не пере-сканируются (однопроходный рендер).
func TestRenderValuesAreNotRescanned(t *testing.T) {
	tpl := writeTemplate(t, "Система.\n---USER---\nТекст: {{text}}\nЧерновик: {{draft}}")
	v := RenderVars{
		Book:  testBook(),
		Text:  "герой сказал {{draft}} и ушёл", // literal braces in source
		Draft: "примечание {{TN: сноска}} осталось", // literal braces in prior LLM output
	}
	msgs, err := Messages(tpl, v)
	if err != nil {
		t.Fatalf("literal braces inside values must not fail: %v", err)
	}
	user := msgs[1].Content
	for _, want := range []string{"герой сказал {{draft}} и ушёл", "примечание {{TN: сноска}} осталось"} {
		if !strings.Contains(user, want) {
			t.Fatalf("value %q must survive verbatim, got:\n%s", want, user)
		}
	}
}

func TestTemplateRequiresUserSeparator(t *testing.T) {
	path := filepath.Join(t.TempDir(), "bad.md")
	if err := os.WriteFile(path, []byte("только системная часть"), 0o644); err != nil {
		t.Fatal(err)
	}
	if _, err := LoadPromptTemplate(path); err == nil {
		t.Fatal("template without ---USER--- must be rejected")
	}
}

func TestNormalizeSource(t *testing.T) {
	// BOM, CRLF/CR и разложенная форма Unicode → канон; хеш стабилен между
	// ОС/редакторами.
	crlf := "\uFEFFпервая\r\nвторая\rтретья"
	if got := NormalizeSource(crlf); got != "первая\nвторая\nтретья" {
		t.Fatalf("BOM/CRLF/CR not normalized: %q", got)
	}
	// Явно строим NFD (e + U+0301) и NFC (U+00E9), чтобы формы точно различались.
	decomposed := "cafe\u0301"
	precomposed := "caf\u00e9"
	if decomposed == precomposed {
		t.Fatal("test setup: the two forms must differ before normalization")
	}
	if NormalizeSource(decomposed) != NormalizeSource(precomposed) {
		t.Fatal("NFD and NFC forms must normalize to the same string")
	}
	// Разные представления одного текста → один request-hash.
	tpl := writeTemplate(t, "S.\n---USER---\n{{text}}")
	m1, _ := Messages(tpl, RenderVars{Book: testBook(), Text: NormalizeSource(decomposed)})
	m2, _ := Messages(tpl, RenderVars{Book: testBook(), Text: NormalizeSource(precomposed)})
	h1 := RequestHash("b", 1, 0, 0, "draft", "translator", "m", 0.3, "off", false, 1024, "snap", m1)
	h2 := RequestHash("b", 1, 0, 0, "draft", "translator", "m", 0.3, "off", false, 1024, "snap", m2)
	if h1 != h2 {
		t.Fatal("normalized equivalent sources must yield the same request hash")
	}
}

// Length-prefix устраняет NUL-коллизию: контент с \x00 не сдвигает границы
// полей и не подделывает лишние сообщения.
func TestRequestHashNulRobust(t *testing.T) {
	a := RequestHash("b", 1, 0, 0, "draft", "translator", "m", 0.3, "off", false, 1024, "snap",
		[]llm.Message{{Role: "user", Content: "a\x00b"}})
	bb := RequestHash("b", 1, 0, 0, "draft", "translator", "m", 0.3, "off", false, 1024, "snap",
		[]llm.Message{{Role: "user", Content: "a"}, {Role: "\x00b", Content: ""}})
	if a == bb {
		t.Fatal("NUL byte in content must not collide with an extra message boundary")
	}
}

func TestEstimateTokens(t *testing.T) {
	// Порядок величин по калибровке полигона: иероглиф ≈1 токен, кириллица ≈3
	// символа на токен. Оценка — для резервов, не для биллинга.
	ja := EstimateTokens("図書館は静寂に包まれていた")               // 13 CJK-рун
	if ja < 10 || ja > 20 {
		t.Errorf("ja estimate out of range: %d", ja)
	}
	ru := EstimateTokens("Библиотека была окутана тишиной") // ~30 не-пробельных
	if ru < 8 || ru > 16 {
		t.Errorf("ru estimate out of range: %d", ru)
	}
}
