/**
 * DC-076 / DC-061: Tests for the dashboard WebSocket server
 *
 * DC-061 added:
 *   - Real authVerifier injection (no string-presence-only check)
 *   - Rejection of bare cookies / token query params
 *   - close() detaches only OUR listeners (not shared SSE listeners)
 *   - Message size cap (16 KB)
 *   - parseCookieHeader unit coverage
 */
const http = require('http');
const WebSocket = require('ws');
const EventEmitter = require('events');
const { createDashboardWS, parseCookieHeader } = require('../../src/websocket/dashboard-ws');

function createMockServer() {
  return http.createServer((req, res) => {
    res.writeHead(404);
    res.end();
  });
}

/**
 * Build a stub verifier that mimics the production `session.isValid`
 * shape: takes an IncomingMessage-ish request, returns true iff the
 * session cookie value is a non-empty string.
 */
function cookieValueVerifier() {
  return (req) => {
    const parsed = parseCookieHeader(req && req.headers && req.headers.cookie);
    const raw = parsed.dashcaddy_session;
    return typeof raw === 'string' && raw.length > 0;
  };
}

describe('DC-076: Dashboard WebSocket', () => {
  let server, wsServer, port;
  let resourceMonitor, healthChecker, updateManager;

  beforeEach((done) => {
    server = createMockServer();
    server.listen(0, () => {
      port = server.address().port;

      resourceMonitor = new EventEmitter();
      healthChecker = new EventEmitter();
      updateManager = new EventEmitter();

      wsServer = createDashboardWS(server, {
        resourceMonitor,
        healthChecker,
        updateManager,
        authVerifier: cookieValueVerifier(),
        log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
      });
      done();
    });
  });

  afterEach((done) => {
    wsServer.close();
    server.close(done);
  });

  it('accepts connections at the upgrade path with a session cookie', (done) => {
    const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`, {
      headers: { Cookie: 'dashcaddy_session=valid-session-id' },
    });
    ws.on('open', () => ws.close());
    ws.on('close', () => done());
    ws.on('error', done);
  });

  it('sends a connected event on join', (done) => {
    const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`, {
      headers: { Cookie: 'dashcaddy_session=valid-session-id' },
    });
    ws.on('message', (raw) => {
      const msg = JSON.parse(raw.toString());
      if (msg.type === 'connected') {
        expect(msg.data).toHaveProperty('clients');
        ws.close();
        done();
      }
    });
    ws.on('error', done);
  });

  it('responds to ping with pong', (done) => {
    const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`, {
      headers: { Cookie: 'dashcaddy_session=valid-session-id' },
    });
    ws.on('open', () => {
      ws.send(JSON.stringify({ type: 'ping' }));
    });
    ws.on('message', (raw) => {
      const msg = JSON.parse(raw.toString());
      if (msg.type === 'pong') {
        ws.close();
        done();
      }
    });
    ws.on('error', done);
  });

  it('responds to subscribe with subscribed confirmation', (done) => {
    const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`, {
      headers: { Cookie: 'dashcaddy_session=valid-session-id' },
    });
    ws.on('open', () => {
      ws.send(JSON.stringify({ type: 'subscribe', events: ['resource-alert', 'incident'] }));
    });
    ws.on('message', (raw) => {
      const msg = JSON.parse(raw.toString());
      if (msg.type === 'subscribed') {
        expect(msg.events).toEqual(['resource-alert', 'incident']);
        ws.close();
        done();
      }
    });
    ws.on('error', done);
  });

  it('responds to client-count request', (done) => {
    const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`, {
      headers: { Cookie: 'dashcaddy_session=valid-session-id' },
    });
    ws.on('open', () => {
      ws.send(JSON.stringify({ type: 'client-count' }));
    });
    ws.on('message', (raw) => {
      const msg = JSON.parse(raw.toString());
      if (msg.type === 'client-count') {
        expect(msg.count).toBeGreaterThanOrEqual(1);
        ws.close();
        done();
      }
    });
    ws.on('error', done);
  });

  it('returns error for invalid JSON', (done) => {
    const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`, {
      headers: { Cookie: 'dashcaddy_session=valid-session-id' },
    });
    ws.on('open', () => {
      ws.send('not json');
    });
    ws.on('message', (raw) => {
      const msg = JSON.parse(raw.toString());
      if (msg.type === 'error') {
        expect(msg.error).toContain('Invalid JSON');
        ws.close();
        done();
      }
    });
    ws.on('error', done);
  });

  it('tracks client count', () => {
    expect(wsServer.getClientCount()).toBe(0);
  });

  it('broadcast method does not throw with no clients', () => {
    expect(() => wsServer.broadcast('test', { foo: 'bar' })).not.toThrow();
  });
});

// ─────────────────────────────────────────────────────────────────────
// DC-061 auth gate tests
// ─────────────────────────────────────────────────────────────────────

describe('DC-061: WS upgrade auth gate', () => {
  let server, wsServer, port;

  beforeEach((done) => {
    server = createMockServer();
    server.listen(0, () => {
      port = server.address().port;
      wsServer = createDashboardWS(server, {
        resourceMonitor: new EventEmitter(),
        healthChecker: new EventEmitter(),
        updateManager: new EventEmitter(),
        authVerifier: cookieValueVerifier(),
        log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
      });
      done();
    });
  });

  afterEach((done) => {
    wsServer.close();
    server.close(done);
  });

  /**
   * Open a raw socket, send a hand-crafted WS upgrade request, and read
   * the server's HTTP status line. Avoids the ws library's auto-retry
   * behaviour so we get a deterministic single response.
   */
  function probeUpgrade({ path, cookie, token } = {}) {
    return new Promise((resolve, reject) => {
      const net = require('net');
      const sock = net.createConnection(port, '127.0.0.1');
      let buf = '';
      const headers = [
        `GET ${path || '/api/v1/ws'} HTTP/1.1`,
        'Host: 127.0.0.1',
        'Upgrade: websocket',
        'Connection: Upgrade',
        'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==',
        'Sec-WebSocket-Version: 13',
      ];
      if (cookie) headers.push(`Cookie: ${cookie}`);
      if (token) {
        const sep = path && path.includes('?') ? '&' : '?';
        headers[0] = headers[0].replace(path, `${path || '/api/v1/ws'}${sep}token=${token}`);
      }
      sock.on('connect', () => {
        sock.write(headers.join('\r\n') + '\r\n\r\n');
      });
      sock.on('data', (chunk) => {
        buf += chunk.toString('utf8');
        if (buf.includes('\r\n\r\n')) {
          sock.destroy();
          const statusLine = buf.split('\r\n')[0];
          const status = parseInt((statusLine.match(/HTTP\/1\.1 (\d+)/) || [])[1], 10);
          resolve({ status, raw: buf });
        }
      });
      sock.on('error', (err) => {
        // Connection reset is fine — server destroys socket after 401.
        if (buf) resolve({ status: -1, raw: buf });
        else reject(err);
      });
      setTimeout(() => {
        if (!buf) {
          sock.destroy();
          reject(new Error('No response within 1s'));
        }
      }, 1000);
    });
  }

  it('rejects WS upgrade with NO cookie', async () => {
    const res = await probeUpgrade({});
    expect(res.status).toBe(401);
  });

  it('rejects WS upgrade with empty session cookie value', async () => {
    const res = await probeUpgrade({ cookie: 'dashcaddy_session=' });
    expect(res.status).toBe(401);
  });

  it('rejects WS upgrade with unrelated cookie (no session cookie)', async () => {
    const res = await probeUpgrade({ cookie: 'foo=bar; baz=qux' });
    expect(res.status).toBe(401);
  });

  it('NO LONGER accepts `?token=` query param bypass (DC-061 fix)', async () => {
    // Pre-DC-061: any 11+ char token in ?token=... granted WS access in
    // production. Post-fix: token query param is ignored entirely; only a
    // valid session cookie grants access.
    const res = await probeUpgrade({ token: 'thisstringisdefinitelylongenough' });
    expect(res.status).toBe(401);
  });

  it('rejects WS upgrade with token= AND empty cookie (no bypass combo)', async () => {
    const res = await probeUpgrade({ cookie: 'dashcaddy_session=', token: 'abcdefghijklmnop' });
    expect(res.status).toBe(401);
  });

  it('accepts upgrade when verifier returns true', async () => {
    const res = await probeUpgrade({ cookie: 'dashcaddy_session=valid-session-id' });
    // 101 Switching Protocols for successful WS handshake
    expect(res.status).toBe(101);
  });
});

// ─────────────────────────────────────────────────────────────────────
// DC-061 close() listener detach test (the SSE-poisoning regression)
// ─────────────────────────────────────────────────────────────────────

describe('DC-061: close() detaches only OUR listeners', () => {
  it('does NOT remove listeners attached by SSE route to shared emitters', () => {
    // Set up two "subscribers" on the same EventEmitter, simulating the
    // real-world shape: SSE route subscribes via `.on('alert', sseHandler)`
    // and dashboard-ws subscribes via `.on('alert', wsHandler)` to the
    // SAME resourceMonitor. Calling dashboard-ws.close() must remove
    // ONLY wsHandler — sseHandler must remain.
    const server = createMockServer();
    const resourceMonitor = new EventEmitter();

    // Pre-existing "SSE" listener (registered before dashboard-ws boots)
    const sseHandler = jest.fn();
    resourceMonitor.on('alert', sseHandler);

    const wsServer = createDashboardWS(server, {
      resourceMonitor,
      healthChecker: new EventEmitter(),
      updateManager: new EventEmitter(),
      authVerifier: () => true,
      log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
    });

    // dashboard-ws added its own listener — verify it's there
    const wsHandlerCallsBefore = resourceMonitor.listenerCount('alert');
    expect(wsHandlerCallsBefore).toBe(2); // sseHandler + wsHandler

    // Now close dashboard-ws — must not remove sseHandler
    wsServer.close();

    const wsHandlerCallsAfter = resourceMonitor.listenerCount('alert');
    expect(wsHandlerCallsAfter).toBe(1); // sseHandler ONLY — wsHandler gone

    // Confirm the surviving listener is the SSE one
    resourceMonitor.emit('alert', { test: true });
    expect(sseHandler).toHaveBeenCalledWith({ test: true });

    server.close();
  });

  it('is safe to call close() multiple times', () => {
    const server = createMockServer();
    const wsServer = createDashboardWS(server, {
      resourceMonitor: new EventEmitter(),
      healthChecker: new EventEmitter(),
      updateManager: new EventEmitter(),
      authVerifier: () => true,
      log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
    });
    expect(() => {
      wsServer.close();
      wsServer.close();
      wsServer.close();
    }).not.toThrow();
    server.close();
  });
});

// ─────────────────────────────────────────────────────────────────────
// DC-061 parseCookieHeader unit tests
// ─────────────────────────────────────────────────────────────────────

describe('parseCookieHeader', () => {
  it('returns empty object for undefined', () => {
    expect(parseCookieHeader(undefined)).toEqual({});
  });
  it('returns empty object for empty string', () => {
    expect(parseCookieHeader('')).toEqual({});
  });
  it('parses a single cookie pair', () => {
    expect(parseCookieHeader('foo=bar')).toEqual({ foo: 'bar' });
  });
  it('parses multiple cookie pairs', () => {
    expect(parseCookieHeader('a=1; b=2; c=3')).toEqual({ a: '1', b: '2', c: '3' });
  });
  it('trims whitespace around names and values', () => {
    expect(parseCookieHeader('  foo  =  bar  ;  baz=qux')).toEqual({ foo: 'bar', baz: 'qux' });
  });
  it('preserves dots/dashes in HMAC-shaped session cookie values', () => {
    // dashcaddy_session cookies are `<b64>.<sig>` — parseCookieHeader
    // must NOT url-decode (the HMAC verifier reads the raw value).
    expect(parseCookieHeader('dashcaddy_session=abc.def_123-XYZ')).toEqual({
      dashcaddy_session: 'abc.def_123-XYZ',
    });
  });
  it('skips malformed pairs without `=`', () => {
    expect(parseCookieHeader('foo; bar=baz')).toEqual({ bar: 'baz' });
  });
  it('skips empty name parts', () => {
    expect(parseCookieHeader('=value; foo=bar')).toEqual({ foo: 'bar' });
  });
});
