# Familiar - Self-Hosted AI Companion Platform
# Copyright (c) 2026 George Scott Foley
# ORCID: 0009-0006-4957-0540
# Email: Georgescottfoley@proton.me
# Licensed under the MIT License - see LICENSE file for details

"""
Unit tests for the cross-channel ConnectMixin.

Uses a lightweight MockAdapter that records wizard_send / wizard_send_menu
calls so we can assert on the wizard flow without any real channel.
"""

import os
import sys
from pathlib import Path
from unittest.mock import patch

import pytest

# Ensure project root on path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))

from familiar.channels.connect_wizard import (
    EMAIL_PRESETS,
    SERVICE_BUTTON_LABELS,
    SERVICE_DISPLAY,
    TAB_CATEGORIES,
    TAB_ORDER,
    ConnectMixin,
    _email_calendar,
    _integrations,
    _llm,
    _security,
    _selfhosted,
    _update_env_file,
)
from familiar.channels.formatting import FormatMode, fmt_bold, fmt_code, fmt_italic

# ── Mock adapter ─────────────────────────────────────────────────────


class MockAgent:
    """Minimal stand-in for an Agent instance."""

    class _Provider:
        name = "MockProvider"

    provider = _Provider()

    class _Config:
        class _LLM:
            ollama_model = "llama3.2"
            lightweight_model = ""

        llm = _LLM()

    config = _Config()

    def switch_provider(self, name):
        self.provider.name = name

    def get_status(self):
        return {"provider": self.provider.name}


class MockAdapter(ConnectMixin):
    """A channel adapter that records all sends for assertions."""

    format_mode = FormatMode.PLAIN
    supports_buttons = False
    supports_message_deletion = False

    def __init__(self):
        self.agent = MockAgent()
        self.sent: list[tuple[str, str]] = []  # (recipient_id, text)
        self.menus: list[tuple[str, str, list]] = []  # (recipient_id, text, options)
        self.deleted: list[tuple[str, object]] = []  # (recipient_id, ref)

    async def wizard_send(self, recipient_id: str, text: str) -> None:
        self.sent.append((recipient_id, text))

    async def wizard_send_menu(
        self, recipient_id: str, text: str, options: list[tuple[str, str]]
    ) -> None:
        self.menus.append((recipient_id, text, options))
        # Also do the default numbered-menu behaviour for intercept testing
        await ConnectMixin.wizard_send_menu(self, recipient_id, text, options)

    async def wizard_delete_message(self, recipient_id: str, message_ref) -> bool:
        self.deleted.append((recipient_id, message_ref))
        return True

    def last_text(self) -> str:
        """Return the text of the last wizard_send call."""
        if self.sent:
            return self.sent[-1][1]
        return ""


class ButtonAdapter(MockAdapter):
    """Adapter that simulates a channel with inline buttons (like Telegram)."""

    format_mode = FormatMode.HTML
    supports_buttons = True
    supports_message_deletion = True

    async def wizard_send_menu(
        self, recipient_id: str, text: str, options: list[tuple[str, str]]
    ) -> None:
        # For button-capable channels, record the menu but don't generate numbered text
        self.menus.append((recipient_id, text, options))


# ── Formatting tests ─────────────────────────────────────────────────


class TestFormatting:
    def test_bold_html(self):
        assert fmt_bold("hello", FormatMode.HTML) == "<b>hello</b>"

    def test_bold_markdown(self):
        assert fmt_bold("hello", FormatMode.MARKDOWN) == "**hello**"

    def test_bold_whatsapp(self):
        assert fmt_bold("hello", FormatMode.WHATSAPP) == "*hello*"

    def test_bold_plain(self):
        assert fmt_bold("hello", FormatMode.PLAIN) == "hello"

    def test_code_html(self):
        assert fmt_code("x", FormatMode.HTML) == "<code>x</code>"

    def test_code_markdown(self):
        assert fmt_code("x", FormatMode.MARKDOWN) == "`x`"

    def test_italic_html(self):
        assert fmt_italic("i", FormatMode.HTML) == "<i>i</i>"

    def test_italic_whatsapp(self):
        assert fmt_italic("i", FormatMode.WHATSAPP) == "_i_"


# ── _update_env_file tests ───────────────────────────────────────────


class TestUpdateEnvFile:
    def test_create_new(self, tmp_path):
        env = tmp_path / ".env"
        _update_env_file(env, {"FOO": "bar", "BAZ": "qux"})
        text = env.read_text()
        assert "FOO=bar" in text
        assert "BAZ=qux" in text
        assert (env.stat().st_mode & 0o777) == 0o600

    def test_update_existing(self, tmp_path):
        env = tmp_path / ".env"
        env.write_text("FOO=old\n# comment\nBAR=keep\n")
        _update_env_file(env, {"FOO": "new"})
        text = env.read_text()
        assert "FOO=new" in text
        assert "BAR=keep" in text
        assert "# comment" in text
        assert "FOO=old" not in text

    def test_append_new_key(self, tmp_path):
        env = tmp_path / ".env"
        env.write_text("A=1\n")
        _update_env_file(env, {"B": "2"})
        text = env.read_text()
        assert "A=1" in text
        assert "B=2" in text


# ── ConnectMixin main menu tests ─────────────────────────────────────


class TestMainMenu:
    @pytest.mark.asyncio
    async def test_no_args_shows_menu(self):
        adapter = MockAdapter()
        await adapter.handle_connect_command("user1", [])
        # Should have at least one menu recorded (tarot spread)
        assert len(adapter.menus) >= 1
        _, text, options = adapter.menus[0]
        # Tarot spread shows card names; old flat menu showed "Connect Services"
        assert "Pick a card" in text or "Connect Services" in text
        assert len(options) > 0

    @pytest.mark.asyncio
    async def test_unknown_service(self):
        adapter = MockAdapter()
        await adapter.handle_connect_command("user1", ["nonexistent"])
        assert "Unknown service" in adapter.last_text()

    @pytest.mark.asyncio
    async def test_status_command(self):
        adapter = MockAdapter()
        await adapter.handle_connect_command("user1", ["status"])
        assert "Connection Status" in adapter.last_text()


# ── Email wizard flow tests ──────────────────────────────────────────


class TestEmailWizard:
    @pytest.mark.asyncio
    async def test_email_presets_exist(self):
        assert "gmail" in EMAIL_PRESETS
        assert "outlook" in EMAIL_PRESETS
        assert "yahoo" in EMAIL_PRESETS
        assert "proton" in EMAIL_PRESETS

    @pytest.mark.asyncio
    async def test_email_unconfigured_shows_menu(self):
        adapter = MockAdapter()
        with (
            patch.dict(os.environ, {}, clear=False),
            patch("familiar.skills.email.accounts.get_all_accounts", return_value=[]),
        ):
            os.environ.pop("EMAIL_ADDRESS", None)
            os.environ.pop("EMAIL_PASSWORD", None)
            os.environ.pop("EMAIL_IMAP_SERVER", None)
            await adapter.handle_connect_command("user1", ["email"])
        assert len(adapter.menus) >= 1
        _, text, options = adapter.menus[0]
        keys = [k for k, _ in options]
        assert "email_gmail" in keys

    @pytest.mark.asyncio
    async def test_start_email_wizard_sets_state(self):
        adapter = MockAdapter()
        await _email_calendar.start_email_wizard(adapter, "user1", "gmail")
        assert "user1" in adapter._wizard_state
        assert adapter._wizard_state["user1"]["type"] == "email"
        assert adapter._wizard_state["user1"]["step"] == "email"
        assert "Gmail" in adapter.last_text()

    @pytest.mark.asyncio
    async def test_email_wizard_rejects_bad_email(self):
        adapter = MockAdapter()
        await _email_calendar.start_email_wizard(adapter, "user1", "gmail")
        consumed = await adapter.handle_wizard_message("user1", "not-an-email")
        assert consumed
        assert "doesn't look like" in adapter.last_text()
        # State should still be active
        assert "user1" in adapter._wizard_state

    @pytest.mark.asyncio
    async def test_email_wizard_advances_to_password(self):
        adapter = MockAdapter()
        await _email_calendar.start_email_wizard(adapter, "user1", "gmail")
        consumed = await adapter.handle_wizard_message("user1", "test@gmail.com")
        assert consumed
        assert "password" in adapter.last_text().lower()
        assert adapter._wizard_state["user1"]["step"] == "password"
        assert adapter._wizard_state["user1"]["email"] == "test@gmail.com"

    @pytest.mark.asyncio
    async def test_email_wizard_password_clears_state(self):
        adapter = MockAdapter()
        await _email_calendar.start_email_wizard(adapter, "user1", "gmail")
        await adapter.handle_wizard_message("user1", "test@gmail.com")
        with patch(
            "familiar.channels.connect_wizard._email_calendar._test_imap_connection"
        ) as mock_test:
            mock_test.return_value = (True, "Inbox: 42 messages")
            consumed = await adapter.handle_wizard_message("user1", "mypassword123", "msg_ref")
        assert consumed
        assert "user1" not in adapter._wizard_state
        assert "Email connected" in adapter.last_text()

    @pytest.mark.asyncio
    async def test_email_wizard_password_shows_warning_no_delete(self):
        """Channels that can't delete messages should show a warning."""
        adapter = MockAdapter()  # supports_message_deletion = False
        await _email_calendar.start_email_wizard(adapter, "user1", "gmail")
        await adapter.handle_wizard_message("user1", "test@gmail.com")
        # Check the password prompt includes a warning
        text = adapter.last_text()
        assert "cannot delete" in text.lower() or "password" in text.lower()

    @pytest.mark.asyncio
    async def test_email_wizard_button_adapter_deletes_message(self):
        adapter = ButtonAdapter()
        await _email_calendar.start_email_wizard(adapter, "user1", "gmail")
        await adapter.handle_wizard_message("user1", "test@gmail.com")
        with patch(
            "familiar.channels.connect_wizard._email_calendar._test_imap_connection"
        ) as mock_test:
            mock_test.return_value = (True, "Inbox: 5 messages")
            await adapter.handle_wizard_message("user1", "secret", "msg123")
        # Should have attempted to delete the message
        assert len(adapter.deleted) == 1
        assert adapter.deleted[0] == ("user1", "msg123")


# ── CalDAV wizard flow tests ─────────────────────────────────────────


class TestCalDAVWizard:
    @pytest.mark.asyncio
    async def test_start_caldav_wizard(self):
        adapter = MockAdapter()
        await _email_calendar.start_caldav_wizard(adapter, "user1")
        assert "user1" in adapter._wizard_state
        assert adapter._wizard_state["user1"]["type"] == "caldav"
        assert "CalDAV" in adapter.last_text()

    @pytest.mark.asyncio
    async def test_caldav_rejects_bad_url(self):
        adapter = MockAdapter()
        await _email_calendar.start_caldav_wizard(adapter, "user1")
        consumed = await adapter.handle_wizard_message("user1", "not-a-url")
        assert consumed
        assert "URL" in adapter.last_text()
        assert adapter._wizard_state["user1"]["step"] == "url"

    @pytest.mark.asyncio
    async def test_caldav_full_flow(self):
        adapter = MockAdapter()
        await _email_calendar.start_caldav_wizard(adapter, "user1")

        # Step 1: URL
        await adapter.handle_wizard_message("user1", "https://cloud.example.org/dav/")
        assert adapter._wizard_state["user1"]["step"] == "user"

        # Step 2: Username
        await adapter.handle_wizard_message("user1", "alice")
        assert adapter._wizard_state["user1"]["step"] == "password"

        # Step 3: Password
        with patch("familiar.channels.connect_wizard._email_calendar._update_env_file"):
            with patch("familiar.channels.connect_wizard._email_calendar.caldav") as mock_caldav:
                mock_client = mock_caldav.DAVClient.return_value
                mock_client.principal.return_value.calendars.return_value = ["Personal"]
                await adapter.handle_wizard_message("user1", "secret123")

        assert "user1" not in adapter._wizard_state
        assert "CalDAV Connected" in adapter.last_text()


# ── Numbered menu resolution tests ───────────────────────────────────


class TestNumberedMenu:
    @pytest.mark.asyncio
    async def test_numbered_reply_resolves(self):
        adapter = MockAdapter()
        await adapter.handle_connect_command("user1", [])
        # Now user replies "1" to select first option
        # The menu state should be set
        assert "user1" in adapter._wizard_state
        assert adapter._wizard_state["user1"]["type"] == "menu"
        # Reply with "1" should resolve to first option
        consumed = await adapter.handle_wizard_message("user1", "1")
        assert consumed

    @pytest.mark.asyncio
    async def test_invalid_number_prompts_retry(self):
        adapter = MockAdapter()
        adapter._ensure_wizard_state()
        adapter._wizard_state["user1"] = {
            "type": "menu",
            "options": [("a", "Option A"), ("b", "Option B")],
        }
        consumed = await adapter.handle_wizard_message("user1", "99")
        assert consumed
        assert "number" in adapter.last_text().lower()

    @pytest.mark.asyncio
    async def test_non_wizard_message_not_consumed(self):
        adapter = MockAdapter()
        consumed = await adapter.handle_wizard_message("user1", "hello world")
        assert not consumed


# ── LLM provider tests ──────────────────────────────────────────────


class TestLLMProvider:
    @pytest.mark.asyncio
    async def test_no_key_shows_instructions(self):
        adapter = MockAdapter()
        await adapter.handle_connect_command("user1", ["anthropic"])
        assert "console.anthropic.com" in adapter.last_text()

    @pytest.mark.asyncio
    async def test_bad_key_format(self):
        adapter = MockAdapter()
        await adapter.handle_connect_command("user1", ["anthropic", "not-a-key"])
        assert "doesn't look like" in adapter.last_text()

    @pytest.mark.asyncio
    async def test_valid_key_saves(self):
        adapter = MockAdapter()
        with patch("familiar.channels.connect_wizard._llm._update_env_file"):
            await adapter.handle_connect_command("user1", ["anthropic", "sk-ant-api03-testkey1234"])
        assert "Connected" in adapter.last_text()


# ── Menu selection tests ─────────────────────────────────────────────


class TestMenuSelection:
    @pytest.mark.asyncio
    async def test_anthropic_button(self):
        adapter = MockAdapter()
        handled = await adapter.handle_connect_menu_selection("user1", "anthropic")
        assert handled
        assert "Anthropic" in adapter.last_text()

    @pytest.mark.asyncio
    async def test_email_gmail_button(self):
        adapter = MockAdapter()
        handled = await adapter.handle_connect_menu_selection("user1", "email_gmail")
        assert handled
        assert "Gmail" in adapter.last_text()

    @pytest.mark.asyncio
    async def test_calendar_caldav_button(self):
        adapter = MockAdapter()
        handled = await adapter.handle_connect_menu_selection("user1", "calendar_caldav")
        assert handled
        assert "CalDAV" in adapter.last_text()

    @pytest.mark.asyncio
    async def test_unknown_key_not_handled(self):
        adapter = MockAdapter()
        handled = await adapter.handle_connect_menu_selection("user1", "nonexistent")
        assert not handled


# ── Browser/Voice status checks ──────────────────────────────────────


class TestStatusCheckers:
    @pytest.mark.asyncio
    async def test_browser_check(self):
        adapter = MockAdapter()
        await adapter.handle_connect_command("user1", ["browser"])
        assert "Browser" in adapter.last_text()

    @pytest.mark.asyncio
    async def test_voice_check(self):
        adapter = MockAdapter()
        await adapter.handle_connect_command("user1", ["voice"])
        assert "Voice" in adapter.last_text()

    @pytest.mark.asyncio
    async def test_status_overview(self):
        adapter = MockAdapter()
        await adapter.handle_connect_command("user1", ["status"])
        text = adapter.last_text()
        assert "LLM Providers" in text
        assert "Integrations" in text


# ── Concurrent user isolation ────────────────────────────────────────


class TestConcurrentUsers:
    @pytest.mark.asyncio
    async def test_separate_wizard_states(self):
        adapter = MockAdapter()
        await _email_calendar.start_email_wizard(adapter, "alice", "gmail")
        await _email_calendar.start_caldav_wizard(adapter, "bob")

        assert adapter._wizard_state["alice"]["type"] == "email"
        assert adapter._wizard_state["bob"]["type"] == "caldav"

        # Alice's input should go to email wizard
        consumed_a = await adapter.handle_wizard_message("alice", "alice@gmail.com")
        assert consumed_a
        assert adapter._wizard_state["alice"]["step"] == "password"

        # Bob's input should go to CalDAV wizard
        consumed_b = await adapter.handle_wizard_message("bob", "https://example.org/dav/")
        assert consumed_b
        assert adapter._wizard_state["bob"]["step"] == "user"

    @pytest.mark.asyncio
    async def test_unrelated_user_not_consumed(self):
        adapter = MockAdapter()
        await _email_calendar.start_email_wizard(adapter, "alice", "gmail")
        # Charlie has no active wizard
        consumed = await adapter.handle_wizard_message("charlie", "hello")
        assert not consumed


# ── Self-hosted interactive wizard tests ─────────────────────────────


class TestGiteaWizard:
    @pytest.mark.asyncio
    async def test_start_gitea_wizard_sets_state(self):
        adapter = ButtonAdapter()
        await _selfhosted.start_gitea_wizard(adapter, "user1")
        assert "user1" in adapter._wizard_state
        assert adapter._wizard_state["user1"]["type"] == "gitea"
        assert adapter._wizard_state["user1"]["step"] == "url"
        assert "Gitea" in adapter.last_text()

    @pytest.mark.asyncio
    async def test_gitea_rejects_bad_url(self):
        adapter = ButtonAdapter()
        await _selfhosted.start_gitea_wizard(adapter, "user1")
        consumed = await adapter.handle_wizard_message("user1", "not-a-url")
        assert consumed
        assert "URL" in adapter.last_text()
        assert adapter._wizard_state["user1"]["step"] == "url"

    @pytest.mark.asyncio
    async def test_gitea_full_flow(self):
        adapter = ButtonAdapter()
        await _selfhosted.start_gitea_wizard(adapter, "user1")

        # Step 1: URL
        await adapter.handle_wizard_message("user1", "https://gitea.example.org")
        assert adapter._wizard_state["user1"]["step"] == "token"
        assert adapter._wizard_state["user1"]["url"] == "https://gitea.example.org"

        # Step 2: Token (should delete message + clear state)
        with (
            patch("familiar.channels.connect_wizard._selfhosted._update_env_file"),
            patch(
                "familiar.channels.connect_wizard._selfhosted._test_http_service",
                return_value=(True, "OK"),
            ),
        ):
            await adapter.handle_wizard_message("user1", "gta_abc123", "msg_ref")

        assert "user1" not in adapter._wizard_state
        assert len(adapter.deleted) == 1
        assert adapter.deleted[0] == ("user1", "msg_ref")

    @pytest.mark.asyncio
    async def test_gitea_buttons_branch(self):
        """ButtonAdapter starts wizard; MockAdapter shows one-shot text."""
        btn = ButtonAdapter()
        with patch.dict(os.environ, {}, clear=False):
            os.environ.pop("GITEA_URL", None)
            os.environ.pop("GITEA_TOKEN", None)
            await _selfhosted.wizard_gitea(btn, "user1", [])
        btn._ensure_wizard_state()
        assert "user1" in btn._wizard_state

        plain = MockAdapter()
        with patch.dict(os.environ, {}, clear=False):
            os.environ.pop("GITEA_URL", None)
            os.environ.pop("GITEA_TOKEN", None)
            await _selfhosted.wizard_gitea(plain, "user1", [])
        plain._ensure_wizard_state()
        assert "user1" not in plain._wizard_state
        assert "One-shot" in plain.last_text()


class TestHomeAssistantWizard:
    @pytest.mark.asyncio
    async def test_start_homeassistant_wizard_sets_state(self):
        adapter = ButtonAdapter()
        await _selfhosted.start_homeassistant_wizard(adapter, "user1")
        assert adapter._wizard_state["user1"]["type"] == "homeassistant"
        assert adapter._wizard_state["user1"]["step"] == "url"

    @pytest.mark.asyncio
    async def test_homeassistant_full_flow(self):
        adapter = ButtonAdapter()
        await _selfhosted.start_homeassistant_wizard(adapter, "user1")

        await adapter.handle_wizard_message("user1", "http://homeassistant.local:8123")
        assert adapter._wizard_state["user1"]["step"] == "token"

        with (
            patch("familiar.channels.connect_wizard._selfhosted._update_env_file"),
            patch(
                "familiar.channels.connect_wizard._selfhosted._test_http_service",
                return_value=(True, "OK"),
            ),
        ):
            await adapter.handle_wizard_message("user1", "eyJ0token", "msg_ref")

        assert "user1" not in adapter._wizard_state
        assert len(adapter.deleted) == 1


class TestJellyfinWizard:
    @pytest.mark.asyncio
    async def test_start_jellyfin_wizard_sets_state(self):
        adapter = ButtonAdapter()
        await _selfhosted.start_jellyfin_wizard(adapter, "user1")
        assert adapter._wizard_state["user1"]["type"] == "jellyfin"
        assert adapter._wizard_state["user1"]["step"] == "url"

    @pytest.mark.asyncio
    async def test_jellyfin_three_step_flow(self):
        adapter = ButtonAdapter()
        await _selfhosted.start_jellyfin_wizard(adapter, "user1")

        # Step 1: URL
        await adapter.handle_wizard_message("user1", "https://jellyfin.example.org")
        assert adapter._wizard_state["user1"]["step"] == "token"

        # Step 2: Token (deletes message)
        await adapter.handle_wizard_message("user1", "api-key-123", "msg_ref")
        assert adapter._wizard_state["user1"]["step"] == "user_id"
        assert adapter._wizard_state["user1"]["token"] == "api-key-123"
        assert len(adapter.deleted) == 1

        # Step 3: User ID (clears state)
        with (
            patch("familiar.channels.connect_wizard._selfhosted._update_env_file"),
            patch(
                "familiar.channels.connect_wizard._selfhosted._test_http_service",
                return_value=(True, "OK"),
            ),
        ):
            await adapter.handle_wizard_message("user1", "a1b2c3d4-e5f6-7890")

        assert "user1" not in adapter._wizard_state


class TestJoplinWizard:
    @pytest.mark.asyncio
    async def test_start_joplin_wizard_sets_state(self):
        adapter = ButtonAdapter()
        await _selfhosted.start_joplin_wizard(adapter, "user1")
        assert adapter._wizard_state["user1"]["type"] == "joplin"
        assert adapter._wizard_state["user1"]["step"] == "token"

    @pytest.mark.asyncio
    async def test_joplin_flow_with_default_url(self):
        adapter = ButtonAdapter()
        await _selfhosted.start_joplin_wizard(adapter, "user1")

        # Step 1: Token (deletes message)
        await adapter.handle_wizard_message("user1", "abc123token", "msg_ref")
        assert adapter._wizard_state["user1"]["step"] == "url"
        assert adapter._wizard_state["user1"]["token"] == "abc123token"
        assert len(adapter.deleted) == 1

        # Step 2: URL (use default)
        with (
            patch("familiar.channels.connect_wizard._selfhosted._update_env_file"),
            patch(
                "familiar.channels.connect_wizard._selfhosted._test_http_service",
                return_value=(True, "OK"),
            ),
        ):
            await adapter.handle_wizard_message("user1", "default")

        assert "user1" not in adapter._wizard_state

    @pytest.mark.asyncio
    async def test_joplin_flow_with_custom_url(self):
        adapter = ButtonAdapter()
        await _selfhosted.start_joplin_wizard(adapter, "user1")
        await adapter.handle_wizard_message("user1", "token123")

        with (
            patch("familiar.channels.connect_wizard._selfhosted._update_env_file"),
            patch(
                "familiar.channels.connect_wizard._selfhosted._test_http_service",
                return_value=(True, "OK"),
            ),
        ):
            await adapter.handle_wizard_message("user1", "http://192.168.1.5:41184")

        assert "user1" not in adapter._wizard_state

    @pytest.mark.asyncio
    async def test_joplin_rejects_bad_url(self):
        adapter = ButtonAdapter()
        await _selfhosted.start_joplin_wizard(adapter, "user1")
        await adapter.handle_wizard_message("user1", "token123")

        consumed = await adapter.handle_wizard_message("user1", "not-a-url")
        assert consumed
        assert adapter._wizard_state["user1"]["step"] == "url"


class TestPiholeWizard:
    @pytest.mark.asyncio
    async def test_start_pihole_wizard_sets_state(self):
        adapter = ButtonAdapter()
        await _selfhosted.start_pihole_wizard(adapter, "user1")
        assert adapter._wizard_state["user1"]["type"] == "pihole"
        assert adapter._wizard_state["user1"]["step"] == "url"

    @pytest.mark.asyncio
    async def test_pihole_flow_with_type_text(self):
        adapter = ButtonAdapter()
        await _selfhosted.start_pihole_wizard(adapter, "user1")

        # Step 1: URL
        await adapter.handle_wizard_message("user1", "http://pi.hole")
        assert adapter._wizard_state["user1"]["step"] == "token"

        # Step 2: Token (deletes message, shows type menu)
        await adapter.handle_wizard_message("user1", "abc123", "msg_ref")
        assert adapter._wizard_state["user1"]["step"] == "type"
        assert len(adapter.deleted) == 1
        # Should have shown a menu for type selection
        assert len(adapter.menus) >= 1

        # Step 3: Type via text
        with (
            patch("familiar.channels.connect_wizard._selfhosted._update_env_file"),
            patch(
                "familiar.channels.connect_wizard._selfhosted._test_http_service",
                return_value=(True, "OK"),
            ),
        ):
            await adapter.handle_wizard_message("user1", "1")

        assert "user1" not in adapter._wizard_state

    @pytest.mark.asyncio
    async def test_pihole_type_button_callback(self):
        adapter = ButtonAdapter()
        await _selfhosted.start_pihole_wizard(adapter, "user1")
        await adapter.handle_wizard_message("user1", "http://pi.hole")
        await adapter.handle_wizard_message("user1", "abc123", "msg_ref")

        # Use button callback instead of text
        with (
            patch("familiar.channels.connect_wizard._selfhosted._update_env_file"),
            patch(
                "familiar.channels.connect_wizard._selfhosted._test_http_service",
                return_value=(True, "OK"),
            ),
        ):
            handled = await adapter.handle_connect_menu_selection("user1", "pihole_type_adguard")

        assert handled
        assert "user1" not in adapter._wizard_state


class TestVaultwardenWizard:
    @pytest.mark.asyncio
    async def test_start_vaultwarden_wizard_sets_state(self):
        adapter = ButtonAdapter()
        await _security.start_vaultwarden_wizard(adapter, "user1")
        assert adapter._wizard_state["user1"]["type"] == "vaultwarden"
        assert adapter._wizard_state["user1"]["step"] == "url"

    @pytest.mark.asyncio
    async def test_vaultwarden_full_flow(self):
        adapter = ButtonAdapter()
        await _security.start_vaultwarden_wizard(adapter, "user1")

        await adapter.handle_wizard_message("user1", "https://vault.example.org")
        assert adapter._wizard_state["user1"]["step"] == "token"

        with (
            patch("familiar.channels.connect_wizard._security._update_env_file"),
            patch(
                "familiar.channels.connect_wizard._security._test_http_service",
                return_value=(True, "OK"),
            ),
        ):
            await adapter.handle_wizard_message("user1", "my-api-token", "msg_ref")

        assert "user1" not in adapter._wizard_state
        assert len(adapter.deleted) == 1


# ── LLM provider wizard tests ───────────────────────────────────────


class TestLLMProviderWizard:
    @pytest.mark.asyncio
    async def test_start_llm_provider_wizard_sets_state(self):
        adapter = ButtonAdapter()
        await _llm.start_llm_provider_wizard(adapter, "user1", "anthropic")
        assert adapter._wizard_state["user1"]["type"] == "llm_provider"
        assert adapter._wizard_state["user1"]["step"] == "key"
        assert adapter._wizard_state["user1"]["provider"] == "anthropic"
        assert "Anthropic" in adapter.last_text()

    @pytest.mark.asyncio
    async def test_llm_provider_rejects_bad_key(self):
        adapter = ButtonAdapter()
        await _llm.start_llm_provider_wizard(adapter, "user1", "anthropic")
        consumed = await adapter.handle_wizard_message("user1", "not-a-valid-key")
        assert consumed
        assert "doesn't look like" in adapter.last_text()
        # State should persist for retry
        assert "user1" in adapter._wizard_state

    @pytest.mark.asyncio
    async def test_llm_provider_valid_key_clears_state(self):
        adapter = ButtonAdapter()
        await _llm.start_llm_provider_wizard(adapter, "user1", "anthropic")
        with patch("familiar.channels.connect_wizard._llm._update_env_file"):
            consumed = await adapter.handle_wizard_message(
                "user1", "sk-ant-api03-testkey1234", "msg_ref"
            )
        assert consumed
        assert "user1" not in adapter._wizard_state
        assert "Connected" in adapter.last_text()
        # Should have deleted the key message
        assert len(adapter.deleted) == 1

    @pytest.mark.asyncio
    async def test_llm_provider_button_starts_wizard(self):
        """Button callbacks should start interactive wizard."""
        adapter = ButtonAdapter()
        handled = await adapter.handle_connect_menu_selection("user1", "anthropic")
        assert handled
        assert "user1" in adapter._wizard_state
        assert adapter._wizard_state["user1"]["type"] == "llm_provider"

    @pytest.mark.asyncio
    async def test_openai_wizard_validates_prefix(self):
        adapter = ButtonAdapter()
        await _llm.start_llm_provider_wizard(adapter, "user1", "openai")
        consumed = await adapter.handle_wizard_message("user1", "AIza-wrong-prefix")
        assert consumed
        assert "doesn't look like" in adapter.last_text()
        assert "user1" in adapter._wizard_state

    @pytest.mark.asyncio
    async def test_gemini_wizard_flow(self):
        adapter = ButtonAdapter()
        await _llm.start_llm_provider_wizard(adapter, "user1", "gemini")
        with patch("familiar.channels.connect_wizard._llm._update_env_file"):
            await adapter.handle_wizard_message("user1", "AIzaSyTestKey123", "msg_ref")
        assert "user1" not in adapter._wizard_state
        assert "Connected" in adapter.last_text()


# ── Browser/Voice button tests ───────────────────────────────────────


class TestBrowserVoiceButtons:
    @pytest.mark.asyncio
    async def test_browser_shows_buttons_when_not_installed(self):
        adapter = ButtonAdapter()
        await _integrations.wizard_browser(adapter, "user1")
        # Should show a menu with buttons (since playwright likely not installed in test)
        if adapter.menus:
            _, _, options = adapter.menus[-1]
            keys = [k for k, _ in options]
            assert "browser_install_cmd" in keys or "browser_recheck" in keys

    @pytest.mark.asyncio
    async def test_browser_install_cmd_callback(self):
        adapter = ButtonAdapter()
        handled = await adapter.handle_connect_menu_selection("user1", "browser_install_cmd")
        assert handled
        # After auto-install, should show browser status (installed or re-check)
        all_text = adapter.last_text()
        if not all_text and adapter.menus:
            all_text = adapter.menus[-1][1]
        assert "Browser" in all_text or "Install" in all_text

    @pytest.mark.asyncio
    async def test_browser_recheck_callback(self):
        adapter = ButtonAdapter()
        handled = await adapter.handle_connect_menu_selection("user1", "browser_recheck")
        assert handled
        # Browser wizard outputs via menu (buttons) or send, check both
        all_text = adapter.last_text()
        if not all_text and adapter.menus:
            all_text = adapter.menus[-1][1]
        assert "Browser" in all_text

    @pytest.mark.asyncio
    async def test_voice_install_cmd_callback(self):
        adapter = ButtonAdapter()
        handled = await adapter.handle_connect_menu_selection("user1", "voice_install_cmd")
        assert handled
        # After auto-install attempt, should show voice status
        all_text = adapter.last_text()
        if not all_text and adapter.menus:
            all_text = adapter.menus[-1][1]
        assert "Voice" in all_text or "Install" in all_text

    @pytest.mark.asyncio
    async def test_voice_recheck_callback(self):
        adapter = ButtonAdapter()
        handled = await adapter.handle_connect_menu_selection("user1", "voice_recheck")
        assert handled
        # Voice wizard outputs via menu (buttons) or send, check both
        all_text = adapter.last_text()
        if not all_text and adapter.menus:
            all_text = adapter.menus[-1][1]
        assert "Voice" in all_text


# ── Security button tests ────────────────────────────────────────────


class TestSecurityButtons:
    @pytest.mark.asyncio
    async def test_encryption_gen_key_callback(self):
        adapter = ButtonAdapter()
        handled = await adapter.handle_connect_menu_selection("user1", "encryption_gen_key")
        assert handled
        assert "generate encryption key" in adapter.last_text()

    @pytest.mark.asyncio
    async def test_encryption_encrypt_all_callback(self):
        adapter = ButtonAdapter()
        handled = await adapter.handle_connect_menu_selection("user1", "encryption_encrypt_all")
        assert handled
        assert "encrypt all data stores" in adapter.last_text()

    @pytest.mark.asyncio
    async def test_encryption_rotate_callback(self):
        adapter = ButtonAdapter()
        handled = await adapter.handle_connect_menu_selection("user1", "encryption_rotate")
        assert handled
        assert "rotate encryption keys" in adapter.last_text()

    @pytest.mark.asyncio
    async def test_phi_enable_safe_callback(self):
        adapter = ButtonAdapter()
        handled = await adapter.handle_connect_menu_selection("user1", "phi_enable_safe")
        assert handled
        assert "enable safe mode" in adapter.last_text()

    @pytest.mark.asyncio
    async def test_phi_set_strict_callback(self):
        adapter = ButtonAdapter()
        handled = await adapter.handle_connect_menu_selection("user1", "phi_set_strict")
        assert handled
        assert "strict" in adapter.last_text()

    @pytest.mark.asyncio
    async def test_rbac_list_callback(self):
        adapter = ButtonAdapter()
        handled = await adapter.handle_connect_menu_selection("user1", "rbac_list")
        assert handled
        assert "list roles" in adapter.last_text()

    @pytest.mark.asyncio
    async def test_rbac_create_callback(self):
        adapter = ButtonAdapter()
        handled = await adapter.handle_connect_menu_selection("user1", "rbac_create")
        assert handled
        assert "create role" in adapter.last_text()

    @pytest.mark.asyncio
    async def test_rbac_assign_callback(self):
        adapter = ButtonAdapter()
        handled = await adapter.handle_connect_menu_selection("user1", "rbac_assign")
        assert handled
        assert "assign role" in adapter.last_text()

    @pytest.mark.asyncio
    async def test_users_list_callback(self):
        adapter = ButtonAdapter()
        handled = await adapter.handle_connect_menu_selection("user1", "users_list")
        assert handled
        assert "list users" in adapter.last_text()

    @pytest.mark.asyncio
    async def test_users_create_callback(self):
        adapter = ButtonAdapter()
        handled = await adapter.handle_connect_menu_selection("user1", "users_create")
        assert handled
        assert "create user" in adapter.last_text()


# ── Card / Tab UI tests ──────────────────────────────────────────────


class TestTabConstants:
    def test_tab_order_matches_categories(self):
        for tid in TAB_ORDER:
            assert tid in TAB_CATEGORIES

    def test_all_services_have_display_names(self):
        for cat in TAB_CATEGORIES.values():
            for svc in cat["services"]:
                assert svc in SERVICE_DISPLAY, f"Missing display name for {svc}"

    def test_all_services_have_button_labels(self):
        for cat in TAB_CATEGORIES.values():
            for svc in cat["services"]:
                assert svc in SERVICE_BUTTON_LABELS, f"Missing button label for {svc}"


class TestGetServiceStatus:
    def test_returns_all_expected_keys(self):
        adapter = MockAdapter()
        with patch.dict(os.environ, {}, clear=False):
            status = adapter._get_service_status()
        # Every service across all tabs should appear
        for cat in TAB_CATEGORIES.values():
            for svc in cat["services"]:
                assert svc in status, f"Missing status key: {svc}"

    def test_detects_anthropic_env_var(self):
        adapter = MockAdapter()
        with patch.dict(os.environ, {"ANTHROPIC_API_KEY": "sk-ant-test"}, clear=False):
            status = adapter._get_service_status()
        assert status["anthropic"] is True

    def test_no_env_vars_all_false(self):
        adapter = MockAdapter()
        env_keys = [
            "ANTHROPIC_API_KEY",
            "OPENAI_API_KEY",
            "GEMINI_API_KEY",
            "TWILIO_ACCOUNT_SID",
            "CALDAV_URL",
            "EMAIL_ADDRESS",
            "JELLYFIN_URL",
            "GITEA_URL",
            "HOMEASSISTANT_URL",
            "JOPLIN_TOKEN",
            "PIHOLE_URL",
            "NEXTCLOUD_URL",
            "VAULTWARDEN_URL",
            "EMAIL_SERVER_DOMAIN",
        ]
        with patch.dict(os.environ, {}, clear=False):
            for k in env_keys:
                os.environ.pop(k, None)
            status = adapter._get_service_status()
        # websearch is always True
        assert status["websearch"] is True
        # These should all be False when env vars are absent
        assert status["anthropic"] is False
        assert status["openai"] is False
        assert status["sms"] is False


class TestBuildTabContent:
    def test_llm_tab_has_four_services(self):
        adapter = MockAdapter()
        status = {k: False for cat in TAB_CATEGORIES.values() for k in cat["services"]}
        text, options = adapter._build_tab_content(status, "llm")
        assert len(options) == 4
        # Should contain all LLM display names
        assert "Anthropic" in text
        assert "OpenAI" in text
        assert "Ollama" in text

    def test_integrations_tab(self):
        adapter = MockAdapter()
        status = {k: False for cat in TAB_CATEGORIES.values() for k in cat["services"]}
        text, options = adapter._build_tab_content(status, "integrations")
        assert len(options) == len(TAB_CATEGORIES["integrations"]["services"])
        assert "Email" in text

    def test_unknown_tab(self):
        adapter = MockAdapter()
        text, options = adapter._build_tab_content({}, "nonexistent")
        assert text == "Unknown tab."
        assert options == []

    def test_connected_service_shows_checkmark(self):
        adapter = MockAdapter()
        status = {k: False for cat in TAB_CATEGORIES.values() for k in cat["services"]}
        status["anthropic"] = True
        text, _ = adapter._build_tab_content(status, "llm")
        assert "\u2705" in text  # checkmark


class TestBuildFlatMenu:
    def test_flat_menu_backward_compat(self):
        adapter = MockAdapter()
        status = {k: False for cat in TAB_CATEGORIES.values() for k in cat["services"]}
        text, options = adapter._build_flat_menu(status)
        assert "Connect Services" in text
        # Should have the same 15 options as the old menu
        assert len(options) == 15
        keys = [k for k, _ in options]
        assert "anthropic" in keys
        assert "selfhosted_menu" in keys
        assert "security_menu" in keys


class TestCardMenuResolution:
    @pytest.mark.asyncio
    async def test_card_menu_numbered_reply(self):
        adapter = MockAdapter()
        adapter._ensure_wizard_state()
        adapter._wizard_state["user1"] = {
            "type": "card_menu",
            "options": [("anthropic", "Claude"), ("openai", "GPT")],
        }
        consumed = await adapter.handle_wizard_message("user1", "1")
        assert consumed

    @pytest.mark.asyncio
    async def test_card_menu_invalid_number(self):
        adapter = MockAdapter()
        adapter._ensure_wizard_state()
        adapter._wizard_state["user1"] = {
            "type": "card_menu",
            "options": [("anthropic", "Claude")],
        }
        consumed = await adapter.handle_wizard_message("user1", "99")
        assert consumed
        assert "number" in adapter.last_text().lower()


class TestShowTab:
    @pytest.mark.asyncio
    async def test_show_tab_sends_menu(self):
        """Plain-text adapter falls back to flat menu via wizard_send_card_menu."""
        adapter = MockAdapter()
        await adapter._show_tab("user1", "llm")
        # Should produce at least one menu (flat fallback)
        assert len(adapter.menus) >= 1

    @pytest.mark.asyncio
    async def test_main_menu_backward_compat(self):
        """/connect with no args sends a tarot spread menu."""
        adapter = MockAdapter()
        await adapter.handle_connect_command("user1", [])
        assert len(adapter.menus) >= 1
        _, text, options = adapter.menus[0]
        assert "Pick a card" in text or "Connect Services" in text
        assert len(options) > 0


class TestBuildCardHeader:
    def test_active_tab_indicator(self):
        adapter = MockAdapter()
        header = adapter._build_card_header("llm")
        assert "Connect Services" in header
        assert "\u25b8 LLM" in header

    def test_inactive_tabs_no_indicator(self):
        adapter = MockAdapter()
        header = adapter._build_card_header("security")
        assert "\u25b8 Security" in header
        # LLM should not have the indicator
        assert "\u25b8 LLM" not in header
