const heartbeat = require('../services/heartbeat');
const { resolveSessionUser } = require('../middleware/auth');
const { db } = require('../db/database');
const { accessContext, accessibleWorkspaceIds } = require('../lib/tenancy');
const { workspaceRoom } = require('../lib/socket-rooms');
const { protectSocket } = require('../lib/safe-socket');
const playerCapabilities = require('../lib/player-capabilities');
const bsSnapshotQueue = require('../lib/brightsign-snapshot-queue');

// Phase 2.3: workspace-scoped socket rooms + per-command permission gates.
// Replaces the previous flat dashboardNs.emit broadcast (which leaked every
// device's status/screenshot/playback events to every connected dashboard)
// and the legacy admin/superadmin role bypass (dead code post-Phase-1
// rename - admin -> user, superadmin -> platform_admin).
//
// On connect: enumerate the user's accessible workspace_ids and socket.join
// a room per workspace. Outbound broadcasts route via dashboardNs.to(room).
// Inbound commands check permission against the target device's workspace.

// Permission gate for inbound socket commands. Read tier = workspace_viewer+;
// write tier = workspace_editor+. Platform_admin and org_owner/admin always
// pass via actingAs.
function canActOnDevice(socket, deviceId, tier /* 'read' | 'write' */) {
  const device = db.prepare('SELECT workspace_id FROM devices WHERE id = ?').get(deviceId);
  if (!device || !device.workspace_id) return false;
  const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(device.workspace_id);
  if (!ws) return false;
  const ctx = accessContext(socket.userId, socket.userRole, ws);
  if (!ctx) return false;
  if (ctx.actingAs) return true; // platform_admin or org admin
  if (tier === 'read') return !!ctx.workspaceRole; // viewer/editor/admin all OK
  // write tier: workspace_editor or workspace_admin
  return ctx.workspaceRole === 'workspace_editor' || ctx.workspaceRole === 'workspace_admin';
}

module.exports = function setupDashboardSocket(io) {
  const dashboardNs = io.of('/dashboard');
  const deviceNs = io.of('/device');

  dashboardNs.use((socket, next) => {
    const token = socket.handshake.auth?.token;
    if (!token) return next(new Error('Authentication required'));
    let session;
    try {
      // Same resolver as requireAuth, so the socket inherits the pre-TOTP refusal and the
      // forced-password-change gate that the HTTP surface enforces.
      session = resolveSessionUser(token);
    } catch (err) {
      if (err.code === 'mfa_required') return next(new Error('mfa_required'));
      if (err.code === 'password_change_required') return next(new Error('password_change_required'));
      return next(new Error('Invalid token'));
    }
    // Break-glass identities have no users row and no workspace membership, so
    // canActOnDevice -> accessContext already denied them every command. Refuse the
    // handshake rather than hold open a socket that can do nothing.
    if (session.viaRecovery) return next(new Error('Invalid token'));
    socket.userId = session.user.id;
    // Role + existence come from the LIVE users row, not the token claim: a deleted or
    // demoted user no longer keeps fleet control for the remainder of a 7-day JWT.
    socket.userRole = session.user.role;
    next();
  });

  dashboardNs.on('connection', (socket) => {
    // #146: same per-connection fail-fast as the device namespace — a throwing
    // dashboard handler disconnects only that client, never crashes the server.
    protectSocket(socket, () => socket.userId);
    // Note on workspace-switch lifecycle: the switcher (Phase 3 MVP) calls
    // window.location.reload() after switching, which forces a new socket
    // connection with fresh JWT claims. So workspace memberships are
    // re-evaluated at connect time and we don't need to re-evaluate per-emit.
    const wsIds = accessibleWorkspaceIds(socket.userId, socket.userRole);
    for (const wsId of wsIds) socket.join(workspaceRoom(wsId));
    console.log(`Dashboard client connected: ${socket.id} (user: ${socket.userId}, rooms: ${wsIds.length})`);

    /*
     * The capability gate for the remote-view handlers.
     *
     * dashboard:device-command below has always checked this; these four did not, and the
     * reasoning that justifies it there applies here word for word — this socket is reachable
     * directly, and a dashboard tab left open still renders the controls the panel had when the
     * page was drawn. Measured: a display declaring `[]` still received screenshot-request,
     * remote-touch, remote-key and remote-start, silently, and the operator got a toast saying
     * the screenshot was on its way.
     *
     * The ack is OPTIONAL by design: the current dashboard senders (frontend/js/socket.js) pass
     * no callback, and a newer one that does gets told which capability is missing instead of
     * watching a spinner. Refusing loudly is the whole point of the mechanism.
     */
    // Silent by design, unlike the command path: the fleet view asks EVERY visible card for a
    // screenshot every 30s, so a log line per refusal would be hundreds every half-minute on a
    // real fleet. The ack carries the reason to anyone who asked for one.
    function capabilityRefused(device_id, cap, ack) {
      const devRow = db.prepare('SELECT * FROM devices WHERE id = ?').get(device_id);
      if (playerCapabilities.supports(devRow, cap)) return false;
      if (typeof ack === 'function') ack({ delivered: false, reason: 'unsupported', capability: cap });
      return true;
    }

    socket.on('dashboard:request-screenshot', (data, ack) => {
      const { device_id } = data;
      if (!canActOnDevice(socket, device_id, 'read')) return;
      if (capabilityRefused(device_id, 'remote.screenshot', ack)) return;
      const conn = heartbeat.getConnection(device_id);
      if (conn) deviceNs.to(device_id).emit('device:screenshot-request', {});
      // BrightSign additionally leaves the request where its HOST can collect it. The page there
      // can capture only the graphics plane — video lives on a hardware plane the DOM cannot read —
      // and it cannot forward the request to the host either, because page->host messaging is dead
      // after load on that platform. So the host polls for this over HTTP, the one direction that
      // works. See lib/brightsign-snapshot-queue.js.
      try {
        const row = db.prepare('SELECT platform FROM devices WHERE id = ?').get(device_id);
        if (row && String(row.platform || '').toLowerCase() === 'brightsign') {
          bsSnapshotQueue.request(device_id, { width: 960, height: 540 });
        }
      } catch (e) { /* the socket path already fired; queueing is the bonus, never the blocker */ }
      if (typeof ack === 'function') ack({ delivered: !!conn, reason: conn ? undefined : 'offline' });
    });

    socket.on('dashboard:remote-touch', (data, ack) => {
      const { device_id, x, y, x2, y2, duration, action } = data;
      if (!canActOnDevice(socket, device_id, 'write')) return;
      if (capabilityRefused(device_id, 'remote.input', ack)) return;
      // #159: a swipe/drag carries an end point + duration (for scrolling); tap is just x/y.
      deviceNs.to(device_id).emit('device:remote-touch', { x, y, x2, y2, duration, action });
      if (typeof ack === 'function') ack({ delivered: true });
    });

    socket.on('dashboard:remote-key', (data, ack) => {
      const { device_id, keycode } = data;
      if (!canActOnDevice(socket, device_id, 'write')) return;
      if (capabilityRefused(device_id, 'remote.input', ack)) return;
      console.log(`Remote key: ${keycode} -> ${device_id}`);
      deviceNs.to(device_id).emit('device:remote-key', { keycode });
      if (typeof ack === 'function') ack({ delivered: true });
    });

    // Track which devices THIS dashboard socket has a live remote (screenshot-stream) session on, so
    // we can stop them if the tab closes / the socket drops — an orphaned stream keeps the device
    // capturing every second and can starve a weak panel's decoder (the black-screen we hit).
    socket.remoteSessions = new Set();

    socket.on('dashboard:remote-start', (data, ack) => {
      const { device_id } = data;
      if (!canActOnDevice(socket, device_id, 'write')) return;
      if (capabilityRefused(device_id, 'remote.stream', ack)) return;
      const room = deviceNs.adapter.rooms.get(device_id);
      console.log(`Remote start for ${device_id}, room has ${room?.size || 0} socket(s)`);
      socket.remoteSessions.add(device_id);
      deviceNs.to(device_id).emit('device:remote-start', {});
      console.log(`Remote session started for device ${device_id}`);
    });

    // Deliberately NOT capability-gated, for the same reason set_debug isn't: stopping is the
    // thing you need most when a panel's declaration has changed underneath a live stream, and
    // refusing it would strand that panel capturing forever.
    socket.on('dashboard:remote-stop', (data) => {
      const { device_id } = data;
      if (!canActOnDevice(socket, device_id, 'write')) return;
      socket.remoteSessions.delete(device_id);
      deviceNs.to(device_id).emit('device:remote-stop', {});
      console.log(`Remote session stopped for device ${device_id}`);
    });

    socket.on('dashboard:device-command', (data, ack) => {
      const { device_id, type, payload } = data;
      if (!canActOnDevice(socket, device_id, 'write')) {
        if (typeof ack === 'function') ack({ delivered: false, reason: 'forbidden' });
        return;
      }

      // Hiding the button is not enforcement. This socket is reachable directly, group sends fan
      // out to mixed-platform fleets, and an older dashboard tab left open still renders the old
      // controls. A command the panel cannot honour is refused HERE, with the capability named, so
      // it fails loudly instead of being delivered and silently ignored — which is the failure
      // this whole mechanism exists to end.
      const devRow = db.prepare('SELECT * FROM devices WHERE id = ?').get(device_id);
      const verdict = playerCapabilities.commandAllowed(devRow, type);
      if (!verdict.ok) {
        console.warn(`Command ${type} refused for device ${device_id}: needs ${verdict.capability}`);
        if (typeof ack === 'function') {
          ack({ delivered: false, reason: 'unsupported', capability: verdict.capability });
        }
        return;
      }

      const room = deviceNs.adapter.rooms.get(device_id);
      if (room && room.size > 0) {
        deviceNs.to(device_id).emit('device:command', { type, payload });
        console.log(`Command delivered to device ${device_id}: ${type}`);
        if (typeof ack === 'function') ack({ delivered: true });
        return;
      }
      // Device offline at emit time. Try to queue (lazy require so reverting
      // the queue commit doesn't break this commit - MODULE_NOT_FOUND on the
      // first try gets cached by Node's module loader, giving consistent
      // queued=false behavior on every subsequent call).
      let queued = false;
      try {
        const queue = require('../lib/command-queue');
        queued = queue.queueCommand(device_id, type, payload);
      } catch (e) { /* command-queue module absent; fall through to lost */ }
      console.log(`Command for offline device ${device_id}: ${type} (queued=${queued})`);
      if (typeof ack === 'function') ack({ delivered: false, queued, reason: 'offline' });
    });

    socket.on('disconnect', () => {
      console.log(`Dashboard client disconnected: ${socket.id}`);
      // Stop any remote screenshot streams this socket left running (tab closed / navigated away),
      // so the device isn't left capturing forever.
      for (const device_id of socket.remoteSessions) {
        deviceNs.to(device_id).emit('device:remote-stop', {});
        console.log(`Auto-stopped orphaned remote session for ${device_id} (dashboard socket ${socket.id} gone)`);
      }
      socket.remoteSessions.clear();
    });
  });

  return dashboardNs;
};

