import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { StreamVideoClient } from '../StreamVideoClient';
import 'dotenv/config';
import { generateUUIDv4 } from '../coordinator/connection/utils';
import { User } from '../coordinator/connection/types';
import { StreamClient } from '@stream-io/node-sdk';
import { ConnectedEvent } from '../gen/coordinator';

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

const serverClient = new StreamClient(apiKey, secret);

const tokenProvider = (userId: string) => {
  return async () => {
    return new Promise<string>((resolve) => {
      setTimeout(() => {
        const token = serverClient.generateUserToken({ user_id: userId });
        resolve(token);
      }, 100);
    });
  };
};

describe('StreamVideoClient', () => {
  let client: StreamVideoClient;

  beforeEach(() => {
    client = new StreamVideoClient(apiKey, {
      // tests run in node, so we have to fake being in browser env
      browser: true,
      timeout: 15000,
    });
  });

  it('API calls are hold until connection is done', async () => {
    const user = {
      id: 'jane',
    };

    client.connectUser(user, tokenProvider(user.id));
    const response = await client.queryCalls({});

    expect(response).toBeDefined();
  });

  it('API calls are hold until connection is done - anonymous users', async () => {
    client.connectUser({ type: 'anonymous' });
    const response = await client.queryCalls({
      filter_conditions: { members: { $in: ['!anon'] } },
    });

    expect(response).toBeDefined();
  });

  it('public endpoints can be called without authentication', async () => {
    const userId = 'guest-' + generateUUIDv4();
    const response = await client.createGuestUser({
      user: { id: userId },
    });

    expect(response.user.id).toContain(userId);
  });

  it(`private endpoints can't be called without authentication`, async () => {
    await expect(() => client.queryCalls({})).rejects.toThrowError();

    await client.connectUser({ id: 'jane' }, tokenProvider('jane'));
    await client.disconnectUser();

    await expect(() => client.queryCalls({})).rejects.toThrowError();
  });

  it('API calls should be enriched with conneciton id', async () => {
    const user = {
      id: 'jane',
    };

    client.connectUser(user, tokenProvider(user.id));
    const spy = vi.spyOn(client.streamClient.axiosInstance, 'post');
    await client.call('default', generateUUIDv4()).getOrCreate();

    const requestConfig: any = spy.mock.calls[spy.mock.calls.length - 1][2];
    const params = requestConfig.params;
    expect(client.streamClient._getConnectionID()).toBeDefined();
    expect(params['connection_id']).toBe(
      client.streamClient._getConnectionID(),
    );
  });

  it('API calls should be hold until auth is done - guest user', async () => {
    const user: User = {
      id: `jane-${generateUUIDv4()}`,
      type: 'guest',
    };

    client.connectUser(user);
    const response = await client.queryCalls({
      filter_conditions: { members: { $in: [user.id] } },
    });

    expect(response.calls).toBeDefined();
  });

  it('should clear token on disconnect', async () => {
    const user = { id: 'jane' };
    const tp = vi.fn(tokenProvider(user.id));
    await client.connectUser(user, tp);
    await client.disconnectUser();
    await client.connectUser({ type: 'anonymous' });
    expect(tp).toBeCalledTimes(1);
  });

  afterEach(() => {
    client.disconnectUser();
  });

  describe('StreamVideoClient.getOrCreateInstance', () => {
    afterEach(() => {
      // @ts-expect-error - private field
      StreamVideoClient._instances.clear();
    });

    it('throws an error if userId is not provided except for anon users', () => {
      expect(() => {
        StreamVideoClient.getOrCreateInstance({ apiKey, user: { id: '' } });
      }).toThrow();
      expect(() => {
        StreamVideoClient.getOrCreateInstance({
          apiKey,
          user: { type: 'anonymous' },
        });
      }).not.toThrow();
    });

    it('throws an error if token or token provider is not provided for authenticated users', () => {
      expect(() => {
        StreamVideoClient.getOrCreateInstance({
          apiKey,
          user: { id: 'jane', type: 'authenticated' },
        });
      }).toThrow();
      expect(() => {
        StreamVideoClient.getOrCreateInstance({
          apiKey,
          user: { id: 'jane', type: 'guest' },
        });
      }).not.toThrow();
      expect(() => {
        StreamVideoClient.getOrCreateInstance({
          apiKey,
          user: { type: 'anonymous' },
        });
      }).not.toThrow();
    });

    it('returns the same instance for the same userId and apiKey', () => {
      const instance1 = StreamVideoClient.getOrCreateInstance({
        apiKey,
        user: { id: 'jane' },
        token: serverClient.generateUserToken({ user_id: 'jane' }),
      });
      const instance2 = StreamVideoClient.getOrCreateInstance({
        apiKey,
        user: { id: 'jane' },
        token: serverClient.generateUserToken({ user_id: 'jane' }),
      });
      expect(instance1).toBe(instance2);
    });

    it('returns different instances for different userIds', () => {
      const instance1 = StreamVideoClient.getOrCreateInstance({
        apiKey,
        user: { id: 'jane' },
        token: serverClient.generateUserToken({ user_id: 'jane' }),
      });
      const instance2 = StreamVideoClient.getOrCreateInstance({
        apiKey,
        user: { id: 'john' },
        token: serverClient.generateUserToken({ user_id: 'jane' }),
      });
      expect(instance1).not.toBe(instance2);
    });

    it('returns different instances if existing user is disconnected', async () => {
      const instance1 = StreamVideoClient.getOrCreateInstance({
        apiKey,
        user: { id: 'jane' },
        token: serverClient.generateUserToken({ user_id: 'jane' }),
      });
      await instance1.disconnectUser();

      const instance2 = StreamVideoClient.getOrCreateInstance({
        apiKey,
        user: { id: 'jane' },
        token: serverClient.generateUserToken({ user_id: 'jane' }),
      });

      expect(instance1).not.toBe(instance2);
    });
  });
});

describe('StreamVideoClient.connectUser retries', () => {
  it('should retry connecting user', async () => {
    const client = new StreamVideoClient(apiKey, {
      // tests run in node, so we have to fake being in browser env
      browser: true,
      maxConnectUserRetries: 2,
    });
    client.streamClient.connectUser = vi
      .fn()
      .mockRejectedValueOnce(new Error('something happened'));

    const user = { id: 'jane' };
    const token = serverClient.generateUserToken({ user_id: user.id });
    await client.connectUser(user, token);
    expect(client.streamClient.connectUser).toBeCalledTimes(2);

    await client.disconnectUser();
  });

  it('should propagate error if max retries reached', async () => {
    const onConnectUserError = vi.fn();
    const client = new StreamVideoClient(apiKey, {
      // tests run in node, so we have to fake being in browser env
      browser: true,
      maxConnectUserRetries: 3,
      onConnectUserError,
    });

    client.streamClient.connectUser = vi
      .fn()
      .mockRejectedValue(new Error('something happened'));

    const user = { id: 'jane' };
    await expect(async () => {
      const token = serverClient.generateUserToken({ user_id: user.id });
      await client.connectUser(user, token);
    }).rejects.toThrowError('something happened');

    expect(onConnectUserError).toBeCalledTimes(1);

    const invocation = onConnectUserError.mock.calls[0];
    const [lastError, allErrors] = invocation;
    expect(lastError.message).toBe('something happened');
    expect(allErrors.length).toBe(3);
    expect(
      allErrors.every((error: Error) => error.message === 'something happened'),
    ).toBe(true);

    await client.disconnectUser();
  });

  it('should connect the user if all is good', async () => {
    const onConnectUserError = vi.fn();
    const client = new StreamVideoClient(apiKey, {
      // tests run in node, so we have to fake being in browser env
      browser: true,
      onConnectUserError,
    });

    const user = { id: 'jane' };

    client.streamClient.connectUser = vi.fn().mockResolvedValue({
      me: { id: user.id },
    } as ConnectedEvent);

    const token = serverClient.generateUserToken({ user_id: user.id });
    await client.connectUser(user, token);

    expect(onConnectUserError).not.toBeCalled();
    expect(client.state.connectedUser?.id).toBe(user.id);

    await client.disconnectUser();
  });
});

describe('StreamVideoClient.watchCalls retries', () => {
  it('should retry queryCalls up to 3 times after reconnecting', async () => {
    vi.useFakeTimers();
    const client = new StreamVideoClient(apiKey, {
      browser: true,
    });

    const user = { id: 'jane' };
    const token = serverClient.generateUserToken({ user_id: user.id });
    await client.connectUser(user, token);

    const call = client.call('default', generateUUIDv4());
    await call.getOrCreate();

    const queryCallsSpy = vi
      .spyOn(client, 'queryCalls')
      .mockRejectedValueOnce(new Error('fail 1'))
      .mockRejectedValueOnce(new Error('fail 2'))
      .mockResolvedValue({
        calls: [],
        duration: '0ms',
      });

    client.streamClient.dispatchEvent({
      type: 'connection.changed',
      online: true,
    });

    await vi.runAllTimersAsync();

    expect(queryCallsSpy).toHaveBeenCalledTimes(3);

    await client.disconnectUser();

    vi.useRealTimers();
  });

  it('should stop retrying after 3 failures on reconnect', async () => {
    vi.useFakeTimers();

    const client = new StreamVideoClient(apiKey, {
      browser: true,
    });

    const user = { id: 'jane' };
    const token = serverClient.generateUserToken({ user_id: user.id });
    await client.connectUser(user, token);

    const call = client.call('default', generateUUIDv4());
    await call.getOrCreate();

    const queryCallsSpy = vi
      .spyOn(client, 'queryCalls')
      .mockRejectedValueOnce(new Error('fail 1'))
      .mockRejectedValueOnce(new Error('fail 2'))
      .mockRejectedValueOnce(new Error('fail 3'))
      .mockResolvedValue({
        calls: [],
        duration: '0ms',
      });

    client.streamClient.dispatchEvent({
      type: 'connection.changed',
      online: true,
    });

    await vi.runAllTimersAsync();

    expect(queryCallsSpy).toHaveBeenCalledTimes(3);

    await client.disconnectUser();
    vi.useRealTimers();
  });

  it('should call only once when no errors occur', async () => {
    vi.useFakeTimers();
    const client = new StreamVideoClient(apiKey, {
      browser: true,
    });

    const user = { id: 'jane' };
    const token = serverClient.generateUserToken({ user_id: user.id });
    await client.connectUser(user, token);

    const call = client.call('default', generateUUIDv4());
    await call.getOrCreate();

    const queryCallsSpy = vi.spyOn(client, 'queryCalls').mockResolvedValue({
      calls: [],
      duration: '0ms',
    });

    client.streamClient.dispatchEvent({
      type: 'connection.changed',
      online: true,
    });

    await vi.runAllTimersAsync();

    expect(queryCallsSpy).toHaveBeenCalledTimes(1);

    await client.disconnectUser();
    vi.useRealTimers();
  });
});
