const crypto = require("crypto");
const fs = require("fs");
const path = require("path");
let overlayService = null;
let encryptSecret = null;
let decryptSecret = null;
let twitchRequest = null;

const PLUGIN_ID = "now_playing";
const FIELD_ID = "plugin.song_overlay.announcement_template";
const PROTOCOL_VERSION = 1;
const DATA_DIR = path.join(process.cwd(), "data", "plugins", PLUGIN_ID);
const COVER_DIR = path.join(DATA_DIR, "covers");
const MAX_COVER_BYTES = 600 * 1024;
const ALLOWED_EVENTS = new Set([
  "snapshot",
  "track_changed",
  "next",
  "previous",
  "play",
  "resume",
  "pause",
  "stop",
  "seek",
  "heartbeat"
]);
const ALLOWED_STATUSES = new Set(["playing", "paused", "stopped", "closed", "unknown"]);
const DEFAULTS = Object.freeze({
  enabled: true,
  display_mode: "always",
  display_seconds: 10,
  show_cover: true,
  show_title: true,
  show_artist: true,
  show_album: false,
  show_release_year: false,
  show_timeline: true,
  show_provider: false,
  scale: 1,
  entry_animation: "slide-up",
  exit_animation: "fade",
  animation_ms: 450,
  opacity: 0.92,
  transparency_scope: "background",
  background_mode: "cover-gradient",
  custom_color_primary: "#111827",
  custom_color_secondary: "#312e81",
  corner_radius: 18,
  announce_enabled: false,
  announce_twitch: true,
  announce_youtube: false,
  announce_only_when_live: true,
  announcement_template: "🎵 Now playing: {{plugin.song_overlay.song_name}} — {{plugin.song_overlay.artist}}",
  announcement_cooldown_seconds: 8,
  heartbeat_stale_seconds: 90,
  overlay_module_id: "",
  overlay_scene_id: "",
  overlay_id: ""
});

const renderClients = new Set();
const announcementTimers = new Map();
let pluginLogger = console;
let activeDb = null;
let activeSettings = null;
let activeClients = null;
let placeholderApi = null;
let webApi = null;
let twitchLiveCache = { checkedAt: 0, live: null };

module.exports = {
  id: PLUGIN_ID,
  init({ web, db, settings, twitchClient, youtubeClient, commandRouter, placeholders, logger }) {
    const express = require("express");
    overlayService = require("../../src/services/overlays");
    ({ encryptSecret, decryptSecret } = require("../../src/services/overlay-secrets"));
    ({ twitchRequest } = require("../../src/services/command-platform"));
    activeDb = db;
    activeSettings = settings;
    activeClients = { twitchClient, youtubeClient };
    placeholderApi = placeholders;
    webApi = web;
    pluginLogger = logger || console;

    fs.mkdirSync(COVER_DIR, { recursive: true });
    ensureTables(db);
    ensureDefaults(db);
    ensureRenderToken(db);
    registerPlaceholderSupport(placeholders);
    registerMusicCommands({ db, commandRouter });

    const router = web.createRouter();
    router.use("/assets", express.static(path.join(__dirname, "public"), {
      immutable: true,
      maxAge: "1h"
    }));

    router.get("/", async (req, res) => {
      if (!isAdmin(req.session?.user)) return renderDenied(res);
      const config = getConfig(db);
      const current = getPublicState(db, req);
      const overlayTargets = listOverlayTargets();
      const moduleStatus = findInstalledModule(config);
      return res.render(path.join(__dirname, "views", "admin.ejs"), {
        title: "Song Overlay",
        config,
        current,
        overlayTargets,
        moduleStatus,
        assetVersion: require("./plugin.json").version,
        placeholders: [
          "plugin.song_overlay.song_name",
          "plugin.song_overlay.artist",
          "plugin.song_overlay.album",
          "plugin.song_overlay.release_year",
          "plugin.song_overlay.link"
        ]
      });
    });

    router.post("/settings", async (req, res) => {
      if (!isAdmin(req.session?.user)) return renderDenied(res);
      try {
        const config = normalizeConfig(req.body || {});
        saveConfig(db, config);
        await refreshInstalledModule(req, config);
        req.session.flash = { type: "success", message: "Song Overlay settings saved." };
        broadcastState(db);
      } catch (error) {
        req.session.flash = { type: "error", message: error?.message || "Unable to save settings." };
      }
      return res.redirect(`/plugins/${PLUGIN_ID}`);
    });

    router.post("/overlay/install", (req, res) => {
      if (!isAdmin(req.session?.user)) return renderDenied(res);
      try {
        const sceneId = String(req.body.scene_id || "").trim();
        if (!sceneId) throw new Error("Choose a Lumi Overlay scene.");
        const overlayId = overlayIdForScene(sceneId);
        if (!overlayId) throw new Error("The selected Lumi Overlay scene no longer exists.");
        const config = getConfig(db);
        const renderUrl = buildRenderUrl(req, db, settings);
        const moduleId = overlayService.addModule(sceneId, {
          type: "web",
          name: "Song Overlay",
          config: {
            x: 50,
            y: 96,
            width: 42,
            height: 18,
            opacity: 1,
            anchor: "bottom-center",
            url: renderUrl,
            allowPointerEvents: false,
            autoRefresh: false,
            healthCheck: false,
            injectPageCss: false,
            forceTransparentBackground: true,
            cropTop: 0,
            cropRight: 0,
            cropBottom: 0,
            cropLeft: 0,
            zoom: 1,
            customCss: ""
          }
        });
        saveConfig(db, {
          ...config,
          overlay_id: overlayId,
          overlay_scene_id: sceneId,
          overlay_module_id: moduleId
        });
        req.session.flash = { type: "success", message: "Song Overlay was added to the selected Lumi Overlay scene." };
      } catch (error) {
        req.session.flash = { type: "error", message: error?.message || "Unable to add the overlay source." };
      }
      return res.redirect(`/plugins/${PLUGIN_ID}`);
    });

    router.post("/overlay/remove", (req, res) => {
      if (!isAdmin(req.session?.user)) return renderDenied(res);
      const config = getConfig(db);
      try {
        if (config.overlay_module_id && findInstalledModule(config)?.exists) {
          overlayService.deleteModule(config.overlay_module_id);
        }
        saveConfig(db, {
          ...config,
          overlay_id: "",
          overlay_scene_id: "",
          overlay_module_id: ""
        });
        req.session.flash = { type: "success", message: "The Song Overlay source was removed. Your plugin settings were kept." };
      } catch (error) {
        req.session.flash = { type: "error", message: error?.message || "Unable to remove the overlay source." };
      }
      return res.redirect(`/plugins/${PLUGIN_ID}`);
    });

    router.post("/test-announcement", async (req, res) => {
      if (!isAdmin(req.session?.user)) return renderDenied(res);
      try {
        const state = getStateRow(db) || sampleState();
        const results = await announceTrack({ db, state, force: true, reqUser: req.session.user });
        req.session.flash = {
          type: results.some((item) => item.success) ? "success" : "error",
          message: results.length
            ? results.map((item) => `${item.platform}: ${item.success ? "sent" : item.error}`).join(" · ")
            : "No announcement platforms are enabled."
        };
      } catch (error) {
        req.session.flash = { type: "error", message: error?.message || "Unable to test the announcement." };
      }
      return res.redirect(`/plugins/${PLUGIN_ID}`);
    });

    router.get("/api/companion/ping", requireCompanionDevice, (req, res) => {
      res.set("Cache-Control", "no-store");
      return res.json({
        ok: true,
        protocol_version: PROTOCOL_VERSION,
        plugin: PLUGIN_ID,
        server_time: Date.now()
      });
    });

    router.post("/api/companion/state", requireCompanionDevice, async (req, res) => {
      res.set("Cache-Control", "no-store");
      try {
        const normalized = normalizeIncomingEvent(withAuthenticatedDevice(req, req.body || {}));
        const previous = getStateRow(db);
        if (isStaleSequence(previous, normalized)) {
          return res.status(202).json({ accepted: false, duplicate: true, server_time: Date.now() });
        }
        const cover = storeCover(normalized.track?.cover);
        const next = applyIncomingEvent(previous, normalized, cover);
        saveStateRow(db, next);
        cleanupCovers(next.cover_hash);
        broadcastState(db);
        web.emitEvent?.("now-playing:changed", publicEventState(next), { scope: `plugin:${PLUGIN_ID}` });

        const changedTrack = Boolean(next.track_key && next.track_key !== previous?.track_key);
        if (changedTrack && next.playback_status === "playing") scheduleAnnouncement(db, next.track_key);
        return res.json({ accepted: true, server_time: Date.now(), sequence: next.sequence });
      } catch (error) {
        pluginLogger.warn?.("Rejected Companion song update", { error: error?.message || String(error) });
        return res.status(error?.status || 400).json({ accepted: false, error: error?.message || "Invalid song update." });
      }
    });

    router.get("/render/:token", (req, res) => {
      if (!validRenderToken(db, req.params.token)) return res.status(404).send("Overlay unavailable.");
      noStore(res);
      res.set("Content-Security-Policy", [
        "default-src 'none'",
        "img-src 'self' data:",
        "style-src 'self'",
        "script-src 'self'",
        "connect-src 'self'"
      ].join("; "));
      return res.render(path.join(__dirname, "views", "render.ejs"), {
        token: req.params.token,
        assetVersion: require("./plugin.json").version,
        initialJson: JSON.stringify(getPublicState(db, req, req.params.token)).replace(/</g, "\\u003c")
      });
    });

    router.get("/render/:token/state", (req, res) => {
      if (!validRenderToken(db, req.params.token)) return res.status(404).json({ available: false });
      noStore(res);
      allowTokenRenderAccess(res);
      return res.json(getPublicState(db, req, req.params.token));
    });

    router.get("/render/:token/events", (req, res) => {
      if (!validRenderToken(db, req.params.token)) return res.status(404).end();
      res.status(200);
      res.set({
        "Content-Type": "text/event-stream",
        "Cache-Control": "no-cache, no-store",
        Connection: "keep-alive",
        "X-Accel-Buffering": "no",
        "Access-Control-Allow-Origin": "*"
      });
      res.flushHeaders?.();
      const client = { res, token: req.params.token };
      renderClients.add(client);
      writeSse(res, "state", getPublicState(db, req, req.params.token));
      const heartbeat = setInterval(() => res.write(": keepalive\n\n"), 20000);
      req.on("close", () => {
        clearInterval(heartbeat);
        renderClients.delete(client);
      });
    });

    router.get("/render/:token/cover/:hash", (req, res) => {
      if (!validRenderToken(db, req.params.token)) return res.status(404).end();
      const state = getStateRow(db);
      if (!state?.cover_hash || !safeEqual(state.cover_hash, req.params.hash)) return res.status(404).end();
      const coverPath = coverPathFor(state.cover_hash, state.cover_mime);
      if (!coverPath || !fs.existsSync(coverPath)) return res.status(404).end();
      res.set("Cache-Control", "public, max-age=86400, immutable");
      res.set("X-Content-Type-Options", "nosniff");
      res.type(state.cover_mime || "image/jpeg");
      return res.sendFile(coverPath);
    });

    web.mount(`/plugins/${PLUGIN_ID}`, router, {
      label: "Song Overlay",
      role: "admin",
      section: "plugins"
    });

    return () => {
      for (const client of renderClients) {
        try { client.res.end(); } catch {}
      }
      renderClients.clear();
      for (const timer of announcementTimers.values()) clearTimeout(timer);
      announcementTimers.clear();
      commandRouter?.clearCommands?.(PLUGIN_ID);
    };
  }
};

function ensureTables(db) {
  db.exec(`
    CREATE TABLE IF NOT EXISTS now_playing_state (
      singleton_id INTEGER PRIMARY KEY CHECK (singleton_id = 1),
      protocol_version INTEGER NOT NULL DEFAULT 1,
      provider TEXT,
      device_id TEXT,
      session_id TEXT,
      sequence INTEGER NOT NULL DEFAULT 0,
      event_type TEXT,
      playback_status TEXT,
      track_key TEXT,
      title TEXT,
      artist TEXT,
      album TEXT,
      release_year TEXT,
      link TEXT,
      duration_ms INTEGER NOT NULL DEFAULT 0,
      position_ms INTEGER NOT NULL DEFAULT 0,
      playback_rate REAL NOT NULL DEFAULT 1,
      cover_hash TEXT,
      cover_mime TEXT,
      color_primary TEXT,
      color_secondary TEXT,
      occurred_at INTEGER,
      reported_at INTEGER,
      track_changed_at INTEGER,
      updated_at INTEGER NOT NULL
    );
    CREATE TABLE IF NOT EXISTS now_playing_announcements (
      id TEXT PRIMARY KEY,
      track_key TEXT NOT NULL,
      platform TEXT NOT NULL,
      status TEXT NOT NULL,
      detail TEXT,
      created_at INTEGER NOT NULL
    );
    CREATE INDEX IF NOT EXISTS idx_now_playing_announcements_track
      ON now_playing_announcements(track_key, platform, created_at);
  `);
}

function ensureDefaults(db) {
  const stored = getPluginSetting(db, "config", "");
  if (!stored) saveConfig(db, DEFAULTS);
}

function ensureRenderToken(db) {
  const existing = getPluginSetting(db, "render_token_encrypted", "");
  if (existing) return;
  const token = crypto.randomBytes(32).toString("base64url");
  setPluginSetting(db, "render_token_hash", sha256(token));
  setPluginSetting(db, "render_token_encrypted", encryptSecret(token));
}

function getPluginSetting(db, key, fallback = "") {
  const row = db.prepare("SELECT value FROM plugin_settings WHERE plugin_id = ? AND key = ?").get(PLUGIN_ID, key);
  return row ? row.value : fallback;
}

function setPluginSetting(db, key, value) {
  db.prepare(
    "INSERT INTO plugin_settings (plugin_id, key, value, updated_at) VALUES (?, ?, ?, ?) " +
    "ON CONFLICT(plugin_id, key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at"
  ).run(PLUGIN_ID, key, String(value ?? ""), Date.now());
}

function getConfig(db) {
  try {
    return { ...DEFAULTS, ...JSON.parse(getPluginSetting(db, "config", "{}")) };
  } catch {
    return { ...DEFAULTS };
  }
}

function saveConfig(db, config) {
  setPluginSetting(db, "config", JSON.stringify({ ...DEFAULTS, ...config }));
}

function normalizeConfig(input) {
  const previous = getConfig(activeDb);
  const boolean = (name, fallback = false) => input[name] === "on" || input[name] === "true" || input[name] === true || (input[name] === undefined ? fallback : false);
  const number = (name, fallback, min, max) => {
    const parsed = Number(input[name]);
    return Number.isFinite(parsed) ? Math.min(max, Math.max(min, parsed)) : fallback;
  };
  const option = (name, allowed, fallback) => allowed.includes(input[name]) ? input[name] : fallback;
  const color = (name, fallback) => /^#[0-9a-f]{6}$/i.test(String(input[name] || "")) ? input[name] : fallback;
  return {
    ...previous,
    enabled: boolean("enabled"),
    display_mode: option("display_mode", ["always", "timed"], DEFAULTS.display_mode),
    display_seconds: number("display_seconds", DEFAULTS.display_seconds, 1, 600),
    show_cover: boolean("show_cover"),
    show_title: boolean("show_title"),
    show_artist: boolean("show_artist"),
    show_album: boolean("show_album"),
    show_release_year: boolean("show_release_year"),
    show_timeline: boolean("show_timeline"),
    show_provider: boolean("show_provider"),
    scale: number("scale", DEFAULTS.scale, 0.5, 2.5),
    entry_animation: option("entry_animation", ["none", "fade", "slide-left", "slide-right", "slide-up", "slide-down", "jump", "drop"], DEFAULTS.entry_animation),
    exit_animation: option("exit_animation", ["none", "fade", "slide-left", "slide-right", "slide-up", "slide-down", "jump", "drop"], DEFAULTS.exit_animation),
    animation_ms: Math.round(number("animation_ms", DEFAULTS.animation_ms, 0, 3000)),
    opacity: number("opacity", DEFAULTS.opacity, 0, 1),
    transparency_scope: option("transparency_scope", ["everything", "background", "everything_except_image_text", "everything_except_text"], DEFAULTS.transparency_scope),
    background_mode: option("background_mode", ["cover-solid", "cover-gradient", "custom-solid", "custom-gradient"], DEFAULTS.background_mode),
    custom_color_primary: color("custom_color_primary", DEFAULTS.custom_color_primary),
    custom_color_secondary: color("custom_color_secondary", DEFAULTS.custom_color_secondary),
    corner_radius: Math.round(number("corner_radius", DEFAULTS.corner_radius, 0, 80)),
    announce_enabled: boolean("announce_enabled"),
    announce_twitch: boolean("announce_twitch"),
    announce_youtube: boolean("announce_youtube"),
    announce_only_when_live: boolean("announce_only_when_live"),
    announcement_template: String(input.announcement_template || DEFAULTS.announcement_template).slice(0, 500),
    announcement_cooldown_seconds: Math.round(number("announcement_cooldown_seconds", DEFAULTS.announcement_cooldown_seconds, 0, 3600)),
    heartbeat_stale_seconds: Math.round(number("heartbeat_stale_seconds", DEFAULTS.heartbeat_stale_seconds, 15, 600))
  };
}

function requireCompanionDevice(req, res, next) {
  const requireDevice = global.lumiFrameworks?.companion?.requireDevice;
  if (typeof requireDevice !== "function") {
    return res.status(503).json({ accepted: false, error: "Companion device authentication is unavailable." });
  }
  return requireDevice(req, res, next);
}

function withAuthenticatedDevice(req, input) {
  return {
    ...(input && typeof input === "object" ? input : {}),
    device_id: String(req.lumiDevice?.id || "")
  };
}

function normalizeIncomingEvent(input) {
  const protocolVersion = Number(input.protocol_version || 0);
  if (protocolVersion !== PROTOCOL_VERSION) {
    const error = new Error(`Unsupported protocol version ${protocolVersion || "missing"}.`);
    error.status = 409;
    throw error;
  }
  const event = String(input.event || "snapshot").trim().toLowerCase();
  if (!ALLOWED_EVENTS.has(event)) throw new Error("Unsupported playback event.");
  const sequence = Math.max(0, Math.floor(Number(input.sequence) || 0));
  const occurredAt = Math.max(0, Math.floor(Number(input.occurred_at) || Date.now()));
  const provider = clean(input.provider, 64) || "unknown";
  const deviceId = clean(input.device_id, 128) || "unknown";
  const sessionId = clean(input.session_id, 128) || "default";
  const playback = input.playback || {};
  const status = ALLOWED_STATUSES.has(String(playback.status || "").toLowerCase())
    ? String(playback.status).toLowerCase()
    : event === "pause" ? "paused" : ["stop", "closed"].includes(event) ? "stopped" : "playing";
  const track = input.track && typeof input.track === "object" ? {
    key: clean(input.track.key, 256),
    title: clean(input.track.title, 500),
    artist: clean(input.track.artist, 500),
    album: clean(input.track.album, 500),
    release_year: clean(input.track.release_year, 16),
    link: validUrl(input.track.link),
    cover: normalizeCover(input.track.cover)
  } : null;
  return {
    protocol_version: protocolVersion,
    provider,
    device_id: deviceId,
    session_id: sessionId,
    sequence,
    event,
    occurred_at: occurredAt,
    playback: {
      status,
      position_ms: boundedInteger(playback.position_ms, 0, 0, 7 * 24 * 60 * 60 * 1000),
      duration_ms: boundedInteger(playback.duration_ms, 0, 0, 7 * 24 * 60 * 60 * 1000),
      rate: boundedNumber(playback.rate, 1, 0, 4)
    },
    track
  };
}

function normalizeCover(cover) {
  if (!cover || typeof cover !== "object") return null;
  const mime = ["image/jpeg", "image/png", "image/webp"].includes(cover.mime) ? cover.mime : "image/jpeg";
  const base64 = String(cover.base64 || "");
  return {
    mime,
    base64,
    primary: normalizeHex(cover.primary, "#1f2937"),
    secondary: normalizeHex(cover.secondary, "#111827")
  };
}

function isStaleSequence(previous, incoming) {
  return Boolean(previous && previous.device_id === incoming.device_id && previous.session_id === incoming.session_id && incoming.sequence <= Number(previous.sequence || 0));
}

function applyIncomingEvent(previous, incoming, cover) {
  const now = Date.now();
  const previousTrack = previous?.track_key || "";
  const incomingTrack = incoming.track?.key || fingerprintTrack(incoming.track);
  const trackChanged = Boolean(incoming.track && incomingTrack && incomingTrack !== previousTrack);
  const clearTrack = ["stop", "closed"].includes(incoming.event) && !incoming.track;
  return {
    singleton_id: 1,
    protocol_version: incoming.protocol_version,
    provider: incoming.provider,
    device_id: incoming.device_id,
    session_id: incoming.session_id,
    sequence: incoming.sequence,
    event_type: incoming.event,
    playback_status: incoming.playback.status,
    track_key: clearTrack ? null : (incomingTrack || previous?.track_key || null),
    title: clearTrack ? null : (incoming.track?.title || previous?.title || null),
    artist: clearTrack ? null : (incoming.track?.artist || previous?.artist || null),
    album: clearTrack ? null : (incoming.track?.album || previous?.album || null),
    release_year: clearTrack ? null : (incoming.track?.release_year || previous?.release_year || null),
    link: clearTrack ? null : (incoming.track?.link || previous?.link || null),
    duration_ms: incoming.playback.duration_ms || previous?.duration_ms || 0,
    position_ms: incoming.playback.position_ms,
    playback_rate: incoming.playback.rate,
    cover_hash: clearTrack ? null : (cover?.hash || previous?.cover_hash || null),
    cover_mime: clearTrack ? null : (cover?.mime || previous?.cover_mime || null),
    color_primary: clearTrack ? null : (cover?.primary || incoming.track?.cover?.primary || previous?.color_primary || "#1f2937"),
    color_secondary: clearTrack ? null : (cover?.secondary || incoming.track?.cover?.secondary || previous?.color_secondary || "#111827"),
    occurred_at: incoming.occurred_at,
    reported_at: now,
    track_changed_at: trackChanged ? now : (previous?.track_changed_at || now),
    updated_at: now
  };
}

function saveStateRow(db, state) {
  db.prepare(`
    INSERT INTO now_playing_state (
      singleton_id, protocol_version, provider, device_id, session_id, sequence, event_type, playback_status,
      track_key, title, artist, album, release_year, link, duration_ms, position_ms, playback_rate,
      cover_hash, cover_mime, color_primary, color_secondary, occurred_at, reported_at, track_changed_at, updated_at
    ) VALUES (
      @singleton_id, @protocol_version, @provider, @device_id, @session_id, @sequence, @event_type, @playback_status,
      @track_key, @title, @artist, @album, @release_year, @link, @duration_ms, @position_ms, @playback_rate,
      @cover_hash, @cover_mime, @color_primary, @color_secondary, @occurred_at, @reported_at, @track_changed_at, @updated_at
    ) ON CONFLICT(singleton_id) DO UPDATE SET
      protocol_version=excluded.protocol_version, provider=excluded.provider, device_id=excluded.device_id,
      session_id=excluded.session_id, sequence=excluded.sequence, event_type=excluded.event_type,
      playback_status=excluded.playback_status, track_key=excluded.track_key, title=excluded.title,
      artist=excluded.artist, album=excluded.album, release_year=excluded.release_year, link=excluded.link,
      duration_ms=excluded.duration_ms, position_ms=excluded.position_ms, playback_rate=excluded.playback_rate,
      cover_hash=excluded.cover_hash, cover_mime=excluded.cover_mime, color_primary=excluded.color_primary,
      color_secondary=excluded.color_secondary, occurred_at=excluded.occurred_at, reported_at=excluded.reported_at,
      track_changed_at=excluded.track_changed_at, updated_at=excluded.updated_at
  `).run(state);
}

function getStateRow(db) {
  return db.prepare("SELECT * FROM now_playing_state WHERE singleton_id = 1").get() || null;
}

function getPublicState(db, req, tokenOverride) {
  const state = getStateRow(db);
  const config = getConfig(db);
  const now = Date.now();
  const stale = !state || now - Number(state.reported_at || 0) > config.heartbeat_stale_seconds * 1000;
  const token = tokenOverride || renderToken(db);
  const position = calculatePosition(state, now);
  return {
    available: Boolean(config.enabled && state?.track_key && !["stopped", "closed"].includes(state.playback_status) && !stale),
    stale,
    now,
    config: publicConfig(config),
    playback: state ? {
      status: state.playback_status,
      position_ms: position,
      duration_ms: Number(state.duration_ms || 0),
      rate: Number(state.playback_rate || 1),
      reported_at: Number(state.reported_at || 0),
      track_changed_at: Number(state.track_changed_at || 0)
    } : null,
    track: state?.track_key ? {
      key: state.track_key,
      name: state.title || "",
      artist: state.artist || "",
      album: state.album || "",
      release_year: state.release_year || "",
      link: state.link || "",
      provider: state.provider || "",
      color_primary: normalizeHex(state.color_primary, "#1f2937"),
      color_secondary: normalizeHex(state.color_secondary, "#111827"),
      cover_url: state.cover_hash && token
        ? `/plugins/${PLUGIN_ID}/render/${encodeURIComponent(token)}/cover/${encodeURIComponent(state.cover_hash)}`
        : ""
    } : null
  };
}

function publicConfig(config) {
  const allowed = [
    "display_mode", "display_seconds", "show_cover", "show_title", "show_artist", "show_album",
    "show_release_year", "show_timeline", "show_provider", "scale", "entry_animation", "exit_animation",
    "animation_ms", "opacity", "transparency_scope", "background_mode", "custom_color_primary",
    "custom_color_secondary", "corner_radius"
  ];
  return Object.fromEntries(allowed.map((key) => [key, config[key]]));
}

function calculatePosition(state, now = Date.now()) {
  if (!state) return 0;
  let position = Number(state.position_ms || 0);
  if (state.playback_status === "playing") {
    position += Math.max(0, now - Number(state.reported_at || now)) * Number(state.playback_rate || 1);
  }
  const duration = Math.max(0, Number(state.duration_ms || 0));
  return Math.round(duration > 0 ? Math.min(duration, Math.max(0, position)) : Math.max(0, position));
}

function storeCover(cover) {
  if (!cover?.base64) return null;
  let bytes;
  try { bytes = Buffer.from(cover.base64, "base64"); } catch { return null; }
  if (!bytes.length || bytes.length > MAX_COVER_BYTES) return null;
  const hash = sha256(bytes);
  const filePath = coverPathFor(hash, cover.mime);
  if (!fs.existsSync(filePath)) fs.writeFileSync(filePath, bytes, { flag: "wx" });
  return { hash, mime: cover.mime, primary: cover.primary, secondary: cover.secondary };
}

function coverPathFor(hash, mime) {
  if (!/^[a-f0-9]{64}$/i.test(String(hash || ""))) return null;
  const ext = mime === "image/png" ? ".png" : mime === "image/webp" ? ".webp" : ".jpg";
  return path.join(COVER_DIR, `${hash}${ext}`);
}

function cleanupCovers(currentHash) {
  try {
    const files = fs.readdirSync(COVER_DIR).map((name) => ({
      name,
      path: path.join(COVER_DIR, name),
      stat: fs.statSync(path.join(COVER_DIR, name))
    })).sort((a, b) => b.stat.mtimeMs - a.stat.mtimeMs);
    for (const file of files.slice(8)) {
      if (!file.name.startsWith(currentHash || "__none__")) fs.unlinkSync(file.path);
    }
  } catch {}
}

function broadcastState(db) {
  for (const client of Array.from(renderClients)) {
    try {
      writeSse(client.res, "changed", { updated_at: Date.now() });
    } catch {
      renderClients.delete(client);
    }
  }
}

function writeSse(res, event, payload) {
  res.write(`event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`);
}

function noStore(res) {
  res.set("Cache-Control", "no-store, private");
  res.set("Pragma", "no-cache");
  res.set("Referrer-Policy", "no-referrer");
}

function allowTokenRenderAccess(res) {
  // Lumi's website overlay module intentionally gives same-origin pages an
  // opaque sandbox origin. The unguessable render token is the authority, so
  // allow that frame to re-fetch only its public display state.
  res.set("Access-Control-Allow-Origin", "*");
}

function renderToken(db) {
  try { return decryptSecret(getPluginSetting(db, "render_token_encrypted", "")); } catch { return ""; }
}

function validRenderToken(db, token) {
  const expected = getPluginSetting(db, "render_token_hash", "");
  return Boolean(expected && token && safeEqual(expected, sha256(token)));
}

function buildRenderUrl(req, db, settings) {
  return `${publicBaseUrl(req, settings)}/plugins/${PLUGIN_ID}/render/${encodeURIComponent(renderToken(db))}`;
}

function publicBaseUrl(req, settings) {
  const configured = String(settings?.getSetting?.("public_base_url", "") || "").trim().replace(/\/$/, "");
  return configured || `${req.protocol}://${req.get("host")}`;
}

function listOverlayTargets() {
  try {
    return overlayService.listOverlays().flatMap((overlay) => {
      const full = overlayService.getOverlay(overlay.id, { includeSecrets: false });
      return (full?.scenes || []).map((scene) => ({
        overlayId: overlay.id,
        overlayName: overlay.name,
        sceneId: scene.id,
        sceneName: scene.name,
        enabled: Boolean(overlay.enabled && scene.enabled)
      }));
    });
  } catch {
    return [];
  }
}

function overlayIdForScene(sceneId) {
  return listOverlayTargets().find((target) => target.sceneId === sceneId)?.overlayId || null;
}

function findInstalledModule(config) {
  if (!config.overlay_id || !config.overlay_module_id) return { exists: false };
  try {
    const overlay = overlayService.getOverlay(config.overlay_id, { includeSecrets: false });
    const scene = overlay?.scenes?.find((item) => item.id === config.overlay_scene_id);
    const module = scene?.modules?.find((item) => item.id === config.overlay_module_id);
    return {
      exists: Boolean(module),
      overlayName: overlay?.name || "",
      sceneName: scene?.name || "",
      moduleName: module?.name || ""
    };
  } catch {
    return { exists: false };
  }
}

async function refreshInstalledModule(req, config) {
  const status = findInstalledModule(config);
  if (!status.exists) return;
  const overlay = overlayService.getOverlay(config.overlay_id, { includeSecrets: false });
  const scene = overlay.scenes.find((item) => item.id === config.overlay_scene_id);
  const module = scene.modules.find((item) => item.id === config.overlay_module_id);
  overlayService.updateModule(module.id, {
    name: module.name,
    enabled: module.enabled,
    type: "web",
    config: {
      ...module.config,
      url: buildRenderUrl(req, activeDb, activeSettings),
      injectPageCss: false,
      healthCheck: false,
      forceTransparentBackground: true
    }
  });
}

function registerPlaceholderSupport(placeholders) {
  if (!placeholders?.registerFieldPolicy || !placeholders?.registerPlaceholders) return;
  placeholders.registerFieldPolicy({
    field_id: FIELD_ID,
    label: "Song Overlay chat announcement",
    field_type: "chat_message",
    output_audience: "user",
    min_editor_role: "admin",
    allowed_namespaces: ["plugin.song_overlay"],
    max_sensitivity: "public_safe"
  });
  const definitions = [
    ["song_name", "Song name", "Current song title."],
    ["artist", "Artist", "Current song artist."],
    ["album", "Album", "Current song album."],
    ["release_year", "Release year", "Current song release year when the provider supplies it."],
    ["link", "Song link", "Provider song link when available."]
  ].map(([key, label, description]) => ({
    id: `plugin.song_overlay.${key}`,
    namespace: "plugin.song_overlay",
    label,
    description,
    value_type: key === "link" ? "url" : "string",
    sensitivity: "public_safe",
    min_editor_role: "admin",
    min_viewer_role: "user",
    allowed_field_types: ["chat_message"],
    plugin_id: PLUGIN_ID,
    aliases: [key],
    resolver: ({ runtimeContext }) => runtimeContext?.songOverlay?.[key] ?? ""
  }));
  placeholders.registerPlaceholders(definitions);
}

function registerMusicCommands({ db, commandRouter }) {
  if (!commandRouter) return;
  commandRouter.registerCommands(PLUGIN_ID, [{
    id: "music",
    triggers: ["music"],
    platforms: ["discord", "twitch", "youtube"],
    description: "Show the song currently playing through Lumi Companion.",
    handler: (ctx) => handleMusicCommand({ ctx, db })
  }]);
}

async function handleMusicCommand({ ctx, db }) {
  // This handler is the extension point for future chat-driven music actions.
  // For now, !music is read-only and shares the automatic announcement text.
  const publicState = getPublicState(db, null);
  const state = getStateRow(db);
  if (!publicState.available || !state?.track_key || !state.title) {
    return "Nothing is playing right now.";
  }
  return await renderAnnouncementMessage({ db, state, user: ctx?.user });
}

function scheduleAnnouncement(db, trackKey) {
  for (const [key, timer] of announcementTimers) {
    if (key !== trackKey) {
      clearTimeout(timer);
      announcementTimers.delete(key);
    }
  }
  if (announcementTimers.has(trackKey)) return;
  const timer = setTimeout(() => {
    announcementTimers.delete(trackKey);
    const current = getStateRow(db);
    if (!current || current.track_key !== trackKey || current.playback_status !== "playing") return;
    announceTrack({ db, state: current }).catch((error) => {
      pluginLogger.warn?.("Song Overlay chat announcement failed", { error: error?.message || String(error) });
    });
  }, 1800);
  timer.unref?.();
  announcementTimers.set(trackKey, timer);
}

async function announceTrack({ db, state, force = false, reqUser = null }) {
  const config = getConfig(db);
  if (!force && !config.announce_enabled) return [];
  if (!state?.track_key || !state.title) return [];
  const platforms = [
    config.announce_twitch ? "twitch" : null,
    config.announce_youtube ? "youtube" : null
  ].filter(Boolean);
  const message = await renderAnnouncementMessage({ db, state, user: reqUser });
  if (!message) return [];
  const results = [];
  for (const platform of platforms) {
    if (!force && alreadyAnnounced(db, state.track_key, platform, config.announcement_cooldown_seconds)) continue;
    try {
      if (!force && config.announce_only_when_live && !(await platformIsLive(platform))) continue;
      await sendPlatformMessage(platform, message);
      recordAnnouncement(db, state.track_key, platform, "sent", null);
      results.push({ platform, success: true });
    } catch (error) {
      recordAnnouncement(db, state.track_key, platform, "failed", error?.message || String(error));
      results.push({ platform, success: false, error: error?.message || String(error) });
    }
  }
  return results;
}

function announcementTokens(state) {
  return {
    song_name: state.title || "",
    artist: state.artist || "",
    album: state.album || "",
    release_year: state.release_year || "",
    link: state.link || ""
  };
}

async function renderAnnouncementMessage({ db, state, user = null }) {
  const config = getConfig(db);
  const tokens = announcementTokens(state);
  const rendered = await placeholderApi.renderTemplate({
    fieldId: FIELD_ID,
    template: config.announcement_template,
    user: user || { isAdmin: true },
    outputAudience: "user",
    runtimeContext: { runtime: true, songOverlay: tokens },
    fallback: ""
  });
  return String(rendered.rendered || "").replace(/\s{2,}/g, " ").trim().slice(0, 450);
}

function alreadyAnnounced(db, trackKey, platform, cooldownSeconds) {
  const row = db.prepare(
    "SELECT created_at FROM now_playing_announcements WHERE track_key = ? AND platform = ? AND status = 'sent' ORDER BY created_at DESC LIMIT 1"
  ).get(trackKey, platform);
  return Boolean(row && Date.now() - row.created_at < Math.max(0, cooldownSeconds) * 1000);
}

function recordAnnouncement(db, trackKey, platform, status, detail) {
  db.prepare(
    "INSERT INTO now_playing_announcements (id, track_key, platform, status, detail, created_at) VALUES (?, ?, ?, ?, ?, ?)"
  ).run(crypto.randomUUID(), trackKey, platform, status, detail ? String(detail).slice(0, 500) : null, Date.now());
  db.prepare(
    "DELETE FROM now_playing_announcements WHERE id NOT IN (SELECT id FROM now_playing_announcements ORDER BY created_at DESC LIMIT 1000)"
  ).run();
}

async function platformIsLive(platform) {
  if (platform === "youtube") return Boolean(activeClients?.youtubeClient?.liveChatId || activeClients?.youtubeClient?.sendMessage);
  if (platform !== "twitch") return false;
  if (twitchLiveCache.live === true && Date.now() - twitchLiveCache.checkedAt < 30000) return true;
  if (twitchLiveCache.live === false && Date.now() - twitchLiveCache.checkedAt < 5000) return false;
  const channels = activeClients?.twitchClient?.getChannels?.() || [];
  const login = String(channels[0] || "").replace(/^#/, "");
  if (!login) return false;
  try {
    const users = await twitchRequest("GET", "/helix/users", { login });
    const id = users?.data?.[0]?.id;
    const streams = id ? await twitchRequest("GET", "/helix/streams", { user_id: id }) : null;
    twitchLiveCache = { checkedAt: Date.now(), live: Boolean(streams?.data?.[0]?.id) };
    return twitchLiveCache.live;
  } catch {
    twitchLiveCache = { checkedAt: Date.now(), live: false };
    return false;
  }
}

async function sendPlatformMessage(platform, message) {
  if (platform === "twitch") {
    const client = activeClients?.twitchClient;
    const channel = client?.getChannels?.()?.[0];
    if (!client?.say || !channel) throw new Error("Twitch chat is unavailable.");
    return client.say(channel, message);
  }
  if (platform === "youtube") {
    const client = activeClients?.youtubeClient;
    if (!client?.sendMessage) throw new Error("YouTube live chat is unavailable.");
    return client.sendMessage(message);
  }
  throw new Error("Unsupported streaming platform.");
}

function publicEventState(state) {
  return {
    provider: state.provider,
    event: state.event_type,
    status: state.playback_status,
    track_key: state.track_key,
    title: state.title,
    artist: state.artist,
    updated_at: state.updated_at
  };
}

function sampleState() {
  return {
    track_key: "sample",
    title: "Sample Song",
    artist: "Sample Artist",
    album: "Sample Album",
    release_year: "2026",
    link: "https://example.com/song",
    playback_status: "playing"
  };
}

function fingerprintTrack(track) {
  if (!track) return "";
  const raw = [track.title, track.artist, track.album].map((value) => String(value || "").trim().toLowerCase()).join("|");
  return raw.replace(/^\|+|\|+$/g, "") ? sha256(raw) : "";
}

function clean(value, max) {
  return String(value || "").replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, max);
}

function validUrl(value) {
  const raw = clean(value, 2048);
  if (!raw) return "";
  try {
    const parsed = new URL(raw);
    return ["http:", "https:"].includes(parsed.protocol) ? parsed.toString() : "";
  } catch {
    return "";
  }
}

function normalizeHex(value, fallback) {
  const normalized = String(value || "").trim();
  return /^#[0-9a-f]{6}$/i.test(normalized) ? normalized : fallback;
}

function boundedInteger(value, fallback, min, max) {
  const parsed = Number(value);
  return Number.isFinite(parsed) ? Math.round(Math.min(max, Math.max(min, parsed))) : fallback;
}

function boundedNumber(value, fallback, min, max) {
  const parsed = Number(value);
  return Number.isFinite(parsed) ? Math.min(max, Math.max(min, parsed)) : fallback;
}

function sha256(value) {
  return crypto.createHash("sha256").update(value).digest("hex");
}

function safeEqual(left, right) {
  const a = Buffer.from(String(left || ""));
  const b = Buffer.from(String(right || ""));
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

function isAdmin(user) {
  return Boolean(user?.isAdmin);
}

function renderDenied(res) {
  return res.status(403).render("error", {
    title: "Access denied",
    message: "Administrator access is required for Song Overlay settings."
  });
}

// Intentionally small test surface used by the standalone verifier. This is not
// part of Lumi's runtime plugin API.
module.exports._test = {
  normalizeIncomingEvent,
  applyIncomingEvent,
  calculatePosition,
  normalizeConfig,
  publicConfig,
  fingerprintTrack,
  announcementTokens,
  requireCompanionDevice,
  withAuthenticatedDevice
};
