package backend

import (
	"context"
	"errors"
	"fmt"
	"net"
	"net/http"
	"net/netip"
	"net/url"
	"strings"
	"time"
)

var (
	errTenantExtractorEndpointPolicy = errors.New("tenant extractor endpoint rejected by network policy")
	errCustomerEndpointPolicy        = errors.New("customer endpoint rejected by network policy")
)

const (
	extractorDefaultTimeout  = 30 * time.Second
	extractorDialTimeout     = 10 * time.Second
	extractorTLSHSTimeout    = 10 * time.Second
	extractorIdleConnTimeout = 90 * time.Second
	extractorMaxIdleConns    = 100
	extractorMaxConnsPerHost = 16
)

// extractorLookupIP resolves host to IPs. It is a package variable so tests
// can stub DNS resolution; restore the default with t.Cleanup.
var extractorLookupIP = net.DefaultResolver.LookupIP

// ValidateTenantAPIBase validates a tenant-configured extractor endpoint base
// URL. Only https URLs with a host are allowed; userinfo and fragments are
// rejected so the URL cannot smuggle credentials or ambiguous targets.
func ValidateTenantAPIBase(raw string) error {
	u, err := url.Parse(raw)
	if err != nil {
		return fmt.Errorf("parse tenant extractor api base: %w", err)
	}
	if u.Scheme != "https" {
		return fmt.Errorf("tenant extractor api base must use https scheme: %q", raw)
	}
	if u.User != nil {
		return fmt.Errorf("tenant extractor api base must not contain userinfo: %q", raw)
	}
	if u.Fragment != "" {
		return fmt.Errorf("tenant extractor api base must not contain a fragment: %q", raw)
	}
	if u.Host == "" {
		return fmt.Errorf("tenant extractor api base must include a host: %q", raw)
	}
	return nil
}

// NewTenantExtractorHTTPClient builds an http.Client for tenant-configured
// extractor endpoints: HTTPS only (enforced at request time by the wrapping
// RoundTripper, independently of ValidateTenantAPIBase), no env proxies, no
// redirects, and a dialer that rejects non-public resolved IPs. TLS verifies the original hostname
// (the URL host is kept intact and only validated IPs are dialed, so SNI and
// certificate verification stay against the hostname by construction).
func NewTenantExtractorHTTPClient(timeout time.Duration) *http.Client {
	timeout = normalizeSafeHTTPTimeout(timeout)
	return &http.Client{
		Transport: httpsOnlyRoundTripper{next: newSafeHTTPTransport(timeout, extractorDialContext)},
		Timeout:   timeout,
		CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
			return http.ErrUseLastResponse
		},
	}
}

// NewCustomerEndpointHTTPClient builds an http.Client for customer-configured
// URLs such as object-store STS mint. It uses the same SSRF policy as
// NewTenantExtractorHTTPClient (HTTPS only, no env proxies, no redirects,
// dial-time public-IP checks) and additionally allows loopback http/https for
// local STS (MinIO). Redirects are refused in the RoundTripper so callers that
// only swap Transport (Tencent/Aliyun SDKs) still cannot follow Location.
func NewCustomerEndpointHTTPClient(timeout time.Duration) *http.Client {
	timeout = normalizeSafeHTTPTimeout(timeout)
	return &http.Client{
		Transport: customerEndpointRoundTripper{
			public:   httpsOnlyRoundTripper{next: newSafeHTTPTransport(timeout, extractorDialContext)},
			loopback: newSafeHTTPTransport(timeout, loopbackDialContext),
		},
		Timeout: timeout,
		CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
			return http.ErrUseLastResponse
		},
	}
}

func normalizeSafeHTTPTimeout(timeout time.Duration) time.Duration {
	if timeout <= 0 {
		return extractorDefaultTimeout
	}
	return timeout
}

func newSafeHTTPTransport(timeout time.Duration, dial func(context.Context, string, string) (net.Conn, error)) *http.Transport {
	return &http.Transport{
		Proxy:                 nil, // never ProxyFromEnvironment
		DialContext:           dial,
		TLSHandshakeTimeout:   extractorTLSHSTimeout,
		MaxIdleConns:          extractorMaxIdleConns,
		MaxConnsPerHost:       extractorMaxConnsPerHost,
		IdleConnTimeout:       extractorIdleConnTimeout,
		ResponseHeaderTimeout: timeout,
	}
}

// httpsOnlyRoundTripper rejects any request whose URL scheme is not https
// before the wrapped transport is touched. This enforces plain-HTTP rejection
// at request time, so the client stays safe even when a caller skips
// ValidateTenantAPIBase.
type httpsOnlyRoundTripper struct {
	next http.RoundTripper
}

// RoundTrip implements http.RoundTripper.
func (r httpsOnlyRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
	if req.URL.Scheme != "https" {
		return nil, fmt.Errorf("%w: client refuses non-https request: %q", errTenantExtractorEndpointPolicy, req.URL)
	}
	return r.next.RoundTrip(req)
}

// customerEndpointRoundTripper routes loopback URLs to a loopback-only dialer
// and everything else through the HTTPS + public-IP path. It is intentionally
// not an *http.Transport so SDKs that type-assert Transport cannot overwrite
// DialContext or Proxy.
type customerEndpointRoundTripper struct {
	public   http.RoundTripper
	loopback http.RoundTripper
}

// RoundTrip implements http.RoundTripper.
func (r customerEndpointRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
	if req == nil || req.URL == nil {
		return nil, fmt.Errorf("%w: missing request URL", errCustomerEndpointPolicy)
	}
	next := r.public
	if isLoopbackURL(req.URL) {
		next = r.loopback
	}
	resp, err := next.RoundTrip(req)
	if err != nil {
		return resp, err
	}
	if resp != nil && resp.StatusCode >= 300 && resp.StatusCode < 400 {
		if resp.Body != nil {
			_ = resp.Body.Close()
		}
		return nil, fmt.Errorf("%w: refusing HTTP redirect", errCustomerEndpointPolicy)
	}
	return resp, nil
}

func isLoopbackURL(u *url.URL) bool {
	switch strings.ToLower(u.Scheme) {
	case "http", "https":
	default:
		return false
	}
	return isLoopbackHost(u.Hostname())
}

func isLoopbackHost(host string) bool {
	host = strings.Trim(strings.ToLower(strings.TrimSpace(host)), "[]")
	if host == "localhost" {
		return true
	}
	ip := net.ParseIP(host)
	return ip != nil && ip.IsLoopback()
}

func loopbackDialContext(ctx context.Context, network, addr string) (net.Conn, error) {
	host, port, err := net.SplitHostPort(addr)
	if err != nil {
		return nil, fmt.Errorf("split host port %q: %w", addr, err)
	}
	ips, err := resolveLoopbackIPs(ctx, host)
	if err != nil {
		return nil, err
	}
	d := net.Dialer{Timeout: extractorDialTimeout}
	return dialExtractorResolvedIPs(ctx, network, addr, port, ips, d.DialContext)
}

func resolveLoopbackIPs(ctx context.Context, host string) ([]net.IP, error) {
	if ip := net.ParseIP(host); ip != nil {
		if !ip.IsLoopback() {
			return nil, fmt.Errorf("%w: endpoint %s is not loopback", errCustomerEndpointPolicy, host)
		}
		return []net.IP{ip}, nil
	}
	if strings.ToLower(host) != "localhost" {
		return nil, fmt.Errorf("%w: endpoint %s is not loopback", errCustomerEndpointPolicy, host)
	}
	ips, err := extractorLookupIP(ctx, "ip", host)
	if err != nil {
		return nil, fmt.Errorf("resolve loopback endpoint %s: %w", host, err)
	}
	if len(ips) == 0 {
		return nil, fmt.Errorf("loopback endpoint %s resolved to no addresses", host)
	}
	for _, ip := range ips {
		if !ip.IsLoopback() {
			return nil, fmt.Errorf("%w: endpoint %s resolves to non-loopback IP %s", errCustomerEndpointPolicy, host, ip)
		}
	}
	return ips, nil
}

// extractorDialContext resolves the host, rejects it if ANY resolved address
// is non-public, and tries each validated IP. The original hostname stays in
// the request URL, so TLS SNI/cert verification applies to the hostname.
func extractorDialContext(ctx context.Context, network, addr string) (net.Conn, error) {
	host, port, err := net.SplitHostPort(addr)
	if err != nil {
		return nil, fmt.Errorf("split host port %q: %w", addr, err)
	}
	ips, err := extractorResolvePublicIPs(ctx, host)
	if err != nil {
		return nil, err
	}
	d := net.Dialer{Timeout: extractorDialTimeout}
	return dialExtractorResolvedIPs(ctx, network, addr, port, ips, d.DialContext)
}

func dialExtractorResolvedIPs(
	ctx context.Context,
	network string,
	originalAddr string,
	port string,
	ips []net.IP,
	dial func(context.Context, string, string) (net.Conn, error),
) (net.Conn, error) {
	var dialErrors []error
	for i, ip := range ips {
		target := net.JoinHostPort(ip.String(), port)
		attemptTimeout := extractorDialTimeout
		if deadline, ok := ctx.Deadline(); ok {
			remainingAddresses := time.Duration(len(ips) - i)
			if share := time.Until(deadline) / remainingAddresses; share < attemptTimeout {
				attemptTimeout = share
			}
		}
		attemptCtx, cancel := context.WithTimeout(ctx, attemptTimeout)
		conn, err := dial(attemptCtx, network, target)
		cancel()
		if err == nil {
			return conn, nil
		}
		dialErrors = append(dialErrors, fmt.Errorf("%s: %w", target, err))
		if ctx.Err() != nil {
			break
		}
	}
	if len(dialErrors) == 0 {
		return nil, fmt.Errorf("dial extractor endpoint %s: no validated addresses", originalAddr)
	}
	return nil, fmt.Errorf("dial extractor endpoint %s: %w", originalAddr, errors.Join(dialErrors...))
}

// extractorResolvePublicIPs resolves host (or parses it as a literal IP) and
// returns the addresses only if every one of them is public.
func extractorResolvePublicIPs(ctx context.Context, host string) ([]net.IP, error) {
	if ip := net.ParseIP(host); ip != nil {
		if !isPublicIP(ip) {
			return nil, fmt.Errorf("%w: extractor endpoint %s is a non-public IP", errTenantExtractorEndpointPolicy, host)
		}
		return []net.IP{ip}, nil
	}
	ips, err := extractorLookupIP(ctx, "ip", host)
	if err != nil {
		return nil, fmt.Errorf("resolve extractor endpoint %s: %w", host, err)
	}
	if len(ips) == 0 {
		return nil, fmt.Errorf("extractor endpoint %s resolved to no addresses", host)
	}
	for _, ip := range ips {
		if !isPublicIP(ip) {
			return nil, fmt.Errorf("%w: extractor endpoint %s resolves to non-public IP %s", errTenantExtractorEndpointPolicy, host, ip)
		}
	}
	return ips, nil
}

// isPublicIP reports whether ip is a globally routable unicast address.
//
// IPv4 is fail-closed via the IANA special-purpose denylist below
// (loopback, RFC1918, link-local incl. cloud-metadata 169.254.169.254,
// CGNAT, documentation, benchmarking, multicast, reserved, ...).
// IPv4-translation forms are unwrapped and the embedded IPv4 is
// re-validated: IPv4-mapped addresses via netip.Unmap, the NAT64
// Well-Known Prefix (64:ff9b::/96) by extracting the low 32 bits — a
// translated loopback/private/metadata address is still rejected while a
// translated public address stays dialable. The RFC 8215 local-use prefix
// 64:ff9b:1::/48 is never unwrapped (its layout is network-defined) and
// is rejected outright.
//
// IPv6 is fail-closed by allowlist: after the NAT64 unwrap (which must
// run first because 64:ff9b::/96 lies outside 2000::/3), only addresses
// within the allocated global-unicast space 2000::/3 are eligible —
// IANA non-global/unallocated space is rejected even when no denylist
// entry names it — and the non-global special prefixes inside 2000::/3
// (all of 2001::/23, documentation, 6to4, SRv6 SIDs, ...) stay denied
// via the list below.
func isPublicIP(ip net.IP) bool {
	addr, ok := netip.AddrFromSlice(ip)
	if !ok {
		return false
	}
	return isPublicAddr(addr)
}

func isPublicAddr(addr netip.Addr) bool {
	addr = addr.Unmap()
	// NAT64 Well-Known Prefix (RFC 6052): unwrap the embedded IPv4 and
	// re-validate it. This must run before the 2000::/3 gate because
	// 64:ff9b::/96 lies outside the allocated global-unicast space.
	if addr.Is6() && extractorNAT64Prefix.Contains(addr) {
		b := addr.As16()
		return isPublicAddr(netip.AddrFrom4([4]byte{b[12], b[13], b[14], b[15]}))
	}
	// Fail-closed for IPv6: only the allocated global-unicast space is
	// eligible; everything outside 2000::/3 is rejected.
	if addr.Is6() && !extractorIPv6GlobalUnicastPrefix.Contains(addr) {
		return false
	}
	for _, p := range extractorNonPublicPrefixes {
		if p.Contains(addr) {
			return false
		}
	}
	return true
}

// extractorNAT64Prefix is the NAT64 Well-Known Prefix (RFC 6052). It is
// handled by unwrapping (see isPublicAddr), not by the denylist, so NAT64
// forms of public IPv4 endpoints remain reachable. The RFC 8215 local-use
// prefix 64:ff9b:1::/48 is NOT unwrapped — its embedded layout is
// network-defined — and is rejected outright via the denylist below.
var extractorNAT64Prefix = netip.MustParsePrefix("64:ff9b::/96")

// extractorIPv6GlobalUnicastPrefix is the IANA-allocated IPv6 global
// unicast space. IPv6 classification is fail-closed against it: any IPv6
// address outside this range (after the NAT64 unwrap) is rejected, so
// non-global/unallocated space can never slip through by omission.
var extractorIPv6GlobalUnicastPrefix = netip.MustParsePrefix("2000::/3")

// extractorNonPublicPrefixes is the IANA special-purpose denial list for
// tenant-configured extractor endpoints. For IPv4 it is the primary
// fail-closed mechanism and covers every special-purpose registry entry
// that is not globally reachable (Globally Reachable = False, N/A, or
// deprecated). For IPv6 it is the secondary layer: the 2000::/3 allowlist
// gate above rejects everything outside the allocated global-unicast
// space, and this list denies the non-global special prefixes inside it
// (entries outside 2000::/3 are redundant with the gate and kept for
// documentation value):
//   - 0.0.0.0/8: "this network" (RFC 791).
//   - 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16: private (RFC 1918).
//   - 100.64.0.0/10: CGNAT shared address space (RFC 6598). Aliyun ACK
//     instance metadata lives at 100.100.100.200 inside this range.
//   - 127.0.0.0/8: loopback (RFC 1122).
//   - 169.254.0.0/16: link-local (RFC 3927); cloud metadata 169.254.169.254.
//   - 192.0.0.0/24: IETF protocol assignments (RFC 6890).
//   - 192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24: documentation
//     TEST-NET-1/2/3 (RFC 5737).
//   - 192.88.99.0/24: deprecated 6to4 relay anycast (RFC 7526).
//   - 198.18.0.0/15: benchmarking (RFC 2544).
//   - 224.0.0.0/4: multicast (RFC 5771).
//   - 240.0.0.0/4: reserved (RFC 1112), incl. 255.255.255.255 limited
//     broadcast (RFC 919).
//   - ::/128: unspecified; ::1/128: loopback (RFC 4291). The IPv4-mapped
//     ::ffff:0:0/96 form is unwrapped via netip.Unmap and re-validated as
//     IPv4; the NAT64 Well-Known Prefix 64:ff9b::/96 (RFC 6052) is likewise
//     unwrapped (see extractorNAT64Prefix above).
//   - 64:ff9b:1::/48: RFC 8215 local-use IPv4/IPv6 translation — rejected
//     outright; the embedded IPv4 layout is network-defined, so unwrapping
//     it would mis-classify addresses.
//   - 100::/64: discard-only (RFC 6666); 100:0:0:1::/64: dummy prefix
//     (RFC 9780).
//   - 2001::/23: IETF protocol assignments (RFC 2928), rejected outright
//     rather than enumerating its few globally-reachable exceptions —
//     acceptable for tenant LLM endpoints. It subsumes Teredo 2001::/32
//     (RFC 4380), benchmarking 2001:2::/48 (RFC 5180), deprecated ORCHID
//     2001:10::/28 (RFC 4843) and ORCHIDv2 2001:20::/28 (RFC 7343).
//   - 2001:db8::/32: documentation (RFC 3849).
//   - 2002::/16: 6to4 (RFC 3056), deprecated and no longer globally
//     reachable (RFC 7526).
//   - 3fff::/20: documentation (RFC 9637); 5f00::/16: SRv6 SIDs (RFC 9602).
//   - fc00::/7: unique local (RFC 4193); fe80::/10: link-local;
//     ff00::/8: multicast (RFC 4291).
var extractorNonPublicPrefixes = []netip.Prefix{
	netip.MustParsePrefix("0.0.0.0/8"),
	netip.MustParsePrefix("10.0.0.0/8"),
	netip.MustParsePrefix("100.64.0.0/10"),
	netip.MustParsePrefix("127.0.0.0/8"),
	netip.MustParsePrefix("169.254.0.0/16"),
	netip.MustParsePrefix("172.16.0.0/12"),
	netip.MustParsePrefix("192.0.0.0/24"),
	netip.MustParsePrefix("192.0.2.0/24"),
	netip.MustParsePrefix("192.88.99.0/24"),
	netip.MustParsePrefix("192.168.0.0/16"),
	netip.MustParsePrefix("198.18.0.0/15"),
	netip.MustParsePrefix("198.51.100.0/24"),
	netip.MustParsePrefix("203.0.113.0/24"),
	netip.MustParsePrefix("224.0.0.0/4"),
	netip.MustParsePrefix("240.0.0.0/4"),
	netip.MustParsePrefix("::/128"),
	netip.MustParsePrefix("::1/128"),
	netip.MustParsePrefix("64:ff9b:1::/48"),
	netip.MustParsePrefix("100::/64"),
	netip.MustParsePrefix("100:0:0:1::/64"),
	netip.MustParsePrefix("2001::/23"),
	netip.MustParsePrefix("2001:db8::/32"),
	netip.MustParsePrefix("2002::/16"),
	netip.MustParsePrefix("3fff::/20"),
	netip.MustParsePrefix("5f00::/16"),
	netip.MustParsePrefix("fc00::/7"),
	netip.MustParsePrefix("fe80::/10"),
	netip.MustParsePrefix("ff00::/8"),
}
