package modagent

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"log/slog"
	"net/http"
	"net/textproto"
	"net/url"

	"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/module/modshared"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/tool/grpctool"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/tool/httpz"
	"google.golang.org/grpc"
)

// Config holds configuration for a Module.
type Config struct {
	// Log can be used for logging from the module.
	// It should not be used for logging from gRPC API methods. Use modshared.RPCAPIFromContext(ctx) instead.
	Log *slog.Logger
	// InstanceID can be used to distinguish agent processes from each other.
	InstanceID int64
	API        API
	// KASConn is the gRPC connection to gitlab-kas.
	// Modules that use this field must use modshared.ModuleStartAfterServers start phase.
	KASConn grpc.ClientConnInterface
	// APIServer is a gRPC server that can be used to expose API endpoints to gitlab-kas and/or GitLab.
	// It can be used to add endpoints in Factory.New.
	// Request handlers can obtain the per-request logger using modshared.RPCAPIFromContext(requestContext).
	APIServer grpctool.GRPCServer
	// AgentName is a string "gitlab-agent". Can be used as a user agent, server name, service name, etc.
	AgentName string
	// AgentName is a string "gitlab-agent/<version>/<git sha>". Can be used as a user agent, server name, service name, etc.
	AgentNameVersion string
	Validator        protovalidate.Validator
}

type GitLabResponse struct {
	Status     string // e.g. "200 OK"
	StatusCode int32  // e.g. 200
	Header     http.Header
	Body       io.ReadCloser
}

// API provides the API for the module to use.
type API interface {
	modshared.API
	// MakeGitLabRequest allows to make an HTTP call to a module's endpoint in GitLab.
	// The correct usage pattern is below. The calling code MUST close the body to release resources!
	// Draining the body is not necessary.
	//
	// resp, err := r.api.MakeGitLabRequest(ctx, "/<path>", <options>)
	// if err != nil {
	//   // handle error
	// }
	// defer errz.SafeClose(resp.Body, &retError) // make sure to check the returned error. E.g. use this helper.
	//
	// // Use resp - check HTTP response status, header, read body, etc.
	MakeGitLabRequest(ctx context.Context, path string, opts ...GitLabRequestOption) (*GitLabResponse, error)
	GetAgentKey(ctx context.Context) (api.AgentKey, error)
	TryGetAgentKey() (api.AgentKey, bool)
	GetGitLabExternalURL(ctx context.Context) (url.URL, error)
}

// Factory creates agent modules.
//
// CF = Config for Factory.
// CM = Config for Module.
type Factory[CF, CM any] interface {
	modshared.Factory
	// IsProducingLeaderModules returns if the modules that this Factory produces are leader modules or not.
	// A leader module must only be run once across all agent replicas.
	IsProducingLeaderModules() bool
	// New creates a new instance of a Module.
	// Can return nil if no module is necessary.
	New(CF) (Module[CM], error)
}

type Module[CM any] interface {
	// Run runs the module.
	// Run must block until the context signals done or the cfg channel is closed. Run must not return before that.
	// If the Run method returns before any of the stop signals is received the module runner will panic.
	// cfg is a channel that gets configuration updates sent to it. It's closed when the module should shut down.
	// cfg sends configuration objects that are shared and must not be mutated.
	// Module should make a copy if it needs to mutate the object.
	// Applying configuration may take time, the provided context may signal done if module should shut down.
	// cfg only provides the latest available configuration, intermediate configuration states are discarded.
	// Run is responsible that it acts according to the received configuration, even if that is just to wait for
	// a new one.
	Run(ctx context.Context, cfg <-chan CM) error
	// DefaultAndValidateConfiguration applies defaults and validates the passed configuration.
	// It is called each time on configuration update before sending it via the channel passed to Run().
	// cfg is a shared instance, module can mutate only the part of it that it owns and only inside of this method.
	DefaultAndValidateConfiguration(cfg CM) error
	// Name returns module's name.
	Name() string
}

type GitLabRequestConfig struct {
	Method string
	Header http.Header
	Query  url.Values
	Body   io.ReadCloser
}

func defaultRequestConfig() *GitLabRequestConfig {
	return &GitLabRequestConfig{
		Method: http.MethodGet,
		Header: make(http.Header),
		Query:  make(url.Values),
	}
}

func ApplyRequestOptions(opts []GitLabRequestOption) (*GitLabRequestConfig, error) {
	c := defaultRequestConfig()
	var errs []error
	for _, o := range opts {
		err := o(c)
		if err != nil {
			errs = append(errs, err)
		}
	}
	if len(errs) > 0 { // return the error(s) but close the body first
		if c.Body != nil {
			err := c.Body.Close()
			if err != nil {
				errs = append(errs, err)
			}
		}
		return nil, errors.Join(errs...)
	}
	return c, nil
}

type GitLabRequestOption func(*GitLabRequestConfig) error

func WithRequestHeader(header string, values ...string) GitLabRequestOption {
	return func(c *GitLabRequestConfig) error {
		c.Header[textproto.CanonicalMIMEHeaderKey(header)] = values
		return nil
	}
}

func WithRequestQueryParam(key string, values ...string) GitLabRequestOption {
	return func(c *GitLabRequestConfig) error {
		c.Query[key] = values
		return nil
	}
}

// WithRequestBody specifies request body to send and HTTP Content-Type header if contentType is not empty.
// If body implements io.ReadCloser, its Close() method will be called once the data has been sent.
// If body is nil, no body or Content-Type header is sent.
func WithRequestBody(body io.Reader, contentType string) GitLabRequestOption {
	return func(c *GitLabRequestConfig) error {
		if body == nil {
			return nil
		}
		if rc, ok := body.(io.ReadCloser); ok {
			c.Body = rc
		} else {
			c.Body = io.NopCloser(body)
		}
		if contentType != "" {
			c.Header[httpz.ContentTypeHeader] = []string{contentType}
		}
		return nil
	}
}

func WithJSONRequestBody(body any) GitLabRequestOption {
	return func(c *GitLabRequestConfig) error {
		bodyBytes, err := json.Marshal(body)
		if err != nil {
			return fmt.Errorf("WithJSONRequestBody: %w", err)
		}
		return WithRequestBody(bytes.NewReader(bodyBytes), "application/json")(c)
	}
}

// WithRequestMethod specifies request HTTP method.
func WithRequestMethod(method string) GitLabRequestOption {
	return func(c *GitLabRequestConfig) error {
		c.Method = method
		return nil
	}
}
