/**
 * Zentrale Erkennung & Freigabe von Upload-Dateitypen.
 *
 * Der vom Client gesendete MIME-Type ist NICHT vertrauenswürdig (Browser und
 * Betriebssysteme melden je nach Quelle "", "application/octet-stream" oder
 * schlicht den falschen Typ). Deshalb wird der Dateityp primär über die
 * Magic Bytes des Buffers bestimmt; der gemeldete MIME-Type dient nur als
 * schwacher Hinweis, wenn die Signatur nichts hergibt.
 */

export type DetectedFileKind =
  | "pdf"
  | "jpeg"
  | "png"
  | "webp"
  | "gif"
  | "tiff"
  | "bmp"
  | "avif"
  | "heic"
  | "unknown";

export interface DetectedFile {
  /** Über Magic Bytes (oder ersatzweise MIME-Type) erkannter Typ */
  kind: DetectedFileKind;
  /** Normalisierter MIME-Type passend zu `kind` */
  mimeType: string;
  /** true, wenn die Signatur eindeutig erkannt wurde (nicht nur MIME-Type geraten) */
  detectedFromMagicBytes: boolean;
  /** true, wenn die Verarbeitungspipeline diesen Typ verarbeiten kann */
  isSupported: boolean;
  /** Deutschsprachige Begründung, falls `isSupported === false` */
  unsupportedReason: string | null;
}

/**
 * MIME-Types für die Dateiauswahl im Browser (`accept`-Attribute), passend zur
 * Beleg-Whitelist. Die tatsächliche Freigabe entscheidet ausschließlich das
 * Gate `assertAllowedUploadKind` über die Magic Bytes.
 */
export const ACCEPTED_MIME_TYPES = [
  "image/jpeg",
  "image/png",
  "image/webp",
  "image/heic",
  "image/heif",
  "application/pdf",
] as const;

/** Dateiendungen passend zu ACCEPTED_MIME_TYPES. */
export const ACCEPTED_FILE_EXTENSIONS = [
  ".jpg",
  ".jpeg",
  ".png",
  ".webp",
  ".heic",
  ".heif",
  ".pdf",
] as const;

/** `accept` for the file picker (gallery + drag-drop). Includes HEIC. */
export const RECEIPT_FILE_ACCEPT = [...ACCEPTED_MIME_TYPES, ...ACCEPTED_FILE_EXTENSIONS].join(",");

/**
 * `accept` for `<input capture="environment">`. iOS honors `image/jpeg` and
 * then writes a JPEG instead of HEIC — cheaper than a round-trip decode.
 * Gallery uploads still go through {@link RECEIPT_FILE_ACCEPT}.
 */
export const RECEIPT_CAMERA_ACCEPT = "image/jpeg";

/* -------------------------------------------------------------------------- */
/*                            Upload-Whitelist (Gate)                          */
/* -------------------------------------------------------------------------- */

/**
 * Dateikinds, die ein Upload-Endpoint ausdrücklich erlauben darf. Alles andere
 * wird serverseitig abgelehnt. `ACCEPTED_MIME_TYPES` oben beschreibt nur, was
 * die Dateiauswahl im Browser vorschlägt — die tatsächliche Freigabe passiert
 * ausschließlich über `assertAllowedUploadKind` und die Magic Bytes.
 */
export type UploadAllowedKind = Extract<DetectedFileKind, "pdf" | "jpeg" | "png" | "webp" | "heic">;

/** Erlaubte Dateitypen für Beleg-Scans (entspricht dem UI-Versprechen „PDF • PNG • JPG • WEBP • HEIC"). */
export const RECEIPT_ALLOWED_KINDS: readonly UploadAllowedKind[] = [
  "pdf",
  "jpeg",
  "png",
  "webp",
  "heic",
];

/**
 * Erlaubte Dateitypen für Profilbilder (Avatar-Upload). Ein eigener Endpoint
 * existiert noch nicht — dieses Set ist die Vorlage dafür, und der Upload darf
 * NUR über dasselbe Gate freigegeben werden.
 */
export const AVATAR_ALLOWED_KINDS: readonly UploadAllowedKind[] = ["jpeg", "png", "webp"];

/** Nutzerfreundliche Labels für die Fehlermeldung. */
const KIND_LABELS: Record<UploadAllowedKind, string> = {
  pdf: "PDF",
  jpeg: "JPG",
  png: "PNG",
  webp: "WebP",
  heic: "HEIC",
};

/** Baut die deutsche Fehlermeldung für eine Whitelist, z. B. „Erlaubt sind: PDF, JPG, PNG, WebP.“ */
export function buildAllowedKindsMessage(allowedKinds: readonly UploadAllowedKind[]): string {
  return `Dateityp nicht erlaubt. Erlaubt sind: ${allowedKinds
    .map((kind) => KIND_LABELS[kind])
    .join(", ")}.`;
}

/**
 * Whitelist-Gate für Datei-Uploads — die EINZIGE Freigabestelle für
 * Upload-Dateitypen. Entscheidend sind ausschließlich die Magic Bytes des
 * Buffers; der deklarierte MIME-Type wird bewusst NICHT herangezogen, denn er
 * ist vom Client steuerbar und darf nie Zugriff gewähren (nur als schwacher
 * Hinweis in `detectFileKind`, nie als Freigabe).
 *
 * Gibt den erkannten Kind zurück oder wirft `UnsupportedFileTypeError` mit
 * deutscher Meldung, wenn der Typ nicht in `allowedKinds` liegt.
 */
export function assertAllowedUploadKind(
  buffer: Buffer,
  declaredMimeType: string | undefined,
  allowedKinds: readonly UploadAllowedKind[]
): DetectedFileKind {
  // Bewusst OHNE declaredMimeType aufrufen: reine Magic-Bytes-Erkennung.
  const detected = detectFileKind(buffer);

  const allowed = new Set<DetectedFileKind>(allowedKinds);
  if (!allowed.has(detected.kind)) {
    throw new UnsupportedFileTypeError(buildAllowedKindsMessage(allowedKinds), detected.kind);
  }
  return detected.kind;
}

const KIND_TO_MIME: Record<DetectedFileKind, string> = {
  pdf: "application/pdf",
  jpeg: "image/jpeg",
  png: "image/png",
  webp: "image/webp",
  gif: "image/gif",
  tiff: "image/tiff",
  bmp: "image/bmp",
  avif: "image/avif",
  heic: "image/heic",
  unknown: "application/octet-stream",
};

/** Bildtypen, die sharp direkt verarbeiten kann. */
const SHARP_READABLE: ReadonlySet<DetectedFileKind> = new Set<DetectedFileKind>([
  "jpeg",
  "png",
  "webp",
  "gif",
  "tiff",
  "bmp",
  "avif",
]);

/**
 * Fehler für Dateien, die bewusst abgelehnt werden (statt still falsche Daten
 * zu erzeugen). Die `message` ist deutschsprachig und wird von der API-Route
 * unverändert an den Nutzer durchgereicht.
 */
export class UnsupportedFileTypeError extends Error {
  readonly kind: DetectedFileKind;

  constructor(message: string, kind: DetectedFileKind) {
    super(message);
    this.name = "UnsupportedFileTypeError";
    this.kind = kind;
  }
}

/**
 * Fehler, wenn eine grundsätzlich unterstützte Datei nicht gelesen werden
 * konnte (z. B. beschädigtes oder passwortgeschütztes PDF).
 */
export class DocumentReadError extends Error {
  constructor(message: string, options?: { cause?: unknown }) {
    super(message);
    this.name = "DocumentReadError";
    if (options?.cause !== undefined) {
      (this as { cause?: unknown }).cause = options.cause;
    }
  }
}

export const HEIC_ERROR_MESSAGE =
  "Die HEIC/HEIF-Datei konnte nicht gelesen werden. Bitte exportiere den Beleg als JPEG, PNG oder PDF.";

function startsWith(buffer: Buffer, bytes: number[], offset = 0): boolean {
  if (buffer.length < offset + bytes.length) return false;
  for (let i = 0; i < bytes.length; i++) {
    if (buffer[offset + i] !== bytes[i]) return false;
  }
  return true;
}

function asciiAt(buffer: Buffer, offset: number, length: number): string {
  if (buffer.length < offset + length) return "";
  return buffer.toString("latin1", offset, offset + length);
}

/**
 * Prüft auf die PDF-Signatur `%PDF-`. Laut Spezifikation darf der Header bis zu
 * 1024 Byte Müll vorangestellt haben, daher wird der Anfang durchsucht.
 */
export function isPdfBuffer(buffer: Buffer): boolean {
  if (buffer.length < 5) return false;
  const head = buffer.subarray(0, Math.min(buffer.length, 1024)).toString("latin1");
  return head.includes("%PDF-");
}

/** ISO-BMFF-Container (`ftyp`-Box) auf HEIC/HEIF-Marken prüfen. */
export function isHeicBuffer(buffer: Buffer): boolean {
  if (asciiAt(buffer, 4, 4) !== "ftyp") return false;
  const brand = asciiAt(buffer, 8, 4).toLowerCase();
  return (
    brand.startsWith("hei") ||
    brand.startsWith("hev") ||
    brand === "mif1" ||
    brand === "msf1"
  );
}

/**
 * Bestimmt den Dateityp anhand der Magic Bytes. Der `declaredMimeType` wird nur
 * als Rückfallebene benutzt, wenn keine Signatur passt.
 */
export function detectFileKind(buffer: Buffer, declaredMimeType?: string): DetectedFile {
  const kind = detectKind(buffer, declaredMimeType);
  const detectedFromMagicBytes = detectKind(buffer) !== "unknown";

  if (kind === "heic" || kind === "pdf" || SHARP_READABLE.has(kind)) {
    return {
      kind,
      mimeType: KIND_TO_MIME[kind],
      detectedFromMagicBytes,
      isSupported: true,
      unsupportedReason: null,
    };
  }

  // Unbekannt: Uploads lehnt das Whitelist-Gate (assertAllowedUploadKind) hier
  // ab — ein unbekannter Typ erreicht die Bildpipeline nicht mehr, und es
  // werden keine Roh-Bytes weitergereicht.
  return {
    kind: "unknown",
    mimeType: declaredMimeType && declaredMimeType !== "" ? declaredMimeType : KIND_TO_MIME.unknown,
    detectedFromMagicBytes: false,
    isSupported: false,
    unsupportedReason:
      "Dateityp nicht erkannt. Erlaubt sind: PDF, JPG, PNG, WebP, HEIC.",
  };
}

function detectKind(buffer: Buffer, declaredMimeType?: string): DetectedFileKind {
  if (isPdfBuffer(buffer)) return "pdf";
  if (startsWith(buffer, [0xff, 0xd8, 0xff])) return "jpeg";
  if (startsWith(buffer, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "png";
  if (startsWith(buffer, [0x42, 0x4d])) return "bmp";

  const gifTag = asciiAt(buffer, 0, 6);
  if (gifTag === "GIF87a" || gifTag === "GIF89a") return "gif";

  if (asciiAt(buffer, 0, 4) === "RIFF" && asciiAt(buffer, 8, 4) === "WEBP") return "webp";

  if (startsWith(buffer, [0x49, 0x49, 0x2a, 0x00]) || startsWith(buffer, [0x4d, 0x4d, 0x00, 0x2a])) {
    return "tiff";
  }

  if (asciiAt(buffer, 4, 4) === "ftyp") {
    const brand = asciiAt(buffer, 8, 4).toLowerCase();
    if (brand === "avif" || brand === "avis") return "avif";
    if (isHeicBuffer(buffer)) return "heic";
  }

  // Keine Signatur erkannt: gemeldeten MIME-Type als schwachen Hinweis nutzen.
  if (declaredMimeType) {
    const normalized = declaredMimeType.toLowerCase().split(";")[0].trim();
    if (normalized === "application/pdf") return "pdf";
    if (normalized === "image/heic" || normalized === "image/heif") return "heic";
  }

  return "unknown";
}
