"""Mail client — admin operations and Mailbox for agent operations."""

from __future__ import annotations

import os
import time as _time
from typing import Literal

import httpx

from primitif._models import PaginatedList
from primitif._transport import (
    DEFAULT_MAX_RETRIES,
    RETRY_STATUS_CODES,
    _retry_delay,
    _transport_retry_delay,
    configure_logging,
    raise_for_status,
    request_with_retries,
)
from primitif.mail.exceptions import (
    MailAuthError,
    MailConflictError,
    MailNetworkError,
    MailError,
    MailNotFoundError,
    MailRateLimitError,
    MailServerError,
    MailValidationError,
)
from primitif.mail.models import (
    AccountInfo,
    AllowlistEntry,
    AttachmentInfo,
    CustomDomain,
    InboxMessage,
    MailboxInfo,
    MailboxListItem,
    MessageDetail,
    PlanLimits,
    ProtectedRecipient,
    SendResult,
    ThreadDetail,
    ThreadSummary,
    WebhookCreated,
    WebhookInfo,
)

DEFAULT_BASE_URL = "https://api.mail.primitif.ai"

logger = configure_logging("mail")

_EXC_MAP = {
    401: MailAuthError,
    404: MailNotFoundError,
    409: MailConflictError,
    422: MailValidationError,
    429: MailRateLimitError,
}


class Mailbox:
    """A single ephemeral mailbox — send, receive, and manage email.

    Typically created via ``p.mail.create_mailbox()``, but can also
    be instantiated directly with a token for agent-scoped operations.

    Usage::

        with Mailbox() as mb:  # reads PRIMITIF_MAIL_TOKEN from env
            mb.send(to="user@example.com", subject="Hi", body_text="Hello")
            for msg in mb.inbox():
                detail = mb.read(msg.id)
            mb.delete()
    """

    def __init__(
        self,
        token: str | None = None,
        *,
        base_url: str | None = None,
        max_retries: int = DEFAULT_MAX_RETRIES,
        timeout: float = 30.0,
        _client: httpx.Client | None = None,
    ) -> None:
        """Create a Mailbox client for agent-scoped email operations.

        Args:
            token: Mailbox token (``mb_tok_...``). Falls back to the
                ``PRIMITIF_MAIL_TOKEN`` environment variable if omitted.
            base_url: Mail API base URL. Defaults to ``PRIMITIF_MAIL_URL``
                env var or ``https://api.mail.primitif.ai``.
            max_retries: Number of automatic retries on transient errors.
                Defaults to 2.
            timeout: HTTP request timeout in seconds. Defaults to 30.0.

        Raises:
            MailError: If no token is provided and ``PRIMITIF_MAIL_TOKEN``
                is not set.

        Example:
            >>> mb = Mailbox(token="mb_tok_abc123")
            >>> print(mb.address)
            'agent-7f3a@mail.primitif.ai'
        """
        self._destroyed = False
        self._token: str | None = None

        if _client is not None:
            # Internal: created by MailAdmin.create_mailbox()
            self._client = _client
            self._max_retries = max_retries
            self._info_cache: MailboxInfo | None = None
            self._owns_client = False
            return

        from primitif._version import __version__

        if token is None:
            token = os.environ.get("PRIMITIF_MAIL_TOKEN")
        if token is not None:
            token = token.strip()
        if not token:
            raise MailError(
                message="No token provided. Pass token= or set PRIMITIF_MAIL_TOKEN env var."
            )

        self._token = token

        if base_url is None:
            base_url = os.environ.get("PRIMITIF_MAIL_URL", DEFAULT_BASE_URL)

        self._max_retries = max_retries
        self._info_cache = None
        self._owns_client = True
        self._client = httpx.Client(
            base_url=base_url.rstrip("/"),
            headers={
                "Authorization": f"Bearer {token}",
                "User-Agent": f"primitif/{__version__}",
            },
            timeout=timeout,
        )

    def _request(self, method: str, path: str, **kwargs) -> dict:
        if self._destroyed:
            raise MailError(message="Mailbox has been destroyed.")
        return request_with_retries(
            self._client,
            method,
            path,
            self._max_retries,
            exc_map=_EXC_MAP,
            server_exc=MailServerError,
            base_exc=MailError,
            connection_exc=MailNetworkError,
            logger=logger,
            **kwargs,
        )

    # ── Info ──

    @property
    def address(self) -> str:
        """The mailbox email address (fetched and cached).

        Calls ``info()`` on first access to populate the cache.

        Returns:
            The full email address (e.g. ``"agent-7f3a@mail.primitif.ai"``).

        Example:
            >>> print(mb.address)
            'agent-7f3a@mail.primitif.ai'
        """
        if self._info_cache is None:
            self.info()
        return self._info_cache.address

    @property
    def token(self) -> str | None:
        """The mailbox token, if available.

        Present when created via ``p.mail.create_mailbox()`` or when
        ``token=`` was passed to the constructor. Useful for persisting
        tokens so a crashed agent can reconnect with ``Mailbox(token=...)``.

        Returns:
            The token string (``mb_tok_...``) or None if not available.

        Example:
            >>> token = mb.token
            >>> # persist token, then later:
            >>> mb = Mailbox(token=token)
        """
        return self._token

    def info(self) -> MailboxInfo:
        """Get mailbox details.

        Returns:
            MailboxInfo with ``id``, ``address``, ``name``, ``expires_at``,
            and ``created_at`` fields.

        Raises:
            MailAuthError: If the token is invalid or expired.
            MailNetworkError: On connection failure.

        Example:
            >>> info = mb.info()
            >>> print(info.address, info.expires_at)
        """
        data = self._request("GET", "/v1/me")
        result = MailboxInfo(**data)
        self._info_cache = result
        return result

    # ── Inbox & messages ──

    def inbox(
        self,
        *,
        unread_only: bool = False,
        limit: int = 20,
        cursor: str | None = None,
    ) -> PaginatedList[InboxMessage]:
        """List inbox messages.

        Args:
            unread_only: If True, return only unread messages. Defaults to False.
            limit: Maximum number of messages per page. Defaults to 20.
            cursor: Pagination cursor from a previous response.

        Returns:
            PaginatedList[InboxMessage] supporting iteration and
            ``.auto_paging_iter()`` for automatic pagination.

        Raises:
            MailAuthError: If the token is invalid.
            MailNetworkError: On connection failure.

        Example:
            >>> for msg in mb.inbox(unread_only=True):
            ...     print(msg.from_address, msg.subject)
            >>> # Auto-paginate through all messages:
            >>> for msg in mb.inbox().auto_paging_iter():
            ...     print(msg.id)
        """
        params: dict = {"limit": limit}
        if unread_only:
            params["unread_only"] = "true"
        if cursor is not None:
            params["cursor"] = cursor
        data = self._request("GET", "/v1/me/inbox", params=params)
        items = [InboxMessage(**m) for m in data["items"]]
        result = PaginatedList(
            items=items, cursor=data.get("cursor"), has_more=data.get("has_more", False)
        )
        result._fetch_fn = self.inbox
        result._fetch_kwargs = {"unread_only": unread_only, "limit": limit}
        return result

    def read(self, message_id: str) -> MessageDetail:
        """Read a full message.

        Marks the message as read on the server.

        Args:
            message_id: The message ID (from InboxMessage.id).

        Returns:
            MessageDetail with full body, headers, and auth results
            (``spf``, ``dkim``, ``dmarc``).

        Raises:
            MailNotFoundError: If the message does not exist.
            MailAuthError: If the token is invalid.

        Example:
            >>> detail = mb.read("msg_abc123")
            >>> print(detail.body_text)
        """
        data = self._request("GET", f"/v1/me/messages/{message_id}")
        return MessageDetail(**data)

    def threads(
        self,
        *,
        limit: int = 20,
        cursor: str | None = None,
    ) -> PaginatedList[ThreadSummary]:
        """List email threads.

        Args:
            limit: Maximum number of threads per page. Defaults to 20.
            cursor: Pagination cursor from a previous response.

        Returns:
            PaginatedList[ThreadSummary] supporting iteration and
            ``.auto_paging_iter()``.

        Raises:
            MailAuthError: If the token is invalid.
            MailNetworkError: On connection failure.

        Example:
            >>> for t in mb.threads():
            ...     print(t.subject, t.message_count)
        """
        params: dict = {"limit": limit}
        if cursor is not None:
            params["cursor"] = cursor
        data = self._request("GET", "/v1/me/threads", params=params)
        items = [ThreadSummary(**t) for t in data["items"]]
        result = PaginatedList(
            items=items, cursor=data.get("cursor"), has_more=data.get("has_more", False)
        )
        result._fetch_fn = self.threads
        result._fetch_kwargs = {"limit": limit}
        return result

    def thread(self, thread_id: str) -> ThreadDetail:
        """Get a full thread with all messages.

        Args:
            thread_id: The thread ID.

        Returns:
            ThreadDetail containing the thread metadata and a ``messages``
            list of MessageDetail objects.

        Raises:
            MailNotFoundError: If the thread does not exist.
            MailAuthError: If the token is invalid.

        Example:
            >>> t = mb.thread("thr_abc123")
            >>> for msg in t.messages:
            ...     print(msg.from_address, msg.subject)
        """
        data = self._request("GET", f"/v1/me/threads/{thread_id}")
        messages = [MessageDetail(**m) for m in data.pop("messages", [])]
        return ThreadDetail(**data, messages=messages)

    # ── Sending ──

    def send(
        self,
        *,
        to: str,
        subject: str,
        body_text: str,
        body_html: str | None = None,
        require_approval: bool = False,
    ) -> SendResult:
        """Send an email from this mailbox.

        Args:
            to: Recipient email address.
            subject: Email subject line.
            body_text: Plain-text email body.
            body_html: Optional HTML email body.
            require_approval: If True, the email is held for human approval
                before sending. Defaults to False.

        Returns:
            SendResult with ``thread_id``, ``message_id``, and ``status``
            (``"sent"``, ``"held"``, or ``"pending_approval"``). When
            approval is required, ``approval_url`` is set.

        Raises:
            MailValidationError: If the payload is invalid (e.g. bad address).
            MailAuthError: If the token is invalid.

        Example:
            >>> result = mb.send(
            ...     to="user@example.com",
            ...     subject="Invoice #42",
            ...     body_text="Please find your invoice attached.",
            ... )
            >>> print(result.message_id)
        """
        payload: dict = {"to": to, "subject": subject, "body_text": body_text}
        if body_html is not None:
            payload["body_html"] = body_html
        if require_approval:
            payload["require_approval"] = True
        data = self._request("POST", "/v1/me/send", json=payload)
        return SendResult(**data)

    def reply(
        self,
        message_id: str,
        *,
        body_text: str,
        body_html: str | None = None,
        require_approval: bool = False,
    ) -> SendResult:
        """Reply to a message.

        Sends a reply in the same thread as the original message.

        Args:
            message_id: The ID of the message to reply to.
            body_text: Plain-text reply body.
            body_html: Optional HTML reply body.
            require_approval: If True, the reply is held for human approval.
                Defaults to False.

        Returns:
            SendResult with ``thread_id``, ``message_id``, and ``status``.

        Raises:
            MailNotFoundError: If the original message does not exist.
            MailValidationError: If the payload is invalid.
            MailAuthError: If the token is invalid.

        Example:
            >>> for msg in mb.inbox(unread_only=True):
            ...     mb.reply(msg.id, body_text="Got it, thanks!")
        """
        payload: dict = {"body_text": body_text}
        if body_html is not None:
            payload["body_html"] = body_html
        if require_approval:
            payload["require_approval"] = True
        data = self._request("POST", f"/v1/me/reply/{message_id}", json=payload)
        return SendResult(**data)

    # ── Attachments ──

    def list_attachments(self, message_id: str) -> list[AttachmentInfo]:
        """List attachments on a message.

        Args:
            message_id: The message ID.

        Returns:
            List of AttachmentInfo with ``id``, ``filename``,
            ``content_type``, and ``size_bytes``.

        Raises:
            MailNotFoundError: If the message does not exist.
            MailAuthError: If the token is invalid.

        Example:
            >>> attachments = mb.list_attachments("msg_abc123")
            >>> for a in attachments:
            ...     print(a.filename, a.size_bytes)
        """
        data = self._request("GET", f"/v1/me/messages/{message_id}/attachments")
        return [AttachmentInfo(**a) for a in data["items"]]

    def download_attachment(self, message_id: str, attachment_id: str) -> bytes:
        """Download a raw attachment.

        Args:
            message_id: The message ID containing the attachment.
            attachment_id: The attachment ID (from AttachmentInfo.id).

        Returns:
            Raw file bytes.

        Raises:
            MailNotFoundError: If the message or attachment does not exist.
            MailAuthError: If the token is invalid.
            MailNetworkError: On connection failure after retries.

        Example:
            >>> data = mb.download_attachment("msg_abc123", "att_def456")
            >>> with open("invoice.pdf", "wb") as f:
            ...     f.write(data)
        """
        if self._destroyed:
            raise MailError(message="Mailbox has been destroyed.")
        path = f"/v1/me/messages/{message_id}/attachments/{attachment_id}"
        last_response: httpx.Response | None = None
        last_exc: httpx.TransportError | None = None
        for attempt in range(self._max_retries + 1):
            try:
                response = self._client.request("GET", path)
            except (httpx.TimeoutException, httpx.ConnectError) as exc:
                last_exc = exc
                if attempt < self._max_retries:
                    _time.sleep(_transport_retry_delay(attempt))
                    continue
                raise MailNetworkError(
                    message=f"{type(exc).__name__}: {exc}",
                ) from exc
            except httpx.TransportError as exc:
                raise MailNetworkError(
                    message=f"{type(exc).__name__}: {exc}",
                ) from exc
            last_exc = None
            last_response = response
            if response.status_code in RETRY_STATUS_CODES and attempt < self._max_retries:
                _time.sleep(_retry_delay(response, attempt))
                continue
            break
        if last_response is None:
            raise MailNetworkError(
                message=f"{type(last_exc).__name__}: {last_exc}",
            ) from last_exc
        raise_for_status(
            last_response,
            exc_map=_EXC_MAP,
            server_exc=MailServerError,
            base_exc=MailError,
        )
        return last_response.content

    # ── Allowlist ──

    def get_allowlist(self) -> list[AllowlistEntry]:
        """List allowed senders.

        Returns:
            List of AllowlistEntry with ``id``, ``sender_address``, and
            ``created_at``.

        Raises:
            MailAuthError: If the token is invalid.

        Example:
            >>> for entry in mb.get_allowlist():
            ...     print(entry.sender_address)
        """
        data = self._request("GET", "/v1/me/allowlist")
        return [AllowlistEntry(**e) for e in data["items"]]

    def add_allowlist(self, sender_address: str) -> AllowlistEntry:
        """Add an allowed sender.

        Only emails from allowlisted senders will be delivered to this
        mailbox (when the allowlist is non-empty).

        Args:
            sender_address: Email address to allow (e.g. ``"noreply@github.com"``).

        Returns:
            The created AllowlistEntry.

        Raises:
            MailConflictError: If the address is already allowlisted.
            MailValidationError: If the address is invalid.
            MailAuthError: If the token is invalid.

        Example:
            >>> entry = mb.add_allowlist("noreply@github.com")
            >>> print(entry.id)
        """
        data = self._request(
            "POST", "/v1/me/allowlist", json={"sender_address": sender_address}
        )
        return AllowlistEntry(**data)

    def remove_allowlist(self, entry_id: str) -> None:
        """Remove an allowed sender.

        Args:
            entry_id: The allowlist entry ID (from AllowlistEntry.id).

        Raises:
            MailNotFoundError: If the entry does not exist.
            MailAuthError: If the token is invalid.

        Example:
            >>> mb.remove_allowlist("ale_abc123")
        """
        self._request("DELETE", f"/v1/me/allowlist/{entry_id}")

    # ── Auth policy ──

    def get_auth_policy(self) -> str:
        """Get the email authentication policy.

        Returns:
            The current policy as a string: ``"none"``, ``"warn"``, or
            ``"reject"``.

        Raises:
            MailAuthError: If the token is invalid.

        Example:
            >>> policy = mb.get_auth_policy()
            >>> print(policy)  # "none", "warn", or "reject"
        """
        data = self._request("GET", "/v1/me/auth-policy")
        return data["auth_policy"]

    def set_auth_policy(self, policy: Literal["none", "warn", "reject"]) -> None:
        """Set the email authentication policy.

        Controls how the mailbox handles emails that fail SPF/DKIM/DMARC
        checks.

        Args:
            policy: One of ``"none"`` (accept all), ``"warn"`` (accept but
                flag), or ``"reject"`` (reject failing emails).

        Raises:
            MailValidationError: If the policy value is invalid.
            MailAuthError: If the token is invalid.

        Example:
            >>> mb.set_auth_policy("reject")
        """
        self._request("PUT", "/v1/me/auth-policy", json={"auth_policy": policy})

    # ── Webhooks ──

    def set_webhook(self, url: str, events: list[str] | None = None) -> WebhookCreated:
        """Register a webhook. Returns WebhookCreated with signing secret (shown once).

        Replaces any existing webhook on this mailbox.

        Args:
            url: The HTTPS URL to receive webhook POST requests.
            events: List of event types to subscribe to (e.g.
                ``["message.received"]``). Omit to subscribe to all events.

        Returns:
            WebhookCreated with ``id``, ``url``, ``secret`` (one-time),
            and ``events``.

        Raises:
            MailValidationError: If the URL is invalid.
            MailAuthError: If the token is invalid.

        Example:
            >>> wh = mb.set_webhook("https://example.com/hook")
            >>> print(wh.secret)  # save this -- shown only once
        """
        payload: dict = {"url": url}
        if events is not None:
            payload["events"] = events
        data = self._request("POST", "/v1/me/webhook", json=payload)
        return WebhookCreated(**data)

    def webhook(self) -> WebhookInfo:
        """Get current webhook config.

        Returns:
            WebhookInfo with ``id``, ``url``, ``events``, and ``created_at``.

        Raises:
            MailNotFoundError: If no webhook is configured.
            MailAuthError: If the token is invalid.

        Example:
            >>> wh = mb.webhook()
            >>> print(wh.url, wh.events)
        """
        data = self._request("GET", "/v1/me/webhook")
        return WebhookInfo(**data)

    def delete_webhook(self) -> None:
        """Remove the webhook.

        Raises:
            MailNotFoundError: If no webhook is configured.
            MailAuthError: If the token is invalid.

        Example:
            >>> mb.delete_webhook()
        """
        self._request("DELETE", "/v1/me/webhook")

    # ── Lifecycle ──

    def delete(self) -> None:
        """Delete this mailbox from the server and close the HTTP client.

        This permanently destroys the mailbox and all its messages. The
        Mailbox instance cannot be used after this call.

        Raises:
            MailAuthError: If the token is invalid.
            MailNetworkError: On connection failure.

        Example:
            >>> mb.delete()  # mailbox is gone
        """
        self._request("DELETE", "/v1/me")
        self._destroyed = True
        if self._owns_client:
            self._client.close()

    def close(self) -> None:
        """Close the HTTP client.

        The mailbox remains active on the server. Use ``delete()`` to
        destroy it entirely. Called automatically when used as a context
        manager.

        Example:
            >>> mb.close()
        """
        if self._owns_client:
            self._client.close()

    def __enter__(self) -> Mailbox:
        return self

    def __exit__(self, *args) -> None:
        self.close()

    def __repr__(self) -> str:
        if self._info_cache:
            return f"Mailbox({self._info_cache.address})"
        return "Mailbox()"


class MailAdmin:
    """Mail admin operations — create/list/delete mailboxes, protected recipients, account info.

    Not instantiated directly. Access via ``Primitif().mail``.
    """

    def __init__(self, client: httpx.Client, max_retries: int, base_url: str) -> None:
        """Initialize MailAdmin (internal -- use ``Primitif().mail`` instead).

        Args:
            client: Pre-configured httpx.Client with auth headers.
            max_retries: Number of automatic retries on transient errors.
            base_url: Mail API base URL.
        """
        self._client = client
        self._max_retries = max_retries
        self._base_url = base_url

    def _request(self, method: str, path: str, **kwargs) -> dict:
        return request_with_retries(
            self._client,
            method,
            path,
            self._max_retries,
            exc_map=_EXC_MAP,
            server_exc=MailServerError,
            base_exc=MailError,
            connection_exc=MailNetworkError,
            logger=logger,
            **kwargs,
        )

    def create_mailbox(self, name: str | None = None, ttl: int | None = None, domain_id: str | None = None) -> Mailbox:
        """Create a new mailbox. Returns a Mailbox ready to use.

        Args:
            name: Mailbox name (used in address prefix). Omit for a random name.
            ttl: Optional TTL in seconds (max 30 days = 2592000).
            domain_id: Optional custom domain ID. Use ``add_custom_domain()``
                to register one first.

        Returns:
            A Mailbox instance with a pre-populated ``token`` and ``address``,
            ready for send/receive operations.

        Raises:
            MailValidationError: If the name or TTL is invalid.
            MailConflictError: If a mailbox with that name already exists.
            MailAuthError: If the API key is invalid.

        Example:
            >>> mb = p.mail.create_mailbox(name="agent", ttl=3600)
            >>> print(mb.address)  # 'agent-7f3a@mail.primitif.ai'
            >>> print(mb.token)    # 'mb_tok_...' -- save for reconnection
        """
        payload: dict = {}
        if name is not None:
            payload["name"] = name
        if ttl is not None:
            payload["ttl_seconds"] = ttl
        if domain_id is not None:
            payload["domain_id"] = domain_id
        data = self._request("POST", "/v1/mailboxes", json=payload)

        from primitif._version import __version__

        token = data["token"]
        mb_client = httpx.Client(
            base_url=self._base_url,
            headers={
                "Authorization": f"Bearer {token}",
                "User-Agent": f"primitif/{__version__}",
            },
            timeout=self._client.timeout,
        )
        try:
            mb = Mailbox(_client=mb_client, max_retries=self._max_retries)
            mb._token = token
            mb._info_cache = MailboxInfo(
                id=data["id"],
                address=data["address"],
                name=name or data.get("name", ""),
                expires_at=data.get("expires_at"),
            )
            mb._owns_client = True
        except BaseException:
            mb_client.close()
            raise
        return mb

    def list_mailboxes(
        self,
        *,
        limit: int = 20,
        cursor: str | None = None,
    ) -> PaginatedList[MailboxListItem]:
        """List all mailboxes in the org.

        Args:
            limit: Maximum number of mailboxes per page. Defaults to 20.
            cursor: Pagination cursor from a previous response.

        Returns:
            PaginatedList[MailboxListItem] supporting iteration and
            ``.auto_paging_iter()``.

        Raises:
            MailAuthError: If the API key is invalid.

        Example:
            >>> for mb in p.mail.list_mailboxes():
            ...     print(mb.address, mb.received_count)
        """
        params: dict = {"limit": limit}
        if cursor is not None:
            params["cursor"] = cursor
        data = self._request("GET", "/v1/mailboxes", params=params)
        items = [MailboxListItem(**m) for m in data["items"]]
        result = PaginatedList(
            items=items, cursor=data.get("cursor"), has_more=data.get("has_more", False)
        )
        result._fetch_fn = self.list_mailboxes
        result._fetch_kwargs = {"limit": limit}
        return result

    def delete_mailbox(self, mailbox_id: str) -> None:
        """Delete a mailbox by ID.

        Args:
            mailbox_id: The mailbox ID (e.g. ``"mbx_abc123..."``).

        Raises:
            MailNotFoundError: If the mailbox does not exist.
            MailAuthError: If the API key is invalid.

        Example:
            >>> p.mail.delete_mailbox("mbx_abc123")
        """
        self._request("DELETE", f"/v1/mailboxes/{mailbox_id}")

    def account(self) -> AccountInfo:
        """Get account info (plan, usage, limits).

        Returns:
            AccountInfo with ``plan``, ``email_count``, ``mailbox_count``,
            and ``limits``.

        Raises:
            MailAuthError: If the API key is invalid.

        Example:
            >>> acct = p.mail.account()
            >>> print(acct.plan, acct.mailbox_count)
        """
        data = self._request("GET", "/v1/account")
        return AccountInfo(**data)

    def list_protected_recipients(self) -> list[ProtectedRecipient]:
        """List all protected recipient patterns.

        Protected recipients block outbound email to matched addresses,
        preventing agents from sending to sensitive contacts.

        Returns:
            List of ProtectedRecipient with ``id``, ``pattern``,
            ``pattern_type``, and ``created_at``.

        Raises:
            MailAuthError: If the API key is invalid.

        Example:
            >>> for r in p.mail.list_protected_recipients():
            ...     print(r.pattern, r.pattern_type)
        """
        data = self._request("GET", "/v1/protected-recipients")
        return [ProtectedRecipient(**e) for e in data["items"]]

    def add_protected_recipient(self, pattern: str) -> ProtectedRecipient:
        """Add a protected recipient pattern (email or @domain).

        Args:
            pattern: An email address (``"ceo@company.com"``) or domain
                pattern (``"@company.com"``) to protect.

        Returns:
            The created ProtectedRecipient.

        Raises:
            MailValidationError: If the pattern is invalid.
            MailConflictError: If the pattern already exists.
            MailAuthError: If the API key is invalid.

        Example:
            >>> p.mail.add_protected_recipient("@internal.company.com")
        """
        data = self._request("POST", "/v1/protected-recipients", json={"pattern": pattern})
        return ProtectedRecipient(**data)

    def remove_protected_recipient(self, entry_id: str) -> None:
        """Remove a protected recipient pattern.

        Args:
            entry_id: The protected recipient entry ID.

        Raises:
            MailNotFoundError: If the entry does not exist.
            MailAuthError: If the API key is invalid.

        Example:
            >>> p.mail.remove_protected_recipient("pr_abc123")
        """
        self._request("DELETE", f"/v1/protected-recipients/{entry_id}")

    # ── Custom domains ──

    def add_custom_domain(self, domain: str) -> CustomDomain:
        """Register a custom email domain. Returns DNS records to add.

        After registering, add the returned DNS records to your domain,
        then call ``verify_custom_domain()`` to complete verification.

        Args:
            domain: The domain name (e.g. ``"mail.yourcompany.com"``).

        Returns:
            CustomDomain with ``id``, ``status``, and ``dns_records``
            that must be added to your DNS provider.

        Raises:
            MailValidationError: If the domain is invalid.
            MailConflictError: If the domain is already registered.
            MailAuthError: If the API key is invalid.

        Example:
            >>> cd = p.mail.add_custom_domain("mail.yourcompany.com")
            >>> for record in cd.dns_records:
            ...     print(record)  # add these to your DNS
        """
        data = self._request("POST", "/v1/custom-domains", json={"domain": domain})
        return CustomDomain(**data)

    def verify_custom_domain(self, domain_id: str) -> CustomDomain:
        """Trigger DNS verification for a custom domain.

        Args:
            domain_id: The custom domain ID (from CustomDomain.id).

        Returns:
            Updated CustomDomain with the new ``status``.

        Raises:
            MailNotFoundError: If the domain does not exist.
            MailValidationError: If DNS records are not yet configured.
            MailAuthError: If the API key is invalid.

        Example:
            >>> cd = p.mail.verify_custom_domain("cd_abc123")
            >>> print(cd.status)  # "verified" or "pending"
        """
        data = self._request("POST", f"/v1/custom-domains/{domain_id}/verify")
        return CustomDomain(**data)

    def list_custom_domains(self) -> list[CustomDomain]:
        """List all custom domains.

        Returns:
            List of CustomDomain with ``id``, ``domain``, ``status``,
            and ``dns_records``.

        Raises:
            MailAuthError: If the API key is invalid.

        Example:
            >>> for cd in p.mail.list_custom_domains():
            ...     print(cd.domain, cd.status)
        """
        data = self._request("GET", "/v1/custom-domains")
        return [CustomDomain(**d) for d in data["items"]]

    def set_default_domain(self, domain_id: str) -> CustomDomain:
        """Set a verified custom domain as the default for the namespace.

        New mailboxes will use this domain for their email addresses.

        Args:
            domain_id: The custom domain ID. Must be verified.

        Returns:
            The updated CustomDomain.

        Raises:
            MailNotFoundError: If the domain does not exist.
            MailValidationError: If the domain is not verified.
            MailAuthError: If the API key is invalid.

        Example:
            >>> p.mail.set_default_domain("cd_abc123")
        """
        data = self._request("POST", f"/v1/custom-domains/{domain_id}/set-default")
        return CustomDomain(**data)

    def clear_default_domain(self) -> None:
        """Clear the default domain. New mailboxes will use @mail.primitif.ai.

        Raises:
            MailAuthError: If the API key is invalid.

        Example:
            >>> p.mail.clear_default_domain()
        """
        self._request("DELETE", "/v1/custom-domains/default")

    def delete_custom_domain(self, domain_id: str) -> None:
        """Delete a custom domain (must have no active mailboxes).

        Args:
            domain_id: The custom domain ID.

        Raises:
            MailNotFoundError: If the domain does not exist.
            MailConflictError: If the domain still has active mailboxes.
            MailAuthError: If the API key is invalid.

        Example:
            >>> p.mail.delete_custom_domain("cd_abc123")
        """
        self._request("DELETE", f"/v1/custom-domains/{domain_id}")
