package provisioner

import (
	"bytes"
	"context"
	"crypto/sha256"
	"crypto/x509"
	"encoding/hex"
	"fmt"
	"io/ioutil"
	"net/http"
	"net/url"
	"strings"
	"time"

	"github.com/pkg/errors"
	"github.com/smallstep/cli/jose"
)

// gcpCertsURL is the url that serves Google OAuth2 public keys.
const gcpCertsURL = "https://www.googleapis.com/oauth2/v3/certs"

// gcpIdentityURL is the base url for the identity document in GCP.
const gcpIdentityURL = "http://metadata/computeMetadata/v1/instance/service-accounts/default/identity"

// gcpPayload extends jwt.Claims with custom GCP attributes.
type gcpPayload struct {
	jose.Claims
	AuthorizedParty string           `json:"azp"`
	Email           string           `json:"email"`
	EmailVerified   bool             `json:"email_verified"`
	Google          gcpGooglePayload `json:"google"`
}

type gcpGooglePayload struct {
	ComputeEngine gcpComputeEnginePayload `json:"compute_engine"`
}

type gcpComputeEnginePayload struct {
	InstanceID                string            `json:"instance_id"`
	InstanceName              string            `json:"instance_name"`
	InstanceCreationTimestamp *jose.NumericDate `json:"instance_creation_timestamp"`
	ProjectID                 string            `json:"project_id"`
	ProjectNumber             int64             `json:"project_number"`
	Zone                      string            `json:"zone"`
	LicenseID                 []string          `json:"license_id"`
}

type gcpConfig struct {
	CertsURL    string
	IdentityURL string
}

func newGCPConfig() *gcpConfig {
	return &gcpConfig{
		CertsURL:    gcpCertsURL,
		IdentityURL: gcpIdentityURL,
	}
}

// GCP is the provisioner that supports identity tokens created by the Google
// Cloud Platform metadata API.
//
// If DisableCustomSANs is true, only the internal DNS and IP will be added as a
// SAN. By default it will accept any SAN in the CSR.
//
// If DisableTrustOnFirstUse is true, multiple sign request for this provisioner
// with the same instance will be accepted. By default only the first request
// will be accepted.
//
// If InstanceAge is set, only the instances with an instance_creation_timestamp
// within the given period will be accepted.
//
// Google Identity docs are available at
// https://cloud.google.com/compute/docs/instances/verifying-instance-identity
type GCP struct {
	Type                   string   `json:"type"`
	Name                   string   `json:"name"`
	ServiceAccounts        []string `json:"serviceAccounts"`
	ProjectIDs             []string `json:"projectIDs"`
	DisableCustomSANs      bool     `json:"disableCustomSANs"`
	DisableTrustOnFirstUse bool     `json:"disableTrustOnFirstUse"`
	InstanceAge            Duration `json:"instanceAge,omitempty"`
	Claims                 *Claims  `json:"claims,omitempty"`
	claimer                *Claimer
	config                 *gcpConfig
	keyStore               *keyStore
	audiences              Audiences
}

// GetID returns the provisioner unique identifier. The name should uniquely
// identify any GCP provisioner.
func (p *GCP) GetID() string {
	return "gcp/" + p.Name
}

// GetTokenID returns the identifier of the token. The default value for GCP the
// SHA256 of "provisioner_id.instance_id", but if DisableTrustOnFirstUse is set
// to true, then it will be the SHA256 of the token.
func (p *GCP) GetTokenID(token string) (string, error) {
	jwt, err := jose.ParseSigned(token)
	if err != nil {
		return "", errors.Wrap(err, "error parsing token")
	}

	// If TOFU is disabled create an ID for the token, so it cannot be reused.
	if p.DisableTrustOnFirstUse {
		sum := sha256.Sum256([]byte(token))
		return strings.ToLower(hex.EncodeToString(sum[:])), nil
	}

	// Get claims w/out verification.
	var claims gcpPayload
	if err = jwt.UnsafeClaimsWithoutVerification(&claims); err != nil {
		return "", errors.Wrap(err, "error verifying claims")
	}

	// Create unique ID for Trust On First Use (TOFU). Only the first instance
	// per provisioner is allowed as we don't have a way to trust the given
	// sans.
	unique := fmt.Sprintf("%s.%s", p.GetID(), claims.Google.ComputeEngine.InstanceID)
	sum := sha256.Sum256([]byte(unique))
	return strings.ToLower(hex.EncodeToString(sum[:])), nil
}

// GetName returns the name of the provisioner.
func (p *GCP) GetName() string {
	return p.Name
}

// GetType returns the type of provisioner.
func (p *GCP) GetType() Type {
	return TypeGCP
}

// GetEncryptedKey is not available in a GCP provisioner.
func (p *GCP) GetEncryptedKey() (kid string, key string, ok bool) {
	return "", "", false
}

// GetIdentityURL returns the url that generates the GCP token.
func (p *GCP) GetIdentityURL(audience string) string {
	// Initialize config if required
	p.assertConfig()

	q := url.Values{}
	q.Add("audience", audience)
	q.Add("format", "full")
	q.Add("licenses", "FALSE")
	return fmt.Sprintf("%s?%s", p.config.IdentityURL, q.Encode())
}

// GetIdentityToken does an HTTP request to the identity url.
func (p *GCP) GetIdentityToken(subject, caURL string) (string, error) {
	audience, err := generateSignAudience(caURL, p.GetID())
	if err != nil {
		return "", err
	}

	req, err := http.NewRequest("GET", p.GetIdentityURL(audience), http.NoBody)
	if err != nil {
		return "", errors.Wrap(err, "error creating identity request")
	}
	req.Header.Set("Metadata-Flavor", "Google")
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return "", errors.Wrap(err, "error doing identity request, are you in a GCP VM?")
	}
	defer resp.Body.Close()
	b, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		return "", errors.Wrap(err, "error on identity request")
	}
	if resp.StatusCode >= 400 {
		return "", errors.Errorf("error on identity request: status=%d, response=%s", resp.StatusCode, b)
	}
	return string(bytes.TrimSpace(b)), nil
}

// Init validates and initializes the GCP provisioner.
func (p *GCP) Init(config Config) error {
	var err error
	switch {
	case p.Type == "":
		return errors.New("provisioner type cannot be empty")
	case p.Name == "":
		return errors.New("provisioner name cannot be empty")
	case p.InstanceAge.Value() < 0:
		return errors.New("provisioner instanceAge cannot be negative")
	}
	// Initialize config
	p.assertConfig()
	// Update claims with global ones
	if p.claimer, err = NewClaimer(p.Claims, config.Claims); err != nil {
		return err
	}
	// Initialize key store
	p.keyStore, err = newKeyStore(p.config.CertsURL)
	if err != nil {
		return err
	}

	p.audiences = config.Audiences.WithFragment(p.GetID())
	return nil
}

// AuthorizeSign validates the given token and returns the sign options that
// will be used on certificate creation.
func (p *GCP) AuthorizeSign(ctx context.Context, token string) ([]SignOption, error) {
	claims, err := p.authorizeToken(token)
	if err != nil {
		return nil, err
	}

	// Check for the sign ssh method, default to sign X.509
	if m := MethodFromContext(ctx); m == SignSSHMethod {
		if p.claimer.IsSSHCAEnabled() == false {
			return nil, errors.Errorf("ssh ca is disabled for provisioner %s", p.GetID())
		}
		return p.authorizeSSHSign(claims)
	}

	ce := claims.Google.ComputeEngine
	// Enforce known common name and default DNS if configured.
	// By default we we'll accept the CN and SANs in the CSR.
	// There's no way to trust them other than TOFU.
	var so []SignOption
	if p.DisableCustomSANs {
		dnsName1 := fmt.Sprintf("%s.c.%s.internal", ce.InstanceName, ce.ProjectID)
		dnsName2 := fmt.Sprintf("%s.%s.c.%s.internal", ce.InstanceName, ce.Zone, ce.ProjectID)
		so = append(so, commonNameSliceValidator([]string{
			ce.InstanceName, ce.InstanceID, dnsName1, dnsName2,
		}))
		so = append(so, dnsNamesValidator([]string{
			dnsName1, dnsName2,
		}))
	}

	return append(so,
		defaultPublicKeyValidator{},
		profileDefaultDuration(p.claimer.DefaultTLSCertDuration()),
		newProvisionerExtensionOption(TypeGCP, p.Name, claims.Subject, "InstanceID", ce.InstanceID, "InstanceName", ce.InstanceName),
		newValidityValidator(p.claimer.MinTLSCertDuration(), p.claimer.MaxTLSCertDuration()),
	), nil
}

// AuthorizeRenewal returns an error if the renewal is disabled.
func (p *GCP) AuthorizeRenewal(cert *x509.Certificate) error {
	if p.claimer.IsDisableRenewal() {
		return errors.Errorf("renew is disabled for provisioner %s", p.GetID())
	}
	return nil
}

// AuthorizeRevoke returns an error because revoke is not supported on GCP
// provisioners.
func (p *GCP) AuthorizeRevoke(token string) error {
	return errors.New("revoke is not supported on a GCP provisioner")
}

// assertConfig initializes the config if it has not been initialized.
func (p *GCP) assertConfig() {
	if p.config == nil {
		p.config = newGCPConfig()
	}
}

// authorizeToken performs common jwt authorization actions and returns the
// claims for case specific downstream parsing.
// e.g. a Sign request will auth/validate different fields than a Revoke request.
func (p *GCP) authorizeToken(token string) (*gcpPayload, error) {
	jwt, err := jose.ParseSigned(token)
	if err != nil {
		return nil, errors.Wrapf(err, "error parsing token")
	}
	if len(jwt.Headers) == 0 {
		return nil, errors.New("error parsing token: header is missing")
	}

	var found bool
	var claims gcpPayload
	kid := jwt.Headers[0].KeyID
	keys := p.keyStore.Get(kid)
	for _, key := range keys {
		if err := jwt.Claims(key.Public(), &claims); err == nil {
			found = true
			break
		}
	}
	if !found {
		return nil, errors.Errorf("failed to validate payload: cannot find key for kid %s", kid)
	}

	// According to "rfc7519 JSON Web Token" acceptable skew should be no
	// more than a few minutes.
	now := time.Now().UTC()
	if err = claims.ValidateWithLeeway(jose.Expected{
		Issuer: "https://accounts.google.com",
		Time:   now,
	}, time.Minute); err != nil {
		return nil, errors.Wrapf(err, "invalid token")
	}

	// validate audiences with the defaults
	if !matchesAudience(claims.Audience, p.audiences.Sign) {
		return nil, errors.New("invalid token: invalid audience claim (aud)")
	}

	// validate subject (service account)
	if len(p.ServiceAccounts) > 0 {
		var found bool
		for _, sa := range p.ServiceAccounts {
			if sa == claims.Subject || sa == claims.Email {
				found = true
				break
			}
		}
		if !found {
			return nil, errors.New("invalid token: invalid subject claim")
		}
	}

	// validate projects
	if len(p.ProjectIDs) > 0 {
		var found bool
		for _, pi := range p.ProjectIDs {
			if pi == claims.Google.ComputeEngine.ProjectID {
				found = true
				break
			}
		}
		if !found {
			return nil, errors.New("invalid token: invalid project id")
		}
	}

	// validate instance age
	if d := p.InstanceAge.Value(); d > 0 {
		if now.Sub(claims.Google.ComputeEngine.InstanceCreationTimestamp.Time()) > d {
			return nil, errors.New("token google.compute_engine.instance_creation_timestamp is too old")
		}
	}

	switch {
	case claims.Google.ComputeEngine.InstanceID == "":
		return nil, errors.New("token google.compute_engine.instance_id cannot be empty")
	case claims.Google.ComputeEngine.InstanceName == "":
		return nil, errors.New("token google.compute_engine.instance_name cannot be empty")
	case claims.Google.ComputeEngine.ProjectID == "":
		return nil, errors.New("token google.compute_engine.project_id cannot be empty")
	case claims.Google.ComputeEngine.Zone == "":
		return nil, errors.New("token google.compute_engine.zone cannot be empty")
	}

	return &claims, nil
}

// authorizeSSHSign returns the list of SignOption for a SignSSH request.
func (p *GCP) authorizeSSHSign(claims *gcpPayload) ([]SignOption, error) {
	ce := claims.Google.ComputeEngine

	signOptions := []SignOption{
		// set the key id to the token subject
		sshCertificateKeyIDModifier(ce.InstanceName),
	}

	// Default to host + known hostnames
	defaults := SSHOptions{
		CertType: SSHHostCert,
		Principals: []string{
			fmt.Sprintf("%s.c.%s.internal", ce.InstanceName, ce.ProjectID),
			fmt.Sprintf("%s.%s.c.%s.internal", ce.InstanceName, ce.Zone, ce.ProjectID),
		},
	}
	// Validate user options
	signOptions = append(signOptions, sshCertificateOptionsValidator(defaults))
	// Set defaults if not given as user options
	signOptions = append(signOptions, sshCertificateDefaultsModifier(defaults))

	return append(signOptions,
		// set the default extensions
		&sshDefaultExtensionModifier{},
		// checks the validity bounds, and set the validity if has not been set
		&sshCertificateValidityModifier{p.claimer},
		// validate public key
		&sshDefaultPublicKeyValidator{},
		// require all the fields in the SSH certificate
		&sshCertificateDefaultValidator{},
	), nil
}
