/* -----------------------------------------------------------------------------
| Copyright (c) Jupyter Development Team.
| Distributed under the terms of the Modified BSD License.
|----------------------------------------------------------------------------*/
import { JSONExt, UUID } from '@lumino/coreutils';
import { Signal } from '@lumino/signaling';
import { Awareness } from 'y-protocols/awareness';
import * as Y from 'yjs';
/**
 * Generic shareable document.
 */
export class YDocument {
    constructor() {
        /**
         * YJS document
         */
        this.ydoc = new Y.Doc();
        /**
         * Shared state
         */
        this.ystate = this.ydoc.getMap('state');
        /**
         * YJS document undo manager
         */
        this.undoManager = new Y.UndoManager([], {
            trackedOrigins: new Set([this]),
            doc: this.ydoc
        });
        /**
         * Shared awareness
         */
        this.awareness = new Awareness(this.ydoc);
        /**
         * Handle a change to the ystate.
         */
        this.onStateChanged = (event) => {
            const stateChange = new Array();
            event.keysChanged.forEach(key => {
                const change = event.changes.keys.get(key);
                if (change) {
                    stateChange.push({
                        name: key,
                        oldValue: change.oldValue,
                        newValue: this.ystate.get(key)
                    });
                }
            });
            this._changed.emit({ stateChange });
        };
        this._changed = new Signal(this);
        this._isDisposed = false;
        this.ystate.observe(this.onStateChanged);
    }
    /**
     * The changed signal.
     */
    get changed() {
        return this._changed;
    }
    /**
     * Whether the document is disposed or not.
     */
    get isDisposed() {
        return this._isDisposed;
    }
    /**
     * Document state
     */
    get state() {
        return JSONExt.deepCopy(this.ystate.toJSON());
    }
    /**
     * Whether the object can undo changes.
     */
    canUndo() {
        return this.undoManager.undoStack.length > 0;
    }
    /**
     * Whether the object can redo changes.
     */
    canRedo() {
        return this.undoManager.redoStack.length > 0;
    }
    /**
     * Dispose of the resources.
     */
    dispose() {
        if (this._isDisposed) {
            return;
        }
        this._isDisposed = true;
        this.ystate.unobserve(this.onStateChanged);
        this.awareness.destroy();
        this.undoManager.destroy();
        this.ydoc.destroy();
        Signal.clearData(this);
    }
    /**
     * Get the value for a state attribute
     *
     * @param key Key to get
     */
    getState(key) {
        const value = this.ystate.get(key);
        return typeof value === 'undefined'
            ? value
            : JSONExt.deepCopy(value);
    }
    /**
     * Set the value of a state attribute
     *
     * @param key Key to set
     * @param value New attribute value
     */
    setState(key, value) {
        if (!JSONExt.deepEqual(this.ystate.get(key), value)) {
            this.ystate.set(key, value);
        }
    }
    /**
     * Undo an operation.
     */
    undo() {
        this.undoManager.undo();
    }
    /**
     * Redo an operation.
     */
    redo() {
        this.undoManager.redo();
    }
    /**
     * Clear the change stack.
     */
    clearUndoHistory() {
        this.undoManager.clear();
    }
    /**
     * Perform a transaction. While the function f is called, all changes to the shared
     * document are bundled into a single event.
     */
    transact(f, undoable = true) {
        this.ydoc.transact(f, undoable ? this : null);
    }
}
/**
 * Shareable text file.
 */
export class YFile extends YDocument {
    /**
     * Create a new file
     *
     * #### Notes
     * The document is empty and must be populated
     */
    constructor() {
        super();
        /**
         * YJS file text.
         */
        this.ysource = this.ydoc.getText('source');
        /**
         * Handle a change to the ymodel.
         */
        this._modelObserver = (event) => {
            this._changed.emit({ sourceChange: event.changes.delta });
        };
        this.undoManager.addToScope(this.ysource);
        this.ysource.observe(this._modelObserver);
    }
    /**
     * File text
     */
    get source() {
        return this.getSource();
    }
    set source(v) {
        this.setSource(v);
    }
    /**
     * Dispose of the resources.
     */
    dispose() {
        if (this.isDisposed) {
            return;
        }
        this.ysource.unobserve(this._modelObserver);
        super.dispose();
    }
    /**
     * Get the file text.
     *
     * @returns File text.
     */
    getSource() {
        return this.ysource.toString();
    }
    /**
     * Set the file text.
     *
     * @param value New text
     */
    setSource(value) {
        this.transact(() => {
            const ytext = this.ysource;
            ytext.delete(0, ytext.length);
            ytext.insert(0, value);
        });
    }
    /**
     * Replace content from `start' to `end` with `value`.
     *
     * @param start: The start index of the range to replace (inclusive).
     * @param end: The end index of the range to replace (exclusive).
     * @param value: New source (optional).
     */
    updateSource(start, end, value = '') {
        this.transact(() => {
            const ysource = this.ysource;
            // insert and then delete.
            // This ensures that the cursor position is adjusted after the replaced content.
            ysource.insert(start, value);
            ysource.delete(start + value.length, end - start);
        });
    }
}
/**
 * Create a new shared cell model given the YJS shared type.
 */
const createCellModelFromSharedType = (type, options = {}) => {
    switch (type.get('cell_type')) {
        case 'code':
            return new YCodeCell(type, type.get('source'), type.get('outputs'), options);
        case 'markdown':
            return new YMarkdownCell(type, type.get('source'), options);
        case 'raw':
            return new YRawCell(type, type.get('source'), options);
        default:
            throw new Error('Found unknown cell type');
    }
};
/**
 * Create a new cell that can be inserted in an existing shared model.
 *
 * If no notebook is specified the cell will be standalone.
 *
 * @param cell Cell JSON representation
 * @param notebook Notebook to which the cell will be added
 */
const createCell = (cell, notebook) => {
    var _a, _b;
    const ymodel = new Y.Map();
    const ysource = new Y.Text();
    ymodel.set('source', ysource);
    ymodel.set('metadata', {});
    ymodel.set('cell_type', cell.cell_type);
    ymodel.set('id', (_a = cell.id) !== null && _a !== void 0 ? _a : UUID.uuid4());
    let ycell;
    switch (cell.cell_type) {
        case 'markdown': {
            ycell = new YMarkdownCell(ymodel, ysource, { notebook });
            if (cell.attachments != null) {
                ycell.setAttachments(cell.attachments);
            }
            break;
        }
        case 'code': {
            const youtputs = new Y.Array();
            ymodel.set('outputs', youtputs);
            ycell = new YCodeCell(ymodel, ysource, youtputs, {
                notebook
            });
            const cCell = cell;
            ycell.execution_count = (_b = cCell.execution_count) !== null && _b !== void 0 ? _b : null;
            if (cCell.outputs) {
                ycell.setOutputs(cCell.outputs);
            }
            break;
        }
        default: {
            // raw
            ycell = new YRawCell(ymodel, ysource, { notebook });
            if (cell.attachments) {
                ycell.setAttachments(cell.attachments);
            }
            break;
        }
    }
    if (cell.metadata != null) {
        ycell.setMetadata(cell.metadata);
    }
    if (cell.source != null) {
        ycell.setSource(typeof cell.source === 'string' ? cell.source : cell.source.join('\n'));
    }
    return ycell;
};
/**
 * Create a new cell that cannot be inserted in an existing shared model.
 *
 * @param cell Cell JSON representation
 */
export const createStandaloneCell = (cell) => createCell(cell);
export class YBaseCell {
    /**
     * Create a new YCell that works standalone. It cannot be
     * inserted into a YNotebook because the Yjs model is already
     * attached to an anonymous Y.Doc instance.
     */
    static createStandalone(id) {
        return createCell({ id, cell_type: this.prototype.cell_type });
    }
    /**
     * Base cell constructor
     *
     * ### Notes
     * Don't use the constructor directly - prefer using ``YNotebook.insertCell``
     *
     * The ``ysource`` is needed because ``ymodel.get('source')`` will
     * not return the real source if the model is not yet attached to
     * a document. Requesting it explicitly allows to introspect a non-empty
     * source before the cell is attached to the document.
     *
     * @param ymodel Cell map
     * @param ysource Cell source
     * @param options { notebook?: The notebook the cell is attached to }
     */
    constructor(ymodel, ysource, options = {}) {
        /**
         * Handle a change to the ymodel.
         */
        this._modelObserver = (events) => {
            this._changed.emit(this.getChanges(events));
        };
        this._metadataChanged = new Signal(this);
        /**
         * The notebook that this cell belongs to.
         */
        this._notebook = null;
        this._changed = new Signal(this);
        this._disposed = new Signal(this);
        this._isDisposed = false;
        this._undoManager = null;
        this.ymodel = ymodel;
        this._ysource = ysource;
        this._prevSourceLength = ysource ? ysource.length : 0;
        this._notebook = null;
        this._awareness = null;
        this._undoManager = null;
        if (options.notebook) {
            this._notebook = options.notebook;
            // We cannot create a undo manager with the cell not yet attached in the notebook
            // so we defer that to the notebook insertCell method
        }
        else {
            // Standalone cell
            const doc = new Y.Doc();
            doc.getArray().insert(0, [this.ymodel]);
            this._awareness = new Awareness(doc);
            this._undoManager = new Y.UndoManager([this.ymodel], {
                trackedOrigins: new Set([this])
            });
        }
        this.ymodel.observeDeep(this._modelObserver);
    }
    /**
     * Cell notebook awareness or null if the cell is standalone.
     */
    get awareness() {
        var _a, _b, _c;
        return (_c = (_a = this._awareness) !== null && _a !== void 0 ? _a : (_b = this.notebook) === null || _b === void 0 ? void 0 : _b.awareness) !== null && _c !== void 0 ? _c : null;
    }
    /**
     * The type of the cell.
     */
    get cell_type() {
        throw new Error('A YBaseCell must not be constructed');
    }
    /**
     * The changed signal.
     */
    get changed() {
        return this._changed;
    }
    /**
     * Signal emitted when the cell is disposed.
     */
    get disposed() {
        return this._disposed;
    }
    /**
     * Cell id
     */
    get id() {
        return this.getId();
    }
    /**
     * Whether the model has been disposed or not.
     */
    get isDisposed() {
        return this._isDisposed;
    }
    /**
     * Whether the cell is standalone or not.
     *
     * If the cell is standalone. It cannot be
     * inserted into a YNotebook because the Yjs model is already
     * attached to an anonymous Y.Doc instance.
     */
    get isStandalone() {
        return this._notebook !== null;
    }
    /**
     * Cell metadata.
     */
    get metadata() {
        return this.getMetadata();
    }
    set metadata(v) {
        this.setMetadata(v);
    }
    /**
     * Signal triggered when the cell metadata changes.
     */
    get metadataChanged() {
        return this._metadataChanged;
    }
    /**
     * The notebook that this cell belongs to.
     */
    get notebook() {
        return this._notebook;
    }
    /**
     * Cell input content.
     */
    get source() {
        return this.getSource();
    }
    set source(v) {
        this.setSource(v);
    }
    /**
     * The cell undo manager.
     */
    get undoManager() {
        var _a;
        if (!this.notebook) {
            return this._undoManager;
        }
        return ((_a = this.notebook) === null || _a === void 0 ? void 0 : _a.disableDocumentWideUndoRedo)
            ? this._undoManager
            : this.notebook.undoManager;
    }
    /**
     * Defer setting the undo manager as it requires the
     * cell to be attached to the notebook Y document.
     */
    setUndoManager() {
        if (this._undoManager) {
            throw new Error('The cell undo manager is already set.');
        }
        if (this._notebook && this._notebook.disableDocumentWideUndoRedo) {
            this._undoManager = new Y.UndoManager([this.ymodel], {
                trackedOrigins: new Set([this])
            });
        }
    }
    get ysource() {
        return this._ysource;
    }
    /**
     * Whether the object can undo changes.
     */
    canUndo() {
        return !!this.undoManager && this.undoManager.undoStack.length > 0;
    }
    /**
     * Whether the object can redo changes.
     */
    canRedo() {
        return !!this.undoManager && this.undoManager.redoStack.length > 0;
    }
    /**
     * Clear the change stack.
     */
    clearUndoHistory() {
        var _a;
        (_a = this.undoManager) === null || _a === void 0 ? void 0 : _a.clear();
    }
    /**
     * Undo an operation.
     */
    undo() {
        var _a;
        (_a = this.undoManager) === null || _a === void 0 ? void 0 : _a.undo();
    }
    /**
     * Redo an operation.
     */
    redo() {
        var _a;
        (_a = this.undoManager) === null || _a === void 0 ? void 0 : _a.redo();
    }
    /**
     * Dispose of the resources.
     */
    dispose() {
        var _a;
        if (this._isDisposed)
            return;
        this._isDisposed = true;
        this.ymodel.unobserveDeep(this._modelObserver);
        if (this._awareness) {
            // A new document is created for standalone cell.
            const doc = this._awareness.doc;
            this._awareness.destroy();
            doc.destroy();
        }
        if (this._undoManager) {
            // Be sure to not destroy the document undo manager.
            if (this._undoManager === ((_a = this.notebook) === null || _a === void 0 ? void 0 : _a.undoManager)) {
                this._undoManager = null;
            }
            else {
                this._undoManager.destroy();
            }
        }
        this._disposed.emit();
        Signal.clearData(this);
    }
    /**
     * Get cell id.
     *
     * @returns Cell id
     */
    getId() {
        return this.ymodel.get('id');
    }
    /**
     * Gets cell's source.
     *
     * @returns Cell's source.
     */
    getSource() {
        return this.ysource.toString();
    }
    /**
     * Sets cell's source.
     *
     * @param value: New source.
     */
    setSource(value) {
        this.transact(() => {
            this.ysource.delete(0, this.ysource.length);
            this.ysource.insert(0, value);
        });
        // @todo Do we need proper replace semantic? This leads to issues in editor bindings because they don't switch source.
        // this.ymodel.set('source', new Y.Text(value));
    }
    /**
     * Replace content from `start' to `end` with `value`.
     *
     * @param start: The start index of the range to replace (inclusive).
     *
     * @param end: The end index of the range to replace (exclusive).
     *
     * @param value: New source (optional).
     */
    updateSource(start, end, value = '') {
        this.transact(() => {
            const ysource = this.ysource;
            // insert and then delete.
            // This ensures that the cursor position is adjusted after the replaced content.
            ysource.insert(start, value);
            ysource.delete(start + value.length, end - start);
        });
    }
    /**
     * Delete a metadata cell.
     *
     * @param key The key to delete
     */
    deleteMetadata(key) {
        if (typeof this.getMetadata(key) === 'undefined') {
            return;
        }
        const allMetadata = JSONExt.deepCopy(this.ymodel.get('metadata'));
        delete allMetadata[key];
        if (key === 'collapsed' && allMetadata.jupyter) {
            delete allMetadata.jupyter.outputs_hidden;
            if (Object.keys(allMetadata.jupyter).length === 0) {
                delete allMetadata.jupyter;
            }
        }
        else if (key === 'jupyter') {
            delete allMetadata.collapsed;
        }
        this.setMetadata(allMetadata);
    }
    getMetadata(key) {
        var _a;
        // Transiently the metadata can be missing - like during destruction
        const metadata = (_a = this.ymodel.get('metadata')) !== null && _a !== void 0 ? _a : {};
        if (typeof key === 'string') {
            const value = metadata[key];
            return typeof value === 'undefined'
                ? undefined // undefined is converted to `{}` by `JSONExt.deepCopy`
                : JSONExt.deepCopy(metadata[key]);
        }
        else {
            return JSONExt.deepCopy(metadata);
        }
    }
    setMetadata(metadata, value) {
        var _a, _b, _c;
        if (typeof metadata === 'string') {
            if (typeof value === 'undefined') {
                throw new TypeError(`Metadata value for ${metadata} cannot be 'undefined'; use deleteMetadata.`);
            }
            const key = metadata;
            // Only set metadata if we change something to avoid infinite
            // loop of signal changes.
            if (JSONExt.deepEqual((_a = this.getMetadata(key)) !== null && _a !== void 0 ? _a : null, value)) {
                return;
            }
            const clone = this.getMetadata();
            clone[key] = value;
            if (key === 'collapsed' && ((_b = clone.jupyter) === null || _b === void 0 ? void 0 : _b.outputs_hidden) !== value) {
                clone.jupyter = {
                    ...clone.jupyter,
                    outputs_hidden: value
                };
            }
            else if (key === 'jupyter') {
                if (typeof value['outputs_hidden'] !== 'undefined') {
                    if (clone.collapsed !== value['outputs_hidden']) {
                        clone.collapsed = value['outputs_hidden'];
                    }
                }
                else {
                    delete clone.collapsed;
                }
            }
            this.transact(() => {
                this.ymodel.set('metadata', clone);
            });
        }
        else {
            const clone = JSONExt.deepCopy(metadata);
            if (clone.collapsed != null) {
                clone.jupyter = clone.jupyter || {};
                clone.jupyter.outputs_hidden = clone.collapsed;
            }
            else if (((_c = clone === null || clone === void 0 ? void 0 : clone.jupyter) === null || _c === void 0 ? void 0 : _c.outputs_hidden) != null) {
                clone.collapsed = clone.jupyter.outputs_hidden;
            }
            if (!JSONExt.deepEqual(clone, this.getMetadata())) {
                this.transact(() => {
                    this.ymodel.set('metadata', clone);
                });
            }
        }
    }
    /**
     * Serialize the model to JSON.
     */
    toJSON() {
        return {
            id: this.getId(),
            cell_type: this.cell_type,
            source: this.getSource(),
            metadata: this.getMetadata()
        };
    }
    /**
     * Perform a transaction. While the function f is called, all changes to the shared
     * document are bundled into a single event.
     */
    transact(f, undoable = true) {
        this.notebook && undoable
            ? this.notebook.transact(f)
            : this.ymodel.doc == null
                ? f()
                : this.ymodel.doc.transact(f, this);
    }
    /**
     * Extract changes from YJS events
     *
     * @param events YJS events
     * @returns Cell changes
     */
    getChanges(events) {
        var _a;
        const changes = {};
        const sourceEvent = events.find(event => event.target === this.ymodel.get('source'));
        if (sourceEvent) {
            changes.sourceChange = sourceEvent.changes.delta;
        }
        const modelEvent = events.find(event => event.target === this.ymodel);
        if (modelEvent && modelEvent.keysChanged.has('metadata')) {
            const change = modelEvent.changes.keys.get('metadata');
            const metadataChange = (changes.metadataChange = {
                oldValue: (change === null || change === void 0 ? void 0 : change.oldValue) ? change.oldValue : undefined,
                newValue: this.getMetadata()
            });
            const oldValue = (_a = metadataChange.oldValue) !== null && _a !== void 0 ? _a : {};
            const oldKeys = Object.keys(oldValue);
            const newKeys = Object.keys(metadataChange.newValue);
            for (let key of new Set(oldKeys.concat(newKeys))) {
                if (!oldKeys.includes(key)) {
                    this._metadataChanged.emit({
                        key,
                        newValue: metadataChange.newValue[key],
                        type: 'add'
                    });
                }
                else if (!newKeys.includes(key)) {
                    this._metadataChanged.emit({
                        key,
                        oldValue: metadataChange.oldValue[key],
                        type: 'remove'
                    });
                }
                else if (!JSONExt.deepEqual(oldValue[key], metadataChange.newValue[key])) {
                    this._metadataChanged.emit({
                        key,
                        newValue: metadataChange.newValue[key],
                        oldValue: metadataChange.oldValue[key],
                        type: 'change'
                    });
                }
            }
        }
        // The model allows us to replace the complete source with a new string. We express this in the Delta format
        // as a replace of the complete string.
        const ysource = this.ymodel.get('source');
        if (modelEvent && modelEvent.keysChanged.has('source')) {
            changes.sourceChange = [
                { delete: this._prevSourceLength },
                { insert: ysource.toString() }
            ];
        }
        this._prevSourceLength = ysource.length;
        return changes;
    }
}
/**
 * Shareable code cell.
 */
export class YCodeCell extends YBaseCell {
    /**
     * Create a new YCodeCell that works standalone. It cannot be
     * inserted into a YNotebook because the Yjs model is already
     * attached to an anonymous Y.Doc instance.
     */
    static createStandalone(id) {
        return super.createStandalone(id);
    }
    /**
     * Code cell constructor
     *
     * ### Notes
     * Don't use the constructor directly - prefer using ``YNotebook.insertCell``
     *
     * The ``ysource`` is needed because ``ymodel.get('source')`` will
     * not return the real source if the model is not yet attached to
     * a document. Requesting it explicitly allows to introspect a non-empty
     * source before the cell is attached to the document.
     *
     * @param ymodel Cell map
     * @param ysource Cell source
     * @param youtputs Code cell outputs
     * @param options { notebook?: The notebook the cell is attached to }
     */
    constructor(ymodel, ysource, youtputs, options = {}) {
        super(ymodel, ysource, options);
        this._youtputs = youtputs;
    }
    /**
     * The type of the cell.
     */
    get cell_type() {
        return 'code';
    }
    /**
     * The code cell's prompt number. Will be null if the cell has not been run.
     */
    get execution_count() {
        return this.ymodel.get('execution_count') || null;
    }
    set execution_count(count) {
        // Do not use `this.execution_count`. When initializing the
        // cell, we need to set execution_count to `null` if we compare
        // using `this.execution_count` it will return `null` and we will
        // never initialize it
        if (this.ymodel.get('execution_count') !== count) {
            this.transact(() => {
                this.ymodel.set('execution_count', count);
            });
        }
    }
    /**
     * Cell outputs.
     */
    get outputs() {
        return this.getOutputs();
    }
    set outputs(v) {
        this.setOutputs(v);
    }
    /**
     * Execution, display, or stream outputs.
     */
    getOutputs() {
        return JSONExt.deepCopy(this._youtputs.toArray());
    }
    /**
     * Replace all outputs.
     */
    setOutputs(outputs) {
        this.transact(() => {
            this._youtputs.delete(0, this._youtputs.length);
            this._youtputs.insert(0, outputs);
        }, false);
    }
    /**
     * Replace content from `start' to `end` with `outputs`.
     *
     * @param start: The start index of the range to replace (inclusive).
     *
     * @param end: The end index of the range to replace (exclusive).
     *
     * @param outputs: New outputs (optional).
     */
    updateOutputs(start, end, outputs = []) {
        const fin = end < this._youtputs.length ? end - start : this._youtputs.length - start;
        this.transact(() => {
            this._youtputs.delete(start, fin);
            this._youtputs.insert(start, outputs);
        }, false);
    }
    /**
     * Serialize the model to JSON.
     */
    toJSON() {
        return {
            ...super.toJSON(),
            outputs: this.getOutputs(),
            execution_count: this.execution_count
        };
    }
    /**
     * Extract changes from YJS events
     *
     * @param events YJS events
     * @returns Cell changes
     */
    getChanges(events) {
        const changes = super.getChanges(events);
        const outputEvent = events.find(event => event.target === this.ymodel.get('outputs'));
        if (outputEvent) {
            changes.outputsChange = outputEvent.changes.delta;
        }
        const modelEvent = events.find(event => event.target === this.ymodel);
        if (modelEvent && modelEvent.keysChanged.has('execution_count')) {
            const change = modelEvent.changes.keys.get('execution_count');
            changes.executionCountChange = {
                oldValue: change.oldValue,
                newValue: this.ymodel.get('execution_count')
            };
        }
        return changes;
    }
}
class YAttachmentCell extends YBaseCell {
    /**
     * Cell attachments
     */
    get attachments() {
        return this.getAttachments();
    }
    set attachments(v) {
        this.setAttachments(v);
    }
    /**
     * Gets the cell attachments.
     *
     * @returns The cell attachments.
     */
    getAttachments() {
        return this.ymodel.get('attachments');
    }
    /**
     * Sets the cell attachments
     *
     * @param attachments: The cell attachments.
     */
    setAttachments(attachments) {
        this.transact(() => {
            if (attachments == null) {
                this.ymodel.delete('attachments');
            }
            else {
                this.ymodel.set('attachments', attachments);
            }
        });
    }
    /**
     * Extract changes from YJS events
     *
     * @param events YJS events
     * @returns Cell changes
     */
    getChanges(events) {
        const changes = super.getChanges(events);
        const modelEvent = events.find(event => event.target === this.ymodel);
        if (modelEvent && modelEvent.keysChanged.has('attachments')) {
            const change = modelEvent.changes.keys.get('attachments');
            changes.executionCountChange = {
                oldValue: change.oldValue,
                newValue: this.ymodel.get('attachments')
            };
        }
        return changes;
    }
}
/**
 * Shareable raw cell.
 */
export class YRawCell extends YAttachmentCell {
    /**
     * Create a new YRawCell that works standalone. It cannot be
     * inserted into a YNotebook because the Yjs model is already
     * attached to an anonymous Y.Doc instance.
     */
    static createStandalone(id) {
        return super.createStandalone(id);
    }
    /**
     * String identifying the type of cell.
     */
    get cell_type() {
        return 'raw';
    }
    /**
     * Serialize the model to JSON.
     */
    toJSON() {
        return {
            id: this.getId(),
            cell_type: 'raw',
            source: this.getSource(),
            metadata: this.getMetadata(),
            attachments: this.getAttachments()
        };
    }
}
/**
 * Shareable markdown cell.
 */
export class YMarkdownCell extends YAttachmentCell {
    /**
     * Create a new YMarkdownCell that works standalone. It cannot be
     * inserted into a YNotebook because the Yjs model is already
     * attached to an anonymous Y.Doc instance.
     */
    static createStandalone(id) {
        return super.createStandalone(id);
    }
    /**
     * String identifying the type of cell.
     */
    get cell_type() {
        return 'markdown';
    }
    /**
     * Serialize the model to JSON.
     */
    toJSON() {
        return {
            id: this.getId(),
            cell_type: 'markdown',
            source: this.getSource(),
            metadata: this.getMetadata(),
            attachments: this.getAttachments()
        };
    }
}
/**
 * Shared implementation of the Shared Document types.
 *
 * Shared cells can be inserted into a SharedNotebook.
 * Shared cells only start emitting events when they are connected to a SharedNotebook.
 *
 * "Standalone" cells must not be inserted into a (Shared)Notebook.
 * Standalone cells emit events immediately after they have been created, but they must not
 * be included into a (Shared)Notebook.
 */
export class YNotebook extends YDocument {
    /**
     * Create a new notebook
     *
     * #### Notes
     * The document is empty and must be populated
     *
     * @param options
     */
    constructor(options = {}) {
        var _a;
        super();
        /**
         * YJS map for the notebook metadata
         */
        this.ymeta = this.ydoc.getMap('meta');
        /**
         * Handle a change to the ystate.
         */
        this._onMetaChanged = (event) => {
            var _a;
            if (event.keysChanged.has('metadata')) {
                const change = event.changes.keys.get('metadata');
                const metadataChange = {
                    oldValue: (change === null || change === void 0 ? void 0 : change.oldValue) ? change.oldValue : undefined,
                    newValue: this.getMetadata()
                };
                const oldValue = (_a = metadataChange.oldValue) !== null && _a !== void 0 ? _a : {};
                const oldKeys = Object.keys(oldValue);
                const newKeys = Object.keys(metadataChange.newValue);
                for (let key of new Set(oldKeys.concat(newKeys))) {
                    if (!oldKeys.includes(key)) {
                        this._metadataChanged.emit({
                            key,
                            newValue: metadataChange.newValue[key],
                            type: 'add'
                        });
                    }
                    else if (!newKeys.includes(key)) {
                        this._metadataChanged.emit({
                            key,
                            oldValue: metadataChange.oldValue[key],
                            type: 'remove'
                        });
                    }
                    else if (!JSONExt.deepEqual(oldValue[key], metadataChange.newValue[key])) {
                        this._metadataChanged.emit({
                            key,
                            newValue: metadataChange.newValue[key],
                            oldValue: metadataChange.oldValue[key],
                            type: 'change'
                        });
                    }
                }
                this._changed.emit({ metadataChange });
            }
            if (event.keysChanged.has('nbformat')) {
                const change = event.changes.keys.get('nbformat');
                const nbformatChanged = {
                    key: 'nbformat',
                    oldValue: (change === null || change === void 0 ? void 0 : change.oldValue) ? change.oldValue : undefined,
                    newValue: this.nbformat
                };
                this._changed.emit({ nbformatChanged });
            }
            if (event.keysChanged.has('nbformat_minor')) {
                const change = event.changes.keys.get('nbformat_minor');
                const nbformatChanged = {
                    key: 'nbformat_minor',
                    oldValue: (change === null || change === void 0 ? void 0 : change.oldValue) ? change.oldValue : undefined,
                    newValue: this.nbformat_minor
                };
                this._changed.emit({ nbformatChanged });
            }
        };
        /**
         * Handle a change to the list of cells.
         */
        this._onYCellsChanged = (event) => {
            // update the type cell mapping by iterating through the added/removed types
            event.changes.added.forEach(item => {
                const type = item.content.type;
                if (!this._ycellMapping.has(type)) {
                    const c = createCellModelFromSharedType(type, { notebook: this });
                    c.setUndoManager();
                    this._ycellMapping.set(type, c);
                }
            });
            event.changes.deleted.forEach(item => {
                const type = item.content.type;
                const model = this._ycellMapping.get(type);
                if (model) {
                    model.dispose();
                    this._ycellMapping.delete(type);
                }
            });
            let index = 0;
            // this reflects the event.changes.delta, but replaces the content of delta.insert with ycells
            const cellsChange = [];
            event.changes.delta.forEach((d) => {
                if (d.insert != null) {
                    const insertedCells = d.insert.map((ycell) => this._ycellMapping.get(ycell));
                    cellsChange.push({ insert: insertedCells });
                    this.cells.splice(index, 0, ...insertedCells);
                    index += d.insert.length;
                }
                else if (d.delete != null) {
                    cellsChange.push(d);
                    this.cells.splice(index, d.delete);
                }
                else if (d.retain != null) {
                    cellsChange.push(d);
                    index += d.retain;
                }
            });
            this._changed.emit({
                cellsChange: cellsChange
            });
        };
        this._metadataChanged = new Signal(this);
        /**
         * Internal Yjs cells list
         */
        this._ycells = this.ydoc.getArray('cells');
        this._ycellMapping = new WeakMap();
        this._disableDocumentWideUndoRedo =
            (_a = options.disableDocumentWideUndoRedo) !== null && _a !== void 0 ? _a : false;
        this.cells = this._ycells.toArray().map(ycell => {
            if (!this._ycellMapping.has(ycell)) {
                this._ycellMapping.set(ycell, createCellModelFromSharedType(ycell, { notebook: this }));
            }
            return this._ycellMapping.get(ycell);
        });
        this.undoManager.addToScope(this._ycells);
        this._ycells.observe(this._onYCellsChanged);
        this.ymeta.observe(this._onMetaChanged);
    }
    /**
     * Wether the undo/redo logic should be
     * considered on the full document across all cells.
     *
     * Default: false
     */
    get disableDocumentWideUndoRedo() {
        return this._disableDocumentWideUndoRedo;
    }
    /**
     * Notebook metadata
     */
    get metadata() {
        return this.getMetadata();
    }
    set metadata(v) {
        this.setMetadata(v);
    }
    /**
     * Signal triggered when a metadata changes.
     */
    get metadataChanged() {
        return this._metadataChanged;
    }
    /**
     * nbformat major version
     */
    get nbformat() {
        return this.ymeta.get('nbformat');
    }
    set nbformat(value) {
        this.transact(() => {
            this.ymeta.set('nbformat', value);
        }, false);
    }
    /**
     * nbformat minor version
     */
    get nbformat_minor() {
        return this.ymeta.get('nbformat_minor');
    }
    set nbformat_minor(value) {
        this.transact(() => {
            this.ymeta.set('nbformat_minor', value);
        }, false);
    }
    /**
     * Dispose of the resources.
     */
    dispose() {
        if (this.isDisposed) {
            return;
        }
        this._ycells.unobserve(this._onYCellsChanged);
        this.ymeta.unobserve(this._onMetaChanged);
        super.dispose();
    }
    /**
     * Get a shared cell by index.
     *
     * @param index: Cell's position.
     *
     * @returns The requested shared cell.
     */
    getCell(index) {
        return this.cells[index];
    }
    /**
     * Add a shared cell at the notebook bottom.
     *
     * @param cell Cell to add.
     *
     * @returns The added cell.
     */
    addCell(cell) {
        return this.insertCell(this._ycells.length, cell);
    }
    /**
     * Insert a shared cell into a specific position.
     *
     * @param index: Cell's position.
     * @param cell: Cell to insert.
     *
     * @returns The inserted cell.
     */
    insertCell(index, cell) {
        return this.insertCells(index, [cell])[0];
    }
    /**
     * Insert a list of shared cells into a specific position.
     *
     * @param index: Position to insert the cells.
     * @param cells: Array of shared cells to insert.
     *
     * @returns The inserted cells.
     */
    insertCells(index, cells) {
        const yCells = cells.map(c => {
            const cell = createCell(c, this);
            this._ycellMapping.set(cell.ymodel, cell);
            return cell;
        });
        this.transact(() => {
            this._ycells.insert(index, yCells.map(cell => cell.ymodel));
        });
        yCells.forEach(c => {
            c.setUndoManager();
        });
        return yCells;
    }
    /**
     * Move a cell.
     *
     * @param fromIndex: Index of the cell to move.
     * @param toIndex: New position of the cell.
     */
    moveCell(fromIndex, toIndex) {
        this.moveCells(fromIndex, toIndex);
    }
    /**
     * Move cells.
     *
     * @param fromIndex: Index of the first cells to move.
     * @param toIndex: New position of the first cell (in the current array).
     * @param n: Number of cells to move (default 1)
     */
    moveCells(fromIndex, toIndex, n = 1) {
        // FIXME we need to use yjs move feature to preserve undo history
        const clones = new Array(n)
            .fill(true)
            .map((_, idx) => this.getCell(fromIndex + idx).toJSON());
        this.transact(() => {
            this._ycells.delete(fromIndex, n);
            this._ycells.insert(fromIndex > toIndex ? toIndex : toIndex - n + 1, clones.map(clone => createCell(clone, this).ymodel));
        });
    }
    /**
     * Remove a cell.
     *
     * @param index: Index of the cell to remove.
     */
    deleteCell(index) {
        this.deleteCellRange(index, index + 1);
    }
    /**
     * Remove a range of cells.
     *
     * @param from: The start index of the range to remove (inclusive).
     * @param to: The end index of the range to remove (exclusive).
     */
    deleteCellRange(from, to) {
        // Cells will be removed from the mapping in the model event listener.
        this.transact(() => {
            this._ycells.delete(from, to - from);
        });
    }
    /**
     * Delete a metadata notebook.
     *
     * @param key The key to delete
     */
    deleteMetadata(key) {
        if (typeof this.getMetadata(key) === 'undefined') {
            return;
        }
        const allMetadata = JSONExt.deepCopy(this.ymeta.get('metadata'));
        delete allMetadata[key];
        this.setMetadata(allMetadata);
    }
    getMetadata(key) {
        var _a;
        const meta = (_a = this.ymeta.get('metadata')) !== null && _a !== void 0 ? _a : {};
        if (typeof key === 'string') {
            const value = meta[key];
            return typeof value === 'undefined'
                ? undefined // undefined is converted to `{}` by `JSONExt.deepCopy`
                : JSONExt.deepCopy(meta[key]);
        }
        else {
            return JSONExt.deepCopy(meta);
        }
    }
    setMetadata(metadata, value) {
        var _a;
        if (typeof metadata === 'string') {
            if (typeof value === 'undefined') {
                throw new TypeError(`Metadata value for ${metadata} cannot be 'undefined'; use deleteMetadata.`);
            }
            if (JSONExt.deepEqual((_a = this.getMetadata(metadata)) !== null && _a !== void 0 ? _a : null, value)) {
                return;
            }
            const update = {};
            update[metadata] = value;
            this.updateMetadata(update);
        }
        else {
            if (!JSONExt.deepEqual(this.metadata, metadata)) {
                this.ymeta.set('metadata', JSONExt.deepCopy(metadata));
            }
        }
    }
    /**
     * Updates the metadata associated with the notebook.
     *
     * @param value: Metadata's attribute to update.
     */
    updateMetadata(value) {
        // TODO: Maybe modify only attributes instead of replacing the whole metadata?
        this.ymeta.set('metadata', { ...this.getMetadata(), ...value });
    }
    /**
     * Override the notebook with a JSON-serialized document.
     *
     * @param value The notebook
     */
    fromJSON(value) {
        this.transact(() => {
            this.nbformat = value.nbformat;
            this.nbformat_minor = value.nbformat_minor;
            const metadata = value.metadata;
            if (metadata['orig_nbformat'] !== undefined) {
                delete metadata['orig_nbformat'];
            }
            this.metadata = metadata;
            const useId = value.nbformat === 4 && value.nbformat_minor >= 5;
            const ycells = value.cells.map(cell => {
                if (!useId) {
                    delete cell.id;
                }
                return cell;
            });
            this.insertCells(this.cells.length, ycells);
            this.deleteCellRange(0, this.cells.length);
        });
    }
    /**
     * Serialize the model to JSON.
     */
    toJSON() {
        // strip cell ids if we have notebook format 4.0-4.4
        const pruneCellId = this.nbformat === 4 && this.nbformat_minor <= 4;
        return {
            metadata: this.metadata,
            nbformat_minor: this.nbformat_minor,
            nbformat: this.nbformat,
            cells: this.cells.map(c => {
                const raw = c.toJSON();
                if (pruneCellId) {
                    delete raw.id;
                }
                return raw;
            })
        };
    }
}
//# sourceMappingURL=ymodels.js.map