import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { GatewayClient } from '../gateway';

/* ------------------------------------------------------------------ */
/*  Minimal WebSocket mock                                             */
/* ------------------------------------------------------------------ */

type WSListener = (ev: { data: string } | { code: number; reason: string } | Event) => void;

class MockWebSocket {
  static OPEN = 1;
  static CLOSED = 3;
  static instances: MockWebSocket[] = [];

  readyState = MockWebSocket.OPEN;
  onopen: WSListener | null = null;
  onmessage: WSListener | null = null;
  onclose: WSListener | null = null;
  onerror: WSListener | null = null;
  sent: string[] = [];
  url: string;

  constructor(url: string) {
    this.url = url;
    MockWebSocket.instances.push(this);
    // Simulate async open
    setTimeout(() => this.onopen?.({} as Event), 0);
  }

  send(data: string) { this.sent.push(data); }

  close() {
    this.readyState = MockWebSocket.CLOSED;
    this.onclose?.({ code: 1000, reason: 'normal' } as never);
  }

  /** Helper: simulate server sending a message */
  _receive(data: unknown) {
    this.onmessage?.({ data: JSON.stringify(data) } as never);
  }

  static reset() { MockWebSocket.instances = []; }
}

// Patch global
const originalWS = globalThis.WebSocket;

beforeEach(() => {
  MockWebSocket.reset();
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  (globalThis as any).WebSocket = MockWebSocket;
  vi.useFakeTimers();
});

afterEach(() => {
  // Close any lingering mock WebSockets to prevent leaked timers
  for (const ws of MockWebSocket.instances) {
    if (ws.readyState !== MockWebSocket.CLOSED) {
      ws.readyState = MockWebSocket.CLOSED;
    }
  }
  vi.clearAllTimers();
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  (globalThis as any).WebSocket = originalWS;
  vi.useRealTimers();
});

/* ------------------------------------------------------------------ */
/*  Tests                                                              */
/* ------------------------------------------------------------------ */

describe('GatewayClient', () => {
  it('initialises with default URL when none provided', () => {
    // GatewayClient falls back to window.location.hostname — mock it for Node env
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    (globalThis as any).window = { location: { hostname: 'localhost' } };
    const gw = new GatewayClient();
    expect(gw.isConnected).toBe(false);
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    delete (globalThis as any).window;
  });

  it('connects and handles challenge → connect flow', async () => {
    const gw = new GatewayClient('ws://test:1234', 'tok123');
    const statuses: string[] = [];
    gw.onStatus(s => statuses.push(s));

    gw.connect();
    expect(statuses).toContain('connecting');

    // Let the setTimeout in MockWebSocket fire (onopen)
    await vi.advanceTimersByTimeAsync(1);

    const ws = MockWebSocket.instances[0]!;

    // Server sends challenge
    ws._receive({ type: 'event', event: 'connect.challenge' });

    // Client should have sent a connect request
    expect(ws.sent.length).toBe(1);
    const req = JSON.parse(ws.sent[0]!);
    expect(req.method).toBe('connect');
    expect(req.params.auth.token).toBe('tok123');

    // Server responds ok
    ws._receive({ type: 'res', id: req.id, ok: true, payload: { session: 'abc' } });

    // Allow microtasks
    await vi.advanceTimersByTimeAsync(0);

    expect(gw.isConnected).toBe(true);
    expect(statuses).toContain('connected');
  });

  it('disconnects cleanly', async () => {
    const gw = new GatewayClient('ws://test:1234', 'tok');
    const statuses: string[] = [];
    gw.onStatus(s => statuses.push(s));

    gw.connect();
    await vi.advanceTimersByTimeAsync(1);

    gw.disconnect();
    expect(gw.isConnected).toBe(false);
    expect(statuses[statuses.length - 1]).toBe('disconnected');
  });

  it('routes events to registered handlers', async () => {
    const gw = new GatewayClient('ws://test:1234', 'tok');
    const events: Array<{ event: string; payload: Record<string, unknown> }> = [];
    gw.onEvent((event, payload) => events.push({ event, payload }));

    gw.connect();
    await vi.advanceTimersByTimeAsync(1);

    const ws = MockWebSocket.instances[0]!;
    ws._receive({ type: 'event', event: 'chat.message', payload: { text: 'hello' } });

    expect(events).toHaveLength(1);
    expect(events[0]!.event).toBe('chat.message');
    expect(events[0]!.payload.text).toBe('hello');
  });

  it('unsubscribes event handler when disposer is called', async () => {
    const gw = new GatewayClient('ws://test:1234', 'tok');
    const events: string[] = [];
    const unsub = gw.onEvent((event) => events.push(event));

    gw.connect();
    await vi.advanceTimersByTimeAsync(1);

    const ws = MockWebSocket.instances[0]!;
    ws._receive({ type: 'event', event: 'first' });
    unsub();
    ws._receive({ type: 'event', event: 'second' });

    expect(events).toEqual(['first']);
  });

  it('resolves send() promise on success response', async () => {
    const gw = new GatewayClient('ws://test:1234', 'tok');
    gw.connect();
    await vi.advanceTimersByTimeAsync(1);

    // Complete the challenge first
    const ws = MockWebSocket.instances[0]!;
    ws._receive({ type: 'event', event: 'connect.challenge' });
    const connectReq = JSON.parse(ws.sent[0]!);
    ws._receive({ type: 'res', id: connectReq.id, ok: true, payload: {} });
    await vi.advanceTimersByTimeAsync(0);

    const promise = gw.send('sessions.list', { limit: 10 });
    const sendReq = JSON.parse(ws.sent[1]!);
    ws._receive({ type: 'res', id: sendReq.id, ok: true, payload: { sessions: [] } });

    const result = await promise;
    expect(result).toEqual({ sessions: [] });
  });

  it('rejects send() promise on error response', async () => {
    const gw = new GatewayClient('ws://test:1234', 'tok');
    gw.connect();
    await vi.advanceTimersByTimeAsync(1);

    const ws = MockWebSocket.instances[0]!;
    ws._receive({ type: 'event', event: 'connect.challenge' });
    const connectReq = JSON.parse(ws.sent[0]!);
    ws._receive({ type: 'res', id: connectReq.id, ok: true, payload: {} });
    await vi.advanceTimersByTimeAsync(0);

    const promise = gw.send('bad.method', {});
    const sendReq = JSON.parse(ws.sent[1]!);
    ws._receive({ type: 'res', id: sendReq.id, ok: false, error: 'not found' });

    await expect(promise).rejects.toBe('not found');
  });

  it('rejects send() when not connected', async () => {
    const gw = new GatewayClient('ws://test:1234', 'tok');
    await expect(gw.send('foo', {})).rejects.toThrow('not connected');
  });

  it('times out pending requests after 30s', async () => {
    const gw = new GatewayClient('ws://test:1234', 'tok');
    gw.connect();
    await vi.advanceTimersByTimeAsync(1);

    const ws = MockWebSocket.instances[0]!;
    ws._receive({ type: 'event', event: 'connect.challenge' });
    const connectReq = JSON.parse(ws.sent[0]!);
    ws._receive({ type: 'res', id: connectReq.id, ok: true, payload: {} });
    await vi.advanceTimersByTimeAsync(0);

    const promise = gw.send('slow.method', {});

    // Attach rejection handler BEFORE advancing timers to avoid unhandled rejection
    const rejection = promise.catch((e: Error) => e);

    // Advance past the 30s timeout
    await vi.advanceTimersByTimeAsync(31000);

    const error = await rejection;
    expect(error).toBeInstanceOf(Error);
    expect((error as Error).message).toBe('timeout');

    // Clean up: disconnect to prevent reconnect timers from firing after test
    gw.disconnect();
  });

  it('schedules reconnect on unexpected close', async () => {
    const gw = new GatewayClient('ws://test:1234', 'tok');
    const statuses: string[] = [];
    gw.onStatus(s => statuses.push(s));

    gw.connect();
    await vi.advanceTimersByTimeAsync(1);

    const ws = MockWebSocket.instances[0]!;
    // Simulate unexpected close (not from disconnect())
    ws.readyState = MockWebSocket.CLOSED;
    ws.onclose?.({ code: 1006, reason: 'abnormal' } as never);

    expect(statuses).toContain('disconnected');

    // After reconnect delay, a new WebSocket should be created
    await vi.advanceTimersByTimeAsync(2000);
    expect(MockWebSocket.instances.length).toBeGreaterThan(1);

    // Clean up to prevent leaked timers
    gw.disconnect();
  });

  it('does not reconnect after explicit disconnect()', async () => {
    const gw = new GatewayClient('ws://test:1234', 'tok');
    gw.connect();
    await vi.advanceTimersByTimeAsync(1);

    gw.disconnect();

    await vi.advanceTimersByTimeAsync(60000);
    // Only the original WebSocket should exist
    expect(MockWebSocket.instances).toHaveLength(1);
  });

  it('setCredentials updates URL and token', () => {
    const gw = new GatewayClient('ws://old:1234', 'old-tok');
    gw.setCredentials('ws://new:5678', 'new-tok');

    // Connect with new credentials
    gw.connect();
    const ws = MockWebSocket.instances[0]!;
    expect(ws.url).toBe('ws://new:5678');
  });
});
