#!/usr/bin/env python3

import argparse
import json
import os
import sqlite3
import sys
from datetime import datetime


STANDARD_FLAGS = {
    "$seen": "S",
    "$answered": "R",
    "$flagged": "F",
    "$draft": "D",
    "$deleted": "T",
}


def fail(message):
    print(f"error: {message}", file=sys.stderr)
    sys.exit(1)


def sanitize(component):
    out = []
    for ch in component:
        if ch in ("/", "\\", "\x00") or ord(ch) < 0x20:
            out.append("_")
        else:
            out.append(ch)
    cleaned = "".join(out).strip().rstrip(".")
    return cleaned or "_unnamed"


def unique_dir(parent, label):
    base = sanitize(label)
    candidate = os.path.join(parent, base)
    suffix = 1
    while os.path.exists(candidate):
        candidate = os.path.join(parent, f"{base}-{suffix}")
        suffix += 1
    os.makedirs(candidate)
    return candidate


def blob_bytes(conn, blob_id):
    if blob_id is None:
        return None
    row = conn.execute("SELECT data FROM blobs WHERE id = ?", (blob_id,)).fetchone()
    return row[0] if row is not None else None


def epoch_of(received_at):
    text = (received_at or "").strip()
    if not text:
        return 0
    if text.endswith("Z"):
        text = text[:-1] + "+00:00"
    try:
        return int(datetime.fromisoformat(text).timestamp())
    except ValueError:
        return 0


def keywords_to_info(keywords_json):
    try:
        keywords = json.loads(keywords_json)
    except (ValueError, TypeError):
        keywords = []
    letters = set()
    for keyword in keywords:
        mapped = STANDARD_FLAGS.get(str(keyword).lower())
        if mapped:
            letters.add(mapped)
    return "".join(sorted(letters))


def ensure_maildir(path):
    for sub in ("cur", "new", "tmp"):
        os.makedirs(os.path.join(path, sub), exist_ok=True)


def maildir_paths(conn, root):
    rows = conn.execute(
        "SELECT id, name, parent_id, role FROM mailboxes"
    ).fetchall()
    info = {
        row[0]: {"name": row[1], "parent": row[2], "role": row[3]} for row in rows
    }

    def components(mailbox_id):
        chain = []
        cursor = mailbox_id
        seen = set()
        while cursor is not None and cursor not in seen:
            seen.add(cursor)
            node = info.get(cursor)
            if node is None:
                break
            chain.append((node["name"], node["role"]))
            cursor = node["parent"]
        chain.reverse()
        if chain and chain[0][1] == "inbox":
            chain = chain[1:]
        return [sanitize(name) for name, _ in chain]

    paths = {}
    os.makedirs(root, exist_ok=True)
    ensure_maildir(root)
    for mailbox_id in info:
        comps = components(mailbox_id)
        path = root if not comps else os.path.join(root, "." + ".".join(comps))
        ensure_maildir(path)
        paths[mailbox_id] = path
    return paths


def export_mail(conn, root):
    paths = maildir_paths(conn, root)
    rows = conn.execute(
        "SELECT e.id, e.received_at, e.mailbox_ids, e.keywords, b.data "
        "FROM emails e JOIN blobs b ON b.id = e.blob_id"
    ).fetchall()
    sequence = 0
    written = 0
    for email_id, received_at, mailbox_ids_json, keywords_json, data in rows:
        try:
            mailbox_ids = json.loads(mailbox_ids_json)
        except (ValueError, TypeError):
            mailbox_ids = []
        info = keywords_to_info(keywords_json)
        stamp = epoch_of(received_at)
        for mailbox_id in mailbox_ids:
            directory = paths.get(mailbox_id)
            if directory is None:
                continue
            sequence += 1
            name = f"{stamp}.M{email_id}P{sequence}.vandelay:2,{info}"
            with open(os.path.join(directory, "cur", name), "wb") as handle:
                handle.write(data)
            written += 1
    return written


def resolve_blob_sentinels(conn, value, attachments_dir):
    if isinstance(value, dict):
        if "@blob" in value and isinstance(value["@blob"], int):
            blob_id = value["@blob"]
            payload = blob_bytes(conn, blob_id)
            if payload is not None:
                os.makedirs(attachments_dir, exist_ok=True)
                filename = f"blob-{blob_id}.bin"
                with open(os.path.join(attachments_dir, filename), "wb") as handle:
                    handle.write(payload)
                value["@blob"] = os.path.join(
                    os.path.basename(attachments_dir), filename
                )
        for nested in value.values():
            resolve_blob_sentinels(conn, nested, attachments_dir)
    elif isinstance(value, list):
        for nested in value:
            resolve_blob_sentinels(conn, nested, attachments_dir)


def write_json_item(conn, directory, label, payload):
    attachments_dir = os.path.join(directory, "_attachments")
    resolve_blob_sentinels(conn, payload, attachments_dir)
    base = sanitize(label)
    candidate = os.path.join(directory, f"{base}.json")
    suffix = 1
    while os.path.exists(candidate):
        candidate = os.path.join(directory, f"{base}-{suffix}.json")
        suffix += 1
    with open(candidate, "w", encoding="utf-8") as handle:
        json.dump(payload, handle, indent=2, ensure_ascii=False)


def export_collections(conn, root, container_table, container_select, item_query,
                       container_label, item_membership_field, augment):
    os.makedirs(root, exist_ok=True)
    containers = {}
    for row in conn.execute(container_select).fetchall():
        containers[row[0]] = unique_dir(root, container_label(row))
    orphans = None
    written = 0
    for row in conn.execute(item_query).fetchall():
        payload, member_ids, label = augment(row)
        targets = [containers[c] for c in member_ids if c in containers]
        if not targets:
            if orphans is None:
                orphans = unique_dir(root, "_orphans")
            targets = [orphans]
        for directory in targets:
            write_json_item(conn, directory, label, json.loads(json.dumps(payload)))
            written += 1
    return written


def export_calendars(conn, root):
    def container_label(row):
        return row[1] or f"calendar-{row[0]}"

    def augment(row):
        event_id, calendar_ids_json, is_draft, use_default_alerts, data_json, data_type = row
        payload = json.loads(data_json)
        if "@type" not in payload and data_type:
            payload["@type"] = data_type
        payload["isDraft"] = bool(is_draft)
        payload["useDefaultAlerts"] = bool(use_default_alerts)
        try:
            calendar_ids = json.loads(calendar_ids_json)
        except (ValueError, TypeError):
            calendar_ids = []
        payload["calendarIds"] = calendar_ids
        label = payload.get("uid") or f"event-{event_id}"
        return payload, calendar_ids, label

    return export_collections(
        conn,
        root,
        "calendars",
        "SELECT id, name FROM calendars",
        "SELECT id, calendar_ids, is_draft, use_default_alerts, data, data_type "
        "FROM calendar_events",
        container_label,
        "calendar_ids",
        augment,
    )


def export_contacts(conn, root):
    def container_label(row):
        return row[1] or f"addressbook-{row[0]}"

    def augment(row):
        card_id, uid, address_book_ids_json, data_json = row
        payload = json.loads(data_json)
        payload["uid"] = uid
        try:
            address_book_ids = json.loads(address_book_ids_json)
        except (ValueError, TypeError):
            address_book_ids = []
        label = uid or f"card-{card_id}"
        return payload, address_book_ids, label

    return export_collections(
        conn,
        root,
        "address_books",
        "SELECT id, name FROM address_books",
        "SELECT id, uid, address_book_ids, data FROM contact_cards",
        container_label,
        "address_book_ids",
        augment,
    )


def export_sieve(conn, root):
    rows = conn.execute(
        "SELECT s.id, s.name, s.is_active, b.data "
        "FROM sieve_scripts s JOIN blobs b ON b.id = s.blob_id"
    ).fetchall()
    if not rows:
        return 0
    os.makedirs(root, exist_ok=True)
    written = 0
    for script_id, name, is_active, data in rows:
        label = sanitize(name or f"script-{script_id}")
        with open(os.path.join(root, f"{label}.sieve"), "wb") as handle:
            handle.write(data)
        if is_active:
            with open(os.path.join(root, "active.sieve"), "wb") as handle:
                handle.write(data)
        written += 1
    return written


def export_files(conn, root):
    rows = conn.execute(
        "SELECT id, parent_id, node_type, blob_id, target, name FROM file_nodes"
    ).fetchall()
    if not rows:
        return 0
    children = {}
    for row in rows:
        children.setdefault(row[1], []).append(row)
    os.makedirs(root, exist_ok=True)
    written = 0

    def walk(parent_id, directory):
        nonlocal written
        for node_id, _parent, node_type, blob_id, target_json, name in children.get(
            parent_id, []
        ):
            path = os.path.join(directory, sanitize(name))
            if node_type == "directory":
                os.makedirs(path, exist_ok=True)
                walk(node_id, path)
            elif node_type == "symlink":
                try:
                    target = json.loads(target_json) if target_json else []
                except (ValueError, TypeError):
                    target = []
                link_target = "/".join(str(part) for part in target)
                try:
                    os.symlink(link_target, path)
                    written += 1
                except (OSError, NotImplementedError):
                    pass
            else:
                payload = blob_bytes(conn, blob_id)
                with open(path, "wb") as handle:
                    handle.write(payload if payload is not None else b"")
                written += 1

    walk(None, root)
    return written


def main():
    parser = argparse.ArgumentParser(
        description="Export a Vandelay SQLite archive to a directory tree."
    )
    parser.add_argument("archive")
    parser.add_argument("target")
    args = parser.parse_args()

    if not os.path.isfile(args.archive):
        fail(f"archive not found: {args.archive}")
    if os.path.exists(args.target):
        fail(f"target already exists: {args.target}")

    os.makedirs(args.target)

    uri = f"file:{os.path.abspath(args.archive)}?mode=ro&immutable=1"
    conn = sqlite3.connect(uri, uri=True)
    try:
        emails = export_mail(conn, os.path.join(args.target, "mail"))
        events = export_calendars(conn, os.path.join(args.target, "calendars"))
        cards = export_contacts(conn, os.path.join(args.target, "contacts"))
        scripts = export_sieve(conn, os.path.join(args.target, "sieve"))
        files = export_files(conn, os.path.join(args.target, "files"))
    finally:
        conn.close()

    print(f"emails:    {emails}")
    print(f"events:    {events}")
    print(f"contacts:  {cards}")
    print(f"sieve:     {scripts}")
    print(f"files:     {files}")


if __name__ == "__main__":
    main()
