"""Tests for the command system."""

from unittest.mock import AsyncMock, MagicMock

import pytest

from mudlib import commands
from mudlib.commands import CommandDefinition, look, movement
from mudlib.effects import active_effects, add_effect
from mudlib.player import Player
from mudlib.render.ansi import RESET
from mudlib.zone import Zone


@pytest.fixture(autouse=True)
def _clean_test_commands():
    """Snapshot and restore command registry to prevent test leakage."""
    snapshot = dict(commands._registry)
    yield
    commands._registry.clear()
    commands._registry.update(snapshot)


@pytest.fixture
def mock_writer():
    writer = MagicMock()
    writer.write = MagicMock()
    writer.drain = AsyncMock()
    return writer


@pytest.fixture
def mock_reader():
    return MagicMock()


@pytest.fixture
def test_zone():
    # Create a 100x100 zone filled with passable terrain
    terrain = [["." for _ in range(100)] for _ in range(100)]
    zone = Zone(
        name="testzone",
        width=100,
        height=100,
        toroidal=True,
        terrain=terrain,
        impassable=set(),  # All terrain is passable for tests
    )
    return zone


@pytest.fixture
def player(mock_reader, mock_writer, test_zone):
    p = Player(name="TestPlayer", x=5, y=5, reader=mock_reader, writer=mock_writer)
    p.location = test_zone
    test_zone._contents.append(p)
    return p


# Test command registration
def test_register_command():
    """Test that commands can be registered."""

    async def test_handler(player, args):
        pass

    commands.register(CommandDefinition("test", test_handler))
    assert "test" in commands._registry
    assert commands._registry["test"].handler is test_handler


def test_register_command_with_aliases():
    """Test that command aliases work."""

    async def test_handler(player, args):
        pass

    commands.register(CommandDefinition("testcmd", test_handler, aliases=["tc", "t"]))
    assert "testcmd" in commands._registry
    assert "tc" in commands._registry
    assert "t" in commands._registry
    assert commands._registry["testcmd"] is commands._registry["tc"]
    assert commands._registry["testcmd"] is commands._registry["t"]


@pytest.mark.asyncio
async def test_dispatch_routes_to_handler(player):
    """Test that dispatch routes input to the correct handler."""
    called = False
    received_args = None

    async def test_handler(p, args):
        nonlocal called, received_args
        called = True
        received_args = args

    commands.register(CommandDefinition("testcmd", test_handler))
    await commands.dispatch(player, "testcmd arg1 arg2")

    assert called
    assert received_args == "arg1 arg2"


@pytest.mark.asyncio
async def test_dispatch_handles_unknown_command(player, mock_writer):
    """Test that unknown commands give feedback."""
    await commands.dispatch(player, "unknowncommand")

    # Should have written some kind of error message
    assert mock_writer.write.called
    error_msg = mock_writer.write.call_args[0][0]
    assert "unknown" in error_msg.lower() or "not found" in error_msg.lower()


@pytest.mark.asyncio
async def test_dispatch_handles_empty_input(player):
    """Test that empty input doesn't crash."""
    await commands.dispatch(player, "")
    await commands.dispatch(player, "   ")


# Test movement direction parsing
@pytest.mark.parametrize(
    "direction,expected_delta",
    [
        ("n", (0, -1)),
        ("north", (0, -1)),
        ("s", (0, 1)),
        ("south", (0, 1)),
        ("e", (1, 0)),
        ("east", (1, 0)),
        ("w", (-1, 0)),
        ("west", (-1, 0)),
        ("ne", (1, -1)),
        ("northeast", (1, -1)),
        ("nw", (-1, -1)),
        ("northwest", (-1, -1)),
        ("se", (1, 1)),
        ("southeast", (1, 1)),
        ("sw", (-1, 1)),
        ("southwest", (-1, 1)),
    ],
)
def test_direction_deltas(direction, expected_delta):
    """Test that all direction commands map to correct deltas."""
    assert movement.DIRECTIONS[direction] == expected_delta


@pytest.mark.asyncio
async def test_movement_updates_position(player, test_zone):
    """Test that movement updates player position when passable."""
    # Clear players registry to avoid test pollution
    from mudlib.player import players

    players.clear()

    original_x, original_y = player.x, player.y
    await movement.move_north(player, "")

    assert player.x == original_x
    assert player.y == original_y - 1


@pytest.mark.asyncio
async def test_movement_blocked_by_impassable_terrain(player, test_zone, mock_writer):
    """Test that movement is blocked by impassable terrain."""
    # Make the target position impassable
    target_y = player.y - 1
    test_zone.terrain[target_y][player.x] = "^"  # mountain
    test_zone.impassable = {"^"}

    original_x, original_y = player.x, player.y
    await movement.move_north(player, "")

    # Position should not change
    assert player.x == original_x
    assert player.y == original_y

    # Should send a message to the player
    assert mock_writer.write.called
    error_msg = mock_writer.write.call_args[0][0]
    assert "can't" in error_msg.lower() or "cannot" in error_msg.lower()


@pytest.mark.asyncio
async def test_movement_sends_departure_message(player, test_zone):
    """Test that movement sends departure message to nearby players."""
    # Create another player in the area
    other_writer = MagicMock()
    other_writer.write = MagicMock()
    other_writer.drain = AsyncMock()
    other_player = Player(
        name="OtherPlayer", x=5, y=4, reader=MagicMock(), writer=other_writer
    )
    other_player.location = test_zone
    test_zone._contents.append(other_player)

    # Register both players
    from mudlib.player import players

    players.clear()
    players[player.name] = player
    players[other_player.name] = other_player

    await movement.move_north(player, "")

    # Other player should have received a departure message
    # (We'll check this is called, exact message format is implementation detail)
    assert other_player.writer.write.called


@pytest.mark.asyncio
async def test_arrival_message_uses_opposite_direction(player, test_zone):
    """Test that arrival messages use the opposite direction."""
    # Create another player at the destination
    other_writer = MagicMock()
    other_writer.write = MagicMock()
    other_writer.drain = AsyncMock()
    other_player = Player(
        name="OtherPlayer", x=5, y=3, reader=MagicMock(), writer=other_writer
    )
    other_player.location = test_zone
    test_zone._contents.append(other_player)

    from mudlib.player import players

    players.clear()
    players[player.name] = player
    players[other_player.name] = other_player

    # Player at (5, 5) moves north to (5, 4)
    await movement.move_north(player, "")

    # Other player at (5, 3) should see arrival "from the south"
    # (Implementation will determine exact message format)
    assert other_player.writer.write.called


# Test look command
@pytest.mark.asyncio
async def test_look_command_sends_viewport(player, test_zone):
    """Test that look command sends the viewport to the player."""
    await look.cmd_look(player, "")

    assert player.writer.write.called


@pytest.mark.asyncio
async def test_look_command_shows_player_at_center(player, test_zone):
    """Test that look command shows player @ at center."""
    await look.cmd_look(player, "")

    # Check that the output contains the @ symbol for the player
    output = "".join([call[0][0] for call in player.writer.write.call_args_list])
    assert "@" in output


@pytest.mark.asyncio
async def test_look_command_shows_other_players(player, test_zone):
    """Test that look command shows other players as *."""
    # Create another player in the viewport
    other_player = Player(
        name="OtherPlayer",
        x=6,
        y=5,
        reader=MagicMock(),
        writer=MagicMock(),
    )
    other_player.location = test_zone
    test_zone._contents.append(other_player)

    from mudlib.player import players

    players.clear()
    players[player.name] = player
    players[other_player.name] = other_player

    await look.cmd_look(player, "")

    # Check that the output contains * for other players
    output = "".join([call[0][0] for call in player.writer.write.call_args_list])
    assert "*" in output


@pytest.mark.asyncio
async def test_look_shows_effects_on_viewport(player, test_zone):
    """Test that active effects overlay on the viewport."""
    from mudlib.player import players

    players.clear()
    players[player.name] = player

    active_effects.clear()
    # place a cloud effect 2 tiles east of the player (viewport center is 10,5)
    # player is at (5, 5), so effect at (7, 5) -> viewport (12, 5)
    add_effect(7, 5, "~", "\033[1;97m", ttl=10.0)

    await look.cmd_look(player, "")

    output = "".join([call[0][0] for call in player.writer.write.call_args_list])
    # the output should contain our cloud color applied to ~
    # (bright white ~ instead of blue ~)
    assert "\033[1;97m~" + RESET in output

    active_effects.clear()


@pytest.mark.asyncio
async def test_effects_dont_override_player_marker(player, test_zone):
    """Effects at the player's position should not hide the @ marker."""
    from mudlib.player import players

    players.clear()
    players[player.name] = player

    active_effects.clear()
    # place an effect right at the player's position
    add_effect(player.x, player.y, "~", "\033[1;97m", ttl=10.0)

    await look.cmd_look(player, "")

    output = "".join([call[0][0] for call in player.writer.write.call_args_list])
    # player @ should still be visible at center
    assert "@" in output

    active_effects.clear()


# Test mode stack
def test_mode_stack_default(player):
    """Player starts in normal mode."""
    assert player.mode == "normal"
    assert player.mode_stack == ["normal"]


@pytest.mark.asyncio
async def test_dispatch_blocks_wrong_mode(player, mock_writer):
    """Commands with wrong mode get rejected."""

    async def combat_handler(p, args):
        pass

    commands.register(CommandDefinition("strike", combat_handler, mode="combat"))
    await commands.dispatch(player, "strike")

    assert mock_writer.write.called
    written = mock_writer.write.call_args[0][0]
    assert "can't" in written.lower()


@pytest.mark.asyncio
async def test_dispatch_allows_wildcard_mode(player):
    """Commands with mode='*' work from any mode."""
    called = False

    async def any_handler(p, args):
        nonlocal called
        called = True

    commands.register(CommandDefinition("universal", any_handler, mode="*"))
    await commands.dispatch(player, "universal")

    assert called


@pytest.mark.asyncio
async def test_dispatch_allows_matching_mode(player):
    """Commands work when player mode matches command mode."""
    called = False

    async def combat_handler(p, args):
        nonlocal called
        called = True

    commands.register(CommandDefinition("strike", combat_handler, mode="combat"))
    player.mode_stack.append("combat")
    await commands.dispatch(player, "strike")

    assert called


@pytest.mark.asyncio
async def test_look_shows_ground_items(player, test_zone):
    """look shows things on the ground at the player's position."""
    from mudlib.player import players
    from mudlib.thing import Thing

    players.clear()
    players[player.name] = player

    Thing(name="rock", location=test_zone, x=5, y=5)
    await look.cmd_look(player, "")

    output = "".join([call[0][0] for call in player.writer.write.call_args_list])
    assert "rock" in output.lower()


@pytest.mark.asyncio
async def test_look_shows_multiple_ground_items(player, test_zone):
    """look shows all things at the player's position."""
    from mudlib.player import players
    from mudlib.thing import Thing

    players.clear()
    players[player.name] = player

    Thing(name="rock", location=test_zone, x=5, y=5)
    Thing(name="sword", location=test_zone, x=5, y=5)
    await look.cmd_look(player, "")

    output = "".join([call[0][0] for call in player.writer.write.call_args_list])
    assert "rock" in output.lower()
    assert "sword" in output.lower()


@pytest.mark.asyncio
async def test_look_no_ground_items_no_extra_output(player, test_zone):
    """look with no ground items doesn't mention items."""
    from mudlib.player import players

    players.clear()
    players[player.name] = player

    await look.cmd_look(player, "")

    output = "".join([call[0][0] for call in player.writer.write.call_args_list])
    assert "on the ground" not in output.lower()


@pytest.mark.asyncio
async def test_look_ignores_items_at_other_positions(player, test_zone):
    """look doesn't show items that are at different positions."""
    from mudlib.player import players
    from mudlib.thing import Thing

    players.clear()
    players[player.name] = player

    Thing(name="far_rock", location=test_zone, x=9, y=9)
    await look.cmd_look(player, "")

    output = "".join([call[0][0] for call in player.writer.write.call_args_list])
    assert "far_rock" not in output.lower()


@pytest.mark.asyncio
async def test_quit_removes_player_from_zone(monkeypatch):
    """Quitting removes player from zone contents."""
    from mudlib.commands import quit as quit_mod
    from mudlib.player import players

    monkeypatch.setattr(quit_mod, "save_player", lambda p: None)

    terrain = [["." for _ in range(10)] for _ in range(10)]
    zone = Zone(
        name="qz",
        width=10,
        height=10,
        toroidal=True,
        terrain=terrain,
        impassable=set(),
    )
    writer = MagicMock()
    writer.write = MagicMock()
    writer.drain = AsyncMock()
    writer.close = MagicMock()
    p = Player(
        name="quitter",
        location=zone,
        x=5,
        y=5,
        reader=MagicMock(),
        writer=writer,
    )
    players.clear()
    players["quitter"] = p

    assert p in zone._contents
    await quit_mod.cmd_quit(p, "")
    assert p not in zone._contents
    assert "quitter" not in players


@pytest.mark.asyncio
async def test_look_shows_entities_here(player, test_zone):
    """look lists mobs and other players at the player's position."""
    from mudlib.entity import Mob
    from mudlib.player import players

    players.clear()
    players[player.name] = player

    Mob(name="goblin", location=test_zone, x=5, y=5, description="a goblin")
    other = Player(
        name="Ally",
        location=test_zone,
        x=5,
        y=5,
        reader=MagicMock(),
        writer=MagicMock(),
    )
    players["Ally"] = other

    await look.cmd_look(player, "")

    output = "".join(c[0][0] for c in player.writer.write.call_args_list)
    # New format shows individual entity lines, not "Here: "
    assert "goblin is standing here." in output
    assert "Ally is standing here." in output


@pytest.mark.asyncio
async def test_look_hides_dead_mobs_from_here(player, test_zone):
    """look omits dead mobs from the here line."""
    from mudlib.entity import Mob
    from mudlib.player import players

    players.clear()
    players[player.name] = player

    Mob(
        name="corpse",
        location=test_zone,
        x=5,
        y=5,
        description="dead",
        alive=False,
    )
    await look.cmd_look(player, "")

    output = "".join(c[0][0] for c in player.writer.write.call_args_list)
    assert "corpse" not in output


@pytest.mark.asyncio
async def test_look_no_here_line_when_alone(player, test_zone):
    """look omits the here line when no other entities are present."""
    from mudlib.player import players

    players.clear()
    players[player.name] = player

    await look.cmd_look(player, "")

    output = "".join(c[0][0] for c in player.writer.write.call_args_list)
    assert "Here:" not in output
