package jenkins

import (
	"context"
	"encoding/base64"
	"encoding/json"
	"io"
	"net/http"
	"net/http/httptest"
	"net/url"
	"strconv"
	"strings"
	"testing"

	"github.com/stretchr/testify/require"

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

type capturedRequest struct {
	method      string
	path        string
	query       url.Values
	body        string
	contentType string
	accept      string
	auth        string
}

type wireCase struct {
	name            string
	action          string
	input           string
	status          int
	responseBody    string
	responseHeaders map[string]string
	wantMethod      string
	wantPath        string
	wantQuery       url.Values
	wantBody        string
	wantContentType string
	wantAccept      string
	wantOutput      string
}

func TestEveryJenkinsActionMatchesItsWireContract(t *testing.T) {
	testCases := append(jenkinsReadWireCases(), jenkinsMutationWireCases()...)
	for _, testCase := range testCases {
		t.Run(testCase.name, func(t *testing.T) {
			runJenkinsWireCase(t, testCase)
		})
	}
}

func jenkinsReadWireCases() []wireCase {
	return []wireCase{
		{
			name:         "list jobs",
			action:       ActionListJobs,
			input:        `{"jobPath":["team"]}`,
			responseBody: `{"jobs":[]}`,
			wantMethod:   http.MethodGet,
			wantPath:     "/jenkins/job/team/api/json",
			wantQuery:    url.Values{"tree": {listJobsTree}},
			wantAccept:   "application/json",
			wantOutput:   `{"jobs":[]}`,
		},
		{
			name:         "get job",
			action:       ActionGetJob,
			input:        `{"jobPath":["team","release build"]}`,
			responseBody: `{"name":"release build"}`,
			wantMethod:   http.MethodGet,
			wantPath:     "/jenkins/job/team/job/release build/api/json",
			wantQuery:    url.Values{"tree": {getJobTree}},
			wantAccept:   "application/json",
			wantOutput:   `{"name":"release build"}`,
		},
		{
			name:         "get queue item",
			action:       ActionGetQueueItem,
			input:        `{"queueId":77}`,
			responseBody: `{"id":77}`,
			wantMethod:   http.MethodGet,
			wantPath:     "/jenkins/queue/item/77/api/json",
			wantQuery:    url.Values{"tree": {queueItemTree}},
			wantAccept:   "application/json",
			wantOutput:   `{"id":77}`,
		},
		{
			name:         "get build",
			action:       ActionGetBuild,
			input:        `{"jobPath":["team","release"],"buildNumber":9}`,
			responseBody: `{"number":9,"building":true}`,
			wantMethod:   http.MethodGet,
			wantPath:     "/jenkins/job/team/job/release/9/api/json",
			wantQuery:    url.Values{"tree": {buildTree}},
			wantAccept:   "application/json",
			wantOutput:   `{"number":9,"building":true}`,
		},
		{
			name:         "get console log",
			action:       ActionGetConsoleLog,
			input:        `{"jobPath":["team","release"],"buildNumber":9,"start":5}`,
			responseBody: "hello",
			responseHeaders: map[string]string{
				"X-Text-Size": "10",
				"X-More-Data": "true",
			},
			wantMethod: http.MethodGet,
			wantPath:   "/jenkins/job/team/job/release/9/logText/progressiveText",
			wantQuery:  url.Values{"start": {"5"}},
			wantAccept: "text/plain",
			wantOutput: `{"text":"hello","nextStart":10,"moreData":true,"cursorReset":false}`,
		},
	}
}

func jenkinsMutationWireCases() []wireCase {
	return []wireCase{
		{
			name:   "trigger parameterized build",
			action: ActionTriggerBuild,
			input:  `{"jobPath":["team","release"],"parameters":{"DEPLOY":true,"BRANCH":"main","RETRIES":3}}`,
			status: http.StatusCreated,
			responseHeaders: map[string]string{
				"Location": "queue-location",
			},
			wantMethod:      http.MethodPost,
			wantPath:        "/jenkins/job/team/job/release/buildWithParameters",
			wantBody:        "BRANCH=main&DEPLOY=true&RETRIES=3",
			wantContentType: "application/x-www-form-urlencoded",
			wantAccept:      "application/json",
			wantOutput:      `{"queueId":77}`,
		},
		{
			name:            "cancel queue item",
			action:          ActionCancelQueueItem,
			input:           `{"queueId":77}`,
			status:          http.StatusFound,
			wantMethod:      http.MethodPost,
			wantPath:        "/jenkins/queue/cancelItem",
			wantBody:        "id=77",
			wantContentType: "application/x-www-form-urlencoded",
			wantAccept:      "application/json",
			wantOutput:      `{"cancelled":true}`,
		},
		{
			name:       "stop build",
			action:     ActionStopBuild,
			input:      `{"jobPath":["team","release"],"buildNumber":9}`,
			status:     http.StatusOK,
			wantMethod: http.MethodPost,
			wantPath:   "/jenkins/job/team/job/release/9/stop",
			wantAccept: "application/json",
			wantOutput: `{"stopped":true}`,
		},
	}
}

func runJenkinsWireCase(t *testing.T, testCase wireCase) {
	t.Helper()
	var captured capturedRequest
	var server *httptest.Server
	server = httptest.NewTLSServer(http.HandlerFunc(
		func(writer http.ResponseWriter, request *http.Request) {
			body, err := io.ReadAll(request.Body)
			require.NoError(t, err)
			captured = capturedRequest{
				method:      request.Method,
				path:        request.URL.Path,
				query:       request.URL.Query(),
				body:        string(body),
				contentType: request.Header.Get("Content-Type"),
				accept:      request.Header.Get("Accept"),
				auth:        request.Header.Get("Authorization"),
			}
			for name, value := range testCase.responseHeaders {
				if name == "Location" && value == "queue-location" {
					value = server.URL + "/jenkins/queue/item/77/"
				}
				writer.Header().Set(name, value)
			}
			status := testCase.status
			if status == 0 {
				status = http.StatusOK
			}
			writer.WriteHeader(status)
			_, _ = io.WriteString(writer, testCase.responseBody)
		},
	))
	defer server.Close()
	target := testTargetClient(t, server)

	output, err := handlers()[testCase.action](
		context.Background(),
		target,
		testCredentials(),
		json.RawMessage(testCase.input),
	)
	require.NoError(t, err)
	require.JSONEq(t, testCase.wantOutput, string(output))
	require.Equal(t, testCase.wantMethod, captured.method)
	require.Equal(t, testCase.wantPath, captured.path)
	require.Equal(t, testCase.wantQuery.Encode(), captured.query.Encode())
	require.Equal(t, testCase.wantBody, captured.body)
	require.Equal(t, testCase.wantContentType, captured.contentType)
	require.Equal(t, testCase.wantAccept, captured.accept)
	require.Equal(t,
		"Basic "+base64.StdEncoding.EncodeToString(
			[]byte("builder:api-token")),
		captured.auth,
	)
}

func TestTriggerBuildWithoutParametersUsesBuildEndpoint(t *testing.T) {
	var path, contentType, body string
	var server *httptest.Server
	server = httptest.NewTLSServer(http.HandlerFunc(
		func(writer http.ResponseWriter, request *http.Request) {
			path = request.URL.Path
			contentType = request.Header.Get("Content-Type")
			raw, err := io.ReadAll(request.Body)
			require.NoError(t, err)
			body = string(raw)
			writer.Header().Set(
				"Location", server.URL+"/jenkins/queue/item/1/")
			writer.WriteHeader(http.StatusCreated)
		},
	))
	defer server.Close()

	output, err := handleTriggerBuild(
		context.Background(),
		testTargetClient(t, server),
		testCredentials(),
		json.RawMessage(`{"jobPath":["release"]}`),
	)
	require.NoError(t, err)
	require.JSONEq(t, `{"queueId":1}`, string(output))
	require.Equal(t, "/jenkins/job/release/build", path)
	require.Empty(t, contentType)
	require.Empty(t, body)
}

func TestJenkinsWireFailsClosedOnMalformedUpstreamMetadata(t *testing.T) {
	testCases := []struct {
		name    string
		action  string
		input   string
		headers map[string]string
		body    string
	}{
		{
			name:   "cross origin queue location",
			action: ActionTriggerBuild,
			input:  `{"jobPath":["release"]}`,
			headers: map[string]string{
				"Location": "https://attacker.example/queue/item/7/",
			},
		},
		{
			name:   "missing console cursor",
			action: ActionGetConsoleLog,
			input:  `{"jobPath":["release"],"buildNumber":1}`,
			body:   "log",
		},
		{
			name:   "non object read response",
			action: ActionGetJob,
			input:  `{"jobPath":["release"]}`,
			body:   `[]`,
		},
	}
	for _, testCase := range testCases {
		t.Run(testCase.name, func(t *testing.T) {
			server := httptest.NewTLSServer(http.HandlerFunc(
				func(writer http.ResponseWriter, _ *http.Request) {
					for name, value := range testCase.headers {
						writer.Header().Set(name, value)
					}
					if testCase.action == ActionTriggerBuild {
						writer.WriteHeader(http.StatusCreated)
					}
					_, _ = io.WriteString(writer, testCase.body)
				},
			))
			defer server.Close()
			_, err := handlers()[testCase.action](
				context.Background(),
				testTargetClient(t, server),
				testCredentials(),
				json.RawMessage(testCase.input),
			)
			require.Equal(t, runner.ErrorUpstreamFailure, runner.CodeOf(err))
			require.NotContains(t, err.Error(), "attacker.example")
		})
	}
}

func TestProgressiveConsoleCursorUsesHeadersAsAuthority(t *testing.T) {
	testCases := []struct {
		name      string
		start     int64
		nextStart string
		moreData  []string
		wantNext  int64
		wantMore  bool
		wantReset bool
		wantErr   bool
	}{
		{
			name:      "body length is irrelevant",
			start:     5,
			nextStart: "42",
			moreData:  []string{"true"},
			wantNext:  42,
			wantMore:  true,
		},
		{
			name:      "rollover is explicit",
			start:     100,
			nextStart: "3",
			moreData:  []string{"false"},
			wantNext:  3,
			wantReset: true,
		},
		{
			name:      "missing more data means false",
			start:     0,
			nextStart: "0",
			wantNext:  0,
		},
		{
			name:      "signed cursor rejected",
			nextStart: "+1",
			wantErr:   true,
		},
		{
			name:      "duplicate continuation rejected",
			nextStart: "1",
			moreData:  []string{"true", "false"},
			wantErr:   true,
		},
	}
	for _, testCase := range testCases {
		t.Run(testCase.name, func(t *testing.T) {
			nextStart, moreData, cursorReset, err := progressiveLogCursor(
				testCase.start,
				[]string{testCase.nextStart},
				testCase.moreData,
			)
			if testCase.wantErr {
				require.Equal(t,
					runner.ErrorUpstreamFailure, runner.CodeOf(err))
				return
			}
			require.NoError(t, err)
			require.Equal(t, testCase.wantNext, nextStart)
			require.Equal(t, testCase.wantMore, moreData)
			require.Equal(t, testCase.wantReset, cursorReset)
		})
	}
}

func TestConsoleLogFinalResponseBudget(t *testing.T) {
	testCases := []struct {
		name     string
		body     string
		wantCode runner.ErrorCode
	}{
		{
			name: "full ASCII chunk fits",
			body: strings.Repeat("x", maxConsoleLogBytes),
		},
		{
			name:     "escaping expansion fails closed",
			body:     strings.Repeat("\x00", maxConsoleLogBytes),
			wantCode: runner.ErrorUpstreamFailure,
		},
	}
	for _, testCase := range testCases {
		t.Run(testCase.name, func(t *testing.T) {
			server := httptest.NewTLSServer(http.HandlerFunc(
				func(writer http.ResponseWriter, _ *http.Request) {
					writer.Header().Set(
						"X-Text-Size", strconv.Itoa(len(testCase.body)))
					_, _ = io.WriteString(writer, testCase.body)
				},
			))
			defer server.Close()
			output, err := handleGetConsoleLog(
				context.Background(),
				testTargetClient(t, server),
				testCredentials(),
				json.RawMessage(
					`{"jobPath":["release"],"buildNumber":1}`),
			)
			if testCase.wantCode != "" {
				require.Equal(t, testCase.wantCode, runner.CodeOf(err))
				return
			}
			require.NoError(t, err)
			require.LessOrEqual(t, len(output), maxJenkinsOutputBytes)
		})
	}
}

func TestBuildParameterBudgetsUseEncodedUTF8Bytes(t *testing.T) {
	require.NoError(t, validateParameterName(strings.Repeat("😀", 32)))
	require.Error(t, validateParameterName(strings.Repeat("😀", 33)))
	require.Error(t, validateParameterName("branch\x00name"))

	accepted, err := json.Marshal(strings.Repeat("界", 5461))
	require.NoError(t, err)
	value, err := buildParameterValue(accepted)
	require.NoError(t, err)
	require.Len(t, []byte(value), 16383)

	rejected, err := json.Marshal(strings.Repeat("界", 5462))
	require.NoError(t, err)
	_, err = buildParameterValue(rejected)
	require.Error(t, err)

	control, err := json.Marshal("release\x00candidate")
	require.NoError(t, err)
	_, err = buildParameterValue(control)
	require.Error(t, err)

	parameters := make(map[string]json.RawMessage, 17)
	for index := 0; index < 17; index++ {
		parameters[strconv.Itoa(index)] = json.RawMessage(
			strconv.Quote(strings.Repeat("x", maxParameterValueBytes)))
	}
	_, err = buildParametersForm(parameters)
	require.Equal(t, runner.ErrorValidation, runner.CodeOf(err))
}

func testTargetClient(
	t *testing.T,
	server *httptest.Server,
) *targetClient {
	t.Helper()
	baseURL, err := url.Parse(server.URL + "/jenkins")
	require.NoError(t, err)
	rest, err := runner.NewRESTClient(runner.RESTOptions{
		BaseURL:   baseURL.String(),
		Auth:      AuthSpec(),
		Transport: server.Client().Transport,
	})
	require.NoError(t, err)
	return &targetClient{
		baseURL: baseURL,
		rest:    rest,
	}
}

func testCredentials() runner.Credentials {
	return runner.Credentials{
		"baseUrl":  "https://jenkins.example",
		"username": "builder",
		"apiToken": "api-token",
	}
}

func requireErrorDoesNotContainSecrets(t *testing.T, err error) {
	t.Helper()
	require.Error(t, err)
	for _, secret := range []string{
		"builder", "api-token", "Basic ", "jenkins/queue",
	} {
		require.NotContains(t, err.Error(), secret)
	}
}
