const LDAPController = require('./LDAPController')
const LDAP = require('ldapjs')
const bcrypt = require('bcrypt')

class UsersController extends LDAPController {
    static get services() {
        return [
            ...super.services,
            'output',
            'ldap_server',
            'models',
            'configs',
            'auth'
        ]
    }

    constructor() {
        super()
        this.User = this.models.get('auth:User')
    }

    // Might need to override compare to support special handling for userPassword

    // TODO generalize some of the addition logic
    // TODO rework some of the registration and validation logic
    async add_people(req, res, next) {
        const Setting = this.models.get('Setting')
        if ( !(await Setting.get('auth.allow_registration')) ) {
            return next(new LDAP.InsufficientAccessRightsError('Operation not enabled.'))
        }

        if ( !req.user.can('ldap:add:users') ) {
            return next(new LDAP.InsufficientAccessRightsError())
        }

        // make sure the add DN is in the auth_dn
        const auth_dn = this.ldap_server.auth_dn()
        if ( !auth_dn.parentOf(req.dn) ) {
            this.output.warn(`Attempted to perform user insertion on invalid DN: ${req.dn.format(this.configs.get('ldap:server.format'))}`)
            return next(new LDAP.InsufficientAccessRightsError())
        }

        // make sure the user object doesn't already exist
        const existing_user = await this.get_resource_from_dn(req.dn)
        if ( existing_user ) {
            return next(new LDAP.EntryAlreadyExistsError())
        }

        // build the user object from the request attributes
        const req_data = req.toObject().attributes
        const register_data = {
            first_name: req_data.cn ? req_data.cn[0] : '',
            last_name: req_data.sn ? req_data.sn[0] : '',
            email: req_data.mail ? req_data.mail[0] : '',
            username: req_data.uid ? req_data.uid[0].toLowerCase() : '',
            password: req_data.userpassword ? req_data.userpassword[0] : '',
        }

        // TODO add data fields

        // Make sure the data uid matches the request DN
        if ( this.get_uid_from_dn(req.dn) !== register_data.username ) {
            this.output.error(`Attempted to register user where request DN's UID differs from the registration data: (${this.get_uid_from_dn(req.dn)} !== ${register_data.username})`)
            return next(new LDAP.ObjectclassViolationError(`Attempted to register user where request DN's UID differs from the registration data!`))
        }

        // Get the auth provider!
        const flitter = this.auth.get_provider('flitter')

        // Make sure required fields are provided
        const reg_errors = await flitter.validate_registration(register_data)
        const other_required_fields = ['first_name', 'last_name', 'email']
        for ( const field of other_required_fields ) {
            if ( !register_data[field] ) reg_errors.push(`Missing field: ${field}`)
        }

        if ( reg_errors.length > 0 ) {
            this.output.error(`Error(s) encountered during LDAP user registration: ${reg_errors.join('; ')}`)
            return next(new LDAP.ObjectclassViolationError(`Object add validation failure: ${reg_errors.join('; ')}`))
        }

        // save the user object
        const registration_args = await flitter.get_registration_args(register_data)
        delete register_data.username
        delete register_data.password
        registration_args[1] = {...register_data, ...registration_args[1]}

        const new_user = await flitter.register(...registration_args)
        this.output.success(`Created new LDAP user: ${new_user.uid}`)

        res.end()
        return next()
    }

    // TODO generalize some of the modification logic
    // TODO rework validation
    async modify_people(req, res, next) {
        if ( !req.user.can('ldap:modify:users') ) {
            return next(new LDAP.InsufficientAccessRightsError())
        }

        if ( req.changes.length <= 0 ) {
            return next(new LDAP.ProtocolError('Must specify at least one change.'))
        }

        // Make sure it's under the user auth DN
        const auth_dn = this.ldap_server.auth_dn()
        if ( !auth_dn.parentOf(req.dn) ) {
            this.output.warn(`Attempted to perform user modify on invalid DN: ${req.dn.format(this.configs.get('ldap:server.format'))}`)
            return next(new LDAP.InsufficientAccessRightsError())
        }

        // Get the target user
        const user = await this.get_resource_from_dn(req.dn)
        if ( !user ) {
            return next(new LDAP.NoSuchObjectError())
        }

        // Iterate over the changes
        const allow_replace = ['cn', 'sn', 'mail', 'userPassword']
        for ( const change of req.changes ) {
            const change_data = change.json.modification
            if ( change.operation === 'add' ) {
                if ( !change_data.type.startsWith('data_') ) {
                    return next(new LDAP.OperationsError(`Addition of non 'data_' fields is not permitted.`))
                }

                const key = `ldap${change_data.type.substr(4)}`
                const val = change_data.vals

                if ( typeof user.data_get(key) !== 'undefined' ) {
                    return next(new LDAP.AttributeOrValueExistsError())
                }

                user.data_set(key, val)
            } else if ( change.operation === 'replace' ) {
                if ( !allow_replace.includes(change_data.type) || change_data.type.startsWith('data_') ) {
                    return next(new LDAP.OperationsError(`Modification of the ${change_data.type} field is not permitted.`))
                }

                if ( change_data.type.startsWith('data_') ) {
                    const key = `ldap${change_data.type.substr(4)}`
                    const val = change_data.vals
                    user.data_set(key, val)
                } else if ( change_data.type === 'cn' ) {
                    user.first_name = change_data.vals[0]
                } else if ( change_data.type === 'sn' ) {
                    user.last_name = change_data.vals[0]
                } else if ( change_data.type === 'mail' ) {
                    // Validate the e-mail address
                    if ( !this.ldap_server.validate_email(change_data.vals[0]) ) {
                        return next(new LDAP.OperationsError(`Unable to make modification: mail must be a valid e-mail address.`))
                    }

                    user.email = change_data.vals[0]
                } else if ( change_data.type === 'userPassword' ) {
                    // Validate the password
                    if ( change_data.vals[0].length < 8 ) {
                        return next(new LDAP.OperationsError(`Unable to make modification: userPassword must be at least 8 characters.`))
                    }

                    // Hash the password before update
                    user.password = await bcrypt.hash(change_data.vals[0], 10)
                }
            } else if ( change.operation === 'delete' ) {
                if ( !change_data.type.startsWith('data_') ) {
                    return next(new LDAP.OperationsError(`Deletion of non 'data_' fields is not permitted.`))
                }

                const key = `ldap${change_data.type.substr(4)}`
                const json_obj = JSON.parse(user.data)
                delete json_obj[key]
                user.data = JSON.stringify(json_obj)
            } else {
                return next(new LDAP.ProtocolError(`Invalid/unknown modify operation: ${change.operation}`))
            }
        }

        await user.save()
        res.end()
        return next()
    }

    async get_base_dn() {
        return this.ldap_server.auth_dn()
    }

    parse_iam_targets(filter, target_ids = []) {
        if ( Array.isArray(filter?.filters) ) {
            for ( const sub_filter of filter.filters ) {
                target_ids = [...target_ids, ...this.parse_iam_targets(sub_filter)]
            }
        } else if ( filter?.attribute ) {
            if ( filter.attribute === 'iamtarget' ) {
                target_ids.push(filter.value)
            }
        }

        return target_ids
    }

    filter_to_obj(filter) {
        if ( filter && filter.json ) {
            const val = filter.json
            for ( const prop in val ) {
                if ( !val.hasOwnProperty(prop) ) continue
                val[prop] = this.filter_to_obj(val[prop])
            }

            return val
        } else if ( Array.isArray(filter) ) {
            return filter.map(x => this.filter_to_obj(x))
        }

        return filter
    }

    // TODO flitter-orm chunk query
    // TODO generalize scoped search logic
    async search_people(req, res, next) {
        if ( !req.user.can('ldap:search:users:me') ) {
            return next(new LDAP.InsufficientAccessRightsError())
        }

        const can_search_all = req.user.can('ldap:search:users')

        const iam_targets = this.parse_iam_targets(req.filter)
        if ( req.scope === 'base' ) {
            // If scope is base, check if the base DN matches the filter.
            // If so, return it. Else, return empty.
            this.output.debug(`Running base DN search for users with DN: ${req.dn.format(this.configs.get('ldap:server.format'))}`)

            const user = await this.get_resource_from_dn(req.dn)

            // Make sure the user is ldap visible && match the filter
            if (
                user
                && user.ldap_visible
                && req.filter.matches(await user.to_ldap(iam_targets))
                && (req.user.id === user.id || can_search_all)
            ) {

                // If so, send the object
                res.send({
                    dn: user.dn,//.format(this.configs.get('ldap:server.format')),
                    attributes: await user.to_ldap(iam_targets),
                })

                this.output.debug({
                    dn: user.dn.format(this.configs.get('ldap:server.format')),
                    attributes: await user.to_ldap(iam_targets),
                })
            } else {
                this.output.debug(`User base search failed: either user not found, not visible, or filter mismatch`)
                global.ireq = req
            }
        } else if ( req.scope === 'one' ) {
            // If scope is one, find all entries that are the immediate
            // subordinates of the base DN that match the filter.
            this.output.debug(`Running one DN search for users with DN: ${req.dn.format(this.configs.get('ldap:server.format'))}`)

            // Fetch the LDAP-visible users
            const users = await this.User.ldap_directory()
            for ( const user of users ) {
                if ( user.id !== req.user.id && !can_search_all ) continue

                // Make sure the user os of the appropriate scope
                if ( req.dn.equals(user.dn) || user.dn.parent().equals(req.dn) ) {

                    // Check if the filter matches
                    if ( req.filter.matches(await user.to_ldap(iam_targets)) ) {

                        // If so, send the object
                        res.send({
                            dn: user.dn,//.format(this.configs.get('ldap:server.format')),
                            attributes: await user.to_ldap(iam_targets),
                        })
                    }
                }
            }

        } else if ( req.scope === 'sub' ) {
            // If scope is sub, find all entries that are subordinates
            // of the base DN at any level and match the filter.
            this.output.debug(`Running sub DN search for users with DN: ${req.dn.format(this.configs.get('ldap:server.format'))}`)

            // Fetch the users as LDAP objects
            const users = await this.User.ldap_directory()
            this.output.debug(`Searching ${users.length} users...`)
            this.output.debug(`Request DN: ${req.dn}`)
            this.output.debug(`Filter:`)
            this.output.debug(this.filter_to_obj(req.filter.json))
            for ( const user of users ) {
                if ( user.id !== req.user.id && !can_search_all ) continue
                this.output.debug(`Checking ${user.uid}...`)
                this.output.debug(`DN: ${user.dn}`)
                this.output.debug(`Req DN equals: ${req.dn.equals(user.dn)}`)
                this.output.debug(`Req DN parent of: ${req.dn.parentOf(user.dn)}`)

                // Make sure the user is of appropriate scope
                if ( req.dn.equals(user.dn) || req.dn.parentOf(user.dn) ) {
                    this.output.debug(await user.to_ldap())
                    this.output.debug(`Matches sub scope. Matches filter? ${req.filter.matches(await user.to_ldap(iam_targets))}`)

                    // Check if filter matches
                    if ( req.filter.matches(await user.to_ldap(iam_targets)) ) {

                        // If so, send the object
                        res.send({
                            dn: user.dn,//.format(this.configs.get('ldap:server.format')),
                            attributes: await user.to_ldap(iam_targets),
                        })
                    }
                }
            }
        } else {
            this.output.error(`Attempted to perform LDAP search with invalid scope: ${req.scope}`)
            return next(new LDAP.OtherError('Attempted to perform LDAP search with invalid scope.'))
        }

        res.end()
        return next()
    }

    get_uid_from_dn(dn) {
        const uid_field = this.ldap_server.config.schema.auth.user_id

        try {
            if ( typeof dn === 'string' ) dn = LDAP.parseDN(dn)
            return dn.rdns[0].attrs[uid_field].value.toLowerCase()
        } catch (e) {}
    }

    async get_resource_from_dn(dn) {
        const uid = this.get_uid_from_dn(dn)
        if ( uid ) {
            const User = this.models.get('auth:User')
            return User.findOne({uid: uid.toLowerCase(), ldap_visible: true})
        }
    }
}

module.exports = exports = UsersController
