package pipeline

import (
	"fmt"
	"regexp"
	"sort"
	"strings"
	"unicode"
)

// sanitizer.go: the output-sanitizer (D30.3) — a deterministic verdict-axis gate on the
// FINAL chunk text that catches the "instant unreadability" defect classes NO existing
// gate detects (exp12 root-cause + flagman §5). It is OPT-IN (Gates.Sanitizer.Enabled),
// following the coverage gate's discipline: when enabled a defect flips the chunk to
// flagged (D2 flag+skip — the contaminated output never commits to TM/export), else it is
// a no-op. Pure and deterministic (no time/rand, sorted detail) so a resume re-derives the
// identical verdict — its rule version folds into the snapshot only when enabled
// (sanitizerVersion, mirroring coverageSnapshot), and moves to verdictSnapshotID once
// content-addressed resume lands (D15.2).
//
// Five classes, each tuned PRECISION over recall (fire only on a high-confidence signal;
// the ambiguous middle stays silent — a false flag turns a good chunk into an export
// placeholder, worse than a missed defect for an opt-in readability gate):
//
//	1. leading service preamble  — «Вот отредактированный перевод: …» (gemini 6/6, exp12 §4.1)
//	2. trailing note/edit block  — appended «Примечание:/Внесённые правки:/Что изменено:» meta
//	3. markdown ### header        — «### Глава 1» (8/54 finals of stage A — D29 finding)
//	4. Latin-script insertion    — an untranslated Latin phrase/heavy Latin in the ru output
//	5. broken word form           — homoglyph-mixed token or an invalid Cyrillic sign bigram
//
// HONEST recall gap (documented, precision over recall): the broken-word class catches only
// the two ZERO-false-positive signatures (invalid Cyrillic sign bigrams, mixed Cyrillic/Latin
// token). The junction-duplication split heuristic («Первопред предок») was REMOVED (D33.1б): it
// false-flagged ordinary prose sharing a morphemic boundary. A word broken WITHOUT an invalid
// character («Первок предок» split into two individually-valid tokens) or a grammatical-but-wrong
// form («валуне») is NOT offline-detectable without a morphology/dictionary pass (Ф2) — left silent.

// sanitizerVersion versions the rules below (folded into the snapshot when the gate is
// enabled, like cheapGateVersion/coverageGateVersion): editing any pattern shifts the
// resolved disposition of flagged chunks, so a bump is a loud --resnapshot. [D15.2 note:
// this is a VERDICT version — once content-addressed resume lands it moves to
// verdictSnapshotID and a rule edit re-classifies free instead of re-paying the book.]
// v2 (D33.1/33.2): tail edit-summary «Основные правки для справки:» now flags; the
// junction-duplication split detector was removed (false-flagged prose); markdown/heavy-Latin
// narrowed. A verdict-axis change → loud --resnapshot + golden re-capture (invariant №8).
const sanitizerVersion = "sanitizer-v2"

// sanitizer tuning constants (versioned by sanitizerVersion).
const (
	// trailingRegionLines: a note/edit header counts only when it sits in the LAST N
	// non-empty lines (a meta block the model appended), so a legitimate mid-narrative
	// «Примечание» in dialogue does not fire.
	trailingRegionLines = 6
	// latinPhraseWords: a run of this many SPACE-adjacent Latin word-tokens is a leaked
	// untranslated phrase/sentence (a couple of proper names never reaches it). Set to 5
	// (адверсариальное ревью): a ≤4-word quoted foreign motto («sic transit gloria mundi»)
	// is legit source-preserved content and must NOT be dropped. ⚠ ACCEPTED recall/precision
	// edge: a deliberately-quoted foreign phrase of ≥5 words («to be or not to be») still
	// trips it — rare, and the gate is opt-in (a redrive surfaces it to a human).
	latinPhraseWords = 5
	// latinHeavyShare / latinHeavyTokens: heavy Latin contamination — Latin letters are a
	// large share of all letters AND there are several Latin tokens (both required, so a
	// short chunk with one long Latin brand name does not trip the share alone).
	latinHeavyShare  = 0.25
	latinHeavyTokens = 5
)

// sanitizerResult is the per-chunk outcome: a count per class plus deterministic detail.
type sanitizerResult struct {
	Preamble       int      `json:"preamble,omitempty"`
	TrailingNote   int      `json:"trailing_note,omitempty"`
	MarkdownHeader int      `json:"markdown_header,omitempty"`
	LatinInsert    int      `json:"latin_insert,omitempty"`
	BrokenWord     int      `json:"broken_word,omitempty"`
	Detail         []string `json:"detail,omitempty"`
}

func (s sanitizerResult) total() int {
	return s.Preamble + s.TrailingNote + s.MarkdownHeader + s.LatinInsert + s.BrokenWord
}

// summary is the human-readable disposition detail for a sanitizer flag (deterministic —
// Detail is sorted in sanitizeOutput). Kept short (the first two findings) for the log/row.
func (s sanitizerResult) summary() string {
	if len(s.Detail) == 0 {
		return "output-sanitizer defect"
	}
	if len(s.Detail) > 2 {
		return strings.Join(s.Detail[:2], "; ") + fmt.Sprintf("; …(+%d)", len(s.Detail)-2)
	}
	return strings.Join(s.Detail, "; ")
}

// sanitizeOutput runs all five classes over one chunk's FINAL translated text.
func sanitizeOutput(text string) sanitizerResult {
	var r sanitizerResult
	if det := detectLeadingPreamble(text); det != "" {
		r.Preamble++
		r.Detail = append(r.Detail, det)
	}
	if det := detectTrailingNote(text); det != "" {
		r.TrailingNote++
		r.Detail = append(r.Detail, det)
	}
	if n, det := detectMarkdownHeaders(text); n > 0 {
		r.MarkdownHeader = n
		r.Detail = append(r.Detail, det...)
	}
	if det := detectLatinInsertion(text); det != "" {
		r.LatinInsert++
		r.Detail = append(r.Detail, det)
	}
	if n, det := detectBrokenWords(text); n > 0 {
		r.BrokenWord = n
		r.Detail = append(r.Detail, det...)
	}
	sort.Strings(r.Detail)
	return r
}

// --- 1. leading service preamble ------------------------------------------------

// leadingPreamblePatterns match a service preamble at the very START of the output — the
// «Вот отредактированный перевод: …» leak (gemini 6/6, exp12 §4.1). PRECISION over recall
// (адверсариальное ревью: a lax rule dropped clean chapter openings like «Вот такой вариант
// развития — самый вероятный.», «Вот его вариант: он молча ушёл.»). Two guards make it tight:
//  1. terminator is a COLON only — the em-dash «—» is ubiquitous in narration, so it is NOT
//     a terminator (a service preamble is a labeled prefix ending in «:»);
//  2. the phrase must be a genuine TRANSLATION/EDIT service label, not merely «lead + common
//     noun»: either an edit-adjective (отредактированн/исправленн/улучшенн) + a перевод/текст/
//     вариант noun, or «перевод фрагмента/текста/…», or «ниже приведён … перевод/текст».
//
// Go RE2 \w/\b are ASCII-only, so Cyrillic uses [а-яё].
var leadingPreamblePatterns = []*regexp.Regexp{
	// «Вот/Представляю/Привожу … отредактированный/исправленный перевод/текст/вариант …:»
	// (the edit-adjective front-gate holds precision, so the tail-to-colon may be long — the
	// gemini leak carries «… с соблюдением всех терминов из глоссария:»).
	regexp.MustCompile(`(?is)^\s*(?:вот|представляю|привожу|держите)\s+[^\n:]{0,40}?(?:отредактированн|исправленн|улучшенн)[а-яё]+\s+(?:перевод|текст|вариант)[а-яё]*[^\n:]{0,120}?:`),
	// «Отредактированный/Исправленный перевод/текст/вариант …:» (label at the very start)
	regexp.MustCompile(`(?is)^\s*(?:отредактированн|исправленн|улучшенн)[а-яё]+\s+(?:перевод|текст|вариант)[а-яё]*[^\n:]{0,120}?:`),
	// «(Вот) перевод фрагмента/текста/отрывка/главы/черновика:»
	regexp.MustCompile(`(?is)^\s*(?:вот\s+)?перевод\s+(?:фрагмента|текста|отрывка|главы|черновика)\s*:`),
	// «Ниже приведён/представлен … перевод/отредактированный текст:»
	regexp.MustCompile(`(?is)^\s*ниже\s+(?:привед[её]н|представлен|дан|след)[а-яё]*\s+[^\n:]{0,40}?(?:перевод|отредактированн|текст)[а-яё]*[^\n:]{0,20}?:`),
}

func detectLeadingPreamble(text string) string {
	t := strings.TrimSpace(text)
	for _, re := range leadingPreamblePatterns {
		if loc := re.FindStringIndex(t); loc != nil {
			return "ведущая служебная преамбула: " + preview(strings.TrimSpace(t[:loc[1]]))
		}
	}
	return ""
}

// --- 2. trailing note / edit block ----------------------------------------------

// trailingNoteRE matches a note/edit meta-HEADER at line start: «Примечание:», «Заметки
// переводчика», «Комментарий:», «(прим. перев.)». PRECISION over recall (адверсариальное
// ревью: «Комментатор захлёбывался…», «Заметка белела на столе.», «Пояснения не требовалось.»,
// «Сноска объясняла обычай.» are ordinary narrative nouns/gerunds — NOT headers). So the header
// word MUST be followed by a COLON, or be «примечание/заметки/комментарий + переводчика/редактора»,
// or be the «(прим. перев./ред.)» marker. Only counted inside the trailing region (below).
var trailingNoteRE = regexp.MustCompile(`(?i)^[*_>\s-]{0,4}(?:(?:примечани[ея]|заметк[аи]|комментари[йяю]|пояснени[ея]|сноск[аи])\s*:|(?:примечани[ея]|заметк[аи]|комментари[йяю])\s+(?:переводчик|редактор)[а-яё]*|прим\.\s*(?:перев|ред)\.?)`)

// editMetaAnywhereRE matches UNAMBIGUOUS editor meta-commentary that is a defect wherever it
// appears (not only trailing): «Внесённые правки:», «Что изменено:», «Список правок:», and the
// gemini tail leak «Основные правки для справки: …» (D33.1а — the A5/5_1 defect the gate exists
// to catch, exp12:229: the leading-preamble rule caught the head form «Вот отредактированный
// перевод:» but NOT this trailing edit-summary). The generic «Изменения:/Правки:» alone were
// REMOVED (адверсариальное ревью — «Изменения — вот что пугало его.» / «Правка — дело тонкое.»
// are common narrative openers), keeping only editor-specific compounds that never occur in prose.
// «основн…правк» is gated to «… для справки» OR a colon so a bare narrative «Основные правки внёс
// редактор» (no summary label) does not fire.
var editMetaAnywhereRE = regexp.MustCompile(`(?im)^[*_>\s-]{0,4}(?:(?:внесённ|внесен)[а-яё]+\s+правк|основн[а-яё]+\s+правк[а-яё]*(?:\s+для\s+справк[а-яё]+|\s*:)|что\s+(?:было\s+)?(?:изменен|исправлен)|список\s+(?:правок|изменений)|список\s+внесённых)`)

func detectTrailingNote(text string) string {
	if loc := editMetaAnywhereRE.FindStringIndex(text); loc != nil {
		return "утёкший блок правок редактора: " + preview(strings.TrimSpace(firstLineAt(text, loc[0])))
	}
	lines := nonEmptyLines(text)
	start := len(lines) - trailingRegionLines
	if start < 0 {
		start = 0
	}
	for _, ln := range lines[start:] {
		if trailingNoteRE.MatchString(ln) {
			return "хвостовой блок заметок/примечаний: " + preview(strings.TrimSpace(ln))
		}
	}
	return ""
}

// --- 3. markdown ### header -----------------------------------------------------

// markdownHeaderRE matches a markdown ATX header line («### Глава», «# Title»): 1–6 hashes,
// a space, then a LETTER or DIGIT. «#1» / «№5» / a lone «#» do not match (no space+text); and
// (D33.2) a decorative scene-break rendered with hashes — «# # #», «## ***», «### ---» — no
// longer matches, since the char after the hashes is punctuation, not a header word/number.
var markdownHeaderRE = regexp.MustCompile(`(?m)^\s{0,3}#{1,6}\s+[\p{L}\p{Nd}]`)

func detectMarkdownHeaders(text string) (int, []string) {
	var det []string
	n := 0
	for _, ln := range strings.Split(text, "\n") {
		if markdownHeaderRE.MatchString(ln) {
			n++
			det = append(det, "markdown-заголовок в выходе: "+preview(strings.TrimSpace(ln)))
		}
	}
	return n, det
}

// --- 4. Latin-script insertion --------------------------------------------------

// detectLatinInsertion flags an untranslated Latin PHRASE (≥ latinPhraseWords Latin words
// separated only by SINGLE SPACES — a real «the quick brown fox», not an alphanumeric id) OR
// heavy Latin contamination (Latin ≥ latinHeavyShare of all letters AND ≥ latinHeavyTokens
// Latin tokens). Requiring space-adjacency is load-bearing: a hex/id run like «138fd5c384e3»
// or «a1b2c3d4» yields several single-letter Latin tokens with NO space between them, which
// must NOT read as a Latin phrase (a false positive the golden fixture's tags exposed). A
// handful of Latin proper names stays silent.
func detectLatinInsertion(text string) string {
	toks := scriptTokens(text)
	maxRun, run, heavyLatTokens := 0, 0, 0
	for _, tk := range toks {
		switch {
		case tk.lat:
			if tk.adjacent && run > 0 {
				run++ // a Latin word separated from the previous Latin word by one space
			} else {
				run = 1 // phrase start (digit/punct separator or a non-Latin predecessor)
			}
			// Heavy-Latin count EXCLUDES capitalized/brand tokens (D33.2): «iPhone», «iPad»,
			// «MacBook», «Google» are legit proper nouns, so a list of 5+ brands must not trip the
			// heavy-share gate. A genuinely leaked English clause is lowercase past its first word,
			// so it still clears latinHeavyTokens (and a real multi-word Latin PHRASE is caught by
			// maxRun below, which still counts every Latin token).
			if !hasUpperLatin(tk.text) {
				heavyLatTokens++
			}
			if run > maxRun {
				maxRun = run
			}
		case tk.cyr || tk.mixed:
			run = 0
		}
	}
	if maxRun >= latinPhraseWords {
		return "латинская вставка (фраза из подряд идущих латинских слов)"
	}
	latinLetters, cyrLetters := 0, 0
	for _, ru := range text {
		if unicode.Is(unicode.Latin, ru) {
			latinLetters++
		} else if unicode.Is(unicode.Cyrillic, ru) {
			cyrLetters++
		}
	}
	total := latinLetters + cyrLetters
	if total > 0 && heavyLatTokens >= latinHeavyTokens && float64(latinLetters)/float64(total) >= latinHeavyShare {
		return "латиница-врезки: тяжёлая доля латиницы в ru-выходе"
	}
	return ""
}

// hasUpperLatin reports whether s contains an uppercase Latin letter — the brand/proper-noun
// signature («iPhone», «MacBook», «Google») excluded from the heavy-Latin token count (D33.2).
func hasUpperLatin(s string) bool {
	for _, ru := range s {
		if unicode.Is(unicode.Latin, ru) && unicode.IsUpper(ru) {
			return true
		}
	}
	return false
}

// --- 5. broken word forms -------------------------------------------------------

// invalidSignRE matches Cyrillic sign bigrams that never occur in valid Russian: a hard/soft
// sign at a word START that is FOLLOWED BY A LETTER (a broken token like «ьзал»), or two signs
// adjacent (ъь/ьъ/ьь/ъъ). Requiring the following letter excludes a STANDALONE «ъ»/«ь» — a
// metalinguistic mention of the letter itself, «Буква ъ называлась ером.» (адверсариальное
// ревью nit). High precision otherwise: word-initial-sign-then-letter and doubled signs are
// impossible in well-formed text. \P{L} (non-letter), not RE2's ASCII-only \b.
var invalidSignRE = regexp.MustCompile(`(?i)(?:^|\P{L})[ъь][а-яё]|[ъь][ъь]`)

func detectBrokenWords(text string) (int, []string) {
	var det []string
	seen := map[string]bool{}
	add := func(s string) {
		if !seen[s] {
			seen[s] = true
			det = append(det, s)
		}
	}
	// (a) invalid Cyrillic sign bigrams.
	if invalidSignRE.MatchString(text) {
		add("невалидная кириллическая биграмма (мягкий/твёрдый знак в начале слова или подряд)")
	}
	// (b) mixed Cyrillic+Latin within one letter-run token (homoglyph artifact).
	// NOTE: the junction-duplication split detector («Первопред предок») was REMOVED (D33.1б).
	// It fired identically on ordinary prose whose adjacent tokens merely share a ≥4-rune
	// morphemic boundary («около колонны/колодца», «стало талой» — «около» is a frequent
	// preposition → flag-storm on 25 legit chapters), while its only synthetic target had an
	// overlap fraction (4/9) INDISTINGUISHABLE from those false positives (4/5): no threshold on
	// overlap length or share separates them, and the canonical «Первок предок» was never caught
	// anyway (recall gap — TestSanitizerBrokenWordRecallGap). Deterministic split-duplication with
	// no invalid sign / homoglyph needs a morphology pass (Ф2), so it stays silent here.
	for _, tk := range scriptTokens(text) {
		if tk.mixed {
			add("смешение кириллицы и латиницы в одном слове (гомоглиф): " + preview(tk.text))
		}
	}
	return len(det), det
}

type scriptToken struct {
	text     string
	cyr      bool // Cyrillic-only
	lat      bool // Latin-only
	mixed    bool // both scripts in one letter run (homoglyph artifact)
	adjacent bool // separated from the previous token by exactly a single space (junction check)
}

// scriptTokens splits text into maximal letter-run tokens (letters only; digits/hyphens/
// spaces/punctuation are separators), tagging Cyrillic-only / Latin-only / mixed-script and
// whether the token is separated from the previous token by exactly a single space
// (adjacency — a duplication artifact sits inside a phrase, not across punctuation/newline).
func scriptTokens(text string) []scriptToken {
	var out []scriptToken
	var b strings.Builder
	hasLat, hasCyr := false, false
	gap := "" // separator run since the previous token: "" none yet, " " one space, "x" other
	flush := func() {
		if b.Len() > 0 {
			out = append(out, scriptToken{
				text:     b.String(),
				cyr:      hasCyr && !hasLat,
				lat:      hasLat && !hasCyr,
				mixed:    hasLat && hasCyr,
				adjacent: gap == " ",
			})
			b.Reset()
			gap = ""
		}
		hasLat, hasCyr = false, false
	}
	for _, ru := range text {
		switch {
		case unicode.Is(unicode.Latin, ru):
			b.WriteRune(ru)
			hasLat = true
		case unicode.Is(unicode.Cyrillic, ru):
			b.WriteRune(ru)
			hasCyr = true
		default:
			flush()
			if ru == ' ' && gap == "" {
				gap = " "
			} else {
				gap = "x" // newline / punctuation / tab / a second space breaks adjacency
			}
		}
	}
	flush()
	return out
}

// --- shared helpers -------------------------------------------------------------

func nonEmptyLines(text string) []string {
	var out []string
	for _, ln := range strings.Split(text, "\n") {
		if strings.TrimSpace(ln) != "" {
			out = append(out, ln)
		}
	}
	return out
}

// firstLineAt returns the line containing byte offset off.
func firstLineAt(text string, off int) string {
	start := strings.LastIndexByte(text[:off], '\n') + 1
	end := strings.IndexByte(text[off:], '\n')
	if end < 0 {
		return text[start:]
	}
	return text[start : off+end]
}
