"""시험용 **시대 원고** — 다섯 갈래가 **자연스럽게** 고증 대상이 되는 판.

## 왜 또 짓나

`synthetic_episode` 는 **지어낸 지역**이라 두 가지를 못 한다 —

1. **고증 정확성을 못 잰다.** 세계 사실이 정한 형태가 실제로 없어서 그 사진이
   세상에 없다. 찾아온 참조는 「비슷해 보이는 실제 사물」이지 정본이 아니다.
2. **다섯 갈래가 다 안 지난다.** 사람 생김새는 지역 특유가 없어 `character` 가
   고증 대상이 못 되고, 조건을 맞추려고 축을 붙였더니 그것이 **지어낸 정답**이
   됐다 (2026-08-31 실측).

그리고 저장소의 실제 원고 47편은 **전부 현대 한국**이라, 현대 배경에서는
신발·시계·가방이 「그리기 어려운 것」이 아니어서 `outlook` 이 안 지난다.

★사용자 지시 (2026-08-31): 「억지로 상황을 만들지 말고 필요하면 시나리오
만들어서 해」. 그래서 **다섯 갈래가 자연스럽게 다 지나는 시대**를 고른다.

## 무엇을 골랐나 — **실제 시대·지역**

1960년대 한국. 이 시대는 —

    character      그 일을 하는 사람의 **차림 자체**가 시대 특유다
    outlook        교복·두루마기 같은 옷이 지금과 완전히 다르다
    prop           검정 고무신·됫박 같은 물건이 그 시대에만 있다
    location       이발소·국밥집의 꼴이 지금과 다르다
    location_part  이발소 회전 간판·나무 미닫이문 같은 고정 설비가 특유하다

**다섯 갈래 전부** 「근거 없이 그리면 틀리고, 그 시대를 아는 사람은 안다」가
성립한다. 그리고 **실제로 있던 것**이라 사진이 세상에 있고, 한국 사람이
「이게 그때 것이 맞나」를 **눈으로 판정할 수 있다.**

## ★지키는 것

- **작품 고유명사를 안 쓴다.** 등장하는 것은 **역할어**(학생·이발사·주인)와
  **일반명사**(고무신·됫박·미닫이문)뿐이다. 특정 작품·인물·지명을 안 가리킨다.
- 시대와 지역은 **세계 사실 블록**에만 적는다 — 지문·코드 규칙에 안 넣는다.
- 산출 모양은 **저장 체크포인트와 같다**(`scene_save.segments` ·
  `shot_validator.scenes[].shots[]`).
- 이 파일은 **시험 데이터**다. production 지문에 이 낱말들이 들어가지 않는다.
"""
from __future__ import annotations

from typing import Any, Dict, List, Tuple

#: ★**구조화된 시대 칸** — production 의 `visual_world_rules.era` 와 같은 자리.
#:  시대는 제 칸이 있다. 세계 사실 **전문**에서 숫자를 긁으면 값·번지·수량까지
#:  시대로 읽는다 (Codex BLOCK 2026-08-31).
ERA = "1960년대"
REGION = "대한민국"

#: 세계 사실 — **실제 시대·지역**. 형태 규정은 그 시대에 실제로 그랬던 것만.
WORLD_FACTS = """이 이야기는 1960년대 대한민국의 한 소읍을 배경으로 한다.
아래는 그 시대·지역의 시각 관례이며, 이 원고 안에서는 이것이 정본이다.

거리와 가게
- 길가 가게는 나무틀에 유리를 끼운 미닫이문을 쓴다. 문틀은 칠하지 않은 나무
  그대로이고, 손잡이 자리에 세로로 긴 나무 막대가 덧대어 있다.
- 머리를 깎는 가게 앞에는 붉은색과 푸른색 띠가 비스듬히 감긴 원통 간판을
  세우고, 그것이 천천히 돈다. 이 간판이 그 가게를 알아보는 표식이다.
- 국을 파는 가게는 흙바닥에 나무 상을 낮게 놓고, 상 옆에 긴 나무 걸상을 둔다.
  벽에는 차림과 값을 붓으로 적은 종이를 붙인다.

사람과 입는 것
- 학교에 다니는 아이는 검은색 윗도리에 목을 세운 깃을 달고, 그 깃 안쪽에
  흰 천을 덧대어 두른다. 단추는 한 줄로 다섯 개다. 머리는 짧게 민다.
- 나이 든 남자는 발목까지 오는 긴 겉옷을 입고 앞을 여며 끈으로 묶는다.
  그 위에 챙이 없는 둥근 모자를 쓴다.
- 머리를 깎는 사람은 흰 무명천으로 만든 앞이 트인 겉옷을 걸치고, 소매를
  팔꿈치까지 걷어 올린다.

신는 것과 드는 것
- 신은 검은 고무로 통째 찍어 낸 것을 신는다. 코가 위로 살짝 올라가 있고
  뒤축이 낮다. 바닥에 무늬가 거의 없다.
- 곡식을 되는 그릇은 네모난 나무통이며, 위가 넓고 아래가 좁다. 옆면에 쇠
  띠를 두르고 한쪽 모서리에 손잡이가 없다.

★이 형태들은 그 시대에 실제로 그러했던 것이라, 근거 없이 그리면 지금 것이
섞여 들어가고 그 시대를 아는 사람은 곧바로 알아본다."""

#: 장면 = (제목, 본문 문단들, [(샷 설명, [인물])]).
#: ★손으로 쓴 산문이다. 같은 시각 대상을 수십 번 되풀이하지 않는다.
_SCENES: List[Tuple[str, List[str], List[Tuple[str, List[str]]]]] = [
    (
        "S#1. 이발소 앞 / 낮",
        [
            "골목 어귀에 이발소 회전 간판이 느리게 돈다. 붉은 띠와 푸른 띠가 "
            "번갈아 올라갔다 사라진다. 그 아래로 나무 미닫이문이 반쯤 열려 "
            "있다.",

            "학생이 문 앞에 선다. 검은 교복 윗도리 깃 안쪽에 두른 흰 천이 "
            "때가 타서 누렇다. 손에는 검정 고무신 한 켤레를 들고 있다.",

            "안에서 이발사가 흰 무명 겉옷 소매를 걷어 올린 채 나온다. 학생을 "
            "보고 턱짓으로 안을 가리킨다.",

            "학생은 신발을 문턱 옆에 내려놓고 들어간다. 미닫이문이 나무틀째 "
            "덜컹이며 닫힌다.",
        ],
        [
            ("이발소 회전 간판이 도는 모습", []),
            ("교복 차림의 학생이 문 앞에 서 있다", ["학생"]),
            ("흰 겉옷을 걸친 이발사가 문에서 나온다", ["이발사"]),
        ],
    ),
    (
        "S#2. 국밥집 / 낮",
        [
            "길 건너 국밥집은 흙바닥에 나무 상이 낮게 깔려 있다. 상 옆으로 "
            "긴 나무 걸상이 벽을 따라 놓였다. 벽에 붙은 종이에 붓으로 쓴 "
            "차림과 값이 번져 있다.",

            "긴 겉옷을 입은 노인이 걸상 끝에 앉아 있다. 앞을 여민 끈이 느슨해 "
            "한쪽이 늘어졌고, 챙 없는 둥근 모자는 무릎에 놓여 있다.",

            "주인이 됫박으로 쌀을 되어 자루에 붓는다. 네모난 나무통 옆면의 "
            "쇠 띠가 빛을 받아 한 줄 반짝인다.",

            "노인이 값을 묻고, 주인은 벽에 붙은 종이를 손등으로 가리킨다. "
            "종이 귀퉁이가 말려 올라가 글자 하나가 가려져 있다.",

            "밖에서 회전 간판 도는 소리가 희미하게 들어온다. 노인이 모자를 "
            "집어 쓰고 일어선다.",
        ],
        [
            ("낮은 나무 상과 벽을 따라 놓인 긴 걸상", []),
            ("긴 겉옷 차림의 노인이 걸상에 앉아 있다", ["노인"]),
            ("주인이 됫박으로 쌀을 되고 있다", ["주인"]),
        ],
    ),
    (
        "S#3. 이발소 안 / 낮",
        [
            "학생이 의자에 앉아 있다. 이발사가 흰 겉옷 소매를 다시 한 번 걷고 "
            "가위를 든다. 창으로 든 빛이 바닥에 네모나게 떨어진다.",

            "벽에는 값을 적은 나무판이 걸려 있다. 판은 가로로 길고, 글자를 "
            "붓으로 눌러 썼다. 아래쪽 끝에 못 하나가 반쯤 박힌 채 남아 있다.",

            "학생의 검은 윗도리가 의자 등받이에 걸쳐져 있다. 단추 다섯 개가 "
            "한 줄로 나란하고, 그중 셋째 것만 색이 다르다.",
        ],
        [
            ("이발사가 소매를 걷고 가위를 든다", ["이발사", "학생"]),
            ("벽에 걸린 나무 값판", []),
        ],
    ),
    (
        "S#4. 골목 / 해질녘",
        [
            "학생이 이발소를 나선다. 문턱 옆에 두었던 검정 고무신을 신는다. "
            "코가 위로 살짝 올라간 모양이 흙바닥에 자국을 남긴다.",

            "회전 간판은 아직 돈다. 붉은 띠가 한 바퀴 돌아 다시 올라온다.",

            "노인이 국밥집에서 나와 반대쪽으로 걸어간다. 긴 겉옷 자락이 "
            "바람에 한 번 부풀었다 가라앉는다.",
        ],
        [
            ("학생이 검정 고무신을 신는다", ["학생"]),
            ("해질녘 골목과 도는 회전 간판", []),
        ],
    ),
]

#: ★엔티티 단위 스텝(entity_t2i 등)의 **선언 상한** — 실측 16(인물 4·아웃룩 4·소품 5·장소 1·장소부분 2). 계획표는 이 값을
#:  쓰고, 실제가 이를 넘으면 스텝 문이 provider 앞에서 선다(돈은 안 새고 판은 미완주).
ENTITY_CAP = 20
#: 아웃룩이 생기는 원고인가 — `composite_image_gen`(`if_has_outlooks`)은 runner 가
#: 있어야 답해 계획표가 production 에 못 묻는다. ★선언은 **안전한 방향**으로만
#: 틀릴 수 있다: True 인데 아웃룩이 0이면 runtime 이 not_applicable(0원)로 내리고,
#: False 인데 있으면 계획에 없는 구매가 된다 — 인물이 있는 원고는 True 로 둔다.
HAS_OUTLOOKS = True
#: (인물, 아웃룩) 쌍의 상한 — `composite_image_gen` 단위. ★production 산출
#: (`outlook_phase3` CP 의 아웃룩 수)과 대조하는 문이 있다(넘으면 선다).
OUTLOOK_PAIR_CAP = 8
#: shot_staging 이 정한 (인물, 상태) 변형의 상한 — `character_state_variant` 단위.
#: ★production 술어 `is_immobilized_state` 로 센 수와 대조하는 문이 있다(넘으면 선다).
STATE_VARIANT_CAP = 0
#: ★야외 건물 묶음 상한 (2026-09-03 야외 canary): 원고의 실외 자리는 골목(S#4) 하나이고, 골목에서 보이는 이발소
#:  바깥(회전 간판)이 묶음으로 따로 설 수 있어 둘로 선언한다. 셋 이상이면 문이 선다(래칫).
OUTDOOR_GROUP_CAP = 2
#: ★배경 사슬 상한 (2026-09-03 05:30): 장소 셋(이발소 안·국밥집 안·골목)이니 chain_bg 그룹은 셋을 넘지 않는다 —
#:  production 산출(`background_classify` chain_bg 수)과 대조하는 문이 있다(넘으면 선다).
CHAIN_GROUP_CAP = 3
#: master plan 이 그룹마다 내는 floor plan 은 실내 하나에 한두 장 — 여섯이면 넉넉하다(문이 대조한다).
FLOOR_PLAN_CAP = 6
#: master plan 의 background 는 floor plan 마다 카메라 몇 개 — 열둘이면 넉넉하다(문이 대조한다).
BACKGROUND_CAP = 12

_SEP = "\n\n"


def _bodies() -> List[str]:
    return [_SEP.join(p) for _h, p, _s in _SCENES]


def manuscript() -> str:
    """원고 전문. ★조립 순서는 `segments()` 좌표와 **같은 값**에서 온다."""
    return _SEP.join(f"**{h}**{_SEP}{b}"
                     for (h, _p, _s), b in zip(_SCENES, _bodies()))


def segments() -> List[Dict[str, Any]]:
    """`scene_save.segments` 와 **같은 모양**."""
    out: List[Dict[str, Any]] = []
    cursor = 0
    for i, (body, (heading, _p, _s)) in enumerate(zip(_bodies(), _SCENES), 1):
        out.append({"scene_index": i, "heading": f"**{heading}**",
                    "start_char": cursor, "end_char": cursor + len(body),
                    "length": len(body), "text": body})
        cursor += len(body) + len(_SEP)
    return out


def segment_texts() -> Dict[str, str]:
    return {f"scene-{s['scene_index']}": s["text"] for s in segments()}


def shot_scenes() -> List[Dict[str, Any]]:
    """`shot_validator.scenes` 와 **같은 모양**."""
    out: List[Dict[str, Any]] = []
    for i, (heading, _paras, shots) in enumerate(_SCENES, start=1):
        out.append({
            "scene_index": i, "scene_heading": f"**{heading}**",
            "shots": [{"shot_index": j, "description": d,
                       "based_on_beat": j, "characters": list(chars),
                       "character_ids": []}
                      for j, (d, chars) in enumerate(shots, start=1)],
        })
    return out


def bundles() -> List[List[int]]:
    """구간 나누기. ★**production 함수 한 곳**을 쓴다 — 여기서 다시 안 적는다."""
    from app.modules.pipeline.grounding_chunk_plan import bundle_scenes

    return bundle_scenes([{"idx": s["scene_index"], "length": s["length"]}
                          for s in segments()])


#: 표적 장부. `at` = (씬, 원문의 완전한 이름 덩어리, 몇 번째).
EXPECTED_TARGETS: List[Dict[str, Any]] = [
    {"key": "owner_character", "owner": "character", "anchors": "any",
     "at": [(1, "이발사", 1)], "rows_after_merge": 1, "min_occurrences": 1,
     "why": "다섯 갈래 — 사람. ★그 일을 하는 차림 자체가 시대 특유다"},
    {"key": "owner_outlook", "owner": "outlook", "anchors": "any",
     "at": [(2, "긴 겉옷", 1)], "rows_after_merge": 1, "min_occurrences": 1,
     "why": "다섯 갈래 — 입은 것"},
    {"key": "owner_prop", "owner": "prop", "anchors": "any",
     "at": [(2, "됫박", 1)], "rows_after_merge": 1, "min_occurrences": 1,
     "why": "다섯 갈래 — 물건"},
    {"key": "owner_location", "owner": "location", "anchors": "any",
     "at": [(2, "국밥집", 1)], "rows_after_merge": 1, "min_occurrences": 1,
     "why": "다섯 갈래 — 장소"},
    {"key": "owner_location_part", "owner": "location_part", "anchors": "any",
     "at": [(1, "이발소 회전 간판", 1)], "rows_after_merge": 1,
     "min_occurrences": 1,
     "why": "다섯 갈래 — 장소의 고정 설비"},
    # ── 음성 대조 — **모든 것이 고증 대상은 아니다** ──
    {"key": "plain_prop", "owner": "prop", "anchors": "any",
     "at": [(3, "가위", 1)], "rows_after_merge": 1, "min_occurrences": 1,
     "why": "★음성 — 가위는 그 시대만의 모양이 따로 없다"},
    {"key": "plain_location_part", "owner": "location_part", "anchors": "any",
     "at": [(3, "창", 1)], "rows_after_merge": 1, "min_occurrences": 1,
     "why": "★음성 — 창은 형태 규정이 없다"},
]

#: 두 축과 그 **근거**. ★근거는 세계 사실에 적힌 것에서만 온다.
AXIS_BASIS: Dict[str, Dict[str, Any]] = {
    "owner_character": {
        "hard": True, "notice": True,
        "basis": ("머리를 깎는 사람은 흰 무명 앞트임 겉옷에 소매를 팔꿈치까지 "
                  "걷어 올린 차림이다 — 지금 이발사 복장과 완전히 달라서 "
                  "근거 없이 그리면 현대 것이 섞이고, 그 시대를 아는 사람은 안다"),
    },
    "owner_outlook": {
        "hard": True, "notice": True,
        "basis": ("발목까지 오는 긴 겉옷을 앞을 여며 끈으로 묶고 챙 없는 둥근 "
                  "모자를 쓴다 — 지금 옷과 겹치는 것이 없다"),
    },
    "owner_prop": {
        "hard": True, "notice": True,
        "basis": ("곡식을 되는 네모난 나무통은 위가 넓고 아래가 좁으며 옆면에 "
                  "쇠 띠를 두른다 — 지금은 쓰지 않는 물건이라 근거 없이 그리면 "
                  "모양이 통째로 틀린다"),
    },
    "owner_location": {
        "hard": True, "notice": True,
        "basis": ("국을 파는 가게는 흙바닥에 낮은 나무 상과 벽을 따라 놓인 긴 "
                  "나무 걸상, 붓으로 쓴 차림표가 붙은 벽으로 이뤄진다 — 지금 "
                  "식당의 꼴과 다르다"),
    },
    "owner_location_part": {
        "hard": True, "notice": True,
        "basis": ("붉은 띠와 푸른 띠가 비스듬히 감긴 원통이 도는 간판이 그 "
                  "가게를 알아보는 표식이다 — 형태가 정해져 있어 다르게 그리면 "
                  "곧바로 안다"),
    },
    "plain_prop": {
        "hard": False, "notice": False,
        "basis": "가위는 그 시대만의 모양이 따로 없다 — 세계 사실에 형태 규정이 없다",
    },
    "plain_location_part": {
        "hard": False, "notice": False,
        "basis": "창은 형태 규정이 없다",
    },
}


def acquisition_targets() -> List[str]:
    """참조 획득을 지나야 하는 표적. ★**같은 행에서 hard AND notice**."""
    return sorted(k for k, v in AXIS_BASIS.items()
                  if v.get("hard") and v.get("notice"))


def acquisition_owners() -> set:
    by = {t["key"]: t["owner"] for t in EXPECTED_TARGETS}
    return {by[k] for k in acquisition_targets() if k in by}


#: 장소 신원 — 씬마다 **벌어지는 자리**의 원고 좌표(씬 · 낱말 · 몇 번째 — 0 이면 그 씬의 모든 자리). ★검사기
#: (`location_identity_check`)는 이 좌표가 가리키는 span 을 production 행의 occurrences 와
#: 겹치는지로만 잇는다 — 낱말을 production 이름과 비교하지 않는다. 씬 1·3 은 같은 업소(같은
#: 낱말) · 씬 2 는 다른 업소 · 씬 4 는 길 — 셋이 서로 다른 ID 여야 한다.
EXPECTED_SCENE_PLACES: Dict[int, Tuple[int, str, int]] = {
    1: (1, "이발소", 0), 2: (2, "국밥집", 0), 3: (3, "이발소", 0), 4: (4, "골목", 0),
}
#: 공간 부분 → 그것이 달린 자리 (둘 다 원고 좌표). 부분의 parent 가 그 자리의 행이어야 한다.
EXPECTED_PART_HOSTS: List[Tuple[Tuple[int, str, int], Tuple[int, str, int]]] = [
    ((1, "미닫이문", 0), (1, "이발소", 0)),
    ((2, "종이", 0), (2, "국밥집", 0)),
]


def span_of(scene: int, word: str, nth: int = 1) -> Dict[str, Any]:
    """그 씬 원문에서 `nth` 번째 자리. ★없으면 **선다**."""
    body = segments()[scene - 1]["text"]
    pos, found = -1, 0
    while found < nth:
        pos = body.find(word, pos + 1)
        if pos < 0:
            raise AssertionError(f"{scene}장에 {word!r} 가 {nth}번째로 없다")
        found += 1
    return {"segment_id": f"scene-{scene}", "start": pos,
            "end": pos + len(word), "quote": word}


def expected_spans(target: Dict[str, Any]) -> List[Dict[str, Any]]:
    return [span_of(sc, w, n) for sc, w, n in target["at"]]


def assert_planted() -> None:
    """심었다고 적은 것이 **원고 안에 실제로 있는지** 본다.

    ★★그리고 **다섯 갈래가 다 고증 대상인지** 본다 — 이 원고를 지은 까닭이
    그것이다. 한 갈래라도 빠지면 「다섯 갈래를 다 봤다」가 거짓이 된다.
    """
    for t in EXPECTED_TARGETS:
        expected_spans(t)                       # 없으면 여기서 선다
        if t["key"] not in AXIS_BASIS:
            raise AssertionError(f"{t['key']} 의 두 축 근거가 없다")
    want = {t["owner"] for t in EXPECTED_TARGETS}
    got = acquisition_owners()
    if want - got:
        raise AssertionError(
            f"고증 대상이 없는 갈래: {sorted(want - got)} — 이 원고는 **다섯 "
            "갈래가 다 지나라고** 지은 것이다")
    if len(want) != 5:
        raise AssertionError(f"갈래가 {sorted(want)} 뿐이다")
    neg = [k for k, v in AXIS_BASIS.items()
           if not (v.get("hard") and v.get("notice"))]
    if not neg:
        raise AssertionError("음성 대조가 없다 — 전부 양성이면 축이 무디다")
