#!/usr/bin/env python3
"""Референс-реализация ГОРЯЧЕГО ПУТИ банка памяти + self-tests.

Не продукт — исполняемая СПЕКА механизма (по architecture/06 §Контракт горячего
пути и research/13 Q1/Q3/Q4), по которой бэкенд пишет Go. Проверяет, что сам
МЕХАНИЗМ работает как задумано, без бэкенда и без LLM-вызовов:

  normalize (NFKC + trad→simp + kana-fold)      A4  «тихо-пусто» из-за орфографии
  → multi-pattern точный матч ключей/алиасов      Q3  detерм. > dense на именах
    с запретом одиночных ключей (longest-match)   A3  омографы 灶/炎/气/修
  → спойлер-фильтр since_ch/until_ch (hard)        C1  утечка будущего факта
  → sticky scene-inertia (местоименные чанки)      A5
  → токен-бюджет с приоритетом + лог вытеснения    F2
  → disposition: confirmed / ambiguous / reject    A2  трёхстороннее решение
  → post-check: утв. dst (по decl) в выходе?        E1  ловит и утечку, и отравление

Запуск: eval/.venv/bin/python eval/memory_hotpath.py   (печатает PASS/FAIL по кейсам)
Прод-замены помечены [PROD]: OpenCC вместо мини-карты, Aho-Corasick вместо substring,
pymorphy/decl вместо accept-регэкспов.
"""
from __future__ import annotations

import re
import unicodedata
from dataclasses import dataclass, field

# --- [PROD] в бою — OpenCC (opencc: t2s); здесь мини-карта под демо-термины ---
TRAD2SIMP = {"魯": "鲁", "鎮": "镇", "趙": "赵", "蕭": "萧", "煉": "炼", "門": "门", "闆": "板"}


def normalize(s: str) -> str:
    """NFKC (полу/полноширина) + trad→simp + катакана→хирагана. Симметрично к
    ключам и тексту — иначе 魯鎮 ≠ 鲁镇 и запись «тихо не совпадёт» (A4)."""
    s = unicodedata.normalize("NFKC", s)
    s = "".join(TRAD2SIMP.get(c, c) for c in s)
    out = []
    for c in s:
        o = ord(c)
        out.append(chr(o - 0x60) if 0x30A1 <= o <= 0x30F6 else c)  # katakana→hiragana
    return "".join(out).lower()


def cjk_len(s: str) -> int:
    """Длина ключа в «значимых» знаках (CJK + буквы), для запрета одиночных ключей."""
    return len(re.findall(r"[぀-ヿ㐀-鿿豈-﫿A-Za-zЀ-ӿ]", s))


@dataclass
class Entry:
    id: str
    src: str
    dst: str
    accept: str                      # регэксп утв. формы в РУ-выходе ([PROD] через decl/лемматизатор)
    aliases: list = field(default_factory=list)
    status: str = "approved"         # approved | auto | draft
    sense: str = ""
    since_ch: int | None = None      # запись валидна с этой главы (спойлер)
    until_ch: int | None = None      # ... по эту главу
    allow_short: bool = False        # разрешить одиночный ключ (редко; только с guard)

    def keys(self):
        return [self.src] + self.aliases


MIN_KEY = 2  # запрет одиночного Han/буквы как самостоятельного ключа (A3)


@dataclass
class Picked:
    entry: Entry
    via: str            # какой ключ сработал | "sticky"
    disp: str           # confirmed | ambiguous | reject:<reason>


def select(glossary, chunk, chapter, sticky_prev=(), budget=6):
    """Горячий путь: из глоссария выбрать записи для инъекции в ЭТОТ чанк.
    Возвращает (injected, rejected, evicted, active_now)."""
    ntext = normalize(chunk)
    hits, rejected = {}, []
    for e in glossary:
        matched_key = None
        # longest-match: длинные ключи раньше, одиночные — под запретом
        for k in sorted(e.keys(), key=len, reverse=True):
            nk = normalize(k)
            if cjk_len(nk) < MIN_KEY and not e.allow_short:
                continue
            if nk and nk in ntext:
                matched_key = k
                break
        if matched_key is None:
            continue
        # спойлер-окно — ЖЁСТКИЙ reject с логом (C1: safety-гейт, не UX)
        if e.since_ch is not None and chapter < e.since_ch:
            rejected.append(Picked(e, matched_key, f"reject:spoiler(since_ch={e.since_ch})"))
            continue
        if e.until_ch is not None and chapter > e.until_ch:
            rejected.append(Picked(e, matched_key, f"reject:spoiler(until_ch={e.until_ch})"))
            continue
        disp = "confirmed" if e.status == "approved" else "ambiguous"
        hits[e.id] = Picked(e, matched_key, disp)

    # sticky scene-inertia: активные в предыдущем чанке — тянем, если не совпали
    active_ids = set(hits)
    for e in glossary:
        if e.id in sticky_prev and e.id not in hits:
            if e.since_ch is not None and chapter < e.since_ch:
                continue  # спойлер бьёт и sticky
            hits[e.id] = Picked(e, "sticky", "confirmed" if e.status == "approved" else "ambiguous")

    # токен-бюджет с приоритетом: confirmed>ambiguous, exact>sticky, approved>auto
    order = sorted(hits.values(), key=lambda p: (p.disp != "confirmed", p.via == "sticky", p.entry.status != "approved"))
    injected, evicted = order[:budget], order[budget:]
    return injected, rejected, evicted, active_ids


def render_block(injected):
    """Сериализация в промпт (SakuraLLM-стиль); ambiguous помечены «проверить»."""
    lines = []
    for p in injected:
        tag = "" if p.disp == "confirmed" else "  ⟨unverified — проверить⟩"
        lines.append(f"{p.entry.src} → {p.entry.dst}{tag}")
    return "\n".join(lines)


def postcheck(injected, output):
    """E1: для каждой ИНЪЕКТИРОВАННОЙ approved-записи — есть ли её утв. форма в
    выходе? нет → флаг (модель проигнорировала ИЛИ мы инъектили неверное)."""
    flags = []
    for p in injected:
        if p.disp != "confirmed":
            continue
        if not re.search(p.entry.accept, output):
            flags.append((p.entry.id, p.entry.dst, "MISSING"))
    return flags


# ============================ SELF-TESTS ============================

G = [
    Entry("ah_q",   "阿Q",   "А-кью",     r"А[-\s]?[Кк]ью", sense="name"),
    Entry("sishu",  "四叔",  "Четвёртый дядюшка", r"[Дд]ядюшк", aliases=["鲁四老爷"], sense="char"),
    Entry("luzhen", "鲁镇",  "Лучжэнь",   r"Лучжэн"),
    Entry("songzao","送灶",  "проводы бога очага", r"бога очага", sense="festival"),  # НЕ бывает от 灶
    Entry("xiaoyan","萧炎",  "Сяо Янь",   r"Сяо\s?Янь", aliases=["炎哥"], sense="name"),  # НЕ от 炎
    Entry("xiuwei", "修为",  "уровень совершенствования", r"совершенствован", sense="cultivation"),  # НЕ от 修
    Entry("shadow", "影卫",  "Тёмный страж", r"[Тт]ёмн\w+ страж", since_ch=200, sense="spoiler"),  # раскрытие в гл.200
    Entry("newname","小D",   "Малыш Дэ",  r"Малыш\s?Дэ", status="auto"),  # неподтверждённый → ambiguous
]


def check(name, cond, detail=""):
    print(f"  [{'PASS' if cond else 'FAIL'}] {name}" + (f" — {detail}" if detail else ""))
    return cond


def main():
    print("=== Референс горячего пути банка памяти — self-tests ===\n")
    ok = []

    # T1: имена в чанке → отбор + confirmed; алиас 鲁四老爷 тянет 四叔
    inj, rej, ev, _ = select(G, "阿Q走进鲁镇，遇见鲁四老爷。", chapter=1)
    ids = {p.entry.id: p.disp for p in inj}
    ok.append(check("T1 exact+alias select", ids.get("ah_q") == "confirmed" and ids.get("luzhen") == "confirmed"
                    and ids.get("sishu") == "confirmed", f"выбрано: {ids}"))

    # T2: 送...灶 в бытовом смысле — 送灶 НЕ инъектится (биграм 送灶 нет; 灶 одиночный запрещён)
    inj, *_ = select(G, "厨房里，老妈子把饭菜送到灶间去热一热。", chapter=1)
    ok.append(check("T2 wrong-sense 送灶 rejected", "songzao" not in {p.entry.id for p in inj},
                    "омограф не инъектирован (A3)"))

    # T3: 炎=пламя — 萧炎 НЕ инъектится (одиночный 炎 не ключ, 萧炎 отсутствует)
    inj, *_ = select(G, "山火蔓延，炎势冲天，村民连夜逃走。", chapter=1)
    ok.append(check("T3 wrong-sense 炎 rejected", "xiaoyan" not in {p.entry.id for p in inj}))

    # T4: 修=чинить — 修为 НЕ инъектится
    inj, *_ = select(G, "工匠把石桥修好了。", chapter=1)
    ok.append(check("T4 wrong-sense 修 rejected", "xiuwei" not in {p.entry.id for p in inj}))

    # T5: нормализация trad→simp — 魯鎮 совпадает с ключом 鲁镇
    inj, *_ = select(G, "魯鎮的冬天很冷。", chapter=1)  # traditional
    ok.append(check("T5 trad→simp normalization", "luzhen" in {p.entry.id for p in inj},
                    "魯鎮 → 鲁镇 сматчилось (A4)"))

    # T6: местоименный чанк (нет имён) — sticky тянет 四叔 из прошлого чанка
    inj, *_ = select(G, "他慢慢站起来，叹了口气。", chapter=2, sticky_prev={"sishu"})
    picked = {p.entry.id: p.via for p in inj}
    ok.append(check("T6 sticky scene-inertia", picked.get("sishu") == "sticky",
                    f"местоименный чанк: {picked}"))

    # T7: спойлер — 影卫 (since_ch=200) в главе 5 → hard reject
    inj, rej, *_ = select(G, "影卫出现了。", chapter=5)
    ok.append(check("T7 spoiler hard-reject", "shadow" not in {p.entry.id for p in inj}
                    and any(p.entry.id == "shadow" for p in rej),
                    f"reject: {[p.disp for p in rej]}"))
    # ... а в главе 250 та же запись проходит
    inj250, *_ = select(G, "影卫出现了。", chapter=250)
    ok.append(check("T7b spoiler passes after reveal", "shadow" in {p.entry.id for p in inj250}))

    # T8: disposition — неподтверждённый auto-термин 小D помечен ambiguous
    inj, *_ = select(G, "小D也来了。", chapter=1)
    d = {p.entry.id: p.disp for p in inj}
    ok.append(check("T8 auto-term → ambiguous", d.get("newname") == "ambiguous",
                    f"disposition: {d}; render:\n     " + render_block(inj).replace("\n", "\n     ")))

    # T9: post-check ловит утечку — approved 阿Q инъектирован, но в выходе имени НЕТ
    inj, *_ = select(G, "阿Q走进鲁镇。", chapter=1)
    flags_bad = postcheck(inj, "Некто вошёл в Лучжэнь.")   # А-кью пропал
    flags_good = postcheck(inj, "А-кью вошёл в Лучжэнь.")   # всё на месте
    ok.append(check("T9 post-check catches missing dst",
                    any(f[0] == "ah_q" for f in flags_bad) and not flags_good,
                    f"плохой выход флаги={flags_bad}; хороший={flags_good}"))

    # T10: post-check ловит ОТРАВЛЕНИЕ — инъектировали неверный dst, выход ему следует
    poisoned = Picked(Entry("ah_q", "阿Q", "А-кью", r"А[-\s]?[Кк]ью"), "阿Q", "confirmed")
    fl = postcheck([poisoned], "Линь Чун вошёл в деревню.")  # выход использует чужое имя
    ok.append(check("T10 post-check catches poisoning", any(f[2] == "MISSING" for f in fl),
                    "утв. форма отсутствует → флаг (страх владельца, эксп.05 C3)"))

    print(f"\n=== {sum(ok)}/{len(ok)} PASS ===")
    return 0 if all(ok) else 1


if __name__ == "__main__":
    raise SystemExit(main())
