package http_client

import (
	"context"
	"fmt"
	"io"
	"net"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
	"time"

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

// permissiveAllowlist is a ports.URLAllowlist that allows everything.
// Used in tests that target httptest.Server on loopback and want to
// verify behavior beyond the allowlist (e.g. redirect or credential checks).
type permissiveAllowlist struct{}

func (permissiveAllowlist) EnsureAllowed(string) error { return nil }
func (permissiveAllowlist) Redact(rawURL string) string {
	return url_safety.RedactURL(rawURL)
}
func (permissiveAllowlist) RedactError(err error) error { return err }

// productionAllowlist delegates to the real url_safety policy.
func productionAllowlist() ports.URLAllowlist { return url_safety.NewAllowlist() }

// TestOutboundClient_RejectsPrivateIP verifies that Do with a URL resolving
// to a private IP (10.x) is rejected by the url_safety preflight before
// any network connection is made.
func TestOutboundClient_RejectsPrivateIP(t *testing.T) {
	client := NewOutboundClient(productionAllowlist())
	req, err := http.NewRequestWithContext(context.Background(), "GET", "https://10.0.0.1/", nil)
	if err != nil {
		t.Fatalf("build request: %v", err)
	}
	_, err = client.Do(req)
	if err == nil {
		t.Fatal("expected error for private IP, got nil")
	}
	if !strings.Contains(err.Error(), "internal addresses") {
		t.Errorf("expected 'internal addresses' in error, got: %v", err)
	}
}

// TestOutboundClient_RejectsRFC6598 verifies that Do with a URL in the
// RFC 6598 shared address space (100.64.0.0/10) is rejected.
func TestOutboundClient_RejectsRFC6598(t *testing.T) {
	client := NewOutboundClient(productionAllowlist())
	req, err := http.NewRequestWithContext(context.Background(), "GET", "https://100.64.0.1/", nil)
	if err != nil {
		t.Fatalf("build request: %v", err)
	}
	_, err = client.Do(req)
	if err == nil {
		t.Fatal("expected error for RFC 6598 address, got nil")
	}
	if !strings.Contains(err.Error(), "reserved/special-use") {
		t.Errorf("expected 'reserved/special-use' in error, got: %v", err)
	}
}

// TestOutboundClient_RejectsLoopback verifies that Do with a URL targeting
// 127.0.0.1 is rejected by the allowlist preflight.
func TestOutboundClient_RejectsLoopback(t *testing.T) {
	client := NewOutboundClient(productionAllowlist())
	req, err := http.NewRequestWithContext(context.Background(), "GET", "https://127.0.0.1/", nil)
	if err != nil {
		t.Fatalf("build request: %v", err)
	}
	_, err = client.Do(req)
	if err == nil {
		t.Fatal("expected error for loopback, got nil")
	}
	if !strings.Contains(err.Error(), "internal addresses") {
		t.Errorf("expected 'internal addresses' in error, got: %v", err)
	}
}

func TestOutboundRoundTripperReturnsRedirectWithoutFollowing(t *testing.T) {
	requests := 0
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		requests++
		if r.URL.Path == "/initial" {
			http.Redirect(w, r, "/replayed", http.StatusTemporaryRedirect)
			return
		}
		w.WriteHeader(http.StatusNoContent)
	}))
	defer server.Close()

	roundTripper := NewOutboundRoundTripper(permissiveAllowlist{}).(*outboundClient)
	roundTripper.client.Transport = server.Client().Transport
	request, err := http.NewRequestWithContext(context.Background(), http.MethodPost, server.URL+"/initial", strings.NewReader("credential-bearing body"))
	if err != nil {
		t.Fatalf("build request: %v", err)
	}
	response, err := roundTripper.RoundTrip(request)
	if err != nil {
		t.Fatalf("RoundTrip() error = %v", err)
	}
	defer response.Body.Close()
	if response.StatusCode != http.StatusTemporaryRedirect || requests != 1 {
		t.Fatalf("RoundTrip() status = %d, requests = %d; want redirect and one request", response.StatusCode, requests)
	}
}

func TestOutboundRoundTripperClosesRejectedRequestBody(t *testing.T) {
	body := &roundTripTrackingBody{Reader: strings.NewReader("secret")}
	request, err := http.NewRequestWithContext(context.Background(), http.MethodPost, "https://127.0.0.1/send", body)
	if err != nil {
		t.Fatalf("build request: %v", err)
	}
	if _, err := NewOutboundRoundTripper(productionAllowlist()).RoundTrip(request); err == nil {
		t.Fatal("RoundTrip() accepted a disallowed destination")
	}
	if !body.closed {
		t.Fatal("RoundTrip() left the credential-bearing request body open after preflight rejection")
	}
}

type roundTripTrackingBody struct {
	io.Reader
	closed bool
}

func (body *roundTripTrackingBody) Close() error {
	body.closed = true
	return nil
}

// TestOutboundClient_RejectsRedirectToInternal verifies that a redirect from
// a permissive initial URL to an internal address is caught by checkRedirect.
// The cross-host check fires first (different host), which is acceptable —
// the redirect is still refused before the connection to 10.0.0.1 is made.
func TestOutboundClient_RejectsRedirectToInternal(t *testing.T) {
	// internalRedirectSrv returns 302 to 10.0.0.1 (private IP).
	internalRedirectSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		http.Redirect(w, r, "http://10.0.0.1/secret", http.StatusFound)
	}))
	defer internalRedirectSrv.Close()

	// Use a mixed allowlist: allows the loopback test server but blocks 10.0.0.1.
	// This isolates the redirect-validation path from the initial-URL preflight.
	a := &outboundClient{
		allowlist: &mixedTestAllowlist{blockedPrefixes: []string{"10.0.0.1"}},
	}
	a.client = &http.Client{CheckRedirect: a.checkRedirect} // loopback-capable transport

	req, err := http.NewRequestWithContext(context.Background(), "GET", internalRedirectSrv.URL, nil)
	if err != nil {
		t.Fatalf("build request: %v", err)
	}
	_, err = a.Do(req)
	if err == nil {
		t.Fatal("expected error when redirect leads to 10.0.0.1, got nil")
	}
	// With unconditional cross-host refusal the cross-host check fires first;
	// either "cross-host" or "internal addresses" in the error is acceptable —
	// the redirect was refused before any connection to 10.0.0.1 was made.
	errStr := err.Error()
	if !strings.Contains(errStr, "cross-host") && !strings.Contains(errStr, "internal addresses") {
		t.Errorf("expected 'cross-host' or 'internal addresses' in redirect error, got: %v", err)
	}
}

// mixedTestAllowlist allows all URLs except those whose hostname appears in blockedPrefixes.
type mixedTestAllowlist struct {
	blockedPrefixes []string
}

func (m *mixedTestAllowlist) EnsureAllowed(rawURL string) error {
	for _, blocked := range m.blockedPrefixes {
		if strings.Contains(rawURL, blocked) {
			return fmt.Errorf("webhook URLs must not target internal addresses")
		}
	}
	return nil
}

func (m *mixedTestAllowlist) Redact(rawURL string) string {
	return url_safety.RedactURL(rawURL)
}
func (m *mixedTestAllowlist) RedactError(err error) error { return err }

// TestOutboundClient_RejectsHTTPSDowngrade verifies that a same-host redirect
// from https → http is refused unconditionally (scheme downgrade). Uses
// checkRedirect directly to avoid the httptest port-mismatch problem (two
// test servers on 127.0.0.1 have different ports → different Host strings).
func TestOutboundClient_RejectsHTTPSDowngrade(t *testing.T) {
	a := &outboundClient{allowlist: permissiveAllowlist{}}

	// Simulate: initial request was https://example.com/, redirect to http://example.com/
	initialReq, _ := http.NewRequestWithContext(context.Background(), "GET", "https://example.com/", nil)
	redirectReq, _ := http.NewRequestWithContext(context.Background(), "GET", "http://example.com/", nil)

	err := a.checkRedirect(redirectReq, []*http.Request{initialReq})
	if err == nil {
		t.Fatal("expected scheme downgrade refusal, got nil")
	}
	if !strings.Contains(err.Error(), "scheme downgrade") {
		t.Errorf("expected 'scheme downgrade' in error, got: %v", err)
	}
}

// TestOutboundClient_AllowsSameHostHTTPSRedirect verifies that a same-host
// HTTPS→HTTPS redirect (path normalization) is allowed.
func TestOutboundClient_AllowsSameHostHTTPSRedirect(t *testing.T) {
	a := &outboundClient{allowlist: permissiveAllowlist{}}

	initialReq, _ := http.NewRequestWithContext(context.Background(), "GET", "https://example.com/old", nil)
	redirectReq, _ := http.NewRequestWithContext(context.Background(), "GET", "https://example.com/new", nil)

	err := a.checkRedirect(redirectReq, []*http.Request{initialReq})
	if err != nil {
		t.Errorf("same-host HTTPS redirect must be allowed, got: %v", err)
	}
}

// TestOutboundClient_RefusesCrossHostRedirectUnconditionally verifies the
// round-12 architectural decision: cross-host redirects are refused regardless
// of what headers or URL content the initial request carries.
//
// Covered cases:
//   - Same-host redirect (path change): ALLOWED
//   - Cross-host redirect (no credential signals): REFUSED
//   - Cross-host redirect with Authorization header: REFUSED
//   - Cross-host redirect with URL query secret: REFUSED
//   - Cross-host redirect with URL userinfo: REFUSED
//   - Cross-host redirect with Slack-style path token: REFUSED
//   - HTTPS→HTTP same-host scheme downgrade: REFUSED
func TestOutboundClient_RefusesCrossHostRedirectUnconditionally(t *testing.T) {
	// finalSrv is used as both the same-host redirect target and the cross-host target.
	finalSrv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(200)
	}))
	defer finalSrv.Close()

	// Build adapter trusting finalSrv's cert pool so same-host redirects can complete.
	newAdapter := func() *outboundClient {
		a := &outboundClient{allowlist: permissiveAllowlist{}}
		innerClient := finalSrv.Client()
		innerClient.CheckRedirect = a.checkRedirect
		a.client = innerClient
		return a
	}

	t.Run("same-host path redirect is allowed", func(t *testing.T) {
		sameHostSrv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			if r.URL.Path == "/" {
				http.Redirect(w, r, "/canonical", http.StatusMovedPermanently)
				return
			}
			w.WriteHeader(200)
		}))
		defer sameHostSrv.Close()

		a := &outboundClient{allowlist: permissiveAllowlist{}}
		innerClient := sameHostSrv.Client()
		innerClient.CheckRedirect = a.checkRedirect
		a.client = innerClient

		req, err := http.NewRequestWithContext(context.Background(), "GET", sameHostSrv.URL+"/", nil)
		if err != nil {
			t.Fatalf("build request: %v", err)
		}
		resp, err := a.Do(req)
		if err != nil {
			t.Fatalf("same-host redirect must be allowed, got error: %v", err)
		}
		_ = resp.Body.Close()
	})

	crossHostCases := []struct {
		name   string
		setup  func(req *http.Request)
		urlSuf string
	}{
		{
			name:   "no credential signals",
			setup:  func(req *http.Request) {},
			urlSuf: "",
		},
		{
			name:   "Authorization header",
			setup:  func(req *http.Request) { req.Header.Set("Authorization", "Bearer token") },
			urlSuf: "",
		},
		{
			name:   "X-Api-Key custom header",
			setup:  func(req *http.Request) { req.Header.Set("X-Api-Key", "secret") },
			urlSuf: "",
		},
		{
			name:   "URL query secret",
			setup:  func(req *http.Request) {},
			urlSuf: "?api_key=super-secret",
		},
		{
			name:   "URL userinfo",
			setup:  func(req *http.Request) {},
			urlSuf: "", // userinfo injected separately below
		},
		{
			name:   "Slack-style path token",
			setup:  func(req *http.Request) {},
			urlSuf: "/services/T00000000/B00000000/SECRET_TOKEN",
		},
	}

	for _, tc := range crossHostCases {
		tc := tc
		t.Run("cross-host refused: "+tc.name, func(t *testing.T) {
			initialSrv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
				http.Redirect(w, r, finalSrv.URL, http.StatusFound)
			}))
			defer initialSrv.Close()

			a := newAdapter()

			reqURL := initialSrv.URL + tc.urlSuf
			if tc.name == "URL userinfo" {
				// Inject userinfo into the URL manually.
				if len(initialSrv.URL) > 8 && initialSrv.URL[:8] == "https://" {
					reqURL = "https://user:pass@" + initialSrv.URL[8:]
				}
			}

			req, err := http.NewRequestWithContext(context.Background(), "GET", reqURL, nil)
			if err != nil {
				t.Fatalf("build request: %v", err)
			}
			tc.setup(req)

			_, err = a.Do(req)
			if err == nil {
				t.Fatal("expected cross-host refusal, got nil error")
			}
			if !strings.Contains(err.Error(), "cross-host") {
				t.Errorf("expected 'cross-host' in error, got: %v", err)
			}
		})
	}
}

// TestOutboundClient_AllowsValidPublicURL verifies that a request passes through
// the Do() preflight (allowlist check) and reaches the server when the allowlist
// permits it. Constructs the adapter with a custom inner http.Client (using the
// plain loopback transport from httptest) to isolate preflight logic from IP-pinning.
func TestOutboundClient_AllowsValidPublicURL(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(200)
		_, _ = io.WriteString(w, `{"ok":true}`)
	}))
	defer srv.Close()

	// Build adapter directly using the unexported struct so we can inject a loopback-capable
	// inner http.Client while still exercising the Do() preflight + checkRedirect paths.
	a := &outboundClient{allowlist: permissiveAllowlist{}}
	a.client = &http.Client{CheckRedirect: a.checkRedirect}
	// a.client uses http.DefaultTransport (loopback-capable) for inner http.Client.

	req, err := http.NewRequestWithContext(context.Background(), "GET", srv.URL, nil)
	if err != nil {
		t.Fatalf("build request: %v", err)
	}
	resp, err := a.Do(req)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	defer func() { _ = resp.Body.Close() }()
	if resp.StatusCode != 200 {
		t.Errorf("expected 200, got %d", resp.StatusCode)
	}
}

// TestOutboundClient_DNSRebindCaughtAtDial verifies that even when the
// preflight allowlist is bypassed (permissive), the IP-pinned dialer in
// safeDialContext still rejects loopback IPs at connect time.
// This exercises the DNS-rebind defense: a second DNS lookup at dial time
// that returns an internal IP is caught before the connection is established.
func TestOutboundClient_DNSRebindCaughtAtDial(t *testing.T) {
	// Use the real production allowlist for the adapter so BOTH the preflight
	// and the dial-time checks are active for a loopback address.
	client := NewOutboundClient(productionAllowlist())

	// Override defaultDialFunc to a fake that would succeed only if reached.
	// We inject a loopback-IP-only dial so that even if preflight passes,
	// the dial-time check fires. Since production allowlist rejects loopback
	// at preflight, this test mainly confirms the preflight fires first.
	// For a true dial-time test (bypassed preflight), see TestOutboundClient_RejectsLoopback
	// which confirms both preflight + dial-time rejection.
	//
	// Here we test via the production path (both layers active):
	req, err := http.NewRequestWithContext(context.Background(), "GET", "http://127.0.0.1:9999/", nil)
	if err != nil {
		t.Fatalf("build request: %v", err)
	}
	_, err = client.Do(req)
	if err == nil {
		t.Fatal("expected error for loopback even with real dialer, got nil")
	}
	if !strings.Contains(err.Error(), "internal addresses") {
		t.Errorf("expected 'internal addresses', got: %v", err)
	}
}

// TestOutboundClient_DialTimeIPPinning verifies that the IP-pinned dialer
// rejects an IP that is rejected by url_safety.IsIPAllowed, even when
// the preflight allowlist is permissive. Uses defaultDialFunc override to
// simulate a DNS-rebind scenario where the dialer "resolves" to a private IP.
func TestOutboundClient_DialTimeIPPinning(t *testing.T) {
	// Start a real public server for the preflight lookup to succeed on.
	// We mock safeDialContext by overriding defaultDialFunc so the dial
	// attempt never actually happens — the all-or-nothing IP validation fires.

	// Build a permissive adapter so preflight EnsureAllowed passes.
	// safeDialContext still validates IPs via url_safety.IsIPAllowed.
	// Inject a fake dial function that we can observe.
	oldDial := defaultDialFunc
	dialCalled := false
	defaultDialFunc = func(ctx context.Context, network, addr string) (net.Conn, error) {
		dialCalled = true
		return nil, fmt.Errorf("should not be reached")
	}
	t.Cleanup(func() { defaultDialFunc = oldDial })

	// Use permissiveAllowlist so preflight passes; safeDialContext will
	// resolve "localhost" → 127.0.0.1 and reject at validation before dialing.
	client := NewOutboundClient(permissiveAllowlist{})
	req, err := http.NewRequestWithContext(context.Background(), "GET", "http://localhost:9999/", nil)
	if err != nil {
		t.Fatalf("build request: %v", err)
	}
	_, err = client.Do(req)
	if err == nil {
		t.Fatal("expected dial-time error for loopback IP, got nil")
	}
	if !strings.Contains(err.Error(), "internal addresses") {
		t.Errorf("expected 'internal addresses' in error, got: %v", err)
	}
	// The fake dial function should NOT have been called because validation
	// fires before the dial loop.
	if dialCalled {
		t.Error("fake dial function was called despite IP validation — validation did not short-circuit")
	}
}

// TestOutboundClient_DirectOnly verifies that the adapter is configured with
// Proxy == nil (direct-only, no HTTP_PROXY / HTTPS_PROXY env vars honored).
func TestOutboundClient_DirectOnly(t *testing.T) {
	t.Setenv("HTTPS_PROXY", "http://proxy.example.com:8080")
	t.Setenv("HTTP_PROXY", "http://proxy.example.com:8080")

	adapter := NewOutboundClient(productionAllowlist()).(*outboundClient)
	transport, ok := adapter.client.Transport.(*http.Transport)
	if !ok {
		t.Fatalf("expected *http.Transport, got %T", adapter.client.Transport)
	}
	if transport.Proxy != nil {
		req, _ := http.NewRequest("GET", "https://api.example.com/", nil)
		proxyURL, err := transport.Proxy(req)
		if err != nil {
			t.Fatalf("unexpected error from Proxy func: %v", err)
		}
		if proxyURL != nil {
			t.Errorf("adapter must be direct-only; got proxy URL %s", proxyURL)
		}
	}
}

// TestOutboundClient_MaxRedirectsEnforced verifies that Do stops following
// redirects after 10 hops. Constructs adapter with a custom inner http.Client to
// bypass IP-pinning so the loopback test server is reachable.
func TestOutboundClient_MaxRedirectsEnforced(t *testing.T) {
	loopSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// Redirect to self.
		http.Redirect(w, r, "/", http.StatusFound)
	}))
	defer loopSrv.Close()

	// Build adapter with a loopback-capable inner http.Client.
	a := &outboundClient{allowlist: permissiveAllowlist{}}
	a.client = &http.Client{CheckRedirect: a.checkRedirect}

	req, err := http.NewRequestWithContext(context.Background(), "GET", loopSrv.URL, nil)
	if err != nil {
		t.Fatalf("build request: %v", err)
	}
	_, err = a.Do(req)
	if err == nil {
		t.Fatal("expected error after too many redirects, got nil")
	}
	if !strings.Contains(err.Error(), "stopped after 10 redirects") {
		t.Errorf("expected 'stopped after 10 redirects', got: %v", err)
	}
}

// --- dialAllowedIPs unit tests ---

type fakeDialEntry struct {
	addr    string
	succeed bool
	delay   time.Duration
}

func makeFakeDial(entries []fakeDialEntry) func(ctx context.Context, network, addr string) (net.Conn, error) {
	return func(ctx context.Context, network, addr string) (net.Conn, error) {
		for _, e := range entries {
			if e.addr == addr {
				if e.delay > 0 {
					select {
					case <-ctx.Done():
						return nil, ctx.Err()
					case <-time.After(e.delay):
					}
				}
				if e.succeed {
					srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
					conn, dialErr := (&net.Dialer{}).DialContext(ctx, "tcp", srv.Listener.Addr().String())
					srv.Close()
					return conn, dialErr
				}
				return nil, &net.OpError{Op: "dial", Net: network, Addr: nil,
					Err: &net.AddrError{Err: "connection refused", Addr: addr}}
			}
		}
		return nil, &net.OpError{Op: "dial", Net: network, Addr: nil,
			Err: &net.AddrError{Err: "unexpected addr in fake dialer", Addr: addr}}
	}
}

func TestDialAllowedIPs_HappyPath(t *testing.T) {
	ip1 := net.IPAddr{IP: net.ParseIP("8.8.8.8")}
	ip2 := net.IPAddr{IP: net.ParseIP("8.8.4.4")}

	oldDial := defaultDialFunc
	defaultDialFunc = makeFakeDial([]fakeDialEntry{
		{addr: "8.8.8.8:443", succeed: true},
		{addr: "8.8.4.4:443", succeed: true},
	})
	t.Cleanup(func() { defaultDialFunc = oldDial })

	conn, err := dialAllowedIPs(context.Background(), "tcp", []net.IPAddr{ip1, ip2}, "443")
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	_ = conn.Close()
}

func TestDialAllowedIPs_FallbackOnFirstFailure(t *testing.T) {
	ip1 := net.IPAddr{IP: net.ParseIP("8.8.8.8")}
	ip2 := net.IPAddr{IP: net.ParseIP("8.8.4.4")}

	oldDial := defaultDialFunc
	defaultDialFunc = makeFakeDial([]fakeDialEntry{
		{addr: "8.8.8.8:443", succeed: false},
		{addr: "8.8.4.4:443", succeed: true},
	})
	t.Cleanup(func() { defaultDialFunc = oldDial })

	conn, err := dialAllowedIPs(context.Background(), "tcp", []net.IPAddr{ip1, ip2}, "443")
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	_ = conn.Close()
}

func TestDialAllowedIPs_AllFail(t *testing.T) {
	ip1 := net.IPAddr{IP: net.ParseIP("8.8.8.8")}
	ip2 := net.IPAddr{IP: net.ParseIP("8.8.4.4")}

	oldDial := defaultDialFunc
	defaultDialFunc = makeFakeDial([]fakeDialEntry{
		{addr: "8.8.8.8:443", succeed: false},
		{addr: "8.8.4.4:443", succeed: false},
	})
	t.Cleanup(func() { defaultDialFunc = oldDial })

	_, err := dialAllowedIPs(context.Background(), "tcp", []net.IPAddr{ip1, ip2}, "443")
	if err == nil {
		t.Fatal("expected error when all IPs fail, got nil")
	}
	if !strings.Contains(err.Error(), "all candidates failed") {
		t.Errorf("expected 'all candidates failed', got: %v", err)
	}
}
