package infisical

import (
	"context"
	"crypto/tls"
	"crypto/x509"
	"errors"
	"fmt"
	"io"
	"os"

	"math"
	"math/rand"
	"net"
	"reflect"
	"strconv"
	"strings"
	"sync"
	"time"

	"github.com/rs/zerolog"
	"github.com/rs/zerolog/log"

	"github.com/go-resty/resty/v2"
	"github.com/hashicorp/golang-lru/v2/expirable"
	"github.com/infisical/go-sdk/packages/models"
	"github.com/infisical/go-sdk/packages/util"
)

type LogLevel string

const (
	LogLevelDebug LogLevel = "debug"
)

type InfisicalClient struct {
	authMethod       util.AuthMethod
	credential       interface{}
	tokenDetails     MachineIdentityCredential
	lastFetchedTime  time.Time
	firstFetchedTime time.Time

	mu sync.RWMutex

	// refreshMu is used to prevent concurrent token refreshes.
	// Only one refresh should happen at a time to avoid race conditions.
	refreshMu sync.Mutex

	cache *expirable.LRU[string, interface{}]

	httpClient *resty.Client
	config     Config

	secrets        SecretsInterface
	folders        FoldersInterface
	auth           AuthInterface
	dynamicSecrets DynamicSecretsInterface
	kms            KmsInterface
	ssh            SshInterface

	logger zerolog.Logger
}

type InfisicalClientInterface interface {
	UpdateConfiguration(config Config)
	Secrets() SecretsInterface
	Folders() FoldersInterface
	Auth() AuthInterface
	DynamicSecrets() DynamicSecretsInterface
	Kms() KmsInterface
	Ssh() SshInterface
}

type ExponentialBackoffStrategy struct {
	// Base delay between retries. Defaults to 1 second
	BaseDelay time.Duration

	// Maximum number of retries. Defaults to 3
	MaxRetries int

	// Maximum delay between retries. Defaults to 30 seconds
	MaxDelay time.Duration
}

func (s *ExponentialBackoffStrategy) GetDelay(retryCount int) time.Duration {

	if s.BaseDelay == 0 {
		s.BaseDelay = 1 * time.Second
	}

	if s.MaxDelay == 0 {
		s.MaxDelay = 30 * time.Second
	}

	if s.MaxRetries == 0 {
		s.MaxRetries = 3
	}

	delay := s.BaseDelay * time.Duration(math.Pow(2, float64(retryCount)))

	// if delay is greater than the user-configured max delay, set the delay to the max delay
	if delay > s.MaxDelay {
		delay = s.MaxDelay
	}

	return s.Jitter(delay)
}

func (s *ExponentialBackoffStrategy) Jitter(delay time.Duration) time.Duration {
	// 20% jitter, negative and positive

	jitterFactor := 0.2

	// generates random value in [-0.2, +0.2] range
	randomFactor := (rand.Float64()*2 - 1) * jitterFactor
	jitter := time.Duration(randomFactor * float64(delay))
	return delay + jitter
}

type RetryRequestsConfig struct {
	ExponentialBackoff *ExponentialBackoffStrategy
}

type Config struct {
	SiteUrl              string `default:"https://app.infisical.com"`
	CaCertificate        string
	LogLevel             LogLevel // Specify the log level for the SDK. If set to debug, the SDK will print to stdout with verbose logging. Defaults to no logging.
	UserAgent            string   `default:"infisical-go-sdk"` // User-Agent header to be used on requests sent by the SDK. Defaults to `infisical-go-sdk`. Do not modify this unless you have a reason to do so.
	AutoTokenRefresh     *bool    // Whether or not to automatically refresh the auth token after using one of the .Auth() methods. Defaults to `true` when nil. Pass a pointer to `false` to disable.
	SilentMode           bool     // If enabled, the SDK will not print any warnings to the console.
	CacheExpiryInSeconds int      // Defines how long certain API responses should be cached in memory, in seconds. When set to a positive value, responses from specific fetch API requests (like secret fetching) will be cached for this duration. Set to 0 to disable caching. Defaults to 0.
	CustomHeaders        map[string]string
	RetryRequestsConfig  *RetryRequestsConfig
	LogWriter            io.Writer
}

func setupLogger(logLevel LogLevel, logWriter io.Writer) zerolog.Logger {
	// very annoying but zerolog doesn't allow us to change one color without changing all of them
	// these are the default colors for each level, except for warn
	levelColors := map[string]string{
		"trace": "\033[35m", // magenta
		"debug": "\033[33m", // yellow
		"info":  "\033[32m", // green
		"warn":  "\033[33m", // yellow (this one is custom, the default is red \033[31m)
		"error": "\033[31m", // red
		"fatal": "\033[31m", // red
		"panic": "\033[31m", // red
	}

	// map full level names to abbreviated forms (default zerolog behavior)
	// see consoleDefaultFormatLevel, in zerolog for example
	levelAbbrev := map[string]string{
		"trace": "TRC",
		"debug": "DBG",
		"info":  "INF",
		"warn":  "WRN",
		"error": "ERR",
		"fatal": "FTL",
		"panic": "PNC",
	}

	// default to stderr
	if logWriter == nil {
		logWriter = os.Stderr
	}

	logger := log.Output(zerolog.ConsoleWriter{
		Out:        logWriter,
		TimeFormat: time.RFC3339,

		FormatLevel: func(i interface{}) string {
			level := fmt.Sprintf("%s", i)
			color := levelColors[level]
			if color == "" {
				color = "\033[0m" // no color for unknown levels
			}
			abbrev := levelAbbrev[level]
			if abbrev == "" {
				abbrev = strings.ToUpper(level) // fallback to uppercase if unknown
			}
			return color + abbrev + "\033[0m"
		},
	})

	if logLevel != "" {
		level, err := zerolog.ParseLevel(string(logLevel))
		if err != nil {
			logger.Warn().Msgf("Invalid log level: %s", logLevel)
		} else {
			logger = logger.Level(level)
			logger.Debug().Msgf("Infisical SDK log level set to %s", logLevel)
		}
	} else {
		logger = logger.Level(zerolog.InfoLevel)
	}

	return logger
}

// autoTokenRefreshEnabled returns true if AutoTokenRefresh is unset (nil) or
// explicitly set to true. nil is treated as the default-on case so that
// callers who omit the field keep the historical behavior, while callers that
// pass a pointer to false can actually disable the background refresh.
func autoTokenRefreshEnabled(cfg Config) bool {
	return cfg.AutoTokenRefresh == nil || *cfg.AutoTokenRefresh
}

func setDefaults(cfg *Config) {
	t := reflect.TypeOf(*cfg) // we need to dereference the pointer to get the struct type
	v := reflect.ValueOf(cfg).Elem()

	for i := 0; i < t.NumField(); i++ {
		field := t.Field(i)
		defaultVal := field.Tag.Get("default")
		if defaultVal == "" {
			continue
		}

		switch field.Type.Kind() {
		case reflect.Int:
			if v.Field(i).Int() == 0 {
				val, _ := strconv.Atoi(defaultVal)
				v.Field(i).SetInt(int64(val))
			}
		case reflect.String:
			if v.Field(i).String() == "" {
				v.Field(i).SetString(defaultVal)
			}
		}
	}
}

// BoolPtr returns a pointer to the given bool value. Use this helper when
// setting AutoTokenRefresh in Config, e.g.: AutoTokenRefresh: infisical.BoolPtr(false).
func BoolPtr(v bool) *bool { return &v }

func (c *InfisicalClient) setAccessToken(tokenDetails MachineIdentityCredential, credential interface{}, authMethod util.AuthMethod) {
	c.mu.Lock()
	defer c.mu.Unlock()

	c.tokenDetails = tokenDetails
	c.lastFetchedTime = time.Now()

	if c.authMethod != authMethod || c.firstFetchedTime.IsZero() {
		c.firstFetchedTime = time.Now()
		c.authMethod = authMethod
	}

	c.credential = credential
	c.httpClient.SetAuthScheme("Bearer")
	c.httpClient.SetAuthToken(c.tokenDetails.AccessToken)
}

func (c *InfisicalClient) clearAccessToken() {
	c.mu.Lock()
	defer c.mu.Unlock()

	c.tokenDetails = MachineIdentityCredential{}
	c.authMethod = ""
	c.httpClient.SetAuthScheme("")
	c.httpClient.SetAuthToken("")
}
func (c *InfisicalClient) setPlainAccessToken(accessToken string) {
	c.mu.Lock()
	defer c.mu.Unlock()

	c.authMethod = util.ACCESS_TOKEN
	c.httpClient.SetAuthScheme("Bearer")
	c.httpClient.SetAuthToken(accessToken)

	c.tokenDetails.AccessToken = accessToken
	c.credential = models.AccessTokenCredential{AccessToken: accessToken}
}

func NewInfisicalClient(context context.Context, config Config) InfisicalClientInterface {
	logger := setupLogger(config.LogLevel, config.LogWriter)

	client := &InfisicalClient{
		logger: logger,
	}
	setDefaults(&config)
	client.UpdateConfiguration(config) // set httpClient and config

	// add interfaces here
	client.secrets = NewSecrets(client)
	client.folders = NewFolders(client)
	client.auth = NewAuth(client)
	client.dynamicSecrets = NewDynamicSecrets(client)
	client.kms = NewKms(client)
	client.ssh = NewSsh(client)
	if config.CacheExpiryInSeconds != 0 {
		// hard limit set at 1000 cache items until forced eviction
		client.cache = expirable.NewLRU[string, interface{}](1000, nil, time.Second*time.Duration(config.CacheExpiryInSeconds))
	}

	if autoTokenRefreshEnabled(config) {
		go client.handleTokenLifeCycle(context)
	}

	return client
}

func (c *InfisicalClient) UpdateConfiguration(config Config) {
	c.mu.Lock()
	defer c.mu.Unlock()

	setDefaults(&config)
	config.SiteUrl = util.AppendAPIEndpoint(config.SiteUrl)
	c.config = config

	if c.httpClient == nil {
		c.httpClient = resty.New().
			SetHeader("User-Agent", config.UserAgent).
			SetBaseURL(config.SiteUrl)

		maxRetries := 3
		maxWaitTime := 30 * time.Second

		if config.RetryRequestsConfig != nil && config.RetryRequestsConfig.ExponentialBackoff != nil {
			maxRetries = config.RetryRequestsConfig.ExponentialBackoff.MaxRetries
			maxWaitTime = 10 * time.Minute
		}

		c.httpClient.SetRetryCount(maxRetries).
			SetRetryWaitTime(1 * time.Second).
			SetRetryMaxWaitTime(maxWaitTime).
			SetRetryAfter(func(rc *resty.Client, r *resty.Response) (time.Duration, error) {

				if config.RetryRequestsConfig != nil && config.RetryRequestsConfig.ExponentialBackoff != nil {
					delay := config.RetryRequestsConfig.ExponentialBackoff.GetDelay(r.Request.Attempt)
					if !config.SilentMode {
						util.PrintWarning(c.logger, fmt.Sprintf("Request failed, [url=%s] [status=%d] [method=%s]\nRetrying in %s (attempt %d)", r.Request.URL, r.StatusCode(), r.Request.Method, delay.String(), r.Request.Attempt))
					}
					return delay, nil
				}

				attempt := r.Request.Attempt + 1
				if attempt <= 0 {
					attempt = 1
				}
				waitTime := math.Min(float64(rc.RetryWaitTime)*math.Pow(2, float64(attempt-1)), float64(rc.RetryMaxWaitTime))

				// Add jitter of +/-20%
				jitterFactor := 0.8 + (rand.Float64() * 0.4)
				waitTime = waitTime * jitterFactor

				waitDuration := time.Duration(waitTime)
				return waitDuration, nil
			}).
			AddRetryCondition(func(r *resty.Response, err error) bool {
				// don't retry if there's no error or it's a timeout
				if errors.Is(err, context.DeadlineExceeded) {
					return false
				}

				if err == nil && r == nil {
					return false
				}

				if config.RetryRequestsConfig != nil && config.RetryRequestsConfig.ExponentialBackoff != nil {
					if (r != nil && r.IsError()) || err != nil {
						return r.Request.Attempt <= config.RetryRequestsConfig.ExponentialBackoff.MaxRetries
					}
				}

				networkErrors := []string{
					"connection refused",
					"connection reset",
					"network",
					"connection",
					"no such host",
					"i/o timeout",
					"dial tcp",
					"broken pipe",
					"wsaetimeout",
					"wsaeconnreset",
					"econnreset",
					"econnrefused",
					"ehostunreach",
					"enetunreach",
				}

				isConditionMet := false

				var netErr net.Error
				if errors.As(err, &netErr) {
					return true
				}

				if err != nil {
					for _, netErr := range networkErrors {
						errMsg := err.Error()

						if strings.Contains(strings.ToLower(errMsg), netErr) {
							isConditionMet = true
							break
						}
					}
				}

				return isConditionMet

			})

		// OnBeforeRequest hook to validate and refresh token before each request.
		// This is a safety net to catch cases where the background token lifecycle
		// goroutine might miss a refresh window due to timing issues (GC pauses,
		// CPU contention, etc.). Most requests will not trigger a refresh here
		// because the background goroutine handles proactive token management.
		if autoTokenRefreshEnabled(config) {
			c.httpClient.OnBeforeRequest(c.beforeRequestAuthInterceptor)
		}
	} else {
		c.httpClient.
			SetHeader("User-Agent", config.UserAgent).
			SetBaseURL(config.SiteUrl)
	}

	if len(config.CustomHeaders) > 0 {
		c.httpClient.SetHeaders(config.CustomHeaders)
	}

	if config.CaCertificate != "" {
		caCertPool, err := x509.SystemCertPool()
		if err != nil && !config.SilentMode {
			util.PrintWarning(c.logger, fmt.Sprintf("failed to load system root CA pool: %v", err))
		}

		if ok := caCertPool.AppendCertsFromPEM([]byte(config.CaCertificate)); !ok && !config.SilentMode {
			util.PrintWarning(c.logger, "failed to append CA certificate")
		}

		tlsConfig := &tls.Config{
			RootCAs: caCertPool,
		}

		c.httpClient.SetTLSClientConfig(tlsConfig)
	}
}

func (c *InfisicalClient) Secrets() SecretsInterface {
	return c.secrets
}

func (c *InfisicalClient) Folders() FoldersInterface {
	return c.folders
}

func (c *InfisicalClient) Auth() AuthInterface {
	return c.auth
}

func (c *InfisicalClient) DynamicSecrets() DynamicSecretsInterface {
	return c.dynamicSecrets
}

func (c *InfisicalClient) Kms() KmsInterface {
	return c.kms
}

func (c *InfisicalClient) Ssh() SshInterface {
	return c.ssh
}

func (c *InfisicalClient) handleTokenLifeCycle(context context.Context) {
	var warningPrinted = false

	for {
		select {
		case <-context.Done():
			return // The context has been cancelled, clean up and return from the loop to stop the goroutine
		default:
			c.mu.RLock()
			config := c.config
			authMethod := c.authMethod
			tokenDetails := c.tokenDetails
			c.mu.RUnlock()

			if autoTokenRefreshEnabled(config) && authMethod != "" && authMethod != util.ACCESS_TOKEN {
				// Print warning once for short TTLs
				if !config.SilentMode && !warningPrinted && tokenDetails.AccessTokenMaxTTL != 0 && tokenDetails.ExpiresIn != 0 {
					if tokenDetails.AccessTokenMaxTTL < 60 || tokenDetails.ExpiresIn < 60 {
						util.PrintWarning(c.logger, "Machine Identity access token TTL or max TTL is less than 60 seconds. This may cause excessive API calls, and you may be subject to rate-limits.")
					}
					warningPrinted = true
				}

				// Check if token needs refresh (using the same buffer as OnBeforeRequest)
				if c.isTokenExpiringSoon(renewalBufferSeconds) {
					// Use refreshTokenSynchronously which handles both renewal and re-auth
					// Pass false for manualTrigger since this is from the background goroutine
					if err := c.refreshTokenSynchronously(false); err != nil {
						c.logger.Debug().Msgf("Background token refresh failed: %s", err.Error())
					}

					// Re-read token details after refresh attempt
					c.mu.RLock()
					tokenDetails = c.tokenDetails
					c.mu.RUnlock()
				}

				// Calculate sleep time until next check
				sleepTime := c.calculateSleepTime(tokenDetails, renewalBufferSeconds)

				if err := util.SleepWithContext(context, sleepTime); err != nil && (err == util.ErrContextCanceled || errors.Is(err, util.ErrContextDeadlineExceeded)) {
					return
				}
			} else {
				if err := util.SleepWithContext(context, 1*time.Second); err != nil && (err == util.ErrContextCanceled || errors.Is(err, util.ErrContextDeadlineExceeded)) {
					return
				}
			}
		}
	}
}
