/**
 * @vitest-environment happy-dom
 */

import '../../rtc/__tests__/mocks/webrtc.mocks';

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { AudioBindingsWatchdog } from '../AudioBindingsWatchdog';
import { Call } from '../../Call';
import { StreamClient } from '../../coordinator/connection/client';
import { CallingState, StreamVideoWriteableStateStore } from '../../store';
import { noopComparator } from '../../sorting';
import { fromPartial } from '@total-typescript/shoehorn';
import { TrackType } from '../../gen/video/sfu/models/models';

describe('AudioBindingsWatchdog', () => {
  let watchdog: AudioBindingsWatchdog;
  let call: Call;

  beforeEach(() => {
    vi.useFakeTimers();
    call = new Call({
      id: 'id',
      type: 'default',
      streamClient: new StreamClient('api-key', {
        devicePersistence: { enabled: false },
      }),
      clientStore: new StreamVideoWriteableStateStore(),
    });
    call.setSortParticipantsBy(noopComparator());
    watchdog = new AudioBindingsWatchdog(call.state, call.tracer);
  });

  afterEach(() => {
    watchdog.dispose();
    call.leave();
    vi.useRealTimers();
  });

  const addRemoteParticipant = (
    sessionId: string,
    userId: string,
    streams?: {
      audioStream?: MediaStream;
      screenShareAudioStream?: MediaStream;
    },
  ) => {
    const publishedTracks = [];
    if (streams?.audioStream) publishedTracks.push(TrackType.AUDIO);
    if (streams?.screenShareAudioStream) {
      publishedTracks.push(TrackType.SCREEN_SHARE_AUDIO);
    }
    call.state.updateOrAddParticipant(
      sessionId,
      fromPartial({
        userId,
        sessionId,
        publishedTracks,
        ...streams,
      }),
    );
  };

  it('should warn about dangling audio streams when active', () => {
    // @ts-expect-error private property
    const warnSpy = vi.spyOn(watchdog.logger, 'warn');

    addRemoteParticipant('session-1', 'user-1', {
      audioStream: new MediaStream(),
    });

    call.state.setCallingState(CallingState.JOINED);
    vi.advanceTimersByTime(3000);

    expect(warnSpy).toHaveBeenCalledWith(
      expect.stringContaining('Dangling audio bindings detected'),
    );
    expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('user-1'));
  });

  it('should not warn when all audio elements are bound', () => {
    // @ts-expect-error private property
    const warnSpy = vi.spyOn(watchdog.logger, 'warn');

    addRemoteParticipant('session-1', 'user-1', {
      audioStream: new MediaStream(),
    });
    watchdog.register(
      document.createElement('audio'),
      'session-1',
      'audioTrack',
    );

    call.state.setCallingState(CallingState.JOINED);
    vi.advanceTimersByTime(3000);

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

  it('should skip local participant', () => {
    // @ts-expect-error private property
    const warnSpy = vi.spyOn(watchdog.logger, 'warn');

    // @ts-expect-error incomplete data
    call.state.updateOrAddParticipant('local-session', {
      userId: 'local-user',
      sessionId: 'local-session',
      isLocalParticipant: true,
      publishedTracks: [],
      audioStream: new MediaStream(),
    });

    call.state.setCallingState(CallingState.JOINED);
    vi.advanceTimersByTime(3000);

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

  it('should start on JOINED and stop on non-JOINED state', () => {
    // @ts-expect-error private property
    const warnSpy = vi.spyOn(watchdog.logger, 'warn');

    addRemoteParticipant('session-1', 'user-1', {
      audioStream: new MediaStream(),
    });

    call.state.setCallingState(CallingState.JOINED);
    vi.advanceTimersByTime(3000);
    expect(warnSpy).toHaveBeenCalled();

    warnSpy.mockClear();

    call.state.setCallingState(CallingState.IDLE);
    vi.advanceTimersByTime(6000);

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

  it('should be disableable via setEnabled', () => {
    // @ts-expect-error private property
    const warnSpy = vi.spyOn(watchdog.logger, 'warn');

    addRemoteParticipant('session-1', 'user-1', {
      audioStream: new MediaStream(),
    });

    watchdog.setEnabled(false);

    call.state.setCallingState(CallingState.JOINED);
    vi.advanceTimersByTime(6000);

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

  it('should re-enable after disabling', () => {
    // @ts-expect-error private property
    const warnSpy = vi.spyOn(watchdog.logger, 'warn');

    addRemoteParticipant('session-1', 'user-1', {
      audioStream: new MediaStream(),
    });

    watchdog.setEnabled(false);
    watchdog.setEnabled(true);

    vi.advanceTimersByTime(3000);

    expect(warnSpy).toHaveBeenCalledWith(
      expect.stringContaining('Dangling audio bindings detected'),
    );
  });

  it('should warn when binding a different element to the same key', () => {
    // @ts-expect-error private property
    const warnSpy = vi.spyOn(watchdog.logger, 'warn');

    const audioElement1 = document.createElement('audio');
    const audioElement2 = document.createElement('audio');

    watchdog.register(audioElement1, 'session-1', 'audioTrack');
    watchdog.register(audioElement2, 'session-1', 'audioTrack');

    expect(warnSpy).toHaveBeenCalledWith(
      expect.stringContaining('Audio element already bound'),
    );
  });

  it('should not warn when re-binding the same element', () => {
    // @ts-expect-error private property
    const warnSpy = vi.spyOn(watchdog.logger, 'warn');

    const audioElement = document.createElement('audio');

    watchdog.register(audioElement, 'session-1', 'audioTrack');
    watchdog.register(audioElement, 'session-1', 'audioTrack');

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

  it('unregisterBinding should remove the binding', () => {
    // @ts-expect-error private property
    const warnSpy = vi.spyOn(watchdog.logger, 'warn');

    addRemoteParticipant('session-1', 'user-1', {
      audioStream: new MediaStream(),
    });
    watchdog.register(
      document.createElement('audio'),
      'session-1',
      'audioTrack',
    );

    call.state.setCallingState(CallingState.JOINED);
    vi.advanceTimersByTime(3000);
    expect(warnSpy).not.toHaveBeenCalled();

    watchdog.unregister('session-1', 'audioTrack');
    vi.advanceTimersByTime(3000);

    expect(warnSpy).toHaveBeenCalledWith(
      expect.stringContaining('Dangling audio bindings detected'),
    );
  });

  it('should warn about dangling screenShareAudioStream', () => {
    // @ts-expect-error private property
    const warnSpy = vi.spyOn(watchdog.logger, 'warn');

    addRemoteParticipant('session-1', 'user-1', {
      screenShareAudioStream: new MediaStream(),
    });

    call.state.setCallingState(CallingState.JOINED);
    vi.advanceTimersByTime(3000);

    expect(warnSpy).toHaveBeenCalledWith(
      expect.stringContaining('Dangling audio bindings detected'),
    );
    expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('user-1'));
  });

  it('should not warn when audioStream exists but audio is not published', () => {
    // @ts-expect-error private property
    const warnSpy = vi.spyOn(watchdog.logger, 'warn');

    call.state.updateOrAddParticipant(
      'session-1',
      fromPartial({
        userId: 'user-1',
        sessionId: 'session-1',
        publishedTracks: [],
        audioStream: new MediaStream(),
      }),
    );

    call.state.setCallingState(CallingState.JOINED);
    vi.advanceTimersByTime(3000);

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

  it('should not warn when screenShareAudio element is bound', () => {
    // @ts-expect-error private property
    const warnSpy = vi.spyOn(watchdog.logger, 'warn');

    addRemoteParticipant('session-1', 'user-1', {
      screenShareAudioStream: new MediaStream(),
    });
    watchdog.register(
      document.createElement('audio'),
      'session-1',
      'screenShareAudioTrack',
    );

    call.state.setCallingState(CallingState.JOINED);
    vi.advanceTimersByTime(3000);

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

  it('should warn only about the unbound track type', () => {
    // @ts-expect-error private property
    const warnSpy = vi.spyOn(watchdog.logger, 'warn');

    addRemoteParticipant('session-1', 'user-1', {
      audioStream: new MediaStream(),
      screenShareAudioStream: new MediaStream(),
    });

    // bind only the regular audio track
    watchdog.register(
      document.createElement('audio'),
      'session-1',
      'audioTrack',
    );

    call.state.setCallingState(CallingState.JOINED);
    vi.advanceTimersByTime(3000);

    // should still warn because screenShareAudio is unbound
    expect(warnSpy).toHaveBeenCalledWith(
      expect.stringContaining('Dangling audio bindings detected'),
    );
  });

  it('should not warn when both audio and screenShareAudio are bound', () => {
    // @ts-expect-error private property
    const warnSpy = vi.spyOn(watchdog.logger, 'warn');

    addRemoteParticipant('session-1', 'user-1', {
      audioStream: new MediaStream(),
      screenShareAudioStream: new MediaStream(),
    });

    watchdog.register(
      document.createElement('audio'),
      'session-1',
      'audioTrack',
    );
    watchdog.register(
      document.createElement('audio'),
      'session-1',
      'screenShareAudioTrack',
    );

    call.state.setCallingState(CallingState.JOINED);
    vi.advanceTimersByTime(3000);

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

  it('dispose should stop the watchdog', () => {
    // @ts-expect-error private property
    const warnSpy = vi.spyOn(watchdog.logger, 'warn');

    addRemoteParticipant('session-1', 'user-1', {
      audioStream: new MediaStream(),
    });

    call.state.setCallingState(CallingState.JOINED);
    vi.advanceTimersByTime(3000);
    expect(warnSpy).toHaveBeenCalled();

    warnSpy.mockClear();

    watchdog.dispose();
    vi.advanceTimersByTime(6000);

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