import appPasswords from './apppasswords.js';
import assert from 'node:assert';
import BoxError from './boxerror.js';
import crypto from 'node:crypto';
import constants from './constants.js';
import dashboard from './dashboard.js';
import branding from './branding.js';
import database from './database.js';
import logger from './logger.js';
import eventlog from './eventlog.js';
import externalLdap from './externalldap.js';
import hat from './hat.js';
import mail from './mail.js';
import mailer from './mailer.js';
import mailPasswords from './mailpasswords.js';
import mysql from 'mysql2';
import notifications from './notifications.js';
import oidcClients from './oidcclients.js';
import passkeys from './passkeys.js';
import qrcode from 'qrcode';
import safe from 'safetydance';
import settings from './settings.js';
import speakeasy from 'speakeasy';
import tokens from './tokens.js';
import translations from './translations.js';
import { UAParser as uaParser } from 'ua-parser-js';
import userDirectory from './user-directory.js';
import superagent from '@cloudron/superagent';
import util from 'node:util';
import validator from './validator.js';
import _ from './underscore.js';

const { log, trace } = logger('user');

const AP_MAIL = 'mail';
const AP_WEBADMIN = 'webadmin';
const ROLE_ADMIN = 'admin';
const ROLE_USER = 'user';
const ROLE_USER_MANAGER = 'usermanager';
const ROLE_MAIL_MANAGER = 'mailmanager';
const ROLE_OWNER = 'owner';


// the avatar and backgroundImage fields are special and not added here to reduce response sizes
const USERS_FIELDS = [ 'id', 'username', 'email', 'fallbackEmail', 'password', 'salt', 'creationTime', 'inviteToken', 'resetToken', 'displayName', 'language',
    'totpEnabled', 'totpSecret', 'active', 'source', 'role', 'resetTokenCreationTime', 'loginLocationsJson', 'notificationConfigJson',
    '(avatar IS NOT NULL) AS hasAvatar', '(backgroundImage IS NOT NULL) AS hasBackgroundImage' ].join(',');

const DEFAULT_GHOST_LIFETIME = 6 * 60 * 60 * 1000; // 6 hours

const CRYPTO_SALT_SIZE = 64; // 512-bit salt
const CRYPTO_ITERATIONS = 10000; // iterations
const CRYPTO_KEY_LENGTH = 512; // bits
const CRYPTO_DIGEST = 'sha1'; // used to be the default in node 4.1.1 cannot change since it will affect existing db records

const pbkdf2Async = util.promisify(crypto.pbkdf2);
const randomBytesAsync = util.promisify(crypto.randomBytes);

function postProcess(result) {
    assert.strictEqual(typeof result, 'object');

    result.totpEnabled = !!result.totpEnabled;
    result.active = !!result.active;

    result.loginLocations = safe.JSON.parse(result.loginLocationsJson) || [];
    if (!Array.isArray(result.loginLocations)) result.loginLocations = [];
    delete result.loginLocationsJson;

    result.notificationConfig = safe.JSON.parse(result.notificationConfigJson) || [];
    delete result.notificationConfigJson;

    return result;
}

// keep this in sync with validateGroupname and validateAlias
function validateUsername(username) {
    assert.strictEqual(typeof username, 'string');

    if (username.length < 1) return new BoxError(BoxError.BAD_FIELD, 'Username must be atleast 1 char');
    if (username.length >= 200) return new BoxError(BoxError.BAD_FIELD, 'Username too long');

    if (constants.RESERVED_NAMES.indexOf(username) !== -1) return new BoxError(BoxError.BAD_FIELD, 'Username is reserved');

    // also need to consider valid LDAP characters here (e.g '+' is reserved). apps like openvpn require _ to not be used
    if (/[^a-zA-Z0-9.-]/.test(username)) return new BoxError(BoxError.BAD_FIELD, 'Username can only contain alphanumerals, dot and -');

    // app emails are sent using the .app suffix
    if (username.endsWith('.app')) return new BoxError(BoxError.BAD_FIELD, 'Username pattern is reserved for apps');

    return null;
}

function validateEmail(email) {
    assert.strictEqual(typeof email, 'string');

    if (!validator.isEmail(email)) return new BoxError(BoxError.BAD_FIELD, 'Invalid email');

    return null;
}

function validateToken(token) {
    assert.strictEqual(typeof token, 'string');

    if (token.length !== 64) return new BoxError(BoxError.BAD_FIELD, 'Invalid token'); // 256-bit hex coded token

    return null;
}

function validateDisplayName(name) {
    assert.strictEqual(typeof name, 'string');

    return null;
}

async function validateLanguage(language) {
    assert.strictEqual(typeof language, 'string');

    if (language === '') return null; // reset to platform default

    const languages = await translations.listLanguages();
    if (!languages.includes(language)) return new BoxError(BoxError.BAD_FIELD, 'Invalid language');

    return null;
}

function validatePassword(password) {
    assert.strictEqual(typeof password, 'string');

    if (password.length < 8) return new BoxError(BoxError.BAD_FIELD, 'Password must be atleast 8 characters');
    if (password.length > 256) return new BoxError(BoxError.BAD_FIELD, 'Password cannot be more than 256 characters');

    return null;
}

// remove all fields that should never be sent out via REST API
function removePrivateFields(user) {
    const result = _.pick(user, [
        'id', 'username', 'email', 'fallbackEmail', 'displayName', 'groupIds', 'active', 'source', 'role', 'createdAt',
        'totpEnabled', 'notificationConfig', 'hasAvatar', 'hasBackgroundImage' ]);

    result.inviteAccepted = !user.inviteToken; // invite status indicator
    return result;
}

function validateRole(role) {
    assert.strictEqual(typeof role, 'string');

    const ORDERED_ROLES = [ ROLE_USER, ROLE_USER_MANAGER, ROLE_MAIL_MANAGER, ROLE_ADMIN, ROLE_OWNER ];

    if (ORDERED_ROLES.includes(role)) return null;

    return new BoxError(BoxError.BAD_FIELD, `Invalid role '${role}'`);
}

function compareRoles(role1, role2) {
    assert.strictEqual(typeof role1, 'string');
    assert.strictEqual(typeof role2, 'string');

    const ORDERED_ROLES = [ ROLE_USER, ROLE_USER_MANAGER, ROLE_MAIL_MANAGER, ROLE_ADMIN, ROLE_OWNER ];

    const roleInt1 = ORDERED_ROLES.indexOf(role1);
    const roleInt2 = ORDERED_ROLES.indexOf(role2);

    return roleInt1 - roleInt2;
}

async function add(email, data, auditSource) {
    assert.strictEqual(typeof email, 'string');
    assert(data && typeof data === 'object');
    assert(auditSource && typeof auditSource === 'object');

    assert(data.username === null || typeof data.username === 'string');
    assert(data.password === null || typeof data.password === 'string');
    assert.strictEqual(typeof data.displayName, 'string');
    if ('fallbackEmail' in data) assert.strictEqual(typeof data.fallbackEmail, 'string');

    const { displayName } = data;
    let { username, password } = data;
    let fallbackEmail = data.fallbackEmail || '';
    const source = data.source || '';        // empty is local user
    const role = data.role || ROLE_USER;
    const notificationConfig = 'notificationConfig' in data ? data.notificationConfig : null;

    let error;

    if (username !== null) {
        username = username.toLowerCase();
        error = validateUsername(username);
        if (error) throw error;
    }

    if (password !== null) {
        error = validatePassword(password);
        if (error) throw error;
    } else {
        password = hat(8 * 8);
    }

    email = email.toLowerCase();
    error = validateEmail(email);
    if (error) throw error;

    fallbackEmail = fallbackEmail.toLowerCase();
    if (fallbackEmail) {
        error = validateEmail(fallbackEmail);
        if (error) throw error;
    }

    error = validateDisplayName(displayName);
    if (error) throw error;

    error = validateRole(role);
    if (error) throw error;

    const [randomBytesError, salt] = await safe(randomBytesAsync(CRYPTO_SALT_SIZE));
    if (randomBytesError) throw new BoxError(BoxError.CRYPTO_ERROR, randomBytesError);

    const [pbkdf2Error, derivedKey] = await safe(pbkdf2Async(password, salt, CRYPTO_ITERATIONS, CRYPTO_KEY_LENGTH, CRYPTO_DIGEST));
    if (pbkdf2Error) throw new BoxError(BoxError.CRYPTO_ERROR, pbkdf2Error);

    const user = {
        id: 'uid-' + crypto.randomUUID(),
        username,
        email,
        fallbackEmail,
        password: Buffer.from(derivedKey, 'binary').toString('hex'),
        salt: salt.toString('hex'),
        resetToken: '',
        inviteToken: hat(256),  // new users start out a one-time invite token
        displayName,
        source,
        role,
        avatar: null,
        language: '',
        notificationConfigJson: notificationConfig ? JSON.stringify(notificationConfig) : null
    };

    const query = 'INSERT INTO users (id, username, password, email, fallbackEmail, salt, resetToken, inviteToken, displayName, source, role, avatar, language, notificationConfigJson) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)';
    const args = [ user.id, user.username, user.password, user.email, user.fallbackEmail, user.salt, user.resetToken, user.inviteToken, user.displayName, user.source, user.role, user.avatar, user.language, user.notificationConfigJson ];

    [error] = await safe(database.query(query, args));
    if (error && error.sqlCode === 'ER_DUP_ENTRY' && error.sqlMessage.indexOf('users_email') !== -1) throw new BoxError(BoxError.ALREADY_EXISTS, 'email already exists');
    if (error && error.sqlCode === 'ER_DUP_ENTRY' && error.sqlMessage.indexOf('users_username') !== -1) throw new BoxError(BoxError.ALREADY_EXISTS, 'username already exists');
    if (error && error.sqlCode === 'ER_DUP_ENTRY' && error.sqlMessage.indexOf('PRIMARY') !== -1) throw new BoxError(BoxError.ALREADY_EXISTS, 'id already exists');
    if (error) throw error;

    // when this is used to create the owner, then we have to patch the auditSource to contain himself
    if (!auditSource.userId) auditSource.userId = user.id;
    if (!auditSource.username) auditSource.username= user.username;

    await eventlog.add(eventlog.ACTION_USER_ADD, auditSource, { userId: user.id, email: user.email, user: removePrivateFields(user) });

    return user.id;
}

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

    if (constants.DEMO && user.username === constants.DEMO_USERNAME) throw new BoxError(BoxError.BAD_STATE, 'Not allowed in demo mode');

    const arSearch = `JSON_SEARCH(accessRestrictionJson, 'one', ?, NULL, '$.users')`;
    const opSearch = `JSON_SEARCH(operatorsJson, 'one', ?, NULL, '$.users')`;

    const queries = [
        { query: `UPDATE apps SET accessRestrictionJson=JSON_REMOVE(accessRestrictionJson, REPLACE(${arSearch}, '"', '')) WHERE ${arSearch} IS NOT NULL`, args: [ user.id, user.id ] },
        { query: `UPDATE apps SET operatorsJson=JSON_REMOVE(operatorsJson, REPLACE(${opSearch}, '"', '')) WHERE ${opSearch} IS NOT NULL`, args: [ user.id, user.id ] },
        { query: 'DELETE FROM groupMembers WHERE userId = ?', args: [ user.id ] },
        { query: 'DELETE FROM tokens WHERE identifier = ?', args: [ user.id ] },
        { query: 'DELETE FROM appPasswords WHERE userId = ?', args: [ user.id ] },
        { query: 'DELETE FROM mailPasswords WHERE userId = ?', args: [ user.id ] },
        { query: 'DELETE FROM passkeys WHERE userId = ?', args: [ user.id ] },
        { query: 'DELETE FROM users WHERE id = ?', args: [ user.id ] }, // keep this the last query as we check affectedRows below
    ];

    const [error, result] = await safe(database.transaction(queries));
    if (error && error.sqlCode === 'ER_NO_REFERENCED_ROW_2') throw new BoxError(BoxError.NOT_FOUND, error);
    if (error) throw error;
    if (result[queries.length-1].affectedRows !== 1) throw new BoxError(BoxError.NOT_FOUND, 'User not found');

    await eventlog.add(eventlog.ACTION_USER_REMOVE, auditSource, { userId: user.id, user: removePrivateFields(user) });
}

async function list() {
    const results = await database.query(`SELECT ${USERS_FIELDS},GROUP_CONCAT(groupMembers.groupId) AS groupIds ` +
        ' FROM users LEFT OUTER JOIN groupMembers ON users.id = groupMembers.userId ' +
        ' GROUP BY users.id ORDER BY users.username');

    results.forEach(function (result) {
        result.groupIds = result.groupIds ? result.groupIds.split(',') : [ ];
    });

    results.forEach(postProcess);

    return results;
}

// if active is null then both active and inactive users are listed
async function listPaged(search, active, page, perPage) {
    assert(typeof search === 'string' || search === null);
    assert(typeof active === 'boolean' || active === null);
    assert.strictEqual(typeof page, 'number');
    assert.strictEqual(typeof perPage, 'number');

    let query = `SELECT ${USERS_FIELDS},GROUP_CONCAT(groupMembers.groupId) AS groupIds FROM users LEFT OUTER JOIN groupMembers ON users.id = groupMembers.userId `;

    if (search) {
        query += ' WHERE ';
        query += '(';
        query += '(LOWER(users.username) LIKE ' + mysql.escape(`%${search.toLowerCase()}%`) + ')';
        query += ' OR ';
        query += '(LOWER(users.email) LIKE ' + mysql.escape(`%${search.toLowerCase()}%`) + ')';
        query += ' OR ';
        query += '(LOWER(users.displayName) LIKE ' + mysql.escape(`%${search.toLowerCase()}%`) + ')';
        query += ')';
    }

    if (active !== null) {
        if (search) query += ' AND ';
        else query += ' WHERE ';

        query += 'users.active' + (!active ? ' IS NOT ' : ' IS ') + 'TRUE';
    }

    query += ` GROUP BY users.id ORDER BY users.username ASC LIMIT ${(page-1)*perPage},${perPage} `;

    const results = await database.query(query);

    results.forEach(function (result) {
        result.groupIds = result.groupIds ? result.groupIds.split(',') : [ ];
    });

    results.forEach(postProcess);

    return results;
}

async function isActivated() {
    const result = await database.query('SELECT COUNT(*) AS total FROM users');

    return result[0].total !== 0;
}

async function internalGet(fieldName, fieldValue) {
    assert.strictEqual(typeof fieldName, 'string');
    assert.strictEqual(typeof fieldValue, 'string');

    const results = await database.query(`SELECT ${USERS_FIELDS},GROUP_CONCAT(groupMembers.groupId) AS groupIds ` +
    ' FROM users LEFT OUTER JOIN groupMembers ON users.id = groupMembers.userId ' +
    ` GROUP BY users.id HAVING users.${fieldName} = ?`, [ fieldValue ]);

    if (results.length === 0) return null;

    results[0].groupIds = results[0].groupIds ? results[0].groupIds.split(',') : [ ];

    return postProcess(results[0]);
}

async function get(userId) {
    assert.strictEqual(typeof userId, 'string');
    return await internalGet('id', userId);
}

async function getByEmail(email) {
    assert.strictEqual(typeof email, 'string');
    return await internalGet('email', email);
}

async function getByResetToken(resetToken) {
    assert.strictEqual(typeof resetToken, 'string');

    const error = validateToken(resetToken);
    if (error) throw error;

    return await internalGet('resetToken', resetToken);
}

async function getByInviteToken(inviteToken) {
    assert.strictEqual(typeof inviteToken, 'string');

    const error = validateToken(inviteToken);
    if (error) throw error;

    return await internalGet('inviteToken', inviteToken);
}

async function getByUsername(username) {
    assert.strictEqual(typeof username, 'string');

    return await internalGet('username', username);
}

async function update(user, data, auditSource) {
    assert.strictEqual(typeof user, 'object');
    assert.strictEqual(typeof data, 'object');
    assert(auditSource && typeof auditSource === 'object');

    assert(!('totpEnabled' in data) || (typeof data.totpEnabled === 'boolean'));
    assert(!('active' in data) || (typeof data.active === 'boolean'));
    assert(!('loginLocations' in data) || (Array.isArray(data.loginLocations)));

    if (constants.DEMO && user.username === constants.DEMO_USERNAME) throw new BoxError(BoxError.BAD_STATE, 'Not allowed in demo mode');

    if (data.username) {
        // regardless of "account setup", username cannot be changed because admin could have logged in with temp password and apps
        // already know about it
        if (user.username) throw new BoxError(BoxError.CONFLICT, 'Username cannot be changed');
        data.username = data.username.toLowerCase();
        const error = validateUsername(data.username);
        if (error) throw error;
    }

    if (data.email) {
        data.email = data.email.toLowerCase();
        const error = validateEmail(data.email);
        if (error) throw error;
    }

    if (data.fallbackEmail) {
        data.fallbackEmail = data.fallbackEmail.toLowerCase();
        const error = validateEmail(data.fallbackEmail);
        if (error) throw error;
    }

    if (data.role) {
        const error = validateRole(data.role);
        if (error) throw error;
    }

    if (data.language) {
        const error = await validateLanguage(data.language);
        if (error) throw error;
    }

    const args = [], fields = [];
    for (const k in data) {
        if (k === 'totpEnabled' || k === 'active') {
            fields.push(k + ' = ?');
            args.push(data[k] ? 1 : 0);
        } else if (k === 'loginLocations' || k === 'notificationConfig') {
            fields.push(`${k}Json = ?`);
            args.push(JSON.stringify(data[k]));
        } else {
            fields.push(k + ' = ?');
            args.push(data[k]);
        }
    }
    if (args.length == 0) return; // nothing to do
    args.push(user.id);

    const [error, result] = await safe(database.query('UPDATE users SET ' + fields.join(', ') + ' WHERE id = ?', args));
    if (error && error.sqlCode === 'ER_DUP_ENTRY' && error.sqlMessage.indexOf('users_email') !== -1) throw new BoxError(BoxError.ALREADY_EXISTS, 'email already exists');
    if (error && error.sqlCode === 'ER_DUP_ENTRY' && error.sqlMessage.indexOf('users_username') !== -1) throw new BoxError(BoxError.ALREADY_EXISTS, 'username already exists');
    if (error) throw new BoxError(BoxError.DATABASE_ERROR, error);
    if (result.affectedRows !== 1) throw new BoxError(BoxError.NOT_FOUND, 'User not found');

    const newUser = Object.assign({}, user, data);

    await eventlog.add(eventlog.ACTION_USER_UPDATE, auditSource, {
        userId: user.id,
        user: removePrivateFields(newUser),
        roleChanged: newUser.role !== user.role,
        activeStatusChanged: ((newUser.active && !user.active) || (!newUser.active && user.active))
    });
}

async function updateProfile(user, profile, auditSource) {
    assert.strictEqual(typeof user, 'object');
    assert.strictEqual(typeof profile, 'object');
    assert(auditSource && typeof auditSource === 'object');

    if (user.source === 'ldap') throw new BoxError(BoxError.BAD_STATE, 'Cannot update profile of external auth user');

    await update(user, profile, auditSource);
}

async function listByRole(role) {
    assert.strictEqual(typeof role, 'string');

    // the mailer code relies on the first object being the 'owner' (thus the ORDER)
    const results = await database.query(`SELECT ${USERS_FIELDS},GROUP_CONCAT(groupMembers.groupId) AS groupIds ` +
        ' FROM users LEFT OUTER JOIN groupMembers ON users.id = groupMembers.userId ' +
        ' GROUP BY users.id HAVING role = ? ORDER BY users.creationTime', [ role ]);

    results.forEach(function (result) {
        result.groupIds = result.groupIds ? result.groupIds.split(',') : [ ];
    });

    results.forEach(postProcess);

    return results;
}

async function getOwner() {
    const owners = await listByRole(ROLE_OWNER);
    if (owners.length === 0) return null;
    return owners[0];
}

async function getAdmins() {
    const owners = await listByRole(ROLE_OWNER);
    const admins = await listByRole(ROLE_ADMIN);

    return owners.concat(admins);
}

async function getSuperadmins() {
    return await listByRole(ROLE_OWNER);
}

async function setGhost(user, password, expiresAt) {
    assert.strictEqual(typeof user, 'object');
    assert.strictEqual(typeof password, 'string');
    assert.strictEqual(typeof expiresAt, 'number');

    if (!user.username) throw new BoxError(BoxError.BAD_STATE, 'user has no username yet');

    expiresAt = expiresAt || (Date.now() + DEFAULT_GHOST_LIFETIME);

    const ghostData = await settings.getJson(settings.GHOSTS_CONFIG_KEY) || {};
    ghostData[user.username] = { password, expiresAt };

    await settings.setJson(settings.GHOSTS_CONFIG_KEY, ghostData);
}

// returns true if ghost user was matched
async function verifyGhost(username, password) {
    assert.strictEqual(typeof username, 'string');
    assert.strictEqual(typeof password, 'string');

    const ghostData = await settings.getJson(settings.GHOSTS_CONFIG_KEY) || {};

    // either the username is an object with { password, expiresAt } or a string with the password which will expire on first match
    if (username in ghostData) {
        if (typeof ghostData[username] === 'object') {
            if (ghostData[username].expiresAt < Date.now()) {
                trace('verifyGhost: password expired');
                delete ghostData[username];

                await settings.setJson(settings.GHOSTS_CONFIG_KEY, ghostData);

                return false;
            } else if (ghostData[username].password === password) {
                trace('verifyGhost: matched ghost user');
                return true;
            } else {
                return false;
            }
        } else if(ghostData[username] === password) {
            trace('verifyGhost: matched ghost user');
            delete ghostData[username];

            await settings.setJson(settings.GHOSTS_CONFIG_KEY, ghostData);
            return true;
        }
    }

    return false;
}

async function verifyAppPassword(userId, password, identifier) {
    assert.strictEqual(typeof userId, 'string');
    assert.strictEqual(typeof password, 'string');
    assert.strictEqual(typeof identifier, 'string');

    const results = await appPasswords.list(userId);

    const now = new Date();
    const hashedPasswords = results.filter(r => r.identifier === identifier).filter(r => !r.expiresAt || new Date(r.expiresAt) > now).map(r => r.hashedPassword);
    const hash = crypto.createHash('sha256').update(password).digest('base64');

    if (hashedPasswords.includes(hash)) return;

    throw new BoxError(BoxError.INVALID_CREDENTIALS, 'Password is not valid');
}

async function verifyMailPassword(userId, password) {
    assert.strictEqual(typeof userId, 'string');
    assert.strictEqual(typeof password, 'string');

    const result = await mailPasswords.getByUserId(userId);

    if (result.find(r => r.password === password)) return;

    throw new BoxError(BoxError.INVALID_CREDENTIALS, 'Password is not valid');
}

// identifier is only used to check if password is valid for a specific app
async function verify(user, password, identifier, options) {
    assert.strictEqual(typeof user, 'object');
    assert.strictEqual(typeof password, 'string');
    assert.strictEqual(typeof identifier, 'string');
    assert.strictEqual(typeof options, 'object');

    if (!user.active) {
        trace(`verify: ${user.username} is not active`);
        throw new BoxError(BoxError.NOT_FOUND, 'User not active');
    }

    // for just invited users the username may be still null
    if (user.username) {
        const valid = await verifyGhost(user.username, password);

        if (valid) {
            trace(`verify: ${user.username} authenticated via impersonation`);
            user.ghost = true;
            return user;
        }
    }

    const [error] = await safe(verifyAppPassword(user.id, password, identifier));
    if (!error) { // matched app password
        trace(`verify: ${user.username || user.id} matched app password`);
        user.appPassword = true;
        return user;
    }

    const [mailPasswordError] = await safe(verifyMailPassword(user.id, password));
    if (!mailPasswordError) { // matched app password
        trace(`verify: ${user.username || user.id} matched mail password`);
        user.mailPassword = true;
        return user;
    }

    let localTotpCheck; // does 2fa need to be verified with local database 2fa creds
    if (user.source === 'ldap') {
        await externalLdap.verifyPassword(user.username, password, options);
        const externalLdapConfig = await externalLdap.getConfig();
        localTotpCheck = user.totpEnabled && !externalLdap.supports2FA(externalLdapConfig);
    } else {
        const saltBinary = Buffer.from(user.salt, 'hex');
        const [cryptoError, derivedKey] = await safe(pbkdf2Async(password, saltBinary, CRYPTO_ITERATIONS, CRYPTO_KEY_LENGTH, CRYPTO_DIGEST));
        if (cryptoError) throw new BoxError(BoxError.CRYPTO_ERROR, cryptoError);

        const derivedKeyHex = Buffer.from(derivedKey, 'binary').toString('hex');
        if (derivedKeyHex !== user.password) {
            trace(`verify: ${user.username || user.id} provided incorrect password`);
            throw new BoxError(BoxError.INVALID_CREDENTIALS, 'Wrong password');
        }

        localTotpCheck = user.totpEnabled;
    }

    if (localTotpCheck && !options.skipTotpCheck) {
        if (!options.totpToken) throw new BoxError(BoxError.INVALID_CREDENTIALS, 'A totpToken must be provided');
        const verified = speakeasy.totp.verify({ secret: user.totpSecret, encoding: 'base32', token: options.totpToken, window: 2 });
        if (!verified) throw new BoxError(BoxError.INVALID_CREDENTIALS, 'Invalid totpToken');
    }

    return user;
}

async function verifyWithId(id, password, identifier, options) {
    assert.strictEqual(typeof id, 'string');
    assert.strictEqual(typeof password, 'string');
    assert.strictEqual(typeof identifier, 'string');
    assert.strictEqual(typeof options, 'object');

    const user = await get(id);
    if (!user) {
        trace(`verifyWithId: ${id} not found`);
        throw new BoxError(BoxError.NOT_FOUND, 'User not found');
    }

    return await verify(user, password, identifier, options);
}

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

    const user = await getByUsername(username.toLowerCase());
    if (user) return await verify(user, password, identifier, options);

    const [error, newUserId] = await safe(externalLdap.maybeCreateUser(username.toLowerCase()));
    if (error && error.reason === BoxError.BAD_STATE) {
        trace(`verifyWithUsername: ${username} not found`);
        throw new BoxError(BoxError.NOT_FOUND, 'User not found'); // no external ldap or no auto create
    }
    if (error) {
        trace(`verifyWithUsername: failed to auto create user ${username}. %o`, error);
        throw new BoxError(BoxError.NOT_FOUND, 'User not found');
    }

    return await verifyWithId(newUserId, password, identifier, options);
}

async function verifyWithEmail(email, password, identifier, options) {
    assert.strictEqual(typeof email, 'string');
    assert.strictEqual(typeof password, 'string');
    assert.strictEqual(typeof identifier, 'string');
    assert.strictEqual(typeof options, 'object');

    const user = await getByEmail(email.toLowerCase());
    if (!user) {
        trace(`verifyWithEmail: ${email} no such user`);
        throw new BoxError(BoxError.NOT_FOUND, 'User not found');
    }

    return await verify(user, password, identifier, options);
}

async function getPasswordResetLink(user, auditSource) {
    assert.strictEqual(typeof user, 'object');
    assert.strictEqual(typeof auditSource, 'object');

    let resetToken = user.resetToken;
    let resetTokenCreationTime = user.resetTokenCreationTime || 0;

    if (!resetToken || (Date.now() - resetTokenCreationTime > 7 * 24 * 60 * 60 * 1000)) {
        resetToken = hat(256);
        resetTokenCreationTime = new Date();

        await update(user, { resetToken, resetTokenCreationTime }, auditSource);
    }

    const { fqdn:dashboardFqdn } = await dashboard.getLocation();
    const resetLink = `https://${dashboardFqdn}/passwordreset.html?resetToken=${resetToken}`;

    return resetLink;
}

async function sendPasswordResetByIdentifier(identifier, auditSource) {
    assert.strictEqual(typeof identifier, 'string');
    assert.strictEqual(typeof auditSource, 'object');

    const user = identifier.indexOf('@') === -1 ? await getByUsername(identifier.toLowerCase()) : await getByEmail(identifier.toLowerCase());
    if (!user) throw new BoxError(BoxError.NOT_FOUND, 'User not found');
    if (user.source === 'ldap') throw new BoxError(BoxError.BAD_STATE, 'Cannot reset password of external auth user');

    const email = user.fallbackEmail || user.email;

    // security measure to prevent a mail manager or admin resetting the superadmin's password
    const mailDomains = await mail.listDomains();
    if (mailDomains.some(d => d.enabled && email.endsWith(`@${d.domain}`))) throw new BoxError(BoxError.CONFLICT, 'Password reset email cannot be sent to email addresses hosted on the same Cloudron');

    const resetLink = await getPasswordResetLink(user, auditSource);
    await mailer.passwordReset(user, email, resetLink);
}

async function sendPasswordResetEmail(user, email, auditSource) {
    assert.strictEqual(typeof user, 'object');
    assert.strictEqual(typeof email, 'string');
    assert.strictEqual(typeof auditSource, 'object');

    const error = validateEmail(email);
    if (error) throw error;

    // security measure to prevent a mail manager or admin resetting the superadmin's password
    const mailDomains = await mail.listDomains();
    if (mailDomains.some(d => d.enabled && email.endsWith(`@${d.domain}`))) throw new BoxError(BoxError.CONFLICT, 'Password reset email cannot be sent to email addresses hosted on the same Cloudron');

    const resetLink = await getPasswordResetLink(user, auditSource);
    await mailer.passwordReset(user, email, resetLink);
}

async function notifyLoginLocation(user, ip, userAgent, auditSource) {
    assert.strictEqual(typeof user, 'object');
    assert.strictEqual(typeof ip, 'string');
    assert.strictEqual(typeof userAgent, 'string');
    assert.strictEqual(typeof auditSource, 'object');

    log(`notifyLoginLocation: ${user.id} ${ip} ${userAgent} ghost:${!!user.ghost}`);

    if (constants.DEMO) return;
    if (constants.TEST && ip === '127.0.0.1') return;
    if (user.ghost || user.source) return; // for external users, rely on the external source to send login notification to avoid dup login emails

    const response = await superagent.get('https://geolocation.cloudron.io/json').query({ ip }).ok(() => true);
    if (response.status !== 200) return log(`Failed to get geoip info. status: ${response.status}`);

    const country = safe.query(response.body, 'country.names.en', '');
    const city = safe.query(response.body, 'city.names.en', '');

    if (!city || !country) return;

    const ua = uaParser(userAgent);
    const simplifiedUserAgent = ua.browser.name ? `${ua.browser.name} - ${ua.os.name}` : userAgent;

    const knownLogin = user.loginLocations.find(function (l) {
        return l.userAgent === simplifiedUserAgent && l.country === country && l.city === city;
    });

    if (knownLogin) return;

    // purge potentially old locations where ts > now() - 6 months
    const sixMonthsBack = Date.now() - 6 * 30 * 24 * 60 * 60 * 1000;
    const newLoginLocation = { ts: Date.now(), ip, userAgent: simplifiedUserAgent, country, city };
    const loginLocations = user.loginLocations.filter(function (l) { return l.ts > sixMonthsBack; });

    // only stash if we have a real useragent, otherwise warn the user every time
    if (simplifiedUserAgent) loginLocations.push(newLoginLocation);

    await update(user, { loginLocations }, auditSource);

    await safe(mailer.sendNewLoginLocation(user, newLoginLocation), { debug: log });
}

async function setPassword(user, newPassword, auditSource) {
    assert.strictEqual(typeof user, 'object');
    assert.strictEqual(typeof newPassword, 'string');
    assert.strictEqual(typeof auditSource, 'object');

    const error = validatePassword(newPassword);
    if (error) throw error;

    if (constants.DEMO && user.username === constants.DEMO_USERNAME) throw new BoxError(BoxError.BAD_STATE, 'Not allowed in demo mode');
    if (user.source) throw new BoxError(BoxError.CONFLICT, 'User is from an external directory');

    const [randomBytesError, salt] = await safe(randomBytesAsync(CRYPTO_SALT_SIZE));
    if (randomBytesError) throw new BoxError(BoxError.CRYPTO_ERROR, randomBytesError);

    const [pbkdf2Error, derivedKey] = await safe(pbkdf2Async(newPassword, salt, CRYPTO_ITERATIONS, CRYPTO_KEY_LENGTH, CRYPTO_DIGEST));
    if (pbkdf2Error) throw new BoxError(BoxError.CRYPTO_ERROR, pbkdf2Error);

    const data = {
        salt: salt.toString('hex'),
        password: Buffer.from(derivedKey, 'binary').toString('hex'),
        resetToken: ''
    };

    await update(user, data, auditSource);
}

async function createOwner(email, username, password, displayName, auditSource) {
    assert.strictEqual(typeof email, 'string');
    assert.strictEqual(typeof username, 'string');
    assert.strictEqual(typeof password, 'string');
    assert.strictEqual(typeof displayName, 'string');
    assert(auditSource && typeof auditSource === 'object');

    // This is only not allowed for the owner. reset of username validation happens in add()
    if (username === '') throw new BoxError(BoxError.BAD_FIELD, 'Username cannot be empty');

    const activated = await isActivated();
    if (activated) throw new BoxError(BoxError.ALREADY_EXISTS, 'Cloudron already activated');

    const notificationConfig = [notifications.TYPE_BACKUP_FAILED, notifications.TYPE_CERTIFICATE_RENEWAL_FAILED, notifications.TYPE_MANUAL_APP_UPDATE_NEEDED, notifications.TYPE_APP_DOWN, notifications.TYPE_CLOUDRON_UPDATE_FAILED, notifications.TYPE_APP_AUTO_UPDATE_FAILED ];
    return await add(email, { username, password, fallbackEmail: '', displayName, role: ROLE_OWNER, notificationConfig }, auditSource);
}

async function getInviteLink(user, auditSource) {
    assert.strictEqual(typeof user, 'object');
    assert.strictEqual(typeof auditSource, 'object');

    if (user.source) throw new BoxError(BoxError.CONFLICT, 'User is from an external directory');
    if (!user.inviteToken) throw new BoxError(BoxError.BAD_STATE, 'User already used invite link');

    const directoryConfig = await userDirectory.getProfileConfig();
    const { fqdn:dashboardFqdn } = await dashboard.getLocation();
    let inviteLink = `https://${dashboardFqdn}/setupaccount.html?inviteToken=${user.inviteToken}&email=${encodeURIComponent(user.email)}`;

    if (user.username) inviteLink += `&username=${encodeURIComponent(user.username)}`;
    if (user.displayName) inviteLink += `&displayName=${encodeURIComponent(user.displayName)}`;
    if (directoryConfig.lockUserProfiles) inviteLink += '&profileLocked=true';

    return inviteLink;
}

async function sendInviteEmail(user, email, auditSource) {
    assert.strictEqual(typeof user, 'object');
    assert.strictEqual(typeof email, 'string');
    assert.strictEqual(typeof auditSource, 'object');

    const error = validateEmail(email);
    if (error) throw error;

    const inviteLink = await getInviteLink(user, auditSource);
    await mailer.sendInvite(user, null /* invitor */, email, inviteLink);
}

async function setupAccount(user, data, auditSource) {
    assert.strictEqual(typeof user, 'object');
    assert.strictEqual(typeof data, 'object');
    assert(auditSource && typeof auditSource === 'object');

    const profileConfig = await userDirectory.getProfileConfig();

    // error out if admin has not provided a username
    if (profileConfig.lockUserProfiles) {
        if (!user.username) throw new BoxError(BoxError.CONFLICT, 'Account cannot be setup without a username');
        if (data.username) throw new BoxError(BoxError.CONFLICT, 'Username cannot be changed because profiles are locked');
    }

    const tmp = { inviteToken: '' };

    if (data.username) {
        const error = validateUsername(data.username);
        if (error) throw error;

        tmp.username = data.username;
    }

    if (data.displayName) {
        const error = validateDisplayName(data.displayName);
        if (error) throw error;

        tmp.displayName = data.displayName;
    }

    const error = validatePassword(data.password);
    if (error) throw error;

    await update(user, tmp, auditSource);
    await setPassword(user, data.password, auditSource);

    const token = { clientId: oidcClients.ID_WEBADMIN, identifier: user.id, expires: Date.now() + constants.DEFAULT_TOKEN_EXPIRATION_MSECS, allowedIpRanges: '' };
    const result = await tokens.add(token);
    return result.accessToken;
}

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

    const externalLdapConfig = await externalLdap.getConfig();
    if (user.source === 'ldap' && externalLdap.supports2FA(externalLdapConfig)) throw new BoxError(BoxError.BAD_STATE, 'Cannot disable 2FA of external auth user');

    if (constants.DEMO && user.username === constants.DEMO_USERNAME) throw new BoxError(BoxError.BAD_STATE, 'Not allowed in demo mode');

    if (user.totpEnabled) throw new BoxError(BoxError.ALREADY_EXISTS, '2FA is already enabled');

    const cloudronName = await branding.getCloudronName();
    const secret = speakeasy.generateSecret({ name: `${cloudronName}` });

    await update(user, { totpSecret: secret.base32, totpEnabled: false }, auditSource);

    const [error, dataUrl] = await safe(qrcode.toDataURL(secret.otpauth_url));
    if (error) throw new BoxError(BoxError.INTERNAL_ERROR, error);

    return { secret: secret.base32, qrcode: dataUrl };
}

async function enableTotp(user, totpToken, auditSource) {
    assert.strictEqual(typeof user, 'object');
    assert.strictEqual(typeof totpToken, 'string');
    assert(auditSource && typeof auditSource === 'object');

    const externalLdapConfig = await externalLdap.getConfig();
    if (user.source === 'ldap' && externalLdap.supports2FA(externalLdapConfig)) throw new BoxError(BoxError.BAD_STATE, 'Cannot enable 2FA of external auth user');

    const verified = speakeasy.totp.verify({ secret: user.totpSecret, encoding: 'base32', token: totpToken, window: 2 });
    if (!verified) throw new BoxError(BoxError.INVALID_CREDENTIALS, 'Invalid 2FA code');

    if (user.totpEnabled) throw new BoxError(BoxError.ALREADY_EXISTS, '2FA already enabled');

    await update(user, { totpEnabled: true }, auditSource);
}

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

    const externalLdapConfig = await externalLdap.getConfig();
    if (user.source === 'ldap' && externalLdap.supports2FA(externalLdapConfig)) throw new BoxError(BoxError.BAD_STATE, 'Cannot disable 2FA of external auth user');

    await update(user, { totpEnabled: false, totpSecret: '' }, auditSource);
}

async function getAvatar(user) {
    assert.strictEqual(typeof user, 'object');

    const result = await database.query('SELECT avatar FROM users WHERE id = ?', [ user.id ]);
    if (result.length === 0) throw new BoxError(BoxError.NOT_FOUND, 'User not found');
    return result[0].avatar;
}

async function setAvatar(user, avatar) {
    assert.strictEqual(typeof user, 'object');
    assert(Buffer.isBuffer(avatar) || avatar === null);

    const result = await database.query('UPDATE users SET avatar=? WHERE id = ?', [ avatar, user.id ]);
    if (result.length === 0) throw new BoxError(BoxError.NOT_FOUND, 'User not found');
}

async function getBackgroundImage(user) {
    assert.strictEqual(typeof user, 'object');

    const result = await database.query('SELECT backgroundImage FROM users WHERE id = ?', [ user.id ]);
    if (result.length === 0) throw new BoxError(BoxError.NOT_FOUND, 'User not found');
    return result[0].backgroundImage;
}

async function setBackgroundImage(user, backgroundImage) {
    assert.strictEqual(typeof user, 'object');
    assert(Buffer.isBuffer(backgroundImage) || backgroundImage === null);

    const result = await database.query('UPDATE users SET backgroundImage=? WHERE id = ?', [ backgroundImage, user.id ]);
    if (result.length === 0) throw new BoxError(BoxError.NOT_FOUND, 'User not found');
}

async function setNotificationConfig(user, notificationConfig, auditSource) {
    assert.strictEqual(typeof user, 'object');
    assert(Array.isArray(notificationConfig));
    assert(auditSource && typeof auditSource === 'object');

    await update(user, { notificationConfig }, auditSource);
}

async function resetSources() {
    await database.query('UPDATE users SET source = ?', [ '' ]);
}

function parseDisplayName(displayName) {
    assert.strictEqual(typeof displayName, 'string');

    const middleName = '';
    const idx = displayName.indexOf(' ');
    if (idx === -1) return { firstName: displayName, lastName: '', middleName };

    const firstName = displayName.substring(0, idx);
    const lastName = displayName.substring(idx+1);
    return { firstName, lastName, middleName };
}

export default {
    removePrivateFields,
    add,
    createOwner,
    isActivated,
    list,
    listPaged,
    get,
    getByInviteToken,
    getByResetToken,
    getByUsername,
    getByEmail,
    getOwner,
    getAdmins,
    getSuperadmins,
    verifyWithId,
    verifyWithUsername,
    verifyWithEmail,
    setPassword,
    setGhost,
    updateProfile,
    update,
    del,
    setTotpSecret,
    enableTotp,
    disableTotp,
    sendPasswordResetByIdentifier,
    getPasswordResetLink,
    sendPasswordResetEmail,
    getInviteLink,
    sendInviteEmail,
    notifyLoginLocation,
    setupAccount,
    setAvatar,
    getAvatar,
    getBackgroundImage,
    setBackgroundImage,
    setNotificationConfig,
    resetSources,
    parseDisplayName,
    AP_MAIL,
    AP_WEBADMIN,
    ROLE_ADMIN,
    ROLE_USER,
    ROLE_USER_MANAGER,
    ROLE_MAIL_MANAGER,
    ROLE_OWNER,
    compareRoles,
};
