import {
  CommandToolbarButton,
  Toolbar,
  ToolbarButton
} from '@jupyterlab/apputils';
import { ActivityMonitor } from '@jupyterlab/coreutils';
import {
  ABCWidgetFactory,
  DocumentRegistry,
  IDocumentWidget,
  DocumentWidget
} from '@jupyterlab/docregistry';
import { PromiseDelegate } from '@lumino/coreutils';
import { Signal } from '@lumino/signaling';
import { TextRenderConfig } from '@jupyterlab/csvviewer';
import {
  DataGrid,
  TextRenderer,
  SelectionModel,
  DataModel,
  CellRenderer
} from '@lumino/datagrid';
import { Message } from '@lumino/messaging';
import { PanelLayout, Widget, LayoutItem } from '@lumino/widgets';
import { EditorModel } from './model';
import { RichMouseHandler } from './mousehandler';
import { numberToCharacter } from './_helper';
import { toArray, range } from '@lumino/algorithm';
import { CommandRegistry } from '@lumino/commands';
import { CommandIDs, LIGHT_EXTRA_STYLE, DARK_EXTRA_STYLE } from './index';
import { VirtualDOM, h } from '@lumino/virtualdom';
import { GridSearchService } from './searchservice';
import { Litestore } from './litestore';
import GhostSelectionModel from './selectionmodel';
import { Fields } from '@lumino/datastore';
import { ListField, MapField } from '@lumino/datastore';
import { unsaveDialog } from './dialog';
import { PaintedGrid } from './grid';
import { HeaderTextRenderer } from './headercelleditor';
import { RichKeyHandler } from './keyhandler';

const CSV_CLASS = 'jp-CSVViewer';
const CSV_GRID_CLASS = 'jp-CSVViewer-grid';
const COLUMN_HEADER_CLASS = 'jp-column-header';
const ROW_HEADER_CLASS = 'jp-row-header';
const BACKGROUND_CLASS = 'jp-background';
const DIRTY_CLASS = 'jp-mod-dirty';
const RENDER_TIMEOUT = 1000;

export class DSVEditor extends Widget {
  private _background: HTMLElement;
  private _ghostCorner: LayoutItem;
  private _ghostRow: LayoutItem;
  private _ghostColumn: LayoutItem;
  /**
   * Construct a new CSV viewer.
   */
  constructor(options: DSVEditor.IOptions) {
    super();

    const context = (this._context = options.context);
    const layout = (this.layout = new PanelLayout());

    this.addClass(CSV_CLASS);

    // Initialize the data grid.
    this._grid = new PaintedGrid({
      defaultSizes: {
        rowHeight: 21,
        columnWidth: 100,
        rowHeaderWidth: 60,
        columnHeaderHeight: 24
      },
      headerVisibility: 'all'
    });

    this._grid.addClass(CSV_GRID_CLASS);
    const keyHandler = new RichKeyHandler();
    this._grid.keyHandler = keyHandler;

    this._grid.copyConfig = {
      separator: '\t',
      format: DataGrid.copyFormatGeneric,
      headers: 'none',
      warningThreshold: 1e6
    };
    layout.addWidget(this._grid);

    // Add the mouse handler to the grid.
    const handler = new RichMouseHandler({ grid: this._grid });
    this._grid.mouseHandler = handler;

    // Connect to the mouse handler signals.
    handler.mouseUpSignal.connect(this._onMouseUp, this);
    handler.hoverSignal.connect(this._onMouseHover, this);

    // init search service to search for matches with the data grid
    this._searchService = new GridSearchService(this._grid);
    this._searchService.changed.connect(this._updateRenderer, this);

    // add the background column and row header elements
    this._background = VirtualDOM.realize(
      h.div({
        className: BACKGROUND_CLASS,
        style: {
          position: 'absolute',
          zIndex: '1'
        }
      })
    );
    this._rowHeader = VirtualDOM.realize(
      h.div({
        className: ROW_HEADER_CLASS,
        style: {
          position: 'absolute',
          zIndex: '2'
        }
      })
    );
    this._columnHeader = VirtualDOM.realize(
      h.div({
        className: COLUMN_HEADER_CLASS,
        style: {
          position: 'absolute',
          zIndex: '2'
        }
      })
    );

    // append the column and row headers to the viewport
    this._grid.viewport.node.appendChild(this._rowHeader);
    this._grid.viewport.node.appendChild(this._columnHeader);
    this._grid.viewport.node.appendChild(this._background);

    void this._context.ready.then(() => {
      this._updateGrid();
      this._revealed.resolve(undefined);
      // Throttle the rendering rate of the widget.
      this._monitor = new ActivityMonitor({
        signal: context.model.contentChanged,
        timeout: RENDER_TIMEOUT
      });
      this._monitor.activityStopped.connect(this._updateGrid, this);
    });
    this._grid.editingEnabled = true;
    this.commandSignal.connect(this._onCommand, this);
  }

  /**
   * The ghost row of the grid.
   */
  get ghostRow(): LayoutItem {
    return this._ghostRow;
  }

  /**
   * The ghost column of the grid.
   */
  get ghostColumn(): LayoutItem {
    return this._ghostColumn;
  }

  /**
   * The ghost corner of the grid.
   */
  get ghostCorner(): LayoutItem {
    return this._ghostCorner;
  }

  /**
   * The CSV widget's context.
   */
  get context(): DocumentRegistry.Context {
    return this._context;
  }

  /**
   * A promise that resolves when the csv viewer is ready to be revealed.
   */
  get revealed(): Promise<void> {
    return this._revealed.promise;
  }

  /**
   * The delimiter for the file.
   */
  get delimiter(): string {
    return this._delimiter;
  }
  set delimiter(value: string) {
    if (value === this._delimiter) {
      return;
    }
    this._delimiter = value;
    this._updateGrid();
  }

  /**
   * The style used by the data grid.
   */
  get style(): DataGrid.Style {
    return this._grid.style;
  }
  set style(value: DataGrid.Style) {
    this._grid.style = value;
  }

  /**
   * The style used by the data grid.
   */
  get extraStyle(): PaintedGrid.ExtraStyle {
    return this._grid.extraStyle;
  }
  set extraStyle(value: PaintedGrid.ExtraStyle) {
    this._grid.extraStyle = value;
  }

  /**
   * The config used to create text renderer.
   */
  set rendererConfig(rendererConfig: TextRenderConfig) {
    this._baseRenderer = rendererConfig;
    this._updateRenderer();
  }

  /**
   * The search service
   */
  get searchService(): GridSearchService {
    return this._searchService;
  }

  get grid(): PaintedGrid {
    return this._grid;
  }

  /**
   * The DataModel used to render the DataGrid
   */
  get dataModel(): EditorModel {
    return this._grid.dataModel as EditorModel;
  }

  get litestore(): Litestore {
    return this._litestore;
  }

  get commandSignal(): Signal<this, DSVEditor.Commands> {
    return this._commandSignal;
  }

  get dirty(): boolean {
    return this._dirty;
  }

  /**
   * Sets the dirty boolean while also toggling the DIRTY_CLASS
   */
  set dirty(dirty: boolean) {
    this._dirty = dirty;
    if (this.dirty && !this.title.className.includes(DIRTY_CLASS)) {
      this.title.className += DIRTY_CLASS;
    } else if (!this.dirty) {
      this.title.className = this.title.className.replace(DIRTY_CLASS, '');
    }
  }

  get rowsSelected(): number {
    const selection: SelectionModel.Selection = this._grid.selectionModel.currentSelection();
    if (!selection) {
      return 0;
    }
    const { r1, r2 } = selection;
    return Math.abs(r2 - r1) + 1;
  }

  get columnsSelected(): number {
    const selection: SelectionModel.Selection = this._grid.selectionModel.currentSelection();
    if (!selection) {
      return 0;
    }
    const { c1, c2 } = selection;
    return Math.abs(c2 - c1) + 1;
  }

  /**
   * Dispose of the resources used by the widget.
   */
  dispose(): void {
    if (this._monitor) {
      this._monitor.dispose();
    }
    super.dispose();
  }

  /**
   * Go to line
   */
  goToLine(lineNumber: number): void {
    this._grid.scrollToRow(lineNumber);
  }

  /**
   * Handle `'activate-request'` messages.
   */
  protected onActivateRequest(msg: Message): void {
    this.node.tabIndex = -1;
    this.node.focus();
  }

  /**
   * Guess the row delimiter if it was not supplied.
   * This will be fooled if a different line delimiter possibility appears in the first row.
   */
  private _guessRowDelimeter(data: string): string {
    const i = data.slice(0, 5000).indexOf('\r');
    if (i === -1) {
      return '\n';
    } else if (data[i + 1] === '\n') {
      return '\r\n';
    } else {
      return '\r';
    }
  }

  /**
   * Counts the occurrences of a substring from a given string
   */
  private _countOccurrences(
    string: string,
    substring: string,
    rowDelimiter: string
  ): number {
    let numCol = 0;
    let pos = 0;
    const l = substring.length;
    const firstRow = string.slice(0, string.indexOf(rowDelimiter));

    pos = firstRow.indexOf(substring, pos);
    while (pos !== -1) {
      numCol++;
      pos += l;
      pos = firstRow.indexOf(substring, pos);
    }
    // number of columns is the amount of columns + 1
    return numCol + 1;
  }

  /**
   * Adds the a column header of alphabets to the top of the data (A..Z,AA..ZZ,AAA...)
   * @param colDelimiter The delimiter used to separated columns (commas, tabs, spaces)
   */
  protected _buildColHeader(colDelimiter: string): string {
    const rawData = this._context.model.toString();
    const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
    // when the model is first created, we don't know how many columns or what the row delimeter is
    const rowDelimiter = this._guessRowDelimeter(rawData);
    const numCol = this._countOccurrences(rawData, colDelimiter, rowDelimiter);

    // if only single alphabets fix the string
    if (numCol <= 26) {
      return (
        alphabet.slice(0, numCol).split('').join(colDelimiter) + rowDelimiter
      );
    }
    // otherwise compute the column header with multi-letters (AA..)
    else {
      // get all single letters
      let columnHeader = alphabet.split('').join(colDelimiter);
      // find the rest
      for (let i = 27; i < numCol; i++) {
        columnHeader += colDelimiter + numberToCharacter(i);
      }
      return columnHeader + rowDelimiter;
    }
  }

  /**
   * Create the model for the grid.
   * TODO: is there a reason we can't just do this once in the constructor?
   */
  protected _updateGrid(): void {
    // Bail early if we already have a data model installed.
    if (this.dataModel) {
      return;
    }

    const delimiter = this.delimiter;
    const data = this._context.model.toString();
    const dataModel = (this._grid.dataModel = new EditorModel({
      data,
      delimiter
    }));

    this._grid.selectionModel = new GhostSelectionModel({ dataModel });

    // create litestore
    this._litestore = new Litestore({
      id: 0,
      schemas: [DSVEditor.DATAMODEL_SCHEMA]
    });

    // Give the litestore as a property of the model for it to read from.
    dataModel.litestore = this._litestore;

    // Define the initial update object for the litestore.
    const update: DSVEditor.ModelChangedArgs = {};

    // Define the initial state of the row and column map.
    const rowUpdate = {
      index: 0,
      remove: 0,
      values: toArray(range(0, this.dataModel.totalRows))
    };
    const columnUpdate = {
      index: 0,
      remove: 0,
      values: toArray(range(0, this.dataModel.totalColumns))
    };

    // Add the map updates to the update object.
    update.rowUpdate = rowUpdate;
    update.columnUpdate = columnUpdate;

    // Set an indicator to show that this is the initial update.
    update.type = 'init';

    // set inital status of litestore
    this.updateModel(update);

    // Connect to the the model signals.
    dataModel.onChangedSignal.connect(this._onModelSignal, this);
    dataModel.isDataFormattedChanged.connect(this._updateRenderer, this);

    // Update the div elements of the grid.
    this._updateContextElements();
  }

  /**
   * Update the renderer for the grid.
   */
  private _updateRenderer(): void {
    if (this._baseRenderer === null) {
      return;
    }
    const isDataFormatted = this.dataModel && this.dataModel.isDataFormatted;
    const rendererConfig = this._baseRenderer;
    const renderer = new TextRenderer({
      textColor: rendererConfig.textColor,
      horizontalAlignment: isDataFormatted
        ? this.cellHorizontalAlignmentRendererFunc()
        : rendererConfig.horizontalAlignment,
      backgroundColor: this._searchService.cellBackgroundColorRendererFunc(
        rendererConfig
      ),
      font: '11px sans-serif'
    });
    const rowHeaderRenderer = new TextRenderer({
      textColor: rendererConfig.textColor,
      horizontalAlignment: 'center',
      backgroundColor: this._searchService.cellBackgroundColorRendererFunc(
        rendererConfig
      ),
      font: '11px sans-serif'
    });
    const headerRenderer = new HeaderTextRenderer({
      textColor: rendererConfig.textColor,
      horizontalAlignment: isDataFormatted ? 'left' : 'center',
      backgroundColor: this._searchService.cellBackgroundColorRendererFunc(
        rendererConfig
      ),
      font: '11px sans-serif',
      indent: 25,
      dataDetection: isDataFormatted
    });

    this._grid.cellRenderers.update({
      body: renderer,
      'column-header': headerRenderer,
      'corner-header': renderer,
      'row-header': rowHeaderRenderer
    });
  }

  cellHorizontalAlignmentRendererFunc(): CellRenderer.ConfigOption<TextRenderer.HorizontalAlignment> {
    return ({ region, row, column }) => {
      const { type } = this.dataModel.dataTypes[column];
      if (region !== 'body' || type === 'boolean') {
        return 'center';
      }
      return type === 'number' || type === 'integer' ? 'right' : 'left';
    };
  }

  /**
   * Called every time the datamodel updates
   * Updates the file and the litestore
   * @param emitter
   * @param args The row, column, value, record update, selection model
   */
  private _onModelSignal(
    emitter: EditorModel,
    args: DSVEditor.ModelChangedArgs | null
  ): void {
    this.updateModel(args);
  }

  /**
   * Serializes and saves the file (default: asynchronous)
   * @param [exiting] - False to save asynchronously
   */
  async save(exiting = false): Promise<void> {
    const newString = this.dataModel.updateString();
    this.context.model.fromString(newString);

    exiting ? await this.context.save() : this.context.save();

    // reset boolean since no new changes exist
    this.dirty = false;
  }

  // private _cancelEditing(emitter: EditorModel): void {
  //   this._grid.editorController.cancel();
  // }

  /**
   * Handles all changes to the data model
   * @param emitter
   * @param command
   */
  private _onCommand(emitter: DSVEditor, command: DSVEditor.Commands): void {
    const selectionModel = this._grid.selectionModel;
    const selection = selectionModel.currentSelection();
    const rowSpan = this.rowsSelected;
    const colSpan = this.columnsSelected;
    let r1, r2, c1, c2: number;

    // grab selection if it exists
    if (selection) {
      // r1 and c1 are always first row/column
      r1 = Math.min(selection.r1, selection.r2);
      r2 = Math.max(selection.r1, selection.r2);
      c1 = Math.min(selection.c1, selection.c2);
      c2 = Math.max(selection.c1, selection.c2);
    }

    // Set up the update object for the litestore.
    let update: DSVEditor.ModelChangedArgs | null = null;

    switch (command) {
      case 'insert-rows-above': {
        update = this.dataModel.addRows('body', r1, rowSpan);
        break;
      }
      case 'insert-rows-below': {
        update = this.dataModel.addRows('body', r2 + 1, rowSpan);

        // move the selection down a row to account for the new row being inserted
        r1 += rowSpan;
        r2 += rowSpan;
        break;
      }
      case 'insert-columns-left': {
        update = this.dataModel.addColumns('body', c1, colSpan);
        break;
      }
      case 'insert-columns-right': {
        update = this.dataModel.addColumns('body', c2 + 1, colSpan);

        // move the selection right a column to account for the new column being inserted
        c1 += colSpan;
        c2 += colSpan;
        break;
      }
      case 'remove-rows': {
        update = this.dataModel.removeRows('body', r1, rowSpan);
        break;
      }
      case 'remove-columns': {
        update = this.dataModel.removeColumns('body', c1, colSpan);
        break;
      }
      case 'cut-cells':
        // Copy to the OS clipboard.
        this._grid.copyToClipboard();

        // Cut the cell selection.
        update = this.dataModel.cut('body', r1, c1, r2, c2);

        break;
      case 'copy-cells': {
        // Copy to the OS clipboard.
        this._grid.copyToClipboard();

        // Make a local copy of the cells.
        this.dataModel.copy('body', r1, c1, r2, c2);
        break;
      }
      case 'paste-cells': {
        // Paste the cells in the region.
        update = this.dataModel.paste('body', r1, c1);

        // By default, upper left cell get's re-edited, so we need to cancel.
        this._cancelEditing();
        break;
      }
      case 'clear-cells': {
        update = this.dataModel.clearCells('body', { r1, r2, c1, c2 });
        break;
      }
      case 'clear-rows': {
        const rowSpan = Math.abs(r1 - r2) + 1;
        update = this.dataModel.clearRows('body', r1, rowSpan);
        break;
      }
      case 'clear-columns': {
        const columnSpan = Math.abs(c1 - c2) + 1;
        update = this.dataModel.clearColumns('body', c1, columnSpan);
        break;
      }
      case 'undo': {
        // check to see if an undo exists (one undo will exist because that's the initial transaction)
        if (this._litestore.transactionStore.undoStack.length === 1) {
          return;
        }

        const { gridState, selection } = this._litestore.getRecord({
          schema: DSVEditor.DATAMODEL_SCHEMA,
          record: DSVEditor.RECORD_ID
        });

        this._litestore.undo();

        // Have the model emit the opposite change to the Grid.
        this.dataModel.emitOppositeChange(gridState);

        this._grid.selectCells(selection);

        break;
      }
      case 'redo': {
        // check to see if an redo exists (one redo will exist because that's the initial transaction)
        if (this._litestore.transactionStore.redoStack.length === 0) {
          return;
        }

        // Redo first, then get the new selection and the new grid change.
        this._litestore.redo();
        const { gridState, selection } = this._litestore.getRecord({
          schema: DSVEditor.DATAMODEL_SCHEMA,
          record: DSVEditor.RECORD_ID
        });

        // Have the data model emit the grid change to the grid.
        this.dataModel.emitCurrentChange(gridState.nextChange);

        if (!selection) {
          break;
        }
        const command = gridState.nextCommand;
        const gridChange = gridState.nextChange;

        let { r1, r2, c1, c2 } = selection;
        let move: DataModel.ChangedArgs;
        // handle special cases for selection
        if (command === 'insert-rows-below') {
          r1 += rowSpan;
          r2 += rowSpan;
        } else if (command === 'insert-columns-right') {
          c1 += colSpan;
          c2 += colSpan;
        } else if (command === 'move-rows') {
          move = gridChange as DataModel.RowsMovedArgs;
          r1 = move.destination;
          r2 = move.destination;
        } else if (command === 'move-columns') {
          move = gridChange as DataModel.ColumnsMovedArgs;
          c1 = move.destination;
          c2 = move.destination;
        }

        // Make the new selection.
        this._grid.selectCells({ r1, r2, c1, c2 });
        break;
      }
      case 'save':
        this.save();
        break;
    }
    if (update) {
      update.selection = selection;
      // Add the command to the grid state.
      update.gridStateUpdate.nextCommand = command;
      this._grid.selectCells({ r1, r2, c1, c2 });
    }
    this.updateModel(update);
  }

  /**
   * Updates the current transaction with the raw data, header, and changeArgs
   * @param update The modelChanged args for the Datagrid (may be null)
   */
  public updateModel(update?: DSVEditor.ModelChangedArgs): void {
    if (update) {
      // If no selection property was passed in, record the current selection.
      if (!update.selection) {
        update.selection = this._grid.selectionModel.currentSelection();
      }

      // Update the litestore.
      this._litestore.beginTransaction();
      this._litestore.updateRecord(
        {
          schema: DSVEditor.DATAMODEL_SCHEMA,
          record: DSVEditor.RECORD_ID
        },
        {
          rowMap: update.rowUpdate || DSVEditor.NULL_NUM_SPLICE,
          columnMap: update.columnUpdate || DSVEditor.NULL_NUM_SPLICE,
          valueMap: update.valueUpdate || null,
          selection: update.selection || null,
          gridState: update.gridStateUpdate || null
        }
      );
      this._litestore.endTransaction();

      // Bail before setting dirty if this is an init command.
      if (update.type === 'init') {
        return;
      }
      this.dirty = true;
    }

    // Recompute all of the metadata.
    // TODO: integrate the metadata with the rest of the model.
    if (this.dataModel.isDataFormatted) {
      this.dataModel.dataTypes = this.dataModel.resetMetadata();
      this._updateRenderer();
    }
  }

  protected getSelectedRange(): SelectionModel.Selection {
    const selections = toArray(this._grid.selectionModel.selections());
    if (selections.length === 0) {
      return;
    }
    return selections[0];
  }

  protected onAfterAttach(msg: Message): void {
    super.onAfterAttach(msg);
    this.node.addEventListener('paste', this._handlePaste.bind(this));
  }

  private _handlePaste(event: ClipboardEvent): void {
    const copiedText: string = event.clipboardData.getData('text/plain');
    // prevent default behavior
    event.preventDefault();
    event.stopPropagation();
    const { r1, r2, c1, c2 } = this.getSelectedRange();
    const row = Math.min(r1, r2);
    const column = Math.min(c1, c2);
    const update = this.dataModel.paste('body', row, column, copiedText);
    this._cancelEditing();
    this.updateModel(update);
  }

  private _cancelEditing(): void {
    this._grid.editorController.cancel();
  }

  /**
   * Updates the context menu elements.
   */
  private _updateContextElements(): void {
    // calculate dimensions for the ghost row/column
    const ghostRow = this._grid.rowSize(
      'body',
      this._grid.rowCount('body') - 1
    );
    const ghostColumn = this._grid.columnSize(
      'body',
      this._grid.columnCount('body') - 1
    );
    // Update the column header, row header, and background elements.
    this._background.style.width = `${this._grid.bodyWidth - ghostColumn}px`;
    this._background.style.height = `${this._grid.bodyHeight - ghostRow}px`;
    this._background.style.left = `${this._grid.headerWidth}px`;
    this._background.style.top = `${this._grid.headerHeight}px`;
    this._columnHeader.style.left = `${this._grid.headerWidth}px`;
    this._columnHeader.style.height = `${this._grid.headerHeight}px`;
    this._columnHeader.style.width = `${this._grid.bodyWidth}px`;
    this._rowHeader.style.top = `${this._grid.headerHeight}px`;
    this._rowHeader.style.width = `${this._grid.headerWidth}px`;
    this._rowHeader.style.height = `${this._grid.bodyHeight}px`;
  }

  /**
   * Handles a mouse up signal.
   */
  private _onMouseUp(
    emitter: RichMouseHandler,
    hit: DataGrid.HitTestResult
  ): void {
    // Update the context menu elements as they may have moved.
    this._updateContextElements();
  }

  /**
   * A handler for the on mouse up signal
   */
  private _onMouseHover(
    emitter: RichMouseHandler,
    hoverRegion: 'ghost-row' | 'ghost-column' | null
  ): void {
    // Switch both to non-hovered state.
    const style = { ...this._grid.extraStyle } as PaintedGrid.ExtraStyle;
    if (this.grid.style.voidColor === '#F3F3F3') {
      style.ghostColumnColor = LIGHT_EXTRA_STYLE.ghostColumnColor;
      style.ghostRowColor = LIGHT_EXTRA_STYLE.ghostRowColor;
    } else {
      style.ghostColumnColor = DARK_EXTRA_STYLE.ghostColumnColor;
      style.ghostRowColor = DARK_EXTRA_STYLE.ghostRowColor;
    }
    switch (hoverRegion) {
      case null: {
        break;
      }
      case 'ghost-row': {
        style.ghostRowColor = 'rgba(0, 0, 0, 0)';
        break;
      }
      case 'ghost-column': {
        style.ghostColumnColor = 'rgba(0, 0, 0, 0)';
        break;
      }
    }
    // Schedule a repaint of the grid.
    this._grid.extraStyle = style;
  }

  private _context: DocumentRegistry.Context;
  private _grid: PaintedGrid;
  private _searchService: GridSearchService;
  private _monitor: ActivityMonitor<
    DocumentRegistry.IModel,
    void
  > | null = null;
  private _delimiter = ',';
  private _revealed = new PromiseDelegate<void>();
  private _baseRenderer: TextRenderConfig | null = null;
  private _litestore: Litestore;
  private _dirty = false;

  // Signals for basic editing functionality
  private _commandSignal = new Signal<this, DSVEditor.Commands>(this);
  private _columnHeader: HTMLElement;
  private _rowHeader: HTMLElement;
}

export namespace DSVEditor {
  /**
   * The Grid update args
   */
  export type GridState = {
    currentRows: number;
    currentColumns: number;
    nextChange: DataModel.ChangedArgs;
    nextCommand?: DSVEditor.Commands;
  };
  /**
   * The types of commands that can be made to the model.
   */
  export type Commands =
    | 'init'
    | 'insert-rows-above'
    | 'insert-rows-below'
    | 'insert-columns-right'
    | 'insert-columns-left'
    | 'remove-rows'
    | 'remove-columns'
    | 'move-rows'
    | 'move-columns'
    | 'clear-cells'
    | 'clear-rows'
    | 'clear-columns'
    | 'cut-cells'
    | 'copy-cells'
    | 'paste-cells'
    | 'undo'
    | 'redo'
    | 'save';
  /**

   * The arguments emitted to the Editor when the datamodel changes
   */
  export type ModelChangedArgs = {
    rowUpdate?: ListField.Update<number>;
    columnUpdate?: ListField.Update<number>;
    valueUpdate?: MapField.Update<string>;
    gridStateUpdate?: GridState;
    type?: string;
    selection?: SelectionModel.Selection;
  };

  export const SCHEMA_ID = 'datamodel';
  export const RECORD_ID = 'datamodel';
  export const DATAMODEL_SCHEMA = {
    id: SCHEMA_ID,
    fields: {
      rowMap: Fields.List<number>(),
      columnMap: Fields.List<number>(),
      valueMap: Fields.Map<string>(),
      selection: Fields.Register<SelectionModel.Selection>({
        value: null
      }),
      gridState: Fields.Register<GridState>({
        value: null
      }),
      type: Fields.String()
    }
  };
  export const NULL_NUMS: number[] = [];
  export const NULL_NUM_SPLICE = { index: 0, remove: 0, values: NULL_NUMS };
  export const NULL_CHANGE: GridState[] = [];
  export const NULL_CHANGE_SPLICE = {
    index: 0,
    remove: 0,
    values: NULL_CHANGE
  };
}

export class EditableCSVDocumentWidget extends DocumentWidget<DSVEditor> {
  constructor(options: EditableCSVDocumentWidget.IOptions) {
    let { content, reveal } = options;
    const { context, commandRegistry, ...other } = options;
    content = content || new DSVEditor({ context });
    reveal = Promise.all([reveal, content.revealed]);
    super({ context, content, reveal, ...other });

    // add commands to the toolbar
    const commands = commandRegistry;
    const {
      save,
      undo,
      redo,
      cutToolbar,
      copyToolbar,
      pasteToolbar
    } = CommandIDs;

    this.toolbar.addItem(
      'save',
      new CommandToolbarButton({ commands, id: save })
    );
    this.toolbar.addItem(
      'undo',
      new CommandToolbarButton({ commands, id: undo })
    );
    this.toolbar.addItem(
      'redo',
      new CommandToolbarButton({ commands, id: redo })
    );
    this.toolbar.addItem(
      'cut',
      new CommandToolbarButton({ commands, id: cutToolbar })
    );
    this.toolbar.addItem(
      'copy',
      new CommandToolbarButton({ commands, id: copyToolbar })
    );
    this.toolbar.addItem(
      'paste',
      new CommandToolbarButton({ commands, id: pasteToolbar })
    );

    /* possible feature
    const filterData = new FilterButton({ selected: content.delimiter });
    this.toolbar.addItem('filter-data', filterData);
    */

    this.toolbar.addItem('spacer', Toolbar.createSpacerItem());
    this.toolbar.addItem(
      'format-data',
      new ToolbarButton({
        label: 'Format Data',
        iconClass: 'jp-ToggleSwitch',
        tooltip: 'Click to format the data based on the column type',
        onClick: (): void => this.toggleDataDetection()
      })
    );
  }

  toggleDataDetection(): void {
    const isDataFormatted = this.content.dataModel.isDataFormatted;
    if (!isDataFormatted) {
      this.node.setAttribute('isDataFormatted', 'true');
    } else {
      this.node.removeAttribute('isDataFormatted');
    }
    this.content.dataModel.isDataFormatted = !isDataFormatted;
  }

  /**
   * Disposes the current widget, handles save dialog
   */
  async dispose(): Promise<void> {
    // if there are unsaved changes, prompt dialog
    if (this.content.dirty && !this.isDisposed) {
      const dialog = unsaveDialog(this.content);
      const result = await dialog.launch();

      dialog.dispose();
      // on Cancel, remove dialog
      if (result.button.label === 'Cancel') {
        return;
      }

      // on Save, save the file
      if (result.button.label === 'Save') {
        await this.content.save(true);
      }
    }
    super.dispose();
  }

  /**
   * Set URI fragment identifier for rows
   */
  setFragment(fragment: string): void {
    const parseFragments = fragment.split('=');

    // TODO: expand to allow columns and cells to be selected
    // reference: https://tools.ietf.org/html/rfc7111#section-3
    if (parseFragments[0] !== '#row') {
      return;
    }

    // multiple rows, separated by semi-colons can be provided, we will just
    // go to the top one
    let topRow = parseFragments[1].split(';')[0];

    // a range of rows can be provided, we will take the first value
    topRow = topRow.split('-')[0];

    // go to that row
    void this.context.ready.then(() => {
      this.content.goToLine(Number(topRow));
    });
  }
}
export declare namespace EditableCSVDocumentWidget {
  interface IOptions extends DocumentWidget.IOptionsOptionalContent<DSVEditor> {
    delimiter?: string;
    commandRegistry: CommandRegistry;
  }
}

export class EditableCSVViewerFactory extends ABCWidgetFactory<
  IDocumentWidget<DSVEditor>
> {
  constructor(
    options: DocumentRegistry.IWidgetFactoryOptions<IDocumentWidget>,
    commandRegistry: CommandRegistry
  ) {
    super(options);
    this._commandReigstry = commandRegistry;
  }

  createNewWidget(
    context: DocumentRegistry.Context
  ): IDocumentWidget<DSVEditor> {
    const commandRegistry = this._commandReigstry;
    return new EditableCSVDocumentWidget({ context, commandRegistry });
  }

  private _commandReigstry: CommandRegistry;
}
export namespace DSVEditor {
  /**
   * Instantiation options for CSV widgets.
   */
  export interface IOptions {
    /**
     * The document context for the CSV being rendered by the widget.
     */
    context: DocumentRegistry.Context;
  }
}