package connector_callout

import (
	"bytes"
	"context"
	"errors"
	"fmt"
	"io"
	"log"
	"net/http"
	"net/url"
	"strings"
	"time"

	"github.com/pingcap-inc/nexuscrm-connectors/features/namedcredential"
)

const defaultMaxResponseBytes = 10 * 1024 * 1024 // 10 MB

// Throttler is the subset of connector_throttle.Service consumed by Engine.
// The concrete implementation is *connector_throttle.Service.
type Throttler interface {
	// Throttle reserves one token for (credentialRef, endpointClass). The caller
	// must defer release(). If wait > 0, the caller must wait before proceeding.
	Throttle(ctx context.Context, credentialRef, endpointClass string) (wait time.Duration, release func(), err error)
}

// CredentialResolver resolves outbound-callout credentials by DeveloperName.
// *namedcredential.Service satisfies this interface.
type CredentialResolver interface {
	// ResolveEndpoint returns the base endpoint URL for devName.
	ResolveEndpoint(ctx context.Context, devName string) (string, error)
	// ResolveCredentialValue returns the plaintext secret of kind for devName.
	ResolveCredentialValue(ctx context.Context, devName, kind string) (string, error)
	// ResolveAuthType returns the AuthType string for devName (e.g. "BearerToken",
	// "BasicAuth", "OAuth2", "Custom"). Used by the engine to select the correct
	// Authorization scheme; never logged.
	ResolveAuthType(ctx context.Context, devName string) (string, error)
}

// Engine executes outbound HTTP callouts against vendor-neutral profiles.
// Construct via NewEngine; zero value is not usable.
//
// HTTPClient must be injected (non-nil). The production implementation is
// infrastructure/http_client.OutboundClient, which provides SSRF preflight,
// IP-pinned dialing, redirect validation, and credential-aware redirect checks.
// Tests inject a mock or a permissive adapter via the constructor.
type Engine struct {
	// HTTPClient is used for all outbound requests. Must be non-nil.
	// Inject infrastructure/http_client.NewOutboundClient(allowlist) in production.
	HTTPClient HTTPClient

	// Allowlist is used for log-safe URL redaction in outbound requests.
	// Must be non-nil; pass testAllowlistForEngine() in production.
	Allowlist URLAllowlist

	// Throttler applies per-(credentialRef, endpointClass) rate limiting.
	Throttler Throttler

	// Credentials resolves endpoint URLs and bearer tokens.
	Credentials CredentialResolver

	// Now is the clock used for retry-after calculation. Defaults to time.Now.
	Now func() time.Time

	// MaxResponseBytes caps the body read per response. Defaults to 10 MB.
	MaxResponseBytes int64

	// SecretKind is the NamedCredential secret kind fetched as the bearer token.
	// Defaults to "Token".
	SecretKind string

	// Logger receives debug-level diagnostic messages. Never logs token or
	// Authorization values. May be nil (silent).
	Logger *log.Logger
}

// NewEngine constructs an Engine with required dependencies.
// httpClient must be non-nil; pass infrastructure/http_client.NewOutboundClient(allowlist)
// in production. allowlist must be non-nil; pass testAllowlistForEngine() in production.
// Tests may inject mocks or permissive adapters.
func NewEngine(credentials CredentialResolver, throttler Throttler, httpClient HTTPClient, allowlist URLAllowlist) *Engine {
	return &Engine{
		Credentials: credentials,
		Throttler:   throttler,
		HTTPClient:  httpClient,
		Allowlist:   allowlist,
	}
}

func (e *Engine) maxResponseBytes() int64 {
	if e.MaxResponseBytes > 0 {
		return e.MaxResponseBytes
	}
	return defaultMaxResponseBytes
}

func (e *Engine) secretKind() string {
	if e.SecretKind != "" {
		return e.SecretKind
	}
	return "Token"
}

func (e *Engine) now() time.Time {
	if e.Now != nil {
		return e.Now()
	}
	return time.Now()
}

func (e *Engine) logf(format string, args ...any) {
	if e.Logger != nil {
		e.Logger.Printf(format, args...)
	}
}

// Do executes req according to policy, retrying on transient failures.
//
// Steps:
//  1. Resolve base endpoint via Credentials.ResolveEndpoint.
//  2. Resolve AuthType via Credentials.ResolveAuthType.
//  3. Compose full URL = base + RelativePath; apply QueryParams.
//  4. SSRF guard via safehttp.EnsureAllowedExternalURL.
//  5. Attempt loop: throttle → build request → applyAuthorization → send →
//     read bounded body → decide retry or terminal.
//
// Content-Type: Do does NOT auto-set Content-Type because arbitrary callers
// may send non-JSON bodies (form data, binary, etc.). Callers that send JSON
// must set req.Headers["Content-Type"] = "application/json" themselves.
// Engine.BatchRead sets this header automatically for its own JSON-marshaled chunks.
//
// Authorization header is never logged. applyAuthorization selects the correct
// Authorization scheme based on the NamedCredential's declared AuthType:
// BearerToken/OAuth2/"" → Bearer token; BasicAuth → HTTP Basic; Custom → no header.
func (e *Engine) Do(ctx context.Context, req CalloutRequest, policy RetryPolicy) (CalloutResponse, error) {
	// 0. Pre-flight: RelativePath must not embed a query string or fragment.
	// Each would confuse URL composition or bypass log redaction
	// (?key=secret, #token=secret). Use CalloutRequest.QueryParams for query
	// parameters, CalloutRequest.Headers for authentication headers.
	// Note: `@` in path segments (e.g. /users/alice@example.com) is allowed —
	// the composed-URL userinfo check below rejects actual authority shifts.
	if strings.ContainsAny(req.RelativePath, "?#") {
		return CalloutResponse{}, fmt.Errorf("%w: RelativePath must not include `?` query or `#` fragment (use CalloutRequest.QueryParams)", ErrInvalidRequest)
	}

	// 1. Resolve base endpoint.
	base, err := e.Credentials.ResolveEndpoint(ctx, req.CredentialRef)
	if err != nil {
		return CalloutResponse{}, fmt.Errorf("%w: %v", ErrEndpointUnavailable, err)
	}

	// 2. Resolve AuthType — determines the Authorization scheme applied per-request.
	authType, err := e.Credentials.ResolveAuthType(ctx, req.CredentialRef)
	if err != nil {
		return CalloutResponse{}, fmt.Errorf("%w: %v", ErrCredentialUnavailable, err)
	}

	// 3. Compose full URL — path first, then query string via url.Values.Encode
	// so values with reserved characters (e.g. cursor=abc&def, page=q a b) are
	// percent-encoded canonically instead of corrupting the URL grammar.
	fullURL := strings.TrimRight(base, "/")
	if req.RelativePath != "" {
		if !strings.HasPrefix(req.RelativePath, "/") {
			fullURL += "/"
		}
		fullURL += req.RelativePath
	}
	if len(req.QueryParams) > 0 {
		sep := "?"
		if strings.Contains(fullURL, "?") {
			sep = "&"
		}
		fullURL += sep + req.QueryParams.Encode()
	}

	// 3b. Post-composition userinfo guard. Parse the composed URL to detect
	// any authority shift introduced by userinfo syntax (user:pass@host or
	// user@host). A bare `@` in a path segment (e.g. /users/alice@example.com)
	// does NOT produce a non-nil url.User, so legitimate email-like paths are
	// allowed while actual credential injection is rejected.
	parsedURL, err := url.Parse(fullURL)
	if err != nil {
		return CalloutResponse{}, fmt.Errorf("%w: composed URL is not parseable: %v", ErrInvalidRequest, err)
	}
	if parsedURL.User != nil {
		return CalloutResponse{}, fmt.Errorf("%w: composed URL must not include userinfo (caller-supplied authority)", ErrInvalidRequest)
	}

	// 4. SSRF guard is applied by HTTPClient.Do (url_safety preflight + IP-pinned
	// dialer). The composed URL is validated there before any network connection.

	// 4b. HTTPS-only gate for credentialed auth types.
	// BearerToken, OAuth2, BasicAuth, and the empty-string default-to-Bearer path
	// all inject server-known secrets; sending them over plain HTTP is a live
	// credential exfiltration path. Custom auth injects no server-known secret
	// and remains http-permissible so callers retain full control over their own
	// auth mechanism. This gate runs after the SSRF check and before the retry
	// loop so that Authorization is never applied to an http:// destination.
	if err := requireHTTPSForCredentialedAuth(authType, fullURL); err != nil {
		return CalloutResponse{}, err
	}

	method := strings.ToUpper(strings.TrimSpace(req.Method))
	if method == "" {
		method = "GET"
	}

	maxAttempts := policy.maxAttempts()
	var lastResp CalloutResponse
	var lastErr error

	for attempt := 1; attempt <= maxAttempts; attempt++ {
		// Each attempt is wrapped in an immediately-invoked closure so that
		// defer release() fires at the end of the attempt (closure return),
		// not at Do() return. Without the closure, Go's defer semantics would
		// hold all N throttle tokens until Do() exits, consuming N slots
		// concurrently for a single logical call and starving other callers
		// when the concurrency limit ≤ maxAttempts.
		type attemptResult struct {
			resp        CalloutResponse
			err         error
			shouldRetry bool
			delay       time.Duration
		}
		ar := func() attemptResult {
			// 5a. Throttle.
			wait, release, err := e.Throttler.Throttle(ctx, req.CredentialRef, req.EndpointClass)
			if err != nil {
				return attemptResult{err: err}
			}
			defer release() // fires at closure return = at iteration end

			if wait > 0 {
				select {
				case <-ctx.Done():
					return attemptResult{err: ctx.Err()}
				case <-time.After(wait):
				}
			}

			// 5b. Build HTTP request.
			//
			// Per CalloutRequest godoc: Body is ignored for GET and HEAD (HTTP semantics
			// expect no body on these methods; some proxies/servers reject GET-with-body
			// outright). Method is normalized to upper-case earlier.
			var bodyReader io.Reader
			if len(req.Body) > 0 && method != http.MethodGet && method != http.MethodHead {
				bodyReader = bytes.NewReader(req.Body)
			}
			httpReq, err := http.NewRequestWithContext(ctx, method, fullURL, bodyReader)
			if err != nil {
				return attemptResult{err: fmt.Errorf("build request: %w", err)}
			}
			// Copy custom headers.
			for k, vs := range req.Headers {
				for _, v := range vs {
					httpReq.Header.Add(k, v)
				}
			}
			// Apply Authorization based on the NamedCredential's declared AuthType.
			// NEVER log any credential value set here.
			if err := e.applyAuthorization(ctx, httpReq, authType, req.CredentialRef); err != nil {
				return attemptResult{err: err}
			}

			// 5c. Send.
			//
			// Log the fully composed URL through allowlist.Redact — this scrubs
			// query string, fragment, and userinfo so secrets cannot leak even when
			// the caller bypasses CalloutRequest.QueryParams. The Authorization
			// header is set on httpReq above but never written to the logger.
			//
			// Credential-aware redirect guard: the injected HTTPClient adapter
			// (infrastructure/http_client.OutboundClient) enforces both the SSRF
			// allowlist and credential-aware redirect checks (scheme downgrade +
			// cross-host rejection when Authorization is present) on every hop.
			// No per-authType client wrapping is needed here.
			e.logf("connector_callout: attempt %d/%d method=%s url=%s", attempt, maxAttempts, method, e.Allowlist.Redact(fullURL))
			resp, err := e.HTTPClient.Do(httpReq)
			if err != nil {
				// Redact any *url.Error so a secret in the composed URL (e.g.
				// ?api_key=...) does not surface in error messages via Go's stdlib
				// *url.Error.Error() which embeds the raw URL string verbatim.
				err = e.Allowlist.RedactError(err)
				if attempt < maxAttempts {
					delay := delayForAttempt(&policy, attempt, 0)
					return attemptResult{err: err, shouldRetry: true, delay: delay}
				}
				return attemptResult{err: err}
			}

			// 5d. Read bounded body. Use max+1 so we can detect overflow vs exact-fit.
			limit := e.maxResponseBytes()
			bodyBytes, readErr := io.ReadAll(io.LimitReader(resp.Body, limit+1))
			overLimit := int64(len(bodyBytes)) > limit
			if overLimit {
				// Drain a small cap of remaining bytes so the transport can reuse
				// the connection. Without this, an oversized response burns one
				// socket per attempt and defeats the cached-client pool (B45).
				_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4<<10))
			}
			_ = resp.Body.Close()
			if readErr != nil {
				return attemptResult{err: fmt.Errorf("read response body: %w", readErr)}
			}
			if overLimit {
				return attemptResult{err: fmt.Errorf("%w: max=%d", ErrResponseTooLarge, limit)}
			}
			cr := CalloutResponse{
				StatusCode: resp.StatusCode,
				Header:     resp.Header,
				Body:       bodyBytes,
			}

			// 5e. Success.
			if resp.StatusCode < 400 {
				return attemptResult{resp: cr}
			}

			// 5f. Error handling.
			if isRetryableStatus(&policy, resp.StatusCode) {
				// Always set shouldRetry=true for retryable statuses so the outer
				// loop accumulates lastResp/lastErr and exits via ErrMaxAttemptsExceeded
				// when all attempts are exhausted. On the final attempt the delay is 0
				// (no backoff needed) and the loop exits normally after the continue.
				var delay time.Duration
				if attempt < maxAttempts {
					// Honor Retry-After ONLY on 429 (issue spec: "honor `Retry-After`
					// on 429"). Other retryable statuses (5xx) use pure exponential
					// backoff so a misbehaving upstream cannot stall the engine for
					// an unbounded amount of time via a Retry-After header on a 503.
					var retryAfter time.Duration
					if resp.StatusCode == http.StatusTooManyRequests {
						retryAfter = parseRetryAfter(resp.Header.Get("Retry-After"), e.now())
					}
					delay = delayForAttempt(&policy, attempt, retryAfter)
				}
				return attemptResult{resp: cr, err: fmt.Errorf("status %d (retryable)", resp.StatusCode), shouldRetry: true, delay: delay}
			}

			// Non-retryable error status.
			return attemptResult{resp: cr, err: fmt.Errorf("%w: status %d", ErrTerminalNonRetryable, resp.StatusCode)}
		}()

		if !ar.shouldRetry {
			if ar.err != nil {
				if ar.resp.StatusCode != 0 {
					return ar.resp, ar.err
				}
				return CalloutResponse{}, ar.err
			}
			return ar.resp, nil
		}
		// Retryable: apply delay then continue.
		lastResp = ar.resp
		lastErr = ar.err
		if ar.delay > 0 {
			select {
			case <-ctx.Done():
				return CalloutResponse{}, ctx.Err()
			case <-time.After(ar.delay):
			}
		}
	}

	if lastErr != nil {
		return lastResp, errors.Join(ErrMaxAttemptsExceeded, lastErr)
	}
	return lastResp, ErrMaxAttemptsExceeded
}

// requireHTTPSForCredentialedAuth fails closed if the resolved AuthType would
// attach server-known credentials (Bearer / OAuth2 / Basic / empty =
// default-Bearer) over plain HTTP. Custom auth does not inject server-known
// secrets and is therefore exempt.
//
// This guard is applied after the SSRF gate and before the retry loop so that
// credentialed requests are blocked before any Authorization header is set,
// preventing plaintext credential egress to an http:// destination.
func requireHTTPSForCredentialedAuth(authType, fullURL string) error {
	switch authType {
	case namedcredential.AuthTypeBearerToken,
		namedcredential.AuthTypeOAuth2,
		namedcredential.AuthTypeBasicAuth,
		"": // empty string defaults to BearerToken (backward compat) — must also require HTTPS
		parsed, err := url.Parse(fullURL)
		if err != nil {
			return fmt.Errorf("%w: %v", ErrInvalidRequest, err)
		}
		if !strings.EqualFold(parsed.Scheme, "https") {
			return fmt.Errorf("%w: credentialed AuthType %q forbids plain HTTP destination", ErrCredentialedEndpointMustBeHTTPS, authType)
		}
	case namedcredential.AuthTypeCustom:
		// Custom auth does not inject server-known credentials; http is permissible.
	}
	return nil
}

// applyAuthorization sets the Authorization header on httpReq based on the
// NamedCredential's declared AuthType. It is called once per attempt, after
// building the request but before sending it, so that retry attempts also carry
// fresh credentials.
//
// AuthType semantics:
//   - BearerToken / OAuth2 / "" → Authorization: Bearer <token>
//     (empty string is treated as BearerToken for backward compatibility with
//     credentials created before AuthType was enforced)
//   - BasicAuth                 → Authorization: Basic <base64(user:pass)>
//     using kinds "Username" and "Password"
//   - Custom                    → no Authorization header; caller is responsible
//     via CalloutRequest.Headers or a vendor-specific query parameter
//   - anything else             → ErrUnsupportedAuthType
func (e *Engine) applyAuthorization(ctx context.Context, httpReq *http.Request, authType, devName string) error {
	switch authType {
	case namedcredential.AuthTypeBearerToken, namedcredential.AuthTypeOAuth2, "":
		token, err := e.Credentials.ResolveCredentialValue(ctx, devName, e.secretKind())
		if err != nil {
			return fmt.Errorf("%w: %v", ErrCredentialUnavailable, err)
		}
		httpReq.Header.Set("Authorization", "Bearer "+token)
	case namedcredential.AuthTypeBasicAuth:
		user, err := e.Credentials.ResolveCredentialValue(ctx, devName, "Username")
		if err != nil {
			return fmt.Errorf("%w: BasicAuth username: %v", ErrCredentialUnavailable, err)
		}
		pass, err := e.Credentials.ResolveCredentialValue(ctx, devName, "Password")
		if err != nil {
			return fmt.Errorf("%w: BasicAuth password: %v", ErrCredentialUnavailable, err)
		}
		httpReq.SetBasicAuth(user, pass)
	case namedcredential.AuthTypeCustom:
		// Caller is responsible for Authorization via CalloutRequest.Headers.
		// Engine does NOT set Authorization in Custom mode.
	default:
		return fmt.Errorf("%w: %q", ErrUnsupportedAuthType, authType)
	}
	return nil
}
