var loopback = require('../loopback');
var debug = require('debug')('loopback:security:role');
var assert = require('assert');
var async = require('async');

var AccessContext = require('./access-context').AccessContext;

// Role model
var RoleSchema = {
  id: {type: String, id: true}, // Id
  name: {type: String, required: true}, // The name of a role
  description: String, // Description

  // Timestamps
  created: {type: Date, default: Date},
  modified: {type: Date, default: Date}
};

/**
 * Map principals to roles
 */
var RoleMappingSchema = {
  id: {type: String, id: true}, // Id
  roleId: String, // The role id
  principalType: String, // The principal type, such as user, application, or role
  principalId: String // The principal id
};

var RoleMapping = loopback.createModel('RoleMapping', RoleMappingSchema, {
  relations: {
    role: {
      type: 'belongsTo',
      model: 'Role',
      foreignKey: 'roleId'
    }
  }
});

// Principal types
RoleMapping.USER = 'USER';
RoleMapping.APP = RoleMapping.APPLICATION = 'APP';
RoleMapping.ROLE = 'ROLE';

/**
 * Get the application principal
 * @param callback
 */
RoleMapping.prototype.application = function (callback) {
  if (this.principalType === RoleMapping.APPLICATION) {
    loopback.Application.findById(this.principalId, callback);
  } else {
    process.nextTick(function () {
      callback && callback(null, null);
    });
  }
};

/**
 * Get the user principal
 * @param callback
 */
RoleMapping.prototype.user = function (callback) {
  if (this.principalType === RoleMapping.USER) {
    loopback.User.findById(this.principalId, callback);
  } else {
    process.nextTick(function () {
      callback && callback(null, null);
    });
  }
};

/**
 * Get the child role principal
 * @param callback
 */
RoleMapping.prototype.childRole = function (callback) {
  if (this.principalType === RoleMapping.ROLE) {
    loopback.User.findById(this.principalId, callback);
  } else {
    process.nextTick(function () {
      callback && callback(null, null);
    });
  }
};

/**
 * Define the Role model with `hasMany` relation to RoleMapping
 */
var Role = loopback.createModel('Role', RoleSchema, {
  relations: {
    principals: {
      type: 'hasMany',
      model: 'RoleMapping',
      foreignKey: 'roleId'
    }
  }
});

// Set up the connection to users/applications/roles once the model
Role.once('dataSourceAttached', function () {
  Role.prototype.users = function (callback) {
    RoleMapping.find({where: {roleId: this.id,
      principalType: RoleMapping.USER}}, function (err, mappings) {
      if (err) {
        callback && callback(err);
        return;
      }
      return mappings.map(function (m) {
        return m.principalId;
      });
    });
  };

  Role.prototype.applications = function (callback) {
    RoleMapping.find({where: {roleId: this.id,
      principalType: RoleMapping.APPLICATION}}, function (err, mappings) {
      if (err) {
        callback && callback(err);
        return;
      }
      return mappings.map(function (m) {
        return m.principalId;
      });
    });
  };

  Role.prototype.roles = function (callback) {
    RoleMapping.find({where: {roleId: this.id,
      principalType: RoleMapping.ROLE}}, function (err, mappings) {
      if (err) {
        callback && callback(err);
        return;
      }
      return mappings.map(function (m) {
        return m.principalId;
      });
    });
  };

});

// Special roles
Role.OWNER = '$owner'; // owner of the object
Role.RELATED = "$related"; // any User with a relationship to the object
Role.AUTHENTICATED = "$authenticated"; // authenticated user
Role.UNAUTHENTICATED = "$unauthenticated"; // authenticated user
Role.EVERYONE = "$everyone"; // everyone

/**
 * Add custom handler for roles
 * @param role
 * @param resolver The resolver function decides if a principal is in the role
 * dynamically
 *
 * function(role, context, callback)
 */
Role.registerResolver = function(role, resolver) {
  if(!Role.resolvers) {
    Role.resolvers = {};
  }
  Role.resolvers[role] = resolver;
};

Role.registerResolver(Role.OWNER, function(role, context, callback) {
  if(!context || !context.model || !context.modelId) {
    process.nextTick(function() {
      callback && callback(null, false);
    });
    return;
  }
  var modelClass = context.model;
  var modelId = context.modelId;
  var userId = context.getUserId();
  Role.isOwner(modelClass, modelId, userId, callback);
});

function isUserClass(modelClass) {
  return modelClass === loopback.User ||
    modelClass.prototype instanceof loopback.User;
}

/**
 * Check if a given userId is the owner the model instance
 * @param {Function} modelClass The model class
 * @param {*} modelId The model id
 * @param {*) userId The user id
 * @param {Function} callback
 */
Role.isOwner = function isOwner(modelClass, modelId, userId, callback) {
  assert(modelClass, 'Model class is required');
  debug('isOwner(): %s %s %s', modelClass && modelClass.modelName, modelId, userId);
  // No userId is present
  if(!userId) {
    process.nextTick(function() {
      callback(null, false);
    });
    return;
  }

  // Is the modelClass User or a subclass of User?
  if(isUserClass(modelClass)) {
    process.nextTick(function() {
      callback(null, modelId == userId);
    });
    return;
  }

  modelClass.findById(modelId, function(err, inst) {
    if(err || !inst) {
      callback && callback(err, false);
      return;
    }
    debug('Model found: %j', inst);
    if(inst.userId || inst.owner) {
      callback && callback(null, (inst.userId || inst.owner) === userId);
      return;
    } else {
      // Try to follow belongsTo
      for(var r in modelClass.relations) {
        var rel = modelClass.relations[r];
        if(rel.type === 'belongsTo' && isUserClass(rel.modelTo)) {
          debug('Checking relation %s to %s: %j', r, rel.modelTo.modelName, rel);
          inst[r](function(err, user) {
            if(!err && user) {
              debug('User found: %j', user.id);
              callback && callback(null, user.id === userId);
            } else {
              callback && callback(err, false);
            }
          });
          return;
        }
      }
      callback && callback(null, false);
    }
  });
};

Role.registerResolver(Role.AUTHENTICATED, function(role, context, callback) {
  if(!context) {
    process.nextTick(function() {
      callback && callback(null, false);
    });
    return;
  }
  Role.isAuthenticated(context, callback);
});

/**
 * Check if the user id is authenticated
 * @param {Object} context The security context
 * @param {Function} callback The callback function
 */
Role.isAuthenticated = function isAuthenticated(context, callback) {
  process.nextTick(function() {
    callback && callback(null, context.isAuthenticated());
  });
};

Role.registerResolver(Role.UNAUTHENTICATED, function(role, context, callback) {
  process.nextTick(function() {
    callback && callback(null, !context || !context.isAuthenticated());
  });
});

Role.registerResolver(Role.EVERYONE, function (role, context, callback) {
  process.nextTick(function () {
    callback && callback(null, true); // Always true
  });
});

/**
 * Check if a given principal is in the role
 *
 * @param {String} role The role name
 * @param {Object} context The context object
 * @param {Function} callback
 */
Role.isInRole = function (role, context, callback) {
  debug('isInRole(): %s %j', role, context);

  if (!(context instanceof AccessContext)) {
    context = new AccessContext(context);
  }

  var resolver = Role.resolvers[role];
  if (resolver) {
    debug('Custom resolver found for role %s', role);
    resolver(role, context, callback);
    return;
  }

  if (context.principals.length === 0) {
    debug('isInRole() returns: false');
    process.nextTick(function () {
      callback && callback(null, false);
    });
    return;
  }

  var inRole = context.principals.some(function (p) {

    var principalType = p.type || undefined;
    var principalId = p.id || undefined;

    // Check if it's the same role
    return principalType === RoleMapping.ROLE && principalId === role;
  });

  if (inRole) {
    debug('isInRole() returns: %j', inRole);
    process.nextTick(function () {
      callback && callback(null, true);
    });
    return;
  }

  Role.findOne({where: {name: role}}, function (err, result) {
    if (err) {
      callback && callback(err);
      return;
    }
    if (!result) {
      callback && callback(null, false);
      return;
    }
    debug('Role found: %j', result);

    // Iterate through the list of principals
    async.some(context.principals, function (p, done) {
      var principalType = p.type || undefined;
      var principalId = p.id || undefined;
      if (principalType && principalId) {
        RoleMapping.findOne({where: {roleId: result.id,
            principalType: principalType, principalId: principalId}},
          function (err, result) {
            debug('Role mapping found: %j', result);
            done(!err && result); // The only arg is the result
          });
      } else {
        process.nextTick(function () {
          done(false);
        });
      }
    }, function (inRole) {
      debug('isInRole() returns: %j', inRole);
      callback && callback(null, inRole);
    });
  });

};

/**
 * List roles for a given principal
 * @param {Object} context The security context
 * @param {Function} callback
 *
 * @callback callback
 * @param err
 * @param {String[]} An array of role ids
 */
Role.getRoles = function (context, callback) {
  debug('getRoles(): %j', context);

  if(!(context instanceof AccessContext)) {
    context = new AccessContext(context);
  }
  var roles = [];

  var addRole = function (role) {
    if (role && roles.indexOf(role) === -1) {
      roles.push(role);
    }
  };

  // Check against the smart roles
  var inRoleTasks = [];
  Object.keys(Role.resolvers).forEach(function (role) {
    inRoleTasks.push(function (done) {
      Role.isInRole(role, context, function (err, inRole) {
        if (!err && inRole) {
          addRole(role);
          done();
        } else {
          done(err, null);
        }
      });
    });
  });

  context.principals.forEach(function (p) {
    // Check against the role mappings
    var principalType = p.type || undefined;
    var principalId = p.id || undefined;

    // Add the role itself
    if (principalType === RoleMapping.ROLE && principalId) {
      addRole(principalId);
    }

    if (principalType && principalId) {
      // Please find() treat undefined matches all values
      inRoleTasks.push(function (done) {
        RoleMapping.find({where: {principalType: principalType,
          principalId: principalId}}, function (err, mappings) {
          debug('Role mappings found: %s %j', err, mappings);
          if (err) {
            done && done(err);
            return;
          }
          mappings.forEach(function (m) {
            addRole(m.roleId);
          });
          done && done();
        });
      });
    }
  });

  async.parallel(inRoleTasks, function (err, results) {
    debug('getRoles() returns: %j %j', err, roles);
    callback && callback(err, roles);
  });
};

module.exports = {
  Role: Role,
  RoleMapping: RoleMapping
};