import { loadCredentials, updatePollState } from "../lib/credentials.js";
import { stripAngleBrackets } from "../lib/graph.js";
import { dispatchEmailMessagesToConversationArchive, } from "../lib/conversation-archive-dispatch.js";
import { takeBatch, peekBatch } from "../lib/ingest-batch.js";
import { fetchAttachmentBytesForMessages } from "../lib/imap.js";
import { archiveAttachment, sha256OfBytes, syntheticAttachmentId, } from "../lib/attachment-archive.js";
function normaliseId(id) {
    return stripAngleBrackets(id)?.toLowerCase() ?? null;
}
/**
 * Apply operator decisions to the most recent email-fetch batch.
 *
 * - Every pending message must have a decision; missing IDs cause refusal
 *   so messages never silently fall through the high-water mark.
 * - Only "ingest" entries are written to the graph; "discard" entries
 *   advance the high-water mark without persisting anything.
 * - The cached batch is consumed (taken, not peeked) only on success.
 */
export async function emailIngest(params) {
    const batch = peekBatch(params.accountId);
    if (!batch) {
        throw new Error("No pending email batch. Call email-fetch first to list new messages, then email-ingest to apply the operator's decisions.");
    }
    // Build a normalised disposition map. The agent may pass IDs with or
    // without RFC 2822 angle brackets — normalise both sides.
    const decisionMap = new Map();
    for (const d of params.decisions) {
        const key = normaliseId(d.messageId);
        if (!key) {
            throw new Error(`Decision had a blank messageId: ${JSON.stringify(d)}`);
        }
        decisionMap.set(key, d.disposition);
    }
    // Every pending message must have a decision. List missing ones so the
    // operator can re-decide rather than silently dropping them.
    const missing = [];
    for (const m of batch.messages) {
        const key = normaliseId(m.messageId);
        if (!key)
            continue; // messages with no Message-ID can't be addressed individually
        if (!decisionMap.has(key))
            missing.push(m.messageId ?? `UID ${m.uid}`);
    }
    if (missing.length > 0) {
        throw new Error(`Refusing to ingest — missing decisions for ${missing.length} message(s): ${missing.join(", ")}. Pass a disposition ("ingest" or "discard") for every Message-ID returned by email-fetch.`);
    }
    const toIngest = [];
    let discarded = 0;
    for (const m of batch.messages) {
        const key = normaliseId(m.messageId);
        const disposition = key ? decisionMap.get(key) : undefined;
        if (disposition === "ingest") {
            toIngest.push({
                messageId: m.messageId,
                inReplyTo: m.inReplyTo,
                toAddresses: m.toAddresses,
                ccAddresses: m.ccAddresses,
                uid: m.uid,
                uidValidity: batch.uidValidity,
                mailbox: "INBOX",
                recipientAddress: batch.recipientAddress,
                fromAddress: m.fromAddress,
                fromName: m.fromName,
                subject: m.subject,
                bodyPreview: m.bodyPreview,
                receivedAt: m.date,
                accountId: params.accountId,
                screening: "clean",
                screeningReason: "operator-approved",
            });
        }
        else {
            discarded++;
        }
    }
    toIngest.sort((a, b) => a.receivedAt.localeCompare(b.receivedAt));
    // ─── Attachment byte fetch + on-disk archive (Task 402) ───────────────
    //
    // For every approved message, look up its attachment metadata in the
    // cached batch, fetch the bytes from IMAP in one connection, archive
    // them to disk, and build the per-attachment records the dispatcher
    // needs to MERGE :DigitalDocument nodes.
    const ingestedUids = new Set(toIngest.map((m) => m.uid));
    const metaByUid = new Map();
    for (const m of batch.messages) {
        if (ingestedUids.has(m.uid) && m.attachments.length > 0) {
            metaByUid.set(m.uid, m.attachments);
        }
    }
    const attachmentRecords = await buildAttachmentRecords(params.accountId, batch.uidValidity, metaByUid);
    const { threadsDispatched, threadsSkipped, unresolvedAddresses, attachmentsAttached, attachmentsFailed } = await dispatchEmailMessagesToConversationArchive(toIngest, attachmentRecords);
    // Advance the high-water mark so the next email-fetch starts after this batch.
    await updatePollState(params.accountId, batch.maxUid, batch.uidValidity);
    // Consume the cached batch only after a successful write + state advance.
    takeBatch(params.accountId);
    console.error(`[email-ingest] tag=email-ingest ingested=${toIngest.length} discarded=${discarded} threadsDispatched=${threadsDispatched} threadsSkipped=${threadsSkipped} unresolved=${unresolvedAddresses.length} attachmentsAttached=${attachmentsAttached} attachmentsFailed=${attachmentsFailed}`);
    const lines = [
        `Ingested ${toIngest.length} message(s); discarded ${discarded}.`,
        `Graph: ${threadsDispatched} thread(s) dispatched to conversation-archive, ${threadsSkipped} skipped.`,
        `Attachments: ${attachmentsAttached} archived as :DigitalDocument, ${attachmentsFailed} failed.`,
        `Inbox high-water mark advanced to UID ${batch.maxUid}.`,
    ];
    if (unresolvedAddresses.length > 0) {
        lines.push(`Unresolved sender/recipient addresses (no :Person or :AdminUser carries these emails — add them to the graph and re-run email-ingest to land their threads): ${unresolvedAddresses.join(", ")}`);
    }
    return lines.join("\n");
}
// ---------------------------------------------------------------------------
// Attachment byte fetch + on-disk archive (Task 402).
//
// metaByUid maps approved-message UID → the attachment metadata extracted at
// email-fetch time (filename, mimeType, sizeBytes, partKey). For each
// metadata entry we either fetch bytes and archive to disk, or skip bytes
// (oversize / fetch-error) and write a synthetic-id record so the
// :DigitalDocument still lands with metadata-only.
// ---------------------------------------------------------------------------
async function buildAttachmentRecords(accountId, uidValidity, metaByUid) {
    if (metaByUid.size === 0)
        return [];
    const accountDir = process.env.ACCOUNT_DIR;
    if (!accountDir) {
        console.error("[email-attach] WARN: ACCOUNT_DIR env not set; attachment bytes will not be archived (metadata-only :DigitalDocument nodes will still land).");
    }
    const credResult = await loadCredentials(accountId);
    if ("error" in credResult) {
        console.error(`[email-attach] WARN: loadCredentials failed during attachment fetch: ${credResult.error}`);
        return [];
    }
    const { config, password } = credResult.credentials;
    // Flatten the (uid, partKey) requests across all approved messages.
    const requests = [];
    for (const [uid, metas] of metaByUid.entries()) {
        for (const m of metas) {
            requests.push({ uid, partKey: m.partKey, expectedSize: m.sizeBytes, encoding: m.encoding, meta: m });
        }
    }
    const fetched = await fetchAttachmentBytesForMessages(config, password, requests.map(({ uid, partKey, expectedSize, encoding }) => ({ uid, partKey, expectedSize, encoding })));
    // Index by (uid, partKey) for O(1) lookup.
    const fetchedKey = (uid, partKey) => `${uid}:${partKey}`;
    const fetchedByKey = new Map(fetched.map((f) => [fetchedKey(f.uid, f.partKey), f]));
    const records = [];
    for (const req of requests) {
        const f = fetchedByKey.get(fetchedKey(req.uid, req.partKey));
        if (!f)
            continue;
        if (f.bytes === null) {
            // Bytes not available — write a metadata-only record with a
            // synthetic content-free attachmentId so the graph node still lands.
            console.error(`[email-attach] metadata-only uid=${req.uid} part=${req.partKey} filename=${req.meta.filename} reason=${f.failureReason}`);
            records.push({
                uid: req.uid,
                partKey: req.partKey,
                filename: req.meta.filename,
                mimeType: req.meta.mimeType,
                sizeBytes: req.meta.sizeBytes,
                attachmentId: syntheticAttachmentId(uidValidity, req.uid, req.partKey, req.meta.filename, req.meta.sizeBytes),
                archivePath: null,
                archived: false,
            });
            continue;
        }
        // Bytes in hand — archive to disk (best-effort) and use sha256(bytes)
        // as the content-addressed attachmentId regardless of archive outcome.
        const attachmentId = sha256OfBytes(f.bytes);
        let archivePath = null;
        let archived = false;
        if (accountDir) {
            const result = archiveAttachment(accountDir, uidValidity, req.uid, req.meta.filename, f.bytes);
            if (result) {
                archivePath = result.archivePath;
                archived = true;
                console.error(`[email-attach] archived uid=${req.uid} part=${req.partKey} sha256=${attachmentId.slice(0, 12)} path=${archivePath}`);
            }
            else {
                console.error(`[email-attach] archive-skipped uid=${req.uid} part=${req.partKey} (write failed; node will still land with archivePath=null)`);
            }
        }
        records.push({
            uid: req.uid,
            partKey: req.partKey,
            filename: req.meta.filename,
            mimeType: req.meta.mimeType,
            sizeBytes: req.meta.sizeBytes,
            attachmentId,
            archivePath,
            archived,
        });
    }
    return records;
}
//# sourceMappingURL=email-ingest.js.map