import { getSession } from "../lib/neo4j.js";
import { withWriteEvent } from "../lib/log-ingest.js";
import { embedBatch } from "../lib/embeddings.js";
import { sectioniseDocument } from "../lib/document-sectioniser.js";
// ---------------------------------------------------------------------------
// Task 636 — embed every vector-indexed label at archive-write time.
//
// Small entities (Person, Organization, DefinedTerm, DigitalDocument, Notion
// row labels) get a bounded props embedding. Real-bodied documents (Obsidian
// pages, Notion pages) get a parent `summary` embedding plus per-:Section body
// embeddings via the deterministic sectioniser — there is no LLM classifier on
// this path. The :Section graph shape (HAS_SECTION + NEXT) mirrors
// memory-ingest so retrieval treats archive docs identically.
// ---------------------------------------------------------------------------
const SECTION_BODY_PREVIEW = 150;
/**
 * Prepare a real-bodied document for the summary+Section write: deterministic
 * split, then one embedBatch over [summary, ...sectionBodies]. Returns the
 * parent summary, its embedding, and the per-section embeddings. A failed embed
 * yields null/empty embeddings (the standing [embed-audit] catches them; the
 * next reindex backfills) — never throws, so a bad batch cannot abort ingest.
 */
async function prepareDocumentEmbedding(label, title, body) {
    const { summary, sections } = sectioniseDocument(body);
    const parentText = summary.length > 0 ? summary : `[${label}] ${title}`;
    const texts = [parentText, ...sections.map((s) => s.body)];
    try {
        const vectors = await embedBatch(texts);
        return {
            summary,
            docEmbedding: vectors[0] ?? null,
            sections: sections.map((s, i) => ({ ...s, embedding: vectors[i + 1] ?? [] })),
        };
    }
    catch (err) {
        process.stderr.write(`[graph-write] warn reason=embed-failed labels=${label} agent=archive-import detail=${err instanceof Error ? err.message : String(err)}\n`);
        return {
            summary,
            docEmbedding: null,
            sections: sections.map((s) => ({ ...s, embedding: [] })),
        };
    }
}
/**
 * Write a document's :Section children (idempotent). Deletes any prior
 * (:KnowledgeDocument)-[:HAS_SECTION]->(:Section) so re-ingest does not
 * duplicate, then re-creates the section chain mirroring memory-ingest's
 * contract (HAS_SECTION + NEXT, embedding per section). Empty-embedding
 * sections are still written (property-searchable; reindex backfills the
 * vector). The `:Section` target label keeps this cleanup from touching other
 * HAS_SECTION uses (e.g. Notion db→row edges).
 */
export async function writeKnowledgeDocSections(tx, opts) {
    await tx.run(`MATCH (d:KnowledgeDocument)-[:HAS_SECTION]->(s:Section)
     WHERE elementId(d) = $docId
     DETACH DELETE s`, { docId: opts.docId });
    const now = new Date().toISOString();
    let previousSectionId = null;
    for (let i = 0; i < opts.sections.length; i++) {
        const sec = opts.sections[i];
        const res = (await tx.run(`MATCH (d:KnowledgeDocument) WHERE elementId(d) = $docId
       CREATE (s:Section)
       SET s = $props
       CREATE (d)-[hs:HAS_SECTION]->(s)
       SET hs.source = $source, hs.createdAt = datetime(), hs.createdBySession = $sessionId,
           hs.sourceDocumentId = $sourceDocumentKey
       RETURN elementId(s) AS sectionId`, {
            docId: opts.docId,
            source: opts.source,
            sessionId: opts.sessionId,
            sourceDocumentKey: opts.sourceDocumentKey,
            props: {
                accountId: opts.accountId,
                title: sec.title,
                body: sec.body,
                bodyPreview: sec.body.slice(0, SECTION_BODY_PREVIEW),
                position: i,
                scope: opts.scope,
                embedding: sec.embedding,
                createdAt: now,
                updatedAt: now,
                createdByAgent: opts.createdByAgent,
                createdBySource: opts.createdByAgent,
                createdBySession: opts.sessionId ?? "",
                source: opts.source,
                sourceDocumentId: opts.sourceDocumentKey,
            },
        }));
        const sectionId = res.records[0].get("sectionId");
        if (previousSectionId) {
            await tx.run(`MATCH (a:Section), (b:Section)
         WHERE elementId(a) = $prev AND elementId(b) = $cur
         CREATE (a)-[n:NEXT]->(b)
         SET n.source = $source, n.createdAt = datetime(), n.sourceDocumentId = $sourceDocumentKey`, { prev: previousSectionId, cur: sectionId, source: opts.source, sourceDocumentKey: opts.sourceDocumentKey });
        }
        previousSectionId = sectionId;
    }
}
/**
 * Props-embed LinkedIn rows: one batch over Person texts, one over the subset
 * with a company (Organization). Returns rows with `personEmbedding` and
 * `orgEmbedding` (null when no company). A failed batch leaves the embeddings
 * null so the write still proceeds.
 */
async function attachLinkedinEmbeddings(rows) {
    const personTexts = rows.map((r) => `[Person] ${[r.givenName, r.familyName, r.title].filter(Boolean).join(" ")}`.trim());
    const orgIdx = [];
    const orgTexts = [];
    rows.forEach((r, i) => {
        if (r.company && r.company.trim()) {
            orgIdx.push(i);
            orgTexts.push(`[Organization] ${r.company}`);
        }
    });
    let personEmb = rows.map(() => null);
    let orgEmbByIdx = new Map();
    try {
        personEmb = await embedBatch(personTexts);
    }
    catch (err) {
        process.stderr.write(`[graph-write] warn reason=embed-failed labels=Person agent=linkedin-import detail=${err instanceof Error ? err.message : String(err)}\n`);
    }
    if (orgTexts.length > 0) {
        try {
            const v = await embedBatch(orgTexts);
            orgIdx.forEach((rowI, j) => orgEmbByIdx.set(rowI, v[j]));
        }
        catch (err) {
            process.stderr.write(`[graph-write] warn reason=embed-failed labels=Organization agent=linkedin-import detail=${err instanceof Error ? err.message : String(err)}\n`);
        }
    }
    return rows.map((r, i) => ({
        ...r,
        personEmbedding: personEmb[i] ?? null,
        orgEmbedding: orgEmbByIdx.get(i) ?? null,
    }));
}
/** Embed a flat list of texts keyed by id; failures yield an empty map. */
async function embedKeyed(entries, label) {
    const out = {};
    if (entries.length === 0)
        return out;
    try {
        const vectors = await embedBatch(entries.map((e) => e.text));
        entries.forEach((e, i) => {
            if (vectors[i])
                out[e.key] = vectors[i];
        });
    }
    catch (err) {
        process.stderr.write(`[graph-write] warn reason=embed-failed labels=${label} agent=obsidian-import detail=${err instanceof Error ? err.message : String(err)}\n`);
    }
    return out;
}
/**
 * Prepare every embedding the Obsidian batch needs: per-page summary+sections
 * (KnowledgeDocument), a distinct-tag map (DefinedTerm), and a per-attachment
 * map (DigitalDocument). Returns rows carrying `summary`/`docEmbedding`, the two
 * dynamic-key maps the Cypher reads, and the per-path section list for the
 * post-pass.
 */
async function attachObsidianEmbeddings(rows) {
    const sectionsByPath = new Map();
    const preparedRows = [];
    for (const row of rows) {
        const { summary, docEmbedding, sections } = await prepareDocumentEmbedding("KnowledgeDocument", row.title, row.body);
        sectionsByPath.set(row.obsidianPath, sections);
        preparedRows.push({ ...row, summary, docEmbedding });
    }
    const tagSet = new Set();
    for (const row of rows)
        for (const t of row.tags ?? [])
            if (t)
                tagSet.add(t);
    const tagEmbeddings = await embedKeyed([...tagSet].map((t) => ({ key: t, text: `[DefinedTerm] ${t}` })), "DefinedTerm");
    const attEntries = new Map();
    for (const row of rows) {
        for (const att of row.attachments ?? []) {
            attEntries.set(att.archivePath, `[DigitalDocument] ${att.alt ?? ""} ${att.encodingFormat}`.trim());
        }
    }
    const attachmentEmbeddings = await embedKeyed([...attEntries].map(([key, text]) => ({ key, text })), "DigitalDocument");
    return { rows: preparedRows, tagEmbeddings, attachmentEmbeddings, sectionsByPath };
}
/**
 * Props-embed Notion database rows. Each row's content lives in `properties`
 * (no body field), so the embedding text is the label plus a bounded
 * serialisation of its properties. A failed batch leaves `embedding` null.
 */
async function attachNotionRowEmbeddings(label, rows) {
    const texts = rows.map((r) => {
        const propText = Object.entries(r.properties ?? {})
            .map(([k, v]) => `${k}: ${v ?? ""}`)
            .join(" ");
        const t = `[${label}] ${propText}`;
        return t.length > 2000 ? t.slice(0, 2000) : t;
    });
    let embeddings = rows.map(() => null);
    try {
        embeddings = await embedBatch(texts);
    }
    catch (err) {
        process.stderr.write(`[graph-write] warn reason=embed-failed labels=${label} agent=notion-import detail=${err instanceof Error ? err.message : String(err)}\n`);
    }
    return rows.map((r, i) => ({ ...r, embedding: embeddings[i] ?? null }));
}
/**
 * memory-archive-write — deterministic bulk-archive write surface for flat
 * datasets only (LinkedIn Connections.csv and future CRM-type seeds).
 *
 * Distinct from memory-write (per-node, adjacency-required) and memory-ingest
 * (any unstructured content → :KnowledgeDocument + :Section). Conversation
 * transcripts (WhatsApp, Telegram, Signal, Granola, Otter, …) flow through
 * the `conversation-archive` skill instead, which writes :KnowledgeDocument
 * (keyed on conversationIdentity) + :Section chunks via memory-ingest — see.
 *
 * Doctrine:
 *   - The Cypher body for each archiveType is fixed inside this tool.
 *     The agent supplies parsed rows + the archiveType discriminant,
 *     never raw Cypher.
 *   - 500-row UNWIND batches per executeWrite transaction.
 *   - Per-node provenance stamps (createdByAgent, createdBySession,
 *     source, createdAt) come from this server, not the agent.
 *   - Counters are per-handler — each handler picks the keys that
 *     describe its archiveType.
 */
const BATCH_SIZE = 500;
const HANDLERS = {
    "linkedin-connections": linkedinConnectionsHandler(),
    "obsidian-vault": obsidianVaultHandler(),
    "notion-pages": notionPagesHandler(),
    "notion-database-rows": notionDatabaseRowsHandler(),
    "notion-relations-pass-2": notionRelationsPass2Handler(),
};
// ---------------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------------
export async function memoryArchiveWrite(params) {
    return withWriteEvent({ tool: "memory-archive-write", accountId: params.accountId, name: params.archiveType }, () => memoryArchiveWriteImpl(params));
}
async function memoryArchiveWriteImpl(params) {
    const { archiveType, ownerNodeId, accountId, rows } = params;
    if (!accountId || !accountId.trim()) {
        throw new Error("memory-archive-write: accountId is required.");
    }
    if (!ownerNodeId || !ownerNodeId.trim()) {
        throw new Error("memory-archive-write: ownerNodeId is required.");
    }
    if (!Array.isArray(rows) || rows.length === 0) {
        throw new Error("memory-archive-write: rows must be a non-empty array.");
    }
    const handler = HANDLERS[archiveType];
    if (!handler) {
        throw new Error(`memory-archive-write: unknown archiveType "${archiveType}". Known: ${Object.keys(HANDLERS).join(", ")}`);
    }
    const session = getSession();
    const startedMs = Date.now();
    const errors = [];
    const counters = {};
    const sessionId = params.sessionId?.trim() || null;
    try {
        await handler.verifyArchive(session, params);
        for (let offset = 0; offset < rows.length; offset += BATCH_SIZE) {
            const batchRows = rows.slice(offset, offset + BATCH_SIZE);
            try {
                const batchCounters = await handler.writeBatch(session, {
                    params,
                    batchRows,
                    sessionId,
                });
                mergeCounters(counters, batchCounters);
            }
            catch (err) {
                const reason = err instanceof Error ? err.message : String(err);
                errors.push({ rowIndex: offset, reason: `batch starting at row ${offset}: ${reason}` });
                process.stderr.write(`[memory-archive-write] batch-failed archiveType=${archiveType} offset=${offset} reason=${reason}\n`);
            }
        }
        let nextChainStatus;
        let nextChainReason;
        if (handler.finalize) {
            try {
                const finalizeResult = await handler.finalize(session, {
                    params,
                    sessionId,
                    hadBatchErrors: errors.length > 0,
                });
                mergeCounters(counters, finalizeResult.counters);
                nextChainStatus = finalizeResult.status;
                nextChainReason = finalizeResult.reason;
            }
            catch (err) {
                const reason = err instanceof Error ? err.message : String(err);
                nextChainStatus = "failed";
                nextChainReason = reason;
                process.stderr.write(`[memory-archive-write] finalize-failed archiveType=${archiveType} reason=${reason}\n`);
            }
        }
        const elapsedMs = Date.now() - startedMs;
        const counterStr = Object.entries(counters)
            .map(([k, v]) => `${k}=${v}`)
            .join(" ");
        const statusStr = nextChainStatus
            ? ` nextChainStatus=${nextChainStatus}${nextChainReason ? ` reason=${nextChainReason}` : ""}`
            : "";
        process.stderr.write(`[memory-archive-write] archiveType=${archiveType} rows=${rows.length} ${counterStr} errors=${errors.length}${statusStr} ms=${elapsedMs}\n`);
        return {
            archiveType,
            processedRows: rows.length,
            counters,
            errors,
            ...(nextChainStatus ? { nextChainStatus } : {}),
            ...(nextChainReason ? { nextChainReason } : {}),
        };
    }
    finally {
        await session.close();
    }
}
function mergeCounters(target, source) {
    for (const [k, v] of Object.entries(source)) {
        target[k] = (target[k] ?? 0) + v;
    }
}
// ---------------------------------------------------------------------------
// linkedin-connections handler — currentTitle denormalisation
// ---------------------------------------------------------------------------
export const PERSON_AND_CONNECTED_EDGE_CYPHER = `
MATCH (owner) WHERE elementId(owner) = $ownerNodeId
WITH owner, $rows AS rows
UNWIND rows AS row
MERGE (p:Person {linkedinUrl: row.linkedinUrl})
  ON CREATE SET
    p.accountId        = $accountId,
    p.source           = 'linkedin',
    p.createdByAgent   = 'linkedin-import',
    p.createdBySource  = 'linkedin-import',
    p.createdBySession = $sessionId,
    p.createdAt        = datetime(),
    p.scope            = 'admin'
SET
    p.givenName    = row.givenName,
    p.familyName   = row.familyName,
    p.currentTitle = row.title,
    p.embedding    = row.personEmbedding
FOREACH (_ IN CASE WHEN row.email IS NOT NULL AND row.email <> '' THEN [1] ELSE [] END |
  SET p.email = row.email
)
MERGE (owner)-[c:CONNECTED_ON_LINKEDIN]->(p)
  ON CREATE SET
    c.connectedOn = date(row.connectedOn),
    c.source      = 'linkedin',
    c.createdAt   = datetime()
RETURN count(*) AS processed
`;
export const ORG_AND_WORKS_FOR_CYPHER = `
UNWIND $rows AS row
WITH row WHERE row.company IS NOT NULL AND row.company <> ''
MATCH (p:Person {linkedinUrl: row.linkedinUrl})
MERGE (o:Organization {accountId: $accountId, name: row.company})
  ON CREATE SET
    o.source           = 'linkedin',
    o.createdByAgent   = 'linkedin-import',
    o.createdBySource  = 'linkedin-import',
    o.createdBySession = $sessionId,
    o.createdAt        = datetime(),
    o.scope            = 'admin',
    o.embedding        = row.orgEmbedding
MERGE (p)-[w:WORKS_FOR]->(o)
  ON CREATE SET
    w.title     = row.title,
    w.source    = 'linkedin',
    w.current   = true,
    w.createdAt = datetime()
  ON MATCH SET
    w.title     = coalesce(row.title, w.title)
RETURN count(*) AS processed
`;
function linkedinConnectionsHandler() {
    return {
        async verifyArchive(session, params) {
            const { ownerNodeId, accountId } = params;
            const result = await session.run(`MATCH (n) WHERE elementId(n) = $ownerNodeId
         RETURN labels(n) AS labels, n.accountId AS accountId LIMIT 1`, { ownerNodeId });
            if (result.records.length === 0) {
                throw new Error(`ownerNodeId ${ownerNodeId} not found. Re-run the archive-owner confirmation flow before invoking memory-archive-write.`);
            }
            const labels = result.records[0].get("labels") ?? [];
            const ownerAccountId = result.records[0].get("accountId");
            const isAdminUser = labels.includes("AdminUser");
            const isPerson = labels.includes("Person");
            if (!isAdminUser && !isPerson) {
                throw new Error(`ownerNodeId ${ownerNodeId} has labels [${labels.join(", ")}]; expected :AdminUser or :Person.`);
            }
            if (ownerAccountId && ownerAccountId !== accountId) {
                throw new Error(`ownerNodeId ${ownerNodeId} belongs to account ${ownerAccountId}, not ${accountId}. Cross-account archive ingest refused.`);
            }
        },
        async writeBatch(session, ctx) {
            const { params, batchRows, sessionId } = ctx;
            const normalisedRows = batchRows.map((r) => normaliseLinkedinRow(r));
            // Task 636 — props-embed each Person and (where present) Organization
            // before the write tx so the Ollama call does not hold a transaction
            // open. A failed batch leaves embeddings null (audit catches it).
            const rows = await attachLinkedinEmbeddings(normalisedRows);
            const queryParams = {
                ownerNodeId: params.ownerNodeId,
                accountId: params.accountId,
                sessionId,
                rows,
            };
            return await session.executeWrite(async (tx) => {
                const personRes = await tx.run(PERSON_AND_CONNECTED_EDGE_CYPHER, queryParams);
                const personCounters = personRes.summary.counters.updates();
                const orgRes = await tx.run(ORG_AND_WORKS_FOR_CYPHER, queryParams);
                const orgCounters = orgRes.summary.counters.updates();
                const createdPersons = personCounters.nodesCreated;
                const createdOrgs = orgCounters.nodesCreated;
                const personsTouched = queryParams.rows.length;
                const mergedPersons = personsTouched - createdPersons;
                const createdEdges = personCounters.relationshipsCreated + orgCounters.relationshipsCreated;
                return {
                    createdPersons,
                    mergedPersons,
                    createdOrganizations: createdOrgs,
                    createdEdges,
                };
            });
        },
    };
}
// ---------------------------------------------------------------------------
// obsidian-vault handler — Obsidian vault pages, wikilinks, tags, attachments
// ---------------------------------------------------------------------------
//
// Page MERGE natural key:  (accountId, obsidianPath)
// Tag MERGE natural key:   (accountId, category='obsidian-tag', name)
// Attachment MERGE key:    (accountId, attachmentPath)
//
// Wikilinks split into three edge writes by target kind:
//   page   → (:KnowledgeDocument)-[:WIKILINKS_TO]->(:KnowledgeDocument)
//   entity → (:KnowledgeDocument)-[:MENTIONS]->(node by elementId)
//   stub   → (:KnowledgeDocument)-[:MENTIONS]->(:Concept) with stub creation
//
// Owner anchor: each page MERGEd gets (:owner)-[:HAS_NOTE]->(page) so the
// page is reachable via the canonical hierarchy chain. Owner is either an
// :AdminUser (operator's own vault) or a :Person (external vault ingested
// for reference) — same shape as linkedin-import.
export const OBSIDIAN_PAGE_CYPHER = `
MATCH (owner) WHERE elementId(owner) = $ownerNodeId
WITH owner, $rows AS rows
UNWIND rows AS row
MERGE (k:KnowledgeDocument {accountId: $accountId, obsidianPath: row.obsidianPath})
  ON CREATE SET
    k.knowledgeDocId   = randomUUID(),
    k.source           = 'obsidian',
    k.createdByAgent   = 'obsidian-import',
    k.createdBySource  = 'obsidian-import',
    k.createdBySession = $sessionId,
    k.createdAt        = datetime(),
    k.scope            = 'admin'
SET
    k.title         = row.title,
    k.contentHash   = row.contentHash,
    k.text          = row.body,
    k.summary       = row.summary,
    k.embedding     = row.docEmbedding,
    k.aliases       = row.aliases,
    k.tags          = row.tags,
    k.obsidianMtime = datetime({ epochMillis: toInteger(row.mtimeMs) }),
    k.updatedAt     = datetime()
FOREACH (_ IN CASE WHEN row.noteDate IS NOT NULL THEN [1] ELSE [] END |
  SET k.noteDate = date(row.noteDate)
)
MERGE (owner)-[h:HAS_NOTE]->(k)
  ON CREATE SET
    h.source    = 'obsidian',
    h.createdAt = datetime()
RETURN collect({ key: row.obsidianPath, docId: elementId(k) }) AS docs
`;
export const OBSIDIAN_TAG_CYPHER = `
UNWIND $rows AS row
MATCH (k:KnowledgeDocument {accountId: $accountId, obsidianPath: row.obsidianPath})
UNWIND row.tags AS tagName
WITH k, tagName WHERE tagName IS NOT NULL AND tagName <> ''
MERGE (t:DefinedTerm {accountId: $accountId, category: 'obsidian-tag', name: tagName})
  ON CREATE SET
    t.source           = 'obsidian',
    t.createdByAgent   = 'obsidian-import',
    t.createdBySource  = 'obsidian-import',
    t.createdBySession = $sessionId,
    t.createdAt        = datetime(),
    t.scope            = 'admin',
    t.embedding        = $tagEmbeddings[tagName]
MERGE (k)-[r:TAGGED_WITH]->(t)
  ON CREATE SET
    r.source    = 'obsidian',
    r.createdAt = datetime()
RETURN count(*) AS processed
`;
export const OBSIDIAN_WIKILINK_PAGE_CYPHER = `
UNWIND $pageLinks AS wl
MATCH (src:KnowledgeDocument {accountId: $accountId, obsidianPath: wl.fromPath})
MATCH (dst:KnowledgeDocument {accountId: $accountId, obsidianPath: wl.toPath})
MERGE (src)-[r:WIKILINKS_TO]->(dst)
  ON CREATE SET
    r.linkText  = wl.linkText,
    r.alias     = wl.alias,
    r.anchor    = wl.anchor,
    r.source    = 'obsidian',
    r.createdAt = datetime()
RETURN count(*) AS processed
`;
export const OBSIDIAN_WIKILINK_ENTITY_CYPHER = `
UNWIND $entityLinks AS wl
MATCH (src:KnowledgeDocument {accountId: $accountId, obsidianPath: wl.fromPath})
MATCH (dst) WHERE elementId(dst) = wl.toNodeId AND dst.accountId = $accountId
MERGE (src)-[r:MENTIONS]->(dst)
  ON CREATE SET
    r.linkText  = wl.linkText,
    r.alias     = wl.alias,
    r.source    = 'obsidian',
    r.createdAt = datetime()
RETURN count(*) AS processed
`;
export const OBSIDIAN_WIKILINK_STUB_CYPHER = `
UNWIND $stubLinks AS wl
MATCH (src:KnowledgeDocument {accountId: $accountId, obsidianPath: wl.fromPath})
MERGE (c:Concept {accountId: $accountId, name: wl.stubName, source: 'obsidian-stub'})
  ON CREATE SET
    c.createdByAgent   = 'obsidian-import',
    c.createdBySource  = 'obsidian-import',
    c.createdBySession = $sessionId,
    c.createdAt        = datetime(),
    c.scope            = 'admin'
MERGE (src)-[r:MENTIONS]->(c)
  ON CREATE SET
    r.linkText  = wl.linkText,
    r.alias     = wl.alias,
    r.source    = 'obsidian',
    r.createdAt = datetime()
RETURN count(*) AS processed
`;
export const OBSIDIAN_ATTACHMENT_CYPHER = `
UNWIND $rows AS row
WITH row WHERE size(row.attachments) > 0
MATCH (k:KnowledgeDocument {accountId: $accountId, obsidianPath: row.obsidianPath})
UNWIND row.attachments AS att
MERGE (d:DigitalDocument {accountId: $accountId, attachmentPath: att.archivePath})
  ON CREATE SET
    d.digitalDocumentId = randomUUID(),
    d.source            = 'obsidian',
    d.encodingFormat    = att.encodingFormat,
    d.alt               = att.alt,
    d.embedding         = $attachmentEmbeddings[att.archivePath],
    d.createdByAgent    = 'obsidian-import',
    d.createdBySource   = 'obsidian-import',
    d.createdBySession  = $sessionId,
    d.createdAt         = datetime(),
    d.scope             = 'admin'
MERGE (d)-[e:EMBEDDED_IN]->(k)
  ON CREATE SET
    e.source    = 'obsidian',
    e.createdAt = datetime()
RETURN count(*) AS processed
`;
function obsidianVaultHandler() {
    return {
        async verifyArchive(session, params) {
            const { ownerNodeId, accountId } = params;
            const result = await session.run(`MATCH (n) WHERE elementId(n) = $ownerNodeId
         RETURN labels(n) AS labels, n.accountId AS accountId LIMIT 1`, { ownerNodeId });
            if (result.records.length === 0) {
                throw new Error(`ownerNodeId ${ownerNodeId} not found. Re-run the archive-owner confirmation flow before invoking memory-archive-write.`);
            }
            const labels = result.records[0].get("labels") ?? [];
            const ownerAccountId = result.records[0].get("accountId");
            const isAdminUser = labels.includes("AdminUser");
            const isPerson = labels.includes("Person");
            if (!isAdminUser && !isPerson) {
                throw new Error(`ownerNodeId ${ownerNodeId} has labels [${labels.join(", ")}]; expected :AdminUser or :Person.`);
            }
            if (ownerAccountId && ownerAccountId !== accountId) {
                throw new Error(`ownerNodeId ${ownerNodeId} belongs to account ${ownerAccountId}, not ${accountId}. Cross-account vault ingest refused.`);
            }
        },
        async writeBatch(session, ctx) {
            const { params, batchRows, sessionId } = ctx;
            const normalisedRows = batchRows.map(normaliseObsidianRow);
            // Task 636 — prepare all embeddings before the write tx (no network call
            // inside the transaction): page summary+sections, distinct-tag map,
            // per-attachment map.
            const { rows, tagEmbeddings, attachmentEmbeddings, sectionsByPath } = await attachObsidianEmbeddings(normalisedRows);
            const pageLinks = [];
            const entityLinks = [];
            const stubLinks = [];
            for (const row of rows) {
                for (const wl of row.wikilinks ?? []) {
                    if (wl.target.kind === "page") {
                        pageLinks.push({
                            fromPath: row.obsidianPath,
                            toPath: wl.target.obsidianPath,
                            linkText: wl.linkText,
                            alias: wl.alias,
                            anchor: wl.anchor,
                        });
                    }
                    else if (wl.target.kind === "entity") {
                        entityLinks.push({
                            fromPath: row.obsidianPath,
                            toNodeId: wl.target.nodeId,
                            linkText: wl.linkText,
                            alias: wl.alias,
                        });
                    }
                    else {
                        stubLinks.push({
                            fromPath: row.obsidianPath,
                            stubName: wl.target.name,
                            linkText: wl.linkText,
                            alias: wl.alias,
                        });
                    }
                }
            }
            const queryParams = {
                ownerNodeId: params.ownerNodeId,
                accountId: params.accountId,
                sessionId,
                rows,
                pageLinks,
                entityLinks,
                stubLinks,
                tagEmbeddings,
                attachmentEmbeddings,
            };
            return await session.executeWrite(async (tx) => {
                const pageRes = await tx.run(OBSIDIAN_PAGE_CYPHER, queryParams);
                const pageCounters = pageRes.summary.counters.updates();
                // Task 636 — write each page's :Section children (idempotent) from the
                // deterministic sectioniser output, mirroring memory-ingest's contract.
                const pageDocs = pageRes.records[0]?.get("docs") ?? [];
                for (const { key, docId } of pageDocs) {
                    const sections = sectionsByPath.get(key) ?? [];
                    if (sections.length === 0)
                        continue;
                    await writeKnowledgeDocSections(tx, {
                        docId,
                        accountId: params.accountId,
                        scope: "admin",
                        source: "obsidian",
                        createdByAgent: "obsidian-import",
                        sessionId: sessionId ?? null,
                        sourceDocumentKey: key,
                        sections,
                    });
                }
                const tagRes = await tx.run(OBSIDIAN_TAG_CYPHER, queryParams);
                const tagCounters = tagRes.summary.counters.updates();
                const attRes = await tx.run(OBSIDIAN_ATTACHMENT_CYPHER, queryParams);
                const attCounters = attRes.summary.counters.updates();
                let wikilinkCountersCreated = 0;
                if (pageLinks.length > 0) {
                    const r = await tx.run(OBSIDIAN_WIKILINK_PAGE_CYPHER, queryParams);
                    wikilinkCountersCreated += r.summary.counters.updates().relationshipsCreated;
                }
                if (entityLinks.length > 0) {
                    const r = await tx.run(OBSIDIAN_WIKILINK_ENTITY_CYPHER, queryParams);
                    wikilinkCountersCreated += r.summary.counters.updates().relationshipsCreated;
                }
                if (stubLinks.length > 0) {
                    const r = await tx.run(OBSIDIAN_WIKILINK_STUB_CYPHER, queryParams);
                    wikilinkCountersCreated += r.summary.counters.updates().relationshipsCreated;
                }
                return {
                    createdPages: pageCounters.nodesCreated,
                    mergedPages: rows.length - pageCounters.nodesCreated,
                    createdTags: tagCounters.nodesCreated,
                    createdTagEdges: tagCounters.relationshipsCreated,
                    createdAttachments: attCounters.nodesCreated,
                    createdAttachmentEdges: attCounters.relationshipsCreated,
                    createdWikilinkEdges: wikilinkCountersCreated,
                    pageLinksRequested: pageLinks.length,
                    entityLinksRequested: entityLinks.length,
                    stubLinksRequested: stubLinks.length,
                };
            });
        },
    };
}
function normaliseObsidianRow(row) {
    if (!row.obsidianPath || !row.obsidianPath.trim()) {
        throw new Error("obsidian-vault row missing obsidianPath (natural key).");
    }
    if (row.obsidianPath.split("/").includes("..")) {
        throw new Error(`obsidian-vault row obsidianPath="${row.obsidianPath}" contains path-traversal segment.`);
    }
    if (!row.contentHash || !/^[0-9a-f]{64}$/i.test(row.contentHash)) {
        throw new Error(`obsidian-vault row contentHash for ${row.obsidianPath} is not a hex SHA-256.`);
    }
    if (row.noteDate && !/^\d{4}-\d{2}-\d{2}$/.test(row.noteDate)) {
        throw new Error(`obsidian-vault row noteDate="${row.noteDate}" is not ISO 8601 (YYYY-MM-DD).`);
    }
    return {
        obsidianPath: row.obsidianPath.trim(),
        title: (row.title ?? "").trim(),
        aliases: Array.isArray(row.aliases) ? row.aliases : [],
        tags: Array.isArray(row.tags) ? row.tags : [],
        body: row.body ?? "",
        contentHash: row.contentHash.toLowerCase(),
        noteDate: row.noteDate ?? null,
        mtimeMs: typeof row.mtimeMs === "number" ? row.mtimeMs : 0,
        wikilinks: Array.isArray(row.wikilinks) ? row.wikilinks : [],
        attachments: Array.isArray(row.attachments) ? row.attachments : [],
    };
}
// ---------------------------------------------------------------------------
// notion-pages / notion-database-rows / notion-relations-pass-2 handlers
// ---------------------------------------------------------------------------
//
// Notion's export shape (page tree + DB schemas + typed relations + mentions)
// does not fit the linkedin flat-row model, so the import skill splits the
// write into three archiveType calls:
//
//   1. notion-pages              — MERGE every :KnowledgeDocument page,
//                                  stamp provenance, attach workspace-root
//                                  HAS_SECTION at insert time, optional
//                                  AUTHORED edge. Page-to-page HAS_SECTION
//                                  is deferred to pass-2 so cross-batch
//                                  forward references resolve cleanly.
//   2. notion-database-rows      — MERGE typed-label nodes (Person, Org,
//                                  Project, Task, Event, KnowledgeDocument
//                                  fallback) and the
//                                  (:notion-database)-[:HAS_SECTION]->(row)
//                                  edge. Per-call `label` decides which
//                                  Cypher template runs.
//   3. notion-relations-pass-2   — MERGE all deferred relations
//                                  (page-to-page HAS_SECTION, MENTIONS,
//                                  WORKS_FOR, OWNS, ASSIGNED_TO, PART_OF,
//                                  ATTENDED, AUTHORED) once every node
//                                  exists. Source/target located by
//                                  (accountId, notionId); missing endpoints
//                                  count as skippedUnknownTarget.
const NOTION_RELATION_EDGE_TYPES = new Set([
    "MENTIONS",
    "WORKS_FOR",
    "OWNS",
    "ASSIGNED_TO",
    "PART_OF",
    "ATTENDED",
    "HAS_SECTION",
    "AUTHORED",
]);
const NOTION_ROW_LABELS = new Set([
    "Person",
    "Organization",
    "Project",
    "Task",
    "Event",
    "KnowledgeDocument",
]);
const NOTION_ID_RE = /^[0-9a-f]{32}$/i;
async function verifyNotionWorkspaceOwner(session, params) {
    const { ownerNodeId, accountId } = params;
    const result = await session.run(`MATCH (n) WHERE elementId(n) = $ownerNodeId
     RETURN labels(n) AS labels, n.accountId AS accountId LIMIT 1`, { ownerNodeId });
    if (result.records.length === 0) {
        throw new Error(`ownerNodeId ${ownerNodeId} not found. Confirm the :NotionWorkspace node before invoking memory-archive-write.`);
    }
    const labels = result.records[0].get("labels") ?? [];
    if (!labels.includes("NotionWorkspace")) {
        throw new Error(`ownerNodeId ${ownerNodeId} has labels [${labels.join(", ")}]; expected :NotionWorkspace.`);
    }
    const ownerAccountId = result.records[0].get("accountId");
    if (ownerAccountId && ownerAccountId !== accountId) {
        throw new Error(`ownerNodeId ${ownerNodeId} belongs to account ${ownerAccountId}, not ${accountId}. Cross-account Notion ingest refused.`);
    }
}
// ---- notion-pages ---------------------------------------------------------
export const NOTION_PAGE_CYPHER = `
MATCH (workspace:NotionWorkspace) WHERE elementId(workspace) = $ownerNodeId
WITH workspace, $rows AS rows
UNWIND rows AS row
MERGE (k:KnowledgeDocument {accountId: $accountId, notionId: row.notionId})
  ON CREATE SET
    k.knowledgeDocId   = randomUUID(),
    k.kind             = 'notion-page',
    k.source           = 'notion',
    k.createdByAgent   = 'notion-import',
    k.createdBySource  = 'notion-import',
    k.createdBySession = $sessionId,
    k.createdAt        = datetime(),
    k.scope            = 'admin',
    k.importId         = $importId
SET
    k.title     = row.title,
    k.text      = row.text,
    k.summary   = row.summary,
    k.embedding = row.docEmbedding,
    k.updatedAt = datetime()
FOREACH (_ IN CASE WHEN row.notionCreatedAt IS NOT NULL THEN [1] ELSE [] END |
  SET k.notionCreatedAt = datetime(row.notionCreatedAt)
)
FOREACH (_ IN CASE WHEN row.notionLastEditedAt IS NOT NULL THEN [1] ELSE [] END |
  SET k.notionLastEditedAt = datetime(row.notionLastEditedAt)
)
FOREACH (_ IN CASE WHEN row.parentNotionId IS NULL THEN [1] ELSE [] END |
  MERGE (workspace)-[r:HAS_SECTION]->(k)
    ON CREATE SET r.source = 'notion', r.createdAt = datetime()
)
RETURN collect({ key: row.notionId, docId: elementId(k) }) AS docs
`;
export const NOTION_PAGE_AUTHOR_CYPHER = `
UNWIND $rows AS row
WITH row WHERE row.authorPersonNodeId IS NOT NULL
MATCH (page:KnowledgeDocument {accountId: $accountId, notionId: row.notionId})
MATCH (author) WHERE elementId(author) = row.authorPersonNodeId AND author.accountId = $accountId
MERGE (author)-[r:AUTHORED]->(page)
  ON CREATE SET r.source = 'notion', r.createdAt = datetime()
RETURN count(*) AS processed
`;
function notionPagesHandler() {
    return {
        async verifyArchive(session, params) {
            if (!params.importId || !params.importId.trim()) {
                throw new Error("notion-pages: params.importId is required (UUID per import run).");
            }
            await verifyNotionWorkspaceOwner(session, params);
        },
        async writeBatch(session, ctx) {
            const { params, batchRows, sessionId } = ctx;
            const normalisedRows = batchRows.map(normaliseNotionPageRow);
            // Task 636 — per-page summary+sections embeddings, prepared before the tx.
            const sectionsByKey = new Map();
            const rows = [];
            for (const row of normalisedRows) {
                const { summary, docEmbedding, sections } = await prepareDocumentEmbedding("KnowledgeDocument", row.title, row.text);
                sectionsByKey.set(row.notionId, sections);
                rows.push({ ...row, summary, docEmbedding });
            }
            const queryParams = {
                ownerNodeId: params.ownerNodeId,
                accountId: params.accountId,
                sessionId,
                importId: params.importId,
                rows,
            };
            return await session.executeWrite(async (tx) => {
                const pageRes = await tx.run(NOTION_PAGE_CYPHER, queryParams);
                const pageCounters = pageRes.summary.counters.updates();
                const pageDocs = pageRes.records[0]?.get("docs") ?? [];
                for (const { key, docId } of pageDocs) {
                    const sections = sectionsByKey.get(key) ?? [];
                    if (sections.length === 0)
                        continue;
                    await writeKnowledgeDocSections(tx, {
                        docId,
                        accountId: params.accountId,
                        scope: "admin",
                        source: "notion",
                        createdByAgent: "notion-import",
                        sessionId: sessionId ?? null,
                        sourceDocumentKey: key,
                        sections,
                    });
                }
                const authorRes = await tx.run(NOTION_PAGE_AUTHOR_CYPHER, queryParams);
                const authorCounters = authorRes.summary.counters.updates();
                return {
                    createdPages: pageCounters.nodesCreated,
                    mergedPages: rows.length - pageCounters.nodesCreated,
                    workspaceEdgesCreated: pageCounters.relationshipsCreated,
                    authoredEdgesCreated: authorCounters.relationshipsCreated,
                };
            });
        },
    };
}
function normaliseNotionPageRow(row) {
    if (!row.notionId || !NOTION_ID_RE.test(row.notionId)) {
        throw new Error(`notion-pages row notionId="${row.notionId}" is not a 32-hex Notion UUID.`);
    }
    if (row.parentNotionId !== null && !NOTION_ID_RE.test(row.parentNotionId)) {
        throw new Error(`notion-pages row parentNotionId="${row.parentNotionId}" is not a 32-hex Notion UUID (use null for workspace-root pages).`);
    }
    return {
        notionId: row.notionId.toLowerCase(),
        title: (row.title ?? "").trim(),
        text: row.text ?? "",
        parentNotionId: row.parentNotionId ? row.parentNotionId.toLowerCase() : null,
        notionCreatedAt: row.notionCreatedAt ?? null,
        notionLastEditedAt: row.notionLastEditedAt ?? null,
        authorPersonNodeId: row.authorPersonNodeId && row.authorPersonNodeId.trim()
            ? row.authorPersonNodeId.trim()
            : null,
    };
}
// ---- notion-database-rows -------------------------------------------------
function notionRowCypher(label) {
    // The label is dispatched at handler call time from the closed NOTION_ROW_LABELS
    // allowlist; string interpolation here is safe because the value is validated
    // in verifyArchive before this template is built.
    const docIdStamp = label === "KnowledgeDocument"
        ? "n.knowledgeDocId  = randomUUID(),\n    n.kind            = 'notion-row',"
        : "";
    return `
MATCH (db:KnowledgeDocument {accountId: $accountId, notionId: $databaseNotionId, kind: 'notion-database'})
WITH db, $rows AS rows
UNWIND rows AS row
MERGE (n:${label} {accountId: $accountId, notionId: row.notionId})
  ON CREATE SET
    ${docIdStamp}
    n.source           = 'notion',
    n.createdByAgent   = 'notion-import',
    n.createdBySource  = 'notion-import',
    n.createdBySession = $sessionId,
    n.createdAt        = datetime(),
    n.scope            = 'admin',
    n.importId         = $importId
SET n += row.properties, n.embedding = row.embedding, n.updatedAt = datetime()
MERGE (db)-[r:HAS_SECTION]->(n)
  ON CREATE SET r.source = 'notion', r.createdAt = datetime()
RETURN count(*) AS processed
`;
}
function notionDatabaseRowsHandler() {
    return {
        async verifyArchive(session, params) {
            if (!params.importId || !params.importId.trim()) {
                throw new Error("notion-database-rows: params.importId is required (UUID per import run).");
            }
            if (!params.databaseNotionId || !NOTION_ID_RE.test(params.databaseNotionId)) {
                throw new Error(`notion-database-rows: params.databaseNotionId="${params.databaseNotionId}" is not a 32-hex Notion UUID.`);
            }
            if (!params.label || !NOTION_ROW_LABELS.has(params.label)) {
                throw new Error(`notion-database-rows: params.label="${params.label}" is not in the allowlist (${Array.from(NOTION_ROW_LABELS).join(", ")}).`);
            }
            await verifyNotionWorkspaceOwner(session, params);
            // The parent database (:KnowledgeDocument {kind:'notion-database'}) must
            // already exist — the skill MERGEs it before calling notion-database-rows.
            const dbRes = await session.run(`MATCH (db:KnowledgeDocument {accountId: $accountId, notionId: $databaseNotionId, kind: 'notion-database'})
         RETURN count(db) AS n LIMIT 1`, { accountId: params.accountId, databaseNotionId: params.databaseNotionId });
            const n = dbRes.records[0]?.get("n");
            const nNum = typeof n === "number" ? n : n?.toNumber?.() ?? 0;
            if (nNum === 0) {
                throw new Error(`notion-database-rows: parent database :KnowledgeDocument {kind:'notion-database', notionId:'${params.databaseNotionId}'} not found for account ${params.accountId}. MERGE the database node before calling notion-database-rows.`);
            }
        },
        async writeBatch(session, ctx) {
            const { params, batchRows, sessionId } = ctx;
            const label = params.label;
            const normalisedRows = batchRows.map(normaliseNotionDatabaseRow);
            // Task 636 — props-embed each row before the tx. Notion rows (including
            // the KnowledgeDocument fallback) carry their content as `properties`,
            // not a body field, so there is no text to section — a props embedding is
            // the right shape.
            const rows = await attachNotionRowEmbeddings(label, normalisedRows);
            const queryParams = {
                accountId: params.accountId,
                sessionId,
                importId: params.importId,
                databaseNotionId: params.databaseNotionId,
                rows,
            };
            return await session.executeWrite(async (tx) => {
                const res = await tx.run(notionRowCypher(label), queryParams);
                const counters = res.summary.counters.updates();
                return {
                    createdRows: counters.nodesCreated,
                    mergedRows: rows.length - counters.nodesCreated,
                    createdHasSectionEdges: counters.relationshipsCreated,
                };
            });
        },
    };
}
function normaliseNotionDatabaseRow(row) {
    if (!row.notionId || !NOTION_ID_RE.test(row.notionId)) {
        throw new Error(`notion-database-rows row notionId="${row.notionId}" is not a 32-hex Notion UUID.`);
    }
    const properties = {};
    for (const [k, v] of Object.entries(row.properties ?? {})) {
        if (v === undefined)
            continue;
        properties[k] = v;
    }
    return {
        notionId: row.notionId.toLowerCase(),
        properties,
    };
}
// ---- notion-relations-pass-2 ----------------------------------------------
function notionRelationsPass2Handler() {
    return {
        async verifyArchive(session, params) {
            await verifyNotionWorkspaceOwner(session, params);
        },
        async writeBatch(session, ctx) {
            const { params, batchRows } = ctx;
            const relations = batchRows.map(normaliseNotionRelationRow);
            const byType = {};
            let skippedUnknownEdgeType = 0;
            for (const r of relations) {
                if (!NOTION_RELATION_EDGE_TYPES.has(r.edgeType)) {
                    skippedUnknownEdgeType += 1;
                    continue;
                }
                (byType[r.edgeType] ??= []).push(r);
            }
            return await session.executeWrite(async (tx) => {
                let relationsCreated = 0;
                let relationsMatched = 0;
                let skippedUnknownTarget = 0;
                for (const [edgeType, rels] of Object.entries(byType)) {
                    // edgeType is validated against NOTION_RELATION_EDGE_TYPES above, so
                    // interpolating it into the MERGE pattern is safe.
                    const cypher = `
            UNWIND $relations AS rel
            MATCH (src {accountId: $accountId, notionId: rel.sourceNotionId})
            MATCH (tgt {accountId: $accountId, notionId: rel.targetNotionId})
            MERGE (src)-[r:${edgeType}]->(tgt)
              ON CREATE SET r.source = 'notion', r.createdAt = datetime()
            SET r += coalesce(rel.properties, {})
            RETURN count(*) AS matched
          `;
                    const res = await tx.run(cypher, {
                        accountId: params.accountId,
                        relations: rels,
                    });
                    const matchedRaw = res.records[0]?.get("matched");
                    const matched = typeof matchedRaw === "number"
                        ? matchedRaw
                        : matchedRaw?.toNumber?.() ?? 0;
                    const created = res.summary.counters.updates().relationshipsCreated;
                    relationsCreated += created;
                    relationsMatched += matched - created;
                    skippedUnknownTarget += rels.length - matched;
                }
                return {
                    relationsCreated,
                    relationsMatched,
                    skippedUnknownTarget,
                    skippedUnknownEdgeType,
                };
            });
        },
    };
}
function normaliseNotionRelationRow(row) {
    if (!row.sourceNotionId || !NOTION_ID_RE.test(row.sourceNotionId)) {
        throw new Error(`notion-relations-pass-2 row sourceNotionId="${row.sourceNotionId}" is not a 32-hex Notion UUID.`);
    }
    if (!row.targetNotionId || !NOTION_ID_RE.test(row.targetNotionId)) {
        throw new Error(`notion-relations-pass-2 row targetNotionId="${row.targetNotionId}" is not a 32-hex Notion UUID.`);
    }
    if (!row.edgeType || !row.edgeType.trim()) {
        throw new Error(`notion-relations-pass-2 row edgeType is required (one of ${Array.from(NOTION_RELATION_EDGE_TYPES).join(", ")} — unknown values are counted, not thrown).`);
    }
    return {
        sourceNotionId: row.sourceNotionId.toLowerCase(),
        targetNotionId: row.targetNotionId.toLowerCase(),
        edgeType: row.edgeType,
        properties: row.properties,
    };
}
// ---------------------------------------------------------------------------
function normaliseLinkedinRow(row) {
    if (!row.linkedinUrl || !row.linkedinUrl.trim()) {
        throw new Error("linkedin-connections row missing linkedinUrl (natural key).");
    }
    if (!row.connectedOn || !/^\d{4}-\d{2}-\d{2}$/.test(row.connectedOn)) {
        throw new Error(`linkedin-connections row connectedOn="${row.connectedOn}" is not ISO 8601 (YYYY-MM-DD). Parser must convert "23 Apr 2026" before calling.`);
    }
    return {
        givenName: (row.givenName ?? "").trim(),
        familyName: (row.familyName ?? "").trim(),
        linkedinUrl: row.linkedinUrl.trim(),
        email: row.email && row.email.trim() ? row.email.trim() : null,
        company: row.company && row.company.trim() ? row.company.trim() : null,
        title: row.title && row.title.trim() ? row.title.trim() : null,
        connectedOn: row.connectedOn,
    };
}
//# sourceMappingURL=memory-archive-write.js.map