package session

import (
	"context"
	"errors"
	"fmt"
	"net/http"
	"net/url"
	"path"
	"strings"
	"time"

	"github.com/akamai/AkamaiOPEN-edgegrid-golang/v13/pkg/log"
	"github.com/hashicorp/go-retryablehttp"
)

// RetryConfig struct contains http retry configuration.
//
// ExcludedEndpoints field is a list of  shell patterns.
// The pattern syntax is:
//
//	pattern:
//		{ term }
//	term:
//		'*'         matches any sequence of non-/ characters
//		'?'         matches any single non-/ character
//		'[' [ '^' ] { character-range } ']'
//		            character class (must be non-empty)
//		c           matches character c (c != '*', '?', '\\', '[')
//		'\\' c      matches character c
//
//	character-range:
//		c           matches character c (c != '\\', '-', ']')
//		'\\' c      matches character c
//		lo '-' hi   matches character c for lo <= c <= hi
type RetryConfig struct {
	RetryMax          int
	RetryWaitMin      time.Duration
	RetryWaitMax      time.Duration
	ExcludedEndpoints []string
}

// NewRetryConfig creates a new retry config with default settings.
func NewRetryConfig() RetryConfig {
	return RetryConfig{
		RetryMax:          10,
		RetryWaitMin:      1 * time.Second,
		RetryWaitMax:      30 * time.Second,
		ExcludedEndpoints: []string{},
	}
}

func configureRetryClient(conf RetryConfig, signFunc func(r *http.Request) error, log log.Interface) (*retryablehttp.Client, error) {
	retryClient := retryablehttp.NewClient()

	err := validateRetryConf(conf)
	if err != nil {
		return nil, err
	}
	retryClient.RetryMax = conf.RetryMax
	retryClient.RetryWaitMin = conf.RetryWaitMin
	retryClient.RetryWaitMax = conf.RetryWaitMax

	retryClient.PrepareRetry = signFunc
	retryClient.HTTPClient.CheckRedirect = func(r *http.Request, _ []*http.Request) error {
		return signFunc(r)
	}
	retryClient.CheckRetry = overrideRetryPolicy(retryablehttp.DefaultRetryPolicy, conf.ExcludedEndpoints)
	retryClient.Backoff = overrideBackoff(retryablehttp.DefaultBackoff, log)
	retryClient.Logger = GetRetryableLogger(log)

	return retryClient, err
}

func validateRetryConf(conf RetryConfig) error {
	errs := []error{}

	if conf.RetryMax < 0 {
		errs = append(errs, errors.New("maximum number of retries cannot be negative"))
	}
	if conf.RetryWaitMin < 0 {
		errs = append(errs, errors.New("minimum retry wait time cannot be negative"))
	}
	if conf.RetryWaitMax < 0 {
		errs = append(errs, errors.New("maximum retry wait time cannot be negative"))
	}
	if conf.RetryWaitMax < conf.RetryWaitMin {
		errs = append(errs, errors.New("maximum retry wait time cannot be shorter than minimum retry wait time"))
	}
	for _, pattern := range conf.ExcludedEndpoints {
		if _, err := path.Match(pattern, ""); err != nil {
			errs = append(errs, fmt.Errorf("malformed exclude endpoint pattern: %v: %s", err, pattern))
		}
	}
	if len(errs) > 0 {
		return errors.Join(errs...)
	}
	return nil
}

func overrideRetryPolicy(basePolicy retryablehttp.CheckRetry, excludedEndpoints []string) retryablehttp.CheckRetry {
	return func(ctx context.Context, resp *http.Response, err error) (bool, error) {
		// do not retry on context.Canceled or context.DeadlineExceeded
		if ctx.Err() != nil {
			return false, ctx.Err()
		}

		if resp == nil || resp.Request.Method != http.MethodGet ||
			(resp.Request.URL != nil && isBlocked(resp.Request.URL.Path, excludedEndpoints)) {
			var urlErr *url.Error
			if resp == nil && errors.As(err, &urlErr) && strings.ToUpper(urlErr.Op) == http.MethodGet {
				return basePolicy(ctx, resp, err)
			}
			return false, err
		}

		// Retry all PAPI and CCM GET requests resulting status code 429
		// The backoff time is calculated in getXRateLimitBackoff
		is429 := resp.StatusCode == http.StatusTooManyRequests
		isPAPI := resp.Request.URL != nil && strings.HasPrefix(resp.Request.URL.Path, "/papi/")
		isCCM := resp.Request.URL != nil && strings.HasPrefix(resp.Request.URL.Path, "/ccm/")
		if is429 && (isPAPI || isCCM) {
			return true, nil
		}
		if resp.StatusCode == http.StatusConflict {
			return true, nil
		}
		return basePolicy(ctx, resp, err)
	}
}

func overrideBackoff(baseBackoff retryablehttp.Backoff, logger log.Interface) retryablehttp.Backoff {
	return func(minT, maxT time.Duration, attemptNum int, resp *http.Response) time.Duration {
		if resp != nil {
			if resp.StatusCode == http.StatusTooManyRequests {
				if wait, ok := getRateLimitBackoff("Akamai-RateLimit-Next", resp, logger); ok {
					return wait
				}
				if wait, ok := getRateLimitBackoff("X-RateLimit-Next", resp, logger); ok {
					return wait
				}
			}
		}
		return baseBackoff(minT, maxT, attemptNum, resp)
	}
}

// getRateLimitBackoff extracts the backoff duration from the given headerKey
// in the http.Response headers. Supports "X-RateLimit-Next" and "Akamai-RateLimit-Next".
// Note that Date's resolution is seconds (e.g. Mon, 01 Jul 2024 14:32:14 GMT),
// while X-RateLimit-Next's resolution is milliseconds (2024-07-01T14:32:28.645Z).
// This may cause the wait time to be inflated by at most one second, like for the
// actual server response time around 2024-07-01T14:32:14.999Z. This is acceptable behavior
// as retry does not occur earlier than expected.
func getRateLimitBackoff(headerKey string, resp *http.Response, logger log.Interface) (time.Duration, bool) {
	nextHeader := resp.Header.Get(headerKey)
	if nextHeader == "" {
		return 0, false
	}
	next, err := time.Parse(time.RFC3339Nano, nextHeader)
	if err != nil {
		if logger != nil {
			logger.Errorf("Could not parse %s header: %v", headerKey, err)
		}
		return 0, false
	}

	dateHeader := resp.Header.Get("Date")
	if dateHeader == "" {
		if logger != nil {
			logger.Warnf("No Date header for %s: %s", headerKey, nextHeader)
		}
		return 0, false
	}
	date, err := time.Parse(time.RFC1123, dateHeader)
	if err != nil {
		if logger != nil {
			logger.Error("Could not parse Date header", "error", err)
		}
		return 0, false
	}

	// Next in the past does not make sense
	if next.Before(date) {
		if logger != nil {
			logger.Warnf("%s: %s before Date: %s", headerKey, nextHeader, dateHeader)
		}
		return 0, false
	}
	return next.Sub(date), true
}

func isBlocked(url string, disabledPatterns []string) bool {
	for _, pattern := range disabledPatterns {
		match, err := path.Match(pattern, url)
		if err != nil {
			return false
		}
		if match {
			return true
		}
	}
	return false
}
