"""W21B-W7 W-C1 (2026-06-12): ZoomContinuityAnchorStep — zoom continuity anchor.

order 21.73 (t2i_review 21.72 직후 / image phase 22 이전). Codex
W_C_NARROW_REVIEW=APPROVE_C3_VARIANT_WITH_CONTRACT_GUARDS 의 C3 변형:
zoom(C) anchor 추출을 visual_continuity_anchor(21.66, printed_prop 전용)에서
분리해 shot_dependency_t2i(21.71) **이후**에 둔다 → refined ref_usage 가
hard dep 으로 항상 선행 = **fresh-run 1-pass proof** (21.66 의 fresh C seed 0
문제 해소). image phase 는 22+ 라 anchor cp 도 이미지 생성 전에 항상 준비됨.

흐름:
  1. seed = ``detect_c_seeds`` 재사용 (ref_usage==zoom_in_detail only, 같은 씬
     selected pair — Codex 판정 ① 불변). shot_dependency_t2i 는 hard 소비 —
     legacy schema 는 scene_context_loader 와 동일하게 fail-fast.
  2. seed 마다 LLM 2콜: ①anchor 추출(기존 provider ``extract_zoom_anchor``,
     씬 원문 전체 + 최종 t2i[t2i_review 반영본]) ②wide 멤버 t2i variations
     일괄 재작문(``rewrite_wide_prompts``, 스파이크1 ②) → ``revised_wide_t2i``.
  3. **토큰 audit (deterministic)**: revised 가 original 에 없던 entity ID
     토큰을 도입하면 그 variation 의 revised 를 폐기 + diagnostic (Codex 안전안
     — ref-contract SOT 는 scene_detail required_refs 불변, revised 는
     text-only). 비차단.
  4. manifest 조립 + 조인 무결성 검증 + review_html (original vs revised 비교).

소비 (W-C2, 이 step 아님): source_wide still 의 image-phase prompt override
(scene_detail cp 불변 — prompt_used/metadata 에 prompt_source=
zoom_continuity_anchor + original/revised hash provenance) + zoom still 의
crop/i2i-fill. 이 step 은 manifest 산출까지만.

opt-in: settings.zoom_continuity_anchor_enabled (default False, D flag 와
독립). OFF 시 not_applicable — default 경로 영향 0. anchor/재작문 품질은
deterministic 테스트 비대상 — canary + 육안 gate.

설계: docs/w21b-w7-visual-continuity-anchor-production-brief-20260612/ 9절.
"""
from __future__ import annotations

import hashlib
import html
import json
import logging
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Tuple

from app.core.step_runner import StepRunner
from app.modules.pipeline.visual_continuity_anchor_plan import (
    CAMERA_RELATION_PUNCH_IN,
    DEFAULT_GROUP_CAP,
    SCHEMA_VERSION,
    build_manifest,
    build_zoom_group,
    detect_c_seeds,
    revised_prompt_new_tokens,
    validate_anchor_manifest,
    zoom_subject_missing_chars,
)

logger = logging.getLogger(__name__)

_HTML_SUBDIR_NAME = "review_html"

ShotKey = Tuple[int, int]


def _hash16(text: str) -> str:
    return hashlib.sha256((text or "").encode("utf-8")).hexdigest()[:16]


# ───────────────────── W-C2 consumer 진입점 (module-level) ─────────────────────


def load_zoom_continuity_context(project_id: str, episode_id: str) -> Dict[str, Any]:
    """zoom continuity consumer 공용 로더 (scene image 생성 경로).

    flag OFF / cp 부재·미완료 / 그룹 0 이면 빈 dict — 소비자 전원 no-op
    (default 경로 byte-identical).

    Returns (비어있지 않으면):
        {
          "source_overrides": {(si, shi): {"group_id", "revised": {int: str},
                                            "provenance": {str: {...hash...}}}},
              — still 의 image-phase prompt override (Codex guard: scene_detail
                cp 불변, 실사용 프롬프트는 prompt_used 로 추적). source_wide 의
                revised_wide_t2i 와, different_camera zoom 멤버의
                revised_zoom_t2i (Cinematography 축 — crop 대신 자기 camera
                plan + locked facts weave) 둘 다 이 맵으로 소비된다.
          "zoom_targets": {(si, shi): {"group_id", "source": (si, shi),
                                        "locked_elements": [...],
                                        "wide_shot_contract": [...]}},
              — crop/i2i-fill 대상 still. camera_relation 이 punch-in 인 그룹만
                (부재=legacy manifest 는 punch-in 취급 — 기존 동작 보존).
        }
    """
    from app.core.config import settings

    if not bool(getattr(settings, "zoom_continuity_anchor_enabled", False)):
        return {}
    cp_path = (
        Path(settings.projects_dir) / project_id / "checkpoints"
        / "episodes" / episode_id / "zoom_continuity_anchor" / "manifest.json"
    )
    if not cp_path.exists():
        return {}
    try:
        cp = json.loads(cp_path.read_text(encoding="utf-8"))
    except Exception as exc:
        logger.warning("zoom_continuity_anchor: consumer cp parse 실패: %s", exc)
        return {}
    if cp.get("status") != "completed":
        return {}
    groups = (cp.get("data") or {}).get("groups") or []

    source_overrides: Dict[ShotKey, Dict[str, Any]] = {}
    zoom_targets: Dict[ShotKey, Dict[str, Any]] = {}
    for g in groups:
        if g.get("anchor_type") != "zoom_continuity":
            continue
        members = {m.get("role"): m for m in g.get("members") or []}
        src_m, zoom_m = members.get("source_wide"), members.get("zoom")
        if not src_m or not zoom_m:
            continue
        src = (src_m.get("scene_index"), src_m.get("shot_index"))
        zoom = (zoom_m.get("scene_index"), zoom_m.get("shot_index"))
        if not all(isinstance(v, int) for v in (*src, *zoom)):
            continue
        revised = {
            int(k): v for k, v in (g.get("revised_wide_t2i") or {}).items()
            if isinstance(v, str) and v.strip()
        }
        if revised:
            source_overrides[src] = {
                "group_id": g.get("group_id"),
                "revised": revised,
                "provenance": g.get("revised_wide_prompt_provenance") or {},
            }
        relation = g.get("camera_relation") or CAMERA_RELATION_PUNCH_IN
        if relation == CAMERA_RELATION_PUNCH_IN:
            zoom_targets[zoom] = {
                "group_id": g.get("group_id"),
                "source": src,
                "locked_elements": g.get("locked_elements") or [],
                "wide_shot_contract": g.get("wide_shot_contract") or [],
            }
        else:
            # Cinematography 축: crop 부적격 zoom 은 자기 camera plan 의
            # 원본 t2i 를 유지하되 locked facts 가 woven 된 revised 로 생성.
            revised_zoom = {
                int(k): v for k, v in (g.get("revised_zoom_t2i") or {}).items()
                if isinstance(v, str) and v.strip()
            }
            if revised_zoom:
                source_overrides[zoom] = {
                    "group_id": g.get("group_id"),
                    "revised": revised_zoom,
                    "provenance": g.get("revised_zoom_prompt_provenance") or {},
                }
    if not source_overrides and not zoom_targets:
        return {}
    return {"source_overrides": source_overrides, "zoom_targets": zoom_targets}


class ZoomContinuityAnchorStep(StepRunner):
    """Step 21.73: zoom continuity anchor + revised wide prompt (W21B-W7 W-C1)."""

    # Test-only injection slots.
    _anchor_override: Optional[Callable[..., Dict[str, Any]]] = None
    _rewrite_override: Optional[Callable[..., Dict[int, str]]] = None
    _rewrite_zoom_override: Optional[Callable[..., Dict[int, str]]] = None

    def set_overrides_for_testing(self, *, anchor=None, rewrite=None, rewrite_zoom=None) -> None:
        self._anchor_override = anchor
        self._rewrite_override = rewrite
        self._rewrite_zoom_override = rewrite_zoom

    # ───────────────────── plumbing ─────────────────────

    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:
                logger.warning("zoom_continuity_anchor: %s parse failed: %s", step_id, exc)
        return None

    def _checkpoint_dir(self) -> Path:
        from app.core.config import settings
        return (
            Path(settings.projects_dir) / self.project_id / "checkpoints"
            / "episodes" / self.episode_id / "zoom_continuity_anchor"
        )

    def _config_hash(self) -> str:
        from app.core.config import settings
        from app.modules.pipeline.visual_continuity_anchor_provider import (
            PROMPT_VERSION,
            PROVIDER_VERSION,
        )
        payload = {
            "enabled": bool(getattr(settings, "zoom_continuity_anchor_enabled", False)),
            "group_cap": int(getattr(
                settings, "zoom_continuity_anchor_group_cap", DEFAULT_GROUP_CAP)),
            "schema_version": SCHEMA_VERSION,
            "prompt_version": PROMPT_VERSION,
            "provider_version": PROVIDER_VERSION,
        }
        return hashlib.sha256(
            json.dumps(payload, sort_keys=True).encode("utf-8")
        ).hexdigest()[:16]

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

    # ───────────────────── input loaders ─────────────────────

    def _refined_dependencies_or_raise(self) -> List[Dict[str, Any]]:
        """shot_dependency_t2i 는 이 step 의 **hard** 소비 — legacy schema 는
        scene_context_loader 와 동일하게 fail-fast (silent legacy 유입 차단)."""
        from app.core.errors import AppError
        cp = self._load_prev_checkpoint("shot_dependency_t2i")
        if not cp or not cp.get("data", {}).get("dependencies"):
            raise AppError(
                code="step.no_input",
                message="zoom_continuity_anchor: shot_dependency_t2i 결과 없음",
                status_code=400,
            )
        if cp.get("schema_version") != 4:
            raise AppError(
                code="zoom_continuity_anchor.legacy_dependency_cp",
                message=(
                    f"shot_dependency_t2i checkpoint schema_version="
                    f"{cp.get('schema_version')!r}, expected 4 — force 재실행 필요."
                ),
                status_code=409,
            )
        return cp["data"]["dependencies"]

    @staticmethod
    def _scene_texts(save_cp: Optional[Dict[str, Any]]) -> Dict[int, str]:
        out: Dict[int, str] = {}
        for seg in (save_cp or {}).get("data", {}).get("segments", []) or []:
            si = seg.get("scene_index")
            if isinstance(si, int):
                out[si] = seg.get("text") or ""
        return out

    @staticmethod
    def _shot_descriptions(shot_cp: Optional[Dict[str, Any]]) -> Dict[ShotKey, str]:
        out: Dict[ShotKey, str] = {}
        for sc in (shot_cp or {}).get("data", {}).get("scenes", []) or []:
            si = sc.get("scene_index")
            for sh in sc.get("shots", []) or []:
                shi = sh.get("shot_index")
                if isinstance(si, int) and isinstance(shi, int):
                    out[(si, shi)] = sh.get("description") or ""
        return out

    @staticmethod
    def _staging_by_shot(staging_cp: Optional[Dict[str, Any]]) -> Dict[ShotKey, Dict[str, Any]]:
        out: Dict[ShotKey, Dict[str, Any]] = {}
        for sh in (staging_cp or {}).get("data", {}).get("shots", []) or []:
            si, shi = sh.get("scene_index"), sh.get("shot_index")
            if isinstance(si, int) and isinstance(shi, int):
                out[(si, shi)] = sh
        return out

    @staticmethod
    def _t2i_variations_by_shot(
        scene_detail_cp: Optional[Dict[str, Any]],
    ) -> Dict[ShotKey, List[Tuple[int, str]]]:
        """(si, shi) → [(variation_index, t2i_prompt)] — t2i_review(21.72) 가
        scene_detail cp 를 직접 수정하므로 여기서 읽는 값이 최종본."""
        out: Dict[ShotKey, List[Tuple[int, str]]] = {}
        for sc in (scene_detail_cp or {}).get("data", {}).get("scenes", []) or []:
            si, shi = sc.get("scene_index"), sc.get("_shot_index")
            if not isinstance(si, int) or not isinstance(shi, int):
                continue
            variations: List[Tuple[int, str]] = []
            for i, v in enumerate(sc.get("t2i_variations") or []):
                prompt = v.get("t2i_prompt") if isinstance(v, dict) else None
                if isinstance(prompt, str) and prompt.strip():
                    variations.append((i, prompt))
            if variations:
                out[(si, shi)] = variations
        return out

    def _source_still_exists(self, scene_index: int, shot_index: int) -> bool:
        from app.models.project import SceneStill
        return (
            self.db.query(SceneStill.id)
            .filter(
                SceneStill.project_id == self.project_id,
                SceneStill.episode_id == self.episode_id,
                SceneStill.scene_index == scene_index,
                SceneStill.shot_index == shot_index,
            )
            .first()
            is not None
        )

    # ───────────────────── execute ─────────────────────

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

        if not bool(getattr(settings, "zoom_continuity_anchor_enabled", False)):
            return self._not_applicable()

        shot_cp = self._load_prev_checkpoint("shot_validator")
        if not shot_cp or not shot_cp.get("data", {}).get("scenes"):
            raise AppError(code="step.no_shots", message="shot_validator 결과 없음", status_code=400)
        from app.core.steps.shot_validator_step import assert_no_failed_scenes
        assert_no_failed_scenes(shot_cp, self.project_config, consumer_step="zoom_continuity_anchor")

        from app.core.steps.episode_reference_policy_step import build_selected_map_or_raise
        selected_map = build_selected_map_or_raise(self._load_prev_checkpoint("shot_selection"))

        dependencies = self._refined_dependencies_or_raise()
        scene_texts = self._scene_texts(self._load_prev_checkpoint("scene_save"))
        shot_descriptions = self._shot_descriptions(shot_cp)
        staging_by_shot = self._staging_by_shot(self._load_prev_checkpoint("shot_staging"))
        t2i_by_shot = self._t2i_variations_by_shot(self._load_prev_checkpoint("scene_detail"))

        seeds, seed_skipped, exact_candidates = detect_c_seeds(
            dependencies, selected_map,
            source_still_exists=self._source_still_exists,
        )
        cap = int(getattr(settings, "zoom_continuity_anchor_group_cap", DEFAULT_GROUP_CAP))
        selected_seeds = seeds[:cap]
        cap_skipped = [
            {"seed": f"zoom:S{s['zoom'][0]}sh{s['zoom'][1]}", "reason": "cap"}
            for s in seeds[cap:]
        ]

        anchor_fn = self._anchor_override
        rewrite_fn = self._rewrite_override
        rewrite_zoom_fn = self._rewrite_zoom_override
        if anchor_fn is None or rewrite_fn is None or rewrite_zoom_fn is None:
            from app.modules.pipeline import visual_continuity_anchor_provider as vca_provider
            anchor_fn = anchor_fn or vca_provider.extract_zoom_anchor
            rewrite_fn = rewrite_fn or vca_provider.rewrite_wide_prompts
            rewrite_zoom_fn = rewrite_zoom_fn or vca_provider.rewrite_zoom_prompts

        groups: List[Dict[str, Any]] = []
        llm_skipped: List[Dict[str, Any]] = []
        audit_violations: List[Dict[str, Any]] = []
        subject_gate_skipped: List[Dict[str, Any]] = []
        rewrite_variation_skipped: List[Dict[str, Any]] = []
        ordinal = 0
        for seed in selected_seeds:
            src = tuple(seed["source"])
            zoom = tuple(seed["zoom"])
            label = f"S{zoom[0]}sh{zoom[1]}"
            scene_text = scene_texts.get(zoom[0], "")
            wide_variations = t2i_by_shot.get(src, [])
            zoom_variations = t2i_by_shot.get(zoom, [])

            # ── S29 fix ① (deterministic ID 조인): zoom 인물 토큰이 없는 source
            # variation 은 재작문 부적격 — 전 variation 부적격이면 seed 자체가
            # 상류 모순(인물 zoom 의 source 가 무인물 wide) → 그룹 제외.
            zoom_prompts = [p for _, p in zoom_variations]
            viable_wide: List[Tuple[int, str]] = []
            seed_variation_skips: List[Dict[str, Any]] = []
            missing_union: set = set()
            for idx, prompt in wide_variations:
                missing = zoom_subject_missing_chars(zoom_prompts, prompt)
                if missing:
                    missing_union |= missing
                    seed_variation_skips.append({
                        "group": label,
                        "variation_index": idx,
                        "reason": "zoom_subject_token_absent_in_original",
                        "missing_chars": sorted(missing),
                    })
                else:
                    viable_wide.append((idx, prompt))
            if wide_variations and not viable_wide:
                subject_gate_skipped.append({
                    "seed": label,
                    "reason": "zoom_subject_absent_in_source",
                    "missing_chars": sorted(missing_union),
                })
                continue
            # 그룹이 살아남은 경우에만 variation 단위 skip 진단 (seed 단위
            # 제외와 중복 기록 방지)
            rewrite_variation_skipped.extend(seed_variation_skips)

            try:
                anchor = anchor_fn(
                    scene_text,
                    {
                        "description": shot_descriptions.get(src, ""),
                        "staging": staging_by_shot.get(src),
                        "t2i_prompt": wide_variations[0][1] if wide_variations else None,
                    },
                    {
                        "description": shot_descriptions.get(zoom, ""),
                        "staging": staging_by_shot.get(zoom),
                        "t2i_prompt": (t2i_by_shot.get(zoom) or [(0, None)])[0][1],
                    },
                    project_config=self.project_config,
                    opik_metadata=self.build_opik_metadata(),
                )
                ordinal += 1
                group = build_zoom_group(seed, anchor, ordinal)

                def _rewrite_and_audit(
                    fn: Callable[..., Dict[int, str]],
                    variations: List[Tuple[int, str]],
                    anchor_subset: Dict[str, Any],
                    side: str,
                ) -> Tuple[Dict[str, str], Dict[str, Dict[str, str]]]:
                    """재작문 + 토큰 audit (Codex 안전안) — 위반 revised 폐기."""
                    revised_map: Dict[str, str] = {}
                    provenance: Dict[str, Dict[str, str]] = {}
                    revised_raw = fn(
                        variations,
                        anchor_subset,
                        project_config=self.project_config,
                        opik_metadata=self.build_opik_metadata(),
                    )
                    original_by_idx = dict(variations)
                    for idx, revised in sorted(revised_raw.items()):
                        original = original_by_idx.get(idx)
                        if original is None:
                            continue
                        new_tokens = revised_prompt_new_tokens(original, revised)
                        if new_tokens:
                            audit_violations.append({
                                "group": label,
                                "side": side,
                                "variation_index": idx,
                                "new_tokens": sorted(new_tokens),
                            })
                            continue  # revised 폐기 — substitution 미적용 (비차단)
                        revised_map[str(idx)] = revised
                        provenance[str(idx)] = {
                            "original_scene_detail_prompt_hash": _hash16(original),
                            "revised_prompt_hash": _hash16(revised),
                        }
                    return revised_map, provenance

                # ── wide 재작문 (스파이크1 ②) — subject gate 통과 variation 만 ──
                revised_map: Dict[str, str] = {}
                provenance: Dict[str, Dict[str, str]] = {}
                if viable_wide:
                    revised_map, provenance = _rewrite_and_audit(
                        rewrite_fn, viable_wide,
                        {k: anchor.get(k) for k in ("locked_elements", "wide_shot_contract")},
                        "wide",
                    )
                group["revised_wide_t2i"] = revised_map
                group["revised_wide_prompt_provenance"] = provenance

                # ── Cinematography 축: crop 부적격(non punch-in) zoom 은 자기
                # camera plan 을 유지한 채 locked facts 만 weave 한 revised 로 ──
                if (
                    group.get("camera_relation") != CAMERA_RELATION_PUNCH_IN
                    and zoom_variations
                ):
                    zoom_revised, zoom_prov = _rewrite_and_audit(
                        rewrite_zoom_fn, zoom_variations,
                        {"locked_elements": anchor.get("locked_elements")},
                        "zoom",
                    )
                    group["revised_zoom_t2i"] = zoom_revised
                    group["revised_zoom_prompt_provenance"] = zoom_prov
                groups.append(group)
            except Exception as exc:
                logger.warning("zoom_continuity_anchor: %s 추출 실패: %s", label, exc)
                llm_skipped.append({"seed": label, "reason": "llm_error"})

        diagnostics = {
            "seeds_detected": len(seeds),
            "groups_extracted": len(groups),
            "skipped": seed_skipped + cap_skipped + subject_gate_skipped + llm_skipped,
            "revised_prompt_audit_violations": audit_violations,
            "rewrite_variation_skipped": rewrite_variation_skipped,
            "candidate_same_moment_exact_background": exact_candidates,
            "ref_usage_source": "shot_dependency_t2i",
        }
        manifest = build_manifest(groups, diagnostics)

        extra_known = {tuple(s["source"]) for s in seeds} | set(shot_descriptions.keys())
        violations = validate_anchor_manifest(
            manifest, selected_map, set(), extra_known_shots=extra_known,
        )
        if violations:
            raise AppError(
                code="zoom_continuity_anchor.manifest_invalid",
                message="anchor manifest 조인 무결성 위반: " + "; ".join(violations),
                status_code=500,
            )

        gallery_rel = self._write_review_html(manifest)

        return {
            # attempted 기준 (P0-1 선례: applicable=실제 시도 수) — subject gate
            # 로 제외된 seed 는 모순 데이터라 시도 대상이 아님 (diagnostics 기록).
            "applicable_count": len(groups) + len(llm_skipped),
            "completed_count": len(groups),
            "failed_count": len(llm_skipped),
            "schema_version": SCHEMA_VERSION,
            "config_hash": self._config_hash(),
            "data": {**manifest, "review_html": gallery_rel},
        }

    # ───────────────────── review gallery ─────────────────────

    def _write_review_html(self, manifest: Dict[str, Any]) -> Optional[str]:
        try:
            out_dir = self._checkpoint_dir() / _HTML_SUBDIR_NAME
            out_dir.mkdir(parents=True, exist_ok=True)
            path = out_dir / "index.html"
            path.write_text(render_zoom_review_html(manifest), encoding="utf-8")
            return f"{_HTML_SUBDIR_NAME}/index.html"
        except Exception as exc:
            logger.warning("zoom_continuity_anchor: review html 생성 실패: %s", exc)
            return None


def render_zoom_review_html(manifest: Dict[str, Any]) -> str:
    """groups + original↔revised wide prompt 비교 — 사람 검토용 (Codex artifacts)."""
    def esc(v: Any) -> str:
        return html.escape(str(v if v is not None else ""))

    rows: List[str] = []
    for g in manifest.get("groups", []) or []:
        members = "<br>".join(
            f"S{m.get('scene_index')}sh{m.get('shot_index')} <em>{esc(m.get('role'))}</em>"
            for m in g.get("members", [])
        )
        locked = "".join(
            f"<li>[{esc(le.get('kind'))}/{esc(le.get('confidence'))}] {esc(le.get('description'))}"
            f"<br><small>“{esc(le.get('evidence_quote'))}”</small></li>"
            for le in g.get("locked_elements", [])
        )
        contract = "".join(
            f"<li>{esc(c)}</li>" for c in g.get("wide_shot_contract", []))
        revised_blocks = ""
        for field in ("revised_wide_t2i", "revised_zoom_t2i"):
            prov_field = field.replace("_t2i", "_prompt_provenance")
            for idx, revised in sorted((g.get(field) or {}).items()):
                prov = (g.get(prov_field) or {}).get(idx, {})
                revised_blocks += (
                    f"<details><summary>{esc(field)} [variation {esc(idx)}] "
                    f"<code>{esc(prov.get('original_scene_detail_prompt_hash'))}→"
                    f"{esc(prov.get('revised_prompt_hash'))}</code></summary>"
                    f"<pre>{esc(revised)}</pre></details>"
                )
        relation = (
            f"<p><strong>camera_relation</strong>: {esc(g.get('camera_relation'))}"
            f"<br><small>“{esc(g.get('camera_relation_evidence'))}”</small></p>"
        )
        rows.append(
            f"<section><h2>{esc(g.get('group_id'))} <small>{esc(g.get('anchor_type'))}</small></h2>"
            f"<p>{members}</p>{relation}<ul>{locked}</ul>"
            f"<p><strong>wide_shot_contract</strong></p><ul>{contract}</ul>"
            f"{revised_blocks}</section>"
        )

    diag = json.dumps(manifest.get("diagnostics", {}), ensure_ascii=False, indent=1)
    return (
        "<!DOCTYPE html><html lang='ko'><head><meta charset='utf-8'>"
        "<title>zoom_continuity_anchor review</title>"
        "<style>body{font-family:sans-serif;background:#14181f;color:#dde;max-width:980px;"
        "margin:0 auto;padding:24px;line-height:1.5}section{border:1px solid #345;"
        "border-radius:8px;padding:12px 16px;margin:14px 0}h2 small{color:#8ac;font-weight:400}"
        "small{color:#9ab}code{color:#9ecbff;font-size:.85em}pre{background:#0d1117;"
        "padding:10px;border-radius:6px;overflow-x:auto;white-space:pre-wrap}"
        "details{margin:8px 0}summary{cursor:pointer;color:#7ee787}</style></head><body>"
        f"<h1>zoom_continuity_anchor — groups {len(manifest.get('groups', []) or [])}</h1>"
        + "".join(rows)
        + f"<h2>diagnostics</h2><pre>{html.escape(diag)}</pre></body></html>"
    )
