#!/usr/bin/env python3
"""Triage medium event/object branch-state candidates against current route indexes."""
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 read_sections
from summarize_event_object_branch_state_stream_candidates import build_summary as build_candidate_summary


ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "out"
LINK_RADIUS = 0x800
PARENT_SCAN_BACK = 0x100
PARENT_SCAN_FORWARD = 0x10
DEFAULT_ROUTE_SOURCE = "map1_01a"
DEFAULT_ROUTE_TARGET = "map2_02d"
DEFAULT_CURRENT_SELECTOR = "2:0"


def hex32(value: int) -> str:
    return f"0x{value:08x}"


def parse_hex(value: str | None) -> int | None:
    return int(value, 16) if isinstance(value, str) else None


def hex_range(start: int | None, end: int | None) -> str | None:
    if start is None or end is None:
        return None
    return f"{hex32(start)}..{hex32(end)}"


def selector_label(row: dict) -> str | None:
    group = row.get("group")
    slot = row.get("slot")
    if group is None or slot is None:
        return None
    return f"{group}:{slot}"


def infer_route(leaf_streams: list[dict] | None) -> dict:
    for row in leaf_streams or []:
        source = row.get("source")
        target = row.get("target")
        selector = row.get("selector")
        if source and target and selector:
            return {"source": source, "target": target, "selector": selector}
    return {
        "source": DEFAULT_ROUTE_SOURCE,
        "target": DEFAULT_ROUTE_TARGET,
        "selector": DEFAULT_CURRENT_SELECTOR,
    }


def add_unique(items: list, value) -> None:
    if value is not None and value not in items:
        items.append(value)


def selector_intervals(save_scene_selectors: list[dict] | None) -> list[dict]:
    roots: dict[int, dict] = {}
    for row in save_scene_selectors or []:
        root = parse_hex(row.get("selectedPointerHex"))
        if root is None:
            continue
        entry = roots.setdefault(root, {"root": root, "labels": [], "fieldMaps": []})
        add_unique(entry["labels"], selector_label(row))
        for field_map in row.get("fieldMaps") or []:
            add_unique(entry["fieldMaps"], field_map)
    ordered = [roots[root] for root in sorted(roots)]
    for index, entry in enumerate(ordered):
        entry["end"] = ordered[index + 1]["root"] if index + 1 < len(ordered) else None
        entry["rootHex"] = hex32(entry["root"])
        entry["endHex"] = hex32(entry["end"]) if entry["end"] is not None else None
        entry["rangeHex"] = hex_range(entry["root"], entry["end"])
    return ordered


def containing_selector_interval(intervals: list[dict], va: int) -> dict | None:
    for entry in intervals:
        end = entry.get("end")
        if entry["root"] <= va and (end is None or va < end):
            return entry
    return None


def route_leaf_ranges(leaf_streams: list[dict] | None) -> dict:
    leaf_addresses: list[int] = []
    scene_record_addresses: list[int] = []
    for stream in leaf_streams or []:
        for key in ("words", "nestedWords"):
            for word in stream.get(key) or []:
                va = parse_hex(word.get("vaHex"))
                if va is not None:
                    leaf_addresses.append(va)
        for key in ("fieldRecords", "nestedFieldRecords"):
            for record in stream.get(key) or []:
                va = parse_hex(record.get("recordVaHex"))
                if va is not None:
                    leaf_addresses.append(va)
                    scene_record_addresses.append(va)
    leaf_start = min(leaf_addresses) if leaf_addresses else None
    leaf_end = max(leaf_addresses) + 4 if leaf_addresses else None
    scene_start = min(scene_record_addresses) if scene_record_addresses else None
    scene_end = max(scene_record_addresses) + 4 if scene_record_addresses else None
    return {
        "leafStreamRange": {"start": leaf_start, "end": leaf_end, "rangeHex": hex_range(leaf_start, leaf_end)},
        "sceneRecordRange": {"start": scene_start, "end": scene_end, "rangeHex": hex_range(scene_start, scene_end)},
    }


def range_contains(range_info: dict | None, va: int | None) -> bool:
    if va is None or not range_info:
        return False
    start = range_info.get("start")
    end = range_info.get("end")
    return start is not None and end is not None and start <= va < end


def route_range_context(
    candidate_va: int,
    parent_va: int | None,
    route: dict,
    intervals: list[dict],
    ranges: dict,
) -> dict:
    route_maps = [route["source"], route["target"]]
    current_selector = route["selector"]
    current_interval = next(
        (entry for entry in intervals if current_selector in (entry.get("labels") or [])),
        None,
    )
    candidate_interval = containing_selector_interval(intervals, candidate_va)
    parent_interval = containing_selector_interval(intervals, parent_va) if parent_va is not None else None

    def interval_summary(entry: dict | None) -> dict | None:
        if entry is None:
            return None
        route_overlap = [field_map for field_map in entry.get("fieldMaps") or [] if field_map in route_maps]
        labels = entry.get("labels") or []
        field_maps = entry.get("fieldMaps") or []
        if current_selector in labels:
            classification = "current selector root interval"
        elif route["source"] in field_maps and route["target"] in field_maps:
            classification = "selector container with source and target field maps"
        elif route_overlap:
            classification = "selector container with route-related field maps"
        elif not field_maps:
            classification = "selector/data container without linked field maps"
        else:
            classification = "non-route selector container"
        return {
            "rootHex": entry.get("rootHex"),
            "endHex": entry.get("endHex"),
            "rangeHex": entry.get("rangeHex"),
            "selectorLabels": labels,
            "fieldMapCount": len(field_maps),
            "routeMapOverlap": route_overlap,
            "classification": classification,
        }

    candidate_summary = interval_summary(candidate_interval)
    parent_summary = interval_summary(parent_interval)
    current_range = None
    if current_interval is not None:
        current_range = {
            "start": current_interval["root"],
            "end": current_interval.get("end"),
            "rangeHex": current_interval.get("rangeHex"),
        }
    leaf_range = ranges["leafStreamRange"]
    scene_range = ranges["sceneRecordRange"]
    candidate_current_selector_hit = range_contains(current_range, candidate_va)
    parent_current_selector_hit = range_contains(current_range, parent_va)
    candidate_leaf_hit = range_contains(leaf_range, candidate_va)
    parent_leaf_hit = range_contains(leaf_range, parent_va)
    candidate_scene_hit = range_contains(scene_range, candidate_va)
    parent_scene_hit = range_contains(scene_range, parent_va)
    candidate_route_map_container_hit = bool((candidate_summary or {}).get("routeMapOverlap"))
    parent_route_map_container_hit = bool((parent_summary or {}).get("routeMapOverlap"))
    any_range_hit = any([
        candidate_current_selector_hit,
        parent_current_selector_hit,
        candidate_leaf_hit,
        parent_leaf_hit,
        candidate_scene_hit,
        parent_scene_hit,
        candidate_route_map_container_hit,
        parent_route_map_container_hit,
    ])
    return {
        "currentSelector": current_selector,
        "source": route["source"],
        "target": route["target"],
        "currentSelectorRootRangeHex": current_range.get("rangeHex") if current_range else None,
        "currentRouteLeafStreamRangeHex": leaf_range.get("rangeHex"),
        "currentRouteSceneRecordRangeHex": scene_range.get("rangeHex"),
        "candidateSelectorContainer": candidate_summary,
        "parentSelectorContainer": parent_summary,
        "candidateCurrentSelectorRootRangeHit": candidate_current_selector_hit,
        "parentCurrentSelectorRootRangeHit": parent_current_selector_hit,
        "candidateRouteLeafRangeHit": candidate_leaf_hit,
        "parentRouteLeafRangeHit": parent_leaf_hit,
        "candidateRouteSceneRecordRangeHit": candidate_scene_hit,
        "parentRouteSceneRecordRangeHit": parent_scene_hit,
        "candidateRouteMapContainerHit": candidate_route_map_container_hit,
        "parentRouteMapContainerHit": parent_route_map_container_hit,
        "anyCurrentRouteRangeHit": any_range_hit,
    }


def route_relevance_text(index_hits: dict, range_hits: dict) -> str:
    if index_hits["anyCurrentRouteIndexHit"]:
        return "linked by current selector/manifest indexes"
    if range_hits["candidateRouteLeafRangeHit"] or range_hits["parentRouteLeafRangeHit"]:
        return "inside current route leaf-stream range"
    if range_hits["candidateRouteSceneRecordRangeHit"] or range_hits["parentRouteSceneRecordRangeHit"]:
        return "inside current route scene-record range"
    if range_hits["candidateCurrentSelectorRootRangeHit"] or range_hits["parentCurrentSelectorRootRangeHit"]:
        return "inside current selector root range but not indexed"
    if range_hits["candidateRouteMapContainerHit"] or range_hits["parentRouteMapContainerHit"]:
        return "inside selector container with route-related field maps"
    candidate_container = range_hits.get("candidateSelectorContainer") or {}
    if candidate_container.get("fieldMapCount") == 0:
        return "outside current route ranges; selector container has no field-map links"
    return "outside current route ranges"


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


def section_for_va(sections: list[dict], va: int) -> dict | None:
    for section in sections:
        if section["va"] <= va < section["va"] + section["raw_size"]:
            return section
    return None


def va_for_offset(sections: list[dict], offset: int) -> int | None:
    section = section_for_offset(sections, offset)
    if section is None:
        return None
    return section["va"] + offset - section["raw"]


def pointer_refs(exe: bytes, sections: list[dict], target: int) -> list[dict]:
    needle = struct.pack("<I", target)
    rows = []
    search = 0
    while True:
        hit = exe.find(needle, search)
        if hit < 0:
            break
        search = hit + 1
        section = section_for_offset(sections, hit)
        if section is None:
            continue
        rows.append({
            "refVaHex": hex32(va_for_offset(sections, hit) or 0),
            "refSection": section["name"],
        })
    return rows


def route_text_indexes(
    leaf_streams: list[dict] | None,
    save_scene_selectors: list[dict] | None,
    scene_manifest: list[dict] | None,
) -> dict[str, str]:
    return {
        "saveSelectorLeafStreams": json.dumps(leaf_streams or [], ensure_ascii=False).lower(),
        "saveSceneSelectors": json.dumps(save_scene_selectors or [], ensure_ascii=False).lower(),
        "sceneManifest": json.dumps(scene_manifest or [], ensure_ascii=False).lower(),
    }


def route_hits(candidate_va_hex: str, indexes: dict[str, str]) -> dict:
    needle = candidate_va_hex.lower()
    hits = {name: needle in text for name, text in indexes.items()}
    return {
        **hits,
        "anyCurrentRouteIndexHit": any(hits.values()),
    }


def candidate_parent(exe: bytes, sections: list[dict], candidate_va: int) -> dict:
    section = section_for_va(sections, candidate_va)
    if section is None:
        return {
            "parentBlockStartHex": None,
            "parentPointerRefs": [],
            "localPointerRefCount": 0,
            "externalPointerRefCount": 0,
        }
    start = max(section["va"], candidate_va - PARENT_SCAN_BACK)
    end = min(section["va"] + section["raw_size"] - 4, candidate_va + PARENT_SCAN_FORWARD)
    best = None
    for target in range(start - (start % 4), end + 1, 4):
        refs = pointer_refs(exe, sections, target)
        if not refs:
            continue
        distance = abs(candidate_va - target)
        before_bonus = 0 if target <= candidate_va else 0x10000
        score = (before_bonus + distance, -len(refs), target)
        if best is None or score < best[0]:
            best = (score, target, refs)
    if best is None:
        return {
            "parentBlockStartHex": None,
            "parentPointerRefs": [],
            "localPointerRefCount": 0,
            "externalPointerRefCount": 0,
        }
    _, parent_va, refs = best
    local_refs = []
    external_refs = []
    for ref in refs:
        ref_va = parse_hex(ref.get("refVaHex")) or 0
        if abs(ref_va - candidate_va) <= LINK_RADIUS:
            local_refs.append(ref)
        else:
            external_refs.append(ref)
    return {
        "parentBlockStartHex": hex32(parent_va),
        "parentPointerRefs": refs,
        "localPointerRefCount": len(local_refs),
        "externalPointerRefCount": len(external_refs),
    }


def build_summary(
    exe: bytes,
    candidate_summary: dict | None = None,
    leaf_streams: list[dict] | None = None,
    save_scene_selectors: list[dict] | None = None,
    scene_manifest: list[dict] | None = None,
) -> dict:
    sections = read_sections(exe)
    candidate_summary = candidate_summary or build_candidate_summary(exe)
    indexes = route_text_indexes(leaf_streams, save_scene_selectors, scene_manifest)
    route = infer_route(leaf_streams)
    intervals = selector_intervals(save_scene_selectors)
    ranges = route_leaf_ranges(leaf_streams)
    linked = []
    for row in candidate_summary.get("candidates") or []:
        if row.get("confidence") != "medium":
            continue
        candidate_va = parse_hex(row.get("vaHex"))
        if candidate_va is None:
            continue
        hits = route_hits(row["vaHex"], indexes)
        parent = candidate_parent(exe, sections, candidate_va)
        parent_va = parse_hex(parent["parentBlockStartHex"])
        range_hits = route_range_context(candidate_va, parent_va, route, intervals, ranges)
        route_relevance = route_relevance_text(hits, range_hits)
        linked.append({
            "candidateVaHex": row["vaHex"],
            "indexHex": row["indexHex"],
            "handlerLabel": row["handlerLabel"],
            "confidence": row["confidence"],
            "classification": row["classification"],
            "parentBlockStartHex": parent["parentBlockStartHex"],
            "localPointerRefCount": parent["localPointerRefCount"],
            "externalPointerRefCount": parent["externalPointerRefCount"],
            "parentPointerRefs": parent["parentPointerRefs"],
            "routeIndexHits": hits,
            "routeRangeHits": range_hits,
            "routeRelevance": route_relevance,
            "nextStep": (
                "trace this linked block through the current route"
                if hits["anyCurrentRouteIndexHit"] or range_hits["anyCurrentRouteRangeHit"]
                else "exclude as current-route proof unless a runtime trace reaches this container"
            ),
        })
    linked.sort(key=lambda item: item["candidateVaHex"])
    linked_count = sum(1 for item in linked if item["routeIndexHits"]["anyCurrentRouteIndexHit"])
    range_hit_count = sum(1 for item in linked if item["routeRangeHits"]["anyCurrentRouteRangeHit"])
    current_root_hit_count = sum(
        1
        for item in linked
        if item["routeRangeHits"]["candidateCurrentSelectorRootRangeHit"]
        or item["routeRangeHits"]["parentCurrentSelectorRootRangeHit"]
    )
    leaf_range_hit_count = sum(
        1
        for item in linked
        if item["routeRangeHits"]["candidateRouteLeafRangeHit"]
        or item["routeRangeHits"]["parentRouteLeafRangeHit"]
    )
    scene_range_hit_count = sum(
        1
        for item in linked
        if item["routeRangeHits"]["candidateRouteSceneRecordRangeHit"]
        or item["routeRangeHits"]["parentRouteSceneRecordRangeHit"]
    )
    route_map_container_count = sum(
        1
        for item in linked
        if item["routeRangeHits"]["candidateRouteMapContainerHit"]
        or item["routeRangeHits"]["parentRouteMapContainerHit"]
    )
    empty_container_count = sum(
        1
        for item in linked
        if ((item["routeRangeHits"].get("candidateSelectorContainer") or {}).get("fieldMapCount") == 0)
    )
    return {
        "scope": "medium-confidence branch-state event/object candidates linked against current route indexes and ranges",
        "candidateSource": "event_object_branch_state_stream_candidates",
        "route": route,
        "currentSelectorRootRangeHex": (
            next(
                (
                    entry.get("rangeHex")
                    for entry in intervals
                    if route["selector"] in (entry.get("labels") or [])
                ),
                None,
            )
        ),
        "currentRouteLeafStreamRangeHex": ranges["leafStreamRange"].get("rangeHex"),
        "currentRouteSceneRecordRangeHex": ranges["sceneRecordRange"].get("rangeHex"),
        "mediumCandidateCount": len(linked),
        "currentRouteLinkedCount": linked_count,
        "currentRouteRangeHitCount": range_hit_count,
        "currentSelectorRootRangeCandidateCount": current_root_hit_count,
        "currentRouteLeafRangeCandidateCount": leaf_range_hit_count,
        "currentRouteSceneRecordRangeCandidateCount": scene_range_hit_count,
        "routeMapContainerCandidateCount": route_map_container_count,
        "emptySelectorContainerCandidateCount": empty_container_count,
        "conclusion": (
            "Medium-confidence raw event/object branch-state candidates are present, but neither the current "
            "selector/manifest indexes nor the current selector root, leaf-stream, scene-record, or route-map "
            "container ranges link them to the active save-selector route. Treat them as candidate event/object "
            "blocks, not as proof that the current web progression executes a branch-state writer."
        ),
        "candidates": linked,
    }


def markdown(summary: dict) -> str:
    lines = [
        "# Event/Object Branch State Candidate Links",
        "",
        "Medium-confidence event/object branch-state byte-pair candidates, checked against current route indexes and ranges.",
        "",
        f"- medium candidates: {summary['mediumCandidateCount']}",
        f"- linked by current route indexes: {summary['currentRouteLinkedCount']}",
        f"- current route range hits: {summary['currentRouteRangeHitCount']}",
        f"- current selector root range: `{summary.get('currentSelectorRootRangeHex') or '-'}`",
        f"- current route leaf stream range: `{summary.get('currentRouteLeafStreamRangeHex') or '-'}`",
        f"- current route scene record range: `{summary.get('currentRouteSceneRecordRangeHex') or '-'}`",
        f"- route-map selector containers: {summary['routeMapContainerCandidateCount']}",
        f"- empty selector containers: {summary['emptySelectorContainerCandidateCount']}",
        "",
        summary["conclusion"],
        "",
        "| candidate | index | parent block | selector container | local refs | external refs | route relevance | next step |",
        "| --- | --- | --- | --- | ---: | ---: | --- | --- |",
    ]
    for row in summary["candidates"]:
        container = (row.get("routeRangeHits") or {}).get("candidateSelectorContainer") or {}
        container_label = ",".join(container.get("selectorLabels") or []) or "-"
        container_bits = f"{container_label} `{container.get('rangeHex') or '-'}`"
        if container.get("fieldMapCount") == 0:
            container_bits += " no maps"
        elif container.get("routeMapOverlap"):
            container_bits += f" route maps {','.join(container.get('routeMapOverlap') or [])}"
        lines.append(
            f"| `{row['candidateVaHex']}` | `{row['indexHex']}` {row['handlerLabel']} | "
            f"`{row.get('parentBlockStartHex') or '-'}` | {container_bits} | {row['localPointerRefCount']} | "
            f"{row['externalPointerRefCount']} | {row['routeRelevance']} | {row['nextStep']} |"
        )
    lines.append("")
    return "\n".join(lines)


def html_page(summary: dict) -> str:
    rows = []
    for row in summary["candidates"]:
        refs = "<br>".join(html.escape(ref["refVaHex"]) for ref in row["parentPointerRefs"][:8]) or "-"
        if len(row["parentPointerRefs"]) > 8:
            refs += f"<br>+{len(row['parentPointerRefs']) - 8} more"
        container = (row.get("routeRangeHits") or {}).get("candidateSelectorContainer") or {}
        container_label = ", ".join(container.get("selectorLabels") or []) or "-"
        container_detail = (
            f"{html.escape(container_label)}<br>"
            f"<code>{html.escape(container.get('rangeHex') or '-')}</code><br>"
            f"{html.escape(container.get('classification') or '-')}"
        )
        if container.get("routeMapOverlap"):
            container_detail += f"<br>route maps: {html.escape(', '.join(container.get('routeMapOverlap') or []))}"
        rows.append(
            "<tr>"
            f"<td><code>{html.escape(row['candidateVaHex'])}</code></td>"
            f"<td><code>{html.escape(row['indexHex'])}</code><br>{html.escape(row['handlerLabel'])}</td>"
            f"<td>{html.escape(row['classification'])}</td>"
            f"<td><code>{html.escape(row.get('parentBlockStartHex') or '-')}</code></td>"
            f"<td>{container_detail}</td>"
            f"<td>{row['localPointerRefCount']}</td>"
            f"<td>{row['externalPointerRefCount']}</td>"
            f"<td>{refs}</td>"
            f"<td>{html.escape(row['routeRelevance'])}</td>"
            f"<td>{html.escape(row['nextStep'])}</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>Event/Object Branch State Candidate Links</title>",
        "  <style>",
        "    body { margin: 24px; background: #101010; color: #eee; font: 14px system-ui, sans-serif; }",
        "    table { border-collapse: collapse; width: 100%; margin-bottom: 24px; }",
        "    th, td { border: 1px solid #333; padding: 6px 8px; vertical-align: top; }",
        "    th { background: #1d1d1d; }",
        "    code { color: #f5d76e; }",
        "  </style>",
        "</head>",
        "<body>",
        "  <h1>Event/Object Branch State Candidate Links</h1>",
        f"  <p>Medium candidates: {summary['mediumCandidateCount']}; "
        f"linked by current route indexes: {summary['currentRouteLinkedCount']}; "
        f"current route range hits: {summary['currentRouteRangeHitCount']}.</p>",
        f"  <p>current selector root range: <code>{html.escape(summary.get('currentSelectorRootRangeHex') or '-')}</code>; "
        f"leaf stream range: <code>{html.escape(summary.get('currentRouteLeafStreamRangeHex') or '-')}</code>; "
        f"scene record range: <code>{html.escape(summary.get('currentRouteSceneRecordRangeHex') or '-')}</code>; "
        f"route-map selector containers: {summary['routeMapContainerCandidateCount']}; "
        f"empty selector containers: {summary['emptySelectorContainerCandidateCount']}.</p>",
        f"  <p>{html.escape(summary['conclusion'])}</p>",
        "  <table><thead><tr><th>candidate</th><th>index</th><th>classification</th><th>parent block</th><th>selector container</th><th>local refs</th><th>external refs</th><th>parent refs</th><th>route relevance</th><th>next step</th></tr></thead>",
        f"  <tbody>{''.join(rows)}</tbody></table>",
        "</body>",
        "</html>",
        "",
    ])


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


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


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--exe", type=Path, default=ROOT / "Hwanse2.exe")
    parser.add_argument("--out-dir", type=Path, default=OUT)
    args = parser.parse_args()
    exe = args.exe.read_bytes()
    candidate_summary = load_json(args.out_dir / "event_object_branch_state_stream_candidates.json", None)
    summary = build_summary(
        exe,
        candidate_summary,
        load_json(args.out_dir / "save_selector_leaf_streams.json", []),
        load_json(args.out_dir / "save_scene_selectors.json", []),
        load_json(args.out_dir / "scene_manifest.json", []),
    )
    write_outputs(summary, args.out_dir)
    print(f"wrote event/object branch-state candidate links -> {args.out_dir / 'event_object_branch_state_candidate_links.html'}")


if __name__ == "__main__":
    main()
