# 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,
    ConnectMixin,
    _check_google_libs,
    _test_imap_connection,
    _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
        assert len(adapter.menus) >= 1
        # The menu text should mention "Connect Services"
        _, text, options = adapter.menus[0]
        assert "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 adapter._start_email_wizard("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 adapter._start_email_wizard("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 adapter._start_email_wizard("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 adapter._start_email_wizard("user1", "gmail")
        await adapter.handle_wizard_message("user1", "test@gmail.com")
        with patch("familiar.channels.connect_wizard._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 adapter._start_email_wizard("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 adapter._start_email_wizard("user1", "gmail")
        await adapter.handle_wizard_message("user1", "test@gmail.com")
        with patch("familiar.channels.connect_wizard._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 adapter._start_caldav_wizard("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 adapter._start_caldav_wizard("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 adapter._start_caldav_wizard("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._update_env_file"):
            with patch("familiar.channels.connect_wizard.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._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 adapter._start_email_wizard("alice", "gmail")
        await adapter._start_caldav_wizard("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 adapter._start_email_wizard("alice", "gmail")
        # Charlie has no active wizard
        consumed = await adapter.handle_wizard_message("charlie", "hello")
        assert not consumed
