#!/usr/bin/env python3
"""Classify static monster action movement patterns.

This report is static-only.  It starts from the already joined monster action
catalog/effect review and groups display VM movement opcode 0xbc usage by
timeline position.  The goal is to separate stationary local-frame attacks
from dash/charge/return actions without using runtime capture.
"""
from __future__ import annotations

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


ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "out"
SOURCE = OUT / "battle_monster_action_effect_review.json"
OUTPUT_JSON = OUT / "battle_monster_movement_pattern_review.json"
OUTPUT_HTML = OUT / "battle_monster_movement_pattern_review.html"


def esc(value: Any) -> str:
    return html.escape(str(value if value is not None else ""))


def wlk_label(value: Any) -> str:
    return f"WLK id {int(value):02d}"


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


def va_int(value: Any) -> int | None:
    if value is None:
        return None
    try:
        return int(str(value), 16)
    except ValueError:
        return None


def phase_for_event(event_va: int | None, first_result_va: int | None, first_wait_va: int | None) -> str:
    if event_va is None:
        return "unknown"
    if first_result_va is not None and event_va < first_result_va:
        return "pre-result"
    if first_wait_va is not None and event_va > first_wait_va:
        return "post-wait"
    if first_result_va is not None and event_va >= first_result_va:
        return "result-window"
    return "pre-result"


def movement_signature(events: list[dict[str, Any]]) -> str:
    parts = []
    for event in events:
        if event.get("type") == "movement":
            parts.append(
                f"{event.get('phase')}:{event.get('motionKind') or 'movement'}/"
                f"m{event.get('motionMode', event.get('movementMode'))}/"
                f"s{event.get('selector')}/d{event.get('stepDivisor', event.get('divisor'))}"
            )
        elif event.get("type") == "position-write":
            parts.append(f"{event.get('phase')}:write")
    return " | ".join(parts) or "-"


def classify_movement(events: list[dict[str, Any]], helper_count: int) -> tuple[str, str]:
    movement_events = [event for event in events if event.get("type") == "movement"]
    position_writes = [event for event in events if event.get("type") == "position-write"]
    nonzero_pre_result = [
        event
        for event in movement_events
        if event.get("movementMode") not in (None, 0) and event.get("phase") == "pre-result"
    ]
    nonzero_any = [event for event in movement_events if event.get("movementMode") not in (None, 0)]
    return_home = [
        event
        for event in movement_events
        if event.get("movementMode") == 0 and event.get("selector") == 0 and event.get("phase") == "post-wait"
    ]
    direct_nonhome = [
        event
        for event in movement_events
        if event.get("movementMode") == 0 and event.get("selector") not in (None, 0)
    ]

    if nonzero_pre_result and return_home:
        return (
            "dash-or-charge-then-return",
            "0xbc nonzero motion appears before result sound and direct placement selector 0 appears after wait.",
        )
    if nonzero_pre_result:
        return (
            "dash-or-charge-no-explicit-return",
            "0xbc nonzero motion appears before result sound, but no post-wait selector 0 return was found.",
        )
    if nonzero_any:
        return (
            "nonzero-motion-other-window",
            "0xbc nonzero motion appears outside the simple pre-result window.",
        )
    if direct_nonhome and helper_count:
        return (
            "direct-placement-helper-effect",
            "0xbc direct placement selector is used with helper effects; likely effect/actor placement rather than dash motion.",
        )
    if direct_nonhome:
        return (
            "direct-placement-local",
            "0xbc direct placement selector is used without nonzero motion.",
        )
    if position_writes:
        return (
            "local-offset-pose",
            "display/actor x/y writes offset the local pose and later restore it.",
        )
    if helper_count:
        return (
            "stationary-helper-effect",
            "no local 0xbc movement; helper may still animate effect children.",
        )
    return ("stationary-local-frame", "no local 0xbc movement or position write.")


def compact_event(event: dict[str, Any], phase: str) -> dict[str, Any]:
    row = {
        "type": event.get("type"),
        "phase": phase,
        "vaHex": event.get("vaHex"),
        "summary": event.get("summary"),
    }
    for key in (
        "movementMode",
        "motionMode",
        "motionKind",
        "selector",
        "selectorHex",
        "divisor",
        "stepDivisor",
        "targetRangePolicy",
        "selectorMeaning",
        "kind1Formula",
        "kind2Formula",
        "handlerEvidence",
        "wlkNo",
        "normalWlkNo",
        "altWlkNo",
        "opcode",
        "maskHex",
    ):
        if event.get(key) is not None:
            row[key] = event.get(key)
    if event.get("type") == "local-frame":
        row["selector"] = event.get("selector")
        row["gate"] = event.get("gate")
        row["label"] = event.get("label")
    return row


def build() -> dict[str, Any]:
    source = load_json(SOURCE)
    rows: list[dict[str, Any]] = []
    class_counter: Counter[str] = Counter()
    signature_counter: Counter[str] = Counter()
    motion_opcode_counter: Counter[str] = Counter()
    examples_by_class: dict[str, list[dict[str, Any]]] = defaultdict(list)

    for action in source.get("rows") or []:
        timeline = (action.get("timelineSummary") or {}).get("displayTimeline") or {}
        raw_events = timeline.get("events") or []
        first_result_va = next((va_int(event.get("vaHex")) for event in raw_events if event.get("type") == "result-sound"), None)
        first_wait_va = next((va_int(event.get("vaHex")) for event in raw_events if event.get("type") == "wait-barrier"), None)
        events: list[dict[str, Any]] = []
        for event in raw_events:
            event_type = event.get("type")
            if event_type not in {"local-frame", "movement", "position-write", "result-sound", "effect-sound", "wait-barrier"}:
                continue
            phase = phase_for_event(va_int(event.get("vaHex")), first_result_va, first_wait_va)
            compact = compact_event(event, phase)
            events.append(compact)
            if event_type == "movement":
                motion_opcode_counter[
                    f"{event.get('motionKind') or 'movement'}/"
                    f"m{event.get('motionMode', event.get('movementMode'))}/"
                    f"s{event.get('selector')}/d{event.get('stepDivisor', event.get('divisor'))}"
                ] += 1

        helper_count = (action.get("timelineSummary") or {}).get("helperCount") or 0
        cls, note = classify_movement(events, helper_count)
        signature = movement_signature(events)
        class_counter[cls] += 1
        signature_counter[signature] += 1
        row = {
            "enemyName": action.get("enemyName"),
            "cns": action.get("cns"),
            "sharedActionName": action.get("sharedActionName"),
            "sharedActionIdHex": action.get("sharedActionIdHex"),
            "visibleSlotHex": action.get("visibleSlotHex"),
            "presentationClass": action.get("presentationClass"),
            "visualClass": action.get("visualClass"),
            "movementClass": cls,
            "movementSignature": signature,
            "classificationNote": note,
            "expandedLocalFrameLabels": (action.get("timelineSummary") or {}).get("expandedLocalFrameLabels") or [],
            "expandedLocalDurationGate": (action.get("timelineSummary") or {}).get("expandedLocalDurationGate"),
            "helperCount": helper_count,
            "events": events,
        }
        rows.append(row)
        if len(examples_by_class[cls]) < 8:
            examples_by_class[cls].append(
                {
                    "enemyName": row["enemyName"],
                    "actionName": row["sharedActionName"],
                    "cns": row["cns"],
                    "sharedActionIdHex": row["sharedActionIdHex"],
                    "movementSignature": signature,
                    "frames": row["expandedLocalFrameLabels"][:12],
                }
            )

    report = {
        "version": 1,
        "kind": "hwanse-battle-monster-movement-pattern-review",
        "title": "몬스터 행동 이동 패턴 정적 리뷰",
        "status": "static-monster-movement-patterns-grounded",
        "runtimeUsed": False,
        "source": str(SOURCE.relative_to(ROOT)),
        "updatedAt": datetime.now(timezone.utc).isoformat(),
        "summary": {
            "actionRows": len(rows),
            "movementClassCounts": [{"value": key, "count": count} for key, count in class_counter.most_common()],
            "topMovementSignatures": [{"value": key, "count": count} for key, count in signature_counter.most_common(30)],
            "movementOpcodeCounts": [{"value": key, "count": count} for key, count in motion_opcode_counter.most_common()],
        },
        "staticConclusions": [
            "0xbc movement opcode is present in monster display scripts and can be grouped without runtime capture.",
            "dash-or-charge-then-return rows have nonzero movement before result sound and selector 0 direct placement after wait.",
            "stationary rows still may have helper child effects; this report separates local actor movement from helper/effect movement.",
            "Classification labels are static-inferred, but the event VA, opcode fields, selector/divisor, WLK order, and frame gates are direct EXE decode output.",
            "Opcode 0xbc handler semantics are grounded from handler table 0x00440538 -> 0x0040e216: byte1=direct vs child motion, byte2=position selector, byte3=motion step divisor.",
        ],
        "knownGaps": [
            "exact pixel trajectory is not solved here. It requires applying the already decoded movement opcode arithmetic in a browser runner.",
        ],
        "examplesByClass": examples_by_class,
        "rows": rows,
    }
    return report


def write_html(report: dict[str, Any]) -> str:
    css = """
    :root{color-scheme:light;--bg:#f5f6f8;--fg:#17202a;--muted:#667482;--line:#d9e0e8;--panel:#fff;--head:#eef3f7;--link:#185abc}
    *{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.48 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
    main{max-width:1480px;margin:0 auto;padding:18px}header{display:flex;justify-content:space-between;gap:16px;align-items:flex-start;margin-bottom:14px}
    h1{margin:0 0 6px;font-size:24px;letter-spacing:0}h2{margin:0;font-size:17px;letter-spacing:0}.muted{color:var(--muted)}
    nav{display:flex;gap:8px;flex-wrap:wrap;justify-content:flex-end}a{color:var(--link);text-decoration:none}a:hover{text-decoration:underline}
    nav a,.tag{display:inline-flex;align-items:center;min-height:28px;padding:3px 8px;border:1px solid var(--line);border-radius:5px;background:var(--panel);font-size:13px}
    section{margin:14px 0;background:var(--panel);border:1px solid var(--line);border-radius:8px;overflow:hidden}.section-head{padding:12px 14px;border-bottom:1px solid var(--line);background:var(--head);display:flex;justify-content:space-between;gap:12px}
    .body{padding:14px}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:10px}.metric{border:1px solid var(--line);border-radius:6px;padding:10px;background:#fafcff}.metric strong{display:block;font-size:22px}.metric span{color:var(--muted)}
    table{width:100%;border-collapse:collapse}th,td{border:1px solid var(--line);padding:6px 7px;vertical-align:top}th{position:sticky;top:0;background:var(--head);z-index:1;text-align:left}
    code{font-family:ui-monospace,SFMono-Regular,Consolas,monospace}.scroll{max-height:720px;overflow:auto}.chips{display:flex;gap:4px;flex-wrap:wrap}.chip{display:inline-flex;border:1px solid var(--line);border-radius:999px;padding:1px 7px;background:#f8fafc;font-size:12px}
    """
    summary = report["summary"]

    def counter_rows(items: list[dict[str, Any]]) -> str:
        return "".join(f"<tr><td>{esc(row['value'])}</td><td>{esc(row['count'])}</td></tr>" for row in items)

    class_rows = counter_rows(summary["movementClassCounts"])
    opcode_rows = counter_rows(summary["movementOpcodeCounts"])
    conclusions = "".join(f"<li>{esc(item)}</li>" for item in report["staticConclusions"])
    gaps = "".join(f"<li>{esc(item)}</li>" for item in report["knownGaps"])
    rows = []
    for row in report["rows"]:
        event_text = "<br>".join(
            esc(
                " ".join(
                    str(part)
                    for part in [
                        event.get("phase"),
                        event.get("type"),
                        event.get("vaHex"),
                        event.get("label") or "",
                        (
                            f"{event.get('motionKind') or 'movement'}/"
                            f"m{event.get('motionMode', event.get('movementMode'))}/"
                            f"s{event.get('selector')}/d{event.get('stepDivisor', event.get('divisor'))}"
                        )
                        if event.get("type") == "movement"
                        else "",
                        wlk_label(event.get("wlkNo")) if event.get("wlkNo") is not None else "",
                    ]
                    if part not in (None, "")
                )
            )
            for event in row["events"]
        )
        rows.append(
            "<tr>"
            f"<td>{esc(row['enemyName'])}<br><code>{esc(row['cns'])}</code></td>"
            f"<td>{esc(row['sharedActionName'])}<br><code>{esc(row['sharedActionIdHex'])}</code> slot <code>{esc(row['visibleSlotHex'])}</code></td>"
            f"<td>{esc(row['movementClass'])}<br><span class='muted'>{esc(row['classificationNote'])}</span></td>"
            f"<td><code>{esc(row['movementSignature'])}</code></td>"
            f"<td>{esc(' '.join(row['expandedLocalFrameLabels'][:18]))}<br><span class='muted'>gate {esc(row['expandedLocalDurationGate'])}</span></td>"
            f"<td>{event_text}</td>"
            "</tr>"
        )
    detail_rows = "".join(rows)
    return f"""<!doctype html>
<html lang="ko">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="icon" href="../favicon.ico">
  <title>몬스터 행동 이동 패턴 정적 리뷰</title>
  <style>{css}</style>
</head>
<body>
<main>
  <header>
    <div>
      <h1>몬스터 행동 이동 패턴 정적 리뷰</h1>
      <div class="muted">EXE 정적 display VM 이벤트만 사용. 런타임/Wine 관찰 없음.</div>
    </div>
    <nav>
      <a href="../web/index.html">홈</a>
      <a href="../web/battle_analysis.html">전투 분석</a>
      <a href="battle_monster_action_effect_review.json">action effect JSON</a>
      <a href="battle_monster_movement_pattern_review.json">JSON</a>
    </nav>
  </header>
  <section>
    <div class="section-head"><h2>요약</h2><span class="tag">{esc(report['status'])}</span></div>
    <div class="body grid">
      <div class="metric"><strong>{esc(summary['actionRows'])}</strong><span>monster action rows</span></div>
      <div><table><thead><tr><th>movement class</th><th>count</th></tr></thead><tbody>{class_rows}</tbody></table></div>
      <div><table><thead><tr><th>0xbc opcode</th><th>count</th></tr></thead><tbody>{opcode_rows}</tbody></table></div>
    </div>
  </section>
  <section><div class="section-head"><h2>정적 결론</h2></div><div class="body"><ul>{conclusions}</ul><h2>남은 점</h2><ul>{gaps}</ul></div></section>
  <section><div class="section-head"><h2>행동 상세</h2></div><div class="body scroll"><table><thead><tr><th>monster</th><th>action</th><th>class</th><th>signature</th><th>frames</th><th>event order</th></tr></thead><tbody>{detail_rows}</tbody></table></div></section>
</main>
</body>
</html>
"""


def main() -> None:
    report = build()
    OUTPUT_JSON.write_text(json.dumps(report, ensure_ascii=False, separators=(",", ":")) + "\n", encoding="utf-8")
    OUTPUT_HTML.write_text(write_html(report), encoding="utf-8")
    print(f"wrote {OUTPUT_JSON}")
    print(f"wrote {OUTPUT_HTML}")


if __name__ == "__main__":
    main()
