import assert from 'node:assert';
import AuditSource from './auditsource.js';
import BoxError from './boxerror.js';
import constants from './constants.js';
import cron from './cron.js';
import logger from './logger.js';
import eventlog from './eventlog.js';
import groups from './groups.js';
import ldap from 'ldapjs';
import safe from 'safetydance';
import settings from './settings.js';
import tasks from './tasks.js';
import users from './users.js';
import util from 'node:util';

const { log } = logger('externalldap');


function removePrivateFields(ldapConfig) {
    assert.strictEqual(typeof ldapConfig, 'object');
    delete ldapConfig.bindPassword;
    return ldapConfig;
}

function injectPrivateFields(newConfig, currentConfig) {
    if (!Object.hasOwn(newConfig, 'bindPassword')) newConfig.bindPassword = currentConfig.bindPassword;
}

function translateUser(ldapConfig, ldapUser) {
    assert.strictEqual(typeof ldapConfig, 'object');

    // RFC: https://datatracker.ietf.org/doc/html/rfc2798
    const user = {
        username: typeof ldapUser[ldapConfig.usernameField] === 'string' ? ldapUser[ldapConfig.usernameField].toLowerCase() : '',
        email: ldapUser.mail || ldapUser.mailPrimaryAddress,
        totpEnabled: !!ldapUser.totpEnabled,
        displayName: ldapUser.displayName || ldapUser.cn // user.giveName + ' ' + user.sn
    };

    if (!user.username || !user.email || !user.displayName) {
        log(`[Invalid LDAP user] username=${user.username} email=${user.email} displayName=${user.displayName}`);
        return null;
    }

    return user;
}

function supports2FA(config) {
    return config.provider === 'cloudron';
}

// performs service bind if required
async function getClient(config, options) {
    assert.strictEqual(typeof config, 'object');
    assert.strictEqual(typeof options, 'object');

    // basic validation to not crash
    try { ldap.parseDN(config.baseDn); } catch (e) { throw new BoxError(BoxError.BAD_FIELD, `invalid baseDn ${config.baseDn}: ${e.message}`); }
    try { ldap.parseFilter(config.filter); } catch (e) { throw new BoxError(BoxError.BAD_FIELD, `invalid filter ${config.filter}: ${e.mssage}`); }

    let client;
    try {
        const ldapConfig = {
            url: config.url,
            tlsOptions: {
                rejectUnauthorized: config.acceptSelfSignedCerts ? false : true
            },
            // https://github.com/ldapjs/node-ldapjs/issues/486
            timeout: 60000,
            connectTimeout: 10000
        };

        client = ldap.createClient(ldapConfig);
    } catch (e) {
        if (e instanceof ldap.ProtocolError) throw new BoxError(BoxError.BAD_FIELD, 'url protocol is invalid');
        throw new BoxError(BoxError.INTERNAL_ERROR, `Client creation error: ${e.message}`);
    }

    return await new Promise((resolve, reject) => {
        // ensure we don't just crash
        client.on('error', function (error) { // don't reject, we must have gotten a bind error
            log('getClient: ExternalLdap client error:', error);
        });

        // skip bind auth if none exist or if not wanted
        if (!config.bindDn || !options.bind) return resolve(client);

        client.bind(config.bindDn, config.bindPassword, function (error) {
            if (error instanceof ldap.InvalidCredentialsError) return reject(new BoxError(BoxError.INVALID_CREDENTIALS, 'Incorrect bind password'));
            if (error) return reject(new BoxError(BoxError.EXTERNAL_ERROR, `Bind error: ${error.message}`));

            resolve(client);
        });
    });
}

async function clientSearch(client, dn, searchOptions) {
    assert.strictEqual(typeof client, 'object');
    assert.strictEqual(typeof dn, 'string');
    assert.strictEqual(typeof searchOptions, 'object');

    // basic validation to not crash
    try { ldap.parseDN(dn); } catch (e) { throw new BoxError(BoxError.BAD_FIELD, `invalid dn ${dn}: ${e.message}`); }

    return await new Promise((resolve, reject) => {
        client.search(dn, searchOptions, function (error, result) {
            if (error instanceof ldap.NoSuchObjectError) return reject(new BoxError(BoxError.NOT_FOUND, `dn not found ${dn}`));
            if (error) return reject(new BoxError(BoxError.EXTERNAL_ERROR, `search error: ${error.message}`));

            const ldapObjects = [];

            result.on('searchEntry', entry => ldapObjects.push(entry.object));
            result.on('error', searchError => reject(new BoxError(BoxError.EXTERNAL_ERROR, `search error: ${searchError.message}`)));

            result.on('end', function (searchResult) {
                if (searchResult.status !== 0) return reject(new BoxError(BoxError.EXTERNAL_ERROR, 'Server returned status ' + searchResult.status));

                resolve(ldapObjects);
            });
        });
    });
}

async function supportsPagination(client) {
    assert.strictEqual(typeof client, 'object');

    const searchOptions = {
        scope: 'base',
        filter: '(objectClass=*)',
        attributes: ['supportedControl', 'supportedExtension', 'supportedFeature']
    };

    const result = await clientSearch(client, '', searchOptions);
    const controls = result.supportedControl;
    if (!controls || !Array.isArray(controls)) {
        log('supportsPagination: no supportedControl attribute returned');
        return false;
    }

    if (!controls.includes(ldap.PagedResultsControl.OID)) {
        log('supportsPagination: server does not support pagination. Available controls:', controls);
        return false;
    }

    log('supportsPagination: server supports pagination');
    return true;
}

async function ldapGetByDN(config, dn) {
    assert.strictEqual(typeof config, 'object');
    assert.strictEqual(typeof dn, 'string');

    log(`ldapGetByDN: Get object at ${dn}`);

    const client = await getClient(config, { bind: true });
    const paged = await supportsPagination(client);
    const searchOptions = {
        paged,
        scope: 'sub'
    };
    const result = await clientSearch(client, dn, searchOptions);
    client.unbind();
    if (result.length === 0) throw new BoxError(BoxError.NOT_FOUND, `dn ${dn} not found`);
    return result[0];
}

async function ldapUserSearch(config, options) {
    assert.strictEqual(typeof config, 'object');
    assert.strictEqual(typeof options, 'object');

    const client = await getClient(config, { bind: true });
    const paged = await supportsPagination(client);

    const searchOptions = {
        paged,
        filter: ldap.parseFilter(config.filter),
        scope: 'sub'
    };

    if (options.filter) { // https://github.com/ldapjs/node-ldapjs/blob/master/docs/filters.md
        const extraFilter = ldap.parseFilter(options.filter);
        searchOptions.filter = new ldap.AndFilter({ filters: [ extraFilter, searchOptions.filter ] });
    }

    const result = await clientSearch(client, config.baseDn, searchOptions);
    client.unbind();
    return result;
}

async function ldapGroupSearch(config, options) {
    assert.strictEqual(typeof config, 'object');
    assert.strictEqual(typeof options, 'object');

    const searchOptions = {
        paged: true,
        scope: 'sub'    // We may have to make this configurable
    };

    if (config.groupFilter) searchOptions.filter = ldap.parseFilter(config.groupFilter);

    if (options.filter) { // https://github.com/ldapjs/node-ldapjs/blob/master/docs/filters.md
        const extraFilter = ldap.parseFilter(options.filter);
        searchOptions.filter = new ldap.AndFilter({ filters: [ extraFilter, searchOptions.filter ] });
    }

    const client = await getClient(config, { bind: true });
    const result = await clientSearch(client, config.groupBaseDn, searchOptions);
    client.unbind();
    return result;
}

async function testConfig(config) {
    assert.strictEqual(typeof config, 'object');

    if (config.provider === 'noop') return null;

    if (!config.url) return new BoxError(BoxError.BAD_FIELD, 'url must not be empty');
    if (!config.url.startsWith('ldap://') && !config.url.startsWith('ldaps://')) return new BoxError(BoxError.BAD_FIELD, 'url is missing ldap:// or ldaps:// prefix');
    if (!config.usernameField) config.usernameField = 'uid';

    // bindDn may not be a dn!
    if (!config.baseDn) return new BoxError(BoxError.BAD_FIELD, 'basedn must not be empty');
    try { ldap.parseDN(config.baseDn); } catch (e) { throw new BoxError(BoxError.BAD_FIELD, `invalid base ${config.baseDn}: ${e.message}`); }

    if (!config.filter) return new BoxError(BoxError.BAD_FIELD, 'filter must not be empty');
    try { ldap.parseFilter(config.filter); } catch (e) { return new BoxError(BoxError.BAD_FIELD, `invalid filter ${config.filter}: ${e.message}`); }

    if ('syncGroups' in config && typeof config.syncGroups !== 'boolean') return new BoxError(BoxError.BAD_FIELD, 'syncGroups must be a boolean');
    if ('acceptSelfSignedCerts' in config && typeof config.acceptSelfSignedCerts !== 'boolean') return new BoxError(BoxError.BAD_FIELD, 'acceptSelfSignedCerts must be a boolean');

    if (config.syncGroups) {
        if (!config.groupBaseDn) return new BoxError(BoxError.BAD_FIELD, 'groupBaseDn must not be empty');
        try { ldap.parseDN(config.groupBaseDn); } catch (e) { return new BoxError(BoxError.BAD_FIELD, `invalid groupBaseDn ${config.groupBaseDn}: ${e.message}`); }

        if (!config.groupFilter) return new BoxError(BoxError.BAD_FIELD, 'groupFilter must not be empty');
        try { ldap.parseFilter(config.groupFilter); } catch (e) { return new BoxError(BoxError.BAD_FIELD, `invalid groupFilter ${config.groupFilter}: ${e.message}`); }

        if (!config.groupnameField || typeof config.groupnameField !== 'string') return new BoxError(BoxError.BAD_FIELD, 'groupFilter must not be empty');
    }

    const [error, client] = await safe(getClient(config, { bind: true }));
    if (error) return error;

    const opts = {
        filter: config.filter,
        scope: 'sub'
    };

    const [searchError, ] = await safe(clientSearch(client, config.baseDn, opts));
    client.unbind();
    if (searchError) return searchError;

    return null;
}

async function getConfig() {
    const value = await settings.get(settings.EXTERNAL_LDAP_KEY);
    if (value === null) return { provider: 'noop', autoCreate: false };

    const config = JSON.parse(value);
    if (!config.autoCreate) config.autoCreate = false; // ensure new keys
    return config;
}

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

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

    const currentConfig = await getConfig();

    injectPrivateFields(newConfig, currentConfig);

    const error = await testConfig(newConfig);
    if (error) throw error;

    await settings.setJson(settings.EXTERNAL_LDAP_KEY, newConfig);

    if (newConfig.provider === 'noop') {
        await users.resetSources(); // otherwise, the owner could be 'ldap' source and lock themselves out
        await groups.resetSources();
    }

    await eventlog.add(eventlog.ACTION_EXTERNAL_LDAP_CONFIGURE, auditSource, { oldConfig: removePrivateFields(currentConfig), config: removePrivateFields(newConfig) });

    await cron.handleExternalLdapChanged(newConfig);
}

async function maybeCreateUser(identifier) {
    assert.strictEqual(typeof identifier, 'string');

    const config = await getConfig();
    if (config.provider === 'noop') throw new BoxError(BoxError.BAD_STATE, 'not enabled');
    if (!config.autoCreate) throw new BoxError(BoxError.BAD_STATE, 'auto create not enabled');

    const ldapUsers = await ldapUserSearch(config, { filter: `${config.usernameField}=${identifier}` });
    if (ldapUsers.length === 0) throw new BoxError(BoxError.NOT_FOUND, `no users found for filter ${config.usernameField}=${identifier}`);
    if (ldapUsers.length > 1) throw new BoxError(BoxError.CONFLICT, `more than 1 user matches filter ${config.usernameField}=${identifier}`);

    const user = translateUser(config, ldapUsers[0]);
    if (!user) throw new BoxError(BoxError.BAD_FIELD, 'Failed to translate user');

    return await users.add(user.email, { username: user.username, password: null, displayName: user.displayName, source: 'ldap' }, AuditSource.EXTERNAL_LDAP);
}

async function verifyPassword(username, password, options) {
    assert.strictEqual(typeof username, 'string');
    assert.strictEqual(typeof password, 'string');
    assert.strictEqual(typeof options, 'object');

    const config = await getConfig();
    if (config.provider === 'noop') throw new BoxError(BoxError.BAD_STATE, 'not enabled');

    const ldapUsers = await ldapUserSearch(config, { filter: `${config.usernameField}=${username}` });
    if (ldapUsers.length === 0) throw new BoxError(BoxError.NOT_FOUND, 'no such user');
    if (ldapUsers.length > 1) throw new BoxError(BoxError.CONFLICT, 'multiple users found');

    const client = await getClient(config, { bind: false });

    let userAuthDn;
    if (!options.skipTotpCheck && supports2FA(config)) {
        // inject totptoken into first attribute. in ldap, '+' is the attribute separator in a RDNS
        const rdns = ldapUsers[0].dn.split(',');
        userAuthDn = `${rdns[0]}+totptoken=${options.totpToken},` + rdns.slice(1).join(',');
    } else {
        userAuthDn = ldapUsers[0].dn;
    }

    const [error] = await safe(util.promisify(client.bind.bind(client))(userAuthDn, password));
    client.unbind();
    if (error instanceof ldap.InvalidCredentialsError) throw new BoxError(BoxError.INVALID_CREDENTIALS, error.lde_message);
    if (error) throw new BoxError(BoxError.EXTERNAL_ERROR, `Bind error: ${error.message}`);

    const user = translateUser(config, ldapUsers[0]);
    if (!user) throw new BoxError(BoxError.BAD_FIELD, 'could not translate user');

    return user;
}

async function startSyncer() {
    const config = await getConfig();
    if (config.provider === 'noop') throw new BoxError(BoxError.BAD_STATE, 'not enabled');

    const taskId = await tasks.add(tasks.TASK_SYNC_EXTERNAL_LDAP, []);
    safe(tasks.startTask(taskId, {}), { debug: log }); // background
    return taskId;
}

async function syncUsers(config, progressCallback) {
    assert.strictEqual(typeof config, 'object');
    assert.strictEqual(typeof progressCallback, 'function');

    const ldapUsers = await ldapUserSearch(config, {});

    log(`syncUsers: Found ${ldapUsers.length} users`);

    let percent = 10;
    const step = 28 / (ldapUsers.length + 1);

    // we ignore all errors here and just log them for now
    for (let i = 0; i < ldapUsers.length; i++) {
        const ldapUser = translateUser(config, ldapUsers[i]);
        if (!ldapUser) continue;

        percent = Math.min(percent + step, 39);
        progressCallback({ percent: Math.round(percent), message: `Syncing user ${ldapUser.username}` });

        const user = await users.getByUsername(ldapUser.username);

        if (!user) {
            log(`syncUsers: [adding user] username=${ldapUser.username} email=${ldapUser.email} displayName=${ldapUser.displayName}`);

            const [userAddError] = await safe(users.add(ldapUser.email, { username: ldapUser.username, password: null, displayName: ldapUser.displayName, source: 'ldap' }, AuditSource.EXTERNAL_LDAP));
            if (userAddError) log('syncUsers: Failed to create user. %j %o', ldapUser, userAddError);
        } else if (user.source !== 'ldap') {
            log(`syncUsers: [mapping user] username=${ldapUser.username} email=${ldapUser.email} displayName=${ldapUser.displayName}`);

            const [userMappingError] = await safe(users.update(user, { email: ldapUser.email, fallbackEmail: ldapUser.email, displayName: ldapUser.displayName, source: 'ldap' }, AuditSource.EXTERNAL_LDAP));
            if (userMappingError) log('Failed to map user. %j %o', ldapUser, userMappingError);
        } else if (user.email !== ldapUser.email || user.displayName !== ldapUser.displayName) {
            log(`syncUsers: [updating user] username=${ldapUser.username} email=${ldapUser.email} displayName=${ldapUser.displayName}`);

            const [userUpdateError] = await safe(users.update(user, { email: ldapUser.email, fallbackEmail: ldapUser.email, displayName: ldapUser.displayName }, AuditSource.EXTERNAL_LDAP));
            if (userUpdateError) log('Failed to update user. %j %o', ldapUser, userUpdateError);
        } else {
            // user known and up-to-date
            log(`syncUsers: [up-to-date user] username=${ldapUser.username} email=${ldapUser.email} displayName=${ldapUser.displayName}`);
        }
    }

    log('syncUsers: done');
}

async function syncGroups(config, progressCallback) {
    assert.strictEqual(typeof config, 'object');
    assert.strictEqual(typeof progressCallback, 'function');

    if (!config.syncGroups) {
        log('syncGroups: Group sync is disabled');
        progressCallback({ percent: 70, message: 'Skipping group sync' });
        return [];
    }

    const ldapGroups = await ldapGroupSearch(config, {});

    log(`syncGroups: Found ${ldapGroups.length} groups:`);
    log(ldapGroups);

    let percent = 40;
    const step = 28 / (ldapGroups.length + 1);

    for (const ldapGroup of ldapGroups) {
        let groupName = ldapGroup[config.groupnameField];
        if (!groupName) return;
        if (typeof groupName !== 'string') return; // some servers return empty array for unknown properties :-/

        groupName = groupName.toLowerCase();

        percent = Math.min(percent + step, 69);
        progressCallback({ percent: Math.round(percent), message: `Syncing group ${groupName}` });

        const result = await groups.getByName(groupName);

        if (!result) {
            log(`syncGroups: [adding group] groupname=${groupName}`);
            const [error] = await safe(groups.add({ name: groupName, source: 'ldap' }, AuditSource.EXTERNAL_LDAP));
            if (error) log('syncGroups: Failed to create group', groupName, error);
        } else {
            // convert local group to ldap group. 2 reasons:
            // 1. we reset source flag when externalldap is disabled. if we renable, it automatically converts
            // 2. externalldap connector usually implies user wants to user external users/groups.
            groups.update(result.id, { source: 'ldap' });
            log(`syncGroups: [up-to-date group] groupname=${groupName}`);
        }
    }

    log('syncGroups: sync done');
}

async function syncGroupMembers(config, progressCallback) {
    assert.strictEqual(typeof config, 'object');
    assert.strictEqual(typeof progressCallback, 'function');

    if (!config.syncGroups) {
        log('syncGroupMembers: Group users sync is disabled');
        progressCallback({ percent: 98, message: 'Skipping group member sync' });
        return [];
    }

    const allGroups = await groups.listWithMembers();
    const ldapGroups = allGroups.filter(function (g) { return g.source === 'ldap'; });
    log(`syncGroupMembers: Found ${ldapGroups.length} groups to sync users`);

    let percent = 70;
    const step = 28 / (ldapGroups.length + 1);

    for (const ldapGroup of ldapGroups) {
        percent = Math.min(percent + step, 98);
        progressCallback({ percent: Math.round(percent), message: `Syncing members of ${ldapGroup.name}` });
        log(`syncGroupMembers: Sync users for group ${ldapGroup.name}`);

        const result = await ldapGroupSearch(config, {});
        if (!result || result.length === 0) {
            log(`syncGroupMembers: Unable to find group ${ldapGroup.name} ignoring for now.`);
            continue;
        }

        // since our group names are lowercase we cannot use potentially case matching ldap filters
        const found = result.find(function (r) {
            if (!r[config.groupnameField]) return false;
            return r[config.groupnameField].toLowerCase() === ldapGroup.name;
        });

        if (!found) {
            log(`syncGroupMembers: Unable to find group ${ldapGroup.name} ignoring for now.`);
            continue;
        }

        let ldapGroupMembers = found.member || found.uniquemember || [];

        // if only one entry is in the group ldap returns a string, not an array!
        if (typeof ldapGroupMembers === 'string') ldapGroupMembers = [ ldapGroupMembers ];

        log(`syncGroupMembers: Group ${ldapGroup.name} has ${ldapGroupMembers.length} members.`);

        const userIds = [];
        for (const memberDn of ldapGroupMembers) {
            const [ldapError, memberResult] = await safe(ldapGetByDN(config, memberDn));
            if (ldapError) {
                log(`syncGroupMembers: Group ${ldapGroup.name} failed to get ${memberDn}: %o`, ldapError);
                continue;
            }

            log(`syncGroupMembers: Group ${ldapGroup.name} has member object ${memberDn}`);

            const username = memberResult[config.usernameField]?.toLowerCase();
            if (!username) continue;

            const [getError, userObject] = await safe(users.getByUsername(username));
            if (getError || !userObject) {
                log(`syncGroupMembers: Failed to get user by username ${username}. %o`, getError ? getError : 'User not found');
                continue;
            }

            userIds.push(userObject.id);
        }
        const membersChanged = ldapGroup.userIds.length !== userIds.length || ldapGroup.userIds.some(id => !userIds.includes(id));
        if (membersChanged) {
            log(`syncGroupMembers: Group ${ldapGroup.name} changed.`);
            const [setError] = await safe(groups.setMembers(ldapGroup, userIds, { skipSourceCheck: true }, AuditSource.EXTERNAL_LDAP));
            if (setError) log(`syncGroupMembers: Failed to set members of group ${ldapGroup.name}. %o`, setError);
        } else {
            log(`syncGroupMembers: Group ${ldapGroup.name} is unchanged.`);
        }
    }

    log('syncGroupMembers: done');
}

async function sync(progressCallback) {
    assert.strictEqual(typeof progressCallback, 'function');

    progressCallback({ percent: 10, message: 'Starting ldap user sync' });

    const config = await getConfig();
    if (config.provider === 'noop') throw new BoxError(BoxError.BAD_STATE, 'not enabled');

    await syncUsers(config, progressCallback);
    await syncGroups(config, progressCallback);
    await syncGroupMembers(config, progressCallback);

    progressCallback({ percent: 100, message: 'Done' });

    log('sync: done');
}

export default {
    getConfig,
    setConfig,

    verifyPassword,
    maybeCreateUser,

    supports2FA,

    startSyncer,

    removePrivateFields,

    sync
};
