// Copyright 2020 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import * as Common from '../common/common.js';
import * as Platform from '../platform/platform.js';
import {ls} from '../platform/platform.js';
import * as Root from '../root/root.js';

import {Context} from './Context.js';

export interface ActionDelegate {
  handleAction(_context: Context, _actionId: string): boolean;
}

export class Action extends Common.ObjectWrapper.ObjectWrapper {
  _enabled = true;
  _toggled = false;
  private actionRegistration: ActionRegistration;
  constructor(actionRegistration: ActionRegistration) {
    super();
    this.actionRegistration = actionRegistration;
  }

  id(): string {
    return this.actionRegistration.actionId;
  }

  async execute(): Promise<boolean> {
    if (!this.actionRegistration.loadActionDelegate) {
      return false;
    }
    const delegate = await this.actionRegistration.loadActionDelegate();
    const actionId = this.id();
    return delegate.handleAction(Context.instance(), actionId);
  }

  icon(): string|undefined {
    return this.actionRegistration.iconClass;
  }

  toggledIcon(): string|undefined {
    return this.actionRegistration.toggledIconClass;
  }

  toggleWithRedColor(): boolean {
    return Boolean(this.actionRegistration.toggleWithRedColor);
  }

  setEnabled(enabled: boolean): void {
    if (this._enabled === enabled) {
      return;
    }

    this._enabled = enabled;
    this.dispatchEventToListeners(Events.Enabled, enabled);
  }

  enabled(): boolean {
    return this._enabled;
  }

  category(): string {
    return this.actionRegistration.category;
  }

  tags(): string|void {
    if (this.actionRegistration.tags) {
      // Get localized keys and separate by null character to prevent fuzzy matching from matching across them.
      return this.actionRegistration.tags.map(tag => tag()).join('\0');
    }
  }

  toggleable(): boolean {
    return Boolean(this.actionRegistration.toggleable);
  }

  title(): string {
    let title = this.actionRegistration.title ? this.actionRegistration.title() : '';
    const options = this.actionRegistration.options;
    if (options) {
      // Actions with an 'options' property don't have a title field. Instead, the displayed
      // title is taken from the 'title' property of the option that is not active. Only one of the
      // two options can be active at a given moment and the 'toggled' property of the action along
      // with the 'value' of the options are used to determine which one it is.

      for (const pair of options) {
        if (pair.value !== this._toggled) {
          title = pair.title();
        }
      }
    }
    return title;
  }

  toggled(): boolean {
    return this._toggled;
  }

  setToggled(toggled: boolean): void {
    console.assert(this.toggleable(), 'Shouldn\'t be toggling an untoggleable action', this.id());
    if (this._toggled === toggled) {
      return;
    }

    this._toggled = toggled;
    this.dispatchEventToListeners(Events.Toggled, toggled);
  }

  options(): undefined|Array<ExtensionOption> {
    return this.actionRegistration.options;
  }

  contextTypes(): undefined|Array<unknown> {
    if (this.actionRegistration.contextTypes) {
      return this.actionRegistration.contextTypes();
    }
    return undefined;
  }

  canInstantiate(): boolean {
    return Boolean(this.actionRegistration.loadActionDelegate);
  }

  bindings(): Array<Binding>|undefined {
    return this.actionRegistration.bindings;
  }

  experiment(): string|undefined {
    return this.actionRegistration.experiment;
  }

  condition(): string|undefined {
    return this.actionRegistration.condition;
  }

  order(): number|undefined {
    return this.actionRegistration.order;
  }
}

const registeredActionExtensions: Array<Action> = [];

const actionIdSet = new Set<string>();

export function registerActionExtension(registration: ActionRegistration): void {
  const actionId = registration.actionId;
  if (actionIdSet.has(actionId)) {
    throw new Error(`Duplicate Action id '${actionId}': ${new Error().stack}`);
  }
  actionIdSet.add(actionId);
  registeredActionExtensions.push(new Action(registration));
}

export function getRegisteredActionExtensions(): Array<Action> {
  return registeredActionExtensions
      .filter(
          action => Root.Runtime.Runtime.isDescriptorEnabled(
              {experiment: action.experiment(), condition: action.condition()}))
      .sort((firstAction, secondAction) => {
        const order1 = firstAction.order() || 0;
        const order2 = secondAction.order() || 0;
        return order1 - order2;
      });
}

export const enum Platforms {
  All = 'All platforms',
  Mac = 'mac',
  WindowsLinux = 'windows,linux',
  Android = 'Android',
  Windows = 'windows',
}

export const Events = {
  Enabled: Symbol('Enabled'),
  Toggled: Symbol('Toggled'),
};

export const ActionCategory = {
  ELEMENTS: ls`Elements`,
  SCREENSHOT: ls`Screenshot`,
  NETWORK: ls`Network`,
  MEMORY: ls`Memory`,
  JAVASCRIPT_PROFILER: ls`JavaScript Profiler`,
  CONSOLE: ls`Console`,
  PERFORMANCE: ls`Performance`,
  MOBILE: ls`Mobile`,
  SENSORS: ls`Sensors`,
  HELP: ls`Help`,
  INPUTS: ls`Inputs`,
  LAYERS: ls`Layers`,
  NAVIGATION: ls`Navigation`,
  DRAWER: ls`Drawer`,
  GLOBAL: ls`Global`,
  RESOURCES: ls`Resources`,
  BACKGROUND_SERVICES: ls`Background Services`,
  SETTINGS: ls`Settings`,
  DEBUGGER: ls`Debugger`,
  RECORDER: ls`Recorder`,
  SOURCES: ls`Sources`,
};

type ActionCategory = typeof ActionCategory[keyof typeof ActionCategory];

export const enum IconClass {
  LARGEICON_NODE_SEARCH = 'largeicon-node-search',
  LARGEICON_START_RECORDING = 'largeicon-start-recording',
  LARGEICON_STOP_RECORDING = 'largeicon-stop-recording',
  LARGEICON_REFRESH = 'largeicon-refresh',
  LARGEICON_CLEAR = 'largeicon-clear',
  LARGEICON_VISIBILITY = 'largeicon-visibility',
  LARGEICON_PHONE = 'largeicon-phone',
  LARGEICON_PLAY = 'largeicon-play',
  LARGEICON_PAUSE = 'largeicon-pause',
  LARGEICON_RESUME = 'largeicon-resume',
  LARGEICON_TRASH_BIN = 'largeicon-trash-bin',
  LARGEICON_SETTINGS_GEAR = 'largeicon-settings-gear',
  LARGEICON_STEP_OVER = 'largeicon-step-over',
  LARGE_ICON_STEP_INTO = 'largeicon-step-into',
  LARGE_ICON_STEP = 'largeicon-step',
  LARGE_ICON_STEP_OUT = 'largeicon-step-out',
  LARGE_ICON_DEACTIVATE_BREAKPOINTS = 'largeicon-deactivate-breakpoints',
  LARGE_ICON_ADD = 'largeicon-add',
}

export const enum KeybindSet {
  DEVTOOLS_DEFAULT = 'devToolsDefault',
  VS_CODE = 'vsCode',
}

export interface ExtensionOption {
  value: boolean;
  title: () => Platform.UIString.LocalizedString;
  text?: string;
}

export interface Binding {
  platform?: Platforms;
  shortcut: string;
  keybindSets?: Array<KeybindSet>;
}

/**
 * The representation of an action extension to be registered.
 */
export interface ActionRegistration {
  /**
   * The unique id of an Action extension.
   */
  actionId: string;
  /**
   * The category with which the action is displayed in the UI.
   */
  category: ActionCategory;
  /**
   * The title with which the action is displayed in the UI.
   */
  title?: () => Platform.UIString.LocalizedString;
  /**
   * The type of the icon used to trigger the action.
   */
  iconClass?: IconClass;
  /**
   * Whether the style of the icon toggles on interaction.
   */
  toggledIconClass?: IconClass;
  /**
   * Whether the class 'toolbar-toggle-with-red-color' is toggled on the icon on interaction.
   */
  toggleWithRedColor?: boolean;
  /**
   * Words used to find an action in the Command Menu.
   */
  tags?: Array<() => Platform.UIString.LocalizedString>;
  /**
   * Whether the action is toggleable.
   */
  toggleable?: boolean;
  /**
   * Loads the class that handles the action when it is triggered. The common pattern for implementing
   * this function relies on having the module that contains the action’s handler lazily loaded. For example:
   * ```js
   *  let loadedElementsModule;
   *
   *  async function loadElementsModule() {
   *
   *    if (!loadedElementsModule) {
   *      loadedElementsModule = await import('./elements.js');
   *    }
   *    return loadedElementsModule;
   *  }
   *  UI.ActionRegistration.registerActionExtension({
   *   <...>
   *    async loadActionDelegate() {
   *      const Elements = await loadElementsModule();
   *      return Elements.ElementsPanel.ElementsActionDelegate.instance();
   *    },
   *   <...>
   *  });
   * ```
   */
  loadActionDelegate?: () => Promise<ActionDelegate>;
  /**
   * Returns the classes that represent the 'context flavors' under which the action is available for triggering.
   * The context of the application is described in 'flavors' that are usually views added and removed to the context
   * as the user interacts with the application (e.g when the user moves across views). (See UI.Context)
   * When the action is supposed to be available globally, that is, it does not depend on the application to have
   * a specific context, the value of this property should be undefined.
   *
   * Because the method is synchronous, context types should be already loaded when the method is invoked.
   * In the case that an action has context types it depends on, and they haven't been loaded yet, the function should
   * return an empty array. Once the context types have been loaded, the function should return an array with all types
   * that it depends on.
   *
   * The common pattern for implementing this function is relying on having the module with the corresponding context
   * types loaded and stored when the related 'view' extension is loaded asynchronously. As an example:
   *
   * ```js
   * let loadedElementsModule;
   *
   * async function loadElementsModule() {
   *
   *   if (!loadedElementsModule) {
   *     loadedElementsModule = await import('./elements.js');
   *   }
   *   return loadedElementsModule;
   * }
   * function maybeRetrieveContextTypes(getClassCallBack: (elementsModule: typeof Elements) => unknown[]): unknown[] {
   *
   *   if (loadedElementsModule === undefined) {
   *     return [];
   *   }
   *   return getClassCallBack(loadedElementsModule);
   * }
   * UI.ActionRegistration.registerActionExtension({
   *
   *   contextTypes() {
   *     return maybeRetrieveContextTypes(Elements => [Elements.ElementsPanel.ElementsPanel]);
   *   }
   *   <...>
   * });
   * ```
   */
  contextTypes?: () => Array<unknown>;
  /**
   * The descriptions for each of the two states in which a toggleable action can be.
   */
  options?: Array<ExtensionOption>;
  /**
   * The description of the variables (e.g. platform, keys and keybind sets) under which a keyboard shortcut triggers the action.
   * If a keybind must be available on all platforms, its 'platform' property must be undefined. The same applies to keybind sets
   * and the keybindSet property.
   *
   * Keybinds also depend on the context types of their corresponding action, and so they will only be available when such context types
   * are flavors of the current appliaction context.
   */
  bindings?: Array<Binding>;
  /**
   * The name of the experiment an action is associated with. Enabling and disabling the declared
   * experiment will enable and disable the action respectively.
   */
  experiment?: Root.Runtime.ExperimentName;
  /**
   * A condition represented as a string the action's availability depends on. Conditions come
   * from the queryParamsObject defined in Runtime and just as the experiment field, they determine the availability
   * of the setting. A condition can be negated by prepending a ‘!’ to the value of the condition
   * property and in that case the behaviour of the action's availability will be inverted.
   */
  condition?: Root.Runtime.ConditionName;
  /**
   * Used to sort actions when all registered actions are queried.
   */
  order?: number;
}
