/**
 * Shared payload preparation for `create_entity` / `update_entity` across
 * the MCP surface ([src/mcp/tools.ts](../mcp/tools.ts)) and the agent surface
 * ([src/agent/tools/graph-tools.ts](../agent/tools/graph-tools.ts)).
 *
 * Owns:
 *   - Actor identity build (`buildActor`).
 *   - Base-field auto-stamping for create (`stampCreateBaseFields`).
 *   - Person.status synonym normalization (`normalizePersonStatus`).
 *   - Two composers (`prepareCreatePayload`, `prepareUpdatePayload`) that
 *     return ready-to-pass options for `graph.create` / `graph.update`.
 *
 * Does NOT own surface-specific concerns: MCP keeps its allowlist check,
 * `withAgentActivity` chip wiring, `emitEntityChange` broadcast, and rich
 * diagnostics; the agent keeps its content sanitiser, workspace dedup
 * warning, eager-skill permission gate, user_handle bridge checks, and
 * `onMutation` / `onAgentActivity` callbacks.
 *
 * Errors from `graph.update` — including `LiveEditorPendingError` from the
 * Phase 2 server-write live-typing guard — propagate through the composers
 * unchanged; callers map them to their surface's error format.
 */
import { droppedAssetEmbeds } from '../core/embeds.js';
/**
 * Person.status accepts only `current` | `former` per PersonSchema, but
 * real-world payloads from external agents carry many synonyms. Map them
 * to canonical before validation rejects the call. Unknown values fall
 * through unchanged so validation surfaces them loudly.
 *
 * On create: defaults missing status to `current`.
 * On update: only normalises when status is supplied — don't fabricate a
 * field the caller didn't ask to change.
 */
export const PERSON_STATUS_ALIASES = {
    current: 'current',
    active: 'current',
    prospective: 'current',
    'prospective-contact': 'current',
    'current-contact': 'current',
    former: 'former',
    'former-contact': 'former',
    inactive: 'former',
    past: 'former',
};
/**
 * Build the git-author / audit-trail identity for an authenticated session.
 * `handle` is the canonical id stamped into `created_by` / `updated_by` and
 * used as the git author; falls back to `user-<id>` if a token from before
 * the handle backfill is still in use.
 *
 * Surfaces that may run without an authenticated user (CLI, server-only
 * automations, tests) should keep their existing orchestrator `GitUser` and
 * pass it directly to the composers — `buildActor` requires an `AuthUser`.
 */
export function buildActor(user) {
    const handle = user.handle || `user-${user.id}`;
    return {
        id: handle,
        name: user.displayName || handle,
        email: `${handle}@studiograph.local`,
    };
}
/**
 * EntityBaseSchema requires entity_id, created_at, updated_at, created_by,
 * and updated_by. External callers (Claude Desktop, Cursor, the agent)
 * typically pass only type-specific data plus the top-level entity_id, so
 * the helper seeds the rest rather than forcing every caller to know the
 * convention. Caller-supplied values for created_at / created_by / etc.
 * win; entity_id is always overwritten to match the top-level arg (it is
 * the lookup key and cannot diverge from the filename).
 */
export function stampCreateBaseFields(data, entityId, actorHandle) {
    const now = new Date().toISOString();
    return {
        created_at: now,
        updated_at: now,
        created_by: actorHandle,
        updated_by: actorHandle,
        ...data,
        entity_id: entityId,
    };
}
export function normalizePersonStatus(data, isCreate) {
    const raw = data.status;
    if (raw === undefined || raw === null || raw === '') {
        return isCreate ? { ...data, status: 'current' } : data;
    }
    if (typeof raw !== 'string')
        return data;
    const mapped = PERSON_STATUS_ALIASES[raw.toLowerCase()];
    if (!mapped || mapped === raw)
        return data;
    return { ...data, status: mapped };
}
/**
 * Compose the options for `graph.create`. Stamps base fields, mirrors
 * `entity_id` from the top-level arg into frontmatter, normalises
 * `person.status` synonyms, and threads the actor through as the git
 * commit author. The caller still owns access checks, allowlist
 * enforcement, content sanitisation, and broadcast/chip wiring.
 */
export function prepareCreatePayload(input, actor) {
    let data = stampCreateBaseFields(input.data, input.entityId, actor.id);
    if (input.entityType === 'person') {
        data = normalizePersonStatus(data, true);
    }
    return {
        entityType: input.entityType,
        entityId: input.entityId,
        data: data,
        content: input.content,
        folderPath: input.folderPath,
        commitMessage: input.commitMessage,
        user: actor,
    };
}
/**
 * Compose the options for `graph.update`. Mirrors `entity_id` from the
 * top-level arg into frontmatter (defensive against callers that pass a
 * disagreeing value), normalises `person.status` synonyms when status is
 * supplied, and declares `source: 'server-write'` whenever content is
 * present so the body-write path engages the per-doc lock + live-editor
 * guard. `graph.update` re-stamps `updated_at` / `updated_by` itself.
 */
export function prepareUpdatePayload(input, actor) {
    let mergedData;
    if (input.data !== undefined) {
        mergedData = { ...input.data, entity_id: input.entityId };
        if (input.entityType === 'person') {
            mergedData = normalizePersonStatus(mergedData, false);
        }
    }
    return {
        entityType: input.entityType,
        entityId: input.entityId,
        data: mergedData,
        content: input.content,
        source: input.content !== undefined ? 'server-write' : undefined,
        commitMessage: input.commitMessage,
        user: actor,
    };
}
// ── Diagnosed update ─────────────────────────────────────────────────────
//
// commit c6ef9297 hardened MCP update_entity so an agent isn't misled by a
// success envelope when the validator silently drops fields or the supplied
// content matches the existing body. That guard lived only in the MCP
// surface; the internal agent tool returned a generic success message.
// Both surfaces now route through diagnosedUpdate so the contract is
// surface-independent.
/** Fields graph.update stamps on every call — they mutate even on a
 *  content-only update, so they're excluded from the semantic diff that
 *  decides "did the caller's intent actually land?" */
const AUTO_STAMPED_FIELDS = new Set(['updated_at', 'updated_by']);
function semanticFrontmatter(data) {
    return Object.fromEntries(Object.entries(data).filter(([k]) => !AUTO_STAMPED_FIELDS.has(k)));
}
function deepEqual(a, b) {
    if (a === b)
        return true;
    if (a === null || b === null)
        return false;
    if (typeof a !== typeof b)
        return false;
    if (typeof a !== 'object')
        return false;
    if (Array.isArray(a) !== Array.isArray(b))
        return false;
    if (Array.isArray(a)) {
        const bArr = b;
        if (a.length !== bArr.length)
            return false;
        return a.every((v, i) => deepEqual(v, bArr[i]));
    }
    const aObj = a;
    const bObj = b;
    const aKeys = Object.keys(aObj);
    const bKeys = Object.keys(bObj);
    if (aKeys.length !== bKeys.length)
        return false;
    return aKeys.every(k => deepEqual(aObj[k], bObj[k]));
}
/**
 * Thrown when an update wrote but produced no on-disk diff despite the
 * caller intending a change — i.e. the validator dropped unknown fields or
 * the supplied content matched the existing body. Carries the entity coords
 * and which inputs were ignored so callers can map it to their surface's
 * failure envelope (MCP `fail`, agent `{ success: false }`).
 */
export class SilentDropError extends Error {
    entityType;
    entityId;
    dropped;
    constructor(entityType, entityId, dropped) {
        super(`update_entity wrote to ${entityType}/${entityId} but the file is byte-identical to its prior state. ` +
            `Inputs that did not produce a diff: ${dropped.join(', ')}. ` +
            `This usually means the schema validator silently dropped unknown fields or the supplied content matched the existing body.`);
        this.entityType = entityType;
        this.entityId = entityId;
        this.dropped = dropped;
        this.name = 'SilentDropError';
    }
}
/**
 * Thrown when a whole-body `update_entity` would remove embedded asset
 * references (image/attachment embeds: `![[Foo.png]]` transclusions or
 * `![alt](/api/assets/…)` markdown images) that are present in the entity's
 * CURRENT body but absent from the submitted content.
 *
 * This is the guard for the silent-image-loss incident: `get_entity` reads
 * the last-persisted disk body, which can lag the live editor (a user's
 * freshly-placed image lives in the Y.Doc before autosave commits it). An
 * agent that fetched stale content, edited text, and resent the whole body
 * would drop those embeds — and because asset binaries live outside git,
 * the lost references aren't recoverable from history. We refuse the write
 * (no disk mutation happens) unless the caller explicitly opts in.
 *
 * Carries the dropped references so callers can map it to their surface's
 * conflict envelope and tell the agent exactly what to preserve.
 */
export class DroppedAssetError extends Error {
    entityType;
    entityId;
    dropped;
    constructor(entityType, entityId, dropped) {
        super(`update_entity would remove ${dropped.length} embedded asset reference(s) from ${entityType}/${entityId} ` +
            `that are present in the current document but missing from the submitted body: ${dropped.join(', ')}. ` +
            `Whole-body writes replace the entire body, so these images/attachments would be unlinked — and asset binaries ` +
            `are not versioned in git, so the references cannot be recovered from history. ` +
            `Re-fetch the entry with get_entity (which now returns live editor content), keep the embed lines you didn't ` +
            `mean to touch, and resubmit. To delete them deliberately, set allow_image_removal: true (MCP) / ` +
            `allowImageRemoval: true (agent). For small text edits like renaming a heading, prefer patch_entity, which ` +
            `never rewrites the whole body.`);
        this.entityType = entityType;
        this.entityId = entityId;
        this.dropped = dropped;
        this.name = 'DroppedAssetError';
    }
}
/**
 * Run graph.update with the c6ef9297 diagnostic contract: snapshot before,
 * update, then report whether frontmatter / content actually changed.
 * Throws {@link SilentDropError} when the caller intended a change that
 * produced no diff. Captures the body-write broadcast disposition.
 *
 * `inputs` are the caller's RAW intent (pre-payload-prep) — used only to
 * decide whether a no-op was actually a silent drop. graph.get may throw
 * (entity not found); callers' existing try/catch surfaces it.
 */
export async function diagnosedUpdate(graph, options, inputs, guard) {
    const before = graph.get(options.entityType, options.entityId);
    const beforeFrontmatter = semanticFrontmatter(before.data);
    const beforeContent = before.document.content;
    // Asset-drop guard (pre-write — nothing is mutated if this throws). A
    // whole-body content rewrite must not silently drop image/attachment
    // embeds that the CURRENT document carries. "Current" prefers the live
    // editor body over disk: the user's freshly-placed image may be in the
    // Y.Doc but not yet committed, and `before` (disk) wouldn't show it. The
    // live-editor write guard (LiveEditorPendingError) only fires when a
    // client is connected; this catches the closed-tab / evicted-doc case and
    // the agent-edited-stale-content case that produced the incident.
    if (options.content !== undefined && !guard?.allowAssetRemoval) {
        const currentBody = graph.getLiveBody?.(options.entityType, options.entityId) ?? beforeContent;
        const dropped = droppedAssetEmbeds(currentBody, options.content);
        if (dropped.length > 0) {
            throw new DroppedAssetError(options.entityType, options.entityId, dropped);
        }
    }
    let broadcast = null;
    options.onBroadcast = (b) => { broadcast = b; };
    const entity = await graph.update(options);
    const afterFrontmatter = semanticFrontmatter(entity.data);
    const afterContent = entity.document.content;
    const frontmatter_changed = !deepEqual(beforeFrontmatter, afterFrontmatter);
    const content_changed = beforeContent !== afterContent;
    const noop = !frontmatter_changed && !content_changed;
    if (noop) {
        const intendedDataChange = inputs.data !== undefined &&
            Object.keys(inputs.data).some(k => {
                if (AUTO_STAMPED_FIELDS.has(k))
                    return false;
                return !deepEqual(inputs.data[k], before.data[k]);
            });
        const intendedContentChange = inputs.content !== undefined && inputs.content !== beforeContent;
        if (intendedDataChange || intendedContentChange) {
            const dropped = [];
            if (intendedDataChange)
                dropped.push('data');
            if (intendedContentChange)
                dropped.push('content');
            throw new SilentDropError(entity.entityType, entity.id, dropped);
        }
    }
    return { entity, frontmatter_changed, content_changed, noop, broadcast };
}
//# sourceMappingURL=entity-mutations.js.map