const app = require('vn-loopback/server/server');
const ldap = require('../util/ldapjs-extra');
const crypto = require('crypto');
const nthash = require('smbhash').nthash;
const isProduction = require('vn-loopback/server/boot/isProduction');

module.exports = Self => {
    const shouldSync = isProduction();

    Self.getLinker = async function() {
        return await Self.findOne({
            fields: [
                'server',
                'rdn',
                'password',
                'userDn',
                'groupDn'
            ]
        });
    };

    Object.assign(Self.prototype, {
        async init() {
            this.client = ldap.createClient({
                url: this.server
            });
            this.client.on('error', () => {});
            await this.client.bind(this.rdn, this.password);
        },

        async deinit() {
            await this.client.unbind();
        },

        async syncUser(userName, info, password) {
            let {
                client,
                accountConfig
            } = this;

            let dn = `uid=${userName},${this.userDn}`;

            if (info.hasAccount) {
                let {user} = info;

                let oldUser = await client.searchOne(this.userDn, {
                    scope: 'sub',
                    filter: `&(uid=${userName})`
                });

                let nickname = user.nickname || userName;
                let nameArgs = nickname.trim().split(' ');
                let sn = nameArgs.length > 1
                    ? nameArgs.splice(1).join(' ')
                    : '-';

                let newEntry = {
                    uid: userName,
                    objectClass: [
                        'inetOrgPerson',
                        'posixAccount',
                        'sambaSamAccount'
                    ],
                    cn: nickname,
                    displayName: nickname,
                    givenName: nameArgs[0],
                    sn,
                    mail: info.corporateMail,
                    preferredLanguage: user.lang || 'en',
                    homeDirectory: `${accountConfig.homedir}/${userName}`,
                    loginShell: accountConfig.shell,
                    uidNumber: info.uidNumber,
                    gidNumber: accountConfig.idBase + user.roleFk,
                    sambaSID: '-'
                };

                if (password) {
                    let salt = crypto
                        .randomBytes(8)
                        .toString('base64');

                    let hash = crypto.createHash('sha1');
                    hash.update(password);
                    hash.update(salt, 'binary');
                    let digest = hash.digest('binary');

                    let ssha = Buffer
                        .from(digest + salt, 'binary')
                        .toString('base64');

                    Object.assign(newEntry, {
                        userPassword: `{SSHA}${ssha}`,
                        sambaNTPassword: nthash(password)
                    });
                } else if (oldUser) {
                    Object.assign(newEntry, {
                        userPassword: oldUser.userPassword,
                        sambaNTPassword: oldUser.sambaNTPassword
                    });
                }

                for (let prop in newEntry) {
                    if (newEntry[prop] == null)
                        delete newEntry[prop];
                }

                if (oldUser) {
                    let changes = [];
                    let skipProps = new Set([
                        'dn',
                        'controls'
                    ]);

                    for (let prop in oldUser) {
                        let deleteProp = !skipProps.has(prop)
                            && !newEntry.hasOwnProperty(prop);
                        if (!deleteProp) continue;
                        changes.push(new ldap.Change({
                            operation: 'delete',
                            modification: {
                                [prop]: oldUser[prop]
                            }
                        }));
                    }
                    for (let prop in newEntry) {
                        if (this.isEqual(oldUser[prop], newEntry[prop]))
                            continue;
                        changes.push(new ldap.Change({
                            operation: 'replace',
                            modification: {
                                [prop]: newEntry[prop]
                            }
                        }));
                    }

                    if (shouldSync && changes.length)
                        await client.modify(dn, changes);
                } else if (shouldSync)
                    await client.add(dn, newEntry);
            } else {
                try {
                    if (shouldSync)
                        await client.del(dn);
                    // eslint-disable-next-line no-console
                    console.log(` -> User '${userName}' removed from LDAP`);
                } catch (e) {
                    if (e.name !== 'NoSuchObjectError') throw e;
                }
            }
        },

        isEqual(a, b) {
            if (Array.isArray(a) && Array.isArray(b)) {
                if (a.length !== b.length)
                    return false;
                for (let element of a) {
                    if (b.indexOf(element) === -1)
                        return false;
                }
                return true;
            } else
                return a == b;
        },

        async syncUserGroups(userName, info) {
            let {client} = this;
            let {user} = info;
            let groupDn = this.groupDn;

            let opts = {
                scope: 'sub',
                attributes: ['dn', 'cn'],
                filter: `&(memberUid=${userName})(objectClass=posixGroup)`
            };
            let oldGroups = await client.searchAll(groupDn, opts);

            let deleteGroups = [];
            let addGroups = [];

            if (info.hasAccount) {
                let oldSet = new Set();
                oldGroups.forEach(e => oldSet.add(e.cn));

                let newSet = new Set();
                user.roles().forEach(e => newSet.add(e.inherits().name));

                for (let group of oldGroups) {
                    if (!newSet.has(group.cn))
                        deleteGroups.push(group.cn);
                }
                for (let role of user.roles()) {
                    if (!oldSet.has(role.inherits().name))
                        addGroups.push(role.inherits().name);
                }
            } else {
                for (let group of oldGroups)
                    deleteGroups.push(group.cn);
            }

            async function applyOperations(groups, operation) {
                for (let group of groups) {
                    try {
                        let dn = `cn=${group},${groupDn}`;
                        if (shouldSync) {
                            await client.modify(dn, new ldap.Change({
                                operation,
                                modification: {memberUid: userName}
                            }));
                        }
                    } catch (err) {
                        if (err.name !== 'NoSuchObjectError')
                            throw err;
                    }
                }
            }

            await applyOperations(deleteGroups, 'delete');
            await applyOperations(addGroups, 'add');
        },

        async getUsers(usersToSync) {
            let {client} = this;

            let opts = {
                scope: 'sub',
                attributes: ['uid'],
                filter: `uid=*`
            };
            await client.searchForeach(this.userDn, opts,
                o => usersToSync.add(o.uid));
        },

        async syncRoles() {
            let $ = app.models;
            let {
                client,
                accountConfig
            } = this;

            // Prepare data

            let roles = await $.VnRole.find({
                fields: ['id', 'name', 'description']
            });
            let roleRoles = await $.RoleRole.find({
                fields: ['role', 'inheritsFrom']
            });
            let roleMap = toMap(roleRoles, e => {
                return {key: e.inheritsFrom, val: e.role};
            });

            let accounts = await $.Account.find({
                fields: ['id'],
                include: {
                    relation: 'user',
                    scope: {
                        fields: ['name', 'roleFk'],
                        where: {active: true}
                    }
                }
            });
            let accountMap = toMap(accounts, e => {
                let user = e.user();
                if (!user) return;
                return {key: user.roleFk, val: user.name};
            });

            // Delete roles

            let opts = {
                scope: 'sub',
                attributes: ['dn'],
                filter: 'objectClass=posixGroup'
            };
            let reqs = [];
            await client.searchForeach(this.groupDn, opts, object => {
                if (shouldSync)
                    reqs.push(client.del(object.dn));
            });
            await Promise.all(reqs);

            // Recreate roles

            reqs = [];
            for (let role of roles) {
                let newEntry = {
                    objectClass: ['top', 'posixGroup'],
                    cn: role.name,
                    description: role.description,
                    gidNumber: accountConfig.idBase + role.id
                };

                let memberUid = [];
                for (let subrole of roleMap.get(role.id) || [])
                    memberUid = memberUid.concat(accountMap.get(subrole) || []);

                if (memberUid.length) {
                    memberUid.sort((a, b) => a.localeCompare(b));
                    newEntry.memberUid = memberUid;
                }

                let dn = `cn=${role.name},${this.groupDn}`;
                if (shouldSync)
                    reqs.push(client.add(dn, newEntry));
            }
            await Promise.all(reqs);
        }
    });
};

function toMap(array, fn) {
    let map = new Map();
    for (let item of array) {
        let keyVal = fn(item);
        if (!keyVal) continue;
        let key = keyVal.key;
        if (!map.has(key)) map.set(key, []);
        map.get(key).push(keyVal.val);
    }
    return map;
}