// Package url_safety provides the shared SSRF allowlist for all L0 outbound
// HTTP traffic. It is the single source of truth for IP and URL admission
// decisions across the platform; no feature package should implement its own
// independent allowlist.
//
// Exported surface:
//   - EnsureAllowedExternalURL: preflight URL + hostname-resolution check
//   - IsIPAllowed: dial-time per-IP check (used by http_client adapter)
//   - RedactURL / RedactURLError: log-scrub helpers
//   - Allowlist / NewAllowlist: implements ports.URLAllowlist for DI
package url_safety

import (
	"errors"
	"fmt"
	"net"
	"net/url"
)

// specialUseCIDRs are IETF special-purpose IP ranges that net.IP's
// built-in IsPrivate / IsLoopback / IsLinkLocalUnicast / IsUnspecified
// do NOT cover but that must still be rejected as outbound targets.
// Includes RFC 6598 shared address space (cloud metadata endpoints like
// Alibaba 100.100.100.200), RFC 5737 documentation ranges, RFC 2544
// benchmarking, RFC 1112 reserved, IPv6 RFC 3849 documentation, and
// the IPv6 RFC 6666 discard prefix.
var specialUseCIDRs = []*net.IPNet{
	mustCIDR("100.64.0.0/10"),      // RFC 6598 shared address space
	mustCIDR("192.0.0.0/24"),       // RFC 6890 IETF protocol assignments
	mustCIDR("192.0.2.0/24"),       // RFC 5737 documentation
	mustCIDR("198.18.0.0/15"),      // RFC 2544 benchmarking
	mustCIDR("198.51.100.0/24"),    // RFC 5737 documentation
	mustCIDR("203.0.113.0/24"),     // RFC 5737 documentation
	mustCIDR("240.0.0.0/4"),        // RFC 1112 reserved
	mustCIDR("255.255.255.255/32"), // RFC 919 limited broadcast
	mustCIDR("2001:db8::/32"),      // RFC 3849 documentation
	mustCIDR("100::/64"),           // RFC 6666 discard prefix
}

func mustCIDR(s string) *net.IPNet {
	_, n, err := net.ParseCIDR(s)
	if err != nil {
		panic("url_safety: invalid built-in CIDR " + s + ": " + err.Error())
	}
	return n
}

// IsIPAllowed reports whether ip is a permitted outbound destination.
// Returns true if the IP is not loopback, private, link-local, multicast,
// unspecified, or in any IETF special-use range. Used by the http_client
// adapter's safeDialContext at connect time.
func IsIPAllowed(ip net.IP) bool {
	return checkIPAllowed(ip) == nil
}

// checkIPAllowed returns a non-nil error when ip is not an acceptable outbound
// target. Shared by EnsureAllowedExternalURL (preflight) and IsIPAllowed
// (dial-time) so both enforce the same policy.
func checkIPAllowed(ip net.IP) error {
	if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsMulticast() || ip.IsUnspecified() {
		return fmt.Errorf("webhook URLs must not target internal addresses")
	}
	for _, n := range specialUseCIDRs {
		if n.Contains(ip) {
			return fmt.Errorf("webhook URLs must not target reserved/special-use addresses (%s)", n.String())
		}
	}
	return nil
}

// EnsureAllowedExternalURL is the preflight URL + IP allowlist for SSRF
// prevention. It returns a non-nil error when:
//   - the URL is not parseable
//   - the scheme is not http or https
//   - the hostname cannot be resolved
//   - any resolved IP is loopback, private, link-local, multicast,
//     unspecified, or a special-use range
func EnsureAllowedExternalURL(rawURL string) error {
	u, err := url.Parse(rawURL)
	if err != nil {
		return fmt.Errorf("invalid URL: %w", err)
	}
	if u.Scheme != "https" && u.Scheme != "http" {
		return fmt.Errorf("webhook URLs must use HTTP(S)")
	}
	ips, err := net.LookupIP(u.Hostname())
	if err != nil {
		return fmt.Errorf("cannot resolve hostname: %w", err)
	}
	for _, ip := range ips {
		if err := checkIPAllowed(ip); err != nil {
			return err
		}
	}
	return nil
}

// RedactURL returns a log-safe representation of rawURL with query string,
// fragment, and userinfo stripped. Webhooks may legitimately carry
// ?api_key=... or ?token=... in their URLs; logging the raw URL would leak
// those secrets into application logs.
//
// Behavior (round-13: path now stripped too, matching the path-as-potential-
// credential threat model declared by the round-12 cross-host redirect
// refusal — e.g. Slack-style /services/T/B/SECRET path tokens):
//   - http(s)://user:pass@host/path?query#frag → http(s)://host
//   - http(s)://host:port/path?query → http(s)://host:port
//   - URL parse failure → "<unparseable-url>" (never the raw input)
//   - empty → ""
//
// Tradeoff: operators lose path detail in webhook logs and only see scheme +
// host[:port]. This is acceptable because (a) path-borne secrets (bearer-like
// path tokens) are a real and common admin webhook configuration, and (b) the
// host is enough to triage which endpoint failed; path-level diagnostics
// require defense-in-depth (operator-level egress monitoring) anyway.
func RedactURL(rawURL string) string {
	if rawURL == "" {
		return ""
	}
	u, err := url.Parse(rawURL)
	if err != nil {
		return "<unparseable-url>"
	}
	redacted := url.URL{
		Scheme: u.Scheme,
		Host:   u.Host,
	}
	return redacted.String()
}

// RedactURLError returns err with any embedded *url.Error.URL field replaced
// by RedactURL. Used to prevent webhook URLs carrying ?api_key= / ?token= /
// user:pass@host from leaking into logger.Error(..., err) or wrapped error
// chains via Go's stdlib *url.Error.Error() which embeds the raw URL string
// verbatim.
func RedactURLError(err error) error {
	if err == nil {
		return nil
	}
	var urlErr *url.Error
	if errors.As(err, &urlErr) {
		return &url.Error{
			Op:  urlErr.Op,
			URL: RedactURL(urlErr.URL),
			Err: urlErr.Err,
		}
	}
	return err
}
