/**
 * Heartbeat interval tests for useJobStatusWebSocket.
 *
 * Verifies that the client ping is sent every 20 000 ms (not 30 000 ms)
 * to stay under the Apache mod_proxy_wstunnel idle timeout.
 * Mod Comms incident 2026-03-18: 25 s was insufficient; 20 s is safe.
 */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { renderHook, act } from '@testing-library/react';

import { useJobStatusWebSocket } from '../useJobStatusWebSocket';

// ── Mocks ────────────────────────────────────────────────────────────────────

vi.mock('../../lib/auth', () => ({
  useAuthStore: () => ({ isAuthenticated: true }),
}));

vi.mock('../../lib/api', () => ({
  apiClient: { getAccessToken: () => 'test-token' },
}));

vi.mock('@tanstack/react-query', () => ({
  useQueryClient: () => ({
    invalidateQueries: vi.fn(),
  }),
}));

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

  readyState = MockWebSocket.CONNECTING;
  private handlers: Map<string, EventListener[]> = new Map();

  send = vi.fn();
  close = vi.fn(() => { this.readyState = MockWebSocket.CLOSED; });

  addEventListener(event: string, handler: EventListener) {
    if (!this.handlers.has(event)) this.handlers.set(event, []);
    this.handlers.get(event)!.push(handler);
  }

  removeEventListener(event: string, handler: EventListener) {
    const list = this.handlers.get(event) ?? [];
    this.handlers.set(event, list.filter(h => h !== handler));
  }

  fireOpen() {
    this.readyState = MockWebSocket.OPEN;
    this.handlers.get('open')?.forEach(h => h(new Event('open')));
  }

  fireClose(code: number, reason = '') {
    this.readyState = MockWebSocket.CLOSED;
    const event = new CloseEvent('close', { code, reason, wasClean: true });
    this.handlers.get('close')?.forEach(h => h(event));
  }

  fireMessage(data: string) {
    const event = new MessageEvent('message', { data });
    this.handlers.get('message')?.forEach(h => h(event));
  }
}

let mockWsInstance: MockWebSocket;

function makeWsConstructor() {
  const ctor = vi.fn(() => mockWsInstance) as unknown as typeof WebSocket;
  Object.assign(ctor, {
    CONNECTING: 0,
    OPEN: 1,
    CLOSING: 2,
    CLOSED: 3,
  });
  return ctor;
}

beforeEach(() => {
  vi.useFakeTimers();
  mockWsInstance = new MockWebSocket();
  vi.stubGlobal('WebSocket', makeWsConstructor());
});

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

// ── Heartbeat interval ────────────────────────────────────────────────────────

describe('heartbeat interval', () => {
  it('sends ping after exactly 20 000 ms', () => {
    const { unmount } = renderHook(() => useJobStatusWebSocket());

    act(() => { mockWsInstance.fireOpen(); });
    mockWsInstance.send.mockClear();

    act(() => { vi.advanceTimersByTime(20_000); });

    expect(mockWsInstance.send).toHaveBeenCalledWith('ping');
    expect(mockWsInstance.send).toHaveBeenCalledTimes(1);

    unmount();
  });

  it('sends second ping after 40 000 ms total', () => {
    const { unmount } = renderHook(() => useJobStatusWebSocket());

    act(() => { mockWsInstance.fireOpen(); });
    mockWsInstance.send.mockClear();

    act(() => { vi.advanceTimersByTime(40_000); });

    // Two pings: at t=20 000 ms and t=40 000 ms
    expect(mockWsInstance.send).toHaveBeenCalledWith('ping');
    expect(mockWsInstance.send).toHaveBeenCalledTimes(2);

    unmount();
  });

  it('does NOT send ping before 20 000 ms', () => {
    const { unmount } = renderHook(() => useJobStatusWebSocket());

    act(() => { mockWsInstance.fireOpen(); });
    mockWsInstance.send.mockClear();

    act(() => { vi.advanceTimersByTime(19_999); });

    expect(mockWsInstance.send).not.toHaveBeenCalled();

    unmount();
  });

  it('does not send ping when socket is not OPEN', () => {
    const { unmount } = renderHook(() => useJobStatusWebSocket());

    act(() => { mockWsInstance.fireOpen(); });
    mockWsInstance.send.mockClear();

    // Force socket into a non-OPEN state before interval fires
    mockWsInstance.readyState = MockWebSocket.CLOSING;

    act(() => { vi.advanceTimersByTime(20_000); });

    expect(mockWsInstance.send).not.toHaveBeenCalled();

    unmount();
  });

  it('interval is cleared on manual disconnect', () => {
    const clearIntervalSpy = vi.spyOn(globalThis, 'clearInterval');

    const { result, unmount } = renderHook(() => useJobStatusWebSocket());

    act(() => { mockWsInstance.fireOpen(); });
    const callsBefore = clearIntervalSpy.mock.calls.length;

    act(() => { result.current.disconnect(); });

    // clearInterval must have been called after disconnect
    expect(clearIntervalSpy.mock.calls.length).toBeGreaterThan(callsBefore);

    unmount();
  });

  it('interval is cleared after terminal close', () => {
    const clearIntervalSpy = vi.spyOn(globalThis, 'clearInterval');

    const { unmount } = renderHook(() => useJobStatusWebSocket());

    act(() => { mockWsInstance.fireOpen(); });
    const callsBefore = clearIntervalSpy.mock.calls.length;

    act(() => { mockWsInstance.fireClose(4403, 'Org denied'); });

    expect(clearIntervalSpy.mock.calls.length).toBeGreaterThan(callsBefore);

    unmount();
  });
});

// ── Keepalive / pong frames silently discarded ────────────────────────────────

describe('server heartbeat frames', () => {
  it('silently discards "keepalive" frame — lastMessage stays null', () => {
    const { result, unmount } = renderHook(() => useJobStatusWebSocket());

    act(() => { mockWsInstance.fireOpen(); });
    act(() => { mockWsInstance.fireMessage('keepalive'); });

    expect(result.current.lastMessage).toBeNull();
    expect(result.current.lastUpdate).toBeNull();

    unmount();
  });

  it('silently discards "pong" frame', () => {
    const { result, unmount } = renderHook(() => useJobStatusWebSocket());

    act(() => { mockWsInstance.fireOpen(); });
    act(() => { mockWsInstance.fireMessage('pong'); });

    expect(result.current.lastMessage).toBeNull();
    expect(result.current.lastUpdate).toBeNull();

    unmount();
  });

  it('processes JSON job_status_update message normally', () => {
    const { result, unmount } = renderHook(() => useJobStatusWebSocket());

    act(() => { mockWsInstance.fireOpen(); });

    const statusMsg = JSON.stringify({
      type: 'job_status_update',
      data: {
        job_id: 'job-1',
        status: 'pending_qc',
        updated_at: '2026-01-01T00:00:00Z',
      },
    });

    act(() => { mockWsInstance.fireMessage(statusMsg); });

    expect(result.current.lastMessage).not.toBeNull();
    expect(result.current.lastMessage?.type).toBe('job_status_update');
    expect(result.current.lastUpdate?.job_id).toBe('job-1');
    expect(result.current.lastUpdate?.status).toBe('pending_qc');

    unmount();
  });
});
