"""
Unit tests for module-level helpers in tools_integrations and
IntegrationTools.ha_remove_helpers_integrations dispatch.
"""

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

import pytest
from fastmcp.exceptions import ToolError

from ha_mcp.client.rest_client import (
    HomeAssistantAPIError,
    HomeAssistantAuthError,
    HomeAssistantConnectionError,
)
from ha_mcp.tools.tools_integrations import (
    IntegrationTools,
    _get_entry_id_for_flow_helper,
    fetch_entry_options_with_status,
    options_from_form_flow,
)


def _make_client(ws_response: Any = None, raises: Exception | None = None) -> MagicMock:
    """Build a mock client whose send_websocket_message returns / raises."""
    client = MagicMock()
    if raises is not None:
        client.send_websocket_message = AsyncMock(side_effect=raises)
    else:
        client.send_websocket_message = AsyncMock(return_value=ws_response)
    return client


class TestGetEntryIdForFlowHelper:
    """Unit tests for the flow-helper entry_id lookup."""

    async def test_returns_entry_id_for_full_entity_id(self) -> None:
        client = _make_client(
            {"success": True, "result": {"config_entry_id": "abc123"}}
        )
        entry_id, reason = await _get_entry_id_for_flow_helper(
            client, "utility_meter", "sensor.peak"
        )
        assert entry_id == "abc123"
        assert reason == "ok"

    async def test_returns_none_for_bare_id_flow_helper(self) -> None:
        # Flow helpers require full entity_id — bare IDs cannot be safely
        # completed because helper_type often differs from entity domain
        # (e.g. utility_meter → sensor.*, switch_as_x → switch/light.*).
        client = _make_client()
        entry_id, reason = await _get_entry_id_for_flow_helper(
            client, "template", "my_sensor"
        )
        assert entry_id is None
        assert reason == "bare_id_not_supported"
        client.send_websocket_message.assert_not_awaited()

    async def test_returns_none_for_unknown_helper_type(self) -> None:
        client = _make_client()
        entry_id, reason = await _get_entry_id_for_flow_helper(
            client,
            "input_button",
            "my_button",  # SIMPLE, not FLOW
        )
        assert entry_id is None
        assert reason == "wrong_helper_type"
        client.send_websocket_message.assert_not_awaited()

    async def test_returns_none_when_entity_not_in_registry(self) -> None:
        client = _make_client({"success": False, "error": "not_found"})
        entry_id, reason = await _get_entry_id_for_flow_helper(
            client, "template", "template.ghost"
        )
        assert entry_id is None
        assert reason == "not_in_registry"

    async def test_returns_none_when_entity_has_no_config_entry_id(self) -> None:
        # YAML-defined helper: entity exists but no config_entry_id
        client = _make_client({"success": True, "result": {"entity_id": "template.x"}})
        entry_id, reason = await _get_entry_id_for_flow_helper(
            client, "template", "template.x"
        )
        assert entry_id is None
        assert reason == "no_config_entry"

    async def test_websocket_exception_appends_to_warnings(self) -> None:
        client = _make_client(raises=ConnectionError("ws drop"))
        warnings: list[str] = []
        entry_id, reason = await _get_entry_id_for_flow_helper(
            client, "utility_meter", "sensor.x", warnings=warnings
        )
        assert entry_id is None
        assert reason == "lookup_failed"
        assert len(warnings) == 1
        assert "entity_registry/get failed" in warnings[0]
        assert "sensor.x" in warnings[0]

    async def test_websocket_exception_without_warnings_is_silent(self) -> None:
        client = _make_client(raises=ConnectionError("ws drop"))
        entry_id, reason = await _get_entry_id_for_flow_helper(
            client, "utility_meter", "sensor.x", warnings=None
        )
        assert entry_id is None
        assert reason == "lookup_failed"

    async def test_unexpected_result_shape_returns_none(self) -> None:
        # success but result is not a dict
        client = _make_client({"success": True, "result": "garbage"})
        entry_id, reason = await _get_entry_id_for_flow_helper(
            client, "template", "template.x"
        )
        assert entry_id is None
        assert reason == "not_in_registry"

    async def test_connection_error_propagates(self) -> None:
        # Auth/connection errors must reach the outer handler — they are
        # not "lookup_failed", they are infrastructure failures.
        client = _make_client(raises=HomeAssistantConnectionError("network down"))
        with pytest.raises(HomeAssistantConnectionError):
            await _get_entry_id_for_flow_helper(client, "utility_meter", "sensor.x")

    async def test_auth_error_propagates(self) -> None:
        client = _make_client(raises=HomeAssistantAuthError("token expired"))
        with pytest.raises(HomeAssistantAuthError):
            await _get_entry_id_for_flow_helper(client, "utility_meter", "sensor.x")


class TestRemoveHelpersIntegrations:
    """Unit tests for ha_remove_helpers_integrations.

    Covers all three routing paths (SIMPLE / FLOW / DIRECT) plus the
    confirm gate and wait flag.
    """

    @pytest.fixture
    def mock_client(self):
        """Mock Home Assistant client with all methods used by the tool."""
        client = MagicMock()
        client.get_entity_state = AsyncMock(return_value={"state": "on"})
        client.send_websocket_message = AsyncMock()
        client.delete_config_entry = AsyncMock(return_value={"require_restart": False})
        return client

    @pytest.fixture
    def tools(self, mock_client):
        return IntegrationTools(mock_client)

    # === Confirm gate ===

    async def test_confirm_false_raises_validation_error(self, tools):
        """confirm=False (default) blocks all three paths."""
        with pytest.raises(ToolError) as exc_info:
            await tools.ha_remove_helpers_integrations(
                target="entry_xyz",
                # helper_type defaults to None → DIRECT path
                # confirm defaults to False
            )
        err = json.loads(str(exc_info.value))
        assert err["success"] is False
        assert err["error"]["code"] == "VALIDATION_INVALID_PARAMETER"
        assert "confirm" in err["error"]["message"].lower()

    # === Path 3: DIRECT ===

    async def test_direct_path_happy(self, tools, mock_client):
        """helper_type=None + entry_id → direct delete."""
        mock_client.delete_config_entry.return_value = {"require_restart": False}
        result = await tools.ha_remove_helpers_integrations(
            target="01HXYZ_entry_id",
            confirm=True,
        )
        assert result["success"] is True
        assert result["method"] == "config_entry_delete"
        assert result["helper_type"] == "config_entry"
        assert result["entry_id"] == "01HXYZ_entry_id"
        assert result["entity_ids"] == []
        assert result["require_restart"] is False
        mock_client.delete_config_entry.assert_awaited_once_with("01HXYZ_entry_id")

    async def test_direct_path_require_restart(self, tools, mock_client):
        """require_restart=True is propagated."""
        mock_client.delete_config_entry.return_value = {"require_restart": True}
        result = await tools.ha_remove_helpers_integrations(
            target="01HXYZ_entry_id",
            confirm=True,
        )
        assert result["require_restart"] is True
        assert "restart required" in result["message"].lower()

    async def test_direct_path_entry_not_found_raises(self, tools, mock_client):
        """Path 3: HA REST 404 on delete_config_entry → raises
        RESOURCE_NOT_FOUND so a typo'd entry_id surfaces instead of being
        silently masked as success.

        Realistic backend behavior: RestClient.delete_config_entry raises
        HomeAssistantAPIError(status_code=404) when the entry is missing.
        Non-404 API errors still surface via exception_to_structured_error.
        """
        mock_client.delete_config_entry.side_effect = HomeAssistantAPIError(
            "Config entry not found", status_code=404
        )
        with pytest.raises(ToolError) as exc_info:
            await tools.ha_remove_helpers_integrations(
                target="ghost_entry",
                confirm=True,
            )
        err = json.loads(str(exc_info.value))
        assert err["success"] is False
        assert err["error"]["code"] == "RESOURCE_NOT_FOUND"
        assert "ghost_entry" in err["error"]["message"]
        # Pin the diagnostic hint so the caller-facing wording can't
        # silently degrade — both the "may indicate" framing and the
        # ha_get_integration tool reference were part of the review-cycle
        # outcome and removing either would regress UX without
        # otherwise failing this test.
        assert "May indicate" in err["error"]["message"]
        assert "ha_get_integration" in err["error"]["message"]
        assert "already_deleted" not in json.dumps(err)

    async def test_direct_path_non_404_apierror_surfaces_structured_error(
        self, tools, mock_client
    ):
        """Pin the Path 3 narrow: only status_code == 404 routes to the
        RESOURCE_NOT_FOUND raise. Non-404 API errors (500 server, 401
        auth, …) must propagate via exception_to_structured_error and
        surface as a different structured error code, not silently
        classified as a missing target. Mirrors the Path 1 narrow in
        test_simple_path_non_404_apierror_propagates.
        """
        mock_client.delete_config_entry.side_effect = HomeAssistantAPIError(
            "Internal server error", status_code=500
        )
        with pytest.raises(ToolError) as exc_info:
            await tools.ha_remove_helpers_integrations(
                target="some_entry",
                confirm=True,
            )
        err = json.loads(str(exc_info.value))
        assert err["success"] is False
        assert "already_deleted" not in json.dumps(err)
        assert err["error"]["code"] not in ("ENTITY_NOT_FOUND", "RESOURCE_NOT_FOUND")

    # === Path 1: SIMPLE ===

    async def test_simple_path_standard_via_unique_id(self, tools, mock_client):
        """Registry returns unique_id → standard delete path."""
        # First call: registry/get → success, unique_id present
        # Second call: <type>/delete → success
        mock_client.send_websocket_message.side_effect = [
            {"success": True, "result": {"unique_id": "uid-123"}},
            {"success": True},
        ]
        # State check returns truthy → no retry needed
        mock_client.get_entity_state.return_value = {"state": "off"}

        result = await tools.ha_remove_helpers_integrations(
            target="my_button",
            helper_type="input_button",
            confirm=True,
            wait=False,  # skip wait_for_entity_removed
        )
        assert result["success"] is True
        assert result["method"] == "websocket_delete"
        assert result["unique_id"] == "uid-123"
        assert result["entity_ids"] == ["input_button.my_button"]
        # Verify the delete WS message used unique_id
        delete_call = mock_client.send_websocket_message.call_args_list[1]
        assert delete_call[0][0]["input_button_id"] == "uid-123"

    @pytest.mark.parametrize(
        "helper_type",
        [
            "input_button",
            "input_boolean",
            "input_number",
            "input_select",
            "input_text",
            "input_datetime",
        ],
    )
    async def test_simple_path_disabled_entity_resolves_via_registry(
        self, tools, mock_client, helper_type
    ):
        """Issue #1057 regression: a disabled entity (registered but absent
        from the state machine) must be resolved via the entity registry
        and deleted via the standard websocket_delete path — not via the
        direct_id fallback (which also reports method=websocket_delete) and
        not via the already_deleted short-circuit.
        """
        mock_client.get_entity_state.return_value = None
        mock_client.send_websocket_message.side_effect = [
            {"success": True, "result": {"unique_id": "uid-disabled-456"}},
            {"success": True},
        ]
        target = f"my_disabled_{helper_type}_target"

        result = await tools.ha_remove_helpers_integrations(
            target=target,
            helper_type=helper_type,
            confirm=True,
            wait=False,
        )
        assert result["success"] is True
        assert result["method"] == "websocket_delete"
        # Distinguishes from direct_id (no unique_id key) and already_deleted
        # (no unique_id, fallback_used set) — together these pin the standard
        # registry-driven path specifically.
        assert "unique_id" in result, (
            f"Standard path not taken (no unique_id in response): {result}"
        )
        assert result["unique_id"] == "uid-disabled-456"
        assert result.get("fallback_used") is None, (
            f"Expected standard websocket_delete path; got fallback: {result}"
        )
        registry_call = mock_client.send_websocket_message.call_args_list[0]
        assert registry_call[0][0]["type"] == "config/entity_registry/get"
        assert registry_call[0][0]["entity_id"] == f"{helper_type}.{target}"

    async def test_simple_path_fallback_direct_id(self, tools, mock_client):
        """Registry has no unique_id → direct_id fallback succeeds."""
        # 3 retries all return "no unique_id", then direct delete succeeds
        mock_client.send_websocket_message.side_effect = (
            [{"success": True, "result": {}}] * 3  # registry returns no uid
            + [{"success": True}]  # direct delete succeeds
        )
        result = await tools.ha_remove_helpers_integrations(
            target="my_button",
            helper_type="input_button",
            confirm=True,
            wait=False,
        )
        assert result["success"] is True
        assert result["fallback_used"] == "direct_id"
        # Direct-id delete used helper_id (bare), not unique_id
        delete_call = mock_client.send_websocket_message.call_args_list[-1]
        assert delete_call[0][0]["input_button_id"] == "my_button"

    async def test_simple_path_state_gone_raises_entity_not_found(
        self, tools, mock_client
    ):
        """Registry empty + direct delete fails + state=None + registry-verify
        confirms gone → raises ENTITY_NOT_FOUND (entity-shape target)."""
        # 3x registry no unique_id, 1x direct delete fails, 1x verify-registry
        # confirms entity is truly gone (success=False)
        mock_client.send_websocket_message.side_effect = (
            [{"success": True, "result": {}}] * 3
            + [{"success": False, "error": "not found"}]
            + [{"success": False, "error": "not_found"}]
        )
        # State check at the end returns None → entity gone from state machine
        mock_client.get_entity_state.side_effect = (
            [{"state": "off"}] * 3  # during retries
            + [None]  # final check after direct-delete fail
        )

        with pytest.raises(ToolError) as exc_info:
            await tools.ha_remove_helpers_integrations(
                target="my_button",
                helper_type="input_button",
                confirm=True,
                wait=False,
            )
        err = json.loads(str(exc_info.value))
        assert err["success"] is False
        assert err["error"]["code"] == "ENTITY_NOT_FOUND"
        assert "my_button" in err["error"]["message"]
        # Pin the diagnostic-hint wording (same rationale as Path 3).
        assert "May indicate" in err["error"]["message"]
        assert "ha_search" in err["error"]["message"]
        assert "already_deleted" not in json.dumps(err)

    async def test_simple_path_404_on_state_check_raises_entity_not_found(
        self, tools, mock_client
    ):
        """Pin the Path 1 fallback-2 contract for never-existed targets:
        get_entity_state raising HomeAssistantAPIError(status_code=404)
        must classify as state_gone → registry-verify confirms absent →
        raises ENTITY_NOT_FOUND. The 404 catch routes the never-existed
        case (typo) through the same confirmed-absent path so the raise
        carries the structured "typo or removed" hint message.
        """
        # 3x registry retries return success=False → no unique_id
        # 1x direct-id delete returns success=False → falls through to
        #   fallback-2
        # 1x verify-registry returns success=False → registry confirms absent
        mock_client.send_websocket_message.side_effect = (
            [{"success": False, "error": "Entity not found"}] * 3
            + [{"success": False, "error": "Unable to find input_button_id"}]
            + [{"success": False, "error": "Entity not found"}]
        )
        # State check raises 404 throughout — never-existed entity case
        mock_client.get_entity_state.side_effect = HomeAssistantAPIError(
            "Entity not found", status_code=404
        )

        with pytest.raises(ToolError) as exc_info:
            await tools.ha_remove_helpers_integrations(
                target="never_existed_button",
                helper_type="input_button",
                confirm=True,
                wait=False,
            )
        err = json.loads(str(exc_info.value))
        assert err["success"] is False
        assert err["error"]["code"] == "ENTITY_NOT_FOUND"
        assert "never_existed_button" in err["error"]["message"]
        # Same diagnostic hint as the state-gone branch — both sub-paths
        # of Path 1 confirmed-absent route to the same raise.
        assert "May indicate" in err["error"]["message"]
        assert "ha_search" in err["error"]["message"]
        assert "already_deleted" not in json.dumps(err)

    async def test_simple_path_non_404_apierror_propagates(self, tools, mock_client):
        """Pin the Path 1 narrow: only status_code == 404 classifies as
        state_gone (→ ENTITY_NOT_FOUND raise). Other APIError status
        codes (e.g. 500 server error, 401 auth failure) must propagate
        via the outer exception chain rather than mis-classify as a
        missing target.
        """
        # 3x registry retries return success=False → no unique_id
        # 1x direct-id delete returns success=False → falls through
        mock_client.send_websocket_message.side_effect = [
            {"success": False, "error": "Entity not found"}
        ] * 3 + [{"success": False, "error": "Unable to find"}]
        # State check raises a non-404 APIError — must propagate
        mock_client.get_entity_state.side_effect = HomeAssistantAPIError(
            "Internal server error", status_code=500
        )

        with pytest.raises(ToolError) as exc_info:
            await tools.ha_remove_helpers_integrations(
                target="server_error_button",
                helper_type="input_button",
                confirm=True,
                wait=False,
            )
        err = json.loads(str(exc_info.value))
        assert err["success"] is False
        # Outer chain converts the propagated APIError via
        # exception_to_structured_error — must surface as a structured
        # error, not as the ENTITY_NOT_FOUND missing-target branch (or
        # leak the old "already_deleted" marker that no longer exists).
        # Two independent assertions so a regression that re-routes the
        # propagated error into a missing-target code still fails.
        assert err["error"]["code"] != "ENTITY_NOT_FOUND"
        assert "already_deleted" not in json.dumps(err)

    async def test_simple_path_disabled_no_unique_id_surfaces_error(
        self, tools, mock_client
    ):
        """Issue #1057 residual hazard: a disabled entity that is registry-
        resident but missing unique_id (and direct-id delete fails) must NOT
        be silently classified as already_deleted. The previous fallback
        relied on state-absence alone, which is exactly the symptom of a
        disabled entity — masking the bug. Post-fix: registry-verify confirms
        the entry is still there and we surface SERVICE_CALL_FAILED instead.
        """
        # 3x registry returns entry but no unique_id, 1x direct delete fails,
        # 1x verify-registry shows entity STILL registered
        mock_client.send_websocket_message.side_effect = (
            [{"success": True, "result": {"entity_id": "input_button.my_button"}}] * 3
            + [{"success": False, "error": "not found"}]
            + [
                {
                    "success": True,
                    "result": {"entity_id": "input_button.my_button"},
                }
            ]
        )
        # Disabled entity: state-absent throughout
        mock_client.get_entity_state.return_value = None

        with pytest.raises(ToolError) as exc_info:
            await tools.ha_remove_helpers_integrations(
                target="my_button",
                helper_type="input_button",
                confirm=True,
                wait=False,
            )
        err = json.loads(str(exc_info.value))
        assert err["success"] is False
        assert err["error"]["code"] == "SERVICE_CALL_FAILED"
        assert "registry entry exists" in err["error"]["message"]

    async def test_simple_path_disabled_state_check_apierror_resolves_via_registry(
        self, tools, mock_client
    ):
        """The HomeAssistantAPIError branch in the state-check try/except
        must not derail registry resolution. A disabled entity often responds
        404 to the state-check; that's informational, the registry path
        still has to run.
        """
        mock_client.get_entity_state.side_effect = HomeAssistantAPIError(
            "404 simulated", status_code=404
        )
        mock_client.send_websocket_message.side_effect = [
            {"success": True, "result": {"unique_id": "uid-disabled-apierror"}},
            {"success": True},
        ]

        result = await tools.ha_remove_helpers_integrations(
            target="my_disabled_button",
            helper_type="input_button",
            confirm=True,
            wait=False,
        )
        assert result["success"] is True
        assert result["method"] == "websocket_delete"
        assert result.get("fallback_used") is None
        assert result["unique_id"] == "uid-disabled-apierror"

    async def test_simple_path_all_fallbacks_exhausted(self, tools, mock_client):
        """Registry empty + direct fails + state still present → ENTITY_NOT_FOUND."""
        mock_client.send_websocket_message.side_effect = [
            {"success": False, "error": "no entity"}
        ] * 3 + [{"success": False, "error": "still no"}]
        # State check ALWAYS returns a state → no fallback path catches it
        mock_client.get_entity_state.return_value = {"state": "off"}

        with pytest.raises(ToolError) as exc_info:
            await tools.ha_remove_helpers_integrations(
                target="ghost_button",
                helper_type="input_button",
                confirm=True,
                wait=False,
            )
        err = json.loads(str(exc_info.value))
        assert err["error"]["code"] == "ENTITY_NOT_FOUND"

    async def test_simple_path_ws_delete_fails(self, tools, mock_client):
        """unique_id found, but {type}/delete returns success=False
        → SERVICE_CALL_FAILED."""
        mock_client.send_websocket_message.side_effect = [
            {"success": True, "result": {"unique_id": "uid-fail"}},
            {"success": False, "error": "in use by automation"},
        ]
        mock_client.get_entity_state.return_value = {"state": "off"}

        with pytest.raises(ToolError) as exc_info:
            await tools.ha_remove_helpers_integrations(
                target="locked_button",
                helper_type="input_button",
                confirm=True,
                wait=False,
            )
        err = json.loads(str(exc_info.value))
        assert err["error"]["code"] == "SERVICE_CALL_FAILED"
        assert "in use by automation" in err["error"]["message"]

    # === Path 2: FLOW ===

    async def test_flow_path_happy_single_subentity(self, tools, mock_client):
        """FLOW helper resolves entity_id → entry_id → delete + wait."""
        # Sequence of WS calls in order:
        # 1. _get_entry_id_for_flow_helper → registry/get → has config_entry_id
        # 2. _get_entities_for_config_entry → registry/list → 1 entity
        # 3. delete_config_entry (not WS, separate mock)
        # Then wait_for_entity_removed → state poll, returns None (gone)
        mock_client.send_websocket_message.side_effect = [
            # registry/get (lookup)
            {
                "success": True,
                "result": {"config_entry_id": "entry_abc"},
            },
            # registry/list (sub-entities)
            {
                "success": True,
                "result": [
                    {
                        "entity_id": "sensor.my_template",
                        "config_entry_id": "entry_abc",
                    },
                    # noise: another entity not in this entry
                    {
                        "entity_id": "sensor.other",
                        "config_entry_id": "entry_other",
                    },
                ],
            },
        ]
        mock_client.delete_config_entry.return_value = {"require_restart": False}

        result = await tools.ha_remove_helpers_integrations(
            target="sensor.my_template",
            helper_type="template",
            confirm=True,
            wait=False,  # skip wait phase, focus on delete logic
        )
        assert result["success"] is True
        assert result["method"] == "config_flow_delete"
        assert result["entry_id"] == "entry_abc"
        assert result["entity_ids"] == ["sensor.my_template"]
        mock_client.delete_config_entry.assert_awaited_once_with("entry_abc")

    async def test_flow_path_multi_subentity_utility_meter(self, tools, mock_client):
        """utility_meter pattern: multiple sub-entities share one entry_id."""
        mock_client.send_websocket_message.side_effect = [
            # lookup for sensor.energy_peak
            {
                "success": True,
                "result": {"config_entry_id": "um_entry"},
            },
            # registry/list — three sub-entities for um_entry
            {
                "success": True,
                "result": [
                    {
                        "entity_id": "sensor.energy_peak",
                        "config_entry_id": "um_entry",
                    },
                    {
                        "entity_id": "sensor.energy_offpeak",
                        "config_entry_id": "um_entry",
                    },
                    {
                        "entity_id": "select.energy_tariff",
                        "config_entry_id": "um_entry",
                    },
                ],
            },
        ]
        result = await tools.ha_remove_helpers_integrations(
            target="sensor.energy_peak",
            helper_type="utility_meter",
            confirm=True,
            wait=False,
        )
        assert result["success"] is True
        assert set(result["entity_ids"]) == {
            "sensor.energy_peak",
            "sensor.energy_offpeak",
            "select.energy_tariff",
        }
        assert result["entry_id"] == "um_entry"

    async def test_flow_path_entity_not_in_registry_raises(self, tools, mock_client):
        """Path 2 not_in_registry: target FLOW entity_id confirmed absent
        from the entity registry → raises ENTITY_NOT_FOUND (entity-shape
        target). Matches the existing bare_id_not_supported branch and
        sibling ha_remove_entity.
        """
        # First lookup returns success=False → entry_id resolves to None
        # Disambiguation re-query also returns success=False → reason
        # discriminates as "not_in_registry"
        mock_client.send_websocket_message.side_effect = [
            {"success": False, "error": "not found"},  # initial lookup
            {"success": False, "error": "not found"},  # disambiguation
        ]
        with pytest.raises(ToolError) as exc_info:
            await tools.ha_remove_helpers_integrations(
                target="template.ghost",
                helper_type="template",
                confirm=True,
            )
        err = json.loads(str(exc_info.value))
        assert err["success"] is False
        assert err["error"]["code"] == "ENTITY_NOT_FOUND"
        assert "template.ghost" in err["error"]["message"]
        # Pin the diagnostic-hint wording (same rationale as Path 1).
        assert "May indicate" in err["error"]["message"]
        assert "ha_search" in err["error"]["message"]
        assert "already_deleted" not in json.dumps(err)

    async def test_flow_path_lookup_failed_maps_to_websocket_disconnected(
        self, tools, mock_client
    ):
        """FLOW: registry lookup raises a WebSocket exception →
        WEBSOCKET_DISCONNECTED, not ENTITY_NOT_FOUND (R8 in KP13 review #1056).

        The lookup helper appends to warnings and returns reason='lookup_failed';
        the caller must surface that as a transient connectivity error so the
        user retries instead of chasing a non-existent entity_id.
        """
        # Initial lookup raises a non-typed WS exception (anything that
        # isn't HomeAssistantConnectionError/HomeAssistantAuthError, since
        # those propagate by design). The lookup helper catches it and
        # returns (None, "lookup_failed").
        mock_client.send_websocket_message.side_effect = ConnectionError(
            "websocket dropped mid-call"
        )
        with pytest.raises(ToolError) as exc_info:
            await tools.ha_remove_helpers_integrations(
                target="sensor.energy_meter",
                helper_type="utility_meter",
                confirm=True,
            )
        err = json.loads(str(exc_info.value))
        assert err["error"]["code"] == "WEBSOCKET_DISCONNECTED"
        assert "sensor.energy_meter" in err["error"]["message"]

    async def test_flow_path_yaml_helper_no_config_entry(self, tools, mock_client):
        """FLOW: entity exists but config_entry_id is None (YAML) →
        RESOURCE_NOT_FOUND."""
        mock_client.send_websocket_message.side_effect = [
            # initial lookup: success but no config_entry_id
            {
                "success": True,
                "result": {"config_entry_id": None},
            },
            # disambiguation: confirms entity is in registry
            {
                "success": True,
                "result": {"config_entry_id": None},
            },
        ]
        with pytest.raises(ToolError) as exc_info:
            await tools.ha_remove_helpers_integrations(
                target="template.yaml_template",
                helper_type="template",
                confirm=True,
            )
        err = json.loads(str(exc_info.value))
        assert err["error"]["code"] == "RESOURCE_NOT_FOUND"
        assert "storage-based" in err["error"]["message"]

    async def test_flow_path_entry_not_found_at_delete_raises(self, tools, mock_client):
        """Path 2 TOCTOU: entry_id resolved at step 1 but already deleted
        before step 3 reaches HA → raises RESOURCE_NOT_FOUND. Silent
        success would hide the race from a wrapper that acted on the
        intermediate state.
        """
        mock_client.send_websocket_message.side_effect = [
            {
                "success": True,
                "result": {"config_entry_id": "stale_entry"},
            },
            {"success": True, "result": []},  # empty registry/list
        ]
        mock_client.delete_config_entry.side_effect = HomeAssistantAPIError(
            "entry not found", status_code=404
        )
        with pytest.raises(ToolError) as exc_info:
            await tools.ha_remove_helpers_integrations(
                target="sensor.stale",
                helper_type="template",
                confirm=True,
            )
        err = json.loads(str(exc_info.value))
        assert err["success"] is False
        assert err["error"]["code"] == "RESOURCE_NOT_FOUND"
        assert "stale_entry" in err["error"]["message"]
        # Pin the TOCTOU-specific diagnostic hint ("concurrent removal"
        # framing) — distinct from the typo-framed hint on other paths
        # because the entry WAS resolvable at step 1 and only vanished
        # before step 3 reached HA.
        assert "concurrent removal" in err["error"]["message"]
        assert "already_deleted" not in json.dumps(err)

    async def test_flow_path_require_restart_propagated(self, tools, mock_client):
        """FLOW: delete_config_entry response require_restart=True is
        propagated in the tool response."""
        mock_client.send_websocket_message.side_effect = [
            {
                "success": True,
                "result": {"config_entry_id": "entry_restart"},
            },
            {
                "success": True,
                "result": [
                    {
                        "entity_id": "sensor.needs_restart",
                        "config_entry_id": "entry_restart",
                    },
                ],
            },
        ]
        mock_client.delete_config_entry.return_value = {"require_restart": True}
        result = await tools.ha_remove_helpers_integrations(
            target="sensor.needs_restart",
            helper_type="template",
            confirm=True,
            wait=False,
        )
        assert result["require_restart"] is True

    # === R7: wait=True coverage (KP13 review #1056) ===

    async def test_simple_path_wait_true_happy(self, tools, mock_client):
        """SIMPLE standard wait=True: wait_for_entity_removed returns True
        → no warning field in response."""
        mock_client.send_websocket_message.side_effect = [
            {"success": True, "result": {"unique_id": "uid-w1"}},
            {"success": True},
        ]
        mock_client.get_entity_state.return_value = {"state": "off"}
        with patch(
            "ha_mcp.tools.tools_integrations.wait_for_entity_removed",
            new_callable=AsyncMock,
        ) as mock_wait:
            mock_wait.return_value = True
            result = await tools.ha_remove_helpers_integrations(
                target="my_button",
                helper_type="input_button",
                confirm=True,
                wait=True,
            )
        assert result["success"] is True
        assert not result.get("warnings"), (
            f"Unexpected warnings: {result.get('warnings')}"
        )
        mock_wait.assert_awaited_once()

    async def test_simple_path_wait_true_timeout_warns(self, tools, mock_client):
        """SIMPLE standard wait=True: wait_for_entity_removed returns False
        (timeout) → warning field set, success still True."""
        mock_client.send_websocket_message.side_effect = [
            {"success": True, "result": {"unique_id": "uid-w2"}},
            {"success": True},
        ]
        mock_client.get_entity_state.return_value = {"state": "off"}
        with patch(
            "ha_mcp.tools.tools_integrations.wait_for_entity_removed",
            new_callable=AsyncMock,
        ) as mock_wait:
            mock_wait.return_value = False  # timeout
            result = await tools.ha_remove_helpers_integrations(
                target="my_button",
                helper_type="input_button",
                confirm=True,
                wait=True,
            )
        assert result["success"] is True
        assert result.get("warnings"), f"Expected warnings list, got: {result}"
        assert any("still present" in w for w in result["warnings"])

    async def test_simple_path_wait_true_propagates_connection_error(
        self, tools, mock_client
    ):
        """SIMPLE standard wait=True: HomeAssistantConnectionError from
        wait_for_entity_removed must propagate as ToolError, not be
        masked as a warning (R2 in KP13 review #1056)."""
        mock_client.send_websocket_message.side_effect = [
            {"success": True, "result": {"unique_id": "uid-w3"}},
            {"success": True},
        ]
        mock_client.get_entity_state.return_value = {"state": "off"}
        with patch(
            "ha_mcp.tools.tools_integrations.wait_for_entity_removed",
            new_callable=AsyncMock,
        ) as mock_wait:
            mock_wait.side_effect = HomeAssistantConnectionError(
                "network down during poll"
            )
            with pytest.raises(ToolError):
                await tools.ha_remove_helpers_integrations(
                    target="my_button",
                    helper_type="input_button",
                    confirm=True,
                    wait=True,
                )

    async def test_simple_path_registry_lookup_connection_error_propagates(
        self, tools, mock_client
    ):
        """SIMPLE: HomeAssistantConnectionError inside the 3-retry registry
        lookup loop (line 968) must escape the bare-except (R8 fix) and reach
        the outer exception_to_structured_error, surfacing as
        CONNECTION_FAILED rather than being swallowed and re-reported as
        ENTITY_NOT_FOUND (R8 in KP13 review #1056).

        Pattern mirrors the :938 state-check fix from R1 — auth/connection
        errors must escape the retry loop without conversion to NOT_FOUND.
        """
        # State check returns nothing (entity not in state) → falls through
        # to registry lookup, which raises a connection error.
        mock_client.get_entity_state.return_value = None
        mock_client.send_websocket_message.side_effect = HomeAssistantConnectionError(
            "websocket disconnected during lookup"
        )
        with pytest.raises(ToolError) as exc_info:
            await tools.ha_remove_helpers_integrations(
                target="my_button",
                helper_type="input_button",
                confirm=True,
            )
        err = json.loads(str(exc_info.value))
        assert err["error"]["code"] == "CONNECTION_FAILED"
        assert err["error"]["code"] != "ENTITY_NOT_FOUND"

    async def test_flow_path_wait_true_multi_subentity_partial_timeout(
        self, tools, mock_client
    ):
        """FLOW utility_meter wait=True: gather returns mixed True/False —
        the False entity_ids land in the warning, success still True
        (KP13 review #1056: highest-value test case)."""
        mock_client.send_websocket_message.side_effect = [
            {
                "success": True,
                "result": {"config_entry_id": "entry_um"},
            },
            {
                "success": True,
                "result": [
                    {
                        "entity_id": "sensor.energy_peak",
                        "config_entry_id": "entry_um",
                    },
                    {
                        "entity_id": "sensor.energy_offpeak",
                        "config_entry_id": "entry_um",
                    },
                ],
            },
        ]
        mock_client.delete_config_entry.return_value = {"require_restart": False}
        with patch(
            "ha_mcp.tools.tools_integrations.wait_for_entity_removed",
            new_callable=AsyncMock,
        ) as mock_wait:
            # First entity removed cleanly, second times out
            mock_wait.side_effect = [True, False]
            result = await tools.ha_remove_helpers_integrations(
                target="sensor.energy_peak",
                helper_type="utility_meter",
                confirm=True,
                wait=True,
            )
        assert result["success"] is True
        assert result.get("warnings"), f"Expected warnings list, got: {result}"
        warnings_text = " ".join(result["warnings"])
        assert "sensor.energy_offpeak" in warnings_text
        assert "sensor.energy_peak" not in warnings_text
        assert mock_wait.await_count == 2


class TestGetIntegrationDiagnostics:
    """Wire-up tests for ha_get_integration's include_diagnostics/device_id branch."""

    @pytest.mark.asyncio
    async def test_include_diagnostics_attaches_helper_result(self) -> None:
        client = MagicMock()
        tools = IntegrationTools(client)
        diag_payload = {
            "config_entry_id": "entry_abc",
            "data": {"home_assistant": {"version": "2026.5.0"}},
        }
        with (
            patch.object(
                IntegrationTools,
                "_get_single_entry",
                new=AsyncMock(return_value={"success": True, "entry_id": "entry_abc"}),
            ),
            patch(
                "ha_mcp.tools.tools_integrations.fetch_integration_diagnostics",
                new=AsyncMock(return_value=diag_payload),
            ) as mock_fetch,
        ):
            result = await tools.ha_get_integration(
                entry_id="entry_abc", include_diagnostics=True
            )
        assert result["diagnostics"] == diag_payload
        mock_fetch.assert_awaited_once_with(
            client,
            "entry_abc",
            None,
            fields=None,
            truncate_at_bytes=None,
            data_path=None,
            data_offset=0,
            data_limit=None,
        )
        assert "warnings" not in result

    @pytest.mark.asyncio
    async def test_device_id_forwarded_to_helper(self) -> None:
        client = MagicMock()
        tools = IntegrationTools(client)
        with (
            patch.object(
                IntegrationTools,
                "_get_single_entry",
                new=AsyncMock(return_value={"success": True, "entry_id": "entry_abc"}),
            ),
            patch(
                "ha_mcp.tools.tools_integrations.fetch_integration_diagnostics",
                new=AsyncMock(return_value={"data": {}}),
            ) as mock_fetch,
        ):
            await tools.ha_get_integration(
                entry_id="entry_abc",
                include_diagnostics=True,
                device_id="dev_xyz",
            )
        mock_fetch.assert_awaited_once_with(
            client,
            "entry_abc",
            "dev_xyz",
            fields=None,
            truncate_at_bytes=None,
            data_path=None,
            data_offset=0,
            data_limit=None,
        )

    @pytest.mark.asyncio
    async def test_device_id_without_include_surfaces_warning(self) -> None:
        client = MagicMock()
        tools = IntegrationTools(client)
        with (
            patch.object(
                IntegrationTools,
                "_get_single_entry",
                new=AsyncMock(return_value={"success": True, "entry_id": "entry_abc"}),
            ),
            patch(
                "ha_mcp.tools.tools_integrations.fetch_integration_diagnostics",
                new=AsyncMock(),
            ) as mock_fetch,
        ):
            result = await tools.ha_get_integration(
                entry_id="entry_abc",
                include_diagnostics=False,
                device_id="dev_xyz",
            )
        mock_fetch.assert_not_awaited()
        assert "diagnostics" not in result
        assert "warnings" in result
        assert any("device_id" in w and "ignored" in w for w in result["warnings"])

    @pytest.mark.asyncio
    async def test_include_diagnostics_false_omits_call(self) -> None:
        """The diagnostics helper must not run when the flag is off."""
        client = MagicMock()
        tools = IntegrationTools(client)
        with (
            patch.object(
                IntegrationTools,
                "_get_single_entry",
                new=AsyncMock(return_value={"success": True, "entry_id": "entry_abc"}),
            ),
            patch(
                "ha_mcp.tools.tools_integrations.fetch_integration_diagnostics",
                new=AsyncMock(),
            ) as mock_fetch,
        ):
            result = await tools.ha_get_integration(
                entry_id="entry_abc", include_diagnostics=False
            )
        mock_fetch.assert_not_awaited()
        assert "diagnostics" not in result
        assert "warnings" not in result

    @pytest.mark.asyncio
    async def test_list_mode_with_diagnostics_args_surfaces_warning(self) -> None:
        """include_diagnostics or device_id without entry_id (list mode) surfaces a warning."""
        client = MagicMock()
        tools = IntegrationTools(client)
        list_payload = {"entries": [], "count": 0}
        with (
            patch.object(
                IntegrationTools,
                "_list_entries",
                new=AsyncMock(return_value=list_payload),
            ),
            patch(
                "ha_mcp.tools.tools_integrations.fetch_integration_diagnostics",
                new=AsyncMock(),
            ) as mock_fetch,
        ):
            result = await tools.ha_get_integration(
                include_diagnostics=True, device_id="dev_xyz"
            )
        mock_fetch.assert_not_awaited()
        assert "warnings" in result
        assert any("entry_id was not set" in w for w in result["warnings"])

    @pytest.mark.asyncio
    async def test_diagnostics_fields_and_truncate_forwarded(self) -> None:
        """Tool surfaces ``diagnostics_fields`` + ``diagnostics_truncate_at_bytes``."""
        client = MagicMock()
        tools = IntegrationTools(client)
        with (
            patch.object(
                IntegrationTools,
                "_get_single_entry",
                new=AsyncMock(return_value={"success": True, "entry_id": "entry_abc"}),
            ),
            patch(
                "ha_mcp.tools.tools_integrations.fetch_integration_diagnostics",
                new=AsyncMock(return_value={"data": {}}),
            ) as mock_fetch,
        ):
            await tools.ha_get_integration(
                entry_id="entry_abc",
                include_diagnostics=True,
                diagnostics_fields='["home_assistant", "issues"]',
                diagnostics_truncate_at_bytes=15000,
            )
        mock_fetch.assert_awaited_once_with(
            client,
            "entry_abc",
            None,
            fields=["home_assistant", "issues"],
            truncate_at_bytes=15000,
            data_path=None,
            data_offset=0,
            data_limit=None,
        )

    @pytest.mark.asyncio
    async def test_diagnostics_data_path_and_pagination_forwarded(self) -> None:
        """Tool surfaces ``diagnostics_data_path`` + offset/limit on the
        single-entry path."""
        client = MagicMock()
        tools = IntegrationTools(client)
        with (
            patch.object(
                IntegrationTools,
                "_get_single_entry",
                new=AsyncMock(return_value={"success": True, "entry_id": "entry_abc"}),
            ),
            patch(
                "ha_mcp.tools.tools_integrations.fetch_integration_diagnostics",
                new=AsyncMock(return_value={"data": {}}),
            ) as mock_fetch,
        ):
            await tools.ha_get_integration(
                entry_id="entry_abc",
                include_diagnostics=True,
                diagnostics_data_path="data.devices",
                diagnostics_data_offset=5,
                diagnostics_data_limit=10,
            )
        mock_fetch.assert_awaited_once_with(
            client,
            "entry_abc",
            None,
            fields=None,
            truncate_at_bytes=None,
            data_path="data.devices",
            data_offset=5,
            data_limit=10,
        )

    @pytest.mark.asyncio
    async def test_list_mode_warns_on_data_path_too(self) -> None:
        """``diagnostics_data_path`` / ``diagnostics_data_limit`` in list mode
        are also caught by the orphan-args parity warning."""
        client = MagicMock()
        tools = IntegrationTools(client)
        with (
            patch.object(
                IntegrationTools,
                "_list_entries",
                new=AsyncMock(return_value={"entries": [], "count": 0}),
            ),
            patch(
                "ha_mcp.tools.tools_integrations.fetch_integration_diagnostics",
                new=AsyncMock(),
            ) as mock_fetch,
        ):
            result = await tools.ha_get_integration(
                diagnostics_data_path="data.devices",
                diagnostics_data_limit=10,
            )
        mock_fetch.assert_not_awaited()
        assert any("diagnostics_data_path" in w for w in result.get("warnings", []))

    @pytest.mark.asyncio
    async def test_list_mode_warns_on_diagnostics_fields_too(self) -> None:
        """Passing ``diagnostics_fields`` in list mode is also ignored, with warning."""
        client = MagicMock()
        tools = IntegrationTools(client)
        with (
            patch.object(
                IntegrationTools,
                "_list_entries",
                new=AsyncMock(return_value={"entries": [], "count": 0}),
            ),
            patch(
                "ha_mcp.tools.tools_integrations.fetch_integration_diagnostics",
                new=AsyncMock(),
            ) as mock_fetch,
        ):
            result = await tools.ha_get_integration(
                diagnostics_fields=["home_assistant"],
            )
        mock_fetch.assert_not_awaited()
        assert any("diagnostics_fields" in w for w in result.get("warnings", []))

    @pytest.mark.asyncio
    async def test_list_mode_warns_on_data_offset_alone(self) -> None:
        """Pure ``diagnostics_data_offset > 0`` (no other diagnostics args)
        in list mode triggers the orphan-args warning — guards the
        ``data_offset_int > 0`` term in the predicate. A regression that
        drops the term would silently swallow the orphan offset."""
        client = MagicMock()
        tools = IntegrationTools(client)
        with (
            patch.object(
                IntegrationTools,
                "_list_entries",
                new=AsyncMock(return_value={"entries": [], "count": 0}),
            ),
            patch(
                "ha_mcp.tools.tools_integrations.fetch_integration_diagnostics",
                new=AsyncMock(),
            ) as mock_fetch,
        ):
            result = await tools.ha_get_integration(
                diagnostics_data_offset=5,
            )
        mock_fetch.assert_not_awaited()
        assert any("diagnostics_data_offset" in w for w in result.get("warnings", []))

    @pytest.mark.asyncio
    async def test_data_path_non_string_rejected_with_validation_error(self) -> None:
        """Non-string ``diagnostics_data_path`` (dict / list / int) surfaces
        as ``VALIDATION_INVALID_PARAMETER`` instead of leaking ``INTERNAL_ERROR``
        from the resolver's ``.strip()`` call downstream. Pins the
        ``isinstance(str)`` type-guard."""
        client = MagicMock()
        tools = IntegrationTools(client)
        with pytest.raises(ToolError) as excinfo:
            await tools.ha_get_integration(
                entry_id="entry_abc",
                include_diagnostics=True,
                diagnostics_data_path={"not": "a string"},  # type: ignore[arg-type]
            )
        err_payload = json.loads(str(excinfo.value))
        assert err_payload["error"]["code"] == "VALIDATION_INVALID_PARAMETER"
        assert "diagnostics_data_path" in err_payload["error"]["message"]


class TestOptionsFromFormFlow:
    """``options_from_form_flow`` field extraction (issues #1457, #1575)."""

    def test_reads_default_when_present(self) -> None:
        flow = {
            "data_schema": [
                {"name": "max_value", "default": 42},
            ]
        }
        assert options_from_form_flow(flow) == {"max_value": 42}

    def test_falls_back_to_value_for_constant_fields(self) -> None:
        flow = {"data_schema": [{"name": "fixed", "value": "constant"}]}
        assert options_from_form_flow(flow) == {"fixed": "constant"}

    def test_falls_back_to_description_suggested_value_for_template(self) -> None:
        # UI-created template helpers stash the current template body under
        # description.suggested_value — not as a top-level field key.
        # Before issue #1457 the extractor skipped this path, so options
        # came back empty for template/group/utility_meter/derivative/...
        flow = {
            "data_schema": [
                {
                    "name": "state",
                    "selector": {"template": {}},
                    "description": {
                        "suggested_value": "{{ states('sensor.x') | float }}",
                    },
                    "required": True,
                },
                {
                    "name": "device_class",
                    "selector": {"select": {"options": ["temperature"]}},
                    "description": {"suggested_value": "temperature"},
                },
            ]
        }
        assert options_from_form_flow(flow) == {
            "state": "{{ states('sensor.x') | float }}",
            "device_class": "temperature",
        }

    def test_suggested_value_wins_over_default(self) -> None:
        # Issue #1575: a field can carry the static schema default AND the
        # entry's current value at the same time. HA injects the persisted
        # option as description.suggested_value (the value the UI renders);
        # the top-level default is just what a brand-new form would show.
        # Real shape from a group helper created with hide_members=True.
        flow = {
            "data_schema": [
                {
                    "name": "hide_members",
                    "selector": {"boolean": {}},
                    "required": True,
                    "default": False,
                    "description": {"suggested_value": True},
                }
            ]
        }
        assert options_from_form_flow(flow) == {"hide_members": True}

    def test_falsy_suggested_value_wins_over_default(self) -> None:
        # A stored False must beat a True schema default — guards against
        # truthiness checks sneaking in where only None means "absent".
        flow = {
            "data_schema": [
                {
                    "name": "field",
                    "default": True,
                    "description": {"suggested_value": False},
                }
            ]
        }
        assert options_from_form_flow(flow) == {"field": False}

    def test_none_suggested_value_falls_back_to_default(self) -> None:
        flow = {
            "data_schema": [
                {
                    "name": "field",
                    "default": "fallback",
                    "description": {"suggested_value": None},
                }
            ]
        }
        assert options_from_form_flow(flow) == {"field": "fallback"}

    def test_suggested_value_wins_over_constant_value(self) -> None:
        # Pin the remaining precedence pair: a constant-type field's `value`
        # loses to a non-None suggested_value (and is only reached when the
        # suggested value is absent/None and no default exists).
        flow = {
            "data_schema": [
                {
                    "name": "field",
                    "value": "constant",
                    "description": {"suggested_value": "stored"},
                }
            ]
        }
        assert options_from_form_flow(flow) == {"field": "stored"}

    def test_skips_fields_with_no_value_anywhere(self) -> None:
        flow = {
            "data_schema": [
                {"name": "optional_blank"},
                {"name": "explicit_none", "default": None},
            ]
        }
        assert options_from_form_flow(flow) == {}

    def test_static_alias_on_class_calls_module_helper(self) -> None:
        # Backwards-compatibility: callers still using the @staticmethod
        # alias must get the same answer as the module-level helper.
        flow = {
            "data_schema": [
                {
                    "name": "state",
                    "description": {"suggested_value": "{{ 1 + 1 }}"},
                }
            ]
        }
        assert (
            IntegrationTools._options_from_form_flow(flow)
            == options_from_form_flow(flow)
            == {"state": "{{ 1 + 1 }}"}
        )

    def test_malformed_data_schema_not_list_returns_empty(self) -> None:
        # HA should never return a non-list data_schema, but a malformed
        # response must degrade to {} rather than iterating a string
        # character-by-character (which would AttributeError on str.get).
        assert options_from_form_flow({"data_schema": "not-a-list"}) == {}

    def test_malformed_field_not_dict_is_skipped(self) -> None:
        # A non-dict entry inside data_schema is skipped, not crashed on.
        flow = {"data_schema": ["garbage", {"name": "state", "default": "kept"}]}
        assert options_from_form_flow(flow) == {"state": "kept"}


@pytest.mark.asyncio
class TestFetchEntryOptionsWithStatus:
    """``fetch_entry_options_with_status`` distinguishes a probe failure from a
    genuinely-empty options form (both yield ``{}`` for options) so bulk
    callers (smart_search) can surface ``partial`` on a mid-search probe
    failure (PR #1529 R8)."""

    async def test_form_read_reports_ok_true(self) -> None:
        client = MagicMock()
        client.start_options_flow = AsyncMock(
            return_value={
                "flow_id": "f1",
                "type": "form",
                "data_schema": [
                    {"name": "state", "description": {"suggested_value": "{{ true }}"}}
                ],
            }
        )
        client.abort_options_flow = AsyncMock()

        options, ok = await fetch_entry_options_with_status(client, "entry_x")
        assert options == {"state": "{{ true }}"}
        assert ok is True

    async def test_empty_form_is_a_successful_read(self) -> None:
        # A form first-step with no harvestable fields is a genuine empty read,
        # NOT a failure — ok must be True so an empty options form doesn't
        # false-flag partial.
        client = MagicMock()
        client.start_options_flow = AsyncMock(
            return_value={"flow_id": "f2", "type": "form", "data_schema": []}
        )
        client.abort_options_flow = AsyncMock()

        options, ok = await fetch_entry_options_with_status(client, "entry_e")
        assert options == {}
        assert ok is True

    async def test_menu_first_step_reports_ok_false(self) -> None:
        # A non-form first step (menu) can't be harvested into options — the
        # config body is unread, so ok is False (a failure to read).
        client = MagicMock()
        client.start_options_flow = AsyncMock(
            return_value={"flow_id": "f3", "type": "menu", "menu_options": []}
        )
        client.abort_options_flow = AsyncMock()

        options, ok = await fetch_entry_options_with_status(client, "entry_m")
        assert options == {}
        assert ok is False
        client.abort_options_flow.assert_awaited_once_with("f3")

    async def test_start_raises_reports_ok_false(self) -> None:
        client = MagicMock()
        client.start_options_flow = AsyncMock(side_effect=RuntimeError("init blew up"))
        client.abort_options_flow = AsyncMock()

        options, ok = await fetch_entry_options_with_status(client, "entry_r")
        assert options == {}
        assert ok is False
        # start raised before a flow_id existed → abort skipped.
        client.abort_options_flow.assert_not_awaited()

    async def test_extraction_raises_after_start_reports_ok_false(self) -> None:
        # The flow starts (flow_id exists) but parsing blows up — ok is False
        # and the finally block still aborts so the flow doesn't sit half-open.
        client = MagicMock()
        client.start_options_flow = AsyncMock(
            return_value={"flow_id": "f4", "type": "form", "data_schema": []}
        )
        client.abort_options_flow = AsyncMock()

        with patch(
            "ha_mcp.tools.tools_integrations.options_from_form_flow",
            side_effect=RuntimeError("parse blew up"),
        ):
            options, ok = await fetch_entry_options_with_status(client, "entry_p")
        assert options == {}
        assert ok is False
        client.abort_options_flow.assert_awaited_once_with("f4")

    async def test_form_without_flow_id_reads_ok_and_skips_abort(self) -> None:
        # A form response missing flow_id is still a readable (empty) form —
        # ok is True — and with no flow_id the abort cleanup is skipped
        # without raising.
        client = MagicMock()
        client.start_options_flow = AsyncMock(return_value={"type": "form"})
        client.abort_options_flow = AsyncMock()

        options, ok = await fetch_entry_options_with_status(client, "entry_nf")
        assert options == {}
        assert ok is True
        client.abort_options_flow.assert_not_awaited()


@pytest.mark.asyncio
class TestOptionsProbeFailureWarnings:
    """``ha_get_integration`` surfaces options-probe failures as ``warnings``
    instead of passing ``{}`` off as the entry's real options (issue #1575
    follow-through: the second reported symptom was an indistinguishable
    ``options: {}`` on a probe hiccup)."""

    @staticmethod
    def _entry(entry_id: str) -> dict[str, Any]:
        return {
            "entry_id": entry_id,
            "domain": "group",
            "title": entry_id,
            "state": "loaded",
            "source": "user",
            "supports_options": True,
        }

    @staticmethod
    def _form_flow(flow_id: str) -> dict[str, Any]:
        return {
            "flow_id": flow_id,
            "type": "form",
            "data_schema": [
                {"name": "hide_members", "description": {"suggested_value": True}}
            ],
        }

    async def test_single_entry_probe_failure_attaches_warning(self) -> None:
        client = MagicMock()
        client.get_config_entry = AsyncMock(return_value=self._entry("entry_bad"))
        client.start_options_flow = AsyncMock(side_effect=RuntimeError("probe down"))
        client.abort_options_flow = AsyncMock()
        tools = IntegrationTools(client)

        with patch(
            "ha_mcp.tools.tools_integrations.get_logger_levels",
            new=AsyncMock(return_value={}),
        ):
            resp = await tools._get_single_entry(
                "entry_bad",
                None,
                include_subentries=False,
                include_subentry_schema=False,
                subentry_type=None,
                subentry_id=None,
                show_advanced_options=False,
            )
        assert resp["entry"]["options"] == {}
        assert resp.get("warnings"), f"expected a probe-failure warning: {resp}"
        assert "entry_bad" in resp["warnings"][0]

    async def test_single_entry_probe_success_has_no_warnings(self) -> None:
        client = MagicMock()
        client.get_config_entry = AsyncMock(return_value=self._entry("entry_good"))
        client.start_options_flow = AsyncMock(return_value=self._form_flow("f1"))
        client.abort_options_flow = AsyncMock()
        tools = IntegrationTools(client)

        with patch(
            "ha_mcp.tools.tools_integrations.get_logger_levels",
            new=AsyncMock(return_value={}),
        ):
            resp = await tools._get_single_entry(
                "entry_good",
                None,
                include_subentries=False,
                include_subentry_schema=False,
                subentry_type=None,
                subentry_id=None,
                show_advanced_options=False,
            )
        assert resp["entry"]["options"] == {"hide_members": True}
        assert "warnings" not in resp

    async def test_list_aggregates_probe_failures_into_one_warning(self) -> None:
        client = MagicMock()
        client._request = AsyncMock(
            return_value=[self._entry("entry_good"), self._entry("entry_bad")]
        )

        async def start_flow(entry_id: str) -> dict[str, Any]:
            if entry_id == "entry_bad":
                raise RuntimeError("probe down")
            return self._form_flow("f1")

        client.start_options_flow = AsyncMock(side_effect=start_flow)
        client.abort_options_flow = AsyncMock()
        tools = IntegrationTools(client)

        with patch(
            "ha_mcp.tools.tools_integrations.get_logger_levels",
            new=AsyncMock(return_value={}),
        ):
            resp = await tools._list_entries(None, None, True, None, 50, 0)

        by_id = {e["entry_id"]: e for e in resp["entries"]}
        assert by_id["entry_good"]["options"] == {"hide_members": True}
        assert by_id["entry_bad"]["options"] == {}
        assert resp.get("warnings"), f"expected an aggregated warning: {resp}"
        assert "entry_bad" in resp["warnings"][0]
        assert "entry_good" not in resp["warnings"][0]

    async def test_list_with_no_failures_has_no_warnings(self) -> None:
        client = MagicMock()
        client._request = AsyncMock(return_value=[self._entry("entry_good")])
        client.start_options_flow = AsyncMock(return_value=self._form_flow("f1"))
        client.abort_options_flow = AsyncMock()
        tools = IntegrationTools(client)

        with patch(
            "ha_mcp.tools.tools_integrations.get_logger_levels",
            new=AsyncMock(return_value={}),
        ):
            resp = await tools._list_entries(None, None, True, None, 50, 0)
        assert "warnings" not in resp

    async def test_schema_probe_failure_attaches_warning(self) -> None:
        client = MagicMock()
        client.start_options_flow = AsyncMock(side_effect=RuntimeError("probe down"))
        client.abort_options_flow = AsyncMock()
        tools = IntegrationTools(client)

        resp: dict[str, Any] = {"entry": {"options": {}}}
        await tools._fetch_options_schema("entry_s", resp)
        assert "options_schema" not in resp
        assert resp.get("warnings"), f"expected a schema-probe warning: {resp}"
        assert "entry_s" in resp["warnings"][0]


def _sample_knxproject() -> dict[str, Any]:
    """A realistic ``knx/get_knx_project`` result payload.

    Keys mirror ``xknxproject.models.KNXProject``; group-address entries mirror
    ``GroupAddress`` (address, name, dpt, description, ...). DPTs are varied:
    switch (1.001), dimming step (3.007), scaling (5.001), 2-byte float /
    temperature (9.001), and one address with no DPT assigned.
    """
    return {
        "info": {
            "name": "Demo Project",
            "last_modified": "2026-01-15T10:00:00",
            "tool_version": "6.1.0",
            "xknxproject_version": "3.8.1",
        },
        "group_addresses": {
            "1/0/1": {
                "name": "Living Room Light Switch",
                "address": "1/0/1",
                "dpt": {"main": 1, "sub": 1},
                "description": "On/Off",
            },
            "1/0/2": {
                "name": "Living Room Light Dim Step",
                "address": "1/0/2",
                "dpt": {"main": 3, "sub": 7},
                "description": "Relative dimming",
            },
            "1/0/3": {
                "name": "Living Room Light Brightness",
                "address": "1/0/3",
                "dpt": {"main": 5, "sub": 1},
                "description": "Brightness %",
            },
            "2/1/0": {
                "name": "Living Room Temperature",
                "address": "2/1/0",
                "dpt": {"main": 9, "sub": 1},
                "description": "Actual temperature",
            },
            "3/3/3": {
                "name": "Unassigned DPT Address",
                "address": "3/3/3",
                "dpt": None,
                "description": "",
            },
        },
        "group_ranges": {
            "1/-/-": {
                "name": "Lighting",
                "address_start": 2048,
                "address_end": 4095,
                "group_addresses": [],
                "group_ranges": {},
            }
        },
        "devices": {},
    }


class TestIncludeKnxProject:
    """Wire-up tests for ha_get_integration's include_knx_project branch."""

    @pytest.mark.asyncio
    async def test_attaches_parsed_project_for_knx_entry(self) -> None:
        client = MagicMock()
        client.send_websocket_message = AsyncMock(
            return_value={"success": True, "result": _sample_knxproject()}
        )
        tools = IntegrationTools(client)
        single = {
            "success": True,
            "entry_id": "knx1",
            "entry": {"domain": "knx", "title": "KNX"},
        }
        with patch.object(
            IntegrationTools, "_get_single_entry", new=AsyncMock(return_value=single)
        ):
            result = await tools.ha_get_integration(
                entry_id="knx1", include_knx_project=True
            )

        project = result["knx_project"]
        assert project["count"] == 5
        assert set(project["group_addresses"]) == {
            "1/0/1",
            "1/0/2",
            "1/0/3",
            "2/1/0",
            "3/3/3",
        }
        # DPTs preserved verbatim, including the null-DPT entry.
        assert project["group_addresses"]["2/1/0"]["dpt"] == {"main": 9, "sub": 1}
        assert project["group_addresses"]["3/3/3"]["dpt"] is None
        assert project["group_ranges"]["1/-/-"]["name"] == "Lighting"
        assert project["info"]["name"] == "Demo Project"
        assert "warnings" not in result
        client.send_websocket_message.assert_awaited_once_with(
            {"type": "knx/get_knx_project"}
        )

    @pytest.mark.asyncio
    async def test_no_project_uploaded_attaches_empty_with_note(self) -> None:
        client = MagicMock()
        client.send_websocket_message = AsyncMock(
            return_value={"success": True, "result": None}
        )
        tools = IntegrationTools(client)
        single = {"success": True, "entry_id": "knx1", "entry": {"domain": "knx"}}
        with patch.object(
            IntegrationTools, "_get_single_entry", new=AsyncMock(return_value=single)
        ):
            result = await tools.ha_get_integration(
                entry_id="knx1", include_knx_project=True
            )

        project = result["knx_project"]
        assert project["count"] == 0
        assert project["group_addresses"] == {}
        assert "note" in project

    @pytest.mark.asyncio
    async def test_ws_failure_surfaces_warning_not_raise(self) -> None:
        client = MagicMock()
        client.send_websocket_message = AsyncMock(
            return_value={
                "success": False,
                "error": "Command failed: KNX integration not loaded.",
            }
        )
        tools = IntegrationTools(client)
        single = {"success": True, "entry_id": "knx1", "entry": {"domain": "knx"}}
        with patch.object(
            IntegrationTools, "_get_single_entry", new=AsyncMock(return_value=single)
        ):
            result = await tools.ha_get_integration(
                entry_id="knx1", include_knx_project=True
            )

        # Primary entry read still succeeds; the project failure is a warning.
        assert result["success"] is True
        assert "knx_project" not in result
        assert result.get("warnings")
        assert "KNX project" in result["warnings"][0]

    @pytest.mark.asyncio
    async def test_non_knx_entry_skips_with_warning(self) -> None:
        client = MagicMock()
        client.send_websocket_message = AsyncMock()
        tools = IntegrationTools(client)
        single = {"success": True, "entry_id": "hue1", "entry": {"domain": "hue"}}
        with patch.object(
            IntegrationTools, "_get_single_entry", new=AsyncMock(return_value=single)
        ):
            result = await tools.ha_get_integration(
                entry_id="hue1", include_knx_project=True
            )

        assert "knx_project" not in result
        assert result.get("warnings")
        assert "not a KNX integration" in result["warnings"][0]
        # No project fetch attempted for a non-KNX entry.
        client.send_websocket_message.assert_not_awaited()

    @pytest.mark.asyncio
    async def test_list_mode_ignores_flag_with_warning(self) -> None:
        client = MagicMock()
        tools = IntegrationTools(client)
        with patch.object(
            IntegrationTools,
            "_list_entries",
            new=AsyncMock(return_value={"success": True, "entries": []}),
        ):
            result = await tools.ha_get_integration(include_knx_project=True)

        assert result.get("warnings")
        assert any("include_knx_project" in w for w in result["warnings"])
