#!/usr/bin/env python3
"""Audit player battle display VM starts against the EXE walker.

This report is stricter than ``battle_skill_timeline_status.json``.  It does
not decide whether an effect is visually complete; it only answers whether the
currently selected actor display VM start can be re-walked from the EXE and
reproduce the canonical frame stream for each player action row.
"""

from __future__ import annotations

import html
import json
import sys
from collections import Counter
from pathlib import Path
from typing import Any


ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "out"

sys.path.insert(0, str(ROOT / "tools"))
import build_battle_display_vm_static_decode as vm  # noqa: E402
import build_battle_action_event_timeline_review as event_timeline  # noqa: E402


OUTPUT_JSON = OUT / "battle_player_display_start_audit.json"
OUTPUT_MD = OUT / "battle_player_display_start_audit.md"
OUTPUT_HTML = OUT / "battle_player_display_start_audit.html"


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


def hex_to_int(value: Any) -> int | None:
    if not isinstance(value, str) or not value:
        return None
    try:
        return int(value, 16)
    except ValueError:
        return None


def row_key(owner: str, skill_id_hex: str) -> tuple[str, str]:
    return owner, skill_id_hex.lower()


def key_from_row(row: dict[str, Any]) -> tuple[str, str]:
    return row_key(str(row.get("ownerKey") or ""), str(row.get("skillIdHex") or ""))


def branches_reference_skill_id(walk: dict[str, Any]) -> bool:
    for branch in walk.get("branches") or []:
        if "actor.skillId" in str(branch.get("summary") or ""):
            return True
    return False


def stop_is_valid(walk: dict[str, Any]) -> bool:
    return str(walk.get("stopReason") or "").startswith("idle/end marker")


def frame_sequence_matches(expected: list[Any], actual: list[Any]) -> bool:
    return [int(value) for value in expected] == [int(value) for value in actual]


def expanded_frame_sequence(walk: dict[str, Any] | None) -> list[int]:
    if walk is None:
        return []
    rows = event_timeline.expand_rows_with_repeat_loops(walk.get("rows") or [])
    return [int(row["frame"]) for row in rows if row.get("category") == "frame" and row.get("frame") is not None]


def find_entry_spec(owner_key: str, skill_id: int) -> dict[str, Any] | None:
    for spec in vm.ENTRY_SPECS:
        if spec.owner_key == owner_key and skill_id in spec.skill_ids:
            return {
                "ownerKey": spec.owner_key,
                "familyName": spec.family_name,
                "startVaHex": vm.hex32(spec.start_va),
                "confidence": spec.confidence,
                "preferStartVa": spec.prefer_start_va,
                "skillIds": [vm.hex8(value) for value in spec.skill_ids],
                "note": spec.note,
            }
    return None


def classify_row(
    *,
    action: dict[str, Any],
    canonical: dict[str, Any] | None,
    timeline: dict[str, Any] | None,
    status: dict[str, Any] | None,
    chosen_walk: dict[str, Any] | None,
    phase_walk: dict[str, Any] | None,
    phase_start_va: int | None,
    chosen_start_va: int | None,
    entry_spec: dict[str, Any] | None,
) -> tuple[str, list[str], list[str]]:
    findings: list[str] = []
    notes: list[str] = []
    start_source = str((canonical or {}).get("displayStartSource") or (timeline or {}).get("startSource") or "")
    expected_frames = (canonical or {}).get("frameSequence") or []
    timeline_frames = (timeline or {}).get("frameSequence") or []

    if canonical is None:
        findings.append("missing canonical row")
    if timeline is None:
        findings.append("missing timeline row")
    if status is None:
        findings.append("missing status row")
    if chosen_start_va is None:
        findings.append("missing chosen display start")
    if chosen_walk is None:
        findings.append("chosen start is not walkable")
    elif not stop_is_valid(chosen_walk):
        findings.append(f"chosen walk does not stop at idle/end marker: {chosen_walk.get('stopReason')}")
    chosen_expanded_frames = expanded_frame_sequence(chosen_walk)
    phase_expanded_frames = expanded_frame_sequence(phase_walk)

    if chosen_walk is not None and expected_frames and not frame_sequence_matches(expected_frames, chosen_expanded_frames):
        findings.append("canonical frameSequence differs from EXE walk at chosen start")
    if chosen_walk is not None and timeline_frames and not frame_sequence_matches(timeline_frames, chosen_expanded_frames):
        findings.append("timeline frameSequence differs from EXE walk at chosen start")

    visual_status = str((status or {}).get("visualAlignmentStatus") or "")
    if visual_status == "display-effect-layer-unresolved" or (status and "display-effect-layer-unresolved" in (status.get("reasons") or [])):
        notes.append("actor display start exists, but result/effect layer is still unresolved")

    chosen_frames = chosen_expanded_frames
    if not chosen_frames:
        # Some support/status rows can validly have a short non-damage display,
        # but a player action row with no actor frames should remain reviewable.
        findings.append("chosen EXE walk has no actor frame writes")

    if findings:
        return "invalid/stale", findings, notes

    if visual_status == "display-effect-layer-unresolved":
        return "helper/effect unresolved", findings, notes

    if start_source == "display-table-phase-pointer" and chosen_start_va == phase_start_va:
        return "phase-table direct", findings, notes

    if start_source in {"entry-spec-family-start", "display-start-override", "entry-spec-fallback"}:
        if chosen_walk and branches_reference_skill_id(chosen_walk):
            notes.append("chosen branch family contains actor.skillId comparisons")
        elif start_source == "display-start-override" and str((timeline or {}).get("confidence") or "").startswith("display-start-corrected"):
            notes.append("EXE-local display-start override corrects a phase table mismatch")
        elif entry_spec and entry_spec.get("confidence") in {
            "static-repeat-loop-family",
            "static-child-display-family",
            "static-shared-special-family",
            "static-branch-family",
        }:
            notes.append(f"entry spec confidence: {entry_spec.get('confidence')}")
        else:
            findings.append("family/override start lacks actor.skillId branch or promoted family confidence")
            return "invalid/stale", findings, notes

        if phase_walk is not None and phase_start_va != chosen_start_va:
            if not frame_sequence_matches(phase_expanded_frames, chosen_expanded_frames):
                notes.append("phase-table walk differs from chosen family/override walk")
        return "branch-family override", findings, notes

    if start_source == "display-table-phase-pointer" and chosen_start_va != phase_start_va:
        findings.append("display-table source recorded but chosen start differs from phase table")
        return "invalid/stale", findings, notes

    findings.append(f"unclassified start source: {start_source or '-'}")
    return "invalid/stale", findings, notes


def build() -> dict[str, Any]:
    mapping = load_json(OUT / "battle_action_mapping.json")
    timeline_report = load_json(OUT / "battle_action_event_timeline_review.json")
    status_report = load_json(OUT / "battle_skill_timeline_status.json")
    canonical_report = load_json(OUT / "battle_skill_timeline_canonical.json")

    data = vm.EXE.read_bytes()
    sections = vm.read_sections(data)

    timeline_by_key = {key_from_row(row): row for row in timeline_report.get("rows") or []}
    status_by_key = {key_from_row(row): row for row in status_report.get("rows") or []}
    canonical_by_key = {key_from_row(row): row for row in canonical_report.get("playerActions") or []}

    rows: list[dict[str, Any]] = []
    counts: Counter[str] = Counter()
    finding_counts: Counter[str] = Counter()
    start_source_counts: Counter[str] = Counter()

    for action in mapping.get("playerRows") or []:
        owner_key = str(action.get("ownerKey") or "")
        skill_id = int(action.get("skillId") or 0)
        skill_id_hex = str(action.get("skillIdHex") or vm.hex8(skill_id)).lower()
        key = row_key(owner_key, skill_id_hex)
        canonical = canonical_by_key.get(key)
        timeline = timeline_by_key.get(key)
        status = status_by_key.get(key)
        entry_spec = find_entry_spec(owner_key, skill_id)

        phase = action.get("phase")
        phase_entry_va, phase_start_va = vm.display_table_start(data, sections, owner_key, phase if isinstance(phase, int) else None)
        chosen_start_va = hex_to_int((canonical or {}).get("displayVmStartVaHex")) or hex_to_int((timeline or {}).get("entryStartVaHex"))
        chosen_walk = vm.walk_script(data, sections, chosen_start_va, skill_id) if chosen_start_va is not None else None
        phase_walk = None
        if phase_start_va is not None:
            phase_walk = vm.walk_script(data, sections, phase_start_va, skill_id)

        evidence_class, findings, notes = classify_row(
            action=action,
            canonical=canonical,
            timeline=timeline,
            status=status,
            chosen_walk=chosen_walk,
            phase_walk=phase_walk,
            phase_start_va=phase_start_va,
            chosen_start_va=chosen_start_va,
            entry_spec=entry_spec,
        )
        counts[evidence_class] += 1
        for finding in findings:
            finding_counts[finding.split(":", 1)[0]] += 1
        start_source = str((canonical or {}).get("displayStartSource") or (timeline or {}).get("startSource") or "")
        start_source_counts[start_source or "-"] += 1

        row = {
            "ownerKey": owner_key,
            "ownerName": action.get("ownerName"),
            "skillId": skill_id,
            "skillIdHex": skill_id_hex,
            "skillName": action.get("name"),
            "levelOrFixed": action.get("levelOrFixed"),
            "phaseHex": action.get("phaseHex"),
            "payloadVaHex": action.get("payloadVaHex"),
            "entryVaHex": action.get("entryVaHex"),
            "displayStartSource": start_source,
            "timelineConfidence": (timeline or {}).get("confidence"),
            "statusVisualAlignment": (status or {}).get("visualAlignmentStatus"),
            "evidenceClass": evidence_class,
            "findings": findings,
            "notes": notes,
            "entrySpec": entry_spec,
            "displayTableEntryVaHex": vm.hex32(phase_entry_va),
            "phaseTableStartVaHex": vm.hex32(phase_start_va),
            "chosenStartVaHex": vm.hex32(chosen_start_va),
            "chosenStopReason": (chosen_walk or {}).get("stopReason"),
            "phaseStopReason": (phase_walk or {}).get("stopReason") if phase_walk else "",
            "canonicalFrameSequence": (canonical or {}).get("frameSequence") or [],
            "timelineFrameSequence": (timeline or {}).get("frameSequence") or [],
            "chosenWalkFrameSequenceRaw": (chosen_walk or {}).get("frameSequence") or [],
            "phaseWalkFrameSequenceRaw": (phase_walk or {}).get("frameSequence") or [],
            "chosenWalkFrameSequence": expanded_frame_sequence(chosen_walk),
            "phaseWalkFrameSequence": expanded_frame_sequence(phase_walk),
            "chosenBranches": (chosen_walk or {}).get("branches") or [],
            "phaseBranches": (phase_walk or {}).get("branches") or [],
            "chosenEffectWlkNos": [row.get("wlkNo") for row in (chosen_walk or {}).get("effectSounds") or []],
            "chosenResultWlkNos": [row.get("wlkNo") for row in (chosen_walk or {}).get("sounds") or []],
            "chosenMovements": (chosen_walk or {}).get("movements") or [],
            "chosenRepeatLoops": (chosen_walk or {}).get("repeatLoops") or [],
        }
        rows.append(row)

    return {
        "version": 1,
        "kind": "hwanse-battle-player-display-start-audit",
        "source": [
            "Hwanse2.exe",
            "out/battle_action_mapping.json",
            "out/battle_action_event_timeline_review.json",
            "out/battle_skill_timeline_status.json",
            "out/battle_skill_timeline_canonical.json",
        ],
        "status": "strict-player-display-start-audit",
        "summary": {
            "playerRows": len(rows),
            "evidenceClassCounts": dict(counts),
            "startSourceCounts": dict(start_source_counts),
            "findingCounts": dict(finding_counts),
            "invalidOrStaleRows": sum(1 for row in rows if row["evidenceClass"] == "invalid/stale"),
            "helperEffectUnresolvedRows": sum(1 for row in rows if row["evidenceClass"] == "helper/effect unresolved"),
        },
        "classificationLegend": {
            "phase-table direct": "Chosen start is the per-actor phase table pointer and re-walking it reproduces the canonical frame sequence.",
            "branch-family override": "Chosen start intentionally differs from the phase table because a promoted branch family or EXE-local override is the valid visible actor script.",
            "helper/effect unresolved": "Actor frame start is valid, but the visible action also depends on a helper/effect/result layer not fully rendered yet.",
            "invalid/stale": "The current canonical/timeline row is missing, stale, or differs from a fresh EXE walk.",
        },
        "notes": [
            "This audit re-walks Hwanse2.exe directly. It is not based on manual observation or browser preview output.",
            "A row can be EXE-grounded and still be helper/effect unresolved; that means the actor frame stream is valid but the effect layer is incomplete.",
            "The audit deliberately treats frame-sequence mismatches as invalid/stale so old manual corrections cannot silently survive.",
        ],
        "rows": rows,
    }


def markdown(report: dict[str, Any]) -> str:
    lines = [
        "# Battle Player Display Start Audit",
        "",
        f"- status: `{report['status']}`",
        f"- player rows: `{report['summary']['playerRows']}`",
        "",
        "## Summary",
        "",
        "| evidence class | count |",
        "| --- | ---: |",
    ]
    for name, count in report["summary"]["evidenceClassCounts"].items():
        lines.append(f"| `{name}` | {count} |")
    lines += [
        "",
        "## Invalid/Stale Or Unresolved Rows",
        "",
        "| actor | skill | id | class | chosen | phase | findings |",
        "| --- | --- | ---: | --- | --- | --- | --- |",
    ]
    for row in report["rows"]:
        if row["evidenceClass"] in {"phase-table direct", "branch-family override"}:
            continue
        lines.append(
            "| "
            + " | ".join(
                [
                    str(row["ownerName"]),
                    str(row["skillName"]),
                    f"`{row['skillIdHex']}`",
                    f"`{row['evidenceClass']}`",
                    f"`{row['chosenStartVaHex']}`",
                    f"`{row['phaseTableStartVaHex']}`",
                    "; ".join(row["findings"] or row["notes"]),
                ]
            )
            + " |"
        )
    lines += [
        "",
        "## All Rows",
        "",
        "| actor | skill | id | level | source | class | chosen frames | phase frames |",
        "| --- | --- | ---: | ---: | --- | --- | --- | --- |",
    ]
    for row in report["rows"]:
        lines.append(
            "| "
            + " | ".join(
                [
                    str(row["ownerName"]),
                    str(row["skillName"]),
                    f"`{row['skillIdHex']}`",
                    str(row.get("levelOrFixed") or ""),
                    f"`{row['displayStartSource']}`",
                    f"`{row['evidenceClass']}`",
                    ", ".join(str(value) for value in row["chosenWalkFrameSequence"]),
                    ", ".join(str(value) for value in row["phaseWalkFrameSequence"]),
                ]
            )
            + " |"
        )
    lines.append("")
    return "\n".join(lines)


def html_page(report: dict[str, Any]) -> str:
    def esc(value: Any) -> str:
        return html.escape(str(value if value is not None else ""))

    summary_rows = "".join(
        f"<tr><td><code>{esc(name)}</code></td><td>{count}</td></tr>"
        for name, count in report["summary"]["evidenceClassCounts"].items()
    )
    row_html = []
    for row in report["rows"]:
        findings = "; ".join(row["findings"] or row["notes"])
        chosen_frames = ", ".join(str(value) for value in row["chosenWalkFrameSequence"])
        phase_frames = ", ".join(str(value) for value in row["phaseWalkFrameSequence"])
        row_html.append(
            "<tr>"
            f"<td>{esc(row['ownerName'])}</td>"
            f"<td>{esc(row['skillName'])}</td>"
            f"<td><code>{esc(row['skillIdHex'])}</code></td>"
            f"<td>{esc(row.get('levelOrFixed'))}</td>"
            f"<td><code>{esc(row['displayStartSource'])}</code></td>"
            f"<td><code>{esc(row['evidenceClass'])}</code></td>"
            f"<td><code>{esc(row['chosenStartVaHex'])}</code></td>"
            f"<td><code>{esc(row['phaseTableStartVaHex'])}</code></td>"
            f"<td>{esc(chosen_frames)}</td>"
            f"<td>{esc(phase_frames)}</td>"
            f"<td>{esc(findings)}</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 Player Display Start Audit</title>
  <style>
    body {{ margin: 20px; background: #101114; color: #f1f3f5; font-family: system-ui, sans-serif; }}
    a {{ color: #9ecbff; }} code {{ color: #ffd37a; }}
    .wrap {{ overflow-x: auto; }}
    table {{ border-collapse: collapse; width: 100%; margin: 14px 0 24px; }}
    th, td {{ border: 1px solid #30343d; padding: 6px 8px; font-size: 12px; vertical-align: top; }}
    th {{ background: #1a1d24; color: #bac2cf; position: sticky; top: 0; }}
    tr:has(td code:nth-child(1)) {{ background: transparent; }}
  </style>
</head>
<body>
  <h1>Battle Player Display Start Audit</h1>
  <p><a href="../web/index.html">홈</a> · <a href="battle_player_display_start_audit.json">JSON</a> · <a href="battle_player_display_start_audit.md">MD</a></p>
  <p>EXE를 다시 walk해서 현재 canonical/timeline의 시작점과 프레임열이 맞는지 확인한 감사 리포트입니다.</p>
  <h2>Summary</h2>
  <table><thead><tr><th>evidence class</th><th>count</th></tr></thead><tbody>{summary_rows}</tbody></table>
  <h2>Rows</h2>
  <div class="wrap">
    <table>
      <thead><tr><th>actor</th><th>skill</th><th>id</th><th>level</th><th>source</th><th>class</th><th>chosen</th><th>phase</th><th>chosen frames</th><th>phase frames</th><th>findings/notes</th></tr></thead>
      <tbody>{''.join(row_html)}</tbody>
    </table>
  </div>
</body>
</html>
"""


def main() -> None:
    report = build()
    OUT.mkdir(parents=True, exist_ok=True)
    OUTPUT_JSON.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
    OUTPUT_MD.write_text(markdown(report), encoding="utf-8")
    OUTPUT_HTML.write_text(html_page(report), encoding="utf-8")
    print(f"wrote {OUTPUT_JSON}")
    print(f"wrote {OUTPUT_MD}")
    print(f"wrote {OUTPUT_HTML}")
    print(json.dumps(report["summary"], ensure_ascii=False, indent=2))


if __name__ == "__main__":
    main()
