#!/usr/bin/env python3
"""Build a browser visual assertion review for EXE-derived battle effects.

This report consumes the browser smoke output.  It is deliberately stricter
than a data-binding report: every row with an effect execution requirement must
have browser diagnostics, non-empty effect-strip canvases, and an active-tick
combined preview canvas with colored pixels.  It is still not an original-game
pixel oracle; it proves the current web runner visibly renders the EXE-derived
effect classes.
"""

from __future__ import annotations

import html
import json
from collections import Counter, defaultdict
from pathlib import Path
from typing import Any, Callable


ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "out"
OUT_JSON = OUT / "battle_effect_visual_assertion_review.json"
OUT_HTML = OUT / "battle_effect_visual_assertion_review.html"


def load_json(name: str) -> dict[str, Any]:
    return json.loads((OUT / name).read_text(encoding="utf-8"))


def pass_fail(rows: list[dict[str, Any]], predicate: Callable[[dict[str, Any]], bool]) -> dict[str, Any]:
    failed = [row for row in rows if not predicate(row)]
    return {
        "total": len(rows),
        "passed": len(rows) - len(failed),
        "failed": len(failed),
        "status": "pass" if not failed else "fail",
        "failedRows": [row_id(row) for row in failed[:40]],
    }


def row_id(row: dict[str, Any]) -> dict[str, Any]:
    return {
        "ownerKey": row.get("ownerKey"),
        "skillIdHex": row.get("skillIdHex"),
        "levelOrFixed": row.get("levelOrFixed"),
        "name": row.get("name"),
        "effectAnimationClass": row.get("effectAnimationClass"),
        "requirements": row.get("requirements") or [],
    }


def diagnostics(row: dict[str, Any]) -> dict[str, Any]:
    return row.get("effectDiagnostics") or {}


def effect_canvas(row: dict[str, Any]) -> dict[str, Any]:
    return row.get("effectCanvasStats") or {}


def preview_canvas(row: dict[str, Any]) -> dict[str, Any]:
    return row.get("previewCanvasStats") or {}


def preview_render(row: dict[str, Any]) -> dict[str, Any]:
    return row.get("previewRender") or {}


def has_effect_frames(row: dict[str, Any]) -> bool:
    return int(row.get("effectFrameCount") or diagnostics(row).get("effectFrameCount") or 0) > 0


def has_nonempty_effect_canvas(row: dict[str, Any]) -> bool:
    if not has_effect_frames(row):
        return True
    stats = effect_canvas(row)
    return int(stats.get("nonEmptyCanvasCount") or 0) > 0


def has_active_preview_canvas(row: dict[str, Any]) -> bool:
    if not has_effect_frames(row):
        return True
    render = preview_render(row)
    stats = preview_canvas(row)
    return (
        int(render.get("activeEffectCount") or 0) > 0
        and int(stats.get("nonEmptyCanvasCount") or 0) > 0
        and int(stats.get("coloredCanvasCount") or 0) > 0
    )


def has_transform_range(row: dict[str, Any]) -> bool:
    diag = diagnostics(row)
    return int(diag.get("framesWithTransformScope") or 0) > 0 or int(diag.get("framesWithCoordinateSummary") or 0) > 0


def has_spawn_over_time(row: dict[str, Any]) -> bool:
    diag = diagnostics(row)
    return int(diag.get("effectFrameCount") or row.get("effectFrameCount") or 0) > 1 and int(diag.get("tickSpan") or 0) > 0


def has_motion_loop_evidence(row: dict[str, Any]) -> bool:
    diag = diagnostics(row)
    return (
        int(diag.get("framesWithMotion") or 0) > 0
        or int(diag.get("framesWithNestedVisual") or 0) > 0
        or int(diag.get("framesWithLoopPreview") or 0) > 0
        or int(diag.get("framesWithSparkleMultiSprite") or 0) > 0
        or (int(diag.get("tickSpan") or 0) > 0 and int(diag.get("framesWithTransformScope") or 0) > 0)
    )


def has_palette_evidence(row: dict[str, Any]) -> bool:
    return diagnostics(row).get("hasPaletteRequirement") is True


def requirement_assertions(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
    buckets: dict[str, list[dict[str, Any]]] = defaultdict(list)
    for row in rows:
        for requirement in row.get("requirements") or []:
            buckets[requirement].append(row)
    checks: dict[str, list[tuple[str, str, Callable[[dict[str, Any]], bool]]]] = {
        "random placement/range present": [
            ("transform-range-diagnostics", "effect frames expose transform scope or coordinate summary", has_transform_range),
            ("visible-effect-strip", "effect-strip canvases contain non-empty pixels", has_nonempty_effect_canvas),
            ("visible-combined-preview", "combined actor/effect preview has active effect and colored pixels", has_active_preview_canvas),
        ],
        "runner must instantiate child objects over time": [
            ("spawn-over-time", "effect frames span multiple ticks", has_spawn_over_time),
            ("visible-effect-strip", "effect-strip canvases contain non-empty pixels", has_nonempty_effect_canvas),
            ("visible-combined-preview", "combined actor/effect preview has active effect and colored pixels", has_active_preview_canvas),
        ],
        "child motion loop present": [
            ("motion-loop-diagnostics", "motion, loop preview, nested visual, multi-sprite, or transform span evidence is present", has_motion_loop_evidence),
            ("visible-effect-strip", "effect-strip canvases contain non-empty pixels", has_nonempty_effect_canvas),
            ("visible-combined-preview", "combined actor/effect preview has active effect and colored pixels", has_active_preview_canvas),
        ],
        "palette transform effect; no CNS frame stream": [
            ("palette-requirement-diagnostics", "palette requirement is preserved in browser marker", has_palette_evidence),
            ("visible-combined-preview", "combined actor/effect preview has active effect and colored pixels", has_active_preview_canvas),
        ],
    }
    out = []
    for requirement, bucket_rows in sorted(buckets.items()):
        assertions = [
            {"id": item_id, "description": description, **pass_fail(bucket_rows, predicate)}
            for item_id, description, predicate in checks.get(requirement, [])
        ]
        out.append(
            {
                "requirement": requirement,
                "rowCount": len(bucket_rows),
                "status": "pass" if assertions and all(item["status"] == "pass" for item in assertions) else "fail",
                "assertions": assertions,
                "samples": [row_id(row) for row in bucket_rows[:12]],
            }
        )
    return out


def class_assertions(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
    buckets: dict[str, list[dict[str, Any]]] = defaultdict(list)
    for row in rows:
        buckets[row.get("effectAnimationClass") or "-"].append(row)
    out = []
    for class_name, bucket_rows in sorted(buckets.items()):
        assertions = [
            {"id": "visible-effect-strip", "description": "effect-strip canvases contain non-empty pixels", **pass_fail(bucket_rows, has_nonempty_effect_canvas)},
            {"id": "visible-combined-preview", "description": "combined actor/effect preview has active effect and colored pixels", **pass_fail(bucket_rows, has_active_preview_canvas)},
        ]
        if class_name == "spawn-stream-effect":
            assertions.append({"id": "spawn-over-time", "description": "spawn stream spans multiple ticks", **pass_fail(bucket_rows, has_spawn_over_time)})
        if class_name == "init-frame-effect":
            assertions.append({"id": "transform-range-diagnostics", "description": "init effects carry placement transform/range diagnostics", **pass_fail(bucket_rows, has_transform_range)})
        if class_name == "frameScript-effect":
            assertions.append({"id": "has-effect-frames", "description": "frameScript effects produce visible frame rows", **pass_fail(bucket_rows, has_effect_frames)})
        out.append(
            {
                "effectAnimationClass": class_name,
                "rowCount": len(bucket_rows),
                "status": "pass" if all(item["status"] == "pass" for item in assertions) else "fail",
                "assertions": assertions,
                "samples": [row_id(row) for row in bucket_rows[:12]],
            }
        )
    return out


def representative_assertions(rows: list[dict[str, Any]]) -> dict[str, Any]:
    return {
        "rowCount": len(rows),
        "visibleEffectStrip": pass_fail(rows, has_nonempty_effect_canvas),
        "visibleCombinedPreview": pass_fail(rows, has_active_preview_canvas),
        "samples": [row_id(row) for row in rows],
    }


def diagnostic_coverage(rows: list[dict[str, Any]]) -> dict[str, Any]:
    def effect_count(row: dict[str, Any]) -> int:
        return int(row.get("effectFrameCount") or diagnostics(row).get("effectFrameCount") or 0)

    checks: list[tuple[str, str, Callable[[dict[str, Any]], bool]]] = [
        ("rowsWithEffectFrames", "EXE-derived effect frame rows exist", lambda row: effect_count(row) > 0),
        ("rowsWithTransformDiagnostics", "transform scope or coordinate summary is exposed", has_transform_range),
        ("rowsWithMotionDiagnostics", "effect frames carry explicit motion deltas", lambda row: int(diagnostics(row).get("framesWithMotion") or 0) > 0),
        ("rowsWithSpawnOverTime", "effect frames span more than one active tick", has_spawn_over_time),
        ("rowsWithLoopPreview", "loop/counter preview diagnostics exist", lambda row: int(diagnostics(row).get("framesWithLoopPreview") or 0) > 0),
        ("rowsWithNestedVisual", "nested child visual frames are present", lambda row: int(diagnostics(row).get("framesWithNestedVisual") or 0) > 0),
        ("rowsWithSparkleMultiSprite", "sparkle/multi-sprite helper frames are present", lambda row: int(diagnostics(row).get("framesWithSparkleMultiSprite") or 0) > 0),
        ("rowsWithPaletteRequirement", "palette-only requirement is preserved", has_palette_evidence),
        (
            "rowsWithHiddenCandidates",
            "hidden/visibility-off candidates remain",
            lambda row: int(diagnostics(row).get("framesWithHiddenCandidate") or 0) > 0
            or int(diagnostics(row).get("framesWithVisibleCandidateFalse") or 0) > 0,
        ),
    ]
    metrics = []
    for key, description, predicate in checks:
        count = sum(1 for row in rows if predicate(row))
        metrics.append({"id": key, "description": description, "count": count, "total": len(rows)})
    return {
        "rowCount": len(rows),
        "metrics": metrics,
        "summary": {item["id"]: item["count"] for item in metrics},
    }


def motion_model(row: dict[str, Any]) -> str:
    diag = diagnostics(row)
    if diag.get("hasPaletteRequirement") is True:
        return "palette-only-frameScript"
    if int(diag.get("framesWithMotion") or 0) > 0:
        return "explicit-motion-delta"
    if int(diag.get("framesWithSparkleMultiSprite") or 0) > 0:
        return "sparkle-stream"
    if int(diag.get("framesWithLoopPreview") or 0) > 0:
        return "loop-counter-placement-stream"
    return "direct-placement-stream"


def motion_model_coverage(rows: list[dict[str, Any]]) -> dict[str, Any]:
    descriptions = {
        "explicit-motion-delta": "effect frames carry EXE-derived motionX/motionY deltas",
        "loop-counter-placement-stream": "movement is represented by repeated/counter-driven placed spawns, not per-frame motion deltas",
        "sparkle-stream": "movement is represented by sparkle/multi-sprite child streams",
        "direct-placement-stream": "movement is represented by multiple placed child frames without an explicit motion delta",
        "palette-only-frameScript": "visual change is palette/frameScript driven rather than CNS motion",
    }
    buckets: dict[str, list[dict[str, Any]]] = defaultdict(list)
    for row in rows:
        buckets[motion_model(row)].append(row)
    models = []
    for model_id in [
        "explicit-motion-delta",
        "loop-counter-placement-stream",
        "sparkle-stream",
        "direct-placement-stream",
        "palette-only-frameScript",
    ]:
        bucket_rows = buckets.get(model_id, [])
        models.append(
            {
                "id": model_id,
                "description": descriptions[model_id],
                "count": len(bucket_rows),
                "total": len(rows),
                "samples": [row_id(row) for row in bucket_rows[:20]],
            }
        )
    return {
        "rowCount": len(rows),
        "models": models,
        "summary": {f"motionModel_{item['id'].replace('-', '_')}": item["count"] for item in models},
        "classifiedRows": sum(item["count"] for item in models),
    }


def build_report() -> dict[str, Any]:
    smoke = load_json("battle_skill_timeline_browser_smoke.json")
    requirement_rows = smoke.get("executionRequirementChecks") or []
    representative_rows = smoke.get("effectChecks") or []
    reqs = requirement_assertions(requirement_rows)
    classes = class_assertions(requirement_rows)
    representative = representative_assertions(representative_rows)
    diagnostics_summary = diagnostic_coverage(requirement_rows)
    motion_models = motion_model_coverage(requirement_rows)
    all_groups = reqs + classes
    failed_groups = [group for group in all_groups if group.get("status") != "pass"]
    status = "visual-assertions-pass" if not failed_groups and representative["visibleCombinedPreview"]["status"] == "pass" else "visual-assertions-fail"
    return {
        "version": 1,
        "kind": "hwanse-battle-effect-visual-assertion-review",
        "source": "tools/build_battle_effect_visual_assertion_review.py",
        "status": status,
        "inputs": ["out/battle_skill_timeline_browser_smoke.json"],
        "summary": {
            "requirementRows": len(requirement_rows),
            "representativeEffectRows": len(representative_rows),
            "requirementGroups": len(reqs),
            "effectClassGroups": len(classes),
            "failedGroups": len(failed_groups),
            "requirementRowsWithColoredPreview": sum(1 for row in requirement_rows if has_active_preview_canvas(row)),
            "requirementRowsWithColoredEffectStrip": sum(1 for row in requirement_rows if has_nonempty_effect_canvas(row)),
            "representativeRowsWithColoredPreview": representative["visibleCombinedPreview"]["passed"],
            **diagnostics_summary["summary"],
            **motion_models["summary"],
            "motionModelClassifiedRows": motion_models["classifiedRows"],
            "pixelOracleStatus": "not-original-runtime-pixel-oracle",
        },
        "requirementAssertions": reqs,
        "effectClassAssertions": classes,
        "representativeAssertions": representative,
        "diagnosticCoverage": diagnostics_summary,
        "motionModelCoverage": motion_models,
        "notes": [
            "This report proves browser-visible output for EXE-derived effect requirements.",
            "It does not compare against original game captures or exact RNG particle positions.",
            "Diagnostic coverage separates what static EXE/browser evidence can prove from original-runtime pixel equality.",
            "Motion model coverage classifies rows without explicit motion deltas into EXE-grounded spawn/loop/sparkle/palette models.",
            "Passing means the current web runner exposes diagnostics and draws colored pixels for active effect ticks.",
        ],
    }


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


def assertion_table(groups: list[dict[str, Any]], label_key: str) -> str:
    rows = []
    for group in groups:
        assertions = "<br>".join(
            f"{badge(item['status'])} <code>{html.escape(item['id'])}</code> "
            f"{html.escape(item['description'])} ({item['passed']}/{item['total']})"
            for item in group.get("assertions") or []
        )
        samples = "<br>".join(
            html.escape(f"{sample['ownerKey']} {sample['skillIdHex']} Lv={sample['levelOrFixed']} {sample['name']}")
            for sample in group.get("samples") or []
        )
        rows.append(
            "<tr>"
            f"<td>{html.escape(str(group.get(label_key)))}</td>"
            f"<td>{group.get('rowCount')}</td>"
            f"<td>{badge(group.get('status'))}</td>"
            f"<td>{assertions}</td>"
            f"<td>{samples}</td>"
            "</tr>"
        )
    return "\n".join(rows)


def sample_label(sample: dict[str, Any]) -> str:
    return f"{sample.get('ownerKey')} {sample.get('skillIdHex')} Lv={sample.get('levelOrFixed')} {sample.get('name')}"


def html_page(report: dict[str, Any]) -> str:
    summary = report["summary"]
    req_rows = assertion_table(report["requirementAssertions"], "requirement")
    cls_rows = assertion_table(report["effectClassAssertions"], "effectAnimationClass")
    representative = report["representativeAssertions"]
    diagnostic_rows = "".join(
        "<tr>"
        f"<td><code>{html.escape(str(item.get('id')))}</code></td>"
        f"<td>{html.escape(str(item.get('description')))}</td>"
        f"<td>{item.get('count')}/{item.get('total')}</td>"
        "</tr>"
        for item in report.get("diagnosticCoverage", {}).get("metrics") or []
    )
    motion_model_rows = "".join(
        "<tr>"
        f"<td><code>{html.escape(str(item.get('id')))}</code></td>"
        f"<td>{html.escape(str(item.get('description')))}</td>"
        f"<td>{item.get('count')}/{item.get('total')}</td>"
        f"<td>{'<br>'.join(html.escape(sample_label(sample)) for sample in item.get('samples') or [])}</td>"
        "</tr>"
        for item in report.get("motionModelCoverage", {}).get("models") or []
    )
    representative_rows = "".join(
        "<tr>"
        f"<td>{html.escape(str(sample.get('ownerKey')))}</td>"
        f"<td>{html.escape(str(sample.get('skillIdHex')))}</td>"
        f"<td>{html.escape(str(sample.get('levelOrFixed')))}</td>"
        f"<td>{html.escape(str(sample.get('name')))}</td>"
        f"<td>{html.escape(str(sample.get('effectAnimationClass')))}</td>"
        f"<td>{html.escape(', '.join(sample.get('requirements') or []))}</td>"
        "</tr>"
        for sample in representative.get("samples") or []
    )
    notes = "".join(f"<li>{html.escape(note)}</li>" for note in report["notes"])
    return f"""<!doctype html>
<html lang="ko">
<head>
  <meta charset="utf-8">
  <title>Battle Effect Visual Assertion Review</title>
  <style>
    body {{ margin: 0; padding: 24px; font-family: system-ui, sans-serif; background: #f8fafc; color: #172033; }}
    h1, h2 {{ margin: 0 0 12px; }}
    .cards {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 12px; margin: 16px 0 24px; }}
    .card {{ background: white; border: 1px solid #d8dee9; border-radius: 8px; padding: 14px; }}
    .muted {{ color: #667085; font-size: 12px; }}
    .value {{ font-size: 26px; font-weight: 800; }}
    .badge {{ display: inline-block; padding: 2px 7px; border-radius: 999px; margin: 2px; font-size: 12px; font-weight: 700; }}
    .ok {{ background: #dcfce7; color: #166534; }}
    .bad {{ background: #fee2e2; color: #991b1b; }}
    table {{ width: 100%; border-collapse: collapse; margin: 12px 0 28px; background: white; border: 1px solid #d8dee9; }}
    th, td {{ padding: 8px 10px; border-bottom: 1px solid #e5e7eb; vertical-align: top; text-align: left; }}
    th {{ background: #eef2f7; font-size: 12px; text-transform: uppercase; letter-spacing: .04em; }}
    code {{ font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }}
  </style>
</head>
<body>
  <h1>Battle Effect Visual Assertion Review</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">requirement rows</div><div class="value">{summary['requirementRows']}</div></div>
    <div class="card"><div class="muted">representative effect rows</div><div class="value">{summary['representativeEffectRows']}</div></div>
    <div class="card"><div class="muted">requirement groups</div><div class="value">{summary['requirementGroups']}</div></div>
    <div class="card"><div class="muted">effect class groups</div><div class="value">{summary['effectClassGroups']}</div></div>
    <div class="card"><div class="muted">failed groups</div><div class="value">{summary['failedGroups']}</div></div>
    <div class="card"><div class="muted">colored previews</div><div class="value">{summary['requirementRowsWithColoredPreview']}</div></div>
  </div>
  <h2>Static Diagnostic Coverage</h2>
  <table>
    <thead><tr><th>Metric</th><th>Meaning</th><th>Rows</th></tr></thead>
    <tbody>{diagnostic_rows}</tbody>
  </table>
  <h2>Motion Model Coverage</h2>
  <table>
    <thead><tr><th>Model</th><th>Meaning</th><th>Rows</th><th>Samples</th></tr></thead>
    <tbody>{motion_model_rows}</tbody>
  </table>
  <h2>Representative Effect Checks</h2>
  <p class="muted">
    effect strip: {badge(representative['visibleEffectStrip']['status'])}
    {representative['visibleEffectStrip']['passed']}/{representative['visibleEffectStrip']['total']} ·
    combined preview: {badge(representative['visibleCombinedPreview']['status'])}
    {representative['visibleCombinedPreview']['passed']}/{representative['visibleCombinedPreview']['total']}
  </p>
  <table>
    <thead><tr><th>Owner</th><th>Skill</th><th>Lv</th><th>Name</th><th>Class</th><th>Requirements</th></tr></thead>
    <tbody>{representative_rows}</tbody>
  </table>
  <h2>Requirement Assertions</h2>
  <table>
    <thead><tr><th>Requirement</th><th>Rows</th><th>Status</th><th>Assertions</th><th>Samples</th></tr></thead>
    <tbody>{req_rows}</tbody>
  </table>
  <h2>Effect Class Assertions</h2>
  <table>
    <thead><tr><th>Class</th><th>Rows</th><th>Status</th><th>Assertions</th><th>Samples</th></tr></thead>
    <tbody>{cls_rows}</tbody>
  </table>
  <h2>Notes</h2>
  <ul>{notes}</ul>
</body>
</html>
"""


def main() -> int:
    report = build_report()
    OUT_JSON.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
    html_text = html_page(report)
    OUT_HTML.write_text(html_text, encoding="utf-8")
    print(json.dumps(report["summary"], ensure_ascii=False))
    if report["status"] != "visual-assertions-pass":
        return 1
    return 0


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