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

import { beforeEach, describe, expect, it, vi } from 'vitest';
import { Call } from '../Call';
import { Publisher } from '../rtc';
import { StreamClient } from '../coordinator/connection/client';
import { generateUUIDv4 } from '../coordinator/connection/utils';
import { PermissionsContext } from '../permissions';
import { OwnCapability } from '../gen/coordinator';
import { StreamVideoWriteableStateStore } from '../store';
import { TrackType } from '../gen/video/sfu/models/models';
import { StreamSfuClient } from '../StreamSfuClient';

describe('Publishing and Unpublishing tracks', () => {
  let call: Call;

  beforeEach(async () => {
    call = new Call({
      type: 'test',
      id: generateUUIDv4(),
      streamClient: new StreamClient('abc'),
      clientStore: new StreamVideoWriteableStateStore(),
    });

    const ctx = new PermissionsContext();
    ctx.setPermissions([
      OwnCapability.SEND_AUDIO,
      OwnCapability.SEND_VIDEO,
      OwnCapability.SCREENSHARE,
    ]);
    // @ts-expect-error permissionsContext is private
    call['permissionsContext'] = ctx;
  });

  describe('Validations', () => {
    it('publishing is not allowed only when call is not joined', async () => {
      const ms = new MediaStream();
      const err = 'Call is not joined yet';
      await expect(call.publish(ms, TrackType.VIDEO)).rejects.toThrowError(err);
      await expect(call.publish(ms, TrackType.AUDIO)).rejects.toThrowError(err);
      await expect(
        call.publish(ms, TrackType.SCREEN_SHARE),
      ).rejects.toThrowError(err);
    });

    it('publishing is not allowed when permissions are not set', async () => {
      // @ts-expect-error sfuClient is private
      call['sfuClient'] = {};

      call['permissionsContext'].setPermissions([]);

      const ms = new MediaStream();
      await expect(call.publish(ms, TrackType.VIDEO)).rejects.toThrowError(
        `No permission to publish VIDEO`,
      );
      await expect(call.publish(ms, TrackType.AUDIO)).rejects.toThrowError(
        'No permission to publish AUDIO',
      );
      await expect(
        call.publish(ms, TrackType.SCREEN_SHARE),
      ).rejects.toThrowError('No permission to publish SCREEN_SHARE');
    });

    it('publishing is not allowed when the publisher is not initialized', async () => {
      // @ts-expect-error sfuClient is private
      call['sfuClient'] = {};

      const ms = new MediaStream();
      await expect(call.publish(ms, TrackType.VIDEO)).rejects.toThrowError(
        'Publisher is not initialized',
      );
    });

    it('publishing is not allowed when there are no tracks in the stream', async () => {
      // @ts-expect-error sfuClient is private
      call['sfuClient'] = {};
      // @ts-expect-error publisher is private
      call['publisher'] = {};

      const ms = new MediaStream();
      vi.spyOn(ms, 'getVideoTracks').mockReturnValue([]);
      vi.spyOn(ms, 'getAudioTracks').mockReturnValue([]);

      await expect(call.publish(ms, TrackType.VIDEO)).rejects.toThrowError(
        'There is no VIDEO track in the stream',
      );
      await expect(call.publish(ms, TrackType.AUDIO)).rejects.toThrowError(
        'There is no AUDIO track in the stream',
      );
      await expect(
        call.publish(ms, TrackType.SCREEN_SHARE),
      ).rejects.toThrowError('There is no SCREEN_SHARE track in the stream');
    });

    it('publishing is not allowed when the track ended', async () => {
      // @ts-expect-error sfuClient is private
      call['sfuClient'] = {};
      // @ts-expect-error publisher is private
      call['publisher'] = {};

      const ms = new MediaStream();
      const track = new MediaStreamTrack();
      vi.spyOn(ms, 'getVideoTracks').mockReturnValue([track]);
      vi.spyOn(track, 'readyState', 'get').mockReturnValue('ended');

      await expect(call.publish(ms, TrackType.VIDEO)).rejects.toThrowError(
        `Can't publish ended tracks.`,
      );
    });
  });

  describe('Publishing and Unpublishing', () => {
    const sessionId = 'abc';
    let publisher: Publisher;
    let sfuClient: StreamSfuClient;

    beforeEach(() => {
      // @ts-expect-error partial data
      call.state.updateOrAddParticipant(sessionId, {
        sessionId,
        publishedTracks: [],
      });

      sfuClient = vi.fn() as unknown as StreamSfuClient;
      // @ts-expect-error sessionId is readonly
      sfuClient['sessionId'] = sessionId;
      sfuClient.updateMuteStates = vi.fn();

      publisher = vi.fn() as unknown as Publisher;
      publisher.publish = vi.fn();
      publisher.stopTracks = vi.fn();

      call['sfuClient'] = sfuClient;
      call.publisher = publisher;
    });

    it('publish video stream', async () => {
      const track = new MediaStreamTrack();
      const mediaStream = new MediaStream();
      vi.spyOn(mediaStream, 'getVideoTracks').mockReturnValue([track]);

      await call.publish(mediaStream, TrackType.VIDEO);
      expect(publisher.publish).toHaveBeenCalledWith(
        track,
        TrackType.VIDEO,
        undefined,
      );
      expect(call['trackPublishOrder']).toEqual([TrackType.VIDEO]);

      expect(sfuClient.updateMuteStates).toHaveBeenCalledWith([
        { trackType: TrackType.VIDEO, muted: false },
      ]);

      const participant = call.state.findParticipantBySessionId(sessionId);
      expect(participant).toBeDefined();
      expect(participant!.publishedTracks).toEqual([TrackType.VIDEO]);
      expect(participant!.videoStream).toEqual(mediaStream);
    });

    it('publish audio stream', async () => {
      const track = new MediaStreamTrack();
      const mediaStream = new MediaStream();
      vi.spyOn(mediaStream, 'getAudioTracks').mockReturnValue([track]);

      await call.publish(mediaStream, TrackType.AUDIO);
      expect(publisher.publish).toHaveBeenCalledWith(
        track,
        TrackType.AUDIO,
        undefined,
      );
      expect(call['trackPublishOrder']).toEqual([TrackType.AUDIO]);

      expect(sfuClient.updateMuteStates).toHaveBeenCalledWith([
        { trackType: TrackType.AUDIO, muted: false },
      ]);

      const participant = call.state.findParticipantBySessionId(sessionId);
      expect(participant).toBeDefined();
      expect(participant!.publishedTracks).toEqual([TrackType.AUDIO]);
      expect(participant!.audioStream).toEqual(mediaStream);
    });

    it('publish screen share stream', async () => {
      const track = new MediaStreamTrack();
      const mediaStream = new MediaStream();
      vi.spyOn(mediaStream, 'getVideoTracks').mockReturnValue([track]);

      await call.publish(mediaStream, TrackType.SCREEN_SHARE);
      expect(publisher.publish).toHaveBeenCalledWith(
        track,
        TrackType.SCREEN_SHARE,
        undefined,
      );
      expect(call['trackPublishOrder']).toEqual([TrackType.SCREEN_SHARE]);

      expect(sfuClient.updateMuteStates).toHaveBeenCalledWith([
        { trackType: TrackType.SCREEN_SHARE, muted: false },
      ]);

      const participant = call.state.findParticipantBySessionId(sessionId);
      expect(participant).toBeDefined();
      expect(participant!.publishedTracks).toEqual([TrackType.SCREEN_SHARE]);
      expect(participant!.screenShareStream).toEqual(mediaStream);
    });

    it('publish screen share stream with audio', async () => {
      const videoTrack = new MediaStreamTrack();
      const audioTrack = new MediaStreamTrack();
      const mediaStream = new MediaStream();
      vi.spyOn(mediaStream, 'getVideoTracks').mockReturnValue([videoTrack]);
      vi.spyOn(mediaStream, 'getAudioTracks').mockReturnValue([audioTrack]);

      await call.publish(mediaStream, TrackType.SCREEN_SHARE);
      expect(publisher.publish).toHaveBeenCalledWith(
        videoTrack,
        TrackType.SCREEN_SHARE,
        undefined,
      );
      expect(publisher.publish).toHaveBeenCalledWith(
        audioTrack,
        TrackType.SCREEN_SHARE_AUDIO,
        undefined,
      );
      expect(call['trackPublishOrder']).toEqual([
        TrackType.SCREEN_SHARE,
        TrackType.SCREEN_SHARE_AUDIO,
      ]);

      expect(sfuClient.updateMuteStates).toHaveBeenCalledWith([
        { trackType: TrackType.SCREEN_SHARE, muted: false },
        { trackType: TrackType.SCREEN_SHARE_AUDIO, muted: false },
      ]);

      const participant = call.state.findParticipantBySessionId(sessionId);
      expect(participant).toBeDefined();
      expect(participant!.publishedTracks).toEqual([
        TrackType.SCREEN_SHARE,
        TrackType.SCREEN_SHARE_AUDIO,
      ]);
      expect(participant!.screenShareStream).toEqual(mediaStream);
      expect(participant!.screenShareAudioStream).toEqual(mediaStream);
    });

    it('unpublish video stream', async () => {
      const mediaStream = new MediaStream();
      call.state.updateParticipant(sessionId, {
        publishedTracks: [TrackType.VIDEO, TrackType.AUDIO],
        videoStream: mediaStream,
      });
      await call.stopPublish(TrackType.VIDEO);
      expect(publisher.publish).not.toHaveBeenCalled();
      expect(publisher.stopTracks).toHaveBeenCalledWith(TrackType.VIDEO);
      const participant = call.state.findParticipantBySessionId(sessionId);
      expect(participant!.publishedTracks).toEqual([TrackType.AUDIO]);
      expect(participant!.videoStream).toBeUndefined();
    });

    it('unpublish audio stream', async () => {
      const mediaStream = new MediaStream();
      call.state.updateParticipant(sessionId, {
        publishedTracks: [TrackType.VIDEO, TrackType.AUDIO],
        audioStream: mediaStream,
      });
      await call.stopPublish(TrackType.AUDIO);
      expect(publisher.publish).not.toHaveBeenCalled();
      expect(publisher.stopTracks).toHaveBeenCalledWith(TrackType.AUDIO);
      const participant = call.state.findParticipantBySessionId(sessionId);
      expect(participant!.publishedTracks).toEqual([TrackType.VIDEO]);
      expect(participant!.audioStream).toBeUndefined();
    });

    it('unpublish screen share stream', async () => {
      const mediaStream = new MediaStream();
      call.state.updateParticipant(sessionId, {
        publishedTracks: [TrackType.SCREEN_SHARE, TrackType.SCREEN_SHARE_AUDIO],
        screenShareStream: mediaStream,
        screenShareAudioStream: mediaStream,
      });
      await call.stopPublish(
        TrackType.SCREEN_SHARE,
        TrackType.SCREEN_SHARE_AUDIO,
      );
      expect(publisher.publish).not.toHaveBeenCalled();
      expect(publisher.stopTracks).toHaveBeenCalledWith(
        TrackType.SCREEN_SHARE,
        TrackType.SCREEN_SHARE_AUDIO,
      );
      const participant = call.state.findParticipantBySessionId(sessionId);
      expect(participant!.publishedTracks).toEqual([]);
      expect(participant!.screenShareStream).toBeUndefined();
      expect(participant!.screenShareAudioStream).toBeUndefined();
    });

    it('does not throw if sfuClient is cleared while the mute-state RPC is in flight', async () => {
      let releaseMuteUpdate!: () => void;
      let signalMuteUpdateEntered!: () => void;
      const muteUpdateEntered = new Promise<void>(
        (resolve) => (signalMuteUpdateEntered = resolve),
      );
      sfuClient.updateMuteStates = vi.fn().mockImplementation(() => {
        signalMuteUpdateEntered();
        return new Promise<void>((resolve) => (releaseMuteUpdate = resolve));
      });

      const track = new MediaStreamTrack() as MediaStreamAudioTrack;
      const mediaStream = new MediaStream();
      vi.spyOn(mediaStream, 'getAudioTracks').mockReturnValue([track]);

      const inflight = call.publish(mediaStream, TrackType.AUDIO);

      await muteUpdateEntered;

      call['sfuClient'] = undefined;
      releaseMuteUpdate();

      await inflight;
    });

    it('updates local stream state when sfuClient is replaced with the same session id', async () => {
      let releaseMuteUpdate!: () => void;
      let signalMuteUpdateEntered!: () => void;
      const muteUpdateEntered = new Promise<void>(
        (resolve) => (signalMuteUpdateEntered = resolve),
      );
      sfuClient.updateMuteStates = vi.fn().mockImplementation(() => {
        signalMuteUpdateEntered();
        return new Promise<void>((resolve) => (releaseMuteUpdate = resolve));
      });

      const track = new MediaStreamTrack() as MediaStreamAudioTrack;
      const mediaStream = new MediaStream();
      vi.spyOn(mediaStream, 'getAudioTracks').mockReturnValue([track]);

      const inflight = call.publish(mediaStream, TrackType.AUDIO);

      await muteUpdateEntered;

      const replacementSfuClient = vi.fn() as unknown as StreamSfuClient;
      // @ts-expect-error sessionId is readonly
      replacementSfuClient['sessionId'] = sessionId;
      replacementSfuClient.updateMuteStates = vi.fn();
      call['sfuClient'] = replacementSfuClient;
      releaseMuteUpdate();

      await inflight;

      const participant = call.state.findParticipantBySessionId(sessionId);
      expect(participant?.publishedTracks).toEqual([TrackType.AUDIO]);
      expect(participant?.audioStream).toBe(mediaStream);
    });

    it('skips local stream state update when sfuClient is replaced with a new session id', async () => {
      let releaseMuteUpdate!: () => void;
      let signalMuteUpdateEntered!: () => void;
      const muteUpdateEntered = new Promise<void>(
        (resolve) => (signalMuteUpdateEntered = resolve),
      );
      sfuClient.updateMuteStates = vi.fn().mockImplementation(() => {
        signalMuteUpdateEntered();
        return new Promise<void>((resolve) => (releaseMuteUpdate = resolve));
      });

      const track = new MediaStreamTrack() as MediaStreamAudioTrack;
      const mediaStream = new MediaStream();
      vi.spyOn(mediaStream, 'getAudioTracks').mockReturnValue([track]);

      const inflight = call.publish(mediaStream, TrackType.AUDIO);

      await muteUpdateEntered;

      const replacementSessionId = 'replacement-session-id';
      // @ts-expect-error partial data
      call.state.updateOrAddParticipant(replacementSessionId, {
        sessionId: replacementSessionId,
        publishedTracks: [],
      });

      const replacementSfuClient = vi.fn() as unknown as StreamSfuClient;
      // @ts-expect-error sessionId is readonly
      replacementSfuClient['sessionId'] = replacementSessionId;
      replacementSfuClient.updateMuteStates = vi.fn();
      call['sfuClient'] = replacementSfuClient;
      releaseMuteUpdate();

      await inflight;

      const originalParticipant =
        call.state.findParticipantBySessionId(sessionId);
      const replacementParticipant =
        call.state.findParticipantBySessionId(replacementSessionId);
      expect(originalParticipant?.publishedTracks).toEqual([]);
      expect(originalParticipant?.audioStream).toBeUndefined();
      expect(replacementParticipant?.publishedTracks).toEqual([]);
      expect(replacementParticipant?.audioStream).toBeUndefined();
    });
  });

  describe('Deprecated methods', () => {
    it('publishVideoStream', async () => {
      const ms = new MediaStream();
      call.publish = vi.fn();
      await call.publishVideoStream(ms);
      expect(call.publish).toHaveBeenCalledWith(ms, TrackType.VIDEO);
    });

    it('publishAudioStream', async () => {
      const ms = new MediaStream();
      call.publish = vi.fn();
      await call.publishAudioStream(ms);
      expect(call.publish).toHaveBeenCalledWith(ms, TrackType.AUDIO);
    });

    it('publishScreenShareStream', async () => {
      const ms = new MediaStream();
      call.publish = vi.fn();
      await call.publishScreenShareStream(ms);
      expect(call.publish).toHaveBeenCalledWith(ms, TrackType.SCREEN_SHARE);
    });
  });
});
