// Package http_client provides two ports.HTTPClient implementations that share
// the same url_safety SSoT (URL allowlist, Redact, RedactError) but differ in
// transport policy:
//
//   - OutboundClient: direct-only (Proxy: nil), IP-pinned dialer with Happy
//     Eyeballs (RFC 8305 simplified). Used by connector_callout.Engine for
//     vendor API calls over public internet where dial-time IP pinning closes
//     the DNS-rebind window.
//
//   - WebhookClient: proxy-aware (http.ProxyFromEnvironment), no IP-pinned
//     dial (proxy CONNECT defeats it anyway), 30s client-level Timeout. Used
//     by ActionService.executeCallWebhook for admin-configured webhook targets
//     that may need to traverse a corporate egress proxy.
//
// Why split: proxy mode and dial-time IP pinning are mutually exclusive on the
// same *http.Client — either the dial-time defense or proxy support has to give
// for a given consumer. The SSoT is the url_safety package + allowlist contract;
// transport policy is per-consumer.
//
// Redirect policy (both adapters, same contract):
//
//   - Cross-host redirects: REFUSED unconditionally. Any part of the initial
//     URL (path / query / userinfo) and any header can carry credentials in
//     conventions we cannot enumerate (Slack-style path tokens, ?api_key=,
//     userinfo, vendor-specific header names). Go's stdlib auto-injects Referer
//     on redirect, so a cross-host hop would leak the full initial URL to the
//     new origin. The only provably-complete defense is to refuse the class.
//   - HTTPS→HTTP scheme downgrade: REFUSED unconditionally (plaintext leak).
//   - Same-host same-scheme redirects (path normalization, 301 to canonical):
//     ALLOWED — cannot leak via Referer to a different origin.
//
// Wire-up: application/services/service_manager_init.go constructs
// url_safety.NewAllowlist() once, then NewOutboundClient(allowlist) for
// connector_callout.Engine and NewWebhookClient(allowlist) for ActionService.
package http_client

import (
	"context"
	"fmt"
	"net"
	"net/http"
	"strings"
	"sync"
	"time"

	"github.com/nexuscrm/kernel/pkg/ports"
	urlsafety "github.com/nexuscrm/outbound/url_safety"
)

// outboundClient is the direct-only, IP-pinned ports.HTTPClient implementation
// used by connector_callout.Engine. Construct via NewOutboundClient.
type outboundClient struct {
	client    *http.Client
	allowlist ports.URLAllowlist
}

// Compile-time assertion: outboundClient implements ports.HTTPClient.
var _ ports.HTTPClient = (*outboundClient)(nil)
var _ http.RoundTripper = (*outboundClient)(nil)

// NewOutboundClient constructs the direct-only, IP-pinned outbound HTTP adapter
// for connector_callout.Engine. allowlist must be non-nil; production callers
// pass url_safety.NewAllowlist().
func NewOutboundClient(allowlist ports.URLAllowlist) ports.HTTPClient {
	return newOutboundClient(allowlist)
}

// NewOutboundRoundTripper exposes the same direct-only, IP-pinned transport
// policy without the redirect behavior owned by http.Client. Credential-bearing
// callers use this seam when one RoundTrip must equal at most one HTTP request.
func NewOutboundRoundTripper(allowlist ports.URLAllowlist) http.RoundTripper {
	return newOutboundClient(allowlist)
}

func newOutboundClient(allowlist ports.URLAllowlist) *outboundClient {
	a := &outboundClient{allowlist: allowlist}
	transport := &http.Transport{
		Proxy:       nil, // direct-only by design; see package comment
		DialContext: a.safeDialContext,
		// ForceAttemptHTTP2 is true by default in Go's http.DefaultTransport
		// but must be set explicitly when DialContext is overridden.
		ForceAttemptHTTP2:     true,
		MaxIdleConns:          100,
		IdleConnTimeout:       90 * time.Second,
		TLSHandshakeTimeout:   10 * time.Second,
		ExpectContinueTimeout: 1 * time.Second,
	}
	a.client = &http.Client{
		Transport:     transport,
		CheckRedirect: a.checkRedirect,
		// No client-level Timeout: callers control deadline via context
		// (Engine applies per-attempt ctx; ActionService passes request ctx).
	}
	return a
}

// RoundTrip performs one allowlisted transaction through the same IP-pinned
// transport used by OutboundClient. It deliberately bypasses http.Client.Do so
// redirects are returned to the caller instead of being followed.
func (a *outboundClient) RoundTrip(req *http.Request) (*http.Response, error) {
	if a == nil || a.allowlist == nil || a.client == nil || a.client.Transport == nil ||
		req == nil || req.URL == nil {
		closeRoundTripRequestBody(req)
		return nil, fmt.Errorf("outbound round trip is unavailable")
	}
	if err := a.allowlist.EnsureAllowed(req.URL.String()); err != nil {
		closeRoundTripRequestBody(req)
		return nil, err
	}
	return a.client.Transport.RoundTrip(req)
}

func closeRoundTripRequestBody(req *http.Request) {
	if req != nil && req.Body != nil {
		_ = req.Body.Close()
	}
}

// Do runs a url_safety preflight on req.URL, then dispatches via the
// cached inner http.Client. The inner client applies IP-pinned dialing and
// unconditional cross-host / scheme-downgrade redirect refusal on every hop.
func (a *outboundClient) Do(req *http.Request) (*http.Response, error) {
	if err := a.allowlist.EnsureAllowed(req.URL.String()); err != nil {
		return nil, err
	}
	return a.client.Do(req)
}

// checkRedirect is wired as http.Client.CheckRedirect. It applies the
// allowlist to every redirect target and refuses cross-host and scheme-downgrade
// redirects unconditionally.
//
// Rationale: any part of the initial URL (path / query / userinfo) and any
// header on the initial request can carry credentials, in conventions we
// cannot reliably enumerate (Slack-style path tokens /services/T/B/SECRET;
// ?api_key=...; user:pass@host; vendor-specific header names). Go's stdlib
// auto-injects Referer on redirect, so a cross-host hop can leak any URL part
// to the new origin. The only provably-complete defense is to refuse the
// dangerous redirect class.
//
// Same-host redirects (path normalization, 301 to canonical URL) are permitted
// because they cannot leak via Referer to a different origin.
func (a *outboundClient) checkRedirect(req *http.Request, via []*http.Request) error {
	if len(via) >= 10 {
		return fmt.Errorf("stopped after 10 redirects")
	}
	if len(via) == 0 {
		return nil
	}
	initial := via[0].URL
	if !strings.EqualFold(req.URL.Host, initial.Host) {
		return fmt.Errorf("cross-host redirect refused: %q → %q", initial.Host, req.URL.Host)
	}
	if strings.EqualFold(initial.Scheme, "https") && !strings.EqualFold(req.URL.Scheme, "https") {
		return fmt.Errorf("https→%s scheme downgrade refused on redirect from %q", req.URL.Scheme, initial.Host)
	}
	// Apply allowlist to the redirect target URL.
	return a.allowlist.EnsureAllowed(req.URL.String())
}

// safeDialContext is the DialContext for the inner transport. It resolves
// the hostname, validates every returned IP via urlsafety.IsIPAllowed,
// then dials the first allowed IP with Happy Eyeballs fallback (RFC 8305
// simplified). This closes the DNS-rebinding window between the preflight
// EnsureAllowed call (which resolves IPs at request time) and the dial
// (which resolves IPs again inside the transport).
func (a *outboundClient) safeDialContext(ctx context.Context, network, addr string) (net.Conn, error) {
	host, port, err := net.SplitHostPort(addr)
	if err != nil {
		return nil, fmt.Errorf("safe dialer: split host/port %q: %w", addr, err)
	}
	// If the host is already an IP literal, validate and dial directly.
	if ip := net.ParseIP(host); ip != nil {
		if !urlsafety.IsIPAllowed(ip) {
			return nil, fmt.Errorf("webhook URLs must not target internal addresses")
		}
		dialer := &net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second}
		return dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
	}
	// Hostname → resolve, validate ALL returned IPs (fail-closed), then dial.
	ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
	if err != nil {
		return nil, fmt.Errorf("safe dialer: resolve %q: %w", host, err)
	}
	if len(ips) == 0 {
		return nil, fmt.Errorf("safe dialer: no IPs for %q", host)
	}
	// Fail-closed all-or-nothing: reject if ANY returned IP is disallowed.
	for _, ipa := range ips {
		if !urlsafety.IsIPAllowed(ipa.IP) {
			return nil, fmt.Errorf("webhook URLs must not target internal addresses")
		}
	}
	return dialAllowedIPs(ctx, network, ips, port)
}

// defaultDialFunc is the production dial function used by dialAllowedIPs.
// Tests override this package-level variable to inject fake dialers without
// touching any global net state.
var defaultDialFunc = func(ctx context.Context, network, addr string) (net.Conn, error) {
	d := &net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second}
	return d.DialContext(ctx, network, addr)
}

// dialAllowedIPs attempts to connect to each IP in ips with a 100ms stagger
// between attempts (RFC 8305 Happy Eyeballs simplified). The first successful
// connection wins; remaining dialers are cancelled. ips MUST already be
// pre-validated by the caller.
func dialAllowedIPs(ctx context.Context, network string, ips []net.IPAddr, port string) (net.Conn, error) {
	if len(ips) == 0 {
		return nil, fmt.Errorf("safe dialer: no IPs")
	}
	if len(ips) == 1 {
		addr := net.JoinHostPort(ips[0].IP.String(), port)
		return defaultDialFunc(ctx, network, addr)
	}

	type dialResult struct {
		conn net.Conn
		err  error
	}
	results := make(chan dialResult, len(ips))
	dialCtx, cancel := context.WithCancel(ctx)
	defer cancel()

	// Capture the current dial function once before spawning goroutines to
	// avoid a data race between goroutines reading defaultDialFunc and test
	// cleanup restoring it via t.Cleanup.
	dialFn := defaultDialFunc

	var wg sync.WaitGroup
	for i, ipa := range ips {
		wg.Add(1)
		go func(i int, ip net.IP) {
			defer wg.Done()
			if i > 0 {
				select {
				case <-dialCtx.Done():
					return
				case <-time.After(time.Duration(i) * 100 * time.Millisecond):
				}
			}
			addr := net.JoinHostPort(ip.String(), port)
			conn, err := dialFn(dialCtx, network, addr)
			results <- dialResult{conn: conn, err: err}
		}(i, ipa.IP)
	}

	var lastErr error
	won := false
	for i := 0; i < len(ips); i++ {
		select {
		case <-ctx.Done():
			cancel()
			return nil, ctx.Err()
		case r := <-results:
			if r.err == nil {
				if won {
					_ = r.conn.Close()
					continue
				}
				won = true
				cancel()
				go func() {
					wg.Wait()
					close(results)
					for extra := range results {
						if extra.conn != nil {
							_ = extra.conn.Close()
						}
					}
				}()
				return r.conn, nil
			}
			lastErr = r.err
		}
	}
	return nil, fmt.Errorf("safe dialer: all candidates failed: %w", lastErr)
}
