/**
 * 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` — `SilentDropError`, `DroppedAssetError`,
 * `BroadcastDisabledLiveClientsError`, `CommitFailedDirtyError`, etc. —
 * propagate through the composers unchanged; callers map them to their
 * surface's error format. (The old `LiveEditorPendingError` live-typing
 * refusal was retired with live-merge: server-writes now CRDT-merge into the
 * live Y.Doc rather than being blocked.)
 */
import { StaleBaseRevisionError } from '../core/graph.js';
import { droppedAssetEmbeds } from '../core/embeds.js';
import { builtinRefTypeFields } from '../core/schema-registry.js';
import { parseRef } from '../core/refs.js';
import { slugifyWikilink } from './markdown.js';
import { validateArtifactBundle } from '../core/artifact-bundle.js';
import { createHash } from 'crypto';
/**
 * Canonical body-revision hash (SHA-256 hex) — the `baseRevision` precondition
 * format BodyWriteCoordinator checks inside the per-doc lock. Agent write tools
 * (patch_entity, create_artifact) pass `bodyRevisionHash(currentBody)` so a
 * racing concurrent write is rejected (StaleBaseRevisionError) instead of
 * silently clobbered. Matches `sha256OfBody` in graph.ts / body-write-coordinator.
 */
export function bodyRevisionHash(body) {
    return createHash('sha256').update(body, 'utf-8').digest('hex');
}
/**
 * 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 };
}
/** Thrown when a reference field resolves to the wrong entity type (e.g. a
 *  task `assignee` pointing at a deal/org), or a user ref is used where the
 *  field expects a non-person entity. Callers map it to their surface's
 *  error envelope (HTTP 400, MCP fail, agent `{ success: false }`). */
export class EntityRefError extends Error {
    field;
    expectedType;
    actualType;
    target;
    code = 'ASSIGNEE_NOT_PERSON';
    constructor(field, expectedType, actualType, target) {
        super(`Field "${field}" must reference a ${expectedType} entity, but [[${target}]] is a ${actualType}. ` +
            `Point it at a ${expectedType} (a not-yet-created ${expectedType} wikilink is allowed).`);
        this.field = field;
        this.expectedType = expectedType;
        this.actualType = actualType;
        this.target = target;
        this.name = 'EntityRefError';
    }
}
/** Thrown when an account reference (`[[user:<id>]]`) names an account that
 *  doesn't exist in this workspace. Only raised when a {@link UserRefResolver}
 *  is supplied to {@link assertEntityRefs}. */
export class UnknownUserRefError extends Error {
    field;
    userId;
    code = 'UNKNOWN_USER_REF';
    constructor(field, userId) {
        super(`Field "${field}" references account user:${userId}, which is not a user in this workspace.`);
        this.field = field;
        this.userId = userId;
        this.name = 'UnknownUserRefError';
    }
}
/**
 * Enforce `refType` field scoping for an entity create/update payload. For
 * each built-in ref field present on `data`, parse the reference and:
 *   - ENTITY ref: resolve it and throw {@link EntityRefError} when it resolves
 *     to a different entity type. Unresolved (forward-ref) and empty pass.
 *   - USER ref (`[[user:<id>]]`): accepted only where the field expects a
 *     `person` (a user IS a person-identity). With a {@link UserRefResolver}
 *     supplied, a ref to a non-existent account throws {@link UnknownUserRefError}.
 * No-op for types without ref fields (everything except `task` today).
 *
 * `data` may be a partial update payload — only fields actually present are
 * checked, so a content-only or unrelated-field update never trips this.
 */
export function assertEntityRefs(resolver, entityType, data, userResolver) {
    if (!data)
        return;
    const refFields = builtinRefTypeFields(entityType);
    for (const [field, expectedType] of Object.entries(refFields)) {
        if (!(field in data))
            continue;
        const ref = parseRef(data[field]);
        if (!ref)
            continue; // empty / cleared / malformed
        if (ref.kind === 'user') {
            // A user ref stands for the account's identity. It's only valid where the
            // field would otherwise point at a person.
            if (expectedType !== 'person') {
                throw new EntityRefError(field, expectedType, 'user', `user:${ref.userId}`);
            }
            if (userResolver && !userResolver.userExists(ref.userId)) {
                throw new UnknownUserRefError(field, ref.userId);
            }
            continue;
        }
        // Resolve the raw id first, then the slugified form (tolerates
        // Obsidian-style display-name wikilinks). Unresolved → allow (forward ref).
        const found = resolver.findEntityById(ref.id) ?? resolver.findEntityById(slugifyWikilink(ref.id));
        if (!found)
            continue;
        if (found.entityType !== expectedType) {
            throw new EntityRefError(field, expectedType, found.entityType, ref.id);
        }
    }
}
/**
 * Thrown when a write to an `artifact` entry's body isn't a valid ArtifactBundle.
 * Agents kept reaching for create_entity / update_entity / patch_entity with a
 * markdown (or hand-assembled, incomplete) body, which then fails to render on
 * the canvas. The message steers them to the right tool + the exact bundle shape.
 */
export class ArtifactBodyError extends Error {
    reasons;
    constructor(reasons) {
        super('An `artifact` entry stores an ArtifactBundle as JSON in its body — not markdown or prose. '
            + 'Use the create_artifact tool to create or edit artifacts: it assembles and validates the bundle for you '
            + '(to edit, call read_artifact first, then create_artifact with the modified bundle and its hash as baseHash). '
            + 'If you only have raw markdown or prose (e.g. slide text), create a `document` or `deck` entry instead — not an `artifact`. '
            + 'A valid bundle body is a JSON object with: schemaVersion (1); artifactId (a stable id that starts alphanumeric); '
            + 'title; kind (one of html|document|slides|social|app|custom); viewport ({ width, height, mode }); '
            + 'entrypoint (e.g. "index.html"); and files (a map of relative file paths to their text contents, including the entrypoint). '
            + 'For slides use kind:"slides" with a fixed 1920×1080 viewport and index.html + styles.css + slides.js files. '
            + (reasons.length ? `Problems with the body you submitted: ${reasons.join('; ')}.` : ''));
        this.reasons = reasons;
        this.name = 'ArtifactBodyError';
    }
}
/**
 * Guard the `artifact` body on every server-side write (create/update/patch) so
 * a markdown or malformed body never lands on disk to fail at render time.
 * No-op for non-artifact types and for frontmatter-only writes (content omitted).
 * The valid create_artifact path passes a serialized, already-validated bundle,
 * so it sails through untouched.
 */
export function assertValidArtifactBody(entityType, content) {
    if (entityType !== 'artifact' || content === undefined)
        return;
    let parsed;
    try {
        parsed = JSON.parse(content);
    }
    catch {
        throw new ArtifactBodyError(['the body is not valid JSON (it looks like markdown or prose)']);
    }
    const result = validateArtifactBundle(parsed);
    if (!result.ok)
        throw new ArtifactBodyError(result.errors);
}
/**
 * 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) {
    assertValidArtifactBody(input.entityType, input.content);
    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) {
    assertValidArtifactBody(input.entityType, input.content);
    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,
        // Only meaningful for body writes; graph.update ignores it when content
        // is undefined (frontmatter-only edits never need the precondition).
        baseRevision: input.content !== undefined ? input.baseRevision : 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. With
    // live-merge there's no longer a pending-typing refusal, so this guard is
    // the primary protection against an agent's stale whole-body target
    // dropping an embed the live editor holds.
    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 };
}
/** Bound on stale-base retries before surfacing a loud WRITE_CONFLICT rather
 *  than looping forever on a perpetually-moving target. */
const MAX_PATCH_ATTEMPTS = 4;
/**
 * Apply a literal substring find/replace to an entry body under optimistic
 * concurrency, shared by the MCP + in-app patch_entity tools.
 *
 * - Reads the LIVE editor body (falling back to disk) for the find/replace, so
 *   a patch never reintroduces the stale-read problem or drops a user's
 *   freshly-typed/placed content.
 * - Derives `baseRevision` from the DISK body (`entity.document.content`) — the
 *   coordinator checks the precondition against disk, so hashing the live body
 *   would mismatch on every attempt when an editor has unsaved edits and
 *   dead-end the retry loop at a spurious conflict. (Same rule as
 *   `graph.appendDatasetRows`.)
 * - On a stale-base conflict (a concurrent write landed since the read) it
 *   re-reads and re-applies, up to {@link MAX_PATCH_ATTEMPTS}, so sibling
 *   patches fired in one agent turn compose instead of last-write-wins clobber.
 *
 * `DroppedAssetError` and any other error propagate to the caller's existing
 * catch. The caller owns activity chips, broadcast emit, and the envelope.
 */
export async function applyPatchEntity(graph, actor, input) {
    for (let attempt = 0;; attempt++) {
        const entity = graph.get(input.entityType, input.entityId);
        const currentBody = graph.getLiveBody(input.entityType, input.entityId) ?? entity.document.content;
        // Count literal (non-regex) occurrences in the freshly-read body.
        let count = 0;
        for (let i = currentBody.indexOf(input.find); i !== -1; i = currentBody.indexOf(input.find, i + input.find.length))
            count++;
        if (count === 0)
            return { status: 'no_match' };
        if (input.expectedCount !== undefined && count !== input.expectedCount) {
            return { status: 'ambiguous', occurrences: count };
        }
        const newBody = currentBody.split(input.find).join(input.replace);
        const options = prepareUpdatePayload({
            entityType: input.entityType,
            entityId: input.entityId,
            content: newBody,
            baseRevision: bodyRevisionHash(entity.document.content),
            commitMessage: input.commitMessage,
        }, actor);
        try {
            const { entity: updated, frontmatter_changed, content_changed, noop, broadcast } = await diagnosedUpdate(graph, options, { content: newBody });
            return { status: 'applied', entity: updated, occurrences: count, frontmatter_changed, content_changed, noop, broadcast };
        }
        catch (err) {
            // A concurrent write moved the body — re-read and re-apply, up to a bound.
            if (err instanceof StaleBaseRevisionError && attempt + 1 < MAX_PATCH_ATTEMPTS)
                continue;
            if (err instanceof StaleBaseRevisionError)
                return { status: 'conflict', attempts: MAX_PATCH_ATTEMPTS };
            throw err;
        }
    }
}
//# sourceMappingURL=entity-mutations.js.map