"""
Unit tests for input helper update persistence (issue #880).

Verifies that ha_config_set_helper uses the {type}/update WebSocket API
(not just entity registry) when updating input helpers, so that config
changes like options, min/max, etc. persist across HA restarts.
"""

from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch

import pytest


@pytest.fixture
def mock_client():
    """Create a mock client with WebSocket support for helper updates."""
    client = MagicMock()

    def make_ws_responses(helper_type: str, unique_id: str = "abc123"):
        """Build side_effect for send_websocket_message based on message type."""

        async def ws_handler(msg: dict) -> dict:
            msg_type = msg.get("type", "")

            if msg_type == "config/entity_registry/get":
                return {
                    "success": True,
                    "result": {
                        "entity_id": msg["entity_id"],
                        "unique_id": unique_id,
                        "platform": helper_type,
                    },
                }

            if msg_type.endswith("/list"):
                return {
                    "success": True,
                    "result": [
                        {
                            "id": unique_id,
                            "name": "Existing Helper",
                            "options": ["old_a", "old_b"],
                            "min": 0,
                            "max": 100,
                        }
                    ],
                }

            if msg_type.endswith("/update"):
                return {
                    "success": True,
                    "result": {
                        "id": unique_id,
                        **{k: v for k, v in msg.items() if k != "type"},
                    },
                }

            if msg_type == "config/entity_registry/update":
                return {
                    "success": True,
                    "result": {"entity_entry": {"entity_id": msg["entity_id"]}},
                }

            return {"success": True, "result": {}}

        return ws_handler

    client._make_ws_responses = make_ws_responses
    return client


@pytest.fixture
def register_tools(mock_client):
    """Register helper config tools and return the captured tool functions."""
    from ha_mcp.tools.tools_config_helpers import register_config_helper_tools

    registered_tools: dict[str, Any] = {}

    def capture_add_tool(method):
        name = (
            method.__fastmcp__.name
            if hasattr(method, "__fastmcp__")
            else method.__name__
        )
        registered_tools[name] = method

    mock_mcp = MagicMock()
    mock_mcp.add_tool = capture_add_tool
    register_config_helper_tools(mock_mcp, mock_client)
    return registered_tools


class TestInputSelectUpdatePersistence:
    """Test that input_select updates use the storage API, not just entity registry."""

    async def test_update_options_calls_storage_api(self, register_tools, mock_client):
        """Updating options should call input_select/update, not entity registry."""
        mock_client.send_websocket_message = AsyncMock(
            side_effect=mock_client._make_ws_responses("input_select")
        )

        with patch(
            "ha_mcp.tools.tools_config_helpers.wait_for_entity_registered",
            new_callable=AsyncMock,
            return_value=True,
        ):
            result = await register_tools["ha_config_set_helper"](
                helper_type="input_select",
                name="My Dropdown",
                helper_id="my_dropdown",
                options=["Option A", "Option B", "Option C"],
            )

        assert result["success"] is True

        # Find the storage update call
        ws_calls = mock_client.send_websocket_message.call_args_list
        update_call = next(
            (c for c in ws_calls if c[0][0].get("type") == "input_select/update"),
            None,
        )
        assert update_call is not None, (
            "Expected input_select/update WebSocket call, got: "
            + str([c[0][0].get("type") for c in ws_calls])
        )
        msg = update_call[0][0]
        assert msg["options"] == ["Option A", "Option B", "Option C"]
        assert "input_select_id" in msg

    async def test_update_initial_value(self, register_tools, mock_client):
        """Updating initial value should be included in storage API call."""
        mock_client.send_websocket_message = AsyncMock(
            side_effect=mock_client._make_ws_responses("input_select")
        )

        with patch(
            "ha_mcp.tools.tools_config_helpers.wait_for_entity_registered",
            new_callable=AsyncMock,
            return_value=True,
        ):
            result = await register_tools["ha_config_set_helper"](
                helper_type="input_select",
                name="My Dropdown",
                helper_id="my_dropdown",
                options=["A", "B"],
                initial="B",
            )

        assert result["success"] is True
        ws_calls = mock_client.send_websocket_message.call_args_list
        update_call = next(
            c for c in ws_calls if c[0][0].get("type") == "input_select/update"
        )
        assert update_call[0][0]["initial"] == "B"


class TestInputNumberUpdatePersistence:
    """Test that input_number updates use the storage API."""

    async def test_update_min_max_calls_storage_api(self, register_tools, mock_client):
        """Updating min/max should call input_number/update."""
        mock_client.send_websocket_message = AsyncMock(
            side_effect=mock_client._make_ws_responses("input_number")
        )

        with patch(
            "ha_mcp.tools.tools_config_helpers.wait_for_entity_registered",
            new_callable=AsyncMock,
            return_value=True,
        ):
            result = await register_tools["ha_config_set_helper"](
                helper_type="input_number",
                name="My Number",
                helper_id="my_number",
                min_value=0,
                max_value=200,
                step=5,
                unit_of_measurement="W",
                mode="slider",
            )

        assert result["success"] is True

        ws_calls = mock_client.send_websocket_message.call_args_list
        update_call = next(
            (c for c in ws_calls if c[0][0].get("type") == "input_number/update"),
            None,
        )
        assert update_call is not None
        msg = update_call[0][0]
        assert msg["min"] == 0
        assert msg["max"] == 200
        assert msg["step"] == 5
        assert msg["unit_of_measurement"] == "W"
        assert msg["mode"] == "slider"


class TestInputTextUpdatePersistence:
    """Test that input_text updates use the storage API."""

    async def test_update_mode_calls_storage_api(self, register_tools, mock_client):
        """Updating mode should call input_text/update."""
        mock_client.send_websocket_message = AsyncMock(
            side_effect=mock_client._make_ws_responses("input_text")
        )

        with patch(
            "ha_mcp.tools.tools_config_helpers.wait_for_entity_registered",
            new_callable=AsyncMock,
            return_value=True,
        ):
            result = await register_tools["ha_config_set_helper"](
                helper_type="input_text",
                name="My Text",
                helper_id="my_text",
                min_value=1,
                max_value=50,
                mode="password",
            )

        assert result["success"] is True
        ws_calls = mock_client.send_websocket_message.call_args_list
        update_call = next(
            (c for c in ws_calls if c[0][0].get("type") == "input_text/update"),
            None,
        )
        assert update_call is not None
        msg = update_call[0][0]
        assert msg["min"] == 1
        assert msg["max"] == 50
        assert msg["mode"] == "password"


class TestInputBooleanUpdatePersistence:
    """Test that input_boolean updates use the storage API."""

    async def test_update_name_calls_storage_api(self, register_tools, mock_client):
        """Updating name should call input_boolean/update."""
        mock_client.send_websocket_message = AsyncMock(
            side_effect=mock_client._make_ws_responses("input_boolean")
        )

        with patch(
            "ha_mcp.tools.tools_config_helpers.wait_for_entity_registered",
            new_callable=AsyncMock,
            return_value=True,
        ):
            result = await register_tools["ha_config_set_helper"](
                helper_type="input_boolean",
                helper_id="my_toggle",
                name="Updated Toggle",
            )

        assert result["success"] is True
        ws_calls = mock_client.send_websocket_message.call_args_list
        update_call = next(
            (c for c in ws_calls if c[0][0].get("type") == "input_boolean/update"),
            None,
        )
        assert update_call is not None
        assert update_call[0][0]["name"] == "Updated Toggle"


class TestInputDatetimeUpdatePersistence:
    """Test that input_datetime updates use the storage API."""

    async def test_update_has_date_time_calls_storage_api(
        self, register_tools, mock_client
    ):
        """Updating has_date/has_time should call input_datetime/update."""
        mock_client.send_websocket_message = AsyncMock(
            side_effect=mock_client._make_ws_responses("input_datetime")
        )

        with patch(
            "ha_mcp.tools.tools_config_helpers.wait_for_entity_registered",
            new_callable=AsyncMock,
            return_value=True,
        ):
            result = await register_tools["ha_config_set_helper"](
                helper_type="input_datetime",
                name="My Datetime",
                helper_id="my_datetime",
                has_date=True,
                has_time=False,
            )

        assert result["success"] is True
        ws_calls = mock_client.send_websocket_message.call_args_list
        update_call = next(
            (c for c in ws_calls if c[0][0].get("type") == "input_datetime/update"),
            None,
        )
        assert update_call is not None
        msg = update_call[0][0]
        assert msg["has_date"] is True
        assert msg["has_time"] is False


class TestCounterUpdatePersistence:
    """Test that counter updates use the storage API."""

    async def test_update_step_calls_storage_api(self, register_tools, mock_client):
        """Updating step/min/max should call counter/update."""
        mock_client.send_websocket_message = AsyncMock(
            side_effect=mock_client._make_ws_responses("counter")
        )

        with patch(
            "ha_mcp.tools.tools_config_helpers.wait_for_entity_registered",
            new_callable=AsyncMock,
            return_value=True,
        ):
            result = await register_tools["ha_config_set_helper"](
                helper_type="counter",
                name="My Counter",
                helper_id="my_counter",
                initial="10",
                min_value=0,
                max_value=100,
                step=2,
                restore=True,
            )

        assert result["success"] is True
        ws_calls = mock_client.send_websocket_message.call_args_list
        update_call = next(
            (c for c in ws_calls if c[0][0].get("type") == "counter/update"),
            None,
        )
        assert update_call is not None
        msg = update_call[0][0]
        assert msg["initial"] == 10
        assert msg["minimum"] == 0
        assert msg["maximum"] == 100
        assert msg["step"] == 2
        assert msg["restore"] is True


class TestTimerUpdatePersistence:
    """Test that timer updates use the storage API."""

    async def test_update_duration_calls_storage_api(self, register_tools, mock_client):
        """Updating duration should call timer/update."""
        mock_client.send_websocket_message = AsyncMock(
            side_effect=mock_client._make_ws_responses("timer")
        )

        with patch(
            "ha_mcp.tools.tools_config_helpers.wait_for_entity_registered",
            new_callable=AsyncMock,
            return_value=True,
        ):
            result = await register_tools["ha_config_set_helper"](
                helper_type="timer",
                name="My Timer",
                helper_id="my_timer",
                duration="00:30:00",
                restore=False,
            )

        assert result["success"] is True
        ws_calls = mock_client.send_websocket_message.call_args_list
        update_call = next(
            (c for c in ws_calls if c[0][0].get("type") == "timer/update"),
            None,
        )
        assert update_call is not None
        msg = update_call[0][0]
        assert msg["duration"] == "00:30:00"
        assert msg["restore"] is False


class TestInputButtonUpdatePersistence:
    """Test that input_button updates use the storage API."""

    async def test_update_name_calls_storage_api(self, register_tools, mock_client):
        """Updating name should call input_button/update."""
        mock_client.send_websocket_message = AsyncMock(
            side_effect=mock_client._make_ws_responses("input_button")
        )

        with patch(
            "ha_mcp.tools.tools_config_helpers.wait_for_entity_registered",
            new_callable=AsyncMock,
            return_value=True,
        ):
            result = await register_tools["ha_config_set_helper"](
                helper_type="input_button",
                helper_id="my_button",
                name="Updated Button",
                icon="mdi:gesture-tap",
            )

        assert result["success"] is True
        ws_calls = mock_client.send_websocket_message.call_args_list
        update_call = next(
            (c for c in ws_calls if c[0][0].get("type") == "input_button/update"),
            None,
        )
        assert update_call is not None
        msg = update_call[0][0]
        assert msg["name"] == "Updated Button"
        assert msg["icon"] == "mdi:gesture-tap"


class TestEntityRegistryFallback:
    """Verify the entity-registry-only fallback still works for unknown types."""

    async def test_unknown_type_uses_entity_registry(self, register_tools, mock_client):
        """Unknown helper types should fall back to entity registry update."""
        mock_client.send_websocket_message = AsyncMock(
            return_value={
                "success": True,
                "result": {"entity_entry": {"entity_id": "unknown_type.test"}},
            }
        )

        with patch(
            "ha_mcp.tools.tools_config_helpers.wait_for_entity_registered",
            new_callable=AsyncMock,
            return_value=True,
        ):
            result = await register_tools["ha_config_set_helper"](
                helper_type="unknown_type",
                helper_id="test",
                name="Test",
            )

        assert result["success"] is True
        ws_calls = mock_client.send_websocket_message.call_args_list
        # Should use entity registry, not {type}/update
        update_call = ws_calls[0][0][0]
        assert update_call["type"] == "config/entity_registry/update"


class TestFlowHelperRouting:
    """Verify that ha_config_set_helper routes flow-based helper types (#967)
    to the Config Entry Flow API, not to the WebSocket {type}/create path.

    Covers the unified-tool routing added in #967: types in FLOW_HELPER_TYPES
    (template, group, utility_meter, ...) are delegated to create_flow_helper /
    update_flow_helper; entity resolution and registry updates then run against
    all entities of the resulting config entry.
    """

    async def test_flow_helper_create_routes_via_config_flow(
        self, register_tools, mock_client
    ):
        """Creating a min_max helper uses start_config_flow, not WebSocket create."""
        mock_client.start_config_flow = AsyncMock(
            return_value={
                "type": "create_entry",
                "flow_id": "flow-1",
                "result": {
                    "entry_id": "entry-1",
                    "title": "avg_temp",
                    "domain": "min_max",
                },
            }
        )
        # entity_registry/list returns one entity for our entry.
        mock_client.send_websocket_message = AsyncMock(
            return_value={
                "success": True,
                "result": [
                    {
                        "entity_id": "sensor.avg_temp",
                        "config_entry_id": "entry-1",
                    }
                ],
            }
        )

        result = await register_tools["ha_config_set_helper"](
            helper_type="min_max",
            name="avg_temp",
            config={"entity_ids": ["sensor.a", "sensor.b"], "type": "mean"},
            wait=False,
        )

        assert result["success"] is True
        assert result["action"] == "create"
        assert result["method"] == "config_flow"
        assert result["entry_id"] == "entry-1"
        assert result["entity_ids"] == ["sensor.avg_temp"]
        # start_config_flow is awaited twice for create: once for schema
        # introspection (to decide whether to inject `name`), once for the
        # real flow. Both calls pass the same helper_type.
        for call in mock_client.start_config_flow.await_args_list:
            assert call.args == ("min_max",)

    async def test_flow_helper_update_routes_via_options_flow(
        self, register_tools, mock_client
    ):
        """Updating a flow helper (helper_id set) uses start_options_flow."""
        mock_client.get_config_entry = AsyncMock(
            return_value={"domain": "min_max", "entry_id": "entry-1"}
        )
        mock_client.start_options_flow = AsyncMock(
            return_value={
                "type": "create_entry",
                "flow_id": "flow-2",
                "result": {"entry_id": "entry-1", "title": "avg_temp"},
            }
        )
        mock_client.send_websocket_message = AsyncMock(
            return_value={"success": True, "result": []}
        )

        result = await register_tools["ha_config_set_helper"](
            helper_type="min_max",
            name="avg_temp",
            config={"entity_ids": ["sensor.c"], "type": "max"},
            helper_id="entry-1",
            wait=False,
        )

        assert result["success"] is True
        assert result["action"] == "update"
        assert result["updated"] is True
        assert result["method"] == "config_flow"
        mock_client.start_options_flow.assert_awaited_once_with("entry-1")

    async def test_flow_helper_multi_entity_registry_updates_apply_to_all(
        self, register_tools, mock_client
    ):
        """utility_meter with 2 tariffs creates 3 entities; area_id/labels
        must be applied to every one of them (#967 scope by kp13)."""
        mock_client.start_config_flow = AsyncMock(
            return_value={
                "type": "create_entry",
                "flow_id": "flow-um",
                "result": {
                    "entry_id": "entry-um",
                    "title": "daily_kwh",
                    "domain": "utility_meter",
                },
            }
        )

        # Simulate HA: Bug 12 collision check (config_entries/get) returns
        # empty (no collision); Bug 16 registry-ID validation lookups (area +
        # label); first business call lists 3 entities (select + 2 tariff
        # sensors); then entity_registry/update calls all succeed.
        responses = [
            # Bug 12 collision check
            {"success": True, "result": []},
            # Bug 16 validation: area_registry/list, label_registry/list
            {"success": True, "result": [{"area_id": "kitchen", "name": "Kitchen"}]},
            {
                "success": True,
                "result": [{"label_id": "metered", "name": "Metered"}],
            },
            {
                "success": True,
                "result": [
                    {"entity_id": "select.daily_kwh", "config_entry_id": "entry-um"},
                    {
                        "entity_id": "sensor.daily_kwh_peak",
                        "config_entry_id": "entry-um",
                    },
                    {
                        "entity_id": "sensor.daily_kwh_offpeak",
                        "config_entry_id": "entry-um",
                    },
                ],
            },
        ]
        responses.extend([{"success": True}] * 10)  # plenty of headroom
        mock_client.send_websocket_message = AsyncMock(side_effect=responses)

        result = await register_tools["ha_config_set_helper"](
            helper_type="utility_meter",
            name="daily_kwh",
            config={
                "source": "sensor.energy",
                "cycle": "daily",
                "tariffs": ["peak", "offpeak"],
            },
            area_id="kitchen",
            labels=["metered"],
            wait=False,
        )

        assert result["success"] is True
        assert sorted(result["entity_ids"]) == sorted(
            [
                "select.daily_kwh",
                "sensor.daily_kwh_peak",
                "sensor.daily_kwh_offpeak",
            ]
        )
        assert result["area_id"] == "kitchen"
        assert result["labels"] == ["metered"]
        assert len(result["applied"]) == 3

        # Verify every entity received a config/entity_registry/update call
        update_calls = [
            call.args[0]
            for call in mock_client.send_websocket_message.call_args_list
            if isinstance(call.args[0], dict)
            and call.args[0].get("type") == "config/entity_registry/update"
        ]
        updated_entities = {c["entity_id"] for c in update_calls}
        assert updated_entities == {
            "select.daily_kwh",
            "sensor.daily_kwh_peak",
            "sensor.daily_kwh_offpeak",
        }
        # icon was not passed, so no icon key may leak into the WS update.
        assert all("icon" not in c for c in update_calls)

    async def test_flow_helper_applies_icon_via_entity_registry(
        self, register_tools, mock_client
    ):
        """A template (flow) helper accepts `icon` and applies it to the
        resulting entity via config/entity_registry/update.

        Flow helpers have no icon field in their config-flow form, but the
        entity-registry icon override works for any entity (same mechanism as
        area_id/labels), so `icon` is allowed and threaded through rather than
        rejected as inapplicable.
        """
        mock_client.start_config_flow = AsyncMock(
            return_value={
                "type": "create_entry",
                "flow_id": "flow-tmpl",
                "result": {
                    "entry_id": "entry-tmpl",
                    "title": "Flash Sensor",
                    "domain": "template",
                },
            }
        )
        responses = [
            # Bug 12 collision check: no existing helper with this slug.
            {"success": True, "result": []},
            # entity_registry/list: one entity for our config entry.
            {
                "success": True,
                "result": [
                    {
                        "entity_id": "sensor.flash_sensor",
                        "config_entry_id": "entry-tmpl",
                    }
                ],
            },
        ]
        responses.extend([{"success": True}] * 5)  # entity_registry/update headroom
        mock_client.send_websocket_message = AsyncMock(side_effect=responses)

        result = await register_tools["ha_config_set_helper"](
            helper_type="template",
            name="Flash Sensor",
            config={"template_type": "sensor", "state": "{{ 1 }}"},
            icon="mdi:flash",
            wait=False,
        )

        assert result["success"] is True
        assert result["method"] == "config_flow"
        assert result["icon"] == "mdi:flash"

        # The entity_registry/update for our entity must carry the icon.
        update_calls = [
            call.args[0]
            for call in mock_client.send_websocket_message.call_args_list
            if isinstance(call.args[0], dict)
            and call.args[0].get("type") == "config/entity_registry/update"
        ]
        assert len(update_calls) == 1
        assert update_calls[0]["entity_id"] == "sensor.flash_sensor"
        assert update_calls[0]["icon"] == "mdi:flash"

    async def test_flow_helper_multi_entity_icon_applies_to_all(
        self, register_tools, mock_client
    ):
        """icon on a multi-entity flow helper (utility_meter + 2 tariffs => 3
        entities) is applied to every entity, like area_id/labels."""
        mock_client.start_config_flow = AsyncMock(
            return_value={
                "type": "create_entry",
                "flow_id": "flow-um-icon",
                "result": {
                    "entry_id": "entry-um-icon",
                    "title": "daily_kwh",
                    "domain": "utility_meter",
                },
            }
        )
        # icon needs no registry-ID validation (unlike area_id/labels), so the
        # sequence is just collision-check then entity-list then the updates.
        responses = [
            {"success": True, "result": []},
            {
                "success": True,
                "result": [
                    {
                        "entity_id": "select.daily_kwh",
                        "config_entry_id": "entry-um-icon",
                    },
                    {
                        "entity_id": "sensor.daily_kwh_peak",
                        "config_entry_id": "entry-um-icon",
                    },
                    {
                        "entity_id": "sensor.daily_kwh_offpeak",
                        "config_entry_id": "entry-um-icon",
                    },
                ],
            },
        ]
        responses.extend([{"success": True}] * 5)
        mock_client.send_websocket_message = AsyncMock(side_effect=responses)

        result = await register_tools["ha_config_set_helper"](
            helper_type="utility_meter",
            name="daily_kwh",
            config={
                "source": "sensor.energy",
                "cycle": "daily",
                "tariffs": ["peak", "offpeak"],
            },
            icon="mdi:flash",
            wait=False,
        )

        assert result["success"] is True
        assert result["icon"] == "mdi:flash"
        update_calls = [
            call.args[0]
            for call in mock_client.send_websocket_message.call_args_list
            if isinstance(call.args[0], dict)
            and call.args[0].get("type") == "config/entity_registry/update"
        ]
        assert {c["entity_id"] for c in update_calls} == {
            "select.daily_kwh",
            "sensor.daily_kwh_peak",
            "sensor.daily_kwh_offpeak",
        }
        assert all(c["icon"] == "mdi:flash" for c in update_calls)

    async def test_flow_helper_clears_icon_on_empty_string(
        self, register_tools, mock_client
    ):
        """icon='' clears the override: the WS update carries icon=None and the
        response echoes icon=None (mirrors the area/labels clear convention)."""
        mock_client.start_config_flow = AsyncMock(
            return_value={
                "type": "create_entry",
                "flow_id": "flow-ic-clear",
                "result": {
                    "entry_id": "entry-ic-clear",
                    "title": "t",
                    "domain": "template",
                },
            }
        )
        responses = [
            {"success": True, "result": []},
            {
                "success": True,
                "result": [
                    {"entity_id": "sensor.t", "config_entry_id": "entry-ic-clear"}
                ],
            },
        ]
        responses.extend([{"success": True}] * 3)
        mock_client.send_websocket_message = AsyncMock(side_effect=responses)

        result = await register_tools["ha_config_set_helper"](
            helper_type="template",
            name="t",
            config={"next_step_id": "sensor", "name": "t", "state": "{{ 1 }}"},
            icon="",
            wait=False,
        )

        assert result["success"] is True
        assert result["icon"] is None
        update_calls = [
            call.args[0]
            for call in mock_client.send_websocket_message.call_args_list
            if isinstance(call.args[0], dict)
            and call.args[0].get("type") == "config/entity_registry/update"
        ]
        assert len(update_calls) == 1
        assert "icon" in update_calls[0]
        assert update_calls[0]["icon"] is None

    async def test_flow_helper_update_applies_icon(self, register_tools, mock_client):
        """icon is applied on the flow-helper UPDATE path (options flow), not
        just create — the registry touchup is shared between both paths."""
        mock_client.get_config_entry = AsyncMock(
            return_value={"domain": "template", "entry_id": "entry-u-ic"}
        )
        mock_client.start_options_flow = AsyncMock(
            return_value={
                "type": "create_entry",
                "flow_id": "flow-u-ic",
                "result": {"entry_id": "entry-u-ic", "title": "t"},
            }
        )
        responses = [
            {
                "success": True,
                "result": [{"entity_id": "sensor.tu", "config_entry_id": "entry-u-ic"}],
            },
        ]
        responses.extend([{"success": True}] * 3)
        mock_client.send_websocket_message = AsyncMock(side_effect=responses)

        result = await register_tools["ha_config_set_helper"](
            helper_type="template",
            helper_id="entry-u-ic",
            config={"state": "{{ 2 }}"},
            icon="mdi:flash",
            wait=False,
        )

        assert result["success"] is True
        assert result["updated"] is True
        assert result["icon"] == "mdi:flash"
        update_calls = [
            call.args[0]
            for call in mock_client.send_websocket_message.call_args_list
            if isinstance(call.args[0], dict)
            and call.args[0].get("type") == "config/entity_registry/update"
        ]
        assert any(c.get("icon") == "mdi:flash" for c in update_calls)

    async def test_flow_helper_registry_update_failure_collects_warning(
        self, register_tools, mock_client
    ):
        """Partial registry-update failure surfaces as per-entity warning, not hard error."""
        mock_client.start_config_flow = AsyncMock(
            return_value={
                "type": "create_entry",
                "flow_id": "flow-g",
                "result": {"entry_id": "entry-g", "title": "grp", "domain": "group"},
            }
        )
        # Bug 12 collision check (no collision), then Bug 16 area_registry/list,
        # then entity_registry/list, then entity_registry/update (which fails).
        responses = [
            {"success": True, "result": []},
            {"success": True, "result": [{"area_id": "hallway", "name": "Hallway"}]},
            {
                "success": True,
                "result": [
                    {"entity_id": "light.grp", "config_entry_id": "entry-g"},
                ],
            },
            {"success": False, "error": {"message": "registry unavailable"}},
        ]
        mock_client.send_websocket_message = AsyncMock(side_effect=responses)

        result = await register_tools["ha_config_set_helper"](
            helper_type="group",
            name="grp",
            config={"group_type": "light", "entities": [], "hide_members": False},
            area_id="hallway",
            icon="mdi:flash",
            wait=False,
        )

        assert result["success"] is True
        assert "warnings" in result
        assert any("light.grp" in w for w in result["warnings"])
        # The update failed, so the optimistic top-level echo must NOT claim
        # area_id/icon were applied — only the warning reflects reality.
        assert "area_id" not in result
        assert "icon" not in result
        # The warning names which batched fields were in the failed update.
        assert any("area_id" in w and "icon" in w for w in result["warnings"])

    async def test_flow_helper_registry_list_raises_surfaces_warning(
        self, register_tools, mock_client
    ):
        """WS raises during entity_registry/list: don't silently return entity_ids=[]."""
        mock_client.start_config_flow = AsyncMock(
            return_value={
                "type": "create_entry",
                "flow_id": "flow-r1",
                "result": {"entry_id": "entry-r1", "title": "m", "domain": "min_max"},
            }
        )
        # First (and only) send_websocket_message raises — simulates
        # connection drop or HA mid-restart during the registry list.
        mock_client.send_websocket_message = AsyncMock(
            side_effect=ConnectionError("WebSocket closed")
        )

        result = await register_tools["ha_config_set_helper"](
            helper_type="min_max",
            name="m",
            config={"entity_ids": ["sensor.a"], "type": "mean"},
            area_id="hallway",
            wait=False,
        )

        # Helper creation still reports success (config entry was created),
        # but the caller must see that registry touchups didn't happen.
        assert result["success"] is True
        assert result["entity_ids"] == []
        assert "warnings" in result
        assert any(
            "entity_registry/list" in w and "entry-r1" in w for w in result["warnings"]
        )

    async def test_flow_helper_registry_update_raises_continues_loop(
        self, register_tools, mock_client
    ):
        """WS raises mid-loop: remaining entities still get their registry update."""
        mock_client.start_config_flow = AsyncMock(
            return_value={
                "type": "create_entry",
                "flow_id": "flow-w3",
                "result": {
                    "entry_id": "entry-w3",
                    "title": "um",
                    "domain": "utility_meter",
                },
            }
        )
        # Bug 12 collision check (no collision), Bug 16 area_registry/list
        # (only area_id passed, no labels/category), entity_registry/list
        # returns 3 entities, then update for entity 1 raises, entity 2+3 succeed.
        responses: list = [
            {"success": True, "result": []},
            {"success": True, "result": [{"area_id": "hallway", "name": "Hallway"}]},
            {
                "success": True,
                "result": [
                    {"entity_id": "select.um", "config_entry_id": "entry-w3"},
                    {"entity_id": "sensor.um_peak", "config_entry_id": "entry-w3"},
                    {"entity_id": "sensor.um_offpeak", "config_entry_id": "entry-w3"},
                ],
            },
            ConnectionError("WebSocket transient fault on update #1"),
            {"success": True, "result": {}},
            {"success": True, "result": {}},
        ]

        async def side_effect(*args, **kwargs):
            val = responses.pop(0)
            if isinstance(val, Exception):
                raise val
            return val

        mock_client.send_websocket_message = AsyncMock(side_effect=side_effect)

        result = await register_tools["ha_config_set_helper"](
            helper_type="utility_meter",
            name="um",
            config={
                "source": "sensor.energy",
                "cycle": "daily",
                "tariffs": ["peak", "offpeak"],
            },
            area_id="hallway",
            wait=False,
        )

        assert result["success"] is True
        assert len(result["entity_ids"]) == 3
        assert "warnings" in result
        # The raising entity must surface as a warning; the other two must not.
        assert any("select.um" in w and "raised" in w for w in result["warnings"])
        assert not any("sensor.um_peak" in w for w in result["warnings"])
        assert not any("sensor.um_offpeak" in w for w in result["warnings"])

    async def test_flow_helper_create_requires_name(self, register_tools, mock_client):
        """Flow helper create without name (neither top-level nor in config) errors."""
        from fastmcp.exceptions import ToolError

        with pytest.raises(ToolError):
            await register_tools["ha_config_set_helper"](
                helper_type="min_max",
                name="",  # explicit empty
                config={"entity_ids": ["sensor.x"], "type": "mean"},
                wait=False,
            )

    async def test_flow_helper_name_param_folded_into_config(
        self, register_tools, mock_client
    ):
        """Top-level name param is injected into config dict when not already present."""
        captured_config: dict = {}

        async def capture_submit(flow_id, data):
            captured_config.update(data)
            return {
                "type": "create_entry",
                "result": {"entry_id": "entry-c", "title": "t", "domain": "min_max"},
            }

        mock_client.start_config_flow = AsyncMock(
            return_value={
                "type": "form",
                "flow_id": "flow-c",
                "step_id": "user",
            }
        )
        mock_client.submit_config_flow_step = AsyncMock(side_effect=capture_submit)
        mock_client.abort_config_flow = AsyncMock(return_value={})
        mock_client.send_websocket_message = AsyncMock(
            return_value={"success": True, "result": []}
        )

        await register_tools["ha_config_set_helper"](
            helper_type="min_max",
            name="my_helper_name",
            config={"entity_ids": ["sensor.x"], "type": "mean"},
            wait=False,
        )

        assert captured_config.get("name") == "my_helper_name"

    async def test_simple_type_rejects_config_param(self, register_tools, mock_client):
        """Passing config for a simple helper type raises VALIDATION_INVALID_PARAMETER.

        Silent-ignore would mislead agents into thinking the payload took effect.
        Empty dict and empty string are tolerated (explicit 'nothing').
        """
        from fastmcp.exceptions import ToolError

        # Non-empty config on simple type → reject
        with pytest.raises(ToolError) as excinfo:
            await register_tools["ha_config_set_helper"](
                helper_type="input_boolean",
                name="probe",
                config={"some_key": "some_value"},
            )
        err_text = str(excinfo.value)
        assert "VALIDATION_INVALID_PARAMETER" in err_text
        assert "flow-based" in err_text.lower()

        # Empty dict → tolerated (would proceed to simple path). We only check
        # that no ToolError with VALIDATION_INVALID_PARAMETER for the config
        # reason is raised; the call itself may fail downstream due to mocks.
        try:
            await register_tools["ha_config_set_helper"](
                helper_type="input_boolean",
                name="probe_empty",
                config={},
            )
        except ToolError as e:
            assert "flow-based" not in str(e).lower(), (
                f"empty config should not trigger the flow-based-rejection message: {e}"
            )

    async def test_flow_type_accepts_empty_string_as_no_config(
        self, register_tools, mock_client
    ):
        """Empty string config is tolerated for flow helpers, mirroring simple path.

        ha_config_set_helper L785 treats config in (None, {}, "") as "nothing passed"
        for simple types. The flow path must behave the same — passing config=""
        (common when agents stringify a None) should not surface as
        'Invalid JSON' from parse_json_param, which is confusing and inconsistent
        with the simple-type branch.
        """
        mock_client.start_config_flow = AsyncMock(
            return_value={
                "type": "create_entry",
                "flow_id": "flow-empty",
                "result": {
                    "entry_id": "entry-empty",
                    "title": "probe",
                    "domain": "min_max",
                },
            }
        )
        mock_client.send_websocket_message = AsyncMock(
            return_value={"success": True, "result": []}
        )

        # Should proceed like config=None / config={}: name folds into config_dict
        # on create, flow is started, no 'Invalid JSON' error surfaces.
        result = await register_tools["ha_config_set_helper"](
            helper_type="min_max",
            name="probe",
            config="",
            wait=False,
        )

        assert result["success"] is True
        assert result["action"] == "create"
        assert result["method"] == "config_flow"
        assert result["entry_id"] == "entry-empty"
        # start_config_flow is awaited twice for create: introspection + real
        # flow. Both calls use the same helper_type.
        for call in mock_client.start_config_flow.await_args_list:
            assert call.args == ("min_max",)

    async def test_flow_helper_clears_area_on_empty_string(
        self, register_tools, mock_client
    ):
        """area_id='' must clear the area assignment (HA-WS 'area_id: null').

        Mirrors ha_set_entity semantics (see test_set_area_clear and
        test_entity_management.py::test_set_entity_clear_area): the consolidated
        update convention treats None as 'not provided', empty string as 'explicit clear'.
        The outer guard must use `is not None` so the empty-string case reaches the
        WebSocket call, and the payload must carry area_id: None for HA to clear it.
        """
        ws_calls: list[dict] = []

        async def ws_handler(msg: dict) -> dict:
            ws_calls.append(msg)
            msg_type = msg.get("type", "")
            if msg_type == "config/entity_registry/list":
                return {
                    "success": True,
                    "result": [
                        {
                            "entity_id": "sensor.probe",
                            "config_entry_id": "entry-clear-area",
                        }
                    ],
                }
            return {"success": True, "result": {}}

        mock_client.send_websocket_message = AsyncMock(side_effect=ws_handler)
        mock_client.start_config_flow = AsyncMock(
            return_value={
                "type": "create_entry",
                "flow_id": "flow-clear-area",
                "result": {
                    "entry_id": "entry-clear-area",
                    "title": "probe",
                    "domain": "min_max",
                },
            }
        )

        result = await register_tools["ha_config_set_helper"](
            helper_type="min_max",
            name="probe",
            config={"entity_ids": ["sensor.a"], "type": "mean"},
            area_id="",
            wait=False,
        )

        assert result["success"] is True
        # Find the entity_registry/update call
        updates = [
            c for c in ws_calls if c.get("type") == "config/entity_registry/update"
        ]
        assert len(updates) == 1, (
            f"expected 1 registry update, got {len(updates)}: {ws_calls}"
        )
        assert "area_id" in updates[0], (
            f"area_id must be present in payload (even when clearing): {updates[0]}"
        )
        assert updates[0]["area_id"] is None, (
            f"area_id='' must translate to area_id: None for HA clear semantics, got {updates[0]['area_id']!r}"
        )

    async def test_flow_helper_clears_labels_on_empty_list(
        self, register_tools, mock_client
    ):
        """labels=[] must clear all labels (HA-WS 'labels: []').

        Mirrors ha_set_entity::test_set_labels_empty_list_clears and
        test_label_crud.py usage of 'labels: []' as the clear payload.
        """
        ws_calls: list[dict] = []

        async def ws_handler(msg: dict) -> dict:
            ws_calls.append(msg)
            msg_type = msg.get("type", "")
            if msg_type == "config/entity_registry/list":
                return {
                    "success": True,
                    "result": [
                        {
                            "entity_id": "sensor.probe",
                            "config_entry_id": "entry-clear-labels",
                        }
                    ],
                }
            return {"success": True, "result": {}}

        mock_client.send_websocket_message = AsyncMock(side_effect=ws_handler)
        mock_client.start_config_flow = AsyncMock(
            return_value={
                "type": "create_entry",
                "flow_id": "flow-clear-labels",
                "result": {
                    "entry_id": "entry-clear-labels",
                    "title": "probe",
                    "domain": "min_max",
                },
            }
        )

        result = await register_tools["ha_config_set_helper"](
            helper_type="min_max",
            name="probe",
            config={"entity_ids": ["sensor.a"], "type": "mean"},
            labels=[],
            wait=False,
        )

        assert result["success"] is True
        updates = [
            c for c in ws_calls if c.get("type") == "config/entity_registry/update"
        ]
        assert len(updates) == 1, (
            f"expected 1 registry update, got {len(updates)}: {ws_calls}"
        )
        assert "labels" in updates[0], (
            f"labels must be present in payload (even when clearing): {updates[0]}"
        )
        assert updates[0]["labels"] == [], (
            f"labels=[] must pass through as [] for HA clear semantics, got {updates[0]['labels']!r}"
        )


class TestOptionalNameOnUpdate:
    """Verify ha_config_set_helper accepts name=None on update (#1012 schema change).

    Previously `name: str` was required at the Pydantic layer even though the
    internal create/update branching only enforced it on create. Update-only
    calls (clear area, clear labels) were rejected at schema validation before
    reaching the tool body. Signature is now `name: str | None = None`,
    matching the ha_set_entity convention.
    """

    async def test_simple_helper_update_without_name(self, register_tools, mock_client):
        """Clearing area on input_boolean without supplying name succeeds (SIMPLE path)."""
        mock_client.send_websocket_message = AsyncMock(
            side_effect=mock_client._make_ws_responses("input_boolean")
        )

        with patch(
            "ha_mcp.tools.tools_config_helpers.wait_for_entity_registered",
            new_callable=AsyncMock,
            return_value=True,
        ):
            # No `name` param — must not raise schema validation error.
            result = await register_tools["ha_config_set_helper"](
                helper_type="input_boolean",
                helper_id="my_toggle",
                area_id="",
            )

        assert result["success"] is True, result
        # registry update must have fired with area_id: None (clear)
        ws_calls = mock_client.send_websocket_message.call_args_list
        reg_update = next(
            (
                c
                for c in ws_calls
                if c[0][0].get("type") == "config/entity_registry/update"
            ),
            None,
        )
        assert reg_update is not None, f"no registry update: {ws_calls}"
        assert reg_update[0][0].get("area_id") is None, reg_update[0][0]

    async def test_flow_helper_update_without_name(self, register_tools, mock_client):
        """Clearing area on min_max without supplying name succeeds (FLOW path)."""
        mock_client.get_config_entry = AsyncMock(
            return_value={"domain": "min_max", "entry_id": "entry-1"}
        )
        mock_client.start_options_flow = AsyncMock(
            return_value={
                "type": "create_entry",
                "flow_id": "flow-1",
                "result": {"entry_id": "entry-1", "title": "avg"},
            }
        )

        ws_calls: list[dict] = []

        async def ws_handler(msg: dict) -> dict:
            ws_calls.append(msg)
            msg_type = msg.get("type", "")
            if msg_type == "config/entity_registry/list":
                return {
                    "success": True,
                    "result": [
                        {"entity_id": "sensor.avg", "config_entry_id": "entry-1"}
                    ],
                }
            if msg_type == "config/entity_registry/update":
                return {"success": True, "result": {}}
            return {"success": True, "result": {}}

        mock_client.send_websocket_message = AsyncMock(side_effect=ws_handler)

        # No `name` param on update — must not raise schema validation error.
        result = await register_tools["ha_config_set_helper"](
            helper_type="min_max",
            helper_id="entry-1",
            area_id="",
            wait=False,
        )

        assert result["success"] is True, result
        reg_updates = [
            c for c in ws_calls if c.get("type") == "config/entity_registry/update"
        ]
        assert reg_updates, f"no registry update: {ws_calls}"
        assert reg_updates[0].get("area_id") is None, reg_updates[0]

    async def test_simple_helper_create_still_requires_name(
        self, register_tools, mock_client
    ):
        """Creating a helper without name still fails (but at the tool logic, not Pydantic)."""
        # No mocking needed: create path raises ToolError before any WS call
        # when name is missing.
        from fastmcp.exceptions import ToolError

        with pytest.raises(ToolError, match="name is required for create"):
            await register_tools["ha_config_set_helper"](
                helper_type="input_boolean",
            )
