/**
 * Copyright 2015 CANAL+ Group
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

import type {
  IMediaElement,
  IMediaKeySystemAccess,
} from "../../compat/browser_compatibility_types";
import { canRelyOnRequestMediaKeySystemAccess } from "../../compat/can_rely_on_request_media_key_system_access";
import type { IEmeApiImplementation } from "../../compat/eme";
import {
  generatePlayReadyInitData,
  DUMMY_PLAY_READY_HEADER,
} from "../../compat/generate_init_data";
import shouldRenewMediaKeySystemAccess from "../../compat/should_renew_media_key_system_access";
import config from "../../config";
import { EncryptedMediaError } from "../../errors";
import log from "../../log";
import type { IKeySystemOption } from "../../public_types";
import { parseCodec } from "../../utils/are_codecs_compatible";
import arrayIncludes from "../../utils/array_includes";
import flatMap from "../../utils/flat_map";
import isNullOrUndefined from "../../utils/is_null_or_undefined";
import type { CancellationSignal } from "../../utils/task_canceller";
import MediaKeysAttacher from "./utils/media_keys_attacher";

type MediaKeysRequirement = "optional" | "required" | "not-allowed";

export type ICodecSupportList = Array<{
  codec: string;
  mimeType: string;
  result: boolean;
}>;

export interface IMediaKeySystemAccessInfos {
  /** `MediaKeySystemAccess` to use to create `MediaKeys` instances. */
  mediaKeySystemAccess: IMediaKeySystemAccess;
  /**
   * The MediaKeySystemConfiguration that has been provided to the
   * `requestMediaKeySystemAccess` API.
   */
  askedConfiguration: MediaKeySystemConfiguration;
  /**
   * Corresponding `keySystems` element that has led to the creation of the
   * `MediaKeySystemAccess`.
   */
  options: IKeySystemOption;
  /** Information on supported or unsupported codec on that `MediaKeySystemAccess`. */
  codecSupport: ICodecSupportList;
}

export interface IReuseMediaKeySystemAccessEvent {
  type: "reuse-media-key-system-access";
  value: IMediaKeySystemAccessInfos;
}

export interface ICreateMediaKeySystemAccessEvent {
  type: "create-media-key-system-access";
  value: IMediaKeySystemAccessInfos;
}

export type IFoundMediaKeySystemAccessEvent =
  | IReuseMediaKeySystemAccessEvent
  | ICreateMediaKeySystemAccessEvent;

interface IKeySystemType {
  /**
   * Generic name for the key system. e.g. "clearkey", "widevine", "playready".
   * Can be used to make exceptions depending on it.
   * `undefined` if unknown or if this concept does not exist for this key
   * system.
   */
  keyName: string | undefined;
  /** KeySystem type (e.g. "com.widevine.alpha") */
  keyType: string;
  /** The original keySystem object */
  keySystemOptions: IKeySystemOption;
}

/**
 * Takes a `newConfiguration` `MediaKeySystemConfiguration`, that is intended
 * for the creation of a `MediaKeySystemAccess`, and a `prevConfiguration`
 * `MediaKeySystemConfiguration`, that was the one used at creation of the
 * current `MediaKeySystemAccess`.
 *
 * This function will then return `true` if it determined that the new
 * configuration is conceptually compatible with the one used before, and
 * `false` otherwise.
 * @param {Object} newConfiguration - New wanted `MediaKeySystemConfiguration`
 * @param {Object} prevConfiguration - The `MediaKeySystemConfiguration` that is
 * relied on util now.
 * @returns {boolean} - `true` if `newConfiguration` is compatible with
 * `prevConfiguration`.
 */
function isNewMediaKeySystemConfigurationCompatibleWithPreviousOne(
  newConfiguration: MediaKeySystemConfiguration,
  prevConfiguration: MediaKeySystemConfiguration,
): boolean {
  if (newConfiguration.label !== prevConfiguration.label) {
    return false;
  }

  const prevDistinctiveIdentifier = prevConfiguration.distinctiveIdentifier ?? "optional";
  const newDistinctiveIdentifier = newConfiguration.distinctiveIdentifier ?? "optional";
  if (prevDistinctiveIdentifier !== newDistinctiveIdentifier) {
    return false;
  }

  const prevPersistentState = prevConfiguration.persistentState ?? "optional";
  const newPersistentState = newConfiguration.persistentState ?? "optional";
  if (prevPersistentState !== newPersistentState) {
    return false;
  }

  const prevInitDataTypes = prevConfiguration.initDataTypes ?? [];
  const newInitDataTypes = newConfiguration.initDataTypes ?? [];
  if (!isArraySubsetOf(newInitDataTypes, prevInitDataTypes)) {
    return false;
  }

  const prevSessionTypes = prevConfiguration.sessionTypes ?? [];
  const newSessionTypes = newConfiguration.sessionTypes ?? [];
  if (!isArraySubsetOf(newSessionTypes, prevSessionTypes)) {
    return false;
  }

  for (const prop of ["audioCapabilities", "videoCapabilities"] as const) {
    const newCapabilities = newConfiguration[prop] ?? [];
    const prevCapabilities = prevConfiguration[prop] ?? [];
    const wasFound = newCapabilities.every((n) => {
      for (let i = 0; i < prevCapabilities.length; i++) {
        const prevCap = prevCapabilities[i];
        if (
          (prevCap.robustness ?? "") === (n.robustness ?? "") ||
          (prevCap.encryptionScheme ?? null) === (n.encryptionScheme ?? null) ||
          (prevCap.robustness ?? "") === (n.robustness ?? "")
        ) {
          return true;
        }
      }
      return false;
    });
    if (!wasFound) {
      return false;
    }
  }

  return true;
}

/**
 * Find key system canonical name from key system type.
 * @param {string} ksType - Obtained via inversion
 * @returns {string|undefined} - Either the canonical name, or undefined.
 */
function findKeySystemCanonicalName(ksType: string): string | undefined {
  const { EME_KEY_SYSTEMS } = config.getCurrent();
  for (const ksName of Object.keys(EME_KEY_SYSTEMS)) {
    if (arrayIncludes(EME_KEY_SYSTEMS[ksName] as string[], ksType)) {
      return ksName;
    }
  }
  return undefined;
}

/**
 * Build configuration for the requestMediaKeySystemAccess EME API, based
 * on the current keySystem object.
 * @param {Object} keySystemTypeInfo
 * @returns {Array.<Object>} - Configuration to give to the
 * requestMediaKeySystemAccess API.
 */
function buildKeySystemConfigurations(
  keySystemTypeInfo: IKeySystemType,
): MediaKeySystemConfiguration[] {
  const { keyName, keyType, keySystemOptions: keySystem } = keySystemTypeInfo;
  let sessionTypes: string[];
  let persistentState: MediaKeysRequirement = "optional";
  let distinctiveIdentifier: MediaKeysRequirement = "optional";

  if (Array.isArray(keySystem.wantedSessionTypes)) {
    sessionTypes = keySystem.wantedSessionTypes;
    if (
      arrayIncludes(keySystem.wantedSessionTypes, "persistent-license") &&
      !isNullOrUndefined(keySystem.persistentLicenseConfig)
    ) {
      persistentState = "required";
    }
  } else if (!isNullOrUndefined(keySystem.persistentLicenseConfig)) {
    persistentState = "required";
    sessionTypes = ["persistent-license"];
  } else {
    sessionTypes = ["temporary"];
  }

  if (!isNullOrUndefined(keySystem.persistentState)) {
    persistentState = keySystem.persistentState;
  }

  if (!isNullOrUndefined(keySystem.distinctiveIdentifier)) {
    distinctiveIdentifier = keySystem.distinctiveIdentifier;
  }
  const {
    EME_DEFAULT_AUDIO_CODECS,
    EME_DEFAULT_VIDEO_CODECS,
    EME_DEFAULT_WIDEVINE_ROBUSTNESSES,
    EME_DEFAULT_PLAYREADY_RECOMMENDATION_ROBUSTNESSES,
  } = config.getCurrent();

  // From the W3 EME spec, we have to provide videoCapabilities and
  // audioCapabilities.
  // These capabilities must specify a codec (even though you can use a
  // completely different codec afterward).
  // It is also strongly recommended to specify the required security
  // robustness. As we do not want to forbide any security level, we specify
  // every existing security level from highest to lowest so that the best
  // security level is selected.
  // More details here:
  // https://storage.googleapis.com/wvdocs/Chrome_EME_Changes_and_Best_Practices.pdf
  // https://www.w3.org/TR/encrypted-media/#get-supported-configuration-and-consent

  let audioCapabilities: MediaKeySystemMediaCapability[];
  let videoCapabilities: MediaKeySystemMediaCapability[];

  const { audioCapabilitiesConfig, videoCapabilitiesConfig } = keySystem;
  if (audioCapabilitiesConfig?.type === "full") {
    audioCapabilities = audioCapabilitiesConfig.value;
  } else {
    let audioRobustnesses: Array<string | undefined>;
    if (audioCapabilitiesConfig?.type === "robustness") {
      audioRobustnesses = audioCapabilitiesConfig.value;
    } else if (keyName === "widevine") {
      audioRobustnesses = EME_DEFAULT_WIDEVINE_ROBUSTNESSES;
    } else if (keyType === "com.microsoft.playready.recommendation") {
      audioRobustnesses = EME_DEFAULT_PLAYREADY_RECOMMENDATION_ROBUSTNESSES;
    } else {
      audioRobustnesses = [];
    }

    if (audioRobustnesses.length === 0) {
      audioRobustnesses.push(undefined);
    }

    const audioCodecs =
      audioCapabilitiesConfig?.type === "contentType"
        ? audioCapabilitiesConfig.value
        : EME_DEFAULT_AUDIO_CODECS;

    audioCapabilities = flatMap(audioRobustnesses, (robustness) =>
      audioCodecs.map((contentType) => {
        return robustness !== undefined ? { contentType, robustness } : { contentType };
      }),
    );
  }
  if (videoCapabilitiesConfig?.type === "full") {
    videoCapabilities = videoCapabilitiesConfig.value;
  } else {
    let videoRobustnesses: Array<string | undefined>;
    if (videoCapabilitiesConfig?.type === "robustness") {
      videoRobustnesses = videoCapabilitiesConfig.value;
    } else if (keyName === "widevine") {
      videoRobustnesses = EME_DEFAULT_WIDEVINE_ROBUSTNESSES;
    } else if (keyType === "com.microsoft.playready.recommendation") {
      videoRobustnesses = EME_DEFAULT_PLAYREADY_RECOMMENDATION_ROBUSTNESSES;
    } else {
      videoRobustnesses = [];
    }

    if (videoRobustnesses.length === 0) {
      videoRobustnesses.push(undefined);
    }

    const videoCodecs =
      videoCapabilitiesConfig?.type === "contentType"
        ? videoCapabilitiesConfig.value
        : EME_DEFAULT_VIDEO_CODECS;

    videoCapabilities = flatMap(videoRobustnesses, (robustness) =>
      videoCodecs.map((contentType) => {
        return robustness !== undefined ? { contentType, robustness } : { contentType };
      }),
    );
  }

  const wantedMediaKeySystemConfiguration: MediaKeySystemConfiguration = {
    initDataTypes: ["cenc"],
    videoCapabilities,
    audioCapabilities,
    distinctiveIdentifier,
    persistentState,
    sessionTypes,
  };

  if (audioCapabilitiesConfig !== undefined) {
    if (videoCapabilitiesConfig !== undefined) {
      return [wantedMediaKeySystemConfiguration];
    }
    return [
      wantedMediaKeySystemConfiguration,
      {
        ...wantedMediaKeySystemConfiguration,
        // Re-try without `videoCapabilities` in case the EME implementation is
        // buggy
        videoCapabilities: undefined,
      } as unknown as MediaKeySystemConfiguration,
    ];
  } else if (videoCapabilitiesConfig !== undefined) {
    return [
      wantedMediaKeySystemConfiguration,
      {
        ...wantedMediaKeySystemConfiguration,
        // Re-try without `audioCapabilities` in case the EME implementation is
        // buggy
        audioCapabilities: undefined,
      } as unknown as MediaKeySystemConfiguration,
    ];
  }

  return [
    wantedMediaKeySystemConfiguration,

    // Some legacy implementations have issues with `audioCapabilities` and
    // `videoCapabilities`, so we're including a supplementary
    // `MediaKeySystemConfiguration` without those properties.
    {
      ...wantedMediaKeySystemConfiguration,
      audioCapabilities: undefined,
      videoCapabilities: undefined,
    } as unknown as MediaKeySystemConfiguration,
  ];
}
/**
 * Extract from the current mediaKeys the supported Codecs.
 * @param {Object} initialConfiguration - The MediaKeySystemConfiguration given
 * to the `navigator.requestMediaKeySystemAccess` API.
 * @param {Object | undefined} mksConfiguration - The result of
 * getConfiguration() of the media keys.
 * @return {Array} The list of supported codec by the CDM.
 */
export function extractCodecSupportListFromConfiguration(
  initialConfiguration: MediaKeySystemConfiguration,
  mksConfiguration: MediaKeySystemConfiguration,
): ICodecSupportList {
  const testedAudioCodecs =
    initialConfiguration.audioCapabilities?.map((v) => v.contentType) ?? [];
  const testedVideoCodecs =
    initialConfiguration.videoCapabilities?.map((v) => v.contentType) ?? [];
  const testedCodecs: string[] = testedAudioCodecs
    .concat(testedVideoCodecs)
    .filter((c): c is string => c !== undefined);
  const supportedVideoCodecs = mksConfiguration.videoCapabilities?.map(
    (entry) => entry.contentType,
  );
  const supportedAudioCodecs = mksConfiguration.audioCapabilities?.map(
    (entry) => entry.contentType,
  );

  const supportedCodecs = [
    ...(supportedVideoCodecs ?? []),
    ...(supportedAudioCodecs ?? []),
  ].filter((contentType): contentType is string => contentType !== undefined);

  if (supportedCodecs.length === 0) {
    // Some legacy implementations have issues with `audioCapabilities` and
    // `videoCapabilities` in requestMediaKeySystemAccess so the codecs are not provided.
    // In this case, we can't tell which codec is supported or not.
    // Let's instead provide an empty list.

    // Note: on a correct EME implementation, if a list of codec is provided
    // with `audioCapabilities` or `videoCapabilities`, but none of them is supported,
    // requestMediaKeySystemAccess should yield an error "NotSupported" and we should
    // never reach this code.
    return [];
  }

  const codecSupportList: ICodecSupportList = testedCodecs.map((codec) => {
    const { codecs, mimeType } = parseCodec(codec);
    const isSupported = arrayIncludes(supportedCodecs, codec);
    return {
      codec: codecs,
      mimeType,
      result: isSupported,
    };
  });
  return codecSupportList;
}
/**
 * Try to find a compatible key system from the keySystems array given.
 *
 * This function will request a MediaKeySystemAccess based on the various
 * keySystems provided.
 *
 * This Promise might either:
 *   - resolves the MediaKeySystemAccess and the keySystems as an object, when
 *     found.
 *   - reject if no compatible key system has been found.
 *
 * @param {Object} eme - current EME implementation
 * @param {HTMLMediaElement} mediaElement
 * @param {Array.<Object>} keySystemsConfigs - The keySystems you want to test.
 * @param {Object} cancelSignal
 * @returns {Promise.<Object>}
 */
export default function getMediaKeySystemAccess(
  eme: IEmeApiImplementation,
  mediaElement: IMediaElement,
  keySystemsConfigs: IKeySystemOption[],
  cancelSignal: CancellationSignal,
): Promise<IFoundMediaKeySystemAccessEvent> {
  log.info("DRM", "Searching for compatible MediaKeySystemAccess");
  /** Array of set keySystems for this content. */
  const keySystemsType: IKeySystemType[] = keySystemsConfigs.reduce(
    (arr: IKeySystemType[], keySystemOptions) => {
      const { EME_KEY_SYSTEMS } = config.getCurrent();
      const managedRDNs = EME_KEY_SYSTEMS[keySystemOptions.type];
      let ksType;

      if (!isNullOrUndefined(managedRDNs)) {
        ksType = managedRDNs.map((keyType) => {
          const keyName = keySystemOptions.type;
          return { keyName, keyType, keySystemOptions };
        });
      } else {
        const keyName = findKeySystemCanonicalName(keySystemOptions.type);
        const keyType = keySystemOptions.type;
        ksType = [{ keyName, keyType, keySystemOptions }];
      }

      return arr.concat(ksType);
    },
    [],
  );

  return recursivelyTestKeySystems(0);

  /**
   * Test all key system configuration stored in `keySystemsType` one by one
   * recursively.
   * Returns a Promise which will emit the MediaKeySystemAccess if one was
   * found compatible with one of the configurations or just reject if none
   * were found to be compatible.
   * @param {Number} index - The index in `keySystemsType` to start from.
   * Should be set to `0` when calling directly.
   * @returns {Promise.<Object>}
   */
  async function recursivelyTestKeySystems(
    index: number,
  ): Promise<IFoundMediaKeySystemAccessEvent> {
    // if we iterated over the whole keySystemsType Array, quit on error
    if (index >= keySystemsType.length) {
      throw new EncryptedMediaError(
        "INCOMPATIBLE_KEYSYSTEMS",
        "No key system compatible with your wanted " +
          "configuration has been found in the current " +
          "browser.",
        {
          keyStatuses: undefined,
          keySystemConfiguration: undefined,
          keySystem: undefined,
        },
      );
    }

    if (isNullOrUndefined(eme.requestMediaKeySystemAccess)) {
      throw new Error("requestMediaKeySystemAccess is not implemented in your browser.");
    }

    const chosenType = keySystemsType[index];
    const { keyType, keySystemOptions } = chosenType;

    const keySystemConfigurations = buildKeySystemConfigurations(chosenType);

    log.debug(`DRM`, `Request keysystem access`, {
      keyType,
      index,
      length: keySystemsType.length,
    });

    let keySystemAccess;
    const currentState = await MediaKeysAttacher.getAttachedMediaKeysState(mediaElement);
    for (let configIdx = 0; configIdx < keySystemConfigurations.length; configIdx++) {
      const keySystemConfiguration = keySystemConfigurations[configIdx];

      // Check if the current `MediaKeySystemAccess` created cannot be reused here
      if (
        currentState !== null &&
        !shouldRenewMediaKeySystemAccess(currentState.mediaKeySystemAccess.keySystem) &&
        // TODO: Do it with MediaKeySystemAccess.prototype.keySystem instead?
        keyType === currentState.mediaKeySystemAccess.keySystem &&
        eme.implementation === currentState.emeImplementation.implementation &&
        isNewMediaKeySystemConfigurationCompatibleWithPreviousOne(
          keySystemConfiguration,
          currentState.askedConfiguration,
        )
      ) {
        log.info("DRM", "Found cached compatible keySystem");
        return Promise.resolve({
          type: "reuse-media-key-system-access" as const,
          value: {
            mediaKeySystemAccess: currentState.mediaKeySystemAccess,
            askedConfiguration: currentState.askedConfiguration,
            options: keySystemOptions,
            codecSupport: extractCodecSupportListFromConfiguration(
              currentState.askedConfiguration,
              currentState.mediaKeySystemAccess.getConfiguration(),
            ),
          },
        });
      }

      try {
        keySystemAccess = await testKeySystem(eme, keyType, [keySystemConfiguration]);
        log.info("DRM", "Found compatible keysystem", {
          keyType,
          index,
          configIndex: configIdx,
        });
        return {
          type: "create-media-key-system-access" as const,
          value: {
            options: keySystemOptions,
            mediaKeySystemAccess: keySystemAccess,
            askedConfiguration: keySystemConfiguration,
            codecSupport: extractCodecSupportListFromConfiguration(
              keySystemConfiguration,
              keySystemAccess.getConfiguration(),
            ),
          },
        };
      } catch (_) {
        log.debug("DRM", "Rejected access to keysystem", {
          keyType,
          index,
          configIndex: configIdx,
        });
        if (cancelSignal.cancellationError !== null) {
          throw cancelSignal.cancellationError;
        }
      }
    }
    return recursivelyTestKeySystems(index + 1);
  }
}

/**
 * Test a key system configuration, resolves with the MediaKeySystemAccess
 * or reject if the key system is unsupported.
 * @param {Object} eme - current EME implementation
 * @param {string} keyType - The KeySystem string to test (ex: com.microsoft.playready.recommendation)
 * @param {Array.<MediaKeySystemMediaCapability>} keySystemConfigurations - Configurations for this keySystem
 * @returns Promise resolving with the MediaKeySystemAccess. Rejects if unsupported.
 */
export async function testKeySystem(
  eme: IEmeApiImplementation,
  keyType: string,
  keySystemConfigurations: MediaKeySystemConfiguration[],
): Promise<IMediaKeySystemAccess> {
  const keySystemAccess = await eme.requestMediaKeySystemAccess(
    keyType,
    keySystemConfigurations,
  );

  if (!canRelyOnRequestMediaKeySystemAccess(keyType)) {
    try {
      const mediaKeys = await keySystemAccess.createMediaKeys();
      const session = mediaKeys.createSession();
      const initData = generatePlayReadyInitData(DUMMY_PLAY_READY_HEADER);
      await session.generateRequest("cenc", initData);
      session.close().catch(() => {
        log.warn("DRM", "Failed to close the dummy session");
      });
    } catch (err) {
      log.debug("DRM", "KeySystemAccess was granted but it is not usable");
      throw err;
    }
  }
  return keySystemAccess;
}

/**
 * Returns `true` if `arr1`'s values are entirely contained in `arr2`.
 * @param {string} arr1
 * @param {string} arr2
 * @return {boolean}
 */
function isArraySubsetOf(arr1: string[], arr2: string[]): boolean {
  for (let i = 0; i < arr1.length; i++) {
    if (!arrayIncludes(arr2, arr1[i])) {
      return false;
    }
  }
  return true;
}
