import log from "../../../../log";
import arrayFind from "../../../../utils/array_find";
import type { IParsedRepresentation } from "../../types";
import type { IContentProtectionIntermediateRepresentation } from "../node_parser_types";

/**
 * Class whose purpose is to parse `<ContentProtection>` elements in a DASH MPD.
 *
 * This element has to be particularly considered because a ContentProtection
 * element can inherit another ContentProtection element coming before it yet
 * even after it in the MPD, through a system of "reference IDs".
 *
 * The idea here is that the main MPD parsing logic just needs to signal to this
 * class when a `ContentProtection` element is encountered - and to which
 * `Representation` it is associated, and this class will then perform the
 * ContentProtection-parsing operation as soon as it has all referenced
 * ContentProtection elements.
 *
 * @class ContentProtectionParser
 */
export default class ContentProtectionParser {
  /**
   * Stores `ContentProtection` elements that can be referenced, identified by
   * their `refId` attribute.
   */
  private _refs: Map<string, IContentProtectionIntermediateRepresentation>;
  /**
   * Storage of `ContentProtection` elements that could not yet have been parsed
   * due to not-yet resolved references.
   *
   * This is only possible if a `ContentProtection` element performs a
   * "forward-reference" to a later `ContentProtection` element that is not yet
   * known by this `ContentProtectionParser`.
   */
  private _stored: Array<
    [IParsedRepresentation, IContentProtectionIntermediateRepresentation]
  >;

  constructor() {
    this._refs = new Map();
    this._stored = [];
  }

  /**
   * Add new `IContentProtectionIntermediateRepresentation` objects that can
   * be relied on as a reference by later
   * `IContentProtectionIntermediateRepresentation` objects, without the need
   * to actually apply it to a Representation.
   * @param {Object} contentProtections
   */
  public addReferences(
    contentProtections: IContentProtectionIntermediateRepresentation[],
  ): void {
    for (const contentProt of contentProtections) {
      if (contentProt.attributes.refId !== undefined) {
        this._refs.set(contentProt.attributes.refId, contentProt);
      }
    }
  }

  /**
   * Add a new `IContentProtectionIntermediateRepresentation` object that should
   * be parsed with the result linked to the given `IParsedRepresentation`.
   * @param {Object} representation
   * @param {Object} contentProt
   */
  public add(
    representation: IParsedRepresentation,
    contentProt: IContentProtectionIntermediateRepresentation,
  ): void {
    if (!this._tryParsing(representation, contentProt, false)) {
      this._stored.push([representation, contentProt]);
    }

    if (contentProt.attributes.refId !== undefined) {
      this._refs.set(contentProt.attributes.refId, contentProt);
      this._resolveStoredRefs(false);
    }
  }

  /**
   * It is possible that even after parsing the full MPD,
   */
  public finalize(): void {
    this._resolveStoredRefs(true);
  }

  /**
   * Try to parse all ContentProtection that are currently waiting due to a
   * referenced ContentProtection not being known yet.
   *
   * Return `true` if all ContentProtection references could have been found
   * and `false` if at least one wasn't.
   *
   * The `force` parameter indicate what should be done if a reference linked
   * to a ContentProtection couldn't be resolved: if `false`, we just keep that
   * ContentProtection aside for later, if `true` we parse it right now even if
   * information could be missing.
   *
   * @param {boolean} force
   * @returns {boolean}
   */
  private _resolveStoredRefs(force: boolean): boolean {
    for (let i = this._stored.length - 1; i >= 0; i--) {
      const [representation, contentProt] = this._stored[i];
      if (this._tryParsing(representation, contentProt, force) || force) {
        this._stored.splice(i, 1);
      }
    }
    return this._stored.length === 0;
  }

  /**
   * Parse the `IContentProtectionIntermediateRepresentation` given and add the
   * corresponding attributes to the given `IParsedRepresentation` when done.
   *
   * Because the `IContentProtectionIntermediateRepresentation` may be
   * referencing another `IContentProtectionIntermediateRepresentation`, this
   * method might not succeed to do so if the referenced
   * `IContentProtectionIntermediateRepresentation` has not yet been encountered.
   *
   * In that last scenario, this method returns `false` and:
   *   - Either `force` is set to `true`, in which case what could be parsed
   *     will still be set on the `IParsedRepresentation`.
   *   - Either `force` is set to `false`, in which case the parsing of this
   *     `IContentProtectionIntermediateRepresentation` is skipped.
   * @param {Object} representation
   * @param {Object} contentProt
   * @param {boolean} force
   * @returns {boolean}
   */
  private _tryParsing(
    representation: IParsedRepresentation,
    contentProt: IContentProtectionIntermediateRepresentation,
    force: boolean,
  ): boolean {
    if (contentProt.attributes.ref === undefined) {
      // There's no reference, we can parse right away
      parseContentProtection(representation, contentProt);
      return true;
    }

    const referenced = this._getReferenced(contentProt.attributes.ref);
    if (referenced === undefined) {
      // Referenced ContentProtection not found, exit
      if (force) {
        log.warn("DASH: forcing the parsing of a referencing ContentProtection");
        parseContentProtection(representation, contentProt);
      }
      return false;
    }

    // Referenced ContentProtection found, let's inherit its attributes

    contentProt.children.cencPssh.push(...referenced.children.cencPssh);
    if (
      contentProt.attributes.keyId === undefined &&
      referenced.attributes.keyId !== undefined
    ) {
      contentProt.attributes.keyId = referenced.attributes.keyId;
    }
    if (
      contentProt.attributes.schemeIdUri === undefined &&
      referenced.attributes.schemeIdUri !== undefined
    ) {
      contentProt.attributes.schemeIdUri = referenced.attributes.schemeIdUri;
    }
    if (
      contentProt.attributes.value === undefined &&
      referenced.attributes.value !== undefined
    ) {
      contentProt.attributes.value = referenced.attributes.value;
    }
    parseContentProtection(representation, contentProt);
    return true;
  }

  /**
   * Returns an `IContentProtectionIntermediateRepresentation` based on its
   * "refId".
   * Returns `undefined` if it is not known yet.
   *
   * @param {string} refId
   * @returns {Object|undefined}
   */
  private _getReferenced(
    refId: string,
  ): IContentProtectionIntermediateRepresentation | undefined {
    return this._refs.get(refId);
  }
}

/**
 * Parses an `IContentProtectionIntermediateRepresentation` and update the
 * corresponding attributes on the given `IParsedRepresentation`.
 * @param {Object} representation
 * @param {Object} contentProtectionIr
 */
function parseContentProtection(
  representation: IParsedRepresentation,
  contentProtectionIr: IContentProtectionIntermediateRepresentation,
): void {
  let systemId: string | undefined;
  if (
    contentProtectionIr.attributes.schemeIdUri !== undefined &&
    contentProtectionIr.attributes.schemeIdUri.substring(0, 9) === "urn:uuid:"
  ) {
    systemId = contentProtectionIr.attributes.schemeIdUri
      .substring(9)
      .replace(/-/g, "")
      .toLowerCase();
  }
  if (
    contentProtectionIr.attributes.keyId !== undefined &&
    contentProtectionIr.attributes.keyId.length > 0
  ) {
    const kid = contentProtectionIr.attributes.keyId;
    if (representation.contentProtections === undefined) {
      representation.contentProtections = { keyIds: [kid], initData: [] };
    } else if (representation.contentProtections.keyIds === undefined) {
      representation.contentProtections.keyIds = [kid];
    } else {
      representation.contentProtections.keyIds.push(kid);
    }
  }

  if (systemId === undefined) {
    return;
  }
  const { cencPssh } = contentProtectionIr.children;
  const values: Array<{ systemId: string; data: Uint8Array }> = [];
  for (const data of cencPssh) {
    values.push({ systemId, data });
  }
  if (values.length === 0) {
    return;
  }
  if (representation.contentProtections === undefined) {
    representation.contentProtections = {
      keyIds: [],
      initData: [{ type: "cenc", values }],
    };
    return;
  }

  const cencInitData = arrayFind(
    representation.contentProtections.initData,
    (i) => i.type === "cenc",
  );
  if (cencInitData === undefined) {
    representation.contentProtections.initData.push({ type: "cenc", values });
  } else {
    cencInitData.values.push(...values);
  }
}
