package todoist_test

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

	"github.com/stretchr/testify/require"

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

func insecureTransport() *http.Transport {
	return &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}
}

type staticCredentialSource struct {
	credentials runner.Credentials
	err         error
}

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

// recordingServer captures one request and replies with the given status and
// body on every call.
type recordingServer struct {
	server   *httptest.Server
	requests chan http.Request
	bodies   chan []byte
}

func newRecordingServer(t *testing.T, status int, body string) *recordingServer {
	t.Helper()
	recording := &recordingServer{
		requests: make(chan http.Request, 16),
		bodies:   make(chan []byte, 16),
	}
	recording.server = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		bodyBytes, err := io.ReadAll(r.Body)
		require.NoError(t, err)
		requestCopy := *r
		recording.requests <- requestCopy
		recording.bodies <- bodyBytes
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(status)
		_, _ = w.Write([]byte(body))
	}))
	t.Cleanup(recording.server.Close)
	return recording
}

func (recording *recordingServer) client() *runner.RESTClient {
	client, err := runner.NewRESTClient(runner.RESTOptions{
		BaseURL:   recording.server.URL,
		Auth:      todoist.AuthSpec(),
		Transport: insecureTransport(),
	})
	if err != nil {
		panic(err)
	}
	return client
}

func (recording *recordingServer) lastRequest() (http.Request, []byte) {
	request := <-recording.requests
	body := <-recording.bodies
	return request, body
}

func testExecutor(
	t *testing.T,
	client *runner.RESTClient,
	source runner.CredentialSource,
) *runner.RESTExecutor {
	t.Helper()
	return runner.NewRESTExecutor(todoist.Domain, client, source, todoist.Handlers())
}

func validCredentials() runner.Credentials {
	return runner.Credentials{"apiKey": "test-token"}
}

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

	definition := todoist.Definition()
	declared := make(map[string]struct{}, len(definition.Actions))
	for _, action := range definition.Actions {
		declared[action.Name] = struct{}{}
	}
	executor := runner.NewRESTExecutor(
		todoist.Domain, nil, nil, todoist.Handlers())
	for _, name := range executor.ActionNames() {
		require.Contains(t, declared, name)
		delete(declared, name)
	}
	require.Empty(t, declared, "every catalog action must have a handler")
}

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

	ext, err := todoist.Extension(staticCredentialSource{})
	require.NoError(t, err)
	reg := registry.New()
	require.NoError(t, bootstrap.Register(reg, []extension.Extension{ext}))
	registered, ok := reg.LookupByIdentity(todoist.Domain, 1)
	require.True(t, ok)
	require.Equal(t, registry.Builtin, registered.Kind)
	require.Len(t, registered.Definition.Actions, 19)
}

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

	recording := newRecordingServer(t, http.StatusOK, `{"id":"123","full_name":"AgentTeam"}`)
	executor := testExecutor(t, recording.client(), staticCredentialSource{credentials: validCredentials()})
	result, err := executor.Invoke(context.Background(), invocation(todoist.ActionGetCurrentUser, `{}`))
	require.NoError(t, err)
	request, _ := recording.lastRequest()
	require.Equal(t, "/user", request.URL.Path)
	require.Equal(t, "Bearer test-token", request.Header.Get("Authorization"))
	var payload struct {
		User json.RawMessage `json:"user"`
	}
	require.NoError(t, json.Unmarshal(result.Data, &payload))
	require.NotEmpty(t, payload.User)
}

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

	recording := newRecordingServer(t, http.StatusOK,
		`{"results":[{"id":"6XG"}],"next_cursor":"cursor-2"}`)
	executor := testExecutor(t, recording.client(), staticCredentialSource{credentials: validCredentials()})
	result, err := executor.Invoke(context.Background(), invocation(todoist.ActionListProjects, `{"limit":50}`))
	require.NoError(t, err)
	request, _ := recording.lastRequest()
	require.Equal(t, "/projects", request.URL.Path)
	require.Equal(t, "50", request.URL.Query().Get("limit"))
	var page struct {
		Projects   []json.RawMessage `json:"projects"`
		NextCursor *string           `json:"nextCursor"`
	}
	require.NoError(t, json.Unmarshal(result.Data, &page))
	require.Len(t, page.Projects, 1)
	require.NotNil(t, page.NextCursor)
	require.Equal(t, "cursor-2", *page.NextCursor)
}

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

	recording := newRecordingServer(t, http.StatusOK, `{"id":"7","content":"ok"}`)
	executor := testExecutor(t, recording.client(), staticCredentialSource{credentials: validCredentials()})
	result, err := executor.Invoke(context.Background(), invocation(todoist.ActionCreateTask,
		`{"content":"Review PR","projectId":"6XG","priority":3,"dueString":"tomorrow"}`))
	require.NoError(t, err)
	request, body := recording.lastRequest()
	require.Equal(t, http.MethodPost, request.Method)
	require.Equal(t, "/tasks", request.URL.Path)
	var sent map[string]any
	require.NoError(t, json.Unmarshal(body, &sent))
	require.Equal(t, "Review PR", sent["content"])
	require.Equal(t, "6XG", sent["project_id"])
	require.Equal(t, float64(3), sent["priority"])
	require.Equal(t, "tomorrow", sent["due_string"])
	require.NotContains(t, sent, "labels")
	var payload struct {
		Task json.RawMessage `json:"task"`
	}
	require.NoError(t, json.Unmarshal(result.Data, &payload))
	require.NotEmpty(t, payload.Task)
}

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

	recording := newRecordingServer(t, http.StatusOK, `{}`)
	executor := testExecutor(t, recording.client(), staticCredentialSource{credentials: validCredentials()})
	result, err := executor.Invoke(context.Background(), invocation(todoist.ActionCloseTask, `{"taskId":"7"}`))
	require.NoError(t, err)
	request, _ := recording.lastRequest()
	require.Equal(t, "/tasks/7/close", request.URL.Path)
	var payload struct {
		Success bool `json:"success"`
	}
	require.NoError(t, json.Unmarshal(result.Data, &payload))
	require.True(t, payload.Success)
}

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

	cases := []struct {
		name   string
		status int
		code   extensionflow.InvocationErrorCode
	}{
		{name: "unauthorized", status: http.StatusUnauthorized, code: extensionflow.InvocationProviderAuthUnavailable},
		{name: "forbidden", status: http.StatusForbidden, code: extensionflow.InvocationProviderAuthUnavailable},
		{name: "not found", status: http.StatusNotFound, code: extensionflow.InvocationNotFound},
		{name: "rate limited", status: http.StatusTooManyRequests, code: extensionflow.InvocationRateLimited},
		{name: "server error", status: http.StatusInternalServerError, code: extensionflow.InvocationUpstreamFailure},
	}
	for _, testCase := range cases {
		t.Run(testCase.name, func(t *testing.T) {
			recording := newRecordingServer(t, testCase.status, `{"error":"boom"}`)
			executor := testExecutor(t, recording.client(), staticCredentialSource{credentials: validCredentials()})
			_, err := executor.Invoke(context.Background(), invocation(todoist.ActionGetTask, `{"taskId":"7"}`))
			require.Error(t, err)
			code, classified := extensionflow.InvocationErrorCodeOf(err)
			require.True(t, classified)
			require.Equal(t, testCase.code, code)
		})
	}
}

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

	recording := newRecordingServer(t, http.StatusOK, `{}`)
	executor := testExecutor(t, recording.client(), staticCredentialSource{})
	_, err := executor.Invoke(context.Background(), invocation("unknown_action", `{}`))
	require.Error(t, err)
	require.Equal(t, runner.ErrorNotFound, runner.CodeOf(err))

	_, err = executor.Invoke(context.Background(), invocation(todoist.ActionGetTask, `{"taskId":"7"}`))
	require.Error(t, err)
	code, classified := extensionflow.InvocationErrorCodeOf(err)
	require.True(t, classified)
	require.Equal(t, extensionflow.InvocationProviderAuthUnavailable, code)
}

func invocation(action string, input string) registry.BuiltinInvocation {
	return registry.BuiltinInvocation{
		WorkspaceID:           "ws",
		ServiceInstallationID: "inst",
		ExtensionName:         "todoist",
		Action:                action,
		Input:                 json.RawMessage(input),
	}
}

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

	server := newRecordingServer(t, http.StatusOK,
		`{"id":"45","attachment":null,"file_attachment":{"file_name":"a.png"}}`)
	client, err := runner.NewRESTClient(runner.RESTOptions{
		BaseURL:   server.server.URL,
		Auth:      todoist.AuthSpec(),
		Transport: insecureTransport(),
	})
	if err != nil {
		t.Fatal(err)
	}
	executor := runner.NewRESTExecutor(todoist.Domain, client,
		staticCredentialSource{credentials: validCredentials()}, todoist.Handlers())
	result, err := executor.Invoke(context.Background(), invocation(todoist.ActionGetComment, `{"commentId":"45"}`))
	require.NoError(t, err)
	var payload struct {
		Comment struct {
			Attachment *struct {
				FileName string `json:"file_name"`
			} `json:"attachment"`
		} `json:"comment"`
	}
	require.NoError(t, json.Unmarshal(result.Data, &payload))
	require.NotNil(t, payload.Comment.Attachment, "null attachment must fall back to file_attachment")
	require.Equal(t, "a.png", payload.Comment.Attachment.FileName)
}

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

	cases := []struct {
		name     string
		response string
	}{
		{name: "null attachment", response: `{"id":"1","attachment":null}`},
		{name: "scalar attachment", response: `{"id":"1","attachment":"raw"}`},
		{name: "number attachment", response: `{"id":"1","attachment":123}`},
		{name: "null attachment with non-object fallback", response: `{"id":"1","attachment":null,"file_attachment":"x"}`},
	}
	for _, testCase := range cases {
		t.Run(testCase.name, func(t *testing.T) {
			server := newRecordingServer(t, http.StatusOK, testCase.response)
			client, err := runner.NewRESTClient(runner.RESTOptions{
				BaseURL:   server.server.URL,
				Auth:      todoist.AuthSpec(),
				Transport: insecureTransport(),
			})
			if err != nil {
				t.Fatal(err)
			}
			executor := runner.NewRESTExecutor(todoist.Domain, client,
				staticCredentialSource{credentials: validCredentials()}, todoist.Handlers())
			result, err := executor.Invoke(context.Background(), invocation(todoist.ActionGetComment, `{"commentId":"1"}`))
			require.NoError(t, err)
			require.NotContains(t, string(result.Data), `"attachment"`,
				"an invalid attachment with no valid fallback must be removed")
		})
	}
}
