import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { WebSocketConnectionService } from '../src/sync/services/websocket-connection.service';
import { Logger } from '../src/logger';

vi.mock('../src/logger', () => ({
  Logger: {
    info: vi.fn(),
    warn: vi.fn(),
    debug: vi.fn(),
    error: vi.fn(),
  },
}));

const WS_OPEN = 1;
const WS_CLOSED = 3;

interface MockWs {
  readyState: number;
  send: ReturnType<typeof vi.fn>;
  close: ReturnType<typeof vi.fn>;
  ping: ReturnType<typeof vi.fn>;
  on: ReturnType<typeof vi.fn>;
  _handlers: Map<string, (...args: unknown[]) => void>;
  _emitPong: () => void;
  _emitClose: () => void;
  _emitMessage: (data: string) => void;
  _emitError: (err: Error) => void;
}

function createMockWs(): MockWs {
  const handlers = new Map<string, (...args: unknown[]) => void>();
  const mock: MockWs = {
    readyState: WS_OPEN,
    send: vi.fn(),
    close: vi.fn(),
    ping: vi.fn(),
    on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
      handlers.set(event, handler);
    }),
    _handlers: handlers,
    _emitPong() {
      handlers.get('pong')?.();
    },
    _emitClose() {
      handlers.get('close')?.();
    },
    _emitMessage(data: string) {
      handlers.get('message')?.(Buffer.from(data));
    },
    _emitError(err: Error) {
      handlers.get('error')?.(err);
    },
  };
  return mock;
}

function parseSendCalls(mockWs: MockWs): Record<string, unknown>[] {
  return mockWs.send.mock.calls.map((c: unknown[]) => JSON.parse(c[0] as string));
}

describe('WebSocketConnectionService', () => {
  let service: WebSocketConnectionService;

  beforeEach(() => {
    vi.useFakeTimers();
    vi.mocked(Logger.info).mockClear();
    vi.mocked(Logger.warn).mockClear();
    vi.mocked(Logger.debug).mockClear();
    vi.mocked(Logger.error).mockClear();
    service = new WebSocketConnectionService();
  });

  afterEach(() => {
    service.stopHeartbeat();
    service.closeAll();
    vi.useRealTimers();
  });

  describe('addConnection', () => {
    it('should track a connection and send "connected" message', () => {
      const ws = createMockWs();

      service.addConnection(1, 'client-a', ws as any);

      expect(service.getConnectionCount()).toBe(1);
      const messages = parseSendCalls(ws);
      expect(messages).toContainEqual(expect.objectContaining({ type: 'connected' }));
    });

    it('should enforce max 10 connections per user', () => {
      const sockets: MockWs[] = [];
      for (let i = 0; i < 10; i++) {
        const ws = createMockWs();
        sockets.push(ws);
        service.addConnection(1, `client-${i}`, ws as any);
      }

      expect(service.getConnectionCount()).toBe(10);

      const eleventhWs = createMockWs();
      service.addConnection(1, 'client-10', eleventhWs as any);

      expect(eleventhWs.close).toHaveBeenCalledWith(4008, 'Too many connections');
      expect(service.getConnectionCount()).toBe(10);
    });

    it('should register close handler that removes connection', () => {
      const ws = createMockWs();
      service.addConnection(1, 'client-a', ws as any);

      expect(service.getConnectionCount()).toBe(1);

      ws._emitClose();

      expect(service.getConnectionCount()).toBe(0);
    });

    it('should replace a stale connection from the same clientId after the reconnect cooldown', () => {
      const stale = createMockWs();
      service.addConnection(1, 'client-a', stale as any);
      expect(service.getConnectionCount()).toBe(1);

      // Past the cooldown: a reconnect is treated as genuine and evicts the
      // stale incumbent (the original recovery path is preserved).
      vi.advanceTimersByTime(5_000);

      const fresh = createMockWs();
      service.addConnection(1, 'client-a', fresh as any);

      // Stale socket was evicted with the "replaced" close code.
      expect(stale.close).toHaveBeenCalledWith(4009, 'Replaced by newer connection');
      // Only the fresh entry remains.
      expect(service.getConnectionCount()).toBe(1);
      // The new connection was accepted, not rejected.
      expect(fresh.close).not.toHaveBeenCalled();

      // Real `ws` fires the 'close' event asynchronously after `ws.close()`.
      // The stale socket's late close event must not disturb the fresh entry.
      stale._emitClose();
      expect(service.getConnectionCount()).toBe(1);
      expect(fresh.close).not.toHaveBeenCalled();
    });

    it('should refuse a challenger that reconnects within the cooldown and keep the incumbent', () => {
      const incumbent = createMockWs();
      service.addConnection(1, 'client-a', incumbent as any);
      expect(service.getConnectionCount()).toBe(1);

      // Same clientId reconnects immediately (the shared-clientId storm).
      const challenger = createMockWs();
      service.addConnection(1, 'client-a', challenger as any);

      // Challenger is refused; incumbent is NOT evicted (no 4009 emitted).
      expect(challenger.close).toHaveBeenCalledWith(4008, 'Reconnecting too fast');
      expect(incumbent.close).not.toHaveBeenCalled();
      expect(service.getConnectionCount()).toBe(1);

      // The incumbent socket — not the challenger — remains the live one.
      challenger.send.mockClear();
      service.notifyNewOps(1, 'other-client', 7);
      vi.advanceTimersByTime(100);
      const incumbentMsgs = parseSendCalls(incumbent);
      expect(incumbentMsgs).toContainEqual(
        expect.objectContaining({ type: 'new_ops', latestSeq: 7 }),
      );
      expect(challenger.send).not.toHaveBeenCalled();
    });

    it('should replace a dead incumbent even within the cooldown', () => {
      const stale = createMockWs();
      service.addConnection(1, 'client-a', stale as any);

      // Incumbent socket is dead at the OS level: a genuine reconnect must
      // recover immediately, not wait out the cooldown behind a dead socket.
      stale.readyState = WS_CLOSED;

      const fresh = createMockWs();
      service.addConnection(1, 'client-a', fresh as any);

      // Fresh socket accepted (not refused with 4008), dead incumbent replaced.
      expect(fresh.close).not.toHaveBeenCalled();
      expect(service.getConnectionCount()).toBe(1);
    });

    it('should not call close() on the evicted socket when it is already CLOSED', () => {
      const stale = createMockWs();
      service.addConnection(1, 'client-a', stale as any);

      // Simulate the stale socket already being closed at the OS level
      // (e.g. removeConnection's gate must skip ws.close to avoid double-close).
      stale.readyState = WS_CLOSED;
      const closeCallsBefore = stale.close.mock.calls.length;

      const fresh = createMockWs();
      service.addConnection(1, 'client-a', fresh as any);

      // Dedup took effect (only the fresh entry remains) but ws.close was not
      // re-invoked on the already-closed stale socket.
      expect(service.getConnectionCount()).toBe(1);
      expect(stale.close.mock.calls.length).toBe(closeCallsBefore);
    });

    it('should not exceed the per-user cap when same clientId reconnects repeatedly', () => {
      // 9 unique clientIds use 9 slots; clientId 'A' takes the remaining slot.
      for (let i = 0; i < 9; i++) {
        service.addConnection(1, `unique-${i}`, createMockWs() as any);
      }

      // First 'A' is accepted into the last slot.
      const incumbent = createMockWs();
      service.addConnection(1, 'A', incumbent as any);
      expect(incumbent.close).not.toHaveBeenCalled();

      // Rapid 'A' reconnects within the cooldown are refused (4008); the
      // incumbent is kept, so the slot count never exceeds the cap and the
      // reconnect storm cannot consume slots.
      for (let i = 0; i < 4; i++) {
        const ws = createMockWs();
        service.addConnection(1, 'A', ws as any);
        expect(ws.close).toHaveBeenCalledWith(4008, 'Reconnecting too fast');
      }
      expect(incumbent.close).not.toHaveBeenCalled();
      expect(service.getConnectionCount()).toBe(10);

      // After the cooldown a genuine reconnect evicts the incumbent and reuses
      // the slot — still capped at 10.
      vi.advanceTimersByTime(5_000);
      const successor = createMockWs();
      service.addConnection(1, 'A', successor as any);
      expect(incumbent.close).toHaveBeenCalledWith(4009, 'Replaced by newer connection');
      expect(successor.close).not.toHaveBeenCalled();
      expect(service.getConnectionCount()).toBe(10);
    });

    it('should slide the cooldown forward on each refused challenger so a sustained storm cannot tick into an eviction', () => {
      const incumbent = createMockWs();
      service.addConnection(1, 'client-a', incumbent as any);

      // Storm: a challenger every 1s for 10s. Without sliding, after 5s the
      // gate would expire and the next challenger would evict the incumbent.
      // With sliding, every refusal pushes the gate so the incumbent is kept.
      for (let i = 0; i < 10; i++) {
        vi.advanceTimersByTime(1_000);
        const challenger = createMockWs();
        service.addConnection(1, 'client-a', challenger as any);
        expect(challenger.close).toHaveBeenCalledWith(4008, 'Reconnecting too fast');
      }

      // Incumbent never evicted (no 4009 sent), only one slot used.
      expect(incumbent.close).not.toHaveBeenCalled();
      expect(service.getConnectionCount()).toBe(1);

      // After the storm quiets (5s with no challengers), the next reconnect
      // is treated as genuine and evicts the incumbent — recovery path intact.
      vi.advanceTimersByTime(5_000);
      const successor = createMockWs();
      service.addConnection(1, 'client-a', successor as any);
      expect(incumbent.close).toHaveBeenCalledWith(4009, 'Replaced by newer connection');
      expect(successor.close).not.toHaveBeenCalled();
    });

    it('should warn only once per incumbent storm and summarize on removal', () => {
      const incumbent = createMockWs();
      service.addConnection(1, 'client-a', incumbent as any);
      vi.mocked(Logger.warn).mockClear();
      vi.mocked(Logger.info).mockClear();

      // 25 refused challengers — only the first should emit a WARN.
      for (let i = 0; i < 25; i++) {
        const challenger = createMockWs();
        service.addConnection(1, 'client-a', challenger as any);
        expect(challenger.close).toHaveBeenCalledWith(4008, 'Reconnecting too fast');
        // Keep sliding the gate but stay inside it.
        vi.advanceTimersByTime(100);
      }

      const warnCalls = vi
        .mocked(Logger.warn)
        .mock.calls.filter((c) => String(c[0]).includes('Reconnect within cooldown'));
      expect(warnCalls).toHaveLength(1);

      // Storm summary fires when the incumbent finally goes away.
      incumbent.readyState = WS_CLOSED;
      incumbent._emitClose();

      const summaryCalls = vi
        .mocked(Logger.info)
        .mock.calls.filter((c) =>
          String(c[0]).includes('Refused 25 reconnect challenger(s)'),
        );
      expect(summaryCalls).toHaveLength(1);
    });

    it('should emit the storm summary exactly once when an incumbent with refusals is evicted by a post-cooldown successor', () => {
      // Regression: the explicit eviction path calls ws.close, which fires the
      // 'close' event handler, which re-enters removeConnection. Without
      // zeroing refusedChallengers, the summary INFO logged twice.
      const incumbent = createMockWs();
      service.addConnection(1, 'client-a', incumbent as any);

      // Refuse a few challengers so refusedChallengers > 0 on this incumbent.
      for (let i = 0; i < 3; i++) {
        const challenger = createMockWs();
        service.addConnection(1, 'client-a', challenger as any);
      }
      vi.mocked(Logger.info).mockClear();

      // Quiet period expires the cooldown.
      vi.advanceTimersByTime(5_000);

      // Successor evicts the incumbent — explicit removeConnection (4009) is
      // followed by the incumbent socket's own 'close' event re-entering
      // removeConnection. Both passes share the same ConnectedClient.
      const successor = createMockWs();
      service.addConnection(1, 'client-a', successor as any);
      incumbent._emitClose();

      const summaryCalls = vi
        .mocked(Logger.info)
        .mock.calls.filter((c) =>
          String(c[0]).includes('Refused 3 reconnect challenger(s)'),
        );
      expect(summaryCalls).toHaveLength(1);
    });

    it('should emit the storm summary exactly once when an incumbent with refusals is reaped by the heartbeat', () => {
      // Regression: heartbeat-dead-connection removal also calls
      // removeConnection -> ws.close -> 'close' event -> removeConnection
      // re-entry. summaryLogged must dedupe that path too.
      const incumbent = createMockWs();
      service.addConnection(1, 'client-a', incumbent as any);

      for (let i = 0; i < 4; i++) {
        const challenger = createMockWs();
        service.addConnection(1, 'client-a', challenger as any);
      }
      vi.mocked(Logger.info).mockClear();

      service.startHeartbeat();
      // Two ping cycles with no pong → heartbeat removes the incumbent.
      vi.advanceTimersByTime(30_000);
      vi.advanceTimersByTime(30_000);
      // The heartbeat's ws.close() would in real ws fire the close handler,
      // re-entering removeConnection.
      incumbent._emitClose();

      const summaryCalls = vi
        .mocked(Logger.info)
        .mock.calls.filter((c) =>
          String(c[0]).includes('Refused 4 reconnect challenger(s)'),
        );
      expect(summaryCalls).toHaveLength(1);
    });

    it('should not emit a storm summary when no challengers were refused', () => {
      const ws = createMockWs();
      service.addConnection(1, 'client-a', ws as any);
      vi.mocked(Logger.info).mockClear();

      ws._emitClose();

      const summaryCalls = vi
        .mocked(Logger.info)
        .mock.calls.filter((c) => String(c[0]).includes('Refused'));
      expect(summaryCalls).toHaveLength(0);
    });

    it('should still enforce the cap across distinct clientIds', () => {
      for (let i = 0; i < 10; i++) {
        service.addConnection(1, `client-${i}`, createMockWs() as any);
      }

      const eleventh = createMockWs();
      service.addConnection(1, 'client-10', eleventh as any);

      expect(eleventh.close).toHaveBeenCalledWith(4008, 'Too many connections');
      expect(service.getConnectionCount()).toBe(10);
    });
  });

  describe('removeConnection', () => {
    it('should clean up empty user sets', () => {
      const ws = createMockWs();
      service.addConnection(1, 'client-a', ws as any);

      expect(service.getConnectionCount()).toBe(1);

      ws._emitClose();

      expect(service.getConnectionCount()).toBe(0);
    });

    it('should not call close() on already-closed WebSocket', () => {
      const ws = createMockWs();
      service.addConnection(1, 'client-a', ws as any);

      // Reset call count after addConnection (which may have called send, but not close)
      const closeCallsBefore = ws.close.mock.calls.length;

      // Mark the socket as already closed
      ws.readyState = WS_CLOSED;

      // Trigger the close event, which calls removeConnection internally
      ws._emitClose();

      // removeConnection should NOT have called ws.close since readyState is CLOSED
      expect(ws.close.mock.calls.length).toBe(closeCallsBefore);
    });
  });

  describe('notifyNewOps', () => {
    it('should send to other clients and exclude sender', () => {
      const wsA = createMockWs();
      const wsB = createMockWs();
      service.addConnection(1, 'A', wsA as any);
      service.addConnection(1, 'B', wsB as any);

      // Reset send mocks after the "connected" messages
      wsA.send.mockClear();
      wsB.send.mockClear();

      service.notifyNewOps(1, 'A', 5);
      vi.advanceTimersByTime(100);

      const messagesB = parseSendCalls(wsB);
      expect(messagesB).toContainEqual(
        expect.objectContaining({ type: 'new_ops', latestSeq: 5 }),
      );

      const messagesA = parseSendCalls(wsA);
      expect(messagesA).not.toContainEqual(expect.objectContaining({ type: 'new_ops' }));
    });

    it('should debounce rapid calls (latest-seq-wins)', () => {
      const wsB = createMockWs();
      service.addConnection(1, 'B', wsB as any);
      wsB.send.mockClear();

      service.notifyNewOps(1, 'A', 3);
      service.notifyNewOps(1, 'A', 7);
      vi.advanceTimersByTime(100);

      const messages = parseSendCalls(wsB);
      const newOpsMessages = messages.filter((m) => m.type === 'new_ops');
      expect(newOpsMessages).toHaveLength(1);
      expect(newOpsMessages[0]).toEqual(
        expect.objectContaining({ type: 'new_ops', latestSeq: 7 }),
      );
    });

    it('should accumulate excludeClientIds across debounced calls', () => {
      const wsA = createMockWs();
      const wsB = createMockWs();
      const wsC = createMockWs();
      service.addConnection(1, 'A', wsA as any);
      service.addConnection(1, 'B', wsB as any);
      service.addConnection(1, 'C', wsC as any);

      wsA.send.mockClear();
      wsB.send.mockClear();
      wsC.send.mockClear();

      service.notifyNewOps(1, 'A', 3);
      service.notifyNewOps(1, 'B', 5);
      vi.advanceTimersByTime(100);

      // Client A excluded from first call, client B excluded from second
      const messagesA = parseSendCalls(wsA);
      expect(messagesA).not.toContainEqual(expect.objectContaining({ type: 'new_ops' }));

      const messagesB = parseSendCalls(wsB);
      expect(messagesB).not.toContainEqual(expect.objectContaining({ type: 'new_ops' }));

      // Only client C should receive the notification
      const messagesC = parseSendCalls(wsC);
      expect(messagesC).toContainEqual(
        expect.objectContaining({ type: 'new_ops', latestSeq: 5 }),
      );
    });

    it('should no-op for nonexistent user', () => {
      expect(() => service.notifyNewOps(999, 'A', 1)).not.toThrow();
      vi.advanceTimersByTime(100);
    });
  });

  describe('startHeartbeat', () => {
    it('should send ping at interval', () => {
      const ws = createMockWs();
      service.addConnection(1, 'client-a', ws as any);
      ws.send.mockClear();

      service.startHeartbeat();
      vi.advanceTimersByTime(30_000);

      const messages = parseSendCalls(ws);
      expect(messages).toContainEqual(expect.objectContaining({ type: 'ping' }));
      expect(ws.ping).toHaveBeenCalled();
    });

    it('should remove dead connections when no pong response', () => {
      const ws = createMockWs();
      service.addConnection(1, 'client-a', ws as any);

      service.startHeartbeat();

      // First heartbeat tick: sends ping, connection is still alive
      // (Date.now() - lastPong is 30_000, which is less than 30_000 + 10_000 = 40_000)
      vi.advanceTimersByTime(30_000);
      expect(service.getConnectionCount()).toBe(1);

      // Second heartbeat tick: no pong received, so Date.now() - lastPong = 60_000 > 40_000
      vi.advanceTimersByTime(30_000);
      expect(service.getConnectionCount()).toBe(0);
    });

    it('should be idempotent', () => {
      service.startHeartbeat();
      service.startHeartbeat();

      const ws = createMockWs();
      service.addConnection(1, 'client-a', ws as any);
      ws.send.mockClear();

      vi.advanceTimersByTime(30_000);

      const pingMessages = parseSendCalls(ws).filter((m) => m.type === 'ping');
      expect(pingMessages).toHaveLength(1);
    });
  });

  describe('closeAll', () => {
    it('should close all connections with code 1001', () => {
      const ws1 = createMockWs();
      const ws2 = createMockWs();
      service.addConnection(1, 'client-a', ws1 as any);
      service.addConnection(2, 'client-b', ws2 as any);

      expect(service.getConnectionCount()).toBe(2);

      service.closeAll();

      expect(ws1.close).toHaveBeenCalledWith(1001, 'Server shutting down');
      expect(ws2.close).toHaveBeenCalledWith(1001, 'Server shutting down');
      expect(service.getConnectionCount()).toBe(0);
    });
  });
});
