package userpassword

import (
	"crypto/rand"
	"crypto/sha256"
	"crypto/subtle"
	"encoding/base64"
	"fmt"
	"os"
	"strconv"
	"strings"

	"golang.org/x/crypto/pbkdf2"
	"golang.org/x/exp/slices"
	"golang.org/x/xerrors"
)

var (
	// The base64 encoder used when producing the string representation of
	// hashes.
	base64Encoding = base64.RawStdEncoding

	// The number of iterations to use when generating the hash. This was chosen
	// to make it about as fast as bcrypt hashes. Increasing this causes hashes
	// to take longer to compute.
	defaultHashIter = 65535

	// This is the length of our output hash. bcrypt has a hash size of up to
	// 60, so we rounded up to a power of 8.
	hashLength = 64

	// The scheme to include in our hashed password.
	hashScheme = "pbkdf2-sha256"

	// A salt size of 16 is the default in passlib. A minimum of 8 can be safely
	// used.
	defaultSaltSize = 16

	// The simulated hash is used when trying to simulate password checks for
	// users that don't exist.
	simulatedHash, _ = Hash("hunter2")
)

// Make password hashing much faster in tests.
func init() {
	args := os.Args[1:]

	// Ensure this can never be enabled if running in server mode.
	if slices.Contains(args, "server") {
		return
	}

	for _, flag := range args {
		if strings.HasPrefix(flag, "-test.") {
			defaultHashIter = 1
			return
		}
	}
}

// Compare checks the equality of passwords from a hashed pbkdf2 string. This
// uses pbkdf2 to ensure FIPS 140-2 compliance. See:
// https://csrc.nist.gov/csrc/media/templates/cryptographic-module-validation-program/documents/security-policies/140sp2261.pdf
func Compare(hashed string, password string) (bool, error) {
	// If the hased password provided is empty, simulate comparing a real hash.
	if hashed == "" {
		hashed = simulatedHash
	}

	if len(hashed) < hashLength {
		return false, xerrors.Errorf("hash too short: %d", len(hashed))
	}
	parts := strings.SplitN(hashed, "$", 5)
	if len(parts) != 5 {
		return false, xerrors.Errorf("hash has too many parts: %d", len(parts))
	}
	if len(parts[0]) != 0 {
		return false, xerrors.Errorf("hash prefix is invalid")
	}
	if parts[1] != hashScheme {
		return false, xerrors.Errorf("hash isn't %q scheme: %q", hashScheme, parts[1])
	}
	iter, err := strconv.Atoi(parts[2])
	if err != nil {
		return false, xerrors.Errorf("parse iter from hash: %w", err)
	}
	salt, err := base64Encoding.DecodeString(parts[3])
	if err != nil {
		return false, xerrors.Errorf("decode salt: %w", err)
	}

	if subtle.ConstantTimeCompare([]byte(hashWithSaltAndIter(password, salt, iter)), []byte(hashed)) != 1 {
		return false, nil
	}

	return true, nil
}

// Hash generates a hash using pbkdf2.
// See the Compare() comment for rationale.
func Hash(password string) (string, error) {
	salt := make([]byte, defaultSaltSize)
	_, err := rand.Read(salt)
	if err != nil {
		return "", xerrors.Errorf("read random bytes for salt: %w", err)
	}

	return hashWithSaltAndIter(password, salt, defaultHashIter), nil
}

// Produces a string representation of the hash.
func hashWithSaltAndIter(password string, salt []byte, iter int) string {
	var (
		hash    = pbkdf2.Key([]byte(password), salt, iter, hashLength, sha256.New)
		encHash = make([]byte, base64Encoding.EncodedLen(len(hash)))
		encSalt = make([]byte, base64Encoding.EncodedLen(len(salt)))
	)

	base64Encoding.Encode(encHash, hash)
	base64Encoding.Encode(encSalt, salt)

	return fmt.Sprintf("$%s$%d$%s$%s", hashScheme, iter, encSalt, encHash)
}

// Validate checks that the plain text password meets the minimum password requirements.
// It returns properly formatted errors for detailed form validation on the client.
func Validate(password string) error {
	const (
		minLength = 8
		maxLength = 64
	)
	if len(password) < minLength {
		return xerrors.Errorf("Password must be at least %d characters.", minLength)
	}
	if len(password) > maxLength {
		return xerrors.Errorf("Password must be no more than %d characters.", maxLength)
	}
	return nil
}
