"""outdoor_place_canon — 장소 캐논 자산 생성 (W22 직행 체인 ②, s29 역방향 체인).

outdoor_place_spec 스펙(그룹당 1개)에서 캐논 자산 2장을 생성한다:

  [1] rolls   : nb2 무참조 3롤 (1:1, ID-free 프롬프트 — 마커 코드 0)
  [2] judge   : GPT+Gemini VLM 0-10 이중 판정 → 합산 최댓값, 동점 시
                Gemini ranking 우선 (s29 계약)
  [3] fix     : 양 모델 결함 critique concat → 결함 있으면 nb2 i2i 1콜
                수정, 결함 0 이면 생략 → 실사 마스터 (룩 SOT)
  [4] map     : gpt-image-2 edit 재투영 — 참조=[실사 마스터(+스타일 FP)],
                순수 평면 강제 + 코드 포함 마커 범례 → 탑다운 맵 (배치 SOT)

모델 강제=call_structured project_config[step]["model"] 오버라이드
("gpt"/"gemini-pro"), 판정 콜은 enable_fallback=False — Gemini 판정이
safety fallback 으로 GPT 에 silent 전환되는 것을 막는다 (이중 판정 의미 보존).
설계: docs/w22-outdoor-canon-direct-compose-design-20260709/design.md
"""

import base64
import logging
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple

from app.core.errors import AppError
from app.modules.prompt_loader import load_prompt, load_schema

logger = logging.getLogger(__name__)

_MODULE = "outdoor_place_canon"

PROMPT_VERSION_MAP = {
    "1": "1.202607100111",
    # 2 (2026-07-11): 시간 중립 계약 — 마스터=영속 기본 상태만(상태물 금지)
    # + critique 에 상태물=결함 규칙. 스펙 v2(시간 불변 마커)와 쌍.
    "2": "2.202607110040",
    # 3 (2026-07-13, s40 레시피 마스터 v2 교정): FACADE & LIVED-IN 절 신설 —
    # 외장·창호·생활 설비를 WORLD FACTS 가 선언한 지역·시대의 실물 전형에
    # 위임(색·자재 하드코딩 금지, 생활 흔적 층층이). 실험 실증: 환경 생활감
    # 밀도가 하류 플레이트·스틸까지 관통. 미배선 보존 — selector 는 step
    # PROMPT_VERSION.
    "3": "3.202607132300",
    # 4 (2026-07-25 사용자 지적 3건): ①REGIONAL AUTHENTICITY 절 —
    # WORLD FACTS 의 지역·시대가 각 시설의 실제 형태·표기·번호판·노후
    # 까지 지배(무국적/신품 카탈로그 룩 금지). 세계관 자체가 이 슬롯에
    # 처음 배선된다(step 이 world_facts_block="" 하드코딩이었음).
    # ②RESTRAINT 절 — 목록에 없는 구조물·식재·표지·차량을 덧붙이지
    # 않는다(과잉 복잡도 금지, "목록이 침묵하면 부지는 소박하다").
    # ③map=정투영+주축 정렬+시트 텍스트 금지(존 라벨은 짧은 이름만,
    # 요소 설명문 금지) — 좁은 대각 띠 구도가 마커 작화를 3회 소진시킨
    # 실측 교정. 판정/critique/fix 스템은 v3 승계.
    "4": "4.202607260153",
}

CANDIDATE_LABELS = ("A", "B", "C")
JUDGE_STEP = "outdoor_place_canon_judge"
CRITIQUE_STEP = "outdoor_place_canon_critique"
JUDGE_MODELS = ("gpt", "gemini-pro")


class CanonError(AppError):
    def __init__(self, stage: str, message: str):
        super().__init__(
            code=f"step.outdoor_place_canon.{stage}",
            message=message,
            status_code=422,
        )


def resolve_prompt_version(selector: str) -> str:
    try:
        return PROMPT_VERSION_MAP[selector]
    except KeyError:
        raise ValueError(
            f"unknown outdoor_place_canon prompt version selector {selector!r} "
            f"(known: {sorted(PROMPT_VERSION_MAP)})"
        )


# ── 프롬프트 조립 (결정론) ───────────────────────────────────────────


def build_elements_block(spec: Dict[str, Any]) -> str:
    """실사 프롬프트용 ID-free 범례 — 코드 없이 서술명+배치문만 (s29 계약)."""
    lines = [
        f"- {it.get('name_en', '')} — {it.get('placement_en', '')}"
        for it in spec.get("items", []) or []
    ]
    return "\n".join(lines)


def build_markers_block(spec: Dict[str, Any]) -> str:
    """맵 프롬프트용 코드 포함 범례 — 맵에는 마커가 필요하다 (s29 계약)."""
    lines = [
        f"- ({it.get('code', '')}) {it.get('name_en', '')} — "
        f"{it.get('placement_en', '')}"
        for it in spec.get("items", []) or []
    ]
    return "\n".join(lines)


def assert_id_free(text: str, codes: Sequence[str], where: str) -> None:
    """ID-free 프롬프트에 스펙 마커 코드가 새지 않았는지 결정론 봉인.

    단어 경계 매칭 — raw substring 은 코드가 다른 토큰의 일부일 때
    false-positive (Codex MINOR).
    """
    import re as _re

    leaked = [
        c for c in codes
        if c and _re.search(rf"\b{_re.escape(c)}\b", text)
    ]
    if leaked:
        raise CanonError(
            "id_free_violation",
            f"{where} 프롬프트에 마커 코드 누출: {leaked}",
        )


def _fill(template: str, blocks: Dict[str, str]) -> str:
    out = template
    for key, val in blocks.items():
        out = out.replace("{" + key + "}", val)
    return out


def build_photo_prompt(
    template: str,
    spec: Dict[str, Any],
    scale_block: str = "",
    world_facts_block: str = "",
) -> str:
    prompt = _fill(template, {
        "layout_narration": spec.get("layout_narration_en", ""),
        "elements_block": build_elements_block(spec),
        "scale_block": scale_block.strip()
        or "(not specified — use plausible, ordinary proportions)",
        "world_facts_block": world_facts_block.strip() or "(none)",
    })
    codes = [it.get("code", "") for it in spec.get("items", []) or []]
    assert_id_free(prompt, codes, "photo")
    return prompt


def build_fix_prompt(template: str, issues: Sequence[Dict[str, Any]]) -> str:
    issues_block = "\n".join(
        f"- {i.get('fix_en', '')}" for i in issues if (i.get("fix_en") or "").strip()
    )
    return _fill(template, {"issues_block": issues_block})


def build_map_prompt(
    template: str,
    spec: Dict[str, Any],
    style_ref_attached: bool,
) -> str:
    style_ref_note = (
        "Match the drawing style of the attached second reference image "
        "(a floor-plan drawing): same line weight, palette and flatness."
        if style_ref_attached else ""
    )
    return _fill(template, {
        "markers_block": build_markers_block(spec),
        "zone_labels_block": "\n".join(
            f"- {z}" for z in spec.get("zone_labels_en", []) or []
        ),
        "style_ref_note": style_ref_note,
    })


# ── VLM 이중 판정 (결정론 합산) ──────────────────────────────────────


def _png_part(png_bytes: bytes) -> Dict[str, Any]:
    b64 = base64.b64encode(png_bytes).decode()
    return {"type": "image_url",
            "image_url": {"url": f"data:image/png;base64,{b64}"}}


def build_judge_user(
    photo_prompt: str, candidates: Sequence[Tuple[str, bytes]]
) -> List[Dict[str, Any]]:
    parts: List[Dict[str, Any]] = [
        {"type": "text", "text": "GENERATION PROMPT:\n" + photo_prompt}
    ]
    for label, png in candidates:
        parts.append({"type": "text", "text": f"Candidate {label}:"})
        parts.append(_png_part(png))
    return parts


def aggregate_judgement(
    results_by_model: Dict[str, Dict[str, Any]],
    labels: Sequence[str] = CANDIDATE_LABELS,
    tie_break_model: str = "gemini-pro",
) -> Dict[str, Any]:
    """양 모델 score 합산 최댓값 선택, 동점 시 tie_break_model ranking 우선.

    s29 계약: `min(tied, key=gemini_ranking.index)`.
    """
    totals: Dict[str, int] = {label: 0 for label in labels}
    for result in results_by_model.values():
        for v in result.get("verdicts", []) or []:
            label = v.get("label")
            if label in totals:
                totals[label] += int(v.get("score", 0))
    top = max(totals.values())
    tied = [label for label in labels if totals[label] == top]
    if len(tied) == 1:
        winner = tied[0]
    else:
        tb_ranking = (
            results_by_model.get(tie_break_model, {}).get("ranking") or list(labels)
        )
        def _rank(label: str) -> int:
            return tb_ranking.index(label) if label in tb_ranking else len(tb_ranking)
        winner = min(tied, key=_rank)
    return {"totals": totals, "winner": winner}


def collect_issues(results_by_model: Dict[str, Dict[str, Any]]) -> List[Dict[str, Any]]:
    """양 모델 critique 결함 단순 concat — 중복 판단 없음 (s29 계약)."""
    out: List[Dict[str, Any]] = []
    for model in sorted(results_by_model):
        out.extend(results_by_model[model].get("issues", []) or [])
    return out


def _dual_model_call(
    call_structured_fn: Callable[..., Dict[str, Any]],
    *,
    step: str,
    system: str,
    user_parts: List[Dict[str, Any]],
    schema: Dict[str, Any],
    project_config: Optional[Dict[str, Any]],
    opik_metadata: Optional[Dict[str, Any]],
    models: Sequence[str] = JUDGE_MODELS,
) -> Dict[str, Dict[str, Any]]:
    """같은 멀티모달 콜을 모델 alias 별로 실행 — project_config[step][model] 강제.

    enable_fallback=False: Gemini 판정이 safety fallback 으로 GPT 에 silent
    전환되면 이중 판정 의미가 깨진다. 실패는 위로 전파(그룹 단위 fail-safe).
    """
    results: Dict[str, Dict[str, Any]] = {}
    for model in models:
        cfg = dict(project_config or {})
        cfg[step] = {**(cfg.get(step) or {}), "model": model}
        results[model] = call_structured_fn(
            step,
            system,
            user_parts,
            schema,
            project_config=cfg,
            schema_name=step,
            opik_metadata=opik_metadata,
            enable_fallback=False,
        )
    return results


# ── 그룹 체인 실행 ───────────────────────────────────────────────────


def run_outdoor_place_canon_group(
    *,
    group_id: str,
    spec: Dict[str, Any],
    scale_block: str,
    world_facts_block: str,
    out_dir: Path,
    nb2_generate_fn: Callable[..., bytes],
    gpt_edit_fn: Callable[..., bytes],
    style_fp_path: Optional[Path] = None,
    call_structured_fn: Optional[Callable[..., Dict[str, Any]]] = None,
    project_config: Optional[Dict[str, Any]] = None,
    opik_metadata: Optional[Dict[str, Any]] = None,
    prompt_version: str = "1",
    capture_fn: Optional[Callable[..., Any]] = None,
) -> Dict[str, Any]:
    """그룹 1개의 캐논 체인 [1]rolls→[2]judge→[3]fix→[4]map 실행.

    - nb2_generate_fn(prompt, labeled_references=None, aspect_ratio="1:1") -> bytes
    - gpt_edit_fn(prompt, ref_paths: List[Path]) -> bytes
    - capture_fn: 중간물 포착 (capture_generated_image 호환, None 이면 생략)
    실패는 CanonError 로 전파 — 스텝이 그룹 단위 격리.
    """
    if call_structured_fn is None:
        from app.modules.llm.llm_client import call_structured

        call_structured_fn = call_structured

    resolved = resolve_prompt_version(prompt_version)
    photo_template = load_prompt(_MODULE, "photo_prompt", version=resolved)
    judge_system = load_prompt(_MODULE, "judge_system", version=resolved)
    judge_schema = load_schema(_MODULE, "judge_schema", version=resolved)
    critique_system = load_prompt(_MODULE, "critique_system", version=resolved)
    critique_schema = load_schema(_MODULE, "critique_schema", version=resolved)
    fix_template = load_prompt(_MODULE, "fix_prompt", version=resolved)
    map_template = load_prompt(_MODULE, "map_prompt", version=resolved)

    out_dir.mkdir(parents=True, exist_ok=True)

    # [1] rolls — nb2 무참조 3롤 (ID-free 봉인은 build_photo_prompt 내부)
    photo_prompt = build_photo_prompt(
        photo_template, spec, scale_block, world_facts_block
    )
    candidates: List[Tuple[str, bytes]] = []
    for i, label in enumerate(CANDIDATE_LABELS):
        try:
            png = nb2_generate_fn(photo_prompt, aspect_ratio="1:1")
        except Exception as exc:  # noqa: BLE001
            raise CanonError("roll", f"후보 {label} 생성 실패: {exc}") from exc
        cand_path = out_dir / f"canon_{group_id}_cand_{label}.png"
        cand_path.write_bytes(png)
        candidates.append((label, png))
        if capture_fn is not None:
            capture_fn(
                png,
                role="outdoor_canon_candidate",
                candidate_index=i,
                prompt=photo_prompt,
                pipeline_metadata={"group_id": group_id, "label": label},
            )

    # [2] judge — GPT+Gemini 합산, 동점 Gemini
    judge_user = build_judge_user(photo_prompt, candidates)
    try:
        judge_results = _dual_model_call(
            call_structured_fn,
            step=JUDGE_STEP,
            system=judge_system,
            user_parts=judge_user,
            schema=judge_schema,
            project_config=project_config,
            opik_metadata=opik_metadata,
        )
    except Exception as exc:  # noqa: BLE001
        raise CanonError("judge", f"이중 판정 실패: {exc}") from exc
    agg = aggregate_judgement(judge_results)
    winner_label = agg["winner"]
    winner_png = dict(candidates)[winner_label]

    # [3] critique + fix — 결함 0 이면 수정 생략 (s29 계약)
    critique_user: List[Dict[str, Any]] = [
        {"type": "text", "text": "GENERATION PROMPT:\n" + photo_prompt},
        {"type": "text", "text": "PHOTOGRAPH:"},
        _png_part(winner_png),
    ]
    try:
        critique_results = _dual_model_call(
            call_structured_fn,
            step=CRITIQUE_STEP,
            system=critique_system,
            user_parts=critique_user,
            schema=critique_schema,
            project_config=project_config,
            opik_metadata=opik_metadata,
        )
    except Exception as exc:  # noqa: BLE001
        raise CanonError("critique", f"결함 취합 실패: {exc}") from exc
    issues = collect_issues(critique_results)

    fix_applied = False
    master_png = winner_png
    if issues:
        fix_prompt = build_fix_prompt(fix_template, issues)
        try:
            master_png = nb2_generate_fn(
                fix_prompt,
                labeled_references=[(
                    "PHOTOGRAPH TO FIX — change only the listed issues, "
                    "keep everything else identical.",
                    winner_png,
                )],
                aspect_ratio="1:1",
            )
            fix_applied = True
        except Exception as exc:  # noqa: BLE001
            raise CanonError("fix", f"i2i 수정 실패: {exc}") from exc

    master_path = out_dir / f"canon_{group_id}_master.png"
    master_path.write_bytes(master_png)

    # [4] map — gpt 재투영 (참조=[마스터(+스타일 FP)], 코드 포함 범례)
    style_path = Path(style_fp_path) if style_fp_path else None
    style_attached = bool(style_path is not None and style_path.exists())
    map_prompt = build_map_prompt(map_template, spec, style_attached)
    ref_paths: List[Path] = [master_path]
    if style_attached and style_path is not None:
        ref_paths.append(style_path)
    try:
        map_png = gpt_edit_fn(map_prompt, ref_paths)
    except Exception as exc:  # noqa: BLE001
        raise CanonError("map", f"맵 재투영 실패: {exc}") from exc
    map_path = out_dir / f"canon_{group_id}_map.png"
    map_path.write_bytes(map_png)

    return {
        "status": "ok",
        "master_png_path": str(master_path),
        "map_png_path": str(map_path),
        "selected_candidate": winner_label,
        "judge_totals": agg["totals"],
        "issues_count": len(issues),
        "issues": issues,
        "fix_applied": fix_applied,
        "style_fp_attached": style_attached,
        "photo_prompt": photo_prompt,
        "map_prompt": map_prompt,
    }
