package runner_test

import (
	"context"
	"io"
	"net/http"
	"net/http/httptest"
	"net/url"
	"strings"
	"testing"

	"github.com/stretchr/testify/require"

	"github.com/tidbcloud/AgentTeam/internal/extension/runner"
)

func TestRESTClientSupportsFormAndAllowlistedJSONResponseHeaders(t *testing.T) {
	server := httptest.NewTLSServer(http.HandlerFunc(
		func(writer http.ResponseWriter, request *http.Request) {
			require.Equal(t, http.MethodPost, request.Method)
			require.Equal(t, "application/x-www-form-urlencoded", request.Header.Get("Content-Type"))
			require.NoError(t, request.ParseForm())
			require.Equal(t, "release", request.Form.Get("branch"))
			writer.Header().Set("Location", "/queue/item/42/")
			writer.Header().Set("Set-Cookie", "must-not-escape=true")
			writer.WriteHeader(http.StatusCreated)
			_, _ = io.WriteString(writer, `{"queued":true}`)
		},
	))
	defer server.Close()
	client := testClient(t, server.URL, runner.AuthSpec{})

	response, err := client.CallResponse(
		context.Background(),
		nil,
		runner.RESTRequest{
			Method: http.MethodPost,
			Path:   "/job/release/buildWithParameters",
			Form:   url.Values{"branch": []string{"release"}},
			Response: runner.RESTResponseSpec{
				Mode:            runner.RESTResponseJSON,
				SuccessStatuses: []int{http.StatusCreated},
				ExposedHeaders:  []string{"Location"},
			},
		},
	)
	require.NoError(t, err)
	require.Equal(t, http.StatusCreated, response.StatusCode)
	require.JSONEq(t, `{"queued":true}`, string(response.Body))
	require.Equal(t, "/queue/item/42/", response.Headers.Get("Location"))
	require.Empty(t, response.Headers.Get("Set-Cookie"))
}

func TestRESTClientSupportsBoundedTextAndResponseHeaders(t *testing.T) {
	server := httptest.NewTLSServer(http.HandlerFunc(
		func(writer http.ResponseWriter, request *http.Request) {
			require.Equal(t, "text/plain", request.Header.Get("Accept"))
			writer.Header().Set("X-Text-Size", "5")
			writer.Header().Set("X-More-Data", "true")
			_, _ = io.WriteString(writer, "hello")
		},
	))
	defer server.Close()
	client := testClient(t, server.URL, runner.AuthSpec{})

	response, err := client.CallResponse(
		context.Background(),
		nil,
		runner.RESTRequest{
			Path: "/job/release/7/logText/progressiveText",
			Response: runner.RESTResponseSpec{
				Mode:           runner.RESTResponseText,
				MaxBytes:       64 << 10,
				ExposedHeaders: []string{"X-Text-Size", "X-More-Data"},
			},
		},
	)
	require.NoError(t, err)
	require.Equal(t, "hello", string(response.Body))
	require.Equal(t, "5", response.Headers.Get("X-Text-Size"))
	require.Equal(t, "true", response.Headers.Get("X-More-Data"))
	require.Equal(t, int64(5), response.RawBytes)
}

func TestRESTClientCustomResponsePathPreservesClientPolicies(t *testing.T) {
	var requestsSeen int
	var capturedUserAgent string
	var classifiedStatus int
	var classifiedBody []byte
	server := httptest.NewTLSServer(http.HandlerFunc(
		func(writer http.ResponseWriter, request *http.Request) {
			requestsSeen++
			capturedUserAgent = request.UserAgent()
			writer.WriteHeader(610)
			_, _ = io.WriteString(writer, "missing")
		},
	))
	defer server.Close()
	client, err := runner.NewRESTClient(runner.RESTOptions{
		BaseURL:   server.URL,
		Transport: insecureTransport(),
		UserAgent: "  Mozilla/5.0 (compatible; agentteam-x-extension/1.0)  ",
		StatusClassifier: func(status int, body []byte) runner.ErrorCode {
			classifiedStatus = status
			classifiedBody = append([]byte(nil), body...)
			return runner.ErrorNotFound
		},
	})
	require.NoError(t, err)

	_, err = client.CallResponse(
		context.Background(),
		nil,
		runner.RESTRequest{
			Path: "/custom",
			Response: runner.RESTResponseSpec{
				Mode:     runner.RESTResponseText,
				MaxBytes: 4,
			},
		},
	)
	require.Equal(t, runner.ErrorNotFound, runner.CodeOf(err))
	require.Equal(t, 610, classifiedStatus)
	require.Equal(t, "miss", string(classifiedBody))
	require.Equal(t, "Mozilla/5.0 (compatible; agentteam-x-extension/1.0)",
		capturedUserAgent)
	require.Equal(t, 1, requestsSeen)

	_, err = client.CallResponse(
		context.Background(),
		nil,
		runner.RESTRequest{
			Path:    "/must-not-send",
			Headers: map[string]string{"uSeR-AgEnT": "override"},
			Response: runner.RESTResponseSpec{
				Mode: runner.RESTResponseText,
			},
		},
	)
	require.Equal(t, runner.ErrorValidation, runner.CodeOf(err))
	require.Equal(t, 1, requestsSeen,
		"reserved header variants must fail before the request is sent")
}

func TestRESTClientRejectsResponseOverRequestBudget(t *testing.T) {
	server := httptest.NewTLSServer(http.HandlerFunc(
		func(writer http.ResponseWriter, _ *http.Request) {
			_, _ = io.WriteString(writer, strings.Repeat("x", 9))
		},
	))
	defer server.Close()
	client := testClient(t, server.URL, runner.AuthSpec{})

	_, err := client.CallResponse(
		context.Background(),
		nil,
		runner.RESTRequest{
			Response: runner.RESTResponseSpec{
				Mode:     runner.RESTResponseText,
				MaxBytes: 8,
			},
		},
	)
	require.Equal(t, runner.ErrorUpstreamFailure, runner.CodeOf(err))
}

func TestRESTClientAcceptsDeclaredEmptyRedirectWithoutFollowing(t *testing.T) {
	var requests int
	server := httptest.NewTLSServer(http.HandlerFunc(
		func(writer http.ResponseWriter, request *http.Request) {
			requests++
			if request.URL.Path != "/stop" {
				t.Fatalf("redirect was followed to %s", request.URL.Path)
			}
			writer.Header().Set("Location", "/")
			writer.WriteHeader(http.StatusFound)
		},
	))
	defer server.Close()
	client := testClient(t, server.URL, runner.AuthSpec{})

	response, err := client.CallResponse(
		context.Background(),
		nil,
		runner.RESTRequest{
			Method: http.MethodPost,
			Path:   "/stop",
			Response: runner.RESTResponseSpec{
				Mode:            runner.RESTResponseEmpty,
				SuccessStatuses: []int{http.StatusFound},
				ExposedHeaders:  []string{"Location"},
			},
		},
	)
	require.NoError(t, err)
	require.Equal(t, http.StatusFound, response.StatusCode)
	require.Equal(t, 1, requests)
	require.Empty(t, response.Body)
}

func TestRESTClientCanClassifyForbiddenAsPermissionDenied(t *testing.T) {
	server := httptest.NewTLSServer(http.HandlerFunc(
		func(writer http.ResponseWriter, _ *http.Request) {
			writer.WriteHeader(http.StatusForbidden)
		},
	))
	defer server.Close()
	client := testClient(t, server.URL, runner.AuthSpec{})

	_, err := client.CallResponse(
		context.Background(),
		nil,
		runner.RESTRequest{
			Response: runner.RESTResponseSpec{
				Mode:                        runner.RESTResponseEmpty,
				ForbiddenAsPermissionDenied: true,
			},
		},
	)
	require.Equal(t, runner.ErrorPermissionDenied, runner.CodeOf(err))
}

func TestRESTClientPreservesStatusClassificationWhenErrorBodyExceedsBudget(t *testing.T) {
	server := httptest.NewTLSServer(http.HandlerFunc(
		func(writer http.ResponseWriter, _ *http.Request) {
			writer.WriteHeader(http.StatusForbidden)
			_, _ = io.WriteString(writer, strings.Repeat("x", 9))
		},
	))
	defer server.Close()
	client := testClient(t, server.URL, runner.AuthSpec{})

	_, err := client.CallResponse(
		context.Background(),
		nil,
		runner.RESTRequest{
			Response: runner.RESTResponseSpec{
				Mode:                        runner.RESTResponseEmpty,
				MaxBytes:                    8,
				ForbiddenAsPermissionDenied: true,
			},
		},
	)
	require.Equal(t, runner.ErrorPermissionDenied, runner.CodeOf(err))
}

func TestRESTClientRejectsUnsafeResponseContract(t *testing.T) {
	client := testClient(t, "https://provider.example", runner.AuthSpec{})

	_, err := client.CallResponse(
		context.Background(),
		nil,
		runner.RESTRequest{
			Body: map[string]any{"json": true},
			Form: url.Values{"form": []string{"true"}},
		},
	)
	require.Equal(t, runner.ErrorValidation, runner.CodeOf(err))

	_, err = client.CallResponse(
		context.Background(),
		nil,
		runner.RESTRequest{
			Response: runner.RESTResponseSpec{
				ExposedHeaders: []string{"Set-Cookie"},
			},
		},
	)
	require.Equal(t, runner.ErrorValidation, runner.CodeOf(err))
}

func TestRESTClientJSONCallRejectsCustomResponseContract(t *testing.T) {
	server := httptest.NewTLSServer(http.HandlerFunc(
		func(http.ResponseWriter, *http.Request) {
			t.Fatal("invalid contract must fail before network")
		},
	))
	defer server.Close()
	client := testClient(t, server.URL, runner.AuthSpec{})

	_, err := client.Call(
		context.Background(),
		nil,
		runner.RESTRequest{
			Response: runner.RESTResponseSpec{MaxBytes: 8},
		},
	)
	require.Equal(t, runner.ErrorValidation, runner.CodeOf(err))
}

func TestRESTClientSanitizesTransportErrors(t *testing.T) {
	client, err := runner.NewRESTClient(runner.RESTOptions{
		BaseURL: "https://jenkins.internal.example/jenkins",
		Auth:    runner.AuthSpec{},
		Transport: responseRoundTripFunc(
			func(*http.Request) (*http.Response, error) {
				return nil, &url.Error{
					Op:  "Get",
					URL: "https://jenkins.internal.example/jenkins/secret",
					Err: io.ErrUnexpectedEOF,
				}
			},
		),
	})
	require.NoError(t, err)

	_, err = client.CallResponse(
		context.Background(), nil, runner.RESTRequest{})
	require.Equal(t, runner.ErrorUpstreamFailure, runner.CodeOf(err))
	require.NotContains(t, err.Error(), "jenkins.internal.example")
	require.NotContains(t, err.Error(), "/secret")
}

func TestAuthSpecRejectsUnsafeInjectedFieldsAndBasicUsernames(t *testing.T) {
	testCases := []struct {
		name        string
		auth        runner.AuthSpec
		credentials runner.Credentials
	}{
		{
			name: "optional bearer token",
			auth: runner.AuthSpec{
				Kind: runner.AuthBearer,
				Fields: []runner.CredentialField{{
					Name: "apiKey", Secret: true,
				}},
			},
		},
		{
			name: "non secret header token",
			auth: runner.AuthSpec{
				Kind:   runner.AuthHeader,
				Header: "X-API-Key",
				Fields: []runner.CredentialField{{
					Name: "apiKey", Required: true,
				}},
			},
		},
		{
			name: "reserved header with different case",
			auth: runner.AuthSpec{
				Kind:   runner.AuthHeader,
				Header: "authorization",
				Fields: []runner.CredentialField{{
					Name: "apiKey", Required: true, Secret: true,
				}},
			},
		},
		{
			name: "fixed Basic username with colon",
			auth: runner.AuthSpec{
				Kind:          runner.AuthBasic,
				BasicUsername: "team:builder",
				Fields: []runner.CredentialField{{
					Name: "apiKey", Required: true, Secret: true,
				}},
			},
		},
		{
			name: "dynamic Basic username with colon",
			auth: runner.AuthSpec{
				Kind:               runner.AuthBasic,
				BasicUsernameField: "username",
				BasicPasswordField: "apiToken",
				Fields: []runner.CredentialField{
					{Name: "username", Required: true},
					{Name: "apiToken", Required: true, Secret: true},
				},
			},
			credentials: runner.Credentials{
				"username": "team:builder",
				"apiToken": "secret",
			},
		},
	}
	for _, testCase := range testCases {
		t.Run(testCase.name, func(t *testing.T) {
			require.Error(t, testCase.auth.Validate(testCase.credentials))
		})
	}
}

type responseRoundTripFunc func(*http.Request) (*http.Response, error)

func (function responseRoundTripFunc) RoundTrip(
	request *http.Request,
) (*http.Response, error) {
	return function(request)
}

func TestAuthHeaderMayClaimAuthorization(t *testing.T) {
	t.Parallel()

	// Every casing of the exact reserved name is a legal AuthHeader claim;
	// the exemption lifts the reserved-name policy only, so surrounding
	// whitespace, control characters, and other reserved names stay
	// rejected, and non-reserved names keep the full static validation.
	names := []struct {
		header string
		valid  bool
	}{
		{header: "Authorization", valid: true},
		{header: "authorization", valid: true},
		{header: "AUTHORIZATION", valid: true},
		{header: "Authorization-Token", valid: true},
		{header: "X-Api-Key", valid: true},
		{header: " Authorization", valid: false},
		{header: "Authorization ", valid: false},
		{header: "Authorization\r\n", valid: false},
		{header: "Authori zation", valid: false},
		{header: "", valid: false},
		{header: "Cookie", valid: false},
		{header: "Content-Type", valid: false},
	}
	for _, name := range names {
		spec := runner.AuthSpec{
			Kind:   runner.AuthHeader,
			Header: name.header,
			Fields: []runner.CredentialField{{Name: "apiKey", Required: true, Secret: true}},
		}
		if name.valid {
			require.NoErrorf(t, spec.ValidateSpec(), "header %q must be claimable", name.header)
		} else {
			require.Errorf(t, spec.ValidateSpec(), "header %q must be rejected", name.header)
		}
	}

	// The reservation itself stays intact for static provider headers: a
	// request carrying an Authorization static header is rejected.
	client := testClient(t, "https://provider.example.com", runner.AuthSpec{})
	_, err := client.CallResponse(context.Background(), nil, runner.RESTRequest{
		Method:  http.MethodGet,
		Path:    "/",
		Headers: map[string]string{"Authorization": "token"},
	})
	require.Error(t, err)
	require.Equal(t, runner.ErrorValidation, runner.CodeOf(err))
}

func TestAuthSpecRejectsSignatureOutsideHeadersKind(t *testing.T) {
	t.Parallel()

	signature := func(runner.Credentials) (map[string]string, error) { return nil, nil }
	kinds := []struct {
		name string
		auth runner.AuthSpec
	}{
		{
			name: "none",
			auth: runner.AuthSpec{Kind: runner.AuthNone},
		},
		{
			name: "bearer",
			auth: runner.AuthSpec{
				Kind:   runner.AuthBearer,
				Fields: []runner.CredentialField{{Name: "apiToken", Required: true, Secret: true}},
			},
		},
		{
			name: "header",
			auth: runner.AuthSpec{
				Kind:   runner.AuthHeader,
				Header: "X-Api-Key",
				Fields: []runner.CredentialField{{Name: "apiToken", Required: true, Secret: true}},
			},
		},
		{
			name: "basic",
			auth: runner.AuthSpec{
				Kind:   runner.AuthBasic,
				Fields: []runner.CredentialField{{Name: "password", Required: true, Secret: true}},
			},
		},
		{
			name: "credential",
			auth: runner.AuthSpec{
				Kind:   runner.AuthCredential,
				Fields: []runner.CredentialField{{Name: "apiToken", Required: true, Secret: true}},
			},
		},
	}
	for _, kind := range kinds {
		withoutSignature := kind.auth
		require.NoErrorf(t, withoutSignature.ValidateSpec(),
			"kind %s fixture must be valid without a signature", kind.name)
		withSignature := kind.auth
		withSignature.Signature = signature
		require.Errorf(t, withSignature.ValidateSpec(),
			"kind %s must reject a signature", kind.name)
	}

	// The signed headers contract stays valid, including the Qichacha
	// shape: several connection-scoped fields and no header binding.
	qichacha := runner.AuthSpec{
		Kind: runner.AuthHeaders,
		Fields: []runner.CredentialField{
			{Name: "appKey", Required: true},
			{Name: "secretKey", Required: true, Secret: true},
		},
		Signature: signature,
	}
	require.NoError(t, qichacha.ValidateSpec())
}
