import neo4j from "neo4j-driver";
import { getSession } from "../lib/neo4j.js";
import { computeEmbedding } from "../lib/embedding.js";
/**
 * Query stored email-thread `:ConversationArchive` rows in the graph.
 *
 * Two modes:
 *   - List (no query): MATCH archives with optional sender + date filters,
 *     sorted by lastMessageAt DESC.
 *   - Search (query provided): embed via Ollama, run vector similarity over
 *     `:Section` chunks, dedup to parent archives, rank by best section.
 *
 * Always scoped to (accountId, source='email'). The display surface is
 * per-archive; per-message envelopes are not stored — Task 445.
 */
export async function emailGraphQuery(params) {
    const limit = Math.min(Math.max(1, params.limit ?? 20), 50);
    const offset = Math.max(0, params.offset ?? 0);
    const query = params.query?.trim();
    const archives = query && query.length > 0
        ? await searchMode(query, params, limit)
        : await listMode(params, limit, offset);
    if (archives.length === 0) {
        return formatEmptyResult(params, query);
    }
    const lines = archives
        .map((a) => {
        const participants = a.participantNames.length > 0
            ? a.participantNames.join(", ")
            : "(unknown)";
        let entry = `Archive: ${a.elementId.slice(0, 16)}…`;
        entry += `\nTitle: ${a.title || "(untitled)"}`;
        entry += `\nParticipants: ${participants}`;
        entry += `\nSections: ${a.sectionCount}`;
        entry += `\nFirst: ${a.firstMessageAt}`;
        entry += `\nLast: ${a.lastMessageAt}`;
        if (a.score !== undefined)
            entry += `\nScore: ${a.score.toFixed(3)}`;
        const preview = a.summary.length > 800 ? `${a.summary.slice(0, 800)}\n…(truncated)` : a.summary;
        if (preview)
            entry += `\n\n${preview}`;
        return entry + "\n---";
    })
        .join("\n\n");
    if (!query && archives.length === limit) {
        return `${lines}\n\nPagination: next offset=${offset + limit} (pass as offset to get more results)`;
    }
    return lines;
}
// ---------------------------------------------------------------------------
// List mode — Cypher MATCH on :ConversationArchive with optional filters
// ---------------------------------------------------------------------------
async function listMode(params, limit, offset) {
    const archiveWhere = [
        "a.source = 'email'",
        "a.accountId = $accountId",
    ];
    const cyParams = {
        accountId: params.accountId,
        limit: neo4j.int(limit),
        offset: neo4j.int(offset),
    };
    // Date filters apply at the section level (firstMessageAt/lastMessageAt
    // span the archive). Carry the archive only when at least one section
    // overlaps the requested window.
    let sectionDateFilter = "";
    const sectionDateConds = [];
    if (params.dateFrom) {
        sectionDateConds.push("s.lastMessageAt >= $dateFrom");
        cyParams.dateFrom = params.dateFrom;
    }
    if (params.dateTo) {
        sectionDateConds.push("s.firstMessageAt <= $dateTo");
        cyParams.dateTo = params.dateTo;
    }
    if (sectionDateConds.length > 0) {
        sectionDateFilter = `WHERE ${sectionDateConds.join(" AND ")}`;
    }
    // Sender filter joins through :PARTICIPANT_IN; matches Person OR AdminUser
    // on case-insensitive email substring.
    let senderFilter = "";
    if (params.sender) {
        senderFilter = `
      WITH a
      MATCH (p)-[:PARTICIPANT_IN]->(a)
      WHERE (p:Person OR p:AdminUser)
        AND p.email IS NOT NULL
        AND toLower(p.email) CONTAINS toLower($sender)
    `;
        cyParams.sender = params.sender;
    }
    const cypher = `
    MATCH (a:ConversationArchive)
    WHERE ${archiveWhere.join(" AND ")}
    ${senderFilter}
    WITH DISTINCT a
    OPTIONAL MATCH (a)-[:HAS_SECTION]->(s:Section)
    ${sectionDateFilter}
    WITH a, collect(s) AS matchedSections
    WHERE size(matchedSections) > 0 OR ($dateFrom IS NULL AND $dateTo IS NULL)
    OPTIONAL MATCH (a)-[:HAS_SECTION]->(allS:Section)
    WITH a, matchedSections, allS
    ORDER BY coalesce(allS.position, 0) ASC
    WITH a,
         matchedSections,
         collect(allS) AS allSections
    WITH a,
         allSections,
         [s IN allSections | coalesce(s.summary, '')] AS summaries,
         [s IN allSections WHERE s.firstMessageAt IS NOT NULL | s.firstMessageAt] AS firsts,
         [s IN allSections WHERE s.lastMessageAt  IS NOT NULL | s.lastMessageAt]  AS lasts
    OPTIONAL MATCH (p)-[:PARTICIPANT_IN]->(a)
    WHERE p:Person OR p:AdminUser
    WITH a,
         allSections,
         summaries,
         firsts,
         lasts,
         collect(DISTINCT coalesce(p.displayName, p.name, p.email, '')) AS participantNames
    RETURN elementId(a) AS elementId,
           coalesce(a.title, '') AS title,
           [x IN summaries WHERE x <> ''] AS summaryParts,
           CASE WHEN size(firsts) > 0 THEN apoc.coll.min(firsts) ELSE coalesce(a.createdAt, '') END AS firstMessageAt,
           CASE WHEN size(lasts)  > 0 THEN apoc.coll.max(lasts)  ELSE coalesce(a.lastIngestedMessageAt, a.updatedAt, '') END AS lastMessageAt,
           size(allSections) AS sectionCount,
           [n IN participantNames WHERE n <> ''] AS participantNames
    ORDER BY lastMessageAt DESC
    SKIP $offset
    LIMIT $limit
  `;
    // Fallback when APOC isn't available: compute min/max in JS. Strip the
    // apoc.* calls and pull raw arrays instead.
    const cypherNoApoc = `
    MATCH (a:ConversationArchive)
    WHERE ${archiveWhere.join(" AND ")}
    ${senderFilter}
    WITH DISTINCT a
    OPTIONAL MATCH (a)-[:HAS_SECTION]->(s:Section)
    ${sectionDateFilter}
    WITH a, collect(s) AS matchedSections
    WHERE size(matchedSections) > 0 OR ($dateFrom IS NULL AND $dateTo IS NULL)
    OPTIONAL MATCH (a)-[:HAS_SECTION]->(allS:Section)
    WITH a, allS
    ORDER BY coalesce(allS.position, 0) ASC
    WITH a, collect(allS) AS allSections
    OPTIONAL MATCH (p)-[:PARTICIPANT_IN]->(a)
    WHERE p:Person OR p:AdminUser
    WITH a,
         allSections,
         collect(DISTINCT coalesce(p.displayName, p.name, p.email, '')) AS participantNames
    RETURN elementId(a) AS elementId,
           coalesce(a.title, '') AS title,
           [x IN allSections | coalesce(x.summary, '')] AS summaryParts,
           [x IN allSections WHERE x.firstMessageAt IS NOT NULL | x.firstMessageAt] AS firsts,
           [x IN allSections WHERE x.lastMessageAt  IS NOT NULL | x.lastMessageAt]  AS lasts,
           coalesce(a.createdAt, '') AS archiveCreatedAt,
           coalesce(a.lastIngestedMessageAt, a.updatedAt, '') AS archiveLastAt,
           size(allSections) AS sectionCount,
           [n IN participantNames WHERE n <> ''] AS participantNames
    ORDER BY archiveLastAt DESC
    SKIP $offset
    LIMIT $limit
  `;
    // dateFrom/dateTo must always be bound (Cypher requires named params even
    // for the no-filter branch above where the WHERE references them).
    if (cyParams.dateFrom === undefined)
        cyParams.dateFrom = null;
    if (cyParams.dateTo === undefined)
        cyParams.dateTo = null;
    const session = getSession();
    try {
        const result = await session.run(cypherNoApoc, cyParams);
        return result.records.map(recordToArchive);
    }
    finally {
        await session.close();
    }
}
// ---------------------------------------------------------------------------
// Search mode — vector similarity over :Section, dedup to parent archive
// ---------------------------------------------------------------------------
async function searchMode(query, params, limit) {
    const embedding = await computeEmbedding(query);
    if (embedding.length === 0) {
        throw new Error("Semantic search unavailable — the embedding service is not responding. " +
            "Try again without a query to list email threads instead.");
    }
    const topK = Math.min(limit * 6, 300);
    const cyParams = {
        topK: neo4j.int(topK),
        embedding,
        accountId: params.accountId,
        limit: neo4j.int(limit),
        sender: params.sender ?? null,
        dateFrom: params.dateFrom ?? null,
        dateTo: params.dateTo ?? null,
    };
    // Section-level vector search → restrict to email archives → keep best
    // section per archive → carry that score as the archive's rank. Sender
    // and date filters are applied post-vector (cheaper than pre-filtering
    // a billion-row index, and the topK over-fetch absorbs the loss).
    const cypher = `
    CALL db.index.vector.queryNodes('section_embedding', $topK, $embedding)
    YIELD node, score
    WITH node, score
    MATCH (a:ConversationArchive { accountId: $accountId, source: 'email' })
          -[:HAS_SECTION]->(node)
    WHERE ($dateFrom IS NULL OR node.lastMessageAt  >= $dateFrom)
      AND ($dateTo   IS NULL OR node.firstMessageAt <= $dateTo)
    WITH a, score
    ORDER BY score DESC
    WITH a, head(collect(score)) AS bestScore
    OPTIONAL MATCH (p)-[:PARTICIPANT_IN]->(a)
    WHERE p:Person OR p:AdminUser
    WITH a, bestScore,
         collect(DISTINCT coalesce(p.displayName, p.name, p.email, '')) AS participantNames,
         collect(DISTINCT toLower(coalesce(p.email, ''))) AS participantEmails
    WHERE $sender IS NULL
       OR any(e IN participantEmails WHERE e CONTAINS toLower($sender))
    OPTIONAL MATCH (a)-[:HAS_SECTION]->(allS:Section)
    WITH a, bestScore, participantNames, allS
    ORDER BY coalesce(allS.position, 0) ASC
    WITH a, bestScore, participantNames, collect(allS) AS allSections
    RETURN elementId(a) AS elementId,
           coalesce(a.title, '') AS title,
           [x IN allSections | coalesce(x.summary, '')] AS summaryParts,
           [x IN allSections WHERE x.firstMessageAt IS NOT NULL | x.firstMessageAt] AS firsts,
           [x IN allSections WHERE x.lastMessageAt  IS NOT NULL | x.lastMessageAt]  AS lasts,
           coalesce(a.createdAt, '') AS archiveCreatedAt,
           coalesce(a.lastIngestedMessageAt, a.updatedAt, '') AS archiveLastAt,
           size(allSections) AS sectionCount,
           [n IN participantNames WHERE n <> ''] AS participantNames,
           bestScore AS score
    ORDER BY score DESC
    LIMIT $limit
  `;
    const session = getSession();
    try {
        const result = await session.run(cypher, cyParams);
        return result.records.map((rec) => {
            const archive = recordToArchive(rec);
            archive.score = rec.get("score");
            return archive;
        });
    }
    finally {
        await session.close();
    }
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function toNumber(v) {
    if (typeof v === "number")
        return v;
    if (typeof v?.toNumber === "function") {
        return v.toNumber();
    }
    return Number(v ?? 0);
}
function recordToArchive(rec) {
    const summaryParts = rec.get("summaryParts") ?? [];
    const firsts = rec.get("firsts") ?? [];
    const lasts = rec.get("lasts") ?? [];
    const firstMessageAt = firsts.length > 0
        ? firsts.slice().sort()[0]
        : rec.get("archiveCreatedAt") ?? "";
    const lastMessageAt = lasts.length > 0
        ? lasts.slice().sort()[lasts.length - 1]
        : rec.get("archiveLastAt") ?? "";
    return {
        elementId: rec.get("elementId"),
        title: rec.get("title") ?? "",
        summary: summaryParts.filter((s) => s).join("\n\n"),
        firstMessageAt,
        lastMessageAt,
        sectionCount: toNumber(rec.get("sectionCount")),
        participantNames: rec.get("participantNames") ?? [],
    };
}
function formatEmptyResult(params, query) {
    const filters = [];
    if (query)
        filters.push(`query: "${query}"`);
    if (params.sender)
        filters.push(`sender: ${params.sender}`);
    if (params.dateFrom)
        filters.push(`from: ${params.dateFrom}`);
    if (params.dateTo)
        filters.push(`to: ${params.dateTo}`);
    return `No email archives found in the graph${filters.length > 0 ? ` matching ${filters.join(", ")}` : ""}.`;
}
//# sourceMappingURL=email-graph-query.js.map