// ============ TRACK ANALYZER ============
// Spectral-flux onset detection + tempo estimation, with IndexedDB caching.
//
// Why not the old detectBPM in audio-engine.jsx? That one autocorrelates raw
// energy across the whole track and tends to lock onto sub-beat transients
// (hi-hats, sweeps, vocal sibilance) when the kick isn't dominant. Spectral
// flux — sum of POSITIVE bin-to-bin magnitude deltas — emphasises the moment
// new energy *appears* in the spectrum, which is what the human ear hears
// as a "beat". It's the same primitive aubio, librosa, and Mixxx use.
//
// API:
//   const result = await analyzeTrack(audioBuffer, trackId, onProgress);
//   // result = { bpm, firstBeat, beatGrid: [t0, t1, ...], confidence }
//
// Progress callback fires with values 0..1 during the analysis (lets the UI
// show a progress badge instead of a frozen "loading…").

(function () {
  // -------- IndexedDB cache --------
  const DB_NAME = 'djanything-analysis-v2';
  const DB_VERSION = 1;
  const ANALYSIS_VERSION = 'beatgrid-2026-04-28';
  const STORE = 'tracks';

  let _dbPromise = null;
  function db() {
    if (_dbPromise) return _dbPromise;
    _dbPromise = new Promise((resolve, reject) => {
      const req = indexedDB.open(DB_NAME, DB_VERSION);
      req.onupgradeneeded = () => {
        const d = req.result;
        if (!d.objectStoreNames.contains(STORE)) d.createObjectStore(STORE, { keyPath: 'key' });
      };
      req.onsuccess = () => resolve(req.result);
      req.onerror = () => reject(req.error);
    });
    return _dbPromise;
  }

  async function cacheGet(key) {
    try {
      const d = await db();
      return await new Promise((resolve, reject) => {
        const tx = d.transaction(STORE, 'readonly');
        const req = tx.objectStore(STORE).get(key);
        req.onsuccess = () => resolve(req.result?.value || null);
        req.onerror = () => reject(req.error);
      });
    } catch { return null; }
  }
  async function cachePut(key, value) {
    try {
      const d = await db();
      await new Promise((resolve, reject) => {
        const tx = d.transaction(STORE, 'readwrite');
        tx.objectStore(STORE).put({ key, value, ts: Date.now() });
        tx.oncomplete = resolve;
        tx.onerror = () => reject(tx.error);
      });
    } catch {}
  }

  // -------- Buffer fingerprint (for cache key) --------
  // Hash the first 32k samples of channel 0 + duration. Fast (sub-ms) and
  // collision-resistant enough for our purposes.
  function fingerprint(buffer) {
    const data = buffer.getChannelData(0);
    const len = Math.min(data.length, 32768);
    let h1 = 0x811c9dc5, h2 = 0xdeadbeef;
    for (let i = 0; i < len; i += 16) {
      const v = Math.floor(data[i] * 32767) | 0;
      h1 = Math.imul(h1 ^ v, 0x01000193) >>> 0;
      h2 = Math.imul(h2 ^ v, 0x85ebca6b) >>> 0;
    }
    const dur = Math.round(buffer.duration * 1000);
    return `${ANALYSIS_VERSION}_fp_${h1.toString(36)}_${h2.toString(36)}_${dur}`;
  }

  // -------- Onset envelope --------
  // Compute a frame-rate (~86 fps @ 44.1kHz) onset-strength signal:
  //   1. Downmix to mono.
  //   2. Hop FFTs (size 2048, hop 512).
  //   3. For each frame, sum positive log-magnitude deltas vs previous frame.
  //   4. Normalise + half-wave rectify.
  // Result: a 1D float array where peaks correspond to perceptual onsets.
  function computeOnsetEnvelope(buffer, onProgress) {
    const sr = buffer.sampleRate;
    const fftSize = 2048;
    const hop = 512;
    const frameRate = sr / hop;

    // Downmix to mono
    const ch0 = buffer.getChannelData(0);
    const ch1 = buffer.numberOfChannels > 1 ? buffer.getChannelData(1) : null;
    const N = ch0.length;
    const mono = new Float32Array(N);
    if (ch1) for (let i = 0; i < N; i++) mono[i] = (ch0[i] + ch1[i]) * 0.5;
    else mono.set(ch0);

    // Hann window
    const win = new Float32Array(fftSize);
    for (let i = 0; i < fftSize; i++) win[i] = 0.5 - 0.5 * Math.cos((2 * Math.PI * i) / (fftSize - 1));

    const numFrames = Math.max(0, Math.floor((N - fftSize) / hop));
    const onset = new Float32Array(numFrames);
    const lowOnset = new Float32Array(numFrames);
    let prevMag = new Float32Array(fftSize / 2);

    // Frequency-band weighting. Spectral flux summed across all bins gives
    // equal weight to hi-hats, claps, and kicks — but for sync we want the
    // detector to LOCK onto the kick (the actual beat in 4-on-the-floor),
    // not the off-beat percussion. Weight bins by a band-pass profile that
    // emphasises 50-200 Hz (kick fundamental + first harmonic) and de-emphasises
    // the brittle high end. Bin k corresponds to k * sr / fftSize Hz.
    const half = fftSize / 2;
    const binWeight = new Float32Array(half);
    for (let k = 1; k < half; k++) {
      const hz = k * sr / fftSize;
      let w;
      if (hz < 30) w = 0.1;            // sub-rumble: ignore
      else if (hz < 200) w = 1.0;      // kick band: full weight
      else if (hz < 500) w = 0.6;      // low-mids: snare body
      else if (hz < 2000) w = 0.3;     // mids: noisy, downweight
      else if (hz < 5000) w = 0.2;     // upper-mids
      else w = 0.1;                    // hats/cymbals: minimal
      binWeight[k] = w;
    }

    // Reusable buffers for radix-2 FFT (in-place)
    const re = new Float32Array(fftSize);
    const im = new Float32Array(fftSize);

    let lastReport = 0;
    for (let f = 0; f < numFrames; f++) {
      const off = f * hop;
      for (let i = 0; i < fftSize; i++) {
        re[i] = mono[off + i] * win[i];
        im[i] = 0;
      }
      fftInPlace(re, im, fftSize);
      let flux = 0;
      let lowFlux = 0;
      for (let k = 1; k < half; k++) {
        const hz = k * sr / fftSize;
        const mag = Math.sqrt(re[k] * re[k] + im[k] * im[k]);
        // Log compression — mirrors human loudness perception
        const logMag = Math.log1p(mag * 100);
        const d = logMag - prevMag[k];
        if (d > 0) {
          flux += d * binWeight[k];
          if (hz >= 35 && hz <= 220) lowFlux += d;
        }
        prevMag[k] = logMag;
      }
      onset[f] = flux;
      lowOnset[f] = lowFlux;
      if (onProgress && f - lastReport > 200) {
        onProgress(f / numFrames * 0.7); // first 70% of the budget
        lastReport = f;
      }
    }

    // Normalise to [0, 1]
    const normalize = (arr) => {
      let max = 0;
      for (let i = 0; i < arr.length; i++) if (arr[i] > max) max = arr[i];
      if (max > 0) for (let i = 0; i < arr.length; i++) arr[i] /= max;
      return max;
    };
    const lowMax = normalize(lowOnset);
    normalize(onset);

    return { onset, lowOnset, lowUsable: lowMax > 1e-7, frameRate };
  }

  // -------- In-place radix-2 FFT (Cooley-Tukey) --------
  // Standard implementation; ~2× slower than WASM but zero deps and runs
  // an entire 4-min track in ~1.5s on a modern laptop.
  function fftInPlace(re, im, n) {
    // Bit-reverse permutation
    let j = 0;
    for (let i = 1; i < n; i++) {
      let bit = n >> 1;
      for (; j & bit; bit >>= 1) j ^= bit;
      j ^= bit;
      if (i < j) {
        let tr = re[i]; re[i] = re[j]; re[j] = tr;
        let ti = im[i]; im[i] = im[j]; im[j] = ti;
      }
    }
    for (let len = 2; len <= n; len <<= 1) {
      const ang = -2 * Math.PI / len;
      const wlr = Math.cos(ang), wli = Math.sin(ang);
      for (let i = 0; i < n; i += len) {
        let wr = 1, wi = 0;
        for (let k = 0; k < len / 2; k++) {
          const a = i + k, b = i + k + len / 2;
          const tr = wr * re[b] - wi * im[b];
          const ti = wr * im[b] + wi * re[b];
          re[b] = re[a] - tr; im[b] = im[a] - ti;
          re[a] = re[a] + tr; im[a] = im[a] + ti;
          const nwr = wr * wlr - wi * wli;
          wi = wr * wli + wi * wlr;
          wr = nwr;
        }
      }
    }
  }

  function sampleOnset(onset, x) {
    const i = Math.floor(x);
    if (i < 0 || i >= onset.length) return 0;
    const f = x - i;
    const a = onset[i] || 0;
    const b = onset[i + 1] || a;
    return a * (1 - f) + b * f;
  }

  function scoreBeatComb(onset, frameRate, bpm) {
    const beatFrames = frameRate * 60 / bpm;
    const beats = Math.floor(onset.length / beatFrames) - 1;
    if (beats < 8) return { score: 0, phase: 0 };

    let bestScore = -Infinity;
    let bestPhase = 0;
    const phaseStep = Math.max(frameRate * 0.005, beatFrames / 96); // ~5ms or finer

    for (let phase = 0; phase < beatFrames; phase += phaseStep) {
      let score = 0;
      for (let b = 0; b < beats; b++) {
        const x = phase + b * beatFrames;
        // A narrow window forgives tiny detector jitter without letting hats
        // half a beat away win the phase.
        score += Math.max(
          sampleOnset(onset, x),
          sampleOnset(onset, x - 1) * 0.72,
          sampleOnset(onset, x + 1) * 0.72,
        );
      }
      score /= beats;
      if (score > bestScore) {
        bestScore = score;
        bestPhase = phase;
      }
    }

    return { score: bestScore, phase: bestPhase };
  }

  function refineTempo(onset, frameRate, bpmGuess) {
    const prior = (bpm) => {
      const d = (bpm - 122) / 34;
      return Math.exp(-0.5 * d * d);
    };
    const score = (bpm) => {
      const r = scoreBeatComb(onset, frameRate, bpm);
      return { ...r, bpm, score: r.score * (0.82 + 0.18 * prior(bpm)) };
    };

    let best = score(bpmGuess);
    const coarseMin = Math.max(70, bpmGuess - 4);
    const coarseMax = Math.min(180, bpmGuess + 4);
    for (let bpm = coarseMin; bpm <= coarseMax; bpm += 0.05) {
      const r = score(bpm);
      if (r.score > best.score) best = r;
    }
    const fineMin = Math.max(70, best.bpm - 0.14);
    const fineMax = Math.min(180, best.bpm + 0.14);
    for (let bpm = fineMin; bpm <= fineMax; bpm += 0.01) {
      const r = score(bpm);
      if (r.score > best.score) best = r;
    }
    return best;
  }

  // -------- Tempo estimation via comb-filter on autocorrelation --------
  // Given the onset envelope, find which BPM in [70, 180] produces the
  // strongest comb-filter response. We bias toward common dance tempos
  // (110-135) using a soft Gaussian prior.
  function estimateTempo(onset, frameRate) {
    const minBpm = 70, maxBpm = 180;
    const minLag = Math.round(frameRate * 60 / maxBpm);
    const maxLag = Math.round(frameRate * 60 / minBpm);

    // Autocorrelation up to maxLag
    const acf = new Float32Array(maxLag + 1);
    for (let lag = minLag; lag <= maxLag; lag++) {
      let s = 0;
      const lim = onset.length - lag;
      for (let i = 0; i < lim; i++) s += onset[i] * onset[i + lag];
      acf[lag] = s;
    }

    // Gaussian prior centred at 122 BPM, σ=30 — gentle, doesn't clobber
    // genuinely fast/slow tracks but breaks ties toward dance music.
    const prior = (bpm) => {
      const d = (bpm - 122) / 30;
      return Math.exp(-0.5 * d * d);
    };

    let bestLag = minLag, bestScore = -Infinity;
    for (let lag = minLag; lag <= maxLag; lag++) {
      const bpm = frameRate * 60 / lag;
      const score = acf[lag] * (0.5 + 0.5 * prior(bpm));
      if (score > bestScore) { bestScore = score; bestLag = lag; }
    }

    let bpm = frameRate * 60 / bestLag;

    // Octave correction: if half/double also has strong support and is
    // closer to dance range, pick the better one.
    const halfLag = bestLag * 2, doubleLag = Math.round(bestLag / 2);
    const candidates = [bestLag];
    if (halfLag <= maxLag) candidates.push(halfLag);
    if (doubleLag >= minLag) candidates.push(doubleLag);
    let bestC = bestLag, bestCScore = -Infinity;
    for (const c of candidates) {
      const b = frameRate * 60 / c;
      const score = (acf[c] || 0) * prior(b);
      if (score > bestCScore) { bestCScore = score; bestC = c; }
    }
    bpm = frameRate * 60 / bestC;
    const refined = refineTempo(onset, frameRate, bpm);
    bpm = refined.bpm;

    // Confidence: ratio of best score to mean
    let mean = 0; for (let lag = minLag; lag <= maxLag; lag++) mean += acf[lag];
    mean /= (maxLag - minLag + 1);
    const confidence = Math.min(1, Math.max(0, (bestScore - mean) / (bestScore + 1e-9)));

    return { bpm, confidence, bestLag: frameRate * 60 / bpm };
  }

  // -------- Phase estimation: where does the first downbeat sit? --------
  // Try every candidate phase offset (in frames) within one beat; the
  // winning phase is the one that maximises the sum of onset values
  // landing on hypothetical beats.
  function estimatePhase(onset, frameRate, bpm) {
    const beatFrames = frameRate * 60 / bpm;
    const numBeats = Math.floor(onset.length / beatFrames) - 1;
    if (numBeats < 4) return { firstBeat: 0, beatGrid: [] };

    let bestPhase = 0, bestSum = -Infinity;
    const step = Math.max(frameRate * 0.005, beatFrames / 96);
    for (let phase = 0; phase < beatFrames; phase += step) {
      let sum = 0;
      for (let b = 0; b < numBeats; b++) {
        const idx = phase + b * beatFrames;
        sum += Math.max(
          sampleOnset(onset, idx),
          sampleOnset(onset, idx - 1) * 0.72,
          sampleOnset(onset, idx + 1) * 0.72,
        );
      }
      if (sum > bestSum) { bestSum = sum; bestPhase = phase; }
    }

    const firstBeat = bestPhase / frameRate;
    const beatGrid = [];
    const beatSec = 60 / bpm;
    for (let b = 0; b < numBeats; b++) beatGrid.push(firstBeat + b * beatSec);
    return { firstBeat, beatGrid };
  }

  // -------- Public API --------
  async function analyzeTrack(buffer, trackId, onProgress) {
    if (!buffer) return null;
    const key = fingerprint(buffer);
    const cached = await cacheGet(key);
    if (cached) {
      if (onProgress) onProgress(1);
      return { ...cached, fromCache: true };
    }

    // Yield to UI before heavy work
    await new Promise(r => setTimeout(r, 0));

    const { onset, lowOnset, lowUsable, frameRate } = computeOnsetEnvelope(buffer, onProgress);
    if (onProgress) onProgress(0.75);
    await new Promise(r => setTimeout(r, 0));

    const { bpm, confidence } = estimateTempo(onset, frameRate);
    if (onProgress) onProgress(0.9);
    await new Promise(r => setTimeout(r, 0));

    const { firstBeat, beatGrid } = estimatePhase(lowUsable ? lowOnset : onset, frameRate, bpm);
    if (onProgress) onProgress(1);

    const result = {
      bpm: Math.round(bpm * 100) / 100,
      firstBeat,
      beatGrid: beatGrid.slice(0, 256), // keep cache lean — 256 beats is plenty
      confidence,
      fromCache: false,
    };
    await cachePut(key, result);
    return result;
  }

  window.TrackAnalyzer = { analyzeTrack };
})();
