import { JupyterFrontEnd } from '@jupyterlab/application';
import { Dialog, showDialog } from '@jupyterlab/apputils';
import { CodeEditor } from '@jupyterlab/codeeditor';
import { DocumentRegistry, IDocumentWidget } from '@jupyterlab/docregistry';
import { ILogPayload } from '@jupyterlab/logconsole';
import { nullTranslator, TranslationBundle } from '@jupyterlab/translation';
import { JSONObject } from '@lumino/coreutils';
import { Signal } from '@lumino/signaling';
import mergeWith from 'lodash.mergewith';

import { ICommandContext } from '../command_manager';
import { LSPConnection } from '../connection';
import {
  DocumentConnectionManager,
  IDocumentConnectionData,
  ISocketConnectionOptions
} from '../connection_manager';
import { EditorAdapter } from '../editor_integration/editor_adapter';
import { IFeature, IFeatureEditorIntegration } from '../feature';
import { ILSPExtension, ILSPLogConsole } from '../index';
import { ClientCapabilities, LanguageIdentifier } from '../lsp';
import { IRootPosition, IVirtualPosition } from '../positioning';
import { IForeignContext, VirtualDocument } from '../virtual/document';
import { IVirtualEditor } from '../virtual/editor';

import IEditor = CodeEditor.IEditor;
import IButton = Dialog.IButton;
import createButton = Dialog.createButton;

export class StatusMessage {
  /**
   * The text message to be shown on the statusbar
   */
  message: string;
  changed: Signal<StatusMessage, void>;
  private timer: number | null;

  constructor() {
    this.message = '';
    this.changed = new Signal(this);
    this.timer = null;
  }

  /**
   * Set the text message and (optionally) the timeout to remove it.
   * @param message
   * @param timeout - number of ms to until the message is cleaned;
   *        -1 if the message should stay up indefinitely;
   *        defaults to 3000ms (3 seconds)
   */
  set(message: string, timeout: number = 1000 * 3) {
    this.expire_timer();
    this.message = message;
    this.changed.emit();
    if (timeout !== -1) {
      this.timer = window.setTimeout(this.clear.bind(this), timeout);
    }
  }

  clear() {
    this.message = '';
    this.changed.emit();
  }

  private expire_timer() {
    if (this.timer !== null) {
      window.clearTimeout(this.timer);
      this.timer = 0;
    }
  }
}

/**
 * The values should follow the https://microsoft.github.io/language-server-protocol/specification guidelines
 */
const mime_type_language_map: JSONObject = {
  'text/x-rsrc': 'r',
  'text/x-r-source': 'r',
  // currently there are no LSP servers for IPython we are aware of
  'text/x-ipython': 'python'
};

export interface IEditorChangedData {
  editor: CodeEditor.IEditor;
}

/**
 * Foreign code: low level adapter is not aware of the presence of foreign languages;
 * it operates on the virtual document and must not attempt to infer the language dependencies
 * as this would make the logic of inspections caching impossible to maintain, thus the WidgetAdapter
 * has to handle that, keeping multiple connections and multiple virtual documents.
 */
export abstract class WidgetAdapter<T extends IDocumentWidget> {
  protected adapters: Map<
    VirtualDocument.id_path,
    EditorAdapter<IVirtualEditor<IEditor>>
  >;
  public adapterConnected: Signal<WidgetAdapter<T>, IDocumentConnectionData>;
  public isConnected: boolean;
  public connection_manager: DocumentConnectionManager;
  public status_message: StatusMessage;
  public trans: TranslationBundle;
  protected isDisposed = false;
  console: ILSPLogConsole;

  protected app: JupyterFrontEnd;

  public activeEditorChanged: Signal<WidgetAdapter<T>, IEditorChangedData>;
  public editorAdded: Signal<WidgetAdapter<T>, IEditorChangedData>;
  public editorRemoved: Signal<WidgetAdapter<T>, IEditorChangedData>;
  public update_finished: Promise<void>;
  public initialized: Promise<void>;

  /**
   * (re)create virtual document using current path and language
   */
  abstract create_virtual_document(): VirtualDocument;

  abstract get_editor_index_at(position: IVirtualPosition): number;

  abstract get_editor_index(ce_editor: CodeEditor.IEditor): number;

  abstract get_editor_wrapper(ce_editor: CodeEditor.IEditor): HTMLElement;

  // note: it could be using namespace/IOptions pattern,
  // but I do not know how to make it work with the generic type T
  // (other than using 'any' in the IOptions interface)
  protected constructor(protected extension: ILSPExtension, public widget: T) {
    this.app = extension.app;
    this.connection_manager = extension.connection_manager;
    this.adapterConnected = new Signal(this);
    this.activeEditorChanged = new Signal(this);
    this.editorRemoved = new Signal(this);
    this.editorAdded = new Signal(this);
    this.adapters = new Map();
    this.status_message = new StatusMessage();
    this.isConnected = false;
    this.console = extension.console.scope('WidgetAdapter');
    this.trans = (extension.translator || nullTranslator).load(
      'jupyterlab-lsp'
    );

    // set up signal connections
    this.widget.context.saveState.connect(this.on_save_state, this);
    this.connection_manager.closed.connect(this.on_connection_closed, this);
    this.widget.disposed.connect(this.dispose, this);
  }

  protected get foreign_code_extractors() {
    return this.extension.foreign_code_extractors;
  }

  protected get code_overrides() {
    return this.extension.code_overrides;
  }

  on_connection_closed(
    manager: DocumentConnectionManager,
    { virtual_document }: IDocumentConnectionData
  ) {
    this.console.log(
      'connection closed, disconnecting adapter',
      virtual_document.id_path
    );
    if (virtual_document !== this.virtual_editor?.virtual_document) {
      return;
    }
    this.dispose();
  }

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

    if (this.virtual_editor?.virtual_document) {
      this.disconnect_adapter(this.virtual_editor?.virtual_document);
    }

    this.widget.context.saveState.disconnect(this.on_save_state, this);
    this.connection_manager.closed.disconnect(this.on_connection_closed, this);
    this.widget.disposed.disconnect(this.dispose, this);

    this.disconnect();

    // just to be sure
    this.virtual_editor = null as any;
    this.app = null as any;
    this.widget = null as any;
    this.connection_manager = null as any;
    this.widget = null as any;

    this.isDisposed = true;
  }

  virtual_editor: IVirtualEditor<IEditor>;

  abstract get document_path(): string;

  abstract get mime_type(): string;

  get widget_id(): string {
    return this.widget.id;
  }

  get language(): LanguageIdentifier {
    // the values should follow https://microsoft.github.io/language-server-protocol/specification guidelines,
    // see the table in https://microsoft.github.io/language-server-protocol/specification#textDocumentItem
    if (mime_type_language_map.hasOwnProperty(this.mime_type)) {
      return mime_type_language_map[this.mime_type] as string;
    } else {
      let without_parameters = this.mime_type.split(';')[0];
      let [type, subtype] = without_parameters.split('/');
      if (type === 'application' || type === 'text') {
        if (subtype.startsWith('x-')) {
          return subtype.substr(2);
        } else {
          return subtype;
        }
      } else {
        return this.mime_type;
      }
    }
  }

  abstract get language_file_extension(): string | undefined;

  disconnect() {
    this.connection_manager.unregister_document(
      this.virtual_editor.virtual_document
    );
    this.widget.context.model.contentChanged.disconnect(
      this.onContentChanged,
      this
    );

    // pretend that all editors were removed to trigger the disconnection of even handlers
    // they will be connected again on new connection
    for (let editor of this.editors) {
      this.editorRemoved.emit({
        editor: editor
      });
    }

    for (let adapter of this.adapters.values()) {
      adapter.dispose();
    }
    this.adapters.clear();

    this.virtual_editor.dispose();
  }

  // equivalent to triggering didClose and didOpen, as per syncing specification,
  // but also reloads the connection; used during file rename (or when it was moved)
  protected reload_connection() {
    // ignore premature calls (before the editor was initialized)
    if (this.virtual_editor == null) {
      return;
    }

    // disconnect all existing connections (and dispose adapters)
    this.disconnect();

    // recreate virtual document using current path and language
    // as virtual editor assumes it gets the virtual document at init,
    // just dispose virtual editor (which disposes virtual document too)
    // and re-initialize both virtual editor and document
    this.init_virtual();

    // reconnect
    this.connect_document(this.virtual_editor.virtual_document, true).catch(
      this.console.warn
    );
  }

  protected on_save_state(context: any, state: DocumentRegistry.SaveState) {
    // ignore premature calls (before the editor was initialized)
    if (this.virtual_editor == null) {
      return;
    }

    // TODO: remove workaround no later than with 3.2 release of JupyterLab
    // workaround for https://github.com/jupyterlab/jupyterlab/issues/10721
    // while already reverted in https://github.com/jupyterlab/jupyterlab/pull/10741,
    // it was not released yet and many users will continue to run 3.1.0 and 3.1.1
    // so lets workaround it for now
    // eslint-disable-next-line @typescript-eslint/ban-ts-comment
    // @ts-ignore
    const completedManually = state === 'completed manually';

    if (state === 'completed' || completedManually) {
      // note: must only be send to the appropriate connections as
      // some servers (Julia) break if they receive save notification
      // for a document that was not opened before, see:
      // https://github.com/jupyter-lsp/jupyterlab-lsp/issues/490
      const documents_to_save = [this.virtual_editor.virtual_document];

      for (let virtual_document of documents_to_save) {
        let connection = this.connection_manager.connections.get(
          virtual_document.uri
        )!;
        this.console.log(
          'Sending save notification for',
          virtual_document.uri,
          'to',
          connection
        );
        connection.sendSaved(virtual_document.document_info);
        for (let foreign of virtual_document.foreign_documents.values()) {
          documents_to_save.push(foreign);
        }
      }
    }
  }

  abstract activeEditor: CodeEditor.IEditor | undefined;

  abstract get editors(): CodeEditor.IEditor[];

  /**
   * public for use in tests (but otherwise could be private)
   */
  public update_documents() {
    if (this.isDisposed) {
      this.console.warn('Cannot update documents: adapter disposed');
      return;
    }
    return this.virtual_editor.virtual_document.update_manager.update_documents(
      this.editors.map(ce_editor => {
        return {
          ce_editor: ce_editor,
          value: this.virtual_editor.get_editor_value(ce_editor)
        };
      })
    );
  }

  get has_multiple_editors(): boolean {
    return this.editors.length > 1;
  }

  protected async on_connected(data: IDocumentConnectionData) {
    let { virtual_document } = data;

    this.connect_adapter(data.virtual_document, data.connection);
    this.adapterConnected.emit(data);
    this.isConnected = true;

    const promise = this.update_documents();

    if (!promise) {
      this.console.warn('Could not update documents');
      return;
    }
    await promise.then(() => {
      // refresh the document on the LSP server
      this.document_changed(virtual_document, virtual_document, true);

      this.console.log(
        'virtual document(s) for',
        this.document_path,
        'have been initialized'
      );
    });

    // Note: the logger extension behaves badly with non-default names
    // as it changes the source to the active file afterwards anyways
    const loggerSourceName = virtual_document.uri;
    let log: (text: string) => void;
    if (this.extension.user_console) {
      const logger = this.extension.user_console.getLogger(loggerSourceName);
      log = (text: string) => {
        logger.log({
          type: 'text',
          data: text
        } as ILogPayload);
      };
    } else {
      log = (text: string) => console.log(text);
    }

    data.connection.serverNotifications['$/logTrace'].connect(
      (connection, message) => {
        this.console.log(
          data.connection.serverIdentifier,
          'trace',
          virtual_document.uri,
          message
        );
      }
    );

    data.connection.serverNotifications['window/logMessage'].connect(
      (connection, message) => {
        this.console.log(
          data.connection.serverIdentifier,
          virtual_document.uri,
          message
        );
        log(connection.serverIdentifier + ': ' + message.message);
      }
    );

    data.connection.serverNotifications['window/showMessage'].connect(
      (connection, message) => {
        this.console.log(
          data.connection.serverIdentifier,
          virtual_document.uri,
          message.message
        );
        void showDialog({
          title: this.trans.__('Message from ') + connection.serverIdentifier,
          body: message.message
        });
      }
    );

    data.connection.serverRequests['window/showMessageRequest'].setHandler(
      async params => {
        this.console.log(
          data.connection.serverIdentifier,
          virtual_document.uri,
          params
        );
        const actionItems = params.actions;
        const buttons = actionItems
          ? actionItems.map(action => {
              return createButton({
                label: action.title
              });
            })
          : [createButton({ label: this.trans.__('Dismiss') })];
        const result = await showDialog<IButton>({
          title:
            this.trans.__('Message from ') + data.connection.serverIdentifier,
          body: params.message,
          buttons: buttons
        });
        const choice = buttons.indexOf(result.button);
        if (choice === -1) {
          return null;
        }
        if (actionItems) {
          return actionItems[choice];
        }
        return null;
      }
    );
  }

  /**
   * Opens a connection for the document. The connection may or may
   * not be initialized, yet, and depending on when this is called, the client
   * may not be fully connected.
   *
   * @param virtual_document a VirtualDocument
   * @param send_open whether to open the document immediately
   */
  protected async connect_document(
    virtual_document: VirtualDocument,
    send_open = false
  ): Promise<void> {
    virtual_document.changed.connect(this.document_changed, this);

    virtual_document.foreign_document_opened.connect(
      this.on_foreign_document_opened,
      this
    );

    const connection_context = await this.connect(virtual_document).catch(
      this.console.warn
    );

    if (!send_open) {
      return;
    }

    if (connection_context && connection_context.connection) {
      connection_context.connection.sendOpenWhenReady(
        virtual_document.document_info
      );
    } else {
      this.console.warn(
        `Connection for ${virtual_document.path} was not opened`
      );
    }
  }

  private create_virtual_editor(
    options: IVirtualEditor.IOptions
  ): IVirtualEditor<IEditor> | null {
    let editorType = this.extension.editor_type_manager.findBestImplementation(
      this.editors
    );
    if (editorType == null) {
      return null;
    }
    let virtualEditorConstructor = editorType.implementation;
    return new virtualEditorConstructor(options);
  }

  protected init_virtual() {
    let virtual_editor = this.create_virtual_editor({
      adapter: this,
      virtual_document: this.create_virtual_document()
    });
    if (virtual_editor == null) {
      this.console.error(
        'Could not initialize a VirtualEditor for adapter: ',
        this
      );
      return;
    }
    this.virtual_editor = virtual_editor;
    this.connect_contentChanged_signal();
  }

  /**
   * Handler for opening a document contained in a parent document. The assumption
   * is that the editor already exists for this, and as such the document
   * should be queued for immediate opening.
   *
   * @param host the VirtualDocument that contains the VirtualDocument in another language
   * @param context information about the foreign VirtualDocument
   */
  protected async on_foreign_document_opened(
    host: VirtualDocument,
    context: IForeignContext
  ) {
    const { foreign_document } = context;

    await this.connect_document(foreign_document, true);

    foreign_document.foreign_document_closed.connect(
      this.on_foreign_document_closed,
      this
    );
  }

  private on_foreign_document_closed(
    host: VirtualDocument,
    context: IForeignContext
  ) {
    const { foreign_document } = context;
    foreign_document.foreign_document_closed.disconnect(
      this.on_foreign_document_closed,
      this
    );
    foreign_document.foreign_document_opened.disconnect(
      this.on_foreign_document_opened,
      this
    );
    foreign_document.changed.disconnect(this.document_changed, this);
  }

  document_changed(
    virtual_document: VirtualDocument,
    document: VirtualDocument,
    is_init = false
  ) {
    if (this.isDisposed) {
      this.console.warn('Cannot swap document: adapter disposed');
      return;
    }

    // TODO only send the difference, using connection.sendSelectiveChange()
    let connection = this.connection_manager.connections.get(
      virtual_document.uri
    );
    let adapter = this.adapters.get(virtual_document.id_path)!;

    if (!connection?.isReady) {
      this.console.log('Skipping document update signal: connection not ready');
      return;
    }
    if (adapter == null) {
      this.console.log('Skipping document update signal: adapter not ready');
      return;
    }

    // this.virtual_editor.console.log(
    //   'LSP: virtual document',
    //   virtual_document.id_path,
    //   'has changed sending update'
    // );
    connection.sendFullTextChange(
      virtual_document.value,
      virtual_document.document_info
    );
    // the first change (initial) is not propagated to features,
    // as it has no associated CodeMirrorChange object
    if (!is_init) {
      // guarantee that the virtual editor won't perform an update of the virtual documents while
      // the changes are recorded...
      // TODO this is not ideal - why it solves the problem of some errors,
      //  it introduces an unnecessary delay. A better way could be to invalidate some of the updates when a new one comes in.
      //  but maybe not every one (then the outdated state could be kept for too long fo a user who writes very quickly)
      //  also we would not want to invalidate the updates for the purpose of autocompletion (the trigger characters)
      this.virtual_editor.virtual_document.update_manager
        .with_update_lock(async () => {
          await adapter.updateAfterChange();
        })
        .then()
        .catch(this.console.warn);
    }
  }

  connect_adapter(
    virtual_document: VirtualDocument,
    connection: LSPConnection,
    features: IFeature[] | null = null
  ): EditorAdapter<any> {
    let adapter = this.create_adapter(virtual_document, connection, features);
    this.adapters.set(virtual_document.id_path, adapter);
    return adapter;
  }

  private disconnect_adapter(virtual_document: VirtualDocument) {
    let adapter = this.adapters.get(virtual_document.id_path);
    this.adapters.delete(virtual_document.id_path);
    if (adapter != null) {
      adapter.dispose();
    }
  }

  public get_features(virtual_document: VirtualDocument) {
    let adapter = this.adapters.get(virtual_document.id_path);
    return adapter?.features;
  }

  private async connect(virtual_document: VirtualDocument) {
    let language = virtual_document.language;

    this.console.log(`will connect using language: ${language}`);

    let capabilities: ClientCapabilities = {
      textDocument: {
        synchronization: {
          dynamicRegistration: true,
          willSave: false,
          didSave: true,
          willSaveWaitUntil: false
        }
      },
      workspace: {
        didChangeConfiguration: {
          dynamicRegistration: true
        }
      }
    };

    for (const feature of this.extension.feature_manager.features) {
      if (!feature.capabilities) {
        continue;
      }
      capabilities = mergeWith(capabilities, feature.capabilities);
    }

    let options: ISocketConnectionOptions = {
      capabilities,
      virtual_document,
      language,
      document_path: this.document_path
    };

    let connection = await this.connection_manager.connect(options);

    if (connection) {
      await this.on_connected({ virtual_document, connection });

      return {
        connection,
        virtual_document
      };
    } else {
      return undefined;
    }
  }

  /**
   * Connect the change signal in order to update all virtual documents after a change.
   *
   * Update to the state of a notebook may be done without a notice on the CodeMirror level,
   * e.g. when a cell is deleted. Therefore a JupyterLab-specific signals are watched instead.
   *
   * While by not using the change event of CodeMirror editors we loose an easy way to send selective,
   * (range) updates this can be still implemented by comparison of before/after states of the
   * virtual documents, which is even more resilient and -obviously - editor-independent.
   */
  private connect_contentChanged_signal() {
    this.widget.context.model.contentChanged.connect(
      this.onContentChanged,
      this
    );
  }

  private create_adapter(
    virtual_document: VirtualDocument,
    connection: LSPConnection,
    features: IFeature[] | null = null
  ): EditorAdapter<IVirtualEditor<IEditor>> {
    let adapter_features = new Array<
      IFeatureEditorIntegration<IVirtualEditor<IEditor>>
    >();

    if (features === null) {
      features = this.extension.feature_manager.features;
    }

    for (let feature of features) {
      let featureEditorIntegrationConstructor =
        feature.editorIntegrationFactory.get(this.virtual_editor.editor_name)!;
      let integration = new featureEditorIntegrationConstructor({
        feature: feature,
        virtual_editor: this.virtual_editor,
        virtual_document: virtual_document,
        connection: connection,
        status_message: this.status_message,
        settings: feature.settings!,
        adapter: this,
        trans: this.trans
      });
      adapter_features.push(integration);
    }

    let adapter = new EditorAdapter(
      this.virtual_editor,
      virtual_document,
      adapter_features,
      this.console
    );
    this.console.log('Adapter for', this.document_path, 'is ready.');
    // the client is now fully ready: signal to the server that the document is "open"
    connection.sendOpenWhenReady(virtual_document.document_info);
    return adapter;
  }

  private async onContentChanged(_slot: any) {
    // update the virtual documents (sending the updates to LSP is out of scope here)
    const promise = this.update_documents();
    if (!promise) {
      this.console.warn('Could not update documents');
      return;
    }
    this.update_finished = promise.catch(this.console.warn);
    await this.update_finished;
  }

  get_position_from_context_menu(): IRootPosition | null {
    // Note: could also try using this.app.contextMenu.menu.contentNode position.
    // Note: could add a guard on this.app.contextMenu.menu.isAttached

    // get the first node as it gives the most accurate approximation
    let leaf_node = this.app.contextMenuHitTest(() => true);

    if (!leaf_node) {
      return null;
    }

    let { left, top } = leaf_node.getBoundingClientRect();

    // eslint-disable-next-line @typescript-eslint/ban-ts-comment
    // @ts-ignore
    let event = this.app._contextMenuEvent;

    // if possible, use more accurate position from the actual event
    // (but this relies on an undocumented and unstable feature)
    if (event !== undefined) {
      left = event.clientX;
      top = event.clientY;
      event.stopPropagation();
    }

    return this.virtual_editor.window_coords_to_root_position({
      left: left,
      top: top
    });
  }

  abstract context_from_active_document(): ICommandContext | null;

  get_context(root_position: IRootPosition): ICommandContext | null {
    // ignore premature calls and calls after disposal
    if (!this.virtual_editor) {
      return null;
    }
    let document = this.virtual_editor.document_at_root_position(root_position);
    let virtual_position =
      this.virtual_editor.root_position_to_virtual_position(root_position);
    return {
      document,
      connection: this.connection_manager.connections.get(document.uri),
      virtual_position,
      root_position,
      features: this.get_features(document)!,
      editor: this.virtual_editor,
      app: this.app,
      adapter: this
    };
  }

  get_context_from_context_menu(): ICommandContext | null {
    let root_position = this.get_position_from_context_menu();
    if (!root_position) {
      return null;
    }
    return this.get_context(root_position);
  }

  abstract get wrapper_element(): HTMLElement;
}
