/* -----------------------------------------------------------------------------
| Copyright (c) Jupyter Development Team.
| Distributed under the terms of the Modified BSD License.
|----------------------------------------------------------------------------*/

import type * as nbformat from '@jupyterlab/nbformat';
import { JSONExt, JSONObject, PartialJSONValue, UUID } from '@lumino/coreutils';
import { ISignal, Signal } from '@lumino/signaling';
import { Awareness } from 'y-protocols/awareness';
import * as Y from 'yjs';
import type {
  CellChange,
  IExecutionState,
  IMapChange,
  ISharedAttachmentsCell,
  ISharedBaseCell,
  ISharedCodeCell,
  ISharedMarkdownCell,
  ISharedRawCell,
  SharedCell
} from './api.js';
import { IYText } from './ytext.js';
import { YNotebook } from './ynotebook.js';

/**
 * Cell type.
 */
export type YCellType = YRawCell | YCodeCell | YMarkdownCell;

/**
 * Create a new shared cell model given the YJS shared type.
 */
export const createCellModelFromSharedType = (
  type: Y.Map<any>,
  options: SharedCell.IOptions = {}
): YCellType => {
  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
 */
export const createCell = (
  cell: SharedCell.Cell,
  notebook?: YNotebook
): YCodeCell | YMarkdownCell | YRawCell => {
  const ymodel = new Y.Map();
  const ysource = new Y.Text();
  const ymetadata = new Y.Map();
  ymodel.set('source', ysource);
  ymodel.set('metadata', ymetadata);
  ymodel.set('cell_type', cell.cell_type);
  ymodel.set('id', cell.id ?? UUID.uuid4());

  let ycell: YCellType;
  switch (cell.cell_type) {
    case 'markdown': {
      ycell = new YMarkdownCell(ymodel, ysource, { notebook }, ymetadata);
      if (cell.attachments != null) {
        ycell.setAttachments(cell.attachments as nbformat.IAttachments);
      }
      break;
    }
    case 'code': {
      const youtputs = new Y.Array();
      ymodel.set('outputs', youtputs);
      ycell = new YCodeCell(
        ymodel,
        ysource,
        youtputs,
        {
          notebook
        },
        ymetadata
      );
      const cCell = cell as Partial<nbformat.ICodeCell>;
      ycell.execution_count = cCell.execution_count ?? null;
      if (cCell.outputs) {
        ycell.setOutputs(cCell.outputs);
      }
      break;
    }
    default: {
      // raw
      ycell = new YRawCell(ymodel, ysource, { notebook }, ymetadata);
      if (cell.attachments) {
        ycell.setAttachments(cell.attachments as nbformat.IAttachments);
      }
      break;
    }
  }

  if (cell.metadata != null) {
    ycell.setMetadata(cell.metadata);
  }
  if (cell.source != null) {
    ycell.setSource(
      typeof cell.source === 'string' ? cell.source : cell.source.join('')
    );
  }
  return ycell;
};

/**
 * Create a new cell that cannot be inserted in an existing shared model.
 *
 * @param cell Cell JSON representation
 */
export const createStandaloneCell = (cell: SharedCell.Cell): YCellType =>
  createCell(cell);

export class YBaseCell<Metadata extends nbformat.IBaseCellMetadata>
  implements ISharedBaseCell<Metadata>, IYText
{
  /**
   * 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 create(id?: string): YBaseCell<any> {
    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 \}
   * @param ymetadata Cell metadata
   */
  constructor(
    ymodel: Y.Map<any>,
    ysource: Y.Text,
    options: SharedCell.IOptions = {},
    ymetadata?: Y.Map<any>
  ) {
    this.ymodel = ymodel;
    this._ysource = ysource;
    this._ymetadata = ymetadata ?? this.ymodel.get('metadata');
    this._prevSourceLength = ysource ? ysource.length : 0;
    this._notebook = null;
    this._awareness = null;
    this._undoManager = null;
    if (options.notebook) {
      this._notebook = options.notebook as YNotebook;
      if (this._notebook.disableDocumentWideUndoRedo) {
        this._undoManager = new Y.UndoManager([this.ymodel], {
          trackedOrigins: new Set([this]),
          doc: this._notebook.ydoc
        });
      }
    } 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.
   */
  get awareness(): Awareness | null {
    return this._awareness ?? this.notebook?.awareness ?? null;
  }

  /**
   * The type of the cell.
   */
  get cell_type(): any {
    throw new Error('A YBaseCell must not be constructed');
  }

  /**
   * The changed signal.
   */
  get changed(): ISignal<this, CellChange> {
    return this._changed;
  }

  /**
   * Signal emitted when the cell is disposed.
   */
  get disposed(): ISignal<this, void> {
    return this._disposed;
  }

  /**
   * Cell id
   */
  get id(): string {
    return this.getId();
  }

  /**
   * Whether the model has been disposed or not.
   */
  get isDisposed(): boolean {
    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(): boolean {
    return this._notebook !== null;
  }

  /**
   * Cell metadata.
   *
   * #### Notes
   * You should prefer to access and modify the specific key of interest.
   */
  get metadata(): Partial<Metadata> {
    return this.getMetadata();
  }
  set metadata(v: Partial<Metadata>) {
    this.setMetadata(v);
  }

  /**
   * Signal triggered when the cell metadata changes.
   */
  get metadataChanged(): ISignal<this, IMapChange> {
    return this._metadataChanged;
  }

  /**
   * The notebook that this cell belongs to.
   */
  get notebook(): YNotebook | null {
    return this._notebook;
  }

  /**
   * Cell input content.
   */
  get source(): string {
    return this.getSource();
  }
  set source(v: string) {
    this.setSource(v);
  }

  /**
   * The cell undo manager.
   */
  get undoManager(): Y.UndoManager | null {
    if (!this.notebook) {
      return this._undoManager;
    }
    return this.notebook?.disableDocumentWideUndoRedo
      ? this._undoManager
      : this.notebook.undoManager;
  }

  readonly ymodel: Y.Map<any>;

  get ysource(): Y.Text {
    return this._ysource;
  }

  /**
   * Whether the object can undo changes.
   */
  canUndo(): boolean {
    return !!this.undoManager && this.undoManager.undoStack.length > 0;
  }

  /**
   * Whether the object can redo changes.
   */
  canRedo(): boolean {
    return !!this.undoManager && this.undoManager.redoStack.length > 0;
  }

  /**
   * Clear the change stack.
   */
  clearUndoHistory(): void {
    this.undoManager?.clear();
  }

  /**
   * Undo an operation.
   */
  undo(): void {
    this.undoManager?.undo();
  }

  /**
   * Redo an operation.
   */
  redo(): void {
    this.undoManager?.redo();
  }

  /**
   * Dispose of the resources.
   */
  dispose(): void {
    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 === this.notebook?.undoManager) {
        this._undoManager = null;
      } else {
        this._undoManager.destroy();
      }
    }
    this._disposed.emit();
    Signal.clearData(this);
  }

  /**
   * Get cell id.
   *
   * @returns Cell id
   */
  getId(): string {
    return this.ymodel.get('id');
  }

  /**
   * Gets cell's source.
   *
   * @returns Cell's source.
   */
  getSource(): string {
    return this.ysource.toString();
  }

  /**
   * Sets cell's source.
   *
   * @param value: New source.
   */
  setSource(value: string): void {
    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: number, end: number, value = ''): void {
    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: string): void {
    if (typeof this.getMetadata(key) === 'undefined') {
      return;
    }

    this.transact(() => {
      this._ymetadata.delete(key);

      const jupyter = this.getMetadata('jupyter') as any;
      if (key === 'collapsed' && jupyter) {
        // eslint-disable-next-line @typescript-eslint/no-unused-vars
        const { outputs_hidden, ...others } = jupyter;

        if (Object.keys(others).length === 0) {
          this._ymetadata.delete('jupyter');
        } else {
          this._ymetadata.set('jupyter', others);
        }
      } else if (key === 'jupyter') {
        this._ymetadata.delete('collapsed');
      }
    }, false);
  }

  /**
   * Returns all or a single metadata associated with the cell.
   *
   * @param key The metadata key
   * @returns cell's metadata.
   */
  getMetadata(): Partial<Metadata>;
  getMetadata(key: string): PartialJSONValue | undefined;
  getMetadata(key?: string): Partial<Metadata> | PartialJSONValue | undefined {
    const metadata = this._ymetadata;

    // Transiently the metadata can be missing - like during destruction
    if (metadata === undefined) {
      return undefined;
    }

    if (typeof key === 'string') {
      const value = metadata.get(key);
      return typeof value === 'undefined'
        ? undefined // undefined is converted to `{}` by `JSONExt.deepCopy`
        : JSONExt.deepCopy(metadata.get(key));
    } else {
      return JSONExt.deepCopy(metadata.toJSON());
    }
  }

  /**
   * Sets all or a single cell metadata.
   *
   * If only one argument is provided, it will override all cell metadata.
   * Otherwise a single key will be set to a new value.
   *
   * @param metadata Cell's metadata key or cell's metadata.
   * @param value Metadata value
   */
  setMetadata(metadata: Partial<Metadata>): void;
  setMetadata(metadata: string, value: PartialJSONValue): void;
  setMetadata(
    metadata: Partial<Metadata> | string,
    value?: PartialJSONValue
  ): void {
    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((this.getMetadata(key) as any) ?? null, value)) {
        return;
      }

      this.transact(() => {
        this._ymetadata.set(key, value);

        if (key === 'collapsed') {
          const jupyter = ((this.getMetadata('jupyter') as any) ?? {}) as any;
          if (jupyter.outputs_hidden !== value) {
            this.setMetadata('jupyter', {
              ...jupyter,
              outputs_hidden: value
            });
          }
        } else if (key === 'jupyter') {
          const isHidden = (value as JSONObject)['outputs_hidden'];
          if (typeof isHidden !== 'undefined') {
            if (this.getMetadata('collapsed') !== isHidden) {
              this.setMetadata('collapsed', isHidden);
            }
          } else {
            this.deleteMetadata('collapsed');
          }
        }
      }, false);
    } else {
      const clone = JSONExt.deepCopy(metadata) as any;
      if (clone.collapsed != null) {
        clone.jupyter = clone.jupyter || {};
        (clone as any).jupyter.outputs_hidden = clone.collapsed;
      } else if (clone?.jupyter?.outputs_hidden != null) {
        clone.collapsed = clone.jupyter.outputs_hidden;
      }
      if (!JSONExt.deepEqual(clone, this.getMetadata())) {
        this.transact(() => {
          for (const [key, value] of Object.entries(clone)) {
            this._ymetadata.set(key, value);
          }
        }, false);
      }
    }
  }

  /**
   * Serialize the model to JSON.
   */
  toJSON(): nbformat.IBaseCell {
    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.
   *
   * @param f Transaction to execute
   * @param undoable Whether to track the change in the action history or not (default `true`)
   */
  transact(f: () => void, undoable = true, origin: any = null): void {
    !this.notebook || this.notebook.disableDocumentWideUndoRedo
      ? this.ymodel.doc == null
        ? f()
        : this.ymodel.doc.transact(f, undoable ? this : origin)
      : this.notebook.transact(f, undoable);
  }

  /**
   * Extract changes from YJS events
   *
   * @param events YJS events
   * @returns Cell changes
   */
  protected getChanges(events: Y.YEvent<any>[]): Partial<CellChange> {
    const changes: CellChange = {};

    const sourceEvent = events.find(
      event => event.target === this.ymodel.get('source')
    );
    if (sourceEvent) {
      changes.sourceChange = sourceEvent.changes.delta as any;
    }

    const metadataEvents = events.find(
      event => event.target === this._ymetadata
    );
    if (metadataEvents) {
      changes.metadataChange = metadataEvents.changes.keys;

      metadataEvents.changes.keys.forEach((change, key) => {
        switch (change.action) {
          case 'add':
            this._metadataChanged.emit({
              key,
              newValue: this._ymetadata.get(key),
              type: 'add'
            });
            break;
          case 'delete':
            this._metadataChanged.emit({
              key,
              oldValue: change.oldValue,
              type: 'remove'
            });
            break;
          case 'update':
            {
              const newValue = this._ymetadata.get(key);
              const oldValue = change.oldValue;
              let equal = true;
              if (typeof oldValue == 'object' && typeof newValue == 'object') {
                equal = JSONExt.deepEqual(oldValue, newValue);
              } else {
                equal = oldValue === newValue;
              }

              if (!equal) {
                this._metadataChanged.emit({
                  key,
                  type: 'change',
                  oldValue,
                  newValue
                });
              }
            }
            break;
        }
      });
    }

    const modelEvent = events.find(event => event.target === this.ymodel) as
      | undefined
      | Y.YMapEvent<any>;

    // 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;
  }

  /**
   * Handle a change to the ymodel.
   */
  private _modelObserver = (
    events: Y.YEvent<any>[],
    transaction: Y.Transaction
  ) => {
    if (transaction.origin !== 'silent-change') {
      this._changed.emit(this.getChanges(events));
    }
  };

  protected _metadataChanged = new Signal<this, IMapChange>(this);
  /**
   * The notebook that this cell belongs to.
   */
  protected _notebook: YNotebook | null = null;
  private _awareness: Awareness | null;
  private _changed = new Signal<this, CellChange>(this);
  private _disposed = new Signal<this, void>(this);
  private _isDisposed = false;
  private _prevSourceLength: number;
  private _undoManager: Y.UndoManager | null = null;
  private _ymetadata: Y.Map<any>;
  private _ysource: Y.Text;
}

/**
 * Shareable code cell.
 */
export class YCodeCell
  extends YBaseCell<nbformat.IBaseCellMetadata>
  implements ISharedCodeCell
{
  /**
   * 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 create(id?: string): YCodeCell {
    return super.create(id) as YCodeCell;
  }

  /**
   * 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 \}
   * @param ymetadata Cell metadata
   */
  constructor(
    ymodel: Y.Map<any>,
    ysource: Y.Text,
    youtputs: Y.Array<any>,
    options: SharedCell.IOptions = {},
    ymetadata?: Y.Map<any>
  ) {
    super(ymodel, ysource, options, ymetadata);
    this._youtputs = youtputs;
  }

  /**
   * The type of the cell.
   */
  get cell_type(): 'code' {
    return 'code';
  }

  /**
   * The code cell's prompt number. Will be null if the cell has not been run.
   */
  get execution_count(): number | null {
    return this.ymodel.get('execution_count') || null;
  }
  set execution_count(count: number | null) {
    // 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);
      }, false);
    }
  }

  /**
   * The code cell's execution state.
   */
  get executionState(): IExecutionState {
    return this.ymodel.get('execution_state') ?? 'idle';
  }
  set executionState(state: IExecutionState) {
    if (this.ymodel.get('execution_state') !== state) {
      this.transact(() => {
        this.ymodel.set('execution_state', state);
      }, false);
    }
  }

  /**
   * Cell outputs.
   */
  get outputs(): Array<nbformat.IOutput> {
    return this.getOutputs();
  }
  set outputs(v: Array<nbformat.IOutput>) {
    this.setOutputs(v);
  }
  get youtputs(): Y.Array<any> {
    return this._youtputs;
  }

  /**
   * Execution, display, or stream outputs.
   */
  getOutputs(): Array<nbformat.IOutput> {
    return JSONExt.deepCopy(this._youtputs.toJSON());
  }

  createOutputs(outputs: Array<nbformat.IOutput>): Array<Y.Map<any>> {
    const newOutputs: Array<Y.Map<any>> = [];
    for (const output of JSONExt.deepCopy(outputs)) {
      let _newOutput1: { [id: string]: any };
      if (output.output_type === 'stream') {
        // Set the text field as a Y.Text
        const { text, ...outputWithoutText } = output;
        _newOutput1 = outputWithoutText;
        const newText = new Y.Text();
        let _text = text instanceof Array ? text.join() : (text as string);
        newText.insert(0, _text);
        _newOutput1['text'] = newText;
      } else {
        _newOutput1 = output;
      }
      const _newOutput2: [string, any][] = [];
      for (const [key, value] of Object.entries(_newOutput1)) {
        _newOutput2.push([key, value]);
      }
      const newOutput = new Y.Map(_newOutput2);
      newOutputs.push(newOutput);
    }
    return newOutputs;
  }

  /**
   * Replace all outputs.
   */
  setOutputs(outputs: Array<nbformat.IOutput>): void {
    this.transact(() => {
      this._youtputs.delete(0, this._youtputs.length);
      const newOutputs = this.createOutputs(outputs);
      this._youtputs.insert(0, newOutputs);
    }, false);
  }

  /**
   * Remove text from a stream output.
   */
  removeStreamOutput(index: number, start: number, origin: any = null): void {
    this.transact(
      () => {
        const output = this._youtputs.get(index);
        const prevText = output.get('text') as Y.Text;
        const length = prevText.length - start;
        prevText.delete(start, length);
      },
      false,
      origin
    );
  }

  /**
   * Append text to a stream output.
   */
  appendStreamOutput(index: number, text: string, origin: any = null): void {
    this.transact(
      () => {
        const output = this._youtputs.get(index);
        const prevText = output.get('text') as Y.Text;
        prevText.insert(prevText.length, text);
      },
      false,
      origin
    );
  }

  /**
   * 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: number,
    end: number,
    outputs: Array<nbformat.IOutput> = [],
    origin: any = null
  ): void {
    const fin =
      end < this._youtputs.length ? end - start : this._youtputs.length - start;
    this.transact(
      () => {
        this._youtputs.delete(start, fin);
        const newOutputs = this.createOutputs(outputs);
        this._youtputs.insert(start, newOutputs);
      },
      false,
      origin
    );
  }

  /**
   * Serialize the model to JSON.
   */
  toJSON(): nbformat.ICodeCell {
    return {
      ...(super.toJSON() as nbformat.ICodeCell),
      outputs: this.getOutputs(),
      execution_count: this.execution_count
    };
  }

  /**
   * Extract changes from YJS events
   *
   * @param events YJS events
   * @returns Cell changes
   */
  protected getChanges(events: Y.YEvent<any>[]): Partial<CellChange> {
    const changes = super.getChanges(events);

    const streamOutputEvent = events.find(
      // Changes to the 'text' of a cell's stream output can be accessed like so:
      // ycell['outputs'][output_idx]['text']
      // This translates to an event path of: ['outputs', output_idx, 'text]
      event =>
        event.path.length === 3 &&
        event.path[0] === 'outputs' &&
        event.path[2] === 'text'
    );
    if (streamOutputEvent) {
      changes.streamOutputChange = streamOutputEvent.changes.delta as any;
    }

    const outputEvent = events.find(
      event => event.target === this.ymodel.get('outputs')
    );
    if (outputEvent) {
      changes.outputsChange = outputEvent.changes.delta as any;
    }

    const modelEvent = events.find(event => event.target === this.ymodel) as
      | undefined
      | Y.YMapEvent<any>;

    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')
      };
    }

    if (modelEvent && modelEvent.keysChanged.has('execution_state')) {
      const change = modelEvent.changes.keys.get('execution_state');
      changes.executionStateChange = {
        oldValue: change!.oldValue,
        newValue: this.ymodel.get('execution_state')
      };
    }

    return changes;
  }

  private _youtputs: Y.Array<Y.Map<any>>;
}

class YAttachmentCell
  extends YBaseCell<nbformat.IBaseCellMetadata>
  implements ISharedAttachmentsCell
{
  /**
   * Cell attachments
   */
  get attachments(): nbformat.IAttachments | undefined {
    return this.getAttachments();
  }
  set attachments(v: nbformat.IAttachments | undefined) {
    this.setAttachments(v);
  }

  /**
   * Gets the cell attachments.
   *
   * @returns The cell attachments.
   */
  getAttachments(): nbformat.IAttachments | undefined {
    return this.ymodel.get('attachments');
  }

  /**
   * Sets the cell attachments
   *
   * @param attachments: The cell attachments.
   */
  setAttachments(attachments: nbformat.IAttachments | undefined): void {
    this.transact(() => {
      if (attachments == null) {
        this.ymodel.delete('attachments');
      } else {
        this.ymodel.set('attachments', attachments);
      }
    }, false);
  }

  /**
   * Extract changes from YJS events
   *
   * @param events YJS events
   * @returns Cell changes
   */
  protected getChanges(events: Y.YEvent<any>[]): Partial<CellChange> {
    const changes = super.getChanges(events);

    const modelEvent = events.find(event => event.target === this.ymodel) as
      | undefined
      | Y.YMapEvent<any>;

    if (modelEvent && modelEvent.keysChanged.has('attachments')) {
      const change = modelEvent.changes.keys.get('attachments');
      changes.attachmentsChange = {
        oldValue: change!.oldValue,
        newValue: this.ymodel.get('attachments')
      };
    }

    return changes;
  }
}

/**
 * Shareable raw cell.
 */
export class YRawCell extends YAttachmentCell implements ISharedRawCell {
  /**
   * 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 create(id?: string): YRawCell {
    return super.create(id) as YRawCell;
  }

  /**
   * String identifying the type of cell.
   */
  get cell_type(): 'raw' {
    return 'raw';
  }

  /**
   * Serialize the model to JSON.
   */
  toJSON(): nbformat.IRawCell {
    return {
      id: this.getId(),
      cell_type: 'raw',
      source: this.getSource(),
      metadata: this.getMetadata(),
      attachments: this.getAttachments()
    };
  }
}

/**
 * Shareable markdown cell.
 */
export class YMarkdownCell
  extends YAttachmentCell
  implements ISharedMarkdownCell
{
  /**
   * 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 create(id?: string): YMarkdownCell {
    return super.create(id) as YMarkdownCell;
  }

  /**
   * String identifying the type of cell.
   */
  get cell_type(): 'markdown' {
    return 'markdown';
  }

  /**
   * Serialize the model to JSON.
   */
  toJSON(): nbformat.IMarkdownCell {
    return {
      id: this.getId(),
      cell_type: 'markdown',
      source: this.getSource(),
      metadata: this.getMetadata(),
      attachments: this.getAttachments()
    };
  }
}
