"""Unit tests for tools_updates module."""

from unittest.mock import AsyncMock, MagicMock

import httpx
import pytest

from ha_mcp.tools.tools_updates import (
    _categorize_update,
    _extract_blog_content,
    _get_monthly_versions_between,
    _parse_breaking_changes_html,
    _parse_patch_breaking_changes,
    _parse_version,
    _strip_html,
    _supports_release_notes,
    _try_raw_cdn,
)


class TestListUpdatesHaMcpUpdate:
    """ha_manage_updates (list mode) surfaces the MCP server's own update status."""

    @pytest.mark.asyncio
    async def test_ha_mcp_update_present_when_available(self, monkeypatch):
        from ha_mcp import update_check
        from ha_mcp.tools.tools_updates import UpdateTools

        monkeypatch.setattr(
            update_check,
            "get_update_field",
            AsyncMock(
                return_value={
                    "current": "7.8.0",
                    "latest": "7.9.0",
                    "update_available": True,
                }
            ),
        )
        client = MagicMock()
        client.get_states = AsyncMock(return_value=[])
        result = await UpdateTools(client).ha_manage_updates()
        assert result["ha_mcp_update"] == {
            "current": "7.8.0",
            "latest": "7.9.0",
            "update_available": True,
        }

    @pytest.mark.asyncio
    async def test_ha_mcp_update_absent_when_not_applicable(self, monkeypatch):
        from ha_mcp import update_check
        from ha_mcp.tools.tools_updates import UpdateTools

        monkeypatch.setattr(
            update_check, "get_update_field", AsyncMock(return_value=None)
        )
        client = MagicMock()
        client.get_states = AsyncMock(return_value=[])
        result = await UpdateTools(client).ha_manage_updates()
        assert "ha_mcp_update" not in result


class TestCategorizeUpdate:
    """Test _categorize_update function."""

    def test_core_update_by_entity_id(self):
        """Core updates are identified by entity_id."""
        result = _categorize_update("update.home_assistant_core_update", {})
        assert result == "core"

    def test_core_without_home_assistant_in_title(self):
        """Entity with 'core' but without 'home_assistant' in title is not categorized as core."""
        # The logic requires BOTH 'core' in entity_id AND 'home_assistant' in title
        # Note: 'home_assistant' (with underscore) must be present, not 'Home Assistant' (with space)
        result = _categorize_update(
            "update.some_core_entity", {"title": "Home Assistant Core Update"}
        )
        # This is 'other' because 'home_assistant' (underscore) is not in 'home assistant core update'
        assert result == "other"

    def test_os_update(self):
        """OS updates are identified correctly."""
        result = _categorize_update("update.home_assistant_operating_system", {})
        assert result == "os"

    def test_supervisor_update(self):
        """Supervisor updates are identified correctly."""
        result = _categorize_update("update.home_assistant_supervisor_update", {})
        assert result == "supervisor"

    def test_hacs_update(self):
        """HACS updates are identified correctly."""
        result = _categorize_update("update.hacs_some_integration", {})
        assert result == "hacs"

    def test_addon_update_by_title(self):
        """Add-on updates are identified by title."""
        result = _categorize_update(
            "update.some_addon_update", {"title": "Some Add-on"}
        )
        assert result == "addons"

    def test_device_firmware_esphome(self):
        """ESPHome device updates are categorized as devices."""
        result = _categorize_update("update.esphome_device_firmware", {})
        assert result == "devices"

    def test_device_firmware_by_title(self):
        """Device firmware updates are identified by title containing firmware."""
        result = _categorize_update(
            "update.slzb_06m_core", {"title": "SLZB-06M Core firmware"}
        )
        assert result == "devices"

    def test_other_update(self):
        """Unknown updates are categorized as other."""
        result = _categorize_update("update.unknown_thing", {"title": "Unknown"})
        assert result == "other"

    def test_none_title_does_not_raise(self):
        """Title attribute being None should not raise an error.

        This test verifies the fix for issue #185 where update entities
        with None values for title would cause:
        'NoneType' object has no attribute 'lower'
        """
        # This should not raise AttributeError
        result = _categorize_update("update.some_entity", {"title": None})
        # Without a title, it should fall through to "other"
        assert result == "other"

    def test_missing_title_does_not_raise(self):
        """Missing title attribute should not raise an error."""
        result = _categorize_update("update.some_entity", {})
        assert result == "other"

    def test_none_title_with_entity_match(self):
        """Entity ID matching should still work even with None title."""
        result = _categorize_update(
            "update.home_assistant_core_update", {"title": None}
        )
        assert result == "core"


class TestSupportsReleaseNotes:
    """Test _supports_release_notes function."""

    def test_feature_flag_set(self):
        """Returns True when release notes feature flag (16) is set."""
        # Feature flag 16 = 0x10 = release notes support
        result = _supports_release_notes("update.test", {"supported_features": 16})
        assert result is True

    def test_release_url_present(self):
        """Returns True when release_url is present."""
        result = _supports_release_notes(
            "update.test",
            {"release_url": "https://github.com/test/repo/releases/tag/v1.0"},
        )
        assert result is True

    def test_both_present(self):
        """Returns True when both feature flag and release_url are present."""
        result = _supports_release_notes(
            "update.test",
            {
                "supported_features": 16,
                "release_url": "https://github.com/test/repo/releases/tag/v1.0",
            },
        )
        assert result is True

    def test_neither_present(self):
        """Returns False when neither feature flag nor release_url is present."""
        result = _supports_release_notes("update.test", {})
        assert result is False

    def test_other_features_only(self):
        """Returns False when only other feature flags are set (not 16)."""
        # Features 1=install, 2=specific_version, 4=progress, 8=backup
        result = _supports_release_notes(
            "update.test",
            {"supported_features": 15},  # 1+2+4+8
        )
        assert result is False


class TestParseVersion:
    """Test _parse_version function."""

    def test_standard_ha_version(self):
        assert _parse_version("2025.11.3") == (2025, 11, 3)

    def test_major_minor_only(self):
        assert _parse_version("2025.11") == (2025, 11)

    def test_empty_and_invalid(self):
        assert _parse_version("") is None
        assert _parse_version("beta") is None
        assert _parse_version("2025.11.beta") is None

    def test_version_comparison(self):
        assert _parse_version("2025.10.0") < _parse_version("2025.11.0")  # type: ignore[operator]
        assert _parse_version("2025.11.1") < _parse_version("2025.11.3")  # type: ignore[operator]


class TestGetMonthlyVersionsBetween:
    """Test _get_monthly_versions_between function."""

    def test_single_month_gap(self):
        assert _get_monthly_versions_between("2025.11.3", "2025.12.0") == ["2025.12.0"]

    def test_multi_month_gap(self):
        assert _get_monthly_versions_between("2025.10.3", "2026.2.1") == [
            "2025.11.0",
            "2025.12.0",
            "2026.1.0",
            "2026.2.0",
        ]

    def test_year_boundary(self):
        assert _get_monthly_versions_between("2025.11.0", "2026.1.0") == [
            "2025.12.0",
            "2026.1.0",
        ]

    def test_same_month_returns_empty(self):
        assert _get_monthly_versions_between("2025.11.0", "2025.11.3") == []
        assert _get_monthly_versions_between("2025.11.0", "2025.11.0") == []

    def test_invalid_versions_fallback(self):
        assert _get_monthly_versions_between("bad", "2025.11.0") == ["2025.11.0"]


class TestStripHtml:
    """Test _strip_html function."""

    def test_removes_tags(self):
        assert _strip_html("<b>bold</b> text") == "bold text"

    def test_preserves_structure(self):
        result = _strip_html("<p>First</p><p>Second</p>")
        assert "First" in result and "Second" in result and "\n" in result
        result = _strip_html("<ul><li>One</li><li>Two</li></ul>")
        assert "- One" in result and "- Two" in result

    def test_edge_cases(self):
        assert _strip_html("") == ""
        assert _strip_html("just plain text") == "just plain text"


class TestExtractBlogContent:
    """Test _extract_blog_content function."""

    def test_extracts_article_content(self):
        html = "<nav>Nav</nav><article><h2>Release</h2><p>Great stuff</p></article><footer>F</footer>"
        result = _extract_blog_content(html)
        assert "Great stuff" in result and "Nav" not in result

    def test_fallback_to_heading(self):
        html = (
            '<div>Nav</div><h1>Release</h1><p>Content</p><footer class="f">F</footer>'
        )
        assert "Content" in _extract_blog_content(html)

    def test_strips_html_tags(self):
        result = _extract_blog_content("<article><p><b>Bold</b> text</p></article>")
        assert "<" not in result and "Bold" in result


class TestParseBreakingChangesHtml:
    """Test _parse_breaking_changes_html function."""

    SAMPLE_HTML = (
        '<h2 id="backward-incompatible-changes">Backward-incompatible changes</h2>'
        "<h3>Tuya</h3><p>HVACMode converted to presets.</p>"
        "<h3>Group</h3><p>Sensor group behavior changed.</p>"
        '<h2 id="all-changes">All changes</h2>'
    )

    def test_parses_entries(self):
        result = _parse_breaking_changes_html(self.SAMPLE_HTML, "https://example.com")
        assert result is not None
        assert result["count"] == 2
        names = [e["integration"] for e in result["entries"]]
        assert "Tuya" in names and "Group" in names

    def test_entry_descriptions(self):
        result = _parse_breaking_changes_html(self.SAMPLE_HTML, "https://example.com")
        assert result is not None
        tuya = next(e for e in result["entries"] if e["integration"] == "Tuya")
        assert "HVACMode" in tuya["description"]

    def test_no_section_returns_none(self):
        assert _parse_breaking_changes_html("<h2>Other</h2>", "url") is None

    def test_empty_section_returns_none(self):
        html = '<h2 id="backward-incompatible-changes">BC</h2><h2 id="next">N</h2>'
        assert _parse_breaking_changes_html(html, "url") is None


class TestParsePatchBreakingChanges:
    """Test _parse_patch_breaking_changes function."""

    def test_parses_tagged_items(self):
        body = (
            "- Normal fix\n- Tuya fix ([tuya docs]) (breaking-change)\n- Another fix\n"
        )
        result = _parse_patch_breaking_changes(body, "2025.11.1")
        assert result is not None
        assert result["count"] == 1
        assert result["entries"][0]["integration"] == "tuya"
        assert "2025.11.1" in result["source_url"]

    def test_no_breaking_changes(self):
        assert _parse_patch_breaking_changes("- Normal fix\n", "2025.11.1") is None

    def test_integration_extraction(self):
        body = "- Fix ([vesync documentation]) (breaking-change)\n"
        result = _parse_patch_breaking_changes(body, "2025.11.2")
        assert result is not None and result["entries"][0]["integration"] == "vesync"

    def test_unknown_when_no_docs_link(self):
        body = "- Some change (breaking-change)\n"
        result = _parse_patch_breaking_changes(body, "2025.11.2")
        assert result is not None and result["entries"][0]["integration"] == "unknown"

    def test_case_insensitive(self):
        body = "- Fix ([hue docs]) (Breaking-Change)\n"
        result = _parse_patch_breaking_changes(body, "2025.11.1")
        assert result is not None and result["count"] == 1


class TestTryRawCdn:
    """Unit tests for _try_raw_cdn loop behavior."""

    def _make_http_client(self, responses: list) -> MagicMock:
        """Build a fake httpx client whose .get() returns responses in order."""
        client = MagicMock()
        client.get = AsyncMock(side_effect=responses)
        return client

    def _ok(self, text: str) -> MagicMock:
        r = MagicMock()
        r.status_code = 200
        r.text = text
        return r

    def _not_found(self) -> MagicMock:
        r = MagicMock()
        r.status_code = 404
        r.text = ""
        return r

    @pytest.mark.asyncio
    async def test_returns_none_when_all_paths_404(self):
        client = self._make_http_client([self._not_found()] * 5)
        result = await _try_raw_cdn(client, "owner", "repo", "v1.0.0")
        assert result is None
        assert client.get.await_count == 5

    @pytest.mark.asyncio
    async def test_skips_short_content_and_returns_none(self):
        short = self._ok("too short")
        client = self._make_http_client([short] * 5)
        result = await _try_raw_cdn(client, "owner", "repo", "v1.0.0")
        assert result is None

    @pytest.mark.asyncio
    async def test_exception_on_one_path_continues_to_next(self):
        good = self._ok("x" * 100)
        client = self._make_http_client([httpx.ConnectError("boom"), good])
        result = await _try_raw_cdn(client, "owner", "repo", "v1.0.0")
        assert result is not None
        assert result["source"] == "github_raw"
        assert client.get.await_count == 2

    @pytest.mark.asyncio
    async def test_returns_first_path_with_sufficient_content(self):
        good = self._ok("y" * 100)
        client = self._make_http_client([self._not_found(), good])
        result = await _try_raw_cdn(client, "owner", "repo", "v1.0.0")
        assert result is not None
        assert result["notes"] == "y" * 100
        assert client.get.await_count == 2


class TestManageUpdatesDispatch:
    """Write-action dispatch: category resolution, filtering, aggregation."""

    @pytest.mark.asyncio
    async def test_install_category_resolves_filters_and_dispatches(self, monkeypatch):
        from ha_mcp import update_check
        from ha_mcp.tools.tools_updates import UpdateTools

        monkeypatch.setattr(
            update_check, "get_update_field", AsyncMock(return_value=None)
        )
        # Titles carry "Add-on" so _categorize_update buckets both as
        # "addons"; addon_b is in_progress and must be filtered out.
        states = [
            {
                "entity_id": "update.addon_a",
                "state": "on",
                "attributes": {"title": "Add-on A", "in_progress": False},
            },
            {
                "entity_id": "update.addon_b",
                "state": "on",
                "attributes": {"title": "Add-on B", "in_progress": True},
            },
        ]
        client = MagicMock()
        client.get_states = AsyncMock(return_value=states)
        client.call_service = AsyncMock(return_value={"success": True})

        result = await UpdateTools(client).ha_manage_updates(
            action="install", categories=["addons"], backup=True
        )

        client.call_service.assert_awaited_once_with(
            "update", "install", {"entity_id": "update.addon_a", "backup": True}
        )
        assert (result["requested"], result["succeeded"], result["failed"]) == (
            1,
            1,
            0,
        )
        assert result["results"][0]["success"] is True

    @pytest.mark.asyncio
    async def test_skip_mixed_existing_and_missing_aggregates(self, monkeypatch):
        from ha_mcp import update_check
        from ha_mcp.tools.tools_updates import UpdateTools

        monkeypatch.setattr(
            update_check, "get_update_field", AsyncMock(return_value=None)
        )
        client = MagicMock()
        client.get_states = AsyncMock(
            return_value=[
                {"entity_id": "update.exists", "state": "on", "attributes": {}}
            ]
        )
        client.call_service = AsyncMock(return_value={"success": True})

        result = await UpdateTools(client).ha_manage_updates(
            action="skip", entity_ids=["update.exists", "update.missing"]
        )

        # The existence precheck routes the missing entity to a per-item
        # ENTITY_NOT_FOUND result; only the real one reaches call_service.
        client.call_service.assert_awaited_once_with(
            "update", "skip", {"entity_id": "update.exists"}
        )
        assert (result["requested"], result["succeeded"], result["failed"]) == (
            2,
            1,
            1,
        )
        assert result["success"] is False
        failures = [r for r in result["results"] if not r.get("success")]
        assert len(failures) == 1
        assert failures[0]["entity_id"] == "update.missing"

    @pytest.mark.asyncio
    async def test_connection_error_propagates_out_of_batch(self, monkeypatch):
        from ha_mcp import update_check
        from ha_mcp.client.rest_client import HomeAssistantConnectionError
        from ha_mcp.tools.tools_updates import UpdateTools

        monkeypatch.setattr(
            update_check, "get_update_field", AsyncMock(return_value=None)
        )
        client = MagicMock()
        client.get_states = AsyncMock(
            return_value=[
                {"entity_id": "update.exists", "state": "on", "attributes": {}}
            ]
        )
        client.call_service = AsyncMock(
            side_effect=HomeAssistantConnectionError("connection lost")
        )

        with pytest.raises(Exception) as excinfo:
            await UpdateTools(client).ha_manage_updates(
                action="skip", entity_ids=["update.exists"]
            )
        assert "connection" in str(excinfo.value).lower()
