#!/usr/bin/env python3
"""exp16 — alias tier-1 clustering (research/20 §B2). miner-v1. $0.

PROP-layer, precision-safe rules over a set of src surfaces + their dominant ru rendering:
  R1 surface containment    X ⊂ Y, |X|>=2  -> 'extension' edge (方源 ⊂ 古月方源)
  R2 surname anchor+compose shared surname  -> FAMILY supercluster edge (NOT identity)
  R3 shared ru rendering    same dominant ru lemma-set -> strong IDENTITY edge (古月方源,方源 -> Фан Юань)
  R4 NEGATIVE constraints (block a merge):
     (i)  same surname + DIFFERENT given names  (方源 vs 方正)                  -> not identity
     (ii) different confirmed gender
     (iii)different approved dst
     (iv) co-presence in one source sentence/dialogue turn                     -> not the same entity
     (v)  title-nesting with different seed dst (族长 ⊂ 四代族长)                -> different entities

Output: identity clusters (R1/R3 minus R4), family superclusters (R2), and tier-2 weak-link proposals.
Precision measured entity-level (B³ style, §A5 — NOT MUC link metric). Recall needs the owner mini-gold
(§E-1): this module also emits a candidate-edge sheet for the ~20-30 min owner touchpoint.
"""
from __future__ import annotations

import json
from collections import defaultdict
from dataclasses import dataclass, field

import exp16_common as X
import spread as SP
import palladius as PAL

SURNAMES = None  # lazy import to avoid cycle


def _surname_of(src_norm: str):
    import patterns as P
    return P.is_surname_start(src_norm)


@dataclass
class Surface:
    src: str                 # normalized src surface
    dst_lemmas: list = field(default_factory=list)   # dominant ru rendering lemma(s)
    gender: str = ""
    approved_dst: str = ""
    typ: str = ""


def dominant_dst(sm: SP.SpreadModel, src_norm: str, top=3):
    variants, _ = sm.dst_variants(src_norm, top=top)
    # keep the ru lemmas that look like a NAME rendering (Palladius) OR the very top associates
    return [lm for lm, d, _ in variants]


def cooccur_same_sentence(chunks, a_norm: str, b_norm: str) -> bool:
    """R4-iv: do a and b co-occur within one source sentence anywhere?"""
    import regex
    for c in chunks:
        for sent in regex.split(r"[。！？\n]", c.nsource):
            if a_norm in sent and b_norm in sent:
                return True
    return False


def build_surfaces(surface_srcs, sm, gt_by_norm) -> dict[str, Surface]:
    out = {}
    for s in surface_srcs:
        sn = X.norm(s)
        if not sn:
            continue
        ent = gt_by_norm.get(sn)
        out[sn] = Surface(src=sn, dst_lemmas=dominant_dst(sm, sn),
                          gender=(ent.gender if ent else ""),
                          approved_dst=(ent.dst if ent and ent.status == "approved" else ""),
                          typ=(ent.typ if ent else ""))
    return out


def propose_edges(surfaces: dict[str, Surface], chunks, sm, gt_norm_surfaces=frozenset()):
    _SM = sm
    ident, family, weak = [], [], []
    keys = list(surfaces)
    for i in range(len(keys)):
        for j in range(i + 1, len(keys)):
            a, b = surfaces[keys[i]], surfaces[keys[j]]
            # ── R4 HARD BLOCKS (checked first; no exceptions) ──
            # R4-ii different confirmed gender
            if a.gender and b.gender and a.gender != b.gender and "hidden" not in (a.gender, b.gender):
                continue
            # R4-iii/v different approved dst -> different entities, EVEN IF one contains the other
            # (this is the 族长⊂四代族长 lesson D38 §3 and the clan⊂person case 古月⊂古月方源).
            if a.approved_dst and b.approved_dst and X.norm(a.approved_dst) != X.norm(b.approved_dst):
                continue
            sur_a, sur_b = _surname_of(a.src), _surname_of(b.src)
            # R1 containment -> extension edge (a and b are the SAME entity, fuller vs shorter surface).
            # Guard against boundary fragments: the SHORTER surface must itself be a GT surface or a
            # multi-char pattern-typed entity (not a longer±particle artifact).
            if len(a.src) >= 2 and len(b.src) >= 2 and (a.src in b.src or b.src in a.src) and a.src != b.src:
                short, long_ = (a, b) if len(a.src) < len(b.src) else (b, a)
                short_is_entity = bool(short.typ) or short.src in gt_norm_surfaces
                # COMPOSITIONAL guard: long = short + Y (or Y + short) where Y is ANOTHER known entity of
                # type title/place/term => long is a PHRASE (古月+族长 = clan-head phrase), not an alias.
                # clan/surname + NAME stays a fullname alias (古月+方源). Blocks the 古月↔族长 chaining.
                rest = long_.src[len(short.src):] if long_.src.startswith(short.src) else long_.src[:-len(short.src)]
                rest_ent = surfaces.get(rest)
                compositional = bool(rest) and rest in gt_norm_surfaces and \
                    (rest_ent is None or rest_ent.typ in ("title", "place", "term"))
                if compositional:
                    weak.append(dict(a=a.src, b=b.src, rule="R1-compositional", kind="phrase_not_alias", part=rest))
                elif short_is_entity:
                    ident.append(dict(a=a.src, b=b.src, rule="R1", kind="extension"))
                else:
                    weak.append(dict(a=a.src, b=b.src, rule="R1-fragment?", kind="containment_weak"))
                continue
            # R2 surname anchor -> family (NOT identity)
            if sur_a and sur_b and sur_a == sur_b and a.src != b.src:
                # R4-i same surname + different given names -> NOT identity, family only
                given_a, given_b = a.src[len(sur_a):], b.src[len(sur_b):]
                if given_a != given_b and given_a and given_b:
                    if cooccur_same_sentence(chunks, a.src, b.src):
                        family.append(dict(a=a.src, b=b.src, rule="R2+R4iv", kind="family_copresent"))
                    else:
                        family.append(dict(a=a.src, b=b.src, rule="R2", kind="family"))
                    continue
            # R3 shared ru rendering -> identity (strong), unless R4-iv co-presence.
            # Palladius tokens must ALSO be capitalized-predominant name lemmas (blocks 'найти'=най+ти FP).
            palla = [lm for lm in a.dst_lemmas[:2] if PAL.is_palladius_token(lm, 2) and _SM.is_name_lemma(lm)]
            pallb = [lm for lm in b.dst_lemmas[:2] if PAL.is_palladius_token(lm, 2) and _SM.is_name_lemma(lm)]
            if palla and pallb and set(palla) & set(pallb):
                if cooccur_same_sentence(chunks, a.src, b.src):
                    weak.append(dict(a=a.src, b=b.src, rule="R3-blockedR4iv", kind="shared_render_copresent"))
                else:
                    ident.append(dict(a=a.src, b=b.src, rule="R3", kind="shared_render", render=list(set(palla) & set(pallb))))
    return ident, family, weak


def cluster(ident_edges, all_surfaces):
    """Union-find over identity edges -> entity clusters."""
    parent = {s: s for s in all_surfaces}
    def find(x):
        while parent[x] != x:
            parent[x] = parent[parent[x]]; x = parent[x]
        return x
    for e in ident_edges:
        if e["a"] in parent and e["b"] in parent:
            parent[find(e["a"])] = find(e["b"])
    clusters = defaultdict(list)
    for s in all_surfaces:
        clusters[find(s)].append(s)
    return [sorted(v) for v in clusters.values() if len(v) > 1]


if __name__ == "__main__":
    chunks = X.load_chunks(); gt = X.load_gt()
    sm = SP.SpreadModel(chunks)
    gt_by_norm = {X.norm(s): e for e in gt for s in e.surfaces}
    # surface set: GT surfaces (measure precision vs the 2 known aliases) + high-conf name candidates
    import arms as A
    ar = A.Arms(chunks, X.Contrast())
    a3 = ar.arm_A3()
    # input = GT surfaces + genuine name/place/title candidates (freq>=5, typed, NOT boundary fragments).
    # Exclude GT-surface+trailing-particle artifacts (方源在/方源心 from the surname window).
    subsumed = ar.va.subsumed
    gt_surf_norm = {X.norm(s) for e in gt for s in e.surfaces}
    PARTICLE = set("的了在是和就也都不心面前后上下今少多大小和与之其")
    def frag(src):
        return len(src) >= 2 and src[:-1] in gt_surf_norm and src[-1] in PARTICLE
    name_cands = [c.src for c in a3[:200]
                  if any(t in c.types for t in ("name", "place", "title"))
                  and c.freq >= 5 and c.src not in subsumed and len(c.src) >= 2 and not frag(c.src)]
    gt_norm_surfaces = frozenset(X.norm(s) for e in gt for s in e.surfaces)
    surface_srcs = list(gt_norm_surfaces | set(name_cands))
    surfaces = build_surfaces(surface_srcs, sm, gt_by_norm)
    ident, family, weak = propose_edges(surfaces, chunks, sm, gt_norm_surfaces)
    clusters = cluster(ident, list(surfaces))
    print(f"surfaces: {len(surfaces)}  identity-edges: {len(ident)}  family-edges: {len(family)}  weak: {len(weak)}")
    print("\n== identity edges (R1/R3) ==")
    for e in ident:
        print(f"  {e['a']} = {e['b']}  [{e['rule']}/{e['kind']}] {e.get('render','')}")
    print("\n== family superclusters (R2, NOT identity) ==")
    for e in family[:20]:
        print(f"  {e['a']} ~ {e['b']}  [{e['rule']}/{e['kind']}]")
    print(f"\n== identity clusters (>1) ==")
    for cl in clusters:
        print(f"  {{ {', '.join(cl)} }}")
    # precision vs known seed aliases (古月方源=方源, 古月方正=方正)
    known = {frozenset([X.norm("古月方源"), X.norm("方源")]), frozenset([X.norm("古月方正"), X.norm("方正")])}
    id_pairs = {frozenset([e["a"], e["b"]]) for e in ident if e["kind"] in ("extension", "shared_render")}
    tp = len(id_pairs & known)
    print(f"\nknown-alias recall: {tp}/{len(known)}; identity-edge count {len(id_pairs)} "
          f"(precision vs seed not meaningful — seed alias set is thin, needs owner mini-gold §E-1)")
