import type { IMediaSource, ISourceBuffer } from "../compat/browser_compatibility_types";
import BROWSER_GLOBALS from "../compat/browser_compatibility_types";
import tryToChangeSourceBufferType from "../compat/change_source_buffer_type";
import { onSourceClose, onSourceEnded, onSourceOpen } from "../compat/event_listeners";
import { MediaError, SourceBufferError } from "../errors";
import log from "../log";
import { concat } from "../utils/byte_parsing";
import EventEmitter from "../utils/event_emitter";
import isNullOrUndefined from "../utils/is_null_or_undefined";
import objectAssign from "../utils/object_assign";
import type { IRange } from "../utils/ranges";
import { convertToRanges } from "../utils/ranges";
import TaskCanceller, { CancellationError } from "../utils/task_canceller";
import type {
  IMediaSourceHandle,
  IMediaSourceInterface,
  IMediaSourceInterfaceEvents,
  ISourceBufferInterface,
  ISourceBufferInterfaceAppendBufferParameters,
  SourceBufferType,
} from "./types";
import { maintainEndOfStream } from "./utils/end_of_stream";
import MediaSourceDurationUpdater from "./utils/media_source_duration_updater";

/**
 * `IMediaSourceInterface` object for when the MSE API are directly available.
 * @see IMediaSourceInterface
 * @class {MainMediaSourceInterface}
 */
export default class MainMediaSourceInterface
  extends EventEmitter<IMediaSourceInterfaceEvents>
  implements IMediaSourceInterface
{
  /** @see IMediaSourceInterface */
  public id: string;
  /**
   * @see IMediaSourceInterface
   *
   * Note: A `MainMediaSourceInterface`'s handle is never `undefined`.
   * It can thus always be relied on when linking it to an `HTMLMediaElement`.
   */
  public handle: IMediaSourceHandle;
  /** @see IMediaSourceInterface */
  public sourceBuffers: MainSourceBufferInterface[];
  /** @see IMediaSourceInterface */
  public readyState: ReadyState;
  /**
   * The `ManagedMediaSource.streaming` attribute
   * Indicates whether the user agent believes it has enough buffered data to ensure
   * uninterrupted playback for a meaningful period or needs more data.
   * It also reflects whether the user agent can retrieve and buffer data in an
   * energy-efficient manner while maintaining the desired memory usage.
   * The value can be `undefined` if the user agent does not provide this indicator.
   * `true` indicates that the buffer is low, and more data should be buffered.
   * `false` indicates that there is enough buffered data, and no additional data needs
   *  to be buffered at this time.
   */
  public streaming?: boolean;
  /** The MSE `MediaSource` instance linked to that `IMediaSourceInterface`. */
  private _mediaSource: IMediaSource;
  /**
   * Abstraction allowing to set and update the MediaSource's duration.
   */
  private _durationUpdater: MediaSourceDurationUpdater;
  /**
   * Only set if there is an `endOfStream` operation pending.
   *
   * Allows to abort it.
   */
  private _endOfStreamCanceller: TaskCanceller | null;
  /**
   * Allows to clean-up long-running operation when the `IMediaSourceInterface`
   * is dispossed
   */
  private _canceller: TaskCanceller;

  /**
   * Creates a new `MainMediaSourceInterface` alongside its `MediaSource` MSE
   * object.
   *
   * You can then obtain a link to that `MediaSource`, for example to link it
   * to an `HTMLMediaElement`, through the `handle` property.
   */
  constructor(id: string, forcedMediaSource?: new () => IMediaSource) {
    super();
    this.id = id;
    this.sourceBuffers = [];
    this._canceller = new TaskCanceller("MainMediaSourceInterface");

    const { MediaSource_ } = BROWSER_GLOBALS;
    if (isNullOrUndefined(MediaSource_)) {
      throw new MediaError(
        "MEDIA_SOURCE_NOT_SUPPORTED",
        "No MediaSource Object was found in the current browser.",
      );
    }

    log.info("mse", "Creating MediaSource");
    const mediaSource =
      forcedMediaSource !== undefined ? new forcedMediaSource() : new MediaSource_();
    const handle = (mediaSource as unknown as { handle: MediaProvider }).handle;
    this.handle = isNullOrUndefined(handle)
      ? // eslint-disable-next-line @typescript-eslint/no-restricted-types
        { type: "media-source", value: mediaSource as unknown as MediaSource }
      : { type: "handle", value: handle };
    this._mediaSource = mediaSource;
    this.readyState = mediaSource.readyState;
    this._durationUpdater = new MediaSourceDurationUpdater(mediaSource);
    this._endOfStreamCanceller = null;
    onSourceOpen(
      mediaSource,
      () => {
        this.readyState = mediaSource.readyState;
        this.trigger("mediaSourceOpen", null);
      },
      this._canceller.signal,
    );
    onSourceEnded(
      mediaSource,
      () => {
        this.readyState = mediaSource.readyState;
        this.trigger("mediaSourceEnded", null);
      },
      this._canceller.signal,
    );
    onSourceClose(
      mediaSource,
      () => {
        this.readyState = mediaSource.readyState;
        this.trigger("mediaSourceClose", null);
      },
      this._canceller.signal,
    );
    if (this._mediaSource.streaming !== undefined) {
      this.streaming = this._mediaSource.streaming;
    }
    this._mediaSource.addEventListener("startstreaming", () => {
      this.streaming = true;
      this.trigger("streamingChanged", null);
    });
    this._mediaSource.addEventListener("endstreaming", () => {
      this.streaming = false;
      this.trigger("streamingChanged", null);
    });
  }

  /** @see IMediaSourceInterface */
  public addSourceBuffer(
    sbType: SourceBufferType,
    codec: string,
  ): MainSourceBufferInterface {
    const sourceBuffer = this._mediaSource.addSourceBuffer(codec);
    const sb = new MainSourceBufferInterface(sbType, codec, sourceBuffer);
    this.sourceBuffers.push(sb);
    return sb;
  }

  /** @see IMediaSourceInterface */
  public setDuration(newDuration: number, isRealEndKnown: boolean): void {
    this._durationUpdater.updateDuration(newDuration, isRealEndKnown);
  }

  /** @see IMediaSourceInterface */
  public interruptDurationSetting(reason: string | undefined) {
    this._durationUpdater.stopUpdating(reason);
  }

  /** @see IMediaSourceInterface */
  public maintainEndOfStream() {
    if (this._endOfStreamCanceller === null) {
      this._endOfStreamCanceller = new TaskCanceller(
        "MainMediaSourceInterface EndOfStream",
      );
      this._endOfStreamCanceller.linkToSignal(this._canceller.signal);
      log.debug("mse", "end-of-stream order received.");
      maintainEndOfStream(this._mediaSource, this._endOfStreamCanceller.signal);
    }
  }

  /** @see IMediaSourceInterface */
  public stopEndOfStream() {
    if (this._endOfStreamCanceller !== null) {
      log.debug("mse", "resume-stream order received.");
      this._endOfStreamCanceller.cancel("MediaSourceInterface stopEndOfStream");
      this._endOfStreamCanceller = null;
    }
  }

  /** @see IMediaSourceInterface */
  public dispose(reason: string | undefined) {
    this.sourceBuffers.forEach((s) => s.dispose(reason));
    this._canceller.cancel(reason ?? "MainMediaSourceInterface dispose");
    resetMediaSource(this._mediaSource);
  }
}

/**
 * `ISourceBufferInterface` object for when the MSE API are directly available.
 * @see ISourceBufferInterface
 * @class {MainSourceBufferInterface}
 */
export class MainSourceBufferInterface implements ISourceBufferInterface {
  /** @see ISourceBufferInterface */
  public codec: string;
  /** @see ISourceBufferInterface */
  public type: SourceBufferType;
  /**
   * Allows to clean-up long-running operation when the `ISourceBufferInterface`
   * is dispossed
   */
  private _canceller: TaskCanceller;
  /** The MSE `SourceBuffer` instance linked to that `ISourceBufferInterface`. */
  private _sourceBuffer: ISourceBuffer;
  /**
   * Queue of operations, from the most to the least urgent, currently waiting
   * their turn to be performed on the `SourceBuffer`.
   */
  private _operationQueue: ISbiQueuedOperation[];
  /**
   * Operation currently performed on the `SourceBuffer`, for which we're
   * awaiting an event to be notified of its success or failure.
   *
   * `null` if no known operation is pending.
   */
  private _currentOperations: Array<Omit<ISbiQueuedOperation, "params">>;

  /**
   * Creates a new `SourceBufferInterface` linked to the given `SourceBuffer`
   * instance.
   * @param {string} sbType
   * @param {string} codec
   * @param {SourceBuffer} sourceBuffer
   */
  constructor(sbType: SourceBufferType, codec: string, sourceBuffer: ISourceBuffer) {
    this.type = sbType;
    this.codec = codec;
    this._canceller = new TaskCanceller("MainSourceBufferInterface " + sbType);
    this._sourceBuffer = sourceBuffer;
    this._operationQueue = [];
    this._currentOperations = [];

    const onError = this._onError.bind(this);
    const onUpdateEnd = this._onUpdateEnd.bind(this);
    sourceBuffer.addEventListener("updateend", onUpdateEnd);
    sourceBuffer.addEventListener("error", onError);
    this._canceller.signal.register(() => {
      sourceBuffer.removeEventListener("updateend", onUpdateEnd);
      sourceBuffer.removeEventListener("error", onError);
    });
  }

  /** @see ISourceBufferInterface */
  public appendBuffer(
    ...args: Parameters<ISourceBufferInterface["appendBuffer"]>
  ): Promise<IRange[]> {
    log.debug("mse", "receiving order to push data to the SourceBuffer", {
      type: this.type,
    });
    return this._addToQueue({
      operationName: SbiOperationName.Push,
      params: args,
    });
  }

  /** @see ISourceBufferInterface */
  public remove(start: number, end: number): Promise<IRange[]> {
    log.debug("mse", "receiving order to remove data from the SourceBuffer", {
      type: this.type,
      start,
      end,
    });
    return this._addToQueue({
      operationName: SbiOperationName.Remove,
      params: [start, end],
    });
  }

  /** @see ISourceBufferInterface */
  public getBuffered(): IRange[] {
    try {
      return convertToRanges(this._sourceBuffer.buffered);
    } catch (err) {
      log.error(
        "mse",
        "Failed to get buffered time range of SourceBuffer",
        {
          type: this.type,
        },
        err instanceof Error ? err : "Unknown Error",
      );
      return [];
    }
  }

  /** @see ISourceBufferInterface */
  public abort(reason: string | undefined): void {
    try {
      this._sourceBuffer.abort();
    } catch (err) {
      log.debug(
        "mse",
        "Failed to abort SourceBuffer:",
        err instanceof Error ? err : "Unknown Error",
      );
    }
    this._emptyCurrentQueue(reason);
  }

  /** @see ISourceBufferInterface */
  public dispose(reason: string | undefined): void {
    try {
      this._sourceBuffer.abort();
    } catch (_) {
      // we don't care
    }
    this._emptyCurrentQueue(reason);
  }

  private _onError(evt: Event) {
    let error: Error;
    if ((evt as unknown as Error) instanceof Error) {
      error = evt as unknown as Error;
    } else if ((evt as unknown as { error: Error }).error instanceof Error) {
      error = (evt as unknown as { error: Error }).error;
    } else {
      error = new Error("Unknown SourceBuffer Error");
    }
    const currentOps = this._currentOperations;
    this._currentOperations = [];
    if (currentOps.length === 0) {
      log.error("mse", "error for an unknown operation", error);
    } else {
      const rejected = new SourceBufferError(
        error.name,
        error.message,
        error.name === "QuotaExceededError",
      );
      for (const op of currentOps) {
        op.reject(rejected);
      }
    }
  }

  private _onUpdateEnd() {
    const currentOps = this._currentOperations;
    this._currentOperations = [];
    try {
      for (const op of currentOps) {
        op.resolve(convertToRanges(this._sourceBuffer.buffered));
      }
    } catch (err) {
      for (const op of currentOps) {
        if (err instanceof Error && err.name === "InvalidStateError") {
          // Most likely the SourceBuffer just has been removed from the
          // `MediaSource`.
          // Just return an empty buffered range.
          op.resolve([]);
        } else {
          op.reject(err);
        }
      }
    }
    this._performNextOperation();
  }

  /**
   * @param {string | undefined} reason - Human-inspectable reason behind the
   * action. Used for debugging matters, especially for debug log
   * inspection.
   */
  private _emptyCurrentQueue(reason: string | undefined): void {
    const error = new CancellationError(
      "MainSourceBufferInterface queue " + this.type,
      reason,
    );
    if (this._currentOperations.length > 0) {
      this._currentOperations.forEach((op) => {
        op.reject(error);
      });
      this._currentOperations = [];
    }
    if (this._operationQueue.length > 0) {
      this._operationQueue.forEach((op) => {
        op.reject(error);
      });
      this._operationQueue = [];
    }
  }

  private _addToQueue(
    operation: Pick<ISbiQueuedOperation, "operationName" | "params">,
  ): Promise<IRange[]> {
    return new Promise<IRange[]>((resolve, reject) => {
      const shouldRestartQueue =
        this._operationQueue.length === 0 && this._currentOperations.length === 0;
      const queueItem = objectAssign(
        { resolve, reject },
        operation,
      ) as ISbiQueuedOperation;
      this._operationQueue.push(queueItem);
      if (shouldRestartQueue) {
        this._performNextOperation();
      }
    });
  }

  private _performNextOperation(): void {
    if (this._currentOperations.length !== 0 || this._sourceBuffer.updating) {
      return;
    }
    const nextElem = this._operationQueue.shift();
    if (nextElem === undefined) {
      return;
    } else if (nextElem.operationName === SbiOperationName.Push) {
      this._currentOperations = [
        {
          operationName: SbiOperationName.Push,
          resolve: nextElem.resolve,
          reject: nextElem.reject,
        },
      ];
      const ogData = nextElem.params[0];
      const params = nextElem.params[1];
      let segmentData: BufferSource = ogData;

      // In some cases with very poor performances, tens of appendBuffer
      // requests could be waiting for their turn here.
      //
      // Instead of pushing each one, one by one, waiting in-between for each
      // one's `"updateend"` event (which would probably have lot of time
      // overhead involved, even more considering that we're probably
      // encountering performance issues), the idea is to concatenate all
      // similar push operations into one huge segment.
      //
      // This seems to have a very large positive effect on the more
      // extreme scenario, such as low-latency CMAF with very small chunks and
      // huge CPU usage in the thread doing the push operation.
      //
      // Because this should still be relatively rare, we pre-check here
      // the condition.
      if (
        this._operationQueue.length > 0 &&
        this._operationQueue[0].operationName === SbiOperationName.Push
      ) {
        let prevU8;
        if (ogData instanceof ArrayBuffer) {
          prevU8 = new Uint8Array(ogData);
        } else if (ogData instanceof Uint8Array) {
          prevU8 = ogData;
        } else {
          prevU8 = new Uint8Array(ogData.buffer);
        }
        const toConcat = [prevU8];
        while (this._operationQueue[0]?.operationName === SbiOperationName.Push) {
          const followingElem = this._operationQueue[0];
          const cAw = params.appendWindow ?? [undefined, undefined];
          const fAw = followingElem.params[1].appendWindow ?? [undefined, undefined];
          const cTo = params.timestampOffset ?? 0;
          const fTo = followingElem.params[1].timestampOffset ?? 0;
          if (
            cAw[0] === fAw[0] &&
            cAw[1] === fAw[1] &&
            params.codec === followingElem.params[1].codec &&
            cTo === fTo
          ) {
            const newData = followingElem.params[0];
            let newU8;
            if (newData instanceof ArrayBuffer) {
              newU8 = new Uint8Array(newData);
            } else if (newData instanceof Uint8Array) {
              newU8 = newData;
            } else {
              newU8 = new Uint8Array(newData.buffer);
            }
            toConcat.push(newU8);
            this._operationQueue.splice(0, 1);
            this._currentOperations.push({
              operationName: SbiOperationName.Push,
              resolve: followingElem.resolve,
              reject: followingElem.reject,
            });
          } else {
            break;
          }
        }
        if (toConcat.length > 1) {
          log.info("mse", `: Merging ${toConcat.length} segments together for perf`, {
            type: this.type,
          });
          segmentData = concat(...toConcat).buffer;
        }
      }
      try {
        this._appendBufferNow(segmentData, params);
      } catch (err) {
        const error =
          err instanceof Error
            ? new SourceBufferError(
                err.name,
                err.message,
                err.name === "QuotaExceededError",
              )
            : new SourceBufferError(
                "Error",
                "Unknown SourceBuffer Error during appendBuffer",
                false,
              );
        this._currentOperations.forEach((op) => {
          op.reject(error);
        });
        this._currentOperations = [];

        // A synchronous error probably will not lead to updateend event, so we need to
        // go to next queue element manually
        //
        // FIXME: This here is needed to ensure that we're not left with a
        // dangling queue of operations.
        // However it can potentially be counter-productive if e.g. the `appendBuffer`
        // error was due to a full buffer and if there are pushing operations awaiting in
        // the queue.
        //
        // A better solution might just be to reject all push operations right away here?
        // Only for a `QuotaExceededError` (to check MSE)?
        // However this is too disruptive for what is now a hotfix
        this._performNextOperation();
      }
    } else {
      // TODO merge contiguous removes?
      this._currentOperations = [nextElem];
      const [start, end] = nextElem.params;
      log.debug("mse", "removing data from SourceBuffer", {
        type: this.type,
        start,
        end,
      });
      try {
        this._sourceBuffer.remove(start, end);
      } catch (err) {
        const error =
          err instanceof Error
            ? new SourceBufferError(err.name, err.message, false)
            : new SourceBufferError(
                "Error",
                "Unknown SourceBuffer Error during remove",
                false,
              );
        nextElem.reject(error);
        this._currentOperations.forEach((op) => {
          op.reject(error);
        });
        this._currentOperations = [];
        // A synchronous error probably will not lead to updateend event, so we need to
        // go to next queue element manually
        this._performNextOperation();
      }
    }
  }

  private _appendBufferNow(
    data: BufferSource,
    params: ISourceBufferInterfaceAppendBufferParameters,
  ): void {
    const sourceBuffer = this._sourceBuffer;
    const { codec, timestampOffset, appendWindow = [] } = params;
    if (codec !== undefined && codec !== this.codec) {
      log.debug("mse", "updating codec", {
        type: this.type,
        prevCodec: this.codec,
        newCodec: codec,
      });
      const hasUpdatedSourceBufferType = tryToChangeSourceBufferType(sourceBuffer, codec);
      if (hasUpdatedSourceBufferType) {
        this.codec = codec;
      } else {
        log.debug("mse", "could not update codec", {
          type: this.type,
          prevCodec: this.codec,
          newCodec: codec,
        });
      }
    }

    if (
      timestampOffset !== undefined &&
      sourceBuffer.timestampOffset !== timestampOffset
    ) {
      const newTimestampOffset = timestampOffset;
      log.debug("mse", "updating timestampOffset", {
        type: this.type,
        codec,
        prevTimestampOffset: sourceBuffer.timestampOffset,
        newTimestampOffset,
      });
      sourceBuffer.timestampOffset = newTimestampOffset;
    }

    if (appendWindow[0] === undefined) {
      if (sourceBuffer.appendWindowStart > 0) {
        log.debug("mse", "re-setting `appendWindowStart`", {
          type: this.type,
          prevWindowStart: sourceBuffer.appendWindowStart,
        });
        sourceBuffer.appendWindowStart = 0;
      }
    } else if (appendWindow[0] !== sourceBuffer.appendWindowStart) {
      if (appendWindow[0] >= sourceBuffer.appendWindowEnd) {
        const newWindowEnd = appendWindow[0] + 1;
        log.debug("mse", "pre-updating `appendWindowEnd`", {
          type: this.type,
          prevWindowEnd: sourceBuffer.appendWindowEnd,
          newWindowEnd,
        });
        sourceBuffer.appendWindowEnd = newWindowEnd;
      }
      log.debug("mse", "setting `appendWindowStart`", {
        type: this.type,
        appendWindowStart: appendWindow[0],
      });
      sourceBuffer.appendWindowStart = appendWindow[0];
    }

    if (appendWindow[1] === undefined) {
      if (sourceBuffer.appendWindowEnd !== Infinity) {
        log.debug("mse", "re-setting `appendWindowEnd`", {
          type: this.type,
          prevWindowStart: sourceBuffer.appendWindowStart,
        });
        sourceBuffer.appendWindowEnd = Infinity;
      }
    } else if (appendWindow[1] !== sourceBuffer.appendWindowEnd) {
      log.debug("mse", "setting `appendWindowEnd`", {
        type: this.type,
        prevWindowEnd: sourceBuffer.appendWindowEnd,
        newWindowEnd: appendWindow[1],
      });
      sourceBuffer.appendWindowEnd = appendWindow[1];
    }
    log.debug("mse", "pushing segment", { type: this.type });
    sourceBuffer.appendBuffer(data);
  }
}

function resetMediaSource(mediaSource: IMediaSource): void {
  if (mediaSource.readyState !== "closed") {
    const { readyState, sourceBuffers } = mediaSource;
    for (let i = sourceBuffers.length - 1; i >= 0; i--) {
      const sourceBuffer = sourceBuffers[i];
      try {
        if (readyState === "open") {
          log.info("mse", "Aborting SourceBuffer before removing");
          try {
            sourceBuffer.abort();
          } catch (_) {
            // We actually don't care at all when resetting
          }
        }
        log.info("mse", "Removing SourceBuffer from mediaSource");
        mediaSource.removeSourceBuffer(sourceBuffer);
      } catch (_) {
        // We actually don't care at all when resetting
      }
    }
    if (sourceBuffers.length > 0) {
      log.info("mse", "Not all SourceBuffers could have been removed.");
    }
  }
}

/**
 * Enum used by a SourceBufferInterface as a discriminant in its queue of
 * "operations".
 */
const enum SbiOperationName {
  Push,
  Remove,
}

type ISbiQueuedOperation = IQueuedSbiPush | IQueuedSbiRemove;

interface IQueuedSbiPush {
  operationName: SbiOperationName.Push;
  params: Parameters<ISourceBufferInterface["appendBuffer"]>;
  resolve: (ranges: IRange[]) => void;
  reject: (error: unknown) => void;
}

interface IQueuedSbiRemove {
  operationName: SbiOperationName.Remove;
  params: Parameters<ISourceBufferInterface["remove"]>;
  resolve: (ranges: IRange[]) => void;
  reject: (error: unknown) => void;
}
