# retoor <retoor@molodetz.nl>

from __future__ import annotations

import email
import imaplib
import re
import smtplib
import ssl
from contextlib import contextmanager
from email.message import EmailMessage
from email.policy import default as default_policy
from email.utils import formataddr, getaddresses, parsedate_to_datetime
from typing import Any, Iterator

from devplacepy.net_guard import BlockedAddressError, guard_public_host_sync

from .config import EmailAccount
from .errors import EmailError

DEFAULT_TIMEOUT_SECONDS = 30.0
MAX_BODY_CHARS = 80_000
HEADER_FIELDS = "(FROM TO CC SUBJECT DATE MESSAGE-ID)"
SUMMARY_ITEMS = f"(UID FLAGS BODY.PEEK[HEADER.FIELDS {HEADER_FIELDS}])"
_UID_PATTERN = re.compile(rb"UID (\d+)")


def imap_quote(value: str) -> str:
    return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'


def _header_addresses(value: str) -> list[str]:
    if not value:
        return []
    return [formataddr(pair) for pair in getaddresses([value]) if pair[1]]


def _decode_date(raw: str) -> str | None:
    if not raw:
        return None
    try:
        return parsedate_to_datetime(raw).isoformat()
    except (TypeError, ValueError):
        return raw


class EmailClient:
    def __init__(
        self, account: EmailAccount, timeout: float = DEFAULT_TIMEOUT_SECONDS
    ) -> None:
        self.account = account
        self.timeout = timeout

    @contextmanager
    def _imap(self) -> Iterator[imaplib.IMAP4]:
        account = self.account
        if not account.imap_host:
            raise EmailError("This account has no IMAP host configured.", kind="config")
        try:
            guard_public_host_sync(account.imap_host)
        except BlockedAddressError as exc:
            raise EmailError(str(exc), kind="blocked") from exc
        context = ssl.create_default_context()
        try:
            if account.imap_ssl:
                conn: imaplib.IMAP4 = imaplib.IMAP4_SSL(
                    account.imap_host,
                    account.imap_port,
                    ssl_context=context,
                    timeout=self.timeout,
                )
            else:
                conn = imaplib.IMAP4(
                    account.imap_host, account.imap_port, timeout=self.timeout
                )
                if account.imap_starttls:
                    conn.starttls(context)
            conn.login(account.username, account.password)
        except (OSError, imaplib.IMAP4.error) as exc:
            raise EmailError(f"IMAP connection failed: {exc}", kind="connect") from exc
        try:
            yield conn
        finally:
            try:
                conn.logout()
            except (OSError, imaplib.IMAP4.error):
                pass

    def _select(self, conn: imaplib.IMAP4, folder: str, readonly: bool) -> None:
        typ, data = conn.select(imap_quote(folder), readonly=readonly)
        if typ != "OK":
            raise EmailError(f"Cannot open folder '{folder}': {_first(data)}")

    def _uid_search(self, conn: imaplib.IMAP4, criteria: list[str]) -> list[str]:
        if not criteria:
            typ, data = conn.uid("SEARCH", None, "ALL")
        else:
            args: list[Any] = [b"CHARSET", b"UTF-8"]
            args.extend(token.encode("utf-8") for token in criteria)
            typ, data = conn.uid("SEARCH", *args)
        if typ != "OK":
            raise EmailError(f"Search failed: {_first(data)}")
        if not data or not data[0]:
            return []
        return [chunk.decode() for chunk in data[0].split()]

    def list_folders(self) -> list[dict[str, Any]]:
        with self._imap() as conn:
            typ, data = conn.list()
            if typ != "OK":
                raise EmailError(f"Cannot list folders: {_first(data)}")
            folders: list[dict[str, Any]] = []
            for entry in data or []:
                if entry is None:
                    continue
                line = entry.decode("utf-8", "replace") if isinstance(entry, bytes) else str(entry)
                match = re.match(r"\((?P<flags>[^)]*)\) \"?(?P<delim>[^\"]*)\"? (?P<name>.+)", line)
                if not match:
                    continue
                name = match.group("name").strip().strip('"')
                folders.append({"name": name, "flags": match.group("flags").split()})
            return folders

    def list_messages(
        self,
        folder: str,
        criteria: list[str],
        limit: int,
        offset: int,
    ) -> dict[str, Any]:
        with self._imap() as conn:
            self._select(conn, folder, readonly=True)
            uids = self._uid_search(conn, criteria)
            uids.reverse()
            total = len(uids)
            window = uids[offset : offset + limit]
            messages = self._fetch_summaries(conn, window)
            return {
                "folder": folder,
                "total": total,
                "offset": offset,
                "limit": limit,
                "count": len(messages),
                "messages": messages,
            }

    def _fetch_summaries(
        self, conn: imaplib.IMAP4, uids: list[str]
    ) -> list[dict[str, Any]]:
        if not uids:
            return []
        typ, data = conn.uid("FETCH", ",".join(uids), SUMMARY_ITEMS)
        if typ != "OK":
            raise EmailError(f"Fetch failed: {_first(data)}")
        order = {uid: index for index, uid in enumerate(uids)}
        summaries: list[dict[str, Any]] = []
        for part in data or []:
            if not isinstance(part, tuple) or len(part) < 2:
                continue
            descriptor, header_bytes = part[0], part[1]
            uid_match = _UID_PATTERN.search(descriptor or b"")
            if not uid_match:
                continue
            uid = uid_match.group(1).decode()
            flags = [flag.decode() if isinstance(flag, bytes) else str(flag) for flag in imaplib.ParseFlags(descriptor)]
            headers = email.message_from_bytes(header_bytes or b"", policy=default_policy)
            summaries.append(
                {
                    "uid": uid,
                    "flags": flags,
                    "seen": "\\Seen" in flags,
                    "from": str(headers.get("From", "")),
                    "to": _header_addresses(str(headers.get("To", ""))),
                    "subject": str(headers.get("Subject", "")),
                    "date": _decode_date(str(headers.get("Date", ""))),
                    "message_id": str(headers.get("Message-ID", "")),
                }
            )
        summaries.sort(key=lambda item: order.get(item["uid"], 0))
        return summaries

    def read_message(self, folder: str, uid: str) -> dict[str, Any]:
        with self._imap() as conn:
            self._select(conn, folder, readonly=True)
            typ, data = conn.uid("FETCH", uid, "(FLAGS BODY.PEEK[])")
            if typ != "OK":
                raise EmailError(f"Fetch failed: {_first(data)}")
            raw = _first_payload(data)
            if raw is None:
                raise EmailError(f"Message {uid} not found in '{folder}'.", kind="not_found")
            flags = [flag.decode() if isinstance(flag, bytes) else str(flag) for flag in imaplib.ParseFlags(_first_descriptor(data) or b"")]
            message = email.message_from_bytes(raw, policy=default_policy)
            return self._render_message(uid, folder, flags, message)

    def _render_message(
        self, uid: str, folder: str, flags: list[str], message: Any
    ) -> dict[str, Any]:
        text_part = message.get_body(preferencelist=("plain",))
        html_part = message.get_body(preferencelist=("html",))
        attachments: list[dict[str, Any]] = []
        for part in message.iter_attachments():
            payload = part.get_content()
            size = len(payload) if isinstance(payload, (bytes, str)) else 0
            attachments.append(
                {
                    "filename": part.get_filename() or "",
                    "content_type": part.get_content_type(),
                    "size": size,
                }
            )
        body = text_part.get_content().strip() if text_part else ""
        html = html_part.get_content().strip() if html_part else ""
        return {
            "uid": uid,
            "folder": folder,
            "flags": flags,
            "from": str(message.get("From", "")),
            "to": _header_addresses(str(message.get("To", ""))),
            "cc": _header_addresses(str(message.get("Cc", ""))),
            "subject": str(message.get("Subject", "")),
            "date": _decode_date(str(message.get("Date", ""))),
            "message_id": str(message.get("Message-ID", "")),
            "body": body[:MAX_BODY_CHARS],
            "body_truncated": len(body) > MAX_BODY_CHARS,
            "html": html[:MAX_BODY_CHARS],
            "attachments": attachments,
        }

    def search(self, folder: str, criteria: list[str], limit: int) -> dict[str, Any]:
        return self.list_messages(folder, criteria, limit, 0)

    def set_flags(
        self, folder: str, uid: str, flags: list[str], add: bool
    ) -> dict[str, Any]:
        with self._imap() as conn:
            self._select(conn, folder, readonly=False)
            command = "+FLAGS" if add else "-FLAGS"
            typ, data = conn.uid("STORE", uid, command, "(%s)" % " ".join(flags))
            if typ != "OK":
                raise EmailError(f"Flag update failed: {_first(data)}")
            return {"uid": uid, "folder": folder, "flags": flags, "added": add}

    def move_message(self, folder: str, uid: str, destination: str) -> dict[str, Any]:
        with self._imap() as conn:
            self._select(conn, folder, readonly=False)
            typ, data = conn.uid("COPY", uid, imap_quote(destination))
            if typ != "OK":
                raise EmailError(f"Copy to '{destination}' failed: {_first(data)}")
            conn.uid("STORE", uid, "+FLAGS", "(\\Deleted)")
            conn.expunge()
            return {"uid": uid, "from_folder": folder, "to_folder": destination}

    def delete_message(
        self, folder: str, uid: str, trash: str | None
    ) -> dict[str, Any]:
        if trash and trash != folder:
            result = self.move_message(folder, uid, trash)
            result["deleted"] = True
            return result
        with self._imap() as conn:
            self._select(conn, folder, readonly=False)
            typ, data = conn.uid("STORE", uid, "+FLAGS", "(\\Deleted)")
            if typ != "OK":
                raise EmailError(f"Delete failed: {_first(data)}")
            conn.expunge()
            return {"uid": uid, "folder": folder, "deleted": True}

    def send_message(
        self,
        to: list[str],
        subject: str,
        body: str,
        cc: list[str] | None = None,
        bcc: list[str] | None = None,
        html: str | None = None,
        in_reply_to: str | None = None,
    ) -> dict[str, Any]:
        account = self.account
        if not account.smtp_host:
            raise EmailError("This account has no SMTP host configured.", kind="config")
        message = EmailMessage()
        message["From"] = (
            formataddr((account.from_name, account.from_address))
            if account.from_name
            else account.from_address
        )
        message["To"] = ", ".join(to)
        if cc:
            message["Cc"] = ", ".join(cc)
        message["Subject"] = subject
        if in_reply_to:
            message["In-Reply-To"] = in_reply_to
            message["References"] = in_reply_to
        message.set_content(body)
        if html:
            message.add_alternative(html, subtype="html")
        recipients = list(to) + list(cc or []) + list(bcc or [])
        try:
            guard_public_host_sync(account.smtp_host)
        except BlockedAddressError as exc:
            raise EmailError(str(exc), kind="blocked") from exc
        context = ssl.create_default_context()
        try:
            if account.smtp_ssl:
                server: smtplib.SMTP = smtplib.SMTP_SSL(
                    account.smtp_host,
                    account.smtp_port,
                    timeout=self.timeout,
                    context=context,
                )
            else:
                server = smtplib.SMTP(
                    account.smtp_host, account.smtp_port, timeout=self.timeout
                )
                if account.smtp_starttls:
                    server.starttls(context=context)
            with server:
                if account.username and account.password:
                    server.login(account.username, account.password)
                server.send_message(
                    message, from_addr=account.from_address, to_addrs=recipients
                )
        except (OSError, smtplib.SMTPException) as exc:
            raise EmailError(f"Sending failed: {exc}", kind="send") from exc
        return {
            "sent": True,
            "from": account.from_address,
            "to": to,
            "cc": cc or [],
            "bcc": bcc or [],
            "subject": subject,
        }


def _first(data: Any) -> str:
    if not data:
        return ""
    head = data[0]
    if isinstance(head, bytes):
        return head.decode("utf-8", "replace")
    return str(head)


def _first_descriptor(data: Any) -> bytes | None:
    for part in data or []:
        if isinstance(part, tuple) and part:
            return part[0]
    return None


def _first_payload(data: Any) -> bytes | None:
    for part in data or []:
        if isinstance(part, tuple) and len(part) >= 2:
            return part[1]
    return None
