import assert from 'node:assert';
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 ipaddr from './ipaddr.js';
import groups from './groups.js';
import ldap from 'ldapjs';
import path from 'node:path';
import paths from './paths.js';
import reverseProxy from './reverseproxy.js';
import safe from 'safetydance';
import settings from './settings.js';
import shellModule from './shell.js';
import users from './users.js';
import util from 'node:util';

const { trace } = logger('directoryserver');
const shell = shellModule('directoryserver');


let gServer = null, gCertificate = null;

const NOOP = function () {};

const SET_LDAP_ALLOWLIST_CMD = path.join(import.meta.dirname, 'scripts/setldapallowlist.sh');

async function getConfig() {
    const value = await settings.get(settings.DIRECTORY_SERVER_KEY);
    if (value === null) return {
        enabled: false,
        secret: '',
        allowlist: ''
    };

    return JSON.parse(value);
}

async function validateConfig(config) {
    const { enabled, secret, allowlist } = config;

    if (!enabled) return;

    if (!secret) throw new BoxError(BoxError.BAD_FIELD, 'secret cannot be empty');

    let gotOne = false;
    for (const line of allowlist.split('\n')) {
        if (!line || line.startsWith('#')) continue;
        const rangeOrIP = line.trim();
        if (!ipaddr.isValid(rangeOrIP) && !ipaddr.isValidCIDR(rangeOrIP)) throw new BoxError(BoxError.BAD_FIELD, `'${rangeOrIP}' is not a valid IP or range`);
        gotOne = true;
    }

    // only allow if we at least have one allowed IP/range
    if (!gotOne) throw new BoxError(BoxError.BAD_FIELD, 'allowlist must at least contain one IP or range');
}

async function authorize(req, res, next) {
    trace('authorize: ', req.connection.ldap.bindDN.toString());

    // this is for connection attempts without previous bind
    if (req.connection.ldap.bindDN.equals('cn=anonymous')) return next(new ldap.InsufficientAccessRightsError());

    // we only allow this one DN to pass
    if (!req.connection.ldap.bindDN.equals(constants.USER_DIRECTORY_LDAP_DN)) return next(new ldap.InsufficientAccessRightsError());

    return next();
}

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();
}

// helper function to deal with pagination
async function userAuth(req, res, next) {
    // extract the common name which might have different attribute names
    const cnAttributeName = Object.keys(req.dn.rdns[0].attrs)[0];
    const commonName = req.dn.rdns[0].attrs[cnAttributeName].value;
    if (!commonName) return next(new ldap.NoSuchObjectError('Missing CN'));

    // totptoken is passed as the "attribute" using the '+' separator in the first RDNS of the request DN
    // when totptoken attribute is present, it signals that we must enforce totp check
    // totp check is currently requested by the client. this is the only way to auth against external cloudron dashboard, external cloudron app and external apps
    const TOTPTOKEN_ATTRIBUTE_NAME = 'totptoken'; // This has to be in-sync with externalldap.js
    const totpToken = TOTPTOKEN_ATTRIBUTE_NAME in req.dn.rdns[0].attrs ? req.dn.rdns[0].attrs[TOTPTOKEN_ATTRIBUTE_NAME].value : null;
    const skipTotpCheck = !(TOTPTOKEN_ATTRIBUTE_NAME in req.dn.rdns[0].attrs);

    let verifyFunc;
    if (cnAttributeName === '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 || '', '', { totpToken, skipTotpCheck }));
    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 stop() {
    if (!gServer) return;

    trace('stopping server');

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

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(pageStart, end) {
        pageStart = (pageStart < min) ? min : pageStart;
        end = (end > max || end < min) ? max : end;
        let i;

        for (i = pageStart; 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();
}

// Will attach req.user if successful
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, allUsers] = await safe(users.list());
    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
    for (const user of allUsers) {
        // skip entries with empty username. Some apps like owncloud can't deal with this
        if (!user.username) continue;

        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' ],
                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`; })
            }
        };

        if (user.totpEnabled) obj.attributes.totpEnabled = true;

        // 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 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 [error, allUsers] = await safe(users.list());
    if (error) return next(new ldap.OperationsError(error.message));

    const results = [];

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

    for (const group of allGroups) {
        const dn = ldap.parseDN(`cn=${group.name},ou=groups,dc=cloudron`);
        const members = group.userIds.filter(function (uid) { return allUsers.map(function (u) { return u.id; }).indexOf(uid) !== -1; });

        const obj = {
            dn: dn.toString(),
            attributes: {
                objectclass: ['group'],
                cn: group.name,
                gidnumber: group.id,
                memberuid: members,
                member: members.map((userId) => `cn=${userId},ou=users,dc=cloudron`),
                uniquemember: members.map((userId) => `cn=${userId},ou=users,dc=cloudron`)
            }
        };

        // 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 start() {
    assert(gServer === null, 'Already running');

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

    gCertificate = await reverseProxy.getDirectoryServerCertificate();

    gServer = ldap.createServer({
        certificate: gCertificate.cert,
        key: gCertificate.key,
        log: ldapLogger
    });

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

    gServer.bind('ou=system,dc=cloudron', async function(req, res, next) {
        trace('system bind: %s (from %s)', req.dn.toString(), req.connection.ldap.id);

        const config = await getConfig();

        if (!req.dn.equals(constants.USER_DIRECTORY_LDAP_DN)) return next(new ldap.InvalidCredentialsError('Invalid DN'));
        if (req.credentials !== config.secret) return next(new ldap.InvalidCredentialsError('Invalid Secret'));

        req.user = { user: 'directoryServerAdmin' };

        res.end();

        // if we use next in the callback, ldapjs requires this after res.end();
        return next();
    });

    gServer.search('ou=users,dc=cloudron', authorize, userSearch);
    gServer.search('ou=groups,dc=cloudron', authorize, groupSearch);
    gServer.bind('ou=users,dc=cloudron', userAuth, async function (req, res) {
        assert.strictEqual(typeof req.user, 'object');

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

        res.end();
    });

    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();
    });

    trace(`starting server on port ${constants.USER_DIRECTORY_LDAPS_PORT}`);
    await util.promisify(gServer.listen.bind(gServer))(constants.USER_DIRECTORY_LDAPS_PORT, '::');
}

// 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 applyConfig(config) {
    assert.strictEqual(typeof config, 'object');

    // this is done only because it's easier for the shell script and the firewall service to get the value
    if (config.enabled) {
        if (!safe.fs.writeFileSync(paths.LDAP_ALLOWLIST_FILE, config.allowlist + '\n', 'utf8')) throw new BoxError(BoxError.FS_ERROR, safe.error.message);
    } else {
        safe.fs.unlinkSync(paths.LDAP_ALLOWLIST_FILE);
    }

    const [error] = await safe(shell.sudo([ SET_LDAP_ALLOWLIST_CMD ], {}));
    if (error) throw new BoxError(BoxError.IPTABLES_ERROR, `Error setting ldap allowlist: ${error.message}`);

    if (!config.enabled) {
        await stop();
        return;
    }

    if (!gServer) await start();
}

async function setConfig(directoryServerConfig, auditSource) {
    assert.strictEqual(typeof directoryServerConfig, 'object');
    assert(auditSource && typeof auditSource === 'object');

    if (constants.DEMO) throw new BoxError(BoxError.BAD_STATE, 'Not allowed in demo mode');

    const oldConfig = await getConfig();

    const config = {
        enabled: directoryServerConfig.enabled,
        secret: directoryServerConfig.secret,
        allowlist: directoryServerConfig.allowlist || ''
    };

    await validateConfig(config);
    await settings.setJson(settings.DIRECTORY_SERVER_KEY, config);
    await applyConfig(config);

    await eventlog.add(eventlog.ACTION_DIRECTORY_SERVER_CONFIGURE, auditSource, { fromEnabled: oldConfig.enabled, toEnabled: config.enabled });
}

async function checkCertificate() {
    assert(gServer !== null, 'Directory server is not running');

    const certificate = await reverseProxy.getDirectoryServerCertificate();
    if (certificate.cert === gCertificate.cert) {
        trace('checkCertificate: certificate has not changed');
        return;
    }

    trace('checkCertificate: certificate changed. restarting');
    await stop();
    await start();
}

export default {
    getConfig,
    setConfig,

    start,
    stop,

    checkCertificate,
};
