import 'dotenv/config';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { StreamVideoClient } from '../StreamVideoClient';
import { StreamClient } from '@stream-io/node-sdk';
import {
  StreamClientOptions,
  StreamVideoEvent,
} from '../coordinator/connection/types';
import { CallingState } from '../store';
import { settled } from '../helpers/concurrency';
import { getCallInitConcurrencyTag } from '../helpers/clientUtils';
import { CallCreatedPayload, CallRingPayload } from './data';
import { expectCall, expectEvent } from './clientTestUtils';

const apiKey = process.env.STREAM_API_KEY!;
const secret = process.env.STREAM_SECRET!;

describe('StreamVideoClient Ringing', () => {
  const serverClient = new StreamClient(apiKey, secret);
  const testUserIds = ['oliver', 'sacha', 'marcelo'];

  let oliverClient: StreamVideoClient;
  let sachaClient: StreamVideoClient;
  let marceloClient: StreamVideoClient;

  const createClients = async (
    userIds: string[],
    clientOptions?: StreamClientOptions,
  ) => {
    const makeClient = async (userId: string) => {
      const client = new StreamVideoClient(apiKey, clientOptions);
      const token = serverClient.generateUserToken({ user_id: userId });
      await client.connectUser({ id: userId }, token);
      return client;
    };

    return await Promise.all(userIds.map(makeClient));
  };

  const disconnectClients = async () => {
    await Promise.all([
      oliverClient.disconnectUser(),
      sachaClient.disconnectUser(),
      marceloClient.disconnectUser(),
    ]);
  };

  beforeEach(async () => {
    [oliverClient, sachaClient, marceloClient] =
      await createClients(testUserIds);
  });

  afterEach(async () => {
    await disconnectClients();
  });

  describe('standard ringing', async () => {
    // TODO OL: enable this test once we know more:
    // https://getstream.slack.com/archives/C040262MY9K/p1755178272202659
    it.skip('server-side: oliver should ring all members', async () => {
      const oliverRing = expectEvent(oliverClient, 'call.ring');
      const sachaRing = expectEvent(sachaClient, 'call.ring');
      const marceloRing = expectEvent(marceloClient, 'call.ring');

      const call = serverClient.video.call('default', crypto.randomUUID());
      await call.create({
        ring: true,
        data: {
          created_by_id: 'oliver',
          members: [
            { user_id: 'oliver' },
            { user_id: 'sacha' },
            { user_id: 'marcelo' },
          ],
        },
      });

      const [oliverRingEvent, sachaRingEvent, marceloRingEvent] =
        await Promise.all([oliverRing, sachaRing, marceloRing]);

      expect(oliverRingEvent.call.cid).toBe(call.cid);
      expect(sachaRingEvent.call.cid).toBe(call.cid);
      expect(marceloRingEvent.call.cid).toBe(call.cid);

      const oliverCall = await expectCall(oliverClient, call.cid);
      const sachaCall = await expectCall(sachaClient, call.cid);
      const marceloCall = await expectCall(marceloClient, call.cid);
      expect(oliverCall).toBeDefined();
      expect(sachaCall).toBeDefined();
      expect(marceloCall).toBeDefined();
      expect(oliverCall.ringing).toBe(true);
      expect(sachaCall.ringing).toBe(true);
      expect(marceloCall.ringing).toBe(true);
    });

    it('client-side: oliver should ring all members', async () => {
      const oliverRing = expectEvent(oliverClient, 'call.ring');
      const sachaRing = expectEvent(sachaClient, 'call.ring');
      const marceloRing = expectEvent(marceloClient, 'call.ring');

      const call = oliverClient.call('default', crypto.randomUUID());
      await call.create({
        ring: true,
        data: {
          members: [
            { user_id: 'oliver' },
            { user_id: 'sacha' },
            { user_id: 'marcelo' },
          ],
        },
      });
      expect(call.ringing).toBe(true);

      const ringEventsPromise = Promise.all([sachaRing, marceloRing]);
      await expect(ringEventsPromise).resolves.toHaveLength(2);
      await expect(oliverRing).rejects.toThrow(); // caller doesn't get ring event
      const [sachaRingEvent, marceloRingEvent] = await ringEventsPromise;

      expect(sachaRingEvent.call.cid).toBe(call.cid);
      expect(marceloRingEvent.call.cid).toBe(call.cid);

      const sachaCall = await expectCall(sachaClient, call.cid);
      const marceloCall = await expectCall(marceloClient, call.cid);
      expect(sachaCall).toBeDefined();
      expect(marceloCall).toBeDefined();
      expect(sachaCall.ringing).toBe(true);
      expect(marceloCall.ringing).toBe(true);
    });
  });

  describe('ringing individual members', () => {
    it('should ring individual members', async () => {
      const oliverCall = oliverClient.call('default', crypto.randomUUID());
      await oliverCall.create({
        ring: false, // don't ring all members by default
        data: {
          members: [
            { user_id: 'oliver' },
            { user_id: 'sacha' },
            { user_id: 'marcelo' },
          ],
        },
      });

      // no one should get a ring event yet
      const oliverRing = expectEvent(oliverClient, 'call.ring', 500);
      const sachaRing = expectEvent(sachaClient, 'call.ring', 500);
      const marceloRing = expectEvent(marceloClient, 'call.ring', 500);
      await expect(
        Promise.all([oliverRing, sachaRing, marceloRing]),
      ).rejects.toThrow();

      // oliver is calling sacha. only sacha should get a ring event
      const sachaIndividualRing = expectEvent(sachaClient, 'call.ring');
      const marceloIndividualRing = expectEvent(marceloClient, 'call.ring');
      await oliverCall.ring({ members_ids: ['sacha'] });
      await expect(sachaIndividualRing).resolves.toHaveProperty(
        'call.cid',
        oliverCall.cid,
      );
      await expect(marceloIndividualRing).rejects.toThrow();

      const sachaCall = await expectCall(sachaClient, oliverCall.cid);
      expect(sachaCall).toBeDefined();

      // sacha is calling marcelo. only marcelo should get a ring event
      const oliverIndividualRing = expectEvent(oliverClient, 'call.ring');
      const marceloIndividualRing2 = expectEvent(marceloClient, 'call.ring');
      await sachaCall.ring({ members_ids: ['marcelo'] });
      await expect(marceloIndividualRing2).resolves.toHaveProperty(
        'call.cid',
        sachaCall.cid,
      );
      await expect(oliverIndividualRing).rejects.toThrow();

      const marceloCall = await expectCall(marceloClient, sachaCall.cid);
      expect(marceloCall).toBeDefined();
    });
  });

  describe('ringing concurrently', async () => {
    it('dispatches `call.ring` before `call.created`', async () => {
      oliverClient.streamClient.dispatchEvent(
        CallRingPayload as StreamVideoEvent,
      );
      oliverClient.streamClient.dispatchEvent(
        CallCreatedPayload as StreamVideoEvent,
      );
      await settled(getCallInitConcurrencyTag(CallCreatedPayload.call_cid));
      expect(oliverClient.state.calls.length).toBe(1);
      expect(oliverClient.state.calls[0].state.callingState).toBe(
        CallingState.RINGING,
      );
    });

    it('dispatches `call.created` then `call.ring`', async () => {
      oliverClient.streamClient.dispatchEvent(
        CallCreatedPayload as StreamVideoEvent,
      );
      oliverClient.streamClient.dispatchEvent(
        CallRingPayload as StreamVideoEvent,
      );

      await settled(getCallInitConcurrencyTag(CallCreatedPayload.call_cid));
      expect(oliverClient.state.calls.length).toBe(1);
      expect(oliverClient.state.calls[0].state.callingState).toBe(
        CallingState.RINGING,
      );
    });

    it('receives a push notification followed by `call.ring` then `call.created`', async () => {
      const call = await oliverClient.onRingingCall(CallRingPayload.call_cid);
      oliverClient.streamClient.dispatchEvent(
        CallRingPayload as StreamVideoEvent,
      );
      oliverClient.streamClient.dispatchEvent(
        CallCreatedPayload as StreamVideoEvent,
      );
      await settled(getCallInitConcurrencyTag(CallRingPayload.call_cid));
      expect(oliverClient.state.calls.length).toBe(1);
      expect(oliverClient.state.calls[0]).toBe(call);
      expect(call.ringing).toBe(true);
    });

    it('new call instance with doJoinRequest({ ring: true }) should replace old instance in store', async () => {
      const callId = crypto.randomUUID();

      const call = oliverClient.call('default', callId, {
        reuseInstance: true,
      });
      const callCreatedEvent = expectEvent(oliverClient, 'call.created');
      const serverCall = serverClient.video.call('default', callId);
      await serverCall.create({
        data: {
          created_by_id: 'oliver',
          members: [{ user_id: 'oliver' }, { user_id: 'sacha' }],
        },
      });

      // the event arrives and since the store is empty registers a new IDLE call
      await callCreatedEvent;

      // join registers or updates the call
      await call.doJoinRequest({ ring: true });

      expect(oliverClient.state.calls.length).toBe(1);
      expect(oliverClient.state.calls[0]).toBe(call);
      expect(oliverClient.state.calls[0].ringing).toBe(true);
    });
  });

  describe('ringing interruption', () => {
    const setupInitialCall = async () => {
      const sachaRing = expectEvent(sachaClient, 'call.ring');
      const oliverCall = oliverClient.call('default', crypto.randomUUID());

      await oliverCall.create({
        ring: true,
        data: {
          members: [{ user_id: 'oliver' }, { user_id: 'sacha' }],
        },
      });

      await expect(sachaRing).resolves.toHaveProperty(
        'call.cid',
        oliverCall.cid,
      );

      const sachaCall = await expectCall(sachaClient, oliverCall.cid);
      expect(sachaCall).toBeDefined();
      expect(oliverCall.state.callingState).toBe(CallingState.RINGING);
      expect(sachaCall.state.callingState).toBe(CallingState.RINGING);

      return { oliverCall, sachaCall };
    };

    it('should reject the call when the caller is busy', async () => {
      [oliverClient, sachaClient, marceloClient] = await createClients(
        testUserIds,
        {
          rejectCallWhenBusy: true,
        },
      );

      //initiate a call between oliver and sacha
      await setupInitialCall();

      //marcelo is calling oliver (the caller). call should be automatically rejected
      const marceloIndividualRing = expectEvent(marceloClient, 'call.rejected');

      const marceloCall = marceloClient.call('default', crypto.randomUUID());
      await marceloCall.create({
        ring: true,
        data: {
          members: [{ user_id: 'marcelo' }, { user_id: 'oliver' }],
        },
      });

      await expect(marceloIndividualRing).resolves.toHaveProperty(
        'call.cid',
        marceloCall.cid,
      );
      expect(oliverClient.state.calls.length).toBe(1);

      await disconnectClients();
    });

    it('should reject the call when the callee is busy', async () => {
      [oliverClient, sachaClient, marceloClient] = await createClients(
        testUserIds,
        {
          rejectCallWhenBusy: true,
        },
      );

      //initiate a call between oliver and sacha
      await setupInitialCall();

      //marcelo is calling sacha (the callee). call should be automatically rejected
      const marceloIndividualRing = expectEvent(marceloClient, 'call.rejected');

      const marceloCall = marceloClient.call('default', crypto.randomUUID());
      await marceloCall.create({
        ring: true,
        data: {
          members: [{ user_id: 'marcelo' }, { user_id: 'sacha' }],
        },
      });

      await expect(marceloIndividualRing).resolves.toHaveProperty(
        'call.cid',
        marceloCall.cid,
      );
      expect(sachaClient.state.calls.length).toBe(1);

      await disconnectClients();
    });

    it('should allow multiple simultaneous calls to the same caller when rejectCallWhenBusy is false', async () => {
      //initiate a call between oliver and sacha
      await setupInitialCall();

      //marcelo is calling oliver (the caller)
      const marceloIndividualRing = expectEvent(marceloClient, 'call.rejected');

      const marceloCall = marceloClient.call('default', crypto.randomUUID());
      await marceloCall.create({
        ring: true,
        data: {
          members: [{ user_id: 'marcelo' }, { user_id: 'oliver' }],
        },
      });

      //call should not be automatically rejected, exception will be thrown by timeout
      await expect(marceloIndividualRing).rejects.toThrow();
      //the caller has 2 calls available
      expect(oliverClient.state.calls.length).toBe(2);
    });

    it('should allow multiple simultaneous calls to the same callee when rejectCallWhenBusy is false', async () => {
      //initiate a call between oliver and sacha
      await setupInitialCall();

      //marcelo is calling sacha (the callee)
      const marceloIndividualRing = expectEvent(marceloClient, 'call.rejected');

      const marceloCall = marceloClient.call('default', crypto.randomUUID());
      await marceloCall.create({
        ring: true,
        data: {
          members: [{ user_id: 'marcelo' }, { user_id: 'sacha' }],
        },
      });

      //call should not be automatically rejected, exception will be thrown by timeout
      await expect(marceloIndividualRing).rejects.toThrow();
      //the callee has 2 calls available
      expect(sachaClient.state.calls.length).toBe(2);
    });
  });
});
