//
//  Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
//
//  Licensed under the Apache License, Version 2.0 (the "License");
//  you may not use this file except in compliance with the License.
//  You may obtain a copy of the License at
//
//      http://www.apache.org/licenses/LICENSE-2.0
//
//  Unless required by applicable law or agreed to in writing, software
//  distributed under the License is distributed on an "AS IS" BASIS,
//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//  See the License for the specific language governing permissions and
//  limitations under the License.
//

package tool

import (
	"context"
	"crypto/ecdsa"
	"crypto/elliptic"
	"crypto/rand"
	"crypto/tls"
	"crypto/x509"
	"crypto/x509/pkix"
	"errors"
	"fmt"
	"io"
	"math/big"
	"net"
	"net/http"
	"net/http/httptest"
	neturl "net/url"
	"sync/atomic"
	"testing"
	"time"
)

func newTestHelper(maxAttempts int, base, max time.Duration) *HTTPHelper {
	return NewHTTPHelperWithRetry(RetryConfig{
		MaxAttempts: maxAttempts,
		BaseBackoff: base,
		MaxBackoff:  max,
	})
}

// TestHTTPHelper_HappyPath verifies a 2xx response is returned on the first
// attempt with no retry, and the body / content-type round-trip cleanly.
func TestHTTPHelper_HappyPath(t *testing.T) {
	t.Parallel()

	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusOK)
		_, _ = io.WriteString(w, `{"ok":true}`)
	}))
	defer srv.Close()

	h := newTestHelper(3, 1*time.Millisecond, 5*time.Millisecond)
	resp, err := h.Do(context.Background(), http.MethodGet, srv.URL, "", "", nil)
	if err != nil {
		t.Fatalf("Do returned error: %v", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		t.Fatalf("status = %d, want 200", resp.StatusCode)
	}
	body, err := io.ReadAll(resp.Body)
	if err != nil {
		t.Fatalf("read body: %v", err)
	}
	if string(body) != `{"ok":true}` {
		t.Fatalf("body = %q, want %q", body, `{"ok":true}`)
	}
}

// TestHTTPHelper_RetriesOn5xx verifies that the helper retries on 5xx and
// returns the first 2xx response. Server returns 503 twice, then 200.
func TestHTTPHelper_RetriesOn5xx(t *testing.T) {
	t.Parallel()

	var hits int32
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		n := atomic.AddInt32(&hits, 1)
		if n < 3 {
			w.WriteHeader(http.StatusServiceUnavailable)
			return
		}
		w.WriteHeader(http.StatusOK)
		_, _ = io.WriteString(w, "recovered")
	}))
	defer srv.Close()

	h := newTestHelper(3, 1*time.Millisecond, 5*time.Millisecond)
	resp, err := h.Do(context.Background(), http.MethodGet, srv.URL, "", "", nil)
	if err != nil {
		t.Fatalf("Do returned error: %v", err)
	}
	defer resp.Body.Close()

	if got := atomic.LoadInt32(&hits); got != 3 {
		t.Fatalf("server hits = %d, want 3 (2 retries + 1 success)", got)
	}
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("status = %d, want 200", resp.StatusCode)
	}
	body, _ := io.ReadAll(resp.Body)
	if string(body) != "recovered" {
		t.Fatalf("body = %q, want %q", body, "recovered")
	}
}

// TestHTTPHelper_NoRetryOn4xx verifies that 4xx is returned immediately with
// no retry — the caller is responsible for fixing 4xx, retrying won't help.
func TestHTTPHelper_NoRetryOn4xx(t *testing.T) {
	t.Parallel()

	var hits int32
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		atomic.AddInt32(&hits, 1)
		w.WriteHeader(http.StatusBadRequest)
		_, _ = io.WriteString(w, "bad")
	}))
	defer srv.Close()

	h := newTestHelper(3, 1*time.Millisecond, 5*time.Millisecond)
	resp, err := h.Do(context.Background(), http.MethodGet, srv.URL, "", "", nil)
	if err != nil {
		t.Fatalf("Do returned error: %v", err)
	}
	defer resp.Body.Close()

	if got := atomic.LoadInt32(&hits); got != 1 {
		t.Fatalf("server hits = %d, want 1 (no retry on 4xx)", got)
	}
	if resp.StatusCode != http.StatusBadRequest {
		t.Fatalf("status = %d, want 400", resp.StatusCode)
	}
}

// TestHTTPHelper_Timeout verifies that a context deadline aborts the call
// and returns context.DeadlineExceeded, with no retry on the server
// (context errors are not retryable per isRetryableNetError).
//
// This test does NOT assert on wall-clock elapsed time. A CPU-stressed
// runner can delay the deadline timer fire and goroutine scheduling
// arbitrarily, so absolute timing thresholds are fragile. Instead the
// load-bearing assertions are behavioral:
//
//  1. err wraps context.DeadlineExceeded (the caller can branch on it).
//  2. The server is hit exactly once — no retry loop iterates while the
//     caller's context is already dead.
//
// These are the two properties downstream code actually depends on; the
// previous "Do took < 250ms" assertion conflated them with CPU jitter
// and flaked under load.
func TestHTTPHelper_Timeout(t *testing.T) {
	t.Parallel()

	var hits int32
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		atomic.AddInt32(&hits, 1)
		// Block well past the caller's deadline. If the helper retried
		// (e.g. a regression that drops the isRetryableNetError
		// short-circuit), the second attempt would land here and
		// bump hits to 2.
		time.Sleep(500 * time.Millisecond)
		w.WriteHeader(http.StatusOK)
	}))
	defer srv.Close()

	h := NewHTTPHelper().WithClient(&http.Client{
		Timeout: 30 * time.Second,
	})
	// Tight 50ms deadline. The server takes 500ms, so this call must
	// abort due to the context, not the server finishing.
	ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
	defer cancel()

	_, err := h.Do(ctx, http.MethodGet, srv.URL, "", "", nil)

	if err == nil {
		t.Fatal("expected timeout error, got nil")
	}
	if !errors.Is(err, context.DeadlineExceeded) {
		t.Errorf("Do err = %v, want context.DeadlineExceeded (callers branch on errors.Is for this)", err)
	}
	if got := atomic.LoadInt32(&hits); got != 1 {
		t.Errorf("server hits = %d, want 1 (context errors are not retryable — a regression to retry on ctx-deadline would show 2+)", got)
	}
}

// TestHTTPHelper_5xxExhaustion verifies that after MaxAttempts the last
// 5xx error is returned.
func TestHTTPHelper_5xxExhaustion(t *testing.T) {
	t.Parallel()

	var hits int32
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		atomic.AddInt32(&hits, 1)
		w.WriteHeader(http.StatusInternalServerError)
	}))
	defer srv.Close()

	h := newTestHelper(3, 1*time.Millisecond, 5*time.Millisecond)
	_, err := h.Do(context.Background(), http.MethodGet, srv.URL, "", "", nil)
	if err == nil {
		t.Fatal("expected error after 5xx exhaustion, got nil")
	}
	if got := atomic.LoadInt32(&hits); got != 3 {
		t.Fatalf("server hits = %d, want 3", got)
	}
}

// TestHTTPHelper_HeadersAndContentType verifies the helper propagates
// custom headers and a non-empty content-type on POST bodies.
func TestHTTPHelper_HeadersAndContentType(t *testing.T) {
	t.Parallel()

	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if got := r.Header.Get("X-Token"); got != "abc" {
			t.Errorf("X-Token = %q, want abc", got)
		}
		if got := r.Header.Get("Content-Type"); got != "application/json" {
			t.Errorf("Content-Type = %q, want application/json", got)
		}
		body, _ := io.ReadAll(r.Body)
		w.Header().Set("X-Echo-Body", string(body))
		w.WriteHeader(http.StatusOK)
		_, _ = w.Write(body)
	}))
	defer srv.Close()

	h := newTestHelper(1, 1*time.Millisecond, 5*time.Millisecond)
	resp, err := h.Do(context.Background(), http.MethodPost, srv.URL, `{"k":1}`, "application/json", map[string]string{"X-Token": "abc"})
	if err != nil {
		t.Fatalf("Do: %v", err)
	}
	defer resp.Body.Close()

	if got := resp.Header.Get("X-Echo-Body"); got != `{"k":1}` {
		t.Fatalf("echoed body = %q, want %q", got, `{"k":1}`)
	}
}

// TestBackoffExponential verifies the helper-internal backoff function grows
// exponentially and caps at MaxBackoff.
func TestBackoffExponential(t *testing.T) {
	t.Parallel()

	base := 50 * time.Millisecond
	max := 300 * time.Millisecond

	got1 := backoff(base, max, 1)
	if got1 < 0 || got1 > base {
		t.Fatalf("backoff(attempt=1) = %s, want [0, %s]", got1, base)
	}
	got3 := backoff(base, max, 3)
	if got3 < 0 || got3 > max {
		t.Fatalf("backoff(attempt=3) = %s, want [0, %s] (capped)", got3, max)
	}
	// With base=50ms, attempt=10 should be capped at 300ms.
	got10 := backoff(base, max, 10)
	if got10 > max {
		t.Fatalf("backoff(attempt=10) = %s, want <= %s (cap)", got10, max)
	}
}

// TestRetryConfigDefaults ensures zero-value RetryConfig falls
// back to the documented defaults.
func TestRetryConfigDefaults(t *testing.T) {
	t.Parallel()

	c := RetryConfig{}.withDefaults()
	if c.MaxAttempts != 3 {
		t.Errorf("MaxAttempts = %d, want 3", c.MaxAttempts)
	}
	if c.BaseBackoff != 200*time.Millisecond {
		t.Errorf("BaseBackoff = %s, want 200ms", c.BaseBackoff)
	}
	if c.MaxBackoff != 3*time.Second {
		t.Errorf("MaxBackoff = %s, want 3s", c.MaxBackoff)
	}
}

// TestHTTPHelper_DoPinnedHTTPS_PreservesSNIAndCert is the regression
// test for the M1-rebinding fix as hardened by the post-Phase-7
// review: DNS pinning MUST happen at the transport layer, not by
// rewriting the request URL. If the URL host were rewritten to the IP,
// the TLS ServerName (auto-populated by Go from req.URL.Host) would
// become the IP, the SNI would send the IP, and cert verification
// would target the IP — which is not what real HTTPS sites have, and
// would manifest as x509 errors against any host-cert-only target.
//
// This test stands up a real TLS server with a cert whose DNS SAN is
// "example.test" and whose IP SAN covers the loopback address. The
// pinned dialer connects to 127.0.0.1, but the request URL host stays
// as "example.test". The server observes the SNI the client sent and
// we assert it equals "example.test" (not the IP), and the request
// completes successfully (cert verification passes because the URL
// host matches the SAN).
func TestHTTPHelper_DoPinnedHTTPS_PreservesSNIAndCert(t *testing.T) {
	t.Parallel()

	// Cert valid for "example.test" (DNS SAN) and 127.0.0.1, ::1
	// (IP SANs, just so the test environment itself can resolve).
	cert := generateTestCert(t, "example.test")

	var observedSNI string
	srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		_, _ = io.WriteString(w, "ok")
	}))
	srv.TLS = &tls.Config{
		Certificates: []tls.Certificate{cert},
		GetCertificate: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
			observedSNI = hello.ServerName
			return &cert, nil
		},
	}
	srv.StartTLS()
	defer srv.Close()

	// srv.URL is https://127.0.0.1:<port>. We extract the port and
	// re-build the target URL with "example.test" as the host — i.e.
	// the URL host we send the request with is NOT 127.0.0.1, even
	// though the connection itself goes to 127.0.0.1.
	u, perr := neturl.Parse(srv.URL)
	if perr != nil {
		t.Fatalf("parse server URL %q: %v", srv.URL, perr)
	}
	serverHost, serverPort, sperr := net.SplitHostPort(u.Host)
	if sperr != nil {
		t.Fatalf("split server host:port from %q: %v", u.Host, sperr)
	}
	targetURL := fmt.Sprintf("https://example.test:%s/", serverPort)
	pinnedIP := net.ParseIP(serverHost)
	if pinnedIP == nil {
		t.Fatalf("server host %q is not an IP literal", serverHost)
	}

	// Trust the self-signed cert for the duration of the test by
	// mutating baseTransport directly. This is the supported way to
	// install a custom trust store (see WithClient's docstring).
	leaf, lperr := x509.ParseCertificate(cert.Certificate[0])
	if lperr != nil {
		t.Fatalf("parse leaf cert: %v", lperr)
	}
	pool := x509.NewCertPool()
	pool.AddCert(leaf)

	h := NewHTTPHelper()
	h.baseTransport.TLSClientConfig = &tls.Config{RootCAs: pool}

	resp, err := h.DoPinned(context.Background(),
		http.MethodGet, targetURL, "", "", nil, "example.test", pinnedIP)
	if err != nil {
		t.Fatalf("DoPinned: %v", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		body, _ := io.ReadAll(resp.Body)
		t.Fatalf("status = %d, want 200; body=%s", resp.StatusCode, body)
	}
	if observedSNI != "example.test" {
		t.Errorf("SNI = %q, want %q — DoPinned must keep the URL host for SNI, "+
			"otherwise HTTPS breaks for any host-cert-only target", observedSNI, "example.test")
	}
}

// TestHTTPHelper_DoPinnedRefusesMismatchedURLHost locks in the
// defense-in-depth check in DoPinned: a caller that passes a URL
// whose host does not match originalHost would produce a request
// whose TLS ServerName differs from the validated hostname, which is
// exactly the SSRF / rebinding bypass we are trying to prevent. We
// refuse rather than silently deliver a broken connection.
func TestHTTPHelper_DoPinnedRefusesMismatchedURLHost(t *testing.T) {
	t.Parallel()

	h := NewHTTPHelper()
	_, err := h.DoPinned(context.Background(),
		http.MethodGet,
		"https://attacker.example/foo",
		"", "", nil,
		"real.example", // originalHost from resolver
		net.ParseIP("1.2.3.4"))
	if err == nil {
		t.Fatal("DoPinned accepted a mismatched URL host, want error")
	}
	if got := err.Error(); !contains(got, "would break TLS SNI") {
		t.Errorf("error %q does not mention TLS SNI breakage", got)
	}
}

// TestHTTPHelper_DoPinnedBypassesProxy locks in the proxy bypass
// added after the post-Phase-7 review: even when baseTransport.Proxy
// is set (e.g. via HTTP_PROXY / HTTPS_PROXY env), DoPinned must NOT
// route through the proxy. Two failure modes were possible without
// the bypass:
//
//  1. *http.Transport dials the proxy first; pinnedDialer would
//     rewrite the proxy's own address to pinnedIP:proxyPort and the
//     connection would fail in any proxied deployment.
//
//  2. Even if the dialer were proxy-aware, the proxy would receive
//     the original hostname and re-resolve it, re-opening the
//     rebinding window the SSRF guard just closed.
//
// The test points baseTransport.Proxy at a port that is guaranteed
// to be closed (127.0.0.1:1) — if DoPinned used the proxy, the
// request would fail with "connection refused"; if it correctly
// bypasses the proxy, the direct dial to 127.0.0.1 succeeds.
func TestHTTPHelper_DoPinnedBypassesProxy(t *testing.T) {
	t.Parallel()

	cert := generateTestCert(t, "example.test")
	srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		_, _ = io.WriteString(w, "ok")
	}))
	srv.TLS = &tls.Config{Certificates: []tls.Certificate{cert}}
	srv.StartTLS()
	defer srv.Close()

	u, perr := neturl.Parse(srv.URL)
	if perr != nil {
		t.Fatalf("parse server URL: %v", perr)
	}
	serverHost, serverPort, sperr := net.SplitHostPort(u.Host)
	if sperr != nil {
		t.Fatalf("split host:port: %v", sperr)
	}
	targetURL := fmt.Sprintf("https://example.test:%s/", serverPort)
	pinnedIP := net.ParseIP(serverHost)
	if pinnedIP == nil {
		t.Fatalf("server host %q is not an IP literal", serverHost)
	}

	leaf, lperr := x509.ParseCertificate(cert.Certificate[0])
	if lperr != nil {
		t.Fatalf("parse leaf cert: %v", lperr)
	}
	pool := x509.NewCertPool()
	pool.AddCert(leaf)

	h := NewHTTPHelper()
	h.baseTransport.TLSClientConfig = &tls.Config{RootCAs: pool}

	// Point the proxy at a port the OS just gave us and which we then
	// released. The port is therefore guaranteed to be closed at this
	// moment (modulo the microsecond race where another process grabs
	// it between Close() and the test dial — acceptable in a unit
	// test). This is more self-documenting than reaching for a
	// "guaranteed-unused" magic port like 127.0.0.1:1.
	probe, lerr := net.Listen("tcp", "127.0.0.1:0")
	if lerr != nil {
		t.Fatalf("listen for a free port: %v", lerr)
	}
	closedAddr := probe.Addr().String()
	if cerr := probe.Close(); cerr != nil {
		t.Fatalf("close probe listener: %v", cerr)
	}
	closedProxy, perr := neturl.Parse("http://" + closedAddr)
	if perr != nil {
		t.Fatalf("parse closed proxy URL: %v", perr)
	}
	h.baseTransport.Proxy = http.ProxyURL(closedProxy)

	resp, err := h.DoPinned(context.Background(),
		http.MethodGet, targetURL, "", "", nil, "example.test", pinnedIP)
	if err != nil {
		t.Fatalf("DoPinned: %v (proxy may not have been bypassed)", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		body, _ := io.ReadAll(resp.Body)
		t.Fatalf("status = %d, want 200; body=%s", resp.StatusCode, body)
	}
}

// TestPinnedDialer_RewritesAddress verifies the unit-level behaviour
// of pinnedDialer: it discards the host in the dial address and
// connects to pinnedIP:port, preserving the port. This is the
// transport-layer primitive that DoPinned uses.
func TestPinnedDialer_RewritesAddress(t *testing.T) {
	t.Parallel()

	// Stand up a TCP server on 127.0.0.1:<random> and capture the
	// accepted conn to confirm the pinned dialer actually dialed it.
	ln, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		t.Fatalf("listen: %v", err)
	}
	defer ln.Close()

	accepted := make(chan struct{}, 1)
	go func() {
		c, aerr := ln.Accept()
		if aerr == nil {
			_ = c.Close()
		}
		accepted <- struct{}{}
	}()

	d := &pinnedDialer{
		pinnedIP: net.ParseIP("127.0.0.1"),
		base:     &net.Dialer{Timeout: 2 * time.Second},
	}
	// Pass a deliberately misleading host in the addr — the dialer
	// must ignore it and dial 127.0.0.1:<ln port> instead.
	misleading := net.JoinHostPort("203.0.113.99", fmt.Sprint(ln.Addr().(*net.TCPAddr).Port))
	conn, derr := d.DialContext(context.Background(), "tcp", misleading)
	if derr != nil {
		t.Fatalf("DialContext: %v", derr)
	}
	_ = conn.Close()

	select {
	case <-accepted:
	case <-time.After(2 * time.Second):
		t.Fatal("pinned dialer did not connect to 127.0.0.1:port (or listener did not accept)")
	}
}

// contains is a tiny helper to avoid dragging in strings just for one
// assertion. (The rest of the file uses strings.Contains.)
func contains(s, sub string) bool {
	for i := 0; i+len(sub) <= len(s); i++ {
		if s[i:i+len(sub)] == sub {
			return true
		}
	}
	return false
}

// generateTestCert builds a self-signed ECDSA cert valid for dnsName
// (DNS SAN) and the loopback addresses (IP SANs). The cert is not
// trusted by the system pool — tests must inject it into RootCAs to
// use it. Returned together with a t.Cleanup-free form so tests can
// store the certificate in their tls.Config.Certificates.
func generateTestCert(t *testing.T, dnsName string) tls.Certificate {
	t.Helper()

	priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
	if err != nil {
		t.Fatalf("ecdsa.GenerateKey: %v", err)
	}
	template := x509.Certificate{
		SerialNumber:          big.NewInt(1),
		Subject:               pkix.Name{Organization: []string{"RAGFlow Tool Test"}},
		NotBefore:             time.Now().Add(-time.Hour),
		NotAfter:              time.Now().Add(time.Hour),
		KeyUsage:              x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
		ExtKeyUsage:           []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
		BasicConstraintsValid: true,
		DNSNames:              []string{dnsName},
		IPAddresses:           []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("::1")},
	}
	der, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
	if err != nil {
		t.Fatalf("x509.CreateCertificate: %v", err)
	}
	return tls.Certificate{
		Certificate: [][]byte{der},
		PrivateKey:  priv,
	}
}
