import {
  ClientInfo_Capability,
  JoinResponse,
  StreamState as ProtoStreamState,
  StreamStateUpdate,
  SubscriptionError,
  SubscriptionResponse,
  TrackInfo,
  TrackType,
} from '@livekit/protocol';
import { afterEach, describe, expect, it, vi } from 'vitest';
import MockMediaStreamTrack from '../test/MockMediaStreamTrack';
import Room, { ConnectionState } from './Room';
import { roomConnectOptionDefaults, roomOptionDefaults } from './defaults';
import { EngineEvent, ParticipantEvent, RoomEvent } from './events';
import RemoteParticipant from './participant/RemoteParticipant';
import RemoteTrackPublication from './track/RemoteTrackPublication';
import RemoteVideoTrack from './track/RemoteVideoTrack';
import { Track } from './track/Track';

describe('Active device switch', () => {
  it('updates devices correctly', async () => {
    const room = new Room();
    await room.switchActiveDevice('audioinput', 'test');
    expect(room.getActiveDevice('audioinput')).toBe('test');
  });
  it('updates devices with exact constraint', async () => {
    const room = new Room();
    await room.switchActiveDevice('audioinput', 'test', true);
    expect(room.getActiveDevice('audioinput')).toBe('test');
  });
  it('emits changed event', async () => {
    const room = new Room();
    let kind: MediaDeviceKind | undefined;
    let deviceId: string | undefined;
    const deviceChangeHandler = (_kind: MediaDeviceKind, _deviceId: string) => {
      kind = _kind;
      deviceId = _deviceId;
    };
    room.on(RoomEvent.ActiveDeviceChanged, deviceChangeHandler);
    await room.switchActiveDevice('audioinput', 'test', true);

    expect(deviceId).toBe('test');
    expect(kind).toBe('audioinput');
  });
});

describe('Room signaling options', () => {
  it('advertises packet trailer capability when E2EE can handle trailers', async () => {
    const room = new Room();
    const join = vi.fn().mockResolvedValue({
      joinResponse: new JoinResponse({
        room: { name: 'test-room', sid: 'room-sid' },
        participant: { sid: 'participant-sid', identity: 'test-user' },
      }),
      serverInfo: { version: '1.0.0' },
    });
    const engine = { join };

    (
      room as unknown as {
        e2eeManager: unknown;
        connectSignal: (
          url: string,
          token: string,
          engine: unknown,
          connectOptions: typeof roomConnectOptionDefaults,
          roomOptions: typeof roomOptionDefaults,
          abortController: AbortController,
        ) => Promise<JoinResponse>;
      }
    ).e2eeManager = {};

    await (
      room as unknown as {
        connectSignal: (
          url: string,
          token: string,
          engine: unknown,
          connectOptions: typeof roomConnectOptionDefaults,
          roomOptions: typeof roomOptionDefaults,
          abortController: AbortController,
        ) => Promise<JoinResponse>;
      }
    ).connectSignal(
      'wss://test.livekit.io',
      'test-token',
      engine,
      roomConnectOptionDefaults,
      roomOptionDefaults,
      new AbortController(),
    );

    expect(join).toHaveBeenCalledWith(
      'wss://test.livekit.io',
      'test-token',
      expect.objectContaining({
        clientInfoCapabilities: [
          ClientInfo_Capability.CAP_PACKET_TRAILER,
          ClientInfo_Capability.CAP_COMPRESSION_DEFLATE_RAW,
        ],
        e2eeEnabled: true,
      }),
      expect.any(AbortSignal),
      false,
    );
  });
});

describe('Room lifecycle', () => {
  afterEach(() => {
    vi.restoreAllMocks();
    // Tear down the mocked mediaDevices so other tests see the env they
    // expected (happy-dom does not provide navigator.mediaDevices by default).
    if ((navigator as { mediaDevices?: unknown }).mediaDevices) {
      Object.defineProperty(navigator, 'mediaDevices', {
        configurable: true,
        value: undefined,
      });
    }
  });

  it('wraps the constructor-registered devicechange listener in a WeakRef so the Room is GC-eligible (#1940)', async () => {
    // happy-dom does not provide navigator.mediaDevices. Install a minimal
    // EventTarget stand-in so the constructor takes the listener-registration
    // branch and we can observe the registered listener.
    const mediaDevices = new EventTarget() as EventTarget & {
      addEventListener: EventTarget['addEventListener'];
      removeEventListener: EventTarget['removeEventListener'];
    };
    Object.defineProperty(navigator, 'mediaDevices', {
      configurable: true,
      value: mediaDevices,
    });

    const addSpy = vi.spyOn(mediaDevices, 'addEventListener');
    const derefSpy = vi.spyOn(WeakRef.prototype, 'deref');
    const cleanupRegistrySpy = Room.cleanupRegistry
      ? vi.spyOn(Room.cleanupRegistry, 'register')
      : undefined;

    const room = new Room();
    const handleDeviceChangeSpy = vi.spyOn(
      room as unknown as { handleDeviceChange: (ev: Event) => void },
      'handleDeviceChange',
    );

    // Constructor must register exactly one devicechange listener with AbortSignal teardown.
    const deviceChangeAdds = addSpy.mock.calls.filter(([type]) => type === 'devicechange');
    expect(deviceChangeAdds).toHaveLength(1);
    const listener = deviceChangeAdds[0][1] as EventListener;
    const addOptions = deviceChangeAdds[0][2] as AddEventListenerOptions | undefined;
    expect(addOptions?.signal).toBeInstanceOf(AbortSignal);

    // FinalizationRegistry must be registered with the Room as the target so the
    // cleanup callback fires when the user drops their Room reference.
    if (Room.cleanupRegistry) {
      expect(cleanupRegistrySpy).toHaveBeenCalledWith(room, expect.any(Function));
    }

    // While the WeakRef still derefs to the Room, the listener forwards to handleDeviceChange.
    listener.call(null, new Event('devicechange'));
    expect(handleDeviceChangeSpy).toHaveBeenCalledTimes(1);

    // Simulate the Room being GC'd by forcing deref to return undefined; the
    // listener must short-circuit instead of calling handleDeviceChange.
    derefSpy.mockReturnValue(undefined);
    listener.call(null, new Event('devicechange'));
    expect(handleDeviceChangeSpy).toHaveBeenCalledTimes(1);
  });

  it('falls back to a direct devicechange listener when WeakRef/FinalizationRegistry are unavailable (#1944)', async () => {
    const mediaDevices = new EventTarget() as EventTarget & {
      addEventListener: EventTarget['addEventListener'];
      removeEventListener: EventTarget['removeEventListener'];
    };
    Object.defineProperty(navigator, 'mediaDevices', {
      configurable: true,
      value: mediaDevices,
    });

    // Simulate a legacy browser by stubbing out cleanupRegistry.
    const originalRegistry = Room.cleanupRegistry;
    Object.defineProperty(Room, 'cleanupRegistry', {
      configurable: true,
      value: false,
    });

    try {
      const addSpy = vi.spyOn(mediaDevices, 'addEventListener');
      const room = new Room();
      const handleDeviceChange = (room as unknown as { handleDeviceChange: () => void })
        .handleDeviceChange;

      const deviceChangeAdds = addSpy.mock.calls.filter(([type]) => type === 'devicechange');
      expect(deviceChangeAdds).toHaveLength(1);
      // The registered listener is the bare handleDeviceChange method (no WeakRef closure).
      expect(deviceChangeAdds[0][1]).toBe(handleDeviceChange);
    } finally {
      Object.defineProperty(Room, 'cleanupRegistry', {
        configurable: true,
        value: originalRegistry,
      });
    }
  });
});

describe('remote track subscription', () => {
  it('does not replay a deferred ontrack after the subscription failed', () => {
    const room = new Room();
    room.state = ConnectionState.Connecting;

    const trackSid = 'TR_h264';
    const mediaTrack = { id: trackSid } as MediaStreamTrack;
    const stream = {
      id: `PA_remote|${trackSid}`,
      getTracks: () => [mediaTrack],
    } as unknown as MediaStream;
    const receiver = {} as RTCRtpReceiver;

    room.engine.emit(EngineEvent.MediaTrackAdded, mediaTrack, stream, receiver);

    const replay = vi.spyOn(
      room as unknown as {
        onTrackAdded(
          mediaTrack: MediaStreamTrack,
          stream: MediaStream,
          receiver: RTCRtpReceiver,
        ): void;
      },
      'onTrackAdded',
    );

    room.engine.emit(
      EngineEvent.SubscriptionError,
      new SubscriptionResponse({
        trackSid,
        err: SubscriptionError.SE_CODEC_UNSUPPORTED,
      }),
    );

    room.state = ConnectionState.Connected;
    room.emit(RoomEvent.Connected);

    expect(replay).not.toHaveBeenCalled();
  });
});

describe('stream state updates', () => {
  const participantSid = 'PA_remote';
  const participantIdentity = 'remote-user';
  const trackSid = 'TR_video';

  function setupSubscribedTrack() {
    const room = new Room();
    room.state = ConnectionState.Connected;

    const participant = new RemoteParticipant(
      room.engine.client,
      participantSid,
      participantIdentity,
    );
    const publication = new RemoteTrackPublication(
      Track.Kind.Video,
      new TrackInfo({ sid: trackSid, type: TrackType.VIDEO, name: 'camera' }),
      true,
    );
    publication.setTrack(
      new RemoteVideoTrack(new MockMediaStreamTrack(), trackSid, undefined as never, {}),
    );
    participant.trackPublications.set(trackSid, publication);

    (
      room as unknown as { remoteParticipants: Map<string, RemoteParticipant> }
    ).remoteParticipants.set(participantIdentity, participant);
    (room as unknown as { sidToIdentity: Map<string, string> }).sidToIdentity.set(
      participantSid,
      participantIdentity,
    );

    return { room, participant, publication };
  }

  function pushStreamState(room: Room, state: ProtoStreamState) {
    (
      room as unknown as { handleStreamStateUpdate: (update: StreamStateUpdate) => void }
    ).handleStreamStateUpdate(
      new StreamStateUpdate({
        streamStates: [{ participantSid, trackSid, state }],
      }),
    );
  }

  it('emits TrackStreamStateChanged when the SFU pauses a subscribed track', () => {
    const { room, participant, publication } = setupSubscribedTrack();

    const roomEvents = vi.fn();
    const participantEvents = vi.fn();
    room.on(RoomEvent.TrackStreamStateChanged, roomEvents);
    participant.on(ParticipantEvent.TrackStreamStateChanged, participantEvents);

    pushStreamState(room, ProtoStreamState.PAUSED);

    expect(publication.track?.streamState).toBe(Track.StreamState.Paused);
    expect(roomEvents).toHaveBeenCalledWith(publication, Track.StreamState.Paused, participant);
    expect(participantEvents).toHaveBeenCalledWith(publication, Track.StreamState.Paused);
  });

  it('does not emit TrackStreamStateChanged when the state is unchanged', () => {
    const { room, participant } = setupSubscribedTrack();

    const roomEvents = vi.fn();
    const participantEvents = vi.fn();
    room.on(RoomEvent.TrackStreamStateChanged, roomEvents);
    participant.on(ParticipantEvent.TrackStreamStateChanged, participantEvents);

    // The track starts Active; a redundant ACTIVE update must stay silent.
    pushStreamState(room, ProtoStreamState.ACTIVE);

    expect(roomEvents).not.toHaveBeenCalled();
    expect(participantEvents).not.toHaveBeenCalled();
  });
});
