"""CARRIED 인물 문장이 **정본 표기에 결속**되는가 (#104, 2026-08-29).

## 무엇이 결함이었나 — 실측 S2sh6

저작은 사람마다 `character_ko`·`character_short_id` 를 **typed 로** 주고
이 모듈이 `visible_short_ids` 로 대조까지 하는데, 문장을 만들 때 그 id 를
버리고 `state_en` 만 이어 붙였다:

    objects_en          … The fallen cane remains beside the prone figure.
    people[0] 노인 C03   'He remains on his side … wearing a gray coat …'
    people[1] 민수 C01   'He wears navy coveralls … He kneels …'

앞 문장이 노인을 세워 놓은 뒤 `He` 가 셋 이어져 **둘 다 노인으로** 읽힌다.
그 프롬프트로 그린 재생성본은 민수를 **노인의 회색 코트**로 그렸다.

## 왜 leaf 함수만 재면 부족한가

`carried_text_for_shot` 하나만 초록이면 「나가는 것을 쟀다」가 아니다.
실제 소비 끝점이 **둘**이다(Codex 판정):

    still       `still_recipe_service._carried_clause`
                → `run_still_recipe_generation` 안 두 자리
    conti light `shot_conti_light.py:763` · `shot_conti_light_step.py:1354`

그래서 **두 끝점 각각**에서 잰다. 그리고 누가 leaf 를 직접 불러 우회하면
계약이 한쪽만 산다 — 그것은 **AST** 로 따로 본다.
"""
from __future__ import annotations

import ast
import pathlib

import pytest

from app.modules.pipeline.shot_continuity_author import (
    CARRIED_PERSON_PROJECTION_VERSION,
    carried_clause_for,
    carried_text_for_shot,
)

APP = pathlib.Path(__file__).resolve().parents[2] / "app"

#: 저작이 **둘 다 `He`** 로 쓴 실물 모양 (S2sh6 그대로).
ROW_TWO_MEN = {
    "carried_contract": "scoped_v1",
    "objects_en": "The fallen cane remains beside the prone figure.",
    "people": [
        {"character_ko": "노인", "character_short_id": "C03",
         "state_en": "He remains on his side, wearing a gray coat."},
        {"character_ko": "민수", "character_short_id": "C01",
         "state_en": "He wears navy coveralls. He kneels on the pavement."},
        {"character_ko": "연희", "character_short_id": "C02",
         "state_en": "She remains nearby in her yellow raincoat."},
    ],
    "offscreen_effects_en": "",
}
VISIBLE_TWO_MEN = {"C01", "C03"}


def _still_endpoint(row, visible, *, bg_only=False) -> str:
    """still 끝점이 실제로 부르는 자리 그대로."""
    from app.services.still_recipe_service import _carried_clause

    return _carried_clause(
        {"carried": {"S2sh6": row}}, "S2sh6", {},
        visible_short_ids=visible, bg_only=bg_only)


def _conti_light_endpoint(row, visible, *, bg_only=False) -> str:
    """conti light 끝점이 부르는 자리 그대로 (`shot_conti_light.py:763`)."""
    return carried_clause_for(
        {"S2sh6": row}, "S2sh6", {},
        visible_short_ids=visible, bg_only=bg_only)


ENDPOINTS = [
    pytest.param(_still_endpoint, id="still"),
    pytest.param(_conti_light_endpoint, id="conti_light"),
]


@pytest.mark.parametrize("endpoint", ENDPOINTS)
def test_동성_대명사_두_사람이_각자_이름에_묶인다(endpoint):
    """★이 판의 본체 — `He` 가 둘이라 앞 문장 주어를 이어받던 자리."""
    out = endpoint(ROW_TWO_MEN, VISIBLE_TWO_MEN)
    assert "노인: He remains on his side" in out
    assert "민수: He wears navy coveralls" in out
    # 결속이 **각자 앞**에 붙어야 한다 — 노인 문장이 민수 것을 삼키면 안 된다
    assert out.index("노인:") < out.index("민수:")


@pytest.mark.parametrize("endpoint", ENDPOINTS)
def test_저작_문안을_한_글자도_안_고친다(endpoint):
    """대명사 재작성 금지 — 앞에 누구인지만 붙인다."""
    out = endpoint(ROW_TWO_MEN, VISIBLE_TWO_MEN)
    for p in ROW_TWO_MEN["people"]:
        if p["character_short_id"] in VISIBLE_TWO_MEN:
            assert p["state_en"] in out


@pytest.mark.parametrize("endpoint", ENDPOINTS)
def test_그_샷에_없는_인물은_이름째로_안_들어간다(endpoint):
    """감사 1-B 필터가 살아 있는지 — 이름을 붙이며 되살리면 안 된다."""
    out = endpoint(ROW_TWO_MEN, VISIBLE_TWO_MEN)
    assert "연희" not in out
    assert "raincoat" not in out


@pytest.mark.parametrize("endpoint", ENDPOINTS)
def test_무인_샷은_사람_칸을_통째로_안_붙인다(endpoint):
    out = endpoint(ROW_TWO_MEN, VISIBLE_TWO_MEN, bg_only=True)
    assert "노인" not in out and "민수" not in out
    assert "The fallen cane" in out          # objects 는 남는다


@pytest.mark.parametrize("endpoint", ENDPOINTS)
def test_objects_offscreen_순서_계약은_그대로(endpoint):
    row = dict(ROW_TWO_MEN, offscreen_effects_en="Rain keeps falling.")
    out = endpoint(row, VISIBLE_TWO_MEN)
    assert out.index("The fallen cane") < out.index("노인:")
    assert out.index("민수:") < out.index("Rain keeps falling.")


def test_이름이_비면_종전_그대로_문장만_낸다():
    """없는 이름을 지어내지 않는다 — 결속만 못 할 뿐 값은 산다."""
    row = {
        "carried_contract": "scoped_v1",
        "objects_en": "",
        "people": [{"character_ko": "", "character_short_id": "C09",
                    "state_en": "He waits by the door."}],
        "offscreen_effects_en": "",
    }
    out = carried_text_for_shot(row, visible_short_ids={"C09"},
                                bg_only=False)
    assert out == "He waits by the door."


def test_두_소비_끝점의_outer_config_hash_가_이_계약을_접는다():
    """★샷 지문만 접으면 outer 가 clean-skip 해 그림에 영영 안 닿는다.

    이 저장소에서 같은 자리를 이미 겪었다(`_config_hash_base` 의
    「조기 return 앞이어야 한다」 주석).
    """
    from unittest import mock

    import app.modules.pipeline.shot_continuity_author as sca
    from app.core.steps.image_steps import SceneImagePipelineStep
    from app.core.steps.shot_conti_light_step import ShotContiLightStep

    pid, eid = "p" * 8, "e" * 8
    for cls, sid in ((SceneImagePipelineStep, "scene_image_pipeline"),
                     (ShotContiLightStep, "shot_conti_light")):
        now = cls(sid, pid, eid, None)._config_hash()
        with mock.patch.object(
                sca, "CARRIED_PERSON_PROJECTION_VERSION", "__OLD__"):
            old = cls(sid, pid, eid, None)._config_hash()
        assert now != old, (
            f"{sid} 의 outer hash 가 안 움직인다 — 이 계약이 그림에 안 닿는다")


def test_leaf_를_직접_불러_우회하는_곳이_없다():
    """★조립을 호출부에 남기면 붙이는 줄이 통째로 빠져도 초록이다.

    소비처는 **전부** `carried_clause_for` 를 통해야 한다. leaf
    (`carried_text_for_shot`)를 `app/` 안에서 직접 부르면 이 계약이
    한쪽만 살아난다 — 그것을 문자열이 아니라 **AST 로** 센다.
    """
    direct = []
    for f in APP.rglob("*.py"):
        tree = ast.parse(f.read_text(encoding="utf-8"))
        # ★2026-09-18: 잠그는 것은 **어느 함수 안에서 부르나**이지 줄 번호가 아니다.
        #  옛 판은 `...:292` 로 줄을 박아, 위에 줄이 늘기만 해도 엉뚱하게 섰다.
        owner = {}
        for fd in ast.walk(tree):
            if isinstance(fd, (ast.FunctionDef, ast.AsyncFunctionDef)):
                for node in ast.walk(fd):
                    owner.setdefault(id(node), fd.name)
        for node in ast.walk(tree):
            if not isinstance(node, ast.Call):
                continue
            fn = node.func
            name = (fn.id if isinstance(fn, ast.Name)
                    else fn.attr if isinstance(fn, ast.Attribute) else "")
            if name == "carried_text_for_shot":
                direct.append(
                    f"{f.relative_to(APP)}:{owner.get(id(node), '(모듈 수준)')}")
    # 정본 안(`shot_continuity_author.carried_clause_for`)에서 부르는 한 번만
    assert direct == [
        "modules/pipeline/shot_continuity_author.py:carried_clause_for"], (
        f"leaf 직접 호출이 늘었다: {direct}")


def test_계약_version_이_비어_있지_않다():
    assert CARRIED_PERSON_PROJECTION_VERSION.strip()
