#!/usr/bin/env python3
"""Scan EXE references for manual map-exit candidate coordinates."""
from __future__ import annotations

import argparse
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


ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "out"
SCAN_SECTIONS = {".text", ".rdata", ".data"}
REF_TEXT_SECTIONS = {".text"}
REF_DATA_SECTIONS = {".rdata", ".data"}
FAILED_MAP_EXIT_COORDINATE_REF_GATE_IDS = [
    "strict-source-coordinate-owner",
    "table-text-reference",
    "target-map-control-path",
    "manual-review-promotion-proof",
]
MAP_EXIT_COORDINATE_REF_MISSING_EVIDENCE = [
    "strict map1_01a event coordinate or source hotspot owning the packed coordinate hit",
    "text/code reference proving the coordinate hit is part of a transition table",
    "control-flow path linking the source coordinate to the target map2_02d load",
    "manual disassembly/review proof promoting a diagnostic coordinate ref",
]
MAP_EXIT_COORDINATE_REF_EVIDENCE_REFS = [
    {"path": "Hwanse2.exe", "fields": ["packed coordinate byte scans", ".text/.data references"]},
    {"path": "out/map_exit_candidates.json", "fields": ["exitCandidates", "blockedTargetCandidates"]},
    {"path": "out/confirmed_route_blockers.json", "fields": ["source", "target"]},
    {"path": "out/event_transitions.json", "fields": ["map", "targets", "recordVa"]},
    {"path": "out/scene_coordinate_candidates.json", "fields": ["map", "coordinate candidates"]},
    {"path": "out/transition_extraction_gaps.json", "fields": ["map", "gap"]},
]


def unique(values: list[str]) -> list[str]:
    seen = set()
    result = []
    for value in values:
        if value in seen:
            continue
        seen.add(value)
        result.append(value)
    return result


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 range_refs(
    data: bytes,
    sections: list[dict],
    start_va: int,
    end_va: int,
    section_names: set[str],
    limit: int = 16,
) -> list[dict]:
    refs = []
    for section in sections:
        if section["name"] not in section_names:
            continue
        raw_start = section["raw"]
        raw_end = section["raw"] + section["raw_size"]
        raw = data[raw_start:raw_end]
        for index in range(0, len(raw) - 3, 4):
            value = struct.unpack_from("<I", raw, index)[0]
            if not (start_va <= value <= end_va):
                continue
            refs.append({
                "section": section["name"],
                "refVa": section["va"] + index,
                "fileOffset": raw_start + index,
                "value": value,
                "valueHex": f"0x{value:08x}",
            })
            if len(refs) >= limit:
                return refs
    return refs


def context_dwords(data: bytes, sections: list[dict], offset: int, before: int = 3, after: int = 4) -> list[dict]:
    aligned = offset - (offset % 4)
    start = max(0, aligned - before * 4)
    end = min(len(data), aligned + after * 4)
    items = []
    for item_offset in range(start, end, 4):
        if item_offset + 4 > len(data):
            break
        va = offset_to_va(sections, item_offset)
        if va is None:
            continue
        value = struct.unpack_from("<I", data, item_offset)[0]
        lo, hi = struct.unpack_from("<HH", data, item_offset)
        items.append({
            "va": va,
            "vaHex": f"0x{va:08x}",
            "fileOffset": item_offset,
            "valueHex": f"0x{value:08x}",
            "u16": [lo, hi],
            "isHitDword": item_offset == aligned,
        })
    return items


def scan_pattern(
    data: bytes,
    sections: list[dict],
    pattern: bytes,
    max_samples: int = 12,
) -> dict:
    section_counts: dict[str, int] = {}
    aligned_counts: dict[str, int] = {}
    samples = []
    total = 0
    aligned_total = 0
    search = 0
    while True:
        hit = data.find(pattern, search)
        if hit < 0:
            break
        search = hit + 1
        section = section_for_offset(sections, hit)
        if section is None or section["name"] not in SCAN_SECTIONS:
            continue
        total += 1
        section_counts[section["name"]] = section_counts.get(section["name"], 0) + 1
        aligned = (hit - section["raw"]) % 4 == 0
        if aligned:
            aligned_total += 1
            aligned_counts[section["name"]] = aligned_counts.get(section["name"], 0) + 1
        if len(samples) >= max_samples:
            continue
        va = offset_to_va(sections, hit)
        if va is None:
            continue
        text_refs = range_refs(data, sections, max(0, va - 0x20), va + 0x20, REF_TEXT_SECTIONS)
        data_refs = range_refs(data, sections, max(0, va - 0x20), va + 0x20, REF_DATA_SECTIONS)
        samples.append({
            "section": section["name"],
            "va": va,
            "vaHex": f"0x{va:08x}",
            "fileOffset": hit,
            "fileOffsetHex": f"0x{hit:06x}",
            "dwordAligned": aligned,
            "textRefsToWindow": text_refs,
            "dataRefsToWindow": data_refs,
            "context": context_dwords(data, sections, hit),
        })
    return {
        "total": total,
        "alignedTotal": aligned_total,
        "sectionCounts": dict(sorted(section_counts.items())),
        "alignedSectionCounts": dict(sorted(aligned_counts.items())),
        "samples": samples,
    }


def exit_candidate_rows(map_exit_candidates: list[dict], route_blockers: list[dict]) -> list[dict]:
    blockers = {(row.get("source"), row.get("target")) for row in route_blockers}
    rows = []
    for source_row in map_exit_candidates:
        source = source_row.get("map")
        for candidate in source_row.get("exitCandidates") or []:
            sample = candidate.get("sample") or {}
            targets = unique([
                *(candidate.get("blockedTargetCandidates") or []),
                *(candidate.get("selectorTargetCandidates") or []),
            ])
            blocked_targets = [
                target
                for target in targets
                if (source, target) in blockers or target in (candidate.get("blockedTargetCandidates") or [])
            ]
            for target in blocked_targets:
                if not isinstance(sample.get("x"), int) or not isinstance(sample.get("y"), int):
                    continue
                rows.append({
                    "source": source,
                    "target": target,
                    "side": candidate.get("side"),
                    "x": sample["x"],
                    "y": sample["y"],
                    "standable": sample.get("standable"),
                    "reviewUrl": candidate.get("reviewUrl"),
                    "trialUrl": candidate.get("trialUrl"),
                })
    return rows


def attach_coordinate_scans(data: bytes, sections: list[dict], rows: list[dict]) -> list[dict]:
    for row in rows:
        x = row["x"]
        y = row["y"]
        xy_value = x | (y << 16)
        yx_value = y | (x << 16)
        xy = scan_pattern(data, sections, struct.pack("<HH", x, y))
        yx = scan_pattern(data, sections, struct.pack("<HH", y, x))
        text_ref_count = sum(len(sample["textRefsToWindow"]) for scan in (xy, yx) for sample in scan["samples"])
        row["packedScans"] = {
            "xy": {
                "value": xy_value,
                "valueHex": f"0x{xy_value:08x}",
                **xy,
            },
            "yx": {
                "value": yx_value,
                "valueHex": f"0x{yx_value:08x}",
                **yx,
            },
        }
        row["promotable"] = False
        if xy["total"] == 0 and yx["total"] == 0:
            row["status"] = "no-exact-packed-coordinate-hit"
        elif text_ref_count == 0:
            row["status"] = "coordinate-like-hit-without-table-text-ref"
        else:
            row["status"] = "coordinate-like-hit-with-table-text-ref-needs-manual-disassembly"
        row["reason"] = (
            "Exact coordinate bytes alone do not prove a map transition. Promotion still needs a strict source "
            "hotspot, event coordinate, or code path that links this tile to the target map."
        )
    return rows


def scene_evidence(source: str, target: str, event_transitions: list[dict], coordinate_candidates: list[dict], gaps: list[dict]) -> dict:
    source_events = [row for row in event_transitions if row.get("map") == source]
    target_events = [
        row
        for row in source_events
        if target in (row.get("targets") or [])
    ]
    return {
        "eventTransitionCount": len(target_events),
        "sourceEventRecordCount": len(source_events),
        "sourceCoordinateCandidateCount": sum(1 for row in coordinate_candidates if row.get("map") == source),
        "sourceExtractionGapCount": sum(1 for row in gaps if row.get("map") == source),
    }


def build_summary(
    exe: bytes,
    map_exit_candidates: list[dict],
    route_blockers: list[dict],
    event_transitions: list[dict],
    coordinate_candidates: list[dict],
    transition_gaps: list[dict],
) -> dict:
    sections = read_sections(exe)
    rows = attach_coordinate_scans(exe, sections, exit_candidate_rows(map_exit_candidates, route_blockers))
    for row in rows:
        row["sceneEvidence"] = scene_evidence(
            row["source"],
            row["target"],
            event_transitions,
            coordinate_candidates,
            transition_gaps,
        )
    return {
        "promotionPolicy": "manual-review-only; coordinate refs are diagnostic and never auto-promoted",
        "promotionStatus": "blocked-diagnostic-only",
        "proofFound": False,
        "mapExitCoordinateRefProofFound": False,
        "failedMapExitCoordinateRefGateIds": FAILED_MAP_EXIT_COORDINATE_REF_GATE_IDS,
        "missingEvidence": MAP_EXIT_COORDINATE_REF_MISSING_EVIDENCE,
        "evidenceRefs": MAP_EXIT_COORDINATE_REF_EVIDENCE_REFS,
        "evidenceRefCount": len(MAP_EXIT_COORDINATE_REF_EVIDENCE_REFS),
        "rows": rows,
    }


def scan_summary(scan: dict) -> str:
    counts = ", ".join(f"{key}={value}" for key, value in scan.get("sectionCounts", {}).items())
    return f"{scan['total']} ({counts or '-'})"


def markdown(summary: dict) -> str:
    rows = summary.get("rows") or []
    lines = [
        "# Map Exit Coordinate Refs",
        "",
        "Exact packed-coordinate scan for geometry-only map-exit trial candidates.",
        "",
        "These rows are diagnostic only. A coordinate-like byte sequence in `Hwanse2.exe` is not enough to promote a normal transition unless it is tied to a strict source hotspot/event coordinate and a target map code path.",
        "",
        f"- promotion status: `{summary.get('promotionStatus')}`",
        f"- proof found: {summary.get('proofFound')}",
        f"- map-exit coordinate ref proof found: {summary.get('mapExitCoordinateRefProofFound')}",
        f"- failed map-exit coordinate ref gates: `{','.join(summary.get('failedMapExitCoordinateRefGateIds') or [])}`",
        f"- evidence refs: {summary.get('evidenceRefCount')}",
        "",
        "## Missing Evidence",
        "",
    ]
    lines.extend(f"- {item}" for item in summary.get("missingEvidence") or [])
    lines.extend([
        "",
        "## Evidence Refs",
        "",
        "| path | fields |",
        "| --- | --- |",
    ])
    for ref in summary.get("evidenceRefs") or []:
        lines.append(f"| `{ref.get('path')}` | `{', '.join(ref.get('fields') or [])}` |")
    lines.extend([
        "",
        "| source | target | side | tile | xy hits | yx hits | scene evidence | status |",
        "| --- | --- | --- | --- | ---: | ---: | --- | --- |",
    ])
    for row in rows:
        scans = row["packedScans"]
        evidence = row["sceneEvidence"]
        lines.append(
            f"| {row['source']} | {row['target']} | {row['side']} | {row['x']},{row['y']} | "
            f"{scan_summary(scans['xy'])} | {scan_summary(scans['yx'])} | "
            f"events={evidence['eventTransitionCount']}, coords={evidence['sourceCoordinateCandidateCount']}, gaps={evidence['sourceExtractionGapCount']} | "
            f"{row['status']} |"
        )
    lines.extend(["", "## Sample Hits", ""])
    for row in rows:
        lines.append(f"### {row['source']} -> {row['target']} {row['side']} {row['x']},{row['y']}")
        for order in ("xy", "yx"):
            scan = row["packedScans"][order]
            lines.append("")
            lines.append(f"- `{order}` packed value `{scan['valueHex']}`: {scan_summary(scan)}")
            if not scan["samples"]:
                continue
            for sample in scan["samples"][:4]:
                text_refs = ", ".join(ref["valueHex"] for ref in sample["textRefsToWindow"][:4]) or "-"
                data_refs = ", ".join(ref["valueHex"] for ref in sample["dataRefsToWindow"][:4]) or "-"
                lines.append(
                    f"  - {sample['section']} `{sample['vaHex']}` file `{sample['fileOffsetHex']}` "
                    f"aligned={sample['dwordAligned']} textRefs={text_refs} dataRefs={data_refs}"
                )
        lines.append("")
    return "\n".join(lines)


def html_page(summary: dict) -> str:
    rows = summary.get("rows") or []
    body = []
    detail = []
    for row in rows:
        scans = row["packedScans"]
        evidence = row["sceneEvidence"]
        body.append(
            "\n".join([
                "<tr>",
                f"  <td>{html.escape(row['source'])}</td>",
                f"  <td>{html.escape(row['target'])}</td>",
                f"  <td>{html.escape(str(row['side']))}</td>",
                f"  <td>{row['x']},{row['y']}</td>",
                f"  <td>{html.escape(scan_summary(scans['xy']))}</td>",
                f"  <td>{html.escape(scan_summary(scans['yx']))}</td>",
                f"  <td>events={evidence['eventTransitionCount']}, coords={evidence['sourceCoordinateCandidateCount']}, gaps={evidence['sourceExtractionGapCount']}</td>",
                f"  <td>{html.escape(row['status'])}</td>",
                "</tr>",
            ])
        )
        sample_items = []
        for order in ("xy", "yx"):
            scan = scans[order]
            sample_items.append(f"<li><code>{order}</code> <code>{scan['valueHex']}</code>: {html.escape(scan_summary(scan))}</li>")
            if scan["samples"]:
                sample_items.append("<ul>")
                for sample in scan["samples"][:4]:
                    text_refs = ", ".join(ref["valueHex"] for ref in sample["textRefsToWindow"][:4]) or "-"
                    data_refs = ", ".join(ref["valueHex"] for ref in sample["dataRefsToWindow"][:4]) or "-"
                    sample_items.append(
                        "<li>"
                        f"{html.escape(sample['section'])} <code>{sample['vaHex']}</code> "
                        f"file <code>{sample['fileOffsetHex']}</code> "
                        f"aligned={sample['dwordAligned']} "
                        f"textRefs={html.escape(text_refs)} dataRefs={html.escape(data_refs)}"
                        "</li>"
                    )
                sample_items.append("</ul>")
        detail.append(
            "\n".join([
                f"<h2>{html.escape(row['source'])} -> {html.escape(row['target'])} {html.escape(str(row['side']))} {row['x']},{row['y']}</h2>",
                "<ul>",
                *sample_items,
                "</ul>",
            ])
        )
    return "\n".join([
        "<!doctype html>",
        '<html lang="en">',
        "<head>",
        '  <meta charset="utf-8">',
        '  <meta name="viewport" content="width=device-width, initial-scale=1">',
        "  <title>Map Exit Coordinate Refs</title>",
        "  <style>",
        "    :root { color-scheme: dark; font-family: system-ui, sans-serif; background: #101010; color: #eee; }",
        "    body { margin: 0; padding: 24px; }",
        "    h1 { margin: 0 0 8px; font-size: 24px; }",
        "    h2 { margin: 26px 0 10px; font-size: 18px; }",
        "    p { margin: 0 0 14px; color: #bbb; max-width: 980px; line-height: 1.45; }",
        "    table { width: 100%; border-collapse: collapse; margin: 0 0 16px; font-size: 13px; }",
        "    th, td { border-bottom: 1px solid #303030; padding: 7px 8px; text-align: left; vertical-align: top; }",
        "    th { position: sticky; top: 0; background: #181818; z-index: 1; color: #ddd; }",
        "    code { color: #d7f0ff; }",
        "    li { margin: 4px 0; }",
        "  </style>",
        "</head>",
        "<body>",
        "  <h1>Map Exit Coordinate Refs</h1>",
        "  <p>Exact packed-coordinate scan for geometry-only map-exit trial candidates. These rows are diagnostic only and do not promote normal transitions.</p>",
        f"  <p><b>promotion status:</b> <code>{html.escape(str(summary.get('promotionStatus')))}</code>; "
        f"<b>proof found:</b> {html.escape(str(summary.get('proofFound')))}; "
        "<b>failed map-exit coordinate ref gates:</b> "
        f"<code>{html.escape(','.join(summary.get('failedMapExitCoordinateRefGateIds') or []))}</code>; "
        f"<b>evidence refs:</b> {html.escape(str(summary.get('evidenceRefCount')))}.</p>",
        "  <h2>Missing Evidence</h2>",
        "  <ul>",
        "\n".join(f"    <li>{html.escape(item)}</li>" for item in summary.get("missingEvidence") or []),
        "  </ul>",
        "  <h2>Evidence Refs</h2>",
        "  <ul>",
        "\n".join(
            f"    <li><code>{html.escape(str(ref.get('path')))}</code>: "
            f"{html.escape(', '.join(ref.get('fields') or []))}</li>"
            for ref in summary.get("evidenceRefs") or []
        ),
        "  </ul>",
        "  <table>",
        "    <thead><tr><th>source</th><th>target</th><th>side</th><th>tile</th><th>xy hits</th><th>yx hits</th><th>scene evidence</th><th>status</th></tr></thead>",
        "    <tbody>",
        *body,
        "    </tbody>",
        "  </table>",
        *detail,
        "</body>",
        "</html>",
        "",
    ])


def write_outputs(summary: dict, out_dir: Path = OUT) -> None:
    out_dir.mkdir(parents=True, exist_ok=True)
    (out_dir / "map_exit_coordinate_refs.json").write_text(
        json.dumps(summary, ensure_ascii=False, indent=2) + "\n",
        encoding="utf-8",
    )
    (out_dir / "map_exit_coordinate_refs.html").write_text(html_page(summary), encoding="utf-8")


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--exe", type=Path, default=ROOT / "Hwanse2.exe")
    parser.add_argument("--map-exits", type=Path, default=OUT / "map_exit_candidates.json")
    parser.add_argument("--route-blockers", type=Path, default=OUT / "confirmed_route_blockers.json")
    parser.add_argument("--event-transitions", type=Path, default=OUT / "event_transitions.json")
    parser.add_argument("--coordinate-candidates", type=Path, default=OUT / "scene_coordinate_candidates.json")
    parser.add_argument("--transition-gaps", type=Path, default=OUT / "transition_extraction_gaps.json")
    parser.add_argument("--out-dir", type=Path, default=OUT)
    args = parser.parse_args()

    summary = build_summary(
        args.exe.read_bytes(),
        json.loads(args.map_exits.read_text(encoding="utf-8")),
        json.loads(args.route_blockers.read_text(encoding="utf-8")),
        json.loads(args.event_transitions.read_text(encoding="utf-8")),
        json.loads(args.coordinate_candidates.read_text(encoding="utf-8")),
        json.loads(args.transition_gaps.read_text(encoding="utf-8")),
    )
    write_outputs(summary, args.out_dir)
    print(f"wrote {len(summary['rows'])} map exit coordinate ref rows -> {args.out_dir / 'map_exit_coordinate_refs.html'}")


if __name__ == "__main__":
    main()
