import { paletteIndexForDataItem, resolveDataItemColor } from '../../shared/config.js';
import { patchEmissiveForInstancing, setInstancePosScale } from '../instanced.js';
import { enableProjectionFlatMorph } from '../projectionFlatMaterial.js';

function addAreaFillShader(mat, hasVertexAlpha, satUniform) {
  mat.onBeforeCompile = (shader) => {
    shader.uniforms.waveSaturation = satUniform;
    let vertDecl = '';
    let vertBody = '';
    let fragDecl = 'uniform float waveSaturation;\n';
    let fragEnd = '';
    if (hasVertexAlpha) {
      vertDecl += 'attribute float vertexAlpha;\nvarying float vVertexAlpha;\n';
      vertBody += 'vVertexAlpha = vertexAlpha;\n';
      fragDecl += 'varying float vVertexAlpha;\n';
      fragEnd += 'gl_FragColor.a *= vVertexAlpha;\n';
    }
    shader.vertexShader = shader.vertexShader
      .replace('#include <common>', '#include <common>\n' + vertDecl)
      .replace('#include <begin_vertex>', '#include <begin_vertex>\n' + vertBody);
    shader.fragmentShader = shader.fragmentShader
      .replace('#include <common>', '#include <common>\n' + fragDecl)
      .replace('#include <color_fragment>',
        '#include <color_fragment>\n' +
        'float wGray = dot(diffuseColor.rgb, vec3(0.299, 0.587, 0.114));\n' +
        'diffuseColor.rgb = mix(vec3(wGray), diffuseColor.rgb, waveSaturation);\n')
      .replace('#include <dithering_fragment>', fragEnd + '#include <dithering_fragment>\n');
  };
}

function addSaturationShader(mat, satUniform) {
  mat.onBeforeCompile = (shader) => {
    shader.uniforms.waveSaturation = satUniform;
    shader.fragmentShader = shader.fragmentShader
      .replace('#include <common>', '#include <common>\nuniform float waveSaturation;\n')
      .replace('#include <color_fragment>',
        '#include <color_fragment>\n' +
        'float wGray = dot(diffuseColor.rgb, vec3(0.299, 0.587, 0.114));\n' +
        'diffuseColor.rgb = mix(vec3(wGray), diffuseColor.rgb, waveSaturation);\n');
  };
}

function makePointGeometry(THREE, radius) {
  return new THREE.SphereGeometry(radius, 12, 10);
}

function buildAreaFill(THREE, curve, barCols, sc, baseY, segs, gradDir, opacity, colorful, satU) {
  const vertCount = (segs + 1) * 2;
  const verts = new Float32Array(vertCount * 3);
  const cols = new Float32Array(vertCount * 3);
  const alphaArr = new Float32Array(vertCount);
  const idx = [];
  const fadeAlpha = gradDir === 'vertical' || gradDir === 'vertical-light';

  const bc = barCols[0];

  for (let i = 0; i <= segs; i++) {
    const t = i / segs;
    const pt = curve.getPointAt(t);
    const top = i * 2, bot = i * 2 + 1;

    verts[top * 3] = pt.x; verts[top * 3 + 1] = pt.y; verts[top * 3 + 2] = pt.z;
    verts[bot * 3] = pt.x; verts[bot * 3 + 1] = baseY; verts[bot * 3 + 2] = pt.z;

    cols[top * 3] = bc.r; cols[top * 3 + 1] = bc.g; cols[top * 3 + 2] = bc.b;
    cols[bot * 3] = bc.r; cols[bot * 3 + 1] = bc.g; cols[bot * 3 + 2] = bc.b;

    alphaArr[top] = 1;
    alphaArr[bot] = fadeAlpha ? 0 : 1;

    if (i < segs) {
      const tr = (i + 1) * 2, br = (i + 1) * 2 + 1;
      idx.push(top, bot, tr, bot, br, tr);
    }
  }

  const geo = new THREE.BufferGeometry();
  geo.setAttribute('position', new THREE.BufferAttribute(verts, 3));
  geo.setAttribute('color', new THREE.BufferAttribute(cols, 3));
  if (fadeAlpha) geo.setAttribute('vertexAlpha', new THREE.BufferAttribute(alphaArr, 1));
  geo.setIndex(idx);

  const mat = new THREE.MeshBasicMaterial({
    vertexColors: true, transparent: true, opacity, side: THREE.DoubleSide, depthWrite: false,
  });
  addAreaFillShader(mat, fadeAlpha, satU);

  const mesh = new THREE.Mesh(geo, mat);
  mesh.userData._waveSatUniform = satU;
  mesh.userData._targetSaturation = colorful ? 1 : 0;
  return mesh;
}

function buildShadow(THREE, curve, tubeR, segs) {
  const sw = tubeR * 4;
  const verts = new Float32Array((segs + 1) * 2 * 3);
  const idx = [];

  for (let i = 0; i <= segs; i++) {
    const t = i / segs;
    const pt = curve.getPointAt(t);
    const a = i * 2, b = i * 2 + 1;
    verts[a * 3] = pt.x; verts[a * 3 + 1] = 0.001; verts[a * 3 + 2] = pt.z - sw;
    verts[b * 3] = pt.x; verts[b * 3 + 1] = 0.001; verts[b * 3 + 2] = pt.z + sw;
    if (i < segs) {
      const c = (i + 1) * 2, d = (i + 1) * 2 + 1;
      idx.push(a, b, c, b, d, c);
    }
  }

  const geo = new THREE.BufferGeometry();
  geo.setAttribute('position', new THREE.BufferAttribute(verts, 3));
  geo.setIndex(idx);

  const mat = new THREE.MeshBasicMaterial({
    color: 0x000000, transparent: true, opacity: 0.12, side: THREE.DoubleSide, depthWrite: false,
  });

  const mesh = new THREE.Mesh(geo, mat);
  mesh.renderOrder = -2;
  return mesh;
}

/** Massives 3D-Band: gefuellte Flaeche Kurve→Grundlinie, in die Tiefe extrudiert. */
function buildRibbon(THREE, curve, barColor, baseY, segs, depth, opacity, satU, colorful, projectionFlatRegistry) {
  const samples = curve.getPoints(segs);
  const shape = new THREE.Shape();
  shape.moveTo(samples[0].x, baseY);
  for (let i = 0; i < samples.length; i++) {
    shape.lineTo(samples[i].x, samples[i].y);
  }
  shape.lineTo(samples[samples.length - 1].x, baseY);
  shape.closePath();

  /* Dezenter, fest verdrahteter Bevel rundet die Kanten leicht ab. */
  const geo = new THREE.ExtrudeGeometry(shape, {
    depth,
    bevelEnabled: true,
    bevelThickness: Math.min(0.025, depth * 0.3),
    bevelSize: 0.012,
    bevelSegments: 2,
    steps: 1,
  });
  geo.computeVertexNormals();

  /* Leichter vertikaler Gradient: oben aufgehellt, an der Grundlinie volle Farbe. */
  const pos = geo.attributes.position;
  let maxY = baseY;
  for (let i = 0; i < pos.count; i++) maxY = Math.max(maxY, pos.getY(i));
  const span = Math.max(1e-4, maxY - baseY);
  const topColor = barColor.clone().lerp(new THREE.Color(0xffffff), 0.3);
  const cols = new Float32Array(pos.count * 3);
  const tmp = new THREE.Color();
  for (let i = 0; i < pos.count; i++) {
    const f = Math.max(0, Math.min(1, (pos.getY(i) - baseY) / span));
    tmp.copy(barColor).lerp(topColor, f);
    cols[i * 3] = tmp.r; cols[i * 3 + 1] = tmp.g; cols[i * 3 + 2] = tmp.b;
  }
  geo.setAttribute('color', new THREE.BufferAttribute(cols, 3));

  const mat = new THREE.MeshPhongMaterial({
    vertexColors: true,
    shininess: 30,
    specular: 0x222222,
    side: THREE.DoubleSide,
    transparent: opacity < 1,
    opacity,
  });
  addSaturationShader(mat, satU);
  if (projectionFlatRegistry) enableProjectionFlatMorph(mat, projectionFlatRegistry);

  const mesh = new THREE.Mesh(geo, mat);
  mesh.userData._waveSatUniform = satU;
  mesh.userData._targetSaturation = colorful ? 1 : 0;
  mesh.castShadow = false;
  mesh.receiveShadow = false;
  return mesh;
}

/**
 * Einzelnes, *gebogenes* 3D-Panel entlang eines Teilstücks der Bahn (Roadmap-Layout).
 * Jedes Datensegment wird als eigenständiges, der Kurve folgendes Paneel mit echter
 * Dicke gebaut (Bandbreite horizontal entlang der Bahn-Normalen, dünne vertikale
 * Platte). Mehrere solcher Paneele überlappen sich leicht und treppen über den
 * Wert/`yLift` gestaffelt — der „gestapelte Karten"-Look wie bei einer Timeline.
 * `uA`/`uB` sind die Bahn-Parameter (0..1) für Anfang/Ende dieses Paneels.
 */
function buildCurvedPanel(THREE, curve, color, uA, uB, segs, depth, opacity, satU, colorful, projectionFlatRegistry, bandWidth, yLift, fitSink) {
  const halfW = Math.max(0.02, bandWidth > 0 ? bandWidth : 0.4) / 2;
  const thickness = Math.max(0.02, depth);
  const lift = yLift || 0;
  const white = new THREE.Color(0xffffff);
  const colTop = color.clone().lerp(white, 0.14);
  const clampU = (u) => Math.min(1, Math.max(0, u));

  const sub = Math.max(2, Math.round(segs * (uB - uA)));
  const sections = [];
  for (let s = 0; s <= sub; s++) {
    const u = clampU(uA + (uB - uA) * (s / sub));
    const p = curve.getPointAt(u);
    const tan = curve.getTangentAt(u);
    /* Bandbreite W = horizontale Normale (Tangente in XZ um 90° gedreht) → das Band
     * liegt flach. Dicke entlang der Flächennormalen N = W × T (senkrecht zur
     * Bandoberfläche), damit die Endkappen im 90°-Winkel zur Laufrichtung stehen
     * (saubere „Quer"-Schnitte statt schräger Enden bei geneigten Segmenten). */
    let wx = -tan.z; let wz = tan.x;
    const wl = Math.hypot(wx, wz) || 1;
    wx /= wl; wz /= wl;
    /* N = cross(W, T) mit W = (wx, 0, wz) */
    let Nx = -wz * tan.y;
    let Ny = wz * tan.x - wx * tan.z;
    let Nz = wx * tan.y;
    const nl = Math.hypot(Nx, Ny, Nz) || 1;
    Nx = (Nx / nl) * thickness; Ny = (Ny / nl) * thickness; Nz = (Nz / nl) * thickness;
    sections.push({
      x: p.x, y: p.y + lift, z: p.z,
      Wx: wx * halfW, Wz: wz * halfW,
      Nx, Ny, Nz,
    });
  }

  const n = sections.length;
  /* Dezente Fase an den beiden oberen Längskanten: statt scharfer 90°-Kante wird die
   * Oberseite leicht eingezogen (…t-Schienen) und über eine schmale Schräg-Facette
   * (…s-Schienen, um die Fasenbreite entlang −N abgesenkt) mit der Seitenwand
   * verbunden. Die Unterseite bleibt scharf (liegt am Boden/ist verdeckt). */
  const bevel = Math.min(halfW, thickness) * 0.15;
  const OTt = []; const ITt = []; const OTs = []; const ITs = []; const OB = []; const IB = [];
  for (let i = 0; i < n; i++) {
    const s = sections[i];
    const wux = s.Wx / halfW; const wuz = s.Wz / halfW;
    const nux = s.Nx / thickness; const nuy = s.Ny / thickness; const nuz = s.Nz / thickness;
    /* Oberkante: nach innen (entlang ∓W) eingezogene Top-Fläche. */
    OTt.push([s.x + s.Wx - wux * bevel, s.y, s.z + s.Wz - wuz * bevel]);
    ITt.push([s.x - s.Wx + wux * bevel, s.y, s.z - s.Wz + wuz * bevel]);
    /* Seitenkante oben: voller Breitenpunkt, um die Fasenbreite entlang −N abgesenkt. */
    OTs.push([s.x + s.Wx - nux * bevel, s.y - nuy * bevel, s.z + s.Wz - nuz * bevel]);
    ITs.push([s.x - s.Wx - nux * bevel, s.y - nuy * bevel, s.z - s.Wz - nuz * bevel]);
    OB.push([s.x + s.Wx - s.Nx, s.y - s.Ny, s.z + s.Wz - s.Nz]);
    IB.push([s.x - s.Wx - s.Nx, s.y - s.Ny, s.z - s.Wz - s.Nz]);
  }

  /* Jede Fläche (oben/Fasen/außen/innen/unten + Kappen) erhält EIGENE Vertices. So mittelt
   * computeVertexNormals nur innerhalb einer Fläche (glatt entlang der Kurve) und
   * erzeugt an den Kanten harte Knicke — keine schräg gemittelten Normalen mehr,
   * die als dunkle Streifen erscheinen. */
  const positions = []; const colors = []; const indices = [];
  const cTop = [colTop.r, colTop.g, colTop.b];
  const cBase = [color.r, color.g, color.b];

  /* Längs-Streifen aus zwei Schienen (railA/railB je Querschnitt). */
  const addStrip = (railA, railB, colA, colB) => {
    const start = positions.length / 3;
    for (let i = 0; i < n; i++) {
      positions.push(railA[i][0], railA[i][1], railA[i][2]); colors.push(colA[0], colA[1], colA[2]);
      positions.push(railB[i][0], railB[i][1], railB[i][2]); colors.push(colB[0], colB[1], colB[2]);
    }
    for (let i = 0; i < n - 1; i++) {
      const b = start + i * 2;
      indices.push(b, b + 1, b + 2, b + 2, b + 1, b + 3);
    }
  };
  /* Endkappe als Sechseck (OTt→OTs→OB→IB→ITs→ITt) per Dreiecksfächer. */
  const addCap = (idx, reverse) => {
    const ring = [OTt[idx], OTs[idx], OB[idx], IB[idx], ITs[idx], ITt[idx]];
    const s = positions.length / 3;
    ring.forEach((p) => { positions.push(p[0], p[1], p[2]); colors.push(cBase[0], cBase[1], cBase[2]); });
    for (let k = 1; k < ring.length - 1; k++) {
      if (reverse) indices.push(s, s + k + 1, s + k);
      else indices.push(s, s + k, s + k + 1);
    }
  };

  addStrip(OTt, ITt, cTop, cTop);   // Oberseite (durch Fase leicht schmaler)
  addStrip(OTt, OTs, cTop, cTop);   // Außen-Fase
  addStrip(ITs, ITt, cTop, cTop);   // Innen-Fase
  addStrip(OTs, OB, cTop, cBase);   // Außenkante
  addStrip(IB, ITs, cBase, cTop);   // Innenkante
  addStrip(IB, OB, cBase, cBase);   // Unterseite
  addCap(0, false);                 // Kappe Anfang
  addCap(n - 1, true);              // Kappe Ende

  /* Echte Band-Eckpunkte (lokale Koordinaten) für den punktbasierten Kamera-Fit
   * sammeln — so rahmt die Kamera die tatsächliche (diagonale) Silhouette statt
   * einer viel zu großen achsenparallelen Bounding-Box. Nur eine Hand voll
   * Querschnitte je Paneel (Start/Ende + wenige dazwischen) genügen für einen
   * engen Fit; eine dichte Wolke ist unnötig. */
  if (fitSink) {
    const step = Math.max(1, Math.floor((n - 1) / 5));
    for (let i = 0; i < n; i += step) {
      const idx = Math.min(i, n - 1);
      fitSink.push(
        OTt[idx][0], OTt[idx][1], OTt[idx][2], ITt[idx][0], ITt[idx][1], ITt[idx][2],
        OB[idx][0], OB[idx][1], OB[idx][2], IB[idx][0], IB[idx][1], IB[idx][2],
      );
    }
    const last = n - 1;
    fitSink.push(
      OTt[last][0], OTt[last][1], OTt[last][2], ITt[last][0], ITt[last][1], ITt[last][2],
      OB[last][0], OB[last][1], OB[last][2], IB[last][0], IB[last][1], IB[last][2],
    );
  }

  const geo = new THREE.BufferGeometry();
  geo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(positions), 3));
  geo.setAttribute('color', new THREE.BufferAttribute(new Float32Array(colors), 3));
  geo.setIndex(indices);
  geo.computeVertexNormals();

  const mat = new THREE.MeshPhongMaterial({
    vertexColors: true,
    shininess: 30,
    specular: 0x222222,
    side: THREE.DoubleSide,
    transparent: opacity < 1,
    opacity,
    depthWrite: true,
  });
  addSaturationShader(mat, satU);
  if (projectionFlatRegistry) enableProjectionFlatMorph(mat, projectionFlatRegistry);

  const mesh = new THREE.Mesh(geo, mat);
  mesh.userData._waveSatUniform = satU;
  mesh.userData._targetSaturation = colorful ? 1 : 0;
  mesh.castShadow = true;
  mesh.receiveShadow = true;
  return mesh;
}

export function createWave(ctx) {
  const {
    THREE, DATA, valueToNorm, chartLeft, chartRight, chartTop, count,
    HOVER_PALETTE, GRAY_PALETTE, alwaysColorful, isMobile, uniformDataColor,
    projectionFlatRegistry,
  } = ctx;
  /* Optionaler Bahn-Hook (z. B. Roadmap-Layout): ersetzt die gerade (x,z)-Grundlinie
   * durch eine beliebige Bahn in der Bodenebene. `y` (= Wert) bleibt unveraendert,
   * der Ausschlag nach oben bedeutet weiterhin einen hoeheren Wert. Ohne Hook
   * verhaelt sich Wave exakt wie zuvor (gerade Links-rechts-Achse). */
  const baselinePath = typeof ctx.baselinePath === 'function' ? ctx.baselinePath : null;
  /* Optionaler Höhen-Hook (z. B. Roadmap): bildet den normalisierten Wert (0..1)
   * auf eine andere Höhe ab (z. B. dezentere Amplitude, Minimum am Boden). Ohne
   * Hook bleibt die Höhe = valueToNorm * chartTop wie zuvor. */
  const heightTransform = typeof ctx.heightTransform === 'function' ? ctx.heightTransform : null;
  /* Free reicht alle Konfigurationsfelder ueber ctx.opts durch (statt sie
   * vorher in init.js zu zerlegen). Fallback auf ctx fuer Abwaertskompatibilitaet
   * mit aelteren Free-Versionen. */
  const o = ctx.opts || ctx;

  const style = o.waveStyle === 'line' ? 'line' : 'ribbon';
  const ribbonDepth = Math.max(0.05, Math.min(1.5, Number(o.waveRibbonDepth) || 0.08));
  const _roRaw = Number(o.waveRibbonOpacity);
  const ribbonOpacity = Math.max(0.1, Math.min(1, Number.isFinite(_roRaw) ? _roRaw : 1));
  /* Abstand zwischen den Bändern in Z (zusätzlich zur Banddicke). */
  const ribbonGap = 0.14;
  const ribbonSpacing = ribbonDepth + ribbonGap;
  const areaFill = o.waveAreaFill === true;
  const areaOpacity = Math.max(0, Math.min(1, Number(o.waveAreaFillOpacity) || 0.35));
  const lineThick = Math.max(0.2, Math.min(2, Number(o.waveLineThickness) || 1));
  const showPoints = o.waveShowPoints !== false;
  const pointSize = Math.max(0.3, Math.min(3, Number(o.wavePointSize) || 1));
  /* `Number(undefined) ?? 0.5` ergaebe NaN (weil ?? nur null/undefined faengt,
   * nicht NaN) → CatmullRomCurve3 wuerde mit tension=NaN bei TubeGeometry abstuerzen.
   * Daher explizit auf endliche Zahl pruefen. */
  const _wsRaw = Number(o.waveSmoothness);
  const smoothness = Math.max(0, Math.min(1, Number.isFinite(_wsRaw) ? _wsRaw : 0.5));
  const tension = smoothness;
  const dropShadow = o.waveDropShadow === true;
  const gradDir = o.waveGradientDirection === 'along-curve' ? 'along-curve' : (o.waveGradientDirection === 'vertical-light' ? 'vertical-light' : 'vertical');
  const multiSeries = o.waveMultiSeries === true;
  const baselineY = typeof o.waveBaselineValue === 'number' ? valueToNorm(o.waveBaselineValue) * chartTop : 0;

  let seriesList;
  if (multiSeries) {
    const map = new Map();
    for (let i = 0; i < count; i++) {
      const key = DATA[i].groupKey || '';
      if (!map.has(key)) map.set(key, []);
      map.get(key).push({ data: DATA[i], origIndex: i });
    }
    seriesList = map.size > 1
      ? [...map.entries()].map(([, items]) => items)
      : [DATA.slice(0, count).map((d, i) => ({ data: d, origIndex: i }))];
  } else {
    seriesList = [DATA.slice(0, count).map((d, i) => ({ data: d, origIndex: i }))];
  }

  const waveGroup = new THREE.Group();
  const areaFillGroup = new THREE.Group();
  areaFillGroup.renderOrder = 1001;
  waveGroup.add(areaFillGroup);
  const waveMeshes = [];
  const allSphereInstData = [];
  const allWavePoints = [];
  const areaMeshes = [];
  const shadowMeshes = [];
  const seriesEndInfo = [];
  const tubeRadius = 0.02 * lineThick;
  /** Abstand der Serien in Z (Richtung Betrachter); etwas enger = alle näher am Lineal. */
  const seriesZGap = 0.12;

  for (let si = 0; si < seriesList.length; si++) {
    const items = seriesList[si];
    const sc = items.length;
    const pts = [];
    const barCols = [];

    for (let j = 0; j < sc; j++) {
      const { data, origIndex } = items[j];
      const t = sc > 1 ? j / (sc - 1) : 0.5;
      const vNorm = valueToNorm(data.value);
      /* Bei gewundener Bahn (Roadmap) die gesamte Bahn um die Banddicke anheben, damit
       * der niedrigste Punkt mit der Bandunterseite auf dem Boden aufliegt statt darin
       * zu stecken. Kurve, Knoten, Annotationen und Punkte verschieben sich gemeinsam. */
      const y = (heightTransform ? heightTransform(vNorm) : vNorm) * chartTop + (baselinePath ? ribbonDepth : 0);
      let x, z;
      if (baselinePath) {
        const p = baselinePath(t, si);
        x = p.x;
        z = p.z;
      } else {
        x = chartLeft + t * (chartRight - chartLeft);
        z = seriesList.length > 1
          ? si * (style === 'ribbon' ? ribbonSpacing : seriesZGap)
          : 0;
      }
      pts.push(new THREE.Vector3(x, y, z));
      barCols.push(new THREE.Color(resolveDataItemColor(data, origIndex, HOVER_PALETTE, uniformDataColor)));
    }
    if (pts.length === 1) pts.push(pts[0].clone());
    allWavePoints.push(...pts);

    const curve = new THREE.CatmullRomCurve3(pts, false, 'catmullrom', tension);
    const tubeSeg = Math.min(300, Math.max(64, 48 + sc * 16));

    const satU = { value: alwaysColorful ? 1 : 0 };

    if (style === 'ribbon') {
      if (baselinePath) {
        /* Roadmap: einzelne, gebogene Paneele — eines je Datenpunkt. Jedes Paneel
         * folgt seinem Bahn-Teilstück und wird an den Innenkanten leicht eingekürzt,
         * sodass zwischen benachbarten Paneelen eine kleine Lücke entsteht (keine
         * Überlappung). */
        const panelWidth = (chartRight - chartLeft) * 0.22;
        const gapHalfU = sc > 1 ? 0.04 / (sc - 1) : 0;
        const fitHull = waveGroup.userData._fitHullLocal || (waveGroup.userData._fitHullLocal = []);
        for (let k = 0; k < sc; k++) {
          const nomStart = sc > 1 ? (k - 0.5) / (sc - 1) : 0;
          const nomEnd = sc > 1 ? (k + 0.5) / (sc - 1) : 1;
          const uA = k === 0 ? 0 : Math.min(1, nomStart + gapHalfU);
          const uB = k === sc - 1 ? 1 : Math.max(0, nomEnd - gapHalfU);
          const panel = buildCurvedPanel(
            THREE, curve, barCols[k] || barCols[0], uA, uB, tubeSeg,
            ribbonDepth, ribbonOpacity, satU, alwaysColorful, projectionFlatRegistry,
            panelWidth, 0, fitHull,
          );
          panel.renderOrder = k;
          panel.userData._dataPoints = pts;
          panel.userData._DATA = items.map(x => x.data);
          panel.userData._seriesIndex = si;
          /* Jedes Roadmap-Segment gehoert zu genau einem Datenpunkt → `_dataIndex`,
           * damit `setDataIndexHidden` (Legende) das Segment wie Bars/Slices ein-/ausblenden kann. */
          panel.userData._dataIndex = items[k] ? items[k].origIndex : k;
          waveGroup.add(panel);
          waveMeshes.push(panel);
        }
      } else {
        /* Massives 3D-Band (gerade Welle): ebene x/y-Form, in die Tiefe extrudiert. */
        const rib = buildRibbon(THREE, curve, barCols[0], baselineY, tubeSeg, ribbonDepth, ribbonOpacity, satU, alwaysColorful, projectionFlatRegistry);
        rib.position.z = pts[0].z;
        /* Stabile Z-Order über den Tiefenpuffer (depthWrite=true): hinterstes Band
         * zuerst, damit Transparenz aus der Front-Ansicht korrekt durchscheint. */
        rib.renderOrder = si;
        rib.castShadow = true;
        rib.receiveShadow = true;
        rib.userData._dataPoints = pts;
        rib.userData._DATA = items.map(x => x.data);
        rib.userData._seriesIndex = si;
        waveGroup.add(rib);
        waveMeshes.push(rib);
      }

      /* Datenpunkt-Metadaten auch im Ribbon-Modus befüllen (Positionen, Farben,
       * Serien-Index). Daran hängen Node-Labels, Tooltips/Hover und Annotationen.
       * Es wird KEIN sichtbarer Kugel-InstancedMesh gebaut, da _sharedPtGeo hier
       * nicht gesetzt wird (Bänder ersetzen die Punkt-Darstellung). */
      {
        const seriesPi = paletteIndexForDataItem(items[0].data, items[0].origIndex);
        const seriesGrayC = GRAY_PALETTE[seriesPi % GRAY_PALETTE.length];
        const seriesBarC = barCols[0].getHex();
        for (let j = 0; j < sc; j++) {
          const initC = alwaysColorful ? seriesBarC : seriesGrayC;
          allSphereInstData.push({
            position: pts[j].clone(),
            dataIndex: j,
            defaultColor: seriesBarC,
            grayColor: seriesGrayC,
            targetColor: initC,
            currentColor: new THREE.Color(initC),
            targetScale: 1,
            _currentScale: 1,
            seriesIndex: si,
            _DATA: items.map(x => x.data),
            isSeriesEnd: false,
          });
        }
      }
    } else {
      const radSeg = 8;
      const geo = new THREE.TubeGeometry(curve, tubeSeg, tubeRadius, radSeg, false);

      const posAttr = geo.attributes.position;
      const cols = new Float32Array(posAttr.count * 3);
      const seriesBarC = barCols[0];
      for (let i = 0; i < posAttr.count; i++) {
        cols[i * 3] = seriesBarC.r; cols[i * 3 + 1] = seriesBarC.g; cols[i * 3 + 2] = seriesBarC.b;
      }

      geo.setAttribute('color', new THREE.BufferAttribute(cols, 3));

      const mat = new THREE.MeshPhongMaterial({ vertexColors: true, shininess: 120, specular: 0x444444, emissive: 0x333333, emissiveIntensity: 0.4 });
      addSaturationShader(mat, satU);
      if (projectionFlatRegistry) enableProjectionFlatMorph(mat, projectionFlatRegistry);

      const tubeMesh = new THREE.Mesh(geo, mat);
      tubeMesh.userData._waveSatUniform = satU;
      tubeMesh.userData._targetSaturation = alwaysColorful ? 1 : 0;
      tubeMesh.castShadow = false;
      tubeMesh.receiveShadow = false;
      tubeMesh.userData._dataPoints = pts;
      tubeMesh.userData._DATA = items.map(x => x.data);
      tubeMesh.userData._seriesIndex = si;
      waveGroup.add(tubeMesh);
      waveMeshes.push(tubeMesh);

      if (areaFill) {
        const am = buildAreaFill(THREE, curve, barCols, sc, baselineY, tubeSeg, gradDir, areaOpacity, alwaysColorful, satU);
        am.userData._seriesIndex = si;
        am.renderOrder = si;
        areaFillGroup.add(am);
        areaMeshes.push(am);
      }

      if (dropShadow) {
        const sm = buildShadow(THREE, curve, tubeRadius, tubeSeg);
        waveGroup.add(sm);
        shadowMeshes.push(sm);
      }

      if (showPoints) {
        const sphR = (isMobile ? 0.062 : 0.04) * pointSize;
        if (!waveGroup.userData._sharedPtGeo) waveGroup.userData._sharedPtGeo = makePointGeometry(THREE, sphR);

        const seriesPi = paletteIndexForDataItem(items[0].data, items[0].origIndex);
        const seriesGrayC = GRAY_PALETTE[seriesPi % GRAY_PALETTE.length];
        const seriesBarC = barCols[0].getHex();
        for (let j = 0; j < sc; j++) {
          const grayC = seriesGrayC;
          const barC = seriesBarC;
          const initC = alwaysColorful ? barC : grayC;
          allSphereInstData.push({
            position: pts[j].clone(),
            dataIndex: j,
            defaultColor: barC,
            grayColor: grayC,
            targetColor: initC,
            currentColor: new THREE.Color(initC),
            targetScale: 1,
            _currentScale: 1,
            seriesIndex: si,
            _DATA: items.map(x => x.data),
            isSeriesEnd: false,
          });
        }
      }
    }

    if (multiSeries && seriesList.length > 1) {
      const lastPt = pts[pts.length - 1];
      const groupKey = items[0]?.data?.groupKey || '';
      const lastColor = barCols[0];
      seriesEndInfo.push({ position: lastPt.clone(), groupKey, color: '#' + lastColor.getHexString() });
      if (allSphereInstData.length > 0) {
        allSphereInstData[allSphereInstData.length - 1].isSeriesEnd = true;
      }
    }
  }

  let waveSphereInstMesh = null;
  if (allSphereInstData.length > 0 && waveGroup.userData._sharedPtGeo) {
    const sphMat = new THREE.MeshPhongMaterial({ color: 0xffffff, emissive: 0xffffff, emissiveIntensity: 0.4, shininess: 120, specular: 0x444444 });
    patchEmissiveForInstancing(sphMat);
    if (projectionFlatRegistry) enableProjectionFlatMorph(sphMat, projectionFlatRegistry);
    waveSphereInstMesh = new THREE.InstancedMesh(waveGroup.userData._sharedPtGeo, sphMat, allSphereInstData.length);
    waveSphereInstMesh.castShadow = false;
    waveSphereInstMesh.receiveShadow = false;
    const _tc = new THREE.Color();
    for (let i = 0; i < allSphereInstData.length; i++) {
      const d = allSphereInstData[i];
      setInstancePosScale(THREE, waveSphereInstMesh, i, d.position.x, d.position.y, d.position.z, 1);
      _tc.set(d.currentColor);
      waveSphereInstMesh.setColorAt(i, _tc);
    }
    waveSphereInstMesh.instanceMatrix.needsUpdate = true;
    if (waveSphereInstMesh.instanceColor) waveSphereInstMesh.instanceColor.needsUpdate = true;
    waveGroup.add(waveSphereInstMesh);
  }

  /* Boden-Empfänger für den echten Schlagschatten der Bänder (nur Ribbon-Stil).
   * ShadowMaterial ist überall transparent und zeigt nur den projizierten Schatten;
   * vom Camera-Fit ausgenommen, damit die große Plane das Framing nicht aufbläht. */
  const ribbonFrontZ = (seriesList.length - 1) * ribbonSpacing + ribbonDepth;
  if (style === 'ribbon' && waveMeshes.length > 0 && baselinePath) {
    /* Gewundene Bahn (Roadmap): der Schatten faellt rundum, daher ein grosser
     * quadratischer Empfaenger ueber der gesamten Bahn-Grundflaeche (gleichmaessige
     * Opacity, keine Richtungs-Ausblendung). Ausmasse aus den XZ-Bounds der Punkte. */
    let minX = Infinity, maxX = -Infinity, minZ = Infinity, maxZ = -Infinity;
    for (const p of allWavePoints) {
      if (p.x < minX) minX = p.x; if (p.x > maxX) maxX = p.x;
      if (p.z < minZ) minZ = p.z; if (p.z > maxZ) maxZ = p.z;
    }
    const margin = 5;
    const planeSize = Math.max(maxX - minX, maxZ - minZ) + margin * 2;
    const ground = new THREE.Mesh(
      new THREE.PlaneGeometry(planeSize, planeSize),
      new THREE.ShadowMaterial({ opacity: 0.2, transparent: true, depthWrite: false }),
    );
    ground.rotation.x = -Math.PI / 2;
    ground.position.set((minX + maxX) / 2, baselineY - 0.003, (minZ + maxZ) / 2);
    ground.receiveShadow = true;
    ground.castShadow = false;
    ground.renderOrder = 1;
    ground.raycast = () => {};
    ground.userData._excludeFromWaveFit = true;
    waveGroup.add(ground);
  } else if (style === 'ribbon' && waveMeshes.length > 0) {
    /* Das keyLight steht vorn-oben → der Schatten fällt nach hinten (−Z) und wird bei
     * hohen Bändern lang. Die Empfänger-Plane muss daher weit nach hinten (und seitlich)
     * reichen, sonst wird der Schatten an der Plane-Kante abrupt abgeschnitten.
     * ShadowMaterial ist überall transparent → große Plane ist visuell unkritisch und
     * via _excludeFromWaveFit vom Camera-Fit ausgenommen. */
    const cx = (chartLeft + chartRight) / 2;
    const backZ = -10;
    const frontZ = ribbonFrontZ + 1.0;
    const planeW = Math.abs(chartRight - chartLeft) + 12;
    const planeD = frontZ - backZ;

    /* Schatten nach hinten ausblenden: kein Built-in dafür, aber per onBeforeCompile
     * günstig (1 Multiply pro Fragment, kein Extra-Pass). Wir rechnen im lokalen
     * Geometrie-Raum der Plane — deren `position.y` läuft entlang der Tiefe und ist
     * unabhängig von waveGroup-/Kamera-Transforms. Die −90°-Drehung der Plane bildet
     * lokales +y auf waveGroup-lokales −z (= nach hinten) ab: py = center − zLocal.
     * Voll bis zLocal ≈ −1 (knapp hinter der Basis), komplett aus bei zLocal ≈ −5. */
    const planeCenterZ = (frontZ + backZ) / 2;
    const fadeFullY = (planeCenterZ - (-1.0)).toFixed(4);
    const fadeEndY = (planeCenterZ - (-5.0)).toFixed(4);
    const groundMat = new THREE.ShadowMaterial({ opacity: 0.24, transparent: true, depthWrite: false });
    groundMat.onBeforeCompile = (shader) => {
      shader.vertexShader = shader.vertexShader
        .replace('#include <common>', '#include <common>\nvarying float vShadowFadeY;')
        .replace('#include <begin_vertex>', '#include <begin_vertex>\nvShadowFadeY = position.y;');
      shader.fragmentShader = shader.fragmentShader
        .replace('#include <common>', '#include <common>\nvarying float vShadowFadeY;')
        .replace(
          'gl_FragColor = vec4( color, opacity * ( 1.0 - getShadowMask() ) );',
          'gl_FragColor = vec4( color, opacity * ( 1.0 - getShadowMask() ) );\n\tgl_FragColor.a *= 1.0 - smoothstep(' + fadeFullY + ', ' + fadeEndY + ', vShadowFadeY);',
        );
    };
    const ground = new THREE.Mesh(
      new THREE.PlaneGeometry(planeW, planeD),
      groundMat,
    );
    ground.rotation.x = -Math.PI / 2;
    ground.position.set(cx, baselineY - 0.003, (frontZ + backZ) / 2);
    ground.receiveShadow = true;
    ground.castShadow = false;
    ground.renderOrder = 1;
    ground.raycast = () => {};
    ground.userData._excludeFromWaveFit = true;
    waveGroup.add(ground);
  }

  return {
    waveGroup,
    waveMesh: waveMeshes[0],
    waveMeshes,
    waveSphereInstMesh,
    waveSphereInstances: allSphereInstData,
    wavePoints: allWavePoints,
    waveAreaMeshes: areaMeshes,
    waveShadowMeshes: shadowMeshes,
    waveSeriesDepth: style === 'ribbon'
      ? ribbonFrontZ
      : (seriesList.length - 1) * seriesZGap,
    waveSeriesEndInfo: seriesEndInfo,
    waveSphereRadius: waveGroup.userData._sharedPtGeo?.parameters?.radius ?? 0.05,
  };
}
