/**
 * WebSocket Hub — Africa Alert PWA
 *
 * Real-time pub/sub hub attached to the SAME http.Server that hosts Express
 * (no separate port). Provides JWT-authenticated channels and per-user
 * delivery. Designed for low-frequency domain events (announcements, marks,
 * attendance updates) rather than high-throughput streaming.
 *
 * Usage (from server/src/index.js):
 *   const http = require('http');
 *   const server = http.createServer(app);
 *   const wsHub = require('./services/wsHub');
 *   wsHub.attach(server);
 *   server.listen(PORT, ...);
 *
 * Client protocol:
 *   1. Connect with token (query string `?token=<jwt>` or
 *      `Sec-WebSocket-Protocol: bearer-<jwt>`). Invalid tokens are rejected
 *      with close code 1008.
 *   2. Subscribe:  ws.send(JSON.stringify({type:'subscribe',
 *                                         channels:['class:1','subject:2']}))
 *   3. Server sends `welcome` on connect and `subscribed` on each subscribe.
 *
 * Channel conventions (advisory — anything string-shaped works):
 *   class:<id>          — events for a single class
 *   subject:<id>        — events for a single subject
 *   user:<id>           — private channel; delivered via broadcastToUser
 *
 * Exports:
 *   broadcastToChannel(channel, payload)  -> number of sockets reached
 *   broadcastToUser(userId, payload)      -> number of sockets reached
 *   getConnectionCount()                 -> total live sockets
 *   getChannelSubscriberCount(channel)    -> subscribers for a channel
 */

const WebSocket = require('ws');
const url = require('url');
const jwt = require('jsonwebtoken');

const { jwtSecret: JWT_SECRET } = require('../config');

// Internal state
const channelSubscribers = new Map(); // channel -> Set<WebSocket>
const socketMeta = new WeakMap();     // WebSocket -> { userId, role, channels:Set, alive:boolean }

let wss = null;
let stats = {
  startedAt: null,
  totalConnections: 0,
  totalDisconnects: 0,
  rejectedAuth: 0,
};

/**
 * Extract a JWT from a WS upgrade request. Tries the Sec-WebSocket-Protocol
 * header first (case `bearer-<token>` or raw token), then the query string.
 * Returns null if neither yields a usable token.
 */
function extractToken(req) {
  // 1. Sec-WebSocket-Protocol — may be a single token or "bearer-<token>"
  const protocol = req.headers['sec-websocket-protocol'];
  if (protocol) {
    const trimmed = protocol.trim();
    if (trimmed.startsWith('bearer-')) {
      return trimmed.slice('bearer-'.length).trim();
    }
    if (trimmed.length > 20 && trimmed.split('.').length === 3) {
      // Looks like a raw JWT
      return trimmed;
    }
  }

  // 2. Query string fallback
  try {
    const parsed = url.parse(req.url, true);
    if (parsed.query && parsed.query.token) {
      return String(parsed.query.token);
    }
  } catch (_e) {
    /* ignore parse errors */
  }

  return null;
}

/**
 * Attach the hub to an existing http.Server. Idempotent — calling twice on
 * the same server is a no-op.
 */
function attach(server) {
  if (wss) {
    console.warn('[wsHub] attach() called twice — ignoring.');
    return wss;
  }

  wss = new WebSocket.Server({ noServer: true });
  stats.startedAt = new Date();

  server.on('upgrade', (req, socket, head) => {
    // Only handle requests routed at our path. Allow anything (clients may
    // connect to "/", "/ws", "/socket" — all the same hub for simplicity).
    const pathname = (url.parse(req.url || '/').pathname || '/').toLowerCase();
    if (pathname !== '/' && pathname !== '/ws' && pathname !== '/socket') {
      socket.destroy();
      return;
    }

    const token = extractToken(req);
    if (!token) {
      stats.rejectedAuth += 1;
      socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
      socket.destroy();
      return;
    }

    let user;
    try {
      user = jwt.verify(token, JWT_SECRET);
    } catch (err) {
      stats.rejectedAuth += 1;
      // RFC 6455 close code 1008 = "policy violation"
      socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
      socket.destroy();
      return;
    }

    wss.handleUpgrade(req, socket, head, (ws) => {
      onConnection(ws, user, req);
    });
  });

  console.log('[wsHub] WebSocket hub attached (path: /, /ws, /socket)');
  return wss;
}

function onConnection(ws, user, req) {
  const meta = {
    userId: user.id,
    role: user.role,
    email: user.email,
    channels: new Set(),
    alive: true,
  };
  socketMeta.set(ws, meta);
  stats.totalConnections += 1;

  // Always auto-subscribe this socket to its private user:<id> channel
  const privateChannel = `user:${user.id}`;
  meta.channels.add(privateChannel);
  addSubscriber(privateChannel, ws);

  send(ws, { type: 'welcome', userId: user.id, role: user.role, serverTime: new Date().toISOString() });

  ws.on('message', (raw) => {
    let msg;
    try {
      msg = JSON.parse(raw.toString());
    } catch (_err) {
      send(ws, { type: 'error', error: 'Invalid JSON' });
      return;
    }
    handleClientMessage(ws, meta, msg);
  });

  ws.on('close', () => onDisconnect(ws, meta));
  ws.on('error', (err) => {
    console.warn('[wsHub] socket error:', err.message);
  });

  // Heartbeat — drop dead sockets within ~60s
  ws.on('pong', () => { meta.alive = true; });
}

function handleClientMessage(ws, meta, msg) {
  if (!msg || typeof msg !== 'object') return;

  switch (msg.type) {
    case 'subscribe': {
      const incoming = Array.isArray(msg.channels) ? msg.channels : [];
      const added = [];
      for (const ch of incoming) {
        if (typeof ch !== 'string' || ch.length === 0 || ch.length > 200) continue;
        // Disallow subscribing to other users' private channels
        if (ch.startsWith('user:') && ch !== `user:${meta.userId}`) continue;
        if (!meta.channels.has(ch)) {
          meta.channels.add(ch);
          addSubscriber(ch, ws);
          added.push(ch);
        }
      }
      send(ws, { type: 'subscribed', channels: added });
      return;
    }

    case 'unsubscribe': {
      const incoming = Array.isArray(msg.channels) ? msg.channels : [];
      const removed = [];
      for (const ch of incoming) {
        if (typeof ch !== 'string') continue;
        if (meta.channels.has(ch)) {
          meta.channels.delete(ch);
          removeSubscriber(ch, ws);
          removed.push(ch);
        }
      }
      send(ws, { type: 'unsubscribed', channels: removed });
      return;
    }

    case 'ping': {
      send(ws, { type: 'pong', serverTime: new Date().toISOString() });
      return;
    }

    default:
      send(ws, { type: 'error', error: `Unknown message type: ${msg.type}` });
  }
}

function onDisconnect(ws, meta) {
  stats.totalDisconnects += 1;
  for (const ch of meta.channels) {
    removeSubscriber(ch, ws);
  }
  meta.channels.clear();
  // socketMeta is a WeakMap — entry is GC'd with the socket. No leak.
}

function addSubscriber(channel, ws) {
  let set = channelSubscribers.get(channel);
  if (!set) {
    set = new Set();
    channelSubscribers.set(channel, set);
  }
  set.add(ws);
}

function removeSubscriber(channel, ws) {
  const set = channelSubscribers.get(channel);
  if (!set) return;
  set.delete(ws);
  if (set.size === 0) channelSubscribers.delete(channel);
}

function send(ws, payload) {
  if (ws.readyState !== WebSocket.OPEN) return false;
  try {
    ws.send(JSON.stringify(payload));
    return true;
  } catch (_err) {
    return false;
  }
}

/**
 * Broadcast to every socket subscribed to the channel. Returns the count
 * of sockets that received the message (excluding ones that errored).
 */
function broadcastToChannel(channel, payload) {
  const set = channelSubscribers.get(channel);
  if (!set || set.size === 0) return 0;
  const data = JSON.stringify(payload);
  let delivered = 0;
  for (const ws of set) {
    if (ws.readyState === WebSocket.OPEN) {
      try {
        ws.send(data);
        delivered += 1;
      } catch (_err) {
        /* skip */
      }
    }
  }
  return delivered;
}

/**
 * Broadcast to every socket owned by a given user. The user is auto-
 * subscribed to `user:<id>` at connect, so this is the canonical
 * "send to a single user" path.
 */
function broadcastToUser(userId, payload) {
  return broadcastToChannel(`user:${userId}`, payload);
}

/**
 * Total live WebSocket connections across all channels.
 */
function getConnectionCount() {
  if (!wss) return 0;
  // wss.clients is a Set; reading its size is O(1) in `ws`
  return wss.clients.size;
}

/**
 * Number of sockets currently subscribed to a channel. Used by tests and
 * future admin tooling.
 */
function getChannelSubscriberCount(channel) {
  const set = channelSubscribers.get(channel);
  return set ? set.size : 0;
}

/**
 * Read-only stats snapshot. Useful for /api/health probes.
 */
function getStats() {
  return {
    ...stats,
    connectionCount: getConnectionCount(),
    channelCount: channelSubscribers.size,
  };
}

// 30-second heartbeat sweep — terminates silent sockets (RFC 6455 ping/pong)
function startHeartbeat() {
  if (!wss) return;
  setInterval(() => {
    for (const ws of wss.clients) {
      const meta = socketMeta.get(ws);
      if (!meta) continue;
      if (meta.alive === false) {
        try { ws.terminate(); } catch (_e) { /* noop */ }
        continue;
      }
      meta.alive = false;
      try { ws.ping(); } catch (_e) { /* noop */ }
    }
  }, 30_000).unref();
}

// Hook the heartbeat as soon as attach() is called
const _origAttach = attach;
attach = function patchedAttach(server) {
  const out = _origAttach(server);
  startHeartbeat();
  return out;
};

module.exports = {
  attach,
  broadcastToChannel,
  broadcastToUser,
  getConnectionCount,
  getChannelSubscriberCount,
  getStats,
};