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

"""Email channel adapter.

Implements the ChannelAdapter protocol for email, wrapping DMARC
authentication, MIME parsing, and SMTP reply delivery.
"""

from __future__ import annotations

import html as html_module
import logging
from dataclasses import dataclass, field
from email.message import Message
from pathlib import Path

from airut.gateway.channel import (
    AuthenticationError,
    ChannelAdapter,
    ChannelSendError,
    ParsedMessage,
    PlanStreamer,
    RawMessage,
)
from airut.gateway.config import EmailChannelConfig
from airut.gateway.email.channel_listener import EmailChannelListener
from airut.gateway.email.parsing import (
    collect_outbox_files,
    decode_subject,
    extract_attachments,
    extract_body,
    extract_conversation_id,
    extract_conversation_id_from_headers,
    extract_model_from_address,
)
from airut.gateway.email.responder import (
    EmailResponder,
    SMTPSendError,
    generate_message_id,
)
from airut.gateway.email.security import SenderAuthenticator, SenderAuthorizer


logger = logging.getLogger(__name__)


@dataclass
class EmailParsedMessage(ParsedMessage):
    """Email-specific parsed message with threading state.

    Carries the additional fields needed for constructing email replies
    (Message-ID references, decoded subject). The core treats this as
    a plain ParsedMessage; the adapter downcasts when sending replies.
    """

    original_message_id: str | None = None
    """Message-ID of the incoming email for In-Reply-To."""

    original_references: list[str] = field(default_factory=list)
    """References header values for threading."""

    decoded_subject: str = ""
    """Decoded subject line for reply construction."""

    _raw_message: Message | None = field(default=None, repr=False)
    """Raw email message retained for deferred attachment extraction.

    Set by authenticate_and_parse() and consumed by save_attachments().
    Not part of the public ParsedMessage interface."""


class EmailChannelAdapter(ChannelAdapter):
    """ChannelAdapter implementation for email (IMAP/SMTP).

    Wraps SenderAuthenticator, SenderAuthorizer, email parsing functions,
    EmailListener, and EmailResponder behind the ChannelAdapter interface.
    """

    def __init__(
        self,
        config: EmailChannelConfig,
        authenticator: SenderAuthenticator,
        authorizer: SenderAuthorizer,
        responder: EmailResponder,
        listener: EmailChannelListener | None = None,
        *,
        repo_id: str,
    ) -> None:
        self._config = config
        self._repo_id = repo_id
        self._authenticator = authenticator
        self._authorizer = authorizer
        self._responder = responder
        self._listener = listener

    @classmethod
    def from_config(
        cls, config: EmailChannelConfig, *, repo_id: str
    ) -> EmailChannelAdapter:
        """Create an adapter with all email components from config.

        Constructs the authenticator, authorizer, responder, and listener
        internally. This is the primary factory for production use.

        Args:
            config: Email channel configuration.
            repo_id: Repository identifier (used for log messages).

        Returns:
            Fully configured EmailChannelAdapter.
        """
        return cls(
            config=config,
            authenticator=SenderAuthenticator(
                config.auth.trusted_authserv_id,
                allow_internal_auth_fallback=(
                    config.auth.microsoft_internal_fallback
                ),
            ),
            authorizer=SenderAuthorizer(config.auth.authorized_senders),
            responder=EmailResponder(config),
            listener=EmailChannelListener(config, repo_id=repo_id),
            repo_id=repo_id,
        )

    @property
    def listener(self) -> EmailChannelListener:
        """Email channel listener for message lifecycle management.

        Raises:
            RuntimeError: If adapter was created without a listener.
        """
        if self._listener is None:
            raise RuntimeError("EmailChannelAdapter created without a listener")
        return self._listener

    @property
    def responder(self) -> EmailResponder:
        """Expose responder for low-level SMTP access."""
        return self._responder

    def channel_context(self) -> str:
        """Return the base system prompt for the email channel.

        This is the standard set of instructions about the email
        interface, formatting, tool limitations, and directory layout
        that applies to every email-delivered task — both interactive
        messages and scheduled tasks.
        """
        return (
            "User is interacting with this session via email interface "
            "and will receive your last reply as email. "
            "After the reply, everything not in /workspace, /inbox, "
            "and /storage is reset. "
            "Markdown formatting is supported in your responses. "
            "To send files back to the user, place them in the "
            "/outbox directory root (no subdirectories). "
            "Use /storage to persist files across messages.\n\n"
            "IMPORTANT: AskUserQuestion and plan mode tools "
            "(EnterPlanMode/ExitPlanMode) do not work over email "
            "interface. If you need clarification, include questions in "
            "your response text and the user will reply via email."
        )

    def authenticate_and_parse(
        self, raw_message: RawMessage[Message]
    ) -> EmailParsedMessage:
        """Authenticate sender via DMARC and parse the email.

        Args:
            raw_message: RawMessage wrapping an email.message.Message.

        Returns:
            EmailParsedMessage if authenticated and authorized.

        Raises:
            AuthenticationError: If DMARC verification or sender
                authorization fails.
        """
        email_msg = raw_message.content
        sender = email_msg.get("From", "")
        message_id = email_msg.get("Message-ID", "")
        repo_id = self._repo_id

        # Authentication: verify DMARC from trusted server
        authenticated_sender = self._authenticator.authenticate(email_msg)
        if authenticated_sender is None:
            logger.warning(
                "Repo '%s': rejecting unauthenticated message from %s "
                "(Message-ID: %s)",
                repo_id,
                sender,
                message_id,
            )
            raise AuthenticationError(
                sender=sender,
                reason="DMARC verification failed",
            )

        # Authorization: check sender is allowed
        if not self._authorizer.is_authorized(authenticated_sender):
            logger.warning(
                "Repo '%s': rejecting unauthorized message from %s "
                "(Message-ID: %s)",
                repo_id,
                sender,
                message_id,
            )
            raise AuthenticationError(
                sender=sender,
                reason="sender not authorized",
            )

        # Extract conversation ID: headers first, then subject fallback
        subject = decode_subject(email_msg)
        references = email_msg.get("References", "").split()
        in_reply_to = email_msg.get("In-Reply-To")
        conv_id = extract_conversation_id_from_headers(
            references, in_reply_to
        ) or extract_conversation_id(subject)

        # Extract model from To address (e.g., airut+opus@domain.com)
        to_address = email_msg.get("To", "")
        model_hint = extract_model_from_address(to_address)

        # Extract body (HTML quotes stripped)
        clean_body = extract_body(email_msg)

        # Build channel context from the shared base
        ctx = self.channel_context()

        # Include subject in channel context for new conversations
        # so Claude can see the email subject line as context.
        if not conv_id and subject:
            ctx += f"\n\nSubject: {subject}"

        # Expose subject on ParsedMessage for protocol-agnostic checks
        # (e.g. allowing empty body when subject carries the request).
        msg_subject = subject if not conv_id and subject else ""

        return EmailParsedMessage(
            sender=sender,
            body=clean_body,
            conversation_id=conv_id,
            model_hint=model_hint,
            display_title=subject or "(no subject)",
            channel_context=ctx,
            subject=msg_subject,
            original_message_id=email_msg.get("Message-ID"),
            original_references=references,
            decoded_subject=subject,
            _raw_message=email_msg,
        )

    def save_attachments(
        self, parsed: ParsedMessage, inbox_dir: Path
    ) -> list[str]:
        """Extract and save email attachments to the inbox directory.

        Called by the core after conversation setup, when the inbox
        directory is available.

        Args:
            parsed: Parsed message (must be an EmailParsedMessage).
            inbox_dir: Path to save attachments to.

        Returns:
            List of saved filenames.
        """
        assert isinstance(parsed, EmailParsedMessage)
        if parsed._raw_message is None:
            return []
        return extract_attachments(parsed._raw_message, inbox_dir)

    def send_acknowledgment(
        self,
        parsed: ParsedMessage,
        conversation_id: str,
        model: str,
        dashboard_url: str | None,
    ) -> None:
        """Send email acknowledgment for new conversations."""
        assert isinstance(parsed, EmailParsedMessage)

        subject, references_list = self._build_reply_headers(
            parsed, conversation_id
        )

        if dashboard_url:
            task_url = f"{dashboard_url}/conversation/{conversation_id}"
            body = (
                f"I've started working on this and will reply shortly. "
                f"See progress at {task_url}"
            )
            html_body = (
                f"I've started working on this and will reply shortly. "
                f'See progress at <a href="{task_url}">{task_url}</a>'
            )
        else:
            body = "I've started working on this and will reply shortly."
            html_body = "I've started working on this and will reply shortly."

        outgoing_message_id = generate_message_id(
            conversation_id, self._config.account.from_address
        )

        try:
            self._responder.send_reply(
                to=parsed.sender,
                subject=subject,
                body=body,
                in_reply_to=parsed.original_message_id,
                references=references_list,
                html_body=html_body,
                message_id=outgoing_message_id,
            )
            logger.info(
                "Sent acknowledgment to %s for conversation %s",
                parsed.sender,
                conversation_id,
            )
        except SMTPSendError as e:
            logger.warning("Failed to send acknowledgment (non-fatal): %s", e)

    def send_reply(
        self,
        parsed: ParsedMessage,
        conversation_id: str,
        response_text: str,
        usage_footer: str,
        outbox_files: list[Path],
    ) -> None:
        """Send email reply with response text and attachments."""
        assert isinstance(parsed, EmailParsedMessage)

        subject, references_list = self._build_reply_headers(
            parsed, conversation_id
        )

        # Append usage footer if present
        body = response_text
        if usage_footer:
            body = f"{response_text}\n\n*{usage_footer}*"

        outgoing_message_id = generate_message_id(
            conversation_id, self._config.account.from_address
        )

        # Collect attachments from outbox directory
        attachments: list[tuple[str, bytes]] | None = None
        outbox_path: Path | None = None
        if outbox_files:
            outbox_path = outbox_files[0].parent
            attachments_data = collect_outbox_files(outbox_path)
            if attachments_data:
                attachments = attachments_data
                logger.info(
                    "Attaching %d files from outbox: %s",
                    len(attachments_data),
                    ", ".join(f[0] for f in attachments_data),
                )

        try:
            self._responder.send_reply(
                to=parsed.sender,
                subject=subject,
                body=body,
                in_reply_to=parsed.original_message_id,
                references=references_list,
                attachments=attachments,
                message_id=outgoing_message_id,
            )

            if outbox_path:
                _clean_outbox(attachments or [], outbox_path)

        except SMTPSendError as e:
            logger.error("Failed to send reply: %s", e)
            # Retry once
            logger.info("Retrying SMTP send...")
            try:
                self._responder.send_reply(
                    to=parsed.sender,
                    subject=subject,
                    body=body,
                    in_reply_to=parsed.original_message_id,
                    references=references_list,
                    attachments=attachments,
                    message_id=outgoing_message_id,
                )

                if outbox_path:
                    _clean_outbox(attachments or [], outbox_path)

            except SMTPSendError as retry_error:
                logger.critical("SMTP retry failed: %s", retry_error)
                raise ChannelSendError(str(retry_error)) from retry_error

    def send_error(
        self,
        parsed: ParsedMessage,
        conversation_id: str | None,
        error_message: str,
    ) -> None:
        """Send error notification email."""
        assert isinstance(parsed, EmailParsedMessage)

        if conversation_id:
            subject, references_list = self._build_reply_headers(
                parsed, conversation_id
            )
        else:
            subject = f"Re: {parsed.decoded_subject}"
            message_id = parsed.original_message_id
            references_list = [message_id] if message_id else []

        try:
            self._responder.send_reply(
                to=parsed.sender,
                subject=subject,
                body=error_message,
                in_reply_to=parsed.original_message_id,
                references=references_list,
            )
        except SMTPSendError as e:
            logger.error("Failed to send error reply: %s", e)

    def send_rejection(
        self,
        parsed: ParsedMessage,
        conversation_id: str,
        reason: str,
        dashboard_url: str | None,
    ) -> None:
        """Send rejection reply when a message cannot be processed."""
        assert isinstance(parsed, EmailParsedMessage)

        subject, references_list = self._build_reply_headers(
            parsed, conversation_id
        )

        escaped_reason = html_module.escape(reason)

        if dashboard_url:
            task_url = f"{dashboard_url}/conversation/{conversation_id}"
            body = (
                "Your message could not be processed.\n"
                "\n"
                f"Reason: {reason}\n"
                "\n"
                f"Conversation ID: {conversation_id} ({task_url})"
            )
            html_body = (
                "Your message could not be processed."
                "<br><br>"
                f"Reason: {escaped_reason}"
                "<br><br>"
                f'Conversation ID: <a href="{task_url}">'
                f"{conversation_id}</a>"
            )
        else:
            body = (
                "Your message could not be processed.\n"
                "\n"
                f"Reason: {reason}\n"
                "\n"
                f"Conversation ID: {conversation_id}"
            )
            html_body = (
                "Your message could not be processed."
                "<br><br>"
                f"Reason: {escaped_reason}"
                "<br><br>"
                f"Conversation ID: {conversation_id}"
            )

        outgoing_message_id = generate_message_id(
            conversation_id, self._config.account.from_address
        )

        try:
            self._responder.send_reply(
                to=parsed.sender,
                subject=subject,
                body=body,
                in_reply_to=parsed.original_message_id,
                references=references_list,
                html_body=html_body,
                message_id=outgoing_message_id,
            )
            logger.info(
                "Sent rejection reply to %s for conversation %s",
                parsed.sender,
                conversation_id,
            )
        except SMTPSendError as e:
            logger.warning("Failed to send rejection reply (non-fatal): %s", e)

    def send_new_message(
        self,
        to: str,
        subject: str,
        body: str,
        conversation_id: str,
        attachments: list[tuple[str, bytes]],
    ) -> None:
        """Send a new (non-reply) email and register for reply routing.

        Used by scheduled tasks to deliver results. The Message-ID embeds
        the conversation ID so replies route back through the normal
        interactive flow.

        Args:
            to: Recipient email address.
            subject: Email subject line.
            body: Markdown-formatted body text.
            conversation_id: Conversation ID for Message-ID encoding.
            attachments: List of (filename, content) pairs.
        """
        outgoing_message_id = generate_message_id(
            conversation_id, self._config.account.from_address
        )

        self._responder.send_reply(
            to=to,
            subject=subject,
            body=body,
            message_id=outgoing_message_id,
            attachments=attachments if attachments else None,
        )

    def create_plan_streamer(
        self, parsed: ParsedMessage
    ) -> PlanStreamer | None:
        """Email does not support plan streaming."""
        return None

    def cleanup_conversations(self, active_conversation_ids: set[str]) -> None:
        """No-op: email adapter has no per-conversation state to clean up."""

    def _build_reply_headers(
        self,
        parsed: EmailParsedMessage,
        conversation_id: str,
    ) -> tuple[str, list[str]]:
        """Build subject and references for an email reply.

        Args:
            parsed: Email parsed message with threading state.
            conversation_id: Conversation ID.

        Returns:
            Tuple of (subject, references_list).
        """
        # Strip existing "Re: " prefixes to avoid accumulation
        clean_subject = parsed.decoded_subject
        while clean_subject.lower().startswith("re: "):
            clean_subject = clean_subject[4:]

        # Build subject with conversation ID
        if f"[ID:{conversation_id}]" not in clean_subject:
            subject = f"Re: [ID:{conversation_id}] {clean_subject}"
        else:
            subject = f"Re: {clean_subject}"

        # Build references list
        references = parsed.original_references
        message_id = parsed.original_message_id
        references_list = (
            references + [message_id]
            if references and message_id
            else [message_id]
            if message_id
            else []
        )

        return subject, references_list


def _clean_outbox(
    attachments: list[tuple[str, bytes]], outbox_path: Path
) -> None:
    """Remove files from outbox after successful send."""
    if not attachments or not outbox_path.exists():
        return
    for filepath in outbox_path.iterdir():
        if filepath.is_file():
            try:
                filepath.unlink()
            except OSError as e:
                logger.warning(
                    "Failed to delete outbox file %s: %s",
                    filepath,
                    e,
                )
    logger.info("Cleaned up outbox directory")
