import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import { WebSocket } from 'ws';
import { resolve } from 'path';
import { createServer } from 'net';
import { GatewayServer } from './server.js';
import type { GatewayServerConfig } from './server.js';
import type { GatewayResponse, GatewayError, GatewayEvent } from './protocol.js';
import { ErrorCode } from './protocol.js';

async function canListenOnLocalhost(): Promise<boolean> {
  return new Promise((resolvePromise) => {
    const s = createServer();
    s.once('error', () => resolvePromise(false));
    s.listen(0, '127.0.0.1', () => {
      s.close(() => resolvePromise(true));
    });
  });
}

let LISTEN_ALLOWED = true;

// Minimal mocks for dependencies
const mockSession = {
  id: 'test',
  addMessage: vi.fn(),
  getHistory: vi.fn(() => []),
  clear: vi.fn(),
  setHistory: vi.fn(),
  replaceHistory: vi.fn(),
};

const mockSessionManager = {
  getSession: vi.fn(() => mockSession),
  listSessions: vi.fn(() => ['ws:test']),
  transferSession: vi.fn(),
  closeSession: vi.fn(),
};

const mockModelClient = {
  chat: vi.fn(async () => ({
    content: 'Hello from Flynn!',
    stopReason: 'end_turn',
    usage: { inputTokens: 10, outputTokens: 5 },
  })),
};

const mockToolRegistry = {
  register: vi.fn(),
  get: vi.fn((name: string) => (name === 'shell.exec' ? { name: 'shell.exec', description: 'Run shell', inputSchema: { type: 'object', properties: {} } } : undefined)),
  list: vi.fn(() => [{ name: 'shell.exec', description: 'Run shell', inputSchema: { type: 'object', properties: {} } }]),
  filteredList: vi.fn(() => [{ name: 'shell.exec', description: 'Run shell', inputSchema: { type: 'object', properties: {} } }]),
  toAnthropicFormat: vi.fn(() => []),
  toOpenAIFormat: vi.fn(() => []),
  filteredToAnthropicFormat: vi.fn(() => []),
  filteredToOpenAIFormat: vi.fn(() => []),
};

const mockToolExecutor = {
  execute: vi.fn(async () => ({ success: true, output: 'executed' })),
};

const TEST_PORT = 18899;

let server: GatewayServer;

function createClient(): Promise<WebSocket> {
  return new Promise((resolve, reject) => {
    const ws = new WebSocket(`ws://127.0.0.1:${TEST_PORT}`);
    ws.on('open', () => resolve(ws));
    ws.on('error', reject);
  });
}

function sendAndReceive(ws: WebSocket, msg: object): Promise<GatewayResponse | GatewayError | GatewayEvent> {
  return new Promise((resolve) => {
    ws.once('message', (data) => {
      resolve(JSON.parse(data.toString()));
    });
    ws.send(JSON.stringify(msg));
  });
}

function sendAndReceiveAll(ws: WebSocket, msg: object, count: number): Promise<Array<GatewayResponse | GatewayError | GatewayEvent>> {
  return new Promise((resolve) => {
    const messages: Array<GatewayResponse | GatewayError | GatewayEvent> = [];
    const handler = (data: Buffer) => {
      messages.push(JSON.parse(data.toString()));
      if (messages.length >= count) {
        ws.off('message', handler);
        resolve(messages);
      }
    };
    ws.on('message', handler);
    ws.send(JSON.stringify(msg));
  });
}

beforeAll(async () => {
  LISTEN_ALLOWED = await canListenOnLocalhost();
});

describe('GatewayServer integration', () => {
  beforeAll(async () => {
    if (!LISTEN_ALLOWED) {
      return;
    }
    server = new GatewayServer({
      port: TEST_PORT,
      sessionManager: mockSessionManager as unknown as GatewayServerConfig['sessionManager'],
      modelClient: mockModelClient,
      systemPrompt: 'Test prompt',
      toolRegistry: mockToolRegistry as unknown as GatewayServerConfig['toolRegistry'],
      toolExecutor: mockToolExecutor as unknown as GatewayServerConfig['toolExecutor'],
      version: '0.1.0-test',
      uiDir: resolve(import.meta.dirname, 'ui'),
    });
    await server.start();
  });

  afterAll(async () => {
    if (!LISTEN_ALLOWED) {
      return;
    }
    await server.stop();
  });

  it('responds to system.health', async () => {
    if (!LISTEN_ALLOWED) {
      return;
    }
    const ws = await createClient();
    try {
      const result = await sendAndReceive(ws, { id: 1, method: 'system.health' });
      const response = result as GatewayResponse;
      expect(response.id).toBe(1);
      const r = response.result as Record<string, unknown>;
      expect(r.status).toBe('ok');
      expect(r.version).toBe('0.1.0-test');
      expect(typeof r.uptime).toBe('number');
    } finally {
      ws.close();
    }
  });

  it('returns MethodNotFound for unknown method', async () => {
    if (!LISTEN_ALLOWED) {
      return;
    }
    const ws = await createClient();
    try {
      const result = await sendAndReceive(ws, { id: 2, method: 'unknown.method' });
      const error = result as GatewayError;
      expect(error.error.code).toBe(ErrorCode.MethodNotFound);
    } finally {
      ws.close();
    }
  });

  it('returns ParseError for invalid JSON', async () => {
    if (!LISTEN_ALLOWED) {
      return;
    }
    const ws = await createClient();
    try {
      const result = await new Promise<GatewayError>((resolve) => {
        ws.once('message', (data) => resolve(JSON.parse(data.toString())));
        ws.send('not valid json');
      });
      expect(result.error.code).toBe(ErrorCode.ParseError);
    } finally {
      ws.close();
    }
  });

  it('lists tools via tools.list', async () => {
    if (!LISTEN_ALLOWED) {
      return;
    }
    const ws = await createClient();
    try {
      const result = await sendAndReceive(ws, { id: 3, method: 'tools.list' });
      const response = result as GatewayResponse;
      const r = response.result as { tools: Array<{ name: string }> };
      expect(r.tools.length).toBeGreaterThan(0);
      expect(r.tools[0].name).toBe('shell.exec');
    } finally {
      ws.close();
    }
  });

  it('sends agent message and receives done event', async () => {
    if (!LISTEN_ALLOWED) {
      return;
    }
    const ws = await createClient();
    try {
      // agent.send streams events — we expect a 'done' event
      const messages = await sendAndReceiveAll(ws, { id: 4, method: 'agent.send', params: { message: 'hi' } }, 1);
      const doneEvent = messages[0] as GatewayEvent;
      expect(doneEvent.id).toBe(4);
      expect(doneEvent.event).toBe('done');
      expect((doneEvent.data as { content?: string }).content).toBe('Hello from Flynn!');
    } finally {
      ws.close();
    }
  });

  it('tracks connections correctly', async () => {
    if (!LISTEN_ALLOWED) {
      return;
    }
    const ws1 = await createClient();
    const ws2 = await createClient();
    try {
      const result = await sendAndReceive(ws1, { id: 5, method: 'system.health' });
      const r = (result as GatewayResponse).result as Record<string, unknown>;
      expect(r.connections).toBe(2);
    } finally {
      ws1.close();
      ws2.close();
    }
  });

  it('lists registered methods', () => {
    if (!LISTEN_ALLOWED) {
      return;
    }
    const methods = server.getMethods();
    expect(methods).toContain('system.health');
    expect(methods).toContain('agent.send');
    expect(methods).toContain('agent.cancel');
    expect(methods).toContain('sessions.list');
    expect(methods).toContain('sessions.history');
    expect(methods).toContain('sessions.create');
    expect(methods).toContain('tools.list');
    expect(methods).toContain('tools.invoke');
    expect(methods).toContain('canvas.put');
    expect(methods).toContain('canvas.list');
    expect(methods).toContain('system.nodes');
    expect(methods).toContain('node.status.set');
    expect(methods).toContain('node.push_token.set');
  });

  it('supports canvas artifact lifecycle via gateway RPC', async () => {
    if (!LISTEN_ALLOWED) {
      return;
    }
    const ws = await createClient();
    try {
      const put = await sendAndReceive(ws, {
        id: 31,
        method: 'canvas.put',
        params: {
          sessionId: 'ws:test-canvas',
          artifactId: 'a1',
          type: 'note',
          content: { text: 'draft' },
        },
      });
      expect((put as GatewayResponse).id).toBe(31);

      const list = await sendAndReceive(ws, {
        id: 32,
        method: 'canvas.list',
        params: { sessionId: 'ws:test-canvas' },
      });
      const artifacts = ((list as GatewayResponse).result as { artifacts: Array<{ id: string }> }).artifacts;
      expect(artifacts).toHaveLength(1);
      expect(artifacts[0]?.id).toBe('a1');

      const clear = await sendAndReceive(ws, {
        id: 33,
        method: 'canvas.clear',
        params: { sessionId: 'ws:test-canvas' },
      });
      expect(((clear as GatewayResponse).result as { cleared: number }).cleared).toBe(1);
    } finally {
      ws.close();
    }
  });

  // ── HTTP static file serving tests ────────────────────────────

  it('serves index.html on HTTP GET /', async () => {
    if (!LISTEN_ALLOWED) {
      return;
    }
    const res = await fetch(`http://127.0.0.1:${TEST_PORT}/`);
    expect(res.status).toBe(200);
    expect(res.headers.get('content-type')).toBe('text/html');
    const body = await res.text();
    expect(body).toContain('Flynn');
  });

  it('serves style.css on HTTP GET /style.css', async () => {
    if (!LISTEN_ALLOWED) {
      return;
    }
    const res = await fetch(`http://127.0.0.1:${TEST_PORT}/style.css`);
    expect(res.status).toBe(200);
    expect(res.headers.get('content-type')).toBe('text/css');
  });

  it('returns 404 for unknown HTTP path', async () => {
    if (!LISTEN_ALLOWED) {
      return;
    }
    const res = await fetch(`http://127.0.0.1:${TEST_PORT}/nonexistent`);
    expect(res.status).toBe(404);
  });

  it('returns 404 for path traversal attempt', async () => {
    if (!LISTEN_ALLOWED) {
      return;
    }
    const res = await fetch(`http://127.0.0.1:${TEST_PORT}/../../../etc/passwd`);
    expect(res.status).toBe(404);
  });
});

describe('GatewayServer lock mode', () => {
  const LOCK_PORT = 18897;
  let lockServer: GatewayServer;

  beforeAll(async () => {
    if (!LISTEN_ALLOWED) {
      return;
    }
    lockServer = new GatewayServer({
      port: LOCK_PORT,
      sessionManager: mockSessionManager as unknown as GatewayServerConfig['sessionManager'],
      modelClient: mockModelClient,
      systemPrompt: 'Test prompt',
      toolRegistry: mockToolRegistry as unknown as GatewayServerConfig['toolRegistry'],
      toolExecutor: mockToolExecutor as unknown as GatewayServerConfig['toolExecutor'],
      lock: true,
      uiDir: resolve(import.meta.dirname, 'ui'),
    });
    await lockServer.start();
  });

  afterAll(async () => {
    if (!LISTEN_ALLOWED) {
      return;
    }
    await lockServer.stop();
  });

  function createLockClient(): Promise<WebSocket> {
    return new Promise((resolve, reject) => {
      const ws = new WebSocket(`ws://127.0.0.1:${LOCK_PORT}`);
      ws.on('open', () => resolve(ws));
      ws.on('error', reject);
    });
  }

  it('allows the first client to connect', async () => {
    if (!LISTEN_ALLOWED) {
      return;
    }
    const ws = await createLockClient();
    try {
      const result = await sendAndReceive(ws, { id: 1, method: 'system.health' });
      const response = result as GatewayResponse;
      expect((response.result as { status?: string }).status).toBe('ok');
    } finally {
      ws.close();
      // Wait for the close to propagate so connectionMap is empty
      await new Promise(r => setTimeout(r, 100));
    }
  });

  it('rejects second client with code 4003 when locked', async () => {
    if (!LISTEN_ALLOWED) {
      return;
    }
    const ws1 = await createLockClient();
    try {
      // Second client should be rejected
      const closePromise = new Promise<{ code: number; reason: string }>((resolve) => {
        const ws2 = new WebSocket(`ws://127.0.0.1:${LOCK_PORT}`);
        ws2.on('close', (code, reason) => {
          resolve({ code, reason: reason.toString() });
        });
      });

      const { code, reason } = await closePromise;
      expect(code).toBe(4003);
      expect(reason).toContain('locked');
    } finally {
      ws1.close();
      await new Promise(r => setTimeout(r, 100));
    }
  });

  it('allows a new client after the previous one disconnects', async () => {
    if (!LISTEN_ALLOWED) {
      return;
    }
    const ws1 = await createLockClient();
    ws1.close();
    // Wait for the close to propagate
    await new Promise(r => setTimeout(r, 100));

    const ws2 = await createLockClient();
    try {
      const result = await sendAndReceive(ws2, { id: 2, method: 'system.health' });
      const response = result as GatewayResponse;
      expect((response.result as { status?: string }).status).toBe('ok');
    } finally {
      ws2.close();
      await new Promise(r => setTimeout(r, 100));
    }
  });

  it('system.lock handler returns lock status', async () => {
    if (!LISTEN_ALLOWED) {
      return;
    }
    const ws = await createLockClient();
    try {
      const result = await sendAndReceive(ws, { id: 3, method: 'system.lock' });
      const response = result as GatewayResponse;
      const r = response.result as { locked: boolean; activeClients: number; maxClients: number | null };
      expect(r.locked).toBe(true);
      expect(r.activeClients).toBe(1);
      expect(r.maxClients).toBe(1);
    } finally {
      ws.close();
      await new Promise(r => setTimeout(r, 100));
    }
  });
});

describe('GatewayServer HTTP auth', () => {
  const AUTH_PORT = 18898;
  let authServer: GatewayServer;

  beforeAll(async () => {
    if (!LISTEN_ALLOWED) {
      return;
    }
    authServer = new GatewayServer({
      port: AUTH_PORT,
      sessionManager: mockSessionManager as unknown as GatewayServerConfig['sessionManager'],
      modelClient: mockModelClient,
      systemPrompt: 'Test prompt',
      toolRegistry: mockToolRegistry as unknown as GatewayServerConfig['toolRegistry'],
      toolExecutor: mockToolExecutor as unknown as GatewayServerConfig['toolExecutor'],
      auth: { token: 'test-secret' },
      authHttp: true,
      uiDir: resolve(import.meta.dirname, 'ui'),
    });
    await authServer.start();
  });

  afterAll(async () => {
    if (!LISTEN_ALLOWED) {
      return;
    }
    await authServer.stop();
  });

  it('returns 401 for HTTP request without token', async () => {
    if (!LISTEN_ALLOWED) {
      return;
    }
    const res = await fetch(`http://127.0.0.1:${AUTH_PORT}/`);
    expect(res.status).toBe(401);
    expect(res.headers.get('www-authenticate')).toBe('Bearer');
  });

  it('serves content with valid Bearer token', async () => {
    if (!LISTEN_ALLOWED) {
      return;
    }
    const res = await fetch(`http://127.0.0.1:${AUTH_PORT}/`, {
      headers: { Authorization: 'Bearer test-secret' },
    });
    expect(res.status).toBe(200);
    expect(res.headers.get('content-type')).toBe('text/html');
  });
});

describe('GatewayServer request body limits', () => {
  const BODY_PORT = 18896;
  let bodyLimitServer: GatewayServer;
  const gmailHandler = {
    handlePushNotification: vi.fn(async () => {}),
  };

  beforeAll(async () => {
    if (!LISTEN_ALLOWED) {
      return;
    }
    bodyLimitServer = new GatewayServer({
      port: BODY_PORT,
      sessionManager: mockSessionManager as unknown as GatewayServerConfig['sessionManager'],
      modelClient: mockModelClient,
      systemPrompt: 'Test prompt',
      toolRegistry: mockToolRegistry as unknown as GatewayServerConfig['toolRegistry'],
      toolExecutor: mockToolExecutor as unknown as GatewayServerConfig['toolExecutor'],
      gmailHandler: gmailHandler as unknown as GatewayServerConfig['gmailHandler'],
      maxRequestBodyBytes: 64,
      uiDir: resolve(import.meta.dirname, 'ui'),
    });
    await bodyLimitServer.start();
  });

  afterAll(async () => {
    if (!LISTEN_ALLOWED) {
      return;
    }
    await bodyLimitServer.stop();
  });

  it('accepts gmail push body under limit', async () => {
    if (!LISTEN_ALLOWED) {
      return;
    }
    gmailHandler.handlePushNotification.mockClear();

    const body = JSON.stringify({ message: { data: 'abc' } });
    const res = await fetch(`http://127.0.0.1:${BODY_PORT}/gmail/push`, {
      method: 'POST',
      body,
      headers: { 'Content-Type': 'application/json' },
    });
    expect(res.status).toBe(200);
    expect(gmailHandler.handlePushNotification).toHaveBeenCalledWith('abc');
  });

  it('rejects gmail push body over limit with 413', async () => {
    if (!LISTEN_ALLOWED) {
      return;
    }
    gmailHandler.handlePushNotification.mockClear();

    const body = JSON.stringify({ message: { data: 'x'.repeat(2048) } });
    const res = await fetch(`http://127.0.0.1:${BODY_PORT}/gmail/push`, {
      method: 'POST',
      body,
      headers: { 'Content-Type': 'application/json' },
    });
    expect(res.status).toBe(413);
    expect(gmailHandler.handlePushNotification).not.toHaveBeenCalled();
  });
});

describe('GatewayServer WebSocket ingress rate limiting', () => {
  const RATE_PORT = 18895;
  let rateServer: GatewayServer;

  beforeAll(async () => {
    if (!LISTEN_ALLOWED) {
      return;
    }
    rateServer = new GatewayServer({
      port: RATE_PORT,
      sessionManager: mockSessionManager as unknown as GatewayServerConfig['sessionManager'],
      modelClient: mockModelClient,
      systemPrompt: 'Test prompt',
      toolRegistry: mockToolRegistry as unknown as GatewayServerConfig['toolRegistry'],
      toolExecutor: mockToolExecutor as unknown as GatewayServerConfig['toolExecutor'],
      uiDir: resolve(import.meta.dirname, 'ui'),
      wsRateLimit: {
        enabled: true,
        capacity: 2,
        refillPerSec: 1,
        maxViolations: 3,
        violationWindowMs: 10_000,
      },
    });
    await rateServer.start();
  });

  afterAll(async () => {
    if (!LISTEN_ALLOWED) {
      return;
    }
    await rateServer.stop();
  });

  it('throttles bursts and closes repeated offenders', async () => {
    if (!LISTEN_ALLOWED) {
      return;
    }

    const ws = await new Promise<WebSocket>((resolve, reject) => {
      const c = new WebSocket(`ws://127.0.0.1:${RATE_PORT}`);
      c.on('open', () => resolve(c));
      c.on('error', reject);
    });

    try {
      const first = await sendAndReceive(ws, { id: 1, method: 'system.health' });
      expect((first as GatewayResponse).id).toBe(1);

      const second = await sendAndReceive(ws, { id: 2, method: 'system.health' });
      expect((second as GatewayResponse).id).toBe(2);

      const third = await sendAndReceive(ws, { id: 3, method: 'system.health' });
      const rateErr = third as GatewayError;
      expect(rateErr.error.code).toBe(ErrorCode.InternalError);
      expect(rateErr.error.message).toContain('Rate limit exceeded');

      // Trigger additional violations; server should close on max violation threshold.
      ws.send(JSON.stringify({ id: 4, method: 'system.health' }));
      ws.send(JSON.stringify({ id: 5, method: 'system.health' }));

      const close = await new Promise<{ code: number; reason: string }>((resolve) => {
        ws.on('close', (code, reason) => resolve({ code, reason: reason.toString() }));
      });
      expect(close.code).toBe(4008);
      expect(close.reason).toContain('Rate limit exceeded');
    } finally {
      if (ws.readyState === WebSocket.OPEN) {
        ws.close();
      }
    }
  });
});

describe('GatewayServer node registration and capability negotiation', () => {
  const NODE_PORT = 18894;
  let nodeServer: GatewayServer;

  beforeAll(async () => {
    if (!LISTEN_ALLOWED) {
      return;
    }
    nodeServer = new GatewayServer({
      port: NODE_PORT,
      sessionManager: mockSessionManager as unknown as GatewayServerConfig['sessionManager'],
      modelClient: mockModelClient,
      systemPrompt: 'Test prompt',
      toolRegistry: mockToolRegistry as unknown as GatewayServerConfig['toolRegistry'],
      toolExecutor: mockToolExecutor as unknown as GatewayServerConfig['toolExecutor'],
      uiDir: resolve(import.meta.dirname, 'ui'),
      nodes: {
        enabled: true,
        allowedRoles: ['companion'],
        featureGates: { 'ui.canvas': true },
        locationEnabled: true,
        pushEnabled: true,
      },
    });
    await nodeServer.start();
  });

  afterAll(async () => {
    if (!LISTEN_ALLOWED) {
      return;
    }
    await nodeServer.stop();
  });

  it('enforces role allow/deny and node registration lifecycle', async () => {
    if (!LISTEN_ALLOWED) {
      return;
    }

    const ws = await new Promise<WebSocket>((resolve, reject) => {
      const c = new WebSocket(`ws://127.0.0.1:${NODE_PORT}`);
      c.on('open', () => resolve(c));
      c.on('error', reject);
    });

    try {
      const beforeRegister = await sendAndReceive(ws, { id: 1, method: 'node.capabilities.get', params: {} });
      expect((beforeRegister as GatewayError).error.code).toBe(ErrorCode.AuthFailed);

      const badRole = await sendAndReceive(ws, {
        id: 2,
        method: 'node.register',
        params: {
          nodeId: 'node-bad',
          role: 'observer',
          protocolVersion: 1,
          capabilities: ['ui.canvas'],
        },
      });
      expect((badRole as GatewayError).error.code).toBe(ErrorCode.AuthFailed);

      const registered = await sendAndReceive(ws, {
        id: 3,
        method: 'node.register',
        params: {
          nodeId: 'node-good',
          role: 'companion',
          protocolVersion: 1,
          capabilities: ['ui.canvas'],
        },
      });
      expect((registered as GatewayResponse).id).toBe(3);
      expect(((registered as GatewayResponse).result as { registered: boolean }).registered).toBe(true);

      const capabilities = await sendAndReceive(ws, { id: 4, method: 'node.capabilities.get', params: {} });
      expect((capabilities as GatewayResponse).id).toBe(4);
      expect(((capabilities as GatewayResponse).result as { node: { role: string } }).node.role).toBe('companion');
    } finally {
      if (ws.readyState === WebSocket.OPEN) {
        ws.close();
      }
    }
  });

  it('supports node location set/get after registration', async () => {
    if (!LISTEN_ALLOWED) {
      return;
    }

    const ws = await new Promise<WebSocket>((resolve, reject) => {
      const c = new WebSocket(`ws://127.0.0.1:${NODE_PORT}`);
      c.on('open', () => resolve(c));
      c.on('error', reject);
    });

    try {
      const registered = await sendAndReceive(ws, {
        id: 10,
        method: 'node.register',
        params: {
          nodeId: 'node-loc',
          role: 'companion',
          protocolVersion: 1,
          capabilities: ['location'],
        },
      });
      expect(((registered as GatewayResponse).result as { registered: boolean }).registered).toBe(true);

      const setResult = await sendAndReceive(ws, {
        id: 11,
        method: 'node.location.set',
        params: {
          latitude: 51.5074,
          longitude: -0.1278,
          source: 'gps',
        },
      });
      expect(((setResult as GatewayResponse).result as { updated: boolean }).updated).toBe(true);

      const getResult = await sendAndReceive(ws, {
        id: 12,
        method: 'node.location.get',
        params: {},
      });
      const location = ((getResult as GatewayResponse).result as {
        location: { latitude: number; longitude: number };
      }).location;
      expect(location.latitude).toBe(51.5074);
      expect(location.longitude).toBe(-0.1278);
    } finally {
      if (ws.readyState === WebSocket.OPEN) {
        ws.close();
      }
    }
  });

  it('supports node.status.set and exposes registered nodes via system.nodes', async () => {
    if (!LISTEN_ALLOWED) {
      return;
    }

    const ws = await new Promise<WebSocket>((resolve, reject) => {
      const c = new WebSocket(`ws://127.0.0.1:${NODE_PORT}`);
      c.on('open', () => resolve(c));
      c.on('error', reject);
    });

    try {
      const registered = await sendAndReceive(ws, {
        id: 20,
        method: 'node.register',
        params: {
          nodeId: 'node-mac',
          role: 'companion',
          protocolVersion: 1,
          capabilities: ['ui.canvas'],
        },
      });
      expect(((registered as GatewayResponse).result as { registered: boolean }).registered).toBe(true);

      const status = await sendAndReceive(ws, {
        id: 21,
        method: 'node.status.set',
        params: {
          platform: 'macos',
          appVersion: '0.3.1',
          deviceName: 'MacBook Pro',
          batteryPct: 64,
          powerSource: 'battery',
        },
      });
      expect(((status as GatewayResponse).result as { updated: boolean }).updated).toBe(true);

      const nodes = await sendAndReceive(ws, {
        id: 22,
        method: 'system.nodes',
        params: { role: 'companion', platform: 'macos', limit: 10 },
      });
      const list = ((nodes as GatewayResponse).result as {
        nodes: Array<{ nodeId: string; status?: { platform: string; appVersion?: string } }>;
      }).nodes;
      expect(list.length).toBeGreaterThanOrEqual(1);
      expect(list.some((entry) => entry.nodeId === 'node-mac')).toBe(true);
      expect(list.find((entry) => entry.nodeId === 'node-mac')?.status?.platform).toBe('macos');
    } finally {
      if (ws.readyState === WebSocket.OPEN) {
        ws.close();
      }
    }
  });

  it('supports node.push_token.set and exposes masked push summary via system.nodes', async () => {
    if (!LISTEN_ALLOWED) {
      return;
    }

    const ws = await new Promise<WebSocket>((resolve, reject) => {
      const c = new WebSocket(`ws://127.0.0.1:${NODE_PORT}`);
      c.on('open', () => resolve(c));
      c.on('error', reject);
    });

    try {
      const registered = await sendAndReceive(ws, {
        id: 30,
        method: 'node.register',
        params: {
          nodeId: 'node-ios',
          role: 'companion',
          protocolVersion: 1,
          capabilities: ['notifications'],
        },
      });
      expect(((registered as GatewayResponse).result as { registered: boolean }).registered).toBe(true);

      const push = await sendAndReceive(ws, {
        id: 31,
        method: 'node.push_token.set',
        params: {
          provider: 'apns',
          token: 'abcd1234abcd1234abcd1234abcd1234',
          topic: 'com.example.flynn',
          environment: 'sandbox',
        },
      });
      const preview = ((push as GatewayResponse).result as {
        push: { tokenPreview: string };
      }).push.tokenPreview;
      expect(preview).toContain('abcd1234');

      const nodes = await sendAndReceive(ws, {
        id: 32,
        method: 'system.nodes',
        params: { role: 'companion', limit: 10 },
      });
      const list = ((nodes as GatewayResponse).result as {
        nodes: Array<{ nodeId: string; push?: { tokenPreview: string } }>;
      }).nodes;
      const iosNode = list.find((entry) => entry.nodeId === 'node-ios');
      expect(iosNode?.push?.tokenPreview).toContain('abcd1234');
      expect(iosNode?.push?.tokenPreview).not.toContain('abcd1234abcd1234abcd1234');
    } finally {
      if (ws.readyState === WebSocket.OPEN) {
        ws.close();
      }
    }
  });

  it('supports android fcm push token registration', async () => {
    if (!LISTEN_ALLOWED) {
      return;
    }

    const ws = await new Promise<WebSocket>((resolve, reject) => {
      const c = new WebSocket(`ws://127.0.0.1:${NODE_PORT}`);
      c.on('open', () => resolve(c));
      c.on('error', reject);
    });

    try {
      const registered = await sendAndReceive(ws, {
        id: 40,
        method: 'node.register',
        params: {
          nodeId: 'node-android',
          role: 'companion',
          protocolVersion: 1,
          capabilities: ['notifications'],
        },
      });
      expect(((registered as GatewayResponse).result as { registered: boolean }).registered).toBe(true);

      const push = await sendAndReceive(ws, {
        id: 41,
        method: 'node.push_token.set',
        params: {
          provider: 'fcm',
          token: 'fcm_abcdefghijklmnopqrstuvwxyz123456',
        },
      });
      expect(((push as GatewayResponse).result as { updated: boolean }).updated).toBe(true);

      const nodes = await sendAndReceive(ws, {
        id: 42,
        method: 'system.nodes',
        params: { role: 'companion', limit: 20 },
      });
      const list = ((nodes as GatewayResponse).result as {
        nodes: Array<{ nodeId: string; push?: { provider: string; environment?: string } }>;
      }).nodes;
      const androidNode = list.find((entry) => entry.nodeId === 'node-android');
      expect(androidNode?.push?.provider).toBe('fcm');
      expect(androidNode?.push?.environment).toBeUndefined();
    } finally {
      if (ws.readyState === WebSocket.OPEN) {
        ws.close();
      }
    }
  });
});
