"""CLI commands for the PROTOCOL game.

PROTOCOL is a deterministic CLI game where teams of agents must infer
a hidden cryptographic-style rule from partial observations.

Unlike cards/chess engines that use tokens, PROTOCOL operates on a
state file specified via --state flag.
"""

from __future__ import annotations

import json
import os
from pathlib import Path
from typing import Any

import typer

from purefn.games.protocol.channels import apply_channel_from_station
from purefn.games.protocol.drift import generate_drift_schedule
from purefn.games.protocol.encoding import decode_byte, encode_byte
from purefn.games.protocol.engine import (
    PROFILE_CONFIGS,
    Profile,
    ProtocolCore,
    create_protocol_core,
    create_protocol_core_at_turn,
    get_protocol_core_from_oracle,
    serialize_oracle,
)
from purefn.games.protocol.map import generate_map, is_gate_blocking_movement
from purefn.games.protocol.resources import (
    apply_all_credit_regeneration,
    check_expedition_failed,
    check_expedition_success,
    check_gate_attempts,
    check_probe_credits,
    consume_gate_attempt,
    consume_probe_credit,
    hamming_distance,
    unlock_gate,
)

app = typer.Typer(
    help="""PROTOCOL - Distributed protocol inference under drift.

A deterministic game where teams of scouts probe labs to infer hidden
cryptographic-style rules and unlock gates to reach the GOAL.

QUICK START:
  export PROTOCOL_SECRET_KEY="your_secret"
  purefn protocol init --seed 42 --team alpha,beta --profile DEV
  purefn protocol status --format compact
  purefn protocol act --scout alpha look
  purefn protocol act --scout alpha move HALL_S1_01

KEY CONCEPTS:
  - Labs: probe to get compressed observations (1-4 bits)
  - Gates: respond with computed byte to unlock
  - Drift: hidden parameter changes require re-inference

See docs/protocol.md for full documentation."""
)

DEFAULT_STATE_PATH = "./protocol_state.json"


def _get_secret_key(game_state: dict[str, Any] | None = None) -> str:
    """Get secret key from environment variable.

    If game_state contains an embedded oracle (_oracle key), returns an empty
    string since the secret key is not needed for oracle-based computation.

    Args:
        game_state: Optional game state dict. If it contains _oracle,
                   secret key is not required.

    Returns:
        Secret key string, or empty string if oracle is embedded

    Raises:
        typer.Exit: If PROTOCOL_SECRET_KEY not set and no oracle embedded
    """
    # If oracle is embedded, secret key is not needed
    if game_state and "_oracle" in game_state:
        return ""

    key = os.environ.get("PROTOCOL_SECRET_KEY")
    if not key:
        _output_error("MISSING_SECRET_KEY", "PROTOCOL_SECRET_KEY environment variable not set")
        raise typer.Exit(code=1)
    return key


def _output_success(turn: int, **fields: Any) -> None:
    """Output a success JSON response.

    Args:
        turn: Current turn number
        **fields: Additional fields to include
    """
    response: dict[str, Any] = {"ok": True, "turn": turn}
    response.update(fields)
    typer.echo(json.dumps(response))


def _output_error(code: str, message: str, **details: Any) -> None:
    """Output an error JSON response.

    Args:
        code: Error code (e.g., INVALID_ACTION)
        message: Human-readable error message
        **details: Additional error details
    """
    response: dict[str, Any] = {
        "ok": False,
        "error": {
            "code": code,
            "message": message,
            "details": details,
        },
    }
    typer.echo(json.dumps(response))


def _load_state(state_path: Path) -> dict[str, Any]:
    """Load state from file.

    Args:
        state_path: Path to state file

    Returns:
        State dictionary

    Raises:
        typer.Exit: If state file doesn't exist
    """
    if not state_path.exists():
        _output_error(
            "STATE_NOT_FOUND",
            f"State file not found: {state_path}",
            path=str(state_path),
        )
        raise typer.Exit(code=1)

    with open(state_path) as f:
        result: dict[str, Any] = json.load(f)
        return result


def _save_state(state_path: Path, state: dict[str, Any]) -> None:
    """Save state to file.

    Args:
        state_path: Path to state file
        state: State dictionary to save
    """
    with open(state_path, "w") as f:
        json.dump(state, f, indent=2)


@app.command()
def init(
    seed: int = typer.Option(..., help="Public seed for deterministic generation"),
    team: str = typer.Option(..., help="Comma-separated scout IDs (e.g., alpha,beta)"),
    profile: str = typer.Option("STANDARD", help="Difficulty profile: TINY, DEV, STANDARD, HARD"),
    state: str = typer.Option(DEFAULT_STATE_PATH, "--state", help="State file path"),
    embed_oracle: bool = typer.Option(
        False,
        "--embed-oracle",
        help="Embed oracle in state file (enables secret-free agent testing)",
    ),
) -> None:
    """Initialize a new PROTOCOL game.

    Creates a new state file with the specified seed, team, and profile.
    Requires PROTOCOL_SECRET_KEY environment variable to be set.

    Use --embed-oracle to create a state file that doesn't require the secret
    key for probe/respond actions. This is useful for agent testing where the
    test harness initializes the game with the secret, but agents play without it.

    Examples:
        PROTOCOL_SECRET_KEY=secret purefn protocol init --seed 12345 --team alpha,beta
        PROTOCOL_SECRET_KEY=secret purefn protocol init --seed 42 --team a,b,c --profile DEV
        PROTOCOL_SECRET_KEY=secret purefn protocol init --seed 42 --team alpha --embed-oracle
    """
    try:
        secret_key = _get_secret_key()

        # Parse team
        scout_ids = [s.strip() for s in team.split(",") if s.strip()]
        if not scout_ids:
            _output_error("INVALID_TEAM", "Team must contain at least one scout ID")
            raise typer.Exit(code=1)

        # Parse profile
        try:
            game_profile = Profile[profile.upper()]
        except KeyError:
            _output_error(
                "INVALID_PROFILE",
                f"Unknown profile: {profile}",
                valid_profiles=["TINY", "DEV", "STANDARD", "HARD"],
            )
            raise typer.Exit(code=1)

        # Create protocol core to derive parameters (validates inputs)
        core = create_protocol_core(secret_key, seed, game_profile)

        # Get profile configuration for resource settings
        profile_config = PROFILE_CONFIGS[game_profile]

        # Generate the map
        generated_map = generate_map(core.prng, game_profile)

        # Sample turn limit based on profile configuration
        if profile_config.turn_limit_min == profile_config.turn_limit_max:
            turn_limit = profile_config.turn_limit_min
        else:
            turn_limit = core.prng.rand_int(
                "turn_limit", profile_config.turn_limit_min, profile_config.turn_limit_max
            )

        # Initialize state (visible portion only)
        state_path = Path(state)
        game_state: dict[str, Any] = {
            "schema_version": "1.0",
            "seed": seed,
            "profile": game_profile.value,
            "turn": 0,
            "turn_limit": turn_limit,
            "team": {scout_id: "CAMP" for scout_id in scout_ids},
            "stations": generated_map.stations,
            "map": {"adjacency": generated_map.adjacency},
            "gates_required": generated_map.gates_required,
            "gates_unlocked": 0,
            "regen_every": profile_config.probe_regen_every,
        }

        # Optionally embed oracle for secret-free agent testing
        if embed_oracle:
            drift_schedule = generate_drift_schedule(
                core.prng, game_profile, core.template_type, turn_limit
            )
            game_state["_oracle"] = serialize_oracle(core, drift_schedule)

        _save_state(state_path, game_state)

        # Build hint for agents
        adjacent = generated_map.adjacency.get("CAMP", [])
        hint = {
            "next_steps": [
                "Use 'status --format compact' to see game state",
                "Use 'act --scout <id> look' to view current station",
                "Use 'act --scout <id> move <station>' to navigate",
            ],
            "all_scouts_at": "CAMP",
            "adjacent_to_camp": adjacent,
        }

        output_fields: dict[str, Any] = {
            "seed": seed,
            "profile": game_profile.value,
            "team": scout_ids,
            "state_path": str(state_path),
            "hint": hint,
        }
        if embed_oracle:
            output_fields["oracle_embedded"] = True

        _output_success(turn=0, **output_fields)

    except typer.Exit:
        raise
    except Exception as e:
        _output_error("INIT_FAILED", str(e))
        raise typer.Exit(code=1)


def _format_status_compact(game_state: dict[str, Any]) -> dict[str, Any]:
    """Format game state in compact agent-friendly format.

    Args:
        game_state: Full game state

    Returns:
        Compact status dictionary
    """
    # Build scouts dict with location and nearby credits
    scouts: dict[str, dict[str, Any]] = {}
    team = game_state.get("team", {})
    stations = game_state.get("stations", {})

    for scout_id, location in team.items():
        scout_info: dict[str, Any] = {"location": location}
        station = stations.get(location, {})
        if station.get("type", "").startswith("LAB_"):
            scout_info["credits"] = station.get("credits", 0)
        scouts[scout_id] = scout_info

    return {
        "turn": game_state.get("turn", 0),
        "max_turns": game_state.get("turn_limit", 80),
        "scouts": scouts,
        "gates_unlocked": game_state.get("gates_unlocked", 0),
        "gates_required": game_state.get("gates_required", 0),
        "profile": game_state.get("profile", "STANDARD"),
    }


@app.command()
def status(
    state: str = typer.Option(DEFAULT_STATE_PATH, "--state", help="State file path"),
    format: str = typer.Option("full", "--format", help="Output format: full, compact"),
) -> None:
    """Show current game status.

    Formats:
        full    - Complete game state (default)
        compact - Agent-friendly minimal output

    Examples:
        purefn protocol status
        purefn protocol status --format compact
        purefn protocol status --state ./runs/game1.json --format compact
    """
    try:
        state_path = Path(state)
        game_state = _load_state(state_path)

        format_lower = format.lower()
        if format_lower == "compact":
            compact = _format_status_compact(game_state)
            turn = compact.pop("turn")  # Extract turn from compact dict
            _output_success(turn=turn, **compact)
        elif format_lower == "full":
            _output_success(
                turn=game_state.get("turn", 0),
                state=game_state,
            )
        else:
            _output_error(
                "INVALID_FORMAT",
                f"Unknown format: {format}",
                valid_formats=["full", "compact"],
            )
            raise typer.Exit(code=1)

    except typer.Exit:
        raise
    except Exception as e:
        _output_error("STATUS_FAILED", str(e))
        raise typer.Exit(code=1)


@app.command(name="map")
def show_map(
    state: str = typer.Option(DEFAULT_STATE_PATH, "--state", help="State file path"),
) -> None:
    """Show map adjacency structure.

    Returns the station adjacency map.

    Examples:
        purefn protocol map
        purefn protocol map --state ./runs/game1.json
    """
    try:
        state_path = Path(state)
        game_state = _load_state(state_path)

        _output_success(
            turn=game_state.get("turn", 0),
            adjacency=game_state.get("map", {}).get("adjacency", {}),
        )

    except typer.Exit:
        raise
    except Exception as e:
        _output_error("MAP_FAILED", str(e))
        raise typer.Exit(code=1)


def _get_available_actions(station_obj: dict[str, Any]) -> list[str]:
    """Get available actions for a station type.

    Args:
        station_obj: Station object with 'type' field

    Returns:
        List of available action names
    """
    station_type = station_obj.get("type", "")

    # Base actions available everywhere
    actions = ["look", "move"]

    if station_type.startswith("LAB_"):
        actions.append("probe")
    elif station_type == "GATE":
        if not station_obj.get("unlocked", False):
            actions.append("respond")

    return actions


def _act_look(scout: str, game_state: dict[str, Any], current_station: str, turn: int) -> None:
    """Handle look action."""
    station_obj = game_state.get("stations", {}).get(current_station, {})
    adjacent = game_state.get("map", {}).get("adjacency", {}).get(current_station, [])
    available_actions = _get_available_actions(station_obj)
    _output_success(
        turn=turn,
        scout=scout,
        action="look",
        station=current_station,
        station_obj=station_obj,
        adjacent=adjacent,
        available_actions=available_actions,
    )


def _advance_turn_and_check_status(
    game_state: dict[str, Any], turn: int
) -> tuple[int, bool, str | None, bool]:
    """Advance turn and check expedition status.

    Args:
        game_state: Game state dictionary (modified in place)
        turn: Current turn number

    Returns:
        Tuple of (new_turn, failed, fail_reason, succeeded)
    """
    new_turn = turn + 1
    game_state["turn"] = new_turn
    regen_every = game_state.get("regen_every", 5)
    apply_all_credit_regeneration(game_state.get("stations", {}), new_turn, regen_every)

    failed, fail_reason = check_expedition_failed(game_state)
    succeeded = check_expedition_success(game_state)
    return new_turn, failed, fail_reason, succeeded


def _act_move(
    scout: str,
    game_state: dict[str, Any],
    current_station: str,
    turn: int,
    args: list[str],
    state_path: Path,
) -> None:
    """Handle move action."""
    if not args:
        _output_error("MISSING_ARGUMENT", "move requires a station_id argument")
        raise typer.Exit(code=1)

    target_station = args[0]
    adjacent = game_state.get("map", {}).get("adjacency", {}).get(current_station, [])

    if target_station not in adjacent:
        _output_error(
            "INVALID_MOVE",
            f"Cannot move from {current_station} to {target_station}",
            current=current_station,
            target=target_station,
            adjacent=adjacent,
        )
        raise typer.Exit(code=1)

    # Check if moving past a locked gate
    stations = game_state.get("stations", {})
    map_adjacency = game_state.get("map", {}).get("adjacency", {})
    blocked, blocking_gate = is_gate_blocking_movement(
        current_station, target_station, stations, map_adjacency
    )
    if blocked:
        _output_error(
            "GATE_BLOCKED",
            f"Cannot move past locked gate {blocking_gate}",
            current=current_station,
            target=target_station,
            blocking_gate=blocking_gate,
        )
        raise typer.Exit(code=1)

    game_state["team"][scout] = target_station

    new_turn, failed, fail_reason, succeeded = _advance_turn_and_check_status(game_state, turn)
    _save_state(state_path, game_state)

    # Get info about new station for HATEOAS hints
    target_station_obj = stations.get(target_station, {})
    new_adjacent = map_adjacency.get(target_station, [])
    available_actions = _get_available_actions(target_station_obj)

    response_fields: dict[str, Any] = {
        "scout": scout,
        "action": "move",
        "from": current_station,
        "to": target_station,
        "now_at": {
            "station": target_station,
            "type": target_station_obj.get("type", ""),
            "adjacent": new_adjacent,
            "available_actions": available_actions,
        },
    }

    if failed:
        response_fields["expedition_failed"] = True
        response_fields["fail_reason"] = fail_reason
    if succeeded:
        response_fields["expedition_success"] = True

    _output_success(turn=new_turn, **response_fields)


def _generate_verbose_hint(x: int, obs: dict[str, Any], station_type: str) -> str:
    """Generate a verbose hint explaining the observation.

    Args:
        x: Input value probed
        obs: Observation result dictionary
        station_type: Type of lab station (LAB_S1, LAB_S2, LAB_S3)

    Returns:
        Human-readable explanation of the observation
    """
    kind = obs.get("kind", "")
    stage_names = {"LAB_S1": "stage M", "LAB_S2": "stage A", "LAB_S3": "core"}
    stage = stage_names.get(station_type, "unknown")

    if kind == "hi4":
        value = obs.get("value", 0)
        return f"For input 0x{x:02X} at {stage}: upper nibble = 0x{value:X} = {value}"
    elif kind == "lo4":
        value = obs.get("value", 0)
        return f"For input 0x{x:02X} at {stage}: lower nibble = 0x{value:X} = {value}"
    elif kind == "parity":
        value = obs.get("value", 0)
        mask = obs.get("mask", "0xFF")
        return f"For input 0x{x:02X} at {stage}: parity(output & {mask}) = {value}"
    elif kind == "parity_pair":
        values = obs.get("values", [0, 0])
        masks = obs.get("masks", ["0x55", "0xAA"])
        return (
            f"For input 0x{x:02X} at {stage}: "
            f"parity(output & {masks[0]}) = {values[0]}, "
            f"parity(output & {masks[1]}) = {values[1]}"
        )
    elif kind == "popcount":
        value = obs.get("value", 0)
        mask = obs.get("mask", "0xFF")
        return f"For input 0x{x:02X} at {stage}: popcount(output & {mask}) = {value}"
    else:
        return f"For input 0x{x:02X} at {stage}: {kind} = {obs}"


def _validate_probe_at_lab(station_obj: dict[str, Any], current_station: str) -> None:
    """Validate probe can be performed at lab station."""
    station_type = station_obj.get("type", "")
    if not station_type.startswith("LAB_"):
        _output_error(
            "INVALID_ACTION",
            "probe is only valid at LAB stations",
            station_type=station_type,
            action="probe",
        )
        raise typer.Exit(code=1)

    can_probe, _ = check_probe_credits(station_obj)
    if not can_probe:
        _output_error(
            "NO_CREDITS",
            f"No probe credits remaining at {current_station}",
            station=current_station,
            credits=0,
            cap=station_obj.get("cap", 0),
        )
        raise typer.Exit(code=1)


def _compute_lab_observation(core: ProtocolCore, station_type: str, x: int) -> int:
    """Compute observation value based on lab stage.

    Args:
        core: Protocol core for computation
        station_type: Type of lab station (LAB_S1, LAB_S2, LAB_S3)
        x: Input byte value

    Returns:
        Observed value from the appropriate stage
    """
    if station_type == "LAB_S1":
        return core.compute_stage_m(x)
    elif station_type == "LAB_S2":
        return core.compute_stage_a(x)
    return core.compute_core(x)


def _parse_probe_arg(args: list[str]) -> int:
    """Parse probe argument to get input byte.

    Args:
        args: List of command arguments

    Returns:
        Decoded byte value

    Raises:
        typer.Exit: If argument missing or invalid
    """
    if not args:
        _output_error("MISSING_ARGUMENT", "probe requires a hex_byte argument (e.g., 0x3F)")
        raise typer.Exit(code=1)

    try:
        return decode_byte(args[0])
    except ValueError as e:
        _output_error("INVALID_HEX_BYTE", str(e))
        raise typer.Exit(code=1)


def _act_probe(
    scout: str,
    game_state: dict[str, Any],
    current_station: str,
    turn: int,
    args: list[str],
    state_path: Path,
    secret_key: str,
    verbose: bool = False,
) -> None:
    """Handle probe action."""
    x = _parse_probe_arg(args)

    station_obj = game_state.get("stations", {}).get(current_station, {})
    _validate_probe_at_lab(station_obj, current_station)
    consume_probe_credit(station_obj)

    # Compute observation using drift-adjusted core
    # Use embedded oracle if available (for secret-free agent testing)
    profile = Profile[game_state.get("profile", "STANDARD")]
    if "_oracle" in game_state:
        core = get_protocol_core_from_oracle(game_state["_oracle"], turn)
    else:
        seed = game_state.get("seed", 0)
        turn_limit = game_state.get("turn_limit", 80)
        core = create_protocol_core_at_turn(secret_key, seed, profile, turn, turn_limit)

    station_type = station_obj.get("type", "")
    observed_value = _compute_lab_observation(core, station_type, x)
    obs = apply_channel_from_station(observed_value, station_obj)

    new_turn, failed, fail_reason, succeeded = _advance_turn_and_check_status(game_state, turn)
    _save_state(state_path, game_state)

    response_fields: dict[str, Any] = {
        "scout": scout,
        "action": "probe",
        "station": current_station,
        "x": encode_byte(x),
        "obs": obs,
        "credits": {
            "remaining": station_obj.get("credits", 0),
            "cap": station_obj.get("cap", 6),
            "regen_every": station_obj.get("regen_every", 5),
        },
    }

    # Add verbose hint for DEV/TINY profiles only
    if verbose and profile in (Profile.TINY, Profile.DEV):
        response_fields["hint"] = _generate_verbose_hint(x, obs, station_type)

    if failed:
        response_fields["expedition_failed"] = True
        response_fields["fail_reason"] = fail_reason
    if succeeded:
        response_fields["expedition_success"] = True

    _output_success(turn=new_turn, **response_fields)


def _validate_respond_preconditions(station_obj: dict[str, Any], current_station: str) -> None:
    """Validate that respond can be performed at gate."""
    can_respond, _ = check_gate_attempts(station_obj)
    if not can_respond:
        if station_obj.get("unlocked", False):
            _output_error(
                "GATE_UNLOCKED",
                f"Gate {current_station} is already unlocked",
                station=current_station,
            )
        else:
            _output_error(
                "NO_ATTEMPTS",
                f"No gate attempts remaining at {current_station}",
                station=current_station,
                attempts=0,
            )
        raise typer.Exit(code=1)


def _compute_gate_feedback(profile: Profile, y: int, expected_y: int) -> dict[str, Any]:
    """Compute gate response feedback based on profile."""
    if profile == Profile.HARD:
        return {"mode": "binary"}
    distance = hamming_distance(y, expected_y)
    return {"mode": "hamming", "distance": distance}


def _act_respond(
    scout: str,
    game_state: dict[str, Any],
    current_station: str,
    turn: int,
    args: list[str],
    state_path: Path,
    secret_key: str,
) -> None:
    """Handle respond action."""
    if not args:
        _output_error("MISSING_ARGUMENT", "respond requires a hex_byte argument (e.g., 0x4D)")
        raise typer.Exit(code=1)

    try:
        y = decode_byte(args[0])
    except ValueError as e:
        _output_error("INVALID_HEX_BYTE", str(e))
        raise typer.Exit(code=1)

    station_obj = game_state.get("stations", {}).get(current_station, {})
    if station_obj.get("type", "") != "GATE":
        _output_error(
            "INVALID_ACTION",
            "respond is only valid at GATE stations",
            station_type=station_obj.get("type", ""),
            action="respond",
        )
        raise typer.Exit(code=1)

    _validate_respond_preconditions(station_obj, current_station)
    consume_gate_attempt(station_obj)

    # Compute expected response using protocol core with drift applied
    # Use embedded oracle if available (for secret-free agent testing)
    profile = Profile[game_state.get("profile", "STANDARD")]
    if "_oracle" in game_state:
        core = get_protocol_core_from_oracle(game_state["_oracle"], turn)
    else:
        seed = game_state.get("seed", 0)
        turn_limit = game_state.get("turn_limit", 80)
        core = create_protocol_core_at_turn(secret_key, seed, profile, turn, turn_limit)

    challenge = decode_byte(station_obj.get("challenge", "0x00"))
    salt = decode_byte(station_obj.get("salt", "0x00"))
    expected_y = core.compute_gate_output(challenge, salt)
    is_correct = y == expected_y

    feedback = _compute_gate_feedback(profile, y, expected_y)

    if is_correct:
        unlock_gate(station_obj)
        game_state["gates_unlocked"] = game_state.get("gates_unlocked", 0) + 1
    result = "pass" if is_correct else "fail"

    new_turn, failed, fail_reason, succeeded = _advance_turn_and_check_status(game_state, turn)
    _save_state(state_path, game_state)

    response_fields: dict[str, Any] = {
        "scout": scout,
        "action": "respond",
        "station": current_station,
        "challenge": encode_byte(challenge),
        "salt": encode_byte(salt),
        "y": encode_byte(y),
        "result": result,
        "feedback": feedback,
        "attempts_remaining": station_obj.get("attempts_remaining", 0),
        "unlocked": station_obj.get("unlocked", False),
    }

    if failed:
        response_fields["expedition_failed"] = True
        response_fields["fail_reason"] = fail_reason
    if succeeded:
        response_fields["expedition_success"] = True

    _output_success(turn=new_turn, **response_fields)


@app.command()
def act(
    scout: str = typer.Option(..., "--scout", help="Scout ID performing the action"),
    action: str = typer.Argument(..., help="Action: move, look, probe, respond"),
    args: list[str] = typer.Argument(None, help="Action arguments"),
    state: str = typer.Option(DEFAULT_STATE_PATH, "--state", help="State file path"),
    verbose: bool = typer.Option(
        False, "--verbose", help="Show verbose hints (probe only, DEV/TINY)"
    ),
) -> None:
    """Execute a scout action.

    Actions:
        move <station_id>  - Move to adjacent station (costs 1 turn)
        look               - View current station (free)
        probe <hex_byte>   - Probe at lab station (costs 1 turn)
        respond <hex_byte> - Respond at gate station (costs 1 turn)

    Examples:
        purefn protocol act --scout alpha move HALL_S1_01
        purefn protocol act --scout alpha look
        purefn protocol act --scout beta probe 0x3F
        purefn protocol act --scout beta probe 0x55 --verbose
        purefn protocol act --scout alpha respond 0x4D
    """
    try:
        state_path = Path(state)
        game_state = _load_state(state_path)

        team = game_state.get("team", {})
        if scout not in team:
            _output_error(
                "INVALID_SCOUT",
                f"Scout '{scout}' not in team",
                valid_scouts=list(team.keys()),
            )
            raise typer.Exit(code=1)

        current_station = team[scout]
        turn = game_state.get("turn", 0)
        action_lower = action.lower()
        args = args or []

        # Get secret key for probe/respond actions
        # Pass game_state to check for embedded oracle (secret not needed if oracle present)
        secret_key = ""
        if action_lower in ("probe", "respond"):
            secret_key = _get_secret_key(game_state)

        handlers = {
            "look": lambda: _act_look(scout, game_state, current_station, turn),
            "move": lambda: _act_move(scout, game_state, current_station, turn, args, state_path),
            "probe": lambda: _act_probe(
                scout, game_state, current_station, turn, args, state_path, secret_key, verbose
            ),
            "respond": lambda: _act_respond(
                scout, game_state, current_station, turn, args, state_path, secret_key
            ),
        }

        handler = handlers.get(action_lower)
        if handler is None:
            _output_error(
                "UNKNOWN_ACTION",
                f"Unknown action: {action}",
                valid_actions=list(handlers.keys()),
            )
            raise typer.Exit(code=1)

        handler()

    except typer.Exit:
        raise
    except Exception as e:
        _output_error("ACT_FAILED", str(e))
        raise typer.Exit(code=1)


@app.command(name="export")
def export_log(
    mode: str = typer.Option(..., "--mode", help="Export mode: agent or oracle"),
    out: str = typer.Option(..., "--out", help="Output file path"),
    state: str = typer.Option(DEFAULT_STATE_PATH, "--state", help="State file path"),
) -> None:
    """Export game logs.

    Modes:
        agent  - Export agent-visible log (observations, actions)
        oracle - Export oracle log (hidden truth) - requires PROTOCOL_ALLOW_ORACLE_EXPORT=1

    Examples:
        purefn protocol export --mode agent --out agent.jsonl
        PROTOCOL_ALLOW_ORACLE_EXPORT=1 purefn protocol export --mode oracle --out oracle.jsonl
    """
    try:
        state_path = Path(state)
        _ = _load_state(state_path)  # Validate state exists

        mode_lower = mode.lower()
        if mode_lower not in ("agent", "oracle"):
            _output_error(
                "INVALID_MODE",
                f"Unknown export mode: {mode}",
                valid_modes=["agent", "oracle"],
            )
            raise typer.Exit(code=1)

        if mode_lower == "oracle":
            if os.environ.get("PROTOCOL_ALLOW_ORACLE_EXPORT") != "1":
                _output_error(
                    "ORACLE_EXPORT_DENIED",
                    "Oracle export requires PROTOCOL_ALLOW_ORACLE_EXPORT=1",
                )
                raise typer.Exit(code=1)

        # Log export will be implemented in Phase 6
        # For now, create empty log file
        out_path = Path(out)
        with open(out_path, "w") as f:
            f.write("")  # Empty JSONL file

        game_state = _load_state(state_path)
        _output_success(
            turn=game_state.get("turn", 0),
            mode=mode_lower,
            out=str(out_path),
            events_written=0,  # Placeholder
        )

    except typer.Exit:
        raise
    except Exception as e:
        _output_error("EXPORT_FAILED", str(e))
        raise typer.Exit(code=1)


# Alias for subcommand registration
protocol_app = app
