package utils

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"net"
	"net/http"
	"net/url"
	"strconv"
	"time"

	"github.com/go-resty/resty/v2"
	"github.com/prometheus/client_golang/prometheus"
	"github.com/tidbcloud/tiinf/monitor/tilog"
	"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
	"go.uber.org/zap"
)

func init() {
	prometheus.MustRegister(
		mRequestTotalTime,
		mRequestResponseTime,
		mRequestTLSHandshakeTime,
		mRequestTCPConnTime,
		mRequestConnTime,
		mRequestDNSLookupTime,
	)
}

const (
	ns = "http_client"
)

var (
	labelKeys = []string{"host", "method", "path", "status_code"}
)

var (
	mRequestTotalTime = prometheus.NewHistogramVec(prometheus.HistogramOpts{
		Namespace: ns,
		Name:      "request_time_seconds",
		Help:      "resty client request total time",
	}, labelKeys)

	mRequestResponseTime = prometheus.NewHistogramVec(prometheus.HistogramOpts{
		Namespace: ns,
		Name:      "request_response_time_seconds",
		Help:      "resty client request response time",
	}, labelKeys)

	mRequestTLSHandshakeTime = prometheus.NewHistogramVec(prometheus.HistogramOpts{
		Namespace: ns,
		Name:      "request_tls_handshake_time_seconds",
		Help:      "resty client request tls handshake time",
	}, labelKeys)

	mRequestTCPConnTime = prometheus.NewHistogramVec(prometheus.HistogramOpts{
		Namespace: ns,
		Name:      "request_tcp_conn_time_seconds",
		Help:      "resty client request tcp connection time",
	}, labelKeys)

	mRequestConnTime = prometheus.NewHistogramVec(prometheus.HistogramOpts{
		Namespace: ns,
		Name:      "request_conn_time_seconds",
		Help:      "resty client request connection time",
	}, labelKeys)

	mRequestDNSLookupTime = prometheus.NewHistogramVec(prometheus.HistogramOpts{
		Namespace: ns,
		Name:      "request_dns_lookup_time_seconds",
		Help:      "resty client request dns lookup time",
	}, labelKeys)
)

// DoRequest sends a http request.
// It has tracing, metrics and log equipped.
// No status code check is done here. It's up to the caller to check the status code.
// Params:
// - method: string, http method, e.g. `http.MethodGet`
// - baseUrl: string, [scheme]://[host][:port], e.g. "http://localhost:8080"
// - apiPath: string, the path of the api, could be empty, e.g. "/api/v1/health"
func DoRequest(
	ctx context.Context,
	client *resty.Client,
	method, baseUrl, apiPath string,
	setParam func(req *resty.Request),
) (*resty.Response, error) {
	return doRequest(ctx, client, method, baseUrl, apiPath, setParam, false)
}

// DoRequestNoRespLog is like DoRequest but suppresses response body logging on success.
// Use for endpoints that return large or binary payloads (e.g. Prometheus /metrics) where
// logging the full body would be too expensive.
func DoRequestNoRespLog(
	ctx context.Context,
	client *resty.Client,
	method, baseUrl, apiPath string,
	setParam func(req *resty.Request),
) (*resty.Response, error) {
	return doRequest(ctx, client, method, baseUrl, apiPath, setParam, true)
}

func doRequest(
	ctx context.Context,
	client *resty.Client,
	method, baseUrl, apiPath string,
	setParam func(req *resty.Request),
	noRespBodyLog bool,
) (*resty.Response, error) {
	// Set tracing.
	if _, ok := client.GetClient().Transport.(*otelhttp.Transport); !ok {
		client.SetTransport(otelhttp.NewTransport(client.GetClient().Transport))
	}

	// Build the request.
	request := client.R().EnableTrace().SetContext(ctx)
	setParam(request)

	// Build the url.
	if baseUrl == "" {
		return nil, fmt.Errorf("baseUrl is empty")
	}
	if baseUrl[len(baseUrl)-1] == '/' {
		baseUrl = baseUrl[:len(baseUrl)-1]
	}
	if apiPath != "" && apiPath[0] != '/' {
		apiPath = "/" + apiPath
	}
	beforeUrl := baseUrl + apiPath

	// Execute the request.
	startedAt := time.Now()
	resp, err := request.Execute(method, beforeUrl)
	if err != nil {
		var netErr net.Error
		switch {
		case errors.Is(err, context.Canceled):
			mRequestTotalTime.WithLabelValues(beforeUrl, method, apiPath, "context.Canceled").Observe(time.Since(startedAt).Seconds())
		case errors.Is(err, context.DeadlineExceeded):
			mRequestTotalTime.WithLabelValues(beforeUrl, method, apiPath, "context.DeadlineExceeded").Observe(time.Since(startedAt).Seconds())
		case errors.As(err, &netErr):
			mRequestTotalTime.WithLabelValues(beforeUrl, method, apiPath, "net.Error").Observe(time.Since(startedAt).Seconds())
		}
		return nil, fmt.Errorf("http request execute failed: %w", err)
	}
	if resp == nil {
		return nil, errors.New("empty response")
	}

	// Extract host[:port] from afterUrl.
	afterUrl, err := url.Parse(resp.Request.URL)
	if err != nil {
		return nil, fmt.Errorf("parse url failed: %w", err)
	}

	// Record the metrics.
	statusCodeStr := strconv.Itoa(resp.StatusCode())
	mRequestTotalTime.WithLabelValues(afterUrl.Host, method, apiPath, statusCodeStr).Observe(resp.Request.TraceInfo().TotalTime.Seconds())
	mRequestResponseTime.WithLabelValues(afterUrl.Host, method, apiPath, statusCodeStr).Observe(resp.Request.TraceInfo().ResponseTime.Seconds())
	mRequestTLSHandshakeTime.WithLabelValues(afterUrl.Host, method, apiPath, statusCodeStr).Observe(resp.Request.TraceInfo().TLSHandshake.Seconds())
	mRequestTCPConnTime.WithLabelValues(afterUrl.Host, method, apiPath, statusCodeStr).Observe(resp.Request.TraceInfo().TCPConnTime.Seconds())
	mRequestConnTime.WithLabelValues(afterUrl.Host, method, apiPath, statusCodeStr).Observe(resp.Request.TraceInfo().ConnTime.Seconds())
	mRequestDNSLookupTime.WithLabelValues(afterUrl.Host, method, apiPath, statusCodeStr).Observe(resp.Request.TraceInfo().DNSLookup.Seconds())

	var reqBody string
	if request.Body != nil {
		switch b := request.Body.(type) {
		case []byte:
			reqBody = string(b)
		case string:
			reqBody = b
		default:
			bs, err := json.Marshal(request.Body)
			if err != nil {
				tilog.Error(ctx, "marshal request body failed",
					zap.Error(err),
					zap.String("body_type", fmt.Sprintf("%T", request.Body)),
				)
			} else {
				reqBody = string(bs)
			}
		}
	}

	redactedReqBody := RedactSecrets(reqBody)

	isSuccess := resp.StatusCode() >= http.StatusOK && resp.StatusCode() < http.StatusMultipleChoices
	logFields := []zap.Field{
		zap.String("method", method),
		zap.Int("status", resp.StatusCode()),
		zap.String("url", RedactURL(resp.Request.URL)),
		zap.String("request", redactedReqBody),
		zap.Duration("cost", resp.Request.TraceInfo().TotalTime),
	}
	if !isSuccess || !noRespBodyLog {
		logFields = append(logFields, zap.String("response", RedactSecrets(string(resp.Body()))))
	}

	if !isSuccess {
		tilog.WithContextL(ctx).WithOptions(zap.WithCaller(false)).Error("---> http client (resty)", logFields...)
	} else {
		tilog.WithContextL(ctx).WithOptions(zap.WithCaller(false)).Info("---> http client (resty)", logFields...)
	}

	return resp, nil
}
