const { v4: uuidv4 } = require('uuid');
const crypto = require('crypto');
const path = require('path');
const fs = require('fs');
const { db, pruneTelemetry, pruneScreenshots } = require('../db/database');
const { effectiveDeviceTz } = require('../lib/device-timezone');
const config = require('../config');
const heartbeat = require('../services/heartbeat');
const liveness = require('../lib/liveness'); // v4 core pass: pure ack/liveness/identity helpers
const commandQueue = require('../lib/command-queue');
const reconnectThrottle = require('../lib/reconnect-throttle');
const contentAckLimiter = require('../lib/content-ack-limiter');
const statusLogWriter = require('../lib/status-log-writer');
const { normalizeTransitions } = require('../lib/transition-config');
const { normalizeWallRotation } = require('../lib/wall-geometry'); // #236 per-panel mounting rotation
const { protectSocket } = require('../lib/safe-socket');
const flapLimiter = require('../lib/flap-limiter');
const sessionSettle = require('../lib/session-settle');   // #148 patch2: eviction-storm debounce
const { resolveIdentity } = require('../lib/device-identity');
const { resolveSyncBackend } = require('../lib/sync-backend');
const capsLib = require('../lib/player-capabilities');
const logCoalescer = require('../lib/log-coalescer');
const loopLag = require('../services/loop-lag');
const deviceSettings = require('../lib/device-settings'); // #150 delete+re-pair settings restore
const incidentClassify = require('../lib/incident-classify'); // offline-cause log: disconnect-reason + connectivity classification

// Debounce window for marking a device offline on socket disconnect. Brief
// flap (Wi-Fi blip, Engine.IO ping miss, server-side eviction-then-reconnect)
// shouldn't toggle the dashboard. If a fresh register lands within this
// window, the pending offline transition is cancelled. Per-device timer is
// stored here; cleared by the register handlers and by stale-disconnect
// guards. In-memory only - the heartbeat checker is the safety net for
// server-restart-during-grace-window edge cases (any 'online' rows whose
// last_heartbeat is older than heartbeatTimeout get marked offline by the
// next checker sweep within heartbeatInterval).
const pendingOfflines = new Map();
const OFFLINE_DEBOUNCE_MS = 5000;

// #146: socket ids we force-disconnected via evictPriorSocket because a NEWER socket
// took over the device. evictPriorSocket runs at register time BEFORE the new socket
// is put in the connection map (registerConnection is later in the same handler), so
// the evicted socket's disconnect handler would see the still-old map entry, pass the
// stale-disconnect guard, and ARM a fresh offline timer — re-marking the device that
// just reconnected offline (the self-reset race). Tagging the id here lets that
// disconnect handler bail out instead of arming a timer. Drained on consumption.
const evictedSockets = new Set();

// Proof-of-play write throttle. A player stuck in a tight loop (e.g. a playlist
// with 0-second item durations) fires device:play-event 'play_start' several
// times per second; unthrottled this once bloated play_logs to ~900k rows
// (~3 inserts/sec from a single web player). Cap proof-of-play inserts to at
// most one per device per PLAY_LOG_MIN_GAP_MS. The live dashboard progress
// event is still forwarded every time, so the UI is unaffected. In-memory only.
const lastPlayLogAt = new Map();
const PLAY_LOG_MIN_GAP_MS = 2000;
// Existence probes for the proof-of-play insert (see the play_start handler): the id a
// player reports comes from its CACHED playlist and can outlive the row it names.
const contentExists = db.prepare('SELECT 1 FROM content WHERE id = ?').pluck();
const widgetExists = db.prepare('SELECT 1 FROM widgets WHERE id = ?').pluck();

// #142 dedup + #143 per-device rate budget + global loop-lag valve for content-acks
// all live in one control: lib/content-ack-limiter.js (required above as
// contentAckLimiter). Kept out of this file so there is a single limiter on the path.

// #143 fingerprint-reclaim deferral log throttle: deviceId -> last-logged ms, so a
// device retrying reclaim every ~2s logs at most once per reclaimRejectLogWindowMs.
const lastReclaimRejectLogAt = new Map();
const { getUserPlan, getUserDeviceCount } = require('../middleware/subscription');
// Phase 2.3: deviceRoom() resolves a device_id to its workspace room so
// dashboardNs.emit can be scoped instead of broadcast platform-wide.
const { deviceRoom, emitToWorkspace } = require('../lib/socket-rooms');

function emitToDeviceWorkspace(dashboardNs, deviceId, event, payload) {
  emitToWorkspace(dashboardNs, deviceRoom(deviceId), event, payload);
}

// In-memory store for latest screenshot per device (avoids disk writes during streaming)
let lastScreenshots = {};

// Generate a random device token
// Persist the panel-reported device_info onto the devices row. Shared by device:register and the
// lightweight #160 device:info event (which re-reports after a volume/brightness change so the
// dashboard reflects it without a full re-register / playlist push). Older APKs omit newer fields.
function applyDeviceInfo(deviceId, di) {
  const num = (v) => (typeof v === 'number' ? v : null);
  // Upgrade incident: if the reported app_version differs from what we had stored, log it
  // (old → new) in the incident feed. Server-side, so it covers every client (Android/Tizen/web)
  // with no client change. Only when we HAD a prior version (a fresh pair isn't an "upgrade").
  try {
    if (di.app_version) {
      const prev = db.prepare('SELECT app_version FROM devices WHERE id = ?').get(deviceId);
      if (prev && prev.app_version && prev.app_version !== di.app_version) {
        db.prepare("INSERT INTO device_events (device_id, type, reason, detail) VALUES (?, 'upgrade', 'upgrade', ?)")
          .run(deviceId, `${prev.app_version} → ${di.app_version}`);
      }
    }
  } catch (_) { /* incident feed is best-effort */ }
  db.prepare(`UPDATE devices SET android_version = ?, app_version = ?, screen_width = ?, screen_height = ?, render_width = ?, render_height = ?,
    ota_status = ?, ota_target_version = ?, ota_attempts = ?, tier = ?, foreign_device_owner = ?,
    can_write_settings = ?, accessibility_enabled = ?, overlay_granted = ?,
    media_volume = ?, system_brightness = ?, window_brightness = ?, screen_off_timeout_ms = ?, ota_updated_at = strftime('%s','now') WHERE id = ?`)
    .run(di.android_version, di.app_version, di.screen_width, di.screen_height, di.render_width ?? null, di.render_height ?? null,
      di.ota_status ?? 'none', di.ota_target_version ?? null, di.ota_attempts ?? 0,
      Number.isInteger(di.tier) ? di.tier : 0, di.foreign_device_owner ? 1 : 0,
      di.can_write_settings ? 1 : 0, di.accessibility_enabled ? 1 : 0, di.overlay_granted ? 1 : 0,
      num(di.media_volume), num(di.system_brightness), num(di.window_brightness), num(di.screen_off_timeout_ms),
      deviceId);
}

/*
 * Persist panel-reported hardware identity (model / OS build / serial / which output).
 *
 * Deliberately NOT folded into applyDeviceInfo. That function is a blind full-row overwrite, and
 * an empty device_info once nulled seventeen columns every five minutes because `{}` is truthy —
 * the exact failure that degraded the browser player family. These fields arrive only on a full
 * register, so the same shape here would wipe them on every lightweight refresh.
 *
 * COALESCE is the guard: a report that omits a field leaves the stored value alone. Hardware
 * identity does not change under a device that is still the same device, so "no news" must mean
 * "unchanged", never "gone".
 *
 * Accepts the fields from device_info OR the top level. The player sends them at the top level
 * today; device_info is where every other panel-reported fact lives, so reading both means the
 * client can move without a flag day in either direction.
 */
function applyHardwareIdentity(deviceId, data) {
  const di = (data && data.device_info) || {};
  const str = (v) => (typeof v === 'string' && v.trim() ? v.trim().slice(0, 64) : null);
  const model = str(di.hardware_model ?? data.bs_model);
  const serial = str(di.hardware_serial ?? data.bs_serial);
  const osVersion = str(di.hardware_os_version ?? data.bs_os_version);
  const rawOutput = di.output_index ?? data.bs_screen;
  const output = Number.isInteger(rawOutput) && rawOutput > 0 ? rawOutput : null;

  if (model == null && serial == null && osVersion == null && output == null) return;

  db.prepare(`UPDATE devices SET
      hardware_model      = COALESCE(?, hardware_model),
      hardware_serial     = COALESCE(?, hardware_serial),
      hardware_os_version = COALESCE(?, hardware_os_version),
      output_index        = COALESCE(?, output_index)
    WHERE id = ?`)
    .run(model, serial, osVersion, output, deviceId);
}

/*
 * Persist what the player says it can do.
 *
 * Written on every register rather than change-detected like persistIdentity, because the set is
 * RUNTIME state, not identity: an Android panel gains remote.screenshot the moment accessibility
 * is switched on and loses the device-owner commands if it is demoted, with no reconnect and no
 * version change to notice. A stale set here means the dashboard hides a control the panel now
 * has, or offers one it just lost — the exact failure this whole mechanism exists to prevent.
 *
 * ⚠️ An absent declaration must leave the column ALONE. A player that does not declare (every
 * device in the field today, and any older build after an upgrade) has to keep falling back to its
 * platform baseline. Writing NULL would give the same outcome, but writing '[]' would strip its
 * entire UI — and those two are one typo apart, which is why parseDeclared's null return is
 * checked explicitly rather than folded into a COALESCE.
 *
 * An EMPTY declaration is the opposite case and is a real statement — a BrightSign widget with no
 * host bridge, say — so it is stored as '[]' and honoured rather than silently upgraded.
 */
function applyCapabilities(deviceId, data) {
  // Top level is where all four players put it; device_info is accepted too so a client that
  // nests it inside its identity block is not silently ignored.
  const raw = data && (data.capabilities ?? (data.device_info && data.device_info.capabilities));

  // parseDeclared drops names this server does not understand, so a newer player cannot grow the
  // column unboundedly, and returns null for both "absent" and "malformed" — neither of which is
  // a statement about the device, so both leave the baseline in charge.
  const declared = capsLib.parseDeclared(raw);
  if (declared === null) return;

  db.prepare('UPDATE devices SET capabilities = ? WHERE id = ?').run(JSON.stringify(declared), deviceId);
}

function generateDeviceToken() {
  return crypto.randomBytes(32).toString('hex');
}

// Validate device_id + device_token pair. Returns true if valid.
function validateDeviceToken(deviceId, token) {
  if (!deviceId || !token) return false;
  const row = db.prepare('SELECT device_token FROM devices WHERE id = ?').get(deviceId);
  if (!row || !row.device_token) return false;
  // Constant-time comparison to prevent timing attacks
  try {
    return crypto.timingSafeEqual(Buffer.from(row.device_token), Buffer.from(token));
  } catch {
    return false;
  }
}

function getClientIp(socket) {
  const forwarded = socket.handshake.headers['x-forwarded-for'];
  if (forwarded) return forwarded.split(',')[0].trim();
  return socket.handshake.address;
}

// #146: route status transitions through the batched, coalescing writer instead of
// an immediate INSERT-per-transition. A flapping device no longer writes a row per
// flap (the table-bloat feedback loop); the per-device age prune now lives in the
// writer and uses config.statusLogRetentionDays (was a hardcoded 7 days here — one
// source of truth). devices.status is still updated immediately by callers; only
// this audit log is deferred to the next flush.
function logDeviceStatus(deviceId, status, reason, detail) {
  statusLogWriter.record(deviceId, status, reason, detail);
}


// Build playlist payload with layout and zones
// Reads from published_snapshot (Phase 3) so draft edits don't affect live devices
// #group-sync: membership is the device_group_members m2m table (a device can be in several
// groups). Sync-eligible members = the group's members whose playlist MATCHES the group's shared
// playlist. A member on a different playlist is ignored (never synced) — index sync would be
// meaningless. Ordered by id for a stable auto-election.
// platform + ip_address are selected for the sync-backend resolver, not for election: it decides
// native-vs-ours from what the members ARE (BrightSign?) and where they are (one L2 network?).
function groupSyncMembers(group) {
  if (!group || !group.playlist_id) return [];
  return db.prepare(`
    SELECT d.id, d.status, d.platform, d.ip_address FROM devices d
    JOIN device_group_members dgm ON dgm.device_id = d.id
    WHERE dgm.group_id = ? AND d.playlist_id = ? ORDER BY d.id
  `).all(group.id, group.playlist_id);
}

// Elect the group's sync leader: the pinned leader if it's an online, playlist-matching member;
// else the first online matching member; else the first matching member (stable id while all
// offline). null if the group has no eligible members.
function resolveGroupLeader(group) {
  const members = groupSyncMembers(group);
  if (!members.length) return null;
  const online = members.filter(m => m.status === 'online');
  if (group.leader_device_id && online.some(m => m.id === group.leader_device_id)) return group.leader_device_id;
  if (online.length) return online[0].id;
  return members[0].id;
}

// The device's sync group: a sync-enabled group it belongs to (m2m) whose shared playlist THIS
// device is on. Deterministic pick if it's somehow in several. Returns the group row or null.
function deviceSyncGroup(deviceId, devicePlaylistId) {
  if (!devicePlaylistId) return null;
  return db.prepare(`
    SELECT g.id, g.sync_enabled, g.playlist_id, g.leader_device_id, g.sync_backend
    FROM device_groups g JOIN device_group_members dgm ON dgm.group_id = g.id
    WHERE dgm.device_id = ? AND g.sync_enabled = 1 AND g.playlist_id = ?
    ORDER BY g.name ASC, g.id ASC LIMIT 1
  `).get(deviceId, devicePlaylistId) || null;
}

// Build the group_sync block for a device, or null (the playlist-match guard lives in deviceSyncGroup).
function resolveGroupSync(device, deviceId) {
  const group = deviceSyncGroup(deviceId, device?.playlist_id);
  if (!group) return null;
  const members = groupSyncMembers(group);
  const leaderId = resolveGroupLeader(group);
  if (!leaderId) return null;

  // Which protocol this group actually runs. The decision lives in one pure function so the
  // dashboard, the tests and this payload can never disagree about it — an operator being told
  // "native sync" while the players ran ours would be undebuggable.
  const decision = resolveSyncBackend(group.sync_backend, members);

  // Native sync is leader/follower and the leader broadcasts; ours is leaderless. A group whose
  // elected leader is OFFLINE would therefore sit unsynchronised on the native protocol — nobody
  // is announcing — where our own clock-derived sync carries on regardless. So fall back rather
  // than leave a wall frozen on whatever it happened to be showing.
  const leaderOnline = members.some(m => m.id === leaderId && m.status === 'online');
  let backend = decision.backend;
  let reason = decision.reason;
  let downgraded = decision.downgraded;
  if (backend === 'brightsign' && !leaderOnline) {
    backend = 'screentinker';
    reason = 'the group leader is offline — native sync has nobody to broadcast';
    downgraded = true;
  }

  return {
    group_id: group.id,
    is_leader: leaderId === deviceId,
    backend,
    // Carried to the player for logging, and to the dashboard so an operator can see WHY a
    // requested backend was refused instead of guessing.
    sync_reason: reason,
    sync_downgraded: downgraded,
  };
}

// A widget's CONTENT is always live — /api/widgets/:id/render reads the current config — but the
// playlist payload is a snapshot taken at publish time, so a widget edited afterwards still carried
// its published revision. The player keeps a widget's WebView while its URL is unchanged (re-
// navigating a widget every duration is a visible flash and destroys widget state), so an unchanged
// URL meant an edit only reached the screen after an app restart.
//
// Refreshing the rev here, at send time, makes the URL differ exactly when the content differs —
// and only then, so the anti-flash reuse still holds for widgets nobody has touched.
const widgetFactsOf = db.prepare(`
  SELECT w.updated_at AS rev,
         COALESCE(o.widget_sandbox_isolation_disabled, 0) AS same_origin
  FROM widgets w
  LEFT JOIN workspaces ws ON ws.id = w.workspace_id
  LEFT JOIN organizations o ON o.id = ws.organization_id
  WHERE w.id = ?
`);
function refreshWidgetRevs(assignments) {
  if (!Array.isArray(assignments)) return;
  for (const a of assignments) {
    if (!a || !a.widget_id) continue;
    try {
      const facts = widgetFactsOf.get(a.widget_id);
      if (!facts) continue;
      a.widget_rev = facts.rev ?? a.widget_rev ?? 0;
      a.widget_allow_same_origin = Number(facts.same_origin || 0) === 1;
    } catch (_) { /* keep published */ }
  }
}

/*
 * The same problem for uploaded media, with a worse failure mode.
 *
 * PUT /api/content/:id/replace swaps the BYTES behind a stable content id, and every player caches
 * media by that id. The URL does not change, so a cached copy is not merely stale until the next
 * refresh — it is stale forever, on every panel that already holds it, with no way for the player
 * to find out. That is the price of caching for offline: an asset that can never be updated.
 *
 * Stamping the revision at send time gives the players a value that changes exactly when the bytes
 * change. They put it in the request URL, so a replaced asset is a cache MISS everywhere at once,
 * and an asset nobody touched is not.
 *
 * COALESCE because a database migrated from before the column existed backfills it once, but rows
 * inserted between the ALTER and that backfill carry 0 — resolving those to created_at keeps the
 * revision stable and truthful rather than collapsing every new upload onto the same "0".
 */
const contentFactsOf = db.prepare(`
  SELECT COALESCE(NULLIF(updated_at, 0), created_at) AS rev, filepath, mime_type, file_size
    FROM content WHERE id = ?
`);
function refreshContentRevs(assignments) {
  if (!Array.isArray(assignments)) return;
  for (const a of assignments) {
    if (!a || !a.content_id) continue;
    try {
      const row = contentFactsOf.get(a.content_id);
      if (!row) continue;                       // deleted mid-flight; the purge sweeps the snapshot
      a.content_rev = row.rev ?? a.content_rev ?? 0;
      // A replace writes a NEW randomly-named file and unlinks the old one, so the filepath in a
      // published snapshot points at a file that no longer exists. The web player builds its media
      // URL from exactly that field: replacing an asset 404'd every web and BrightSign panel until
      // somebody thought to republish the playlist. Refreshing it here is the same fix as the
      // widget rev above — the snapshot is a snapshot of the ARRANGEMENT, not of the bytes.
      if (row.filepath) a.filepath = row.filepath;
      if (row.mime_type) a.mime_type = row.mime_type;
      if (row.file_size != null) a.file_size = row.file_size;
    } catch (_) { /* keep published */ }
  }
}

function buildPlaylistPayload(deviceId) {
  const device = db.prepare('SELECT playlist_id, layout_id, orientation, wall_id, timezone, reported_timezone FROM devices WHERE id = ?').get(deviceId);

  let assignments = [];
  if (device?.playlist_id) {
    const playlist = db.prepare('SELECT published_snapshot FROM playlists WHERE id = ?').get(device.playlist_id);
    if (playlist?.published_snapshot) {
      try { assignments = JSON.parse(playlist.published_snapshot); } catch (e) { assignments = []; }
      refreshWidgetRevs(assignments);
      refreshContentRevs(assignments);
    }
  }

  let layout = null;
  if (device?.layout_id) {
    layout = db.prepare('SELECT * FROM layouts WHERE id = ?').get(device.layout_id);
    if (layout) {
      layout.zones = db.prepare('SELECT * FROM layout_zones WHERE layout_id = ? ORDER BY sort_order').all(layout.id);
    }
  }

  // Wall membership flips the player into wall mode. The renderer needs two
  // rectangles in canvas-space: this device's screen rect, and the wall's
  // player rect. The intersection is what this screen displays. The leader
  // drives playback; followers track via wall:sync.
  let wall_config = null;
  if (device?.wall_id) {
    const wall = db.prepare('SELECT * FROM video_walls WHERE id = ?').get(device.wall_id);
    const pos = db.prepare('SELECT * FROM video_wall_devices WHERE wall_id = ? AND device_id = ?').get(device.wall_id, deviceId);
    if (wall && pos) {
      const baseW = 320, baseH = 180;
      const bezelH = wall.bezel_h_mm || 0;
      const bezelV = wall.bezel_v_mm || 0;

      // Backfill canvas rect from grid math when canvas_* is unset (legacy
      // walls that haven't been touched by the new editor yet). Coords are
      // rounded to integers so sub-pixel drift can't cause two visually
      // identical rects to compute different stage offsets.
      const screenRect = {
        x: Math.round(pos.canvas_x ?? (pos.grid_col * (baseW + bezelH))),
        y: Math.round(pos.canvas_y ?? (pos.grid_row * (baseH + bezelV))),
        w: Math.round(pos.canvas_width ?? baseW),
        h: Math.round(pos.canvas_height ?? baseH),
      };

      // Player rect defaults to the bounding box of all screens on the wall.
      let playerRect;
      if (wall.player_x !== null && wall.player_x !== undefined) {
        playerRect = { x: wall.player_x, y: wall.player_y, w: wall.player_width, h: wall.player_height };
      } else {
        const all = db.prepare('SELECT * FROM video_wall_devices WHERE wall_id = ?').all(wall.id);
        let x = Infinity, y = Infinity, x2 = -Infinity, y2 = -Infinity;
        for (const p of all) {
          const px = p.canvas_x ?? (p.grid_col * (baseW + bezelH));
          const py = p.canvas_y ?? (p.grid_row * (baseH + bezelV));
          const pw = p.canvas_width ?? baseW;
          const ph = p.canvas_height ?? baseH;
          if (px < x) x = px;
          if (py < y) y = py;
          if (px + pw > x2) x2 = px + pw;
          if (py + ph > y2) y2 = py + ph;
        }
        playerRect = isFinite(x)
          ? { x, y, w: x2 - x, h: y2 - y }
          : { x: 0, y: 0, w: baseW, h: baseH };
      }
      // Round the player rect too — same rationale.
      playerRect = {
        x: Math.round(playerRect.x), y: Math.round(playerRect.y),
        w: Math.round(playerRect.w), h: Math.round(playerRect.h),
      };

      wall_config = {
        wall_id: wall.id,
        screen_rect: screenRect,
        player_rect: playerRect,
        is_leader: wall.leader_device_id === deviceId,
        // #236: how far this panel's own image has to be turned to come out upright on the wall.
        // Normalised here so a junk column value can never reach a player and stand one panel of a
        // live wall on its side — a bad rotation degrades to "as drawn", not to "sideways".
        rotation: normalizeWallRotation(pos.rotation),
      };
    }
  }

  // #74/#75: the effective IANA timezone the player evaluates schedule blocks in.
  // An explicit (non-default) devices.timezone override wins; otherwise the player's
  // last OS-reported zone; otherwise null = the player trusts its own OS clock.
  // Shared with routes/schedules.js via lib/device-timezone — creation and evaluation
  // MUST resolve the same zone, or a schedule runs in a different one than it was written in.
  const timezone = effectiveDeviceTz(device);
  // #group-sync: synchronized group playback (wall takes precedence — a wall member is never
  // also group-synced). Null unless the device is on a sync-enabled group's matching playlist.
  const group_sync = wall_config ? null : resolveGroupSync(device, deviceId);
  // #104: shared shape + zone-reset tail so the device payload and the dashboard
  // preview payload (GET /api/playlists/:id/preview-payload) can never drift.
  return assemblePayload({ assignments, layout, orientation: device?.orientation || 'landscape', wall_config, group_sync, timezone });
}

// #104: the canonical player payload shape, shared by the device path
// (buildPlaylistPayload) and the device-free dashboard preview.
// Zone reset: if this isn't a real multi-zone layout (single zone or no layout),
// strip any leftover zone_id so content falls back to the fullscreen renderer
// instead of binding to a now-gone left/right zone and never playing.
function assemblePayload({ assignments, layout, orientation, wall_config, group_sync, timezone }) {
  let a = Array.isArray(assignments) ? assignments : [];
  // Transition widgets are normalized OUT here (the single device+preview chokepoint): each is dropped
  // from the visible list and its config attached as an opaque `transition` on the item it plays into.
  // Old players simply see no transition widget and ignore the field (hard cut) — no regression.
  a = normalizeTransitions(a);
  const zoneCount = layout?.zones?.length || 0;
  if (zoneCount < 2) a = a.map(x => (x && x.zone_id != null ? { ...x, zone_id: null } : x));
  return {
    assignments: a,
    layout: layout || null,
    orientation: orientation || 'landscape',
    wall_config: wall_config || null,
    group_sync: group_sync || null,
    timezone: timezone || null,
  };
}

// Check if a device should show trial expired screen
function checkDeviceAccess(deviceId) {
  const device = db.prepare('SELECT * FROM devices WHERE id = ?').get(deviceId);
  if (!device || !device.user_id) return { allowed: true };

  const plan = getUserPlan(device.user_id);
  if (!plan) return { allowed: true };

  // Check if trial expired and over free limit
  if (plan.trial_started && !plan.trial_active && plan.plan_name === 'free') {
    const deviceCount = getUserDeviceCount(device.user_id);
    // Get this device's position (ordered by created_at)
    const userDevices = db.prepare('SELECT id FROM devices WHERE user_id = ? ORDER BY created_at ASC').all(device.user_id);
    const deviceIndex = userDevices.findIndex(d => d.id === deviceId);

    // Only the first device (within free limit) is allowed
    if (deviceIndex >= plan.max_devices) {
      return {
        allowed: false,
        reason: 'trial_expired',
        message: 'Trial Expired',
        detail: 'Upgrade your plan to continue using this display.',
      };
    }
  }

  // Check if over plan device limit (non-trial)
  if (!plan.trial_started && plan.max_devices > 0) {
    const userDevices = db.prepare('SELECT id FROM devices WHERE user_id = ? ORDER BY created_at ASC').all(device.user_id);
    const deviceIndex = userDevices.findIndex(d => d.id === deviceId);
    if (deviceIndex >= plan.max_devices) {
      return {
        allowed: false,
        reason: 'device_limit',
        message: 'Device Limit Reached',
        detail: 'Upgrade your plan to activate this display.',
      };
    }
  }

  return { allowed: true };
}

// v4 core-pass helpers (module scope; db is a ready singleton at require time).
const _deviceExistsStmt = db.prepare('SELECT 1 FROM devices WHERE id = ?');
function deviceExists(id) { return !!(id && _deviceExistsStmt.get(id)); }
const _identityReadStmt = db.prepare('SELECT client_type, client_version, platform, contract_version FROM devices WHERE id = ?');
const _persistIdentityStmt = db.prepare('UPDATE devices SET client_type = ?, client_version = ?, platform = ?, contract_version = ? WHERE id = ?');
function persistIdentity(deviceId, data) {
  if (!deviceId) return;
  // FIX 3: capture-don't-act; degrades to legacy/unknown for old clients; NEVER breaks register.
  // A1 change-detection: only WRITE when the identity actually changed vs stored. A genuine
  // reconnect with unchanged identity (the common case, incl. flapping / re-pair churn) does a cheap
  // read and NO write — no UPDATE, no WAL churn. First provision (stored NULLs) and a real change
  // (e.g. new client_version after an OTA) still write.
  try {
    const stored = _identityReadStmt.get(deviceId);
    // ⚠️ A register that does not mention `platform` must not ERASE the one we have — see
    // liveness.preserveKnownIdentity for why that column is load-bearing.
    const i = liveness.preserveKnownIdentity(stored, liveness.captureIdentity(data));
    if (!liveness.identityChanged(stored, i)) return; // unchanged — skip the write
    _persistIdentityStmt.run(i.client_type, i.client_version, i.platform, i.contract_version, deviceId);
  } catch (e) { /* identity capture must never break registration */ }
}

/*
 * One ingest for a screenshot, whatever carried it.
 *
 * The socket path is how every self-capturing player delivers. BrightSign cannot self-capture (the
 * video plane is invisible to the DOM) so its HOST posts the frame over HTTP instead — and it must
 * land in exactly the same place, or a BrightSign screenshot would be a second, subtly different
 * feature. Returns false only when the sockets are not up yet.
 */
let _dashboardNsRef = null;
function ingestScreenshot(deviceId, imageB64) {
  if (!deviceId || !imageB64) return false;
  // Same cap as the socket path enforced: max 2MB base64 (~1.5MB image).
  if (imageB64.length > 2 * 1024 * 1024) return false;
  if (!_dashboardNsRef) return false;

  if (!lastScreenshots) lastScreenshots = {};
  lastScreenshots[deviceId] = imageB64;

  try {
    emitToDeviceWorkspace(_dashboardNsRef, deviceId, 'dashboard:screenshot-ready', {
      device_id: deviceId,
      image_data: `data:image/jpeg;base64,${imageB64}`,
      timestamp: Date.now(),
    });
  } catch (err) {
    console.error('Screenshot relay error:', err);
  }
  return true;
}


module.exports = function setupDeviceSocket(io) {
  // Expose helpers for use by route handlers
  module.exports.lastScreenshots = lastScreenshots;
  module.exports.buildPlaylistPayload = buildPlaylistPayload;
  module.exports.assemblePayload = assemblePayload;
  module.exports.generateDeviceToken = generateDeviceToken;
  const deviceNs = io.of('/device');
  _dashboardNsRef = io.of('/dashboard');   // so ingestScreenshot() can relay from an HTTP route too
  const dashboardNs = io.of('/dashboard');

  // Disconnect any existing socket that is currently registered for this device_id.
  // Called when a fresh registration comes in for the same device so the old (likely
  // half-dead) socket can't fire its disconnect handler and clobber the new entry.
  function evictPriorSocket(deviceId, exceptSocketId) {
    const prior = heartbeat.getConnection(deviceId);
    if (!prior || prior.socketId === exceptSocketId) return;
    const oldSocket = deviceNs.sockets.get(prior.socketId);
    if (oldSocket) {
      console.log(`Evicting prior socket ${prior.socketId} for device ${deviceId}`);
      // Mark BEFORE disconnect: disconnect(true) fires the old socket's 'disconnect'
      // handler synchronously, so the flag must already be set when it runs.
      evictedSockets.add(prior.socketId);
      try { oldSocket.disconnect(true); } catch (_) { evictedSockets.delete(prior.socketId); }
    }
  }

  deviceNs.on('connection', (socket) => {
    console.log(`Device socket connected: ${socket.id}`);
    let currentDeviceId = null;
    let authenticated = false; // Track whether this socket has been authenticated

    // #146: wrap every handler on THIS socket so a throw disconnects only this device
    // (logged with its id) instead of crashing the whole server. Backstop to the
    // per-site try/catch in the handlers below.
    protectSocket(socket, () => currentDeviceId);

    // Device registers with a pairing code (first time) or device_id + device_token (reconnect)
    socket.on('device:register', (data) => {
      const { pairing_code, device_id, device_token, device_info, fingerprint, hw_fingerprint } = data;

      // #146: resolve identity ONCE via the SNAT-safe chain (device_id -> fingerprint
      // -> token -> global anon), used by BOTH the operator block and the flap limiter.
      const ident = resolveIdentity({ device_id, fingerprint, device_token });

      // #143 operator KILL SWITCH — the FIRST gate, before the fingerprint block, the
      // throttle, any DB writes, or playlist build. #146: resolve the effective
      // device_id via the identity chain (device_id directly, OR fingerprint->device_id)
      // so a blocked device that reconnects WITHOUT a device_id is STILL caught — the
      // old `if (device_id)` gate let a device_id-less reconnect slip past. Settable by
      // DIRECT SQLite during an outage (dashboard down), takes effect on the device's
      // NEXT register with NO restart (the row is re-read every register):
      //   UPDATE devices SET blocked = 1 WHERE id = '<device_id>';   (0 to unblock)
      // Unlike nulling the token (#143: that re-provisioned instead of locking out),
      // `blocked` is an explicit, enforceable lever. Also settable via the dashboard
      // (routes/devices.js POST /:id/block) — same DB write, same next-register effect.
      if (ident.deviceId) {
        const blk = db.prepare('SELECT blocked FROM devices WHERE id = ?').get(ident.deviceId);
        if (blk && blk.blocked) {
          console.warn(`[blocked] refused device ${ident.deviceId} (operator block, via ${ident.kind})`);
          socket.emit('device:auth-error', { error: 'Device blocked' });
          process.nextTick(() => { try { socket.disconnect(true); } catch (_) { /* */ } });
          return;
        }
      }

      // #146 Item B: SUSTAINED flap limiter — BEFORE fingerprint tracking, throttle,
      // DB writes, or playlist build, so a refusal is cheap. Skips same-socket playlist
      // refreshes (currentDeviceId===device_id — a periodic pull, not a new connection).
      // Keyed via the same SNAT-safe identity, NEVER IP.
      const isRefreshConnect = device_id && currentDeviceId === device_id;
      if (!isRefreshConnect) {
        // #148: a paired + AUTHENTICATED device reconnecting is exempt from the flap
        // QUARANTINE (not from the soft cooldown). validateDeviceToken confirms device_id +
        // a matching STORED token (false for missing/mismatch), so a spoofed device_id can't
        // claim the exemption — an attacker without the real token is still quarantinable.
        const paired = !!device_id && validateDeviceToken(device_id, device_token);
        const fv = flapLimiter.check(ident.key, Date.now(), { paired });
        if (!fv.allow) {
          // #146 P0: auto-quarantine is IN-MEMORY + TIME-LIMITED (lib/flap-limiter),
          // never a DB block — a stuck-then-recovered device self-heals. The
          // devices.blocked column is now written ONLY by an operator. Log the
          // quarantine START once; coalesce the repeat refusals.
          if (fv.quarantined) {
            console.warn(`[flap] quarantined ${ident.deviceId || ident.key} for ${Math.round(config.connectRateQuarantineMs / 60000)}m after ${fv.trips} trips`);
          } else {
            logCoalescer.record(`flap-refused:${ident.key}`, `[flap] refused ${ident.kind} ${ident.deviceId || ident.key} reason=${fv.reason}`);
          }
          socket.emit('device:throttled', { retry_after_ms: fv.retryAfterMs, reason: 'connect_rate' });
          process.nextTick(() => { try { socket.disconnect(true); } catch (_) { /* */ } });
          return;
        }
      }

      // Track device fingerprint to prevent reinstall abuse
      if (fingerprint) {
        try {
          let existing = db.prepare('SELECT * FROM device_fingerprints WHERE fingerprint = ?').get(fingerprint);

          // MIGRATION ONLY, and only for a caller that has ALREADY proved who it is.
          //
          // An existing player keeps its device_id and token across an update, so it authenticates
          // by token and merely needs its stored fingerprint moved to the new salted form. That is
          // safe: identity was established before we got here, and the hint is only used to find
          // the row that identity already belongs to.
          //
          // A caller WITHOUT credentials must never resolve through the hardware hint, however
          // few rows it appears to match. The value describes a MODEL, not a unit — every
          // identical panel emits the same one — so "exactly one row" means one row was recorded,
          // not that one display exists. Two UniFi Pro Displays at DIFFERENT sites both produced
          // web-m73u8w-5f; whichever connected second would have been handed the other's row,
          // token and content. Such a caller falls through and is provisioned a new device, which
          // costs the operator one pairing code and is the only answer that cannot be wrong.
          //
          // Clients that send no hw_fingerprint (older players, and the APK/.wgt whose fingerprint
          // is a genuinely per-unit hardware id) never enter this branch at all and behave exactly
          // as before.
          const tokenProven = !!(device_id && validateDeviceToken(device_id, device_token));
          if (!existing && hw_fingerprint && tokenProven) {
            const candidates = db.prepare(
              'SELECT * FROM device_fingerprints WHERE (hw_fingerprint = ? OR fingerprint = ?) AND device_id = ?')
              .all(hw_fingerprint, hw_fingerprint, device_id);
            if (candidates.length === 1) {
              const prior = candidates[0].fingerprint;
              db.prepare('UPDATE device_fingerprints SET fingerprint = ?, hw_fingerprint = ? WHERE fingerprint = ?')
                .run(fingerprint, hw_fingerprint, prior);
              existing = { ...candidates[0], fingerprint, hw_fingerprint };
              console.log(`[fingerprint] migrated ${prior} -> per-install identity for authenticated device ${device_id}`);
            }
          }
          if (existing) {
            // device_id arrives from the client and can name a row that no longer exists (a
            // reconnect after the device was deleted — the same case that emits device:unpaired).
            // device_fingerprints.device_id has an FK to devices(id), so writing a stale id
            // throws, the catch below swallows it, and the WHOLE fingerprint block is abandoned
            // — including the #150 settings restore that a post-delete re-pair depends on.
            // Prefer the incoming id, fall back to what is already stored, and only ever write
            // an id that still resolves. Same guard as the INSERT path below; this UPDATE was
            // missed, and it is the one that actually fires (37 FK failures on prod).
            const known = (id) => !!(id && db.prepare('SELECT 1 FROM devices WHERE id = ?').get(id));
            const fpDeviceId = known(device_id) ? device_id
              : (known(existing.device_id) ? existing.device_id : null);
            db.prepare("UPDATE device_fingerprints SET last_seen = strftime('%s','now'), device_id = ?, hw_fingerprint = COALESCE(?, hw_fingerprint) WHERE fingerprint = ?")
              .run(fpDeviceId, hw_fingerprint || null, fingerprint);
            // If this fingerprint was previously registered to a different device, block the new registration
            if (!device_id && existing.device_id && pairing_code) {
              // Someone reinstalled - link them back to existing device
              const oldDevice = db.prepare('SELECT * FROM devices WHERE id = ?').get(existing.device_id);
              if (oldDevice) {
                // Fingerprint reclaim guard: a leaked/duplicated fingerprint shouldn't be enough
                // to take over a LIVE device. #143: decide "still alive" from RUNTIME signals —
                // a live socket, OR a genuinely recent heartbeat (within the settle window). The
                // old check used `secondsSince < 24h`, which treated a device merely offline <24h
                // as "active": a legitimately-gone device (liveConn=false, status=offline, stale
                // heartbeat) could never reclaim and retried every ~2s, flooding logs (Bold beta1
                // / 2febcaa9, 1984694c, 139159eb). NOT a missing clear — liveConn IS removed on
                // disconnect + the offline-timeout sweep, and status IS set offline on both; the
                // 24h TIME gate was the cause. A device gone by every runtime signal is reclaimable.
                const liveConn = heartbeat.getConnection(existing.device_id);
                const lastBeat = oldDevice.last_heartbeat || 0;
                const secondsSince = Math.floor(Date.now() / 1000) - lastBeat;
                // Bold 1.9.3->1.9.6 upgrade fix: a reinstalled panel registers with
                // { pairing_code, fingerprint } and NO device_id — it is asking to be
                // PROVISIONED, not to reclaim. The ONLY grounds to reject is a genuinely
                // LIVE socket on the old device row (stops a duplicated fingerprint from
                // hijacking an actively-connected display). The previous guard also rejected
                // on a merely-recent heartbeat (secondsSince < reclaimSettleSeconds), which
                // on an in-place-reinstall upgrade is ALWAYS true — so it returned before the
                // pairing_code INSERT and the code the player was displaying was never created,
                // leaving the dashboard with "code does not exist" until device_fingerprints
                // was cleared by hand. The real security boundary is the operator claiming the
                // code in the dashboard; a cloned fingerprint that falls through only gets an
                // unclaimed, tokenless, content-less row — harmless.
                // Pairing-race (1.9.9): a device mid-DEFERRED-OFFLINE is NOT genuinely live.
                // On disconnect we defer heartbeat.removeConnection() by OFFLINE_DEBOUNCE_MS
                // (anti-flap), so heartbeat.getConnection() keeps returning a ZOMBIE entry for the
                // just-closed socket for the whole grace window. A same-fingerprint reconnect inside
                // that window (this branch is only reached via a device_fingerprints match, so it
                // inherently IS the same physical display) would otherwise hit a FALSE-POSITIVE
                // "active on another connection" reject, and for an unclaimed row the retry then
                // collided on UNIQUE(devices.pairing_code) and wedged the player. Gate the reject on
                // there being NO pending-offline timer for this device.
                //
                // ANTI-HIJACK BOUNDARY (unchanged): a DIFFERENT physical device presenting a CLONED
                // fingerprint while the REAL device is actively connected never disconnected -> no
                // pending-offline timer is armed -> inDeferredOffline is false -> the liveConn reject
                // STILL fires. We ONLY relax the reject when the matched row's OWN connection is in
                // its deferred-offline grace (i.e. it just dropped) — a legitimate reconnect of that
                // same display. Even then, a CLAIMED row is reclaimed only because liveConn (the real
                // anti-hijack check) already passed, and an UNCLAIMED fall-through only ever yields a
                // tokenless, content-less row: the operator claiming the on-screen code stays the
                // real trust boundary, so relaxing this reject grants an attacker nothing.
                const inDeferredOffline = pendingOfflines.has(existing.device_id);
                if (liveConn && !inDeferredOffline) {
                  // Log at most once per device per window so a retrying/stuck device can't flood stdout.
                  const nowMs = Date.now();
                  if (nowMs - (lastReclaimRejectLogAt.get(existing.device_id) || 0) >= config.reclaimRejectLogWindowMs) {
                    lastReclaimRejectLogAt.set(existing.device_id, nowMs);
                    console.warn(`Fingerprint reclaim rejected for ${existing.device_id}: old device has a LIVE socket (status=${oldDevice.status}, ${secondsSince}s since heartbeat)`);
                  }
                  socket.emit('device:auth-error', {
                    error: 'This display is currently active on another connection.'
                  });
                  return;
                }
                // No live socket from here on — the old connection is gone.
                lastReclaimRejectLogAt.delete(existing.device_id);
                if (oldDevice.user_id) {
                  // The old row is CLAIMED. A reinstalled panel (MDM wiped its data, so it presents
                  // only its stable fingerprint — no device_id, no token) must REMATCH to this row —
                  // preserving the claim, name, playlist, and content — instead of provisioning a
                  // stranger the operator has to re-pair across the whole fleet. device:paired below
                  // drives the app off the pairing screen, so the fresh code it was displaying is
                  // irrelevant. We reclaim regardless of the settle window: liveConn (checked above)
                  // is the real anti-hijack boundary; the settle delay only postponed a
                  // fingerprint-only reclaim the server already grants once the window elapses.

                // Fingerprint matched — this is a reinstalled app reconnecting to its old device.
                // Issue a fresh token so the app can authenticate going forward.
                const newToken = generateDeviceToken();
                db.prepare('UPDATE devices SET device_token = ? WHERE id = ?').run(newToken, existing.device_id);
                console.log(`Fingerprint match: linking reinstalled app to existing device ${existing.device_id} (new token issued)`);
                authenticated = true;
                // Cancel any pending offline timer - device is back in the grace window
                if (pendingOfflines.has(existing.device_id)) {
                  clearTimeout(pendingOfflines.get(existing.device_id));
                  pendingOfflines.delete(existing.device_id);
                }
                evictPriorSocket(existing.device_id, socket.id);
                db.prepare("UPDATE devices SET status = 'online', last_heartbeat = strftime('%s','now'), ip_address = ?, updated_at = strftime('%s','now'), offline_reason = NULL, offline_reason_at = NULL, offline_detail = NULL WHERE id = ?")
                  .run(getClientIp(socket), existing.device_id);
                socket.emit('device:registered', { device_id: existing.device_id, device_token: newToken, status: 'online' });
                // If device was already claimed by a user, tell the player it's paired
                if (oldDevice.user_id) {
                  socket.emit('device:paired', {
                    device_id: oldDevice.id,
                    name: oldDevice.name || 'Display',
                    settings_pin: oldDevice.settings_pin || undefined
                  });
                }
                currentDeviceId = existing.device_id;
                heartbeat.registerConnection(existing.device_id, socket.id);
                heartbeat.recordReconnect(existing.device_id);   // FIX 2: churn signal
                persistIdentity(existing.device_id, data);       // FIX 3: identity capture
                socket.join(existing.device_id);
                logDeviceStatus(existing.device_id, 'online');
                emitToDeviceWorkspace(dashboardNs, existing.device_id, 'dashboard:device-status', { device_id: existing.device_id, status: 'online', liveness: heartbeat.livenessFor(existing.device_id) });
                // Flush any commands/playlist-updates queued while this device was offline.
                commandQueue.flushQueue(deviceNs, existing.device_id, buildPlaylistPayload);
                // Send playlist
                const access = checkDeviceAccess(existing.device_id);
                if (!access.allowed) {
                  socket.emit('device:playlist-update', { assignments: [], suspended: true, message: access.message, detail: access.detail });
                } else {
                  socket.emit('device:playlist-update', buildPlaylistPayload(existing.device_id));
                }
                return;
                }
                // The old row is UNCLAIMED (never paired). Reclaiming it would leave it carrying a
                // stale/null pairing_code while the player shows a fresh one -> "code does not exist".
                // Fall through to the pairing_code path below, which provisions a fresh row with the
                // on-screen code and (#150) relinks the fingerprint to it. reclaimSettleSeconds no
                // longer gates this path — a genuinely live socket (above) is the only rejection.
                //
                // IDEMPOTENCY (pairing-race 1.9.9): EXCEPT when the reconnecting player presents the
                // SAME pairing_code this unclaimed row already holds. That is exactly the zombie case
                // Fix A now lets through: the row is mid-deferred-offline and STILL holds the on-screen
                // code, so the fall-through INSERT below collides on UNIQUE(devices.pairing_code) and
                // wedges the player unclaimed with no content. The stale-code hazard in the comment
                // above applies ONLY when the codes DIFFER; when they MATCH there is no stale code to
                // strand, so ADOPT the existing row (mirror the claimed-reclaim refresh above) instead
                // of re-INSERTing. Deliberately NO device:paired — the row is unclaimed and the
                // operator must still claim the on-screen code (the real trust boundary is untouched).
                if (pairing_code === oldDevice.pairing_code) {
                  const newToken = generateDeviceToken();
                  db.prepare('UPDATE devices SET device_token = ? WHERE id = ?').run(newToken, existing.device_id);
                  console.log(`Fingerprint match: adopting UNCLAIMED same-code row ${existing.device_id} (code ${pairing_code}) instead of re-provisioning`);
                  authenticated = true;
                  // Cancel any pending offline timer - device is back in the grace window
                  if (pendingOfflines.has(existing.device_id)) {
                    clearTimeout(pendingOfflines.get(existing.device_id));
                    pendingOfflines.delete(existing.device_id);
                  }
                  evictPriorSocket(existing.device_id, socket.id);
                  db.prepare("UPDATE devices SET status = 'online', last_heartbeat = strftime('%s','now'), ip_address = ?, updated_at = strftime('%s','now'), offline_reason = NULL, offline_reason_at = NULL, offline_detail = NULL WHERE id = ?")
                    .run(getClientIp(socket), existing.device_id);
                  socket.emit('device:registered', { device_id: existing.device_id, device_token: newToken, status: 'online' });
                  // No device:paired — the row is unclaimed; the player stays on the pairing screen
                  // showing its (still-valid) code for the operator to claim.
                  currentDeviceId = existing.device_id;
                  heartbeat.registerConnection(existing.device_id, socket.id);
                  heartbeat.recordReconnect(existing.device_id);
                  persistIdentity(existing.device_id, data);
                  socket.join(existing.device_id);
                  logDeviceStatus(existing.device_id, 'online');
                  emitToDeviceWorkspace(dashboardNs, existing.device_id, 'dashboard:device-status', { device_id: existing.device_id, status: 'online', liveness: heartbeat.livenessFor(existing.device_id) });
                  // Flush any commands/playlist-updates queued while this device was offline.
                  commandQueue.flushQueue(deviceNs, existing.device_id, buildPlaylistPayload);
                  // Send playlist
                  const access = checkDeviceAccess(existing.device_id);
                  if (!access.allowed) {
                    socket.emit('device:playlist-update', { assignments: [], suspended: true, message: access.message, detail: access.detail });
                  } else {
                    socket.emit('device:playlist-update', buildPlaylistPayload(existing.device_id));
                  }
                  return;
                }
              }
            }
          } else if (device_id || pairing_code) {
            // device_id can be stale (e.g. a reconnect after the device row was
            // deleted). device_fingerprints.device_id has an FK to devices(id), and
            // INSERT OR IGNORE does NOT suppress FK violations - so null out an
            // unknown id instead of letting it throw (was a caught, noisy error).
            const fpDeviceId = (device_id && db.prepare('SELECT 1 FROM devices WHERE id = ?').get(device_id)) ? device_id : null;
            db.prepare("INSERT OR IGNORE INTO device_fingerprints (fingerprint, device_id, hw_fingerprint) VALUES (?, ?, ?)")
              .run(fingerprint, fpDeviceId, hw_fingerprint || null);
          }
        } catch (e) {
          console.error('Fingerprint tracking error:', e.message);
        }
      }

      if (device_id) {
        // Reconnecting known device — require valid token
        const device = db.prepare('SELECT * FROM devices WHERE id = ?').get(device_id);
        if (device) {
          // A re-register on the SAME socket is a playlist REFRESH, not a reconnect: the
          // player re-emits device:register every ~45-60s (requestPlaylistRefresh) to pull a
          // fresh playlist, and the socket never dropped. currentDeviceId is still null on a
          // genuinely new socket and already === device_id on a same-socket refresh. Tracking
          // this stops a healthy device (~2000 re-registers/day) from spamming "Device
          // reconnected" and reading as connection instability (#134 — there were 1415
          // "reconnected" logs against only ~30 real socket connects and 0 heartbeat timeouts).
          const isPlaylistRefresh = currentDeviceId === device_id;
          // #143 AUTH FIX: an already-provisioned device (it has a row — every row,
          // even `provisioning`, is created WITH a token) presenting a null/empty/
          // invalid token is NOT authenticated — reject and disconnect. The old guard
          // `device.device_token && !validate(...)` short-circuited on a NULL stored
          // token, so nulling a device's token RE-PROVISIONED it (auth skipped + a
          // fresh token minted) instead of locking it out (Bold #143 / 75c2a08a).
          // validateDeviceToken already returns false for null-stored/missing/mismatch.
          // First pairing is the pairing_code path below (no device_id) — unaffected.
          if (!validateDeviceToken(device_id, device_token)) {
            console.warn(`Invalid/missing device token for ${device_id} from ${getClientIp(socket)} — received_len=${(device_token || '').length}, has_stored_token=${!!device.device_token}`);
            socket.emit('device:auth-error', { error: 'Invalid device token' });
            return;
          }

          // #142: per-device reconnect throttle. Only GENUINE reconnects (a new
          // socket) count — same-socket playlist refreshes (isPlaylistRefresh) are
          // exempt. This runs BEFORE the heavy register work (DB writes, playlist
          // build) so a single flapping device cannot saturate the event loop. The
          // verdict is per-device; global lag only scales an already-flagged
          // device's backoff, never gates a healthy one.
          if (!isPlaylistRefresh) {
            const verdict = reconnectThrottle.check(device_id);
            if (!verdict.allow) {
              console.warn(`[throttle] device ${device_id} reconnect throttled: reason=${verdict.reason} band=${verdict.band} observed=${verdict.observed}/${verdict.allowed} per ${config.reconnectWindowMs}ms -> backoff ${verdict.retryAfterMs}ms (level ${verdict.level})`);
              socket.emit('device:throttled', { retry_after_ms: verdict.retryAfterMs, reason: 'reconnect_rate' });
              // nextTick disconnect so the throttle notice flushes first.
              process.nextTick(() => { try { socket.disconnect(true); } catch (_) { /* */ } });
              return;
            }
          }

          // #148 patch2: SESSION-SETTLE debounce. A device opening duplicate/rapid sockets
          // must converge on ONE live connection and stay online, not churn through evictions
          // (the reconnect-throttle's 30s post-restart warm-up skips this — this does NOT).
          // If a LIVE incumbent exists and we accepted a socket for this device within the
          // settle window, soft-refuse THIS duplicate and keep the incumbent.
          // LIVENESS SAFEGUARD (load-bearing): only hold when the incumbent socket is actually
          // in the namespace — a dead/half-open incumbent is replaced below, NEVER stranding
          // the device offline. Soft refusal (paired-safe), never a quarantine.
          const priorConn = heartbeat.getConnection(device_id);
          const incumbentAlive = !!(priorConn && priorConn.socketId !== socket.id && deviceNs.sockets.has(priorConn.socketId));
          if (sessionSettle.shouldHold(device_id, incumbentAlive)) {
            logCoalescer.record(`settle:${device_id}`, `[settle] device ${device_id} keeping live incumbent ${priorConn.socketId}; soft-refusing duplicate ${socket.id}`);
            evictedSockets.add(socket.id);   // this refused socket's disconnect must NOT touch device state
            socket.emit('device:throttled', { retry_after_ms: config.sessionSettleWindowMs, reason: 'session_settle' });
            process.nextTick(() => { try { socket.disconnect(true); } catch (_) { evictedSockets.delete(socket.id); } });
            return;
          }

          currentDeviceId = device_id;
          authenticated = true;
          // Cancel any pending offline timer - device is back in the grace window
          if (pendingOfflines.has(device_id)) {
            clearTimeout(pendingOfflines.get(device_id));
            pendingOfflines.delete(device_id);
          }
          evictPriorSocket(device_id, socket.id);
          sessionSettle.accepted(device_id);   // #148 patch2: (re)arm the settle window on an accepted connection
          db.prepare("UPDATE devices SET status = 'online', last_heartbeat = strftime('%s','now'), ip_address = ?, updated_at = strftime('%s','now'), offline_reason = NULL, offline_reason_at = NULL, offline_detail = NULL WHERE id = ?")
            .run(getClientIp(socket), device_id);

          // #143: past the validateDeviceToken gate above the stored token is
          // guaranteed non-null, so we just echo it back. The old "mint a token for a
          // null-token device" path is removed — that was the re-provisioning vector.
          const tokenToSend = device.device_token;

          // An EMPTY device_info means "I have nothing new to tell you", not "wipe what you know".
          // The web/BrightSign player's refresh-register sends `device_info: {}` on a 300s timer,
          // and `{}` is truthy — so every five minutes applyDeviceInfo, which is a blind full-row
          // overwrite with no per-field presence check, bound `undefined` for all 17 columns.
          // better-sqlite3 stores those as NULL rather than throwing, so the write succeeded:
          // android_version, app_version, screen_width/height, render_*, ota_*, tier, the four
          // capability flags and the four volume/brightness columns were all nulled. Fleet view,
          // resolution diagnostics and version-based logic read blank for exactly the client family
          // that cannot be inspected any other way. The code around this already anticipates the
          // shape — recordReconnect/persistIdentity are gated behind `if (!isPlaylistRefresh)` —
          // this call was the one that was not.
          if (device_info && Object.keys(device_info).length > 0) applyDeviceInfo(device_id, device_info);
          // AFTER applyDeviceInfo, and unconditionally: these fields ride the top level of the
          // register payload, not device_info, so the emptiness guard above does not apply to
          // them. The function no-ops when the panel reports none of them.
          applyHardwareIdentity(device_id, data);
          // Same reasoning, same unconditional call: capabilities ride the top level and the
          // function no-ops when the panel declares nothing.
          applyCapabilities(device_id, data);

          heartbeat.registerConnection(device_id, socket.id);
          // #134: a same-socket re-register is a playlist REFRESH (~45-60s), NOT a reconnect and NOT
          // a new identity. Match the existing !isPlaylistRefresh gates (:486/:605): don't count it as
          // churn (A2 — else healthy refreshers cross DEGRADED_RECONNECTS and show Degraded) and don't
          // re-write identity (A1 — else a sync UPDATE + WAL churn every ~45-60s per device).
          if (!isPlaylistRefresh) {
            heartbeat.recordReconnect(device_id);     // genuine reconnect only
            persistIdentity(device_id, data);         // change-detected write (see persistIdentity)
          }
          socket.join(device_id);
          // The device just proved its identity (device_token, timing-safe). Clear any OTA
          // rate-backoff held against it: /api/update/check is unauthenticated and takes a
          // caller-supplied ?device_id=, so that bucket can have been burned by anyone who
          // merely knows this UUID. A genuine reconnect is the proof that lets us forgive it.
          try { require('../lib/ota-breaker').forgiveDevice(device_id); } catch (_) { /* non-fatal */ }
          socket.emit('device:registered', { device_id, device_token: tokenToSend, status: 'online' });
          // #143: a device paired/claimed server-side (user_id set) that RECONNECTS must be told
          // it's paired — the app leaves the Connect page ONLY on 'device:paired' (web: hides the
          // setup screen; Android ProvisioningActivity.onPaired -> MainActivity). The
          // /api/provision/pair endpoint pushes device:paired to a LIVE socket at pair time
          // (server.js), but a screen paired while disconnected — or that reconnects after pairing
          // — never received it and sat on the Connect page forever showing the URL (Bold #143).
          // Re-send the exact event the client already listens for; no client change needed.
          if (device.user_id) {
            socket.emit('device:paired', { device_id, name: device.name || 'Display', settings_pin: device.settings_pin || undefined });
          }
          logDeviceStatus(device_id, 'online');
          // Flush any commands/playlist-updates queued while this device was offline.
          commandQueue.flushQueue(deviceNs, device_id, buildPlaylistPayload);

          // If this device is part of a wall, re-evaluate leadership.
          // Preferred leader = online member with smallest (canvas_x +
          // canvas_y), falling back to grid 0,0. If the original leader
          // (top-left tile) is back, they reclaim the role and peers re-sync.
          if (device.wall_id) {
            try {
              const wall = db.prepare('SELECT * FROM video_walls WHERE id = ?').get(device.wall_id);
              if (wall) {
                const candidates = db.prepare(`
                  SELECT vwd.device_id, vwd.canvas_x, vwd.canvas_y, vwd.grid_col, vwd.grid_row
                  FROM video_wall_devices vwd
                  JOIN devices d ON d.id = vwd.device_id
                  WHERE vwd.wall_id = ? AND d.status = 'online'
                `).all(wall.id);
                if (candidates.length > 0) {
                  const score = (c) => (c.canvas_x ?? c.grid_col * 320) + (c.canvas_y ?? c.grid_row * 180);
                  candidates.sort((a, b) => score(a) - score(b));
                  const preferredLeader = candidates[0].device_id;
                  if (wall.leader_device_id !== preferredLeader) {
                    db.prepare('UPDATE video_walls SET leader_device_id = ? WHERE id = ?').run(preferredLeader, wall.id);
                    console.log(`Wall ${wall.id} leader reassigned to ${preferredLeader} on reconnect`);
                    // Re-push payload to every member so role flags refresh.
                    const members = db.prepare('SELECT device_id FROM video_wall_devices WHERE wall_id = ?').all(wall.id);
                    for (const m of members) {
                      if (m.device_id !== device_id) {
                        commandQueue.queueOrEmitPlaylistUpdate(deviceNs, m.device_id, buildPlaylistPayload);
                      }
                    }
                  }
                }
              }
            } catch (e) { console.error('Wall leader reclaim failed:', e.message); }
          }

          // #group-sync: on (re)connect of a sync-group member, re-push the payload to the OTHER
          // sync-eligible members so their is_leader flag refreshes — this self-heals leadership
          // when the pinned leader returns or a first-online fallback takes over. The effective
          // leader is COMPUTED (resolveGroupLeader), never persisted, so the operator's pin is
          // preserved. The connecting device gets its own payload below.
          try {
            const syncGroups = db.prepare(`
              SELECT g.id, g.sync_enabled, g.playlist_id FROM device_groups g
              JOIN device_group_members dgm ON dgm.group_id = g.id
              WHERE dgm.device_id = ? AND g.sync_enabled = 1 AND g.playlist_id IS NOT NULL
            `).all(device_id);
            for (const group of syncGroups) {
              for (const m of groupSyncMembers(group)) {
                if (m.id !== device_id) commandQueue.queueOrEmitPlaylistUpdate(deviceNs, m.id, buildPlaylistPayload);
              }
            }
          } catch (e) { console.error('Group sync re-push failed:', e.message); }

          // Check subscription/trial status before sending playlist
          const access = checkDeviceAccess(device_id);
          if (!access.allowed) {
            socket.emit('device:playlist-update', { assignments: [], suspended: true, message: access.message, detail: access.detail });
          } else {
            socket.emit('device:playlist-update', buildPlaylistPayload(device_id));
          }

          emitToDeviceWorkspace(dashboardNs, device_id, 'dashboard:device-status', { device_id, status: 'online' });
          // Only log a genuine reconnect (new socket). Same-socket periodic refreshes stay
          // quiet so the log reflects real connection events, not the 45s refresh cadence.
          if (!isPlaylistRefresh) logCoalescer.record('device-reconnected', `Device reconnected: ${device_id}`);
          return;
        }

        // Device ID not found in database - tell device to re-provision
        console.log(`Device ${device_id} not found in database, sending unpaired`);
        socket.emit('device:unpaired', { reason: 'not_found' });
        return;
      }

      if (pairing_code) {
        // New device registering with pairing code — generate a device_token
        const id = uuidv4();
        const newToken = generateDeviceToken();

        // #146 scale-hardening: a DB error on this INSERT must reject THIS device's
        // registration, never throw out of the handler. The likely error is a UNIQUE
        // pairing_code collision when many devices provision at once (client-supplied
        // 6-digit codes collide by birthday paradox), but ANY error counts. An
        // unhandled throw in a socket handler escalates to uncaughtException ->
        // logFatalAndExit -> the WHOLE server exits and every device drops — one
        // colliding code crash-looped the fleet in the load test. Catch it, log, and
        // tell just this device to retry. currentDeviceId/authenticated are set only
        // AFTER the row exists, so a failed insert leaves no half-authenticated socket.
        try {
          db.prepare(`
            INSERT INTO devices (id, pairing_code, device_token, status, ip_address, android_version, app_version, screen_width, screen_height, render_width, render_height, last_heartbeat)
            VALUES (?, ?, ?, 'provisioning', ?, ?, ?, ?, ?, ?, ?, strftime('%s','now'))
          `).run(
            id, pairing_code, newToken, getClientIp(socket),
            device_info?.android_version || null,
            device_info?.app_version || null,
            device_info?.screen_width || null,
            device_info?.screen_height || null,
            device_info?.render_width || null,
            device_info?.render_height || null
          );
        } catch (e) {
          console.warn(`Provisioning rejected for pairing_code ${pairing_code} from ${getClientIp(socket)}: ${e.message}`);
          socket.emit('device:auth-error', { error: 'Registration failed, please retry.' });
          return;
        }
        currentDeviceId = id;
        authenticated = true;

        // #150: relink the fingerprint to the NEW device row (the fingerprint block above
        // leaves device_id NULL on a post-delete re-pair) so the settings key is reliable,
        // then restore any settings this physical device had at its last deletion —
        // orientation/name/playlist/etc come back automatically instead of resetting. Runs
        // BEFORE the dashboard:device-added emit below so that emit carries restored values.
        if (fingerprint) {
          try {
            db.prepare("INSERT INTO device_fingerprints (fingerprint, device_id, last_seen, hw_fingerprint) VALUES (?, ?, strftime('%s','now'), ?) ON CONFLICT(fingerprint) DO UPDATE SET device_id = excluded.device_id, last_seen = excluded.last_seen, hw_fingerprint = COALESCE(excluded.hw_fingerprint, device_fingerprints.hw_fingerprint)")
              .run(fingerprint, id, hw_fingerprint || null);
            const restored = deviceSettings.applyToDevice(id, fingerprint);
            if (restored) console.log(`[#150] restored saved settings for re-paired device ${id} (fp ${fingerprint.slice(0, 8)}…)`);
          } catch (e) { console.warn(`[#150] settings restore failed for ${id}: ${e.message}`); }
        }

        heartbeat.registerConnection(id, socket.id);
        persistIdentity(id, data);   // FIX 3: capture v4 identity on first provision (degrades for old clients)
        socket.join(id);
        socket.emit('device:registered', { device_id: id, device_token: newToken, status: 'provisioning' });

        // Newly-provisioned devices have no workspace_id yet (they'll get one
        // on pair claim). emitToDeviceWorkspace silently drops when there's no
        // workspace; that's safer than the previous platform-wide broadcast.
        // Dashboards refresh /api/devices/unassigned on poll for the
        // platform_admin pairing view.
        emitToDeviceWorkspace(dashboardNs, id, 'dashboard:device-added', db.prepare('SELECT * FROM devices WHERE id = ?').get(id));
        console.log(`New device registered: ${id} with pairing code: ${pairing_code}`);
      }
    });

    // Require authentication for all events after register
    function requireDeviceAuth() {
      if (!authenticated || !currentDeviceId) {
        socket.emit('device:auth-error', { error: 'Not authenticated. Send device:register first.' });
        return false;
      }
      return true;
    }

    // #160: lightweight device_info refresh (e.g. after a volume/brightness change) — updates the
    // reported fields WITHOUT a full re-register (no playlist re-push / no reconnect side effects).
    socket.on('device:info', (data) => {
      if (!requireDeviceAuth()) return;
      const di = data && data.device_info;
      if (di) { try { applyDeviceInfo(currentDeviceId, di); } catch (e) { /* never crash the socket on a bad info blob */ } }
    });

    // Heartbeat with telemetry
    socket.on('device:heartbeat', (data) => {
      const { device_id, telemetry } = data || {};
      // v4 PRIMARY + FIX 1 — UNIFORM ACK. Emitted from THIS single shared handler for every client
      // type (APK / .wgt / /player hit the same handler = uniform by construction), and BEFORE the
      // auth guard so a KNOWN device's watchdog stays armed even mid-reconnect (before this socket
      // finishes re-registering). Anonymous / never-authenticated sockets are NOT acked (degrade-safe
      // covers them). Old clients simply ignore the ack — harmless.
      if (liveness.ackableHeartbeat(currentDeviceId, device_id, deviceExists)) {
        // #group-sync clock discipline: the server is the time authority. Echo the client's send
        // time (t1) and stamp the server's clock (t2≈t3, synchronous handler) so the client can do
        // NTP-style offset+RTT correction: offset = server_ms - (t1 + t4)/2. The offset is CACHED by
        // the client and used at play-time (local + offset), so schedule-based group sync keeps
        // working through an internet outage. Absent/old clients just ignore the extra fields.
        socket.emit('device:heartbeat-ack', { server_ms: Date.now(), client_ms: data?.client_ms ?? null });
      }
      if (!requireDeviceAuth()) return;
      if (!device_id || device_id !== currentDeviceId) return;

      currentDeviceId = device_id;
      heartbeat.updateHeartbeat(device_id);

      db.prepare("UPDATE devices SET status = 'online', last_heartbeat = strftime('%s','now'), updated_at = strftime('%s','now') WHERE id = ?")
        .run(device_id);

      // A device row can vanish mid-session — deleted by an operator, or replaced by a re-pair —
      // while its socket is still heartbeating. The telemetry insert then fails the foreign key,
      // the safe-socket wrapper reads that throw as a broken handler and disconnects the socket
      // SERVER-side, and socket.io deliberately does not retry that kind of disconnect. The panel
      // goes dark until a human reloads it; that happened to a live screen. A heartbeat for a
      // device that no longer exists is not worth killing a connection over — skip the write and
      // let the register path answer with unpaired, which is what actually helps it recover.
      if (telemetry && deviceExists(device_id)) {
        db.prepare(`
          INSERT INTO device_telemetry (device_id, battery_level, battery_charging, storage_free_mb, storage_total_mb,
            ram_free_mb, ram_total_mb, cpu_usage, wifi_ssid, wifi_rssi, uptime_seconds, local_ip, local_ip6, temperature_c,
            attached_display, video_mode)
          VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
        `).run(
          device_id,
          telemetry.battery_level ?? null,
          telemetry.battery_charging ? 1 : 0,
          telemetry.storage_free_mb ?? null,
          telemetry.storage_total_mb ?? null,
          telemetry.ram_free_mb ?? null,
          telemetry.ram_total_mb ?? null,
          telemetry.cpu_usage ?? null,
          telemetry.wifi_ssid ?? null,
          telemetry.wifi_rssi ?? null,
          telemetry.uptime_seconds ?? null,
          // Device-supplied text headed for a column the dashboard renders: trim and cap it.
          // 45 chars is the longest legitimate value (a full IPv6 address).
          typeof telemetry.local_ip === 'string' ? telemetry.local_ip.trim().slice(0, 45) || null : null,
          // Same treatment for the v6 address. 45 is still the cap: it is the longest legitimate
          // IPv6 text form (an IPv4-mapped one, `::ffff:255.255.255.255`).
          typeof telemetry.local_ip6 === 'string' ? telemetry.local_ip6.trim().slice(0, 45) || null : null,
          // Only a finite number is a reading. A panel with no sensor sends nothing, and NaN or
          // Infinity from a flaky one must land as "no reading" rather than poisoning the column.
          typeof telemetry.temperature_c === 'number' && Number.isFinite(telemetry.temperature_c)
            ? telemetry.temperature_c : null,
          // Free text from the panel's EDID and the mode the output is driving. Trimmed and
          // bounded like the address fields above: this is a string the DISPLAY chose, not one
          // we control, and a monitor with a silly name must not be able to grow the row.
          typeof telemetry.attached_display === 'string' ? telemetry.attached_display.trim().slice(0, 64) || null : null,
          typeof telemetry.video_mode === 'string' ? telemetry.video_mode.trim().slice(0, 32) || null : null
        );
        pruneTelemetry(device_id);

        // #74/#75: capture the player's reported clock (OS IANA zone + its UTC time)
        // for effective-timezone resolution and the dashboard clock-skew indicator.
        if (telemetry.timezone || telemetry.device_utc != null) {
          db.prepare("UPDATE devices SET reported_timezone = COALESCE(?, reported_timezone), reported_utc = ?, reported_at = strftime('%s','now') WHERE id = ?")
            .run(telemetry.timezone || null, telemetry.device_utc ?? null, device_id);
        }

        emitToDeviceWorkspace(dashboardNs, device_id, 'dashboard:device-status', {
          device_id,
          status: 'online',
          liveness: heartbeat.livenessFor(device_id), // FIX 2: server-derived 3-state (healthy/degraded/offline)
          telemetry
        });
      }
    });

    // Screenshot received from device - relay via WebSocket, keep latest in memory
    socket.on('device:screenshot', (data) => {
      if (!requireDeviceAuth()) return;
      const { device_id, image_b64 } = data;
      if (!device_id || device_id !== currentDeviceId || !image_b64) return;
      ingestScreenshot(device_id, image_b64);
    });

    // #161 device-owner tooling: relay a remote-shell result back to the operator's dashboard.
    socket.on('device:shell-result', (data) => {
      if (!requireDeviceAuth()) return;
      const { device_id, cmd, output, exit } = data || {};
      if (!device_id || device_id !== currentDeviceId) return;
      emitToDeviceWorkspace(dashboardNs, device_id, 'dashboard:shell-result', {
        device_id, cmd: String(cmd || '').slice(0, 500), output: String(output || '').slice(0, 8000), exit,
      });
    });

    // Content download acknowledgement
    socket.on('device:content-ack', (data) => {
      if (!requireDeviceAuth()) return;
      const { device_id, content_id, status } = data;
      if (device_id !== currentDeviceId) return;
      // #142 dedup + #143 per-device rate budget + global critical-lag valve, in one
      // control. Anything but 'pass' is dropped BEFORE the log+emit (that per-ack work
      // is the cost we shed). Drops are SILENT except a single line per device per
      // window when rate-shedding STARTS (re-logging per drop would recreate the
      // flood). The valve's open/close is logged once at the band edge in loop-lag.
      const verdict = contentAckLimiter.check(device_id, content_id, status, loopLag.getBand());
      if (verdict.action !== 'pass') {
        if (verdict.action === 'shed-rate' && verdict.logStart) {
          console.warn(`[content-ack] shedding device ${device_id}: ${verdict.observed}/${verdict.budget} per ${config.contentAckRateWindowMs}ms — flood control engaged`);
        }
        return;
      }
      console.log(`Device ${device_id} content ${content_id}: ${status}`);
      emitToDeviceWorkspace(dashboardNs, device_id, 'dashboard:content-ack', { device_id, content_id, status });
    });

    // Playback state update
    socket.on('device:playback-state', (data) => {
      if (!requireDeviceAuth()) return;
      // currentDeviceId is the authenticated device for this socket; use it for the workspace
      // lookup since data may not carry device_id consistently — and STAMP it over whatever the
      // payload claims before relaying. This was the only relay forwarding the client's object
      // verbatim, so a device could report progress attributed to a different screen in the same
      // workspace and the dashboard would believe it. Every other relay here stamps the
      // authenticated id; this one now matches.
      emitToDeviceWorkspace(dashboardNs, currentDeviceId, 'dashboard:playback-state',
        { ...(data || {}), device_id: currentDeviceId });
    });

    // Live debug log line from the player (only sent when debug logging is toggled
    // on for this device). Relayed to the device's workspace dashboard room so the
    // open device-detail screen can stream it. Not persisted.
    socket.on('device:log', (data) => {
      if (!requireDeviceAuth() || !currentDeviceId) return;
      const message = typeof data?.message === 'string' ? data.message.slice(0, 2000) : '';
      if (!message) return;
      emitToDeviceWorkspace(dashboardNs, currentDeviceId, 'dashboard:device-log', {
        device_id: currentDeviceId,
        tag: typeof data?.tag === 'string' ? data.tag.slice(0, 64) : '',
        level: typeof data?.level === 'string' ? data.level.slice(0, 8) : 'd',
        message,
        ts: Date.now(),
      });
    });

    // #139 Phase 2 (Option B): event-driven OTA status. The device announces a status TRANSITION
    // ('manual_update_required' on enter-backoff, 'none' on clear) so the dashboard badge updates
    // promptly without waiting for a reconnect. The register path still persists these fields too
    // (the reconnect backstop if a transition event is missed). Same columns + ?? defaults.
    socket.on('device:ota-status', (data) => {
      if (!requireDeviceAuth()) return;
      const { device_id, ota_status, ota_target_version, ota_attempts } = data || {};
      // Unknown / forged / mismatched id -> no-op. WHERE id = ? also makes an unregistered id a
      // 0-row update (never throws), so a stray event can't error the socket.
      if (!device_id || device_id !== currentDeviceId) return;
      db.prepare("UPDATE devices SET ota_status = ?, ota_target_version = ?, ota_attempts = ?, ota_updated_at = strftime('%s','now') WHERE id = ?")
        .run(ota_status ?? 'none', ota_target_version ?? null, ota_attempts ?? 0, device_id);
    });

    // Exit-signal contract v1 — the device's best-effort "last gasp": it announces its manner of death
    // (crashed | clean_exit) as (usually) its final act. We record it; when the device then goes Offline
    // the annotation is applied (else 'silent'). ADDITIVE — never touches offline detection. Cleared on
    // (re)online (the register UPDATEs) so a stale reason can't mislabel a later death. The same canonical
    // shape also arrives via the beacon POST /api/device/exit for reliable-on-unload delivery.
    socket.on('device:exit', (data) => {
      if (!requireDeviceAuth() || !currentDeviceId) return;
      const { device_id, reason, detail } = data || {};
      if (device_id && device_id !== currentDeviceId) return;                 // forged/mismatched -> no-op
      const e = liveness.sanitizeExitReason(reason, detail);                  // unknown -> null -> falls to 'silent'
      if (!e) return;
      db.prepare("UPDATE devices SET offline_reason = ?, offline_reason_at = strftime('%s','now'), offline_detail = ? WHERE id = ?")
        .run(e.reason, e.detail, currentDeviceId);
    });

    // Offline-cause log: a typed incident from the player (display_off/display_on, crash,
    // app_error, ...). Just records a device_events row. Guarded by requireDeviceAuth like
    // every other device event; unknown/forged types are dropped (never inserted).
    socket.on('device:event', (data) => {
      if (!requireDeviceAuth()) return;
      const { device_id, type, reason, detail } = data || {};
      if (device_id && device_id !== currentDeviceId) return;          // forged/mismatched -> no-op
      if (!incidentClassify.isAllowedEventType(type)) return;          // unknown type -> ignore
      try {
        db.prepare("INSERT INTO device_events (device_id, type, reason, detail) VALUES (?, ?, ?, ?)")
          .run(currentDeviceId, type, reason ? String(reason).slice(0, 64) : null, detail ? String(detail).slice(0, 500) : null);
      } catch (_) { /* incident feed is best-effort; never crash the socket */ }
    });

    // Offline-cause log: the device's ground-truth account of an in-process disconnect it
    // just recovered from (app SURVIVED the gap -> not a reboot unless cold_start). Compose
    // reason+detail per the contract, then UPGRADE the server's earlier guess: flush the
    // status-log writer so the offline row exists, UPDATE that recent offline row's
    // reason/detail, and add a device_events row (type network|reboot).
    socket.on('device:connectivity-report', (data) => {
      if (!requireDeviceAuth()) return;
      const { device_id } = data || {};
      if (device_id && device_id !== currentDeviceId) return;          // forged/mismatched -> no-op
      const deviceId = currentDeviceId;
      try {
        const { reason, detail, type } = incidentClassify.classifyConnectivity(data);
        // Ensure any buffered offline transition for this device is on disk before we
        // reach back to annotate it (the writer coalesces on a ~1s interval otherwise).
        statusLogWriter.flushNow();
        // Upgrade the most-recent offline row (server guess) to the device's ground truth.
        db.prepare(`UPDATE device_status_log SET reason = ?, detail = ?
          WHERE id = (
            SELECT id FROM device_status_log
            WHERE device_id = ? AND status IN ('offline','offline_timeout')
              AND timestamp > strftime('%s','now') - 900
            ORDER BY timestamp DESC, id DESC LIMIT 1)`).run(reason, detail, deviceId);
        db.prepare("INSERT INTO device_events (device_id, type, reason, detail) VALUES (?, ?, ?, ?)")
          .run(deviceId, type, reason, detail);
      } catch (_) { /* offline-cause annotation is best-effort; never crash the socket */ }
    });

    // Play event logging (proof-of-play)
    socket.on('device:play-event', (data) => {
      if (!requireDeviceAuth()) return;
      const { device_id, event, content_id, widget_id, content_name, zone_id, completed, duration_sec } = data;
      if (device_id !== currentDeviceId) return;
      try {
        if (event === 'play_start') {
          // Throttle proof-of-play inserts per device so a runaway player
          // (0-second items) can't flood play_logs. Skipped cycles simply
          // don't create a row; the dashboard progress event below still fires.
          const nowMs = Date.now();
          const lastMs = lastPlayLogAt.get(device_id) || 0;
          if (nowMs - lastMs >= PLAY_LOG_MIN_GAP_MS) {
            lastPlayLogAt.set(device_id, nowMs);
            // Resolve the reported id against what actually exists rather than handing it
            // straight to a foreign key. content_id references content(id) and widget_id
            // references widgets(id); a stale id from a cached playlist made the INSERT
            // throw and the whole row was lost. Widgets were never attributed at all —
            // widget_id was simply never written. Write whichever column the id belongs
            // in; an id matching neither degrades to NULL references, so content_name
            // still records WHAT played instead of the event vanishing.
            // Prefer an EXPLICIT widget_id. A widget playlist item has no content_id at all, so
            // sniffing content_id could never attribute it — those plays were recorded with both
            // columns null, and Reports read empty for any screen showing a widget. Older players
            // send only content_id (sometimes carrying a widget id), so the sniff stays as their
            // fallback.
            const explicitWidget = widget_id && widgetExists.get(widget_id) ? widget_id : null;
            const isContent = (!explicitWidget && content_id) ? !!contentExists.get(content_id) : false;
            const isWidget = (!explicitWidget && !isContent && content_id) ? !!widgetExists.get(content_id) : false;
            db.prepare(`
              INSERT INTO play_logs (device_id, content_id, widget_id, zone_id, content_name, started_at, trigger_type)
              VALUES (?, ?, ?, ?, ?, strftime('%s','now'), 'playlist')
            `).run(
              device_id,
              isContent ? content_id : null,
              explicitWidget || (isWidget ? content_id : null),
              zone_id || null,
              content_name || 'Unknown'
            );
          }
          // Forward to dashboard so it can render a per-device progress bar.
          // Server-side timestamp avoids clock-skew between player and dashboard.
          emitToDeviceWorkspace(dashboardNs, device_id, 'dashboard:playback-progress', {
            device_id,
            content_id: content_id || null,
            content_name: content_name || null,
            duration_sec: typeof duration_sec === 'number' && duration_sec > 0 ? duration_sec : null,
            started_at: Date.now(),
          });
        } else if (event === 'play_end') {
          // A widget play is closed by its widget id. Binding content_id to BOTH columns meant a
          // widget row could never match itself, so it was never closed and never gained a
          // duration — the other half of what made widget reporting useless.
          // (Any comment must stay OUT of the template literal below; inside it, it becomes SQL.)
          db.prepare(`
            UPDATE play_logs SET ended_at = strftime('%s','now'),
              duration_sec = strftime('%s','now') - started_at,
              completed = ?
            WHERE id = (
              SELECT id FROM play_logs
              WHERE device_id = ? AND ended_at IS NULL
                AND (content_id = ? OR widget_id = ?)
              -- started_at has second granularity, so two plays inside one second tie. Break
              -- on id so this always closes the most recently INSERTED open row rather than an
              -- arbitrary one of the tied set.
              ORDER BY started_at DESC, id DESC LIMIT 1
            )
          `).run(completed ? 1 : 0, device_id, content_id || null, widget_id || content_id || null);
        }
      } catch (err) {
        // Include the identifiers. Without them this is undiagnosable in production: it
        // fired ~360 times in six hours on prod with nothing in the log to point at.
        console.error('Play log error:', err.message,
          `(event=${event} device=${device_id} content=${content_id} zone=${zone_id})`);
      }
    });

    // Video wall sync relay. Sender must be a member of the wall it claims —
    // otherwise an authenticated device could inject sync packets into a wall
    // it doesn't belong to (jitter/DoS that wall's playback). Exclusion uses
    // currentDeviceId, never the client-supplied data.device_id.
    socket.on('wall:sync', (data) => {
      if (!requireDeviceAuth()) return;
      if (!data?.wall_id) return;
      const isMember = db.prepare(
        'SELECT 1 FROM video_wall_devices WHERE wall_id = ? AND device_id = ?'
      ).get(data.wall_id, currentDeviceId);
      if (!isMember) return;
      const wallDevices = db.prepare(
        'SELECT device_id FROM video_wall_devices WHERE wall_id = ? AND device_id != ?'
      ).all(data.wall_id, currentDeviceId);
      // Stamp device_id with the authenticated id so followers can trust it.
      const payload = { ...data, device_id: currentDeviceId };
      for (const wd of wallDevices) {
        deviceNs.to(wd.device_id).emit('wall:sync', payload);
      }
    });

    // A follower asks for an immediate position update from the leader.
    // Used on (re)connect so the follower doesn't drift for ~1s waiting on
    // the next periodic wall:sync tick. Server forwards only to the leader,
    // and only when the requester is actually a member of the named wall.
    socket.on('wall:sync-request', (data) => {
      if (!requireDeviceAuth()) return;
      if (!data?.wall_id) return;
      const isMember = db.prepare(
        'SELECT 1 FROM video_wall_devices WHERE wall_id = ? AND device_id = ?'
      ).get(data.wall_id, currentDeviceId);
      if (!isMember) return;
      const wall = db.prepare('SELECT leader_device_id FROM video_walls WHERE id = ?').get(data.wall_id);
      if (!wall?.leader_device_id || wall.leader_device_id === currentDeviceId) return;
      deviceNs.to(wall.leader_device_id).emit('wall:sync-request', {
        wall_id: data.wall_id,
        requested_by: currentDeviceId,
      });
    });

    // #group-sync: leader broadcasts its index+position; relay to the OTHER sync-eligible members
    // (same group_id AND on the group's shared playlist — the playlist-match guard). Mirrors
    // wall:sync. The device_id is stamped with the authenticated id so followers can trust it.
    // Sender must be an eligible member: in the group (m2m) AND on the group's shared playlist.
    function groupSenderEligible(group) {
      if (!group || !group.sync_enabled || !group.playlist_id) return false;
      return !!db.prepare(`
        SELECT 1 FROM device_group_members dgm JOIN devices d ON d.id = dgm.device_id
        WHERE dgm.group_id = ? AND dgm.device_id = ? AND d.playlist_id = ?
      `).get(group.id, currentDeviceId, group.playlist_id);
    }

    socket.on('group:sync', (data) => {
      if (!requireDeviceAuth()) return;
      if (!data?.group_id) return;
      const group = db.prepare('SELECT id, sync_enabled, playlist_id FROM device_groups WHERE id = ?').get(data.group_id);
      if (!groupSenderEligible(group)) return;
      const payload = { ...data, device_id: currentDeviceId };
      for (const m of groupSyncMembers(group)) {
        if (m.id !== currentDeviceId) deviceNs.to(m.id).emit('group:sync', payload);
      }
    });

    // A follower asks the current leader for an immediate position update (on (re)connect, so it
    // doesn't drift a tick). Forwarded only to the group's elected leader, only from an eligible member.
    socket.on('group:sync-request', (data) => {
      if (!requireDeviceAuth()) return;
      if (!data?.group_id) return;
      const group = db.prepare('SELECT id, sync_enabled, playlist_id, leader_device_id FROM device_groups WHERE id = ?').get(data.group_id);
      if (!groupSenderEligible(group)) return;
      const leaderId = resolveGroupLeader(group);
      if (!leaderId || leaderId === currentDeviceId) return;
      deviceNs.to(leaderId).emit('group:sync-request', { group_id: group.id, requested_by: currentDeviceId });
    });

    socket.on('disconnect', (reason) => {
      // Offline-cause log: capture socket.io's disconnect reason (transport_close /
      // ping_timeout / transport_error / ...) and normalize it to a category token now,
      // while it's in scope; the offline transition below (deferred by the debounce
      // timer) uses it as the fallback offline reason when the device sent no explicit
      // exit signal this session. Falls back to 'silent' when absent.
      const socketOfflineReason = incidentClassify.normalizeDisconnectReason(reason);
      // #146: this socket was force-evicted by a newer registration for the same
      // device. The new socket owns the device now (or is mid-register), so this
      // disconnect must NOT arm an offline timer — doing so was the self-reset race
      // that re-marked just-reconnected devices offline. The map-based stale guard
      // below can't catch it because eviction runs before the new socket is in the
      // map. Drain the flag and bail. (delete() returns true iff it was present.)
      if (evictedSockets.delete(socket.id)) return;

      if (!currentDeviceId) return;

      // Stale-disconnect guard: a newer socket already took over this device_id
      // via eviction. Skip the offline transition entirely - don't even start a
      // debounce timer.
      const activeConn = heartbeat.getConnection(currentDeviceId);
      if (activeConn && activeConn.socketId !== socket.id) {
        console.log(`Stale disconnect for ${currentDeviceId} (socket ${socket.id}); active is ${activeConn.socketId}, skipping offline`);
        return;
      }

      const deviceId = currentDeviceId;
      const closingSocketId = socket.id;
      console.log(`Device disconnected: ${deviceId} (offline transition deferred ${OFFLINE_DEBOUNCE_MS}ms)`);

      // Defensive: clear any existing timer for this device. Shouldn't happen
      // (register would have cleared it), but if two disconnects fire in
      // sequence we want the second to refresh the window, not double up.
      if (pendingOfflines.has(deviceId)) clearTimeout(pendingOfflines.get(deviceId));

      pendingOfflines.set(deviceId, setTimeout(() => {
        pendingOfflines.delete(deviceId);
        // Re-check at fire time: did a DIFFERENT socket reclaim during the
        // grace window? If activeConn exists but it's still our (now-closed)
        // socket's entry, the entry is just stale - heartbeat.removeConnection
        // hasn't run yet because we defer it inside this same block. Only
        // abort if a genuinely different socket has registered.
        const activeNow = heartbeat.getConnection(deviceId);
        if (activeNow && activeNow.socketId !== closingSocketId) return;

        // Exit-signal contract (UNCHANGED): devices.offline_reason stays the app's self-reported
        // manner-of-death — 'crashed'/'clean_exit' if it announced one this session, else 'silent'
        // (a violent/abrupt death is 'silent', never a socket-inferred value — Bold-critical).
        db.prepare("UPDATE devices SET status = 'offline', updated_at = strftime('%s','now'), offline_reason = COALESCE(offline_reason, 'silent'), offline_reason_at = COALESCE(offline_reason_at, strftime('%s','now')) WHERE id = ?").run(deviceId);
        heartbeat.removeConnection(deviceId);
        const _off = db.prepare("SELECT offline_reason, offline_detail, client_type FROM devices WHERE id = ?").get(deviceId) || {};
        // The offline-CAUSE log (device_status_log + device_events) gets the richer signal, which is
        // a SEPARATE axis from the exit-signal field: the app's announced reason if it gave one, else
        // the normalized socket transport reason (transport_close/ping_timeout/...). This never touches
        // devices.offline_reason, so the exit-signal 'silent' semantics above are preserved.
        const finalReason = (_off.offline_reason && _off.offline_reason !== 'silent') ? _off.offline_reason : socketOfflineReason;
        logDeviceStatus(deviceId, 'offline', finalReason, null);
        // Offline-cause log: also record the transition in the unified incident feed.
        try { db.prepare("INSERT INTO device_events (device_id, type, reason, detail) VALUES (?, 'offline', ?, NULL)").run(deviceId, finalReason); } catch (_) { /* incident feed is best-effort */ }
        emitToDeviceWorkspace(dashboardNs, deviceId, 'dashboard:device-status', { device_id: deviceId, status: 'offline', liveness: 'offline', offline_reason: _off.offline_reason || 'silent', offline_detail: _off.offline_detail || null, client_type: _off.client_type || null });

        // If this device was leading a wall, reassign leadership to the next
        // online member so playback stays driven.
        try {
          const wall = db.prepare('SELECT id FROM video_walls WHERE leader_device_id = ?').get(deviceId);
          if (wall) {
            const candidates = db.prepare(`
              SELECT vwd.device_id FROM video_wall_devices vwd
              JOIN devices d ON d.id = vwd.device_id
              WHERE vwd.wall_id = ? AND d.status = 'online' AND vwd.device_id != ?
              ORDER BY vwd.grid_row, vwd.grid_col LIMIT 1
            `).all(wall.id, deviceId);
            const newLeader = candidates[0]?.device_id || null;
            db.prepare('UPDATE video_walls SET leader_device_id = ? WHERE id = ?').run(newLeader, wall.id);
            const members = db.prepare('SELECT device_id FROM video_wall_devices WHERE wall_id = ?').all(wall.id);
            for (const m of members) {
              if (m.device_id !== deviceId) {
                commandQueue.queueOrEmitPlaylistUpdate(deviceNs, m.device_id, buildPlaylistPayload);
              }
            }
          }
        } catch (e) { console.error('Wall leader reassign failed:', e.message); }

        // Save last screenshot to disk as offline snapshot
        const lastB64 = lastScreenshots[deviceId];
        if (lastB64) {
          try {
            const filename = `${deviceId}_latest.jpg`;
            const buffer = Buffer.from(lastB64, 'base64');
            fs.writeFileSync(path.join(config.screenshotsDir, filename), buffer);
            const existing = db.prepare('SELECT id FROM screenshots WHERE device_id = ?').get(deviceId);
            if (existing) {
              db.prepare('UPDATE screenshots SET filepath = ?, captured_at = strftime(\'%s\',\'now\') WHERE device_id = ?').run(filename, deviceId);
            } else {
              db.prepare('INSERT INTO screenshots (device_id, filepath) VALUES (?, ?)').run(deviceId, filename);
            }
          } catch (e) {
            console.error('Failed to save offline screenshot:', e.message);
          }
          delete lastScreenshots[deviceId];
        }
      }, OFFLINE_DEBOUNCE_MS));
    });
  });

  return deviceNs;
};

// #146 test hooks — read-only views of the internal offline-timer / eviction state,
// so the cause-1 re-arm race (evicted socket arming an offline timer for a
// just-reconnected device) is test-PROVEN, not just correct-by-construction. Prefixed
// `__` and never used by production code.
// Test-only, same convention as the handles below: the COALESCE semantics are the whole point of
// this function and are not reachable through a socket handshake in a unit test.
// Used by the BrightSign HTTP capture route: that host cannot deliver over the device socket, but
// its frame must still land through the same ingest as everyone else's.
module.exports.ingestScreenshot = ingestScreenshot;
module.exports.validateDeviceToken = validateDeviceToken;
module.exports.__applyHardwareIdentity = applyHardwareIdentity;
module.exports.__hasPendingOffline = (deviceId) => pendingOfflines.has(deviceId);
module.exports.__pendingOfflineCount = () => pendingOfflines.size;
module.exports.__evictedSize = () => evictedSockets.size;
module.exports.__resetTimers = () => {
  for (const t of pendingOfflines.values()) clearTimeout(t);
  pendingOfflines.clear();
  evictedSockets.clear();
};

// Test seam: which protocol a group runs, and whether a request was refused, is the one piece of
// branching in this file with nothing to do with sockets. Exposing it lets that decision be tested
// against a real database without standing up a socket server.
module.exports.__test = { resolveGroupSync, resolveGroupLeader, groupSyncMembers };
