"""Microsoft Exchange retrieval handler via Graph API.

Fetches emails from Microsoft Exchange Online using the OAuth2 client
credentials flow. Emails are returned as complete MIME (.eml) files,
base64-encoded.

Features:
    - Folder traversal by path (e.g., ``["Inbox", "Invoices"]``)
    - PDF attachment pre-filtering (server-side + client-side)
    - Configurable delay before emails become visible
    - Post-download email archival (move to folder)
    - Token caching with automatic refresh

Requires the ``[microsoft]`` extra::

    uv add entr-adapter-core[microsoft]

Usage::

    from entr_adapter_core.connectors.microsoft import (
        MicrosoftEntraRetrievalHandler,
        MicrosoftConnectorConfig,
    )

    config = MicrosoftConnectorConfig(
        tenant_name="Acme",
        entra_tenant_id="...",
        entra_client_id="...",
        entra_client_secret="...",
        mailbox="invoices@acme.com",
    )
    handler = MicrosoftEntraRetrievalHandler(config)
"""

import base64
import email as email_lib
import logging
import re
from datetime import datetime, timedelta
from email.header import decode_header
from typing import List, Optional

try:
    import httpx
except ImportError as exc:
    raise ImportError(
        "The Microsoft connector requires the 'httpx' package. "
        "Install it with: uv add entr-adapter-core[microsoft]"
    ) from exc

from pydantic import BaseModel, Field

from entr_adapter_core.handlers import RetrievalHandler
from entr_adapter_core.schemas import (
    DocumentDownloadResponse,
    DocumentIdentifier,
    RetrievalPayload,
    RetrievalResponse,
    StatusEnum,
)

logger = logging.getLogger(__name__)


class MicrosoftConnectorConfig(BaseModel):
    """Configuration for the Microsoft Exchange retrieval connector.

    Tenant-specific settings for connecting to Microsoft Graph API.

    Attributes:
        tenant_name: Identifier for the tenant (for logging).
        entra_tenant_id: Azure AD / Entra tenant ID (directory ID).
        entra_client_id: OAuth2 application (client) ID.
        entra_client_secret: OAuth2 client secret.
        mailbox: Email address of the mailbox to read from.
        folder_path: List of folder names to traverse from root.
            Defaults to ``["Inbox"]``.
        delay_days: Number of days to wait before emails become visible.
            Useful for giving senders time to correct or recall messages.
            0 = immediate (default).
        move_to_folder_path: Folder path to move emails to after download.
            ``None`` = don't move (default). Example: ``["Inbox", "Processed"]``.
        require_pdf_attachment: Only list emails that have at least one PDF
            attachment. Reduces noise from auto-replies and notifications.
    """

    tenant_name: str = Field(
        ...,
        description="Tenant identifier (for logging)",
    )
    entra_tenant_id: str = Field(
        ...,
        description="Azure AD / Entra tenant ID (directory ID)",
    )
    entra_client_id: str = Field(
        ...,
        description="OAuth2 application (client) ID",
    )
    entra_client_secret: str = Field(
        ...,
        description="OAuth2 client secret",
    )
    mailbox: str = Field(
        ...,
        description="Email address of the mailbox to read from",
    )
    folder_path: List[str] = Field(
        default_factory=lambda: ["Inbox"],
        description="Folder path to traverse from root (e.g., ['Inbox', 'Invoices'])",
    )
    delay_days: int = Field(
        default=0,
        ge=0,
        description="Days to wait before emails become visible (0 = immediate)",
    )
    move_to_folder_path: Optional[List[str]] = Field(
        default=None,
        description="Folder path to move emails to after download (None = don't move)",
    )
    require_pdf_attachment: bool = Field(
        default=False,
        description="Only list emails with at least one PDF attachment",
    )

    # Expose uppercase aliases for backward compatibility with legacy settings.
    @property
    def TENANT_NAME(self) -> str:  # noqa: N802
        return self.tenant_name

    @property
    def ENTRA_TENANT_ID(self) -> str:  # noqa: N802
        return self.entra_tenant_id

    @property
    def ENTRA_CLIENT_ID(self) -> str:  # noqa: N802
        return self.entra_client_id

    @property
    def ENTRA_CLIENT_SECRET(self) -> str:  # noqa: N802
        return self.entra_client_secret


class MicrosoftEntraRetrievalHandler(RetrievalHandler):
    """Retrieval handler for Microsoft Exchange via Graph API.

    Uses OAuth2 client credentials flow for authentication. Supports
    folder traversal, PDF attachment filtering, email delay, and
    post-download archival.

    Accepts either a ``MicrosoftConnectorConfig`` instance or any settings
    object that exposes ``TENANT_NAME``, ``ENTRA_TENANT_ID``,
    ``ENTRA_CLIENT_ID``, and ``ENTRA_CLIENT_SECRET`` attributes.

    Args:
        settings: Configuration object with tenant-specific credentials.
        mailbox: Email address of the mailbox to read from. Ignored when
            ``settings`` is a ``MicrosoftConnectorConfig`` with its own mailbox.
        folder_path: Folder names to traverse (default: ``["Inbox"]``).
            Ignored when ``settings`` is a ``MicrosoftConnectorConfig``.
        delay_days: Days to wait before emails become visible (default: 0).
            Ignored when ``settings`` is a ``MicrosoftConnectorConfig``.
        move_to_folder_path: Folder to move emails to after download.
            Ignored when ``settings`` is a ``MicrosoftConnectorConfig``.
        require_pdf_attachment: Only list emails with PDF attachments.
            Ignored when ``settings`` is a ``MicrosoftConnectorConfig``.

    Example::

        config = MicrosoftConnectorConfig(
            tenant_name="Acme",
            entra_tenant_id="abc-123",
            entra_client_id="def-456",
            entra_client_secret="secret",
            mailbox="invoices@acme.com",
            folder_path=["Inbox", "Invoices"],
            require_pdf_attachment=True,
        )
        handler = MicrosoftEntraRetrievalHandler(config)
        response = await handler.list_documents(payload)
    """

    def __init__(
        self,
        settings,
        mailbox: str = None,
        folder_path: Optional[list[str]] = None,
        delay_days: int = 0,
        move_to_folder_path: Optional[list[str]] = None,
        require_pdf_attachment: bool = False,
    ):
        super().__init__(settings)

        # Extract config from MicrosoftConnectorConfig or use constructor args
        if isinstance(settings, MicrosoftConnectorConfig):
            self.mailbox = settings.mailbox
            self.folder_path = settings.folder_path
            self.delay_days = settings.delay_days
            self.move_to_folder_path = settings.move_to_folder_path
            self.require_pdf_attachment = settings.require_pdf_attachment
        else:
            self.mailbox = mailbox
            self.folder_path = folder_path if folder_path else ["Inbox"]
            self.delay_days = delay_days
            self.move_to_folder_path = move_to_folder_path
            self.require_pdf_attachment = require_pdf_attachment

        if not all([
            self.settings.ENTRA_TENANT_ID,
            self.settings.ENTRA_CLIENT_ID,
            self.settings.ENTRA_CLIENT_SECRET,
            self.mailbox,
        ]):
            raise ValueError(
                "ENTRA_TENANT_ID, ENTRA_CLIENT_ID, ENTRA_CLIENT_SECRET must be configured, "
                "and mailbox must be provided."
            )

        logger.info("Initialized MicrosoftEntraRetrievalHandler for tenant: %s", self.settings.TENANT_NAME)
        logger.info("Entra Mailbox: %s", self.mailbox)
        logger.info("Target folder path: %s", " / ".join(self.folder_path))
        if self.delay_days > 0:
            logger.info("Delay days: %d", self.delay_days)
        if self.require_pdf_attachment:
            logger.info("Require PDF attachment: True")
        if self.move_to_folder_path:
            logger.info("Move after download to: %s", " / ".join(self.move_to_folder_path))

        self._access_token = None
        self._token_expiry = None
        self._target_folder_id = None
        self._move_to_folder_id = None

    # ── Authentication ───────────────────────────────────────────────────

    async def _get_access_token(self) -> str:
        """Acquire an OAuth2 access token from Microsoft Entra."""
        if self._access_token and self._token_expiry and datetime.now() < self._token_expiry:
            return self._access_token

        logger.info("Acquiring new access token from Microsoft Entra")

        token_url = (
            f"https://login.microsoftonline.com/{self.settings.ENTRA_TENANT_ID}/oauth2/v2.0/token"
        )
        payload = {
            "grant_type": "client_credentials",
            "client_id": self.settings.ENTRA_CLIENT_ID,
            "client_secret": self.settings.ENTRA_CLIENT_SECRET,
            "scope": "https://graph.microsoft.com/.default",
        }

        try:
            async with httpx.AsyncClient() as client:
                response = await client.post(token_url, data=payload)
                response.raise_for_status()

                token_data = response.json()
                self._access_token = token_data["access_token"]

                # Cache token with expiry (subtract 5 minutes for safety)
                expires_in = token_data.get("expires_in", 3600)
                self._token_expiry = datetime.now() + timedelta(seconds=expires_in - 300)

                logger.info("Successfully acquired access token")
                return self._access_token

        except httpx.HTTPStatusError as e:
            logger.error("Failed to acquire access token: %s", e)
            logger.error("Response: %s", e.response.text)
            raise
        except httpx.RequestError as e:
            logger.error("Failed to acquire access token: %s", e)
            raise

    # ── Folder traversal ─────────────────────────────────────────────────

    async def _find_folder_by_path(self, access_token: str, folder_path: list[str]) -> Optional[str]:
        """Traverse a folder path and return the final folder's ID.

        Args:
            access_token: OAuth2 access token.
            folder_path: List of folder names to traverse from root.

        Returns:
            The folder ID, or ``None`` if any folder in the path was not found.
        """
        logger.info("Searching for folder path: %s", " / ".join(folder_path))

        headers = {"Authorization": f"Bearer {access_token}"}
        url = f"https://graph.microsoft.com/v1.0/users/{self.mailbox}/mailFolders"
        parent_folder_id = None

        async with httpx.AsyncClient() as client:
            for i, folder_name in enumerate(folder_path):
                logger.debug("  -> Searching for '%s'...", folder_name)

                if i > 0 and parent_folder_id:
                    url = (
                        f"https://graph.microsoft.com/v1.0/users/{self.mailbox}"
                        f"/mailFolders/{parent_folder_id}/childFolders"
                    )

                try:
                    response = await client.get(url, headers=headers)
                    response.raise_for_status()
                    folders = response.json().get("value", [])

                    # Find the folder (case-insensitive)
                    found_folder = None
                    for folder in folders:
                        if folder.get("displayName", "").lower() == folder_name.lower():
                            found_folder = folder
                            break

                    if not found_folder:
                        logger.error("Could not find folder '%s' in path", folder_name)
                        logger.error(
                            "Available folders: %s",
                            [f.get("displayName") for f in folders],
                        )
                        return None

                    parent_folder_id = found_folder["id"]
                    logger.debug("  Found '%s' with ID: %s", found_folder["displayName"], parent_folder_id)

                except httpx.HTTPStatusError as e:
                    logger.error("Error searching for folder: %s", e)
                    return None
                except httpx.RequestError as e:
                    logger.error("Error searching for folder: %s", e)
                    return None

        logger.info("Successfully found folder with ID: %s", parent_folder_id)
        return parent_folder_id

    async def _find_target_folder(self, access_token: str) -> Optional[str]:
        """Find and cache the target folder ID."""
        if self._target_folder_id:
            return self._target_folder_id
        self._target_folder_id = await self._find_folder_by_path(access_token, self.folder_path)
        return self._target_folder_id

    async def _get_move_to_folder_id(self, access_token: str) -> Optional[str]:
        """Find and cache the move-to folder ID."""
        if self._move_to_folder_id:
            return self._move_to_folder_id
        if not self.move_to_folder_path:
            return None
        self._move_to_folder_id = await self._find_folder_by_path(access_token, self.move_to_folder_path)
        return self._move_to_folder_id

    # ── Message operations ───────────────────────────────────────────────

    async def _move_message_to_folder(
        self, access_token: str, message_id: str, destination_folder_id: str,
    ) -> bool:
        """Move a message to a different folder.

        Returns True if successful, False otherwise (non-fatal).
        """
        url = f"https://graph.microsoft.com/v1.0/users/{self.mailbox}/messages/{message_id}/move"
        headers = {
            "Authorization": f"Bearer {access_token}",
            "Content-Type": "application/json",
        }
        payload = {"destinationId": destination_folder_id}

        try:
            async with httpx.AsyncClient() as client:
                response = await client.post(url, headers=headers, json=payload)
                response.raise_for_status()
            logger.info("Successfully moved message %s to archive folder", message_id[:20])
            return True
        except (httpx.HTTPStatusError, httpx.RequestError) as e:
            logger.warning("Failed to move message %s: %s", message_id[:20], e)
            return False

    async def _message_has_pdf_attachment(
        self, client: httpx.AsyncClient, access_token: str, message_id: str,
    ) -> bool:
        """Check if a message has at least one PDF attachment.

        Returns True on error to avoid false negatives.
        """
        url = (
            f"https://graph.microsoft.com/v1.0/users/{self.mailbox}"
            f"/messages/{message_id}/attachments"
            "?$select=name,contentType"
        )
        headers = {"Authorization": f"Bearer {access_token}"}

        try:
            response = await client.get(url, headers=headers)
            response.raise_for_status()
            attachments = response.json().get("value", [])

            for att in attachments:
                name = (att.get("name") or "").lower()
                content_type = (att.get("contentType") or "").lower()
                if name.endswith(".pdf") or content_type == "application/pdf":
                    return True
            return False

        except (httpx.HTTPStatusError, httpx.RequestError) as e:
            logger.warning("Failed to check attachments for message %s: %s", message_id[:20], e)
            # On error, include the message to avoid false negatives
            return True

    def _generate_filename_from_message(self, message: dict) -> str:
        """Generate a filename from a Graph API message object using the subject."""
        try:
            subject = message.get("subject", "")
            if subject:
                subject_clean = re.sub(r'[<>:"/\\|?*]', "_", subject).strip()
                return f"{subject_clean}.eml"
            else:
                message_id = message.get("id", "unknown")[:20]
                return f"{message_id}_email.eml"
        except Exception as e:
            logger.warning("Error generating filename: %s", e)
            return f"{message.get('id', 'unknown')[:20]}_email.eml"

    # ── Public API ───────────────────────────────────────────────────────

    async def list_documents(self, payload: RetrievalPayload) -> RetrievalResponse:
        """List emails in the target folder since the specified date.

        When ``require_pdf_attachment`` is enabled, applies a two-stage filter:
        1. Server-side: ``hasAttachments eq true`` (reduces API calls)
        2. Client-side: checks each email for actual PDF attachments

        Uses ``sentDateTime`` (immutable) instead of ``receivedDateTime``
        for consistent filtering even when emails are moved between folders.
        """
        logger.info(
            "MicrosoftEntraRetrievalHandler: list_documents for source: %s",
            payload.source_identifier,
        )

        try:
            access_token = await self._get_access_token()

            folder_id = await self._find_target_folder(access_token)
            if not folder_id:
                return RetrievalResponse(
                    status=StatusEnum.FAILED,
                    message=f"Could not find folder: {' / '.join(self.folder_path)}",
                )

            # Build date filter using sentDateTime (immutable)
            date_str = payload.date_from.strftime("%Y-%m-%dT00:00:00Z")

            # Apply delay filter if configured
            if self.delay_days > 0:
                cutoff_date = datetime.utcnow() - timedelta(days=self.delay_days)
                cutoff_str = cutoff_date.strftime("%Y-%m-%dT%H:%M:%SZ")
                date_filter = f"sentDateTime ge {date_str} and sentDateTime le {cutoff_str}"
                logger.info("Applying %d-day delay filter: only emails before %s", self.delay_days, cutoff_str)
            else:
                date_filter = f"sentDateTime ge {date_str}"

            # Server-side pre-filter for attachments
            if self.require_pdf_attachment:
                date_filter = f"{date_filter} and hasAttachments eq true"
                logger.info("Filtering for emails with attachments (server-side pre-filter)")

            url = (
                f"https://graph.microsoft.com/v1.0/users/{self.mailbox}"
                f"/mailFolders/{folder_id}/messages"
                f"?$filter={date_filter}"
                "&$select=id,subject,from,sentDateTime,receivedDateTime,hasAttachments"
                "&$orderby=sentDateTime desc"
                "&$top=999"
            )

            headers = {"Authorization": f"Bearer {access_token}"}

            async with httpx.AsyncClient(timeout=30.0) as client:
                response = await client.get(url, headers=headers)
                response.raise_for_status()
                data = response.json()
                messages = data.get("value", [])

                # Warn if results are paginated (more emails than $top limit)
                if data.get("@odata.nextLink"):
                    logger.warning(
                        "Results are paginated — more emails exist than the $top limit. "
                        "Consider narrowing the date range or increasing the limit."
                    )

            logger.info("Found %d messages since %s", len(messages), date_str)

            # Client-side PDF attachment check
            if self.require_pdf_attachment and messages:
                logger.info("Checking %d messages for PDF attachments...", len(messages))
                messages_with_pdf = []

                async with httpx.AsyncClient() as client:
                    for msg in messages:
                        msg_id = msg.get("id")
                        if not msg_id:
                            continue
                        if await self._message_has_pdf_attachment(client, access_token, msg_id):
                            messages_with_pdf.append(msg)

                excluded = len(messages) - len(messages_with_pdf)
                logger.info("Filtered to %d messages with PDF attachments (excluded %d)", len(messages_with_pdf), excluded)
                messages = messages_with_pdf

            listed_documents: List[DocumentIdentifier] = []
            for msg in messages:
                msg_id = msg.get("id")
                if not msg_id:
                    continue
                listed_documents.append(
                    DocumentIdentifier(
                        document_identifier=msg_id,
                        filename=self._generate_filename_from_message(msg),
                    )
                )

            logger.info("Successfully listed %d email documents", len(listed_documents))
            return RetrievalResponse(
                status=StatusEnum.SUCCESS,
                documents=listed_documents,
            )

        except Exception as e:
            logger.error("Error in list_documents: %s", e, exc_info=True)
            return RetrievalResponse(
                status=StatusEnum.FAILED,
                message=f"Error listing documents: {e}",
            )

    async def get_document(self, document_identifier: str) -> DocumentDownloadResponse:
        """Retrieve a specific email by message ID as a .eml file.

        If ``move_to_folder_path`` is configured, the email is moved to the
        archive folder after successful download. Move failures are logged
        as warnings but do not fail the download.
        """
        logger.info(
            "MicrosoftEntraRetrievalHandler: get_document for ID: %s",
            document_identifier,
        )

        try:
            access_token = await self._get_access_token()

            url = (
                f"https://graph.microsoft.com/v1.0/users/{self.mailbox}"
                f"/messages/{document_identifier}/$value"
            )
            headers = {"Authorization": f"Bearer {access_token}"}

            async with httpx.AsyncClient() as client:
                response = await client.get(url, headers=headers)
                response.raise_for_status()
                email_data = response.content

            logger.info(
                "Successfully fetched email data for ID %s, size: %d bytes",
                document_identifier, len(email_data),
            )

            # Parse email to generate filename from subject
            msg = email_lib.message_from_bytes(email_data)
            subject = msg.get("Subject", "")
            if subject:
                decoded_parts = []
                for bytes_part, charset in decode_header(subject):
                    if isinstance(bytes_part, bytes):
                        try:
                            decoded_parts.append(bytes_part.decode(charset or "utf-8", errors="replace"))
                        except Exception:
                            decoded_parts.append(bytes_part.decode("utf-8", errors="replace"))
                    else:
                        decoded_parts.append(str(bytes_part))
                subject = "".join(decoded_parts)

            if subject:
                subject_clean = re.sub(r'[<>:"/\\|?*]', "_", subject).strip()
                filename = f"{subject_clean}.eml"
            else:
                filename = f"{document_identifier[:20]}_email.eml"

            content_b64 = base64.b64encode(email_data).decode("utf-8")

            logger.info("Successfully retrieved email: %s as %s", document_identifier, filename)

            # Move email to archive folder after successful download
            if self.move_to_folder_path:
                move_folder_id = await self._get_move_to_folder_id(access_token)
                if move_folder_id:
                    moved = await self._move_message_to_folder(access_token, document_identifier, move_folder_id)
                    if moved:
                        logger.info("Email %s moved to '%s'", document_identifier[:20], " / ".join(self.move_to_folder_path))
                    else:
                        logger.warning("Failed to move email %s to archive folder, but download succeeded", document_identifier[:20])
                else:
                    logger.warning("Could not find archive folder '%s', email not moved", " / ".join(self.move_to_folder_path))

            return DocumentDownloadResponse(
                document_identifier=filename,
                adapter_document_id=document_identifier,
                content_b64=content_b64,
            )

        except Exception as e:
            logger.error("Error in get_document for ID %s: %s", document_identifier, e, exc_info=True)
            raise
