'use strict';

exports = module.exports = {
    start: start,
    stop: stop
};

var assert = require('assert'),
    appdb = require('./appdb.js'),
    apps = require('./apps.js'),
    async = require('async'),
    config = require('./config.js'),
    DatabaseError = require('./databaseerror.js'),
    debug = require('debug')('box:ldap'),
    eventlog = require('./eventlog.js'),
    user = require('./user.js'),
    UserError = user.UserError,
    ldap = require('ldapjs'),
    mailboxdb = require('./mailboxdb.js'),
    safe = require('safetydance');

var gServer = null;

var NOOP = function () {};

var GROUP_USERS_DN = 'cn=users,ou=groups,dc=cloudron';
var GROUP_ADMINS_DN = 'cn=admins,ou=groups,dc=cloudron';

function getAppByRequest(req, callback) {
    assert.strictEqual(typeof req, 'object');
    assert.strictEqual(typeof callback, 'function');

    var sourceIp = req.connection.ldap.id.split(':')[0];
    if (sourceIp.split('.').length !== 4) return callback(new ldap.InsufficientAccessRightsError('Missing source identifier'));

    apps.getByIpAddress(sourceIp, function (error, app) {
        if (error) return callback(new ldap.OperationsError(error.message));

        if (!app) return callback(new ldap.OperationsError('Could not detect app source'));

        callback(null, app);
    });
}

function getUsersWithAccessToApp(req, callback) {
    assert.strictEqual(typeof req, 'object');
    assert.strictEqual(typeof callback, 'function');

    getAppByRequest(req, function (error, app) {
        if (error) return callback(error);

        user.list(function (error, result){
            if (error) return callback(new ldap.OperationsError(error.toString()));

            async.filter(result, apps.hasAccessTo.bind(null, app), function (error, result) {
                if (error) return callback(new ldap.OperationsError(error.toString()));

                callback(null, result);
            });
        });
    });
}

function userSearch(req, res, next) {
    debug('user search: dn %s, scope %s, filter %s (from %s)', req.dn.toString(), req.scope, req.filter.toString(), req.connection.ldap.id);

    getUsersWithAccessToApp(req, function (error, result) {
        if (error) return next(error);

        // send user objects
        result.forEach(function (entry) {
            // skip entries with empty username. Some apps like owncloud can't deal with this
            if (!entry.username) return;

            var dn = ldap.parseDN('cn=' + entry.id + ',ou=users,dc=cloudron');

            var groups = [ GROUP_USERS_DN ];
            if (entry.admin) groups.push(GROUP_ADMINS_DN);

            var displayName = entry.displayName || entry.username || ''; // displayName can be empty and username can be null
            var nameParts = displayName.split(' ');
            var firstName = nameParts[0];
            var lastName = nameParts.length > 1  ? nameParts[nameParts.length - 1] : ''; // choose last part, if it exists

            var obj = {
                dn: dn.toString(),
                attributes: {
                    objectclass: ['user'],
                    objectcategory: 'person',
                    cn: entry.id,
                    uid: entry.id,
                    mail: entry.email,
                    mailAlternateAddress: entry.alternateEmail,
                    displayname: displayName,
                    givenName: firstName,
                    username: entry.username,
                    samaccountname: entry.username,      // to support ActiveDirectory clients
                    isadmin: entry.admin ? 1 : 0,
                    memberof: groups
                }
            };

            // http://www.zytrax.com/books/ldap/ape/core-schema.html#sn has 'name' as SUP which is a DirectoryString
            // which is required to have atleast one character if present
            if (lastName.length !== 0) obj.attributes.sn = lastName;

            // ensure all filter values are also lowercase
            var lowerCaseFilter = safe(function () { return ldap.parseFilter(req.filter.toString().toLowerCase()); }, null);
            if (!lowerCaseFilter) return next(new ldap.OperationsError(safe.error.toString()));

            if ((req.dn.equals(dn) || req.dn.parentOf(dn)) && lowerCaseFilter.matches(obj.attributes)) {
                res.send(obj);
            }
        });

        res.end();
    });
}

function groupSearch(req, res, next) {
    debug('group search: dn %s, scope %s, filter %s (from %s)', req.dn.toString(), req.scope, req.filter.toString(), req.connection.ldap.id);

    getUsersWithAccessToApp(req, function (error, result) {
        if (error) return next(error);

        var groups = [{
            name: 'users',
            admin: false
        }, {
            name: 'admins',
            admin: true
        }];

        groups.forEach(function (group) {
            var dn = ldap.parseDN('cn=' + group.name + ',ou=groups,dc=cloudron');
            var members = group.admin ? result.filter(function (entry) { return entry.admin; }) : result;

            var obj = {
                dn: dn.toString(),
                attributes: {
                    objectclass: ['group'],
                    cn: group.name,
                    memberuid: members.map(function(entry) { return entry.id; })
                }
            };

            // ensure all filter values are also lowercase
            var lowerCaseFilter = safe(function () { return ldap.parseFilter(req.filter.toString().toLowerCase()); }, null);
            if (!lowerCaseFilter) return next(new ldap.OperationsError(safe.error.toString()));

            if ((req.dn.equals(dn) || req.dn.parentOf(dn)) && lowerCaseFilter.matches(obj.attributes)) {
                res.send(obj);
            }
        });

        res.end();
    });
}

function mailboxSearch(req, res, next) {
    debug('mailbox search: dn %s, scope %s, filter %s (from %s)', req.dn.toString(), req.scope, req.filter.toString(), req.connection.ldap.id);

    if (!req.dn.rdns[0].attrs.cn) return next(new ldap.NoSuchObjectError(req.dn.toString()));
    var name = req.dn.rdns[0].attrs.cn.value.toLowerCase();
    // allow login via email
    var parts = name.split('@');
    if (parts[1] === config.fqdn()) {
        name = parts[0];
    }

    mailboxdb.getMailbox(name, function (error, mailbox) {
        if (error && error.reason === DatabaseError.NOT_FOUND) return next(new ldap.NoSuchObjectError(req.dn.toString()));
        if (error) return next(new ldap.OperationsError(error.toString()));

        var obj = {
            dn: req.dn.toString(),
            attributes: {
                objectclass: ['mailbox'],
                objectcategory: 'mailbox',
                cn: mailbox.name,
                uid: mailbox.name,
                mail: mailbox.name + '@' + config.fqdn(),
                ownerType: mailbox.ownerType
            }
        };

        // ensure all filter values are also lowercase
        var lowerCaseFilter = safe(function () { return ldap.parseFilter(req.filter.toString().toLowerCase()); }, null);
        if (!lowerCaseFilter) return next(new ldap.OperationsError(safe.error.toString()));

        if (lowerCaseFilter.matches(obj.attributes)) res.send(obj);

        res.end();
    });
}

function mailAliasSearch(req, res, next) {
    debug('mail alias get: dn %s, scope %s, filter %s (from %s)', req.dn.toString(), req.scope, req.filter.toString(), req.connection.ldap.id);

    if (!req.dn.rdns[0].attrs.cn) return next(new ldap.NoSuchObjectError(req.dn.toString()));
    mailboxdb.getAlias(req.dn.rdns[0].attrs.cn.value.toLowerCase(), function (error, alias) {
        if (error && error.reason === DatabaseError.NOT_FOUND) return next(new ldap.NoSuchObjectError(req.dn.toString()));
        if (error) return next(new ldap.OperationsError(error.toString()));

        // https://wiki.debian.org/LDAP/MigrationTools/Examples
        // https://docs.oracle.com/cd/E19455-01/806-5580/6jej518pp/index.html
        var obj = {
            dn: req.dn.toString(),
            attributes: {
                objectclass: ['nisMailAlias'],
                objectcategory: 'nisMailAlias',
                cn: alias.name,
                rfc822MailMember: alias.aliasTarget
            }
        };

        // ensure all filter values are also lowercase
        var lowerCaseFilter = safe(function () { return ldap.parseFilter(req.filter.toString().toLowerCase()); }, null);
        if (!lowerCaseFilter) return next(new ldap.OperationsError(safe.error.toString()));

        if (lowerCaseFilter.matches(obj.attributes)) res.send(obj);

        res.end();
    });
}

function mailingListSearch(req, res, next) {
    debug('mailing list get: dn %s, scope %s, filter %s (from %s)', req.dn.toString(), req.scope, req.filter.toString(), req.connection.ldap.id);

    if (!req.dn.rdns[0].attrs.cn) return next(new ldap.NoSuchObjectError(req.dn.toString()));
    mailboxdb.getGroup(req.dn.rdns[0].attrs.cn.value.toLowerCase(), function (error, group) {
        if (error && error.reason === DatabaseError.NOT_FOUND) return next(new ldap.NoSuchObjectError(req.dn.toString()));
        if (error) return next(new ldap.OperationsError(error.toString()));

        // http://ldapwiki.willeke.com/wiki/Original%20Mailgroup%20Schema%20From%20Netscape
        var obj = {
            dn: req.dn.toString(),
            attributes: {
                objectclass: ['mailGroup'],
                objectcategory: 'mailGroup',
                cn: group.name,
                mail: group.name + '@' + config.fqdn(),
                mgrpRFC822MailMember: group.members
            }
        };

        // ensure all filter values are also lowercase
        var lowerCaseFilter = safe(function () { return ldap.parseFilter(req.filter.toString().toLowerCase()); }, null);
        if (!lowerCaseFilter) return next(new ldap.OperationsError(safe.error.toString()));

        if (lowerCaseFilter.matches(obj.attributes)) res.send(obj);

        res.end();
    });
}

function authenticateUser(req, res, next) {
    debug('user bind: %s (from %s)', req.dn.toString(), req.connection.ldap.id);

    // extract the common name which might have different attribute names
    var attributeName = Object.keys(req.dn.rdns[0].attrs)[0];
    var commonName = req.dn.rdns[0].attrs[attributeName].value;
    if (!commonName) return next(new ldap.NoSuchObjectError(req.dn.toString()));

    var api;
    if (attributeName === 'mail') {
        api = user.verifyWithEmail;
    } else if (commonName.indexOf('@') !== -1) { // if mail is specified, enforce mail check
        api = user.verifyWithEmail;
    } else if (commonName.indexOf('uid-') === 0) {
        api = user.verify;
    } else {
        api = user.verifyWithUsername;
    }

    api(commonName, req.credentials || '', function (error, user) {
        if (error && error.reason === UserError.NOT_FOUND) return next(new ldap.NoSuchObjectError(req.dn.toString()));
        if (error && error.reason === UserError.WRONG_PASSWORD) return next(new ldap.InvalidCredentialsError(req.dn.toString()));
        if (error) return next(new ldap.OperationsError(error.message));

        req.user = user;

        next();
    });
}

function authorizeUserForApp(req, res, next) {
    assert(req.user);

    getAppByRequest(req, function (error, app) {
        if (error) return next(error);

        apps.hasAccessTo(app, req.user, function (error, result) {
            if (error) return next(new ldap.OperationsError(error.toString()));

            // we return no such object, to avoid leakage of a users existence
            if (!result) return next(new ldap.NoSuchObjectError(req.dn.toString()));

            eventlog.add(eventlog.ACTION_USER_LOGIN, { authType: 'ldap', appId: app.id }, { userId: req.user.id });

            res.end();
        });
    });
}

function authenticateMailbox(req, res, next) {
    if (!req.dn.rdns[0].attrs.cn) return next(new ldap.NoSuchObjectError(req.dn.toString()));

    var name = req.dn.rdns[0].attrs.cn.value.toLowerCase();

    // allow login via email
    var parts = name.split('@');
    if (parts[1] === config.fqdn()) {
        name = parts[0];
    }

    mailboxdb.getMailbox(name, function (error, mailbox) {
        if (error && error.reason === DatabaseError.NOT_FOUND) return next(new ldap.NoSuchObjectError(req.dn.toString()));
        if (error) return next(new ldap.OperationsError(error.message));

        if (mailbox.ownerType === mailboxdb.TYPE_APP) {
            var addonId = req.dn.rdns[1].attrs.ou.value.toLowerCase(); // 'sendmail' or 'recvmail'
            var name;
            if (addonId === 'sendmail') name = 'MAIL_SMTP_PASSWORD';
            else if (addonId === 'recvmail') name = 'MAIL_IMAP_PASSWORD';
            else return next(new ldap.OperationsError('Invalid DN'));

            appdb.getAddonConfigByName(mailbox.ownerId, addonId, name, function (error, value) {
                if (error) return next(new ldap.OperationsError(error.message));
                if (req.credentials !== value) return next(new ldap.InvalidCredentialsError(req.dn.toString()));

                eventlog.add(eventlog.ACTION_APP_LOGIN, { authType: 'ldap', mailboxId: name }, { appId: mailbox.ownerId, addonId: addonId });
                return res.end();
            });
        } else if (mailbox.ownerType === mailboxdb.TYPE_USER) {
            authenticateUser(req, res, function (error) {
                if (error) return next(error);
                eventlog.add(eventlog.ACTION_USER_LOGIN, { authType: 'ldap', mailboxId: name }, { userId: req.user.username });
                res.end();
            });
        } else {
            return next(new ldap.OperationsError('Unknown ownerType for mailbox'));
        }
    });
}

function start(callback) {
    assert.strictEqual(typeof callback, 'function');

    var logger = {
        trace: NOOP,
        debug: NOOP,
        info: debug,
        warn: debug,
        error: console.error,
        fatal: console.error
    };

    gServer = ldap.createServer({ log: logger });

    gServer.search('ou=users,dc=cloudron', userSearch);
    gServer.search('ou=groups,dc=cloudron', groupSearch);
    gServer.bind('ou=users,dc=cloudron', authenticateUser, authorizeUserForApp);

    // http://www.ietf.org/proceedings/43/I-D/draft-srivastava-ldap-mail-00.txt
    gServer.search('ou=mailboxes,dc=cloudron', mailboxSearch);
    gServer.search('ou=mailaliases,dc=cloudron', mailAliasSearch);
    gServer.search('ou=mailinglists,dc=cloudron', mailingListSearch);

    gServer.bind('ou=recvmail,dc=cloudron', authenticateMailbox);
    gServer.bind('ou=sendmail,dc=cloudron', authenticateMailbox);

    // this is the bind for addons (after bind, they might search and authenticate)
    gServer.bind('ou=addons,dc=cloudron', function(req, res, next) {
        debug('addons bind: %s', req.dn.toString()); // note: cn can be email or id
        res.end();
    });

    // this is the bind for apps (after bind, they might search and authenticate user)
    gServer.bind('ou=apps,dc=cloudron', function(req, res, next) {
        // TODO: validate password
        debug('application bind: %s', req.dn.toString());
        res.end();
    });

    gServer.listen(config.get('ldapPort'), '0.0.0.0', callback);
}

function stop(callback) {
    assert.strictEqual(typeof callback, 'function');

    if (gServer) gServer.close();

    callback();
}
