import { describe, expect, it, vi } from 'vitest';
import type { QueryClient, QueryKey } from '@tanstack/react-query';

import {
  subscribeRealtimeInvalidations,
  type EventMap,
  type SocketLike,
} from '@/hooks/realtime-invalidation-core';

/**
 * Pure-logic tests for the realtime-invalidation subscription helper. The
 * React hook (`useRealtimeInvalidation`) is just a thin wrapper around this
 * function - verifying the handler-registration / fire-time-lookup behavior
 * here is sufficient to lock in the bug fixes:
 *   1. Re-subscribe storm (caller passing inline literals)
 *   2. Fresh queryKeys read at fire-time
 *
 * The `useSocket` provider fix (don't null-context on disconnect) is verified
 * separately by manual smoke + the existing socket integration coverage.
 */

function makeStubSocket() {
  const listeners = new Map<string, Array<(...args: unknown[]) => void>>();
  const onCalls: Array<{ event: string }> = [];
  const offCalls: Array<{ event: string }> = [];

  const socket: SocketLike = {
    on(event, handler) {
      onCalls.push({ event });
      const arr = listeners.get(event) ?? [];
      arr.push(handler);
      listeners.set(event, arr);
    },
    off(event, handler) {
      offCalls.push({ event });
      const arr = listeners.get(event) ?? [];
      listeners.set(
        event,
        arr.filter((h) => h !== handler),
      );
    },
  };

  function emit(event: string, ...args: unknown[]) {
    for (const h of listeners.get(event) ?? []) h(...args);
  }

  return { socket, emit, onCalls, offCalls, listeners };
}

function makeStubQueryClient() {
  const calls: QueryKey[] = [];
  const queryClient = {
    invalidateQueries: vi.fn(({ queryKey }: { queryKey: QueryKey }) => {
      calls.push(queryKey);
      return Promise.resolve();
    }),
  } as unknown as QueryClient;
  return { queryClient, calls };
}

describe('subscribeRealtimeInvalidations', () => {
  it('registers one .on() per event key', () => {
    const { socket, onCalls } = makeStubSocket();
    const { queryClient } = makeStubQueryClient();
    const map: EventMap = {
      'client:created': [['clients']],
      'client:updated': [['clients'], ['clients', 'abc']],
    };

    subscribeRealtimeInvalidations(socket, Object.keys(map), queryClient, () => map);

    expect(onCalls.map((c) => c.event).sort()).toEqual(['client:created', 'client:updated']);
  });

  it('invalidates each queryKey for the matching event', () => {
    const { socket, emit } = makeStubSocket();
    const { queryClient, calls } = makeStubQueryClient();
    const map: EventMap = {
      'client:updated': [['clients'], ['clients', 'abc']],
    };

    subscribeRealtimeInvalidations(socket, Object.keys(map), queryClient, () => map);
    emit('client:updated');

    expect(calls).toEqual([['clients'], ['clients', 'abc']]);
  });

  it('reads the LATEST eventMap at fire time, not at subscription time', () => {
    // This is the core of the re-subscribe-storm fix: callers can swap in a
    // new eventMap object without re-subscribing, and the handler still sees
    // the fresh queryKey list.
    const { socket, emit } = makeStubSocket();
    const { queryClient, calls } = makeStubQueryClient();

    let currentMap: EventMap = {
      'client:updated': [['clients']],
    };
    subscribeRealtimeInvalidations(socket, ['client:updated'], queryClient, () => currentMap);

    // First fire: see the original map
    emit('client:updated');
    expect(calls).toEqual([['clients']]);

    // Caller re-renders with a fresh literal that includes more queryKeys
    currentMap = {
      'client:updated': [['clients'], ['clients', 'abc']],
    };
    emit('client:updated');

    expect(calls).toEqual([['clients'], ['clients'], ['clients', 'abc']]);
  });

  it('cleanup deregisters every handler it registered', () => {
    const { socket, emit, offCalls, listeners } = makeStubSocket();
    const { queryClient, calls } = makeStubQueryClient();
    const map: EventMap = {
      'a:event': [['a']],
      'b:event': [['b']],
    };

    const cleanup = subscribeRealtimeInvalidations(
      socket,
      Object.keys(map),
      queryClient,
      () => map,
    );

    cleanup();

    expect(offCalls.map((c) => c.event).sort()).toEqual(['a:event', 'b:event']);
    // All listeners removed - emitting after cleanup invalidates nothing.
    emit('a:event');
    emit('b:event');
    expect(calls).toEqual([]);
    // Defensive: the listener list should be empty after cleanup.
    expect(listeners.get('a:event')?.length ?? 0).toBe(0);
    expect(listeners.get('b:event')?.length ?? 0).toBe(0);
  });

  it('silently ignores events that have no entry in the current map', () => {
    // If the caller swaps an event OUT mid-session, the registered handler
    // still fires (we don't re-subscribe) but should be a no-op rather than
    // throw.
    const { socket, emit } = makeStubSocket();
    const { queryClient, calls } = makeStubQueryClient();

    let currentMap: EventMap = {
      'client:updated': [['clients']],
    };
    subscribeRealtimeInvalidations(socket, ['client:updated'], queryClient, () => currentMap);

    // Wipe the entry - handler will fire but find nothing to invalidate.
    currentMap = {};
    expect(() => emit('client:updated')).not.toThrow();
    expect(calls).toEqual([]);
  });
});
