import { createHash } from "node:crypto";
import { getSession } from "./neo4j.js";
import { computeEmbedding } from "./embedding.js";
import neo4j from "neo4j-driver";
/**
 * Strip RFC 2822 angle brackets from a Message-ID or In-Reply-To header.
 * "<abc@host>" → "abc@host". Already-clean values pass through unchanged.
 */
export function stripAngleBrackets(value) {
    if (!value)
        return null;
    const trimmed = value.trim();
    if (trimmed.startsWith("<") && trimmed.endsWith(">")) {
        return trimmed.slice(1, -1);
    }
    return trimmed;
}
/**
 * Derive a deterministic emailId for deduplication.
 * Uses RFC 2822 Message-ID when available (stable across UIDVALIDITY resets).
 * Falls back to SHA-256 of UID + UIDVALIDITY + mailbox + recipientAddress.
 */
export function deriveEmailId(data) {
    if (data.messageId) {
        return createHash("sha256")
            .update(`msgid:${data.messageId}:${data.recipientAddress}`)
            .digest("hex");
    }
    return createHash("sha256")
        .update(`uid:${data.uidValidity}:${data.uid}:${data.mailbox}:${data.recipientAddress}`)
        .digest("hex");
}
/**
 * Store an email in the graph. Uses MERGE on emailId for idempotent writes.
 * Creates the RECEIVED_BY relationship to the EmailAccount in the same transaction.
 * Embedding is computed inline — if Ollama is down, the email is stored without one.
 *
 * After storing, creates REPLY_TO edges:
 *   - If this email has inReplyTo, links to the parent (if it exists in the graph)
 *   - Back-fills any orphaned emails whose inReplyTo references this email's messageId
 */
export async function storeEmail(data) {
    const normalisedMessageId = stripAngleBrackets(data.messageId);
    const normalisedInReplyTo = stripAngleBrackets(data.inReplyTo);
    const emailId = deriveEmailId({ ...data, messageId: normalisedMessageId });
    // Dedup pre-check: when messageId is non-null, check for an existing Email
    // with the same messageId + accountId. This catches duplicates caused by
    // recipientAddress changes (different emailId hash, same underlying email).
    // Null-messageId emails (UID-based fallback) skip this check.
    if (normalisedMessageId) {
        const checkSession = getSession();
        try {
            const existing = await checkSession.run(`MATCH (e:Email {messageId: $messageId, accountId: $accountId})
         RETURN e.emailId AS emailId LIMIT 1`, { messageId: normalisedMessageId, accountId: data.accountId });
            if (existing.records.length > 0) {
                const existingEmailId = existing.records[0].get("emailId");
                console.error(`[email-fetch] INFO: messageId ${normalisedMessageId} already exists for account ${data.accountId} — skipping (emailId=${existingEmailId})`);
                return { emailId: existingEmailId, created: false };
            }
        }
        finally {
            await checkSession.close();
        }
    }
    // Embedding computed after pre-check to avoid wasted Ollama calls on duplicates.
    // Skip embedding for discarded emails — prevents vector search pollution.
    let embedding = [];
    if (data.screening !== "discarded") {
        const embeddingText = [data.subject, data.bodyPreview].filter(Boolean).join(" — ");
        embedding = await computeEmbedding(embeddingText);
    }
    const session = getSession();
    try {
        const result = await session.run(`MERGE (e:Email {emailId: $emailId})
       ON CREATE SET
         e.messageId = $messageId,
         e.inReplyTo = $inReplyTo,
         e.toAddresses = $toAddresses,
         e.ccAddresses = $ccAddresses,
         e.recipientAddress = $recipientAddress,
         e.fromAddress = $fromAddress,
         e.fromName = $fromName,
         e.subject = $subject,
         e.bodyPreview = $bodyPreview,
         e.receivedAt = $receivedAt,
         e.fetchedAt = $fetchedAt,
         e.accountId = $accountId,
         e.scope = 'admin',
         e.screening = $screening,
         e.screeningReason = $screeningReason,
         e.promptInjectionFlag = $promptInjectionFlag
       WITH e
       MATCH (ea:EmailAccount {accountId: $accountId})
       MERGE (e)-[:RECEIVED_BY]->(ea)
       RETURN e.fetchedAt = $fetchedAt AS created`, {
            emailId,
            messageId: normalisedMessageId,
            inReplyTo: normalisedInReplyTo,
            toAddresses: data.toAddresses,
            ccAddresses: data.ccAddresses,
            recipientAddress: data.recipientAddress,
            fromAddress: data.fromAddress,
            fromName: data.fromName,
            subject: data.subject,
            bodyPreview: data.bodyPreview.slice(0, 4096),
            receivedAt: data.receivedAt,
            fetchedAt: new Date().toISOString(),
            accountId: data.accountId,
            screening: data.screening ?? null,
            screeningReason: data.screeningReason ?? null,
            promptInjectionFlag: data.promptInjectionFlag ?? null,
        });
        // Set embedding separately — avoids passing a large array through Cypher params
        // that could fail on very old Neo4j versions
        if (embedding.length > 0) {
            await session.run(`MATCH (e:Email {emailId: $emailId})
         WHERE e.embedding IS NULL
         SET e.embedding = $embedding`, { emailId, embedding });
        }
        const created = result.records[0]?.get("created") === true;
        // Thread linking — best-effort, failures logged but never block email storage
        try {
            if (normalisedInReplyTo) {
                await linkReplyTo(emailId, normalisedInReplyTo, data.accountId);
            }
            if (normalisedMessageId) {
                await backfillOrphans(normalisedMessageId, data.accountId);
            }
        }
        catch (err) {
            console.error(`[email-fetch] WARN: thread linking failed for ${emailId}: ${err instanceof Error ? err.message : String(err)}`);
        }
        return { emailId, created };
    }
    catch (err) {
        // Constraint violation from email_message_id_account_unique — race condition
        // where two concurrent storeEmail calls passed the pre-check before either
        // MERGE completed. Treat as a graceful duplicate skip.
        if (err instanceof neo4j.Neo4jError &&
            err.code === "Neo.ClientError.Schema.ConstraintValidationFailed") {
            console.error(`[email-fetch] INFO: constraint caught duplicate for messageId ${normalisedMessageId} account ${data.accountId} — skipping (emailId=${emailId})`);
            return { emailId, created: false };
        }
        throw err;
    }
    finally {
        await session.close();
    }
}
/**
 * Store multiple emails. Returns counts of created and skipped (duplicate) emails.
 */
export async function storeEmails(emails) {
    let created = 0;
    let skipped = 0;
    for (const email of emails) {
        const result = await storeEmail(email);
        if (result.created) {
            created++;
        }
        else {
            skipped++;
        }
    }
    return { created, skipped };
}
// ---------------------------------------------------------------------------
// Thread linking
// ---------------------------------------------------------------------------
/**
 * Create a REPLY_TO edge from child to parent.
 * Looks up the parent Email by messageId within the same account.
 * Uses MERGE for idempotency — safe to call multiple times.
 */
async function linkReplyTo(childEmailId, inReplyTo, accountId) {
    const session = getSession();
    try {
        const result = await session.run(`MATCH (child:Email {emailId: $childEmailId})
       MATCH (parent:Email {messageId: $inReplyTo, accountId: $accountId})
       WHERE child <> parent
       MERGE (child)-[:REPLY_TO]->(parent)
       RETURN parent.emailId AS parentEmailId`, { childEmailId, inReplyTo, accountId });
        if (result.records.length > 0) {
            const parentId = result.records[0].get("parentEmailId");
            console.error(`[email-fetch] INFO: linked REPLY_TO edge: ${childEmailId} → ${parentId}`);
        }
    }
    finally {
        await session.close();
    }
}
/**
 * Back-fill REPLY_TO edges for orphaned emails.
 * When a new email arrives, check if any existing emails have an inReplyTo
 * matching this email's messageId (out-of-order delivery). Create edges
 * for any matches within the same account.
 */
async function backfillOrphans(messageId, accountId) {
    const session = getSession();
    try {
        const result = await session.run(`MATCH (parent:Email {messageId: $messageId, accountId: $accountId})
       MATCH (orphan:Email {inReplyTo: $messageId, accountId: $accountId})
       WHERE NOT (orphan)-[:REPLY_TO]->(parent)
         AND orphan <> parent
       MERGE (orphan)-[:REPLY_TO]->(parent)
       RETURN count(orphan) AS linked`, { messageId, accountId });
        const linked = neo4j.integer.toNumber(result.records[0]?.get("linked") ?? 0);
        if (linked > 0) {
            console.error(`[email-fetch] INFO: back-filled ${linked} orphaned REPLY_TO edge(s) for messageId=${messageId}`);
        }
    }
    finally {
        await session.close();
    }
}
// ---------------------------------------------------------------------------
// Outbound email storage (auto-respond audit trail)
// ---------------------------------------------------------------------------
/**
 * Store an outbound auto-respond reply as an Email node.
 * Creates the Email node with direction:"outbound" and a REPLY_TO edge
 * to the original inbound email. Accepts a Driver parameter because
 * email-auto-respond.ts manages its own standalone Neo4j connection.
 *
 * Embedding is computed inline — if Ollama is down, stored without one.
 */
export async function storeOutboundEmail(data, driver) {
    const normalisedMessageId = stripAngleBrackets(data.messageId);
    const normalisedInReplyTo = stripAngleBrackets(data.inReplyTo);
    // Derive emailId using the outbound messageId
    const emailId = createHash("sha256")
        .update(`msgid:${normalisedMessageId}:${data.fromAddress}`)
        .digest("hex");
    const embeddingText = [data.subject, data.bodyPreview].filter(Boolean).join(" — ");
    const embedding = await computeEmbedding(embeddingText);
    const session = driver.session();
    try {
        // Create the outbound Email node + REPLY_TO edge to original in one query
        await session.run(`MERGE (e:Email {emailId: $emailId})
       ON CREATE SET
         e.messageId = $messageId,
         e.inReplyTo = $inReplyTo,
         e.direction = 'outbound',
         e.toAddresses = $toAddresses,
         e.ccAddresses = [],
         e.fromAddress = $fromAddress,
         e.subject = $subject,
         e.bodyPreview = $bodyPreview,
         e.receivedAt = $sentAt,
         e.fetchedAt = $sentAt,
         e.accountId = $accountId,
         e.scope = 'admin'
       WITH e
       MATCH (original:Email {emailId: $originalEmailId})
       MERGE (e)-[:REPLY_TO]->(original)`, {
            emailId,
            messageId: normalisedMessageId,
            inReplyTo: normalisedInReplyTo,
            toAddresses: data.toAddresses,
            fromAddress: data.fromAddress,
            subject: data.subject,
            bodyPreview: data.bodyPreview.slice(0, 4096),
            sentAt: new Date().toISOString(),
            accountId: data.accountId,
            originalEmailId: data.originalEmailId,
        });
        if (embedding.length > 0) {
            await session.run(`MATCH (e:Email {emailId: $emailId})
         WHERE e.embedding IS NULL
         SET e.embedding = $embedding`, { emailId, embedding });
        }
        return { emailId };
    }
    finally {
        await session.close();
    }
}
/**
 * Batch-query thread info for a set of messageIds.
 * For each email, traverses the REPLY_TO chain (capped at 50 hops) to find
 * the thread root. Returns threadDepth (number of hops to root) and
 * threadId (emailId of the root message).
 *
 * Emails not found in the graph or without REPLY_TO edges return null.
 */
export async function getThreadInfo(messageIds, accountId) {
    if (messageIds.length === 0)
        return new Map();
    const normalised = messageIds
        .map((mid) => stripAngleBrackets(mid))
        .filter((mid) => mid !== null);
    if (normalised.length === 0)
        return new Map();
    const session = getSession();
    try {
        // For each messageId, find the longest REPLY_TO path to a root node
        // (a node with no outgoing REPLY_TO edge). Cap at 50 hops.
        const result = await session.run(`UNWIND $messageIds AS mid
       MATCH (e:Email {messageId: mid, accountId: $accountId})
       OPTIONAL MATCH path = (e)-[:REPLY_TO*1..50]->(ancestor:Email)
       WHERE NOT (ancestor)-[:REPLY_TO]->(:Email)
       WITH mid, e,
         CASE WHEN path IS NULL THEN 0 ELSE length(path) END AS depth,
         CASE WHEN path IS NULL THEN e ELSE ancestor END AS root
       ORDER BY mid, depth DESC
       WITH mid, head(collect(depth)) AS threadDepth, head(collect(root)).emailId AS threadId
       WHERE threadDepth > 0
       RETURN mid AS messageId, threadDepth, threadId`, { messageIds: normalised, accountId });
        const map = new Map();
        for (const record of result.records) {
            const mid = record.get("messageId");
            map.set(mid, {
                messageId: mid,
                threadDepth: neo4j.integer.toNumber(record.get("threadDepth")),
                threadId: record.get("threadId"),
            });
        }
        return map;
    }
    finally {
        await session.close();
    }
}
//# sourceMappingURL=graph.js.map