from unittest.mock import AsyncMock, MagicMock

import pytest
from aiogram.types import CallbackQuery, Message

from ui_router.exceptions import ActionError, ContentError, MissingActionFieldError
from ui_router.execution.actions import ActionExecutor, ContentResolver
from ui_router.schema import (
    ActionType,
    AnswerCallbackAction,
    BackAction,
    BusinessAction,
    ClearDataAction,
    ContentTemplate,
    CustomAction,
    DeleteMessageAction,
    DynamicContent,
    EditMessageAction,
    EmitEventAction,
    GotoSceneAction,
    LocalizedContent,
    LocalizedTemplate,
    SaveInputAction,
    ScheduleEventAction,
    ScheduleType,
    SendDocumentAction,
    SendMessageAction,
    SendPhotoAction,
    SendVideoAction,
    SetVariableAction,
    TransitionType,
    VariableScope,
)


def make_context(flags=None, user_id=1, chat_id=1, scene_id="test"):
    ctx = MagicMock()
    flags = flags or {}
    ctx.get_flag.side_effect = lambda name, default=None: flags.get(name, default)
    ctx.get_all_flags.return_value = flags
    ctx.set_flag = MagicMock()
    ctx.user_id = user_id
    ctx.chat_id = chat_id
    ctx.scene_id = scene_id
    ctx.bot = AsyncMock()
    ctx.bot.id = 1
    ctx.bot.send_message = AsyncMock()
    ctx.bot.send_photo = AsyncMock()
    ctx.bot.send_video = AsyncMock()
    ctx.bot.send_document = AsyncMock()
    ctx.navigation_state = MagicMock()
    ctx.navigation_state.user_input = {}
    ctx.save_user_input = MagicMock()
    ctx.get_user_input = MagicMock(return_value=None)
    ctx.clear_user_input = MagicMock()
    ctx.event_data = {}
    return ctx


def make_callback_manager():
    cm = AsyncMock()
    cm.encode = AsyncMock(return_value="cb_data")
    return cm


def make_content_resolver(fluent_bundles=None):
    cm = make_callback_manager()
    return ContentResolver(callback_manager=cm, fluent_bundles=fluent_bundles)


def make_message_event():
    msg = MagicMock(spec=Message)
    msg.text = "user input text"
    msg.delete = AsyncMock()
    return msg


def make_callback_event():
    cb = MagicMock(spec=CallbackQuery)
    cb.message = MagicMock()
    cb.message.edit_text = AsyncMock()
    cb.message.delete = AsyncMock()
    cb.answer = AsyncMock()
    return cb


def make_media_content(source="photo_url", caption=None):
    media = MagicMock()
    media.source = source
    media.caption = caption
    return media


def make_message_content(text="Hello", parse_mode="HTML", disable_web_page_preview=False, media=None):
    content = MagicMock()
    content.text = text
    content.parse_mode = parse_mode
    content.disable_web_page_preview = disable_web_page_preview
    content.media = media
    return content


class TestContentResolverReplaceFlags:
    def test_replaces_flags_in_plain_text(self):
        resolver = make_content_resolver()
        ctx = make_context(flags={"name": "Alice", "age": "30"})
        result = resolver._replace_flags("Hello {name}, age {age}", ctx)
        assert result == "Hello Alice, age 30"

    def test_no_flags_returns_original(self):
        resolver = make_content_resolver()
        ctx = make_context()
        result = resolver._replace_flags("plain text", ctx)
        assert result == "plain text"


class TestContentResolverResolveTemplate:
    def test_substitutes_flags_from_template(self):
        resolver = make_content_resolver()
        ctx = make_context(flags={"username": "Bob", "balance": "100"})
        template = ContentTemplate(
            template="User: {username}, Balance: {balance}",
            flags=["username", "balance"],
        )
        result = resolver._resolve_template(template, ctx)
        assert result == "User: Bob, Balance: 100"

    def test_missing_flag_uses_empty_string(self):
        resolver = make_content_resolver()
        ctx = make_context(flags={})
        template = ContentTemplate(
            template="Hello {name}!",
            flags=["name"],
        )
        result = resolver._resolve_template(template, ctx)
        assert result == "Hello !"


class TestContentResolverResolveLocalizedTemplate:
    def test_without_fluent_uses_translations_and_flags(self):
        resolver = make_content_resolver(fluent_bundles=None)
        ctx = make_context(flags={"locale": "ru", "name": "Ivan"})
        template = LocalizedTemplate(
            translations={"en": "Hello {name}!", "ru": "Привет {name}!"},
            flags=["name"],
            locale_flag="locale",
            default_locale="en",
        )
        result = resolver._resolve_localized_template(template, ctx)
        assert result == "Привет Ivan!"

    def test_falls_back_to_default_locale_when_locale_missing(self):
        resolver = make_content_resolver(fluent_bundles=None)
        ctx = make_context(flags={"locale": "fr"})
        template = LocalizedTemplate(
            translations={"en": "Hello!", "ru": "Привет!"},
            flags=[],
            locale_flag="locale",
            default_locale="en",
        )
        result = resolver._resolve_localized_template(template, ctx)
        assert result == "Hello!"

    def test_with_fluent_bundles_calls_resolve_via_fluent(self):
        bundle = MagicMock()
        resolver = make_content_resolver(fluent_bundles={"en": bundle})
        ctx = make_context(flags={"locale": "en"})
        template = LocalizedTemplate(
            translations={"en": "Hello!"},
            flags=[],
            locale_flag="locale",
            default_locale="en",
        )

        msg = MagicMock()
        msg.value = "fluent_pattern"
        bundle.get_message.return_value = msg
        bundle.format_pattern.return_value = ("Fluent Hello!", [])

        result = resolver._resolve_localized_template(template, ctx)
        assert result == "Fluent Hello!"
        bundle.get_message.assert_called_once()
        bundle.format_pattern.assert_called_once()


class TestContentResolverResolveViaFluent:
    def test_fluent_message_found_and_formatted(self):
        bundle = MagicMock()
        msg = MagicMock()
        msg.value = "fluent_pattern"
        bundle.get_message.return_value = msg
        bundle.format_pattern.return_value = ("Welcome Bob!", [])

        resolver = make_content_resolver(fluent_bundles={"en": bundle})
        ctx = make_context(flags={"name": "Bob"})
        template = LocalizedTemplate(
            translations={"en": "Welcome {name}!"},
            flags=["name"],
        )

        result = resolver._resolve_via_fluent(template, ctx, "en")
        assert result == "Welcome Bob!"

    def test_fluent_message_not_found_falls_back_to_template(self):
        bundle = MagicMock()
        msg = MagicMock()
        msg.value = None
        bundle.get_message.return_value = msg

        resolver = make_content_resolver(fluent_bundles={"en": bundle})
        ctx = make_context(flags={"name": "Alice"})
        template = LocalizedTemplate(
            translations={"en": "Hi {name}!"},
            flags=["name"],
        )

        result = resolver._resolve_via_fluent(template, ctx, "en")
        assert result == "Hi Alice!"

    def test_fluent_message_none_falls_back_to_template(self):
        bundle = MagicMock()
        bundle.get_message.return_value = None

        resolver = make_content_resolver(fluent_bundles={"en": bundle})
        ctx = make_context(flags={})
        template = LocalizedTemplate(
            translations={"en": "Fallback text"},
            flags=[],
        )

        result = resolver._resolve_via_fluent(template, ctx, "en")
        assert result == "Fallback text"

    def test_fluent_exception_falls_back_to_template(self):
        bundle = MagicMock()
        bundle.get_message.side_effect = RuntimeError("fluent crash")

        resolver = make_content_resolver(fluent_bundles={"en": bundle})
        ctx = make_context(flags={"name": "Dave"})
        template = LocalizedTemplate(
            translations={"en": "Hey {name}!"},
            flags=["name"],
        )

        result = resolver._resolve_via_fluent(template, ctx, "en")
        assert result == "Hey Dave!"


class TestContentResolverResolveString:
    def test_plain_string_replaces_flags(self):
        resolver = make_content_resolver()
        ctx = make_context(flags={"city": "Moscow"})
        result = resolver.resolve_string("City: {city}", ctx)
        assert result == "City: Moscow"

    def test_dynamic_content_template(self):
        resolver = make_content_resolver()
        ctx = make_context(flags={"user": "Eve"})
        dc = DynamicContent(
            type="template",
            template=ContentTemplate(template="Welcome {user}", flags=["user"]),
        )
        result = resolver.resolve_string(dc, ctx)
        assert result == "Welcome Eve"

    def test_dynamic_content_localized_template(self):
        resolver = make_content_resolver()
        ctx = make_context(flags={"locale": "en", "count": "5"})
        dc = DynamicContent(
            type="localized_template",
            localized_template=LocalizedTemplate(
                translations={"en": "You have {count} items"},
                flags=["count"],
            ),
        )
        result = resolver.resolve_string(dc, ctx)
        assert result == "You have 5 items"

    def test_dynamic_content_function_raises_content_error(self):
        resolver = make_content_resolver()
        ctx = make_context()
        dc = DynamicContent(type="function", function="some_func")
        with pytest.raises(ContentError):
            resolver.resolve_string(dc, ctx)

    def test_localized_content_returns_translation(self):
        resolver = make_content_resolver()
        ctx = make_context(flags={"locale": "ru"})
        lc = LocalizedContent(
            type="localized",
            translations={"en": "Hello", "ru": "Привет"},
            locale_getter="get_locale",
            default_locale="en",
        )
        result = resolver.resolve_string(lc, ctx)
        assert result == "Привет"

    def test_localized_content_falls_back_to_default(self):
        resolver = make_content_resolver()
        ctx = make_context(flags={"locale": "fr"})
        lc = LocalizedContent(
            type="localized",
            translations={"en": "Hello", "ru": "Привет"},
            locale_getter="get_locale",
            default_locale="en",
        )
        result = resolver.resolve_string(lc, ctx)
        assert result == "Hello"


class TestActionExecutorSendMessage:
    @pytest.mark.asyncio
    async def test_sends_message_with_resolved_text(self):
        resolver = make_content_resolver()
        executor = ActionExecutor(content_resolver=resolver)
        ctx = make_context(flags={"name": "Test"})
        event = make_message_event()

        action = SendMessageAction(type=ActionType.SEND_MESSAGE)
        action.content = make_message_content(text="Hello {name}")
        action.keyboard = None

        await executor.execute(action, ctx, event)
        ctx.bot.send_message.assert_called_once_with(
            chat_id=1,
            text="Hello Test",
            parse_mode="HTML",
            reply_markup=None,
            disable_web_page_preview=False,
        )

    @pytest.mark.asyncio
    async def test_raises_when_no_content(self):
        resolver = make_content_resolver()
        executor = ActionExecutor(content_resolver=resolver)
        ctx = make_context()
        event = make_message_event()

        action = SendMessageAction(type=ActionType.SEND_MESSAGE)
        action.content = None

        with pytest.raises(ActionError):
            await executor.execute(action, ctx, event)


class TestActionExecutorEditMessage:
    @pytest.mark.asyncio
    async def test_edits_message_on_callback_query(self):
        resolver = make_content_resolver()
        executor = ActionExecutor(content_resolver=resolver)
        ctx = make_context()
        event = make_callback_event()

        action = EditMessageAction(type=ActionType.EDIT_MESSAGE)
        action.content = make_message_content(text="Edited text")
        action.keyboard = None

        await executor.execute(action, ctx, event)
        event.message.edit_text.assert_called_once_with(
            text="Edited text",
            parse_mode="HTML",
            reply_markup=None,
            disable_web_page_preview=False,
        )

    @pytest.mark.asyncio
    async def test_raises_on_non_callback_query(self):
        resolver = make_content_resolver()
        executor = ActionExecutor(content_resolver=resolver)
        ctx = make_context()
        event = make_message_event()

        action = EditMessageAction(type=ActionType.EDIT_MESSAGE)
        action.content = make_message_content(text="text")

        with pytest.raises(ActionError):
            await executor.execute(action, ctx, event)

    @pytest.mark.asyncio
    async def test_raises_when_no_content(self):
        resolver = make_content_resolver()
        executor = ActionExecutor(content_resolver=resolver)
        ctx = make_context()
        event = make_callback_event()

        action = EditMessageAction(type=ActionType.EDIT_MESSAGE)
        action.content = None

        with pytest.raises(ActionError):
            await executor.execute(action, ctx, event)


class TestActionExecutorSendPhoto:
    @pytest.mark.asyncio
    async def test_sends_photo_with_caption(self):
        resolver = make_content_resolver()
        executor = ActionExecutor(content_resolver=resolver)
        ctx = make_context()
        event = make_message_event()

        action = SendPhotoAction(type=ActionType.SEND_PHOTO)
        action.content = make_message_content(
            media=make_media_content(source="photo.jpg", caption="Nice photo"),
        )
        action.keyboard = None

        await executor.execute(action, ctx, event)
        ctx.bot.send_photo.assert_called_once_with(
            chat_id=1,
            photo="photo.jpg",
            caption="Nice photo",
            parse_mode="HTML",
            reply_markup=None,
        )

    @pytest.mark.asyncio
    async def test_sends_photo_without_caption(self):
        resolver = make_content_resolver()
        executor = ActionExecutor(content_resolver=resolver)
        ctx = make_context()
        event = make_message_event()

        action = SendPhotoAction(type=ActionType.SEND_PHOTO)
        action.content = make_message_content(
            media=make_media_content(source="photo.jpg", caption=None),
        )
        action.keyboard = None

        await executor.execute(action, ctx, event)
        ctx.bot.send_photo.assert_called_once_with(
            chat_id=1,
            photo="photo.jpg",
            caption=None,
            parse_mode="HTML",
            reply_markup=None,
        )

    @pytest.mark.asyncio
    async def test_raises_when_no_content(self):
        resolver = make_content_resolver()
        executor = ActionExecutor(content_resolver=resolver)
        ctx = make_context()
        event = make_message_event()

        action = SendPhotoAction(type=ActionType.SEND_PHOTO)
        action.content = None

        with pytest.raises(ActionError):
            await executor.execute(action, ctx, event)

    @pytest.mark.asyncio
    async def test_raises_when_no_media(self):
        resolver = make_content_resolver()
        executor = ActionExecutor(content_resolver=resolver)
        ctx = make_context()
        event = make_message_event()

        action = SendPhotoAction(type=ActionType.SEND_PHOTO)
        action.content = make_message_content(media=None)

        with pytest.raises(ActionError):
            await executor.execute(action, ctx, event)


class TestActionExecutorSendVideo:
    @pytest.mark.asyncio
    async def test_sends_video_with_caption(self):
        resolver = make_content_resolver()
        executor = ActionExecutor(content_resolver=resolver)
        ctx = make_context()
        event = make_message_event()

        action = SendVideoAction(type=ActionType.SEND_VIDEO)
        action.content = make_message_content(
            media=make_media_content(source="video.mp4", caption="Cool video"),
        )
        action.keyboard = None

        await executor.execute(action, ctx, event)
        ctx.bot.send_video.assert_called_once_with(
            chat_id=1,
            video="video.mp4",
            caption="Cool video",
            parse_mode="HTML",
            reply_markup=None,
        )

    @pytest.mark.asyncio
    async def test_raises_when_no_content(self):
        resolver = make_content_resolver()
        executor = ActionExecutor(content_resolver=resolver)
        ctx = make_context()
        event = make_message_event()

        action = SendVideoAction(type=ActionType.SEND_VIDEO)
        action.content = None

        with pytest.raises(ActionError):
            await executor.execute(action, ctx, event)

    @pytest.mark.asyncio
    async def test_raises_when_no_media(self):
        resolver = make_content_resolver()
        executor = ActionExecutor(content_resolver=resolver)
        ctx = make_context()
        event = make_message_event()

        action = SendVideoAction(type=ActionType.SEND_VIDEO)
        action.content = make_message_content(media=None)

        with pytest.raises(ActionError):
            await executor.execute(action, ctx, event)


class TestActionExecutorSendDocument:
    @pytest.mark.asyncio
    async def test_sends_document_with_caption(self):
        resolver = make_content_resolver()
        executor = ActionExecutor(content_resolver=resolver)
        ctx = make_context()
        event = make_message_event()

        action = SendDocumentAction(type=ActionType.SEND_DOCUMENT)
        action.content = make_message_content(
            media=make_media_content(source="file.pdf", caption="A document"),
        )
        action.keyboard = None

        await executor.execute(action, ctx, event)
        ctx.bot.send_document.assert_called_once_with(
            chat_id=1,
            document="file.pdf",
            caption="A document",
            parse_mode="HTML",
            reply_markup=None,
        )

    @pytest.mark.asyncio
    async def test_raises_when_no_content(self):
        resolver = make_content_resolver()
        executor = ActionExecutor(content_resolver=resolver)
        ctx = make_context()
        event = make_message_event()

        action = SendDocumentAction(type=ActionType.SEND_DOCUMENT)
        action.content = None

        with pytest.raises(ActionError):
            await executor.execute(action, ctx, event)

    @pytest.mark.asyncio
    async def test_raises_when_no_media(self):
        resolver = make_content_resolver()
        executor = ActionExecutor(content_resolver=resolver)
        ctx = make_context()
        event = make_message_event()

        action = SendDocumentAction(type=ActionType.SEND_DOCUMENT)
        action.content = make_message_content(media=None)

        with pytest.raises(ActionError):
            await executor.execute(action, ctx, event)


class TestActionExecutorDeleteMessage:
    @pytest.mark.asyncio
    async def test_deletes_callback_query_message(self):
        resolver = make_content_resolver()
        executor = ActionExecutor(content_resolver=resolver)
        ctx = make_context()
        event = make_callback_event()

        action = DeleteMessageAction(type=ActionType.DELETE_MESSAGE)

        await executor.execute(action, ctx, event)
        event.message.delete.assert_called_once()

    @pytest.mark.asyncio
    async def test_deletes_message_event(self):
        resolver = make_content_resolver()
        executor = ActionExecutor(content_resolver=resolver)
        ctx = make_context()
        event = make_message_event()

        action = DeleteMessageAction(type=ActionType.DELETE_MESSAGE)

        await executor.execute(action, ctx, event)
        event.delete.assert_called_once()


class TestActionExecutorAnswerCallback:
    @pytest.mark.asyncio
    async def test_answers_callback_with_text(self):
        resolver = make_content_resolver()
        executor = ActionExecutor(content_resolver=resolver)
        ctx = make_context()
        event = make_callback_event()

        action = AnswerCallbackAction(
            type=ActionType.ANSWER_CALLBACK,
            callback_text="Done!",
            show_alert=True,
        )

        await executor.execute(action, ctx, event)
        event.answer.assert_called_once_with(text="Done!", show_alert=True)

    @pytest.mark.asyncio
    async def test_answers_callback_without_text(self):
        resolver = make_content_resolver()
        executor = ActionExecutor(content_resolver=resolver)
        ctx = make_context()
        event = make_callback_event()

        action = AnswerCallbackAction(type=ActionType.ANSWER_CALLBACK)

        await executor.execute(action, ctx, event)
        event.answer.assert_called_once_with(text=None, show_alert=False)

    @pytest.mark.asyncio
    async def test_does_nothing_for_non_callback(self):
        resolver = make_content_resolver()
        executor = ActionExecutor(content_resolver=resolver)
        ctx = make_context()
        event = make_message_event()

        action = AnswerCallbackAction(
            type=ActionType.ANSWER_CALLBACK,
            callback_text="text",
        )

        await executor.execute(action, ctx, event)


class TestActionExecutorGotoScene:
    @pytest.mark.asyncio
    async def test_sets_pending_scene_and_transition(self):
        executor = ActionExecutor()
        ctx = make_context()
        event = make_message_event()

        action = GotoSceneAction(
            type=ActionType.GOTO_SCENE,
            scene_id="menu",
            transition=TransitionType.EDIT_SMART,
        )

        await executor.execute(action, ctx, event)
        ctx.set_flag.assert_any_call("_pending_scene", "menu")
        ctx.set_flag.assert_any_call("_pending_transition", "edit_smart")

    @pytest.mark.asyncio
    async def test_defaults_to_send_transition(self):
        executor = ActionExecutor()
        ctx = make_context()
        event = make_message_event()

        action = GotoSceneAction(
            type=ActionType.GOTO_SCENE,
            scene_id="settings",
        )

        await executor.execute(action, ctx, event)
        ctx.set_flag.assert_any_call("_pending_scene", "settings")
        ctx.set_flag.assert_any_call("_pending_transition", "send")


class TestActionExecutorBack:
    @pytest.mark.asyncio
    async def test_sets_pending_back_flag(self):
        executor = ActionExecutor()
        ctx = make_context()
        event = make_message_event()

        action = BackAction(type=ActionType.BACK)

        await executor.execute(action, ctx, event)
        ctx.set_flag.assert_any_call("_pending_back", True)

    @pytest.mark.asyncio
    async def test_sets_transition_when_provided(self):
        executor = ActionExecutor()
        ctx = make_context()
        event = make_message_event()

        action = BackAction(
            type=ActionType.BACK,
            transition=TransitionType.EDIT_SMART,
        )

        await executor.execute(action, ctx, event)
        ctx.set_flag.assert_any_call("_pending_back", True)
        ctx.set_flag.assert_any_call("_pending_transition", "edit_smart")


class TestActionExecutorSaveInput:
    @pytest.mark.asyncio
    async def test_saves_message_text(self):
        executor = ActionExecutor()
        ctx = make_context()
        event = make_message_event()

        action = SaveInputAction(
            type=ActionType.SAVE_INPUT,
            save_as="email",
        )

        await executor.execute(action, ctx, event)
        ctx.save_user_input.assert_called_once_with("email", "user input text")

    @pytest.mark.asyncio
    async def test_raises_when_no_save_as(self):
        executor = ActionExecutor()
        ctx = make_context()
        event = make_message_event()

        action = SaveInputAction(type=ActionType.SAVE_INPUT)

        with pytest.raises(MissingActionFieldError):
            await executor.execute(action, ctx, event)

    @pytest.mark.asyncio
    async def test_saves_from_callback_param(self):
        executor = ActionExecutor()
        ctx = make_context()
        ctx.get_user_input = MagicMock(return_value="param_value")
        event = make_callback_event()

        action = SaveInputAction(
            type=ActionType.SAVE_INPUT,
            save_as="selection",
            params={"from_param": "source_key"},
        )

        await executor.execute(action, ctx, event)
        ctx.get_user_input.assert_called_once_with("source_key")
        ctx.save_user_input.assert_called_once_with("selection", "param_value")

    @pytest.mark.asyncio
    async def test_callback_param_none_does_not_save(self):
        executor = ActionExecutor()
        ctx = make_context()
        ctx.get_user_input = MagicMock(return_value=None)
        event = make_callback_event()

        action = SaveInputAction(
            type=ActionType.SAVE_INPUT,
            save_as="selection",
            params={"from_param": "missing_key"},
        )

        await executor.execute(action, ctx, event)
        ctx.save_user_input.assert_not_called()


class TestActionExecutorClearData:
    @pytest.mark.asyncio
    async def test_clears_user_input(self):
        executor = ActionExecutor()
        ctx = make_context()
        event = make_message_event()

        action = ClearDataAction(
            type=ActionType.CLEAR_DATA,
            data_keys=["email", "phone"],
        )

        await executor.execute(action, ctx, event)
        ctx.clear_user_input.assert_called_once_with(["email", "phone"])

    @pytest.mark.asyncio
    async def test_clears_all_when_no_keys(self):
        executor = ActionExecutor()
        ctx = make_context()
        event = make_message_event()

        action = ClearDataAction(type=ActionType.CLEAR_DATA)

        await executor.execute(action, ctx, event)
        ctx.clear_user_input.assert_called_once_with(None)


class TestActionExecutorSetVariable:
    @pytest.mark.asyncio
    async def test_operation_set_calls_repository_directly(self):
        repo = AsyncMock()
        executor = ActionExecutor(variable_repository=repo)
        ctx = make_context()
        event = make_message_event()

        action = SetVariableAction(
            type=ActionType.SET_VARIABLE,
            variable_name="counter",
            value=42,
            operation="set",
            scope=VariableScope.USER,
        )

        await executor.execute(action, ctx, event)
        repo.set.assert_called_once_with(
            bot_id=1,
            name="counter",
            value=42,
            scope=VariableScope.USER,
            user_id=1,
            chat_id=None,
        )

    @pytest.mark.asyncio
    async def test_operation_add_gets_current_and_adds(self):
        repo = AsyncMock()
        repo.get = AsyncMock(return_value=10)
        executor = ActionExecutor(variable_repository=repo)
        ctx = make_context()
        event = make_message_event()

        action = SetVariableAction(
            type=ActionType.SET_VARIABLE,
            variable_name="counter",
            value=5,
            operation="add",
            scope=VariableScope.USER,
        )

        await executor.execute(action, ctx, event)
        repo.get.assert_called_once_with(
            bot_id=1,
            name="counter",
            scope=VariableScope.USER,
            user_id=1,
            chat_id=None,
        )
        repo.set.assert_called_once_with(
            bot_id=1,
            name="counter",
            value=15,
            scope=VariableScope.USER,
            user_id=1,
            chat_id=None,
        )

    @pytest.mark.asyncio
    async def test_operation_add_with_none_current_uses_zero(self):
        repo = AsyncMock()
        repo.get = AsyncMock(return_value=None)
        executor = ActionExecutor(variable_repository=repo)
        ctx = make_context()
        event = make_message_event()

        action = SetVariableAction(
            type=ActionType.SET_VARIABLE,
            variable_name="counter",
            value=3,
            operation="add",
            scope=VariableScope.USER,
        )

        await executor.execute(action, ctx, event)
        repo.set.assert_called_once_with(
            bot_id=1,
            name="counter",
            value=3,
            scope=VariableScope.USER,
            user_id=1,
            chat_id=None,
        )

    @pytest.mark.asyncio
    async def test_operation_subtract(self):
        repo = AsyncMock()
        repo.get = AsyncMock(return_value=20)
        executor = ActionExecutor(variable_repository=repo)
        ctx = make_context()
        event = make_message_event()

        action = SetVariableAction(
            type=ActionType.SET_VARIABLE,
            variable_name="counter",
            value=7,
            operation="subtract",
            scope=VariableScope.BOT,
        )

        await executor.execute(action, ctx, event)
        repo.set.assert_called_once_with(
            bot_id=1,
            name="counter",
            value=13,
            scope=VariableScope.BOT,
            user_id=None,
            chat_id=None,
        )

    @pytest.mark.asyncio
    async def test_operation_multiply(self):
        repo = AsyncMock()
        repo.get = AsyncMock(return_value=5)
        executor = ActionExecutor(variable_repository=repo)
        ctx = make_context()
        event = make_message_event()

        action = SetVariableAction(
            type=ActionType.SET_VARIABLE,
            variable_name="score",
            value=3,
            operation="multiply",
            scope=VariableScope.CHAT,
        )

        await executor.execute(action, ctx, event)
        repo.set.assert_called_once_with(
            bot_id=1,
            name="score",
            value=15,
            scope=VariableScope.CHAT,
            user_id=None,
            chat_id=1,
        )

    @pytest.mark.asyncio
    async def test_unknown_operation_raises_action_error(self):
        repo = AsyncMock()
        repo.get = AsyncMock(return_value=10)
        executor = ActionExecutor(variable_repository=repo)
        ctx = make_context()
        event = make_message_event()

        action = SetVariableAction(
            type=ActionType.SET_VARIABLE,
            variable_name="counter",
            value=1,
            operation="divide",
            scope=VariableScope.USER,
        )

        with pytest.raises(ActionError):
            await executor.execute(action, ctx, event)

    @pytest.mark.asyncio
    async def test_no_variable_name_raises_missing_field_error(self):
        repo = AsyncMock()
        executor = ActionExecutor(variable_repository=repo)
        ctx = make_context()
        event = make_message_event()

        action = SetVariableAction(type=ActionType.SET_VARIABLE, value=1)

        with pytest.raises(MissingActionFieldError):
            await executor.execute(action, ctx, event)

    @pytest.mark.asyncio
    async def test_chat_scope_passes_chat_id(self):
        repo = AsyncMock()
        executor = ActionExecutor(variable_repository=repo)
        ctx = make_context(chat_id=999)
        event = make_message_event()

        action = SetVariableAction(
            type=ActionType.SET_VARIABLE,
            variable_name="chat_var",
            value="hello",
            operation="set",
            scope=VariableScope.CHAT,
        )

        await executor.execute(action, ctx, event)
        repo.set.assert_called_once_with(
            bot_id=1,
            name="chat_var",
            value="hello",
            scope=VariableScope.CHAT,
            user_id=None,
            chat_id=999,
        )


class TestActionExecutorScheduleEvent:
    @pytest.mark.asyncio
    async def test_schedule_once(self):
        scheduler = AsyncMock()
        scheduler.schedule_once = AsyncMock(return_value="task_1")
        executor = ActionExecutor(event_scheduler=scheduler)
        ctx = make_context()
        event = make_message_event()

        action = ScheduleEventAction(
            type=ActionType.SCHEDULE_EVENT,
            event_name="reminder",
            schedule_type="once",
            schedule_value="60",
            event_data={"key": "value"},
        )

        await executor.execute(action, ctx, event)
        scheduler.schedule_once.assert_called_once_with(
            event_name="reminder",
            delay="60",
            event_data={"key": "value"},
            bot_id=1,
            user_id=1,
            chat_id=1,
        )

    @pytest.mark.asyncio
    async def test_schedule_cron(self):
        scheduler = AsyncMock()
        scheduler.schedule_cron = AsyncMock(return_value="task_2")
        executor = ActionExecutor(event_scheduler=scheduler)
        ctx = make_context()
        event = make_message_event()

        action = ScheduleEventAction(
            type=ActionType.SCHEDULE_EVENT,
            event_name="daily_check",
            schedule_type="cron",
            schedule_value="0 9 * * *",
            event_data={},
        )

        await executor.execute(action, ctx, event)
        scheduler.schedule_cron.assert_called_once_with(
            event_name="daily_check",
            cron_expr="0 9 * * *",
            event_data={},
            bot_id=1,
            user_id=1,
            chat_id=1,
        )

    @pytest.mark.asyncio
    async def test_schedule_interval(self):
        scheduler = AsyncMock()
        scheduler.schedule_interval = AsyncMock(return_value="task_3")
        executor = ActionExecutor(event_scheduler=scheduler)
        ctx = make_context()
        event = make_message_event()

        action = ScheduleEventAction(
            type=ActionType.SCHEDULE_EVENT,
            event_name="heartbeat",
            schedule_type="interval",
            schedule_value="30",
            event_data={},
        )

        await executor.execute(action, ctx, event)
        scheduler.schedule_interval.assert_called_once_with(
            event_name="heartbeat",
            interval_seconds=30,
            event_data={},
            bot_id=1,
            user_id=1,
            chat_id=1,
        )

    @pytest.mark.asyncio
    async def test_schedule_once_with_enum(self):
        scheduler = AsyncMock()
        scheduler.schedule_once = AsyncMock(return_value="task_4")
        executor = ActionExecutor(event_scheduler=scheduler)
        ctx = make_context()
        event = make_message_event()

        action = ScheduleEventAction(
            type=ActionType.SCHEDULE_EVENT,
            event_name="delayed",
            schedule_type=ScheduleType.ONCE,
            schedule_value="120",
        )

        await executor.execute(action, ctx, event)
        scheduler.schedule_once.assert_called_once()

    @pytest.mark.asyncio
    async def test_unknown_schedule_type_raises(self):
        scheduler = AsyncMock()
        executor = ActionExecutor(event_scheduler=scheduler)
        ctx = make_context()
        event = make_message_event()

        action = ScheduleEventAction(
            type=ActionType.SCHEDULE_EVENT,
            event_name="bad",
            schedule_type="unknown_type",
            schedule_value="60",
        )

        with pytest.raises(ActionError):
            await executor.execute(action, ctx, event)

    @pytest.mark.asyncio
    async def test_missing_event_name_raises(self):
        scheduler = AsyncMock()
        executor = ActionExecutor(event_scheduler=scheduler)
        ctx = make_context()
        event = make_message_event()

        action = ScheduleEventAction(
            type=ActionType.SCHEDULE_EVENT,
            schedule_value="60",
        )

        with pytest.raises(MissingActionFieldError):
            await executor.execute(action, ctx, event)

    @pytest.mark.asyncio
    async def test_missing_schedule_value_raises(self):
        scheduler = AsyncMock()
        executor = ActionExecutor(event_scheduler=scheduler)
        ctx = make_context()
        event = make_message_event()

        action = ScheduleEventAction(
            type=ActionType.SCHEDULE_EVENT,
            event_name="test_event",
        )

        with pytest.raises(MissingActionFieldError):
            await executor.execute(action, ctx, event)


class TestActionExecutorEmitEvent:
    @pytest.mark.asyncio
    async def test_emits_event(self):
        bus = AsyncMock()
        executor = ActionExecutor(event_bus=bus)
        ctx = make_context(user_id=42, chat_id=100)
        event = make_message_event()

        action = EmitEventAction(
            type=ActionType.EMIT_EVENT,
            event_name="user_action",
            event_data={"detail": "clicked"},
        )

        await executor.execute(action, ctx, event)
        bus.emit.assert_called_once()
        emitted = bus.emit.call_args[0][0]
        assert emitted.event_name == "user_action"
        assert emitted.data == {"detail": "clicked"}
        assert emitted.bot_id == 1
        assert emitted.user_id == 42
        assert emitted.chat_id == 100

    @pytest.mark.asyncio
    async def test_missing_event_name_raises(self):
        bus = AsyncMock()
        executor = ActionExecutor(event_bus=bus)
        ctx = make_context()
        event = make_message_event()

        action = EmitEventAction(type=ActionType.EMIT_EVENT)

        with pytest.raises(MissingActionFieldError):
            await executor.execute(action, ctx, event)


class TestActionExecutorBusinessAction:
    @pytest.mark.asyncio
    async def test_calls_registry_business_actions(self):
        registry = MagicMock()
        registry.business_actions = AsyncMock()
        registry.business_actions.execute = AsyncMock(return_value="result")
        executor = ActionExecutor(registry=registry)
        ctx = make_context()
        event = make_message_event()

        action = BusinessAction(
            type=ActionType.BUSINESS_ACTION,
            business_action="process_payment",
            params={"amount": 100},
        )

        result = await executor.execute(action, ctx, event)
        registry.business_actions.execute.assert_called_once_with(
            action_name="process_payment",
            context=ctx,
            event=event,
            params={"amount": 100},
        )
        assert result == "result"

    @pytest.mark.asyncio
    async def test_missing_business_action_raises(self):
        registry = MagicMock()
        executor = ActionExecutor(registry=registry)
        ctx = make_context()
        event = make_message_event()

        action = BusinessAction(type=ActionType.BUSINESS_ACTION)

        with pytest.raises(MissingActionFieldError):
            await executor.execute(action, ctx, event)


class TestActionExecutorCustom:
    @pytest.mark.asyncio
    async def test_calls_registry_actions_execute(self):
        resolver = make_content_resolver()
        registry = MagicMock()
        registry.actions = AsyncMock()
        registry.actions.execute = AsyncMock(return_value="custom_result")
        executor = ActionExecutor(content_resolver=resolver, registry=registry)
        ctx = make_context(flags={"val": "resolved"})
        event = make_message_event()

        action = CustomAction(
            type=ActionType.CUSTOM,
            function="my_func",
            function_params={"key": "{val}"},
        )

        result = await executor.execute(action, ctx, event)
        registry.actions.execute.assert_called_once_with(
            "my_func",
            ctx,
            {"key": "resolved"},
        )
        assert result == "custom_result"

    @pytest.mark.asyncio
    async def test_missing_function_raises(self):
        resolver = make_content_resolver()
        executor = ActionExecutor(content_resolver=resolver)
        ctx = make_context()
        event = make_message_event()

        action = CustomAction(type=ActionType.CUSTOM)

        with pytest.raises(MissingActionFieldError):
            await executor.execute(action, ctx, event)

    @pytest.mark.asyncio
    async def test_empty_params(self):
        resolver = make_content_resolver()
        registry = MagicMock()
        registry.actions = AsyncMock()
        registry.actions.execute = AsyncMock(return_value=None)
        executor = ActionExecutor(content_resolver=resolver, registry=registry)
        ctx = make_context()
        event = make_message_event()

        action = CustomAction(
            type=ActionType.CUSTOM,
            function="no_params_func",
        )

        await executor.execute(action, ctx, event)
        registry.actions.execute.assert_called_once_with("no_params_func", ctx, {})
