package ones_test

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

	"github.com/stretchr/testify/require"

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

// wireServer records every request and answers with the scripted status and
// body for every call.
type wireServer struct {
	server   *httptest.Server
	requests []wireRequest
	status   int
	body     string
}

type wireRequest struct {
	method string
	path   string
	query  string
	body   string
	header http.Header
}

func newWireServer(t *testing.T, status int, body string) *wireServer {
	t.Helper()
	wire := &wireServer{status: status, body: body}
	wire.server = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		bodyBytes, err := io.ReadAll(r.Body)
		require.NoError(t, err)
		wire.requests = append(wire.requests, wireRequest{
			method: r.Method,
			path:   r.URL.Path,
			query:  r.URL.RawQuery,
			body:   string(bodyBytes),
			header: r.Header.Clone(),
		})
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(status)
		_, _ = w.Write([]byte(body))
	}))
	t.Cleanup(wire.server.Close)
	return wire
}

func (wire *wireServer) last() wireRequest {
	if len(wire.requests) == 0 {
		panic("no recorded request")
	}
	return wire.requests[len(wire.requests)-1]
}

// invoke runs one action against the double through the production handler
// table and classifier, with a loopback-friendly transport like every wire
// double in this package.
func invoke(
	t *testing.T,
	server interface{ URL() string },
	action string,
	input string,
) (json.RawMessage, error) {
	t.Helper()
	executor, err := runner.NewConnectionScopedRESTExecutor(
		ones.Domain,
		runner.RESTTemplate{
			Auth:             ones.AuthSpec(),
			BaseURLField:     "base_url",
			StatusClassifier: ones.ClassifyStatus,
			Transport:        insecureTransport(),
		},
		staticCredentialSource{credentials: runner.Credentials{
			"base_url": server.URL(),
			"user_id":  "DU6krHBN",
			"token":    "tok",
		}},
		ones.Handlers())
	require.NoError(t, err)
	result, err := executor.Invoke(context.Background(), registry.BuiltinInvocation{
		WorkspaceID:           "ws",
		ServiceInstallationID: "inst",
		ExtensionName:         ones.Domain,
		Action:                action,
		Input:                 json.RawMessage(input),
	})
	if err != nil {
		return nil, err
	}
	return result.Data, nil
}

// url exposes the double's origin for invoke.
func (wire *wireServer) URL() string { return wire.server.URL }

func requireOnesHeaders(t *testing.T, request wireRequest, origin string) {
	t.Helper()
	require.Equal(t, "DU6krHBN", request.header.Get("Ones-User-Id"))
	require.Equal(t, "tok", request.header.Get("Ones-Auth-Token"))
	require.Equal(t, origin, request.header.Get("Referer"))
}

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

	cases := []struct {
		action    string
		input     string
		method    string
		path      string
		query     string
		bodyParts []string
		response  string
	}{
		{
			action: "get-auth-info", input: `{}`,
			method: "GET", path: "/project/api/project/auth/token_info",
			response: `{"user":{"uuid":"DU6krHBN","name":"u","token":"SECRET"},"teams":[],"org":{}}`,
		},
		{
			action: "list-projects", input: `{"team_uuid":"3pDzCwAe"}`,
			method: "GET", path: "/project/api/project/team/3pDzCwAe/projects/my_project",
		},
		{
			action: "list-projects", input: `{"team_uuid":"3pDzCwAe","ids":"DU6krHBNJEEeoG8G"}`,
			method: "GET", path: "/project/api/project/team/3pDzCwAe/projects/info",
			query: "ids=DU6krHBNJEEeoG8G",
		},
		{
			action: "create-project",
			input:  `{"team_uuid":"3pDzCwAe","project":{"uuid":"DU6krHBN12345678","name":"p","assign":"DU6krHBN"}}`,
			method: "POST", path: "/project/api/project/team/3pDzCwAe/projects/add",
			bodyParts: []string{`"project":{"assign":"DU6krHBN","name":"p","uuid":"DU6krHBN12345678"}`},
		},
		{
			action: "create-project",
			input:  `{"team_uuid":"3pDzCwAe","project":{"uuid":"DU6krHBN12345678","name":"p","assign":"DU6krHBN"},"template_id":"project-t2","members":["DU6krHBN"]}`,
			method: "POST", path: "/project/api/project/team/3pDzCwAe/projects/add",
			bodyParts: []string{`"template_id":"project-t2"`, `"members":["DU6krHBN"]`},
		},
		{
			action: "list-issue-types", input: `{"team_uuid":"3pDzCwAe"}`,
			method: "GET", path: "/project/api/project/team/3pDzCwAe/issue_types",
		},
		{
			action: "list-task-statuses", input: `{"team_uuid":"3pDzCwAe"}`,
			method: "GET", path: "/project/api/project/team/3pDzCwAe/task_statuses",
		},
		{
			action: "create-task",
			input:  `{"team_uuid":"3pDzCwAe","tasks":[{"uuid":"DU6krHBNNKSnnHNj","assign":"DU6krHBN","project_uuid":"DU6krHBNXuPAbpv8","issue_type_uuid":"GLLfcQxq","summary":"s","priority":"7tKAV46c"}]}`,
			method: "POST", path: "/project/api/project/team/3pDzCwAe/tasks/add2",
			bodyParts: []string{`"tasks":[`, `"summary":"s"`, `"assign":"DU6krHBN"`, `"priority":"7tKAV46c"`},
		},
		{
			action: "update-task",
			input:  `{"team_uuid":"3pDzCwAe","tasks":[{"uuid":"DU6krHBNNKSnnHNj","desc_rich":"x"}]}`,
			method: "POST", path: "/project/api/project/team/3pDzCwAe/tasks/update3",
			bodyParts: []string{`"uuid":"DU6krHBNNKSnnHNj"`},
		},
		{
			action: "get-task", input: `{"team_uuid":"3pDzCwAe","task":"46"}`,
			method: "GET", path: "/project/api/project/team/3pDzCwAe/task/46/info",
		},
		{
			action: "get-task", input: `{"team_uuid":"3pDzCwAe","task":46}`,
			method: "GET", path: "/project/api/project/team/3pDzCwAe/task/46/info",
		},
		{
			action: "get-task", input: `{"team_uuid":"3pDzCwAe","task":"DU6krHBNJPoQgfJv"}`,
			method: "GET", path: "/project/api/project/team/3pDzCwAe/task/DU6krHBNJPoQgfJv/info",
		},
		{
			action: "get-tasks",
			input:  `{"team_uuid":"3pDzCwAe","ids":["DU6krHBNJPoQgfJv","DU6krHBNNKSnnHNI"]}`,
			method: "POST", path: "/project/api/project/team/3pDzCwAe/tasks/info",
			bodyParts: []string{`"ids":["DU6krHBNJPoQgfJv","DU6krHBNNKSnnHNI"]`},
		},
		{
			action: "delete-task", input: `{"team_uuid":"3pDzCwAe","task_uuid":"DU6krHBN7TS9TKx7"}`,
			method: "POST", path: "/project/api/project/team/3pDzCwAe/task/DU6krHBN7TS9TKx7/delete",
		},
		{
			action: "filter-tasks",
			input:  `{"team_uuid":"3pDzCwAe","query":{"must":[{"in":{"field_values.field006":["DU6krHBNJEEeoG8G"]}}]},"include_subtasks":true}`,
			method: "POST", path: "/project/api/project/team/3pDzCwAe/filters/peek",
			bodyParts: []string{`"query":{"must":[{"in":{"field_values.field006":["DU6krHBNJEEeoG8G"]}}]}`, `"include_subtasks":true`},
		},
		{
			action: "filter-tasks",
			input:  `{"team_uuid":"3pDzCwAe","query":{"must":[]},"sort":[{"field_values.field009":{"order":"desc"}}],"group_by":"","include_status_uuid":false}`,
			method: "POST", path: "/project/api/project/team/3pDzCwAe/filters/peek",
			bodyParts: []string{`"group_by":""`, `"include_status_uuid":false`},
		},
		{
			action: "list-sprints",
			input:  `{"team_uuid":"3pDzCwAe","project_uuid":"DU6krHBNXuPAbpv8"}`,
			method: "GET", path: "/project/api/project/team/3pDzCwAe/project/DU6krHBNXuPAbpv8/sprints",
		},
		{
			action: "create-sprint",
			input:  `{"team_uuid":"3pDzCwAe","project_uuid":"DU6krHBNXuPAbpv8","sprints":[{"title":"s1","goal":"g","is_open_gantt":true}]}`,
			method: "POST", path: "/project/api/project/team/3pDzCwAe/project/DU6krHBNXuPAbpv8/sprints/add",
			bodyParts: []string{`"sprints":[{"title":"s1","goal":"g","is_open_gantt":true}]`},
		},
		{
			action: "bind-task-page",
			input:  `{"team_uuid":"3pDzCwAe","task_uuid":"DU6krHBN7TS9TKx7","pages":[{"uuid":"3snnvcEu","title":"会议记录","position":3}]}`,
			method: "POST", path: "/project/api/project/team/3pDzCwAe/task/DU6krHBN7TS9TKx7/bind_wiki_page",
			bodyParts: []string{`"uuid":"3snnvcEu"`, `"position":3`},
		},
		{
			action: "unbind-task-page",
			input:  `{"team_uuid":"3pDzCwAe","task_uuid":"DU6krHBN7TS9TKx7","page":{"uuid":"FBFrDoJG","title":"更新日志"}}`,
			method: "POST", path: "/project/api/project/team/3pDzCwAe/task/DU6krHBN7TS9TKx7/unbind_wiki_page",
			bodyParts: []string{`"uuid":"FBFrDoJG"`},
		},
		{
			action: "list-spaces", input: `{"team_uuid":"3pDzCwAe"}`,
			method: "GET", path: "/wiki/api/wiki/team/3pDzCwAe/spaces",
		},
		{
			action: "create-space",
			input:  `{"team_uuid":"3pDzCwAe","title":"docs","description":"team docs"}`,
			method: "POST", path: "/wiki/api/wiki/team/3pDzCwAe/spaces/add",
			bodyParts: []string{`"title":"docs"`, `"description":"team docs"`},
		},
		{
			action: "get-space", input: `{"team_uuid":"3pDzCwAe","space_uuid":"Gj7aPmA1"}`,
			method: "GET", path: "/wiki/api/wiki/team/3pDzCwAe/space/Gj7aPmA1",
		},
		{
			action: "update-space",
			input:  `{"team_uuid":"3pDzCwAe","space_uuid":"Gj7aPmA1","title":"docs2","description":"d2"}`,
			method: "POST", path: "/wiki/api/wiki/team/3pDzCwAe/space/Gj7aPmA1/update",
			bodyParts: []string{`"title":"docs2"`},
		},
		{
			action: "delete-space", input: `{"team_uuid":"3pDzCwAe","space_uuid":"Gj7aPmA1"}`,
			method: "POST", path: "/wiki/api/wiki/team/3pDzCwAe/space/Gj7aPmA1/delete",
		},
		{
			action: "list-pages", input: `{"team_uuid":"3pDzCwAe","space_uuid":"Gj7aPmA1","status":1}`,
			method: "GET", path: "/wiki/api/wiki/team/3pDzCwAe/space/Gj7aPmA1/pages",
			query: "status=1",
		},
		{
			action: "get-page", input: `{"team_uuid":"3pDzCwAe","space_uuid":"Gj7aPmA1","page_uuid":"CbA3y4i1"}`,
			method: "GET", path: "/wiki/api/wiki/team/3pDzCwAe/space/Gj7aPmA1/page/CbA3y4i1",
		},
		{
			action: "get-page", input: `{"team_uuid":"3pDzCwAe","space_uuid":"Gj7aPmA1","page_uuid":"CbA3y4i1","version":3}`,
			method: "GET", path: "/wiki/api/wiki/team/3pDzCwAe/space/Gj7aPmA1/page/CbA3y4i1",
			query: "version=3",
		},
		{
			action: "delete-page", input: `{"team_uuid":"3pDzCwAe","space_uuid":"Gj7aPmA1","page_uuid":"N9TcsWjJ"}`,
			method: "POST", path: "/wiki/api/wiki/team/3pDzCwAe/space/Gj7aPmA1/page/N9TcsWjJ/delete",
		},
	}

	for _, testCase := range cases {
		t.Run(testCase.action+" "+testCase.path, func(t *testing.T) {
			t.Parallel()
			response := testCase.response
			if response == "" {
				response = `{"fixture":true}`
			}
			wire := newWireServer(t, http.StatusOK, response)
			_, err := invoke(t, wire, testCase.action, testCase.input)
			require.NoError(t, err)
			request := wire.last()
			require.Equal(t, testCase.method, request.method)
			require.Equal(t, testCase.path, request.path)
			if testCase.query != "" {
				require.Equal(t, testCase.query, request.query)
			} else {
				require.Empty(t, request.query)
			}
			for _, part := range testCase.bodyParts {
				require.True(t, strings.Contains(request.body, part),
					"body %s must contain %s", request.body, part)
			}
			requireOnesHeaders(t, request, wire.server.URL)
		})
	}
}

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

	wire := newWireServer(t, http.StatusOK, `{}`)
	_, err := invoke(t, wire, "get-space",
		`{"team_uuid":"../evil","space_uuid":"Gj7aPmA1"}`)
	mapped := runner.InvocationError(err)
	require.NotNil(t, mapped)
	var invocation *extensionflow.InvocationError
	require.ErrorAs(t, mapped, &invocation)
	require.Equal(t, extensionflow.InvocationValidation, invocation.Code)
	require.Empty(t, wire.requests)
}

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

	cases := []struct {
		name   string
		status int
		body   string
		code   extensionflow.InvocationErrorCode
	}{
		{name: "401", status: 401, code: extensionflow.InvocationProviderAuthUnavailable},
		{name: "403-default-permission", status: 403, code: extensionflow.InvocationProviderAuthUnavailable},
		{name: "603", status: 603, code: extensionflow.InvocationNotFound},
		{name: "607", status: 607, code: extensionflow.InvocationValidation},
		{name: "609", status: 609, code: extensionflow.InvocationNotFound},
		{name: "610", status: 610, code: extensionflow.InvocationNotFound},
		{name: "620", status: 620, code: extensionflow.InvocationNotFound},
		{name: "630", status: 630, code: extensionflow.InvocationNotFound},
		{name: "801", status: 801, code: extensionflow.InvocationValidation},
		{name: "808", status: 808, code: extensionflow.InvocationProviderAuthUnavailable},
		{name: "813", status: 813, code: extensionflow.InvocationProviderAuthUnavailable},
		{name: "817", status: 817, code: extensionflow.InvocationValidation},
		{name: "819", status: 819, code: extensionflow.InvocationValidation},
		{name: "900", status: 900, code: extensionflow.InvocationValidation},
		{name: "901", status: 901, code: extensionflow.InvocationValidation},
		{name: "500", status: 500, code: extensionflow.InvocationUpstreamFailure},
		{
			name: "403-permission-denied-body", status: 403,
			body: `{"errcode":"PermissionDenied.Task","type":"PermissionDenied"}`,
			code: extensionflow.InvocationProviderAuthUnavailable,
		},
		{
			name: "403-constraint-body", status: 403,
			body: `{"errcode":"ConstraintViolation.RootPageCannotDelete","type":"ConstraintViolation"}`,
			code: extensionflow.InvocationValidation,
		},
		{
			name: "403-limit-body", status: 403,
			body: `{"errcode":"LimitExceeded.Page","type":"LimitExceeded"}`,
			code: extensionflow.InvocationValidation,
		},
		{
			name: "404-notfound-body", status: 404,
			body: `{"errcode":"NotFound.Draft","type":"NotFound"}`,
			code: extensionflow.InvocationNotFound,
		},
		{
			name: "819-version-conflict-body", status: 819,
			body: `{"errcode":"VersionConflict","type":"VersionConflict"}`,
			code: extensionflow.InvocationValidation,
		},
	}
	for _, testCase := range cases {
		t.Run(testCase.name, func(t *testing.T) {
			t.Parallel()
			wire := newWireServer(t, testCase.status, testCase.body)
			_, err := invoke(t, wire, "list-spaces", `{"team_uuid":"3pDzCwAe"}`)
			mapped := runner.InvocationError(err)
			require.NotNil(t, mapped)
			var invocation *extensionflow.InvocationError
			require.ErrorAs(t, mapped, &invocation)
			require.Equal(t, testCase.code, invocation.Code)
		})
	}
}

func jsonInt(value int) string {
	encoded, _ := json.Marshal(value)
	return string(encoded)
}
