// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1

package consul

import (
	"context"
	"fmt"
	"time"

	consulapi "github.com/hashicorp/consul/api"
	"github.com/hashicorp/go-hclog"
	"github.com/hashicorp/go-multierror"
	"github.com/hashicorp/nomad/helper"
	"github.com/hashicorp/nomad/helper/useragent"
	"github.com/hashicorp/nomad/nomad/structs"
	"github.com/hashicorp/nomad/nomad/structs/config"
)

// SupportedProxiesAPI is the interface the Nomad Client uses to request from
// Consul the set of supported proxied to use for Consul Connect.
//
// No ACL requirements
type SupportedProxiesAPI interface {
	Proxies() (map[string][]string, error)
}

// SupportedProxiesAPIFunc returns an interface that the Nomad client uses for
// requesting the set of supported proxies from Consul.
type SupportedProxiesAPIFunc func(string) SupportedProxiesAPI

// JWTLoginRequest is an object representing a login request with JWT
type JWTLoginRequest struct {
	JWT            string
	AuthMethodName string
	Meta           map[string]string
}

// Client is the interface that the nomad client uses to interact with
// Consul tokens
type Client interface {
	// DeriveTokenWithJWT logs into Consul using JWT and retrieves a Consul ACL
	// token.
	DeriveTokenWithJWT(JWTLoginRequest) (*consulapi.ACLToken, error)

	RevokeTokens([]*consulapi.ACLToken) error

	// TokenPreflightCheck verifies that a token has been replicated before we
	// try to use it for registering services or bootstrapping Envoy
	TokenPreflightCheck(context.Context, *consulapi.ACLToken) error
}

type consulClient struct {
	// client is the API client to interact with consul
	client *consulapi.Client

	// partition is the Consul partition for the local agent
	partition string

	// config is the configuration to connect to consul
	config *config.ConsulConfig

	logger hclog.Logger

	// preflightCheckTimeout/BaseInterval control how long the client will wait
	// for Consul ACLs tokens to be fully replicated before giving up on the
	// allocation; these are configurable via node metadata
	preflightCheckTimeout      time.Duration
	preflightCheckBaseInterval time.Duration
}

// ConsulClientFunc creates a new Consul client for the specific Consul config
type ConsulClientFunc func(config *config.ConsulConfig, logger hclog.Logger) (Client, error)

// NodeGetter breaks a circular dependency between client/config.Config and this
// package
type NodeGetter interface {
	GetNode() *structs.Node
}

// NewConsulClientFactory returns a ConsulClientFunc that closes over the
// partition
func NewConsulClientFactory(nodeGetter NodeGetter) ConsulClientFunc {

	return func(config *config.ConsulConfig, logger hclog.Logger) (Client, error) {
		if config == nil {
			return nil, fmt.Errorf("nil consul config")
		}

		logger = logger.Named("consul").With("name", config.Name)

		node := nodeGetter.GetNode()
		partition := node.Attributes["consul.partition"]
		preflightCheckTimeout := durationFromMeta(
			node, "consul.token_preflight_check.timeout", time.Second*10)
		preflightCheckBaseInterval := durationFromMeta(
			node, "consul.token_preflight_check.base", time.Millisecond*500)

		c := &consulClient{
			config:                     config,
			logger:                     logger,
			partition:                  partition,
			preflightCheckTimeout:      preflightCheckTimeout,
			preflightCheckBaseInterval: preflightCheckBaseInterval,
		}

		// Get the Consul API configuration
		apiConf, err := config.ApiConfig()
		if err != nil {
			logger.Error("error creating default Consul API config", "error", err)
			return nil, err
		}

		// Create the API client
		client, err := consulapi.NewClient(apiConf)
		if err != nil {
			logger.Error("error creating Consul client", "error", err)
			return nil, err
		}

		useragent.SetHeaders(client)
		c.client = client

		return c, nil

	}
}

func durationFromMeta(node *structs.Node, key string, defaultDur time.Duration) time.Duration {
	val := node.Meta[key]
	if key == "" {
		return defaultDur
	}
	d, err := time.ParseDuration(val)
	if err != nil || d == 0 {
		return defaultDur
	}
	return d
}

// DeriveTokenWithJWT takes a JWT from request and returns a consul token.
func (c *consulClient) DeriveTokenWithJWT(req JWTLoginRequest) (*consulapi.ACLToken, error) {
	t, _, err := c.client.ACL().Login(&consulapi.ACLLoginParams{
		AuthMethod:  req.AuthMethodName,
		BearerToken: req.JWT,
		Meta:        req.Meta,
	}, &consulapi.WriteOptions{
		Partition: c.partition,
	})

	return t, err
}

func (c *consulClient) RevokeTokens(tokens []*consulapi.ACLToken) error {
	var mErr *multierror.Error
	for _, token := range tokens {
		_, err := c.client.ACL().Logout(&consulapi.WriteOptions{
			Namespace: token.Namespace,
			Partition: token.Partition,
			Token:     token.SecretID,
		})
		mErr = multierror.Append(mErr, err)
	}

	return mErr.ErrorOrNil()
}

// TokenPreflightCheck verifies that a token has been replicated before we
// try to use it for registering services or bootstrapping Envoy
func (c *consulClient) TokenPreflightCheck(pctx context.Context, t *consulapi.ACLToken) error {
	timer, timerStop := helper.NewStoppedTimer()
	defer timerStop()

	var retry uint64
	var err error
	ctx, cancel := context.WithTimeout(pctx, c.preflightCheckTimeout)
	defer cancel()

	for {
		_, _, err = c.client.ACL().TokenReadSelf(&consulapi.QueryOptions{
			Namespace:  t.Namespace,
			Partition:  c.partition,
			AllowStale: true,
			Token:      t.SecretID,
		})
		if err == nil {
			return nil
		}

		retry++
		backoff := helper.Backoff(
			c.preflightCheckBaseInterval, c.preflightCheckBaseInterval*2, retry)
		c.logger.Trace("Consul token not ready", "error", err, "backoff", backoff)
		timer.Reset(backoff)
		select {
		case <-ctx.Done():
			return err
		case <-timer.C:
			continue
		}
	}
}
