package http

import (
	"context"
	"errors"
	"galaxy/connector"
	gerr "galaxy/error"
	"galaxy/model/report"
	"io"
	"net"
	stdhttp "net/http"
	"net/http/httptest"
	"net/url"
	"reflect"
	"strings"
	"testing"
	"time"
)

// checkConnectionCase describes one CheckConnection behavior scenario.
type checkConnectionCase struct {
	name     string
	setup    func(t *testing.T) (*httpConnector, <-chan string)
	want     bool
	wantPath string
}

// checkVersionCase describes one CheckVersion behavior scenario.
type checkVersionCase struct {
	name     string
	setup    func(t *testing.T) (*httpConnector, <-chan string)
	want     []connector.VersionInfo
	wantErr  bool
	wantPath string
}

// downloadVersionCase describes one DownloadVersion behavior scenario.
type downloadVersionCase struct {
	name     string
	setup    func(t *testing.T) (*httpConnector, string, <-chan string, func(t *testing.T))
	want     []byte
	wantErr  bool
	wantPath string
}

// fetchReportCase describes one FetchReport behavior scenario.
type fetchReportCase struct {
	name        string
	setup       func(t *testing.T) (*httpConnector, <-chan requestDetails)
	turn        uint
	want        report.Report
	wantErr     bool
	wantPath    string
	wantPlayer  string
	wantTurn    string
	wantRequest bool
}

// fetchReportResult captures one asynchronous FetchReport callback result.
type fetchReportResult struct {
	report report.Report
	err    error
}

// requestDetails captures one received request path and query parameters.
type requestDetails struct {
	path  string
	query url.Values
}

// TestCheckConnection verifies backend reachability probe behavior.
func TestCheckConnection(t *testing.T) {
	tests := []checkConnectionCase{
		{
			name: "status 200 returns true",
			setup: func(t *testing.T) (*httpConnector, <-chan string) {
				return newServerConnector(t, context.Background(), stdhttp.StatusOK, "")
			},
			want:     true,
			wantPath: "/api/v1/status",
		},
		{
			name: "non-2xx status returns true",
			setup: func(t *testing.T) (*httpConnector, <-chan string) {
				return newServerConnector(t, context.Background(), stdhttp.StatusServiceUnavailable, "")
			},
			want:     true,
			wantPath: "/api/v1/status",
		},
		{
			name: "canceled context returns false",
			setup: func(t *testing.T) (*httpConnector, <-chan string) {
				ctx, cancel := context.WithCancel(context.Background())
				cancel()
				conn, err := NewHttpConnector(ctx, "http://127.0.0.1")
				if err != nil {
					t.Fatalf("NewHttpConnector() error = %v", err)
				}
				return conn, nil
			},
			want: false,
		},
		{
			name: "transport failure returns false",
			setup: func(t *testing.T) (*httpConnector, <-chan string) {
				return newUnreachableConnector(t, context.Background()), nil
			},
			want: false,
		},
		{
			name: "backend path prefix is preserved",
			setup: func(t *testing.T) (*httpConnector, <-chan string) {
				return newServerConnector(t, context.Background(), stdhttp.StatusOK, "/base")
			},
			want:     true,
			wantPath: "/base/api/v1/status",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			conn, pathCh := tt.setup(t)

			got := conn.CheckConnection()
			if got != tt.want {
				t.Fatalf("CheckConnection() = %v, want %v", got, tt.want)
			}

			if tt.wantPath == "" {
				return
			}

			select {
			case gotPath := <-pathCh:
				if gotPath != tt.wantPath {
					t.Fatalf("request path = %q, want %q", gotPath, tt.wantPath)
				}
			default:
				t.Fatalf("expected request path %q, got no request", tt.wantPath)
			}
		})
	}
}

// TestCheckVersion verifies versions retrieval behavior.
func TestCheckVersion(t *testing.T) {
	tests := []checkVersionCase{
		{
			name: "status 200 with valid body returns versions",
			setup: func(t *testing.T) (*httpConnector, <-chan string) {
				return newVersionServerConnector(
					t,
					context.Background(),
					stdhttp.StatusOK,
					`[{"os":"darwin","arch":"amd64","kind":"executable","version":"1.2.3","url":"https://example.com/darwin"}]`,
					"",
				)
			},
			want: []connector.VersionInfo{
				{OS: "darwin", Arch: "amd64", Kind: connector.ArtifactKindExecutable, Version: "1.2.3", URL: "https://example.com/darwin"},
			},
			wantPath: "/api/v1/versions",
		},
		{
			name: "status 200 with invalid json returns error",
			setup: func(t *testing.T) (*httpConnector, <-chan string) {
				return newVersionServerConnector(
					t,
					context.Background(),
					stdhttp.StatusOK,
					`{"versions":`,
					"",
				)
			},
			wantErr:  true,
			wantPath: "/api/v1/versions",
		},
		{
			name: "non-200 status returns error",
			setup: func(t *testing.T) (*httpConnector, <-chan string) {
				return newVersionServerConnector(
					t,
					context.Background(),
					stdhttp.StatusServiceUnavailable,
					`[]`,
					"",
				)
			},
			wantErr:  true,
			wantPath: "/api/v1/versions",
		},
		{
			name: "canceled context returns error",
			setup: func(t *testing.T) (*httpConnector, <-chan string) {
				ctx, cancel := context.WithCancel(context.Background())
				cancel()
				conn, err := NewHttpConnector(ctx, "http://127.0.0.1")
				if err != nil {
					t.Fatalf("NewHttpConnector() error = %v", err)
				}
				return conn, nil
			},
			wantErr: true,
		},
		{
			name: "transport failure returns error",
			setup: func(t *testing.T) (*httpConnector, <-chan string) {
				return newUnreachableConnector(t, context.Background()), nil
			},
			wantErr: true,
		},
		{
			name: "backend path prefix is preserved",
			setup: func(t *testing.T) (*httpConnector, <-chan string) {
				return newVersionServerConnector(
					t,
					context.Background(),
					stdhttp.StatusOK,
					`[{"os":"linux","arch":"amd64","kind":"executable","version":"2.0.0","url":"https://example.com/linux"}]`,
					"/base",
				)
			},
			want: []connector.VersionInfo{
				{OS: "linux", Arch: "amd64", Kind: connector.ArtifactKindExecutable, Version: "2.0.0", URL: "https://example.com/linux"},
			},
			wantPath: "/base/api/v1/versions",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			conn, pathCh := tt.setup(t)

			got, err := conn.CheckVersion()
			if tt.wantErr {
				if err == nil {
					t.Fatal("CheckVersion() error = nil, want non-nil")
				}
			} else {
				if err != nil {
					t.Fatalf("CheckVersion() error = %v, want nil", err)
				}
				if !reflect.DeepEqual(got, tt.want) {
					t.Fatalf("CheckVersion() = %#v, want %#v", got, tt.want)
				}
			}

			if tt.wantPath == "" {
				return
			}

			select {
			case gotPath := <-pathCh:
				if gotPath != tt.wantPath {
					t.Fatalf("request path = %q, want %q", gotPath, tt.wantPath)
				}
			default:
				t.Fatalf("expected request path %q, got no request", tt.wantPath)
			}
		})
	}
}

// TestCheckVersionClassifiesTransportFailure verifies transport failures are surfaced as connection errors.
func TestCheckVersionClassifiesTransportFailure(t *testing.T) {
	t.Parallel()

	conn := newUnreachableConnector(t, context.Background())

	_, err := conn.CheckVersion()
	if err == nil {
		t.Fatal("CheckVersion() error = nil, want non-nil")
	}
	if !gerr.IsConnection(err) {
		t.Fatalf("CheckVersion() error = %v, want connection classified error", err)
	}
}

// TestCheckVersionClassifiesInvalidJSON verifies malformed backend payloads are surfaced as service errors.
func TestCheckVersionClassifiesInvalidJSON(t *testing.T) {
	t.Parallel()

	conn, _ := newVersionServerConnector(
		t,
		context.Background(),
		stdhttp.StatusOK,
		`{"versions":`,
		"",
	)

	_, err := conn.CheckVersion()
	if err == nil {
		t.Fatal("CheckVersion() error = nil, want non-nil")
	}
	if !gerr.IsService(err) {
		t.Fatalf("CheckVersion() error = %v, want service classified error", err)
	}
}

// TestDownloadVersionClassifiesUnexpectedStatus verifies HTTP protocol failures are surfaced as service errors.
func TestDownloadVersionClassifiesUnexpectedStatus(t *testing.T) {
	t.Parallel()

	server := httptest.NewServer(stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, r *stdhttp.Request) {
		w.WriteHeader(stdhttp.StatusBadGateway)
	}))
	t.Cleanup(server.Close)

	conn, err := NewHttpConnector(context.Background(), server.URL)
	if err != nil {
		t.Fatalf("NewHttpConnector() error = %v", err)
	}

	_, err = conn.DownloadVersion("downloads/client.bin")
	if err == nil {
		t.Fatal("DownloadVersion() error = nil, want non-nil")
	}
	if !gerr.IsService(err) {
		t.Fatalf("DownloadVersion() error = %v, want service classified error", err)
	}
}

// TestFetchReport verifies asynchronous report retrieval behavior.
func TestFetchReport(t *testing.T) {
	tests := []fetchReportCase{
		{
			name: "status 200 with valid body returns report",
			setup: func(t *testing.T) (*httpConnector, <-chan requestDetails) {
				return newFetchReportServerConnector(
					t,
					context.Background(),
					stdhttp.StatusOK,
					`{"version":2,"turn":7,"mapWidth":120,"mapHeight":80,"race":"Race_01","votes":1.5}`,
					"",
				)
			},
			turn: 7,
			want: report.Report{
				Version: 2,
				Turn:    7,
				Width:   120,
				Height:  80,
				Race:    "Race_01",
				Votes:   report.Float(1.5),
			},
			wantPath:    "/api/v1/report",
			wantPlayer:  fetchReportPlayer,
			wantTurn:    "7",
			wantRequest: true,
		},
		{
			name: "status 200 with invalid json returns error",
			setup: func(t *testing.T) (*httpConnector, <-chan requestDetails) {
				return newFetchReportServerConnector(
					t,
					context.Background(),
					stdhttp.StatusOK,
					`{"turn":`,
					"",
				)
			},
			turn:        8,
			wantErr:     true,
			wantPath:    "/api/v1/report",
			wantPlayer:  fetchReportPlayer,
			wantTurn:    "8",
			wantRequest: true,
		},
		{
			name: "non-200 status returns error",
			setup: func(t *testing.T) (*httpConnector, <-chan requestDetails) {
				return newFetchReportServerConnector(
					t,
					context.Background(),
					stdhttp.StatusBadGateway,
					`{}`,
					"",
				)
			},
			turn:        9,
			wantErr:     true,
			wantPath:    "/api/v1/report",
			wantPlayer:  fetchReportPlayer,
			wantTurn:    "9",
			wantRequest: true,
		},
		{
			name: "canceled context returns error",
			setup: func(t *testing.T) (*httpConnector, <-chan requestDetails) {
				ctx, cancel := context.WithCancel(context.Background())
				cancel()
				return newFetchReportServerConnector(
					t,
					ctx,
					stdhttp.StatusOK,
					`{"turn":1}`,
					"",
				)
			},
			turn:        10,
			wantErr:     true,
			wantRequest: false,
		},
		{
			name: "backend path prefix is preserved",
			setup: func(t *testing.T) (*httpConnector, <-chan requestDetails) {
				return newFetchReportServerConnector(
					t,
					context.Background(),
					stdhttp.StatusOK,
					`{"turn":11,"mapWidth":20,"mapHeight":30}`,
					"/base",
				)
			},
			turn: 11,
			want: report.Report{
				Turn:   11,
				Width:  20,
				Height: 30,
			},
			wantPath:    "/base/api/v1/report",
			wantPlayer:  fetchReportPlayer,
			wantTurn:    "11",
			wantRequest: true,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			conn, requestCh := tt.setup(t)
			resultCh := make(chan fetchReportResult, 1)

			conn.FetchReport("", tt.turn, func(rep report.Report, err error) {
				resultCh <- fetchReportResult{report: rep, err: err}
			})

			select {
			case result := <-resultCh:
				if tt.wantErr {
					if result.err == nil {
						t.Fatal("FetchReport() error = nil, want non-nil")
					}
				} else {
					if result.err != nil {
						t.Fatalf("FetchReport() error = %v, want nil", result.err)
					}
					if !reflect.DeepEqual(result.report, tt.want) {
						t.Fatalf("FetchReport() report = %#v, want %#v", result.report, tt.want)
					}
				}
			case <-time.After(time.Second):
				t.Fatal("FetchReport() callback was not called")
			}

			if !tt.wantRequest {
				select {
				case req := <-requestCh:
					t.Fatalf("unexpected request = %#v", req)
				case <-time.After(100 * time.Millisecond):
				}
				return
			}

			select {
			case req := <-requestCh:
				if req.path != tt.wantPath {
					t.Fatalf("request path = %q, want %q", req.path, tt.wantPath)
				}
				if got := req.query.Get("player"); got != tt.wantPlayer {
					t.Fatalf("request player = %q, want %q", got, tt.wantPlayer)
				}
				if got := req.query.Get("turn"); got != tt.wantTurn {
					t.Fatalf("request turn = %q, want %q", got, tt.wantTurn)
				}
			case <-time.After(time.Second):
				t.Fatal("expected request, got none")
			}
		})
	}
}

// TestFetchReportAsync verifies FetchReport returns immediately and calls callback once after response is ready.
func TestFetchReportAsync(t *testing.T) {
	requestCh := make(chan requestDetails, 1)
	releaseResponse := make(chan struct{})
	server := httptest.NewServer(stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, r *stdhttp.Request) {
		requestCh <- requestDetails{path: r.URL.Path, query: r.URL.Query()}
		<-releaseResponse
		w.WriteHeader(stdhttp.StatusOK)
		_, _ = w.Write([]byte(`{"turn":12,"mapWidth":1,"mapHeight":1}`))
	}))
	t.Cleanup(server.Close)

	conn, err := NewHttpConnector(context.Background(), server.URL)
	if err != nil {
		t.Fatalf("NewHttpConnector() error = %v", err)
	}

	resultCh := make(chan fetchReportResult, 2)
	start := time.Now()
	conn.FetchReport("", 12, func(rep report.Report, err error) {
		resultCh <- fetchReportResult{report: rep, err: err}
	})
	if elapsed := time.Since(start); elapsed > 50*time.Millisecond {
		t.Fatalf("FetchReport() elapsed = %v, want quick return", elapsed)
	}

	select {
	case req := <-requestCh:
		if req.path != "/api/v1/report" {
			t.Fatalf("request path = %q, want %q", req.path, "/api/v1/report")
		}
		if got := req.query.Get("player"); got != fetchReportPlayer {
			t.Fatalf("request player = %q, want %q", got, fetchReportPlayer)
		}
		if got := req.query.Get("turn"); got != "12" {
			t.Fatalf("request turn = %q, want %q", got, "12")
		}
	case <-time.After(time.Second):
		t.Fatal("expected request, got none")
	}

	select {
	case result := <-resultCh:
		t.Fatalf("unexpected early callback = %#v", result)
	case <-time.After(100 * time.Millisecond):
	}

	close(releaseResponse)

	select {
	case result := <-resultCh:
		if result.err != nil {
			t.Fatalf("FetchReport() error = %v, want nil", result.err)
		}
		if result.report.Turn != 12 {
			t.Fatalf("FetchReport() report turn = %d, want %d", result.report.Turn, 12)
		}
	case <-time.After(time.Second):
		t.Fatal("FetchReport() callback was not called")
	}

	select {
	case extra := <-resultCh:
		t.Fatalf("FetchReport() callback called more than once: %#v", extra)
	case <-time.After(100 * time.Millisecond):
	}
}

// TestDownloadVersion verifies artifact download behavior for relative and absolute URLs.
func TestDownloadVersion(t *testing.T) {
	tests := []downloadVersionCase{
		{
			name: "relative path uses backend URL",
			setup: func(t *testing.T) (*httpConnector, string, <-chan string, func(t *testing.T)) {
				pathCh := make(chan string, 1)
				server := httptest.NewServer(stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, r *stdhttp.Request) {
					pathCh <- r.URL.Path
					w.WriteHeader(stdhttp.StatusOK)
					_, _ = w.Write([]byte("artifact"))
				}))
				t.Cleanup(server.Close)

				conn, err := NewHttpConnector(context.Background(), server.URL+"/base")
				if err != nil {
					t.Fatalf("NewHttpConnector() error = %v", err)
				}

				return conn, "downloads/client.bin", pathCh, func(t *testing.T) {}
			},
			want:     []byte("artifact"),
			wantPath: "/base/downloads/client.bin",
		},
		{
			name: "fully qualified URL bypasses backend URL",
			setup: func(t *testing.T) (*httpConnector, string, <-chan string, func(t *testing.T)) {
				backendPathCh := make(chan string, 1)
				backendServer := httptest.NewServer(stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, r *stdhttp.Request) {
					backendPathCh <- r.URL.Path
					w.WriteHeader(stdhttp.StatusInternalServerError)
				}))
				t.Cleanup(backendServer.Close)

				downloadPathCh := make(chan string, 1)
				downloadServer := httptest.NewServer(stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, r *stdhttp.Request) {
					downloadPathCh <- r.URL.Path
					w.WriteHeader(stdhttp.StatusOK)
					_, _ = w.Write([]byte("external-artifact"))
				}))
				t.Cleanup(downloadServer.Close)

				conn, err := NewHttpConnector(context.Background(), backendServer.URL+"/base")
				if err != nil {
					t.Fatalf("NewHttpConnector() error = %v", err)
				}

				return conn, downloadServer.URL + "/artifact.bin", downloadPathCh, func(t *testing.T) {
					t.Helper()

					select {
					case gotPath := <-backendPathCh:
						t.Fatalf("unexpected backend request path = %q", gotPath)
					default:
					}
				}
			},
			want:     []byte("external-artifact"),
			wantPath: "/artifact.bin",
		},
		{
			name: "non-200 status returns error",
			setup: func(t *testing.T) (*httpConnector, string, <-chan string, func(t *testing.T)) {
				pathCh := make(chan string, 1)
				server := httptest.NewServer(stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, r *stdhttp.Request) {
					pathCh <- r.URL.Path
					w.WriteHeader(stdhttp.StatusBadGateway)
				}))
				t.Cleanup(server.Close)

				conn, err := NewHttpConnector(context.Background(), server.URL)
				if err != nil {
					t.Fatalf("NewHttpConnector() error = %v", err)
				}

				return conn, "downloads/client.bin", pathCh, func(t *testing.T) {}
			},
			wantErr:  true,
			wantPath: "/downloads/client.bin",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			conn, urlOrPath, pathCh, verify := tt.setup(t)
			got, err := conn.DownloadVersion(urlOrPath)
			if tt.wantErr {
				if err == nil {
					t.Fatal("DownloadVersion() error = nil, want non-nil")
				}
			} else {
				if err != nil {
					t.Fatalf("DownloadVersion() error = %v, want nil", err)
				}
				if !reflect.DeepEqual(got, tt.want) {
					t.Fatalf("DownloadVersion() = %q, want %q", got, tt.want)
				}
			}

			select {
			case gotPath := <-pathCh:
				if gotPath != tt.wantPath {
					t.Fatalf("request path = %q, want %q", gotPath, tt.wantPath)
				}
			default:
				t.Fatalf("expected request path %q, got no request", tt.wantPath)
			}

			verify(t)
		})
	}
}

// TestDoRequestUsesPassedContext verifies request context is provided by caller.
func TestDoRequestUsesPassedContext(t *testing.T) {
	conn, pathCh := newServerConnector(t, context.Background(), stdhttp.StatusOK, "")

	ctx, cancel := context.WithCancel(context.Background())
	cancel()

	_, err := conn.doRequest(ctx, checkConnectionPath)
	if err == nil {
		t.Fatal("doRequest() error = nil, want non-nil")
	}
	if !errors.Is(err, context.Canceled) {
		t.Fatalf("doRequest() error = %v, want context canceled", err)
	}

	select {
	case gotPath := <-pathCh:
		t.Fatalf("expected no request with canceled context, got %q", gotPath)
	default:
	}
}

// TestDoRequestMovedPermanentlyRedirectsRelative verifies 301 responses follow relative Location redirects.
func TestDoRequestMovedPermanentlyRedirectsRelative(t *testing.T) {
	requestPaths := make(chan string, 2)
	server := httptest.NewServer(stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, r *stdhttp.Request) {
		requestPaths <- r.URL.Path
		if r.URL.Path == "/base/api/v1/status" {
			w.Header().Set("Location", "/redirected")
			w.WriteHeader(stdhttp.StatusMovedPermanently)
			return
		}

		w.WriteHeader(stdhttp.StatusOK)
	}))
	t.Cleanup(server.Close)

	conn, err := NewHttpConnector(context.Background(), server.URL+"/base")
	if err != nil {
		t.Fatalf("NewHttpConnector() error = %v", err)
	}

	resp, err := conn.doRequest(context.Background(), checkConnectionPath)
	if err != nil {
		t.Fatalf("doRequest() error = %v, want nil", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != stdhttp.StatusOK {
		t.Fatalf("doRequest() status code = %d, want %d", resp.StatusCode, stdhttp.StatusOK)
	}

	gotFirstPath := <-requestPaths
	if gotFirstPath != "/base/api/v1/status" {
		t.Fatalf("first request path = %q, want %q", gotFirstPath, "/base/api/v1/status")
	}

	gotSecondPath := <-requestPaths
	if gotSecondPath != "/redirected" {
		t.Fatalf("redirect request path = %q, want %q", gotSecondPath, "/redirected")
	}
}

// TestDoRequestMovedPermanentlyRedirectsAbsolute verifies 301 responses follow absolute Location redirects.
func TestDoRequestMovedPermanentlyRedirectsAbsolute(t *testing.T) {
	initialPaths := make(chan string, 1)
	redirectPaths := make(chan string, 1)
	redirectServer := httptest.NewServer(stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, r *stdhttp.Request) {
		redirectPaths <- r.URL.Path
		w.WriteHeader(stdhttp.StatusOK)
	}))
	t.Cleanup(redirectServer.Close)

	server := httptest.NewServer(stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, r *stdhttp.Request) {
		initialPaths <- r.URL.Path
		w.Header().Set("Location", redirectServer.URL+"/redirected")
		w.WriteHeader(stdhttp.StatusMovedPermanently)
	}))
	t.Cleanup(server.Close)

	conn, err := NewHttpConnector(context.Background(), server.URL)
	if err != nil {
		t.Fatalf("NewHttpConnector() error = %v", err)
	}

	resp, err := conn.doRequest(context.Background(), checkConnectionPath)
	if err != nil {
		t.Fatalf("doRequest() error = %v, want nil", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != stdhttp.StatusOK {
		t.Fatalf("doRequest() status code = %d, want %d", resp.StatusCode, stdhttp.StatusOK)
	}

	select {
	case gotPath := <-initialPaths:
		if gotPath != "/api/v1/status" {
			t.Fatalf("initial request path = %q, want %q", gotPath, "/api/v1/status")
		}
	default:
		t.Fatal("expected initial request, got none")
	}

	select {
	case gotPath := <-redirectPaths:
		if gotPath != "/redirected" {
			t.Fatalf("redirect request path = %q, want %q", gotPath, "/redirected")
		}
	default:
		t.Fatal("expected redirect request, got none")
	}
}

// TestDoRequestMovedPermanentlyWithoutLocation verifies invalid 301 responses return an error.
func TestDoRequestMovedPermanentlyWithoutLocation(t *testing.T) {
	conn, _ := newServerConnector(t, context.Background(), stdhttp.StatusMovedPermanently, "")

	_, err := conn.doRequest(context.Background(), checkConnectionPath)
	if err == nil {
		t.Fatal("doRequest() error = nil, want non-nil")
	}
	if !errors.Is(err, errMovedPermanentlyWithoutLocation) {
		t.Fatalf("doRequest() error = %v, want %v", err, errMovedPermanentlyWithoutLocation)
	}
}

// TestDoRequestMovedPermanentlyUsesRetryBudget verifies redirect follow-up requests consume jittered attempts.
func TestDoRequestMovedPermanentlyUsesRetryBudget(t *testing.T) {
	attempts := 0
	jitterCaps := make([]time.Duration, 0)
	sleepDurations := make([]time.Duration, 0)

	conn := newTransportConnector(t, func(req *stdhttp.Request) (*stdhttp.Response, error) {
		attempts++
		switch req.URL.String() {
		case "http://example.com/api/v1/status":
			return &stdhttp.Response{
				StatusCode: stdhttp.StatusMovedPermanently,
				Header: stdhttp.Header{
					"Location": []string{"/redirected"},
				},
				Body: io.NopCloser(strings.NewReader("")),
			}, nil
		case "http://example.com/redirected":
			return &stdhttp.Response{
				StatusCode: stdhttp.StatusOK,
				Body:       io.NopCloser(strings.NewReader("")),
			}, nil
		default:
			t.Fatalf("unexpected request URL = %q", req.URL.String())
			return nil, nil
		}
	})
	conn.jitterFn = func(cap time.Duration) time.Duration {
		jitterCaps = append(jitterCaps, cap)
		return cap
	}
	conn.sleepFn = func(ctx context.Context, d time.Duration) error {
		sleepDurations = append(sleepDurations, d)
		return nil
	}

	resp, err := conn.doRequest(context.Background(), checkConnectionPath)
	if err != nil {
		t.Fatalf("doRequest() error = %v, want nil", err)
	}
	defer resp.Body.Close()

	if attempts != 2 {
		t.Fatalf("attempts = %d, want 2", attempts)
	}

	wantCaps := []time.Duration{5 * time.Second}
	if !reflect.DeepEqual(jitterCaps, wantCaps) {
		t.Fatalf("jitter caps = %v, want %v", jitterCaps, wantCaps)
	}
	if !reflect.DeepEqual(sleepDurations, wantCaps) {
		t.Fatalf("sleep durations = %v, want %v", sleepDurations, wantCaps)
	}
}

// TestDoRequestMovedPermanentlyExhaustsRetryBudget verifies repeated redirects eventually fail.
func TestDoRequestMovedPermanentlyExhaustsRetryBudget(t *testing.T) {
	attempts := 0
	jitterCaps := make([]time.Duration, 0)
	sleepDurations := make([]time.Duration, 0)

	conn := newTransportConnector(t, func(req *stdhttp.Request) (*stdhttp.Response, error) {
		attempts++
		return &stdhttp.Response{
			StatusCode: stdhttp.StatusMovedPermanently,
			Header: stdhttp.Header{
				"Location": []string{"/redirected"},
			},
			Body: io.NopCloser(strings.NewReader("")),
		}, nil
	})
	conn.jitterFn = func(cap time.Duration) time.Duration {
		jitterCaps = append(jitterCaps, cap)
		return cap
	}
	conn.sleepFn = func(ctx context.Context, d time.Duration) error {
		sleepDurations = append(sleepDurations, d)
		return nil
	}

	_, err := conn.doRequest(context.Background(), checkConnectionPath)
	if err == nil {
		t.Fatal("doRequest() error = nil, want non-nil")
	}

	wantCaps := append([]time.Duration(nil), defaultRetryCaps...)
	if attempts != len(wantCaps)+1 {
		t.Fatalf("attempts = %d, want %d", attempts, len(wantCaps)+1)
	}
	if !reflect.DeepEqual(jitterCaps, wantCaps) {
		t.Fatalf("jitter caps = %v, want %v", jitterCaps, wantCaps)
	}
	if !reflect.DeepEqual(sleepDurations, wantCaps) {
		t.Fatalf("sleep durations = %v, want %v", sleepDurations, wantCaps)
	}
}

// TestDoRequestResponseHeaderTimeout verifies client distinguishes response timeout.
func TestDoRequestResponseHeaderTimeout(t *testing.T) {
	const (
		dialTimeout           = time.Second
		headerTimeout         = 30 * time.Millisecond
		serverHeaderDelayTime = 150 * time.Millisecond
	)

	server := httptest.NewServer(stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, r *stdhttp.Request) {
		time.Sleep(serverHeaderDelayTime)
		w.WriteHeader(stdhttp.StatusOK)
	}))
	t.Cleanup(server.Close)

	backendURL, err := url.Parse(server.URL)
	if err != nil {
		t.Fatalf("parse backend URL error = %v", err)
	}

	conn := &httpConnector{
		ctx:        context.Background(),
		backendURL: backendURL,
		httpClient: newHTTPClient(dialTimeout, headerTimeout),
	}

	start := time.Now()
	_, err = conn.doRequest(context.Background(), checkConnectionPath)
	elapsed := time.Since(start)
	if err == nil {
		t.Fatal("doRequest() error = nil, want timeout")
	}
	if elapsed < headerTimeout {
		t.Fatalf("doRequest() elapsed = %v, want >= %v", elapsed, headerTimeout)
	}

	var netErr net.Error
	if !errors.As(err, &netErr) || !netErr.Timeout() {
		t.Fatalf("doRequest() error = %v, want timeout error", err)
	}
}

// TestDoRequestSuccessFirstAttemptNoRetry verifies successful call does not schedule retries.
func TestDoRequestSuccessFirstAttemptNoRetry(t *testing.T) {
	attempts := 0
	sleepCalls := 0
	jitterCalls := 0

	conn := newTransportConnector(t, func(req *stdhttp.Request) (*stdhttp.Response, error) {
		attempts++
		return &stdhttp.Response{
			StatusCode: stdhttp.StatusOK,
			Body:       io.NopCloser(strings.NewReader("")),
		}, nil
	})
	conn.jitterFn = func(cap time.Duration) time.Duration {
		jitterCalls++
		return cap
	}
	conn.sleepFn = func(ctx context.Context, d time.Duration) error {
		sleepCalls++
		return nil
	}

	resp, err := conn.doRequest(context.Background(), checkConnectionPath)
	if err != nil {
		t.Fatalf("doRequest() error = %v, want nil", err)
	}
	defer resp.Body.Close()

	if attempts != 1 {
		t.Fatalf("attempts = %d, want 1", attempts)
	}
	if jitterCalls != 0 {
		t.Fatalf("jitter calls = %d, want 0", jitterCalls)
	}
	if sleepCalls != 0 {
		t.Fatalf("sleep calls = %d, want 0", sleepCalls)
	}
}

// TestDoRequestConnectTimeoutRetriesWithJitter verifies retries for connect timeout errors.
func TestDoRequestConnectTimeoutRetriesWithJitter(t *testing.T) {
	attempts := 0
	jitterCaps := make([]time.Duration, 0)
	sleepDurations := make([]time.Duration, 0)

	conn := newTransportConnector(t, func(req *stdhttp.Request) (*stdhttp.Response, error) {
		attempts++
		if attempts <= 2 {
			return nil, newDialTimeoutError()
		}
		return &stdhttp.Response{
			StatusCode: stdhttp.StatusOK,
			Body:       io.NopCloser(strings.NewReader("")),
		}, nil
	})
	conn.jitterFn = func(cap time.Duration) time.Duration {
		jitterCaps = append(jitterCaps, cap)
		return cap
	}
	conn.sleepFn = func(ctx context.Context, d time.Duration) error {
		sleepDurations = append(sleepDurations, d)
		return nil
	}

	resp, err := conn.doRequest(context.Background(), checkConnectionPath)
	if err != nil {
		t.Fatalf("doRequest() error = %v, want nil", err)
	}
	defer resp.Body.Close()

	if attempts != 3 {
		t.Fatalf("attempts = %d, want 3", attempts)
	}

	wantCaps := []time.Duration{5 * time.Second, 15 * time.Second}
	if !reflect.DeepEqual(jitterCaps, wantCaps) {
		t.Fatalf("jitter caps = %v, want %v", jitterCaps, wantCaps)
	}
	if !reflect.DeepEqual(sleepDurations, wantCaps) {
		t.Fatalf("sleep durations = %v, want %v", sleepDurations, wantCaps)
	}
}

// TestDoRequestConnectTimeoutExhaustsRetries verifies retry count and final timeout error.
func TestDoRequestConnectTimeoutExhaustsRetries(t *testing.T) {
	attempts := 0
	jitterCaps := make([]time.Duration, 0)
	sleepDurations := make([]time.Duration, 0)

	conn := newTransportConnector(t, func(req *stdhttp.Request) (*stdhttp.Response, error) {
		attempts++
		return nil, newDialTimeoutError()
	})
	conn.jitterFn = func(cap time.Duration) time.Duration {
		jitterCaps = append(jitterCaps, cap)
		return cap
	}
	conn.sleepFn = func(ctx context.Context, d time.Duration) error {
		sleepDurations = append(sleepDurations, d)
		return nil
	}

	_, err := conn.doRequest(context.Background(), checkConnectionPath)
	if err == nil {
		t.Fatal("doRequest() error = nil, want timeout")
	}
	if !isConnectTimeout(err) {
		t.Fatalf("doRequest() error = %v, want connect timeout", err)
	}

	wantCaps := append([]time.Duration(nil), defaultRetryCaps...)
	if attempts != len(wantCaps)+1 {
		t.Fatalf("attempts = %d, want %d", attempts, len(wantCaps)+1)
	}
	if !reflect.DeepEqual(jitterCaps, wantCaps) {
		t.Fatalf("jitter caps = %v, want %v", jitterCaps, wantCaps)
	}
	if !reflect.DeepEqual(sleepDurations, wantCaps) {
		t.Fatalf("sleep durations = %v, want %v", sleepDurations, wantCaps)
	}
}

// TestDoRequestResponseTimeoutNoRetry verifies response timeout does not trigger retries.
func TestDoRequestResponseTimeoutNoRetry(t *testing.T) {
	attempts := 0
	sleepCalls := 0
	jitterCalls := 0

	conn := newTransportConnector(t, func(req *stdhttp.Request) (*stdhttp.Response, error) {
		attempts++
		return nil, newResponseHeaderTimeoutError()
	})
	conn.jitterFn = func(cap time.Duration) time.Duration {
		jitterCalls++
		return cap
	}
	conn.sleepFn = func(ctx context.Context, d time.Duration) error {
		sleepCalls++
		return nil
	}

	_, err := conn.doRequest(context.Background(), checkConnectionPath)
	if err == nil {
		t.Fatal("doRequest() error = nil, want timeout")
	}

	var netErr net.Error
	if !errors.As(err, &netErr) || !netErr.Timeout() {
		t.Fatalf("doRequest() error = %v, want timeout error", err)
	}
	if attempts != 1 {
		t.Fatalf("attempts = %d, want 1", attempts)
	}
	if jitterCalls != 0 {
		t.Fatalf("jitter calls = %d, want 0", jitterCalls)
	}
	if sleepCalls != 0 {
		t.Fatalf("sleep calls = %d, want 0", sleepCalls)
	}
}

// TestDoRequestContextCanceledDuringBackoff verifies cancellation interrupts retry wait.
func TestDoRequestContextCanceledDuringBackoff(t *testing.T) {
	attempts := 0
	sleepCalls := 0

	conn := newTransportConnector(t, func(req *stdhttp.Request) (*stdhttp.Response, error) {
		attempts++
		return nil, newDialTimeoutError()
	})
	ctx, cancel := context.WithCancel(context.Background())
	conn.jitterFn = func(cap time.Duration) time.Duration {
		return cap
	}
	conn.sleepFn = func(ctx context.Context, d time.Duration) error {
		sleepCalls++
		cancel()
		return ctx.Err()
	}

	_, err := conn.doRequest(ctx, checkConnectionPath)
	if !errors.Is(err, context.Canceled) {
		t.Fatalf("doRequest() error = %v, want context canceled", err)
	}
	if attempts != 1 {
		t.Fatalf("attempts = %d, want 1", attempts)
	}
	if sleepCalls != 1 {
		t.Fatalf("sleep calls = %d, want 1", sleepCalls)
	}
}

type roundTripperFunc func(req *stdhttp.Request) (*stdhttp.Response, error)

// RoundTrip implements [http.RoundTripper].
func (f roundTripperFunc) RoundTrip(req *stdhttp.Request) (*stdhttp.Response, error) {
	return f(req)
}

// timeoutError simulates a timeout error returned by transport.
type timeoutError struct {
	message string
}

func (e timeoutError) Error() string {
	return e.message
}

func (e timeoutError) Timeout() bool {
	return true
}

func (e timeoutError) Temporary() bool {
	return true
}

// newDialTimeoutError builds a connect timeout shaped like dial failure.
func newDialTimeoutError() error {
	return &net.OpError{
		Op:  "dial",
		Net: "tcp",
		Err: timeoutError{message: "i/o timeout"},
	}
}

// newResponseHeaderTimeoutError builds timeout error for response header wait.
func newResponseHeaderTimeoutError() error {
	return timeoutError{message: "net/http: timeout awaiting response headers"}
}

// newTransportConnector creates connector with custom round tripper for request tests.
func newTransportConnector(t *testing.T, transport roundTripperFunc) *httpConnector {
	t.Helper()

	backendURL, err := url.Parse("http://example.com")
	if err != nil {
		t.Fatalf("parse backend URL error = %v", err)
	}

	return &httpConnector{
		ctx:        context.Background(),
		backendURL: backendURL,
		httpClient: &stdhttp.Client{Transport: transport},
		retryCaps:  append([]time.Duration(nil), defaultRetryCaps...),
	}
}

// newServerConnector creates connector backed by an HTTP test server and captures requested path.
func newServerConnector(t *testing.T, ctx context.Context, status int, backendPath string) (*httpConnector, <-chan string) {
	t.Helper()

	pathCh := make(chan string, 1)
	server := httptest.NewServer(stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, r *stdhttp.Request) {
		pathCh <- r.URL.Path
		w.WriteHeader(status)
	}))
	t.Cleanup(server.Close)

	conn, err := NewHttpConnector(ctx, server.URL+backendPath)
	if err != nil {
		t.Fatalf("NewHttpConnector() error = %v", err)
	}

	return conn, pathCh
}

// newVersionServerConnector creates connector with configurable response body for versions endpoint tests.
func newVersionServerConnector(t *testing.T, ctx context.Context, status int, body, backendPath string) (*httpConnector, <-chan string) {
	t.Helper()

	pathCh := make(chan string, 1)
	server := httptest.NewServer(stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, r *stdhttp.Request) {
		pathCh <- r.URL.Path
		w.WriteHeader(status)
		_, _ = w.Write([]byte(body))
	}))
	t.Cleanup(server.Close)

	conn, err := NewHttpConnector(ctx, server.URL+backendPath)
	if err != nil {
		t.Fatalf("NewHttpConnector() error = %v", err)
	}

	return conn, pathCh
}

// newFetchReportServerConnector creates connector with configurable response body for report endpoint tests.
func newFetchReportServerConnector(t *testing.T, ctx context.Context, status int, body, backendPath string) (*httpConnector, <-chan requestDetails) {
	t.Helper()

	requestCh := make(chan requestDetails, 1)
	server := httptest.NewServer(stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, r *stdhttp.Request) {
		requestCh <- requestDetails{path: r.URL.Path, query: r.URL.Query()}
		w.WriteHeader(status)
		_, _ = w.Write([]byte(body))
	}))
	t.Cleanup(server.Close)

	conn, err := NewHttpConnector(ctx, server.URL+backendPath)
	if err != nil {
		t.Fatalf("NewHttpConnector() error = %v", err)
	}

	return conn, requestCh
}

// newUnreachableConnector creates connector pointing to a closed localhost TCP address.
func newUnreachableConnector(t *testing.T, ctx context.Context) *httpConnector {
	t.Helper()

	ln, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		t.Fatalf("listen error = %v", err)
	}
	addr := ln.Addr().String()
	if err := ln.Close(); err != nil {
		t.Fatalf("close listener error = %v", err)
	}

	conn, err := NewHttpConnector(ctx, "http://"+addr)
	if err != nil {
		t.Fatalf("NewHttpConnector() error = %v", err)
	}
	return conn
}
