/** Content Security Policy Level 2, section 4.2 */
export type HashAlgorithm = "sha256" | "sha384" | "sha512";

/** Supported Hash Algorithms */
export const supportedHashAlgorithms = {
  sha256: "SHA-256",
  sha384: "SHA-384",
  sha512: "SHA-512",
} as const satisfies Record<HashAlgorithm, HashAlgorithmIdentifier>;

export type PrioritizedHashAlgorithm = "" | HashAlgorithm;

/** [W3C Subresource Integrity getPrioritizedHashFunction(a, b)](https://www.w3.org/TR/SRI/#dfn-getprioritizedhashfunction-a-b) */
export type GetPrioritizedHashAlgorithm = (
  a: HashAlgorithm,
  b: HashAlgorithm,
) => PrioritizedHashAlgorithm;

/** [W3C Subresource Integrity getPrioritizedHashFunction(a, b)](https://www.w3.org/TR/SRI/#dfn-getprioritizedhashfunction-a-b) */
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;
}

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]*))?$/;

export const SeparatorRegex = /[^\x21-\x7e]+/;

/** Integrity Metadata Like */
export type IntegrityMetadataLike = {
  alg: PrioritizedHashAlgorithm;
  val: string;
  opt?: string[];
};

/** Integrity Metadata */
export class IntegrityMetadata implements IntegrityMetadataLike {
  alg: PrioritizedHashAlgorithm;
  val: string;
  opt: string[];
  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("?") ?? [],
    });
  }

  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;
  }

  toString(): string {
    return IntegrityMetadata.stringify(this);
  }

  toJSON(): string {
    return this.toString();
  }

  static stringify({ alg, val, opt = [] }: IntegrityMetadataLike): string {
    if (!alg) return "";
    if (!val) return "";
    if (!(alg in supportedHashAlgorithms)) return "";
    return `${alg}-${[val, ...opt].join("?")}`;
  }
}

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);
}

/** Integrity Metadata Set Options */
export type IntegrityMetadataSetOptions = {
  getPrioritizedHashAlgorithm?: GetPrioritizedHashAlgorithm;
};

/** Integrity Metadata Set */
export class IntegrityMetadataSet {
  /** [W3C Subresource Integrity 3.3.4 Get the strongest metadata from set.](https://www.w3.org/TR/SRI/#get-the-strongest-metadata-from-set) */
  readonly strongest: Array<IntegrityMetadata> = [];

  #set: ReadonlyArray<IntegrityMetadata>;

  constructor(
    integrity:
      | ReadonlyArray<IntegrityMetadataLike | string | null | undefined>
      | IntegrityMetadataLike
      | string
      | null
      | undefined,
    {
      getPrioritizedHashAlgorithm:
        _getPrioritizedHashAlgorithm = getPrioritizedHashAlgorithm,
    }: IntegrityMetadataSetOptions = {},
  ) {
    const set: ReadonlyArray<
      IntegrityMetadataLike | string | null | undefined
    > = [integrity]
      .flat()
      .flatMap(
        (
          integrity: IntegrityMetadataLike | string | null | undefined,
        ): ReadonlyArray<IntegrityMetadataLike | string | null | undefined> =>
          typeof integrity === "string"
            ? integrity.split(SeparatorRegex)
            : [integrity],
      );

    this.#set = set
      .map((integrity) => new IntegrityMetadata(integrity))
      .filter((integrityMetadata) => integrityMetadata.toString() !== "");

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

      const prioritizedHashAlgorithm = _getPrioritizedHashAlgorithm(
        strongest.alg as HashAlgorithm,
        integrityMetadata.alg as HashAlgorithm,
      );

      switch (prioritizedHashAlgorithm) {
        case "":
          this.strongest.push(integrityMetadata);
          break;
        case integrityMetadata.alg:
          this.strongest = [integrityMetadata];
          break;
      }
    }
  }

  *[Symbol.iterator](): Generator<IntegrityMetadata> {
    for (const integrityMetadata of this.#set) {
      yield integrityMetadata;
    }
  }

  get size(): number {
    return this.#set.length;
  }

  get strongestHashAlgorithms(): ReadonlyArray<HashAlgorithm> {
    const strongestHashAlgorithms = this.strongest
      .map(({ alg }) => alg as HashAlgorithm)
      .filter(Boolean);

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

  join(separator = " "): string {
    return this.#set.map(String).join(separator);
  }

  toString(): string {
    return this.join();
  }

  toJSON(): string {
    return this.toString();
  }
}

export async function createIntegrityMetadataSet(
  hashAlgorithms: HashAlgorithm[],
  data: ArrayBuffer,
  options: IntegrityMetadataSetOptions = {
    getPrioritizedHashAlgorithm,
  },
): Promise<IntegrityMetadataSet> {
  const integrityMetadata = await Promise.all(
    hashAlgorithms.map((alg) => createIntegrityMetadata(alg, data)),
  );

  return new IntegrityMetadataSet(integrityMetadata.join(" "), options);
}