import { accepts, contentTypeOf, extOf } from "./mime.ts";

export interface UploadPolicy {
  maxBytes?: number;
  accept?: string[];
  requireChecksum?: boolean;
  filenamePattern?: RegExp;
}

export interface UploadInspection {
  filename: string;
  contentType: string;
  size: number;
  sha256: string;
  extension: string;
}

export class UploadPolicyError extends Error {
  constructor(
    message: string,
    readonly code: string,
  ) {
    super(message);
    this.name = "UploadPolicyError";
  }
}

function safePrefix(prefix: string): string {
  const segments = prefix
    .split(/[\\/]+/)
    .map((segment) => segment.trim())
    .filter((segment) => segment && segment !== "." && segment !== "..")
    .map((segment) => segment.replace(/[^A-Za-z0-9._-]+/g, "-"))
    .filter(Boolean);
  return segments.join("/") || "uploads";
}

export function safeObjectKey(filename: string, prefix = "uploads"): string {
  const extension = extOf(filename)
    .replace(/[^A-Za-z0-9.]/g, "")
    .slice(0, 16);
  const date = new Date().toISOString().slice(0, 10);
  return `${safePrefix(prefix)}/${date}/${crypto.randomUUID()}${extension}`;
}

export async function inspectUpload(
  filename: string,
  bytes: Uint8Array,
  declaredType?: string,
): Promise<UploadInspection> {
  const digest = await crypto.subtle.digest("SHA-256", Uint8Array.from(bytes).buffer);
  const sha256 = [...new Uint8Array(digest)]
    .map((byte) => byte.toString(16).padStart(2, "0"))
    .join("");
  return {
    filename,
    contentType: declaredType || contentTypeOf(filename),
    size: bytes.byteLength,
    sha256,
    extension: extOf(filename),
  };
}

export function enforceUploadPolicy(
  inspection: UploadInspection,
  policy: UploadPolicy,
  expectedChecksum?: string,
): void {
  if (policy.maxBytes !== undefined) {
    if (!Number.isFinite(policy.maxBytes) || policy.maxBytes < 0) {
      throw new TypeError("Upload maxBytes must be a non-negative number");
    }
    if (inspection.size > policy.maxBytes) {
      throw new UploadPolicyError(`File exceeds ${policy.maxBytes} bytes`, "UPLOAD_TOO_LARGE");
    }
  }
  if (
    policy.accept?.length &&
    !accepts(policy.accept, {
      name: inspection.filename,
      type: inspection.contentType,
    })
  ) {
    throw new UploadPolicyError("File type is not allowed", "UPLOAD_TYPE_REJECTED");
  }
  if (policy.filenamePattern && !policy.filenamePattern.test(inspection.filename)) {
    throw new UploadPolicyError("Filename is not allowed", "UPLOAD_FILENAME_REJECTED");
  }
  if (policy.requireChecksum && !expectedChecksum) {
    throw new UploadPolicyError("Checksum is required", "UPLOAD_CHECKSUM_REQUIRED");
  }
  if (expectedChecksum && expectedChecksum.toLowerCase() !== inspection.sha256) {
    throw new UploadPolicyError("Checksum does not match", "UPLOAD_CHECKSUM_MISMATCH");
  }
}

function signature(bytes: Uint8Array, values: number[]): boolean {
  return values.every((value, index) => bytes[index] === value);
}

export function sniffContentType(bytes: Uint8Array): string | null {
  if (signature(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) {
    return "image/png";
  }
  if (signature(bytes, [0xff, 0xd8, 0xff])) return "image/jpeg";
  if (signature(bytes, [0x47, 0x49, 0x46, 0x38])) return "image/gif";
  if (signature(bytes, [0x25, 0x50, 0x44, 0x46])) return "application/pdf";
  if (signature(bytes, [0x50, 0x4b, 0x03, 0x04])) return "application/zip";
  if (
    signature(bytes, [0x52, 0x49, 0x46, 0x46]) &&
    String.fromCharCode(...bytes.slice(8, 12)) === "WEBP"
  ) {
    return "image/webp";
  }
  return null;
}

export interface SignedFileToken {
  store: string;
  key: string;
  expiresAt: number;
  disposition?: "inline" | "attachment";
}

function base64url(value: Uint8Array): string {
  let text = "";
  for (const byte of value) text += String.fromCharCode(byte);
  return btoa(text).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}

function assertSigningSecret(secret: string): void {
  if (secret.length < 16) {
    throw new TypeError("Signed file token secret must be at least 16 characters");
  }
}

function assertSignedFileToken(value: SignedFileToken): void {
  if (!value.store.trim()) throw new TypeError("Signed file token store is required");
  if (!value.key.trim()) throw new TypeError("Signed file token key is required");
  if (!Number.isFinite(value.expiresAt)) {
    throw new TypeError("Signed file token expiresAt must be finite");
  }
  if (
    value.disposition !== undefined &&
    value.disposition !== "inline" &&
    value.disposition !== "attachment"
  ) {
    throw new TypeError("Signed file token disposition is invalid");
  }
}

async function sign(value: string, secret: string): Promise<string> {
  assertSigningSecret(secret);
  const key = await crypto.subtle.importKey(
    "raw",
    new TextEncoder().encode(secret),
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["sign"],
  );
  return base64url(
    new Uint8Array(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(value))),
  );
}

function constantTimeEqual(left: string, right: string): boolean {
  const length = Math.max(left.length, right.length);
  let difference = left.length ^ right.length;
  for (let index = 0; index < length; index++) {
    difference |= (left.charCodeAt(index) || 0) ^ (right.charCodeAt(index) || 0);
  }
  return difference === 0;
}

export async function createSignedFileToken(
  input: SignedFileToken,
  secret: string,
): Promise<string> {
  assertSignedFileToken(input);
  const payload = base64url(new TextEncoder().encode(JSON.stringify(input)));
  return `${payload}.${await sign(payload, secret)}`;
}

export async function verifySignedFileToken(
  token: string,
  secret: string,
  now = Date.now(),
): Promise<SignedFileToken | null> {
  const parts = token.split(".");
  if (parts.length !== 2) return null;
  const [payload, signature] = parts;
  if (!payload || !signature) return null;

  const expected = await sign(payload, secret);
  if (!constantTimeEqual(expected, signature)) return null;

  try {
    const padding = payload.length % 4 ? "=".repeat(4 - (payload.length % 4)) : "";
    const raw = atob(payload.replace(/-/g, "+").replace(/_/g, "/") + padding);
    const value = JSON.parse(
      new TextDecoder().decode(Uint8Array.from(raw, (character) => character.charCodeAt(0))),
    ) as SignedFileToken;
    assertSignedFileToken(value);
    return value.expiresAt > now ? value : null;
  } catch {
    return null;
  }
}
