// Package runner owns the shared execution facet of trusted Builtin provider
// Extensions: the provider auth contract, the guarded REST client, the
// credential source executors resolve connections through, and the stable
// provider error classification.
package runner

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"net/url"
	"strings"
	"time"
)

// Budget bounds for provider REST calls, mirroring the External egress
// envelope so Builtin and External invocations share one cost profile.
const (
	DefaultRequestTimeout  = 30 * time.Second
	MaxResponseBytes       = 4 << 20
	providerUserAgentValue = "agentteam-extension"
)

// RESTOptions configures one provider REST client.
type RESTOptions struct {
	// BaseURL is the fixed provider API origin (for example
	// "https://api.todoist.com/api/v1"). Requests may only address paths
	// under it.
	BaseURL string

	// Auth is the provider auth contract.
	Auth AuthSpec

	// Transport overrides the guarded default transport (tests inject a
	// loopback-friendly transport here; production leaves it nil).
	Transport http.RoundTripper

	// Timeout bounds one request. Zero selects DefaultRequestTimeout.
	Timeout time.Duration

	// UserAgent overrides the shared provider User-Agent for origins whose
	// access policy requires one (for example a browser-compatibility
	// prefix). Empty selects providerUserAgentValue. It names the client, not
	// the caller: credentials are still applied separately and per-request
	// header overrides remain forbidden.
	UserAgent string

	// StatusClassifier overrides the default HTTP-status-to-error mapping for
	// providers that carry business semantics in non-standard status codes
	// (for example ONES 6xx/8xx). It receives the bounded upstream error
	// body so providers can refine on errcode/type fields; it must return a
	// closed-set ErrorCode. Nil keeps the default classification.
	StatusClassifier func(status int, body []byte) ErrorCode
}

// RESTClient executes authenticated JSON requests against one fixed provider
// origin. It applies credentials after binding, follows no redirects, and
// bounds response size.
type RESTClient struct {
	baseURL          url.URL
	auth             AuthSpec
	client           *http.Client
	userAgent        string
	statusClassifier func(status int, body []byte) ErrorCode
}

// NewRESTClient validates the options and constructs the client.
func NewRESTClient(options RESTOptions) (*RESTClient, error) {
	parsed, err := url.Parse(strings.TrimSpace(options.BaseURL))
	if err != nil || parsed.Scheme != "https" || parsed.Host == "" {
		return nil, fmt.Errorf("provider REST base URL must be an absolute https origin")
	}
	if options.Transport == nil && parsed.Scheme == "https" {
		// Production clients always dial through the guarded transport so a
		// provider DNS rebinding cannot reach private infrastructure. The
		// override exists only for loopback test doubles.
		options.Transport = guardedProviderTransport()
	}
	timeout := options.Timeout
	if timeout <= 0 {
		timeout = DefaultRequestTimeout
	}
	client := &http.Client{
		Transport: options.Transport,
		Timeout:   timeout,
		CheckRedirect: func(*http.Request, []*http.Request) error {
			return http.ErrUseLastResponse
		},
	}
	userAgent := strings.TrimSpace(options.UserAgent)
	if userAgent == "" {
		userAgent = providerUserAgentValue
	}
	return &RESTClient{
		baseURL:          *parsed,
		auth:             options.Auth,
		client:           client,
		userAgent:        userAgent,
		statusClassifier: options.StatusClassifier,
	}, nil
}

// RESTRequest is one provider API call.
type RESTRequest struct {
	Method string
	Path   string
	Query  url.Values
	Body   any
	// Form is an application/x-www-form-urlencoded body. It is mutually
	// exclusive with Body.
	Form url.Values
	// Headers carries non-secret static provider headers (for example an API
	// version header). Credential-bearing names are rejected.
	Headers map[string]string
	// Response selects the bounded response representation and the exact
	// response headers the provider handler may inspect.
	Response RESTResponseSpec
}

// Call performs the request and returns the decoded single JSON value of a
// 2xx response. Non-2xx responses return a classified runtime error whose
// cause carries the bounded upstream body for server-side observability only.
func (client *RESTClient) Call(
	ctx context.Context,
	credentials Credentials,
	request RESTRequest,
) (json.RawMessage, error) {
	payload, _, err := client.CallWithMetrics(ctx, credentials, request)
	return payload, err
}

// CallWithMetrics is Call plus the raw response body size in bytes actually
// read from the wire (before any trimming), so callers charging cumulative
// budgets cannot be evaded by whitespace-padded payloads.
func (client *RESTClient) CallWithMetrics(
	ctx context.Context,
	credentials Credentials,
	request RESTRequest,
) (json.RawMessage, int64, error) {
	if !zeroRESTResponseSpec(request.Response) {
		return nil, 0, Errorf(
			ErrorValidation,
			"provider JSON Call does not accept a custom response contract",
			nil,
		)
	}
	request.Response = RESTResponseSpec{Mode: RESTResponseJSON}
	response, err := client.CallResponse(ctx, credentials, request)
	if err != nil {
		return nil, response.RawBytes, err
	}
	return json.RawMessage(response.Body), response.RawBytes, nil
}

func zeroRESTResponseSpec(spec RESTResponseSpec) bool {
	return spec.Mode == RESTResponseJSON &&
		len(spec.SuccessStatuses) == 0 &&
		spec.MaxBytes == 0 &&
		len(spec.ExposedHeaders) == 0 &&
		!spec.ForbiddenAsPermissionDenied
}

// CallJSON performs the request and decodes the 2xx JSON response into
// target. A null or empty body leaves target untouched.
func (client *RESTClient) CallJSON(
	ctx context.Context,
	credentials Credentials,
	request RESTRequest,
	target any,
) error {
	raw, err := client.Call(ctx, credentials, request)
	if err != nil {
		return err
	}
	trimmed := bytes.TrimSpace(raw)
	if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
		return nil
	}
	if err := json.Unmarshal(trimmed, target); err != nil {
		return Errorf(ErrorUpstreamFailure, "provider response was not decodable", err)
	}
	return nil
}

// resolveURL builds the target URL confined to the provider base origin. A
// path carrying an authority component (for example "//host/x") or control
// characters is rejected before joining; the post-join scheme/host check is
// defense-in-depth against any residual escape.
func (client *RESTClient) resolveURL(request RESTRequest) (*url.URL, error) {
	path := strings.TrimSpace(request.Path)
	if path == "" {
		path = "/"
	}
	if !strings.HasPrefix(path, "/") {
		path = "/" + path
	}
	if strings.HasPrefix(path, "//") || strings.ContainsAny(path, "\x00\r\n") {
		return nil, Errorf(ErrorValidation, "provider request path is invalid", nil)
	}
	target := client.baseURL.JoinPath(path)
	if request.Query != nil {
		target.RawQuery = request.Query.Encode()
	}
	if target.Scheme != client.baseURL.Scheme || target.Host != client.baseURL.Host {
		return nil, Errorf(ErrorValidation, "provider request escapes the fixed origin", nil)
	}
	return target, nil
}

// reservedRequestHeaders are the credential-bearing and hop-by-hop names a
// static provider header must not override. Keys are lowercase: header names
// are case-insensitive on the wire.
var reservedRequestHeaders = map[string]bool{
	"authorization":        true,
	"cookie":               true,
	"host":                 true,
	"content-type":         true,
	"content-length":       true,
	"accept":               true,
	"user-agent":           true,
	"mcp-session-id":       true,
	"mcp-protocol-version": true,
	"last-event-id":        true,
}

// validateHeaderNameSyntax enforces the raw header-name syntax: non-empty,
// no surrounding whitespace, and RFC token characters only.
func validateHeaderNameSyntax(name string) error {
	if name == "" || strings.TrimSpace(name) != name {
		return fmt.Errorf("header name %q is invalid", name)
	}
	for index := 0; index < len(name); index++ {
		char := name[index]
		valid := char >= 'a' && char <= 'z' ||
			char >= 'A' && char <= 'Z' ||
			char >= '0' && char <= '9' || char == '-'
		if !valid {
			return fmt.Errorf("header name %q is invalid", name)
		}
	}
	return nil
}

func validateStaticHeader(name string) error {
	if err := validateHeaderNameSyntax(name); err != nil {
		return err
	}
	if reservedRequestHeaders[strings.ToLower(name)] {
		return fmt.Errorf("header name %q is reserved", name)
	}
	return nil
}
