from unittest.mock import AsyncMock, MagicMock, patch

import pytest
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup

from ui_router.execution.keyboard import DynamicKeyboardRenderer
from ui_router.exceptions import KeyboardRenderError
from ui_router.schema import (
    Button,
    ButtonTemplate,
    ConditionOperator,
    DynamicKeyboard,
    PaginationConfig,
    RuleCondition,
)


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


def make_context(flags=None, pagination_page=1):
    ctx = MagicMock()
    flags = flags or {}
    ctx.get_flag.side_effect = lambda name, default=None: flags.get(name, default)
    ctx.get_from_event.side_effect = lambda key, default=None: pagination_page if key == "_pagination_page" else default
    return ctx


def make_renderer(callback_manager=None):
    return DynamicKeyboardRenderer(callback_manager or make_callback_manager())


def make_keyboard(**overrides):
    defaults = {
        "data_source": "items",
        "button_template": ButtonTemplate(text="{name}", callback_action="select"),
    }
    defaults.update(overrides)
    return DynamicKeyboard(**defaults)


class TestGetDataSource:
    def test_returns_list_from_flag(self):
        renderer = make_renderer()
        items = [{"name": "a"}, {"name": "b"}]
        ctx = make_context(flags={"products": items})

        result = renderer._get_data_source("products", ctx)

        assert result == items

    def test_returns_empty_list_when_flag_is_none(self):
        renderer = make_renderer()
        ctx = make_context(flags={})

        result = renderer._get_data_source("missing_flag", ctx)

        assert result == []

    def test_raises_when_not_a_list(self):
        renderer = make_renderer()
        ctx = make_context(flags={"products": "not_a_list"})

        with pytest.raises(KeyboardRenderError, match="must be a list"):
            renderer._get_data_source("products", ctx)

    def test_raises_when_flag_is_dict(self):
        renderer = make_renderer()
        ctx = make_context(flags={"products": {"key": "value"}})

        with pytest.raises(KeyboardRenderError, match="must be a list"):
            renderer._get_data_source("products", ctx)

    def test_raises_when_flag_is_int(self):
        renderer = make_renderer()
        ctx = make_context(flags={"products": 42})

        with pytest.raises(KeyboardRenderError, match="must be a list"):
            renderer._get_data_source("products", ctx)


class TestResolveTemplate:
    def test_simple_field_substitution(self):
        renderer = make_renderer()
        ctx = make_context()

        result = renderer._resolve_template("{name}", {"name": "test"}, ctx)

        assert result == "test"

    def test_dot_notation_nested_dict(self):
        renderer = make_renderer()
        ctx = make_context()
        item = {"product": {"price": 9.99}}

        result = renderer._resolve_template("{product.price}", item, ctx)

        assert result == "9.99"

    def test_flag_substitution(self):
        renderer = make_renderer()
        ctx = make_context(flags={"locale": "en"})

        result = renderer._resolve_template("{flag:locale}", {}, ctx)

        assert result == "en"

    def test_mixed_item_and_flag(self):
        renderer = make_renderer()
        ctx = make_context(flags={"currency": "USD"})

        result = renderer._resolve_template("{name} ({flag:currency})", {"name": "Item"}, ctx)

        assert result == "Item (USD)"

    def test_missing_field_returns_template_as_is(self):
        renderer = make_renderer()
        ctx = make_context()

        result = renderer._resolve_template("{missing}", {"name": "test"}, ctx)

        assert result == "{missing}"

    def test_missing_flag_returns_empty_string(self):
        renderer = make_renderer()
        ctx = make_context(flags={})

        result = renderer._resolve_template("{flag:missing_flag}", {}, ctx)

        assert result == ""

    def test_multiple_fields(self):
        renderer = make_renderer()
        ctx = make_context()
        item = {"first": "A", "second": "B"}

        result = renderer._resolve_template("{first} - {second}", item, ctx)

        assert result == "A - B"

    def test_no_placeholders(self):
        renderer = make_renderer()
        ctx = make_context()

        result = renderer._resolve_template("static text", {}, ctx)

        assert result == "static text"


class TestApplyLayout:
    def test_list_layout_one_per_row(self):
        renderer = make_renderer()
        btn1 = InlineKeyboardButton(text="A", callback_data="a")
        btn2 = InlineKeyboardButton(text="B", callback_data="b")
        btn3 = InlineKeyboardButton(text="C", callback_data="c")

        rows = renderer._apply_layout([btn1, btn2, btn3], "list", columns=2)

        assert len(rows) == 3
        assert rows[0] == [btn1]
        assert rows[1] == [btn2]
        assert rows[2] == [btn3]

    def test_grid_layout_with_columns(self):
        renderer = make_renderer()
        buttons = [InlineKeyboardButton(text=str(i), callback_data=str(i)) for i in range(7)]

        rows = renderer._apply_layout(buttons, "grid", columns=3)

        assert len(rows) == 3
        assert len(rows[0]) == 3
        assert len(rows[1]) == 3
        assert len(rows[2]) == 1

    def test_grid_layout_exact_fit(self):
        renderer = make_renderer()
        buttons = [InlineKeyboardButton(text=str(i), callback_data=str(i)) for i in range(6)]

        rows = renderer._apply_layout(buttons, "grid", columns=3)

        assert len(rows) == 2
        assert all(len(row) == 3 for row in rows)

    def test_inline_layout_all_in_one_row(self):
        renderer = make_renderer()
        buttons = [InlineKeyboardButton(text=str(i), callback_data=str(i)) for i in range(5)]

        rows = renderer._apply_layout(buttons, "inline", columns=2)

        assert len(rows) == 1
        assert len(rows[0]) == 5

    def test_inline_layout_empty_list(self):
        renderer = make_renderer()

        rows = renderer._apply_layout([], "inline", columns=2)

        assert rows == []

    def test_list_layout_empty_list(self):
        renderer = make_renderer()

        rows = renderer._apply_layout([], "list", columns=2)

        assert rows == []

    def test_grid_layout_empty_list(self):
        renderer = make_renderer()

        rows = renderer._apply_layout([], "grid", columns=2)

        assert rows == []

    def test_unknown_layout_raises(self):
        renderer = make_renderer()
        buttons = [InlineKeyboardButton(text="A", callback_data="a")]

        with pytest.raises(KeyboardRenderError, match="Unknown layout"):
            renderer._apply_layout(buttons, "diagonal", columns=2)


class TestEvalCondition:
    def test_rule_condition_matching(self):
        renderer = make_renderer()
        condition = RuleCondition(variable="active", operator=ConditionOperator.EQ, value=True)
        item = {"active": True}

        assert renderer._eval_condition(condition, item) is True

    def test_rule_condition_not_matching(self):
        renderer = make_renderer()
        condition = RuleCondition(variable="active", operator=ConditionOperator.EQ, value=True)
        item = {"active": False}

        assert renderer._eval_condition(condition, item) is False

    def test_string_condition_parsed_and_evaluated(self):
        renderer = make_renderer()
        item = {"active": True}

        assert renderer._eval_condition("active == True", item) is True

    def test_string_condition_not_matching(self):
        renderer = make_renderer()
        item = {"active": False}

        assert renderer._eval_condition("active == True", item) is False

    def test_unparseable_string_condition_returns_true(self):
        renderer = make_renderer()
        item = {"active": True}

        assert renderer._eval_condition("some gibberish without operators", item) is True

    def test_exception_during_evaluation_returns_true(self):
        renderer = make_renderer()
        item = {"value": "not_a_number"}

        with patch.object(
            renderer, "_parse_string_condition", side_effect=RuntimeError("unexpected")
        ):
            assert renderer._eval_condition("value > 5", item) is True

    def test_rule_condition_ne(self):
        renderer = make_renderer()
        condition = RuleCondition(variable="status", operator=ConditionOperator.NE, value="deleted")
        item = {"status": "active"}

        assert renderer._eval_condition(condition, item) is True

    def test_rule_condition_gt(self):
        renderer = make_renderer()
        condition = RuleCondition(variable="count", operator=ConditionOperator.GT, value=5)
        item = {"count": 10}

        assert renderer._eval_condition(condition, item) is True

    def test_rule_condition_lt(self):
        renderer = make_renderer()
        condition = RuleCondition(variable="count", operator=ConditionOperator.LT, value=5)
        item = {"count": 3}

        assert renderer._eval_condition(condition, item) is True


class TestParseStringCondition:
    def test_eq_string_value(self):
        renderer = make_renderer()

        result = renderer._parse_string_condition("field == value", {})

        assert result is not None
        assert result.variable == "field"
        assert result.operator == ConditionOperator.EQ
        assert result.value == "value"

    def test_gt_int_value(self):
        renderer = make_renderer()

        result = renderer._parse_string_condition("count > 5", {})

        assert result is not None
        assert result.variable == "count"
        assert result.operator == ConditionOperator.GT
        assert result.value == 5

    def test_gte_float_value(self):
        renderer = make_renderer()

        result = renderer._parse_string_condition("price >= 3.14", {})

        assert result is not None
        assert result.variable == "price"
        assert result.operator == ConditionOperator.GTE
        assert result.value == 3.14

    def test_eq_bool_true(self):
        renderer = make_renderer()

        result = renderer._parse_string_condition("active == True", {})

        assert result is not None
        assert result.value is True

    def test_eq_bool_false(self):
        renderer = make_renderer()

        result = renderer._parse_string_condition("active == False", {})

        assert result is not None
        assert result.value is False

    def test_eq_none(self):
        renderer = make_renderer()

        result = renderer._parse_string_condition("status == None", {})

        assert result is not None
        assert result.value is None

    def test_eq_null(self):
        renderer = make_renderer()

        result = renderer._parse_string_condition("status == null", {})

        assert result is not None
        assert result.value is None

    def test_quoted_string_double_quotes(self):
        renderer = make_renderer()

        result = renderer._parse_string_condition('name == "hello"', {})

        assert result is not None
        assert result.value == "hello"

    def test_quoted_string_single_quotes(self):
        renderer = make_renderer()

        result = renderer._parse_string_condition("name == 'hello'", {})

        assert result is not None
        assert result.value == "hello"

    def test_ne_operator(self):
        renderer = make_renderer()

        result = renderer._parse_string_condition("status != active", {})

        assert result is not None
        assert result.operator == ConditionOperator.NE
        assert result.variable == "status"
        assert result.value == "active"

    def test_lte_operator(self):
        renderer = make_renderer()

        result = renderer._parse_string_condition("count <= 10", {})

        assert result is not None
        assert result.operator == ConditionOperator.LTE
        assert result.value == 10

    def test_lt_operator(self):
        renderer = make_renderer()

        result = renderer._parse_string_condition("count < 10", {})

        assert result is not None
        assert result.operator == ConditionOperator.LT
        assert result.value == 10

    def test_unparseable_returns_none(self):
        renderer = make_renderer()

        result = renderer._parse_string_condition("no operators here", {})

        assert result is None

    def test_eq_lowercase_true(self):
        renderer = make_renderer()

        result = renderer._parse_string_condition("active == true", {})

        assert result is not None
        assert result.value is True

    def test_eq_lowercase_false(self):
        renderer = make_renderer()

        result = renderer._parse_string_condition("active == false", {})

        assert result is not None
        assert result.value is False


class TestCreateButtonFromTemplate:
    async def test_creates_button_with_resolved_text_and_callback(self):
        cm = make_callback_manager()
        renderer = make_renderer(cm)
        template = ButtonTemplate(text="{name}", callback_action="select_{id}")
        item = {"name": "Product A", "id": "123"}
        ctx = make_context()

        button = await renderer._create_button_from_template(template, item, ctx, "scene_1")

        assert button.text == "Product A"
        assert button.callback_data == "callback_data_mock"
        cm.encode.assert_awaited_once()

    async def test_url_button_no_callback_encoding(self):
        cm = make_callback_manager()
        renderer = make_renderer(cm)
        template = ButtonTemplate(
            text="{name}",
            callback_action="select",
            url="{link}",
        )
        item = {"name": "Google", "link": "https://google.com"}
        ctx = make_context()

        button = await renderer._create_button_from_template(template, item, ctx, "scene_1")

        assert button.text == "Google"
        assert button.url == "https://google.com"
        assert button.callback_data is None
        cm.encode.assert_not_awaited()

    async def test_callback_params_resolved_via_template(self):
        cm = make_callback_manager()
        renderer = make_renderer(cm)
        template = ButtonTemplate(
            text="{name}",
            callback_action="select",
            callback_params={"item_id": "{id}", "category": "{cat}"},
        )
        item = {"name": "Item", "id": "42", "cat": "electronics"}
        ctx = make_context()

        await renderer._create_button_from_template(template, item, ctx, "scene_1")

        call_kwargs = cm.encode.call_args.kwargs
        assert call_kwargs["params"] == {"item_id": "42", "category": "electronics"}

    async def test_button_preserves_style_and_emoji(self):
        cm = make_callback_manager()
        renderer = make_renderer(cm)
        template = ButtonTemplate(
            text="{name}",
            callback_action="select",
            style="danger",
            icon_custom_emoji_id="12345",
        )
        item = {"name": "Delete"}
        ctx = make_context()

        button = await renderer._create_button_from_template(template, item, ctx, "scene_1")

        assert button.style == "danger"
        assert button.icon_custom_emoji_id == "12345"


class TestRender:
    async def test_empty_data_source_returns_empty_keyboard(self):
        renderer = make_renderer()
        keyboard = make_keyboard()
        ctx = make_context(flags={"items": []})

        result = await renderer.render(keyboard, ctx, "scene_1")

        assert isinstance(result, InlineKeyboardMarkup)
        assert result.inline_keyboard == []

    async def test_data_with_template_creates_buttons(self):
        renderer = make_renderer()
        items = [{"name": "A"}, {"name": "B"}, {"name": "C"}]
        keyboard = make_keyboard(layout="list")
        ctx = make_context(flags={"items": items})

        result = await renderer.render(keyboard, ctx, "scene_1")

        assert len(result.inline_keyboard) == 3
        assert result.inline_keyboard[0][0].text == "A"
        assert result.inline_keyboard[1][0].text == "B"
        assert result.inline_keyboard[2][0].text == "C"

    async def test_pagination_adds_row_when_data_exceeds_page_size(self):
        renderer = make_renderer()
        items = [{"name": str(i)} for i in range(15)]
        pagination = PaginationConfig(enabled=True, page_size=5)
        keyboard = make_keyboard(layout="list", pagination=pagination)
        ctx = make_context(flags={"items": items}, pagination_page=1)

        result = await renderer.render(keyboard, ctx, "scene_1")

        data_rows = 5
        pagination_row = 1
        assert len(result.inline_keyboard) == data_rows + pagination_row

    async def test_pagination_no_extra_row_when_data_fits_single_page(self):
        renderer = make_renderer()
        items = [{"name": str(i)} for i in range(3)]
        pagination = PaginationConfig(enabled=True, page_size=10)
        keyboard = make_keyboard(layout="list", pagination=pagination)
        ctx = make_context(flags={"items": items}, pagination_page=1)

        result = await renderer.render(keyboard, ctx, "scene_1")

        assert len(result.inline_keyboard) == 3

    async def test_pagination_clamps_page_to_valid_range(self):
        renderer = make_renderer()
        items = [{"name": str(i)} for i in range(10)]
        pagination = PaginationConfig(enabled=True, page_size=5)
        keyboard = make_keyboard(layout="list", pagination=pagination)
        ctx = make_context(flags={"items": items}, pagination_page=999)

        result = await renderer.render(keyboard, ctx, "scene_1")

        assert len(result.inline_keyboard) == 5 + 1

    async def test_pagination_clamps_page_below_one(self):
        renderer = make_renderer()
        items = [{"name": str(i)} for i in range(10)]
        pagination = PaginationConfig(enabled=True, page_size=5)
        keyboard = make_keyboard(layout="list", pagination=pagination)
        ctx = make_context(flags={"items": items}, pagination_page=0)

        result = await renderer.render(keyboard, ctx, "scene_1")

        assert len(result.inline_keyboard) == 5 + 1

    async def test_condition_filtering_removes_items(self):
        renderer = make_renderer()
        items = [
            {"name": "active_1", "active": True},
            {"name": "inactive", "active": False},
            {"name": "active_2", "active": True},
        ]
        template = ButtonTemplate(
            text="{name}",
            callback_action="select",
            condition="active == True",
        )
        keyboard = make_keyboard(layout="list", button_template=template)
        ctx = make_context(flags={"items": items})

        result = await renderer.render(keyboard, ctx, "scene_1")

        assert len(result.inline_keyboard) == 2
        assert result.inline_keyboard[0][0].text == "active_1"
        assert result.inline_keyboard[1][0].text == "active_2"

    async def test_header_buttons_included(self):
        renderer = make_renderer()
        items = [{"name": "A"}]
        header = [[Button(text="Back", callback_action="go_back")]]
        keyboard = make_keyboard(layout="list", header_buttons=header)
        ctx = make_context(flags={"items": items})

        with patch(
            "ui_router.execution.keyboard.DynamicKeyboardRenderer._render_button",
            new_callable=AsyncMock,
            return_value=InlineKeyboardButton(text="Back", callback_data="back_data"),
        ):
            result = await renderer.render(keyboard, ctx, "scene_1")

        assert result.inline_keyboard[0][0].text == "Back"
        assert len(result.inline_keyboard) == 2

    async def test_footer_buttons_included(self):
        renderer = make_renderer()
        items = [{"name": "A"}]
        footer = [[Button(text="Help", callback_action="help")]]
        keyboard = make_keyboard(layout="list", footer_buttons=footer)
        ctx = make_context(flags={"items": items})

        with patch(
            "ui_router.execution.keyboard.DynamicKeyboardRenderer._render_button",
            new_callable=AsyncMock,
            return_value=InlineKeyboardButton(text="Help", callback_data="help_data"),
        ):
            result = await renderer.render(keyboard, ctx, "scene_1")

        assert result.inline_keyboard[-1][0].text == "Help"
        assert len(result.inline_keyboard) == 2

    async def test_grid_layout_applied(self):
        renderer = make_renderer()
        items = [{"name": str(i)} for i in range(5)]
        keyboard = make_keyboard(layout="grid", columns=2)
        ctx = make_context(flags={"items": items})

        result = await renderer.render(keyboard, ctx, "scene_1")

        assert len(result.inline_keyboard) == 3
        assert len(result.inline_keyboard[0]) == 2
        assert len(result.inline_keyboard[1]) == 2
        assert len(result.inline_keyboard[2]) == 1

    async def test_none_data_source_produces_empty_keyboard(self):
        renderer = make_renderer()
        keyboard = make_keyboard()
        ctx = make_context(flags={})

        result = await renderer.render(keyboard, ctx, "scene_1")

        assert result.inline_keyboard == []

    async def test_header_and_footer_with_pagination(self):
        renderer = make_renderer()
        items = [{"name": str(i)} for i in range(15)]
        header = [[Button(text="Title", callback_action="noop")]]
        footer = [[Button(text="Close", callback_action="close")]]
        pagination = PaginationConfig(enabled=True, page_size=5)
        keyboard = make_keyboard(
            layout="list",
            pagination=pagination,
            header_buttons=header,
            footer_buttons=footer,
        )
        ctx = make_context(flags={"items": items}, pagination_page=1)

        with patch(
            "ui_router.execution.keyboard.DynamicKeyboardRenderer._render_button",
            new_callable=AsyncMock,
            return_value=InlineKeyboardButton(text="mock", callback_data="mock_data"),
        ):
            result = await renderer.render(keyboard, ctx, "scene_1")

        expected_rows = 1 + 5 + 1 + 1
        assert len(result.inline_keyboard) == expected_rows


class TestCreatePaginationRow:
    async def test_creates_five_buttons(self):
        cm = make_callback_manager()
        renderer = make_renderer(cm)
        config = PaginationConfig(enabled=True, page_size=5)

        row = await renderer._create_pagination_row(2, 5, config, "items", "scene_1")

        assert len(row) == 5

    async def test_first_button_is_page_one(self):
        cm = make_callback_manager()
        renderer = make_renderer(cm)
        config = PaginationConfig(enabled=True, page_size=5)

        row = await renderer._create_pagination_row(3, 10, config, "items", "scene_1")

        assert row[0].text == "1"

    async def test_last_button_is_total_pages(self):
        cm = make_callback_manager()
        renderer = make_renderer(cm)
        config = PaginationConfig(enabled=True, page_size=5)

        row = await renderer._create_pagination_row(3, 10, config, "items", "scene_1")

        assert row[4].text == "10"

    async def test_current_page_indicator(self):
        cm = make_callback_manager()
        renderer = make_renderer(cm)
        config = PaginationConfig(enabled=True, page_size=5)

        row = await renderer._create_pagination_row(3, 10, config, "items", "scene_1")

        assert row[2].text == "· 3 ·"

    async def test_prev_and_next_button_texts(self):
        cm = make_callback_manager()
        renderer = make_renderer(cm)
        config = PaginationConfig(enabled=True, page_size=5, prev_button_text="<<", next_button_text=">>")

        row = await renderer._create_pagination_row(3, 10, config, "items", "scene_1")

        assert row[1].text == "<<"
        assert row[3].text == ">>"

    async def test_cyclic_prev_on_first_page_goes_to_last(self):
        cm = make_callback_manager()
        call_index = 0
        page_params = {}

        async def encode_side_effect(**kwargs):
            nonlocal call_index
            call_index += 1
            if kwargs.get("params", {}).get("action") == "goto_page":
                page_params[call_index] = kwargs["params"]["page"]
            return f"cb_{call_index}"

        cm.encode = AsyncMock(side_effect=encode_side_effect)
        renderer = make_renderer(cm)
        config = PaginationConfig(enabled=True, page_size=5)

        row = await renderer._create_pagination_row(1, 5, config, "items", "scene_1")

        prev_button_call = cm.encode.call_args_list[2]
        prev_page = prev_button_call.kwargs["params"]["page"]
        assert prev_page == "5"

    async def test_cyclic_next_on_last_page_goes_to_first(self):
        cm = make_callback_manager()
        call_index = 0

        async def encode_side_effect(**kwargs):
            nonlocal call_index
            call_index += 1
            return f"cb_{call_index}"

        cm.encode = AsyncMock(side_effect=encode_side_effect)
        renderer = make_renderer(cm)
        config = PaginationConfig(enabled=True, page_size=5)

        row = await renderer._create_pagination_row(5, 5, config, "items", "scene_1")

        next_button_call = cm.encode.call_args_list[3]
        next_page = next_button_call.kwargs["params"]["page"]
        assert next_page == "1"

    async def test_non_edge_page_prev_and_next(self):
        cm = make_callback_manager()
        call_index = 0

        async def encode_side_effect(**kwargs):
            nonlocal call_index
            call_index += 1
            return f"cb_{call_index}"

        cm.encode = AsyncMock(side_effect=encode_side_effect)
        renderer = make_renderer(cm)
        config = PaginationConfig(enabled=True, page_size=5)

        await renderer._create_pagination_row(3, 5, config, "items", "scene_1")

        prev_call = cm.encode.call_args_list[2]
        assert prev_call.kwargs["params"]["page"] == "2"

        next_call = cm.encode.call_args_list[3]
        assert next_call.kwargs["params"]["page"] == "4"

    async def test_all_buttons_have_callback_data(self):
        cm = make_callback_manager()
        renderer = make_renderer(cm)
        config = PaginationConfig(enabled=True, page_size=5)

        row = await renderer._create_pagination_row(2, 5, config, "items", "scene_1")

        for button in row:
            assert button.callback_data is not None


class TestRenderButton:
    async def test_render_button_with_callback_action(self):
        cm = make_callback_manager()
        renderer = make_renderer(cm)
        button = Button(text="Click me", callback_action="do_action")
        ctx = make_context()

        with patch("ui_router.execution.actions.ContentResolver") as mock_resolver_cls:
            mock_resolver = MagicMock()
            mock_resolver.resolve_string.return_value = "Click me"
            mock_resolver_cls.return_value = mock_resolver

            result = await renderer._render_button(button, ctx, "scene_1")

        assert result.text == "Click me"
        assert result.callback_data == "callback_data_mock"

    async def test_render_button_with_url(self):
        cm = make_callback_manager()
        renderer = make_renderer(cm)
        button = Button(text="Google", url="https://google.com")
        ctx = make_context()

        with patch("ui_router.execution.actions.ContentResolver") as mock_resolver_cls:
            mock_resolver = MagicMock()
            mock_resolver.resolve_string.side_effect = lambda text, _ctx: (
                "Google" if text == "Google" else "https://google.com"
            )
            mock_resolver_cls.return_value = mock_resolver

            result = await renderer._render_button(button, ctx, "scene_1")

        assert result.text == "Google"
        assert result.url == "https://google.com"
        assert result.callback_data is None

    async def test_render_button_global_callback(self):
        cm = make_callback_manager()
        renderer = make_renderer(cm)
        button = Button(text="Global", callback_global="global_action")
        ctx = make_context()

        with patch("ui_router.execution.actions.ContentResolver") as mock_resolver_cls:
            mock_resolver = MagicMock()
            mock_resolver.resolve_string.return_value = "Global"
            mock_resolver_cls.return_value = mock_resolver

            await renderer._render_button(button, ctx, "scene_1")

        call_kwargs = cm.encode.call_args.kwargs
        assert call_kwargs["is_global"] is True
        assert call_kwargs["handler_name"] == "global_action"
