"""Unit tests for the dashboard-resolver helpers in tools_config_dashboards.

The helpers under test (`_should_lazy_resolve`, `_resolve_dashboard`,
`_lazy_resolve_and_retry`) hold the substring-trigger contract and the
two-call-site resolver glue that the rest of the dual-accept identifier
design rests on. End-to-end tests would only catch a regression here on
the right HA-version axis; these unit tests pin the contract independent
of HA wording stability.
"""

import json
import logging
from unittest.mock import AsyncMock, MagicMock

import pytest
from fastmcp.exceptions import ToolError

from ha_mcp.tools.tools_config_dashboards import (
    _LAZY_RESOLVE_TRIGGER,
    _lazy_resolve_and_retry,
    _resolve_dashboard,
    _should_lazy_resolve,
    register_config_dashboard_tools,
)

# -----------------------------------------------------------------------------
# Fixtures / helpers
# -----------------------------------------------------------------------------


@pytest.fixture
def fake_client():
    client = MagicMock()
    client.send_websocket_message = AsyncMock()
    return client


def _trigger_response(missing_id: str = "anything") -> dict:
    """Build the WS error envelope HA emits when ``lovelace/config`` is
    called with an identifier it does not recognise. Includes the literal
    trigger substring."""
    return {
        "success": False,
        "error": {
            "message": f"{_LAZY_RESOLVE_TRIGGER}: {missing_id}",
            "code": "config_not_found",
        },
    }


def _success_response(payload: dict | None = None) -> dict:
    return {"success": True, "result": payload or {"views": []}}


# -----------------------------------------------------------------------------
# _should_lazy_resolve — substring contract
# -----------------------------------------------------------------------------


class TestShouldLazyResolve:
    """The substring trigger is the only signal available at the tool
    layer. Pin the contract so an HA-side wording change is caught here
    rather than degrading to "lazy fallback never fires" silently."""

    def test_exact_trigger_message(self):
        assert _should_lazy_resolve(_LAZY_RESOLVE_TRIGGER) is True

    def test_trigger_with_identifier_suffix(self):
        # The HA emit form is f"Unknown config specified: {url_path}".
        assert _should_lazy_resolve("Unknown config specified: my_dashboard") is True

    def test_trigger_embedded_in_longer_message(self):
        assert (
            _should_lazy_resolve("Command failed: Unknown config specified: foo")
            is True
        )

    def test_unrelated_message_does_not_match(self):
        assert _should_lazy_resolve("Some other error") is False
        assert _should_lazy_resolve("") is False

    def test_legitimate_empty_dashboard_message_does_not_match(self):
        # HA emits "No config found." for genuinely empty (un-initialised)
        # dashboards — must NOT trigger a lazy retry, otherwise the
        # caller's empty-state path is hidden.
        assert _should_lazy_resolve("No config found.") is False


# -----------------------------------------------------------------------------
# _resolve_dashboard — registry lookup
# -----------------------------------------------------------------------------


class TestResolveDashboard:
    """Tests both arms of ``_resolve_dashboard``: matched and unexpected-shape."""

    async def test_match_by_url_path(self, fake_client):
        dashboards_list = [
            {"url_path": "my-dash", "id": "my_dash"},
            {"url_path": "other", "id": "other_id"},
        ]
        fake_client.send_websocket_message.return_value = {"result": dashboards_list}
        match, dashboards = await _resolve_dashboard(fake_client, "my-dash")
        assert match == {"url_path": "my-dash", "id": "my_dash"}
        assert dashboards == dashboards_list

    async def test_match_by_internal_id(self, fake_client):
        dashboards_list = [{"url_path": "my-dash", "id": "my_dash"}]
        fake_client.send_websocket_message.return_value = {"result": dashboards_list}
        match, dashboards = await _resolve_dashboard(fake_client, "my_dash")
        assert match == {"url_path": "my-dash", "id": "my_dash"}
        assert dashboards == dashboards_list

    async def test_response_as_bare_list(self, fake_client):
        # Older HA versions / different response shapes return the list
        # directly rather than wrapped in {"result": ...}.
        dashboards_list = [{"url_path": "my-dash", "id": "my_dash"}]
        fake_client.send_websocket_message.return_value = dashboards_list
        match, dashboards = await _resolve_dashboard(fake_client, "my_dash")
        assert match == {"url_path": "my-dash", "id": "my_dash"}
        assert dashboards == dashboards_list

    async def test_no_match_still_returns_dashboards_list(self, fake_client):
        # When the identifier doesn't match any dashboard, ``match`` is
        # None but ``dashboards`` still carries the fetched list — the
        # fetch happened, only the match check failed.
        dashboards_list = [{"url_path": "my-dash", "id": "my_dash"}]
        fake_client.send_websocket_message.return_value = {"result": dashboards_list}
        match, dashboards = await _resolve_dashboard(fake_client, "nonexistent")
        assert match is None
        assert dashboards == dashboards_list

    async def test_malformed_shape_logs_warning_and_returns_none_pair(
        self, fake_client, caplog
    ):
        # Neither dict-with-result nor list — could be a future HA shape
        # change or an error envelope. Must surface as a logger.warning,
        # not silently degrade to "always no match". Both elements of
        # the tuple are None so callers know the fetch failed and they
        # can fall back to a fresh fetch instead of treating ``[]`` as
        # an authoritative empty registry.
        fake_client.send_websocket_message.return_value = "unexpected string"
        with caplog.at_level(
            logging.WARNING, logger="ha_mcp.tools.tools_config_dashboards"
        ):
            match, dashboards = await _resolve_dashboard(fake_client, "anything")
        assert match is None
        assert dashboards is None
        assert any("unexpected shape" in rec.message for rec in caplog.records), (
            f"expected an 'unexpected shape' warning; got {caplog.records}"
        )

    async def test_missing_url_path_in_match_returns_none_match(self, fake_client):
        # Malformed registry entry where the matching dashboard is
        # missing one of the required fields. Match is None (skipped
        # rather than forwarding empty strings to delete_dashboard) but
        # the list itself is still returned.
        dashboards_list = [{"id": "my_dash"}]  # url_path missing entirely
        fake_client.send_websocket_message.return_value = {"result": dashboards_list}
        match, dashboards = await _resolve_dashboard(fake_client, "my_dash")
        assert match is None
        assert dashboards == dashboards_list

    async def test_empty_id_in_match_returns_none_match(self, fake_client):
        dashboards_list = [{"url_path": "my-dash", "id": ""}]
        fake_client.send_websocket_message.return_value = {"result": dashboards_list}
        match, dashboards = await _resolve_dashboard(fake_client, "my-dash")
        assert match is None
        assert dashboards == dashboards_list


# -----------------------------------------------------------------------------
# _lazy_resolve_and_retry — composition + no-op axes
# -----------------------------------------------------------------------------


class TestLazyResolveAndRetry:
    async def test_success_response_short_circuits(self, fake_client):
        ws_data = {"type": "lovelace/config", "url_path": "anything"}
        response = _success_response()
        new_url, new_response = await _lazy_resolve_and_retry(
            fake_client, "anything", ws_data, response
        )
        assert (new_url, new_response) == ("anything", response)
        # No WS call — short-circuit must not pay the round-trip.
        fake_client.send_websocket_message.assert_not_called()

    async def test_empty_url_path_short_circuits(self, fake_client):
        # Default-dashboard path: caller passes None for url_path.
        ws_data = {"type": "lovelace/config"}
        response = _trigger_response()
        new_url, new_response = await _lazy_resolve_and_retry(
            fake_client, None, ws_data, response
        )
        assert new_url is None
        assert new_response is response
        fake_client.send_websocket_message.assert_not_called()

    async def test_non_trigger_failure_short_circuits(self, fake_client):
        # Failure response, but with a different error message. Must NOT
        # invoke the resolver — that would surface a synthetic resolver
        # error instead of the real HA error to the caller.
        ws_data = {"type": "lovelace/config", "url_path": "x"}
        response = {
            "success": False,
            "error": {"message": "permission denied"},
        }
        new_url, new_response = await _lazy_resolve_and_retry(
            fake_client, "x", ws_data, response
        )
        assert (new_url, new_response) == ("x", response)
        fake_client.send_websocket_message.assert_not_called()

    async def test_resolver_no_match_returns_original_response(self, fake_client):
        # Trigger fired, resolver runs, but the registry has no match —
        # original failure response wins so the caller's existing error
        # path runs against the real HA error.
        fake_client.send_websocket_message.side_effect = [
            {"result": []},  # resolver: empty list, no match
        ]
        ws_data = {"type": "lovelace/config", "url_path": "ghost"}
        original = _trigger_response("ghost")
        new_url, new_response = await _lazy_resolve_and_retry(
            fake_client, "ghost", ws_data, original
        )
        assert new_url == "ghost"
        assert new_response is original

    async def test_resolver_exception_falls_through(self, fake_client, caplog):
        # Resolver raises (timeout, network blip). Must NOT escape; must
        # log at WARNING and fall through to the original response so
        # the caller's existing error path surfaces the real HA error.
        fake_client.send_websocket_message.side_effect = ConnectionError("ws gone")
        ws_data = {"type": "lovelace/config", "url_path": "x"}
        original = _trigger_response("x")
        with caplog.at_level(
            logging.WARNING, logger="ha_mcp.tools.tools_config_dashboards"
        ):
            new_url, new_response = await _lazy_resolve_and_retry(
                fake_client, "x", ws_data, original
            )
        assert (new_url, new_response) == ("x", original)
        assert any("Lazy resolver failed" in rec.message for rec in caplog.records)

    async def test_happy_path_resolves_and_retries(self, fake_client):
        # Trigger fires, resolver finds the canonical url_path, retry
        # succeeds with new url_path on the WS data dict.
        fake_client.send_websocket_message.side_effect = [
            {  # resolver
                "result": [{"url_path": "my-dash", "id": "my_dash"}]
            },
            _success_response({"views": [{"cards": []}]}),  # retry
        ]
        ws_data = {"type": "lovelace/config", "url_path": "my_dash", "force": True}
        original = _trigger_response("my_dash")
        new_url, new_response = await _lazy_resolve_and_retry(
            fake_client, "my_dash", ws_data, original
        )
        assert new_url == "my-dash"
        assert new_response["success"] is True

        # Caller's ws_data dict must NOT be mutated — the retry uses a
        # shallow copy. Verify both the contract and that the retry call
        # carried the canonical url_path.
        assert ws_data["url_path"] == "my_dash", (
            "_lazy_resolve_and_retry mutated the caller's ws_data dict"
        )
        retry_call = fake_client.send_websocket_message.call_args_list[1]
        assert retry_call.args[0]["url_path"] == "my-dash"
        assert retry_call.args[0]["type"] == "lovelace/config"


class TestDeleteDashboardNotFoundShape:
    """Pin the 404 and idempotent-success shapes on ``ha_config_delete_dashboard``
    (issue #1300): ``RESOURCE_NOT_FOUND`` with top-level ``action`` + ``url_path``,
    no ``resource_type`` / ``identifier``, and the success path's matching key
    set on the WS-not-found idempotent branch.
    """

    @pytest.fixture
    def mock_mcp(self):
        mcp = MagicMock()
        self.registered_tools: dict = {}

        def tool_decorator(*args, **kwargs):
            def wrapper(func):
                self.registered_tools[func.__name__] = func
                return func

            return wrapper

        mcp.tool = tool_decorator
        return mcp

    @pytest.fixture
    def mock_client(self):
        client = MagicMock()
        client.send_websocket_message = AsyncMock()
        return client

    @pytest.fixture
    def delete_tool(self, mock_mcp, mock_client):
        register_config_dashboard_tools(mock_mcp, mock_client)
        return self.registered_tools["ha_config_delete_dashboard"]

    @pytest.mark.asyncio
    async def test_unresolvable_url_path_raises_resource_not_found(
        self, delete_tool, mock_client
    ):
        """When _resolve_dashboard finds no match (empty registry), the tool
        raises a ToolError carrying the canonical RESOURCE_NOT_FOUND shape
        with top-level ``action`` and ``url_path`` matching the idempotent
        already-deleted success branch on the same function.
        """
        # Empty dashboards list → _resolve_dashboard returns None.
        mock_client.send_websocket_message.return_value = {
            "success": True,
            "result": [],
        }

        with pytest.raises(ToolError) as exc_info:
            await delete_tool(url_path="ghost-dash")

        body = json.loads(str(exc_info.value))

        # Error code lives at error.code (canonical builder shape).
        assert body["success"] is False
        assert body["error"]["code"] == "RESOURCE_NOT_FOUND"
        assert "ghost-dash" in body["error"]["message"]

        # Top-level shape mirrors the idempotent already-deleted success branch:
        # action="delete" + url_path=<input>.
        assert body["action"] == "delete"
        assert body["url_path"] == "ghost-dash"

        # The pre-#1300 hard-coded resource_type / identifier from the
        # previous helper path are intentionally dropped — those keys were
        # the source of the cross-family shape divergence.
        assert "resource_type" not in body
        assert "identifier" not in body

    @pytest.mark.asyncio
    async def test_already_deleted_returns_idempotent_success_shape(
        self, delete_tool, mock_client
    ):
        """When _resolve_dashboard succeeds but the WS delete call replies with
        a ``not found`` error, the tool returns the idempotent success shape
        — locking the ``action`` + ``url_path`` keys that the failure-path
        404 mirrors. Drift between the two top-level shapes would surface
        as a test break here.
        """
        # First call: registry lookup → returns a real dashboard so
        # _resolve_dashboard succeeds. Second call: delete → server reports
        # the dashboard is already gone, triggering the idempotent branch.
        mock_client.send_websocket_message.side_effect = [
            {"success": True, "result": [{"id": "abc123", "url_path": "stale-dash"}]},
            {"success": False, "error": {"message": "Dashboard not found"}},
        ]

        result = await delete_tool(url_path="stale-dash")

        assert result["success"] is True
        assert result["action"] == "delete"
        assert result["url_path"] == "stale-dash"
        # Symmetric absence to the 404-path: success branch must also not
        # leak the dropped resource_type / identifier keys.
        assert "resource_type" not in result
        assert "identifier" not in result
