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)
	}
}

// TestSanitizerCJKLeak — the D38.1 class: a stray Han/kana/fullwidth glyph in the ru final.
func TestSanitizerCJKLeak(t *testing.T) {
	fire := []string{
		// The span the orchestrator verified (arms/C/17.0): a raw Han run mid-Russian-sentence.
		"«…охотники,却有 на это лишние деньги!»",
		"Судзуки нашёл 特产 древнего клана.", // single Han run
		"Разряд资质乙等 остался неясен.",       // Han glued to Cyrillic
		"Он сказал: すごい, и замолчал.",      // kana leak (ja book)
	}
	for _, s := range fire {
		if got := sanitizeOutput(s); got.CJKLeak == 0 {
			t.Errorf("CJK-leak not detected in %q: %+v", preview(s), got)
		}
	}
	clean := []string{
		"Судзуки шёл по коридорам Академии магии, вспоминая вчерашнюю долгую лекцию.",
		"— Сегодня я открою дверь книгохранилища, — прошептал он, сжимая ключ.",
		"На гербе была латинская надпись, но он её не разобрал в темноте.",
		"Цена была 123 золотых, и он расплатился не торгуясь.", // ASCII digits — not fullwidth
		// U+3000 ideographic space alone is an INDENT artifact, NOT a content leak → must NOT fire
		// (research/18 §C#4); the export contract normalises it, but it does not flag the chunk.
		"　Судзуки медленно шёл по длинному коридору к башне.",
		// D39 слой 6: RECOVERABLE wrong-glyph renderings — fullwidth ASCII «１２３» and the ideographic
		// comma «、» — no longer FIRE the leak class. The detector is a pure detector over the folded
		// text (fullwidth «１２３»→«123», «、»→«,»), and the export contract fixes them silently. Only
		// genuinely contentless CJK (Han/kana above) is flagged.
		"Цена была １２３ золотых.",            // fullwidth digits → silently folded to «123»
		"Он вошёл в дом、и дверь закрылась.", // ideographic comma (U+3001) → silently folded to «,»
	}
	for _, s := range clean {
		if got := sanitizeOutput(s); got.CJKLeak != 0 {
			t.Errorf("CJK-leak FALSE POSITIVE in %q: %v", preview(s), got.Detail)
		}
	}
}

// TestSanitizerCosmeticOnly pins the strip-vs-skip disposition matrix (D35.4a): only the markdown
// header and CJK-leak classes are strippable; any substantive class pulls the chunk into skip.
func TestSanitizerCosmeticOnly(t *testing.T) {
	cosmetic := []string{
		"### Глава 7\n\nСудзуки открыл седьмую дверь и замер на пороге.", // markdown only
		"Судзуки нашёл 特产 древнего клана в тёмном углу книгохранилища.",  // cjk only
		"### Пролог\n\nСудзуки увидел 却有 на алтаре забытого клана.",      // BOTH cosmetic classes
	}
	for _, s := range cosmetic {
		got := sanitizeOutput(s)
		if got.total() == 0 || !got.cosmeticOnly() {
			t.Errorf("expected cosmeticOnly for %q: %+v", preview(s), got)
		}
	}
	substantive := []string{
		"Вот перевод фрагмента: Судзуки открыл дверь.", // preamble (substantive)
		// a LEADING preamble co-occurring with a CJK leak → the substantive class wins (skip, not strip).
		"Вот перевод фрагмента: Судзуки увидел 特产 на алтаре забытого клана.",
		"Судзуки открыл дверь.\n\nПримечание: имя героя оставлено как есть.", // trailing note
		"Он прочитал: the quick brown fox jumps over lazy dog today.",        // latin phrase
	}
	for _, s := range substantive {
		if got := sanitizeOutput(s); got.total() == 0 || got.cosmeticOnly() {
			t.Errorf("expected NOT cosmeticOnly (substantive) for %q: %+v", preview(s), got)
		}
	}
}

// TestStripCosmetic pins the exact deterministic strip output — the "экспортит текст БЕЗ ведущего
// ###" contract — and that the result is itself clean (the classifyOutput guarantee).
func TestStripCosmetic(t *testing.T) {
	cases := []struct{ in, want string }{
		// markdown header prefix removed, heading text kept.
		{"### Глава 7\n\nСудзуки открыл дверь.", "Глава 7\n\nСудзуки открыл дверь."},
		{"# Пролог\nТекст главы.", "Пролог\nТекст главы."},
		// Han run removed; the seam is tidied (no double space, no space-before-comma), no word-merge.
		{"«…охотники,却有 на это лишние деньги!»", "«…охотники, на это лишние деньги!»"},
		{"Судзуки нашёл 特产 древнего клана.", "Судзуки нашёл древнего клана."},
		{"нашёл特产древний", "нашёл древний"}, // no spaces in source → strip leaves a space, not a merge
		// CONTENT-BEARING fullwidth ASCII is FOLDED, not deleted (adversarial review MAJOR): the number survives.
		{"Цена была １２３ золотых.", "Цена была 123 золотых."},
		{"Индекс ＡＢＣ найден.", "Индекс ABC найден."},
		// ideographic comma/period fold to plain — the clause boundary is kept.
		{"Он вошёл в дом、и дверь закрылась。", "Он вошёл в дом,и дверь закрылась."},
		// a whitelisted CJK term gloss is now PRESERVED (D39.2 T2 — owner keeps glosses); a co-occurring
		// BARE Han run (no head, not in parens) is still stripped, and the gloss parens do not empty out.
		{"Кулак Ло Хань (羅漢拳) ударил 特产 врага.", "Кулак Ло Хань (羅漢拳) ударил врага."},
		// a legit ratio «2 : 1» is NOT reformatted (colon dropped from the tidy set).
		{"### Итог\n\nСчёт был 2 : 1 却有 в пользу гостей.", "Итог\n\nСчёт был 2 : 1 в пользу гостей."},
		// U+3000 ideographic space normalised to a plain space (the indent artifact).
		{"　Судзуки шёл.", " Судзуки шёл."},
	}
	for _, c := range cases {
		got := stripCosmetic(c.in)
		if got != c.want {
			t.Errorf("stripCosmetic(%q) = %q, want %q", c.in, got, c.want)
		}
		if san := sanitizeOutput(got); san.total() != 0 {
			t.Errorf("stripCosmetic(%q) is not clean: %+v", c.in, san.Detail)
		}
	}
}

// TestExportNormalize pins the export-contract (D39 слой 6, D29.1a): every final text is normalised
// before it ships — a CLEAN chunk (which the sanitizer never flags) now gets its cosmetic Unicode
// artifacts fixed, not shipped raw (L5-normalization-fused-to-flag-path). Width fold + CJK-punct map
// + combining-stress strip; «…»/«№»/«й»/«ё» preserved; idempotent.
func TestExportNormalize(t *testing.T) {
	cases := []struct{ in, want string }{
		// The finding's exact case: a clean chunk with only a U+3000 indent shipped raw before.
		{"　Судзуки медленно шёл к башне.", " Судзуки медленно шёл к башне."},
		// fullwidth ASCII folded (a clean chunk, never flagged) — the number/letters survive.
		{"Цена была １２３ золотых, индекс ＡＢＣ.", "Цена была 123 золотых, индекс ABC."},
		// ideographic comma/period → ASCII on a CLEAN chunk (no Han → sanitizer never fired).
		{"Он вошёл в дом、дверь закрылась。", "Он вошёл в дом,дверь закрылась."},
		// combining stress accent on a Cyrillic base dropped (L5-diacritics-class-lost): «источа́ло».
		{"Он источа́ло тепло за́мка.", "Он источало тепло замка."},
		// legit typography PRESERVED (the NFKC-would-clobber regression the width fold avoids).
		{"Он ждал… но никто не пришёл. № 5, ½ часа.", "Он ждал… но никто не пришёл. № 5, ½ часа."},
		// trailing horizontal whitespace trimmed per line; the newline is kept.
		{"Первая строка.   \nВторая строка.", "Первая строка.\nВторая строка."},
	}
	for _, c := range cases {
		got := exportNormalize(c.in)
		if got != c.want {
			t.Errorf("exportNormalize(%q) = %q, want %q", c.in, got, c.want)
		}
		if again := exportNormalize(got); again != got {
			t.Errorf("exportNormalize not idempotent for %q: %q → %q", c.in, got, again)
		}
	}
	// A DECOMPOSED «й» (и U+0438 + combining breve U+0306) / «ё» (е U+0435 + diaeresis U+0308) must be
	// COMPOSED by NFC, NOT corrupted to «и»/«е» by the combining-stress strip — the load-bearing reason
	// NFC runs BEFORE stripCombiningOnCyrillic in exportNormalize.
	if got := exportNormalize("\u0438\u0306"); got != "\u0439" { // decomposed й → composed «й»
		t.Errorf("decomposed й corrupted by the combining strip: %q (want «й» U+0439)", got)
	}
	if got := exportNormalize("\u0435\u0308"); got != "\u0451" { // decomposed ё → composed «ё»
		t.Errorf("decomposed ё corrupted by the combining strip: %q (want «ё» U+0451)", got)
	}
}

// TestSanitizerCJKGlossWhitelist pins the CJK-gloss whitelist (D39.2 T2, owner decision 16.07: KEEP
// glosses): a balanced CJK run inside parens directly after a Cyrillic/Latin head word is NOT a leak —
// detectCJKLeak does not flag it and stripCosmetic/exportNormalize preserve its bytes; a bare Han run
// (no head, line start, after punctuation, CJK punctuation inside, nested paren) is STILL a leak.
func TestSanitizerCJKGlossWhitelist(t *testing.T) {
	// Whitelisted glosses: NOT flagged, PRESERVED verbatim by both strip and normalize.
	preserved := []string{
		"Кулак Ло Хань (羅漢拳) был силён.",              // the owner's canonical case (ASCII parens)
		"Он изучил дар (資質) древнего клана.",          // gloss with no space before paren-adjacent head
		"Приём Ло Хань(羅漢拳) сработал.",                // head directly adjacent to «(» (no space)
		"Два приёма: Кулак (拳) и Нога (腿) в бою.",     // two glosses in a row — both preserved
		"Стиль Тайцзи (太極拳 tàijíquán) он знал.",       // Han + pinyin with tone marks and a space
		"Техника Zhang (張三豐) древних мастеров.",       // Latin head word, Han gloss
		"Название клана（羅漢）на воротах.",               // FULLWIDTH parens — preserved verbatim, not folded
		"Кулак (羅漢拳) был силён.",                      // NBSP between head and paren (finding B) — term kept
		"Приём (五行八卦掌 wu xing ba gua zhang) стар.",    // 5 space-separated PINYIN syllables (finding A)
		"Он изучил приём (九阳真经 jiǔyángzhēnjīng) там.", // JOINED pinyin of a 4-char term (D39.4 finding 2)
	}
	for _, s := range preserved {
		if got := sanitizeOutput(s); got.CJKLeak != 0 {
			t.Errorf("gloss FALSE-flagged as leak in %q: %v", preview(s), got.Detail)
		}
		if got := stripCosmetic(s); got != s {
			t.Errorf("stripCosmetic altered a preserved gloss:\n  in  %q\n  got %q", s, got)
		}
		if got := exportNormalize(s); got != s {
			t.Errorf("exportNormalize altered a preserved gloss:\n  in  %q\n  got %q", s, got)
		}
	}
	// FINDING A: a space-separated pinyin gloss must NOT trip the Latin-insertion class and DROP the whole
	// chunk (detectLatinInsertion runs gloss-masked). The chunk must be fully clean (nothing fired).
	if got := sanitizeOutput("Приём (五行八卦掌 wu xing ba gua zhang) стар."); got.total() != 0 {
		t.Errorf("pinyin gloss must not flag the chunk (finding A), got %+v", got)
	}
	// FINDING B: a DOUBLE space between head and paren — the gloss TERM is kept and the chunk not flagged
	// (the tidy normalises the double space to one, so it is not byte-verbatim, hence a separate check).
	if got := sanitizeOutput("Кулак  (羅漢拳) был силён."); got.CJKLeak != 0 {
		t.Errorf("double-space gloss FALSE-flagged (finding B): %+v", got)
	}
	if got := stripCosmetic("Кулак  (羅漢拳) был силён."); !strings.Contains(got, "羅漢拳") {
		t.Errorf("double-space gloss term dropped (finding B): %q", got)
	}
	// D39.4 finding 1: a U+3000 (ideographic-space) gap binds the term to the gloss — the term is kept
	// and the chunk not flagged (the U+3000 in the head normalises to a plain space, so not byte-verbatim).
	if got := sanitizeOutput("Кулак　(羅漢拳) был силён."); got.CJKLeak != 0 {
		t.Errorf("U+3000-bound gloss FALSE-flagged (finding 1): %+v", got)
	}
	if got := stripCosmetic("Кулак　(羅漢拳) был силён."); !strings.Contains(got, "羅漢拳") {
		t.Errorf("U+3000-bound gloss term dropped (finding 1): %q", got)
	}
	// NOT a whitelisted gloss → the CJK stays a leak (bare run / no letter head / CJK punctuation /
	// nesting / a CLAUSE too long to be a term — finding D).
	leak := []string{
		"Судзуки нашёл 特产 древнего клана.",                     // bare Han, no parens at all
		"(羅漢拳) — так назвали приём.",                           // paren gloss at LINE START, no letter head → leak
		"Он сказал: （太極） и ушёл.",                              // after «:» + space, no letter head → leak
		"Приём 5 (羅漢拳) в списке.",                              // digit head, not a letter → not a gloss
		"Клан (羅、漢) распался.",                                 // CJK PUNCTUATION «、» inside the parens → not a term gloss
		"Он ушёл (却有很多事情要做), но вернулся.",                     // 8-CJK CLAUSE, not a term gloss (finding D) → leak
		"Техника (九字真言破邪法) была забыта тогда.",                 // 7 CJK > maxGlossCJKRunes → leak (finding D bound)
		"Кулак (拳 the iron fist that breaks the sky) взлетел.", // F2: English clause after a token Han → leak
		"Приём (拳 antidisestablishmentarianism) стар.",         // F2: single over-long non-pinyin Latin word → leak
		"Стиль (拳 iron fist of sky) забыт.",                    // F2: non-proportional Latin (3 toks vs 1 CJK) → leak
	}
	for _, s := range leak {
		if got := sanitizeOutput(s); got.CJKLeak == 0 {
			t.Errorf("expected a CJK leak (not a valid gloss) in %q: %+v", preview(s), got)
		}
	}
	// The emptied-bracket regression must NOT reappear: a preserved gloss keeps its parens and term, so
	// stripCosmetic never yields «()» — and a real bare leak next to a gloss strips cleanly.
	if got := stripCosmetic("Кулак (羅漢拳) и 特产 рядом."); got != "Кулак (羅漢拳) и рядом." {
		t.Errorf("mixed gloss+leak strip = %q, want the gloss kept and the bare leak dropped", got)
	}
	// D39.4 finding 1: a U+3000-BOUND gloss must survive the strip of a CO-OCCURRING bare leak — the
	// fold-first detector and the original-bytes strip must agree the U+3000 gloss is protected.
	if got := stripCosmetic("Кулак　(羅漢拳) и 特产 рядом."); !strings.Contains(got, "羅漢拳") {
		t.Errorf("finding 1: U+3000-bound gloss destroyed alongside a bare leak: %q", got)
	}
}

// TestSanitizerFoldFirstBypass pins F1 (D39.4): a FULLWIDTH variation of a signature must be detected,
// not slip past the ASCII regexes while exportNormalize manufactures the ASCII defect in the export.
// All six classes detect over the export-normalised (folded) text.
func TestSanitizerFoldFirstBypass(t *testing.T) {
	fire := map[string]string{
		"fullwidth-colon-preamble": "Вот отредактированный перевод：\n\nСудзуки шёл.",      // U+FF1A colon
		"fullwidth-markdown":       "＃＃＃ Глава 5\n\nСудзуки открыл дверь.",                // U+FF03 hash ×3
		"fullwidth-trailing-note":  "Судзуки ушёл.\n\nПримечание：имя оставлено как есть.", // U+FF1A trailing
		"fullwidth-latin-phrase":   "Он прочитал： ｔｈｅ ｑｕｉｃｋ ｂｒｏｗｎ ｆｏｘ today.",             // fullwidth latin
	}
	for name, s := range fire {
		if got := sanitizeOutput(s); got.total() == 0 {
			t.Errorf("F1 %s: a fullwidth signature slipped past the detector (bypass): %q → %+v", name, preview(s), got)
		}
	}
}

// TestSanitizerExportNormalizeInvariant pins the F1 gate/export-contract invariant: for any text the
// sanitizer calls CLEAN, its export-normalised form is ALSO clean — the detector and exportNormalize
// agree, so no fullwidth defect is manufactured in the export of a "clean" chunk. Runs over the whole
// legitimate-prose corpus the class tests use.
func TestSanitizerExportNormalizeInvariant(t *testing.T) {
	corpus := []string{
		"Судзуки шёл по коридорам Академии магии, вспоминая вчерашнюю долгую лекцию.",
		"— Сегодня я открою дверь книгохранилища, — прошептал он, сжимая ключ.",
		"Цена была 123 золотых, и он расплатился не торгуясь.",
		"　Судзуки медленно шёл по длинному коридору к башне.",
		"Цена была １２３ золотых.",
		"Он вошёл в дом、и дверь закрылась.",
		"Он источа́ло тепло за́мка.",
		"Он ждал… но никто не пришёл. № 5, ½ часа.",
		"Кулак Ло Хань (羅漢拳) был силён.",
		"Стиль Тайцзи (太極拳 tàijíquán) он знал.",
		"Он купил iPhone, iPad, MacBook, Apple Watch и Google Pixel вчера.",
		"На гербе была выбита надпись: sic transit gloria mundi.",
	}
	for _, s := range corpus {
		if sanitizeOutput(s).total() != 0 {
			continue // not a clean input — the invariant is stated for clean verdicts
		}
		if got := sanitizeOutput(exportNormalize(s)); got.total() != 0 {
			t.Errorf("F1 invariant: clean %q became a defect after exportNormalize: %+v", preview(s), got)
		}
	}
}

// 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")
	}
}
