'use strict';

const root = '../../../..';

const mockrequire = require('mock-require');
const should = require('should');
const sinon = require('sinon');
const _ = require('lodash');
const { Client: ESClient } = require('@elastic/elasticsearch');
const {
  Request,
  KuzzleError,
  UnauthorizedError,
  TooManyRequestsError,
  SizeLimitError,
  ServiceUnavailableError,
  PreconditionError,
  PluginImplementationError,
  PartialError,
  NotFoundError,
  InternalError,
  GatewayTimeoutError,
  ForbiddenError,
  ExternalServiceError,
  BadRequestError,
} = require('kuzzle-common-objects');

const KuzzleMock = require(`${root}/test/mocks/kuzzle.mock`);
const { EmbeddedSDK } = require('../../../../lib/core/shared/sdk/embeddedSdk');

describe('Plugin Context', () => {
  const someCollection = 'someCollection';
  let kuzzle;
  let context;
  let PluginContext;

  beforeEach(() => {
    PluginContext = mockrequire.reRequire(`${root}/lib/core/plugin/pluginContext`);

    kuzzle = new KuzzleMock();
    context = new PluginContext(kuzzle, 'pluginName');
  });

  afterEach(() => {
    mockrequire.stopAll();
  });

  describe('#constructor', () => {
    it('should be an instance of a PluginContext object', () => {
      should(context).be.an.instanceOf(PluginContext);
    });

    it('should expose the right constructors', () => {
      let repository;
      const Koncorde = require('koncorde');

      should(context.constructors).be.an.Object().and.not.be.empty();
      should(context.constructors.Koncorde).be.a.Function();
      should(context.constructors.Request).be.a.Function();
      should(context.constructors.RequestContext).be.a.Function();
      should(context.constructors.RequestInput).be.a.Function();
      should(context.constructors.BaseValidationType).be.a.Function();
      should(context.constructors.Repository).be.a.Function();

      should(new context.constructors.Koncorde).be.instanceOf(Koncorde);
      should(new context.constructors.Request(new Request({}), {})).be.instanceOf(Request);

      repository = new context.constructors.Repository(someCollection);

      should(repository.search).be.a.Function();
      should(repository.get).be.a.Function();
      should(repository.mGet).be.a.Function();
      should(repository.delete).be.a.Function();
      should(repository.create).be.a.Function();
      should(repository.createOrReplace).be.a.Function();
      should(repository.replace).be.a.Function();
      should(repository.update).be.a.Function();
    });

    it('should exposes secrets from vault', () => {
      should(context.secrets)
        .not.be.undefined()
        .match({
          aws: {
            secretKeyId: 'the cake is a lie'
          },
          kuzzleApi: 'the spoon does not exist'
        });
    });

    describe('#ESClient', () => {
      it('should expose the ESClient constructor', () => {
        const esClient = new context.constructors.ESClient();

        should(esClient).be.instanceOf(ESClient);
      });

      it('should allow to instantiate an ESClient connected to the ES cluster', () => {
        const esClient = new context.constructors.ESClient();

        should(esClient.connectionPool.connections[0].url.origin)
          .be.eql(kuzzle.storageEngine.config.client.node);
      });
    });

    describe('#Request', () => {
      it('should throw when trying to instantiate a Request object without providing any data', () => {
        should(function () { new context.constructors.Request(); })
          .throw(PluginImplementationError, { id: 'plugin.context.missing_request_data' });
      });

      it('should replicate the right request information', () => {
        let
          request = new Request({
            action: 'action',
            controller: 'controller',
            foobar: 'foobar',
            _id: '_id',
            index: 'index',
            collection: 'collection',
            result: 'result',
            error: new Error('error'),
            status: 666,
            jwt: 'jwt',
            volatile: {foo: 'bar'}
          }, {
            protocol: 'protocol',
            connectionId: 'connectionId'
          }),
          pluginRequest = new context.constructors.Request(request, {});

        should(pluginRequest.context.protocol).be.eql(request.context.protocol);
        should(pluginRequest.context.connectionId).be.eql(request.context.connectionId);
        should(pluginRequest.result).be.null();
        should(pluginRequest.error).be.null();
        should(pluginRequest.status).be.eql(102);
        should(pluginRequest.input.action).be.null();
        should(pluginRequest.input.controller).be.null();
        should(pluginRequest.input.jwt).be.eql(request.input.jwt);
        should(pluginRequest.input.args.foobar).be.eql(request.input.args.foobar);
        should(pluginRequest.input.resource._id).be.eql(request.input.resource._id);
        should(pluginRequest.input.resource.index).be.eql(request.input.resource.index);
        should(pluginRequest.input.resource.collection).be.eql(request.input.resource.collection);
        should(pluginRequest.input.volatile).match({foo: 'bar'});
      });

      it('should override origin request data with provided ones', () => {
        let
          request = new Request({
            action: 'action',
            controller: 'controller',
            foo: 'foo',
            bar: 'bar',
            _id: '_id',
            index: 'index',
            collection: 'collection',
            result: 'result',
            error: new Error('error'),
            status: 666,
            jwt: 'jwt',
            volatile: {foo: 'bar'}
          }, {
            protocol: 'protocol',
            connectionId: 'connectionId'
          }),
          pluginRequest = new context.constructors.Request(request, {
            action: 'pluginAction',
            controller: 'pluginController',
            foo: false,
            from: 0,
            size: 99,
            collection: 'pluginCollection',
            jwt: null,
            volatile: {foo: 'overridden', bar: 'baz'}
          });

        should(pluginRequest.context.protocol).be.eql('protocol');
        should(pluginRequest.context.connectionId).be.eql('connectionId');
        should(pluginRequest.result).be.null();
        should(pluginRequest.error).be.null();
        should(pluginRequest.status).be.eql(102);
        should(pluginRequest.input.action).be.eql('pluginAction');
        should(pluginRequest.input.controller).be.eql('pluginController');
        should(pluginRequest.input.jwt).be.null();
        should(pluginRequest.input.args.foo).be.eql(false);
        should(pluginRequest.input.args.bar).be.eql('bar');
        should(pluginRequest.input.args.from).be.eql(0);
        should(pluginRequest.input.args.size).be.eql(99);
        should(pluginRequest.input.resource._id).be.eql('_id');
        should(pluginRequest.input.resource.index).be.eql('index');
        should(pluginRequest.input.resource.collection).be.eql('pluginCollection');
        should(pluginRequest.input.volatile).match({foo: 'overridden', bar: 'baz'});
      });

      it('should allow building a request without providing another one', () => {
        const rq = new context.constructors.Request({controller: 'foo', action: 'bar'});

        should(rq).be.instanceOf(Request);
        should(rq.input.action).be.eql('bar');
        should(rq.input.controller).be.eql('foo');
      });
    });

    it('should expose all error objects as capitalized constructors', () => {
      const errors = {
        KuzzleError,
        UnauthorizedError,
        TooManyRequestsError,
        SizeLimitError,
        ServiceUnavailableError,
        PreconditionError,
        PluginImplementationError,
        PartialError,
        NotFoundError,
        InternalError,
        GatewayTimeoutError,
        ForbiddenError,
        ExternalServiceError,
        BadRequestError,
      };

      should(context.errors).be.an.Object().and.not.be.empty();

      _.forOwn(errors, (constructor, name) => {
        should(context.errors[name]).be.a.Function();
        should(new context.errors[name]('foo')).be.instanceOf(constructor);
      });
    });

    it('should expose the right accessors', () => {
      [
        'silly',
        'verbose',
        'info',
        'debug',
        'warn',
        'error'
      ].forEach(level => {
        should(context.log[level]).be.an.instanceOf(Function);

        context.log[level]('test');

        should(kuzzle.log[level])
          .calledOnce()
          .calledWithExactly('[pluginName] test');
      });

      should(context.accessors).be.an.Object().and.not.be.empty();
      should(context.accessors).have.properties(
        ['execute', 'validation', 'storage', 'trigger', 'strategies', 'sdk']);
    });

    it('should adds the plugin name in logs', done => {
      context.log.info('foobar');

      process.nextTick(() => {
        try {
          should(kuzzle.log.info)
            .be.calledOnce()
            .be.calledWith('[pluginName] foobar');

          done();
        }
        catch (e) {
          done(e);
        }
      });
    });

    it('should expose a data validation accessor', () => {
      const validation = context.accessors.validation;

      should(validation.addType)
        .be.eql(kuzzle.validation.addType.bind(kuzzle.validation));
      should(validation.validate)
        .be.eql(kuzzle.validation.validate.bind(kuzzle.validation));
    });

    it('should expose an API execution accessor', () => {
      const execute = context.accessors.execute;

      should(execute).be.a.Function();
    });

    it('should expose an event trigger accessor', () => {
      const trigger = context.accessors.trigger;

      should(trigger).be.a.Function();
    });

    it('should expose a private storage accessor', () => {
      const storage = context.accessors.storage;

      should(storage.bootstrap).be.a.Function();
      should(storage.createCollection).be.a.Function();
    });

    it('should expose an authentication strategies management accessor', () => {
      const strategies = context.accessors.strategies;

      should(strategies.add).be.a.Function();
      should(strategies.remove).be.a.Function();
    });

    it('should expose a EmbeddedSDK in accessors', () => {
      const sdk = context.accessors.sdk;

      should(sdk).be.instanceOf(EmbeddedSDK);
    });

    describe('#trigger', () => {
      it('should call trigger with the given event name and payload and return pipe chain result', async () => {
        kuzzle.pipe.resolves('pipe chain result');
        const eventName = 'backHome';
        const payload = {
          question: 'whose motorcycle is this?',
          answer: 'it\'s a chopper, baby.',
          anotherQuestion: 'whose chopper is this, then?',
          anotherAnswer: 'it\'s Zed\'s',
          yetAnotherQuestion: 'who\'s Zed?',
          yetAnotherAnswer: 'Zed\'s dead, baby, Zed\'s dead.'
        };

        const result = await context.accessors.trigger(eventName, payload);

        should(result).be.eql('pipe chain result');
        should(kuzzle.pipe)
          .be.calledWithExactly(`plugin-pluginName:${eventName}`, payload);
      });
    });

    describe('#execute', () => {
      it('should call the callback with a result if everything went well', done => {
        const
          request = new Request({requestId: 'request'}, {connectionId: 'connectionid'}),
          result = {foo: 'bar'},
          callback = sinon.spy((err, res) => {
            try {
              should(callback).be.calledOnce();
              should(err).be.null();
              should(res).match(request);
              should(res.result).be.equal(result);
              should(kuzzle.funnel.executePluginRequest).calledWith(request);
              done();
            }
            catch(e) {
              done(e);
            }
          });

        kuzzle.funnel.executePluginRequest.resolves(result);

        should(context.accessors.execute(request, callback)).not.be.a.Promise();
      });

      it('should resolve a Promise with a result if everything went well', () => {
        const
          request = new Request({requestId: 'request'}, {connectionId: 'connectionid'}),
          result = {foo: 'bar'};

        kuzzle.funnel.executePluginRequest.resolves(result);

        const ret = context.accessors.execute(request);

        should(ret).be.a.Promise();

        return ret
          .then(res => {
            should(res).match(request);
            should(res.result).be.equal(result);
            should(kuzzle.funnel.executePluginRequest).calledWith(request);
          });
      });

      it('should call the callback with an error if something went wrong', done => {
        const
          request = new Request({body: {some: 'request'}}, {connectionId: 'connectionid'}),
          error = new Error('error'),
          callback = sinon.spy(
            (err, res) => {
              try {
                should(kuzzle.funnel.executePluginRequest).calledWith(request);
                should(callback).be.calledOnce();
                should(err).match(error);
                should(res).be.undefined();
                done();
              }
              catch(e) {
                done(e);
              }
            });

        kuzzle.funnel.executePluginRequest.rejects(error);

        context.accessors.execute(request, callback);
      });

      it('should reject a Promise with an error if something went wrong', () => {
        const
          request = new Request({body: {some: 'request'}}, {connectionId: 'connectionid'}),
          error = new Error('error');

        kuzzle.funnel.executePluginRequest.rejects(error);

        return context.accessors.execute(request)
          .catch(err => {
            should(kuzzle.funnel.executePluginRequest).calledWith(request);
            should(err).match(error);
          });
      });

      it('should resolve to an error if no Request object is provided', done => {
        const
          callback = sinon.spy(
            (err, res) => {
              try {
                should(kuzzle.funnel.executePluginRequest).not.be.called();
                should(callback).be.calledOnce();
                should(err).be.instanceOf(PluginImplementationError);
                should(err.message).startWith('Invalid argument: a Request object must be supplied');
                should(res).be.undefined();
                done();
              }
              catch(e) {
                done(e);
              }
            });

        context.accessors.execute({}, callback);
      });

      it('should reject if no Request object is provided', () => {
        return should(context.accessors.execute({})).be.rejectedWith(
          /Invalid argument: a Request object must be supplied/
        );
      });

      it('should reject if callback argument is not a function', () => {
        return should(context.accessors.execute({requestId: 'request'}, 'foo'))
          .be.rejectedWith({message: /^Invalid argument: Expected callback to be a function, received "string"/});

      });

      it('should reject if trying to call forbidden methods from realtime controller', () => {
        return Promise.resolve()
          .then(() => {
            return should(context.accessors.execute(new Request({
              controller: 'realtime',
              action: 'subscribe'
            })))
              .be.rejectedWith(PluginImplementationError, {
                id: 'plugin.context.unavailable_realtime'
              });
          })
          .then(() => {
            return should(context.accessors.execute(new Request({
              controller: 'realtime',
              action: 'unsubscribe'
            })))
              .be.rejectedWith(PluginImplementationError, {
                id: 'plugin.context.unavailable_realtime'
              });
          });
      });
    });

    describe('#strategies', () => {
      it('should allow to add a strategy and link it to its owner plugin', () => {
        const
          mockedStrategy = {
            config: {
              authenticator: 'foo'
            }
          },
          result = context.accessors.strategies.add('foo', mockedStrategy);

        should(result).be.a.Promise();

        return result
          .then(() => {
            should(kuzzle.pluginsManager.registerStrategy).calledWith('pluginName', 'foo', mockedStrategy);
            should(kuzzle.pipe).calledWith('core:auth:strategyAdded', {
              pluginName: 'pluginName',
              name: 'foo',
              strategy: mockedStrategy
            });
          });
      });

      it('should reject the promise if the strategy registration throws', () => {
        const error = new Error('foobar');
        kuzzle.pluginsManager.registerStrategy.throws(error);

        const result = context.accessors.strategies.add('foo', {
          config: {
            authenticator: 'foobar'
          }
        });

        should(result).be.a.Promise();

        return should(result).be.rejectedWith(error);
      });

      it('should throw if no authenticator is provided', () => {

        return should(context.accessors.strategies.add('foo', null))
          .rejectedWith(PluginImplementationError, {message: '[pluginName] Strategy foo: dynamic strategy registration can only be done using an "authenticator" option (see https://tinyurl.com/y7boozbk).\nThis is probably not a Kuzzle error, but a problem with a plugin implementation.'});
      });

      it('should allow to remove a strategy', () => {
        const result = context.accessors.strategies.remove('foo');

        should(result).be.a.Promise();

        return result
          .then(() => {
            should(kuzzle.pluginsManager.unregisterStrategy).calledWith('pluginName', 'foo');
            should(kuzzle.pipe).calledWith('core:auth:strategyRemoved', {
              pluginName: 'pluginName',
              name: 'foo'
            });
          });
      });

      it('should reject the promise if the strategy removal throws', () => {
        const error = new Error('foobar');
        kuzzle.pluginsManager.unregisterStrategy.throws(error);

        const result = context.accessors.strategies.remove('foo');

        should(result).be.a.Promise();

        return should(result).be.rejectedWith(error);
      });
    });
  });
});
