package gcp

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io/ioutil"
	"net/http"
	"time"

	cleanhttp "github.com/hashicorp/go-cleanhttp"
	"github.com/hashicorp/go-gcp-common/gcputil"
	hclog "github.com/hashicorp/go-hclog"
	"github.com/hashicorp/go-secure-stdlib/parseutil"
	"github.com/hashicorp/vault/api"
	"github.com/hashicorp/vault/command/agent/auth"
	"golang.org/x/oauth2"
	"google.golang.org/api/iamcredentials/v1"
)

const (
	typeGCE                    = "gce"
	typeIAM                    = "iam"
	identityEndpoint           = "http://metadata/computeMetadata/v1/instance/service-accounts/%s/identity"
	defaultIamMaxJwtExpMinutes = 15
)

type gcpMethod struct {
	logger         hclog.Logger
	authType       string
	mountPath      string
	role           string
	credentials    string
	serviceAccount string
	project        string
	jwtExp         int64
}

func NewGCPAuthMethod(conf *auth.AuthConfig) (auth.AuthMethod, error) {
	if conf == nil {
		return nil, errors.New("empty config")
	}
	if conf.Config == nil {
		return nil, errors.New("empty config data")
	}

	var err error

	g := &gcpMethod{
		logger:         conf.Logger,
		mountPath:      conf.MountPath,
		serviceAccount: "default",
	}

	typeRaw, ok := conf.Config["type"]
	if !ok {
		return nil, errors.New("missing 'type' value")
	}
	g.authType, ok = typeRaw.(string)
	if !ok {
		return nil, errors.New("could not convert 'type' config value to string")
	}

	roleRaw, ok := conf.Config["role"]
	if !ok {
		return nil, errors.New("missing 'role' value")
	}
	g.role, ok = roleRaw.(string)
	if !ok {
		return nil, errors.New("could not convert 'role' config value to string")
	}

	switch {
	case g.role == "":
		return nil, errors.New("'role' value is empty")
	case g.authType == "":
		return nil, errors.New("'type' value is empty")
	case g.authType != typeGCE && g.authType != typeIAM:
		return nil, errors.New("'type' value is invalid")
	}

	credentialsRaw, ok := conf.Config["credentials"]
	if ok {
		g.credentials, ok = credentialsRaw.(string)
		if !ok {
			return nil, errors.New("could not convert 'credentials' value into string")
		}
	}

	serviceAccountRaw, ok := conf.Config["service_account"]
	if ok {
		g.serviceAccount, ok = serviceAccountRaw.(string)
		if !ok {
			return nil, errors.New("could not convert 'service_account' value into string")
		}
	}

	projectRaw, ok := conf.Config["project"]
	if ok {
		g.project, ok = projectRaw.(string)
		if !ok {
			return nil, errors.New("could not convert 'project' value into string")
		}
	}

	jwtExpRaw, ok := conf.Config["jwt_exp"]
	if ok {
		g.jwtExp, err = parseutil.ParseInt(jwtExpRaw)
		if err != nil {
			return nil, fmt.Errorf("error parsing 'jwt_raw' into integer: %w", err)
		}
	}

	return g, nil
}

func (g *gcpMethod) Authenticate(ctx context.Context, client *api.Client) (retPath string, header http.Header, retData map[string]interface{}, retErr error) {
	g.logger.Trace("beginning authentication")

	data := make(map[string]interface{})
	var jwt string

	switch g.authType {
	case typeGCE:
		httpClient := cleanhttp.DefaultClient()

		// Fetch token
		{
			req, err := http.NewRequest("GET", fmt.Sprintf(identityEndpoint, g.serviceAccount), nil)
			if err != nil {
				retErr = fmt.Errorf("error creating request: %w", err)
				return
			}
			req = req.WithContext(ctx)
			req.Header.Add("Metadata-Flavor", "Google")
			q := req.URL.Query()
			q.Add("audience", fmt.Sprintf("%s/vault/%s", client.Address(), g.role))
			q.Add("format", "full")
			req.URL.RawQuery = q.Encode()
			resp, err := httpClient.Do(req)
			if err != nil {
				retErr = fmt.Errorf("error fetching instance token: %w", err)
				return
			}
			if resp == nil {
				retErr = errors.New("empty response fetching instance toke")
				return
			}
			defer resp.Body.Close()
			jwtBytes, err := ioutil.ReadAll(resp.Body)
			if err != nil {
				retErr = fmt.Errorf("error reading instance token response body: %w", err)
				return
			}

			jwt = string(jwtBytes)
		}

	default:
		ctx := context.WithValue(context.Background(), oauth2.HTTPClient, cleanhttp.DefaultClient())

		credentials, tokenSource, err := gcputil.FindCredentials(g.credentials, ctx, iamcredentials.CloudPlatformScope)
		if err != nil {
			retErr = fmt.Errorf("could not obtain credentials: %w", err)
			return
		}

		httpClient := oauth2.NewClient(ctx, tokenSource)

		var serviceAccount string
		if g.serviceAccount == "" && credentials != nil {
			serviceAccount = credentials.ClientEmail
		} else {
			serviceAccount = g.serviceAccount
		}
		if serviceAccount == "" {
			retErr = errors.New("could not obtain service account from credentials (possibly Application Default Credentials are being used); a service account to authenticate as must be provided")
			return
		}

		ttlMin := int64(defaultIamMaxJwtExpMinutes)
		if g.jwtExp != 0 {
			ttlMin = g.jwtExp
		}
		ttl := time.Minute * time.Duration(ttlMin)

		jwtPayload := map[string]interface{}{
			"aud": fmt.Sprintf("http://vault/%s", g.role),
			"sub": serviceAccount,
			"exp": time.Now().Add(ttl).Unix(),
		}
		payloadBytes, err := json.Marshal(jwtPayload)
		if err != nil {
			retErr = fmt.Errorf("could not convert JWT payload to JSON string: %w", err)
			return
		}

		jwtReq := &iamcredentials.SignJwtRequest{
			Payload: string(payloadBytes),
		}

		iamClient, err := iamcredentials.New(httpClient)
		if err != nil {
			retErr = fmt.Errorf("could not create IAM client: %w", err)
			return
		}

		resourceName := fmt.Sprintf("projects/-/serviceAccounts/%s", serviceAccount)
		resp, err := iamClient.Projects.ServiceAccounts.SignJwt(resourceName, jwtReq).Do()
		if err != nil {
			retErr = fmt.Errorf("unable to sign JWT for %s using given Vault credentials: %w", resourceName, err)
			return
		}

		jwt = resp.SignedJwt
	}

	data["role"] = g.role
	data["jwt"] = jwt

	return fmt.Sprintf("%s/login", g.mountPath), nil, data, nil
}

func (g *gcpMethod) NewCreds() chan struct{} {
	return nil
}

func (g *gcpMethod) CredSuccess() {
}

func (g *gcpMethod) Shutdown() {
}
