import {
  afterEach,
  beforeEach,
  describe,
  expect,
  it,
  vi
} from 'vitest';
import {
  SIGNALING_CONNECT_TIMEOUT_MS,
  SIGNALING_KEEPALIVE_ACK_TIMEOUT_MS,
  SIGNALING_KEEPALIVE_INTERVAL_MS,
  SIGNALING_RECONNECT_BASE_DELAY_MS,
  STATE_HEARTBEAT_INTERVAL_MS
} from '../realtime.constants';
import { WebRTCLogger } from '../logging/webrtc-logger';
import { SignalingManager } from './signaling.manager';
import { probeSignalingEndpointHealth } from './signaling-endpoint-health.rules';

vi.mock('./signaling-endpoint-health.rules', () => ({
  probeSignalingEndpointHealth: vi.fn(async () => ({ ok: true, serverInstanceId: 'instance-a' }))
}));

type MockSocketHandler = (event?: { code?: number; reason?: string; wasClean?: boolean }) => void;

class MockWebSocket {
  static readonly CONNECTING = 0;
  static readonly OPEN = 1;
  static readonly CLOSING = 2;
  static readonly CLOSED = 3;

  static instances: MockWebSocket[] = [];

  shouldFailSend = false;
  autoAckKeepalive = true;
  readonly url: string;
  readyState = MockWebSocket.CONNECTING;
  onopen: MockSocketHandler | null = null;
  onclose: MockSocketHandler | null = null;
  onerror: MockSocketHandler | null = null;
  onmessage: ((event: { data: string }) => void) | null = null;

  constructor(url: string) {
    this.url = url;
    MockWebSocket.instances.push(this);
  }

  close(): void {
    if (this.readyState === MockWebSocket.CLOSED) {
      return;
    }

    this.readyState = MockWebSocket.CLOSED;
    this.onclose?.({ code: 1000, reason: 'closed', wasClean: true });
  }

  send(payload: string): void {
    if (this.shouldFailSend) {
      throw new Error('mock send failure');
    }

    if (!this.autoAckKeepalive) {
      return;
    }

    try {
      const message = JSON.parse(payload) as { type?: string };

      if (message.type === 'keepalive') {
        this.onmessage?.({ data: JSON.stringify({ type: 'keepalive_ack' }) });
      }
    } catch {
      // ignore malformed payloads in tests
    }
  }

  simulateOpen(): void {
    this.readyState = MockWebSocket.OPEN;
    this.onopen?.();
  }

  simulateError(): void {
    this.onerror?.(new Event('error'));
    this.close();
  }
}

describe('SignalingManager reconnection', () => {
  const serverUrl = 'ws://localhost:3001';

  beforeEach(() => {
    vi.useFakeTimers();
    MockWebSocket.instances = [];
    vi.stubGlobal('WebSocket', MockWebSocket);
    vi.mocked(probeSignalingEndpointHealth).mockResolvedValue({ ok: true, serverInstanceId: 'instance-a' });
  });

  afterEach(() => {
    vi.unstubAllGlobals();
    vi.clearAllTimers();
    vi.useRealTimers();
  });

  function createManager(
    identifyCredentials: {
      oderId: string;
      displayName: string;
      description?: string;
      profileUpdatedAt?: number;
      homeSignalServerUrl?: string;
    } = {
      oderId: 'peer-a',
      token: 'session-token',
      displayName: 'Peer A',
      description: 'hello',
      profileUpdatedAt: 42,
      homeSignalServerUrl: 'https://signal.example'
    }
  ): SignalingManager {
    const sentMessages: Record<string, unknown>[] = [];
    const manager = new SignalingManager(
      new WebRTCLogger(false),
      () => identifyCredentials,
      () => ({ serverId: 'server-1', userId: 'peer-a' }),
      () => new Set(['server-1'])
    );
    const originalSendRawMessage = manager.sendRawMessage.bind(manager);

    manager.sendRawMessage = (message: Record<string, unknown>) => {
      sentMessages.push(message);
      originalSendRawMessage(message);
    };

    Object.assign(manager, { sentMessages });

    return manager;
  }

  it('schedules reconnect after an established socket closes', async () => {
    const manager = createManager();
    const connected = vi.fn();

    manager.connect(serverUrl).subscribe({ next: connected });
    MockWebSocket.instances[0]?.simulateOpen();
    expect(connected).toHaveBeenCalledWith(true);

    MockWebSocket.instances[0]?.close();

    vi.advanceTimersByTime(SIGNALING_RECONNECT_BASE_DELAY_MS);

    expect(MockWebSocket.instances).toHaveLength(2);
    expect(MockWebSocket.instances[1]?.readyState).toBe(MockWebSocket.CONNECTING);
  });

  it('keeps retrying after a failed reconnect attempt until the server accepts the socket', async () => {
    const manager = createManager();

    manager.connect(serverUrl).subscribe();
    MockWebSocket.instances[0]?.simulateOpen();
    MockWebSocket.instances[0]?.close();

    vi.advanceTimersByTime(SIGNALING_RECONNECT_BASE_DELAY_MS);
    MockWebSocket.instances[1]?.simulateError();

    vi.advanceTimersByTime(SIGNALING_RECONNECT_BASE_DELAY_MS * 2);
    expect(MockWebSocket.instances).toHaveLength(3);

    MockWebSocket.instances[2]?.simulateOpen();
    expect(manager.isSocketOpen()).toBe(true);
  });

  it('discards a stale connecting socket and retries after the connect timeout', async () => {
    const manager = createManager();

    manager.connect(serverUrl).subscribe();
    MockWebSocket.instances[0]?.simulateOpen();
    MockWebSocket.instances[0]?.close();

    vi.advanceTimersByTime(SIGNALING_RECONNECT_BASE_DELAY_MS);
    expect(MockWebSocket.instances).toHaveLength(2);

    vi.advanceTimersByTime(SIGNALING_CONNECT_TIMEOUT_MS);

    vi.advanceTimersByTime(SIGNALING_RECONNECT_BASE_DELAY_MS * 2);
    expect(MockWebSocket.instances.length).toBeGreaterThanOrEqual(3);
  });

  it('does not treat a timed-out waitForOpen result as a successful reconnect', async () => {
    const manager = createManager();

    manager.connect(serverUrl).subscribe();
    MockWebSocket.instances[0]?.simulateOpen();
    MockWebSocket.instances[0]?.close();

    vi.advanceTimersByTime(SIGNALING_RECONNECT_BASE_DELAY_MS);
    expect(MockWebSocket.instances).toHaveLength(2);

    const secondAttempt = vi.fn();

    manager.connect(serverUrl).subscribe({ next: secondAttempt });

    vi.advanceTimersByTime(SIGNALING_CONNECT_TIMEOUT_MS);
    expect(secondAttempt).toHaveBeenCalledWith(false);
    expect(manager.isSocketOpen()).toBe(false);

    vi.advanceTimersByTime(SIGNALING_RECONNECT_BASE_DELAY_MS * 2);
    expect(MockWebSocket.instances.length).toBeGreaterThanOrEqual(3);
  });

  it('replays the full identify payload after reconnect', () => {
    const manager = createManager() as SignalingManager & { sentMessages: Record<string, unknown>[] };

    manager.connect(serverUrl).subscribe();
    MockWebSocket.instances[0]?.simulateOpen();
    manager.sentMessages.length = 0;
    MockWebSocket.instances[0]?.close();

    vi.advanceTimersByTime(SIGNALING_RECONNECT_BASE_DELAY_MS);
    MockWebSocket.instances[1]?.simulateOpen();

    const identifyMessage = manager.sentMessages.find((message) => message['type'] === 'identify');

    expect(identifyMessage).toMatchObject({
      type: 'identify',
      token: 'session-token',
      oderId: 'peer-a',
      displayName: 'Peer A',
      description: 'hello',
      profileUpdatedAt: 42,
      homeSignalServerUrl: 'https://signal.example'
    });
  });

  it('does not force reconnect on servers that never send keepalive acknowledgements', () => {
    const manager = createManager();

    manager.connect(serverUrl).subscribe();
    const socket = MockWebSocket.instances[0];

    if (socket) {
      socket.autoAckKeepalive = false;
    }

    socket?.simulateOpen();

    vi.advanceTimersByTime(STATE_HEARTBEAT_INTERVAL_MS);
    vi.advanceTimersByTime(SIGNALING_KEEPALIVE_ACK_TIMEOUT_MS);

    expect(MockWebSocket.instances).toHaveLength(1);
    expect(manager.isSocketOpen()).toBe(true);
  });

  it('forces reconnect when keepalive acknowledgements stop arriving on ack-capable servers', () => {
    const manager = createManager();

    manager.connect(serverUrl).subscribe();
    const socket = MockWebSocket.instances[0];

    socket?.simulateOpen();
    vi.advanceTimersByTime(STATE_HEARTBEAT_INTERVAL_MS);

    if (socket) {
      socket.autoAckKeepalive = false;
    }

    vi.advanceTimersByTime(SIGNALING_KEEPALIVE_INTERVAL_MS + STATE_HEARTBEAT_INTERVAL_MS);
    vi.advanceTimersByTime(SIGNALING_KEEPALIVE_ACK_TIMEOUT_MS);
    vi.advanceTimersByTime(SIGNALING_RECONNECT_BASE_DELAY_MS);

    expect(MockWebSocket.instances.length).toBeGreaterThanOrEqual(2);
    expect(manager.isSocketOpen()).toBe(false);
  });

  it('silently drops transient negotiation messages while the socket is reconnecting', () => {
    const logger = new WebRTCLogger(false);
    const errorSpy = vi.spyOn(logger, 'error');
    const manager = new SignalingManager(
      logger,
      () => ({ oderId: 'peer-a', token: 'session-token', displayName: 'Peer A' }),
      () => ({ serverId: 'server-1', userId: 'peer-a' }),
      () => new Set(['server-1'])
    );

    expect(manager.sendRawMessage({
      type: 'ice_candidate',
      targetUserId: 'peer-b',
      payload: { candidate: { candidate: 'candidate:1' } }
    })).toBe(false);

    expect(errorSpy).not.toHaveBeenCalled();
  });

  it('forces reconnect when the signaling health probe fails on an open socket', async () => {
    vi.mocked(probeSignalingEndpointHealth).mockResolvedValue({ ok: false });
    const manager = createManager();

    manager.connect(serverUrl).subscribe();
    MockWebSocket.instances[0]?.simulateOpen();
    await Promise.resolve();
    vi.advanceTimersByTime(SIGNALING_RECONNECT_BASE_DELAY_MS);

    expect(MockWebSocket.instances.length).toBeGreaterThanOrEqual(2);
    expect(manager.isSocketOpen()).toBe(false);
  });

  it('forces reconnect when the signaling server instance changes under an open socket', async () => {
    const manager = createManager();

    manager.connect(serverUrl).subscribe();
    MockWebSocket.instances[0]?.simulateOpen();

    vi.advanceTimersByTime(STATE_HEARTBEAT_INTERVAL_MS);
    await Promise.resolve();

    vi.mocked(probeSignalingEndpointHealth).mockResolvedValueOnce({
      ok: true,
      serverInstanceId: 'instance-b'
    });

    vi.advanceTimersByTime(STATE_HEARTBEAT_INTERVAL_MS);
    await Promise.resolve();
    vi.advanceTimersByTime(SIGNALING_RECONNECT_BASE_DELAY_MS);

    expect(MockWebSocket.instances.length).toBeGreaterThanOrEqual(2);
    expect(manager.isSocketOpen()).toBe(false);
  });

  it('forces reconnect when outbound signaling sends fail on a stale socket', () => {
    const manager = createManager();

    manager.connect(serverUrl).subscribe();
    const socket = MockWebSocket.instances[0];

    socket?.simulateOpen();

    if (socket) {
      socket.shouldFailSend = true;
    }

    vi.advanceTimersByTime(SIGNALING_KEEPALIVE_INTERVAL_MS + STATE_HEARTBEAT_INTERVAL_MS);
    vi.advanceTimersByTime(SIGNALING_RECONNECT_BASE_DELAY_MS);

    expect(MockWebSocket.instances.length).toBeGreaterThanOrEqual(2);
    expect(manager.isSocketOpen()).toBe(false);
  });
});
