import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const PACKAGE_ROOT = resolve(__dirname, "..", "..", "..");
const INGEST_PATH = resolve(PACKAGE_ROOT, "src/tools/memory-ingest.ts");
function readIngest() {
    return readFileSync(INGEST_PATH, "utf-8");
}
// ---------------------------------------------------------------------------
// memory-ingest collapses two writer paths under one tool surface. The
// dispatcher picks the conversation-archive path when `conversationIdentity`
// is set (MERGEs `:ConversationArchive` — Task 397); otherwise it runs the
// document path keyed on `attachmentId` (MERGEs `:KnowledgeDocument`). Both
// paths attach `:Section` children.
//
// Conversation-archive MERGE keyed on `conversationIdentity`, cleans up
// only THIS export's chunks (by `archiveSha256`), extends the existing NEXT
// chain from its tail (restricted to this parent's sections), and MERGEs
// :PARTICIPANT_IN edges off the parent.
//
// Document-mode behaviour is unchanged (anchor edges, related entities,
// documentEdges, orphan candidates all preserved).
//
// Tests assert the contract by reading source — same pattern as the
// memory-archive-write doctrine tests in this directory. Live Cypher is
// exercised by the operator-run integration test on the laptop.
// ---------------------------------------------------------------------------
describe("memory-ingest conversation-archive contract", () => {
    it("declares mutually exclusive `attachmentId` / `conversationIdentity` parameters on IngestParams", () => {
        const source = readIngest();
        expect(source).toMatch(/attachmentId\?:\s*string/);
        expect(source).toMatch(/conversationIdentity\?:\s*string/);
    });
    it("dispatches to the conversation-archive branch when `conversationIdentity` is set", () => {
        const source = readIngest();
        expect(source).toMatch(/if\s*\(\s*params\.conversationIdentity\s*\)/);
        expect(source).toMatch(/ingestConversationDocument\s*\(/);
    });
    it("rejects callers that set both or neither identity key", () => {
        const source = readIngest();
        expect(source).toMatch(/mutually exclusive/);
        expect(source).toMatch(/exactly one of/);
    });
    it("MERGEs the conversation-archive parent on `:ConversationArchive { conversationIdentity }`", () => {
        expect(readIngest()).toMatch(/MERGE\s*\(\s*a:ConversationArchive\s*\{\s*conversationIdentity:\s*\$conversationIdentity\s*\}\s*\)/);
    });
    it("scopes cleanup by archiveSha256 against the plain `:Section` child (delta discipline — prior exports' chunks survive)", () => {
        expect(readIngest()).toMatch(/\(c:Section\s*\{\s*archiveSha256:\s*\$archiveSha256\s*\}\s*\)/);
    });
    it("restricts the NEXT-chain tail lookup to THIS parent's :Section children", () => {
        expect(readIngest()).toMatch(/HAS_SECTION\]->\(c:Section\)\s*WHERE NOT EXISTS\s*\{[\s\S]*?\(c\)-\[:NEXT\]->\(:Section\)/);
    });
    it("MERGEs :PARTICIPANT_IN edges from operator-confirmed participants to the conversation-archive parent", () => {
        expect(readIngest()).toMatch(/MERGE\s*\(p\)-\[r:PARTICIPANT_IN\]->\(a\)/);
    });
    it("records lastIngestedMessageHash + lastIngestedMessageAt on the parent (cursor advance, set by the bash orchestrator)", () => {
        const source = readIngest();
        expect(source).toMatch(/a\.lastIngestedMessageHash\s*=\s*\$lastIngestedMessageHash/);
        expect(source).toMatch(/a\.lastIngestedMessageAt\s*=\s*\$lastIngestedMessageAt/);
    });
    it("traps the Cypher silent-no-op pattern explicitly", () => {
        expect(readIngest()).toMatch(/silent no-op/);
    });
    it("revives a :Trashed conversation-archive parent before MERGE so re-ingest never writes under a hidden parent", () => {
        const source = readIngest();
        // The trash-revival lookup scopes by accountId + conversationIdentity
        // and searches both the live property and the snapshot `_trashedKeys`
        // (graph-trash nulls the unique-key property at trash time).
        expect(source).toMatch(/MATCH\s*\(\s*a:ConversationArchive:Trashed\s*\)[\s\S]*?a\.conversationIdentity\s*=\s*\$conversationIdentity/);
        expect(source).toMatch(/_trashedKeys\s+CONTAINS\s+\$conversationIdentity/);
        expect(source).toMatch(/restored trashed ConversationArchive/);
    });
});
describe("memory-ingest — document mode preserved (contract)", () => {
    it("KnowledgeDocument MERGE on attachmentId is unchanged", () => {
        expect(readIngest()).toMatch(/MERGE\s*\(d:KnowledgeDocument\s*\{\s*attachmentId:\s*\$attachmentId\s*\}\s*\)/);
    });
});
// ---------------------------------------------------------------------------
// `source` becomes a closed-enum property on the `:ConversationArchive`
// parent (Task 397) and every `:Section` chunk written on the
// conversation-archive path. Provenance agent stays 'conversation-archive'
// (source-agnostic). Source enum is imported from
// conversation-normalisers/types.ts; memory-ingest validates it at the
// boundary so silent typos cannot reach the index.
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Task 482 — typed-entity-edge writer guard.
//
// `:FOUNDED`, `:WORKS_AT`, `:INVESTED_IN`, `:ADVISES` (and every other
// edge type in `TYPED_EDGE_ALLOWLIST`) must never land on a `:Section`
// node. The classifier opts a related-edge into anchor-routing via
// `edge.targetMode = "anchor"`; the writer then connects the related
// entity to the document anchor (e.g. `:Organization` for a company
// website) instead of the section. The writer rejects loud if a
// classifier ever emits a typed-entity edge whose (source, target)
// labels are not in the allowlist — naming the offending row.
//
// Trigger: BioSymm website ingest on Joel Smalley's install produced
// `(:Person)-[:FOUNDED]->(:Section)` rows targeting the Team section
// instead of the merged BioSymm `:Organization`. Filed by Task 478.
// ---------------------------------------------------------------------------
describe("memory-ingest — typed-entity edge guard (Task 482)", () => {
    it("imports `isAllowedTypedEdge` and `isTypedEntityEdgeType` from the typed-edge schema", () => {
        expect(readIngest()).toMatch(/import\s*\{\s*isAllowedTypedEdge\s*,\s*isTypedEntityEdgeType\s*\}\s*from\s*["']\.\.\/lib\/typed-edge-schema\.js["']/);
    });
    it("defines a `rejectIfDisallowedTypedEdge` helper that gates on `isTypedEntityEdgeType`", () => {
        const source = readIngest();
        expect(source).toMatch(/function\s+rejectIfDisallowedTypedEdge\s*\(/);
        expect(source).toMatch(/if\s*\(\s*!isTypedEntityEdgeType\s*\(\s*edgeType\s*\)\s*\)\s*return/);
        expect(source).toMatch(/if\s*\(\s*isAllowedTypedEdge\s*\(\s*sourceLabel\s*,\s*edgeType\s*,\s*targetLabel\s*\)\s*\)\s*return/);
    });
    it("rejects loud with the offending (:Source)-[:EDGE]->(:Target) triple in the message", () => {
        const source = readIngest();
        expect(source).toMatch(/disallowed typed-entity edge/);
        expect(source).toMatch(/\(:\$\{sourceLabel\}\)-\[:\$\{edgeType\}\]->\(:\$\{targetLabel\}\)/);
        expect(source).toMatch(/not in TYPED_EDGE_ALLOWLIST/);
    });
    it("writeRelatedAndEdge accepts an `anchorNodeId`/`anchorLabel` pair plus the section's own label", () => {
        const source = readIngest();
        expect(source).toMatch(/async function writeRelatedAndEdge\([\s\S]*?fromLabel:\s*string,[\s\S]*?anchorNodeId:\s*string,[\s\S]*?anchorLabel:\s*string,/);
    });
    it("routes the edge endpoint to the anchor when `edge.targetMode === 'anchor'`", () => {
        const source = readIngest();
        expect(source).toMatch(/const targetMode\s*=\s*related\.edge\.targetMode\s*\?\?\s*["']section["']/);
        expect(source).toMatch(/fromEndpointId\s*=\s*targetMode === ["']anchor["']\s*\?\s*anchorNodeId\s*:\s*fromNodeId/);
        expect(source).toMatch(/fromEndpointLabel\s*=\s*targetMode === ["']anchor["']\s*\?\s*anchorLabel\s*:\s*fromLabel/);
    });
    it("invokes the guard inside writeRelatedAndEdge before the CREATE", () => {
        const source = readIngest();
        // The guard call lives between the `writeRelatedNode` resolution and the
        // `CREATE (a)-[edge:...]->(b)` query.
        expect(source).toMatch(/rejectIfDisallowedTypedEdge\s*\(\s*\{\s*sourceLabel\s*,\s*edgeType:\s*related\.edge\.type\s*,\s*targetLabel\s*,/);
    });
    it("invokes the guard inside writeDocumentEdge with KnowledgeDocument as source", () => {
        const source = readIngest();
        expect(source).toMatch(/sourceLabel\s*=\s*docEdge\.direction === ["']outgoing["']\s*\?\s*["']KnowledgeDocument["']\s*:\s*docEdge\.targetKind/);
        expect(source).toMatch(/rejectIfDisallowedTypedEdge\s*\(\s*\{\s*sourceLabel\s*,\s*edgeType:\s*docEdge\.type\s*,\s*targetLabel\s*,/);
    });
    it("both call sites pass the section's own label and anchor info into writeRelatedAndEdge", () => {
        const source = readIngest();
        // Standalone path — section.kind (e.g. 'Project') is the from-label.
        expect(source).toMatch(/writeRelatedAndEdge\(\s*dbSession,\s*standaloneId,\s*section\.kind,/);
        // Section path — from-label is the literal 'Section'.
        expect(source).toMatch(/writeRelatedAndEdge\(\s*dbSession,\s*sectionId,\s*["']Section["'],/);
    });
});
describe("memory-ingest source-agnostic provenance", () => {
    it("declares source: ConversationSource on IngestParams", () => {
        expect(readIngest()).toMatch(/source\??:\s*ConversationSource/);
    });
    it("imports the closed-enum guard from conversation-normalisers/types", () => {
        expect(readIngest()).toMatch(/from\s+["']\.\.\/lib\/conversation-normalisers\/types\.js["']/);
        expect(readIngest()).toMatch(/isConversationSource/);
    });
    it("validates source at the conversation-document entry — loud-fail on missing or invalid", () => {
        expect(readIngest()).toMatch(/!isConversationSource\(source\)/);
        expect(readIngest()).toMatch(/source is required on the conversation-document path/);
    });
    it("uses 'conversation-archive' as the chat-mode provenance agent (source-agnostic)", () => {
        const source = readIngest();
        expect(source).toMatch(/CHAT_PROVENANCE_AGENT\s*=\s*["']conversation-archive["']/);
        expect(source).not.toMatch(/CHAT_PROVENANCE_AGENT\s*=\s*["']whatsapp-import["']/);
    });
    it("threads the dynamic `source` parameter into node + edge stamps (no hardcoded 'whatsapp')", () => {
        const source = readIngest();
        // The legacy `CHAT_PROVENANCE_SOURCE` constant is gone; every chat-branch
        // SET clause that stamps `source` reads from the params now.
        expect(source).not.toMatch(/CHAT_PROVENANCE_SOURCE/);
    });
});
//# sourceMappingURL=memory-ingest.test.js.map