package credentials

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net"
	"net/http"
	"net/http/httputil"
	"os"
	"sync"
	"time"

	"github.com/hashicorp/go-retryablehttp"
	"go.uber.org/zap"
)

const (
	metadataIamTokenValidityThreshold = time.Second * 5
	metadataIamTokenRefreshTimeout    = time.Second * 10

	// metadataTokenCappedExpirationSeconds is a constant for a case you can find below. This implementation overrides
	// the expiration duration to 60 seconds max to handle cases when the token gets invalid: E.g. instance sa is
	// stripped of all permissions (or even worse, deleted), instance is updated to use another sa with valid
	// permissions; however, since the token is not expired it is still used for subsequent requests resulting in
	// Permission Denied. Shorter expiration time is a workaround
	metadataTokenCappedExpirationDuration = time.Second * 60
	metadataTokenRefreshInterval          = metadataTokenCappedExpirationDuration - metadataIamTokenRefreshTimeout
)

var _ NonExchangeableCredentials = (*metadataServiceCredentialProvider)(nil)
var _ MetadataServiceCredentialProvider = (*metadataServiceCredentialProvider)(nil)

type MetadataServiceCredentialProvider interface {
	NonExchangeableCredentials

	Addr() string
	Available(ctx context.Context) bool
}

type metadataServiceCredentialProvider struct {
	metadataServiceAddr string
	client              retryablehttp.Client

	currentTokenMutex          sync.RWMutex
	currentToken               string
	currentTokenRealExpiration time.Time

	refreshMutex    sync.Mutex
	lastRefreshTime time.Time

	refreshStop   chan struct{}
	refreshTicker *time.Ticker
	logger        *zap.Logger
}

var providerInstances = make(map[string]MetadataServiceCredentialProvider)
var providerInstancesLock sync.Mutex

// MetadataService returns credentials provider that queries local metadata service for IAM tokens
// This is currently available on Yandex Cloud Compute Instances instances with a Service Account attached
// https://yandex.cloud/ru/docs/compute/concepts/vm-metadata
func MetadataService() MetadataServiceCredentialProvider {
	return NewMetadataServiceCredentialProvider(GetMetadataServiceAddr())
}

func NewMetadataServiceCredentialProvider(metadataServiceAddr string) MetadataServiceCredentialProvider {
	providerInstancesLock.Lock()
	defer providerInstancesLock.Unlock()

	if provider, ok := providerInstances[metadataServiceAddr]; ok {
		return provider
	}

	provider := metadataServiceCredentialProvider{
		metadataServiceAddr: metadataServiceAddr,
		client: retryablehttp.Client{
			HTTPClient: &http.Client{
				Transport: &http.Transport{
					DialContext: (&net.Dialer{
						Timeout:   time.Second, // One second should be enough for localhost connection.
						KeepAlive: -1,          // No keep alive. Near token per hour requested.
					}).DialContext,
					ResponseHeaderTimeout: 5 * time.Second, // Prevent hanging after successful request write.
				},
			},
			RetryMax:   5,
			CheckRetry: retryablehttp.DefaultRetryPolicy,
			Backoff:    retryablehttp.DefaultBackoff,
		},
		refreshStop:   make(chan struct{}),
		refreshTicker: time.NewTicker(metadataTokenRefreshInterval),
		logger:        zap.NewNop(),
	}
	go provider.tokenRefreshLoop()

	providerInstances[metadataServiceAddr] = &provider

	return &provider
}

func (c *metadataServiceCredentialProvider) InjectLogger(logger *zap.Logger) {
	c.logger = logger
}

func (c *metadataServiceCredentialProvider) tokenRefreshLoop() {
	for {
		select {
		case <-c.refreshStop:
			c.logger.Info("Token refresh loop stopped")
			c.refreshTicker.Stop()
			return
		case <-c.refreshTicker.C:
			c.logger.Debug("Starting token refresh")
			c.refreshTokenWithTimeout()
		}
	}
}

func (c *metadataServiceCredentialProvider) refreshTokenWithTimeout() {
	refreshContext, cancel := context.WithTimeout(context.Background(), metadataIamTokenRefreshTimeout)
	defer cancel()

	if err := c.refreshToken(refreshContext); err != nil {
		c.logger.Error("Failed to refresh token", zap.Error(err))
	}
}

func (c *metadataServiceCredentialProvider) refreshToken(ctx context.Context) error {
	c.refreshMutex.Lock()
	defer c.refreshMutex.Unlock()

	if c.recentlyRefreshedAndValid() {
		c.logger.Debug("Token is still valid, skipping refresh")
		return nil
	}

	c.logger.Debug("Getting new IAM token")
	token, actualExpiration, err := c.iamToken(ctx)
	if err != nil {
		return fmt.Errorf("failed to get compute instance service account token from instance metadata service: GET %s: %w", c.url(), err)
	}

	c.currentTokenMutex.Lock()
	defer c.currentTokenMutex.Unlock()

	c.currentTokenRealExpiration = actualExpiration
	c.currentToken = token
	c.lastRefreshTime = time.Now()

	c.logger.Info("Token refreshed successfully",
		zap.Time("expiresAt", actualExpiration),
		zap.Time("refreshedAt", c.lastRefreshTime))

	return nil
}

func (c *metadataServiceCredentialProvider) recentlyRefreshedAndValid() bool {
	if time.Since(c.lastRefreshTime) < metadataTokenCappedExpirationDuration {
		_, err := c.getValidCachedToken()
		return err == nil
	}

	return false
}

func (c *metadataServiceCredentialProvider) iamToken(ctx context.Context) (string, time.Time, error) {
	req, err := retryablehttp.NewRequest("GET", c.url(), nil)
	if err != nil {
		return "", time.Time{}, fmt.Errorf("request make failed: %w", err)
	}
	req.Header.Set("Metadata-Flavor", "Google")
	reqDump, _ := httputil.DumpRequestOut(req.Request, false)
	c.logger.Debug("Requesting instance SA token", zap.String("request", string(reqDump)))

	resp, err := c.client.Do(req.WithContext(ctx))
	if err != nil {
		c.logger.Error("Metadata service call failed", zap.Error(err))
		return "", time.Time{}, fmt.Errorf("compute instance metadata service call failed.\n" +
			"Are you inside compute instance?\n" +
			"Details")
	}

	defer resp.Body.Close()
	respDump, _ := httputil.DumpResponse(resp, false)
	c.logger.Debug("Received metadata service response", zap.String("response", string(respDump)))

	if resp.StatusCode == http.StatusNotFound {
		c.logger.Error("Service account not found")
		return "", time.Time{}, fmt.Errorf("%s.\n"+
			"Is this compute instance running using Service Account? That is, Instance.service_account_id should not be empty.",
			resp.Status)
	}

	body, err := io.ReadAll(resp.Body)

	if resp.StatusCode != http.StatusOK {
		if err != nil {
			body = []byte(fmt.Sprintf("Failed response body read failed: %s", err.Error()))
		}

		c.logger.Error("Failed to get SA token",
			zap.String("status", resp.Status),
			zap.String("body", string(body)))

		return "", time.Time{}, fmt.Errorf("%s", resp.Status)
	}

	if err != nil {
		return "", time.Time{}, fmt.Errorf("response read failed: %s", err)
	}

	var tokenResponse struct {
		AccessToken string `json:"access_token"`
		ExpiresIn   int64  `json:"expires_in"`
		TokenType   string `json:"token_type"`
	}

	err = json.Unmarshal(body, &tokenResponse)
	if err != nil {
		c.logger.Error("Failed to unmarshal token response",
			zap.Error(err))
		return "", time.Time{}, fmt.Errorf("body unmarshal failed: %w", err)
	}

	actualExpirationTime := time.Now().Add(time.Duration(tokenResponse.ExpiresIn) * time.Second)
	c.logger.Debug("Successfully parsed token response",
		zap.Time("expiresAt", actualExpirationTime),
		zap.String("tokenType", tokenResponse.TokenType))

	return tokenResponse.AccessToken, actualExpirationTime, nil
}

func (c *metadataServiceCredentialProvider) url() string {
	return fmt.Sprintf("http://%s/computeMetadata/v1/instance/service-accounts/default/token", c.metadataServiceAddr)
}

func (c *metadataServiceCredentialProvider) Addr() string {
	return c.metadataServiceAddr
}

func (c *metadataServiceCredentialProvider) Available(ctx context.Context) bool {
	_, err := c.getValidCachedToken()
	if err == nil {
		c.logger.Debug("Cached token is available and valid")
		return true
	}

	c.logger.Debug("Checking metadata service availability")
	dialer := net.Dialer{Timeout: 50 * time.Millisecond}

	conn, err := dialer.Dial("tcp", c.metadataServiceAddr)
	if err != nil {
		c.logger.Error("Failed to connect to metadata service", zap.Error(err))
		return false
	}

	_ = conn.Close()

	return c.refreshToken(ctx) == nil
}

func (c *metadataServiceCredentialProvider) getValidCachedToken() (string, error) {
	c.currentTokenMutex.RLock()
	defer c.currentTokenMutex.RUnlock()

	if c.currentToken != "" && !c.currentTokenRealExpiration.IsZero() {
		if c.currentTokenRealExpiration.After(time.Now().Add(-metadataIamTokenValidityThreshold)) {
			return c.currentToken, nil
		} else {
			c.logger.Debug("Cached token is expired",
				zap.Time("expiresAt", c.currentTokenRealExpiration))
			return "", errors.New("current token is expired")
		}
	}

	c.logger.Debug("No cached token available")
	return "", errors.New("current token is unavailable")
}

func (c *metadataServiceCredentialProvider) IAMToken(ctx context.Context) (*CredentialsToken, error) {
	_, err := c.getValidCachedToken()
	if err == nil {
		c.logger.Debug("Using cached token")
		return c.toTokenResponse(), nil
	}

	c.logger.Debug("Cached token invalid or expired, refreshing token")
	// It is expected that the token is updated at the background, but we do this as the last resort measure here
	// (basically, the legacy behavior reproduction)
	err = c.refreshToken(ctx)
	if err != nil {
		c.logger.Error("Failed to refresh token", zap.Error(err))
		return nil, err
	}

	return c.toTokenResponse(), nil
}

func (c *metadataServiceCredentialProvider) toTokenResponse() *CredentialsToken {
	c.currentTokenMutex.RLock()
	defer c.currentTokenMutex.RUnlock()

	expiresAt := getCappedExpiresAt(c.currentTokenRealExpiration)
	c.logger.Debug("Creating token response", zap.Time("expiresAt", expiresAt))

	return &CredentialsToken{
		Token:     c.currentToken,
		ExpiresAt: expiresAt,
	}
}

// getCappedExpiresAt limits the token expiration duration by the metadataTokenCappedExpirationDuration to handle the
// following use-case: instance sa is stripped of all permissions (or even worse, deleted), instance is updated to use
// another sa with valid permissions; however, since the token is not expired it is still used for subsequent requests
// resulting in Permission Denied. Shorter expiration time is a workaround
func getCappedExpiresAt(actualExpirationTime time.Time) time.Time {
	result := time.Now()
	if actualExpirationTime.Sub(result) > metadataTokenCappedExpirationDuration {
		result = result.Add(metadataTokenCappedExpirationDuration)
	}

	return result
}

func (c *metadataServiceCredentialProvider) YandexCloudAPICredentials() {}

// GetMetadataServiceAddr returns the address of Metadata Service, gets the value from InstanceMetadataOverrideEnvVar
// env variable if it is set, otherwise uses the default address from InstanceMetadataAddr.
func GetMetadataServiceAddr() string {
	if nonDefaultAddr := os.Getenv(InstanceMetadataOverrideEnvVar); nonDefaultAddr != "" {
		return nonDefaultAddr
	}

	return InstanceMetadataAddr
}
