/**
 * Copyright 2015 CANAL+ Group
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

import { NetworkError } from "../../../errors";
import log from "../../../log";
import type { IRepresentationIndex, ISegment } from "../../../manifest";
import type { IPlayerError } from "../../../public_types";
import assert from "../../../utils/assert";
import getMonotonicTimeStamp from "../../../utils/monotonic_timestamp";
import { checkDiscontinuity, getIndexSegmentEnd } from "../utils/index_helpers";
import type { IIndexSegment } from "./shared_smooth_segment_timeline";
import type SharedSmoothSegmentTimeline from "./shared_smooth_segment_timeline";
import { replaceSegmentSmoothTokens } from "./utils/tokens";

/**
 * @param {Number} start
 * @param {Number} up
 * @param {Number} duration
 * @returns {Number}
 */
function getSegmentNumber(start: number, up: number, duration: number): number {
  const diff = up - start;
  return diff > 0 ? Math.floor(diff / duration) : 0;
}

/**
 * Convert second-based start time and duration to the timescale of the
 * manifest's index.
 * @param {Object} index
 * @param {Number} start
 * @param {Number} duration
 * @returns {Object} - Object with two properties:
 *   - up {Number}: timescaled timestamp of the beginning time
 *   - to {Number}: timescaled timestamp of the end time (start time + duration)
 */
function normalizeRange(
  timescale: number | undefined,
  start: number,
  duration: number,
): { up: number; to: number } {
  const ts = timescale === undefined || timescale === 0 ? 1 : timescale;
  return { up: start * ts, to: (start + duration) * ts };
}

/**
 * Calculate the number of times a segment repeat based on the next segment.
 * @param {Object} segment
 * @param {Object} nextSegment
 * @returns {Number}
 */
function calculateRepeat(segment: IIndexSegment, nextSegment?: IIndexSegment): number {
  let repeatCount = segment.repeatCount;

  // A negative value of the @r attribute of the S element indicates
  // that the duration indicated in @d attribute repeats until the
  // start of the next S element, the end of the Period or until the
  // next MPD update.
  // TODO Also for SMOOTH????
  if (segment.duration !== undefined && repeatCount < 0) {
    const repeatEnd = nextSegment !== undefined ? nextSegment.start : Infinity;
    repeatCount = Math.ceil((repeatEnd - segment.start) / segment.duration) - 1;
  }

  return repeatCount;
}

/**
 * Supplementary options taken by a SmoothRepresentationIndex bringing the
 * context the segments are in.
 */
export interface ISmoothRepresentationIndexContextInformation {
  /**
   * If `true` the corresponding Smooth Manifest was announced as a live
   * content.
   * `false` otherwise.
   */
  isLive: boolean;
  /**
   * Generic tokenized (e.g. with placeholders for time information) URL for
   * every segments anounced here.
   */
  media: string;
  /**
   * Contains information allowing to generate the corresponding initialization
   * segment.
   */
  segmentPrivateInfos: ISmoothInitSegmentPrivateInfos;

  sharedSmoothTimeline: SharedSmoothSegmentTimeline;
}

/** Information allowing to generate a Smooth initialization segment. */
interface ISmoothInitSegmentPrivateInfos {
  bitsPerSample?: number | undefined;
  channels?: number | undefined;
  codecPrivateData?: string | undefined;
  packetSize?: number | undefined;
  samplingRate?: number | undefined;
  protection?: { keyId: Uint8Array } | undefined;
  height?: number | undefined;
  width?: number | undefined;
}

/**
 * RepresentationIndex implementation for Smooth Manifests.
 *
 * Allows to interact with the index to create new Segments.
 *
 * @class SmoothRepresentationIndex
 */
export default class SmoothRepresentationIndex implements IRepresentationIndex {
  /**
   * Information needed to generate an initialization segment.
   * Taken from the Manifest.
   */
  private _initSegmentInfos: {
    codecPrivateData?: string | undefined;
    bitsPerSample?: number | undefined;
    channels?: number | undefined;
    packetSize?: number | undefined;
    samplingRate?: number | undefined;
    timescale: number;
    height?: number | undefined;
    width?: number | undefined;
    protection?: { keyId: Uint8Array } | undefined;
  };

  /**
   * Value only calculated for live contents.
   *
   * Calculates the difference, in timescale, between the monotonically-raising
   * timestamp used by the RxPlayer and the time of the last segment known to
   * have been generated on the server-side.
   * Useful to know if a segment present in the timeline has actually been
   * generated on the server-side
   */
  private _scaledLiveGap: number | undefined;

  /**
   * Defines the end of the latest available segment when this index was known to
   * be valid, in the index's timescale.
   */
  private _initialScaledLastPosition: number | undefined;

  /**
   * If `true` the corresponding Smooth Manifest was announced as a live
   * content.
   * `false` otherwise.
   */
  private _isLive: boolean;

  /**
   * Contains information on the list of segments available in this
   * SmoothRepresentationIndex.
   */
  private _sharedSmoothTimeline: SharedSmoothSegmentTimeline;

  /**
   * Generic tokenized (e.g. with placeholders for time information) URL for
   * every segments anounced here.
   */
  private _media: string;

  /**
   * Creates a new `SmoothRepresentationIndex`.
   * @param {Object} index
   * @param {Object} options
   */
  constructor(options: ISmoothRepresentationIndexContextInformation) {
    const { isLive, segmentPrivateInfos, media, sharedSmoothTimeline } = options;
    this._sharedSmoothTimeline = sharedSmoothTimeline;

    this._initSegmentInfos = {
      bitsPerSample: segmentPrivateInfos.bitsPerSample,
      channels: segmentPrivateInfos.channels,
      codecPrivateData: segmentPrivateInfos.codecPrivateData,
      packetSize: segmentPrivateInfos.packetSize,
      samplingRate: segmentPrivateInfos.samplingRate,
      timescale: sharedSmoothTimeline.timescale,
      height: segmentPrivateInfos.height,
      width: segmentPrivateInfos.width,
      protection: segmentPrivateInfos.protection,
    };

    this._isLive = isLive;
    this._media = media;

    if (sharedSmoothTimeline.timeline.length !== 0 && isLive) {
      const { timeline, validityTime } = sharedSmoothTimeline;
      const lastItem = timeline[timeline.length - 1];
      const scaledEnd = getIndexSegmentEnd(lastItem, null);
      const scaledTimelineValidityTime =
        (validityTime / 1000) * sharedSmoothTimeline.timescale;
      this._scaledLiveGap = scaledTimelineValidityTime - scaledEnd;
    }
  }

  /**
   * Construct init Segment compatible with a Smooth Manifest.
   * @returns {Object}
   */
  getInitSegment(): ISegment {
    return {
      id: "init",
      isInit: true,
      privateInfos: { smoothInitSegment: this._initSegmentInfos },
      url: null,
      time: 0,
      end: 0,
      duration: 0,
      timescale: 1,
      complete: true,
    };
  }

  /**
   * Generate a list of Segments for a particular period of time.
   *
   * @param {Number} from
   * @param {Number} dur
   * @returns {Array.<Object>}
   */
  getSegments(from: number, dur: number): ISegment[] {
    this._refreshTimeline();
    const { timescale, timeline } = this._sharedSmoothTimeline;
    const { up, to } = normalizeRange(timescale, from, dur);
    const media = this._media;

    let currentNumber: number | undefined;
    const segments: ISegment[] = [];

    const timelineLength = timeline.length;

    const maxPosition =
      this._scaledLiveGap === undefined
        ? undefined
        : (getMonotonicTimeStamp() / 1000) * timescale - this._scaledLiveGap;

    for (let i = 0; i < timelineLength; i++) {
      const segmentRange = timeline[i];
      const { duration, start } = segmentRange;
      const repeat = calculateRepeat(segmentRange, timeline[i + 1]);
      let segmentNumberInCurrentRange = getSegmentNumber(start, up, duration);
      let segmentTime = start + segmentNumberInCurrentRange * duration;
      const timeToAddToCheckMaxPosition = duration;
      while (
        segmentTime < to &&
        segmentNumberInCurrentRange <= repeat &&
        (maxPosition === undefined ||
          segmentTime + timeToAddToCheckMaxPosition <= maxPosition)
      ) {
        const time = segmentTime;
        const number =
          currentNumber !== undefined
            ? currentNumber + segmentNumberInCurrentRange
            : undefined;
        const segment = {
          id: String(segmentTime),
          isInit: false,
          time: time / timescale,
          end: (time + duration) / timescale,
          duration: duration / timescale,
          timescale: 1 as const,
          number,
          url: replaceSegmentSmoothTokens(media, time),
          complete: true,
          privateInfos: { smoothMediaSegment: { time, duration } },
        };
        segments.push(segment);

        // update segment number and segment time for the next segment
        segmentNumberInCurrentRange++;
        segmentTime = start + segmentNumberInCurrentRange * duration;
      }

      if (segmentTime >= to) {
        // we reached ``to``, we're done
        return segments;
      }

      if (currentNumber !== undefined) {
        currentNumber += repeat + 1;
      }
    }

    return segments;
  }

  /**
   * Returns true if, based on the arguments, the index should be refreshed.
   * (If we should re-fetch the manifest)
   * @param {Number} up
   * @param {Number} to
   * @returns {Boolean}
   */
  shouldRefresh(up: number, to: number): boolean {
    this._refreshTimeline();
    if (!this._isLive) {
      return false;
    }
    const { timeline, timescale } = this._sharedSmoothTimeline;

    const lastSegmentInCurrentTimeline = timeline[timeline.length - 1];
    if (lastSegmentInCurrentTimeline === undefined) {
      return false;
    }

    const repeat = lastSegmentInCurrentTimeline.repeatCount;
    const endOfLastSegmentInCurrentTimeline =
      lastSegmentInCurrentTimeline.start +
      (repeat + 1) * lastSegmentInCurrentTimeline.duration;

    if (to * timescale < endOfLastSegmentInCurrentTimeline) {
      return false;
    }

    if (up * timescale >= endOfLastSegmentInCurrentTimeline) {
      return true;
    }

    // ----

    const startOfLastSegmentInCurrentTimeline =
      lastSegmentInCurrentTimeline.start + repeat * lastSegmentInCurrentTimeline.duration;

    return up * timescale > startOfLastSegmentInCurrentTimeline;
  }

  /**
   * Returns first position available in the index.
   * @returns {Number|null}
   */
  getFirstAvailablePosition(): number | null {
    this._refreshTimeline();
    const { timeline, timescale } = this._sharedSmoothTimeline;
    if (timeline.length === 0) {
      return null;
    }
    return timeline[0].start / timescale;
  }

  /**
   * Returns last position available in the index.
   * @returns {Number}
   */
  getLastAvailablePosition(): number | undefined {
    this._refreshTimeline();
    const { timeline, timescale } = this._sharedSmoothTimeline;

    if (this._scaledLiveGap === undefined) {
      const lastTimelineElement = timeline[timeline.length - 1];
      return getIndexSegmentEnd(lastTimelineElement, null) / timescale;
    }

    for (let i = timeline.length - 1; i >= 0; i--) {
      const timelineElt = timeline[i];
      const timescaledNow = (getMonotonicTimeStamp() / 1000) * timescale;
      const { start, duration, repeatCount } = timelineElt;
      for (let j = repeatCount; j >= 0; j--) {
        const end = start + duration * (j + 1);
        const positionToReach = end;
        if (positionToReach <= timescaledNow - this._scaledLiveGap) {
          return end / timescale;
        }
      }
    }
    return undefined;
  }

  /**
   * Returns the absolute end in seconds this RepresentationIndex can reach once
   * all segments are available.
   * @returns {number|null|undefined}
   */
  getEnd(): number | null | undefined {
    if (!this._isLive) {
      return this.getLastAvailablePosition();
    }
    return undefined;
  }

  /**
   * Returns:
   *   - `true` if in the given time interval, at least one new segment is
   *     expected to be available in the future.
   *   - `false` either if all segments in that time interval are already
   *     available for download or if none will ever be available for it.
   *   - `undefined` when it is not possible to tell.
   * @param {number} start
   * @param {number} end
   * @returns {boolean|undefined}
   */
  awaitSegmentBetween(start: number, end: number): boolean | undefined {
    assert(start <= end);
    if (this.isStillAwaitingFutureSegments()) {
      return false;
    }
    const lastAvailablePosition = this.getLastAvailablePosition();
    if (lastAvailablePosition !== undefined && end < lastAvailablePosition) {
      return false;
    }
    return end > (this.getFirstAvailablePosition() ?? 0) ? undefined : false;
  }

  /**
   * Checks if `timeSec` is in a discontinuity.
   * That is, if there's no segment available for the `timeSec` position.
   * @param {number} timeSec - The time to check if it's in a discontinuity, in
   * seconds.
   * @returns {number | null} - If `null`, no discontinuity is encountered at
   * `time`. If this is a number instead, there is one and that number is the
   * position for which a segment is available in seconds.
   */
  checkDiscontinuity(timeSec: number): number | null {
    this._refreshTimeline();
    return checkDiscontinuity(this._sharedSmoothTimeline, timeSec, undefined);
  }

  /**
   * Returns `true` if a Segment returned by this index is still considered
   * available.
   * Returns `false` if it is not available anymore.
   * Returns `undefined` if we cannot know whether it is still available or not.
   * @param {Object} segment
   * @returns {Boolean|undefined}
   */
  isSegmentStillAvailable(segment: ISegment): boolean | undefined {
    if (segment.isInit) {
      return true;
    }
    this._refreshTimeline();
    const { timeline, timescale } = this._sharedSmoothTimeline;
    for (let i = 0; i < timeline.length; i++) {
      const tSegment = timeline[i];
      const tSegmentTime = tSegment.start / timescale;
      if (tSegmentTime > segment.time) {
        return false; // We went over it without finding it
      } else if (tSegmentTime === segment.time) {
        return true;
      } else {
        // tSegment.start < segment.time
        if (tSegment.repeatCount >= 0 && tSegment.duration !== undefined) {
          const timeDiff = tSegmentTime - tSegment.start;
          const repeat = timeDiff / tSegment.duration - 1;
          return repeat % 1 === 0 && repeat <= tSegment.repeatCount;
        }
      }
    }
    return false;
  }

  /**
   * @param {Error} error
   * @returns {Boolean}
   */
  canBeOutOfSyncError(error: IPlayerError): boolean {
    if (!this._isLive) {
      return false;
    }
    return (
      error instanceof NetworkError && (error.isHttpError(404) || error.isHttpError(412))
    );
  }

  /**
   * Returns the `duration` of each segment in the context of its Manifest (i.e.
   * as the Manifest anounces them, actual segment duration may be different due
   * to approximations), in seconds.
   *
   * NOTE: we could here do a median or a mean but I chose to be lazy (and
   * more performant) by returning the duration of the first element instead.
   * As `isPrecize` is `false`, the rest of the code should be notified that
   * this is only an approximation.
   * @returns {number}
   */
  getTargetSegmentDuration(): { duration: number; isPrecize: boolean } | undefined {
    this._refreshTimeline();
    const { timeline, timescale } = this._sharedSmoothTimeline;
    const firstElementInTimeline = timeline[0];
    if (firstElementInTimeline === undefined) {
      return undefined;
    }
    return {
      duration: firstElementInTimeline.duration / timescale,
      isPrecize: false,
    };
  }

  /**
   * Replace this RepresentationIndex by a newly downloaded one.
   * Check if the old index had more information about new segments and re-add
   * them if that's the case.
   * @param {Object} newIndex
   */
  _replace(newIndex: SmoothRepresentationIndex): void {
    this._initialScaledLastPosition = newIndex._initialScaledLastPosition;
    this._scaledLiveGap = newIndex._scaledLiveGap;
    this._sharedSmoothTimeline.replace(newIndex._sharedSmoothTimeline);
  }

  /**
   * Update the current index with a new, partial, version.
   * This method might be use to only add information about new segments.
   * @param {Object} newIndex
   */
  _update(newIndex: SmoothRepresentationIndex): void {
    this._scaledLiveGap = newIndex._scaledLiveGap;
    this._sharedSmoothTimeline.update(newIndex._sharedSmoothTimeline);
  }

  /**
   * Returns `false` if the last segments in this index have already been
   * generated.
   * Returns `true` if the index is still waiting on future segments to be
   * generated.
   *
   * For Smooth, it should only depend on whether the content is a live content
   * or not.
   * TODO What about Smooth live content that finishes at some point?
   * @returns {boolean}
   */
  isStillAwaitingFutureSegments(): boolean {
    return this._isLive;
  }

  /**
   * @returns {Boolean}
   */
  isInitialized(): true {
    return true;
  }

  initialize(): void {
    log.error("A `SmoothRepresentationIndex` does not need to be initialized");
  }

  /**
   * Add segments to a `SharedSmoothSegmentTimeline` that were predicted to come
   * after `currentSegment`.
   * @param {Array.<Object>} nextSegments - The segment information parsed.
   * @param {Object} currentSegment - Information on the segment which contained
   * that new segment information.
   */
  addPredictedSegments(
    nextSegments: Array<{ duration: number; time: number; timescale: number }>,
    currentSegment: ISegment,
  ): void {
    this._sharedSmoothTimeline.addPredictedSegments(nextSegments, currentSegment);
  }

  /**
   * Clean-up timeline to remove segment information which should not be
   * available due to the timeshift window
   */
  private _refreshTimeline(): void {
    this._sharedSmoothTimeline.refresh();
  }
}
