/**
 * Tests for useListSSE — D-11 bounded backoff + D-10 reconnect invalidation.
 *
 * Covers:
 *   - D-11: bounded exponential backoff (250→500→1000→2000→4000→cap 8000ms)
 *   - D-11: stop retrying after MAX_ATTEMPTS (≥6), transition to 'disconnected'
 *   - D-10: on successful open, reset attempt counter + invalidate ['list', listId]
 *   - on each list-change event, invalidate ['list', listId]
 *   - cleanup: closes EventSource and clears timers on unmount (no reconnect storm — Pitfall 3)
 *
 * Run: pnpm --filter @familysync/pwa exec vitest run src/hooks/useListSSE.test.ts
 */

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import type { ReactNode } from 'react';
import React from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

// ---------------------------------------------------------------------------
// Mock EventSource
//
// The browser EventSource is not available in jsdom. We provide a minimal mock
// that lets tests control open/error/message events synchronously.
// ---------------------------------------------------------------------------

type MockEventSourceInstance = {
  url: string;
  withCredentials: boolean;
  readyState: number;
  onopen: ((ev: Event) => void) | null;
  onerror: ((ev: Event) => void) | null;
  listeners: Map<string, Array<(ev: MessageEvent) => void>>;
  addEventListener: (type: string, handler: (ev: MessageEvent) => void) => void;
  close: () => void;
  // Test helpers — trigger events
  _triggerOpen: () => void;
  _triggerError: () => void;
  _triggerMessage: (type: string, data: unknown) => void;
};

let mockInstances: MockEventSourceInstance[] = [];

class MockEventSource {
  url: string;
  withCredentials: boolean;
  readyState: number = 0;
  onopen: ((ev: Event) => void) | null = null;
  onerror: ((ev: Event) => void) | null = null;
  listeners: Map<string, Array<(ev: MessageEvent) => void>> = new Map();
  closeCalled = false;

  constructor(url: string, init?: { withCredentials?: boolean }) {
    this.url = url;
    this.withCredentials = init?.withCredentials ?? false;
    mockInstances.push(this);
  }

  addEventListener(type: string, handler: (ev: MessageEvent) => void) {
    const existing = this.listeners.get(type) ?? [];
    this.listeners.set(type, [...existing, handler]);
  }

  close() {
    this.closeCalled = true;
    this.readyState = 2; // CLOSED
  }

  _triggerOpen() {
    this.readyState = 1; // OPEN
    this.onopen?.({} as Event);
  }

  _triggerError() {
    this.onerror?.({} as Event);
  }

  _triggerMessage(type: string, data: unknown) {
    const handlers = this.listeners.get(type) ?? [];
    const event = new MessageEvent(type, { data: JSON.stringify(data) });
    handlers.forEach((h) => h(event));
  }
}

// ---------------------------------------------------------------------------
// Test setup
// ---------------------------------------------------------------------------

function makeWrapper(queryClient: QueryClient) {
  return function Wrapper({ children }: { children: ReactNode }) {
    return React.createElement(QueryClientProvider, { client: queryClient }, children);
  };
}

beforeEach(() => {
  mockInstances = [];
  vi.useFakeTimers();
  // Replace global EventSource with mock
  vi.stubGlobal('EventSource', MockEventSource);
});

afterEach(() => {
  vi.useRealTimers();
  vi.unstubAllGlobals();
  mockInstances = [];
});

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

describe('useListSSE — D-11 bounded backoff', () => {
  it('reports connected and resets attempt counter when EventSource fires open', async () => {
    const { useListSSE } = await import('./useListSSE.js');
    const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
    const stateChanges: string[] = [];

    const { unmount } = renderHook(
      () => useListSSE({ listId: 1, onStateChange: (s) => stateChanges.push(s) }),
      { wrapper: makeWrapper(queryClient) },
    );

    // Trigger open on the created EventSource
    const es = mockInstances[0] as unknown as { _triggerOpen: () => void };
    act(() => {
      es._triggerOpen();
    });

    expect(stateChanges).toContain('connected');
    unmount();
  });

  it('invalidates [list, listId] query on successful open (D-10 full refetch on reconnect)', async () => {
    const { useListSSE } = await import('./useListSSE.js');
    const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
    const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');

    const { unmount } = renderHook(() => useListSSE({ listId: 42, onStateChange: vi.fn() }), {
      wrapper: makeWrapper(queryClient),
    });

    act(() => {
      const es = mockInstances[0] as unknown as { _triggerOpen: () => void };
      es._triggerOpen();
    });

    expect(invalidateSpy).toHaveBeenCalledWith(expect.objectContaining({ queryKey: ['list', 42] }));
    unmount();
  });

  it('invalidates [list, listId] when a list-change SSE event is received', async () => {
    const { useListSSE } = await import('./useListSSE.js');
    const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
    const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');

    const { unmount } = renderHook(() => useListSSE({ listId: 7, onStateChange: vi.fn() }), {
      wrapper: makeWrapper(queryClient),
    });

    act(() => {
      const es = mockInstances[0] as unknown as {
        _triggerOpen: () => void;
        _triggerMessage: (type: string, data: unknown) => void;
      };
      es._triggerOpen();
      es._triggerMessage('item:added', { type: 'item:added', listId: 7 });
    });

    const calls = invalidateSpy.mock.calls;
    const hasListQuery = calls.some((args) => {
      const opts = args[0] as { queryKey?: unknown[] };
      return JSON.stringify(opts?.queryKey) === JSON.stringify(['list', 7]);
    });
    expect(hasListQuery).toBe(true);
    unmount();
  });

  it('transitions to reconnecting on first error and schedules retry after 250ms (D-11 step 0)', async () => {
    const { useListSSE } = await import('./useListSSE.js');
    const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
    const stateChanges: string[] = [];

    const { unmount } = renderHook(
      () => useListSSE({ listId: 1, onStateChange: (s) => stateChanges.push(s) }),
      { wrapper: makeWrapper(queryClient) },
    );

    act(() => {
      mockInstances[0]._triggerError();
    });

    // Should be reconnecting (not disconnected — still have attempts left)
    expect(stateChanges).toContain('reconnecting');

    // Advance 250ms — a new EventSource should be created
    act(() => {
      vi.advanceTimersByTime(250);
    });

    expect(mockInstances.length).toBeGreaterThanOrEqual(2); // reconnected
    unmount();
  });

  it('stops retrying after MAX_ATTEMPTS and transitions to disconnected (D-11 backoff exhausted)', async () => {
    const { useListSSE } = await import('./useListSSE.js');
    const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
    const stateChanges: string[] = [];

    renderHook(() => useListSSE({ listId: 1, onStateChange: (s) => stateChanges.push(s) }), {
      wrapper: makeWrapper(queryClient),
    });

    // Exhaust all 6 backoff steps: 250→500→1000→2000→4000→8000ms
    const backoffSteps = [250, 500, 1000, 2000, 4000, 8000];

    for (const delay of backoffSteps) {
      // Trigger error on the latest EventSource instance
      act(() => {
        const es = mockInstances[mockInstances.length - 1];
        es._triggerError();
      });
      // Advance to the next backoff delay to allow next connection attempt
      act(() => {
        vi.advanceTimersByTime(delay);
      });
    }

    // After exhausting all attempts, trigger error on last instance
    act(() => {
      const es = mockInstances[mockInstances.length - 1];
      es._triggerError();
    });

    // State must be 'disconnected' — no more retries
    const lastState = stateChanges[stateChanges.length - 1];
    expect(lastState).toBe('disconnected');

    // No additional timer scheduled — advancing time should not create a new instance
    const instanceCount = mockInstances.length;
    act(() => {
      vi.advanceTimersByTime(30_000);
    });
    expect(mockInstances.length).toBe(instanceCount); // no new connection attempt
  });

  it('resets backoff counter on successful reconnect (D-11 — counter reset)', async () => {
    const { useListSSE } = await import('./useListSSE.js');
    const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
    const stateChanges: string[] = [];

    renderHook(() => useListSSE({ listId: 1, onStateChange: (s) => stateChanges.push(s) }), {
      wrapper: makeWrapper(queryClient),
    });

    // Fail once
    act(() => {
      mockInstances[0]._triggerError();
    });
    act(() => {
      vi.advanceTimersByTime(250); // first backoff
    });

    // Reconnect successfully
    act(() => {
      const latest = mockInstances[mockInstances.length - 1];
      latest._triggerOpen();
    });

    expect(stateChanges).toContain('connected');

    // The attempt counter should be reset. Fail again — should reconnect, not give up.
    const statesBefore = stateChanges.length;
    act(() => {
      const latest = mockInstances[mockInstances.length - 1];
      latest._triggerError();
    });

    // Should be reconnecting again (counter reset → still has attempts)
    const newStates = stateChanges.slice(statesBefore);
    expect(newStates).toContain('reconnecting');
  });

  it('closes EventSource and clears timers on unmount (no reconnect storm — Pitfall 3)', async () => {
    const { useListSSE } = await import('./useListSSE.js');
    const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });

    const { unmount } = renderHook(() => useListSSE({ listId: 1, onStateChange: vi.fn() }), {
      wrapper: makeWrapper(queryClient),
    });

    const firstEs = mockInstances[0];

    // Trigger error to schedule a reconnect timer
    act(() => {
      firstEs._triggerError();
    });

    // Unmount — should cancel the pending timer and close the EventSource
    unmount();

    const instanceCountAfterUnmount = mockInstances.length;

    // Advance well past any backoff timer — no new instances should be created
    act(() => {
      vi.advanceTimersByTime(30_000);
    });

    expect(mockInstances.length).toBe(instanceCountAfterUnmount); // no new connection post-unmount
    expect((firstEs as unknown as { closeCalled?: boolean }).closeCalled).toBeTruthy();
  });

  it('uses withCredentials: true on EventSource (Pitfall 7 — session cookie)', async () => {
    const { useListSSE } = await import('./useListSSE.js');
    const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });

    const { unmount } = renderHook(() => useListSSE({ listId: 1, onStateChange: vi.fn() }), {
      wrapper: makeWrapper(queryClient),
    });

    const es = mockInstances[0];
    expect(es.withCredentials).toBe(true);
    unmount();
  });
});
