import type { Mock, MockInstance } from "vitest";
import { vi } from "vitest";
import type { IMediaElement } from "../../../../compat/browser_compatibility_types";
import type { IEncryptedEventData, IEmeApiImplementation } from "../../../../compat/eme";
import type { IKeySystemOption } from "../../../../public_types";
import { base64ToBytes, bytesToBase64 } from "../../../../utils/base64";
import EventEmitter from "../../../../utils/event_emitter";
import flatMap from "../../../../utils/flat_map";
import { strToUtf8, utf8ToStr } from "../../../../utils/string_parsing";
import type { CancellationSignal } from "../../../../utils/task_canceller";
import type IContentDecryptor from "../../content_decryptor";

/** Default MediaKeySystemAccess configuration used by the RxPlayer. */
export const defaultKSConfig: MediaKeySystemConfiguration[] = [
  {
    audioCapabilities: [
      { contentType: 'audio/mp4;codecs="mp4a.40.2"' },
      { contentType: 'audio/webm;codecs="opus"' },
      { contentType: 'audio/mp4;codecs="ec-3"' },
    ],
    distinctiveIdentifier: "optional" as const,
    initDataTypes: ["cenc"] as const,
    persistentState: "optional" as const,
    sessionTypes: ["temporary"] as const,
    videoCapabilities: [
      { contentType: 'video/mp4;codecs="avc1.4d401e"' },
      { contentType: 'video/mp4;codecs="avc1.42e01e"' },
      { contentType: 'video/mp4;codecs="hvc1.1.6.L93.B0"' },
      { contentType: 'video/webm;codecs="vp8"' },
    ],
  },
];

/**
 * Default "com.microsoft.playready.recommendation" MediaKeySystemAccess
 * configuration used by the RxPlayer.
 */
export const defaultPRRecommendationKSConfig: MediaKeySystemConfiguration[] = [
  {
    audioCapabilities: [
      { robustness: "3000", contentType: 'audio/mp4;codecs="mp4a.40.2"' },
      { robustness: "3000", contentType: 'audio/webm;codecs="opus"' },
      { robustness: "3000", contentType: 'audio/mp4;codecs="ec-3"' },
      { robustness: "2000", contentType: 'audio/mp4;codecs="mp4a.40.2"' },
      { robustness: "2000", contentType: 'audio/webm;codecs="opus"' },
      { robustness: "2000", contentType: 'audio/mp4;codecs="ec-3"' },
    ],
    distinctiveIdentifier: "optional" as const,
    initDataTypes: ["cenc"] as const,
    persistentState: "optional" as const,
    sessionTypes: ["temporary"] as const,
    videoCapabilities: [
      { robustness: "3000", contentType: 'video/mp4;codecs="avc1.4d401e"' },
      { robustness: "3000", contentType: 'video/mp4;codecs="avc1.42e01e"' },
      { robustness: "3000", contentType: 'video/mp4;codecs="hvc1.1.6.L93.B0"' },
      { robustness: "3000", contentType: 'video/webm;codecs="vp8"' },
      { robustness: "2000", contentType: 'video/mp4;codecs="avc1.4d401e"' },
      { robustness: "2000", contentType: 'video/mp4;codecs="avc1.42e01e"' },
      { robustness: "2000", contentType: 'video/mp4;codecs="hvc1.1.6.L93.B0"' },
      { robustness: "2000", contentType: 'video/webm;codecs="vp8"' },
    ],
  },
];

/** Default Widevine MediaKeySystemAccess configuration used by the RxPlayer. */
export const defaultWidevineConfig: MediaKeySystemConfiguration[] = (() => {
  const ROBUSTNESSES = [
    "HW_SECURE_ALL",
    "HW_SECURE_DECODE",
    "HW_SECURE_CRYPTO",
    "SW_SECURE_DECODE",
    "SW_SECURE_CRYPTO",
  ];
  const videoCapabilities = flatMap(ROBUSTNESSES, (robustness) => {
    return [
      { contentType: 'video/mp4;codecs="avc1.4d401e"', robustness },
      { contentType: 'video/mp4;codecs="avc1.42e01e"', robustness },
      { contentType: 'video/mp4;codecs="hvc1.1.6.L93.B0"', robustness },
      { contentType: 'video/webm;codecs="vp8"', robustness },
    ];
  });
  const audioCapabilities = flatMap(ROBUSTNESSES, (robustness) => {
    return [
      { contentType: 'audio/mp4;codecs="mp4a.40.2"', robustness },
      { contentType: 'audio/webm;codecs="opus"', robustness },
      { contentType: 'audio/mp4;codecs="ec-3"', robustness },
    ];
  });
  return [{ ...defaultKSConfig[0], audioCapabilities, videoCapabilities }];
})();

/**
 * Custom implementation of an EME-compliant MediaKeyStatusMap.
 * @class MediaKeyStatusMapImpl
 */
export class MediaKeyStatusMapImpl {
  public get size(): number {
    return this._map.size;
  }

  private _map: Map<ArrayBuffer, MediaKeyStatus>;
  constructor() {
    this._map = new Map();
  }

  public get(keyId: BufferSource): MediaKeyStatus | undefined {
    const keyIdAB = keyId instanceof ArrayBuffer ? keyId : keyId.buffer;
    return this._map.get(keyIdAB);
  }

  public has(keyId: BufferSource): boolean {
    const keyIdAB = keyId instanceof ArrayBuffer ? keyId : keyId.buffer;
    return this._map.has(keyIdAB);
  }

  public forEach(
    callbackfn: (
      value: MediaKeyStatus,
      key: BufferSource,
      parent: MediaKeyStatusMapImpl,
    ) => void,
    thisArg?: unknown,
  ): void {
    this._map.forEach((value, key) => callbackfn.call(thisArg, value, key, this));
  }

  public _setKeyStatus(keyId: BufferSource, value: MediaKeyStatus | undefined): void {
    const keyIdAB = keyId instanceof ArrayBuffer ? keyId : keyId.buffer;
    if (value === undefined) {
      this._map.delete(keyIdAB);
    } else {
      this._map.set(keyIdAB, value);
    }
  }
}

/**
 * Custom implementation of an EME-compliant MediaKeySession.
 * @class MediaKeySessionImpl
 */
export class MediaKeySessionImpl extends EventEmitter<Record<string, unknown>> {
  public readonly closed: Promise<void>;
  public readonly expiration: number;
  public readonly keyStatuses: MediaKeyStatusMapImpl;
  public readonly sessionId: string;
  public onkeystatuseschange: ((this: MediaKeySessionImpl, ev: Event) => unknown) | null;
  public onmessage:
    | ((this: MediaKeySessionImpl, ev: MediaKeyMessageEvent) => unknown)
    | null;

  private _currentKeyId: number;
  private _close?: () => void;
  constructor() {
    super();
    this._currentKeyId = 0;
    this.expiration = Number.MAX_VALUE;
    this.keyStatuses = new MediaKeyStatusMapImpl();

    this.closed = new Promise((res) => {
      this._close = res;
    });

    this.onkeystatuseschange = null;
    this.onmessage = null;
    this.sessionId = "";
  }

  public close(): Promise<void> {
    if (this._close !== undefined) {
      this._close();
    }
    return Promise.resolve();
  }

  public generateRequest(initDataType: string, initData: BufferSource): Promise<void> {
    const msg = formatFakeChallengeFromInitData(initData, initDataType);
    setTimeout(() => {
      // eslint-disable-next-line no-restricted-properties
      const event: MediaKeyMessageEvent = Object.assign(new CustomEvent("message"), {
        message: msg.buffer,
        messageType: "license-request" as const,
      });

      this.trigger("message", event);
      if (this.onmessage !== null && this.onmessage !== undefined) {
        this.onmessage(event);
      }
    }, 5);
    return Promise.resolve();
  }

  public load(_sessionId: string): Promise<boolean> {
    throw new Error("Not implemented yet");
  }

  public remove(): Promise<void> {
    return Promise.resolve();
  }

  public update(_response: BufferSource): Promise<void> {
    this.keyStatuses._setKeyStatus(
      new Uint8Array([0, 1, 2, this._currentKeyId++]),
      "usable",
    );
    const event = new CustomEvent("keystatuseschange");
    setTimeout(() => {
      this.trigger("keyStatusesChange", event);
      if (this.onkeystatuseschange !== null && this.onkeystatuseschange !== undefined) {
        this.onkeystatuseschange(event);
      }
    }, 50);
    return Promise.resolve();
  }
}

/**
 * Custom implementation of an EME-compliant MediaKeys.
 * @class MediaKeysImpl
 */
export class MediaKeysImpl {
  createSession(_sessionType?: MediaKeySessionType): MediaKeySessionImpl {
    return new MediaKeySessionImpl();
  }

  setServerCertificate(_serverCertificate: BufferSource): Promise<boolean> {
    return Promise.resolve(true);
  }
}

/**
 * Custom implementation of an EME-compliant MediaKeySystemAccess.
 * @class MediaKeySystemAccessImpl
 */
export class MediaKeySystemAccessImpl {
  public readonly keySystem: string;
  private readonly _config: MediaKeySystemConfiguration[];
  constructor(keySystem: string, config: MediaKeySystemConfiguration[]) {
    this.keySystem = keySystem;
    this._config = config;
  }
  createMediaKeys(): Promise<MediaKeysImpl> {
    return Promise.resolve(new MediaKeysImpl());
  }
  getConfiguration(): MediaKeySystemConfiguration[] {
    return this._config;
  }
}

export function requestMediaKeySystemAccessImpl(
  keySystem: string,
  config: MediaKeySystemConfiguration[],
): Promise<MediaKeySystemAccessImpl> {
  return Promise.resolve(new MediaKeySystemAccessImpl(keySystem, config));
}

class MockedDecryptorEventEmitter extends EventEmitter<{
  encrypted: { elt: IMediaElement; value: unknown };
  keymessage: { session: MediaKeySessionImpl; value: unknown };
  keyerror: { session: MediaKeySessionImpl; value: unknown };
  keystatuseschange: { session: MediaKeySessionImpl; value: unknown };
}> {
  public triggerEncrypted(elt: IMediaElement, value: unknown) {
    this.trigger("encrypted", { elt, value });
  }
  public triggerKeyError(session: MediaKeySessionImpl, value: unknown) {
    this.trigger("keyerror", { session, value });
  }
  public triggerKeyStatusesChange(session: MediaKeySessionImpl, value: unknown) {
    this.trigger("keystatuseschange", { session, value });
  }
  public triggerKeyMessage(session: MediaKeySessionImpl, value: unknown) {
    this.trigger("keymessage", { session, value });
  }
}

/**
 * Mock functions coming from the compat directory.
 */
export function mockCompat(mocks: {
  canReuseMediaKeys: Mock;
  shouldRenewMediaKeySystemAccess: Mock;
  onEncrypted: Mock;
  requestMediaKeySystemAccess: Mock;
  setMediaKeys: Mock;
  getInitData: Mock;
  generateKeyRequest: Mock;
}) {
  const ee = new MockedDecryptorEventEmitter();
  const mockEvents: Record<string, MockInstance> = {
    onKeyMessage: vi
      .fn()
      .mockImplementation(
        (
          elt: MediaKeySessionImpl,
          fn: (x: unknown) => void,
          signal: CancellationSignal,
        ) => {
          elt.addEventListener("message", fn, signal);
          ee.addEventListener(
            "keymessage",
            (evt) => {
              if (evt.session === elt) {
                fn(evt.value);
              }
            },
            signal,
          );
        },
      ),
    onKeyError: vi
      .fn()
      .mockImplementation(
        (
          elt: MediaKeySessionImpl,
          fn: (x: unknown) => void,
          signal: CancellationSignal,
        ) => {
          elt.addEventListener("error", fn, signal);
          ee.addEventListener(
            "keyerror",
            (evt) => {
              if (evt.session === elt) {
                fn(evt.value);
              }
            },
            signal,
          );
        },
      ),
    onKeyStatusesChange: vi
      .fn()
      .mockImplementation(
        (
          elt: MediaKeySessionImpl,
          fn: (x: unknown) => void,
          signal: CancellationSignal,
        ) => {
          elt.addEventListener("keystatuseschange", fn, signal);
          ee.addEventListener(
            "keystatuseschange",
            (evt) => {
              if (evt.session === elt) {
                fn(evt.value);
              }
            },
            signal,
          );
        },
      ),
  };

  const mockOnEncrypted = vi
    .fn()
    .mockImplementation(
      (elt: IMediaElement, fn: (x: unknown) => void, signal: CancellationSignal) => {
        elt.addEventListener("encrypted", fn);
        signal.register(() => {
          elt.removeEventListener("encrypted", fn);
        });
        ee.addEventListener(
          "encrypted",
          (evt) => {
            if (evt.elt === elt) {
              fn(evt.value);
            }
          },
          signal,
        );
      },
    );
  const mockRmksa = requestMediaKeySystemAccessImpl;
  const mockSetMediaKeys = () => Promise.resolve();
  const mockGenerateKeyRequest = (
    mks: MediaKeySessionImpl,
    initializationDataType: string,
    initializationData: BufferSource,
  ) => {
    return mks.generateRequest(initializationDataType, initializationData);
  };

  const mockGetInitData = (encryptedEvent: IEncryptedEventData) => {
    return encryptedEvent;
  };

  mocks.onEncrypted.mockImplementation(mockOnEncrypted);
  mocks.requestMediaKeySystemAccess.mockImplementation(mockRmksa);
  mocks.setMediaKeys.mockImplementation(mockSetMediaKeys);
  mocks.getInitData.mockImplementation(mockGetInitData);
  mocks.generateKeyRequest.mockImplementation(mockGenerateKeyRequest);

  return {
    mockEvents,
    eventTriggers: {
      triggerEncrypted(elt: IMediaElement, value: unknown) {
        ee.triggerEncrypted(elt, value);
      },
      triggerKeyMessage(session: MediaKeySessionImpl, value: unknown) {
        ee.triggerKeyMessage(session, value);
      },
      triggerKeyError(session: MediaKeySessionImpl, value: unknown) {
        ee.triggerKeyError(session, value);
      },
      triggerKeyStatusesChange(session: MediaKeySessionImpl, value: unknown) {
        ee.triggerKeyStatusesChange(session, value);
      },
    },
  };
}

/**
 * Check that the ContentDecryptor, when called with those arguments, throws.
 * If that's the case, resolve with the corresponding error.
 * Else, reject.
 * @param {HTMLMediaElement} mediaElement
 * @param {Array.<Object>} keySystemsConfigs
 * @param {Array} keySystemsConfigs
 * @returns {Promise}
 */
export function testContentDecryptorError(
  eme: IEmeApiImplementation,
  // eslint-disable-next-line @typescript-eslint/naming-convention
  ContentDecryptor: typeof IContentDecryptor,
  mediaElement: IMediaElement,
  keySystemsConfigs: IKeySystemOption[],
): Promise<Error> {
  return new Promise((res, rej) => {
    const contentDecryptor = new ContentDecryptor(eme, mediaElement, keySystemsConfigs);
    contentDecryptor.addEventListener("error", (error: Error) => {
      res(error);
    });
    setTimeout(() => {
      rej(new Error("Timeout exceeded"));
    }, 10);
  });
}

/**
 * Does the reverse operation than what `formatFakeChallengeFromInitData` does:
 * Retrieve initialization data from a fake challenge done in our tests
 * @param {Uint8Array} challenge
 * @returns {Object}
 */
export function extrackInfoFromFakeChallenge(challenge: Uint8Array): {
  initData: Uint8Array;
  initDataType: string;
} {
  const licenseData = JSON.stringify(utf8ToStr(challenge));
  const initData = base64ToBytes(licenseData[1]);
  return { initData, initDataType: licenseData[0] };
}

/**
 * @param {BufferSource} initData
 * @param {string} initDataType
 * @returns {Uint8Array}
 */
export function formatFakeChallengeFromInitData(
  initData: BufferSource,
  initDataType: string,
): Uint8Array<ArrayBuffer> {
  const initDataAB = initData instanceof ArrayBuffer ? initData : initData.buffer;
  const objChallenge = [initDataType, bytesToBase64(new Uint8Array(initDataAB))];
  let data = strToUtf8(JSON.stringify(objChallenge));
  // Work-around some testing environment issue
  // see https://github.com/vitest-dev/vitest/issues/4043
  if (!(data instanceof Uint8Array)) {
    data = new Uint8Array(data);
  }
  return data;
}
