/**
 * Persist LLM extraction results as Person nodes.
 *
 * Takes the structured JSON output from an LLM extraction step
 * and creates/updates Person nodes idempotently via MERGE-on-email.
 *
 *   LLM OUTPUT (JSON array)
 *     │
 *     ├─ Validate: must be an array of extraction objects
 *     ├─ For each extraction:
 *     │    ├─ Validate email present and well-formed
 *     │    ├─ Hallucination guard: email must appear in source messages
 *     │    ├─ MERGE Person node (idempotent on email + accountId)
 *     │    ├─ ON CREATE: set all fields; createdOn = conversationDate
 *     │    │   (fallback: extraction time); extractedOn = extraction time
 *     │    ├─ ON MATCH: backfill name if existing is "Unknown";
 *     │    │   preserve createdOn (earliest conversation date)
 *     │    ├─ Merge discoveryNotes (new enriches existing)
 *     │    └─ Recompute embedding with discovery context
 *     │
 *     v
 *   SUMMARY STATS
 */
import { getSession } from "../lib/neo4j.js";
import { computeEmbedding, buildPersonEmbeddingText } from "../lib/embedding.js";
// ---------------------------------------------------------------------------
// Discovery notes helpers
// ---------------------------------------------------------------------------
function buildDiscoveryNotes(entry) {
    const str = (v) => typeof v === "string" && v.trim() ? v.trim() : null;
    const notes = {
        businessType: str(entry.businessType),
        role: str(entry.role),
        needs: str(entry.needs),
        challenges: str(entry.challenges),
        interests: str(entry.interests),
        engagementQuality: str(entry.engagementQuality),
        otherSignals: str(entry.otherSignals),
    };
    const hasAny = Object.values(notes).some((v) => v !== null);
    return hasAny ? notes : null;
}
/**
 * Merge two DiscoveryNotes objects: new non-null values overwrite,
 * null values in the new extraction preserve existing values.
 */
function mergeDiscoveryNotes(existing, incoming) {
    return {
        businessType: incoming.businessType ?? existing.businessType,
        role: incoming.role ?? existing.role,
        needs: incoming.needs ?? existing.needs,
        challenges: incoming.challenges ?? existing.challenges,
        interests: incoming.interests ?? existing.interests,
        engagementQuality: incoming.engagementQuality ?? existing.engagementQuality,
        otherSignals: incoming.otherSignals ?? existing.otherSignals,
    };
}
// ---------------------------------------------------------------------------
// Input validation
// ---------------------------------------------------------------------------
function parseExtractions(raw) {
    // The workflow engine passes step outputs through parseStepOutput(),
    // which wraps non-JSON text as { _raw: text }. If the LLM returned
    // malformed JSON, we get that wrapper instead of an array.
    if (raw === null || raw === undefined) {
        throw new Error("No extraction data received — LLM step may have failed.");
    }
    // If raw is already an array (template resolved the full object)
    if (Array.isArray(raw)) {
        return raw;
    }
    // If raw is an object, check for _raw (malformed LLM output) or
    // try to find an array property
    if (typeof raw === "object" && !Array.isArray(raw)) {
        const obj = raw;
        // If it has a _raw field, the LLM output wasn't valid JSON
        if ("_raw" in obj && typeof obj._raw === "string") {
            // Try to parse the raw string as JSON array
            try {
                const parsed = JSON.parse(obj._raw);
                if (Array.isArray(parsed))
                    return parsed;
            }
            catch {
                // Fall through to error
            }
            throw new Error("LLM returned malformed extraction data — expected a JSON array of contacts. " +
                "Check the extraction prompt or run workflow-get to inspect it.");
        }
        // Find the first top-level property that is an array — the LLM may
        // wrap results under any key (extractions, results, leads, contacts, etc.)
        const arrayProp = Object.values(obj).find(Array.isArray);
        if (arrayProp)
            return arrayProp;
    }
    throw new Error("Extraction data is not a JSON array. " +
        "The LLM extraction step must return a JSON array of contact objects.");
}
/**
 * Build a lookup of conversation messages for email hallucination validation.
 * Maps conversationId → joined lowercase messages.
 */
function buildConversationIndex(conversations) {
    const index = new Map();
    if (!conversations || typeof conversations !== "object")
        return index;
    // Handle the structured output from waitlist-scan
    let convArray;
    if (Array.isArray(conversations)) {
        convArray = conversations;
    }
    else {
        const obj = conversations;
        if (Array.isArray(obj.conversations)) {
            convArray = obj.conversations;
        }
        else if ("_raw" in obj && typeof obj._raw === "string") {
            // Fallback: try parsing the _raw text
            try {
                const parsed = JSON.parse(obj._raw);
                if (parsed && Array.isArray(parsed.conversations)) {
                    convArray = parsed.conversations;
                }
                else {
                    return index;
                }
            }
            catch {
                return index;
            }
        }
        else {
            return index;
        }
    }
    // Build a combined text per conversation and a global combined text
    let allMessages = "";
    for (const conv of convArray) {
        const joined = (conv.messages ?? []).join(" ").toLowerCase();
        index.set(conv.conversationId, joined);
        allMessages += " " + joined;
    }
    // Store global text under a special key for entries without conversationId
    index.set("__all__", allMessages);
    return index;
}
/**
 * Check if an email appears in the source conversation messages.
 * Guards against LLM hallucination — if the model invents an email
 * that wasn't in the conversation, we skip rather than create a
 * spurious Person node.
 */
function emailInSource(email, conversationId, index) {
    if (index.size === 0)
        return true; // No source data — skip validation
    const emailLower = email.toLowerCase();
    // Check specific conversation first
    if (conversationId && index.has(conversationId)) {
        return index.get(conversationId).includes(emailLower);
    }
    // Fall back to global search
    const allMessages = index.get("__all__");
    return allMessages ? allMessages.includes(emailLower) : true;
}
// ---------------------------------------------------------------------------
// Person node creation/update
// ---------------------------------------------------------------------------
async function createOrUpdatePerson(entry, accountId, discoveryNotes) {
    const email = entry.email.trim().toLowerCase();
    const rawGiven = typeof entry.givenName === "string" ? entry.givenName.trim() || null : null;
    const familyName = typeof entry.familyName === "string" ? entry.familyName.trim() || null : null;
    const hasName = !!rawGiven;
    const status = hasName ? "waitlist" : "review";
    const givenName = rawGiven ?? "Unknown";
    const session = getSession();
    try {
        const extractedOn = new Date().toISOString();
        const rawConvDate = typeof entry.conversationDate === "string" && entry.conversationDate.trim()
            ? entry.conversationDate.trim()
            : null;
        if (!rawConvDate) {
            console.error(`[waitlist-persist] conversationDate missing for ${email}, using extraction time`);
        }
        const createdOn = rawConvDate ?? extractedOn;
        const notesJson = discoveryNotes ? JSON.stringify(discoveryNotes) : null;
        const conversationId = entry.conversationId ?? null;
        const setProps = [
            "p.givenName = $givenName",
            ...(familyName ? ["p.familyName = $familyName"] : []),
            "p.source = 'waitlist'",
            "p.status = $status",
            "p.createdOn = $createdOn",
            "p.extractedOn = $extractedOn",
            ...(conversationId ? ["p.sessionId = $conversationId"] : []),
            ...(notesJson ? ["p.discoveryNotes = $notesJson"] : []),
        ].join(",\n         ");
        // ON MATCH SET: update name and status only when:
        // - the existing node has no real name (givenName is "Unknown" or missing)
        // - the new extraction provides a real name
        // This preserves admin-reviewed names while backfilling from extraction.
        const matchProps = hasName
            ? [
                "p.givenName = CASE WHEN p.givenName = 'Unknown' OR p.givenName IS NULL THEN $givenName ELSE p.givenName END",
                ...(familyName
                    ? [
                        "p.familyName = CASE WHEN p.givenName = 'Unknown' OR p.givenName IS NULL THEN $familyName ELSE COALESCE(p.familyName, $familyName) END",
                    ]
                    : []),
                "p.status = CASE WHEN p.givenName = 'Unknown' OR p.givenName IS NULL THEN $status ELSE p.status END",
            ].join(",\n         ")
            : "";
        const onMatchClause = matchProps
            ? `ON MATCH SET ${matchProps}`
            : "";
        const result = await session.run(`MERGE (p:Person {email: $email, accountId: $accountId})
       ON CREATE SET ${setProps}
       ${onMatchClause}
       RETURN p.createdOn = $createdOn AS created,
              p.givenName AS currentName,
              p.discoveryNotes AS existingNotes`, {
            email,
            accountId,
            givenName,
            ...(familyName ? { familyName } : {}),
            status,
            createdOn,
            extractedOn,
            ...(conversationId ? { conversationId } : {}),
            ...(notesJson ? { notesJson } : {}),
        });
        const record = result.records[0];
        if (!record)
            throw new Error(`MERGE returned no record for ${email}`);
        const created = record.get("created");
        const currentName = record.get("currentName");
        const existingNotesRaw = record.get("existingNotes");
        const updated = !created && currentName === givenName && givenName !== "Unknown";
        // On MATCH: merge discoveryNotes (new non-null fields enrich existing)
        let finalNotes = discoveryNotes;
        if (!created && discoveryNotes) {
            let existingNotes = null;
            if (existingNotesRaw) {
                try {
                    existingNotes = JSON.parse(existingNotesRaw);
                }
                catch {
                    // Malformed existing notes — overwrite with fresh extraction
                }
            }
            finalNotes = existingNotes
                ? mergeDiscoveryNotes(existingNotes, discoveryNotes)
                : discoveryNotes;
            const mergedJson = JSON.stringify(finalNotes);
            await session.run(`MATCH (p:Person {email: $email, accountId: $accountId})
         SET p.discoveryNotes = $mergedJson`, { email, accountId, mergedJson });
        }
        // Recompute embedding with discovery context
        if (created || updated || discoveryNotes) {
            try {
                const finalNotesJson = finalNotes ? JSON.stringify(finalNotes) : null;
                const textForEmbedding = buildPersonEmbeddingText(givenName, familyName, email, status, finalNotesJson);
                const embedding = await computeEmbedding(textForEmbedding);
                if (embedding.length > 0) {
                    await session.run(`MATCH (p:Person {email: $email, accountId: $accountId})
             SET p.embedding = $embedding`, { email, accountId, embedding });
                }
            }
            catch {
                // Non-fatal — Person stored without embedding
            }
        }
        return { created, updated, status };
    }
    finally {
        await session.close();
    }
}
// ---------------------------------------------------------------------------
// Main persist function
// ---------------------------------------------------------------------------
export async function waitlistPersist(params) {
    const { accountId, extractions: rawExtractions, conversations: rawConversations } = params;
    // Validate and parse the LLM extraction output
    const entries = parseExtractions(rawExtractions);
    if (entries.length === 0) {
        return {
            processed: 0, created: 0, updated: 0, existing: 0,
            flaggedForReview: 0, withDiscoveryNotes: 0, filtered: 0, errors: 0,
        };
    }
    // Build conversation index for email hallucination validation
    const convIndex = buildConversationIndex(rawConversations);
    let created = 0;
    let updated = 0;
    let existing = 0;
    let flaggedForReview = 0;
    let withDiscoveryNotes = 0;
    let filtered = 0;
    let errors = 0;
    for (const entry of entries) {
        try {
            // Validate email
            if (!entry.email || typeof entry.email !== "string") {
                filtered++;
                continue;
            }
            const email = entry.email.trim().toLowerCase();
            if (!email.includes("@")) {
                filtered++;
                continue;
            }
            // Hallucination guard: email must appear in source messages
            if (!emailInSource(email, entry.conversationId, convIndex)) {
                console.error(`[waitlist-persist] filtered hallucinated email: ${email}`);
                filtered++;
                continue;
            }
            const discoveryNotes = buildDiscoveryNotes(entry);
            if (discoveryNotes)
                withDiscoveryNotes++;
            const result = await createOrUpdatePerson({ ...entry, email }, accountId, discoveryNotes);
            if (result.created) {
                if (result.status === "review") {
                    flaggedForReview++;
                }
                else {
                    created++;
                }
            }
            else if (result.updated) {
                updated++;
            }
            else {
                existing++;
            }
        }
        catch (err) {
            console.error(`[waitlist-persist] error processing ${entry.email ?? "unknown"}: ${err instanceof Error ? err.message : String(err)}`);
            errors++;
        }
    }
    return {
        processed: entries.length,
        created,
        updated,
        existing,
        flaggedForReview,
        withDiscoveryNotes,
        filtered,
        errors,
    };
}
//# sourceMappingURL=waitlist-persist.js.map