/**
 * 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 type {
  IStreamOrchestratorPlaybackObservation,
  IBufferType,
} from "../../core/types";
import { MediaError } from "../../errors";
import type {
  IManifest,
  IAdaptation,
  IRepresentationIndex,
  IPeriod,
} from "../../manifest";
import type { IReadOnlyPlaybackObserver } from "../../playback_observer";
import type { IPlayerError } from "../../public_types";
import arrayIncludes from "../../utils/array_includes";
import EventEmitter from "../../utils/event_emitter";
import isNullOrUndefined from "../../utils/is_null_or_undefined";
import queueMicrotask from "../../utils/queue_microtask";
import SortedList from "../../utils/sorted_list";
import TaskCanceller from "../../utils/task_canceller";

/**
 * Observes what's being played and take care of media events relating to time
 * boundaries:
 *   - Emits a `endingPositionChange` when the known maximum playable position
 *     of the current content is known and every time it changes.
 *   - Emits `endOfStream` API once segments have been pushed until the end and
 *     `resumeStream` if downloads starts back.
 *   - Emits a `periodChange` event when the currently-playing Period seemed to
 *     have changed.
 *   - emit "warning" events when what is being played is outside of the
 *     Manifest range.
 * @class ContentTimeBoundariesObserver
 */

// `HTMLMediaElement.currentTime` can be off by a very small amount from the
// Manifest-derived boundaries. We keep an epsilon so those rounding
// differences do not trigger spurious boundary warnings.
const EPSILON = 1e-3;

export default class ContentTimeBoundariesObserver extends EventEmitter<IContentTimeBoundariesObserverEvent> {
  /** Allows to interrupt everything the `ContentTimeBoundariesObserver` is doing. */
  private _canceller: TaskCanceller;

  /** Store information on every created "Streams". */
  private _activeStreams: Map<IBufferType, IActiveStreamsInfo>;

  /** The `Manifest` object linked to the current content. */
  private _manifest: IManifest;

  /** Allows to calculate at any time maximum positions of the content */
  private _maximumPositionCalculator: MaximumPositionCalculator;

  /** Enumerate all possible buffer types in the current content. */
  private _allBufferTypes: IBufferType[];

  /**
   * Stores the `id` property of the last Period for which a `periodChange`
   * event has been sent.
   * Allows to avoid multiple times in a row `periodChange` for the same
   * Period.
   */
  private _lastCurrentPeriodId: string | null;

  /**
   * @param {Object} manifest
   * @param {Object} playbackObserver
   */
  constructor(
    manifest: IManifest,
    playbackObserver: IReadOnlyPlaybackObserver<IStreamOrchestratorPlaybackObservation>,
    bufferTypes: IBufferType[],
  ) {
    super();

    this._canceller = new TaskCanceller("Boundaries Observation");
    this._manifest = manifest;
    this._activeStreams = new Map();
    this._allBufferTypes = bufferTypes;
    this._lastCurrentPeriodId = null;

    /**
     * Allows to calculate the minimum and maximum playable position on the
     * whole content.
     */
    const maximumPositionCalculator = new MaximumPositionCalculator(manifest);

    // Indicate directly that no Adaptation of a particular type will be set
    if (!arrayIncludes(this._allBufferTypes, "video")) {
      maximumPositionCalculator.updateLastVideoAdaptation(null);
    }
    if (!arrayIncludes(this._allBufferTypes, "audio")) {
      maximumPositionCalculator.updateLastAudioAdaptation(null);
    }

    this._maximumPositionCalculator = maximumPositionCalculator;

    const cancelSignal = this._canceller.signal;

    // As the following code may send events synchronously, which would not be
    // catchable as a caller could not have called `addEventListener` yet,
    // we schedule it in a micro-task
    queueMicrotask(() => {
      playbackObserver.listen(
        ({ position }) => {
          const wantedPosition = position.getWanted();
          const minimumPosition = manifest.getMinimumSafePosition();
          const maximumPosition = maximumPositionCalculator.getMaximumAvailablePosition();

          // Only report "before manifest" when the position is meaningfully
          // behind the minimum, not when `<video>.currentTime` and the
          // Manifest disagree by a tiny floating-point delta.
          if (wantedPosition < minimumPosition - EPSILON) {
            const warning = new MediaError(
              "MEDIA_TIME_BEFORE_MANIFEST",
              "The current position is behind the " +
                "earliest time announced in the Manifest.",
              {
                timeInfo: {
                  position: wantedPosition,
                  minPosition: minimumPosition,
                  maxPosition: maximumPosition,
                },
              },
            );
            this.trigger("warning", warning);
          } else if (wantedPosition > maximumPosition + EPSILON) {
            const warning = new MediaError(
              "MEDIA_TIME_AFTER_MANIFEST",
              "The current position is after the latest " +
                "time announced in the Manifest.",
              {
                timeInfo: {
                  position: wantedPosition,
                  minPosition: minimumPosition,
                  maxPosition: maximumPosition,
                },
              },
            );
            this.trigger("warning", warning);
          }
        },
        { includeLastObservation: false, clearSignal: cancelSignal },
      );
    });

    manifest.addEventListener(
      "manifestUpdate",
      () => {
        this.trigger("endingPositionChange", this._getManifestEndTime());
        if (cancelSignal.isCancelled()) {
          return;
        }
        this._checkEndOfStream();
      },
      cancelSignal,
    );
  }

  /**
   * Returns an estimate of the current last position which may be played in
   * the content at the moment.
   * @returns {Object}
   */
  public getCurrentEndingTime(): IEndingPositionInformation {
    return this._getManifestEndTime();
  }

  /**
   * Method to call any time an Adaptation has been selected.
   *
   * That Adaptation switch will be considered as active until the
   * `onPeriodCleared` method has been called for the same `bufferType` and
   * `Period`, or until `dispose` is called.
   * @param {string} bufferType - The type of buffer concerned by the Adaptation
   * switch
   * @param {Object} period - The Period concerned by the Adaptation switch
   * @param {Object|null} adaptation - The Adaptation selected. `null` if the
   * absence of `Adaptation` has been explicitely selected for this Period and
   * buffer type (e.g. no video).
   */
  public onAdaptationChange(
    bufferType: IBufferType,
    period: IPeriod,
    adaptation: IAdaptation | null,
  ): void {
    if (this._manifest.isLastPeriodKnown) {
      const lastPeriod = this._manifest.periods[this._manifest.periods.length - 1];
      if (period.id === lastPeriod?.id) {
        if (bufferType === "audio" || bufferType === "video") {
          if (bufferType === "audio") {
            this._maximumPositionCalculator.updateLastAudioAdaptation(adaptation);
          } else {
            this._maximumPositionCalculator.updateLastVideoAdaptation(adaptation);
          }
          const endingPosition = this._maximumPositionCalculator.getEndingPosition();
          const newEndingPosition =
            endingPosition !== undefined
              ? { isEnd: true, endingPosition }
              : {
                  isEnd: false,
                  endingPosition:
                    this._maximumPositionCalculator.getMaximumAvailablePosition(),
                };
          this.trigger("endingPositionChange", newEndingPosition);
        }
      }
    }
    if (this._canceller.isUsed()) {
      return;
    }
    if (adaptation === null) {
      this._addActivelyLoadedPeriod(period, bufferType);
    }
  }

  /**
   * Method to call any time a Representation has been selected.
   *
   * That Representation switch will be considered as active until the
   * `onPeriodCleared` method has been called for the same `bufferType` and
   * `Period`, or until `dispose` is called.
   * @param {string} bufferType - The type of buffer concerned by the
   * Representation switch
   * @param {Object} period - The Period concerned by the Representation switch
   */
  public onRepresentationChange(bufferType: IBufferType, period: IPeriod): void {
    this._addActivelyLoadedPeriod(period, bufferType);
  }

  /**
   * Method to call any time a Period and type combination is not considered
   * anymore.
   *
   * Calling this method allows to signal that a previous Adaptation and/or
   * Representation change respectively indicated by an `onAdaptationChange` and
   * an `onRepresentationChange` call, are not active anymore.
   * @param {string} bufferType - The type of buffer concerned
   * @param {Object} period - The Period concerned
   */
  public onPeriodCleared(bufferType: IBufferType, period: IPeriod): void {
    this._removeActivelyLoadedPeriod(period, bufferType);
  }

  /**
   * Method to call when the last chronological segment for a given buffer type
   * is known to have been loaded and is either pushed or in the process of
   * being pushed to the corresponding MSE `SourceBuffer` or equivalent.
   *
   * This method can even be called multiple times in a row as long as the
   * aforementioned condition is true, if it simplify your code's management.
   * @param {string} bufferType
   */
  public onLastSegmentFinishedLoading(bufferType: IBufferType): void {
    const streamInfo = this._lazilyCreateActiveStreamInfo(bufferType);
    if (!streamInfo.hasFinishedLoadingLastPeriod) {
      streamInfo.hasFinishedLoadingLastPeriod = true;
      this._checkEndOfStream();
    }
  }

  /**
   * Method to call to "cancel" a previous call to
   * `onLastSegmentFinishedLoading`.
   *
   * That is, calling this method indicates that the last chronological segment
   * of a given buffer type is now either not loaded or it is not known.
   *
   * This method can even be called multiple times in a row as long as the
   * aforementioned condition is true, if it simplify your code's management.
   * @param {string} bufferType
   */
  public onLastSegmentLoadingResume(bufferType: IBufferType): void {
    const streamInfo = this._lazilyCreateActiveStreamInfo(bufferType);
    if (streamInfo.hasFinishedLoadingLastPeriod) {
      streamInfo.hasFinishedLoadingLastPeriod = false;
      this._checkEndOfStream();
    }
  }

  /**
   * Free all resources used by the `ContentTimeBoundariesObserver` and cancels
   * all recurring processes it performs.
   * @param {string | undefined} reason - Human-inspectable reason behind the
   * dispose. Used for debugging matters, especially for debug log
   * inspection.
   */
  public dispose(reason: string | undefined) {
    this.removeEventListener();
    this._canceller.cancel(reason ?? "ContentTimeBoundariesObserver dispose");
  }

  private _addActivelyLoadedPeriod(period: IPeriod, bufferType: IBufferType): void {
    const streamInfo = this._lazilyCreateActiveStreamInfo(bufferType);
    if (!streamInfo.activePeriods.has(period)) {
      streamInfo.activePeriods.add(period);
      this._checkCurrentPeriod();
    }
  }

  private _removeActivelyLoadedPeriod(period: IPeriod, bufferType: IBufferType): void {
    const streamInfo = this._activeStreams.get(bufferType);
    if (streamInfo === undefined) {
      return;
    }
    if (streamInfo.activePeriods.has(period)) {
      streamInfo.activePeriods.removeElement(period);
      this._checkCurrentPeriod();
    }
  }

  private _checkCurrentPeriod(): void {
    if (this._allBufferTypes.length === 0) {
      return;
    }

    const streamInfo = this._activeStreams.get(this._allBufferTypes[0]);
    if (streamInfo === undefined) {
      return;
    }
    for (const period of streamInfo.activePeriods.toArray()) {
      let wasFoundInAllTypes = true;
      for (const bufferType of this._allBufferTypes) {
        const streamInfo2 = this._activeStreams.get(bufferType);
        if (streamInfo2 === undefined) {
          return;
        }

        const activePeriods = streamInfo2.activePeriods.toArray();
        const hasPeriod = activePeriods.some((p) => p.id === period.id);
        if (!hasPeriod) {
          wasFoundInAllTypes = false;
          break;
        }
      }
      if (wasFoundInAllTypes) {
        if (this._lastCurrentPeriodId !== period.id) {
          this._lastCurrentPeriodId = period.id;
          this.trigger("periodChange", period);
        }
        return;
      }
    }
  }

  private _getManifestEndTime(): IEndingPositionInformation {
    const endingPosition = this._maximumPositionCalculator.getEndingPosition();
    return endingPosition !== undefined
      ? { isEnd: true, endingPosition }
      : {
          isEnd: false,
          endingPosition: this._maximumPositionCalculator.getMaximumAvailablePosition(),
        };
  }

  private _lazilyCreateActiveStreamInfo(bufferType: IBufferType): IActiveStreamsInfo {
    let streamInfo = this._activeStreams.get(bufferType);
    if (streamInfo === undefined) {
      streamInfo = {
        activePeriods: new SortedList((a, b) => a.start - b.start),
        hasFinishedLoadingLastPeriod: false,
      };
      this._activeStreams.set(bufferType, streamInfo);
    }
    return streamInfo;
  }

  private _checkEndOfStream(): void {
    if (!this._manifest.isLastPeriodKnown) {
      return;
    }
    const everyBufferTypeLoaded = this._allBufferTypes.every((bt) => {
      const streamInfo = this._activeStreams.get(bt);
      return streamInfo !== undefined && streamInfo.hasFinishedLoadingLastPeriod;
    });
    if (everyBufferTypeLoaded) {
      this.trigger("endOfStream", null);
    } else {
      this.trigger("resumeStream", null);
    }
  }
}

export interface IEndingPositionInformation {
  /**
   * The new maximum known position (note that this is the ending position
   * currently known of the current content, it might be superior to the last
   * position at which segments are available and it might also evolve over
   * time), in seconds.
   */
  endingPosition: number;
  /**
   * If `true`, the communicated `endingPosition` is the actual end of the content.
   * It may still be updated due to a track change or to add precision, but it
   * is still a (rough) estimate of the maximum position that content should
   * have.
   *
   * If `false`, this is the currently known maximum position associated to
   * the content, but the content is still evolving (typically, new media
   * segments are still being generated) and as such it can still have a
   * longer `endingPosition` in the future.
   */
  isEnd: boolean;
}

/**
 * Events triggered by a `ContentTimeBoundariesObserver` where the keys are the
 * event names and the value is the payload of those events.
 */
export interface IContentTimeBoundariesObserverEvent {
  /** Triggered when a minor error is encountered. */
  warning: IPlayerError;
  /** Triggered when a new `Period` is currently playing. */
  periodChange: IPeriod;
  /**
   * Triggered when the ending position of the currently-playing content became
   * known or changed.
   */
  endingPositionChange: IEndingPositionInformation;
  /**
   * Triggered when the last possible chronological segment for all types of
   * buffers has either been pushed or is being pushed to the corresponding
   * MSE `SourceBuffer` or equivalent.
   * As such, the `endOfStream` MSE API might from now be able to be called.
   *
   * Note that it is possible to receive this event even if `endOfStream` has
   * already been called and even if an "endOfStream" event has already been
   * triggered.
   */
  endOfStream: null;
  /**
   * Triggered when the last possible chronological segment for all types of
   * buffers have NOT been pushed, or if it is not known whether is has been
   * pushed, and as such any potential pending `endOfStream` MSE API call
   * need to be cancelled.
   *
   * Note that it is possible to receive this event even if `endOfStream` has
   * not been called and even if an "resumeStream" event has already been
   * triggered.
   */
  resumeStream: null;
}

/**
 * Calculate the last position from the last chosen audio and video Adaptations
 * for the last Period (or a default one, if no Adaptations has been chosen).
 * @class MaximumPositionCalculator
 */
class MaximumPositionCalculator {
  private _manifest: IManifest;

  // TODO replicate for the minimum position ?
  private _lastAudioAdaptation: IAdaptation | undefined | null;
  private _lastVideoAdaptation: IAdaptation | undefined | null;

  /**
   * @param {Object} manifest
   */
  constructor(manifest: IManifest) {
    this._manifest = manifest;
    this._lastAudioAdaptation = undefined;
    this._lastVideoAdaptation = undefined;
  }

  /**
   * Update the last known audio Adaptation for the last Period.
   * If no Adaptation has been set, it should be set to `null`.
   *
   * Allows to calculate the maximum position more precizely in
   * `getMaximumAvailablePosition` and `getEndingPosition`.
   * @param {Object|null} adaptation
   */
  public updateLastAudioAdaptation(adaptation: IAdaptation | null): void {
    this._lastAudioAdaptation = adaptation;
  }

  /**
   * Update the last known video Adaptation for the last Period.
   * If no Adaptation has been set, it should be set to `null`.
   *
   * Allows to calculate the maximum position more precizely in
   * `getMaximumAvailablePosition` and `getEndingPosition`.
   * @param {Object|null} adaptation
   */
  public updateLastVideoAdaptation(adaptation: IAdaptation | null): void {
    this._lastVideoAdaptation = adaptation;
  }

  /**
   * Returns an estimate of the maximum position currently reachable (i.e.
   * segments are available) under the current circumstances.
   * @returns {number}
   */
  public getMaximumAvailablePosition(): number {
    if (this._manifest.isDynamic) {
      return this._manifest.getMaximumSafePosition();
    }
    if (
      this._lastVideoAdaptation === undefined ||
      this._lastAudioAdaptation === undefined
    ) {
      return this._manifest.getMaximumSafePosition();
    } else if (this._lastAudioAdaptation === null) {
      if (this._lastVideoAdaptation === null) {
        return this._manifest.getMaximumSafePosition();
      } else {
        const lastVideoPosition = getLastAvailablePositionFromAdaptation(
          this._lastVideoAdaptation,
        );
        if (typeof lastVideoPosition !== "number") {
          return this._manifest.getMaximumSafePosition();
        }
        return lastVideoPosition;
      }
    } else if (this._lastVideoAdaptation === null) {
      const lastAudioPosition = getLastAvailablePositionFromAdaptation(
        this._lastAudioAdaptation,
      );
      if (typeof lastAudioPosition !== "number") {
        return this._manifest.getMaximumSafePosition();
      }
      return lastAudioPosition;
    } else {
      const lastAudioPosition = getLastAvailablePositionFromAdaptation(
        this._lastAudioAdaptation,
      );
      const lastVideoPosition = getLastAvailablePositionFromAdaptation(
        this._lastVideoAdaptation,
      );
      if (
        typeof lastAudioPosition !== "number" ||
        typeof lastVideoPosition !== "number"
      ) {
        return this._manifest.getMaximumSafePosition();
      } else {
        return Math.min(lastAudioPosition, lastVideoPosition);
      }
    }
  }

  /**
   * Returns an estimate of the actual ending position once
   * the full content is available.
   * Returns `undefined` if that could not be determined, for various reasons.
   * @returns {number|undefined}
   */
  public getEndingPosition(): number | undefined {
    if (!this._manifest.isDynamic) {
      return this.getMaximumAvailablePosition();
    }
    if (
      this._lastVideoAdaptation === undefined ||
      this._lastAudioAdaptation === undefined
    ) {
      return undefined;
    } else if (this._lastAudioAdaptation === null) {
      if (this._lastVideoAdaptation === null) {
        return undefined;
      } else {
        return getEndingPositionFromAdaptation(this._lastVideoAdaptation) ?? undefined;
      }
    } else if (this._lastVideoAdaptation === null) {
      return getEndingPositionFromAdaptation(this._lastAudioAdaptation) ?? undefined;
    } else {
      const lastAudioPosition = getEndingPositionFromAdaptation(
        this._lastAudioAdaptation,
      );
      const lastVideoPosition = getEndingPositionFromAdaptation(
        this._lastVideoAdaptation,
      );
      if (
        typeof lastAudioPosition !== "number" ||
        typeof lastVideoPosition !== "number"
      ) {
        return undefined;
      } else {
        return Math.min(lastAudioPosition, lastVideoPosition);
      }
    }
  }
}

/**
 * Returns last currently available position from the Adaptation given.
 * `undefined` if a time could not be found.
 * `null` if the Adaptation has no segments (it could be that it didn't started or
 * that it already finished for example).
 *
 * We consider the earliest last available position from every Representation
 * in the given Adaptation.
 * @param {Object} adaptation
 * @returns {Number|undefined|null}
 */
function getLastAvailablePositionFromAdaptation(
  adaptation: IAdaptation,
): number | undefined | null {
  const { representations } = adaptation;
  let min: null | number = null;

  /**
   * Some Manifest parsers use the exact same `IRepresentationIndex` reference
   * for each Representation of a given Adaptation, because in the actual source
   * Manifest file, indexing data is often defined at Adaptation-level.
   * This variable allows to optimize the logic here when this is the case.
   */
  let lastIndex: IRepresentationIndex | undefined;
  for (const representation of representations) {
    if (representation.index !== lastIndex) {
      lastIndex = representation.index;
      const lastPosition = representation.index.getLastAvailablePosition();
      if (lastPosition === undefined) {
        // we cannot tell
        return undefined;
      }
      if (lastPosition !== null) {
        min = isNullOrUndefined(min) ? lastPosition : Math.min(min, lastPosition);
      }
    }
  }
  return min;
}

/**
 * Returns ending time from the Adaptation given, once all its segments are
 * available.
 * `undefined` if a time could not be found.
 * `null` if the Adaptation has no segments (it could be that it already
 * finished for example).
 *
 * We consider the earliest ending time from every Representation in the given
 * Adaptation.
 * @param {Object} adaptation
 * @returns {Number|undefined|null}
 */
function getEndingPositionFromAdaptation(
  adaptation: IAdaptation,
): number | undefined | null {
  const { representations } = adaptation;
  let min: null | number = null;

  /**
   * Some Manifest parsers use the exact same `IRepresentationIndex` reference
   * for each Representation of a given Adaptation, because in the actual source
   * Manifest file, indexing data is often defined at Adaptation-level.
   * This variable allows to optimize the logic here when this is the case.
   */
  let lastIndex: IRepresentationIndex | undefined;
  for (const representation of representations) {
    if (representation.index !== lastIndex) {
      lastIndex = representation.index;
      const lastPosition = representation.index.getEnd();
      if (lastPosition === undefined) {
        // we cannot tell
        return undefined;
      }
      if (lastPosition !== null) {
        min = isNullOrUndefined(min) ? lastPosition : Math.min(min, lastPosition);
      }
    }
  }
  return min;
}

interface IActiveStreamsInfo {
  /**
   * Active Periods being currently actively loaded by the "Streams".
   * That is: either this Period's corresponding `Representation` has been
   * selected or we didn't chose any `Adaptation` for that type), in
   * chronological order.
   *
   * The first chronological Period in that list is the active one for
   * the current type.
   */
  activePeriods: SortedList<IPeriod>;
  /**
   * If `true` the last segment for the last currently known Period has been
   * pushed for the current Adaptation and Representation choice.
   */
  hasFinishedLoadingLastPeriod: boolean;
}

export type IContentTimeObserverPlaybackObservation = Pick<
  IStreamOrchestratorPlaybackObservation,
  "position"
>;
