"""Абстракция отправки email: контракт `EmailBackend`, dev- и SMTP-реализации.

Выбор бэкенда (`console`|`smtp`) — переменная окружения `EMAIL_BACKEND`
(`core/config.py`), не настройка в БД: секреты SMTP — только в `.env`,
а `SettingsOut` админки их не должен видеть.
"""

from __future__ import annotations

import logging
from collections.abc import Sequence
from dataclasses import dataclass
from email.message import EmailMessage
from typing import TYPE_CHECKING, Protocol

import aiosmtplib

if TYPE_CHECKING:
    from core.config import Settings

logger = logging.getLogger(__name__)


@dataclass(frozen=True)
class EmailAttachment:
    """Вложение письма (например, `.ics`-приглашение, `text/calendar; method=REQUEST`)."""

    filename: str
    content: bytes
    mime_type: str


class EmailSendError(Exception):
    """Ошибка отправки письма транспортом.

    `retryable=True` — временный сбой транспорта (сервер недоступен/оборвал
    соединение/таймаут): вызывающая Celery-задача должна повторить попытку.
    `retryable=False` — конкретный получатель отклонён сервером (повторять
    без изменения адреса бессмысленно) — вызывающая сторона пропускает его,
    не роняя всю рассылку (см. `workers/tasks/notify.py`).
    """

    def __init__(self, message: str, *, retryable: bool) -> None:
        super().__init__(message)
        self.retryable = retryable


class EmailBackend(Protocol):
    """Контракт отправки email; новые бэкенды подставляются без правки ядра."""

    async def send(
        self,
        *,
        to: str,
        subject: str,
        body: str,
        html_body: str | None = None,
        attachments: Sequence[EmailAttachment] = (),
        reply_to: str | None = None,
    ) -> None:
        """Отправить письмо получателю `to` (plaintext body обязателен, HTML — альтернатива).

        `reply_to` — необязательный контактный адрес инстанса (см.
        `services/instance_settings.py::InstanceConfig.contact_email`), проставляется
        заголовком `Reply-To`, если задан.
        """
        ...


class ConsoleEmailBackend:
    """Бэкенд для разработки: пишет письмо в лог вместо реальной отправки."""

    async def send(
        self,
        *,
        to: str,
        subject: str,
        body: str,
        html_body: str | None = None,
        attachments: Sequence[EmailAttachment] = (),
        reply_to: str | None = None,
    ) -> None:
        """Залогировать письмо (вложения — только имена файлов, без содержимого)."""
        attachment_names = ", ".join(a.filename for a in attachments) or "нет"
        logger.info(
            "EMAIL to=%s subject=%s reply_to=%s attachments=[%s]\n%s",
            to,
            subject,
            reply_to or "нет",
            attachment_names,
            body,
        )


class SmtpEmailBackend:
    """Бэкенд реальной отправки email через SMTP (`aiosmtplib.send`)."""

    def __init__(
        self,
        *,
        hostname: str,
        port: int,
        username: str | None,
        password: str | None,
        start_tls: bool,
        use_tls: bool,
        timeout: float,
        sender: str,
    ) -> None:
        self._hostname = hostname
        self._port = port
        self._username = username
        self._password = password
        self._start_tls = start_tls
        self._use_tls = use_tls
        self._timeout = timeout
        self._sender = sender

    async def send(
        self,
        *,
        to: str,
        subject: str,
        body: str,
        html_body: str | None = None,
        attachments: Sequence[EmailAttachment] = (),
        reply_to: str | None = None,
    ) -> None:
        """Отправить письмо; ошибки транспорта транслируются в `EmailSendError`."""
        message = _build_message(
            sender=self._sender,
            to=to,
            subject=subject,
            body=body,
            html_body=html_body,
            attachments=attachments,
            reply_to=reply_to,
        )
        try:
            await aiosmtplib.send(
                message,
                hostname=self._hostname,
                port=self._port,
                username=self._username or None,
                password=self._password or None,
                start_tls=self._start_tls,
                use_tls=self._use_tls,
                timeout=self._timeout,
            )
        except aiosmtplib.SMTPRecipientsRefused as exc:
            # Сервер отклонил конкретного получателя — повтор не поможет без
            # изменения адреса; вызывающая сторона (notify_session) пропускает
            # только его, не роняя рассылку остальным получателям.
            logger.warning("SMTP: получатель %s отклонён сервером: %s", to, exc)
            raise EmailSendError(f"получатель отклонён сервером: {to}", retryable=False) from exc
        except (
            aiosmtplib.SMTPConnectError,
            aiosmtplib.SMTPServerDisconnected,
            aiosmtplib.SMTPTimeoutError,
            aiosmtplib.SMTPAuthenticationError,
        ) as exc:
            # Временный сбой транспорта — стоит повторить попытку позже.
            # `SMTPTimeoutError` после отправки DATA — доставка неизвестна:
            # принят at-least-once, редкий дубль
            # предпочтительнее потери письма.
            logger.warning("SMTP: временный сбой при отправке на %s: %s", to, exc)
            raise EmailSendError(f"временный сбой SMTP: {exc}", retryable=True) from exc


def _build_message(
    *,
    sender: str,
    to: str,
    subject: str,
    body: str,
    html_body: str | None,
    attachments: Sequence[EmailAttachment],
    reply_to: str | None = None,
) -> EmailMessage:
    """Собрать `EmailMessage`: plaintext (+ HTML-альтернатива) + вложения."""
    message = EmailMessage()
    message["From"] = sender
    message["To"] = to
    message["Subject"] = subject
    if reply_to:
        message["Reply-To"] = reply_to
    message.set_content(body)
    if html_body is not None:
        message.add_alternative(html_body, subtype="html")
    for attachment in attachments:
        maintype, _, rest = attachment.mime_type.partition("/")
        subtype = rest.split(";", 1)[0].strip() or "octet-stream"
        message.add_attachment(
            attachment.content,
            maintype=maintype or "application",
            subtype=subtype,
            filename=attachment.filename,
        )
    return message


def create_email_backend(settings: Settings) -> EmailBackend:
    """Собрать бэкенд отправки email по `settings.email_backend` (`console` по умолчанию)."""
    if settings.email_backend == "smtp":
        return SmtpEmailBackend(
            hostname=settings.smtp_host,
            port=settings.smtp_port,
            username=settings.smtp_username,
            password=settings.smtp_password,
            start_tls=settings.smtp_start_tls,
            use_tls=settings.smtp_use_tls,
            timeout=settings.smtp_timeout_s,
            sender=settings.smtp_from,
        )
    return ConsoleEmailBackend()
