package pipeline

import (
	"strings"
	"testing"
)

// sanitizer_test.go — the output-sanitizer (D30.3) unit suite. Per the gate discipline
// (precision over recall) EVERY class asserts BOTH firing on the exp12/flagman defect and
// NON-firing on legitimate prose, so a future rule edit that starts false-flagging real
// Russian text fails loudly here.

func TestSanitizerLeadingPreamble(t *testing.T) {
	fire := []string{
		// gemini 6/6 leak (exp12 §4.1) — the canonical target.
		"Вот отредактированный перевод с соблюдением всех терминов из глоссария:\n\nСудзуки шёл по коридору.",
		"Вот отредактированный перевод:\nТекст главы.",
		"Вот перевод фрагмента: Судзуки открыл дверь.",
		"Ниже приведён отредактированный текст:\nГлава началась.",
		"Отредактированный перевод: Судзуки вошёл.",
		"Представляю исправленный вариант перевода: он ушёл.",
	}
	for _, s := range fire {
		if got := sanitizeOutput(s); got.Preamble == 0 {
			t.Errorf("preamble not detected in %q", preview(s))
		}
	}
	clean := []string{
		"Судзуки шёл по коридорам Академии магии, вспоминая вчерашнюю лекцию.",
		"Вот и настал тот день, которого он так ждал.",           // «Вот и…» narrative — no service phrase
		"Вот дом стоял на холме, окружённый старыми соснами.",    // «Вот дом…»
		"— Вот твой перевод, — сказал он и протянул конверт.",    // dialogue about a translation, not a preamble label
		"Перевод стрелки часов занял у него всё утро.",           // «Перевод» as a common noun, no service label
		"Ниже по реке стоял старый мост, к которому вела тропа.", // «Ниже…» geographic
		// адверсариальное ревью false positives — must NOT fire (em-dash terminator + common nouns):
		"Вот такой вариант развития событий — самый вероятный.", // вот + вариант + em-dash, no edit-adjective
		"Ниже текста была подпись — размашистая.",               // ниже + текст + em-dash, not «ниже приведён … перевод:»
		"Вот новая версия карты — с пометками отца.",            // вот + версия + em-dash
		"Итоговый текст письма — три строки.",                   // итоговый + текст + em-dash
		"Вот его вариант: он молча ушёл.",                       // вот + вариант + COLON, but no edit-adjective / service phrase
	}
	for _, s := range clean {
		if got := sanitizeOutput(s); got.Preamble != 0 {
			t.Errorf("preamble FALSE POSITIVE in %q: %v", preview(s), got.Detail)
		}
	}
}

func TestSanitizerTrailingNote(t *testing.T) {
	fire := []string{
		"Судзуки открыл дверь.\n\nПримечание: имя героя оставлено без перевода.",
		"Он ушёл в туман.\n\nВнесённые правки: заменил «фракции» на «школы».",
		"Глава закончилась.\n\nЧто изменено: исправлена терминология гу.",
		"Текст главы.\n\n*Примечание переводчика:* здесь игра слов.",
		"Финал сцены.\n\nЗаметки редактора: стиль приведён к норме.",
		"Список правок: 1) термины; 2) вёрстка.\nТекст главы дальше.", // edit-meta anywhere
		// D33.1а — the gemini A5/5_1 TAIL leak (exp12:229): the head-preamble rule missed it.
		// Reproduces the leaked block STRUCTURE (header + edit list), not the copyrighted chapter.
		"Судзуки закрыл дверь книгохранилища и медленно пошёл прочь.\n\n" +
			"Основные правки для справки:\n- унифицированы термины культивации;\n- «фракции» заменены на «школы».",
		"Текст главы.\n\nОсновные правки: приведены имена к глоссарию.", // colon-header variant
	}
	for _, s := range fire {
		if got := sanitizeOutput(s); got.TrailingNote == 0 {
			t.Errorf("trailing note not detected in %q", preview(s))
		}
	}
	clean := []string{
		"Судзуки открыл дверь и вошёл в тёмный зал, где ждала тишина.",
		"Примечательно, что никто не заметил его исчезновения в ту ночь.", // «Примечательно» ≠ «Примечание:»
		"Он оставил заметку на столе и вышел, не оглядываясь назад.",      // «заметку» mid-narrative, not a header
		"— Что изменилось? — спросил он, оглядывая пустую комнату.",       // dialogue, not «Что изменено:»
		// адверсариальное ревью false positives — ordinary narrative nouns/gerunds ending a chapter:
		"Комментатор захлёбывался от восторга, махая руками над головой.", // «Комментатор» ≠ «Комментарий:»
		"Комментируя случившееся, старик лишь развёл руками в тишине.",    // «Комментируя» gerund
		"Заметка белела на столе, и никто не решался её тронуть.",         // «Заметка» + no colon
		"Пояснения не требовалось: всё было ясно без единого слова.",      // «Пояснения» + no colon (the colon comes later)
		"Сноска в конце страницы объясняла древний обычай клана.",         // «Сноска» narrative subject
		"Изменения — вот что пугало его больше всего на этом свете.",      // «Изменения —» common narrative opener
		"Правка — дело тонкое, и старый мастер знал это лучше всех.",      // «Правка —» aphorism
		"Основные правки внёс редактор газеты, и статья пошла в набор.",   // «Основные правки» + no «для справки»/colon (D33.1а precision guard)
	}
	for _, s := range clean {
		if got := sanitizeOutput(s); got.TrailingNote != 0 {
			t.Errorf("trailing note FALSE POSITIVE in %q: %v", preview(s), got.Detail)
		}
	}
}

func TestSanitizerMarkdownHeader(t *testing.T) {
	fire := []string{
		"### Глава 1\n\nСудзуки шёл по коридору.",
		"# Пролог\n\nТекст.",
		"Текст первой сцены.\n\n## Часть вторая\n\nПродолжение.",
		"### 1. Вступление\n\nТекст.", // digit after hashes is still a header (D33.2)
	}
	for _, s := range fire {
		if got := sanitizeOutput(s); got.MarkdownHeader == 0 {
			t.Errorf("markdown header not detected in %q", preview(s))
		}
	}
	clean := []string{
		"Он купил дом №5 на этой улице за бесценок прошлой весной.", // «№5» not a header
		"Комната #1 была заперта, и ключа ни у кого не нашлось.",    // «#1» no space+text
		"Язык C# он выучил ещё в университете, но давно забросил.",  // «C#» mid-line
		"Судзуки шёл по коридорам, вспоминая вчерашнюю долгую лекцию.",
		// D33.2 — decorative scene-breaks rendered with hashes: punctuation after the hashes,
		// not a header word/number → must NOT fire.
		"Сцена оборвалась.\n\n# # #\n\nНачалась новая.", // hash-spaced scene break
		"Он умолк.\n\n## ***\n\nУтро выдалось ясным.",   // hashes then asterisks
		"Конец главы.\n\n### ---\n\nСледующий день.",    // hashes then dashes
	}
	for _, s := range clean {
		if got := sanitizeOutput(s); got.MarkdownHeader != 0 {
			t.Errorf("markdown header FALSE POSITIVE in %q: %v", preview(s), got.Detail)
		}
	}
}

func TestSanitizerLatinInsertion(t *testing.T) {
	fire := []string{
		"Судзуки открыл книгу и прочитал: the quick brown fox jumps over.", // ≥4-word latin phrase
		"He walked into the ancient library at dawn and found nothing.",    // whole latin sentence
	}
	for _, s := range fire {
		if got := sanitizeOutput(s); got.LatinInsert == 0 {
			t.Errorf("latin insertion not detected in %q", preview(s))
		}
	}
	clean := []string{
		"Судзуки купил iPhone и MacBook, чтобы работать в дороге по ночам.", // 2 latin brand names
		"На экране светилась надпись Google, и он нажал на неё пальцем.",    // single latin name
		"Он listened внимательно.", // 1 stray latin word
		"Судзуки шёл по коридорам Академии магии, вспоминая вчерашнюю лекцию.",
		"Идентификатор 138fd5c384e3 и код a1b2c3d4e5f6 записаны в журнале.", // hex/id: single-letter latin tokens separated by digits — NOT a phrase
		"На гербе была выбита надпись: sic transit gloria mundi.",           // 4-word quoted Latin motto — legit preserved (адверсариальное ревью)
		// D33.2 — a list of 5+ capitalized brands is legit proper nouns, not a Latin leak: the
		// heavy-count excludes capitalized tokens (this fired under the pre-D33.2 heavy-share gate).
		"Он купил iPhone, iPad, MacBook, Apple Watch и Google Pixel вчера.",
	}
	for _, s := range clean {
		if got := sanitizeOutput(s); got.LatinInsert != 0 {
			t.Errorf("latin insertion FALSE POSITIVE in %q: %v", preview(s), got.Detail)
		}
	}
}

func TestSanitizerBrokenWord(t *testing.T) {
	fire := []string{
		"Стольь высокая башня терялась в облаках над городом.",   // doubled soft sign ьь
		"Он вошёл в ьзал и замер на пороге, не решаясь шагнуть.", // soft sign word-initial
	}
	for _, s := range fire {
		if got := sanitizeOutput(s); got.BrokenWord == 0 {
			t.Errorf("broken word not detected in %q", preview(s))
		}
	}
	// Mixed Cyrillic+Latin homoglyph in one token (latin 'p'/'e'/'o' inside a Cyrillic word).
	if got := sanitizeOutput("Пеpвок предок восстал из пепла."); got.BrokenWord == 0 {
		t.Errorf("mixed-script homoglyph not detected")
	}
	clean := []string{
		"Судзуки шёл по коридорам Академии магии, вспоминая вчерашнюю лекцию.",
		"— Хорошо, хорошо, — устало ответил он, поднимая обе руки вверх.",  // emphatic exact repeat, NOT a split
		"Молоко около дома скисло за одну жаркую летнюю ночь под навесом.", // adjacent words
		"На валуне у реки сидел одинокий путник в сером плаще.",            // «валуне» is a VALID form (recall gap)
		// адверсариальное ревью false positives:
		"Старик старика видел издалека, но узнал не сразу в тумане.",   // polyptoton (старик + старика)
		"Город города не выдаёт — так говаривал его дед у очага.",      // город + города
		"Буква ъ раньше называлась ером и стояла в конце многих слов.", // metalinguistic mention of the letter ъ (standalone)
		"Хорошо хорошо всё это выглядело только со стороны, издалека.", // capitalized emphatic repeat
		// D33.1б — the removed junction-duplication detector false-flagged these: adjacent tokens
		// sharing an incidental ≥4-rune morphemic boundary («коло», «тало»), NOT a broken split.
		"Он остановился около колонны и поднял голову к тёмному своду.", // «около колонны» — overlap «коло», no containment
		"Путник склонился около колодца и зачерпнул ледяной воды.",      // «около колодца» — overlap «коло»
		"К утру всё стало талой водой и растеклось по камням двора.",    // «стало талой» — overlap «тало»
	}
	for _, s := range clean {
		if got := sanitizeOutput(s); got.BrokenWord != 0 {
			t.Errorf("broken word FALSE POSITIVE in %q: %v", preview(s), got.Detail)
		}
	}
}

// TestSanitizerBrokenWordRecallGap documents the honest boundary of the DETERMINISTIC
// minimum (D30.3: precision over recall, «это ФЛАГГЕР»): a word split into two
// individually-valid tokens with NO invalid sign and NO homoglyph — the bare «Первок предок»
// from exp12 — is NOT offline-detectable without a morphology/dictionary pass (Ф2). Since the
// junction-duplication heuristic was removed (D33.1б: it false-flagged prose), such a split is
// caught only when it leaves an invalid character or a mixed-script token. This test PINS that
// gap so a future morphology-based detector is a conscious change, not an accident.
func TestSanitizerBrokenWordRecallGap(t *testing.T) {
	if got := sanitizeOutput("Первок предок восстал из пепла забытых веков."); got.BrokenWord != 0 {
		t.Errorf("unexpected: the pure-orthographic split «Первок предок» is documented as a recall gap, but it fired: %v", got.Detail)
	}
}

// TestSanitizerCleanNarrativePassesEntirely guards the whole gate against false positives
// on a realistic clean Russian passage with dialogue, names and numbers.
func TestSanitizerCleanNarrative(t *testing.T) {
	passage := strings.Join([]string{
		"Судзуки медленно шёл по длинным коридорам Академии магии.",
		"",
		"— Сегодня я наконец открою дверь книгохранилища, — прошептал он.",
		"",
		"За окном лил серебристый дождь, собираясь в ручьи на брусчатке двора.",
		"Башня библиотеки тонула в тумане, и вершины её не было видно.",
	}, "\n")
	if got := sanitizeOutput(passage); got.total() != 0 {
		t.Fatalf("clean narrative flagged by the sanitizer: %+v", got.Detail)
	}
}

// TestSanitizerDeterministic asserts the verdict is a pure function of the text (resume-safe).
func TestSanitizerDeterministic(t *testing.T) {
	s := "### Заголовок\nВот отредактированный перевод: текст. Стольь странно.\nПримечание: правка."
	a, b := sanitizeOutput(s), sanitizeOutput(s)
	if a.total() != b.total() || strings.Join(a.Detail, "|") != strings.Join(b.Detail, "|") {
		t.Fatalf("sanitizeOutput not deterministic: %v vs %v", a, b)
	}
	if a.total() == 0 {
		t.Fatalf("expected the multi-defect fixture to flag")
	}
}
