import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { TextDecoder } from 'node:util';
import multer from 'multer';
import { fileTypeFromFile } from 'file-type';
import { detectAv } from '@file-type/av';
import { config } from '../config.js';
import { HttpError } from './errors.js';

const MAX_GPX_PREFIX_BYTES = 131072;
const BASE64_PATTERN = /^[A-Za-z0-9+/]*={0,2}$/;
const IGNORED_XML_MARKUP = Object.freeze([
  Object.freeze({ opening: '<!--', closing: '-->' }),
  Object.freeze({ opening: '<?', closing: '?>' }),
  Object.freeze({ opening: '<!', closing: '>' })
]);

const ALLOWED_TYPES = Object.freeze({
  picture: Object.freeze({
    mime: new Set(['image/jpeg', 'image/png', 'image/webp']),
    ext: new Set(['jpg', 'png', 'webp'])
  }),
  audio: Object.freeze({
    mime: new Set(['audio/mpeg', 'audio/mp4', 'audio/aac', 'audio/ogg', 'audio/wav', 'audio/webm']),
    ext: new Set(['mp3', 'm4a', 'aac', 'ogg', 'opus', 'spx', 'wav', 'webm'])
  })
});

function sanitizeFilename(value) {
  return String(value).replaceAll(/[^a-zA-Z0-9._-]/g, '_') || 'upload';
}

function stripBase64Padding(value) {
  let end = value.length;
  while (end > 0 && value[end - 1] === '=') end -= 1;
  return value.slice(0, end);
}

function base64FromDataUrl(value, fieldName) {
  const dataUrl = value.trim();
  const marker = ';base64,';
  const markerIndex = dataUrl.indexOf(marker);
  if (!dataUrl.startsWith('data:') || markerIndex < 5) {
    throw new HttpError(400, `${fieldName}.dataUrl muss eine Base64-Data-URL sein.`);
  }
  return dataUrl.slice(markerIndex + marker.length);
}

function isXmlNameCharacter(character, first = false) {
  const code = character.codePointAt(0);
  const letter = (code >= 65 && code <= 90) || (code >= 97 && code <= 122);
  if (letter || character === '_') return true;
  return !first && ((code >= 48 && code <= 57) || character === '.' || character === '-' || character === ':');
}

function ignoredMarkupEnd(xml, opening) {
  const markup = IGNORED_XML_MARKUP.find(item => xml.startsWith(item.opening, opening));
  if (!markup) return null;

  const closing = xml.indexOf(markup.closing, opening + markup.opening.length);
  return closing < 0 ? -1 : closing + markup.closing.length;
}

function readXmlName(xml, start) {
  if (start >= xml.length || !isXmlNameCharacter(xml[start], true)) return null;

  let end = start + 1;
  while (end < xml.length && isXmlNameCharacter(xml[end])) end += 1;
  return xml.slice(start, end);
}

function rootElementName(xml) {
  let cursor = 0;

  while (cursor < xml.length) {
    const opening = xml.indexOf('<', cursor);
    if (opening < 0 || opening + 1 >= xml.length) return null;

    const nextCursor = ignoredMarkupEnd(xml, opening);
    if (nextCursor === -1) return null;
    if (nextCursor != null) {
      cursor = nextCursor;
      continue;
    }

    if (xml.startsWith('</', opening)) return null;
    return readXmlName(xml, opening + 1);
  }

  return null;
}

const storage = multer.diskStorage({
  destination: path.join(config.storageDir, 'uploads'),
  filename(req, file, callback) {
    const safe = sanitizeFilename(file.originalname);
    callback(null, `${Date.now()}-${crypto.randomUUID()}-${safe}`);
  }
});

export const upload = multer({
  storage,
  limits: { fileSize: config.maxUploadBytes, files: 25 }
});

function removeTemporaryFile(file) {
  if (!file?.path || !fs.existsSync(file.path)) return;
  fs.rmSync(file.path, { force: true });
}

function assertValidBase64(compact, fieldName) {
  if (!BASE64_PATTERN.test(compact) || compact.length % 4 === 1) {
    throw new HttpError(400, `${fieldName}.base64 enthält keine gültigen Base64-Daten.`);
  }
}

function assertCanonicalBase64(compact, buffer, fieldName) {
  const canonicalInput = stripBase64Padding(compact);
  const canonicalOutput = stripBase64Padding(buffer.toString('base64'));
  if (canonicalInput !== canonicalOutput) {
    throw new HttpError(400, `${fieldName}.base64 enthält keine gültigen Base64-Daten.`);
  }
}

function assertUploadSize(buffer) {
  if (buffer.length <= config.maxUploadBytes) return;
  const limitMb = Math.floor(config.maxUploadBytes / 1024 / 1024);
  throw new HttpError(413, `Die Datei überschreitet das Limit von ${limitMb} MB.`);
}

function decodeBase64(value, fieldName) {
  if (typeof value !== 'string' || !value.trim()) {
    throw new HttpError(400, `${fieldName}.base64 muss eine nichtleere Base64-Zeichenkette sein.`);
  }

  const compact = value.replaceAll(/\s/g, '');
  assertValidBase64(compact, fieldName);

  const buffer = Buffer.from(compact, 'base64');
  assertCanonicalBase64(compact, buffer, fieldName);
  assertUploadSize(buffer);
  return buffer;
}

function jsonFileDescriptor(value, fieldName) {
  if (value == null) return null;
  if (typeof value !== 'object' || Array.isArray(value)) {
    throw new HttpError(400, `${fieldName} muss ein JSON-Objekt sein.`);
  }

  const fallbackName = `${fieldName}.upload`;
  const originalname = String(value.filename ?? fallbackName).trim() || fallbackName;
  let base64 = value.base64;

  if (value.dataUrl != null) {
    if (typeof value.dataUrl !== 'string') {
      throw new HttpError(400, `${fieldName}.dataUrl muss eine Zeichenkette sein.`);
    }
    base64 = base64FromDataUrl(value.dataUrl, fieldName);
  }

  return { originalname, buffer: decodeBase64(base64, fieldName) };
}

function materializeJsonUpload(req, fieldName) {
  if (req.file || req.body?.[fieldName] == null) return;

  const descriptor = jsonFileDescriptor(req.body[fieldName], fieldName);
  if (!descriptor) return;

  const destination = path.join(config.storageDir, 'uploads');
  fs.mkdirSync(destination, { recursive: true });
  const safe = sanitizeFilename(descriptor.originalname);
  const filename = `${Date.now()}-${crypto.randomUUID()}-${safe}`;
  const filePath = path.join(destination, filename);
  fs.writeFileSync(filePath, descriptor.buffer, { flag: 'wx' });

  req.file = {
    fieldname: fieldName,
    originalname: descriptor.originalname,
    encoding: '7bit',
    mimetype: 'application/octet-stream',
    destination,
    filename,
    path: filePath,
    size: descriptor.buffer.length
  };
  delete req.body[fieldName];
}

function detectGpx(file) {
  let xml;
  try {
    const data = fs.readFileSync(file.path);
    xml = new TextDecoder('utf-8', { fatal: true }).decode(data);
  } catch {
    throw new HttpError(415, 'Der Upload ist keine gültig kodierte GPX-Datei.');
  }

  const prefix = xml.slice(0, MAX_GPX_PREFIX_BYTES);
  const start = prefix.startsWith('\uFEFF') ? prefix.slice(1) : prefix;
  const localName = rootElementName(start)?.split(':').at(-1);
  if (localName !== 'gpx') {
    throw new HttpError(415, 'Der Upload enthält kein GPX-Dokument.');
  }
  return { ext: 'gpx', mime: 'application/gpx+xml' };
}

async function detectBinary(file) {
  try {
    return await fileTypeFromFile(file.path, {
      customDetectors: [detectAv],
      mpegOffsetTolerance: 10
    });
  } catch {
    return undefined;
  }
}

function unsupportedContentMessage(kind) {
  return kind === 'picture'
    ? 'Der Dateiinhalt ist kein unterstütztes Bildformat.'
    : 'Der Dateiinhalt ist kein unterstütztes Audioformat.';
}

function unsupportedFormatMessage(kind, mime) {
  return kind === 'picture'
    ? `Nicht unterstütztes Bildformat: ${mime}`
    : `Nicht unterstütztes Audioformat: ${mime}`;
}

function validateDetectedType(detected, kind) {
  if (kind === 'gpx') return;

  const rules = ALLOWED_TYPES[kind];
  const baseMime = String(detected.mime).split(';', 1)[0].toLowerCase();
  const extension = String(detected.ext).toLowerCase();
  if (!rules?.mime.has(baseMime) || !rules.ext.has(extension)) {
    throw new HttpError(415, unsupportedFormatMessage(kind, detected.mime));
  }
}

async function detectAndValidate(file, kind) {
  const detected = kind === 'gpx' ? detectGpx(file) : await detectBinary(file);
  if (!detected) throw new HttpError(415, unsupportedContentMessage(kind));

  validateDetectedType(detected, kind);
  file.mimetype = detected.mime;
  file.detectedMime = detected.mime;
  file.detectedExtension = detected.ext;
  return file;
}

export function inspectUpload(fieldName, kind, { allowJson = false } = {}) {
  return async function inspectUploadedFile(req, res, next) {
    try {
      if (allowJson) materializeJsonUpload(req, fieldName);
      if (!req.file) return next();

      await detectAndValidate(req.file, kind);
      return next();
    } catch (error) {
      removeTemporaryFile(req.file);
      req.file = undefined;
      return next(error);
    }
  };
}
