// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license.
// This module is browser compatible.

/**
 * Provides functions for dealing with and matching ETags, including
 * {@linkcode calculate} to calculate an etag for a given entity,
 * {@linkcode ifMatch} for validating if an ETag matches against a `If-Match`
 * header and {@linkcode ifNoneMatch} for validating an Etag against an
 * `If-None-Match` header.
 *
 * See further information on the `ETag` header on
 * {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag | MDN}.
 *
 * @module
 */

import { encodeBase64 as base64Encode } from "jsr:/@std/encoding@1.0.0-rc.2/base64";

/**
 * Just the part of {@linkcode Deno.FileInfo} that is required to calculate an `ETag`,
 * so partial or user generated file information can be passed.
 */
export interface FileInfo {
  /** The last modification time of the file. This corresponds to the `mtime`
   * field from `stat` on Linux/Mac OS and `ftLastWriteTime` on Windows. This
   * may not be available on all platforms. */
  mtime: Date | null;
  /** The size of the file, in bytes. */
  size: number;
}

/** Represents an entity that can be used for generating an ETag. */
export type Entity = string | Uint8Array | FileInfo;

const encoder = new TextEncoder();

const DEFAULT_ALGORITHM: AlgorithmIdentifier = "SHA-256";

/** Options for {@linkcode calculate}. */
export interface ETagOptions {
  /**
   * A digest algorithm to use to calculate the etag.
   *
   * @default {"SHA-256"}
   */
  algorithm?: AlgorithmIdentifier;

  /** Override the default behavior of calculating the `ETag`, either forcing
   * a tag to be labelled weak or not. */
  weak?: boolean;
}

function isFileInfo(value: unknown): value is FileInfo {
  return Boolean(
    value && typeof value === "object" && "mtime" in value && "size" in value,
  );
}

async function calcEntity(
  entity: string | Uint8Array,
  { algorithm = DEFAULT_ALGORITHM }: ETagOptions,
) {
  // a short circuit for zero length entities
  if (entity.length === 0) {
    return `0-47DEQpj8HBSa+/TImW+5JCeuQeR`;
  }

  if (typeof entity === "string") {
    entity = encoder.encode(entity);
  }

  const hash = base64Encode(await crypto.subtle.digest(algorithm, entity))
    .substring(0, 27);

  return `${entity.length.toString(16)}-${hash}`;
}

async function calcFileInfo(
  fileInfo: FileInfo,
  { algorithm = DEFAULT_ALGORITHM }: ETagOptions,
) {
  if (fileInfo.mtime) {
    const hash = base64Encode(
      await crypto.subtle.digest(
        algorithm,
        encoder.encode(fileInfo.mtime.toJSON()),
      ),
    ).substring(0, 27);
    return `${fileInfo.size.toString(16)}-${hash}`;
  }
}

/**
 * Calculate an ETag for an entity. When the entity is a specific set of data
 * it will be fingerprinted as a "strong" tag, otherwise if it is just file
 * information, it will be calculated as a weak tag.
 *
 * @example Usage
 * ```ts
 * import { calculate } from "@std/http/etag";
 * import { assert } from "@std/assert/assert";
 *
 * const body = "hello deno!";
 *
 * const etag = await calculate(body);
 * assert(etag);
 *
 * const res = new Response(body, { headers: { etag } });
 * ```
 *
 * @param entity The entity to get the ETag for.
 * @param options Various additional options.
 * @returns The calculated ETag.
 */
export async function calculate(
  entity: Entity,
  options: ETagOptions = {},
): Promise<string | undefined> {
  const weak = options.weak ?? isFileInfo(entity);
  const tag =
    await (isFileInfo(entity)
      ? calcFileInfo(entity, options)
      : calcEntity(entity, options));

  return tag ? weak ? `W/"${tag}"` : `"${tag}"` : undefined;
}

/** A helper function that takes the value from the `If-Match` header and a
 * calculated etag for the target. By using strong comparison, return `true` if
 * the values match, otherwise `false`.
 *
 * See MDN's [`If-Match`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Match)
 * article for more information on how to use this function.
 *
 * @example Usage
 * ```ts no-eval
 * import {
 *   calculate,
 *   ifMatch,
 * } from "@std/http/etag";
 * import { assert } from "@std/assert/assert"
 *
 * const body = "hello deno!";
 *
 * Deno.serve(async (req) => {
 *   const ifMatchValue = req.headers.get("if-match");
 *   const etag = await calculate(body);
 *   assert(etag);
 *   if (!ifMatchValue || ifMatch(ifMatchValue, etag)) {
 *     return new Response(body, { status: 200, headers: { etag } });
 *   } else {
 *     return new Response(null, { status: 412, statusText: "Precondition Failed"});
 *   }
 * });
 * ```
 *
 * @param value the If-Match header value.
 * @param etag the ETag to check against.
 * @returns whether or not the parameters match.
 */
export function ifMatch(
  value: string | null,
  etag: string | undefined,
): boolean {
  // Weak tags cannot be matched and return false.
  if (!value || !etag || etag.startsWith("W/")) {
    return false;
  }
  if (value.trim() === "*") {
    return true;
  }
  const tags = value.split(/\s*,\s*/);
  return tags.includes(etag);
}

/** A helper function that takes the value from the `If-None-Match` header and
 * a calculated etag for the target entity and returns `false` if the etag for
 * the entity matches the supplied value, otherwise `true`.
 *
 * See MDN's [`If-None-Match`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-None-Match)
 * article for more information on how to use this function.
 *
 * @example Usage
 * ```ts no-eval
 * import {
 *   calculate,
 *   ifNoneMatch,
 * } from "@std/http/etag";
 * import { assert } from "@std/assert/assert"
 *
 * const body = "hello deno!";
 *
 * Deno.serve(async (req) => {
 *   const ifNoneMatchValue = req.headers.get("if-none-match");
 *   const etag = await calculate(body);
 *   assert(etag);
 *   if (!ifNoneMatch(ifNoneMatchValue, etag)) {
 *     return new Response(null, { status: 304, headers: { etag } });
 *   } else {
 *     return new Response(body, { status: 200, headers: { etag } });
 *   }
 * });
 * ```
 *
 * @param value the If-None-Match header value.
 * @param etag the ETag to check against.
 * @returns whether or not the parameters do not match.
 */
export function ifNoneMatch(
  value: string | null,
  etag: string | undefined,
): boolean {
  if (!value || !etag) {
    return true;
  }
  if (value.trim() === "*") {
    return false;
  }
  etag = etag.startsWith("W/") ? etag.slice(2) : etag;
  const tags = value.split(/\s*,\s*/).map((tag) =>
    tag.startsWith("W/") ? tag.slice(2) : tag
  );
  return !tags.includes(etag);
}

// denoCacheMetadata={"headers":{"vary":"Origin","x-content-type-options":"nosniff","x-jsr-backend":"modules","cf-cache-status":"HIT","content-security-policy":"default-src 'none'; script-src 'none'; style-src 'none'; img-src 'none'; font-src 'none'; connect-src 'none'; frame-src 'none'; object-src 'none'; frame-ancestors 'none'; sandbox; form-action 'none';","cross-origin-resource-policy":"cross-origin","x-robots-tag":"noindex","set-cookie":"__cf_bm=RJdfdfMHkB8MgGJvaMEFPcN3HRiRdNdCKkJ4kOfkgz4-1785241875.0015237-1.0.1.1-LaQzqRt.MnbiHg5z_0RY2A4yaYJFuKSVMDtTRHqBpxiKYLn1nHJsUvZS.XASnLBf6FbzCsHt_ZaS4Ee.Ob42f_T5WAVwypVR3FUa2kI9apQU2Eg.B2ZikIH2F2Q5qY4Q; HttpOnly; SameSite=None; Secure; Path=/; Domain=jsr.io; Expires=Tue, 28 Jul 2026 13:01:15 GMT","cf-ray":"a223e656bff07db6-LAX","server":"cloudflare","alt-svc":"h3=\":443\"; ma=86400","report-to":"{\"group\":\"cf-nel\",\"max_age\":604800,\"endpoints\":[{\"url\":\"https://a.nel.cloudflare.com/report/v4?s=FgFNvOIpg7Tr7dfraR6v9Ry4YHtlqUA9QPlJe3Vs4t5m2utJ57ZUDKLC5mevwkSxwT8mrbmh6dXvHOBtGFYSI8EEDvXn0eA%2BVOYJSiG%2BI3XfqgeueEkEn18%3D\"}]}","access-control-expose-headers":"*","content-length":"6582","accept-ranges":"bytes","date":"Tue, 28 Jul 2026 12:31:15 GMT","etag":"\"3b7d00018870fc40dc7c0df7e34296a0\"","access-control-allow-origin":"*","nel":"{\"report_to\":\"cf-nel\",\"success_fraction\":0.0,\"max_age\":604800}","age":"126790","content-type":"text/typescript","cache-control":"public, max-age=31536000, immutable"},"url":"https://jsr.io/@std/http/0.224.5/etag.ts","time":1785241875}