/**
 * 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 config from "../../../config";
import type { ISegmentFetcher } from "../../../core/fetchers/segment/segment_fetcher";
import createSegmentFetcher from "../../../core/fetchers/segment/segment_fetcher";
import log from "../../../log";
import type { IRxPlayer } from "../../../main_thread/types";
import type { ISegment } from "../../../manifest";
import Manifest from "../../../manifest/classes";
import type { MainSourceBufferInterface } from "../../../mse/main_media_source_interface";
import arrayFind from "../../../utils/array_find";
import isNullOrUndefined from "../../../utils/is_null_or_undefined";
import objectAssign from "../../../utils/object_assign";
import TaskCanceller, { CancellationError } from "../../../utils/task_canceller";
import loadAndPushSegment from "./load_and_push_segment";
import prepareSourceBuffer from "./prepare_source_buffer";
import removeBufferAroundTime from "./remove_buffer_around_time";
import type { IContentInfo, ILoaders } from "./types";
import VideoThumbnailLoaderError from "./video_thumbnail_loader_error";

const MIN_NEEDED_DATA_AFTER_TIME = 2;

const loaders: ILoaders = {};

/**
 * This tool, as a supplement to the RxPlayer, intent to help creating thumbnails
 * from a video source.
 *
 * The tools will extract a "thumbnail track" either from a video track (whose light
 * chunks are adapted from such use case) or direclty from the media content.
 */
export default class VideoThumbnailLoader {
  private readonly _videoElement: HTMLVideoElement;
  private _player: IRxPlayer;
  private _lastRepresentationInfo: IVideoThumbnailLoaderRepresentationInfo | null;

  constructor(videoElement: HTMLVideoElement, player: IRxPlayer) {
    this._videoElement = videoElement;
    this._player = player;
    this._lastRepresentationInfo = null;
  }

  /**
   * Add imported loader to thumbnail loader loader object.
   * It allows to use it when setting time.
   * @param {function} loaderFunc
   */
  static addLoader(loaderFunc: (features: ILoaders) => void): void {
    loaderFunc(loaders);
  }

  /**
   * Set time of thumbnail video media element :
   * - Remove buffer when too much buffered data
   * - Search for thumbnail track element to display
   * - Load data
   * - Append data
   * Resolves when time is set.
   * @param {number} time
   * @returns {Promise}
   */
  setTime(time: number): Promise<number> {
    // TODO cleaner way to interop than an undocumented method?
    const manifest = this._player.__priv_getManifest();
    if (manifest === null) {
      if (this._lastRepresentationInfo !== null) {
        this._lastRepresentationInfo.cleaner.cancel("VTL: no manifest");
        this._lastRepresentationInfo = null;
      }
      return Promise.reject(
        new VideoThumbnailLoaderError("NO_MANIFEST", "No manifest available."),
      );
    }
    if (!(manifest instanceof Manifest)) {
      throw new Error(
        "Impossible to run VideoThumbnailLoader in the current context.\n" +
          "Are you running the RxPlayer in a WebWorker?",
      );
    }
    const content = getTrickModeInfo(time, manifest);
    if (content === null) {
      if (this._lastRepresentationInfo !== null) {
        this._lastRepresentationInfo.cleaner.cancel("VTL: no trickmode");
        this._lastRepresentationInfo = null;
      }
      return Promise.reject(
        new VideoThumbnailLoaderError(
          "NO_TRACK",
          "Couldn't find a trickmode track for this time.",
        ),
      );
    }

    if (
      this._lastRepresentationInfo !== null &&
      !areSameRepresentation(this._lastRepresentationInfo.content, content)
    ) {
      this._lastRepresentationInfo.cleaner.cancel("VTL: Representation is different");
      this._lastRepresentationInfo = null;
    }

    const neededSegments = content.representation.index.getSegments(
      time,
      MIN_NEEDED_DATA_AFTER_TIME,
    );

    if (neededSegments.length === 0) {
      if (this._lastRepresentationInfo !== null) {
        this._lastRepresentationInfo.cleaner.cancel("VTL: no segment info");
        this._lastRepresentationInfo = null;
      }
      return Promise.reject(
        new VideoThumbnailLoaderError(
          "NO_THUMBNAIL",
          "Couldn't find any thumbnail for the given time.",
        ),
      );
    }

    // Check which of `neededSegments` are already buffered
    for (let j = 0; j < neededSegments.length; j++) {
      const { time: stime, duration, timescale } = neededSegments[j];
      const start = stime / timescale;
      const end = start + duration / timescale;
      for (let i = 0; i < this._videoElement.buffered.length; i++) {
        if (
          this._videoElement.buffered.start(i) - 0.001 <= start &&
          this._videoElement.buffered.end(i) + 0.001 >= end
        ) {
          neededSegments.splice(j, 1);
          j--;
          break;
        }
      }
    }

    if (neededSegments.length === 0) {
      this._videoElement.currentTime = time;
      log.debug("VideoThumbnailLoader", "Thumbnails already loaded.", time);
      return Promise.resolve(time);
    }

    if (log.hasLevel("DEBUG")) {
      log.debug(
        "VideoThumbnailLoader",
        "Found thumbnail for time",
        time,
        neededSegments.map((s) => `start: ${s.time} - end: ${s.end}`).join(", "),
      );
    }

    const loader = loaders[content.manifest.transport];
    if (loader === undefined) {
      if (this._lastRepresentationInfo !== null) {
        this._lastRepresentationInfo.cleaner.cancel("VTL: no parser");
        this._lastRepresentationInfo = null;
      }
      return Promise.reject(
        new VideoThumbnailLoaderError(
          "NO_LOADER",
          "VideoThumbnailLoaderError: No imported loader for this transport type: " +
            content.manifest.transport,
        ),
      );
    }

    let lastRepInfo: IVideoThumbnailLoaderRepresentationInfo;
    if (this._lastRepresentationInfo === null) {
      const lastRepInfoCleaner = new TaskCanceller("VTL: Representation Info");
      const segmentFetcher = createSegmentFetcher({
        bufferType: "video",
        pipeline: loader.video,
        cdnPrioritizer: null,
        cmcdDataBuilder: null,
        requestOptions: {
          baseDelay: 0,
          maxDelay: 0,
          maxRetry: 0,
          requestTimeout: config.getCurrent().DEFAULT_REQUEST_TIMEOUT,
          connectionTimeout: config.getCurrent().DEFAULT_CONNECTION_TIMEOUT,
        },
        // We don't care about the SegmentFetcher's lifecycle events
        eventListeners: {},
      }) as ISegmentFetcher<ArrayBuffer | Uint8Array>;
      const initSegment = content.representation.index.getInitSegment();
      const initSegmentUniqueId =
        initSegment !== null ? content.representation.uniqueId : null;
      const sourceBufferProm = prepareSourceBuffer(
        this._videoElement,
        content.representation.getMimeTypeString(),
        lastRepInfoCleaner.signal,
      ).then(async (sourceBufferInterface) => {
        if (initSegment === null || initSegmentUniqueId === null) {
          lastRepInfo.initSegmentUniqueId = null;
          return sourceBufferInterface;
        }
        const segmentInfo = objectAssign(
          { segment: initSegment, nextSegment: undefined },
          content,
        );
        await loadAndPushSegment(
          segmentInfo,
          sourceBufferInterface,
          lastRepInfo.segmentFetcher,
          lastRepInfoCleaner.signal,
        );
        return sourceBufferInterface;
      });
      lastRepInfo = {
        cleaner: lastRepInfoCleaner,
        sourceBuffer: sourceBufferProm,
        content,
        initSegmentUniqueId,
        segmentFetcher,
        pendingRequests: [],
      };
      this._lastRepresentationInfo = lastRepInfo;
    } else {
      lastRepInfo = this._lastRepresentationInfo;
    }

    abortUnlistedSegmentRequests(lastRepInfo.pendingRequests, neededSegments);

    const currentTaskCanceller = new TaskCanceller("VTL: Task");

    return lastRepInfo.sourceBuffer
      .catch((err) => {
        if (this._lastRepresentationInfo !== null) {
          this._lastRepresentationInfo.cleaner.cancel(
            "VTL: SourceBuffer initialization failure",
          );
          this._lastRepresentationInfo = null;
        }
        throw new VideoThumbnailLoaderError(
          "LOADING_ERROR",
          "VideoThumbnailLoaderError: Error when initializing buffers: " + String(err),
        );
      })
      .then(async (sourceBufferInterface) => {
        abortUnlistedSegmentRequests(lastRepInfo.pendingRequests, neededSegments);

        log.debug("VideoThumbnailLoader", "Removing buffer around time.", time);
        await removeBufferAroundTime(
          this._videoElement,
          sourceBufferInterface,
          time,
          undefined,
        );
        if (currentTaskCanceller.signal.cancellationError !== null) {
          throw currentTaskCanceller.signal.cancellationError;
        }

        abortUnlistedSegmentRequests(lastRepInfo.pendingRequests, neededSegments);
        const promises: Array<Promise<unknown>> = [];
        for (const segment of neededSegments) {
          const pending = arrayFind(
            lastRepInfo.pendingRequests,
            ({ segmentId }) => segmentId === segment.id,
          );
          if (pending !== undefined) {
            promises.push(pending.promise);
          } else {
            const requestCanceller = new TaskCanceller("VTL: Request");
            const unlinkSignal = requestCanceller.linkToSignal(
              lastRepInfo.cleaner.signal,
            );
            const segmentInfo = objectAssign(
              { segment, nextSegment: undefined },
              content,
            );
            const prom = loadAndPushSegment(
              segmentInfo,
              sourceBufferInterface,
              lastRepInfo.segmentFetcher,
              requestCanceller.signal,
            ).then(unlinkSignal, (err) => {
              unlinkSignal();
              throw err;
            });
            const newReq = {
              segmentId: segment.id,
              canceller: requestCanceller,
              promise: prom,
            };
            lastRepInfo.pendingRequests.push(newReq);

            const removePendingRequest = () => {
              const indexOf = lastRepInfo.pendingRequests.indexOf(newReq);
              if (indexOf >= 0) {
                lastRepInfo.pendingRequests.splice(indexOf, 1);
              }
            };
            prom.then(removePendingRequest, removePendingRequest);
            promises.push(prom);
          }
        }
        await Promise.all(promises);
        this._videoElement.currentTime = time;
        return time;
      })
      .catch((err) => {
        if (err instanceof CancellationError) {
          throw new VideoThumbnailLoaderError(
            "ABORTED",
            "VideoThumbnailLoaderError: Aborted job.",
          );
        }
        throw err;
      });
  }

  /**
   * Dispose thumbnail loader.
   * @returns {void}
   */
  dispose(): void {
    if (this._lastRepresentationInfo !== null) {
      this._lastRepresentationInfo.cleaner.cancel("VTL: dispose");
      this._lastRepresentationInfo = null;
    }
  }
}

/**
 * @param {Object} contentInfo1
 * @param {Object} contentInfo2
 * @returns {Boolean}
 */
function areSameRepresentation(
  contentInfo1: IContentInfo,
  contentInfo2: IContentInfo,
): boolean {
  return (
    contentInfo1.representation.id === contentInfo2.representation.id &&
    contentInfo1.adaptation.id === contentInfo2.adaptation.id &&
    contentInfo1.period.id === contentInfo2.period.id &&
    contentInfo1.manifest.id === contentInfo2.manifest.id
  );
}

/**
 * From a given time, find the trickmode representation and return
 * the content information.
 * @param {number} time
 * @param {Object} manifest
 * @returns {Object|null}
 */
function getTrickModeInfo(time: number, manifest: Manifest): IContentInfo | null {
  const period = manifest.getPeriodForTime(time);
  if (
    period === undefined ||
    period.adaptations.video === undefined ||
    period.adaptations.video.length === 0
  ) {
    return null;
  }
  for (const videoAdaptation of period.adaptations.video) {
    const representation = videoAdaptation.trickModeTracks?.[0].representations?.[0];
    if (!isNullOrUndefined(representation)) {
      return { manifest, period, adaptation: videoAdaptation, representation };
    }
  }
  return null;
}

function abortUnlistedSegmentRequests(
  pendingRequests: IPendingRequestInfo[],
  neededSegments: ISegment[],
): void {
  pendingRequests
    .filter((req) => !neededSegments.some(({ id }) => id === req.segmentId))
    .forEach((req) => {
      req.canceller.cancel("VTL: abort unlisted segments");
    });
}

/**
 * Object containing information stored by a `VideoThumbnailLoader` linked to
 * a single chosen Representation.
 */
interface IVideoThumbnailLoaderRepresentationInfo {
  /**
   * TaskCanceller allowing on cancellation to Stop everything, free resources
   * allocated for the current Manifest and remove MediaSource from the video
   * Element the VideoThumbnailLoader is associated with.
   */
  cleaner: TaskCanceller;
  /**
   * Promise encapsulating the task of creating the MediaSource, the video
   * `SourceBufferInterface`, and pushing the initialization segment of the
   * current content on it.
   *
   * Resolves when done, rejects if any of those steps fail.
   */
  sourceBuffer: Promise<MainSourceBufferInterface>;
  /**
   * Information on the content considered in this
   * `IVideoThumbnailLoaderRepresentationInfo`.
   */
  content: IContentInfo;
  /**
   * `ISegmentFetcher` used to fetch video media segments for the current
   * Representation.
   */
  segmentFetcher: ISegmentFetcher<ArrayBuffer | Uint8Array>;
  /**
   * List video media segment requests AND pushing (on the buffer) operations
   * that are currently pending.
   *
   * Once a segment is loaded and pushed with success, it is removed from
   * `pendingRequests`.
   */
  pendingRequests: IPendingRequestInfo[];
  initSegmentUniqueId: string | null;
}

interface IPendingRequestInfo {
  /** `id` property of the `ISegment` concerned. */
  segmentId: string;
  /**
   * When this promise resolves, the segment is both loaded and pushed with
   * success.
   *
   * If it rejects, we could not either load or push the segment.
   */
  promise: Promise<unknown>;
  /**
   * Allows to stop loading and/or pushing the segment.
   */
  canceller: TaskCanceller;
}

export { default as DASH_LOADER } from "./features/dash";
export { default as MPL_LOADER } from "./features/metaplaylist";
