// Copyright IBM Corp. 2013,2019. All Rights Reserved.
// Node module: loopback-datasource-juggler
// This file is licensed under the MIT License.
// License text available at https://opensource.org/licenses/MIT

// This test written in mocha+should.js
'use strict';
const should = require('./init.js');
const assert = require('assert');

const jdb = require('../');
const ModelBuilder = jdb.ModelBuilder;
const DataSource = jdb.DataSource;
const Memory = require('../lib/connectors/memory');

const ModelDefinition = require('../lib/model-definition');

describe('ModelDefinition class', function() {
  let memory;
  beforeEach(function() {
    memory = new DataSource({connector: Memory});
  });

  it('should be able to define plain models', function(done) {
    const modelBuilder = new ModelBuilder();

    const User = new ModelDefinition(modelBuilder, 'User', {
      name: 'string',
      bio: ModelBuilder.Text,
      approved: Boolean,
      joinedAt: Date,
      age: 'number',
    });

    User.build();
    assert.equal(User.properties.name.type, String);
    assert.equal(User.properties.bio.type, ModelBuilder.Text);
    assert.equal(User.properties.approved.type, Boolean);
    assert.equal(User.properties.joinedAt.type, Date);
    assert.equal(User.properties.age.type, Number);

    const json = User.toJSON();
    assert.equal(json.name, 'User');
    assert.equal(json.properties.name.type, 'String');
    assert.equal(json.properties.bio.type, 'Text');
    assert.equal(json.properties.approved.type, 'Boolean');
    assert.equal(json.properties.joinedAt.type, 'Date');
    assert.equal(json.properties.age.type, 'Number');

    assert.deepEqual(User.toJSON(), json);

    done();
  });

  it('should be able to define additional properties', function(done) {
    const modelBuilder = new ModelBuilder();

    const User = new ModelDefinition(modelBuilder, 'User', {
      name: 'string',
      bio: ModelBuilder.Text,
      approved: Boolean,
      joinedAt: Date,
      age: 'number',
    });

    User.build();

    let json = User.toJSON();

    User.defineProperty('id', {type: 'number', id: true});
    assert.equal(User.properties.name.type, String);
    assert.equal(User.properties.bio.type, ModelBuilder.Text);
    assert.equal(User.properties.approved.type, Boolean);
    assert.equal(User.properties.joinedAt.type, Date);
    assert.equal(User.properties.age.type, Number);

    assert.equal(User.properties.id.type, Number);

    json = User.toJSON();
    assert.deepEqual(json.properties.id, {type: 'Number', id: true});

    done();
  });

  it('should be able to define nesting models', function(done) {
    const modelBuilder = new ModelBuilder();

    const User = new ModelDefinition(modelBuilder, 'User', {
      name: String,
      bio: ModelBuilder.Text,
      approved: Boolean,
      joinedAt: Date,
      age: Number,
      address: {
        street: String,
        city: String,
        zipCode: String,
        state: String,
      },
    });

    User.build();
    assert.equal(User.properties.name.type, String);
    assert.equal(User.properties.bio.type, ModelBuilder.Text);
    assert.equal(User.properties.approved.type, Boolean);
    assert.equal(User.properties.joinedAt.type, Date);
    assert.equal(User.properties.age.type, Number);
    assert.equal(typeof User.properties.address.type, 'function');

    const json = User.toJSON();
    assert.equal(json.name, 'User');
    assert.equal(json.properties.name.type, 'String');
    assert.equal(json.properties.bio.type, 'Text');
    assert.equal(json.properties.approved.type, 'Boolean');
    assert.equal(json.properties.joinedAt.type, 'Date');
    assert.equal(json.properties.age.type, 'Number');

    assert.deepEqual(json.properties.address.type, {street: {type: 'String'},
      city: {type: 'String'},
      zipCode: {type: 'String'},
      state: {type: 'String'}});

    done();
  });

  it('should be able to define referencing models', function(done) {
    const modelBuilder = new ModelBuilder();

    const Address = modelBuilder.define('Address', {
      street: String,
      city: String,
      zipCode: String,
      state: String,
    });
    const User = new ModelDefinition(modelBuilder, 'User', {
      name: String,
      bio: ModelBuilder.Text,
      approved: Boolean,
      joinedAt: Date,
      age: Number,
      address: Address,

    });

    User.build();
    assert.equal(User.properties.name.type, String);
    assert.equal(User.properties.bio.type, ModelBuilder.Text);
    assert.equal(User.properties.approved.type, Boolean);
    assert.equal(User.properties.joinedAt.type, Date);
    assert.equal(User.properties.age.type, Number);
    assert.equal(User.properties.address.type, Address);

    const json = User.toJSON();
    assert.equal(json.name, 'User');
    assert.equal(json.properties.name.type, 'String');
    assert.equal(json.properties.bio.type, 'Text');
    assert.equal(json.properties.approved.type, 'Boolean');
    assert.equal(json.properties.joinedAt.type, 'Date');
    assert.equal(json.properties.age.type, 'Number');

    assert.equal(json.properties.address.type, 'Address');

    done();
  });

  it('should be able to define referencing models by name', function(done) {
    const modelBuilder = new ModelBuilder();

    const Address = modelBuilder.define('Address', {
      street: String,
      city: String,
      zipCode: String,
      state: String,
    });
    const User = new ModelDefinition(modelBuilder, 'User', {
      name: String,
      bio: ModelBuilder.Text,
      approved: Boolean,
      joinedAt: Date,
      age: Number,
      address: 'Address',

    });

    User.build();
    assert.equal(User.properties.name.type, String);
    assert.equal(User.properties.bio.type, ModelBuilder.Text);
    assert.equal(User.properties.approved.type, Boolean);
    assert.equal(User.properties.joinedAt.type, Date);
    assert.equal(User.properties.age.type, Number);
    assert.equal(User.properties.address.type, Address);

    const json = User.toJSON();
    assert.equal(json.name, 'User');
    assert.equal(json.properties.name.type, 'String');
    assert.equal(json.properties.bio.type, 'Text');
    assert.equal(json.properties.approved.type, 'Boolean');
    assert.equal(json.properties.joinedAt.type, 'Date');
    assert.equal(json.properties.age.type, 'Number');

    assert.equal(json.properties.address.type, 'Address');

    done();
  });

  it('should report correct id names', function(done) {
    const modelBuilder = new ModelBuilder();

    const User = new ModelDefinition(modelBuilder, 'User', {
      userId: {type: String, id: true},
      name: 'string',
      bio: ModelBuilder.Text,
      approved: Boolean,
      joinedAt: Date,
      age: 'number',
    });

    assert.equal(User.idName(), 'userId');
    assert.deepEqual(User.idNames(), ['userId']);
    done();
  });

  it('should sort id properties by its index', function() {
    const modelBuilder = new ModelBuilder();

    const User = new ModelDefinition(modelBuilder, 'User', {
      userId: {type: String, id: 2},
      userType: {type: String, id: 1},
      name: 'string',
      bio: ModelBuilder.Text,
      approved: Boolean,
      joinedAt: Date,
      age: 'number',
    });

    const ids = User.ids();
    assert.ok(Array.isArray(ids));
    assert.equal(ids.length, 2);
    assert.equal(ids[0].id, 1);
    assert.equal(ids[0].name, 'userType');
    assert.equal(ids[1].id, 2);
    assert.equal(ids[1].name, 'userId');
  });

  it('should report correct table/column names', function(done) {
    const modelBuilder = new ModelBuilder();

    const User = new ModelDefinition(modelBuilder, 'User', {
      userId: {type: String, id: true, oracle: {column: 'ID'}},
      name: 'string',
    }, {oracle: {table: 'USER'}});

    assert.equal(User.tableName('oracle'), 'USER');
    assert.equal(User.tableName('mysql'), 'User');
    assert.equal(User.columnName('oracle', 'userId'), 'ID');
    assert.equal(User.columnName('mysql', 'userId'), 'userId');
    done();
  });

  describe('maxDepthOfQuery', function() {
    it('should report errors for deep query than maxDepthOfQuery', function(done) {
      const MyModel = memory.createModel('my-model', {}, {
        maxDepthOfQuery: 5,
      });

      const filter = givenComplexFilter();

      MyModel.find(filter, function(err) {
        should.exist(err);
        err.message.should.match('The query object exceeds maximum depth 5');
        done();
      });
    });

    it('should honor maxDepthOfQuery setting', function(done) {
      const MyModel = memory.createModel('my-model', {}, {
        maxDepthOfQuery: 20,
      });

      const filter = givenComplexFilter();

      MyModel.find(filter, function(err) {
        should.not.exist(err);
        done();
      });
    });

    it('should honor maxDepthOfQuery in options', function(done) {
      const MyModel = memory.createModel('my-model', {}, {
        maxDepthOfQuery: 5,
      });

      const filter = givenComplexFilter();

      MyModel.find(filter, {maxDepthOfQuery: 20}, function(err) {
        should.not.exist(err);
        done();
      });
    });

    function givenComplexFilter() {
      const filter = {where: {and: [{and: [{and: [{and: [{and: [{and:
        [{and: [{and: [{and: [{x: 1}]}]}]}]}]}]}]}]}]}};
      return filter;
    }
  });

  it('should serialize protected properties into JSON', function() {
    const ProtectedModel = memory.createModel('protected', {}, {
      protected: ['protectedProperty'],
    });
    const pm = new ProtectedModel({
      id: 1, foo: 'bar', protectedProperty: 'protected',
    });
    const serialized = pm.toJSON();
    assert.deepEqual(serialized, {
      id: 1, foo: 'bar', protectedProperty: 'protected',
    });
  });

  it('should not serialize protected properties of nested models into JSON', function(done) {
    const Parent = memory.createModel('parent');
    const Child = memory.createModel('child', {}, {protected: ['protectedProperty']});
    Parent.hasMany(Child);
    Parent.create({
      name: 'parent',
    }, function(err, parent) {
      if (err) return done(err);
      parent.children.create({
        name: 'child',
        protectedProperty: 'protectedValue',
      }, function(err, child) {
        if (err) return done(err);
        Parent.find({include: 'children'}, function(err, parents) {
          if (err) return done(err);
          const serialized = parents[0].toJSON();
          const child = serialized.children[0];
          assert.equal(child.name, 'child');
          assert.notEqual(child.protectedProperty, 'protectedValue');
          done();
        });
      });
    });
  });

  it('should not serialize hidden properties into JSON', function() {
    const HiddenModel = memory.createModel('hidden', {}, {
      hidden: ['secret'],
    });
    const hm = new HiddenModel({
      id: 1,
      foo: 'bar',
      secret: 'secret',
    });
    const serialized = hm.toJSON();
    assert.deepEqual(serialized, {
      id: 1,
      foo: 'bar',
    });
  });

  it('should not serialize hidden properties of nested models into JSON', function(done) {
    const Parent = memory.createModel('parent');
    const Child = memory.createModel('child', {}, {hidden: ['secret']});
    Parent.hasMany(Child);
    Parent.create({
      name: 'parent',
    }, function(err, parent) {
      if (err) return done(err);
      parent.children.create({
        name: 'child',
        secret: 'secret',
      }, function(err, child) {
        if (err) return done(err);
        Parent.find({include: 'children'}, function(err, parents) {
          if (err) return done(err);
          const serialized = parents[0].toJSON();
          const child = serialized.children[0];
          assert.equal(child.name, 'child');
          assert.notEqual(child.secret, 'secret');
          done();
        });
      });
    });
  });

  describe('hidden properties', function() {
    let Child;

    describe('with hidden array', function() {
      beforeEach(function() { givenChildren(); });

      it('should be removed if used in where', function() {
        return Child.find({
          where: {secret: 'guess'},
        }, optionsFromRemoteReq).then(assertHiddenPropertyIsIgnored);
      });

      it('should be removed if used in where.and', function() {
        return Child.find({
          where: {and: [{secret: 'guess'}]},
        }, optionsFromRemoteReq).then(assertHiddenPropertyIsIgnored);
      });

      it('should be allowed for update', function() {
        return Child.update({name: 'childA'}, {secret: 'new-secret'}, optionsFromRemoteReq).then(
          function(result) {
            result.count.should.equal(1);
          },
        );
      });

      it('should be allowed if prohibitHiddenPropertiesInQuery is `false`', function() {
        Child.definition.settings.prohibitHiddenPropertiesInQuery = false;
        return Child.find({
          where: {secret: 'guess'},
        }).then(function(children) {
          children.length.should.equal(1);
          children[0].secret.should.equal('guess');
        });
      });

      it('should be allowed by default if not remote call', function() {
        return Child.find({
          where: {secret: 'guess'},
        }).then(function(children) {
          children.length.should.equal(1);
          children[0].secret.should.equal('guess');
        });
      });

      it('should be allowed if prohibitHiddenPropertiesInQuery is `false` in options', function() {
        return Child.find({
          where: {secret: 'guess'},
        }, {
          prohibitHiddenPropertiesInQuery: false,
        }).then(function(children) {
          children.length.should.equal(1);
          children[0].secret.should.equal('guess');
        });
      });
    });

    describe('with hidden object', function() {
      beforeEach(function() { givenChildren({hiddenProperties: {secret: true}}); });

      it('should be removed if used in where', function() {
        return Child.find({
          where: {secret: 'guess'},
        }, optionsFromRemoteReq).then(assertHiddenPropertyIsIgnored);
      });

      it('should be removed if used in where.and', function() {
        return Child.find({
          where: {and: [{secret: 'guess'}]},
        }, optionsFromRemoteReq).then(assertHiddenPropertyIsIgnored);
      });
    });

    /**
     * Create two children with a hidden property, one with a matching
     * value, the other with a non-matching value
     */
    function givenChildren(hiddenProps) {
      hiddenProps = hiddenProps || {hidden: ['secret']};
      Child = memory.createModel('child', {
        name: String,
        secret: String,
      }, hiddenProps);
      return Child.create([{
        name: 'childA',
        secret: 'secret',
      }, {
        name: 'childB',
        secret: 'guess',
      }]);
    }

    function assertHiddenPropertyIsIgnored(children) {
      // All children are found whether the `secret` condition matches or not
      // as the condition is removed because it's hidden
      children.length.should.equal(2);
    }
  });

  /**
   * Mock up for default values set by the remote model
   */
  const optionsFromRemoteReq = {
    prohibitHiddenPropertiesInQuery: true,
    maxDepthOfQuery: 12,
    maxDepthOfQuery: 32,
  };

  describe('hidden nested properties', function() {
    let Child;
    beforeEach(givenChildren);

    it('should be removed if used in where as a composite key - x.secret', function() {
      return Child.find({
        where: {'x.secret': 'guess'},
      }, optionsFromRemoteReq).then(assertHiddenPropertyIsIgnored);
    });

    it('should be removed if used in where as a composite key - secret.y', function() {
      return Child.find({
        where: {'secret.y': 'guess'},
      }, optionsFromRemoteReq).then(assertHiddenPropertyIsIgnored);
    });

    it('should be removed if used in where as a composite key - a.secret.b', function() {
      return Child.find({
        where: {'a.secret.b': 'guess'},
      }, optionsFromRemoteReq).then(assertHiddenPropertyIsIgnored);
    });

    function givenChildren() {
      const hiddenProps = {hidden: ['secret']};
      Child = memory.createModel('child', {
        name: String,
        x: {
          secret: String,
        },
        secret: {
          y: String,
        },
        a: {
          secret: {
            b: String,
          },
        },
      }, hiddenProps);
      return Child.create([{
        name: 'childA',
        x: {secret: 'secret'},
        secret: {y: 'secret'},
        a: {secret: {b: 'secret'}},
      }, {
        name: 'childB',
        x: {secret: 'guess'},
        secret: {y: 'guess'},
        a: {secret: {b: 'guess'}},
      }]);
    }

    function assertHiddenPropertyIsIgnored(children) {
      // All children are found whether the `secret` condition matches or not
      // as the condition is removed because it's hidden
      children.length.should.equal(2);
    }
  });

  function assertParentIncludeChildren(parents) {
    parents[0].toJSON().children.length.should.equal(1);
  }

  describe('protected properties', function() {
    let Parent;
    let Child;
    beforeEach(givenParentAndChild);

    it('should be removed if used in include scope', function() {
      Parent.find({
        include: {
          relation: 'children',
          scope: {
            where: {
              secret: 'x',
            },
          },
        },
      }, optionsFromRemoteReq).then(assertParentIncludeChildren);
    });

    it('should be rejected if used in include scope.where.and', function() {
      return Parent.find({
        include: {
          relation: 'children',
          scope: {
            where: {
              and: [{secret: 'x'}],
            },
          },
        },
      }, optionsFromRemoteReq).then(assertParentIncludeChildren);
    });

    it('should be removed if a hidden property is used in include scope', function() {
      return Parent.find({
        include: {
          relation: 'children',
          scope: {
            where: {
              secret: 'x',
            },
          },
        },
      }, optionsFromRemoteReq).then(assertParentIncludeChildren);
    });

    function givenParentAndChild() {
      Parent = memory.createModel('parent');
      Child = memory.createModel('child', {}, {protected: ['secret']});
      Parent.hasMany(Child);
      return Parent.create({
        name: 'parent',
      }).then(parent => {
        return parent.children.create({
          name: 'child',
          secret: 'secret',
        });
      });
    }
  });

  describe('hidden properties in include', function() {
    let Parent;
    let Child;
    beforeEach(givenParentAndChildWithHiddenProperty);

    it('should be rejected if used in scope', function() {
      return Parent.find({
        include: {
          relation: 'children',
          scope: {
            where: {
              secret: 'x',
            },
          },
        },
      }, optionsFromRemoteReq).then(assertParentIncludeChildren);
    });

    function givenParentAndChildWithHiddenProperty() {
      Parent = memory.createModel('parent');
      Child = memory.createModel('child', {}, {hidden: ['secret']});
      Parent.hasMany(Child);
      return Parent.create({
        name: 'parent',
      }).then(parent => {
        return parent.children.create({
          name: 'child',
          secret: 'secret',
        });
      });
    }
  });

  it('should throw error for property names containing dot', function() {
    (function() { memory.createModel('Dotted', {'dot.name': String}); })
      .should
      .throw(/dot\(s\).*Dotted.*dot\.name/);
  });

  it('should report deprecation warning for property named constructor', function() {
    let message = 'deprecation not reported';
    process.once('deprecation', function(err) { message = err.message; });

    memory.createModel('Ctor', {'constructor': String});

    message.should.match(/Property name should not be "constructor" in Model: Ctor/);
  });

  it('should throw error for dynamic property names containing dot',
    function(done) {
      const Model = memory.createModel('DynamicDotted');
      Model.create({'dot.name': 'dot.value'}, function(err) {
        err.should.be.instanceOf(Error);
        err.message.should.match(/dot\(s\).*DynamicDotted.*dot\.name/);
        done();
      });
    });

  it('should throw error for dynamic property named constructor', function(done) {
    const Model = memory.createModel('DynamicCtor');
    Model.create({'constructor': 'myCtor'}, function(err) {
      assert.equal(err.message, 'Property name "constructor" is not allowed in DynamicCtor data');
      done();
    });
  });

  it('should support "array" type shortcut', function() {
    const Model = memory.createModel('TwoArrays', {
      regular: Array,
      sugar: 'array',
    });

    const props = Model.definition.properties;
    props.regular.type.should.equal(props.sugar.type);
  });

  context('hasPK', function() {
    context('with primary key defined', function() {
      let Todo;
      before(function prepModel() {
        Todo = new ModelDefinition(new ModelBuilder(), 'Todo', {
          content: 'string',
        });
        Todo.defineProperty('id', {
          type: 'number',
          id: true,
        });
        Todo.build();
      });

      it('should return true', function() {
        Todo.hasPK().should.be.ok;
      });
    });

    context('without primary key defined', function() {
      let Todo;
      before(function prepModel() {
        Todo = new ModelDefinition(new ModelBuilder(), 'Todo', {
          content: 'string',
        });
        Todo.build();
      });

      it('should return false', function() {
        Todo.hasPK().should.not.be.ok;
      });
    });
  });
});
