/**
 * @beignet/core/idempotency
 *
 * Idempotency primitives for retry-safe commands, webhooks, and jobs.
 */

/**
 * Value or promise of that value.
 */
export type MaybePromise<T> = T | Promise<T>;

/**
 * Primitive value accepted inside an idempotency scope object.
 */
export type IdempotencyScopeValue =
  | string
  | number
  | boolean
  | null
  | undefined;

/**
 * Logical scope for idempotency keys.
 *
 * String scopes and object scopes are normalized with type tags so adapters can
 * build stable storage keys without primitive or delimiter collisions.
 */
export type IdempotencyScope = string | Record<string, IdempotencyScopeValue>;

/**
 * Scope mode that HTTP hooks can use when deriving an idempotency scope.
 */
export type IdempotencyScopeMode =
  | "global"
  | "actor"
  | "tenant"
  | "actor-tenant";

/** Default lifetime for unfinished idempotency reservations. */
export const DEFAULT_IDEMPOTENCY_RESERVATION_TTL_SEC = 300;

/**
 * Contract metadata for idempotency-aware routes.
 *
 * This metadata is enforced at the HTTP boundary by
 * `createIdempotencyHooks(...)` from `@beignet/core/server`.
 * `runIdempotently(...)` remains the primitive for non-HTTP workflows such as
 * jobs, listeners, webhooks, and schedules.
 */
export interface IdempotencyMeta {
  /**
   * Whether this operation requires an idempotency key at the HTTP boundary.
   */
  required?: boolean;

  /**
   * Header that carries the idempotency key.
   *
   * Default: "idempotency-key".
   */
  header?: string;

  /**
   * How to scope idempotency keys when an HTTP hook derives the scope.
   *
   * By default the HTTP hook scopes to the actor and includes the tenant when
   * one is present. Use "global" explicitly only when every caller should
   * share one idempotency-key namespace for the operation.
   */
  scope?: IdempotencyScopeMode;

  /**
   * Time-to-live for completed replay results.
   */
  ttlSec?: number;

  /**
   * Time-to-live for unfinished reservations.
   *
   * @default 300
   */
  reservationTtlSec?: number;
}

/**
 * Input for reserving an idempotency key.
 */
export interface IdempotencyReserveInput {
  /**
   * Operation namespace, usually a use-case or route name.
   */
  namespace: string;
  /**
   * Client-provided idempotency key.
   */
  key: string;
  /**
   * Logical scope for this key.
   */
  scope?: IdempotencyScope;
  /**
   * Fingerprint of the logical command payload.
   */
  fingerprint: string;
  /**
   * Optional time-to-live for the completed replay result.
   */
  ttlSec?: number;
  /**
   * Time-to-live for unfinished work before a successor may reserve the key.
   *
   * @default 300
   */
  reservationTtlSec?: number;
}

/**
 * Result of reserving an idempotency key.
 */
export type IdempotencyReservation =
  | {
      status: "reserved";
      namespace: string;
      key: string;
      scopeKey: string;
      fingerprint: string;
      reservationToken: string;
      reservedAt: Date;
      expiresAt: Date | null;
    }
  | {
      status: "replay";
      namespace: string;
      key: string;
      scopeKey: string;
      fingerprint: string;
      result: unknown;
      reservedAt: Date;
      completedAt: Date;
      expiresAt: Date | null;
    }
  | {
      status: "inProgress";
      namespace: string;
      key: string;
      scopeKey: string;
      fingerprint: string;
      reservedAt: Date;
      expiresAt: Date | null;
    }
  | {
      status: "conflict";
      namespace: string;
      key: string;
      scopeKey: string;
      storedFingerprint: string;
      receivedFingerprint: string;
      reservedAt: Date;
      completedAt?: Date;
      expiresAt: Date | null;
    };

/**
 * Input for marking an idempotency key complete.
 */
export interface IdempotencyCompleteInput {
  /**
   * Operation namespace.
   */
  namespace: string;
  /**
   * Client-provided idempotency key.
   */
  key: string;
  /**
   * Logical scope for this key.
   */
  scope?: IdempotencyScope;
  /**
   * Fingerprint that must match the reserved operation.
   */
  fingerprint: string;
  /** Opaque identity returned by the matching reserved result. */
  reservationToken: string;
  /**
   * Result to replay for future matching requests.
   */
  result?: unknown;
}

/**
 * Input for releasing or marking a failed idempotency reservation.
 */
export interface IdempotencyFailInput {
  /**
   * Operation namespace.
   */
  namespace: string;
  /**
   * Client-provided idempotency key.
   */
  key: string;
  /**
   * Logical scope for this key.
   */
  scope?: IdempotencyScope;
  /**
   * Fingerprint that must match the reserved operation.
   */
  fingerprint: string;
  /** Opaque identity returned by the matching reserved result. */
  reservationToken: string;
  /**
   * Error that caused the protected operation to fail.
   */
  error?: unknown;
}

/**
 * App-facing idempotency port.
 */
export interface IdempotencyPort {
  /**
   * Atomically reserve a key for work, replay an already completed result, or
   * report that the key is in progress/conflicting.
   */
  reserve(input: IdempotencyReserveInput): Promise<IdempotencyReservation>;

  /**
   * Mark a reserved key as complete and store the result that may be replayed.
   *
   * Implementations must reject when the fingerprint, reservation token, or
   * in-progress state no longer matches the current reservation.
   */
  complete(input: IdempotencyCompleteInput): Promise<void>;

  /**
   * Release or mark a reserved key after the protected work fails.
   *
   * Implementations must reject when the fingerprint, reservation token, or
   * in-progress state no longer matches the current reservation.
   */
  fail(input: IdempotencyFailInput): Promise<void>;
}

/**
 * Options for reserving an idempotency key around a protected operation.
 */
export interface RunIdempotentlyOptions<Result>
  extends IdempotencyReserveInput {
  /**
   * Protected operation to run after the key is reserved.
   */
  run: () => MaybePromise<Result>;
  /**
   * Replay behavior for completed matching reservations.
   *
   * Defaults to returning the stored result. Use `"error"` when callers need to
   * distinguish replay from first execution.
   */
  replay?: "return" | "error";
}

/**
 * Options for `createIdempotencyFingerprint(...)`.
 */
export interface CreateIdempotencyFingerprintOptions {
  /**
   * Omit values from the fingerprint input. Use this for the idempotency key
   * itself or other request metadata that does not define the logical command.
   *
   * String paths can be top-level keys (`"idempotencyKey"`) or dotted paths
   * (`"metadata.requestId"`). Array paths avoid ambiguity when keys contain
   * dots.
   */
  omit?: readonly (string | readonly string[])[];
}

/**
 * In-memory idempotency store for tests and local examples.
 */
export interface MemoryIdempotencyStore extends IdempotencyPort {
  /**
   * Current store entries.
   */
  readonly entries: readonly MemoryIdempotencyEntry[];
  /**
   * Remove all entries.
   */
  clear(): void;
}

/**
 * Snapshot entry from the memory idempotency store.
 */
export interface MemoryIdempotencyEntry {
  /**
   * Operation namespace.
   */
  namespace: string;
  /**
   * Client-provided idempotency key.
   */
  key: string;
  /**
   * Normalized scope key.
   */
  scopeKey: string;
  /**
   * Fingerprint of the logical command payload.
   */
  fingerprint: string;
  /** Opaque identity of the current in-progress reservation. */
  reservationToken: string;
  /**
   * Memory store status.
   */
  status: "in-progress" | "completed";
  /**
   * Stored result for completed entries.
   */
  result?: unknown;
  /**
   * Reservation timestamp.
   */
  reservedAt: Date;
  /**
   * Completion timestamp.
   */
  completedAt?: Date;
  /**
   * Expiration timestamp, or null for no expiration.
   */
  expiresAt: Date | null;
}

/**
 * Error thrown when an idempotency key is reused with a different fingerprint.
 */
export class IdempotencyConflictError extends Error {
  readonly namespace: string;
  readonly key: string;
  readonly scopeKey: string;
  readonly storedFingerprint: string;
  readonly receivedFingerprint: string;

  constructor(args: {
    namespace: string;
    key: string;
    scopeKey: string;
    storedFingerprint: string;
    receivedFingerprint: string;
  }) {
    super(
      `Idempotency key "${args.key}" conflicts with a different payload in namespace "${args.namespace}".`,
    );
    this.name = "IdempotencyConflictError";
    this.namespace = args.namespace;
    this.key = args.key;
    this.scopeKey = args.scopeKey;
    this.storedFingerprint = args.storedFingerprint;
    this.receivedFingerprint = args.receivedFingerprint;
  }
}

/**
 * Error thrown when an idempotency key is already reserved by in-progress work.
 */
export class IdempotencyInProgressError extends Error {
  readonly namespace: string;
  readonly key: string;
  readonly scopeKey: string;

  constructor(args: { namespace: string; key: string; scopeKey: string }) {
    super(
      `Idempotency key "${args.key}" is already in progress in namespace "${args.namespace}".`,
    );
    this.name = "IdempotencyInProgressError";
    this.namespace = args.namespace;
    this.key = args.key;
    this.scopeKey = args.scopeKey;
  }
}

/**
 * Error thrown when replay is disabled for a completed idempotency key.
 */
export class IdempotencyReplayError extends Error {
  readonly namespace: string;
  readonly key: string;
  readonly scopeKey: string;

  constructor(args: { namespace: string; key: string; scopeKey: string }) {
    super(
      `Idempotency key "${args.key}" already completed in namespace "${args.namespace}".`,
    );
    this.name = "IdempotencyReplayError";
    this.namespace = args.namespace;
    this.key = args.key;
    this.scopeKey = args.scopeKey;
  }
}

/**
 * Error thrown when fingerprint input cannot be canonicalized.
 */
export class IdempotencyFingerprintError extends Error {
  constructor(message: string) {
    super(message);
    this.name = "IdempotencyFingerprintError";
  }
}

/**
 * Error thrown when a completion or failure mutation no longer owns the
 * matching in-progress reservation.
 */
export class IdempotencyMutationError extends Error {
  /** Mutation that failed to match the current reservation. */
  readonly action: "complete" | "fail";
  /** Operation namespace supplied to the failed mutation. */
  readonly namespace: string;
  /** Client-provided idempotency key supplied to the failed mutation. */
  readonly key: string;
  /** Normalized logical scope key supplied to the failed mutation. */
  readonly scopeKey: string;

  constructor(args: {
    action: "complete" | "fail";
    namespace: string;
    key: string;
    scope?: IdempotencyScope;
  }) {
    const scopeKey = normalizeIdempotencyScope(args.scope);
    super(
      `Idempotency ${args.action} for key "${args.key}" in namespace "${args.namespace}" matched no in-progress reservation with this fingerprint and reservation token. The reservation may be missing, expired, already completed or released, or owned by a different execution.`,
    );
    this.name = "IdempotencyMutationError";
    this.action = args.action;
    this.namespace = args.namespace;
    this.key = args.key;
    this.scopeKey = scopeKey;
  }
}

type CanonicalValue =
  | null
  | string
  | number
  | boolean
  | readonly CanonicalValue[]
  | { readonly [key: string]: CanonicalValue };

type MemoryRecord = MemoryIdempotencyEntry & {
  replayTtlSec?: number;
};

function assertNonEmptyString(name: string, value: string): void {
  if (typeof value !== "string" || value.trim().length === 0) {
    throw new Error(`${name} must be a non-empty string`);
  }
}

function assertTtl(ttlSec: number | undefined): void {
  if (ttlSec === undefined) return;
  if (!Number.isInteger(ttlSec) || ttlSec <= 0) {
    throw new Error("ttlSec must be a positive integer when provided");
  }
}

function normalizeScopeValue(value: IdempotencyScopeValue): readonly string[] {
  if (value === undefined) return ["undefined"];
  if (value === null) return ["null"];
  if (typeof value === "number") {
    return ["number", Object.is(value, -0) ? "-0" : String(value)];
  }
  if (typeof value === "boolean") {
    return ["boolean", value ? "true" : "false"];
  }
  return ["string", value];
}

/**
 * Normalize an idempotency scope into a stable string.
 */
export function normalizeIdempotencyScope(
  scope: IdempotencyScope | undefined,
): string {
  if (scope === undefined) return JSON.stringify(["string", "global"]);
  if (typeof scope === "string") return JSON.stringify(["string", scope]);

  return JSON.stringify([
    "object",
    Object.keys(scope)
      .sort()
      .map((key) => [key, normalizeScopeValue(scope[key])]),
  ]);
}

/**
 * Create the stable storage key for an idempotency operation.
 */
export function createIdempotencyStorageKey(input: {
  namespace: string;
  key: string;
  scope?: IdempotencyScope;
}): string {
  assertNonEmptyString("namespace", input.namespace);
  assertNonEmptyString("key", input.key);

  // Join with the ASCII unit separator. A control character cannot appear in
  // real namespace, scope, or key values, so the joined key cannot collide
  // with a different namespace/scope/key combination. NUL would give the same
  // collision resistance, but Postgres `text` columns reject NUL bytes
  // (error 22P05), so database-backed idempotency stores must avoid it.
  return [
    input.namespace,
    normalizeIdempotencyScope(input.scope),
    input.key,
  ].join("\u001f");
}

function resolveExpiresAt(ttlSec: number | undefined, now: Date): Date | null {
  assertTtl(ttlSec);
  return ttlSec === undefined ? null : new Date(now.getTime() + ttlSec * 1000);
}

function isExpired(entry: MemoryRecord, now: Date): boolean {
  return entry.expiresAt !== null && entry.expiresAt.getTime() <= now.getTime();
}

function reservationFromRecord(
  record: MemoryRecord,
  receivedFingerprint: string,
): IdempotencyReservation {
  if (record.fingerprint !== receivedFingerprint) {
    return {
      status: "conflict",
      namespace: record.namespace,
      key: record.key,
      scopeKey: record.scopeKey,
      storedFingerprint: record.fingerprint,
      receivedFingerprint,
      reservedAt: record.reservedAt,
      completedAt: record.completedAt,
      expiresAt: record.expiresAt,
    };
  }

  if (record.status === "completed" && record.completedAt) {
    return {
      status: "replay",
      namespace: record.namespace,
      key: record.key,
      scopeKey: record.scopeKey,
      fingerprint: record.fingerprint,
      result: record.result,
      reservedAt: record.reservedAt,
      completedAt: record.completedAt,
      expiresAt: record.expiresAt,
    };
  }

  return {
    status: "inProgress",
    namespace: record.namespace,
    key: record.key,
    scopeKey: record.scopeKey,
    fingerprint: record.fingerprint,
    reservedAt: record.reservedAt,
    expiresAt: record.expiresAt,
  };
}

/**
 * Options for `createMemoryIdempotencyStore(...)`.
 */
export interface MemoryIdempotencyStoreOptions {
  /**
   * Clock used for reservation and completion timestamps. Defaults to the
   * system clock.
   */
  now?: () => Date;
  /** Token factory used for deterministic tests. */
  createReservationToken?: () => string;
}

/**
 * Create an in-memory idempotency store for tests and local examples.
 *
 * The memory store is process-local and not suitable for multi-process
 * production deployments.
 */
export function createMemoryIdempotencyStore(
  options: MemoryIdempotencyStoreOptions = {},
): MemoryIdempotencyStore {
  const storeNow = options.now ?? (() => new Date());
  const records = new Map<string, MemoryRecord>();
  const pendingReservations = new Map<string, Promise<void>>();

  return {
    get entries() {
      return [...records.values()];
    },

    async reserve(input) {
      assertNonEmptyString("namespace", input.namespace);
      assertNonEmptyString("key", input.key);
      assertNonEmptyString("fingerprint", input.fingerprint);
      assertTtl(input.ttlSec);
      assertTtl(input.reservationTtlSec);

      const storageKey = createIdempotencyStorageKey(input);
      while (true) {
        const now = storeNow();
        const existing = records.get(storageKey);

        if (existing && !isExpired(existing, now)) {
          return reservationFromRecord(existing, input.fingerprint);
        }

        if (existing) {
          records.delete(storageKey);
        }

        const pendingReservation = pendingReservations.get(storageKey);
        if (pendingReservation) {
          await pendingReservation;
          continue;
        }

        let releasePendingReservation!: () => void;
        pendingReservations.set(
          storageKey,
          new Promise<void>((resolve) => {
            releasePendingReservation = resolve;
          }),
        );

        try {
          const reservationToken =
            options.createReservationToken?.() ??
            (await createRandomReservationToken());
          const reservedAt = storeNow();
          const record: MemoryRecord = {
            namespace: input.namespace,
            key: input.key,
            scopeKey: normalizeIdempotencyScope(input.scope),
            fingerprint: input.fingerprint,
            reservationToken,
            replayTtlSec: input.ttlSec,
            status: "in-progress",
            reservedAt,
            expiresAt: resolveExpiresAt(
              input.reservationTtlSec ??
                DEFAULT_IDEMPOTENCY_RESERVATION_TTL_SEC,
              reservedAt,
            ),
          };

          records.set(storageKey, record);

          return {
            status: "reserved",
            namespace: record.namespace,
            key: record.key,
            scopeKey: record.scopeKey,
            fingerprint: record.fingerprint,
            reservationToken: record.reservationToken,
            reservedAt: record.reservedAt,
            expiresAt: record.expiresAt,
          };
        } finally {
          pendingReservations.delete(storageKey);
          releasePendingReservation();
        }
      }
    },

    async complete(input) {
      assertNonEmptyString("namespace", input.namespace);
      assertNonEmptyString("key", input.key);
      assertNonEmptyString("fingerprint", input.fingerprint);

      const storageKey = createIdempotencyStorageKey(input);
      const existing = records.get(storageKey);
      assertNonEmptyString("reservationToken", input.reservationToken);
      if (
        !existing ||
        existing.fingerprint !== input.fingerprint ||
        existing.reservationToken !== input.reservationToken ||
        existing.status !== "in-progress"
      ) {
        throw new IdempotencyMutationError({
          action: "complete",
          namespace: input.namespace,
          key: input.key,
          scope: input.scope,
        });
      }

      existing.status = "completed";
      existing.result = input.result;
      existing.completedAt = storeNow();
      existing.expiresAt = resolveExpiresAt(
        existing.replayTtlSec,
        existing.completedAt,
      );
    },

    async fail(input) {
      assertNonEmptyString("namespace", input.namespace);
      assertNonEmptyString("key", input.key);
      assertNonEmptyString("fingerprint", input.fingerprint);

      const storageKey = createIdempotencyStorageKey(input);
      const existing = records.get(storageKey);
      assertNonEmptyString("reservationToken", input.reservationToken);
      if (
        !existing ||
        existing.fingerprint !== input.fingerprint ||
        existing.reservationToken !== input.reservationToken ||
        existing.status === "completed"
      ) {
        throw new IdempotencyMutationError({
          action: "fail",
          namespace: input.namespace,
          key: input.key,
          scope: input.scope,
        });
      }

      records.delete(storageKey);
    },

    clear() {
      records.clear();
    },
  };
}

/**
 * Run an operation behind an idempotency reservation.
 *
 * The flow is: reserve the key, replay completed matching results, reject
 * in-progress/conflicting keys, run the operation for new reservations, then
 * complete or fail the reservation. Callers are responsible for choosing a
 * namespace, scope, key, and fingerprint that match their business operation.
 */
export async function runIdempotently<Result>(
  idempotency: IdempotencyPort,
  options: RunIdempotentlyOptions<Result>,
): Promise<Result> {
  const operation = {
    namespace: options.namespace,
    key: options.key,
    scope: options.scope,
    fingerprint: options.fingerprint,
    ttlSec: options.ttlSec,
    reservationTtlSec: options.reservationTtlSec,
  };
  const reservation = await idempotency.reserve(operation);

  switch (reservation.status) {
    case "replay": {
      if (options.replay === "error") {
        throw new IdempotencyReplayError(reservation);
      }
      return reservation.result as Result;
    }
    case "inProgress": {
      throw new IdempotencyInProgressError(reservation);
    }
    case "conflict": {
      throw new IdempotencyConflictError(reservation);
    }
    case "reserved": {
      let result: Result;
      try {
        result = await options.run();
      } catch (error) {
        try {
          await idempotency.fail({
            namespace: operation.namespace,
            key: operation.key,
            scope: operation.scope,
            fingerprint: operation.fingerprint,
            reservationToken: reservation.reservationToken,
            error,
          });
        } catch (settlementError) {
          throw new AggregateError(
            [error, settlementError],
            "Idempotent operation failed and releasing its reservation also failed.",
            { cause: error },
          );
        }
        throw error;
      }

      await idempotency.complete({
        namespace: operation.namespace,
        key: operation.key,
        scope: operation.scope,
        fingerprint: operation.fingerprint,
        reservationToken: reservation.reservationToken,
        result,
      });
      return result;
    }
    default: {
      const unsupported = reservation as { status?: unknown };
      throw new Error(
        `Idempotency port returned unsupported reservation status "${String(unsupported.status)}".`,
      );
    }
  }
}

async function createRandomReservationToken(): Promise<string> {
  const crypto = globalThis.crypto;
  if (typeof crypto?.randomUUID === "function") {
    return crypto.randomUUID();
  }
  if (typeof crypto?.getRandomValues === "function") {
    const bytes = new Uint8Array(16);
    crypto.getRandomValues(bytes);
    return Array.from(bytes, (value) =>
      value.toString(16).padStart(2, "0"),
    ).join("");
  }

  try {
    const nodeCrypto = await import("node:crypto");
    if (typeof nodeCrypto.randomUUID === "function") {
      return nodeCrypto.randomUUID();
    }
    return nodeCrypto.randomBytes(16).toString("hex");
  } catch {
    throw new Error(
      "Idempotency reservations require Web Crypto or Node.js crypto.",
    );
  }
}

function normalizeOmitPath(
  path: string | readonly string[],
): readonly string[] {
  if (typeof path === "string") return path.split(".").filter(Boolean);
  return path.map(String);
}

function shouldOmitPath(
  path: readonly string[],
  omit: readonly (string | readonly string[])[],
): boolean {
  return omit.some((entry) => {
    const omitPath = normalizeOmitPath(entry);
    if (omitPath.length !== path.length) return false;
    return omitPath.every((segment, index) => segment === path[index]);
  });
}

function canonicalize(
  value: unknown,
  options: CreateIdempotencyFingerprintOptions,
  path: readonly string[],
  seen: WeakSet<object>,
): CanonicalValue | undefined {
  if (shouldOmitPath(path, options.omit ?? [])) {
    return undefined;
  }

  if (value === undefined || typeof value === "function") {
    return undefined;
  }

  if (
    value === null ||
    typeof value === "string" ||
    typeof value === "boolean"
  ) {
    return value;
  }

  if (typeof value === "number") {
    if (!Number.isFinite(value)) {
      throw new IdempotencyFingerprintError(
        "Cannot fingerprint non-finite numeric values.",
      );
    }
    return value;
  }

  if (typeof value === "bigint") {
    return value.toString();
  }

  if (value instanceof Date) {
    return value.toISOString();
  }

  if (Array.isArray(value)) {
    return value.map(
      (item, index) =>
        canonicalize(item, options, [...path, String(index)], seen) ?? null,
    );
  }

  if (typeof value === "object") {
    if (seen.has(value)) {
      throw new IdempotencyFingerprintError(
        "Cannot fingerprint circular values.",
      );
    }
    seen.add(value);

    const result: Record<string, CanonicalValue> = {};
    for (const key of Object.keys(value as Record<string, unknown>).sort()) {
      const nestedValue = canonicalize(
        (value as Record<string, unknown>)[key],
        options,
        [...path, key],
        seen,
      );
      if (nestedValue !== undefined) {
        result[key] = nestedValue;
      }
    }

    seen.delete(value);
    return result;
  }

  throw new IdempotencyFingerprintError(
    `Cannot fingerprint value of type "${typeof value}".`,
  );
}

function bytesToHex(bytes: ArrayBuffer): string {
  return [...new Uint8Array(bytes)]
    .map((byte) => byte.toString(16).padStart(2, "0"))
    .join("");
}

/**
 * Create a SHA-256 fingerprint from a canonicalized value.
 *
 * Object keys are sorted, `undefined` and functions are omitted, `Date` values
 * become ISO strings, BigInts become strings, and circular or non-finite values
 * throw. Exact omit paths may be supplied as dotted strings or string arrays.
 */
export async function createIdempotencyFingerprint(
  value: unknown,
  options: CreateIdempotencyFingerprintOptions = {},
): Promise<string> {
  if (!globalThis.crypto?.subtle) {
    throw new IdempotencyFingerprintError(
      "Cannot create an idempotency fingerprint because Web Crypto is unavailable.",
    );
  }

  const canonical = canonicalize(value, options, [], new WeakSet());
  const json = JSON.stringify(canonical ?? null);
  const digest = await globalThis.crypto.subtle.digest(
    "SHA-256",
    new TextEncoder().encode(json),
  );

  return `sha256:${bytesToHex(digest)}`;
}
