import { CodeEditor } from '@jupyterlab/codeeditor';
import { Signal } from '@lumino/signaling';
import { IDocumentInfo } from 'lsp-ws-connection';

import { DocumentConnectionManager } from '../connection_manager';
import {
  IForeignCodeExtractor,
  IForeignCodeExtractorsRegistry
} from '../extractors/types';
import { LanguageIdentifier } from '../lsp';
import { ReversibleOverridesMap } from '../overrides/maps';
import { ICodeOverridesRegistry } from '../overrides/tokens';
import {
  IEditorPosition,
  ISourcePosition,
  IVirtualPosition,
  PositionError
} from '../positioning';
import { ILSPLogConsole } from '../tokens';
import { DefaultMap, until_ready } from '../utils';

import { BrowserConsole } from './console';

import IRange = CodeEditor.IRange;

type language = string;

interface IVirtualLine {
  /**
   * Inspections for which document should be skipped for this virtual line?
   */
  skip_inspect: Array<VirtualDocument.id_path>;
  /**
   * Where does the virtual line belongs to in the source document?
   */
  source_line: number | null;
  editor: CodeEditor.IEditor;
}

export interface ICodeBlockOptions {
  ce_editor: CodeEditor.IEditor;
  value: string;
}

export interface IVirtualDocumentBlock {
  /**
   * Line corresponding to the block in the entire foreign document
   */
  virtual_line: number;
  virtual_document: VirtualDocument;
  editor: CodeEditor.IEditor;
}

export type ForeignDocumentsMap = Map<IRange, IVirtualDocumentBlock>;

interface ISourceLine {
  virtual_line: number;
  editor: CodeEditor.IEditor;
  // shift
  editor_line: number;
  editor_shift: CodeEditor.IPosition;
  /**
   * Everything which is not in the range of foreign documents belongs to the host.
   */
  foreign_documents_map: ForeignDocumentsMap;
}

export interface IForeignContext {
  foreign_document: VirtualDocument;
  parent_host: VirtualDocument;
}

/**
 * Check if given position is within range.
 * Both start and end are inclusive.
 * @param position
 * @param range
 */
export function is_within_range(
  position: CodeEditor.IPosition,
  range: CodeEditor.IRange
): boolean {
  if (range.start.line === range.end.line) {
    return (
      position.line === range.start.line &&
      position.column >= range.start.column &&
      position.column <= range.end.column
    );
  }

  return (
    (position.line === range.start.line &&
      position.column >= range.start.column &&
      position.line < range.end.line) ||
    (position.line > range.start.line &&
      position.column <= range.end.column &&
      position.line === range.end.line) ||
    (position.line > range.start.line && position.line < range.end.line)
  );
}

/**
 * a virtual implementation of IDocumentInfo
 */
export class VirtualDocumentInfo implements IDocumentInfo {
  private _document: VirtualDocument;
  version = 0;

  constructor(document: VirtualDocument) {
    this._document = document;
  }

  get text() {
    return this._document.value;
  }

  get uri() {
    const uris = DocumentConnectionManager.solve_uris(
      this._document,
      this.languageId
    );
    return uris.document;
  }

  get languageId() {
    return this._document.language;
  }
}

export namespace VirtualDocument {
  export interface IOptions {
    language: LanguageIdentifier;
    foreign_code_extractors: IForeignCodeExtractorsRegistry;
    overrides_registry: ICodeOverridesRegistry;
    path: string;
    file_extension: string | undefined;
    console: ILSPLogConsole;
    /**
     * Notebooks or any other aggregates of documents are not supported
     * by the LSP specification, and we need to make appropriate
     * adjustments for them, pretending they are simple files
     * so that the LSP servers do not refuse to cooperate.
     */
    has_lsp_supported_file: boolean;
    /**
     * Being standalone is relevant to foreign documents
     * and defines whether following chunks of code in the same
     * language should be appended to this document (false, not standalone)
     * or should be considered separate documents (true, standalone)
     *
     */
    standalone?: boolean;
    parent?: VirtualDocument;
  }
}

/**
 * A notebook can hold one or more virtual documents; there is always one,
 * "root" document, corresponding to the language of the kernel. All other
 * virtual documents are extracted out of the notebook, based on magics,
 * or other syntax constructs, depending on the kernel language.
 *
 * Virtual documents represent the underlying code in a single language,
 * which has been parsed excluding interactive kernel commands (magics)
 * which could be misunderstood by the specific LSP server.
 *
 * VirtualDocument has no awareness of the notebook or editor it lives in,
 * however it is able to transform its content back to the notebook space,
 * as it keeps editor coordinates for each virtual line.
 *
 * The notebook/editor aware transformations are preferred to be placed in
 * VirtualEditor descendants rather than here.
 *
 * No dependency on editor implementation (such as CodeMirrorEditor)
 * is allowed for VirtualEditor.
 */
export class VirtualDocument {
  language: string;
  public last_virtual_line: number;
  public foreign_document_closed: Signal<VirtualDocument, IForeignContext>;
  public foreign_document_opened: Signal<VirtualDocument, IForeignContext>;
  public readonly instance_id: number;
  protected console: ILSPLogConsole;

  standalone: boolean;
  isDisposed = false;
  /**
   * the remote document uri, version and other server-related info
   */
  public document_info: IDocumentInfo;
  /**
   * Virtual lines keep all the lines present in the document AND extracted to the foreign document.
   */
  public virtual_lines: Map<number, IVirtualLine>; // probably should go protected
  protected source_lines: Map<number, ISourceLine>;

  foreign_extractors: IForeignCodeExtractor[];
  overrides_registry: ICodeOverridesRegistry;
  protected foreign_extractors_registry: IForeignCodeExtractorsRegistry;
  protected line_blocks: Array<string>;

  // TODO: merge into unused documents {standalone: Map, continuous: Map} ?
  protected unused_documents: Set<VirtualDocument>;
  protected unused_standalone_documents: DefaultMap<
    language,
    Array<VirtualDocument>
  >;

  private _remaining_lifetime: number;
  private cell_magics_overrides: ReversibleOverridesMap;
  private line_magics_overrides: ReversibleOverridesMap;
  private static instances_count = 0;
  public foreign_documents: Map<VirtualDocument.virtual_id, VirtualDocument>;

  // TODO: make this configurable, depending on the language used
  blank_lines_between_cells: number = 2;
  last_source_line: number;
  private previous_value: string;
  public changed: Signal<VirtualDocument, VirtualDocument>;

  public path: string;
  public file_extension: string | undefined;
  public has_lsp_supported_file: boolean;
  public parent?: VirtualDocument | null;
  private readonly options: VirtualDocument.IOptions;
  public update_manager: UpdateManager;

  constructor(options: VirtualDocument.IOptions) {
    this.options = options;
    this.path = options.path;
    this.console = options.console;
    this.file_extension = options.file_extension;
    this.has_lsp_supported_file = options.has_lsp_supported_file;
    this.parent = options.parent;
    this.language = options.language;
    let overrides =
      this.language in options.overrides_registry
        ? options.overrides_registry[this.language]
        : null;
    this.cell_magics_overrides = new ReversibleOverridesMap(
      overrides ? overrides.cell : []
    );
    this.line_magics_overrides = new ReversibleOverridesMap(
      overrides ? overrides.line : []
    );
    this.foreign_extractors_registry = options.foreign_code_extractors;
    this.foreign_extractors =
      this.language in this.foreign_extractors_registry
        ? this.foreign_extractors_registry[this.language]
        : [];
    this.virtual_lines = new Map();
    this.source_lines = new Map();
    this.foreign_documents = new Map();
    this.overrides_registry = options.overrides_registry;
    this.standalone = options.standalone || false;
    this.instance_id = VirtualDocument.instances_count;
    VirtualDocument.instances_count += 1;
    this.unused_standalone_documents = new DefaultMap(
      () => new Array<VirtualDocument>()
    );
    this._remaining_lifetime = 6;
    this.foreign_document_closed = new Signal(this);
    this.foreign_document_opened = new Signal(this);
    this.changed = new Signal(this);
    this.unused_documents = new Set();
    this.document_info = new VirtualDocumentInfo(this);
    this.update_manager = new UpdateManager(this, options.console);
    this.clear();
  }

  dispose() {
    if (this.isDisposed) {
      return;
    }
    this.isDisposed = true;

    this.parent = null;

    for (const doc of this.foreign_documents.values()) {
      doc.dispose();
    }

    this.close_all_foreign_documents();
    this.update_manager.dispose();

    // clear all the maps
    this.foreign_documents.clear();
    this.source_lines.clear();
    this.unused_documents.clear();
    this.unused_standalone_documents.clear();
    this.virtual_lines.clear();

    // just to be sure - if anything is accessed after disposal (it should not) we
    // will get alterted by errors in the console AND this will limit memory leaks
    this.cell_magics_overrides = null as any;
    this.document_info = null as any;
    this.foreign_extractors = null as any;
    this.foreign_extractors_registry = null as any;
    this.line_magics_overrides = null as any;
    this.line_blocks = null as any;
    this.overrides_registry = null as any;
  }

  /**
   * When this counter goes down to 0, the document will be destroyed and the associated connection will be closed;
   * This is meant to reduce the number of open connections when a a foreign code snippet was removed from the document.
   *
   * Note: top level virtual documents are currently immortal (unless killed by other means); it might be worth
   * implementing culling of unused documents, but if and only if JupyterLab will also implement culling of
   * idle kernels - otherwise the user experience could be a bit inconsistent, and we would need to invent our own rules.
   */
  protected get remaining_lifetime() {
    if (!this.parent) {
      return Infinity;
    }
    return this._remaining_lifetime;
  }
  protected set remaining_lifetime(value: number) {
    if (this.parent) {
      this._remaining_lifetime = value;
    }
  }

  clear() {
    // TODO - deep clear (assure that there is no memory leak)
    this.unused_standalone_documents.clear();

    for (let document of this.foreign_documents.values()) {
      document.clear();
      if (document.standalone) {
        // once the standalone document was cleared, we may want to remove it and close connection;
        // but wait, this is a waste of resources (opening a connection takes 1-3 seconds) and,
        // since this is cleaned anyway, we could use it for another standalone document of the same language.
        let set = this.unused_standalone_documents.get(document.language);
        set.push(document);
      }
    }
    this.unused_documents = new Set(this.foreign_documents.values());
    this.virtual_lines.clear();
    this.source_lines.clear();
    this.last_virtual_line = 0;
    this.last_source_line = 0;
    this.line_blocks = [];
  }

  private forward_closed_signal(
    host: VirtualDocument,
    context: IForeignContext
  ) {
    this.foreign_document_closed.emit(context);
  }

  private forward_opened_signal(
    host: VirtualDocument,
    context: IForeignContext
  ) {
    this.foreign_document_opened.emit(context);
  }

  // TODO: what could be refactored into "ForeignDocumentsManager" has started to emerge;
  //   we should consider refactoring later on.
  private open_foreign(
    language: language,
    standalone: boolean,
    file_extension: string
  ): VirtualDocument {
    let document = new VirtualDocument({
      ...this.options,
      parent: this,
      standalone: standalone,
      file_extension: file_extension,
      language: language
    });
    const context: IForeignContext = {
      foreign_document: document,
      parent_host: this
    };
    this.foreign_document_opened.emit(context);
    // pass through any future signals
    document.foreign_document_closed.connect(this.forward_closed_signal, this);
    document.foreign_document_opened.connect(this.forward_opened_signal, this);

    this.foreign_documents.set(document.virtual_id, document);

    return document;
  }

  document_at_source_position(position: ISourcePosition): VirtualDocument {
    let source_line = this.source_lines.get(position.line);

    if (source_line == null) {
      return this;
    }

    let source_position_ce: CodeEditor.IPosition = {
      line: source_line.editor_line,
      column: position.ch
    };

    for (let [
      range,
      { virtual_document: document }
    ] of source_line.foreign_documents_map) {
      if (is_within_range(source_position_ce, range)) {
        let source_position_cm = {
          line: source_position_ce.line - range.start.line,
          ch: source_position_ce.column - range.start.column
        };

        return document.document_at_source_position(
          source_position_cm as ISourcePosition
        );
      }
    }

    return this;
  }

  is_within_foreign(source_position: ISourcePosition): boolean {
    let source_line = this.source_lines.get(source_position.line)!;

    let source_position_ce: CodeEditor.IPosition = {
      line: source_line.editor_line,
      column: source_position.ch
    };
    for (let [range] of source_line.foreign_documents_map) {
      if (is_within_range(source_position_ce, range)) {
        return true;
      }
    }
    return false;
  }

  virtual_position_at_document(
    source_position: ISourcePosition
  ): IVirtualPosition {
    let source_line = this.source_lines.get(source_position.line);
    if (source_line == null) {
      throw new PositionError('Source line not mapped to virtual position');
    }
    let virtual_line = source_line.virtual_line;

    // position inside the cell (block)
    let source_position_ce: CodeEditor.IPosition = {
      line: source_line.editor_line,
      column: source_position.ch
    };

    for (let [
      range,
      { virtual_line, virtual_document: document }
    ] of source_line.foreign_documents_map) {
      if (is_within_range(source_position_ce, range)) {
        // position inside the foreign document block
        let source_position_cm = {
          line: source_position_ce.line - range.start.line,
          ch: source_position_ce.column - range.start.column
        };
        if (document.is_within_foreign(source_position_cm as ISourcePosition)) {
          return this.virtual_position_at_document(
            source_position_cm as ISourcePosition
          );
        } else {
          // where in this block in the entire foreign document?
          source_position_cm.line += virtual_line;
          return source_position_cm as IVirtualPosition;
        }
      }
    }

    return {
      ch: source_position.ch,
      line: virtual_line
    } as IVirtualPosition;
  }

  private choose_foreign_document(extractor: IForeignCodeExtractor) {
    let foreign_document: VirtualDocument;
    // if not standalone, try to append to existing document
    let foreign_exists = this.foreign_documents.has(extractor.language);
    if (!extractor.standalone && foreign_exists) {
      foreign_document = this.foreign_documents.get(extractor.language)!;
      this.unused_documents.delete(foreign_document);
    } else {
      // if standalone, try to re-use existing connection to the server
      let unused_standalone = this.unused_standalone_documents.get(
        extractor.language
      );
      if (extractor.standalone && unused_standalone.length > 0) {
        foreign_document = unused_standalone.pop()!;
        this.unused_documents.delete(foreign_document);
      } else {
        // if (previous document does not exists) or (extractor produces standalone documents
        // and no old standalone document could be reused): create a new document
        foreign_document = this.open_foreign(
          extractor.language,
          extractor.standalone,
          extractor.file_extension
        );
      }
    }
    return foreign_document;
  }

  extract_foreign_code(
    block: ICodeBlockOptions,
    editor_shift: CodeEditor.IPosition
  ) {
    let foreign_document_map = new Map<
      CodeEditor.IRange,
      IVirtualDocumentBlock
    >();

    let cell_code = block.value;

    for (let extractor of this.foreign_extractors) {
      // first, check if there is any foreign code:

      if (!extractor.has_foreign_code(cell_code)) {
        continue;
      }

      let results = extractor.extract_foreign_code(cell_code);

      let kept_cell_code = '';

      for (let result of results) {
        if (result.foreign_code !== null) {
          // result.range should only be null if result.foregin_code is null
          if (result.range === null) {
            this.console.warn(
              'Failure in foreign code extraction: `range` is null but `foreign_code` is not!'
            );
            continue;
          }
          let foreign_document = this.choose_foreign_document(extractor);
          foreign_document_map.set(result.range, {
            virtual_line: foreign_document.last_virtual_line,
            virtual_document: foreign_document,
            editor: block.ce_editor
          });
          let foreign_shift = {
            line: editor_shift.line + result.range.start.line,
            column: editor_shift.column + result.range.start.column
          };
          foreign_document.append_code_block(
            {
              value: result.foreign_code,
              ce_editor: block.ce_editor
            },
            foreign_shift,
            result.virtual_shift!
          );
        }
        if (result.host_code != null) {
          kept_cell_code += result.host_code;
        }
      }
      // not breaking - many extractors are allowed to process the code, one after each other
      // (think JS and CSS in HTML, or %R inside of %%timeit).

      cell_code = kept_cell_code;
    }

    return { cell_code_kept: cell_code, foreign_document_map };
  }

  decode_code_block(raw_code: string): string {
    // TODO: add back previously extracted foreign code
    let cell_override =
      this.cell_magics_overrides.reverse.override_for(raw_code);
    if (cell_override != null) {
      return cell_override;
    } else {
      let lines = this.line_magics_overrides.reverse_replace_all(
        raw_code.split('\n')
      );
      return lines.join('\n');
    }
  }

  prepare_code_block(
    block: ICodeBlockOptions,
    editor_shift: CodeEditor.IPosition = { line: 0, column: 0 }
  ) {
    let lines: Array<string>;
    let skip_inspect: Array<Array<VirtualDocument.id_path>>;

    let { cell_code_kept, foreign_document_map } = this.extract_foreign_code(
      block,
      editor_shift
    );
    let cell_code = cell_code_kept;

    // cell magics are replaced if requested and matched
    let cell_override = this.cell_magics_overrides.override_for(cell_code);
    if (cell_override != null) {
      lines = cell_override.split('\n');
      skip_inspect = lines.map(l => [this.id_path]);
    } else {
      // otherwise, we replace line magics - if any
      let result = this.line_magics_overrides.replace_all(
        cell_code.split('\n')
      );
      lines = result.lines;
      skip_inspect = result.skip_inspect.map(skip =>
        skip ? [this.id_path] : []
      );
    }

    return { lines, foreign_document_map, skip_inspect };
  }

  get foreign_document_maps(): ForeignDocumentsMap[] {
    let maps = new Set<ForeignDocumentsMap>();
    for (let line of this.source_lines.values()) {
      maps.add(line.foreign_documents_map);
    }
    return [...maps.values()];
  }

  append_code_block(
    block: ICodeBlockOptions,
    editor_shift: CodeEditor.IPosition = { line: 0, column: 0 },
    virtual_shift?: CodeEditor.IPosition
  ) {
    let cell_code = block.value;
    let ce_editor = block.ce_editor;

    if (this.isDisposed) {
      console.warn('Cannot append code block: document disposed');
      return;
    }

    let source_cell_lines = cell_code.split('\n');

    let { lines, foreign_document_map, skip_inspect } = this.prepare_code_block(
      block,
      editor_shift
    );

    for (let i = 0; i < lines.length; i++) {
      this.virtual_lines.set(this.last_virtual_line + i, {
        skip_inspect: skip_inspect[i],
        editor: ce_editor,
        // TODO this is incorrect, wont work if something was extracted
        source_line: this.last_source_line + i
      });
    }
    for (let i = 0; i < source_cell_lines.length; i++) {
      this.source_lines.set(this.last_source_line + i, {
        editor_line: i,
        editor_shift: {
          line: editor_shift.line - (virtual_shift?.line || 0),
          column:
            i === 0 ? editor_shift.column - (virtual_shift?.column || 0) : 0
        },
        // TODO: move those to a new abstraction layer (DocumentBlock class)
        editor: ce_editor,
        foreign_documents_map: foreign_document_map,
        // TODO this is incorrect, wont work if something was extracted
        virtual_line: this.last_virtual_line + i
      });
    }

    this.last_virtual_line += lines.length;

    // one empty line is necessary to separate code blocks, next 'n' lines are to silence linters;
    // the final cell does not get the additional lines (thanks to the use of join, see below)
    this.line_blocks.push(lines.join('\n') + '\n');

    // adding the virtual lines for the blank lines
    for (let i = 0; i < this.blank_lines_between_cells; i++) {
      this.virtual_lines.set(this.last_virtual_line + i, {
        skip_inspect: [this.id_path],
        editor: ce_editor,
        source_line: null
      });
    }

    this.last_virtual_line += this.blank_lines_between_cells;
    this.last_source_line += source_cell_lines.length;
  }

  get value() {
    let lines_padding = '\n'.repeat(this.blank_lines_between_cells);
    return this.line_blocks.join(lines_padding);
  }

  get last_line() {
    const lines_in_last_block =
      this.line_blocks[this.line_blocks.length - 1].split('\n');
    return lines_in_last_block[lines_in_last_block.length - 1];
  }

  close_expired_documents() {
    for (let document of this.unused_documents.values()) {
      document.remaining_lifetime -= 1;
      if (document.remaining_lifetime <= 0) {
        this.close_foreign(document);
      }
    }
  }

  close_foreign(document: VirtualDocument) {
    console.log('LSP: closing document', document);
    this.foreign_document_closed.emit({
      foreign_document: document,
      parent_host: this
    });
    // remove it from foreign documents list
    this.foreign_documents.delete(document.virtual_id);
    // and delete the documents within it
    document.close_all_foreign_documents();

    document.foreign_document_closed.disconnect(
      this.forward_closed_signal,
      this
    );
    document.foreign_document_opened.disconnect(
      this.forward_opened_signal,
      this
    );
  }

  close_all_foreign_documents() {
    for (let document of this.foreign_documents.values()) {
      this.close_foreign(document);
    }
  }

  get virtual_id(): VirtualDocument.virtual_id {
    // for easier debugging, the language information is included in the ID:
    return this.standalone
      ? this.instance_id + '(' + this.language + ')'
      : this.language;
  }

  get ancestry(): Array<VirtualDocument> {
    if (!this.parent) {
      return [this];
    }
    return this.parent.ancestry.concat([this]);
  }

  get id_path(): VirtualDocument.id_path {
    if (!this.parent) {
      return this.virtual_id;
    }
    return this.parent.id_path + '-' + this.virtual_id;
  }

  get uri(): VirtualDocument.uri {
    const encodedPath = encodeURI(this.path);
    if (!this.parent) {
      return encodedPath;
    }
    return encodedPath + '.' + this.id_path + '.' + this.file_extension;
  }

  transform_source_to_editor(pos: ISourcePosition): IEditorPosition {
    let source_line = this.source_lines.get(pos.line)!;
    let editor_line = source_line.editor_line;
    let editor_shift = source_line.editor_shift;
    return {
      // only shift column in the line beginning the virtual document (first list of the editor in cell magics, but might be any line of editor in line magics!)
      ch: pos.ch + (editor_line === 0 ? editor_shift.column : 0),
      line: editor_line + editor_shift.line
      // TODO or:
      //  line: pos.line + editor_shift.line - this.first_line_of_the_block(editor)
    } as IEditorPosition;
  }

  /**
  Can be null because some lines are added as padding/anchors
  to the virtual document and those do not exist in the source document
  and thus they are absent in the editor.
  */
  transform_virtual_to_editor(
    virtual_position: IVirtualPosition
  ): IEditorPosition | null {
    let source_position = this.transform_virtual_to_source(virtual_position);
    if (source_position == null) {
      return null;
    }
    return this.transform_source_to_editor(source_position);
  }

  /**
  Can be null because some lines are added as padding/anchors
  to the virtual document and those do not exist in the source document.
  */
  transform_virtual_to_source(
    position: IVirtualPosition
  ): ISourcePosition | null {
    const line = this.virtual_lines.get(position.line)!.source_line;
    if (line == null) {
      return null;
    }
    return {
      ch: position.ch,
      line: line
    } as ISourcePosition;
  }

  get root(): VirtualDocument {
    if (this.parent == null) {
      return this;
    }
    return this.parent.root;
  }

  get_editor_at_virtual_line(pos: IVirtualPosition): CodeEditor.IEditor {
    let line = pos.line;
    // tolerate overshot by one (the hanging blank line at the end)
    if (!this.virtual_lines.has(line)) {
      line -= 1;
    }
    return this.virtual_lines.get(line)!.editor;
  }

  get_editor_at_source_line(pos: ISourcePosition): CodeEditor.IEditor {
    return this.source_lines.get(pos.line)!.editor;
  }

  /**
   * Recursively emits changed signal from the document or any descendant foreign document.
   */
  maybe_emit_changed() {
    if (this.value !== this.previous_value) {
      this.changed.emit(this);
    }
    this.previous_value = this.value;
    for (let document of this.foreign_documents.values()) {
      document.maybe_emit_changed();
    }
  }
}

export namespace VirtualDocument {
  /**
   * Identifier composed of `virtual_id`s of a nested structure of documents,
   * used to aide assignment of the connection to the virtual document
   * handling specific, nested language usage; it will be appended to the file name
   * when creating a connection.
   */
  export type id_path = string;
  /**
   * Instance identifier for standalone documents (snippets), or language identifier
   * for documents which should be interpreted as one when stretched across cells.
   */
  export type virtual_id = string;
  /**
   * Identifier composed of the file path and id_path.
   */
  export type uri = string;
}

export function collect_documents(
  virtual_document: VirtualDocument
): Set<VirtualDocument> {
  let collected = new Set<VirtualDocument>();
  collected.add(virtual_document);
  for (let foreign of virtual_document.foreign_documents.values()) {
    let foreign_languages = collect_documents(foreign);
    foreign_languages.forEach(collected.add, collected);
  }
  return collected;
}

export interface IBlockAddedInfo {
  virtual_document: VirtualDocument;
  block: ICodeBlockOptions;
}

export class UpdateManager {
  console: ILSPLogConsole;

  /**
   * Virtual documents update guard.
   */
  private is_update_in_progress: boolean = false;

  private update_lock: boolean = false;

  protected isDisposed = false;

  /**
   * Signal emitted by the editor that triggered the update, providing the root document of the updated documents.
   */
  private document_updated: Signal<UpdateManager, VirtualDocument>;
  public block_added: Signal<UpdateManager, IBlockAddedInfo>;
  update_done: Promise<void> = new Promise<void>(resolve => {
    resolve();
  });
  update_began: Signal<UpdateManager, ICodeBlockOptions[]>;
  update_finished: Signal<UpdateManager, ICodeBlockOptions[]>;

  constructor(
    private virtual_document: VirtualDocument,
    console: ILSPLogConsole
  ) {
    this.document_updated = new Signal(this);
    this.block_added = new Signal(this);
    this.update_began = new Signal(this);
    this.update_finished = new Signal(this);
    this.document_updated.connect(this.on_updated, this);
    this.console = console || new BrowserConsole();
  }

  dispose() {
    if (this.isDisposed) {
      return;
    }
    this.document_updated.disconnect(this.on_updated, this);
  }

  /**
   * Once all the foreign documents were refreshed, the unused documents (and their connections)
   * should be terminated if their lifetime has expired.
   */
  private on_updated(manager: UpdateManager, root_document: VirtualDocument) {
    try {
      root_document.close_expired_documents();
    } catch (e) {
      this.console.warn('Failed to close expired documents');
    }
  }

  private can_update() {
    return !this.isDisposed && !this.is_update_in_progress && !this.update_lock;
  }

  /**
   * Execute provided callback within an update-locked context, which guarantees that:
   *  - the previous updates must have finished before the callback call, and
   *  - no update will happen when executing the callback
   * @param fn - the callback to execute in update lock
   */
  public async with_update_lock(fn: () => void): Promise<void> {
    await until_ready(() => this.can_update(), 12, 10).then(() => {
      try {
        this.update_lock = true;
        fn();
      } finally {
        this.update_lock = false;
      }
    });
  }

  /**
   * Update all the virtual documents, emit documents updated with root document if succeeded,
   * and resolve a void promise. The promise does not contain the text value of the root document,
   * as to avoid an easy trap of ignoring the changes in the virtual documents.
   */
  public async update_documents(blocks: ICodeBlockOptions[]): Promise<void> {
    let update = new Promise<void>(async (resolve, reject) => {
      // defer the update by up to 50 ms (10 retrials * 5 ms break),
      // awaiting for the previous update to complete.
      await until_ready(() => this.can_update(), 10, 5).then(() => {
        if (this.isDisposed || !this.virtual_document) {
          resolve();
        }
        try {
          this.is_update_in_progress = true;
          this.update_began.emit(blocks);

          this.virtual_document.clear();

          for (let code_block of blocks) {
            this.block_added.emit({
              block: code_block,
              virtual_document: this.virtual_document
            });
            this.virtual_document.append_code_block(code_block);
          }

          this.update_finished.emit(blocks);

          if (this.virtual_document) {
            this.document_updated.emit(this.virtual_document);
            this.virtual_document.maybe_emit_changed();
          }

          resolve();
        } catch (e) {
          this.console.warn('Documents update failed:', e);
          reject(e);
        } finally {
          this.is_update_in_progress = false;
        }
      });
    });
    this.update_done = update;
    return update;
  }
}
