"""Tests for src/adapters/discord_bot.py — Discord bot adapter."""

import json
import logging
import signal
import pytest
from unittest.mock import AsyncMock, MagicMock, patch

import discord

from src.config import Config
from src.adapters import discord_bot
from src.adapters.discord_bot import (
    create_bot,
    is_admin,
    is_owner,
    is_registered_channel,
    split_message,
)


# --- Fixtures ---


@pytest.fixture
def tmp_config(tmp_path):
    """Create a Config backed by a temp file with default data."""
    data = {
        "bot": {
            "name": "Echo",
            "default_model": "sonnet",
            "owner": None,
            "admins": [],
        },
        "channels": {},
    }
    config_file = tmp_path / "config.json"
    config_file.write_text(json.dumps(data, indent=2))
    return Config(config_file)


@pytest.fixture
def owned_config(tmp_path):
    """Config with owner already set."""
    data = {
        "bot": {
            "name": "Echo",
            "default_model": "sonnet",
            "owner": "111",
            "admins": ["222"],
        },
        "channels": {
            "general": {"id": "900", "default_model": "sonnet"},
        },
    }
    config_file = tmp_path / "config.json"
    config_file.write_text(json.dumps(data, indent=2))
    return Config(config_file)


@pytest.fixture
def bot(tmp_config):
    """Create a bot with fresh (no-owner) config, return the client."""
    return create_bot(tmp_config)


@pytest.fixture
def owned_bot(owned_config):
    """Create a bot with owned config, return the client."""
    return create_bot(owned_config)


def _mock_interaction(user_id="123", channel_id="456"):
    """Create a mock discord.Interaction."""
    interaction = AsyncMock(spec=discord.Interaction)
    interaction.user = MagicMock()
    interaction.user.id = int(user_id)
    interaction.channel_id = int(channel_id)
    interaction.response = AsyncMock()
    interaction.response.send_message = AsyncMock()
    return interaction


def _find_command(tree, name):
    """Find a top-level command on the tree by name."""
    for cmd in tree.get_commands():
        if cmd.name == name:
            return cmd
    return None


def _find_subcommand(tree, group_name, sub_name):
    """Find a subcommand inside a group on the tree."""
    group = _find_command(tree, group_name)
    if group is None or not isinstance(group, discord.app_commands.Group):
        return None
    for cmd in group.commands:
        if cmd.name == sub_name:
            return cmd
    return None


# --- Authorization helpers ---


class TestIsOwner:
    def test_is_owner_true(self, owned_bot, owned_config):
        assert is_owner("111") is True

    def test_is_owner_false(self, owned_bot, owned_config):
        assert is_owner("999") is False

    def test_is_owner_none_owner(self, bot, tmp_config):
        assert is_owner("123") is False


class TestIsAdmin:
    def test_is_admin_owner_is_admin(self, owned_bot):
        assert is_admin("111") is True

    def test_is_admin_listed(self, owned_bot):
        assert is_admin("222") is True

    def test_is_admin_not_listed(self, owned_bot):
        assert is_admin("999") is False


class TestIsRegisteredChannel:
    def test_is_registered_channel_true(self, owned_bot):
        assert is_registered_channel("900") is True

    def test_is_registered_channel_false(self, owned_bot):
        assert is_registered_channel("000") is False

    def test_is_registered_channel_empty(self, bot):
        assert is_registered_channel("900") is False


# --- create_bot ---


class TestCreateBot:
    def test_create_bot_returns_client(self, bot):
        assert isinstance(bot, discord.Client)

    def test_create_bot_has_tree(self, bot):
        assert hasattr(bot, "tree")
        assert isinstance(bot.tree, discord.app_commands.CommandTree)

    def test_create_bot_intents_message_content(self, bot):
        assert bot.intents.message_content is True


# --- Slash commands ---


class TestPing:
    @pytest.mark.asyncio
    async def test_ping_responds(self, bot):
        cmd = _find_command(bot.tree, "ping")
        assert cmd is not None

        interaction = _mock_interaction()
        with patch.object(type(bot), "latency", new_callable=lambda: property(lambda self: 0.042)):
            await cmd.callback(interaction)

        interaction.response.send_message.assert_awaited_once()
        msg = interaction.response.send_message.call_args
        assert "Pong!" in msg.args[0] or "Pong!" in msg.kwargs.get("content", msg.args[0] if msg.args else "")
        assert msg.kwargs.get("ephemeral") is True


class TestHelp:
    @pytest.mark.asyncio
    async def test_help_responds_with_commands(self, bot):
        cmd = _find_command(bot.tree, "help")
        assert cmd is not None

        interaction = _mock_interaction()
        await cmd.callback(interaction)

        interaction.response.send_message.assert_awaited_once()
        msg_text = interaction.response.send_message.call_args.args[0]
        assert "/ping" in msg_text
        assert "/help" in msg_text
        assert "/setup" in msg_text
        assert msg_text  # non-empty


class TestSetup:
    @pytest.mark.asyncio
    async def test_setup_sets_owner(self, bot, tmp_config):
        cmd = _find_command(bot.tree, "setup")
        interaction = _mock_interaction(user_id="123")
        await cmd.callback(interaction)

        interaction.response.send_message.assert_awaited_once()
        msg = interaction.response.send_message.call_args
        assert "owner of Echo" in msg.args[0]
        assert tmp_config.get("bot.owner") == "123"

    @pytest.mark.asyncio
    async def test_setup_already_set(self, owned_bot, owned_config):
        cmd = _find_command(owned_bot.tree, "setup")
        interaction = _mock_interaction(user_id="999")
        await cmd.callback(interaction)

        msg = interaction.response.send_message.call_args
        assert "already set" in msg.args[0].lower()


class TestChannelAdd:
    @pytest.mark.asyncio
    async def test_channel_add_as_owner(self, owned_bot, owned_config):
        cmd = _find_subcommand(owned_bot.tree, "channel", "add")
        assert cmd is not None

        interaction = _mock_interaction(user_id="111", channel_id="456")
        await cmd.callback(interaction, alias="work")

        interaction.response.send_message.assert_awaited_once()
        msg = interaction.response.send_message.call_args
        assert "work" in msg.args[0]
        assert owned_config.get("channels.work") is not None
        assert owned_config.get("channels.work")["id"] == "456"

    @pytest.mark.asyncio
    async def test_channel_add_not_owner(self, owned_bot, owned_config):
        cmd = _find_subcommand(owned_bot.tree, "channel", "add")
        interaction = _mock_interaction(user_id="999", channel_id="456")
        await cmd.callback(interaction, alias="work")

        msg = interaction.response.send_message.call_args
        assert "owner only" in msg.args[0].lower()

    @pytest.mark.asyncio
    async def test_channel_add_saves_to_config(self, owned_bot, owned_config):
        cmd = _find_subcommand(owned_bot.tree, "channel", "add")
        interaction = _mock_interaction(user_id="111", channel_id="789")
        await cmd.callback(interaction, alias="dev")

        # Reload config from disk to verify save() was called
        owned_config.reload()
        assert owned_config.get("channels.dev")["id"] == "789"
        assert owned_config.get("channels.dev")["default_model"] == "sonnet"


class TestChannels:
    @pytest.mark.asyncio
    async def test_channels_lists(self, owned_bot, owned_config):
        cmd = _find_command(owned_bot.tree, "channels")
        interaction = _mock_interaction()
        await cmd.callback(interaction)

        msg = interaction.response.send_message.call_args
        text = msg.args[0]
        assert "general" in text
        assert "900" in text

    @pytest.mark.asyncio
    async def test_channels_empty(self, bot, tmp_config):
        cmd = _find_command(bot.tree, "channels")
        interaction = _mock_interaction()
        await cmd.callback(interaction)

        msg = interaction.response.send_message.call_args
        assert "no channels" in msg.args[0].lower()


class TestAdminAdd:
    @pytest.mark.asyncio
    async def test_admin_add_as_owner(self, owned_bot, owned_config):
        cmd = _find_subcommand(owned_bot.tree, "admin", "add")
        assert cmd is not None

        interaction = _mock_interaction(user_id="111")
        await cmd.callback(interaction, user_id="333")

        msg = interaction.response.send_message.call_args
        assert "333" in msg.args[0]
        assert "333" in owned_config.get("bot.admins")

    @pytest.mark.asyncio
    async def test_admin_add_not_owner(self, owned_bot, owned_config):
        cmd = _find_subcommand(owned_bot.tree, "admin", "add")
        interaction = _mock_interaction(user_id="999")
        await cmd.callback(interaction, user_id="333")

        msg = interaction.response.send_message.call_args
        assert "owner only" in msg.args[0].lower()

    @pytest.mark.asyncio
    async def test_admin_add_idempotent(self, owned_bot, owned_config):
        cmd = _find_subcommand(owned_bot.tree, "admin", "add")
        interaction = _mock_interaction(user_id="111")
        # Add "222" which is already in admins list
        await cmd.callback(interaction, user_id="222")

        admins = owned_config.get("bot.admins")
        assert admins.count("222") == 1  # no duplicates

    @pytest.mark.asyncio
    async def test_admin_add_saves_to_config(self, owned_bot, owned_config):
        cmd = _find_subcommand(owned_bot.tree, "admin", "add")
        interaction = _mock_interaction(user_id="111")
        await cmd.callback(interaction, user_id="444")

        owned_config.reload()
        assert "444" in owned_config.get("bot.admins")


# --- on_message ---


class TestOnMessage:
    def _get_on_message(self, client):
        """Extract the on_message handler from the client's event listeners."""
        # discord.py Client stores overridden events; we can call it directly
        # The on_message is registered via @client.event decorator
        return client.on_message

    @pytest.mark.asyncio
    async def test_ignores_own_messages(self, owned_bot):
        on_message = self._get_on_message(owned_bot)
        message = MagicMock(spec=discord.Message)
        message.author = owned_bot.user  # bot's own user
        message.channel = MagicMock()

        # Should return without logging (no error)
        await on_message(message)

    @pytest.mark.asyncio
    async def test_ignores_unregistered_channel(self, owned_bot, caplog):
        on_message = self._get_on_message(owned_bot)
        message = MagicMock(spec=discord.Message)
        message.author = MagicMock()
        message.author.id = 999
        message.author != owned_bot.user  # not the bot
        # Make sure author comparison returns False
        message.author.__eq__ = lambda self, other: False
        message.channel = MagicMock(spec=discord.TextChannel)
        message.channel.id = 12345  # not registered
        message.content = "hello"

        with caplog.at_level(logging.INFO, logger="echo-core.discord"):
            await on_message(message)

        assert "registered channel" not in caplog.text.lower()

    @pytest.mark.asyncio
    async def test_logs_registered_channel(self, owned_bot, caplog):
        on_message = self._get_on_message(owned_bot)
        message = MagicMock(spec=discord.Message)
        message.author = MagicMock()
        message.author.id = 555
        message.author.__eq__ = lambda self, other: False
        message.channel = MagicMock(spec=discord.TextChannel)
        message.channel.id = 900  # registered channel
        message.content = "hello world"

        with caplog.at_level(logging.INFO, logger="echo-core.discord"):
            await on_message(message)

        assert "registered channel" in caplog.text.lower()

    @pytest.mark.asyncio
    async def test_ignores_non_admin_dm(self, owned_bot, caplog):
        on_message = self._get_on_message(owned_bot)
        message = MagicMock(spec=discord.Message)
        message.author = MagicMock()
        message.author.id = 999  # not admin
        message.author.__eq__ = lambda self, other: False
        message.channel = MagicMock(spec=discord.DMChannel)
        message.content = "hello"

        with caplog.at_level(logging.INFO, logger="echo-core.discord"):
            await on_message(message)

        assert "dm from admin" not in caplog.text.lower()

    @pytest.mark.asyncio
    async def test_logs_admin_dm(self, owned_bot, caplog):
        on_message = self._get_on_message(owned_bot)
        message = MagicMock(spec=discord.Message)
        message.author = MagicMock()
        message.author.id = 222  # in admins list
        message.author.__eq__ = lambda self, other: False
        message.channel = MagicMock(spec=discord.DMChannel)
        message.content = "admin message"

        with caplog.at_level(logging.INFO, logger="echo-core.discord"):
            await on_message(message)

        assert "dm from admin" in caplog.text.lower()

    @pytest.mark.asyncio
    @patch("src.adapters.discord_bot.route_message")
    async def test_chat_flow(self, mock_route, owned_bot):
        """on_message chat flow: reaction, typing, route, send, cleanup."""
        mock_route.return_value = ("Hello from Claude!", False)

        on_message = self._get_on_message(owned_bot)

        message = AsyncMock(spec=discord.Message)
        message.author = MagicMock()
        message.author.id = 555
        message.author.__eq__ = lambda self, other: False
        message.channel = AsyncMock(spec=discord.TextChannel)
        message.channel.id = 900  # registered channel
        message.content = "test message"

        await on_message(message)

        # Verify eyes reaction added
        message.add_reaction.assert_awaited_once_with("\U0001f440")

        # Verify typing indicator was triggered
        message.channel.typing.assert_called_once()

        # Verify response sent
        message.channel.send.assert_awaited_once_with("Hello from Claude!")

        # Verify eyes reaction removed
        message.remove_reaction.assert_awaited_once()


# --- split_message ---


class TestSplitMessage:
    def test_short_text_no_split(self):
        result = split_message("hello")
        assert result == ["hello"]

    def test_long_text_split_at_newline(self):
        text = "a" * 10 + "\n" + "b" * 10
        result = split_message(text, limit=15)
        assert result == ["a" * 10, "b" * 10]

    def test_very_long_without_newlines_hard_split(self):
        text = "a" * 30
        result = split_message(text, limit=10)
        assert result == ["a" * 10, "a" * 10, "a" * 10]


# --- /clear slash command ---


class TestClearSlashCommand:
    @pytest.mark.asyncio
    @patch("src.adapters.discord_bot.clear_session")
    async def test_clear_with_session(self, mock_clear, owned_bot):
        mock_clear.return_value = True
        cmd = _find_command(owned_bot.tree, "clear")
        interaction = _mock_interaction(channel_id="900")
        await cmd.callback(interaction)

        msg = interaction.response.send_message.call_args
        assert "session cleared" in msg.args[0].lower()

    @pytest.mark.asyncio
    @patch("src.adapters.discord_bot.clear_session")
    async def test_clear_no_session(self, mock_clear, owned_bot):
        mock_clear.return_value = False
        cmd = _find_command(owned_bot.tree, "clear")
        interaction = _mock_interaction(channel_id="900")
        await cmd.callback(interaction)

        msg = interaction.response.send_message.call_args
        assert "no active session" in msg.args[0].lower()


# --- /status slash command ---


class TestStatusSlashCommand:
    @pytest.mark.asyncio
    @patch("src.adapters.discord_bot.get_active_session")
    async def test_status_with_session(self, mock_get, owned_bot):
        mock_get.return_value = {
            "session_id": "abcdef1234567890",
            "model": "sonnet",
            "message_count": 3,
        }
        cmd = _find_command(owned_bot.tree, "status")
        interaction = _mock_interaction(channel_id="900")
        await cmd.callback(interaction)

        msg = interaction.response.send_message.call_args
        text = msg.args[0] if msg.args else msg.kwargs.get("content", "")
        assert "sonnet" in text
        assert "3" in text

    @pytest.mark.asyncio
    @patch("src.adapters.discord_bot.get_active_session")
    async def test_status_no_session(self, mock_get, owned_bot):
        mock_get.return_value = None
        cmd = _find_command(owned_bot.tree, "status")
        interaction = _mock_interaction(channel_id="900")
        await cmd.callback(interaction)

        msg = interaction.response.send_message.call_args
        assert "no active session" in msg.args[0].lower()


# --- /clear mentions model reset ---


class TestClearMentionsModelReset:
    @pytest.mark.asyncio
    @patch("src.adapters.discord_bot.clear_session")
    async def test_clear_mentions_model_reset(self, mock_clear, owned_bot):
        mock_clear.return_value = True
        cmd = _find_command(owned_bot.tree, "clear")
        interaction = _mock_interaction(channel_id="900")
        await cmd.callback(interaction)

        msg = interaction.response.send_message.call_args
        text = msg.args[0]
        assert "model reset" in text.lower()
        assert "sonnet" in text.lower()


# --- /model slash command ---


class TestModelSlashCommand:
    @pytest.mark.asyncio
    @patch("src.adapters.discord_bot.get_active_session")
    async def test_model_no_args_shows_current_with_session(self, mock_get, owned_bot):
        mock_get.return_value = {"model": "opus"}
        cmd = _find_command(owned_bot.tree, "model")
        interaction = _mock_interaction(channel_id="900")
        await cmd.callback(interaction, choice=None)

        msg = interaction.response.send_message.call_args
        text = msg.args[0]
        assert "opus" in text.lower()
        assert "haiku" in text
        assert "sonnet" in text
        assert msg.kwargs.get("ephemeral") is True

    @pytest.mark.asyncio
    @patch("src.adapters.discord_bot.get_active_session")
    async def test_model_no_args_shows_default_without_session(self, mock_get, owned_bot):
        mock_get.return_value = None
        cmd = _find_command(owned_bot.tree, "model")
        interaction = _mock_interaction(channel_id="900")
        await cmd.callback(interaction, choice=None)

        msg = interaction.response.send_message.call_args
        text = msg.args[0]
        assert "sonnet" in text  # default from config

    @pytest.mark.asyncio
    @patch("src.adapters.discord_bot.set_session_model")
    @patch("src.adapters.discord_bot.get_active_session")
    async def test_model_with_choice_changes_existing_session(self, mock_get, mock_set, owned_bot):
        mock_get.return_value = {"model": "sonnet", "session_id": "abc"}
        cmd = _find_command(owned_bot.tree, "model")
        interaction = _mock_interaction(channel_id="900")
        choice = MagicMock()
        choice.value = "opus"
        await cmd.callback(interaction, choice=choice)

        mock_set.assert_called_once_with("900", "opus")
        msg = interaction.response.send_message.call_args
        assert "opus" in msg.args[0]

    @pytest.mark.asyncio
    @patch("src.claude_session._save_sessions")
    @patch("src.claude_session._load_sessions")
    @patch("src.adapters.discord_bot.get_active_session")
    async def test_model_with_choice_presets_when_no_session(self, mock_get, mock_load, mock_save, owned_bot):
        mock_get.return_value = None
        mock_load.return_value = {}
        cmd = _find_command(owned_bot.tree, "model")
        interaction = _mock_interaction(channel_id="900")
        choice = MagicMock()
        choice.value = "haiku"
        await cmd.callback(interaction, choice=choice)

        mock_save.assert_called_once()
        saved_data = mock_save.call_args[0][0]
        assert saved_data["900"]["model"] == "haiku"
        assert saved_data["900"]["session_id"] == ""
        msg = interaction.response.send_message.call_args
        assert "haiku" in msg.args[0]


# --- /restart slash command ---


class TestRestartSlashCommand:
    @pytest.mark.asyncio
    async def test_restart_owner_succeeds(self, owned_bot, tmp_path):
        pid_file = tmp_path / "echo-core.pid"
        pid_file.write_text("12345")
        with patch.object(discord_bot, "PROJECT_ROOT", tmp_path), \
             patch("os.kill") as mock_kill:
            cmd = _find_command(owned_bot.tree, "restart")
            interaction = _mock_interaction(user_id="111")
            await cmd.callback(interaction)

            mock_kill.assert_called_once_with(12345, signal.SIGTERM)
            msg = interaction.response.send_message.call_args
            assert "restarting" in msg.args[0].lower()

    @pytest.mark.asyncio
    async def test_restart_non_owner_rejected(self, owned_bot):
        cmd = _find_command(owned_bot.tree, "restart")
        interaction = _mock_interaction(user_id="999")
        await cmd.callback(interaction)

        msg = interaction.response.send_message.call_args
        assert "owner only" in msg.args[0].lower()

    @pytest.mark.asyncio
    async def test_restart_no_pid_file(self, owned_bot, tmp_path):
        with patch.object(discord_bot, "PROJECT_ROOT", tmp_path):
            cmd = _find_command(owned_bot.tree, "restart")
            interaction = _mock_interaction(user_id="111")
            await cmd.callback(interaction)

            msg = interaction.response.send_message.call_args
            assert "no pid file" in msg.args[0].lower()


# --- /logs slash command ---


class TestLogsSlashCommand:
    @pytest.mark.asyncio
    async def test_logs_returns_code_block(self, owned_bot, tmp_path):
        log_dir = tmp_path / "logs"
        log_dir.mkdir()
        log_file = log_dir / "echo-core.log"
        log_file.write_text("line1\nline2\nline3\n")
        with patch.object(discord_bot, "PROJECT_ROOT", tmp_path):
            cmd = _find_command(owned_bot.tree, "logs")
            interaction = _mock_interaction()
            await cmd.callback(interaction, n=10)

            msg = interaction.response.send_message.call_args
            text = msg.args[0]
            assert "```" in text
            assert "line1" in text
            assert "line3" in text

    @pytest.mark.asyncio
    async def test_logs_no_file(self, owned_bot, tmp_path):
        with patch.object(discord_bot, "PROJECT_ROOT", tmp_path):
            cmd = _find_command(owned_bot.tree, "logs")
            interaction = _mock_interaction()
            await cmd.callback(interaction, n=10)

            msg = interaction.response.send_message.call_args
            assert "no log file" in msg.args[0].lower()


# --- /cron slash commands ---


class TestCronList:
    @pytest.mark.asyncio
    async def test_cron_list_shows_table(self, owned_bot):
        mock_scheduler = MagicMock()
        mock_scheduler.list_jobs.return_value = [
            {
                "name": "daily-summary",
                "cron": "30 6 * * *",
                "channel": "work",
                "model": "sonnet",
                "enabled": True,
                "last_status": "ok",
                "next_run": "2025-01-15T06:30:00+00:00",
            }
        ]
        owned_bot.scheduler = mock_scheduler

        cmd = _find_subcommand(owned_bot.tree, "cron", "list")
        assert cmd is not None
        interaction = _mock_interaction()
        await cmd.callback(interaction)

        msg = interaction.response.send_message.call_args
        text = msg.args[0]
        assert "daily-summary" in text
        assert "30 6 * * *" in text
        assert "```" in text

    @pytest.mark.asyncio
    async def test_cron_list_empty(self, owned_bot):
        mock_scheduler = MagicMock()
        mock_scheduler.list_jobs.return_value = []
        owned_bot.scheduler = mock_scheduler

        cmd = _find_subcommand(owned_bot.tree, "cron", "list")
        interaction = _mock_interaction()
        await cmd.callback(interaction)

        msg = interaction.response.send_message.call_args
        assert "no scheduled jobs" in msg.args[0].lower()

    @pytest.mark.asyncio
    async def test_cron_list_no_scheduler(self, owned_bot):
        cmd = _find_subcommand(owned_bot.tree, "cron", "list")
        interaction = _mock_interaction()
        await cmd.callback(interaction)

        msg = interaction.response.send_message.call_args
        assert "not available" in msg.args[0].lower()


class TestCronRun:
    @pytest.mark.asyncio
    async def test_cron_run_defers_and_runs(self, owned_bot):
        mock_scheduler = MagicMock()
        mock_scheduler.run_job = AsyncMock(return_value="Job output here")
        owned_bot.scheduler = mock_scheduler

        cmd = _find_subcommand(owned_bot.tree, "cron", "run")
        assert cmd is not None
        interaction = _mock_interaction()
        interaction.followup = AsyncMock()
        await cmd.callback(interaction, name="my-job")

        interaction.response.defer.assert_awaited_once()
        mock_scheduler.run_job.assert_awaited_once_with("my-job")
        interaction.followup.send.assert_awaited_once_with("Job output here")

    @pytest.mark.asyncio
    async def test_cron_run_not_found(self, owned_bot):
        mock_scheduler = MagicMock()
        mock_scheduler.run_job = AsyncMock(side_effect=KeyError("Job 'nope' not found"))
        owned_bot.scheduler = mock_scheduler

        cmd = _find_subcommand(owned_bot.tree, "cron", "run")
        interaction = _mock_interaction()
        interaction.followup = AsyncMock()
        await cmd.callback(interaction, name="nope")

        interaction.response.defer.assert_awaited_once()
        msg = interaction.followup.send.call_args
        assert "not found" in msg.args[0].lower()

    @pytest.mark.asyncio
    async def test_cron_run_no_scheduler(self, owned_bot):
        cmd = _find_subcommand(owned_bot.tree, "cron", "run")
        interaction = _mock_interaction()
        await cmd.callback(interaction, name="test")

        msg = interaction.response.send_message.call_args
        assert "not available" in msg.args[0].lower()


class TestCronAdd:
    @pytest.mark.asyncio
    async def test_cron_add_admin_only(self, owned_bot):
        cmd = _find_subcommand(owned_bot.tree, "cron", "add")
        assert cmd is not None
        # Non-admin user
        interaction = _mock_interaction(user_id="999")
        await cmd.callback(interaction, name="test", expression="0 * * * *", model=None)

        msg = interaction.response.send_message.call_args
        assert "admin only" in msg.args[0].lower()

    @pytest.mark.asyncio
    async def test_cron_add_rejects_non_admin(self, owned_bot):
        cmd = _find_subcommand(owned_bot.tree, "cron", "add")
        interaction = _mock_interaction(user_id="888")
        await cmd.callback(interaction, name="test", expression="0 * * * *", model=None)

        msg = interaction.response.send_message.call_args
        assert "admin only" in msg.args[0].lower()


class TestCronRemove:
    @pytest.mark.asyncio
    async def test_cron_remove_admin_only(self, owned_bot):
        cmd = _find_subcommand(owned_bot.tree, "cron", "remove")
        assert cmd is not None
        interaction = _mock_interaction(user_id="999")
        await cmd.callback(interaction, name="test")

        msg = interaction.response.send_message.call_args
        assert "admin only" in msg.args[0].lower()

    @pytest.mark.asyncio
    async def test_cron_remove_success(self, owned_bot):
        mock_scheduler = MagicMock()
        mock_scheduler.remove_job.return_value = True
        owned_bot.scheduler = mock_scheduler

        cmd = _find_subcommand(owned_bot.tree, "cron", "remove")
        interaction = _mock_interaction(user_id="111")  # owner
        await cmd.callback(interaction, name="my-job")

        msg = interaction.response.send_message.call_args
        assert "removed" in msg.args[0].lower()

    @pytest.mark.asyncio
    async def test_cron_remove_not_found(self, owned_bot):
        mock_scheduler = MagicMock()
        mock_scheduler.remove_job.return_value = False
        owned_bot.scheduler = mock_scheduler

        cmd = _find_subcommand(owned_bot.tree, "cron", "remove")
        interaction = _mock_interaction(user_id="111")
        await cmd.callback(interaction, name="ghost")

        msg = interaction.response.send_message.call_args
        assert "not found" in msg.args[0].lower()


class TestCronEnable:
    @pytest.mark.asyncio
    async def test_cron_enable_admin_only(self, owned_bot):
        cmd = _find_subcommand(owned_bot.tree, "cron", "enable")
        assert cmd is not None
        interaction = _mock_interaction(user_id="999")
        await cmd.callback(interaction, name="test")

        msg = interaction.response.send_message.call_args
        assert "admin only" in msg.args[0].lower()

    @pytest.mark.asyncio
    async def test_cron_enable_success(self, owned_bot):
        mock_scheduler = MagicMock()
        mock_scheduler.enable_job.return_value = True
        owned_bot.scheduler = mock_scheduler

        cmd = _find_subcommand(owned_bot.tree, "cron", "enable")
        interaction = _mock_interaction(user_id="111")
        await cmd.callback(interaction, name="my-job")

        msg = interaction.response.send_message.call_args
        assert "enabled" in msg.args[0].lower()

    @pytest.mark.asyncio
    async def test_cron_enable_not_found(self, owned_bot):
        mock_scheduler = MagicMock()
        mock_scheduler.enable_job.return_value = False
        owned_bot.scheduler = mock_scheduler

        cmd = _find_subcommand(owned_bot.tree, "cron", "enable")
        interaction = _mock_interaction(user_id="111")
        await cmd.callback(interaction, name="ghost")

        msg = interaction.response.send_message.call_args
        assert "not found" in msg.args[0].lower()


class TestCronDisable:
    @pytest.mark.asyncio
    async def test_cron_disable_admin_only(self, owned_bot):
        cmd = _find_subcommand(owned_bot.tree, "cron", "disable")
        assert cmd is not None
        interaction = _mock_interaction(user_id="999")
        await cmd.callback(interaction, name="test")

        msg = interaction.response.send_message.call_args
        assert "admin only" in msg.args[0].lower()

    @pytest.mark.asyncio
    async def test_cron_disable_success(self, owned_bot):
        mock_scheduler = MagicMock()
        mock_scheduler.disable_job.return_value = True
        owned_bot.scheduler = mock_scheduler

        cmd = _find_subcommand(owned_bot.tree, "cron", "disable")
        interaction = _mock_interaction(user_id="111")
        await cmd.callback(interaction, name="my-job")

        msg = interaction.response.send_message.call_args
        assert "disabled" in msg.args[0].lower()

    @pytest.mark.asyncio
    async def test_cron_disable_not_found(self, owned_bot):
        mock_scheduler = MagicMock()
        mock_scheduler.disable_job.return_value = False
        owned_bot.scheduler = mock_scheduler

        cmd = _find_subcommand(owned_bot.tree, "cron", "disable")
        interaction = _mock_interaction(user_id="111")
        await cmd.callback(interaction, name="ghost")

        msg = interaction.response.send_message.call_args
        assert "not found" in msg.args[0].lower()
