"""소비된 형태 참조의 **계보 계약** (2026-08-01, Codex 2차 재리뷰 BLOCKING-2).

## 무엇이 잘못됐었나

1차 재리뷰 뒤 `_resolve_form_ref` 에 `asset_id` 결손 검사를 넣었다. 그런데
검사가 **"빈 문자열이 아닌가"** 까지였다. 존재하지 않는 UUID 를 넣어도
정상 지문으로 통과하고, `annotate_generated_asset` 은 받은 문자열을 JSON 으로
적을 뿐 실제 `ImageAsset` 을 조회하지 않는다. 결과는 **가짜 간선** — 계보가
있는 것처럼 보이지만 가리키는 자산이 없다.

키 존재 ≠ 계약 일치라는 같은 함정이 계보 층에서 반복된 것이다.

또 성공 CP entry 에 소비한 참조의 어떤 typed 필드도 없었다. 변형 모드가
꺼져 있으면 `extra_fingerprint` 도 CP 에 안 남아, **DB 간선 말고는 감사
근거가 전혀 없다.**

## 새 계약

소비 직전에 그 `asset_id` 가 지금 project/episode 의 form_ref 자산인지
조회하고, 경로까지 CP 참조와 같은지 본다. 어긋나면 필수 그룹은 선다.
그리고 소비한 값을 CP entry 의 typed 필드로 영속한다.
"""
from __future__ import annotations

from pathlib import Path

import pytest

from app.core.errors import AppError
from app.core.steps.outdoor_structure_seed_step import OutdoorStructureSeedStep

GID = "g_alpha"


class _FakeQuery:
    def __init__(self, rows):
        self._rows = rows
        self._filters = {}

    def filter_by(self, **kw):
        self._filters = kw
        return self

    def first(self):
        for row in self._rows:
            if all(getattr(row, k, None) == v
                   for k, v in self._filters.items()):
                return row
        return None


class _FakeAsset:
    def __init__(self, *, asset_id, file_path, project_id="P", episode_id="E",
                 asset_type="structure_form_ref", entity_id=GID,
                 variant_type="form_ref"):
        self.id = asset_id
        self.file_path = file_path
        self.project_id = project_id
        self.episode_id = episode_id
        self.asset_type = asset_type
        self.entity_id = entity_id
        self.variant_type = variant_type


class _FakeDB:
    def __init__(self, rows):
        self._rows = rows

    def query(self, *_a):
        return _FakeQuery(self._rows)


@pytest.fixture()
def ref_png(tmp_path: Path) -> Path:
    p = tmp_path / "form_ref.png"
    p.write_bytes(b"sample-fixture-bytes")
    return p


def _sha(p: Path) -> str:
    import hashlib

    return hashlib.sha256(p.read_bytes()).hexdigest()


def _entry(png: Path, *, asset_id: str) -> dict:
    return {"status": "ok", "form_ref_path": str(png),
            "form_ref_sha256": _sha(png), "form_ref_asset_id": asset_id}


def _step(rows) -> OutdoorStructureSeedStep:
    step = OutdoorStructureSeedStep.__new__(OutdoorStructureSeedStep)
    step.project_id, step.episode_id = "P", "E"
    step.db = _FakeDB(rows)
    return step


def test_existing_asset_is_accepted(ref_png, monkeypatch):
    monkeypatch.setattr("app.core.file_paths.to_relative_image_path",
                        lambda p: str(p))
    step = _step([_FakeAsset(asset_id="A1", file_path=str(ref_png))])
    refs, fp = step._resolve_form_ref(
        gid=GID, entry=_entry(ref_png, asset_id="A1"), mandatory=True)
    assert [label for label, _ in refs] == ["FORM REFERENCE"]
    assert fp["asset_id"] == "A1"


def test_dangling_asset_id_is_rejected_for_mandatory(ref_png):
    """★재현 — 존재하지 않는 UUID 가 정상 계보로 기록되던 경로."""
    step = _step([])  # DB 에 그 자산이 없다
    with pytest.raises(AppError) as exc:
        step._resolve_form_ref(
            gid=GID, entry=_entry(ref_png, asset_id="nonexistent-uuid"),
            mandatory=True)
    assert GID in str(exc.value.message)


def test_dangling_asset_id_is_not_consumed_for_optional(ref_png):
    """비필수여도 가짜 간선을 남기지 않는다 — 참조 없이 간다."""
    step = _step([])
    refs, fp = step._resolve_form_ref(
        gid=GID, entry=_entry(ref_png, asset_id="nonexistent-uuid"),
        mandatory=False)
    assert (refs, fp) == ([], None)


def test_asset_pointing_at_a_different_file_is_rejected(ref_png, tmp_path,
                                                        monkeypatch):
    """자산은 있는데 다른 파일을 가리키면 계보가 거짓이다."""
    monkeypatch.setattr("app.core.file_paths.to_relative_image_path",
                        lambda p: str(p))
    other = tmp_path / "other.png"
    other.write_bytes(b"different")
    step = _step([_FakeAsset(asset_id="A1", file_path=str(other))])
    with pytest.raises(AppError):
        step._resolve_form_ref(
            gid=GID, entry=_entry(ref_png, asset_id="A1"), mandatory=True)


def test_asset_of_another_group_is_rejected(ref_png, monkeypatch):
    """entity_id 가 다른 그룹의 자산을 물면 그룹 간 계보가 뒤섞인다."""
    monkeypatch.setattr("app.core.file_paths.to_relative_image_path",
                        lambda p: str(p))
    step = _step([_FakeAsset(asset_id="A1", file_path=str(ref_png),
                             entity_id="g_other")])
    with pytest.raises(AppError):
        step._resolve_form_ref(
            gid=GID, entry=_entry(ref_png, asset_id="A1"), mandatory=True)


def test_asset_of_another_episode_is_rejected(ref_png, monkeypatch):
    monkeypatch.setattr("app.core.file_paths.to_relative_image_path",
                        lambda p: str(p))
    step = _step([_FakeAsset(asset_id="A1", file_path=str(ref_png),
                             episode_id="E_other")])
    with pytest.raises(AppError):
        step._resolve_form_ref(
            gid=GID, entry=_entry(ref_png, asset_id="A1"), mandatory=True)


# ── typed 소비 기록 (Codex: 이번 wave 의 승인 조건) ────────────────────

def test_consumed_ref_record_is_typed_and_complete(ref_png, monkeypatch):
    """CP 에 남길 소비 기록은 **감사 가능한 typed 필드**여야 한다.

    변형 모드가 꺼져 있으면 `extra_fingerprint` 조차 CP 에 안 남는다 — DB
    간선 하나 말고는 "어느 참조를 물고 그렸는가"를 되짚을 근거가 없었다.
    """
    from app.core.steps.outdoor_structure_seed_step import (
        build_consumed_ref_record,
    )
    from app.modules.pipeline.search_grounded_ref import (
        TARGET_POLICY_VERSION,
        resolve_ref_pack_version,
    )

    monkeypatch.setattr("app.core.file_paths.to_relative_image_path",
                        lambda p: str(p))
    step = _step([_FakeAsset(asset_id="A1", file_path=str(ref_png))])
    _refs, fp = step._resolve_form_ref(
        gid=GID, entry=_entry(ref_png, asset_id="A1"), mandatory=True)

    rec = build_consumed_ref_record(fp)
    assert rec["asset_id"] == "A1"
    assert rec["path"] == str(ref_png)
    assert rec["sha256"] == _sha(ref_png)
    assert rec["pack_version"] == resolve_ref_pack_version()
    assert rec["policy_version"] == TARGET_POLICY_VERSION
    assert rec["consumer_contract"]


def test_consumed_ref_record_is_explicit_when_no_ref():
    """참조 없이 간 그룹도 **그렇게 갔다고** 남긴다 — 키 부재로 두지 않는다."""
    from app.core.steps.outdoor_structure_seed_step import (
        build_consumed_ref_record,
    )

    rec = build_consumed_ref_record(None)
    assert rec["asset_id"] is None
    assert rec["consumer_contract"]


# ── 경로 비교 계층 (2026-08-01, Codex 재확인에서 발견) ─────────────────
# ★내 첫 판본은 CP 경로를 `to_relative_image_path` 로 **상대화**해서 row.file_path
# 와 비교했다. 그런데 `ImageAsset.file_path` 는 `ImagePathType` 이라 **읽을 때
# 상대→절대로 복원**된다(file_paths.py:90). 즉 프로젝트 루트 안에 있는 실제
# 자산은 상대 vs 절대로 영영 어긋나 **정상 자산이 전부 거부**된다.
#
# 내 유닛이 이걸 못 잡은 이유가 더 중요하다 — 픽스처 경로가 /tmp(루트 밖)이라
# `to_relative_image_path` 가 입력을 그대로 돌려줘 우연히 같아졌고, fake row 의
# file_path 도 **내 구현과 같은 함수로** 만들었다. 즉 구현이 아니라 mock 을
# 검증하고 있었다. 여기서는 ORM 이 실제로 돌려주는 모양(절대)으로 세운다.

def _root_png(tmp_path: Path, monkeypatch) -> Path:
    """프로젝트 루트 **안**의 참조 파일 — 실제 배치와 같은 형태."""
    root = tmp_path / "the-road-root"
    p = root / "projects" / "P" / "images" / "E" / "form_ref.png"
    p.parent.mkdir(parents=True, exist_ok=True)
    p.write_bytes(b"sample-fixture-bytes-in-root")
    monkeypatch.setattr("app.core.file_paths._resolve_root", lambda: root)
    return p


def test_asset_inside_project_root_is_accepted(tmp_path, monkeypatch):
    """★ORM 은 절대로 돌려준다 — 상대화해 비교하면 정상 자산이 거부된다."""
    png = _root_png(tmp_path, monkeypatch)
    step = _step([_FakeAsset(asset_id="A1", file_path=str(png))])
    refs, fp = step._resolve_form_ref(
        gid=GID, entry=_entry(png, asset_id="A1"), mandatory=True)
    assert [label for label, _ in refs] == ["FORM REFERENCE"]
    assert fp["asset_id"] == "A1"


def test_relative_row_path_inside_root_is_also_accepted(tmp_path, monkeypatch):
    """DB 에 상대로 적힌 row 를 ORM 없이 직접 받아도 같은 자산으로 본다."""
    png = _root_png(tmp_path, monkeypatch)
    rel = "projects/P/images/E/form_ref.png"
    step = _step([_FakeAsset(asset_id="A1", file_path=rel)])
    refs, _fp = step._resolve_form_ref(
        gid=GID, entry=_entry(png, asset_id="A1"), mandatory=True)
    assert len(refs) == 1


def test_different_file_inside_root_is_still_rejected(tmp_path, monkeypatch):
    """정규화가 관대해져서 다른 파일까지 통과시키면 안 된다."""
    png = _root_png(tmp_path, monkeypatch)
    other = png.parent / "other.png"
    other.write_bytes(b"different")
    step = _step([_FakeAsset(asset_id="A1", file_path=str(other))])
    with pytest.raises(AppError):
        step._resolve_form_ref(
            gid=GID, entry=_entry(png, asset_id="A1"), mandatory=True)


def test_orm_round_trip_path_is_accepted(tmp_path, monkeypatch):
    """★ORM 이 실제로 돌려주는 값으로 시험한다 — 내가 만든 모양이 아니라.

    `to_relative_image_path` 로 fake 를 만들면 구현과 같은 함수를 쓰는 것이라
    **내 구현이 틀려도 같이 틀린다.** 저장·읽기를 실제 컬럼 타입에 통과시켜
    나온 값을 쓴다.
    """
    from app.core.file_paths import ImagePathType

    png = _root_png(tmp_path, monkeypatch)
    col = ImagePathType()
    stored = col.process_bind_param(str(png), None)      # 저장 = 상대
    loaded = col.process_result_value(stored, None)      # 읽기 = 절대
    assert stored != loaded, "round-trip 이 표현을 바꾸지 않으면 이 회귀는 무의미"

    step = _step([_FakeAsset(asset_id="A1", file_path=loaded)])
    refs, _fp = step._resolve_form_ref(
        gid=GID, entry=_entry(png, asset_id="A1"), mandatory=True)
    assert len(refs) == 1
