"""IMAP-to-IMAP migration engine. Bounded worker pool + scheduler."""
import os
import re
import json
import imaplib
import threading
import time
import queue
import email
from datetime import datetime, timezone

from database import SessionLocal
from models import Migration, MigrationFolder, MigrationLog
from security import decrypt_secret
import cloud

imaplib._MAXLINE = 10_000_000

MAX_WORKERS = int(os.environ.get("MAX_CONCURRENT_MIGRATIONS", "3"))

# In-memory control flags: migration_id -> "run" | "pause" | "cancel"
_control = {}
_lock = threading.Lock()
_task_queue = queue.Queue()
_running = set()
_queued = set()
_workers_started = False
_scheduler_started = False

# Per-destination locks: only one migration writes to a given dest mailbox at a time
_dest_locks = {}
_dest_locks_guard = threading.Lock()


def _dest_lock(key):
    with _dest_locks_guard:
        lk = _dest_locks.get(key)
        if lk is None:
            lk = threading.Lock()
            _dest_locks[key] = lk
        return lk

LIST_RE = re.compile(rb'\((?P<flags>[^)]*)\) "?(?P<delim>[^"]*)"? (?P<name>.*)')
SKIP_FLAGS = {b"\\All", b"\\Noselect"}
KEEP_MSG_FLAGS = {b"\\Seen", b"\\Answered", b"\\Flagged", b"\\Draft"}

# Normalize folders so migration lands inbox->INBOX, sent->Sent, etc. (no Gmail-style labels/tags)
_FOLDER_MAP = {
    "inbox": "INBOX",
    "sent": "Sent", "sent mail": "Sent", "sent items": "Sent", "sent messages": "Sent",
    "drafts": "Drafts", "draft": "Drafts",
    "trash": "Trash", "bin": "Trash", "deleted": "Trash", "deleted items": "Trash", "deleted messages": "Trash",
    "spam": "Junk", "junk": "Junk", "junk email": "Junk", "junk e-mail": "Junk", "bulk mail": "Junk",
    "archive": "Archive", "all mail": None, "starred": None, "important": None, "chats": None,
}


def map_folder_name(name: str):
    """Return normalized destination folder name, or None if it should be skipped."""
    n = name
    for pre in ("[Gmail]/", "[Google Mail]/"):
        if n.startswith(pre):
            n = n[len(pre):]
    key = n.strip().lower().strip("[]")
    if key in _FOLDER_MAP:
        return _FOLDER_MAP[key]
    return n


import base64


def _xoauth2_string(user, access_token):
    return f"user={user}\x01auth=Bearer {access_token}\x01\x01"


def _connect(host, port, user, password, use_ssl, auth_method="password", access_token=None):
    if use_ssl:
        M = imaplib.IMAP4_SSL(host, int(port))
    else:
        M = imaplib.IMAP4(host, int(port))
    if auth_method == "oauth":
        auth_string = _xoauth2_string(user, access_token)
        M.authenticate("XOAUTH2", lambda _: auth_string.encode())
    else:
        M.login(user, password)
    return M


def test_connection(host, port, user, password, use_ssl, auth_method="password", access_token=None):
    """Returns (ok, message, folder_count)."""
    try:
        M = _connect(host, port, user, password, use_ssl, auth_method, access_token)
        typ, data = M.list()
        count = len(data) if typ == "OK" and data else 0
        try:
            M.logout()
        except Exception:
            pass
        return True, "Connection successful", count
    except imaplib.IMAP4.error as e:
        return False, f"IMAP error: {e}", 0
    except Exception as e:
        return False, f"Connection failed: {e}", 0


def list_folders(host, port, user, password, use_ssl, auth_method="password", access_token=None):
    """Returns (ok, message, folders[])."""
    try:
        M = _connect(host, port, user, password, use_ssl, auth_method, access_token)
        typ, data = M.list()
        folders = _parse_folders(data) if typ == "OK" else []
        try:
            M.logout()
        except Exception:
            pass
        return True, "ok", folders
    except Exception as e:
        return False, f"{e}", []


def mailbox_overview(host, port, user, password, use_ssl, auth_method="password", access_token=None):
    """Connects and returns folder/message counts, plus total size in bytes when the
    server supports it (RFC 8438 STATUS=SIZE — common on Dovecot/cPanel, often
    unsupported on Gmail/Outlook, in which case size_bytes comes back as None)."""
    try:
        M = _connect(host, port, user, password, use_ssl, auth_method, access_token)
        typ, data = M.list()
        folders = _parse_folders(data) if typ == "OK" else []

        total_messages = 0
        total_bytes = 0
        size_known = False

        for name in folders:
            quoted = name if (" " not in name and '"' not in name) else f'"{name}"'
            try:
                typ2, resp = M.status(quoted, "(MESSAGES SIZE)")
            except Exception:
                typ2, resp = None, None

            text = None
            if typ2 == "OK" and resp and resp[0]:
                text = resp[0].decode(errors="ignore") if isinstance(resp[0], bytes) else str(resp[0])

            if text and "SIZE" in text:
                m_count = re.search(r"MESSAGES (\d+)", text)
                m_size = re.search(r"SIZE (\d+)", text)
                if m_count:
                    total_messages += int(m_count.group(1))
                if m_size:
                    total_bytes += int(m_size.group(1))
                    size_known = True
            else:
                # Server doesn't support SIZE in STATUS — fall back to message count only
                try:
                    typ3, resp3 = M.status(quoted, "(MESSAGES)")
                    if typ3 == "OK" and resp3 and resp3[0]:
                        text3 = resp3[0].decode(errors="ignore") if isinstance(resp3[0], bytes) else str(resp3[0])
                        m_count = re.search(r"MESSAGES (\d+)", text3)
                        if m_count:
                            total_messages += int(m_count.group(1))
                except Exception:
                    pass

        try:
            M.logout()
        except Exception:
            pass

        return {
            "ok": True,
            "folders": len(folders),
            "messages": total_messages,
            "size_bytes": total_bytes if size_known else None,
        }
    except Exception as e:
        return {"ok": False, "message": str(e)}


def _decode_folder_name(raw_name: bytes) -> str:
    name = raw_name.strip()
    if name.startswith(b'"') and name.endswith(b'"'):
        name = name[1:-1]
    try:
        return name.decode("utf-7").replace("&", "+").encode().decode("imap4-utf-7") if False else name.decode()
    except Exception:
        return name.decode("utf-8", errors="replace")


def _parse_folders(list_data):
    folders = []
    for line in list_data:
        if not line:
            continue
        if isinstance(line, tuple):
            line = line[0]
        m = LIST_RE.match(line)
        if not m:
            continue
        flags = m.group("flags").split()
        raw_name = m.group("name")
        if any(f in SKIP_FLAGS for f in flags):
            continue
        name = _decode_folder_name(raw_name)
        folders.append(name)
    return folders


def _quote(name: str) -> str:
    return '"' + name.replace('"', '\\"') + '"'


def _imap_date(dt):
    """Format a datetime/date as IMAP date string, e.g. 01-Jan-2024."""
    return dt.strftime("%d-%b-%Y")


def _build_search_criteria(date_from, date_to):
    """Return IMAP SEARCH args list. date_to is inclusive (BEFORE = date_to + 1 day)."""
    args = []
    if date_from:
        args += ["SINCE", _imap_date(date_from)]
    if date_to:
        from datetime import timedelta as _td
        args += ["BEFORE", _imap_date(date_to + _td(days=1))]
    return args if args else ["ALL"]


def _log(db, migration_id, message, level="info"):
    db.add(MigrationLog(migration_id=migration_id, message=message, level=level,
                        ts=datetime.now(timezone.utc)))
    db.commit()


def _dest_message_ids(dst, folder_quoted):
    """Return set of Message-IDs already present in destination folder."""
    ids = set()
    try:
        typ, _ = dst.select(folder_quoted)
        if typ != "OK":
            return ids
        typ, data = dst.uid("SEARCH", None, "ALL")
        if typ != "OK" or not data or not data[0]:
            return ids
        uids = data[0].split()
        for i in range(0, len(uids), 200):
            chunk = b",".join(uids[i:i + 200])
            typ, resp = dst.uid("FETCH", chunk, "(BODY.PEEK[HEADER.FIELDS (MESSAGE-ID)])")
            if typ != "OK":
                continue
            for part in resp:
                if isinstance(part, tuple) and len(part) > 1 and part[1]:
                    mid = _extract_message_id(part[1])
                    if mid:
                        ids.add(mid)
    except Exception:
        pass
    return ids


def _extract_message_id(raw_bytes):
    try:
        msg = email.message_from_bytes(raw_bytes)
        mid = msg.get("Message-ID") or msg.get("Message-Id")
        return mid.strip() if mid else None
    except Exception:
        return None


def _get_control(migration_id):
    with _lock:
        return _control.get(migration_id, "run")


def set_control(migration_id, value):
    with _lock:
        _control[migration_id] = value


def is_running(migration_id):
    with _lock:
        return migration_id in _running


def is_active(migration_id):
    """Running or waiting in queue."""
    with _lock:
        return migration_id in _running or migration_id in _queued


def _wait_if_paused(migration_id):
    """Returns True if should continue, False if cancelled."""
    while True:
        ctrl = _get_control(migration_id)
        if ctrl == "cancel":
            return False
        if ctrl != "pause":
            return True
        time.sleep(1.5)


def _ensure_workers():
    global _workers_started
    with _lock:
        if _workers_started:
            return
        for _ in range(MAX_WORKERS):
            threading.Thread(target=_worker_loop, daemon=True).start()
        _workers_started = True


def _worker_loop():
    while True:
        migration_id = _task_queue.get()
        try:
            with _lock:
                _queued.discard(migration_id)
                if _control.get(migration_id) == "cancel":
                    continue
                _running.add(migration_id)
            _run(migration_id)
        except Exception:
            pass
        finally:
            with _lock:
                _running.discard(migration_id)
            _task_queue.task_done()


def start_migration(migration_id):
    """Enqueue a migration; a bounded worker pool runs at most MAX_WORKERS at once."""
    _ensure_workers()
    with _lock:
        if migration_id in _running or migration_id in _queued:
            return False
        _control[migration_id] = "run"
        _queued.add(migration_id)
    db = SessionLocal()
    try:
        m = db.query(Migration).filter(Migration.id == migration_id).first()
        if m and m.status not in ("running",):
            m.status = "queued"
            m.error = None
            db.commit()
    finally:
        db.close()
    _task_queue.put(migration_id)
    return True


def queue_depth():
    with _lock:
        return len(_queued), len(_running), MAX_WORKERS


def start_scheduler():
    global _scheduler_started
    with _lock:
        if _scheduler_started:
            return
        _scheduler_started = True
    threading.Thread(target=_scheduler_loop, daemon=True).start()


def _scheduler_loop():
    _ensure_workers()
    while True:
        try:
            db = SessionLocal()
            now = datetime.now(timezone.utc).replace(tzinfo=None)
            due = (db.query(Migration)
                   .filter(Migration.status == "scheduled", Migration.scheduled_at != None,
                           Migration.scheduled_at <= now)
                   .all())
            ids = [m.id for m in due]
            # recurring sync: completed/failed migrations whose next run is due
            rec = (db.query(Migration)
                   .filter(Migration.recurring == True, Migration.next_run_at != None,
                           Migration.next_run_at <= now,
                           Migration.status.in_(["completed", "failed"]))
                   .all())
            rec_ids = [m.id for m in rec]
            # clear next_run_at now so it won't re-trigger while running (re-set on completion)
            for m in rec:
                m.next_run_at = None
            db.commit()
            db.close()
            for mid in ids + rec_ids:
                start_migration(mid)
        except Exception:
            pass
        time.sleep(20)


def _run(migration_id):
    db = SessionLocal()
    src = dst = None
    dlock = None
    try:
        m = db.query(Migration).filter(Migration.id == migration_id).first()
        if not m:
            return
        dlock = _dest_lock(f"{m.dest_host}:{m.dest_email}".lower())
        dlock.acquire()
        m = db.query(Migration).filter(Migration.id == migration_id).first()
        if not m:
            return
        if _get_control(migration_id) == "cancel":
            _finish(db, migration_id, "failed", "Cancelled by user")
            return
        m.status = "running"
        m.started_at = datetime.now(timezone.utc)
        m.error = None
        db.commit()
        _log(db, migration_id, f"Starting migration: {m.source_email} -> {m.dest_email}")

        def _endpoint_credentials(prefix):
            """Returns (auth_method, password_or_none, access_token_or_none) for source/dest."""
            auth_method = getattr(m, f"{prefix}_auth_method", "password") or "password"
            if auth_method == "oauth":
                refresh_enc = getattr(m, f"{prefix}_refresh_token_enc", None)
                provider = getattr(m, f"{prefix}_oauth_provider", None) or "google"
                if not refresh_enc:
                    raise RuntimeError(f"{prefix} OAuth connection is missing a refresh token — reconnect the account.")
                refresh_token = decrypt_secret(refresh_enc)
                tokens = cloud.mail_refresh(provider, refresh_token)
                return "oauth", None, tokens["access_token"]
            return "password", decrypt_secret(getattr(m, f"{prefix}_password_enc")), None

        s_auth, s_pw, s_token = _endpoint_credentials("source")
        d_auth, d_pw, d_token = _endpoint_credentials("dest")
        source_params = dict(host=m.source_host, port=m.source_port, user=m.source_email,
                              password=s_pw, use_ssl=m.source_ssl, auth_method=s_auth, access_token=s_token)
        dest_params = dict(host=m.dest_host, port=m.dest_port, user=m.dest_email,
                            password=d_pw, use_ssl=m.dest_ssl, auth_method=d_auth, access_token=d_token)

        _log(db, migration_id, f"Connecting to source {m.source_host}:{m.source_port}")
        src = _connect(**source_params)
        _log(db, migration_id, f"Connecting to destination {m.dest_host}:{m.dest_port}")
        dst = _connect(**dest_params)

        typ, list_data = src.list()
        folders = _parse_folders(list_data) if typ == "OK" else []
        if not folders:
            folders = ["INBOX"]

        # Folder selection filter (null/empty => all folders)
        selected = None
        if m.selected_folders:
            try:
                sel_list = json.loads(m.selected_folders)
                if isinstance(sel_list, list) and sel_list:
                    selected = set(sel_list)
            except Exception:
                selected = None
        if selected is not None:
            folders = [f for f in folders if f in selected]
        _log(db, migration_id, f"Found {len(folders)} folder(s) to migrate")

        # Custom date-range filter (IMAP SINCE/BEFORE); empty => all mail
        search_criteria = _build_search_criteria(m.date_from, m.date_to)
        if m.date_from or m.date_to:
            df = m.date_from.date().isoformat() if m.date_from else "beginning"
            dt_ = m.date_to.date().isoformat() if m.date_to else "now"
            _log(db, migration_id, f"Date range filter active: {df} to {dt_}")

        # reset folder rows
        db.query(MigrationFolder).filter(MigrationFolder.migration_id == migration_id).delete()
        db.commit()

        m.total_folders = len(folders)
        m.migrated_folders = 0
        m.total_emails = 0
        m.migrated_emails = 0
        m.failed_emails = 0
        m.bytes_transferred = 0
        db.commit()

        folder_rows = {}
        grand_total = 0
        migratable_count = 0
        for fname in folders:
            is_skipped = map_folder_name(fname) is None
            try:
                typ, sel = src.select(_quote(fname), readonly=True)
                if typ != "OK":
                    cnt = 0
                elif search_criteria == ["ALL"]:
                    cnt = int(sel[0]) if sel and sel[0] else 0
                else:
                    typ2, data2 = src.uid("SEARCH", None, *search_criteria)
                    cnt = len(data2[0].split()) if typ2 == "OK" and data2 and data2[0] else 0
            except Exception:
                cnt = 0
            fr = MigrationFolder(migration_id=migration_id, name=fname, total=cnt, migrated=0,
                                  status="skipped" if is_skipped else "pending")
            db.add(fr)
            db.flush()
            folder_rows[fname] = fr.id
            if not is_skipped:
                grand_total += cnt
                migratable_count += 1
        m.total_emails = grand_total
        m.total_folders = migratable_count
        db.commit()
        _log(db, migration_id, f"Total {grand_total} message(s) to migrate across {migratable_count} folder(s) "
                                f"(label/system folders excluded from this count)")

        for fname in folders:
            if not _wait_if_paused(migration_id):
                _finish(db, migration_id, "paused", "Migration cancelled by user")
                return
            fr_id = folder_rows[fname]
            dest_name = map_folder_name(fname)
            if dest_name is None:
                _log(db, migration_id, f"Skipping folder '{fname}' (label/system folder, not migrated)")
                fr = db.query(MigrationFolder).filter(MigrationFolder.id == fr_id).first()
                fr.status = "done"
                db.commit()
                continue
            if dest_name != fname:
                _log(db, migration_id, f"Mapping '{fname}' -> '{dest_name}'")
            src, dst = _migrate_folder(db, migration_id, src, dst, fname, dest_name, fr_id, search_criteria, source_params, dest_params)
            fr = db.query(MigrationFolder).filter(MigrationFolder.id == fr_id).first()
            fr.status = "done"
            m = db.query(Migration).filter(Migration.id == migration_id).first()
            m.migrated_folders = (m.migrated_folders or 0) + 1
            db.commit()

        _finish(db, migration_id, "completed", None)
        _log(db, migration_id, "Migration completed successfully", "success")
    except Exception as e:
        _log(db, migration_id, f"Fatal error: {e}", "error")
        _finish(db, migration_id, "failed", str(e))
    finally:
        for c in (src, dst):
            try:
                if c:
                    c.logout()
            except Exception:
                pass
        if dlock:
            try:
                dlock.release()
            except Exception:
                pass
        db.close()


def _is_connection_error(e) -> bool:
    """True for errors that mean the socket/connection itself is dead (not just
    one bad message) — these need a reconnect, not just a retry."""
    import ssl
    import socket
    if isinstance(e, (ssl.SSLError, socket.error, OSError, ConnectionError)):
        return True
    if isinstance(e, imaplib.IMAP4.abort):
        return True
    msg = str(e).lower()
    return any(s in msg for s in ("bad_length", "socket error", "broken pipe", "connection reset", "eof occurred"))


def _migrate_folder(db, migration_id, src, dst, fname, dest_name, fr_id, search_criteria=None,
                     source_params=None, dest_params=None):
    if search_criteria is None:
        search_criteria = ["ALL"]
    fq = _quote(fname)          # source folder (read from)
    dq = _quote(dest_name)      # destination folder (write to, normalized)
    _log(db, migration_id, f"Processing folder: {fname}")
    # ensure destination folder exists
    try:
        dst.create(dq)
    except Exception:
        pass
    try:
        dst.subscribe(dq)
    except Exception:
        pass

    existing = _dest_message_ids(dst, dq)

    try:
        typ, sel = src.select(fq, readonly=True)
        if typ != "OK":
            _log(db, migration_id, f"Cannot open source folder {fname}, skipping", "warning")
            fr = db.query(MigrationFolder).filter(MigrationFolder.id == fr_id).first()
            if fr:
                fr.status = "error"
                db.commit()
            return src, dst
    except Exception as e:
        _log(db, migration_id, f"Skip folder {fname}: {e}", "warning")
        fr = db.query(MigrationFolder).filter(MigrationFolder.id == fr_id).first()
        if fr:
            fr.status = "error"
            db.commit()
        return src, dst

    typ, data = src.uid("SEARCH", None, *search_criteria)
    if typ != "OK" or not data or not data[0]:
        fr = db.query(MigrationFolder).filter(MigrationFolder.id == fr_id).first()
        if fr:
            fr.status = "done"
            db.commit()
        return src, dst
    uids = data[0].split()

    done = 0
    fr_status_set = False
    reconnect_attempts = 0
    MAX_RECONNECTS = 3

    def _reconnect():
        """Rebuild both connections and re-select the source folder. Returns True on success."""
        nonlocal src, dst
        try:
            try:
                src.logout()
            except Exception:
                pass
            try:
                dst.logout()
            except Exception:
                pass
            src = _connect(**source_params)
            dst = _connect(**dest_params)
            src.select(fq, readonly=True)
            return True
        except Exception as re_err:
            _log(db, migration_id, f"Reconnect failed: {re_err}", "warning")
            return False

    i = 0
    while i < len(uids):
        uid = uids[i]
        cont = _wait_if_paused(migration_id)
        if not cont:
            return src, dst
        if not fr_status_set:
            fr = db.query(MigrationFolder).filter(MigrationFolder.id == fr_id).first()
            fr.status = "running"
            db.commit()
            fr_status_set = True
        try:
            typ, msgdata = src.uid("FETCH", uid, "(FLAGS INTERNALDATE BODY.PEEK[])")
            if typ != "OK" or not msgdata or not isinstance(msgdata[0], tuple):
                _bump_failed(db, migration_id)
                i += 1
                continue
            meta = msgdata[0][0]
            raw = msgdata[0][1]
            mid = _extract_message_id(raw)
            if mid and mid in existing:
                done += 1
                _bump(db, migration_id, fr_id, done, len(raw))
                i += 1
                continue
            flags = _parse_flags(meta)
            internaldate = _parse_internaldate(meta)
            append_typ, append_data = dst.append(dq, flags, internaldate, raw)
            if append_typ != "OK":
                detail = append_data[0].decode(errors="ignore") if append_data and isinstance(append_data[0], bytes) else str(append_data)
                raise RuntimeError(f"Destination server rejected message (APPEND {append_typ}): {detail[:200]}")
            if mid:
                existing.add(mid)
            done += 1
            _bump(db, migration_id, fr_id, done, len(raw))
            reconnect_attempts = 0  # reset backoff counter after any success
            i += 1
        except Exception as e:
            if _is_connection_error(e) and source_params and dest_params:
                reconnect_attempts += 1
                if reconnect_attempts > MAX_RECONNECTS:
                    _log(db, migration_id, f"Connection kept failing after {MAX_RECONNECTS} reconnects — "
                                            f"stopping folder '{fname}' early ({e})", "error")
                    return src, dst
                _log(db, migration_id, f"Connection dropped ({e}) — reconnecting (attempt {reconnect_attempts})...", "warning")
                time.sleep(min(2 * reconnect_attempts, 10))  # brief backoff before retrying
                if _reconnect():
                    continue  # retry the same uid on the fresh connection
                else:
                    _bump_failed(db, migration_id)
                    i += 1
            else:
                _bump_failed(db, migration_id)
                _log(db, migration_id, f"Failed message in {fname}: {e}", "warning")
                i += 1
    _log(db, migration_id, f"Folder {fname}: migrated {done} message(s)")
    return src, dst


def _parse_flags(meta_bytes):
    """Return flags as a plain space-separated str (imaplib wraps in parens)."""
    try:
        m = re.search(rb"FLAGS \(([^)]*)\)", meta_bytes)
        if not m:
            return None
        flags = [f.decode() for f in m.group(1).split() if f in KEEP_MSG_FLAGS]
        return " ".join(flags) if flags else None
    except Exception:
        return None


def _parse_internaldate(meta_bytes):
    try:
        t = imaplib.Internaldate2tuple(meta_bytes)
        if t:
            return imaplib.Time2Internaldate(t)
    except Exception:
        pass
    return None


_last_commit = {}


def _bump(db, migration_id, fr_id, folder_done, nbytes):
    m = db.query(Migration).filter(Migration.id == migration_id).first()
    m.migrated_emails = (m.migrated_emails or 0) + 1
    m.bytes_transferred = (m.bytes_transferred or 0) + int(nbytes or 0)
    fr = db.query(MigrationFolder).filter(MigrationFolder.id == fr_id).first()
    fr.migrated = folder_done
    # commit every message (counts are small); acceptable for real-time UI
    db.commit()


def _bump_failed(db, migration_id):
    m = db.query(Migration).filter(Migration.id == migration_id).first()
    m.failed_emails = (m.failed_emails or 0) + 1
    db.commit()


def _finish(db, migration_id, status, error):
    m = db.query(Migration).filter(Migration.id == migration_id).first()
    if not m:
        return
    m.status = status
    m.error = error
    if status in ("completed", "failed"):
        m.completed_at = datetime.now(timezone.utc)
        # schedule next recurring run
        if m.recurring and m.recurring_minutes and status == "completed":
            from datetime import timedelta
            m.next_run_at = datetime.now(timezone.utc).replace(tzinfo=None) + timedelta(minutes=int(m.recurring_minutes))
    db.commit()
    with _lock:
        _control.pop(migration_id, None)
    if status in ("completed", "failed"):
        _notify_finish(m, status, error)


def _notify_finish(m, status, error):
    try:
        import notifications
        from models import User
        if not notifications.is_configured():
            return
        db2 = SessionLocal()
        try:
            user = db2.query(User).filter(User.id == m.user_id).first()
            if not user:
                return
            template_key = "migration_completed" if status == "completed" else "migration_failed"
            ctx = {
                "name": user.name or "there",
                "migration_name": m.name,
                "migrated": m.migrated_emails or 0,
                "total": m.total_emails or 0,
                "error": error or "Unknown error",
            }
            # site_name/app_url come from settings via send_templated's caller context;
            # merge in the basics it needs directly here to keep this self-contained.
            import settings_store as _settings
            from server import _get_settings, settings_dict  # local import avoids a circular import at module load time
            site = settings_dict(_get_settings(db2))
            ctx["site_name"] = site.get("site_name") or "MailShift"
            ctx["app_url"] = _settings.get("OAUTH_PUBLIC_BASE", "") or ""
            notifications.send_templated(db2, template_key, user.email, ctx)
            admin_email = os.environ.get("ADMIN_EMAIL")
            if admin_email and admin_email.lower() != user.email.lower():
                notifications.send_templated(db2, template_key, admin_email, ctx)
        finally:
            db2.close()
    except Exception:
        pass
