"""Email backend for the NexusOS mail client.

IMAP read via imap-tools, SMTP send via the stdlib. Defaults target iCloud
(imap.mail.me.com / smtp.mail.me.com); with a custom-domain iCloud account the
LOGIN is the primary Apple ID + an app-specific password, and the custom-domain
address is the From alias.

Multiple accounts are supported (e.g. two iCloud aliases sharing the same
server config) — each gets its own id, credentials, and cached IMAP
connection. Credentials live in a gitignored file under runtime/ — NEVER the
settings table, which bin/sync.py dumps to git. Password is write-only over
the API.
"""
from __future__ import annotations

import json
import os
import smtplib
import ssl
import threading
import uuid
from email.message import EmailMessage
from typing import Any, Callable, Dict, List, Optional

from synapse.nexus_config import RUNTIME_DIR

_ACCOUNT_FILE = RUNTIME_DIR / "mail_accounts.json"

# Both imaplib.IMAP4_SSL and smtplib.SMTP.starttls() fall back to
# ssl._create_stdlib_context() when handed no context, and that one sets
# verify_mode=CERT_NONE with check_hostname=False -- encrypted, but to nobody in
# particular. Anything positioned to intercept the connection can present its
# own certificate and collect the app-specific password on login. The default
# context verifies the chain and the hostname.
_TLS = ssl.create_default_context()

_DEFAULTS: Dict[str, Any] = {
    "label": "",          # optional nickname shown in the account list
    "imap_host": "imap.mail.me.com",
    "imap_port": 993,
    "smtp_host": "smtp.mail.me.com",
    "smtp_port": 587,
    "username": "",       # primary Apple ID address (login), not the alias
    "password": "",       # app-specific password
    "from_addr": "",      # e.g. nexus@enderofwings.com
    "from_name": "",
}


def _new_id() -> str:
    return uuid.uuid4().hex[:12]


def _write_accounts(accounts: List[Dict[str, Any]]) -> None:
    _ACCOUNT_FILE.parent.mkdir(parents=True, exist_ok=True)
    # Create the file 0600 and rename it into place, rather than write-then-chmod:
    # that left it at the umask (usually world-readable) for the length of the
    # write, and a crash partway through truncated the real account file. The
    # temp file inherits the mode through os.replace.
    tmp = _ACCOUNT_FILE.with_name(_ACCOUNT_FILE.name + ".tmp")
    fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
    with os.fdopen(fd, "w") as fh:
        json.dump({"accounts": accounts}, fh, indent=2)
    os.replace(tmp, _ACCOUNT_FILE)


def load_accounts() -> List[Dict[str, Any]]:
    try:
        raw = json.loads(_ACCOUNT_FILE.read_text())
    except Exception:
        return []
    if isinstance(raw, dict) and "accounts" in raw:
        accounts = raw["accounts"]
    elif isinstance(raw, dict) and raw.get("username"):
        # Legacy single-account file (pre-multi-account) — migrate in place.
        accounts = [{**_DEFAULTS, **raw, "id": _new_id()}]
        _write_accounts(accounts)
    else:
        accounts = []
    return [{**_DEFAULTS, **a} for a in accounts]


def get_account(account_id: str) -> Dict[str, Any]:
    for a in load_accounts():
        if a["id"] == account_id:
            return a
    raise KeyError(account_id)


def save_account(account_id: Optional[str], update: Dict[str, Any]) -> Dict[str, Any]:
    """Create (account_id=None) or merge-update an account. A blank/missing
    password on update keeps the stored one."""
    accounts = load_accounts()
    if account_id is None:
        data = dict(_DEFAULTS)
        data["id"] = _new_id()
        for k in _DEFAULTS:
            if k in update:
                data[k] = update[k]
        accounts.append(data)
    else:
        for a in accounts:
            if a["id"] != account_id:
                continue
            for k in _DEFAULTS:
                if k == "password":
                    if update.get("password"):
                        a["password"] = update["password"]
                elif k in update:
                    a[k] = update[k]
            data = a
            break
        else:
            raise KeyError(account_id)
    _write_accounts(accounts)
    _reset_conn(data["id"])  # creds/host may have changed
    return public_account(data)


def delete_account(account_id: str) -> None:
    accounts = [a for a in load_accounts() if a["id"] != account_id]
    _write_accounts(accounts)
    _reset_conn(account_id)


def public_account(a: Dict[str, Any]) -> Dict[str, Any]:
    """Account config for the UI — password replaced by a has_password flag."""
    configured = _is_configured(a)
    data = dict(a)
    pw = data.pop("password", "")
    data["has_password"] = bool(pw)
    data["configured"] = configured
    return data


def public_accounts() -> List[Dict[str, Any]]:
    return [public_account(a) for a in load_accounts()]


def _is_configured(a: Dict[str, Any]) -> bool:
    return bool(a.get("username") and a.get("password") and a.get("imap_host"))


def is_configured(account_id: str) -> bool:
    try:
        return _is_configured(get_account(account_id))
    except KeyError:
        return False


# --- IMAP ----------------------------------------------------------------------
# One authenticated connection per account, reused across requests — a fresh
# login per click is the ~1-2s iCloud handshake, and that was the visible lag.
# imap-tools/imaplib is single-command-at-a-time, so a lock serialises the
# thread-pooled endpoints.
_conns: Dict[str, Any] = {}
_conn_lock = threading.Lock()


def _new_mailbox(a: Dict[str, Any]):
    from imap_tools import MailBox
    return MailBox(a["imap_host"], a["imap_port"], ssl_context=_TLS).login(a["username"], a["password"])


def _reset_conn(account_id: str) -> None:
    with _conn_lock:
        conn = _conns.pop(account_id, None)
    try:
        if conn is not None:
            conn.logout()
    except Exception:
        pass


def _run(account_id: str, fn: Callable):
    """Run fn(mailbox) on the account's shared connection, rebuilding it once
    if the connection was dropped (iCloud closes idle sockets)."""
    a = get_account(account_id)
    with _conn_lock:
        for attempt in (1, 2):
            try:
                conn = _conns.get(account_id)
                if conn is None:
                    conn = _conns[account_id] = _new_mailbox(a)
                return fn(conn)
            except Exception:
                conn = _conns.pop(account_id, None)
                try:
                    if conn is not None:
                        conn.logout()
                except Exception:
                    pass
                if attempt == 2:
                    raise


def test_connection(account_id: str) -> Dict[str, Any]:
    _reset_conn(account_id)  # force a fresh login so the test reflects the saved creds
    try:
        return {"ok": True, "folders": len(_run(account_id, lambda mb: mb.folder.list()))}
    except Exception as e:
        return {"ok": False, "error": str(e)}


def list_folders(account_id: str) -> List[str]:
    return _run(account_id, lambda mb: [f.name for f in mb.folder.list()])


def _summary(msg) -> Dict[str, Any]:
    return {
        "uid": msg.uid,
        "subject": msg.subject,
        "from": msg.from_,
        "to": list(msg.to),
        "date": msg.date.isoformat() if msg.date else None,
        "seen": "\\Seen" in msg.flags,
        "preview": (msg.text or msg.html or "")[:160].strip(),
    }


def list_messages(account_id: str, folder: str = "INBOX", limit: int = 30, offset: int = 0) -> List[Dict[str, Any]]:
    def op(mb):
        mb.folder.set(folder)
        # Newest first; over-fetch by offset then slice (fine at personal scale).
        msgs = list(mb.fetch(reverse=True, limit=offset + limit, headers_only=True, bulk=True, mark_seen=False))
        return [_summary(m) for m in msgs[offset:offset + limit]]
    return _run(account_id, op)


def get_message(account_id: str, folder: str, uid: str) -> Optional[Dict[str, Any]]:
    from imap_tools import AND

    def op(mb):
        mb.folder.set(folder)
        for msg in mb.fetch(AND(uid=uid), mark_seen=True, bulk=True):
            return {
                "uid": msg.uid,
                "subject": msg.subject,
                "from": msg.from_,
                "to": list(msg.to),
                "cc": list(msg.cc),
                "date": msg.date.isoformat() if msg.date else None,
                "html": msg.html,
                "text": msg.text,
                "attachments": [{"name": a.filename, "size": a.size} for a in msg.attachments],
            }
        return None
    return _run(account_id, op)


def set_seen(account_id: str, folder: str, uid: str, seen: bool = True) -> None:
    def op(mb):
        mb.folder.set(folder)
        mb.flag(uid, "\\Seen", seen)
    _run(account_id, op)


def delete_message(account_id: str, folder: str, uid: str) -> None:
    def op(mb):
        mb.folder.set(folder)
        mb.delete(uid)
    _run(account_id, op)


# --- SMTP ----------------------------------------------------------------------
def send_message(account_id: str, to: str, subject: str, body: str,
                 cc: str = "", from_addr: str = "") -> Dict[str, Any]:
    a = get_account(account_id)
    sender = from_addr or a["from_addr"] or a["username"]
    msg = EmailMessage()
    msg["From"] = f'{a["from_name"]} <{sender}>' if a["from_name"] else sender
    msg["To"] = to
    if cc:
        msg["Cc"] = cc
    msg["Subject"] = subject
    msg.set_content(body)

    recipients = [r.strip() for r in (to + "," + cc).split(",") if r.strip()]
    with smtplib.SMTP(a["smtp_host"], a["smtp_port"]) as s:
        s.starttls(context=_TLS)
        s.login(a["username"], a["password"])
        s.send_message(msg, from_addr=sender, to_addrs=recipients)
    return {"sent": True, "to": recipients}
