/**
 * conversation-archive-derive-insights MCP tool.
 *
 * Chunk-anchored Phase 2 cypher emitter for conversation transcripts
 * (:ConversationArchive keyed on `conversationIdentity` — Task 397).
 *
 * Task 433: the per-chunk LLM step runs in the conversation-archive-enrich
 * skill's specialist turn. This tool no longer walks chunks or calls Haiku.
 * It accepts a single chunk's agent-produced claims, applies the durable
 * rejection sidecar filter, and emits per-kind cypher proposals for the
 * skill's per-row operator gate. Idempotency on (chunkElementId, kind,
 * contentHash) — re-runs collapse on the MERGE keys baked into mergeCypher.
 *
 * Per-row dispatch (the skill executes; this tool emits the cypher):
 *   mention                → (:Section)-[:MENTIONS {contentHash}]->(:Person|:Organization)
 *   task                   → (:Task)-[:REFERENCES {contentHash}]->(:Section)
 *                            (the skill calls mcp__plugin_work_work__work-create first,
 *                             then this REFERENCES edge via raw cypher.)
 *   preference             → MERGE (p:Preference {accountId, contentHash})
 *                            then (p)-[:OBSERVED_IN]->(chunk)
 *   observed-relationship  → (:Person|:Organization)-[:RELATED_TO {contentHash, operatorConfirmed:true}]->(:Person|:Organization)
 */
import { createHash } from "node:crypto";
import { readFileSync, existsSync } from "node:fs";
import { resolve } from "node:path";
import { withWriteEvent } from "../lib/log-ingest.js";
import { getSession } from "../lib/neo4j.js";
import { embed } from "../lib/embeddings.js";
// ---------------------------------------------------------------------------
// durable rejection memory.
//
// Sidecar JSONL is the single source of truth for "operator rejected this
// proposal." `wire`-by-the-same-key clears the rejection (see the rejection
// MCP tool's `undo` mode), so a re-run only re-surfaces claims the operator
// has not already triaged. The rejection tool imports `rejectionsJsonlPath`
// and `rejectionKey` from this module to keep the path/key logic in one place.
// ---------------------------------------------------------------------------
// Read PLATFORM_ROOT lazily so tests can override per-test via
// process.env.PLATFORM_ROOT in a beforeEach hook.
function platformRoot() {
    return (process.env.PLATFORM_ROOT ?? resolve(import.meta.dirname, "../../../.."));
}
export function rejectionsJsonlPath(accountId) {
    return resolve(platformRoot(), "..", "data/accounts", accountId, "state", "conversation-archive-enrich-rejections.jsonl");
}
// Key shape mirrors the MERGE-key the proposals use so a rejection survives
// across re-runs — chunk elementIds are stable, chunk indices are not.
export function rejectionKey(chunkElementId, kind, contentHash) {
    return `${chunkElementId}|${kind}|${contentHash}`;
}
// Parse-tolerant: a malformed line emits `rejection-parse-skip` to stderr
// and is silently dropped. First-run (missing file) returns an empty set.
export function loadRejections(accountId) {
    const set = new Set();
    const path = rejectionsJsonlPath(accountId);
    if (!existsSync(path))
        return set;
    let raw;
    try {
        raw = readFileSync(path, "utf8");
    }
    catch (err) {
        process.stderr.write(`[conversation-archive-enrich] rejection-load-fail accountId=${accountId} reason="${err instanceof Error ? err.message : String(err)}"\n`);
        return set;
    }
    const lines = raw.split("\n");
    for (let i = 0; i < lines.length; i++) {
        const line = lines[i].trim();
        if (!line)
            continue;
        let parsed;
        try {
            parsed = JSON.parse(line);
        }
        catch {
            process.stderr.write(`[conversation-archive-enrich] rejection-parse-skip line=${i + 1}\n`);
            continue;
        }
        if (!parsed || typeof parsed !== "object")
            continue;
        const obj = parsed;
        const chunkElementId = obj.chunkElementId;
        const kind = obj.kind;
        const contentHash = obj.contentHash;
        if (typeof chunkElementId === "string" &&
            typeof kind === "string" &&
            typeof contentHash === "string") {
            set.add(rejectionKey(chunkElementId, kind, contentHash));
        }
    }
    return set;
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const EVIDENCE_SNIPPET_MAX_CHARS = 80;
export const PROPOSAL_KINDS = [
    "mention",
    "task",
    "preference",
    "observed-relationship",
];
// ---------------------------------------------------------------------------
// Chunk membership guard — chunkElementId must belong to the named archive.
// LOUD-FAIL otherwise; never silently emit cypher for a chunk in another archive.
// ---------------------------------------------------------------------------
async function verifyChunkBelongsToArchive(archiveElementId, chunkElementId, accountId) {
    const session = getSession();
    try {
        const result = await session.run(`MATCH (a:ConversationArchive { accountId: $accountId })-[:HAS_SECTION]->(c:Section)
       WHERE elementId(a) = $aid AND elementId(c) = $cid
         AND a.conversationIdentity IS NOT NULL
       RETURN elementId(c) AS eid LIMIT 1`, { aid: archiveElementId, cid: chunkElementId, accountId });
        return result.records.length > 0;
    }
    finally {
        await session.close();
    }
}
// ---------------------------------------------------------------------------
// Per-kind cypher emitters
// ---------------------------------------------------------------------------
function hashContent(kind, parts) {
    const h = createHash("sha256");
    h.update(kind);
    for (const p of parts) {
        h.update(" ");
        h.update(p ?? "");
    }
    return h.digest("hex");
}
function buildMentionProposal(chunkElementId, claim, sessionId) {
    const subject = (claim.subject ?? "").trim();
    if (!subject)
        return null;
    const contentHash = hashContent("mention", [subject, claim.evidenceSnippet]);
    return {
        chunkElementId,
        kind: "mention",
        contentHash,
        evidenceSnippet: claim.evidenceSnippet,
        proposedAction: `MENTIONS edge → ${subject}`,
        mergeCypher: `MATCH (c) WHERE elementId(c) = $chunkElementId
MATCH (t) WHERE elementId(t) = $targetElementId
MERGE (c)-[m:MENTIONS { contentHash: $contentHash }]->(t)
ON CREATE SET m.evidenceSnippet = $evidenceSnippet,
              m.createdAt = datetime(),
              m.createdByTool = 'conversation-archive-derive-insights',
              m.createdBySession = $sessionId
RETURN elementId(m) AS edgeId`,
        mergeParams: {
            chunkElementId,
            contentHash,
            evidenceSnippet: claim.evidenceSnippet,
            sessionId: sessionId ?? null,
        },
        disambiguation: {
            needsResolution: [
                {
                    role: "subject",
                    displayName: subject,
                    proposedLabels: ["Person", "Organization"],
                },
            ],
        },
    };
}
function buildTaskProposal(chunkElementId, claim, sessionId) {
    const payload = claim.payload ?? {};
    const taskTitle = typeof payload.taskTitle === "string" ? payload.taskTitle : "";
    if (!taskTitle.trim())
        return null;
    const taskDueHint = typeof payload.taskDueHint === "string" ? payload.taskDueHint : "";
    const contentHash = hashContent("task", [taskTitle, claim.evidenceSnippet]);
    return {
        chunkElementId,
        kind: "task",
        contentHash,
        evidenceSnippet: claim.evidenceSnippet,
        proposedAction: `Task "${taskTitle}"${taskDueHint ? ` (${taskDueHint})` : ""} REFERENCES this chunk`,
        mergeCypher: `MATCH (c) WHERE elementId(c) = $chunkElementId
MATCH (t:Task) WHERE elementId(t) = $taskElementId
MERGE (t)-[r:REFERENCES { contentHash: $contentHash }]->(c)
ON CREATE SET r.evidenceSnippet = $evidenceSnippet,
              r.createdAt = datetime(),
              r.createdByTool = 'conversation-archive-derive-insights',
              r.createdBySession = $sessionId
RETURN elementId(r) AS edgeId`,
        mergeParams: {
            chunkElementId,
            contentHash,
            evidenceSnippet: claim.evidenceSnippet,
            taskTitle,
            taskDueHint,
            sessionId: sessionId ?? null,
        },
        disambiguation: null,
    };
}
async function buildPreferenceProposal(chunkElementId, claim, accountId, sessionId) {
    const payload = claim.payload ?? {};
    const category = typeof payload.preferenceCategory === "string" ? payload.preferenceCategory : "";
    const key = typeof payload.preferenceKey === "string" ? payload.preferenceKey : "";
    const value = typeof payload.preferenceValue === "string" ? payload.preferenceValue : "";
    if (!category.trim() || !key.trim() || !value.trim())
        return null;
    const contentHash = hashContent("preference", [category, key, value]);
    // Task 636 — :Preference is vector-indexed; embed the proposal so the wired
    // node is findable by vector search at creation, not only after a reindex.
    // A failed embed sets embedding=null (the node stays property-searchable and
    // the next reindex backfills it) and surfaces a warn — never blocks the
    // proposal. (Coordinates with Task 490, which re-keys the dedup MERGE.)
    let embedding = null;
    try {
        embedding = await embed(`${category}: ${key} — ${value}`);
    }
    catch (err) {
        process.stderr.write(`[graph-write] warn reason=embed-failed labels=Preference detail=${err instanceof Error ? err.message : String(err)}\n`);
    }
    return {
        chunkElementId,
        kind: "preference",
        contentHash,
        evidenceSnippet: claim.evidenceSnippet,
        proposedAction: `Preference ${category}/${key} = "${value}" — OBSERVED_IN this chunk`,
        mergeCypher: `MATCH (c) WHERE elementId(c) = $chunkElementId
MERGE (p:Preference { accountId: $accountId, contentHash: $contentHash })
ON CREATE SET p.category = $category,
              p.key = $key,
              p.value = $value,
              p.evidenceSnippet = $evidenceSnippet,
              p.embedding = $embedding,
              p.createdAt = datetime(),
              p.createdByTool = 'conversation-archive-derive-insights',
              p.createdBySession = $sessionId
MERGE (p)-[:OBSERVED_IN]->(c)
RETURN elementId(p) AS preferenceId`,
        mergeParams: {
            chunkElementId,
            accountId,
            contentHash,
            category,
            key,
            value,
            evidenceSnippet: claim.evidenceSnippet,
            embedding,
            sessionId: sessionId ?? null,
        },
        disambiguation: null,
    };
}
function buildObservedRelationshipProposal(chunkElementId, claim, sessionId) {
    const subject = (claim.subject ?? "").trim();
    const object = (claim.object ?? "").trim();
    const payload = claim.payload ?? {};
    const relType = typeof payload.relationshipType === "string" ? payload.relationshipType.trim() : "";
    if (!subject || !object || !relType)
        return null;
    const contentHash = hashContent("observed-relationship", [
        subject,
        object,
        relType,
        claim.evidenceSnippet,
    ]);
    return {
        chunkElementId,
        kind: "observed-relationship",
        contentHash,
        evidenceSnippet: claim.evidenceSnippet,
        proposedAction: `${subject} —${relType}→ ${object} (RELATED_TO, operator-confirmed)`,
        mergeCypher: `MATCH (s) WHERE elementId(s) = $subjectElementId
MATCH (o) WHERE elementId(o) = $objectElementId
MERGE (s)-[r:RELATED_TO { contentHash: $contentHash }]->(o)
ON CREATE SET r.relationshipType = $relationshipType,
              r.operatorConfirmed = true,
              r.evidenceSnippet = $evidenceSnippet,
              r.observedInChunkElementId = $chunkElementId,
              r.createdAt = datetime(),
              r.createdByTool = 'conversation-archive-derive-insights',
              r.createdBySession = $sessionId
RETURN elementId(r) AS edgeId`,
        mergeParams: {
            chunkElementId,
            contentHash,
            relationshipType: relType,
            evidenceSnippet: claim.evidenceSnippet,
            sessionId: sessionId ?? null,
        },
        disambiguation: {
            needsResolution: [
                {
                    role: "subject",
                    displayName: subject,
                    proposedLabels: ["Person", "Organization"],
                },
                {
                    role: "object",
                    displayName: object,
                    proposedLabels: ["Person", "Organization"],
                },
            ],
        },
    };
}
async function buildProposal(chunkElementId, claim, accountId, sessionId) {
    switch (claim.kind) {
        case "mention":
            return buildMentionProposal(chunkElementId, claim, sessionId);
        case "task":
            return buildTaskProposal(chunkElementId, claim, sessionId);
        case "preference":
            return await buildPreferenceProposal(chunkElementId, claim, accountId, sessionId);
        case "observed-relationship":
            return buildObservedRelationshipProposal(chunkElementId, claim, sessionId);
    }
}
function normaliseClaim(raw) {
    if (!raw || typeof raw !== "object")
        return null;
    const obj = raw;
    const kind = obj.kind;
    if (typeof kind !== "string" || !PROPOSAL_KINDS.includes(kind)) {
        return null;
    }
    const evidenceSnippet = typeof obj.evidenceSnippet === "string"
        ? obj.evidenceSnippet.slice(0, EVIDENCE_SNIPPET_MAX_CHARS)
        : "";
    if (!evidenceSnippet)
        return null;
    return {
        kind: kind,
        evidenceSnippet,
        subject: typeof obj.subject === "string" ? obj.subject : null,
        object: typeof obj.object === "string" ? obj.object : null,
        payload: obj.payload && typeof obj.payload === "object"
            ? obj.payload
            : {},
    };
}
// ---------------------------------------------------------------------------
// Public entry — invoked from the MCP server.tool registration in index.ts
// ---------------------------------------------------------------------------
export async function conversationArchiveDeriveInsights(params) {
    return withWriteEvent({
        tool: "conversation-archive-derive-insights",
        accountId: params.accountId,
        labels: ["ConversationArchive"],
        name: params.archiveElementId,
    }, () => conversationArchiveDeriveInsightsImpl(params));
}
async function conversationArchiveDeriveInsightsImpl(params) {
    const { accountId, archiveElementId, chunkElementId, claims, sessionId } = params;
    const belongs = await verifyChunkBelongsToArchive(archiveElementId, chunkElementId, accountId);
    if (!belongs) {
        throw new Error(`[conversation-archive-derive-insights] FAIL phase=chunk-not-in-archive reason="chunkElementId=${chunkElementId} is not a :Section of :ConversationArchive elementId=${archiveElementId} (accountId=${accountId}, or archive lacks conversationIdentity)"`);
    }
    const start = Date.now();
    process.stderr.write(`[conversation-archive-derive-insights] start accountId=${accountId} archiveElementId=${archiveElementId} chunkElementId=${chunkElementId} claims=${claims.length}\n`);
    const rejections = loadRejections(accountId);
    const proposals = [];
    // All valid (normalised + buildable) proposals, including rejected ones.
    // Used for inline-rewrite signal counting so the density signal is not
    // deflated by the operator's rejection history on specific evidence snippets.
    const validProposals = [];
    let rejectionsFiltered = 0;
    let invalidClaims = 0;
    for (const rawClaim of claims) {
        const claim = normaliseClaim(rawClaim);
        if (!claim) {
            invalidClaims += 1;
            continue;
        }
        const proposal = await buildProposal(chunkElementId, claim, accountId, sessionId);
        if (!proposal) {
            invalidClaims += 1;
            continue;
        }
        validProposals.push(proposal);
        if (rejections.has(rejectionKey(proposal.chunkElementId, proposal.kind, proposal.contentHash))) {
            rejectionsFiltered += 1;
            continue;
        }
        proposals.push(proposal);
    }
    // Inline compiled-truth reconciliation signals (opt-in — Task 595).
    // Count mention-claim subjects across ALL valid claims (including
    // operator-rejected ones) so the density signal is not deflated by
    // rejections of specific evidence snippets. A rejection of an evidence
    // snippet does not mean the entity should be excluded from rewrite signals.
    let inlineRewriteSignals;
    if (params.inlineRewrite) {
        const threshold = params.inlineRewriteThreshold ?? 2;
        const counts = {};
        for (const p of validProposals) {
            if (p.kind === "mention" && p.disambiguation) {
                const displayName = p.disambiguation.needsResolution[0]?.displayName ?? "";
                if (displayName)
                    counts[displayName] = (counts[displayName] ?? 0) + 1;
            }
        }
        inlineRewriteSignals = Object.entries(counts)
            .filter(([, count]) => count >= threshold)
            .map(([displayName, mentionCount]) => ({ displayName, mentionCount }));
    }
    process.stderr.write(`[conversation-archive-derive-insights] done accountId=${accountId} archiveElementId=${archiveElementId} chunkElementId=${chunkElementId} proposals=${proposals.length} rejections-filtered=${rejectionsFiltered} invalidClaims=${invalidClaims} ms=${Date.now() - start}\n`);
    return {
        archiveElementId,
        chunkElementId,
        proposals,
        rejectionsFiltered,
        invalidClaims,
        ...(inlineRewriteSignals !== undefined ? { inlineRewriteSignals } : {}),
    };
}
//# sourceMappingURL=conversation-archive-derive-insights.js.map