#!/usr/bin/env python3
"""Summarize geometry-only map exit candidates for manual route review."""
from __future__ import annotations

import argparse
import html
import json
from collections import deque
from pathlib import Path
from urllib.parse import urlencode

from summarize_map_tiles import load_maps
from tile_classes import load as load_tile_classes


ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "out"
DATA = ROOT / "data"
SIDE_ORDER = {"top": 0, "bottom": 1, "left": 2, "right": 3}
OPPOSITE_SIDE = {"top": "bottom", "bottom": "top", "left": "right", "right": "left"}


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 web_href(params: dict[str, str], web_prefix: str = "../web") -> str:
    return f"{web_prefix}/game.html?{urlencode(params)}"


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


def tile_at(info: dict, tile_x: int, tile_y: int) -> tuple[int, int]:
    index = tile_y * info["width"] + tile_x
    return info["layers"][0][index], info["layers"][1][index]


def tile_passable(info: dict, tile_x: int, tile_y: int, tile_classes: dict[str, dict]) -> bool:
    if tile_x < 0 or tile_y < 0 or tile_x >= info["width"] or tile_y >= info["height"]:
        return False
    layer0, layer1 = tile_at(info, tile_x, tile_y)
    entry = tile_classes.get(first_tileset(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
    return layer0 > 0


def can_stand_on_tile(info: dict, tile_x: int, tile_y: int, tile_classes: dict[str, dict]) -> bool:
    tile_size = info["tileSize"]
    pixel_x = tile_x * tile_size - 16
    pixel_y = tile_y * tile_size - 16
    points = [
        ((pixel_x + 8) // tile_size, (pixel_y + 18) // tile_size),
        ((pixel_x + 23) // tile_size, (pixel_y + 18) // tile_size),
        ((pixel_x + 8) // tile_size, (pixel_y + 31) // tile_size),
        ((pixel_x + 23) // tile_size, (pixel_y + 31) // tile_size),
    ]
    return all(tile_passable(info, x, y, tile_classes) for x, y in points)


def connected_components(info: dict, tile_classes: dict[str, dict]) -> list[list[tuple[int, int]]]:
    width = info["width"]
    height = info["height"]
    seen: set[tuple[int, int]] = set()
    components: list[list[tuple[int, int]]] = []
    for y in range(height):
        for x in range(width):
            start = (x, y)
            if start in seen or not tile_passable(info, x, y, tile_classes):
                continue
            seen.add(start)
            cells = []
            queue = deque([start])
            while queue:
                cell_x, cell_y = queue.popleft()
                cells.append((cell_x, cell_y))
                for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)):
                    next_cell = (cell_x + dx, cell_y + dy)
                    if next_cell in seen:
                        continue
                    if not tile_passable(info, next_cell[0], next_cell[1], tile_classes):
                        continue
                    seen.add(next_cell)
                    queue.append(next_cell)
            components.append(cells)
    components.sort(key=len, reverse=True)
    return components


def side_distance(side: str, x: int, y: int, width: int, height: int) -> int:
    if side == "top":
        return y
    if side == "bottom":
        return height - 1 - y
    if side == "left":
        return x
    if side == "right":
        return width - 1 - x
    raise ValueError(f"unknown side {side}")


def side_projection(side: str, x: int, y: int) -> int:
    return x if side in {"top", "bottom"} else y


def auto_trigger(candidate: dict) -> bool:
    sample = candidate.get("sample") or {}
    return candidate.get("edgeDistance") == 0 and sample.get("standable") is True


def projection_fraction(row: dict, candidate: dict) -> float:
    sample = candidate.get("sample") or {}
    side = candidate.get("side")
    if side in {"top", "bottom"}:
        denominator = max(int(row.get("width") or 1) - 1, 1)
        return float(sample.get("x", 0)) / denominator
    denominator = max(int(row.get("height") or 1) - 1, 1)
    return float(sample.get("y", 0)) / denominator


def target_hint(source_row: dict, source_candidate: dict, target_row: dict, target_candidate: dict) -> dict:
    preferred_side = OPPOSITE_SIDE.get(source_candidate.get("side"), "")
    source_projection = projection_fraction(source_row, source_candidate)
    target_projection = projection_fraction(target_row, target_candidate)
    projection_delta = abs(source_projection - target_projection)
    sample = target_candidate.get("sample") or {}
    side_match = target_candidate.get("side") == preferred_side
    rank = [
        0 if side_match else 1,
        0 if auto_trigger(target_candidate) else 1,
        0 if sample.get("standable") is True else 1,
        int(target_candidate.get("edgeDistance") or 0),
        int(round(projection_delta * 1000)),
        -int(target_candidate.get("tileCount") or 0),
        int(sample.get("y") or 0),
        int(sample.get("x") or 0),
    ]
    return {
        "target": target_row["map"],
        "preferredSide": preferred_side,
        "side": target_candidate.get("side"),
        "x": sample.get("x"),
        "y": sample.get("y"),
        "standable": sample.get("standable"),
        "edgeDistance": target_candidate.get("edgeDistance"),
        "autoTrigger": auto_trigger(target_candidate),
        "sideMatch": side_match,
        "projectionDelta": round(projection_delta, 4),
        "rank": rank,
    }


def attach_target_hints(rows: list[dict]) -> None:
    by_map = {row["map"]: row for row in rows}
    for row in rows:
        for candidate in row.get("exitCandidates") or []:
            hints = []
            for target in unique([
                *(candidate.get("blockedTargetCandidates") or []),
                *(candidate.get("selectorTargetCandidates") or []),
            ]):
                if target == row["map"]:
                    continue
                target_row = by_map.get(target)
                target_candidates = (target_row or {}).get("exitCandidates") or []
                if not target_candidates:
                    continue
                best = min(
                    (
                        target_hint(row, candidate, target_row, target_candidate)
                        for target_candidate in target_candidates
                    ),
                    key=lambda hint: hint["rank"],
                )
                hints.append(best)
            hints.sort(key=lambda hint: hint["rank"] + [hint["target"]])
            candidate["targetHints"] = hints


def split_projection_groups(values: list[int]) -> list[tuple[int, int]]:
    if not values:
        return []
    groups = []
    start = values[0]
    previous = values[0]
    for value in values[1:]:
        if value > previous + 1:
            groups.append((start, previous))
            start = value
        previous = value
    groups.append((start, previous))
    return groups


def choose_sample(
    info: dict,
    side: str,
    cells: list[tuple[int, int]],
    tile_classes: dict[str, dict],
) -> dict:
    width = info["width"]
    height = info["height"]
    mid = (min(side_projection(side, x, y) for x, y in cells) + max(side_projection(side, x, y) for x, y in cells)) / 2

    def sample_key(cell: tuple[int, int]) -> tuple[int, float]:
        x, y = cell
        distance = side_distance(side, x, y, width, height)
        return distance, abs(side_projection(side, x, y) - mid)

    standable = [cell for cell in cells if can_stand_on_tile(info, cell[0], cell[1], tile_classes)]
    sample_x, sample_y = min(standable or cells, key=sample_key)
    layer0, layer1 = tile_at(info, sample_x, sample_y)
    return {
        "x": sample_x,
        "y": sample_y,
        "standable": bool(standable),
        "layer0": layer0,
        "layer1": layer1,
    }


def component_bounds(cells: list[tuple[int, int]]) -> dict:
    xs = [x for x, _ in cells]
    ys = [y for _, y in cells]
    return {
        "xMin": min(xs),
        "yMin": min(ys),
        "xMax": max(xs),
        "yMax": max(ys),
    }


def side_candidates_for_component(
    name: str,
    info: dict,
    component_index: int,
    component: list[tuple[int, int]],
    tile_classes: dict[str, dict],
    selector_targets: list[str],
    blocked_targets: list[str],
    edge_band: int,
    max_groups_per_side: int,
) -> list[dict]:
    width = info["width"]
    height = info["height"]
    candidates = []
    bounds = component_bounds(component)
    for side in ("top", "bottom", "left", "right"):
        min_distance = min(side_distance(side, x, y, width, height) for x, y in component)
        if min_distance > edge_band:
            continue
        side_cells = [
            (x, y)
            for x, y in component
            if side_distance(side, x, y, width, height) <= min_distance + edge_band
        ]
        projection_groups = split_projection_groups(sorted({side_projection(side, x, y) for x, y in side_cells}))
        group_rows = []
        for start, end in projection_groups:
            group_cells = [
                (x, y)
                for x, y in side_cells
                if start <= side_projection(side, x, y) <= end
            ]
            if not group_cells:
                continue
            sample = choose_sample(info, side, group_cells, tile_classes)
            perp_values = [
                y if side in {"top", "bottom"} else x
                for x, y in group_cells
            ]
            params = {
                "map": name,
                "startTile": f"{sample['x']},{sample['y']}",
                "focusTile": f"{sample['x']},{sample['y']}",
                "collision": "1",
                "overview": "1",
            }
            trial_params = dict(params)
            trial_params["trialTransitions"] = "mapExitCandidates"
            group_rows.append({
                "map": name,
                "side": side,
                "componentIndex": component_index,
                "componentSize": len(component),
                "componentBounds": bounds,
                "edgeDistance": min(side_distance(side, x, y, width, height) for x, y in group_cells),
                "tileCount": len(group_cells),
                "span": {
                    "axis": "x" if side in {"top", "bottom"} else "y",
                    "from": start,
                    "to": end,
                    "perpendicularFrom": min(perp_values),
                    "perpendicularTo": max(perp_values),
                },
                "sample": sample,
                "selectorTargetCandidates": selector_targets,
                "blockedTargetCandidates": blocked_targets,
                "promotionStatus": "manual-review-only",
                "reason": (
                    "Geometry-only edge of a passable connected component. "
                    "This is not a strict event coordinate or a confirmed map transition."
                ),
                "reviewUrl": web_href(params),
                "trialUrl": web_href(trial_params),
            })
        group_rows.sort(key=lambda row: (row["edgeDistance"], -row["tileCount"], row["span"]["from"]))
        candidates.extend(group_rows[:max_groups_per_side])
    return candidates


def blocked_targets_by_source(route_blockers: list[dict]) -> dict[str, list[str]]:
    result: dict[str, list[str]] = {}
    for row in route_blockers:
        source = row.get("source")
        target = row.get("target")
        if not source or not target:
            continue
        result.setdefault(source, []).append(target)
    return {source: unique(targets) for source, targets in result.items()}


def build_rows(
    maps: dict[str, dict],
    tile_classes: dict[str, dict],
    save_selector_scene_links: dict[str, dict],
    route_blockers: list[dict],
    edge_band: int = 2,
    max_components: int = 6,
    max_groups_per_side: int = 3,
) -> list[dict]:
    blocked_targets = blocked_targets_by_source(route_blockers)
    source_names = sorted(set(save_selector_scene_links) | set(blocked_targets))
    rows = []
    for name in source_names:
        info = maps.get(name)
        if not info:
            continue
        selector_targets = save_selector_scene_links.get(name, {}).get("fieldMaps") or []
        map_blocked_targets = blocked_targets.get(name, [])
        components = connected_components(info, tile_classes)
        candidates = []
        for component_index, component in enumerate(components[:max_components]):
            candidates.extend(
                side_candidates_for_component(
                    name,
                    info,
                    component_index,
                    component,
                    tile_classes,
                    selector_targets,
                    map_blocked_targets,
                    edge_band,
                    max_groups_per_side,
                )
            )
        candidates.sort(
            key=lambda row: (
                SIDE_ORDER[row["side"]],
                row["componentIndex"],
                row["edgeDistance"],
                -row["tileCount"],
                row["span"]["from"],
            )
        )
        passable_count = sum(len(component) for component in components)
        rows.append({
            "map": name,
            "width": info["width"],
            "height": info["height"],
            "tileSize": info["tileSize"],
            "tileset": first_tileset(info),
            "layerTilesets": info.get("layerTilesets") or [info.get("tileset")],
            "collisionMode": "tileClassLayer0",
            "edgeBand": edge_band,
            "componentCount": len(components),
            "passableTileCount": passable_count,
            "selectorTargetCandidates": selector_targets,
            "blockedTargetCandidates": map_blocked_targets,
            "priority": bool(map_blocked_targets),
            "promotionStatus": "manual-review-only",
            "exitCandidates": candidates,
        })
    rows.sort(key=lambda row: (not row["priority"], row["map"]))
    attach_target_hints(rows)
    return rows


def hint_label(candidate: dict) -> str:
    hints = candidate.get("targetHints") or []
    if not hints:
        return "-"
    hint = hints[0]
    return (
        f"{hint['target']} {hint.get('side')} "
        f"{hint.get('x')},{hint.get('y')} "
        f"score={','.join(str(value) for value in hint.get('rank') or [])}"
    )


def candidate_links(candidates: list[dict], limit: int = 8) -> str:
    links = []
    for candidate in candidates[:limit]:
        sample = candidate["sample"]
        label = f"{candidate['side']} {sample['x']},{sample['y']} -> {hint_label(candidate)}"
        links.append(f"[{label}]({candidate['reviewUrl']})")
    return ", ".join(links) or "-"


def markdown(rows: list[dict]) -> str:
    priority_rows = [row for row in rows if row["priority"]]
    lines = [
        "# Map Exit Candidates",
        "",
        "Generated from `out/maps.js`, `data/tile_classes.json`, save-selector scene links, and current route blockers.",
        "",
        "These rows are geometry-only review hints. `manual-review-only` means the candidate must not be promoted to normal gameplay until a strict event coordinate, hotspot, or equivalent runtime proof is found.",
        "",
        "Target hints rank reciprocal target exits by opposite side, auto-trigger eligibility, standability, edge distance, and normalized side projection. They are trial-spawn hints only, not promotion evidence.",
        "",
        "## Blocked Route Sources",
        "",
        "| map | selector target candidates | blocked targets | exits | open |",
        "| --- | --- | --- | --- | --- |",
    ]
    for row in priority_rows:
        lines.append(
            f"| {row['map']} | {', '.join(row['selectorTargetCandidates']) or '-'} | "
            f"{', '.join(row['blockedTargetCandidates']) or '-'} | "
            f"{candidate_links(row['exitCandidates'])} | "
            f"[source](../web/game.html?map={row['map']}&collision=1&overview=1) |"
        )
    if not priority_rows:
        lines.append("| - | - | - | - | - |")
    lines.extend([
        "",
        "## All Selector Sources",
        "",
        "| map | size | tileset | components | passable tiles | selector targets | exits | status |",
        "| --- | ---: | --- | ---: | ---: | --- | ---: | --- |",
    ])
    for row in rows:
        lines.append(
            f"| {row['map']} | {row['width']}x{row['height']} | {row['tileset']} | "
            f"{row['componentCount']} | {row['passableTileCount']} | "
            f"{', '.join(row['selectorTargetCandidates']) or '-'} | "
            f"{len(row['exitCandidates'])} | {row['promotionStatus']} |"
        )
    lines.append("")
    return "\n".join(lines)


def html_links(candidates: list[dict], limit: int = 8) -> str:
    links = []
    for candidate in candidates[:limit]:
        sample = candidate["sample"]
        label = f"{candidate['side']} {sample['x']},{sample['y']} -> {hint_label(candidate)}"
        links.append(f'<a href="{html.escape(candidate["reviewUrl"])}">{html.escape(label)}</a>')
    return ", ".join(links) or "-"


def html_page(rows: list[dict]) -> str:
    priority_rows = [row for row in rows if row["priority"]]
    priority_body = []
    for row in priority_rows:
        source_href = f"../web/game.html?map={html.escape(row['map'])}&amp;collision=1&amp;overview=1"
        priority_body.append(
            "\n".join([
                "<tr>",
                f'  <td><a href="{source_href}">{html.escape(row["map"])}</a></td>',
                f"  <td>{html.escape(', '.join(row['selectorTargetCandidates']) or '-')}</td>",
                f"  <td>{html.escape(', '.join(row['blockedTargetCandidates']) or '-')}</td>",
                f"  <td>{html_links(row['exitCandidates'])}</td>",
                f"  <td>{html.escape(row['promotionStatus'])}</td>",
                "</tr>",
            ])
        )
    all_body = []
    for row in rows:
        all_body.append(
            "\n".join([
                "<tr>",
                f'  <td><a href="../web/game.html?map={html.escape(row["map"])}&amp;collision=1&amp;overview=1">{html.escape(row["map"])}</a></td>',
                f"  <td>{row['width']}x{row['height']}</td>",
                f"  <td>{html.escape(row['tileset'])}</td>",
                f"  <td>{row['componentCount']}</td>",
                f"  <td>{row['passableTileCount']}</td>",
                f"  <td>{html.escape(', '.join(row['selectorTargetCandidates']) or '-')}</td>",
                f"  <td>{len(row['exitCandidates'])}</td>",
                f"  <td>{html.escape(row['promotionStatus'])}</td>",
                "</tr>",
            ])
        )
    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 Candidates</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; }",
        "    a { color: #8fd0ff; text-decoration: none; }",
        "    a:hover { text-decoration: underline; }",
        "  </style>",
        "</head>",
        "<body>",
        "  <h1>Map Exit Candidates</h1>",
        "  <p>Geometry-only review hints from passable connected components. manual-review-only rows must not be promoted until strict runtime evidence is found. Target hints only rank reciprocal trial spawn exits.</p>",
        "  <h2>Blocked Route Sources</h2>",
        "  <table>",
        "    <thead><tr><th>map</th><th>selector targets</th><th>blocked targets</th><th>exit review links</th><th>status</th></tr></thead>",
        "    <tbody>",
        *priority_body,
        "    </tbody>",
        "  </table>",
        "  <h2>All Selector Sources</h2>",
        "  <table>",
        "    <thead><tr><th>map</th><th>size</th><th>tileset</th><th>components</th><th>passable</th><th>selector targets</th><th>exits</th><th>status</th></tr></thead>",
        "    <tbody>",
        *all_body,
        "    </tbody>",
        "  </table>",
        "</body>",
        "</html>",
        "",
    ])


def runtime_index(rows: list[dict]) -> dict[str, dict]:
    result = {}
    for row in rows:
        exits = []
        for candidate in row.get("exitCandidates") or []:
            sample = candidate.get("sample") or {}
            hint_ranks = {
                hint["target"]: hint.get("rank")
                for hint in candidate.get("targetHints") or []
            }
            target_hints = {
                hint["target"]: [hint.get("x"), hint.get("y"), hint.get("rank")]
                for hint in candidate.get("targetHints") or []
            }
            targets = unique([
                *(candidate.get("blockedTargetCandidates") or []),
                *(candidate.get("selectorTargetCandidates") or []),
            ])
            original_order = {target: index for index, target in enumerate(targets)}
            targets.sort(
                key=lambda target: (
                    tuple(hint_ranks.get(target) or [999, 999, 999, 999, 999, 0, 999, 999]),
                    original_order.get(target, 999),
                    target,
                )
            )
            exits.append({
                "side": candidate.get("side"),
                "x": sample.get("x"),
                "y": sample.get("y"),
                "standable": sample.get("standable"),
                "edgeDistance": candidate.get("edgeDistance"),
                "autoTrigger": auto_trigger(candidate),
                "targets": targets,
                "targetHints": target_hints,
                "status": candidate.get("promotionStatus"),
            })
        result[row["map"]] = {
            "status": row.get("promotionStatus"),
            "priority": row.get("priority"),
            "targets": unique([
                *(row.get("blockedTargetCandidates") or []),
                *(row.get("selectorTargetCandidates") or []),
            ]),
            "exits": exits,
        }
    return result


def write_outputs(rows: list[dict], out_dir: Path = OUT) -> None:
    out_dir.mkdir(parents=True, exist_ok=True)
    (out_dir / "map_exit_candidates.json").write_text(
        json.dumps(rows, ensure_ascii=False, separators=(",", ":")) + "\n",
        encoding="utf-8",
    )
    (out_dir / "map_exit_candidates_runtime.js").write_text(
        "window.HWANSE_MAP_EXIT_CANDIDATES = "
        + json.dumps(runtime_index(rows), ensure_ascii=False, separators=(",", ":"))
        + ";\n",
        encoding="utf-8",
    )
    (out_dir / "map_exit_candidates.html").write_text(html_page(rows), encoding="utf-8")


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--maps", type=Path, default=OUT / "maps.js")
    parser.add_argument("--classes", type=Path, default=DATA / "tile_classes.json")
    parser.add_argument("--scene-links", type=Path, default=OUT / "save_selector_scene_links.json")
    parser.add_argument("--route-blockers", type=Path, default=OUT / "confirmed_route_blockers.json")
    parser.add_argument("--out-dir", type=Path, default=OUT)
    parser.add_argument("--edge-band", type=int, default=2)
    args = parser.parse_args()
    rows = build_rows(
        load_maps(args.maps),
        load_tile_classes(args.classes),
        json.loads(args.scene_links.read_text(encoding="utf-8")),
        json.loads(args.route_blockers.read_text(encoding="utf-8")),
        edge_band=args.edge_band,
    )
    write_outputs(rows, args.out_dir)
    print(f"wrote {len(rows)} map exit candidate rows -> {args.out_dir / 'map_exit_candidates.html'}")


if __name__ == "__main__":
    main()
