/**
 * @file vtt-segment-loader.js
 */
import SegmentLoader from './segment-loader';
import videojs from 'video.js';
import window from 'global/window';
import { removeCuesFromTrack, removeDuplicateCuesFromTrack } from './util/text-tracks';
import { initSegmentId } from './bin-utils';
import { uint8ToUtf8 } from './util/string';
import { REQUEST_ERRORS } from './media-segment-request';
import { ONE_SECOND_IN_TS } from 'mux.js/lib/utils/clock';
import {createTimeRanges} from './util/vjs-compat';

const VTT_LINE_TERMINATORS =
  new Uint8Array('\n\n'.split('').map(char => char.charCodeAt(0)));

class NoVttJsError extends Error {
  constructor() {
    super('Trying to parse received VTT cues, but there is no WebVTT. Make sure vtt.js is loaded.');
  }
}

/**
 * An object that manages segment loading and appending.
 *
 * @class VTTSegmentLoader
 * @param {Object} options required and optional options
 * @extends videojs.EventTarget
 */
export default class VTTSegmentLoader extends SegmentLoader {
  constructor(settings, options = {}) {
    super(settings, options);

    // SegmentLoader requires a MediaSource be specified or it will throw an error;
    // however, VTTSegmentLoader has no need of a media source, so delete the reference
    this.mediaSource_ = null;

    this.subtitlesTrack_ = null;

    this.featuresNativeTextTracks_ = settings.featuresNativeTextTracks;

    this.loadVttJs = settings.loadVttJs;

    // The VTT segment will have its own time mappings. Saving VTT segment timing info in
    // the sync controller leads to improper behavior.
    this.shouldSaveSegmentTimingInfo_ = false;
  }

  /**
   * Indicates which time ranges are buffered
   *
   * @return {TimeRange}
   *         TimeRange object representing the current buffered ranges
   */
  buffered_() {
    if (!this.subtitlesTrack_ || !this.subtitlesTrack_.cues || !this.subtitlesTrack_.cues.length) {
      return createTimeRanges();
    }

    const cues = this.subtitlesTrack_.cues;
    const start = cues[0].startTime;
    const end = cues[cues.length - 1].startTime;

    return createTimeRanges([[start, end]]);
  }

  /**
   * Gets and sets init segment for the provided map
   *
   * @param {Object} map
   *        The map object representing the init segment to get or set
   * @param {boolean=} set
   *        If true, the init segment for the provided map should be saved
   * @return {Object}
   *         map object for desired init segment
   */
  initSegmentForMap(map, set = false) {
    if (!map) {
      return null;
    }

    const id = initSegmentId(map);
    let storedMap = this.initSegments_[id];

    if (set && !storedMap && map.bytes) {
      // append WebVTT line terminators to the media initialization segment if it exists
      // to follow the WebVTT spec (https://w3c.github.io/webvtt/#file-structure) that
      // requires two or more WebVTT line terminators between the WebVTT header and the
      // rest of the file
      const combinedByteLength = VTT_LINE_TERMINATORS.byteLength + map.bytes.byteLength;
      const combinedSegment = new Uint8Array(combinedByteLength);

      combinedSegment.set(map.bytes);
      combinedSegment.set(VTT_LINE_TERMINATORS, map.bytes.byteLength);

      this.initSegments_[id] = storedMap = {
        resolvedUri: map.resolvedUri,
        byterange: map.byterange,
        bytes: combinedSegment
      };
    }

    return storedMap || map;
  }

  /**
   * Returns true if all configuration required for loading is present, otherwise false.
   *
   * @return {boolean} True if the all configuration is ready for loading
   * @private
   */
  couldBeginLoading_() {
    return this.playlist_ &&
           this.subtitlesTrack_ &&
           !this.paused();
  }

  /**
   * Once all the starting parameters have been specified, begin
   * operation. This method should only be invoked from the INIT
   * state.
   *
   * @private
   */
  init_() {
    this.state = 'READY';
    this.resetEverything();
    return this.monitorBuffer_();
  }

  /**
   * Set a subtitle track on the segment loader to add subtitles to
   *
   * @param {TextTrack=} track
   *        The text track to add loaded subtitles to
   * @return {TextTrack}
   *        Returns the subtitles track
   */
  track(track) {
    if (typeof track === 'undefined') {
      return this.subtitlesTrack_;
    }

    this.subtitlesTrack_ = track;

    // if we were unpaused but waiting for a sourceUpdater, start
    // buffering now
    if (this.state === 'INIT' && this.couldBeginLoading_()) {
      this.init_();
    }

    return this.subtitlesTrack_;
  }

  /**
   * Remove any data in the source buffer between start and end times
   *
   * @param {number} start - the start time of the region to remove from the buffer
   * @param {number} end - the end time of the region to remove from the buffer
   */
  remove(start, end) {
    removeCuesFromTrack(start, end, this.subtitlesTrack_);
  }

  /**
   * fill the buffer with segements unless the sourceBuffers are
   * currently updating
   *
   * Note: this function should only ever be called by monitorBuffer_
   * and never directly
   *
   * @private
   */
  fillBuffer_() {
    // see if we need to begin loading immediately
    const segmentInfo = this.chooseNextRequest_();

    if (!segmentInfo) {
      return;
    }

    if (this.syncController_.timestampOffsetForTimeline(segmentInfo.timeline) === null) {
      // We don't have the timestamp offset that we need to sync subtitles.
      // Rerun on a timestamp offset or user interaction.
      const checkTimestampOffset = () => {
        this.state = 'READY';
        if (!this.paused()) {
          // if not paused, queue a buffer check as soon as possible
          this.monitorBuffer_();
        }
      };

      this.syncController_.one('timestampoffset', checkTimestampOffset);
      this.state = 'WAITING_ON_TIMELINE';
      return;
    }

    this.loadSegment_(segmentInfo);
  }

  // never set a timestamp offset for vtt segments.
  timestampOffsetForSegment_() {
    return null;
  }

  chooseNextRequest_() {
    return this.skipEmptySegments_(super.chooseNextRequest_());
  }

  /**
   * Prevents the segment loader from requesting segments we know contain no subtitles
   * by walking forward until we find the next segment that we don't know whether it is
   * empty or not.
   *
   * @param {Object} segmentInfo
   *        a segment info object that describes the current segment
   * @return {Object}
   *         a segment info object that describes the current segment
   */
  skipEmptySegments_(segmentInfo) {
    while (segmentInfo && segmentInfo.segment.empty) {
      // stop at the last possible segmentInfo
      if (segmentInfo.mediaIndex + 1 >= segmentInfo.playlist.segments.length) {
        segmentInfo = null;
        break;
      }
      segmentInfo = this.generateSegmentInfo_({
        playlist: segmentInfo.playlist,
        mediaIndex: segmentInfo.mediaIndex + 1,
        startOfSegment: segmentInfo.startOfSegment + segmentInfo.duration,
        isSyncRequest: segmentInfo.isSyncRequest
      });
    }
    return segmentInfo;
  }

  stopForError(error) {
    this.error(error);
    this.state = 'READY';
    this.pause();
    this.trigger('error');
  }

  /**
   * append a decrypted segement to the SourceBuffer through a SourceUpdater
   *
   * @private
   */
  segmentRequestFinished_(error, simpleSegment, result) {
    if (!this.subtitlesTrack_) {
      this.state = 'READY';
      return;
    }

    this.saveTransferStats_(simpleSegment.stats);

    // the request was aborted
    if (!this.pendingSegment_) {
      this.state = 'READY';
      this.mediaRequestsAborted += 1;
      return;
    }

    if (error) {
      if (error.code === REQUEST_ERRORS.TIMEOUT) {
        this.handleTimeout_();
      }

      if (error.code === REQUEST_ERRORS.ABORTED) {
        this.mediaRequestsAborted += 1;
      } else {
        this.mediaRequestsErrored += 1;
      }

      this.stopForError(error);
      return;
    }

    const segmentInfo = this.pendingSegment_;
    const isMp4WebVttSegmentWithCues = result.mp4VttCues && result.mp4VttCues.length;

    if (isMp4WebVttSegmentWithCues) {
      segmentInfo.mp4VttCues = result.mp4VttCues;
    }

    // although the VTT segment loader bandwidth isn't really used, it's good to
    // maintain functionality between segment loaders
    this.saveBandwidthRelatedStats_(segmentInfo.duration, simpleSegment.stats);

    // if this request included a segment key, save that data in the cache
    if (simpleSegment.key) {
      this.segmentKey(simpleSegment.key, true);
    }

    this.state = 'APPENDING';

    // used for tests
    this.trigger('appending');

    const segment = segmentInfo.segment;

    if (segment.map) {
      segment.map.bytes = simpleSegment.map.bytes;
    }
    segmentInfo.bytes = simpleSegment.bytes;

    // Make sure that vttjs has loaded, otherwise, load it and wait till it finished loading
    if (typeof window.WebVTT !== 'function' && typeof this.loadVttJs === 'function') {
      this.state = 'WAITING_ON_VTTJS';
      // should be fine to call multiple times
      // script will be loaded once but multiple listeners will be added to the queue, which is expected.
      this.loadVttJs()
        .then(
          () => this.segmentRequestFinished_(error, simpleSegment, result),
          () => this.stopForError({
            message: 'Error loading vtt.js'
          })
        );
      return;
    }

    segment.requested = true;

    try {
      this.parseVTTCues_(segmentInfo);
    } catch (e) {
      this.stopForError({
        message: e.message,
        metadata: {
          errorType: videojs.Error.StreamingVttParserError,
          error: e
        }
      });
      return;
    }

    if (!isMp4WebVttSegmentWithCues) {
      this.updateTimeMapping_(
        segmentInfo,
        this.syncController_.timelines[segmentInfo.timeline],
        this.playlist_
      );
    }

    if (segmentInfo.cues.length) {
      segmentInfo.timingInfo = {
        start: segmentInfo.cues[0].startTime,
        end: segmentInfo.cues[segmentInfo.cues.length - 1].endTime
      };
    } else {
      segmentInfo.timingInfo = {
        start: segmentInfo.startOfSegment,
        end: segmentInfo.startOfSegment + segmentInfo.duration
      };
    }

    if (segmentInfo.isSyncRequest) {
      this.trigger('syncinfoupdate');
      this.pendingSegment_ = null;
      this.state = 'READY';
      return;
    }

    segmentInfo.byteLength = segmentInfo.bytes.byteLength;

    this.mediaSecondsLoaded += segment.duration;

    // Create VTTCue instances for each cue in the new segment and add them to
    // the subtitle track
    segmentInfo.cues.forEach((cue) => {
      this.subtitlesTrack_.addCue(this.featuresNativeTextTracks_ ?
        new window.VTTCue(cue.startTime, cue.endTime, cue.text) :
        cue);
    });

    // Remove any duplicate cues from the subtitle track. The WebVTT spec allows
    // cues to have identical time-intervals, but if the text is also identical
    // we can safely assume it is a duplicate that can be removed (ex. when a cue
    // "overlaps" VTT segments)
    removeDuplicateCuesFromTrack(this.subtitlesTrack_);

    this.handleAppendsDone_();
  }

  handleData_(simpleSegment, result) {
    const isVttType = simpleSegment && simpleSegment.type === 'vtt';
    const isTextResult = result && result.type === 'text';
    const isFmp4VttSegment = isVttType && isTextResult;
    // handle segment data for fmp4 encapsulated webvtt

    if (isFmp4VttSegment) {
      super.handleData_(simpleSegment, result);
    }
  }

  updateTimingInfoEnd_() {
    // noop
  }

  /**
   * Utility function for converting mp4 webvtt cue objects into VTTCues.
   *
   * @param {Object} segmentInfo with mp4 webvtt cues for parsing into VTTCue objecs
   */
  parseMp4VttCues_(segmentInfo) {
    const timestampOffset = this.sourceUpdater_.videoTimestampOffset() === null ?
      this.sourceUpdater_.audioTimestampOffset() :
      this.sourceUpdater_.videoTimestampOffset();

    segmentInfo.mp4VttCues.forEach((cue) => {
      const start = cue.start + timestampOffset;
      const end = cue.end + timestampOffset;
      const vttCue = new window.VTTCue(start, end, cue.cueText);

      if (cue.settings) {
        cue.settings.split(' ').forEach((cueSetting) => {
          const keyValString = cueSetting.split(':');
          const key = keyValString[0];
          const value = keyValString[1];

          vttCue[key] = isNaN(value) ? value : Number(value);
        });
      }
      segmentInfo.cues.push(vttCue);
    });
  }

  /**
   * Uses the WebVTT parser to parse the segment response
   *
   * @throws NoVttJsError
   *
   * @param {Object} segmentInfo
   *        a segment info object that describes the current segment
   * @private
   */
  parseVTTCues_(segmentInfo) {
    let decoder;
    let decodeBytesToString = false;

    if (typeof window.WebVTT !== 'function') {
      // caller is responsible for exception handling.
      throw new NoVttJsError();
    }

    segmentInfo.cues = [];
    segmentInfo.timestampmap = { MPEGTS: 0, LOCAL: 0 };

    if (segmentInfo.mp4VttCues) {
      this.parseMp4VttCues_(segmentInfo);
      return;
    }

    if (typeof window.TextDecoder === 'function') {
      decoder = new window.TextDecoder('utf8');
    } else {
      decoder = window.WebVTT.StringDecoder();
      decodeBytesToString = true;
    }

    const parser = new window.WebVTT.Parser(
      window,
      window.vttjs,
      decoder
    );

    parser.oncue = segmentInfo.cues.push.bind(segmentInfo.cues);
    parser.ontimestampmap = (map) => {
      segmentInfo.timestampmap = map;
    };
    parser.onparsingerror = (error) => {
      videojs.log.warn('Error encountered when parsing cues: ' + error.message);
    };

    if (segmentInfo.segment.map) {
      let mapData = segmentInfo.segment.map.bytes;

      if (decodeBytesToString) {
        mapData = uint8ToUtf8(mapData);
      }

      parser.parse(mapData);
    }

    let segmentData = segmentInfo.bytes;

    if (decodeBytesToString) {
      segmentData = uint8ToUtf8(segmentData);
    }

    parser.parse(segmentData);
    parser.flush();
  }

  /**
   * Updates the start and end times of any cues parsed by the WebVTT parser using
   * the information parsed from the X-TIMESTAMP-MAP header and a TS to media time mapping
   * from the SyncController
   *
   * @param {Object} segmentInfo
   *        a segment info object that describes the current segment
   * @param {Object} mappingObj
   *        object containing a mapping from TS to media time
   * @param {Object} playlist
   *        the playlist object containing the segment
   * @private
   */
  updateTimeMapping_(segmentInfo, mappingObj, playlist) {
    const segment = segmentInfo.segment;

    if (!mappingObj) {
      // If the sync controller does not have a mapping of TS to Media Time for the
      // timeline, then we don't have enough information to update the cue
      // start/end times
      return;
    }

    if (!segmentInfo.cues.length) {
      // If there are no cues, we also do not have enough information to figure out
      // segment timing. Mark that the segment contains no cues so we don't re-request
      // an empty segment.
      segment.empty = true;
      return;
    }

    const { MPEGTS, LOCAL } = segmentInfo.timestampmap;

    /**
     * From the spec:
     * The MPEGTS media timestamp MUST use a 90KHz timescale,
     * even when non-WebVTT Media Segments use a different timescale.
     */
    const mpegTsInSeconds = MPEGTS / ONE_SECOND_IN_TS;

    const diff = mpegTsInSeconds - LOCAL + mappingObj.mapping;

    segmentInfo.cues.forEach((cue) => {
      const duration = cue.endTime - cue.startTime;
      const startTime = this.handleRollover_(cue.startTime + diff, mappingObj.time);

      cue.startTime = Math.max(startTime, 0);
      cue.endTime = Math.max(startTime + duration, 0);
    });

    if (!playlist.syncInfo) {
      const firstStart = segmentInfo.cues[0].startTime;
      const lastStart = segmentInfo.cues[segmentInfo.cues.length - 1].startTime;

      playlist.syncInfo = {
        mediaSequence: playlist.mediaSequence + segmentInfo.mediaIndex,
        time: Math.min(firstStart, lastStart - segment.duration)
      };
    }
  }

  /**
   * MPEG-TS PES timestamps are limited to 2^33.
   * Once they reach 2^33, they roll over to 0.
   * mux.js handles PES timestamp rollover for the following scenarios:
   * [forward rollover(right)] ->
   *    PES timestamps monotonically increase, and once they reach 2^33, they roll over to 0
   * [backward rollover(left)] -->
   *    we seek back to position before rollover.
   *
   * According to the HLS SPEC:
   * When synchronizing WebVTT with PES timestamps, clients SHOULD account
   * for cases where the 33-bit PES timestamps have wrapped and the WebVTT
   * cue times have not.  When the PES timestamp wraps, the WebVTT Segment
   * SHOULD have a X-TIMESTAMP-MAP header that maps the current WebVTT
   * time to the new (low valued) PES timestamp.
   *
   * So we want to handle rollover here and align VTT Cue start/end time to the player's time.
   */
  handleRollover_(value, reference) {
    if (reference === null) {
      return value;
    }

    let valueIn90khz = value * ONE_SECOND_IN_TS;
    const referenceIn90khz = reference * ONE_SECOND_IN_TS;

    let offset;

    if (referenceIn90khz < valueIn90khz) {
      // - 2^33
      offset = -8589934592;
    } else {
      // + 2^33
      offset = 8589934592;
    }

    // distance(value - reference) > 2^32
    while (Math.abs(valueIn90khz - referenceIn90khz) > 4294967296) {
      valueIn90khz += offset;
    }

    return valueIn90khz / ONE_SECOND_IN_TS;
  }
}
