/**
 * @license
 * Copyright 2019 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */
import {getAppContext} from '../../../services/app-context';
import {
  PLUGIN_LOADING_TIMEOUT_MS,
  getPluginNameFromUrl,
  isThemeFile,
  THEME_JS,
} from './gr-api-utils';
import {Plugin} from './gr-public-js-api';
import {getBaseUrl} from '../../../utils/url-util';
import {getPluginEndpoints} from './gr-plugin-endpoints';
import {PluginApi} from '../../../api/plugin';
import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
import {fireAlert} from '../../../utils/event-util';

enum PluginState {
  /** State that indicates the plugin is pending to be loaded. */
  PENDING = 'PENDING',
  /** State that indicates the plugin is already loaded. */
  LOADED = 'LOADED',
  /** State that indicates the plugin failed to load. */
  LOAD_FAILED = 'LOAD_FAILED',
}

interface PluginObject {
  name: string;
  url: string;
  state: PluginState;
  plugin: PluginApi | null;
}

interface PluginOption {
  sync?: boolean;
}

export interface PluginOptionMap {
  [path: string]: PluginOption;
}

type GerritScriptElement = HTMLScriptElement & {
  __importElement: HTMLScriptElement;
};

// Prefix for any unrecognized plugin urls.
// Url should match following patterns:
// /plugins/PLUGINNAME/static/SCRIPTNAME.js
// /plugins/PLUGINNAME.js
const UNKNOWN_PLUGIN_PREFIX = '__$$__';

// Current API version for Plugin,
// plugins with incompatible version will not be loaded.
const API_VERSION = '0.1';

/**
 * PluginLoader, responsible for:
 *
 * Loading all plugins and handling errors etc.
 * Recording plugin state.
 * Reporting on plugin loading status.
 * Retrieve plugin.
 * Check plugin status and if all plugins loaded.
 */
export class PluginLoader {
  _pluginListLoaded = false;

  _plugins = new Map<string, PluginObject>();

  _reporting: ReportingService | null = null;

  // Promise that resolves when all plugins loaded
  _loadingPromise: Promise<void> | null = null;

  // Resolver to resolve _loadingPromise once all plugins loaded
  _loadingResolver: (() => void) | null = null;

  private instanceId?: string;

  _getReporting() {
    if (!this._reporting) {
      this._reporting = getAppContext().reportingService;
    }
    return this._reporting;
  }

  /**
   * Use the plugin name or use the full url if not recognized.
   */
  _getPluginKeyFromUrl(url: string) {
    return getPluginNameFromUrl(url) || `${UNKNOWN_PLUGIN_PREFIX}${url}`;
  }

  /**
   * Load multiple plugins with certain options.
   */
  loadPlugins(plugins: string[] = [], instanceId?: string) {
    this.instanceId = instanceId;
    this._pluginListLoaded = true;

    plugins.forEach(path => {
      const url = this._urlFor(path, window.ASSETS_PATH);
      const pluginKey = this._getPluginKeyFromUrl(url);
      // Skip if already installed.
      if (this._plugins.has(pluginKey)) return;
      this._plugins.set(pluginKey, {
        name: pluginKey,
        url,
        state: PluginState.PENDING,
        plugin: null,
      });

      if (this._isPathEndsWith(url, '.js')) {
        this._loadJsPlugin(path);
      } else {
        this._failToLoad(`Unrecognized plugin path ${path}`, path);
      }
    });

    this.awaitPluginsLoaded().then(() => {
      const loaded = this.getPluginsByState(PluginState.LOADED);
      const failed = this.getPluginsByState(PluginState.LOAD_FAILED);
      this._getReporting().pluginsLoaded(loaded.map(p => p.name));
      this._getReporting().pluginsFailed(failed.map(p => p.name));
    });
  }

  _isPathEndsWith(url: string | URL, suffix: string) {
    if (!(url instanceof URL)) {
      try {
        url = new URL(url);
      } catch (e: unknown) {
        this._getReporting().error(
          'GrPluginLoader',
          new Error('url parse error'),
          e
        );
        return false;
      }
    }

    return url.pathname && url.pathname.endsWith(suffix);
  }

  private getPluginsByState(state: PluginState) {
    return [...this._plugins.values()].filter(p => p.state === state);
  }

  install(
    callback: (plugin: PluginApi) => void,
    version?: string,
    src?: string
  ) {
    // HTML import polyfill adds __importElement pointing to the import tag.
    const gerritScript = document.currentScript as GerritScriptElement | null;
    const script = gerritScript?.__importElement ?? gerritScript;
    if (!src && script && script.src) {
      src = script.src;
    }
    if ((!src || src.startsWith('data:')) && script && script.baseURI) {
      src = script && script.baseURI;
    }
    if (!src) {
      this._failToLoad('Failed to determine src.');
      return;
    }
    if (version && version !== API_VERSION) {
      this._failToLoad(
        `Plugin ${src} install error: only version ${API_VERSION} is supported in PolyGerrit. ${version} was given.`,
        src
      );
      return;
    }

    const url = this._urlFor(src);
    const pluginObject = this.getPlugin(url);
    let plugin = pluginObject && pluginObject.plugin;
    if (!plugin) {
      plugin = new Plugin(url);
    }
    try {
      callback(plugin);
      this._pluginInstalled(url, plugin);
    } catch (e: unknown) {
      if (e instanceof Error) {
        this._failToLoad(`${e.name}: ${e.message}`, src);
      } else {
        this._getReporting().error(
          'GrPluginLoader',
          new Error('plugin callback error'),
          e
        );
      }
    }
  }

  arePluginsLoaded() {
    if (!this._pluginListLoaded) return false;
    return this.getPluginsByState(PluginState.PENDING).length === 0;
  }

  _checkIfCompleted() {
    if (this.arePluginsLoaded()) {
      getPluginEndpoints().setPluginsReady();
      if (this._loadingResolver) {
        this._loadingResolver();
        this._loadingResolver = null;
        this._loadingPromise = null;
      }
    }
  }

  _timeout() {
    const pending = this.getPluginsByState(PluginState.PENDING);
    for (const plugin of pending) {
      this._updatePluginState(plugin.url, PluginState.LOAD_FAILED);
    }
    this._checkIfCompleted();
    const errorMessage = `Timeout when loading plugins: ${pending
      .map(p => p.name)
      .join(',')}`;
    fireAlert(document, errorMessage);
    return errorMessage;
  }

  _failToLoad(message: string, pluginUrl?: string) {
    // Show an alert with the error
    fireAlert(document, `Plugin install error: ${message} from ${pluginUrl}`);
    if (pluginUrl) this._updatePluginState(pluginUrl, PluginState.LOAD_FAILED);
    this._checkIfCompleted();
  }

  _updatePluginState(pluginUrl: string, state: PluginState): PluginObject {
    const key = this._getPluginKeyFromUrl(pluginUrl);
    if (this._plugins.has(key)) {
      this._plugins.get(key)!.state = state;
    } else {
      // Plugin is not recorded for some reason.
      console.info(`Plugin loaded separately: ${pluginUrl}`);
      this._plugins.set(key, {
        name: key,
        url: pluginUrl,
        state,
        plugin: null,
      });
    }
    console.debug(`Plugin ${key} ${state}`);
    return this._plugins.get(key)!;
  }

  _pluginInstalled(url: string, plugin: PluginApi) {
    const pluginObj = this._updatePluginState(url, PluginState.LOADED);
    pluginObj.plugin = plugin;
    this._getReporting().pluginLoaded(plugin.getPluginName() || url);
    this._checkIfCompleted();
  }

  /**
   * Checks if given plugin path/url is enabled or not.
   */
  isPluginEnabled(pathOrUrl: string) {
    const url = this._urlFor(pathOrUrl);
    const key = this._getPluginKeyFromUrl(url);
    return this._plugins.has(key);
  }

  /**
   * Returns the plugin object with a given url.
   */
  getPlugin(pathOrUrl: string) {
    const url = this._urlFor(pathOrUrl);
    const key = this._getPluginKeyFromUrl(url);
    return this._plugins.get(key);
  }

  /**
   * Checks if given plugin path/url is loaded or not.
   */
  isPluginLoaded(pathOrUrl: string): boolean {
    const url = this._urlFor(pathOrUrl);
    const key = this._getPluginKeyFromUrl(url);
    return this._plugins.has(key)
      ? this._plugins.get(key)!.state === PluginState.LOADED
      : false;
  }

  _loadJsPlugin(pluginUrl: string) {
    const urlWithAP = this._urlFor(pluginUrl, window.ASSETS_PATH);
    const urlWithoutAP = this._urlFor(pluginUrl);
    let onerror = undefined;
    if (urlWithAP !== urlWithoutAP) {
      onerror = () => this._createScriptTag(urlWithoutAP);
    }

    this._createScriptTag(urlWithAP, onerror);
  }

  _createScriptTag(url: string, onerror?: OnErrorEventHandler) {
    if (!onerror) {
      onerror = () => this._failToLoad(`${url} load error`, url);
    }

    const el = document.createElement('script');
    el.defer = true;
    el.setAttribute('src', url);
    // no credentials to send when fetch plugin js
    // and this will help provide more meaningful error than
    // 'Script error.'
    el.setAttribute('crossorigin', 'anonymous');
    el.onerror = onerror;
    return document.body.appendChild(el);
  }

  _urlFor(pathOrUrl: string, assetsPath?: string): string {
    if (isThemeFile(pathOrUrl)) {
      if (assetsPath && this.instanceId) {
        return `${assetsPath}/hosts/${this.instanceId}${THEME_JS}`;
      }
      return window.location.origin + getBaseUrl() + THEME_JS;
    }

    if (pathOrUrl.startsWith('http')) {
      // Plugins are loaded from another domain or preloaded.
      if (pathOrUrl.includes(location.host) && assetsPath) {
        // if is loading from host server, try replace with cdn when assetsPath provided
        return pathOrUrl.replace(location.origin, assetsPath);
      }
      return pathOrUrl;
    }

    if (!pathOrUrl.startsWith('/')) {
      pathOrUrl = '/' + pathOrUrl;
    }
    if (assetsPath) {
      return assetsPath + pathOrUrl;
    }
    return window.location.origin + getBaseUrl() + pathOrUrl;
  }

  awaitPluginsLoaded() {
    // Resolve if completed.
    this._checkIfCompleted();

    if (this.arePluginsLoaded()) {
      return Promise.resolve();
    }
    if (!this._loadingPromise) {
      // specify window here so that TS pulls the correct setTimeout method
      // if window is not specified, then the function is pulled from node
      // and the return type is NodeJS.Timeout object
      let timerId: number;
      this._loadingPromise = Promise.race([
        new Promise<void>(resolve => (this._loadingResolver = resolve)),
        new Promise(
          (_, reject) =>
            (timerId = window.setTimeout(() => {
              reject(new Error(this._timeout()));
            }, PLUGIN_LOADING_TIMEOUT_MS))
        ),
      ]).finally(() => {
        if (timerId) clearTimeout(timerId);
      }) as Promise<void>;
    }
    return this._loadingPromise;
  }
}

// TODO(dmfilippov): Convert to service and add to appContext
let pluginLoader = new PluginLoader();
export function _testOnly_resetPluginLoader() {
  pluginLoader = new PluginLoader();
  return pluginLoader;
}

export function getPluginLoader() {
  return pluginLoader;
}
