"""Unit tests for the bug report tool (ha_report_issue)."""

from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch

import httpx
import pytest

from ha_mcp import __version__
from ha_mcp.tools.tools_bug_report import (
    _detect_mcp_transport,
    _extract_client_info,
    _fetch_addon_logs,
    _fetch_core_error_log,
    _format_client_info_for_template,
    _format_config_toggles_for_template,
    _format_logs_for_report,
    _format_startup_logs,
    _get_config_toggles,
    _sanitize_log_text,
    register_bug_report_tools,
)


@pytest.fixture
def mock_mcp():
    """Create a mock MCP server for bug report tool tests."""
    mcp = MagicMock()
    mcp._tools = {}

    def add_tool(method):
        name = (
            getattr(getattr(method, "__fastmcp__", None), "name", None)
            or method.__name__
        )

        async def _wrapper(*args, **kwargs):
            return await method(*args, **kwargs)

        _wrapper.__name__ = name
        mcp._tools[name] = _wrapper

    mcp.add_tool = add_tool
    return mcp


class TestBugReportTool:
    """Test suite for the ha_report_issue tool."""

    @pytest.fixture
    def mock_client(self):
        """Create a mock Home Assistant client."""
        client = MagicMock()
        client.get_config = AsyncMock()
        client.get_states = AsyncMock()
        client.get_error_log = AsyncMock(return_value="")
        return client

    @pytest.fixture
    def registered_tools(self, mock_mcp, mock_client):
        """Register the bug report tools and return the mock MCP."""
        register_bug_report_tools(mock_mcp, mock_client)
        return mock_mcp

    @pytest.fixture
    def ha_report_issue_func(self, registered_tools):
        """Return the unwrapped ha_report_issue function."""
        ha_report_issue = registered_tools._tools["ha_report_issue"]
        actual_func = ha_report_issue
        while hasattr(actual_func, "__wrapped__"):
            actual_func = actual_func.__wrapped__
        return actual_func

    @pytest.mark.asyncio
    async def test_bug_report_success(self, registered_tools, mock_client):
        """Test successful bug report generation."""
        # Setup mock responses
        mock_client.get_config.return_value = {
            "version": "2024.12.0",
            "location_name": "Test Home",
            "time_zone": "America/New_York",
        }
        mock_client.get_states.return_value = [
            {"entity_id": f"light.light_{i}"} for i in range(150)
        ]

        # Get the registered tool (now called ha_report_issue)
        ha_report_issue = registered_tools._tools["ha_report_issue"]

        # Strip the decorator wrapper to get the actual function
        # The log_tool_usage decorator wraps the function
        actual_func = ha_report_issue
        while hasattr(actual_func, "__wrapped__"):
            actual_func = actual_func.__wrapped__

        # Call the tool with default parameters
        result = await actual_func()

        # Verify the result
        assert result["success"] is True
        assert "diagnostic_info" in result
        assert "formatted_report" in result
        assert "runtime_bug_template" in result
        assert "agent_behavior_template" in result
        assert "anonymization_guide" in result
        assert "recent_logs" in result
        assert "log_count" in result
        assert "instructions" in result
        assert "suggested_title" in result
        assert "duplicate_check_urls" in result

        # Check diagnostic info
        diag = result["diagnostic_info"]
        assert diag["ha_mcp_version"] == __version__
        assert diag["home_assistant_version"] == "2024.12.0"
        assert diag["connection_status"] == "Connected"
        assert diag["entity_count"] == 150
        assert diag["location_name"] == "Test Home"
        assert diag["time_zone"] == "America/New_York"

        # Check formatted report contains expected content
        report = result["formatted_report"]
        assert "=== ha-mcp Bug Report Info ===" in report
        assert "Home Assistant Version: 2024.12.0" in report
        assert f"ha-mcp Version: {__version__}" in report
        assert "Connection Status: Connected" in report
        assert "Entity Count: 150" in report

        # Check runtime bug template
        runtime_template = result["runtime_bug_template"]
        assert "## 🚨 Auto-Generated by `ha_report_issue` Tool" in runtime_template
        assert "📋 Bug Description" in runtime_template
        assert "🔄 Steps to Reproduce" in runtime_template
        assert "✅ Expected vs ❌ Actual Behavior" in runtime_template
        assert "🔧 Environment" in runtime_template
        assert (
            "https://github.com/homeassistant-ai/ha-mcp/issues/new?template=runtime_bug.yml"
            in runtime_template
        )

        # Check agent behavior template
        agent_template = result["agent_behavior_template"]
        assert "## 🤖 Auto-Generated by `ha_report_issue` Tool" in agent_template
        assert "🤖 What Did the AI Agent Do?" in agent_template
        assert "🎯 What Should the Agent Have Done?" in agent_template
        assert "🔧 Tool Calls Made" in agent_template
        assert (
            "https://github.com/homeassistant-ai/ha-mcp/issues/new?template=agent_behavior.yml"
            in agent_template
        )

        # Check anonymization guide
        anon_guide = result["anonymization_guide"]
        assert "## Anonymization Guide" in anon_guide
        assert "MUST ANONYMIZE" in anon_guide
        assert "CONSIDER ANONYMIZING" in anon_guide
        assert "KEEP AS-IS" in anon_guide

    @pytest.mark.asyncio
    async def test_bug_report_connection_error(self, registered_tools, mock_client):
        """Test bug report when connection fails."""
        # Setup mock to raise exception
        mock_client.get_config.side_effect = Exception("Connection refused")
        mock_client.get_states.side_effect = Exception("Connection refused")

        # Get the registered tool
        ha_report_issue = registered_tools._tools["ha_report_issue"]
        actual_func = ha_report_issue
        while hasattr(actual_func, "__wrapped__"):
            actual_func = actual_func.__wrapped__

        # Call the tool
        result = await actual_func()

        # Should still succeed but with error info
        assert result["success"] is True
        diag = result["diagnostic_info"]
        assert diag["ha_mcp_version"] == __version__
        assert "Connection Error" in diag["connection_status"]
        assert diag["home_assistant_version"] == "Unknown"
        assert diag["entity_count"] == 0

    @pytest.mark.asyncio
    async def test_bug_report_partial_failure(self, registered_tools, mock_client):
        """Test bug report when only some data is available."""
        # Config succeeds but states fails
        mock_client.get_config.return_value = {
            "version": "2024.12.0",
            "location_name": "Test Home",
        }
        mock_client.get_states.side_effect = Exception("States unavailable")

        # Get the registered tool
        ha_report_issue = registered_tools._tools["ha_report_issue"]
        actual_func = ha_report_issue
        while hasattr(actual_func, "__wrapped__"):
            actual_func = actual_func.__wrapped__

        # Call the tool
        result = await actual_func()

        # Should succeed with partial info
        assert result["success"] is True
        diag = result["diagnostic_info"]
        assert diag["connection_status"] == "Connected"
        assert diag["home_assistant_version"] == "2024.12.0"
        assert diag["entity_count"] == 0  # Failed to get states

    @pytest.mark.asyncio
    async def test_bug_report_issue_urls(self, registered_tools, mock_client):
        """Test that both template URLs are correctly included."""
        mock_client.get_config.return_value = {"version": "2024.12.0"}
        mock_client.get_states.return_value = []

        ha_report_issue = registered_tools._tools["ha_report_issue"]
        actual_func = ha_report_issue
        while hasattr(actual_func, "__wrapped__"):
            actual_func = actual_func.__wrapped__

        result = await actual_func()

        # Check both templates have correct URLs
        assert (
            "https://github.com/homeassistant-ai/ha-mcp/issues/new?template=runtime_bug.yml"
            in result["runtime_bug_template"]
        )
        assert (
            "https://github.com/homeassistant-ai/ha-mcp/issues/new?template=agent_behavior.yml"
            in result["agent_behavior_template"]
        )

    @pytest.mark.asyncio
    async def test_bug_report_no_timezone(self, registered_tools, mock_client):
        """Test bug report when timezone is not available."""
        mock_client.get_config.return_value = {
            "version": "2024.12.0",
            # No time_zone key
        }
        mock_client.get_states.return_value = []

        ha_report_issue = registered_tools._tools["ha_report_issue"]
        actual_func = ha_report_issue
        while hasattr(actual_func, "__wrapped__"):
            actual_func = actual_func.__wrapped__

        result = await actual_func()

        # Should still have time_zone with Unknown value
        assert result["success"] is True
        assert result["diagnostic_info"]["time_zone"] == "Unknown"

    @pytest.mark.asyncio
    async def test_bug_report_with_custom_tool_call_count(
        self, registered_tools, mock_client
    ):
        """Test bug report with custom tool_call_count parameter."""
        mock_client.get_config.return_value = {"version": "2024.12.0"}
        mock_client.get_states.return_value = []

        ha_report_issue = registered_tools._tools["ha_report_issue"]
        actual_func = ha_report_issue
        while hasattr(actual_func, "__wrapped__"):
            actual_func = actual_func.__wrapped__

        # Mock get_recent_logs to verify it's called with correct max_entries
        # Formula: AVG_LOG_ENTRIES_PER_TOOL (3) * 2 * tool_call_count
        with patch("ha_mcp.tools.tools_bug_report.get_recent_logs") as mock_get_logs:
            mock_get_logs.return_value = [
                {
                    "timestamp": "2024-12-01T10:00:00",
                    "tool_name": "ha_search",
                    "success": True,
                    "execution_time_ms": 150,
                },
                {
                    "timestamp": "2024-12-01T10:00:01",
                    "tool_name": "ha_call_service",
                    "success": False,
                    "execution_time_ms": 50,
                    "error_message": "Service not found",
                },
            ]

            # Call with tool_call_count=5
            result = await actual_func(tool_call_count=5)

            # Verify get_recent_logs was called with 3 * 4 * 5 = 60 (multiplier changed from 2x to 4x)
            mock_get_logs.assert_called_once_with(max_entries=60)

            # Verify logs are included in result
            assert result["log_count"] == 2
            assert len(result["recent_logs"]) == 2

    @pytest.mark.asyncio
    async def test_startup_logs_secret_path_redacted_in_output(
        self, registered_tools, mock_client
    ):
        """Regression (GHSA-mc92-ww4q-6fg4): ``ha_report_issue`` must scrub the
        connect-URL secret path from the returned ``startup_logs`` so it cannot
        leak into a pasted public report. The field stays present; only the
        tool's output copy is sanitized — the underlying logs are untouched."""
        mock_client.get_config.return_value = {"version": "2024.12.0"}
        mock_client.get_states.return_value = []

        ha_report_issue = registered_tools._tools["ha_report_issue"]
        actual_func = ha_report_issue
        while hasattr(actual_func, "__wrapped__"):
            actual_func = actual_func.__wrapped__

        with patch(
            "ha_mcp.tools.tools_bug_report.get_startup_logs"
        ) as mock_get_startup:
            mock_get_startup.return_value = [
                {
                    "timestamp": "2024-12-01T10:00:00",
                    "level": "INFO",
                    "logger": "FastMCP",
                    "message": (
                        "Starting MCP server 'ha-mcp' on "
                        "http://0.0.0.0:9583/private_T6tSJxqYuTESTtoken"
                    ),
                    "elapsed_seconds": 1.0,
                }
            ]
            result = await actual_func(tool_call_count=5)

        assert "startup_logs" in result
        message = result["startup_logs"][0]["message"]
        assert "/private_T6tSJxqYuTESTtoken" not in message
        assert "[REDACTED_SECRET_PATH]" in message
        # The same secret path must also be scrubbed from formatted_report,
        # which embeds the startup-log summary.
        assert "/private_T6tSJxqYuTESTtoken" not in result["formatted_report"]
        assert "[REDACTED_SECRET_PATH]" in result["formatted_report"]

    @pytest.mark.asyncio
    async def test_startup_logs_configured_secret_path_redacted_in_output(
        self, registered_tools, mock_client, monkeypatch
    ):
        """A custom configured ``MCP_SECRET_PATH`` is redacted end-to-end in
        ``ha_report_issue`` output — the realistic worst case, since a custom
        path has no ``/private_`` shape for the regex to fall back on."""
        monkeypatch.setenv("MCP_SECRET_PATH", "/customhook")
        mock_client.get_config.return_value = {"version": "2024.12.0"}
        mock_client.get_states.return_value = []

        ha_report_issue = registered_tools._tools["ha_report_issue"]
        actual_func = ha_report_issue
        while hasattr(actual_func, "__wrapped__"):
            actual_func = actual_func.__wrapped__

        with patch(
            "ha_mcp.tools.tools_bug_report.get_startup_logs"
        ) as mock_get_startup:
            mock_get_startup.return_value = [
                {
                    "timestamp": "2024-12-01T10:00:00",
                    "level": "INFO",
                    "logger": "FastMCP",
                    "message": "serving at http://ha.local:9583/customhook",
                    "elapsed_seconds": 1.0,
                }
            ]
            result = await actual_func(tool_call_count=5)

        message = result["startup_logs"][0]["message"]
        assert "/customhook" not in message
        assert "[REDACTED_SECRET_PATH]" in message

    @pytest.mark.asyncio
    async def test_startup_logs_source_not_mutated(self, registered_tools, mock_client):
        """The returned ``startup_logs`` is a sanitized copy; the underlying log
        record handed to the tool must not be mutated."""
        mock_client.get_config.return_value = {"version": "2024.12.0"}
        mock_client.get_states.return_value = []

        ha_report_issue = registered_tools._tools["ha_report_issue"]
        actual_func = ha_report_issue
        while hasattr(actual_func, "__wrapped__"):
            actual_func = actual_func.__wrapped__

        raw_message = "on http://0.0.0.0:9583/private_T6tSJxqYuTESTtoken"
        raw_entry = {
            "timestamp": "2024-12-01T10:00:00",
            "level": "INFO",
            "logger": "FastMCP",
            "message": raw_message,
            "elapsed_seconds": 1.0,
        }
        with patch(
            "ha_mcp.tools.tools_bug_report.get_startup_logs"
        ) as mock_get_startup:
            mock_get_startup.return_value = [raw_entry]
            result = await actual_func(tool_call_count=5)

        assert "[REDACTED_SECRET_PATH]" in result["startup_logs"][0]["message"]
        # The source record is untouched.
        assert raw_entry["message"] == raw_message

    def test_sanitize_log_text_redacts_generated_secret_path(self):
        """The generated ``/private_<token>`` connect path is redacted."""
        out = _sanitize_log_text(
            "on http://0.0.0.0:9583/private_T6tSJxqYuTESTtoken/mcp"
        )
        assert "/private_T6tSJxqYuTESTtoken" not in out
        assert "[REDACTED_SECRET_PATH]" in out

    def test_sanitize_log_text_redacts_configured_secret_path(self, monkeypatch):
        """A custom configured ``MCP_SECRET_PATH`` is redacted by value."""
        monkeypatch.setenv("MCP_SECRET_PATH", "/customhook")
        out = _sanitize_log_text("connect at http://ha.local:9583/customhook/mcp")
        assert "/customhook" not in out
        assert "[REDACTED_SECRET_PATH]" in out

    def test_sanitize_log_text_keeps_default_mcp_path(self, monkeypatch):
        """The low-entropy ``/mcp`` default is left intact (avoids noise)."""
        monkeypatch.delenv("MCP_SECRET_PATH", raising=False)
        out = _sanitize_log_text("on http://0.0.0.0:9583/mcp")
        assert "/mcp" in out

    def test_sanitize_log_text_short_configured_path_does_not_corrupt(
        self, monkeypatch
    ):
        """A short configured secret path must redact only its own path segment,
        not unrelated text that happens to share the prefix."""
        monkeypatch.setenv("MCP_SECRET_PATH", "/ha")
        out = _sanitize_log_text("Connecting to /happy/path via /ha")
        assert "/happy/path" in out
        assert out.count("[REDACTED_SECRET_PATH]") == 1

    @pytest.mark.asyncio
    async def test_bug_report_log_formatting(self, registered_tools, mock_client):
        """Test that logs are properly formatted in the report."""
        mock_client.get_config.return_value = {"version": "2024.12.0"}
        mock_client.get_states.return_value = []

        ha_report_issue = registered_tools._tools["ha_report_issue"]
        actual_func = ha_report_issue
        while hasattr(actual_func, "__wrapped__"):
            actual_func = actual_func.__wrapped__

        with patch("ha_mcp.tools.tools_bug_report.get_recent_logs") as mock_get_logs:
            mock_get_logs.return_value = [
                {
                    "timestamp": "2024-12-01T10:00:00.123456",
                    "tool_name": "ha_get_state",
                    "success": True,
                    "execution_time_ms": 100,
                },
                {
                    "timestamp": "2024-12-01T10:00:01.654321",
                    "tool_name": "ha_call_service",
                    "success": False,
                    "execution_time_ms": 50,
                    "error_message": "Entity not found",
                },
            ]

            result = await actual_func()

            # Check formatted_report includes log section
            report = result["formatted_report"]
            assert "=== Recent Tool Calls (2 entries) ===" in report
            assert "ha_get_state" in report
            assert "ha_call_service" in report
            assert "OK" in report
            assert "FAIL" in report

            # Check template includes logs
            template = result["runtime_bug_template"]
            assert "📊 Recent Tool Calls" in template
            assert "ha_get_state" in template

    @pytest.mark.asyncio
    async def test_bug_report_instructions(self, registered_tools, mock_client):
        """Test that instructions for the AI agent are included."""
        mock_client.get_config.return_value = {"version": "2024.12.0"}
        mock_client.get_states.return_value = []

        ha_report_issue = registered_tools._tools["ha_report_issue"]
        actual_func = ha_report_issue
        while hasattr(actual_func, "__wrapped__"):
            actual_func = actual_func.__wrapped__

        result = await actual_func()

        # Check instructions are present and useful
        instructions = result["instructions"]
        assert "RUNTIME_BUG_TEMPLATE" in instructions
        assert "AGENT_BEHAVIOR_TEMPLATE" in instructions
        assert "ANALYZE THE CONVERSATION" in instructions
        assert "anonymize" in instructions.lower()
        assert "personal information" in instructions.lower()

    @pytest.mark.asyncio
    async def test_bug_report_includes_core_error_log(
        self, ha_report_issue_func, mock_client
    ):
        """Issue #1694: the report surfaces home-assistant.log (the high-value
        auth/integration errors) in both the structured output and the runtime
        template — the gap that made #1694 a multi-round diagnosis."""
        mock_client.get_config.return_value = {"version": "2026.6.4"}
        mock_client.get_states.return_value = []
        mock_client.get_error_log.return_value = (
            "WARNING InsecureKeyLengthWarning: The HMAC key is 0 bytes long"
        )

        result = await ha_report_issue_func()

        assert "InsecureKeyLengthWarning" in result["core_error_log"]
        assert "InsecureKeyLengthWarning" in result["formatted_report"]
        assert "Home Assistant Error Log" in result["runtime_bug_template"]
        assert "InsecureKeyLengthWarning" in result["runtime_bug_template"]

    @pytest.mark.asyncio
    async def test_bug_report_omits_empty_core_error_log(
        self, ha_report_issue_func, mock_client
    ):
        """When get_error_log returns empty, the 'Home Assistant Error Log'
        section is omitted (the `if core_error_log:` gates) rather than left as
        a dangling empty header, and core_error_log is an empty string."""
        mock_client.get_config.return_value = {"version": "2026.6.4"}
        mock_client.get_states.return_value = []
        mock_client.get_error_log.return_value = ""

        result = await ha_report_issue_func()

        assert result["core_error_log"] == ""
        assert "Home Assistant Error Log" not in result["formatted_report"]
        assert "Home Assistant Error Log" not in result["runtime_bug_template"]

    @pytest.mark.asyncio
    async def test_bug_report_suggested_title(self, ha_report_issue_func, mock_client):
        """Test that a suggested title is generated."""
        mock_client.get_config.return_value = {"version": "2024.12.0"}
        mock_client.get_states.return_value = []

        with patch("ha_mcp.tools.tools_bug_report.get_recent_logs") as mock_get_logs:
            mock_get_logs.return_value = [
                {
                    "timestamp": "2024-12-01T10:00:00",
                    "tool_name": "ha_call_service",
                    "success": False,
                    "execution_time_ms": 50,
                    "error_message": "Service not found",
                },
            ]

            result = await ha_report_issue_func()

            # Check suggested title is present and has correct format
            assert "suggested_title" in result
            title = result["suggested_title"]
            assert isinstance(title, str)
            assert len(title) > 0
            assert len(title) <= 60
            # Should be exactly: "tool_name: error_message"
            assert title == "ha_call_service: Service not found"

    @pytest.mark.asyncio
    async def test_bug_report_suggested_title_no_errors(
        self, ha_report_issue_func, mock_client
    ):
        """Test title generation when there are no errors."""
        mock_client.get_config.return_value = {"version": "2024.12.0"}
        mock_client.get_states.return_value = []

        with patch("ha_mcp.tools.tools_bug_report.get_recent_logs") as mock_get_logs:
            mock_get_logs.return_value = [
                {
                    "timestamp": "2024-12-01T10:00:00",
                    "tool_name": "ha_get_state",
                    "success": True,
                    "execution_time_ms": 100,
                },
            ]

            result = await ha_report_issue_func()

            # Should still generate a generic title
            assert "suggested_title" in result
            title = result["suggested_title"]
            assert isinstance(title, str)
            assert len(title) > 0
            assert len(title) <= 60

    @pytest.mark.asyncio
    async def test_bug_report_duplicate_check_urls(
        self, ha_report_issue_func, mock_client
    ):
        """Test that duplicate check URLs are generated with correct keywords."""
        mock_client.get_config.return_value = {"version": "2024.12.0"}
        mock_client.get_states.return_value = []

        with patch("ha_mcp.tools.tools_bug_report.get_recent_logs") as mock_get_logs:
            mock_get_logs.return_value = [
                {
                    "timestamp": "2024-12-01T10:00:00",
                    "tool_name": "ha_call_service",
                    "success": False,
                    "execution_time_ms": 50,
                    "error_message": "Connection timeout",
                },
            ]

            result = await ha_report_issue_func()

            # Check duplicate check URLs are present
            assert "duplicate_check_urls" in result
            urls = result["duplicate_check_urls"]
            assert isinstance(urls, list)
            assert len(urls) > 0
            # All URLs should be GitHub issue search URLs
            for url in urls:
                assert "github.com/homeassistant-ai/ha-mcp/issues" in url
                assert "is%3Aissue" in url

            # Verify keywords are in URLs
            url_content = "".join(urls)
            assert "ha_call_service" in url_content
            assert "connection" in url_content
            assert "timeout" in url_content

    @pytest.mark.asyncio
    async def test_bug_report_updated_instructions(
        self, ha_report_issue_func, mock_client
    ):
        """Test that instructions include new workflow steps."""
        mock_client.get_config.return_value = {"version": "2024.12.0"}
        mock_client.get_states.return_value = []

        result = await ha_report_issue_func()

        instructions = result["instructions"]
        # Check for new workflow steps
        assert "Check for duplicates FIRST" in instructions
        assert "duplicate_check_urls" in instructions
        assert "suggested_title" in instructions
        assert "markdown code block" in instructions.lower()
        assert "```markdown" in instructions
        assert "PROMINENTLY display the submission URL" in instructions

    @pytest.mark.asyncio
    async def test_bug_report_addon_logs_included_for_addon(
        self, registered_tools, mock_client
    ):
        """Test that addon logs are fetched and included when running as addon."""
        mock_client.get_config.return_value = {"version": "2024.12.0"}
        mock_client.get_states.return_value = []

        ha_report_issue = registered_tools._tools["ha_report_issue"]
        actual_func = ha_report_issue
        while hasattr(actual_func, "__wrapped__"):
            actual_func = actual_func.__wrapped__

        fake_logs = "2024-12-01 10:00:00 ERROR ha_mcp.server: Something broke\n"

        with (
            patch(
                "ha_mcp.tools.tools_bug_report._detect_installation_method",
                return_value="addon",
            ),
            patch(
                "ha_mcp.tools.tools_bug_report._fetch_addon_logs",
                return_value=fake_logs,
            ),
        ):
            result = await actual_func()

        assert result["addon_logs"] == fake_logs
        assert "Add-on Container Logs" in result["formatted_report"]
        assert "Add-on Container Logs" in result["runtime_bug_template"]

    @pytest.mark.asyncio
    async def test_bug_report_no_addon_logs_for_non_addon(
        self, registered_tools, mock_client
    ):
        """Test that addon logs are NOT fetched for non-addon installs."""
        mock_client.get_config.return_value = {"version": "2024.12.0"}
        mock_client.get_states.return_value = []

        ha_report_issue = registered_tools._tools["ha_report_issue"]
        actual_func = ha_report_issue
        while hasattr(actual_func, "__wrapped__"):
            actual_func = actual_func.__wrapped__

        with patch(
            "ha_mcp.tools.tools_bug_report._detect_installation_method",
            return_value="docker",
        ):
            result = await actual_func()

        assert result["addon_logs"] == ""
        assert "Add-on Container Logs" not in result["formatted_report"]
        assert "Add-on Container Logs" not in result["runtime_bug_template"]


class TestFetchCoreErrorLog:
    """`_fetch_core_error_log` captures home-assistant.log over REST, scrubs
    secrets, truncates to the recent tail, and never raises (issue #1694)."""

    @pytest.mark.asyncio
    async def test_scrubs_secrets_keeps_diagnostics(self):
        """Secrets that can appear in home-assistant.log are scrubbed via the
        same _sanitize_log_text the add-on-log path uses, while the diagnostic
        lines survive."""
        client = MagicMock()
        client.get_error_log = AsyncMock(
            return_value=(
                "invalid authentication from localhost\n"
                "InsecureKeyLengthWarning: The HMAC key is 0 bytes long\n"
                "Bearer eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJhYmMifQ.sIgNature_xyz123\n"
                "token=deadbeefdeadbeefdeadbeefdeadbeefdeadbeef\n"
                "client_secret: kX9pQ4mZ2vL8nR3sT6uW1yA5cB7dF0gH\n"
                "http://user:hunter2@ha:8123/private_axAloe89imJfMt8RgkwuVA\n"
            )
        )
        out = await _fetch_core_error_log(client)
        # Diagnostic lines kept.
        assert "invalid authentication" in out
        assert "InsecureKeyLengthWarning" in out
        # Every secret shape scrubbed.
        for secret in (
            "eyJhbGciOiJIUzI1NiJ9",
            "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
            "kX9pQ4mZ2vL8nR3sT6uW1yA5cB7dF0gH",
            "hunter2",
            "axAloe89imJfMt8RgkwuVA",
        ):
            assert secret not in out, f"leaked: {secret}"

    @pytest.mark.asyncio
    async def test_truncates_to_recent_tail(self):
        from ha_mcp.tools.tools_bug_report import _CORE_LOG_MAX_CHARS

        client = MagicMock()
        client.get_error_log = AsyncMock(
            return_value="x" * (_CORE_LOG_MAX_CHARS + 4000) + "TAIL_MARKER\n"
        )
        out = await _fetch_core_error_log(client)
        assert "TAIL_MARKER" in out  # keeps the most recent lines
        assert "truncated" in out
        assert len(out) <= _CORE_LOG_MAX_CHARS + 200  # only the marker overhead

    @pytest.mark.asyncio
    async def test_empty_when_log_empty(self):
        client = MagicMock()
        client.get_error_log = AsyncMock(return_value="")
        assert await _fetch_core_error_log(client) == ""

    @pytest.mark.asyncio
    async def test_best_effort_on_exception(self):
        """A 403 (role too low), auth, or transport error must yield "" rather
        than break the bug-report path."""
        client = MagicMock()
        client.get_error_log = AsyncMock(side_effect=RuntimeError("boom"))
        assert await _fetch_core_error_log(client) == ""


class TestSanitizeLogText:
    """Tests for _sanitize_log_text."""

    def test_redacts_jwt_tokens(self):
        text = "auth: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U"
        result = _sanitize_log_text(text)
        assert "eyJ" not in result
        assert "[REDACTED_JWT]" in result

    def test_redacts_bearer_tokens(self):
        text = "Authorization: Bearer abc123secrettoken"
        result = _sanitize_log_text(text)
        assert "abc123" not in result
        assert "Bearer [REDACTED]" in result

    def test_redacts_long_hex_strings(self):
        # Bare hex (no leading "token:" — that path is exercised by the
        # key=value rule below) gets the dedicated hex marker.
        text = f"raw: {'a1b2c3d4' * 5}"  # 40-char hex string
        result = _sanitize_log_text(text)
        assert "a1b2c3d4" not in result
        assert "[REDACTED_HEX]" in result

    def test_redacts_ipv4_with_port(self):
        text = "Connected to 192.168.1.100:8123"
        result = _sanitize_log_text(text)
        assert "192.168.1.100" not in result
        assert "[IP]" in result

    def test_redacts_ipv4_in_url(self):
        text = "fetched https://192.168.1.1/api/states"
        result = _sanitize_log_text(text)
        assert "192.168.1.1" not in result
        assert "[IP]" in result

    def test_redacts_ipv4_after_keyword(self):
        text = "host=10.0.0.5 connecting"
        result = _sanitize_log_text(text)
        assert "10.0.0.5" not in result
        assert "[IP]" in result

    def test_preserves_four_segment_version_strings(self):
        # Regression: bare four-segment values without network context (e.g.
        # version banners) were being mangled by the IPv4 rule.
        text = "ha-mcp version 1.2.3.4 starting"
        result = _sanitize_log_text(text)
        assert result == text

    def test_redacts_key_value_credentials(self):
        cases = [
            ("OPENAI_API_KEY=sk-proj-AbCdEf1234567890qwerty", "sk-proj-AbCdEf"),
            ("token=ghp_AbCdEf1234567890qwerty1234567890qwer", "ghp_AbCdEf"),
            ("password=hunter2-S3cret!", "hunter2"),
            ("api_key: somekey1234", "somekey1234"),
        ]
        for text, secret in cases:
            result = _sanitize_log_text(text)
            assert secret not in result, f"{secret!r} leaked in {result!r}"
            assert "[REDACTED]" in result

    def test_redacts_url_userinfo(self):
        cases = [
            ("https://admin:supersecret@homeassistant.local/api", "supersecret"),
            ("mqtt://user:pass@broker.local:1883", "user:pass"),
        ]
        for text, secret in cases:
            result = _sanitize_log_text(text)
            assert secret not in result, f"{secret!r} leaked in {result!r}"
            assert "[REDACTED]" in result

    def test_bearer_preserves_casing(self):
        # All casings get redacted via re.IGNORECASE; the lambda echoes
        # m.group(1) so the original casing is preserved in the output.
        cases = [
            ("Authorization: Bearer abc123token", "Bearer [REDACTED]"),
            ("auth: bearer abc123token", "bearer [REDACTED]"),
            ("HDR: BEARER abc123token", "BEARER [REDACTED]"),
            ("hdr: BeArEr abc123token", "BeArEr [REDACTED]"),
        ]
        for text, expected in cases:
            result = _sanitize_log_text(text)
            assert "abc123" not in result, f"abc123 leaked in {result!r}"
            assert expected in result, f"missing {expected!r} in {result!r}"

    def test_handles_multiple_secrets_in_one_line(self):
        text = "token=abc123 connected to 192.168.1.5 with bearer xyz789"
        result = _sanitize_log_text(text)
        assert "abc123" not in result
        assert "xyz789" not in result
        assert "192.168.1.5" not in result
        assert "[REDACTED]" in result
        assert "[IP]" in result

    def test_preserves_normal_text(self):
        text = "2024-12-01 10:00:00 ERROR ha_mcp.server: Entity not found"
        result = _sanitize_log_text(text)
        assert result == text


class TestFetchAddonLogs:
    """Tests for _fetch_addon_logs."""

    @pytest.fixture
    def _no_supervisor_token(self, monkeypatch):
        """Remove SUPERVISOR_TOKEN without clobbering the rest of os.environ."""
        monkeypatch.delenv("SUPERVISOR_TOKEN", raising=False)

    @pytest.mark.asyncio
    async def test_returns_empty_without_supervisor_token(self, _no_supervisor_token):
        result = await _fetch_addon_logs()
        assert result == ""

    @pytest.mark.asyncio
    async def test_returns_empty_when_supervisor_token_is_empty(self, monkeypatch):
        # Empty-string token is treated the same as missing.
        monkeypatch.setenv("SUPERVISOR_TOKEN", "")
        result = await _fetch_addon_logs()
        assert result == ""

    @pytest.mark.asyncio
    async def test_fetches_and_sanitizes_logs(self, monkeypatch):
        fake_response = httpx.Response(
            200,
            text="INFO ha_mcp: connected to 192.168.1.50\n\x1b[31mERROR\x1b[0m: fail",
        )

        monkeypatch.setenv("SUPERVISOR_TOKEN", "test-token")
        with patch("httpx.AsyncClient.get", return_value=fake_response):
            result = await _fetch_addon_logs()

        # ANSI codes stripped
        assert "\x1b[" not in result
        # IPs sanitized
        assert "192.168.1.50" not in result
        assert "[IP]" in result
        # Content preserved
        assert "ha_mcp" in result

    @pytest.mark.asyncio
    async def test_truncates_long_logs_with_marker(self, monkeypatch):
        # Use a long, sanitizer-clean payload so character math is exact.
        long_log = "x" * 5000
        fake_response = httpx.Response(200, text=long_log)

        monkeypatch.setenv("SUPERVISOR_TOKEN", "test-token")
        with patch("httpx.AsyncClient.get", return_value=fake_response):
            result = await _fetch_addon_logs()

        # Marker prepended, then last 3000 chars of the sanitized payload.
        assert result.startswith("[...truncated, showing last 3000 of 5000 chars...]\n")
        assert result.endswith("x" * 3000)
        assert "x" * 3001 not in result  # exactly 3000 chars after marker

    @pytest.mark.asyncio
    async def test_short_logs_not_marked_or_truncated(self, monkeypatch):
        # Logs shorter than the cap should be returned verbatim, no marker.
        short_log = "INFO ha_mcp: started cleanly\n"
        fake_response = httpx.Response(200, text=short_log)

        monkeypatch.setenv("SUPERVISOR_TOKEN", "test-token")
        with patch("httpx.AsyncClient.get", return_value=fake_response):
            result = await _fetch_addon_logs()

        assert result == short_log
        assert "truncated" not in result

    @pytest.mark.asyncio
    async def test_jwt_split_at_truncation_boundary_is_redacted(self, monkeypatch):
        # G1 regression: position the JWT so the truncation cut slices THROUGH
        # it — the eyJ prefix falls in the dropped region, the signature falls
        # in the kept region. Under the old truncate-then-sanitize order, the
        # surviving JWT tail (no eyJ prefix) would not match the JWT regex and
        # the signature characters would leak. Under the fixed sanitize-then-
        # truncate order, the entire JWT is replaced with [REDACTED_JWT]
        # before the cut, so nothing from the JWT survives.
        from ha_mcp.tools.tools_bug_report import _ADDON_LOG_MAX_CHARS

        jwt = (
            "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9."
            "eyJzdWIiOiIxMjM0NTY3ODkwIn0."
            "SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
        )
        # Place the JWT so cut_point lands 50 chars inside it (well past `eyJ`,
        # well before `SflKx`).
        prefix_len = 100
        jwt_chars_to_drop = 50
        total_len = prefix_len + jwt_chars_to_drop + _ADDON_LOG_MAX_CHARS
        suffix_len = total_len - prefix_len - len(jwt)
        assert suffix_len > 0, "suffix_len math is off"
        # Sanity-check the placement: the buggy order really would leak SflKx.
        assert "SflKx" in jwt[jwt_chars_to_drop:]
        text = "x" * prefix_len + jwt + "x" * suffix_len
        fake_response = httpx.Response(200, text=text)

        monkeypatch.setenv("SUPERVISOR_TOKEN", "test-token")
        with patch("httpx.AsyncClient.get", return_value=fake_response):
            result = await _fetch_addon_logs()

        # Pre-fix code (truncate-then-sanitize) would leave SflKx in the tail.
        assert "SflKx" not in result
        # Confirm the marker survived the cut (positive proof sanitization
        # actually ran before truncation, not just that SflKx happened to be
        # truncated away).
        assert "[REDACTED_JWT]" in result

    @pytest.mark.asyncio
    async def test_returns_empty_on_http_error_with_log(self, monkeypatch, caplog):
        fake_response = httpx.Response(403, text="Forbidden")

        monkeypatch.setenv("SUPERVISOR_TOKEN", "test-token")
        with (
            patch("httpx.AsyncClient.get", return_value=fake_response),
            caplog.at_level("INFO", logger="ha_mcp.tools.tools_bug_report"),
        ):
            result = await _fetch_addon_logs()

        assert result == ""
        # G6: status code is logged so users can distinguish 4xx/5xx from
        # "Supervisor unreachable" / "not running as add-on".
        assert any("403" in rec.getMessage() for rec in caplog.records)

    @pytest.mark.asyncio
    async def test_returns_empty_on_network_error(self, monkeypatch):
        monkeypatch.setenv("SUPERVISOR_TOKEN", "test-token")
        with patch(
            "httpx.AsyncClient.get",
            side_effect=httpx.ConnectError("Connection refused"),
        ):
            result = await _fetch_addon_logs()

        assert result == ""

    @pytest.mark.asyncio
    async def test_passes_relative_url_with_ctor_authorization(self, monkeypatch):
        """Post-refactor wire shape: relative path on ``.get()``, with
        ``Authorization`` assembled in the constructor kwargs rather than
        per-call. Mirrors the parallel assertions in
        ``test_tools_utility_supervisor_logs.py`` so a regression that
        hard-codes the absolute URL or moves the Bearer header back to
        per-call kwargs is caught here, not just on the rest-client branch.
        """
        monkeypatch.setenv("SUPERVISOR_TOKEN", "test-token-abc")

        inner_client = MagicMock()
        fake_response = httpx.Response(200, text="addon log line\n")
        inner_client.get = AsyncMock(return_value=fake_response)
        cm = MagicMock()
        cm.__aenter__ = AsyncMock(return_value=inner_client)
        cm.__aexit__ = AsyncMock(return_value=None)

        client_class = MagicMock(return_value=cm)
        with patch("httpx.AsyncClient", client_class):
            await _fetch_addon_logs()

        inner_client.get.assert_awaited_once()
        args, kwargs = inner_client.get.call_args
        assert args[0] == "/addons/self/logs"
        assert "Authorization" not in kwargs.get("headers", {})

        ctor_kwargs = client_class.call_args.kwargs
        assert ctor_kwargs["base_url"] == "http://supervisor"
        assert ctor_kwargs["headers"]["Authorization"] == "Bearer test-token-abc"


# Shared JWT fixture for the format-helper boundary tests below. 108 chars.
_JWT_SAMPLE = (
    "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9."
    "eyJzdWIiOiIxMjM0NTY3ODkwIn0."
    "SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
)


class TestFormatLogsForReportSanitization:
    """Boy-scout-2 regression: sanitize-before-truncate for error_message."""

    def test_jwt_split_at_100char_error_truncation_is_redacted(self):
        # _format_logs_for_report truncates each error_message to the first
        # 100 chars after sanitization. Position the JWT so the cut would
        # land mid-JWT under the buggy truncate-then-sanitize order; the
        # surviving JWT prefix wouldn't match the JWT regex (no signature)
        # and would leak `eyJhbG…` into the report.
        prefix_len = 50
        # Cut at offset 100 = prefix_len (50) + jwt_chars_to_drop (50).
        # JWT (108 chars) extends past the cut into the truncated region.
        error = "x" * prefix_len + _JWT_SAMPLE + "x" * 200
        logs = [
            {
                "timestamp": "2024-12-01T10:00:00",
                "tool_name": "ha_test",
                "success": False,
                "execution_time_ms": 50,
                "error_message": error,
            }
        ]

        formatted = _format_logs_for_report(logs)

        # No JWT fragment leaks (the buggy order would leave eyJhbG... in the
        # truncated tail because the regex requires the full 3-part JWT shape).
        assert "eyJhbG" not in formatted
        # Positive proof: sanitization ran first, so the marker survives the
        # 100-char cut.
        assert "[REDACTED_JWT]" in formatted


class TestFormatStartupLogsSanitization:
    """Boy-scout-2 regression: sanitize-before-truncate for startup messages."""

    def test_jwt_split_at_200char_message_truncation_is_redacted(self):
        # _format_startup_logs truncates message to 200 chars after sanitize.
        # Same shape as the _format_logs_for_report test, but with the JWT
        # placed across the 200-char boundary.
        prefix_len = 150
        # Cut at offset 200 = prefix_len (150) + jwt_chars_to_drop (50).
        message = "x" * prefix_len + _JWT_SAMPLE + "x" * 200
        logs = [
            {
                "elapsed_seconds": 1.23,
                "level": "INFO",
                "logger": "ha_mcp.startup",
                "message": message,
            }
        ]

        formatted = _format_startup_logs(logs)

        assert "eyJhbG" not in formatted
        assert "[REDACTED_JWT]" in formatted


class TestGetConfigToggles:
    """Tests for _get_config_toggles."""

    def test_collects_known_toggles_from_settings(self):
        # Use SimpleNamespace as a stand-in for Settings to avoid touching the
        # real env-driven singleton. Only fields present on the namespace are
        # collected; missing fields fall through (None).
        fake = SimpleNamespace(
            enable_websocket=True,
            enable_dashboard_partial_tools=True,
            enable_tool_search=False,
            tool_search_max_results=5,
            enable_yaml_config_editing=False,
            enable_code_mode=False,
            enabled_tool_modules="all",
            disabled_tools="ha_foo,ha_bar",
            pinned_tools="",
        )
        toggles = _get_config_toggles(fake)
        assert toggles["enable_tool_search"] is False
        assert toggles["tool_search_max_results"] == 5
        assert toggles["enabled_tool_modules"] == "all"
        # Lists are summarized, not dumped — count of comma-separated entries.
        assert toggles["disabled_tools_count"] == 2
        assert toggles["pinned_tools_count"] == 0

    def test_returns_empty_dict_when_settings_construction_fails(self):
        with patch(
            "ha_mcp.tools.tools_bug_report.get_global_settings",
            side_effect=RuntimeError("env missing"),
        ):
            assert _get_config_toggles() == {}


class TestDetectMcpTransport:
    """Tests for _detect_mcp_transport."""

    @pytest.fixture(autouse=True)
    def _clear_transport_env(self, monkeypatch):
        """Drop any env vars that could otherwise leak between tests."""
        for var in (
            "FASTMCP_TRANSPORT",
            "MCP_HTTP_PORT",
            "FASTMCP_PORT",
            "SUPERVISOR_TOKEN",
        ):
            monkeypatch.delenv(var, raising=False)

    def test_http_argv0(self, monkeypatch):
        monkeypatch.setattr("sys.argv", ["/usr/bin/ha-mcp-web"])
        assert _detect_mcp_transport() == "http"

    def test_sse_argv0(self, monkeypatch):
        monkeypatch.setattr("sys.argv", ["/usr/bin/ha-mcp-sse"])
        assert _detect_mcp_transport() == "sse"

    def test_argv0_wins_over_env(self, monkeypatch):
        # Real precedence test: argv0 ending in -web returns "http" early,
        # before the FASTMCP_TRANSPORT env check would otherwise force "stdio".
        monkeypatch.setattr("sys.argv", ["/usr/bin/ha-mcp-web"])
        monkeypatch.setenv("FASTMCP_TRANSPORT", "stdio")
        assert _detect_mcp_transport() == "http"

    def test_env_picks_up_when_argv0_is_neutral(self, monkeypatch):
        monkeypatch.setattr("sys.argv", ["/usr/bin/ha-mcp"])
        monkeypatch.setenv("FASTMCP_TRANSPORT", "http")
        assert _detect_mcp_transport() == "http"

    def test_env_streamable_http_collapses_to_http(self, monkeypatch):
        # FastMCP documents "streamable-http" as a valid transport literal.
        # We collapse it to "http" — the distinction doesn't change triage.
        monkeypatch.setattr("sys.argv", ["/usr/bin/ha-mcp"])
        monkeypatch.setenv("FASTMCP_TRANSPORT", "streamable-http")
        assert _detect_mcp_transport() == "http"

    def test_env_port_implies_http(self, monkeypatch):
        monkeypatch.setattr("sys.argv", ["/usr/bin/ha-mcp"])
        monkeypatch.setenv("MCP_HTTP_PORT", "8086")
        assert _detect_mcp_transport() == "http"

    def test_fastmcp_port_implies_http(self, monkeypatch):
        # Cover the second half of the port-env clause that previously had no test.
        monkeypatch.setattr("sys.argv", ["/usr/bin/ha-mcp"])
        monkeypatch.setenv("FASTMCP_PORT", "8086")
        assert _detect_mcp_transport() == "http"

    def test_piped_stdin_is_stdio(self, monkeypatch):
        # The most common production deployment shape (Claude Desktop launching
        # ha-mcp via stdio pipe). Without this test the stdio branch is dead
        # from the suite — a regression there would silently mislabel every
        # Desktop bug report's transport as "unknown".
        monkeypatch.setattr("sys.argv", ["/usr/bin/ha-mcp"])
        fake_stdin = SimpleNamespace(isatty=lambda: False)
        monkeypatch.setattr("sys.stdin", fake_stdin)
        assert _detect_mcp_transport() == "stdio"

    def test_tty_stdin_with_no_hints_returns_unknown(self, monkeypatch):
        # Manual / interactive run with no transport hints whatsoever.
        monkeypatch.setattr("sys.argv", ["/usr/bin/ha-mcp"])
        fake_stdin = SimpleNamespace(isatty=lambda: True)
        monkeypatch.setattr("sys.stdin", fake_stdin)
        assert _detect_mcp_transport() == "unknown"

    def test_detached_stdin_falls_through_to_unknown(self, monkeypatch):
        # ``sys.stdin.isatty()`` can raise OSError when stdin is detached
        # (pythonw, daemonized contexts). The except clause must not let
        # the helper crash.
        monkeypatch.setattr("sys.argv", ["/usr/bin/ha-mcp"])

        def boom():
            raise OSError("stdin closed")

        monkeypatch.setattr("sys.stdin", SimpleNamespace(isatty=boom))
        assert _detect_mcp_transport() == "unknown"

    def test_addon_short_circuits_to_http(self, monkeypatch):
        # Regression for #1322: addon launches via `python start.py` (no -web
        # argv0, no FASTMCP_TRANSPORT env) inside a TTY-less Supervisor
        # container, so the stdin-isatty fallback used to mislabel addon
        # installs as "stdio". The addon entrypoint always invokes
        # mcp.run(transport="http", ...), so short-circuit on SUPERVISOR_TOKEN.
        monkeypatch.setattr("sys.argv", ["/usr/bin/python", "start.py"])
        monkeypatch.setenv("SUPERVISOR_TOKEN", "fake-supervisor-token")
        fake_stdin = SimpleNamespace(isatty=lambda: False)
        monkeypatch.setattr("sys.stdin", fake_stdin)
        assert _detect_mcp_transport() == "http"


class TestFormatConfigTogglesForTemplate:
    """Tests for _format_config_toggles_for_template."""

    def test_empty_returns_placeholder(self):
        assert (
            _format_config_toggles_for_template({}) == "_(config toggles unavailable)_"
        )

    def test_renders_bullets_with_value(self):
        rendered = _format_config_toggles_for_template(
            {"enable_tool_search": False, "tool_search_max_results": 5}
        )
        assert "- **enable_tool_search:** `False`" in rendered
        assert "- **tool_search_max_results:** `5`" in rendered


class TestExtractClientInfo:
    """Tests for _extract_client_info — pulls clientInfo off the MCP context."""

    def test_returns_empty_when_ctx_is_none(self):
        # Tool can be invoked outside an MCP request (unit tests, scripts).
        # The bug-report path must not blow up — empty dict is the contract.
        assert _extract_client_info(None) == {}

    def test_returns_empty_when_session_has_no_client_params(self):
        # Some transports / direct callers won't have completed an initialize
        # handshake yet. _extract_client_info should treat that as "no info".
        ctx = SimpleNamespace(session=SimpleNamespace(client_params=None))
        assert _extract_client_info(ctx) == {}

    def test_reads_name_version_title_off_initialize_params(self):
        # Mirrors the shape of mcp.types.InitializeRequestParams.clientInfo
        # (an Implementation: name + version, optional title) without pulling
        # the real types into the test.
        client_info_obj = SimpleNamespace(
            name="Claude Desktop",
            version="0.7.42",
            title="Claude Desktop (claude.ai integration)",
        )
        ctx = SimpleNamespace(
            session=SimpleNamespace(
                client_params=SimpleNamespace(clientInfo=client_info_obj)
            )
        )
        info = _extract_client_info(ctx)
        assert info == {
            "name": "Claude Desktop",
            "version": "0.7.42",
            "title": "Claude Desktop (claude.ai integration)",
        }

    def test_missing_optional_title_falls_back_to_empty_string(self):
        client_info_obj = SimpleNamespace(name="Cursor", version="0.42", title=None)
        ctx = SimpleNamespace(
            session=SimpleNamespace(
                client_params=SimpleNamespace(clientInfo=client_info_obj)
            )
        )
        info = _extract_client_info(ctx)
        assert info["name"] == "Cursor"
        assert info["version"] == "0.42"
        assert info["title"] == ""

    def test_missing_version_falls_back_to_unknown(self):
        # Pin the docstring promise: name/version default to "unknown" when
        # the client didn't send them. If the future SDK refactor stops
        # sending `version`, the f"{name} {version}" downstream rendering
        # must not produce "Cursor None".
        client_info_obj = SimpleNamespace(name="Cursor", version=None, title=None)
        ctx = SimpleNamespace(
            session=SimpleNamespace(
                client_params=SimpleNamespace(clientInfo=client_info_obj)
            )
        )
        info = _extract_client_info(ctx)
        assert info == {"name": "Cursor", "version": "unknown", "title": ""}

    def test_snake_case_client_info_attribute_is_picked_up(self):
        # ha-mcp pins mcp 1.24.x where the attribute is `clientInfo`. Future
        # SDK versions may switch to `client_info` (snake_case). The helper
        # falls back to the snake_case attribute name so we keep working
        # across the rename.
        client_info_obj = SimpleNamespace(
            name="Future Client", version="9.9.9", title=None
        )
        ctx = SimpleNamespace(
            session=SimpleNamespace(
                client_params=SimpleNamespace(client_info=client_info_obj)
            )
        )
        info = _extract_client_info(ctx)
        assert info == {"name": "Future Client", "version": "9.9.9", "title": ""}

    def test_swallows_exceptions_and_returns_empty(self):
        # If the context shape is unexpected (future MCP / FastMCP API churn),
        # we'd rather degrade silently than fail the bug report.
        class Boom:
            # Use a property (not __getattr__) so the raise is a normal
            # attribute access, not a special-method protocol violation.
            # _extract_client_info reads ``ctx.session`` first, so raising
            # here drives it into the broad ``except Exception`` swallow path.
            @property
            def session(self):
                raise RuntimeError("unexpected ctx shape")

        assert _extract_client_info(Boom()) == {}


class TestFormatClientInfoForTemplate:
    """Tests for _format_client_info_for_template."""

    def test_empty_renders_unknown_placeholder(self):
        rendered = _format_client_info_for_template({})
        assert "unknown" in rendered
        # Phrasing describes the observable, not the underlying API field name,
        # so the message stays accurate if MCP renames the attribute.
        assert "did not advertise" in rendered

    def test_name_and_version_render_as_single_line(self):
        rendered = _format_client_info_for_template(
            {"name": "Claude Desktop", "version": "0.7.42", "title": ""}
        )
        assert rendered == "Claude Desktop 0.7.42"

    def test_distinct_title_appears_as_aside(self):
        rendered = _format_client_info_for_template(
            {
                "name": "ClaudeDesktop",
                "version": "0.7.42",
                "title": "Claude Desktop (claude.ai integration)",
            }
        )
        assert "ClaudeDesktop 0.7.42" in rendered
        assert "Claude Desktop (claude.ai integration)" in rendered

    def test_title_matching_name_is_not_repeated(self):
        # Many clients send title == name; don't print the same string twice.
        rendered = _format_client_info_for_template(
            {"name": "Cursor", "version": "0.42", "title": "Cursor"}
        )
        assert rendered == "Cursor 0.42"


class TestBugReportNewIdentityFields:
    """End-to-end checks that the new self-reported placeholders + auto fields
    land in both templates and the response payload."""

    @pytest.fixture
    def mock_client(self):
        client = MagicMock()
        client.get_config = AsyncMock(return_value={"version": "2024.12.0"})
        client.get_states = AsyncMock(return_value=[])
        return client

    @pytest.fixture
    def ha_report_issue_func(self, mock_mcp, mock_client):
        register_bug_report_tools(mock_mcp, mock_client)
        func = mock_mcp._tools["ha_report_issue"]
        while hasattr(func, "__wrapped__"):
            func = func.__wrapped__
        return func

    @pytest.mark.asyncio
    async def test_self_reported_placeholders_in_runtime_template(
        self, ha_report_issue_func
    ):
        result = await ha_report_issue_func()
        runtime = result["runtime_bug_template"]
        # All three self-report fields and the auto-detected transport row
        # are present, with the "fill in" sentinel still visible — this is
        # what the agent populates before showing it to the user.
        # AI Model is collected, but as a bare label with no placeholder
        # examples to bias the agent — the instructions block straight-up
        # asks the agent to write its identity. Old multi-axis placeholders
        # (Model family / Model version / Client application) are gone.
        assert "**AI Model:**" in runtime
        assert "**Model family:**" not in runtime
        assert "**Model version:**" not in runtime
        assert "**Client application:**" not in runtime
        # No vendor enumeration in the template body — that's exactly the
        # hallucination-bait we removed.
        for vendor in ("Claude, Gemini", "Gemini, GPT", "GPT, Llama"):
            assert vendor not in runtime
        assert "**MCP Client:**" in runtime
        assert "**MCP Transport:**" in runtime
        assert "<fill in" in runtime
        # Triggering prompt + tool call section is present and asks for the
        # exact user message + tool call.
        assert "Triggering Prompt & Tool Call" in runtime
        assert "**User prompt:**" in runtime

    @pytest.mark.asyncio
    async def test_self_reported_placeholders_in_agent_template(
        self, ha_report_issue_func
    ):
        result = await ha_report_issue_func()
        agent = result["agent_behavior_template"]
        assert "**AI Model:**" in agent
        assert "**Model family:**" not in agent
        assert "**Model version:**" not in agent
        assert "**Client application:**" not in agent
        for vendor in ("Claude, Gemini", "Gemini, GPT", "GPT, Llama"):
            assert vendor not in agent
        assert "**MCP Client:**" in agent
        assert "**MCP Transport:**" in agent
        assert "Triggering Prompt & Tool Call" in agent

    @pytest.mark.asyncio
    async def test_config_toggles_section_renders_in_templates(
        self, ha_report_issue_func, monkeypatch
    ):
        # Use a fake Settings so we don't depend on real env vars. The
        # `delenv("SUPERVISOR_TOKEN")` keeps `_fetch_addon_logs` on the
        # early-return path so the test stays env-independent (the addon
        # path would otherwise try `get_global_settings().verify_ssl` on
        # the fake, which only carries the toggle fields).
        monkeypatch.delenv("SUPERVISOR_TOKEN", raising=False)
        fake = SimpleNamespace(
            enable_websocket=True,
            enable_dashboard_partial_tools=True,
            enable_tool_search=True,
            tool_search_max_results=7,
            enable_yaml_config_editing=False,
            enable_code_mode=False,
            enabled_tool_modules="all",
            disabled_tools="",
            pinned_tools="",
        )
        with patch(
            "ha_mcp.tools.tools_bug_report.get_global_settings", return_value=fake
        ):
            result = await ha_report_issue_func()

        for template in (
            result["runtime_bug_template"],
            result["agent_behavior_template"],
        ):
            # Section header surfaces the toggle block.
            assert "ha-mcp Configuration" in template
            # At least one known toggle ends up in the rendered section.
            assert "enable_tool_search" in template

        # The plain-text formatted_report body (separate output from the
        # markdown templates, returned to callers as a triage-readable
        # summary) must mirror the new auto-detected rows. A future refactor
        # that drops them from formatted_report while keeping the templates
        # correct would silently regress the plain-text output.
        report = result["formatted_report"]
        assert "MCP Transport:" in report
        assert "MCP Client:" in report
        # Renamed from "Platform" to "Operating System" in this PR — pin it.
        assert "Operating System:" in report
        assert "=== ha-mcp Config Toggles ===" in report
        assert "enable_tool_search" in report

        # The diagnostic dict carries the structured value too.
        toggles = result["diagnostic_info"]["config_toggles"]
        assert toggles["enable_tool_search"] is True
        assert toggles["tool_search_max_results"] == 7

    @pytest.mark.asyncio
    async def test_submit_urls_include_prefilled_title(self, ha_report_issue_func):
        # The runtime/agent submit URLs append &title=<urlencoded suggested_title>
        # so GitHub auto-fills the issue title field. This addresses bare-`[BUG]`
        # title submissions seen in #1095.
        result = await ha_report_issue_func()
        suggested = result["suggested_title"]
        assert suggested  # always non-empty
        assert "&title=" in result["runtime_bug_submit_url"]
        assert "&title=" in result["agent_behavior_submit_url"]
        assert result["runtime_bug_submit_url"].startswith(
            "https://github.com/homeassistant-ai/ha-mcp/issues/new?template=runtime_bug.yml"
        )
        # Verify the title is actually URL-encoded — a regression that drops
        # the quote_plus call would leak raw spaces / brackets into the
        # query, which GitHub mangles.
        title_query = result["runtime_bug_submit_url"].split("&title=", 1)[1]
        assert " " not in title_query, (
            f"Title query must be URL-encoded; got raw space in {title_query!r}"
        )
        # The submit URL is the one printed inside the rendered template body.
        assert result["runtime_bug_submit_url"] in result["runtime_bug_template"]
        assert result["agent_behavior_submit_url"] in result["agent_behavior_template"]

    @pytest.mark.asyncio
    async def test_diagnostic_info_carries_transport_and_toggles(
        self, ha_report_issue_func
    ):
        result = await ha_report_issue_func()
        diag = result["diagnostic_info"]
        # New top-level diagnostic fields requested by issue #1095.
        assert "mcp_transport" in diag
        assert "config_toggles" in diag
        assert "mcp_client_info" in diag
        assert isinstance(diag["config_toggles"], dict)
        assert isinstance(diag["mcp_client_info"], dict)

    @pytest.mark.asyncio
    async def test_ctx_client_info_populates_mcp_client_row(self, ha_report_issue_func):
        # When ha_report_issue is called from inside an MCP request, the
        # FastMCP-injected Context exposes the initialize handshake's
        # clientInfo. Verify the helper picks it up and the rendered template
        # surfaces it on the `MCP Client:` row — no agent self-report needed.
        client_info_obj = SimpleNamespace(
            name="Claude Desktop",
            version="0.7.42",
            title=None,
        )
        ctx = SimpleNamespace(
            session=SimpleNamespace(
                client_params=SimpleNamespace(clientInfo=client_info_obj)
            )
        )
        result = await ha_report_issue_func(ctx=ctx)

        # Diagnostic dict carries the structured value.
        assert result["diagnostic_info"]["mcp_client_info"] == {
            "name": "Claude Desktop",
            "version": "0.7.42",
            "title": "",
        }
        # And both rendered templates show the formatted line on the
        # `MCP Client:` row.
        for key in ("runtime_bug_template", "agent_behavior_template"):
            template = result[key]
            assert "**MCP Client:** Claude Desktop 0.7.42" in template

    @pytest.mark.asyncio
    async def test_instructions_describe_all_self_report_fields(
        self, ha_report_issue_func
    ):
        result = await ha_report_issue_func()
        instructions = result["instructions"]
        # The instructions block must straight-up ASK the agent for both
        # self-reported fields (no enumerated options to pick from — that's
        # what biases agents and invites hallucinations).
        assert "**AI Model:**" in instructions
        assert "**Triggering Prompt & Tool Call:**" in instructions
        # The old multi-axis identity placeholders are gone.
        assert "**Model family:**" not in instructions
        assert "**Model version:**" not in instructions
        assert "**Client application:**" not in instructions
        # And the instructions must NOT enumerate vendor lists — that's the
        # hallucination bait we removed.
        for vendor in ("Claude, Gemini", "Gemini, GPT", "GPT, Llama"):
            assert vendor not in instructions
