package api

import (
	"bytes"
	"encoding/json"
	"errors"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
	"time"

	"github.com/gin-gonic/gin"
	"github.com/stretchr/testify/require"

	"github.com/tidbcloud/AgentTeam/internal/store/agent"
	"github.com/tidbcloud/AgentTeam/internal/store/identity"
	"github.com/tidbcloud/AgentTeam/internal/store/workspace"
)

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

	recorder := httptest.NewRecorder()
	_, router := gin.CreateTestContext(recorder)
	router.GET("/test", HandlePublic(func(request PublicRequest) (Response, error) {
		require.NotNil(t, request.Gin)
		return OK(gin.H{"public": true}), nil
	}))

	router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/test", nil))

	require.Equal(t, http.StatusOK, recorder.Code)
	require.JSONEq(t, `{"public":true}`, recorder.Body.String())
}

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

	principal := agent.AuthenticatedAgent{
		AgentID:     "agt_1",
		WorkspaceID: "wsp_1",
		MemberID:    "mem_1",
		MachineID:   "mch_1",
	}
	recorder := httptest.NewRecorder()
	_, router := gin.CreateTestContext(recorder)
	router.GET("/test", func(ctx *gin.Context) {
		SetAuthenticatedAgent(ctx, principal)
	}, HandleAgentScoped(func(request AgentScopedRequest) (Response, error) {
		require.Equal(t, principal, request.Principal)
		require.NotNil(t, request.Gin)
		require.Equal(t, request.Gin.Request.Context(), request.Context())
		return OK(gin.H{"agentId": request.Principal.AgentID}), nil
	}))

	router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/test", nil))

	require.Equal(t, http.StatusOK, recorder.Code)
	require.JSONEq(t, `{"agentId":"agt_1"}`, recorder.Body.String())
}

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

	type requestBody struct {
		Name string `json:"name" binding:"required"`
	}
	principal := agent.AuthenticatedAgent{AgentID: "agt_1", WorkspaceID: "wsp_1"}
	recorder := httptest.NewRecorder()
	_, router := gin.CreateTestContext(recorder)
	router.POST("/test", func(ctx *gin.Context) {
		SetAuthenticatedAgent(ctx, principal)
	}, HandleAgentScopedJSON(func(request AgentScopedJSON[requestBody]) (Response, error) {
		require.Equal(t, principal, request.Principal)
		require.Equal(t, "alice", request.Body.Name)
		return OK(gin.H{"name": request.Body.Name}), nil
	}))

	httpRequest := httptest.NewRequest(http.MethodPost, "/test", strings.NewReader(`{"name":"alice"}`))
	httpRequest.Header.Set("Content-Type", "application/json")
	router.ServeHTTP(recorder, httpRequest)

	require.Equal(t, http.StatusOK, recorder.Code)
	require.JSONEq(t, `{"name":"alice"}`, recorder.Body.String())
}

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

	recorder := httptest.NewRecorder()
	_, router := gin.CreateTestContext(recorder)
	router.GET("/test", HandleAgentScoped(func(AgentScopedRequest) (Response, error) {
		t.Fatal("handler should not run")
		return NoContent(), nil
	}))

	router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/test", nil))

	require.Equal(t, http.StatusUnauthorized, recorder.Code)
	require.Equal(t, "Unauthorized", testJSONError(t, recorder))
}

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

	recorder := httptest.NewRecorder()
	_, router := gin.CreateTestContext(recorder)
	router.GET("/test", func(ctx *gin.Context) {
		SetAuthenticatedUser(ctx, identity.AuthenticatedUser{User: identity.User{ID: "usr_1"}})
		SetCurrentWorkspace(ctx, workspace.Summary{WorkspaceID: "wsp_1"})
	}, HandleWorkspaceScoped(func(request WorkspaceScopedRequest) (Response, error) {
		require.Equal(t, "usr_1", request.User.User.ID)
		require.Equal(t, "usr_1", request.UserID())
		require.Equal(t, "wsp_1", request.Workspace.WorkspaceID)
		require.Equal(t, "wsp_1", request.WorkspaceID())
		require.NotNil(t, request.Gin)
		return OK(gin.H{"ok": true}), nil
	}))

	router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/test", nil))

	require.Equal(t, http.StatusOK, recorder.Code)
	require.JSONEq(t, `{"ok":true}`, recorder.Body.String())
}

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

	recorder := httptest.NewRecorder()
	_, router := gin.CreateTestContext(recorder)
	router.GET("/test", func(ctx *gin.Context) {
		SetAuthenticatedUser(ctx, identity.AuthenticatedUser{User: identity.User{ID: "usr_1"}})
		SetCurrentWorkspace(ctx, workspace.Summary{WorkspaceID: "wsp_1"})
	}, HandleWorkspaceScoped(func(WorkspaceScopedRequest) (Response, error) {
		return Content(
			http.StatusOK,
			"preview.jpg",
			"image/jpeg",
			bytes.NewReader([]byte("preview-bytes")),
			time.Time{},
			http.Header{
				"Cache-Control": []string{"private, max-age=60"},
				"ETag":          []string{`"preview-v1"`},
			},
		), nil
	}))

	router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/test", nil))

	require.Equal(t, http.StatusOK, recorder.Code)
	require.Equal(t, "image/jpeg", recorder.Header().Get("Content-Type"))
	require.Equal(t, "private, max-age=60", recorder.Header().Get("Cache-Control"))
	require.Equal(t, `"preview-v1"`, recorder.Header().Get("ETag"))
	require.Equal(t, "preview-bytes", recorder.Body.String())
}

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

	recorder := httptest.NewRecorder()
	_, router := gin.CreateTestContext(recorder)
	router.GET("/test", func(ctx *gin.Context) {
		SetAuthenticatedUser(ctx, identity.AuthenticatedUser{User: identity.User{ID: "usr_1"}})
		SetCurrentWorkspace(ctx, workspace.Summary{WorkspaceID: "wsp_1"})
	}, HandleWorkspaceScoped(func(WorkspaceScopedRequest) (Response, error) {
		return Content(
			http.StatusOK,
			"preview.jpg",
			"image/jpeg",
			bytes.NewReader([]byte("preview-bytes")),
			time.Time{},
			nil,
		), nil
	}))

	request := httptest.NewRequest(http.MethodGet, "/test", nil)
	request.Header.Set("Range", "bytes=0-6")
	router.ServeHTTP(recorder, request)

	require.Equal(t, http.StatusPartialContent, recorder.Code)
	require.Equal(t, "bytes 0-6/13", recorder.Header().Get("Content-Range"))
	require.Equal(t, "preview", recorder.Body.String())
}

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

	recorder := httptest.NewRecorder()
	_, router := gin.CreateTestContext(recorder)
	router.GET("/test", func(ctx *gin.Context) {
		SetAuthenticatedUser(ctx, identity.AuthenticatedUser{User: identity.User{ID: "usr_1"}})
		SetCurrentWorkspace(ctx, workspace.Summary{WorkspaceID: "wsp_1"})
	}, HandleWorkspaceScoped(func(WorkspaceScopedRequest) (Response, error) {
		return Content(
			http.StatusOK,
			"preview.jpg",
			"image/jpeg",
			bytes.NewReader([]byte("preview-bytes")),
			time.Time{},
			http.Header{"ETag": []string{`"preview-v1"`}},
		), nil
	}))

	request := httptest.NewRequest(http.MethodGet, "/test", nil)
	request.Header.Set("If-None-Match", `"preview-v1"`)
	router.ServeHTTP(recorder, request)

	require.Equal(t, http.StatusNotModified, recorder.Code)
	require.Empty(t, recorder.Body.String())
}

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

	recorder := httptest.NewRecorder()
	_, router := gin.CreateTestContext(recorder)
	router.GET("/test", HandleAuth(func(AuthenticatedRequest) (Response, error) {
		t.Fatal("handler should not run")
		return NoContent(), nil
	}))

	router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/test", nil))

	require.Equal(t, http.StatusUnauthorized, recorder.Code)
	require.Equal(t, "Unauthorized", testJSONError(t, recorder))
}

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

	recorder := httptest.NewRecorder()
	_, router := gin.CreateTestContext(recorder)
	router.GET("/test", func(ctx *gin.Context) {
		SetAuthenticatedUser(ctx, identity.AuthenticatedUser{User: identity.User{ID: "usr_1"}})
	}, HandleWorkspaceScoped(func(WorkspaceScopedRequest) (Response, error) {
		t.Fatal("handler should not run")
		return NoContent(), nil
	}))

	router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/test", nil))

	require.Equal(t, http.StatusBadRequest, recorder.Code)
	require.Equal(t, "No workspace selected", testJSONError(t, recorder))
}

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

	type requestBody struct {
		Name string `json:"name" binding:"required"`
	}
	recorder := httptest.NewRecorder()
	_, router := gin.CreateTestContext(recorder)
	router.POST("/test", seedWorkspaceScopedRequestContext(), HandleWorkspaceScopedJSON(func(WorkspaceScopedJSON[requestBody]) (Response, error) {
		t.Fatal("handler should not run")
		return NoContent(), nil
	}))

	request := httptest.NewRequest(http.MethodPost, "/test", strings.NewReader("{"))
	request.Header.Set("Content-Type", "application/json")
	router.ServeHTTP(recorder, request)

	require.Equal(t, http.StatusBadRequest, recorder.Code)
	require.Equal(t, "Invalid request body", testJSONError(t, recorder))
}

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

	type requestBody struct {
		Name string `json:"name" binding:"required"`
	}
	recorder := httptest.NewRecorder()
	_, router := gin.CreateTestContext(recorder)
	router.POST("/test", seedWorkspaceScopedRequestContext(), HandleWorkspaceScopedJSON(func(WorkspaceScopedJSON[requestBody]) (Response, error) {
		t.Fatal("handler should not run")
		return NoContent(), nil
	}, WithInvalidJSONMessage("invalid request body")))

	request := httptest.NewRequest(http.MethodPost, "/test", strings.NewReader("{"))
	request.Header.Set("Content-Type", "application/json")
	router.ServeHTTP(recorder, request)

	require.Equal(t, http.StatusBadRequest, recorder.Code)
	require.Equal(t, "invalid request body", testJSONError(t, recorder))
}

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

	type requestBody struct {
		Name string `json:"name"`
	}
	recorder := httptest.NewRecorder()
	_, router := gin.CreateTestContext(recorder)
	router.POST("/test",
		seedWorkspaceScopedRequestContext(),
		func(ctx *gin.Context) {
			ctx.Request.Body = http.MaxBytesReader(ctx.Writer, ctx.Request.Body, 16)
		},
		HandleWorkspaceScopedJSON(func(WorkspaceScopedJSON[requestBody]) (Response, error) {
			t.Fatal("handler should not run")
			return NoContent(), nil
		}),
	)

	request := httptest.NewRequest(http.MethodPost, "/test", strings.NewReader(`{"name":"`+strings.Repeat("x", 32)+`"}`))
	request.Header.Set("Content-Type", "application/json")
	router.ServeHTTP(recorder, request)

	require.Equal(t, http.StatusRequestEntityTooLarge, recorder.Code)
	require.Equal(t, "Invalid request body", testJSONError(t, recorder))
}

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

	type requestBody struct {
		Name string `json:"name" binding:"required"`
	}
	recorder := httptest.NewRecorder()
	_, router := gin.CreateTestContext(recorder)
	router.POST("/test", seedWorkspaceScopedRequestContext(), HandleWorkspaceScopedJSON(func(r WorkspaceScopedJSON[requestBody]) (Response, error) {
		require.Equal(t, "usr_1", r.UserID())
		require.Equal(t, "wsp_1", r.WorkspaceID())
		require.Equal(t, "alice", r.Body.Name)
		return OK(gin.H{"name": r.Body.Name, "workspaceId": r.WorkspaceID()}), nil
	}))

	request := httptest.NewRequest(http.MethodPost, "/test", strings.NewReader(`{"name":"alice"}`))
	request.Header.Set("Content-Type", "application/json")
	router.ServeHTTP(recorder, request)

	require.Equal(t, http.StatusOK, recorder.Code)
	require.JSONEq(t, `{"name":"alice","workspaceId":"wsp_1"}`, recorder.Body.String())
}

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

	type requestBody struct {
		Name string `json:"name" binding:"required"`
	}
	recorder := httptest.NewRecorder()
	_, router := gin.CreateTestContext(recorder)
	router.POST("/test", func(ctx *gin.Context) {
		SetAuthenticatedUser(ctx, identity.AuthenticatedUser{User: identity.User{ID: "usr_1"}})
	}, HandleAuthJSON(func(r AuthenticatedJSON[requestBody]) (Response, error) {
		require.Equal(t, "usr_1", r.UserID())
		require.Equal(t, "alice", r.Body.Name)
		return OK(gin.H{"name": r.Body.Name, "userId": r.UserID()}), nil
	}))

	request := httptest.NewRequest(http.MethodPost, "/test", strings.NewReader(`{"name":"alice"}`))
	request.Header.Set("Content-Type", "application/json")
	router.ServeHTTP(recorder, request)

	require.Equal(t, http.StatusOK, recorder.Code)
	require.JSONEq(t, `{"name":"alice","userId":"usr_1"}`, recorder.Body.String())
}

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

	type requestBody struct {
		Name string `json:"name"`
	}
	recorder := httptest.NewRecorder()
	ctx, _ := gin.CreateTestContext(recorder)
	ctx.Request = httptest.NewRequest(http.MethodPost, "/test", nil)

	body, hasBody, err := BindEmptyOrJSON[requestBody](ctx)

	require.NoError(t, err)
	require.False(t, hasBody)
	require.Empty(t, body.Name)
}

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

	type requestBody struct {
		Name string `json:"name"`
	}
	recorder := httptest.NewRecorder()
	ctx, _ := gin.CreateTestContext(recorder)
	ctx.Request = httptest.NewRequest(http.MethodPost, "/test", strings.NewReader(" \n\t "))
	ctx.Request.Header.Set("Content-Type", "application/json")

	body, hasBody, err := BindEmptyOrJSON[requestBody](ctx)

	require.NoError(t, err)
	require.False(t, hasBody)
	require.Empty(t, body.Name)
}

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

	type requestBody struct {
		Name string `json:"name"`
	}
	recorder := httptest.NewRecorder()
	ctx, _ := gin.CreateTestContext(recorder)
	ctx.Request = httptest.NewRequest(http.MethodPost, "/test", strings.NewReader("{}"))
	ctx.Request.Header.Set("Content-Type", "application/json")

	body, hasBody, err := BindEmptyOrJSON[requestBody](ctx)

	require.NoError(t, err)
	require.True(t, hasBody)
	require.Empty(t, body.Name)
}

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

	type requestBody struct {
		Name string `json:"name"`
	}
	recorder := httptest.NewRecorder()
	ctx, _ := gin.CreateTestContext(recorder)
	ctx.Request = httptest.NewRequest(http.MethodPost, "/test", strings.NewReader("{"))
	ctx.Request.Header.Set("Content-Type", "application/json")

	_, hasBody, err := BindEmptyOrJSON[requestBody](ctx)

	require.Error(t, err)
	require.True(t, hasBody)
}

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

	recorder := httptest.NewRecorder()
	_, router := gin.CreateTestContext(recorder)
	router.GET("/test", seedWorkspaceScopedRequestContext(), HandleWorkspaceScoped(func(request WorkspaceScopedRequest) (Response, error) {
		require.Equal(t, request.Gin.Request.Context(), request.Context())
		require.Equal(t, "usr_1", request.UserID())
		require.Equal(t, "wsp_1", request.WorkspaceID())
		return NoContent(), nil
	}))

	router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/test", nil))

	require.Equal(t, http.StatusNoContent, recorder.Code)
}

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

	recorder := httptest.NewRecorder()
	_, router := gin.CreateTestContext(recorder)
	router.GET("/test", seedWorkspaceScopedRequestContext(), HandleWorkspaceScoped(func(WorkspaceScopedRequest) (Response, error) {
		return Response{}, ErrNotFound
	}))

	router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/test", nil))

	require.Equal(t, http.StatusNotFound, recorder.Code)
	require.Equal(t, ErrNotFound.Error(), testJSONError(t, recorder))
}

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

	handlerErr := errors.New("database exploded")
	var observed ObservedServerError
	var observedOK bool
	recorder := httptest.NewRecorder()
	_, router := gin.CreateTestContext(recorder)
	router.GET(
		"/test",
		seedWorkspaceScopedRequestContext(),
		HandleWorkspaceScoped(func(WorkspaceScopedRequest) (Response, error) {
			return Response{}, handlerErr
		}),
		func(ctx *gin.Context) {
			observed, observedOK = ObservedServerErrorFromContext(ctx)
		},
	)

	router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/test", nil))

	require.Equal(t, http.StatusInternalServerError, recorder.Code)
	require.Equal(t, handlerErr.Error(), testJSONError(t, recorder))
	require.True(t, observedOK)
	require.Same(t, handlerErr, observed.Err)
	require.Equal(t, http.StatusInternalServerError, observed.Status)
}

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

	var observedOK bool
	recorder := httptest.NewRecorder()
	_, router := gin.CreateTestContext(recorder)
	router.GET(
		"/test",
		seedWorkspaceScopedRequestContext(),
		HandleWorkspaceScoped(func(WorkspaceScopedRequest) (Response, error) {
			return Response{}, ErrNotFound
		}),
		func(ctx *gin.Context) {
			_, observedOK = ObservedServerErrorFromContext(ctx)
		},
	)

	router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/test", nil))

	require.Equal(t, http.StatusNotFound, recorder.Code)
	require.False(t, observedOK)
}

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

	cause := errors.New("store unavailable")
	var observed ObservedServerError
	var observedOK bool
	recorder := httptest.NewRecorder()
	_, router := gin.CreateTestContext(recorder)
	router.GET(
		"/test",
		seedWorkspaceScopedRequestContext(),
		HandleWorkspaceScoped(func(WorkspaceScopedRequest) (Response, error) {
			return Response{}, NewHTTPErrorWithCause(http.StatusInternalServerError, "Internal server error", cause)
		}),
		func(ctx *gin.Context) {
			observed, observedOK = ObservedServerErrorFromContext(ctx)
		},
	)

	router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/test", nil))

	require.Equal(t, http.StatusInternalServerError, recorder.Code)
	require.Equal(t, "Internal server error", testJSONError(t, recorder))
	require.True(t, observedOK)
	require.Same(t, cause, observed.Err)
	require.Equal(t, http.StatusInternalServerError, observed.Status)
}

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

	domainErr := errors.New("domain says no")
	recorder := httptest.NewRecorder()
	_, router := gin.CreateTestContext(recorder)
	router.GET("/test", seedWorkspaceScopedRequestContext(), HandleWorkspaceScoped(func(WorkspaceScopedRequest) (Response, error) {
		return Response{}, domainErr
	}, WithErrorClassifier(func(err error) (ErrorResponse, bool) {
		if errors.Is(err, domainErr) {
			return ErrorResponse{Status: http.StatusTeapot, Message: "classified"}, true
		}
		return ErrorResponse{}, false
	})))

	router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/test", nil))

	require.Equal(t, http.StatusTeapot, recorder.Code)
	require.Equal(t, "classified", testJSONError(t, recorder))
}

func seedWorkspaceScopedRequestContext() gin.HandlerFunc {
	return func(ctx *gin.Context) {
		SetAuthenticatedUser(ctx, identity.AuthenticatedUser{User: identity.User{ID: "usr_1"}})
		SetCurrentWorkspace(ctx, workspace.Summary{WorkspaceID: "wsp_1"})
	}
}

func testJSONError(t *testing.T, recorder *httptest.ResponseRecorder) string {
	t.Helper()
	var payload struct {
		Error string `json:"error"`
	}
	require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &payload))
	return payload.Error
}
