const { Controller } = require('libflitter')
const zxcvbn = require('zxcvbn')
const email_validator = require('email-validator')

class AuthController extends Controller {
    static get services() {
        return [...super.services, 'models', 'auth', 'MFA', 'output', 'configs', 'utility', 'activity']
    }

    async get_auth_user(req, res, next) {
        if ( req.user ) {
            return res.api({
                authenticated: true,
                uid: req.user.uid,
            })
        }

        return res.api({ authenticated: false })
    }

    async get_traps(req, res, next) {
        const trap_config = this.configs.get('traps')
        const data = [{ name: req.T('auth.none'), trap: '', redirect_to: '/' }]
        for ( const name in trap_config.types ) {
            if ( !trap_config.types.hasOwnProperty(name) ) continue
            data.push({
                name: name.replace(/_/g, ' ')
                    .split(' ')
                    .map(x => x.charAt(0).toUpperCase() + x.substr(1))
                    .join(' '),
                trap: name,
                redirect_to: trap_config.types[name].redirect_to
            })
        }

        return res.api(data)
    }

    async registration(req, res, next) {
        const User = this.models.get('auth:User')
        const required_fields = ['first_name', 'last_name', 'uid', 'email']
        const unique_fields = ['uid', 'email']

        for ( const field of required_fields ) {
            if ( !req.body[field] )
                return res.status(400)
                    .message(`${req.T('api.missing_field')} ${field}`)
                    .api()
        }

        if ( !req.body.uid.match(/^([A-Z]|[a-z]|[0-9]|_|-|\.)+$/) )
            return res.status(400)
                .message(`${req.T('api.improper_field')} uid ${req.T('api.alphanum_underscores')}`)
                .api()

        if ( !email_validator.validate(req.body.email) )
            return res.status(400)
                .message(`${req.T('api.improper_field')} email`)
                .api()

        for ( const field of unique_fields ) {
            const params = {}
            params[field] = req.body[field]
            const match_user = await User.findOne(params)
            if ( match_user )
                return res.status(400)
                    .message(`${req.T('auth.user_exists_with_field')} ${field}`)
                    .api()
        }

        const user = new User({
            first_name: req.body.first_name,
            last_name: req.body.last_name,
            uid: req.body.uid.toLowerCase(),
            email: req.body.email,
            trap: 'password_reset', // Force user to reset password
        })

        const Setting = this.models.get('Setting')
        try {
            const default_roles = await Setting.get('auth.default_roles')
            if ( Array.isArray(default_roles) ) {
                for ( const role of default_roles ) user.promote(role)
            }
        } catch (e) {
            this.output.error('Unable to read default roles to promote registered user: ')
            this.output.error(e)
        }

        // If this is the first user, make them root
        if ( !(await User.findOne()) ) user.promote('root')

        await user.save()
        await user.grant_defaults()

        // Log in the user automatically
        await this.auth.get_provider().session(req, user)
        return res.api(await user.to_api())
    }

    async attempt_mfa_recovery(req, res, next) {
        if (
            !req.user.mfa_enabled
            || !Array.isArray(req.user.mfa_token.recovery_codes)
            || req.user.mfa_token.recovery_codes.length < 1
        )
            return res.status(400)
                .message(req.T('auth.no_mfa_or_recovery'))
                .api()

        if ( !req.body.code )
            return res.status(400)
                .message(`${req.T('api.missing_field')} code`)
                .api()

        const success = await req.user.mfa_token.attempt_recovery(req.body.code)

        if ( !success )
            return res.api({ success })

        if ( req.trap.has_trap('mfa_challenge') )
            await req.trap.end()

        let next_destination = req.session.auth.flow || this.configs.get('auth.default_login_route')
        delete req.session.auth.flow

        req.session.mfa_remember = true

        return res.api({
            success: true,
            next_destination,
            remaining_codes: req.user.mfa_token.recovery_codes.filter(x => !x.used).length,
        })
    }

    async validate_email(req, res, next) {
        let is_valid = !!req.body.email

        if ( is_valid ) {
            is_valid = email_validator.validate(req.body.email)
        }

        return res.api({ is_valid })
    }

    async get_users(req, res, next) {
        const User = this.models.get('auth:User')
        const users = await User.find()
        const data = []

        for ( const user of users ) {
            if ( !req.user.can(`auth:user:${user.id}:view`) && req.user.id !== user.id) continue
            data.push(await user.to_api())
        }

        return res.api(data)
    }

    async get_groups(req, res, next) {
        const Group = this.models.get('auth:Group')
        const groups = await Group.find({active: true})
        const data = []

        for ( const group of groups ) {
            if ( !req.user.can(`auth:group:${group.id}:view`) ) continue
            data.push(await group.to_api())
        }

        return res.api(data)
    }

    async get_roles(req, res, next) {
        const role_config = this.configs.get('auth.roles')
        const data = []
        for ( const role_name in role_config ) {
            if ( !role_config.hasOwnProperty(role_name) ) continue
            data.push({
                role: role_name,
                permissions: role_config[role_name],
            })
        }

        return res.api(data)
    }

    async get_group(req, res, next) {
        const Group = this.models.get('auth:Group')
        const group = await Group.findById(req.params.id)

        if ( !group || !group.active )
            return res.status(404)
                .message(req.T('api.group_not_found'))
                .api()

        if ( !req.user.can(`auth:group:${group.id}:view`) )
            return res.status(401)
                .message('Insufficient permissions.')
                .api()

        return res.api(await group.to_api())
    }

    async get_user(req, res, next) {
        if ( req.params.id === 'me' )
            return res.api(await req.user.to_api())

        const User = this.models.get('auth:User')
        const user = await User.findById(req.params.id)

        if ( !user )
            return res.status(404)
                .message(req.T('api.user_not_found'))
                .api()

        if ( !req.user.can(`auth:user:${user.id}:view`) )
            return res.status(401)
                .message(req.T('api.insufficient_permissions'))
                .api()

        return res.api(await user.to_api())
    }

    async get_user_flat(req, res, next) {
        if ( req.params.id === 'me' )
            return res.json(await req.user.to_api())

        const User = this.models.get('auth:User')
        const user = await User.findById(req.params.id)

        if ( !user )
            return res.status(404)
                .message(req.T('api.user_not_found'))
                .api()

        if ( !req.user.can(`auth:user:${user.id}:view`) )
            return res.status(401)
                .message(req.T('api.insufficient_permissions'))
                .api()

        return res.json(await user.to_api())
    }

    async get_user_photo(req, res, next) {
        let user
        if ( req.params.id === 'me' ) {
            user = req.user
        } else {
            const User = this.models.get('auth:User')
            user = await User.findOne({ uid: req.params.id })
        }

        if ( !user )
            return res.status(404)
                .message(req.T('api.user_not_found'))
                .api()

        const file = await user.photo()
        if ( !file )
            // The user does not have a profile. Send the default.
            return res.sendFile(this.utility.path('app/assets/people.png'))

        await file.send(res)
    }

    async create_group(req, res, next) {
        if ( !req.user.can(`auth:group:create`) )
            return res.status(401)
                .message(req.T('api.insufficient_permissions'))
                .api()

        if ( !req.body.name )
            return res.status(400)
                .message(`${req.T('api.missing_field')} name`)
                .api()

        const Group = this.models.get('auth:Group')

        // Make sure the name is free
        const existing_group = await Group.findOne({ name: req.body.name })
        if ( existing_group )
            return res.status(400)
                .message(req.T('api.group_already_exists'))
                .api()

        const group = new Group({
            name: req.body.name,
            grants_sudo: !!req.body.grants_sudo,
        })

        // Validate user ids
        const User = this.models.get('auth:User')
        if ( req.body.user_ids ) {
            const parsed = typeof req.body.user_ids === 'string' ? this.utility.infer(req.body.user_ids) : req.body.user_ids
            const user_ids = Array.isArray(parsed) ? parsed : [parsed]
            for ( const user_id of user_ids ) {
                const user = await User.findById(user_id)
                if ( !user )
                    return res.status(400)
                        .message(`${req.T('common.invalid')} user_id.`)
                        .api()
            }

            group.user_ids = user_ids
        }

        await group.save()
        await group.get_gid_number()
        return res.api(await group.to_api())
    }

    async create_user(req, res, next) {
        if ( !req.user.can('auth:user:create') )
            return res.status(401)
                .message(req.T('api.insufficient_permissions'))
                .api()

        const required_fields = ['uid', 'first_name', 'last_name', 'email', 'password']
        for ( const field of required_fields ) {
            if ( !req.body[field] )
                return res.status(400)
                    .message(`${req.T('api.missing_field')} ${field}`)
                    .api()
        }

        // Make sure uid & email are unique
        const User = this.models.get('auth:User')
        const unique_fields = ['uid', 'email']
        for ( const field of unique_fields ) {
            const filter = {}
            filter[field] = req.body[field]
            const existing_user = await User.findOne(filter)
            if ( existing_user )
                return res.status(400)
                    .message(`${req.T('auth.user_exists_with_field')} ${field}`)
                    .api()
        }

        // Verify password complexity
        const min_score = 3
        const result = zxcvbn(req.body.password)
        if ( result.score < min_score )
            return res.status(400)
                .message(req.T('auth.password_complexity_fail').replace('MIN_SCORE', min_score))
                .api()

        const user = new User({
            uid: req.body.uid.toLowerCase(),
            email: req.body.email,
            first_name: req.body.first_name,
            last_name: req.body.last_name,
        })

        if ( req.body.tagline )
            user.tagline = req.body.tagline

        if ( req.body.trap ) {
            if ( !req.trap.trap_exists(req.body.trap) )
                return res.status(400)
                    .message(req.T('auth.invalid_trap'))
                    .api()

            user.trap = req.body.trap
        }

        await user.reset_password(req.body.password, 'create')
        await user.save()
        await user.grant_defaults()
        return res.api(await user.to_api())
    }

    async update_group(req, res, next) {
        const Group = this.models.get('auth:Group')
        const User = this.models.get('auth:User')

        const group = await Group.findById(req.params.id)
        if ( !group )
            return res.status(404)
                .message(req.T('api.group_not_found'))
                .api()

        if ( !req.user.can(`auth:group:${group.id}:update`) )
            return res.status(401)
                .message(req.T('api.insufficient_permissions'))
                .api()

        if ( !req.body.name )
            return res.status(400)
                .message(`${req.T('api.missing_field')} name`)
                .api()

        // Make sure the group name is unique
        const existing_group = await Group.findOne({ name: req.body.name })
        if ( existing_group && existing_group.id !== group.id )
            return res.status(400)
                .message(req.T('api.group_already_exists'))
                .api()

        // Validate user_ids
        if ( req.body.user_ids ) {
            const parsed = typeof req.body.user_ids === 'string' ? this.utility.infer(req.body.user_ids) : req.body.user_ids
            const user_ids = Array.isArray(parsed) ? parsed : [parsed]
            for ( const user_id of user_ids ) {
                const user = await User.findById(user_id)
                if ( !user )
                    return res.status(400)
                        .message(`${req.T('common.invalid')} user_id.`)
                        .api()
            }

            group.user_ids = user_ids
        } else {
            group.user_ids = []
        }

        group.name = req.body.name
        group.grants_sudo = !!req.body.grants_sudo

        await group.save()
        await group.get_gid_number()
        return res.api()
    }

    async update_user(req, res, next) {
        const User = this.models.get('auth:User')
        const user = await User.findById(req.params.id)

        if ( !user )
            return res.status(404)
                .message(req.T('api.user_not_found'))
                .api()

        if ( !req.user.can(`auth:user:${user.id}:update`) )
            return res.status(401)
                .message(req.T('api.insufficient_permissions'))
                .api()

        const required_fields = ['uid', 'first_name', 'last_name', 'email']
        for ( const field of required_fields ) {
            if ( !req.body[field] )
                return res.status(400)
                    .message(`${req.T('api.missing_field')} ${field}`)
                    .api()
        }

        // Make sure the uid/email are unique
        const unique_fields = ['uid', 'email']
        for ( const field of unique_fields ) {
            const filter = {}
            filter[field] = req.body[field]
            const existing_user = await User.findOne(filter)
            if ( existing_user && existing_user.id !== user.id )
                return res.status(400)
                    .message(`${req.T('auth.user_exists_with_field')} ${field}`)
                    .api()
        }

        // Verify password complexity
        if ( req.body.password ) {
            const min_score = 3
            const result = zxcvbn(req.body.password)
            if (result.score < min_score)
                return res.status(400)
                    .message(req.T('auth.password_complexity_fail').replace('MIN_SCORE', min_score))
                    .api()

            await user.reset_password(req.body.password, 'api')
        }

        user.first_name = req.body.first_name
        user.last_name = req.body.last_name
        user.uid = req.body.uid.toLowerCase()
        user.email = req.body.email

        if ( req.body.tagline )
            user.tagline = req.body.tagline
        else
            user.tagline = ''

        if ( req.body.trap ) {
            if ( !req.trap.trap_exists(req.body.trap) )
                return res.status(400)
                    .message(req.T('auth.invalid_trap'))
                    .api()

            user.trap = req.body.trap
        } else
            user.trap = ''

        await user.save()
        return res.api()
    }

    async delete_group(req, res, next) {
        const Group = this.models.get('auth:Group')
        const group = await Group.findById(req.params.id)

        if ( !group )
            return res.status(404)
                .message(req.T('api.group_not_found'))
                .api()

        if ( !req.user.can(`auth:group:${group.id}:delete`) )
            return res.status(401)
                .message(req.T('api.insufficient_permissions'))
                .api()

        group.active = false
        await group.save()
        return res.api()
    }

    async delete_user(req, res, next) {
        const User = this.models.get('auth:User')
        const user = await User.findById(req.params.id)

        if ( !user )
            return res.status(404)
                .message(req.T('api.user_not_found'))
                .api()

        if ( !req.user.can(`auth:user:${user.id}:delete`) )
            return res.status(401)
                .message(req.T('api.insufficient_permissions'))
                .api()

        // check if the user is an LDAP client. if so, delete the client
        const Client = this.models.get('ldap:Client')
        const matching_client = await Client.findOne({ user_id: user.id })
        if ( matching_client ) {
            matching_client.active = false
            await matching_client.save()
        }

        user.active = false
        await user.kickout()
        await user.save()
        return res.api()
    }

    async validate_username(req, res, next) {
        let is_valid = true

        if ( !req.body.username ) is_valid = false

        if ( is_valid ) {
            const User = this.models.get('auth:User')
            const user = await User.findOne({uid: req.body.username.toLowerCase()})
            if ( !user || !user.can_login ) is_valid = false
        }

        return res.api({ is_valid })
    }

    async user_exists(req, res, next) {
        const User = this.models.get('auth:User')

        if ( !req.body.username && !req.body.email )
            return res.status(400)
                .message(`${req.T('api.provide_one')} username, email`)
                .api()

        const data = {}
        if ( req.body.username ) {
            const existing_user = await User.findOne({
                uid: req.body.username.toLowerCase(),
            })

            data.username_taken = !!existing_user
        }

        if ( req.body.email ) {
            const existing_user = await User.findOne({
                email: req.body.email,
            })

            data.email_taken = !!existing_user
        }

        return res.api(data)
    }

    // TODO XSRF Token
    /*
     * Request Params:
     *  - username
     *  - password
     *  - [create_session = false]
     */
    async attempt(req, res, next) {
        const flitter = this.auth.get_provider('flitter')

        const errors = await flitter.validate_login(req.body)
        if ( errors && errors.length > 0 )
            return res.status(400)
                .message(req.T('auth.unable_to_complete'))
                .api({ errors })

        const [username, ...other_args] = await flitter.get_login_args(req.body)
        const login_args = [username.toLowerCase(), ...other_args]
        const user = await flitter.login.apply(flitter, login_args)

        if ( !user )
            return res.status(200)
                .message(req.T('auth.invalid_un_or_pw'))
                .api({
                    message: req.T('auth.invalid_un_or_pw'),
                    success: false,
                })

        // Make sure the user can sign in.
        // Sign-in is NOT allowed for LDAP clients
        const Client = this.models.get('ldap:Client')
        const client = await Client.findOne({ user_id: user.id })
        if ( client )
            return res.status(200)
                .message(req.T('auth.invalid_un_or_pw'))
                .api({
                    message: req.T('auth.invalid_un_or_pw'),
                    success: false,
                })

        if ( req.body.create_session )
            await flitter.session(req, user)

        let destination = this.configs.get('auth.default_login_route')
        if ( req.session.auth.flow ) {
            destination = req.session.auth.flow
        }

        if ( user.mfa_enabled && !req.session.mfa_remember ) {
            await req.trap.begin('mfa_challenge', { session_only: true })
        }

        if ( req.session?.auth?.message )
            delete req.session.auth.message

        // If we're doing a trust flow, check the grant
        if ( req.body.grant_code && req.trust.has_flow() ) {
            if ( req.trust.check_grant(req.body.grant_code) ) {
                req.trust.grant(req.trust.flow_scope())

                // Trust re-verification is granted,
                // but the user might still need to verify MFA
                const next = req.trust.end()
                if ( req.trap.has_trap('mfa_challenge') ) {
                    req.session.auth.flow = next
                } else {
                    destination = next
                }
            } else {
                return res.status(401)
                    .message(req.T('auth.unable_to_grant_trust'))
                    .api()
            }
        }

        // Create a login tracking activity
        await this.activity.login(req)

        // If there are login messages, show those
        const LoginMessage = this.models.get('LoginMessage')
        const messages = await LoginMessage.for_user(user)
        if ( !req.trap.has_trap('mfa_challenge') && messages.length > 0 ) {
            await req.trap.begin('login_message', { session_only: true })
        }

        return res.api({
            success: true,
            session_created: !!req.body.create_session,
            next: destination,
        })
    }

    async get_mfa_recovery(req, res, next) {
        if ( !req.user.mfa_enabled )
            return res.status(400)
                .message(req.T('auth.no_mfa'))
                .api()

        const token = req.user.mfa_token
        if ( !Array.isArray(token.recovery_codes) || token.recovery_codes.length < 1 )
            return res.api({ has_recovery: false })

        return res.api({
            has_recovery: true,
            generated: token.recovery_codes[0].generated,
            remaining_codes: token.recovery_codes.filter(x => !x.used).length,
        })
    }

    async generate_mfa_recovery(req, res, next) {
        if ( !req.user.mfa_enabled )
            return res.status(400)
                .message(req.T('auth.no_mfa'))
                .api()

        const token = req.user.mfa_token
        const codes = await token.generate_recovery()
        await req.user.save()
        await this.activity.mfa_recovery_created({ req })
        return res.api({
            codes,
        })
    }

    async generate_mfa_key(req, res, next) {
        if ( req.user.mfa_enabled )
            return res.status(400)
                .message(req.T('auth.already_has_mfa'))
                .api()

        const MFAToken = this.models.get('auth:MFAToken')
        const secret = await this.MFA.secret(req.user)

        req.user.mfa_token = new MFAToken({
            secret: secret.base32,
            otpauth_url: secret.otpauth_url
        }, req.user)
        await req.user.save()

        return res.api({
            success: true,
            secret: secret.base32,
            otpauth_url: secret.otpauth_url,
            qr_code: await this.MFA.qr_code(secret)
        })
    }

    async attempt_mfa(req, res, next) {
        if ( !req.user.mfa_token )
            return res.status(400)
                .message(req.T('auth.no_mfa'))
                .api()

        const code = req.body.verify_code
        const token = req.user.mfa_token
        const is_valid = token.verify(code)

        let next_destination = undefined
        if ( is_valid ) {
            if ( req.trap.has_trap('mfa_challenge') )
                await req.trap.end()

            // If there are login messages, show those
            const LoginMessage = this.models.get('LoginMessage')
            const messages = await LoginMessage.for_user(req.user)
            if ( messages.length > 0 ) {
                await req.trap.begin('login_message', { session_only: true })
            }

            next_destination = req.session.auth.flow || this.configs.get('auth.default_login_route')

            if ( messages.length < 1 )
                delete req.session.auth.flow
        }

        req.session.mfa_remember = true

        return res.api({
            success: true,
            verify_code: code,
            is_valid,
            next_destination,
        })
    }

    async enable_mfa(req, res, next) {
        if ( !req.user.mfa_token )
            return res.status(400)
                .message(req.T('auth.no_mfa'))
                .api()

        req.user.mfa_enabled = true
        req.user.mfa_enable_date = new Date
        req.user.save()

        await this.activity.mfa_enable({ req })

        // invalidate existing tokens and other logins
        await req.user.logout(req)
        await req.user.kickout()

        return res.api({success: true, mfa_enabled: req.user.mfa_enabled})
    }

    async disable_mfa(req, res, next) {
        if ( !req.user.mfa_enabled )
            return res.status(400)
                .message(req.T('auth.no_mfa'))
                .api()

        req.user.mfa_enabled = false
        delete req.user.mfa_enable_date
        delete req.user.mfa_token
        req.user.app_passwords = []
        await req.user.save()

        await this.activity.mfa_disable({ req })

        // invalidate existing login tokens and logins
        await req.user.logout(req)
        await req.user.kickout()

        return res.api({success: true, mfa_enabled: req.user.mfa_enabled})
    }

    async get_mfa_enable_date(req, res, next) {
        if ( !req.user.mfa_enabled )
            return res.api({ mfa_enabled: false })

        return res.api({ mfa_enabled: true, mfa_enable_date: req.user.mfa_enable_date })
    }
}

module.exports = exports = AuthController
