package runner

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"io"
	"net/http"
	"strings"
)

// RESTResponseMode selects the representation returned to a trusted provider
// handler. The zero value preserves the original JSON-only REST client.
type RESTResponseMode uint8

const (
	RESTResponseJSON RESTResponseMode = iota
	RESTResponseText
	RESTResponseEmpty
)

// RESTResponseSpec is a trusted, code-defined response contract. It is never
// populated from Agent input.
type RESTResponseSpec struct {
	Mode RESTResponseMode
	// SuccessStatuses overrides the default 2xx success set. It is used for
	// provider command endpoints that acknowledge with a no-follow redirect.
	SuccessStatuses []int
	// MaxBytes narrows the shared 4 MiB response ceiling.
	MaxBytes int64
	// ExposedHeaders is the exact response metadata copied to the handler.
	// Only the runtime's closed, secret-free header set is accepted.
	ExposedHeaders []string
	// ForbiddenAsPermissionDenied distinguishes an action-level 403 from a
	// rejected connection after that connection has already been validated.
	ForbiddenAsPermissionDenied bool
}

// RESTResponse is the bounded response visible to a trusted provider handler.
// Headers contains only names explicitly requested through ExposedHeaders.
type RESTResponse struct {
	StatusCode int
	Body       []byte
	Headers    http.Header
	RawBytes   int64
}

// CallResponse performs one fixed-origin request with a code-defined body and
// response policy. It supports JSON, form, bounded text, and empty command
// responses without exposing arbitrary upstream headers.
func (client *RESTClient) CallResponse(
	ctx context.Context,
	credentials Credentials,
	request RESTRequest,
) (RESTResponse, error) {
	if client == nil {
		return RESTResponse{}, Errorf(
			ErrorUpstreamFailure, "provider client is unavailable", nil)
	}
	contract, err := validateRESTResponseSpec(request.Response)
	if err != nil {
		return RESTResponse{}, err
	}
	httpRequest, err := client.buildHTTPRequest(ctx, credentials, request, contract.mode)
	if err != nil {
		return RESTResponse{}, err
	}
	response, err := client.client.Do(httpRequest)
	if err != nil {
		var safeCause error
		switch {
		case ctx.Err() != nil:
			safeCause = ctx.Err()
		case errors.Is(err, context.DeadlineExceeded):
			safeCause = context.DeadlineExceeded
		case errors.Is(err, context.Canceled):
			safeCause = context.Canceled
		}
		return RESTResponse{}, Errorf(
			ErrorUpstreamFailure, "provider request failed", safeCause)
	}
	defer func() { _ = response.Body.Close() }()

	body, err := io.ReadAll(io.LimitReader(response.Body, contract.maxBytes+1))
	result := RESTResponse{
		StatusCode: response.StatusCode,
		RawBytes:   int64(len(body)),
	}
	if err != nil {
		return result, Errorf(
			ErrorUpstreamFailure, "provider response was not readable", err)
	}
	if !contract.accepts(response.StatusCode) {
		errorBody := body
		if int64(len(errorBody)) > contract.maxBytes {
			errorBody = errorBody[:contract.maxBytes]
		}
		code := classifyHTTPStatus(response.StatusCode)
		if client.statusClassifier != nil {
			code = client.statusClassifier(response.StatusCode, errorBody)
		}
		if response.StatusCode == http.StatusForbidden &&
			request.Response.ForbiddenAsPermissionDenied {
			code = ErrorPermissionDenied
		}
		return result, Errorf(code, "provider rejected the request", &httpStatusError{
			status: response.StatusCode,
			body:   errorBody,
		})
	}
	if int64(len(body)) > contract.maxBytes {
		return result, Errorf(
			ErrorUpstreamFailure, "provider response exceeded the size budget", nil)
	}

	result.Headers = exposeResponseHeaders(response.Header, contract.headers)
	switch contract.mode {
	case RESTResponseJSON:
		trimmed := bytes.TrimSpace(body)
		if len(trimmed) == 0 {
			result.Body = []byte("null")
			return result, nil
		}
		if !json.Valid(trimmed) {
			return result, Errorf(
				ErrorUpstreamFailure, "provider response was not JSON", nil)
		}
		result.Body = append([]byte(nil), trimmed...)
	case RESTResponseText:
		result.Body = append([]byte(nil), body...)
	case RESTResponseEmpty:
		result.Body = nil
	}
	return result, nil
}

type validatedRESTResponseSpec struct {
	mode     RESTResponseMode
	maxBytes int64
	statuses map[int]struct{}
	headers  []string
}

func validateRESTResponseSpec(
	spec RESTResponseSpec,
) (validatedRESTResponseSpec, error) {
	if spec.Mode != RESTResponseJSON &&
		spec.Mode != RESTResponseText &&
		spec.Mode != RESTResponseEmpty {
		return validatedRESTResponseSpec{}, Errorf(
			ErrorValidation, "provider response mode is invalid", nil)
	}
	maxBytes := spec.MaxBytes
	if maxBytes == 0 {
		maxBytes = MaxResponseBytes
	}
	if maxBytes < 1 || maxBytes > MaxResponseBytes {
		return validatedRESTResponseSpec{}, Errorf(
			ErrorValidation, "provider response size budget is invalid", nil)
	}
	statuses := make(map[int]struct{}, len(spec.SuccessStatuses))
	for _, status := range spec.SuccessStatuses {
		if status < http.StatusOK || status >= http.StatusBadRequest {
			return validatedRESTResponseSpec{}, Errorf(
				ErrorValidation, "provider success status is invalid", nil)
		}
		if _, duplicate := statuses[status]; duplicate {
			return validatedRESTResponseSpec{}, Errorf(
				ErrorValidation, "provider success status is duplicated", nil)
		}
		statuses[status] = struct{}{}
	}
	headers := make([]string, 0, len(spec.ExposedHeaders))
	seenHeaders := make(map[string]struct{}, len(spec.ExposedHeaders))
	for _, name := range spec.ExposedHeaders {
		canonical, ok := exposedProviderResponseHeaders[strings.ToLower(name)]
		if !ok {
			return validatedRESTResponseSpec{}, Errorf(
				ErrorValidation, "provider response header is not exposable", nil)
		}
		if _, duplicate := seenHeaders[canonical]; duplicate {
			return validatedRESTResponseSpec{}, Errorf(
				ErrorValidation, "provider response header is duplicated", nil)
		}
		seenHeaders[canonical] = struct{}{}
		headers = append(headers, canonical)
	}
	return validatedRESTResponseSpec{
		mode:     spec.Mode,
		maxBytes: maxBytes,
		statuses: statuses,
		headers:  headers,
	}, nil
}

func (spec validatedRESTResponseSpec) accepts(status int) bool {
	if len(spec.statuses) == 0 {
		return status >= http.StatusOK && status < http.StatusMultipleChoices
	}
	_, ok := spec.statuses[status]
	return ok
}

var exposedProviderResponseHeaders = map[string]string{
	"location":    "Location",
	"x-jenkins":   "X-Jenkins",
	"x-more-data": "X-More-Data",
	"x-text-size": "X-Text-Size",
}

func exposeResponseHeaders(source http.Header, names []string) http.Header {
	exposed := make(http.Header, len(names))
	for _, name := range names {
		values := source.Values(name)
		if len(values) != 0 {
			exposed[name] = append([]string(nil), values...)
		}
	}
	return exposed
}

func (client *RESTClient) buildHTTPRequest(
	ctx context.Context,
	credentials Credentials,
	request RESTRequest,
	mode RESTResponseMode,
) (*http.Request, error) {
	if request.Body != nil && request.Form != nil {
		return nil, Errorf(
			ErrorValidation, "provider request declares JSON and form bodies", nil)
	}
	method := strings.ToUpper(strings.TrimSpace(request.Method))
	if method == "" {
		method = http.MethodGet
	}
	switch method {
	case http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch,
		http.MethodDelete:
	default:
		return nil, Errorf(
			ErrorValidation, "provider request method is invalid", nil)
	}
	target, err := client.resolveURL(request)
	if err != nil {
		return nil, err
	}

	var bodyReader io.Reader
	contentType := ""
	switch {
	case request.Body != nil:
		encoded, err := json.Marshal(request.Body)
		if err != nil {
			return nil, Errorf(
				ErrorValidation, "provider request body was not encodable", err)
		}
		if len(encoded) > MaxResponseBytes {
			return nil, Errorf(
				ErrorValidation, "provider request body exceeded the size budget", nil)
		}
		bodyReader = bytes.NewReader(encoded)
		contentType = "application/json"
	case request.Form != nil:
		encoded := request.Form.Encode()
		if len(encoded) > MaxResponseBytes {
			return nil, Errorf(
				ErrorValidation, "provider request body exceeded the size budget", nil)
		}
		bodyReader = strings.NewReader(encoded)
		contentType = "application/x-www-form-urlencoded"
	}
	httpRequest, err := http.NewRequestWithContext(
		ctx, method, target.String(), bodyReader)
	if err != nil {
		return nil, Errorf(
			ErrorValidation, "provider request was not buildable", err)
	}
	if contentType != "" {
		httpRequest.Header.Set("Content-Type", contentType)
	}
	if mode == RESTResponseText {
		httpRequest.Header.Set("Accept", "text/plain")
	} else {
		httpRequest.Header.Set("Accept", "application/json")
	}
	httpRequest.Header.Set("User-Agent", client.userAgent)
	for name, value := range request.Headers {
		if err := validateStaticHeader(name); err != nil {
			return nil, Errorf(
				ErrorValidation, "provider request header is invalid", err)
		}
		httpRequest.Header.Set(name, value)
	}
	if err := client.auth.Validate(credentials); err != nil {
		return nil, Errorf(
			ErrorConnectionUnavailable,
			"provider connection credentials are invalid",
			err,
		)
	}
	if err := client.auth.applyAuthentication(httpRequest, credentials); err != nil {
		return nil, Errorf(
			ErrorConnectionUnavailable,
			"provider connection credentials are unusable",
			err,
		)
	}
	return httpRequest, nil
}
