import { getSession } from "../lib/neo4j.js";
import { embed } from "../lib/embeddings.js";
import { randomUUID } from "node:crypto";
import { notTrashed } from "../../../../../lib/graph-trash/dist/index.js";
import { validateWrite } from "../lib/schema-validator.js";
import { loadEnabledPluginProfileKeys } from "./profile-read.js";
import { withWriteEvent } from "../lib/log-ingest.js";
// Cross-process agreement: these values must match neo4j-store.ts
const INITIAL_CONFIDENCE = 0.5;
const REINFORCEMENT_INCREMENT = 0.15;
const CONTRADICTION_DECREMENT = 0.3;
const CANONICAL_CATEGORIES = new Set([
    "communication",
    "scheduling",
    "decision",
    "workflow",
    "content",
    "interaction",
]);
// categories accepted by profile-update are canonical-six PLUS any
// category contributed by an enabled plugin's PLUGIN.md `profileKeys:`. The
// set is computed per-call from `accountId` so that disabling a plugin
// removes its categories from the accept-list in the same session.
function validCategoriesFor(accountId) {
    const extra = loadEnabledPluginProfileKeys(accountId);
    const result = new Set(CANONICAL_CATEGORIES);
    for (const category of Object.keys(extra))
        result.add(category);
    return result;
}
const VALID_MODES = new Set(["reinforce", "update", "contradict", "merge"]);
// system-managed Person properties that agent input must never
// overwrite. Account-isolation (accountId, userId, scope) and write
// provenance (createdAt, createdByAgent, createdBySession, updatedAt)
// belong to the platform, not the agent. The Cypher MATCH pins the row
// by accountId, but `SET p += $fields` would still overwrite that
// property if accountId appears in the map — silently breaking account
// isolation for any future read keyed on the property. Defence in depth.
const PERSON_SYSTEM_MANAGED_PROPS = new Set([
    "accountId",
    "userId",
    "scope",
    "embedding",
    "createdAt",
    "updatedAt",
    "createdByAgent",
    "createdByTool",
    "createdBySession",
]);
export async function profileUpdate(params) {
    return withWriteEvent({ tool: "profile-update", accountId: params.accountId, labels: ["Preference"], name: `${params.category}.${params.key}` }, () => profileUpdateImpl(params), (r) => ({ elementId: r.preferenceId }));
}
async function profileUpdateImpl(params) {
    const { accountId, userId, category, key, value, source = "behavioural", mode = "reinforce", sessionId, profileFields, personFields, mergeSourceIds, notApplicable = false, validator, } = params;
    const validCategories = validCategoriesFor(accountId);
    if (!validCategories.has(category)) {
        throw new Error(`Invalid category "${category}". Must be one of: ${[...validCategories].join(", ")}`);
    }
    if (!VALID_MODES.has(mode)) {
        throw new Error(`Invalid mode "${mode}". Must be one of: ${[...VALID_MODES].join(", ")}`);
    }
    // notApplicable validation. Mutually exclusive with non-empty
    // value; rejects mode merge|contradict (declination is immutable once
    // set — the agent calls profile-delete if the user reverses themselves).
    if (notApplicable === true) {
        if (typeof value === "string" && value.trim().length > 0) {
            throw new Error("profile-update: `notApplicable: true` is mutually exclusive with a non-empty `value` — a declination has no value to record.");
        }
        if (mode === "merge" || mode === "contradict") {
            throw new Error(`profile-update: \`notApplicable: true\` rejects mode "${mode}" — declination is immutable once set. Call profile-delete to reverse.`);
        }
    }
    else if (!value || value.trim().length === 0) {
        throw new Error("Value is required and cannot be empty.");
    }
    if (mode === "merge" && (!mergeSourceIds || mergeSourceIds.length === 0)) {
        throw new Error("mergeSourceIds required when mode is 'merge'.");
    }
    // synchronous personFields hygiene + schema validation. Runs
    // here (before getSession()/embed()) so synonym/forbidden rejection
    // throws without opening a Neo4j session or burning an Ollama call. The
    // resulting `personSafeFields` map is reused below for the SET.
    //
    // Two layers of protection on the input:
    //   1. Validator (synonyms + Forbidden Properties) — semantic enforcement.
    //   2. System-managed-property strip — defends account-isolation and
    //      provenance from LLM-driven tampering. Never accept agent input
    //      that would overwrite accountId, userId, scope, or createdBy*
    //      stamps on the Person; those are env- or system-managed.
    //      Account isolation is doctrine (loud-fail rather than silent
    //      cross-account write).
    let personSafeFields = null;
    if (personFields && Object.keys(personFields).length > 0) {
        const filtered = {};
        for (const [k, v] of Object.entries(personFields)) {
            if (v === null || v === undefined)
                continue;
            if (typeof v === "string" && v.trim().length === 0)
                continue;
            if (PERSON_SYSTEM_MANAGED_PROPS.has(k)) {
                throw new Error(`profile-update personFields: '${k}' is a system-managed Person property — refusing to accept agent input that would overwrite it. Account isolation and provenance stamps are set by the platform, not the agent.`);
            }
            filtered[k] = v;
        }
        if (Object.keys(filtered).length > 0) {
            if (!validator) {
                throw new Error("profile-update: `personFields` requires the schema validator — caller (MCP server) must pass `validator` from the env built at startup.");
            }
            // Throws on synonym (e.g. `phone`) or forbidden (`name`) keys.
            // Person rules live in schema-base.md (always in scope), so the
            // validator's default base-only behaviour is correct here.
            // Validation runs before getSession()/embed() — a synonym or
            // forbidden rejection short-circuits without opening a session or
            // burning an Ollama call. profile-update never writes vertical-
            // specific labels; vertical resolution belongs to memory-write.
            validateWrite({ labels: ["Person"], properties: filtered, accountId }, validator, { mode: "update" });
            personSafeFields = filtered;
        }
    }
    const session = getSession();
    const now = new Date().toISOString();
    try {
        // Compute embedding from preference text representation
        const embeddingText = `${category}: ${key} — ${value}`;
        let embedding = null;
        try {
            embedding = await embed(embeddingText);
        }
        catch (err) {
            // Preference is still written — searchable by property queries but not
            // vector search until reindexed. Task 636: surface the failure (the
            // empty catch hid it) so a standing [embed-audit] non-zero reading has a
            // per-event trail. Grep-consistent with the graph-write embed-net signal.
            process.stderr.write(`[graph-write] warn reason=embed-failed labels=Preference detail=${err instanceof Error ? err.message : String(err)}\n`);
        }
        // Update top-level UserProfile fields if provided
        if (profileFields && Object.keys(profileFields).length > 0) {
            const restricted = new Set([
                "accountId",
                "userId",
                "embedding",
                "createdAt",
                "profileVersion",
            ]);
            const safeFields = {};
            for (const [k, v] of Object.entries(profileFields)) {
                if (!restricted.has(k))
                    safeFields[k] = v;
            }
            if (Object.keys(safeFields).length > 0) {
                await session.run(`
          MATCH (up:UserProfile {accountId: $accountId, userId: $userId})
          SET up += $fields, up.updatedAt = $now
          `, { accountId, userId, fields: safeFields, now });
            }
        }
        // open Person properties for personal-profile elicitation.
        // The closed `{ email?, telephone? }` literal is gone;
        // hygiene + validator both ran synchronously above, so by here
        // `personSafeFields` is either null (nothing to write) or a non-empty
        // map of validator-accepted keys. Writes route via the OWNS edge.
        // The RETURN count guard is mandatory: MATCH-SET against a missing
        // Person silently no-ops (sprint-learning: neo4j-match-set-no-op).
        if (personSafeFields !== null) {
            const personUpdate = await session.run(`
        MATCH (au:AdminUser {userId: $userId})-[:OWNS]->(p:Person {accountId: $accountId})
        SET p += $fields, p.updatedAt = $now
        RETURN count(p) AS affected, collect(elementId(p))[0] AS personElementId
        `, { accountId, userId, fields: personSafeFields, now });
            const affected = personUpdate.records[0]?.get("affected");
            const affectedN = typeof affected === "number" ? affected : (affected?.toNumber?.() ?? 0);
            if (affectedN !== 1) {
                throw new Error(`profile-update personFields: AdminUser-OWNS-Person not found for userId=${userId} accountId=${accountId.slice(0, 8)}… (affected=${affectedN}). Personal-profile Person is created at PIN setup; if this fires the OWNS edge is missing and the operator-identity capture path is broken.`);
            }
            // One log line per Person property set in this call — generic over
            // the open Record, not per-kind. Operators grep
            // `[profile] identity-completed` to see what landed.
            for (const fieldName of Object.keys(personSafeFields)) {
                process.stderr.write(`[profile] identity-completed accountId=${accountId.slice(0, 8)}… field=${fieldName}\n`);
            }
        }
        // Handle merge mode — combines multiple preferences into one
        if (mode === "merge") {
            return await executeMerge(session, accountId, userId, category, key, value, source, embedding, mergeSourceIds, now);
        }
        // Check if preference already exists for this account+category+key.
        // Trashed preferences are skipped — a new live preference can be created
        // while the trashed one awaits empty-trash, and memory-restore handles
        // the unique-key conflict if the user restores the trashed one later.
        const existingResult = await session.run(`
      MATCH (up:UserProfile {accountId: $accountId, userId: $userId})-[:HAS_PREFERENCE]->(pref:Preference {category: $category, key: $key})
      WHERE ${notTrashed("pref")}
      RETURN pref.preferenceId AS preferenceId, pref.confidence AS confidence
      `, { accountId, userId, category, key });
        if (existingResult.records.length > 0) {
            // Existing preference — apply mode
            const existingId = existingResult.records[0].get("preferenceId");
            const existingConfidence = existingResult.records[0].get("confidence");
            let newConfidence;
            let action;
            // notApplicable bypasses the reinforcement curve and forces
            // confidence: 1.0. The action label "updated" matches the existing
            // contract for in-place writes (the agent doesn't need a new label
            // for declination — the notApplicable flag carries the semantic).
            if (notApplicable === true) {
                newConfidence = 1.0;
                action = "updated";
            }
            else {
                switch (mode) {
                    case "reinforce":
                        // Converge toward 1.0: increment scaled by distance from max
                        newConfidence = Math.min(1.0, existingConfidence + REINFORCEMENT_INCREMENT * (1.0 - existingConfidence));
                        action = "reinforced";
                        break;
                    case "update":
                        newConfidence = existingConfidence;
                        action = "updated";
                        break;
                    case "contradict":
                        newConfidence = Math.max(0.0, existingConfidence - CONTRADICTION_DECREMENT);
                        action = "contradicted";
                        break;
                    default:
                        newConfidence = existingConfidence;
                        action = "updated";
                }
            }
            const writeValue = notApplicable === true ? "" : value;
            const updateProps = {
                accountId,
                preferenceId: existingId,
                value: writeValue,
                source,
                confidence: newConfidence,
                observedAt: now,
                updatedAt: now,
                notApplicable: notApplicable === true,
            };
            if (embedding && notApplicable !== true)
                updateProps.embedding = embedding;
            await session.run(`
        MATCH (pref:Preference {preferenceId: $preferenceId, accountId: $accountId})
        SET pref.value = $value,
            pref.source = $source,
            pref.confidence = $confidence,
            pref.observedAt = $observedAt,
            pref.updatedAt = $updatedAt,
            pref.notApplicable = $notApplicable
            ${embedding && notApplicable !== true ? ", pref.embedding = $embedding" : ""}
        `, updateProps);
            if (notApplicable === true) {
                process.stderr.write(`[profile] not-applicable accountId=${accountId.slice(0, 8)}… userId=${userId} field=${category}.${key}\n`);
            }
            // Add evidence link if sessionId provided
            if (sessionId) {
                await session.run(`
          MATCH (pref:Preference {preferenceId: $preferenceId})
          MATCH (conv:Conversation {sessionId: $sessionId})
          MERGE (pref)-[:OBSERVED_IN]->(conv)
          `, { preferenceId: existingId, sessionId });
            }
            return { preferenceId: existingId, action, confidence: newConfidence };
        }
        // New preference — create node and link to UserProfile
        // notApplicable forces confidence: 1.0 and stamps the flag;
        // value is empty (declination has no value to record).
        const preferenceId = randomUUID();
        const createProps = {
            preferenceId,
            accountId,
            userId,
            category,
            key,
            value: notApplicable === true ? "" : value,
            confidence: notApplicable === true ? 1.0 : INITIAL_CONFIDENCE,
            source,
            observedAt: now,
            createdAt: now,
            updatedAt: now,
            scope: "admin",
            notApplicable: notApplicable === true,
        };
        if (embedding && notApplicable !== true)
            createProps.embedding = embedding;
        await session.run(`
      MATCH (up:UserProfile {accountId: $accountId, userId: $userId})
      CREATE (pref:Preference $props)
      CREATE (up)-[:HAS_PREFERENCE]->(pref)
      `, { accountId, userId, props: createProps });
        if (notApplicable === true) {
            process.stderr.write(`[profile] not-applicable accountId=${accountId.slice(0, 8)}… userId=${userId} field=${category}.${key}\n`);
        }
        // Add evidence link if sessionId provided
        if (sessionId) {
            await session.run(`
        MATCH (pref:Preference {preferenceId: $preferenceId})
        MATCH (conv:Conversation {sessionId: $sessionId})
        MERGE (pref)-[:OBSERVED_IN]->(conv)
        `, { preferenceId, sessionId });
        }
        return {
            preferenceId,
            action: "created",
            confidence: notApplicable === true ? 1.0 : INITIAL_CONFIDENCE,
        };
    }
    finally {
        await session.close();
    }
}
async function executeMerge(session, accountId, userId, category, key, value, source, embedding, mergeSourceIds, now) {
    const txc = session.beginTransaction();
    try {
        // Collect evidence links and max confidence from source preferences.
        // Merge only absorbs live sources — a trashed source cannot be re-merged.
        // If the caller passed a trashed sourceId, loud-fail: silently computing
        // a partial merge would produce a merged preference with misleading
        // confidence and evidence drawn from a subset of what the caller asked
        // for (doctrine: "loud failures mandatory").
        const sourcesResult = await txc.run(`
      MATCH (pref:Preference)
      WHERE pref.preferenceId IN $sourceIds AND pref.accountId = $accountId AND ${notTrashed("pref")}
      OPTIONAL MATCH (pref)-[:OBSERVED_IN]->(conv:Conversation) WHERE ${notTrashed("conv")}
      RETURN pref.preferenceId AS id, pref.confidence AS confidence,
             collect(DISTINCT conv.sessionId) AS convIds
      `, { sourceIds: mergeSourceIds, accountId });
        if (sourcesResult.records.length !== mergeSourceIds.length) {
            const foundIds = new Set(sourcesResult.records.map((r) => r.get("id")));
            const missing = mergeSourceIds.filter((id) => !foundIds.has(id));
            throw new Error(`Merge failed: ${missing.length} of ${mergeSourceIds.length} source preference(s) could not be merged (not found, trashed, or belong to a different account): ${missing.join(", ")}`);
        }
        const maxConfidence = sourcesResult.records.reduce((max, r) => Math.max(max, r.get("confidence")), INITIAL_CONFIDENCE);
        const allConvIds = new Set();
        for (const r of sourcesResult.records) {
            for (const id of r.get("convIds")) {
                if (id)
                    allConvIds.add(id);
            }
        }
        // Create the merged preference
        const preferenceId = randomUUID();
        const mergedProps = {
            preferenceId,
            accountId,
            userId,
            category,
            key,
            value,
            confidence: maxConfidence,
            source,
            observedAt: now,
            createdAt: now,
            updatedAt: now,
            scope: "admin",
        };
        if (embedding)
            mergedProps.embedding = embedding;
        await txc.run(`
      MATCH (up:UserProfile {accountId: $accountId, userId: $userId})
      CREATE (pref:Preference $props)
      CREATE (up)-[:HAS_PREFERENCE]->(pref)
      `, { accountId, userId, props: mergedProps });
        // Transfer evidence links to the merged preference
        for (const convId of allConvIds) {
            await txc.run(`
        MATCH (pref:Preference {preferenceId: $preferenceId})
        MATCH (conv:Conversation {sessionId: $convId})
        MERGE (pref)-[:OBSERVED_IN]->(conv)
        `, { preferenceId, convId });
        }
        // Remove merged-away source preferences. Hard-delete is deliberate
        //: merge absorbs the sources into the new preference, so
        // they have no independent identity to restore — resurrecting them
        // would duplicate facts in profile-read. The user-facing delete path
        // (profile-delete) does soft-delete via trashNode.
        await txc.run(`
      MATCH (pref:Preference)
      WHERE pref.preferenceId IN $sourceIds AND pref.accountId = $accountId
      DETACH DELETE pref
      `, { sourceIds: mergeSourceIds, accountId });
        await txc.commit();
        return {
            preferenceId,
            action: "merged",
            confidence: maxConfidence,
            mergedCount: sourcesResult.records.length,
        };
    }
    catch (err) {
        await txc.rollback();
        throw err;
    }
}
//# sourceMappingURL=profile-update.js.map