# Copyright (c) 2026 Pyry Haulos
#
# This software is released under the MIT License.
# https://opensource.org/licenses/MIT

"""Tests for EmailChannelAdapter."""

from email.parser import BytesParser
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest

from airut.gateway.channel import ChannelSendError, RawMessage
from airut.gateway.config import (
    EmailAccountConfig,
    EmailAuthConfig,
    EmailChannelConfig,
    ImapConfig,
    SmtpConfig,
)
from airut.gateway.email.adapter import (
    EmailChannelAdapter,
    EmailParsedMessage,
    _clean_outbox,
)
from airut.gateway.email.responder import SMTPSendError


def _make_config() -> EmailChannelConfig:
    return EmailChannelConfig(
        account=EmailAccountConfig(
            username="test@example.com",
            from_address="Bot <bot@example.com>",
            password="pass",
        ),
        imap=ImapConfig(server="imap.example.com", port=993),
        smtp=SmtpConfig(server="smtp.example.com", port=587),
        auth=EmailAuthConfig(
            authorized_senders=["user@example.com"],
            trusted_authserv_id="mx.example.com",
        ),
    )


def _make_email(
    *,
    sender: str = "user@example.com",
    to: str = "bot@example.com",
    subject: str = "Hello",
    body: str = "Test body",
    message_id: str = "<msg1@example.com>",
    references: str = "",
    auth_results: str = ("mx.example.com; dmarc=pass; spf=pass"),
):
    raw = (
        f"From: {sender}\r\n"
        f"To: {to}\r\n"
        f"Subject: {subject}\r\n"
        f"Message-ID: {message_id}\r\n"
    )
    if references:
        raw += f"References: {references}\r\n"
    if auth_results:
        raw += f"Authentication-Results: {auth_results}\r\n"
    raw += f"\r\n{body}"
    return BytesParser().parsebytes(raw.encode())


def _make_raw_message(**kwargs: str) -> RawMessage:
    """Build a RawMessage wrapping an email for authenticate_and_parse tests."""
    sender = str(kwargs.get("sender", "user@example.com"))
    subject = str(kwargs.get("subject", "Hello"))
    email_msg = _make_email(**kwargs)
    return RawMessage(sender=sender, content=email_msg, display_title=subject)


def _make_adapter(
    config: EmailChannelConfig | None = None,
) -> tuple[
    EmailChannelAdapter,
    MagicMock,
    MagicMock,
    MagicMock,
]:
    """Create adapter with mocked auth/responder components."""
    cfg = config or _make_config()
    authenticator = MagicMock()
    authorizer = MagicMock()
    responder = MagicMock()
    adapter = EmailChannelAdapter(
        config=cfg,
        authenticator=authenticator,
        authorizer=authorizer,
        responder=responder,
        repo_id="test",
    )
    return adapter, authenticator, authorizer, responder


class TestAuthenticateAndParse:
    def test_successful_parse(self) -> None:
        adapter, auth, authz, _ = _make_adapter()
        auth.authenticate.return_value = "user@example.com"
        authz.is_authorized.return_value = True
        msg = _make_raw_message(body="Do something")

        result = adapter.authenticate_and_parse(msg)

        assert result is not None
        assert isinstance(result, EmailParsedMessage)
        assert result.sender == "user@example.com"
        assert result.body == "Do something"
        assert result.conversation_id is None
        assert result.original_message_id == "<msg1@example.com>"
        assert result._raw_message is msg.content

    def test_unauthenticated_raises(self) -> None:
        from airut.gateway.channel import AuthenticationError

        adapter, auth, _, _ = _make_adapter()
        auth.authenticate.return_value = None
        msg = _make_raw_message()

        with pytest.raises(AuthenticationError) as exc_info:
            adapter.authenticate_and_parse(msg)
        assert exc_info.value.sender == "user@example.com"
        assert "DMARC" in exc_info.value.reason

    def test_unauthorized_raises(self) -> None:
        from airut.gateway.channel import AuthenticationError

        adapter, auth, authz, _ = _make_adapter()
        auth.authenticate.return_value = "user@example.com"
        authz.is_authorized.return_value = False
        msg = _make_raw_message()

        with pytest.raises(AuthenticationError) as exc_info:
            adapter.authenticate_and_parse(msg)
        assert exc_info.value.sender == "user@example.com"
        assert "not authorized" in exc_info.value.reason

    def test_extracts_conversation_id_from_subject(self) -> None:
        adapter, auth, authz, _ = _make_adapter()
        auth.authenticate.return_value = "user@example.com"
        authz.is_authorized.return_value = True
        msg = _make_raw_message(subject="Re: [ID:aabb1122] Follow-up")

        result = adapter.authenticate_and_parse(msg)
        assert result is not None
        assert result.conversation_id == "aabb1122"

    def test_extracts_model_from_address(self) -> None:
        adapter, auth, authz, _ = _make_adapter()
        auth.authenticate.return_value = "user@example.com"
        authz.is_authorized.return_value = True
        msg = _make_raw_message(to="bot+opus@example.com")

        result = adapter.authenticate_and_parse(msg)
        assert result is not None
        assert result.model_hint == "opus"

    def test_channel_context_set(self) -> None:
        adapter, auth, authz, _ = _make_adapter()
        auth.authenticate.return_value = "user@example.com"
        authz.is_authorized.return_value = True
        msg = _make_raw_message()

        result = adapter.authenticate_and_parse(msg)
        assert result is not None
        assert "email interface" in result.channel_context

    def test_subject_included_for_new_conversation(
        self,
    ) -> None:
        """Subject appears in channel_context and subject field."""
        adapter, auth, authz, _ = _make_adapter()
        auth.authenticate.return_value = "user@example.com"
        authz.is_authorized.return_value = True
        msg = _make_raw_message(subject="Fix the login bug")

        result = adapter.authenticate_and_parse(msg)
        assert result is not None
        assert result.conversation_id is None
        assert "\n\nSubject: Fix the login bug" in result.channel_context
        assert result.subject == "Fix the login bug"

    def test_subject_excluded_for_resumed_conversation(
        self,
    ) -> None:
        """Subject excluded when conversation ID is present."""
        adapter, auth, authz, _ = _make_adapter()
        auth.authenticate.return_value = "user@example.com"
        authz.is_authorized.return_value = True
        msg = _make_raw_message(subject="Re: [ID:aabb1122] Fix the login bug")

        result = adapter.authenticate_and_parse(msg)
        assert result is not None
        assert result.conversation_id == "aabb1122"
        assert "Subject:" not in result.channel_context
        assert result.subject == ""

    def test_empty_subject_not_added(self) -> None:
        """Empty subject produces no Subject line or field."""
        adapter, auth, authz, _ = _make_adapter()
        auth.authenticate.return_value = "user@example.com"
        authz.is_authorized.return_value = True
        msg = _make_raw_message(subject="")

        result = adapter.authenticate_and_parse(msg)
        assert result is not None
        assert "Subject:" not in result.channel_context
        assert result.subject == ""

    def test_subject_excluded_when_conv_id_from_headers(
        self,
    ) -> None:
        """Subject excluded when conv_id from threading headers."""
        adapter, auth, authz, _ = _make_adapter()
        auth.authenticate.return_value = "user@example.com"
        authz.is_authorized.return_value = True
        msg = _make_raw_message(
            subject="Follow-up question",
            references=("<airut.aabb1122.12345.nonce@example.com>"),
        )

        result = adapter.authenticate_and_parse(msg)
        assert result is not None
        assert result.conversation_id == "aabb1122"
        assert "Subject:" not in result.channel_context
        assert result.subject == ""

    def test_display_title_set_from_email(self) -> None:
        """Parsed message includes decoded email subject for task tracker."""
        adapter, auth, authz, _ = _make_adapter()
        auth.authenticate.return_value = "user@example.com"
        authz.is_authorized.return_value = True
        msg = _make_raw_message(subject="Fix the login bug")

        result = adapter.authenticate_and_parse(msg)
        assert result is not None
        assert result.display_title == "Fix the login bug"

    def test_display_title_fallback_no_subject(self) -> None:
        """Empty email subject falls back to '(no subject)'."""
        adapter, auth, authz, _ = _make_adapter()
        auth.authenticate.return_value = "user@example.com"
        authz.is_authorized.return_value = True
        msg = _make_raw_message(subject="")

        result = adapter.authenticate_and_parse(msg)
        assert result is not None
        assert result.display_title == "(no subject)"

    def test_references_parsed(self) -> None:
        adapter, auth, authz, _ = _make_adapter()
        auth.authenticate.return_value = "user@example.com"
        authz.is_authorized.return_value = True
        msg = _make_raw_message(references="<ref1@ex.com> <ref2@ex.com>")

        result = adapter.authenticate_and_parse(msg)
        assert result is not None
        assert result.original_references == [
            "<ref1@ex.com>",
            "<ref2@ex.com>",
        ]


class TestSaveAttachments:
    def test_extracts_attachments_from_raw_message(
        self, tmp_path: Path
    ) -> None:
        adapter, _, _, _ = _make_adapter()
        parsed = EmailParsedMessage(
            sender="user@example.com",
            body="see attached",
            conversation_id=None,
            model_hint=None,
        )
        parsed._raw_message = _make_email()

        with patch(
            "airut.gateway.email.adapter.extract_attachments",
            return_value=["file.txt"],
        ) as mock_extract:
            result = adapter.save_attachments(parsed, tmp_path)

        assert result == ["file.txt"]
        mock_extract.assert_called_once_with(parsed._raw_message, tmp_path)

    def test_returns_empty_when_no_raw_message(self, tmp_path: Path) -> None:
        adapter, _, _, _ = _make_adapter()
        parsed = EmailParsedMessage(
            sender="user@example.com",
            body="no attach",
            conversation_id=None,
            model_hint=None,
        )

        result = adapter.save_attachments(parsed, tmp_path)
        assert result == []


class TestSendAcknowledgment:
    def test_sends_ack_without_dashboard(self) -> None:
        adapter, _, _, responder = _make_adapter()
        parsed = EmailParsedMessage(
            sender="user@example.com",
            body="body",
            conversation_id=None,
            model_hint=None,
            original_message_id="<msg1@ex.com>",
            decoded_subject="Test",
        )

        adapter.send_acknowledgment(parsed, "conv1", "sonnet", None)
        responder.send_reply.assert_called_once()
        call_kw = responder.send_reply.call_args[1]
        assert "started working" in call_kw["body"]
        assert call_kw["to"] == "user@example.com"

    def test_sends_ack_with_dashboard(self) -> None:
        adapter, _, _, responder = _make_adapter()
        parsed = EmailParsedMessage(
            sender="user@example.com",
            body="body",
            conversation_id=None,
            model_hint=None,
            original_message_id="<msg1@ex.com>",
            decoded_subject="Test",
        )

        adapter.send_acknowledgment(
            parsed,
            "conv1",
            "sonnet",
            "https://dash.example.com",
        )
        call_kw = responder.send_reply.call_args[1]
        assert "dash.example.com/conversation/conv1" in call_kw["body"]
        assert "dash.example.com/conversation/conv1" in call_kw["html_body"]

    def test_smtp_failure_non_fatal(self) -> None:
        adapter, _, _, responder = _make_adapter()
        responder.send_reply.side_effect = SMTPSendError("fail")
        parsed = EmailParsedMessage(
            sender="user@example.com",
            body="body",
            conversation_id=None,
            model_hint=None,
            decoded_subject="Test",
        )

        # Should not raise
        adapter.send_acknowledgment(parsed, "conv1", "sonnet", None)


class TestSendReply:
    def test_sends_reply_text(self) -> None:
        adapter, _, _, responder = _make_adapter()
        parsed = EmailParsedMessage(
            sender="user@example.com",
            body="body",
            conversation_id=None,
            model_hint=None,
            original_message_id="<msg1@ex.com>",
            decoded_subject="Test",
        )

        adapter.send_reply(parsed, "conv1", "Response text", "", [])
        responder.send_reply.assert_called_once()
        call_kw = responder.send_reply.call_args[1]
        assert call_kw["body"] == "Response text"

    def test_appends_usage_footer(self) -> None:
        adapter, _, _, responder = _make_adapter()
        parsed = EmailParsedMessage(
            sender="user@example.com",
            body="body",
            conversation_id=None,
            model_hint=None,
            original_message_id="<msg1@ex.com>",
            decoded_subject="Test",
        )

        adapter.send_reply(parsed, "conv1", "Done", "Cost: $0.01", [])
        call_kw = responder.send_reply.call_args[1]
        assert "Cost: $0.01" in call_kw["body"]

    def test_sends_attachments_from_outbox(self, tmp_path: Path) -> None:
        adapter, _, _, responder = _make_adapter()
        parsed = EmailParsedMessage(
            sender="user@example.com",
            body="body",
            conversation_id=None,
            model_hint=None,
            original_message_id="<msg1@ex.com>",
            decoded_subject="Test",
        )

        outbox = tmp_path / "outbox"
        outbox.mkdir()
        outfile = outbox / "report.txt"
        outfile.write_text("report content")

        with patch(
            "airut.gateway.email.adapter.collect_outbox_files",
            return_value=[("report.txt", b"report content")],
        ):
            adapter.send_reply(
                parsed,
                "conv1",
                "Done",
                "",
                [outfile],
            )

        call_kw = responder.send_reply.call_args[1]
        assert call_kw["attachments"] == [("report.txt", b"report content")]

    def test_retry_cleans_outbox_on_success(self, tmp_path: Path) -> None:
        adapter, _, _, responder = _make_adapter()
        parsed = EmailParsedMessage(
            sender="user@example.com",
            body="body",
            conversation_id=None,
            model_hint=None,
            original_message_id="<msg1@ex.com>",
            decoded_subject="Test",
        )

        outbox = tmp_path / "outbox"
        outbox.mkdir()
        outfile = outbox / "report.txt"
        outfile.write_text("data")

        # First call fails, second succeeds
        responder.send_reply.side_effect = [
            SMTPSendError("temp"),
            None,
        ]

        with patch(
            "airut.gateway.email.adapter.collect_outbox_files",
            return_value=[("report.txt", b"data")],
        ):
            adapter.send_reply(
                parsed,
                "conv1",
                "Done",
                "",
                [outfile],
            )

        # Outbox should be cleaned after retry success
        assert not outfile.exists()

    def test_retry_on_smtp_failure(self) -> None:
        adapter, _, _, responder = _make_adapter()
        parsed = EmailParsedMessage(
            sender="user@example.com",
            body="body",
            conversation_id=None,
            model_hint=None,
            original_message_id="<msg1@ex.com>",
            decoded_subject="Test",
        )

        # First call fails, second succeeds
        responder.send_reply.side_effect = [
            SMTPSendError("temp fail"),
            None,
        ]

        adapter.send_reply(parsed, "conv1", "Done", "", [])
        assert responder.send_reply.call_count == 2

    def test_retry_failure_raises(self) -> None:
        adapter, _, _, responder = _make_adapter()
        parsed = EmailParsedMessage(
            sender="user@example.com",
            body="body",
            conversation_id=None,
            model_hint=None,
            original_message_id="<msg1@ex.com>",
            decoded_subject="Test",
        )

        # Both calls fail
        responder.send_reply.side_effect = SMTPSendError("permanent fail")

        with pytest.raises(ChannelSendError):
            adapter.send_reply(parsed, "conv1", "Done", "", [])


class TestSendError:
    def test_sends_error_reply_with_conversation_id(self) -> None:
        """Error reply includes [ID:conv_id] in subject when conv_id given."""
        adapter, _, _, responder = _make_adapter()
        parsed = EmailParsedMessage(
            sender="user@example.com",
            body="body",
            conversation_id=None,
            model_hint=None,
            original_message_id="<msg1@ex.com>",
            original_references=["<ref1@ex.com>"],
            decoded_subject="Test Subject",
        )

        adapter.send_error(parsed, "conv1", "Something broke")
        responder.send_reply.assert_called_once()
        call_kw = responder.send_reply.call_args[1]
        assert call_kw["body"] == "Something broke"
        assert "[ID:conv1]" in call_kw["subject"]
        # References should include the full chain
        assert "<ref1@ex.com>" in call_kw["references"]
        assert "<msg1@ex.com>" in call_kw["references"]

    def test_sends_error_reply_without_conversation_id(self) -> None:
        """Error reply uses plain subject when conv_id is None."""
        adapter, _, _, responder = _make_adapter()
        parsed = EmailParsedMessage(
            sender="user@example.com",
            body="body",
            conversation_id=None,
            model_hint=None,
            original_message_id="<msg1@ex.com>",
            decoded_subject="Test Subject",
        )

        adapter.send_error(parsed, None, "Something broke")
        responder.send_reply.assert_called_once()
        call_kw = responder.send_reply.call_args[1]
        assert call_kw["subject"] == "Re: Test Subject"
        assert call_kw["references"] == ["<msg1@ex.com>"]

    def test_smtp_failure_logged(self) -> None:
        adapter, _, _, responder = _make_adapter()
        responder.send_reply.side_effect = SMTPSendError("fail")
        parsed = EmailParsedMessage(
            sender="user@example.com",
            body="body",
            conversation_id=None,
            model_hint=None,
            decoded_subject="Test",
        )

        # Should not raise
        adapter.send_error(parsed, "conv1", "error msg")


class TestSendRejection:
    def test_sends_rejection_without_dashboard(self) -> None:
        adapter, _, _, responder = _make_adapter()
        parsed = EmailParsedMessage(
            sender="user@example.com",
            body="body",
            conversation_id=None,
            model_hint=None,
            original_message_id="<msg1@ex.com>",
            decoded_subject="Test",
        )

        adapter.send_rejection(parsed, "conv1", "duplicate", None)
        responder.send_reply.assert_called_once()
        call_kw = responder.send_reply.call_args[1]
        assert "duplicate" in call_kw["body"]
        assert "conv1" in call_kw["body"]

    def test_sends_rejection_with_dashboard(self) -> None:
        adapter, _, _, responder = _make_adapter()
        parsed = EmailParsedMessage(
            sender="user@example.com",
            body="body",
            conversation_id=None,
            model_hint=None,
            original_message_id="<msg1@ex.com>",
            decoded_subject="Test",
        )

        adapter.send_rejection(
            parsed,
            "conv1",
            "busy",
            "https://dash.example.com",
        )
        call_kw = responder.send_reply.call_args[1]
        assert "dash.example.com/conversation/conv1" in call_kw["body"]
        assert "dash.example.com/conversation/conv1" in call_kw["html_body"]

    def test_smtp_failure_non_fatal(self) -> None:
        adapter, _, _, responder = _make_adapter()
        responder.send_reply.side_effect = SMTPSendError("fail")
        parsed = EmailParsedMessage(
            sender="user@example.com",
            body="body",
            conversation_id=None,
            model_hint=None,
            decoded_subject="Test",
        )

        # Should not raise
        adapter.send_rejection(parsed, "conv1", "reason", None)

    def test_html_escapes_reason(self) -> None:
        adapter, _, _, responder = _make_adapter()
        parsed = EmailParsedMessage(
            sender="user@example.com",
            body="body",
            conversation_id=None,
            model_hint=None,
            original_message_id="<msg1@ex.com>",
            decoded_subject="Test",
        )

        adapter.send_rejection(
            parsed,
            "conv1",
            "<script>alert('xss')</script>",
            None,
        )
        call_kw = responder.send_reply.call_args[1]
        assert "<script>" not in call_kw["html_body"]
        assert "&lt;script&gt;" in call_kw["html_body"]


class TestStructuredMessageId:
    """Tests for structured Message-ID generation on all reply types."""

    def test_acknowledgment_has_structured_id(self) -> None:
        adapter, _, _, responder = _make_adapter()
        parsed = EmailParsedMessage(
            sender="user@example.com",
            body="body",
            conversation_id=None,
            model_hint=None,
            original_message_id="<msg1@ex.com>",
            decoded_subject="Test",
        )

        adapter.send_acknowledgment(parsed, "aabb1122", "sonnet", None)
        call_kw = responder.send_reply.call_args[1]
        mid = call_kw["message_id"]
        assert mid.startswith("<airut.aabb1122.")
        assert "@example.com>" in mid

    def test_rejection_has_structured_id(self) -> None:
        adapter, _, _, responder = _make_adapter()
        parsed = EmailParsedMessage(
            sender="user@example.com",
            body="body",
            conversation_id=None,
            model_hint=None,
            original_message_id="<msg1@ex.com>",
            decoded_subject="Test",
        )

        adapter.send_rejection(parsed, "aabb1122", "Busy", None)
        call_kw = responder.send_reply.call_args[1]
        mid = call_kw["message_id"]
        assert mid.startswith("<airut.aabb1122.")
        assert "@example.com>" in mid

    def test_reply_has_structured_id(self) -> None:
        adapter, _, _, responder = _make_adapter()
        parsed = EmailParsedMessage(
            sender="user@example.com",
            body="body",
            conversation_id=None,
            model_hint=None,
            original_message_id="<msg1@ex.com>",
            decoded_subject="Test",
        )

        adapter.send_reply(parsed, "aabb1122", "Done", "", [])
        call_kw = responder.send_reply.call_args[1]
        mid = call_kw["message_id"]
        assert mid.startswith("<airut.aabb1122.")
        assert "@example.com>" in mid

    def test_retry_preserves_message_id(self) -> None:
        """SMTP retry uses the same Message-ID."""
        adapter, _, _, responder = _make_adapter()
        parsed = EmailParsedMessage(
            sender="user@example.com",
            body="body",
            conversation_id=None,
            model_hint=None,
            original_message_id="<msg1@ex.com>",
            decoded_subject="Test",
        )

        # First call fails, second succeeds
        responder.send_reply.side_effect = [
            SMTPSendError("fail"),
            None,
        ]
        adapter.send_reply(parsed, "aabb1122", "body", "", [])
        assert responder.send_reply.call_count == 2
        first_mid = responder.send_reply.call_args_list[0][1]["message_id"]
        second_mid = responder.send_reply.call_args_list[1][1]["message_id"]
        assert first_mid == second_mid
        assert first_mid.startswith("<airut.aabb1122.")


class TestBuildReplyHeaders:
    def test_adds_conversation_id_to_subject(self) -> None:
        adapter, _, _, _ = _make_adapter()
        parsed = EmailParsedMessage(
            sender="user@example.com",
            body="body",
            conversation_id=None,
            model_hint=None,
            original_message_id="<msg1@ex.com>",
            original_references=[],
            decoded_subject="Hello World",
        )

        subject, refs = adapter._build_reply_headers(parsed, "conv1")
        assert subject == "Re: [ID:conv1] Hello World"
        assert refs == ["<msg1@ex.com>"]

    def test_strips_re_prefix(self) -> None:
        adapter, _, _, _ = _make_adapter()
        parsed = EmailParsedMessage(
            sender="user@example.com",
            body="body",
            conversation_id=None,
            model_hint=None,
            original_message_id="<msg1@ex.com>",
            original_references=[],
            decoded_subject="Re: Re: Hello",
        )

        subject, _ = adapter._build_reply_headers(parsed, "conv1")
        assert subject == "Re: [ID:conv1] Hello"

    def test_preserves_existing_conv_id(self) -> None:
        adapter, _, _, _ = _make_adapter()
        parsed = EmailParsedMessage(
            sender="user@example.com",
            body="body",
            conversation_id=None,
            model_hint=None,
            original_message_id="<msg1@ex.com>",
            original_references=[],
            decoded_subject="Re: [ID:conv1] Hello",
        )

        subject, _ = adapter._build_reply_headers(parsed, "conv1")
        assert subject == "Re: [ID:conv1] Hello"

    def test_builds_references_list(self) -> None:
        adapter, _, _, _ = _make_adapter()
        parsed = EmailParsedMessage(
            sender="user@example.com",
            body="body",
            conversation_id=None,
            model_hint=None,
            original_message_id="<msg2@ex.com>",
            original_references=["<msg1@ex.com>"],
            decoded_subject="Test",
        )

        _, refs = adapter._build_reply_headers(parsed, "conv1")
        assert refs == ["<msg1@ex.com>", "<msg2@ex.com>"]

    def test_empty_references_and_message_id(self) -> None:
        adapter, _, _, _ = _make_adapter()
        parsed = EmailParsedMessage(
            sender="user@example.com",
            body="body",
            conversation_id=None,
            model_hint=None,
            original_message_id=None,
            original_references=[],
            decoded_subject="Test",
        )

        _, refs = adapter._build_reply_headers(parsed, "conv1")
        assert refs == []


class TestCleanOutbox:
    def test_removes_files(self, tmp_path: Path) -> None:
        outbox = tmp_path / "outbox"
        outbox.mkdir()
        (outbox / "file1.txt").write_text("a")
        (outbox / "file2.txt").write_text("b")

        _clean_outbox(
            [("file1.txt", b"a"), ("file2.txt", b"b")],
            outbox,
        )

        remaining = list(outbox.iterdir())
        assert len(remaining) == 0

    def test_noop_for_empty_attachments(self, tmp_path: Path) -> None:
        outbox = tmp_path / "outbox"
        outbox.mkdir()
        (outbox / "file.txt").write_text("keep me")

        _clean_outbox([], outbox)

        # File should still exist
        assert (outbox / "file.txt").exists()

    def test_noop_for_missing_outbox(self, tmp_path: Path) -> None:
        nonexistent = tmp_path / "nonexistent"

        # Should not raise
        _clean_outbox([("file.txt", b"data")], nonexistent)

    def test_handles_unlink_error(self, tmp_path: Path) -> None:
        outbox = tmp_path / "outbox"
        outbox.mkdir()
        f = outbox / "file.txt"
        f.write_text("data")

        with patch.object(Path, "unlink", side_effect=OSError("perm denied")):
            # Should not raise
            _clean_outbox([("file.txt", b"data")], outbox)


class TestResponderProperty:
    def test_exposes_responder(self) -> None:
        adapter, _, _, responder = _make_adapter()
        assert adapter.responder is responder


class TestListenerProperty:
    def test_exposes_listener(self) -> None:
        listener = MagicMock()
        adapter = EmailChannelAdapter(
            config=_make_config(),
            authenticator=MagicMock(),
            authorizer=MagicMock(),
            responder=MagicMock(),
            listener=listener,
            repo_id="test",
        )
        assert adapter.listener is listener

    def test_raises_when_no_listener(self) -> None:
        adapter, _, _, _ = _make_adapter()
        with pytest.raises(RuntimeError, match="created without a listener"):
            _ = adapter.listener


class TestCreatePlanStreamer:
    def test_returns_none(self) -> None:
        """Email does not support plan streaming."""
        from airut.gateway.channel import ParsedMessage

        adapter, _, _, _ = _make_adapter()
        parsed = ParsedMessage(
            sender="user@example.com",
            body="text",
            conversation_id=None,
            model_hint=None,
        )
        assert adapter.create_plan_streamer(parsed) is None


class TestChannelContext:
    def test_returns_email_instructions(self) -> None:
        """channel_context() returns the email system prompt."""
        adapter, _, _, _ = _make_adapter()
        ctx = adapter.channel_context()
        assert "email interface" in ctx
        assert "AskUserQuestion" in ctx
        assert "/outbox" in ctx
        assert "/storage" in ctx

    def test_consistent_with_authenticate_and_parse(
        self,
    ) -> None:
        """channel_context() matches what authenticate_and_parse uses."""
        adapter, auth, authz, _ = _make_adapter()
        auth.authenticate.return_value = "user@example.com"
        authz.is_authorized.return_value = True
        msg = _make_raw_message(subject="")

        parsed = adapter.authenticate_and_parse(msg)
        ctx = adapter.channel_context()
        # The parsed channel_context starts with the base context
        assert parsed.channel_context.startswith(ctx)


class TestCleanupConversations:
    def test_noop(self) -> None:
        """Email adapter has no per-conversation state to clean up."""
        adapter, _, _, _ = _make_adapter()
        # Should not raise or have any side effects
        adapter.cleanup_conversations({"conv1", "conv2"})

    def test_noop_empty_set(self) -> None:
        adapter, _, _, _ = _make_adapter()
        adapter.cleanup_conversations(set())


class TestFromConfig:
    def test_creates_all_components(self) -> None:
        with (
            patch(
                "airut.gateway.email.adapter.SenderAuthenticator"
            ) as mock_auth,
            patch("airut.gateway.email.adapter.SenderAuthorizer") as mock_authz,
            patch("airut.gateway.email.adapter.EmailResponder") as mock_resp,
            patch(
                "airut.gateway.email.adapter.EmailChannelListener"
            ) as mock_listener,
        ):
            config = _make_config()
            adapter = EmailChannelAdapter.from_config(config, repo_id="test")

        mock_auth.assert_called_once()
        mock_authz.assert_called_once()
        mock_resp.assert_called_once_with(config)
        mock_listener.assert_called_once_with(config, repo_id="test")
        assert adapter.listener is mock_listener.return_value
        assert adapter.responder is mock_resp.return_value


class TestSendNewMessage:
    """Test send_new_message for scheduled task delivery."""

    def test_basic_send(self) -> None:
        adapter, _, _, responder = _make_adapter()
        adapter.send_new_message(
            to="user@example.com",
            subject="[ID:conv1] daily-report",
            body="Task results",
            conversation_id="conv1",
            attachments=[],
        )

        responder.send_reply.assert_called_once()
        call_kw = responder.send_reply.call_args[1]
        assert call_kw["to"] == "user@example.com"
        assert call_kw["subject"] == "[ID:conv1] daily-report"
        assert "Task results" in call_kw["body"]
        # No in_reply_to — this is a new conversation
        assert call_kw.get("in_reply_to") is None
        # Message-ID includes conversation ID
        assert "conv1" in call_kw["message_id"]
        assert call_kw["message_id"].startswith("<airut.")
        # Empty attachments → None
        assert call_kw["attachments"] is None

    def test_send_with_attachments(self) -> None:
        adapter, _, _, responder = _make_adapter()
        attachments = [("report.txt", b"data")]

        adapter.send_new_message(
            to="user@example.com",
            subject="test",
            body="body",
            conversation_id="conv2",
            attachments=attachments,
        )

        call_kw = responder.send_reply.call_args[1]
        assert call_kw["attachments"] == [("report.txt", b"data")]
