"""씨드의 재생성 이슈 정책 배선 — "명시 OFF" 를 계약으로 잠근다.

2026-08-01. critique 는 v13 팩부터 어떤 결함을 `needs_regeneration` 으로
표시한다(층수·매스·footprint 처럼 국소 편집의 사정거리 밖인 것). 그런데
재생성 경로는 아무 production 호출자도 배선하지 않았고, 그 상태의 기본
동작은 **그 결함을 그대로 i2i 편집 지시에 실어 보내는 것**이었다.

실측(프로젝트 475e5694, 25그룹): 8그룹이 이 경로를 탔고 구조 결함 11건이
편집 지시에 실렸다. 그중 3그룹은 편집 가능한 결함이 하나도 없어, 고칠 수
없는 것만으로 유료 편집 호출을 한 번씩 태웠다. 게다가 씨드는 재판정도
배선하지 않아 그 실패본이 원본을 무판정 대체한다.

그래서 이 wave 는 재생성을 켜는 대신 **끈 상태의 동작을 정의**한다 —
씨드는 `defer` 를 명시로 전달하고, 그 정책이 config_hash 에 실려 완료 CP
가 옛 편집본을 재사용하지 못하게 한다. 비대상 소비자(plate 등)는 기본값
`legacy_edit` 그대로여서 지문도 행동도 변하지 않는다.
"""
from __future__ import annotations

import ast
import pathlib

from app.modules.pipeline.multiroll_select import (
    REGEN_ISSUE_POLICY_DEFER,
    REGEN_ISSUE_POLICY_LEGACY_EDIT,
)

BACKEND = pathlib.Path(__file__).resolve().parents[2]
SEED_STEP = BACKEND / "app/core/steps/outdoor_structure_seed_step.py"
PLATE = BACKEND / "app/modules/pipeline/plate_multiroll.py"


def _calls(path: pathlib.Path, names: set[str]):
    """AST 로 호출부의 명시 키워드를 모은다.

    글자로 훑으면 주석·docstring·다중행 호출에서 새거나 잘못 잡는다.
    """
    tree = ast.parse(path.read_text(encoding="utf-8"))
    found = []
    for node in ast.walk(tree):
        if not isinstance(node, ast.Call):
            continue
        f = node.func
        name = f.attr if isinstance(f, ast.Attribute) else getattr(f, "id", None)
        if name in names:
            found.append(
                (node.lineno, {k.arg for k in node.keywords if k.arg}))
    return found


def test_seed_passes_the_defer_policy_explicitly():
    """씨드는 정책을 명시로 넘긴다 — 기본값에 기대지 않는다."""
    calls = _calls(SEED_STEP, {"run_multiroll_select"})
    assert calls, "씨드에서 run_multiroll_select 호출을 찾지 못했다"
    for lineno, kws in calls:
        assert "regeneration_issue_policy" in kws, (
            f"outdoor_structure_seed_step.py:{lineno} 가 재생성 이슈 정책을 "
            f"명시하지 않는다 — 공용 기본값(legacy)으로 조용히 내려간다")


def test_seed_declares_defer_not_some_other_policy():
    from app.core.steps import outdoor_structure_seed_step as mod

    assert mod.SEED_REGEN_ISSUE_POLICY == REGEN_ISSUE_POLICY_DEFER


def test_seed_does_not_wire_regeneration_yet():
    """★선언과 구현을 일치시킨다 — 재생성은 아직 배선하지 않는다.

    켜려면 생성 함수·브리프·재판정 계약이 함께 있어야 하고, 그 셋이 갖춰지지
    않은 채 켜지면 공용 진입점이 fail-closed 로 막는다. 이 잠금은 "언젠가
    켠다"는 약속만 남고 경로가 없는 상태를 방지한다.
    """
    for lineno, kws in _calls(SEED_STEP, {"run_multiroll_select"}):
        assert "regen_gen_fn" not in kws, (
            f"outdoor_structure_seed_step.py:{lineno} 에 재생성이 배선됐다 — "
            f"정책·재판정 계약이 함께 갖춰졌는지 확인하고 이 잠금을 갱신하라")


def test_plate_consumer_keeps_the_default_policy():
    """비대상 소비자는 손대지 않는다 — 지문·행동 byte 호환."""
    calls = _calls(PLATE, {"run_multiroll_select"})
    assert calls, "plate 에서 run_multiroll_select 호출을 찾지 못했다"
    for lineno, kws in calls:
        assert "regeneration_issue_policy" not in kws, (
            f"plate_multiroll.py:{lineno} 가 정책을 바꿨다 — 이번 wave 범위는 "
            f"씨드뿐이고, 공용 전환은 별도 계약 변경이다")


def test_seed_config_hash_carries_the_policy(monkeypatch):
    """★정책이 config_hash 에 실려야 완료 CP 가 옛 편집본을 버린다.

    실리지 않으면 새 계약이 실행에 도달하지 못한다 — 같은 함정을 이미
    겪었다(관할절을 통째로 바꿔도 config_hash 가 같아 CP 가 재사용됐다).
    """
    from app.core.steps import outdoor_structure_seed_step as mod

    step = object.__new__(mod.OutdoorStructureSeedStep)
    before = step._config_hash()
    monkeypatch.setattr(
        mod, "SEED_REGEN_ISSUE_POLICY", REGEN_ISSUE_POLICY_LEGACY_EDIT)
    after = step._config_hash()

    assert before != after, (
        "재생성 이슈 정책이 config_hash 축에 없다 — 정책을 바꿔도 완료된 "
        "seed CP 가 그대로 재사용된다")


def test_seed_config_hash_carries_the_policy_version(monkeypatch):
    """★정책 **이름**이 같아도 그 의미가 바뀌면 stale 이어야 한다.

    값만 실으면 "defer 의 뜻을 고쳤다"가 CP 를 무효화하지 못한다.
    """
    from app.core.steps import outdoor_structure_seed_step as mod

    step = object.__new__(mod.OutdoorStructureSeedStep)
    before = step._config_hash()
    monkeypatch.setattr(
        mod, "_regen_issue_policy_version", lambda: "regen_issue_v2_probe")
    after = step._config_hash()

    assert before != after, "정책 버전이 config_hash 축에 없다"


def test_schema_version_is_cross_pinned_with_the_manifest():
    """★CP shape 가 바뀌었으면 스키마 버전도 함께 올라간다.

    이번 변경은 감사 계약의 shape 변경이다 — seed_decision 이 변형 전용에서
    모든 성공 entry 의 필드가 됐고 정책·보류 필드가 새로 생겼다. config_hash
    만으로는 부족하다: 해시가 없는 옛 CP 에는 mismatch 검사가 적용되지 않는
    경로가 있어 스키마 버전이 따로 의미를 갖는다. 선례 = e18a776e(3→4).
    """
    from app.core.step_manifest import STEP_MANIFEST
    from app.core.steps import outdoor_structure_seed_step as mod

    assert mod.SCHEMA_VERSION >= 5, (
        "감사 계약 shape 를 바꿨는데 SCHEMA_VERSION 이 그대로다")
    assert (STEP_MANIFEST["outdoor_structure_seed"]["schema_version"]
            == mod.SCHEMA_VERSION), (
        "manifest 와 스텝의 schema_version 이 어긋났다 — 동기 의무")


def test_cp_records_the_policy_version_not_just_its_name():
    """★정책 **이름**만 남기면 그 뜻이 바뀐 뒤 CP 를 해석할 수 없다."""
    tree = ast.parse(SEED_STEP.read_text(encoding="utf-8"))
    for node in ast.walk(tree):
        if not isinstance(node, ast.Dict):
            continue
        keys = [k.value for k in node.keys
                if isinstance(k, ast.Constant) and isinstance(k.value, str)]
        if "seed_png_path" not in keys:
            continue
        assert "seed_regen_issue_policy" in keys
        assert "seed_regen_issue_policy_version" in keys, (
            "정책 버전이 CP 에 없다 — defer 의 뜻이 바뀌면 옛 기록을 "
            "어느 계약으로 읽어야 할지 알 수 없다")


def test_cp_audit_keys_survive_regardless_of_variants():
    """★감사 키가 CP whitelist 에서 탈락하지 않는지 소스로 잠근다.

    sidecar 영속은 변형 ON 에서만 일어난다. whitelist 가 사유·보류 목록을
    떨어뜨리면 OFF 경로에서 "왜 원본을 그대로 뒀는지" 알 길이 없다.
    """
    tree = ast.parse(SEED_STEP.read_text(encoding="utf-8"))
    entry_dicts = []
    for node in ast.walk(tree):
        if not isinstance(node, ast.Dict):
            continue
        keys = [k.value for k in node.keys
                if isinstance(k, ast.Constant) and isinstance(k.value, str)]
        if "seed_png_path" in keys:
            entry_dicts.append((node, keys))
    assert entry_dicts, "CP entry dict 를 찾지 못했다"

    for node, keys in entry_dicts:
        # ★entry 리터럴 안에 있어야 변형 여부와 무관하게 항상 기록된다.
        #  변형 ON 블록에서 나중에 붙이면 OFF 경로에서 통째로 사라진다.
        assert "seed_decision" in keys, (
            "seed_decision 이 CP entry 리터럴 밖에서 조건부로 붙는다 — "
            "변형 OFF 경로에서 감사 기록이 사라진다")
        decision = node.values[keys.index("seed_decision")]
        whitelist = {
            elt.value for elt in ast.walk(decision)
            if isinstance(elt, ast.Constant) and isinstance(elt.value, str)}
        for key in ("fix_skip_reason", "repair_mode", "regen_deferred_issues",
                    "regen_deferred_issue_count"):
            assert key in whitelist, (
                f"seed_decision whitelist 에 {key} 가 없다 — 왜 원본을 그대로 "
                f"뒀는지 CP 만으로 알 수 없다")
