import addonConfigs from './addonconfigs.js';
import assert from 'node:assert';
import apps from './apps.js';
import AuditSource from './auditsource.js';
import BoxError from './boxerror.js';
import constants from './constants.js';
import logger from './logger.js';
import eventlog from './eventlog.js';
import groups from './groups.js';
import ldap from 'ldapjs';
import mail from './mail.js';
import safe from 'safetydance';
import users from './users.js';
import util from 'node:util';

const { trace } = logger('ldapserver');

let _MOCK_APP = null;

function _setMockApp(app) {
    _MOCK_APP = app;
}


let gServer = null;

const NOOP = function () {};

// Will attach req.app if successful
async function authenticateApp(req, res, next) {
    const sourceIp = req.connection.ldap.id.split(':')[0];
    if (sourceIp.split('.').length !== 4) return next(new ldap.InsufficientAccessRightsError('Missing source identifier'));

    // this is only used by the ldap test. the apps tests still uses proper docker
    if (constants.TEST && sourceIp === '127.0.0.1') {
        req.app = _MOCK_APP;
        return next();
    }

    const [error, app] = await safe(apps.getByIpAddress(sourceIp));
    if (error) return next(new ldap.OperationsError(error.message));
    if (!app) return next(new ldap.OperationsError('Could not detect app source'));

    req.app = app;

    next();
}

// Will attach req.user if successful
async function userAuthInternal(appId, req, res, next) {
    // extract the common name which might have different attribute names
    const attributeName = Object.keys(req.dn.rdns[0].attrs)[0];
    const commonName = req.dn.rdns[0].attrs[attributeName].value;
    if (!commonName) return next(new ldap.NoSuchObjectError('Missing CN'));

    let verifyFunc;
    if (attributeName === 'mail') {
        verifyFunc = users.verifyWithEmail;
    } else if (commonName.indexOf('@') !== -1) { // if mail is specified, enforce mail check
        verifyFunc = users.verifyWithEmail;
    } else if (commonName.indexOf('uid-') === 0) {
        verifyFunc = users.verifyWithId;
    } else {
        verifyFunc = users.verifyWithUsername;
    }

    const [error, user] = await safe(verifyFunc(commonName, req.credentials || '', appId || '', { skipTotpCheck: true }));
    if (error && error.reason === BoxError.NOT_FOUND) return next(new ldap.NoSuchObjectError(error.message));
    if (error && error.reason === BoxError.INVALID_CREDENTIALS) return next(new ldap.InvalidCredentialsError(error.message));
    if (error) return next(new ldap.OperationsError(error.message));

    req.user = user;

    next();
}

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

    const result = await users.list();
    const allowedUsers = result.filter((user) => user.active && apps.canAccess(req.app, user)); // do not list inactive users
    return allowedUsers;
}

// helper function to deal with pagination
function finalSend(results, req, res, next) {
    const min = 0, max = results.length;
    let cookie = null;
    let pageSize = 0;

    // check if this is a paging request, if so get the cookie for session info
    req.controls.forEach(function (control) {
        if (control.type === ldap.PagedResultsControl.OID) {
            pageSize = control.value.size;
            cookie = control.value.cookie;
        }
    });

    function sendPagedResults(startIdx, end) {
        startIdx = (startIdx < min) ? min : startIdx;
        end = (end > max || end < min) ? max : end;
        let i;

        for (i = startIdx; i < end; i++) {
            res.send(results[i]);
        }

        return i;
    }

    if (cookie && Buffer.isBuffer(cookie)) {
        // we have pagination
        let first = min;
        if (cookie.length !== 0) {
            first = parseInt(cookie.toString(), 10);
        }
        const last = sendPagedResults(first, first + pageSize);

        let resultCookie;
        if (last < max) {
            resultCookie = Buffer.from(last.toString());
        } else {
            resultCookie = Buffer.from('');
        }

        res.controls.push(new ldap.PagedResultsControl({
            value: {
                size: pageSize, // correctness not required here
                cookie: resultCookie
            }
        }));
    } else {
        // no pagination simply send all
        results.forEach(function (result) {
            res.send(result);
        });
    }

    // all done
    res.end();
    next();
}

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

    const [error, result] = await safe(getUsersWithAccessToApp(req));
    if (error) return next(new ldap.OperationsError(error.message));

    const [groupsError, allGroups] = await safe(groups.listWithMembers());
    if (groupsError) return next(new ldap.OperationsError(groupsError.message));

    const results = [];

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

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

        const displayName = user.displayName || user.username || ''; // displayName can be empty and username can be null
        const { firstName, lastName, middleName } = users.parseDisplayName(displayName);

        // https://datatracker.ietf.org/doc/html/rfc2798
        const obj = {
            dn: dn.toString(),
            attributes: {
                objectclass: ['user', 'inetorgperson', 'person', 'organizationalperson', 'top' ],
                objectcategory: 'person',
                cn: displayName,
                uid: user.id,
                entryuuid: user.id, // to support OpenLDAP clients
                mail: user.email,
                mailAlternateAddress: user.fallbackEmail,
                displayname: displayName,
                givenName: firstName,
                sn: lastName,
                middleName: middleName,
                username: user.username,
                samaccountname: user.username,      // to support ActiveDirectory clients
                memberof: allGroups.filter(function (g) { return g.userIds.indexOf(user.id) !== -1; }).map(function (g) { return `cn=${g.name},ou=groups,dc=cloudron`; })
            }
        };

        const lowerCaseFilter = safe(function () { return ldap.parseFilter(req.filter.toString().toLowerCase()); }, null);
        if (!lowerCaseFilter) return next(new ldap.OperationsError(safe.error.message));

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

    finalSend(results, req, res, next);
}

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

    const results = [];

    const [groupsListError, groupsResult] = await safe(groups.listWithMembers());
    if (groupsListError) return next(new ldap.OperationsError(groupsListError.message));

    for (const group of groupsResult) {
        if (req.app.accessRestriction?.groups?.indexOf(group.id) === -1) continue;

        const dn = ldap.parseDN(`cn=${group.name},ou=groups,dc=cloudron`);

        const obj = {
            dn: dn.toString(),
            attributes: {
                objectclass: ['group'],
                cn: group.name,
                gidnumber: group.id,
                memberuid: group.userIds
            }
        };

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

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

    finalSend(results, req, res, next);
}

async function groupUsersCompare(req, res, next) {
    trace('group users compare: dn %s, attribute %s, value %s (from %s)', req.dn.toString(), req.attribute, req.value, req.connection.ldap.id);

    const [error, result] = await safe(getUsersWithAccessToApp(req));
    if (error) return next(new ldap.OperationsError(error.message));

    // we only support memberuid here, if we add new group attributes later add them here
    if (req.attribute === 'memberuid') {
        const found = result.find(function (u) { return u.id === req.value; });
        if (found) return res.end(true);
    }

    res.end(false);
}

async function groupAdminsCompare(req, res, next) {
    trace('group admins compare: dn %s, attribute %s, value %s (from %s)', req.dn.toString(), req.attribute, req.value, req.connection.ldap.id);

    const [error, result] = await safe(getUsersWithAccessToApp(req));
    if (error) return next(new ldap.OperationsError(error.message));

    // we only support memberuid here, if we add new group attributes later add them here
    if (req.attribute === 'memberuid') {
        const user = result.find(function (u) { return u.id === req.value; });
        if (user && users.compareRoles(user.role, users.ROLE_ADMIN) >= 0) return res.end(true);
    }

    res.end(false);
}

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

    // if cn is set OR filter is mail= we only search for one mailbox specifically
    let email, dn;
    if (req.dn.rdns[0].attrs.cn) {
        email = req.dn.rdns[0].attrs.cn.value.toLowerCase();
        dn = req.dn.toString();
    } else if (req.filter instanceof ldap.EqualityFilter && req.filter.attribute === 'mail') {
        email = req.filter.value.toLowerCase();
        dn = `cn=${email},${req.dn.toString()}`;
    }

    if (email) {
        const parts = email.split('@');
        if (parts.length !== 2) return next(new ldap.NoSuchObjectError(dn.toString()));

        const [error, mailbox] = await safe(mail.getMailbox(parts[0], parts[1]));
        if (error) return next(new ldap.OperationsError(error.message));
        if (!mailbox) return next(new ldap.NoSuchObjectError(dn.toString()));
        if (!mailbox.active) return next(new ldap.NoSuchObjectError('Mailbox is not active'));

        const obj = {
            dn: dn.toString(),
            attributes: {
                objectclass: ['mailbox'],
                objectcategory: 'mailbox',
                cn: `${mailbox.name}@${mailbox.domain}`,
                uid: `${mailbox.name}@${mailbox.domain}`,
                mail: `${mailbox.name}@${mailbox.domain}`,
                storagequota: mailbox.storageQuota,
                messagesquota: mailbox.messagesQuota,
            }
        };

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

        if (lowerCaseFilter.matches(obj.attributes)) {
            finalSend([ obj ], req, res, next);
        } else {
            res.end();
        }
    } else { // new sogo and dovecot listing (doveadm -A)
        // TODO figure out how proper pagination here could work
        const [error, mailboxes] = await safe(mail.listMailboxes(1, 100000));
        if (error) return next(new ldap.OperationsError(error.message));

        const results = [];

        for (const mailbox of mailboxes) {
            if (!mailbox.active) continue;
            const mailboxDn = ldap.parseDN(`cn=${mailbox.name}@${mailbox.domain},ou=mailboxes,dc=cloudron`);

            if (mailbox.ownerType === mail.OWNERTYPE_APP) continue; // cannot login with app mailbox anyway
            const [ownerError, ownerObject] = await safe(mailbox.ownerType === mail.OWNERTYPE_USER ? users.get(mailbox.ownerId) : groups.get(mailbox.ownerId));
            if (ownerError || !ownerObject) continue; // skip mailboxes with unknown user

            const obj = {
                dn: mailboxDn.toString(),
                attributes: {
                    objectclass: ['mailbox'],
                    objectcategory: 'mailbox',
                    displayname: mailbox.ownerType === mail.OWNERTYPE_USER ? ownerObject.displayName : ownerObject.name,
                    cn: `${mailbox.name}@${mailbox.domain}`,
                    uid: `${mailbox.name}@${mailbox.domain}`,
                    mail: `${mailbox.name}@${mailbox.domain}`,
                    storagequota: mailbox.storageQuota,
                    messagesquota: mailbox.messagesQuota,
                }
            };

            mailbox.aliases.forEach(function (a, idx) {
                obj.attributes['mail' + idx] = `${a.name}@${a.domain}`;
            });

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

            if ((req.dn.equals(mailboxDn) || req.dn.parentOf(mailboxDn)) && lowerCaseFilter.matches(obj.attributes)) {
                results.push(obj);
            }
        }

        finalSend(results, req, res, next);
    }
}

async function mailAliasSearch(req, res, next) {
    trace('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('Missing CN'));

    const email = req.dn.rdns[0].attrs.cn.value.toLowerCase();
    const parts = email.split('@');
    if (parts.length !== 2) return next(new ldap.NoSuchObjectError('Invalid CN'));

    const [error, alias] = await safe(mail.searchAlias(parts[0], parts[1]));
    if (error) return next(new ldap.OperationsError(error.message));
    if (!alias) return next(new ldap.NoSuchObjectError('No such alias'));

    if (!alias.active) return next(new ldap.NoSuchObjectError('Mailbox is not active')); // there is no way to disable an alias. this is just here for completeness

    // https://wiki.debian.org/LDAP/MigrationTools/Examples
    // https://docs.oracle.com/cd/E19455-01/806-5580/6jej518pp/index.html
    // member is fully qualified - https://docs.oracle.com/cd/E19957-01/816-6082-10/chap4.doc.html#43314
    const obj = {
        dn: req.dn.toString(),
        attributes: {
            objectclass: ['nisMailAlias'],
            objectcategory: 'nisMailAlias',
            cn: `${parts[0]}@${alias.domain}`, // alias.name can contain wildcard character
            rfc822MailMember: `${alias.aliasName}@${alias.aliasDomain}`
        }
    };

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

    if (lowerCaseFilter.matches(obj.attributes)) {
        finalSend([ obj ], req, res, next);
    } else {
        res.end();
    }
}

async function mailingListSearch(req, res, next) {
    trace('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('Missing CN'));

    const email = req.dn.rdns[0].attrs.cn.value.toLowerCase();
    const parts = email.split('@');
    if (parts.length !== 2) return next(new ldap.NoSuchObjectError('Invalid CN'));
    const name = parts[0], domain = parts[1];

    const [error, result] = await safe(mail.resolveMailingList(parts[0], parts[1]));
    if (error && error.reason === BoxError.NOT_FOUND) return next(new ldap.NoSuchObjectError('No such list'));
    if (error) return next(new ldap.OperationsError(error.message));

    const { resolvedMembers, list } = result;

    if (!list.active) return next(new ldap.NoSuchObjectError('List is not active'));

    // http://ldapwiki.willeke.com/wiki/Original%20Mailgroup%20Schema%20From%20Netscape
    // members are fully qualified (https://docs.oracle.com/cd/E19444-01/816-6018-10/groups.htm#13356)
    const obj = {
        dn: req.dn.toString(),
        attributes: {
            objectclass: ['mailGroup'],
            objectcategory: 'mailGroup',
            cn: `${name}@${domain}`, // fully qualified
            mail: `${name}@${domain}`,
            membersOnly: list.membersOnly, // ldapjs only supports strings and string array. so this is not a bool!
            mgrpRFC822MailMember: resolvedMembers // fully qualified
        }
    };

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

    if (lowerCaseFilter.matches(obj.attributes)) {
        finalSend([ obj ], req, res, next);
    } else {
        res.end();
    }
}

// Will attach req.user if successful
async function authenticateUser(req, res, next) {
    trace('user bind: %s (from %s)', req.dn.toString(), req.connection.ldap.id);

    const appId = req.app.id;

    await userAuthInternal(appId, req, res, next);
}

async function authorizeUserForApp(req, res, next) {
    assert.strictEqual(typeof req.user, 'object');
    assert.strictEqual(typeof req.app, 'object');

    const canAccess = apps.canAccess(req.app, req.user);
    // we return no such object, to avoid leakage of a users existence
    if (!canAccess) return next(new ldap.NoSuchObjectError('Invalid user or insufficient previleges'));

    await eventlog.upsertLoginEvent(req.user.ghost ? eventlog.ACTION_USER_LOGIN_GHOST : eventlog.ACTION_USER_LOGIN, AuditSource.LDAP, { appId: req.app.id, userId: req.user.id, user: users.removePrivateFields(req.user) });

    res.end();
}

async function verifyMailboxPassword(mailbox, password) {
    assert.strictEqual(typeof mailbox, 'object');
    assert.strictEqual(typeof password, 'string');

    if (mailbox.ownerType === mail.OWNERTYPE_USER) {
        return await users.verifyWithId(mailbox.ownerId, password, users.AP_MAIL /* identifier */, { skipTotpCheck: true });
    } else if (mailbox.ownerType === mail.OWNERTYPE_GROUP) {
        const userIds = await groups.getMemberIds(mailbox.ownerId);

        let verifiedUser = null;
        for (const userId of userIds) {
            const [error, result] = await safe(users.verifyWithId(userId, password, users.AP_MAIL /* identifier */, { skipTotpCheck: true }));
            if (error) continue; // try the next user
            verifiedUser = result;
            break; // found a matching validated user
        }

        if (!verifiedUser) throw new BoxError(BoxError.INVALID_CREDENTIALS, 'Incorrect password');
        return verifiedUser;
    } else {
        throw new BoxError(BoxError.INVALID_CREDENTIALS, 'Incorrect password');
    }
}

async function authenticateSftp(req, res, next) {
    trace('sftp auth: %s (from %s)', req.dn.toString(), req.connection.ldap.id);

    if (!req.dn.rdns[0].attrs.cn) return next(new ldap.NoSuchObjectError('Missing CN'));

    const email = req.dn.rdns[0].attrs.cn.value.toLowerCase();
    const parts = email.split('@');
    if (parts.length !== 2) return next(new ldap.NoSuchObjectError('Invalid CN'));

    const [getAppError, app] = await safe(apps.getByFqdn(parts[1]));
    if (getAppError || !app) return next(new ldap.InvalidCredentialsError());

    const [verifyError] = await safe(users.verifyWithUsername(parts[0], req.credentials, app.id, { skipTotpCheck: true }));
    if (verifyError) return next(new ldap.InvalidCredentialsError(verifyError.message));

    trace('sftp auth: success');

    res.end();
}

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

    if (req.filter.attribute !== 'username' || !req.filter.value) return next(new ldap.NoSuchObjectError());

    const parts = req.filter.value.split('@');
    if (parts.length !== 2) return next(new ldap.NoSuchObjectError());

    const username = parts[0];
    const appFqdn = parts[1];

    const [error, app] = await safe(apps.getByFqdn(appFqdn));
    if (error) return next(new ldap.OperationsError(error.message));
    if (!app) return next(new ldap.OperationsError('Invalid app'));

    // only allow apps which specify "ftp" support in the localstorage addon
    if (!safe.query(app.manifest.addons, 'localstorage.ftp.uid')) return next(new ldap.UnavailableError('Not supported'));
    if (typeof app.manifest.addons.localstorage.ftp.uid !== 'number') return next(new ldap.UnavailableError('Bad uid, must be a number'));

    const uidNumber = app.manifest.addons.localstorage.ftp.uid;

    const [userGetError, user] = await safe(users.getByUsername(username));
    if (userGetError) return next(new ldap.OperationsError(userGetError.message));
    if (!user) return next(new ldap.OperationsError('Invalid username'));

    if (!apps.isOperator(app, user)) return next(new ldap.InsufficientAccessRightsError('Not authorized'));

    const obj = {
        dn: ldap.parseDN(`cn=${username}@${appFqdn},ou=sftp,dc=cloudron`).toString(),
        attributes: {
            homeDirectory: app.storageVolumeId ? `/mnt/app-${app.id}` : `/mnt/appsdata/${app.id}/data`, // see also sftp.js
            objectclass: ['user'],
            objectcategory: 'person',
            cn: user.id,
            uid: `${username}@${appFqdn}`,  // for bind after search
            uidNumber: uidNumber,           // unix uid for ftp access
            gidNumber: uidNumber            // unix gid for ftp access
        }
    };

    finalSend([ obj ], req, res, next);
}

async function verifyAppMailboxPassword(serviceId, username, password) {
    assert.strictEqual(typeof serviceId, 'string');
    assert.strictEqual(typeof username, 'string');
    assert.strictEqual(typeof password, 'string');

    const pattern = serviceId === 'msa' ? 'MAIL_SMTP' : 'MAIL_IMAP';
    const addonId = serviceId === 'msa' ? 'sendmail' : 'recvmail';
    const appId = await addonConfigs.getAppIdByValue(addonId, `%${pattern}_PASSWORD`, password); // search by password because this is unique for each app
    if (!appId) throw new BoxError(BoxError.NOT_FOUND, 'Could not find app');

    const result = await addonConfigs.get(appId, addonId);

    if (!result.some(r => r.name.endsWith(`${pattern}_USERNAME`) && r.value === username)) throw new BoxError(BoxError.INVALID_CREDENTIALS, 'Could not locate user');
}

async function authenticateService(serviceId, dn, req, res, next) {
    trace(`authenticateService: ${req.dn.toString()} (from ${req.connection.ldap.id})`);

    if (!dn.rdns[0].attrs.cn) return next(new ldap.NoSuchObjectError(dn.toString()));

    const email = dn.rdns[0].attrs.cn.value.toLowerCase();
    const parts = email.split('@');
    if (parts.length !== 2) return next(new ldap.NoSuchObjectError(dn.toString()));

    const knownServices = [ 'msa', 'imap', 'pop3', 'sieve', 'sogo' ];
    if (!knownServices.includes(serviceId)) return next(new ldap.OperationsError('Invalid DN. Unknown service'));

    const [error, domain] = await safe(mail.getDomain(parts[1]));
    if (error) return next(new ldap.OperationsError(error.message));
    if (!domain) return next(new ldap.NoSuchObjectError(dn.toString()));

    const serviceNeedsMailbox = serviceId === 'imap' || serviceId === 'sieve' || serviceId === 'pop3' || serviceId === 'sogo';
    if (serviceNeedsMailbox && !domain.enabled) return next(new ldap.NoSuchObjectError(dn.toString()));

    const [getMailboxError, mailbox] = await safe(mail.getMailbox(parts[0], parts[1]));
    if (getMailboxError) return next(new ldap.OperationsError(getMailboxError.message));

    if (serviceNeedsMailbox) {
        if (!mailbox || !mailbox.active) return next(new ldap.NoSuchObjectError(dn.toString()));
        if (serviceId === 'pop3' && !mailbox.enablePop3) return next(new ldap.OperationsError('POP3 is not enabled'));
    }

    const [appPasswordError] = await safe(verifyAppMailboxPassword(serviceId, email, req.credentials || ''));
    if (!appPasswordError) return res.end(); // validated as app

    if (appPasswordError.reason === BoxError.INVALID_CREDENTIALS) return next(new ldap.InvalidCredentialsError(appPasswordError.message));
    if (appPasswordError.reason !== BoxError.NOT_FOUND) return next(new ldap.OperationsError(appPasswordError.message));

    if (!mailbox || !mailbox.active) return next(new ldap.NoSuchObjectError(dn.toString())); // user auth requires active mailbox

    const [verifyError, result] = await safe(verifyMailboxPassword(mailbox, req.credentials || ''));
    if (verifyError && verifyError.reason === BoxError.NOT_FOUND) return next(new ldap.NoSuchObjectError(verifyError.message));
    if (verifyError && verifyError.reason === BoxError.INVALID_CREDENTIALS) return next(new ldap.InvalidCredentialsError(verifyError.message));
    if (verifyError) return next(new ldap.OperationsError(verifyError.message));

    eventlog.upsertLoginEvent(result.ghost ? eventlog.ACTION_USER_LOGIN_GHOST : eventlog.ACTION_USER_LOGIN, AuditSource.MAIL, { mailboxId: email, userId: result.id, user: users.removePrivateFields(result) });

    res.end();
}

async function authenticateMail(req, res, next) {
    if (!req.dn.rdns[1].attrs.ou) return next(new ldap.NoSuchObjectError());
    await authenticateService(req.dn.rdns[1].attrs.ou.value.toLowerCase(), req.dn, req, res, next);
}

// https://ldapwiki.com/wiki/RootDSE / RFC 4512 - ldapsearch -x -h "${CLOUDRON_LDAP_SERVER}" -p "${CLOUDRON_LDAP_PORT}" -b  "" -s base
// ldapjs seems to call this handler for everything when search === ''
async function maybeRootDSE(req, res, next) {
    trace(`maybeRootDSE: requested with scope:${req.scope} dn:${req.dn.toString()}`);

    if (req.scope !== 'base') return next(new ldap.NoSuchObjectError()); // per the spec, rootDSE search require base scope
    if (!req.dn || req.dn.toString() !== '') return next(new ldap.NoSuchObjectError());

    res.send({
        dn: '',
        attributes: {
            objectclass: [ 'RootDSE', 'top', 'OpenLDAProotDSE' ],
            supportedLDAPVersion: '3',
            vendorName: 'Cloudron LDAP',
            vendorVersion: '1.0.0',
            supportedControl: [ ldap.PagedResultsControl.OID ],
            supportedExtension: []
        }
    });
    res.end();
}

async function start() {
    assert(gServer === null, 'Already started');

    const ldapLogger = {
        trace: NOOP,
        debug: NOOP,
        info: trace,
        warn: trace,
        error: trace,
        fatal: trace
    };

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

    gServer.on('error', function (error) {
        trace('start: server error. %o', error);
    });

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

    // http://www.ietf.org/proceedings/43/I-D/draft-srivastava-ldap-mail-00.txt
    gServer.search('ou=mailboxes,dc=cloudron', mailboxSearch); // haraka (address translation), dovecot (LMTP), sogo (mailbox search)
    gServer.bind('ou=mailboxes,dc=cloudron', async function (req, res, next) {  // used for sogo only. this route happens only at sogo login time. after that it will use imap ldap route
        await authenticateService('sogo', req.dn, req, res, next);
    });
    gServer.search('ou=mailaliases,dc=cloudron', mailAliasSearch); // haraka
    gServer.search('ou=mailinglists,dc=cloudron', mailingListSearch); // haraka

    gServer.bind('ou=imap,dc=cloudron', authenticateMail); // dovecot (IMAP auth)
    gServer.bind('ou=msa,dc=cloudron', authenticateMail); // haraka (MSA auth)
    gServer.bind('ou=sieve,dc=cloudron', authenticateMail); // dovecot (sieve auth)
    gServer.bind('ou=pop3,dc=cloudron', authenticateMail); // dovecot (pop3 auth)

    gServer.bind('ou=sftp,dc=cloudron', authenticateSftp);    // sftp
    gServer.search('ou=sftp,dc=cloudron', userSearchSftp);

    gServer.compare('cn=users,ou=groups,dc=cloudron', authenticateApp, groupUsersCompare);
    gServer.compare('cn=admins,ou=groups,dc=cloudron', authenticateApp, groupAdminsCompare);

    // this is the bind for addons (after bind, they might search and authenticate)
    gServer.bind('ou=addons,dc=cloudron', function(req, res /*, next */) {
        trace('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
        trace('application bind: %s', req.dn.toString());
        res.end();
    });

    // directus looks for the "DN" of the bind user
    gServer.search('ou=apps,dc=cloudron', function(req, res, next) {
        const obj = {
            dn: req.dn.toString(),
        };
        finalSend([obj], req, res, next);
    });

    gServer.search('', maybeRootDSE); // when '', it seems the callback is called for everything else

    // just log that an attempt was made to unknown route, this helps a lot during app packaging
    gServer.use(function(req, res, next) {
        trace('not handled: dn %s, scope %s, filter %s (from %s)', req.dn ? req.dn.toString() : '-', req.scope, req.filter ? req.filter.toString() : '-', req.connection.ldap.id);
        return next();
    });

    await util.promisify(gServer.listen.bind(gServer))(constants.LDAP_PORT, '0.0.0.0');
}

async function stop() {
    if (!gServer) return;

    await util.promisify(gServer.close.bind(gServer))();
    gServer = null;
}

export default {
    start,
    stop,
    _MOCK_APP,
    _setMockApp,
};
