import { UIContainer } from './components/UIContainer';
import { DOM } from './DOM';
import { Component, ComponentConfig, ViewModeChangedEventArgs } from './components/Component';
import { Container } from './components/Container';
import { SeekBar, SeekBarMarker } from './components/seekbar/SeekBar';
import { NoArgs, EventDispatcher, CancelEventArgs } from './EventDispatcher';
import { UIUtils } from './utils/UIUtils';
import { ArrayUtils } from './utils/ArrayUtils';
import { BrowserUtils } from './utils/BrowserUtils';
import { TimelineMarker, UIConfig } from './UIConfig';
import { PlayerAPI, PlayerEventCallback, PlayerEventBase, PlayerEvent, AdEvent, LinearAd } from 'bitmovin-player';
import { VolumeController } from './utils/VolumeController';
import { i18n, CustomVocabulary, Vocabularies, I18n, LanguageChangedArgument } from './localization/i18n';
import { FocusVisibilityTracker } from './utils/FocusVisibilityTracker';
import { isMobileV3PlayerAPI, MobileV3PlayerAPI, MobileV3PlayerEvent } from './utils/MobileV3PlayerAPI';
import { SpatialNavigation } from './spatialnavigation/SpatialNavigation';
import { SubtitleSettingsManager } from './utils/SubtitleSettingsManager';
import { StorageUtils } from './utils/StorageUtils';
import { BufferingOverlay } from './components/overlays/BufferingOverlay';
import { ShadowDomManager } from './utils/ShadowDomManager';
import { AdBreakTracker } from './utils/AdBreakTracker';

/**
 * @category Configs
 */
export interface LocalizationConfig {
  /**
   * Sets the desired language, and falls back to 'en' if there is no vocabulary for the desired language. Setting it
   * to "auto" will enable language detection from the browser's locale.
   */
  language?: 'auto' | 'en' | 'de' | string;
  /**
   * A map of `language` to {@link CustomVocabulary} definitions. Can be used to overwrite default translations and add
   * custom strings or additional languages.
   */
  vocabularies?: Vocabularies;

  events?: {
    /**
     * Fires when the UI language has been changed during the lifetime of the UI.
     */
    onLanguageChanged: EventDispatcher<I18n, LanguageChangedArgument>;
  };
  /**
   * Specifies if the UI localization should automatically adapt to the selected subtitle language.
   * When enabled, the UI language will change to match the subtitle track's language, falling back
   * to the configured default UI language (English unless configured otherwise) if the language is not available.
   *
   * Default: false
   */
  adaptLocalizationToSubtitleLanguage?: boolean;
}

/**
 * @category Configs
 */
export interface InternalUIConfig extends UIConfig {
  events: {
    /**
     * Fires when the configuration has been updated/changed.
     */
    onUpdated: EventDispatcher<UIManager, void>;
  };
  volumeController: VolumeController;
  adBreakTracker: AdBreakTracker;
}

/**
 * The context that will be passed to a {@link UIConditionResolver} to determine if it's conditions fulfil the context.
 */
export interface UIConditionContext {
  /**
   * Tells if the player is loading or playing an ad.
   */
  isAd: boolean;
  /**
   * Tells if the current ad requires an external UI, if {@link #isAd} is true.
   */
  adRequiresUi: boolean;
  /**
   * Tells if the player is currently in fullscreen mode.
   */
  isFullscreen: boolean;
  /**
   * Tells if the UI is running in a mobile browser.
   */
  isMobile: boolean;
  /**
   * Tells if the UI is running in a TV browser.
   */
  isTv: boolean;
  /**
   * Tells if the player is in playing or paused state.
   */
  isPlaying: boolean;
  /**
   * Tells if the player has a Source.
   */
  isSourceLoaded: boolean;
  /**
   * The width of the player/UI element.
   */
  width: number;
  /**
   * The width of the document where the player/UI is embedded in.
   */
  documentWidth: number;
}

/**
 * Resolves the conditions of its associated UI in a {@link UIVariant} upon a {@link UIConditionContext} and decides
 * if the UI should be displayed. If it returns true, the UI is a candidate for display; if it returns false, it will
 * not be displayed in the given context.
 */
export interface UIConditionResolver {
  (context: UIConditionContext): boolean;
}

/**
 * Associates a UI instance with an optional {@link UIConditionResolver} that determines if the UI should be displayed.
 */
export interface UIVariant {
  ui: UIContainer;
  condition?: UIConditionResolver;
  spatialNavigation?: SpatialNavigation;
}

export interface ActiveUiChangedArgs extends NoArgs {
  /**
   * The previously active {@link UIInstanceManager} prior to the {@link UIManager} switching to a different UI variant.
   */
  previousUi: UIInstanceManager;
  /**
   * The currently active {@link UIInstanceManager}.
   */
  currentUi: UIInstanceManager;
}

export class UIManager {
  private player: PlayerAPI;
  private uiContainerElement: DOM;
  private uiVariants: UIVariant[];
  private uiInstanceManagers: InternalUIInstanceManager[];
  private currentUi: InternalUIInstanceManager;
  private config: InternalUIConfig; // Conjunction of provided uiConfig and sourceConfig from the player
  private managerPlayerWrapper: PlayerWrapper;
  private focusVisibilityTracker: FocusVisibilityTracker;
  private subtitleSettingsManager: SubtitleSettingsManager;
  private shadowDomManager: ShadowDomManager;

  private events = {
    onUiVariantResolve: new EventDispatcher<UIManager, UIConditionContext>(),
    onActiveUiChanged: new EventDispatcher<UIManager, ActiveUiChangedArgs>(),
  };

  /**
   * Creates a UI manager with a single UI variant that will be permanently shown.
   * @param player the associated player of this UI
   * @param ui the UI to add to the player
   * @param uiconfig optional UI configuration
   */
  constructor(player: PlayerAPI, ui: UIContainer, uiconfig?: UIConfig);
  /**
   * Creates a UI manager with a list of UI variants that will be dynamically selected and switched according to
   * the context of the UI.
   *
   * Every time the UI context changes, the conditions of the UI variants will be sequentially resolved and the first
   * UI, whose condition evaluates to true, will be selected and displayed. The last variant in the list might omit the
   * condition resolver and will be selected as default/fallback UI when all other conditions fail. If there is no
   * fallback UI and all conditions fail, no UI will be displayed.
   *
   * @param player the associated player of this UI
   * @param uiVariants a list of UI variants that will be dynamically switched
   * @param uiconfig optional UI configuration
   */
  constructor(player: PlayerAPI, uiVariants: UIVariant[], uiconfig?: UIConfig);
  constructor(player: PlayerAPI, playerUiOrUiVariants: UIContainer | UIVariant[], uiconfig: UIConfig = {}) {
    if (playerUiOrUiVariants instanceof UIContainer) {
      // Single-UI constructor has been called, transform arguments to UIVariant[] signature
      const playerUi = <UIContainer>playerUiOrUiVariants;
      const uiVariants = [];

      // Add the default player UI
      uiVariants.push({ ui: playerUi });

      this.uiVariants = uiVariants;
    } else {
      // Default constructor (UIVariant[]) has been called
      this.uiVariants = <UIVariant[]>playerUiOrUiVariants;
    }

    this.subtitleSettingsManager = new SubtitleSettingsManager();
    this.shadowDomManager = new ShadowDomManager();
    this.player = player;
    this.managerPlayerWrapper = new PlayerWrapper(player);

    // ensure that at least the metadata object does exist in the uiconfig
    uiconfig.metadata = uiconfig.metadata ? uiconfig.metadata : {};

    this.config = {
      playbackSpeedSelectionEnabled: true, // Switch on speed selector by default
      autoUiVariantResolve: true, // Switch on auto UI resolving by default
      disableAutoHideWhenHovered: false, // Disable auto hide when UI is hovered
      enableSeekPreview: true,
      shadowDom: false,
      ...uiconfig,
      events: {
        onUpdated: new EventDispatcher<UIManager, void>(),
      },
      volumeController: new VolumeController(this.managerPlayerWrapper.getPlayer()),
      adBreakTracker: new AdBreakTracker(this.managerPlayerWrapper.getPlayer()),
    };

    /**
     * Gathers configuration data from the UI config and player source config and creates a merged UI config
     * that is used throughout the UI instance.
     */
    const updateConfig = () => {
      const playerSourceConfig = player.getSource() || {};
      this.config.metadata = JSON.parse(JSON.stringify(uiconfig.metadata || {}));

      // Extract the UI-related config properties from the source config
      const playerSourceUiConfig: UIConfig = {
        metadata: {
          // TODO move metadata into source.metadata namespace in player v8
          title: playerSourceConfig.title,
          description: playerSourceConfig.description,
          markers: (playerSourceConfig as any).markers,
          recommendations: (playerSourceConfig as any).recommendations,
        },
      };

      // Player source config takes precedence over the UI config, because the config in the source is attached
      // to a source which changes with every player.load, whereas the UI config stays the same for the whole
      // lifetime of the player instance.
      this.config.metadata.title = playerSourceUiConfig.metadata.title || uiconfig.metadata.title;
      this.config.metadata.description = playerSourceUiConfig.metadata.description || uiconfig.metadata.description;
      this.config.metadata.markers = playerSourceUiConfig.metadata.markers || uiconfig.metadata.markers || [];
      this.config.metadata.recommendations =
        playerSourceUiConfig.metadata.recommendations || uiconfig.metadata.recommendations || [];

      StorageUtils.setStorageApiDisabled(uiconfig);
    };

    updateConfig();
    if (this.config.localization) {
      i18n.setConfig(this.config.localization);
    }
    this.subtitleSettingsManager.initialize();

    // Update the source configuration when a new source is loaded and dispatch onUpdated
    const updateSource = () => {
      updateConfig();
      this.config.events.onUpdated.dispatch(this);
    };

    const wrappedPlayer = this.managerPlayerWrapper.getPlayer();

    wrappedPlayer.on(this.player.exports.PlayerEvent.SourceLoaded, updateSource);

    // The PlaylistTransition event is only available on Mobile v3 for now.
    // This event is fired when a new source becomes active in the player.
    if (isMobileV3PlayerAPI(wrappedPlayer)) {
      wrappedPlayer.on(MobileV3PlayerEvent.PlaylistTransition, updateSource);
    }

    if (uiconfig.container) {
      // Unfortunately "uiContainerElement = new DOM(config.container)" will not accept the container with
      // string|HTMLElement type directly, although it accepts both types, so we need to spit these two cases up here.
      // TODO check in upcoming TS versions if the container can be passed in directly, or fix the constructor
      this.uiContainerElement =
        uiconfig.container instanceof HTMLElement ? new DOM(uiconfig.container) : new DOM(uiconfig.container);
    } else {
      this.uiContainerElement = new DOM(player.getContainer());
    }

    if (this.config.shadowDom == true || (this.config.shadowDom && this.config.shadowDom.enabled)) {
      if (ShadowDomManager.isShadowDomSupported()) {
        this.shadowDomManager.initialize(
          this.uiContainerElement,
          this.config.shadowDom === true ? { enabled: true } : this.config.shadowDom,
        );
      } else {
        console.warn('Shadow DOM is not supported in this environment. Falling back to classic UI rendering.');
      }
    }

    // Create UI instance managers for the UI variants
    // The instance managers map to the corresponding UI variants by their array index
    this.uiInstanceManagers = [];
    const uiVariantsWithoutCondition = [];
    for (const uiVariant of this.uiVariants) {
      if (uiVariant.condition == null) {
        // Collect variants without conditions for error checking
        uiVariantsWithoutCondition.push(uiVariant);
      }
      // Create the instance manager for a UI variant
      this.uiInstanceManagers.push(
        new InternalUIInstanceManager(
          player,
          uiVariant.ui,
          this.config,
          this.subtitleSettingsManager,
          this.uiWrapperElement,
          uiVariant.spatialNavigation,
        ),
      );
    }
    // Make sure that there is only one UI variant without a condition
    // It does not make sense to have multiple variants without condition, because only the first one in the list
    // (the one with the lowest index) will ever be selected.
    if (uiVariantsWithoutCondition.length > 1) {
      throw Error('Too many UIs without a condition: You cannot have more than one default UI');
    }
    // Make sure that the default UI variant, if defined, is at the end of the list (last index)
    // If it comes earlier, the variants with conditions that come afterwards will never be selected because the
    // default variant without a condition always evaluates to 'true'
    if (
      uiVariantsWithoutCondition.length > 0 &&
      uiVariantsWithoutCondition[0] !== this.uiVariants[this.uiVariants.length - 1]
    ) {
      throw Error('Invalid UI variant order: the default UI (without condition) must be at the end of the list');
    }

    let adStartedEvent: AdEvent = null; // keep the event stored here during ad playback

    let isSourceLoaded = player.getSource() != null;
    player.on(player.exports.PlayerEvent.SourceLoaded, () => {
      isSourceLoaded = true;
    });
    player.on(player.exports.PlayerEvent.SourceUnloaded, () => {
      isSourceLoaded = false;
    });

    // Dynamically select a UI variant that matches the current UI condition.
    const resolveUiVariant = (event: PlayerEventBase) => {
      // Make sure that the AdStarted event data is persisted through ad playback in case other events happen
      // in the meantime, e.g. player resize. We need to store this data because there is no other way to find out
      // ad details while an ad is playing (in v8.0 at least; from v8.1 there will be ads.getActiveAd()).
      // Existing event data signals that an ad is currently active (instead of ads.isLinearAdActive()).
      if (event != null) {
        switch (event.type) {
          // The ads UI is shown upon the first AdStarted event. Subsequent AdStarted events within an ad break
          // will not change the condition context and thus not lead to undesired UI variant resolving.
          // The ads UI is shown upon AdStarted instead of AdBreakStarted because there can be a loading delay
          // between these two events in the player, and the AdBreakStarted event does not carry any metadata to
          // initialize the ads UI, so it would be rendered in an uninitialized state for a certain amount of time.
          // TODO show ads UI upon AdBreakStarted and display loading overlay between AdBreakStarted and first AdStarted
          // TODO display loading overlay between AdFinished and next AdStarted
          case player.exports.PlayerEvent.AdStarted:
            adStartedEvent = event as AdEvent;
            break;
          // The ads UI is hidden only when the ad break is finished, i.e. not on AdFinished events. This way we keep
          // the ads UI variant active throughout an ad break, as reacting to AdFinished would lead to undesired UI
          // variant switching between two ads in an ad break, e.g. ads UI -> AdFinished -> content UI ->
          // AdStarted -> ads UI.
          case player.exports.PlayerEvent.AdBreakFinished:
            adStartedEvent = null;
            // When switching to a variant for the first time, a config.events.onUpdated event is fired to trigger a UI
            // update of the new variant, because most components subscribe to this event to update themselves. When
            // switching to the ads UI on the first AdStarted, all UI variants update themselves with the ad data, so
            // when switching back to the "normal" UI it will carry properties of the ad instead of the main content.
            // We thus fire this event here to force an UI update with the properties of the main content. This is
            // basically a hack because the config.events.onUpdated event is abused in many places and not just used
            // for config updates (e.g. adding a marker to the seekbar).
            // TODO introduce an event that is fired when the playback content is updated, a switch to/from ads
            this.config.events.onUpdated.dispatch(this);
            break;
          case player.exports.PlayerEvent.SourceLoaded:
            // No need to take care of SourceLoaded. As when the source changes, a SourceUnloaded event is received.
            // When the source gets loaded during ad playback, we don't want to change the UI.
            break;
          case player.exports.PlayerEvent.SourceUnloaded:
            // When the source gets unloaded during ad playback, there will be no Ad(Break)Finished event.
            // This also covers changing a source
            adStartedEvent = null;
            break;
        }
      }

      // Detect if an ad has started
      const isAd = adStartedEvent != null;
      let adRequiresUi = false;
      if (isAd) {
        const ad = adStartedEvent.ad;
        // for now only linear ads can request a UI
        if (ad.isLinear) {
          const linearAd = ad as LinearAd;
          adRequiresUi = (linearAd.uiConfig && linearAd.uiConfig.requestsUi) || false;
        }
      }

      if (adRequiresUi) {
        // we dispatch onUpdated event because if there are multiple adBreaks for same position
        // `Play` and `Playing` events will not be dispatched which will cause `PlaybackButton` state
        // to be out of sync
        this.config.events.onUpdated.dispatch(this);
      }

      this.resolveUiVariant(
        {
          isAd,
          adRequiresUi,
          isSourceLoaded,
        },
        context => {
          // If this is an ad UI, we need to relay the saved ON_AD_STARTED event data so ad components can configure
          // themselves for the current ad.
          if (context.isAd) {
            /* Relay the ON_AD_STARTED event to the ads UI
             *
             * Because the ads UI is initialized in the ON_AD_STARTED handler, i.e. when the ON_AD_STARTED event has
             * already been fired, components in the ads UI that listen for the ON_AD_STARTED event never receive it.
             * Since this can break functionality of components that rely on this event, we relay the event to the
             * ads UI components with the following call.
             */
            this.currentUi.getWrappedPlayer().fireEventInUI(this.player.exports.PlayerEvent.AdStarted, adStartedEvent);
          }
        },
      );
    };

    // Listen to the following events to trigger UI variant resolution
    if (this.config.autoUiVariantResolve) {
      this.managerPlayerWrapper.getPlayer().on(this.player.exports.PlayerEvent.SourceLoaded, resolveUiVariant);
      this.managerPlayerWrapper.getPlayer().on(this.player.exports.PlayerEvent.SourceUnloaded, resolveUiVariant);
      this.managerPlayerWrapper.getPlayer().on(this.player.exports.PlayerEvent.Play, resolveUiVariant);
      this.managerPlayerWrapper.getPlayer().on(this.player.exports.PlayerEvent.Paused, resolveUiVariant);
      this.managerPlayerWrapper.getPlayer().on(this.player.exports.PlayerEvent.Playing, resolveUiVariant);
      this.managerPlayerWrapper.getPlayer().on(this.player.exports.PlayerEvent.AdStarted, resolveUiVariant);
      this.managerPlayerWrapper.getPlayer().on(this.player.exports.PlayerEvent.AdBreakFinished, resolveUiVariant);
      this.managerPlayerWrapper.getPlayer().on(this.player.exports.PlayerEvent.PlayerResized, resolveUiVariant);
      this.managerPlayerWrapper.getPlayer().on(this.player.exports.PlayerEvent.ViewModeChanged, resolveUiVariant);
    }

    this.focusVisibilityTracker = new FocusVisibilityTracker('{{PREFIX}}', this.uiWrapperElement);

    // Initialize the UI
    resolveUiVariant(null);
  }

  /**
   * Exposes i18n.getLocalizer() function
   * @returns {I18nApi.getLocalizer()}
   */
  static localize<V extends CustomVocabulary<Record<string, string>>>(key: keyof V) {
    return i18n.getLocalizer(key);
  }

  /**
   * Provide configuration to support Custom UI languages
   * default language: 'en'
   */
  static setLocalizationConfig(localizationConfig: LocalizationConfig) {
    i18n.setConfig(localizationConfig);
  }

  getSubtitleSettingsManager() {
    return this.subtitleSettingsManager;
  }

  getConfig(): UIConfig {
    return this.config;
  }

  /**
   * Returns the list of UI variants as passed into the constructor of {@link UIManager}.
   * @returns {UIVariant[]} the list of available UI variants
   */
  getUiVariants(): UIVariant[] {
    return this.uiVariants;
  }

  /**
   * Switches to a UI variant from the list returned by {@link getUiVariants}.
   * @param {UIVariant} uiVariant the UI variant to switch to
   * @param {() => void} onShow a callback that is executed just before the new UI variant is shown
   */
  switchToUiVariant(uiVariant: UIVariant, onShow?: () => void): void {
    const uiVariantIndex = this.uiVariants.indexOf(uiVariant);

    const previousUi = this.currentUi;
    const nextUi: InternalUIInstanceManager = this.uiInstanceManagers[uiVariantIndex];
    // Determine if the UI variant is changing
    // Only if the UI variant is changing, we need to do some stuff. Else we just leave everything as-is.
    if (nextUi === this.currentUi) {
      return;
      // console.log('switched from ', this.currentUi ? this.currentUi.getUI() : 'none',
      //   ' to ', nextUi ? nextUi.getUI() : 'none');
    }

    // Hide the currently active UI variant
    if (this.currentUi) {
      this.currentUi.getUI().hide();
    }

    // Assign the new UI variant as current UI
    this.currentUi = nextUi;

    // When we switch to a different UI instance, there's some additional stuff to manage. If we do not switch
    // to an instance, we're done here.
    if (this.currentUi == null) {
      return;
    }
    // Add the UI to the DOM (and configure it) the first time it is selected
    if (!this.currentUi.isConfigured()) {
      this.addUi(this.currentUi);
      // ensure that the internal state is ready for the upcoming show call
      if (!this.currentUi.getUI().isHidden()) {
        this.currentUi.getUI().hide();
      }
    }
    if (onShow) {
      onShow();
    }
    this.currentUi.getUI().show();
    this.events.onActiveUiChanged.dispatch(this, { previousUi, currentUi: nextUi });
  }

  /**
   * Triggers a UI variant switch as triggered by events when automatic switching is enabled. It allows to overwrite
   * properties of the {@link UIConditionContext}.
   * @param {Partial<UIConditionContext>} context an optional set of properties that overwrite properties of the
   *   automatically determined context
   * @param {(context: UIConditionContext) => void} onShow a callback that is executed just before the new UI variant
   *   is shown (if a switch is happening)
   */
  resolveUiVariant(context: Partial<UIConditionContext> = {}, onShow?: (context: UIConditionContext) => void): void {
    // Determine the current context for which the UI variant will be resolved
    const defaultContext: UIConditionContext = {
      isAd: false,
      adRequiresUi: false,
      isFullscreen: this.player.getViewMode() === this.player.exports.ViewMode.Fullscreen,
      isMobile: BrowserUtils.isMobile,
      isTv: BrowserUtils.isTv,
      isPlaying: this.player.isPlaying(),
      isSourceLoaded: false,
      width: this.uiContainerElement.width(),
      documentWidth: document.body.clientWidth,
    };

    // Overwrite properties of the default context with passed in context properties
    const switchingContext = { ...defaultContext, ...context };

    // Fire the event and allow modification of the context before it is used to resolve the UI variant
    this.events.onUiVariantResolve.dispatch(this, switchingContext);

    let nextUiVariant: UIVariant = null;

    // Select new UI variant
    // If no variant condition is fulfilled, we switch to *no* UI
    for (const uiVariant of this.uiVariants) {
      const matchesCondition = uiVariant.condition == null || uiVariant.condition(switchingContext) === true;
      if (nextUiVariant == null && matchesCondition) {
        nextUiVariant = uiVariant;
      } else {
        // hide all UIs besides the one which should be active
        uiVariant.ui.hide();
      }
    }

    this.switchToUiVariant(nextUiVariant, () => {
      if (onShow) {
        onShow(switchingContext);
      }
    });
  }

  /**
   * The node the UI renders into. When Shadow DOM is enabled, this wraps the ShadowRoot; otherwise it wraps the
   * provided `UIConfig.container` (or the `player.container`).
   */
  get uiWrapperElement(): DOM {
    const shadowRoot = this.shadowDomManager.getShadowRoot();
    return shadowRoot != undefined ? new DOM(shadowRoot) : this.uiContainerElement;
  }

  private addUi(ui: InternalUIInstanceManager): void {
    const dom = ui.getUI().getDomElement();
    const player = ui.getWrappedPlayer();

    ui.configureControls();
    /* Append the UI DOM after configuration to avoid CSS transitions at initialization
     * Example: Components are hidden during configuration and these hides may trigger CSS transitions that are
     * undesirable at this time. */
    this.uiWrapperElement.append(dom);

    // When the UI is loaded after a source was loaded, we need to tell the components to initialize themselves
    if (player.getSource()) {
      this.config.events.onUpdated.dispatch(this);
    }

    // Fire onConfigured after UI DOM elements are successfully added. When fired immediately, the DOM elements
    // might not be fully configured and e.g. do not have a size.
    // https://swizec.com/blog/how-to-properly-wait-for-dom-elements-to-show-up-in-modern-browsers/swizec/6663
    if (window.requestAnimationFrame) {
      requestAnimationFrame(() => {
        ui.onConfigured.dispatch(ui.getUI());
      });
    } else {
      // IE9 fallback
      setTimeout(() => {
        ui.onConfigured.dispatch(ui.getUI());
      }, 0);
    }
  }

  private releaseUi(ui: InternalUIInstanceManager): void {
    ui.releaseControls();

    const uiContainer = ui.getUI();
    if (uiContainer.hasDomElement()) {
      uiContainer.getDomElement().remove();
    }

    ui.clearEventHandlers();
  }

  release(): void {
    this.config.adBreakTracker.release();

    for (const uiInstanceManager of this.uiInstanceManagers) {
      this.releaseUi(uiInstanceManager);
    }
    this.managerPlayerWrapper.clearEventHandlers();
    this.focusVisibilityTracker.release();
    this.shadowDomManager.release();
  }

  /**
   * Fires just before UI variants are about to be resolved and the UI variant is possibly switched. It is fired when
   * the switch is triggered from an automatic switch and when calling {@link resolveUiVariant}.
   * Can be used to modify the {@link UIConditionContext} before resolving is done.
   * @returns {EventDispatcher<UIManager, UIConditionContext>}
   */
  get onUiVariantResolve(): EventDispatcher<UIManager, UIConditionContext> {
    return this.events.onUiVariantResolve;
  }

  /**
   * Fires after the UIManager has switched to a different UI variant.
   * @returns {EventDispatcher<UIManager, ActiveUiChangedArgs>}
   */
  get onActiveUiChanged(): EventDispatcher<UIManager, ActiveUiChangedArgs> {
    return this.events.onActiveUiChanged;
  }

  /**
   * The current active {@link UIInstanceManager}.
   */
  get activeUi(): UIInstanceManager {
    return this.currentUi;
  }

  /**
   * Returns the list of all added markers in undefined order.
   */
  getTimelineMarkers(): TimelineMarker[] {
    return this.config.metadata.markers;
  }

  /**
   * Adds a marker to the timeline. Does not check for duplicates/overlaps at the `time`.
   */
  addTimelineMarker(timelineMarker: TimelineMarker): void {
    this.config.metadata.markers.push(timelineMarker);
    this.config.events.onUpdated.dispatch(this);
  }

  /**
   * Removes a marker from the timeline (by reference) and returns `true` if the marker has
   * been part of the timeline and successfully removed, or `false` if the marker could not
   * be found and thus not removed.
   */
  removeTimelineMarker(timelineMarker: TimelineMarker): boolean {
    if (ArrayUtils.remove(this.config.metadata.markers, timelineMarker) === timelineMarker) {
      this.config.events.onUpdated.dispatch(this);
      return true;
    }

    return false;
  }
}

export interface SeekPreviewArgs extends NoArgs {
  /**
   * The timeline position in percent where the event originates from.
   */
  position: number;
  /**
   * The timeline marker associated with the current position, if existing.
   */
  marker?: SeekBarMarker;
}

/**
 * Encapsulates functionality to manage a UI instance. Used by the {@link UIManager} to manage multiple UI instances.
 */
export class UIInstanceManager {
  private playerWrapper: PlayerWrapper;
  private ui: UIContainer;
  private config: InternalUIConfig;
  private subtitleSettingsManager: SubtitleSettingsManager;
  protected spatialNavigation?: SpatialNavigation;
  readonly uiWrapperElement: DOM;

  private events = {
    onConfigured: new EventDispatcher<UIContainer, NoArgs>(),
    onSeek: new EventDispatcher<SeekBar, NoArgs>(),
    onSeekPreview: new EventDispatcher<SeekBar, SeekPreviewArgs>(),
    onSeeked: new EventDispatcher<SeekBar, NoArgs>(),
    onComponentShow: new EventDispatcher<Component<ComponentConfig>, NoArgs>(),
    onComponentHide: new EventDispatcher<Component<ComponentConfig>, NoArgs>(),
    onComponentViewModeChanged: new EventDispatcher<Component<ComponentConfig>, ViewModeChangedEventArgs>(),
    onControlsShow: new EventDispatcher<UIContainer, NoArgs>(),
    onPreviewControlsHide: new EventDispatcher<UIContainer, CancelEventArgs>(),
    onControlsHide: new EventDispatcher<UIContainer, NoArgs>(),
    onRelease: new EventDispatcher<UIContainer, NoArgs>(),
    onBufferingShow: new EventDispatcher<BufferingOverlay, NoArgs>(),
    onBufferingHide: new EventDispatcher<BufferingOverlay, NoArgs>(),
  };

  constructor(
    player: PlayerAPI,
    ui: UIContainer,
    config: InternalUIConfig,
    subtitleSettingsManager: SubtitleSettingsManager,
    uiWrapperElement: DOM,
    spatialNavigation?: SpatialNavigation,
  ) {
    this.playerWrapper = new PlayerWrapper(player);
    this.ui = ui;
    this.config = config;
    this.subtitleSettingsManager = subtitleSettingsManager;
    this.uiWrapperElement = uiWrapperElement;
    this.spatialNavigation = spatialNavigation;
  }

  getSubtitleSettingsManager() {
    return this.subtitleSettingsManager;
  }

  getConfig(): InternalUIConfig {
    return this.config;
  }

  getUI(): UIContainer {
    return this.ui;
  }

  getPlayer(): PlayerAPI {
    return this.playerWrapper.getPlayer();
  }

  /**
   * Fires when the UI is fully configured and added to the DOM.
   * @returns {EventDispatcher}
   */
  get onConfigured(): EventDispatcher<UIContainer, NoArgs> {
    return this.events.onConfigured;
  }

  /**
   * Fires when a seek starts.
   * @returns {EventDispatcher}
   */
  get onSeek(): EventDispatcher<SeekBar, NoArgs> {
    return this.events.onSeek;
  }

  /**
   * Fires when the seek timeline is scrubbed.
   * @returns {EventDispatcher}
   */
  get onSeekPreview(): EventDispatcher<SeekBar, SeekPreviewArgs> {
    return this.events.onSeekPreview;
  }

  /**
   * Fires when a seek is finished.
   * @returns {EventDispatcher}
   */
  get onSeeked(): EventDispatcher<SeekBar, NoArgs> {
    return this.events.onSeeked;
  }

  /**
   * Fires when a component is showing.
   * @returns {EventDispatcher}
   */
  get onComponentShow(): EventDispatcher<Component<ComponentConfig>, NoArgs> {
    return this.events.onComponentShow;
  }

  /**
   * Fires when a component is hiding.
   * @returns {EventDispatcher}
   */
  get onComponentHide(): EventDispatcher<Component<ComponentConfig>, NoArgs> {
    return this.events.onComponentHide;
  }

  /**
   * Fires when the UI controls are showing.
   * @returns {EventDispatcher}
   */
  get onControlsShow(): EventDispatcher<UIContainer, NoArgs> {
    return this.events.onControlsShow;
  }

  /**
   * Fires before the UI controls are hiding to check if they are allowed to hide.
   * @returns {EventDispatcher}
   */
  get onPreviewControlsHide(): EventDispatcher<UIContainer, CancelEventArgs> {
    return this.events.onPreviewControlsHide;
  }

  /**
   * Fires when the UI controls are hiding.
   * @returns {EventDispatcher}
   */
  get onControlsHide(): EventDispatcher<UIContainer, NoArgs> {
    return this.events.onControlsHide;
  }

  /**
   * Fires when the BufferingOverlay shows.
   * @returns {EventDispatcher}
   */
  get onBufferingShow(): EventDispatcher<BufferingOverlay, NoArgs> {
    return this.events.onBufferingShow;
  }

  /**
   * Fires when the BufferingOverlay hides.
   * @returns {EventDispatcher}
   */
  get onBufferingHide(): EventDispatcher<BufferingOverlay, NoArgs> {
    return this.events.onBufferingHide;
  }

  /**
   * Fires when the UI controls are released.
   * @returns {EventDispatcher}
   */
  get onRelease(): EventDispatcher<UIContainer, NoArgs> {
    return this.events.onRelease;
  }

  get onComponentViewModeChanged(): EventDispatcher<Component<ComponentConfig>, ViewModeChangedEventArgs> {
    return this.events.onComponentViewModeChanged;
  }

  protected clearEventHandlers(): void {
    this.playerWrapper.clearEventHandlers();

    const events = <any>this.events; // avoid TS7017
    for (const event in events) {
      const dispatcher = <EventDispatcher<Object, Object>>events[event];
      dispatcher.unsubscribeAll();
    }
  }
}

/**
 * Extends the {@link UIInstanceManager} for internal use in the {@link UIManager} and provides access to functionality
 * that components receiving a reference to the {@link UIInstanceManager} should not have access to.
 */
class InternalUIInstanceManager extends UIInstanceManager {
  private configured: boolean;
  private released: boolean;

  getWrappedPlayer(): WrappedPlayer {
    // TODO find a non-hacky way to provide the WrappedPlayer to the UIManager without exporting it
    // getPlayer() actually returns the WrappedPlayer but its return type is set to Player so the WrappedPlayer does
    // not need to be exported
    return <WrappedPlayer>this.getPlayer();
  }

  configureControls(): void {
    this.configureControlsTree(this.getUI());
    this.configured = true;
  }

  isConfigured(): boolean {
    return this.configured;
  }

  private configureControlsTree(component: Component<ComponentConfig>) {
    const configuredComponents: Component<ComponentConfig>[] = [];

    UIUtils.traverseTree(component, component => {
      // First, check if we have already configured a component, and throw an error if we did. Multiple configuration
      // of the same component leads to unexpected UI behavior. Also, a component that is in the UI tree multiple
      // times hints at a wrong UI structure.
      // We could just skip configuration in such a case and not throw an exception, but enforcing a clean UI tree
      // seems like the better choice.
      for (const configuredComponent of configuredComponents) {
        if (configuredComponent === component) {
          // Write the component to the console to simplify identification of the culprit
          // (e.g. by inspecting the config)
          if (console) {
            console.error('Circular reference in UI tree', component);
          }

          // Additionally throw an error, because this case must not happen and leads to unexpected UI behavior.
          throw Error('Circular reference in UI tree: ' + component.constructor.name);
        }
      }

      component.initialize();
      component.configure(this.getPlayer(), this);
      configuredComponents.push(component);
    });
  }

  releaseControls(): void {
    // Do not call release methods if the components have never been configured; this can result in exceptions
    if (this.configured) {
      this.onRelease.dispatch(this.getUI());
      this.releaseControlsTree(this.getUI());
      this.configured = false;
    }
    this.spatialNavigation?.release();
    this.released = true;
  }

  isReleased(): boolean {
    return this.released;
  }

  private releaseControlsTree(component: Component<ComponentConfig>) {
    component.release();

    if (component instanceof Container) {
      for (const childComponent of component.getComponents()) {
        this.releaseControlsTree(childComponent);
      }
    }
  }

  clearEventHandlers(): void {
    super.clearEventHandlers();
  }
}

/**
 * Extended interface of the {@link Player} for use in the UI.
 */
export interface WrappedPlayer extends PlayerAPI {
  /**
   * Fires an event on the player that targets all handlers in the UI but never enters the real player.
   * @param event the event to fire
   * @param data data to send with the event
   */
  fireEventInUI(event: PlayerEvent, data: {}): void;
}

/**
 * Wraps the player to track event handlers and provide a simple method to remove all registered event
 * handlers from the player.
 *
 * @category Utils
 */
export class PlayerWrapper {
  private player: PlayerAPI;
  private wrapper: WrappedPlayer;

  private eventHandlers: { [eventType: string]: PlayerEventCallback<PlayerEvent>[] } = {};

  constructor(player: PlayerAPI) {
    this.player = player;

    // Collect all members of the player (public API methods and properties of the player)
    const objectProtoPropertyNames = Object.getOwnPropertyNames(Object.getPrototypeOf({}));
    const namesToIgnore = ['constructor', ...objectProtoPropertyNames];
    const members = getAllPropertyNames(player).filter(name => namesToIgnore.indexOf(name) === -1);
    // Split the members into methods and properties
    const methods = <any[]>[];
    const properties = <any[]>[];

    for (const member of members) {
      if (typeof (<any>player)[member] === 'function') {
        methods.push(member);
      } else {
        properties.push(member);
      }
    }

    // Create wrapper object
    const wrapper = <any>{};

    // Add function wrappers for all API methods that do nothing but calling the base method on the player
    for (const method of methods) {
      wrapper[method] = function () {
        // console.log('called ' + member); // track method calls on the player
        // eslint-disable-next-line @typescript-eslint/no-unsafe-call
        return (<any>player)[method].apply(player, arguments);
      };
    }

    // Add all public properties of the player to the wrapper
    for (const property of properties) {
      // Get an eventually existing property descriptor to differentiate between plain properties and properties with
      // getters/setters.
      const propertyDescriptor = ((target: PlayerAPI) => {
        while (target) {
          const propertyDescriptor = Object.getOwnPropertyDescriptor(target, property);
          if (propertyDescriptor) {
            return propertyDescriptor;
          }
          // Check if the PropertyDescriptor exists on a child prototype in case we have an inheritance of the player
          target = Object.getPrototypeOf(target);
        }
      })(player);

      // If the property has getters/setters, wrap them accordingly...
      if (propertyDescriptor && (propertyDescriptor.get || propertyDescriptor.set)) {
        Object.defineProperty(wrapper, property, {
          get: () => propertyDescriptor.get.call(player),
          set: (value: any) => propertyDescriptor.set.call(player, value),
        });
      }
      // ... else just transfer the property to the wrapper
      else {
        wrapper[property] = (<any>player)[property];
      }
    }

    // Explicitly add a wrapper method for 'on' that adds added event handlers to the event list
    wrapper.on = <T extends PlayerEvent>(eventType: T, callback: PlayerEventCallback<T>) => {
      player.on(eventType, callback);

      if (!this.eventHandlers[eventType]) {
        this.eventHandlers[eventType] = [];
      }

      this.eventHandlers[eventType].push(callback);

      return wrapper;
    };

    // Explicitly add a wrapper method for 'off' that removes removed event handlers from the event list
    wrapper.off = <T extends PlayerEvent>(eventType: T, callback: PlayerEventCallback<T>) => {
      player.off(eventType, callback);

      if (this.eventHandlers[eventType]) {
        ArrayUtils.remove(this.eventHandlers[eventType], callback);
      }

      return wrapper;
    };

    wrapper.fireEventInUI = (event: PlayerEvent, data: {}) => {
      if (this.eventHandlers[event]) {
        // check if there are handlers for this event registered
        // Extend the data object with default values to convert it to a {@link PlayerEventBase} object.
        const playerEventData = <PlayerEventBase>Object.assign(
          {},
          {
            timestamp: Date.now(),
            type: event,
            // Add a marker property so the UI can detect UI-internal player events
            uiSourced: true,
          },
          data,
        );

        // Execute the registered callbacks
        for (const callback of this.eventHandlers[event]) {
          callback(playerEventData);
        }
      }
    };

    this.wrapper = <WrappedPlayer>wrapper;
  }

  /**
   * Returns a wrapped player object that can be used on place of the normal player object.
   * @returns {WrappedPlayer} a wrapped player
   */
  getPlayer(): WrappedPlayer {
    return this.wrapper;
  }

  /**
   * Clears all registered event handlers from the player that were added through the wrapped player.
   */
  clearEventHandlers(): void {
    try {
      // Call the player API to check if the instance is still valid or already destroyed.
      // This can be any call throwing the PlayerAPINotAvailableError when the player instance is destroyed.
      this.player.getSource();
    } catch (error) {
      if (error instanceof this.player.exports.PlayerAPINotAvailableError) {
        // We have detected that the player instance is already destroyed, so we clear the event handlers to avoid
        // event handler unsubscription attempts (which would result in PlayerAPINotAvailableError errors).
        this.eventHandlers = {};
      }
    }

    for (const eventType in this.eventHandlers) {
      for (const callback of this.eventHandlers[eventType]) {
        this.player.off(eventType as PlayerEvent, callback);
      }
    }
  }
}

function getAllPropertyNames(target: Object): string[] {
  let names: string[] = [];

  while (target) {
    const newNames = Object.getOwnPropertyNames(target).filter(name => names.indexOf(name) === -1);
    names = names.concat(newNames);
    // go up prototype chain
    target = Object.getPrototypeOf(target);
  }

  return names;
}
