/**
 * @module source_editor_instance
 */

/**
 * A Source Editor Extension definition
 * @typedef {Object} SourceEditorExtensionDefinition
 * @property {Object} definition
 * @property {Object} setupOptions
 */

/**
 * A Source Editor Extension
 * @typedef {Object} SourceEditorExtension
 * @property {Object} obj
 * @property {string} extensionName
 * @property {Object} api
 */

import { isEqual } from 'lodash';
import { editor as monacoEditor } from 'monaco-editor';
import { getBlobLanguage } from '~/editor/utils';
import { logError } from '~/lib/logger';
import { sprintf } from '~/locale';
import EditorExtension from './source_editor_extension';
import {
  EDITOR_EXTENSION_DEFINITION_TYPE_ERROR,
  EDITOR_EXTENSION_NAMING_CONFLICT_ERROR,
  EDITOR_EXTENSION_NO_DEFINITION_ERROR,
  EDITOR_EXTENSION_NOT_REGISTERED_ERROR,
  EDITOR_EXTENSION_NOT_SPECIFIED_FOR_UNUSE_ERROR,
  EDITOR_EXTENSION_STORE_IS_MISSING_ERROR,
} from './constants';

const utils = {
  removeExtFromMethod: (method, extensionName, container) => {
    if (!container) {
      return;
    }
    if (Object.prototype.hasOwnProperty.call(container, method)) {
      // eslint-disable-next-line no-param-reassign
      delete container[method];
    }
  },

  getStoredExtension: (extensionsStore, extensionName) => {
    if (!extensionsStore) {
      logError(EDITOR_EXTENSION_STORE_IS_MISSING_ERROR);
      return undefined;
    }
    return extensionsStore.get(extensionName);
  },

  hasFullApiRegistered: (targetMethods, newMethods) => {
    return newMethods.find((fn) => !targetMethods.includes(fn)) === undefined;
  },
};

/** Class representing a Source Editor Instance */
export default class EditorInstance {
  /**
   * Create a Source Editor Instance
   * @param {Object} rootInstance - Monaco instance to build on top of
   * @param {Map} extensionsStore - The global registry for the extension instances
   * @returns {Object} - A Proxy returning props/methods from either registered extensions, or Source Editor instance, or underlying Monaco instance
   */
  constructor(rootInstance = {}, extensionsStore = new Map()) {
    /** The methods provided by extensions. */
    this.methods = {};

    const seInstance = this;
    const getHandler = {
      get(target, prop, receiver) {
        const methodExtension =
          Object.prototype.hasOwnProperty.call(seInstance.methods, prop) &&
          seInstance.methods[prop];
        if (methodExtension) {
          const extension = extensionsStore.get(methodExtension);

          if (typeof extension.api[prop] === 'function') {
            return extension.api[prop].bind(extension.obj, receiver);
          }

          return extension.api[prop];
        }
        return Reflect.get(seInstance[prop] ? seInstance : target, prop, receiver);
      },
    };
    const instProxy = new Proxy(rootInstance, getHandler);

    this.dispatchExtAction = EditorInstance.useUnuse.bind(instProxy, extensionsStore);

    return instProxy;
  }

  /**
   * A private dispatcher function for both `use` and `unuse`
   * @param {Map} extensionsStore - The global registry for the extension instances
   * @param {Function} fn - A function to route to. Either `this.useExtension` or `this.unuseExtension`
   * @param {SourceEditorExtensionDefinition[]} extensions - The extensions to use/unuse.
   * @returns {Function}
   */
  static useUnuse(extensionsStore, fn, extensions) {
    if (Array.isArray(extensions)) {
      /**
       * We cut short if the Array is empty and let the destination function to throw
       * Otherwise, we run the destination function on every entry of the Array
       */
      return extensions.length
        ? extensions.map(fn.bind(this, extensionsStore))
        : fn.call(this, extensionsStore);
    }
    return fn.call(this, extensionsStore, extensions);
  }

  //
  // REGISTERING NEW EXTENSION
  //

  /**
   * Run all registrations when using an extension
   * @param {Map} extensionsStore - The global registry for the extension instances
   * @param {SourceEditorExtensionDefinition} extension - The extension definition to use.
   * @returns {EditorExtension|*}
   */
  useExtension(extensionsStore, extension = {}) {
    const { definition } = extension;
    if (!definition) {
      throw new Error(EDITOR_EXTENSION_NO_DEFINITION_ERROR);
    }
    if (typeof definition !== 'function') {
      throw new Error(EDITOR_EXTENSION_DEFINITION_TYPE_ERROR);
    }

    // Existing Extension Path
    const existingExt = utils.getStoredExtension(extensionsStore, definition.extensionName);
    if (existingExt) {
      if (isEqual(extension.setupOptions, existingExt.setupOptions)) {
        if (utils.hasFullApiRegistered(this.extensionsAPI, Object.keys(existingExt.api))) {
          return existingExt;
        }
      }
      this.unuseExtension(extensionsStore, existingExt);
    }

    // New Extension Path
    const extensionInstance = new EditorExtension(extension);
    const { setupOptions, obj: extensionObj } = extensionInstance;
    if (extensionObj.onSetup) {
      extensionObj.onSetup(this, setupOptions);
    }
    if (extensionsStore) {
      this.registerExtension(extensionInstance, extensionsStore);
    }
    this.registerExtensionMethods(extensionInstance);
    return extensionInstance;
  }

  /**
   * Register extension in the global extensions store
   * @param {SourceEditorExtension} extension - Instance of Source Editor extension
   * @param {Map} extensionsStore - The global registry for the extension instances
   */
  registerExtension(extension, extensionsStore) {
    const { extensionName } = extension;
    const hasExtensionRegistered =
      extensionsStore.has(extensionName) &&
      isEqual(extension.setupOptions, extensionsStore.get(extensionName).setupOptions);
    if (hasExtensionRegistered) {
      return;
    }
    extensionsStore.set(extensionName, extension);
    const { obj: extensionObj } = extension;
    if (extensionObj.onUse) {
      extensionObj.onUse(this);
    }
  }

  /**
   * Register extension methods in the registry on the instance
   * @param {SourceEditorExtension} extension - Instance of Source Editor extension
   */
  registerExtensionMethods(extension) {
    const { api, extensionName } = extension;

    if (!api) {
      return;
    }

    Object.keys(api).forEach((prop) => {
      if (this[prop]) {
        logError(sprintf(EDITOR_EXTENSION_NAMING_CONFLICT_ERROR, { prop }));
      } else {
        this.methods[prop] = extensionName;
      }
    }, this);
  }

  //
  // UNREGISTERING AN EXTENSION
  //

  /**
   * Unregister extension with the cleanup
   * @param {Map} extensionsStore - The global registry for the extension instances
   * @param {SourceEditorExtension} extension - Instance of Source Editor extension to un-use
   */
  unuseExtension(extensionsStore, extension) {
    if (!extension) {
      throw new Error(EDITOR_EXTENSION_NOT_SPECIFIED_FOR_UNUSE_ERROR);
    }
    const { extensionName } = extension;
    const existingExt = utils.getStoredExtension(extensionsStore, extensionName);
    if (!existingExt) {
      throw new Error(sprintf(EDITOR_EXTENSION_NOT_REGISTERED_ERROR, { extensionName }));
    }
    const { obj: extensionObj } = existingExt;
    if (extensionObj.onBeforeUnuse) {
      extensionObj.onBeforeUnuse(this);
    }
    this.unregisterExtensionMethods(existingExt);
    if (extensionObj.onUnuse) {
      extensionObj.onUnuse(this);
    }
  }

  /**
   * Remove all methods associated with this extension from the registry on the instance
   * @param {SourceEditorExtension} extension - Instance of Source Editor extension to un-use
   */
  unregisterExtensionMethods(extension) {
    const { api, extensionName } = extension;
    if (!api) {
      return;
    }
    Object.keys(api).forEach((method) => {
      utils.removeExtFromMethod(method, extensionName, this.methods);
    });
  }

  /**
   * PUBLIC API OF AN INSTANCE
   */

  /**
   * Updates model language based on the path
   * @param {String} path - blob path
   */
  updateModelLanguage(path) {
    const lang = getBlobLanguage(path);
    const model = this.getModel();
    // return monacoEditor.setModelLanguage(model, lang);
    monacoEditor.setModelLanguage(model, lang);
  }

  /**
   * Main entry point to apply an extension to the instance
   * @param {SourceEditorExtensionDefinition[]|SourceEditorExtensionDefinition} extDefs - The extension(s) to use
   * @returns {EditorExtension|*}
   */
  use(extDefs) {
    return this.dispatchExtAction(this.useExtension, extDefs);
  }

  /**
   * Main entry point to remove an extension to the instance
   * @param {SourceEditorExtension[]|SourceEditorExtension} exts -
   * @returns {*}
   */
  unuse(exts) {
    return this.dispatchExtAction(this.unuseExtension, exts);
  }

  /**
   * Get the methods returned by extensions.
   * @returns {Array}
   */
  get extensionsAPI() {
    return Object.keys(this.methods);
  }
}
