package v1

import (
	"context"
	"crypto/tls"
	"crypto/x509"
	"encoding/json"
	"fmt"
	"io/ioutil"
	"regexp"
	"strings"

	"github.com/pkg/errors"
	ethpb "github.com/prysmaticlabs/ethereumapis/eth/v1alpha1"
	"github.com/prysmaticlabs/prysm/shared/bls"
	"github.com/prysmaticlabs/prysm/shared/bytesutil"
	pb "github.com/wealdtech/eth2-signer-api/pb/v1"
	"google.golang.org/grpc"
	"google.golang.org/grpc/credentials"
)

const (
	// maxMessageSize is the largest message that can be received over GRPC.  Set to 8MB, which handles ~128K keys.
	maxMessageSize = 8 * 1024 * 1024
)

// Remote is a key manager that accesses a remote wallet daemon.
type Remote struct {
	paths               []string
	conn                *grpc.ClientConn
	accounts            map[[48]byte]*accountInfo
	signClientInitiator func(*grpc.ClientConn)
}

type accountInfo struct {
	Name   string `json:"name"`
	PubKey []byte `json:"pubkey"`
}

type remoteOpts struct {
	Location     string                 `json:"location"`
	Accounts     []string               `json:"accounts"`
	Certificates *remoteCertificateOpts `json:"certificates"`
}

type remoteCertificateOpts struct {
	CACert     string `json:"ca_cert"`
	ClientCert string `json:"client_cert"`
	ClientKey  string `json:"client_key"`
}

var remoteOptsHelp = `The remote key manager connects to a walletd instance.  The options are:
  - location This is the location to look for wallets.  If not supplied it will
    use the standard (operating system-dependent) path.
  - accounts This is a list of account specifiers.  An account specifier is of
    the form <wallet name>/[account name],  where the account name can be a
    regular expression.  If the account specifier is just <wallet name> all
    accounts in that wallet will be used.  Multiple account specifiers can be
    supplied if required.
  - certificates This provides paths to certificates:
    - ca_cert This is the path to the server's certificate authority certificate file
    - client_cert This is the path to the client's certificate file
    - client_key This is the path to the client's key file

An sample keymanager options file (with annotations; these should be removed if
using this as a template) is:

  {
	"location":    "host.example.com:12345", // Connect to walletd at host.example.com on port 12345
    "accounts":    ["Validators/Account.*"]  // Use all accounts in the 'Validators' wallet starting with 'Account'
	"certificates": {
	  "ca_cert": "/home/eth2/certs/ca.crt"         // Certificate file for the CA that signed the server's certificate
	  "client_cert": "/home/eth2/certs/client.crt" // Certificate file for this client
	  "client_key": "/home/eth2/certs/client.key"  // Key file for this client
	}
  }`

// NewRemoteWallet creates a key manager populated with the keys from walletd.
func NewRemoteWallet(input string) (KeyManager, string, error) {
	opts := &remoteOpts{}
	err := json.Unmarshal([]byte(input), opts)
	if err != nil {
		return nil, remoteOptsHelp, err
	}

	if len(opts.Accounts) == 0 {
		return nil, remoteOptsHelp, errors.New("at least one account specifier is required")
	}

	// Load the client certificates.
	if opts.Certificates == nil {
		return nil, remoteOptsHelp, errors.New("certificates are required")
	}
	if opts.Certificates.ClientCert == "" {
		return nil, remoteOptsHelp, errors.New("client certificate is required")
	}
	if opts.Certificates.ClientKey == "" {
		return nil, remoteOptsHelp, errors.New("client key is required")
	}
	clientPair, err := tls.LoadX509KeyPair(opts.Certificates.ClientCert, opts.Certificates.ClientKey)
	if err != nil {
		return nil, remoteOptsHelp, errors.Wrap(err, "failed to obtain client's certificate and/or key")
	}

	// Load the CA for the server certificate if present.
	cp := x509.NewCertPool()
	if opts.Certificates.CACert != "" {
		serverCA, err := ioutil.ReadFile(opts.Certificates.CACert)
		if err != nil {
			return nil, remoteOptsHelp, errors.Wrap(err, "failed to obtain server's CA certificate")
		}
		if !cp.AppendCertsFromPEM(serverCA) {
			return nil, remoteOptsHelp, errors.Wrap(err, "failed to add server's CA certificate to pool")
		}
	}

	tlsCfg := &tls.Config{
		Certificates: []tls.Certificate{clientPair},
		RootCAs:      cp,
	}
	clientCreds := credentials.NewTLS(tlsCfg)

	grpcOpts := []grpc.DialOption{
		// Require TLS with client certificate.
		grpc.WithTransportCredentials(clientCreds),
		// Receive large messages without erroring.
		grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(maxMessageSize)),
	}

	conn, err := grpc.Dial(opts.Location, grpcOpts...)
	if err != nil {
		return nil, remoteOptsHelp, errors.New("failed to connect to remote wallet")
	}

	km := &Remote{
		conn:  conn,
		paths: opts.Accounts,
	}

	err = km.RefreshValidatingKeys()
	if err != nil {
		return nil, remoteOptsHelp, errors.Wrap(err, "failed to fetch accounts from remote wallet")
	}

	return km, remoteOptsHelp, nil
}

// FetchValidatingKeys fetches the list of public keys that should be used to validate with.
func (km *Remote) FetchValidatingKeys() ([][48]byte, error) {
	res := make([][48]byte, 0, len(km.accounts))
	for _, accountInfo := range km.accounts {
		res = append(res, bytesutil.ToBytes48(accountInfo.PubKey))
	}
	return res, nil
}

// Sign without protection is not supported by remote keymanagers.
func (km *Remote) Sign(pubKey [48]byte, root [32]byte) (bls.Signature, error) {
	return nil, errors.New("remote keymanager does not support unprotected signing")
}

// SignGeneric signs a generic message for the validator to broadcast.
func (km *Remote) SignGeneric(pubKey [48]byte, root [32]byte, domain [32]byte) (bls.Signature, error) {
	accountInfo, exists := km.accounts[pubKey]
	if !exists {
		return nil, ErrNoSuchKey
	}

	client := pb.NewSignerClient(km.conn)
	req := &pb.SignRequest{
		Id:     &pb.SignRequest_Account{Account: accountInfo.Name},
		Data:   root[:],
		Domain: domain[:],
	}
	resp, err := client.Sign(context.Background(), req)
	if err != nil {
		return nil, err
	}
	switch resp.State {
	case pb.ResponseState_DENIED:
		return nil, ErrDenied
	case pb.ResponseState_FAILED:
		return nil, ErrCannotSign
	}
	return bls.SignatureFromBytes(resp.Signature)
}

// SignProposal signs a block proposal for the validator to broadcast.
func (km *Remote) SignProposal(pubKey [48]byte, domain [32]byte, data *ethpb.BeaconBlockHeader) (bls.Signature, error) {
	accountInfo, exists := km.accounts[pubKey]
	if !exists {
		return nil, ErrNoSuchKey
	}

	client := pb.NewSignerClient(km.conn)
	req := &pb.SignBeaconProposalRequest{
		Id:     &pb.SignBeaconProposalRequest_Account{Account: accountInfo.Name},
		Domain: domain[:],
		Data: &pb.BeaconBlockHeader{
			Slot:          data.Slot,
			ProposerIndex: data.ProposerIndex,
			ParentRoot:    data.ParentRoot,
			StateRoot:     data.StateRoot,
			BodyRoot:      data.BodyRoot,
		},
	}
	resp, err := client.SignBeaconProposal(context.Background(), req)
	if err != nil {
		return nil, err
	}
	switch resp.State {
	case pb.ResponseState_DENIED:
		return nil, ErrDenied
	case pb.ResponseState_FAILED:
		return nil, ErrCannotSign
	}
	return bls.SignatureFromBytes(resp.Signature)
}

// SignAttestation signs an attestation for the validator to broadcast.
func (km *Remote) SignAttestation(pubKey [48]byte, domain [32]byte, data *ethpb.AttestationData) (bls.Signature, error) {
	accountInfo, exists := km.accounts[pubKey]
	if !exists {
		return nil, ErrNoSuchKey
	}

	client := pb.NewSignerClient(km.conn)
	req := &pb.SignBeaconAttestationRequest{
		Id:     &pb.SignBeaconAttestationRequest_Account{Account: accountInfo.Name},
		Domain: domain[:],
		Data: &pb.AttestationData{
			Slot:            data.Slot,
			CommitteeIndex:  data.CommitteeIndex,
			BeaconBlockRoot: data.BeaconBlockRoot,
			Source: &pb.Checkpoint{
				Epoch: data.Source.Epoch,
				Root:  data.Source.Root,
			},
			Target: &pb.Checkpoint{
				Epoch: data.Target.Epoch,
				Root:  data.Target.Root,
			},
		},
	}
	resp, err := client.SignBeaconAttestation(context.Background(), req)
	if err != nil {
		return nil, err
	}
	switch resp.State {
	case pb.ResponseState_DENIED:
		return nil, ErrDenied
	case pb.ResponseState_FAILED:
		return nil, ErrCannotSign
	}
	return bls.SignatureFromBytes(resp.Signature)
}

// RefreshValidatingKeys refreshes the list of validating keys from the remote signer.
func (km *Remote) RefreshValidatingKeys() error {
	listerClient := pb.NewListerClient(km.conn)
	listAccountsReq := &pb.ListAccountsRequest{
		Paths: km.paths,
	}
	resp, err := listerClient.ListAccounts(context.Background(), listAccountsReq)
	if err != nil {
		return err
	}
	if resp.State == pb.ResponseState_DENIED {
		return errors.New("attempt to fetch keys denied")
	}
	if resp.State == pb.ResponseState_FAILED {
		return errors.New("attempt to fetch keys failed")
	}

	verificationRegexes := pathsToVerificationRegexes(km.paths)
	accounts := make(map[[48]byte]*accountInfo, len(resp.Accounts))
	for _, account := range resp.Accounts {
		verified := false
		for _, verificationRegex := range verificationRegexes {
			if verificationRegex.Match([]byte(account.Name)) {
				verified = true
				break
			}
		}
		if !verified {
			log.WithField("path", account.Name).Warn("Received unwanted account from server; ignoring")
			continue
		}
		account := &accountInfo{
			Name:   account.Name,
			PubKey: account.PublicKey,
		}
		accounts[bytesutil.ToBytes48(account.PubKey)] = account
	}
	km.accounts = accounts
	return nil
}

// pathsToVerificationRegexes turns path specifiers in to regexes to ensure accounts we are given are good.
func pathsToVerificationRegexes(paths []string) []*regexp.Regexp {
	regexes := make([]*regexp.Regexp, 0, len(paths))
	for _, path := range paths {
		log := log.WithField("path", path)
		parts := strings.Split(path, "/")
		if len(parts) == 0 || len(parts[0]) == 0 {
			log.Debug("Invalid path")
			continue
		}
		if len(parts) == 1 {
			parts = append(parts, ".*")
		}
		if strings.HasPrefix(parts[1], "^") {
			parts[1] = parts[1][1:]
		}
		var specifier string
		if strings.HasSuffix(parts[1], "$") {
			specifier = fmt.Sprintf("^%s/%s", parts[0], parts[1])
		} else {
			specifier = fmt.Sprintf("^%s/%s$", parts[0], parts[1])
		}
		regex, err := regexp.Compile(specifier)
		if err != nil {
			log.WithField("specifier", specifier).WithError(err).Warn("Invalid path regex")
			continue
		}
		regexes = append(regexes, regex)
	}
	return regexes
}
