"""Unit tests for `_get_local_backup_agent_id` in the backup tools module.

Regression coverage for the hardcoded `hassio.local` bug that broke `ha_backup_*`
tools on HA Core installs (which only register `backup.local`).
"""

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

import pytest
from fastmcp.exceptions import ToolError

from ha_mcp.tools.backup import (
    _backup_protected,
    _get_local_backup_agent_id,
    _summarize_backup,
    list_backups,
    restore_backup,
)


class TestBackupProtected:
    """`_backup_protected` derives the encrypted flag from the per-agent map."""

    def test_any_agent_protected_makes_it_protected(self):
        entry = {
            "agents": {
                "backup.local": {"protected": False},
                "google_drive.cloud": {"protected": True},
            }
        }
        assert _backup_protected(entry) is True

    def test_all_agents_unprotected(self):
        entry = {"agents": {"backup.local": {"protected": False}}}
        assert _backup_protected(entry) is False

    def test_missing_agents_is_unknown(self):
        assert _backup_protected({}) is None

    def test_no_agent_reports_protected_is_unknown(self):
        # agents present but none carry the field → unknown, not "unprotected".
        assert _backup_protected({"agents": {"backup.local": {"size": 1}}}) is None

    def test_non_dict_agents_is_unknown(self):
        assert _backup_protected({"agents": ["unexpected"]}) is None


def _ws_client(agents_payload: dict) -> AsyncMock:
    """Build a mock WS client whose `send_command("backup/agents/info")` returns the given payload."""
    ws = AsyncMock()
    ws.send_command.return_value = agents_payload
    return ws


class TestGetLocalBackupAgentId:
    @pytest.mark.asyncio
    async def test_core_only_returns_backup_local(self):
        """HA Core install (only `backup.local` registered) returns `backup.local`."""
        ws = _ws_client(
            {
                "success": True,
                "result": {"agents": [{"agent_id": "backup.local", "name": "local"}]},
            }
        )
        assert await _get_local_backup_agent_id(ws) == "backup.local"

    @pytest.mark.asyncio
    async def test_supervised_only_returns_hassio_local(self):
        """HA Supervised install (only `hassio.local` registered) returns `hassio.local`."""
        ws = _ws_client(
            {
                "success": True,
                "result": {"agents": [{"agent_id": "hassio.local", "name": "local"}]},
            }
        )
        assert await _get_local_backup_agent_id(ws) == "hassio.local"

    @pytest.mark.asyncio
    async def test_both_present_prefers_hassio_local(self):
        """When both agents are registered, prefer `hassio.local` (Supervisor)."""
        ws = _ws_client(
            {
                "success": True,
                "result": {
                    "agents": [
                        {"agent_id": "backup.local", "name": "local"},
                        {"agent_id": "hassio.local", "name": "local"},
                    ],
                },
            }
        )
        assert await _get_local_backup_agent_id(ws) == "hassio.local"

    @pytest.mark.asyncio
    async def test_only_remote_agents_raises(self):
        """No agent named `local` raises `ToolError` listing the available agents."""
        ws = _ws_client(
            {
                "success": True,
                "result": {
                    "agents": [
                        {"agent_id": "google_drive.cloud", "name": "Google Drive"}
                    ]
                },
            }
        )
        with pytest.raises(ToolError) as exc_info:
            await _get_local_backup_agent_id(ws)
        error = json.loads(str(exc_info.value))
        assert "No local backup agent found" in error["error"]["message"]
        assert error["available_agents"] == ["google_drive.cloud"]

    @pytest.mark.asyncio
    async def test_empty_agent_list_raises(self):
        """Empty agent list raises `ToolError` with an actionable suggestion."""
        ws = _ws_client({"success": True, "result": {"agents": []}})
        with pytest.raises(ToolError) as exc_info:
            await _get_local_backup_agent_id(ws)
        error = json.loads(str(exc_info.value))
        assert "No backup agents registered" in error["error"]["message"]

    @pytest.mark.asyncio
    async def test_send_command_failure_raises(self):
        """A `success=False` response from HA raises `ToolError`."""
        ws = _ws_client({"success": False, "error": "WS error"})
        with pytest.raises(ToolError) as exc_info:
            await _get_local_backup_agent_id(ws)
        error = json.loads(str(exc_info.value))
        assert "Failed to enumerate backup agents" in error["error"]["message"]

    @pytest.mark.asyncio
    async def test_malformed_local_entry_filtered_out(self):
        """An agent with `name=local` but missing `agent_id` is filtered, not returned as None."""
        ws = _ws_client(
            {
                "success": True,
                "result": {
                    "agents": [
                        {"name": "local"},  # malformed — no agent_id
                        {"agent_id": "backup.local", "name": "local"},
                    ],
                },
            }
        )
        assert await _get_local_backup_agent_id(ws) == "backup.local"

    @pytest.mark.asyncio
    async def test_only_malformed_local_entry_raises(self):
        """If the only `name=local` entry is malformed, raise rather than return None."""
        ws = _ws_client(
            {
                "success": True,
                "result": {"agents": [{"name": "local"}]},
            }
        )
        with pytest.raises(ToolError) as exc_info:
            await _get_local_backup_agent_id(ws)
        error = json.loads(str(exc_info.value))
        assert "No local backup agent found" in error["error"]["message"]


class TestRestoreBackupWarnings:
    """Pin the post-#1332 warnings-list contract on the restore_backup
    success path (``backup.py`` ~L447-457). Pre-#1332 emitted singular
    ``warning``; the migrated shape is ``warnings: list[str]`` containing
    the connection-lost-during-restart notice.
    """

    @pytest.mark.asyncio
    async def test_success_returns_top_level_warnings_list(self):
        ws = AsyncMock()
        ws.send_command.side_effect = [
            # backup/info — verify backup exists
            {"success": True, "result": {"backups": [{"backup_id": "abc123"}]}},
            # _get_local_backup_agent_id → backup/agents/info
            {
                "success": True,
                "result": {"agents": [{"agent_id": "backup.local", "name": "local"}]},
            },
            # _get_backup_password → backup/config/info
            {
                "success": True,
                "result": {"config": {"create_backup": {"password": "pw"}}},
            },
            # _create_safety_backup → backup/generate
            {"success": True, "result": {"backup_job_id": "safety_job_1"}},
            # backup/restore — the actual restore call
            {"success": True},
        ]

        client = MagicMock()
        client.base_url = "http://test"
        client.token = "token"
        client.verify_ssl = False

        # The safety-backup completion poll is exercised by test_backup_restore;
        # stub it here so this test stays focused on the warnings contract.
        with (
            patch(
                "ha_mcp.tools.backup.get_connected_ws_client",
                new=AsyncMock(return_value=(ws, None)),
            ),
            patch(
                "ha_mcp.tools.backup._poll_backup_completion",
                new=AsyncMock(return_value={"success": True}),
            ),
        ):
            result = await restore_backup(client, "abc123")

        assert result["success"] is True
        assert result["backup_id"] == "abc123"
        warnings = result.get("warnings")
        assert isinstance(warnings, list) and warnings, (
            f"Expected non-empty warnings list, got: {result!r}"
        )
        assert any("Connection will be temporarily lost" in w for w in warnings), (
            f"Expected connection-lost warning content; got: {warnings!r}"
        )


class TestSummarizeBackup:
    """_summarize_backup projects a backup/info entry to caller-facing fields (#1586)."""

    def test_projects_core_fields_and_largest_agent_size(self):
        entry = {
            "backup_id": "abc",
            "name": "Nightly",
            "date": "2026-06-14T02:00:00+00:00",
            "database_included": False,
            "homeassistant_included": True,
            "homeassistant_version": "2026.6.0",
            "with_automatic_settings": True,
            # `protected` is a per-agent field (AgentBackupStatus), not top-level.
            "agents": {
                "backup.local": {"size": 100, "protected": True},
                "google_drive.cloud": {"size": 250, "protected": True},
            },
        }
        out = _summarize_backup(entry)
        assert out["backup_id"] == "abc"
        assert out["name"] == "Nightly"
        assert out["date"] == "2026-06-14T02:00:00+00:00"
        # Size is per-agent; surface the largest reported size across agents
        # (here the cloud agent's 250, not the local 100).
        assert out["size_bytes"] == 250
        assert out["protected"] is True
        assert set(out["agent_ids"]) == {"backup.local", "google_drive.cloud"}

    def test_missing_fields_become_none(self):
        out = _summarize_backup({})
        assert out["backup_id"] is None
        assert out["size_bytes"] is None
        assert out["agent_ids"] == []

    def test_non_int_sizes_ignored(self):
        out = _summarize_backup({"agents": {"a": {"size": None}, "b": {}}})
        assert out["size_bytes"] is None

    def test_float_size_coerced_to_int(self):
        out = _summarize_backup({"agents": {"a": {"size": 123.0}}})
        assert out["size_bytes"] == 123

    def test_non_dict_agents_handled(self):
        # HA WS payloads aren't schema-guaranteed; a non-dict ``agents`` must
        # not raise — size unknown, no agent ids.
        out = _summarize_backup({"agents": ["unexpected"]})
        assert out["size_bytes"] is None
        assert out["agent_ids"] == []


class TestListBackups:
    """list_backups surfaces HA's backup/info inventory, newest first (#1586)."""

    @staticmethod
    def _client() -> MagicMock:
        client = MagicMock()
        client.base_url = "http://test"
        client.token = "token"
        client.verify_ssl = False
        return client

    @pytest.mark.asyncio
    async def test_lists_backups_newest_first(self):
        ws = AsyncMock()
        ws.send_command.return_value = {
            "success": True,
            "result": {
                "backups": [
                    {
                        "backup_id": "old",
                        "name": "Old",
                        "date": "2026-06-01T00:00:00+00:00",
                        "agents": {"backup.local": {"size": 10}},
                    },
                    {
                        "backup_id": "new",
                        "name": "New",
                        "date": "2026-06-10T00:00:00+00:00",
                        "agents": {"backup.local": {"size": 20}},
                    },
                ]
            },
        }
        with patch(
            "ha_mcp.tools.backup.get_connected_ws_client",
            new=AsyncMock(return_value=(ws, None)),
        ):
            result = await list_backups(self._client())

        assert result["success"] is True
        assert result["count"] == 2
        assert result["total"] == 2
        assert [b["backup_id"] for b in result["backups"]] == ["new", "old"]
        ws.send_command.assert_awaited_with("backup/info")

    @pytest.mark.asyncio
    async def test_limit_truncates_but_reports_total(self):
        ws = AsyncMock()
        ws.send_command.return_value = {
            "success": True,
            "result": {
                "backups": [
                    {
                        "backup_id": f"b{i}",
                        "date": f"2026-06-{i + 1:02d}T00:00:00+00:00",
                    }
                    for i in range(5)
                ]
            },
        }
        with patch(
            "ha_mcp.tools.backup.get_connected_ws_client",
            new=AsyncMock(return_value=(ws, None)),
        ):
            result = await list_backups(self._client(), limit=2)

        assert result["count"] == 2
        assert result["total"] == 5

    @pytest.mark.asyncio
    async def test_backup_info_failure_raises(self):
        ws = AsyncMock()
        ws.send_command.return_value = {"success": False, "error": "nope"}
        with (
            patch(
                "ha_mcp.tools.backup.get_connected_ws_client",
                new=AsyncMock(return_value=(ws, None)),
            ),
            pytest.raises(ToolError),
        ):
            await list_backups(self._client())

    @pytest.mark.asyncio
    async def test_undated_entry_sinks_last(self):
        # An entry with a missing/unparseable date must not crash the sort
        # (datetime vs None) — it sinks below dated entries via the floor.
        ws = AsyncMock()
        ws.send_command.return_value = {
            "success": True,
            "result": {
                "backups": [
                    {"backup_id": "undated"},
                    {"backup_id": "dated", "date": "2026-06-10T00:00:00+00:00"},
                ]
            },
        }
        with patch(
            "ha_mcp.tools.backup.get_connected_ws_client",
            new=AsyncMock(return_value=(ws, None)),
        ):
            result = await list_backups(self._client())

        assert result["success"] is True
        assert [b["backup_id"] for b in result["backups"]] == ["dated", "undated"]
