#!/usr/bin/env python3
"""Replay recorded LSP traces against clice to detect hangs and crashes.

Usage:
    python replay.py tests/smoke/session.jsonl --clice build/clice
"""

import argparse
import asyncio
import json
import os
import re
import signal
import sys
import time
from pathlib import Path
from urllib.parse import quote, unquote

REPO_ROOT = Path(__file__).resolve().parent.parent

SERVER_REQUEST_DEFAULTS: dict[str, object] = {
    "window/workDoneProgress/create": None,
    "client/registerCapability": None,
    "workspace/configuration": [{}],
}


def load_trace(path: Path) -> list[dict]:
    """Load a JSONL trace file. Each line: {"ts": <ms>, "msg": "<json>"}"""
    return [json.loads(line) for line in path.read_text().splitlines() if line.strip()]


def extract_original_workspace(records: list[dict]) -> str | None:
    for rec in records:
        parsed = json.loads(rec["msg"])
        if parsed.get("method") != "initialize":
            continue
        root_uri = parsed.get("params", {}).get("rootUri")
        if root_uri and root_uri.startswith("file://"):
            path = unquote(root_uri[len("file://") :])
            if len(path) > 2 and path[0] == "/" and path[2] == ":":
                path = path[1:]
            return path
    return None


def rewrite_workspace(original_ws: str) -> str | None:
    """Map recorded workspace path to the current repo location."""
    for marker in ("tests/data/", "tests/smoke/"):
        idx = original_ws.find(marker)
        if idx != -1:
            return str(REPO_ROOT / original_ws[idx:])
    return None


def rewrite_records(records: list[dict], original_ws: str, new_ws: str) -> list[dict]:
    """Text-level workspace path replacement in all messages.

    Backslashes in Windows paths must be doubled inside JSON strings.
    """
    new_ws_json = new_ws.replace("\\", "\\\\")
    original_encoded = quote(original_ws, safe="/:")
    new_encoded = quote(new_ws, safe="/:")
    new_encoded_json = new_encoded.replace("\\", "\\\\")
    rewritten = []
    for rec in records:
        msg = rec["msg"]
        msg = msg.replace(original_ws, new_ws_json)
        if original_encoded != original_ws:
            msg = msg.replace(original_encoded, new_encoded_json)
        rewritten.append({"ts": rec["ts"], "msg": msg})
    return rewritten


def print_trace_info(name: str, records: list[dict], workspace: str | None):
    methods: dict[str, int] = {}
    for rec in records:
        m = json.loads(rec["msg"]).get("method")
        if m:
            methods[m] = methods.get(m, 0) + 1
    print(f"--- {name} ---")
    print(f"  messages: {len(records)}")
    if workspace:
        print(f"  workspace: {workspace}")
    if records:
        print(f"  duration: {(records[-1]['ts'] - records[0]['ts']) / 1000.0:.1f}s")
    top = sorted(methods.items(), key=lambda x: -x[1])[:8]
    print(f"  methods: {', '.join(f'{m}({n})' for m, n in top)}")


async def read_lsp_message(reader: asyncio.StreamReader) -> dict | None:
    header = b""
    while True:
        line = await reader.readline()
        if not line:
            return None
        header += line
        if header.endswith(b"\r\n\r\n"):
            break
    match = re.search(rb"Content-Length:\s*(\d+)", header)
    if not match:
        return None
    return json.loads(await reader.readexactly(int(match.group(1))))


async def write_lsp_message(writer: asyncio.StreamWriter, payload: str):
    encoded = payload.encode("utf-8")
    writer.write(f"Content-Length: {len(encoded)}\r\n\r\n".encode("ascii") + encoded)
    await writer.drain()


async def replay_one(trace_path: Path, clice_bin: Path, timeout: int) -> bool | None:
    """Replay a single trace. Returns True=PASS, False=FAIL, None=SKIP."""
    records = load_trace(trace_path)
    if not records:
        print(f"SKIP: {trace_path.name} (empty)")
        return None

    original_ws = extract_original_workspace(records)
    display_ws = original_ws
    if original_ws is not None:
        new_ws = rewrite_workspace(original_ws)
        if new_ws and original_ws != new_ws:
            records = rewrite_records(records, original_ws, new_ws)
            display_ws = new_ws

    print_trace_info(trace_path.name, records, display_ws)

    env = dict(os.environ)
    if sys.platform == "darwin":
        prev = env.get("ASAN_OPTIONS", "")
        env["ASAN_OPTIONS"] = f"{prev}:detect_leaks=0" if prev else "detect_leaks=0"

    proc = await asyncio.create_subprocess_exec(
        str(clice_bin),
        "--mode",
        "pipe",
        env=env,
        stdin=asyncio.subprocess.PIPE,
        stdout=asyncio.subprocess.PIPE,
        stderr=asyncio.subprocess.PIPE,
    )

    pending: dict[int | str, asyncio.Future] = {}

    async def reader_loop():
        try:
            while True:
                msg = await read_lsp_message(proc.stdout)
                if msg is None:
                    break
                msg_id, method = msg.get("id"), msg.get("method")
                if msg_id is not None and method is not None:
                    resp = SERVER_REQUEST_DEFAULTS.get(method)
                    await write_lsp_message(
                        proc.stdin,
                        json.dumps({"jsonrpc": "2.0", "id": msg_id, "result": resp}),
                    )
                elif msg_id is not None:
                    fut = pending.pop(msg_id, None)
                    if fut and not fut.done():
                        fut.set_result(msg)
        except (asyncio.IncompleteReadError, ConnectionError, BrokenPipeError):
            pass
        finally:
            for fut in pending.values():
                if not fut.done():
                    fut.cancel()

    wall_start = time.monotonic()
    reader_task = asyncio.create_task(reader_loop())
    success = True
    last_method = None
    sent_count = 0

    try:
        for i, rec in enumerate(records):
            if i > 0:
                delay = rec["ts"] - records[i - 1]["ts"]
                if delay > 0:
                    await asyncio.sleep(delay / 1000.0)

            parsed = json.loads(rec["msg"])
            method = parsed.get("method")
            msg_id = parsed.get("id")
            last_method = method or last_method

            # Before shutdown/exit, wait for all pending responses
            if method in ("shutdown", "exit") and pending:
                try:
                    await asyncio.wait_for(
                        asyncio.gather(*pending.values(), return_exceptions=True),
                        timeout=timeout,
                    )
                except asyncio.TimeoutError:
                    elapsed = time.monotonic() - wall_start
                    print(
                        f"  result: HANG ({len(pending)} pending before {method}, {elapsed:.1f}s)"
                    )
                    success = False
                    break
                pending.clear()

            if msg_id is not None and method is not None:
                pending[msg_id] = asyncio.get_event_loop().create_future()

            await write_lsp_message(proc.stdin, rec["msg"])
            sent_count = i + 1

    except (ConnectionError, BrokenPipeError):
        elapsed = time.monotonic() - wall_start
        try:
            await asyncio.wait_for(proc.wait(), timeout=5.0)
        except asyncio.TimeoutError:
            proc.kill()
            await proc.wait()
        print(
            f"  result: CRASH (broken pipe at {last_method}, exit={proc.returncode},"
            f" sent={sent_count}/{len(records)}, {elapsed:.1f}s)"
        )
        success = False

    # Wait for remaining responses after all messages sent
    if success and pending:
        try:
            await asyncio.wait_for(
                asyncio.gather(*pending.values(), return_exceptions=True),
                timeout=timeout,
            )
        except asyncio.TimeoutError:
            elapsed = time.monotonic() - wall_start
            print(f"  result: HANG ({len(pending)} pending after {elapsed:.1f}s)")
            success = False

    if success:
        try:
            proc.stdin.close()
            await proc.stdin.wait_closed()
        except (ConnectionError, BrokenPipeError):
            pass

    try:
        await asyncio.wait_for(proc.wait(), timeout=10.0)
    except asyncio.TimeoutError:
        proc.kill()
        await proc.wait()

    reader_task.cancel()
    try:
        await reader_task
    except asyncio.CancelledError:
        pass

    stderr_data = await proc.stderr.read()
    elapsed = time.monotonic() - wall_start
    returncode = proc.returncode

    def print_stderr():
        if stderr_data:
            for line in stderr_data.decode("utf-8", errors="replace").splitlines()[
                -20:
            ]:
                print(f"  | {line}")

    if not success:
        print_stderr()
        return False

    if returncode and returncode != 0:
        sig = ""
        if returncode < 0:
            try:
                sig = f" ({signal.Signals(-returncode).name})"
            except (ValueError, AttributeError):
                pass
        print(f"  result: CRASH (exit={returncode}{sig}, {elapsed:.1f}s)")
        print_stderr()
        return False

    print(f"  result: PASS ({elapsed:.1f}s)")
    return True


async def async_main(args):
    passed = failed = skipped = 0
    for trace in args.traces:
        if not trace.exists():
            print(f"SKIP: {trace} (not found)")
            skipped += 1
            continue
        result = await replay_one(trace, args.clice, args.timeout)
        if result is None:
            skipped += 1
        elif result:
            passed += 1
        else:
            failed += 1

    total = passed + failed + skipped
    print(f"\n{passed}/{total} passed", end="")
    if skipped:
        print(f", {skipped} skipped", end="")
    if failed:
        print(f", {failed} FAILED", end="")
    print()
    return 1 if failed else 0


def main():
    p = argparse.ArgumentParser(description="Replay LSP traces against clice")
    p.add_argument("traces", nargs="+", type=Path, help="JSONL trace files")
    p.add_argument("--clice", required=True, type=Path, help="Path to clice binary")
    p.add_argument(
        "--timeout", type=int, default=120, help="Timeout in seconds (default: 120)"
    )
    args = p.parse_args()
    sys.exit(asyncio.run(async_main(args)))


if __name__ == "__main__":
    main()
