const express = require('express');
const router = express.Router();
const fs = require('fs');
const path = require('path');
const { v4: uuidv4 } = require('uuid');
const { db } = require('../db/database');
const appConfig = require('../config');
const { PLATFORM_ROLES, ELEVATED_ROLES } = require('../middleware/auth');
// Phase 2.2d: workspace-aware access. Same pattern as devices.js / content.js.
const { accessContext } = require('../lib/tenancy');

// For preview only: inline /api/content/:id/file and /thumbnail URLs as data URIs,
// scoped to the caller's current workspace. Lets the srcdoc preview iframe show
// logos/bg images before the widget is saved (post-save they're reachable via
// the widget-reference gate).
const MAX_INLINE_BYTES = 10 * 1024 * 1024; // 10MB cap — base64 expands ~1.33x
const MIME_RE = /^image\/[a-zA-Z0-9.+-]+$/;
function inlineUserContent(html, workspaceId) {
  if (!workspaceId) return html;
  return html.replace(/\/api\/content\/([a-f0-9-]+)\/(file|thumbnail)/gi, (match, id, kind) => {
    const c = db.prepare('SELECT filepath, thumbnail_path, mime_type, workspace_id FROM content WHERE id = ?').get(id);
    // Inline content only when it lives in the caller's workspace, or is a
    // platform-template row (workspace_id IS NULL) shared with everyone.
    if (!c) return match;
    if (c.workspace_id && c.workspace_id !== workspaceId) return match;
    const filename = kind === 'thumbnail' ? c.thumbnail_path : c.filepath;
    if (!filename) return match;
    // YouTube (and other remote-sourced) content stores thumbnail_path as a remote
    // http(s) URL, not a local file. Don't try to read it from disk (would ENOENT the
    // same way the serving route did) — leave the /api/content/:id/thumbnail reference
    // in place; the thumbnail route proxies it same-origin and CSP img-src allows https:.
    if (/^https?:\/\//i.test(filename)) return match;
    const mime = kind === 'thumbnail' ? 'image/jpeg' : c.mime_type;
    if (!mime || !MIME_RE.test(mime)) return match;
    const safe = path.resolve(appConfig.contentDir, path.basename(filename));
    if (!safe.startsWith(path.resolve(appConfig.contentDir))) return match;
    try {
      const st = fs.statSync(safe);
      if (!st.isFile() || st.size > MAX_INLINE_BYTES) return match;
      const buf = fs.readFileSync(safe);
      return `data:${mime};base64,${buf.toString('base64')}`;
    } catch { return match; }
  });
}

// Escape HTML to prevent XSS
function escapeHtml(str) {
  if (typeof str !== 'string') return str;
  return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}

// Validate timezone format (e.g. America/New_York, UTC, Etc/GMT+5)
function safeTimezone(tz) {
  if (!tz) return 'UTC';
  return /^[A-Za-z_\-\/+0-9]+$/.test(tz) ? tz : 'UTC';
}

// Validate ISO date string format
function safeDateString(d) {
  if (!d) return '';
  return /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2})?)?/.test(d) ? d : '';
}

// Validate URL is http/https
function safeUrl(url) {
  if (!url) return 'about:blank';
  try {
    const parsed = new URL(url);
    return ['http:', 'https:'].includes(parsed.protocol) ? url : 'about:blank';
  } catch { return 'about:blank'; }
}

// Security: widget render output is public and CSP-exempt, so config values that
// get inlined into <style>/CSS must not be able to break out (a config field set
// via the API could otherwise carry `}</style><script>...`). safeCss allows
// colors/gradients but rejects breakout/exfil constructs; safeNumber coerces to
// a finite number (so e.g. font_size can't smuggle markup).
function safeCss(v, fallback) {
  if (typeof v !== 'string') return fallback;
  if (/[<>{}\\;]/.test(v) || /url\s*\(/i.test(v) || /@import/i.test(v) || /expression/i.test(v) || /javascript:/i.test(v)) return fallback;
  return v.trim().slice(0, 200);
}
function safeNumber(v, fallback) {
  const n = Number(v);
  return Number.isFinite(n) ? n : fallback;
}

// List widgets accessible to the caller's current workspace, plus any
// platform-template rows (workspace_id IS NULL) shared with all workspaces.
// Phase 2.2d: workspace-scoped. Cross-workspace visibility comes from
// switch-workspace, not a special list branch.
router.get('/', (req, res) => {
  if (!req.workspaceId) return res.json([]);
  const widgets = db.prepare(
    'SELECT * FROM widgets WHERE (workspace_id = ? OR workspace_id IS NULL) ORDER BY created_at DESC'
  ).all(req.workspaceId);
  res.json(widgets);
});

// Create widget in the caller's current workspace.
router.post('/', (req, res) => {
  if (!req.workspaceId) return res.status(403).json({ error: 'No workspace context. Switch to a workspace before creating widgets.' });
  const { widget_type, name, config } = req.body;
  if (!widget_type || !name) return res.status(400).json({ error: 'widget_type and name required' });

  const id = uuidv4();
  db.prepare('INSERT INTO widgets (id, user_id, workspace_id, widget_type, name, config) VALUES (?, ?, ?, ?, ?, ?)')
    .run(id, req.user.id, req.workspaceId, widget_type, name, JSON.stringify(config || {}));

  res.status(201).json(db.prepare('SELECT * FROM widgets WHERE id = ?').get(id));
});

// Phase 2.2d: workspace-aware access. Mirrors the device/content pattern.
// Platform-template widgets (workspace_id IS NULL) are readable by anyone
// authenticated and writable only by platform_admin.
function checkWidgetRead(req, res) {
  const widget = db.prepare('SELECT * FROM widgets WHERE id = ?').get(req.params.id);
  if (!widget) { res.status(404).json({ error: 'Widget not found' }); return null; }
  if (!widget.workspace_id) return widget;
  const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(widget.workspace_id);
  const ctx = ws && accessContext(req.user.id, req.user.role, ws);
  if (!ctx) { res.status(403).json({ error: 'Access denied' }); return null; }
  return widget;
}

function checkWidgetWrite(req, res) {
  const widget = db.prepare('SELECT * FROM widgets WHERE id = ?').get(req.params.id);
  if (!widget) { res.status(404).json({ error: 'Widget not found' }); return null; }
  if (!widget.workspace_id) {
    if (!PLATFORM_ROLES.includes(req.user.role)) {
      res.status(403).json({ error: 'Platform admin required to modify shared widgets' }); return null;
    }
    return widget;
  }
  const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(widget.workspace_id);
  const ctx = ws && accessContext(req.user.id, req.user.role, ws);
  if (!ctx) { res.status(403).json({ error: 'Access denied' }); return null; }
  if (!ctx.actingAs && ctx.workspaceRole === 'workspace_viewer') {
    res.status(403).json({ error: 'Read-only access' }); return null;
  }
  return widget;
}

// Get widget
router.get('/:id', (req, res) => {
  const widget = checkWidgetRead(req, res);
  if (!widget) return;
  res.json(widget);
});

// Update widget
router.put('/:id', (req, res) => {
  const widget = checkWidgetWrite(req, res);
  if (!widget) return;

  const { name, config } = req.body;
  if (name) db.prepare('UPDATE widgets SET name = ?, updated_at = strftime(\'%s\',\'now\') WHERE id = ?').run(name, req.params.id);
  if (config) db.prepare('UPDATE widgets SET config = ?, updated_at = strftime(\'%s\',\'now\') WHERE id = ?').run(JSON.stringify(config), req.params.id);

  // Push the change to any display currently showing this widget. Editing a widget used to
  // notify nothing at all: the render endpoint serves live config, but a player that already has
  // the widget on screen keeps its WebView (deliberately — re-navigating a widget every duration
  // is a visible flash and destroys widget state). With no push and no change to the URL, an edit
  // reached the screen only when the app was restarted. Reported on #234: "I changed the text and
  // the new text did not appear on the screen. I had to close the app and then open again."
  //
  // The push is what makes it prompt; the rev in the payload is what makes the player reload.
  try {
    const io = req.app.get('io');
    if (io) {
      const { buildPlaylistPayload } = require('../ws/deviceSocket');
      const commandQueue = require('../lib/command-queue');
      const affected = db.prepare(`
        SELECT DISTINCT d.id FROM devices d
        JOIN playlist_items pi ON pi.playlist_id = d.playlist_id
        WHERE pi.widget_id = ?
      `).all(req.params.id);
      for (const d of affected) {
        commandQueue.queueOrEmitPlaylistUpdate(io.of('/device'), d.id, buildPlaylistPayload);
      }
    }
  } catch (e) { /* best-effort; the heartbeat refresh still picks it up */ }

  res.json(db.prepare('SELECT * FROM widgets WHERE id = ?').get(req.params.id));
});

// Delete widget
router.delete('/:id', (req, res) => {
  const widget = checkWidgetWrite(req, res);
  if (!widget) return;
  db.prepare('DELETE FROM widgets WHERE id = ?').run(req.params.id);
  res.json({ success: true });
});

const KNOWN_WIDGET_TYPES = new Set(['clock','weather','rss','text','webpage','social','directory-board','directory-search','diag-smoothness']);
function renderWidgetHtml(type, config, opts = {}) {
  const iframeSandbox = opts.iframeSandbox || 'allow-scripts';
  config = config || {};
  switch (type) {
    case 'clock': return renderClock(config);
    case 'weather': return renderWeather(config);
    case 'rss': return renderRSS(config);
    case 'text': return renderText(config, iframeSandbox);
    case 'webpage': return renderWebpage(config, iframeSandbox);
    case 'social': return renderSocial(config);
    case 'directory-board': return renderDirectoryBoard(config);
    case 'directory-search': return renderDirectorySearch(config);
    case 'diag-smoothness': return renderDiagSmoothness(config);
    default: return '<html><body style="color:white;background:black;display:flex;align-items:center;justify-content:center;height:100vh;margin:0"><h1>Unknown widget</h1></body></html>';
  }
}

// The widget editor's Preview is framed by the DASHBOARD, from the dashboard's own
// origin, and the dashboard keeps its session JWT in localStorage. So preview HTML is
// pinned to the isolating sandbox and never consults the org setting: otherwise anyone
// who can author a widget (workspace_editor and up) could run script in the dashboard
// origin and lift the session of whichever admin clicked Preview.
//
// The org setting exists so PLAYERS can embed origin-strict third-party sites. A player
// runs on a kiosk with a device token, which is the risk the confirmation modal
// describes; an admin's dashboard session is not.
const PREVIEW_IFRAME_SANDBOX = 'allow-scripts';

function widgetIframeSandboxForWorkspace(workspaceId) {
  if (!workspaceId) return 'allow-scripts';
  try {
    const row = db.prepare(`
      SELECT COALESCE(o.widget_sandbox_isolation_disabled, 0) AS disabled
      FROM workspaces ws
      LEFT JOIN organizations o ON o.id = ws.organization_id
      WHERE ws.id = ?
    `).get(workspaceId);
    return Number(row?.disabled || 0) === 1
      ? 'allow-scripts allow-same-origin'
      : 'allow-scripts';
  } catch (_) {
    return 'allow-scripts';
  }
}

// Render widget as HTML page
router.get('/:id/render', (req, res) => {
  const widget = db.prepare('SELECT * FROM widgets WHERE id = ?').get(req.params.id);
  if (!widget) return res.status(404).send('Widget not found');
  const config = JSON.parse(widget.config || '{}');
  const iframeSandbox = widgetIframeSandboxForWorkspace(widget.workspace_id);
  // This page is DESIGNED to be embedded by the player, which frames it in a
  // sandboxed (allow-scripts, no allow-same-origin) iframe = a null origin. The
  // global helmet X-Frame-Options: SAMEORIGIN refuses that (null != same), so
  // widgets render blank in the web player. Drop it here; the sandbox - not
  // X-Frame-Options - is what isolates the widget (it can't read the dashboard JWT).
  res.removeHeader('X-Frame-Options');
  // Caching is keyed on whether the caller pinned a revision.
  //
  // A URL carrying ?rev=<widget.updated_at> is content-addressed: those exact bytes cannot change
  // without the rev changing, so it is safe to cache hard — and it NEEDS to be, because a player
  // that loses its network must still be able to render its widgets. Offline resilience is the
  // point of the player's cache, and no-store made widgets the one thing it could never keep.
  //
  // A URL with no rev is the old shape and stays uncacheable: nothing distinguishes one render
  // from the next, so a cached copy could serve content the operator has already changed.
  if (req.query.rev) {
    res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
  } else {
    res.setHeader('Cache-Control', 'no-store');
  }
  res.setHeader('Content-Type', 'text/html');
  res.send(renderWidgetHtml(widget.widget_type, config, { iframeSandbox }));
});

// Public JSON feed of a directory board's entries. A directory-search page polls
// this to reflect board edits without a reload. It exposes only the same data
// already public via /render, and is CORS-open so a null-origin sandboxed widget
// iframe can read it. 404 (not empty) on a missing/wrong-type source so the
// polling page keeps its last-good data instead of blanking on a transient miss.
router.get('/:id/data.json', (req, res) => {
  const widget = db.prepare('SELECT * FROM widgets WHERE id = ?').get(req.params.id);
  if (!widget || widget.widget_type !== 'directory-board') return res.status(404).json({ error: 'Not a directory board' });
  let categories = [];
  try {
    const cfg = JSON.parse(widget.config || '{}');
    categories = Array.isArray(cfg.categories) ? cfg.categories : [];
  } catch (e) { categories = []; }
  res.removeHeader('X-Frame-Options');
  res.setHeader('Access-Control-Allow-Origin', '*');
  res.setHeader('Cache-Control', 'no-store');
  res.json({ categories });
});

// Latest frame-rate telemetry per widget, reported by the diag-smoothness widget running on a device.
// In-memory (diagnostic, not persisted) — a device page reads the snapshot for the widget it plays.
//
// BOUNDED, because the writer is unauthenticated (the widget runs in a null-origin sandboxed
// iframe and cannot carry a session) and the key comes from the request body. An uncapped map
// keyed on caller-supplied values is a remote memory-exhaustion path, and on this product a dead
// server means the whole fleet reconnects at once.
//
// The cap is GLOBAL rather than per-IP on purpose: signage sites egress through one NAT address,
// so a per-IP limit would punish an entire venue for one noisy panel while doing nothing about a
// distributed writer. Same reasoning as lib/ota-download-guard ("NEVER per-IP (SNAT)"). Eviction
// is least-recently-written, and a live panel rewrites its key every 2.5s, so only entries the
// dashboard would already call stale (>15s) are ever eligible.
const widgetTelemetry = require('../lib/bounded-snapshot-store').createStore({ max: 500, ttlMs: 60_000 });
widgetTelemetry.startSweep();
// Public POST from the widget: it runs in a null-origin sandboxed iframe, so this must be no-auth +
// CORS-open. The widget sends text/plain (a "simple" request → no CORS preflight); we JSON.parse it.
router.post('/:id/telemetry', express.text({ type: '*/*', limit: '16kb' }), (req, res) => {
  res.setHeader('Access-Control-Allow-Origin', '*');
  let t = {};
  try { t = typeof req.body === 'string' ? JSON.parse(req.body || '{}') : (req.body || {}); } catch (e) { t = {}; }
  t.receivedAt = Date.now();
  // Key by the reporting device (player passes ?device=<id>) so multiple panels don't collide;
  // fall back to a widget-scoped key for players that don't pass a device id yet.
  const key = (t.device && String(t.device).slice(0, 64)) || ('w:' + req.params.id);
  widgetTelemetry.set(key, t);
  // 204, not res.json(): this is fire-and-forget diagnostic data and the reporting widget ignores
  // the response entirely (routes/widgets.js renderDiagSmoothness -> fetch(...).catch()). It also
  // keeps services/activity.js activityLogger — which wraps res.json — from writing an activity_log
  // row per unauthenticated report, i.e. from letting an anonymous caller grow a DB table.
  res.status(204).end();
});
// Public GET so the dashboard device page can display the snapshot. ?device=<id> reads that panel's
// report; without it (or if that panel hasn't reported) falls back to the widget-scoped snapshot.
router.get('/:id/telemetry', (req, res) => {
  res.setHeader('Access-Control-Allow-Origin', '*');
  res.setHeader('Cache-Control', 'no-store');
  const dev = req.query.device ? String(req.query.device) : null;
  // Device-scoped request returns ONLY that device's report — NO widget-wide fallback, or one
  // reporting panel's data would show on every other device's page (incl. offline ones). A request
  // with no device id gets the widget-scoped snapshot (raw/debug view only).
  // get() returns null for a missing OR expired entry, so a stale snapshot is never served
  // as live even between sweeps.
  const rec = dev ? widgetTelemetry.get(dev) : widgetTelemetry.get('w:' + req.params.id);
  res.json(rec || null);
});

// Preview unsaved widget from config (used by editor Preview button)
router.post('/preview', (req, res) => {
  const { widget_type, config } = req.body || {};
  if (!widget_type || typeof widget_type !== 'string') return res.status(400).json({ error: 'widget_type required' });
  if (!KNOWN_WIDGET_TYPES.has(widget_type)) return res.status(400).json({ error: 'Unknown widget_type' });
  // Preview renders inside the DASHBOARD origin, so it never opts into same-origin —
  // see PREVIEW_IFRAME_SANDBOX.
  let html = renderWidgetHtml(widget_type, config || {}, { iframeSandbox: PREVIEW_IFRAME_SANDBOX });
  if (req.workspaceId) html = inlineUserContent(html, req.workspaceId);
  res.setHeader('Content-Type', 'text/html');
  res.send(html);
});

// Preview sessions — ephemeral store so the preview iframe loads via src (not srcdoc)
// and bypasses the dashboard CSP that would block the widget's inline scripts.
const previewStore = new Map();
const PREVIEW_TTL = 5 * 60 * 1000;
setInterval(() => {
  const now = Date.now();
  for (const [key, entry] of previewStore) {
    if (now - entry.created > PREVIEW_TTL) previewStore.delete(key);
  }
}, 60 * 1000).unref();

router.post('/preview-session', (req, res) => {
  const { widget_type, config } = req.body || {};
  if (!widget_type || typeof widget_type !== 'string') return res.status(400).json({ error: 'widget_type required' });
  if (!KNOWN_WIDGET_TYPES.has(widget_type)) return res.status(400).json({ error: 'Unknown widget_type' });
  const id = uuidv4();
  // Same reasoning as /preview — dashboard origin, never same-origin.
  const html = renderWidgetHtml(widget_type, config || {}, { iframeSandbox: PREVIEW_IFRAME_SANDBOX });
  previewStore.set(id, { html, widget_type, created: Date.now() });
  res.json({ id, url: `/api/widgets/preview-session/${id}` });
});

router.get('/preview-session/:id', (req, res) => {
  const entry = previewStore.get(req.params.id);
  if (!entry) return res.status(410).send('Preview expired');
  if (Date.now() - entry.created > PREVIEW_TTL) {
    previewStore.delete(req.params.id);
    return res.status(410).send('Preview expired');
  }
  let html = entry.html;
  if (req.workspaceId) html = inlineUserContent(html, req.workspaceId);
  res.removeHeader('X-Frame-Options');
  res.setHeader('Cache-Control', 'no-store');
  res.setHeader('Content-Type', 'text/html');
  res.send(html);
});

function renderClock(c) {
  return `<!DOCTYPE html><html><head><style>
  * { margin:0; padding:0; box-sizing:border-box; }
  body { background:${safeCss(c.background, 'transparent')}; display:flex; flex-direction:column; align-items:center; justify-content:center; height:100vh; font-family:-apple-system,sans-serif; overflow:hidden; }
  #time { font-size:${safeNumber(c.font_size, 64)}px; font-weight:700; color:${safeCss(c.color, '#FFFFFF')}; }
  #date { font-size:${Math.max(16, safeNumber(c.font_size, 64) / 3)}px; color:${safeCss(c.color, '#FFFFFF')}; opacity:0.7; margin-top:8px; }
</style></head><body>
<div id="time"></div>
${c.show_date !== false ? '<div id="date"></div>' : ''}
<script>
function update() {
  const opts = { hour12: ${c.format !== '24h'}, timeZone: '${safeTimezone(c.timezone)}', hour:'2-digit', minute:'2-digit', second:'2-digit' };
  document.getElementById('time').textContent = new Date().toLocaleTimeString('en-US', opts);
  ${c.show_date !== false ? `document.getElementById('date').textContent = new Date().toLocaleDateString('en-US', { timeZone: '${safeTimezone(c.timezone)}', weekday:'long', year:'numeric', month:'long', day:'numeric' });` : ''}
}
setInterval(update, 1000); update();
</script></body></html>`;
}

function renderWeather(c) {
  return `<!DOCTYPE html><html><head><style>
  * { margin:0; padding:0; box-sizing:border-box; }
  body { background:${safeCss(c.background, 'transparent')}; display:flex; align-items:center; justify-content:center; height:100vh; font-family:-apple-system,sans-serif; color:${safeCss(c.color, '#FFF')}; }
  .weather { text-align:center; }
  .temp { font-size:${safeNumber(c.font_size, 48)}px; font-weight:700; }
  .location { font-size:18px; opacity:0.7; margin-top:4px; }
  .desc { font-size:16px; opacity:0.6; margin-top:8px; }
  .icon { font-size:64px; }
</style></head><body>
<div class="weather">
  <div class="icon" id="icon"></div>
  <div class="temp" id="temp">--</div>
  <div class="location">${escapeHtml(c.location) || 'Unknown'}</div>
  <div class="desc" id="desc"></div>
</div>
<script>
async function load() {
  try {
    const r = await fetch('https://wttr.in/${encodeURIComponent(c.location || 'New York')}?format=j1');
    const d = await r.json();
    const cur = d.current_condition[0];
    const unit = '${c.units === 'metric' ? 'temp_C' : 'temp_F'}';
    const deg = '${c.units === 'metric' ? '°C' : '°F'}';
    document.getElementById('temp').textContent = cur[unit] + deg;
    document.getElementById('desc').textContent = cur.weatherDesc[0].value;
    const code = parseInt(cur.weatherCode);
    const icons = {113:'☀️',116:'⛅',119:'☁️',122:'☁️',143:'🌫️',176:'🌧️',200:'⛈️',227:'🌨️',260:'🌫️',263:'🌧️',266:'🌧️',293:'🌧️',296:'🌧️',299:'🌧️',302:'🌧️',305:'🌧️',308:'🌧️',311:'🌧️',314:'🌧️',317:'🌧️',320:'🌨️',323:'🌨️',326:'🌨️',329:'🌨️',332:'🌨️',335:'🌨️',338:'🌨️',350:'🌧️',353:'🌧️',356:'🌧️',359:'🌧️',362:'🌨️',365:'🌨️',368:'🌨️',371:'🌨️',374:'🌨️',377:'🌨️',386:'⛈️',389:'⛈️',392:'⛈️',395:'🌨️'};
    document.getElementById('icon').textContent = icons[code] || '🌡️';
  } catch(e) { document.getElementById('desc').textContent = 'Weather unavailable'; }
}
load(); setInterval(load, 600000);
</script></body></html>`;
}

function renderRSS(c) {
  // scroll_speed is authored in the UI as "seconds" (legacy field), but that used to be wired
  // straight into animation-duration: a *fixed total time* for the whole strip to cross the
  // screen. That makes the on-screen speed depend on how much content there is - a feed with
  // many items gets dragged through in the same {scroll_speed}s as a feed with one, so it
  // flies past far too fast, never lets the reader finish, and simply "jumps back to the
  // start" once the fixed duration is up. Instead we treat scroll_speed as calibrating a
  // constant px/sec rate (using one viewport-width per scroll_speed seconds as the reference,
  // matching prior behaviour for content that fits in one screen), then measure the actual
  // rendered width of the ticker and derive a duration long enough to move that full distance
  // at the same constant speed - so more items simply take proportionally longer, and every
  // item scrolls fully into and out of view before the loop restarts.
  const scrollSpeedSec = safeNumber(c.scroll_speed, 30);
  return `<!DOCTYPE html><html><head><style>
  * { margin:0; padding:0; box-sizing:border-box; }
  body { background:${safeCss(c.background, '#000')}; height:100vh; overflow:hidden; font-family:-apple-system,sans-serif; }
  .ticker { display:flex; align-items:center; height:100%; white-space:nowrap; position:relative; will-change:transform; }
  .item { display:inline-block; padding:0 40px; font-size:${safeNumber(c.font_size, 24)}px; color:${safeCss(c.color, '#FFF')}; }
  .item .title { font-weight:600; }
  .item .sep { margin:0 20px; opacity:0.3; }
</style></head><body>
<div class="ticker" id="ticker"><div class="item">Loading feed...</div></div>
<script>
var SCROLL_SPEED_SEC = ${scrollSpeedSec};
var ticker = document.getElementById('ticker');
var anim = null;
function restartAnimation() {
  if (anim) { anim.cancel(); anim = null; }
  var viewportW = window.innerWidth;
  var tickerW = ticker.scrollWidth;
  // Reference speed: one viewport-width travelled every SCROLL_SPEED_SEC seconds, so the
  // default of 30s behaves the same as before for a feed that fits within one screen.
  var pxPerSec = viewportW / SCROLL_SPEED_SEC;
  var distance = viewportW + tickerW; // starts fully off-screen right, ends fully off-screen left
  var durationMs = Math.max(1000, (distance / pxPerSec) * 1000);
  anim = ticker.animate(
    [
      { transform: 'translateX(' + viewportW + 'px)' },
      { transform: 'translateX(-' + tickerW + 'px)' },
    ],
    { duration: durationMs, iterations: Infinity, easing: 'linear' }
  );
}
async function load() {
  try {
    const r = await fetch('https://api.rss2json.com/v1/api.json?rss_url=' + encodeURIComponent('${escapeHtml(c.feed_url) || ''}'));
    const d = await r.json();
    const items = d.items?.slice(0, ${safeNumber(c.max_items, 10)}) || [];
    // NOTE: RSS feed titles are external content - using textContent instead of innerHTML to prevent XSS
    ticker.innerHTML = items.map(i => {
      const el = document.createElement('span'); el.textContent = i.title;
      return '<div class="item"><span class="title">' + el.innerHTML + '</span></div><div class="item sep">•</div>';
    }).join('') || '<div class="item">No items</div>';
  } catch(e) { ticker.innerHTML = '<div class="item">Feed unavailable</div>'; }
  requestAnimationFrame(restartAnimation);
}
window.addEventListener('resize', restartAnimation);
load(); setInterval(load, 300000);
</script></body></html>`;
}

function renderText(c, iframeSandbox = 'allow-scripts') {
  let html = c.html || '<p style="color:white;padding:20px">Empty text widget</p>';

  // LEGACY DESIGNER RESCUE — deliberately narrow.
  //
  // The Content Designer used to publish absolute font sizes as fontSize*10.8 px; today it emits
  // cqw (see designer.js). Converting px/108 back to vw restores the author's intended size and
  // makes those old widgets scale to any screen.
  //
  // It must NOT touch hand-authored HTML. This regex used to run over EVERY text widget, so
  // someone writing `font-size:16px` in the Text/HTML editor got 0.15vw — 2.8px on a 1080p
  // screen, and smaller still on anything narrower. Their text was not clipped or hidden; it was
  // rendered too small to read, in the one widget whose whole purpose is hand-written HTML.
  //
  // Designer output is identified by its absolutely-positioned elements, the same signal the
  // dashboard uses to decide whether a text widget can be reopened in the designer. Hand-written
  // markup keeps its px exactly as typed.
  const isDesignerAuthored = /position:\s*absolute;\s*left:/.test(html);
  if (isDesignerAuthored) {
    html = html.replace(/font-size:\s*([\d.]+)px/g, (match, px) => {
      return `font-size:${(parseFloat(px) / 108).toFixed(2)}vw`;
    });
  }

  // What to do when the text is taller than the screen. It used to be clipped in silence: the
  // document was overflow:hidden with no scrollbar and nothing to scroll it, so on a display
  // shorter than the content the bottom simply vanished — reported as "text goes to bottom and
  // disappears. It dont fit."
  //
  //   fit    (default) shrink until it fits. A no-op when the content already fits, so this
  //          rescues widgets that are currently losing text without altering ones that are fine.
  //   scroll pan through it on a loop, with a pause at each end. For content that is genuinely
  //          longer than a screen, where shrinking it would make it unreadable.
  //   clip   the old behaviour, kept because a designer-positioned layout may deliberately run
  //          past the edge and must not be rescaled underneath the author.
  const overflowMode = ['fit', 'scroll', 'clip'].includes(c.overflow) ? c.overflow : 'fit';

  // Runs inside the sandboxed iframe (allow-scripts, null origin). Measures after layout, after
  // web fonts settle, and on resize — a rotation or a resized zone changes the answer, and fonts
  // loading late is the classic cause of a fit that was computed against the wrong height.
  const fitScript = overflowMode === 'clip' ? '' : `<script>
  (function () {
    var mode = ${JSON.stringify(overflowMode)};
    var wrap = document.getElementById('st-wrap');
    if (!wrap) return;
    var anim = null;
    function apply() {
      // Reset before measuring, or we measure the previous transform's result.
      wrap.style.transform = '';
      if (anim) { anim.cancel(); anim = null; }
      var avail = document.documentElement.clientHeight;
      var need = wrap.scrollHeight;
      if (!avail || !need || need <= avail + 1) return;   // already fits: leave it alone
      if (mode === 'fit') {
        var k = avail / need;
        wrap.style.transformOrigin = 'top center';
        wrap.style.transform = 'scale(' + k + ')';
        return;
      }
      // scroll: hold, pan the overflow, hold, return. Speed is distance-based so a long
      // document is not unreadably fast and a short one is not tediously slow.
      var over = need - avail;
      var panMs = Math.max(4000, (over / 40) * 1000);
      var holdMs = 2000;
      var total = panMs * 2 + holdMs * 2;
      var p1 = holdMs / total, p2 = (holdMs + panMs) / total, p3 = (holdMs * 2 + panMs) / total;
      anim = wrap.animate(
        [
          { transform: 'translateY(0)', offset: 0 },
          { transform: 'translateY(0)', offset: p1 },
          { transform: 'translateY(' + (-over) + 'px)', offset: p2 },
          { transform: 'translateY(' + (-over) + 'px)', offset: p3 },
          { transform: 'translateY(0)', offset: 1 },
        ],
        { duration: total, iterations: Infinity, easing: 'linear' }
      );
    }
    addEventListener('resize', apply);
    if (document.fonts && document.fonts.ready) document.fonts.ready.then(apply).catch(function(){});
    // Late images change the height too; rAF lets first layout finish before measuring.
    addEventListener('load', function () { requestAnimationFrame(apply); });
    requestAnimationFrame(apply);
  })();
  </script>`;

  // Security: c.html / c.css are intentionally raw user-authored content, but the
  // render is public and same-origin with the dashboard - injected <script> could
  // otherwise read the dashboard's localStorage JWT. Render the user content inside
  // a sandboxed iframe with NO allow-same-origin: scripts still run (so legit
  // widget markup works) but in a null origin that can't touch the app's storage.
  const inner = `<!DOCTYPE html><html><head><style>
  * { margin:0; padding:0; box-sizing:border-box; }
  html, body { width:100vw; height:100vh; overflow:hidden; }
  /* The wrapper is what gets scaled or panned. It must be allowed to exceed the viewport,
     otherwise there is nothing to measure and nothing to move. */
  #st-wrap { width:100%; min-height:100%; will-change:transform; }
  ${c.css || ''}
</style></head><body><div id="st-wrap">${html}</div>${fitScript}</body></html>`;
  return `<!DOCTYPE html><html><head><style>
  * { margin:0; padding:0; }
  html, body { width:100vw; height:100vh; overflow:hidden; background:${safeCss(c.background, 'transparent')}; }
  iframe { width:100%; height:100%; border:0; display:block; }
</style></head><body><iframe sandbox="${escapeHtml(iframeSandbox)}" srcdoc="${escapeHtml(inner)}"></iframe></body></html>`;
}

function renderWebpage(c, iframeSandbox = 'allow-scripts') {
  const zoom = (c.zoom || 100) / 100;
  const invZoom = 100 / (c.zoom || 100) * 100;
  return `<!DOCTYPE html><html><head><style>
  * { margin:0; } body { height:100vh; overflow:hidden; }
  iframe { width:${invZoom}%; height:${invZoom}%; border:0; transform:scale(${zoom}); transform-origin:0 0; }
</style></head><body>
<iframe src="${escapeHtml(safeUrl(c.url))}" sandbox="${escapeHtml(iframeSandbox)}"></iframe>
${c.refresh_interval > 0 ? `<script>setInterval(()=>document.querySelector('iframe').src=document.querySelector('iframe').src,${c.refresh_interval * 1000});</script>` : ''}
</body></html>`;
}

function renderSocial(c) {
  return `<!DOCTYPE html><html><head><style>
  body { background:${safeCss(c.background, '#000')}; color:${safeCss(c.color, '#FFF')}; font-family:-apple-system,sans-serif; display:flex; align-items:center; justify-content:center; height:100vh; margin:0; }
</style></head><body>
<div style="text-align:center">
  <p style="font-size:24px">Social Feed</p>
  <p style="opacity:0.5;margin-top:8px">${escapeHtml(c.platform) || 'twitter'}: ${escapeHtml(c.query) || ''}</p>
  <p style="opacity:0.3;margin-top:16px;font-size:13px">Configure API key in widget settings</p>
</div></body></html>`;
}

// Directory Board — lobby tenant directory with scrolling content, header/footer,
// rotating background images, and anti-burn-in motion (pixel shift, bg pulse).
// All user-supplied strings are rendered via textContent in-browser, not inlined
// into HTML, so no server-side HTML escaping is needed for entries/categories.
function renderDirectoryBoard(c) {
  const configJson = JSON.stringify(c || {}).replace(/</g, '\\u003c');
  return `<!DOCTYPE html><html lang="en"><head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Directory</title>
<style>
  * { margin:0; padding:0; box-sizing:border-box; }
  html, body { width:100%; height:100%; overflow:hidden; }
  body {
    font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
    color:#fff;
    background:#1a1a2e;
    animation: bg-pulse 60s ease-in-out infinite;
  }
  body.light { color:#1a1a2e; background:#f5f5f5; animation: bg-pulse-light 60s ease-in-out infinite; }
  @keyframes bg-pulse { 0%,100% { background:#1a1a2e; } 50% { background:#1b1b30; } }
  @keyframes bg-pulse-light { 0%,100% { background:#f5f5f5; } 50% { background:#ededf0; } }

  .page { position:fixed; inset:0; overflow:hidden; transition: transform 1.5s ease; will-change: transform; }

  .bg-layer { position:absolute; inset:0; z-index:0; }
  .bg-img { position:absolute; inset:0; width:100%; height:100%; object-fit:cover; opacity:0; transition: opacity 2s ease-in-out; }
  .bg-img.active { opacity:0.30; }

  .header {
    position:absolute; top:0; left:0; right:0; z-index:2;
    padding:32px 48px 24px; text-align:center;
    background: linear-gradient(to bottom, rgba(0,0,0,0.55), rgba(0,0,0,0));
  }
  body.light .header { background: linear-gradient(to bottom, rgba(255,255,255,0.75), rgba(255,255,255,0)); }
  .header img.logo { max-height:160px; max-width:440px; object-fit:contain; margin-bottom:16px; }
  .header h1 { font-size:72px; font-weight:600; letter-spacing:0.02em; }

  .footer {
    position:absolute; bottom:0; left:0; right:0; z-index:2;
    padding:22px 48px; text-align:center;
    background: linear-gradient(to top, rgba(0,0,0,0.65), rgba(0,0,0,0));
    font-size:28px; color:#fff; line-height:1.3;
  }
  body.light .footer { color:#1a1a2e; background: linear-gradient(to top, rgba(255,255,255,0.85), rgba(255,255,255,0)); }

  .scroller {
    position:absolute; left:0; right:0; z-index:1;
    overflow:hidden;
    mask-image: linear-gradient(to bottom, transparent 0, #000 40px, #000 calc(100% - 40px), transparent 100%);
    -webkit-mask-image: linear-gradient(to bottom, transparent 0, #000 40px, #000 calc(100% - 40px), transparent 100%);
  }
  .track { position:absolute; top:0; left:0; right:0; }

  .category { padding:36px 0 16px; }
  .category h2 {
    text-align:center;
    font-size:52px;
    font-weight:500;
    letter-spacing:0.08em;
    text-transform:uppercase;
    opacity:0.9;
    padding-bottom:14px;
    border-bottom: 1px solid rgba(255,255,255,0.15);
    margin-bottom:22px;
  }
  body.light .category h2 { border-bottom-color: rgba(0,0,0,0.12); }

  .entries { display:grid; gap:14px 36px; }
  .entries[data-cols="auto"] { grid-template-columns: repeat(auto-fit, minmax(440px, 1fr)); }
  .entries[data-cols="1"] { grid-template-columns: 1fr; }
  .entries[data-cols="2"] { grid-template-columns: repeat(2, 1fr); }
  .entries[data-cols="3"] { grid-template-columns: repeat(3, 1fr); }
  .entries[data-cols="4"] { grid-template-columns: repeat(4, 1fr); }

  .entry { font-size:38px; line-height:1.35; color:#fff; display:flex; gap:14px; align-items:baseline; }
  .entry .id { font-weight:600; min-width:3.5em; flex-shrink:0; }
  .entry .text { display:flex; flex-direction:column; flex:1; min-width:0; }
  .entry .nm { font-weight:400; }
  .entry .sub { font-size:0.55em; opacity:0.65; margin-top:4px; line-height:1.3; font-weight:400; }
  .entry.available { color:#00ff00; }
  .entry.available .id { color:#00ff00; }
  body.light .entry { color:#1a1a2e; }
  body.light .entry.available, body.light .entry.available .id { color:#059669; }

  @media (max-width: 1280px) {
    .header h1 { font-size:54px; }
    .header img.logo { max-height:120px; }
    .category h2 { font-size:40px; }
    .entry { font-size:28px; }
    .footer { font-size:22px; padding:16px 32px; }
    .entries[data-cols="auto"] { grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); }
  }
</style>
</head>
<body>
  <div class="page" id="page">
    <div class="bg-layer" id="bgLayer"></div>
    <header class="header" id="header"></header>
    <div class="scroller" id="scroller">
    </div>
    <footer class="footer" id="footer"></footer>
  </div>

<script>
(function(){
  var cfg = ${configJson};
  var SPEEDS = { slow: 20, medium: 45, fast: 75 };

  if (cfg.theme === 'light') document.body.classList.add('light');
  var GAP_PX = 120; // blank space between the end of the directory and where it repeats (loop seam)
  var MIN_SCROLL_PX_SEC = 5; // anti-burn-in minimum when content fits
  var REFRESH_MS = 60000;    // poll data.json this often; re-render ONLY when entries changed

  // ----- header -----
  var header = document.getElementById('header');
  function safeImgUrl(u) {
    return typeof u === 'string' && (u.indexOf('/') === 0 || /^https?:\\/\\//.test(u) || /^data:image\\//.test(u)) ? u : '';
  }
  var logoSrc = safeImgUrl(cfg.logo_url);
  if (logoSrc) {
    var img = document.createElement('img');
    img.className = 'logo';
    img.src = logoSrc;
    img.alt = '';
    header.appendChild(img);
  }
  // A logo replaces the title text — showing both stacks the wordmark over the name.
  if (cfg.title && !logoSrc) {
    var h1 = document.createElement('h1');
    h1.textContent = cfg.title;
    header.appendChild(h1);
  }

  // ----- footer -----
  var footer = document.getElementById('footer');
  footer.textContent = cfg.footer_text || '';

  // ----- background images crossfade -----
  var bgLayer = document.getElementById('bgLayer');
  var bgs = Array.isArray(cfg.background_images) ? cfg.background_images.map(safeImgUrl).filter(Boolean) : [];
  var bgEls = [];
  bgs.forEach(function(url){
    var el = document.createElement('img');
    el.className = 'bg-img';
    el.src = url;
    el.alt = '';
    bgLayer.appendChild(el);
    bgEls.push(el);
  });
  if (bgEls.length > 0) {
    bgEls[0].classList.add('active');
    if (bgEls.length > 1) {
      var idx = 0;
      setInterval(function(){
        bgEls[idx].classList.remove('active');
        idx = (idx + 1) % bgEls.length;
        bgEls[idx].classList.add('active');
      }, 15000);
    }
  }

  // ----- layout the scroller between header and footer -----
  var scroller = document.getElementById('scroller');
  function layoutScroller() {
    var headerH = header.getBoundingClientRect().height;
    var footerH = footer.getBoundingClientRect().height;
    scroller.style.top = headerH + 'px';
    scroller.style.bottom = footerH + 'px';
  }
  layoutScroller();
  window.addEventListener('resize', layoutScroller);

  // ----- build directory content -----
  var cols = cfg.columns || 'auto';
  if (['auto','1','2','3','4'].indexOf(String(cols)) === -1) cols = 'auto';

  function buildCategoryEl(cat) {
    var catEl = document.createElement('div');
    catEl.className = 'category';
    var h2 = document.createElement('h2');
    h2.textContent = cat.name || '';
    catEl.appendChild(h2);
    var entries = document.createElement('div');
    entries.className = 'entries';
    entries.setAttribute('data-cols', String(cols));
    (cat.entries || []).forEach(function(e){
      var row = document.createElement('div');
      row.className = 'entry' + (e.available ? ' available' : '');
      var id = document.createElement('span');
      id.className = 'id';
      id.textContent = (e.identifier || '') + ':';
      var text = document.createElement('div');
      text.className = 'text';
      var nm = document.createElement('span');
      nm.className = 'nm';
      nm.textContent = e.name || '';
      text.appendChild(nm);
      if (e.subtitle) {
        var sub = document.createElement('span');
        sub.className = 'sub';
        sub.textContent = e.subtitle;
        text.appendChild(sub);
      }
      row.appendChild(id);
      row.appendChild(text);
      entries.appendChild(row);
    });
    catEl.appendChild(entries);
    return catEl;
  }

  var stage = scroller; // the clip window between header & footer
  var N = 4;            // panels in the ring (2 tile the screen, 1 dwells below, 1 above)
  var baseStyle = document.createElement('style');
  baseStyle.textContent =
    '.panel{ position:absolute; left:0; right:0; top:0; overflow:hidden; contain:paint; will-change:transform; backface-visibility:hidden; }' +
    '.pcontent{ position:absolute; left:0; right:0; top:0; padding:0 48px; }';
  document.head.appendChild(baseStyle);
  var scrollStyle = document.createElement('style');
  scrollStyle.id = 'dir-scroll-kf';
  document.head.appendChild(scrollStyle);

  // ----- scroll: a ring of compositor-animated, viewport-tall panels -----
  // Animating one tall track fails on Firefox (it won't composite a transform bigger than ~1.1x the
  // viewport / 4096px and falls back to a stuttering main-thread animation) and churns GPU tiles even
  // on Chromium. Instead we run N panels, each exactly one stage-height tall (overflow:hidden +
  // contain:paint clamp each compositor layer to that box). Each panel is a static window onto a full
  // copy of the directory (positioned by a static inner translateY = -slice); the PANEL is slid
  // rigidly upward by ONE CSS @keyframes animation, and the panels are phase-locked by negative
  // animation-delay so two always tile the screen while one dwells off-screen below and one above.
  // There is NO per-frame JS — "scrolling" is the compositor sliding pre-rasterized viewport-sized
  // textures, so nothing on the main thread (GC, extensions, the host player) can stutter it. On each
  // off-screen wrap a panel jumps its slice N screens ahead (content already built — nothing to load
  // when it reappears) and, if a data refresh is pending, rebuilds its content THEN, safely off-screen.
  var panels = [];       // [{el, content, version, slice}]
  var Sh = 0;            // panel / stage height
  var C = 0;             // looped directory height (one full copy)
  var speedPxSec = 0;
  var contentVersion = 0;
  var pending = null;    // a queued data refresh, picked up per-panel while off-screen

  function fillContent(el) { // full directory + a clone of the top (>= one screen) for the within-panel wrap
    var arr = Array.isArray(cfg.categories) ? cfg.categories : [];
    arr.forEach(function(c){ el.appendChild(buildCategoryEl(c)); });
    var full = el.scrollHeight; // == C (one full directory)
    var i = 0, guard = arr.length * 4 + 1;
    while ((el.scrollHeight - full) < Sh + 4 && arr.length && i < guard) {
      el.appendChild(buildCategoryEl(arr[i % arr.length])); i++;
    }
    return full;
  }

  function globalScroll() { return speedPxSec * ((document.timeline.currentTime || 0) / 1000); }
  function mod(a, n) { return n > 0 ? ((a % n) + n) % n : 0; }
  function setSlice(p, off) { p.slice = off; p.content.style.transform = 'translate3d(0,' + (-off) + 'px,0)'; }

  function seedSlices() { // four consecutive screens, matching the lanes' physical phase (delays 0..-3T)
    var base = globalScroll();
    var laneStart = [2 * Sh, 1 * Sh, 0, -1 * Sh];
    panels.forEach(function(p, i){ setSlice(p, mod(base + laneStart[i % 4], C)); });
  }

  function onWrap(p) { // fires as a panel wraps to the bottom (off-screen); rebuild + advance N screens
    if (pending && p.version !== pending.version) {
      p.content.replaceChildren();
      C = fillContent(p.content); // all panels share the same data => same C
      p.version = pending.version;
    }
    setSlice(p, mod(p.slice + N * Sh, C));
  }

  function setup() {
    layoutScroller();
    Sh = stage.getBoundingClientRect().height || window.innerHeight;
    stage.replaceChildren();
    panels = [];
    for (var i = 0; i < N; i++) {
      var el = document.createElement('div'); el.className = 'panel'; el.setAttribute('data-lane', i);
      el.style.height = Sh + 'px';
      var content = document.createElement('div'); content.className = 'pcontent';
      el.appendChild(content);
      stage.appendChild(el);
      panels.push({ el: el, content: content, version: contentVersion, slice: 0 });
    }
    C = fillContent(panels[0].content);
    for (var j = 1; j < N; j++) fillContent(panels[j].content);
    speedPxSec = (C <= Sh) ? MIN_SCROLL_PX_SEC : (SPEEDS[cfg.scroll_speed] || SPEEDS.medium);
    var T = Sh / speedPxSec, dur = N * T;
    var kf = '@keyframes dir-pan { from { transform: translate3d(0,' + (2 * Sh) + 'px,0); } to { transform: translate3d(0,' + (-2 * Sh) + 'px,0); } }';
    kf += '.panel{ animation: dir-pan ' + dur + 's linear infinite; }';
    for (var k = 0; k < N; k++) kf += '.panel[data-lane="' + k + '"]{ animation-delay: ' + (-k * T).toFixed(4) + 's; }';
    scrollStyle.textContent = kf;
    seedSlices();
    panels.forEach(function(p){ p.el.addEventListener('animationiteration', function(){ onWrap(p); }); });
  }

  // wait for images (logo + bgs) to load before the first layout, so heights are correct
  var pendingImgs = Array.from(document.images).filter(function(i){ return !i.complete; });
  if (pendingImgs.length === 0) {
    setup();
  } else {
    var built = false, build = function(){ if (!built) { built = true; setup(); } };
    pendingImgs.forEach(function(i){
      i.addEventListener('load', build, { once:true });
      i.addEventListener('error', build, { once:true });
    });
    setTimeout(build, 5000); // hard timeout so we never hang
  }

  // re-layout on resize (debounced) — rebuild the ring; globalScroll() keeps the same content position
  var rT;
  window.addEventListener('resize', function(){
    clearTimeout(rT);
    rT = setTimeout(setup, 250);
  });

  // ----- live data refresh: poll data.json; re-render ONLY when the entries changed -----
  // Mirrors the directory-search poll. data.json is THIS board's own feed (relative URL,
  // CORS-open, no-store). Diff the categories signature and rebuild IN PLACE only on a real
  // change, so an unchanged poll never touches the running scroll (no periodic reset).
  var lastSig = JSON.stringify(cfg.categories || []);
  setInterval(function(){
    if (document.hidden) return;
    fetch('data.json', { cache: 'no-store' })
      .then(function(r){ return r.ok ? r.json() : Promise.reject(r.status); })
      .then(function(data){
        var cats = data && Array.isArray(data.categories) ? data.categories : [];
        var sig = JSON.stringify(cats);
        if (sig === lastSig) return;      // unchanged -> leave the scroll running untouched
        lastSig = sig;
        cfg.categories = cats;
        contentVersion++;                 // queue it; each panel adopts it on its next off-screen wrap
        pending = { version: contentVersion };
      })
      .catch(function(){ /* transient error -> keep last-good board */ });
  }, REFRESH_MS);

  // ----- pixel shift (anti-burn-in): every 5 min, shift .page 0-3px random dir -----
  var page = document.getElementById('page');
  setInterval(function(){
    var dx = Math.floor(Math.random() * 7) - 3; // -3..+3
    var dy = Math.floor(Math.random() * 7) - 3;
    page.style.transform = 'translate(' + dx + 'px, ' + dy + 'px)';
  }, 5 * 60 * 1000);
})();
</script>
</body></html>`;
}

// Friendly full-page fallback when a directory-search points at a missing or
// non-directory-board source. Matches the "Unknown widget" fallback tone.
function renderDirectorySearchMissing() {
  return `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Directory Search</title></head>
<body style="margin:0;height:100vh;display:flex;align-items:center;justify-content:center;text-align:center;padding:24px;box-sizing:border-box;color:#fff;background:#1a1a2e;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif">
<div style="max-width:640px"><h1 style="font-size:2.2em;font-weight:600;margin:0 0 14px">Directory source not found</h1><p style="opacity:0.7;font-size:1.2em;margin:0;line-height:1.4">Pick a directory board in the widget settings.</p></div>
</body></html>`;
}

// Interactive walk-up search over an existing directory-board's entries. It
// REFERENCES the source board by id (no data copy): the board scrolls on a main
// screen while this lets someone find an entry instantly on a tablet.
function renderDirectorySearch(c) {
  c = c || {};
  const src = db.prepare('SELECT * FROM widgets WHERE id = ?').get(c.source_widget_id);
  if (!src || src.widget_type !== 'directory-board') return renderDirectorySearchMissing();
  let categories = [];
  try {
    const sc = JSON.parse(src.config || '{}');
    categories = Array.isArray(sc.categories) ? sc.categories : [];
  } catch (e) { categories = []; }

  // Inline everything the page needs as one JSON blob, guarded the same way the
  // board does. All user text is rendered via textContent below — never concat.
  const payload = {
    categories: categories,
    source_widget_id: src.id,
    title: c.title || '',
    logo_url: c.logo_url || '',
    theme: c.theme === 'light' ? 'light' : 'dark',
    placeholder_text: c.placeholder_text || 'Search…',
    show_onscreen_keyboard: c.show_onscreen_keyboard !== false,
  };
  const configJson = JSON.stringify(payload).replace(/</g, '\\u003c');
  return `<!DOCTYPE html><html lang="en"><head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Directory Search</title>
<style>
  * { margin:0; padding:0; box-sizing:border-box; }
  html, body { width:100%; height:100%; }
  body {
    font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
    color:#fff; background:#1a1a2e;
    display:flex; flex-direction:column; height:100vh; overflow:hidden;
  }
  body.light { color:#1a1a2e; background:#f5f5f5; }

  .header { flex:0 0 auto; text-align:center; padding:20px 24px 8px; }
  .header img.logo { max-height:90px; max-width:320px; object-fit:contain; margin:0 auto 8px; display:block; }
  .header h1 { font-size:40px; font-weight:600; letter-spacing:0.01em; }

  .searchbar { flex:0 0 auto; padding:10px 24px; }
  #q {
    width:100%; font-size:34px; padding:18px 22px; border-radius:14px; color:inherit; outline:none;
    border:2px solid rgba(255,255,255,0.2); background:rgba(255,255,255,0.08);
  }
  #q:focus { border-color:#4a9eff; }
  #q::placeholder { color:rgba(255,255,255,0.4); }
  body.light #q { border-color:rgba(0,0,0,0.15); background:#fff; }
  body.light #q:focus { border-color:#2563eb; }
  body.light #q::placeholder { color:rgba(0,0,0,0.4); }

  .results { flex:1 1 auto; overflow-y:auto; padding:8px 24px 16px; -webkit-overflow-scrolling:touch; }
  .msg { text-align:center; opacity:0.55; font-size:26px; padding:48px 16px; line-height:1.4; }

  .group { margin-bottom:22px; }
  .group h2 {
    font-size:22px; font-weight:500; letter-spacing:0.06em; text-transform:uppercase; opacity:0.6;
    padding:14px 0 8px; border-bottom:1px solid rgba(255,255,255,0.15); margin-bottom:10px;
  }
  body.light .group h2 { border-bottom-color:rgba(0,0,0,0.12); }

  .entry { display:flex; gap:14px; align-items:baseline; padding:10px 8px; font-size:30px; line-height:1.3; border-radius:8px; }
  .entry:nth-child(even) { background:rgba(255,255,255,0.03); }
  body.light .entry:nth-child(even) { background:rgba(0,0,0,0.03); }
  .entry .id { font-weight:700; min-width:2.6em; flex-shrink:0; }
  .entry .text { display:flex; flex-direction:column; flex:1; min-width:0; }
  .entry .nm { font-weight:400; }
  .entry .sub { font-size:0.6em; opacity:0.6; margin-top:3px; }
  .entry.available, .entry.available .id { color:#00ff00; }
  body.light .entry.available, body.light .entry.available .id { color:#059669; }

  /* The keyboard is sized against the VIEWPORT, not in fixed px. A panel's CSS viewport is its
     physical resolution divided by its density, so a 1080p screen at 240dpi presents only 1280x720
     CSS px - and a keyboard laid out for 1920x1080 then eats ~37% of the height instead of ~24%.
     The vh terms scale it down on short viewports; the clamp() maxima are the original values, so
     a 1080-tall viewport renders pixel-identically to before (5.3vh and 2.3vh both exceed their
     max at 1080 and clamp). The px minima keep the keys tappable on very short screens. */
  .keyboard { flex:0 0 auto; padding:clamp(5px,0.8vh,8px) 12px clamp(8px,1.3vh,14px); background:rgba(0,0,0,0.25); user-select:none; }
  body.light .keyboard { background:rgba(0,0,0,0.05); }
  .krow { display:flex; gap:clamp(4px,0.6vh,6px); justify-content:center; margin-bottom:clamp(4px,0.6vh,6px); }
  .key {
    flex:1 1 0; max-width:96px; min-width:0;
    height:clamp(34px,5.3vh,56px); font-size:clamp(15px,2.3vh,24px); text-transform:uppercase;
    border:0; border-radius:8px; background:rgba(255,255,255,0.12); color:inherit; cursor:pointer;
  }
  .key:active { background:#4a9eff; color:#fff; }
  body.light .key { background:#fff; box-shadow:0 1px 2px rgba(0,0,0,0.15); }
  .key-space { flex:4 1 0; max-width:none; text-transform:none; }
  .key-wide { flex:2 1 0; max-width:none; text-transform:none; }

  @media (max-width:700px) {
    .header h1 { font-size:30px; }
    #q { font-size:26px; padding:14px 16px; }
    .entry { font-size:24px; }
    /* .key is viewport-scaled above - no fixed override here, it would undo the clamp. */
  }
</style>
</head>
<body>
  <header class="header" id="header"></header>
  <div class="searchbar"><input id="q" type="text" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false"></div>
  <div class="results" id="results"></div>
  <div class="keyboard" id="keyboard"></div>
<script>
(function(){
  var cfg = ${configJson};
  if (cfg.theme === 'light') document.body.classList.add('light');

  function safeImgUrl(u) {
    return typeof u === 'string' && (u.indexOf('/') === 0 || /^https?:\\/\\//.test(u) || /^data:image\\//.test(u)) ? u : '';
  }

  // ----- header -----
  var header = document.getElementById('header');
  var logoSrc = safeImgUrl(cfg.logo_url);
  if (logoSrc) {
    var img = document.createElement('img');
    img.className = 'logo'; img.src = logoSrc; img.alt = '';
    header.appendChild(img);
  }
  // A logo replaces the title text — showing both stacks the wordmark over the name.
  if (cfg.title && !logoSrc) {
    var h1 = document.createElement('h1');
    h1.textContent = cfg.title;
    header.appendChild(h1);
  }
  if (!logoSrc && !cfg.title) header.style.display = 'none';

  // ----- flatten source entries (preserve category order) -----
  function buildFlat(categories) {
    var out = [];
    (Array.isArray(categories) ? categories : []).forEach(function(cat){
      var cn = cat && cat.name != null ? String(cat.name) : '';
      var entries = cat && Array.isArray(cat.entries) ? cat.entries : [];
      entries.forEach(function(e){
        var item = {
          cat: cn,
          identifier: e && e.identifier != null ? String(e.identifier) : '',
          name: e && e.name != null ? String(e.name) : '',
          subtitle: e && e.subtitle != null ? String(e.subtitle) : '',
          available: !!(e && e.available)
        };
        item._h = (item.identifier + ' ' + item.name + ' ' + item.subtitle).toLowerCase();
        out.push(item);
      });
    });
    return out;
  }
  var flat = buildFlat(cfg.categories);

  var input = document.getElementById('q');
  input.placeholder = cfg.placeholder_text || '';
  var results = document.getElementById('results');
  var HINT = 'Start typing to search the directory…';
  var NO_MATCHES = 'No matches';

  function showMessage(msg) {
    results.textContent = '';
    var d = document.createElement('div');
    d.className = 'msg';
    d.textContent = msg;
    results.appendChild(d);
  }

  function render(q) {
    q = (q || '').trim().toLowerCase();
    if (!q) { showMessage(HINT); return; }
    var matches = flat.filter(function(e){ return e._h.indexOf(q) !== -1; });
    if (!matches.length) { showMessage(NO_MATCHES); return; }
    var order = [], groups = {};
    matches.forEach(function(e){
      if (!groups[e.cat]) { groups[e.cat] = []; order.push(e.cat); }
      groups[e.cat].push(e);
    });
    results.textContent = '';
    order.forEach(function(cn){
      var group = document.createElement('div');
      group.className = 'group';
      if (cn) {
        var h2 = document.createElement('h2');
        h2.textContent = cn;
        group.appendChild(h2);
      }
      groups[cn].forEach(function(e){
        var row = document.createElement('div');
        row.className = 'entry' + (e.available ? ' available' : '');
        var id = document.createElement('span');
        id.className = 'id';
        id.textContent = e.identifier;
        var text = document.createElement('div');
        text.className = 'text';
        var nm = document.createElement('span');
        nm.className = 'nm';
        nm.textContent = e.name;
        text.appendChild(nm);
        if (e.subtitle) {
          var sub = document.createElement('span');
          sub.className = 'sub';
          sub.textContent = e.subtitle;
          text.appendChild(sub);
        }
        row.appendChild(id);
        row.appendChild(text);
        group.appendChild(row);
      });
      results.appendChild(group);
    });
    results.scrollTop = 0;
  }

  // ----- debounced input (~120ms) -----
  var dT;
  function onInput() { clearTimeout(dT); dT = setTimeout(function(){ render(input.value); }, 120); }
  input.addEventListener('input', onInput);

  // ----- on-screen keyboard (drives the same filter path as typing) -----
  if (cfg.show_onscreen_keyboard) {
    var kb = document.getElementById('keyboard');
    function press(ch) { input.value += ch; try { input.focus(); } catch(e){} onInput(); }
    ['1234567890','qwertyuiop','asdfghjkl','zxcvbnm'].forEach(function(r){
      var rowEl = document.createElement('div');
      rowEl.className = 'krow';
      r.split('').forEach(function(ch){
        var b = document.createElement('button');
        b.type = 'button'; b.className = 'key'; b.textContent = ch;
        b.addEventListener('click', function(){ press(ch); });
        rowEl.appendChild(b);
      });
      kb.appendChild(rowEl);
    });
    var act = document.createElement('div');
    act.className = 'krow';
    var back = document.createElement('button');
    back.type = 'button'; back.className = 'key key-wide'; back.textContent = '\\u232B';
    back.addEventListener('click', function(){ input.value = input.value.slice(0, -1); try { input.focus(); } catch(e){} onInput(); });
    var space = document.createElement('button');
    space.type = 'button'; space.className = 'key key-space'; space.textContent = 'space';
    space.addEventListener('click', function(){ press(' '); });
    var clear = document.createElement('button');
    clear.type = 'button'; clear.className = 'key key-wide'; clear.textContent = 'clear';
    clear.addEventListener('click', function(){ input.value = ''; try { input.focus(); } catch(e){} onInput(); });
    act.appendChild(back); act.appendChild(space); act.appendChild(clear);
    kb.appendChild(act);
  } else {
    var kbOff = document.getElementById('keyboard');
    if (kbOff) kbOff.style.display = 'none';
  }

  // ----- initial state + autofocus -----
  render('');
  try { input.focus(); } catch(e){}

  // ----- live sync: poll the source board so edits appear without a reload -----
  // The board's data.json sits next to this page (/api/widgets/<board>/data.json),
  // reached with a relative URL so it works behind any proxy/base path and from a
  // null-origin sandboxed iframe (data.json is CORS-open). We only rebuild + rerender
  // when the data actually changed, so a mid-search view isn't disturbed every tick.
  var SRC_ID = cfg.source_widget_id || '';
  var POLL_MS = 30000;
  var lastSig = JSON.stringify(cfg.categories || []);
  if (SRC_ID) {
    setInterval(function(){
      if (document.hidden) return;
      fetch('../' + encodeURIComponent(SRC_ID) + '/data.json', { cache: 'no-store' })
        .then(function(r){ return r.ok ? r.json() : Promise.reject(r.status); })
        .then(function(data){
          var cats = data && Array.isArray(data.categories) ? data.categories : [];
          var sig = JSON.stringify(cats);
          if (sig === lastSig) return;      // unchanged -> leave the view alone
          lastSig = sig;
          flat = buildFlat(cats);
          render(input.value);              // refresh results for the current query
        })
        .catch(function(){ /* transient error -> keep last-good data */ });
    }, POLL_MS);
  }
})();
</script>
</body></html>`;
}

// diag-smoothness: a self-contained frame-cadence tester for the ACTUAL panel. Two GPU-composited
// animations (a vertical scroll like the board + a fast sweep) plus a big on-screen HUD (FPS, refresh
// estimate, long-frame count, worst stall, SMOOTH/STALLING verdict) — so a stutter can be read off the
// panel screen with no console. If this stalls on real signage hardware, the hardware is the cause.
function renderDiagSmoothness(config) {
  return `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Smoothness Diagnostic</title><style>
  *{box-sizing:border-box;margin:0;padding:0}
  html,body{height:100%}
  body{background:#0a0d13;color:#cbd4e4;font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;overflow:hidden;height:100vh}
  .bar{position:absolute;top:0;left:0;right:0;padding:1.4vh 2vw;border-bottom:1px solid #20293a;background:#0f1420;z-index:5}
  .bar h1{font-size:2.2vh;font-weight:700;letter-spacing:.02em}
  .bar p{font-size:1.7vh;color:#6d7789;margin-top:.4vh}
  .bar b{color:#54a6ff}
  .stage{position:absolute;top:0;left:0;right:0;bottom:0}
  .col{position:absolute;top:0;left:0;width:52%;height:100%;overflow:hidden;border-right:1px solid #20293a}
  .roll{position:absolute;left:0;right:0;top:0;will-change:transform;animation:roll 30s linear infinite}
  @keyframes roll{from{transform:translate3d(0,0,0)}to{transform:translate3d(0,-50%,0)}}
  .row{display:flex;align-items:center;gap:1.4vw;padding:1.5vh 2vw;border-bottom:1px solid #20293a;font-family:ui-monospace,Menlo,Consolas,monospace;font-size:2.6vh}
  .row .n{color:#54a6ff;min-width:3.2em;font-variant-numeric:tabular-nums}
  .row:nth-child(3n) .n{color:#37d391}
  .sweep{position:absolute;top:0;right:0;width:48%;height:100%;background:repeating-linear-gradient(90deg,#0f1420 0 3vw,#182234 3vw 6vw)}
  .marker{position:absolute;top:0;bottom:0;width:.5vw;background:#f5b451;box-shadow:0 0 3vw #f5b451;will-change:transform;animation:sweep 2s linear infinite}
  @keyframes sweep{from{transform:translateX(0)}to{transform:translateX(calc(48vw - .5vw))}}
  .tag{position:absolute;top:1.5vh;font-family:ui-monospace,monospace;font-size:1.6vh;color:#6d7789;z-index:2}
  .col .tag{left:1.5vw;background:#0a0d13;padding:.4vh .8vw;border-radius:4px}
  .sweep .tag{right:1.5vw}
  .hud{position:absolute;left:50%;bottom:3vh;transform:translateX(-50%);background:rgba(12,16,24,.94);border:1px solid #20293a;border-radius:14px;padding:2.2vh 2.4vw;min-width:64vw;z-index:6;box-shadow:0 1.4vh 4vh rgba(0,0,0,.55)}
  .verdict{display:flex;align-items:center;gap:1.4vw;margin-bottom:1.8vh}
  .dot{width:1.8vh;height:1.8vh;border-radius:50%;background:#6d7789}
  .verdict.smooth .dot{background:#37d391;box-shadow:0 0 0 .6vh rgba(55,211,145,.16)}
  .verdict.stall .dot{background:#ff5d5d;box-shadow:0 0 0 .6vh rgba(255,93,93,.18)}
  .verdict .txt{font-size:3.4vh;font-weight:750;letter-spacing:.01em}
  .verdict.smooth .txt{color:#37d391}.verdict.stall .txt{color:#ff5d5d}
  .verdict .sub{font-size:1.9vh;color:#6d7789;font-weight:400;margin-left:auto;text-align:right}
  .grid{display:grid;grid-template-columns:repeat(4,1fr);gap:1.2vw;margin-bottom:1.4vh}
  .stat{background:#121826;border:1px solid #20293a;border-radius:10px;padding:1.2vh 1vw}
  .stat .k{font-size:1.4vh;text-transform:uppercase;letter-spacing:.08em;color:#6d7789}
  .stat .v{font-family:ui-monospace,Menlo,monospace;font-size:4vh;font-variant-numeric:tabular-nums;margin-top:.4vh}
  .stat .v small{font-size:1.8vh;color:#6d7789}
  .log{font-family:ui-monospace,Menlo,monospace;font-size:1.7vh;color:#6d7789;height:2.4vh;overflow:hidden}
  .log b{color:#ff5d5d}
  </style></head><body>
  <div class="bar"><h1>Panel Smoothness Diagnostic</h1><p>Two GPU-composited animations, zero app logic. If the scroll or the yellow bar <b>skips</b> — or the HUD reads STALLING — this <b>panel/hardware</b> is dropping frames.</p></div>
  <div class="stage">
    <div class="col"><div class="tag">TEST 1 &middot; vertical scroll</div><div class="roll" id="roll"></div></div>
    <div class="sweep"><div class="tag">TEST 2 &middot; fast sweep</div><div class="marker"></div></div>
    <div class="hud">
      <div class="verdict" id="verdict"><span class="dot"></span><span class="txt" id="vtxt">measuring&hellip;</span><span class="sub" id="vsub">collecting frames</span></div>
      <div class="grid">
        <div class="stat"><div class="k">FPS now</div><div class="v" id="fps">&ndash;</div></div>
        <div class="stat"><div class="k">Refresh est.</div><div class="v" id="hz">&ndash;<small> Hz</small></div></div>
        <div class="stat"><div class="k">Long frames</div><div class="v" id="long">0<small> &gt;50ms</small></div></div>
        <div class="stat"><div class="k">Worst stall</div><div class="v" id="worst">0<small> ms</small></div></div>
      </div>
      <div class="log" id="log">no stalls yet &middot; a healthy panel shows 0 long frames</div>
    </div>
  </div>
  <script>
  (function(){
    var roll=document.getElementById('roll'),half='',i;
    for(i=1;i<=26;i++){ half+='<div class="row"><span class="n">'+(100+i)+'</span><span class="t">Directory line '+i+'</span></div>'; }
    roll.innerHTML=half+half;
    var last=0,worst=0,longCount=0,recent=[],dts=[],started=0,lastPaint=0;
    var elFps=document.getElementById('fps'),elHz=document.getElementById('hz'),elLong=document.getElementById('long'),
        elWorst=document.getElementById('worst'),elLog=document.getElementById('log'),verdict=document.getElementById('verdict'),
        vtxt=document.getElementById('vtxt'),vsub=document.getElementById('vsub');
    function median(a){ var b=a.slice().sort(function(x,y){return x-y}); return b[Math.floor(b.length/2)]||0; }
    function paint(ts){
      var med=median(dts)||16.7;
      elFps.innerHTML=(1000/med).toFixed(0);
      elHz.innerHTML=(1000/med).toFixed(0)+'<small> Hz</small>';
      elLong.innerHTML=longCount+'<small> &gt;50ms</small>';
      elWorst.innerHTML=worst.toFixed(0)+'<small> ms</small>';
      if(recent.length) elLog.innerHTML=recent.join(' \\u00b7 ');
      var el=ts-started;
      if(el>4000){
        if(longCount===0){ verdict.className='verdict smooth'; vtxt.innerHTML='SMOOTH'; vsub.innerHTML='0 long frames &mdash; this panel animates cleanly'; }
        else { verdict.className='verdict stall'; vtxt.innerHTML='STALLING'; vsub.innerHTML=longCount+' long frame'+(longCount>1?'s':'')+' &mdash; this panel is dropping frames'; }
      } else { vsub.innerHTML='collecting frames&hellip; '+(el/1000).toFixed(0)+'s'; }
    }
    function frame(ts){
      if(!started) started=ts;
      if(last){ var dt=ts-last; dts.push(dt); if(dts.length>180) dts.shift();
        if(dt>50){ longCount++; if(dt>worst) worst=dt;
          recent.unshift('<b>'+dt.toFixed(0)+'ms</b> @ '+((ts-started)/1000).toFixed(0)+'s'); if(recent.length>3) recent.pop(); }
      }
      last=ts;
      if(ts-lastPaint>250){ lastPaint=ts; paint(ts); }
      requestAnimationFrame(frame);
    }
    requestAnimationFrame(frame);
    // the player appends ?device=<id> to the render URL so telemetry can be keyed to THIS panel.
    var DEVID=''; try{ var mm=(location.search||'').match(/[?&](?:device|d)=([^&]+)/); if(mm) DEVID=decodeURIComponent(mm[1]); }catch(e){}
    // report the snapshot back to the server (relative 'telemetry' -> /api/widgets/<id>/telemetry).
    // text/plain keeps it a CORS-simple request (no preflight) from the null-origin sandboxed iframe.
    function report(){
      try{
        var med=median(dts)||16.7, now=(typeof performance!=='undefined'&&performance.now)?performance.now():Date.now();
        var payload={device:DEVID,fps:Math.round(1000/med),refreshHz:Math.round(1000/med),longFrames:longCount,worstStallMs:Math.round(worst),
          elapsedS:started?Math.round((now-started)/1000):0,
          verdict:(started&&(now-started>4000))?(longCount?'STALLING':'SMOOTH'):'measuring',
          recent:recent.slice(0,3).map(function(s){return s.replace(/<[^>]+>/g,'');}),
          vp:window.innerWidth+'x'+window.innerHeight,dpr:window.devicePixelRatio||1,ua:(navigator.userAgent||'').slice(0,180)};
        fetch('telemetry',{method:'POST',headers:{'Content-Type':'text/plain'},body:JSON.stringify(payload),keepalive:true})['catch'](function(){});
      }catch(e){}
    }
    setInterval(report,2500);
  })();
  </script></body></html>`;
}

module.exports = router;
