package pipeline

import (
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"maps"
	"slices"
	"sort"
	"strconv"
	"strings"
	"unicode"

	"textmachine/backend/internal/store"
)

// memory.go: the DETERMINISTIC hot path of the memory bank v2 (registry §Контракт
// горячего пути / research/13 Q3). $0, no LLM, no embeddings, no FTS5 — a multi-pattern
// exact match (Aho-Corasick) of glossary keys/aliases over the NORMALIZED chunk, with
// the single-key ban (A3), longest-match whole-entity replacement, the spoiler
// hard-reject window (C1), sticky scene-inertia (A5), a priority token budget with a
// logged eviction (F2), and the three-way injection disposition (A2). Pure and
// deterministic (no map-order output, no time/rand): the injected block is a pure
// function of (frozen bank, normalized chunk, chapter, sticky, budget), and it enters
// request_hash via the rendered messages — so a resumed chunk reproduces it for free.
//
// The registry's principle drives every choice here: "лучше ничего, чем мусор" and
// PRECISION over recall. A wrong injection (a homograph / short alias firing on the
// wrong sense) is the MAIN source of silent degradation (2510.00829) — worse than an
// empty match, which is a safe fallback to the model's base behaviour. So the matcher
// errs toward NOT firing, and the post-check (memcheck) makes what did fire observable.

// memoryMatchVersion versions the matcher + injection-selection ALGORITHM (min-key
// ban, longest-match containment, spoiler gate, sticky, budget priority order). Folded
// into memoryVersion() → a change is a loud --resnapshot (it shifts the injected bytes
// → the wire → a resumed checkpoint's content). Sibling of memoryNormVersion.
// v3 (D16.2/D16.3): sticky now INHERITS the original match's disposition instead of
// recomputing it status-based (a collision-prone AMBIGUOUS carry no longer silently
// upgrades to CONFIRMED), and a spaced-phonetic (Latin/Cyrillic) source key is validated
// against a word boundary (approved "rose" no longer fires inside "roseanne").
const memoryMatchVersion = "memmatch-v3-perlang-minkey+collision-ambiguous+longest+spoiler+sticky-inherit-disp+phonetic-srcboundary+budget+postcheck-declaware"

// minKeyLenHan / minKeyLenPhonetic are the per-language single-key floors (A3, registry
// "min_key_len per-язык"). An ideographic (Han) key is semantically distinct at 2 chars
// (empirically precision-1.0 on the zh retrieval_bench homograph traps). A PURELY PHONETIC
// key (kana/latin/cyrillic, no Han anchor) is collision-prone at 2 — short phonetic
// sequences appear INSIDE ordinary words (リン in リンゴ/apple, AI in RAID) — so it needs ≥3.
// external-review major #3: the MIN=2 measurement was on zh-Han and does NOT transfer to
// ja-kana; 3 is a conservative default pending a ja-kana precision measurement (an E1-
// protocol extension). A per-entry allow_short overrides both (rare, guarded).
const (
	minKeyLenHan      = 2
	minKeyLenPhonetic = 3
)

// minKeyLenFor is the floor for a normalized key: any Han ideograph anchors it (2), an
// all-phonetic key needs 3.
func minKeyLenFor(normKey string) int {
	if runesAnyHan(normKey) {
		return minKeyLenHan
	}
	return minKeyLenPhonetic
}

// collisionProneKey reports whether a key is short AND purely phonetic (no Han anchor),
// so it is likely to fire inside an unrelated word — the A2 "surface collision" case,
// injected as AMBIGUOUS (unverified) rather than authoritatively CONFIRMED.
func collisionProneKey(normKey string) bool {
	return !runesAnyHan(normKey) && significantLen(normKey) <= minKeyLenPhonetic
}

// stickyDepth is the scene-inertia window (A5): entries exact-matched in the previous
// N chunks of the SAME chapter are carried into a pronominal chunk that names nobody.
// A code rule (Р2), reset at each chapter boundary (a new chapter is a scene change).
const stickyDepth = 2

// injectionDisposition is the three-way per-record decision (registry A2) — the core
// mechanism that converts silent degradation into loud. Distinct from the chunk×stage
// Disposition (disposition.go): that is ok|flagged|skipped for a completion; this is
// how much to TRUST an injected glossary record.
type injectionDisposition string

const (
	memConfirmed injectionDisposition = "confirmed" // exact key/alias, approved, spoiler-valid → authoritative
	memAmbiguous injectionDisposition = "ambiguous" // auto/draft term → inject "unverified" + forced post-check
	memReject    injectionDisposition = "reject"    // spoiler-window violation → dropped and logged (C1 safety)
)

// declInfo is the parsed decl column: the dst declension forms the post-check accepts.
type declInfo struct {
	Invariant bool     `json:"invariant"`
	Forms     []string `json:"forms"`
}

// memoryEntry is one frozen, materialized glossary entry with its matchable keys
// pre-normalized. Immutable for a job.
type memoryEntry struct {
	id      string // stable unique id: src\x1f sense\x1f since\x1f until (= the UNIQUE key)
	src     string // raw source key
	dst     string // raw approved translation (may be "" for a ruby candidate)
	status  string // auto|draft|approved
	sense   string
	sinceCh int
	untilCh int
	// normKeys are the normalized source surfaces (src + aliases) ELIGIBLE to fire
	// (significantLen ≥ minKeyLen OR allow_short). A key too short is dropped here, so
	// it is never in the automaton — the single-key ban is structural, not a runtime skip.
	normKeys []string
	// declForms are the normalized-target dst forms the post-check accepts. Empty →
	// the post-check falls back to the single normalized dst (the naive base form that
	// research/14 shows false-flags on inflection — the reason full decl matters).
	declForms     []string
	declInvariant bool
	// allowShort is the author's explicit override of the single-key ban AND the
	// collision-prone disposition downgrade (full trust in a short key).
	allowShort bool
}

// MemoryBank is a book's frozen glossary materialized for one job: the entries, the
// Aho-Corasick automaton over all eligible keys, and the F1 memoryVersion. Built once
// (materializeMemory) before the chunk loop and never mutated.
type MemoryBank struct {
	entries   []memoryEntry
	ac        *ahoCorasick
	keyOwners map[string][]int // normalized key → indices of entries that contributed it
	version   string           // memoryVersion(): hash of frozen APPROVED rows + algo versions (F1/D8)
}

// pickedEntry is one selected record for a chunk with its firing key and disposition.
type pickedEntry struct {
	entry *memoryEntry
	via   string // the matching key, or "sticky"
	disp  injectionDisposition
}

// memorySelection is the hot path's output for one chunk.
type memorySelection struct {
	injected []pickedEntry // in budget priority order (what the model sees)
	rejected []pickedEntry // spoiler-window rejects (C1, logged)
	evicted  []pickedEntry // dropped by the token budget (F2, logged)
	// activeIDs are the exact-matched ids (NOT sticky) → the next chunk's sticky_prev,
	// each mapped to the DISPOSITION it fired with. Sticky carries this disposition
	// forward instead of recomputing it (D16.2): a sticky carry has no firing key, so it
	// cannot re-derive the collision-prone downgrade, and recomputing from status alone
	// would silently upgrade a collision-prone AMBIGUOUS match to CONFIRMED.
	activeIDs map[string]injectionDisposition
}

func (b *MemoryBank) Version() string { return b.version }

// materializeMemory builds a MemoryBank from the book's stored glossary rows (ORDER
// BY-stable — GlossaryForBook). Pure and deterministic. It computes memoryVersion as a
// content hash of the frozen APPROVED rows (D8: content-hash, not a version-counter —
// drift-proof) plus the normalization + matcher algorithm versions, so ANY change to
// the approved glossary OR to the deterministic machinery is a loud --resnapshot (F1).
func materializeMemory(rows []store.GlossaryEntry, gateOn bool) *MemoryBank {
	b := &MemoryBank{keyOwners: map[string][]int{}}
	var allKeys []string
	seenKey := map[string]bool{}

	for _, row := range rows {
		e := memoryEntry{
			id:      row.Src + "\x1f" + row.Sense + "\x1f" + strconv.Itoa(row.SinceCh) + "\x1f" + strconv.Itoa(row.UntilCh),
			src:     row.Src,
			dst:     row.Dst,
			status:  row.Status,
			sense:   row.Sense,
			sinceCh:    row.SinceCh,
			untilCh:    row.UntilCh,
			allowShort: row.AllowShort,
		}
		// Eligible source surfaces (src + aliases) under the single-key ban. A record
		// with NO dst yet (a ruby auto-candidate) is NOT matchable: it renders nothing
		// (renderGlossaryBlock/postcheck both skip empty dst), so admitting its keys would
		// let it consume the token budget, evict a renderable line, and inflate the
		// retrieval-state exact-hit count for a record the model never sees (self-review
		// #1). It stays in the store for future promotion, just inert on the hot path.
		var surfaces []string
		if strings.TrimSpace(row.Dst) != "" {
			surfaces = append(surfaces, row.Src)
			for _, a := range row.Aliases {
				surfaces = append(surfaces, a.Alias)
			}
		}
		for _, s := range surfaces {
			nk := normalizeSourceKey(s)
			if nk == "" {
				continue
			}
			if significantLen(nk) < minKeyLenFor(nk) && !row.AllowShort {
				continue // A3: a too-short key never fires on its own (per-language floor)
			}
			e.normKeys = append(e.normKeys, nk)
		}
		// Parse decl forms for the post-check (normalized target side).
		if row.Decl != "" {
			var d declInfo
			if err := json.Unmarshal([]byte(row.Decl), &d); err == nil {
				e.declInvariant = d.Invariant
				for _, f := range d.Forms {
					if nf := normalizeTargetForm(f); nf != "" {
						e.declForms = append(e.declForms, nf)
					}
				}
			}
		}
		idx := len(b.entries)
		b.entries = append(b.entries, e)
		for _, nk := range e.normKeys {
			b.keyOwners[nk] = append(b.keyOwners[nk], idx)
			if !seenKey[nk] {
				seenKey[nk] = true
				allKeys = append(allKeys, nk)
			}
		}
	}
	sort.Strings(allKeys) // deterministic automaton construction
	b.ac = buildAC(allKeys)
	b.version = computeMemoryVersion(rows, gateOn)
	return b
}

// computeMemoryVersion is the F1 content-hash: the frozen rows (ORDER BY-stable) plus the
// normalization and matcher algorithm versions. Approved-only per D8/§8 when the post-check
// hard gate is OFF (auto/draft injected-content changes are caught at the per-chunk
// content_hash level; their decl changes affect only the recomputed retrieval-state).
// When the gate is ON, the resolved chunk disposition depends on the decl forms of ALL
// injected records (approved AND auto/draft), and those are in NEITHER content_hash (decl
// is not injected) NOR the approved-only hash — so a decl edit would silently flip a
// resumed chunk's disposition (self-review #4). So gateOn folds every row's content
// (incl. decl + status) into the hash, making any such edit a loud --resnapshot. The
// glossary.id autoincrement is deliberately EXCLUDED (fresh each replace); only content
// columns are hashed.
func computeMemoryVersion(rows []store.GlossaryEntry, gateOn bool) string {
	h := sha256.New()
	h.Write([]byte("tm-memory-v2\x00"))
	h.Write([]byte(memoryNormVersion + "\x00" + memoryMatchVersion + "\x00"))
	h.Write([]byte("gate:" + strconv.FormatBool(gateOn) + "\x00"))
	for _, r := range rows {
		if r.Status != "approved" && !gateOn {
			continue
		}
		// A fixed, length-prefixed field layout so no content can forge a boundary.
		writeField(h, r.Status)
		writeField(h, r.Src)
		writeField(h, r.Dst)
		writeField(h, r.Sense)
		writeField(h, r.Type)
		writeField(h, r.Gender)
		writeField(h, r.Decl)
		writeField(h, strconv.Itoa(r.SinceCh))
		writeField(h, strconv.Itoa(r.UntilCh))
		writeField(h, strconv.FormatBool(r.AllowShort))
		writeField(h, strconv.Itoa(len(r.Aliases)))
		for _, a := range r.Aliases { // GlossaryForBook returns aliases ORDER BY alias
			writeField(h, a.Alias)
			writeField(h, a.AliasType)
		}
	}
	return hex.EncodeToString(h.Sum(nil))
}

func writeField(h interface{ Write([]byte) (int, error) }, s string) {
	var lb [8]byte
	n := uint64(len(s))
	for i := 0; i < 8; i++ {
		lb[i] = byte(n >> (8 * i))
	}
	h.Write(lb[:])
	h.Write([]byte(s))
}

// glossaryLineTokens is the injected token cost of one record's "src → dst" line — the
// unit the token budget (config glossary_token_budget) spends. It is FLOOR-FREE (the raw
// cjk + other/3 estimate, NOT EstimateTokens' 16-token minimum): the floor is for sizing
// a whole request's max_tokens, but applying it PER glossary line ~1.85×-overcounts a
// block of short name lines and needlessly evicts records that fit (self-review #7). The
// header's cost is small and constant; omitting it keeps the unit purely per-record.
// Shared by Select's budget and the eviction test so the two never diverge.
func glossaryLineTokens(e *memoryEntry) int {
	cjk, other := 0, 0
	for _, r := range e.src + " → " + e.dst {
		switch {
		case unicode.In(r, unicode.Han, unicode.Hiragana, unicode.Katakana, unicode.Hangul):
			cjk++
		case unicode.IsSpace(r):
		default:
			other++
		}
	}
	return cjk + other/3
}

// Select is the hot path for ONE chunk: match → spoiler-reject → disposition → sticky
// → priority token budget. Pure and deterministic. stickyPrev is the set of ids
// exact-matched in the prior chunk(s) of the same chapter (A5). budgetTokens ≤ 0 means
// unbounded; otherwise records are kept in priority order while the cumulative injected
// token cost stays within budget — the rest are EVICTED (dropped but logged, F2; the
// budget may be underfilled, "лучше ничего, чем мусор").
func (b *MemoryBank) Select(chunk string, chapter int, stickyPrev map[string]injectionDisposition, budgetTokens int) memorySelection {
	ntext := []rune(normalizeSourceKey(chunk))
	occ := b.ac.matches(ntext)
	// Source word-boundary for spaced-phonetic keys (D16.3): drop an occurrence of a Latin/
	// Cyrillic key that fired INSIDE a longer word of the same script ("rose" in "roseanne").
	// Closes the source-vs-target boundary asymmetry (the target side already has
	// containsWholeWord). Han/kana are NOT boundary-checked (no word segmentation) — deliberate.
	occ = b.suppressUnboundedPhonetic(occ, ntext)
	// Longest-match: drop a key fully inside a strictly-longer key's span — but ONLY when
	// the longer key belongs to a spoiler-VALID entry at this chapter (self-review #6). A
	// spoiler-blocked longer key must not suppress a valid shorter name nested inside it.
	occ = b.suppressContained(occ, chapter)

	// Map surviving key occurrences → entries, recording the LONGEST firing key per entry.
	matchedVia := map[int]string{}
	for _, m := range occ {
		k := b.ac.keys[m.keyIdx]
		for _, ei := range b.keyOwners[k] {
			if cur, ok := matchedVia[ei]; !ok || len([]rune(k)) > len([]rune(cur)) {
				matchedVia[ei] = k
			}
		}
	}

	sel := memorySelection{activeIDs: map[string]injectionDisposition{}}
	hits := map[string]pickedEntry{} // id → picked (exact)

	// Iterate entries in their frozen (ORDER BY-stable) order — never map order.
	for ei := range b.entries {
		via, ok := matchedVia[ei]
		if !ok {
			continue
		}
		e := &b.entries[ei]
		if blocked := spoilerBlocked(e, chapter); blocked {
			sel.rejected = append(sel.rejected, pickedEntry{e, via, memReject})
			continue
		}
		disp := dispositionFor(e, via)
		hits[e.id] = pickedEntry{e, via, disp}
		sel.activeIDs[e.id] = disp // exact-matched ids feed the next chunk's sticky WITH the disposition they fired with (D16.2)
	}

	// Sticky scene-inertia: carry prior-chunk exact matches not re-matched here, unless
	// the spoiler window blocks them (spoiler beats sticky). The carry INHERITS the prior
	// match's disposition (D16.2) — a sticky carry has no firing key, so it cannot re-derive
	// the collision-prone downgrade; recomputing status-based would silently upgrade a
	// collision-prone AMBIGUOUS match to CONFIRMED, past the post-check, into the editor
	// constraints. An AMBIGUOUS carry stays AMBIGUOUS (⟨проверить⟩, excluded from the editor).
	for ei := range b.entries {
		e := &b.entries[ei]
		carryDisp, sticky := stickyPrev[e.id]
		if !sticky {
			continue
		}
		if _, already := hits[e.id]; already {
			continue
		}
		if spoilerBlocked(e, chapter) {
			continue
		}
		hits[e.id] = pickedEntry{e, "sticky", carryDisp}
	}

	// Deterministic priority order for the budget: confirmed>ambiguous, exact>sticky,
	// approved>auto. Collect in frozen-entry order (NOT map order) then stable-sort, so
	// ties keep the deterministic base order.
	order := make([]pickedEntry, 0, len(hits))
	for ei := range b.entries {
		if p, ok := hits[b.entries[ei].id]; ok {
			order = append(order, p)
		}
	}
	sort.SliceStable(order, func(i, j int) bool {
		return priorityRank(order[i]) < priorityRank(order[j])
	})

	if budgetTokens > 0 {
		used, cut := 0, len(order)
		for i := range order {
			used += glossaryLineTokens(order[i].entry)
			if used > budgetTokens {
				cut = i // this record and everything after it overflow the budget
				break
			}
		}
		sel.injected = order[:cut]
		sel.evicted = order[cut:] // F2: dropped, but LOGGED via the retrieval-state
	} else {
		sel.injected = order
	}
	return sel
}

// spoilerBlocked reports whether the entry's since_ch/until_ch window excludes this
// chapter (C1: a hard reject gate — a fact the current chapter must not know yet).
func spoilerBlocked(e *memoryEntry, chapter int) bool {
	if e.sinceCh > 0 && chapter < e.sinceCh {
		return true
	}
	if e.untilCh > 0 && chapter > e.untilCh {
		return true
	}
	return false
}

// dispositionFor maps a term + its firing key to an injection disposition (A2). approved →
// CONFIRMED (authoritative); auto/draft → AMBIGUOUS. AND: even an approved entry matched by
// a COLLISION-PRONE key (short + purely phonetic — リン in リンゴ, AI in RAID) is downgraded to
// AMBIGUOUS (unverified + forced post-check), because such a key may have fired inside an
// unrelated word rather than on the entity (external-review major #2 — the A2
// "surface-collision → AMBIGUOUS" branch). allow_short is the author's explicit override.
// Called ONLY for an EXACT match (a real firing key): a sticky carry INHERITS its prior
// disposition instead (D16.2), never routing through here, so it cannot silently upgrade a
// collision-prone match. The via!="sticky" guard is kept defensive.
func dispositionFor(e *memoryEntry, via string) injectionDisposition {
	if via != "sticky" && !e.allowShort && collisionProneKey(via) {
		return memAmbiguous
	}
	if e.status == "approved" {
		return memConfirmed
	}
	return memAmbiguous
}

// priorityRank orders the budget: confirmed before ambiguous, exact before sticky,
// approved before auto/draft (F2 eviction keeps the most trustworthy records).
func priorityRank(p pickedEntry) int {
	rank := 0
	if p.disp != memConfirmed {
		rank |= 1 << 2
	}
	if p.via == "sticky" {
		rank |= 1 << 1
	}
	if p.entry.status != "approved" {
		rank |= 1 << 0
	}
	return rank
}

// glossaryBlockHeader introduces the injected glossary block. The layout mirrors the
// SakuraLLM/GalTransl convention "src → dst", with unverified (ambiguous) records
// tagged so the model — and a human reviewer — see they are not authoritative (A2).
const glossaryBlockHeader = "ГЛОССАРИЙ (используй эти утверждённые переводы имён и терминов последовательно; строки с пометкой ⟨проверить⟩ — неподтверждённые кандидаты):"

// renderGlossaryBlock serializes the selected records into the injection message
// (§C). Records with no dst yet (ruby candidates) are skipped — a "src → " line
// carries nothing. Returns "" when nothing renders, so an empty selection injects NO
// message at all ("лучше ничего, чем мусор"). Deterministic: the injected order is the
// budget priority order fixed by Select.
func renderGlossaryBlock(injected []pickedEntry) string {
	var lines []string
	for _, p := range injected {
		if strings.TrimSpace(p.entry.dst) == "" {
			continue
		}
		line := p.entry.src + " → " + p.entry.dst
		if p.disp != memConfirmed {
			line += "  ⟨проверить⟩"
		}
		lines = append(lines, line)
	}
	if len(lines) == 0 {
		return ""
	}
	return glossaryBlockHeader + "\n" + strings.Join(lines, "\n")
}

// editorConstraintHeader introduces the monolingual editor's dst-constraint block (D1).
// The editor never sees the source, so it gets ONLY the approved target renderings as
// consistency constraints — it must normalise any divergent rendering in the draft to
// these canonical forms (inflecting for context) and touch nothing else.
const editorConstraintHeader = "КАНОНИЧЕСКИЕ ПЕРЕВОДЫ имён и терминов (в черновике эти сущности должны быть переданы ИМЕННО этими формами — приводи к ним любые расхождения, склоняя по контексту; не вводи иных вариантов и не меняй ничего другого):"

// renderEditorConstraintBlock serializes the selected records' CONFIRMED dst renderings into
// the monolingual editor's injection (D1). Unlike renderGlossaryBlock it emits the TARGET
// forms only — no "src → dst" (the editor never sees the source: decisions-log D1). It is
// CONFIRMED-only on purpose: an AMBIGUOUS record is a candidate the translator MAY have
// legitimately rejected, so forcing the editor to rewrite the draft toward it would corrupt
// a correct translation (mirrors the gate's CONFIRMED-only discipline, external-review #1).
// Deduped by dst (an alias and its main entry share a rendering), in the budget priority
// order fixed by Select. Returns "" when nothing CONFIRMED renders → the editor gets NO
// injection (the plain draft-only layout). The block is a subset of the already
// budget-limited memSel.injected, so it needs no separate token budget.
func renderEditorConstraintBlock(injected []pickedEntry) string {
	var lines []string
	seen := map[string]bool{}
	for _, p := range injected {
		if p.disp != memConfirmed {
			continue
		}
		dst := strings.TrimSpace(p.entry.dst)
		if dst == "" || seen[dst] {
			continue
		}
		seen[dst] = true
		lines = append(lines, "- «"+dst+"»")
	}
	if len(lines) == 0 {
		return ""
	}
	return editorConstraintHeader + "\n" + strings.Join(lines, "\n")
}

// --- Aho-Corasick multi-pattern automaton over runes ----------------------------

type acMatch struct {
	keyIdx     int
	start, end int // rune indices [start, end)
}

type acNode struct {
	next map[rune]int
	fail int
	out  []int // key indices whose pattern ends at this node (incl. via fail links)
}

type ahoCorasick struct {
	nodes []acNode
	keys  []string
	klen  []int // rune length of each key
}

// buildAC constructs the automaton from the (already sorted, unique) normalized keys.
// Deterministic in RESULT regardless of map iteration order: fail links and outputs are
// a function of the key set, not the BFS visit order.
func buildAC(keys []string) *ahoCorasick {
	ac := &ahoCorasick{keys: keys, klen: make([]int, len(keys))}
	ac.nodes = []acNode{{next: map[rune]int{}}} // root = node 0
	for i, k := range keys {
		kr := []rune(k)
		ac.klen[i] = len(kr)
		cur := 0
		for _, r := range kr {
			nxt, ok := ac.nodes[cur].next[r]
			if !ok {
				nxt = len(ac.nodes)
				ac.nodes = append(ac.nodes, acNode{next: map[rune]int{}})
				ac.nodes[cur].next[r] = nxt
			}
			cur = nxt
		}
		ac.nodes[cur].out = append(ac.nodes[cur].out, i)
	}
	// BFS to compute fail links; propagate outputs down fail chains one level (each
	// node inherits its fail node's already-complete output set).
	var queue []int
	for _, c := range ac.nodes[0].next {
		ac.nodes[c].fail = 0
		queue = append(queue, c)
	}
	for len(queue) > 0 {
		cur := queue[0]
		queue = queue[1:]
		for r, nxt := range ac.nodes[cur].next {
			queue = append(queue, nxt)
			f := ac.nodes[cur].fail
			for f != 0 {
				if _, ok := ac.nodes[f].next[r]; ok {
					break
				}
				f = ac.nodes[f].fail
			}
			if fn, ok := ac.nodes[f].next[r]; ok && fn != nxt {
				ac.nodes[nxt].fail = fn
			} else {
				ac.nodes[nxt].fail = 0
			}
			ac.nodes[nxt].out = append(ac.nodes[nxt].out, ac.nodes[ac.nodes[nxt].fail].out...)
		}
	}
	return ac
}

// matches returns every key occurrence in text (rune indices), sorted deterministically.
func (ac *ahoCorasick) matches(text []rune) []acMatch {
	var out []acMatch
	cur := 0
	for i, r := range text {
		for cur != 0 {
			if _, ok := ac.nodes[cur].next[r]; ok {
				break
			}
			cur = ac.nodes[cur].fail
		}
		if nxt, ok := ac.nodes[cur].next[r]; ok {
			cur = nxt
		} else {
			cur = 0
		}
		for _, ki := range ac.nodes[cur].out {
			out = append(out, acMatch{keyIdx: ki, start: i - ac.klen[ki] + 1, end: i + 1})
		}
	}
	sort.Slice(out, func(a, b int) bool {
		if out[a].start != out[b].start {
			return out[a].start < out[b].start
		}
		if out[a].end != out[b].end {
			return out[a].end < out[b].end
		}
		return out[a].keyIdx < out[b].keyIdx
	})
	return out
}

// suppressContained drops an occurrence fully contained inside a STRICTLY longer one
// (longest-match, whole-entity replacement — A3), where the longer one belongs to a
// spoiler-VALID entry at this chapter. Gating the SUPPRESSOR on spoiler validity closes
// self-review #6: a spoiler-blocked longer key (e.g. 林动的父亲, until_ch=3, read at ch10)
// must not silently drop a valid shorter name nested inside it (林动) — the shorter name
// survives and injects, the longer one is separately spoiler-rejected+logged. Equal-length
// overlaps are both kept (genuine surface ambiguity, surfaced by the injectivity check).
// O(n²) over the few matches in a chunk.
func (b *MemoryBank) suppressContained(ms []acMatch, chapter int) []acMatch {
	validSuppressor := func(m acMatch) bool {
		for _, ei := range b.keyOwners[b.ac.keys[m.keyIdx]] {
			if !spoilerBlocked(&b.entries[ei], chapter) {
				return true
			}
		}
		return false
	}
	var kept []acMatch
	for i, m := range ms {
		contained := false
		for j, o := range ms {
			if i == j {
				continue
			}
			if o.start <= m.start && o.end >= m.end && (o.end-o.start) > (m.end-m.start) && validSuppressor(o) {
				contained = true
				break
			}
		}
		if !contained {
			kept = append(kept, m)
		}
	}
	return kept
}

// suppressUnboundedPhonetic drops an occurrence of a SPACED-SCRIPT phonetic key (Latin or
// Cyrillic — scripts that delimit words with whitespace/non-letters, exactly like the
// target-side containsWholeWord) when a neighbouring rune is a letter of the SAME script:
// the key fired INSIDE a longer word ("rose" in "roseanne"/"roses"), not on the entity
// (D16.3). This closes the source-vs-target boundary asymmetry the ≤3 collision-downgrade
// does not (a ≥4 phonetic key fires CONFIRMED with no boundary today). A cross-script
// neighbour (a Latin name abutting a Han char in unspaced source) is a valid boundary, so
// only a SAME-script letter suppresses — mirroring "не-буквенных или чужескриптовых".
// KANA and HAN are deliberately NOT boundary-checked: they have no word segmentation, so a
// letter-boundary rule would false-negative the ubiquitous kana name+particle case (すずきは)
// or contradict the long-key-CONFIRMED contract — their ≥4 precision is deferred to the
// kana-precision measurement + the B6 tokenizer (D16; flagged to the orchestrator). ntext is
// the already-normalized chunk. O(n) over the few matches in a chunk.
func (b *MemoryBank) suppressUnboundedPhonetic(ms []acMatch, ntext []rune) []acMatch {
	var kept []acMatch
	for _, m := range ms {
		script := spacedPhoneticScript(b.ac.keys[m.keyIdx])
		if script != nil {
			beforeBad := m.start > 0 && letterInScript(ntext[m.start-1], script)
			afterBad := m.end < len(ntext) && letterInScript(ntext[m.end], script)
			if beforeBad || afterBad {
				continue // the key is inside a longer same-script word — not a whole-entity match
			}
		}
		kept = append(kept, m)
	}
	return kept
}

// spacedPhoneticScript returns the word-delimited alphabetic script a normalized key belongs
// to for the source word-boundary check — unicode.Latin or unicode.Cyrillic — or nil when the
// key carries a Han ideograph or kana (no word segmentation) or mixes the two spaced scripts.
// Digits/punctuation don't set the script but don't disqualify (so "o'brien" is still Latin).
func spacedPhoneticScript(normKey string) *unicode.RangeTable {
	var script *unicode.RangeTable
	for _, r := range normKey {
		switch {
		case unicode.In(r, unicode.Han, unicode.Hiragana, unicode.Katakana, unicode.Hangul):
			return nil // an ideographic/kana anchor is never boundary-checked here
		case unicode.In(r, unicode.Latin):
			if script == unicode.Cyrillic {
				return nil // mixed spaced scripts — do not boundary-check
			}
			script = unicode.Latin
		case unicode.In(r, unicode.Cyrillic):
			if script == unicode.Latin {
				return nil
			}
			script = unicode.Cyrillic
		}
	}
	return script
}

// letterInScript reports whether r is a LETTER of the given spaced script — the "same-script
// letter" that breaks a word boundary. A digit, punctuation, space, or a letter of another
// script is a valid boundary and returns false.
func letterInScript(r rune, script *unicode.RangeTable) bool {
	return unicode.IsLetter(r) && unicode.In(r, script)
}

// injectivityCollisions reports approved dst collisions (B2): two distinct source
// terms mapped to the SAME dst (one Russian surface for two entities → the reader
// cannot tell them apart), returned as human-readable strings for a load-time warning.
// A pure diagnostic; it does not reject (some collisions are legitimate, e.g. a title
// shared by rank tiers), so the caller logs it, not aborts.
func injectivityCollisions(rows []store.GlossaryEntry) []string {
	bySurface := map[string][]string{}
	for _, r := range rows {
		if r.Status != "approved" || strings.TrimSpace(r.Dst) == "" {
			continue
		}
		key := normalizeTargetForm(r.Dst)
		bySurface[key] = append(bySurface[key], r.Src)
	}
	var out []string
	// Deterministic order: iterate a sorted key list, not the map.
	keys := slices.Sorted(maps.Keys(bySurface))
	for _, k := range keys {
		srcs := bySurface[k]
		if len(distinct(srcs)) > 1 {
			sort.Strings(srcs)
			out = append(out, "dst "+strconv.Quote(k)+" ← "+strings.Join(distinct(srcs), ", "))
		}
	}
	return out
}

func distinct(ss []string) []string {
	seen := map[string]bool{}
	var out []string
	for _, s := range ss {
		if !seen[s] {
			seen[s] = true
			out = append(out, s)
		}
	}
	sort.Strings(out)
	return out
}
