"""수동 합성을 **끝까지 태운다** — 유료 경계만 대역으로 (2026-09-20).

★왜 이 파일이 따로 있나 (Codex 감사 정정):
 처음에는 「함수는 유료 생성기를 부르니 태울 수 없다」고 적고 AST 로
 구조만 봤다. **그건 사실이 아니다.** 생성 경계와 DB·파일만 대역으로
 두면 `generate_composite_image` 를 무료로 끝까지 돌릴 수 있다.
 AST 검사는 「조건을 거꾸로 써도」 통과할 수 있어 흐름 검사의 대체가
 아니다.

여기서 잠그는 것은 **나가는 입력**이다:
  · derive 면 참조가 **몸 한 장**뿐인가
  · 옷 **상세**가 생성에 실리는가
  · 안 쓴 옷이 **계보**에서 빠지는가
  · 옷 자산이 **아예 없어도** 끝까지 도는가
  · 사람은 종전대로 옷이 **필수**이고 참조가 두 장인가
"""
from __future__ import annotations

from typing import Any, Dict, List, Optional

import pytest

from app.core.errors import AppError


# ── 대역 ────────────────────────────────────────────────────────────

class _Ent:
    def __init__(self, id_, short_id, name, description="", etype="character"):
        self.id = id_
        self.short_id = short_id
        self.name = name
        self.description = description
        self.entity_type = etype
        self.project_id = "P"


class _Asset:
    def __init__(self, id_, path):
        self.id = id_
        self.file_path = str(path)


class _Query:
    """`.filter(...).first()` 만 흉내낸다 — 무엇을 물었는지로 답을 고른다."""

    def __init__(self, db, model):
        self._db, self._model, self._args = db, model, []

    def filter(self, *args):
        self._args.extend(args)
        return self

    def first(self):
        return self._db._answer(self._model, self._args)


class _DB:
    def __init__(self, *, char, outlook, face, outfit):
        self._char, self._outlook = char, outlook
        self._face, self._outfit = face, outfit
        self._seen_order: List[str] = []
        self.added: List[Any] = []
        self.executed: List[Any] = []

    # -- 서비스가 쓰는 것만 --
    def query(self, model):
        return _Query(self, model)

    def add(self, obj):
        self.added.append(obj)

    def execute(self, *a, **kw):
        self.executed.append((a, kw))

    def commit(self):
        pass

    def _answer(self, model, args):
        """조회 **순서**로 답한다.

        ★SQLAlchemy 표현식은 `entity_canon.id = :id_1` 로 렌더되어
         **값이 안 보인다** — 인자를 글자로 뒤져 구분하려던 첫 대역이
         그래서 둘 다 인물로 답했다. 서비스가 읽는 순서는 고정이다:
         인물 → 아웃룩 → 얼굴 자산 → 옷 자산.
        """
        name = getattr(model, "__name__", "")
        self._seen_order.append(name)
        if name == "EntityCanon":
            n = self._seen_order.count("EntityCanon")
            return self._char if n == 1 else self._outlook
        n = self._seen_order.count("ImageAsset")
        return self._face if n == 1 else self._outfit


@pytest.fixture
def _wire(tmp_path, monkeypatch):
    """서비스를 세우고 유료 경계를 잡아 둔다. 반환 = (svc, 잡힌 호출)."""
    from app.services import reference_composite_service as mod

    seen: Dict[str, Any] = {}

    def _fake_generate(**kwargs):
        seen.update(kwargs)
        out = tmp_path / "composite.png"
        out.write_bytes(b"png")
        return {"file_path": str(out), "generation_model": "fake",
                "validation": {"score": 90}}

    class _FakeClient:
        def __init__(self, *a, **kw):
            pass

        def set_context(self, **kw):
            pass

    monkeypatch.setattr(mod, "GeminiImageClient", _FakeClient)
    monkeypatch.setattr(
        "app.modules.pipeline.ref_image_pipeline.generate_and_validate_reference",
        _fake_generate)
    monkeypatch.setattr(mod, "annotate_generated_asset",
                        lambda asset, **kw: seen.update({"lineage": kw}))
    monkeypatch.setattr(mod, "image_to_dict", lambda a: {"id": a.id})
    monkeypatch.setattr(mod, "to_relative_image_path", lambda p: str(p))
    # 몸=신원 명단은 시험이 정한다
    monkeypatch.setattr("app.core.body_identity.body_identity_short_ids",
                        lambda pid, episode_id=None: {"C06"})
    return mod, seen


def _make(mod, tmp_path, *, short_id: str, with_outfit: bool = True):
    face = tmp_path / "face.png"
    face.write_bytes(b"face")
    outfit = None
    if with_outfit:
        o = tmp_path / "outfit.png"
        o.write_bytes(b"outfit")
        outfit = _Asset("OUTFIT-ASSET", o)
    char = _Ent("CHAR", short_id, "찰리" if short_id == "C06" else "현우")
    outlook = _Ent("OUTLOOK", "O07", "알록달록우비세트",
                   "커다란 밀짚모자와 거대한 장화, 알록달록한 우비",
                   etype="outlook")
    db = _DB(char=char, outlook=outlook,
             face=_Asset("FACE-ASSET", face), outfit=outfit)
    svc = mod.ReferenceCompositeService(
        db=db, project_id="P", actor_id=None, activity_logger=None)
    monkey_ep = "EP"
    svc._storage_episode = lambda *a, **kw: monkey_ep   # 화 조회는 범위 밖
    return svc, db


# ── 몸=신원 (derive) ────────────────────────────────────────────────

def test_derive_sends_one_reference_and_the_real_outfit_text(_wire, tmp_path):
    mod, seen = _wire
    svc, _db = _make(mod, tmp_path, short_id="C06")

    svc.generate_composite_image("CHAR", "OUTLOOK")

    refs = seen["extra_references"]
    assert len(refs) == 1, f"몸=신원인데 참조가 {len(refs)}장이다 — 몸이 섞인다"
    assert "COMPLETE BODY" in refs[0][0]
    assert seen["entity_type"] == "composite_derive"
    # ★옷 상세가 **글로** 간다
    assert "밀짚모자" in seen["outlook_description"]
    assert "장화" in seen["outlook_description"]


def test_derive_without_any_outfit_image_still_completes(_wire, tmp_path):
    """옷 자산이 **아예 없어도** 끝까지 돈다 — 쓰지도 않을 것이었다."""
    mod, seen = _wire
    svc, _db = _make(mod, tmp_path, short_id="C06", with_outfit=False)

    out = svc.generate_composite_image("CHAR", "OUTLOOK")

    assert out["id"], "옷 사진이 없다고 막혔다"
    assert len(seen["extra_references"]) == 1
    assert "밀짚모자" in seen["outlook_description"]


def test_derive_lineage_drops_the_unused_outfit(_wire, tmp_path):
    """안 쓴 옷은 **계보에도** 안 남는다."""
    mod, seen = _wire
    svc, _db = _make(mod, tmp_path, short_id="C06")

    svc.generate_composite_image("CHAR", "OUTLOOK")

    ids = seen["lineage"]["input_image_ids"]
    assert ids == ["FACE-ASSET"], f"안 쓴 옷이 계보에 남았다: {ids}"


# ── 사람 (종전 계약) ────────────────────────────────────────────────

def test_human_still_gets_two_references(_wire, tmp_path):
    """사람은 **종전 그대로** — 얼굴 + 옷 두 장."""
    mod, seen = _wire
    svc, _db = _make(mod, tmp_path, short_id="C01")

    svc.generate_composite_image("CHAR", "OUTLOOK")

    refs = seen["extra_references"]
    assert len(refs) == 2, "사람 합성에서 옷 사진이 빠졌다"
    assert seen["entity_type"] == "composite"
    assert seen["lineage"]["input_image_ids"] == ["FACE-ASSET", "OUTFIT-ASSET"]


def test_human_without_outfit_image_is_still_rejected(_wire, tmp_path):
    """사람은 옷 사진이 **여전히 필수** — 이 보호를 같이 풀면 안 된다."""
    mod, _seen = _wire
    svc, _db = _make(mod, tmp_path, short_id="C01", with_outfit=False)

    with pytest.raises(AppError) as err:
        svc.generate_composite_image("CHAR", "OUTLOOK")
    assert "outfit" in str(err.value.code)


# ── 검사·비교가 같은 설명을 쓴다 (2026-09-20 Codex BLOCK 2) ─────────

def test_validation_and_comparison_share_one_description():
    """옷 상세가 **첫 검사에만** 실리고 재생성 후 비교에는 빠지던 것.

    `severe` 뒤 비교자(`compare_two_images`)가 이름 한 줄만 받으면,
    모자·장화 결손을 알아채 다시 산 **직후 그 요구를 모르는 비교자**가
    원본을 다시 고르거나 다른 불완전본을 고른다.

    같은 문자열을 **한 번 만들어** 두 호출이 함께 쓴다.
    """
    import inspect
    import pathlib

    from app.modules.pipeline import ref_image_pipeline as mod

    src = pathlib.Path(inspect.getfile(mod)).read_text(encoding="utf-8")
    assert src.count("_checked_description = _validation_description(") == 1, (
        "합친 설명을 두 곳에서 따로 만든다 — 한쪽만 고쳐진다")
    i = src.find("validation = validate_reference_image(")
    j = src.find("comparison = compare_two_images(")
    assert i > 0 and j > 0, "전제 확인"
    assert "_checked_description" in src[i:i + 200], "검사가 합친 설명을 안 쓴다"
    assert "_checked_description" in src[j:j + 220], "비교가 합친 설명을 안 쓴다"
    assert "entity_description," not in src[j:j + 220], (
        "비교가 아직 이름 한 줄을 넘긴다")
