import { DATA_CHANNEL_RECOVERY_GRACE_MS, DATA_CHANNEL_STATE_OPEN } from '../../realtime.constants';
import type { PeerData } from '../../realtime.types';
import {
  createPeerConnectionManagerState,
  PeerConnectionManagerContext,
  RecoveryHandlers
} from '../shared';
import { scheduleDataChannelRecovery } from './peer-recovery';

describe('peer recovery', () => {
  afterEach(() => {
    vi.clearAllTimers();
    vi.useRealTimers();
  });

  it('recreates a peer immediately when the data channel is already closed', () => {
    vi.useFakeTimers();

    const channel = createDataChannel('closed');
    const context = createContext('alice');
    const handlers = createRecoveryHandlers(context);

    context.state.activePeerConnections.set('bob', createPeerData(channel, 'connected'));

    scheduleDataChannelRecovery(context, 'bob', channel, 'close', handlers);

    expect(handlers.removePeer).toHaveBeenCalledWith('bob', { preserveReconnectState: true });
    expect(handlers.createPeerConnection).toHaveBeenCalledWith('bob', true);
    expect(handlers.createAndSendOffer).toHaveBeenCalledWith('bob');
    expect(context.state.dataChannelRecoveryTimers.has('bob')).toBe(false);
  });

  it('waits a short grace period before recreating a peer with a closing data channel', () => {
    vi.useFakeTimers();

    const channel = createDataChannel('closing');
    const context = createContext('alice');
    const handlers = createRecoveryHandlers(context);

    context.state.activePeerConnections.set('bob', createPeerData(channel, 'connected'));

    scheduleDataChannelRecovery(context, 'bob', channel, 'close', handlers);

    vi.advanceTimersByTime(DATA_CHANNEL_RECOVERY_GRACE_MS - 1);
    expect(handlers.removePeer).not.toHaveBeenCalled();
    expect(handlers.createPeerConnection).not.toHaveBeenCalled();

    vi.advanceTimersByTime(1);

    expect(handlers.removePeer).toHaveBeenCalledWith('bob', { preserveReconnectState: true });
    expect(handlers.createPeerConnection).toHaveBeenCalledWith('bob', true);
    expect(handlers.createAndSendOffer).toHaveBeenCalledWith('bob');
  });

  it('does not recreate a peer when a replacement data channel is adopted before the grace expires', () => {
    vi.useFakeTimers();

    const staleChannel = createDataChannel('closing');
    const replacementChannel = createDataChannel(DATA_CHANNEL_STATE_OPEN);
    const context = createContext('alice');
    const handlers = createRecoveryHandlers(context);

    context.state.activePeerConnections.set('bob', createPeerData(staleChannel, 'connected'));

    scheduleDataChannelRecovery(context, 'bob', staleChannel, 'close', handlers);
    context.state.activePeerConnections.set('bob', createPeerData(replacementChannel, 'connected'));

    vi.advanceTimersByTime(DATA_CHANNEL_RECOVERY_GRACE_MS);

    expect(handlers.removePeer).not.toHaveBeenCalled();
    expect(handlers.replaceDataChannel).not.toHaveBeenCalled();
    expect(handlers.createPeerConnection).not.toHaveBeenCalled();
  });

  it('does not schedule recovery for an open data channel', () => {
    vi.useFakeTimers();

    const channel = createDataChannel(DATA_CHANNEL_STATE_OPEN);
    const context = createContext('alice');
    const handlers = createRecoveryHandlers(context);

    context.state.activePeerConnections.set('bob', createPeerData(channel, 'connected'));

    scheduleDataChannelRecovery(context, 'bob', channel, 'error', handlers);
    vi.advanceTimersByTime(DATA_CHANNEL_RECOVERY_GRACE_MS);

    expect(handlers.removePeer).not.toHaveBeenCalled();
    expect(handlers.replaceDataChannel).not.toHaveBeenCalled();
    expect(handlers.createPeerConnection).not.toHaveBeenCalled();
  });

  it('recreates a connected non-initiator peer and waits for the remote initiator offer', () => {
    vi.useFakeTimers();

    const channel = createDataChannel('closed');
    const context = createContext('zoe');
    const handlers = createRecoveryHandlers(context);

    context.state.activePeerConnections.set('bob', createPeerData(channel, 'connected', false));
    scheduleDataChannelRecovery(context, 'bob', channel, 'close', handlers);

    expect(handlers.removePeer).toHaveBeenCalledWith('bob', { preserveReconnectState: true });
    expect(handlers.replaceDataChannel).not.toHaveBeenCalled();
    expect(handlers.createPeerConnection).toHaveBeenCalledWith('bob', false);
    expect(handlers.createAndSendOffer).not.toHaveBeenCalled();
  });

  it('waits for the remote initiator when a non-connected peer needs full reconnect', () => {
    vi.useFakeTimers();

    const channel = createDataChannel('closed');
    const context = createContext('zoe');
    const handlers = createRecoveryHandlers(context);

    context.state.activePeerConnections.set('bob', createPeerData(channel, 'disconnected'));

    scheduleDataChannelRecovery(context, 'bob', channel, 'close', handlers);
    vi.advanceTimersByTime(DATA_CHANNEL_RECOVERY_GRACE_MS);

    expect(handlers.createPeerConnection).toHaveBeenCalledWith('bob', false);
    expect(handlers.createAndSendOffer).not.toHaveBeenCalled();
  });
});

function createContext(localOderId: string): PeerConnectionManagerContext {
  return {
    logger: {
      error: vi.fn(),
      info: vi.fn(),
      logStream: vi.fn(),
      traffic: vi.fn(),
      warn: vi.fn()
    } as unknown as PeerConnectionManagerContext['logger'],
    callbacks: {
      getIceServers: vi.fn(() => []),
      getIdentifyCredentials: vi.fn(() => ({ oderId: localOderId, token: 'session-token', displayName: localOderId })),
      getLocalMediaStream: vi.fn(() => null),
      getLocalPeerId: vi.fn(() => localOderId),
      getVoiceStateSnapshot: vi.fn(() => ({
        isConnected: true,
        isMuted: false,
        isDeafened: false,
        isScreenSharing: false,
        roomId: 'voice-room',
        serverId: 'server-1'
      })),
      isCameraEnabled: vi.fn(() => false),
      isScreenSharingActive: vi.fn(() => false),
      isSignalingConnected: vi.fn(() => true),
      sendRawMessage: vi.fn()
    },
    state: createPeerConnectionManagerState()
  };
}

function createRecoveryHandlers(context: PeerConnectionManagerContext): RecoveryHandlers & {
  createAndSendOffer: ReturnType<typeof vi.fn>;
  createPeerConnection: ReturnType<typeof vi.fn>;
  removePeer: ReturnType<typeof vi.fn>;
  replaceDataChannel: ReturnType<typeof vi.fn>;
} {
  return {
    createAndSendOffer: vi.fn(async () => undefined),
    createPeerConnection: vi.fn((peerId: string, isInitiator: boolean) => {
      const peerData = createPeerData(createDataChannel(isInitiator ? 'connecting' : 'closed'), 'new', isInitiator);

      context.state.activePeerConnections.set(peerId, peerData);
      return peerData;
    }),
    removePeer: vi.fn((peerId: string) => {
      context.state.activePeerConnections.delete(peerId);
    }),
    replaceDataChannel: vi.fn((peerId: string, expectedChannel: RTCDataChannel) => {
      const peerData = context.state.activePeerConnections.get(peerId);

      if (!peerData || peerData.dataChannel !== expectedChannel) {
        return false;
      }

      peerData.dataChannel = createDataChannel('connecting');
      return true;
    })
  };
}

function createPeerData(
  dataChannel: RTCDataChannel,
  connectionState: RTCPeerConnectionState,
  isInitiator = true
): PeerData {
  return {
    audioSender: undefined,
    connection: {
      close: vi.fn(),
      connectionState
    } as unknown as RTCPeerConnection,
    createdAt: Date.now(),
    dataChannel,
    isInitiator,
    pendingIceCandidates: [],
    remoteCameraStreamIds: new Set<string>(),
    remoteScreenShareStreamIds: new Set<string>(),
    remoteVoiceStreamIds: new Set<string>(),
    videoSender: undefined
  };
}

function createDataChannel(readyState: RTCDataChannelState): RTCDataChannel {
  return {
    bufferedAmount: 0,
    label: 'chat',
    readyState
  } as unknown as RTCDataChannel;
}
