"""background_chain_render StepRunner — chain plan에 따라 배경 PNG 체이닝 생성.

PR #4 (2026-04-27). on_demand: 사용자 명시 트리거할 때만.

의존:
  - background_chain_planning (필수, 핵심 입력)
  - shot_validator (선택, shot description 컨텍스트)
  - entity_merge (선택, location 메타)

DB:
  - ImageAsset 테이블에 asset_type='background_chain_node' UPSERT
  - 위치별 entity_canon (location)에 연결
"""
from __future__ import annotations

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

from app.core.step_runner import StepRunner
from app.modules.pipeline.background_chain_render import (
    run_background_chain_render,
)
from app.services.image_capture.annotate import annotate_generated_asset

logger = logging.getLogger(__name__)


class BackgroundChainRenderStep(StepRunner):
    """Step: chain plan → 노드별 배경 PNG (gpt-image) + DB 등록."""

    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():
            return json.loads(cp.read_text(encoding="utf-8"))
        return None

    def _load_floor_plan_paths(self) -> Dict[str, Path]:
        """location_floor_plan checkpoint에서 status='ok' + PNG 파일 존재하는 매핑.

        Phase 4: background_mode=floor_plan_anchored 시 chain_bg_render의 1순위 ref.
        체크포인트 없거나 모든 location 실패면 빈 dict (mode=off / chain_only 회귀 보장).
        """
        cp = self._load_prev_checkpoint("location_floor_plan")
        if not cp:
            return {}
        locations = cp.get("data", {}).get("locations", []) or []
        out: Dict[str, Path] = {}
        from app.core.config import settings
        projects_root = Path(settings.projects_dir).parent
        for loc in locations:
            if loc.get("status") != "ok":
                continue
            rel = loc.get("image_path", "")
            if not rel:
                continue
            p = projects_root / rel
            if p.exists():
                out[loc["id"]] = p
        return out

    def _load_planner_chain_order(self) -> Optional[List[str]]:
        """background_planner 체크포인트에서 chain_bg_order 로드.

        Phase 5: planner cp가 있고 chain_bg_order가 비어있지 않으면 planner-driven
        path로 진입한다. 없거나 비어있으면 None → legacy `data.locations` (Phase 4)
        경로 fallback (mode=chain_only 회귀 보장).
        """
        cp = self._load_prev_checkpoint("background_planner")
        if not cp:
            return None
        order = cp.get("data", {}).get("chain_bg_order") or []
        if not order:
            return None
        return list(order)

    def _load_planner_floor_plan_specs(self) -> Dict[str, Dict[str, Any]]:
        """background_planner 체크포인트에서 floor_plans 리스트 → fp_id → spec dict.

        building_group reverse lookup 용. cp 없거나 floor_plans 비어있으면 빈 dict.
        """
        cp = self._load_prev_checkpoint("background_planner")
        if not cp:
            return {}
        fp_list = cp.get("data", {}).get("floor_plans") or []
        out: Dict[str, Dict[str, Any]] = {}
        for fp in fp_list:
            if not isinstance(fp, dict):
                continue
            fp_id = fp.get("id", "")
            if fp_id:
                out[fp_id] = fp
        return out

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

        plan_cp = self._load_prev_checkpoint("background_chain_planning")
        if not plan_cp:
            raise AppError(
                code="step.no_input",
                message=(
                    "background_chain_planning 체크포인트 없음 — "
                    "render 전에 planning step을 먼저 실행하세요."
                ),
                status_code=400,
            )
        # 빈 locations dict는 정상 — 파이프라인이 graceful 처리(no-op + 빈 결과).

        from app.core.openai_keys import has_openai_key
        if not has_openai_key():
            raise AppError(
                code="step.no_openai_key",
                message="OPENAI_API_KEY 미설정 — gpt-image 호출 불가.",
                status_code=400,
            )

        # shot 메타 (선택) — t2i prompt 컨텍스트용
        shot_meta_by_id: Dict[str, Dict[str, Any]] = {}
        shot_cp = self._load_prev_checkpoint("shot_validator")
        if shot_cp:
            for sc in shot_cp.get("data", {}).get("scenes", []) or []:
                si = sc.get("scene_index")
                for sh in sc.get("shots", []) or []:
                    # T9 review I1 fix: planner/chain_bg_planning이 사용하는 unpadded 형식과 통일.
                    # 기존 zero-padded는 legacy chain_bg_render path에서만 사용됨 (호환 위해 둘 다 등록).
                    shi = sh.get("shot_index", 0)
                    description = sh.get("description", "") or ""
                    for sid in (f"S{si}_Shot{shi}", f"S{si:02d}_Shot{shi}"):
                        shot_meta_by_id[sid] = {
                            "shot_id": sid,
                            "description": description,
                        }

        location_ref_paths = self._load_location_ref_paths()
        floor_plan_paths = self._load_floor_plan_paths()  # Phase 4: 도면 PNG 매핑 (mode=off 시 빈 dict)

        # Phase 5: planner-driven 분기 입력 (cp 없으면 None → legacy)
        planner_chain_order = self._load_planner_chain_order()
        planner_floor_plan_specs = self._load_planner_floor_plan_specs()

        from app.core.openai_keys import openai_client
        from app.modules.prompt_sanitizer import PromptSanitizer

        # timeout: settings.llm_timeout_image_gen (env LLM_TIMEOUT_IMAGE_GEN
        # override 가능). default 600s 의 long hang 회귀 가드. retry 는 OpenAI
        # SDK default (max 2).
        client = openai_client(
            timeout=float(settings.llm_timeout_image_gen),
        )
        sanitizer = PromptSanitizer(project_config=self.project_config)

        image_dir = (
            Path(settings.projects_dir)
            / self.project_id
            / "images"
            / self.episode_id
            / "background_chain"
        )
        image_dir.mkdir(parents=True, exist_ok=True)

        result = run_background_chain_render(
            planning_data=plan_cp.get("data", {}),
            image_dir=image_dir,
            location_ref_paths=location_ref_paths,
            floor_plan_paths=floor_plan_paths,  # Phase 4: 1순위 ref
            openai_client=client,
            image_model=os.getenv("BACKGROUND_CHAIN_IMAGE_MODEL", "gpt-image-2.5-sunburst"),
            sanitizer=sanitizer,
            size=os.getenv("BACKGROUND_CHAIN_IMAGE_SIZE", "1024x1024"),
            quality=os.getenv("BACKGROUND_CHAIN_IMAGE_QUALITY", "high"),
            max_attempts=4,
            opik_metadata=self.build_opik_metadata(),
            shot_meta_by_id=shot_meta_by_id,
            planner_chain_order=planner_chain_order,
            planner_floor_plan_specs=planner_floor_plan_specs,
        )

        failed = result.pop("_failed_count", 0)

        # ── Phase 5 vs Phase 4 분기 ──
        # planner-driven path: result에 "groups" 키 존재. group 단위 ImageAsset UPSERT.
        # legacy path: result에 "locations" 키. 노드 단위 UPSERT (기존 Phase 4 흐름).
        if "groups" in result and planner_chain_order is not None:
            groups = dict(result.get("groups", {}))
            try:
                self._register_chain_bg_image_assets(groups)
            except Exception as exc:
                ok_groups = sum(1 for v in groups.values() if v.get("status") == "ok")
                logger.error(
                    "[CHAIN_BG_SYNC_FAIL] planner path: chain_bg ImageAsset INSERT 실패. "
                    "PNG는 디스크에 있으나 DB out of sync — downstream(scene_image_pipeline)이 "
                    "참조 이미지를 못 찾음. groups_total=%d ok_status=%d exc=%s\n%s",
                    len(groups), ok_groups, exc, traceback.format_exc(),
                )
                try:
                    self.db.rollback()
                except Exception as rb_exc:
                    logger.error(
                        "[CHAIN_BG_SYNC_FAIL] rollback also failed: %s", rb_exc
                    )
                # silent miss 차단 (PID 0bb48ebf E2E 회귀 가드).
                # DB 동기화 실패 = step 실패. step_runner.run의 except가 "failed" 마킹.
                raise

            applicable = len(planner_chain_order)
            completed = sum(1 for v in groups.values() if v.get("status") == "ok")
            return {
                "completed_count": completed,
                "applicable_count": applicable,
                "failed_count": failed,
                "data": result.get("data") or {"groups": groups},
            }

        # ── Phase 4 LEGACY path (변경 없음) ──
        # shallow copy로 result["locations"] 와 분리 — _register_image_assets가
        # 향후 변경되어 dict mutate를 도입하면 체크포인트 data가 함께 변형되는
        # 잠재 위험을 차단 (Claude PR #4 Issue 2).
        locations = dict(result.get("locations", {}))

        # DB 등록 — 실패는 즉시 raise하여 step → failed (silent miss 차단).
        try:
            self._register_image_assets(locations)
        except Exception as exc:
            ok_count = sum(
                len([n for n in v.get("nodes", []) if n.get("render_status") == "ok"])
                for v in locations.values()
            )
            logger.error(
                "[CHAIN_BG_SYNC_FAIL] legacy path: background_chain_node INSERT 실패. "
                "PNG는 디스크에 있으나 DB out of sync. locations_total=%d ok_nodes=%d exc=%s\n%s",
                len(locations), ok_count, exc, traceback.format_exc(),
            )
            try:
                self.db.rollback()
            except Exception as rb_exc:
                logger.error(
                    "[CHAIN_BG_SYNC_FAIL] legacy rollback also failed: %s", rb_exc
                )
            raise

        applicable = sum(v.get("total_node_count", 0) for v in locations.values())
        completed = sum(
            v.get("total_node_count", 0) - v.get("failed_node_count", 0)
            for v in locations.values()
        )

        return {
            "completed_count": completed,
            "applicable_count": applicable,
            "failed_count": failed,
            "data": result,
        }

    def _load_location_ref_paths(self) -> Dict[str, Path]:
        """DB의 ImageAsset(asset_type='reference', is_primary=1)에서
        location entity → 첫 ref PNG 경로를 매핑한다."""
        from app.core.file_paths import resolve_image_path
        from app.models.project import EntityCanon, ImageAsset

        loc_canons = (
            self.db.query(EntityCanon)
            .filter(
                EntityCanon.project_id == self.project_id,
                EntityCanon.entity_type == "location",
            )
            .all()
        )
        if not loc_canons:
            return {}

        loc_id_by_short: Dict[str, str] = {
            c.short_id: c.id for c in loc_canons if c.short_id
        }
        if not loc_id_by_short:
            return {}

        assets = (
            self.db.query(ImageAsset)
            .filter(
                ImageAsset.project_id == self.project_id,
                ImageAsset.asset_type == "reference",
                ImageAsset.is_primary == 1,
                ImageAsset.entity_id.in_(loc_id_by_short.values()),
            )
            .all()
        )

        canon_to_short = {v: k for k, v in loc_id_by_short.items()}
        out: Dict[str, Path] = {}
        for a in assets:
            short = canon_to_short.get(a.entity_id)
            if not short:
                continue
            p = resolve_image_path(a.file_path)
            if p and p.exists():
                out[short] = p
        logger.info("background_chain_render: %d location ref images loaded", len(out))
        return out

    def _register_chain_bg_image_assets(self, groups: Dict[str, Any]) -> None:
        """Phase 5 — group PNG를 ImageAsset(asset_type='chain_bg') UPSERT.

        match key (application-level): (project_id, asset_type, entity_id,
        variant_index, episode_id). variant_index는 location 단위로 1+ 단조 증가
        (floor_plan은 v00=0이 primary, chain_bg는 v01부터).

        file_path는 projects_root 기준 RELATIVE (Phase 3 컨벤션 + T5 review I1 fix).
        t2i_guide는 group의 shot_guides[]를 newline join한 텍스트 — Phase 6
        scene_detail consumer가 이를 prepend하여 chain_bg PNG의 가구 위치 가이드를
        scene t2i 작성에 주입한다.
        """
        from app.models.project import EntityCanon, ImageAsset
        from app.core.config import settings

        canons = (
            self.db.query(EntityCanon)
            .filter(
                EntityCanon.project_id == self.project_id,
                EntityCanon.entity_type == "location",
            )
            .all()
        )
        canon_id_by_short: Dict[str, str] = {c.short_id: c.id for c in canons if c.short_id}
        if not canon_id_by_short:
            logger.warning(
                "background_chain_render (planner): no location EntityCanon — skip chain_bg DB sync"
            )
            return

        now = datetime.now(timezone.utc).isoformat()
        registered = 0

        # persist-all Wave1 정련: FP→chain_bg lineage per-step 캐시맵(canon→floor_plan
        # ImageAsset, tie-breaker is_primary desc/created_at desc — background_render 와 동일).
        _fp_id_by_canon: Dict[str, str] = {}
        for _fp in (
            self.db.query(ImageAsset)
            .filter(
                ImageAsset.project_id == self.project_id,
                ImageAsset.asset_type == "floor_plan",
            )
            .order_by(ImageAsset.is_primary.desc(), ImageAsset.created_at.desc())
            .all()
        ):
            if _fp.entity_id and _fp.entity_id not in _fp_id_by_canon:
                _fp_id_by_canon[_fp.entity_id] = _fp.id

        for group_id, gres in groups.items():
            if gres.get("status") != "ok":
                continue
            png_path_str = gres.get("png_path", "") or ""
            if not png_path_str:
                continue

            # Phase 3 컨벤션: file_path 는 projects_root 기준 relative.
            # to_relative_image_path 는 root 외부면 절대 그대로 반환 (e2e tmp_path 호환).
            from app.core.file_paths import to_relative_image_path
            rel_png_path = to_relative_image_path(png_path_str)

            loc_short = gres.get("location_id", "") or ""
            canon_id = canon_id_by_short.get(loc_short)
            if not canon_id:
                logger.warning(
                    "background_chain_render (planner): location %s no canon — skip group %s",
                    loc_short, group_id,
                )
                continue

            variant_index = int(gres.get("variant_index") or 0)
            variant_label = gres.get("variant_label", "") or f"v{variant_index:02d}"

            # t2i_guide — shot_guides[]의 guide 텍스트를 newline join (no truncation).
            shot_guides = gres.get("shot_guides", []) or []
            guide_lines: List[str] = []
            for sg in shot_guides:
                if not isinstance(sg, dict):
                    continue
                sid = sg.get("shot_id", "") or ""
                guide = sg.get("guide", "") or ""
                if guide:
                    if sid:
                        guide_lines.append(f"[{sid}] {guide}")
                    else:
                        guide_lines.append(guide)
            t2i_guide_text = "\n".join(guide_lines) if guide_lines else None

            existing = (
                self.db.query(ImageAsset)
                .filter_by(
                    project_id=self.project_id,
                    asset_type="chain_bg",
                    entity_id=canon_id,
                    variant_index=variant_index,
                    episode_id=self.episode_id,
                )
                .first()
            )
            if existing:
                existing.file_path = rel_png_path
                existing.prompt_used = gres.get("t2i_prompt", "") or ""
                existing.variant_label = variant_label
                existing.t2i_guide = t2i_guide_text
                existing.variant_type = group_id  # I2 fix: traceability 갱신
                existing.status = "generated"
                existing.is_primary = 0  # floor_plan(v00)만 primary
                existing.generation_model = "gpt-image-2.5-sunburst"
                _chain_row = existing
            else:
                _chain_row = ImageAsset(
                    id=str(uuid.uuid4()),
                    project_id=self.project_id,
                    asset_type="chain_bg",
                    entity_id=canon_id,
                    episode_id=self.episode_id,
                    variant_index=variant_index,
                    variant_label=variant_label,
                    t2i_guide=t2i_guide_text,
                    file_path=rel_png_path,
                    prompt_used=gres.get("t2i_prompt", "") or "",
                    generation_model="gpt-image-2.5-sunburst",
                    status="generated",
                    is_primary=0,
                    variant_type=group_id,  # traceability: group_id를 variant_type에 보존
                    created_at=now,
                )
                self.db.add(_chain_row)
            # persist-all Wave1 정련 — background_chain role + FP lineage(구조키).
            _fp_id_for_chain = _fp_id_by_canon.get(canon_id)
            if _fp_id_for_chain is not None:
                annotate_generated_asset(
                    _chain_row, pipeline_role="background_chain",
                    input_image_ids=[_fp_id_for_chain],
                )
            else:
                annotate_generated_asset(
                    _chain_row, pipeline_role="background_chain",
                    pipeline_metadata={"unresolved_inputs": ["floor_plan:" + str(canon_id)]},
                )
            registered += 1

        if registered:
            self.db.commit()
            logger.info(
                "background_chain_render (planner): registered %d chain_bg ImageAssets",
                registered,
            )

    def _register_image_assets(self, locations: Dict[str, Any]) -> None:
        """노드 PNG를 ImageAsset 테이블에 UPSERT (asset_type='background_chain_node').

        producer 측 명시적 ``to_relative_image_path()`` 호출 (Claude I2). ImagePathType이
        자동 normalize 하지만 ``_register_chain_bg_image_assets`` 와 일관성을 위해 명시.
        """
        from app.core.file_paths import to_relative_image_path
        from app.models.project import EntityCanon, ImageAsset

        canons = (
            self.db.query(EntityCanon)
            .filter(
                EntityCanon.project_id == self.project_id,
                EntityCanon.entity_type == "location",
            )
            .all()
        )
        canon_id_by_short: Dict[str, str] = {c.short_id: c.id for c in canons if c.short_id}
        if not canon_id_by_short:
            logger.warning("background_chain_render: no location EntityCanon — skip DB sync")
            return

        now = datetime.now(timezone.utc).isoformat()
        registered = 0
        # persist-all Wave1 정련 — FP→node lineage per-step 캐시맵.
        _fp_id_by_canon: Dict[str, str] = {}
        for _fp in (
            self.db.query(ImageAsset)
            .filter(
                ImageAsset.project_id == self.project_id,
                ImageAsset.asset_type == "floor_plan",
            )
            .order_by(ImageAsset.is_primary.desc(), ImageAsset.created_at.desc())
            .all()
        ):
            if _fp.entity_id and _fp.entity_id not in _fp_id_by_canon:
                _fp_id_by_canon[_fp.entity_id] = _fp.id
        for loc_id, loc_data in locations.items():
            canon_id = canon_id_by_short.get(loc_id)
            if not canon_id:
                logger.warning(
                    "background_chain_render: location %s has no EntityCanon — skip DB", loc_id,
                )
                continue
            for node in loc_data.get("nodes", []) or []:
                if node.get("render_status") != "ok":
                    continue
                fp = node.get("image_path", "")
                if not fp:
                    continue
                fp = to_relative_image_path(fp)
                node_id = node.get("id", "")
                existing = (
                    self.db.query(ImageAsset)
                    .filter(
                        ImageAsset.project_id == self.project_id,
                        ImageAsset.entity_id == canon_id,
                        ImageAsset.asset_type == "background_chain_node",
                        ImageAsset.variant_type == node_id,
                        ImageAsset.episode_id == self.episode_id,
                    )
                    .first()
                )
                if existing:
                    existing.file_path = fp
                    existing.prompt_used = node.get("t2i_prompt", "")
                    existing.status = "generated"
                    _node_row = existing
                else:
                    _node_row = ImageAsset(
                        id=str(uuid.uuid4()),
                        project_id=self.project_id,
                        asset_type="background_chain_node",
                        entity_id=canon_id,
                        episode_id=self.episode_id,
                        file_path=fp,
                        prompt_used=node.get("t2i_prompt", ""),
                        generation_model="gpt-image-2.5-sunburst",
                        status="generated",
                        variant_type=node_id,
                        is_primary=0,
                        created_at=now,
                    )
                    self.db.add(_node_row)
                # persist-all Wave1 정련 — background_chain role + FP lineage(구조키).
                _fp_id_for_node = _fp_id_by_canon.get(canon_id)
                if _fp_id_for_node is not None:
                    annotate_generated_asset(
                        _node_row, pipeline_role="background_chain",
                        input_image_ids=[_fp_id_for_node],
                    )
                else:
                    annotate_generated_asset(
                        _node_row, pipeline_role="background_chain",
                        pipeline_metadata={"unresolved_inputs": ["floor_plan:" + str(canon_id)]},
                    )
                registered += 1

        if registered:
            self.db.commit()
            logger.info("background_chain_render: registered %d ImageAssets", registered)

    def verify_completion(self):
        """chain_bg/background_chain_node 산출물 무결성 검증 (planner+legacy dual path).

        production 분기 (Phase 5+):
          - planner-driven: `background_planner.data.chain_bg_order` 가 비어있지 않으면
            ImageAsset.asset_type='chain_bg' 행 + variant_type=group_id + 파일 stat 검증.
          - legacy: planner cp 없거나 chain_bg_order 비어있으면 background_chain_planning
            체크포인트의 `data.locations[loc_id].nodes[]` 노드 id를 기대치로 사용해
            asset_type='background_chain_node' + variant_type=node_id + 파일 stat 검증.

        plan.md(2026-05-01) §4.4 Task 23 가정과 production 차이:
          - asset_type은 'reference'가 아니라 'chain_bg' / 'background_chain_node'
          - traceability는 prompt_used regex가 아니라 variant_type 컬럼
          - planner 입력은 background_chain_planning이 아니라 background_planner
          - legacy 입력 키는 chain_plan.nodes가 아니라 nodes (locations[loc_id].nodes)

        cleanup_artifacts override 안 함 → default noop (사용자 caveat 따름).
        """
        from app.core.file_paths import resolve_image_path
        from app.core.integrity_report import CompletionReport
        from app.models.project import ImageAsset

        # ── Planner-driven path (Phase 5+) ──
        chain_order = self._load_planner_chain_order()
        if chain_order:
            expected_groups = list(chain_order)
            expected = len(expected_groups)
            rows = self.db.query(ImageAsset).filter(
                ImageAsset.project_id == self.project_id,
                ImageAsset.episode_id == self.episode_id,
                ImageAsset.asset_type == "chain_bg",
                ImageAsset.variant_type.in_(expected_groups),
            ).all()
            groups_with_file = set()
            for r in rows:
                p = resolve_image_path(r.file_path)
                if p and p.exists():
                    groups_with_file.add(r.variant_type)
            found_set = groups_with_file & set(expected_groups)
            found = len(found_set)
            missing_groups = sorted(set(expected_groups) - found_set)
            if found == expected:
                severity = "clean"
            elif found == 0:
                severity = "missing"
            else:
                severity = "partial"
            missing_msgs: List[str] = []
            if missing_groups:
                missing_msgs.append(
                    f"{len(missing_groups)} chain_bg groups missing "
                    f"(expected={expected}, found={found}): {missing_groups}"
                )
            return CompletionReport(
                is_complete=(severity == "clean"),
                missing=missing_msgs,
                severity=severity,
                metadata={
                    "chain_bg_expected": expected,
                    "chain_bg_found": found,
                    "path": "planner",
                },
            )

        # ── Legacy path (Phase 4 or planner cp 없음) ──
        plan_cp = self._load_prev_checkpoint("background_chain_planning")
        if not plan_cp:
            return CompletionReport(
                is_complete=True, missing=[], severity="clean",
                metadata={"chain_bg_expected": 0, "chain_bg_found": 0, "path": "none"},
            )
        locations = (plan_cp.get("data") or {}).get("locations") or {}
        node_ids: List[str] = []
        for loc in locations.values():
            for node in loc.get("nodes") or []:
                nid = node.get("id") or node.get("node_id")
                if nid:
                    node_ids.append(nid)
        if not node_ids:
            return CompletionReport(
                is_complete=True, missing=[], severity="clean",
                metadata={"chain_bg_expected": 0, "chain_bg_found": 0, "path": "legacy"},
            )

        expected = len(node_ids)
        rows = self.db.query(ImageAsset).filter(
            ImageAsset.project_id == self.project_id,
            ImageAsset.episode_id == self.episode_id,
            ImageAsset.asset_type == "background_chain_node",
            ImageAsset.variant_type.in_(node_ids),
        ).all()
        nodes_with_file = set()
        for r in rows:
            p = resolve_image_path(r.file_path)
            if p and p.exists():
                nodes_with_file.add(r.variant_type)
        found_set = nodes_with_file & set(node_ids)
        found = len(found_set)
        missing_nodes = sorted(set(node_ids) - found_set)
        if found == expected:
            severity = "clean"
        elif found == 0:
            severity = "missing"
        else:
            severity = "partial"
        missing_msgs = []
        if missing_nodes:
            missing_msgs.append(
                f"{len(missing_nodes)} chain_bg_node images missing "
                f"(expected={expected}, found={found}): {missing_nodes}"
            )
        return CompletionReport(
            is_complete=(severity == "clean"),
            missing=missing_msgs,
            severity=severity,
            metadata={
                "chain_bg_expected": expected,
                "chain_bg_found": found,
                "path": "legacy",
            },
        )
