import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest';
import { fromPartial } from '@total-typescript/shoehorn';
import { NoiseCancellationStub } from './NoiseCancellationStub';
import { Call } from '../../Call';
import { StreamClient } from '../../coordinator/connection/client';
import { sleep } from '../../coordinator/connection/utils';
import {
  NoiseCancellationSettingsModeEnum,
  OwnCapability,
} from '../../gen/coordinator';
import {
  AudioBitrateProfile,
  TrackType,
} from '../../gen/video/sfu/models/models';
import { CallingState, StreamVideoWriteableStateStore } from '../../store';
import {
  createLocalStorageMock,
  emitDeviceIds,
  mockAudioDevices,
  mockAudioStream,
  mockBrowserPermission,
  mockCall,
  mockDeviceIds$,
} from './mocks';
import { createAudioStreamForDevice } from './mediaStreamTestHelpers';
import { setupAudioContextMock } from './web-audio.mocks';
import { getAudioStream } from '../devices';
import { MicrophoneManager } from '../MicrophoneManager';
import { of } from 'rxjs';
import {
  createSoundDetector,
  SoundStateChangeHandler,
} from '../../helpers/sound-detector';
import {
  createNoAudioDetector,
  NoAudioDetectorOptions,
} from '../../helpers/no-audio-detector';
import { PermissionsContext } from '../../permissions';
import { Tracer } from '../../stats';
import { settled, withoutConcurrency } from '../../helpers/concurrency';
import {
  defaultDeviceId,
  readPreferences,
  toPreferenceList,
} from '../devicePersistence';

vi.mock('../devices.ts', () => {
  console.log('MOCKING devices API');
  return {
    disposeOfMediaStream: vi.fn(),
    getAudioDevices: vi.fn(() => {
      return of(mockAudioDevices);
    }),
    getAudioStream: vi.fn(() => Promise.resolve(mockAudioStream())),
    getAudioBrowserPermission: () => mockBrowserPermission,
    getVideoBrowserPermission: () => mockBrowserPermission,
    deviceIds$: mockDeviceIds$(),
    resolveDeviceId: (deviceId) => deviceId,
  };
});

vi.mock('../../helpers/sound-detector.ts', () => {
  console.log('MOCKING sound detector');
  return {
    createSoundDetector: vi.fn(() => () => {}),
  };
});

vi.mock('../../helpers/no-audio-detector.ts', () => {
  console.log('MOCKING no-audio detector');
  return {
    createNoAudioDetector: vi.fn(() => async () => {}),
  };
});

vi.mock('../../Call.ts', () => {
  console.log('MOCKING Call');
  return {
    Call: vi.fn(function () {
      return mockCall();
    }),
  };
});

describe('MicrophoneManager', () => {
  let manager: MicrophoneManager;
  let call: Call;

  beforeEach(() => {
    setupAudioContextMock();
    vi.spyOn(mockBrowserPermission, 'asStateObservable').mockReturnValue(
      of('granted'),
    );

    call = new Call({
      id: '',
      type: '',
      streamClient: new StreamClient('abc123'),
      clientStore: new StreamVideoWriteableStateStore(),
    });
    const devicePersistence = { enabled: false, storageKey: '' };
    manager = new MicrophoneManager(call, devicePersistence, 'disable-tracks');
  });
  it('list devices', () => {
    const spy = vi.fn();
    manager.listDevices().subscribe(spy);

    expect(spy).toHaveBeenCalledWith(mockAudioDevices);
  });

  it('get stream', async () => {
    await manager.enable();

    expect(getAudioStream).toHaveBeenCalledWith(
      { deviceId: undefined },
      expect.any(Tracer),
    );
  });

  it('should get device id from stream', async () => {
    expect(manager.state.selectedDevice).toBeUndefined();

    await manager.enable();

    expect(manager.state.selectedDevice).toBeDefined();
  });

  it('publish stream', async () => {
    manager['call'].state.setCallingState(CallingState.JOINED);

    await manager.enable();

    expect(manager['call'].publish).toHaveBeenCalledWith(
      manager.state.mediaStream,
      TrackType.AUDIO,
      { audioBitrateProfile: AudioBitrateProfile.VOICE_STANDARD_UNSPECIFIED },
    );
  });

  it('stop publish stream', async () => {
    manager['call'].state.setCallingState(CallingState.JOINED);
    await manager.enable();

    await manager.disable();

    expect(manager['call'].stopPublish).toHaveBeenCalledWith(TrackType.AUDIO);
  });

  it('disable-enable mic should set track.enabled', async () => {
    await manager.enable();

    expect(manager.state.mediaStream!.getAudioTracks()[0].enabled).toBe(true);

    await manager.disable();

    expect(manager.state.mediaStream!.getAudioTracks()[0].enabled).toBe(false);
  });

  it('disable mic with forceStop should remove the stream', async () => {
    await manager.enable();

    expect(manager.state.mediaStream!.getAudioTracks()[0].enabled).toBe(true);

    await manager.disable();

    expect(manager.state.mediaStream!.getAudioTracks()[0].enabled).toBe(false);

    await manager.disable(true);

    expect(manager.state.mediaStream).toBeUndefined();
  });

  describe('Speaking While Muted', () => {
    it(`should start sound detection if mic is disabled`, async () => {
      await manager.enable();
      // @ts-expect-error private api
      const fn = vi.spyOn(manager, 'startSpeakingWhileMutedDetection');
      await manager.disable();

      await vi.waitUntil(() => fn.mock.calls.length > 0, { timeout: 100 });
      expect(fn).toHaveBeenCalled();
    });

    it('should not start sound detection if browser mic permission is denied', async () => {
      vi.spyOn(mockBrowserPermission, 'asStateObservable').mockReturnValue(
        of('denied'),
      );
      const devicePersistence = { enabled: false, storageKey: '' };
      const innerManager = new MicrophoneManager(
        call,
        devicePersistence,
        'disable-tracks',
      );
      // @ts-expect-error private api
      const fn = vi.spyOn(innerManager, 'startSpeakingWhileMutedDetection');

      await innerManager.enable();

      await sleep(25);
      expect(fn).not.toHaveBeenCalled();
    });

    it(`should stop sound detection if mic is enabled`, async () => {
      manager.state.setSpeakingWhileMuted(true);
      manager['soundDetectorCleanup'] = async () => {};

      await manager.enable();
      // @ts-expect-error private field
      const syncTag = manager.soundDetectorConcurrencyTag;
      await withoutConcurrency(syncTag, () => Promise.resolve());
      await settled(syncTag);

      await sleep(25);
      expect(manager.state.speakingWhileMuted).toBe(false);
    });

    it('should update speaking while muted state', async () => {
      const mock = createSoundDetector as Mock;
      let handler: SoundStateChangeHandler;
      const prevMockImplementation = mock.getMockImplementation();
      mock.mockImplementation((_: MediaStream, h: SoundStateChangeHandler) => {
        handler = h;
      });
      try {
        await manager['startSpeakingWhileMutedDetection']();

        expect(manager.state.speakingWhileMuted).toBe(false);

        handler!({ isSoundDetected: true, audioLevel: 2 });

        expect(manager.state.speakingWhileMuted).toBe(true);

        handler!({ isSoundDetected: false, audioLevel: 0 });

        expect(manager.state.speakingWhileMuted).toBe(false);
      } finally {
        mock.mockImplementation(prevMockImplementation!);
      }
    });

    it('should not create duplicate sound detectors for the same device', async () => {
      const detectorMock = vi.mocked(createSoundDetector);
      const cleanup = vi.fn(async () => {});
      detectorMock.mockImplementationOnce(() => cleanup);

      await manager['startSpeakingWhileMutedDetection']('device-1');
      await manager['startSpeakingWhileMutedDetection']('device-1');

      expect(detectorMock).toHaveBeenCalledTimes(1);

      await manager['stopSpeakingWhileMutedDetection']();
      expect(cleanup).toHaveBeenCalledTimes(1);
    });

    it('should cleanup previous detector before starting a new device detector', async () => {
      const detectorMock = vi.mocked(createSoundDetector);
      const cleanupFirst = vi.fn(async () => {});
      const cleanupSecond = vi.fn(async () => {});
      detectorMock
        .mockImplementationOnce(() => cleanupFirst)
        .mockImplementationOnce(() => cleanupSecond);

      await manager['startSpeakingWhileMutedDetection']('device-1');
      await manager['startSpeakingWhileMutedDetection']('device-2');

      expect(detectorMock).toHaveBeenCalledTimes(2);
      expect(cleanupFirst).toHaveBeenCalledTimes(1);

      await manager['stopSpeakingWhileMutedDetection']();
      expect(cleanupSecond).toHaveBeenCalledTimes(1);
    });

    // --- this ---
    it('should stop speaking while muted notifications if user loses permission to send audio', async () => {
      await manager.enable();
      await manager.disable();

      // @ts-expect-error private api
      const fn = vi.spyOn(manager, 'stopSpeakingWhileMutedDetection');
      manager['call'].state.setOwnCapabilities([]);

      await vi.waitUntil(() => fn.mock.calls.length > 0, { timeout: 100 });
      expect(fn).toHaveBeenCalled();
    });

    // --- this ---
    it('should start speaking while muted notifications if user gains permission to send audio', async () => {
      await manager.enable();
      await manager.disable();

      manager['call'].state.setOwnCapabilities([]);

      // @ts-expect-error private api
      const fn = vi.spyOn(manager, 'startSpeakingWhileMutedDetection');
      manager['call'].state.setOwnCapabilities([OwnCapability.SEND_AUDIO]);

      await vi.waitUntil(() => fn.mock.calls.length > 0, { timeout: 100 });
      expect(fn).toHaveBeenCalled();
    });

    it(`disables speaking while muted notifications`, async () => {
      // @ts-expect-error - private api
      vi.spyOn(manager, 'startSpeakingWhileMutedDetection');
      // @ts-expect-error - private api
      vi.spyOn(manager, 'stopSpeakingWhileMutedDetection');
      await manager.disableSpeakingWhileMutedNotification();
      await manager.disable();
      expect(manager['stopSpeakingWhileMutedDetection']).toHaveBeenCalled();
      expect(
        manager['startSpeakingWhileMutedDetection'],
      ).not.toHaveBeenCalled();
    });

    it(`enables speaking while muted notifications`, async () => {
      // @ts-expect-error - private api
      vi.spyOn(manager, 'startSpeakingWhileMutedDetection');
      await manager.enableSpeakingWhileMutedNotification();
      await manager.disable();
      expect(manager['startSpeakingWhileMutedDetection']).toHaveBeenCalled();
    });
  });

  describe('Noise Cancellation', () => {
    it('should register filter if all preconditions are met', async () => {
      call.state.setCallingState(CallingState.IDLE);
      const registerFilter = vi.spyOn(manager, 'registerFilter');
      const noiseCancellation = new NoiseCancellationStub();
      const noiseCancellationEnable = vi.spyOn(noiseCancellation, 'enable');
      await manager.enableNoiseCancellation(noiseCancellation);

      expect(registerFilter).toBeCalled();
      expect(noiseCancellationEnable).not.toBeCalled();
    });

    it('should unregister filter when disabling noise cancellation', async () => {
      const noiseCancellation = new NoiseCancellationStub();
      await manager.enableNoiseCancellation(noiseCancellation);
      await manager.disableNoiseCancellation();
      expect(call.notifyNoiseCancellationStopped).toBeCalled();
    });

    it('should throw when own capabilities are missing', async () => {
      call.state.setOwnCapabilities([]);

      await expect(() =>
        manager.enableNoiseCancellation(new NoiseCancellationStub()),
      ).rejects.toThrow();
    });

    it('should throw when noise cancellation is disabled in call settings', async () => {
      call.state.setOwnCapabilities([OwnCapability.ENABLE_NOISE_CANCELLATION]);
      call.state.updateFromCallResponse(
        fromPartial({
          egress: {},
          settings: {
            audio: {
              noise_cancellation: {
                mode: NoiseCancellationSettingsModeEnum.DISABLED,
              },
            },
          },
        }),
      );
      await expect(() =>
        manager.enableNoiseCancellation(new NoiseCancellationStub()),
      ).rejects.toThrow();
    });

    it('should automatically enable noise noise suppression after joining a call', async () => {
      call.state.setCallingState(CallingState.IDLE); // reset state
      call.state.updateFromCallResponse(
        fromPartial({
          egress: {},
          settings: {
            audio: {
              noise_cancellation: {
                mode: NoiseCancellationSettingsModeEnum.AUTO_ON,
              },
            },
          },
        }),
      );

      const noiseCancellation = new NoiseCancellationStub();
      const noiseCancellationEnable = vi.spyOn(noiseCancellation, 'enable');
      await manager.enableNoiseCancellation(noiseCancellation);

      expect(noiseCancellationEnable).not.toBeCalled();

      call.state.setCallingState(CallingState.JOINED);

      // it is quite hard to test the "detached" callingState$ subscription
      // with the current tools and architecture.
      // that is why we go with the good old sleep
      await sleep(25);

      expect(noiseCancellationEnable).toBeCalled();
      expect(call.notifyNoiseCancellationStarting).toBeCalled();
    });

    it('should automatically disable noise suppression after leaving the call', async () => {
      const noiseCancellation = new NoiseCancellationStub();
      const noiseSuppressionDisable = vi.spyOn(noiseCancellation, 'disable');
      await manager.enableNoiseCancellation(noiseCancellation);

      expect(noiseSuppressionDisable).not.toBeCalled();

      call.state.setCallingState(CallingState.LEFT);

      // it is quite hard to test the "detached" callingState$ subscription
      // with the current tools and architecture.
      // that is why we go with the good old sleep
      await sleep(25);

      expect(noiseSuppressionDisable).toBeCalled();
      expect(call.notifyNoiseCancellationStopped).toBeCalled();
    });
  });

  describe('Audio Settings', () => {
    beforeEach(() => {
      // @ts-expect-error - read only property
      call.permissionsContext = new PermissionsContext();
      call.permissionsContext.canPublish = vi.fn().mockReturnValue(true);
    });

    it('should apply defaults when mic_default_on is true and enabled is pristine', async () => {
      const enable = vi.spyOn(manager, 'enable');
      // @ts-expect-error - partial data
      await manager.apply({ mic_default_on: true }, true);
      expect(enable).toHaveBeenCalled();
    });

    it('should turn the mic on when set on dashboard', async () => {
      const enable = vi.spyOn(manager, 'enable');
      // @ts-expect-error - partial data
      await manager.apply({ mic_default_on: true }, true);
      expect(enable).toHaveBeenCalled();
    });

    it('should not turn the mic on when set on dashboard', async () => {
      const enable = vi.spyOn(manager, 'enable');
      // @ts-expect-error - partial data
      await manager.apply({ mic_default_on: false }, true);
      expect(enable).not.toHaveBeenCalled();
    });

    it('should not turn on the mic when permission is missing', async () => {
      call.permissionsContext.canPublish = vi.fn().mockReturnValue(false);
      const enable = vi.spyOn(manager, 'enable');
      // @ts-expect-error - partial data
      await manager.apply({ mic_default_on: true }, true);
      expect(enable).not.toHaveBeenCalled();
    });

    it('should publish the audio stream when mic is turned on before settings are applied', async () => {
      await manager.enable();
      // @ts-expect-error - private api
      vi.spyOn(manager, 'publishStream');
      // @ts-expect-error - partial data
      await manager.apply({ mic_default_on: true }, true);
      expect(manager['publishStream']).toHaveBeenCalled();
    });

    it('should skip defaults when preferences are applied', async () => {
      const devicePersistence = { enabled: true, storageKey: '' };
      const persistedManager = new MicrophoneManager(
        call,
        devicePersistence,
        'disable-tracks',
      );
      const applySpy = vi
        .spyOn(persistedManager as never, 'applyPersistedPreferences')
        .mockResolvedValue(true);
      const enableSpy = vi.spyOn(persistedManager, 'enable');

      // @ts-expect-error - partial data
      await persistedManager.apply({ mic_default_on: true }, true);

      expect(applySpy).toHaveBeenCalledWith(true);
      expect(enableSpy).not.toHaveBeenCalled();
    });

    it('should skip persisted preferences when permission is not granted', async () => {
      vi.spyOn(mockBrowserPermission, 'asStateObservable').mockReturnValue(
        of('prompt'),
      );
      const devicePersistence = { enabled: true, storageKey: '' };
      const persistedManager = new MicrophoneManager(
        call,
        devicePersistence,
        'disable-tracks',
      );
      const applySpy = vi.spyOn(
        persistedManager as never,
        'applyPersistedPreferences',
      );
      const enableSpy = vi.spyOn(persistedManager, 'enable');

      // @ts-expect-error - partial data
      await persistedManager.apply({ mic_default_on: true }, true);

      expect(applySpy).not.toHaveBeenCalled();
      expect(enableSpy).toHaveBeenCalled();
    });

    it('should not apply defaults when mic is not pristine', async () => {
      manager.state.setStatus('enabled');
      const applySpy = vi.spyOn(manager as never, 'applyPersistedPreferences');
      const enableSpy = vi.spyOn(manager, 'enable');

      // @ts-expect-error - partial data
      await manager.apply({ mic_default_on: true }, true);

      expect(applySpy).not.toHaveBeenCalled();
      expect(enableSpy).not.toHaveBeenCalled();
    });
  });

  describe('Hi-Fi Audio', () => {
    it('enables hi-fi audio', async () => {
      call.state.updateFromCallResponse(
        fromPartial({
          egress: {},
          settings: { audio: { hifi_audio_enabled: true } },
        }),
      );

      await manager.enable();

      // @ts-expect-error - private api
      const apply = vi.spyOn(manager, 'applySettingsToStream');
      const publish = vi.spyOn(call, 'publish');
      const profile = AudioBitrateProfile.MUSIC_HIGH_QUALITY;
      await manager.setAudioBitrateProfile(profile);

      expect(apply).toHaveBeenCalledOnce();
      expect(publish).toHaveBeenCalledWith(
        manager.state.mediaStream,
        TrackType.AUDIO,
        { audioBitrateProfile: profile },
      );
    });

    it('throws an error when enabling hi-fi audio if not allowed', async () => {
      call.state.updateFromCallResponse(
        fromPartial({
          egress: {},
          settings: { audio: { hifi_audio_enabled: false } },
        }),
      );

      await manager.enable();
      await expect(() =>
        manager.setAudioBitrateProfile(AudioBitrateProfile.VOICE_HIGH_QUALITY),
      ).rejects.toThrowError();
    });
  });

  describe('performTest', () => {
    it('should return true when microphone captures audio', async () => {
      const mock = vi.mocked(createNoAudioDetector);

      mock.mockImplementationOnce((_stream, options) => {
        // Simulate audio detected immediately
        setImmediate(() => options.onCaptureStatusChange(true));
        return async () => {};
      });

      const capturesAudio = await manager.performTest('test-device-id');
      expect(capturesAudio).toBe(true);
    });

    it('should return false when microphone does not capture audio', async () => {
      const mock = vi.mocked(createNoAudioDetector);

      mock.mockImplementationOnce((_stream, options) => {
        // Simulate no audio detected after test duration
        setImmediate(() => options.onCaptureStatusChange(false));
        return async () => {};
      });

      const capturesAudio = await manager.performTest('test-device-id');
      expect(capturesAudio).toBe(false);
    });

    it('should use custom testDurationMs when provided', async () => {
      const mock = vi.mocked(createNoAudioDetector);
      let capturedOptions: NoAudioDetectorOptions;

      mock.mockImplementationOnce((_stream, options) => {
        capturedOptions = options;
        setTimeout(() => options.onCaptureStatusChange(true), 50);
        return async () => {};
      });

      const customDuration = 5000;
      await manager.performTest('test-device-id', {
        testDurationMs: customDuration,
      });

      expect(capturedOptions.noAudioThresholdMs).toBe(customDuration);
      expect(capturedOptions.emitIntervalMs).toBe(customDuration);
    });

    it('should call getStream with exact deviceId', async () => {
      const mock = vi.mocked(createNoAudioDetector);
      mock.mockImplementationOnce((_stream, options) => {
        setTimeout(() => options.onCaptureStatusChange(true), 50);
        return async () => {};
      });

      const deviceId = 'specific-device-id';
      await manager.performTest(deviceId);

      expect(getAudioStream).toHaveBeenCalledWith(
        { deviceId: { exact: deviceId } },
        expect.any(Tracer),
      );
    });

    it('should cleanup detector and dispose stream after test completes', async () => {
      const mock = vi.mocked(createNoAudioDetector);
      const cleanupFn = vi.fn(async () => {});
      let onCaptureStatusChange: ((capturesAudio: boolean) => void) | undefined;

      mock.mockImplementationOnce((_stream, options) => {
        onCaptureStatusChange = options.onCaptureStatusChange;
        setTimeout(() => onCaptureStatusChange?.(true), 50);
        return cleanupFn;
      });

      await manager.performTest('test-device-id');

      // Wait for cleanup to be called
      await vi.waitFor(() => {
        expect(cleanupFn).toHaveBeenCalled();
      });
    });
  });

  describe('no-audio detector configuration', () => {
    it('applies silence threshold and emit interval in runtime monitoring', async () => {
      const noAudioDetector = vi.mocked(createNoAudioDetector);

      manager.setSilenceThreshold(3000);
      manager['call'].state.setCallingState(CallingState.JOINED);
      await manager.enable();

      await vi.waitFor(() => {
        expect(noAudioDetector).toHaveBeenCalled();
      });

      const options = noAudioDetector.mock.calls.at(-1)?.[1];
      expect(options).toMatchObject({
        noAudioThresholdMs: 3000,
        emitIntervalMs: 3000,
      });
    });
  });

  describe('Device Persistence Stress', () => {
    it('persists the final microphone and muted state after rapid toggles, switches, and unplug', async () => {
      const storageKey = '@test/device-preferences-microphone-stress';
      const localStorageMock = createLocalStorageMock();
      const originalWindow = globalThis.window;
      Object.defineProperty(globalThis, 'window', {
        configurable: true,
        value: { localStorage: localStorageMock },
      });

      const getAudioStreamMock = vi.mocked(getAudioStream);
      getAudioStreamMock.mockImplementation((constraints) => {
        const requestedDeviceId = (constraints?.deviceId as { exact?: string })
          ?.exact;
        const selectedDevice =
          mockAudioDevices.find((d) => d.deviceId === requestedDeviceId) ??
          mockAudioDevices[0];
        return Promise.resolve(
          createAudioStreamForDevice(
            selectedDevice.deviceId,
            selectedDevice.label,
          ),
        );
      });

      const stressManager = new MicrophoneManager(
        call,
        { enabled: true, storageKey },
        'disable-tracks',
      );

      try {
        const finalDevice = mockAudioDevices[2];
        emitDeviceIds(mockAudioDevices);

        await Promise.allSettled([
          stressManager.enable(),
          stressManager.select(mockAudioDevices[1].deviceId),
          stressManager.toggle(),
          stressManager.select(finalDevice.deviceId),
          stressManager.toggle(),
          stressManager.enable(),
        ]);
        await stressManager.statusChangeSettled();
        await stressManager.select(finalDevice.deviceId);
        await stressManager.enable();
        await stressManager.statusChangeSettled();

        expect(stressManager.state.selectedDevice).toBe(finalDevice.deviceId);
        expect(stressManager.state.status).toBe('enabled');

        const persistedBeforeUnplug = toPreferenceList(
          readPreferences(storageKey).microphone,
        );
        expect(persistedBeforeUnplug[0]).toEqual({
          selectedDeviceId: finalDevice.deviceId,
          selectedDeviceLabel: finalDevice.label,
          muted: false,
        });

        emitDeviceIds(
          mockAudioDevices.filter((d) => d.deviceId !== finalDevice.deviceId),
        );

        await vi.waitFor(() => {
          expect(stressManager.state.selectedDevice).toBe(undefined);
          expect(stressManager.state.status).toBe('disabled');
        });

        const persistedAfterUnplug = toPreferenceList(
          readPreferences(storageKey).microphone,
        );
        expect(persistedAfterUnplug[0]).toEqual({
          selectedDeviceId: defaultDeviceId,
          selectedDeviceLabel: '',
          muted: true,
        });
        expect(persistedAfterUnplug).toContainEqual({
          selectedDeviceId: finalDevice.deviceId,
          selectedDeviceLabel: finalDevice.label,
          muted: true,
        });
      } finally {
        stressManager.dispose();
        Object.defineProperty(globalThis, 'window', {
          configurable: true,
          value: originalWindow,
        });
      }
    });
  });

  afterEach(() => {
    vi.restoreAllMocks();
    vi.clearAllMocks();
    vi.resetModules();
  });
});
