package workspace

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

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

	"github.com/tidbcloud/AgentTeam/internal/api"
	"github.com/tidbcloud/AgentTeam/internal/api/dto"
	"github.com/tidbcloud/AgentTeam/internal/runtimecontrol"
	workspaceservice "github.com/tidbcloud/AgentTeam/internal/service/workspace"
	"github.com/tidbcloud/AgentTeam/internal/store/agent"
	"github.com/tidbcloud/AgentTeam/internal/store/identity"
)

// mountedDirectoryHandler wires the ListDirectories handler onto a router
// using the same middleware chain the live route uses, so tests exercise the
// real auth+classifier+body-limit path rather than calling the handler in
// isolation.
func mountedDirectoryHandler(t *testing.T, handler *MachineHandler) *gin.Engine {
	t.Helper()
	router := gin.New()
	router.POST(
		"/api/v1/workspaces/:workspaceId/machines/:machineId/directories:list",
		limitMachineDirectoryListBody,
		func(ctx *gin.Context) {
			api.SetAuthenticatedUser(ctx, identity.AuthenticatedUser{User: identity.User{ID: "usr_1"}})
		},
		handleWorkspaceAuthJSON[dto.MachineDirectoryListRequest](handler.ListDirectories),
	)
	return router
}

func postDirectoryList(t *testing.T, router *gin.Engine, workspaceID, machineID, body string) *httptest.ResponseRecorder {
	t.Helper()
	recorder := httptest.NewRecorder()
	path := "/api/v1/workspaces/" + workspaceID + "/machines/" + machineID + "/directories:list"
	var reader *strings.Reader
	if body != "" {
		reader = strings.NewReader(body)
	}
	request := httptest.NewRequest(http.MethodPost, path, reader)
	request.Header.Set("Content-Type", "application/json")
	router.ServeHTTP(recorder, request)
	return recorder
}

// TestMachineHandlerListDirectoriesAuthRunsBeforeRuntimeChecks pins the
// auth-first ordering: when RequireMachineManagementForUser rejects, the
// handler MUST return the auth error without touching runtime, even if the
// runtime is nil and would otherwise 503 first.
func TestMachineHandlerListDirectoriesAuthRunsBeforeRuntimeChecks(t *testing.T) {
	t.Parallel()

	authErr := api.NewHTTPError(http.StatusForbidden, "not allowed")
	fake := &fakeWorkspaceService{machineManagementErr: authErr}
	// runtime is intentionally nil: if auth runs first, the handler must
	// never reach the runtime-nil branch that would have returned 503.
	handler := &MachineHandler{workspaceService: fake}
	router := mountedDirectoryHandler(t, handler)

	recorder := postDirectoryList(t, router, "wsp_1", "mach_1", `{"path":"/repo","limit":10}`)

	require.Equal(t, http.StatusForbidden, recorder.Code, "auth error MUST take precedence over runtime-nil 503")
	require.True(t, fake.requireManagementHit, "RequireMachineManagementForUser MUST run before any runtime check")
}

// TestMachineHandlerListDirectoriesReturnsUnavailableWhenRuntimeNil pins
// the second ordering invariant: with auth passing but no runtime wired,
// the handler MUST surface a typed 503 unavailable, not panic.
func TestMachineHandlerListDirectoriesReturnsUnavailableWhenRuntimeNil(t *testing.T) {
	t.Parallel()

	fake := &fakeWorkspaceService{}
	handler := &MachineHandler{workspaceService: fake}
	router := mountedDirectoryHandler(t, handler)

	recorder := postDirectoryList(t, router, "wsp_1", "mach_1", `{"path":"/repo"}`)

	require.Equal(t, http.StatusServiceUnavailable, recorder.Code)
	require.True(t, fake.requireManagementHit, "auth MUST have run before returning unavailable")
}

// TestMachineHandlerListDirectoriesForwardsRawPathByteForByte pins the
// raw-path contract: leading/trailing whitespace and interior spacing MUST
// reach the runtime verbatim so the daemon's path-resolution layer is the
// only authority that decides what "valid" means.
func TestMachineHandlerListDirectoriesForwardsRawPathByteForByte(t *testing.T) {
	t.Parallel()

	const rawPath = "  /users/lonng/ SECRET Path/..  "
	runtime := &fakeMachineRuntime{
		directoryBrowseSupported: map[string]bool{"mach_1": true},
		directoryPage: &agent.MachineDirectoryPage{
			CurrentPath: rawPath,
			ParentPath:  "/users/lonng",
			Directories: []agent.MachineDirectory{{Name: "repo", Path: rawPath + "/repo"}},
			Limit:       10,
		},
	}
	fake := &fakeWorkspaceService{}
	handler := &MachineHandler{workspaceService: fake, runtime: runtime}
	router := mountedDirectoryHandler(t, handler)

	recorder := postDirectoryList(t, router, "wsp_1", "mach_1", `{"path":"`+jsonEscape(rawPath)+`","limit":10}`)
	require.Equal(t, http.StatusOK, recorder.Code)

	require.Equal(t, rawPath, runtime.lastDirectoryListArgs.Path,
		"path MUST be forwarded byte-for-byte (no TrimSpace)")
	require.Equal(t, "mach_1", runtime.lastDirectoryListArgs.MachineID)
	require.Equal(t, 10, runtime.lastDirectoryListArgs.Options.Limit)
}

// TestMachineHandlerListDirectoriesParsesLimitAndStrictCursor pins the
// directory-specific body parser: limit defaults to 50, caps at 200, and
// the cursor accepts only canonical decimal form ("", "0", or [1-9][0-9]*
// up to 1000) — rejecting whitespace, leading zeros, negatives, and
// over-budget values with machine_directory_invalid.
func TestMachineHandlerListDirectoriesParsesLimitAndStrictCursor(t *testing.T) {
	t.Parallel()

	cases := []struct {
		name        string
		body        string
		wantLimit   int
		wantCursor  string
		wantStatus  int
		wantCode    string
		description string
	}{
		{
			name:       "empty limit defaults to 50",
			body:       `{"path":"/r"}`,
			wantLimit:  50,
			wantStatus: http.StatusOK,
		},
		{
			name:       "explicit limit preserved",
			body:       `{"path":"/r","limit":17}`,
			wantLimit:  17,
			wantStatus: http.StatusOK,
		},
		{
			name:       "over-cap limit caps to 200",
			body:       `{"path":"/r","limit":5000}`,
			wantLimit:  200,
			wantStatus: http.StatusOK,
		},
		{
			name:       "empty cursor accepted",
			body:       `{"path":"/r","cursor":""}`,
			wantLimit:  50,
			wantCursor: "",
			wantStatus: http.StatusOK,
		},
		{
			name:       "zero cursor accepted",
			body:       `{"path":"/r","cursor":"0"}`,
			wantLimit:  50,
			wantCursor: "0",
			wantStatus: http.StatusOK,
		},
		{
			name:       "max canonical cursor accepted",
			body:       `{"path":"/r","cursor":"1000"}`,
			wantLimit:  50,
			wantCursor: "1000",
			wantStatus: http.StatusOK,
		},
		{
			name:        "whitespace cursor rejected",
			body:        `{"path":"/r","cursor":" 10 "}`,
			wantStatus:  http.StatusBadRequest,
			wantCode:    "machine_directory_invalid",
			description: "whitespace MUST be rejected (strict parser, not TrimSpace)",
		},
		{
			name:        "leading-zero cursor rejected",
			body:        `{"path":"/r","cursor":"01"}`,
			wantStatus:  http.StatusBadRequest,
			wantCode:    "machine_directory_invalid",
			description: "non-canonical decimal form MUST be rejected",
		},
		{
			name:       "negative cursor rejected",
			body:       `{"path":"/r","cursor":"-1"}`,
			wantStatus: http.StatusBadRequest,
			wantCode:   "machine_directory_invalid",
		},
		{
			name:       "non-numeric cursor rejected",
			body:       `{"path":"/r","cursor":"abc"}`,
			wantStatus: http.StatusBadRequest,
			wantCode:   "machine_directory_invalid",
		},
		{
			name:        "overflow cursor rejected",
			body:        `{"path":"/r","cursor":"1001"}`,
			wantStatus:  http.StatusBadRequest,
			wantCode:    "machine_directory_invalid",
			description: "values >1000 MUST be rejected (raw-entry budget)",
		},
	}

	for _, tt := range cases {
		tt := tt
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()

			runtime := &fakeMachineRuntime{
				directoryBrowseSupported: map[string]bool{"mach_1": true},
				directoryPage: &agent.MachineDirectoryPage{
					CurrentPath: "/r",
					Limit:       tt.wantLimit,
					Cursor:      tt.wantCursor,
				},
			}
			handler := &MachineHandler{workspaceService: &fakeWorkspaceService{}, runtime: runtime}
			router := mountedDirectoryHandler(t, handler)

			recorder := postDirectoryList(t, router, "wsp_1", "mach_1", tt.body)
			require.Equal(t, tt.wantStatus, recorder.Code, "status mismatch for %s", tt.name)

			if tt.wantStatus != http.StatusOK {
				require.Contains(t, recorder.Body.String(), tt.wantCode, tt.description)
				require.Equal(t, 0, runtime.directoryListCallCount,
					"runtime MUST NOT be called when input is invalid")
				return
			}
			require.Equal(t, tt.wantLimit, runtime.lastDirectoryListArgs.Options.Limit)
			require.Equal(t, tt.wantCursor, runtime.lastDirectoryListArgs.Options.Cursor)
		})
	}
}

// TestMachineHandlerListDirectoriesMapsTypedSentinels pins the
// classifier table: every Hub-emitted typed sentinel MUST reach the wire
// as the exact HTTP status and stable code Web clients branch on.
func TestMachineHandlerListDirectoriesMapsTypedSentinels(t *testing.T) {
	t.Parallel()

	cases := []struct {
		name       string
		err        error
		wantStatus int
		wantCode   string
	}{
		{
			name:       "invalid maps to 400",
			err:        runtimecontrol.ErrMachineDirectoryInvalid,
			wantStatus: http.StatusBadRequest,
			wantCode:   "machine_directory_invalid",
		},
		{
			name:       "unreadable maps to 422",
			err:        runtimecontrol.ErrMachineDirectoryUnreadable,
			wantStatus: http.StatusUnprocessableEntity,
			wantCode:   "machine_directory_unreadable",
		},
		{
			name:       "unsupported maps to 501",
			err:        runtimecontrol.ErrMachineDirectoryUnsupported,
			wantStatus: http.StatusNotImplemented,
			wantCode:   "machine_directory_unsupported",
		},
		{
			name:       "unavailable maps to 503",
			err:        runtimecontrol.ErrMachineDirectoryUnavailable,
			wantStatus: http.StatusServiceUnavailable,
			wantCode:   "machine_directory_unavailable",
		},
		{
			name:       "busy maps to 503",
			err:        runtimecontrol.ErrMachineDirectoryBusy,
			wantStatus: http.StatusServiceUnavailable,
			wantCode:   "machine_directory_busy",
		},
	}

	for _, tt := range cases {
		tt := tt
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()

			runtime := &fakeMachineRuntime{
				directoryBrowseSupported: map[string]bool{"mach_1": true},
				directoryErr:             tt.err,
			}
			handler := &MachineHandler{workspaceService: &fakeWorkspaceService{}, runtime: runtime}
			router := mountedDirectoryHandler(t, handler)

			recorder := postDirectoryList(t, router, "wsp_1", "mach_1", `{"path":"/repo","limit":10}`)
			require.Equal(t, tt.wantStatus, recorder.Code)
			require.Contains(t, recorder.Body.String(), tt.wantCode)
			require.Equal(t, 1, runtime.directoryListCallCount)
		})
	}
}

// TestMachineHandlerListDirectoriesReturnsAllFieldsIncludingEmpty pins
// the wire-shape invariant: empty slices and strings MUST be present
// (not omitted), so Web clients can render an empty-state UI without
// truthy/falsy branching.
func TestMachineHandlerListDirectoriesReturnsAllFieldsIncludingEmpty(t *testing.T) {
	t.Parallel()

	runtime := &fakeMachineRuntime{
		directoryBrowseSupported: map[string]bool{"mach_1": true},
		directoryPage: &agent.MachineDirectoryPage{
			CurrentPath: "/empty",
			ParentPath:  "/",
			Directories: []agent.MachineDirectory{},
			Limit:       50,
			Cursor:      "0",
			HasMore:     false,
			NextCursor:  "",
			Truncated:   false,
		},
	}
	handler := &MachineHandler{workspaceService: &fakeWorkspaceService{}, runtime: runtime}
	router := mountedDirectoryHandler(t, handler)

	recorder := postDirectoryList(t, router, "wsp_1", "mach_1", `{"path":"/empty"}`)
	require.Equal(t, http.StatusOK, recorder.Code)

	// Empty arrays/strings must serialize as `[]` and `""`, not be omitted.
	require.JSONEq(t, `{
		"currentPath":"/empty",
		"parentPath":"/",
		"directories":[],
		"limit":50,
		"cursor":"0",
		"hasMore":false,
		"nextCursor":"",
		"truncated":false
	}`, recorder.Body.String())
}

// TestMachineHandlerListDirectoriesRejectsOversizedBody pins the
// 64 KiB request body ceiling: bodies above the limit MUST return 413
// without echoing the body, and the runtime MUST NOT be called.
func TestMachineHandlerListDirectoriesRejectsOversizedBody(t *testing.T) {
	t.Parallel()

	runtime := &fakeMachineRuntime{
		directoryBrowseSupported: map[string]bool{"mach_1": true},
		directoryPage:            &agent.MachineDirectoryPage{CurrentPath: "/x"},
	}
	handler := &MachineHandler{workspaceService: &fakeWorkspaceService{}, runtime: runtime}
	router := mountedDirectoryHandler(t, handler)

	// 70 KiB JSON body — well above the 64 KiB ceiling.
	padding := strings.Repeat("a", 70*1024)
	body := `{"path":"/x","limit":10,"cursor":"0","pad":"` + padding + `"}`

	recorder := postDirectoryList(t, router, "wsp_1", "mach_1", body)

	require.Condition(t, func() bool {
		return recorder.Code == http.StatusRequestEntityTooLarge || recorder.Code == http.StatusBadRequest
	}, "oversized body MUST return 413 (or 400 if the framework maps it); got %d", recorder.Code)
	require.NotContains(t, recorder.Body.String(), padding,
		"oversized body MUST NOT be echoed back")
	require.Equal(t, 0, runtime.directoryListCallCount,
		"runtime MUST NOT be called when body exceeds byte ceiling")
}

// TestMachineHandlerListDirectoriesDoesNotEchoBodyOnInvalidJSON pins the
// no-echo contract on JSON parse failure: invalid JSON MUST return 400
// with a fixed message, never the raw body.
func TestMachineHandlerListDirectoriesDoesNotEchoBodyOnInvalidJSON(t *testing.T) {
	t.Parallel()

	runtime := &fakeMachineRuntime{
		directoryBrowseSupported: map[string]bool{"mach_1": true},
	}
	handler := &MachineHandler{workspaceService: &fakeWorkspaceService{}, runtime: runtime}
	router := mountedDirectoryHandler(t, handler)

	rawBody := `{"path":"/repo","limit":10,this-is-not-json`
	recorder := postDirectoryList(t, router, "wsp_1", "mach_1", rawBody)

	require.Equal(t, http.StatusBadRequest, recorder.Code)
	require.NotContains(t, recorder.Body.String(), "this-is-not-json",
		"invalid JSON body MUST NOT be echoed back")
	require.Equal(t, 0, runtime.directoryListCallCount)
}

// TestMachineHandlerListDirectoriesPropagatesRemoteRequestContext pins
// that the handler attaches the user-interactive + web-api cause labels
// so the Hub's path-free telemetry can attribute the request correctly.
func TestMachineHandlerListDirectoriesPropagatesRemoteRequestContext(t *testing.T) {
	t.Parallel()

	runtime := &fakeMachineRuntime{
		directoryBrowseSupported: map[string]bool{"mach_1": true},
		directoryPage:            &agent.MachineDirectoryPage{CurrentPath: "/r"},
	}
	handler := &MachineHandler{workspaceService: &fakeWorkspaceService{}, runtime: runtime}
	router := mountedDirectoryHandler(t, handler)

	postDirectoryList(t, router, "wsp_1", "mach_1", `{"path":"/r"}`)
	require.Equal(t, runtimecontrol.RemoteRequestOriginUserInteractive, runtime.lastDirectoryListRequest.Origin)
	require.Equal(t, runtimecontrol.RemoteRequestCauseWebAPI, runtime.lastDirectoryListRequest.Cause)
}

// TestMachineHandlerListProjectsDirectoryBrowseSupported pins the
// live-capability DTO threading: only connected machines whose daemon
// advertises machine.directory.list@1 surface directoryBrowseSupported=true.
// Disconnected or unadvertised machines MUST default to false.
func TestMachineHandlerListProjectsDirectoryBrowseSupported(t *testing.T) {
	t.Parallel()

	fake := &fakeWorkspaceService{
		ownMachines: []workspaceservice.Machine{
			{ID: "mach_supported", Name: "Supported"},
			{ID: "mach_unadvertised", Name: "Unadvertised"},
			{ID: "mach_offline", Name: "Offline"},
		},
	}
	runtime := &fakeMachineRuntime{
		connectedMachines: map[string]bool{
			"mach_supported":    true,
			"mach_unadvertised": true,
		},
		directoryBrowseSupported: map[string]bool{
			"mach_supported": true,
			// mach_unadvertised is connected but does not advertise the endpoint
		},
	}
	handler := &MachineHandler{workspaceService: fake, runtime: runtime}
	router := gin.New()
	router.GET("/api/v1/workspaces/:workspaceId/machines", func(ctx *gin.Context) {
		api.SetAuthenticatedUser(ctx, identity.AuthenticatedUser{User: identity.User{ID: "usr_1"}})
	}, handleWorkspaceAuth(handler.List))

	recorder := httptest.NewRecorder()
	request := httptest.NewRequest(http.MethodGet, "/api/v1/workspaces/wsp_1/machines", nil)
	router.ServeHTTP(recorder, request)

	require.Equal(t, http.StatusOK, recorder.Code)

	var response struct {
		Machines []dto.Machine `json:"machines"`
	}
	require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response))
	require.Len(t, response.Machines, 3)

	got := make(map[string]bool, 3)
	for _, m := range response.Machines {
		got[m.ID] = m.DirectoryBrowseSupported
	}
	require.True(t, got["mach_supported"], "connected + advertised machine MUST report directoryBrowseSupported=true")
	require.False(t, got["mach_unadvertised"], "connected but unadvertised machine MUST default to false")
	require.False(t, got["mach_offline"], "offline machine MUST default to false")
}

// TestMachineDirectoryResponseNeverContainsPathMarker pins the path-free
// response contract end-to-end: even when the underlying daemon error
// surface would naturally carry path text, neither the JSON response body
// nor the api.ObservedServerError captured after the handler MAY contain
// the marker.
func TestMachineDirectoryResponseNeverContainsPathMarker(t *testing.T) {
	t.Parallel()

	const marker = "SECRET_PATH_MARKER_xyz"
	// Synthesize a 5xx that would naturally carry path text if mishandled.
	// The Hub already replaces raw failures with typed sentinels (Task 3),
	// so the handler should classify this as ErrMachineDirectoryUnavailable
	// without ever surfacing the original text.
	pathInside := "/users/" + marker
	runtime := &fakeMachineRuntime{
		directoryBrowseSupported: map[string]bool{"mach_1": true},
		directoryErr:             runtimecontrol.ErrMachineDirectoryUnavailable,
	}

	var observed api.ObservedServerError
	var observedOK bool
	handler := &MachineHandler{workspaceService: &fakeWorkspaceService{}, runtime: runtime}
	router := gin.New()
	router.POST(
		"/api/v1/workspaces/:workspaceId/machines/:machineId/directories:list",
		func(ctx *gin.Context) {
			api.SetAuthenticatedUser(ctx, identity.AuthenticatedUser{User: identity.User{ID: "usr_1"}})
		},
		handleWorkspaceAuthJSON[dto.MachineDirectoryListRequest](handler.ListDirectories),
		func(ctx *gin.Context) {
			observed, observedOK = api.ObservedServerErrorFromContext(ctx)
		},
	)

	recorder := postDirectoryList(t, router, "wsp_1", "mach_1", `{"path":"`+jsonEscape(pathInside)+`"}`)
	require.Equal(t, http.StatusServiceUnavailable, recorder.Code)
	require.NotContains(t, recorder.Body.String(), marker,
		"JSON response body MUST NOT carry the raw path marker")

	if observedOK {
		require.NotContains(t, observed.Err.Error(), marker,
			"ObservedServerError cause MUST NOT carry the raw path marker")
	}
}

// TestMachineDirectoryByteBudgetGuardRejectsMultibyteOversizedPath pins
// the byte-budget preflight at the API layer: a multibyte path whose
// marshaled payload exceeds the 64 KiB request body ceiling MUST return
// 413 (the body-limit middleware's contract) before any RPC. The Hub's
// own RequestBytes guard (Task 3) surfaces ErrMachineDirectoryInvalid
// for payloads that fit through the API body limit but still exceed the
// descriptor RequestBytes budget; that path is tested at the daemon
// layer.
func TestMachineDirectoryByteBudgetGuardRejectsMultibyteOversizedPath(t *testing.T) {
	t.Parallel()

	runtime := &fakeMachineRuntime{
		directoryBrowseSupported: map[string]bool{"mach_1": true},
	}
	handler := &MachineHandler{workspaceService: &fakeWorkspaceService{}, runtime: runtime}
	router := mountedDirectoryHandler(t, handler)

	// 4-byte UTF-8 rune × 20_000 = 80_000 bytes of raw path text, which
	// JSON-escapes to ~120_000 bytes of `\uXXXX` — well above 64 KiB.
	multibyte := strings.Repeat("🛠", 20000)
	body := `{"path":"` + multibyte + `"}`

	recorder := postDirectoryList(t, router, "wsp_1", "mach_1", body)
	require.Equal(t, http.StatusRequestEntityTooLarge, recorder.Code,
		"oversized multibyte body MUST hit the 64 KiB body-limit middleware as 413")
	require.NotContains(t, recorder.Body.String(), multibyte,
		"oversized body MUST NOT be echoed back")
	require.Equal(t, 0, runtime.directoryListCallCount,
		"byte-budget preflight MUST fire before runtime is called")
}

// jsonEscape returns the JSON-escaped form of raw so it can be inlined
// into a manually-built JSON body literal.
func jsonEscape(raw string) string {
	encoded, err := json.Marshal(raw)
	if err != nil {
		panic(err)
	}
	// json.Marshal returns the surrounding quotes; strip them so the caller
	// can embed the value inside its own pair of quotes.
	return string(encoded[1 : len(encoded)-1])
}
