import { getSession } from "../lib/neo4j.js";
import { embed } from "../lib/embeddings.js";
import { validateWrite } from "../lib/schema-validator.js";
import { resolveActiveVertical } from "../lib/resolve-active-vertical.js";
import { checkGraphWriteGate, checkLabelWriteGate, checkPropertyWriteGate, } from "../lib/graph-write-gate.js";
import { extractDatedStatements } from "../lib/timeline-extractor.js";
import { randomUUID } from "node:crypto";
import { checkLabelOriginGate } from "../lib/label-origin-gate.js";
import { withWriteEvent, pickName } from "../lib/log-ingest.js";
import { writeNodeWithEdges, injectConversationProvenance, } from "../../../../../lib/graph-write/dist/index.js";
/** Admin-twin labels carrying `compiledTruth` (Task 306 + 392 extension to :Project, :LocalBusiness). */
const TIMELINE_SUBJECT_LABELS = [
    "Person",
    "Organization",
    "Concept",
    "Project",
    "LocalBusiness",
];
// LEGACY (Task 654) — the `compiledTruthPublic` public-twin subsystem
// (this constant, the six `compiledTruthPublic*` system-managed fields, the
// `compiled-truth-rewriter` producer, the `memory-update` stamping/history, and
// `projectPublicProperties` in memory-search.ts) exists to shape a *public-scope
// graph read*. Task 654 retires that read path (the public agent is toolless and
// the session-end reviewer no longer searches), leaving `compiledTruthPublic`
// written but never read. Retained intentionally — not deleted — in case a
// public-facing compiled-truth surface is wanted again. This comment is the
// standing record of that decision (no teardown task). Do not wire new readers
// without restoring the public-scope search surface first.
/** Public-twin labels carrying `compiledTruthPublic` (Task 392). :Person excluded by design. */
const PUBLIC_TWIN_LABELS = [
    "Organization",
    "Concept",
    "Project",
    "LocalBusiness",
];
/** Default `compiledTruthUpdatedAt` on first write — epoch makes stale-detection trivial. */
const COMPILED_TRUTH_EPOCH = "1970-01-01T00:00:00Z";
/**
 * Loud-fail keys. The six compiled-truth fields are system-managed on both
 * admin and public twins; Task 424 made the `compiled-truth-rewriter`
 * specialist the only sanctioned writer (it writes via memory-update under
 * MAXY_SPECIALIST env gate). First-write defaults stamped in the block below
 * are the other path. A caller supplying any of these keys in the properties
 * bag bypasses the audit chain — refuse loudly.
 */
const SYSTEM_MANAGED_COMPILED_TRUTH_KEYS = [
    "compiledTruth",
    "compiledTruthUpdatedAt",
    "compiledTruthSource",
    "compiledTruthPublic",
    "compiledTruthPublicUpdatedAt",
    "compiledTruthPublicSource",
];
export async function memoryWrite(params) {
    const { labels, properties, accountId, scope, relationships, createdBy, validator, producedByTaskId } = params;
    const nameHint = pickName(properties);
    return withWriteEvent({ tool: "memory-write", accountId, labels, name: nameHint }, () => memoryWriteImpl({ labels, properties, accountId, scope, relationships, createdBy, validator, producedByTaskId }), (r) => ({ elementId: r.nodeId, labels: r.labels, name: nameHint }));
}
async function memoryWriteImpl(params) {
    const { labels, properties, accountId, scope, relationships, createdBy, validator, producedByTaskId } = params;
    if (!scope) {
        throw new Error("scope is required — valid values: 'public', 'shared', 'admin', 'user:{identifier}'");
    }
    // Refuse operator-supplied compiled-truth fields in the properties bag.
    // Defaults stamped below remain the first-write path; mutations land via
    // memory-update under MAXY_SPECIALIST=compiled-truth-rewriter (Task 424).
    for (const key of SYSTEM_MANAGED_COMPILED_TRUTH_KEYS) {
        if (key in properties) {
            throw new Error(`Write blocked (system-managed-key): '${key}' cannot be set via the properties bag. ` +
                `Compiled-truth fields are written only by the compiled-truth-rewriter specialist — dispatch it via the Task tool.`);
        }
    }
    const session = getSession();
    const labelStr = labels.map((l) => `\`${l}\``).join(":");
    try {
        // Resolve the active vertical for this write before validation, on the
        // same session that performs the write. A LocalBusiness with
        // `businessType` overrides the brand-declared default; otherwise the
        // brand vertical applies. The validator scopes rules to base + the
        // resolved vertical.
        const verticalContext = await resolveActiveVertical({
            session,
            accountId,
            brandVertical: validator.schema.brandVertical,
        });
        // Schema validation runs before embedding so a rejected write does not
        // waste an Ollama call. Errors are logged to stderr then re-thrown so the
        // MCP tool handler can report a descriptive message to the agent and the
        // operator can see the rejection in server.log.
        try {
            validateWrite({ labels, properties, accountId }, validator, { verticalContext });
        }
        catch (err) {
            console.error(`memory-write: schema validation rejected [${labels.join(", ")}] for account ${accountId ?? "<none>"}: ${err instanceof Error ? err.message : String(err)}`);
            throw err;
        }
        // Task 350: refuse agent-origin LocalBusiness writes. The installer's
        // seed-neo4j.sh writes the single root via raw cypher (system origin)
        // and never goes through this tool, so the seed path is unaffected.
        const origin = checkLabelOriginGate({ labels, createdBy, accountId, tool: "memory-write" });
        if (!origin.ok) {
            throw new Error(`Write blocked (${origin.reason}): ${origin.message}`);
        }
        // precondition: AdminUser + LocalBusiness must exist for the
        // account before any non-exempt node is written. Exempt labels (Person,
        // LocalBusiness, AdminUser) pass through so the bootstrap writes the
        // agent needs to run `business-profile` are not themselves blocked.
        if (accountId) {
            const gate = await checkGraphWriteGate({ session, accountId, labels });
            if (!gate.ok) {
                throw new Error(`Write blocked (${gate.reason}): ${gate.message}`);
            }
        }
        // Property-level gate (Task 306). Refuse a write whose
        // `compiledTruth` exceeds COMPILED_TRUTH_MAX_CHARS — this protects against
        // operators / agents pasting in long bodies as compiled truth.
        const propGate = checkPropertyWriteGate({ properties });
        if (!propGate.ok) {
            throw new Error(`Write blocked (${propGate.reason}): ${propGate.message}`);
        }
        // Label-level gate (Task 391). Refuse direct writes to read-only
        // audit labels (e.g. :CompiledTruthRevision) — these are owned by
        // the rewriter + dream-cycle, never the agent.
        const labelGate = checkLabelWriteGate({ labels });
        if (!labelGate.ok) {
            throw new Error(`Write blocked (${labelGate.reason}): ${labelGate.message}`);
        }
        const textForEmbedding = buildTextRepresentation(labels, properties);
        const embedding = await embed(textForEmbedding);
        const nodeProps = {
            ...properties,
            accountId,
            scope,
            embedding,
            updatedAt: new Date().toISOString(),
        };
        if (!properties.createdOn && !properties.createdAt) {
            nodeProps.createdAt = new Date().toISOString();
        }
        // Task 485 — when the writer runs under a reviewer dispatch, every node
        // it creates is stamped with the visitor's sliceToken and forced to
        // scope `"user"`. The env var is the single source of truth (the
        // reviewer agent / database-operator both inherit it). The forced
        // scope override defends against an agent-supplied `scope: "public"`
        // leaking the user-scoped row into other visitors' reads.
        const recorderSliceToken = typeof process.env.RECORDER_SLICE_TOKEN === "string" &&
            process.env.RECORDER_SLICE_TOKEN.length > 0
            ? process.env.RECORDER_SLICE_TOKEN
            : undefined;
        if (recorderSliceToken) {
            nodeProps.sliceToken = recorderSliceToken;
            nodeProps.scope = "user";
        }
        process.stderr.write(`[memory-write] sliceStamp=${recorderSliceToken ? recorderSliceToken.slice(0, 8) : "none"} ` +
            `nodeLabels=${labels.join(",")} scope=${String(nodeProps.scope)}\n`);
        // Task 306 + 392: stamp compiled-truth defaults on the five admin-twin
        // labels that carry the compiled-truth + timeline doctrine. New nodes
        // start with an empty compiledTruth and an epoch updatedAt so
        // stale-detection queries treat them as eligible for the next rewriter
        // pass. `compiledTruthSource = "rewriter"` is the default writer; the
        // operator override flips it to "operator".
        //
        // Task 392: also stamp public-twin defaults on the four public-twin
        // labels. :Person is in the admin set but NOT the public set — the
        // public twin remains undefined on Person nodes so the graph projection
        // can detect "this label never carries a public summary" structurally.
        const isCompiledTruthSubject = TIMELINE_SUBJECT_LABELS.some((l) => labels.includes(l));
        if (isCompiledTruthSubject) {
            if (nodeProps.compiledTruth === undefined)
                nodeProps.compiledTruth = "";
            if (nodeProps.compiledTruthUpdatedAt === undefined)
                nodeProps.compiledTruthUpdatedAt = COMPILED_TRUTH_EPOCH;
            if (nodeProps.compiledTruthSource === undefined)
                nodeProps.compiledTruthSource = "rewriter";
        }
        const isPublicTwinSubject = PUBLIC_TWIN_LABELS.some((l) => labels.includes(l));
        if (isPublicTwinSubject) {
            if (nodeProps.compiledTruthPublic === undefined)
                nodeProps.compiledTruthPublic = "";
            if (nodeProps.compiledTruthPublicUpdatedAt === undefined)
                nodeProps.compiledTruthPublicUpdatedAt = COMPILED_TRUTH_EPOCH;
            if (nodeProps.compiledTruthPublicSource === undefined)
                nodeProps.compiledTruthPublicSource = "rewriter";
        }
        // compose the inbound `:PRODUCED` edge from the active
        // Task before delegating to the gate. We resolve the Task here (not
        // in the gate) so we can verify accountId parity and emit a clear
        // error before the write transaction starts. Mismatched accountId is
        // a doctrine violation (account isolation is absolute) — fail loud.
        let resolvedRelationships = relationships;
        if (producedByTaskId) {
            const taskRes = await session.run(`MATCH (t:Task) WHERE elementId(t) = $taskId RETURN t.accountId AS accountId LIMIT 1`, { taskId: producedByTaskId });
            if (taskRes.records.length === 0) {
                throw new Error(`memory-write: producedByTaskId '${producedByTaskId}' did not resolve to a :Task node`);
            }
            const taskAccountId = taskRes.records[0].get("accountId");
            if (accountId && taskAccountId !== accountId) {
                throw new Error(`memory-write: producedByTaskId Task.accountId='${taskAccountId}' does not match write accountId='${accountId}' (account isolation violation)`);
            }
            resolvedRelationships = [
                ...relationships,
                {
                    type: "PRODUCED",
                    direction: "incoming",
                    targetNodeId: producedByTaskId,
                },
            ];
        }
        else {
            // direct-ask fallback. When the caller didn't pass an
            // explicit `producedByTaskId` (typical of direct admin asks: "add
            // Anneke as person"), the admin server's `SESSION_NODE_ID`
            // env-stamp covers the gate by injecting a PRODUCED edge from the
            // active :AdminConversation. No-op for non-action-provenance label
            // writes and when the env is unset.
            resolvedRelationships = await injectConversationProvenance({
                session,
                relationships,
                accountId: accountId ?? "",
                writeLabels: labels,
                conversationNodeId: process.env.SESSION_NODE_ID,
                logNamespace: "mcp:memory",
                tool: "memory-write",
            });
        }
        const result = await writeNodeWithEdges({
            session,
            labels,
            props: nodeProps,
            relationships: resolvedRelationships,
            createdBy,
        });
        // Task 306: extract dated statements from the body of compiled-truth
        // subjects and path-anchor-MERGE :TimelineEvent rows. Strictly deterministic
        // here; the 305 typed-edge hook upgrades `kind` from "mentioned" to a
        // more specific value asynchronously. Failures here MUST NOT roll back
        // the entity write — the node persisting with an empty timeline is the
        // right degradation mode; a rewriter call on demand still works.
        if (isCompiledTruthSubject && typeof properties.body === "string" && accountId) {
            try {
                // Task 390: reference timestamp anchors relative date forms
                // ("yesterday", "two weeks ago", "Q3 last year"). Use the
                // operator-supplied createdAt when present; otherwise the
                // createdAt we just stamped on nodeProps; otherwise now.
                const refRaw = (typeof properties.createdAt === "string" && properties.createdAt) ||
                    (typeof properties.createdOn === "string" && properties.createdOn) ||
                    (typeof nodeProps.createdAt === "string" && nodeProps.createdAt) ||
                    undefined;
                const reference = refRaw ? new Date(refRaw) : new Date();
                const statements = extractDatedStatements(properties.body, Number.isNaN(reference.getTime()) ? new Date() : reference);
                if (statements.length > 0) {
                    for (const s of statements) {
                        await session.run(`MATCH (e) WHERE elementId(e) = $entityId AND e.accountId = $accountId
               MERGE (e)-[:HAS_TIMELINE_EVENT]->(t:TimelineEvent {timelineEventId: $id})
                 ON CREATE SET t.accountId = $accountId,
                               t.occurredAt = datetime($occurredAt),
                               t.kind = $kind,
                               t.summary = $summary,
                               t.source = $source,
                               t.matched = $matched,
                               t.createdAt = datetime(),
                               t.learnedAt = datetime(),
                               t.scope = $scope
               RETURN elementId(t) AS timelineEventNodeId`, {
                            entityId: result.nodeId,
                            accountId,
                            id: randomUUID(),
                            occurredAt: s.occurredAt,
                            kind: "mentioned",
                            summary: s.summary,
                            source: typeof properties.sourceMessageId === "string"
                                ? properties.sourceMessageId
                                : "memory-write",
                            matched: s.matched,
                            scope,
                        });
                    }
                }
                console.log(`[timeline-extract] nodeId=${result.nodeId.slice(0, 12)} ` +
                    `subject="${properties.name ?? "?"}" dates=${statements.length} kinds=[mentioned×${statements.length}]`);
            }
            catch (err) {
                const msg = err instanceof Error ? err.message : String(err);
                const bodyLen = properties.body.length;
                console.error(`[timeline-extract] error nodeId=${result.nodeId.slice(0, 12)} ` +
                    `body-len=${bodyLen} error="${msg}"`);
                // intentional swallow — entity write succeeded; timeline is opportunistic.
            }
        }
        return { nodeId: result.nodeId, labels: result.labels, created: true };
    }
    catch (error) {
        // Constraint violations include an unusable internal ID (e.g. "Node(219)").
        // Look up the existing node's elementId so the agent can call memory-update.
        if (error instanceof Error &&
            "code" in error &&
            error.code ===
                "Neo.ClientError.Schema.ConstraintValidationFailed") {
            const parsed = parseConstraintProperty(error.message);
            console.error(`memory-write: constraint violation on ${labels.join(":")}` +
                (parsed
                    ? ` — property '${parsed.propName}' = '${parsed.propValue}'`
                    : ` — could not parse property from: ${error.message}`));
            try {
                let lookupQuery;
                let lookupParams;
                if (parsed) {
                    // propName is safe to interpolate — regex constrains it to \w+ characters
                    lookupQuery = `
            MATCH (n:${labelStr})
            WHERE n.accountId = $accountId AND n.\`${parsed.propName}\` = $propValue
            RETURN elementId(n) AS existingId
          `;
                    lookupParams = { accountId, propValue: parsed.propValue };
                }
                else {
                    lookupQuery = `
            MATCH (n:${labelStr})
            WHERE n.accountId = $accountId
            RETURN elementId(n) AS existingId
            LIMIT 1
          `;
                    lookupParams = { accountId };
                }
                const lookupResult = await session.run(lookupQuery, lookupParams);
                if (lookupResult.records.length > 0) {
                    const existingId = lookupResult.records[0].get("existingId");
                    const reason = parsed
                        ? ` — property '${parsed.propName}' = '${parsed.propValue}' is unique.`
                        : " (match may be approximate).";
                    console.error(`memory-write: conflicting node elementId: ${existingId}`);
                    throw new Error(`Node already exists (elementId: ${existingId})${reason} Use memory-update with this nodeId to modify it.`);
                }
            }
            catch (lookupError) {
                // If the lookup itself produced our actionable error, propagate it.
                if (lookupError instanceof Error &&
                    lookupError.message.startsWith("Node already exists")) {
                    throw lookupError;
                }
                console.error("memory-write: lookup for conflicting node failed:", lookupError);
                // Lookup failed — fall through to re-throw the original constraint error.
            }
        }
        throw error;
    }
    finally {
        await session.close();
    }
}
/** Parse the violated property name and value from a Neo4j constraint error message. */
export function parseConstraintProperty(message) {
    // Neo4j format: "... property `name` = 'value'" or "... property 'name' = 'value'"
    const match = message.match(/property [`'](\w+)[`'] = '([^']*)'/);
    if (!match)
        return null;
    return { propName: match[1], propValue: match[2] };
}
// Embedding-text bounds. A single large `body`/`text`/`compiledTruth` prop
// could otherwise produce a multi-KB representation that exceeds the embed
// model's input limit. Cap each value (so one huge prop cannot crowd out the
// rest) and the total (the final backstop is embeddings.ts' EMBED_INPUT_MAX_CHARS).
const REPR_PER_VALUE_MAX = 2000;
const REPR_TOTAL_MAX = 8000;
export function buildTextRepresentation(labels, properties) {
    const parts = [`[${labels.join(", ")}]`];
    for (const [key, value] of Object.entries(properties)) {
        if (value !== null && value !== undefined && key !== "embedding") {
            const v = String(value);
            parts.push(`${key}: ${v.length > REPR_PER_VALUE_MAX ? v.slice(0, REPR_PER_VALUE_MAX) : v}`);
        }
    }
    const joined = parts.join(" | ");
    return joined.length > REPR_TOTAL_MAX ? joined.slice(0, REPR_TOTAL_MAX) : joined;
}
//# sourceMappingURL=memory-write.js.map