"""Tests for the EmailListener daemon integration."""

import asyncio
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from fliiq.runtime.email.listener import EmailListener, _extract_email_address


# ---------------------------------------------------------------------------
# _extract_email_address
# ---------------------------------------------------------------------------

def test_extract_email_address_with_name():
    assert _extract_email_address("Alice <alice@example.com>") == "alice@example.com"


def test_extract_email_address_bare():
    assert _extract_email_address("bob@example.com") == "bob@example.com"


def test_extract_email_address_uppercase():
    assert _extract_email_address("Alice <ALICE@Example.COM>") == "alice@example.com"


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _make_email_data(uid="1", sender="alice@example.com", subject="Hello", body="Hi there"):
    return {
        "id": uid,
        "from": sender,
        "to": "bot@gmail.com",
        "cc": "",
        "subject": subject,
        "date": "Mon, 1 Mar 2026 12:00:00 +0000",
        "body": f'<external_message source="email" sender="{sender}">\n{body}\n</external_message>',
        "read": False,
        "message_id": f"<msg-{uid}@mail.gmail.com>",
        "in_reply_to": "",
        "references": "",
    }


def _make_listener():
    llm_config = MagicMock()
    project_root = Path("/tmp/fliiq-test")
    return EmailListener(llm_config, project_root)


# ---------------------------------------------------------------------------
# _poll_emails dedup
# ---------------------------------------------------------------------------

async def test_poll_emails_dedup():
    """Same UID is not returned on second poll."""
    listener = _make_listener()

    emails = [_make_email_data(uid="1"), _make_email_data(uid="2")]
    fetch_result = {"emails": emails, "has_more": False}

    with patch(
        "fliiq.runtime.email.listener.resolve_gmail_creds",
        new_callable=AsyncMock,
        return_value=("bot@gmail.com", "password", "fake-pass"),
    ), patch(
        "fliiq.data.skills.core.receive_emails.main._fetch_emails",
        return_value=fetch_result,
    ):
        first = await listener._poll_emails()
        second = await listener._poll_emails()

    assert len(first) == 2
    assert len(second) == 0


async def test_poll_emails_new_only():
    """Only new UIDs returned on subsequent polls."""
    listener = _make_listener()

    with patch(
        "fliiq.runtime.email.listener.resolve_gmail_creds",
        new_callable=AsyncMock,
        return_value=("bot@gmail.com", "password", "fake-pass"),
    ), patch(
        "fliiq.data.skills.core.receive_emails.main._fetch_emails",
    ) as mock_fetch:
        mock_fetch.return_value = {"emails": [_make_email_data(uid="1")], "has_more": False}
        first = await listener._poll_emails()

        mock_fetch.return_value = {
            "emails": [_make_email_data(uid="1"), _make_email_data(uid="2")],
            "has_more": False,
        }
        second = await listener._poll_emails()

    assert len(first) == 1
    assert first[0]["id"] == "1"
    assert len(second) == 1
    assert second[0]["id"] == "2"


# ---------------------------------------------------------------------------
# _handle_email
# ---------------------------------------------------------------------------

async def test_handle_email_calls_agent_loop():
    """Verify agent_loop is called with tagged email content."""
    listener = _make_listener()

    mock_resources = MagicMock()
    mock_resources.soul = "soul"
    mock_resources.user_soul = None
    mock_resources.skill_info = []
    mock_resources.memory_context = None
    mock_resources.user_profile = None
    mock_resources.fliiq_email = None
    mock_resources.llm = MagicMock()
    mock_resources.tools = MagicMock()

    mock_result = MagicMock()
    mock_result.messages = [{"role": "assistant", "content": "Reply sent"}]
    mock_result.iterations = 1
    mock_result.final_text = "Reply sent"

    with patch.object(
        listener, "_get_or_create_session",
        new_callable=AsyncMock,
        return_value={"messages": [], "resources": mock_resources},
    ), patch(
        "fliiq.runtime.email.listener.agent_loop",
        new_callable=AsyncMock,
        return_value=mock_result,
    ) as mock_loop, patch(
        "fliiq.runtime.email.listener.assemble_agent_prompt",
        return_value="system prompt",
    ), patch(
        "fliiq.runtime.agent.setup.enrich_memory_context",
        return_value=None,
    ), patch(
        "fliiq.runtime.memory.extractor.extract_and_save_memories",
        new_callable=AsyncMock,
    ):
        email_data = _make_email_data()
        await listener._handle_email(email_data)

    mock_loop.assert_called_once()
    call_kwargs = mock_loop.call_args
    # Messages should contain the tagged email
    messages = call_kwargs.kwargs.get("messages") or call_kwargs[1].get("messages") or call_kwargs[0][1]
    # Find the user message
    user_msgs = [m for m in messages if m.get("role") == "user"] if isinstance(messages, list) else []
    assert len(user_msgs) >= 1
    assert "external_message" in user_msgs[0]["content"]
    assert "Message-ID:" in user_msgs[0]["content"]


async def test_handle_email_marks_read():
    """After successful processing, mark_read is called."""
    listener = _make_listener()

    mock_resources = MagicMock()
    mock_resources.soul = "soul"
    mock_resources.user_soul = None
    mock_resources.skill_info = []
    mock_resources.memory_context = None
    mock_resources.user_profile = None
    mock_resources.fliiq_email = None
    mock_resources.llm = MagicMock()
    mock_resources.tools = MagicMock()

    mock_result = MagicMock()
    mock_result.messages = []
    mock_result.iterations = 0

    with patch.object(
        listener, "_get_or_create_session",
        new_callable=AsyncMock,
        return_value={"messages": [], "resources": mock_resources},
    ), patch(
        "fliiq.runtime.email.listener.agent_loop",
        new_callable=AsyncMock,
        return_value=mock_result,
    ), patch(
        "fliiq.runtime.email.listener.assemble_agent_prompt",
        return_value="system prompt",
    ), patch(
        "fliiq.runtime.agent.setup.enrich_memory_context",
        return_value=None,
    ), patch.object(
        listener, "_mark_read",
        new_callable=AsyncMock,
    ) as mock_mark:
        email_data = _make_email_data(uid="42")
        await listener._handle_email(email_data)
        await listener._mark_read(email_data["id"])

    mock_mark.assert_called_once_with("42")


async def test_handle_email_error_stays_unread():
    """If agent_loop throws, email should NOT be marked read (handled in run loop)."""
    listener = _make_listener()

    mock_resources = MagicMock()
    mock_resources.soul = "soul"
    mock_resources.user_soul = None
    mock_resources.skill_info = []
    mock_resources.memory_context = None
    mock_resources.user_profile = None
    mock_resources.fliiq_email = None
    mock_resources.llm = MagicMock()
    mock_resources.tools = MagicMock()

    with patch.object(
        listener, "_get_or_create_session",
        new_callable=AsyncMock,
        return_value={"messages": [], "resources": mock_resources},
    ), patch(
        "fliiq.runtime.email.listener.agent_loop",
        new_callable=AsyncMock,
        side_effect=RuntimeError("LLM timeout"),
    ), patch(
        "fliiq.runtime.email.listener.assemble_agent_prompt",
        return_value="system prompt",
    ), patch(
        "fliiq.runtime.agent.setup.enrich_memory_context",
        return_value=None,
    ):
        # _handle_email should NOT raise (it catches internally)
        await listener._handle_email(_make_email_data())


# ---------------------------------------------------------------------------
# Session management
# ---------------------------------------------------------------------------

async def test_session_per_sender():
    """Different senders get different sessions."""
    listener = _make_listener()

    with patch(
        "fliiq.runtime.email.listener.setup_agent_resources",
        new_callable=AsyncMock,
    ) as mock_setup:
        mock_setup.return_value = MagicMock()
        s1 = await listener._get_or_create_session("alice@example.com")
        s2 = await listener._get_or_create_session("bob@example.com")

    assert s1 is not s2
    assert mock_setup.call_count == 2


async def test_same_sender_shares_session():
    """Same sender reuses existing session."""
    listener = _make_listener()

    with patch(
        "fliiq.runtime.email.listener.setup_agent_resources",
        new_callable=AsyncMock,
    ) as mock_setup:
        mock_setup.return_value = MagicMock()
        s1 = await listener._get_or_create_session("alice@example.com")
        s2 = await listener._get_or_create_session("alice@example.com")

    assert s1 is s2
    assert mock_setup.call_count == 1


# ---------------------------------------------------------------------------
# Run loop
# ---------------------------------------------------------------------------

async def test_run_loop_backoff_on_error():
    """Poll errors trigger exponential backoff."""
    listener = _make_listener()
    call_count = 0

    async def _fake_poll():
        nonlocal call_count
        call_count += 1
        if call_count <= 2:
            raise ConnectionError("IMAP down")
        # After 2 failures, stop the listener
        listener._running = False
        return []

    with patch.object(listener, "_poll_emails", side_effect=_fake_poll), \
         patch("fliiq.runtime.email.listener.asyncio.sleep", new_callable=AsyncMock) as mock_sleep:
        listener._running = True
        await listener.run()

    # backoff variable doubles as counter: 0→2→8
    assert mock_sleep.call_count >= 2
    backoff_vals = [c.args[0] for c in mock_sleep.call_args_list]
    assert backoff_vals[0] == 2   # min(2^(0+1), 300) = 2
    assert backoff_vals[1] == 8   # min(2^(2+1), 300) = 8


async def test_stop_ends_loop():
    """Calling stop() breaks the run loop."""
    listener = _make_listener()
    poll_count = 0

    async def _fake_poll():
        nonlocal poll_count
        poll_count += 1
        if poll_count >= 2:
            await listener.stop()
        return []

    with patch.object(listener, "_poll_emails", side_effect=_fake_poll), \
         patch("fliiq.runtime.email.listener.asyncio.sleep", new_callable=AsyncMock):
        await listener.run()

    assert listener._running is False
    assert poll_count >= 2
