// Package sandbox — http_module.go
//
// v0.29.1 CS0: HTTP outbound module for Starlark extensions.
// Requires permission: api.http
//
// Starlark API:
//
//	resp = http.get(url, headers={})
//	resp = http.post(url, body="", headers={})
//	resp = http.put(url, body="", headers={})
//	resp = http.delete(url, headers={})
//	resp = http.request(method, url, body="", headers={})
//
//	# resp is a dict: {"status": 200, "headers": {...}, "body": "..."}
//
// Network access rules from manifest:
//
//	{
//	  "network_access": {
//	    "allow": ["api.example.com"],   // allowlist mode: ONLY these domains
//	    "block": ["evil.com"]           // blocklist: additive to built-in blocks
//	  }
//	}
//
// Security:
//   - Private/loopback IPs blocked (SSRF protection)
//   - DNS resolution checked before connect
//   - Response body capped at 1 MB
//   - 10 s default timeout (inherits context)
//   - Max 10 redirects, each checked for SSRF
package sandbox

import (
	"context"
	"fmt"
	"io"
	"net"
	"net/http"
	"net/url"
	"strings"
	"syscall"
	"time"

	"go.starlark.net/starlark"
	"go.starlark.net/starlarkstruct"
)

// ─── Configuration ──────────────────────────

const (
	httpDefaultTimeout  = 10 * time.Second
	httpMaxResponseBody = 1 << 20 // 1 MB
	httpMaxRedirects    = 10
)

// HTTPModuleConfig controls the HTTP module's network access policy.
type HTTPModuleConfig struct {
	// AllowDomains: if non-empty, only these exact domains are reachable.
	// When set, BlockDomains is ignored (allowlist takes precedence).
	AllowDomains []string

	// BlockDomains: domains to block in addition to built-in SSRF rules.
	// Ignored when AllowDomains is non-empty.
	BlockDomains []string

	// AllowPrivateIPs disables the SSRF check that blocks connections to
	// private/loopback/link-local addresses. Use for self-hosted deployments
	// where extensions need to reach internal services.
	// Default: false (private IPs blocked).
	AllowPrivateIPs bool

	// Timeout overrides the default 10 s request timeout.
	// Zero means use default.
	Timeout time.Duration

	// MaxBodyBytes overrides the default 1 MB response body limit.
	// Zero means use default.
	MaxBodyBytes int64
}

func (c HTTPModuleConfig) timeout() time.Duration {
	if c.Timeout > 0 {
		return c.Timeout
	}
	return httpDefaultTimeout
}

func (c HTTPModuleConfig) maxBody() int64 {
	if c.MaxBodyBytes > 0 {
		return c.MaxBodyBytes
	}
	return httpMaxResponseBody
}

// ParseNetworkAccess extracts HTTPModuleConfig from a package manifest.
// Missing or malformed fields are silently ignored (no access = default policy).
func ParseNetworkAccess(manifest map[string]any) HTTPModuleConfig {
	cfg := HTTPModuleConfig{}

	na, ok := manifest["network_access"]
	if !ok {
		return cfg
	}
	naMap, ok := na.(map[string]any)
	if !ok {
		return cfg
	}

	if allow, ok := naMap["allow"]; ok {
		cfg.AllowDomains = toStringSlice(allow)
	}
	if block, ok := naMap["block"]; ok {
		cfg.BlockDomains = toStringSlice(block)
	}

	return cfg
}

func toStringSlice(v any) []string {
	switch arr := v.(type) {
	case []any:
		out := make([]string, 0, len(arr))
		for _, item := range arr {
			if s, ok := item.(string); ok && s != "" {
				out = append(out, strings.ToLower(s))
			}
		}
		return out
	case []string:
		out := make([]string, 0, len(arr))
		for _, s := range arr {
			if s != "" {
				out = append(out, strings.ToLower(s))
			}
		}
		return out
	default:
		return nil
	}
}

// ─── Module Builder ─────────────────────────

// BuildHTTPModule creates the "http" Starlark module with the given
// network access policy. Each call to a builtin creates a fresh
// request bound to the parent context.
func BuildHTTPModule(ctx context.Context, cfg HTTPModuleConfig) *starlarkstruct.Module {
	ac := &accessControl{cfg: cfg}

	doRequest := func(method, rawURL, body string, headers map[string]string) (starlark.Value, error) {
		return executeHTTPRequest(ctx, ac, method, rawURL, body, headers, cfg.timeout(), cfg.maxBody())
	}

	return MakeModule("http", starlark.StringDict{
		"request": starlark.NewBuiltin("http.request", func(
			thread *starlark.Thread, b *starlark.Builtin,
			args starlark.Tuple, kwargs []starlark.Tuple,
		) (starlark.Value, error) {
			var method, rawURL string
			var body string
			var hdrs *starlark.Dict
			if err := starlark.UnpackArgs(b.Name(), args, kwargs,
				"method", &method,
				"url", &rawURL,
				"body?", &body,
				"headers?", &hdrs,
			); err != nil {
				return nil, err
			}
			return doRequest(strings.ToUpper(method), rawURL, body, dictToStringMap(hdrs))
		}),

		"get": starlark.NewBuiltin("http.get", func(
			thread *starlark.Thread, b *starlark.Builtin,
			args starlark.Tuple, kwargs []starlark.Tuple,
		) (starlark.Value, error) {
			var rawURL string
			var hdrs *starlark.Dict
			if err := starlark.UnpackArgs(b.Name(), args, kwargs,
				"url", &rawURL,
				"headers?", &hdrs,
			); err != nil {
				return nil, err
			}
			return doRequest("GET", rawURL, "", dictToStringMap(hdrs))
		}),

		"post": starlark.NewBuiltin("http.post", func(
			thread *starlark.Thread, b *starlark.Builtin,
			args starlark.Tuple, kwargs []starlark.Tuple,
		) (starlark.Value, error) {
			var rawURL, body string
			var hdrs *starlark.Dict
			if err := starlark.UnpackArgs(b.Name(), args, kwargs,
				"url", &rawURL,
				"body?", &body,
				"headers?", &hdrs,
			); err != nil {
				return nil, err
			}
			return doRequest("POST", rawURL, body, dictToStringMap(hdrs))
		}),

		"put": starlark.NewBuiltin("http.put", func(
			thread *starlark.Thread, b *starlark.Builtin,
			args starlark.Tuple, kwargs []starlark.Tuple,
		) (starlark.Value, error) {
			var rawURL, body string
			var hdrs *starlark.Dict
			if err := starlark.UnpackArgs(b.Name(), args, kwargs,
				"url", &rawURL,
				"body?", &body,
				"headers?", &hdrs,
			); err != nil {
				return nil, err
			}
			return doRequest("PUT", rawURL, body, dictToStringMap(hdrs))
		}),

		"delete": starlark.NewBuiltin("http.delete", func(
			thread *starlark.Thread, b *starlark.Builtin,
			args starlark.Tuple, kwargs []starlark.Tuple,
		) (starlark.Value, error) {
			var rawURL string
			var hdrs *starlark.Dict
			if err := starlark.UnpackArgs(b.Name(), args, kwargs,
				"url", &rawURL,
				"headers?", &hdrs,
			); err != nil {
				return nil, err
			}
			return doRequest("DELETE", rawURL, "", dictToStringMap(hdrs))
		}),
	})
}

// ─── Request Execution ──────────────────────

func executeHTTPRequest(
	ctx context.Context,
	ac *accessControl,
	method, rawURL, body string,
	headers map[string]string,
	timeout time.Duration,
	maxBody int64,
) (starlark.Value, error) {
	// Validate URL
	parsed, err := url.Parse(rawURL)
	if err != nil {
		return nil, fmt.Errorf("http.%s: invalid URL: %w", strings.ToLower(method), err)
	}
	if parsed.Scheme != "http" && parsed.Scheme != "https" {
		return nil, fmt.Errorf("http.%s: only http/https schemes allowed, got %q", strings.ToLower(method), parsed.Scheme)
	}

	// Check domain against access control (pre-DNS)
	host := parsed.Hostname()
	if err := ac.checkDomain(host); err != nil {
		return nil, fmt.Errorf("http.%s: %w", strings.ToLower(method), err)
	}

	// Build request
	reqCtx, cancel := context.WithTimeout(ctx, timeout)
	defer cancel()

	var bodyReader io.Reader
	if body != "" {
		bodyReader = strings.NewReader(body)
	}

	req, err := http.NewRequestWithContext(reqCtx, method, rawURL, bodyReader)
	if err != nil {
		return nil, fmt.Errorf("http.%s: %w", strings.ToLower(method), err)
	}

	// Set headers (user-provided override defaults)
	req.Header.Set("User-Agent", "ChatSwitchboard-Extension/1.0")
	for k, v := range headers {
		req.Header.Set(k, v)
	}

	// SSRF-safe client with custom dialer
	client := ac.client()

	resp, err := client.Do(req)
	if err != nil {
		return nil, fmt.Errorf("http.%s: request failed: %w", strings.ToLower(method), err)
	}
	defer resp.Body.Close()

	// Read body with size limit
	limited := io.LimitReader(resp.Body, maxBody+1)
	respBody, err := io.ReadAll(limited)
	if err != nil {
		return nil, fmt.Errorf("http.%s: reading response: %w", strings.ToLower(method), err)
	}
	if int64(len(respBody)) > maxBody {
		respBody = respBody[:maxBody]
		// Truncated — still return what we have, don't error
	}

	return buildResponseDict(resp.StatusCode, resp.Header, string(respBody))
}

// buildResponseDict converts an HTTP response into a Starlark dict:
//
//	{"status": int, "headers": dict, "body": string}
func buildResponseDict(status int, h http.Header, body string) (starlark.Value, error) {
	// Build headers dict (lowercase keys, first value only for simplicity)
	hdrDict := starlark.NewDict(len(h))
	for k, vals := range h {
		if len(vals) > 0 {
			_ = hdrDict.SetKey(starlark.String(strings.ToLower(k)), starlark.String(vals[0]))
		}
	}

	resp := starlark.NewDict(3)
	_ = resp.SetKey(starlark.String("status"), starlark.MakeInt(status))
	_ = resp.SetKey(starlark.String("headers"), hdrDict)
	_ = resp.SetKey(starlark.String("body"), starlark.String(body))
	return resp, nil
}

// ─── Access Control ─────────────────────────

type accessControl struct {
	cfg HTTPModuleConfig
}

// checkDomain validates a hostname against the access policy.
func (ac *accessControl) checkDomain(host string) error {
	host = strings.ToLower(host)

	// Allowlist mode: only listed domains are permitted
	if len(ac.cfg.AllowDomains) > 0 {
		for _, d := range ac.cfg.AllowDomains {
			if host == d {
				return nil
			}
		}
		return fmt.Errorf("domain %q not in network_access allow list", host)
	}

	// Blocklist mode: check against explicit blocks
	for _, d := range ac.cfg.BlockDomains {
		if host == d {
			return fmt.Errorf("domain %q is blocked by network_access policy", host)
		}
	}

	return nil
}

// client builds an HTTP client with optional SSRF IP validation on connect.
func (ac *accessControl) client() *http.Client {
	dialer := &net.Dialer{
		Timeout: 5 * time.Second,
	}
	if !ac.cfg.AllowPrivateIPs {
		dialer.Control = ssrfControl
	}

	transport := &http.Transport{
		DialContext:         dialer.DialContext,
		TLSHandshakeTimeout: 5 * time.Second,
		MaxIdleConns:        1,
		IdleConnTimeout:     30 * time.Second,
		DisableKeepAlives:   true, // extension requests are one-shot
	}

	return &http.Client{
		Transport: transport,
		CheckRedirect: func(req *http.Request, via []*http.Request) error {
			if len(via) >= httpMaxRedirects {
				return fmt.Errorf("too many redirects (max %d)", httpMaxRedirects)
			}
			// Re-check domain on redirect to prevent SSRF via redirect
			host := req.URL.Hostname()
			if err := ac.checkDomain(host); err != nil {
				return err
			}
			return nil
		},
	}
}

// ssrfControl is a net.Dialer.Control function that rejects connections
// to private, loopback, and link-local addresses. Called after DNS
// resolution, before the TCP handshake — prevents DNS rebinding attacks.
func ssrfControl(network, address string, _ syscall.RawConn) error {
	host, _, err := net.SplitHostPort(address)
	if err != nil {
		return fmt.Errorf("ssrf: invalid address %q: %w", address, err)
	}

	ip := net.ParseIP(host)
	if ip == nil {
		return fmt.Errorf("ssrf: could not parse IP %q", host)
	}

	if !isPublicIP(ip) {
		return fmt.Errorf("ssrf: connection to private/reserved address %s blocked", ip)
	}

	return nil
}

// isPublicIP returns true if the IP is a routable public address.
// Blocks all RFC1918, loopback, link-local, multicast, and unspecified.
func isPublicIP(ip net.IP) bool {
	// Unspecified (0.0.0.0, ::)
	if ip.IsUnspecified() {
		return false
	}
	// Loopback (127.0.0.0/8, ::1)
	if ip.IsLoopback() {
		return false
	}
	// Link-local unicast (169.254.0.0/16, fe80::/10)
	if ip.IsLinkLocalUnicast() {
		return false
	}
	// Link-local multicast
	if ip.IsLinkLocalMulticast() {
		return false
	}
	// Multicast
	if ip.IsMulticast() {
		return false
	}
	// Private (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7)
	if ip.IsPrivate() {
		return false
	}

	return true
}

// ─── Starlark Dict Helpers ──────────────────

// dictToStringMap converts a Starlark dict to a Go map[string]string.
// Non-string keys or values are silently skipped.
func dictToStringMap(d *starlark.Dict) map[string]string {
	if d == nil {
		return nil
	}
	m := make(map[string]string, d.Len())
	for _, item := range d.Items() {
		k, ok1 := starlark.AsString(item[0])
		v, ok2 := starlark.AsString(item[1])
		if ok1 && ok2 {
			m[k] = v
		}
	}
	return m
}
