/**
 * 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.
 */

/**
 * /!\ This file is feature-switchable.
 * It always should be imported through the `features` object.
 */

import type { IMediaElement } from "../../compat/browser_compatibility_types";
import clearElementSrc from "../../compat/clear_element_src";
import getStartDate from "../../compat/get_start_date";
import type { MediaError } from "../../errors";
import log from "../../log";
import type { IMediaElementPlaybackObserver } from "../../playback_observer";
import type { IKeySystemOption, IPlayerError } from "../../public_types";
import assert from "../../utils/assert";
import isNullOrUndefined from "../../utils/is_null_or_undefined";
import noop from "../../utils/noop";
import type { IReadOnlySharedReference } from "../../utils/reference";
import type { CancellationSignal } from "../../utils/task_canceller";
import TaskCanceller from "../../utils/task_canceller";
import { ContentInitializer } from "./types";
import type { IInitialTimeOptions } from "./utils/get_initial_time";
import getLoadedReference from "./utils/get_loaded_reference";
import performInitialSeekAndPlay from "./utils/initial_seek_and_play";
import initializeContentDecryption from "./utils/initialize_content_decryption";
import RebufferingController from "./utils/rebuffering_controller";
import listenToMediaError from "./utils/throw_on_media_error";

/**
 * `ContentIntializer` which will load contents by putting their URL in the
 * `src` attribute of the given HTMLMediaElement.
 *
 * Because such contents are mainly loaded by the browser, those (called
 * "directfile" contents in the RxPlayer) needs a simpler logic in-JS when
 * compared to a content that relies on the MSE API.
 *
 * @class DirectFileContentInitializer
 */
export default class DirectFileContentInitializer extends ContentInitializer {
  /**
   * Initial options given to the `DirectFileContentInitializer`.
   */
  private _settings: IDirectFileOptions;
  /**
   * Allows to abort and clean everything the `DirectFileContentInitializer` is
   * doing.
   */
  private _initCanceller: TaskCanceller;

  /**
   * Creates a new `DirectFileContentInitializer` linked to the given settings.
   * @param {Object} settings
   */
  constructor(settings: IDirectFileOptions) {
    super();
    this._settings = settings;
    this._initCanceller = new TaskCanceller("Directfile Init");
  }

  /**
   * "Prepare" content so it can later be played by calling `start`.
   */
  public prepare(): void {
    return; // Directfile contents do not have any preparation
  }

  /**
   * Start playback of the content linked to this `DirectFileContentInitializer`
   * on the given `HTMLMediaElement` and its associated `PlaybackObserver`.
   * @param {HTMLMediaElement} mediaElement - HTMLMediaElement on which the
   * content will be played.
   * @param {Object} playbackObserver - Object regularly emitting playback
   * information.
   */
  public start(
    mediaElement: IMediaElement,
    playbackObserver: IMediaElementPlaybackObserver,
  ): void {
    const cancelSignal = this._initCanceller.signal;
    const { keySystems, speed, url } = this._settings;

    clearElementSrc(mediaElement);

    // Set the autoplay attribute on the mediaElement.
    // On Apple devices, the native HLS player needs autoplay to be set
    // in order to start buffering,which is required for our API's autoplay to work.
    setAutoplay(mediaElement, this._settings.autoPlay, cancelSignal);

    const { statusRef: drmInitRef } = initializeContentDecryption(
      mediaElement,
      keySystems,
      {
        onError: (err) => this._onFatalError(err),
        onWarning: (err: IPlayerError) => this.trigger("warning", err),
        onBlackListProtectionData: noop,
        onKeyIdsCompatibilityUpdate: noop,
      },
      cancelSignal,
    );

    /** Translate errors coming from the media element into RxPlayer errors. */
    listenToMediaError(
      mediaElement,
      (error: MediaError) => this._onFatalError(error),
      cancelSignal,
    );

    /**
     * Class trying to avoid various stalling situations, emitting "stalled"
     * events when it cannot, as well as "unstalled" events when it get out of one.
     */
    const rebufferingController = new RebufferingController(
      playbackObserver,
      null,
      speed,
    );
    rebufferingController.addEventListener("stalled", (evt) =>
      this.trigger("stalled", evt),
    );
    rebufferingController.addEventListener("unstalled", () =>
      this.trigger("unstalled", null),
    );
    rebufferingController.addEventListener("warning", (err) =>
      this.trigger("warning", err),
    );
    cancelSignal.register((err) => {
      rebufferingController.destroy(err.reason);
    });
    rebufferingController.start();

    drmInitRef.onUpdate(
      (evt, stopListeningToDrmUpdates) => {
        if (evt.initializationState.type === "uninitialized") {
          return; // nothing done yet
        }
        stopListeningToDrmUpdates();

        // Start everything! (Just put the URL in the element's src).
        log.info("Init", "Setting URL to HTMLMediaElement", { url });
        mediaElement.src = url;
        cancelSignal.register(() => {
          log.info("Init", "Removing directfile src from media element", {
            src: mediaElement.src,
          });
          clearElementSrc(mediaElement);
        });

        if (evt.initializationState.type === "awaiting-media-link") {
          evt.initializationState.value.isMediaLinked.setValue(true);
          drmInitRef.onUpdate(
            (newDrmStatus, stopListeningToDrmUpdatesAgain) => {
              if (newDrmStatus.initializationState.type === "initialized") {
                stopListeningToDrmUpdatesAgain();
                this._seekAndPlay(mediaElement, playbackObserver);
              }
            },
            { emitCurrentValue: true, clearSignal: cancelSignal },
          );
        } else {
          assert(evt.initializationState.type === "initialized");
          this._seekAndPlay(mediaElement, playbackObserver);
        }
      },
      { emitCurrentValue: true, clearSignal: cancelSignal },
    );
  }

  /**
   * Update URL this `ContentIntializer` depends on.
   * @param {Array.<string>|undefined} _urls
   * @param {boolean} _refreshNow
   */
  public updateContentUrls(_urls: string[] | undefined, _refreshNow: boolean): void {
    throw new Error("Cannot update content URL of directfile contents");
  }

  /**
   * Stop content and free all resources linked to this `ContentIntializer`.
   * @param {string | undefined} reason - Human-inspectable reason behind the
   * dispose. Used for debugging matters, especially for debug log
   * inspection.
   */
  public dispose(reason: string | undefined): void {
    this._initCanceller.cancel(reason ?? "Directfile Init dispose");
  }

  /**
   * Logic performed when a fatal error was triggered.
   * @param {*} err - The fatal error in question.
   */
  private _onFatalError(err: unknown): void {
    this._initCanceller.cancel("Directfile Init err");
    this.trigger("error", err);
  }

  /**
   * Perform the initial seek (to begin playback at an initially-calculated
   * position based on settings) and auto-play if needed when loaded.
   * @param {HTMLMediaElement} mediaElement
   * @param {Object} playbackObserver
   */
  private _seekAndPlay(
    mediaElement: IMediaElement,
    playbackObserver: IMediaElementPlaybackObserver,
  ): void {
    const cancelSignal = this._initCanceller.signal;
    const { autoPlay, startAt } = this._settings;
    const initialTime = () => {
      log.debug("Init", "Calculating initial time");
      const initTime = getDirectFileInitialTime(mediaElement, startAt);
      log.debug("Init", "Initial time calculated", { initialTime: initTime });
      return initTime;
    };
    performInitialSeekAndPlay(
      {
        mediaElement,
        playbackObserver,
        startTime: initialTime,
        mustAutoPlay: autoPlay,
        onWarning: (err) => this.trigger("warning", err),
        isDirectfile: true,
      },
      cancelSignal,
    )
      .autoPlayResult.then(() =>
        getLoadedReference(playbackObserver, true, cancelSignal).onUpdate(
          (isLoaded, stopListening) => {
            if (isLoaded) {
              stopListening();
              this.trigger("loaded", {
                getSegmentSinkMetrics: null,
                getThumbnailData: () =>
                  Promise.reject(
                    new Error("Thumbnail data not available with directfile contents"),
                  ),
              });
            }
          },
          { emitCurrentValue: true, clearSignal: cancelSignal },
        ),
      )
      .catch((err) => {
        if (!cancelSignal.isCancelled()) {
          this._onFatalError(err);
        }
      });
  }
}

/**
 * Set autoplay value on the mediaElement.
 *
 * @param {HTMLElement} mediaElement - The media element whose `autoplay`
 * attribute will be modified.
 * @param {CancellationSignal} cancellationSignal - The signal that, when triggered,
 * restores the `autoplay` attribute to its original value.
 */
export function setAutoplay(
  mediaElement: IMediaElement,
  autoplay: boolean,
  cancellationSignal: CancellationSignal,
) {
  if (!autoplay) {
    // If autoplay option is set to false, don't touch to `autoplay`
    // videoElement attribute.
    return;
  }
  const autoplayPreviousValue = mediaElement.autoplay;
  mediaElement.autoplay = autoplay;
  cancellationSignal.register(() => {
    /**
     * Restore the `autoplay` attribute to its previous value.
     * This ensures that the media element's state is the same as it was before
     * calling `RxPlayer.loadVideo` in the application.
     */
    mediaElement.autoplay = autoplayPreviousValue;
  });
}

/**
 * calculate initial time as a position in seconds.
 * @param {HTMLMediaElement} mediaElement
 * @param {Object|undefined} [startAt]
 * @returns {number}
 */
function getDirectFileInitialTime(
  mediaElement: IMediaElement,
  startAt?: IInitialTimeOptions,
): number | undefined {
  if (isNullOrUndefined(startAt)) {
    return 0;
  }

  if (!isNullOrUndefined(startAt.position)) {
    return startAt.position;
  } else if (!isNullOrUndefined(startAt.wallClockTime)) {
    /**
     * Special case for Safari with HLS in directfile mode:
     *
     * Safari's native HLS implementation uses `#EXT-X-PROGRAM-DATE-TIME` from the manifest
     * as the time reference. This means `mediaElement.currentTime` represents time relative
     * to the playlist's start date.
     *
     * When seeking to a specific `wallClockTime`, we calculate the position by subtracting
     * the playlist's start date from `startPosition.wallClockTime`:
     *   seekPosition = wallClockTime - startDate
     *
     * The startDate is retrieved via `mediaElement.getStartDate()`, which only returns
     * a valid date once `mediaElement.readyState >= HAVE_CURRENT_DATA` (≥ 2).
     * If unavailable, we return undefined.
     */
    const startDate = getStartDate(mediaElement);
    if (startDate === undefined) {
      log.warn(
        "Init",
        "startAt.wallClockTime set but startDate is not known, either it's too soon to seek" +
          " or the browser has no API to get the startDate",
      );
      return undefined;
    }
    return startAt.wallClockTime - startDate;
  } else if (!isNullOrUndefined(startAt.fromFirstPosition)) {
    return startAt.fromFirstPosition;
  }

  const duration = mediaElement.duration;

  const getLastPosition = () => {
    if (mediaElement.seekable.length > 0) {
      const lastSegmentEnd = mediaElement.seekable.end(mediaElement.seekable.length - 1);
      if (isFinite(lastSegmentEnd)) {
        return lastSegmentEnd;
      }
    }
    if (!isNullOrUndefined(duration) && isFinite(duration)) {
      return duration;
    }
    return undefined;
  };

  if (typeof startAt.fromLastPosition === "number") {
    const lastPosition = getLastPosition();
    if (!isNullOrUndefined(lastPosition)) {
      return Math.max(0, lastPosition + startAt.fromLastPosition);
    }
    log.warn(
      "Init",
      "startAt.fromLastPosition set but duration is not known, " +
        "it may be too soon to seek",
    );
    return undefined;
  } else if (typeof startAt.fromLivePosition === "number") {
    // Directfile contents do not expose a distinct live edge here, so
    // `fromLivePosition` falls back to the same reference point as `fromLastPosition`.
    const livePosition = getLastPosition();
    if (!isNullOrUndefined(livePosition)) {
      return Math.max(0, livePosition + startAt.fromLivePosition);
    }
    log.warn(
      "Init",
      "startAt.fromLivePosition set but live position is not known, " +
        "it may be too soon to seek",
    );

    return undefined;
  } else if (!isNullOrUndefined(startAt.percentage)) {
    if (isNullOrUndefined(duration) || !isFinite(duration)) {
      log.warn(
        "Init",
        "startAt.percentage set but duration is not known, " + "beginning at 0.",
      );
      return 0;
    }
    const { percentage } = startAt;
    if (percentage >= 100) {
      return duration;
    } else if (percentage <= 0) {
      return 0;
    }
    const ratio = +percentage / 100;
    return duration * ratio;
  }

  return 0;
}

/** Options used by the `DirectFileContentInitializer` */
export interface IDirectFileOptions {
  /** If `true` we will play right after the content is considered "loaded". */
  autoPlay: boolean;
  /**
   * Encryption-related settings. Can be left as an empty array if the content
   * isn't encrypted.
   */
  keySystems: IKeySystemOption[];
  /** Communicate the playback rate wanted by the user. */
  speed: IReadOnlySharedReference<number>;
  /** Optional initial position to start at. */
  startAt?: IInitialTimeOptions | undefined;
  /** URL that should be played. */
  url: string;
}
