/**
 * Body-write coordinator — single critical section for entity markdown body writes.
 *
 * Owns the safe path that every server-side surface (autosave PUT, MCP
 * update_entity, agent tools, event processor) shares. The contract:
 *
 *   1. assertWritable gate (server-write only — autosave is the client's
 *      own write and must not be refused). Folded into lock acquisition so
 *      a throwing gate releases the lock cleanly.
 *   2. Per-doc lock — serializes server-side body writes for the same doc.
 *      Inbound client MSG_SYNC frames queue while held (see
 *      yjsManager.withDocLock + msgSyncQueue).
 *   3. Base-hash precondition (autosave 412 path). Read inside the lock so
 *      we see prior locked writes before deciding.
 *   4. Atomic disk write via MarkdownService (tmp-write + fsync + rename).
 *   5. mdRevisionHash bookkeeping (yjsManager.recordDiskBody) so the next
 *      persistence flush captures the post-write state.
 *   6. Git commit. On failure, register the dirty file with SessionManager
 *      (if wired) and surface `CommitFailedDirtyError` to the caller.
 *   7. Server-write only: post-commit `applyAgentContent` broadcast to the
 *      live Y.Doc. Autosave skips this — its content originated in the
 *      live Y.Doc; re-applying would race in-flight keystrokes.
 *
 * Scope boundary: body writes only. Structural ops (rename, delete,
 * changeType, move, folder ops) keep their own paths on graph.ts using
 * the existing transaction — they have a different concurrency profile
 * (no per-doc lock, no base-hash precondition) and routing them through
 * here would create a god-class.
 *
 * Also exposes `reconcileFromDisk` — the explicit external-disk-change
 * primitive callable from startup boot, git-pull event handlers, and
 * dev-tools "reload from disk". Not called from the normal write path.
 */
import { createHash } from 'node:crypto';
import { MarkdownService } from '../services/markdown.js';
import { CommitFailedDirtyError, StaleBaseRevisionError } from '../core/graph.js';
function sha256OfBody(body) {
    return createHash('sha256').update(body, 'utf-8').digest('hex');
}
export class BodyWriteCoordinator {
    commitHook;
    yjs;
    sessionManager;
    markdownService = new MarkdownService();
    constructor(commitHook, yjs, sessionManager) {
        this.commitHook = commitHook;
        this.yjs = yjs;
        this.sessionManager = sessionManager;
    }
    /**
     * Atomically write a body+frontmatter to disk, commit, and (for
     * server-write) broadcast to live editors. The single body-write
     * critical section shared by autosave + MCP + agent + event processor.
     */
    async writeBody(coords, body, opts) {
        const docName = `${coords.repo}/${coords.entityType}/${coords.entityId}`;
        return this.yjs.withDocLock(docName, async () => {
            // assertWritable for server-write (autosave is the client's own write
            // and never refused). Inside the lock so a throwing gate releases via
            // withDocLock's try/finally — without that, a future change could
            // deadlock the write path.
            if (opts.source === 'server-write') {
                this.yjs.assertWritable(docName);
            }
            // Single parse inside the lock: yields the current body (for the
            // base-hash precondition) AND the current frontmatter (to merge
            // against). Both must reflect any prior locked write before we decide.
            const current = this.markdownService.parseFile(coords.entityPath);
            // Optimistic-concurrency check.
            if (opts.baseHash !== undefined) {
                const currentHash = sha256OfBody(current.content);
                if (currentHash !== opts.baseHash) {
                    throw new StaleBaseRevisionError(coords.entityType, coords.entityId, opts.baseHash, currentHash);
                }
            }
            // Merge frontmatter against fresh on-disk state, inside the lock.
            const frontmatter = opts.prepareFrontmatter(current.frontmatter);
            this.markdownService.writeFile(coords.entityPath, {
                frontmatter,
                content: body,
                wikilinks: this.markdownService.extractWikilinks(body, frontmatter),
            });
            // Pin the live entry.mdRevisionHash to disk state. No-op when no
            // Y.Doc is loaded for this entity.
            try {
                this.yjs.recordDiskBody(docName, body);
            }
            catch (err) {
                console.warn(`[coordinator] recordDiskBody failed for ${coords.entityType}/${coords.entityId}:`, err);
            }
            if (!opts.skipCommit) {
                const message = opts.commitMessage ?? `Update ${coords.entityType}: ${coords.entityId}`;
                try {
                    await this.commitHook.commit(coords, opts.actor, message);
                }
                catch (err) {
                    // Disk write already landed; commit failed. Register the
                    // dirty file with sessionManager (if wired) so the existing
                    // dirty-flush retry has a chance to recover. Then surface a
                    // typed error so the caller knows the repo is dirty.
                    try {
                        this.sessionManager?.markDirty(coords.repo, opts.actor, coords.entityType, coords.entityId, coords.entityPath);
                    }
                    catch (registerErr) {
                        console.warn(`[coordinator] markDirty failed for ${coords.entityType}/${coords.entityId}:`, registerErr);
                    }
                    throw new CommitFailedDirtyError(coords.entityType, coords.entityId, coords.entityPath, err);
                }
            }
            // Server-write body updates apply to the live Y.Doc AFTER commit.
            // Returns 'no-doc' / 'unseeded' when there's no live editor — the
            // disk write is the only side effect, which is correct.
            // Autosave does NOT re-apply: its content originated in the live
            // Y.Doc and re-applying would race in-flight keystrokes typed
            // during the PUT round-trip.
            if (opts.source === 'server-write') {
                // Only propagate to the live Y.Doc when the body actually CHANGED on
                // disk (`current.content` is the pre-write disk body, read above inside
                // this lock). A server-write whose body equals disk — e.g. an agent
                // that meant to change only frontmatter but echoed back the unchanged
                // body — must NOT applyAgentContent: `applyTextDiff(ytext, body)` would
                // wipe a live editor's un-autosaved edits to a body this write never
                // touched. (The retired LiveEditorPendingError gate refused the whole
                // write here; this narrower guard preserves the no-op-body case while
                // still merging real body changes.)
                if (body === current.content) {
                    return { broadcast: 'skipped' };
                }
                try {
                    const { applied } = this.yjs.applyAgentContent(docName, body);
                    return { broadcast: applied };
                }
                catch (err) {
                    // applyAgentContent already sent a doc_conflict frame to live
                    // clients. Log but do not rethrow — disk + git are
                    // authoritative; surfacing the error to the caller would
                    // suggest the write failed when in fact only the Y.Doc broadcast
                    // did.
                    console.warn(`[coordinator] applyAgentContent failed post-commit for ${coords.entityType}/${coords.entityId} — clients notified via doc_conflict`, err);
                    return { broadcast: 'broadcast-failed' };
                }
            }
            return { broadcast: 'skipped' };
        });
    }
    /**
     * Reconcile a live Y.Doc against the latest .md file on disk. EXPLICIT
     * external-disk-change primitive — call this from startup boot, git-pull
     * event handlers, and dev-tools "reload from disk" only. NOT called from
     * the normal write path; server-side body writes route through
     * {@link writeBody}, which broadcasts via `applyAgentContent` under the
     * per-doc lock.
     *
     * Acquires the per-doc lock so a reconcile mid-write composes with the
     * Phase 2 MSG_SYNC queue + write serialization.
     */
    async reconcileFromDisk(coords) {
        const docName = `${coords.repo}/${coords.entityType}/${coords.entityId}`;
        await this.yjs.withDocLock(docName, async () => {
            this.yjs.reconcileFromDisk(docName);
        });
    }
    /**
     * Live body for an entity if a seeded editor doc is open, else null.
     * Synchronous and lock-free — a point-in-time peek used by lossless reads
     * (get_entity) and the asset-drop guard so they see the body the user is
     * actually editing, not just the last-persisted disk state.
     */
    peekLiveBody(coords) {
        return this.yjs.peekLiveBody?.(`${coords.repo}/${coords.entityType}/${coords.entityId}`) ?? null;
    }
    /**
     * docNames of every Y.Doc the editor layer currently has loaded. Empty when
     * no editor layer is wired (CLI/tests). Lets callers (e.g.
     * `graph.pullAndReconcile`) cheaply decide whether any open editor needs a
     * disk reconcile before doing per-file work.
     */
    activeDocNames() {
        return this.yjs.getActiveDocNames?.() ?? [];
    }
}
//# sourceMappingURL=body-write-coordinator.js.map