const pull = require('../server/node_modules/pull-stream');
const FileType = require("../server/node_modules/file-type");
const promisesFs = require('fs').promises;
const ssb = require("../client/gui");
const config = require("../server/SSB_server").config;
const cooler = ssb({ offline: config.offline });

let sharp;
try {
  sharp = require("sharp");
} catch (e) {
}

/**
 * Quitar metadatos de una imagen SIN sharp.
 *
 * sharp no se empaqueta en Android (lleva binarios nativos y el bundle del movil
 * va sin ninguno), asi que alli esta funcion devolvia la imagen intacta y las
 * fotos subian con su EXIF entero, ubicacion GPS incluida.
 *
 * Esto recorre el contenedor y tira los bloques de metadatos, sin decodificar ni
 * recomprimir la imagen: los pixeles se quedan byte a byte como estaban.
 *
 * De la orientacion se guarda solo eso, la orientacion. Sin sharp no se pueden
 * girar los pixeles, y tirar la etiqueta dejaria las fotos verticales tumbadas;
 * asi que se reconstruye un bloque EXIF minimo con esa unica etiqueta, que no
 * dice nada de quien hizo la foto ni donde.
 */

// JPEG: cada segmento es FF <marca> <longitud:2> <datos>. Se tiran los que llevan
// metadatos y se dejan JFIF (APP0) y el perfil de color (APP2), que afectan a como
// se ve la imagen.
const JPEG_DROP = new Set([
  0xE1, // APP1  Exif y XMP
  0xE5, // APP5
  0xE6, // APP6
  0xEC, // APP12 Ducky/Picture Info
  0xED, // APP13 IPTC / Photoshop
  0xEE, // APP14 Adobe
  0xFE  // COM   comentario
]);

const jpegOrientation = (seg) => {
  // seg empieza en "Exif\0\0"; detras va una cabecera TIFF con la IFD0
  if (seg.length < 14 || seg.toString("latin1", 0, 6) !== "Exif\0\0") return 0;
  const tiff = seg.subarray(6);
  const le = tiff.toString("latin1", 0, 2) === "II";
  const u16 = (o) => (le ? tiff.readUInt16LE(o) : tiff.readUInt16BE(o));
  const u32 = (o) => (le ? tiff.readUInt32LE(o) : tiff.readUInt32BE(o));
  if (u16(2) !== 0x002A) return 0;
  const ifd0 = u32(4);
  if (ifd0 + 2 > tiff.length) return 0;
  const n = u16(ifd0);
  for (let i = 0; i < n; i++) {
    const e = ifd0 + 2 + i * 12;
    if (e + 12 > tiff.length) break;
    if (u16(e) === 0x0112) {                 // Orientation
      const v = u16(e + 8);
      return v >= 1 && v <= 8 ? v : 0;
    }
  }
  return 0;
};

/** Bloque APP1 de 32 bytes con la orientacion y nada mas. */
const exifOnlyOrientation = (orientation) => {
  const b = Buffer.alloc(32);
  b.write("Exif\0\0", 0, "latin1");
  b.write("II", 6, "latin1");
  b.writeUInt16LE(0x002A, 8);
  b.writeUInt32LE(8, 10);        // la IFD0 empieza justo detras de la cabecera TIFF
  b.writeUInt16LE(1, 14);        // una sola entrada
  b.writeUInt16LE(0x0112, 16);   // Orientation
  b.writeUInt16LE(3, 18);        // SHORT
  b.writeUInt32LE(1, 20);        // un valor
  b.writeUInt16LE(orientation, 24);
  b.writeUInt32LE(0, 28);        // no hay mas IFDs
  return b;
};

const stripJpeg = (buf) => {
  if (buf.length < 4 || buf[0] !== 0xFF || buf[1] !== 0xD8) return null;
  const out = [buf.subarray(0, 2)];
  let orientation = 0;
  let i = 2;
  while (i + 4 <= buf.length) {
    if (buf[i] !== 0xFF) return null;                 // no es un JPEG sano: no tocarlo
    const marker = buf[i + 1];
    if (marker === 0xD8 || (marker >= 0xD0 && marker <= 0xD9)) { i += 2; continue; }
    if (marker === 0xDA) { out.push(buf.subarray(i)); break; }   // datos comprimidos: hasta el final
    const len = buf.readUInt16BE(i + 2);
    if (len < 2 || i + 2 + len > buf.length) return null;
    const seg = buf.subarray(i + 4, i + 2 + len);
    if (marker === 0xE1 && !orientation) orientation = jpegOrientation(seg);
    if (!JPEG_DROP.has(marker)) out.push(buf.subarray(i, i + 2 + len));
    i += 2 + len;
  }
  if (orientation > 1) {
    const exif = exifOnlyOrientation(orientation);
    const head = Buffer.alloc(4);
    head.writeUInt16BE(0xFFE1, 0);
    head.writeUInt16BE(exif.length + 2, 2);
    out.splice(1, 0, head, exif);
  }
  return Buffer.concat(out);
};

// PNG: cadena de bloques <longitud:4> <tipo:4> <datos> <crc:4>.
const PNG_DROP = new Set(["tEXt", "zTXt", "iTXt", "eXIf", "tIME", "dSIG"]);

const stripPng = (buf) => {
  const SIG = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);
  if (buf.length < 8 || !buf.subarray(0, 8).equals(SIG)) return null;
  const out = [buf.subarray(0, 8)];
  let i = 8;
  while (i + 8 <= buf.length) {
    const len = buf.readUInt32BE(i);
    const type = buf.toString("latin1", i + 4, i + 8);
    const end = i + 12 + len;
    if (end > buf.length) return null;
    if (!PNG_DROP.has(type)) out.push(buf.subarray(i, end));
    i = end;
    if (type === "IEND") break;
  }
  return Buffer.concat(out);
};

// WebP: contenedor RIFF con bloques <tipo:4> <longitud:4> <datos> (+1 de relleno
// si la longitud es impar).
const stripWebp = (buf) => {
  if (buf.length < 12) return null;
  if (buf.toString("latin1", 0, 4) !== "RIFF" || buf.toString("latin1", 8, 12) !== "WEBP") return null;
  const out = [];
  let i = 12;
  while (i + 8 <= buf.length) {
    const type = buf.toString("latin1", i, i + 4);
    const len = buf.readUInt32LE(i + 4);
    const end = i + 8 + len + (len % 2);
    if (end > buf.length) return null;
    if (type !== "EXIF" && type !== "XMP ") out.push(buf.subarray(i, end));
    i = end;
  }
  const body = Buffer.concat(out);
  const head = Buffer.alloc(12);
  head.write("RIFF", 0, "latin1");
  head.writeUInt32LE(body.length + 4, 4);
  head.write("WEBP", 8, "latin1");
  return Buffer.concat([head, body]);
};

/** Limpia segun el formato; si algo no cuadra, devuelve la imagen tal cual. */
const stripMetadataPure = (buffer) => {
  try {
    const out = stripJpeg(buffer) || stripPng(buffer) || stripWebp(buffer);
    return out && out.length ? out : buffer;
  } catch {
    return buffer;
  }
};

const stripImageMetadata = async (buffer) => {
  // Con sharp se prefiere su camino: ademas de quitar metadatos, aplica la
  // orientacion girando los pixeles de verdad.
  if (typeof sharp === "function") {
    try {
      return await sharp(buffer).rotate().toBuffer();
    } catch {
      return stripMetadataPure(buffer);
    }
  }
  return stripMetadataPure(buffer);
};

const PDF_METADATA_KEYS = [
  '/Title', '/Author', '/Subject', '/Keywords',
  '/Creator', '/Producer', '/CreationDate', '/ModDate'
];

const stripPdfMetadata = (buffer) => {
  try {
    let str = buffer.toString('binary');
    for (const key of PDF_METADATA_KEYS) {
      const keyBytes = key;
      const regex = new RegExp(
        keyBytes.replace(/\//g, '\\/') + '\\s*\\([^)]*\\)',
        'g'
      );
      str = str.replace(regex, keyBytes + ' ()');
      const hexRegex = new RegExp(
        keyBytes.replace(/\//g, '\\/') + '\\s*<[^>]*>',
        'g'
      );
      str = str.replace(hexRegex, keyBytes + ' <>');
    }
    return Buffer.from(str, 'binary');
  } catch {
    return buffer;
  }
};

const MAX_BLOB_SIZE = 50 * 1024 * 1024;

class FileTooLargeError extends Error {
  constructor(fileName, fileSize) {
    super(`File too large: ${fileName} (${(fileSize / 1024 / 1024).toFixed(1)} MB)`);
    this.name = 'FileTooLargeError';
    this.fileName = fileName;
    this.fileSize = fileSize;
  }
}

const OGG_VIDEO_MARKERS = ['theora', 'VP80', 'OggDS', 'Dirac'];
const OGG_AUDIO_MARKERS = ['vorbis', 'OpusHead', 'FLAC', 'Speex'];

const oggMime = (buffer) => {
  const head = buffer.slice(0, 65536);
  for (const marker of OGG_VIDEO_MARKERS) if (head.includes(Buffer.from(marker))) return 'video/ogg';
  for (const marker of OGG_AUDIO_MARKERS) if (head.includes(Buffer.from(marker))) return 'audio/ogg';
  return 'video/ogg';
};

const storeUploadedFile = async function (blobUpload) {
  if (!blobUpload || !blobUpload.filepath) return null;

  let data = await promisesFs.readFile(blobUpload.filepath);
  if (data.length === 0) return null;

  if (data.length > MAX_BLOB_SIZE) {
    throw new FileTooLargeError(blobUpload.originalFilename || blobUpload.name || 'file', data.length);
  }

  const EXTENSION_MIME_MAP = {
    '.mp4': 'video/mp4', '.webm': 'video/webm', '.ogg': 'video/ogg',
    '.ogv': 'video/ogg', '.oga': 'audio/ogg', '.mkv': 'video/x-matroska', '.avi': 'video/x-msvideo', '.mov': 'video/quicktime',
    '.mp3': 'audio/mpeg', '.wav': 'audio/wav',
    '.flac': 'audio/flac', '.aac': 'audio/aac', '.opus': 'audio/opus',
    '.pdf': 'application/pdf', '.png': 'image/png', '.jpg': 'image/jpeg',
    '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp',
    '.svg': 'image/svg+xml', '.bmp': 'image/bmp',
    '.torrent': 'application/x-bittorrent'
  };

  const blob = { name: blobUpload.originalFilename || blobUpload.name || 'file' };

  try {
    const fileType = await FileType.fromBuffer(data);
    blob.mime = (fileType && fileType.mime) ? fileType.mime : null;
  } catch {
    blob.mime = null;
  }

  if (blob.mime === 'application/ogg') blob.mime = oggMime(data);

  const GENERIC_MIMES = new Set(['application/octet-stream', 'video/x-matroska']);
  if ((!blob.mime || GENERIC_MIMES.has(blob.mime)) && blob.name) {
    const ext = (blob.name.match(/\.[^.]+$/) || [''])[0].toLowerCase();
    blob.mime = EXTENSION_MIME_MAP[ext] || blob.mime || 'application/octet-stream';
  }

  if (!blob.mime) {
    blob.mime = 'application/octet-stream';
  }

  if (blob.mime.startsWith('image/') && blob.mime !== 'image/gif') {
    data = await stripImageMetadata(data);
  } else if (blob.mime === 'application/pdf') {
    data = stripPdfMetadata(data);
  }

  const ssbClient = await cooler.open();

  blob.id = await new Promise((resolve, reject) => {
    pull(
      pull.values([data]),
      ssbClient.blobs.add((err, ref) => (err ? reject(err) : resolve(ref)))
    );
  });

  if (blob.mime.startsWith("image/")) return `\n![image:${blob.name}](${blob.id})`;
  if (blob.mime.startsWith("audio/")) return `\n[audio:${blob.name}](${blob.id})`;
  if (blob.mime.startsWith("video/")) return `\n[video:${blob.name}](${blob.id})`;
  if (blob.mime === "application/pdf") return `[pdf:${blob.name}](${blob.id})`;
  if (blob.mime === "application/x-bittorrent") return `\n[torrent:${blob.name}](${blob.id})`;

  return `\n[${blob.name}](${blob.id})`;
};

const handleBlobUpload = async function (ctx, fileFieldName) {
  if (!ctx.request.files || !ctx.request.files[fileFieldName]) return null;
  const entry = ctx.request.files[fileFieldName];
  const first = Array.isArray(entry) ? entry[0] : entry;
  return storeUploadedFile(first);
};

const handleBlobUploads = async function (ctx, fileFieldName, max = 8) {
  if (!ctx.request.files || !ctx.request.files[fileFieldName]) return [];
  const entry = ctx.request.files[fileFieldName];
  const files = (Array.isArray(entry) ? entry : [entry]).slice(0, Math.max(0, max));
  const out = [];
  for (const file of files) {
    const stored = await storeUploadedFile(file);
    if (stored) out.push(stored);
  }
  return out;
};

function waitForBlob(ssbClient, blobId, timeoutMs = 8000) {
  return new Promise((resolve, reject) => {
    let done = false;

    const finishOk = () => {
      if (done) return;
      done = true;
      clearTimeout(timer);
      resolve();
    };

    const finishErr = (err) => {
      if (done) return;
      done = true;
      clearTimeout(timer);
      reject(err);
    };

    const timer = setTimeout(() => {
      finishErr(new Error(`Timeout waiting for blob ${blobId}`));
    }, timeoutMs);

    if (!ssbClient.blobs || typeof ssbClient.blobs.has !== 'function') {
      return finishErr(new Error('ssb.blobs.has is not available'));
    }

    ssbClient.blobs.has(blobId, (err, has) => {
      if (err) return finishErr(err);
      if (has) return finishOk();

      if (typeof ssbClient.blobs.want !== 'function') {
        return finishErr(new Error('ssb.blobs.want is not available'));
      }

      ssbClient.blobs.want(blobId, (err2) => {
        if (err2) return finishErr(err2);
        finishOk();
      });
    });
  });
}

const serveBlob = async function (ctx) {
  const encodedParam = (ctx.params.id || ctx.params.blobId || '').trim();
  const raw = decodeURIComponent(encodedParam);

  if (!raw) {
    ctx.status = 400;
    ctx.body = 'Invalid blob id';
    return;
  }

  const blobId = raw.startsWith('&') ? raw : `&${raw}`;

  const ssbClient = await cooler.open();

  try {
    await waitForBlob(ssbClient, blobId, 8000);
  } catch (err) {
    ctx.status = 504;
    ctx.body = 'Blob not available';
    return;
  }

  let buffer;
  try {
    buffer = await new Promise((resolve, reject) => {
      pull(
        ssbClient.blobs.get(blobId),
        pull.collect((err, chunks) => {
          if (err) return reject(err);
          resolve(Buffer.concat(chunks));
        })
      );
    });
  } catch (err) {
    ctx.status = 500;
    ctx.body = 'Error reading blob';
    return;
  }

  const size = buffer.length;

  let mime = 'application/octet-stream';
  try {
    const ft = await FileType.fromBuffer(buffer);
    if (ft && ft.mime) mime = ft.mime;
  } catch {}

  if (mime === 'application/octet-stream' && buffer.length > 10 && buffer[0] === 0x64) {
    const head = buffer.slice(0, 128).toString('ascii');
    if (head.includes('announce') || head.includes('8:announce') || head.includes('4:info')) mime = 'application/x-bittorrent';
  }

  if (mime === 'application/octet-stream' || mime === 'text/plain' || mime === 'application/xml' || mime === 'text/xml') {
    let head = buffer.slice(0, 512).toString('utf8');
    if (head.charCodeAt(0) === 0xFEFF) head = head.slice(1);
    const trimmed = head.replace(/^\s+/, '').toLowerCase();
    if (trimmed.startsWith('<?xml') || trimmed.startsWith('<svg')) {
      if (trimmed.includes('<svg')) mime = 'image/svg+xml';
    }
  }

  if (mime === 'application/ogg') mime = oggMime(buffer);

  const isSvg = mime === 'image/svg+xml';
  const qName = ctx.query.name ? String(ctx.query.name).replace(/["\r\n\\]/g, '').trim() : '';
  const safeRaw = String(raw).replace(/["\r\n\\]/g, '');
  const filename = qName || (mime === 'application/x-bittorrent' ? 'download.torrent' : safeRaw);
  const disposition = isSvg ? 'attachment' : 'inline';
  ctx.type = mime;
  ctx.set('Content-Disposition', `${disposition}; filename="${filename}"`);
  ctx.set('Cache-Control', 'public, max-age=31536000, immutable');

  const range = ctx.headers.range;

  if (range) {
    const match = /^bytes=(\d*)-(\d*)$/.exec(range);
    if (!match) {
      ctx.status = 416;
      ctx.set('Content-Range', `bytes */${size}`);
      return;
    }

    let start = match[1] ? parseInt(match[1], 10) : 0;
    let end = match[2] ? parseInt(match[2], 10) : size - 1;

    if (Number.isNaN(start) || start < 0) start = 0;
    if (Number.isNaN(end) || end >= size) end = size - 1;

    if (start > end || start >= size) {
      ctx.status = 416;
      ctx.set('Content-Range', `bytes */${size}`);
      return;
    }

    const chunk = buffer.slice(start, end + 1);

    ctx.status = 206;
    ctx.set('Content-Range', `bytes ${start}-${end}/${size}`);
    ctx.set('Accept-Ranges', 'bytes');
    ctx.set('Content-Length', String(chunk.length));
    ctx.body = chunk;
  } else {
    ctx.status = 200;
    ctx.set('Accept-Ranges', 'bytes');
    ctx.set('Content-Length', String(size));
    ctx.body = buffer;
  }
};

module.exports = { handleBlobUpload, handleBlobUploads, serveBlob, oggMime, FileTooLargeError };

