import { describe, it, expect, vi } from 'vitest';
import AES from 'crypto-js/aes.js';
import enc from 'crypto-js/enc-utf8.js';
import appConfig from '../config/app.js';
import AppAuthClient from './app-auth-client.js';
import App from './app.js';
import AppConfig from './app-config.js';
import Base from './base.js';
import Connection from './connection';
import Step from './step.js';
import User from './user.js';
import Telemetry from '../helpers/telemetry/index.js';
import { createConnection } from '../../test/factories/connection.js';
import { createAppConfig } from '../../test/factories/app-config.js';
import { createAppAuthClient } from '../../test/factories/app-auth-client.js';

describe('Connection model', () => {
  it('tableName should return correct name', () => {
    expect(Connection.tableName).toBe('connections');
  });

  it('jsonSchema should have correct validations', () => {
    expect(Connection.jsonSchema).toMatchSnapshot();
  });

  it('virtualAttributes should return correct attributes', () => {
    const virtualAttributes = Connection.virtualAttributes;

    const expectedAttributes = ['reconnectable'];

    expect(virtualAttributes).toStrictEqual(expectedAttributes);
  });

  describe('relationMappings', () => {
    it('should return correct associations', () => {
      const relationMappings = Connection.relationMappings();

      const expectedRelations = {
        user: {
          relation: Base.BelongsToOneRelation,
          modelClass: User,
          join: {
            from: 'connections.user_id',
            to: 'users.id',
          },
        },
        steps: {
          relation: Base.HasManyRelation,
          modelClass: Step,
          join: {
            from: 'connections.id',
            to: 'steps.connection_id',
          },
        },
        triggerSteps: {
          relation: Base.HasManyRelation,
          modelClass: Step,
          join: {
            from: 'connections.id',
            to: 'steps.connection_id',
          },
          filter: expect.any(Function),
        },
        appConfig: {
          relation: Base.BelongsToOneRelation,
          modelClass: AppConfig,
          join: {
            from: 'connections.key',
            to: 'app_configs.key',
          },
        },
        appAuthClient: {
          relation: Base.BelongsToOneRelation,
          modelClass: AppAuthClient,
          join: {
            from: 'connections.app_auth_client_id',
            to: 'app_auth_clients.id',
          },
        },
      };

      expect(relationMappings).toStrictEqual(expectedRelations);
    });

    it('triggerSteps should return only trigger typed steps', () => {
      const relations = Connection.relationMappings();
      const whereSpy = vi.fn();

      relations.triggerSteps.filter({ where: whereSpy });

      expect(whereSpy).toHaveBeenCalledWith('type', '=', 'trigger');
    });
  });

  describe('reconnectable', () => {
    it('should return active status of app auth client when created via app auth client', async () => {
      const appAuthClient = await createAppAuthClient({
        active: true,
        formattedAuthDefaults: {
          clientId: 'sample-id',
        },
      });

      const connection = await createConnection({
        appAuthClientId: appAuthClient.id,
        formattedData: {
          token: 'sample-token',
        },
      });

      const connectionWithAppAuthClient = await connection
        .$query()
        .withGraphFetched({
          appAuthClient: true,
        });

      expect(connectionWithAppAuthClient.reconnectable).toBe(true);
    });

    it('should return true when app config is not disabled and allows custom connection', async () => {
      const appConfig = await createAppConfig({
        key: 'gitlab',
        disabled: false,
        customConnectionAllowed: true,
      });

      const connection = await createConnection({
        key: appConfig.key,
        formattedData: {
          token: 'sample-token',
        },
      });

      const connectionWithAppAuthClient = await connection
        .$query()
        .withGraphFetched({
          appConfig: true,
        });

      expect(connectionWithAppAuthClient.reconnectable).toBe(true);
    });

    it('should return false when app config is disabled or does not allow custom connection', async () => {
      const connection = await createConnection({
        key: 'gitlab',
        formattedData: {
          token: 'sample-token',
        },
      });

      await createAppConfig({
        key: 'gitlab',
        disabled: true,
        customConnectionAllowed: false,
      });

      const connectionWithAppAuthClient = await connection
        .$query()
        .withGraphFetched({
          appConfig: true,
        });

      expect(connectionWithAppAuthClient.reconnectable).toBe(false);
    });
  });

  describe('encryptData', () => {
    it('should return undefined if eligibleForEncryption is not true', async () => {
      vi.spyOn(Connection.prototype, 'eligibleForEncryption').mockReturnValue(
        false
      );

      const connection = new Connection();

      expect(connection.encryptData()).toBeUndefined();
    });

    it('should encrypt formattedData and set it to data', async () => {
      vi.spyOn(Connection.prototype, 'eligibleForEncryption').mockReturnValue(
        true
      );

      const formattedData = {
        key: 'value',
      };

      const connection = new Connection();
      connection.formattedData = formattedData;
      connection.encryptData();

      const expectedDecryptedValue = JSON.parse(
        AES.decrypt(connection.data, appConfig.encryptionKey).toString(enc)
      );

      expect(formattedData).toStrictEqual(expectedDecryptedValue);
      expect(connection.data).not.toStrictEqual(formattedData);
    });

    it('should encrypt formattedData and remove formattedData', async () => {
      vi.spyOn(Connection.prototype, 'eligibleForEncryption').mockReturnValue(
        true
      );

      const formattedData = {
        key: 'value',
      };

      const connection = new Connection();
      connection.formattedData = formattedData;
      connection.encryptData();

      expect(connection.formattedData).not.toBeDefined();
    });
  });

  describe('decryptData', () => {
    it('should return undefined if eligibleForDecryption is not true', () => {
      vi.spyOn(Connection.prototype, 'eligibleForDecryption').mockReturnValue(
        false
      );

      const connection = new Connection();

      expect(connection.decryptData()).toBeUndefined();
    });

    it('should decrypt data and set it to formattedData', async () => {
      vi.spyOn(Connection.prototype, 'eligibleForDecryption').mockReturnValue(
        true
      );

      const formattedData = {
        key: 'value',
      };

      const data = AES.encrypt(
        JSON.stringify(formattedData),
        appConfig.encryptionKey
      ).toString();

      const connection = new Connection();
      connection.data = data;
      connection.decryptData();

      expect(connection.formattedData).toStrictEqual(formattedData);
      expect(connection.data).not.toStrictEqual(formattedData);
    });
  });

  describe('eligibleForEncryption', () => {
    it('should return true when formattedData property exists', async () => {
      const connection = new Connection();
      connection.formattedData = { clientId: 'sample-id' };

      expect(connection.eligibleForEncryption()).toBe(true);
    });

    it("should return false when formattedData property doesn't exist", async () => {
      const connection = new Connection();
      connection.formattedData = undefined;

      expect(connection.eligibleForEncryption()).toBe(false);
    });
  });

  describe('eligibleForDecryption', () => {
    it('should return true when data property exists', async () => {
      const connection = new Connection();
      connection.data = 'encrypted-data';

      expect(connection.eligibleForDecryption()).toBe(true);
    });

    it("should return false when data property doesn't exist", async () => {
      const connection = new Connection();
      connection.data = undefined;

      expect(connection.eligibleForDecryption()).toBe(false);
    });
  });

  describe('getApp', () => {
    it('should return connection app when valid key exists', async () => {
      const connection = new Connection();
      connection.key = 'gitlab';

      const connectionApp = await connection.getApp();
      const app = await App.findOneByKey('gitlab');

      expect(connectionApp).toStrictEqual(app);
    });

    it('should throw an error when invalid key exists', async () => {
      const connection = new Connection();
      connection.key = 'invalid-key';

      await expect(() => connection.getApp()).rejects.toThrowError(
        `An application with the "invalid-key" key couldn't be found.`
      );
    });

    it('should return null when no key exists', async () => {
      const connection = new Connection();

      await expect(connection.getApp()).resolves.toBe(null);
    });
  });

  it('getAppConfig should return connection app config', async () => {
    const connection = new Connection();
    connection.key = 'gitlab';

    const appConfig = await createAppConfig({ key: 'gitlab' });

    const connectionAppConfig = await connection.getAppConfig();

    expect(connectionAppConfig).toStrictEqual(appConfig);
  });

  describe('checkEligibilityForCreation', () => {
    it('should return connection if no app config exists', async () => {
      vi.spyOn(Connection.prototype, 'getApp').mockResolvedValue({
        name: 'gitlab',
      });

      vi.spyOn(Connection.prototype, 'getAppConfig').mockResolvedValue();

      const connection = new Connection();

      expect(await connection.checkEligibilityForCreation()).toBe(connection);
    });

    it('should throw an error when app does not exist', async () => {
      vi.spyOn(Connection.prototype, 'getApp').mockRejectedValue(
        new Error(
          `An application with the "unexisting-app" key couldn't be found.`
        )
      );

      vi.spyOn(Connection.prototype, 'getAppConfig').mockResolvedValue();

      const connection = new Connection();

      await expect(() =>
        connection.checkEligibilityForCreation()
      ).rejects.toThrow(
        `An application with the "unexisting-app" key couldn't be found.`
      );
    });

    it('should throw an error when app config is disabled', async () => {
      vi.spyOn(Connection.prototype, 'getApp').mockResolvedValue({
        name: 'gitlab',
      });

      vi.spyOn(Connection.prototype, 'getAppConfig').mockResolvedValue({
        disabled: true,
      });

      const connection = new Connection();

      await expect(() =>
        connection.checkEligibilityForCreation()
      ).rejects.toThrow(
        'The application has been disabled for new connections!'
      );
    });

    it('should throw an error when app config does not allow custom connection with formatted data', async () => {
      vi.spyOn(Connection.prototype, 'getApp').mockResolvedValue({
        name: 'gitlab',
      });

      vi.spyOn(Connection.prototype, 'getAppConfig').mockResolvedValue({
        disabled: false,
        customConnectionAllowed: false,
      });

      const connection = new Connection();
      connection.formattedData = {};

      await expect(() =>
        connection.checkEligibilityForCreation()
      ).rejects.toThrow(
        'New custom connections have been disabled for gitlab!'
      );
    });

    it('should throw an error when app config is not shared with app auth client', async () => {
      vi.spyOn(Connection.prototype, 'getApp').mockResolvedValue({
        name: 'gitlab',
      });

      vi.spyOn(Connection.prototype, 'getAppConfig').mockResolvedValue({
        disabled: false,
        shared: false,
      });

      const connection = new Connection();
      connection.appAuthClientId = 'sample-id';

      await expect(() =>
        connection.checkEligibilityForCreation()
      ).rejects.toThrow(
        'The connection with the given app auth client is not allowed!'
      );
    });

    it('should apply app auth client auth defaults when creating with shared app auth client', async () => {
      await createAppConfig({
        key: 'gitlab',
        disabled: false,
        customConnectionAllowed: true,
        shared: true,
      });

      const appAuthClient = await createAppAuthClient({
        appKey: 'gitlab',
        active: true,
        formattedAuthDefaults: {
          clientId: 'sample-id',
        },
      });

      const connection = await createConnection({
        key: 'gitlab',
        appAuthClientId: appAuthClient.id,
        formattedData: null,
      });

      await connection.checkEligibilityForCreation();

      expect(connection.formattedData).toStrictEqual({
        clientId: 'sample-id',
      });
    });
  });

  describe('testAndUpdateConnection', () => {
    it('should verify connection and persist it', async () => {
      const connection = await createConnection({ verified: false });

      const isStillVerifiedSpy = vi.fn().mockReturnValue(true);

      const originalApp = await connection.getApp();

      const getAppSpy = vi
        .spyOn(connection, 'getApp')
        .mockImplementation(() => {
          return {
            ...originalApp,
            auth: {
              ...originalApp.auth,
              isStillVerified: isStillVerifiedSpy,
            },
          };
        });

      const updatedConnection = await connection.testAndUpdateConnection();

      expect(getAppSpy).toHaveBeenCalledOnce();
      expect(isStillVerifiedSpy).toHaveBeenCalledOnce();
      expect(updatedConnection.verified).toBe(true);
    });

    it('should unverify connection and persist it', async () => {
      const connection = await createConnection({ verified: true });

      const isStillVerifiedSpy = vi
        .fn()
        .mockRejectedValue(new Error('Wrong credentials!'));

      const originalApp = await connection.getApp();

      const getAppSpy = vi
        .spyOn(connection, 'getApp')
        .mockImplementation(() => {
          return {
            ...originalApp,
            auth: {
              ...originalApp.auth,
              isStillVerified: isStillVerifiedSpy,
            },
          };
        });

      const updatedConnection = await connection.testAndUpdateConnection();

      expect(getAppSpy).toHaveBeenCalledOnce();
      expect(isStillVerifiedSpy).toHaveBeenCalledOnce();
      expect(updatedConnection.verified).toBe(false);
    });
  });

  describe('verifyAndUpdateConnection', () => {
    it('should verify connection with valid token', async () => {
      const connection = await createConnection({
        verified: false,
        draft: true,
      });

      const verifyCredentialsSpy = vi.fn().mockResolvedValue(true);

      const originalApp = await connection.getApp();

      vi.spyOn(connection, 'getApp').mockImplementation(() => {
        return {
          ...originalApp,
          auth: {
            ...originalApp.auth,
            verifyCredentials: verifyCredentialsSpy,
          },
        };
      });

      const updatedConnection = await connection.verifyAndUpdateConnection();

      expect(verifyCredentialsSpy).toHaveBeenCalledOnce();
      expect(updatedConnection.verified).toBe(true);
      expect(updatedConnection.draft).toBe(false);
    });

    it('should throw an error with invalid token', async () => {
      const connection = await createConnection({
        verified: false,
        draft: true,
      });

      const verifyCredentialsSpy = vi
        .fn()
        .mockRejectedValue(new Error('Invalid token!'));

      const originalApp = await connection.getApp();

      vi.spyOn(connection, 'getApp').mockImplementation(() => {
        return {
          ...originalApp,
          auth: {
            ...originalApp.auth,
            verifyCredentials: verifyCredentialsSpy,
          },
        };
      });

      await expect(() =>
        connection.verifyAndUpdateConnection()
      ).rejects.toThrowError('Invalid token!');
      expect(verifyCredentialsSpy).toHaveBeenCalledOnce();
    });
  });

  describe('verifyWebhook', () => {
    it('should verify webhook on remote', async () => {
      const connection = await createConnection({ key: 'typeform' });

      const verifyWebhookSpy = vi.fn().mockResolvedValue('verified-webhook');

      const originalApp = await connection.getApp();

      vi.spyOn(connection, 'getApp').mockImplementation(() => {
        return {
          ...originalApp,
          auth: {
            ...originalApp.auth,
            verifyWebhook: verifyWebhookSpy,
          },
        };
      });

      expect(await connection.verifyWebhook()).toBe('verified-webhook');
    });

    it('should return true if connection does not have value in key property', async () => {
      const connection = await createConnection({ key: null });

      expect(await connection.verifyWebhook()).toBe(true);
    });

    it('should throw an error at failed webhook verification', async () => {
      const connection = await createConnection({ key: 'typeform' });

      const verifyWebhookSpy = vi.fn().mockRejectedValue('unverified-webhook');

      const originalApp = await connection.getApp();

      vi.spyOn(connection, 'getApp').mockImplementation(() => {
        return {
          ...originalApp,
          auth: {
            ...originalApp.auth,
            verifyWebhook: verifyWebhookSpy,
          },
        };
      });

      await expect(() => connection.verifyWebhook()).rejects.toThrowError(
        'unverified-webhook'
      );
    });
  });

  it('generateAuthUrl should return authentication url', async () => {
    const connection = await createConnection({
      key: 'typeform',
      formattedData: {
        url: 'https://automatisch.io/authentication-url',
      },
    });

    const generateAuthUrlSpy = vi.fn();

    const originalApp = await connection.getApp();

    vi.spyOn(connection, 'getApp').mockImplementation(() => {
      return {
        ...originalApp,
        auth: {
          ...originalApp.auth,
          generateAuthUrl: generateAuthUrlSpy,
        },
      };
    });

    expect(await connection.generateAuthUrl()).toStrictEqual({
      url: 'https://automatisch.io/authentication-url',
    });
  });

  describe('reset', () => {
    it('should keep screen name when exists and reset the rest of the formatted data', async () => {
      const connection = await createConnection({
        formattedData: {
          screenName: 'Sample connection',
          token: 'sample-token',
        },
      });

      await connection.reset();

      const refetchedConnection = await connection.$query();

      expect(refetchedConnection.formattedData).toStrictEqual({
        screenName: 'Sample connection',
      });
    });

    it('should empty formatted data object when screen name does not exist', async () => {
      const connection = await createConnection({
        formattedData: {
          token: 'sample-token',
        },
      });

      await connection.reset();

      const refetchedConnection = await connection.$query();

      expect(refetchedConnection.formattedData).toStrictEqual({});
    });
  });

  describe('updateFormattedData', () => {
    it('should extend connection data with app auth client auth defaults', async () => {
      const appAuthClient = await createAppAuthClient({
        formattedAuthDefaults: {
          clientId: 'sample-id',
        },
      });

      const connection = await createConnection({
        appAuthClientId: appAuthClient.id,
        formattedData: {
          token: 'sample-token',
        },
      });

      const updatedConnection = await connection.updateFormattedData({
        appAuthClientId: appAuthClient.id,
      });

      expect(updatedConnection.formattedData).toStrictEqual({
        clientId: 'sample-id',
        token: 'sample-token',
      });
    });
  });

  describe('$beforeInsert', () => {
    it('should call super.$beforeInsert', async () => {
      const superBeforeInsertSpy = vi
        .spyOn(Base.prototype, '$beforeInsert')
        .mockResolvedValue();

      await createConnection();

      expect(superBeforeInsertSpy).toHaveBeenCalledOnce();
    });

    it('should call checkEligibilityForCreation', async () => {
      const checkEligibilityForCreationSpy = vi
        .spyOn(Connection.prototype, 'checkEligibilityForCreation')
        .mockResolvedValue();

      await createConnection();

      expect(checkEligibilityForCreationSpy).toHaveBeenCalledOnce();
    });

    it('should call encryptData', async () => {
      const encryptDataSpy = vi
        .spyOn(Connection.prototype, 'encryptData')
        .mockResolvedValue();

      await createConnection();

      expect(encryptDataSpy).toHaveBeenCalledOnce();
    });
  });

  describe('$beforeUpdate', () => {
    it('should call super.$beforeUpdate', async () => {
      const superBeforeUpdateSpy = vi
        .spyOn(Base.prototype, '$beforeUpdate')
        .mockResolvedValue();

      const connection = await createConnection();

      await connection.$query().patch({ verified: false });

      expect(superBeforeUpdateSpy).toHaveBeenCalledOnce();
    });

    it('should call encryptData', async () => {
      const connection = await createConnection();

      const encryptDataSpy = vi
        .spyOn(Connection.prototype, 'encryptData')
        .mockResolvedValue();

      await connection.$query().patch({ verified: false });

      expect(encryptDataSpy).toHaveBeenCalledOnce();
    });
  });

  describe('$afterFind', () => {
    it('should call decryptData', async () => {
      const connection = await createConnection();

      const decryptDataSpy = vi
        .spyOn(Connection.prototype, 'decryptData')
        .mockResolvedValue();

      await connection.$query();

      expect(decryptDataSpy).toHaveBeenCalledOnce();
    });
  });

  describe('$afterInsert', () => {
    it('should call super.$afterInsert', async () => {
      const superAfterInsertSpy = vi.spyOn(Base.prototype, '$afterInsert');

      await createConnection();

      expect(superAfterInsertSpy).toHaveBeenCalledOnce();
    });

    it('should call Telemetry.connectionCreated', async () => {
      const telemetryConnectionCreatedSpy = vi
        .spyOn(Telemetry, 'connectionCreated')
        .mockImplementation(() => {});

      const connection = await createConnection();

      expect(telemetryConnectionCreatedSpy).toHaveBeenCalledWith(connection);
    });
  });

  describe('$afterUpdate', () => {
    it('should call super.$afterUpdate', async () => {
      const superAfterInsertSpy = vi.spyOn(Base.prototype, '$afterUpdate');

      const connection = await createConnection();

      await connection.$query().patch({ verified: false });

      expect(superAfterInsertSpy).toHaveBeenCalledOnce();
    });

    it('should call Telemetry.connectionUpdated', async () => {
      const telemetryconnectionUpdatedSpy = vi
        .spyOn(Telemetry, 'connectionCreated')
        .mockImplementation(() => {});

      const connection = await createConnection();

      await connection.$query().patch({ verified: false });

      expect(telemetryconnectionUpdatedSpy).toHaveBeenCalledWith(connection);
    });
  });
});
