// ==========================================================================
// Plyr Captions
// TODO: Create as class
// ==========================================================================

import controls from './controls';
import support from './support';
import { dedupe } from './utils/arrays';
import browser from './utils/browser';
import {
  createElement,
  emptyElement,
  getAttributesFromSelector,
  insertAfter,
  removeElement,
  toggleClass,
} from './utils/elements';
import { on, triggerEvent } from './utils/events';
import fetch from './utils/fetch';
import i18n from './utils/i18n';
import is from './utils/is';
import { getHTML } from './utils/strings';
import { parseUrl } from './utils/urls';

const captions = {
  // Setup captions
  setup() {
    // Requires UI support
    if (!this.supported.ui) {
      return;
    }

    // Only Vimeo and HTML5 video supported at this point
    if (!this.isVideo || this.isYouTube || (this.isHTML5 && !support.textTracks)) {
      // Clear menu and hide
      if (
        is.array(this.config.controls) &&
        this.config.controls.includes('settings') &&
        this.config.settings.includes('captions')
      ) {
        controls.setCaptionsMenu.call(this);
      }

      return;
    }

    // Inject the container
    if (!is.element(this.elements.captions)) {
      this.elements.captions = createElement('div', getAttributesFromSelector(this.config.selectors.captions));

      insertAfter(this.elements.captions, this.elements.wrapper);
    }

    // Fix IE captions if CORS is used
    // Fetch captions and inject as blobs instead (data URIs not supported!)
    if (browser.isIE && window.URL) {
      const elements = this.media.querySelectorAll('track');

      Array.from(elements).forEach(track => {
        const src = track.getAttribute('src');
        const url = parseUrl(src);

        if (
          url !== null &&
          url.hostname !== window.location.href.hostname &&
          ['http:', 'https:'].includes(url.protocol)
        ) {
          fetch(src, 'blob')
            .then(blob => {
              track.setAttribute('src', window.URL.createObjectURL(blob));
            })
            .catch(() => {
              removeElement(track);
            });
        }
      });
    }

    // Get and set initial data
    // The "preferred" options are not realized unless / until the wanted language has a match
    // * languages: Array of user's browser languages.
    // * language:  The language preferred by user settings or config
    // * active:    The state preferred by user settings or config
    // * toggled:   The real captions state

    const browserLanguages = navigator.languages || [navigator.language || navigator.userLanguage || 'en'];
    const languages = dedupe(browserLanguages.map(language => language.split('-')[0]));
    let language = (this.storage.get('language') || this.config.captions.language || 'auto').toLowerCase();

    // Use first browser language when language is 'auto'
    if (language === 'auto') {
      [language] = languages;
    }

    let active = this.storage.get('captions');
    if (!is.boolean(active)) {
      ({ active } = this.config.captions);
    }

    Object.assign(this.captions, {
      toggled: false,
      active,
      language,
      languages,
    });

    // Watch changes to textTracks and update captions menu
    if (this.isHTML5) {
      const trackEvents = this.config.captions.update ? 'addtrack removetrack' : 'removetrack';
      on.call(this, this.media.textTracks, trackEvents, captions.update.bind(this));
    }

    // Update available languages in list next tick (the event must not be triggered before the listeners)
    setTimeout(captions.update.bind(this), 0);
  },

  // Update available language options in settings based on tracks
  update() {
    const tracks = captions.getTracks.call(this, true);
    // Get the wanted language
    const { active, language, meta, currentTrackNode } = this.captions;
    const languageExists = Boolean(tracks.find(track => track.language === language));

    // Handle tracks (add event listener and "pseudo"-default)
    if (this.isHTML5 && this.isVideo) {
      tracks
        .filter(track => !meta.get(track))
        .forEach(track => {
          this.debug.log('Track added', track);

          // Attempt to store if the original dom element was "default"
          meta.set(track, {
            default: track.mode === 'showing',
          });

          // Turn off native caption rendering to avoid double captions
          // Note: mode='hidden' forces a track to download. To ensure every track
          // isn't downloaded at once, only 'showing' tracks should be reassigned
          // eslint-disable-next-line no-param-reassign
          if (track.mode === 'showing') {
            // eslint-disable-next-line no-param-reassign
            track.mode = 'hidden';
          }

          // Add event listener for cue changes
          on.call(this, track, 'cuechange', () => captions.updateCues.call(this));
        });
    }

    // Update language first time it matches, or if the previous matching track was removed
    if ((languageExists && this.language !== language) || !tracks.includes(currentTrackNode)) {
      captions.setLanguage.call(this, language);
      captions.toggle.call(this, active && languageExists);
    }

    // Enable or disable captions based on track length
    toggleClass(this.elements.container, this.config.classNames.captions.enabled, !is.empty(tracks));

    // Update available languages in list
    if (
      is.array(this.config.controls) &&
      this.config.controls.includes('settings') &&
      this.config.settings.includes('captions')
    ) {
      controls.setCaptionsMenu.call(this);
    }
  },

  // Toggle captions display
  // Used internally for the toggleCaptions method, with the passive option forced to false
  toggle(input, passive = true) {
    // If there's no full support
    if (!this.supported.ui) {
      return;
    }

    const { toggled } = this.captions; // Current state
    const activeClass = this.config.classNames.captions.active;
    // Get the next state
    // If the method is called without parameter, toggle based on current value
    const active = is.nullOrUndefined(input) ? !toggled : input;

    // Update state and trigger event
    if (active !== toggled) {
      // When passive, don't override user preferences
      if (!passive) {
        this.captions.active = active;
        this.storage.set({ captions: active });
      }

      // Force language if the call isn't passive and there is no matching language to toggle to
      if (!this.language && active && !passive) {
        const tracks = captions.getTracks.call(this);
        const track = captions.findTrack.call(this, [this.captions.language, ...this.captions.languages], true);

        // Override user preferences to avoid switching languages if a matching track is added
        this.captions.language = track.language;

        // Set caption, but don't store in localStorage as user preference
        captions.set.call(this, tracks.indexOf(track));
        return;
      }

      // Toggle button if it's enabled
      if (this.elements.buttons.captions) {
        this.elements.buttons.captions.pressed = active;
      }

      // Add class hook
      toggleClass(this.elements.container, activeClass, active);

      this.captions.toggled = active;

      // Update settings menu
      controls.updateSetting.call(this, 'captions');

      // Trigger event (not used internally)
      triggerEvent.call(this, this.media, active ? 'captionsenabled' : 'captionsdisabled');
    }

    // Wait for the call stack to clear before setting mode='hidden'
    // on the active track - forcing the browser to download it
    setTimeout(() => {
      if (active && this.captions.toggled) {
        this.captions.currentTrackNode.mode = 'hidden';
      }
    });
  },

  // Set captions by track index
  // Used internally for the currentTrack setter with the passive option forced to false
  set(index, passive = true) {
    const tracks = captions.getTracks.call(this);

    // Disable captions if setting to -1
    if (index === -1) {
      captions.toggle.call(this, false, passive);
      return;
    }

    if (!is.number(index)) {
      this.debug.warn('Invalid caption argument', index);
      return;
    }

    if (!(index in tracks)) {
      this.debug.warn('Track not found', index);
      return;
    }

    if (this.captions.currentTrack !== index) {
      this.captions.currentTrack = index;
      const track = tracks[index];
      const { language } = track || {};

      // Store reference to node for invalidation on remove
      this.captions.currentTrackNode = track;

      // Update settings menu
      controls.updateSetting.call(this, 'captions');

      // When passive, don't override user preferences
      if (!passive) {
        this.captions.language = language;
        this.storage.set({ language });
      }

      // Handle Vimeo captions
      if (this.isVimeo) {
        this.embed.enableTextTrack(language);
      }

      // Trigger event
      triggerEvent.call(this, this.media, 'languagechange');
    }

    // Show captions
    captions.toggle.call(this, true, passive);

    if (this.isHTML5 && this.isVideo) {
      // If we change the active track while a cue is already displayed we need to update it
      captions.updateCues.call(this);
    }
  },

  // Set captions by language
  // Used internally for the language setter with the passive option forced to false
  setLanguage(input, passive = true) {
    if (!is.string(input)) {
      this.debug.warn('Invalid language argument', input);
      return;
    }
    // Normalize
    const language = input.toLowerCase();
    this.captions.language = language;

    // Set currentTrack
    const tracks = captions.getTracks.call(this);
    const track = captions.findTrack.call(this, [language]);
    captions.set.call(this, tracks.indexOf(track), passive);
  },

  // Get current valid caption tracks
  // If update is false it will also ignore tracks without metadata
  // This is used to "freeze" the language options when captions.update is false
  getTracks(update = false) {
    // Handle media or textTracks missing or null
    const tracks = Array.from((this.media || {}).textTracks || []);
    // For HTML5, use cache instead of current tracks when it exists (if captions.update is false)
    // Filter out removed tracks and tracks that aren't captions/subtitles (for example metadata)
    return tracks
      .filter(track => !this.isHTML5 || update || this.captions.meta.has(track))
      .filter(track => ['captions', 'subtitles'].includes(track.kind));
  },

  // Match tracks based on languages and get the first
  findTrack(languages, force = false) {
    const tracks = captions.getTracks.call(this);
    const sortIsDefault = track => Number((this.captions.meta.get(track) || {}).default);
    const sorted = Array.from(tracks).sort((a, b) => sortIsDefault(b) - sortIsDefault(a));
    let track;

    languages.every(language => {
      track = sorted.find(t => t.language === language);
      return !track; // Break iteration if there is a match
    });

    // If no match is found but is required, get first
    return track || (force ? sorted[0] : undefined);
  },

  // Get the current track
  getCurrentTrack() {
    return captions.getTracks.call(this)[this.currentTrack];
  },

  // Get UI label for track
  getLabel(track) {
    let currentTrack = track;

    if (!is.track(currentTrack) && support.textTracks && this.captions.toggled) {
      currentTrack = captions.getCurrentTrack.call(this);
    }

    if (is.track(currentTrack)) {
      if (!is.empty(currentTrack.label)) {
        return currentTrack.label;
      }

      if (!is.empty(currentTrack.language)) {
        return track.language.toUpperCase();
      }

      return i18n.get('enabled', this.config);
    }

    return i18n.get('disabled', this.config);
  },

  // Update captions using current track's active cues
  // Also optional array argument in case there isn't any track (ex: vimeo)
  updateCues(input) {
    // Requires UI
    if (!this.supported.ui) {
      return;
    }

    if (!is.element(this.elements.captions)) {
      this.debug.warn('No captions element to render to');
      return;
    }

    // Only accept array or empty input
    if (!is.nullOrUndefined(input) && !Array.isArray(input)) {
      this.debug.warn('updateCues: Invalid input', input);
      return;
    }

    let cues = input;

    // Get cues from track
    if (!cues) {
      const track = captions.getCurrentTrack.call(this);

      cues = Array.from((track || {}).activeCues || [])
        .map(cue => cue.getCueAsHTML())
        .map(getHTML);
    }

    // Set new caption text
    const content = cues.map(cueText => cueText.trim()).join('\n');
    const changed = content !== this.elements.captions.innerHTML;

    if (changed) {
      // Empty the container and create a new child element
      emptyElement(this.elements.captions);
      const caption = createElement('span', getAttributesFromSelector(this.config.selectors.caption));
      caption.innerHTML = content;
      this.elements.captions.appendChild(caption);

      // Trigger event
      triggerEvent.call(this, this.media, 'cuechange');
    }
  },
};

export default captions;
