import features from "../../features";
import log from "../../log";
import type { IContentInitializationData } from "../../main_thread/types";
import type { IManifest } from "../../manifest";
import type { IMediaSourceInterface } from "../../mse";
import MainMediaSourceInterface from "../../mse/main_media_source_interface";
import WorkerMediaSourceInterface from "../../mse/worker_media_source_interface";
import type { IPlayerError } from "../../public_types";
import idGenerator from "../../utils/id_generator";
import type { CancellationError, CancellationSignal } from "../../utils/task_canceller";
import TaskCanceller from "../../utils/task_canceller";
import type { IRepresentationEstimator } from "../adaptive";
import createAdaptiveRepresentationSelector from "../adaptive";
import type { IRepresentationEstimatorThrottlers } from "../adaptive/adaptive_representation_selector";
import CmcdDataBuilder from "../cmcd";
import type { IManifestRefreshSettings } from "../fetchers";
import { ManifestFetcher, SegmentQueueCreator } from "../fetchers";
import CdnPrioritizer from "../fetchers/cdn_prioritizer";
import createThumbnailFetcher from "../fetchers/thumbnails/thumbnail_fetcher";
import type { IThumbnailFetcher } from "../fetchers/thumbnails/thumbnail_fetcher";
import SegmentSinksStore from "../segment_sinks";
import type { IAttachMediaSourceCoreMessagePayload, ICoreMessage } from "../types";
import { CoreMessageType } from "../types";
import CoreTextDisplayerInterface from "./core_text_displayer_interface";
import FreezeResolver from "./FreezeResolver";
import TrackChoiceSetter from "./track_choice_setter";
import type { ICorePlugins } from "./utils";
import {
  extractExternalPlugins,
  formatErrorForSender,
  updateCodecSupportInWorkerMode,
} from "./utils";

/** Function allowing to associate a unique identifier to all created `MediaSource` */
const generateMediaSourceId = idGenerator();

/**
 * Class facilitating the workflows behind loading a new content for the
 * RxPlayer Core:
 *
 *   - Handle Manifest fetching and Manifest updates.
 *
 *   - Handle the `MediaSource`'s creation and indirectly of its `SourceBuffer`s
 *     as well as handling "MediaSource reloading".
 *
 *   - initialize various modules (`segmentQueueCreator`, CmcdDataBuilder`,
 *     `RepresentationEstimator`) linked to the initialized content.
 *
 * You can start loading a content through the `initializeNewContent` method.
 *
 * When a content is linked to the `ContentPreparer` you can inspect the
 * different initialized modules by calling its `getCurrentContent` method.
 *
 * @class ContentPreparer
 */
export default class ContentPreparer {
  /**
   * Information on the content linked to that `ContentPreparer` through its
   * `initializeNewContent` method.
   * `null` if no content is initialized.
   */
  private _currentContent: IPreparedContentData | null;
  /**
   * TaskCanceller which is triggered when the currently-initialized content is
   * not needed anymore, because we stopped it since or switched to a new content.
   */
  private _contentCanceller: TaskCanceller;
  /**
   * TaskCanceller which is triggered when the currently-created MediaSource is
   * not needed anymore, either because the content has changed or because we
   * had to reload.
   */
  private _currentMediaSourceCanceller: TaskCanceller;

  /** @see constructor */
  private _hasVideo: boolean;

  /**
   * @param {Object} capabilities
   * @param {boolean} capabilities.hasVideo - If `true`, we're playing on an
   * element which has video capabilities.
   * If `false`, we're only able to play audio, optionally with subtitles.
   *
   * Typically this boolean is `true` for `<video>` HTMLElement and `false` for
   * `<audio>` HTMLElement.
   */
  constructor({ hasVideo }: { hasVideo: boolean }) {
    this._currentContent = null;
    this._currentMediaSourceCanceller = new TaskCanceller("ContentPreparer MediaSource");
    this._hasVideo = hasVideo;
    const contentCanceller = new TaskCanceller("ContentPreparer");
    this._contentCanceller = contentCanceller;
  }

  /**
   * Start fetching the wanted content's Manifest and initializing the various
   * modules stored by the `ContentPreparer` linked to that content.
   *
   * The returned Promise resolves with the parsed Manifest when those modules
   * are all ready and you can thus begin to load the content.
   *
   * Reject if it failed to do so.
   * @param {Object} context - Information on the content that should be
   * initialized.
   * @param {Object} corePlugins - Callbacks that may have been registered by
   * the application if it loaded the core independently as a worker.
   * @returns {Promise.<Object>}
   */
  public initializeNewContent(
    sendMessage: (msg: ICoreMessage, transferables?: Transferable[]) => void,
    context: IContentInitializationData,
    /** Allows to filter which Representations can be choosen. */
    throttlers: IRepresentationEstimatorThrottlers,
    corePlugins: ICorePlugins,
  ): Promise<IManifest> {
    return new Promise((res, rej) => {
      this.disposeCurrentContent("new init");
      const contentCanceller = this._contentCanceller;
      const currentMediaSourceCanceller = new TaskCanceller(
        "ContentPreparer MediaSource",
      );
      this._currentMediaSourceCanceller = currentMediaSourceCanceller;

      currentMediaSourceCanceller.linkToSignal(contentCanceller.signal);

      const {
        contentId,
        url,
        hasText,
        transportOptions,
        useMseInWorker,
        enableRepresentationAvoidance,
        transport,
      } = context;
      let manifest: IManifest | null = null;

      const transportFn = features.transports[transport];
      if (typeof transportFn !== "function") {
        rej(
          new Error(
            `transport "${transport}" not supported. ` +
              "Did you add the corresponding feature?",
          ),
        );
        return;
      }

      const transportPipelines = transportFn({
        ...transportOptions,
        ...extractExternalPlugins(transportOptions, corePlugins),
      });

      const cmcdDataBuilder =
        context.cmcd === undefined ? null : new CmcdDataBuilder(context.cmcd);
      const manifestFetcher = new ManifestFetcher(
        url === undefined ? undefined : [url],
        transportPipelines,
        {
          cmcdDataBuilder,
          ...context.manifestRetryOptions,
        },
      );
      const representationEstimator = createAdaptiveRepresentationSelector({
        initialBitrates: {
          audio: context.initialAudioBitrate ?? 0,
          video: context.initialVideoBitrate ?? 0,
        },
        lowLatencyMode: transportOptions.lowLatencyMode,
        throttlers,
      });

      const unbindRejectOnCancellation = currentMediaSourceCanceller.signal.register(
        (error: CancellationError) => {
          rej(error);
        },
      );

      const cdnPrioritizer = new CdnPrioritizer(contentCanceller.signal);
      const segmentQueueCreator = new SegmentQueueCreator(
        transportPipelines,
        cdnPrioritizer,
        cmcdDataBuilder,
        context.segmentRetryOptions,
      );
      const fetchThumbnailData = createThumbnailFetcher(
        transportPipelines.thumbnails,
        cdnPrioritizer,
      );

      const trackChoiceSetter = new TrackChoiceSetter();

      const [mediaSource, segmentSinksStore, coreTextSender] =
        createMediaSourceInterfaceAndSegmentSinksStore(
          sendMessage,
          contentId,
          {
            useMseInWorker,
            hasVideo: this._hasVideo,
            hasText,
          },
          currentMediaSourceCanceller.signal,
        );
      const freezeResolver = new FreezeResolver(segmentSinksStore);
      this._currentContent = {
        cmcdDataBuilder,
        contentId,
        enableRepresentationAvoidance,
        freezeResolver,
        mediaSource,
        manifest: null,
        manifestFetcher,
        representationEstimator,
        segmentSinksStore,
        segmentQueueCreator,
        fetchThumbnailData,
        coreTextSender,
        trackChoiceSetter,
        useMseInWorker,
      };
      mediaSource.addEventListener(
        "mediaSourceOpen",
        function () {
          checkIfReadyAndValidate();
        },
        currentMediaSourceCanceller.signal,
      );

      contentCanceller.signal.register((err) => {
        manifestFetcher.dispose(err.reason);
      });
      manifestFetcher.addEventListener(
        "warning",
        (err: IPlayerError) => {
          sendMessage({
            type: CoreMessageType.Warning,
            contentId,
            value: formatErrorForSender(err),
          });
        },
        contentCanceller.signal,
      );
      manifestFetcher.addEventListener(
        "manifestReady",
        (man: IManifest) => {
          if (manifest !== null) {
            log.warn("Core", "Multiple `manifestReady` events, ignoring");
            return;
          }
          manifest = man;
          if (this._currentContent !== null) {
            this._currentContent.manifest = manifest;
          }
          checkIfReadyAndValidate();
        },
        currentMediaSourceCanceller.signal,
      );
      manifestFetcher.addEventListener(
        "error",
        (err: unknown) => {
          rej(err);
        },
        contentCanceller.signal,
      );
      manifestFetcher.start();

      function checkIfReadyAndValidate() {
        if (
          manifest === null ||
          mediaSource.readyState === "closed" ||
          currentMediaSourceCanceller.isUsed()
        ) {
          return;
        }
        updateCodecSupportInWorkerMode(manifest);
        manifest.addEventListener(
          "manifestUpdate",
          (updates) => {
            if (manifest === null) {
              // TODO log warn?
              return;
            }
            sendMessage({
              type: CoreMessageType.ManifestUpdate,
              contentId,
              value: { manifest, updates },
            });
          },
          contentCanceller.signal,
        );
        unbindRejectOnCancellation();
        res(manifest);
      }
    });
  }

  /**
   * Get information on the current content prepared through the
   * `initializeNewContent` method, or `null` if no content is currently
   * prepared.
   * @returns {Object|null}
   */
  public getCurrentContent(): IPreparedContentData | null {
    return this._currentContent;
  }

  /**
   * Schedule an update for the Manifest file,
   *
   * Do nothing if no content is currently prepared.
   * @param {Object} settings - Various settings to configure the ways and
   * moment at which the Manifest will be refreshed.
   */
  public scheduleManifestRefresh(settings: IManifestRefreshSettings): void {
    this._currentContent?.manifestFetcher.scheduleManualRefresh(settings);
  }

  /**
   * Change the MediaSource attached for the current content.
   * It is assumed that main thread is already notified that such a reload is
   * happening.
   *
   * The returned Promise resolves when it restarts being ready.
   * @param {Function} sendMessage
   * @returns {Promise}
   */
  public reloadMediaSource(
    sendMessage: (msg: ICoreMessage, transferables?: Transferable[]) => void,
  ): Promise<void> {
    this._currentMediaSourceCanceller.cancel("ContentPreparer MediaSource reload");
    if (this._currentContent === null) {
      return Promise.reject(new Error("CP: No content anymore"));
    }
    this._currentContent.trackChoiceSetter.reset();
    this._currentContent.coreTextSender?.stop("ContentPreparer MediaSource reload");
    this._currentMediaSourceCanceller = new TaskCanceller("ContentPreparer MediaSource");
    this._currentMediaSourceCanceller.linkToSignal(this._contentCanceller.signal);

    const [mediaSourceInterface, segmentSinksStore, coreTextSender] =
      createMediaSourceInterfaceAndSegmentSinksStore(
        sendMessage,
        this._currentContent.contentId,
        {
          useMseInWorker: this._currentContent.useMseInWorker,
          hasVideo: this._hasVideo,
          hasText: this._currentContent.coreTextSender !== null,
        },
        this._currentMediaSourceCanceller.signal,
      );
    this._currentContent.mediaSource = mediaSourceInterface;
    this._currentContent.segmentSinksStore = segmentSinksStore;
    this._currentContent.freezeResolver = new FreezeResolver(segmentSinksStore);
    this._currentContent.coreTextSender = coreTextSender;
    return new Promise((res, rej) => {
      mediaSourceInterface.addEventListener(
        "mediaSourceOpen",
        function () {
          res();
        },
        this._currentMediaSourceCanceller.signal,
      );
      mediaSourceInterface.addEventListener(
        "mediaSourceClose",
        function () {
          rej(new Error("MediaSource ReadyState changed to close during init."));
        },
        this._currentMediaSourceCanceller.signal,
      );
      this._currentMediaSourceCanceller.signal.register((error) => {
        rej(error);
      });
    });
  }

  /**
   * Dispose all resources linked to the currently preopared content if one and
   * stop linking it to this `ContentPreparer`.
   * @param {string | undefined} reason - Human-inspectable reason behind the
   * dispose. Used for debugging matters, especially for debug log
   * inspection.
   */
  public disposeCurrentContent(reason: string | undefined) {
    this._contentCanceller.cancel(reason);
    this._contentCanceller = new TaskCanceller("ContentPreparer");
  }
}

/**
 * Modules and Metadata associated to the current "prepared" content.
 */
export interface IPreparedContentData {
  /**
   * Identifier uniquely identifying a specific content.
   *
   * Protects against all kind of race conditions or asynchronous issues.
   */
  contentId: string;
  /**
   * Perform data collection and retrieval for the "Common Media Client Data"
   * scheme, which is a specification allowing to communicate about playback
   * conditions with a CDN.
   */
  cmcdDataBuilder: CmcdDataBuilder | null;
  /**
   * If `true`, the RxPlayer can enable its "Representation avoidance"
   * mechanism, where it avoid loading Representation that it suspect
   * have issues being decoded on the current device.
   */
  enableRepresentationAvoidance: boolean;
  /**
   * Interface to the MediaSource implementation, allowing to buffer audio
   * and video media segments.
   */
  mediaSource: IMediaSourceInterface;
  /** Class abstracting Manifest fetching and refreshing. */
  manifestFetcher: ManifestFetcher;
  /**
   * Manifest instance.
   *
   * `null` when not fetched yet.
   */
  manifest: IManifest | null;
  /**
   * Specific module detecting freezing issues and trying to work-around
   * them.
   */
  freezeResolver: FreezeResolver;
  /**
   * Perform the adaptive logic, allowing to choose the best Representation for
   * the different types of media to load.
   */
  representationEstimator: IRepresentationEstimator;
  /**
   * Allows to create a "SegmentSink" (powerful abstraction over media
   * buffering API) for each type of media.
   */
  segmentSinksStore: SegmentSinksStore;
  /** Allows to send timed text media data so it can be rendered. */
  coreTextSender: CoreTextDisplayerInterface | null;
  /**
   * Allows to create `SegmentQueue` which simplifies complex media segment
   * fetching.
   */
  segmentQueueCreator: SegmentQueueCreator;
  /** Allows to load image thumbnails. */
  fetchThumbnailData: IThumbnailFetcher;
  /**
   * Allows to store and update the wanted tracks and Representation inside that
   * track.
   */
  trackChoiceSetter: TrackChoiceSetter;
  /**
   * If `true`, MSE API should be used in the core part of the RxPlayer (in the
   * WebWorker).
   * If `false`, they should be relied on on main thread.
   */
  useMseInWorker: boolean;
}

/**
 * @param {Function} sendMessage
 * @param {string} contentId
 * @param {Object} capabilities
 * @param {boolean} capabilities.useMseInWorker
 * @param {boolean} capabilities.hasVideo
 * @param {boolean} capabilities.hasText
 * @param {Object} cancelSignal
 * @returns {Array.<Object>}
 */
function createMediaSourceInterfaceAndSegmentSinksStore(
  sendMessage: (msg: ICoreMessage, transferables?: Transferable[]) => void,
  contentId: string,
  capabilities: {
    useMseInWorker: boolean;
    hasVideo: boolean;
    hasText: boolean;
  },
  cancelSignal: CancellationSignal,
): [IMediaSourceInterface, SegmentSinksStore, CoreTextDisplayerInterface | null] {
  let mediaSourceInterface: IMediaSourceInterface;
  if (capabilities.useMseInWorker) {
    const mainMediaSource = new MainMediaSourceInterface(generateMediaSourceId());
    mediaSourceInterface = mainMediaSource;

    let sentMediaSourceLink: IAttachMediaSourceCoreMessagePayload;
    const handle = mainMediaSource.handle;
    if (handle.type === "handle") {
      sentMediaSourceLink = { type: "handle" as const, value: handle.value };
    } else {
      const url = URL.createObjectURL(handle.value);
      sentMediaSourceLink = { type: "url" as const, value: url };
      cancelSignal.register(() => {
        URL.revokeObjectURL(url);
      });
    }

    sendMessage(
      {
        type: CoreMessageType.AttachMediaSource,
        contentId,
        value: sentMediaSourceLink,
        mediaSourceId: mediaSourceInterface.id,
      },
      // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
      [handle.value as unknown as Transferable],
    );
  } else {
    mediaSourceInterface = new WorkerMediaSourceInterface(
      generateMediaSourceId(),
      contentId,
      sendMessage,
    );
  }

  const textSender = capabilities.hasText
    ? new CoreTextDisplayerInterface(contentId, sendMessage)
    : null;
  const { hasVideo } = capabilities;
  const segmentSinksStore = new SegmentSinksStore(
    mediaSourceInterface,
    hasVideo,
    textSender,
  );
  cancelSignal.register((err) => {
    segmentSinksStore.disposeAll(err.reason);
    textSender?.stop(err.reason);
    mediaSourceInterface.dispose(err.reason);
  });

  return [mediaSourceInterface, segmentSinksStore, textSender];
}
