import { Guid } from '../utils/Guid';
import { DOM } from '../DOM';
import { EventDispatcher, NoArgs, Event } from '../EventDispatcher';
import { UIInstanceManager } from '../UIManager';
import { PlayerAPI } from 'bitmovin-player';
import { i18n, LocalizableText } from '../localization/i18n';

/**
 * Base configuration interface for a component.
 * Should be extended by components that want to add additional configuration options.
 *
 * @category Configs
 */
export interface ComponentConfig {
  /**
   * The HTML tag name of the component.
   * Default: 'div'
   */
  tag?: string;
  /**
   * The HTML ID of the component.
   * Default: automatically generated with pattern 'ui-id-{guid}'.
   */
  id?: string;

  /**
   * A prefix to prepend all CSS classes with.
   */
  cssPrefix?: string;

  /**
   * The CSS classes of the component. This is usually the class from where the component takes its styling.
   */
  cssClass?: string; // 'class' is a reserved keyword, so we need to make the name more complicated

  /**
   * Additional CSS classes of the component.
   */
  cssClasses?: string[];

  /**
   * Specifies if the component should be hidden at startup.
   * Default: false
   */
  hidden?: boolean;

  /**
   * Specifies if the component is enabled (interactive) or not.
   * Default: false
   */
  disabled?: boolean;

  /**
   * Specifies the component role for WCAG20 standard
   */
  role?: string;

  /**
   * WCAG20 requirement for screen reader navigation
   */
  tabIndex?: number;

  /**
   * WCAG20 standard for defining info about the component (usually the name)
   */
  ariaLabel?: LocalizableText;
}

export interface ComponentHoverChangedEventArgs extends NoArgs {
  /**
   * True is the component is hovered, else false.
   */
  hovered: boolean;
}

export enum ViewMode {
  /**
   * Indicates that the component has entered a view mode where it must stay visible. Auto-hiding of this component
   * must be disabled as long as it resides in this state.
   */
  Persistent = 'persistent',

  /**
   * The control can be hidden at any time.
   */
  Temporary = 'temporary',
}

export interface ViewModeChangedEventArgs extends NoArgs {
  /**
   * The `ViewMode` the control is currently in.
   */
  mode: ViewMode;
}

export interface ComponentFocusChangedEventArgs extends NoArgs {
  /**
   * True is the component is focused, else false.
   */
  focused: boolean;
}

/**
 * The base class of the UI framework.
 * Each component must extend this class and optionally the config interface.
 *
 * @category Components
 */
export class Component<Config extends ComponentConfig> {
  /**
   * The classname that is attached to the element when it is in the hidden state.
   * @type {string}
   */
  private static readonly CLASS_HIDDEN = 'hidden';

  /**
   * The classname that is attached to the element when it is in the disabled state.
   * @type {string}
   */
  private static readonly CLASS_DISABLED = 'disabled';

  /**
   * Configuration object of this component.
   */
  protected config: Config;

  /**
   * The component's DOM element.
   */
  private element: DOM;

  /**
   * Flag that keeps track of the hidden state.
   */
  private hidden: boolean;

  /**
   * Flat that keeps track of the disabled state.
   */
  private disabled: boolean;

  /**
   * Flag that keeps track of the hover state.
   */
  private hovered: boolean;

  /**
   * The current view mode of the component.
   */
  private viewMode: ViewMode;

  /**
   * The list of events that this component offers. These events should always be private and only directly
   * accessed from within the implementing component.
   *
   * Because TypeScript does not support private properties with the same name on different class hierarchy levels
   * (i.e. superclass and subclass cannot contain a private property with the same name), the default naming
   * convention for the event list of a component that should be followed by subclasses is the concatenation of the
   * camel-cased class name + 'Events' (e.g. SubClass extends Component => subClassEvents).
   * See {@link #componentEvents} for an example.
   *
   * Event properties should be named in camel case with an 'on' prefix and in the present tense. Async events may
   * have a start event (when the operation starts) in the present tense, and must have an end event (when the
   * operation ends) in the past tense (or present tense in special cases (e.g. onStart/onStarted or onPlay/onPlaying).
   * See {@link #componentEvents#onShow} for an example.
   *
   * Each event should be accompanied with a protected method named by the convention eventName + 'Event'
   * (e.g. onStartEvent), that actually triggers the event by calling {@link EventDispatcher#dispatch dispatch} and
   * passing a reference to the component as first parameter. Components should always trigger their events with these
   * methods. Implementing this pattern gives subclasses means to directly listen to the events by overriding the
   * method (and saving the overhead of passing a handler to the event dispatcher) and more importantly to trigger
   * these events without having access to the private event list.
   * See {@link #onShow} for an example.
   *
   * To provide external code the possibility to listen to this component's events (subscribe, unsubscribe, etc.),
   * each event should also be accompanied by a public getter function with the same name as the event's property,
   * that returns the {@link Event} obtained from the event dispatcher by calling {@link EventDispatcher#getEvent}.
   * See {@link #onShow} for an example.
   *
   * Full example for an event representing an example action in a example component:
   *
   * <code>
   * // Define an example component class with an example event
   * class ExampleComponent extends Component<ComponentConfig> {
   *
   *     private exampleComponentEvents = {
   *         onExampleAction: new EventDispatcher<ExampleComponent, NoArgs>()
   *     }
   *
   *     // constructor and other stuff...
   *
   *     protected onExampleActionEvent() {
   *        this.exampleComponentEvents.onExampleAction.dispatch(this);
   *    }
   *
   *    get onExampleAction(): Event<ExampleComponent, NoArgs> {
   *        return this.exampleComponentEvents.onExampleAction.getEvent();
   *    }
   * }
   *
   * // Create an instance of the component somewhere
   * var exampleComponentInstance = new ExampleComponent();
   *
   * // Subscribe to the example event on the component
   * exampleComponentInstance.onExampleAction.subscribe(function (sender: ExampleComponent) {
   *     console.log('onExampleAction of ' + sender + ' has fired!');
   * });
   * </code>
   */
  private componentEvents = {
    onShow: new EventDispatcher<Component<Config>, NoArgs>(),
    onHide: new EventDispatcher<Component<Config>, NoArgs>(),
    onViewModeChanged: new EventDispatcher<Component<Config>, ViewModeChangedEventArgs>(),
    onHoverChanged: new EventDispatcher<Component<Config>, ComponentHoverChangedEventArgs>(),
    onEnabled: new EventDispatcher<Component<Config>, NoArgs>(),
    onDisabled: new EventDispatcher<Component<Config>, NoArgs>(),
    onFocusChanged: new EventDispatcher<Component<Config>, ComponentFocusChangedEventArgs>(),
  };

  /**
   * Constructs a component with an optionally supplied config. All subclasses must call the constructor of their
   * superclass and then merge their configuration into the component's configuration.
   * @param config the configuration for the component
   */
  constructor(config: ComponentConfig = {}) {
    // Create the configuration for this component
    this.config = <Config>this.mergeConfig(
      config,
      {
        tag: 'div',
        id: '{{PREFIX}}-id-' + Guid.next(),
        cssPrefix: '{{PREFIX}}',
        cssClass: 'ui-component',
        cssClasses: [],
        hidden: false,
        disabled: false,
        tabIndex: -1,
      },
      {},
    );
    this.viewMode = ViewMode.Temporary;
  }

  /**
   * Initializes the component, e.g. by applying config settings.
   * This method must not be called from outside the UI framework.
   *
   * This method is automatically called by the {@link UIInstanceManager}. If the component is an inner component of
   * some component, and thus encapsulated abd managed internally and never directly exposed to the UIManager,
   * this method must be called from the managing component's {@link #initialize} method.
   */
  initialize(): void {
    this.hidden = this.config.hidden;
    this.disabled = this.config.disabled;

    // Hide the component at initialization if it is configured to be hidden
    if (this.isHidden()) {
      this.hidden = false; // Set flag to false for the following hide() call to work (hide() checks the flag)
      this.hide();
    }

    // Disable the component at initialization if it is configured to be disabled
    if (this.isDisabled()) {
      this.disabled = false; // Set flag to false for the following disable() call to work (disable() checks the flag)
      this.disable();
    }
  }

  /**
   * Configures the component for the supplied Player and UIInstanceManager. This is the place where all the magic
   * happens, where components typically subscribe and react to events (on their DOM element, the Player, or the
   * UIInstanceManager), and basically everything that makes them interactive.
   * This method is called only once, when the UIManager initializes the UI.
   *
   * Subclasses usually overwrite this method to add their own functionality.
   *
   * @param player the player which this component controls
   * @param uimanager the UIInstanceManager that manages this component
   */
  configure(player: PlayerAPI, uimanager: UIInstanceManager): void {
    this.onShow.subscribe(() => uimanager.onComponentShow.dispatch(this));
    this.onHide.subscribe(() => uimanager.onComponentHide.dispatch(this));
    this.onViewModeChanged.subscribe((_, args) => uimanager.onComponentViewModeChanged.dispatch(this, args));

    // Track the hovered state of the element
    this.getDomElement().on('mouseenter', () => this.onHoverChangedEvent(true));
    this.getDomElement().on('mouseleave', () => this.onHoverChangedEvent(false));

    // Track the focused state of the element
    this.getDomElement().on('focusin', () => this.onFocusChangedEvent(true));
    this.getDomElement().on('focusout', () => this.onFocusChangedEvent(false));
  }

  /**
   * Releases all resources and dependencies that the component holds. Player, DOM, and UIManager events are
   * automatically removed during release and do not explicitly need to be removed here.
   * This method is called by the UIManager when it releases the UI.
   *
   * Subclasses that need to release resources should override this method and call super.release().
   */
  release(): void {
    // Nothing to do here, override where necessary
  }

  /**
   * Generate the DOM element for this component.
   *
   * Subclasses usually overwrite this method to extend or replace the DOM element with their own design.
   */
  protected toDomElement(): DOM {
    const element = new DOM(
      this.config.tag,
      {
        id: this.config.id,
        class: this.getCssClasses(),
        role: this.config.role,
      },
      this,
    );

    if (typeof this.config.tabIndex === 'number') {
      element.attr('tabindex', this.config.tabIndex.toString());
    }

    return element;
  }

  /**
   * Returns the DOM element of this component. Creates the DOM element if it does not yet exist.
   *
   * Should not be overwritten by subclasses.
   *
   * @returns {DOM}
   */
  getDomElement(): DOM {
    if (!this.element) {
      this.element = this.toDomElement();
    }

    return this.element;
  }

  /**
   * Checks if this component has a DOM element.
   */
  hasDomElement(): boolean {
    return Boolean(this.element);
  }

  setAriaLabel(label: LocalizableText): void {
    this.setAriaAttr('label', i18n.performLocalization(label));
  }

  setAriaAttr(name: string, value: string) {
    this.getDomElement().attr(`aria-${name}`, value);
  }

  /**
   * Merges a configuration with a default configuration and a base configuration from the superclass.
   *
   * @param config the configuration settings for the components, as usually passed to the constructor
   * @param defaults a default configuration for settings that are not passed with the configuration
   * @param base configuration inherited from a superclass
   * @returns {Config}
   */
  protected mergeConfig<Config>(config: Config, defaults: Partial<Config>, base: Config): Config {
    // Extend default config with supplied config
    const merged = Object.assign({}, base, defaults, config);

    // Return the extended config
    return merged;
  }

  /**
   * Helper method that returns a string of all CSS classes of the component.
   *
   * @returns {string}
   */
  protected getCssClasses(): string {
    // Merge all CSS classes into single array
    let flattenedArray = [this.config.cssClass].concat(this.config.cssClasses);
    // Prefix classes
    flattenedArray = flattenedArray.map(css => {
      return this.prefixCss(css);
    });
    // Join array values into a string
    const flattenedString = flattenedArray.join(' ');
    // Return trimmed string to prevent whitespace at the end from the join operation
    return flattenedString.trim();
  }

  protected prefixCss(cssClassOrId: string): string {
    return this.config.cssPrefix + '-' + cssClassOrId;
  }

  /**
   * Returns the configuration object of the component.
   * @returns {Config}
   */
  public getConfig(): Config {
    return this.config;
  }

  /**
   * Hides the component if shown.
   * This method basically transfers the component into the hidden state. Actual hiding is done via CSS.
   */
  hide() {
    if (!this.hidden) {
      this.hidden = true;
      this.getDomElement().addClass(this.prefixCss(Component.CLASS_HIDDEN));
      this.onHideEvent();
    }
  }

  /**
   * Shows the component if hidden.
   */
  show() {
    if (this.hidden) {
      this.getDomElement().removeClass(this.prefixCss(Component.CLASS_HIDDEN));
      this.hidden = false;
      this.onShowEvent();
    }
  }

  /**
   * Determines if the component is hidden.
   * @returns {boolean} true if the component is hidden, else false
   */
  isHidden(): boolean {
    return this.hidden;
  }

  /**
   * Determines if the component is shown.
   * @returns {boolean} true if the component is visible, else false
   */
  isShown(): boolean {
    return !this.isHidden();
  }

  /**
   * Toggles the hidden state by hiding the component if it is shown, or showing it if hidden.
   */
  toggleHidden() {
    if (this.isHidden()) {
      this.show();
    } else {
      this.hide();
    }
  }

  /**
   * Disables the component.
   * This method basically transfers the component into the disabled state. Actual disabling is done via CSS or child
   * components. (e.g. Button needs to unsubscribe click listeners)
   */
  disable(): void {
    if (!this.disabled) {
      this.disabled = true;
      this.getDomElement().addClass(this.prefixCss(Component.CLASS_DISABLED));
      this.onDisabledEvent();
    }
  }

  /**
   * Enables the component.
   * This method basically transfers the component into the enabled state. Actual enabling is done via CSS or child
   * components. (e.g. Button needs to subscribe click listeners)
   */
  enable(): void {
    if (this.disabled) {
      this.getDomElement().removeClass(this.prefixCss(Component.CLASS_DISABLED));
      this.disabled = false;
      this.onEnabledEvent();
    }
  }

  /**
   * Determines if the component is disabled.
   * @returns {boolean} true if the component is disabled, else false
   */
  isDisabled(): boolean {
    return this.disabled;
  }

  /**
   * Determines if the component is enabled.
   * @returns {boolean} true if the component is enabled, else false
   */
  isEnabled(): boolean {
    return !this.isDisabled();
  }

  /**
   * Determines if the component is currently hovered.
   * @returns {boolean} true if the component is hovered, else false
   */
  isHovered(): boolean {
    return this.hovered;
  }

  /**
   * Fires the onShow event.
   * See the detailed explanation on event architecture on the {@link #componentEvents events list}.
   */
  protected onShowEvent(): void {
    this.componentEvents.onShow.dispatch(this);
  }

  /**
   * Fires the onHide event.
   * See the detailed explanation on event architecture on the {@link #componentEvents events list}.
   */
  protected onHideEvent(): void {
    this.componentEvents.onHide.dispatch(this);
  }

  /**
   * Fires the onEnabled event.
   * See the detailed explanation on event architecture on the {@link #componentEvents events list}.
   */
  protected onEnabledEvent(): void {
    this.componentEvents.onEnabled.dispatch(this);
  }

  /**
   * Fires the onDisabled event.
   * See the detailed explanation on event architecture on the {@link #componentEvents events list}.
   */
  protected onDisabledEvent(): void {
    this.componentEvents.onDisabled.dispatch(this);
  }

  /**
   * Fires the onViewModeChanged event.
   * See the detailed explanation on event architecture on the {@link #componentEvents events list}.
   */
  protected onViewModeChangedEvent(mode: ViewMode): void {
    if (this.viewMode === mode) {
      return;
    }

    this.viewMode = mode;
    this.componentEvents.onViewModeChanged.dispatch(this, { mode });
  }

  /**
   * Fires the onHoverChanged event.
   * See the detailed explanation on event architecture on the {@link #componentEvents events list}.
   */
  protected onHoverChangedEvent(hovered: boolean): void {
    this.hovered = hovered;
    this.componentEvents.onHoverChanged.dispatch(this, { hovered: hovered });
  }

  protected onFocusChangedEvent(focused: boolean): void {
    this.componentEvents.onFocusChanged.dispatch(this, { focused: focused });
  }

  /**
   * Gets the event that is fired when the component is showing.
   * See the detailed explanation on event architecture on the {@link #componentEvents events list}.
   * @returns {Event<Component<Config>, NoArgs>}
   */
  get onShow(): Event<Component<Config>, NoArgs> {
    return this.componentEvents.onShow.getEvent();
  }

  /**
   * Gets the event that is fired when the component is hiding.
   * See the detailed explanation on event architecture on the {@link #componentEvents events list}.
   * @returns {Event<Component<Config>, NoArgs>}
   */
  get onHide(): Event<Component<Config>, NoArgs> {
    return this.componentEvents.onHide.getEvent();
  }

  /**
   * Gets the event that is fired when the component is enabling.
   * See the detailed explanation on event architecture on the {@link #componentEvents events list}.
   * @returns {Event<Component<Config>, NoArgs>}
   */
  get onEnabled(): Event<Component<Config>, NoArgs> {
    return this.componentEvents.onEnabled.getEvent();
  }

  /**
   * Gets the event that is fired when the component is disabling.
   * See the detailed explanation on event architecture on the {@link #componentEvents events list}.
   * @returns {Event<Component<Config>, NoArgs>}
   */
  get onDisabled(): Event<Component<Config>, NoArgs> {
    return this.componentEvents.onDisabled.getEvent();
  }

  /**
   * Gets the event that is fired when the component's hover-state is changing.
   * @returns {Event<Component<Config>, ComponentHoverChangedEventArgs>}
   */
  get onHoverChanged(): Event<Component<Config>, ComponentHoverChangedEventArgs> {
    return this.componentEvents.onHoverChanged.getEvent();
  }

  /**
   * Gets the event that is fired when the `ViewMode` of this component has changed.
   * @returns {Event<Component<Config>, ViewModeChangedEventArgs>}
   */
  get onViewModeChanged(): Event<Component<Config>, ViewModeChangedEventArgs> {
    return this.componentEvents.onViewModeChanged.getEvent();
  }

  /**
   * Gets the event that is fired when the component's focus-state is changing.
   * @returns {Event<Component<Config>, ComponentFocusChangedEventArgs>}
   */
  get onFocusedChanged(): Event<Component<Config>, ComponentFocusChangedEventArgs> {
    return this.componentEvents.onFocusChanged.getEvent();
  }
}
