"""OutdoorPlaceCanonStep — 장소 캐논 자산 생성 (W22 직행 체인 ②, order 21.78).

outdoor_place_spec 스펙이 있는 그룹당 캐논 자산 2장(실사 마스터=룩 SOT,
탑다운 맵=배치 SOT)을 s29 역방향 체인으로 생성·UPSERT 한다.

  data.groups[<group_id>] = {
      status, master_png_path, map_png_path, master_asset_id, map_asset_id,
      selected_candidate, judge_totals, issues_count, fix_applied,
      style_fp_attached,
  }
  실패 그룹 = {"status": "failed", "error": str}  (그룹 단위 격리, raise 안 함)

ImageAsset: asset_type="place_canon", entity_id=<group_id>,
variant_type="master"|"map" (UPSERT 매치 키),
pipeline_role="outdoor_canon_photo"|"outdoor_canon_map",
map.input_image_ids=[master asset id]. 후보 3롤은 generation_context scope
안에서 capture(is_intermediate) 보존.
설계: docs/w22-outdoor-canon-direct-compose-design-20260709/design.md
"""

import json
import logging
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple

from app.core.step_runner import StepRunner

logger = logging.getLogger(__name__)

SCHEMA_VERSION = 1
# 2026-07-25 사용자 지적: v3(FACADE & LIVED-IN)은 저작만 되고
# 미배선이었다(selector 가 "2"). v4=지역 진정성·과잉 복잡도 억제·
# 맵 정투영/주축 정렬까지 포함 — 함께 배선한다.
PROMPT_VERSION = "4"  # 07-11 시간 중립 + 07-13 생활감 + 07-25 지역성
# 마커 맵 엔진 — 2026-08-25 사용자 지시로 **설정**이 정한다(종전 소스 상수
# `"gpt-image-2"`). 실패하면 후보 모델로 물러난다 — marker_map_engine 참조.
MAX_IMAGE_ATTEMPTS = 2


def _marker_map_model() -> str:
    from app.core.config import settings

    return str(
        getattr(settings, "marker_map_image_model", "") or "gpt-image-2.5-sunburst")


def _resolve_openai_client():
    """T11 FloorPlanRenderStep 패턴 — settings key 명시 전달."""
    from app.core.openai_keys import openai_client
    from app.core.config import settings
    return openai_client(
        timeout=float(settings.llm_timeout_image_gen),
    )


def collect_scale_block(
    fp_prompt_cp: Optional[Dict[str, Any]], group_id: str
) -> str:
    """그룹의 floor_plan_prompt scale_guideline 취합 (v10+, 없으면 빈 문자열)."""
    fps = (fp_prompt_cp or {}).get("data", {}).get("floor_plans", {}) or {}
    guidelines: List[str] = []
    for entry in fps.values():
        if not isinstance(entry, dict):
            continue
        if entry.get("group_id") != group_id:
            continue
        guideline = (entry.get("scale_guideline") or "").strip()
        if guideline and guideline not in guidelines:
            guidelines.append(guideline)
    return "\n".join(guidelines)


def pick_style_fp_path(
    fp_render_cp: Optional[Dict[str, Any]],
    fp_prompt_cp: Optional[Dict[str, Any]],
    group_id: str,
) -> Optional[Path]:
    """스타일 참조 FP 선택 — 같은 그룹 fp 우선, 없으면 첫 completed fp.

    fp_id 정렬로 결정론 보장. 전부-야외 프로젝트(FP 0)는 None (스타일 텍스트
    계약만으로 작도 — s27 검증 경로).
    """
    rendered = (fp_render_cp or {}).get("data", {}).get("floor_plans", {}) or {}
    prompt_fps = (fp_prompt_cp or {}).get("data", {}).get("floor_plans", {}) or {}
    ok_items: List[Tuple[str, Path]] = []
    for fid in sorted(rendered):
        entry = rendered[fid]
        if not isinstance(entry, dict) or entry.get("status") != "ok":
            continue
        png = entry.get("png_path") or ""
        if png and Path(png).exists():
            ok_items.append((fid, Path(png)))
    if not ok_items:
        return None
    for fid, path in ok_items:
        if (prompt_fps.get(fid) or {}).get("group_id") == group_id:
            return path
    return ok_items[0][1]


class OutdoorPlaceCanonStep(StepRunner):
    """장소 캐논 자산 (W22 ②) — nb2 3롤→이중 판정→수정→gpt 재투영 맵."""

    def _config_hash(self) -> str:
        import hashlib
        import json as _json

        from app.core.config import settings

        payload = {
            "background_mode": settings.background_mode,
            "nb2_model": settings.gemini_image_model,
            # ★기본 엔진만 접는다. 후보는 **실패했을 때만** 발화하므로
            #  지문에 넣으면 한 번도 안 쓰인 후보를 바꿔도 전량이 무효화된다.
            "map_model": _marker_map_model(),
            "schema_version": SCHEMA_VERSION,
            "prompt_version": PROMPT_VERSION,
            # direct ON 경로에선 기존 상수 True 와 byte-identical (BLOCKING-4:
            # map 게이트 분리 — 실제 게이트 상태 스탬프).
            "outdoor_direct_compose_enabled": bool(
                getattr(settings, "outdoor_direct_compose_enabled", False)
            ),
        }
        if bool(getattr(settings, "outdoor_map_conti_enabled", False)):
            payload["outdoor_map_conti_enabled"] = True
        # Stage D BLOCKING-1: lane ON 시 canon 은 map_marker 그룹만 대상
        # (target 선정 로직 변경) — ON 시만 스탬프
        from app.core.applicability import outdoor_lane_pipe_on

        if outdoor_lane_pipe_on():
            payload["outdoor_lane_pipe_enabled"] = True
        return hashlib.sha256(
            _json.dumps(payload, sort_keys=True).encode("utf-8")
        ).hexdigest()[:16]

    def _load_prev_checkpoint(self, step_id: str) -> Optional[Dict[str, Any]]:
        from app.core.config import settings

        cp = (
            Path(settings.projects_dir)
            / self.project_id / "checkpoints" / "episodes"
            / self.episode_id / step_id / "manifest.json"
        )
        if cp.exists():
            try:
                return json.loads(cp.read_text(encoding="utf-8"))
            except Exception as exc:  # noqa: BLE001
                logger.warning("outdoor_place_canon: %s 로드 실패: %s", step_id, exc)
        return None

    # ── 이미지 생성 어댑터 (bounded retry + sanitize) ─────────────

    def _make_nb2_fn(self):
        from app.core.image_call_budget import reserve_current_call
        from app.modules.llm.gemini_image_client import GeminiImageClient
        from app.modules.llm.image_send_state import ImageSubmissionUnknown
        from app.modules.llm.safety import sanitize_for_safety

        # ★계보를 실어 준다. `GeminiImageClient` 는 `set_context` 로 받은
        #   것만 `log_llm_call` 에 넘긴다 — 안 부르면 그 호출은 DB 에
        #   `project_id`·`episode_id` 가 NULL 로 남아 어느 작품 것인지
        #   모른다(2026-08-25 실측: 이 스텝 4건이 그랬다).
        client = GeminiImageClient().set_context(
            project_id=self.project_id,
            episode_id=self.episode_id,
            step_name="outdoor_place_canon",
        )

        def nb2_generate(prompt, labeled_references=None, aspect_ratio="1:1"):
            current = prompt
            last_exc: Optional[Exception] = None
            for attempt in range(1, MAX_IMAGE_ATTEMPTS + 1):
                reserve_current_call(source="outdoor_place_canon.nb2")
                try:
                    png, _ms = client.generate_image(
                        current,
                        labeled_references=labeled_references,
                        aspect_ratio=aspect_ratio,
                    )
                    return png
                except ImageSubmissionUnknown:
                    # ★**다시 보내지 않는다** (2026-08-26 자체 리뷰).
                    #  클라이언트가 「보냈는지 모른다」로 멈춘 것을 여기서
                    #  순화본으로 다시 보내면, 클라이언트 안에서 막은 중복
                    #  결제가 **한 층 위에서 그대로 열린다.** 순화는 검열
                    #  대응이지 전송 불명 대응이 아니다.
                    raise
                except Exception as exc:  # noqa: BLE001
                    last_exc = exc
                    logger.warning(
                        "outdoor_place_canon nb2 attempt=%d 실패: %s", attempt, exc
                    )
                    current = sanitize_for_safety(current)
            raise RuntimeError(f"nb2 생성 실패 (attempts={MAX_IMAGE_ATTEMPTS}): {last_exc}")

        return nb2_generate

    def _make_gpt_edit_fn(self):
        """장소 마커 맵을 그린다 — 실패하면 **후보 모델로 물러난다**.

        2026-08-25 사용자 지시. 종전에는 소스 상수 하나(`gpt-image-2`)뿐이라
        그 모델이 계통적으로 틀리면 손쓸 데가 없었다(콘티 lane 마커 맵이
        같은 날 그렇게 멈췄다). 후보가 비면 목록이 한 칸뿐이라 종전과
        같은 횟수로 돈다.
        """
        from app.core.image_call_budget import reserve_current_call
        from app.modules.llm.gpt_image_primitive import call_gpt_image_bytes
        from app.modules.llm.image_send_state import ImageSubmissionUnknown
        from app.modules.llm.safety import sanitize_for_safety
        from app.modules.pipeline.marker_map_engine import (
            marker_map_models,
            draw_marker_map_via_model,
        )

        client = _resolve_openai_client()

        def gpt_edit(prompt, ref_paths):
            models = marker_map_models()
            last_exc: Optional[Exception] = None
            for mi, model in enumerate(models):
                # 모델이 바뀌면 문안을 처음 것으로 되돌린다 — 앞 모델에서
                # 순화된 문안이 새 모델의 첫 시도를 오염시키지 않게.
                current = prompt
                if mi:
                    logger.warning(
                        "outdoor_place_canon: 맵 재투영이 %s 로 안 됐다 — "
                        "%s 로 바꿔 다시 그린다", models[mi - 1], model)
                for attempt in range(1, MAX_IMAGE_ATTEMPTS + 1):
                    reserve_current_call(
                        source="outdoor_place_canon.map_edit")
                    try:
                        return draw_marker_map_via_model(
                            model=model, prompt=current,
                            ref_paths=list(ref_paths),
                            role="outdoor_canon_map",
                            capture_context={
                                "project_id": self.project_id,
                                "episode_id": self.episode_id,
                                "operation_type": "outdoor_place_canon",
                            },
                            openai_render=lambda m, _p=current: (
                                call_gpt_image_bytes(
                                    client, mode="edit", prompt=_p,
                                    ref_paths=[str(x) for x in ref_paths],
                                    call_kwargs={
                                        "model": m, "size": "1024x1024",
                                        "quality": "high", "n": 1,
                                    })),
                        )
                    except ImageSubmissionUnknown:
                        # ★**다시 보내지 않는다** (2026-08-26 Codex 재리뷰
                        #  BLOCK). `nb2_generate` 와 같은 자리인데 여기만
                        #  빠져 있었다. 기본 모델이 nb2 라 이 갈래는
                        #  `render_via_gemini` → `GeminiImageClient.
                        #  generate_image` 로 곧장 간다 — 클라이언트가
                        #  「보냈는지 모른다」로 멈춘 것을 여기서 삼키면
                        #  같은 맵 한 칸을 **모델 수 × attempt 수** 만큼
                        #  다시 산다. 두 겹 루프라 곱해진다.
                        raise
                    except Exception as exc:  # noqa: BLE001
                        last_exc = exc
                        logger.warning(
                            "outdoor_place_canon map model=%s attempt=%d "
                            "실패: %s", model, attempt, exc
                        )
                        current = sanitize_for_safety(current)
            raise RuntimeError(
                f"맵 재투영 실패 — 모델 {len(models)}개 × "
                f"{MAX_IMAGE_ATTEMPTS}회 ({', '.join(models)}): {last_exc}")

        return gpt_edit

    # ── ImageAsset UPSERT ────────────────────────────────────────

    def _upsert_canon_asset(
        self,
        *,
        group_id: str,
        variant_type: str,       # "master" | "map"
        pipeline_role: str,
        abs_png_path: str,
        prompt_used: str,
        generation_model: str,
        input_image_ids: List[str],
    ) -> str:
        from app.core.file_paths import to_relative_image_path
        from app.models.project import ImageAsset
        from app.services.image_capture.annotate import annotate_generated_asset

        rel_path = to_relative_image_path(abs_png_path)
        existing = (
            self.db.query(ImageAsset)
            .filter_by(
                project_id=self.project_id,
                episode_id=self.episode_id,
                asset_type="place_canon",
                entity_id=group_id,
                variant_type=variant_type,
            )
            .first()
        )
        if existing:
            existing.file_path = rel_path
            existing.prompt_used = prompt_used
            existing.status = "generated"
            existing.generation_model = generation_model
            row = existing
        else:
            row = ImageAsset(
                id=str(uuid.uuid4()),
                project_id=self.project_id,
                episode_id=self.episode_id,
                asset_type="place_canon",
                entity_id=group_id,
                variant_type=variant_type,
                file_path=rel_path,
                prompt_used=prompt_used,
                generation_model=generation_model,
                status="generated",
                created_at=datetime.now(timezone.utc),
            )
            self.db.add(row)
        annotate_generated_asset(
            row,
            pipeline_role=pipeline_role,
            input_image_ids=input_image_ids,
        )
        return str(row.id)

    # ── 실행 ─────────────────────────────────────────────────────

    def _execute(self, mode: str = "resume") -> Dict[str, Any]:
        from app.core.config import settings

        def _empty() -> Dict[str, Any]:
            return {
                "applicable_count": 0,
                "completed_count": 0,
                "failed_count": 0,
                "schema_version": SCHEMA_VERSION,
                "config_hash": self._config_hash(),
                "data": {"groups": {}},
            }

        if settings.background_mode not in {"on", "floor_plan_anchored"}:
            return _empty()
        from app.core.applicability import outdoor_lane_pipe_on

        if not (
            getattr(settings, "outdoor_direct_compose_enabled", False)
            or getattr(settings, "outdoor_map_conti_enabled", False)
            # Stage D BLOCKING-1: lane pipe 도 spec 소비자 — manifest 게이트
            # (if_outdoor_direct_or_map_or_lane)와 동일 predicate
            or outdoor_lane_pipe_on()
        ):
            return _empty()

        spec_cp = self._load_prev_checkpoint("outdoor_place_spec")
        spec_groups = (spec_cp or {}).get("data", {}).get("groups", {}) or {}
        target_groups = {
            gid: entry for gid, entry in spec_groups.items()
            if isinstance(entry, dict) and entry.get("spec")
        }
        if not target_groups:
            logger.info("outdoor_place_canon: 스펙 있는 그룹 없음 — no-op")
            return _empty()

        # ── Stage D BLOCKING-1: lane pipe ON 이면 map_marker 그룹만 대상
        # (structure_plate 전용 그룹=seed 파이프 — photo canon 누출·비용
        # 방지). lane plan CP 부재 시 기존 all-groups fallback. persisted
        # plan 은 authoritative 현재 선택 샷으로 소비 직전 재검증. ──────
        lane_skipped: Dict[str, Any] = {}
        if outdoor_lane_pipe_on():
            lane_cp = self._load_prev_checkpoint("outdoor_lane_plan")
            lane_groups = (
                (lane_cp or {}).get("data", {}).get("groups", {}) or {}
            )
            if lane_cp is not None:
                from app.core.errors import AppError
                from app.modules.pipeline.outdoor_lane_plan import (
                    build_group_shot_reconstructor,
                    revalidate_persisted_plan,
                    validate_group_parity,
                )

                scene_cp_for_lane = self._load_prev_checkpoint("scene_save")
                lane_scene_texts: Dict[int, str] = {}
                for seg in (scene_cp_for_lane or {}).get("data", {}).get(
                        "segments", []) or []:
                    l_si = seg.get("scene_index")
                    if isinstance(l_si, int):
                        lane_scene_texts[l_si] = seg.get("text") or ""
                reconstruct = build_group_shot_reconstructor(
                    staging_cp=self._load_prev_checkpoint("shot_staging"),
                    validator_cp=self._load_prev_checkpoint(
                        "shot_validator"),
                    selection_cp=self._load_prev_checkpoint(
                        "shot_selection"),
                    director_cp=self._load_prev_checkpoint(
                        "scene_director"),
                )
                # 그룹 단위 parity (Codex 재리뷰 BLOCKING-1): CP 가 존재하면
                # 개별 그룹 누락/failed 를 fallback 으로 취급하지 않는다 —
                # 현재 샷 있는 그룹의 lane 미배정 = fail-closed
                parity = validate_group_parity(
                    lane_data=(lane_cp or {}).get("data", {}) or {},
                    spec_groups=spec_groups,
                    reconstruct=reconstruct,
                )
                if parity:
                    raise AppError(
                        code="step.missing_input.outdoor_place_canon",
                        message=(
                            "lane plan 그룹 parity 위반 — "
                            "outdoor_lane_plan 재실행 필요: "
                            + "; ".join(parity[:8])
                        ),
                        status_code=422,
                    )
                filtered: Dict[str, Any] = {}
                for gid, entry in target_groups.items():
                    lane_entry = lane_groups.get(gid)
                    if not isinstance(lane_entry, dict) or (
                            lane_entry.get("status") != "ok"):
                        # parity 통과 후 남는 non-ok = 현재 선택 샷 0 인
                        # skip 그룹 — lane 배정 없음, 기존 canon 대상 유지
                        filtered[gid] = entry
                        continue
                    plan = lane_entry.get("plan") or {}
                    violations = revalidate_persisted_plan(
                        plan, lane_scene_texts,
                        group_shots=reconstruct(entry))
                    if violations:
                        raise AppError(
                            code=(
                                "step.contract_violation."
                                "outdoor_place_canon"),
                            message=(
                                "persisted lane plan 재검증 실패 "
                                f"(group={gid}): "
                                + "; ".join(violations[:8])),
                            status_code=422,
                        )
                    lanes = {
                        (b or {}).get("lane")
                        for b in plan.get("shot_bindings") or []
                    }
                    if "map_marker" in lanes:
                        filtered[gid] = entry
                    else:
                        lane_skipped[gid] = {
                            "skipped": (
                                "lane pipe: structure_plate 전용 그룹 — "
                                "seed 파이프(outdoor_structure_seed) 담당"),
                        }
                target_groups = filtered
                if not target_groups:
                    logger.info(
                        "outdoor_place_canon: lane 필터 후 map_marker "
                        "그룹 0 — no-op (structure_plate 전용)")
                    out_empty = _empty()
                    out_empty["data"]["groups"] = lane_skipped
                    return out_empty

        fp_prompt_cp = self._load_prev_checkpoint("floor_plan_prompt")
        fp_render_cp = self._load_prev_checkpoint("floor_plan_render")

        # 2026-07-10 실측 fix: **제작자 정정 원문**은 캐논 T2I 프롬프트에
        # 넣지 않는다. T2I 는 언급=묘사라 무관 장소(바다/포구 등)에까지
        # 정정 대상(예: 철제 계단)이 그려지는 전역 오염 실측. 정정 사실은
        # outdoor_place_spec 저작(LLM, 관련성 판단 가능)에 이미 주입되어
        # 관련 장소의 마커 서술(name_en/placement_en)로만 관통한다.
        #
        # 2026-07-25 사용자 지적("맵도 콘티도 샷도 한국적이지 않다"): 위
        # 판단은 정정 채널 이야기였고, **세계관(지역·시대)은 애초에 이
        # 슬롯에 들어간 적이 없다** — 별개 결손. 지역·시대는 특정 대상을
        # 지목하는 정정과 달리 그 지역의 실제 시설·건축 관행을 부르는
        # 앵커라 오염 위험이 없고, 없으면 무국적 시설이 생성된다.
        # 장소 물성 규칙만 선별(인물/현상 규칙 제외)해 싣는다.
        from app.core.world_context import build_world_facts_block

        world_facts_block = build_world_facts_block(
            self._load_prev_checkpoint("visual_world_rules")
        )

        out_dir = (
            Path(settings.projects_dir) / self.project_id
            / "images" / "background_chain"
        )

        from app.modules.pipeline.outdoor_place_canon import (
            run_outdoor_place_canon_group,
        )
        from app.services.image_capture.context import generation_context
        from app.services.image_capture.sink import capture_generated_image

        nb2_fn = self._make_nb2_fn()
        gpt_edit_fn = self._make_gpt_edit_fn()

        results: Dict[str, Any] = dict(lane_skipped)
        completed = 0
        failed = 0
        for gid in sorted(target_groups):
            entry = target_groups[gid]
            spec = entry["spec"]
            scale_block = collect_scale_block(fp_prompt_cp, gid)
            style_fp = pick_style_fp_path(fp_render_cp, fp_prompt_cp, gid)
            try:
                with generation_context(
                    self.project_id, self.episode_id,
                    stage="outdoor_place_canon",
                ):
                    out = run_outdoor_place_canon_group(
                        group_id=gid,
                        spec=spec,
                        scale_block=scale_block,
                        world_facts_block=world_facts_block,
                        out_dir=out_dir,
                        nb2_generate_fn=nb2_fn,
                        gpt_edit_fn=gpt_edit_fn,
                        style_fp_path=style_fp,
                        project_config=self.project_config,
                        opik_metadata=self.build_opik_metadata(),
                        prompt_version=PROMPT_VERSION,
                        capture_fn=capture_generated_image,
                    )
            except Exception as exc:  # noqa: BLE001
                logger.exception("outdoor_place_canon: group=%s 실패", gid)
                results[gid] = {"status": "failed", "error": str(exc)}
                failed += 1
                continue

            master_asset_id = self._upsert_canon_asset(
                group_id=gid,
                variant_type="master",
                pipeline_role="outdoor_canon_photo",
                abs_png_path=out["master_png_path"],
                prompt_used=out["photo_prompt"],
                generation_model=settings.gemini_image_model,
                input_image_ids=[],
            )
            map_asset_id = self._upsert_canon_asset(
                group_id=gid,
                variant_type="map",
                pipeline_role="outdoor_canon_map",
                abs_png_path=out["map_png_path"],
                prompt_used=out["map_prompt"],
                # ★실제로 그린 모델을 적는다. 지금은 후보 사다리가 없어
                #  (사용자 지시 "그냥 nb2 만") 설정값이 곧 그린 모델이다.
                #  후보를 다시 넣는 날에는 **그린 쪽이 이름을 돌려주게**
                #  해야 한다 — 기본값을 적어 두면 기록이 거짓이 된다.
                generation_model=_marker_map_model(),
                input_image_ids=[master_asset_id],
            )
            results[gid] = {
                "status": "ok",
                "master_png_path": out["master_png_path"],
                "map_png_path": out["map_png_path"],
                "master_asset_id": master_asset_id,
                "map_asset_id": map_asset_id,
                "selected_candidate": out["selected_candidate"],
                "judge_totals": out["judge_totals"],
                "issues_count": out["issues_count"],
                "fix_applied": out["fix_applied"],
                "style_fp_attached": out["style_fp_attached"],
            }
            completed += 1

        # 렌더 루프 종료 후 메인 스레드 일괄 commit (background_render 관례)
        self.db.commit()

        return {
            "applicable_count": len(target_groups),
            "completed_count": completed,
            "failed_count": failed,
            "schema_version": SCHEMA_VERSION,
            "config_hash": self._config_hash(),
            "data": {"groups": results},
        }
