"""Tests for the fly command."""

import time
from unittest.mock import AsyncMock, MagicMock

import pytest

from mudlib.commands import fly
from mudlib.effects import active_effects
from mudlib.player import Player, players
from mudlib.zone import Zone


@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():
    terrain = [["." for _ in range(100)] for _ in range(100)]
    zone = Zone(
        name="testzone",
        width=100,
        height=100,
        toroidal=True,
        terrain=terrain,
        impassable=set(),
    )
    return zone


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


@pytest.fixture(autouse=True)
def clean_state(test_zone):
    """Clean global state before/after each test."""
    players.clear()
    active_effects.clear()
    yield
    players.clear()
    active_effects.clear()


# --- toggle ---


@pytest.mark.asyncio
async def test_fly_toggles_on(player, mock_writer):
    """fly with no args sets flying=True."""
    players[player.name] = player
    assert not player.flying

    await fly.cmd_fly(player, "")

    assert player.flying
    calls = [str(c) for c in mock_writer.write.call_args_list]
    assert any("fly" in c.lower() and "air" in c.lower() for c in calls)


@pytest.mark.asyncio
async def test_fly_toggles_off(player, mock_writer):
    """fly again sets flying=False."""
    players[player.name] = player
    player.flying = True

    await fly.cmd_fly(player, "")

    assert not player.flying
    calls = [str(c) for c in mock_writer.write.call_args_list]
    assert any("land" in c.lower() for c in calls)


@pytest.mark.asyncio
async def test_fly_toggle_on_notifies_nearby(player, test_zone):
    """Others see liftoff message."""
    players[player.name] = player

    other_writer = MagicMock()
    other_writer.write = MagicMock()
    other_writer.drain = AsyncMock()
    other = Player(
        name="bystander",
        x=52,
        y=50,
        reader=MagicMock(),
        writer=other_writer,
    )
    other.location = test_zone
    test_zone._contents.append(other)
    players[other.name] = other

    await fly.cmd_fly(player, "")

    calls = [str(c) for c in other_writer.write.call_args_list]
    assert any("shmup" in c.lower() and "lifts" in c.lower() for c in calls)


@pytest.mark.asyncio
async def test_fly_toggle_off_notifies_nearby(player, test_zone):
    """Others see landing message."""
    players[player.name] = player
    player.flying = True

    other_writer = MagicMock()
    other_writer.write = MagicMock()
    other_writer.drain = AsyncMock()
    other = Player(
        name="bystander",
        x=52,
        y=50,
        reader=MagicMock(),
        writer=other_writer,
    )
    other.location = test_zone
    test_zone._contents.append(other)
    players[other.name] = other

    await fly.cmd_fly(player, "")

    calls = [str(c) for c in other_writer.write.call_args_list]
    assert any("shmup" in c.lower() and "lands" in c.lower() for c in calls)


# --- must be flying to move ---


@pytest.mark.asyncio
async def test_fly_direction_without_flying_errors(player, mock_writer):
    """fly east when not flying gives an error."""
    players[player.name] = player
    assert not player.flying

    await fly.cmd_fly(player, "east")

    calls = [str(c) for c in mock_writer.write.call_args_list]
    assert any("aren't flying" in c.lower() for c in calls)
    assert player.x == 50
    assert player.y == 50


# --- movement while flying ---


@pytest.mark.asyncio
async def test_fly_east_moves_5_tiles(player):
    """fly east should move player 5 tiles east."""
    players[player.name] = player
    player.flying = True
    await fly.cmd_fly(player, "east")

    assert player.x == 55
    assert player.y == 50


@pytest.mark.asyncio
async def test_fly_north_moves_5_tiles(player):
    players[player.name] = player
    player.flying = True
    await fly.cmd_fly(player, "north")

    assert player.x == 50
    assert player.y == 45


@pytest.mark.asyncio
async def test_fly_northwest_moves_5_diagonal(player):
    players[player.name] = player
    player.flying = True
    await fly.cmd_fly(player, "nw")

    assert player.x == 45
    assert player.y == 45


@pytest.mark.asyncio
async def test_fly_wraps_around_world(player):
    """Flying near an edge should wrap toroidally."""
    player.x = 98
    player.y = 50
    player.flying = True
    players[player.name] = player
    await fly.cmd_fly(player, "east")

    assert player.x == 3
    assert player.y == 50


# --- cloud trail ---


@pytest.mark.asyncio
async def test_fly_creates_cloud_trail(player):
    """Flying should leave ~ effects along the path."""
    players[player.name] = player
    player.flying = True
    await fly.cmd_fly(player, "east")

    assert len(active_effects) == 5
    trail_positions = [(e.x, e.y) for e in active_effects]
    for i in range(5):
        assert (50 + i, 50) in trail_positions


@pytest.mark.asyncio
async def test_cloud_trail_chars_are_tilde(player):
    players[player.name] = player
    player.flying = True
    await fly.cmd_fly(player, "east")

    for e in active_effects:
        assert e.char == "~"


@pytest.mark.asyncio
async def test_cloud_trail_dissolves_from_origin(player):
    """Origin cloud expires first, trail shrinks toward player."""
    players[player.name] = player
    player.flying = True

    before = time.monotonic()
    await fly.cmd_fly(player, "east")

    # effects are in path order (step 0..4)
    expiries = [e.expires_at for e in active_effects]
    # each should expire after the previous one (origin fades first)
    for i in range(1, len(expiries)):
        assert expiries[i] > expiries[i - 1]
    # origin cloud ~1.5s, near-dest cloud ~1.5 + 4*0.4 = ~3.1s
    assert 1.3 <= expiries[0] - before <= 1.7
    assert 2.9 <= expiries[-1] - before <= 3.3


@pytest.mark.asyncio
async def test_fly_diagonal_trail(player):
    """Diagonal flight should leave trail at each intermediate step."""
    players[player.name] = player
    player.flying = True
    await fly.cmd_fly(player, "se")

    assert len(active_effects) == 5
    trail_positions = [(e.x, e.y) for e in active_effects]
    for i in range(5):
        assert (50 + i, 50 + i) in trail_positions


# --- no movement without flying leaves no trail ---


@pytest.mark.asyncio
async def test_no_trail_when_not_flying(player):
    """Trying to fly a direction while grounded creates no effects."""
    players[player.name] = player
    await fly.cmd_fly(player, "east")

    assert len(active_effects) == 0


# --- error cases ---


@pytest.mark.asyncio
async def test_fly_bad_direction_gives_error(player, mock_writer):
    """fly with an invalid direction should give feedback."""
    players[player.name] = player
    player.flying = True
    await fly.cmd_fly(player, "sideways")

    calls = [str(c) for c in mock_writer.write.call_args_list]
    assert any("direction" in c.lower() for c in calls)
    assert player.x == 50
    assert player.y == 50


@pytest.mark.asyncio
async def test_fly_triggers_look(player, test_zone):
    """Flying should auto-look at the destination."""
    players[player.name] = player
    player.flying = True
    await fly.cmd_fly(player, "east")

    # look was called (check that writer was written to)
    assert player.writer.write.called


@pytest.mark.asyncio
async def test_stays_flying_after_move(player):
    """Moving while flying doesn't turn off flying."""
    players[player.name] = player
    player.flying = True
    await fly.cmd_fly(player, "east")

    assert player.flying
