"""Inbound mail → Repository ingestion (FRREP-DCI012, FRREP-DCI017 audit).

Each enabled :class:`apps.repository.documents.models.EmailIngestMailbox` is
polled in turn. Per-mailbox credentials (host / port / user / password) are
stored on the row, so multiple institutions can share one deployment.

Every processed message creates an :class:`EmailIngestEvent` keyed on
``(mailbox, message_uid)`` — the unique constraint makes re-polling
idempotent and the row is the FRREP-DCI017 audit-trail entry.

When no mailbox row exists yet but the legacy env-var settings
(``REPO_INBOUND_IMAP_HOST`` etc.) are populated, a "Default (env)" mailbox
is auto-created so single-deployment installs keep working without any
admin action.
"""

from __future__ import annotations

import logging
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable, Iterator

from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
from django.core.files.base import ContentFile
from django.utils import timezone

logger = logging.getLogger(__name__)
User = get_user_model()


@dataclass
class IngestSummary:
    """Per-poll counters returned by :func:`poll_inbox` for observability."""

    polled: int = 0
    ingested: int = 0
    skipped_dup: int = 0
    skipped_invalid: int = 0
    rejected_sender: int = 0
    rejected_recipient: int = 0
    errors: int = 0
    mailboxes: int = 0


# ── Helpers ────────────────────────────────────────────────────────────────


def _imap_env_present() -> bool:
    return bool((getattr(settings, "REPO_INBOUND_IMAP_HOST", "") or "").strip())


def _ensure_env_mailbox():
    """When env vars are set but no mailbox rows exist, materialise one so
    every event still has a real ``mailbox`` FK."""
    from apps.repository.documents.models import (
        Collection,
        Document,
        EmailIngestMailbox,
    )

    if EmailIngestMailbox.objects.exists():
        return None
    if not _imap_env_present():
        return None

    fallback_collection = (
        Collection.objects.filter(email_ingest_enabled=True)
        .order_by("id")
        .first()
        or Collection.objects.order_by("id").first()
    )
    if fallback_collection is None:
        logger.warning("mail-ingest: env vars set but no Collection exists; cannot bootstrap mailbox")
        return None

    mailbox = EmailIngestMailbox.objects.create(
        name="Default (env)",
        imap_host=settings.REPO_INBOUND_IMAP_HOST,
        imap_port=int(getattr(settings, "REPO_INBOUND_IMAP_PORT", 993)),
        use_ssl=True,
        username=getattr(settings, "REPO_INBOUND_IMAP_USER", "") or "",
        inbox_folder=getattr(settings, "REPO_INBOUND_IMAP_FOLDER", "INBOX"),
        processed_folder=getattr(settings, "REPO_INBOUND_PROCESSED_FOLDER", "Processed"),
        failed_folder=getattr(settings, "REPO_INBOUND_FAILED_FOLDER", "Failed"),
        default_collection=fallback_collection,
        default_license=Document.License.CC_BY,
    )
    mailbox.password = getattr(settings, "REPO_INBOUND_IMAP_PASSWORD", "") or ""
    mailbox.save(update_fields=["password_signed"])
    return mailbox


@contextmanager
def _connect_mailbox(mailbox):
    """Yield an open ``imap_tools.MailBox`` for the given mailbox row."""
    try:
        from imap_tools import MailBox, MailBoxUnencrypted
    except ImportError:  # pragma: no cover
        logger.warning("imap-tools not installed; cannot poll inbox")
        yield None
        return

    cls = MailBox if mailbox.use_ssl else MailBoxUnencrypted
    try:
        mb = cls(host=mailbox.imap_host, port=mailbox.imap_port).login(
            mailbox.username, mailbox.password, initial_folder=mailbox.inbox_folder,
        )
    except Exception as exc:  # noqa: BLE001
        logger.exception("mail-ingest: failed to connect to %s", mailbox.name)
        mailbox.last_error = str(exc)[:1000]
        mailbox.last_polled_at = timezone.now()
        mailbox.save(update_fields=["last_error", "last_polled_at", "updated_at"])
        yield None
        return
    try:
        yield mb
    finally:
        try:
            mb.logout()
        except Exception:  # pragma: no cover
            logger.exception("mail-ingest: failed to close connection for %s", mailbox.name)


def _collection_for_recipient(recipients: Iterator[str], mailbox):
    from apps.repository.documents.models import Collection

    addrs = [a.strip().lower() for a in recipients if a]
    if not addrs:
        return mailbox.default_collection
    match = (
        Collection.objects.filter(
            email_ingest_enabled=True,
            email_ingest_address__in=addrs,
            is_deprecated=False,
        )
        .first()
    )
    return match or mailbox.default_collection


def _resolve_submitter(sender_email: str, mailbox):
    if sender_email:
        match = User.objects.filter(email__iexact=sender_email).first()
        if match:
            return match
    if mailbox.submitter_id:
        return mailbox.submitter
    email = getattr(settings, "REPO_INBOUND_SERVICE_USER_EMAIL", "mail-ingest@iilmp.org")
    user, _ = User.objects.get_or_create(
        email=email, defaults={"first_name": "Mail", "last_name": "Ingest"},
    )
    return user


# ── Per-message processing ─────────────────────────────────────────────────


def process_message(msg, mailbox) -> tuple[int, dict, str, "Document | None", str]:
    """Process one ``imap_tools.MailMessage`` for ``mailbox``.

    Returns ``(documents_ingested, counters, status, document, error_message)``.
    The ``status`` value is the EmailIngestEvent.Status to persist; ``document``
    is the last successfully-ingested Document (or ``None``); ``error_message``
    is the last error string (or empty).
    """
    from apps.repository.documents import services
    from apps.repository.documents.models import Document, EmailIngestEvent

    counters = {
        "ingested": 0,
        "skipped_dup": 0,
        "skipped_invalid": 0,
        "rejected_sender": 0,
        "rejected_recipient": 0,
        "errors": 0,
    }

    recipients = list(msg.to or []) + list(msg.cc or [])
    collection = _collection_for_recipient(recipients, mailbox)
    if collection is None:
        counters["rejected_recipient"] += 1
        return 0, counters, EmailIngestEvent.Status.REJECTED_RECIPIENT, None, ""

    sender = (msg.from_ or "").strip().lower()
    if not collection.email_ingest_sender_allowed(sender):
        counters["rejected_sender"] += 1
        return 0, counters, EmailIngestEvent.Status.REJECTED_SENDER, None, ""

    submitter = _resolve_submitter(sender, mailbox)

    # FRREP-DCI012 — per-mailbox parsing_rules control how subject/body map to
    # title/abstract. Supported keys (all optional, default shown):
    #   "title_from": "subject" (default) | "filename"
    #   "abstract_from": "body" (default) | "subject" | "none"
    rules = mailbox.parsing_rules or {}
    title_from = str(rules.get("title_from") or "subject").strip().lower()
    abstract_from = str(rules.get("abstract_from") or "body").strip().lower()

    subject_title = (msg.subject or "Untitled email submission").strip()[:380]
    if abstract_from == "none":
        abstract = ""
    elif abstract_from == "subject":
        abstract = (msg.subject or "")[:8000]
    else:
        abstract = (msg.text or msg.html or "")[:8000]

    ingested = 0
    last_status = EmailIngestEvent.Status.INGESTED
    last_error = ""
    last_doc = None
    for att in (msg.attachments or []):
        if not att.payload or not att.filename:
            continue
        if title_from == "filename":
            stem = Path(att.filename).stem.replace("_", " ").replace("-", " ").strip()
            title = stem[:380] or subject_title
        else:
            title = (
                f"{subject_title} — {att.filename}"
                if len(msg.attachments) > 1
                else subject_title
            )
        try:
            doc = services.ingest_document(
                file=ContentFile(att.payload, name=att.filename),
                title=title[:380],
                collection=collection,
                document_type=Document.DocumentType.OTHER,
                abstract=abstract,
                license_type=mailbox.default_license,
                submitted_by=submitter,
                source_system="email",
                actor=submitter,
            )
            last_doc = doc
            ingested += 1
        except services.DuplicateDocumentError:
            counters["skipped_dup"] += 1
            last_status = EmailIngestEvent.Status.SKIPPED_DUPLICATE
        except ValidationError as exc:
            counters["skipped_invalid"] += 1
            last_status = EmailIngestEvent.Status.REJECTED_VALIDATION
            last_error = str(exc)
            logger.info("mail-ingest: rejected %s from %s — %s", att.filename, sender, exc)
        except Exception as exc:  # noqa: BLE001
            counters["errors"] += 1
            last_status = EmailIngestEvent.Status.ERROR
            last_error = str(exc)
            logger.exception("mail-ingest: unexpected failure on %s from %s", att.filename, sender)

    counters["ingested"] = ingested
    if ingested > 0:
        last_status = EmailIngestEvent.Status.INGESTED
        last_error = ""
    return ingested, counters, last_status, last_doc, last_error


# ── Public entrypoint ──────────────────────────────────────────────────────


def poll_inbox(*, limit: int | None = None) -> IngestSummary:
    """Poll every enabled mailbox once. Returns aggregate counters."""
    from apps.repository.documents.models import EmailIngestEvent, EmailIngestMailbox

    summary = IngestSummary()

    _ensure_env_mailbox()

    mailboxes = list(EmailIngestMailbox.objects.filter(is_enabled=True))
    summary.mailboxes = len(mailboxes)
    if not mailboxes:
        return summary

    cap = int(limit if limit is not None else getattr(settings, "REPO_INBOUND_BATCH_LIMIT", 50))

    for mailbox in mailboxes:
        with _connect_mailbox(mailbox) as mb:
            if mb is None:
                continue
            from imap_tools import AND

            try:
                msgs = list(mb.fetch(AND(seen=False), limit=cap, mark_seen=False))
            except Exception as exc:  # noqa: BLE001
                logger.exception("mail-ingest: fetch failed for %s", mailbox.name)
                mailbox.last_error = str(exc)[:1000]
                mailbox.last_polled_at = timezone.now()
                mailbox.save(update_fields=["last_error", "last_polled_at", "updated_at"])
                continue

            for msg in msgs:
                summary.polled += 1
                event, created = EmailIngestEvent.objects.get_or_create(
                    mailbox=mailbox,
                    message_uid=msg.uid,
                    defaults={
                        "subject": (msg.subject or "")[:500],
                        "sender": (msg.from_ or "")[:255],
                        "status": EmailIngestEvent.Status.ERROR,  # provisional
                    },
                )
                if not created and event.status != EmailIngestEvent.Status.ERROR:
                    # Already processed in a previous poll — leave it alone.
                    continue

                try:
                    _, deltas, status, doc, err = process_message(msg, mailbox)
                except Exception as exc:  # noqa: BLE001
                    logger.exception("mail-ingest: crash on UID %s", msg.uid)
                    event.status = EmailIngestEvent.Status.ERROR
                    event.error_message = str(exc)
                    event.save(update_fields=["status", "error_message"])
                    summary.errors += 1
                    continue

                event.status = status
                event.error_message = err or ""
                event.document = doc
                event.save(update_fields=["status", "error_message", "document"])

                for key, val in deltas.items():
                    setattr(summary, key, getattr(summary, key) + val)

                had_error = deltas.get("errors", 0) > 0
                target = mailbox.failed_folder if had_error else mailbox.processed_folder
                try:
                    mb.move(msg.uid, target)
                except Exception:  # pragma: no cover — best-effort
                    logger.exception("mail-ingest: failed to move UID %s to %s", msg.uid, target)
                    try:
                        mb.flag(msg.uid, "\\Seen", True)
                    except Exception:
                        logger.exception("mail-ingest: failed to mark UID %s as seen", msg.uid)

            mailbox.last_polled_at = timezone.now()
            mailbox.last_error = ""
            mailbox.save(update_fields=["last_polled_at", "last_error", "updated_at"])

    return summary
