package staticsite

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

// ErrBlockedAddress is returned when the dialer refuses to connect
// to a reserved IP (loopback / link-local / unspecified / multicast).
// RFC1918 private ranges are intentionally allowed — self-hosted Gitea
// on a LAN is the dominant deployment pattern.
var ErrBlockedAddress = errors.New("connection to reserved address blocked")

// ValidateBaseURL enforces scheme + host shape on a user-supplied
// provider base URL. Connect-time IP filtering happens later in the
// safe-HTTP transport so DNS rebinding cannot bypass this check.
func ValidateBaseURL(raw string) error {
	raw = strings.TrimSpace(raw)
	if raw == "" {
		return errors.New("base_url is required")
	}
	u, err := url.Parse(raw)
	if err != nil {
		return fmt.Errorf("invalid base_url: %w", err)
	}
	if u.Scheme != "http" && u.Scheme != "https" {
		return fmt.Errorf("unsupported scheme %q (must be http or https)", u.Scheme)
	}
	if u.Host == "" {
		return errors.New("base_url is missing host")
	}
	return nil
}

// NewSafeHTTPClient returns an http.Client whose DialContext rejects
// loopback, link-local, multicast, and unspecified addresses at connect
// time. The dialer re-resolves and connects to the resolved IP so a
// rebind between resolution and connect cannot slip through.
//
// RFC1918 / ULA private ranges are NOT blocked — operators routinely
// point Tinyforge at self-hosted Gitea instances on private networks.
// The threat model here is cloud-metadata exfiltration and loopback
// service probing, not "any private IP is suspect".
func NewSafeHTTPClient(timeout time.Duration) *http.Client {
	dialer := &net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}
	transport := &http.Transport{
		DialContext:         SafeDialContext(dialer),
		MaxIdleConns:        16,
		IdleConnTimeout:     30 * time.Second,
		TLSHandshakeTimeout: 10 * time.Second,
	}
	return &http.Client{Timeout: timeout, Transport: transport}
}

// SafeDialContext returns a DialContext that rejects loopback, link-local,
// multicast, unspecified, and cloud-metadata addresses at connect time,
// re-resolving and binding to the resolved IP so a DNS rebind between
// resolution and connect cannot slip through. Exposed so other transports
// (e.g. the outbound notification client) can apply the same SSRF policy
// without duplicating it or losing their own connection-pool tuning.
func SafeDialContext(dialer *net.Dialer) func(ctx context.Context, network, addr string) (net.Conn, error) {
	return func(ctx context.Context, network, addr string) (net.Conn, error) {
		host, port, err := net.SplitHostPort(addr)
		if err != nil {
			return nil, err
		}
		// If the caller passed a literal IP, skip the DNS round-trip.
		if literal := net.ParseIP(host); literal != nil {
			if reason := blockReason(literal); reason != "" {
				return nil, fmt.Errorf("%w: %s (%s)", ErrBlockedAddress, literal, reason)
			}
			return dialer.DialContext(ctx, network, addr)
		}
		ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
		if err != nil {
			return nil, err
		}
		if len(ips) == 0 {
			return nil, fmt.Errorf("no addresses for %s", host)
		}
		for _, ip := range ips {
			if reason := blockReason(ip.IP); reason != "" {
				return nil, fmt.Errorf("%w: %s (%s)", ErrBlockedAddress, ip.IP, reason)
			}
		}
		// Bind to the first resolved IP so a rebind between resolution
		// and connect cannot redirect the request to a blocked address.
		return dialer.DialContext(ctx, network, net.JoinHostPort(ips[0].IP.String(), port))
	}
}

// blockReason returns a human label for why an IP is rejected, or ""
// if the IP is allowed. Centralized so all callers share the same
// policy.
func blockReason(ip net.IP) string {
	if ip == nil {
		return "nil address"
	}
	// Normalize IPv4-mapped IPv6 (::ffff:x.x.x.x) so the loopback / link-local
	// classifiers below catch them. net.IP.To4() returns the 4-byte form for
	// IPv4-mapped addresses; net's IsLoopback already handles this, but pin
	// the conversion to avoid future surprises if the std-lib semantics drift.
	if v4 := ip.To4(); v4 != nil {
		ip = v4
	}
	switch {
	case ip.IsLoopback():
		return "loopback"
	case ip.IsUnspecified():
		return "unspecified"
	case ip.IsLinkLocalUnicast():
		return "link-local"
	case ip.IsLinkLocalMulticast():
		return "link-local multicast"
	case ip.IsMulticast():
		return "multicast"
	}
	// Cloud metadata endpoints — AWS / GCP / Azure are covered by the
	// link-local block (169.254.169.254). The rest must be enumerated.
	if metadataIPSet[ip.String()] {
		return "cloud metadata endpoint"
	}
	return ""
}

// metadataIPSet enumerates well-known cloud metadata IPs that are NOT
// covered by net.IP.IsLinkLocalUnicast. Updating this set is the lightest
// way to keep up with new providers without changing the policy shape.
var metadataIPSet = map[string]bool{
	// Alibaba Cloud ECS metadata.
	"100.100.100.200": true,
	// Oracle Cloud Infrastructure metadata.
	"192.0.0.192": true,
	// AWS IMDS over IPv6 (ULA — not link-local, must be listed).
	"fd00:ec2::254": true,
}
