#!/usr/bin/env python3
"""Build player battle skill timeline confirmation status.

This report is intentionally narrow: it answers whether each player skill row
has enough EXE-derived evidence to be treated as having a valid display VM
start.  Named branch-family starts and EXE-local corrections are the strongest
evidence.  Rows that only come from the per-actor phase display table are still
EXE-grounded starts, but they are kept in a separate evidence grade so the UI
does not imply that helper/effect presentation has also been fully solved.
"""

from __future__ import annotations

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


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


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


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


def skill_key_text(row: dict[str, Any]) -> str:
    owner, skill = key(row)
    return f"{owner}:{skill}"


def visual_alignment_status(row: dict[str, Any] | None) -> tuple[str, str]:
    if row is None:
        return "missing-timeline", "timeline row missing"
    start_source = str(row.get("startSource") or "")
    confidence = str(row.get("confidence") or "")
    family_name = str(row.get("familyName") or "")
    if confidence == "display-table-phase-pointer-effect-layer-unresolved" or row.get("displayStartInvalidated"):
        return "display-effect-layer-unresolved", (
            "The phase-table pointer exists, but this display VM fragment only carries actor support frames and "
            "an effect/cast cue. The result/effect layer that completes the visible action is not connected yet."
        )
    if start_source == "entry-spec-family-start":
        return "family-start-confirmed", "EXE branch-family start was selected over the generic phase table."
    if start_source == "display-start-override":
        return "display-start-corrected", "EXE-local display start override corrects a known phase-table mismatch."
    if start_source == "display-table-phase-pointer":
        if confidence.startswith("display-table") or family_name == "display-table fallback":
            return "phase-table-start-confirmed", (
                "Uses the actor phase display table directly. This is an EXE-grounded display VM start, "
                "but not the same evidence grade as a named branch-family start."
            )
        return "display-table-with-family-confidence", (
            "Uses the phase display table and carries family confidence; verify visually if frames look shifted."
        )
    if start_source:
        return start_source, "Non-standard display start source."
    return "unknown-start-source", "No display start source was recorded."


def helper_ids_from_body(body: dict[str, Any]) -> set[int]:
    ids: set[int] = set()
    for row in body.get("helperRows") or []:
        if isinstance(row.get("helperId"), int):
            ids.add(row["helperId"])
    for group in body.get("functionGroups") or []:
        ids.update(int(value) for value in group.get("helperIds") or [] if isinstance(value, int))
    return ids


def helper_ids_from_opcode(opcode: dict[str, Any]) -> set[int]:
    ids: set[int] = set()
    for row in opcode.get("helperIdGroups") or []:
        if isinstance(row.get("helperId"), int):
            ids.add(row["helperId"])
    for row in opcode.get("rows") or []:
        ids.update(int(value) for value in row.get("helperIds") or [] if isinstance(value, int))
    return ids


def helper_sync_index(sync: dict[str, Any]) -> dict[int, dict[str, Any]]:
    return {
        int(row["helperId"]): row
        for row in sync.get("rows") or []
        if isinstance(row.get("helperId"), int)
    }


def timeline_helper_ids(row: dict[str, Any] | None) -> list[int]:
    if row is None:
        return []
    timeline = row.get("timeline") or {}
    calls = timeline.get("helperCalls") or []
    ids: list[int] = []
    for call in calls:
        value = call.get("helperId") if isinstance(call, dict) else None
        if isinstance(value, int):
            ids.append(value)
    if ids:
        return ids
    # Older timeline artifacts only exposed a row-level helperIds field.  Keep
    # this as a fallback, but prefer helperCalls because row-level helperIds can
    # include sibling branch helpers from the same display VM family.
    return [int(value) for value in row.get("helperIds") or [] if isinstance(value, int)]


def classify_hit(row: dict[str, Any] | None, hit_gap: dict[str, Any] | None) -> tuple[str, str, list[str]]:
    if row is None:
        return "missing", "timeline row missing", ["timeline row missing"]

    hit_events = (row.get("timeline") or {}).get("hitEvents") or []
    if hit_events:
        return "confirmed-result-hit-window", f"{len(hit_events)} result hit window(s)", []

    row_class = str((hit_gap or {}).get("rowClass") or "")
    if row_class == "no-hit-expected-self-or-mode":
        return row_class, "non-damage/support command", []
    if row_class == "no-result-sound-weapon-basic-effect-path":
        note = (hit_gap or {}).get("contextNote") or "effect-only weapon basic path"
        return row_class, note, []

    return row_class or "missing-hit-window", "damage row without confirmed hit path", ["hit path missing"]


def build() -> dict[str, Any]:
    mapping = load_json("battle_action_mapping.json")
    timeline = load_json("battle_action_event_timeline_review.json")
    hit_gap = load_json("battle_hit_window_gap_review.json")
    helper_body = load_json("battle_helper_body_review.json")
    helper_opcode = load_json("battle_helper_opcode_review.json")
    helper_sync = load_json("battle_helper_sync_timing_review.json")
    effect_semantics = load_json("battle_effect_semantics_review.json")

    timeline_by_key = {key(row): row for row in timeline.get("rows") or []}
    hit_gap_by_key = {key(row): row for row in hit_gap.get("rows") or []}
    semantics_by_key = {key(row): row for row in effect_semantics.get("rows") or []}
    body_ids = helper_ids_from_body(helper_body)
    opcode_ids = helper_ids_from_opcode(helper_opcode)
    sync_by_id = helper_sync_index(helper_sync)

    rows: list[dict[str, Any]] = []
    status_counts: Counter[str] = Counter()
    note_counts: Counter[str] = Counter()
    reason_counts: Counter[str] = Counter()

    for action in mapping.get("playerRows") or []:
        row_key = key(action)
        tl = timeline_by_key.get(row_key)
        gap = hit_gap_by_key.get(row_key)
        semantics = semantics_by_key.get(row_key)
        reasons: list[str] = []
        notes: list[str] = []

        actor_frame_count = len((tl.get("timeline") or {}).get("frameEvents") or []) if tl else 0
        if not actor_frame_count:
            reasons.append("actor-frame-timeline-missing")
        visual_status, visual_note = visual_alignment_status(tl)
        if visual_status == "phase-table-start-confirmed":
            notes.append(visual_note)
        elif visual_status == "display-effect-layer-unresolved":
            reasons.append("display-effect-layer-unresolved")
            notes.append(visual_note)

        hit_class, hit_note, hit_reasons = classify_hit(tl, gap)
        reasons.extend(hit_reasons)
        if hit_note and hit_class != "confirmed-result-hit-window":
            notes.append(hit_note)

        helper_ids = timeline_helper_ids(tl)
        missing_helpers = [
            helper_id for helper_id in helper_ids
            if helper_id not in body_ids and helper_id not in opcode_ids
        ]
        if missing_helpers:
            reasons.append("helper-dispatch-body-missing:" + ",".join(map(str, missing_helpers)))

        unmatched_helpers: list[int] = []
        sync_helpers: list[int] = []
        body_only_helpers: list[int] = []
        looping_helpers: list[int] = []
        for helper_id in helper_ids:
            sync_row = sync_by_id.get(helper_id)
            if sync_row:
                sync_helpers.append(helper_id)
                if "looping-frame-script" in (sync_row.get("signals") or []):
                    looping_helpers.append(helper_id)
                for event in sync_row.get("directSpawnFrameEvents") or []:
                    status = event.get("transformStatus")
                    if status and status != "matched-position-motion-scope":
                        unmatched_helpers.append(helper_id)
            elif helper_id in body_ids or helper_id in opcode_ids:
                body_only_helpers.append(helper_id)

        if unmatched_helpers:
            reasons.append("helper-transform-unmatched:" + ",".join(map(str, sorted(set(unmatched_helpers)))))
        if body_only_helpers:
            notes.append("helper dispatch body confirmed without frame-sync row: " + ",".join(map(str, body_only_helpers)))
        if looping_helpers:
            notes.append("looping helper frameScript: " + ",".join(map(str, looping_helpers)))

        position_class = str((tl or {}).get("positionClass") or "")
        if "inferred" in position_class:
            notes.append("position class is semantic/inferred label")

        implementation_state = str((semantics or {}).get("implementationState") or "")
        if implementation_state.startswith("needs-"):
            notes.append("visual implementation note: " + implementation_state)

        status = "needs-review" if reasons else "confirmed"
        status_counts[status] += 1
        for reason in reasons:
            reason_counts[reason.split(":", 1)[0]] += 1
        for note in notes:
            note_counts[note.split(":", 1)[0]] += 1

        rows.append({
            "key": skill_key_text(action),
            "ownerKey": action.get("ownerKey"),
            "ownerName": action.get("ownerName"),
            "skillIdHex": action.get("skillIdHex"),
            "skillName": action.get("name"),
            "levelOrFixed": action.get("levelOrFixed"),
            "entryVaHex": action.get("entryVaHex"),
            "payloadVaHex": action.get("payloadVaHex"),
            "status": status,
            "visualAlignmentStatus": visual_status,
            "visualAlignmentNote": visual_note,
            "startSource": (tl or {}).get("startSource"),
            "reasons": reasons,
            "notes": notes,
            "actorFrameCount": actor_frame_count,
            "actorDurationGate": (tl or {}).get("durationGateFromFrames"),
            "hitClass": hit_class,
            "hitEventCount": (tl or {}).get("hitEventCount", 0),
            "helperIds": helper_ids,
            "helperSyncIds": sync_helpers,
            "helperBodyOnlyIds": body_only_helpers,
            "positionClass": position_class,
            "helperRole": (tl or {}).get("helperRole"),
            "semanticImplementationState": implementation_state,
            "semanticRenderTrack": (semantics or {}).get("renderTrack"),
        })

    return {
        "version": 1,
        "kind": "hwanse-battle-skill-timeline-status",
        "source": [
            "out/battle_action_mapping.json",
            "out/battle_action_event_timeline_review.json",
            "out/battle_hit_window_gap_review.json",
            "out/battle_helper_body_review.json",
            "out/battle_helper_opcode_review.json",
            "out/battle_helper_sync_timing_review.json",
            "out/battle_effect_semantics_review.json",
        ],
        "status": "player-skill-timeline-confirmation",
        "summary": {
            "playerRows": len(rows),
            "statusCounts": dict(status_counts),
            "visualAlignmentCounts": dict(Counter(row["visualAlignmentStatus"] for row in rows)),
            "reasonCounts": dict(reason_counts),
            "noteCounts": dict(note_counts),
            "helperIdsReferenced": len({helper_id for row in rows for helper_id in row["helperIds"]}),
            "helperIdsMissingDispatchBody": sum(
                1 for row in rows for reason in row["reasons"]
                if reason.startswith("helper-dispatch-body-missing")
            ),
        },
        "interpretationNotes": [
            "status=confirmed means the player action has an EXE-grounded display VM start, an actor-frame timeline, a confirmed hit/no-hit path, and every referenced 0xbd helper id is grounded by helper body/opcode evidence.",
            "phase-table-start-confirmed rows are direct per-actor phase table starts. They are valid starts, but intentionally separated from named branch-family/override starts.",
            "helperBodyOnlyIds are not a rejection condition: those helpers have EXE dispatch/function-body evidence but no frame-sync overlay row.",
            "position-inferred notes are semantic labels for runner placement, not a missing actor-frame timeline.",
        ],
        "rows": rows,
    }


def main() -> None:
    report = build()
    (OUT / "battle_skill_timeline_status.json").write_text(
        json.dumps(report, ensure_ascii=False, indent=2) + "\n",
        encoding="utf-8",
    )
    print("wrote out/battle_skill_timeline_status.json")
    print(json.dumps(report["summary"], ensure_ascii=False, indent=2))


if __name__ == "__main__":
    main()
