/*
 * nowplaying — wires visualiser.js to Home Assistant's media_player state.
 *
 * HA is the source because it is the one thing every endpoint here can reach and it
 * already knows what is playing in every room. That also means a panel can show ANOTHER
 * room's music, which is the normal case and the reason the synthetic tier exists.
 */
"use strict";

const params = new URLSearchParams(location.search);
const HA = (params.get("ha") || "").replace(/\/$/, "");
const TOKEN = params.get("token") || "";
const ENTITY = params.get("entity") || "";
// How often to ask HA. Two seconds is plenty: the ring animates locally from position,
// so polling only has to catch track CHANGES, not drive the animation.
const POLL_MS = Number(params.get("poll") || 2000);

const $ = (id) => document.getElementById(id);

if (!HA || !TOKEN || !ENTITY) {
  $("error").hidden = false;
  $("error").textContent =
    "Not configured — needs ?ha=&token=&entity= in the URL. See nowplaying.html.";
  throw new Error("nowplaying: missing ?ha=/&token=/&entity=");
}

const visualiser = new MediaVisualiser($("ring"), { background: "#0a0510" });
visualiser.start();

let currentTrackKey = "";
// Position is interpolated between polls rather than asked for constantly: HA reports
// media_position with the timestamp it was measured at, so the honest current position
// is that value plus the time since. Polling for it instead would make the progress arc
// tick in two-second jumps.
let positionBase = { ms: 0, at: Date.now(), playing: false };

function haGet(path) {
  return fetch(`${HA}${path}`, { headers: { Authorization: `Bearer ${TOKEN}` } }).then((res) => {
    if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
    return res.json();
  });
}

function poll() {
  haGet(`/api/states/${encodeURIComponent(ENTITY)}`)
    .then((state) => {
      $("error").hidden = true;
      render(state);
    })
    .catch((err) => {
      $("error").hidden = false;
      $("error").textContent = `Home Assistant: ${err.message}`;
    })
    .finally(() => setTimeout(poll, POLL_MS));
}

function render(state) {
  const a = state.attributes || {};
  const playing = state.state === "playing";
  const key = `${a.media_title || ""}|${a.media_artist || ""}|${a.entity_picture || ""}`;

  $("title").textContent = a.media_title || (state.state === "off" ? "Nothing playing" : "—");
  $("artist").textContent = [a.media_artist, a.media_album_name].filter(Boolean).join(" — ");

  // HA gives media_position together with media_position_updated_at, which is what makes
  // interpolation correct rather than a guess.
  const updatedAt = a.media_position_updated_at ? Date.parse(a.media_position_updated_at) : Date.now();
  positionBase = { ms: (a.media_position || 0) * 1000, at: updatedAt, playing };

  if (key !== currentTrackKey) {
    currentTrackKey = key;
    loadArtwork(a.entity_picture);
    loadLyrics(a);
  }

  visualiser.setTrack({
    duration_ms: (a.media_duration || 0) * 1000,
    position_ms: currentPosition(),
    playing,
    // Some sources expose a tempo; most do not. Null means the synthetic tier uses its
    // default rate rather than pretending to know the BPM.
    tempo: a.media_tempo || null,
  });

  $("tier-note").textContent =
    visualiser.tier === "reactive"
      ? ""
      : "visual rhythm — not an audio analysis";
}

function currentPosition() {
  if (!positionBase.playing) return positionBase.ms;
  return positionBase.ms + (Date.now() - positionBase.at);
}

// Keep the arc and the lyric highlight moving between polls.
setInterval(() => {
  visualiser.setTrack({ position_ms: currentPosition() });
  highlightLyric();
}, 250);

function loadArtwork(picture) {
  const cover = $("cover");
  if (!picture) {
    cover.hidden = true;
    $("cover-fallback").hidden = false;
    return;
  }
  const url = picture.startsWith("http") ? picture : `${HA}${picture}`;
  const image = new Image();
  // Needed for paletteFrom(): without it the canvas is tainted and getImageData throws,
  // which the SDK handles by keeping the default palette — this just makes the good
  // path possible when HA sends the header.
  image.crossOrigin = "anonymous";
  image.onload = () => {
    cover.src = image.src;
    cover.hidden = false;
    $("cover-fallback").hidden = true;
    visualiser.setArtwork(image);
  };
  image.onerror = () => {
    cover.hidden = true;
    $("cover-fallback").hidden = false;
  };
  image.src = url;
}

// --- lyrics ---------------------------------------------------------------------------
// ABSENT IS THE NORMAL CASE. Most tracks in most libraries have no lyrics, so the layout
// is designed for "no lyrics" with lyrics as the addition — not a gap where they would be.
let lyricLines = [];
let lyricPlain = "";

function loadLyrics(attributes) {
  lyricLines = [];
  lyricPlain = "";
  const raw = attributes.media_lyrics || attributes.lyrics || "";
  if (!raw) {
    $("lyrics").hidden = true;
    return;
  }
  lyricLines = MediaVisualiser.parseLrc(raw);
  if (lyricLines.length) {
    $("lyrics-lines").innerHTML = lyricLines
      .map((l, i) => `<p data-i="${i}">${escapeHtml(l.text)}</p>`)
      .join("");
  } else {
    // Plain text: shown, never auto-scrolled at a guessed rate. A guessed scroll is
    // wrong within ten seconds and stays wrong for the rest of the song.
    lyricPlain = raw;
    $("lyrics-lines").innerHTML = `<pre class="plain">${escapeHtml(raw)}</pre>`;
  }
  $("lyrics").hidden = false;
}

function highlightLyric() {
  if (!lyricLines.length) return;
  const index = MediaVisualiser.currentLyricIndex(lyricLines, currentPosition());
  const lines = $("lyrics-lines").children;
  for (let i = 0; i < lines.length; i++) {
    lines[i].classList.toggle("current", Number(lines[i].dataset.i) === index);
  }
  const active = $("lyrics-lines").querySelector(".current");
  if (active) active.scrollIntoView({ block: "center", behavior: "smooth" });
}

function escapeHtml(s) {
  return String(s ?? "").replace(/[&<>"']/g, (c) =>
    ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c])
  );
}

poll();
