package gitlab

import (
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"maps"
	"net/http"
	"net/url"
	"strconv"
	"strings"

	"buf.build/go/protovalidate"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/api"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/tool/featureflag"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/tool/httpz"
	otelmetric "go.opentelemetry.io/otel/metric"
	"google.golang.org/protobuf/encoding/protojson"
	"google.golang.org/protobuf/proto"
)

const (
	AgentTokenHeader = "Gitlab-Agent-Api-Request" //nolint: gosec
	AgentTypeHeader  = "Gitlab-Agent-Type"        //nolint: gosec
)

type ResponseHandler interface {
	// Handle is invoked with HTTP client's response and error values.
	Handle(protovalidate.Validator, *http.Response, error) error
	// Accept returns the value to send in the Accept HTTP header.
	// Empty string means no value should be sent.
	Accept() string
}

type featureFlagsHandler func(http.Header, otelmetric.Int64Counter)

// doConfig holds configuration for the Do call.
// At most one of bodyBytes or bodyReader can be set.
type doConfig struct {
	// This is the validator from the client. It can be used to validate proto messages passed via options.
	validator protovalidate.Validator

	method              string
	path                string
	query               url.Values
	header              http.Header
	bodyBytes           []byte
	bodyReader          io.Reader
	responseHandler     ResponseHandler
	featureFlagsHandler featureFlagsHandler
	withJWT             bool
	noRetry             bool
}

func (c *doConfig) ensureHeaderNotNil() {
	if c.header == nil {
		c.header = make(http.Header)
	}
}

// DoOption to configure the Do call of the client.
type DoOption func(*doConfig) error

func applyDoOptions(v protovalidate.Validator, opts []DoOption) (doConfig, error) {
	config := doConfig{
		validator: v,
		method:    http.MethodGet,
	}
	for _, v := range opts {
		err := v(&config)
		if err != nil {
			return doConfig{}, err
		}
	}
	if config.responseHandler == nil {
		return doConfig{}, errors.New("missing response handler")
	}

	// Note that we check bodyBytes != nil not len(bodyBytes) > 0 to allow sending an empty body.
	if config.bodyBytes != nil && config.bodyReader != nil {
		return doConfig{}, errors.New("cannot set both bodyBytes and bodyReader at the same time")
	}

	return config, nil
}

func WithMethod(method string) DoOption {
	return func(config *doConfig) error {
		config.method = method
		return nil
	}
}

func WithPath(path string) DoOption {
	return func(config *doConfig) error {
		config.path = path
		return nil
	}
}

func WithQuery(query url.Values) DoOption {
	return func(config *doConfig) error {
		config.query = query
		return nil
	}
}

func WithHeader(header http.Header) DoOption {
	return func(config *doConfig) error {
		clone := header.Clone()
		if config.header == nil {
			config.header = clone
		} else {
			maps.Copy(config.header, clone)
		}
		return nil
	}
}

func WithJWT(withJWT bool) DoOption {
	return func(config *doConfig) error {
		config.withJWT = withJWT
		return nil
	}
}

func WithAgentToken(agentToken api.AgentToken) DoOption {
	return func(config *doConfig) error {
		config.ensureHeaderNotNil()
		config.header[AgentTokenHeader] = []string{string(agentToken)}
		return nil
	}
}

func WithAgentType(agentType api.AgentType) DoOption {
	return func(config *doConfig) error {
		config.ensureHeaderNotNil()
		config.header[AgentTypeHeader] = []string{agentType.String()}
		return nil
	}
}

func WithAgentTokenAndType(agentTokenAndType api.AgentTokenWithType) DoOption {
	return func(config *doConfig) error {
		if err := WithAgentToken(agentTokenAndType.Token)(config); err != nil {
			return err
		}
		return WithAgentType(agentTokenAndType.Type)(config)
	}
}

// WithJobToken sets the CI job token to authenticate this API request.
// See https://docs.gitlab.com/ee/api/rest/#job-tokens.
func WithJobToken(jobToken string) DoOption {
	return func(config *doConfig) error {
		config.ensureHeaderNotNil()
		config.header["Job-Token"] = []string{jobToken}
		return nil
	}
}

// The WithRequestBodyReader option is not used at the moment because the client always buffers the reader's contents into a buffer anyway.
// This allows the client to retry failed requests.
// If one day we need to support real streaming, we'll need to rework the client to not use the retrying client.

// WithRequestBodyReader sets the request body and HTTP Content-Type header if contentType is not empty.
//func WithRequestBodyReader(body io.Reader, contentType string) DoOption {
//	return func(config *doConfig) error {
//		config.bodyReader = body
//		maybeSetContentType(config, contentType)
//		return nil
//	}
//}

// WithRequestBodyBytes sets the request body and HTTP Content-Type header if contentType is not empty.
func WithRequestBodyBytes(body []byte, contentType string) DoOption {
	return func(config *doConfig) error {
		config.bodyBytes = body
		maybeSetContentType(config, contentType)
		return nil
	}
}

// WithProtoJSONRequestBody specifies the object to marshal to JSON and use as request body.
// Use this method with proto messages.
func WithProtoJSONRequestBody(body proto.Message) DoOption {
	return func(config *doConfig) error {
		if err := config.validator.Validate(body); err != nil {
			return fmt.Errorf("WithProtoJSONRequestBody: %w", err)
		}
		bodyBytes, err := protojson.Marshal(body)
		if err != nil {
			return fmt.Errorf("WithProtoJSONRequestBody: %w", err)
		}
		return WithRequestBodyBytes(bodyBytes, "application/json")(config)
	}
}

// WithJSONRequestBody specifies the object to marshal to JSON and use as request body.
// Do NOT use this method with proto messages, use WithProtoJSONRequestBody instead.
func WithJSONRequestBody(body any) DoOption {
	return func(config *doConfig) error {
		bodyBytes, err := json.Marshal(body)
		if err != nil {
			return fmt.Errorf("WithJSONRequestBody: %w", err)
		}
		return WithRequestBodyBytes(bodyBytes, "application/json")(config)
	}
}

func WithResponseHandler(handler ResponseHandler) DoOption {
	return func(config *doConfig) error {
		config.responseHandler = handler
		accept := handler.Accept()
		if accept != "" {
			config.ensureHeaderNotNil()
			config.header[httpz.AcceptHeader] = []string{accept}
		}
		return nil
	}
}

func WithoutRetries() DoOption {
	return func(config *doConfig) error {
		config.noRetry = true
		return nil
	}
}

func WithFeatureFlags(s *featureflag.Set) DoOption {
	return func(config *doConfig) error {
		config.featureFlagsHandler = featureFlagsHandlerFunc(s)
		return nil
	}
}

func featureFlagsHandlerFunc(s *featureflag.Set) featureFlagsHandler {
	return func(h http.Header, ffCheckCounter otelmetric.Int64Counter) {
		var ffs map[string]bool
		for v := range httpz.HeaderValues(h, httpz.GitlabFeatureFlagHeader) {
			name, value, found := strings.Cut(v, "=")
			if !found {
				continue
			}

			b, err := strconv.ParseBool(value)
			if err != nil {
				continue // we fallback to the default
			}
			if ffs == nil {
				ffs = make(map[string]bool)
			}
			ffs[name] = b
		}
		*s = featureflag.NewSet(ffs, ffCheckCounter)
	}
}

func maybeSetContentType(config *doConfig, contentType string) {
	if contentType != "" {
		config.ensureHeaderNotNil()
		config.header[httpz.ContentTypeHeader] = []string{contentType}
	}
}
