'use strict';

const { Client, Attribute, Change } = require('ldapts');
const { LRUCache } = require('lru-cache');
const crypto = require('crypto');

const {Mail} = require('./email');
const {Token, InviteToken, PasswordResetToken} = require('./token');
const {Group} = require('./group_ldap');
const {UserVerification} = require('./verification');
const conf = require('@simpleworkjs/conf').ldap;
// Connection + escaping come from the shared @simpleworkjs/ldap package. The
// wrappers below preserve this file's no-arg call signatures (makeClient() /
// withClient(fn)) so no call site changes; sso's makeClient passes no
// tlsOptions, which the shared client forwards as undefined — identical to the
// previous `new Client({ url: conf.url })`.
const { makeClient: _makeClient, withClient: _withClient, escapeFilter, escapeDN } = require('@simpleworkjs/ldap');
const escapeLDAPSearchValue = escapeFilter;
const escapeLDAPDNValue = escapeDN;

function hashPasswordSSHA512(password) {
	const salt = crypto.randomBytes(8);
	const hash = crypto.createHash('sha512').update(password).update(salt).digest();
	return '{SSHA512}' + Buffer.concat([hash, salt]).toString('base64');
}

const cache = new LRUCache({
	// how long to live in ms
	ttlAutopurge: true,
  ttl: 1000 * 60 * 5,
});

function makeClient() {
	return _makeClient(conf);
}

async function withClient(fn) {
	return _withClient(conf, fn);
}

// Compute the next available uid/gidNumber: the highest existing value below
// conf.uidGidReservedFloor, plus one -- or conf.uidGidMin if there are no
// such entries yet. Entries at/above the reserved floor (e.g. a bootstrap
// admin deliberately given a high, easily-recognizable id -- see
// theta-env's bootstrap.js) are ignored, so they don't drag every real
// user's id up into that same range. Math.max() on an empty array is
// -Infinity in JS, not 0 -- without the explicit floor here, a fresh
// directory with zero existing entries produces an invalid ("-Infinity")
// LDAP attribute value and the add fails with InvalidSyntaxError.
function nextPosixId(entries, key){
	const existing = entries
		.map(i => Number(i[key]))
		.filter(n => Number.isFinite(n) && n < conf.uidGidReservedFloor);

	return String(Math.max(conf.uidGidMin - 1, ...existing) + 1);
}

async function addPosixGroup(client, data){

  try{
	const groups = (await client.search(conf.groupBase, {
	  scope: 'sub',
	  filter: '(&(objectClass=posixGroup))',
	})).searchEntries;

	data.gidNumber = nextPosixId(groups, 'gidNumber');

	const safeCn = escapeLDAPDNValue(data.cn);
	await client.add(`cn=${safeCn},${conf.groupBase}`, {
	  cn: data.cn,
	  gidNumber: data.gidNumber,
	  objectclass: [ 'posixGroup', 'top' ]
	});

	return data;

  }catch(error){
	throw error;
  }
}

async function addPosixAccount(client, data){
  try{
	const people = (await client.search(conf.userBase, {
		scope: 'sub',
		filter: conf.userFilter,
	})).searchEntries;

	data.uidNumber = nextPosixId(people, 'uidNumber');

	const safeCn = escapeLDAPDNValue(data.cn);
	const entry = {
		cn: data.cn,
		sn: data.sn,
		uid: data.uid,
		uidNumber: data.uidNumber,
		gidNumber: data.gidNumber,
		givenName: data.givenName,
		loginShell: data.loginShell,
		homeDirectory: data.homeDirectory,
		description: data.description || ' ',
		sudoHost: 'ALL',
		sudoCommand: 'ALL',
		sudoUser: data.uid,
		objectclass: ['inetOrgPerson', 'sudoRole', 'ldapPublicKey', 'posixAccount', 'top', 'theta42Person'],
	};

	// mail is optional in the inetOrgPerson schema, but ldapts/slapd reject an
	// attribute given an explicit undefined value ("no values for attribute
	// type") rather than just omitting it -- service accounts (a Unix account
	// an app/service runs as) commonly have no real mailbox.
	if (data.mail) {
		entry.mail = data.mail;
	}

	if (data.mobile) {
		entry.mobile = data.mobile;
	}

	if (data.sshPublicKey) {
		entry.sshPublicKey = data.sshPublicKey;
	}

	if (data.dob) {
		entry.dateOfBirth = data.dob;
	}

	if (data.location) {
		entry.l = data.location;
	}

	// userPassword is optional -- a service account with no password set
	// simply can't bind (no special enforcement needed, that's the default
	// LDAP simple-bind behavior for an entry lacking the attribute).
	if (data.userPassword) {
		entry.userPassword = data.userPassword;
	}

	// manager (COSINE, SUP distinguishedName) is naturally multi-valued --
	// every account gets at least the DN of whoever created it.
	if (data.manager && [].concat(data.manager).length) {
		entry.manager = [].concat(data.manager);
	}

	await client.add(`cn=${safeCn},${conf.userBase}`, entry);

	return data

  }catch(error){
	throw error;
  }

}

async function addLdapUser(client, data){

	var group;


	try{
		if (!data.uid) {
			data.uid = `${data.givenName[0]}${data.sn}`.toLowerCase();
		}
		data.cn = data.uid;
		data.loginShell = data.loginShell || '/bin/bash';
		data.homeDirectory = data.homeDirectory || `/home/${data.uid}`;
		if (data.userPassword) {
			data.userPassword = hashPasswordSSHA512(data.userPassword);
		} else {
			delete data.userPassword;
		}

		group = await addPosixGroup(client, data);
		data = await addPosixAccount(client, group);

		return data;

	}catch(error){
		await deleteLdapDN(client, `cn=${data.uid},${conf.groupBase}`, true);
		throw error;
	}
}

async function deleteLdapUser(client, data){
	try{
		await client.del(`cn=${data.cn},${conf.groupBase}`);
	}catch(error){
		if (error.code !== 0x20) throw error; // ignore NoSuchObject — personal group may not exist
	}
	await client.del(data.dn);
}

async function deleteLdapDN(client, dn, ignoreError){
	try{
		await client.del(dn);
	}catch(error){
		if(!ignoreError) throw error;
		console.error('ERROR: deleteLdapDN', error)
	}
}

const user_parse = function(data){
	if(data[conf.userNameAttribute]){
		data.username = data[conf.userNameAttribute]
		data.userPassword = undefined;
	}
	data.location = data.l ? String(data.l) : '';
	// Use truthy strings so jq-repeat section blocks ({{#isActive}}) fire correctly
	data.isActive   = data.pwdAccountLockedTime ? '' : 'active';
	data.isInactive = data.pwdAccountLockedTime ? 'inactive' : '';

	// manager (COSINE, SUP distinguishedName) and memberOf (from the memberof
	// overlay) are both multi-valued; ldapts returns a bare string for a
	// single value and an array for multiple -- normalize both to always be
	// an array, or app-base.js's `for(let group of user.memberOf)` silently
	// iterates a single DN string character-by-character instead of once.
	data.manager   = [].concat(data.manager || []).filter(Boolean);
	data.memberOf  = [].concat(data.memberOf || []).filter(Boolean);

	return data;
}

var User = {}

User.backing = "LDAP";

User.clearCache = function() { cache.clear(); };

User.list = async function(){
	try{
		return await withClient(async (client) => {
			const res = await client.search(conf.userBase, {
			  scope: 'sub',
			  filter: conf.userFilter,
			  attributes: ['*', '+'],
			});
			return res.searchEntries.map(function(user){return user.uid});
		});
	}catch(error){
		throw error;
	}
};

User.listDetail = async function(){
	try{
		const hit = cache.get('__list__');
		if (hit) return hit;

		const searchEntries = await withClient(async (client) => {
			const res = await client.search(conf.userBase, {
			  scope: 'sub',
			  filter: conf.userFilter,
			  attributes: ['*', '+'],
			});
			return res.searchEntries;
		});

		// Members of app_sso_service_account are non-person accounts (media
		// managers, app service users, ...) -- fetched once here rather than
		// relying on the memberof overlay's reverse attribute, which isn't
		// reliably returned by every LDAP server this app might point at.
		let serviceAccountDNs = new Set();
		try{
			const svcGroup = await Group.get('app_sso_service_account');
			serviceAccountDNs = new Set((svcGroup.member || []).map(dn => dn.toLowerCase()));
		}catch(error){ /* group not seeded yet on an old deployment -- treat as none */ }

		const dnToUid = new Map(searchEntries.map(e => [String(e.dn).toLowerCase(), e.uid]));

		const users = await Promise.all(searchEntries.map(async (entry) => {
			const rawPassword = entry.userPassword ? entry.userPassword.toString() : '';
			const isLegacyMD5 = rawPassword.toUpperCase().startsWith('{MD5}');

			let obj = Object.create(this);
			Object.assign(obj, user_parse(entry));

			const verif = await UserVerification.getOrCreate(obj.uid);

			if (isLegacyMD5 && !verif.password_must_change) {
				await verif.update({ password_must_change: true });
			}

			const passwordMustChange = isLegacyMD5 || verif.password_must_change;

			obj.emailVerified      = verif.email_verified ? 'verified' : '';
			obj.phoneVerified      = verif.phone_verified ? 'verified' : '';
			obj.tosAccepted        = verif.tos_accepted   ? 'accepted' : '';
			obj.tosNotAccepted     = verif.tos_accepted   ? '' : 'pending';
			obj.passwordMustChange = passwordMustChange   ? 'yes' : '';
			obj.onboardingNeeds    = [
				!verif.tos_accepted && 'tos',
				!obj.dateOfBirth    && 'dob',
				passwordMustChange  && 'password',
			].filter(Boolean);
			obj.onboardingRequired = obj.onboardingNeeds.length > 0 ? 'yes' : '';
			obj.isServiceAccount   = serviceAccountDNs.has(String(obj.dn).toLowerCase()) ? 'yes' : '';
			obj.managerUids        = obj.manager.map(dn => dnToUid.get(String(dn).toLowerCase()) || dn);

			return obj;
		}));

		cache.set('__list__', users);
		return users;

	}catch(error){
		throw error;
	}
};


User.get = async function(data, key) {
    if (typeof data !== 'object') {
        data = { uid: data };
    }

    const searchKey = data.searchKey || key || conf.userNameAttribute;
    const searchValue = escapeLDAPSearchValue(data.searchValue || data.uid);
    const filter = `(&${conf.userFilter}(${searchKey}=${searchValue}))`;

    // Check cache for an existing result or active promise
    const cached = cache.get(filter);
    if (cached) return cached;

    // Define the execution logic as a discrete promise
    const fetchPromise = (async () => {
        const res = await withClient(async (client) => {
            return await client.search(conf.userBase, {
                scope: 'sub',
                filter: filter,
                attributes: ['*', '+'],
            });
        });

        const user = res.searchEntries[0];

        if (!user) {
            let error = new Error('UserNotFound');
            error.name = 'UserNotFound';
            error.message = `LDAP:${searchValue} does not exist`;
            error.status = 404;
            throw error;
        }

        // Check password hash type before user_parse wipes the field
        const rawPassword = user.userPassword ? user.userPassword.toString() : '';
        const isLegacyMD5 = rawPassword.toUpperCase().startsWith('{MD5}');

        let obj = Object.create(this);
        Object.assign(obj, user_parse(user));

        const verif = await UserVerification.getOrCreate(obj.uid);

        // Same membership check as User.listDetail() -- see the comment there.
        try{
            const svcGroup = await Group.get('app_sso_service_account');
            const serviceAccountDNs = new Set((svcGroup.member || []).map(dn => dn.toLowerCase()));
            obj.isServiceAccount = serviceAccountDNs.has(String(obj.dn).toLowerCase()) ? 'yes' : '';
        }catch(error){ obj.isServiceAccount = ''; }

        // Auto-flag legacy MD5 password users — persist so subsequent cache hits see it
        if (isLegacyMD5 && !verif.password_must_change) {
            await verif.update({ password_must_change: true });
        }

        const passwordMustChange = isLegacyMD5 || verif.password_must_change;

        obj.emailVerified      = verif.email_verified ? 'verified' : '';
        obj.phoneVerified      = verif.phone_verified ? 'verified' : '';
        obj.tosAccepted        = verif.tos_accepted   ? 'accepted' : '';
        obj.tosNotAccepted     = verif.tos_accepted   ? '' : 'pending';
        obj.passwordMustChange = passwordMustChange   ? 'yes' : '';
        obj.onboardingNeeds    = [
            !verif.tos_accepted && 'tos',
            !obj.dateOfBirth    && 'dob',
            passwordMustChange  && 'password',
        ].filter(Boolean);
        obj.onboardingRequired = obj.onboardingNeeds.length > 0 ? 'yes' : '';

        // Replace the promise in the cache with the actual parsed object
        cache.set(filter, obj);
        return obj;
    })();

    // Cache the promise immediately to prevent stampedes
    cache.set(filter, fetchPromise);

    // If the promise fails, evict it from the cache immediately
    fetchPromise.catch(() => {
        cache.delete(filter);
    });

    return fetchPromise;
};

User.exists = async function(data, key){
	try{
		return await this.get(data, key);
	}catch(error){
		return null;
	}
};

User.add = async function(data) {
	try{
		if (await this.exists(data.mail, 'mail')) {
			throw Object.assign(new Error('Email already in use'), {status: 409, name: 'EmailInUse'});
		}
		if (data.mobile && await this.exists(data.mobile, 'mobile')) {
			throw Object.assign(new Error('Phone number already in use'), {status: 409, name: 'PhoneInUse'});
		}

		await withClient(async (client) => {
			await addLdapUser(client, data);
		});
		cache.clear();

		let user = await this.get(data.uid);

		await UserVerification.getOrCreate(user.uid);

		try {
			await Mail.sendTemplate(
				user.mail,
				'welcome',
				{
					user: user
				}
			);
		} catch(mailErr) {
			console.error(`User.add: welcome email failed for ${user.uid}:`, mailErr.message);
		}

		return user;

	}catch(error){
		if(error.message && error.message.includes('exists')){
			let err = new Error('UserNameUsed');
			err.name = 'UserNameUsed';
			err.message = `LDAP:${data.uid} already exists`;
			err.status = 409;

			throw err;
		}
		throw error;
	}
};

User.update = async function(data){
	try{
		if (data.mobile) {
			const existing = await User.exists(data.mobile, 'mobile');
			if (existing && existing.uid !== this.uid) {
				throw Object.assign(new Error('Phone number already in use'), {status: 409, name: 'PhoneInUse'});
			}
		}

		let editableFeilds = ['mobile', 'description', 'homeDirectory', 'loginShell'];

		await withClient(async (client) => {
			for(let field of editableFeilds){
				if(data[field]){
					await client.modify(this.dn, [
						new Change({
							operation: 'replace',
							modification: new Attribute({
								type: field,
								values: [data[field]]
							})
						}),
					]);
					this[field] = data[field];
				}
			}

			if(data.sshPublicKey){
				// Ensure the auxiliary objectClass is present before setting the attribute
				// -- accounts created before ldapPublicKey was added to addPosixAccount's
				// objectclass list (e.g. the bootstrap admin) won't have it yet.
				try {
					await client.modify(this.dn, [
						new Change({
							operation: 'add',
							modification: new Attribute({ type: 'objectClass', values: ['ldapPublicKey'] }),
						}),
					]);
				} catch(e) {
					if(e.name !== 'TypeOrValueExistsError') throw e;
				}
				await client.modify(this.dn, [
					new Change({
						operation: 'replace',
						modification: new Attribute({ type: 'sshPublicKey', values: [data.sshPublicKey] }),
					}),
				]);
				this.sshPublicKey = data.sshPublicKey;
			}

			if(data.dateOfBirth){
				// Ensure the auxiliary objectClass is present before setting the attribute
				try {
					await client.modify(this.dn, [
						new Change({
							operation: 'add',
							modification: new Attribute({ type: 'objectClass', values: ['theta42Person'] }),
						}),
					]);
				} catch(e) {
					if(e.name !== 'TypeOrValueExistsError') throw e;
				}
				await client.modify(this.dn, [
					new Change({
						operation: 'replace',
						modification: new Attribute({ type: 'dateOfBirth', values: [data.dateOfBirth] }),
					}),
				]);
				this.dateOfBirth = data.dateOfBirth;
			}

			if(data.location !== undefined){
				await client.modify(this.dn, [
					new Change({
						operation: 'replace',
						modification: new Attribute({ type: 'l', values: [data.location] }),
					}),
				]);
				this.location = data.location;
			}

			if(data.manager !== undefined){
				// Client sends uids; resolve each to a DN before writing --
				// manager (COSINE, SUP distinguishedName) stores DNs, not uids.
				const uids = [].concat(data.manager || []).filter(Boolean);
				const managers = await Promise.all(uids.map(uid => User.get(uid)));
				const dns = managers.map(u => u.dn);
				await client.modify(this.dn, [
					new Change({
						operation: 'replace',
						modification: new Attribute({ type: 'manager', values: dns }),
					}),
				]);
				this.manager = dns;
			}
		});
		cache.clear();

		return this;

	}catch(error){
		throw error;
	}
};

User.usernameSuggestions = async function(givenName, sn, dob) {
	const fn = (givenName || '').toLowerCase().replace(/[^a-z0-9]/g, '');
	const ln = (sn || '').toLowerCase().replace(/[^a-z0-9]/g, '');
	if (!fn || !ln) return [];
	const gi = fn[0];
	const li = ln[0];

	const candidates = [
		`${gi}${ln}`,     // jsmith
		`${fn}${ln}`,     // johnsmith
		`${fn}_${ln}`,    // john_smith
		`${fn}${li}`,     // johns
		`${ln}${gi}`,     // smithj
	];

	if (dob) {
		const year = new Date(dob).getFullYear();
		if (!isNaN(year)) {
			const y4 = String(year);
			const y2 = y4.slice(2);
			candidates.push(
				`${gi}${ln}${y2}`,  // jsmith90
				`${fn}${ln}${y2}`,  // johnsmith90
				`${fn}_${ln}${y2}`, // john_smith90
				`${gi}${ln}${y4}`,  // jsmith1990
				`${fn}${ln}${y4}`,  // johnsmith1990
				`${fn}_${ln}${y4}`, // john_smith1990
			);
		}
	}

	const available = [];
	for (const uid of [...new Set(candidates)]) {
		if (!(await this.exists(uid))) available.push(uid);
	}
	if (!available.length) {
		for (let i = 2; i <= 9; i++) {
			const uid = `${gi}${ln}${i}`;
			if (!(await this.exists(uid))) { available.push(uid); break; }
		}
	}
	return available;
};

User.addByInvite = async function(data){
	try{
		let token = await InviteToken.get(data.token);

		if(!token.is_valid && data.mailToken !== token.mail_token){
			let error = new Error('Token Invalid');
			error.name = 'Token Invalid';
			error.message = `Token is not valid or as allready been used. ${data.token}`;
			error.status = 401;
			throw error;
		}

		data.mail = token.mail;

		// Default manager: whoever sent the invite.
		try {
			const inviter = await this.get(token.created_by);
			data.manager = [inviter.dn];
		} catch(e) { /* inviter no longer exists -- leave manager unset */ }

		const suggestions = await this.usernameSuggestions(data.givenName, data.sn, data.dob);
		if (!data.uid || !suggestions.includes(data.uid)) {
			const err = new Error('Invalid username selection');
			err.status = 400;
			throw err;
		}

		let user = await this.add(data);

		if(user){
			await token.consume({claimed_by: user.uid});
			const verif = await UserVerification.getOrCreate(user.uid);
			await verif.markEmailVerified();
			await verif.markTosAccepted();
			await verif.update({ password_must_change: false });
			cache.clear(); // evict the cached user so the next get() reads fresh verification flags

			const groupNames = JSON.parse(token.groups || '[]');
			for (const groupName of groupNames) {
				try {
					const group = await Group.get(groupName);
					await group.addMember(user);
				} catch(e) {
					console.error(`invite: could not add ${user.uid} to group ${groupName}:`, e.message);
				}
			}

			return user;
		}

	}catch(error){
		throw error;
	}

};

User.verifyEmail = async function(data){
	try{

		let exists = await this.exists(data.mail, 'mail');

		if(exists) throw new Error('EmailInUse');

		let token = await InviteToken.get(data.token);
		const mail_token = crypto.randomUUID();
		await token.update({mail: data.mail, mail_token});

		try {
			await Mail.sendTemplate(
				data.mail,
				'validate_link',
				{
					link:`${data.url}/login/invite/${token.token}/${token.mail_token}`
				}
			);
		} catch(mailErr) {
			console.error(`verifyEmail: email failed for ${data.mail}:`, mailErr.message);
		}

		return this;
	}catch(error){
		throw error;
	}
};

User.passwordReset = async function(url, mail){
	try{

		let user = await User.get({
			searchKey: 'mail',
			searchValue: mail
		});

		let token = await PasswordResetToken.create({created_by: user.uid});

		try {
			await Mail.sendTemplate(
				user.mail,
				'reset_link',
				{
					user: user,
					link:`${url}/login/resetpassword/${token.token}`
				}
			);
		} catch(mailErr) {
			console.error(`passwordReset: email failed for ${user.uid}:`, mailErr.message);
		}

		return true;
	}catch(error){
		// if(error.name === 'UserNotFound') return false;
		throw error;
	}
};


User.remove = async function(data){
	try{

		await withClient(async (client) => {
			await deleteLdapUser(client, this);
		});
		cache.clear();

		return true;

	}catch(error){
		throw error;
	}
};

User.setPassword = async function(data){
	try{

		await withClient(async (client) => {
			await client.modify(this.dn, [
			  new Change({
				operation: 'replace',
				modification: new Attribute({
				  type: 'userPassword',
				  values: [hashPasswordSSHA512(data.userPassword)]
				})}),
			]); 
		});

		return this;
	}catch(error){
		throw error;
	}
};

User.addTempPassword = async function(hash) {
	await withClient(async (client) => {
		await client.modify(this.dn, [
			new Change({ operation: 'add', modification: new Attribute({ type: 'userPassword', values: [hash] }) }),
		]);
	});
};

User.removeTempPassword = async function(hash) {
	await withClient(async (client) => {
		await client.modify(this.dn, [
			new Change({ operation: 'delete', modification: new Attribute({ type: 'userPassword', values: [hash] }) }),
		]);
	});
};

User.setActive = async function(active) {
	try {
		await withClient(async (client) => {
			if (active) {
				await client.modify(this.dn, [
					new Change({ operation: 'delete', modification: new Attribute({ type: 'pwdAccountLockedTime', values: [] }) }),
				]);
			} else {
				await client.modify(this.dn, [
					new Change({ operation: 'replace', modification: new Attribute({ type: 'pwdAccountLockedTime', values: ['000001010000Z'] }) }),
				]);
			}
		});
	} catch (e) {
		if (active && e.name === 'NoSuchAttributeError') {
			// Already active — nothing to do
		} else if (e.name === 'UndefinedTypeError' || (e.message && e.message.includes('pwdAccountLockedTime'))) {
			const err = new Error('OpenLDAP ppolicy overlay is not configured. See README for setup instructions.');
			err.status = 503;
			throw err;
		} else {
			throw e;
		}
	}
	this.pwdAccountLockedTime = active ? undefined : '000001010000Z';
	this.isActive   = active ? 'active' : '';
	this.isInactive = active ? '' : 'inactive';
	cache.clear();
	return this;
};

User.addSSHkey = async function(data) {
	const user = await this.get(data.uid);
	let result;
	try {
		await withClient(async (client) => {
			// Ensure the auxiliary objectClass is present before setting the attribute
			// -- accounts created before ldapPublicKey was added to addPosixAccount's
			// objectclass list (e.g. the bootstrap admin) won't have it yet.
			try {
				await client.modify(user.dn, [
					new Change({
						operation: 'add',
						modification: new Attribute({ type: 'objectClass', values: ['ldapPublicKey'] }),
					}),
				]);
			} catch(e) {
				if (e.name !== 'TypeOrValueExistsError') throw e;
			}
			await client.modify(user.dn, [
				new Change({
					operation: 'add',
					modification: new Attribute({ type: 'sshPublicKey', values: [data.key] }),
				}),
			]);
		});
		result = true;
	} catch(e) {
		if (e.name === 'TypeOrValueExistsError') {
			result = 'Key already added';
		} else {
			throw e;
		}
	}
	if (result === true) cache.clear();
	return result;
};

// Every user gets a personal Unix group of the same name at creation (see
// addPosixGroup) -- just a GID holder, cn always equal to the user's uid.
// memberUid (RFC 2307, posixGroup) is a bare username, not a DN, unlike
// groupOfNames' `member` used by app_sso_* groups in group_ldap.js.
function personalGroupDN(uid){
	return `cn=${escapeLDAPDNValue(uid)},${conf.groupBase}`;
}

User.getPersonalGroupMembers = async function(uid) {
	try {
		return await withClient(async (client) => {
			const res = await client.search(personalGroupDN(uid), {
				scope: 'base',
				filter: '(objectClass=posixGroup)',
				attributes: ['memberUid'],
			});
			const entry = res.searchEntries[0];
			return [].concat((entry && entry.memberUid) || []).filter(Boolean);
		});
	} catch(error) {
		throw error;
	}
};

User.addPersonalGroupMember = async function(uid, memberUid) {
	await this.get(memberUid); // throws UserNotFound if the target uid doesn't exist
	await withClient(async (client) => {
		await client.modify(personalGroupDN(uid), [
			new Change({
				operation: 'add',
				modification: new Attribute({ type: 'memberUid', values: [memberUid] }),
			}),
		]);
	});
};

User.removePersonalGroupMember = async function(uid, memberUid) {
	await withClient(async (client) => {
		await client.modify(personalGroupDN(uid), [
			new Change({
				operation: 'delete',
				modification: new Attribute({ type: 'memberUid', values: [memberUid] }),
			}),
		]);
	});
};

User.invite = async function(data = {}){
	try{
		let token = await InviteToken.create({
			created_by: this.uid,
			groups: JSON.stringify([].concat(data.groups || [])),
		});

		if (data.mail) {
			await User.verifyEmail({ token: token.token, mail: data.mail, url: data.url });
			return InviteToken.get(token.token);
		}

		return token;
	}catch(error){
		throw error;
	}
};

User.login = async function(data){
	try{
		if (!data.uid && !data.username) {
			let error = new Error('Invalid Credentials, login failed.');
			error.name = 'LDAPLoginFailed';
			error.status = 401;
			throw error;
		}
		let user = await this.get(data.uid || data.username);

		const loginClient = makeClient();
		try {
			await loginClient.bind(user.dn, data.password);
		} finally {
			await loginClient.unbind().catch(() => {});
		}

		return user;

	}catch(error){
		console.error("USER LOGIN error:", error.name, error.message);
		throw error;
	}
};


module.exports = {User, hashPasswordSSHA512, nextPosixId};


// (async function(){
// try{
// 	console.log(await User.list());

// 	console.log(await User.listDetail());

// 	console.log(await User.get('wmantly'))

// }catch(error){
// 	console.error(error)
// }
// })()