import type {
  IAbortBufferCoreMessage,
  IAddSourceBufferCoreMessage,
  IAppendBufferCoreMessage,
  ICreateMediaSourceCoreMessage,
  IDisposeMediaSourceCoreMessage,
  IEndOfStreamCoreMessage,
  IInterruptMediaSourceDurationCoreMessage,
  IRemoveBufferCoreMessage,
  IStopEndOfStreamCoreMessage,
  IUpdateMediaSourceDurationCoreMessage,
} from "../core/types";
import { CoreMessageType } from "../core/types";
import { SourceBufferError } from "../errors";
import type { ISerializedSourceBufferError } from "../errors/source_buffer_error";
import log from "../log";
import EventEmitter from "../utils/event_emitter";
import idGenerator from "../utils/id_generator";
import type { IRange } from "../utils/ranges";
import TaskCanceller, { CancellationError } from "../utils/task_canceller";
import type {
  IMediaSourceInterface,
  IMediaSourceInterfaceEvents,
  ISourceBufferInterface,
  ISourceBufferInterfaceAppendBufferParameters,
  SourceBufferType,
} from "./types";

const generateMediaSourceId = idGenerator();
const generateSourceBufferOperationId = idGenerator();

/**
 * We may maintain a worker-side queue to avoid overwhelming the main thread
 * with sent media segments. This queue maximum size if stored in that value.
 *
 * To set to `Infinity` to disable.
 */
const MAX_WORKER_SOURCE_BUFFER_QUEUE_SIZE = Infinity;

/**
 * Interface to the MediaSource browser APIs of the Media Source Extentions for
 * a WebWorker environment where MSE API are not available (if MSE API are
 * available in WebWorker in the current environment you don't have to rely on
 * this class).
 *
 * What this class actually does for most MSE API is to post a message
 * corresponding to the wanted action - which will have to be processed on the
 * main thread.
 * @class {WorkerMediaSourceInterface}
 */
export default class WorkerMediaSourceInterface
  extends EventEmitter<IMediaSourceInterfaceEvents>
  implements IMediaSourceInterface
{
  public id: string;
  public handle: undefined;
  public sourceBuffers: WorkerSourceBufferInterface[];
  public readyState: ReadyState;
  private _canceller: TaskCanceller;
  private _messageSender: IWorkerMediaSourceInterfaceMessageSender;

  constructor(
    id: string,
    contentId: string,
    messageSender: IWorkerMediaSourceInterfaceMessageSender,
  ) {
    super();
    this.id = id;
    this.sourceBuffers = [];
    this._canceller = new TaskCanceller("WorkerMediaSourceInterface");
    this.readyState = "closed";
    this._messageSender = messageSender;

    const mediaSourceId = generateMediaSourceId();
    this._messageSender({
      type: CoreMessageType.CreateMediaSource,
      contentId,
      mediaSourceId,
    });
  }

  public onMediaSourceReadyStateChanged(readyState: ReadyState): void {
    switch (readyState) {
      case "closed":
        this.readyState = "closed";
        this.trigger("mediaSourceClose", null);
        break;
      case "open":
        this.readyState = "open";
        this.trigger("mediaSourceOpen", null);
        break;
      case "ended":
        this.readyState = "ended";
        this.trigger("mediaSourceEnded", null);
        break;
    }
  }

  public addSourceBuffer(
    sbType: SourceBufferType,
    codec: string,
  ): ISourceBufferInterface {
    this._messageSender({
      type: CoreMessageType.AddSourceBuffer,
      mediaSourceId: this.id,
      value: {
        sourceBufferType: sbType,
        codec,
      },
    });
    const sb = new WorkerSourceBufferInterface(
      sbType,
      codec,
      this.id,
      this._messageSender,
    );
    this.sourceBuffers.push(sb);
    return sb;
  }

  public setDuration(newDuration: number, isRealEndKnown: boolean) {
    this._messageSender({
      type: CoreMessageType.UpdateMediaSourceDuration,
      mediaSourceId: this.id,
      value: {
        duration: newDuration,
        isRealEndKnown,
      },
    });
  }

  public interruptDurationSetting() {
    this._messageSender({
      type: CoreMessageType.InterruptMediaSourceDurationUpdate,
      mediaSourceId: this.id,
      value: null,
    });
  }

  public maintainEndOfStream() {
    this._messageSender({
      type: CoreMessageType.EndOfStream,
      mediaSourceId: this.id,
      value: null,
    });
  }

  public stopEndOfStream() {
    this._messageSender({
      type: CoreMessageType.InterruptEndOfStream,
      mediaSourceId: this.id,
      value: null,
    });
  }

  /**
   * @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.sourceBuffers.forEach((s) => s.dispose(reason));
    this._canceller.cancel("WorkerMediaSourceInterface dispose");
    this._messageSender({
      type: CoreMessageType.DisposeMediaSource,
      mediaSourceId: this.id,
      value: null,
    });
  }
}

export type IWorkerMediaSourceInterfaceMessageSender = (
  msg:
    | IAppendBufferCoreMessage
    | IRemoveBufferCoreMessage
    | IAbortBufferCoreMessage
    | ICreateMediaSourceCoreMessage
    | IAddSourceBufferCoreMessage
    | IUpdateMediaSourceDurationCoreMessage
    | IInterruptMediaSourceDurationCoreMessage
    | IEndOfStreamCoreMessage
    | IStopEndOfStreamCoreMessage
    | IDisposeMediaSourceCoreMessage,
  transferables?: Transferable[],
) => void;

export class WorkerSourceBufferInterface implements ISourceBufferInterface {
  /** Last codec linked to that `WorkerSourceBufferInterface`. */
  public codec: string;
  /**
   * Media type handled by that `WorkerSourceBufferInterface`.
   * Equal to `Video` as long as it contains video, even if it also contains
   * audio.
   */
  public type: SourceBufferType;
  /**
   * Push and remove operations which have not yet been sent to avoid
   * overwhelming the main thread.
   * @see `MAX_WORKER_SOURCE_BUFFER_QUEUE_SIZE`
   */
  public _queuedOperations: ISbiQueuedOperation[];
  /**
   * Operations currently running in the main thread for which we are awaiting
   * a response.
   */
  public _pendingOperations: Map<
    string,
    {
      resolve: (ranges: IRange[]) => void;
      reject: (err: CancellationError | SourceBufferError) => void;
    }
  >;
  /**
   * Identifier Identifying this `WorkerSourceBufferInterface` in-between
   * threads.
   */
  private _mediaSourceId: string;
  /**
   * When it emits, clean-up all resources taken by the
   * WorkerSourceBufferInterface.
   */
  private _canceller: TaskCanceller;
  /** Allows to send messages to the main thread. */
  private _messageSender: IWorkerMediaSourceInterfaceMessageSender;

  constructor(
    sbType: SourceBufferType,
    codec: string,
    mediaSourceId: string,
    messageSender: IWorkerMediaSourceInterfaceMessageSender,
  ) {
    this.type = sbType;
    this.codec = codec;
    this._canceller = new TaskCanceller("WorkerSourceBufferInterface " + sbType);
    this._mediaSourceId = mediaSourceId;
    this._queuedOperations = [];
    this._pendingOperations = new Map();
    this._messageSender = messageSender;
  }

  public onOperationSuccess(operationId: string, ranges: IRange[]): void {
    const mapElt = this._pendingOperations.get(operationId);
    if (mapElt === undefined) {
      log.warn("mse", "unknown SourceBuffer operation succeeded", {
        type: this.type,
      });
    } else {
      this._pendingOperations.delete(operationId);
      mapElt.resolve(ranges);
    }
    this._performNextQueuedOperationIfItExists();
  }

  public onOperationFailure(
    operationId: string,
    error: ISerializedSourceBufferError | { errorName: "CancellationError" },
  ): void {
    const formattedErr =
      error.errorName === "CancellationError"
        ? new CancellationError("Pending SBI Operation " + this.type, "SBI Failure")
        : new SourceBufferError(error.errorName, error.message, error.isBufferFull);
    const mapElt = this._pendingOperations.get(operationId);
    if (mapElt === undefined) {
      log.info(
        "mse",
        "unknown SourceBuffer operation failed",
        { type: this.type },
        formattedErr,
      );
    } else {
      this._pendingOperations.delete(operationId);
      mapElt.reject(formattedErr);
    }

    const cancellationError = new CancellationError(
      "Queued SBI Operation " + this.type,
      "SBI failure",
    );
    for (const operation of this._queuedOperations) {
      operation.reject(cancellationError);
    }
    this._queuedOperations = [];
  }

  public appendBuffer(
    data: BufferSource,
    params: ISourceBufferInterfaceAppendBufferParameters,
  ): Promise<IRange[]> {
    return new Promise((resolve, reject) => {
      if (
        this._queuedOperations.length > 0 ||
        this._pendingOperations.size >= MAX_WORKER_SOURCE_BUFFER_QUEUE_SIZE
      ) {
        this._queuedOperations.push({
          operationName: SbiOperationName.Push,
          params: [data, params],
          resolve,
          reject,
        });
        return;
      }
      try {
        let segmentSinkPushed: ArrayBuffer;
        if (data instanceof ArrayBuffer) {
          segmentSinkPushed = data;
        } else if (data.byteLength === data.buffer.byteLength) {
          segmentSinkPushed = data.buffer;
        } else {
          segmentSinkPushed = data.buffer.slice(
            data.byteOffset,
            data.byteLength + data.byteOffset,
          );
        }
        const operationId = generateSourceBufferOperationId();
        this._messageSender(
          {
            type: CoreMessageType.SourceBufferAppend,
            mediaSourceId: this._mediaSourceId,
            sourceBufferType: this.type,
            operationId,
            value: {
              data: segmentSinkPushed,
              params,
            },
          },
          [segmentSinkPushed],
        );
        this._addOperationToQueue(operationId, resolve, reject);
      } catch (err) {
        reject(err);
      }
    });
  }

  public remove(start: number, end: number): Promise<IRange[]> {
    return new Promise((resolve, reject) => {
      if (
        this._queuedOperations.length > 0 ||
        this._pendingOperations.size >= MAX_WORKER_SOURCE_BUFFER_QUEUE_SIZE
      ) {
        this._queuedOperations.push({
          operationName: SbiOperationName.Remove,
          params: [start, end],
          resolve,
          reject,
        });
        return;
      }
      try {
        const operationId = generateSourceBufferOperationId();
        this._messageSender({
          type: CoreMessageType.SourceBufferRemove,
          mediaSourceId: this._mediaSourceId,
          sourceBufferType: this.type,
          operationId,
          value: {
            start,
            end,
          },
        });
        this._addOperationToQueue(operationId, resolve, reject);
      } catch (err) {
        reject(err);
      }
    });
  }

  public abort(): void {
    this._messageSender({
      type: CoreMessageType.AbortSourceBuffer,
      mediaSourceId: this._mediaSourceId,
      sourceBufferType: this.type,
      value: null,
    });
  }

  /**
   * @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.abort();
    this._canceller.cancel(reason ?? "WorkerSourceBufferInterface dispose");
  }

  public getBuffered(): undefined {
    return;
  }

  private _addOperationToQueue(
    operationId: string,
    resolve: (ranges: IRange[]) => void,
    reject: (err: unknown) => void,
  ): void {
    this._pendingOperations.set(operationId, {
      resolve: onResolve,
      reject: onReject,
    });
    const unbindCanceller = this._canceller.signal.register((error) => {
      this._pendingOperations.delete(operationId);
      reject(error);
    });
    function onResolve(ranges: IRange[]) {
      unbindCanceller();
      resolve(ranges);
    }
    function onReject(err: unknown) {
      unbindCanceller();
      reject(err);
    }
  }

  private _performNextQueuedOperationIfItExists() {
    const nextOp = this._queuedOperations.shift();
    if (nextOp !== undefined) {
      try {
        if (nextOp.operationName === SbiOperationName.Push) {
          const [data, params] = nextOp.params;
          let segmentSinkPushed: ArrayBuffer;
          if (data instanceof ArrayBuffer) {
            segmentSinkPushed = data;
          } else if (data.byteLength === data.buffer.byteLength) {
            segmentSinkPushed = data.buffer;
          } else {
            segmentSinkPushed = data.buffer.slice(
              data.byteOffset,
              data.byteLength + data.byteOffset,
            );
          }
          const nOpId = generateSourceBufferOperationId();
          this._messageSender(
            {
              type: CoreMessageType.SourceBufferAppend,
              mediaSourceId: this._mediaSourceId,
              sourceBufferType: this.type,
              operationId: nOpId,
              value: {
                data: segmentSinkPushed,
                params,
              },
            },
            [segmentSinkPushed],
          );
          this._addOperationToQueue(nOpId, nextOp.resolve, nextOp.reject);
        } else {
          const [start, end] = nextOp.params;
          const nOpId = generateSourceBufferOperationId();
          this._messageSender({
            type: CoreMessageType.SourceBufferRemove,
            mediaSourceId: this._mediaSourceId,
            sourceBufferType: this.type,
            operationId: nOpId,
            value: {
              start,
              end,
            },
          });
          this._addOperationToQueue(nOpId, nextOp.resolve, nextOp.reject);
        }
      } catch (err) {
        nextOp.reject(err);
      }
    }
  }
}

type ISbiQueuedOperation = IQueuedSbiPush | IQueuedSbiRemove;

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

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;
}
