import { describe, afterEach, it, expect, vi } from "vitest";
import getEmeApiImplementation from "../../../../compat/eme";
import type { IKeySystemOption, IPlayerError } from "../../../../public_types";
import assert from "../../../../utils/assert";
import { concat } from "../../../../utils/byte_parsing";
import ContentDecryptor from "../../content_decryptor";
import { ContentDecryptorState } from "../../types";
import {
  formatFakeChallengeFromInitData,
  MediaKeySessionImpl,
  MediaKeysImpl,
  mockCompat,
} from "./utils";

const mocks = vi.hoisted(() => {
  return {
    shouldRenewMediaKeySystemAccess: vi.fn(() => false),
    canReuseMediaKeys: vi.fn(() => true),
    onEncrypted: vi.fn(),
    requestMediaKeySystemAccess: vi.fn(),
    setMediaKeys: vi.fn(),
    getInitData: vi.fn(),
    generateKeyRequest: vi.fn(),
  };
});
vi.mock("../../../../compat/should_renew_media_key_system_access", () => ({
  default: mocks.shouldRenewMediaKeySystemAccess,
}));
vi.mock("../../../../compat/can_reuse_media_keys", () => ({
  default: mocks.canReuseMediaKeys,
}));
vi.mock("../../../../compat/eme", () => ({
  default: () => ({
    onEncrypted: mocks.onEncrypted,
    requestMediaKeySystemAccess: mocks.requestMediaKeySystemAccess,
    setMediaKeys: mocks.setMediaKeys,
  }),
  getInitData: mocks.getInitData,
  generateKeyRequest: mocks.generateKeyRequest,
  closeSession: vi.fn(),
  loadSession: vi.fn(),
}));

describe("decrypt - global tests - getLicense", () => {
  afterEach(() => {
    vi.resetModules();
    vi.restoreAllMocks();
    mocks.shouldRenewMediaKeySystemAccess.mockReset();
    mocks.canReuseMediaKeys.mockReset();
    mocks.onEncrypted.mockReset();
    mocks.requestMediaKeySystemAccess.mockReset();
    mocks.setMediaKeys.mockReset();
    mocks.getInitData.mockReset();
    mocks.generateKeyRequest.mockReset();
  });

  it("should update the session after getLicense resolves with a license", async () => {
    await checkGetLicense({
      isGetLicensePromiseBased: true,
      configuredRetries: undefined,
      configuredTimeout: undefined,
      getTimeout: () => undefined,
      nbRetries: 0,
      ignoreLicenseRequests: false,
    });
  });

  it("should update the session after getLicense returns a license directly", async () => {
    await checkGetLicense({
      isGetLicensePromiseBased: false,
      configuredRetries: undefined,
      configuredTimeout: undefined,
      getTimeout: () => undefined,
      nbRetries: 0,
      ignoreLicenseRequests: false,
    });
  });

  it("should not update the session after getLicense resolves with null", async () => {
    await checkGetLicense({
      isGetLicensePromiseBased: true,
      configuredRetries: undefined,
      configuredTimeout: undefined,
      getTimeout: () => undefined,
      nbRetries: 0,
      ignoreLicenseRequests: true,
    });
  });

  it("should not update the session after getLicense returns null directly", async () => {
    await checkGetLicense({
      isGetLicensePromiseBased: false,
      configuredRetries: undefined,
      configuredTimeout: undefined,
      getTimeout: () => undefined,
      nbRetries: 0,
      ignoreLicenseRequests: true,
    });
  });

  it("should be able to retry maximum two times and fail after 3 tries with a rejected promise", async () => {
    await checkGetLicense({
      isGetLicensePromiseBased: true,
      configuredRetries: undefined,
      configuredTimeout: undefined,
      getTimeout: () => undefined,
      nbRetries: 3,
      ignoreLicenseRequests: false,
    });
  });

  it("should be able to retry maximum two times and fail after 3 tries with a thrown error", async () => {
    await checkGetLicense({
      isGetLicensePromiseBased: false,
      configuredRetries: undefined,
      configuredTimeout: undefined,
      getTimeout: () => undefined,
      nbRetries: 3,
      ignoreLicenseRequests: true,
    });
  });

  it("should be able to retry two times and succeed after a time with a resolved license", async () => {
    await checkGetLicense({
      isGetLicensePromiseBased: true,
      configuredRetries: undefined,
      configuredTimeout: undefined,
      getTimeout: () => undefined,
      nbRetries: 2,
      ignoreLicenseRequests: false,
    });
  });

  it("should be able to retry two times and succeed after a time with a returned license", async () => {
    await checkGetLicense({
      isGetLicensePromiseBased: false,
      configuredRetries: undefined,
      configuredTimeout: undefined,
      getTimeout: () => undefined,
      nbRetries: 2,
      ignoreLicenseRequests: false,
    });
  });

  it("should be able to retry two times and succeed after a time with a resolved null", async () => {
    await checkGetLicense({
      isGetLicensePromiseBased: true,
      configuredRetries: undefined,
      configuredTimeout: undefined,
      getTimeout: () => undefined,
      nbRetries: 2,
      ignoreLicenseRequests: true,
    });
  });

  it("should be able to retry two times and succeed after a time with a returned null", async () => {
    await checkGetLicense({
      isGetLicensePromiseBased: false,
      configuredRetries: undefined,
      configuredTimeout: undefined,
      getTimeout: () => undefined,
      nbRetries: 2,
      ignoreLicenseRequests: true,
    });
  });

  it("should be able to retry two times and succeed after a time with a resolved license", async () => {
    await checkGetLicense({
      isGetLicensePromiseBased: true,
      configuredRetries: undefined,
      configuredTimeout: undefined,
      getTimeout: () => undefined,
      nbRetries: 1,
      ignoreLicenseRequests: false,
    });
  });

  it("should be able to retry two times and succeed after a time with a returned license", async () => {
    await checkGetLicense({
      isGetLicensePromiseBased: false,
      configuredRetries: undefined,
      configuredTimeout: undefined,
      getTimeout: () => undefined,
      nbRetries: 1,
      ignoreLicenseRequests: false,
    });
  });

  it("should be able to retry two times and succeed after a time with a resolved null", async () => {
    await checkGetLicense({
      isGetLicensePromiseBased: true,
      configuredRetries: undefined,
      configuredTimeout: undefined,
      getTimeout: () => undefined,
      nbRetries: 1,
      ignoreLicenseRequests: true,
    });
  });

  it("should be able to retry two times and succeed after a time with a returned null", async () => {
    await checkGetLicense({
      isGetLicensePromiseBased: false,
      configuredRetries: undefined,
      configuredTimeout: undefined,
      getTimeout: () => undefined,
      nbRetries: 1,
      ignoreLicenseRequests: true,
    });
  });

  it("should be able to fetch a license directly even when getLicenseConfig.retry is set to `0`", async () => {
    await checkGetLicense({
      isGetLicensePromiseBased: true,
      configuredRetries: 0,
      configuredTimeout: undefined,
      getTimeout: () => undefined,
      nbRetries: 0,
      ignoreLicenseRequests: false,
    });
  });

  it("should fail after first failure when getLicenseConfig.retry is set to `0`", async () => {
    await checkGetLicense({
      isGetLicensePromiseBased: true,
      configuredRetries: 0,
      configuredTimeout: undefined,
      getTimeout: () => undefined,
      nbRetries: 1,
      ignoreLicenseRequests: false,
    });
  });

  it("should fail after two failures when getLicenseConfig.retry is set to `1`", async () => {
    await checkGetLicense({
      isGetLicensePromiseBased: true,
      configuredRetries: 1,
      configuredTimeout: undefined,
      getTimeout: () => undefined,
      nbRetries: 2,
      ignoreLicenseRequests: false,
    });
  });

  it("should not fail after one failure when getLicenseConfig.retry is set to `1`", async () => {
    await checkGetLicense({
      isGetLicensePromiseBased: true,
      configuredRetries: 1,
      configuredTimeout: undefined,
      getTimeout: () => undefined,
      nbRetries: 1,
      ignoreLicenseRequests: false,
    });
  });

  it("should not fail after 6 failures when getLicenseConfig.retry is set to `6`", async () => {
    await checkGetLicense({
      isGetLicensePromiseBased: true,
      configuredRetries: 6,
      configuredTimeout: undefined,
      getTimeout: () => undefined,
      nbRetries: 6,
      ignoreLicenseRequests: false,
    });
  }, 25000);

  it("should fail after 7 failures when getLicenseConfig.retry is set to `6`", async () => {
    await checkGetLicense({
      isGetLicensePromiseBased: true,
      configuredRetries: 6,
      configuredTimeout: undefined,
      getTimeout: () => undefined,
      nbRetries: 7,
      ignoreLicenseRequests: false,
    });
  }, 25000);

  it("should not fail after 5 failures when getLicenseConfig.retry is set to `Infinity`", async () => {
    await checkGetLicense({
      isGetLicensePromiseBased: true,
      configuredRetries: Infinity,
      configuredTimeout: undefined,
      getTimeout: () => undefined,
      nbRetries: 5,
      ignoreLicenseRequests: false,
    });
  }, 15000);

  it("should emit KEY_LOAD_TIMEOUT when getLicense times out", async () => {
    const initData = new Uint8Array([54, 55, 75]);
    const initDataEvent = {
      type: "cenc",
      values: [{ systemId: "15", data: initData }],
    };
    const challenge = formatFakeChallengeFromInitData(initData, "cenc");
    const mediaKeySession = new MediaKeySessionImpl();
    vi.spyOn(MediaKeysImpl.prototype, "createSession").mockReturnValue(mediaKeySession);
    const mockGetLicense = vi.fn(() => {
      return new Promise<BufferSource>((resolve) => {
        setTimeout(() => {
          resolve(challenge);
        }, 30);
      });
    });

    const videoElt = document.createElement("video");
    mockCompat(mocks);

    await new Promise<void>((res, rej) => {
      const ksConfig: IKeySystemOption[] = [
        {
          type: "com.widevine.alpha",
          getLicense: mockGetLicense,
          getLicenseConfig: {
            retry: 0,
            timeout: 5,
          },
        },
      ];
      const eme = getEmeApiImplementation("auto");
      assert(eme !== null);
      const contentDecryptor = new ContentDecryptor(eme, videoElt, ksConfig);

      contentDecryptor.addEventListener("stateChange", (newState: number) => {
        if (newState !== ContentDecryptorState.WaitingForAttachment) {
          rej(new Error(`Unexpected state: ${newState}`));
          return;
        }
        contentDecryptor.removeEventListener("stateChange");
        contentDecryptor.attach();
      });

      contentDecryptor.addEventListener("warning", (warning: Error) => {
        rej(new Error(`Unexpected warning: ${warning.toString()}`));
      });

      contentDecryptor.addEventListener("error", (error: Error) => {
        try {
          expect(error).toBeInstanceOf(Error);
          expect((error as IPlayerError).name).toEqual("EncryptedMediaError");
          expect((error as IPlayerError).type).toEqual("ENCRYPTED_MEDIA_ERROR");
          expect((error as IPlayerError).code).toEqual("KEY_LOAD_TIMEOUT");
          expect((error as IPlayerError).message).toEqual(
            "KEY_LOAD_TIMEOUT: The license server took too much time to respond.",
          );
          expect(mockGetLicense).toHaveBeenCalledTimes(1);
          expect(mockGetLicense).toHaveBeenNthCalledWith(1, challenge, "license-request");
          contentDecryptor.dispose(undefined);
          res();
        } catch (e) {
          rej(e);
        }
      });

      contentDecryptor.onInitializationData(initDataEvent);
    });
  });
});

/**
 * HUGE function to check most getLicense scenarios.
 * @param {Object} opts
 */
async function checkGetLicense({
  isGetLicensePromiseBased,
  configuredRetries,
  configuredTimeout,
  getTimeout,
  nbRetries,
  ignoreLicenseRequests,
}: {
  /**
   * Set it to false if you want to return directly a value from
   * `getLicense` (i.e. not wrapped in a Promise).
   * This is valable both for success, and errors (which will be either
   * thrown directly or wrapped in a rejecting Promise).
   *
   * This value will be forced to `true` if the `getTimeout` call for the
   * corresponding `getLicense` returns something other than `undefined`.
   */
  isGetLicensePromiseBased: boolean;
  /** Maximum amount of retries: value of getLicenseConfig.retry. */
  configuredRetries: number | undefined;
  /**
   * Return getLicense timeout after which the response will be emitted, in
   * milliseconds.
   * Put to `undefined` for no timeout at all (synchronous).
   * Note that if a timeout is given, getLicense will return a Promise.
   * As such, `isGetLicensePromiseBased` will be ignored.
   */
  getTimeout: (callIdx: number) => number | undefined;
  /** Maximum configured timeout: value of getLicenseConfig.timeout. */
  configuredTimeout: number | undefined;
  /**
   * Nb of times getLicense should fail in a row.
   * If put at a higher value than `configuredRetries`, no license will be
   * obtained.
   */
  nbRetries: number;
  /**
   * If `true`, getLicense will return `null` - to ignore a request - when
   * it succeed.
   */
  ignoreLicenseRequests: boolean;
}): Promise<void> {
  const initData = new Uint8Array([54, 55, 75]);
  const initDataEvent = {
    type: "cenc",
    values: [{ systemId: "15", data: initData }],
  };
  const challenge = formatFakeChallengeFromInitData(initData, "cenc");
  // == mocks ==
  const mediaKeySession = new MediaKeySessionImpl();
  vi.spyOn(MediaKeysImpl.prototype, "createSession").mockReturnValue(mediaKeySession);
  const mockUpdate = vi.spyOn(mediaKeySession, "update");
  let remainingRetries = nbRetries;
  const mockGetLicense = vi.fn((): BufferSource | Promise<BufferSource | null> | null => {
    const callIdx = nbRetries - remainingRetries;
    const timeout = getTimeout(callIdx);
    if (remainingRetries === 0) {
      const challengeU8 = new Uint8Array(challenge);
      const result = ignoreLicenseRequests ? null : concat(challengeU8, challengeU8);
      if (timeout !== undefined) {
        return new Promise((resolve) => {
          setTimeout(() => resolve(result), timeout);
        });
      }
      return isGetLicensePromiseBased ? Promise.resolve(result) : result;
    }
    remainingRetries--;
    if (timeout !== undefined) {
      return new Promise((_, reject) => {
        setTimeout(() => {
          reject("AAAA");
        }, timeout);
      });
    }
    if (!isGetLicensePromiseBased) {
      throw new Error("AAAA");
    }
    return Promise.reject(new Error("AAAA"));
  });
  const videoElt = document.createElement("video");
  mockCompat(mocks);
  return new Promise((res, rej) => {
    // == vars ==
    /** Default keySystems configuration used in our tests. */
    const maxRetries = configuredRetries === undefined ? 2 : configuredRetries;
    const shouldFail = nbRetries > maxRetries;
    let warningsLeft = nbRetries;
    const ksConfig: IKeySystemOption[] = [
      {
        type: "com.widevine.alpha",
        getLicense: mockGetLicense,
        getLicenseConfig:
          configuredRetries !== undefined || configuredTimeout !== undefined
            ? { retry: configuredRetries, timeout: configuredTimeout }
            : undefined,
      },
    ];
    function checkKeyLoadError(error: unknown) {
      try {
        expect(error).toBeInstanceOf(Error);
        expect((error as IPlayerError).name).toEqual("EncryptedMediaError");
        expect((error as IPlayerError).type).toEqual("ENCRYPTED_MEDIA_ERROR");
        expect((error as IPlayerError).code).toEqual("KEY_LOAD_ERROR");
        expect((error as IPlayerError).message).toEqual("AAAA");
      } catch (e) {
        rej(e);
      }
    }

    // == test ==
    const eme = getEmeApiImplementation("auto");
    assert(eme !== null);
    const contentDecryptor = new ContentDecryptor(eme, videoElt, ksConfig);

    contentDecryptor.addEventListener("stateChange", (newState: number) => {
      if (newState !== ContentDecryptorState.WaitingForAttachment) {
        rej(new Error(`Unexpected state: ${newState}`));
      }
      contentDecryptor.removeEventListener("stateChange");
      contentDecryptor.attach();
    });

    contentDecryptor.addEventListener("error", (error: Error) => {
      if (shouldFail) {
        try {
          checkKeyLoadError(error);
          expect(mockGetLicense).toHaveBeenCalledTimes(maxRetries + 1);
          for (let i = 1; i <= maxRetries + 1; i++) {
            // TODO there's seem to be an issue with how vitest check Uint8Array
            // equality
            expect(mockGetLicense).toHaveBeenNthCalledWith(
              i,
              challenge,
              "license-request",
            );
          }
          expect(mockUpdate).toHaveBeenCalledTimes(0);
          res();
        } catch (e) {
          rej(e);
        }
      } else {
        rej(new Error(`Unexpected error: ${error.toString()}`));
      }
    });

    contentDecryptor.addEventListener("warning", (warning: Error) => {
      if (warningsLeft-- > 0) {
        try {
          checkKeyLoadError(warning);
          const requestIdx = nbRetries - remainingRetries;
          expect(mockGetLicense).toHaveBeenCalledTimes(requestIdx);
          // TODO there's seem to be an issue with how vitest check Uint8Array
          // equality
          expect(mockGetLicense).toHaveBeenNthCalledWith(
            requestIdx,
            challenge,
            "license-request",
          );
        } catch (e) {
          rej(e);
        }
      } else {
        rej(new Error(`Unexpected warning: ${warning.toString()}`));
      }
    });

    contentDecryptor.onInitializationData(initDataEvent);

    if (!shouldFail) {
      let timeout: number;
      switch (nbRetries) {
        case 0:
          timeout = 100;
          break;
        case 1:
          timeout = 300;
          break;
        case 2:
          timeout = 800;
          break;
        case 3:
          timeout = 2000;
          break;
        case 4:
          timeout = 4000;
          break;
        case 5:
          timeout = 8000;
          break;
        case 6:
          timeout = 12000;
          break;
        default:
          timeout = 16000;
      }
      setTimeout(() => {
        try {
          if (ignoreLicenseRequests) {
            expect(mockUpdate).toHaveBeenCalledTimes(0);
          } else {
            const license = concat(challenge, challenge);
            expect(mockUpdate).toHaveBeenCalledTimes(1);
            expect(mockUpdate).toHaveBeenCalledWith(license);
          }
          expect(mockGetLicense).toHaveBeenCalledTimes(nbRetries + 1);
          for (let i = 1; i <= nbRetries + 1; i++) {
            // TODO there's seem to be an issue with how vitest check Uint8Array
            // equality
            expect(mockGetLicense).toHaveBeenNthCalledWith(
              i,
              challenge,
              "license-request",
            );
          }
          contentDecryptor.dispose(undefined);
          res();
        } catch (e) {
          rej(e);
        }
      }, timeout);
    }
  });
}
