/**
 * PatchBuilder — fluent API for building schema:2 WARP patches.
 *
 * Maintains a VersionVector per writer, assigns dots on add operations,
 * reads current state to populate observedDots for removes, and includes
 * context VersionVector in the patch.
 *
 * @module domain/services/PatchBuilder
 */
import nullLogger from "../utils/nullLogger.js";
import VersionVector from "../crdt/VersionVector.js";
import Patch from "../types/Patch.js";
import NodeAdd from "../types/ops/NodeAdd.js";
import NodeRemove from "../types/ops/NodeRemove.js";
import EdgeAdd from "../types/ops/EdgeAdd.js";
import EdgeRemove from "../types/ops/EdgeRemove.js";
import NodePropSet from "../types/ops/NodePropSet.js";
import EdgePropSet from "../types/ops/EdgePropSet.js";
import ContentAttachmentWriteIntent from "../graph/ContentAttachmentWriteIntent.js";
import EdgePropertyWriteIntent from "../graph/EdgePropertyWriteIntent.js";
import NodePropertyWriteIntent from "../graph/NodePropertyWriteIntent.js";
import { encodeEdgeKey, CONTENT_PROPERTY_KEY, CONTENT_MIME_PROPERTY_KEY, CONTENT_SIZE_PROPERTY_KEY, EFFECT_NODE_PREFIX } from "./KeyCodec.js";
import { lowerCanonicalOp } from "./OpNormalizer.js";
import WriterError from "../errors/WriterError.js";
import PatchError from "../errors/PatchError.js";
import { canonicalStringify } from "../utils/canonicalStringify.js";
import { findAttachedData, assertNoReservedBytes, assertObservedDotsForRemove, } from "./PatchBuilderValidation.js";
import { requirePatchPropertyValue, storeContentAttachmentPayload, } from "./PatchBuilderContent.js";
import { commitPatch } from "./PatchCommitter.js";
import { DEFAULT_COMMIT_MESSAGE_CODEC } from "./codec/WarpMessageCodec.js";
export class PatchBuilder {
    _persistence;
    _graphName;
    _writerId;
    _targetRefPath;
    _lamport;
    _vv;
    _getCurrentState;
    _expectedParentSha;
    _onCommitSuccess;
    _onDeleteWithData;
    _patchJournal;
    _commitMessageCodec;
    _logger;
    _blobStorage;
    _ops = [];
    _nodesAdded = new Set();
    _edgesAdded = new Set();
    _observedOperands = new Set();
    _writes = new Set();
    _contentBlobs = [];
    _snapshotState = undefined;
    _hasEdgeProps = false;
    _committed = false;
    _committing = false;
    constructor(options) {
        this._persistence = options.persistence;
        this._graphName = options.graphName;
        this._writerId = options.writerId;
        this._targetRefPath = typeof options.targetRefPath === 'string' && options.targetRefPath.length > 0
            ? options.targetRefPath : null;
        this._lamport = options.lamport;
        this._vv = options.versionVector.clone();
        this._getCurrentState = options.getCurrentState;
        this._expectedParentSha = options.expectedParentSha ?? null;
        this._onCommitSuccess = options.onCommitSuccess ?? null;
        this._onDeleteWithData = options.onDeleteWithData ?? 'warn';
        this._patchJournal = options.patchJournal ?? null;
        this._commitMessageCodec = options.commitMessageCodec ?? DEFAULT_COMMIT_MESSAGE_CODEC;
        this._logger = options.logger ?? nullLogger;
        this._blobStorage = options.blobStorage ?? null;
    }
    // ── State access ───────────────────────────────────────────────────
    _getSnapshotState() {
        if (this._snapshotState === undefined) {
            this._snapshotState = this._getCurrentState() ?? null;
        }
        return this._snapshotState;
    }
    _assertNotCommitted() {
        if (this._committed || this._committing) {
            throw new PatchError('PatchBuilder already committed — create a new builder', { code: 'E_PATCH_ALREADY_COMMITTED' });
        }
    }
    // ── Graph operations ───────────────────────────────────────────────
    addNode(nodeId) {
        this._assertNotCommitted();
        assertNoReservedBytes(nodeId, 'nodeId');
        const dot = this._vv.increment(this._writerId);
        this._ops.push(new NodeAdd(nodeId, dot));
        this._nodesAdded.add(nodeId);
        this._writes.add(nodeId);
        return this;
    }
    removeNode(nodeId) {
        this._assertNotCommitted();
        const state = this._getSnapshotState();
        if (this._onDeleteWithData === 'cascade' && state) {
            const { edges } = findAttachedData(state, nodeId);
            for (const edgeKey of edges) {
                const parts = edgeKey.split('\0');
                const edgeDots = [...state.edgeAlive.getDots(edgeKey)];
                this._ops.push(new EdgeRemove({ from: parts[0], to: parts[1], label: parts[2], observedDots: edgeDots }));
                this._observedOperands.add(edgeKey);
            }
        }
        if (state && this._onDeleteWithData !== 'cascade') {
            const { edges, props, hasData } = findAttachedData(state, nodeId);
            if (hasData) {
                const details = [];
                if (edges.length > 0) {
                    details.push(`${edges.length} edge(s)`);
                }
                if (props.length > 0) {
                    details.push(`${props.length} propert${props.length === 1 ? 'y' : 'ies'}`);
                }
                const summary = details.join(' and ');
                if (this._onDeleteWithData === 'reject') {
                    throw new PatchError(`Cannot delete node '${nodeId}': node has attached data (${summary}). ` +
                        `Remove edges and properties first, or set onDeleteWithData to 'cascade'.`, { code: 'E_PATCH_DELETE_WITH_DATA', context: { nodeId, edges: edges.length, props: props.length } });
                }
                if (this._onDeleteWithData === 'warn') {
                    this._logger.warn(`[warp] Deleting node '${nodeId}' which has attached data (${summary}). Orphaned data will remain in state.`);
                }
            }
        }
        if (!state) {
            throw new PatchError(`Cannot remove node '${nodeId}': graph must be materialized before removing nodes`, { code: 'E_PATCH_NO_STATE' });
        }
        const observedDots = [...state.nodeAlive.getDots(nodeId)];
        assertObservedDotsForRemove(observedDots, 'node', { nodeId });
        this._ops.push(new NodeRemove(nodeId, observedDots));
        this._observedOperands.add(nodeId);
        return this;
    }
    addEdge(from, to, label) {
        this._assertNotCommitted();
        assertNoReservedBytes(from, 'from node ID');
        assertNoReservedBytes(to, 'to node ID');
        assertNoReservedBytes(label, 'edge label');
        const dot = this._vv.increment(this._writerId);
        this._ops.push(new EdgeAdd({ from, to, label, dot }));
        const edgeKey = encodeEdgeKey(from, to, label);
        this._edgesAdded.add(edgeKey);
        this._observedOperands.add(from);
        this._observedOperands.add(to);
        this._writes.add(edgeKey);
        return this;
    }
    removeEdge(from, to, label) {
        this._assertNotCommitted();
        const state = this._getSnapshotState();
        const edgeKey = encodeEdgeKey(from, to, label);
        if (!state) {
            throw new PatchError(`Cannot remove edge '${from}->${to}' (${label}): graph must be materialized before removing edges`, { code: 'E_PATCH_NO_STATE' });
        }
        const observedDots = [...state.edgeAlive.getDots(edgeKey)];
        assertObservedDotsForRemove(observedDots, 'edge', { edgeKey });
        this._ops.push(new EdgeRemove({ from, to, label, observedDots }));
        this._observedOperands.add(edgeKey);
        return this;
    }
    emitEffect(kind, payload, options) {
        this._assertNotCommitted();
        if (typeof kind !== 'string' || kind.length === 0) {
            throw new PatchError('emitEffect: kind must be a non-empty string', {
                code: 'E_EFFECT_INVALID_KIND', context: { kind },
            });
        }
        const effectId = (options?.effectId !== undefined && options.effectId !== '')
            ? options.effectId
            : `${EFFECT_NODE_PREFIX}${this._writerId}-${this._lamport}-${this._ops.length}`;
        this.addNode(effectId);
        this.setProperty(effectId, 'kind', kind);
        this.setProperty(effectId, 'writer', this._writerId);
        if (payload !== null && payload !== undefined) {
            this.setProperty(effectId, 'payload', canonicalStringify(payload));
        }
        return effectId;
    }
    setProperty(nodeId, key, value) {
        this._assertNotCommitted();
        assertNoReservedBytes(nodeId, 'nodeId');
        assertNoReservedBytes(key, 'key');
        const intent = NodePropertyWriteIntent.fromLegacyProperty(nodeId, key, requirePatchPropertyValue(value));
        this._lowerNodePropertyIntent(intent);
        return this;
    }
    setEdgeProperty(from, to, label, key, value) {
        this._assertNotCommitted();
        assertNoReservedBytes(from, 'from node ID');
        assertNoReservedBytes(to, 'to node ID');
        assertNoReservedBytes(label, 'edge label');
        assertNoReservedBytes(key, 'key');
        const intent = EdgePropertyWriteIntent.fromLegacyProperty({
            from,
            to,
            label,
            key,
            value: requirePatchPropertyValue(value),
        });
        const ek = this._assertEdgeExists(from, to, label);
        this._lowerEdgePropertyIntent(intent);
        this._observedOperands.add(ek);
        this._writes.add(ek);
        return this;
    }
    // ── Content operations ─────────────────────────────────────────────
    async attachContent(nodeId, content, metadata) {
        this._assertNotCommitted();
        assertNoReservedBytes(nodeId, 'nodeId');
        assertNoReservedBytes(CONTENT_PROPERTY_KEY, 'key');
        this._assertNodeExistsForContent(nodeId);
        if (!this._blobStorage) {
            throw new WriterError('Cannot attach content without blob storage — inject blobStorage via open() or use InMemoryBlobStorageAdapter', { code: 'NO_BLOB_STORAGE' });
        }
        const slug = `${this._graphName}/${nodeId}`;
        const payload = await storeContentAttachmentPayload({
            blobStorage: this._blobStorage,
            content,
            metadata,
            slug,
        });
        const intent = ContentAttachmentWriteIntent.forNode(nodeId, payload);
        this._lowerNodeContentIntent(intent);
        this._contentBlobs.push(intent.oid());
        return this;
    }
    clearContent(nodeId) {
        this._assertNotCommitted();
        assertNoReservedBytes(nodeId, 'nodeId');
        assertNoReservedBytes(CONTENT_PROPERTY_KEY, 'key');
        this._assertNodeExistsForContent(nodeId);
        this.setProperty(nodeId, CONTENT_PROPERTY_KEY, null);
        this.setProperty(nodeId, CONTENT_SIZE_PROPERTY_KEY, null);
        this.setProperty(nodeId, CONTENT_MIME_PROPERTY_KEY, null);
        return this;
    }
    async attachEdgeContent(from, to, label, content, metadata) {
        this._assertNotCommitted();
        assertNoReservedBytes(from, 'from');
        assertNoReservedBytes(to, 'to');
        assertNoReservedBytes(label, 'label');
        assertNoReservedBytes(CONTENT_PROPERTY_KEY, 'key');
        this._assertEdgeExists(from, to, label);
        if (!this._blobStorage) {
            throw new WriterError('Cannot attach content without blob storage — inject blobStorage via open() or use InMemoryBlobStorageAdapter', { code: 'NO_BLOB_STORAGE' });
        }
        const slug = `${this._graphName}/${from}/${to}/${label}`;
        const payload = await storeContentAttachmentPayload({
            blobStorage: this._blobStorage,
            content,
            metadata,
            slug,
        });
        const intent = ContentAttachmentWriteIntent.forEdge({ from, to, label }, payload);
        this._lowerEdgeContentIntent(intent);
        this._contentBlobs.push(intent.oid());
        return this;
    }
    clearEdgeContent(from, to, label) {
        this._assertNotCommitted();
        assertNoReservedBytes(from, 'from');
        assertNoReservedBytes(to, 'to');
        assertNoReservedBytes(label, 'label');
        assertNoReservedBytes(CONTENT_PROPERTY_KEY, 'key');
        this._assertEdgeExists(from, to, label);
        this.setEdgeProperty(from, to, label, CONTENT_PROPERTY_KEY, null);
        this.setEdgeProperty(from, to, label, CONTENT_SIZE_PROPERTY_KEY, null);
        this.setEdgeProperty(from, to, label, CONTENT_MIME_PROPERTY_KEY, null);
        return this;
    }
    _lowerNodeContentIntent(intent) {
        const nodeId = intent.nodeId();
        this.setProperty(nodeId, CONTENT_PROPERTY_KEY, intent.oid());
        this.setProperty(nodeId, CONTENT_SIZE_PROPERTY_KEY, intent.size());
        this.setProperty(nodeId, CONTENT_MIME_PROPERTY_KEY, intent.mime());
    }
    _lowerEdgeContentIntent(intent) {
        const target = intent.edgeTarget();
        this.setEdgeProperty(target.from, target.to, target.label, CONTENT_PROPERTY_KEY, intent.oid());
        this.setEdgeProperty(target.from, target.to, target.label, CONTENT_SIZE_PROPERTY_KEY, intent.size());
        this.setEdgeProperty(target.from, target.to, target.label, CONTENT_MIME_PROPERTY_KEY, intent.mime());
    }
    _lowerNodePropertyIntent(intent) {
        const nodeId = intent.nodeId();
        this._ops.push(new NodePropSet(nodeId, intent.propertyKey(), intent.propertyValue()));
        this._observedOperands.add(nodeId);
        this._writes.add(nodeId);
    }
    _lowerEdgePropertyIntent(intent) {
        const target = intent.edgeTarget();
        this._ops.push(new EdgePropSet({
            from: target.from,
            to: target.to,
            label: target.label,
            key: intent.propertyKey(),
            value: intent.propertyValue(),
        }));
        this._hasEdgeProps = true;
    }
    // ── Existence guards ───────────────────────────────────────────────
    _assertNodeExistsForContent(nodeId) {
        if (this._nodesAdded.has(nodeId)) {
            return;
        }
        const state = this._getSnapshotState();
        if (!state || !state.nodeAlive.contains(nodeId)) {
            throw new PatchError(`Cannot attach content to unknown node '${nodeId}': add the node first`, // nosemgrep: ts-no-unknown-outside-adapters -- 0025B
            { code: 'E_PATCH_CONTENT_UNKNOWN_NODE', context: { nodeId } });
        }
    }
    _assertEdgeExists(from, to, label) {
        const ek = encodeEdgeKey(from, to, label);
        if (!this._edgesAdded.has(ek)) {
            const state = this._getSnapshotState();
            if (!state || !state.edgeAlive.contains(ek)) {
                throw new PatchError(`Cannot set property on unknown edge (${from} → ${to} [${label}]): add the edge first`, // nosemgrep: ts-no-unknown-outside-adapters -- 0025B
                { code: 'E_PATCH_EDGE_PROP_UNKNOWN_EDGE', context: { from, to, label } });
            }
        }
        return ek;
    }
    // ── Build & Commit ─────────────────────────────────────────────────
    build() {
        const schema = this._hasEdgeProps ? 3 : 2;
        const rawOps = this._ops.map((op) => lowerCanonicalOp(op));
        return new Patch({
            schema,
            writer: this._writerId,
            lamport: this._lamport,
            context: VersionVector.serialize(this._vv),
            ops: rawOps,
            reads: [...this._observedOperands].sort(),
            writes: [...this._writes].sort(),
        });
    }
    async commit() {
        this._assertNotCommitted();
        this._committing = true;
        try {
            const sha = await commitPatch({
                persistence: this._persistence,
                graphName: this._graphName,
                writerId: this._writerId,
                lamport: this._lamport,
                vv: this._vv,
                ops: this._ops,
                observedOperands: this._observedOperands,
                writes: this._writes,
                hasEdgeProps: this._hasEdgeProps,
                expectedParentSha: this._expectedParentSha,
                targetRefPath: this._targetRefPath,
                contentBlobs: this._contentBlobs,
                patchJournal: this._patchJournal,
                commitMessageCodec: this._commitMessageCodec,
                logger: this._logger,
                onCommitSuccess: this._onCommitSuccess,
            });
            this._committed = true;
            return sha;
        }
        finally {
            this._committing = false;
        }
    }
    // ── Accessors ──────────────────────────────────────────────────────
    get ops() { return this._ops; }
    get versionVector() { return this._vv; }
    get reads() { return new Set(this._observedOperands); }
    get writes() { return new Set(this._writes); }
    /**
     * Content-blob OIDs captured via `attachNodeContent` / `attachEdgeContent`.
     * Exposed for strand-overlay queued-intent assembly, which needs the
     * snapshot of blob OIDs to persist alongside the patch entry.
     */
    get contentBlobs() { return [...this._contentBlobs]; }
}
