#!/usr/bin/env python3
"""Compare original-runtime battle effect captures against web captures.

This tool is intentionally dependency-free.  It supports non-interlaced 8-bit
PNG captures (RGB/RGBA/grayscale) and directories of PNG frames.  Without a
local capture manifest it reports the remaining blocker instead of failing.
"""

from __future__ import annotations

import argparse
import html
import json
import math
import struct
import zlib
from pathlib import Path
from typing import Any


ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "out"
DATA = ROOT / "data"
CAPTURES = ROOT / "captures" / "battle_effect_pixel_oracle"

MANIFEST = DATA / "battle_effect_capture_manifest.json"
OUT_JSON = OUT / "battle_effect_pixel_compare_report.json"
OUT_HTML = OUT / "battle_effect_pixel_compare_report.html"

DEFAULT_MAX_DIFFERING_PIXEL_RATIO = 0.0
DEFAULT_MAX_MEAN_ABS_DELTA = 0.0


class PngDecodeError(RuntimeError):
    pass


def read_json(path: Path) -> dict[str, Any]:
    return json.loads(path.read_text(encoding="utf-8"))


def row_id(row: dict[str, Any]) -> str:
    return str(row.get("id") or "")


def resolve_path(value: str | None) -> Path | None:
    if not value:
        return None
    path = Path(value)
    if not path.is_absolute():
        path = ROOT / path
    return path


def collect_pngs(path: Path | None) -> list[Path]:
    if path is None or not path.exists():
        return []
    if path.is_file():
        return [path] if path.suffix.lower() == ".png" else []
    return sorted(item for item in path.iterdir() if item.is_file() and item.suffix.lower() == ".png")


def safe_row_dir(value: str | None) -> str:
    text = str(value or "row")
    return "".join(ch if ch.isalnum() or ch in "._-" else "_" for ch in text).strip("._") or "row"


def display_path(path: Path) -> str:
    try:
        return str(path.relative_to(ROOT))
    except ValueError:
        return str(path)


def web_url(path_value: str | None) -> str | None:
    if not path_value:
        return None
    path = Path(path_value)
    if path.is_absolute():
        try:
            rel = path.relative_to(ROOT)
        except ValueError:
            return None
    else:
        rel = path
    return "../" + "/".join(rel.parts)


def paeth(a: int, b: int, c: int) -> int:
    p = a + b - c
    pa = abs(p - a)
    pb = abs(p - b)
    pc = abs(p - c)
    if pa <= pb and pa <= pc:
        return a
    if pb <= pc:
        return b
    return c


def decode_png_rgba(path: Path) -> tuple[int, int, bytes]:
    data = path.read_bytes()
    if not data.startswith(b"\x89PNG\r\n\x1a\n"):
        raise PngDecodeError(f"{path} is not a PNG file")
    pos = 8
    width = height = bit_depth = color_type = interlace = None
    idat = bytearray()
    while pos + 8 <= len(data):
        length = struct.unpack(">I", data[pos:pos + 4])[0]
        chunk_type = data[pos + 4:pos + 8]
        chunk = data[pos + 8:pos + 8 + length]
        pos += 12 + length
        if chunk_type == b"IHDR":
            width, height, bit_depth, color_type, _compression, _filter, interlace = struct.unpack(">IIBBBBB", chunk)
        elif chunk_type == b"IDAT":
            idat.extend(chunk)
        elif chunk_type == b"IEND":
            break
    if width is None or height is None or bit_depth is None or color_type is None:
        raise PngDecodeError(f"{path} is missing IHDR")
    if bit_depth != 8:
        raise PngDecodeError(f"{path} uses unsupported bit depth {bit_depth}")
    if interlace != 0:
        raise PngDecodeError(f"{path} uses unsupported interlace mode {interlace}")
    channels_by_type = {0: 1, 2: 3, 6: 4}
    channels = channels_by_type.get(color_type)
    if channels is None:
        raise PngDecodeError(f"{path} uses unsupported color type {color_type}")
    raw = zlib.decompress(bytes(idat))
    stride = width * channels
    expected = (stride + 1) * height
    if len(raw) < expected:
        raise PngDecodeError(f"{path} has truncated image data")
    rows: list[bytearray] = []
    offset = 0
    prev = bytearray(stride)
    for _y in range(height):
        filter_type = raw[offset]
        scan = bytearray(raw[offset + 1:offset + 1 + stride])
        offset += stride + 1
        for i, value in enumerate(scan):
            left = scan[i - channels] if i >= channels else 0
            up = prev[i]
            up_left = prev[i - channels] if i >= channels else 0
            if filter_type == 0:
                recon = value
            elif filter_type == 1:
                recon = value + left
            elif filter_type == 2:
                recon = value + up
            elif filter_type == 3:
                recon = value + ((left + up) // 2)
            elif filter_type == 4:
                recon = value + paeth(left, up, up_left)
            else:
                raise PngDecodeError(f"{path} uses unsupported filter {filter_type}")
            scan[i] = recon & 0xff
        rows.append(scan)
        prev = scan
    rgba = bytearray(width * height * 4)
    out = 0
    for row in rows:
        for x in range(width):
            base = x * channels
            if color_type == 0:
                gray = row[base]
                rgba[out:out + 4] = bytes((gray, gray, gray, 255))
            elif color_type == 2:
                rgba[out:out + 4] = bytes((row[base], row[base + 1], row[base + 2], 255))
            else:
                rgba[out:out + 4] = bytes((row[base], row[base + 1], row[base + 2], row[base + 3]))
            out += 4
    return width, height, bytes(rgba)


def encode_png_rgba(path: Path, width: int, height: int, rgba: bytes) -> None:
    def chunk(chunk_type: bytes, payload: bytes) -> bytes:
        crc = zlib.crc32(chunk_type)
        crc = zlib.crc32(payload, crc)
        return struct.pack(">I", len(payload)) + chunk_type + payload + struct.pack(">I", crc & 0xffffffff)

    rows = bytearray()
    stride = width * 4
    for y in range(height):
        rows.append(0)
        start = y * stride
        rows.extend(rgba[start:start + stride])
    payload = (
        b"\x89PNG\r\n\x1a\n"
        + chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0))
        + chunk(b"IDAT", zlib.compress(bytes(rows), level=6))
        + chunk(b"IEND", b"")
    )
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_bytes(payload)


def build_diff_rgba(original: bytes, web: bytes) -> bytes:
    diff = bytearray(len(original))
    for i in range(0, len(original), 4):
        dr = abs(original[i] - web[i])
        dg = abs(original[i + 1] - web[i + 1])
        db = abs(original[i + 2] - web[i + 2])
        da = abs(original[i + 3] - web[i + 3])
        delta = max(dr, dg, db, da)
        if delta:
            diff[i:i + 4] = bytes((255, max(40, delta), 0, 255))
        else:
            # Keep matching pixels faint and transparent enough to show context
            # without competing with actual differences.
            gray = 24
            diff[i:i + 4] = bytes((gray, gray, gray, 28))
    return bytes(diff)


def compare_png_pair(original: Path, web: Path, diff_output: Path | None = None) -> dict[str, Any]:
    ow, oh, opixels = decode_png_rgba(original)
    ww, wh, wpixels = decode_png_rgba(web)
    if (ow, oh) != (ww, wh):
        return {
            "original": display_path(original),
            "web": display_path(web),
            "dimensionsMatch": False,
            "originalSize": [ow, oh],
            "webSize": [ww, wh],
            "status": "dimension-mismatch",
        }
    total_pixels = ow * oh
    differing_pixels = 0
    total_abs_delta = 0
    max_channel_delta = 0
    sum_squared = 0
    for i in range(0, len(opixels), 4):
        pixel_differs = False
        for channel in range(4):
            delta = abs(opixels[i + channel] - wpixels[i + channel])
            if delta:
                pixel_differs = True
                total_abs_delta += delta
                sum_squared += delta * delta
                max_channel_delta = max(max_channel_delta, delta)
        if pixel_differs:
            differing_pixels += 1
    compared_channels = total_pixels * 4
    mean_abs_delta = total_abs_delta / compared_channels if compared_channels else 0.0
    rms_delta = math.sqrt(sum_squared / compared_channels) if compared_channels else 0.0
    differing_ratio = differing_pixels / total_pixels if total_pixels else 0.0
    diff_path = None
    if diff_output is not None and differing_pixels:
        encode_png_rgba(diff_output, ow, oh, build_diff_rgba(opixels, wpixels))
        diff_path = display_path(diff_output)
    return {
        "original": display_path(original),
        "web": display_path(web),
        "diff": diff_path,
        "dimensionsMatch": True,
        "size": [ow, oh],
        "totalPixels": total_pixels,
        "differingPixels": differing_pixels,
        "differingPixelRatio": differing_ratio,
        "meanAbsDelta": mean_abs_delta,
        "rmsDelta": rms_delta,
        "maxChannelDelta": max_channel_delta,
        "status": "pass" if differing_pixels == 0 else "differs",
    }


def compare_entry(entry: dict[str, Any]) -> dict[str, Any]:
    original_path = resolve_path(entry.get("originalCapture"))
    web_path = resolve_path(entry.get("webReferenceCapture"))
    original_frames = collect_pngs(original_path)
    web_frames = collect_pngs(web_path)
    if not original_frames:
        return {"id": entry.get("id"), "status": "missing-original-capture", "frameComparisons": []}
    if not web_frames:
        return {"id": entry.get("id"), "status": "missing-web-reference-capture", "frameComparisons": []}
    comparisons = []
    pair_count = min(len(original_frames), len(web_frames))
    diff_dir = CAPTURES / safe_row_dir(str(entry.get("id") or "")) / "diff"
    for index, (original, web) in enumerate(zip(original_frames[:pair_count], web_frames[:pair_count])):
        try:
            comparisons.append(compare_png_pair(original, web, diff_dir / f"diff_{index:05d}.png"))
        except Exception as exc:  # report decode issues without aborting other rows
            comparisons.append({
                "original": str(original),
                "web": str(web),
                "status": "decode-error",
                "error": f"{type(exc).__name__}: {exc}",
            })
    frame_count_match = len(original_frames) == len(web_frames)
    failed = [
        item for item in comparisons
        if item.get("status") != "pass"
        or float(item.get("differingPixelRatio") or 0) > float(entry.get("maxDifferingPixelRatio", DEFAULT_MAX_DIFFERING_PIXEL_RATIO))
        or float(item.get("meanAbsDelta") or 0) > float(entry.get("maxMeanAbsDelta", DEFAULT_MAX_MEAN_ABS_DELTA))
    ]
    if not frame_count_match:
        status = "frame-count-mismatch"
    elif failed:
        status = "differs"
    else:
        status = "pass"
    return {
        "id": entry.get("id"),
        "status": status,
        "originalFrameCount": len(original_frames),
        "webFrameCount": len(web_frames),
        "comparedFrameCount": pair_count,
        "frameCountMatch": frame_count_match,
        "failedFrameCount": len(failed),
        "frameComparisons": comparisons,
    }


def load_manifest() -> dict[str, Any] | None:
    if not MANIFEST.exists():
        return None
    return read_json(MANIFEST)


def manifest_entries(manifest: dict[str, Any] | None) -> list[dict[str, Any]]:
    if not manifest:
        return []
    captures = manifest.get("captures")
    if isinstance(captures, list):
        return [item for item in captures if isinstance(item, dict) and item.get("id")]
    if isinstance(captures, dict):
        return [{"id": key, **item} for key, item in captures.items() if isinstance(item, dict)]
    return []


def build_report() -> dict[str, Any]:
    plan = read_json(OUT / "battle_effect_pixel_oracle_plan.json")
    first_pass_ids = {row_id(row) for row in plan.get("recommendedFirstPassRows") or []}
    full_ids = {row_id(row) for row in plan.get("fullOracleRows") or []}
    manifest = load_manifest()
    entries = manifest_entries(manifest)
    rows = [compare_entry(entry) for entry in entries]
    first_pass_rows = [row for row in rows if row.get("id") in first_pass_ids]
    full_rows = [row for row in rows if row.get("id") in full_ids]
    first_pass_passed = sum(1 for row in first_pass_rows if row.get("status") == "pass")
    full_passed = sum(1 for row in full_rows if row.get("status") == "pass")
    if not manifest:
        status = "waiting-for-captures"
    elif first_pass_passed < len(first_pass_ids):
        status = "first-pass-incomplete"
    elif full_passed < len(full_ids):
        status = "first-pass-passed-full-incomplete"
    else:
        status = "full-oracle-passed"
    return {
        "version": 1,
        "kind": "hwanse-battle-effect-pixel-compare-report",
        "source": "tools/compare_battle_effect_pixel_oracle.py",
        "status": status,
        "inputs": [
            "out/battle_effect_pixel_oracle_plan.json",
            "data/battle_effect_capture_manifest.json",
        ],
        "summary": {
            "manifestExists": manifest is not None,
            "manifestEntries": len(entries),
            "firstPassRowsExpected": len(first_pass_ids),
            "firstPassRowsCompared": len(first_pass_rows),
            "firstPassRowsPassed": first_pass_passed,
            "fullRowsExpected": len(full_ids),
            "fullRowsCompared": len(full_rows),
            "fullRowsPassed": full_passed,
            "completionDecision": "eligible-for-update-goal" if status == "full-oracle-passed" else "do-not-call-update-goal",
        },
        "rows": rows,
    }


def badge(text: str) -> str:
    cls = "ok" if text == "pass" or text == "full-oracle-passed" else "bad"
    return f"<span class='badge {cls}'>{html.escape(str(text))}</span>"


def frame_preview(path_value: str | None, label: str) -> str:
    url = web_url(path_value)
    if not url:
        return f"<code>{html.escape(str(path_value or ''))}</code>"
    return (
        f"<a href='{html.escape(url)}' target='_blank' rel='noreferrer'>"
        f"<img src='{html.escape(url)}' alt='{html.escape(label)}'>"
        f"</a><br><code>{html.escape(str(path_value or ''))}</code>"
    )


def frame_details(row: dict[str, Any]) -> str:
    comparisons = row.get("frameComparisons") or []
    if not comparisons:
        return "<span class='muted'>frame comparisons 없음</span>"
    items = []
    for index, frame in enumerate(comparisons[:40]):
        metrics = []
        for key in ("differingPixels", "differingPixelRatio", "meanAbsDelta", "rmsDelta", "maxChannelDelta"):
            if key in frame:
                value = frame[key]
                if isinstance(value, float):
                    value = f"{value:.6f}"
                metrics.append(f"{key}={value}")
        items.append(
            "<div class='frame-card'>"
            f"<div class='frame-title'>#{index:03d} {badge(str(frame.get('status')))}</div>"
            "<div class='frame-grid'>"
            f"<div><div class='muted'>original</div>{frame_preview(frame.get('original'), 'original')}</div>"
            f"<div><div class='muted'>web</div>{frame_preview(frame.get('web'), 'web')}</div>"
            f"<div><div class='muted'>diff</div>{frame_preview(frame.get('diff'), 'diff')}</div>"
            "</div>"
            f"<div class='muted'>{html.escape(' · '.join(metrics) or str(frame.get('error') or ''))}</div>"
            "</div>"
        )
    if len(comparisons) > 40:
        items.append(f"<div class='muted'>... {len(comparisons) - 40} more frames in JSON</div>")
    return "".join(items)


def html_page(report: dict[str, Any]) -> str:
    summary = report["summary"]
    rows = []
    for row in report["rows"]:
        rows.append(
            "<tr>"
            f"<td><code>{html.escape(str(row.get('id')))}</code></td>"
            f"<td>{badge(str(row.get('status')))}</td>"
            f"<td>{row.get('originalFrameCount', 0)}</td>"
            f"<td>{row.get('webFrameCount', 0)}</td>"
            f"<td>{row.get('failedFrameCount', 0)}</td>"
            f"<td><details><summary>frames</summary>{frame_details(row)}</details></td>"
            "</tr>"
        )
    return f"""<!doctype html>
<html lang="ko">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Battle Effect Pixel Compare Report</title>
  <style>
    body {{ margin: 0; padding: 24px; font-family: system-ui, sans-serif; background: #f8fafc; color: #172033; }}
    .cards {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 10px; margin: 16px 0 24px; }}
    .card {{ background: white; border: 1px solid #dbe3ef; border-radius: 8px; padding: 12px; }}
    .muted {{ color: #64748b; font-size: 12px; }}
    .value {{ font-size: 24px; font-weight: 800; }}
    table {{ width: 100%; border-collapse: collapse; background: white; border: 1px solid #dbe3ef; }}
    th, td {{ border-bottom: 1px solid #e2e8f0; padding: 8px 10px; text-align: left; }}
    th {{ background: #eaf0f8; font-size: 12px; text-transform: uppercase; }}
    code {{ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }}
    .badge {{ display: inline-block; padding: 2px 7px; border-radius: 999px; font-size: 12px; font-weight: 700; }}
    .ok {{ background: #dcfce7; color: #166534; }}
    .bad {{ background: #fee2e2; color: #991b1b; }}
    details summary {{ cursor: pointer; font-weight: 700; }}
    .frame-card {{ margin: 10px 0; padding: 10px; border: 1px solid #dbe3ef; border-radius: 8px; background: #f8fafc; }}
    .frame-title {{ font-weight: 800; margin-bottom: 8px; }}
    .frame-grid {{ display: grid; grid-template-columns: repeat(3, minmax(120px, 1fr)); gap: 10px; align-items: start; }}
    .frame-grid img {{ max-width: 100%; image-rendering: pixelated; border: 1px solid #cbd5e1; background: #0f172a; }}
  </style>
</head>
<body>
  <h1>Battle Effect Pixel Compare Report</h1>
  <p class="muted">source: <code>{html.escape(report['source'])}</code> · status: {badge(report['status'])}</p>
  <div class="cards">
    <div class="card"><div class="muted">manifest</div><div class="value">{summary['manifestExists']}</div></div>
    <div class="card"><div class="muted">first pass</div><div class="value">{summary['firstPassRowsPassed']}/{summary['firstPassRowsExpected']}</div></div>
    <div class="card"><div class="muted">full oracle</div><div class="value">{summary['fullRowsPassed']}/{summary['fullRowsExpected']}</div></div>
    <div class="card"><div class="muted">completion</div><div class="value">{html.escape(summary['completionDecision'])}</div></div>
  </div>
  <table>
    <thead><tr><th>row</th><th>status</th><th>original frames</th><th>web frames</th><th>failed frames</th><th>frame details</th></tr></thead>
    <tbody>{''.join(rows)}</tbody>
  </table>
</body>
</html>
"""


def write_outputs(report: dict[str, Any], json_out: Path = OUT_JSON, html_out: Path | None = None) -> None:
    json_out.parent.mkdir(parents=True, exist_ok=True)
    json_out.write_text(json.dumps(report, ensure_ascii=False, separators=(",", ":")), encoding="utf-8")
    if html_out is not None:
        html_out.parent.mkdir(parents=True, exist_ok=True)
        html_out.write_text(html_page(report), encoding="utf-8")


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("--json-out", type=Path, default=OUT_JSON)
    parser.add_argument("--html-out", type=Path)
    args = parser.parse_args()
    report = build_report()
    write_outputs(report, args.json_out, args.html_out)
    print(json.dumps(report["summary"], ensure_ascii=False))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
