package wallet

import (
	"context"
	"fmt"
	"io"
	"io/ioutil"
	"os"
	"path/filepath"
	"strings"

	"github.com/pkg/errors"
	"github.com/prysmaticlabs/prysm/cmd/validator/flags"
	"github.com/prysmaticlabs/prysm/encoding/bytesutil"
	"github.com/prysmaticlabs/prysm/io/file"
	"github.com/prysmaticlabs/prysm/io/prompt"
	"github.com/prysmaticlabs/prysm/validator/accounts/iface"
	accountsprompt "github.com/prysmaticlabs/prysm/validator/accounts/userprompt"
	"github.com/prysmaticlabs/prysm/validator/keymanager"
	"github.com/prysmaticlabs/prysm/validator/keymanager/derived"
	"github.com/prysmaticlabs/prysm/validator/keymanager/local"
	"github.com/prysmaticlabs/prysm/validator/keymanager/remote"
	remote_web3signer "github.com/prysmaticlabs/prysm/validator/keymanager/remote-web3signer"
	"github.com/sirupsen/logrus"
	"github.com/urfave/cli/v2"
)

const (
	// KeymanagerConfigFileName for the keymanager used by the wallet: imported, derived, remote, or web3signer.
	KeymanagerConfigFileName = "keymanageropts.json"
	// NewWalletPasswordPromptText for wallet creation.
	NewWalletPasswordPromptText = "New wallet password"
	// PasswordPromptText for wallet unlocking.
	PasswordPromptText = "Wallet password"
	// ConfirmPasswordPromptText for confirming a wallet password.
	ConfirmPasswordPromptText = "Confirm password"
	// DefaultWalletPasswordFile used to store a wallet password with appropriate permissions
	// if a user signs up via the Prysm web UI via RPC.
	DefaultWalletPasswordFile = "walletpassword.txt"
	// CheckExistsErrMsg for when there is an error while checking for a wallet
	CheckExistsErrMsg = "could not check if wallet exists"
	// CheckValidityErrMsg for when there is an error while checking wallet validity
	CheckValidityErrMsg = "could not check if wallet is valid"
	// InvalidWalletErrMsg for when a directory does not contain a valid wallet
	InvalidWalletErrMsg = "directory does not contain valid wallet"
)

var (
	// ErrNoWalletFound signifies there was no wallet directory found on-disk.
	ErrNoWalletFound = errors.New(
		"no wallet found. You can create a new wallet with `validator wallet create`. " +
			"If you already did, perhaps you created a wallet in a custom directory, which you can specify using " +
			"`--wallet-dir=/path/to/my/wallet`",
	)
	// KeymanagerKindSelections as friendly text.
	KeymanagerKindSelections = map[keymanager.Kind]string{
		keymanager.Local:      "Imported Wallet (Recommended)",
		keymanager.Derived:    "HD Wallet",
		keymanager.Remote:     "Remote Signing Wallet (Advanced)",
		keymanager.Web3Signer: "Consensys Web3Signer (Advanced)",
	}
	// ValidateExistingPass checks that an input cannot be empty.
	ValidateExistingPass = func(input string) error {
		if input == "" {
			return errors.New("password input cannot be empty")
		}
		return nil
	}
)

// Config to open a wallet programmatically.
type Config struct {
	WalletDir      string
	KeymanagerKind keymanager.Kind
	WalletPassword string
}

// Wallet is a primitive in Prysm's account management which
// has the capability of creating new accounts, reading existing accounts,
// and providing secure access to Ethereum proof of stake secrets depending on an
// associated keymanager (either imported, derived, or remote signing enabled).
type Wallet struct {
	walletDir      string
	accountsPath   string
	configFilePath string
	walletPassword string
	keymanagerKind keymanager.Kind
}

// New creates a struct from config values.
func New(cfg *Config) *Wallet {
	accountsPath := filepath.Join(cfg.WalletDir, cfg.KeymanagerKind.String())
	return &Wallet{
		walletDir:      cfg.WalletDir,
		accountsPath:   accountsPath,
		keymanagerKind: cfg.KeymanagerKind,
		walletPassword: cfg.WalletPassword,
	}
}

// Exists checks if directory at walletDir exists
func Exists(walletDir string) (bool, error) {
	dirExists, err := file.HasDir(walletDir)
	if err != nil {
		return false, errors.Wrap(err, "could not parse wallet directory")
	}
	isValid, err := IsValid(walletDir)
	if errors.Is(err, ErrNoWalletFound) {
		return false, nil
	} else if err != nil {
		return false, errors.Wrap(err, "could not check if dir is valid")
	}
	return dirExists && isValid, nil
}

// IsValid checks if a folder contains a single key directory such as `derived`, `remote` or `imported`.
// Returns true if one of those subdirectories exist, false otherwise.
func IsValid(walletDir string) (bool, error) {
	expanded, err := file.ExpandPath(walletDir)
	if err != nil {
		return false, err
	}
	f, err := os.Open(expanded) // #nosec G304
	if err != nil {
		if strings.Contains(err.Error(), "no such file") ||
			strings.Contains(err.Error(), "cannot find the file") ||
			strings.Contains(err.Error(), "cannot find the path") {
			return false, nil
		}
		return false, err
	}
	defer func() {
		if err := f.Close(); err != nil {
			log.Debugf("Could not close directory: %s", expanded)
		}
	}()
	names, err := f.Readdirnames(-1)
	if err != nil {
		return false, err
	}

	if len(names) == 0 {
		return false, ErrNoWalletFound
	}

	// Count how many wallet types we have in the directory
	numWalletTypes := 0
	for _, name := range names {
		// Nil error means input name is `derived`, `remote` or `imported`
		_, err = keymanager.ParseKind(name)
		if err == nil {
			numWalletTypes++
		}
	}
	return numWalletTypes == 1, nil
}

// OpenWalletOrElseCli tries to open the wallet and if it fails or no wallet
// is found, invokes a callback function.
func OpenWalletOrElseCli(cliCtx *cli.Context, otherwise func(cliCtx *cli.Context) (*Wallet, error)) (*Wallet, error) {
	exists, err := Exists(cliCtx.String(flags.WalletDirFlag.Name))
	if err != nil {
		return nil, errors.Wrap(err, CheckExistsErrMsg)
	}
	if !exists {
		return otherwise(cliCtx)
	}
	isValid, err := IsValid(cliCtx.String(flags.WalletDirFlag.Name))
	if errors.Is(err, ErrNoWalletFound) {
		return otherwise(cliCtx)
	}
	if err != nil {
		return nil, errors.Wrap(err, CheckValidityErrMsg)
	}
	if !isValid {
		return nil, errors.New(InvalidWalletErrMsg)
	}

	walletDir, err := accountsprompt.InputDirectory(cliCtx, accountsprompt.WalletDirPromptText, flags.WalletDirFlag)
	if err != nil {
		return nil, err
	}
	walletPassword, err := InputPassword(
		cliCtx,
		flags.WalletPasswordFileFlag,
		PasswordPromptText,
		false, /* Do not confirm password */
		ValidateExistingPass,
	)
	if err != nil {
		return nil, err
	}
	return OpenWallet(cliCtx.Context, &Config{
		WalletDir:      walletDir,
		WalletPassword: walletPassword,
	})
}

// NewWalletForWeb3Signer returns a new wallet for web3 signer which is temporary and not stored locally.
func NewWalletForWeb3Signer() *Wallet {
	// wallet is just a temporary wallet for web3 signer used to call intialize keymanager.
	return &Wallet{
		walletDir:      "",
		accountsPath:   "",
		keymanagerKind: keymanager.Web3Signer,
		walletPassword: "",
	}
}

// OpenWallet instantiates a wallet from a specified path. It checks the
// type of keymanager associated with the wallet by reading files in the wallet
// path, if applicable. If a wallet does not exist, returns an appropriate error.
func OpenWallet(_ context.Context, cfg *Config) (*Wallet, error) {
	exists, err := Exists(cfg.WalletDir)
	if err != nil {
		return nil, errors.Wrap(err, CheckExistsErrMsg)
	}
	if !exists {
		return nil, ErrNoWalletFound
	}
	valid, err := IsValid(cfg.WalletDir)
	// ErrNoWalletFound represents both a directory that does not exist as well as an empty directory
	if errors.Is(err, ErrNoWalletFound) {
		return nil, ErrNoWalletFound
	}
	if err != nil {
		return nil, errors.Wrap(err, CheckValidityErrMsg)
	}
	if !valid {
		return nil, errors.New(InvalidWalletErrMsg)
	}

	keymanagerKind, err := readKeymanagerKindFromWalletPath(cfg.WalletDir)
	if err != nil {
		return nil, errors.Wrap(err, "could not read keymanager kind for wallet")
	}
	accountsPath := filepath.Join(cfg.WalletDir, keymanagerKind.String())
	return &Wallet{
		walletDir:      cfg.WalletDir,
		accountsPath:   accountsPath,
		keymanagerKind: keymanagerKind,
		walletPassword: cfg.WalletPassword,
	}, nil
}

// SaveWallet persists the wallet's directories to disk.
func (w *Wallet) SaveWallet() error {
	if err := file.MkdirAll(w.accountsPath); err != nil {
		return errors.Wrap(err, "could not create wallet directory")
	}
	return nil
}

// KeymanagerKind used by the wallet.
func (w *Wallet) KeymanagerKind() keymanager.Kind {
	return w.keymanagerKind
}

// AccountsDir for the wallet.
func (w *Wallet) AccountsDir() string {
	return w.accountsPath
}

// Password for the wallet.
func (w *Wallet) Password() string {
	return w.walletPassword
}

// InitializeKeymanager reads a keymanager config from disk at the wallet path,
// unmarshals it based on the wallet's keymanager kind, and returns its value.
func (w *Wallet) InitializeKeymanager(ctx context.Context, cfg iface.InitKeymanagerConfig) (keymanager.IKeymanager, error) {
	var km keymanager.IKeymanager
	var err error
	switch w.KeymanagerKind() {
	case keymanager.Local:
		km, err = local.NewKeymanager(ctx, &local.SetupConfig{
			Wallet:           w,
			ListenForChanges: cfg.ListenForChanges,
		})
		if err != nil {
			return nil, errors.Wrap(err, "could not initialize imported keymanager")
		}
	case keymanager.Derived:
		km, err = derived.NewKeymanager(ctx, &derived.SetupConfig{
			Wallet:           w,
			ListenForChanges: cfg.ListenForChanges,
		})
		if err != nil {
			return nil, errors.Wrap(err, "could not initialize derived keymanager")
		}
	case keymanager.Remote:
		configFile, err := w.ReadKeymanagerConfigFromDisk(ctx)
		if err != nil {
			return nil, errors.Wrap(err, "could not read keymanager config")
		}
		opts, err := remote.UnmarshalOptionsFile(configFile)
		if err != nil {
			return nil, errors.Wrap(err, "could not unmarshal keymanager config file")
		}
		km, err = remote.NewKeymanager(ctx, &remote.SetupConfig{
			Opts:           opts,
			MaxMessageSize: 100000000,
		})
		if err != nil {
			return nil, errors.Wrap(err, "could not initialize remote keymanager")
		}
	case keymanager.Web3Signer:
		config := cfg.Web3SignerConfig
		if config == nil {
			return nil, errors.New("web3signer config is nil")
		}
		// TODO(9883): future work needs to address how initialize keymanager is called for web3signer.
		// an error may be thrown for genesis validators root for some InitializeKeymanager calls.
		if !bytesutil.IsValidRoot(config.GenesisValidatorsRoot) {
			return nil, errors.New("web3signer requires a genesis validators root value")
		}
		km, err = remote_web3signer.NewKeymanager(ctx, config)
		if err != nil {
			return nil, errors.Wrap(err, "could not initialize web3signer keymanager")
		}
	default:
		return nil, fmt.Errorf("keymanager kind not supported: %s", w.keymanagerKind)
	}
	return km, nil
}

// WriteFileAtPath within the wallet directory given the desired path, filename, and raw data.
func (w *Wallet) WriteFileAtPath(_ context.Context, filePath, fileName string, data []byte) error {
	accountPath := filepath.Join(w.accountsPath, filePath)
	hasDir, err := file.HasDir(accountPath)
	if err != nil {
		return err
	}
	if !hasDir {
		if err := file.MkdirAll(accountPath); err != nil {
			return errors.Wrapf(err, "could not create path: %s", accountPath)
		}
	}
	fullPath := filepath.Join(accountPath, fileName)
	if err := file.WriteFile(fullPath, data); err != nil {
		return errors.Wrapf(err, "could not write %s", filePath)
	}
	log.WithFields(logrus.Fields{
		"path":     fullPath,
		"fileName": fileName,
	}).Debug("Wrote new file at path")
	return nil
}

// ReadFileAtPath within the wallet directory given the desired path and filename.
func (w *Wallet) ReadFileAtPath(_ context.Context, filePath, fileName string) ([]byte, error) {
	accountPath := filepath.Join(w.accountsPath, filePath)
	hasDir, err := file.HasDir(accountPath)
	if err != nil {
		return nil, err
	}
	if !hasDir {
		if err := file.MkdirAll(accountPath); err != nil {
			return nil, errors.Wrapf(err, "could not create path: %s", accountPath)
		}
	}
	fullPath := filepath.Join(accountPath, fileName)
	matches, err := filepath.Glob(fullPath)
	if err != nil {
		return []byte{}, errors.Wrap(err, "could not find file")
	}
	if len(matches) == 0 {
		return []byte{}, fmt.Errorf("no files found in path: %s", fullPath)
	}
	rawData, err := ioutil.ReadFile(matches[0])
	if err != nil {
		return nil, errors.Wrapf(err, "could not read path: %s", filePath)
	}
	return rawData, nil
}

// FileNameAtPath return the full file name for the requested file. It allows for finding the file
// with a regex pattern.
func (w *Wallet) FileNameAtPath(_ context.Context, filePath, fileName string) (string, error) {
	accountPath := filepath.Join(w.accountsPath, filePath)
	if err := file.MkdirAll(accountPath); err != nil {
		return "", errors.Wrapf(err, "could not create path: %s", accountPath)
	}
	fullPath := filepath.Join(accountPath, fileName)
	matches, err := filepath.Glob(fullPath)
	if err != nil {
		return "", errors.Wrap(err, "could not find file")
	}
	if len(matches) == 0 {
		return "", fmt.Errorf("no files found in path: %s", fullPath)
	}
	fullFileName := filepath.Base(matches[0])
	return fullFileName, nil
}

// ReadKeymanagerConfigFromDisk opens a keymanager config file
// for reading if it exists at the wallet path.
func (w *Wallet) ReadKeymanagerConfigFromDisk(_ context.Context) (io.ReadCloser, error) {
	configFilePath := filepath.Join(w.accountsPath, KeymanagerConfigFileName)
	if !file.FileExists(configFilePath) {
		return nil, fmt.Errorf("no keymanager config file found at path: %s", w.accountsPath)
	}
	w.configFilePath = configFilePath
	return os.Open(configFilePath) // #nosec G304

}

// WriteKeymanagerConfigToDisk takes an encoded keymanager config file
// and writes it to the wallet path.
func (w *Wallet) WriteKeymanagerConfigToDisk(_ context.Context, encoded []byte) error {
	configFilePath := filepath.Join(w.accountsPath, KeymanagerConfigFileName)
	// Write the config file to disk.
	if err := file.WriteFile(configFilePath, encoded); err != nil {
		return errors.Wrapf(err, "could not write config to path: %s", configFilePath)
	}
	log.WithField("configFilePath", configFilePath).Debug("Wrote keymanager config file to disk")
	return nil
}

func readKeymanagerKindFromWalletPath(walletPath string) (keymanager.Kind, error) {
	walletItem, err := os.Open(walletPath) // #nosec G304
	if err != nil {
		return 0, err
	}
	defer func() {
		if err := walletItem.Close(); err != nil {
			log.WithField(
				"path", walletPath,
			).Errorf("Could not close wallet directory: %v", err)
		}
	}()
	list, err := walletItem.Readdirnames(0) // 0 to read all files and folders.
	if err != nil {
		return 0, fmt.Errorf("could not read files in directory: %s", walletPath)
	}
	for _, n := range list {
		keymanagerKind, err := keymanager.ParseKind(n)
		if err == nil {
			return keymanagerKind, nil
		}
	}
	return 0, errors.New("no keymanager folder (imported, remote, derived) found in wallet path")
}

// InputPassword prompts for a password and optionally for password confirmation.
// The password is validated according to custom rules.
func InputPassword(
	cliCtx *cli.Context,
	passwordFileFlag *cli.StringFlag,
	promptText string,
	confirmPassword bool,
	passwordValidator func(input string) error,
) (string, error) {
	if cliCtx.IsSet(passwordFileFlag.Name) {
		passwordFilePathInput := cliCtx.String(passwordFileFlag.Name)
		data, err := file.ReadFileAsBytes(passwordFilePathInput)
		if err != nil {
			return "", errors.Wrap(err, "could not read file as bytes")
		}
		enteredPassword := strings.TrimRight(string(data), "\r\n")
		if err := passwordValidator(enteredPassword); err != nil {
			return "", errors.Wrap(err, "password did not pass validation")
		}
		return enteredPassword, nil
	}
	var hasValidPassword bool
	var walletPassword string
	var err error
	for !hasValidPassword {
		walletPassword, err = prompt.PasswordPrompt(promptText, passwordValidator)
		if err != nil {
			return "", fmt.Errorf("could not read account password: %w", err)
		}

		if confirmPassword {
			passwordConfirmation, err := prompt.PasswordPrompt(ConfirmPasswordPromptText, passwordValidator)
			if err != nil {
				return "", fmt.Errorf("could not read password confirmation: %w", err)
			}
			if walletPassword != passwordConfirmation {
				log.Error("Passwords do not match")
				continue
			}
			hasValidPassword = true
		} else {
			return walletPassword, nil
		}
	}
	return walletPassword, nil
}
