/**
 * Represents the available hash algorithms used for Subresource Integrity.
 * @see {@link https://www.w3.org/TR/CSP2/#hash_algo}
 */
export type HashAlgorithm = "sha256" | "sha384" | "sha512";

/**
 * A constant object defining the supported hash algorithms and their corresponding string
 * representations for cryptographic operations. These algorithms are referenced by name when
 * working with hashing functions in Web Crypto APIs.
 */
export const supportedHashAlgorithms = {
  /** SHA-256 hash algorithm */
  sha256: "SHA-256",
  /** SHA-384 hash algorithm */
  sha384: "SHA-384",
  /** SHA-512 hash algorithm */
  sha512: "SHA-512",
} as const satisfies Record<HashAlgorithm, HashAlgorithmIdentifier>;

/**
 * A union type representing either an empty string or a valid hash algorithm.
 * The empty string is used when no hash algorithm is selected or is considered equal.
 */
export type PrioritizedHashAlgorithm = "" | HashAlgorithm;

/**
 * Function to prioritize two hash algorithms, returning the stronger or an empty string if both
 * are unsupported or equal.
 * @see {@link https://www.w3.org/TR/SRI/#dfn-getprioritizedhashfunction-a-b}
 * @param a The first hash algorithm to compare.
 * @param b The second hash algorithm to compare.
 * @returns The hash algorithm or an empty string if both algorithms are unsupported or equal.
 */
export type GetPrioritizedHashAlgorithm = (
  a: HashAlgorithm,
  b: HashAlgorithm,
) => PrioritizedHashAlgorithm;

/**
 * Function to prioritize two hash algorithms, returning the stronger or an empty string if both
 * are unsupported or equal.
 * @see {@link https://www.w3.org/TR/SRI/#dfn-getprioritizedhashfunction-a-b}
 * @param a The first hash algorithm to compare.
 * @param b The second hash algorithm to compare.
 * @returns The hash algorithm or an empty string if both algorithms are unsupported or equal.
 */
export function getPrioritizedHashAlgorithm(
  a: HashAlgorithm,
  b: HashAlgorithm,
): PrioritizedHashAlgorithm {
  if (a === b) return "";

  if (!(a in supportedHashAlgorithms)) {
    return b in supportedHashAlgorithms ? b : "";
  }

  if (!(b in supportedHashAlgorithms)) {
    return a in supportedHashAlgorithms ? a : "";
  }

  return a < b ? b : a;
}

/**
 * Regular expression for matching integrity metadata format.
 */
export const IntegrityMetadataRegex =
  /^(?<alg>sha256|sha384|sha512)-(?<val>(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?)(?:[?](?<opt>[\x21-\x7e]*))?$/;

/**
 * Regular expression for separating integrity metadata.
 */
export const SeparatorRegex = /[^\x21-\x7e]+/;

/**
 * Represents the structure of integrity metadata used for validating resources with Subresource
 * Integrity.
 */
export type IntegrityMetadataLike = {
  /** Hash algorithm */
  alg: PrioritizedHashAlgorithm;
  /** The base64-encoded hash value of the resource */
  val: string;
  /** Optional additional attributes */
  opt?: string[];
};

/**
 * Class representing integrity metadata, consisting of a hash algorithm and hash value.
 */
export class IntegrityMetadata implements IntegrityMetadataLike {
  /** Hash algorithm */
  alg: PrioritizedHashAlgorithm;
  /** The base64-encoded hash value of the resource */
  val: string;
  /** Optional additional attributes */
  opt: string[];

  /**
   * Creates an instance of `IntegrityMetadata` from a given object or string.
   * @param integrity The integrity metadata input, which can be a string or object.
   * @example
   * ```js
   * new IntegrityMetadata("sha256-MV9b23bQeMQ7isAGTkoBZGErH853yGk0W/yUx1iU7dM=")
   * ```
   *
   * or
   *
   * ```js
   * new IntegrityMetadata({
   *   alg: "sha256",
   *   val: "MV9b23bQeMQ7isAGTkoBZGErH853yGk0W/yUx1iU7dM=",
   * })
   * ```
   */
  constructor(integrity: IntegrityMetadataLike | string | null | undefined) {
    const integrityString =
      typeof integrity === "object" && integrity !== null
        ? IntegrityMetadata.stringify(integrity)
        : String(integrity ?? "").trim();

    const {
      alg = "",
      val = "",
      opt,
    } = IntegrityMetadataRegex.exec(integrityString)?.groups ?? {};

    Object.assign(this, {
      alg,
      val,
      opt: opt?.split("?") ?? [],
    });
  }

  /**
   * Compares the current integrity metadata with another object or string.
   * @param integrity The integrity metadata to compare with.
   * @returns `true` if the integrity metadata matches, `false` otherwise.
   * @example
   * ```js
   * integrityMetadata.match("sha256-MV9b23bQeMQ7isAGTkoBZGErH853yGk0W/yUx1iU7dM=")
   * ```
   *
   * or
   *
   * ```js
   * integrityMetadata.match({
   *   alg: "sha256",
   *   val: "MV9b23bQeMQ7isAGTkoBZGErH853yGk0W/yUx1iU7dM=",
   * })
   * ```
   */
  match(integrity: IntegrityMetadataLike | string | null | undefined): boolean {
    const { alg, val } = new IntegrityMetadata(integrity);
    if (!alg) return false;
    if (!val) return false;
    if (!(alg in supportedHashAlgorithms)) return false;
    return alg === this.alg && val === this.val;
  }

  /**
   * Converts the integrity metadata into a string representation.
   * @returns The string representation of the integrity metadata.
   */
  toString(): string {
    return IntegrityMetadata.stringify(this);
  }

  /**
   * Converts the integrity metadata into a JSON string.
   * @returns The JSON string representation of the integrity metadata.
   */
  toJSON(): string {
    return this.toString();
  }

  /**
   * Static method to stringify an integrity metadata object.
   * @param integrity The integrity metadata object to stringify.
   * @returns The stringified integrity metadata.
   * @example
   * ```js
   * IntegrityMetadata.stringify({
   *   alg: "sha256",
   *   val: "MV9b23bQeMQ7isAGTkoBZGErH853yGk0W/yUx1iU7dM=",
   * }) // "sha256-MV9b23bQeMQ7isAGTkoBZGErH853yGk0W/yUx1iU7dM="
   * ```
   */
  static stringify({ alg, val, opt = [] }: IntegrityMetadataLike): string {
    if (!alg) return "";
    if (!val) return "";
    if (!(alg in supportedHashAlgorithms)) return "";
    return `${alg}-${[val, ...opt].join("?")}`;
  }
}

/**
 * Asynchronously creates an `IntegrityMetadata` object from a hash algorithm and data.
 * @param hashAlgorithm The hash algorithm to use (e.g., `sha256`).
 * @param data The data to hash (in `ArrayBuffer` format).
 * @param opt Optional additional attributes.
 * @returns A promise that resolves to an `IntegrityMetadata` object.
 * @example
 * ```js
 * const res = new Response("Hello, world!");
 * const data = await res.arrayBuffer();
 * const integrityMetadata = await createIntegrityMetadata("sha256", data);
 * ```
 */
export async function createIntegrityMetadata(
  hashAlgorithm: HashAlgorithm,
  data: ArrayBuffer,
  opt: string[] = [],
): Promise<IntegrityMetadata> {
  const alg = hashAlgorithm.toLowerCase() as HashAlgorithm;

  if (!(alg in supportedHashAlgorithms)) {
    return new IntegrityMetadata("");
  }

  const hashAlgorithmIdentifier = supportedHashAlgorithms[alg];
  const arrayBuffer = await crypto.subtle.digest(hashAlgorithmIdentifier, data);
  const val = btoa(String.fromCharCode(...new Uint8Array(arrayBuffer)));
  const integrity = IntegrityMetadata.stringify({ alg, val, opt });

  return new IntegrityMetadata(integrity);
}

/**
 * Options for configuring an `IntegrityMetadataSet`.
 */
export type IntegrityMetadataSetOptions = {
  /** A custom function to determine the prioritized hash algorithm. */
  getPrioritizedHashAlgorithm?: GetPrioritizedHashAlgorithm;
};

/**
 * Class representing a set of integrity metadata, used for managing multiple hash algorithms and
 * their associated metadata.
 */
export class IntegrityMetadataSet {
  #set: ReadonlyArray<IntegrityMetadata>;
  #getPrioritizedHashAlgorithm = getPrioritizedHashAlgorithm;

  /**
   * Create an instance of `IntegrityMetadataSet` from integrity metadata or an array of integrity
   * metadata.
   * @param integrity The integrity metadata or an array of integrity metadata.
   * @param options Optional configuration options for hash algorithm prioritization.
   * @example
   * ```js
   * new IntegrityMetadataSet([
   *   "sha256-MV9b23bQeMQ7isAGTkoBZGErH853yGk0W/yUx1iU7dM=",
   *   "sha384-VbxVaw0v4Pzlgrpf4Huq//A1ZTY4x6wNVJTCpkwL6hzFczHHwSpFzbyn9MNKCJ7r",
   *   "sha512-wVJ82JPBJHc9gRkRlwyP5uhX1t9dySJr2KFgYUwM2WOk3eorlLt9NgIe+dhl1c6ilKgt1JoLsmn1H256V/eUIQ==",
   * ])
   * ```
   *
   * or
   *
   * ```js
   * new IntegrityMetadataSet(`
   *   sha256-MV9b23bQeMQ7isAGTkoBZGErH853yGk0W/yUx1iU7dM=
   *   sha384-VbxVaw0v4Pzlgrpf4Huq//A1ZTY4x6wNVJTCpkwL6hzFczHHwSpFzbyn9MNKCJ7r
   *   sha512-wVJ82JPBJHc9gRkRlwyP5uhX1t9dySJr2KFgYUwM2WOk3eorlLt9NgIe+dhl1c6ilKgt1JoLsmn1H256V/eUIQ==
   * `)
   * ```
   */
  constructor(
    integrity:
      | ReadonlyArray<IntegrityMetadataLike | string | null | undefined>
      | IntegrityMetadataLike
      | string
      | null
      | undefined,
    {
      getPrioritizedHashAlgorithm:
        _getPrioritizedHashAlgorithm = getPrioritizedHashAlgorithm,
    }: IntegrityMetadataSetOptions = {},
  ) {
    this.#set = [integrity]
      .flat()
      .flatMap(
        (
          integrity: IntegrityMetadataLike | string | null | undefined,
        ): ReadonlyArray<IntegrityMetadataLike | string | null | undefined> => {
          if (typeof integrity === "string") {
            return integrity.split(SeparatorRegex);
          }

          return [integrity];
        },
      )
      .map((integrity) => new IntegrityMetadata(integrity))
      .filter((integrityMetadata) => integrityMetadata.toString() !== "");

    this.#getPrioritizedHashAlgorithm = _getPrioritizedHashAlgorithm;
  }

  /**
   * Enables iteration over the set of integrity metadata.
   * @returns A generator that yields each IntegrityMetadata object.
   * @example
   * ```js
   * [...integrityMetadataSet]
   * ```
   */
  *[Symbol.iterator](): Generator<IntegrityMetadata> {
    for (const integrityMetadata of this.#set) {
      yield new IntegrityMetadata(integrityMetadata);
    }
  }

  /**
   * The number of integrity metadata entries in the set.
   */
  get size(): number {
    return this.#set.length;
  }

  /**
   * The strongest (most secure) integrity metadata from the set.
   * @see {@link https://www.w3.org/TR/SRI/#get-the-strongest-metadata-from-set}
   */
  get strongest(): IntegrityMetadataSet {
    let strongest = new IntegrityMetadataSet([]);

    for (const integrityMetadata of this.#set) {
      const [{ alg } = new IntegrityMetadata("")] = strongest;

      const prioritizedHashAlgorithm = this.#getPrioritizedHashAlgorithm(
        alg as HashAlgorithm,
        integrityMetadata.alg as HashAlgorithm,
      );

      switch (prioritizedHashAlgorithm) {
        case "":
          strongest = new IntegrityMetadataSet([
            ...strongest,
            integrityMetadata,
          ]);
          break;
        case integrityMetadata.alg:
          strongest = new IntegrityMetadataSet(integrityMetadata);
          break;
        case alg:
          break;
      }
    }

    return strongest;
  }

  /**
   * Returns an array of the strongest supported hash algorithms in the set.
   */
  get strongestHashAlgorithms(): ReadonlyArray<HashAlgorithm> {
    const strongestHashAlgorithms = [...this.strongest]
      .map(({ alg }) => alg as HashAlgorithm)
      .filter(Boolean);

    return [...new Set(strongestHashAlgorithms)];
  }

  /**
   * Checks if a given integrity metadata object or string matches any in the set.
   * @param integrity The integrity metadata to match.
   * @returns `true` if the integrity metadata matches, `false` otherwise.
   * @example
   * ```js
   * integrityMetadataSet.match("sha256-MV9b23bQeMQ7isAGTkoBZGErH853yGk0W/yUx1iU7dM=")
   * ```
   *
   * or
   *
   * ```js
   * integrityMetadataSet.match({
   *   alg: "sha256",
   *   val: "MV9b23bQeMQ7isAGTkoBZGErH853yGk0W/yUx1iU7dM=",
   * })
   * ```
   */
  match(integrity: IntegrityMetadataLike | string | null | undefined): boolean {
    return this.#set.some((integrityMetadata) =>
      integrityMetadata.match(integrity),
    );
  }

  /**
   * Joins the integrity metadata in the set into a single string, separated by the specified
   * separator.
   * @param separator The separator to use (default is a space).
   * @returns The joined string representation of the set.
   */
  join(separator = " "): string {
    return this.#set.map(String).join(separator);
  }

  /**
   * Converts the set of integrity metadata to a string representation.
   * @returns The string representation of the set.
   */
  toString(): string {
    return this.join();
  }

  /**
   * Converts the set of integrity metadata to a JSON string.
   * @returns The JSON string representation of the set.
   */
  toJSON(): string {
    return this.toString();
  }
}

/**
 * Asynchronously creates an `IntegrityMetadataSet` from a set of hash algorithms and data.
 * @param hashAlgorithms A single hash algorithm or an array of supported hash algorithms.
 * @param data The data to hash (in `ArrayBuffer` format).
 * @param options Optional configuration options for the metadata set.
 * @returns A promise that resolves to an `IntegrityMetadataSet` object.
 * @example
 * ```js
 * const res = new Response("Hello, world!");
 * const data = await res.arrayBuffer();
 * const set = await createIntegrityMetadataSet(["sha256", "sha384", "sha512"], data);
 * ```
 */
export async function createIntegrityMetadataSet(
  hashAlgorithms: ReadonlyArray<HashAlgorithm> | HashAlgorithm,
  data: ArrayBuffer,
  options: IntegrityMetadataSetOptions = {
    getPrioritizedHashAlgorithm,
  },
): Promise<IntegrityMetadataSet> {
  const set = await Promise.all(
    [hashAlgorithms].flat().map((alg) => createIntegrityMetadata(alg, data)),
  );

  return new IntegrityMetadataSet(set, options);
}