"""C(c) 산출 채점 — ★**구조만** 본다. 의미는 사람이 본다.

두 번째 판이다. 첫 판 채점기를 Codex 가 실제 반례로 태웠더니 **핵심 셋을
전부 통과**시켰다 (2026-08-31). 재현했다 —

    ① 두 축 칸과 등록 장부가 **아예 없어도** passed=True
    ② 두 구간에 걸친 실물에서 **한쪽 구간 출현을 지워도** passed=True
    ③ 한 대상의 인용을 빌려 **전혀 다른 이름의 행**을 지어도 passed=True

까닭은 셋 다 같다. **덮였는지**를 「기대 span **하나라도** 겹치나」로 봤고,
**등록 효과**를 안 봤고, **목록 밖**을 자동 합격으로 뒀다.

## 이 판에서 바꾼 것

    ① 기대 span 을 **전부** 덮었는지 본다 (하나라도 안 덮이면 wrong)
    ② `reduce_episode` 의 **등록 장부**까지 받아서, 예외 축 표적이
       `hard AND notice` · `registered` · 사유 · `final_id` 까지 섰는지 본다
    ③ 표적 장부 **밖의 행**은 자동 합격이 아니라 **사람 검토 대상**이다.
       인용이 원문에 있다는 것은 그 인용이 **그 이름을 뒷받침한다**는 뜻이
       아니다 — 남의 인용으로 아무 이름이나 지을 수 있다.

## ★결과는 두 칸이다

`mechanical_candidate` 는 **기계가 본 것**이고, `final_candidate` 는 사람이
보기 전까지 **`None`** 이다. 기계 통과를 최종 통과로 쓰지 않는다.
"""
from __future__ import annotations

from typing import Any, Dict, List, Optional, Sequence

OK, MISSING, WRONG, UNRESOLVED = "ok", "missing", "wrong", "unresolved"

#: 예외 축으로 등록됐을 때 장부에 남는 사유 (`grounding_chunk_merge`).
EXCEPTION_REASON = "grounding_exception"


def _overlaps(a: Dict[str, Any], b: Dict[str, Any]) -> bool:
    return (a.get("segment_id") == b.get("segment_id")
            and int(a.get("start", -1)) < int(b.get("end", -1))
            and int(b.get("start", -1)) < int(a.get("end", -1)))


def _row_spans(r: Dict[str, Any]) -> List[Dict[str, Any]]:
    return [o.get("source_span") or {} for o in (r.get("occurrences") or ())]


def _same(a: Dict[str, Any], b: Dict[str, Any]) -> bool:
    return (a.get("segment_id") == b.get("segment_id")
            and a.get("start") == b.get("start")
            and a.get("end") == b.get("end"))


def _contains(outer: Dict[str, Any], inner: Dict[str, Any]) -> bool:
    return (outer.get("segment_id") == inner.get("segment_id")
            and int(outer.get("start", 1)) <= int(inner.get("start", -1))
            and int(inner.get("end", -1)) <= int(outer.get("end", -1)))


def claims(span: Dict[str, Any], key: str,
           spans_by_key: Dict[str, List[Dict[str, Any]]]) -> Optional[bool]:
    """이 언급 자리가 표적 `key` 를 **주장하나**. ★주소 규칙이다.

    ★앞 판은 「조금이라도 겹치면 주장」이었다. 그래서 「가방 손잡이」가
    「가방」 표적에, 「운전사가 제복 상의를 벗어 의자에 건다」가 「제복 상의」
    표적에, 「벽에 옛 요금표가 붙어 있다」가 「옛 요금표」 표적에 **같이**
    걸렸다 (Codex 2026-08-31).

    ★두 번째 판은 「품으면 True」였는데 그것도 **부분문자열 결속**이다 —
    「낡은 가방」+「가방 손잡이」+4장 「가방」을 한 행으로 합쳐도 통과했다
    (Codex 재현). 그래서 **정확히 같을 때만** 주장으로 센다.

    Returns:
        `True` **정확히 그 자리**다 · `False` 무관하다 ·
        `None` **모호하다** — 품기는 하는데 정확히 같지 않다(더 넓게 인용했다).
        조용히 귀속하지 않고 사람에게 넘긴다.
    """
    if any(_same(span, t) for t in (spans_by_key.get(key) or ())):
        return True
    if any(_contains(span, t) for t in (spans_by_key.get(key) or ())):
        return None
    return False


def rows_covering(target_spans: Sequence[Dict[str, Any]],
                  rows: Sequence[Dict[str, Any]]) -> List[str]:
    """표적을 **조금이라도** 덮는 행. ★사람 검토 목록에만 쓴다 — 채점 주소가
    아니다."""
    return [str(r.get("local_id")) for r in rows
            if any(_overlaps(s, t) for s in _row_spans(r)
                   for t in target_spans)]


def rows_claiming(key: str, spans_by_key: Dict[str, List[Dict[str, Any]]],
                  rows: Sequence[Dict[str, Any]]) -> tuple:
    """표적을 **주장하는** 행과 **모호한** 행. ★채점은 이것으로 한다."""
    hit, amb = [], []
    for r in rows:
        v = [claims(sp, key, spans_by_key) for sp in _row_spans(r)]
        if any(x is True for x in v):
            hit.append(str(r.get("local_id")))
        elif any(x is None for x in v):
            amb.append(str(r.get("local_id")))
    return hit, amb


def _distinct_rows_per_anchor(target_spans: Sequence[Dict[str, Any]],
                              key: str,
                              spans_by_key: Dict[str, List[Dict[str, Any]]],
                              rows: Sequence[Dict[str, Any]]) -> Optional[str]:
    """정본 자리와 살아남은 행이 **일대일**인가. 아니면 사유를 돌려준다.

    ★두 번째 판은 「자리마다 서로 다른 행을 하나씩 찾으면 통과」였다. 그러면
    한 행이 **두 자리를 같이** 주장해도 짝이 지어져 통과한다 — 실제로 그랬다
    (Codex 재현): 1장 표만 든 행 A 와, 1장+4장 표를 **한 행으로 잘못 합친**
    행 B 를 넣으니 1장→A · 4장→B 로 맞춰져 `ok` 가 났다.

    그래서 셋을 다 본다 —
      ① 자리마다 그것을 주장하는 살아남은 행이 **정확히 하나**
      ② 그 행이 이 표적의 자리를 **정확히 하나만** 주장
      ③ 짝지어진 행의 가짓수 == 자리 수
    """
    by_anchor: Dict[int, List[str]] = {}
    for n, t in enumerate(target_spans):
        hit = [str(r.get("local_id")) for r in rows
               if any(_same(sp, t) and claims(sp, key, spans_by_key) is True
                      for sp in _row_spans(r))]
        by_anchor[n] = hit
        if len(hit) != 1:
            return (f"자리 {n} 을 주장하는 행이 {len(hit)}개다 ({hit}) — "
                    "하나여야 한다")

    picked = [v[0] for v in by_anchor.values()]
    if len(set(picked)) != len(target_spans):
        return (f"한 행이 여러 자리를 같이 주장한다 ({picked}) — 서로 다른 "
                "실물을 한 행으로 합친 것이다")

    by_id = {str(r.get("local_id")): r for r in rows}
    for n, lid in enumerate(picked):
        mine = [t for t in target_spans
                if any(_same(sp, t) for sp in _row_spans(by_id[lid]))]
        if len(mine) != 1:
            return (f"행 {lid} 가 이 표적의 자리를 {len(mine)}개 주장한다 — "
                    "하나만이어야 한다")
    return None


def _unclaimed(target_spans: Sequence[Dict[str, Any]], key: str,
               spans_by_key: Dict[str, List[Dict[str, Any]]],
               rows: Sequence[Dict[str, Any]],
               anchors: str = "all") -> List[Dict[str, Any]]:
    """기대 자리 중 **주장된 것이 없는** 것. ★겹침이 아니라 주소로 본다.

    Args:
        anchors: `all` 이면 적힌 자리를 **전부** 불러야 한다(구간을 넘는
            실물이 그렇다 — 한쪽을 잃으면 잡아야 한다). `any` 면 **하나만**
            불러도 된다(같은 씬 안에서 여러 번 나오는 말이 그렇다 — 전부
            부르라고 하면 뜻 없는 요구가 된다).
    """
    ok = [sp for r in rows for sp in _row_spans(r)
          if claims(sp, key, spans_by_key) is True]
    missed = [t for t in target_spans if not any(_same(sp, t) for sp in ok)]
    if anchors == "any":
        return [] if len(missed) < len(target_spans) else list(target_spans)
    return missed


def uncovered_spans(target_spans: Sequence[Dict[str, Any]],
                    rows: Sequence[Dict[str, Any]]) -> List[Dict[str, Any]]:
    """★기대 span 중 **아무 행도 안 덮은 것**.

    앞 판은 이걸 안 봐서, 두 구간에 걸친 실물의 **한쪽 구간을 통째로 잃어도**
    통과했다 — 남은 쪽에 출현이 둘 있으면 「출현 ≥2」가 맞아 버린다.
    """
    all_spans = [s for r in rows for s in _row_spans(r)]
    return [t for t in target_spans
            if not any(_overlaps(s, t) for s in all_spans)]


def span_integrity(rows: Sequence[Dict[str, Any]],
                   segments: Dict[str, str]) -> List[str]:
    """인용이 그 자리에 실제로 있나. ★어긋난 행의 `local_id`."""
    bad = []
    for r in rows:
        for o in (r.get("occurrences") or ()):
            sp = o.get("source_span") or {}
            text = segments.get(str(sp.get("segment_id")))
            a, b = sp.get("start"), sp.get("end")
            if (text is None or not isinstance(a, int) or not isinstance(b, int)
                    or text[a:b] != o.get("source_quote")):
                bad.append(str(r.get("local_id")))
                break
    return bad


def _flags_and_registration(t: Dict[str, Any], cover: Sequence[str],
                            rows: Sequence[Dict[str, Any]],
                            registered: Dict[str, Any]) -> Optional[str]:
    """예외 축 표적의 **모양과 효과**. ★뜻이 옳은지는 사람이 본다.

    자동으로 보는 것은 셋뿐이다 — 두 bool 이 **같은 행에서** 참인가,
    **등록됐나**, 그 사유가 예외 축인가, 그리고 `final_id` 가 섰나.
    """
    if not t.get("exception_axis"):
        return None
    by_id = {str(r.get("local_id")): r for r in rows}
    for lid in cover:
        r = by_id.get(lid) or {}
        if r.get("hard_to_generate") is not True:
            return f"{lid}: `hard_to_generate` 가 True 가 아니다"
        if r.get("viewers_would_notice") is not True:
            return f"{lid}: `viewers_would_notice` 가 True 가 아니다"
        rec = registered.get(lid)
        if not isinstance(rec, dict):
            return f"{lid}: 등록 장부에 없다 — 등록 효과를 못 본다"
        if rec.get("registered") is not True:
            return f"{lid}: 등록이 안 됐다 ({rec.get('reason')!r})"
        if str(rec.get("reason")) != EXCEPTION_REASON:
            return (f"{lid}: 사유가 {rec.get('reason')!r} 다 — 예외 축이 아니라 "
                    "반복 출현으로 살았다. 이 표적은 한 번만 나온다")
        if not rec.get("final_id"):
            return f"{lid}: `final_id` 가 없다"
    return None


def score(targets: Sequence[Dict[str, Any]],
          spans_by_key: Dict[str, List[Dict[str, Any]]],
          rows: Sequence[Dict[str, Any]],
          *,
          segments: Dict[str, str],
          relations: Sequence[Dict[str, Any]] = (),
          registered: Optional[Dict[str, Any]] = None,
          quarantined: Sequence[Dict[str, Any]] = ()) -> Dict[str, Any]:
    """표적 장부 대 산출.

    Args:
        rows: **줄인 뒤**의 행 — `reduce_episode(...)["rows"]`.
        relations: `reduce_episode(...)["part_of"]`.
        registered: `reduce_episode(...)["registered"]`. ★없으면 등록 축을
            **미확정**으로 둔다 — 「안 봤다」를 「맞다」로 세지 않는다.
    """
    findings: List[Dict[str, Any]] = []
    reg = registered if isinstance(registered, dict) else None

    bad_spans = span_integrity(rows, segments)
    findings.append({"axis": "span_integrity",
                     "verdict": OK if not bad_spans else WRONG,
                     "detail": f"인용이 그 자리에 없는 행 {len(bad_spans)}개",
                     "rows": bad_spans})

    # ★★★**한 행이 두 표적을 주장하면 안 된다** (Codex 2026-08-31).
    #  서로 다른 실물을 한 행으로 합친 것이고, 자리마다 정확히 맞아도
    #  행 수만으로는 안 잡힌다 — 실제로 통과했다.
    multi: Dict[str, List[str]] = {}
    for r in rows:
        keys = sorted({k for k in spans_by_key
                       for sp in _row_spans(r)
                       if claims(sp, k, spans_by_key) is True})
        if len(keys) > 1:
            multi[str(r.get("local_id"))] = keys

    claimed: set = set()
    by_key: Dict[str, List[str]] = {}
    for t in targets:
        key = t["key"]
        want = spans_by_key[key]
        cover, ambiguous = rows_claiming(key, spans_by_key, rows)
        by_key[key] = cover
        claimed |= set(cover) | set(ambiguous)
        if ambiguous and not cover:
            findings.append({
                "axis": f"target:{key}", "verdict": UNRESOLVED,
                "detail": (f"언급이 두 표적 이상을 같이 품어 **어느 쪽인지 "
                           f"못 정한다** — {ambiguous}. 사람이 본다"),
                "rows": ambiguous})
            continue
        if not cover:
            findings.append({"axis": f"target:{key}", "verdict": MISSING,
                             "detail": t.get("why", ""), "rows": []})
            continue

        v, why = OK, ""
        merged_in = [lid for lid, ks in multi.items()
                     if key in ks and lid in cover]
        missed = _unclaimed(want, key, spans_by_key, rows,
                            str(t.get("anchors") or "all"))
        if merged_in:
            findings.append({
                "axis": f"target:{key}", "verdict": WRONG,
                "detail": (f"한 행이 여러 표적을 주장한다 — "
                           f"{ {lid: multi[lid] for lid in merged_in} }. "
                           "서로 다른 실물을 한 행으로 합친 것이다"),
                "rows": merged_in})
            continue
        want_n = int(t.get("rows_after_merge", 1))
        got_owners = {str(r.get("owner_type")) for r in rows
                      if str(r.get("local_id")) in cover}
        occ = max((len(r.get("occurrences") or ()) for r in rows
                   if str(r.get("local_id")) in cover), default=0)
        if missed:
            v, why = WRONG, (f"기대 span {len(missed)}/{len(want)}개가 아무 행에도 "
                             f"안 덮였다 — {missed}")
        elif len(cover) != want_n:
            v, why = WRONG, f"행 {len(cover)}개인데 {want_n}개를 기대했다"
        elif want_n > 1 and _distinct_rows_per_anchor(want, key,
                                                      spans_by_key, rows):
            # ★★자리와 행이 **일대일**이어야 한다. 「서로 다른 행을 하나씩
            #  찾으면 통과」로는 한 행이 두 자리를 같이 주장해도 지나간다.
            v = WRONG
            why = _distinct_rows_per_anchor(want, key, spans_by_key, rows)
        elif got_owners != {t["owner"]}:
            v, why = WRONG, f"owner {sorted(got_owners)} — {t['owner']} 를 기대했다"
        elif occ < int(t.get("min_occurrences", 1)):
            v, why = WRONG, f"출현 {occ} — 최소 {t['min_occurrences']} 을 기대했다"
        elif t.get("exception_axis"):
            if reg is None:
                v, why = UNRESOLVED, "등록 장부를 안 받아서 등록 효과를 못 본다"
            else:
                bad = _flags_and_registration(t, cover, rows, reg)
                if bad:
                    v, why = WRONG, bad
        findings.append({"axis": f"target:{key}", "verdict": v,
                         "detail": why or t.get("why", ""), "rows": cover})

    # ── 관계 ──
    part = set(by_key.get("part_whole_part") or [])
    whole = set(by_key.get("part_whole_whole") or [])
    if not part or not whole:
        v, why = UNRESOLVED, "부분/전체 행 자체가 안 나와 관계를 못 본다"
    else:
        hit = [x for x in relations
               if str(x.get("part")) in part and str(x.get("whole")) in whole]
        v = OK if hit else MISSING
        why = "부분⊂전체 관계" + ("" if hit else " 가 안 적혔다")
    findings.append({"axis": "relation:part_of", "verdict": v,
                     "detail": why, "rows": sorted(part | whole)})

    # ── ★★표적 장부 **밖**의 행 ──
    #  앞 판은 이걸 자동 합격으로 뒀다. 그래서 한 대상의 인용을 빌려 **전혀
    #  다른 이름**의 행을 지어도 통과했다. 인용이 원문에 있다는 것은 그
    #  인용이 **그 이름을 뒷받침한다**는 뜻이 **아니다.**
    #  ★그렇다고 「목록 밖 = 결함」도 아니다 — 내가 안 적은 정당한 것이 있다.
    #  그래서 **사람 검토 대상**으로 세운다.
    extra = sorted({str(r.get("local_id")) for r in rows} - claimed)
    findings.append({
        "axis": "unlisted_rows",
        "verdict": OK if not extra else UNRESOLVED,
        "detail": (f"표적 장부 밖 {len(extra)}행 — 근거가 그 이름을 뒷받침하는지는 "
                   "**사람이 본다**. 목록 밖이라고 결함이 아니고, 인용이 있다고 "
                   "맞는 것도 아니다"),
        "rows": extra})

    # ★★격리된 행이 있으면 **미확정**이다. 「인용이 안 맞는 행이 있었다」를
    #  자동으로 지나가게 두면 지어낸 것이 조용히 사라진다 (Codex).
    findings.append({
        "axis": "mention_resolution",
        "verdict": OK if not quarantined else UNRESOLVED,
        "detail": (f"인용이 원문과 안 맞아 **행째로 격리**한 것 "
                   f"{len(quarantined)}개 — 자동 경로에 하나도 안 넣었다. "
                   "사람이 본다"),
        "rows": [str(x.get("local_id")) for x in quarantined],
    })

    counts = {k: sum(1 for f in findings if f["verdict"] == k)
              for k in (OK, MISSING, WRONG, UNRESOLVED)}
    mech = (counts[MISSING] == 0 and counts[WRONG] == 0
            and counts[UNRESOLVED] == 0)
    return {
        "findings": findings, "counts": counts,
        # ★기계가 본 것과 최종 판정을 **갈라 둔다**. 사람이 보기 전에는
        #  최종이 없다 — 「기계 통과」를 「통과」로 쓰면 §2-5·§2-6 규칙을 어긴다.
        "mechanical_candidate": mech,
        "final_candidate": None,
        "needs_human": [f["axis"] for f in findings
                        if f["verdict"] == UNRESOLVED],
    }
