"""experiment_background_image_smoke_remaining_l05_slice — W18H.

Generate the remaining four L05 background smokes (L05B01, L05B02,
L05B03, L05B05) after the W18G L05B04 smoke. L05B04 is intentionally
excluded from this batch to avoid duplicating the W18G generation.

For each target bg_id, this wrapper:
- carries the W18F ``assembled_background_prompt_preview`` verbatim,
- reuses the W18G rendering preamble (no scene-specific prose added),
- references the same W18B base FP PNG used by W18G,
- issues exactly one gpt-image-2 ``images.edit`` call per target when
  ``--generate`` is set; ``image_api_call_count`` therefore matches
  the number of targets and not the number of retries (retries: 0).

No DB write. No ImageAsset write. No production manifest mutation.
No commit. No push. No second batch.

CLI:
  --derive-background-smoke-from <W18F_run_dir>      (required)
  --target-bg-ids L05B01,L05B02,L05B03,L05B05        (default; L05B04 banned here)
  --generate                                          (default off → dry-run)
  --model gpt-image-2                                (fail-closed)
  --output-root <path>
  --diag-print-imports
"""
from __future__ import annotations

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

_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_actual_floor_plan_generation_slice import (  # type: ignore
    _create_openai_client,
)
from experiment_background_image_smoke_from_region_ref_slice import (  # type: ignore
    W18G_IMAGE_BACKEND,
    W18G_IMAGE_SIZE,
    _RENDERING_PREAMBLE,
    _build_smoke_prompt,
    _edit_png_via_openai_with_reference,
    _resolve_w18b_base_fp_png,
)

W18H_STAGE = "w18h_background_image_smoke_remaining_l05_slice"
W18H_ALLOWED_BG_IDS = {"L05B01", "L05B02", "L05B03", "L05B05"}

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

# Module-level caller alias is the test monkeypatch seam.
_openai_edit_caller: Callable = _edit_png_via_openai_with_reference


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=(
            "W18H remaining L05 background smokes — one gpt-image-2 "
            "images.edit call per target bg_id. L05B04 is excluded "
            "from this wave because W18G already generated it."
        )
    )
    p.add_argument(
        "--derive-background-smoke-from", required=True,
        help="Path to a prior W18F success run dir.",
    )
    p.add_argument(
        "--target-bg-ids", default="L05B01,L05B02,L05B03,L05B05",
        help=(
            "CSV of bg_ids. W18H only allows {L05B01,L05B02,L05B03,"
            "L05B05}. L05B04 is rejected here (it was generated by "
            "W18G)."
        ),
    )
    p.add_argument(
        "--generate", action="store_true",
        help="Issue one images.edit call per target. Default off → dry-run.",
    )
    p.add_argument("--model", default=W18G_IMAGE_BACKEND)
    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_target_bg_ids(raw: str) -> tuple[list[str], list[str]]:
    parts = [p.strip() for p in (raw or "").split(",") if p.strip()]
    seen: List[str] = []
    invalid: List[str] = []
    for p in parts:
        if p in W18H_ALLOWED_BG_IDS:
            if p not in seen:
                seen.append(p)
        else:
            invalid.append(p)
    return seen, invalid


def _build_w18h_compatibility_report(
    *, target_bg_ids: List[str], invalid_targets: List[str],
    per_bg_results: Dict[str, Dict[str, Any]], mode: str,
    image_api_call_count: int, image_generation_count: int,
    production_diff_empty: bool, db_write_count: int,
    image_asset_write_count: int, image_import_seen: bool,
    missing_inputs: List[str], base_fp_png_path: Optional[Path],
) -> dict:
    inv: Dict[str, Dict[str, Any]] = {}

    inv["inputs_present"] = {
        "pass": (
            not missing_inputs
            and bool(per_bg_results)
            and base_fp_png_path is not None
            and base_fp_png_path.exists()
        ),
        "detail": {
            "missing_inputs": list(missing_inputs),
            "per_bg_result_count": len(per_bg_results),
            "base_fp_png_path": (
                str(base_fp_png_path) if base_fp_png_path else None
            ),
            "base_fp_png_exists": (
                bool(base_fp_png_path and base_fp_png_path.exists())
            ),
        },
    }

    inv["target_bgs_within_allowed_remaining_l05"] = {
        "pass": (
            bool(target_bg_ids)
            and not invalid_targets
            and set(target_bg_ids).issubset(W18H_ALLOWED_BG_IDS)
            and "L05B04" not in target_bg_ids
        ),
        "detail": {
            "received_target_bg_ids": list(target_bg_ids),
            "invalid_targets": list(invalid_targets),
            "allowed": sorted(W18H_ALLOWED_BG_IDS),
            "l05b04_excluded": "L05B04" not in target_bg_ids,
        },
    }

    prompt_fp_failures: List[dict] = []
    for bg_id, row in per_bg_results.items():
        if (row or {}).get("prompt_text_len_chars", 0) <= 0:
            prompt_fp_failures.append({"bg_id": bg_id,
                                       "reason": "prompt_text_missing"})
        if not (row or {}).get("base_fp_png_exists"):
            prompt_fp_failures.append({
                "bg_id": bg_id, "reason": "base_fp_png_unresolved",
            })
    inv["prompt_and_fp_ref_present_for_all_targets"] = {
        "pass": not prompt_fp_failures and bool(per_bg_results),
        "detail": {
            "failures": prompt_fp_failures,
            "failure_count": len(prompt_fp_failures),
        },
    }

    if mode == "dry_run":
        png_all_absent = all(
            not (row or {}).get("png_exists_on_disk")
            for row in per_bg_results.values()
        )
        gen_ok = (
            image_api_call_count == 0
            and image_generation_count == 0
            and png_all_absent
        )
    else:
        n_targets = len(per_bg_results)
        png_all_present = all(
            (row or {}).get("png_exists_on_disk")
            for row in per_bg_results.values()
        )
        gen_ok = (
            image_api_call_count == n_targets
            and image_generation_count == n_targets
            and png_all_present
            and n_targets > 0
        )
    inv["image_generation_count_matches_targets_and_mode"] = {
        "pass": gen_ok,
        "detail": {
            "mode": mode,
            "image_api_call_count": image_api_call_count,
            "image_generation_count": image_generation_count,
            "per_bg_png_existence": {
                bg_id: bool((row or {}).get("png_exists_on_disk"))
                for bg_id, row in per_bg_results.items()
            },
        },
    }

    inv["production_diff_zero_db_write_zero_no_imageasset_write"] = {
        "pass": (
            production_diff_empty
            and db_write_count == 0
            and image_asset_write_count == 0
            and not image_import_seen
        ),
        "detail": {
            "production_diff_empty": production_diff_empty,
            "db_write_count": db_write_count,
            "image_asset_write_count": image_asset_write_count,
            "image_import_seen": image_import_seen,
        },
    }

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


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_background_smoke_from)
    if not prev_run_dir.is_absolute():
        prev_run_dir = Path.cwd() / prev_run_dir

    target_bg_ids, invalid_targets = _resolve_target_bg_ids(args.target_bg_ids)

    failed_invariants: List[str] = []
    run_status = "succeeded"
    exit_code = 0
    mode = "dry_run"
    image_api_call_count = 0
    image_generation_count = 0

    run_meta: Dict[str, Any] = {
        "run_id": run_id,
        "stage": W18H_STAGE,
        "plan_version": PLAN_VERSION,
        "generated_at": datetime.now(KST).isoformat(),
        "args": vars(args),
        "derived_from": prev_run_dir.name,
        "target_bg_ids": list(target_bg_ids),
        "invalid_targets": list(invalid_targets),
        "model": args.model,
        "mode": mode,
        "image_api_call_count": image_api_call_count,
        "image_generation_count": image_generation_count,
        "outputs": [],
        "run_status": run_status,
        "exit_code": exit_code,
        "failed_invariants": failed_invariants,
    }

    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

    if invalid_targets:
        failed_invariants.append("invalid_target_bg_ids")
        return _persist_and_exit(1)
    if not target_bg_ids:
        failed_invariants.append("no_target_bg_ids")
        return _persist_and_exit(1)
    if "L05B04" in target_bg_ids:
        failed_invariants.append("l05b04_not_allowed_in_w18h")
        return _persist_and_exit(1)

    w18f_payload_path = (
        prev_run_dir / "background_prompt_region_ref_assembly_preview.json"
    )
    w18f_meta_path = prev_run_dir / "run_meta.json"
    missing: List[str] = []
    if not w18f_payload_path.exists():
        missing.append("background_prompt_region_ref_assembly_preview.json")
    if not w18f_meta_path.exists():
        missing.append("run_meta.json")
    if missing:
        failed_invariants.append("w18f_inputs_missing")
        run_meta["missing_inputs"] = missing
        return _persist_and_exit(1)

    w18f_payload = json.loads(w18f_payload_path.read_text())
    base_fp_png_path: Optional[Path] = None
    if target_bg_ids:
        base_fp_png_path = _resolve_w18b_base_fp_png(
            w18f_assembly_payload=w18f_payload, bg_id=target_bg_ids[0],
        )

    client = None
    if args.generate:
        if (args.model or "").strip() != W18G_IMAGE_BACKEND:
            failed_invariants.append("model_must_be_gpt_image_2")
            run_meta["model_error"] = (
                f"got {args.model!r}, required {W18G_IMAGE_BACKEND!r}"
            )
            return _persist_and_exit(1)
        if base_fp_png_path is None or not base_fp_png_path.exists():
            failed_invariants.append("base_fp_png_unresolved")
            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:
            client = _create_openai_client()
        except Exception as exc:  # noqa: BLE001
            failed_invariants.append("openai_client_unavailable")
            run_meta["openai_client_error"] = str(exc)[:240]
            return _persist_and_exit(1)
        mode = "generated"

    per_bg_results: Dict[str, Dict[str, Any]] = {}

    for bg_id in target_bg_ids:
        bg_payload = (
            (w18f_payload or {}).get("assembly_preview_by_bg") or {}
        ).get(bg_id) or {}
        assembled_preview = (
            bg_payload.get("assembled_background_prompt_preview") or ""
        )
        prompt_text = (
            _build_smoke_prompt(assembled_preview=assembled_preview)
            if assembled_preview else ""
        )
        bg_base_fp = _resolve_w18b_base_fp_png(
            w18f_assembly_payload=w18f_payload, bg_id=bg_id,
        )
        png_path = run_dir / "png" / f"{bg_id}.png"
        row: Dict[str, Any] = {
            "bg_id": bg_id,
            "fp_id": bg_payload.get("fp_id") or "",
            "model": args.model,
            "size": W18G_IMAGE_SIZE,
            "prompt_text_len_chars": len(prompt_text),
            "rendering_preamble": _RENDERING_PREAMBLE,
            "assembled_preview_carried": bool(assembled_preview),
            "assembled_preview_len_chars": len(assembled_preview),
            "transient_overlay_marker_numbers": list(
                bg_payload.get("transient_overlay_marker_numbers") or []
            ),
            "clean_background_expected": bool(
                bg_payload.get("clean_background_expected")
            ),
            "base_fp_png_path": (
                str(bg_base_fp) if bg_base_fp else ""
            ),
            "base_fp_png_exists": (
                bool(bg_base_fp and bg_base_fp.exists())
            ),
            "png_relative_path": str(png_path.relative_to(run_dir)),
            "png_exists_on_disk": False,
            "status": "dry_run",
        }

        if args.generate:
            if not prompt_text:
                row["status"] = "skipped_missing_prompt"
                failed_invariants.append(f"prompt_missing_for_{bg_id}")
                per_bg_results[bg_id] = row
                continue
            if bg_base_fp is None or not bg_base_fp.exists():
                row["status"] = "skipped_missing_fp_ref"
                failed_invariants.append(f"fp_ref_missing_for_{bg_id}")
                per_bg_results[bg_id] = row
                continue

            image_api_call_count += 1
            outcome = _openai_edit_caller(
                bg_id=bg_id, prompt=prompt_text, model=args.model,
                size=W18G_IMAGE_SIZE, base_fp_png_path=bg_base_fp,
                target_path=png_path, client=client,
            )
            row.update(outcome)
            if outcome.get("status") == "success":
                image_generation_count += 1
                row["png_exists_on_disk"] = png_path.exists()
            else:
                failed_invariants.append(f"openai_call_failed_for_{bg_id}")
                run_status = "validation_failed"
                exit_code = 1
        else:
            row["png_exists_on_disk"] = png_path.exists()

        per_bg_results[bg_id] = row

    (run_dir / "background_image_smoke_remaining_results.json").write_text(
        json.dumps(
            {"per_bg_results": per_bg_results,
             "target_bg_ids": list(target_bg_ids)},
            ensure_ascii=False, indent=2,
        )
    )
    run_meta["outputs"].append("background_image_smoke_remaining_results.json")

    production_diff_empty = _check_production_diff_empty()
    image_import_seen = _check_image_imports_present()

    report = _build_w18h_compatibility_report(
        target_bg_ids=target_bg_ids, invalid_targets=invalid_targets,
        per_bg_results=per_bg_results, mode=mode,
        image_api_call_count=image_api_call_count,
        image_generation_count=image_generation_count,
        production_diff_empty=production_diff_empty,
        db_write_count=0, image_asset_write_count=0,
        image_import_seen=image_import_seen,
        missing_inputs=missing, base_fp_png_path=base_fp_png_path,
    )
    (run_dir / "w18h_compatibility_report.json").write_text(
        json.dumps(report, ensure_ascii=False, indent=2)
    )
    run_meta["outputs"].append("w18h_compatibility_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["mode"] = mode
    run_meta["image_api_call_count"] = image_api_call_count
    run_meta["image_generation_count"] = image_generation_count
    run_meta["run_status"] = run_status
    run_meta["exit_code"] = exit_code
    run_meta["failed_invariants"] = failed_invariants
    run_meta["per_bg_png_size_bytes"] = {
        bg_id: (
            (run_dir / "png" / f"{bg_id}.png").stat().st_size
            if (run_dir / "png" / f"{bg_id}.png").exists() else 0
        )
        for bg_id in target_bg_ids
    }

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

    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))[:700]}</pre></td>"
        f"</tr>"
        for k, v in (report.get("invariants") or {}).items()
    )

    bg_sections: List[str] = []
    for bg_id in target_bg_ids:
        row = per_bg_results.get(bg_id) or {}
        png_rel = row.get("png_relative_path") or ""
        png_full = run_dir / png_rel
        png_html = (
            f'<img src="{esc(png_rel)}" '
            f'style="max-width:480px;border:1px solid #ccc"/>'
            if png_full.exists()
            else "<p><i>(no PNG — dry-run or failed call)</i></p>"
        )
        bg_sections.append(
            f"<section><h2>bg: {esc(bg_id)}</h2>"
            f"<p>status: <b>{esc(row.get('status'))}</b>"
            f" | clean: <b>{esc(row.get('clean_background_expected'))}</b>"
            f" | transient: <code>{esc(row.get('transient_overlay_marker_numbers'))}</code>"
            f" | png_size_bytes: <b>{esc(run_meta['per_bg_png_size_bytes'].get(bg_id, 0))}</b></p>"
            f"{png_html}</section>"
        )

    (run_dir / "index.html").write_text(
        f"""<!doctype html><html><head><meta charset=\"utf-8\">
<title>W18H background_image_smoke_remaining_l05 {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:96ch}}
section{{margin:1.5em 0}}</style></head>
<body>
<h1>W18H — background_image_smoke_remaining_l05 {esc(run_id)}</h1>
<p>mode: <b>{esc(mode)}</b>
| run_status: <b>{esc(run_status)}</b>
| exit_code: {esc(exit_code)}
| model: <b>{esc(args.model)}</b>
| image_api_call_count: <b>{esc(image_api_call_count)}</b>
| image_generation_count: <b>{esc(image_generation_count)}</b>
| target_bg_ids: <code>{esc(target_bg_ids)}</code>
| derived_from(W18F): <b>{esc(prev_run_dir.name)}</b></p>

{''.join(bg_sections)}

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

<details><summary>per_bg_results</summary>
<pre>{esc(json.dumps(per_bg_results, ensure_ascii=False, indent=2))}</pre></details>
<details><summary>run_meta.json</summary>
<pre>{esc(json.dumps(run_meta, ensure_ascii=False, indent=2))}</pre></details>
</body></html>"""
    )
    run_meta["outputs"].append("index.html")
    for bg_id in target_bg_ids:
        p = run_dir / "png" / f"{bg_id}.png"
        if p.exists():
            run_meta["outputs"].append(str(p.relative_to(run_dir)))

    (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())
