// Shared 15-band parametric EQ editor.
//
// Loaded by both pages. The curve and the table are two views of one state:
// dragging a point is the fast way to set gain and frequency, the table is the
// only way to set type and Q, and both have to stay in step or the user cannot
// tell which one is real.
//
// The response drawn here is computed from the same RBJ Cookbook formulas the
// algorithm uses, evaluated on the unit circle. It is a prediction of what the
// algorithm will do, not a measurement of what it did -- if the two ever
// disagree, the algorithm is right and this is a bug.
(function (global) {
  'use strict';

  const BANDS = 15;
  const SAMPLE_RATE = 44100;
  const MIN_HZ = 20;
  const MAX_HZ = 20000;
  const MIN_DB = -30;
  const MAX_DB = 30;

  const TYPES = [
    {value: 0, label: '关闭', usesGain: false, usesQ: false},
    {value: 1, label: '峰值', usesGain: true, usesQ: true},
    {value: 2, label: '低架', usesGain: true, usesQ: true},
    {value: 3, label: '高架', usesGain: true, usesQ: true},
    {value: 4, label: '低通', usesGain: false, usesQ: true},
    {value: 5, label: '高通', usesGain: false, usesQ: true},
    {value: 6, label: '带通', usesGain: false, usesQ: true},
    {value: 7, label: '带阻', usesGain: false, usesQ: true},
    {value: 8, label: '全通', usesGain: false, usesQ: true}
  ];

  function typeInfo(value) {
    return TYPES.find(entry => entry.value === Number(value)) || TYPES[0];
  }

  function flatBands() {
    const bands = [];
    for (let index = 0; index < BANDS; ++index) {
      bands.push({type: 0, freq_hz: defaultFrequency(index), gain_db: 0, q: 0.707});
    }
    return bands;
  }

  // Spread across the audible range so a freshly enabled band lands somewhere
  // useful instead of all fifteen stacking up at 1 kHz.
  function defaultFrequency(index) {
    const ratio = index / (BANDS - 1);
    return Math.round(MIN_HZ * Math.pow(MAX_HZ / MIN_HZ, ratio));
  }

  function clamp(value, low, high) {
    return Math.min(high, Math.max(low, value));
  }

  // ---- RBJ Cookbook biquad coefficients ------------------------------------
  function coefficients(band) {
    const type = Number(band.type);
    if (type === 0) return null;
    const freq = clamp(Number(band.freq_hz), MIN_HZ, MAX_HZ);
    const q = clamp(Number(band.q), 0.1, 18);
    const gain = clamp(Number(band.gain_db), MIN_DB, MAX_DB);
    const A = Math.pow(10, gain / 40);
    const w0 = 2 * Math.PI * freq / SAMPLE_RATE;
    const cos0 = Math.cos(w0);
    const sin0 = Math.sin(w0);
    const alpha = sin0 / (2 * q);
    let b0, b1, b2, a0, a1, a2;
    switch (type) {
      case 1: // peaking
        b0 = 1 + alpha * A; b1 = -2 * cos0; b2 = 1 - alpha * A;
        a0 = 1 + alpha / A; a1 = -2 * cos0; a2 = 1 - alpha / A;
        break;
      case 2: { // low shelf
        const beta = 2 * Math.sqrt(A) * alpha;
        b0 = A * ((A + 1) - (A - 1) * cos0 + beta);
        b1 = 2 * A * ((A - 1) - (A + 1) * cos0);
        b2 = A * ((A + 1) - (A - 1) * cos0 - beta);
        a0 = (A + 1) + (A - 1) * cos0 + beta;
        a1 = -2 * ((A - 1) + (A + 1) * cos0);
        a2 = (A + 1) + (A - 1) * cos0 - beta;
        break;
      }
      case 3: { // high shelf
        const beta = 2 * Math.sqrt(A) * alpha;
        b0 = A * ((A + 1) + (A - 1) * cos0 + beta);
        b1 = -2 * A * ((A - 1) + (A + 1) * cos0);
        b2 = A * ((A + 1) + (A - 1) * cos0 - beta);
        a0 = (A + 1) - (A - 1) * cos0 + beta;
        a1 = 2 * ((A - 1) - (A + 1) * cos0);
        a2 = (A + 1) - (A - 1) * cos0 - beta;
        break;
      }
      case 4: // low pass
        b0 = (1 - cos0) / 2; b1 = 1 - cos0; b2 = (1 - cos0) / 2;
        a0 = 1 + alpha; a1 = -2 * cos0; a2 = 1 - alpha;
        break;
      case 5: // high pass
        b0 = (1 + cos0) / 2; b1 = -(1 + cos0); b2 = (1 + cos0) / 2;
        a0 = 1 + alpha; a1 = -2 * cos0; a2 = 1 - alpha;
        break;
      case 6: // band pass, 0 dB peak
        b0 = alpha; b1 = 0; b2 = -alpha;
        a0 = 1 + alpha; a1 = -2 * cos0; a2 = 1 - alpha;
        break;
      case 7: // notch
        b0 = 1; b1 = -2 * cos0; b2 = 1;
        a0 = 1 + alpha; a1 = -2 * cos0; a2 = 1 - alpha;
        break;
      case 8: // all pass
        b0 = 1 - alpha; b1 = -2 * cos0; b2 = 1 + alpha;
        a0 = 1 + alpha; a1 = -2 * cos0; a2 = 1 - alpha;
        break;
      default:
        return null;
    }
    return {b0: b0 / a0, b1: b1 / a0, b2: b2 / a0, a1: a1 / a0, a2: a2 / a0};
  }

  function magnitudeDb(coeff, frequency) {
    const w = 2 * Math.PI * frequency / SAMPLE_RATE;
    const cos1 = Math.cos(w), sin1 = Math.sin(w);
    const cos2 = Math.cos(2 * w), sin2 = Math.sin(2 * w);
    const numeratorReal = coeff.b0 + coeff.b1 * cos1 + coeff.b2 * cos2;
    const numeratorImag = -(coeff.b1 * sin1 + coeff.b2 * sin2);
    const denominatorReal = 1 + coeff.a1 * cos1 + coeff.a2 * cos2;
    const denominatorImag = -(coeff.a1 * sin1 + coeff.a2 * sin2);
    const numerator = Math.hypot(numeratorReal, numeratorImag);
    const denominator = Math.hypot(denominatorReal, denominatorImag);
    if (denominator === 0) return 0;
    return 20 * Math.log10(Math.max(numerator / denominator, 1e-9));
  }

  function combinedDb(bands, frequency) {
    let total = 0;
    for (const band of bands) {
      const coeff = coefficients(band);
      if (coeff) total += magnitudeDb(coeff, frequency);
    }
    return total;
  }

  // ---- editor ---------------------------------------------------------------
  function createEditor(options) {
    const canvas = options.canvas;
    const tableBody = options.tableBody;
    const onChange = options.onChange || function () {};
    const state = {bands: flatBands(), bypass: true, fade_ms: 20};
    let selected = -1;
    let dragging = -1;

    function xForFrequency(freq, width) {
      const ratio = Math.log(freq / MIN_HZ) / Math.log(MAX_HZ / MIN_HZ);
      return ratio * width;
    }
    function frequencyForX(x, width) {
      const ratio = clamp(x / width, 0, 1);
      return MIN_HZ * Math.pow(MAX_HZ / MIN_HZ, ratio);
    }
    function yForDb(db, height) {
      return height * (1 - (db - MIN_DB) / (MAX_DB - MIN_DB));
    }
    function dbForY(y, height) {
      return MIN_DB + (1 - clamp(y / height, 0, 1)) * (MAX_DB - MIN_DB);
    }

    function draw() {
      const ratio = global.devicePixelRatio || 1;
      const width = canvas.clientWidth;
      const height = canvas.clientHeight;
      if (canvas.width !== Math.round(width * ratio)) {
        canvas.width = Math.round(width * ratio);
        canvas.height = Math.round(height * ratio);
      }
      const context = canvas.getContext('2d');
      context.setTransform(ratio, 0, 0, ratio, 0, 0);
      context.clearRect(0, 0, width, height);

      context.strokeStyle = '#26314e';
      context.fillStyle = '#5b6a91';
      context.lineWidth = 1;
      context.font = '10px system-ui, sans-serif';
      for (const freq of [20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000]) {
        const x = xForFrequency(freq, width);
        context.beginPath();
        context.moveTo(x, 0);
        context.lineTo(x, height);
        context.stroke();
        context.fillText(freq >= 1000 ? (freq / 1000) + 'k' : String(freq), x + 3, height - 4);
      }
      for (const db of [-30, -20, -10, 0, 10, 20, 30]) {
        const y = yForDb(db, height);
        context.beginPath();
        context.moveTo(0, y);
        context.lineTo(width, y);
        context.strokeStyle = db === 0 ? '#3d4c73' : '#26314e';
        context.stroke();
        if (db !== 0) context.fillText(db > 0 ? '+' + db : String(db), 3, y - 3);
      }

      // Drawn even when bypassed, only dimmed. It has to stay clearly visible:
      // the default curve is a flat line, and if that line is invisible the
      // panel looks broken rather than neutral.
      context.strokeStyle = state.bypass ? '#7d8dbb' : '#66d3ff';
      context.lineWidth = 2;
      context.beginPath();
      for (let px = 0; px <= width; ++px) {
        const db = combinedDb(state.bands, frequencyForX(px, width));
        const y = yForDb(clamp(db, MIN_DB, MAX_DB), height);
        if (px === 0) context.moveTo(px, y); else context.lineTo(px, y);
      }
      context.stroke();

      state.bands.forEach((band, index) => {
        if (Number(band.type) === 0) return;
        const x = xForFrequency(clamp(band.freq_hz, MIN_HZ, MAX_HZ), width);
        const info = typeInfo(band.type);
        const y = yForDb(clamp(info.usesGain ? band.gain_db : 0, MIN_DB, MAX_DB), height);
        context.beginPath();
        context.arc(x, y, index === selected ? 7 : 5, 0, Math.PI * 2);
        context.fillStyle = index === selected ? '#9df0c4' : '#66d3ff';
        context.fill();
        context.fillStyle = '#0b1020';
        context.font = '9px system-ui, sans-serif';
        context.textAlign = 'center';
        context.fillText(String(index + 1), x, y + 3);
        context.textAlign = 'left';
      });
    }

    function nearestBand(x, y, width, height) {
      let best = -1;
      let bestDistance = 18;
      state.bands.forEach((band, index) => {
        if (Number(band.type) === 0) return;
        const info = typeInfo(band.type);
        const bx = xForFrequency(clamp(band.freq_hz, MIN_HZ, MAX_HZ), width);
        const by = yForDb(clamp(info.usesGain ? band.gain_db : 0, MIN_DB, MAX_DB), height);
        const distance = Math.hypot(bx - x, by - y);
        if (distance < bestDistance) {
          bestDistance = distance;
          best = index;
        }
      });
      return best;
    }

    function pointerPosition(event) {
      const rect = canvas.getBoundingClientRect();
      return {x: event.clientX - rect.left, y: event.clientY - rect.top,
              width: rect.width, height: rect.height};
    }

    canvas.addEventListener('pointerdown', event => {
      const p = pointerPosition(event);
      const index = nearestBand(p.x, p.y, p.width, p.height);
      if (index < 0) return;
      dragging = index;
      selected = index;
      canvas.setPointerCapture(event.pointerId);
      syncTable();
      draw();
    });

    canvas.addEventListener('pointermove', event => {
      if (dragging < 0) return;
      const p = pointerPosition(event);
      const band = state.bands[dragging];
      band.freq_hz = Math.round(frequencyForX(p.x, p.width));
      if (typeInfo(band.type).usesGain) {
        band.gain_db = Math.round(dbForY(p.y, p.height) * 10) / 10;
      }
      syncRow(dragging);
      draw();
      onChange(snapshot());
    });

    function endDrag(event) {
      if (dragging < 0) return;
      dragging = -1;
      if (canvas.hasPointerCapture && canvas.hasPointerCapture(event.pointerId)) {
        canvas.releasePointerCapture(event.pointerId);
      }
      onChange(snapshot());
    }
    canvas.addEventListener('pointerup', endDrag);
    canvas.addEventListener('pointercancel', endDrag);

    function buildTable() {
      tableBody.innerHTML = '';
      // Every handler reaches its band through state.bands[index], never
      // through a captured object: load() replaces the whole array, and a
      // handler holding the old element would keep writing somewhere nothing
      // reads. That failure is silent -- the table looks like it works and the
      // curve that gets sent is the one from before the load.
      state.bands.forEach((_unused, index) => {
        const row = document.createElement('tr');
        row.dataset.index = String(index);

        const number = document.createElement('td');
        number.textContent = String(index + 1);
        row.appendChild(number);

        const typeCell = document.createElement('td');
        const select = document.createElement('select');
        TYPES.forEach(entry => {
          const option = document.createElement('option');
          option.value = String(entry.value);
          option.textContent = entry.label;
          select.appendChild(option);
        });
        select.addEventListener('change', () => {
          state.bands[index].type = Number(select.value);
          syncRow(index);
          draw();
          onChange(snapshot());
        });
        typeCell.appendChild(select);
        row.appendChild(typeCell);

        const makeNumber = (min, max, step, field) => {
          const cell = document.createElement('td');
          const input = document.createElement('input');
          input.type = 'number';
          input.min = String(min);
          input.max = String(max);
          input.step = String(step);
          input.addEventListener('change', () => {
            const value = clamp(Number(input.value), min, max);
            input.value = String(value);
            state.bands[index][field] = value;
            draw();
            onChange(snapshot());
          });
          cell.appendChild(input);
          row.appendChild(cell);
          return input;
        };

        const freq = makeNumber(MIN_HZ, MAX_HZ, 1, 'freq_hz');
        const gain = makeNumber(MIN_DB, MAX_DB, 0.1, 'gain_db');
        const q = makeNumber(0.1, 18, 0.01, 'q');

        row._controls = {select, freq, gain, q};
        row.addEventListener('click', () => {
          selected = index;
          draw();
        });
        tableBody.appendChild(row);
      });
      state.bands.forEach((_, index) => syncRow(index));
    }

    function syncRow(index) {
      const row = tableBody.children[index];
      if (!row || !row._controls) return;
      const band = state.bands[index];
      const info = typeInfo(band.type);
      row._controls.select.value = String(band.type);
      row._controls.freq.value = String(Math.round(band.freq_hz));
      row._controls.gain.value = String(Math.round(band.gain_db * 10) / 10);
      row._controls.q.value = String(Math.round(band.q * 100) / 100);
      // Greyed rather than hidden: the value is still what will be sent, and a
      // disappearing field makes the table jump while editing.
      row._controls.gain.disabled = !info.usesGain;
      row._controls.q.disabled = !info.usesQ;
      row._controls.freq.disabled = Number(band.type) === 0;
    }

    function syncTable() {
      state.bands.forEach((_, index) => syncRow(index));
    }

    function snapshot() {
      return {
        bypass: state.bypass,
        fade_ms: state.fade_ms,
        bands: state.bands.map(band => ({
          type: Number(band.type),
          freq_hz: Number(band.freq_hz),
          gain_db: Number(band.gain_db),
          q: Number(band.q)
        }))
      };
    }

    function load(data) {
      if (data && Array.isArray(data.bands) && data.bands.length === BANDS) {
        state.bands = data.bands.map((band, index) => ({
          type: Number(band.type) || 0,
          freq_hz: Number(band.freq_hz) || defaultFrequency(index),
          gain_db: Number(band.gain_db) || 0,
          q: Number(band.q) || 0.707
        }));
      }
      if (data && typeof data.bypass === 'boolean') state.bypass = data.bypass;
      if (data && Number(data.fade_ms) > 0) state.fade_ms = Number(data.fade_ms);
      syncTable();
      draw();
    }

    function reset() {
      state.bands = flatBands();
      syncTable();
      draw();
      onChange(snapshot());
    }

    buildTable();
    draw();
    global.addEventListener('resize', draw);

    return {
      load,
      reset,
      draw,
      snapshot,
      get bypass() { return state.bypass; },
      set bypass(value) { state.bypass = Boolean(value); draw(); },
      get fadeMs() { return state.fade_ms; },
      set fadeMs(value) { state.fade_ms = Number(value); }
    };
  }

  global.SoundkaEq = {
    BANDS,
    TYPES,
    createEditor,
    flatBands,
    combinedDb
  };
})(window);
