/**
 * 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 isSeekingApproximate from "../../../compat/is_seeking_approximate";
import config from "../../../config";
import type { IBufferType } from "../../../core/types";
import { MediaError } from "../../../errors";
import log from "../../../log";
import type { IManifestMetadata, IPeriodMetadata } from "../../../manifest";
import { getPeriodAfter } from "../../../manifest";
import { SeekingState } from "../../../playback_observer";
import type {
  IMediaElementPlaybackObserver,
  IPlaybackObservation,
} from "../../../playback_observer";
import type { IPlayerError } from "../../../public_types";
import EventEmitter from "../../../utils/event_emitter";
import getMonotonicTimeStamp from "../../../utils/monotonic_timestamp";
import { getNextBufferedTimeRangeGap } from "../../../utils/ranges";
import type { IReadOnlySharedReference } from "../../../utils/reference";
import TaskCanceller from "../../../utils/task_canceller";
import type { IStallingSituation } from "../types";

/**
 * Work-around rounding errors with floating points by setting an acceptable,
 * very short, deviation when checking equalities.
 */
const EPSILON = 1 / 60;

/**
 * Monitor playback, trying to avoid stalling situation.
 * If stopping the player to build buffer is needed, temporarily set the
 * playback rate (i.e. speed) at `0` until enough buffer is available again.
 *
 * Emit "stalled" then "unstalled" respectively when an unavoidable stall is
 * encountered and exited.
 */
export default class RebufferingController extends EventEmitter<IRebufferingControllerEvent> {
  /** Emit the current playback conditions */
  private _playbackObserver: IMediaElementPlaybackObserver;
  private _manifest: IManifestMetadata | null;
  private _speed: IReadOnlySharedReference<number>;
  private _isStarted: boolean;

  /**
   * Every known audio and video buffer discontinuities in chronological
   * order (first ordered by Period's start, then by bufferType in any order.
   */
  private _discontinuitiesStore: IDiscontinuityStoredInfo[];

  private _canceller: TaskCanceller;

  /**
   * @param {object} playbackObserver - emit the current playback conditions.
   * @param {Object} manifest - The Manifest of the currently-played content.
   * @param {Object} speed - The last speed set by the user
   */
  constructor(
    playbackObserver: IMediaElementPlaybackObserver,
    manifest: IManifestMetadata | null,
    speed: IReadOnlySharedReference<number>,
  ) {
    super();
    this._playbackObserver = playbackObserver;
    this._manifest = manifest;
    this._speed = speed;
    this._discontinuitiesStore = [];
    this._isStarted = false;
    this._canceller = new TaskCanceller("RebufferingController");
  }

  public start(): void {
    if (this._isStarted) {
      return;
    }
    this._isStarted = true;

    const playbackRateUpdater = new PlaybackRateUpdater(
      this._playbackObserver,
      this._speed,
    );
    this._canceller.signal.register((err) => {
      playbackRateUpdater.dispose(err.reason);
    });

    this._playbackObserver.listen(
      (observation) => {
        const discontinuitiesStore = this._discontinuitiesStore;
        const { buffered, position, readyState, rebuffering, freezing } = observation;

        const { BUFFER_DISCONTINUITY_THRESHOLD, FREEZING_STALLED_DELAY } =
          config.getCurrent();

        if (freezing !== null) {
          const now = getMonotonicTimeStamp();
          if (now - freezing.timestamp > FREEZING_STALLED_DELAY) {
            if (rebuffering === null) {
              playbackRateUpdater.stopRebuffering();
            } else {
              playbackRateUpdater.startRebuffering();
            }
            this.trigger("stalled", "freezing");
            return;
          }
        }

        if (rebuffering === null) {
          playbackRateUpdater.stopRebuffering();
          if (readyState === 1) {
            // With a readyState set to 1, we should still not be able to play:
            // Return that we're stalled
            let reason: IStallingSituation;
            if (observation.seeking !== SeekingState.None) {
              reason =
                observation.seeking === SeekingState.Internal
                  ? "internal-seek"
                  : "seeking";
            } else {
              reason = "not-ready";
            }
            this.trigger("stalled", reason);
            return;
          }
          this.trigger("unstalled", null);
          return;
        }

        // We want to separate a stall situation when a seek is due to a seek done
        // internally by the player to when its due to a regular user seek.
        const stalledReason =
          rebuffering.reason === "seeking" &&
          observation.seeking === SeekingState.Internal
            ? ("internal-seek" as const)
            : rebuffering.reason;

        if (position.isAwaitingFuturePosition()) {
          playbackRateUpdater.stopRebuffering();
          log.debug(
            "Init",
            "let rebuffering happen as we're awaiting a future position",
            {
              wantedPosition: position.getWanted(),
              lastPolledPosition: position.getPolled(),
            },
          );
        } else {
          playbackRateUpdater.startRebuffering();
        }

        if (
          this._manifest === null ||
          (isSeekingApproximate() &&
            // Don't handle discontinuities on devices with broken seeks before
            // enough time have passed because seeking brings more risks to
            // lead to a lengthy rebuffering-exiting process
            getMonotonicTimeStamp() - rebuffering.timestamp <= 1000)
        ) {
          this.trigger("stalled", stalledReason);
          return;
        }

        /** Position at which data is awaited. */
        const { position: stalledPosition } = rebuffering;

        /**
         * We may still be in the process of waiting for a position to be seeked
         * to. When calculating a potential position to e.g. skip over
         * discontinuities, we should compare it to that "target" position if
         * one, not the one we're currently playing.
         */
        const targetTime = observation.position.isAwaitingFuturePosition()
          ? observation.position.getWanted()
          : this._playbackObserver.getCurrentTime();

        if (
          stalledPosition !== null &&
          stalledPosition !== undefined &&
          this._speed.getValue() > 0
        ) {
          const skippableDiscontinuity = findSeekableDiscontinuity(
            discontinuitiesStore,
            this._manifest,
            stalledPosition,
          );
          if (skippableDiscontinuity !== null) {
            const realSeekTime = skippableDiscontinuity + 0.001;
            if (realSeekTime <= targetTime) {
              log.info("Init", "position to seek already reached, no seeking", {
                targetTime,
                realSeekTime,
              });
            } else {
              log.warn("Init", "skippable discontinuity found in the stream", {
                lastPolledPosition: position.getPolled(),
                realSeekTime,
              });
              this._playbackObserver.setCurrentTime(realSeekTime);
              this.trigger(
                "warning",
                generateDiscontinuityError(stalledPosition, realSeekTime),
              );
              return;
            }
          }
        }

        const positionBlockedAt = stalledPosition ?? position.getPolled();

        // Is it a very short discontinuity in buffer ? -> Seek at the beginning of the
        //                                                 next range
        //
        // Discontinuity check in case we are close a buffered range but still
        // calculate a stalled state. This is useful for some
        // implementation that might drop an injected segment, or in
        // case of small discontinuity in the content.
        const nextBufferRangeGap = getNextBufferedTimeRangeGap(
          buffered,
          positionBlockedAt,
        );
        if (
          (!isSeekingApproximate() ||
            getMonotonicTimeStamp() - rebuffering.timestamp > 1000) &&
          this._speed.getValue() > 0 &&
          nextBufferRangeGap < BUFFER_DISCONTINUITY_THRESHOLD
        ) {
          const seekTo = positionBlockedAt + nextBufferRangeGap + EPSILON;
          if (targetTime < seekTo) {
            log.warn("Init", "discontinuity encountered inferior to the threshold", {
              positionBlockedAt,
              seekTo,
              BUFFER_DISCONTINUITY_THRESHOLD,
            });
            this._playbackObserver.setCurrentTime(seekTo);
            this.trigger(
              "warning",
              generateDiscontinuityError(positionBlockedAt, seekTo),
            );
            return;
          }
        }

        // Are we in a discontinuity between periods ? -> Seek at the beginning of the
        //                                                next period
        for (let i = this._manifest.periods.length - 2; i >= 0; i--) {
          const period = this._manifest.periods[i];
          if (period.end !== undefined && period.end <= positionBlockedAt) {
            if (
              this._manifest.periods[i + 1].start > positionBlockedAt &&
              this._manifest.periods[i + 1].start > targetTime
            ) {
              const nextPeriod = this._manifest.periods[i + 1];
              this._playbackObserver.setCurrentTime(nextPeriod.start);
              this.trigger(
                "warning",
                generateDiscontinuityError(positionBlockedAt, nextPeriod.start),
              );
              return;
            }
            break;
          }
        }

        this.trigger("stalled", stalledReason);
      },
      { includeLastObservation: true, clearSignal: this._canceller.signal },
    );
  }

  /**
   * Update information on an upcoming discontinuity for a given buffer type and
   * Period.
   * Each new update for the same Period and type overwrites the previous one.
   * @param {Object} evt
   */
  public updateDiscontinuityInfo(evt: IDiscontinuityEvent): void {
    if (!this._isStarted) {
      this.start();
    }
    const lastObservation = this._playbackObserver.getReference().getValue();
    updateDiscontinuitiesStore(this._discontinuitiesStore, evt, lastObservation);
  }

  /**
   * Function to call when a Stream is currently locked, i.e. we cannot load
   * segments for the corresponding Period and buffer type until it is seeked
   * to.
   * @param {string} bufferType - Buffer type for which no segment will
   * currently load.
   * @param {Object} period - Period for which no segment will currently load.
   */
  public onLockedStream(bufferType: IBufferType, period: IPeriodMetadata): void {
    if (!this._isStarted) {
      this.start();
    }
    const observation = this._playbackObserver.getReference().getValue();
    if (
      observation.rebuffering === null ||
      observation.paused ||
      this._speed.getValue() <= 0 ||
      (bufferType !== "audio" && bufferType !== "video")
    ) {
      return;
    }
    const loadedPos = observation.position.getWanted();
    const rebufferingPos = observation.rebuffering.position ?? loadedPos;
    const lockedPeriodStart = period.start;
    if (
      loadedPos < lockedPeriodStart &&
      Math.abs(rebufferingPos - lockedPeriodStart) < 1
    ) {
      log.warn(
        "Init",
        "rebuffering because of a future locked stream.\n" +
          "Trying to unlock by seeking to the next Period",
      );
      this._playbackObserver.setCurrentTime(lockedPeriodStart + 0.001);
    }
  }

  /**
   * Stops the `RebufferingController` from montoring stalling situations,
   * forever.
   * @param {string | undefined} reason - Human-inspectable reason behind the
   * dispose. Used for debugging matters, especially for debug log
   * inspection.
   */
  public destroy(reason: string | undefined): void {
    this._canceller.cancel(reason ?? "RebufferingController destroy");
  }
}

/**
 * @param {Array.<Object>} discontinuitiesStore
 * @param {Object} manifest
 * @param {number} stalledPosition
 * @returns {number|null}
 */
function findSeekableDiscontinuity(
  discontinuitiesStore: IDiscontinuityStoredInfo[],
  manifest: IManifestMetadata,
  stalledPosition: number,
): number | null {
  if (discontinuitiesStore.length === 0) {
    return null;
  }
  let maxDiscontinuityEnd: number | null = null;
  for (const discontinuityInfo of discontinuitiesStore) {
    const { period } = discontinuityInfo;
    if (period.start > stalledPosition) {
      return maxDiscontinuityEnd;
    }

    let discontinuityEnd: number | undefined;

    if (period.end === undefined || period.end > stalledPosition) {
      const { discontinuity, position } = discontinuityInfo;
      const { start, end } = discontinuity;
      const discontinuityLowerLimit = start ?? position;
      if (stalledPosition >= discontinuityLowerLimit - EPSILON) {
        if (end === null) {
          const nextPeriod = getPeriodAfter(manifest, period);
          if (nextPeriod !== null) {
            discontinuityEnd = nextPeriod.start + EPSILON;
          } else {
            log.warn("Init", "discontinuity at Period's end but no next Period", {
              periodId: period.id,
            });
          }
        } else if (stalledPosition < end + EPSILON) {
          discontinuityEnd = end + EPSILON;
        }
      }
      if (discontinuityEnd !== undefined) {
        log.info("Init", "discontinuity found", { stalledPosition, discontinuityEnd });
        maxDiscontinuityEnd =
          maxDiscontinuityEnd !== null && maxDiscontinuityEnd > discontinuityEnd
            ? maxDiscontinuityEnd
            : discontinuityEnd;
      }
    }
  }
  return maxDiscontinuityEnd;
}

/**
 * Return `true` if the given event indicates that a discontinuity is present.
 * @param {Object} evt
 * @returns {Array.<Object>}
 */
function eventContainsDiscontinuity(
  evt: IDiscontinuityEvent,
): evt is IDiscontinuityStoredInfo {
  return evt.discontinuity !== null;
}

/**
 * Update the `discontinuitiesStore` Object with the given event information:
 *
 *   - If that event indicates than no discontinuity is found for a Period
 *     and buffer type, remove a possible existing discontinuity for that
 *     combination.
 *
 *   - If that event indicates that a discontinuity can be found for a Period
 *     and buffer type, replace previous occurences for that combination and
 *     store it in Period's chronological order in the Array.
 * @param {Array.<Object>} discontinuitiesStore
 * @param {Object} evt
 * @param {Object} observation
 * @returns {Array.<Object>}
 */
function updateDiscontinuitiesStore(
  discontinuitiesStore: IDiscontinuityStoredInfo[],
  evt: IDiscontinuityEvent,
  observation: IPlaybackObservation,
): void {
  const gcTime = Math.min(
    observation.position.getPolled(),
    observation.position.getWanted(),
  );

  // First, perform clean-up of old discontinuities
  while (
    discontinuitiesStore.length > 0 &&
    discontinuitiesStore[0].period.end !== undefined &&
    discontinuitiesStore[0].period.end + 10 < gcTime
  ) {
    discontinuitiesStore.shift();
  }

  const { period, bufferType } = evt;
  if (bufferType !== "audio" && bufferType !== "video") {
    return;
  }

  for (let i = 0; i < discontinuitiesStore.length; i++) {
    if (discontinuitiesStore[i].period.id === period.id) {
      if (discontinuitiesStore[i].bufferType === bufferType) {
        if (!eventContainsDiscontinuity(evt)) {
          discontinuitiesStore.splice(i, 1);
        } else {
          discontinuitiesStore[i] = evt;
        }
        return;
      }
    } else if (discontinuitiesStore[i].period.start > period.start) {
      if (eventContainsDiscontinuity(evt)) {
        discontinuitiesStore.splice(i, 0, evt);
      }
      return;
    }
  }
  if (eventContainsDiscontinuity(evt)) {
    discontinuitiesStore.push(evt);
  }
  return;
}

/**
 * Generate error emitted when a discontinuity has been encountered.
 * @param {number} stalledPosition
 * @param {number} seekTo
 * @returns {Error}
 */
function generateDiscontinuityError(stalledPosition: number, seekTo: number): MediaError {
  return new MediaError(
    "DISCONTINUITY_ENCOUNTERED",
    "A discontinuity has been encountered at position " +
      String(stalledPosition) +
      ", seeking at position " +
      String(seekTo),
  );
}

/**
 * Manage playback speed, allowing to force a playback rate of `0` when
 * rebuffering is wanted.
 *
 * Only one `PlaybackRateUpdater` should be created per HTMLMediaElement.
 * Note that the `PlaybackRateUpdater` reacts to playback event and wanted
 * speed change. You should call its `dispose` method once you don't need it
 * anymore.
 * @class PlaybackRateUpdater
 */
class PlaybackRateUpdater {
  private _playbackObserver: IMediaElementPlaybackObserver;
  private _speed: IReadOnlySharedReference<number>;
  private _speedUpdateCanceller: TaskCanceller;
  private _isRebuffering: boolean;
  private _isDisposed: boolean;

  /**
   * Create a new `PlaybackRateUpdater`.
   * @param {Object} playbackObserver
   * @param {Object} speed
   */
  constructor(
    playbackObserver: IMediaElementPlaybackObserver,
    speed: IReadOnlySharedReference<number>,
  ) {
    this._speedUpdateCanceller = new TaskCanceller("PlaybackRateUpdater speed updates");
    this._isRebuffering = false;
    this._playbackObserver = playbackObserver;
    this._isDisposed = false;
    this._speed = speed;
    this._updateSpeed();
  }

  /**
   * Force the playback rate to `0`, to start a rebuffering phase.
   *
   * You can call `stopRebuffering` when you want the rebuffering phase to end.
   */
  public startRebuffering(): void {
    if (this._isRebuffering || this._isDisposed) {
      return;
    }
    this._isRebuffering = true;
    this._speedUpdateCanceller.cancel("start rebuffering");
    log.info("Init", "Pause playback to build buffer");
    this._playbackObserver.setPlaybackRate(0);
  }

  /**
   * If in a rebuffering phase (during which the playback rate is forced to
   * `0`), exit that phase to apply the wanted playback rate instead.
   *
   * Do nothing if not in a rebuffering phase.
   */
  public stopRebuffering() {
    if (!this._isRebuffering || this._isDisposed) {
      return;
    }
    this._isRebuffering = false;
    this._speedUpdateCanceller = new TaskCanceller("PlaybackRateUpdater speed updates");
    this._updateSpeed();
  }

  /**
   * The `PlaybackRateUpdater` allocate resources to for example listen to
   * wanted speed changes and react to it.
   *
   * Consequently, you should call the `dispose` method, when you don't want the
   * `PlaybackRateUpdater` to have an effect anymore.
   * @param {string | undefined} reason - Human-inspectable reason behind the
   * cancellation. Used for debugging matters, especially for debug log
   * inspection.
   */
  public dispose(reason: string | undefined) {
    this._speedUpdateCanceller.cancel(reason ?? "PlaybackRateUpdater dispose");
    this._isDisposed = true;
  }

  private _updateSpeed() {
    this._speed.onUpdate(
      (lastSpeed) => {
        log.info("Init", "Resume playback speed", { newSpeed: lastSpeed });
        this._playbackObserver.setPlaybackRate(lastSpeed);
      },
      {
        clearSignal: this._speedUpdateCanceller.signal,
        emitCurrentValue: true,
      },
    );
  }
}

export interface IRebufferingControllerEvent {
  stalled: IStallingSituation;
  unstalled: null;
  needsReload: null;
  warning: IPlayerError;
}

/**
 * Event indicating that a discontinuity has been found.
 * Each event for a `bufferType` and `period` combination replaces the previous
 * one.
 */
export interface IDiscontinuityEvent {
  /** Buffer type concerned by the discontinuity. */
  bufferType: IBufferType;
  /** Period concerned by the discontinuity. */
  period: IPeriodMetadata;
  /**
   * Close discontinuity time information.
   * `null` if no discontinuity has been detected currently for that buffer
   * type and Period.
   */
  discontinuity: IDiscontinuityTimeInfo | null;
  /**
   * Position at which the discontinuity was found.
   * Can be important for when a current discontinuity's start is unknown.
   */
  position: number;
}

/** Information on a found discontinuity. */
export interface IDiscontinuityTimeInfo {
  /**
   * Start time of the discontinuity.
   * `undefined` for when the start is unknown but the discontinuity was
   * currently encountered at the position we were in when this event was
   * created.
   */
  start: number | undefined;
  /**
   * End time of the discontinuity, in seconds.
   * If `null`, no further segment can be loaded for the corresponding Period.
   */
  end: number | null;
}

/**
 * Internally stored information about a known discontinuity in the audio or
 * video buffer.
 */
interface IDiscontinuityStoredInfo {
  /** Buffer type concerned by the discontinuity. */
  bufferType: IBufferType;
  /** Period concerned by the discontinuity. */
  period: IPeriodMetadata;
  /** Discontinuity time information. */
  discontinuity: IDiscontinuityTimeInfo;
  /**
   * Position at which the discontinuity was found.
   * Can be important for when a current discontinuity's start is unknown.
   */
  position: number;
}
