package password

import (
	"context"
	"crypto/subtle"
	"errors"
	"fmt"

	"github.com/hashicorp/boundary/internal/db"
	"github.com/hashicorp/boundary/internal/kms"
	"github.com/hashicorp/boundary/internal/oplog"
	"golang.org/x/crypto/argon2"
)

type authAccount struct {
	*Account
	*Argon2Credential
	*Argon2Configuration
	IsCurrentConf bool
}

// Authenticate authenticates loginName and password match for loginName in
// authMethodId. The account for the loginName is returned if authentication
// is successful. Returns nil if authentication fails.
//
// The CredentialId in the returned account represents a user's current
// password. A new CredentialId is generated when a user's password is
// changed and the old one is deleted.
//
// Authenticate will update the stored values for password to the current
// password settings for authMethodId if authentication is successful and
// the stored values are not using the current password settings.
func (r *Repository) Authenticate(ctx context.Context, scopeId, authMethodId, loginName, password string) (*Account, error) {
	if authMethodId == "" {
		return nil, fmt.Errorf("password authenticate: no authMethodId: %w", db.ErrInvalidParameter)
	}
	if loginName == "" {
		return nil, fmt.Errorf("password authenticate: no loginName: %w", db.ErrInvalidParameter)
	}
	if password == "" {
		return nil, fmt.Errorf("password authenticate: no password: %w", db.ErrInvalidParameter)
	}
	if scopeId == "" {
		return nil, fmt.Errorf("password authenticate: no scopeId: %w", db.ErrInvalidParameter)
	}

	databaseWrapper, err := r.kms.GetWrapper(ctx, scopeId, kms.KeyPurposeDatabase)
	if err != nil {
		return nil, fmt.Errorf("password authenticate: unable to get database wrapper: %w", err)
	}

	acct, err := r.authenticate(ctx, scopeId, authMethodId, loginName, password)
	if err != nil {
		return nil, fmt.Errorf("password authenticate: %w", err)
	}
	if acct == nil {
		return nil, nil
	}

	if !acct.IsCurrentConf {
		cc, err := r.currentConfig(ctx, authMethodId)
		if err != nil {
			return acct.Account, fmt.Errorf("password authenticate: retrieve current password configuration: %w", err)
		}
		cred, err := newArgon2Credential(acct.PublicId, password, cc.argon2())
		if err != nil {
			return acct.Account, fmt.Errorf("password authenticate: update credential: %w", err)
		}

		oplogWrapper, err := r.kms.GetWrapper(ctx, scopeId, kms.KeyPurposeOplog)
		if err != nil {
			return acct.Account, fmt.Errorf("password authenticate: unable to get oplog wrapper: %w", err)
		}

		// do not change the Credential Id
		cred.PrivateId = acct.CredentialId
		if err := cred.encrypt(ctx, databaseWrapper); err != nil {
			return acct.Account, fmt.Errorf("password authenticate: update credential: encrypt: %w", err)
		}

		fields := []string{"CtSalt", "DerivedKey", "PasswordConfId"}
		metadata := cred.oplog(oplog.OpType_OP_TYPE_UPDATE)

		_, err = r.writer.DoTx(ctx, db.StdRetryCnt, db.ExpBackoff{},
			func(_ db.Reader, w db.Writer) error {
				rowsUpdated, err := w.Update(ctx, cred, fields, nil, db.WithOplog(oplogWrapper, metadata))
				if err == nil && rowsUpdated > 1 {
					return db.ErrMultipleRecords
				}
				return err
			},
		)
		if err != nil {
			return acct.Account, fmt.Errorf("password authenticate: update credential: %w", err)
		}
	}
	return acct.Account, nil
}

// ChangePassword updates the password for accountId to new if old equals
// the stored password. The account for the accountId is returned with a
// new CredentialId if password is successfully changed.
//
// Returns nil, db.ErrorRecordNotFound if the account doesn't exist.
// Returns nil, nil if old does not match the stored password for accountId.
// Returns nil, ErrPasswordsEqual if old and new are equal.
func (r *Repository) ChangePassword(ctx context.Context, scopeId, accountId, old, new string, version uint32) (*Account, error) {
	if accountId == "" {
		return nil, fmt.Errorf("change password: no account id: %w", db.ErrInvalidParameter)
	}
	if old == "" {
		return nil, fmt.Errorf("change password: no old password: %w", db.ErrInvalidParameter)
	}
	if new == "" {
		return nil, fmt.Errorf("change password: no new password: %w", db.ErrInvalidParameter)
	}
	if old == new {
		return nil, fmt.Errorf("change password: %w", ErrPasswordsEqual)
	}
	if version == 0 {
		return nil, fmt.Errorf("change password: no version supplied: %w", db.ErrInvalidParameter)
	}
	if scopeId == "" {
		return nil, fmt.Errorf("change password: no scopeId: %w", db.ErrInvalidParameter)
	}

	authAccount, err := r.LookupAccount(ctx, accountId)
	if err != nil {
		return nil, fmt.Errorf("change password: lookup account: %w", err)
	}
	if authAccount == nil {
		return nil, fmt.Errorf("change password: lookup account: account not found: %w", db.ErrRecordNotFound)
	}

	oplogWrapper, err := r.kms.GetWrapper(ctx, scopeId, kms.KeyPurposeOplog)
	if err != nil {
		return nil, fmt.Errorf("change password: unable to get oplog wrapper: %w", err)
	}
	databaseWrapper, err := r.kms.GetWrapper(ctx, scopeId, kms.KeyPurposeDatabase)
	if err != nil {
		return nil, fmt.Errorf("change password: unable to get database wrapper: %w", err)
	}

	acct, err := r.authenticate(ctx, scopeId, authAccount.GetAuthMethodId(), authAccount.GetLoginName(), old)
	if err != nil {
		return nil, fmt.Errorf("change password: %w", err)
	}
	if acct == nil {
		return nil, nil
	}

	cc, err := r.currentConfig(ctx, authAccount.GetAuthMethodId())
	if err != nil {
		return nil, fmt.Errorf("change password: retrieve current password configuration: %w", err)
	}
	if cc.MinPasswordLength > len(new) {
		return nil, fmt.Errorf("change password: %w", ErrTooShort)
	}
	newCred, err := newArgon2Credential(accountId, new, cc.argon2())
	if err != nil {
		return nil, fmt.Errorf("change password: %w", err)
	}

	if err := newCred.encrypt(ctx, databaseWrapper); err != nil {
		return nil, fmt.Errorf("change password: encrypt: %w", err)
	}

	oldCred := acct.Argon2Credential

	var updatedAccount *Account
	_, err = r.writer.DoTx(ctx, db.StdRetryCnt, db.ExpBackoff{},
		func(_ db.Reader, w db.Writer) error {
			updatedAccount = allocAccount()
			updatedAccount.PublicId = accountId
			updatedAccount.Version = version + 1
			rowsUpdated, err := w.Update(ctx, updatedAccount, []string{"Version"}, nil, db.WithOplog(oplogWrapper, acct.Account.oplog(oplog.OpType_OP_TYPE_UPDATE)), db.WithVersion(&version))
			if err != nil {
				return fmt.Errorf("change password: unable to update account version: %w", err)
			}
			if rowsUpdated != 1 {
				return fmt.Errorf("change password: updated account and %d rows updated", rowsUpdated)
			}

			rowsDeleted, err := w.Delete(ctx, oldCred, db.WithOplog(oplogWrapper, oldCred.oplog(oplog.OpType_OP_TYPE_DELETE)))
			if err == nil && rowsDeleted > 1 {
				return db.ErrMultipleRecords
			}
			if err != nil {
				return err
			}
			return w.Create(ctx, newCred, db.WithOplog(oplogWrapper, newCred.oplog(oplog.OpType_OP_TYPE_CREATE)))
		},
	)
	if err != nil {
		return nil, fmt.Errorf("change password: %w", err)
	}

	// change the Credential Id
	updatedAccount.CredentialId = newCred.PrivateId
	return updatedAccount, nil
}

func (r *Repository) authenticate(ctx context.Context, scopeId, authMethodId, loginName, password string) (*authAccount, error) {
	var accts []authAccount

	rows, err := r.reader.Query(ctx, authenticateQuery, []interface{}{authMethodId, loginName})
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	for rows.Next() {
		var aa authAccount
		if err := r.reader.ScanRows(rows, &aa); err != nil {
			return nil, err
		}
		accts = append(accts, aa)
	}

	var acct authAccount
	switch {
	case len(accts) == 0:
		return nil, nil
	case len(accts) > 1:
		// this should never happen
		return nil, fmt.Errorf("multiple accounts returned for user name")
	default:
		acct = accts[0]
	}

	// We don't pass a wrapper in here because for ecryption we want to indicate the expected key ID
	databaseWrapper, err := r.kms.GetWrapper(ctx, scopeId, kms.KeyPurposeDatabase, kms.WithKeyId(acct.GetKeyId()))
	if err != nil {
		return nil, fmt.Errorf("unable to get database wrapper: %w", err)
	}

	if err := acct.decrypt(ctx, databaseWrapper); err != nil {
		return nil, fmt.Errorf("cannot decrypt credential: %w", err)
	}

	inputKey := argon2.IDKey([]byte(password), acct.Salt, acct.Iterations, acct.Memory, uint8(acct.Threads), acct.KeyLength)
	if subtle.ConstantTimeCompare(inputKey, acct.DerivedKey) == 0 {
		// authentication failed, password does not match
		return nil, nil
	}
	return &acct, nil
}

// SetPassword sets the password for accountId to password. If password
// contains an empty string, the password for accountId will be deleted.
func (r *Repository) SetPassword(ctx context.Context, scopeId, accountId, password string, version uint32) (*Account, error) {
	if accountId == "" {
		return nil, fmt.Errorf("set password: no accountId: %w", db.ErrInvalidParameter)
	}
	if version == 0 {
		return nil, fmt.Errorf("set password: no version supplied: %w", db.ErrInvalidParameter)
	}
	if scopeId == "" {
		return nil, fmt.Errorf("set password: no scopeId: %w", db.ErrInvalidParameter)
	}

	oplogWrapper, err := r.kms.GetWrapper(ctx, scopeId, kms.KeyPurposeOplog)
	if err != nil {
		return nil, fmt.Errorf("set password: unable to get oplog wrapper: %w", err)
	}
	databaseWrapper, err := r.kms.GetWrapper(ctx, scopeId, kms.KeyPurposeDatabase)
	if err != nil {
		return nil, fmt.Errorf("set password: unable to get database wrapper: %w", err)
	}

	var newCred *Argon2Credential
	if password != "" {
		cc, err := r.currentConfigForAccount(ctx, accountId)
		if err != nil {
			return nil, fmt.Errorf("set password: retrieve current configuration: %w", err)
		}
		if cc == nil {
			return nil, fmt.Errorf("set password: retrieve current configuration: %w", db.ErrRecordNotFound)
		}
		if cc.MinPasswordLength > len(password) {
			return nil, fmt.Errorf("set password: new password: %w", ErrTooShort)
		}
		newCred, err = newArgon2Credential(accountId, password, cc.argon2())
		if err != nil {
			return nil, fmt.Errorf("set password: %w", err)
		}
		if err := newCred.encrypt(ctx, databaseWrapper); err != nil {
			return nil, fmt.Errorf("set password: encrypt: %w", err)
		}
	}

	var acct *Account
	_, err = r.writer.DoTx(ctx, db.StdRetryCnt, db.ExpBackoff{},
		func(rr db.Reader, w db.Writer) error {
			updatedAccount := allocAccount()
			updatedAccount.PublicId = accountId
			updatedAccount.Version = version + 1
			rowsUpdated, err := w.Update(ctx, updatedAccount, []string{"Version"}, nil, db.WithOplog(oplogWrapper, updatedAccount.oplog(oplog.OpType_OP_TYPE_UPDATE)), db.WithVersion(&version))
			if err != nil {
				return fmt.Errorf("set password: unable to update account version: %w", err)
			}
			if rowsUpdated != 1 {
				return fmt.Errorf("set password: updated account and %d rows updated", rowsUpdated)
			}
			acct = updatedAccount

			oldCred := allocCredential()
			if err := rr.LookupWhere(ctx, &oldCred, "password_account_id = ?", accountId); err != nil {
				if !errors.Is(err, db.ErrRecordNotFound) {
					return err
				}
			}
			if oldCred.PrivateId != "" {
				dCred := oldCred.clone()
				rowsDeleted, err := w.Delete(ctx, dCred, db.WithOplog(oplogWrapper, oldCred.oplog(oplog.OpType_OP_TYPE_DELETE)))
				if err == nil && rowsDeleted > 1 {
					return db.ErrMultipleRecords
				}
				if err != nil {
					return err
				}
			}
			if newCred != nil {
				return w.Create(ctx, newCred, db.WithOplog(oplogWrapper, newCred.oplog(oplog.OpType_OP_TYPE_CREATE)))
			}
			return nil
		},
	)
	if err != nil {
		return nil, fmt.Errorf("set password: %w", err)
	}
	return acct, nil
}
