import { useEffect, useRef } from 'react';

interface CenteredWaveformProps {
  sourceNode: AudioNode;
  /** Canvas size in CSS pixels. */
  width?: number;
  height?: number;
  barWidth?: number;
  gap?: number;
  /** Bars are drawn with currentColor — set a text-* class to color them. */
  className?: string;
}

// Voice band fanned out from the center bar (lows) to the edges (highs).
const MIN_FREQ_HZ = 100;
const MAX_FREQ_HZ = 4500;

// Analyser dB range mapped onto bar height; the defaults (-100/-30) waste
// most of the range on inaudible levels.
const MIN_DB = -75;
const MAX_DB = -25;

// Per-bar asymmetric smoothing time constants.
const ATTACK_MS = 40;
const RELEASE_MS = 220;

// Edge bars keep a fraction of their response so the whole row stays alive
// instead of only the middle moving.
const EDGE_RESPONSE = 0.3;

/**
 * Centered live voice indicator, like a meeting app's "speaking" glyph.
 * Bars fan out symmetrically from the middle: the center tracks the low
 * frequencies where voice energy lives, the edges track the highs, and a
 * cosine envelope tapers the response so the shape blooms from the center
 * while someone speaks and settles into a dot line in silence.
 *
 * Renders on canvas with an rAF loop and no React state. No reduced-motion
 * branch: the animation is signal-bearing audio feedback, not decoration,
 * and only runs while audio is being captured or played.
 */
export function CenteredWaveform({
  sourceNode,
  width = 168,
  height = 56,
  barWidth = 4,
  gap = 4,
  className,
}: CenteredWaveformProps) {
  const canvasRef = useRef<HTMLCanvasElement | null>(null);

  useEffect(() => {
    const canvasEl = canvasRef.current;
    const ctx2d = canvasEl?.getContext('2d');
    if (!canvasEl || !ctx2d) return;
    // Rebind post-guard so the narrowed types carry into the closures below.
    const canvas = canvasEl;
    const drawCtx = ctx2d;

    const { analyser, detach } = attachAnalyser(sourceNode, 512);
    analyser.smoothingTimeConstant = 0.8;
    analyser.minDecibels = MIN_DB;
    analyser.maxDecibels = MAX_DB;

    const bins = new Uint8Array(analyser.frequencyBinCount);

    const stride = barWidth + gap;
    // Odd count so one bar sits exactly at the center.
    let barCount = Math.floor((width + gap) / stride);
    if (barCount % 2 === 0) barCount -= 1;
    const half = (barCount - 1) / 2;

    // Map bar k (0 = center) to a frequency bin, log-spaced so the busy low
    // end of the voice spectrum spreads across several bars.
    const binHz = sourceNode.context.sampleRate / analyser.fftSize;
    const binIndex = new Uint16Array(half + 1);
    for (let k = 0; k <= half; k++) {
      const freq =
        MIN_FREQ_HZ *
        Math.pow(MAX_FREQ_HZ / MIN_FREQ_HZ, half === 0 ? 0 : k / half);
      binIndex[k] = Math.min(bins.length - 1, Math.round(freq / binHz));
    }

    const levels = new Float32Array(half + 1);

    const ensureBackingStore = createBackingStoreScaler(
      canvas,
      drawCtx,
      width,
      height,
    );
    // currentColor, cached and refreshed periodically (theme switches show
    // within 200ms) instead of paying a computed-style read every frame.
    let fillColor = '';
    let lastColorTime = 0;
    let lastTime = performance.now();
    let rafId = 0;

    function tick() {
      const now = performance.now();
      const dt = now - lastTime;
      lastTime = now;

      analyser.getByteFrequencyData(bins);

      for (let k = 0; k <= half; k++) {
        const raw = bins[binIndex[k]] / 255;
        const envelope =
          half === 0
            ? 1
            : EDGE_RESPONSE +
              (1 - EDGE_RESPONSE) * Math.cos(((k / half) * Math.PI) / 2);
        // Gamma keeps the floor quiet so silence reads as a dot line.
        const target = Math.pow(raw, 1.4) * envelope;

        // Asymmetric exponential smoothing (frame-rate independent)
        const timeConstant = target > levels[k] ? ATTACK_MS : RELEASE_MS;
        const alpha = 1 - Math.exp(-dt / timeConstant);
        levels[k] += alpha * (target - levels[k]);
      }

      if (ensureBackingStore() || !fillColor || now - lastColorTime > 200) {
        fillColor = getComputedStyle(canvas).color;
        lastColorTime = now;
      }
      drawCtx.clearRect(0, 0, width, height);
      drawCtx.fillStyle = fillColor;

      const centerX = width / 2 - barWidth / 2;
      drawCtx.beginPath();
      for (let k = 0; k <= half; k++) {
        const h = Math.max(barWidth, levels[k] * height);
        const y = (height - h) / 2;
        drawCtx.roundRect(centerX + k * stride, y, barWidth, h, barWidth / 2);
        if (k > 0) {
          drawCtx.roundRect(centerX - k * stride, y, barWidth, h, barWidth / 2);
        }
      }
      drawCtx.fill();

      rafId = requestAnimationFrame(tick);
    }

    rafId = requestAnimationFrame(tick);

    return () => {
      cancelAnimationFrame(rafId);
      detach();
    };
  }, [sourceNode, width, height, barWidth, gap]);

  return (
    <canvas ref={canvasRef} className={className} style={{ width, height }} />
  );
}

/**
 * Connects an AnalyserNode to the source for visualization.
 *
 * The analyser is routed to the destination via a silent gain node — without
 * this, Chromium suspends processing on disconnected audio graphs.
 */
function attachAnalyser(
  sourceNode: AudioNode,
  fftSize: number,
): { analyser: AnalyserNode; detach: () => void } {
  const ctx = sourceNode.context;
  const analyser = ctx.createAnalyser();
  analyser.fftSize = fftSize;
  sourceNode.connect(analyser);

  const silentGain = ctx.createGain();
  silentGain.gain.value = 0;
  analyser.connect(silentGain);
  silentGain.connect(ctx.destination);

  return {
    analyser,
    detach() {
      try {
        sourceNode.disconnect(analyser);
        analyser.disconnect(silentGain);
        silentGain.disconnect(ctx.destination);
      } catch {
        // Nodes may already be disconnected
      }
    },
  };
}

/**
 * Keeps the canvas backing store sized to width × height CSS pixels at the
 * current devicePixelRatio, rescaling if the DPR changes (window moved
 * between monitors). Call the returned function each frame; it returns true
 * when the store was (re)initialized so callers can refresh cached state.
 */
function createBackingStoreScaler(
  canvas: HTMLCanvasElement,
  drawCtx: CanvasRenderingContext2D,
  width: number,
  height: number,
): () => boolean {
  let dpr = 0;
  return () => {
    const currentDpr = window.devicePixelRatio || 1;
    if (currentDpr === dpr) return false;
    dpr = currentDpr;
    canvas.width = Math.round(width * dpr);
    canvas.height = Math.round(height * dpr);
    drawCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
    return true;
  };
}
