#!/usr/bin/env python3
"""exp16 — bank-mining substrate (research/20 §B1/§D). Deterministic, $0. miner-v1.

Shared layer for the detector arms A1–A3: candidate generation (Han char n-grams 1–6, NO word
segmentation — §B1/Qiu&Zhang), general-zh contrast corpus (jieba dict.txt, versioned artifact),
GT loading + occurrence counting (Aho-Corasick-equivalent WITHOUT suppressContained — D24.2), frequency
strata, the 60/40 tuning/test chapter split, and the catastrophe screen.

Reuses the VALIDATED backend port eval/exp15/mem_select.py for memnorm normalization (byte-faithful to
memnorm.go). Candidate normalization is memnorm-symmetric so candidate keys and GT keys live in the same
space (trad2simp-folded, NFKC, lowercased) — and comparable to jieba's simplified-zh dict.

Self-review by execution: `python exp16_common.py --selftest`.
"""
from __future__ import annotations

import json
import math
import sys
from collections import Counter, defaultdict
from dataclasses import dataclass, field
from pathlib import Path

import regex

HERE = Path(__file__).resolve().parent
# APPEND exp15 (not insert(0)) so exp15's colliding module names (arms.py/chunker.py) do NOT shadow
# exp16's own modules; only unique names like mem_select are resolved from exp15.
sys.path.append(str(HERE.parent / "exp15"))
import mem_select as MS   # validated memnorm port

BOOK = Path("/home/ubuntu/books/gu-zhenren")
SEED = BOOK / "guzhenren-seed-v2.yaml"
RECORDS = BOOK / "rerun" / "records.json"
CONTRAST = HERE / "data" / "jieba_dict_general_zh.txt"   # versioned general-zh word-freq artifact

_RE_HAN = regex.compile(r"\p{Han}")

# ── frozen substrate params (miner-v1); thresholds tuned on the tuning half are set in detectors.py ──
NGRAM_MIN, NGRAM_MAX = 1, 6
FREQ_FLOOR = 3                       # §A1/§B1: below this V-A does not score (conscious blind spot)
CATASTROPHE = ["方源", "蛊", "蛊师", "古月"]   # §D4-в: must be in top-50 of the winner
# 60/40 chapter split (§D1): thresholds chosen on tuning, frozen, only test reported.
TUNING_CH = set(range(1, 16))        # chapters 1–15
TEST_CH = set(range(16, 26))         # chapters 16–25
ANNOTATION_CHUNK = (1, 0)            # ch1/chunk0 = cover blurb + metadata (spoiler-region for blurb terms)


def norm(s: str) -> str:
    return MS.normalize_source_key(s)


def han_only(s: str) -> bool:
    return bool(s) and all(_RE_HAN.match(c) for c in s)


# ── materials ───────────────────────────────────────────────────────────────────────────────────
@dataclass
class Chunk:
    chapter: int
    chunk_idx: int
    source: str          # raw zh
    draft: str           # raw ru draft (deepseek-v4-flash, WITH seed injection — confound §D1)
    nsource: str = ""    # memnorm-normalized source

    @property
    def is_annotation(self) -> bool:
        return (self.chapter, self.chunk_idx) == ANNOTATION_CHUNK


def load_chunks() -> list[Chunk]:
    recs = json.load(open(RECORDS, encoding="utf-8"))
    out = []
    for r in recs:
        c = Chunk(chapter=r["chapter"], chunk_idx=r["chunk_idx"],
                  source=r["source"], draft=r.get("draft", ""))
        c.nsource = norm(c.source)
        out.append(c)
    out.sort(key=lambda c: (c.chapter, c.chunk_idx))
    return out


@dataclass
class GTEntity:
    src: str
    dst: str
    typ: str
    status: str
    since_ch: int
    until_ch: int
    aliases: list = field(default_factory=list)     # alias surface strings
    gender: str = ""

    @property
    def surfaces(self) -> list[str]:
        return [self.src] + list(self.aliases)

    @property
    def norm_surfaces(self) -> list[str]:
        return [norm(s) for s in self.surfaces if norm(s)]


def load_gt() -> list[GTEntity]:
    rows = MS.load_seed_rows(str(SEED))
    out = []
    for r in rows:
        out.append(GTEntity(
            src=r["src"], dst=r.get("dst", "") or "", typ=r.get("type", "") or "",
            status=r.get("status") or "approved",
            since_ch=int(r.get("since_ch", 0) or 0), until_ch=int(r.get("until_ch", 0) or 0),
            aliases=[a["alias"] for a in (r.get("aliases") or []) if a.get("alias")],
            gender=r.get("gender", "") or ""))
    return out


# ── occurrence counting (AC-equivalent, overlapping, NO suppressContained — D24.2) ────────────────
def count_occurrences(needle_norm: str, chunks: list[Chunk], include_annotation: bool = True) -> int:
    """Total overlapping occurrences of a normalized needle across chunk sources."""
    if not needle_norm:
        return 0
    total = 0
    for c in chunks:
        if c.is_annotation and not include_annotation:
            continue
        hay = c.nsource
        pos = 0
        while True:
            i = hay.find(needle_norm, pos)
            if i < 0:
                break
            total += 1
            pos = i + 1
    return total


def gt_occurrences(ent: GTEntity, chunks: list[Chunk], include_annotation: bool = True) -> int:
    """Entity occurrence count = sum over its normalized surfaces (src + aliases), overlapping."""
    return sum(count_occurrences(ns, chunks, include_annotation) for ns in ent.norm_surfaces)


def gt_chapters(ent: GTEntity, chunks: list[Chunk], include_annotation: bool = True) -> set[int]:
    chs = set()
    for c in chunks:
        if c.is_annotation and not include_annotation:
            continue
        if any(ns and ns in c.nsource for ns in ent.norm_surfaces):
            chs.add(c.chapter)
    return chs


def freq_stratum(f: int) -> str:
    if f >= 10:
        return "f>=10"
    if f >= 3:
        return "f3-9"
    return "f<3"


# ── general-zh contrast corpus (jieba dict.txt: 'word freq POS' per line) ──────────────────────────
class Contrast:
    """General-domain zh reference: word frequencies + derived char frequencies. Versioned artifact."""

    def __init__(self, path: Path = CONTRAST):
        self.word_freq: dict[str, int] = {}
        self.char_freq: Counter = Counter()
        total_w = 0
        for line in path.read_text(encoding="utf-8").splitlines():
            parts = line.split(" ")
            if len(parts) < 2:
                continue
            w, fr = parts[0], parts[1]
            try:
                fr = int(fr)
            except ValueError:
                continue
            wn = norm(w)                 # normalize the dict key into the same space as candidates
            if not wn:
                continue
            # keep the max freq if normalization collides (trad/simp variants folding together)
            if wn not in self.word_freq or fr > self.word_freq[wn]:
                self.word_freq[wn] = fr
            total_w += fr
            for ch in wn:
                self.char_freq[ch] += fr
        self.total_word = total_w
        self.total_char = sum(self.char_freq.values())

    def word_rel(self, ngram_norm: str) -> float:
        """P_general(ngram) as a word (0 if OOV)."""
        return self.word_freq.get(ngram_norm, 0) / self.total_word if self.total_word else 0.0

    def char_indep_rel(self, ngram_norm: str) -> float:
        """Expected P_general(ngram) under char-independence (smoothing for OOV multiword; §A1)."""
        p = 1.0
        for ch in ngram_norm:
            pc = (self.char_freq.get(ch, 0) + 1) / (self.total_char + len(self.char_freq))
            p *= pc
        return p

    def char_rarity(self, ngram_norm: str) -> float:
        """Mean surprisal of constituent chars in general zh (rare chars like 蛊/窍 → high)."""
        if not ngram_norm:
            return 0.0
        s = 0.0
        for ch in ngram_norm:
            pc = (self.char_freq.get(ch, 0) + 1) / (self.total_char + len(self.char_freq))
            s += -math.log(pc)
        return s / len(ngram_norm)


# ── candidate generation: Han char n-grams 1–6 over normalized source (§B1) ────────────────────────
def han_runs(ntext: str):
    """Maximal runs of Han characters in normalized text."""
    runs = []
    cur = []
    for ch in ntext:
        if _RE_HAN.match(ch):
            cur.append(ch)
        else:
            if cur:
                runs.append("".join(cur))
                cur = []
    if cur:
        runs.append("".join(cur))
    return runs


def enumerate_candidates(chunks: list[Chunk], nmin=NGRAM_MIN, nmax=NGRAM_MAX) -> Counter:
    """All Han n-grams (length nmin..nmax) with overlapping counts across chunk sources.
    Annotation chunk INCLUDED (blurb terms live there; GT filter handles the blurb rule separately)."""
    cnt = Counter()
    for c in chunks:
        for run in han_runs(c.nsource):
            L = len(run)
            for n in range(nmin, nmax + 1):
                for i in range(0, L - n + 1):
                    cnt[run[i:i + n]] += 1
    return cnt


def candidate_chapters(cand_norm: str, chunks: list[Chunk]) -> set[int]:
    return {c.chapter for c in chunks if cand_norm in c.nsource}


if __name__ == "__main__":
    if "--selftest" not in sys.argv:
        print("usage: python exp16_common.py --selftest"); sys.exit(0)

    chunks = load_chunks()
    gt = load_gt()
    print(f"chunks: {len(chunks)}  (chapters {min(c.chapter for c in chunks)}–{max(c.chapter for c in chunks)})")
    print(f"GT entities: {len(gt)}  aliases: {sum(len(e.aliases) for e in gt)}")

    # GT occurrence filter (§D1): every GT term must have >=1 occurrence. Blurb rule both ways.
    print("\n== GT occurrence filter (incl. vs excl. annotation chunk) ==")
    n_pass_incl = n_pass_excl = 0
    blurb_terms = []
    for e in gt:
        oi = gt_occurrences(e, chunks, include_annotation=True)
        oe = gt_occurrences(e, chunks, include_annotation=False)
        n_pass_incl += oi >= 1
        n_pass_excl += oe >= 1
        if oi >= 1 and oe == 0:
            blurb_terms.append((e.src, e.dst, e.since_ch, oi))
    print(f"  pass (incl annotation): {n_pass_incl}/{len(gt)}")
    print(f"  pass (excl annotation): {n_pass_excl}/{len(gt)}")
    print(f"  annotation-only terms ({len(blurb_terms)}): "
          + ", ".join(f"{s}({dst},since{sc},occ{o})" for s, dst, sc, o in blurb_terms))

    # frequency strata of GT (incl annotation)
    strata = Counter(freq_stratum(gt_occurrences(e, chunks)) for e in gt)
    print(f"\n== GT freq strata (incl annotation): {dict(strata)} ==")

    # candidate space
    cands = enumerate_candidates(chunks)
    above = {k: v for k, v in cands.items() if v >= FREQ_FLOOR}
    print(f"\ncandidates: {len(cands)} distinct Han n-grams; {len(above)} at freq>={FREQ_FLOOR}")

    # catastrophe entities present + their counts
    print("\n== catastrophe screen entities ==")
    for s in CATASTROPHE:
        print(f"  {s}: source-occ={count_occurrences(norm(s), chunks)}  in-candidate-set={norm(s) in cands} "
              f"(freq {cands.get(norm(s),0)})")

    # contrast smoke
    C = Contrast()
    print(f"\ncontrast: {len(C.word_freq)} words, {len(C.char_freq)} chars, total_word={C.total_word}")
    for w in ["方源", "蛊", "蛊师", "古月", "自己", "一个", "时候"]:
        wn = norm(w)
        print(f"  {w:<4} word_rel={C.word_rel(wn):.2e} char_rarity={C.char_rarity(wn):.2f} "
              f"char_indep={C.char_indep_rel(wn):.2e}")
    print("\nselftest OK")
