package jenkins

import (
	"context"
	"encoding/json"
	"errors"
	"net/http"
	"net/http/httptest"
	"net/url"
	"sort"
	"testing"

	"github.com/stretchr/testify/require"

	"github.com/tidbcloud/AgentTeam/internal/extension/binding"
	"github.com/tidbcloud/AgentTeam/internal/extension/registry"
	"github.com/tidbcloud/AgentTeam/internal/extension/runner"
	"github.com/tidbcloud/AgentTeam/internal/service/extensionflow"
)

type credentialSourceStub struct {
	credentials runner.Credentials
	err         error
}

func (source credentialSourceStub) ResolveProviderCredentials(
	context.Context,
	string,
	string,
) (runner.Credentials, error) {
	return source.credentials.Clone(), source.err
}

func TestJenkinsDefinitionMatchesExecutorAndDistribution(t *testing.T) {
	ext, err := Extension(credentialSourceStub{})
	require.NoError(t, err)
	definitionNames := make([]string, 0, len(ext.Definition.Actions))
	for _, action := range ext.Definition.Actions {
		definitionNames = append(definitionNames, action.Name)
		require.Equal(t, schemaDialect, action.InputSchema["$schema"])
		require.Equal(t, schemaDialect, action.OutputSchema["$schema"])
	}
	sort.Strings(definitionNames)
	require.Equal(t, []string{
		ActionCancelQueueItem,
		ActionGetBuild,
		ActionGetConsoleLog,
		ActionGetJob,
		ActionGetQueueItem,
		ActionListJobs,
		ActionStopBuild,
		ActionTriggerBuild,
	}, definitionNames)
	require.Equal(t, definitionNames,
		ext.Implementation.Executor.(*Executor).ActionNames())
	require.Equal(t, Revision, ext.Implementation.Revision)
	require.Equal(t, registry.ConnectionRequired,
		ext.Implementation.ConnectionPolicy)
	require.Equal(t, "app_jenkins", ext.Distribution.AppID)
	require.Equal(t, "jenkins", ext.Distribution.AppSlug)
	require.Equal(t, "/app-icons/jenkins.svg", ext.Distribution.IconURL)
	require.True(t, ext.Distribution.DefaultEnabled)
	require.NotNil(t, ext.Implementation.ValidateCredentials)
	require.Equal(t, []runner.CredentialField{
		{
			Name:        "baseUrl",
			DisplayName: "Jenkins URL",
			Description: "HTTPS URL of the Jenkins instance.",
			Required:    true,
		},
		{
			Name:        "username",
			DisplayName: "Jenkins username",
			Description: "Jenkins user associated with the API token.",
			Required:    true,
		},
		{
			Name:        "apiToken",
			DisplayName: "Jenkins API token",
			Description: "Jenkins user API token; account passwords are not supported.",
			Required:    true,
			Secret:      true,
		},
	}, ext.Implementation.Auth.Fields)
}

func TestJenkinsSchemaAndHandlersRejectTheSameNullableLexicals(t *testing.T) {
	testCases := []struct {
		name   string
		action string
		input  string
	}{
		{
			name:   "list jobPath null",
			action: ActionListJobs,
			input:  `{"jobPath":null}`,
		},
		{
			name:   "trigger parameters null",
			action: ActionTriggerBuild,
			input:  `{"jobPath":["release"],"parameters":null}`,
		},
		{
			name:   "console start null",
			action: ActionGetConsoleLog,
			input:  `{"jobPath":["release"],"buildNumber":1,"start":null}`,
		},
		{
			name:   "dot job segment",
			action: ActionGetJob,
			input:  `{"jobPath":[".."]}`,
		},
		{
			name:   "null build parameter",
			action: ActionTriggerBuild,
			input:  `{"jobPath":["release"],"parameters":{"BRANCH":null}}`,
		},
	}
	for _, testCase := range testCases {
		t.Run(testCase.name, func(t *testing.T) {
			action := definitionAction(t, testCase.action)
			_, schemaErr := binding.ValidateActionInput(
				action.InputSchema,
				json.RawMessage(testCase.input),
			)
			_, handlerErr := handlers()[testCase.action](
				context.Background(),
				nil,
				testCredentials(),
				json.RawMessage(testCase.input),
			)
			require.Error(t, schemaErr)
			require.Equal(t, runner.ErrorValidation, runner.CodeOf(handlerErr))
		})
	}
}

func TestJenkinsConnectionValidation(t *testing.T) {
	testCases := []struct {
		name     string
		status   int
		body     string
		wantCode runner.ErrorCode
	}{
		{
			name:   "authenticated",
			status: http.StatusOK,
			body:   `{"name":"builder","authenticated":true,"anonymous":false}`,
		},
		{
			name:     "forbidden credential",
			status:   http.StatusForbidden,
			body:     `{"message":"secret detail"}`,
			wantCode: runner.ErrorProviderAuthUnavailable,
		},
		{
			name:     "anonymous identity",
			status:   http.StatusOK,
			body:     `{"name":"anonymous","authenticated":false,"anonymous":true}`,
			wantCode: runner.ErrorProviderAuthUnavailable,
		},
		{
			name:     "malformed identity",
			status:   http.StatusOK,
			body:     `[]`,
			wantCode: runner.ErrorUpstreamFailure,
		},
	}
	for _, testCase := range testCases {
		t.Run(testCase.name, func(t *testing.T) {
			server := httptest.NewTLSServer(http.HandlerFunc(
				func(writer http.ResponseWriter, request *http.Request) {
					require.Equal(t, "/jenkins/whoAmI/api/json", request.URL.Path)
					require.Equal(t, "name,authenticated,anonymous",
						request.URL.Query().Get("tree"))
					username, password, ok := request.BasicAuth()
					require.True(t, ok)
					require.Equal(t, "builder", username)
					require.Equal(t, "api-token", password)
					writer.WriteHeader(testCase.status)
					_, _ = writer.Write([]byte(testCase.body))
				},
			))
			defer server.Close()
			credentials := testCredentials()
			credentials["baseUrl"] = server.URL + "/jenkins"
			executor := testExecutorForServer(t, server, credentials)

			err := executor.ValidateCredentials(
				context.Background(), credentials)
			if testCase.wantCode == "" {
				require.NoError(t, err)
				return
			}
			require.Equal(t, testCase.wantCode, runner.CodeOf(err))
			requireErrorDoesNotContainSecrets(t, err)
		})
	}
}

func TestJenkinsExecutorMapsActionForbiddenToPermissionDenied(t *testing.T) {
	server := httptest.NewTLSServer(http.HandlerFunc(
		func(writer http.ResponseWriter, _ *http.Request) {
			writer.WriteHeader(http.StatusForbidden)
		},
	))
	defer server.Close()
	executor := testExecutorForServer(t, server, testCredentials())

	result, err := executor.Invoke(context.Background(), registry.BuiltinInvocation{
		WorkspaceID:           "ws_1",
		ServiceInstallationID: "svcinst_1",
		Action:                ActionGetJob,
		Input:                 json.RawMessage(`{"jobPath":["release"]}`),
	})
	var invocationErr *extensionflow.InvocationError
	require.ErrorAs(t, err, &invocationErr)
	require.Equal(t, extensionflow.InvocationPermissionDenied, invocationErr.Code)
	require.Empty(t, result.Data)
}

func TestJenkinsExecutorFailsClosedOnCredentialAndURLResolution(t *testing.T) {
	clients := newClientCache(nil)
	executor := newExecutor(
		credentialSourceStub{err: errors.New("cipher detail")}, clients)
	_, err := executor.Invoke(context.Background(), registry.BuiltinInvocation{
		WorkspaceID:           "ws_1",
		ServiceInstallationID: "svcinst_1",
		Action:                ActionListJobs,
		Input:                 json.RawMessage(`{}`),
	})
	var invocationErr *extensionflow.InvocationError
	require.ErrorAs(t, err, &invocationErr)
	require.Equal(t,
		extensionflow.InvocationProviderAuthUnavailable, invocationErr.Code)
	require.NotContains(t, err.Error(), "cipher detail")

	invalid := testCredentials()
	invalid["baseUrl"] = "http://jenkins.example"
	executor = newExecutor(credentialSourceStub{credentials: invalid}, clients)
	_, err = executor.Invoke(context.Background(), registry.BuiltinInvocation{
		WorkspaceID:           "ws_1",
		ServiceInstallationID: "svcinst_1",
		Action:                ActionListJobs,
		Input:                 json.RawMessage(`{}`),
	})
	require.ErrorAs(t, err, &invocationErr)
	require.Equal(t,
		extensionflow.InvocationProviderAuthUnavailable, invocationErr.Code)
}

func TestJenkinsClientCacheReusesURLAndIsolatesOrigins(t *testing.T) {
	var created []*runner.RESTClient
	factory := func(baseURL *url.URL) (*runner.RESTClient, error) {
		client, err := runner.NewRESTClient(runner.RESTOptions{
			BaseURL: baseURL.String(),
			Auth:    AuthSpec(),
			Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
				return nil, errors.New(baseURL.Host)
			}),
		})
		created = append(created, client)
		return client, err
	}
	cache := newClientCache(factory)
	one, err := cache.resolve("https://ONE.example:443/jenkins/")
	require.NoError(t, err)
	oneAgain, err := cache.resolve("https://one.example/jenkins")
	require.NoError(t, err)
	two, err := cache.resolve("https://two.example/jenkins")
	require.NoError(t, err)
	require.Len(t, created, 2)
	require.Same(t, one, oneAgain)
	require.NotSame(t, created[0], created[1])
	require.NotSame(t, one.rest, two.rest)
}

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

	for _, testCase := range []struct {
		name string
		raw  string
		want string
	}{
		{
			name: "normalizes host port and slash",
			raw:  " https://JENKINS.example:443/team/ ",
			want: "https://jenkins.example/team",
		},
		{
			name: "allows IPv6",
			raw:  "https://[fd12:3456::10]:8443/jenkins",
			want: "https://[fd12:3456::10]:8443/jenkins",
		},
	} {
		t.Run(testCase.name, func(t *testing.T) {
			parsed, err := validateBaseURL(testCase.raw)
			require.NoError(t, err)
			require.Equal(t, testCase.want, parsed.String())
		})
	}

	for _, raw := range []string{
		"",
		"http://jenkins.example",
		"https://user@jenkins.example",
		"https://jenkins.example?token=x",
		"https://jenkins.example#fragment",
		"https://bad_host.example",
		"https://-bad.example",
		"https://bad-.example",
		"https://jenkins.example:0",
		"https://jenkins.example:65536",
		"https://jenkins.example/a/../b",
		"https://jenkins.example/a//b",
		"https://jenkins.example/a%2fb",
		"https://jenkins.example/a%5cb",
		"https://jenkins.example/a\\b",
	} {
		t.Run(raw, func(t *testing.T) {
			_, err := validateBaseURL(raw)
			require.Error(t, err)
		})
	}
}

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

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

func definitionAction(t *testing.T, name string) registry.DefinitionAction {
	t.Helper()
	for _, action := range Definition().Actions {
		if action.Name == name {
			return action
		}
	}
	t.Fatalf("action %q not found", name)
	return registry.DefinitionAction{}
}

func testExecutorForServer(
	t *testing.T,
	server *httptest.Server,
	credentials runner.Credentials,
) *Executor {
	t.Helper()
	credentials = credentials.Clone()
	credentials["baseUrl"] = server.URL + "/jenkins"
	clients := newClientCache(
		func(parsed *url.URL) (*runner.RESTClient, error) {
			return runner.NewRESTClient(runner.RESTOptions{
				BaseURL:   parsed.String(),
				Auth:      AuthSpec(),
				Transport: server.Client().Transport,
			})
		},
	)
	return newExecutor(credentialSourceStub{credentials: credentials}, clients)
}
