import {
  Injector,
  runInInjectionContext,
  signal,
  ɵChangeDetectionScheduler as ChangeDetectionScheduler,
  ɵEffectScheduler as EffectScheduler
} from '@angular/core';
import { Store } from '@ngrx/store';
import { Subject } from 'rxjs';
import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors';
import { ScreenShareFacade } from '../../../screen-share';
import { VoiceConnectionFacade } from '../facades/voice-connection.facade';
import { VoicePlaybackService } from './voice-playback.service';

let audioContextCount = 0;

describe('VoicePlaybackService', () => {
  beforeEach(() => {
    audioContextCount = 0;
    installAudioDomMocks();
    installLocalStorageMock();
  });

  it('creates one audio pipeline per peer on first connect', () => {
    const context = createServiceContext({ isVoiceConnected: true });
    const stream = createMockAudioStream(['track-a']);

    context.service.handleRemoteStream('peer-1', stream, connectedOptions());

    expect(audioContextCount).toBe(1);
  });

  it('reuses the existing pipeline when the same voice stream is handled again', () => {
    const context = createServiceContext({ isVoiceConnected: true });
    const stream = createMockAudioStream(['track-a']);

    context.service.handleRemoteStream('peer-1', stream, connectedOptions());
    context.service.handleRemoteStream('peer-1', stream, connectedOptions());

    expect(audioContextCount).toBe(1);
  });

  it('reuses the pipeline when only the MediaStream wrapper changes but live audio tracks are unchanged', () => {
    const context = createServiceContext({ isVoiceConnected: true });
    const track = createMockAudioTrack('track-a');
    const firstStream = createMockAudioStreamFromTracks([track]);
    const secondStream = createMockAudioStreamFromTracks([track]);

    context.service.handleRemoteStream('peer-1', firstStream, connectedOptions());
    context.service.handleRemoteStream('peer-1', secondStream, connectedOptions());

    expect(audioContextCount).toBe(1);
  });

  it('rebuilds the pipeline when the live audio track set changes', () => {
    const context = createServiceContext({ isVoiceConnected: true });
    const firstStream = createMockAudioStream(['track-a']);
    const secondStream = createMockAudioStream(['track-b']);

    context.service.handleRemoteStream('peer-1', firstStream, connectedOptions());
    context.service.handleRemoteStream('peer-1', secondStream, connectedOptions());

    expect(audioContextCount).toBe(2);
  });

  it('does not recreate pipelines for unrelated remote stream notifications', () => {
    const context = createServiceContext({ isVoiceConnected: true });
    const voiceStream = createMockAudioStream(['track-a']);

    context.voiceConnection.getRemoteVoiceStream.mockReturnValue(voiceStream);
    context.service.handleRemoteStream('peer-1', voiceStream, connectedOptions());

    context.remoteStream$.next({ peerId: 'peer-1', stream: createMockAudioStream(['screen-audio']) });

    expect(audioContextCount).toBe(1);
  });

  it('creates pipelines for each peer when multiple friends join voice', () => {
    const context = createServiceContext({ isVoiceConnected: true });

    context.service.handleRemoteStream('peer-1', createMockAudioStream(['track-1']), connectedOptions());
    context.service.handleRemoteStream('peer-2', createMockAudioStream(['track-2']), connectedOptions());

    expect(audioContextCount).toBe(2);
  });

  it('still applies updated playback options when reusing an existing pipeline', () => {
    const context = createServiceContext({ isVoiceConnected: true });
    const stream = createMockAudioStream(['track-a']);

    context.service.handleRemoteStream('peer-1', stream, connectedOptions({ outputVolume: 1 }));
    context.service.handleRemoteStream('peer-1', stream, connectedOptions({ outputVolume: 0.5, isDeafened: true }));

    expect(audioContextCount).toBe(1);
    expect(context.service.getUserVolume('peer-1')).toBe(100);
  });
});

interface ServiceContext {
  service: VoicePlaybackService;
  voiceConnection: {
    isVoiceConnected: ReturnType<typeof signal<boolean>>;
    onRemoteStream: Subject<{ peerId: string; stream: MediaStream }>;
    onVoiceConnected: Subject<void>;
    onPeerDisconnected: Subject<string>;
    getRemoteVoiceStream: ReturnType<typeof vi.fn>;
    getConnectedPeers: ReturnType<typeof vi.fn>;
    syncOutgoingVoiceRouting: ReturnType<typeof vi.fn>;
  };
  remoteStream$: Subject<{ peerId: string; stream: MediaStream }>;
}

function createServiceContext(options: { isVoiceConnected?: boolean } = {}): ServiceContext {
  const remoteStream$ = new Subject<{ peerId: string; stream: MediaStream }>();
  const voiceConnection = {
    isVoiceConnected: signal(options.isVoiceConnected ?? false),
    onRemoteStream: remoteStream$,
    onVoiceConnected: new Subject<void>(),
    onPeerDisconnected: new Subject<string>(),
    getRemoteVoiceStream: vi.fn(() => null),
    getConnectedPeers: vi.fn(() => []),
    syncOutgoingVoiceRouting: vi.fn()
  };
  const screenShare = {
    isScreenShareRemotePlaybackSuppressed: signal(false),
    forceDefaultRemotePlaybackOutput: signal(false)
  };
  const store = {
    selectSignal: vi.fn((selector: unknown) => {
      if (selector === selectCurrentUser) {
        return signal(null);
      }

      if (selector === selectAllUsers) {
        return signal([]);
      }

      throw new Error(`Unexpected selector in VoicePlaybackService test: ${String(selector)}`);
    })
  };
  const scheduledEffects = new Set<{ dirty: boolean; run: () => void }>();
  const effectScheduler = {
    add: vi.fn((scheduledEffect: { dirty: boolean; run: () => void }) => {
      scheduledEffects.add(scheduledEffect);
    }),
    flush: vi.fn(() => {
      for (const scheduledEffect of scheduledEffects) {
        if (scheduledEffect.dirty) {
          scheduledEffect.run();
        }
      }
    }),
    remove: vi.fn((scheduledEffect: { dirty: boolean; run: () => void }) => {
      scheduledEffects.delete(scheduledEffect);
    }),
    schedule: vi.fn((scheduledEffect: { dirty: boolean; run: () => void }) => {
      scheduledEffects.add(scheduledEffect);
    })
  };
  const injector = Injector.create({
    providers: [
      VoicePlaybackService,
      {
        provide: ChangeDetectionScheduler,
        useValue: { notify: vi.fn() }
      },
      { provide: EffectScheduler, useValue: effectScheduler },
      { provide: VoiceConnectionFacade, useValue: voiceConnection },
      { provide: ScreenShareFacade, useValue: screenShare },
      { provide: Store, useValue: store }
    ]
  });

  return {
    service: runInInjectionContext(injector, () => injector.get(VoicePlaybackService)),
    voiceConnection,
    remoteStream$
  };
}

function connectedOptions(overrides: Partial<{ outputVolume: number; isDeafened: boolean }> = {}) {
  return {
    isConnected: true,
    outputVolume: overrides.outputVolume ?? 1,
    isDeafened: overrides.isDeafened ?? false
  };
}

function createMockAudioTrack(id: string, readyState: MediaStreamTrackState = 'live'): MediaStreamTrack {
  return {
    id,
    kind: 'audio',
    readyState,
    enabled: true
  } as MediaStreamTrack;
}

function createMockAudioStream(trackIds: string[]): MediaStream {
  return createMockAudioStreamFromTracks(trackIds.map((id) => createMockAudioTrack(id)));
}

function createMockAudioStreamFromTracks(tracks: MediaStreamTrack[]): MediaStream {
  return {
    getAudioTracks: () => tracks,
    getTracks: () => tracks
  } as unknown as MediaStream;
}

function installAudioDomMocks(): void {
  vi.stubGlobal('MediaStream', class MediaStream {
    constructor(private readonly tracks: MediaStreamTrack[] = []) {}

    getAudioTracks(): MediaStreamTrack[] {
      return this.tracks;
    }

    getTracks(): MediaStreamTrack[] {
      return this.tracks;
    }
  });

  vi.stubGlobal('Audio', class {
    muted = false;
    volume = 1;
    srcObject: MediaStream | null = null;

    play = vi.fn().mockResolvedValue(undefined);
    remove = vi.fn();
  });

  vi.stubGlobal('AudioContext', class {
    state = 'running';
    close = vi.fn().mockResolvedValue(undefined);

    constructor() {
      audioContextCount += 1;
    }

    createGain() {
      return {
        gain: { value: 0 },
        connect: vi.fn()
      };
    }

    createMediaStreamSource() {
      return { connect: vi.fn() };
    }

    createMediaStreamDestination() {
      return { stream: createMockAudioStream(['destination']) };
    }
  });
}

function installLocalStorageMock(): void {
  const storage = new Map<string, string>();

  vi.stubGlobal('localStorage', {
    getItem: (key: string) => storage.get(key) ?? null,
    setItem: (key: string, value: string) => {
      storage.set(key, value);
    },
    removeItem: (key: string) => {
      storage.delete(key);
    },
    clear: () => {
      storage.clear();
    }
  });
}
