"""experiment_floor_plan_vlm_readback_slice — W17C.

Pass the W17B3 schematic floor-plan PNG to a vision LLM together with
the W17A numbered marker legend and the W15e candidate so the VLM can
report, per marker, where it actually appears on the rendered image
(approximate region, observed unit/area label, confidence). Compare
the readback to the W17A/W15e mapping deterministically; the VLM
output is NOT promoted to truth — it is a diagnostic surface.

This stage:
- input PNG = W17B3 run's `png/<fp_id>.png` ONLY (W17B/W17B2 PNGs are
  comparison archive; not used as input)
- single fp (wave-1 lock = fp_l05_01)
- VLM/image-understanding call only; image API (gpt-image-2) call 0
- no DB / ImageAsset write, no production manifest mutation
- no commit, no push
- VLM model is exact GPT-5.5; fail-closed; no auto retry by default

CLI:
  --derive-readback-from <W17B3_run_dir>        (required)
  --target-fp-ids fp_l05_01                     (default, restricted)
  --generate                                     (default off → dry-run)
  --model gpt-5.5                                (default; fail-closed)
  --output-root <path>
  --diag-print-imports
"""
from __future__ import annotations

import argparse
import base64
import json
import os
import sys
from datetime import datetime
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Set

_REPO_ROOT = Path(__file__).resolve().parents[2]
_SCRIPTS_DIR = _REPO_ROOT / "backend" / "scripts"
if str(_SCRIPTS_DIR) not in sys.path:
    sys.path.insert(0, str(_SCRIPTS_DIR))

from experiment_background_pipeline_slice import (  # type: ignore
    KST,
    PLAN_VERSION,
    _check_image_imports_present,
    _check_production_diff_empty,
    _load_backend_env,
    _maybe_print_imports,
)
from experiment_floor_plan_grid_layout_slice import (  # type: ignore
    W16_ALLOWED_TARGET_FP_IDS,
    _resolve_targets,
)
from experiment_floor_plan_image_smoke_slice import (  # type: ignore
    _load_w15e_candidate_index,
)

W17C_STAGE = "w17c_floor_plan_vlm_readback_slice"
W17C_DEFAULT_MODEL = "gpt-5.5"

W17C_CONFIDENCE_ENUM = frozenset({"high", "medium", "low", "unknown"})

W17C_SYSTEM_PROMPT = """\
You are a schematic floor-plan diagram reader.

Input you receive:
- One PNG image of a schematic floor-plan diagram with circled marker
  numbers drawn directly on the plan.
- A JSON `legend` mapping marker_number -> {label, element_kind,
  visual_encoding, unit_id_pointer, position_hint}. Treat this legend
  as the ground-truth mapping; your job is to OBSERVE where each
  marker actually appears in the image, not to redesign the legend.

Output: a single JSON object with this shape (do NOT add extra
top-level keys; do NOT wrap in markdown):

{
  "read_markers": [
    {
      "marker_number": int,
      "visible": bool,
      "confidence": "high" | "medium" | "low" | "unknown",
      "approximate_region_or_cell": string,
      "observed_unit_label_or_area": string,
      "notes": string
    }, ...
  ],
  "missing_or_ambiguous_markers": [
    {
      "marker_number": int,
      "reason": string
    }, ...
  ],
  "unit_boundary_summary": string,
  "readback_conflicts": [
    {
      "marker_number": int,
      "expected_unit": string,
      "observed_unit_label_or_area": string,
      "note": string
    }, ...
  ]
}

Reading rules:
- Use a coarse approximate region (e.g. "northwest area", "center",
  "east room", "row 3 column 5") — do NOT invent pixel-precise
  coordinates. Optionally use a 10x10 cell index like "(col=6,row=4)"
  when the marker sits unambiguously inside one cell of an imagined
  10x10 coarse grid overlay; otherwise stay with the verbal region.
- `observed_unit_label_or_area` is what you SEE in the diagram for the
  area the marker sits in. It may not match the legend's
  `unit_id_pointer`; that is fine — the comparison stage will resolve
  it. Do NOT silently rewrite to match the legend.
- `readback_conflicts[]` lists markers whose observed unit/area
  visibly disagrees with the legend mapping. Mention each conflict
  exactly once and explain briefly.
- Every marker_number from the legend MUST appear EITHER in
  `read_markers[]` OR in `missing_or_ambiguous_markers[]`. Do not skip
  any number, do not duplicate any number across the two lists.
- Do not invent marker numbers that are not in the legend.
- Use generic architectural / spatial language only. Do not embed
  scenario-specific proper nouns or narrative wording in any string
  field beyond what the legend itself already provides.

Reply ONLY with the JSON object.
"""


def _vlm_caller_default(*, llm_input: Dict[str, Any], model: str,
                        retry_once: bool = False) -> Dict[str, Any]:
    """Default VLM caller using litellm. Test monkeypatch seam is the
    module-level `_vlm_caller` alias below. Fail-closed: missing
    OPENAI_API_KEY → RuntimeError; model id ≠ exact `W17C_DEFAULT_MODEL`
    → RuntimeError BEFORE litellm call. No fallback. No auto retry by
    default."""
    if not os.environ.get("OPENAI_API_KEY"):
        raise RuntimeError("missing OPENAI_API_KEY env var")
    if (model or "").strip() != W17C_DEFAULT_MODEL:
        raise RuntimeError(
            f"w17c routing refuses non-exact model: got '{model}', "
            f"required exact id '{W17C_DEFAULT_MODEL}' (no fallback allowed)"
        )

    import litellm  # lazy

    png_b64 = llm_input.get("_png_base64_data_url") or ""
    legend_payload = {
        "legend": llm_input.get("legend") or [],
        "fp_id": llm_input.get("fp_id") or "",
        "candidate_numbered_elements": llm_input.get(
            "candidate_numbered_elements"
        ) or [],
    }
    user_content = [
        {
            "type": "text",
            "text": (
                "Read the marker positions in the attached PNG using the "
                "provided legend.\n\n"
                + json.dumps(legend_payload, ensure_ascii=False)
            ),
        },
        {"type": "image_url", "image_url": {"url": png_b64}},
    ]
    last_exc: Optional[Exception] = None
    attempts = 2 if retry_once else 1
    for _ in range(attempts):
        try:
            resp = litellm.completion(
                model=model,
                messages=[
                    {"role": "system", "content": W17C_SYSTEM_PROMPT},
                    {"role": "user", "content": user_content},
                ],
                response_format={"type": "json_object"},
            )
            raw_text = resp.choices[0].message.content
            decoder = json.JSONDecoder()
            stripped = (raw_text or "").lstrip()
            parsed, _ = decoder.raw_decode(stripped)
            return parsed
        except Exception as exc:  # noqa: BLE001
            last_exc = exc
            continue
    raise RuntimeError(f"w17c_vlm_failed: {last_exc!s}"[:400])


# Test-monkeypatch seam.
_vlm_caller: Callable = _vlm_caller_default


_DEFAULT_OUTPUT_ROOT = (
    _REPO_ROOT / "scripts_output" / "floor_plan_vlm_readback_slice_experiment"
)


def _run_id() -> str:
    import secrets

    return datetime.now(KST).strftime("%Y%m%d_%H%M") + "_" + secrets.token_hex(3)


def _parse_args(argv):
    p = argparse.ArgumentParser(
        description=(
            "W17C VLM readback slice — read marker positions from the "
            "W17B3 PNG. No image API call."
        )
    )
    p.add_argument(
        "--derive-readback-from", required=True,
        help="Path to a prior W17B3 success run dir (containing "
             "png/<fp_id>.png + run_meta.json).",
    )
    p.add_argument(
        "--target-fp-ids", default="fp_l05_01",
        help="Comma-separated fp_id subset. Wave 1 only allows fp_l05_01.",
    )
    p.add_argument(
        "--generate", action="store_true",
        help="Actually call the VLM once. Default off → dry-run.",
    )
    p.add_argument("--model", default=W17C_DEFAULT_MODEL)
    p.add_argument("--output-root", default=str(_DEFAULT_OUTPUT_ROOT))
    p.add_argument("--diag-print-imports", action="store_true")
    return p.parse_args(argv)


def _resolve_w17a_run_dir_from_w17b3(w17b3_meta: Dict[str, Any]) -> Optional[Path]:
    args_dict = (w17b3_meta or {}).get("args") or {}
    if not isinstance(args_dict, dict):
        return None
    raw = args_dict.get("derive_image_smoke_from")
    if isinstance(raw, str) and raw:
        p = Path(raw)
        if not p.is_absolute():
            p = _REPO_ROOT / raw
        if (p / "floor_plan_image_prompt.json").exists():
            return p
    derived_from = w17b3_meta.get("derived_from") or ""
    if isinstance(derived_from, str) and derived_from:
        parent = (
            _REPO_ROOT / "scripts_output" / "floor_plan_image_prompt_slice_experiment"
        )
        if (parent / derived_from / "floor_plan_image_prompt.json").exists():
            return parent / derived_from
    return None


def _build_w17c_compatibility_report(
    *, vlm_output: Dict[str, Any],
    expected_legend: List[Dict[str, Any]],
    candidate_index: Dict[int, Dict[str, str]],
    production_diff_empty: bool,
    db_write_count: int,
    image_import_seen: bool,
    image_api_call_count: int,
    vlm_api_call_count: int,
    model_used: Optional[str],
    stage_status: str,
    missing_inputs: List[str],
    prev_run_id: str,
    target_fp_ids: Set[str],
) -> dict:
    inv: Dict[str, Dict[str, Any]] = {}

    inv["inputs_present"] = {
        "pass": (
            not missing_inputs
            and bool(expected_legend)
            and bool(vlm_output)
        ),
        "detail": {
            "missing_inputs": list(missing_inputs),
            "legend_entry_count": len(expected_legend),
            "stage_status": stage_status,
            "prev_run_id": prev_run_id,
        },
    }

    inv["target_fp_only_fp_l05_01"] = {
        "pass": bool(target_fp_ids) and target_fp_ids.issubset(
            W16_ALLOWED_TARGET_FP_IDS
        ),
        "detail": {
            "received_target_fp_ids": sorted(target_fp_ids or set()),
            "allowed": sorted(W16_ALLOWED_TARGET_FP_IDS),
        },
    }

    if stage_status == "dry_run":
        model_pass = True
        model_detail = {"skip_reason": "dry_run"}
    else:
        model_pass = (model_used or "").strip() == W17C_DEFAULT_MODEL
        model_detail = {
            "model_used": model_used,
            "required": W17C_DEFAULT_MODEL,
            "stage_status": stage_status,
        }
    inv["model_is_gpt_5_5_when_generated"] = {
        "pass": model_pass, "detail": model_detail,
    }

    llm_active = stage_status == "generated"
    dry_skip = {"skip_reason": "dry_run"}

    # 4. vlm_output_schema_shape_valid — light shape check.
    if not llm_active:
        inv["vlm_output_schema_shape_valid"] = {
            "pass": True, "detail": dict(dry_skip),
        }
    else:
        shape_failures: List[dict] = []
        for key in ("read_markers", "missing_or_ambiguous_markers",
                    "readback_conflicts"):
            v = vlm_output.get(key)
            if not isinstance(v, list):
                shape_failures.append({"field": key, "reason": "not_a_list"})
        if not isinstance(vlm_output.get("unit_boundary_summary"), str):
            shape_failures.append({
                "field": "unit_boundary_summary", "reason": "not_a_string",
            })
        for entry in vlm_output.get("read_markers") or []:
            if not isinstance(entry, dict):
                shape_failures.append({
                    "field": "read_markers[*]", "reason": "not_a_dict",
                })
                continue
            if not isinstance(entry.get("marker_number"), int):
                shape_failures.append({
                    "field": "read_markers[*].marker_number",
                    "reason": "not_int",
                })
            if not isinstance(entry.get("visible"), bool):
                shape_failures.append({
                    "field": "read_markers[*].visible",
                    "reason": "not_bool",
                })
            if entry.get("confidence") not in W17C_CONFIDENCE_ENUM:
                shape_failures.append({
                    "field": "read_markers[*].confidence",
                    "reason": "not_in_enum",
                    "value": entry.get("confidence"),
                })
        for entry in vlm_output.get("missing_or_ambiguous_markers") or []:
            if not isinstance(entry, dict):
                shape_failures.append({
                    "field": "missing_or_ambiguous_markers[*]",
                    "reason": "not_a_dict",
                })
                continue
            if not isinstance(entry.get("marker_number"), int):
                shape_failures.append({
                    "field": "missing_or_ambiguous_markers[*].marker_number",
                    "reason": "not_int",
                })
        inv["vlm_output_schema_shape_valid"] = {
            "pass": not shape_failures,
            "detail": {
                "failures": shape_failures[:30],
                "failure_count": len(shape_failures),
            },
        }

    # 5. read_markers_partition_full_legend — every legend marker either
    # in `read_markers` or `missing_or_ambiguous_markers`, no overlap,
    # no extras.
    expected_numbers: Set[int] = {
        int(it["marker_number"]) for it in expected_legend
        if isinstance(it, dict) and isinstance(it.get("marker_number"), int)
    }
    if not llm_active:
        inv["read_markers_partition_full_legend"] = {
            "pass": True, "detail": dict(dry_skip),
        }
    else:
        read_numbers: Set[int] = set()
        missing_numbers: Set[int] = set()
        for entry in vlm_output.get("read_markers") or []:
            if isinstance(entry, dict) and isinstance(entry.get("marker_number"), int):
                read_numbers.add(int(entry["marker_number"]))
        for entry in vlm_output.get("missing_or_ambiguous_markers") or []:
            if isinstance(entry, dict) and isinstance(entry.get("marker_number"), int):
                missing_numbers.add(int(entry["marker_number"]))
        overlap = read_numbers & missing_numbers
        covered = read_numbers | missing_numbers
        unaccounted = expected_numbers - covered
        extras = covered - expected_numbers
        passed = not overlap and not unaccounted and not extras
        inv["read_markers_partition_full_legend"] = {
            "pass": passed,
            "detail": {
                "expected_count": len(expected_numbers),
                "read_count": len(read_numbers),
                "missing_count": len(missing_numbers),
                "overlap_marker_numbers": sorted(overlap),
                "missing_marker_numbers": sorted(unaccounted),
                "extra_marker_numbers": sorted(extras),
            },
        }

    # 6. unit_conflict_report_against_legend — for each read_marker,
    # compare its observed_unit_label_or_area against the legend
    # `unit_id_pointer` (case-insensitive substring containment, only as
    # a diagnostic — no semantic LLM judgement). This invariant is
    # ALWAYS PASS (it is a surface, not a gate). Detail surfaces the
    # potential conflicts so a human can decide.
    if not llm_active:
        inv["unit_conflict_report_against_legend"] = {
            "pass": True, "detail": dict(dry_skip),
        }
    else:
        unit_index_by_n: Dict[int, str] = {
            n: (info.get("unit") or "")
            for n, info in (candidate_index or {}).items()
        }
        potential_conflicts: List[dict] = []
        for entry in vlm_output.get("read_markers") or []:
            if not isinstance(entry, dict):
                continue
            n = entry.get("marker_number")
            if not isinstance(n, int):
                continue
            expected_unit = unit_index_by_n.get(n) or ""
            observed = entry.get("observed_unit_label_or_area") or ""
            if not expected_unit:
                continue
            eu = expected_unit.lower().replace("_", " ")
            ob = observed.lower()
            if eu and eu not in ob and observed.lower() not in eu:
                potential_conflicts.append({
                    "marker_number": n,
                    "expected_unit": expected_unit,
                    "observed_unit_label_or_area": observed,
                })
        # Surface the VLM's own conflicts list too.
        vlm_reported_conflicts = vlm_output.get("readback_conflicts") or []
        inv["unit_conflict_report_against_legend"] = {
            "pass": True,
            "detail": {
                "potential_conflicts": potential_conflicts[:50],
                "potential_conflict_count": len(potential_conflicts),
                "vlm_reported_conflict_count": len(vlm_reported_conflicts),
                "note": "diagnostic only — VLM output is not promoted to truth",
            },
        }

    # 7. production / db / image-api guard.
    inv["production_diff_zero_db_write_zero_image_api_call_zero"] = {
        "pass": (
            production_diff_empty
            and db_write_count == 0
            and not image_import_seen
            and image_api_call_count == 0
        ),
        "detail": {
            "production_diff_empty": production_diff_empty,
            "db_write_count": db_write_count,
            "image_import_seen": image_import_seen,
            "image_api_call_count": image_api_call_count,
            "vlm_api_call_count": vlm_api_call_count,
        },
    }

    all_pass = all(v["pass"] for v in inv.values())
    return {"invariants": inv, "all_pass": all_pass}


def _placeholder_dry_run_vlm_output() -> dict:
    return {
        "read_markers": [],
        "missing_or_ambiguous_markers": [],
        "unit_boundary_summary": "placeholder_dry_run",
        "readback_conflicts": [],
    }


def main(argv=None) -> int:
    args = _parse_args(argv)
    if args.generate:
        _load_backend_env()
    run_id = _run_id()
    out_root = Path(args.output_root)
    run_dir = out_root / run_id
    run_dir.mkdir(parents=True, exist_ok=True)

    prev_run_dir = Path(args.derive_readback_from)
    if not prev_run_dir.is_absolute():
        prev_run_dir = Path.cwd() / prev_run_dir

    target_fp_ids, invalid_targets = _resolve_targets(args.target_fp_ids)

    failed_invariants: List[str] = []
    run_status = "succeeded"
    exit_code = 0
    stage_status = "dry_run"
    vlm_api_call_count = 0
    model_used: Optional[str] = None

    run_meta: Dict[str, Any] = {
        "run_id": run_id,
        "stage": W17C_STAGE,
        "plan_version": PLAN_VERSION,
        "generated_at": datetime.now(KST).isoformat(),
        "args": vars(args),
        "derived_from": prev_run_dir.name,
        "target_fp_ids": sorted(target_fp_ids),
        "invalid_targets": invalid_targets,
        "model_used": model_used,
        "expected_model": W17C_DEFAULT_MODEL,
        "image_api_call_count": 0,
        "vlm_api_call_count": vlm_api_call_count,
        "outputs": [],
        "run_status": run_status,
        "exit_code": exit_code,
        "failed_invariants": failed_invariants,
        "stage_status": stage_status,
    }

    def _persist_and_exit(code: int) -> int:
        run_meta["exit_code"] = code
        if code != 0:
            run_meta["run_status"] = "validation_failed"
        run_meta["failed_invariants"] = failed_invariants
        (run_dir / "run_meta.json").write_text(
            json.dumps(run_meta, ensure_ascii=False, indent=2)
        )
        return code

    # Required input files.
    w17b3_meta_path = prev_run_dir / "run_meta.json"
    missing: List[str] = []
    if not w17b3_meta_path.exists():
        missing.append("run_meta.json")
    if missing:
        failed_invariants.append("w17b3_inputs_missing")
        run_meta["missing_inputs"] = missing
        return _persist_and_exit(1)
    if invalid_targets:
        failed_invariants.append("invalid_target_fp_ids")
        return _persist_and_exit(1)
    if not target_fp_ids.issubset(W16_ALLOWED_TARGET_FP_IDS):
        failed_invariants.append("target_fp_outside_allowed")
        return _persist_and_exit(1)

    w17b3_meta = json.loads(w17b3_meta_path.read_text())
    fp_id = sorted(target_fp_ids)[0]
    png_path = prev_run_dir / "png" / f"{fp_id}.png"
    if not png_path.exists():
        failed_invariants.append("w17b3_png_missing")
        run_meta["png_path"] = str(png_path)
        return _persist_and_exit(1)

    w17a_run_dir = _resolve_w17a_run_dir_from_w17b3(w17b3_meta)
    expected_legend: List[Dict[str, Any]] = []
    candidate_index: Dict[int, Dict[str, str]] = {}
    if w17a_run_dir is not None:
        try:
            prompt_artifact = json.loads(
                (w17a_run_dir / "floor_plan_image_prompt.json").read_text()
            )
            by_fp = (prompt_artifact or {}).get("floor_plan_image_prompt_by_fp") or {}
            fp_entry = by_fp.get(fp_id) or {}
            expected_legend = list(fp_entry.get("numbered_marker_legend") or [])
        except Exception:  # noqa: BLE001
            expected_legend = []
        try:
            w17a_meta = json.loads((w17a_run_dir / "run_meta.json").read_text())
            cand_idx = _load_w15e_candidate_index(
                w17a_run_dir=w17a_run_dir, w17a_run_meta=w17a_meta,
            )
            candidate_index = cand_idx.get(fp_id) or {}
        except Exception:  # noqa: BLE001
            candidate_index = {}

    vlm_output: Dict[str, Any] = {}
    if args.generate:
        if (args.model or "").strip() != W17C_DEFAULT_MODEL:
            failed_invariants.append("model_must_be_gpt_5_5")
            return _persist_and_exit(1)
        if not os.environ.get("OPENAI_API_KEY"):
            failed_invariants.append("openai_api_key_missing")
            return _persist_and_exit(1)
        try:
            png_bytes = png_path.read_bytes()
            png_b64 = base64.b64encode(png_bytes).decode("ascii")
            data_url = f"data:image/png;base64,{png_b64}"
            llm_input = {
                "_png_base64_data_url": data_url,
                "fp_id": fp_id,
                "legend": expected_legend,
                "candidate_numbered_elements": [
                    {"number": n, **(info or {})}
                    for n, info in candidate_index.items()
                ],
            }
            vlm_api_call_count = 1
            vlm_output = _vlm_caller(
                llm_input=llm_input, model=args.model, retry_once=False,
            )
            model_used = args.model
            stage_status = "generated"
        except Exception as exc:  # noqa: BLE001
            failed_invariants.append("vlm_call_failed")
            run_meta["vlm_error"] = str(exc)[:400]
            run_status = "validation_failed"
            exit_code = 1
            stage_status = "vlm_failed"
    else:
        vlm_output = _placeholder_dry_run_vlm_output()
        model_used = None
        stage_status = "dry_run"

    (run_dir / "vlm_readback.json").write_text(
        json.dumps(vlm_output, ensure_ascii=False, indent=2)
    )
    run_meta["outputs"].append("vlm_readback.json")

    report = _build_w17c_compatibility_report(
        vlm_output=vlm_output,
        expected_legend=expected_legend,
        candidate_index=candidate_index,
        production_diff_empty=_check_production_diff_empty(),
        db_write_count=0,
        image_import_seen=_check_image_imports_present(),
        image_api_call_count=0,
        vlm_api_call_count=vlm_api_call_count,
        model_used=model_used,
        stage_status=stage_status,
        missing_inputs=missing,
        prev_run_id=prev_run_dir.name,
        target_fp_ids=target_fp_ids,
    )
    (run_dir / "vlm_readback_comparison_report.json").write_text(
        json.dumps(report, ensure_ascii=False, indent=2)
    )
    run_meta["outputs"].append("vlm_readback_comparison_report.json")

    for name, v in report["invariants"].items():
        if not v["pass"] and name not in failed_invariants:
            failed_invariants.append(name)
    if failed_invariants and run_status == "succeeded":
        run_status = "validation_failed"
        exit_code = 1

    run_meta["model_used"] = model_used
    run_meta["stage_status"] = stage_status
    run_meta["vlm_api_call_count"] = vlm_api_call_count
    run_meta["run_status"] = run_status
    run_meta["exit_code"] = exit_code
    run_meta["failed_invariants"] = failed_invariants

    # Lightweight HTML index.
    def esc(x):
        return (
            str(x).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
        )

    read_rows = "".join(
        f"<tr><td>#{esc(it.get('marker_number'))}</td>"
        f"<td>{esc(it.get('visible'))}</td>"
        f"<td>{esc(it.get('confidence'))}</td>"
        f"<td>{esc(it.get('approximate_region_or_cell'))}</td>"
        f"<td>{esc(it.get('observed_unit_label_or_area'))}</td>"
        f"<td>{esc(it.get('notes'))}</td></tr>"
        for it in (vlm_output.get("read_markers") or [])
    )
    missing_rows = "".join(
        f"<tr><td>#{esc(it.get('marker_number'))}</td>"
        f"<td>{esc(it.get('reason'))}</td></tr>"
        for it in (vlm_output.get("missing_or_ambiguous_markers") or [])
    )
    conflict_rows = "".join(
        f"<tr><td>#{esc(it.get('marker_number'))}</td>"
        f"<td>{esc(it.get('expected_unit'))}</td>"
        f"<td>{esc(it.get('observed_unit_label_or_area'))}</td>"
        f"<td>{esc(it.get('note'))}</td></tr>"
        for it in (vlm_output.get("readback_conflicts") or [])
    )
    inv_rows = "".join(
        f"<tr><td>{esc(k)}</td>"
        f"<td class=\"{'pass' if v['pass'] else 'fail'}\">"
        f"{'PASS' if v['pass'] else 'FAIL'}</td>"
        f"<td><pre>{esc(json.dumps(v.get('detail'), ensure_ascii=False))[:600]}</pre></td>"
        f"</tr>"
        for k, v in (report.get("invariants") or {}).items()
    )

    (run_dir / "index.html").write_text(
        f"""<!doctype html><html><head><meta charset="utf-8">
<title>W17C vlm_readback {esc(run_id)}</title>
<style>body{{font-family:sans-serif;margin:1.5em}}
table{{border-collapse:collapse;margin:0.5em 0}} td,th{{border:1px solid #ccc;padding:4px 8px;vertical-align:top}}
.pass{{color:#080}} .fail{{color:#b00}}
pre{{white-space:pre-wrap;font-size:0.85em;max-width:84ch}}
section{{margin:1.5em 0}}</style></head><body>
<h1>W17C — vlm_readback {esc(run_id)}</h1>
<p>stage: <b>{esc(stage_status)}</b>
| run_status: <b>{esc(run_status)}</b>
| exit_code: {esc(exit_code)}
| model: <b>{esc(model_used)}</b>
| vlm_api_call_count: <b>{esc(vlm_api_call_count)}</b>
| image_api_call_count: <b>0</b></p>

<section><h2>unit_boundary_summary</h2>
<pre>{esc(vlm_output.get('unit_boundary_summary') or '')}</pre></section>

<section><h2>read_markers</h2>
<table><tr><th>marker</th><th>visible</th><th>confidence</th><th>region</th>
<th>observed_unit</th><th>notes</th></tr>{read_rows}</table></section>

<section><h2>missing_or_ambiguous_markers</h2>
<table><tr><th>marker</th><th>reason</th></tr>{missing_rows}</table></section>

<section><h2>readback_conflicts (VLM-reported)</h2>
<table><tr><th>marker</th><th>expected_unit</th><th>observed_unit_label_or_area</th>
<th>note</th></tr>{conflict_rows}</table></section>

<section><h2>Invariants</h2>
<table><tr><th>invariant</th><th>status</th><th>detail</th></tr>
{inv_rows}</table></section>

<details><summary>raw run_meta.json</summary>
<pre>{esc(json.dumps(run_meta, ensure_ascii=False, indent=2))}</pre></details>
<details><summary>raw vlm_readback.json</summary>
<pre>{esc(json.dumps(vlm_output, ensure_ascii=False, indent=2))[:200000]}</pre></details>
</body></html>"""
    )
    run_meta["outputs"].append("index.html")

    (run_dir / "run_meta.json").write_text(
        json.dumps(run_meta, ensure_ascii=False, indent=2)
    )
    _maybe_print_imports(args)
    return exit_code


if __name__ == "__main__":
    sys.exit(main())
