#!/usr/bin/env python3
"""Probe whether X events or key-buffer pokes can advance Hwanse2.exe."""
from __future__ import annotations

import argparse
import bisect
import json
import os
import shutil
import struct
import subprocess
import time
from pathlib import Path
from typing import Any


ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "out"
DEFAULT_PREFIX = Path.home() / ".cache" / "hwanse-wine32"
NULL_ALSA_CONFIG = ROOT / "tools" / "runtime_trace_null_alsa.conf"
IMAGE_BASE = 0x00400000
IMAGE_SIZE = 0x001BE000
SELECTED_POINTER_GLOBAL = 0x0059DE30
CURRENT_ROOT = 0x00540714
KEYBOARD_BUFFER = 0x0055B868
ROUTE_WATCH_VALUES = {
    "opcode24Mode1Source": (0x0059E348, 1),
    "opcode24RuntimeFlag": (0x0059E34D, 1),
    "opcode24CurrentObjectIndex": (0x0059E33E, 1),
}
KEYS = [
    ("Return", 0x1C),
    ("space", 0x39),
    ("z", 0x2C),
    ("Down", 0xD0),
    ("Escape", 0x01),
]


def load_selector_contexts() -> tuple[list[int], dict[int, dict]]:
    path = OUT / "save_scene_selectors.json"
    if not path.exists():
        return [], {}
    try:
        selectors = json.loads(path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError):
        return [], {}
    contexts: dict[int, dict] = {}
    for row in selectors:
        root = row.get("selectedPointer")
        if isinstance(root, int):
            contexts[root] = row
    return sorted(contexts), contexts


def selected_pointer_context(value_hex: str | None, roots: list[int], contexts: dict[int, dict]) -> dict[str, Any] | None:
    if not value_hex or not roots:
        return None
    value = int(value_hex, 16)
    index = bisect.bisect_right(roots, value) - 1
    if index < 0:
        return None
    root = roots[index]
    end = roots[index + 1] if index + 1 < len(roots) else root + 0x4000
    if value >= end:
        return None
    row = contexts[root]
    return {
        "selector": f"{row.get('group')}:{row.get('slot')}",
        "rootHex": hex32(root),
        "rootEndHex": hex32(end),
        "offsetHex": f"0x{value - root:04x}",
        "fieldMaps": row.get("fieldMaps") or [],
        "equalsCurrentRouteRoot": root == CURRENT_ROOT,
    }


def hex32(value: int | None) -> str | None:
    if value is None:
        return None
    return f"0x{value & 0xFFFFFFFF:08x}"


def truncate(text: str, limit: int = 3000) -> str:
    if len(text) <= limit:
        return text
    return text[:limit] + f"\n... truncated {len(text) - limit} bytes ..."


def run(cmd: list[str], env: dict[str, str], timeout: int = 5) -> dict:
    try:
        result = subprocess.run(
            cmd,
            cwd=ROOT,
            env=env,
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
            text=True,
            errors="replace",
            timeout=timeout,
        )
        return {
            "command": cmd,
            "status": result.returncode,
            "output": truncate(result.stdout),
        }
    except subprocess.TimeoutExpired as exc:
        output = (exc.stdout or "") + (exc.stderr or "")
        return {
            "command": cmd,
            "status": 124,
            "output": truncate(output),
        }
    except OSError as exc:
        return {
            "command": cmd,
            "status": 127,
            "output": str(exc),
        }


def env_for(prefix: Path, display: str | None = None) -> dict[str, str]:
    env = os.environ.copy()
    runtime_dir = Path("/tmp") / f"hwanse-runtime-{os.getuid()}"
    runtime_dir.mkdir(mode=0o700, exist_ok=True)
    try:
        runtime_dir.chmod(0o700)
    except OSError:
        pass
    env.update({
        "WINEPREFIX": str(prefix),
        "WINEARCH": "win32",
        "WINEDEBUG": "-all",
        "XDG_RUNTIME_DIR": str(runtime_dir),
    })
    if display:
        env["DISPLAY"] = display
    if NULL_ALSA_CONFIG.exists():
        env["ALSA_CONFIG_PATH"] = str(NULL_ALSA_CONFIG)
    return env


def find_free_display() -> str:
    for number in range(90, 1000):
        socket = Path(f"/tmp/.X11-unix/X{number}")
        if not socket.exists():
            return f":{number}"
    raise RuntimeError("no free X display number found")


def process_has_hwanse_mapping(pid: int) -> bool:
    maps_path = Path("/proc") / str(pid) / "maps"
    try:
        lines = maps_path.read_text(encoding="utf-8", errors="replace").splitlines()
    except OSError:
        return False
    return any(line.rsplit(maxsplit=1)[-1].endswith("/Hwanse2.exe") for line in lines if "/" in line)


def process_name_is_hwanse(pid: int) -> bool:
    proc_dir = Path("/proc") / str(pid)
    try:
        comm = (proc_dir / "comm").read_text(encoding="utf-8", errors="replace").strip()
    except OSError:
        comm = ""
    return comm.lower() == "hwanse2.exe"


def find_hwanse_pid() -> int | None:
    candidates: list[tuple[int, int]] = []
    output = subprocess.getoutput("pgrep -x Hwanse2.exe || true")
    for item in output.split():
        if item.isdigit():
            pid = int(item)
            candidates.append((0 if process_has_hwanse_mapping(pid) else 1, pid))
    for proc_dir in Path("/proc").iterdir():
        if not proc_dir.name.isdigit():
            continue
        pid = int(proc_dir.name)
        mapped = process_has_hwanse_mapping(pid)
        if mapped or process_name_is_hwanse(pid):
            candidates.append((0 if mapped else 2, pid))
    if not candidates:
        return None
    return min(set(candidates))[1]


def loaded_base(pid: int) -> int | None:
    maps_path = Path("/proc") / str(pid) / "maps"
    for line in maps_path.read_text(encoding="utf-8", errors="replace").splitlines():
        parts = line.split(maxsplit=5)
        if len(parts) < 6:
            continue
        start_text, _end_text = parts[0].split("-", 1)
        offset = int(parts[2], 16)
        path = parts[5]
        if offset == 0 and path.endswith("/Hwanse2.exe"):
            return int(start_text, 16)
    return None


def static_from_runtime(value: int | None, base: int) -> int | None:
    if value is None:
        return None
    if base <= value < base + IMAGE_SIZE:
        return IMAGE_BASE + (value - base)
    return None


def sample_process(pid: int, base: int) -> dict[str, Any]:
    try:
        with (Path("/proc") / str(pid) / "mem").open("r+b", buffering=0) as mem:
            mem.seek(base + (SELECTED_POINTER_GLOBAL - IMAGE_BASE))
            selected_runtime = struct.unpack("<I", mem.read(4))[0]
            selected_static = static_from_runtime(selected_runtime, base)
            mem.seek(base + (KEYBOARD_BUFFER - IMAGE_BASE))
            keyboard = mem.read(256)
            watch_values = {}
            for name, (static_va, size) in ROUTE_WATCH_VALUES.items():
                runtime_address = base + (static_va - IMAGE_BASE)
                mem.seek(runtime_address)
                raw = mem.read(size)
                value = int.from_bytes(raw, "little") if len(raw) == size else None
                watch_values[name] = {
                    "staticVaHex": hex32(static_va),
                    "runtimeAddressHex": hex32(runtime_address),
                    "size": size,
                    "valueHex": f"0x{value:0{size * 2}x}" if value is not None else None,
                    "value": value,
                }
    except OSError as exc:
        return {
            "readOk": False,
            "error": str(exc),
        }
    pressed = [index for index, value in enumerate(keyboard) if value & 0x80]
    return {
        "readOk": True,
        "selectedPointerRuntimeHex": hex32(selected_runtime),
        "selectedPointerStaticHex": hex32(selected_static),
        "selectedPointerEqualsCurrentRoot": selected_static == CURRENT_ROOT,
        "watchValues": watch_values,
        "pressedKeyOffsets": pressed[:32],
    }


def write_key_buffer(pid: int, base: int, key_offset: int, duration: float = 1.0) -> dict[str, Any]:
    address = base + (KEYBOARD_BUFFER - IMAGE_BASE) + key_offset
    try:
        with (Path("/proc") / str(pid) / "mem").open("r+b", buffering=0) as mem:
            deadline = time.time() + duration
            while time.time() < deadline:
                mem.seek(address)
                mem.write(b"\x80")
                time.sleep(0.01)
            mem.seek(address)
            mem.write(b"\x00")
    except OSError as exc:
        return {
            "writeOk": False,
            "error": str(exc),
        }
    return {
        "writeOk": True,
        "runtimeAddressHex": hex32(address),
    }


def parse_windows(search_outputs: list[str]) -> list[str]:
    windows = []
    seen = set()
    for output in search_outputs:
        for token in output.split():
            if token.isdigit() and token not in seen:
                windows.append(token)
                seen.add(token)
    return windows


def build_summary(wait_seconds: float, prefix: Path) -> dict[str, Any]:
    xdotool_path = shutil.which("xdotool")
    xwininfo_path = shutil.which("xwininfo")
    display = find_free_display()
    base_env = env_for(prefix, display)
    prefix.mkdir(parents=True, exist_ok=True)
    xvfb = subprocess.Popen(
        ["Xvfb", display, "-screen", "0", "1280x1024x24"],
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT,
        text=True,
        errors="replace",
    )
    time.sleep(1)
    wine = subprocess.Popen(
        ["wine", "explorer", "/desktop=hwanse,640x480", "Hwanse2.exe"],
        cwd=ROOT,
        env=base_env,
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT,
        text=True,
        errors="replace",
    )
    time.sleep(wait_seconds)
    pid = find_hwanse_pid()
    base = loaded_base(pid) if pid else None
    initial = sample_process(pid, base) if pid and base else {"readOk": False}
    class_search = run(["xdotool", "search", "--class", "hwanse2"], base_env) if xdotool_path else {"output": ""}
    name_search = run(["xdotool", "search", "--name", "hwanse"], base_env) if xdotool_path else {"output": ""}
    windows = parse_windows([class_search.get("output", ""), name_search.get("output", "")])
    window_rows = []
    x_event_rows = []
    held_key_rows = []
    if pid and base and xdotool_path:
        for window in windows[:8]:
            info = run(["xwininfo", "-id", window], base_env) if xwininfo_path else {"output": ""}
            window_rows.append({
                "window": window,
                "xwininfo": info.get("output", ""),
            })
            run(["xdotool", "windowactivate", "--sync", window], base_env)
            run(["xdotool", "windowfocus", window], base_env)
            run(["xdotool", "mousemove", "--window", window, "320", "240", "click", "1"], base_env)
            for key_name, _offset in KEYS[:4]:
                run(["xdotool", "keydown", "--window", window, "--clearmodifiers", key_name], base_env)
                time.sleep(0.25)
                held_sample = sample_process(pid, base)
                run(["xdotool", "keyup", "--window", window, "--clearmodifiers", key_name], base_env)
                held_key_rows.append({
                    "window": window,
                    "key": key_name,
                    "sampleDuringKeydown": held_sample,
                })
                run(["xdotool", "key", "--window", window, "--clearmodifiers", key_name], base_env)
                time.sleep(0.4)
                sample = sample_process(pid, base)
                x_event_rows.append({
                    "window": window,
                    "key": key_name,
                    "sample": sample,
                })
    poke_rows = []
    if pid and base:
        for key_name, key_offset in KEYS:
            write_result = write_key_buffer(pid, base, key_offset, duration=1.0)
            time.sleep(0.4)
            sample = sample_process(pid, base)
            poke_rows.append({
                "key": key_name,
                "keyOffsetHex": hex32(key_offset),
                "write": write_result,
                "sample": sample,
            })
    final_sample = sample_process(pid, base) if pid and base else {"readOk": False}
    run(["wineserver", "-k"], base_env, timeout=5)
    try:
        wine_output, _ = wine.communicate(timeout=5)
    except subprocess.TimeoutExpired:
        wine.kill()
        wine_output, _ = wine.communicate(timeout=5)
    xvfb.terminate()
    try:
        xvfb_output, _ = xvfb.communicate(timeout=5)
    except subprocess.TimeoutExpired:
        xvfb.kill()
        xvfb_output, _ = xvfb.communicate(timeout=5)
    selected_samples = [initial]
    selected_samples.extend((row.get("sampleDuringKeydown") or {}) for row in held_key_rows)
    selected_samples.extend((row.get("sample") or {}) for row in x_event_rows)
    selected_samples.extend((row.get("sample") or {}) for row in poke_rows)
    selected_samples.append(final_sample)
    baseline_selected = next(
        (
            sample.get("selectedPointerStaticHex")
            for sample in selected_samples
            if sample.get("selectedPointerStaticHex")
        ),
        None,
    )
    roots, contexts = load_selector_contexts()
    def sample_changed(sample: dict[str, Any]) -> bool:
        selected = sample.get("selectedPointerStaticHex")
        return bool(baseline_selected and selected and selected != baseline_selected)

    x_changed = any(sample_changed(row.get("sample") or {}) for row in x_event_rows)
    held_key_pressed = any((row.get("sampleDuringKeydown") or {}).get("pressedKeyOffsets") for row in held_key_rows)
    held_changed = any(sample_changed(row.get("sampleDuringKeydown") or {}) for row in held_key_rows)
    poke_changed = any(sample_changed(row.get("sample") or {}) for row in poke_rows)
    return {
        "objective": "bounded runtime input-path probe for original Hwanse2.exe",
        "display": display,
        "winePrefix": str(prefix),
        "xdotoolPath": xdotool_path,
        "xwininfoPath": xwininfo_path,
        "linuxPid": pid,
        "loadedBaseHex": hex32(base),
        "initialSample": initial,
        "baselineSelectedPointerStaticHex": baseline_selected,
        "baselineSelectedPointerContext": selected_pointer_context(baseline_selected, roots, contexts),
        "finalSample": final_sample,
        "finalSelectedPointerContext": selected_pointer_context(final_sample.get("selectedPointerStaticHex"), roots, contexts),
        "classSearch": class_search,
        "nameSearch": name_search,
        "windows": window_rows,
        "heldKeyRows": held_key_rows,
        "xEventRows": x_event_rows,
        "heldKeyPressedDetected": held_key_pressed,
        "heldKeyChangedSelectedPointer": held_changed,
        "keyBufferPokeRows": poke_rows,
        "xEventChangedSelectedPointer": x_changed,
        "keyBufferPokeChangedSelectedPointer": poke_changed,
        "startupOutput": truncate(wine_output or ""),
        "xvfbOutput": truncate(xvfb_output or ""),
        "promotionStatus": "blocked",
        "conclusion": (
            "The original process starts and exposes X windows plus readable memory. Held-key samples now separate "
            "whether Wine/DirectInput sees synthetic X keydown state from whether that input changes selected-pointer "
            "global 0x0059de30. This remains diagnostic only and does not promote the route blocker."
        ),
    }


def write_outputs(summary: dict[str, Any], out_dir: Path = OUT) -> None:
    out_dir.mkdir(parents=True, exist_ok=True)
    (out_dir / "runtime_input_path_probe.json").write_text(
        json.dumps(summary, ensure_ascii=False, separators=(",", ":")) + "\n",
        encoding="utf-8",
    )


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--wait", type=float, default=14.0)
    parser.add_argument("--prefix", type=Path, default=DEFAULT_PREFIX)
    parser.add_argument("--out-dir", type=Path, default=OUT)
    args = parser.parse_args()
    summary = build_summary(wait_seconds=args.wait, prefix=args.prefix)
    write_outputs(summary, args.out_dir)
    print(f"wrote runtime input path probe -> {args.out_dir / 'runtime_input_path_probe.json'}")


if __name__ == "__main__":
    main()
