#!/usr/bin/env python3
"""Review collision-flag-derived field exit candidates with standalone EXE scans."""
from __future__ import annotations

import html
import json
import struct
import sys
from pathlib import Path

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

from probe_exe_scene_tables import offset_to_va, read_sections, va_to_offset  # noqa: E402


ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "out"
BITS = {"up": 0x01, "down": 0x02, "left": 0x04, "right": 0x08}
SCAN_SECTIONS = {".text", ".rdata", ".data"}
TOP_EXIT_HEAD_TO_FOOT_TILE_OFFSET = 3


def ignored_field_exit_map_reason(map_name: str) -> str | None:
    if map_name.startswith("map0_") and map_name.endswith("n"):
        return "event-map-map0-n"
    if map_name.startswith("map2_") and map_name.endswith("j"):
        try:
            ordinal = int(map_name.split("_", 1)[1][:2])
        except ValueError:
            return None
        if 20 <= ordinal <= 39:
            return "event-cleaning-map-map2-20j-through-39j"
    return None


def load_maps() -> dict:
    text = (OUT / "maps.js").read_text(encoding="utf-8")
    return json.loads(text.split("=", 1)[1].strip().rstrip(";"))


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


def flag_at(map_info: dict, x: int, y: int) -> int:
    if x < 0 or y < 0 or x >= map_info["width"] or y >= map_info["height"]:
        return 0
    return map_info["layers"][1][y * map_info["width"] + x] or 0


def actor_trigger_sample(map_info: dict, sample: dict, direction: str) -> dict:
    if direction == "up":
        return {
            "x": sample["x"],
            "y": min(map_info["height"] - 1, sample["y"] + TOP_EXIT_HEAD_TO_FOOT_TILE_OFFSET),
            "basis": "actor-foot-when-head-enters-top-boundary",
        }
    if direction == "down":
        return {**sample, "basis": "actor-foot-boundary"}
    return {**sample, "basis": "actor-side-boundary"}


def horizontal_runs(map_info: dict, y: int, direction: str) -> list[dict]:
    outward = BITS["up"] if direction == "up" else BITS["down"]
    runs = []
    x = 0
    while x < map_info["width"]:
        start_flag = flag_at(map_info, x, y)
        if (start_flag & BITS["left"]) and not (start_flag & outward):
            end = x
            while end < map_info["width"]:
                end_flag = flag_at(map_info, end, y)
                if (end_flag & BITS["right"]) and not (end_flag & outward):
                    break
                end += 1
            if end < map_info["width"]:
                clear = all(not (flag_at(map_info, cx, y) & outward) for cx in range(x, end + 1))
                width = end - x + 1
                if clear and width >= 3:
                    sample = {"x": (x + end) // 2, "y": y}
                    runs.append(
                        {
                            "direction": direction,
                            "axis": "h",
                            "x0": x,
                            "y0": y,
                            "x1": end,
                            "y1": y,
                            "length": width,
                            "sample": sample,
                            "triggerAnchor": "actor-head" if direction == "up" else "actor-foot",
                            "actorTriggerSample": actor_trigger_sample(map_info, sample, direction),
                            "tiles": [{"x": cx, "y": y} for cx in range(x, end + 1)],
                        }
                    )
                    x = end + 1
                    continue
        x += 1
    return runs


def vertical_runs(map_info: dict, x: int, direction: str) -> list[dict]:
    outward = BITS["left"] if direction == "left" else BITS["right"]
    runs = []
    y = 0
    while y < map_info["height"]:
        start_flag = flag_at(map_info, x, y)
        if (start_flag & BITS["up"]) and not (start_flag & outward):
            end = y
            while end < map_info["height"]:
                end_flag = flag_at(map_info, x, end)
                if (end_flag & BITS["down"]) and not (end_flag & outward):
                    break
                end += 1
            if end < map_info["height"]:
                clear = all(not (flag_at(map_info, x, cy) & outward) for cy in range(y, end + 1))
                height = end - y + 1
                if clear and height >= 1:
                    sample = {"x": x, "y": (y + end) // 2}
                    runs.append(
                        {
                            "direction": direction,
                            "axis": "v",
                            "x0": x,
                            "y0": y,
                            "x1": x,
                            "y1": end,
                            "length": height,
                            "sample": sample,
                            "triggerAnchor": "actor-side",
                            "actorTriggerSample": actor_trigger_sample(map_info, sample, direction),
                            "tiles": [{"x": x, "y": cy} for cy in range(y, end + 1)],
                        }
                    )
                    y = end + 1
                    continue
        y += 1
    return runs


def field_exit_runs(map_info: dict) -> list[dict]:
    return [
        *horizontal_runs(map_info, 0, "up"),
        *horizontal_runs(map_info, map_info["height"] - 1, "down"),
        *vertical_runs(map_info, 0, "left"),
        *vertical_runs(map_info, map_info["width"] - 1, "right"),
    ]


def section_for_offset(sections: list[dict], offset: int) -> dict | None:
    for section in sections:
        start = section["raw"]
        end = section["raw"] + section["raw_size"]
        if start <= offset < end:
            return section
    return None


def packed_hits(data: bytes, sections: list[dict], x: int, y: int, *, center_va: int | None = None, radius: int = 0x800) -> dict:
    if center_va is not None:
        center_offset = va_to_offset(sections, center_va)
        if center_offset is None:
            return {"total": 0, "samples": []}
        start = max(0, center_offset - radius)
        end = min(len(data), center_offset + radius)
    else:
        start = 0
        end = len(data)
    forms = [
        ("xy16", struct.pack("<HH", x, y)),
        ("yx16", struct.pack("<HH", y, x)),
    ]
    if 0 <= x < 256 and 0 <= y < 256:
        forms.extend(
            [
                ("xy8", bytes([x, y])),
                ("yx8", bytes([y, x])),
            ]
        )
    total = 0
    samples = []
    kind_counts: dict[str, int] = {}
    for kind, pattern in forms:
        position = start
        while True:
            hit = data.find(pattern, position, end)
            if hit < 0:
                break
            position = hit + 1
            section = section_for_offset(sections, hit)
            if section is None or section["name"] not in SCAN_SECTIONS:
                continue
            total += 1
            kind_counts[kind] = kind_counts.get(kind, 0) + 1
            if len(samples) < 8:
                va = offset_to_va(sections, hit)
                samples.append(
                    {
                        "kind": kind,
                        "section": section["name"],
                        "va": va,
                        "vaHex": f"0x{va:08x}" if va is not None else None,
                        "deltaFromCenter": hit - center_offset if center_va is not None and center_offset is not None else None,
                    }
                )
    return {"total": total, "kindCounts": kind_counts, "samples": samples}


def full_run_pattern_hits(data: bytes, sections: list[dict], run: dict) -> dict:
    x0, y0, x1, y1 = run["x0"], run["y0"], run["x1"], run["y1"]
    forms = [
        ("u16-x0y0x1y1", struct.pack("<HHHH", x0, y0, x1, y1)),
        ("u16-y0x0y1x1", struct.pack("<HHHH", y0, x0, y1, x1)),
    ]
    if max(x0, y0, x1, y1) < 256:
        forms.extend(
            [
                ("u8-x0y0x1y1", bytes([x0, y0, x1, y1])),
                ("u8-y0x0y1x1", bytes([y0, x0, y1, x1])),
            ]
        )
    total = 0
    samples = []
    kind_counts: dict[str, int] = {}
    for kind, pattern in forms:
        position = 0
        while True:
            hit = data.find(pattern, position)
            if hit < 0:
                break
            position = hit + 1
            section = section_for_offset(sections, hit)
            if section is None or section["name"] not in SCAN_SECTIONS:
                continue
            total += 1
            kind_counts[kind] = kind_counts.get(kind, 0) + 1
            if len(samples) < 8:
                va = offset_to_va(sections, hit)
                samples.append({"kind": kind, "section": section["name"], "vaHex": f"0x{va:08x}" if va is not None else None})
    return {"total": total, "kindCounts": kind_counts, "samples": samples}


def sum_kind_counts(rows: list[dict], kinds: set[str]) -> int:
    total = 0
    for row in rows:
        counts = row.get("kindCounts") or {}
        total += sum(count for kind, count in counts.items() if kind in kinds)
    return total


def full_pattern_kind_total(row: dict, prefix: str) -> int:
    counts = row.get("kindCounts") or {}
    return sum(count for kind, count in counts.items() if kind.startswith(prefix))


def pixel_point_variants(point: dict) -> list[tuple[str, int, int]]:
    x = point["x"]
    y = point["y"]
    return [
        ("pixel-top-left", x * 16, y * 16),
        ("pixel-center", x * 16 + 8, y * 16 + 8),
    ]


def pixel_packed_hits_for_point(data: bytes, sections: list[dict], point: dict, *, center_va: int | None) -> list[dict]:
    rows = []
    for kind, x, y in pixel_point_variants(point):
        rows.append(
            {
                "pixelKind": kind,
                "point": point,
                **packed_hits(data, sections, x, y, center_va=center_va),
            }
        )
    return rows


def build_review() -> dict:
    maps = load_maps()
    scene_manifest = load_json(OUT / "scene_manifest.json", [])
    exe = (ROOT / "Hwanse2.exe").read_bytes()
    sections = read_sections(exe)
    scenes_by_map: dict[str, list[dict]] = {}
    for row in scene_manifest:
        scenes_by_map.setdefault(row["map"], []).append(row)

    rows = []
    totals = {
        "mapCount": len(maps),
        "mapWithCandidateCount": 0,
        "candidateRunCount": 0,
        "candidateTileCount": 0,
        "ignoredMapCount": 0,
        "ignoredMaps": [],
        "sceneWindowCoordinateHitRunCount": 0,
        "sceneWindowPixelCoordinateHitRunCount": 0,
        "fullRunPatternHitRunCount": 0,
        "strictU16DiagnosticRunCount": 0,
        "weakU8OnlyDiagnosticRunCount": 0,
    }
    for map_name, map_info in sorted(maps.items()):
        ignored_reason = ignored_field_exit_map_reason(map_name)
        if ignored_reason:
            totals["ignoredMapCount"] += 1
            totals["ignoredMaps"].append({"map": map_name, "reason": ignored_reason})
            continue
        runs = field_exit_runs(map_info)
        if not runs:
            continue
        totals["mapWithCandidateCount"] += 1
        scenes = scenes_by_map.get(map_name) or []
        reviewed_runs = []
        for run in runs:
            sample = run["sample"]
            sample_hits = []
            endpoint_hits = []
            pixel_sample_hits = []
            pixel_endpoint_hits = []
            actor_sample = run["actorTriggerSample"]
            actor_hits = []
            pixel_actor_hits = []
            for scene in scenes[:4]:
                record_va = scene.get("recordVa")
                if not isinstance(record_va, int):
                    continue
                sample_hits.append(
                    {
                        "sceneRecordVaHex": scene.get("recordVaHex"),
                        **packed_hits(exe, sections, sample["x"], sample["y"], center_va=record_va),
                    }
                )
                for pixel_hit in pixel_packed_hits_for_point(exe, sections, sample, center_va=record_va):
                    pixel_sample_hits.append({"sceneRecordVaHex": scene.get("recordVaHex"), **pixel_hit})
                actor_hits.append(
                    {
                        "sceneRecordVaHex": scene.get("recordVaHex"),
                        "point": actor_sample,
                        **packed_hits(exe, sections, actor_sample["x"], actor_sample["y"], center_va=record_va),
                    }
                )
                for pixel_hit in pixel_packed_hits_for_point(exe, sections, actor_sample, center_va=record_va):
                    pixel_actor_hits.append({"sceneRecordVaHex": scene.get("recordVaHex"), **pixel_hit})
                for point in ({"x": run["x0"], "y": run["y0"]}, {"x": run["x1"], "y": run["y1"]}):
                    endpoint_hits.append(
                        {
                            "sceneRecordVaHex": scene.get("recordVaHex"),
                            "point": point,
                            **packed_hits(exe, sections, point["x"], point["y"], center_va=record_va),
                        }
                    )
                    for pixel_hit in pixel_packed_hits_for_point(exe, sections, point, center_va=record_va):
                        pixel_endpoint_hits.append({"sceneRecordVaHex": scene.get("recordVaHex"), **pixel_hit})
            sample_total = sum(item["total"] for item in sample_hits)
            endpoint_total = sum(item["total"] for item in endpoint_hits)
            actor_total = sum(item["total"] for item in actor_hits)
            pixel_sample_total = sum(item["total"] for item in pixel_sample_hits)
            pixel_endpoint_total = sum(item["total"] for item in pixel_endpoint_hits)
            pixel_actor_total = sum(item["total"] for item in pixel_actor_hits)
            full_pattern = full_run_pattern_hits(exe, sections, run)
            tile_u16_total = sum_kind_counts(sample_hits + endpoint_hits + actor_hits, {"xy16", "yx16"})
            tile_u8_total = sum_kind_counts(sample_hits + endpoint_hits + actor_hits, {"xy8", "yx8"})
            pixel_u16_total = sum_kind_counts(pixel_sample_hits + pixel_endpoint_hits + pixel_actor_hits, {"xy16", "yx16"})
            pixel_u8_total = sum_kind_counts(pixel_sample_hits + pixel_endpoint_hits + pixel_actor_hits, {"xy8", "yx8"})
            full_u16_total = full_pattern_kind_total(full_pattern, "u16")
            full_u8_total = full_pattern_kind_total(full_pattern, "u8")
            if sample_total or endpoint_total or actor_total:
                totals["sceneWindowCoordinateHitRunCount"] += 1
            if pixel_sample_total or pixel_endpoint_total or pixel_actor_total:
                totals["sceneWindowPixelCoordinateHitRunCount"] += 1
            if full_pattern["total"]:
                totals["fullRunPatternHitRunCount"] += 1
            strict_hit = bool(tile_u16_total or pixel_u16_total or full_u16_total)
            weak_hit = bool(tile_u8_total or pixel_u8_total or full_u8_total)
            if strict_hit:
                totals["strictU16DiagnosticRunCount"] += 1
            elif weak_hit:
                totals["weakU8OnlyDiagnosticRunCount"] += 1
            totals["candidateRunCount"] += 1
            totals["candidateTileCount"] += run["length"]
            reviewed_runs.append(
                {
                    **{key: run[key] for key in ("direction", "axis", "x0", "y0", "x1", "y1", "length", "sample", "triggerAnchor", "actorTriggerSample")},
                    "sceneWindowSamplePackedHitTotal": sample_total,
                    "sceneWindowEndpointPackedHitTotal": endpoint_total,
                    "sceneWindowActorPackedHitTotal": actor_total,
                    "sceneWindowPixelSamplePackedHitTotal": pixel_sample_total,
                    "sceneWindowPixelEndpointPackedHitTotal": pixel_endpoint_total,
                    "sceneWindowPixelActorPackedHitTotal": pixel_actor_total,
                    "sceneWindowTileU16HitTotal": tile_u16_total,
                    "sceneWindowTileU8HitTotal": tile_u8_total,
                    "sceneWindowPixelU16HitTotal": pixel_u16_total,
                    "sceneWindowPixelU8HitTotal": pixel_u8_total,
                    "sceneWindowSamplePackedHits": sample_hits,
                    "sceneWindowEndpointPackedHits": endpoint_hits[:8],
                    "sceneWindowActorPackedHits": actor_hits[:8],
                    "sceneWindowPixelSamplePackedHits": pixel_sample_hits[:8],
                    "sceneWindowPixelEndpointPackedHits": pixel_endpoint_hits[:8],
                    "sceneWindowPixelActorPackedHits": pixel_actor_hits[:8],
                    "fullRunPatternHitTotal": full_pattern["total"],
                    "fullRunPatternU16HitTotal": full_u16_total,
                    "fullRunPatternU8HitTotal": full_u8_total,
                    "fullRunPatternSamples": full_pattern["samples"],
                    "evidenceState": (
                        "strict-u16-coordinate-hit-diagnostic"
                        if strict_hit
                        else "weak-byte-coordinate-hit-diagnostic"
                        if weak_hit
                        else "geometry-only"
                    ),
                    "routeProofState": "not-promoted-coordinate-consumer-unverified",
                }
            )
        rows.append(
            {
                "map": map_name,
                "width": map_info["width"],
                "height": map_info["height"],
                "tilesets": map_info.get("layerTilesets") or [map_info.get("tileset")],
                "sceneRecordVas": [scene.get("recordVaHex") for scene in scenes],
                "runCount": len(reviewed_runs),
                "tileCount": sum(run["length"] for run in reviewed_runs),
                "runs": reviewed_runs,
            }
        )
    return {
        "kind": "hwanse-map-field-exit-candidate-review",
        "source": "CNS layer1 collision direction bits; boundary bracket heuristic; standalone EXE coordinate scans only",
        "promotionStatus": "geometry-strong-route-proof-unconfirmed",
        "summary": totals,
        "rules": {
            "topBottom": "boundary run with left/right collision edge brackets, no outward up/down bit, width >= 3 tiles",
            "leftRight": "boundary run with up/down collision edge brackets, no outward left/right bit, height >= 1 tile",
            "topTriggerAnchor": "up exits are inspected with an additional actor-foot sample at boundary y + 3 because gameplay triggers when the actor head enters the top edge",
            "ignoredMaps": "map0_*n event maps and map2_20j..map2_39j cleaning event maps are excluded from field-exit validation",
            "knownLimitation": "building entrances and blocked-by-object passages can be detected as geometry candidates; raw EXE coordinate hits are diagnostic only because small numeric tuples collide with unrelated VM/resource parameters",
        },
        "rows": rows,
    }


def render_html(data: dict) -> str:
    rows = []
    for row in data["rows"]:
        run_lines = []
        for run in row["runs"]:
            run_lines.append(
                "<div>"
                f"<b>{html.escape(run['direction'])}</b> "
                f"({run['x0']},{run['y0']})-({run['x1']},{run['y1']}) "
                f"len {run['length']} · {html.escape(run['triggerAnchor'])} · {html.escape(run['evidenceState'])} · "
                f"tile u16/u8 {run['sceneWindowTileU16HitTotal']}/{run['sceneWindowTileU8HitTotal']} · "
                f"pixel u16/u8 {run['sceneWindowPixelU16HitTotal']}/{run['sceneWindowPixelU8HitTotal']} · "
                f"run u16/u8 {run['fullRunPatternU16HitTotal']}/{run['fullRunPatternU8HitTotal']}"
                "</div>"
            )
        rows.append(
            "<tr>"
            f"<td><a href='../web/map_review.html?map={html.escape(row['map'])}&collisionFlags=1&fieldExitCandidates=1'>{html.escape(row['map'])}</a></td>"
            f"<td>{row['width']}x{row['height']}</td>"
            f"<td>{html.escape(', '.join(row['tilesets']))}</td>"
            f"<td>{row['runCount']}</td>"
            f"<td>{row['tileCount']}</td>"
            f"<td>{''.join(run_lines)}</td>"
            "</tr>"
        )
    summary = data["summary"]
    return f"""<!doctype html>
<html lang="ko">
<head>
  <meta charset="utf-8">
  <title>Map Field Exit Candidate Review</title>
  <style>
    body {{ font-family: system-ui, sans-serif; margin: 20px; background:#101114; color:#eceff4; }}
    a {{ color:#8fc7ff; }}
    table {{ border-collapse: collapse; width: 100%; font-size: 13px; }}
    th, td {{ border:1px solid #30343c; padding: 6px 8px; vertical-align: top; }}
    th {{ background:#1f2430; position: sticky; top:0; }}
    .note {{ color:#b8c0cc; max-width: 980px; line-height: 1.55; }}
    code {{ color:#ffd479; }}
  </style>
</head>
<body>
  <h1>Map Field Exit Candidate Review</h1>
  <p class="note">
    Collision flag bracket heuristic로 얻은 필드 경계 출구 후보를 EXE 내부 좌표 히트와 독립적으로 대조한 진단 자료다.
    <code>event_transitions</code>와의 겹침은 의도적으로 쓰지 않는다. 좌표/패턴 hit는 작은 수치의 우연 및
    resource VM 파라미터와 충돌할 수 있으므로 route proof로 승격하지 않는다.
    <code>map0_*n</code> 이벤트 맵과 <code>map2_20j..map2_39j</code> 청소 이벤트 맵은 제외한다.
    위쪽 출구는 발밑이 아니라 머리 진입 기준이므로 actor-foot 예상 좌표도 추가 스캔한다.
  </p>
  <p>
    maps {summary['mapWithCandidateCount']}/{summary['mapCount']} ·
    ignored maps {summary['ignoredMapCount']} ·
    runs {summary['candidateRunCount']} ·
    tiles {summary['candidateTileCount']} ·
    scene-window tile-coordinate-hit runs {summary['sceneWindowCoordinateHitRunCount']} ·
    scene-window pixel-coordinate-hit runs {summary['sceneWindowPixelCoordinateHitRunCount']} ·
    full-run byte-pattern-hit runs {summary['fullRunPatternHitRunCount']} ·
    strict u16 diagnostic runs {summary['strictU16DiagnosticRunCount']} ·
    weak u8-only diagnostic runs {summary['weakU8OnlyDiagnosticRunCount']}
  </p>
  <table>
    <thead>
      <tr><th>map</th><th>size</th><th>tilesets</th><th>runs</th><th>tiles</th><th>candidate runs</th></tr>
    </thead>
    <tbody>{''.join(rows)}</tbody>
  </table>
</body>
</html>
"""


def main() -> None:
    data = build_review()
    OUT.mkdir(exist_ok=True)
    (OUT / "map_field_exit_candidate_review.json").write_text(json.dumps(data, ensure_ascii=False, separators=(",", ":")) + "\n", encoding="utf-8")
    print(
        "wrote out/map_field_exit_candidate_review.json "
        f"runs={data['summary']['candidateRunCount']} maps={data['summary']['mapWithCandidateCount']}"
    )


if __name__ == "__main__":
    main()
