import { describe, it, expect, vi } from 'vitest';
import Flow from './flow.js';
import User from './user.js';
import Base from './base.js';
import Step from './step.js';
import Execution from './execution.js';
import Telemetry from '../helpers/telemetry/index.js';
import * as globalVariableModule from '../helpers/global-variable.js';
import { createFlow } from '../../test/factories/flow.js';
import { createStep } from '../../test/factories/step.js';
import { createExecution } from '../../test/factories/execution.js';
import { createExecutionStep } from '../../test/factories/execution-step.js';

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

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

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

      const expectedRelations = {
        steps: {
          relation: Base.HasManyRelation,
          modelClass: Step,
          join: {
            from: 'flows.id',
            to: 'steps.flow_id',
          },
          filter: expect.any(Function),
        },
        triggerStep: {
          relation: Base.HasOneRelation,
          modelClass: Step,
          join: {
            from: 'flows.id',
            to: 'steps.flow_id',
          },
          filter: expect.any(Function),
        },
        executions: {
          relation: Base.HasManyRelation,
          modelClass: Execution,
          join: {
            from: 'flows.id',
            to: 'executions.flow_id',
          },
        },
        lastExecution: {
          relation: Base.HasOneRelation,
          modelClass: Execution,
          join: {
            from: 'flows.id',
            to: 'executions.flow_id',
          },
          filter: expect.any(Function),
        },
        user: {
          relation: Base.HasOneRelation,
          modelClass: User,
          join: {
            from: 'flows.user_id',
            to: 'users.id',
          },
        },
      };

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

    it('steps should return the steps', () => {
      const relations = Flow.relationMappings();
      const orderBySpy = vi.fn();

      relations.steps.filter({ orderBy: orderBySpy });

      expect(orderBySpy).toHaveBeenCalledWith('position', 'asc');
    });

    it('triggerStep should return the trigger step', () => {
      const relations = Flow.relationMappings();

      const firstSpy = vi.fn();

      const limitSpy = vi.fn().mockImplementation(() => ({
        first: firstSpy,
      }));

      const whereSpy = vi.fn().mockImplementation(() => ({
        limit: limitSpy,
      }));

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

      expect(whereSpy).toHaveBeenCalledWith('type', 'trigger');
      expect(limitSpy).toHaveBeenCalledWith(1);
      expect(firstSpy).toHaveBeenCalledOnce();
    });

    it('lastExecution should return the last execution', () => {
      const relations = Flow.relationMappings();

      const firstSpy = vi.fn();

      const limitSpy = vi.fn().mockImplementation(() => ({
        first: firstSpy,
      }));

      const orderBySpy = vi.fn().mockImplementation(() => ({
        limit: limitSpy,
      }));

      relations.lastExecution.filter({ orderBy: orderBySpy });

      expect(orderBySpy).toHaveBeenCalledWith('created_at', 'desc');
      expect(limitSpy).toHaveBeenCalledWith(1);
      expect(firstSpy).toHaveBeenCalledOnce();
    });
  });

  describe('populateStatusProperty', () => {
    it('should assign "draft" to status property when a flow is not active', async () => {
      const referenceFlow = await createFlow({ active: false });

      const flows = [referenceFlow];

      vi.spyOn(referenceFlow, 'isPaused').mockResolvedValue();

      await Flow.populateStatusProperty(flows);

      expect(referenceFlow.status).toBe('draft');
    });

    it('should assign "paused" to status property when a flow is active, but should be paused', async () => {
      const referenceFlow = await createFlow({ active: true });

      const flows = [referenceFlow];

      vi.spyOn(referenceFlow, 'isPaused').mockResolvedValue(true);

      await Flow.populateStatusProperty(flows);

      expect(referenceFlow.status).toBe('paused');
    });

    it('should assign "published" to status property when a flow is active', async () => {
      const referenceFlow = await createFlow({ active: true });

      const flows = [referenceFlow];

      vi.spyOn(referenceFlow, 'isPaused').mockResolvedValue(false);

      await Flow.populateStatusProperty(flows);

      expect(referenceFlow.status).toBe('published');
    });
  });

  it('afterFind should call Flow.populateStatusProperty', async () => {
    const populateStatusPropertySpy = vi
      .spyOn(Flow, 'populateStatusProperty')
      .mockImplementation(() => {});

    await createFlow();

    expect(populateStatusPropertySpy).toHaveBeenCalledOnce();
  });

  describe('lastInternalId', () => {
    it('should return internal ID of last execution when exists', async () => {
      const flow = await createFlow();

      await createExecution({ flowId: flow.id });
      await createExecution({ flowId: flow.id });
      const lastExecution = await createExecution({ flowId: flow.id });

      expect(await flow.lastInternalId()).toBe(lastExecution.internalId);
    });

    it('should return null when no flow execution exists', async () => {
      const flow = await createFlow();

      expect(await flow.lastInternalId()).toBe(null);
    });
  });

  describe('lastInternalIds', () => {
    it('should return last internal IDs', async () => {
      const flow = await createFlow();

      const internalIds = [
        await createExecution({ flowId: flow.id }),
        await createExecution({ flowId: flow.id }),
        await createExecution({ flowId: flow.id }),
      ].map((execution) => execution.internalId);

      expect(await flow.lastInternalIds()).toStrictEqual(internalIds);
    });

    it('should return last 50 internal IDs by default', async () => {
      const flow = new Flow();

      const limitSpy = vi.fn().mockResolvedValue([]);

      vi.spyOn(flow, '$relatedQuery').mockReturnValue({
        select: vi.fn().mockReturnThis(),
        orderBy: vi.fn().mockReturnThis(),
        limit: limitSpy,
      });

      await flow.lastInternalIds();

      expect(limitSpy).toHaveBeenCalledWith(50);
    });
  });

  it('IncompleteStepsError should return validation error for incomplete steps', () => {
    expect(() => {
      throw Flow.IncompleteStepsError;
    }).toThrowError(
      'flow: All steps should be completed before updating flow status!'
    );
  });

  it('createInitialSteps should create one trigger and one action step', async () => {
    const flow = await createFlow();
    await flow.createInitialSteps();
    const steps = await flow.$relatedQuery('steps');

    expect(steps.length).toBe(2);

    expect(steps[0]).toMatchObject({
      flowId: flow.id,
      type: 'trigger',
      position: 1,
    });

    expect(steps[1]).toMatchObject({
      flowId: flow.id,
      type: 'action',
      position: 2,
    });
  });

  it('getStepById should return the step with the given ID from the flow', async () => {
    const flow = await createFlow();

    const step = await createStep({ flowId: flow.id });

    expect(await flow.getStepById(step.id)).toStrictEqual(step);
  });

  it('insertActionStepAtPosition should insert action step at given position', async () => {
    const flow = await createFlow();

    await flow.createInitialSteps();

    const createdStep = await flow.insertActionStepAtPosition(2);

    expect(createdStep).toMatchObject({
      type: 'action',
      position: 2,
    });
  });

  it('getStepsAfterPosition should return steps after the given position', async () => {
    const flow = await createFlow();

    await flow.createInitialSteps();

    await createStep({ flowId: flow.id });

    expect(await flow.getStepsAfterPosition(1)).toMatchObject([
      { position: 2 },
      { position: 3 },
    ]);
  });

  it('updateStepPositionsFrom', async () => {
    const flow = await createFlow();

    await createStep({ type: 'trigger', flowId: flow.id, position: 6 });
    await createStep({ type: 'action', flowId: flow.id, position: 8 });
    await createStep({ type: 'action', flowId: flow.id, position: 10 });

    await flow.updateStepPositionsFrom(2, await flow.$relatedQuery('steps'));

    expect(await flow.$relatedQuery('steps')).toMatchObject([
      { position: 2, type: 'trigger' },
      { position: 3, type: 'action' },
      { position: 4, type: 'action' },
    ]);
  });

  it('createStepAfter should create an action step after given step ID', async () => {
    const flow = await createFlow();

    const triggerStep = await createStep({ type: 'trigger', flowId: flow.id });
    const actionStep = await createStep({ type: 'action', flowId: flow.id });

    const createdStep = await flow.createStepAfter(triggerStep.id);

    const refetchedActionStep = await actionStep.$query();

    expect(createdStep).toMatchObject({ type: 'action', position: 2 });
    expect(refetchedActionStep.position).toBe(3);
  });

  describe('unregisterWebhook', () => {
    it('should unregister webhook on remote when supported', async () => {
      const flow = await createFlow();
      const triggerStep = await createStep({
        flowId: flow.id,
        appKey: 'typeform',
        key: 'new-entry',
        type: 'trigger',
      });

      const unregisterHookSpy = vi.fn().mockResolvedValue();

      vi.spyOn(Step.prototype, 'getTriggerCommand').mockResolvedValue({
        type: 'webhook',
        unregisterHook: unregisterHookSpy,
      });

      const globalVariableSpy = vi
        .spyOn(globalVariableModule, 'default')
        .mockResolvedValue('global-variable');

      await flow.unregisterWebhook();

      expect(unregisterHookSpy).toHaveBeenCalledWith('global-variable');
      expect(globalVariableSpy).toHaveBeenCalledWith({
        flow,
        step: triggerStep,
        connection: undefined,
        app: await triggerStep.getApp(),
      });
    });

    it('should silently fail when unregistration fails', async () => {
      const flow = await createFlow();
      await createStep({
        flowId: flow.id,
        appKey: 'typeform',
        key: 'new-entry',
        type: 'trigger',
      });

      const unregisterHookSpy = vi.fn().mockRejectedValue(new Error());

      vi.spyOn(Step.prototype, 'getTriggerCommand').mockResolvedValue({
        type: 'webhook',
        unregisterHook: unregisterHookSpy,
      });

      expect(await flow.unregisterWebhook()).toBe(undefined);
      expect(unregisterHookSpy).toHaveBeenCalledOnce();
    });

    it('should do nothing when trigger step is not webhook', async () => {
      const flow = await createFlow();
      await createStep({
        flowId: flow.id,
        type: 'trigger',
      });

      const unregisterHookSpy = vi.fn().mockRejectedValue(new Error());

      expect(await flow.unregisterWebhook()).toBe(undefined);
      expect(unregisterHookSpy).not.toHaveBeenCalled();
    });
  });

  it('deleteExecutionSteps should delete related execution steps', async () => {
    const flow = await createFlow();
    const execution = await createExecution({ flowId: flow.id });
    const firstExecutionStep = await createExecutionStep({
      executionId: execution.id,
    });
    const secondExecutionStep = await createExecutionStep({
      executionId: execution.id,
    });

    await flow.deleteExecutionSteps();

    expect(await firstExecutionStep.$query()).toBe(undefined);
    expect(await secondExecutionStep.$query()).toBe(undefined);
  });

  it('deleteExecutions should delete related executions', async () => {
    const flow = await createFlow();
    const firstExecution = await createExecution({ flowId: flow.id });
    const secondExecution = await createExecution({ flowId: flow.id });

    await flow.deleteExecutions();

    expect(await firstExecution.$query()).toBe(undefined);
    expect(await secondExecution.$query()).toBe(undefined);
  });

  it('deleteSteps should delete related steps', async () => {
    const flow = await createFlow();
    await flow.createInitialSteps();
    await flow.deleteSteps();

    expect(await flow.$relatedQuery('steps')).toStrictEqual([]);
  });

  it('delete should delete the flow with its relations', async () => {
    const flow = await createFlow();

    const unregisterWebhookSpy = vi
      .spyOn(flow, 'unregisterWebhook')
      .mockResolvedValue();
    const deleteExecutionStepsSpy = vi
      .spyOn(flow, 'deleteExecutionSteps')
      .mockResolvedValue();
    const deleteExecutionsSpy = vi
      .spyOn(flow, 'deleteExecutions')
      .mockResolvedValue();
    const deleteStepsSpy = vi.spyOn(flow, 'deleteSteps').mockResolvedValue();

    await flow.delete();

    expect(unregisterWebhookSpy).toHaveBeenCalledOnce();
    expect(deleteExecutionStepsSpy).toHaveBeenCalledOnce();
    expect(deleteExecutionsSpy).toHaveBeenCalledOnce();
    expect(deleteStepsSpy).toHaveBeenCalledOnce();
    expect(await flow.$query()).toBe(undefined);
  });

  it.todo('duplicateFor');

  it('getTriggerStep', async () => {
    const flow = await createFlow();
    const triggerStep = await createStep({ flowId: flow.id, type: 'trigger' });

    await createStep({ flowId: flow.id, type: 'action' });

    expect(await flow.getTriggerStep()).toStrictEqual(triggerStep);
  });

  describe('isPaused', () => {
    it('should return true when user.isAllowedToRunFlows returns false', async () => {
      const flow = await createFlow();

      const isAllowedToRunFlowsSpy = vi.fn().mockResolvedValue(false);
      vi.spyOn(flow, '$relatedQuery').mockReturnValue({
        withSoftDeleted: vi.fn().mockReturnThis(),
        isAllowedToRunFlows: isAllowedToRunFlowsSpy,
      });

      expect(await flow.isPaused()).toBe(true);
      expect(isAllowedToRunFlowsSpy).toHaveBeenCalledOnce();
    });

    it('should return false when user.isAllowedToRunFlows returns true', async () => {
      const flow = await createFlow();

      const isAllowedToRunFlowsSpy = vi.fn().mockResolvedValue(true);
      vi.spyOn(flow, '$relatedQuery').mockReturnValue({
        withSoftDeleted: vi.fn().mockReturnThis(),
        isAllowedToRunFlows: isAllowedToRunFlowsSpy,
      });

      expect(await flow.isPaused()).toBe(false);
      expect(isAllowedToRunFlowsSpy).toHaveBeenCalledOnce();
    });
  });

  describe('throwIfHavingIncompleteSteps', () => {
    it('should throw validation error with incomplete steps', async () => {
      const flow = await createFlow();

      await flow.createInitialSteps();

      await expect(() =>
        flow.throwIfHavingIncompleteSteps()
      ).rejects.toThrowError(
        'flow: All steps should be completed before updating flow status!'
      );
    });

    it('should return undefined when all steps are completed', async () => {
      const flow = await createFlow();

      await createStep({
        flowId: flow.id,
        status: 'completed',
        type: 'trigger',
      });

      await createStep({
        flowId: flow.id,
        status: 'completed',
        type: 'action',
      });

      expect(await flow.throwIfHavingIncompleteSteps()).toBe(undefined);
    });
  });

  describe('throwIfHavingLessThanTwoSteps', () => {
    it('should throw validation error with less than two steps', async () => {
      const flow = await createFlow();

      await expect(() =>
        flow.throwIfHavingLessThanTwoSteps()
      ).rejects.toThrowError(
        'flow: There should be at least one trigger and one action steps in the flow!'
      );
    });

    it('should return undefined when there are at least two steps', async () => {
      const flow = await createFlow();

      await createStep({
        flowId: flow.id,
        type: 'trigger',
      });

      await createStep({
        flowId: flow.id,
        type: 'action',
      });

      expect(await flow.throwIfHavingLessThanTwoSteps()).toBe(undefined);
    });
  });

  describe('$beforeUpdate', () => {
    it('should invoke throwIfHavingIncompleteSteps when flow is becoming active', async () => {
      const flow = await createFlow({ active: false });

      const throwIfHavingIncompleteStepsSpy = vi
        .spyOn(Flow.prototype, 'throwIfHavingIncompleteSteps')
        .mockImplementation(() => {});

      const throwIfHavingLessThanTwoStepsSpy = vi
        .spyOn(Flow.prototype, 'throwIfHavingLessThanTwoSteps')
        .mockImplementation(() => {});

      await flow.$query().patch({ active: true });

      expect(throwIfHavingIncompleteStepsSpy).toHaveBeenCalledOnce();
      expect(throwIfHavingLessThanTwoStepsSpy).toHaveBeenCalledOnce();
    });

    it('should invoke throwIfHavingIncompleteSteps when flow is not becoming active', async () => {
      const flow = await createFlow({ active: true });

      const throwIfHavingIncompleteStepsSpy = vi
        .spyOn(Flow.prototype, 'throwIfHavingIncompleteSteps')
        .mockImplementation(() => {});

      const throwIfHavingLessThanTwoStepsSpy = vi
        .spyOn(Flow.prototype, 'throwIfHavingLessThanTwoSteps')
        .mockImplementation(() => {});

      await flow.$query().patch({});

      expect(throwIfHavingIncompleteStepsSpy).not.toHaveBeenCalledOnce();
      expect(throwIfHavingLessThanTwoStepsSpy).not.toHaveBeenCalledOnce();
    });
  });

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

      await createFlow();

      expect(superAfterInsertSpy).toHaveBeenCalled();
    });

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

      const flow = await createFlow();

      expect(telemetryFlowCreatedSpy).toHaveBeenCalledWith(flow);
    });
  });

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

      const flow = await createFlow();

      await flow.$query().patch({ active: false });

      expect(superAfterUpdateSpy).toHaveBeenCalledOnce();
    });

    it('$afterUpdate should call Telemetry.flowUpdated', async () => {
      const telemetryFlowUpdatedSpy = vi
        .spyOn(Telemetry, 'flowUpdated')
        .mockImplementation(() => {});

      const flow = await createFlow();

      await flow.$query().patch({ active: false });

      expect(telemetryFlowUpdatedSpy).toHaveBeenCalled({});
    });
  });
});
