package pipeline

import (
	"strings"
	"unicode"
)

// mempostcheck.go: the post-check (registry E1/E2 / research/13 Q5) — the main, almost
// free detector of silent degradation. For every injected record whose SRC actually
// fired in THIS chunk, it asks: did an accepted DST form appear in the output? A miss
// catches BOTH failure modes — "the model ignored the glossary" (soft-following loses
// 17–36% of terms, 2310.05824) AND "we injected the wrong dst and the model obeyed it"
// (the owner's fear, 2510.00829). It is a post-CHECK, never a blind post-REPLACE (E2):
// forcing a dictionary form into an oblique Russian case breaks agreement (46% of
// constrained-model errors in en-cs are agreement, 2106.12398), so v1 only FLAGS.
//
// Decl-awareness is load-bearing (research/14 §2, quantified): a naive \b-regexp on the
// base form gives 18–36% FALSE flags on a hard chunk — inflected forms rendered
// CORRECTLY ("Вэйчжуане", "Бородатого Вана", "Дэна", "сюцая") that the boundary regexp
// misses. So the check matches against the STORED decl forms (filled at term-commit),
// not the bare lemma. BUT stored-decl reliability is itself a function of decl
// COMPLETENESS — an OOV translit name whose forms were under-filled re-introduces the
// same false flags. That is why v1 keeps the post-check a FLAGGER (observable, in the
// retrieval-state), NOT a hard disposition gate, until the false-flag rate is MEASURED
// on real inflected Russian (E1 — memory_e1_test.go). A hard gate flips on only after
// the owner validates precision (mirrors the coverage gate's opt-in, D12 Q4).
//
// Sticky records are DELIBERATELY excluded: a sticky entry's src is NOT in this chunk
// (that is why scene-inertia carries it), so its dst is legitimately absent from the
// output — post-checking it would false-flag every pronominal chunk. Only records whose
// key exactly fired here are expected in the output.

// postcheckMiss is one flagged term: an injected record whose src fired but whose dst
// (any accepted form) is absent from the output.
type postcheckMiss struct {
	Src  string `json:"src"`
	Dst  string `json:"dst"`
	Disp string `json:"disp"` // confirmed | ambiguous (A2: ambiguous injections force a post-check)
}

// countConfirmedMisses counts misses of CONFIRMED (approved) records only. AMBIGUOUS
// misses (an auto/draft candidate the model was ENTITLED to reject) must never flip a
// disposition or count as a consistency failure: doing so would invert the contract —
// punishing a model that correctly rejected an unverified suggestion while passing one
// that obeyed a wrong CONFIRMED injection (external-review major #1). The AMBIGUOUS misses
// still travel in the detail (A2 forced-post-check observability), just not as the count.
func countConfirmedMisses(misses []postcheckMiss) int {
	n := 0
	for _, m := range misses {
		if m.Disp == string(memConfirmed) {
			n++
		}
	}
	return n
}

// postcheck runs the decl-aware check over the injected records against the model
// output. Pure and deterministic. Returns the misses (empty = all fired terms present).
func (b *MemoryBank) postcheck(injected []pickedEntry, output string) []postcheckMiss {
	nout := normalizeTargetForm(output)
	var misses []postcheckMiss
	noutRunes := []rune(nout)
	for _, p := range injected {
		if p.via == "sticky" { // sticky context is not expected in the output
			continue
		}
		if strings.TrimSpace(p.entry.dst) == "" { // a ruby candidate with no dst yet — nothing to check
			continue
		}
		if !dstFormPresent(p.entry, noutRunes) {
			misses = append(misses, postcheckMiss{Src: p.entry.src, Dst: p.entry.dst, Disp: string(p.disp)})
		}
	}
	return misses
}

// dstFormPresent reports whether any accepted dst form of the entry appears in the
// normalized output as a WHOLE WORD (bounded by non-letters or string edges), not a bare
// substring. The whole-word rule closes self-review #3: a DROPPED short translit name
// («Ван») must not be masked by an unrelated common word that merely contains its letters
// («караВАН», «диВАН», «ИВАН» — a different person). Decl-aware (the research/14
// requirement): the stored decl forms carry the inflections a boundary regexp would miss.
//
// The accepted set is the UNION of the base dst AND the stored decl forms (D24.4 — D24.3 is
// the max_tokens floor, a different fix; the union is the post-check verdict change):
// the base translation is itself an approved form, so it is ALWAYS checked — not only as a
// fallback when decl is empty. Checking it only on empty decl false-flagged 37/55
// acceptance-stage-A misses (nominative_gap): an approved dst present in the NOMINATIVE
// while `decl.forms` listed only oblique cases (方源→Фан Юань present in the output, decl
// carried only genitive/dative). Requiring the base form to be duplicated into decl is
// redundant input every future book would trip on.
//
// PRECISION/RECALL TRADEOFF, not a free win (adversarial review, pkg 5): enlarging the
// accepted set is monotonic — it can only turn misses into passes, never the reverse. On
// the measured stage-A data those removed misses are all FALSE positives on correctly
// rendered nominatives (precision up, the 37/55). The one way it can COST recall is narrow
// but real: an entry whose decl OMITS the nominative AND whose base dst is a common word
// that recurs elsewhere while the term itself was DROPPED for its firing occurrence
// (剑→меч dropped as «клинок», yet an unrelated «меч» sits elsewhere) — this bag-of-words
// presence check then passes and masks the drop. That class is rare on real data (translit
// names do not recur coincidentally) and TOLERABLE only while this stays a FLAGGER: a
// hard-gate promotion (postcheck_gate) MUST re-measure recall on common-noun terms, not
// assume it — the "recall unaffected" phrasing in the D24.4 rationale is imprecise here.
// The remaining inflection_gap (18/55: a plural rendered but only the singular seeded) is a
// SEED completeness fix (Polygon), not a code one. noutRunes is the normalized output
// pre-decomposed.
func dstFormPresent(e *memoryEntry, noutRunes []rune) bool {
	if base := normalizeTargetForm(e.dst); base != "" && containsWholeWord(noutRunes, []rune(base)) {
		return true
	}
	for _, f := range e.declForms {
		if f != "" && containsWholeWord(noutRunes, []rune(f)) {
			return true
		}
	}
	return false
}

// containsWholeWord reports whether form occurs in text with a letter-boundary on both
// ends (the char before the match is not a letter or is the start; likewise after). Runs
// on runes so Cyrillic boundaries are correct. O(len(text)·len(form)) — fine, the
// post-check runs once per chunk over a handful of short forms.
func containsWholeWord(text, form []rune) bool {
	n := len(form)
	if n == 0 || n > len(text) {
		return false
	}
	for i := 0; i+n <= len(text); i++ {
		if !runesEqual(text[i:i+n], form) {
			continue
		}
		if (i == 0 || !unicode.IsLetter(text[i-1])) && (i+n == len(text) || !unicode.IsLetter(text[i+n])) {
			return true
		}
	}
	return false
}

func runesEqual(a, b []rune) bool {
	for i := range a {
		if a[i] != b[i] {
			return false
		}
	}
	return true
}
