'use strict';

import LibImageQuant from '@fe-daily/libimagequant-wasm';
import * as wasmModule from '@fe-daily/libimagequant-wasm/wasm/libimagequant_wasm.js';
import oxipngInit, { optimize as oxipngOptimize, OxipngOptions } from '../oxipng-wasm/pkg/oxipng_wasm.js';

// ============================================================
// PNG: libimagequant
// ============================================================
let quantizer;

async function getQuantizer() {
  if (!quantizer) {
    quantizer = new LibImageQuant({ wasmModule });
  }
  return quantizer;
}

// ============================================================
// JPEG: cjpegli (WebAssembly — direkte API)
// ============================================================
let cjpegliWasm = null;
let cjpegliFailed = false;
let HEAPU32 = null;

const extBase = self.location.href.replace(/[^/]*$/, '');

function updateMemoryViews() {
  if (!cjpegliWasm) return;
  HEAPU32 = new Uint32Array(cjpegliWasm.memory.buffer);
}

async function getCjpegli() {
  if (cjpegliWasm && !cjpegliFailed) return cjpegliWasm;
  cjpegliFailed = false;

  try {
    const wasmUrl = extBase + 'wasm/cjpegli.wasm';
    const response = await fetch(wasmUrl);
    if (!response.ok) {
      throw new Error(`WASM fetch fehlgeschlagen: ${response.status} ${response.url}`);
    }

    const bytes = await response.arrayBuffer();

    const importObject = {
      env: {
        emscripten_notify_memory_growth: () => updateMemoryViews(),
        fd_close: () => 52,
        fd_seek: () => 70,
        fd_write: (fd, iov, iovcnt, pnum) => {
          let num = 0;
          for (let i = 0; i < iovcnt; i++) {
            const ptr = HEAPU32[iov >> 2];
            const len = HEAPU32[(iov + 4) >> 2];
            iov += 8;
            num += len;
          }
          HEAPU32[pnum >> 2] = num;
          return 0;
        },
        proc_exit: (code) => {
          console.log('[cjpegli] proc_exit:', code);
        },
      },
      wasi_snapshot_preview1: {
        fd_close: () => 52,
        fd_seek: () => 70,
        fd_write: (fd, iov, iovcnt, pnum) => {
          let num = 0;
          for (let i = 0; i < iovcnt; i++) {
            const ptr = HEAPU32[iov >> 2];
            const len = HEAPU32[(iov + 4) >> 2];
            iov += 8;
            num += len;
          }
          HEAPU32[pnum >> 2] = num;
          return 0;
        },
        proc_exit: (code) => {
          console.log('[cjpegli] proc_exit:', code);
        },
      },
    };

    const result = await WebAssembly.instantiate(bytes, importObject);
    const instance = result.instance || result;
    const exports = instance.exports;

    cjpegliWasm = {
      encode: exports.encode,
      get_size: exports.get_size,
      malloc: exports.malloc,
      free: exports.free,
      initialize: exports._initialize,
      memory: exports.memory
    };

    updateMemoryViews();

    if (cjpegliWasm.initialize) {
      cjpegliWasm.initialize();
    }

    console.log('[cjpegli] WASM geladen, Memory:', cjpegliWasm.memory.buffer.byteLength, 'bytes');
    return cjpegliWasm;
  } catch (err) {
    cjpegliFailed = true;
    throw err;
  }
}

// ============================================================
// PNG-Optimierung: oxipng (direkt aus codec/pkg)
// ============================================================
let oxipngReady = false;
let oxipngFailed = false;

async function ensureOxipng() {
  if (oxipngReady || oxipngFailed) return;
  try {
    await oxipngInit(extBase + 'wasm/oxipng_wasm_bg.wasm');
    oxipngReady = true;
    console.log('oxipng WASM geladen (eigenes Build, oxipng 10.x)');
  } catch (err) {
    oxipngFailed = true;
    console.warn('oxipng Initialisierung fehlgeschlagen', err.message);
  }
}

async function optimisePngImpl(pngBytes, options) {
  if (!oxipngReady && !oxipngFailed) await ensureOxipng();
  if (oxipngFailed) return pngBytes;
  let opts = null;
  try {
    opts = new OxipngOptions();
    const level = Math.min(6, Math.max(1, options?.oxipngLevel ?? 2));
    const optimiseAlpha = options?.oxipngAlpha !== false;
    opts.level = level;
    opts.alpha = optimiseAlpha;
    opts.strip_all = false; // Chunks behalten – Metadaten-Handling macht der Worker selbst
    // optimize() kopiert die Eingabe selbst in den WASM-Heap (passArray8ToWasm0),
    // ein Uint8Array-View kann daher direkt übergeben werden – kein buffer.slice() nötig.
    const result = oxipngOptimize(pngBytes, opts);
    if (result.length < pngBytes.length) {
      console.log(`oxipng L${level}${optimiseAlpha ? '+alpha' : ''}: ${pngBytes.length} B → ${result.length} B (−${(100 - result.length / pngBytes.length * 100).toFixed(1)}%)`);
      return result;
    }
    console.log(`oxipng L${level}: keine Verbesserung (${pngBytes.length} B)`);
    return pngBytes;
  } catch (e) {
    console.warn('oxipng Optimierung fehlgeschlagen', e.message);
    return pngBytes;
  } finally {
    opts?.free(); // WASM-Objekt deterministisch freigeben (FinalizationRegistry ist nur Fallback)
  }
}

// ============================================================
// Metadaten-Erhalt: PNG-Chunks und JPEG-Marker
// ============================================================
const PNG_PRESERVE = new Set([
  'tEXt', 'iTXt', 'zTXt', 'eXIf',
  'iCCP', 'sRGB', 'gAMA', 'cHRM',
  'pHYs', 'tIME', 'sTER',
]);

function readUint32BE(bytes, offset) {
  return (bytes[offset] * 0x1000000) + ((bytes[offset + 1] << 16) | (bytes[offset + 2] << 8) | bytes[offset + 3]);
}

function extractPngMetadata(bytes) {
  if (!(bytes instanceof Uint8Array) || bytes.length < 8) return [];
  if (bytes[0] !== 0x89 || bytes[1] !== 0x50 || bytes[2] !== 0x47) return [];
  const chunks = [];
  let offset = 8;
  while (offset + 8 <= bytes.length) {
    const length = readUint32BE(bytes, offset);
    const type = String.fromCharCode(bytes[offset + 4], bytes[offset + 5], bytes[offset + 6], bytes[offset + 7]);
    if (type === 'IDAT') break;
    if (PNG_PRESERVE.has(type)) {
      const chunkEnd = offset + 12 + length;
      if (chunkEnd <= bytes.length) chunks.push(bytes.slice(offset, chunkEnd));
    }
    offset += 12 + length;
  }
  return chunks;
}

function injectPngMetadata(pngBytes, metadataChunks) {
  if (!metadataChunks || metadataChunks.length === 0) return pngBytes;
  if (pngBytes.length < 33) return pngBytes;
  const ihdrEnd = 8 + 25;
  if (ihdrEnd > pngBytes.length) return pngBytes;
  const existingTypes = new Set();
  let pos = ihdrEnd;
  while (pos + 8 <= pngBytes.length) {
    const type = String.fromCharCode(pngBytes[pos + 4], pngBytes[pos + 5], pngBytes[pos + 6], pngBytes[pos + 7]);
    if (type === 'IDAT' || type === 'IEND') break;
    existingTypes.add(type);
    const len = readUint32BE(pngBytes, pos);
    pos += 12 + len;
  }
  const parts = [pngBytes.slice(0, ihdrEnd)];
  for (const chunk of metadataChunks) {
    const type = String.fromCharCode(chunk[4], chunk[5], chunk[6], chunk[7]);
    if (!existingTypes.has(type)) parts.push(chunk);
  }
  parts.push(pngBytes.slice(ihdrEnd));
  const totalLength = parts.reduce((s, p) => s + p.length, 0);
  const result = new Uint8Array(totalLength);
  let writePos = 0;
  for (const part of parts) { result.set(part, writePos); writePos += part.length; }
  if (metadataChunks.length > 0) {
    const preserved = metadataChunks.filter(c => !existingTypes.has(String.fromCharCode(c[4], c[5], c[6], c[7])));
    if (preserved.length > 0) console.log(`[meta] PNG: ${preserved.length} Chunk(s) injiziert (${preserved.map(c => String.fromCharCode(c[4], c[5], c[6], c[7])).join(', ')})`);
  }
  return result;
}

function extractJpegMetadata(bytes) {
  if (!(bytes instanceof Uint8Array) || bytes.length < 4) return [];
  if (bytes[0] !== 0xFF || bytes[1] !== 0xD8) return [];
  const markers = [];
  let offset = 2;
  while (offset + 3 < bytes.length) {
    if (bytes[offset] !== 0xFF) { offset++; continue; }
    let marker = bytes[offset + 1];
    while (marker === 0xFF && offset + 2 < bytes.length) { offset++; marker = bytes[offset + 1]; }
    if (marker === 0xDA) break;
    if (marker === 0xD8 || marker === 0xD9 || (marker >= 0xD0 && marker <= 0xD7)) { offset += 2; continue; }
    const length = (bytes[offset + 2] << 8) | bytes[offset + 3];
    const markerEnd = offset + 2 + length;
    if (markerEnd > bytes.length) break;
    if ((marker >= 0xE0 && marker <= 0xEF) || marker === 0xFE) markers.push(bytes.slice(offset, markerEnd));
    offset = markerEnd;
  }
  return markers;
}

function injectJpegMetadata(jpegBytes, metadataMarkers) {
  if (!metadataMarkers || metadataMarkers.length === 0) return jpegBytes;
  if (jpegBytes.length < 2) return jpegBytes;
  if (jpegBytes[0] !== 0xFF || jpegBytes[1] !== 0xD8) return jpegBytes;
  const parts = [jpegBytes.slice(0, 2)];
  for (const marker of metadataMarkers) parts.push(marker);
  parts.push(jpegBytes.slice(2));
  const totalLength = parts.reduce((s, p) => s + p.length, 0);
  const result = new Uint8Array(totalLength);
  let writePos = 0;
  for (const part of parts) { result.set(part, writePos); writePos += part.length; }
  const types = metadataMarkers.map(m => 'APP' + (m[1] - 0xE0));
  console.log(`[meta] JPEG: ${metadataMarkers.length} Marker injiziert (${types.join(', ')})`);
  return result;
}

// ============================================================
// JPEG-Header-Analyse: originales Chroma Subsampling erkennen
// ============================================================
const CHROMA_RANK = { '420': 0, '422': 1, '440': 2, '444': 3 };

function detectJpegChromaSubsampling(bytes) {
  if (!(bytes instanceof Uint8Array) || bytes.length < 4) return null;
  if (bytes[0] !== 0xFF || bytes[1] !== 0xD8) return null;
  let i = 2;
  while (i + 1 < bytes.length) {
    if (bytes[i] !== 0xFF) { i++; continue; }
    let marker = bytes[i + 1];
    while (marker === 0xFF && i + 2 < bytes.length) { i++; marker = bytes[i + 1]; }
    if (marker >= 0xC0 && marker <= 0xCF && marker !== 0xC4 && marker !== 0xC8 && marker !== 0xCC) {
      if (i + 9 >= bytes.length) return null;
      const numComponents = bytes[i + 9];
      if (numComponents !== 3) return null;
      const compStart = i + 10;
      const sf = [];
      for (let c = 0; c < 3; c++) {
        const offset = compStart + c * 3;
        if (offset + 1 >= bytes.length) return null;
        const s = bytes[offset + 1];
        sf.push({ h: (s >> 4) & 0x0F, v: s & 0x0F });
      }
      const yH = sf[0].h, yV = sf[0].v;
      const cbH = sf[1].h, cbV = sf[1].v;
      if (yH === 2 && yV === 2 && cbH === 1 && cbV === 1) return '420';
      if (yH === 2 && yV === 1 && cbH === 1 && cbV === 1) return '422';
      if (yH === 1 && yV === 2 && cbH === 1 && cbV === 1) return '440';
      if (yH === 1 && yV === 1 && cbH === 1 && cbV === 1) return '444';
      return '444';
    }
    if (marker === 0xD8 || marker === 0xD9 || (marker >= 0xD0 && marker <= 0xD7)) { i += 2; }
    else if (marker === 0xDA) break;
    else { if (i + 3 >= bytes.length) return null; const length = (bytes[i + 2] << 8) | bytes[i + 3]; i += 2 + length; }
  }
  return null;
}

function limitChromaSubsampling(originalChroma, userChroma) {
  const valid = ['444', '440', '422', '420'];
  const user = valid.includes(userChroma) ? userChroma : '420';
  if (!originalChroma || !valid.includes(originalChroma)) return user;
  return CHROMA_RANK[originalChroma] < CHROMA_RANK[user] ? originalChroma : user;
}

// ============================================================
// Resize: Canvas-API für Bilinear, Bicubic, Nearest-Neighbor
// Lanczos, Mitchell und Auto bleiben eigene Implementierungen.
// ============================================================

const SMOOTHING_MAP = {
  'bilinear': 'low',
  'bicubic': 'medium',
  'lanczos': 'high',
  'mitchell': 'high',
  'auto': 'high',
};

function applySmoothingQuality(ctx, algorithm) {
  if (algorithm === 'nearest') {
    ctx.imageSmoothingEnabled = false;
  } else {
    ctx.imageSmoothingEnabled = true;
    ctx.imageSmoothingQuality = SMOOTHING_MAP[algorithm] || 'high';
  }
}

function resizeViaCanvas(imageData, dstW, dstH, algorithm) {
  const srcCanvas = new OffscreenCanvas(imageData.width, imageData.height);
  const srcCtx = srcCanvas.getContext('2d', { willReadFrequently: true });
  srcCtx.putImageData(imageData, 0, 0);

  const dstCanvas = new OffscreenCanvas(dstW, dstH);
  const dstCtx = dstCanvas.getContext('2d', { willReadFrequently: true });
  applySmoothingQuality(dstCtx, algorithm);
  dstCtx.drawImage(srcCanvas, 0, 0, dstW, dstH);
  return dstCtx.getImageData(0, 0, dstW, dstH);
}

// ============================================================
// Lanczos-Resize (eigene Implementierung, a=3)
// ============================================================

const LANCZOS_A = 3;

function lanczosWeight(distance, scale) {
  const x = scale > 1 ? distance / scale : distance;
  if (x === 0) return 1;
  if (Math.abs(x) >= LANCZOS_A) return 0;
  const px = Math.PI * x;
  return (Math.sin(px) / px) * (Math.sin(px / LANCZOS_A) / (px / LANCZOS_A));
}

// ============================================================
// Mitchell-Netravali-Resize (B=1/3, C=1/3)
// ============================================================

const MITCHELL_B = 1 / 3;
const MITCHELL_C = 1 / 3;
const MITCHELL_SUPPORT = 2.0;

function mitchellWeight(distance, scale) {
  const x = scale > 1 ? distance / scale : distance;
  const ax = Math.abs(x);
  if (ax >= MITCHELL_SUPPORT) return 0;

  const B = MITCHELL_B;
  const C = MITCHELL_C;
  const x2 = ax * ax;
  const x3 = x2 * ax;

  if (ax < 1) {
    return (1 / 6) * (
      (12 - 9 * B - 6 * C) * x3 +
      (-18 + 12 * B + 6 * C) * x2 +
      (6 - 2 * B)
    );
  } else {
    return (1 / 6) * (
      (-B - 6 * C) * x3 +
      (6 * B + 30 * C) * x2 +
      (-12 * B - 48 * C) * ax +
      (8 * B + 24 * C)
    );
  }
}

// ============================================================
// Generische Weight-Precomputation & separable Resize
// ============================================================

function precomputeWeights(srcSize, dstSize, weightFn, supportRadius) {
  const scale = srcSize / dstSize;
  const support = scale > 1 ? supportRadius * scale : supportRadius;
  const weights = new Array(dstSize);

  for (let dst = 0; dst < dstSize; dst++) {
    const center = (dst + 0.5) * scale - 0.5;
    const left = Math.max(0, Math.floor(center - support));
    const right = Math.min(srcSize - 1, Math.ceil(center + support));
    const w = [];
    let sum = 0;

    for (let src = left; src <= right; src++) {
      const weight = weightFn(center - src, scale);
      if (weight !== 0) {
        w.push(src, weight);
        sum += weight;
      }
    }

    if (sum !== 0) {
      for (let i = 1; i < w.length; i += 2) w[i] /= sum;
    }

    weights[dst] = w;
  }

  return weights;
}

function resizeWithFilter(imageData, dstW, dstH, weightFn, supportRadius) {
  const { width: srcW, height: srcH, data: src } = imageData;
  if (srcW === dstW && srcH === dstH) return imageData;

  const xWeights = precomputeWeights(srcW, dstW, weightFn, supportRadius);
  const yWeights = precomputeWeights(srcH, dstH, weightFn, supportRadius);

  const temp = new Float32Array(dstW * srcH * 4);
  for (let y = 0; y < srcH; y++) {
    const sRow = y * srcW * 4;
    const tRow = y * dstW * 4;
    for (let x = 0; x < dstW; x++) {
      const w = xWeights[x];
      let r = 0, g = 0, b = 0, a = 0;
      for (let i = 0; i < w.length; i += 2) {
        const idx = sRow + (w[i] | 0) * 4;
        const wt = w[i + 1];
        r += src[idx] * wt;
        g += src[idx + 1] * wt;
        b += src[idx + 2] * wt;
        a += src[idx + 3] * wt;
      }
      const o = tRow + x * 4;
      temp[o] = r; temp[o + 1] = g; temp[o + 2] = b; temp[o + 3] = a;
    }
  }

  const dst = new Uint8ClampedArray(dstW * dstH * 4);
  for (let y = 0; y < dstH; y++) {
    const w = yWeights[y];
    for (let x = 0; x < dstW; x++) {
      let r = 0, g = 0, b = 0, a = 0;
      for (let i = 0; i < w.length; i += 2) {
        const idx = (w[i] | 0) * dstW * 4 + x * 4;
        const wt = w[i + 1];
        r += temp[idx] * wt;
        g += temp[idx + 1] * wt;
        b += temp[idx + 2] * wt;
        a += temp[idx + 3] * wt;
      }
      const o = y * dstW * 4 + x * 4;
      dst[o] = r; dst[o + 1] = g; dst[o + 2] = b; dst[o + 3] = a;
    }
  }

  return new ImageData(dst, dstW, dstH);
}

function resizeLanczos(imageData, dstW, dstH) {
  return resizeWithFilter(imageData, dstW, dstH, lanczosWeight, LANCZOS_A);
}

function resizeMitchell(imageData, dstW, dstH) {
  return resizeWithFilter(imageData, dstW, dstH, mitchellWeight, MITCHELL_SUPPORT);
}

// ============================================================
// Bilddekodierung & Skalierung
// ============================================================
async function decodeImage(bytes) {
  const blob = new Blob([bytes]);
  const bitmap = await createImageBitmap(blob);
  const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
  const context = canvas.getContext('2d', { willReadFrequently: true });
  context.drawImage(bitmap, 0, 0);
  const imageData = context.getImageData(0, 0, bitmap.width, bitmap.height);
  bitmap.close();
  return imageData;
}

// ============================================================
// FIX: decodeAndScale verwendet für Lanczos, Mitchell und Auto nun die
// eigenen Implementierungen statt Canvas imageSmoothingQuality.
// Zuvor wurden Lanczos, Mitchell und Auto alle auf Canvas 'high'
// gemappt und lieferten identische Ergebnisse.
//
// Bei Lanczos/Mitchell: Dekodierung in voller Auflösung, dann
// Resize mit separablem Filter (resizeWithFilter).
// Bei Auto: Dekodierung in voller Auflösung, pro-Format Resize
// (PNG → Mitchell, JPEG → Lanczos) erfolgt in compressImage.
// Bei Canvas-Algorithmen (bilinear, bicubic, nearest):
// createImageBitmap + drawImage mit imageSmoothingQuality (wie bisher).
// ============================================================
async function decodeAndScale(bytes, targetWidth, targetHeight, algorithm) {
  if (!targetWidth || !targetHeight) return decodeImage(bytes);

  // 'auto': In voller Auflösung dekodieren — pro-Format Resize erfolgt in compressImage
  if (algorithm === 'auto') return decodeImage(bytes);

  // Eigene Implementierungen für Lanczos und Mitchell
  if (algorithm === 'lanczos' || algorithm === 'mitchell') {
    const fullData = await decodeImage(bytes);
    if (algorithm === 'lanczos') {
      console.log(`[Worker] decodeAndScale (lanczos): ${fullData.width}x${fullData.height} → ${targetWidth}x${targetHeight}`);
      return resizeLanczos(fullData, targetWidth, targetHeight);
    }
    console.log(`[Worker] decodeAndScale (mitchell): ${fullData.width}x${fullData.height} → ${targetWidth}x${targetHeight}`);
    return resizeMitchell(fullData, targetWidth, targetHeight);
  }

  // Canvas-basierte Algorithmen (bilinear, bicubic, nearest)
  const blob = new Blob([bytes]);
  const bitmap = await createImageBitmap(blob);
  const canvas = new OffscreenCanvas(targetWidth, targetHeight);
  const context = canvas.getContext('2d', { willReadFrequently: true });
  applySmoothingQuality(context, algorithm || 'lanczos');
  context.drawImage(bitmap, 0, 0, targetWidth, targetHeight);
  const imageData = context.getImageData(0, 0, targetWidth, targetHeight);
  bitmap.close();
  return imageData;
}

function hasTransparency(imageData) {
  const data = imageData.data;
  for (let i = 3; i < data.length; i += 4) { if (data[i] < 255) return true; }
  return false;
}

// ============================================================
// Cross-Check-Prüfungen für JPEG-Originale
// ============================================================
const CROSS_CHECK_MIN_SIZE = 2 * 1024 * 1024;
const CROSS_CHECK_MIN_REPETITION = 0.10;

function estimateColorRepetition(imageData) {
  const data = imageData.data;
  const totalPixels = (data.length / 4) | 0;
  if (totalPixels === 0) return 0;

  const sampleSize = Math.min(totalPixels, 10000);
  const step = Math.max(1, Math.floor(totalPixels / sampleSize));

  const counts = new Map();
  let sampled = 0;
  let maxCount = 0;

  for (let p = 0; p < totalPixels; p += step) {
    const i = p * 4;
    const r = data[i] >> 3;
    const g = data[i + 1] >> 3;
    const b = data[i + 2] >> 3;
    const key = (r << 10) | (g << 5) | b;
    const c = (counts.get(key) || 0) + 1;
    counts.set(key, c);
    if (c > maxCount) maxCount = c;
    sampled++;
  }

  return sampled > 0 ? maxCount / sampled : 0;
}

// ============================================================
// Encoder
// ============================================================
async function encodePngImpl(imageData, options) {
  const q = await getQuantizer();
  const result = await q.quantizeImageData(imageData, {
    maxColors: options?.maxColors ?? 256,
    dithering: options?.dithering ?? 0.5,
    quality: { min: options?.minQuality ?? 0, target: options?.maxQuality ?? 100 },
    speed: options?.speed ?? 1,
  });
  if (result?.pngBytes instanceof Uint8Array) return result.pngBytes;
  if (result instanceof Uint8Array) return result;
  if (result?.png instanceof Uint8Array) return result.png;
  if (result?.bytes instanceof Uint8Array) return result.bytes;
  if (result?.data instanceof Uint8Array) return result.data;
  throw new Error('libimagequant: kein PNG-Ergebnis: ' + Object.keys(result || {}).join(', '));
}

function imageDataToRgba(imageData) {
  const { width, height, data } = imageData;
  let hasAlpha = false;
  for (let i = 3; i < data.length; i += 4) { if (data[i] < 255) { hasAlpha = true; break; } }
  if (!hasAlpha) return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
  const rgba = new Uint8Array(width * height * 4);
  for (let i = 0; i < data.length; i += 4) {
    const a = data[i + 3] / 255;
    rgba[i] = Math.round(data[i] * a);
    rgba[i + 1] = Math.round(data[i + 1] * a);
    rgba[i + 2] = Math.round(data[i + 2] * a);
    rgba[i + 3] = 255;
  }
  return rgba;
}

// Stellt sicher, dass mindestens neededBytes im WASM-Heap frei sind.
// (Früher: neededBytes * 3 — mit dem Aufruf ensureMemory(wasm, rgbaSize * 2)
// also das 6-fache der RGBA-Größe. Jetzt: exakt das Übergebene = 2× RGBA,
// was Eingabepuffer + JPEG-Ausgabe abdeckt.)
function ensureMemory(wasm, neededBytes) {
  const currentBytes = wasm.memory.buffer.byteLength;
  if (currentBytes < neededBytes) {
    const neededPages = Math.ceil((neededBytes - currentBytes) / 65536);
    try { wasm.memory.grow(neededPages); updateMemoryViews(); } catch (e) { console.warn('[cjpegli] Memory grow fehlgeschlagen:', e.message); }
  }
}

function chromaToInt(chroma) {
  const valid = { '444': 444, '440': 440, '422': 422, '420': 420 };
  return valid[chroma] || 420;
}

async function encodeJpegImpl(imageData, options, originalBytes) {
  const quality = options?.jpegQuality ?? 85;
  const userChroma = options?.chromaSubsampling ?? '420';
  const originalChroma = originalBytes ? detectJpegChromaSubsampling(originalBytes) : null;
  const effectiveChroma = limitChromaSubsampling(originalChroma, userChroma);
  if (originalChroma) console.log(`[cjpegli] Chroma: Original=${originalChroma}, User=${userChroma} → ${effectiveChroma}`);
  const rgba = imageDataToRgba(imageData);
  const { width, height } = imageData;
  const rgbaSize = rgba.length;
  const wasm = await getCjpegli();
  ensureMemory(wasm, rgbaSize * 2);
  const rgbaPtr = wasm.malloc(rgbaSize);
  if (!rgbaPtr) throw new Error('cjpegli: malloc fehlgeschlagen — Speicher voll?');
  try {
    updateMemoryViews();
    new Uint8Array(wasm.memory.buffer, rgbaPtr, rgbaSize).set(rgba);
    const chromaSubsampling = chromaToInt(effectiveChroma);
    const jpegPtr = wasm.encode(rgbaPtr, width, height, chromaSubsampling, quality, 2, 0, 1, 0);
    const jpegSize = wasm.get_size();
    if (!jpegPtr || jpegPtr <= 0) throw new Error(`cjpegli: encode fehlgeschlagen — jpegPtr=${jpegPtr}`);
    if (!jpegSize || jpegSize <= 0) throw new Error(`cjpegli: encode fehlgeschlagen — jpegSize=${jpegSize}`);
    updateMemoryViews();
    const jpegView = new Uint8Array(wasm.memory.buffer, jpegPtr, jpegSize);
    const jpeg = new Uint8Array(jpegView);
    if (jpeg[0] !== 0xFF || jpeg[1] !== 0xD8) throw new Error('cjpegli: ungültiger JPEG-Header');
    console.log(`[cjpegli] JPEG: ${jpegSize} bytes, ${width}x${height}, q=${quality}, chroma=${effectiveChroma}`);
    return jpeg;
  } finally { wasm.free(rgbaPtr); }
}

// ============================================================
// Sonderpfad "No-Resize-Check" (sehr kleine PNGs + shrink-to-inline)
// Die Mitchell-Interpolation erzeugt bei winzigen PNGs oft so viele
// neue Farben in der Palette, dass das verkleinerte PNG größer ausfällt
// als das unskalierte. In dem Fall gewinnt die Original-Auflösung.
// ============================================================
const NORESIZE_CHECK_MAX_PIXELS = 100000;

// PNG-Abmessungen direkt aus dem IHDR-Header lesen (ohne Decode).
function readPngDimensions(bytes) {
  if (!(bytes instanceof Uint8Array) || bytes.length < 24) return null;
  if (bytes[0] !== 0x89 || bytes[1] !== 0x50 || bytes[2] !== 0x4E || bytes[3] !== 0x47) return null;
  return { w: readUint32BE(bytes, 16), h: readUint32BE(bytes, 20) };
}

// ============================================================
// Kompression
// ============================================================
async function compressImage(bytes, options, format, tryPng, tryJpeg, targetWidth, targetHeight, resizePercent, forcePng) {
  const preserveMeta = options?.preserveMeta !== false;
  const resizeAlgorithm = options?.resizeAlgorithm ?? 'lanczos';

  const pngMetadata = (preserveMeta && format === 'png') ? extractPngMetadata(bytes) : [];
  const jpegMetadata = (preserveMeta && format === 'jpeg') ? extractJpegMetadata(bytes) : [];

  // --- Pre-Checks auf Rohdaten (vor Decode) ---
  // Bei forcePng: Pre-Checks überspringen (EXIF, Größe, Farb-Repetition)
  if (format === 'jpeg' && tryPng && !forcePng) {
    if (bytes.length > CROSS_CHECK_MIN_SIZE) {
      tryPng = false;
      console.log(`[Worker] JPEG ${(bytes.length / 1048576).toFixed(1)} MB > 2 MB → PNG cross-check übersprungen`);
    }
  }

  let imageData = await decodeAndScale(bytes, targetWidth, targetHeight, resizeAlgorithm);
  let pixelCount = imageData.width * imageData.height;

  // --- No-Resize-Check: Bedingungen ---
  // (1) PNG-Original, Zielformat PNG (tryPng)
  // (2) shrink-to-inline aktiv → targetWidth/targetHeight gesetzt
  //     (werden nur für Inline-/Zitat-Bilder übergeben; Anhänge: null)
  // (3) Original (vor Resize) kleiner als NORESIZE_CHECK_MAX_PIXELS
  // (4) es würde tatsächlich verkleinert (Ziel < Original)
  let noResizeCheck = false;
  if (format === 'png' && tryPng && targetWidth && targetHeight) {
    const dims = readPngDimensions(bytes);
    if (dims && dims.w * dims.h < NORESIZE_CHECK_MAX_PIXELS &&
        (targetWidth < dims.w || targetHeight < dims.h)) {
      noResizeCheck = true;
      console.log(`[Worker] No-Resize-Check aktiv: ${dims.w}x${dims.h} (${dims.w * dims.h} px), Ziel ${targetWidth}x${targetHeight}`);
    }
  }


  // --- Farb-Repetition-Check (nach Decode) ---
  if (format === 'jpeg' && tryPng && !forcePng) {
    const repetition = estimateColorRepetition(imageData);
    if (repetition < CROSS_CHECK_MIN_REPETITION) {
      tryPng = false;
      console.log(`[Worker] Farb-Repetition ${(repetition * 100).toFixed(1)}% < 10% → PNG cross-check übersprungen`);
    } else {
      console.log(`[Worker] Farb-Repetition ${(repetition * 100).toFixed(1)}% ≥ 10% → PNG cross-check wird durchgeführt`);
    }
  }

  // --- Resize ---
  // Bei 'auto' wird das Original behalten und pro Ausgabeformat separat resized:
  // PNG → Mitchell, JPEG → Lanczos. Bei Cross-Konvertierung passiert das Resize
  // also tatsächlich zweimal, jeweils vom unresizeden Original aus.
  let originalImageData = null;
  let resizeDims = null;

  if (resizeAlgorithm === 'auto' && targetWidth && targetHeight) {
    // Auto mit Ziel-Auflösung (Inline/Zitat): Original behalten, pro-Format Resize
    originalImageData = imageData;
    resizeDims = { newW: targetWidth, newH: targetHeight };
    console.log(`[Worker] Resize (auto): ${imageData.width}x${imageData.height} → ${targetWidth}x${targetHeight} — pro Format`);
  } else if (resizePercent && resizePercent < 100) {
    const origW = imageData.width;
    const origH = imageData.height;
    const newW = Math.max(1, Math.round(origW * resizePercent / 100));
    const newH = Math.max(1, Math.round(origH * resizePercent / 100));
    resizeDims = { newW, newH };

    if (resizeAlgorithm === 'auto') {
      originalImageData = imageData;
      console.log(`[Worker] Resize (auto): ${origW}x${origH} → ${newW}x${newH} (${resizePercent}%) — pro Format`);
    } else if (resizeAlgorithm === 'lanczos') {
      imageData = resizeLanczos(imageData, newW, newH);
      console.log(`[Worker] Resize (lanczos): ${origW}x${origH} → ${newW}x${newH} (${resizePercent}%)`);
    } else if (resizeAlgorithm === 'mitchell') {
      imageData = resizeMitchell(imageData, newW, newH);
      console.log(`[Worker] Resize (mitchell): ${origW}x${origH} → ${newW}x${newH} (${resizePercent}%)`);
    } else {
      imageData = resizeViaCanvas(imageData, newW, newH, resizeAlgorithm);
      console.log(`[Worker] Resize (${resizeAlgorithm}): ${origW}x${origH} → ${newW}x${newH} (${resizePercent}%)`);
    }
  }

  // Liefert das für das Zielfformat passende resized ImageData.
  // Bei 'auto': PNG → Mitchell, JPEG → Lanczos (vom Original).
  // Sonst: bereits einmal resizedes imageData.
  function getResizeData(targetFormat) {
    if (!originalImageData) return imageData;
    const algo = targetFormat === 'png' ? 'mitchell' : 'lanczos';
    const { newW, newH } = resizeDims;
    if (algo === 'mitchell') {
      console.log(`[Worker] Resize (auto/mitchell → ${targetFormat}): ${originalImageData.width}x${originalImageData.height} → ${newW}x${newH}`);
      return resizeMitchell(originalImageData, newW, newH);
    }
    console.log(`[Worker] Resize (auto/lanczos → ${targetFormat}): ${originalImageData.width}x${originalImageData.height} → ${newW}x${newH}`);
    return resizeLanczos(originalImageData, newW, newH);
  }
  if (originalImageData && resizeDims) {
    pixelCount = resizeDims.newW * resizeDims.newH;
  } else if (resizePercent && resizePercent < 100) {
    pixelCount = imageData.width * imageData.height;
  }
  if (forcePng) {
    console.log(`[Worker] Force PNG aktiv: ${format.toUpperCase()} → PNG`);
  }

  const results = [];

  if (format === 'png') {
     if (tryPng) {
       try {
         const data = getResizeData('png');
         let pngBytes = await encodePng(data, options);
         pngBytes = await optimisePng(pngBytes, options);
         if (pngMetadata.length > 0) pngBytes = injectPngMetadata(pngBytes, pngMetadata);
         // --- No-Resize-Check: zusätzlich ohne Resize encodieren ---
         // Gewinnt die unskalierte Variante, werden die Original-
         // Abmessungen beibehalten und auf das Shrinking verzichtet.
         if (noResizeCheck) {
           try {
             const fullData = originalImageData || await decodeImage(bytes);
             let nrBytes = await encodePng(fullData, options);
             nrBytes = await optimisePng(nrBytes, options);
             if (pngMetadata.length > 0) nrBytes = injectPngMetadata(nrBytes, pngMetadata);
             console.log(`[Worker] No-Resize-Check: resized ${pngBytes.length} B vs. unresized ${nrBytes.length} B`);
             if (nrBytes.length < pngBytes.length) {
               console.log(`[Worker] No-Resize-Check: Original-Auflösung ${fullData.width}x${fullData.height} behalten — Resize übersprungen`);
               pngBytes = nrBytes;
             }
           } catch (e) { console.warn('[Worker] No-Resize-Check fehlgeschlagen:', e.message); }
         }
         results.push({ format: 'png', bytes: pngBytes });
       } catch (e) { console.error('[Worker] PNG fehlgeschlagen:', e.message); }
     }
    if (tryJpeg && !hasTransparency(imageData)) {
      try {
        const data = getResizeData('jpeg');
        let jpegBytes = await encodeJpeg(data, options, null);
        if (jpegMetadata.length > 0) jpegBytes = injectJpegMetadata(jpegBytes, jpegMetadata);
        results.push({ format: 'jpeg', bytes: jpegBytes });
      } catch (e) { console.error('[Worker] JPEG fehlgeschlagen:', e.message); }
    }
  } else {
    let jpegResult = null;

    if (tryJpeg) {
      try {
        const data = getResizeData('jpeg');
        let jpegBytes = await encodeJpeg(data, options, bytes);
        if (jpegMetadata.length > 0) jpegBytes = injectJpegMetadata(jpegBytes, jpegMetadata);
        jpegResult = { format: 'jpeg', bytes: jpegBytes };
        results.push(jpegResult);
      } catch (e) { console.error('[Worker] JPEG fehlgeschlagen:', e.message); }
    }

     if (tryPng) {
       try {
         const data = getResizeData('png');
         let pngBytes = await encodePng(data, options);
         // FIX (Formatwahl): oxipng immer ausführen, BEVOR der Größen-
         // vergleich mit dem JPEG-Kandidaten entscheidet. Der alte Shortcut
         // (oxipng nur, wenn pngquant bereits < JPEG) ließ das PNG genau in
         // den knappen Fällen unoptimiert, in denen oxipng es noch unter das
         // JPEG hätte drücken können — JPEG gewann den Vergleich zu Unrecht
         // (v. a. bei kleinen Bildern, wo wenige Bytes das Format kippen).
         pngBytes = await optimisePng(pngBytes, options);
         if (jpegResult) {
           console.log(`[Worker] Formatvergleich: PNG (pngquant+oxipng) ${pngBytes.length} B vs. JPEG ${jpegResult.bytes.length} B → ${pngBytes.length < jpegResult.bytes.length ? 'PNG' : 'JPEG'} gewinnt`);
         }
         if (pngMetadata.length > 0) pngBytes = injectPngMetadata(pngBytes, pngMetadata);
         results.push({ format: 'png', bytes: pngBytes });
       } catch (e) { console.error('[Worker] PNG fehlgeschlagen:', e.message); }
     }
  }

  if (results.length === 0) throw new Error('Keine Encodierung erfolgreich');
  results.sort((a, b) => a.bytes.length - b.bytes.length);
  return results[0];
}

// ============================================================
// Message-Handler
// ============================================================
let queue = Promise.resolve();

self.onmessage = (event) => {
  // ── oxipng-only / meta-only Fast-Path (Stufen-Cache im Background-Skript) ──
  if (event.data && event.data.oxipngOnly) {
    const { id, originalBytes, pngBytes, options, metaOnly } = event.data;
    queue = queue.then(async () => {
      try {
        const r = await optimisePngStage(new Uint8Array(originalBytes), new Uint8Array(pngBytes), options, !!metaOnly);
        const buf = r.final.buffer.slice(r.final.byteOffset, r.final.byteOffset + r.final.byteLength);
        const pre = r.preMeta.buffer.slice(r.preMeta.byteOffset, r.preMeta.byteOffset + r.preMeta.byteLength);
        self.postMessage({ id, bytes: buf, format: 'png', error: null, stages: { pngFinal: pre } }, [buf, pre]);
      } catch (error) {
        self.postMessage({ id, bytes: null, format: null, error: error?.stack || error?.message || String(error) });
      }
    });
    return;
  }
  const { id, bytes, options, format, tryPng, tryJpeg, targetWidth, targetHeight, resizePercent, forcePng } = event.data;
  queue = queue.then(async () => {
    try {
      const inputBytes = new Uint8Array(bytes);
      // Stufen-Aufzeichnung nur bei Vollbild-Läufen (kein Resize, keine Zielgröße):
      // nur dann ist der No-Resize-Check inaktiv und die Stufen eindeutig.
      stageCollector = ((resizePercent || 100) === 100 && !targetWidth && !targetHeight) ? {} : null;
      const result = await compressImage(inputBytes, options, format, tryPng || false, tryJpeg || false, targetWidth || null, targetHeight || null, resizePercent || 100, forcePng || false);
      const stages = stageCollector;
      stageCollector = null;
      const buffer = result.bytes.buffer.slice(result.bytes.byteOffset, result.bytes.byteOffset + result.bytes.byteLength);
      const msg = { id, bytes: buffer, format: result.format, error: null };
      const transfers = [buffer];
      if (stages && (stages.pqBytes || stages.pngFinal || stages.jpeg)) {
        msg.stages = {};
        for (const [k, v] of Object.entries(stages)) {
          if (!v) continue;
          const b = v.buffer.slice(v.byteOffset, v.byteOffset + v.byteLength);
          msg.stages[k] = b;
          transfers.push(b);
        }
      }
      self.postMessage(msg, transfers);
    } catch (error) {
      stageCollector = null;
      self.postMessage({ id, bytes: null, format: null, error: error?.stack || error?.message || String(error) });
    }
  });
};

// ════════════════════════════════════════════════════════════════════════════
// Stage-Cache-Unterstützung (Time-Memory-Tradeoff, per Patch hinzugefügt)
// ════════════════════════════════════════════════════════════════════════════

// Collector für Zwischenstufen der laufenden Kompression (null = Aufzeichnung aus).
// Wird im Message-Handler gesetzt/gelöscht. `let` am Modulende ist sicher:
// Der Handler feuert frühestens nach vollständiger Modul-Auswertung.
let stageCollector = null;

async function encodePng(imageData, options) {
  const out = await encodePngImpl(imageData, options);
  if (stageCollector) stageCollector.pqBytes = out;
  return out;
}

async function optimisePng(pngBytes, options) {
  const out = await optimisePngImpl(pngBytes, options);
  if (stageCollector) {
    stageCollector.pqBytes = stageCollector.pqBytes || pngBytes;
    stageCollector.pngFinal = out; // nach oxipng, VOR Metadaten-Injektion
  }
  return out;
}

async function encodeJpeg(imageData, options, originalBytes) {
  const out = await encodeJpegImpl(imageData, options, originalBytes);
  if (stageCollector) stageCollector.jpeg = out;
  return out;
}

// oxipng-only Fast-Path: pngquant-Ausgabe + Original (für Metadaten) → finales PNG.
// metaOnly: oxipng überspringen (Ergebnis kam aus dem Cache), nur injizieren.
async function optimisePngStage(originalBytes, pqBytes, options, metaOnly) {
  const preserveMeta = options?.preserveMeta !== false;
  const preMeta = metaOnly ? pqBytes : await optimisePngImpl(pqBytes, options);
  const metadata = preserveMeta ? extractPngMetadata(originalBytes) : [];
  const final = (metadata.length > 0) ? injectPngMetadata(preMeta, metadata) : preMeta;
  return { preMeta, final };
}
