import { Sha256 } from "@aws-crypto/sha256-js";

export type Hash = string;

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

export function sha256Text(value: string): Hash {
  const hasher = new Sha256();
  hasher.update(new TextEncoder().encode(value));
  return toHex(hasher.digestSync());
}

export function sha256JSON(value: unknown): Hash {
  return sha256Text(JSON.stringify(toCanonicalValue(value)));
}

function toCanonicalValue(value: unknown, seen: WeakSet<object> = new WeakSet<object>()): CanonicalValue {
  if (value === null) {
    return null;
  }

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

  if (typeof value === "number") {
    return Number.isFinite(value) ? value : null;
  }

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

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

  const objectRef = value as object;
  if (seen.has(objectRef)) {
    throw new TypeError("Cannot hash cyclic JSON value");
  }
  seen.add(objectRef);

  if (Array.isArray(value)) {
    const items = value.map((item) => toCanonicalValue(item, seen));
    seen.delete(objectRef);
    return items;
  }

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

  if (value instanceof Map) {
    const entries = Array.from(value.entries())
      .map(([key, entryValue]) => [String(key), toCanonicalValue(entryValue, seen)] as const)
      .sort(([left], [right]) => left.localeCompare(right));
    const record: Record<string, CanonicalValue> = {};
    for (const [key, entryValue] of entries) {
      record[key] = entryValue;
    }
    seen.delete(objectRef);
    return record;
  }

  if (value instanceof Set) {
    const items = Array.from(value.values())
      .map((item) => toCanonicalValue(item, seen))
      .sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right)));
    seen.delete(objectRef);
    return items;
  }

  const objectValue = value as Record<string, unknown>;
  const record: Record<string, CanonicalValue> = {};
  for (const key of Object.keys(objectValue).sort()) {
    const item = objectValue[key];
    if (typeof item !== "undefined" && typeof item !== "function" && typeof item !== "symbol") {
      record[key] = toCanonicalValue(item, seen);
    }
  }
  seen.delete(objectRef);
  return record;
}

function toHex(bytes: Uint8Array): string {
  return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
}
