"""세그먼트를 LLM 에 제시할 때 쓰는 **불투명 키**와 그 왕복 계약.

## 왜 이게 따로 있나 (2026-08-07, 같은 결함 3회)

씬 전체를 한 콜에 넣는 스텝들은 하나같이 이렇게 써 왔다.

    f"## 씬 {segment_index}: {heading}\\n{raw_text}"

그런데 `raw_text` 안에는 **대본 자신의 번호 헤딩**(`5. 실내. …`)이 그대로 들어
있다. 즉 모델 눈에는 "씬 번호"가 두 벌 보이고, 어느 쪽을 되돌려줄지 고정하는
장치가 없었다. 스키마도 `scene_index: {"type": "integer"}` 로 무제약이었다.

실측된 결과는 **회귀가 아니라 실행마다 갈리는 선택**이었다.

* `scene_director` — 7/28 은 세그먼트 인덱스(110/110), 8/04 는 본문 번호(116/113).
  하류가 이 값을 dict 키로 조인해 **255샷 중 151샷의 인물 배정이 어긋났다.**
* `outlook_phase2` — 한 실행은 세그먼트 인덱스, 다음 실행은 본문 번호(1~116,
  114행). 아웃룩이 한 칸 밀려 배정됐다.

그래서 잠금을 한곳에 모은다. 규칙은 셋이다.

1. 블록 머리를 **본문 번호와 글자 모양이 겹칠 수 없는 토큰**으로 준다.
2. 스키마의 키 필드를 그 토큰 **enum + required** 로 잠근다.
   (enum 만 걸고 required 를 빼면 모델이 필드를 통째로 건너뛴다 — 아웃룩에서
   실측했다: 309건 중 13건만 채워 하류가 미배정으로 오인했다.)
3. 반환 뒤 **키 집합이 입력과 정확히 일치**하는지 본다. enum 이 못 막는
   누락·중복이 여기서 걸린다.

★본문은 자르지 않는다. 안에 든 번호 헤딩도 그대로 보낸다 — 잘라내면 모델이
읽을 재료가 줄고, 애초에 문제는 번호가 있다는 것이 아니라 **어느 번호로
답할지 안 정해 준 것**이었다.
"""
from __future__ import annotations

import logging
from typing import Any, Dict, List, Sequence, Tuple

logger = logging.getLogger(__name__)

KEY_FIELD = "segment_key"
_PREFIX = "SEG-"


def segment_keys(count: int) -> List[str]:
    """1-based 순번 기반 불투명 키. 자리수는 최소 3, 개수에 따라 늘어난다."""
    width = max(3, len(str(count)))
    return [f"{_PREFIX}{i:0{width}d}" for i in range(1, count + 1)]


def key_index_map(keys: Sequence[str], segments: Sequence[Dict]) -> Dict[str, int]:
    """키 → 입력 세그먼트의 scene_index. 인덱스가 1..N 이 아니어도 유지된다."""
    out: Dict[str, int] = {}
    for ordinal, (key, seg) in enumerate(zip(keys, segments), start=1):
        si = seg.get("scene_index")
        out[key] = si if isinstance(si, int) else ordinal
    return out


def build_blocks(keys: Sequence[str], segments: Sequence[Dict],
                 fulltext: str = "") -> str:
    """`## <키>: <헤딩>` + 본문 전체."""
    lines = []
    for key, seg in zip(keys, segments):
        text = seg.get("text") or fulltext[
            seg.get("start_char", 0):seg.get("end_char", 0)]
        lines.append(f"## {key}: {seg.get('heading', '')}\n{text}")
    return "\n\n".join(lines)


def pin_key_field(items_def: Dict[str, Any], keys: Sequence[str],
                  legacy_field: str = "scene_index") -> None:
    """항목 스키마의 키 필드를 enum + required 로 잠근다 (제자리 수정).

    `items_def` 는 배열 items 의 object 스키마 — `properties`/`required` 를 가진다.
    """
    props = items_def.setdefault("properties", {})
    props.pop(legacy_field, None)
    props[KEY_FIELD] = {
        "type": "string",
        "enum": list(keys),
        "description": "이 판정이 해당하는 입력 블록의 키 — 블록 머리에 적힌 값 그대로",
    }
    required = items_def.get("required")
    if isinstance(required, list):
        items_def["required"] = [KEY_FIELD] + [
            r for r in required if r not in (legacy_field, KEY_FIELD)]


def key_parity(rows: Sequence[Dict], keys: Sequence[str],
               *, allowed: Sequence[str] | None = None
               ) -> Tuple[List[str], List[str], List[str]]:
    """(누락, 입력에 없는 키, 중복) — 셋 다 비어야 파리티 성립.

    `keys` 는 **회신 의무가 있는** 키, `allowed` 는 **회신해도 되는** 키다.
    기본은 둘이 같다(기존 호출부 무변경).

    둘이 갈리는 경우가 하나 있다 — outlook_phase2 는 씬 전체를 블록으로
    보내지만 **인물이 0명인 씬**(건물 전경·간판 같은 설정샷)에는 배정할
    대상이 없어 행이 없는 것이 정상이다. 2026-08-17 실측: 같은 대본에서
    앞선 주행은 그 씬에도 빈 배정 행을 채워 보내 통과했고, 이번 주행은
    생략해 게이트가 막았다 — 계약이 모델 회신 형태에 걸린 운이었다.
    그 씬 키는 `allowed` 에만 넣어 회신을 허용하되 강제하지 않는다.
    """
    got = [r.get(KEY_FIELD) for r in rows if isinstance(r, dict)]
    expected = set(keys)
    allow = expected if allowed is None else set(allowed)
    return (
        sorted(expected - set(got)),
        sorted({str(k) for k in got if k not in allow}),
        sorted({str(k) for k in got if got.count(k) > 1}),
    )


def parity_ok(rows: Sequence[Dict], keys: Sequence[str], *, step: str,
              allowed: Sequence[str] | None = None) -> bool:
    missing, unexpected, dupes = key_parity(rows, keys, allowed=allowed)
    if missing or unexpected or dupes:
        logger.warning(
            "%s key parity 실패 — 누락=%s 미상=%s 중복=%s (하위 tier 재시도)",
            step, missing[:5], unexpected[:5], dupes[:5])
        return False
    return True


def assert_parity(rows: Sequence[Dict], keys: Sequence[str], *, step: str,
                  allowed: Sequence[str] | None = None) -> None:
    """파리티가 깨지면 실패시킨다 — 조용히 넘기면 하류가 엉뚱하게 조인한다."""
    missing, unexpected, dupes = key_parity(rows, keys, allowed=allowed)
    if not (missing or unexpected or dupes):
        return
    from app.core.errors import AppError

    raise AppError(
        code=f"{step}.key_parity",
        message=(
            f"{step} 출력 키가 입력 세그먼트와 불일치 (key parity violation) — "
            f"입력 {len(keys)}개 / 출력 {len(rows)}개, "
            f"누락={missing} 입력에없음={unexpected} 중복={dupes}"
        ),
        status_code=502,
    )


def map_back(rows: Sequence[Dict], key_to_index: Dict[str, int]) -> List[Dict]:
    """키를 벗기고 `scene_index` 를 달아 준다 — 하류 계약은 그대로 둔다."""
    out: List[Dict] = []
    for r in rows:
        row = dict(r)
        row["scene_index"] = key_to_index[row.pop(KEY_FIELD)]
        out.append(row)
    out.sort(key=lambda r: r["scene_index"])
    return out
