package pipeline

import (
	"encoding/json"
	"strings"
	"testing"

	"textmachine/backend/internal/store"
)

// memory_test.go ports the executable spec eval/memory_hotpath.py (T1–T10) to Go
// unit tests, per the onboarding contract — but NOT the accept-REGEXPS verbatim
// (research/14: a \b-regexp is the 18–36% false-flag source). The selection tests
// (T1–T8) are the same cases; the post-check tests (T9/T10) use the decl-aware
// matcher (memory_e1_test.go measures its false-flag rate on real Russian).

func gl(src, dst, sense, status string) store.GlossaryEntry {
	return store.GlossaryEntry{BookID: "b", Src: src, Dst: dst, Sense: sense, Status: status, Source: "seed"}
}

func declJSON(invariant bool, forms ...string) string {
	b, _ := json.Marshal(declInfo{Invariant: invariant, Forms: forms})
	return string(b)
}

func alias(a string) store.GlossaryAlias { return store.GlossaryAlias{Alias: a, AliasType: "прозвище"} }

// specGlossary mirrors eval/memory_hotpath.py's G (with decl forms replacing the
// toy accept-regexps).
func specGlossary() []store.GlossaryEntry {
	ahq := gl("阿Q", "А-кью", "name", "approved")
	ahq.Decl = declJSON(true, "А-кью", "А кью")

	sishu := gl("四叔", "Четвёртый дядюшка", "char", "approved")
	sishu.Aliases = []store.GlossaryAlias{alias("鲁四老爷")}
	sishu.Decl = declJSON(false, "дядюшка", "дядюшки", "дядюшке", "дядюшку", "дядюшкой")

	luzhen := gl("鲁镇", "Лучжэнь", "", "approved")
	luzhen.Decl = declJSON(false, "Лучжэнь", "Лучжэня", "Лучжэне")

	songzao := gl("送灶", "проводы бога очага", "festival", "approved")
	songzao.Decl = declJSON(true, "бога очага")

	xiaoyan := gl("萧炎", "Сяо Янь", "name", "approved")
	xiaoyan.Aliases = []store.GlossaryAlias{alias("炎哥")}
	xiaoyan.Decl = declJSON(true, "Сяо Янь")

	xiuwei := gl("修为", "уровень совершенствования", "cultivation", "approved")
	xiuwei.Decl = declJSON(false, "совершенствования", "совершенствование")

	shadow := gl("影卫", "Тёмный страж", "spoiler", "approved")
	shadow.SinceCh = 200
	shadow.Decl = declJSON(false, "Тёмный страж", "Тёмного стража")

	newname := gl("小D", "Малыш Дэ", "", "auto")
	newname.Decl = declJSON(false, "Малыш Дэ", "Малыша Дэ")

	return []store.GlossaryEntry{ahq, sishu, luzhen, songzao, xiaoyan, xiuwei, shadow, newname}
}

func bankFrom(entries []store.GlossaryEntry) *MemoryBank { return materializeMemory(entries, false) }

// injMap maps injected src → disposition for assertions.
func injMap(sel memorySelection) map[string]injectionDisposition {
	m := map[string]injectionDisposition{}
	for _, p := range sel.injected {
		m[p.entry.src] = p.disp
	}
	return m
}

func TestHotPathSpecT1toT10(t *testing.T) {
	b := bankFrom(specGlossary())
	const budget = 0

	// T1: exact + alias select; alias 鲁四老爷 pulls 四叔.
	sel := b.Select("阿Q走进鲁镇，遇见鲁四老爷。", 1, nil, budget)
	m := injMap(sel)
	if m["阿Q"] != memConfirmed || m["鲁镇"] != memConfirmed || m["四叔"] != memConfirmed {
		t.Errorf("T1 exact+alias: %v", m)
	}

	// T2: 送到灶 (literal) — 送灶 must NOT inject (bigram absent, 灶 single-key banned).
	sel = b.Select("厨房里，老妈子把饭菜送到灶间去热一热。", 1, nil, budget)
	if _, ok := injMap(sel)["送灶"]; ok {
		t.Error("T2 wrong-sense 送灶 injected (A3 homograph)")
	}

	// T3: 炎势 (flame) — 萧炎 must NOT inject.
	sel = b.Select("山火蔓延，炎势冲天，村民连夜逃走。", 1, nil, budget)
	if _, ok := injMap(sel)["萧炎"]; ok {
		t.Error("T3 wrong-sense 炎 injected")
	}

	// T4: 修好 (repair) — 修为 must NOT inject.
	sel = b.Select("工匠把石桥修好了。", 1, nil, budget)
	if _, ok := injMap(sel)["修为"]; ok {
		t.Error("T4 wrong-sense 修 injected")
	}

	// T5: trad→simp normalization — 魯鎮 matches key 鲁镇 (A4).
	sel = b.Select("魯鎮的冬天很冷。", 1, nil, budget)
	if _, ok := injMap(sel)["鲁镇"]; !ok {
		t.Error("T5 trad→simp: 魯鎮 did not match 鲁镇")
	}

	// T6: pronominal chunk — sticky carries 四叔 from the previous chunk, INHERITING the
	// prior chunk's CONFIRMED disposition (D16.2: sticky carries the disposition forward).
	sishuID := "四叔\x1fchar\x1f0\x1f0"
	sel = b.Select("他慢慢站起来，叹了口气。", 2, map[string]injectionDisposition{sishuID: memConfirmed}, budget)
	var stickySishu *pickedEntry
	for i := range sel.injected {
		if sel.injected[i].entry.src == "四叔" {
			stickySishu = &sel.injected[i]
		}
	}
	if stickySishu == nil || stickySishu.via != "sticky" {
		t.Errorf("T6 sticky scene-inertia failed: %+v", injMap(sel))
	}

	// T7: spoiler — 影卫 (since_ch=200) in chapter 5 → hard reject.
	sel = b.Select("影卫出现了。", 5, nil, budget)
	if _, ok := injMap(sel)["影卫"]; ok {
		t.Error("T7 spoiler 影卫 injected in ch5")
	}
	if len(sel.rejected) != 1 || sel.rejected[0].entry.src != "影卫" {
		t.Errorf("T7 spoiler not rejected+logged: %+v", sel.rejected)
	}
	// T7b: same entry passes after the reveal chapter.
	sel = b.Select("影卫出现了。", 250, nil, budget)
	if _, ok := injMap(sel)["影卫"]; !ok {
		t.Error("T7b spoiler entry did not pass at ch250")
	}

	// T8: auto term 小D → ambiguous disposition.
	sel = b.Select("小D也来了。", 1, nil, budget)
	if injMap(sel)["小D"] != memAmbiguous {
		t.Errorf("T8 auto-term not ambiguous: %v", injMap(sel))
	}

	// T9: post-check catches a missing dst (leak) and passes a present one.
	sel = b.Select("阿Q走进鲁镇。", 1, nil, budget)
	bad := b.postcheck(sel.injected, "Некто вошёл в Лучжэнь.") // А-кью dropped
	good := b.postcheck(sel.injected, "А-кью вошёл в Лучжэнь.")
	if !hasMiss(bad, "阿Q") {
		t.Errorf("T9 post-check missed the leak: %+v", bad)
	}
	if len(good) != 0 {
		t.Errorf("T9 post-check false-flagged a correct output: %+v", good)
	}

	// T10: post-check catches poisoning — injected dst absent, output used a foreign name.
	sel = b.Select("阿Q走进鲁镇。", 1, nil, budget)
	poison := b.postcheck(sel.injected, "Линь Чун вошёл в деревню Вэйчжуан.")
	if !hasMiss(poison, "阿Q") {
		t.Errorf("T10 post-check missed the poisoning: %+v", poison)
	}
}

func hasMiss(misses []postcheckMiss, src string) bool {
	for _, m := range misses {
		if m.Src == src {
			return true
		}
	}
	return false
}

// TestTrapCorpus ports the retrieval_bench wrong-sense traps: exact multi-char match
// must fire 0/N on homograph chunks that contain the KEY CHARACTER but not the term.
func TestTrapCorpus(t *testing.T) {
	entries := []store.GlossaryEntry{
		gl("送灶", "проводы бога очага", "festival", "approved"),
		gl("修为", "уровень совершенствования", "cultivation", "approved"),
		gl("萧炎", "Сяо Янь", "name", "approved"),
		gl("斗气", "боевая ци", "term", "approved"),
		gl("钱老爷", "господин Цянь", "name", "approved"), // multi-char; the bare 钱 homograph must not fire
	}
	b := bankFrom(entries)
	traps := []struct{ name, text string }{
		{"送到灶 literal", "厨房里冷了，老妈子把剩下的饭菜送到灶间去重新热一热。"},
		{"修好 repair", "工匠们花了三天，终于把那条被山洪冲垮的石桥修好了。"},
		{"炎势 flame", "山火蔓延，炎势冲天，浓烟滚滚，村民连夜收拾细软。"},
		{"雾气 mist", "清晨的山谷里雾气很重，空气格外清新，露水打湿了草叶。"},
		{"钱 money homograph", "他摸出几个铜钱，买了一碗热汤。"}, // 钱 present but 钱老爷 absent
		{"Chekhov negative", "Дмитрий Гуров сидел в ялтинском павильоне и лениво пил чай."},
	}
	for _, tr := range traps {
		t.Run(tr.name, func(t *testing.T) {
			sel := b.Select(tr.text, 1, nil, 0)
			if len(sel.injected) != 0 {
				t.Errorf("trap fired %d injection(s), want 0: %v", len(sel.injected), injMap(sel))
			}
		})
	}
}

// TestLongestMatchContainment: a shorter key fully inside a longer key's span is
// suppressed (longest-match / whole-entity replacement, A3), but the shorter entity
// still fires when it stands alone.
func TestLongestMatchContainment(t *testing.T) {
	entries := []store.GlossaryEntry{
		gl("阿Q", "А-кью", "name", "approved"),
		gl("阿Q正传", "«Подлинная история А-кью»", "title", "approved"),
	}
	b := bankFrom(entries)
	// Inside the title: only the title fires.
	sel := b.Select("我读了《阿Q正传》。", 1, nil, 0)
	m := injMap(sel)
	if _, ok := m["阿Q正传"]; !ok {
		t.Errorf("title did not fire: %v", m)
	}
	if _, ok := m["阿Q"]; ok {
		t.Errorf("short key 阿Q fired inside the longer title span: %v", m)
	}
	// Standalone: the character name fires.
	sel = b.Select("阿Q走进村子。", 1, nil, 0)
	if _, ok := injMap(sel)["阿Q"]; !ok {
		t.Error("阿Q did not fire standalone")
	}
}

func TestSingleKeyBanAndAllowShort(t *testing.T) {
	banned := gl("炎", "пламя-как-имя", "", "approved") // single Han → banned by default
	b := bankFrom([]store.GlossaryEntry{banned})
	if sel := b.Select("炎势冲天。", 1, nil, 0); len(sel.injected) != 0 {
		t.Error("single-char key fired despite the A3 ban")
	}
	// allow_short escape hatch lets it fire.
	banned.AllowShort = true
	b = bankFrom([]store.GlossaryEntry{banned})
	if sel := b.Select("炎势冲天。", 1, nil, 0); len(sel.injected) != 1 {
		t.Error("allow_short did not re-enable the single-char key")
	}
}

func TestBudgetEviction(t *testing.T) {
	entries := []store.GlossaryEntry{
		gl("甲甲", "Альфа", "", "approved"),
		gl("乙乙", "Бета", "", "auto"),   // ambiguous → lower priority
		gl("丙丙", "Гамма", "", "approved"),
	}
	b := bankFrom(entries)
	chunk := "甲甲、乙乙、丙丙都在。"
	// Unbounded: all three fire, in priority order (the two approved before the auto).
	all := b.Select(chunk, 1, nil, 0)
	if len(all.injected) != 3 {
		t.Fatalf("unbounded: want 3 injected, got %d", len(all.injected))
	}
	// A token budget that fits exactly the top TWO priority records (computed with the
	// same cost helper Select uses, so the test never drifts from the implementation).
	budget := glossaryLineTokens(all.injected[0].entry) + glossaryLineTokens(all.injected[1].entry)
	sel := b.Select(chunk, 1, nil, budget)
	if len(sel.injected) != 2 || len(sel.evicted) != 1 {
		t.Fatalf("budget: injected=%d evicted=%d (budget=%d)", len(sel.injected), len(sel.evicted), budget)
	}
	// The auto (ambiguous) term is the one evicted (confirmed>ambiguous priority, F2).
	if sel.evicted[0].entry.src != "乙乙" {
		t.Errorf("eviction priority wrong: evicted %s (want the auto term 乙乙)", sel.evicted[0].entry.src)
	}
}

// TestSelectDeterminism runs Select many times over a multi-hit chunk and asserts the
// injected order is byte-identical — guards against a map-iteration leak (the
// determinism invariant: the injected bytes enter request_hash).
func TestSelectDeterminism(t *testing.T) {
	b := bankFrom(specGlossary())
	chunk := "阿Q走进鲁镇，遇见鲁四老爷，谈起修为。"
	first := selKey(b.Select(chunk, 1, nil, 0))
	for i := 0; i < 200; i++ {
		if got := selKey(b.Select(chunk, 1, nil, 0)); got != first {
			t.Fatalf("non-deterministic Select: run %d gave %q, first was %q", i, got, first)
		}
	}
}

func selKey(sel memorySelection) string {
	s := ""
	for _, p := range sel.injected {
		s += p.entry.src + ":" + string(p.disp) + "|"
	}
	return s
}

// TestEmptyDstNotMatchable covers self-review #1: a ruby candidate (dst="") must not be
// matchable — it renders nothing, so admitting it would waste budget, evict a real line,
// and inflate the exact-hit count.
func TestEmptyDstNotMatchable(t *testing.T) {
	phantom := gl("甲甲", "", "", "auto") // ruby-style candidate, no dst
	real := gl("乙乙", "Настоящий", "", "auto")
	b := bankFrom([]store.GlossaryEntry{phantom, real})
	// Tiny budget that would fit exactly one line: the phantom must not steal it.
	budget := glossaryLineTokens(&memoryEntry{src: "乙乙", dst: "Настоящий"})
	sel := b.Select("甲甲和乙乙都在场。", 1, nil, budget)
	if len(sel.injected) != 1 || sel.injected[0].entry.src != "乙乙" {
		t.Fatalf("empty-dst phantom polluted selection: injected=%v", injMap(sel))
	}
	if renderGlossaryBlock(sel.injected) == "" {
		t.Error("a renderable line was evicted by an empty-dst phantom")
	}
	// Unbounded: the phantom never appears as an exact hit.
	sel = b.Select("甲甲和乙乙都在场。", 1, nil, 0)
	if _, ok := injMap(sel)["甲甲"]; ok {
		t.Error("empty-dst phantom counted as an exact hit")
	}
}

// TestSpoilerBeforeSuppression covers self-review #6: a spoiler-blocked LONGER key must
// not suppress a valid SHORTER name nested inside it.
func TestSpoilerBeforeSuppression(t *testing.T) {
	short := gl("林动", "Линь Дун", "", "approved")
	long := gl("林动的父亲", "отец Линь Дуна", "", "approved")
	long.UntilCh = 3 // a spoiler: valid only through chapter 3
	b := bankFrom([]store.GlossaryEntry{short, long})

	// Chapter 10: the longer key is spoiler-blocked; the shorter valid name must survive.
	sel := b.Select("林动的父亲缓缓转身。", 10, nil, 0)
	if _, ok := injMap(sel)["林动"]; !ok {
		t.Errorf("valid shorter name dropped by a spoiler-blocked longer key: %v", injMap(sel))
	}
	if _, ok := injMap(sel)["林动的父亲"]; ok {
		t.Error("spoiler-blocked longer key was injected")
	}
	if len(sel.rejected) != 1 || sel.rejected[0].entry.src != "林动的父亲" {
		t.Errorf("spoiler-blocked longer key not logged as rejected: %+v", sel.rejected)
	}
	// Chapter 2 (longer key in window): longest-match still prefers the longer entity.
	sel = b.Select("林动的父亲缓缓转身。", 2, nil, 0)
	if _, ok := injMap(sel)["林动"]; ok {
		t.Error("longest-match failed when the longer key is spoiler-valid")
	}
	if _, ok := injMap(sel)["林动的父亲"]; !ok {
		t.Error("spoiler-valid longer key was not injected at ch2")
	}
}

// TestBudgetFloorFree covers self-review #7: the per-line budget cost is floor-free, so a
// block of short name lines is not ~1.85×-overcounted and needlessly evicted.
func TestBudgetFloorFree(t *testing.T) {
	var entries []store.GlossaryEntry
	srcs := []string{"甲甲", "乙乙", "丙丙", "丁丁", "戊戊", "己己", "庚庚", "辛辛"}
	dsts := []string{"Ко", "Оцу", "Хэй", "Тэй", "Бо", "Ки", "Ко2", "Син"}
	for i := range srcs {
		entries = append(entries, gl(srcs[i], dsts[i], "", "approved"))
	}
	b := bankFrom(entries)
	chunk := strings.Join(srcs, "、") + "都在场。"
	all := b.Select(chunk, 1, nil, 0)
	if len(all.injected) != 8 {
		t.Fatalf("want 8 injected, got %d", len(all.injected))
	}
	// A budget equal to the EXACT sum of the (floor-free) line costs fits all 8.
	budget := 0
	for _, p := range all.injected {
		budget += glossaryLineTokens(p.entry)
	}
	sel := b.Select(chunk, 1, nil, budget)
	if len(sel.injected) != 8 || len(sel.evicted) != 0 {
		t.Errorf("floor-free budget=%d should fit all 8: injected=%d evicted=%d", budget, len(sel.injected), len(sel.evicted))
	}
}

// TestPerLanguageMinKeyAndCollisionDisposition covers external-review major #2/#3: a
// purely-phonetic key needs ≥3 chars (a 2-char kana/latin key collides inside ordinary
// words), and a short (≤3) phonetic key injects as AMBIGUOUS even when approved.
func TestPerLanguageMinKeyAndCollisionDisposition(t *testing.T) {
	entries := []store.GlossaryEntry{
		gl("鈴木", "Судзуки", "", "approved"),         // 2 Han → fires, CONFIRMED (ideographic anchor)
		gl("すずき", "Судзуки-х", "s2", "approved"),    // 3 kana → fires but AMBIGUOUS (collision-prone)
		gl("ながいなまえ", "Длинное имя", "", "approved"), // 6 kana → fires, CONFIRMED (long enough)
		gl("リン", "Рин", "", "approved"),              // 2 kana → BANNED (phonetic min 3)
		gl("ai", "ИИ", "", "approved"),                 // 2 latin → BANNED (phonetic min 3)
	}
	b := bankFrom(entries)
	sel := b.Select("鈴木とすずきとながいなまえが会った。", 1, nil, 0)
	m := injMap(sel)
	if m["鈴木"] != memConfirmed {
		t.Errorf("Han key must be CONFIRMED: %v", m)
	}
	if m["すずき"] != memAmbiguous {
		t.Errorf("short phonetic key must be AMBIGUOUS (A2 collision): %v", m)
	}
	if m["ながいなまえ"] != memConfirmed {
		t.Errorf("long phonetic key must be CONFIRMED: %v", m)
	}
	// The collision cases external-review named: a short phonetic key must NOT fire inside
	// an ordinary word (リン in リンゴ/apple, ai in raid).
	if s := b.Select("リンゴを食べた。", 1, nil, 0); len(s.injected) != 0 {
		t.Errorf("2-kana key fired inside リンゴ (collision): %v", injMap(s))
	}
	if s := b.Select("raidアレイを組んだ。", 1, nil, 0); len(s.injected) != 0 {
		t.Errorf("2-latin key fired inside raid (collision): %v", injMap(s))
	}
	// allow_short is the author's explicit override: the short key fires AND as CONFIRMED.
	rin := gl("リン", "Рин", "", "approved")
	rin.AllowShort = true
	b2 := bankFrom([]store.GlossaryEntry{rin})
	if s := b2.Select("リンさんが来た。", 1, nil, 0); injMap(s)["リン"] != memConfirmed {
		t.Errorf("allow_short must let リン fire as CONFIRMED: %v", injMap(s))
	}
}

// TestStickyInheritsDisposition covers D16.2: a collision-prone AMBIGUOUS exact match must
// carry into the next chunk's sticky AS AMBIGUOUS — never silently upgraded to CONFIRMED. A
// status-based recompute would upgrade it (past the post-check, into the editor constraints).
// Reverting the inherit change (recomputing via dispositionFor) makes the carry CONFIRMED here.
func TestStickyInheritsDisposition(t *testing.T) {
	// An approved name whose only key is collision-prone (3 kana): an exact match fires
	// AMBIGUOUS (the A2 collision downgrade), not CONFIRMED, despite the approved status.
	suzuki := gl("すずき", "Судзуки", "", "approved")
	b := bankFrom([]store.GlossaryEntry{suzuki})

	sel1 := b.Select("すずきが来た。", 1, nil, 0)
	if injMap(sel1)["すずき"] != memAmbiguous {
		t.Fatalf("collision-prone approved key must fire AMBIGUOUS: %v", injMap(sel1))
	}
	if len(sel1.activeIDs) != 1 {
		t.Fatalf("want exactly one active id, got %d", len(sel1.activeIDs))
	}
	for _, disp := range sel1.activeIDs {
		if disp != memAmbiguous {
			t.Fatalf("activeIDs must carry the AMBIGUOUS disposition, got %q", disp)
		}
	}

	// Pronominal chunk (no key): sticky carries すずき, and its disposition MUST stay AMBIGUOUS.
	sel2 := b.Select("彼は立ち上がった。", 1, sel1.activeIDs, 0)
	var carried *pickedEntry
	for i := range sel2.injected {
		if sel2.injected[i].entry.src == "すずき" {
			carried = &sel2.injected[i]
		}
	}
	if carried == nil || carried.via != "sticky" {
		t.Fatalf("sticky did not carry すずき: %v", injMap(sel2))
	}
	if carried.disp != memAmbiguous {
		t.Errorf("D16.2: sticky carry upgraded disposition to %q, want AMBIGUOUS", carried.disp)
	}
}

// TestUnionStickyNewestWins covers self-review finding #6: unionSticky merges the sticky
// window so the MOST RECENT firing's disposition wins — an id that fired CONFIRMED via a clean
// key then AMBIGUOUS via a collision alias must carry AMBIGUOUS (no silent upgrade); a revert to
// keep-first would reintroduce the D16.2 upgrade. This exercises the merge that
// TestStickyInheritsDisposition (single-chunk activeIDs) bypasses.
func TestUnionStickyNewestWins(t *testing.T) {
	id := "x\x1f\x1f0\x1f0"
	// Oldest→newest CONFIRMED then AMBIGUOUS → newest (AMBIGUOUS) wins (the anti-upgrade case).
	if got := unionSticky([]map[string]injectionDisposition{{id: memConfirmed}, {id: memAmbiguous}})[id]; got != memAmbiguous {
		t.Errorf("newest firing must win: got %q, want ambiguous", got)
	}
	// Reversed → a re-established CONFIRMED (via a clean key in the newer chunk) wins.
	if got := unionSticky([]map[string]injectionDisposition{{id: memAmbiguous}, {id: memConfirmed}})[id]; got != memConfirmed {
		t.Errorf("a re-established CONFIRMED must win: got %q, want confirmed", got)
	}
	if unionSticky(nil) != nil {
		t.Error("empty window → nil")
	}
}

// TestSourcePhoneticWordBoundary covers D16.3: a spaced-phonetic (Latin/Cyrillic) source key
// fires only as a WHOLE WORD, never inside a longer same-script word ("rose" in "roseanne").
// A cross-script neighbour is a valid boundary. Reverting suppressUnboundedPhonetic fires
// "rose" inside "roseanne" and fails here.
func TestSourcePhoneticWordBoundary(t *testing.T) {
	rose := gl("rose", "Роза", "", "approved") // 4 Latin → CONFIRMED, but boundary-checked
	b := bankFrom([]store.GlossaryEntry{rose})

	// Inside a longer Latin word → suppressed (both a suffix and a prefix collision).
	if s := b.Select("roseanne walked in.", 1, nil, 0); len(s.injected) != 0 {
		t.Errorf("latin key fired inside 'roseanne': %v", injMap(s))
	}
	if s := b.Select("the roses bloomed.", 1, nil, 0); len(s.injected) != 0 {
		t.Errorf("latin key fired inside 'roses': %v", injMap(s))
	}
	// Standalone / at string edges → fires CONFIRMED.
	if injMap(b.Select("a rose bloomed.", 1, nil, 0))["rose"] != memConfirmed {
		t.Error("latin key must fire as a whole word")
	}
	if injMap(b.Select("rose.", 1, nil, 0))["rose"] != memConfirmed {
		t.Error("latin key at string edges must fire")
	}
	// Cross-script neighbour (a Han char in unspaced source) is a valid boundary → fires.
	if injMap(b.Select("我叫rose。", 1, nil, 0))["rose"] != memConfirmed {
		t.Error("latin key abutting a cross-script (Han) neighbour must fire")
	}
}

// TestKanaNotSourceBoundaryChecked documents the D16.3 kana carve-out: kana has no word
// segmentation, so a kana key is NOT boundary-checked — it fires between kana neighbours (a
// name+particle case すずきは), matching the long-key-CONFIRMED contract. The ≥4-kana compound
// precision is deferred to the kana-precision measurement + B6 tokenizer (flagged).
func TestKanaNotSourceBoundaryChecked(t *testing.T) {
	// A long kana key must still fire between kana neighbours (と…が), unaffected by the
	// source boundary check (this mirrors the existing long-phonetic-CONFIRMED expectation).
	name := gl("ながいなまえ", "Длинное имя", "", "approved")
	b := bankFrom([]store.GlossaryEntry{name})
	if injMap(b.Select("私とながいなまえが会った。", 1, nil, 0))["ながいなまえ"] != memConfirmed {
		t.Error("kana key must NOT be source-boundary-suppressed (name+particle recall)")
	}
}

// TestOverlappingNonNestedKeysBothFire documents the eval-review low finding: two keys
// that OVERLAP without nesting both fire (both surfaces are genuinely present in the
// text). Accepted behavior (precision-side), asserted so a future change is deliberate.
func TestOverlappingNonNestedKeysBothFire(t *testing.T) {
	entries := []store.GlossaryEntry{
		gl("甲乙", "Пара-А", "", "approved"),
		gl("乙丙", "Пара-Б", "", "approved"),
	}
	b := bankFrom(entries)
	sel := b.Select("甲乙丙都在。", 1, nil, 0) // 甲乙 at [0,2), 乙丙 at [1,3) — overlap, neither contains the other
	m := injMap(sel)
	if m["甲乙"] != memConfirmed || m["乙丙"] != memConfirmed {
		t.Errorf("overlapping non-nested keys should both fire: %v", m)
	}
}

func TestInjectivityCollision(t *testing.T) {
	entries := []store.GlossaryEntry{
		{BookID: "b", Src: "甲", Dst: "Мастер", Status: "approved"},
		{BookID: "b", Src: "乙", Dst: "Мастер", Status: "approved"}, // same dst → collision (B2)
		{BookID: "b", Src: "丙", Dst: "Ученик", Status: "approved"},
	}
	cols := injectivityCollisions(entries)
	if len(cols) != 1 {
		t.Fatalf("want 1 dst collision, got %d: %v", len(cols), cols)
	}
}

// TestRenderEditorConstraintBlock covers D1: the monolingual editor's injection is the
// CONFIRMED dst forms ONLY (target constraints), deduped, with no source or "src → dst".
// Reverting the CONFIRMED-only guard leaks an AMBIGUOUS candidate and fails here.
func TestRenderEditorConstraintBlock(t *testing.T) {
	suzuki := &memoryEntry{src: "鈴木", dst: "Судзуки", status: "approved"}
	cand := &memoryEntry{src: "小D", dst: "Малыш Дэ", status: "auto"} // AMBIGUOUS
	tanaka := &memoryEntry{src: "田中", dst: "Танака", status: "approved"}
	tanakaKana := &memoryEntry{src: "たなか", dst: "Танака", status: "approved"} // same dst → deduped
	sel := []pickedEntry{
		{entry: suzuki, via: "鈴木", disp: memConfirmed},
		{entry: cand, via: "小d", disp: memAmbiguous},
		{entry: tanaka, via: "田中", disp: memConfirmed},
		{entry: tanakaKana, via: "たなか", disp: memConfirmed},
	}
	block := renderEditorConstraintBlock(sel)

	if !strings.Contains(block, "«Судзуки»") || !strings.Contains(block, "«Танака»") {
		t.Errorf("confirmed dst forms missing: %q", block)
	}
	// AMBIGUOUS excluded — the editor must not be forced toward an unverified rendering.
	if strings.Contains(block, "Малыш Дэ") {
		t.Errorf("AMBIGUOUS candidate leaked into the editor constraints: %q", block)
	}
	// dst-only: no source key, no arrow (the editor is monolingual).
	if strings.Contains(block, "→") || strings.Contains(block, "鈴木") || strings.Contains(block, "ГЛОССАРИЙ") {
		t.Errorf("editor block must be target-only, no src→dst: %q", block)
	}
	// Deduped by dst (Танака appears once despite two source keys).
	if n := strings.Count(block, "«Танака»"); n != 1 {
		t.Errorf("dst not deduped: «Танака» appears %d times: %q", n, block)
	}
	// An all-AMBIGUOUS (or empty) selection yields no block at all.
	if got := renderEditorConstraintBlock([]pickedEntry{{entry: cand, disp: memAmbiguous}}); got != "" {
		t.Errorf("an all-AMBIGUOUS selection must yield no editor block, got %q", got)
	}
}
