#!/usr/bin/env python3
"""Trace Wine file syscalls while probing original EXE save-load input."""
from __future__ import annotations

import argparse
import json
import shutil
import subprocess
import sys
import time
from pathlib import Path
from typing import Any

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

from probe_runtime_input_path import (  # noqa: E402
    DEFAULT_PREFIX,
    ROOT,
    env_for,
    find_free_display,
    find_hwanse_pid,
    hex32,
    loaded_base,
    sample_process,
    truncate,
    write_key_buffer,
)
from probe_runtime_key_sequences import KEY_OFFSETS  # noqa: E402
from probe_runtime_selected_pointer_poll import focus_windows  # noqa: E402
from runtime_case_aliases import cleanup_case_aliases, prepare_case_aliases  # noqa: E402


OUT = ROOT / "out"
DEFAULT_SAVE_SOURCE = ROOT / "data" / "public_savedata" / "HandyHwanseEditor" / "bin" / "Debug" / "savedat2.dat"
DEFAULT_SEQUENCES = [
    ("load-down-enter", ["Down", "Return"]),
    ("load-down-z", ["Down", "z"]),
    ("load-down-space", ["Down", "space"]),
    ("load-return-down-enter", ["Return", "Down", "Return"]),
]


def parse_sequence(raw: str, index: int) -> tuple[str, list[str]]:
    if "=" in raw:
        name, key_text = raw.split("=", 1)
    else:
        name, key_text = f"custom-{index}", raw
    keys = [item.strip() for item in key_text.split(",") if item.strip()]
    if not keys:
        raise SystemExit(f"empty sequence: {raw!r}")
    missing = [key for key in keys if key not in KEY_OFFSETS]
    if missing:
        raise SystemExit(f"unknown key(s) in {name!r}: {', '.join(missing)}")
    return name, keys


def prepare_temporary_save(source: Path, slot: int) -> tuple[Path, dict[str, Any]]:
    save_dir = ROOT / "SaveData"
    target = save_dir / f"savedat{slot}.dat"
    created_dir = False
    if not save_dir.exists():
        save_dir.mkdir()
        created_dir = True
    if target.exists():
        raise SystemExit(f"{target.relative_to(ROOT)} already exists; aborting to avoid overwriting")
    shutil.copyfile(source, target)
    return target, {
        "source": str(source.relative_to(ROOT)),
        "target": str(target.relative_to(ROOT)),
        "createdDirectory": created_dir,
        "createdFile": True,
    }


def cleanup_temporary_save(target: Path, created_dir: bool) -> dict[str, Any]:
    removed_file = False
    removed_dir = False
    if target.exists():
        target.unlink()
        removed_file = True
    if created_dir:
        try:
            target.parent.rmdir()
            removed_dir = True
        except OSError:
            removed_dir = False
    return {
        "removedFile": removed_file,
        "removedDirectory": removed_dir,
    }


def matching_file_lines(output: str) -> list[str]:
    rows = []
    for line in output.splitlines():
        lower = line.lower()
        if "savedat" in lower or "savedata" in lower:
            rows.append(line)
    return rows


def summarize_matches(lines: list[str]) -> dict[str, Any]:
    lowered = [line.lower() for line in lines]
    return {
        "matchedLineCount": len(lines),
        "saveDataLineCount": sum("savedata" in line for line in lowered),
        "savedatLineCount": sum("savedat" in line for line in lowered),
        "savedat1DatLineCount": sum("savedat1.dat" in line for line in lowered),
        "savedat2DatLineCount": sum("savedat2.dat" in line for line in lowered),
        "hasSavedatAccess": any("savedat" in line for line in lowered),
        "hasSavedat1DatAccess": any("savedat1.dat" in line for line in lowered),
        "sampleLines": [truncate(line, 800) for line in lines[:80]],
        "sampleLinesTruncated": max(0, len(lines) - 80),
    }


def drive_keys(pid: int, base: int, keys: list[str], hold: float, gap: float) -> list[dict[str, Any]]:
    writes = []
    for key in keys:
        offset = KEY_OFFSETS[key]
        writes.append({
            "key": key,
            "keyOffsetHex": hex32(offset),
            "write": write_key_buffer(pid, base, offset, duration=hold),
        })
        time.sleep(gap)
    return writes


def run_sequence(
    name: str,
    keys: list[str],
    *,
    backend: str,
    startup_wait: float,
    hold: float,
    gap: float,
    prefix: Path,
) -> dict[str, Any]:
    display = find_free_display()
    env = env_for(prefix, display)
    if backend == "winedebug":
        env["WINEDEBUG"] = "+file"
    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)
    if backend == "strace":
        trace_cmd = [
            "strace",
            "-f",
            "-e",
            "trace=file",
            "-s",
            "320",
            "wine",
            "explorer",
            "/desktop=hwanse,640x480",
            "Hwanse2.exe",
        ]
        wine_cmd = trace_cmd
    else:
        trace_cmd = ["wine", "explorer", "/desktop=hwanse,640x480", "Hwanse2.exe"]
        wine_cmd = trace_cmd
    wine = subprocess.Popen(
        wine_cmd,
        cwd=ROOT,
        env=env,
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT,
        text=True,
        errors="replace",
    )
    time.sleep(startup_wait)
    pid = find_hwanse_pid()
    base = loaded_base(pid) if pid else None
    focus = focus_windows(env)
    before_sample = sample_process(pid, base) if pid and base else {}
    trace = wine
    attach_trace_started = False
    if backend == "strace-attach":
        trace_cmd = ["strace", "-f", "-e", "trace=file", "-s", "320", "-p", str(pid)] if pid else []
        if pid:
            trace = subprocess.Popen(
                trace_cmd,
                cwd=ROOT,
                env=env,
                stdout=subprocess.PIPE,
                stderr=subprocess.STDOUT,
                text=True,
                errors="replace",
            )
            attach_trace_started = True
            time.sleep(0.8)
    writes = drive_keys(pid, base, keys, hold, gap) if pid and base else []
    time.sleep(1)
    after_sample = sample_process(pid, base) if pid and base else {}
    if backend == "strace-attach" and attach_trace_started and trace.poll() is None:
        trace.terminate()
        try:
            trace_output, _ = trace.communicate(timeout=5)
        except subprocess.TimeoutExpired:
            trace.kill()
            trace_output, _ = trace.communicate(timeout=5)
    else:
        trace_output = None
    try:
        kill_result = subprocess.run(
            ["wineserver", "-k"],
            cwd=ROOT,
            env=env,
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
            text=True,
            errors="replace",
            timeout=15,
        )
        wine_server_kill = {
            "status": kill_result.returncode,
            "timedOut": False,
            "output": truncate(kill_result.stdout or ""),
        }
    except subprocess.TimeoutExpired as exc:
        wine_server_kill = {
            "status": 124,
            "timedOut": True,
            "output": truncate((exc.stdout or "") + (exc.stderr or "")),
        }
    if trace_output is None:
        try:
            trace_output, _ = trace.communicate(timeout=8)
        except subprocess.TimeoutExpired:
            trace.kill()
            trace_output, _ = trace.communicate(timeout=8)
    wine_output = ""
    if backend == "strace-attach":
        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)
    matched = matching_file_lines(trace_output or "")
    match_summary = summarize_matches(matched)
    return {
        "name": name,
        "keys": keys,
        "display": display,
        "linuxPid": pid,
        "loadedBaseHex": hex32(base),
        **focus,
        "beforeSample": before_sample,
        "afterSample": after_sample,
        "writes": writes,
        "traceCommand": trace_cmd,
        "launchCommand": wine_cmd,
        "traceBackend": backend,
        "attachTraceStarted": attach_trace_started,
        "traceLineCount": len((trace_output or "").splitlines()),
        "traceOutputSample": truncate(trace_output or "", 5000),
        "wineOutputSample": truncate(wine_output or "", 3000),
        "wineServerKill": wine_server_kill,
        **match_summary,
        "xvfbOutput": truncate(xvfb_output or ""),
    }


def build_summary(args: argparse.Namespace) -> dict[str, Any]:
    if args.sequence:
        sequences = [parse_sequence(raw, index) for index, raw in enumerate(args.sequence, start=1)]
    else:
        sequences = DEFAULT_SEQUENCES
    case_aliases = prepare_case_aliases(ROOT, args.case_aliases)
    target, save_info = prepare_temporary_save(args.save_source, args.slot)
    cleanup: dict[str, Any] = {}
    alias_cleanup: dict[str, Any] = {}
    try:
        rows = [
            run_sequence(
                name,
                keys,
                backend=args.backend,
                startup_wait=args.startup_wait,
                hold=args.hold,
                gap=args.gap,
                prefix=args.prefix,
            )
            for name, keys in sequences
        ]
    finally:
        cleanup = cleanup_temporary_save(target, save_info["createdDirectory"])
        alias_cleanup = cleanup_case_aliases(ROOT, case_aliases)
    any_savedat = any(row.get("hasSavedatAccess") for row in rows)
    any_slot = any(row.get("hasSavedat1DatAccess") for row in rows)
    total_matched = sum(row.get("matchedLineCount", 0) for row in rows)
    with_pid = sum(1 for row in rows if row.get("linuxPid"))
    with_writes = sum(
        1
        for row in rows
        if any((write.get("write") or {}).get("writeOk") for write in row.get("writes") or [])
    )
    input_trace_usable = with_pid == len(rows) and with_writes == len(rows)
    if input_trace_usable:
        conclusion = (
            f"Placed {save_info['target']} from {save_info['source']} and traced Wine file activity with "
            f"{args.backend} while driving {len(rows)} load-menu candidate sequence(s). savedat access "
            f"observed={any_savedat}; savedat1.dat access observed={any_slot}. This checks whether the current "
            "input attempts reach a real file I/O path; it does not prove selector 2:0 or route promotion."
        )
    else:
        conclusion = (
            f"Placed {save_info['target']} from {save_info['source']} and tried to trace Wine file activity with "
            f"{args.backend}, but only {with_pid}/{len(rows)} run(s) exposed a Hwanse2.exe PID and "
            f"{with_writes}/{len(rows)} run(s) accepted key-buffer writes before shutdown. Therefore the absent "
            "savedat syscall lines are backend-feasibility evidence, not proof that the original load menu rejected "
            "or skipped the save file."
        )
    return {
        "objective": "runtime file-I/O trace while probing original EXE SaveData load-menu input",
        "backend": args.backend,
        "temporarySave": save_info,
        "caseAliases": case_aliases,
        "caseAliasCleanup": alias_cleanup,
        "cleanup": cleanup,
        "startupWaitSeconds": args.startup_wait,
        "holdSeconds": args.hold,
        "gapSeconds": args.gap,
        "sequenceCount": len(rows),
        "sequenceWithPidCount": with_pid,
        "sequenceWithKeyWritesCount": with_writes,
        "inputTraceUsable": input_trace_usable,
        "matchedLineCount": total_matched,
        "anySavedatAccess": any_savedat,
        "anySavedat1DatAccess": any_slot,
        "promotionStatus": "blocked",
        "rows": rows,
        "conclusion": conclusion,
    }


def markdown(summary: dict[str, Any]) -> str:
    lines = [
        "# Runtime Save File I/O Probe",
        "",
        f"- objective: {summary.get('objective')}",
        f"- backend: `{summary.get('backend')}`",
        f"- temporary save: `{summary.get('temporarySave', {}).get('target')}`",
        f"- source save: `{summary.get('temporarySave', {}).get('source')}`",
        f"- sequence count: {summary.get('sequenceCount')}",
        f"- sequence with Hwanse2.exe PID: {summary.get('sequenceWithPidCount')}",
        f"- sequence with key-buffer writes: {summary.get('sequenceWithKeyWritesCount')}",
        f"- input trace usable: {summary.get('inputTraceUsable')}",
        f"- matched file-I/O lines: {summary.get('matchedLineCount')}",
        f"- any savedat access: {summary.get('anySavedatAccess')}",
        f"- any savedat1.dat access: {summary.get('anySavedat1DatAccess')}",
        f"- cleanup: file removed={summary.get('cleanup', {}).get('removedFile')}; directory removed={summary.get('cleanup', {}).get('removedDirectory')}",
        f"- case aliases: enabled={summary.get('caseAliases', {}).get('enabled')}; removed={','.join(summary.get('caseAliasCleanup', {}).get('removedAliases') or []) or '-'}",
        f"- promotion status: `{summary.get('promotionStatus')}`",
        "",
        summary.get("conclusion") or "",
        "",
        "## Sequences",
        "",
        "| name | keys | trace lines | matched | savedat | savedat1.dat | before selected | after selected |",
        "| --- | --- | ---: | ---: | ---: | ---: | --- | --- |",
    ]
    for row in summary.get("rows") or []:
        before = row.get("beforeSample") or {}
        after = row.get("afterSample") or {}
        lines.append(
            f"| `{row.get('name')}` | `{','.join(row.get('keys') or [])}` | {row.get('traceLineCount')} | "
            f"{row.get('matchedLineCount')} | {row.get('savedatLineCount')} | {row.get('savedat1DatLineCount')} | "
            f"`{before.get('selectedPointerStaticHex') or '-'}` | `{after.get('selectedPointerStaticHex') or '-'}` |"
        )
    lines.extend(["", "## Matched Lines", ""])
    for row in summary.get("rows") or []:
        lines.append(f"### {row.get('name')}")
        if not row.get("sampleLines"):
            lines.append("- no SaveData/savedat syscall lines matched")
        for line in row.get("sampleLines") or []:
            lines.append(f"- `{line}`")
        if row.get("sampleLinesTruncated"):
            lines.append(f"- ... {row.get('sampleLinesTruncated')} more matched line(s)")
        lines.append("")
    return "\n".join(lines)


def write_outputs(summary: dict[str, Any], out_dir: Path, output_prefix: str) -> None:
    out_dir.mkdir(parents=True, exist_ok=True)
    (out_dir / f"{output_prefix}.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("--save-source", type=Path, default=DEFAULT_SAVE_SOURCE)
    parser.add_argument("--slot", type=int, default=1)
    parser.add_argument("--startup-wait", type=float, default=18.0)
    parser.add_argument("--hold", type=float, default=0.35)
    parser.add_argument("--gap", type=float, default=0.45)
    parser.add_argument("--sequence", action="append", default=[])
    parser.add_argument("--backend", choices=["winedebug", "strace", "strace-attach"], default="winedebug")
    parser.add_argument(
        "--case-aliases",
        action="store_true",
        help="temporarily symlink original uppercase archives to the mixed/lowercase names embedded in Hwanse2.exe",
    )
    parser.add_argument("--prefix", type=Path, default=DEFAULT_PREFIX)
    parser.add_argument("--out-dir", type=Path, default=OUT)
    parser.add_argument("--output-prefix", default="runtime_save_file_io_probe")
    args = parser.parse_args()
    summary = build_summary(args)
    write_outputs(summary, args.out_dir, args.output_prefix)
    print(f"wrote runtime save file I/O probe -> {args.out_dir / (args.output_prefix + '.json')}")


if __name__ == "__main__":
    main()
