import { getSession } from "../lib/neo4j.js";
import { embed } from "../lib/embeddings.js";
import { buildTextRepresentation } from "./memory-write.js";
import { checkGraphWriteGate, checkLabelWriteGate, checkPropertyWriteGate, } from "../lib/graph-write-gate.js";
import { withWriteEvent, pickName } from "../lib/log-ingest.js";
import { REVISION_CYPHER_FRAGMENT_UNWIND, } from "../lib/compiled-truth-revision.js";
/** Stripped silently — operator can't update these via the agent. */
const RESTRICTED_KEYS = new Set(["embedding", "accountId", "createdAt", "createdOn"]);
/**
 * Compiled-truth fields are written only by the `compiled-truth-rewriter`
 * specialist (Task 424 replaced the server-side rewriter with a dispatched
 * specialist). Direct writes via the properties bag are refused unless the
 * caller is that specialist — gated by `MAXY_SPECIALIST` env which the
 * pty-spawner stamps on every specialist subprocess.
 */
const SYSTEM_MANAGED_KEYS = new Set([
    "compiledTruth",
    "compiledTruthUpdatedAt",
    "compiledTruthSource",
    "compiledTruthPublic",
    "compiledTruthPublicUpdatedAt",
    "compiledTruthPublicSource",
]);
function isCompiledTruthRewriter() {
    return process.env.MAXY_SPECIALIST === "compiled-truth-rewriter";
}
export async function memoryUpdate(params) {
    const nameHint = pickName(params.properties);
    return withWriteEvent({ tool: "memory-update", accountId: params.accountId, name: nameHint }, () => memoryUpdateImpl(params), (r) => ({ elementId: r.nodeId, labels: r.labels, name: nameHint }));
}
async function memoryUpdateImpl(params) {
    const { nodeId, properties, accountId } = params;
    // System-managed keys (compiled-truth set) are gated. The compiled-truth-rewriter
    // specialist is the only sanctioned writer — Task 424. Other callers see a loud
    // refusal directing them to dispatch the specialist.
    const rewriterCaller = isCompiledTruthRewriter();
    if (!rewriterCaller) {
        for (const key of Object.keys(properties)) {
            if (SYSTEM_MANAGED_KEYS.has(key)) {
                throw new Error(`Update blocked (system-managed-key): '${key}' cannot be set via the properties bag. ` +
                    `Dispatch the compiled-truth-rewriter specialist via the Task tool to refresh compiled-truth fields.`);
            }
        }
    }
    if (Object.keys(properties).length === 0) {
        throw new Error("No properties to update — provide at least one property.");
    }
    // Property-level cap gate. Stays in place for the specialist too — even the
    // rewriter must respect the COMPILED_TRUTH_MAX_CHARS ceiling.
    const propGate = checkPropertyWriteGate({ properties });
    if (!propGate.ok) {
        throw new Error(`Update blocked (${propGate.reason}): ${propGate.message}`);
    }
    // Filter restricted keys (silently — operator can't update these).
    const safeProps = {};
    for (const [key, value] of Object.entries(properties)) {
        if (!RESTRICTED_KEYS.has(key))
            safeProps[key] = value;
    }
    if (Object.keys(safeProps).length === 0) {
        throw new Error("All provided properties are restricted and cannot be updated.");
    }
    const session = getSession();
    try {
        const labelResult = await session.run(`MATCH (n) WHERE elementId(n) = $nodeId AND n.accountId = $accountId
       RETURN labels(n) AS nodeLabels, n.name AS name, n.sliceToken AS sliceToken`, { nodeId, accountId });
        if (labelResult.records.length === 0) {
            throw new Error(`Node not found or not owned by this account (ID: ${nodeId}).`);
        }
        const targetLabels = labelResult.records[0].get("nodeLabels");
        const targetSliceToken = labelResult.records[0].get("sliceToken");
        // Task 485 — when the caller runs under a reviewer dispatch, refuse any
        // update whose target sliceToken does not match the env var. The check
        // is loud (throws) and logs every refusal — every occurrence is a
        // doctrine breach per the brief's observability section. The clearance
        // also fires the success-side observability so a sliceMatch=ok line is
        // always grep-able when the env is set.
        const recorderSliceToken = typeof process.env.RECORDER_SLICE_TOKEN === "string" &&
            process.env.RECORDER_SLICE_TOKEN.length > 0
            ? process.env.RECORDER_SLICE_TOKEN
            : undefined;
        if (recorderSliceToken) {
            const targetSlice = typeof targetSliceToken === "string" ? targetSliceToken : "";
            if (targetSlice !== recorderSliceToken) {
                process.stderr.write(`[memory-update] sliceMatch=mismatch-refused ` +
                    `targetSliceToken=${targetSlice ? targetSlice.slice(0, 8) : "absent"} ` +
                    `envSliceToken=${recorderSliceToken.slice(0, 8)} nodeId=${nodeId}\n`);
                throw new Error(`Update blocked (slice-mismatch): node sliceToken does not match RECORDER_SLICE_TOKEN. ` +
                    `This is a doctrine breach — never retry; investigate the dispatch.`);
            }
            process.stderr.write(`[memory-update] sliceMatch=ok ` +
                `targetSliceToken=${targetSlice.slice(0, 8)} envSliceToken=${recorderSliceToken.slice(0, 8)}\n`);
        }
        // Task 448: label-origin gate is create-only (memory-write). Property
        // updates against the existing :LocalBusiness root are how the operator
        // fills in business identity (name, vertical, description), and are
        // already guarded by checkLabelWriteGate + checkGraphWriteGate +
        // property restriction below.
        // Task 391: refuse updates against read-only audit labels
        // (e.g. :CompiledTruthRevision).
        const labelGate = checkLabelWriteGate({ labels: targetLabels });
        if (!labelGate.ok) {
            throw new Error(`Update blocked (${labelGate.reason}): ${labelGate.message}`);
        }
        const gate = await checkGraphWriteGate({ session, accountId, labels: targetLabels });
        if (!gate.ok) {
            throw new Error(`Update blocked (${gate.reason}): ${gate.message}`);
        }
        // When the compiled-truth-rewriter specialist writes a compiledTruth /
        // compiledTruthPublic field, stamp the matching *UpdatedAt + *Source values
        // automatically so the audit trail stays consistent — the specialist
        // shouldn't have to remember the housekeeping fields.
        const stamped = { ...safeProps };
        const revisionRows = [];
        if (rewriterCaller) {
            const now = new Date().toISOString();
            if ("compiledTruth" in stamped) {
                stamped.compiledTruthUpdatedAt = now;
                if (!("compiledTruthSource" in stamped))
                    stamped.compiledTruthSource = "rewriter";
                revisionRows.push({
                    truth: String(stamped.compiledTruth ?? ""),
                    twin: "compiledTruth",
                    source: stamped.compiledTruthSource === "operator" ? "operator" : "rewriter",
                });
            }
            if ("compiledTruthPublic" in stamped) {
                stamped.compiledTruthPublicUpdatedAt = now;
                if (!("compiledTruthPublicSource" in stamped))
                    stamped.compiledTruthPublicSource = "rewriter";
                revisionRows.push({
                    truth: String(stamped.compiledTruthPublic ?? ""),
                    twin: "compiledTruthPublic",
                    source: stamped.compiledTruthPublicSource === "operator" ? "operator" : "rewriter",
                });
            }
        }
        // Task 436: when the rewriter writes a twin, the `:CompiledTruthRevision`
        // CREATE rides in the same `session.run` as the live SET so revision
        // history can never desync from the live property. UNWIND-on-empty is a
        // no-op, but we still gate the fragment on revisionRows.length to keep
        // the standard (non-rewriter) update path untouched.
        const revisionClause = revisionRows.length > 0
            ? `
      WITH n
      ${REVISION_CYPHER_FRAGMENT_UNWIND}
      WITH DISTINCT n
        `
            : "";
        const updateQuery = `
      MATCH (n)
      WHERE elementId(n) = $nodeId AND n.accountId = $accountId
      SET n += $props, n.updatedAt = $updatedAt
      ${revisionClause}
      RETURN elementId(n) AS nodeId, labels(n) AS nodeLabels, properties(n) AS allProps
    `;
        const result = await session.run(updateQuery, {
            nodeId,
            accountId,
            updatedAt: new Date().toISOString(),
            props: stamped,
            revisions: revisionRows,
        });
        if (result.records.length === 0) {
            throw new Error(`Node not found or not owned by this account (ID: ${nodeId}).`);
        }
        const record = result.records[0];
        const updatedNodeId = record.get("nodeId");
        const nodeLabels = record.get("nodeLabels");
        const allProps = record.get("allProps");
        // Re-embed from updated properties.
        const propsForEmbed = { ...allProps };
        delete propsForEmbed.embedding;
        delete propsForEmbed.accountId;
        try {
            const textForEmbedding = buildTextRepresentation(nodeLabels, propsForEmbed);
            const embedding = await embed(textForEmbedding);
            await session.run(`MATCH (n) WHERE elementId(n) = $nodeId SET n.embedding = $embedding`, { nodeId: updatedNodeId, embedding });
        }
        catch {
            // Properties were updated successfully — embedding re-computation failed.
            // Stale embedding is acceptable; the node remains searchable with the old vector.
        }
        return {
            nodeId: updatedNodeId,
            labels: nodeLabels,
            updated: true,
        };
    }
    finally {
        await session.close();
    }
}
//# sourceMappingURL=memory-update.js.map