package vmquery

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net"
	"net/http"
	"net/url"
	"strings"
	"syscall"
	"time"

	"github.com/tidbcloud/observability/vm-migration/internal/retry"
)

// Client queries vmselect PromQL APIs. Production uses HTTPClient; tests inject
// a fake.
type Client interface {
	Query(ctx context.Context, baseURL, api string, params map[string]string, timeout time.Duration) (*QueryResponse, error)
}

// Endpoint joins base and api, stripping trailing slash from base.
func Endpoint(base, api string) string {
	return strings.TrimSuffix(base, "/") + api
}

// RenderURL substitutes {tenant} in template. Colon ':' is preserved for the
// accountID:projectID format.
func RenderURL(template, tenant string) string {
	escaped := url.PathEscape(tenant)
	// PathEscape percent-encodes ':' as %3A; the tenant ID format requires
	// the colon to survive literally in the path segment.
	escaped = strings.ReplaceAll(escaped, "%3A", ":")
	return strings.ReplaceAll(template, "{tenant}", escaped)
}

// sharedTransport is the connection-pooled Transport used by HTTP clients in
// this package. Concurrent-safe.
var sharedTransport = &http.Transport{
	Proxy:                 http.ProxyFromEnvironment,
	MaxIdleConns:          100,
	MaxIdleConnsPerHost:   32,
	IdleConnTimeout:       90 * time.Second,
	TLSHandshakeTimeout:   10 * time.Second,
	ExpectContinueTimeout: 1 * time.Second,
	ResponseHeaderTimeout: 30 * time.Second,
}

// NewSharedClient returns an *http.Client backed by sharedTransport.
func NewSharedClient() *http.Client {
	return &http.Client{Transport: sharedTransport}
}

// HTTPClient is the production Client implementation. Reuses one *http.Client
// (concurrency-safe) and the shared transport's connection pool.
//
// RetryPolicy governs transport-level retries. The default (set by
// NewHTTPClient) retries once on connection-level errors with no backoff.
type HTTPClient struct {
	Client      *http.Client
	RetryPolicy retry.Policy
}

// NewHTTPClient returns an HTTPClient with the default PromQL retry policy
// (one retry, no backoff).
func NewHTTPClient() *HTTPClient {
	return &HTTPClient{
		Client:      NewSharedClient(),
		RetryPolicy: retry.Policy{MaxAttempts: 2},
	}
}

// Query POSTs form-encoded params to baseURL+api and decodes the response
// into a QueryResponse. Concurrent-safe: never mutates h.Client.
func (h *HTTPClient) Query(ctx context.Context, baseURL, api string, params map[string]string, timeout time.Duration) (*QueryResponse, error) {
	data, err := h.postForm(ctx, Endpoint(baseURL, api), params, timeout)
	if err != nil {
		return nil, err
	}
	var resp QueryResponse
	if err := json.Unmarshal(data, &resp); err != nil {
		return nil, fmt.Errorf("vmquery: decode response: %w", err)
	}
	return &resp, nil
}

// NewFormRequest builds a POST request carrying form-encoded params with a
// per-request timeout. The returned CancelFunc must be called by the caller.
func NewFormRequest(ctx context.Context, target string, params map[string]string, timeout time.Duration) (*http.Request, context.CancelFunc, error) {
	reqCtx, cancel := context.WithTimeout(ctx, timeout)
	form := url.Values{}
	for k, v := range params {
		form.Set(k, v)
	}
	req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, target, strings.NewReader(form.Encode()))
	if err != nil {
		cancel()
		return nil, nil, err
	}
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	return req, cancel, nil
}

// CheckResponse returns *HTTPError when resp is non-2xx (reading and closing
// resp.Body for the error detail), or nil for 2xx. 4xx codes classify as
// permanent; 5xx as transient.
func CheckResponse(resp *http.Response) error {
	if resp.StatusCode >= 200 && resp.StatusCode < 300 {
		return nil
	}
	body, _ := io.ReadAll(resp.Body)
	resp.Body.Close()
	return &HTTPError{Status: resp.Status, Code: resp.StatusCode, Body: body}
}

// postForm POSTs form-encoded params to target and returns the response body
// on success. Retries per h.RetryPolicy on connection-level errors; HTTP
// non-2xx become HTTPError.
func (h *HTTPClient) postForm(ctx context.Context, target string, params map[string]string, timeout time.Duration) ([]byte, error) {
	client := h.client()

	var data []byte
	err := retry.Do(ctx, h.RetryPolicy, func() error {
		req, cancel, err := NewFormRequest(ctx, target, params, timeout)
		if err != nil {
			return err
		}
		defer cancel()
		resp, err := client.Do(req)
		if err != nil {
			return wrapConnError(err)
		}
		defer resp.Body.Close()
		if err := CheckResponse(resp); err != nil {
			return err
		}
		data, err = io.ReadAll(resp.Body)
		return err
	})
	if err != nil {
		return nil, err
	}
	return data, nil
}

// client returns h.Client or a default backed by sharedTransport.
func (h *HTTPClient) client() *http.Client {
	if h.Client != nil {
		return h.Client
	}
	return NewSharedClient()
}

// HTTPError is a non-2xx HTTP response. 4xx codes classify as permanent;
// 5xx codes as transient. Use errors.Is to check classification.
type HTTPError struct {
	Status string
	Code   int
	Body   []byte
}

func (e *HTTPError) Error() string { return fmt.Sprintf("vmquery: HTTP %s", e.Status) }

// Is reports the retry classification. 5xx → ErrTransient, 4xx → ErrPermanent.
func (e *HTTPError) Is(target error) bool {
	switch target {
	case retry.ErrTransient:
		return e.Code >= 500
	case retry.ErrPermanent:
		return e.Code >= 400 && e.Code < 500
	}
	return false
}

// ConnError wraps a connection-level transport error (stale keep-alive, reset,
// refused). Always classified transient.
type ConnError struct {
	Inner error
}

func (e *ConnError) Error() string { return "vmquery: connection error: " + e.Inner.Error() }

func (e *ConnError) Unwrap() error { return e.Inner }

// Is reports transient classification for retry decisions.
func (e *ConnError) Is(target error) bool { return target == retry.ErrTransient }

// transientSyscalls are the OS-level errors that indicate a stale or refused
// connection worth one retry.
var transientSyscalls = []error{
	io.EOF,
	syscall.ECONNREFUSED,
	syscall.ECONNRESET,
	syscall.EPIPE,
}

// wrapConnError returns err unchanged if it isn't a connection-level failure,
// otherwise a *ConnError wrapping it. Timeout errors are not wrapped.
func wrapConnError(err error) error {
	if err == nil {
		return nil
	}
	// net.Error covers both bare *net.OpError and any wrapper that satisfies
	// the interface. Timeout takes precedence: a slow query must not retry.
	var netErr net.Error
	if errors.As(err, &netErr) {
		if netErr.Timeout() {
			return err
		}
		return &ConnError{Inner: err}
	}
	for _, sentinel := range transientSyscalls {
		if errors.Is(err, sentinel) {
			return &ConnError{Inner: err}
		}
	}
	return err
}
