#!/usr/bin/env python3
"""Build a source-backed event parity audit inventory.

This is intentionally conservative: it does not claim prose parity. It gathers
the evidence needed to audit every event against Inform in repeatable batches.
"""

from __future__ import annotations

import argparse
import ast
import json
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any


EVENT_ASSIGN_START_RE = re.compile(r"(?P<name>[A-Z][A-Z0-9_]+_(?:EVENTS?|QUESTS?))\s*=\s*\{")
REGISTER_EVENT_RE = re.compile(r"register_(?:event|quest)\(\s*(?P<arg>[A-Z][A-Z0-9_]+_(?:EVENTS?|QUESTS?)|\{)")
LABEL_RE = re.compile(r"^\s*label\s+([A-Za-z_][A-Za-z0-9_]*)\s*:", re.MULTILINE)
JUMP_CALL_RE = re.compile(r"^\s*(?:jump|call)\s+([A-Za-z_][A-Za-z0-9_]*)\b", re.MULTILINE)
MENU_RE = re.compile(r"^\s*menu\s*:", re.MULTILINE)
MENU_OPTION_RE = re.compile(r"^\s*(['\"])(?P<option>.+?)\1\s*:", re.MULTILINE)

I7_TABLE_ROW_RE = re.compile(r"^(?P<object>[A-Za-z0-9][^\t\r\n]+?)\t\"(?P<name>[^\"]+)\"", re.MULTILINE)
I7_SITUATION_RE = re.compile(r"^(?P<object>[A-Za-z0-9][^\r\n]+?) is a situation\.", re.MULTILINE)
I7_RESOLVE_RE = re.compile(r"^ResolveFunction of (?P<object>.+?) is \"\[ResolveEvent (?P<resolve>.+?)\]\"\.", re.MULTILINE)
I7_LEVEL_RE = re.compile(r"^The level of (?P<object>.+?) is (?P<level>-?\d+)\.", re.MULTILINE)
I7_SAREA_RE = re.compile(r"^Sarea of (?P<object>.+?) is \"(?P<sarea>[^\"]+)\"\.", re.MULTILINE)
I7_LINK_LINE_RE = re.compile(r"say\s+\"[^\"]*\(\[link\](?P<key>[^\[]+)\[as\](?P<label>[^\[]+)\[end link\]\)\s*-\s*(?P<text>[^\"]+)\"")
I7_IF_CONSENTS_RE = re.compile(r"^\s*(?:else\s+)?if Player consents(?::|\s*\[)", re.MULTILINE)
I7_TO_SAY_RE = re.compile(r"^to say (?P<name>[A-Za-z0-9][^:\r\n]+):", re.MULTILINE)


def normalize_key(value: str | None) -> str:
    if not value:
        return ""
    return re.sub(r"[^a-z0-9]+", "", value.lower())


def event_id_from_name(value: str | None) -> str:
    if not value:
        return ""
    text = re.sub(r"[^a-zA-Z0-9]+", "_", value.strip()).strip("_").lower()
    return text


def area_from_trigger(trigger: Any) -> str | None:
    if not isinstance(trigger, str):
        return None
    trigger = trigger.strip()
    if not trigger.lower().startswith("area:"):
        return None
    return trigger.split(":", 1)[1].strip() or None


def strip_python_comments(text: str) -> str:
    lines: list[str] = []
    for line in text.splitlines():
        in_single = False
        in_double = False
        escaped = False
        out: list[str] = []
        for char in line:
            if escaped:
                out.append(char)
                escaped = False
                continue
            if char == "\\":
                out.append(char)
                escaped = True
                continue
            if char == "'" and not in_double:
                in_single = not in_single
                out.append(char)
                continue
            if char == '"' and not in_single:
                in_double = not in_double
                out.append(char)
                continue
            if char == "#" and not in_single and not in_double:
                break
            out.append(char)
        lines.append("".join(out))
    return "\n".join(lines)


def strip_inform_comments(text: str) -> str:
    """Remove Inform bracket comments while preserving line offsets."""
    output: list[str] = []
    in_string = False
    comment_depth = 0
    index = 0
    while index < len(text):
        char = text[index]
        if comment_depth:
            output.append("\n" if char == "\n" else " ")
            if char == "[":
                comment_depth += 1
            elif char == "]":
                comment_depth -= 1
            index += 1
            continue

        if char == '"' and (index == 0 or text[index - 1] != "\\"):
            in_string = not in_string
            output.append(char)
        elif char == "[" and not in_string:
            comment_depth = 1
            output.append(" ")
        else:
            output.append(char)
        index += 1
    return "".join(output)


def literal_dict(body: str) -> dict[str, Any] | None:
    cleaned = strip_python_comments(body)
    try:
        parsed = ast.literal_eval(cleaned)
    except (SyntaxError, ValueError):
        try:
            node = ast.parse(cleaned, mode="eval").body
            parsed = ast_to_data(node)
        except (SyntaxError, ValueError):
            return None
    if isinstance(parsed, dict):
        return parsed
    return None


def ast_to_data(node: ast.AST) -> Any:
    if isinstance(node, ast.Dict):
        data: dict[Any, Any] = {}
        for key, value in zip(node.keys, node.values):
            if key is None:
                continue
            data[ast_to_data(key)] = ast_to_data(value)
        return data
    if isinstance(node, ast.List):
        return [ast_to_data(item) for item in node.elts]
    if isinstance(node, ast.Tuple):
        return tuple(ast_to_data(item) for item in node.elts)
    if isinstance(node, ast.Constant):
        return node.value
    if isinstance(node, ast.Name):
        return node.id
    if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.USub):
        value = ast_to_data(node.operand)
        if isinstance(value, (int, float)):
            return -value
    raise ValueError(f"unsupported AST node in event dictionary: {type(node).__name__}")


@dataclass
class RenpyEvent:
    id: str
    name: str
    path: str
    line: int
    trigger: Any = None
    sarea: Any = None
    level: Any = None
    scenes: list[str] = field(default_factory=list)
    menu_count: int = 0
    menu_options: list[str] = field(default_factory=list)
    parse_error: bool = False

    @property
    def match_keys(self) -> set[str]:
        keys = {normalize_key(self.id), normalize_key(self.name)}
        keys.add(normalize_key(self.id.replace("_", " ")))
        return {key for key in keys if key}

    @property
    def effective_sarea(self) -> Any:
        return self.sarea or area_from_trigger(self.trigger)


@dataclass
class InformEvent:
    object_name: str
    display_name: str
    path: str
    line: int
    resolve_name: str | None = None
    level: int | None = None
    sarea: str | None = None
    menu_options: list[str] = field(default_factory=list)
    consent_branches: int = 0
    helper_says: list[str] = field(default_factory=list)

    @property
    def expected_id(self) -> str:
        return event_id_from_name(self.display_name or self.object_name)

    @property
    def match_keys(self) -> set[str]:
        keys = {
            normalize_key(self.object_name),
            normalize_key(self.display_name),
            normalize_key(self.expected_id),
            normalize_key(self.resolve_name),
        }
        return {key for key in keys if key}


def line_number(text: str, offset: int) -> int:
    return text.count("\n", 0, offset) + 1


def find_matching(text: str, start: int, opener: str, closer: str) -> int | None:
    depth = 0
    in_single = False
    in_double = False
    escaped = False
    comment = False
    for index in range(start, len(text)):
        char = text[index]
        if comment:
            if char == "\n":
                comment = False
            continue
        if escaped:
            escaped = False
            continue
        if char == "\\":
            escaped = True
            continue
        if char == "#" and not in_single and not in_double:
            comment = True
            continue
        if char == "'" and not in_double:
            in_single = not in_single
            continue
        if char == '"' and not in_single:
            in_double = not in_double
            continue
        if in_single or in_double:
            continue
        if char == opener:
            depth += 1
        elif char == closer:
            depth -= 1
            if depth == 0:
                return index
    return None


def extract_event_assignments(text: str) -> dict[str, tuple[str, int, int]]:
    assignments: dict[str, tuple[str, int, int]] = {}
    for match in EVENT_ASSIGN_START_RE.finditer(text):
        brace_start = text.find("{", match.start())
        brace_end = find_matching(text, brace_start, "{", "}")
        if brace_end is None:
            continue
        assignments[match.group("name")] = (text[brace_start : brace_end + 1], match.start(), brace_end + 1)
    return assignments


def extract_registered_events(text: str) -> list[tuple[str, int, int]]:
    assignments = extract_event_assignments(text)
    events: list[tuple[str, int, int]] = []
    for match in REGISTER_EVENT_RE.finditer(text):
        arg = match.group("arg")
        if arg == "{":
            brace_start = text.find("{", match.start())
            brace_end = find_matching(text, brace_start, "{", "}")
            if brace_end is not None:
                events.append((text[brace_start : brace_end + 1], match.start(), brace_end + 1))
            continue
        if arg in assignments:
            body, start, end = assignments[arg]
            events.append((body, start, end))
    return events


def label_sections(text: str) -> dict[str, tuple[int, int]]:
    matches = list(LABEL_RE.finditer(text))
    sections: dict[str, tuple[int, int]] = {}
    for index, match in enumerate(matches):
        start = match.start()
        end = matches[index + 1].start() if index + 1 < len(matches) else len(text)
        sections[match.group(1)] = (start, end)
    return sections


def menu_options_in(text: str) -> list[str]:
    return [match.group("option").strip() for match in MENU_OPTION_RE.finditer(text)]


def inform_top_level_end(text: str, start: int) -> int:
    marker = re.compile(
        r"^(?:Section\b|Table of\b|to say\b|instead of\b|after\b|before\b|check\b|carry out\b|report\b|An everyturn rule\b|an everyturn rule\b|a postimport rule\b|when play begins\b)",
        re.MULTILINE,
    )
    match = marker.search(text, start)
    return match.start() if match else len(text)


def inform_named_say_block(text: str, name: str | None) -> str | None:
    if not name:
        return None
    pattern = re.compile(rf"^to say {re.escape(name)}:\s*$", re.MULTILINE)
    match = pattern.search(text)
    if not match:
        return None
    end = inform_top_level_end(text, match.end())
    return text[match.start():end]


def scan_renpy(root: Path) -> tuple[list[RenpyEvent], dict[str, Any]]:
    labels: dict[str, str] = {}
    label_ranges: dict[str, dict[str, Any]] = {}
    jumps: list[dict[str, Any]] = []
    events: list[RenpyEvent] = []
    parse_failures: list[dict[str, Any]] = []

    for path in sorted((root / "game" / "scripts").rglob("*.rpy")):
        text = path.read_text(encoding="utf-8", errors="ignore")
        rel = path.relative_to(root).as_posix()

        sections = label_sections(text)
        for label, (start, end) in sections.items():
            labels[label] = rel
            label_ranges[label] = {"path": rel, "start": start, "end": end}

        for match in JUMP_CALL_RE.finditer(text):
            target = match.group(1)
            if target not in {"expression", "screen"}:
                jumps.append({"path": rel, "line": line_number(text, match.start()), "target": target})

        for body, start_offset, end_offset in extract_registered_events(text):
            data = literal_dict(body)
            line = line_number(text, start_offset)
            if data is None:
                parse_failures.append({"path": rel, "line": line})
                continue
            event_id = str(data.get("id", "")).strip()
            name = str(data.get("name", event_id)).strip()
            scenes_raw = data.get("scenes", [])
            scenes = [str(scene) for scene in scenes_raw] if isinstance(scenes_raw, list) else []
            scene_texts: list[str] = []
            for scene in scenes:
                if scene in sections:
                    scene_start, scene_end = sections[scene]
                    scene_texts.append(text[scene_start:scene_end])
            if scene_texts:
                inspect_text = "\n".join(scene_texts)
            else:
                fallback_start = max(0, start_offset - 4000)
                fallback_end = min(len(text), end_offset + 12000)
                inspect_text = text[fallback_start:fallback_end]
            menu_count = len(MENU_RE.findall(inspect_text))
            menu_options = menu_options_in(inspect_text)
            events.append(
                RenpyEvent(
                    id=event_id,
                    name=name,
                    path=rel,
                    line=line,
                    trigger=data.get("trigger"),
                    sarea=data.get("sarea"),
                    level=data.get("level"),
                    scenes=scenes,
                    menu_count=menu_count,
                    menu_options=menu_options,
                )
            )

    missing_scene_refs: list[dict[str, Any]] = []
    for event in events:
        for scene in event.scenes:
            if scene not in labels:
                missing_scene_refs.append({"event_id": event.id, "scene": scene, "path": event.path, "line": event.line})

    missing_jump_refs = [ref for ref in jumps if ref["target"] not in labels]
    missing_event_jump_refs = [
        ref
        for ref in missing_jump_refs
        if ref["path"].startswith("game/scripts/Content/Events/")
    ]
    return events, {
        "labels": labels,
        "label_ranges": label_ranges,
        "parse_failures": parse_failures,
        "missing_scene_refs": missing_scene_refs,
        "missing_jump_refs": missing_jump_refs,
        "missing_event_jump_refs": missing_event_jump_refs,
    }


def scan_inform(source_root: Path) -> list[InformEvent]:
    events: list[InformEvent] = []
    for path in sorted(source_root.rglob("*.i7x")):
        raw_text = path.read_text(encoding="utf-8", errors="ignore")
        text = strip_inform_comments(raw_text)
        table_names = {m.group("object").strip(): m.group("name").strip() for m in I7_TABLE_ROW_RE.finditer(text)}
        resolve = {m.group("object").strip(): m.group("resolve").strip() for m in I7_RESOLVE_RE.finditer(text)}
        levels = {m.group("object").strip(): int(m.group("level")) for m in I7_LEVEL_RE.finditer(text)}
        sareas = {m.group("object").strip(): m.group("sarea").strip() for m in I7_SAREA_RE.finditer(text)}
        helper_says = [m.group("name").strip() for m in I7_TO_SAY_RE.finditer(text)]
        rel = path.relative_to(source_root).as_posix()

        for match in I7_SITUATION_RE.finditer(text):
            object_name = match.group("object").strip()
            display_name = table_names.get(object_name, object_name)
            if rel in {"Core Mechanics/Situations.i7x", "MinimalBuild/Situations.i7x"} and display_name in {"PlaceholderSituation", "ExampleEvent"}:
                continue
            resolve_name = resolve.get(object_name)
            start = match.start()
            next_match = I7_SITUATION_RE.search(text, match.end())
            section = text[start : next_match.start() if next_match else len(text)]
            event_text = inform_named_say_block(text, f"ResolveEvent {resolve_name}") if resolve_name else None
            if event_text is None:
                event_text = section
            menu_options = [m.group("text").strip() for m in I7_LINK_LINE_RE.finditer(event_text)]
            consent_branches = len(I7_IF_CONSENTS_RE.findall(event_text))
            events.append(
                InformEvent(
                    object_name=object_name,
                    display_name=display_name,
                    path=rel,
                    line=line_number(text, match.start()),
                    resolve_name=resolve_name,
                    level=levels.get(object_name),
                    sarea=sareas.get(object_name),
                    menu_options=menu_options,
                    consent_branches=consent_branches,
                    helper_says=helper_says,
                )
            )
    return events


def match_events(renpy_events: list[RenpyEvent], inform_events: list[InformEvent]) -> list[dict[str, Any]]:
    by_key: dict[str, list[RenpyEvent]] = {}
    for event in renpy_events:
        for key in event.match_keys:
            by_key.setdefault(key, []).append(event)

    rows: list[dict[str, Any]] = []
    matched_renpy_ids: set[tuple[str, str, int]] = set()
    for source in inform_events:
        candidates: list[RenpyEvent] = []
        seen: set[tuple[str, str, int]] = set()
        for key in source.match_keys:
            for candidate in by_key.get(key, []):
                ident = (candidate.path, candidate.id, candidate.line)
                if ident not in seen:
                    seen.add(ident)
                    candidates.append(candidate)

        for candidate in candidates:
            matched_renpy_ids.add((candidate.path, candidate.id, candidate.line))

        primary = candidates[0] if candidates else None
        mismatches: list[str] = []
        if primary:
            if source.level is not None and primary.level != source.level:
                mismatches.append(f"level Inform={source.level} RenPy={primary.level}")
            if source.sarea and str(primary.effective_sarea or "").lower() != source.sarea.lower():
                mismatches.append(f"sarea Inform={source.sarea} RenPy={primary.effective_sarea}")
            if source.consent_branches and primary.menu_count == 0:
                mismatches.append("Inform has consent/menu branches but nearby RenPy menu count is 0")
        else:
            mismatches.append("no matching RenPy event registration found")

        rows.append(
            {
                "inform": source.__dict__,
                "renpy_matches": [candidate.__dict__ for candidate in candidates],
                "mismatches": mismatches,
                "status": "unmatched" if primary is None else ("needs_review" if mismatches else "metadata_match"),
            }
        )

    unmatched_renpy = [
        event
        for event in renpy_events
        if (event.path, event.id, event.line) not in matched_renpy_ids
    ]
    rows.append({"unmatched_renpy_events": [event.__dict__ for event in unmatched_renpy]})
    return rows


def write_markdown(path: Path, report: dict[str, Any]) -> None:
    rows = report["event_rows"]
    audit_rows = [row for row in rows if "inform" in row]
    unmatched = next((row["unmatched_renpy_events"] for row in rows if "unmatched_renpy_events" in row), [])
    status_counts: dict[str, int] = {}
    for row in audit_rows:
        status_counts[row["status"]] = status_counts.get(row["status"], 0) + 1

    lines = [
        "# Event Parity Audit Inventory",
        "",
        "This is a generated checklist. `metadata_match` means only registration metadata matched; prose, menu text, and branch effects still require source-backed review.",
        "",
        "## Summary",
        "",
        f"- Ren'Py events parsed: {report['summary']['renpy_event_count']}",
        f"- Inform situations parsed: {report['summary']['inform_event_count']}",
        f"- Ren'Py event parse failures: {report['summary']['renpy_parse_failure_count']}",
        f"- Missing registered scene labels: {report['summary']['missing_scene_ref_count']}",
        f"- Missing jump/call labels in event files: {report['summary']['missing_event_jump_ref_count']}",
        f"- Missing jump/call labels globally: {report['summary']['missing_jump_ref_count']}",
        f"- Unmatched Ren'Py events: {len(unmatched)}",
    ]
    for status, count in sorted(status_counts.items()):
        lines.append(f"- {status}: {count}")

    lines.extend(
        [
            "",
            "## Inform Situation Rows",
            "",
            "| Status | Inform event | Source | Ren'Py match | Inform menus | Ren'Py menus | Mismatches |",
            "|---|---|---|---|---|---|---|",
        ]
    )
    for row in audit_rows:
        inf = row["inform"]
        matches = row["renpy_matches"]
        first = matches[0] if matches else None
        match_text = "" if first is None else f"{first['id']} ({first['path']}:{first['line']})"
        inform_menu_text = "<br>".join(inf.get("menu_options", []))
        renpy_menu_text = ""
        if first:
            renpy_options = first.get("menu_options", [])
            if renpy_options:
                renpy_menu_text = "<br>".join(renpy_options)
            else:
                renpy_menu_text = str(first.get("menu_count", 0))
        mismatch_text = "; ".join(row["mismatches"])
        lines.append(
            f"| {row['status']} | {inf['display_name']} | {inf['path']}:{inf['line']} | {match_text} | {inform_menu_text} | {renpy_menu_text} | {mismatch_text} |"
        )

    lines.extend(["", "## Unmatched Ren'Py Events", "", "| Event | File | Trigger | Scenes |", "|---|---|---|---|"])
    for event in unmatched:
        lines.append(
            f"| {event['id']} | {event['path']}:{event['line']} | {event.get('trigger')} | {', '.join(event.get('scenes', []))} |"
        )

    path.write_text("\n".join(lines) + "\n", encoding="utf-8")


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("--repo-root", type=Path, default=Path.cwd())
    parser.add_argument("--inform-root", type=Path, default=Path(r"C:\Users\dmssi\Documents\GitHub\Flexible-Survival"))
    parser.add_argument("--json-out", type=Path, default=Path("docs/event_parity_audit.json"))
    parser.add_argument("--md-out", type=Path, default=Path("docs/event_parity_audit.md"))
    args = parser.parse_args()

    repo_root = args.repo_root.resolve()
    inform_root = args.inform_root.resolve()
    renpy_events, renpy_checks = scan_renpy(repo_root)
    inform_events = scan_inform(inform_root)
    rows = match_events(renpy_events, inform_events)

    report = {
        "summary": {
            "renpy_event_count": len(renpy_events),
            "inform_event_count": len(inform_events),
            "renpy_parse_failure_count": len(renpy_checks["parse_failures"]),
            "missing_scene_ref_count": len(renpy_checks["missing_scene_refs"]),
            "missing_event_jump_ref_count": len(renpy_checks["missing_event_jump_refs"]),
            "missing_jump_ref_count": len(renpy_checks["missing_jump_refs"]),
        },
        "renpy_checks": renpy_checks,
        "event_rows": rows,
    }

    json_out = (repo_root / args.json_out).resolve()
    md_out = (repo_root / args.md_out).resolve()
    json_out.parent.mkdir(parents=True, exist_ok=True)
    md_out.parent.mkdir(parents=True, exist_ok=True)
    json_out.write_text(json.dumps(report, indent=2, sort_keys=True), encoding="utf-8")
    write_markdown(md_out, report)
    print(f"Wrote {json_out}")
    print(f"Wrote {md_out}")
    print(json.dumps(report["summary"], indent=2, sort_keys=True))
    return 0


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