/**
 * Stellt den DJ-NIZZ-Render frei.
 *
 * Der Render ist eine Vektor-Illustration: das Motiv ist durchgehend von dunkler
 * Linework umschlossen, der Hintergrund ist ein sehr weicher Verlauf. Ein reiner
 * Farb-Flood-Fill laeuft durch die hellen Hemd- und Hosenpartien durch (die sind
 * farblich fast identisch mit dem Hintergrund). Deshalb wird der Fill hier an einer
 * Sobel-Kantenkarte gestoppt: starke Kanten = Wand, alles was vom Bildrand aus ohne
 * Wandkontakt erreichbar ist = Hintergrund.
 *
 * Aufruf: node scripts/cutout.mjs <input> <output.png> [edgeThreshold]
 */
import sharp from 'sharp';

const SRC = process.argv[2];
const OUT = process.argv[3];
const EDGE_T = Number(process.argv[4] ?? 20);

const { data, info } = await sharp(SRC)
  .removeAlpha()
  .raw()
  .toBuffer({ resolveWithObject: true });
const { width: W, height: H, channels: C } = info;
console.log(`source ${W}x${H} channels=${C} edgeThreshold=${EDGE_T}`);

// --- Luminanz -------------------------------------------------------------
const grey = new Float32Array(W * H);
for (let i = 0; i < W * H; i++) {
  const p = i * C;
  grey[i] = 0.299 * data[p] + 0.587 * data[p + 1] + 0.114 * data[p + 2];
}

// --- Sobel-Kantenkarte ----------------------------------------------------
const wall = new Uint8Array(W * H);
let wallCount = 0;
for (let y = 1; y < H - 1; y++) {
  for (let x = 1; x < W - 1; x++) {
    const i = y * W + x;
    const tl = grey[i - W - 1], t = grey[i - W], tr = grey[i - W + 1];
    const l = grey[i - 1], r = grey[i + 1];
    const bl = grey[i + W - 1], b = grey[i + W], br = grey[i + W + 1];
    const gx = tl + 2 * l + bl - tr - 2 * r - br;
    const gy = tl + 2 * t + tr - bl - 2 * b - br;
    if (Math.abs(gx) + Math.abs(gy) > EDGE_T) {
      wall[i] = 1;
      wallCount++;
    }
  }
}
console.log(`edge pixels: ${wallCount} (${((wallCount / (W * H)) * 100).toFixed(1)}%)`);

// --- Flood-Fill vom Bildrand, an Kanten blockiert --------------------------
const bg = new Uint8Array(W * H);
const queue = new Int32Array(W * H);
let qh = 0;
let qt = 0;
const push = (i) => {
  if (!bg[i] && !wall[i]) {
    bg[i] = 1;
    queue[qt++] = i;
  }
};

for (let x = 0; x < W; x++) {
  push(x);
  push((H - 1) * W + x);
}
for (let y = 0; y < H; y++) {
  push(y * W);
  push(y * W + W - 1);
}

while (qh < qt) {
  const i = queue[qh++];
  const x = i % W;
  const y = (i / W) | 0;
  if (x > 0) push(i - 1);
  if (x < W - 1) push(i + 1);
  if (y > 0) push(i - W);
  if (y < H - 1) push(i + W);
}

let bgCount = 0;
for (let i = 0; i < bg.length; i++) bgCount += bg[i];
console.log(`vom Rand erreicht: ${((bgCount / bg.length) * 100).toFixed(1)}%`);

// --- Eingeschlossene Hintergrundflaechen ------------------------------------
// Flaechen wie zwischen Arm und Oberkoerper (Hand in der Hosentasche) sind
// ringsum von Linework umschlossen und damit vom Bildrand aus nicht
// erreichbar. Sie werden ueber ihre Farbe erkannt: liegt der Mittelwert einer
// eingeschlossenen Flaeche im Farbbereich des Hintergrunds, ist es Hintergrund.
// Der Hintergrund ist ein Verlauf - ein globaler Mittelwert waere so unscharf,
// dass auch die cremefarbenen Shorts hineinfielen. Deshalb wird pro Bildzeile
// gemittelt und eine Tasche gegen den Hintergrund *auf ihrer eigenen Hoehe*
// geprueft.
const rowSum = [new Float64Array(H), new Float64Array(H), new Float64Array(H)];
const rowCount = new Float64Array(H);
for (let y = 0; y < H; y++) {
  for (let x = 0; x < W; x++) {
    const i = y * W + x;
    if (!bg[i]) continue;
    rowCount[y]++;
    for (let c = 0; c < 3; c++) rowSum[c][y] += data[i * C + c];
  }
}

const BAND = 40; // +/- Zeilen, ueber die der lokale Hintergrund gemittelt wird
const HOLE_TOL = Number(process.env.CUTOUT_HOLE_TOL ?? 22); // 0 = Taschensuche aus
const MIN_HOLE = 400; // kleiner = Bildrauschen bzw. Details im Motiv

// Obergrenze relativ zur Motivflaeche. Noetig, weil die cremefarbenen Shorts
// dem Hintergrund auf ihrer Hoehe farblich fast exakt entsprechen
// (Abweichung nur 10/1/3) und ueber die Farbe allein nicht zu trennen sind.
// Groessenordnung im Ausgangsbild: Armluecken ~3.000-7.000 px,
// Shorts ~38.000-48.000 px bei ~713.000 px Motivflaeche.
const subjectPixels = W * H - bgCount;
const MAX_HOLE = Math.round(subjectPixels * Number(process.env.CUTOUT_MAX_HOLE ?? 0.03));
console.log(`Taschengroesse zulaessig: ${MIN_HOLE} .. ${MAX_HOLE} px`);

/** Mittlere Hintergrundfarbe rund um Zeile `y`, oder null bei zu wenig Daten. */
const localBackground = (y) => {
  const from = Math.max(0, y - BAND);
  const to = Math.min(H - 1, y + BAND);
  let n = 0;
  const sum = [0, 0, 0];
  for (let r = from; r <= to; r++) {
    n += rowCount[r];
    for (let c = 0; c < 3; c++) sum[c] += rowSum[c][r];
  }
  return n < 500 ? null : sum.map((s) => s / n);
};
const seen = new Uint8Array(W * H);
const pocket = new Int32Array(W * H);
let holePixels = 0;
let holeCount = 0;

for (let seed = 0; seed < W * H; seed++) {
  if (bg[seed] || wall[seed] || seen[seed]) continue;

  let ph = 0;
  let pt = 0;
  seen[seed] = 1;
  pocket[pt++] = seed;
  const sum = [0, 0, 0];
  let rowTotal = 0;
  let colTotal = 0;

  while (ph < pt) {
    const i = pocket[ph++];
    for (let c = 0; c < 3; c++) sum[c] += data[i * C + c];
    rowTotal += (i / W) | 0;
    colTotal += i % W;
    const x = i % W;
    const y = (i / W) | 0;
    const visit = (n) => {
      if (!bg[n] && !wall[n] && !seen[n]) {
        seen[n] = 1;
        pocket[pt++] = n;
      }
    };
    if (x > 0) visit(i - 1);
    if (x < W - 1) visit(i + 1);
    if (y > 0) visit(i - W);
    if (y < H - 1) visit(i + W);
  }

  if (pt < MIN_HOLE) continue;

  const reference = localBackground(Math.round(rowTotal / pt));
  if (!reference) continue;
  const mean = sum.map((s) => s / pt);
  const looksLikeBackground =
    pt <= MAX_HOLE && mean.every((m, c) => Math.abs(m - reference[c]) <= HOLE_TOL);

  if (process.env.CUTOUT_DEBUG) {
    const fmt = (v) => v.map((n) => n.toFixed(0).padStart(3)).join(',');
    console.log(
      `  Tasche x=${String(Math.round(colTotal / pt)).padStart(4)} y=${String(Math.round(rowTotal / pt)).padStart(4)} ${String(pt).padStart(6)} px` +
        `  mean=(${fmt(mean)})  bg=(${fmt(reference)})` +
        `  d=(${fmt(mean.map((m, c) => Math.abs(m - reference[c])))})` +
        `  -> ${looksLikeBackground ? 'HINTERGRUND' : 'Motiv'}`,
    );
  }

  if (!looksLikeBackground) continue;

  for (let k = 0; k < pt; k++) bg[pocket[k]] = 1;
  holePixels += pt;
  holeCount++;
}

console.log(`eingeschlossene Hintergrundflaechen: ${holeCount} (${holePixels} px)`);

bgCount = 0;
for (let i = 0; i < bg.length; i++) bgCount += bg[i];
console.log(`background: ${((bgCount / bg.length) * 100).toFixed(1)}%  subject: ${(100 - (bgCount / bg.length) * 100).toFixed(1)}%`);

// --- Morphologisches Opening ----------------------------------------------
// Erodieren trennt duenne Anhaengsel (Horizontlinie am Schuh, Schattenreste) vom
// Koerper ab, damit sie der Groesste-Komponente-Filter danach verwirft.
const solid = Buffer.alloc(W * H);
for (let i = 0; i < W * H; i++) solid[i] = bg[i] ? 0 : 255;

const morph = async (buf, blurSigma, threshold) => {
  const out = await sharp(buf, { raw: { width: W, height: H, channels: 1 } })
    .blur(blurSigma)
    // Harte Schwelle bei `threshold` (0..1). linear() rechnet auf 0..255,
    // die Verschiebung muss also mit 255*255 skaliert werden.
    .linear(255, -threshold * 255 * 255)
    .toColourspace('b-w')
    .raw()
    .toBuffer();
  if (out.length !== W * H) throw new Error(`Maske hat ${out.length} Bytes, erwartet ${W * H}`);
  return out;
};

const opened = await morph(solid, 2.0, 0.86); // erodiert ~2 px

// --- Nur die groesste zusammenhaengende Motivflaeche behalten ---------------
// Entfernt Kantenrauschen im Hintergrund (z. B. Reste des Bodenschattens).
for (let i = 0; i < W * H; i++) bg[i] = opened[i] > 127 ? 0 : 1;

const label = new Int32Array(W * H).fill(-1);
let best = -1;
let bestSize = 0;
let current = 0;
for (let seed = 0; seed < W * H; seed++) {
  if (bg[seed] || label[seed] !== -1) continue;
  let size = 0;
  qh = 0;
  qt = 0;
  label[seed] = current;
  queue[qt++] = seed;
  while (qh < qt) {
    const i = queue[qh++];
    size++;
    const x = i % W;
    const y = (i / W) | 0;
    const visit = (n) => {
      if (!bg[n] && label[n] === -1) {
        label[n] = current;
        queue[qt++] = n;
      }
    };
    if (x > 0) visit(i - 1);
    if (x < W - 1) visit(i + 1);
    if (y > 0) visit(i - W);
    if (y < H - 1) visit(i + W);
  }
  if (size > bestSize) {
    bestSize = size;
    best = current;
  }
  current++;
}
console.log(`components: ${current}, largest: ${bestSize} px`);

const largest = Buffer.alloc(W * H);
for (let i = 0; i < W * H; i++) largest[i] = label[i] === best ? 255 : 0;

// Opening abschliessen: wieder dilatieren und mit der Originalmaske schneiden,
// damit die Silhouette exakt bleibt, die abgetrennten Anhaengsel aber weg sind.
const dilated = await morph(largest, 2.0, 0.14);
const mask = Buffer.alloc(W * H);
for (let i = 0; i < W * H; i++) mask[i] = dilated[i] > 127 && solid[i] > 127 ? 255 : 0;

// --- Kante glaetten und minimal erodieren ----------------------------------
// Weicher Verlauf statt harter Schwelle -> antialiaste Silhouette ohne hellen Saum.
const alpha = await sharp(mask, { raw: { width: W, height: H, channels: 1 } })
  .blur(1.1)
  .linear(3, -1.7 * 255)
  .toColourspace('b-w')
  .raw()
  .toBuffer();

if (alpha.length !== W * H) {
  throw new Error(`Alpha hat ${alpha.length} Bytes, erwartet ${W * H}`);
}

// --- Bounding Box ----------------------------------------------------------
let minX = W, minY = H, maxX = -1, maxY = -1;
for (let y = 0; y < H; y++) {
  for (let x = 0; x < W; x++) {
    if (alpha[y * W + x] > 8) {
      if (x < minX) minX = x;
      if (x > maxX) maxX = x;
      if (y < minY) minY = y;
      if (y > maxY) maxY = y;
    }
  }
}
console.log(`bbox: x ${minX}..${maxX}  y ${minY}..${maxY}`);

const rgba = Buffer.alloc(W * H * 4);
for (let i = 0; i < W * H; i++) {
  rgba[i * 4] = data[i * C];
  rgba[i * 4 + 1] = data[i * C + 1];
  rgba[i * 4 + 2] = data[i * C + 2];
  rgba[i * 4 + 3] = alpha[i];
}

const pad = 4;
const left = Math.max(0, minX - pad);
const top = Math.max(0, minY - pad);
await sharp(rgba, { raw: { width: W, height: H, channels: 4 } })
  .extract({
    left,
    top,
    width: Math.min(W - 1, maxX + pad) - left,
    height: Math.min(H - 1, maxY + pad) - top,
  })
  .png({ compressionLevel: 9 })
  .toFile(OUT);

console.log(`written ${OUT}`);
