#!/usr/bin/env python3
"""Reclassify scene/event point-table coordinates by possible runtime role.

The event transition extractor exposes compact coordinate pairs, but those pairs
must not be promoted directly to map-transition triggers.  This review applies
the confirmed field actor bottom 3x1 footprint rule and separates source trigger
fit, target spawn fit, object/camera point candidates, and manual review metadata.
"""
from __future__ import annotations

import argparse
import html
import json
import sys
from collections import Counter, defaultdict
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))

from summarize_map_tiles import load_maps  # noqa: E402
from tile_classes import load as load_tile_classes  # noqa: E402


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

FOOTPRINT_RULE = "actor bottom 3x1 footprint: center tile plus left/right foot tiles on the same row"
PROMOTION_STATUS = "coordinate-role-reclassified-route-trigger-blocked"


def load_json(path: Path, default):
    if not path.exists():
        return default
    return json.loads(path.read_text(encoding="utf-8"))


def tile_key(info: dict) -> str:
    return (info.get("layerTilesets") or [info.get("tileset") or ""])[0]


def tile_at(info: dict, x: int, y: int) -> tuple[int | None, int | None]:
    if x < 0 or y < 0 or x >= info["width"] or y >= info["height"]:
        return None, None
    index = y * info["width"] + x
    return info["layers"][0][index], info["layers"][1][index]


def tile_passable(info: dict, x: int, y: int, tile_classes: dict[str, dict]) -> bool:
    layer0, layer1 = tile_at(info, x, y)
    if layer0 is None or layer1 is None:
        return False
    entry = tile_classes.get(tile_key(info), {})
    if [layer0, layer1] in entry.get("passPairs", []):
        return True
    if [layer0, layer1] in entry.get("blockPairs", []):
        return False
    if layer0 in entry.get("pass", []):
        return True
    if layer0 in entry.get("block", []):
        return False
    terrain_blocked = set(info.get("terrainBlockedTiles") or [])
    return layer0 not in terrain_blocked and layer1 == 0


def footprint_cells(x: int, y: int) -> list[dict]:
    return [
        {"x": x - 1, "y": y, "role": "left-foot"},
        {"x": x, "y": y, "role": "center-foot"},
        {"x": x + 1, "y": y, "role": "right-foot"},
    ]


def footprint_eval(info: dict | None, x: int, y: int, tile_classes: dict[str, dict]) -> dict:
    if not info:
        return {"inBounds": False, "allPassable": False, "cells": [], "reason": "map metadata missing"}
    cells = []
    in_bounds = True
    all_passable = True
    for cell in footprint_cells(x, y):
        layer0, layer1 = tile_at(info, cell["x"], cell["y"])
        passable = tile_passable(info, cell["x"], cell["y"], tile_classes)
        in_bounds = in_bounds and layer0 is not None
        all_passable = all_passable and passable
        cells.append({**cell, "layer0": layer0, "layer1": layer1, "passable": passable})
    return {
        "inBounds": in_bounds,
        "allPassable": all_passable and in_bounds,
        "cells": cells,
        "reason": "all three foot tiles are passable" if all_passable and in_bounds else "one or more foot tiles are blocked/out-of-bounds",
    }


def candidate_rect(candidate: dict) -> dict | None:
    span = candidate.get("span") or {}
    axis = span.get("axis")
    if axis == "x":
        return {
            "x0": int(span.get("from", 0)),
            "x1": int(span.get("to", 0)),
            "y0": int(span.get("perpendicularFrom", 0)),
            "y1": int(span.get("perpendicularTo", 0)),
        }
    if axis == "y":
        return {
            "x0": int(span.get("perpendicularFrom", 0)),
            "x1": int(span.get("perpendicularTo", 0)),
            "y0": int(span.get("from", 0)),
            "y1": int(span.get("to", 0)),
        }
    sample = candidate.get("sample") or {}
    if isinstance(sample.get("x"), int) and isinstance(sample.get("y"), int):
        return {"x0": sample["x"], "x1": sample["x"], "y0": sample["y"], "y1": sample["y"]}
    return None


def cell_in_rect(cell: dict, rect: dict) -> bool:
    return rect["x0"] <= cell["x"] <= rect["x1"] and rect["y0"] <= cell["y"] <= rect["y1"]


def exit_overlaps(exit_candidates_by_map: dict[str, list[dict]], x: int, y: int) -> list[dict]:
    overlaps = []
    for candidate in exit_candidates_by_map.get("__current__", []):
        rect = candidate_rect(candidate)
        if not rect:
            continue
        matched_cells = [cell for cell in footprint_cells(x, y) if cell_in_rect(cell, rect)]
        if not matched_cells:
            continue
        overlaps.append(
            {
                "side": candidate.get("side"),
                "edgeDistance": candidate.get("edgeDistance"),
                "span": candidate.get("span"),
                "sample": candidate.get("sample"),
                "matchedFootCells": matched_cells,
                "promotionStatus": candidate.get("promotionStatus"),
            }
        )
    return overlaps


def build_exit_candidate_index(rows: list[dict]) -> dict[str, list[dict]]:
    return {row["map"]: row.get("exitCandidates") or [] for row in rows}


def review_key(source: str, record: str, x: int, y: int, target: str) -> str:
    return f"{source}@{record}:{x},{y}->{target}"


def reviews_for_point(reviews: dict[str, dict], source: str, record: str, x: int, y: int, targets: list[str]) -> list[dict]:
    rows = []
    for target in targets:
        row = reviews.get(review_key(source, record, x, y, target))
        if not row:
            continue
        rows.append(
            {
                "target": target,
                "state": row.get("state"),
                "spawnX": row.get("spawnX"),
                "spawnY": row.get("spawnY"),
                "source": "manual-transition-review-ledger",
            }
        )
    return rows


def target_spawn_fits(maps: dict, targets: list[str], x: int, y: int, tile_classes: dict[str, dict]) -> list[dict]:
    fits = []
    for target in targets:
        target_info = maps.get(target)
        ev = footprint_eval(target_info, x, y, tile_classes)
        fits.append(
            {
                "target": target,
                "inBounds": ev["inBounds"],
                "allPassable3x1": ev["allPassable"],
                "reason": ev["reason"],
            }
        )
    return fits


def point_signature(row: dict) -> str:
    return ";".join(f"{p.get('x')},{p.get('y')}" for p in row.get("points") or [])


def decide_role(source_foot: dict, overlaps: list[dict], target_fits: list[dict], reviews: list[dict], signature_count: int) -> tuple[str, list[str]]:
    reasons = []
    if overlaps and source_foot["allPassable"]:
        reasons.append("3x1 source footprint overlaps a geometry exit span")
        if signature_count > 1:
            reasons.append("same point-table signature is reused by multiple records, so this is still not route proof")
        return "source-trigger-candidate-unproven", reasons
    if overlaps:
        reasons.append("footprint touches a geometry exit span, but 3x1 passability is not satisfied")
        return "source-trigger-geometry-rejected", reasons
    if any(review.get("spawnX") is not None and review.get("spawnY") is not None for review in reviews):
        reasons.append("manual review rows contain spawnX/spawnY metadata, but no EXE spawn writer is decoded")
        return "manual-target-spawn-review", reasons
    if any(fit["allPassable3x1"] for fit in target_fits):
        reasons.append("same coordinate can stand on a target map with 3x1 footprint")
        reasons.append("this is a spawn candidate only; no source->target spawn writer is decoded")
        return "target-spawn-candidate-unproven", reasons
    if source_foot["inBounds"]:
        reasons.append("coordinate is in the source map but does not fit any source exit span")
        if signature_count > 1:
            reasons.append("same point-table signature is reused across records, matching object/camera/scene point-table behavior")
        return "object-or-camera-point-candidate", reasons
    reasons.append("coordinate does not fit the source map bounds")
    return "out-of-source-map-point", reasons


def summarize_point_sets(records: list[dict]) -> dict:
    sig_counts = Counter(point_signature(row) for row in records)
    repeated = {sig: count for sig, count in sig_counts.items() if sig and count > 1}
    return {
        "uniquePointTableSignatures": len(sig_counts),
        "reusedPointTableSignatures": len(repeated),
        "mostCommonPointTableSignatures": [
            {"count": count, "signature": sig}
            for sig, count in sig_counts.most_common(8)
        ],
    }


def classify_records(transitions: list[dict], maps: dict, reviews: dict[str, dict], exits: dict[str, list[dict]], tile_classes: dict[str, dict]) -> list[dict]:
    sig_counts = Counter(point_signature(row) for row in transitions)
    result = []
    for row in transitions:
        source = row.get("map")
        source_info = maps.get(source)
        record_hex = row.get("recordVaHex") or hex(row.get("recordVa") or 0)
        targets = row.get("targets") or []
        local_exits = {"__current__": exits.get(source, [])}
        classified_points = []
        role_counts = Counter()
        trigger_candidate_count = 0
        for point in row.get("points") or []:
            x = point.get("x")
            y = point.get("y")
            if not isinstance(x, int) or not isinstance(y, int):
                continue
            source_foot = footprint_eval(source_info, x, y, tile_classes)
            overlaps = exit_overlaps(local_exits, x, y)
            point_reviews = reviews_for_point(reviews, source, record_hex, x, y, targets)
            spawn_fits = target_spawn_fits(maps, targets, x, y, tile_classes)
            role, reasons = decide_role(
                source_foot,
                overlaps,
                spawn_fits,
                point_reviews,
                sig_counts[point_signature(row)],
            )
            role_counts[role] += 1
            trigger_candidate_count += int(role.startswith("source-trigger"))
            classified_points.append(
                {
                    "x": x,
                    "y": y,
                    "activePoint": {"x": x, "y": y} in (row.get("activePoints") or []),
                    "role": role,
                    "roleReasons": reasons,
                    "sourceFootprint": source_foot,
                    "sourceExitOverlaps": overlaps,
                    "targetSpawnFits": spawn_fits,
                    "manualReviewRows": point_reviews,
                }
            )
        row_result = {
            "source": source,
            "sceneIdHex": row.get("sceneIdHex"),
            "recordVaHex": record_hex,
            "eventKind": row.get("eventKind"),
            "targets": targets,
            "pointTableSignature": point_signature(row),
            "pointTableSignatureReuseCount": sig_counts[point_signature(row)],
            "pointCount": len(row.get("points") or []),
            "activePoints": row.get("activePoints") or [],
            "roleCounts": dict(sorted(role_counts.items())),
            "sourceTriggerCandidateCount": trigger_candidate_count,
            "routeTriggerProofFound": False,
            "promotionStatus": PROMOTION_STATUS,
            "classification": (
                "source-trigger-candidate-needs-consumer-proof"
                if trigger_candidate_count
                else "no-source-trigger-fit-under-3x1-rule"
            ),
            "points": classified_points,
        }
        result.append(row_result)
    return result


def chip(text: str, cls: str = "") -> str:
    return f'<span class="chip {html.escape(cls)}">{html.escape(text)}</span>'


def point_row_html(record: dict, point: dict) -> str:
    reasons = "<br>".join(html.escape(reason) for reason in point.get("roleReasons") or [])
    cells = ", ".join(
        f"{cell['role']}=({cell['x']},{cell['y']}) {'pass' if cell['passable'] else 'block'}"
        for cell in point["sourceFootprint"]["cells"]
    )
    exits = point.get("sourceExitOverlaps") or []
    exit_text = "<br>".join(
        f"{html.escape(str(item.get('side')))} {html.escape(json.dumps(item.get('span'), ensure_ascii=False))}"
        for item in exits
    ) or "-"
    spawn = "<br>".join(
        f"{html.escape(fit['target'])}: {'3x1 pass' if fit['allPassable3x1'] else 'no'}"
        for fit in point.get("targetSpawnFits") or []
    ) or "-"
    manual = "<br>".join(
        f"{html.escape(row['target'])}: {html.escape(str(row.get('state')))} spawn={row.get('spawnX')},{row.get('spawnY')}"
        for row in point.get("manualReviewRows") or []
    ) or "-"
    role = point["role"]
    role_cls = "bad" if role in {"object-or-camera-point-candidate", "out-of-source-map-point"} else "warn"
    if role.startswith("source-trigger"):
        role_cls = "warn"
    search_terms = " ".join(
        str(part)
        for part in [
            record.get("recordVaHex", ""),
            record.get("source", ""),
            " ".join(record.get("targets") or []),
            record.get("eventKind", ""),
            role,
            point.get("x", ""),
            point.get("y", ""),
            " ".join(point.get("roleReasons") or []),
        ]
    )
    return "\n".join(
        [
            f'<tr data-point-row data-search="{html.escape(search_terms)}">',
            f"<td><code>{html.escape(record['recordVaHex'])}</code><br><small>{html.escape(record['source'])}</small></td>",
            f"<td>{point['x']},{point['y']}<br><small>{'active' if point.get('activePoint') else ''}</small></td>",
            f"<td>{chip(role, role_cls)}<br><small>{reasons}</small></td>",
            f"<td><small>{html.escape(cells)}</small><br>{chip('3x1 pass' if point['sourceFootprint']['allPassable'] else '3x1 blocked', 'good' if point['sourceFootprint']['allPassable'] else 'bad')}</td>",
            f"<td><small>{exit_text}</small></td>",
            f"<td><small>{spawn}</small></td>",
            f"<td><small>{manual}</small></td>",
            f"<td><small>{record['pointTableSignatureReuseCount']}x reused</small></td>",
            "</tr>",
        ]
    )


def write_html(path: Path, records: list[dict], summary: dict) -> None:
    point_rows = []
    for record in records:
        for point in record.get("points") or []:
            point_rows.append(point_row_html(record, point))
    body = "\n".join(point_rows)
    record_rows = "\n".join(
        f"<tr><td><code>{html.escape(record['recordVaHex'])}</code></td><td>{html.escape(record['source'])}</td>"
        f"<td>{html.escape(', '.join(record['targets']) or '-')}</td><td>{record['pointTableSignatureReuseCount']}</td>"
        f"<td>{html.escape(', '.join(f'{k}={v}' for k, v in record['roleCounts'].items()) or '-')}</td>"
        f"<td>{chip(record['classification'], 'warn')}</td></tr>"
        for record in records
    )
    path.write_text(
        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>환세취호전 scene/event coordinate semantics</title>
  <style>
    :root {{ color-scheme: dark; --bg:#0b0d0f; --panel:#15191d; --line:#313940; --text:#eff3f5; --muted:#aeb8bf; --green:#72d19b; --yellow:#f3ce62; --red:#f1797d; --blue:#6cb8ff; font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; }}
    * {{ box-sizing:border-box; }}
    body {{ margin:0; background:var(--bg); color:var(--text); }}
    a {{ color:var(--blue); text-decoration:none; }}
    header {{ position:sticky; top:0; z-index:5; display:grid; gap:10px; padding:12px 16px; border-bottom:1px solid var(--line); background:rgba(11,13,15,.96); }}
    .top {{ display:flex; align-items:center; gap:12px; }}
    h1 {{ margin:0; font-size:18px; }}
    nav {{ margin-left:auto; display:flex; gap:8px; flex-wrap:wrap; }}
    nav a, .chip {{ display:inline-flex; align-items:center; min-height:28px; padding:0 9px; border:1px solid var(--line); border-radius:4px; background:#101316; color:var(--text); font-size:12px; }}
    input {{ width:100%; height:36px; padding:0 10px; border:1px solid var(--line); border-radius:4px; background:#101316; color:var(--text); }}
    main {{ padding:16px; display:grid; gap:14px; }}
    .metrics {{ display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:10px; }}
    .metric, section {{ border:1px solid var(--line); border-radius:6px; background:var(--panel); }}
    .metric {{ padding:10px; }}
    .metric span, small, .note {{ color:var(--muted); }}
    .metric b {{ display:block; margin-top:4px; font:20px ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }}
    .note {{ padding:12px; line-height:1.5; }}
    section {{ overflow:auto; }}
    table {{ width:100%; min-width:1120px; border-collapse:collapse; }}
    th,td {{ padding:8px 10px; border-bottom:1px solid var(--line); vertical-align:top; font-size:13px; line-height:1.35; }}
    th {{ position:sticky; top:98px; background:#101316; text-align:left; color:var(--muted); }}
    code {{ color:#d5e8ff; }}
    .chip.good {{ border-color:#2e6f4b; color:var(--green); }}
    .chip.warn {{ border-color:#856a1f; color:var(--yellow); }}
    .chip.bad {{ border-color:#7a373a; color:var(--red); }}
    tr[hidden] {{ display:none; }}
    @media (max-width:920px) {{ .top,.metrics {{ display:block; }} nav {{ margin:10px 0 0; }} }}
  </style>
</head>
<body>
  <header>
    <div class="top">
      <h1>Scene/Event Coordinate Semantics</h1>
      <nav>
        <a href="index.html">관리 홈</a>
        <a href="scene_event_vm_review.html">Scene/Event VM</a>
        <a href="../out/event_record_structure_review.html">event record 구조</a>
        <a href="../out/scene_event_coordinate_semantics_review.json">JSON</a>
      </nav>
    </div>
    <input id="search" type="search" placeholder="검색: map1_02b, 0x00503350, object-or-camera, manual-target-spawn, 11,12">
  </header>
  <main>
    <div class="metrics">
      <div class="metric"><span>records</span><b>{summary['recordCount']}</b></div>
      <div class="metric"><span>points</span><b>{summary['pointRowCount']}</b></div>
      <div class="metric"><span>source trigger proof</span><b>{str(summary['sourceTriggerProofFound']).lower()}</b></div>
      <div class="metric"><span>object/camera</span><b>{summary['objectOrCameraCandidatePointCount']}</b></div>
      <div class="metric"><span>manual spawn review</span><b>{summary['manualSpawnReviewPointCount']}</b></div>
    </div>
    <section><p class="note">{html.escape(FOOTPRINT_RULE)}. 좌표가 source trigger가 되려면 3x1 발판이 source exit span과 맞아야 하며, 별도의 디코딩된 trigger consumer가 필요하다. 이 리뷰는 manual confirmed 좌표도 trigger로 자동 승격하지 않는다.</p></section>
    <section>
      <table>
        <thead><tr><th>record</th><th>source</th><th>targets</th><th>signature reuse</th><th>roles</th><th>classification</th></tr></thead>
        <tbody>{record_rows}</tbody>
      </table>
    </section>
    <section>
      <table>
        <thead><tr><th>record/source</th><th>point</th><th>role</th><th>source 3x1</th><th>source exit span</th><th>target spawn fit</th><th>manual review</th><th>reuse</th></tr></thead>
        <tbody id="rows">{body}</tbody>
      </table>
    </section>
  </main>
  <script>
    const q = document.getElementById("search");
    const rows = [...document.querySelectorAll("[data-point-row]")];
    q.addEventListener("input", () => {{
      const needle = q.value.trim().toLowerCase();
      for (const row of rows) row.hidden = needle && !row.dataset.search.toLowerCase().includes(needle);
    }});
    window.HWANSE_SCENE_EVENT_COORDINATE_SEMANTICS_READY = {{
      coordinateSemanticsReviewImplemented: true,
      promotionStatus: "{PROMOTION_STATUS}",
      footprintRule: "bottom-3x1",
      sourceTriggerProofFound: {str(summary['sourceTriggerProofFound']).lower()},
      pointRowCount: {summary['pointRowCount']},
      objectOrCameraCandidatePointCount: {summary['objectOrCameraCandidatePointCount']},
      manualSpawnReviewPointCount: {summary['manualSpawnReviewPointCount']},
      reusedPointTableSignatures: {summary['reusedPointTableSignatures']}
    }};
  </script>
</body>
</html>
""",
        encoding="utf-8",
    )


def summary_for(records: list[dict], point_set_summary: dict) -> dict:
    point_rows = [point for record in records for point in record.get("points") or []]
    role_counts = Counter(point["role"] for point in point_rows)
    return {
        "promotionStatus": PROMOTION_STATUS,
        "recordCount": len(records),
        "pointRowCount": len(point_rows),
        "sourceTriggerProofFound": False,
        "sourceTriggerCandidatePointCount": sum(count for role, count in role_counts.items() if role.startswith("source-trigger")),
        "objectOrCameraCandidatePointCount": role_counts.get("object-or-camera-point-candidate", 0),
        "manualSpawnReviewPointCount": role_counts.get("manual-target-spawn-review", 0),
        "targetSpawnCandidatePointCount": role_counts.get("target-spawn-candidate-unproven", 0),
        "outOfSourceMapPointCount": role_counts.get("out-of-source-map-point", 0),
        "roleCounts": dict(sorted(role_counts.items())),
        **point_set_summary,
    }


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--transitions", type=Path, default=OUT / "event_transitions.json")
    parser.add_argument("--reviews", type=Path, default=DATA / "transition_reviews.json")
    parser.add_argument("--maps", type=Path, default=OUT / "maps.js")
    parser.add_argument("--tile-classes", type=Path, default=DATA / "tile_classes.json")
    parser.add_argument("--exit-candidates", type=Path, default=OUT / "map_exit_candidates.json")
    parser.add_argument("--out", type=Path, default=OUT)
    parser.add_argument("--web", type=Path, default=WEB)
    parser.add_argument(
        "--out-html",
        type=Path,
        default=None,
        help="Optional extra HTML output path. The tracked active page is written under web/ by default.",
    )
    args = parser.parse_args()

    transitions = load_json(args.transitions, [])
    reviews = load_json(args.reviews, {})
    maps = load_maps(args.maps)
    tile_classes = load_tile_classes(args.tile_classes)
    exit_rows = load_json(args.exit_candidates, [])
    exits = build_exit_candidate_index(exit_rows)

    records = classify_records(transitions, maps, reviews, exits, tile_classes)
    records.sort(key=lambda row: (row.get("source") or "", row.get("recordVaHex") or ""))
    point_set_summary = summarize_point_sets(transitions)
    summary = summary_for(records, point_set_summary)
    result = {
        "sourceFiles": {
            "eventTransitions": str(args.transitions),
            "transitionReviews": str(args.reviews),
            "maps": str(args.maps),
            "tileClasses": str(args.tile_classes),
            "exitCandidates": str(args.exit_candidates),
        },
        "promotionStatus": PROMOTION_STATUS,
        "footprintRule": FOOTPRINT_RULE,
        "summary": summary,
        "records": records,
    }

    args.out.mkdir(parents=True, exist_ok=True)
    args.web.mkdir(parents=True, exist_ok=True)
    (args.out / "scene_event_coordinate_semantics_review.json").write_text(
        json.dumps(result, ensure_ascii=False, indent=2) + "\n",
        encoding="utf-8",
    )
    if args.out_html:
        write_html(args.out_html, records, summary)
    write_html(args.web / "scene_event_coordinate_semantics_review.html", records, summary)
    print(
        "wrote scene/event coordinate semantics review: "
        f"{summary['recordCount']} records, {summary['pointRowCount']} points, "
        f"{summary['objectOrCameraCandidatePointCount']} object/camera candidates"
    )


if __name__ == "__main__":
    main()
