import { spawnSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { randomUUID } from "node:crypto";
import { getSession } from "./neo4j.js";
import { stripAngleBrackets } from "./graph.js";
// ---------------------------------------------------------------------------
// conversation-archive-dispatch — Task 426.
//
// Hands a batch of operator-approved IMAP messages to the source-agnostic
// conversation-archive pipeline. One bash invocation per distinct thread,
// one `:ConversationArchive {source:'email'}` per thread.
//
// Thread grouping uses the same In-Reply-To root derivation that the
// retired graph.ts writer used: a message whose parent hasn't arrived yet
// becomes its own thread root. Participant resolution is closed-set:
// every From/To/Cc address must already exist as `:Person.email` or
// `:AdminUser.email`. Any unresolved address skips its thread and returns
// in `unresolvedAddresses` for the caller to surface to the operator;
// :Person nodes are never auto-created here.
// ---------------------------------------------------------------------------
function ingestBashPath() {
    const platformRoot = process.env.PLATFORM_ROOT ?? resolve(import.meta.dirname, "../../../..");
    return resolve(platformRoot, "plugins/memory/bin/conversation-archive-ingest.sh");
}
export async function dispatchEmailMessagesToConversationArchive(messages, attachmentRecords = []) {
    if (messages.length === 0) {
        return {
            threadsDispatched: 0,
            threadsSkipped: 0,
            unresolvedAddresses: [],
            attachmentsAttached: 0,
            attachmentsFailed: 0,
        };
    }
    const accountId = messages[0].accountId;
    const threads = groupByThread(messages);
    const attachmentsByUid = groupAttachmentsByUid(attachmentRecords);
    const distinctAddresses = new Set();
    for (const t of threads)
        for (const a of t.addresses)
            distinctAddresses.add(a);
    const addressToElementId = await resolveAddresses([...distinctAddresses], accountId);
    let threadsDispatched = 0;
    let threadsSkipped = 0;
    let attachmentsAttached = 0;
    let attachmentsFailed = 0;
    const unresolved = new Set();
    for (const thread of threads) {
        const ids = [];
        let threadOk = true;
        for (const addr of thread.addresses) {
            const elementId = addressToElementId.get(addr);
            if (!elementId) {
                unresolved.add(addr);
                threadOk = false;
                continue;
            }
            ids.push(elementId);
        }
        if (!threadOk) {
            threadsSkipped += 1;
            continue;
        }
        const tmpDir = mkdtempSync(join(tmpdir(), "email-thread-"));
        const jsonPath = join(tmpDir, `thread-${thread.rootMessageId.replace(/[^a-z0-9-]+/gi, "_")}.json`);
        try {
            writeFileSync(jsonPath, JSON.stringify(buildThreadJson(thread)));
            const exit = spawnSync("bash", [
                ingestBashPath(),
                jsonPath,
                "--source",
                "email",
                "--participant-person-ids",
                ids.join(","),
                "--scope",
                "admin",
                "--session-id",
                `email-${randomUUID()}`,
            ], { stdio: ["ignore", "pipe", "inherit"] });
            if (exit.status === 0) {
                threadsDispatched += 1;
                const conversationIdentity = parseConversationIdentity(exit.stdout);
                if (!conversationIdentity) {
                    console.error(`[email-attach] WARN: no conversationIdentity in dispatch stdout root=${thread.rootMessageId} — attachments will be archived but not pending-edge tagged`);
                }
                // Collect every attachment for messages in this thread and write
                // a :DigitalDocument per unique attachmentId, tagging with the
                // thread's pending conversation identity for later edge attach.
                const threadAttachments = [];
                for (const m of thread.messages) {
                    const recs = attachmentsByUid.get(m.uid);
                    if (recs)
                        threadAttachments.push(...recs);
                }
                const { attached, failed } = await writeDigitalDocuments(accountId, conversationIdentity, threadAttachments);
                attachmentsAttached += attached;
                attachmentsFailed += failed;
            }
            else {
                threadsSkipped += 1;
                console.error(`[email-ingest] conversation-archive-ingest.sh failed for thread root=${thread.rootMessageId} status=${exit.status}`);
            }
        }
        finally {
            rmSync(tmpDir, { recursive: true, force: true });
        }
    }
    return {
        threadsDispatched,
        threadsSkipped,
        unresolvedAddresses: [...unresolved].sort(),
        attachmentsAttached,
        attachmentsFailed,
    };
}
// ---------------------------------------------------------------------------
// Stdout parsing — Task 402.
//
// conversation-archive-ingest.mjs (Task 435) writes exactly one JSON line to
// stdout on success. That JSON already carries conversationIdentity — we
// just capture and parse it.
// ---------------------------------------------------------------------------
function parseConversationIdentity(stdoutBuf) {
    if (!stdoutBuf)
        return null;
    const text = typeof stdoutBuf === "string" ? stdoutBuf : stdoutBuf.toString("utf-8");
    // Find the last non-empty line; the .mjs may emit progress lines on stderr
    // (we route stderr to inherit) but its stdout is the single JSON line.
    const lines = text.split(/\r?\n/).map((l) => l.trim()).filter((l) => l.length > 0);
    if (lines.length === 0)
        return null;
    for (let i = lines.length - 1; i >= 0; i--) {
        try {
            const parsed = JSON.parse(lines[i]);
            if (typeof parsed.conversationIdentity === "string" && parsed.conversationIdentity.length > 0) {
                return parsed.conversationIdentity;
            }
        }
        catch {
            // try the next line up
        }
    }
    return null;
}
function groupAttachmentsByUid(records) {
    const map = new Map();
    for (const r of records) {
        const list = map.get(r.uid);
        if (list)
            list.push(r);
        else
            map.set(r.uid, [r]);
    }
    return map;
}
// ---------------------------------------------------------------------------
// :DigitalDocument write — Task 402.
//
// Each attachment becomes one content-addressed :DigitalDocument node keyed
// on (accountId, attachmentId). pendingArchiveEdges carries the parent
// thread's conversationIdentity; memory-ingest's later :ConversationArchive
// MERGE wires :HAS_ENCLOSURE and drains the entry. Same bytes from two
// threads → one node, two distinct pending-edge entries, two edges later.
// ---------------------------------------------------------------------------
async function writeDigitalDocuments(accountId, conversationIdentity, records) {
    if (records.length === 0)
        return { attached: 0, failed: 0 };
    let attached = 0;
    let failed = 0;
    const session = getSession();
    try {
        for (const r of records) {
            try {
                await session.run(`MERGE (d:DigitalDocument { accountId: $accountId, attachmentId: $attachmentId })
           ON CREATE SET d.name             = $filename,
                         d.encodingFormat   = $mimeType,
                         d.contentSize      = $sizeBytes,
                         d.sha256           = $sha256,
                         d.archivePath      = $archivePath,
                         d.archived         = $archived,
                         d.dateCreated      = $now,
                         d.source           = 'email',
                         d.pendingArchiveEdges = $pendingForCreate
           SET d.pendingArchiveEdges = CASE
                 WHEN $pendingOnMatch IS NULL THEN d.pendingArchiveEdges
                 WHEN d.pendingArchiveEdges IS NULL THEN [$pendingOnMatch]
                 WHEN $pendingOnMatch IN d.pendingArchiveEdges THEN d.pendingArchiveEdges
                 ELSE d.pendingArchiveEdges + $pendingOnMatch
               END
           RETURN elementId(d) AS nodeId`, {
                    accountId,
                    attachmentId: r.attachmentId,
                    filename: r.filename,
                    mimeType: r.mimeType,
                    sizeBytes: r.sizeBytes,
                    sha256: r.archived ? r.attachmentId : null,
                    archivePath: r.archivePath,
                    archived: r.archived,
                    now: new Date().toISOString(),
                    pendingForCreate: conversationIdentity ? [conversationIdentity] : [],
                    pendingOnMatch: conversationIdentity,
                });
                attached += 1;
                console.error(`[email-attach] attached uid=${r.uid} part=${r.partKey} attachmentId=${r.attachmentId.slice(0, 12)} archived=${r.archived} pendingFor=${conversationIdentity?.slice(0, 12) ?? "none"}`);
            }
            catch (err) {
                failed += 1;
                console.error(`[email-attach] WARN: DigitalDocument MERGE failed uid=${r.uid} part=${r.partKey} reason=${err instanceof Error ? err.message : String(err)}`);
            }
        }
    }
    finally {
        await session.close();
    }
    return { attached, failed };
}
// ---------------------------------------------------------------------------
// Thread grouping
// ---------------------------------------------------------------------------
function groupByThread(messages) {
    const byMessageId = new Map();
    for (const m of messages) {
        const id = stripAngleBrackets(m.messageId);
        if (id)
            byMessageId.set(id, m);
    }
    const rootFor = new Map();
    function resolveRoot(messageId, seen) {
        if (rootFor.has(messageId))
            return rootFor.get(messageId);
        if (seen.has(messageId))
            return messageId; // cycle guard
        seen.add(messageId);
        const m = byMessageId.get(messageId);
        const parent = stripAngleBrackets(m?.inReplyTo ?? null);
        const root = parent && byMessageId.has(parent) ? resolveRoot(parent, seen) : messageId;
        rootFor.set(messageId, root);
        return root;
    }
    const groups = new Map();
    for (const m of messages) {
        const id = stripAngleBrackets(m.messageId);
        const rootMessageId = id
            ? resolveRoot(id, new Set())
            : `synthetic:${m.accountId}:${m.uidValidity}:${m.uid}`;
        let g = groups.get(rootMessageId);
        if (!g) {
            g = { rootMessageId, messages: [], addresses: [] };
            groups.set(rootMessageId, g);
        }
        g.messages.push(m);
    }
    for (const g of groups.values()) {
        g.messages.sort((a, b) => a.receivedAt.localeCompare(b.receivedAt));
        const addrs = new Set();
        for (const m of g.messages) {
            if (m.fromAddress)
                addrs.add(m.fromAddress.trim().toLowerCase());
            for (const a of m.toAddresses)
                addrs.add(a.trim().toLowerCase());
            for (const a of m.ccAddresses)
                addrs.add(a.trim().toLowerCase());
        }
        g.addresses = [...addrs].filter((a) => a.length > 0).sort();
    }
    return [...groups.values()];
}
// ---------------------------------------------------------------------------
// Participant resolution
// ---------------------------------------------------------------------------
async function resolveAddresses(addresses, accountId) {
    const map = new Map();
    if (addresses.length === 0)
        return map;
    const session = getSession();
    try {
        const r = await session.run(`UNWIND $addresses AS addr
       OPTIONAL MATCH (p) WHERE (p:Person OR p:AdminUser)
         AND p.accountId = $accountId
         AND toLower(p.email) = addr
       WITH addr, p
       WHERE p IS NOT NULL
       RETURN addr, elementId(p) AS eid`, { addresses, accountId });
        for (const rec of r.records) {
            map.set(rec.get("addr"), rec.get("eid"));
        }
    }
    finally {
        await session.close();
    }
    return map;
}
// ---------------------------------------------------------------------------
// Thread JSON shape (matches conversation-normalisers/email.ts)
// ---------------------------------------------------------------------------
function buildThreadJson(thread) {
    return thread.messages.map((m) => ({
        from: { name: m.fromName ?? null, email: m.fromAddress },
        date: m.receivedAt,
        bodyText: m.bodyPreview,
    }));
}
//# sourceMappingURL=conversation-archive-dispatch.js.map