import { of, throwError } from 'rxjs';
import { delay } from 'rxjs/operators';

import {
  type DataSourceApi,
  getDefaultTimeRange,
  LoadingState,
  type QueryVariableModel,
  VariableSupportType,
} from '@grafana/data';

import { queryBuilder } from '../shared/testing/builders';
import { getPreloadedState } from '../state/helpers';
import { toKeyedAction } from '../state/keyedVariablesReducer';
import { initialTransactionState } from '../state/transactionReducer';
import { type KeyedVariableIdentifier } from '../state/types';
import { toKeyedVariableIdentifier } from '../utils';

import { type UpdateOptionsResults, VariableQueryRunner } from './VariableQueryRunner';
import { type QueryRunner, type QueryRunners } from './queryRunners';
import { updateVariableOptions } from './reducer';

function expectOnResults(args: {
  runner: VariableQueryRunner;
  identifier: KeyedVariableIdentifier;
  done: jest.DoneCallback;
  expect: (results: UpdateOptionsResults[]) => void;
}) {
  const { runner, identifier, done, expect: expectCallback } = args;
  const results: UpdateOptionsResults[] = [];
  const subscription = runner.getResponse(identifier).subscribe({
    next: (value) => {
      results.push(value);
      if (value.state === LoadingState.Done || value.state === LoadingState.Error) {
        try {
          expectCallback(results);
          subscription.unsubscribe();
          done();
        } catch (err) {
          subscription.unsubscribe();
          done(err);
        }
      }
    },
  });
}

function getTestContext(variable?: QueryVariableModel) {
  const getTimeSrv = jest.fn().mockReturnValue({
    timeRange: jest.fn().mockReturnValue(getDefaultTimeRange()),
  });
  const key = '0123456789';
  variable = variable ?? queryBuilder().withId('query').withRootStateKey(key).withName('query').build();
  const datasource = { metricFindQuery: jest.fn().mockResolvedValue([]) } as unknown as DataSourceApi;
  const identifier = toKeyedVariableIdentifier(variable);
  const searchFilter = undefined;
  const getTemplatedRegex = jest.fn().mockReturnValue('getTemplatedRegex result');
  const dispatch = jest.fn().mockResolvedValue({});
  const templatingState = {
    transaction: { ...initialTransactionState, uid: key },
    variables: {
      [variable.id]: variable,
    },
  };
  const getState = jest.fn().mockReturnValue(getPreloadedState(key, templatingState));
  const queryRunner: QueryRunner = {
    type: VariableSupportType.Standard,
    canRun: jest.fn().mockReturnValue(true),
    getTarget: jest.fn().mockReturnValue({ refId: 'A', query: 'A query' }),
    runRequest: jest.fn().mockReturnValue(of({ series: [], state: LoadingState.Done })),
  };
  const queryRunners = {
    isQueryRunnerAvailableForDatasource: jest.fn().mockReturnValue(true),
    getRunnerForDatasource: jest.fn().mockReturnValue(queryRunner),
  } as unknown as QueryRunners;
  const getVariable = jest.fn().mockReturnValue(variable);
  const runRequest = jest.fn().mockReturnValue(of({}));
  const runner = new VariableQueryRunner({
    getTimeSrv,
    getTemplatedRegex,
    dispatch,
    getState,
    getVariable,
    queryRunners,
    runRequest,
  });

  return {
    key,
    identifier,
    datasource,
    runner,
    searchFilter,
    getTemplatedRegex,
    dispatch,
    getState,
    queryRunner,
    queryRunners,
    getVariable,
    runRequest,
    variable,
    getTimeSrv,
  };
}

describe('VariableQueryRunner', () => {
  describe('happy case', () => {
    it('then it should work as expected', (done) => {
      const { key, identifier, runner, datasource, getState, getVariable, queryRunners, queryRunner, dispatch } =
        getTestContext();

      expectOnResults({
        identifier,
        runner,
        expect: (results) => {
          // verify that the observable works as expected
          expect(results).toEqual([
            { state: LoadingState.Loading, identifier },
            { state: LoadingState.Done, identifier },
          ]);

          // verify that mocks have been called as expected
          expect(getState).toHaveBeenCalledTimes(3);
          expect(getVariable).toHaveBeenCalledTimes(1);
          expect(queryRunners.getRunnerForDatasource).toHaveBeenCalledTimes(1);
          expect(queryRunner.getTarget).toHaveBeenCalledTimes(1);
          expect(queryRunner.runRequest).toHaveBeenCalledTimes(1);
          expect(datasource.metricFindQuery).not.toHaveBeenCalled();

          // updateVariableOptions and validateVariableSelectionState
          expect(dispatch).toHaveBeenCalledTimes(2);
          expect(dispatch.mock.calls[0][0]).toEqual(
            toKeyedAction(
              key,
              updateVariableOptions({
                id: 'query',
                type: 'query',
                data: { results: [], templatedRegex: 'getTemplatedRegex result' },
              })
            )
          );
        },
        done,
      });

      runner.queueRequest({ identifier, datasource });
    });
  });

  describe('error cases', () => {
    describe('queryRunners.isQueryRunnerAvailableForDatasource throws', () => {
      it('then it should work as expected', (done) => {
        const { identifier, runner, datasource, getState, getVariable, queryRunners, queryRunner, dispatch } =
          getTestContext();

        queryRunners.isQueryRunnerAvailableForDatasource = jest.fn().mockReturnValue(false);
        expectOnResults({
          identifier,
          runner,
          expect: (results) => {
            // verify that the observable works as expected
            expect(results).toEqual([
              { state: LoadingState.Loading, identifier },
              {
                state: LoadingState.Error,
                identifier,
                error: new Error('Query Runner is not available for datasource.'),
              },
            ]);

            // verify that mocks have been called as expected
            expect(getState).toHaveBeenCalledTimes(2);
            expect(getVariable).toHaveBeenCalledTimes(1);
            expect(queryRunners.isQueryRunnerAvailableForDatasource).toHaveBeenCalledTimes(1);
            expect(queryRunner.getTarget).not.toHaveBeenCalled();
            expect(queryRunner.runRequest).not.toHaveBeenCalled();
            expect(datasource.metricFindQuery).not.toHaveBeenCalled();
            expect(dispatch).not.toHaveBeenCalled();
          },
          done,
        });

        runner.queueRequest({ identifier, datasource });
      });
    });

    describe('runRequest throws', () => {
      it('then it should work as expected', (done) => {
        const { identifier, runner, datasource, getState, getVariable, queryRunners, queryRunner, dispatch } =
          getTestContext();

        queryRunner.runRequest = jest.fn().mockReturnValue(throwError(new Error('runRequest error')));

        expectOnResults({
          identifier,
          runner,
          expect: (results) => {
            // verify that the observable works as expected
            expect(results).toEqual([
              { state: LoadingState.Loading, identifier },
              { state: LoadingState.Error, identifier, error: new Error('runRequest error') },
            ]);

            // verify that mocks have been called as expected
            expect(getState).toHaveBeenCalledTimes(2);
            expect(getVariable).toHaveBeenCalledTimes(1);
            expect(queryRunners.getRunnerForDatasource).toHaveBeenCalledTimes(1);
            expect(queryRunner.getTarget).toHaveBeenCalledTimes(1);
            expect(queryRunner.runRequest).toHaveBeenCalledTimes(1);
            expect(datasource.metricFindQuery).not.toHaveBeenCalled();
            expect(dispatch).not.toHaveBeenCalled();
          },
          done,
        });

        runner.queueRequest({ identifier, datasource });
      });
    });
  });

  describe('cancellation cases', () => {
    describe('long running request is cancelled', () => {
      it('then it should work as expected', (done) => {
        const { identifier, datasource, runner, queryRunner } = getTestContext();

        queryRunner.runRequest = jest
          .fn()
          .mockReturnValue(of({ series: [], state: LoadingState.Done }).pipe(delay(10000)));

        expectOnResults({
          identifier,
          runner,
          expect: (results) => {
            // verify that the observable works as expected
            expect(results).toEqual([
              { state: LoadingState.Loading, identifier },
              { state: LoadingState.Loading, identifier, cancelled: true },
              { state: LoadingState.Done, identifier },
            ]);
          },
          done,
        });

        runner.queueRequest({ identifier, datasource });
        runner.cancelRequest(identifier);
      });
    });

    describe('an identical request is triggered before first request is finished', () => {
      it('then it should work as expected', (done) => {
        const { identifier, datasource, runner, queryRunner } = getTestContext();

        queryRunner.runRequest = jest
          .fn()
          .mockReturnValueOnce(of({ series: [], state: LoadingState.Done }).pipe(delay(10000)))
          .mockReturnValue(of({ series: [], state: LoadingState.Done }));

        expectOnResults({
          identifier,
          runner,
          expect: (results) => {
            // verify that the observable works as expected
            expect(results).toEqual([
              { state: LoadingState.Loading, identifier },
              { state: LoadingState.Loading, identifier },
              { state: LoadingState.Loading, identifier, cancelled: true },
              { state: LoadingState.Done, identifier },
            ]);
          },
          done,
        });

        runner.queueRequest({ identifier, datasource });
        runner.queueRequest({ identifier, datasource });
      });
    });
  });
});
