"""몸이 곧 신원인 인물은 **전신 기본 형태**를 갖고, 아웃룩은 거기서 파생된다.

## 무엇이 결함이었나 (2026-09-18 컨트리로드, 합성 참조 27장 전수 확인)

로봇 주인공 찰리의 기본 참조 시트(23샷이 쓴 것) 한 장에 **정상 2 + 파손 1**,
세 몸이 섞여 있었다. 다른 참조들 사이에서도 바탕색(건메탈/녹슨 강철/은색)과
체형(육중한 고릴라/사람 체형)이 갈렸다. 사람 인물 26장은 전부 깨끗했다.

뿌리는 한 줄이었다:

    ref_entity_type = "character_nonhuman" if is_null_outlook else "character"

한 줄에 **두 물음**이 뭉쳐 있다 — ①이 인물에 옷이 있나 ②이 인물의 몸이 곧
신원인가. 찰리는 둘 다 「예」라서, 옷이 있다는 이유로 사람 경로(얼굴 흉상)로
갔고 **몸은 합성 단계에서 매번 새로 그려졌다**(얼굴 + 옷 → 전신).

## 이 시험이 잠그는 것

① 아웃룩 1단계가 「몸이 곧 신원인 인물」을 **따로** 답하고 산출에 남긴다
② 그 인물은 **아웃룩이 있어도** 전신 기본 형태 경로를 탄다
③ 그 인물의 합성본은 **파생 문안**(몸 보존)을 쓰고 첫 참조를 「몸」으로 표시한다
④ 사람은 종전 그대로 (얼굴 문안 · 얼굴 라벨)
⑤ 맨몸·기본 외형은 아웃룩이 아니라는 계약이 문안에 있다
"""
from __future__ import annotations

import json
import pathlib


# ── ① 아웃룩 1단계의 계약 ─────────────────────────────────────────

def _pack_dir() -> pathlib.Path:
    from app.modules.prompt_loader import PROMPTS_BASE, resolve_effective

    v = resolve_effective("outlook_extractor", "phase1", kind="prompt")["version"]
    return PROMPTS_BASE / "outlook_extractor" / v


def test_phase1_asks_for_body_identity_characters():
    schema = json.loads((_pack_dir() / "phase1_schema.json").read_text(encoding="utf-8"))
    assert "body_identity_characters" in schema["properties"], (
        "몸이 곧 신원인 인물을 물어보는 칸이 없다")
    assert "body_identity_characters" in schema["required"], (
        "필수가 아니면 모델이 그냥 빼먹는다")
    # 기존 축은 그대로 — 다른 물음이다
    assert "non_humanoid_characters" in schema["properties"]


def test_phase1_prompt_separates_the_two_questions():
    t = (_pack_dir() / "phase1.md").read_text(encoding="utf-8")
    assert "body_identity_characters" in t
    assert "다른 물음" in t, "비인간형과 같은 물음으로 읽히면 로봇이 빠진다"
    assert "맨몸" in t and "아웃룩이 아닙니다" in t, (
        "맨몸·기본 외형이 아웃룩으로 등록되는 것을 막는 절이 없다")


def test_the_step_keeps_the_list(monkeypatch):
    """스텝이 그 답을 **산출에 남기는지** — 안 남기면 참조 단계가 못 읽는다."""
    import inspect

    from app.core.steps import outlook_steps

    src = inspect.getsource(outlook_steps.OutlookPhase1Step._execute)
    assert "body_identity_characters" in src, "답을 읽는 줄이 없다"
    assert '"body_identity_chars"' in src, "산출에 싣는 줄이 없다"
    assert "_retry_results" in src, "재시도 답의 판정이 버려진다"


# ── ② 전신 경로 선택 ──────────────────────────────────────────────

def test_reference_type_uses_both_questions():
    import inspect

    from app.services import reference_phase1_service as svc

    src = inspect.getsource(svc)
    assert "is_body_identity" in src
    assert "reference_entity_type(" in src, (
        "공용 helper 를 안 쓰면 배치·수동 판정이 갈린다")


def test_the_context_carries_it():
    """기본값이 비어야 **기존 프로젝트의 동작이 한 바이트도 안 바뀐다**."""
    import dataclasses

    from app.services.reference_pipeline_context import ReferencePipelineContext

    fields = {f.name: f for f in dataclasses.fields(ReferencePipelineContext)}
    assert "body_identity_char_ids" in fields, "칸이 없다"
    f = fields["body_identity_char_ids"]
    assert f.default_factory is set, f"기본값이 빈 집합이 아니다: {f}"


# ── ③④ 합성: 파생 문안 ────────────────────────────────────────────

def test_derive_prompt_exists_and_protects_the_body():
    from app.modules.pipeline.ref_image_pipeline import (
        _ASPECT_MAP, _REF_PROMPT_MAP, _load_ref_image_prompt)

    assert _REF_PROMPT_MAP.get("composite_derive") == "character_composite_derive_ref"
    assert _ASPECT_MAP.get("composite_derive") == _ASPECT_MAP["composite"]
    t = _load_ref_image_prompt("composite_derive") or ""
    # ★머리말 글자가 아니라 「이 인물의 몸이 기준」이라는 뜻을 잠근다 — 팩 v9
    #  (2026-09-19, 옷 사진을 빼고 글로 준 판)가 머리말을 「There is ONE
    #  reference image, and it is this character's BODY」로 바꿨다. 아래
    #  방어 셋(파손·한 컷·바탕색)은 v9 에도 그대로다.
    assert "this character's body" in t.lower()
    # 이번 실측 결함 셋을 문안이 직접 막는가
    assert "Do NOT add damage" in t, "파손 상태가 섞인 것이 이번 결함이다"
    assert "exactly ONE figure" in t, "3면 시트로 나온 것이 이번 결함이다"
    assert "re-colour" in t or "re-color" in t, "바탕색이 갈린 것이 이번 결함이다"


def test_human_composite_prompt_is_unchanged():
    from app.modules.pipeline.ref_image_pipeline import _load_ref_image_prompt

    t = _load_ref_image_prompt("composite") or ""
    assert "face/identity" in t, "사람 경로 문안이 바뀌면 26장이 다시 만들어진다"
    assert "BODY is its identity" not in t


def test_composite_step_picks_the_derive_path():
    import inspect

    from app.services import reference_phase3_service as svc

    src = inspect.getsource(svc)
    assert '"composite_derive" if _derive else "composite"' in src
    assert "COMPLETE BODY" in src, "첫 참조를 몸으로 표시하지 않으면 문안과 어긋난다"
    assert "body_identity" in src


# ── ⑥ 국적·인종은 근거가 있을 때만 (같은 주행에서 나온 결함) ──────

def test_ethnicity_is_only_written_when_the_text_says_so():
    """★2026-09-18 실측: 계약이 「인종/민족 **필수**」라 근거가 없어도 고르게
    했고, 모델이 지어냈다 — 대본에 「백인」은 한 곳(앰버)뿐인데 인물 셋이
    백인이 됐고 그게 **이름에 박혀** 하류 전부로 퍼졌다."""
    from app.modules.prompt_loader import load_prompt

    t = load_prompt("entity_extractor_v2", "turn1_7_detail_batch",
                    entity_list="X", fulltext="Y")
    assert "인종/민족 필수" not in t, "근거 없이 고르게 만드는 절이 남아 있다"
    assert "이름으로 짐작하지 마라" in t, "이름으로 국적을 정하는 것을 막는 절이 없다"
    assert "주된 지역 사람" in t, "근거가 없을 때 무엇으로 둘지가 없다(비우면 그림마다 달라진다)"


def test_names_do_not_carry_invented_ethnicity():
    from app.modules.prompt_loader import load_prompt

    for module in ("entity_all", "entity_extract_v4"):
        t = load_prompt(module, "character")
        assert "국적·인종·피부색" in t, f"{module}: 이름에 인종을 넣지 말라는 절이 없다"


# ── ⑦ Codex 가 짚은 「놓치면 무효가 되는 네 곳」 ──────────────────

def test_the_derive_template_is_actually_selected():
    """★타입으로 문안이 정해진다 — `t2i_prompt` 만 바꾸면 공통 생성기가
    타입 템플릿으로 **덮어쓴다**. 처음 구현이 그래서 무효였다."""
    import inspect

    from app.services import reference_phase3_service as svc

    src = inspect.getsource(svc)
    assert 'entity_type=("composite_derive" if _derive' in src, (
        "entity_type 을 안 가르면 파생 문안이 안 실린다(무효 수정)")


def test_batch_and_manual_read_the_same_judgment():
    """배치만 고치면 손으로 다시 만들 때 흉상으로 돌아간다."""
    import inspect

    from app.services import (reference_entity_service,
                              reference_pipeline_orchestrator)

    for mod, name in ((reference_pipeline_orchestrator, "배치"),
                      (reference_entity_service, "수동")):
        src = inspect.getsource(mod)
        assert "body_identity" in src, f"{name} 경로가 판정을 안 읽는다"
        assert "app.core.body_identity" in src, f"{name} 경로가 공용 helper 를 안 쓴다"


def test_reference_entity_type_helper_splits_the_two_questions():
    from app.core.body_identity import reference_entity_type as R

    assert R("character", is_body_identity=False) == "character"
    assert R("character", is_body_identity=True) == "character_nonhuman", (
        "옷이 있는 로봇이 흉상으로 간다")
    assert R("character", is_body_identity=False,
             is_null_outlook=True) == "character_nonhuman", "종전 동작이 깨졌다"
    assert R("prop", is_body_identity=True) == "prop", "인물 밖으로 번지면 안 된다"


def test_composite_reuse_is_bound_to_the_base_reference():
    """기본 몸체를 다시 만들었는데 낡은 합성본이 남으면 고친 것이 안 닿는다."""
    import inspect

    from app.services import reference_phase3_service as svc

    src = inspect.getsource(svc)
    assert 'f"composite:{char_id}:{outlook_id}:{face_asset.id}"' in src, (
        "재사용 키가 기본 참조 신원에 안 묶였다")


def test_t2i_pack_has_the_nonhuman_exception():
    """상세에 몸을 적어도 T2I 팩이 「바디 묘사 금지」면 다시 지워진다."""
    from app.modules.prompt_loader import load_prompt

    t = load_prompt("entity_extractor_v2", "turn_entity_detail",
                    entity_name="x", entity_type="character")
    assert "Full body reference shot" in t, "비인간 전신 예외가 없다"
    assert "몸이 곧 신원" in t
    assert "Passport-style ID photo" in t, "사람 경로가 바뀌면 안 된다"


# ── ⑧ 맨몸 씬은 「덧입은 것 없음」이다 (2026-09-19 실주행에서 멎었다) ──

def test_null_outlook_is_not_a_borrowed_garment():
    """★몸이 곧 신원인 인물의 몸을 아웃룩에서 뺐더니, **덧입는 게 없는 씬**에서
    phase2 가 O00(덧입은 것 없음)을 배정했고 소유자 검사가 그것을 「주인 없는
    옷을 빌려 줬다」로 보고 단계를 세웠다(찰리 C06, 실주행 01:30).

    O00 은 옷이 아니라 **표시**다 — 주인이 없는 게 당연하다."""
    import inspect

    from app.core.entity_identity import NULL_OUTLOOK_SHORT_ID
    from app.modules.pipeline import outlook_extractor_v2 as ox

    src = inspect.getsource(ox)
    assert "if oid == NULL_OUTLOOK_SHORT_ID:" in src, (
        "O00 이 소유 검사에서 안 빠졌다 — 맨몸 씬마다 단계가 멎는다")
    assert NULL_OUTLOOK_SHORT_ID == "O00"
    # 남의 옷 빌려오기 검사 자체는 살아 있어야 한다
    assert "(소유 " in src and "다른 인물 소유 아웃룩을 배정했다" in src
