"""W-M (2026-07-03) — 야외 plate 단계화: 순서 결정 + 배치도 프롬프트 계약.

LLM / image API call 0 (call_structured_fn 은 순수 mock DI). 커버:

- validate_plate_order: 순열(중복/누락/외부 id) + depends_on 제약 (순수).
- decide_group_plate_order: LLM ok / 검증 실패 → bounded retry → 성공 /
  소진 → 구조 순서 fallback / 예외 → fallback / 1장 이하 trivial(호출 0) /
  LLM 입력에 t2i_prompt **전문** 포함(자르기 금지 계약).
- W-M 배치도 프롬프트: 개구부 동그라미 기호 + 상층 레벨 시각 단서 +
  주변 컨텍스트 마진 + 텍스트 금지 유지 (build_aerial_establishing_prompt),
  AERIAL_FP_COHERENCE_GUIDANCE 개구부 정렬 문장, AERIAL_SITE_GUIDANCE
  기호 누출 가드, AERIAL_PROMPT_VERSION 4.x bump.
"""
from __future__ import annotations

from typing import Any, Dict, List

from app.modules.pipeline.location_aerial import (
    AERIAL_FP_COHERENCE_GUIDANCE,
    AERIAL_PROMPT_VERSION,
    AERIAL_SITE_GUIDANCE,
    PLATE_ORDER_SCHEMA,
    build_aerial_establishing_prompt,
    decide_group_plate_order,
    validate_plate_order,
)


def _cands(*bids: str, deps: Dict[str, List[str]] | None = None):
    deps = deps or {}
    return [
        {
            "bg_id": b,
            "location_label": f"label {b}",
            "location_summary": f"summary {b}",
            "surface_role": "exterior_plate",
            "state_label": "day",
            "depends_on": deps.get(b, []),
            "t2i_prompt": f"full t2i prompt body for {b} " + ("x" * 600),
        }
        for b in bids
    ]


# ─── validate_plate_order (순수) ───


def test_validate_plate_order_ok():
    assert validate_plate_order(
        ["B2", "B1"], ["B1", "B2"], {"B1": [], "B2": []}) is None


def test_validate_plate_order_rejects_non_permutation():
    # 누락
    assert validate_plate_order(["B1"], ["B1", "B2"], {}) is not None
    # 중복
    assert validate_plate_order(
        ["B1", "B1"], ["B1", "B2"], {}) is not None
    # 외부 id
    assert validate_plate_order(
        ["B1", "B9"], ["B1", "B2"], {}) is not None


def test_validate_plate_order_dependency():
    # B2 는 B1 의 상태변형 — B1 이 먼저 와야 한다.
    assert validate_plate_order(
        ["B2", "B1"], ["B1", "B2"], {"B2": ["B1"]}) is not None
    assert validate_plate_order(
        ["B1", "B2"], ["B1", "B2"], {"B2": ["B1"]}) is None
    # 후보 집합 밖 dep 은 제약에 안 접는다.
    assert validate_plate_order(
        ["B2", "B1"], ["B1", "B2"], {"B2": ["EXT"]}) is None


# ─── decide_group_plate_order ───


def test_decide_llm_order_ok():
    calls: List[Dict[str, Any]] = []

    def _fake(**kwargs):
        calls.append(kwargs)
        return {"order": ["B2", "B1"], "first_reason": "widest view"}

    res = decide_group_plate_order(
        group_id="g1",
        candidates=_cands("B1", "B2"),
        call_structured_fn=_fake,
    )
    assert res["order"] == ["B2", "B1"]
    assert res["order_source"] == "llm"
    assert res["first_reason"] == "widest view"
    assert res["error"] == ""
    assert len(calls) == 1
    assert calls[0]["step"] == "background_plate_order"
    assert calls[0]["response_schema"] is PLATE_ORDER_SCHEMA
    # LLM 에 t2i 전문이 잘리지 않고 들어간다 (자르기 금지 계약).
    assert ("full t2i prompt body for B1 " + "x" * 600) in (
        calls[0]["user_prompt"])


def test_decide_invalid_then_valid_retry():
    outs = [
        {"order": ["B1"], "first_reason": "missing one"},        # 순열 위반
        {"order": ["B1", "B2"], "first_reason": "ok"},
    ]
    calls: List[Dict[str, Any]] = []

    def _fake(**kwargs):
        calls.append(kwargs)
        return outs[len(calls) - 1]

    res = decide_group_plate_order(
        group_id="g1",
        candidates=_cands("B1", "B2"),
        call_structured_fn=_fake,
    )
    assert res["order"] == ["B1", "B2"]
    assert res["order_source"] == "llm"
    assert res["attempts"] == 2


def test_decide_exhausted_falls_back_structural():
    def _fake(**kwargs):
        return {"order": ["B9", "B1"], "first_reason": "bad"}  # 외부 id

    res = decide_group_plate_order(
        group_id="g1",
        candidates=_cands("B1", "B2"),
        call_structured_fn=_fake,
        max_retries=2,
    )
    # fallback = 입력(구조) 순서 그대로.
    assert res["order"] == ["B1", "B2"]
    assert res["order_source"] == "fallback_structural"
    assert res["error"]


def test_decide_exception_falls_back():
    def _boom(**kwargs):
        raise RuntimeError("llm down")

    res = decide_group_plate_order(
        group_id="g1",
        candidates=_cands("B1", "B2"),
        call_structured_fn=_boom,
    )
    assert res["order"] == ["B1", "B2"]
    assert res["order_source"] == "fallback_structural"
    assert "llm down" in res["error"]


def test_decide_dependency_violation_rejected():
    def _fake(**kwargs):
        return {"order": ["B2", "B1"], "first_reason": "wrong"}

    res = decide_group_plate_order(
        group_id="g1",
        candidates=_cands("B1", "B2", deps={"B2": ["B1"]}),
        call_structured_fn=_fake,
        max_retries=1,
    )
    assert res["order"] == ["B1", "B2"]
    assert res["order_source"] == "fallback_structural"


def test_decide_single_candidate_trivial_no_call():
    calls: List[Dict[str, Any]] = []

    def _fake(**kwargs):
        calls.append(kwargs)
        return {}

    res = decide_group_plate_order(
        group_id="g1",
        candidates=_cands("B1"),
        call_structured_fn=_fake,
    )
    assert res["order"] == ["B1"]
    assert res["order_source"] == "trivial"
    assert calls == []


# ─── W-M 배치도 프롬프트 계약 ───


def test_aerial_prompt_wm_contract():
    p = build_aerial_establishing_prompt(members=[
        {"label": "outdoor yard", "summary": "an open yard",
         "is_indoor": False},
    ])
    # 개구부 동그라미 기호 — 문/창/레벨 진입점, 실물 경계선 위에만.
    assert "hollow CIRCLE" in p
    assert "doorway" in p and "window" in p
    assert "never floating in open space" in p
    # 상층 레벨 시각 단서 — 지지 건물 지붕 외곽선 = 그 레벨의 경계,
    # 지상 단독 부지 금지.
    assert "ON TOP of a building" in p
    assert "roof-edge/parapet" in p
    assert "never draw an upper level as if it were its own" in p
    # 주변 컨텍스트 마진 (조금 넓게).
    assert "a little WIDER" in p
    # 텍스트/화살표 금지는 유지, 허용 기호는 명시 한정.
    assert "NO text, letters, numbers" in p
    assert "ONLY marker symbols allowed" in p
    # canary 후속: 개구부 circle 외 원형 장식/무늬 발명 금지(배치도의
    # 장식성 원형 패턴이 plate 표면 문양으로 전이된 사례 차단).
    assert "ONLY circular marks anywhere on the plan" in p


def test_fp_coherence_guidance_opening_alignment():
    # mixed 그룹 aerial 생성 시 개구부 기호가 실내 fp 의 문/창 위치와
    # 일치해야 한다는 정합 문장.
    assert "COINCIDE" in AERIAL_FP_COHERENCE_GUIDANCE
    assert "opening markers" in AERIAL_FP_COHERENCE_GUIDANCE


def test_site_guidance_symbol_leakage_guard():
    # plate 렌더 쪽 기호 누출 가드 — 동그라미는 도면 표기 전용, 렌더에
    # 원/마커 오버레이 금지 + 표면 문양/데칼 재현 금지 + 그 위치에 실물
    # 개구부.
    assert "drafting notation" in AERIAL_SITE_GUIDANCE
    assert "never draw circles" in AERIAL_SITE_GUIDANCE
    assert "painted pattern, decal or decoration" in AERIAL_SITE_GUIDANCE
    assert "render the real door" in AERIAL_SITE_GUIDANCE


def test_aerial_prompt_version_bumped_to_4x():
    # W-M 의미 변경 — 소비자 캐시 무효화 계약 (4.x).
    assert AERIAL_PROMPT_VERSION.split(".", 1)[0] == "4"
