/**
 * File upload helpers. The legacy `saveUpload` keeps the original sanitized
 * filename for compatibility. New applications should use `saveUploadSecure`,
 * which stores a random name and supports content inspection/scanning hooks.
 */

export class UploadError extends Error {
  readonly code: string;
  constructor(message: string, code = "WRN-UPLOAD-REJECTED") {
    super(message);
    this.name = "UploadError";
    this.code = code;
  }
}

export interface UploadInspectionResult {
  allowed: boolean;
  detectedType?: string;
  reason?: string;
}

export type UploadInspector = (input: {
  file: File;
  bytes: Uint8Array;
  filename: string;
}) => UploadInspectionResult | Promise<UploadInspectionResult>;

export type UploadScanner = (input: {
  file: File;
  bytes: Uint8Array;
  filename: string;
}) =>
  | boolean
  | { clean: boolean; reason?: string }
  | Promise<boolean | { clean: boolean; reason?: string }>;

export interface SaveUploadOptions {
  /** Destination directory. Keep this outside the public web root. */
  dir: string;
  /** Reject files larger than this many bytes. */
  maxBytes?: number;
  /** Allowed MIME types (e.g. "image/png") and/or extensions (e.g. ".png"). */
  allowedTypes?: string[];
  /** Choose the stored filename. Default: the sanitised original name. */
  filename?: (file: File) => string;
  /** Content/magic-byte inspection hook. */
  inspect?: UploadInspector;
  /** Malware scanning hook. */
  scan?: UploadScanner;
  /** Called after validation but before persistence. */
  beforeSave?: (input: { file: File; bytes: Uint8Array; filename: string }) => void | Promise<void>;
}

export interface SecureUploadOptions extends Omit<SaveUploadOptions, "filename"> {
  /** Preserve the original sanitized name instead of a random server name. */
  preserveOriginalName?: boolean;
  /** Optional custom secure filename generator. */
  filename?: (file: File) => string;
  /** Preserve a conservative extension on random filenames. Defaults to true. */
  preserveExtension?: boolean;
}

export interface SavedUpload {
  path: string;
  filename: string;
  size: number;
  type: string;
  detectedType?: string;
}

/** All `File` values in a parsed form, with their field names. */
export function collectUploads(
  form: FormData,
  options: { maxFiles?: number; maxTotalBytes?: number } = {},
): { field: string; file: File }[] {
  const out: { field: string; file: File }[] = [];
  let totalBytes = 0;
  for (const [field, value] of form) {
    if (!(value instanceof File) || value.size <= 0) continue;
    out.push({ field, file: value });
    totalBytes += value.size;
    if (options.maxFiles !== undefined && out.length > options.maxFiles) {
      throw new UploadError(
        `Upload contains more than ${options.maxFiles} files.`,
        "WRN-UPLOAD-FILE-COUNT",
      );
    }
    if (options.maxTotalBytes !== undefined && totalBytes > options.maxTotalBytes) {
      throw new UploadError(
        `Upload exceeds the ${options.maxTotalBytes}-byte aggregate limit.`,
        "WRN-UPLOAD-TOTAL-SIZE",
      );
    }
  }
  return out;
}

/** Validate and write one uploaded file using a compatibility filename policy. */
export async function saveUpload(file: File, options: SaveUploadOptions): Promise<SavedUpload> {
  return persistUpload(
    file,
    options,
    sanitizeFilename(options.filename ? options.filename(file) : file.name || "upload"),
  );
}

/** Store an upload under a random server-generated name by default. */
export async function saveUploadSecure(
  file: File,
  options: SecureUploadOptions,
): Promise<SavedUpload> {
  const requested = options.filename?.(file);
  const filename = requested
    ? sanitizeFilename(requested)
    : options.preserveOriginalName
      ? sanitizeFilename(file.name || "upload")
      : randomUploadFilename(file.name, options.preserveExtension !== false);
  return persistUpload(file, options, filename);
}

async function persistUpload(
  file: File,
  options: SaveUploadOptions,
  filename: string,
): Promise<SavedUpload> {
  if (options.maxBytes !== undefined && file.size > options.maxBytes) {
    throw new UploadError(
      `File "${file.name}" exceeds the ${options.maxBytes}-byte limit`,
      "WRN-UPLOAD-SIZE",
    );
  }
  if (options.allowedTypes && !isAllowed(file, options.allowedTypes)) {
    throw new UploadError(
      `File type not allowed: ${file.type || file.name || "unknown"}`,
      "WRN-UPLOAD-TYPE",
    );
  }

  const bytes = new Uint8Array(await file.arrayBuffer());
  let detectedType: string | undefined;
  if (options.inspect) {
    const result = await options.inspect({ file, bytes, filename });
    if (!result.allowed) {
      throw new UploadError(result.reason ?? "File content is not allowed.", "WRN-UPLOAD-CONTENT");
    }
    detectedType = result.detectedType;
  }
  if (options.scan) {
    const result = await options.scan({ file, bytes, filename });
    const clean = typeof result === "boolean" ? result : result.clean;
    if (!clean) {
      throw new UploadError(
        typeof result === "boolean"
          ? "File failed malware scanning."
          : (result.reason ?? "File failed malware scanning."),
        "WRN-UPLOAD-MALWARE",
      );
    }
  }
  await options.beforeSave?.({ file, bytes, filename });

  const path = `${options.dir.replace(/[/\\]+$/, "")}/${filename}`;
  await Bun.write(path, bytes);
  return {
    path,
    filename,
    size: file.size,
    type: file.type,
    ...(detectedType ? { detectedType } : {}),
  };
}

function isAllowed(file: File, allowed: string[]): boolean {
  const type = (file.type || "").toLowerCase();
  const name = (file.name || "").toLowerCase();
  return allowed.some((entry) => {
    const candidate = entry.toLowerCase();
    if (candidate.startsWith(".")) return name.endsWith(candidate);
    if (candidate.endsWith("/*")) return type.startsWith(candidate.slice(0, -1));
    return type === candidate;
  });
}

/** Strip directory separators, traversal, and control chars from a filename. */
export function sanitizeFilename(name: string): string {
  const base = name
    .replace(/[/\\]+/g, "_")
    .replace(/\.\.+/g, ".")
    // eslint-disable-next-line no-control-regex -- intentionally stripping control chars
    .replace(/[\x00-\x1f<>:"|?*]/g, "")
    .replace(/^\.+/, "")
    .trim();
  return base.length > 0 ? base.slice(0, 255) : "upload";
}

export function randomUploadFilename(originalName = "", preserveExtension = true): string {
  const bytes = new Uint8Array(16);
  crypto.getRandomValues(bytes);
  const id = [...bytes].map((value) => value.toString(16).padStart(2, "0")).join("");
  if (!preserveExtension) return id;
  const match = /(?:^|\.)([A-Za-z0-9]{1,10})$/.exec(originalName);
  return match ? `${id}.${match[1]!.toLowerCase()}` : id;
}

export function secureDownloadHeaders(
  filename: string,
  type = "application/octet-stream",
): Headers {
  const safe = sanitizeFilename(filename).replace(/["\\]/g, "_");
  return new Headers({
    "content-type": type,
    "content-disposition": `attachment; filename="${safe}"`,
    "x-content-type-options": "nosniff",
    "cache-control": "private, no-store",
  });
}
