package pipeline

import (
	"os"
	"path/filepath"
	"testing"

	"textmachine/backend/internal/store"
)

func writeSeed(t *testing.T, content string) string {
	t.Helper()
	p := filepath.Join(t.TempDir(), "glossary.yaml")
	if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
		t.Fatal(err)
	}
	return p
}

func TestLoadGlossarySeed(t *testing.T) {
	seed := writeSeed(t, `
terms:
  - src: 阿Q
    dst: А-кью
    type: name
    decl: { invariant: true, forms: ["А-кью"] }
    aliases:
      - { alias: 阿桂, type: прозвище }
  - src: 王胡
    dst: Бородатый Ван
    status: draft
    decl: { forms: ["Бородатый Ван", "Бородатого Вана"] }
`)
	entries, err := loadGlossarySeed(seed)
	if err != nil {
		t.Fatal(err)
	}
	if len(entries) != 2 {
		t.Fatalf("want 2 entries, got %d", len(entries))
	}
	// Empty status defaults to approved (a manual seed is curated).
	if entries[0].Status != "approved" {
		t.Errorf("default status = %q, want approved", entries[0].Status)
	}
	if entries[0].Source != "seed" {
		t.Errorf("source = %q, want seed", entries[0].Source)
	}
	if len(entries[0].Aliases) != 1 || entries[0].Aliases[0].Alias != "阿桂" {
		t.Errorf("alias not parsed: %+v", entries[0].Aliases)
	}
	if entries[0].Decl == "" {
		t.Error("decl JSON not marshaled")
	}
	if entries[1].Status != "draft" {
		t.Errorf("explicit status not honored: %q", entries[1].Status)
	}
}

func TestLoadGlossarySeedRejectsBad(t *testing.T) {
	// Missing src → fail loud (a silently-dropped term is the A-class hole).
	if _, err := loadGlossarySeed(writeSeed(t, "terms:\n  - dst: без ключа\n")); err == nil {
		t.Error("expected error for a term without src")
	}
	// Unknown status → fail loud.
	if _, err := loadGlossarySeed(writeSeed(t, "terms:\n  - src: 甲\n    status: bogus\n")); err == nil {
		t.Error("expected error for an unknown status")
	}
}

// TestSeedAliasDedupAndDuplicateTerm covers self-review #5: a duplicate alias within a
// term must be deduped (not crash the run on UNIQUE), and a duplicate (src,sense,window)
// term must fail loud (not crash mid-run).
func TestSeedAliasDedupAndDuplicateTerm(t *testing.T) {
	// Duplicate alias surface (annotated with two types) → deduped, no error.
	entries, err := loadGlossarySeed(writeSeed(t, `
terms:
  - src: 四叔
    dst: Четвёртый дядюшка
    aliases:
      - { alias: 鲁四老爷, type: имя }
      - { alias: 鲁四老爷, type: титул }
`))
	if err != nil {
		t.Fatalf("duplicate alias should be deduped, not error: %v", err)
	}
	if len(entries) != 1 || len(entries[0].Aliases) != 1 {
		t.Fatalf("alias not deduped: %+v", entries)
	}
	// Duplicate (src, sense, window) → fail loud.
	if _, err := loadGlossarySeed(writeSeed(t, `
terms:
  - { src: 道, dst: путь }
  - { src: 道, dst: дао }
`)); err == nil {
		t.Error("duplicate (src,sense,window) term must fail loud")
	}
	// Same src, different SENSE on a SINGLE-CHAR Han src (道): the src is A3-banned
	// (significantLen 1 < min-key 2), so it never enters the automaton → its polysemy is
	// inert and cannot livelock → still allowed (D16.1 fail-loud is scoped to a matchable src).
	if _, err := loadGlossarySeed(writeSeed(t, `
terms:
  - { src: 道, dst: путь, sense: way }
  - { src: 道, dst: дао, sense: dao }
`)); err != nil {
		t.Errorf("polysemy on a single-key-banned src is inert and must stay allowed: %v", err)
	}
}

// TestSeedPolysemyOverlapFailsLoud covers D16.1: two approved rows with the same MATCHABLE
// src, DIFFERENT senses, DIFFERENT dst and OVERLAPPING spoiler windows both inject as
// CONFIRMED (the matcher keys only on src) and the post-check is guaranteed to miss one —
// with the gate ON a deterministic chunk livelock. Fail loud. Reverting the different-sense
// branch in loadGlossarySeed passes the contradiction through and fails here.
func TestSeedPolysemyOverlapFailsLoud(t *testing.T) {
	// Matchable src (神通, 2 Han ≥ min-key), different senses, default (overlapping) windows,
	// different dst → livelock → fail loud.
	if _, err := loadGlossarySeed(writeSeed(t, `
terms:
  - { src: 神通, dst: сила, sense: power }
  - { src: 神通, dst: чудо, sense: miracle }
`)); err == nil {
		t.Error("D16.1: polysemy on a matchable src with overlapping windows must fail loud")
	}
	// Same src+senses but NON-overlapping windows is a legitimate spoiler-boundary rendering
	// change (only one sense can fire per chapter) → allowed.
	if _, err := loadGlossarySeed(writeSeed(t, `
terms:
  - { src: 神通, dst: сила, sense: power, until_ch: 5 }
  - { src: 神通, dst: чудо, sense: miracle, since_ch: 6 }
`)); err != nil {
		t.Errorf("polysemy with non-overlapping windows should be allowed: %v", err)
	}
	// Same MATCHABLE src+sense with the SAME dst and overlapping windows → NOT a contradiction
	// (both render identically), allowed.
	if _, err := loadGlossarySeed(writeSeed(t, `
terms:
  - { src: 神通, dst: сила, sense: power, until_ch: 5 }
  - { src: 神通, dst: сила, sense: might, since_ch: 3 }
`)); err != nil {
		t.Errorf("same-dst overlap is not a contradiction: %v", err)
	}
}

// TestSeedApprovedEmptyDstFailsLoud covers external-review minor #5.
func TestSeedApprovedEmptyDstFailsLoud(t *testing.T) {
	for _, status := range []string{"approved", "draft"} {
		if _, err := loadGlossarySeed(writeSeed(t, "terms:\n  - { src: 甲, status: "+status+" }\n")); err == nil {
			t.Errorf("a %s term with an empty dst must fail loud (silently inert otherwise)", status)
		}
	}
	// Only status=auto (a raw candidate — e.g. a ruby reading) may have an empty dst.
	if _, err := loadGlossarySeed(writeSeed(t, "terms:\n  - { src: 甲, status: auto }\n")); err != nil {
		t.Errorf("an auto candidate may have an empty dst: %v", err)
	}
}

// TestSeedOverlappingSpoilerWindowsFailLoud covers external-review minor #4.
func TestSeedOverlappingSpoilerWindowsFailLoud(t *testing.T) {
	// Same src+sense, overlapping windows [0,5] and [3,10], DIFFERENT dst → contradiction.
	if _, err := loadGlossarySeed(writeSeed(t, `
terms:
  - { src: 影卫, dst: страж, until_ch: 5 }
  - { src: 影卫, dst: тень, since_ch: 3, until_ch: 10 }
`)); err == nil {
		t.Error("overlapping spoiler windows with different dst must fail loud")
	}
	// Non-overlapping windows (a rendering that changes after a spoiler boundary) → allowed.
	if _, err := loadGlossarySeed(writeSeed(t, `
terms:
  - { src: 影卫, dst: страж, until_ch: 5 }
  - { src: 影卫, dst: предатель, since_ch: 6 }
`)); err != nil {
		t.Errorf("non-overlapping windows should be allowed: %v", err)
	}
}

func TestClassifyRubyReading(t *testing.T) {
	cases := []struct {
		base, reading, want string
	}{
		{"鈴木", "すずき", rubyClassName},        // all-Han base + all-kana reading → name candidate
		{"強敵", "とも", rubyClassName},         // SAME shape (a double-reading!) — deliberately NOT distinguishable offline → still a CANDIDATE, never auto-locked
		{"強敵", "きょうてき", rubyClassName},      // real reading, same class
		{"泣蟲", "泣き虫", rubyClassGloss},       // reading carries kanji → a semantic gloss/double-reading → footnote, not a lock
		{"ゲーム", "gēmu", rubyClassAmbiguous}, // base is kana → not a plain furigana-name
	}
	for _, c := range cases {
		if got := classifyRubyReading(c.base, c.reading); got != c.want {
			t.Errorf("classifyRubyReading(%q,%q) = %q; want %q", c.base, c.reading, got, c.want)
		}
	}
}

func TestRubyToCandidates(t *testing.T) {
	readings := []store.RubyReading{
		{BookID: "b", Base: "鈴木", Reading: "すずき", FirstChapter: 2, Occurrences: 9},
		{BookID: "b", Base: "鈴木", Reading: "スズキ", FirstChapter: 5, Occurrences: 3}, // variant → deduped by base
		{BookID: "b", Base: "田中", Reading: "たなか", FirstChapter: 1, Occurrences: 4},
		{BookID: "b", Base: "図書館", Reading: "としょかん", FirstChapter: 1, Occurrences: 20},
	}
	// 図書館 is owned by the manual seed → its ruby candidate is skipped.
	manual := map[string]bool{"図書館": true}
	cands := rubyToCandidates(readings, manual)

	if len(cands) != 2 {
		t.Fatalf("want 2 candidates (鈴木, 田中; 図書館 skipped), got %d: %+v", len(cands), cands)
	}
	byBase := map[string]store.GlossaryEntry{}
	for _, c := range cands {
		byBase[c.Src] = c
		if c.Status != "auto" || c.Source != "ruby" || c.Dst != "" {
			t.Errorf("ruby candidate must be auto/ruby with no dst: %+v", c)
		}
	}
	// 鈴木 deduped to the DOMINANT reading (すずき, 9 occ > スズキ, 3) with the earliest chapter.
	suzuki := byBase["鈴木"]
	if suzuki.RubyReading != "すずき" {
		t.Errorf("dominant reading = %q, want すずき", suzuki.RubyReading)
	}
	if suzuki.SinceCh != 2 {
		t.Errorf("since_ch = %d, want the earliest (2)", suzuki.SinceCh)
	}
	if suzuki.Confidence != 9 {
		t.Errorf("confidence = %d, want 9 (occurrences of the dominant reading)", suzuki.Confidence)
	}
	if suzuki.RubyClass != rubyClassName {
		t.Errorf("ruby_class = %q, want %q", suzuki.RubyClass, rubyClassName)
	}
	if _, skipped := byBase["図書館"]; skipped {
		t.Error("図書館 should have been skipped (owned by the manual seed)")
	}
}

// TestApprovedSharedKeyCollisions covers the Task-6 re-audit finding: a firing key shared by
// two DIFFERENT approved terms with different dst + overlapping windows is the alias
// generalization of the D16.1 polysemy livelock. Reverting approvedSharedKeyCollisions (or its
// seedGlossary wiring) admits the contradictory injection. Legitimate cases (same dst,
// non-overlapping windows) and the same-src case (D16.1's job) must NOT be flagged here.
func TestApprovedSharedKeyCollisions(t *testing.T) {
	al := func(a string) []store.GlossaryAlias {
		return []store.GlossaryAlias{{Alias: a, AliasType: "прозвище"}}
	}
	// Shared alias 老赵 across two different approved terms, different dst, default (overlapping)
	// windows → collision (livelock).
	e1 := store.GlossaryEntry{Src: "赵甲", Dst: "Чжао Цзя", Status: "approved", Aliases: al("老赵")}
	e2 := store.GlossaryEntry{Src: "赵乙", Dst: "Чжао И", Status: "approved", Aliases: al("老赵")}
	if cols := approvedSharedKeyCollisions([]store.GlossaryEntry{e1, e2}); len(cols) == 0 {
		t.Error("shared alias 老赵 across different terms must be flagged (livelock)")
	}
	// Same dst → a legitimate merge (both render identically), no contradiction.
	e2same := e2
	e2same.Dst = "Чжао Цзя"
	if cols := approvedSharedKeyCollisions([]store.GlossaryEntry{e1, e2same}); len(cols) != 0 {
		t.Errorf("same-dst shared surface is a merge, not a collision: %v", cols)
	}
	// Non-overlapping windows → a spoiler-boundary handoff (only one fires per chapter) → allowed.
	e1w, e2w := e1, e2
	e1w.UntilCh, e2w.SinceCh = 5, 6
	if cols := approvedSharedKeyCollisions([]store.GlossaryEntry{e1w, e2w}); len(cols) != 0 {
		t.Errorf("non-overlapping windows are allowed: %v", cols)
	}
	// Self-review #1: a SUB-FLOOR src (道, single-key-banned) that shares an ELIGIBLE alias
	// (大道) between different-dst rows slips loadGlossarySeed's src-scoped D16.1 check, so this
	// guard must catch it via the shared alias key (livelock).
	s1 := store.GlossaryEntry{Src: "道", Dst: "путь", Sense: "way", Status: "approved", Aliases: al("大道")}
	s2 := store.GlossaryEntry{Src: "道", Dst: "дао", Sense: "dao", Status: "approved", Aliases: al("大道")}
	if cols := approvedSharedKeyCollisions([]store.GlossaryEntry{s1, s2}); len(cols) == 0 {
		t.Error("#1: sub-floor src sharing an eligible alias with a different dst must be flagged")
	}
	// The same sub-floor src with DISTINCT aliases is fine (道 does not fire, no shared key).
	d1 := store.GlossaryEntry{Src: "道", Dst: "путь", Sense: "way", Status: "approved", Aliases: al("正道")}
	d2 := store.GlossaryEntry{Src: "道", Dst: "дао", Sense: "dao", Status: "approved", Aliases: al("大道")}
	if cols := approvedSharedKeyCollisions([]store.GlossaryEntry{d1, d2}); len(cols) != 0 {
		t.Errorf("sub-floor src with distinct aliases has no shared firing key: %v", cols)
	}
	// A same-src pair with an ELIGIBLE shared src key IS a real collision in isolation (in the
	// live flow loadGlossarySeed's D16.1 check fails loud first, so no double report).
	p1 := store.GlossaryEntry{Src: "神通", Dst: "сила", Sense: "a", Status: "approved"}
	p2 := store.GlossaryEntry{Src: "神通", Dst: "чудо", Sense: "b", Status: "approved"}
	if cols := approvedSharedKeyCollisions([]store.GlossaryEntry{p1, p2}); len(cols) == 0 {
		t.Error("an eligible shared src key is a real collision here too")
	}
	// An auto term sharing the key is not CONFIRMED, so no authoritative contradiction → allowed.
	a1 := store.GlossaryEntry{Src: "钱甲", Dst: "Цянь А", Status: "approved", Aliases: al("老钱")}
	a2 := store.GlossaryEntry{Src: "钱乙", Dst: "Цянь Б", Status: "auto", Aliases: al("老钱")}
	if cols := approvedSharedKeyCollisions([]store.GlossaryEntry{a1, a2}); len(cols) != 0 {
		t.Errorf("an auto term sharing a key is not a CONFIRMED contradiction: %v", cols)
	}
}

// TestRubyReadingBecomesManualAlias covers D16.4: a manual term's name-shape ruby reading
// (all-Han base + all-kana reading) becomes a matchable ALIAS so the kana spelling of a
// seeded name is found. A kanji-bearing gloss/double-reading is NOT attached (footnote, not
// a surface), and a reading whose base is not a manual term is left to the auto-candidate
// path. Reverting attachRubyAliasesToManual leaves the manual entry alias-less and fails here.
func TestRubyReadingBecomesManualAlias(t *testing.T) {
	entries := []store.GlossaryEntry{
		{BookID: "b", Src: "鈴木", Dst: "Судзуки", Status: "approved", Source: "seed"},
		{BookID: "b", Src: "泣蟲", Dst: "плакса", Status: "approved", Source: "seed"},
	}
	readings := []store.RubyReading{
		{BookID: "b", Base: "鈴木", Reading: "すずき", FirstChapter: 1, Occurrences: 5}, // name-shape → alias
		{BookID: "b", Base: "泣蟲", Reading: "泣き虫", FirstChapter: 1, Occurrences: 2}, // gloss (carries kanji) → NOT an alias
		{BookID: "b", Base: "田中", Reading: "たなか", FirstChapter: 1, Occurrences: 3}, // base not manual → skipped (auto path)
	}
	attachRubyAliasesToManual(entries, readings)

	if !hasAliasSurface(entries[0].Aliases, "すずき") {
		t.Errorf("鈴木 did not gain its kana reading as an alias: %+v", entries[0].Aliases)
	}
	if hasAliasSurface(entries[1].Aliases, "泣き虫") {
		t.Errorf("a kanji-bearing gloss reading must NOT become an alias: %+v", entries[1].Aliases)
	}
	// Idempotent: a second attach must not duplicate the alias.
	attachRubyAliasesToManual(entries, readings)
	n := 0
	for _, a := range entries[0].Aliases {
		if a.Alias == "すずき" {
			n++
		}
	}
	if n != 1 {
		t.Errorf("ruby-alias attach not idempotent: すずき appears %d times", n)
	}
	// End-to-end: the kana spelling now matches through the materialized bank.
	b := materializeMemory(entries, false)
	if _, ok := injMap(b.Select("きのう、すずきに会った。", 1, nil, 0))["鈴木"]; !ok {
		t.Error("kana spelling すずき did not match the manual 鈴木 via the reading alias")
	}
}

// TestRubyHomophoneSkippedNotFailLoud covers the self-review interaction: two homophone seeded
// names (鈴木/鈴樹 both read すずき, different dst) must NOT get the kana alias — the reading is
// genuinely ambiguous between them, and attaching it would make approvedSharedKeyCollisions fail
// the whole book loud on an alias the operator cannot edit out. External-review MAJOR (homophone
// first-wins): the fix skips the reading for BOTH owners symmetrically (previously the
// alphabetically-first base claimed it and only the latecomer was skipped). Both readings are
// skipped+reported (graceful), and the resulting entry set passes the shared-key collision check.
func TestRubyHomophoneSkippedNotFailLoud(t *testing.T) {
	entries := []store.GlossaryEntry{
		{BookID: "b", Src: "鈴木", Dst: "Судзуки", Status: "approved", Source: "seed"},
		{BookID: "b", Src: "鈴樹", Dst: "Судзуки-другой", Status: "approved", Source: "seed"},
	}
	readings := []store.RubyReading{
		{BookID: "b", Base: "鈴木", Reading: "すずき", FirstChapter: 1, Occurrences: 8},
		{BookID: "b", Base: "鈴樹", Reading: "すずき", FirstChapter: 3, Occurrences: 4}, // homophone → collision
	}
	skipped := attachRubyAliasesToManual(entries, readings)
	// BOTH readings are skipped (first-wins fix): neither base may claim a contested reading.
	if len(skipped) != 2 {
		t.Fatalf("want both homophone readings skipped, got %d: %v", len(skipped), skipped)
	}
	// NEITHER term got the kana alias.
	got := 0
	for _, e := range entries {
		if hasAliasSurface(e.Aliases, "すずき") {
			got++
		}
	}
	if got != 0 {
		t.Fatalf("want the contested kana alias on NEITHER term, got %d", got)
	}
	// The resulting set must NOT trip the shared-key collision fail-loud (the whole point).
	if cols := approvedSharedKeyCollisions(entries); len(cols) != 0 {
		t.Errorf("homophone skip must avoid the fail-loud, got collisions: %v", cols)
	}
}

// TestRubyHomophoneNoWrongConfirmedInjection is the sharp mutation-verifier for the external-review
// MAJOR (homophone first-wins, class A2). Two seeded names 高橋/高梁 both read たかはし (4 kana, so
// the key is NOT collision-prone → it would fire CONFIRMED, not AMBIGUOUS). Under the old
// attach-then-block pass the alphabetically-first base won たかはし as an authoritative alias, so a
// kana chunk injected it as a CONFIRMED (and wrong) rendering for that base. The fix skips the
// reading for both, so NEITHER is injected — the kana form is left unmatchable, never wrongly
// authoritative. Reverting the fix (restoring first-wins) makes one of them fire CONFIRMED here.
func TestRubyHomophoneNoWrongConfirmedInjection(t *testing.T) {
	entries := []store.GlossaryEntry{
		{BookID: "b", Src: "高橋", Dst: "Такахаси", Status: "approved", Source: "seed"},
		{BookID: "b", Src: "高梁", Dst: "Такаянаги", Status: "approved", Source: "seed"},
	}
	readings := []store.RubyReading{
		{BookID: "b", Base: "高橋", Reading: "たかはし", FirstChapter: 1, Occurrences: 9},
		{BookID: "b", Base: "高梁", Reading: "たかはし", FirstChapter: 1, Occurrences: 2},
	}
	skipped := attachRubyAliasesToManual(entries, readings)
	if len(skipped) != 2 {
		t.Fatalf("want both 4-kana homophone readings skipped, got %d: %v", len(skipped), skipped)
	}
	if cols := approvedSharedKeyCollisions(entries); len(cols) != 0 {
		t.Errorf("contested reading must not be attached, got collisions: %v", cols)
	}
	// End-to-end: a kana occurrence of たかはし must NOT inject either name (no wrong authoritative
	// rendering). Under first-wins the alphabetically-first base would be present here.
	b := materializeMemory(entries, false)
	inj := injMap(b.Select("たかはしが来た。", 1, nil, 0))
	if d, ok := inj["高橋"]; ok {
		t.Errorf("高橋 wrongly injected (%s) for an ambiguous homophone reading", d)
	}
	if d, ok := inj["高梁"]; ok {
		t.Errorf("高梁 wrongly injected (%s) for an ambiguous homophone reading", d)
	}
}

// TestRubyReadingSharedSameDstAttached locks the review minor (window/dst-eligibility awareness):
// a reading shared by two seeded terms that render the SAME dst is NOT a contradiction (both inject
// identically), and a reading shared across NON-overlapping spoiler windows is a legitimate handoff
// — neither is falsely skipped anymore. The old collides() (dst-only, window-blind) skipped both.
func TestRubyReadingSharedSameDstAttached(t *testing.T) {
	// Same dst (two kanji spellings of one character) — attach to both.
	same := []store.GlossaryEntry{
		{BookID: "b", Src: "斉藤", Dst: "Сайто", Status: "approved", Source: "seed"},
		{BookID: "b", Src: "齊藤", Dst: "Сайто", Status: "approved", Source: "seed"},
	}
	sameReadings := []store.RubyReading{
		{BookID: "b", Base: "斉藤", Reading: "さいとう", FirstChapter: 1, Occurrences: 4},
		{BookID: "b", Base: "齊藤", Reading: "さいとう", FirstChapter: 1, Occurrences: 3},
	}
	if skipped := attachRubyAliasesToManual(same, sameReadings); len(skipped) != 0 {
		t.Fatalf("same-dst shared reading must not be skipped, got: %v", skipped)
	}
	for _, e := range same {
		if !hasAliasSurface(e.Aliases, "さいとう") {
			t.Errorf("same-dst reading not attached to %q: %+v", e.Src, e.Aliases)
		}
	}
	// Different dst but NON-overlapping windows (a spoiler handoff of the same reading) — attach both.
	handoff := []store.GlossaryEntry{
		{BookID: "b", Src: "序盤", Dst: "Раннее", Status: "approved", Source: "seed", SinceCh: 1, UntilCh: 100},
		{BookID: "b", Src: "終盤", Dst: "Позднее", Status: "approved", Source: "seed", SinceCh: 101},
	}
	handoffReadings := []store.RubyReading{
		{BookID: "b", Base: "序盤", Reading: "じょばん", FirstChapter: 1, Occurrences: 5},
		{BookID: "b", Base: "終盤", Reading: "じょばん", FirstChapter: 101, Occurrences: 5},
	}
	if skipped := attachRubyAliasesToManual(handoff, handoffReadings); len(skipped) != 0 {
		t.Fatalf("non-overlapping-window shared reading must not be skipped, got: %v", skipped)
	}
}

// TestSeedSurroundingWhitespaceTrimmed covers the workflow-confirmed seed major: surrounding
// whitespace on a keying surface passed the emptiness check but was stored raw, so the term
// keyed WITH the space and could never match (silently inert), and a trailing-space sense
// disguised a same-sense contradiction. Reverting any of the three trims in loadGlossarySeed
// fails a branch here.
func TestSeedSurroundingWhitespaceTrimmed(t *testing.T) {
	// (1) A leading-space src on an approved term must be trimmed, not stored raw — else the
	// key " 強敵" can never match "強敵" and the approved term renders nothing for the whole book.
	entries, err := loadGlossarySeed(writeSeed(t, "terms:\n  - { src: \" 強敵\", dst: Соперник }\n"))
	if err != nil {
		t.Fatal(err)
	}
	if len(entries) != 1 || entries[0].Src != "強敵" {
		t.Fatalf("src surrounding whitespace not trimmed: %+v", entries)
	}
	// (2) A trailing-space sense must be trimmed so a same-sense contradiction (same src, same
	// sense, same default window, DIFFERENT dst) is caught by the duplicate-key guard — not
	// stored as two distinct-looking rows that both inject a contradictory rendering.
	if _, err := loadGlossarySeed(writeSeed(t, "terms:\n  - { src: 先生, dst: учитель, sense: teacher }\n  - { src: 先生, dst: доктор, sense: \"teacher \" }\n")); err == nil {
		t.Error("trailing-space sense must be trimmed so the duplicate (src,sense,window) fails loud")
	}
	// (3) An alias with surrounding whitespace must be trimmed (an alias is itself a match key).
	al, err := loadGlossarySeed(writeSeed(t, "terms:\n  - src: 甲\n    dst: A\n    aliases:\n      - { alias: \" 乙 \", type: x }\n"))
	if err != nil {
		t.Fatal(err)
	}
	if len(al) != 1 || len(al[0].Aliases) != 1 || al[0].Aliases[0].Alias != "乙" {
		t.Fatalf("alias surrounding whitespace not trimmed: %+v", al)
	}
}
