"""Tests for discord_bot.py slash command handlers."""

from __future__ import annotations

import datetime
import itertools
from collections.abc import Generator
from unittest.mock import AsyncMock, MagicMock

import discord
import pytest

import idlemaxx.discord_bot as bot_module
from idlemaxx.game import Game
from idlemaxx.models.combat import CombatArea
from idlemaxx.models.models import Activity, Character, Item, Skill, TradeStatus, now_utc

# @bot.tree.command / @group.command wraps functions as Command objects.
# Access .callback to get the raw coroutine for direct testing.
help_command = bot_module.help_command.callback
info_command = bot_module.info_command.callback
status_command = bot_module.status_command.callback
start_command = bot_module.start_command.callback
end_command = bot_module.end_command.callback
skills_command = bot_module.skills_command.callback
inventory_command = bot_module.inventory_command.callback
queue_add_command = bot_module.queue_add_command.callback
queue_view_command = bot_module.queue_view_command.callback
queue_remove_command = bot_module.queue_remove_command.callback
trade_offer_command = bot_module.trade_offer_command.callback
trade_view_command = bot_module.trade_view_command.callback
trade_show_command = bot_module.trade_show_command.callback
trade_accept_command = bot_module.trade_accept_command.callback
trade_reject_command = bot_module.trade_reject_command.callback
trade_counter_command = bot_module.trade_counter_command.callback
combat_enter_command = bot_module.combat_enter_command.callback
combat_end_command = bot_module.combat_end_command.callback
combat_status_command = bot_module.combat_status_command.callback
equipment_view_command = bot_module.equipment_view_command.callback
equipment_equip_command = bot_module.equipment_equip_command.callback
equipment_equip_food_command = bot_module.equipment_equip_food_command.callback
equipment_unequip_command = bot_module.equipment_unequip_command.callback
equipment_recover_command = bot_module.equipment_recover_command.callback

# ---------------------------------------------------------------------------
# Fixtures and helpers
# ---------------------------------------------------------------------------

_interaction_id = itertools.count(1)


@pytest.fixture(autouse=True)
def fresh_game() -> Generator[Game, None, None]:
    g = Game("sqlite:///:memory:")
    bot_module.bot.game = g
    bot_module._seen_interactions.clear()
    yield g
    bot_module._seen_interactions.clear()


def make_interaction(discord_user_id: int = 111) -> MagicMock:
    ix = MagicMock()
    ix.id = next(_interaction_id)
    ix.user = MagicMock()
    ix.user.id = discord_user_id
    ix.user.name = f"user_{discord_user_id}"
    ix.response = AsyncMock()
    ix.response.send_message = AsyncMock()
    ix.response.send_modal = AsyncMock()
    ix.response.is_done = MagicMock(return_value=False)
    ix.followup = AsyncMock()
    ix.followup.send = AsyncMock()
    ix.client = bot_module.bot
    return ix


def last_send_kwargs(ix: MagicMock) -> dict:
    return ix.response.send_message.call_args.kwargs


def get_embed(ix: MagicMock) -> discord.Embed:
    return last_send_kwargs(ix)["embed"]


def give_item(game: Game, character: Character, item: Item, qty: int) -> None:
    ci = game._get_character_item(character, item)
    ci.quantity = qty
    game._session.commit()


# ---------------------------------------------------------------------------
# _parse_items unit tests
# ---------------------------------------------------------------------------


def test_parse_items_empty() -> None:
    assert bot_module._parse_items(None) == {}
    assert bot_module._parse_items("") == {}


def test_parse_items_single() -> None:
    result = bot_module._parse_items("REGULAR_LOG:5")
    assert result == {Item.REGULAR_LOG: 5}


def test_parse_items_multiple() -> None:
    result = bot_module._parse_items("REGULAR_LOG:3,BRONZE_BAR:2")
    assert result == {Item.REGULAR_LOG: 3, Item.BRONZE_BAR: 2}


def test_parse_items_bad_format() -> None:
    with pytest.raises(ValueError, match="Invalid item format"):
        bot_module._parse_items("REGULAR_LOG")


def test_parse_items_unknown_item() -> None:
    with pytest.raises(ValueError, match="Unknown item"):
        bot_module._parse_items("FAKE_ITEM:5")


def test_parse_items_non_int_qty() -> None:
    with pytest.raises(ValueError, match="Invalid quantity"):
        bot_module._parse_items("REGULAR_LOG:abc")


# ---------------------------------------------------------------------------
# _format_elapsed unit tests
# ---------------------------------------------------------------------------


def test_format_elapsed_seconds_only() -> None:
    result = bot_module._format_elapsed(datetime.timedelta(seconds=45))
    assert result == "00m 45s"


def test_format_elapsed_minutes_and_seconds() -> None:
    result = bot_module._format_elapsed(datetime.timedelta(seconds=125))
    assert result == "02m 05s"


def test_format_elapsed_hours() -> None:
    result = bot_module._format_elapsed(datetime.timedelta(hours=2, minutes=3, seconds=4))
    assert result == "2h 03m 04s"


# ---------------------------------------------------------------------------
# /info
# ---------------------------------------------------------------------------


async def test_info_returns_embed(fresh_game: Game) -> None:
    ix = make_interaction()
    await info_command(ix)
    ix.response.send_message.assert_called_once()
    embed = get_embed(ix)
    assert embed.title == "IdleMaxx Bot Info"
    field_names = [f.name for f in embed.fields]
    assert "Version" in field_names
    assert "Uptime" in field_names
    assert "Registered characters" in field_names


async def test_info_character_count(fresh_game: Game) -> None:
    fresh_game.create_character("Alice", "111")
    fresh_game.create_character("Bob", "222")
    ix = make_interaction()
    await info_command(ix)
    embed = get_embed(ix)
    count_field = next(f for f in embed.fields if f.name == "Registered characters")
    assert count_field.value == "2"


# ---------------------------------------------------------------------------
# /help
# ---------------------------------------------------------------------------


async def test_help_returns_embed_ephemeral(fresh_game: Game) -> None:
    ix = make_interaction()
    await help_command(ix)
    ix.response.send_message.assert_called_once()
    kwargs = last_send_kwargs(ix)
    assert "embed" in kwargs
    assert kwargs.get("ephemeral") is True


async def test_help_embed_has_command_info(fresh_game: Game) -> None:
    ix = make_interaction()
    await help_command(ix)
    embed = get_embed(ix)
    assert "Commands" in (embed.title or "")
    assert any(f.value and "/status" in f.value for f in embed.fields)


# ---------------------------------------------------------------------------
# /status
# ---------------------------------------------------------------------------


async def test_status_no_character_sends_modal(fresh_game: Game) -> None:
    ix = make_interaction(discord_user_id=999)
    await status_command(ix)
    ix.response.send_modal.assert_called_once()
    ix.response.send_message.assert_not_called()


async def test_status_idle_character(fresh_game: Game) -> None:
    fresh_game.create_character("Idle", discord_user_id="111")
    ix = make_interaction(discord_user_id=111)
    await status_command(ix)
    embed = get_embed(ix)
    assert embed.title == "Idle"
    assert any(f.name == "Status" and f.value == "Idle" for f in embed.fields)


async def test_status_active_character(fresh_game: Game) -> None:
    char = fresh_game.create_character("Active", discord_user_id="111")
    fresh_game.start_activity(char, Activity.CHOP_REGULAR_TREE)
    ix = make_interaction(discord_user_id=111)
    await status_command(ix)
    embed = get_embed(ix)
    # CHOP_REGULAR_TREE.value.name = "chop_tree" → "Chop Tree"
    assert "Chop Tree" in (embed.title or "")


async def test_status_active_with_queue(fresh_game: Game) -> None:
    char = fresh_game.create_character("Queued", discord_user_id="111")
    fresh_game.start_activity(char, Activity.CHOP_REGULAR_TREE, action_limit=10)
    fresh_game.queue_activity(char, Activity.MINE_TIN)
    ix = make_interaction(discord_user_id=111)
    await status_command(ix)
    embed = get_embed(ix)
    assert any(f.name == "Queue" for f in embed.fields)


# ---------------------------------------------------------------------------
# /start
# ---------------------------------------------------------------------------


async def test_start_no_character_sends_modal(fresh_game: Game) -> None:
    ix = make_interaction(discord_user_id=999)
    await start_command(ix, activity="CHOP_REGULAR_TREE")
    ix.response.send_modal.assert_called_once()
    ix.response.send_message.assert_not_called()


async def test_start_valid_activity(fresh_game: Game) -> None:
    fresh_game.create_character("StartTest", discord_user_id="111")
    ix = make_interaction(discord_user_id=111)
    await start_command(ix, activity="CHOP_REGULAR_TREE")
    embed = get_embed(ix)
    assert "Chop Tree" in (embed.title or "")


async def test_start_with_limit(fresh_game: Game) -> None:
    fresh_game.create_character("LimitTest", discord_user_id="111")
    ix = make_interaction(discord_user_id=111)
    await start_command(ix, activity="CHOP_REGULAR_TREE", limit=5)
    embed = get_embed(ix)
    assert any(f.name == "Action limit" and f.value == "5" for f in embed.fields)


async def test_start_unknown_activity(fresh_game: Game) -> None:
    fresh_game.create_character("StartTest2", discord_user_id="111")
    ix = make_interaction(discord_user_id=111)
    await start_command(ix, activity="FAKE_ACTIVITY")
    kwargs = last_send_kwargs(ix)
    assert kwargs.get("ephemeral") is True


async def test_start_already_active(fresh_game: Game) -> None:
    char = fresh_game.create_character("BusyChar", discord_user_id="111")
    fresh_game.start_activity(char, Activity.CHOP_REGULAR_TREE)
    ix = make_interaction(discord_user_id=111)
    await start_command(ix, activity="MINE_TIN")
    kwargs = last_send_kwargs(ix)
    assert kwargs.get("ephemeral") is True


async def test_start_skill_requirement_not_met(fresh_game: Game) -> None:
    fresh_game.create_character("LowLevel", discord_user_id="111")
    ix = make_interaction(discord_user_id=111)
    await start_command(ix, activity="CHOP_REDWOOD")
    kwargs = last_send_kwargs(ix)
    assert kwargs.get("ephemeral") is True


# ---------------------------------------------------------------------------
# /end
# ---------------------------------------------------------------------------


async def test_end_no_character_sends_modal(fresh_game: Game) -> None:
    ix = make_interaction(discord_user_id=999)
    await end_command(ix)
    ix.response.send_modal.assert_called_once()
    ix.response.send_message.assert_not_called()


async def test_end_not_active(fresh_game: Game) -> None:
    fresh_game.create_character("Idle", discord_user_id="111")
    ix = make_interaction(discord_user_id=111)
    await end_command(ix)
    kwargs = last_send_kwargs(ix)
    assert kwargs.get("ephemeral") is True
    assert "not doing anything" in ix.response.send_message.call_args.args[0]


async def test_end_active_backdated(fresh_game: Game) -> None:
    char = fresh_game.create_character("Chopper", discord_user_id="111")
    ca = fresh_game.start_activity(char, Activity.CHOP_REGULAR_TREE)
    ca.started_at = now_utc() - datetime.timedelta(seconds=30)
    ix = make_interaction(discord_user_id=111)
    await end_command(ix)
    embed = get_embed(ix)
    assert "Activity Complete" in (embed.title or "")


async def test_end_active_with_queue(fresh_game: Game) -> None:
    char = fresh_game.create_character("QueueChain", discord_user_id="111")
    ca = fresh_game.start_activity(char, Activity.CHOP_REGULAR_TREE, action_limit=2)
    ca.started_at = now_utc() - datetime.timedelta(seconds=30)
    fresh_game.queue_activity(char, Activity.MINE_TIN)
    ix = make_interaction(discord_user_id=111)
    await end_command(ix)
    embed = get_embed(ix)
    assert "Activity Complete" in (embed.title or "")


# ---------------------------------------------------------------------------
# /skills
# ---------------------------------------------------------------------------


async def test_skills_no_character_sends_modal(fresh_game: Game) -> None:
    ix = make_interaction(discord_user_id=999)
    await skills_command(ix)
    ix.response.send_modal.assert_called_once()
    ix.response.send_message.assert_not_called()


async def test_skills_with_xp(fresh_game: Game) -> None:
    char = fresh_game.create_character("Skilled", discord_user_id="111")
    wc = next(s for s in char.skills if s.skill == Skill.WOODCUTTING)
    wc.experience = 100
    fresh_game._session.commit()
    ix = make_interaction(discord_user_id=111)
    await skills_command(ix)
    embed = get_embed(ix)
    assert "Skills" in (embed.title or "")
    assert "Woodcutting" in (embed.description or "")


# ---------------------------------------------------------------------------
# /inventory
# ---------------------------------------------------------------------------


async def test_inventory_no_character_sends_modal(fresh_game: Game) -> None:
    ix = make_interaction(discord_user_id=999)
    await inventory_command(ix)
    ix.response.send_modal.assert_called_once()
    ix.response.send_message.assert_not_called()


async def test_inventory_empty(fresh_game: Game) -> None:
    fresh_game.create_character("Empty", discord_user_id="111")
    ix = make_interaction(discord_user_id=111)
    await inventory_command(ix)
    embed = get_embed(ix)
    assert "empty" in (embed.description or "").lower()


async def test_inventory_with_items(fresh_game: Game) -> None:
    char = fresh_game.create_character("Rich", discord_user_id="111")
    give_item(fresh_game, char, Item.REGULAR_LOG, 42)
    ix = make_interaction(discord_user_id=111)
    await inventory_command(ix)
    embed = get_embed(ix)
    assert "Regular Log" in (embed.description or "")
    assert "42" in (embed.description or "")


# ---------------------------------------------------------------------------
# /queue add
# ---------------------------------------------------------------------------


async def test_queue_add_no_character_sends_modal(fresh_game: Game) -> None:
    ix = make_interaction(discord_user_id=999)
    await queue_add_command(ix, activity="CHOP_REGULAR_TREE")
    ix.response.send_modal.assert_called_once()
    ix.response.send_message.assert_not_called()


async def test_queue_add_idle_starts_immediately(fresh_game: Game) -> None:
    char = fresh_game.create_character("Idle", discord_user_id="111")
    ix = make_interaction(discord_user_id=111)
    await queue_add_command(ix, activity="CHOP_REGULAR_TREE")
    embed = get_embed(ix)
    assert "Chop Tree" in (embed.title or "")
    assert char.current_activity is not None


async def test_queue_add_active_queues(fresh_game: Game) -> None:
    char = fresh_game.create_character("Active", discord_user_id="111")
    fresh_game.start_activity(char, Activity.CHOP_REGULAR_TREE, action_limit=10)
    ix = make_interaction(discord_user_id=111)
    await queue_add_command(ix, activity="MINE_TIN")
    embed = get_embed(ix)
    assert "Mine Tin" in (embed.title or "")
    assert len(fresh_game.get_activity_queue(char)) == 1


async def test_queue_add_unknown_activity(fresh_game: Game) -> None:
    fresh_game.create_character("Test", discord_user_id="111")
    ix = make_interaction(discord_user_id=111)
    await queue_add_command(ix, activity="FAKE_ACTIVITY")
    kwargs = last_send_kwargs(ix)
    assert kwargs.get("ephemeral") is True


async def test_queue_add_queue_full(fresh_game: Game) -> None:
    char = fresh_game.create_character("Full", discord_user_id="111")
    fresh_game.start_activity(char, Activity.CHOP_REGULAR_TREE, action_limit=10)
    fresh_game.queue_activity(char, Activity.MINE_TIN)
    fresh_game.queue_activity(char, Activity.MINE_COPPER)
    fresh_game.queue_activity(char, Activity.FISH_SHRIMP)
    ix = make_interaction(discord_user_id=111)
    await queue_add_command(ix, activity="MINE_TIN")
    kwargs = last_send_kwargs(ix)
    assert kwargs.get("ephemeral") is True


async def test_queue_add_skill_requirement_not_met(fresh_game: Game) -> None:
    char = fresh_game.create_character("LowLevel", discord_user_id="111")
    fresh_game.start_activity(char, Activity.CHOP_REGULAR_TREE, action_limit=10)
    ix = make_interaction(discord_user_id=111)
    await queue_add_command(ix, activity="CHOP_REDWOOD")
    kwargs = last_send_kwargs(ix)
    assert kwargs.get("ephemeral") is True


# ---------------------------------------------------------------------------
# /queue view
# ---------------------------------------------------------------------------


async def test_queue_view_no_character_sends_modal(fresh_game: Game) -> None:
    ix = make_interaction(discord_user_id=999)
    await queue_view_command(ix)
    ix.response.send_modal.assert_called_once()
    ix.response.send_message.assert_not_called()


async def test_queue_view_idle_no_queue(fresh_game: Game) -> None:
    fresh_game.create_character("Idle", discord_user_id="111")
    ix = make_interaction(discord_user_id=111)
    await queue_view_command(ix)
    embed = get_embed(ix)
    now_field = next(f for f in embed.fields if f.name == "Now")
    assert now_field.value == "Idle"
    next_field = next(f for f in embed.fields if f.name == "Up next")
    assert "Nothing queued" in (next_field.value or "")


async def test_queue_view_active_no_queue(fresh_game: Game) -> None:
    char = fresh_game.create_character("Active", discord_user_id="111")
    fresh_game.start_activity(char, Activity.CHOP_REGULAR_TREE)
    ix = make_interaction(discord_user_id=111)
    await queue_view_command(ix)
    embed = get_embed(ix)
    now_field = next(f for f in embed.fields if f.name == "Now")
    assert "Chop Tree" in (now_field.value or "")


async def test_queue_view_active_with_queue(fresh_game: Game) -> None:
    char = fresh_game.create_character("Queued", discord_user_id="111")
    fresh_game.start_activity(char, Activity.CHOP_REGULAR_TREE, action_limit=10)
    fresh_game.queue_activity(char, Activity.MINE_TIN)
    ix = make_interaction(discord_user_id=111)
    await queue_view_command(ix)
    embed = get_embed(ix)
    queue_field = next(f for f in embed.fields if f.name and "Up next" in f.name)
    assert "Mine Tin" in (queue_field.value or "")


# ---------------------------------------------------------------------------
# /queue remove
# ---------------------------------------------------------------------------


async def test_queue_remove_no_character_sends_modal(fresh_game: Game) -> None:
    ix = make_interaction(discord_user_id=999)
    await queue_remove_command(ix, position=1)
    ix.response.send_modal.assert_called_once()
    ix.response.send_message.assert_not_called()


async def test_queue_remove_empty_queue(fresh_game: Game) -> None:
    fresh_game.create_character("Empty", discord_user_id="111")
    ix = make_interaction(discord_user_id=111)
    await queue_remove_command(ix, position=1)
    kwargs = last_send_kwargs(ix)
    assert kwargs.get("ephemeral") is True


async def test_queue_remove_invalid_position(fresh_game: Game) -> None:
    char = fresh_game.create_character("OneQueued", discord_user_id="111")
    fresh_game.start_activity(char, Activity.CHOP_REGULAR_TREE, action_limit=10)
    fresh_game.queue_activity(char, Activity.MINE_TIN)
    ix = make_interaction(discord_user_id=111)
    await queue_remove_command(ix, position=5)
    kwargs = last_send_kwargs(ix)
    assert kwargs.get("ephemeral") is True


async def test_queue_remove_valid_position(fresh_game: Game) -> None:
    char = fresh_game.create_character("Remove", discord_user_id="111")
    fresh_game.start_activity(char, Activity.CHOP_REGULAR_TREE, action_limit=10)
    fresh_game.queue_activity(char, Activity.MINE_TIN)
    fresh_game.queue_activity(char, Activity.MINE_COPPER)
    ix = make_interaction(discord_user_id=111)
    await queue_remove_command(ix, position=1)
    ix.response.send_message.assert_called_once()
    assert "Mine Tin" in ix.response.send_message.call_args.args[0]
    assert len(fresh_game.get_activity_queue(char)) == 1


# ---------------------------------------------------------------------------
# /trade offer
# ---------------------------------------------------------------------------


async def test_trade_offer_no_character_sends_modal(fresh_game: Game) -> None:
    ix = make_interaction(discord_user_id=999)
    await trade_offer_command(ix, player="Bob")
    ix.response.send_modal.assert_called_once()
    ix.response.send_message.assert_not_called()


async def test_trade_offer_counterparty_not_found(fresh_game: Game) -> None:
    fresh_game.create_character("Alice", discord_user_id="111")
    ix = make_interaction(discord_user_id=111)
    await trade_offer_command(ix, player="NoSuchPlayer")
    kwargs = last_send_kwargs(ix)
    assert kwargs.get("ephemeral") is True


async def test_trade_offer_invalid_item_format(fresh_game: Game) -> None:
    fresh_game.create_character("Alice", discord_user_id="111")
    fresh_game.create_character("Bob", discord_user_id="222")
    ix = make_interaction(discord_user_id=111)
    await trade_offer_command(ix, player="Bob", give="BADFORMAT")
    kwargs = last_send_kwargs(ix)
    assert kwargs.get("ephemeral") is True


async def test_trade_offer_both_sides_empty(fresh_game: Game) -> None:
    fresh_game.create_character("Alice", discord_user_id="111")
    fresh_game.create_character("Bob", discord_user_id="222")
    ix = make_interaction(discord_user_id=111)
    await trade_offer_command(ix, player="Bob")  # give=None, want=None
    kwargs = last_send_kwargs(ix)
    assert kwargs.get("ephemeral") is True


async def test_trade_offer_happy_path(fresh_game: Game) -> None:
    char_a = fresh_game.create_character("Alice", discord_user_id="111")
    fresh_game.create_character("Bob", discord_user_id="222")
    give_item(fresh_game, char_a, Item.REGULAR_LOG, 10)
    ix = make_interaction(discord_user_id=111)
    await trade_offer_command(ix, player="Bob", give="REGULAR_LOG:5")
    embed = get_embed(ix)
    assert "Trade Offer Sent" in (embed.title or "")


# ---------------------------------------------------------------------------
# /trade view
# ---------------------------------------------------------------------------


async def test_trade_view_no_character_sends_modal(fresh_game: Game) -> None:
    ix = make_interaction(discord_user_id=999)
    await trade_view_command(ix)
    ix.response.send_modal.assert_called_once()
    ix.response.send_message.assert_not_called()


async def test_trade_view_no_trades(fresh_game: Game) -> None:
    fresh_game.create_character("Alice", discord_user_id="111")
    ix = make_interaction(discord_user_id=111)
    await trade_view_command(ix)
    embed = get_embed(ix)
    assert any(f.value == "None" for f in embed.fields)


async def test_trade_view_pending_incoming(fresh_game: Game) -> None:
    char_a = fresh_game.create_character("Alice", discord_user_id="111")
    char_b = fresh_game.create_character("Bob", discord_user_id="222")
    give_item(fresh_game, char_a, Item.REGULAR_LOG, 10)
    fresh_game.create_trade(char_a, char_b, {Item.REGULAR_LOG: 5}, {})
    # It's Bob's turn — appears in his "Awaiting your response"
    ix = make_interaction(discord_user_id=222)
    await trade_view_command(ix)
    embed = get_embed(ix)
    awaiting_field = next(f for f in embed.fields if f.name == "Awaiting your response")
    assert "Alice" in (awaiting_field.value or "")


async def test_trade_view_outgoing(fresh_game: Game) -> None:
    char_a = fresh_game.create_character("Alice", discord_user_id="111")
    char_b = fresh_game.create_character("Bob", discord_user_id="222")
    give_item(fresh_game, char_a, Item.REGULAR_LOG, 10)
    fresh_game.create_trade(char_a, char_b, {Item.REGULAR_LOG: 5}, {})
    # Alice is waiting on Bob — appears in her "Waiting on others"
    ix = make_interaction(discord_user_id=111)
    await trade_view_command(ix)
    embed = get_embed(ix)
    waiting_field = next(f for f in embed.fields if f.name == "Waiting on others")
    assert "Bob" in (waiting_field.value or "")


# ---------------------------------------------------------------------------
# /trade show
# ---------------------------------------------------------------------------


async def test_trade_show_no_character_sends_modal(fresh_game: Game) -> None:
    ix = make_interaction(discord_user_id=999)
    await trade_show_command(ix, trade_id="abcd1234")
    ix.response.send_modal.assert_called_once()
    ix.response.send_message.assert_not_called()


async def test_trade_show_invalid_trade_id(fresh_game: Game) -> None:
    fresh_game.create_character("Alice", discord_user_id="111")
    ix = make_interaction(discord_user_id=111)
    await trade_show_command(ix, trade_id="notreal")
    kwargs = last_send_kwargs(ix)
    assert kwargs.get("ephemeral") is True


async def test_trade_show_valid_trade(fresh_game: Game) -> None:
    char_a = fresh_game.create_character("Alice", discord_user_id="111")
    char_b = fresh_game.create_character("Bob", discord_user_id="222")
    give_item(fresh_game, char_a, Item.REGULAR_LOG, 10)
    trade = fresh_game.create_trade(char_a, char_b, {Item.REGULAR_LOG: 5}, {})
    trade_id_prefix = str(trade.id)[:8]
    # Bob can see it (it's his turn)
    ix = make_interaction(discord_user_id=222)
    await trade_show_command(ix, trade_id=trade_id_prefix)
    embed = get_embed(ix)
    assert "Alice" in (embed.title or "")
    assert "Bob" in (embed.title or "")


# ---------------------------------------------------------------------------
# /trade accept
# ---------------------------------------------------------------------------


async def test_trade_accept_no_character_sends_modal(fresh_game: Game) -> None:
    ix = make_interaction(discord_user_id=999)
    await trade_accept_command(ix, trade_id="abcd1234")
    ix.response.send_modal.assert_called_once()
    ix.response.send_message.assert_not_called()


async def test_trade_accept_not_your_turn(fresh_game: Game) -> None:
    char_a = fresh_game.create_character("Alice", discord_user_id="111")
    char_b = fresh_game.create_character("Bob", discord_user_id="222")
    give_item(fresh_game, char_a, Item.REGULAR_LOG, 10)
    trade = fresh_game.create_trade(char_a, char_b, {Item.REGULAR_LOG: 5}, {})
    trade_id_prefix = str(trade.id)[:8]
    # It's Bob's turn; Alice can't accept
    ix = make_interaction(discord_user_id=111)
    await trade_accept_command(ix, trade_id=trade_id_prefix)
    kwargs = last_send_kwargs(ix)
    assert kwargs.get("ephemeral") is True


async def test_trade_accept_happy_path(fresh_game: Game) -> None:
    char_a = fresh_game.create_character("Alice", discord_user_id="111")
    char_b = fresh_game.create_character("Bob", discord_user_id="222")
    give_item(fresh_game, char_a, Item.REGULAR_LOG, 10)
    trade = fresh_game.create_trade(char_a, char_b, {Item.REGULAR_LOG: 5}, {})
    trade_id_prefix = str(trade.id)[:8]
    ix = make_interaction(discord_user_id=222)
    await trade_accept_command(ix, trade_id=trade_id_prefix)
    embed = get_embed(ix)
    assert "Trade Accepted" in (embed.title or "")
    fresh_game._session.refresh(char_b)
    assert fresh_game._get_character_item(char_b, Item.REGULAR_LOG).quantity == 5


async def test_trade_accept_insufficient_items(fresh_game: Game) -> None:
    char_a = fresh_game.create_character("Alice", discord_user_id="111")
    char_b = fresh_game.create_character("Bob", discord_user_id="222")
    give_item(fresh_game, char_a, Item.REGULAR_LOG, 10)
    trade = fresh_game.create_trade(char_a, char_b, {Item.REGULAR_LOG: 5}, {})
    # Drain Alice's logs after trade was created
    give_item(fresh_game, char_a, Item.REGULAR_LOG, 0)
    trade_id_prefix = str(trade.id)[:8]
    ix = make_interaction(discord_user_id=222)
    await trade_accept_command(ix, trade_id=trade_id_prefix)
    kwargs = last_send_kwargs(ix)
    assert kwargs.get("ephemeral") is True


# ---------------------------------------------------------------------------
# /trade reject
# ---------------------------------------------------------------------------


async def test_trade_reject_no_character_sends_modal(fresh_game: Game) -> None:
    ix = make_interaction(discord_user_id=999)
    await trade_reject_command(ix, trade_id="abcd1234")
    ix.response.send_modal.assert_called_once()
    ix.response.send_message.assert_not_called()


async def test_trade_reject_invalid_trade_id(fresh_game: Game) -> None:
    fresh_game.create_character("Alice", discord_user_id="111")
    ix = make_interaction(discord_user_id=111)
    await trade_reject_command(ix, trade_id="notreal")
    kwargs = last_send_kwargs(ix)
    assert kwargs.get("ephemeral") is True


async def test_trade_reject_happy_path(fresh_game: Game) -> None:
    char_a = fresh_game.create_character("Alice", discord_user_id="111")
    char_b = fresh_game.create_character("Bob", discord_user_id="222")
    give_item(fresh_game, char_a, Item.REGULAR_LOG, 10)
    trade = fresh_game.create_trade(char_a, char_b, {Item.REGULAR_LOG: 5}, {})
    trade_id_prefix = str(trade.id)[:8]
    ix = make_interaction(discord_user_id=222)
    await trade_reject_command(ix, trade_id=trade_id_prefix)
    ix.response.send_message.assert_called_once()
    assert trade.status == TradeStatus.REJECTED


# ---------------------------------------------------------------------------
# /trade counter
# ---------------------------------------------------------------------------


async def test_trade_counter_no_character_sends_modal(fresh_game: Game) -> None:
    ix = make_interaction(discord_user_id=999)
    await trade_counter_command(ix, trade_id="abcd1234")
    ix.response.send_modal.assert_called_once()
    ix.response.send_message.assert_not_called()


async def test_trade_counter_invalid_trade_id(fresh_game: Game) -> None:
    fresh_game.create_character("Alice", discord_user_id="111")
    ix = make_interaction(discord_user_id=111)
    await trade_counter_command(ix, trade_id="notreal")
    kwargs = last_send_kwargs(ix)
    assert kwargs.get("ephemeral") is True


async def test_trade_counter_not_your_turn(fresh_game: Game) -> None:
    char_a = fresh_game.create_character("Alice", discord_user_id="111")
    char_b = fresh_game.create_character("Bob", discord_user_id="222")
    give_item(fresh_game, char_a, Item.REGULAR_LOG, 10)
    trade = fresh_game.create_trade(char_a, char_b, {Item.REGULAR_LOG: 5}, {})
    trade_id_prefix = str(trade.id)[:8]
    # It's Bob's turn; Alice can't counter
    ix = make_interaction(discord_user_id=111)
    await trade_counter_command(ix, trade_id=trade_id_prefix, give="REGULAR_LOG:3")
    kwargs = last_send_kwargs(ix)
    assert kwargs.get("ephemeral") is True


async def test_trade_counter_happy_path_as_counterparty(fresh_game: Game) -> None:
    char_a = fresh_game.create_character("Alice", discord_user_id="111")
    char_b = fresh_game.create_character("Bob", discord_user_id="222")
    give_item(fresh_game, char_a, Item.REGULAR_LOG, 10)
    give_item(fresh_game, char_b, Item.BRONZE_BAR, 5)
    trade = fresh_game.create_trade(char_a, char_b, {Item.REGULAR_LOG: 5}, {})
    trade_id_prefix = str(trade.id)[:8]
    # Bob (counterparty) counters: he gives 2 bronze bars, wants 3 logs
    ix = make_interaction(discord_user_id=222)
    await trade_counter_command(ix, trade_id=trade_id_prefix, give="BRONZE_BAR:2", want="REGULAR_LOG:3")
    embed = get_embed(ix)
    assert "Counter-Offer" in (embed.title or "")
    assert len(trade.offers) == 2


async def test_trade_counter_happy_path_as_initiator(fresh_game: Game) -> None:
    char_a = fresh_game.create_character("Alice", discord_user_id="111")
    char_b = fresh_game.create_character("Bob", discord_user_id="222")
    give_item(fresh_game, char_a, Item.REGULAR_LOG, 10)
    give_item(fresh_game, char_b, Item.BRONZE_BAR, 5)
    trade = fresh_game.create_trade(char_a, char_b, {Item.REGULAR_LOG: 5}, {})
    # Bob counters first (via game layer directly)
    fresh_game.counter_trade(trade, char_b, {Item.REGULAR_LOG: 3}, {Item.BRONZE_BAR: 2})
    # Now it's Alice's turn; she counters back
    trade_id_prefix = str(trade.id)[:8]
    ix = make_interaction(discord_user_id=111)
    await trade_counter_command(ix, trade_id=trade_id_prefix, give="REGULAR_LOG:4", want="BRONZE_BAR:1")
    embed = get_embed(ix)
    assert "Counter-Offer" in (embed.title or "")
    assert len(trade.offers) == 3


# ---------------------------------------------------------------------------
# Cross-cutting
# ---------------------------------------------------------------------------


async def test_duplicate_interaction_is_no_op(fresh_game: Game) -> None:
    fresh_game.create_character("DupTest", discord_user_id="111")
    ix = make_interaction(discord_user_id=111)
    await status_command(ix)
    assert ix.response.send_message.call_count == 1
    # Replay the exact same interaction object (same ix.id)
    await status_command(ix)
    assert ix.response.send_message.call_count == 1


async def test_get_or_prompt_character_no_character(fresh_game: Game) -> None:
    ix = make_interaction(discord_user_id=888)
    result = await bot_module.get_or_prompt_character(ix)
    assert result is None
    ix.response.send_modal.assert_called_once()


# ---------------------------------------------------------------------------
# /combat enter
# ---------------------------------------------------------------------------


async def test_combat_enter_no_character_sends_modal(fresh_game: Game) -> None:
    ix = make_interaction(discord_user_id=999)
    await combat_enter_command(ix, area="GOBLIN_CAVE")
    ix.response.send_modal.assert_called_once()
    ix.response.send_message.assert_not_called()


async def test_combat_enter_valid_area(fresh_game: Game) -> None:
    fresh_game.create_character("Fighter", discord_user_id="111")
    ix = make_interaction(discord_user_id=111)
    await combat_enter_command(ix, area="GOBLIN_CAVE")
    embed = get_embed(ix)
    assert "Goblin Cave" in (embed.title or "")


async def test_combat_enter_already_in_combat(fresh_game: Game) -> None:
    char = fresh_game.create_character("Fighter", discord_user_id="111")
    fresh_game.enter_combat(char, CombatArea.GOBLIN_CAVE)
    ix = make_interaction(discord_user_id=111)
    await combat_enter_command(ix, area="GOBLIN_CAVE")
    kwargs = last_send_kwargs(ix)
    assert kwargs.get("ephemeral") is True


async def test_combat_enter_busy_with_activity(fresh_game: Game) -> None:
    char = fresh_game.create_character("Fighter", discord_user_id="111")
    fresh_game.start_activity(char, Activity.CHOP_REGULAR_TREE)
    ix = make_interaction(discord_user_id=111)
    await combat_enter_command(ix, area="GOBLIN_CAVE")
    kwargs = last_send_kwargs(ix)
    assert kwargs.get("ephemeral") is True


# ---------------------------------------------------------------------------
# /combat end
# ---------------------------------------------------------------------------


async def test_combat_end_no_character_sends_modal(fresh_game: Game) -> None:
    ix = make_interaction(discord_user_id=999)
    await combat_end_command(ix)
    ix.response.send_modal.assert_called_once()
    ix.response.send_message.assert_not_called()


async def test_combat_end_no_active_combat(fresh_game: Game) -> None:
    fresh_game.create_character("Fighter", discord_user_id="111")
    ix = make_interaction(discord_user_id=111)
    await combat_end_command(ix)
    kwargs = last_send_kwargs(ix)
    assert kwargs.get("ephemeral") is True


async def test_combat_end_survived(fresh_game: Game) -> None:
    char = fresh_game.create_character("Fighter", discord_user_id="111")
    session = fresh_game.enter_combat(char, CombatArea.GOBLIN_CAVE)
    # Backdate so some time elapsed
    session.started_at = now_utc() - datetime.timedelta(seconds=1)
    fresh_game._session.commit()
    ix = make_interaction(discord_user_id=111)
    await combat_end_command(ix)
    embed = get_embed(ix)
    assert embed is not None
    # "Kills" on survival, "Kills before death" on death
    assert any("Kills" in (f.name or "") for f in embed.fields)


async def test_combat_end_death_shows_recovery_info(fresh_game: Game) -> None:
    """When the character dies, the result embed mentions recovery."""
    char = fresh_game.create_character("Fighter", discord_user_id="111")
    # Give bare-minimum equipment so death is possible (no food, no armor)
    session = fresh_game.enter_combat(char, CombatArea.DARK_DUNGEON)
    # Backdate 24 hours — guaranteed death with no gear
    session.started_at = now_utc() - datetime.timedelta(hours=24)
    fresh_game._session.commit()
    ix = make_interaction(discord_user_id=111)
    await combat_end_command(ix)
    embed = get_embed(ix)
    # Either survived or died — we just want the command to return an embed
    assert embed is not None


# ---------------------------------------------------------------------------
# /combat status
# ---------------------------------------------------------------------------


async def test_combat_status_no_character_sends_modal(fresh_game: Game) -> None:
    ix = make_interaction(discord_user_id=999)
    await combat_status_command(ix)
    ix.response.send_modal.assert_called_once()
    ix.response.send_message.assert_not_called()


async def test_combat_status_no_active_combat(fresh_game: Game) -> None:
    fresh_game.create_character("Fighter", discord_user_id="111")
    ix = make_interaction(discord_user_id=111)
    await combat_status_command(ix)
    kwargs = last_send_kwargs(ix)
    assert kwargs.get("ephemeral") is True


async def test_combat_status_active_session(fresh_game: Game) -> None:
    char = fresh_game.create_character("Fighter", discord_user_id="111")
    fresh_game.enter_combat(char, CombatArea.GOBLIN_CAVE)
    ix = make_interaction(discord_user_id=111)
    await combat_status_command(ix)
    embed = get_embed(ix)
    assert "In Combat" in (embed.title or "")
    assert any(f.name == "Area" for f in embed.fields)


# ---------------------------------------------------------------------------
# /equipment view
# ---------------------------------------------------------------------------


async def test_equipment_view_no_character_sends_modal(fresh_game: Game) -> None:
    ix = make_interaction(discord_user_id=999)
    await equipment_view_command(ix)
    ix.response.send_modal.assert_called_once()
    ix.response.send_message.assert_not_called()


async def test_equipment_view_nothing_equipped(fresh_game: Game) -> None:
    fresh_game.create_character("Fighter", discord_user_id="111")
    ix = make_interaction(discord_user_id=111)
    await equipment_view_command(ix)
    embed = get_embed(ix)
    assert "Equipment" in (embed.title or "")
    assert "(empty)" in (embed.description or "")


async def test_equipment_view_weapon_shown(fresh_game: Game) -> None:
    char = fresh_game.create_character("Fighter", discord_user_id="111")
    give_item(fresh_game, char, Item.BRONZE_SCIMITAR, 1)
    fresh_game.equip_item(char, Item.BRONZE_SCIMITAR)
    ix = make_interaction(discord_user_id=111)
    await equipment_view_command(ix)
    embed = get_embed(ix)
    assert "Bronze Scimitar" in (embed.description or "")


# ---------------------------------------------------------------------------
# /equipment equip
# ---------------------------------------------------------------------------


async def test_equipment_equip_no_character_sends_modal(fresh_game: Game) -> None:
    ix = make_interaction(discord_user_id=999)
    await equipment_equip_command(ix, item="BRONZE_SCIMITAR")
    ix.response.send_modal.assert_called_once()
    ix.response.send_message.assert_not_called()


async def test_equipment_equip_not_in_inventory(fresh_game: Game) -> None:
    fresh_game.create_character("Fighter", discord_user_id="111")
    ix = make_interaction(discord_user_id=111)
    await equipment_equip_command(ix, item="BRONZE_SCIMITAR")
    kwargs = last_send_kwargs(ix)
    assert kwargs.get("ephemeral") is True


async def test_equipment_equip_level_requirement_not_met(fresh_game: Game) -> None:
    char = fresh_game.create_character("Fighter", discord_user_id="111")
    give_item(fresh_game, char, Item.RUNITE_SCIMITAR, 1)
    ix = make_interaction(discord_user_id=111)
    await equipment_equip_command(ix, item="RUNITE_SCIMITAR")
    kwargs = last_send_kwargs(ix)
    assert kwargs.get("ephemeral") is True


async def test_equipment_equip_valid(fresh_game: Game) -> None:
    char = fresh_game.create_character("Fighter", discord_user_id="111")
    give_item(fresh_game, char, Item.BRONZE_SCIMITAR, 1)
    ix = make_interaction(discord_user_id=111)
    await equipment_equip_command(ix, item="BRONZE_SCIMITAR")
    ix.response.send_message.assert_called_once()
    kwargs = last_send_kwargs(ix)
    assert kwargs.get("ephemeral") is not True


# ---------------------------------------------------------------------------
# /equipment equip-food
# ---------------------------------------------------------------------------


async def test_equipment_equip_food_no_character_sends_modal(fresh_game: Game) -> None:
    ix = make_interaction(discord_user_id=999)
    await equipment_equip_food_command(ix, item="COOKED_LOBSTER", quantity=5)
    ix.response.send_modal.assert_called_once()
    ix.response.send_message.assert_not_called()


async def test_equipment_equip_food_not_a_food_item(fresh_game: Game) -> None:
    char = fresh_game.create_character("Fighter", discord_user_id="111")
    give_item(fresh_game, char, Item.BRONZE_SCIMITAR, 1)
    ix = make_interaction(discord_user_id=111)
    await equipment_equip_food_command(ix, item="BRONZE_SCIMITAR", quantity=1)
    kwargs = last_send_kwargs(ix)
    assert kwargs.get("ephemeral") is True


async def test_equipment_equip_food_not_enough_in_inventory(fresh_game: Game) -> None:
    char = fresh_game.create_character("Fighter", discord_user_id="111")
    give_item(fresh_game, char, Item.COOKED_LOBSTER, 2)
    ix = make_interaction(discord_user_id=111)
    await equipment_equip_food_command(ix, item="COOKED_LOBSTER", quantity=10)
    kwargs = last_send_kwargs(ix)
    assert kwargs.get("ephemeral") is True


async def test_equipment_equip_food_valid(fresh_game: Game) -> None:
    char = fresh_game.create_character("Fighter", discord_user_id="111")
    give_item(fresh_game, char, Item.COOKED_LOBSTER, 10)
    ix = make_interaction(discord_user_id=111)
    await equipment_equip_food_command(ix, item="COOKED_LOBSTER", quantity=5)
    ix.response.send_message.assert_called_once()
    kwargs = last_send_kwargs(ix)
    assert kwargs.get("ephemeral") is not True


# ---------------------------------------------------------------------------
# /equipment unequip
# ---------------------------------------------------------------------------


async def test_equipment_unequip_no_character_sends_modal(fresh_game: Game) -> None:
    ix = make_interaction(discord_user_id=999)
    await equipment_unequip_command(ix, slot="WEAPON")
    ix.response.send_modal.assert_called_once()
    ix.response.send_message.assert_not_called()


async def test_equipment_unequip_nothing_in_slot(fresh_game: Game) -> None:
    fresh_game.create_character("Fighter", discord_user_id="111")
    ix = make_interaction(discord_user_id=111)
    await equipment_unequip_command(ix, slot="WEAPON")
    kwargs = last_send_kwargs(ix)
    assert kwargs.get("ephemeral") is True


async def test_equipment_unequip_valid(fresh_game: Game) -> None:
    char = fresh_game.create_character("Fighter", discord_user_id="111")
    give_item(fresh_game, char, Item.BRONZE_SCIMITAR, 1)
    fresh_game.equip_item(char, Item.BRONZE_SCIMITAR)
    ix = make_interaction(discord_user_id=111)
    await equipment_unequip_command(ix, slot="WEAPON")
    ix.response.send_message.assert_called_once()
    kwargs = last_send_kwargs(ix)
    assert kwargs.get("ephemeral") is not True
    # Item returned to inventory
    fresh_game._session.refresh(char)
    inv = fresh_game._get_character_item(char, Item.BRONZE_SCIMITAR)
    assert inv.quantity == 1


# ---------------------------------------------------------------------------
# /equipment recover
# ---------------------------------------------------------------------------


async def test_equipment_recover_no_character_sends_modal(fresh_game: Game) -> None:
    ix = make_interaction(discord_user_id=999)
    await equipment_recover_command(ix)
    ix.response.send_modal.assert_called_once()
    ix.response.send_message.assert_not_called()


async def test_equipment_recover_no_pending_recovery(fresh_game: Game) -> None:
    fresh_game.create_character("Fighter", discord_user_id="111")
    ix = make_interaction(discord_user_id=111)
    await equipment_recover_command(ix)
    kwargs = last_send_kwargs(ix)
    assert kwargs.get("ephemeral") is True


async def test_equipment_recover_valid(fresh_game: Game) -> None:
    char = fresh_game.create_character("Fighter", discord_user_id="111")
    give_item(fresh_game, char, Item.BRONZE_SCIMITAR, 1)
    fresh_game.equip_item(char, Item.BRONZE_SCIMITAR)
    # Simulate a death: create a session with died_at set recently
    session = fresh_game.enter_combat(char, CombatArea.GOBLIN_CAVE)
    session.ended_at = now_utc()
    session.died_at = now_utc()
    fresh_game._session.commit()
    ix = make_interaction(discord_user_id=111)
    await equipment_recover_command(ix)
    embed = get_embed(ix)
    assert "Recovered" in (embed.title or "")
    # Item should be back in inventory
    fresh_game._session.refresh(char)
    inv = fresh_game._get_character_item(char, Item.BRONZE_SCIMITAR)
    assert inv.quantity == 1
