"""세그먼테이션 실험 도구들이 함께 쓰는 것 — 지문과 파일 저장.

## 왜 따로 뺐나

지문 식이 저장하는 쪽(`segmenter_crosscheck`)과 읽는 쪽
(`build_segmenter_gallery`)에 따로 있으면, 한쪽만 고쳤을 때 **모든 검토가
조용히 "다른 규칙"으로 보이거나 반대로 다 통과한다.** 대조하는 값은 한
군데서 만든다.
"""
from __future__ import annotations

import hashlib
import json
import os
import re
import tempfile
import unicodedata
from pathlib import Path


def norm(s: object) -> str:
    """공백 차이·자모 분리 차이를 지운다 — macOS 파일에서 실제로 갈린다."""
    return re.sub(r"\s+", " ", unicodedata.normalize("NFC", str(s))).strip()


def fingerprint(row: dict, text: str) -> str:
    """검토가 실제로 본 것의 지문 — 규칙·원문·경계 자리를 함께 접는다.

    ★원문을 **문자열로 받는다**. 파일 경로를 받아 여기서 읽으면, 모델 호출이
    끝난 뒤의 파일을 읽게 된다(Codex 재현). 호출은 몇 분 걸리고 그 사이
    정리본이 다시 만들어질 수 있어서, **모델이 본 것과 지문이 가리키는 것이
    어긋난다.** 호출 직전에 읽은 그 문자열을 그대로 넘길 것.

    ★개수로 대조하면 안 된다. 오늘 찾은 결함 넷이 전부 "개수는 맞는데 경계가
    틀린" 것이었다 — 같은 개수로 다른 자리를 잡은 두 규칙을 개수는 못 가른다.

    ★헤딩 문자열만으로도 모자란다(Codex 지적). 같은 문구가 여러 번 나오는
    대본에서 경계가 다른 자리로 옮겨가도 문자열 목록은 똑같다. 그래서 **줄
    번호와 함께** 접는다.

    ★원문도 넣는다. `text_cleanup` 은 같은 원본에서도 다른 정리본을 낸 적이
    있다(실측: 4,078자 차이). 정리본이 바뀌면 같은 규칙도 다른 경계를 찍으므로,
    그때 옛 검토가 현행 판정으로 읽히면 안 된다.
    """
    parts = [row.get("pattern") or "", str(row.get("number_group")),
             hashlib.sha1((text or "").encode("utf-8")).hexdigest()]

    for s in (row.get("segments") or []):
        parts.append(f"{s.get('line')}:{norm(s.get('heading'))}")

    return hashlib.sha1("\n".join(parts).encode("utf-8")).hexdigest()[:16]


def load_json(path: Path) -> list:
    """기존 기록을 읽는다. **모양이 안 맞으면 멈춘다 — 빈 것으로 넘기지 않는다.**

    ★조용히 `[]` 로 내리면 손상된 파일 위에 한 건만 쓰고 앞서 낸 유료 호출
    기록을 통째로 버린다(Codex 지적). 이 프로젝트에는 파일을 날린 이력이
    있어서, 판단이 안 서면 멈추는 쪽이 맞다.

    ★글자만 읽히면 통과시키면 안 된다(Codex 3차 지적, 재현함). `null` 과
    `{}` 는 문법이 맞아 **빈 기록처럼 통과해 덮어쓰고**, 목록 안에 dict 가
    아닌 것이나 `name` 없는 항목이 있으면 **유료 호출을 다 마친 뒤** 병합에서
    터진다. 여기서 쓸 수 있는 모양인지까지 본다.
    """
    if not path.exists():
        return []
    try:
        data = json.loads(path.read_text("utf-8"))
    except (json.JSONDecodeError, OSError) as exc:
        raise SystemExit(_stop(path, f"{type(exc).__name__}: {exc}")) from exc

    if not isinstance(data, list):
        raise SystemExit(_stop(path, f"목록이 아니다 ({type(data).__name__})"))
    check_names(data, why=f"기존 기록 {path.name}")
    return data


def check_names(rows: list, *, why: str) -> None:
    """`name` 이 **키로 쓸 수 있는 문자열**이고 서로 겹치지 않는지 본다.

    ★`str()` 로 감싸 재면 안 된다(Codex 4차 지적, 재현함). `name` 이 목록이면
    `str()` 은 통과시키지만 나중에 dict 키로 쓸 때 터진다 — 유료 호출이 이미
    나간 뒤다.

    ★겹치는 이름은 더 조용하다. 병합은 지나가고, 화면이 이름으로 접을 때
    **뒤에 있는 옛 기록이 새 결과를 덮는다** — 새로 잰 판정이 안 보인다.
    """
    seen: dict = {}
    for i, item in enumerate(rows):
        if not isinstance(item, dict):
            raise SystemExit(_stop_msg(why, f"{i}번째가 dict 가 아니다 "
                                            f"({type(item).__name__})"))
        name = item.get("name")
        if not isinstance(name, str) or not name.strip():
            raise SystemExit(_stop_msg(
                why, f"{i}번째 name 이 쓸 수 있는 글자가 아니다 "
                     f"({type(name).__name__}: {name!r})"))
        if name in seen:
            raise SystemExit(_stop_msg(
                why, f"name 이 겹친다: {name!r} ({seen[name]}번째와 {i}번째)"))
        seen[name] = i


def _stop(path: Path, why: str) -> str:
    return _stop_msg(f"기존 기록 {path}", why)


def _stop_msg(what: str, why: str) -> str:
    return (f"{what} 를 못 쓴다\n"
            f"  {why}\n"
            "  ★덮어쓰면 앞서 낸 호출 기록이 사라진다. 파일을 확인하거나"
            " 옮겨 두고 다시 실행할 것.")


def save_json(path: Path, rows: list) -> None:
    """같은 디렉토리 임시 파일에 쓰고 **원자적으로 바꿔 끼운다.**

    ★곧바로 덮어쓰면 쓰는 도중 멈췄을 때 파일이 잘린다(Codex 지적). 한 건씩
    저장하도록 바꾼 뒤로는 이 경로를 매 건마다 지나므로 창이 그만큼 넓다.
    """
    path.parent.mkdir(parents=True, exist_ok=True)
    fd, tmp = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp")
    try:
        with os.fdopen(fd, "w", encoding="utf-8") as fh:
            json.dump(rows, fh, ensure_ascii=False, indent=2)
            fh.flush()
            os.fsync(fh.fileno())
        os.replace(tmp, path)
    except BaseException:
        Path(tmp).unlink(missing_ok=True)
        raise


def save_text_addressed(path: Path, text: str) -> None:
    """내용으로 이름 지은 파일을 남긴다 — 이미 있으면 **내용이 같은지 확인**한다.

    ★있다고 그냥 믿으면 안 된다(Codex 4차 지적, 재현함). 앞선 실행이 쓰는 중에
    죽어 잘린 파일을 남겼으면, 다음 실행은 그것을 건너뛰고 **맞는 지문을 기록에
    적는다.** 그러면 기록은 멀쩡해 보이는데 근거 파일이 다른 내용이고, 화면도
    그 틀린 본문을 보여준다.

    없으면 임시 파일에 쓰고 fsync 뒤 원자적으로 바꿔 끼운다 — json 쪽만 원자적
    으로 해 놓고 이 곁 파일을 그냥 쓰면 창은 그대로 남는다.
    """
    want = text.encode("utf-8")
    if path.exists():
        got = path.read_bytes()
        if got != want:
            raise SystemExit(_stop_msg(
                f"원문 파일 {path.name}",
                f"이름은 같은데 내용이 다르다 (있는 것 {len(got):,}바이트 / "
                f"쓰려는 것 {len(want):,}바이트) — 앞선 실행이 쓰다 만 파일일 수 있다"))
        return
    path.parent.mkdir(parents=True, exist_ok=True)
    fd, tmp = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp")
    try:
        with os.fdopen(fd, "wb") as fh:
            fh.write(want)
            fh.flush()
            os.fsync(fh.fileno())
        os.replace(tmp, path)
    except BaseException:
        Path(tmp).unlink(missing_ok=True)
        raise


def merge_by_name(path: Path, rows: list) -> tuple[list, int]:
    """새로 잰 것만 갈아 끼우고 나머지는 그대로 둔다 — 덮어쓰기 금지.

    ★`--only` 로 대본 하나만 다시 재고 저장하면 나머지 기록이 통째로 사라진다
    (Codex 지적). 몇 시간치 결과가 조용히 없어지는 것이라, 가드를 부르는 쪽
    습관이 아니라 **코드에** 둔다.
    """
    check_names(rows, why="새로 잰 결과")     # 들어오는 쪽도 같은 계약이어야 한다
    old = load_json(path)
    if not old:
        return list(rows), 0
    fresh = {r["name"]: r for r in rows}
    merged = [fresh.pop(r["name"], r) for r in old]
    # ★그대로 둔 것을 직접 센다. 개수 뺄셈으로 재면 같은 이름이 두 번 들어올 때
    #  어긋난다 — 화면의 "기존 N개 그대로"가 틀리면 기록이 사라진 줄 알거나
    #  반대로 안심하게 된다.
    changed = {r["name"] for r in rows}
    kept = sum(1 for r in old if r["name"] not in changed)
    merged += [r for r in rows if r["name"] in fresh]
    return merged, kept
