/*
 * media-visualiser — a circular spectrum behind a now-playing screen, coloured from the
 * album art, with lyrics under the cover.
 *
 * Vendored, dependency-free, no build step — the same choice as digest-canvas-sdk and
 * canvas-sdk. Copied into each host that needs it rather than served from one place, so
 * a kiosk with no route to the container host still renders.
 *
 * THE TWO TIERS, WHICH ARE THE WHOLE DESIGN
 * ------------------------------------------
 * CAVA reads an audio stream. Most endpoints do not have one: a kitchen panel showing
 * what the LIVING ROOM is playing has no audio to analyse, and never will. A design
 * that assumes real audio works on one screen and shows a dead circle on the rest,
 * which is worse than not having it — a dead visualiser reads as broken, not as absent.
 *
 *   reactive  — audio is local. Real FFT, via WebAudio's AnalyserNode or a `cava -r`
 *               feed pushed in by the host's agent. Bars are the actual spectrum.
 *   synthetic — everywhere else. The ring breathes from track POSITION and tempo. It is
 *               a mood light, it is honest about being one (see `tier` on the object and
 *               the `data-tier` attribute on the canvas), and nobody watching from
 *               across a kitchen can tell.
 *
 * The synthetic tier must never claim to be the reactive one. The moment somebody
 * believes it is a spectrum, every bass drop it does not match becomes a bug report.
 */
"use strict";

(function (global) {
  const TAU = Math.PI * 2;

  // --- palette ---------------------------------------------------------------------
  /**
   * Dominant colours from an image, as [{r,g,b}]. Done on a 64x64 downscale because
   * this is a palette, not a photograph — full resolution costs time and changes
   * nothing.
   */
  function paletteFrom(image, count) {
    const size = 64;
    const canvas = document.createElement("canvas");
    canvas.width = canvas.height = size;
    const ctx = canvas.getContext("2d", { willReadFrequently: true });
    ctx.drawImage(image, 0, 0, size, size);

    let data;
    try {
      data = ctx.getImageData(0, 0, size, size).data;
    } catch (err) {
      // A cross-origin cover taints the canvas and getImageData throws. That is a
      // configuration problem (art served from another origin without CORS), not a
      // reason to have no visualiser — fall back to the default palette.
      return null;
    }

    // Bucket in a coarse RGB grid. 4 bits per channel is enough to group "the same
    // colour" without merging colours a person would call different.
    const buckets = new Map();
    for (let i = 0; i < data.length; i += 4) {
      const r = data[i], g = data[i + 1], b = data[i + 2];
      if (data[i + 3] < 128) continue;

      // REJECT NEAR-GREYS AND NEAR-BLACKS BEFORE RANKING. Album art is full of them,
      // and a naive palette from a dark cover is four indistinguishable dark greys —
      // i.e. a visualiser that looks switched off.
      const max = Math.max(r, g, b), min = Math.min(r, g, b);
      if (max < 40) continue;              // near-black
      if (max - min < 24) continue;        // near-grey: no hue to speak of

      const key = ((r >> 4) << 8) | ((g >> 4) << 4) | (b >> 4);
      const entry = buckets.get(key) || { r: 0, g: 0, b: 0, n: 0 };
      entry.r += r; entry.g += g; entry.b += b; entry.n += 1;
      buckets.set(key, entry);
    }

    const ranked = [...buckets.values()]
      .sort((a, b) => b.n - a.n)
      .slice(0, count || 4)
      .map((e) => ({ r: Math.round(e.r / e.n), g: Math.round(e.g / e.n), b: Math.round(e.b / e.n) }));

    return ranked.length ? ranked : null;
  }

  function relativeLuminance({ r, g, b }) {
    const f = (c) => {
      const v = c / 255;
      return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
    };
    return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
  }

  /**
   * Lift a colour until it clears the background it is drawn on.
   *
   * This is the step that gets skipped, and skipping it is why so many album-art
   * visualisers are invisible on dark covers: a visualiser you cannot see is the same
   * as no visualiser at all.
   */
  function ensureContrast(colour, backgroundLuminance, minRatio) {
    let { r, g, b } = colour;
    for (let i = 0; i < 24; i++) {
      const l = relativeLuminance({ r, g, b });
      const ratio = (Math.max(l, backgroundLuminance) + 0.05) / (Math.min(l, backgroundLuminance) + 0.05);
      if (ratio >= (minRatio || 3)) break;
      r = Math.min(255, Math.round(r * 1.12 + 8));
      g = Math.min(255, Math.round(g * 1.12 + 8));
      b = Math.min(255, Math.round(b * 1.12 + 8));
    }
    return { r, g, b };
  }

  const css = ({ r, g, b }, alpha) =>
    alpha === undefined ? `rgb(${r},${g},${b})` : `rgba(${r},${g},${b},${alpha})`;

  // --- the visualiser ----------------------------------------------------------------
  class MediaVisualiser {
    /**
     * @param {HTMLCanvasElement} canvas
     * @param {object} options  { bars, background, minContrast }
     */
    constructor(canvas, options) {
      this.canvas = canvas;
      this.ctx = canvas.getContext("2d");
      this.options = Object.assign({ bars: 96, background: "#0a0510", minContrast: 3 }, options || {});
      this.palette = [{ r: 192, g: 132, b: 252 }, { r: 255, g: 62, b: 200 }];
      this.levels = new Float32Array(this.options.bars);
      this.targets = new Float32Array(this.options.bars);
      this.tier = "synthetic";
      this.track = { position_ms: 0, duration_ms: 0, tempo: null, playing: false };
      this.analyser = null;
      this.running = false;
      this._resize();
      window.addEventListener("resize", () => this._resize());
    }

    _resize() {
      const rect = this.canvas.getBoundingClientRect();
      const dpr = Math.min(window.devicePixelRatio || 1, 2);
      this.canvas.width = Math.max(1, Math.round(rect.width * dpr));
      this.canvas.height = Math.max(1, Math.round(rect.height * dpr));
      this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      this.width = rect.width;
      this.height = rect.height;
    }

    /** Colour the ring from an already-loaded <img> of the cover. */
    setArtwork(image) {
      const found = paletteFrom(image, 4);
      if (!found) return false;
      const bg = relativeLuminance(this._backgroundRgb());
      this.palette = found.map((c) => ensureContrast(c, bg, this.options.minContrast));
      return true;
    }

    _backgroundRgb() {
      const hex = String(this.options.background).replace("#", "");
      return {
        r: parseInt(hex.slice(0, 2), 16) || 0,
        g: parseInt(hex.slice(2, 4), 16) || 0,
        b: parseInt(hex.slice(4, 6), 16) || 0,
      };
    }

    /** Reactive tier: drive the ring from a real WebAudio graph. */
    attachAudio(mediaElementOrStream) {
      try {
        const AudioContextCtor = global.AudioContext || global.webkitAudioContext;
        if (!AudioContextCtor) return false;
        const audioCtx = new AudioContextCtor();
        const source =
          mediaElementOrStream instanceof MediaStream
            ? audioCtx.createMediaStreamSource(mediaElementOrStream)
            : audioCtx.createMediaElementSource(mediaElementOrStream);
        const analyser = audioCtx.createAnalyser();
        analyser.fftSize = 512;
        analyser.smoothingTimeConstant = 0.7;
        source.connect(analyser);
        if (!(mediaElementOrStream instanceof MediaStream)) analyser.connect(audioCtx.destination);
        this.analyser = analyser;
        this.bins = new Uint8Array(analyser.frequencyBinCount);
        this.tier = "reactive";
        this.canvas.dataset.tier = "reactive";
        return true;
      } catch (err) {
        // Autoplay policy, a cross-origin element, no AudioContext — all end here, and
        // all mean the same thing: fall back to synthetic rather than showing nothing.
        return false;
      }
    }

    /**
     * Reactive tier, remote feed: levels pushed in by the host's agent from `cava -r`.
     * An array of 0..1 values. Same tier as WebAudio because it is the same claim —
     * these numbers came from the actual audio.
     */
    pushLevels(levels) {
      if (!levels || !levels.length) return;
      this.tier = "reactive";
      this.canvas.dataset.tier = "reactive";
      for (let i = 0; i < this.targets.length; i++) {
        const source = levels[Math.floor((i / this.targets.length) * levels.length)];
        this.targets[i] = Math.max(0, Math.min(1, source || 0));
      }
    }

    /** Track state, for the synthetic tier and for the progress arc. */
    setTrack(track) {
      this.track = Object.assign(this.track, track || {});
      if (!this.analyser && this.tier !== "reactive") {
        this.canvas.dataset.tier = "synthetic";
      }
    }

    start() {
      if (this.running) return;
      this.running = true;
      const frame = (now) => {
        if (!this.running) return;
        this._step(now);
        requestAnimationFrame(frame);
      };
      requestAnimationFrame(frame);
    }

    stop() {
      this.running = false;
    }

    _step(now) {
      const n = this.targets.length;

      if (this.analyser) {
        this.analyser.getByteFrequencyData(this.bins);
        for (let i = 0; i < n; i++) {
          // Log-ish bin mapping: linear FFT bins put almost everything in the first
          // eighth of the ring, which looks like a fault rather than like music.
          const t = i / n;
          const bin = Math.floor(Math.pow(t, 1.7) * (this.bins.length - 1));
          this.targets[i] = this.bins[bin] / 255;
        }
      } else if (this.tier !== "reactive") {
        // SYNTHETIC. Derived from position and tempo — a slow travelling wave with a
        // beat-rate pulse. Deliberately smooth: anything jittery invites the comparison
        // with real audio that this tier cannot win.
        const bpm = this.track.tempo || 100;
        const beat = (now / 1000) * (bpm / 60);
        const pulse = 0.5 + 0.5 * Math.sin(beat * TAU);
        const energy = this.track.playing ? 0.45 + 0.35 * pulse : 0.06;
        for (let i = 0; i < n; i++) {
          const phase = (i / n) * TAU * 3 + now / 1400;
          this.targets[i] = Math.max(0, energy * (0.55 + 0.45 * Math.sin(phase)));
        }
      }

      // One smoother for both tiers, so switching between them does not jump.
      for (let i = 0; i < n; i++) {
        this.levels[i] += (this.targets[i] - this.levels[i]) * 0.22;
      }
      this._draw();
    }

    _draw() {
      const { ctx, width, height } = this;
      const cx = width / 2;
      const cy = height / 2;
      const inner = Math.min(width, height) * 0.30;
      const maxBar = Math.min(width, height) * 0.17;

      ctx.clearRect(0, 0, width, height);

      const n = this.levels.length;
      for (let i = 0; i < n; i++) {
        const angle = (i / n) * TAU - Math.PI / 2;
        const level = this.levels[i];
        const length = 2 + level * maxBar;
        const colour = this.palette[i % this.palette.length];

        ctx.strokeStyle = css(colour, 0.25 + level * 0.75);
        ctx.lineWidth = Math.max(2, (TAU * inner) / n - 2);
        ctx.lineCap = "round";
        ctx.beginPath();
        ctx.moveTo(cx + Math.cos(angle) * inner, cy + Math.sin(angle) * inner);
        ctx.lineTo(cx + Math.cos(angle) * (inner + length), cy + Math.sin(angle) * (inner + length));
        ctx.stroke();
      }

      // Progress as a thin arc just inside the ring — the one piece of information a
      // now-playing screen is actually asked for from across a room.
      const { position_ms: pos, duration_ms: dur } = this.track;
      if (dur > 0) {
        ctx.strokeStyle = css(this.palette[0], 0.9);
        ctx.lineWidth = 3;
        ctx.lineCap = "butt";
        ctx.beginPath();
        ctx.arc(cx, cy, inner - 10, -Math.PI / 2, -Math.PI / 2 + TAU * Math.min(1, pos / dur));
        ctx.stroke();
      }
    }
  }

  // --- lyrics --------------------------------------------------------------------------
  /**
   * Parse an LRC document into [{ms, text}]. Returns [] for anything that is not LRC,
   * which is how the caller tells synced from plain: if this comes back empty and there
   * was text, the text is plain and must NOT be auto-scrolled to a guessed rate — that
   * is wrong within ten seconds and stays wrong.
   */
  function parseLrc(text) {
    const lines = [];
    const re = /\[(\d+):(\d+)(?:[.:](\d+))?\]/g;
    for (const raw of String(text || "").split(/\r?\n/)) {
      let match;
      const stamps = [];
      re.lastIndex = 0;
      while ((match = re.exec(raw)) !== null) {
        const centis = match[3] ? parseInt(match[3].padEnd(3, "0").slice(0, 3), 10) : 0;
        stamps.push(parseInt(match[1], 10) * 60000 + parseInt(match[2], 10) * 1000 + centis);
      }
      const body = raw.replace(re, "").trim();
      if (stamps.length && body) stamps.forEach((ms) => lines.push({ ms, text: body }));
    }
    return lines.sort((a, b) => a.ms - b.ms);
  }

  /** Index of the line that should be highlighted at `positionMs`, or -1. */
  function currentLyricIndex(lines, positionMs) {
    let index = -1;
    for (let i = 0; i < lines.length; i++) {
      if (lines[i].ms <= positionMs) index = i;
      else break;
    }
    return index;
  }

  global.MediaVisualiser = MediaVisualiser;
  global.MediaVisualiser.paletteFrom = paletteFrom;
  global.MediaVisualiser.ensureContrast = ensureContrast;
  global.MediaVisualiser.parseLrc = parseLrc;
  global.MediaVisualiser.currentLyricIndex = currentLyricIndex;
})(typeof window !== "undefined" ? window : globalThis);
