// Copyright © 2020 Intel Corporation
//
// SPDX-License-Identifier: GPL-3.0-only

package user

import (
	"bytes"
	"fmt"
	"io/ioutil"
	"os"
	"path/filepath"
	"strings"
	"time"

	"github.com/clearlinux/clr-installer/cmd"
	"github.com/clearlinux/clr-installer/conf"
	"github.com/clearlinux/clr-installer/encrypt"
	"github.com/clearlinux/clr-installer/errors"
	"github.com/clearlinux/clr-installer/log"
	"github.com/clearlinux/clr-installer/progress"
	"github.com/clearlinux/clr-installer/utils"
)

// User abstracts a target system definition
type User struct {
	Login    string   `yaml:"login,omitempty"`
	UserName string   `yaml:"username,omitempty,flow"`
	Password string   `yaml:"password,omitempty,flow"`
	Admin    bool     `yaml:"admin,omitempty,flow"`
	SSHKeys  []string `yaml:"ssh-keys,omitempty,flow"`
}

const (
	defaultUsersFile = "/usr/share/defaults/etc/passwd"

	// RequiredBundle the bundle needed to enable non-root user accounts
	RequiredBundle = "sysadmin-basic"
)

var (
	sysDefaultUsers = []string{}
)

// IsSysDefaultUser checks if a given login is in the list of default users
func IsSysDefaultUser(login string) (bool, error) {
	if login == "" {
		return false, nil
	}

	if err := loadSysDefaultUsers(); err != nil {
		return false, err
	}

	for _, curr := range sysDefaultUsers {
		if curr == login {
			return true, nil
		}
	}

	return false, nil
}

func loadSysDefaultUsers() error {
	if len(sysDefaultUsers) != 0 {
		return nil
	}

	content, err := ioutil.ReadFile(defaultUsersFile)
	if err != nil {
		return err
	}

	for _, line := range strings.Split(string(content), "\n") {
		tks := strings.Split(line, ":")

		if len(tks) == 0 {
			return errors.Errorf("Could not parse passwd file, line: %s", line)
		}

		sysDefaultUsers = append(sysDefaultUsers, tks[0])
	}

	return nil
}

// NewUser creates/allocates a new user handle
func NewUser(login string, username string, pwd string, admin bool) (*User, error) {
	hashed, err := encrypt.Crypt(pwd)
	if err != nil {
		return nil, err
	}

	return &User{
		Login:    login,
		UserName: username,
		Password: hashed,
		Admin:    admin,
	}, nil
}

// SetPassword sets a users password
func (u *User) SetPassword(pwd string) error {
	hashed, err := encrypt.Crypt(pwd)
	if err != nil {
		return err
	}

	u.Password = hashed
	return nil
}

// Equals returns true if u and usr point to the same struct or if both have
// the same Login string
func (u *User) Equals(usr *User) bool {
	return u == usr || u.Login == usr.Login
}

// setTempTargetPAMConfig copy the temporary chpasswd PAM config to target system
// this is required for changing user's password into target system.
func setTempTargetPAMConfig(rootDir string) error {
	var chpasswdFile string
	var err error

	pamDir := filepath.Join(rootDir, "etc", "pam.d")

	if err = utils.MkdirAll(pamDir, 0755); err != nil {
		return err
	}

	if chpasswdFile, err = conf.LookupChpasswdConfig(); err != nil {
		return err
	}

	targetPamFile := filepath.Join(pamDir, conf.ChpasswdPAMFile)
	if err = utils.CopyFile(chpasswdFile, targetPamFile); err != nil {
		return err
	}

	return nil
}

// Apply creates the user and sets their password into chroot'ed rootDir
func Apply(rootDir string, users []*User) error {
	if len(users) == 0 {
		return nil
	}

	prg := progress.NewLoop(utils.Locale.Get("Adding extra users"))
	if err := setTempTargetPAMConfig(rootDir); err != nil {
		prg.Failure()
		return err
	}

	// Should we lock out the root account?
	haveAdmins := false
	rootPassSet := false
	rootSSHOnly := false

	for _, usr := range users {
		log.Info("Adding extra user '%s'", usr.Login)
		if err := usr.apply(rootDir); err != nil {
			prg.Failure()
			return err
		}

		if usr.Admin {
			haveAdmins = true
		}

		// This should not be possible in the TUI as all system accounts
		// are not allowed to be defined, but is possible via the command
		// line (aka mass installer)
		if usr.Login == "root" {
			if usr.Password == "" {
				if len(usr.SSHKeys) > 0 {
					rootSSHOnly = true
				}
			} else {
				rootPassSet = true
			}
		}
	}

	// If the root account is not defined with an encrypted password and
	// we have user account which are Admin (sudo)
	// OR
	// The root account is defined with SSH Keys, no password
	if (!rootPassSet && haveAdmins) || rootSSHOnly {
		log.Info("Disabling the 'root' account.")
		if err := disableRoot(rootDir); err != nil {
			prg.Failure()
			return err
		}
	}

	prg.Success()
	return nil
}

// disableRoot will lockout the root account
// should be called only when adding an account which
// has been granted admin privileges (sudo)
func disableRoot(rootDir string) error {
	// Lock the account
	args := []string{
		"chroot",
		rootDir,
		"usermod",
		"--lock",
		"root",
	}

	if err := cmd.RunAndLog(args...); err != nil {
		return errors.Wrap(err)
	}

	// How many days since the beginning of (UNIX) time
	beginning := time.Date(1970, time.Month(1), 1, 0, 0, 0, 0, time.UTC)
	now := time.Now()
	days := fmt.Sprintf("%d", int64(now.Sub(beginning).Hours()/24))

	// Set a password change date so we are not prompted
	// when sudo'ing to root account or when ssh'ing at root
	args = []string{
		"chroot",
		rootDir,
		"chage",
		"--lastday",
		days,
		"root",
	}

	if err := cmd.RunAndLog(args...); err != nil {
		return errors.Wrap(err)
	}

	return nil
}

// userExist will lockout the root account
// should be called only when adding an account which
// has been granted admin privileges (sudo)
func (u *User) userExist(rootDir string) bool {
	exists := true

	args := []string{
		"chroot",
		rootDir,
		"getent",
		"passwd",
		u.Login,
	}

	if err := cmd.RunAndLog(args...); err != nil {
		exists = false
	}

	return exists
}

// getUserHome returns the home directory of the user
// on the installation target
func (u *User) getUserHome(rootDir string) string {
	home := filepath.Join("/home", u.Login)

	// Ask for the accounts passwd entry and parse the home directory
	args := []string{
		"chroot",
		rootDir,
		"getent",
		"passwd",
		u.Login,
	}

	w := bytes.NewBuffer(nil)

	err := cmd.Run(w, args...)
	if err != nil {
		return home
	}

	getent := bytes.Split(w.Bytes(), []byte(":"))
	homeDir := string(getent[len(getent)-2])
	if len(homeDir) > 0 {
		home = homeDir
	}

	return home
}

// apply applies the user configuration to the target install
func (u *User) apply(rootDir string) error {
	accountAdded := false

	if u.userExist(rootDir) {
		log.Info("Account '%s' already a defined system account, skipping add.", u.Login)
	} else {
		args := []string{
			"chroot",
			rootDir,
			"useradd",
			"--comment",
			u.UserName,
			u.Login,
		}

		if u.Admin {
			args = append(args, []string{
				"-G",
				"wheel",
			}...)
		}

		if err := cmd.RunAndLog(args...); err != nil {
			return errors.Wrap(err)
		}

		accountAdded = true
	}

	if u.Password != "" {
		if !accountAdded {
			// Unlock the account
			// This is hack to ensure the account gets added to the
			// /etc/passwd file before trying to set the password with
			// chpasswd as the ch* commands only look in /etc
			args := []string{
				"chroot",
				rootDir,
				"usermod",
				"--unlock",
				u.Login,
			}

			if err := cmd.RunAndLog(args...); err != nil {
				return errors.Wrap(err)
			}
		}

		args := []string{
			"chroot",
			rootDir,
			"chpasswd",
			"-e",
		}

		pwd := fmt.Sprintf("%s:%s", u.Login, u.Password)

		if err := cmd.PipeRunAndLog(pwd, args...); err != nil {
			return errors.Wrap(err)
		}
	}

	if len(u.SSHKeys) > 0 {
		if err := writeSSHKey(rootDir, u); err != nil {
			return err
		}
	}

	return nil
}

func writeSSHKey(rootDir string, u *User) error {
	sshDir := filepath.Join(u.getUserHome(rootDir), ".ssh")
	dpath := filepath.Join(rootDir, sshDir)
	fpath := filepath.Join(dpath, "authorized_keys")

	if err := utils.MkdirAll(dpath, 0700); err != nil {
		return err
	}

	f, err := os.OpenFile(fpath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600)
	if err != nil {
		return err
	}

	defer func() {
		_ = f.Close()
	}()

	cnt := fmt.Sprintf("%s\n", strings.Join(u.SSHKeys, "\n"))
	bt := []byte(cnt)
	n, err := f.Write(bt)
	if err != nil {
		return err
	}

	if n != len(bt) {
		return errors.Errorf("Failed to write ssh key, wrote %d of %d bytes", n, len(bt))
	}

	args := []string{
		"chroot",
		rootDir,
		"/usr/bin/chown",
		"-R",
		fmt.Sprintf("%s:%s", u.Login, u.Login),
		sshDir,
	}

	if err := cmd.RunAndLog(args...); err != nil {
		return err
	}

	return nil
}

// IsValidUsername checks the username restrictions
func IsValidUsername(username string) (bool, string) {
	validator := NewValidator("", username, "")

	if err := validator.usernameRegexCheck(); err != nil {
		return false, err.Error()
	}

	if err := validator.usernameMaxLengthCheck(); err != nil {
		return false, err.Error()
	}

	return true, ""
}

// IsValidLogin checks the minimum login requirements
func IsValidLogin(login string) (bool, string) {
	validator := NewValidator(login, "", "")

	if err := validator.loginEmptyCheck(); err != nil {
		return false, err.Error()
	}

	if err := validator.loginRegexCheck(); err != nil {
		return false, err.Error()
	}

	if err := validator.loginMaxLengthCheck(); err != nil {
		return false, err.Error()
	}

	return true, ""
}

// IsValidPassword checks the minimum password requirements
func IsValidPassword(pwd string) (bool, string) {
	validator := NewValidator("", "", pwd)

	if err := validator.passwordMaximumLengthCheck(); err != nil {
		return false, err.Error()
	}

	if err := validator.passwordMinimumLengthCheck(); err != nil {
		return false, err.Error()
	}

	// always perform this at the end
	if err := validator.passwordCracklibCheck(); err != nil {
		return false, err.Error()
	}

	return true, ""
}
