#!/usr/bin/env -S uv run --script
"""Smoke test all Z-machine games.

Runs each game through a series of basic commands, collecting opcode coverage
and detecting crashes.
"""

# ruff: noqa: E402
import contextlib
import sys
from collections import Counter
from dataclasses import dataclass
from pathlib import Path

project_root = Path(__file__).parent.parent
sys.path.insert(0, str(project_root / "src"))

from mudlib.zmachine import ZMachine, zopdecoder, zstream, zui
from mudlib.zmachine.trivialzui import (
    TrivialAudio,
    TrivialFilesystem,
    TrivialScreen,
)
from mudlib.zmachine.zcpu import (
    ZCpuNotImplemented,
    ZCpuQuit,
    ZCpuRestart,
)


class AutoInputStream(zstream.ZInputStream):
    """Input stream that auto-feeds commands."""

    def __init__(self, commands=None):
        super().__init__()
        self._commands = commands or ["look", "inventory", "north", "south", "look"]
        self._input_count = 0

    def read_line(self, *args, **kwargs):
        if self._input_count >= len(self._commands):
            raise ZCpuQuit
        cmd = self._commands[self._input_count]
        self._input_count += 1
        return cmd

    def read_char(self, *args, **kwargs):
        if self._input_count >= len(self._commands):
            raise ZCpuQuit
        cmd = self._commands[self._input_count]
        self._input_count += 1
        # Return first character as ord
        return ord(cmd[0]) if cmd else ord(" ")


@dataclass
class TestResult:
    """Result of testing a single game."""

    filename: str
    version: int
    success: bool
    steps: int
    unique_opcodes: int
    opcodes: Counter
    error_type: str | None = None
    error_message: str | None = None
    error_pc: int | None = None
    error_opcode: str | None = None


def test_game(story_path: Path, max_steps: int = 1_000_000) -> TestResult:
    """Test a single game, returning results."""
    story_bytes = story_path.read_bytes()
    version = story_bytes[0]

    audio = TrivialAudio()
    screen = TrivialScreen()
    keyboard = AutoInputStream()
    filesystem = TrivialFilesystem()
    ui = zui.ZUI(audio, screen, keyboard, filesystem)

    zm = ZMachine(story_bytes, ui)

    opcodes_seen = Counter()
    step_count = 0
    error_type = None
    error_message = None
    error_pc = None
    error_opcode = None

    try:
        while step_count < max_steps:
            pc = zm._cpu._opdecoder.program_counter
            (opcode_class, opcode_number, operands) = (
                zm._cpu._opdecoder.get_next_instruction()
            )

            cls_str = zopdecoder.OPCODE_STRINGS.get(opcode_class, f"?{opcode_class}")
            key = f"{cls_str}:{opcode_number:02x}"

            try:
                implemented, func = zm._cpu._get_handler(opcode_class, opcode_number)
            except Exception as e:
                error_type = "ILLEGAL"
                error_message = str(e)
                error_pc = pc
                error_opcode = key
                break

            opcodes_seen[f"{key} ({func.__name__})"] += 1

            if not implemented:
                error_type = "UNIMPLEMENTED"
                error_message = f"Opcode {key} -> {func.__name__}"
                error_pc = pc
                error_opcode = key
                break

            try:
                func(zm._cpu, *operands)
            except ZCpuQuit:
                break
            except ZCpuRestart:
                break
            except ZCpuNotImplemented as e:
                error_type = "NOT_IMPLEMENTED"
                error_message = str(e)
                error_pc = pc
                error_opcode = key
                break
            except Exception as e:
                error_type = type(e).__name__
                error_message = str(e)
                error_pc = pc
                error_opcode = key
                # Dump trace for debugging
                with contextlib.suppress(Exception):
                    zm._cpu._dump_trace()
                break

            step_count += 1

    except KeyboardInterrupt:
        error_type = "INTERRUPTED"
        error_message = "Keyboard interrupt"

    success = error_type is None
    return TestResult(
        filename=story_path.name,
        version=version,
        success=success,
        steps=step_count,
        unique_opcodes=len(opcodes_seen),
        opcodes=opcodes_seen,
        error_type=error_type,
        error_message=error_message,
        error_pc=error_pc,
        error_opcode=error_opcode,
    )


def main():
    """Run smoke tests on all games or a specified game."""
    stories_dir = project_root / "content" / "stories"

    if len(sys.argv) > 1:
        # Test a specific game
        story_path = Path(sys.argv[1])
        if not story_path.exists():
            print(f"ERROR: {story_path} not found")
            sys.exit(1)
        story_paths = [story_path]
    else:
        # Test all games
        story_paths = sorted(stories_dir.glob("*.z[358]"))

    if not story_paths:
        print("No story files found")
        sys.exit(1)

    print(f"Testing {len(story_paths)} games...")
    print()

    results = []
    for story_path in story_paths:
        print(f"Testing {story_path.name}...", end=" ", flush=True)
        result = test_game(story_path)
        results.append(result)

        if result.success:
            print(f"OK ({result.steps} steps, {result.unique_opcodes} opcodes)")
        else:
            print(f"FAILED: {result.error_type}")
            if result.error_opcode:
                print(f"  Opcode: {result.error_opcode}")
            if result.error_pc is not None:
                print(f"  PC: {result.error_pc:#x}")
            if result.error_message:
                print(f"  Message: {result.error_message}")
        print()

    # Print summary table
    print()
    print("=" * 80)
    print("SUMMARY")
    print("=" * 80)
    print()
    print(
        f"{'Game':<20} {'Ver':<4} {'Status':<12} "
        f"{'Steps':>8} {'Opcodes':>8} {'Error':<20}"
    )
    print("-" * 80)

    for result in results:
        status = "PASS" if result.success else "FAIL"
        error = result.error_type or "-"
        print(
            f"{result.filename:<20} {result.version:<4} {status:<12} "
            f"{result.steps:>8} {result.unique_opcodes:>8} {error:<20}"
        )

    print()
    passed = sum(1 for r in results if r.success)
    failed = len(results) - passed
    print(f"Total: {len(results)} games, {passed} passed, {failed} failed")

    # Print detailed error information
    if failed > 0:
        print()
        print("=" * 80)
        print("FAILED GAMES DETAILS")
        print("=" * 80)
        for result in results:
            if not result.success:
                print()
                print(f"{result.filename} (V{result.version}):")
                print(f"  Error type: {result.error_type}")
                print(f"  Error message: {result.error_message}")
                if result.error_pc is not None:
                    print(f"  PC: {result.error_pc:#x}")
                if result.error_opcode:
                    print(f"  Opcode: {result.error_opcode}")
                print(f"  Steps completed: {result.steps}")
                print(f"  Unique opcodes seen: {result.unique_opcodes}")


if __name__ == "__main__":
    main()
