package forwarder

import (
	"bytes"
	"context"
	"encoding/base64"
	"encoding/json"
	"errors"
	"io"
	"log/slog"
	"net/http"
	"net/http/httptest"
	"net/url"
	"strings"
	"sync/atomic"
	"testing"
	"time"

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

	"github.com/tidbcloud/AgentTeam/internal/api"
	"github.com/tidbcloud/AgentTeam/internal/store/agent"
	"github.com/tidbcloud/AgentTeam/internal/store/identity"
	"github.com/tidbcloud/AgentTeam/internal/store/message"
	"github.com/tidbcloud/AgentTeam/internal/store/model"
	"github.com/tidbcloud/AgentTeam/internal/store/plan"
	"github.com/tidbcloud/AgentTeam/internal/store/workspace"
	"github.com/tidbcloud/AgentTeam/internal/workspace/owner"
)

type fakeAgentThreadSessionChannelStore struct {
	ensured bool
}

func expectedWorkspacePath(path string) string {
	return "/api/v1/workspaces/wsp_1" + strings.TrimPrefix(path, "/api/v1")
}

func (store *fakeAgentThreadSessionChannelStore) EnsureAgentThreadSessionChannelForUser(_ context.Context, _ string, _ string) (message.ChannelSummary, error) {
	store.ensured = true
	return message.ChannelSummary{ChannelID: "chn_agent_session", DisplayName: "Agent Sessions"}, nil
}

type fakeAuthStore struct {
	authenticated identity.AuthenticatedUser
}

func (store fakeAuthStore) AuthenticateSession(_ context.Context, _ string) (identity.AuthenticatedUser, error) {
	return store.authenticated, nil
}

type recordingAuthStore struct {
	authenticated identity.AuthenticatedUser
	err           error
	token         string
	user          identity.User
	identifier    string
}

func (store *recordingAuthStore) AuthenticateSession(_ context.Context, token string) (identity.AuthenticatedUser, error) {
	store.token = token
	return store.authenticated, store.err
}

func (store *recordingAuthStore) FindUserByLoginIdentifier(_ context.Context, identifier string) (identity.User, error) {
	store.identifier = identifier
	return store.user, nil
}

type fakeWorkspaceStore struct {
	workspace workspace.Summary
}

func (store fakeWorkspaceStore) GetWorkspaceForUser(_ context.Context, _ string, _ string) (workspace.Summary, error) {
	return store.workspace, nil
}

type fakeOwnedAgentStore struct {
	agents []agent.AppAgent
}

func (store fakeOwnedAgentStore) ListOwnedAgentsForWorkspace(context.Context, string, string) ([]agent.AppAgent, error) {
	return append([]agent.AppAgent(nil), store.agents...), nil
}

type fakeWorkspaceResourceLimitStore struct {
	limit     plan.WorkspaceResourceLimit
	err       error
	called    int
	workspace string
	owner     string
}

func TestForwarderWorkspaceOwnerGuardFailsClosed(t *testing.T) {
	dependencies := Dependencies{EnforceRouteWorkspaceOwner: true, ManagedWorkspaces: owner.NewStaticSet([]string{"wsp_a"})}

	for _, test := range []struct {
		name      string
		routeID   string
		currentID string
		want      int
	}{
		{name: "missing", want: http.StatusBadRequest},
		{name: "wrong owner", routeID: "wsp_b", want: http.StatusMisdirectedRequest},
		{name: "current conflict", routeID: "wsp_a", currentID: "wsp_b", want: http.StatusBadRequest},
		{name: "owned", routeID: "wsp_a", currentID: "wsp_a", want: http.StatusNoContent},
	} {
		t.Run(test.name, func(t *testing.T) {
			gin.SetMode(gin.ReleaseMode)
			router := gin.New()
			router.GET("/route", requireForwarderRouteWorkspaceOwner(dependencies), func(ctx *gin.Context) { ctx.Status(http.StatusNoContent) })
			request := httptest.NewRequest(http.MethodGet, "/route", nil)
			if test.routeID != "" {
				request.Header.Set(api.HeaderRouteWorkspaceID, test.routeID)
			}
			if test.currentID != "" {
				request.Header.Set(api.HeaderWorkspaceID, test.currentID)
			}
			recorder := httptest.NewRecorder()
			router.ServeHTTP(recorder, request)
			require.Equal(t, test.want, recorder.Code)
		})
	}
}

func TestForwarderWorkspaceOwnerGuardCoversEveryMeshXRouteFamily(t *testing.T) {
	gin.SetMode(gin.ReleaseMode)
	upstream := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
		t.Fatal("wrong-owner request reached MeshX upstream")
	}))
	t.Cleanup(upstream.Close)

	router := gin.New()
	RegisterRoutes(router, Dependencies{
		AuthStore:                  fakeAuthStore{authenticated: identity.AuthenticatedUser{User: model.User{ID: "usr_1"}}},
		WorkspaceStore:             fakeWorkspaceStore{workspace: workspace.Summary{WorkspaceID: "wsp_b", WorkspaceRole: "member"}},
		BaseURL:                    upstream.URL,
		EnforceRouteWorkspaceOwner: true,
		ManagedWorkspaces:          owner.NewStaticSet([]string{"wsp_a"}),
	})
	server := httptest.NewServer(router)
	t.Cleanup(server.Close)

	for _, route := range []struct {
		method string
		path   string
	}{
		{method: http.MethodGet, path: "/api/v1/workspaces/wsp_b/kb"},
		{method: http.MethodGet, path: "/api/v1/workspaces/wsp_b/kb-publication-reviews/appr_1"},
		{method: http.MethodGet, path: "/api/v1/workspaces/wsp_b/mcp"},
		{method: http.MethodGet, path: "/api/v1/workspaces/wsp_b/repositories/code-wiki/jobs/job_1"},
		{method: http.MethodGet, path: "/api/v1/workspaces/wsp_b/agent-thread/session-channel"},
	} {
		t.Run(route.path, func(t *testing.T) {
			request, err := http.NewRequest(route.method, server.URL+route.path, nil)
			require.NoError(t, err)
			request.Header.Set(api.HeaderRouteWorkspaceID, "wsp_b")
			request.Header.Set("Authorization", "Bearer agentteam-session")
			response, err := server.Client().Do(request)
			require.NoError(t, err)
			response.Body.Close()
			require.Equal(t, http.StatusMisdirectedRequest, response.StatusCode)
		})
	}
	for _, route := range []struct{ method, path string }{
		{method: http.MethodGet, path: "/api/ext/v1/user"},
		{method: http.MethodPost, path: "/api/graphql"},
		{method: http.MethodGet, path: "/owner/repo/raw/main/README.md"},
	} {
		request, err := http.NewRequest(route.method, server.URL+route.path, nil)
		require.NoError(t, err)
		response, err := server.Client().Do(request)
		require.NoError(t, err)
		response.Body.Close()
		require.Equal(t, http.StatusNotFound, response.StatusCode, route.path)
	}
}

func TestForwarderSplitProfileRejectsUnqualifiedAGSCompatibility(t *testing.T) {
	var upstreamCalls atomic.Int32
	upstream := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
		upstreamCalls.Add(1)
		writer.WriteHeader(http.StatusNoContent)
	}))
	t.Cleanup(upstream.Close)

	router := gin.New()
	RegisterRoutes(router, Dependencies{
		BaseURL:                    upstream.URL,
		EnforceRouteWorkspaceOwner: true,
		ManagedWorkspaces:          owner.NewStaticSet([]string{"wsp_a"}),
	})
	server := httptest.NewServer(router)
	t.Cleanup(server.Close)
	for _, route := range []struct {
		method string
		path   string
	}{
		{method: http.MethodGet, path: "/api/v3"},
		{method: http.MethodGet, path: "/api/v3/openapi.json"},
		{method: http.MethodGet, path: "/api/ext/v1"},
		{method: http.MethodGet, path: "/api/ext/v1/openapi.json"},
		{method: http.MethodPost, path: "/api/ext/v1/agents"},
		{method: http.MethodPost, path: "/login/device/code"},
	} {
		request, err := http.NewRequest(route.method, server.URL+route.path, nil)
		require.NoError(t, err)
		response, err := server.Client().Do(request)
		require.NoError(t, err)
		response.Body.Close()
		require.Equal(t, http.StatusNotFound, response.StatusCode, route.path)
	}
	require.Zero(t, upstreamCalls.Load())
}

func (store *fakeWorkspaceResourceLimitStore) LoadWorkspaceResourceLimit(_ context.Context, workspaceID string, ownerUserID string, metric plan.MetricKey) (plan.WorkspaceResourceLimit, error) {
	store.called++
	store.workspace = workspaceID
	store.owner = ownerUserID
	if store.err != nil {
		return plan.WorkspaceResourceLimit{}, store.err
	}
	limit := store.limit
	limit.Metric = metric
	if limit.OrganizationID == "" {
		limit.OrganizationID = "org_1"
	}
	if limit.PlanKey == "" {
		limit.PlanKey = plan.PlanFree
	}
	if limit.PlanName == "" {
		limit.PlanName = "Free"
	}
	if limit.Scope == "" {
		limit.Scope = plan.QuotaScopeWorkspaceMember
	}
	return limit, nil
}

func TestForwarderForwardsNativePathWithoutRewriteAndInjectsTrustedJWT(t *testing.T) {
	gin.SetMode(gin.ReleaseMode)

	var seenPath string
	var seenQuery string
	var seenAuthorization string
	var seenTrustedJWT string
	upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		seenPath = r.URL.Path
		seenQuery = r.URL.RawQuery
		seenAuthorization = r.Header.Get("Authorization")
		seenTrustedJWT = r.Header.Get(trustedJWTHeader)
		w.Header().Set("Content-Type", "application/json")
		_, _ = w.Write([]byte(`{"ok":true}`))
	}))
	defer upstream.Close()

	router := gin.New()
	RegisterRoutes(router, Dependencies{
		AuthStore: fakeAuthStore{authenticated: identity.AuthenticatedUser{
			User:    model.User{ID: "usr_1", Email: "alice@example.com", Name: "alice", DisplayName: "Alice"},
			Session: model.UserSession{ID: "sess_1", UserID: "usr_1"},
		}},
		WorkspaceStore:     fakeWorkspaceStore{workspace: workspace.Summary{WorkspaceID: "wsp_1", WorkspaceRole: "member"}},
		BaseURL:            upstream.URL,
		TrustedJWTAudience: "meshx",
		Now:                func() time.Time { return time.Unix(1700000000, 0) },
	})

	server := httptest.NewServer(router)
	defer server.Close()

	request, err := http.NewRequest(http.MethodGet, server.URL+expectedWorkspacePath("/api/v1/mcp/items?limit=10"), nil)
	require.NoError(t, err)
	request.Header.Set("Authorization", "Bearer client-supplied-token")
	response, err := server.Client().Do(request)
	require.NoError(t, err)
	defer response.Body.Close()

	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, "/api/v1/mcp/items", seenPath)
	require.Equal(t, "limit=10", seenQuery)
	require.NotEqual(t, "Bearer client-supplied-token", seenAuthorization)
	require.True(t, strings.HasPrefix(seenAuthorization, "Bearer "))
	require.Equal(t, seenAuthorization, seenTrustedJWT)

	claims := decodeClaims(t, strings.TrimPrefix(seenAuthorization, "Bearer "))
	require.Equal(t, "agentteam", claims["iss"])
	require.Equal(t, "meshx", claims["aud"])
	require.Equal(t, "usr_1", claims["sub"])
	require.Equal(t, "alice@example.com", claims["email"])
	require.Equal(t, "alice", claims["preferred_username"])
}

func TestWorkspaceAGSSessionBridgeReauthenticatesAndInjectsTrustedIdentity(t *testing.T) {
	gin.SetMode(gin.ReleaseMode)
	sessionToken := base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{7}, 32))
	authStore := &recordingAuthStore{authenticated: identity.AuthenticatedUser{
		User:    model.User{ID: "usr_1", Email: "alice@example.com", Name: "alice", DisplayName: "Alice"},
		Session: model.UserSession{ID: "sess_1", UserID: "usr_1"},
	}}
	nextCalls := 0
	bridge := WorkspaceAGSSessionBridge(Dependencies{
		AuthStore:          authStore,
		WorkspaceStore:     fakeWorkspaceStore{workspace: workspace.Summary{WorkspaceID: "wsp_1", WorkspaceRole: "member"}},
		ManagedWorkspaces:  owner.NewStaticSet([]string{"wsp_1"}),
		TrustedJWTAudience: "meshx",
		Now:                func() time.Time { return time.Unix(1700000000, 0) },
	}, http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
		nextCalls++
		require.Equal(t, "wsp_1", request.Header.Get(api.HeaderWorkspaceID))
		require.Equal(t, request.Header.Get("Authorization"), request.Header.Get(trustedJWTHeader))
		claims := decodeClaims(t, strings.TrimPrefix(request.Header.Get(trustedJWTHeader), "Bearer "))
		require.Equal(t, "usr_1", claims["sub"])
		require.Equal(t, []any{"wsp_1"}, claims["workspace_ids"])
		writer.WriteHeader(http.StatusNoContent)
	}))

	request := httptest.NewRequest(http.MethodGet, "/api/v1/workspaces/wsp_1/ags/api/v3/user/repos", nil)
	request.Header.Set("Authorization", "Bearer "+sessionToken)
	recorder := httptest.NewRecorder()
	bridge.ServeHTTP(recorder, request)

	require.Equal(t, http.StatusNoContent, recorder.Code)
	require.Equal(t, sessionToken, authStore.token)
	require.Equal(t, 1, nextCalls)
}

func TestWorkspaceAGSSessionBridgeLeavesRepositoryCredentialsToAGS(t *testing.T) {
	authStore := &recordingAuthStore{}
	nextCalls := 0
	bridge := WorkspaceAGSSessionBridge(Dependencies{AuthStore: authStore}, http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
		nextCalls++
		require.Equal(t, "Bearer ags_rat_repo", request.Header.Get("Authorization"))
		require.Empty(t, request.Header.Get(trustedJWTHeader))
		writer.WriteHeader(http.StatusNoContent)
	}))
	request := httptest.NewRequest(http.MethodGet, "/api/v1/workspaces/wsp_1/ags/api/v3/repos/acme/docs", nil)
	request.Header.Set("Authorization", "Bearer ags_rat_repo")
	recorder := httptest.NewRecorder()

	bridge.ServeHTTP(recorder, request)

	require.Equal(t, http.StatusNoContent, recorder.Code)
	require.Empty(t, authStore.token)
	require.Equal(t, 1, nextCalls)
}

func TestWorkspaceAGSSessionBridgeRejectsInvalidSessionBeforeAGS(t *testing.T) {
	sessionToken := base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{8}, 32))
	authStore := &recordingAuthStore{err: errors.New("invalid session")}
	nextCalls := 0
	bridge := WorkspaceAGSSessionBridge(Dependencies{AuthStore: authStore}, http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
		nextCalls++
	}))
	request := httptest.NewRequest(http.MethodGet, "/api/v1/workspaces/wsp_1/ags/api/v3/user/repos", nil)
	request.Header.Set("Authorization", "Bearer "+sessionToken)
	recorder := httptest.NewRecorder()

	bridge.ServeHTTP(recorder, request)

	require.Equal(t, http.StatusUnauthorized, recorder.Code)
	require.Equal(t, sessionToken, authStore.token)
	require.Zero(t, nextCalls)
}

func TestForwarderWorkspaceRouteStripsOnlyPublicPrefix(t *testing.T) {
	gin.SetMode(gin.ReleaseMode)

	type observedRequest struct {
		path        string
		escapedPath string
		query       string
		body        string
		workspaceID string
	}
	observed := make(chan observedRequest, 1)
	upstream := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
		body, _ := io.ReadAll(request.Body)
		observed <- observedRequest{
			path:        request.URL.Path,
			escapedPath: request.URL.EscapedPath(),
			query:       request.URL.RawQuery,
			body:        string(body),
			workspaceID: request.Header.Get(api.HeaderWorkspaceID),
		}
		writer.WriteHeader(http.StatusNoContent)
	}))
	defer upstream.Close()

	router := gin.New()
	RegisterRoutes(router, Dependencies{
		AuthStore:      fakeAuthStore{authenticated: identity.AuthenticatedUser{User: model.User{ID: "usr_1"}}},
		WorkspaceStore: fakeWorkspaceStore{workspace: workspace.Summary{WorkspaceID: "wsp_1", WorkspaceRole: "member"}},
		BaseURL:        upstream.URL,
	})
	server := httptest.NewServer(router)
	defer server.Close()

	request, err := http.NewRequest(
		http.MethodPost,
		server.URL+"/api/v1/workspaces/wsp_1/kb/sources/source%2Fbranch?limit=10",
		strings.NewReader(`{"name":"source"}`),
	)
	require.NoError(t, err)
	request.Header.Set("Authorization", "Bearer agentteam-session")
	request.Header.Set(api.HeaderRouteWorkspaceID, "wsp_1")
	request.Header.Set(api.HeaderWorkspaceID, "wsp_1")
	response, err := server.Client().Do(request)
	require.NoError(t, err)
	response.Body.Close()
	require.Equal(t, http.StatusNoContent, response.StatusCode)

	seen := <-observed
	require.Equal(t, "/api/v1/kb/sources/source/branch", seen.path)
	require.Equal(t, "/api/v1/kb/sources/source%2Fbranch", seen.escapedPath)
	require.Equal(t, "limit=10", seen.query)
	require.JSONEq(t, `{"name":"source"}`, seen.body)
	require.Equal(t, "wsp_1", seen.workspaceID)

	for _, legacyPath := range []string{"/api/v1/kb", "/api/v1/skills"} {
		legacyRequest, err := http.NewRequest(http.MethodGet, server.URL+legacyPath, nil)
		require.NoError(t, err)
		legacyRequest.Header.Set("Authorization", "Bearer agentteam-session")
		legacyRequest.Header.Set(api.HeaderRouteWorkspaceID, "wsp_1")
		legacyResponse, err := server.Client().Do(legacyRequest)
		require.NoError(t, err)
		legacyResponse.Body.Close()
		require.Equal(t, http.StatusNotFound, legacyResponse.StatusCode, legacyPath)
	}
}

func TestForwarderForwardsAGSExtensionAPIWithTrustedIdentity(t *testing.T) {
	gin.SetMode(gin.ReleaseMode)

	var seenPath string
	var seenAuthorization string
	upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		seenPath = r.URL.Path
		seenAuthorization = r.Header.Get("Authorization")
		w.WriteHeader(http.StatusCreated)
	}))
	defer upstream.Close()

	router := gin.New()
	RegisterRoutes(router, Dependencies{
		AuthStore: fakeAuthStore{authenticated: identity.AuthenticatedUser{
			User:    model.User{ID: "usr_1", Email: "alice@example.com", Name: "alice"},
			Session: model.UserSession{ID: "sess_1", UserID: "usr_1"},
		}},
		BaseURL:            upstream.URL,
		TrustedJWTAudience: "meshx",
	})
	server := httptest.NewServer(router)
	defer server.Close()

	request, err := http.NewRequest(http.MethodPost, server.URL+"/api/ext/v1/user/orgs", bytes.NewBufferString(`{"login":"agentteam"}`))
	require.NoError(t, err)
	request.Header.Set("Authorization", "Bearer client-supplied-token")
	response, err := server.Client().Do(request)
	require.NoError(t, err)
	defer response.Body.Close()

	require.Equal(t, http.StatusCreated, response.StatusCode)
	require.Equal(t, "/api/ext/v1/user/orgs", seenPath)
	require.NotEqual(t, "Bearer client-supplied-token", seenAuthorization)
	require.True(t, strings.HasPrefix(seenAuthorization, "Bearer "))
}

func TestForwarderForwardJSONInjectsTrustedJWTAndWorkspace(t *testing.T) {
	var seenPath string
	var seenBody string
	var seenAuthorization string
	var seenTrustedJWT string
	var seenWorkspace string
	var seenAttestation string
	upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		body, _ := io.ReadAll(r.Body)
		seenPath = r.URL.Path
		seenBody = string(body)
		seenAuthorization = r.Header.Get("Authorization")
		seenTrustedJWT = r.Header.Get(trustedJWTHeader)
		seenWorkspace = r.Header.Get(api.HeaderWorkspaceID)
		seenAttestation = r.Header.Get("X-AgentTeam-Agent-Thread-Attestation")
		w.Header().Set("Content-Type", "application/json")
		_, _ = w.Write([]byte(`{"ok":true}`))
	}))
	defer upstream.Close()

	handler, err := NewHandler(Dependencies{
		BaseURL:            upstream.URL,
		HTTPClient:         upstream.Client(),
		TrustedJWTAudience: "meshx",
		Now:                func() time.Time { return time.Unix(1700000000, 0) },
	})
	require.NoError(t, err)

	var response map[string]bool
	err = handler.ForwardJSON(context.Background(), JSONRequest{
		Path:                      "/api/v1/kb/retrieval/knowledge/search",
		WorkspaceID:               "ws_1",
		AgentOwnershipWorkspaceID: "ws_1",
		OwnedAgents: []TrustedOwnedAgent{
			{ID: "agt_source", Name: "source", DisplayName: "Source"},
			{ID: "agt_target", Name: "target", DisplayName: "Target"},
			{ID: "agt_source", Name: "duplicate"},
		},
		Identity: TrustedIdentity{
			UserID:      "usr_1",
			Email:       "jiachen@example.com",
			Name:        "Jiachen Bai",
			DisplayName: "Jiachen Bai",
		},
		Body:    map[string]any{"query": "index"},
		Headers: map[string]string{"X-AgentTeam-Agent-Thread-Attestation": "attested"},
	}, &response)
	require.NoError(t, err)

	require.Equal(t, map[string]bool{"ok": true}, response)
	require.Equal(t, "/api/v1/kb/retrieval/knowledge/search", seenPath)
	require.JSONEq(t, `{"query":"index"}`, seenBody)
	require.Equal(t, "ws_1", seenWorkspace)
	require.Equal(t, "attested", seenAttestation)
	require.True(t, strings.HasPrefix(seenAuthorization, "Bearer "))
	require.Equal(t, seenAuthorization, seenTrustedJWT)

	claims := decodeClaims(t, strings.TrimPrefix(seenAuthorization, "Bearer "))
	require.Equal(t, "agentteam", claims["iss"])
	require.Equal(t, "meshx", claims["aud"])
	require.Equal(t, "usr_1", claims["sub"])
	require.Equal(t, "usr_1", claims["agentteam_user_id"])
	require.Equal(t, "jiachen@example.com", claims["email"])
	require.Equal(t, "jiachen-bai", claims["preferred_username"])
	require.Equal(t, "ws_1", claims["agent_ownership_workspace_id"])
	require.Equal(t, []any{"agt_source", "agt_target"}, claims["owned_agent_ids"])
	require.Equal(t, []any{
		map[string]any{"id": "agt_source", "name": "source", "display_name": "Source"},
		map[string]any{"id": "agt_target", "name": "target", "display_name": "Target"},
	}, claims["owned_agents"])
}

func TestForwarderForwardsMeshXDomainAPIsWithoutRewrite(t *testing.T) {
	gin.SetMode(gin.ReleaseMode)

	var seenPaths []string
	var seenWorkspaceIDs []string
	var seenAuthorizations []string
	upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		seenPaths = append(seenPaths, r.URL.Path)
		seenWorkspaceIDs = append(seenWorkspaceIDs, r.Header.Get(api.HeaderWorkspaceID))
		seenAuthorizations = append(seenAuthorizations, r.Header.Get("Authorization"))
		w.WriteHeader(http.StatusNoContent)
	}))
	defer upstream.Close()

	router := gin.New()
	RegisterRoutes(router, Dependencies{
		AuthStore:      fakeAuthStore{authenticated: identity.AuthenticatedUser{User: model.User{ID: "usr_1"}}},
		WorkspaceStore: fakeWorkspaceStore{workspace: workspace.Summary{WorkspaceID: "wsp_1", WorkspaceRole: "member"}},
		BaseURL:        upstream.URL,
		MeshXEnabled:   true,
	})
	server := httptest.NewServer(router)
	defer server.Close()

	requests := []struct {
		method string
		path   string
	}{
		{method: http.MethodGet, path: "/api/v1/repository-owners"},
		{method: http.MethodGet, path: "/api/v1/repository-names/available?resourceType=memory&name=Runbooks&storageScope=personal"},
		{method: http.MethodPost, path: "/api/v1/repositories/pingcap/agentteam/code-wiki/generate"},
		{method: http.MethodGet, path: "/api/v1/repositories/code-wiki/jobs/job_1"},
		{method: http.MethodGet, path: "/api/v1/roles/capabilities"},
		{method: http.MethodGet, path: "/api/v1/roles/presets?locale=zh-CN"},
		{method: http.MethodGet, path: "/api/v1/automations/runs"},
		{method: http.MethodGet, path: "/api/v1/automations/runs/wfr_1/events"},
	}
	for _, forwarded := range requests {
		request, err := http.NewRequest(forwarded.method, server.URL+expectedWorkspacePath(forwarded.path), nil)
		require.NoError(t, err)
		request.Header.Set("Authorization", "Bearer agentteam-session")
		request.Header.Set(api.HeaderWorkspaceID, "wsp_1")
		response, err := server.Client().Do(request)
		require.NoError(t, err, forwarded.path)
		response.Body.Close()
		require.Equal(t, http.StatusNoContent, response.StatusCode, forwarded.path)
	}

	require.Equal(t, []string{
		"/api/v1/repository-owners",
		"/api/v1/repository-names/available",
		"/api/v1/repositories/pingcap/agentteam/code-wiki/generate",
		"/api/v1/repositories/code-wiki/jobs/job_1",
		"/api/v1/roles/capabilities",
		"/api/v1/roles/presets",
		"/api/v1/automations/runs",
		"/api/v1/automations/runs/wfr_1/events",
	}, seenPaths)
	require.Equal(t, []string{"wsp_1", "wsp_1", "wsp_1", "wsp_1", "wsp_1", "wsp_1", "wsp_1", "wsp_1"}, seenWorkspaceIDs)
	for index, authorization := range seenAuthorizations {
		claims := decodeClaims(t, strings.TrimPrefix(authorization, "Bearer "))
		if strings.HasPrefix(seenPaths[index], "/api/v1/automations/") {
			require.ElementsMatch(t, []any{"wsp_1"}, claims["workspace_ids"])
			require.ElementsMatch(t, []any{"automation:repo:read", "automation:run:read", "automation:run:start", "automation:wait:resume"}, claims["automation_permissions"])
			continue
		}
		if seenPaths[index] == "/api/v1/repository-owners" || seenPaths[index] == "/api/v1/repository-names/available" {
			require.ElementsMatch(t, []any{"wsp_1"}, claims["workspace_ids"])
			continue
		}
		if strings.HasPrefix(seenPaths[index], "/api/v1/roles") {
			require.ElementsMatch(t, []any{"wsp_1"}, claims["workspace_ids"])
			continue
		}
		require.ElementsMatch(t, []any{"wsp_1"}, claims["workspace_ids"])
		require.NotContains(t, claims, "automation_permissions")
	}
}

func TestForwarderForwardsRoleRoutesToMeshXWithWorkspaceAndOwnedAgents(t *testing.T) {
	gin.SetMode(gin.ReleaseMode)

	var seenPaths []string
	var seenTrustedJWTs []string
	upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		seenPaths = append(seenPaths, r.URL.Path)
		seenTrustedJWTs = append(seenTrustedJWTs, strings.TrimPrefix(r.Header.Get(trustedJWTHeader), "Bearer "))
		w.Header().Set("Content-Type", "application/json")
		_, _ = io.WriteString(w, `{"ok":true}`)
	}))
	defer upstream.Close()

	router := gin.New()
	RegisterRoutes(router, Dependencies{
		AuthStore:      fakeAuthStore{authenticated: identity.AuthenticatedUser{User: model.User{ID: "usr_1"}}},
		WorkspaceStore: fakeWorkspaceStore{workspace: workspace.Summary{WorkspaceID: "wsp_1", WorkspaceRole: "member"}},
		AgentStore: fakeOwnedAgentStore{agents: []agent.AppAgent{
			{ID: "agt_alice", Name: "alice", DisplayName: "Alice Agent"},
		}},
		BaseURL:      upstream.URL,
		MeshXEnabled: true,
	})
	server := httptest.NewServer(router)
	defer server.Close()

	for _, forwarded := range []struct {
		method string
		path   string
	}{
		{method: http.MethodGet, path: "/api/v1/roles"},
		{method: http.MethodPost, path: "/api/v1/roles/presets:realize"},
		{method: http.MethodPost, path: "/api/v1/roles:from-agent"},
		{method: http.MethodPost, path: "/api/v1/roles/role_1/realize"},
		{method: http.MethodPost, path: "/api/v1/roles/role_1/from-agent"},
	} {
		request, err := http.NewRequest(forwarded.method, server.URL+expectedWorkspacePath(forwarded.path), nil)
		require.NoError(t, err)
		request.Header.Set("Authorization", "Bearer agentteam-session")
		request.Header.Set(api.HeaderWorkspaceID, "wsp_1")
		response, err := server.Client().Do(request)
		require.NoError(t, err, forwarded.path)
		response.Body.Close()
		require.Equal(t, http.StatusOK, response.StatusCode, forwarded.path)
	}

	require.Equal(t, []string{
		"/api/v1/roles",
		"/api/v1/roles/presets:realize",
		"/api/v1/roles:from-agent",
		"/api/v1/roles/role_1/realize",
		"/api/v1/roles/role_1/from-agent",
	}, seenPaths)
	require.NotEmpty(t, seenTrustedJWTs)
	for index, token := range seenTrustedJWTs {
		claims := decodeClaims(t, token)
		require.Equal(t, []any{"wsp_1"}, claims["workspace_ids"], seenPaths[index])
		require.Equal(t, "wsp_1", claims["agent_ownership_workspace_id"], seenPaths[index])
		require.Equal(t, []any{"agt_alice"}, claims["owned_agent_ids"], seenPaths[index])
		require.Equal(t, []any{map[string]any{"id": "agt_alice", "name": "alice", "display_name": "Alice Agent"}}, claims["owned_agents"], seenPaths[index])
	}
}

func TestRepositoryAccessTokenForwarderRequiresMeshXGateAndWorkspaceManage(t *testing.T) {
	gin.SetMode(gin.ReleaseMode)

	newServer := func(meshXEnabled bool, role string) (*httptest.Server, func() int) {
		var upstreamHits int
		upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			upstreamHits++
			w.WriteHeader(http.StatusNoContent)
		}))
		router := gin.New()
		RegisterRoutes(router, Dependencies{
			AuthStore:      fakeAuthStore{authenticated: identity.AuthenticatedUser{User: model.User{ID: "usr_1"}}},
			WorkspaceStore: fakeWorkspaceStore{workspace: workspace.Summary{WorkspaceID: "wsp_1", WorkspaceRole: role}},
			BaseURL:        upstream.URL,
			MeshXEnabled:   meshXEnabled,
		})
		server := httptest.NewServer(router)
		return server, func() int {
			server.Close()
			upstream.Close()
			return upstreamHits
		}
	}

	for _, tt := range []struct {
		name        string
		meshX       bool
		role        string
		wantStatus  int
		wantCode    string
		wantForward bool
	}{
		{name: "meshx disabled", meshX: false, role: "admin", wantStatus: http.StatusForbidden, wantCode: "meshx_gate_required"},
		{name: "member denied", meshX: true, role: "member", wantStatus: http.StatusForbidden, wantCode: "permission_denied"},
		{name: "admin forwarded", meshX: true, role: "admin", wantStatus: http.StatusNoContent, wantForward: true},
		{name: "admin internal lifecycle forwarded", meshX: true, role: "admin", wantStatus: http.StatusNoContent, wantForward: true},
	} {
		t.Run(tt.name, func(t *testing.T) {
			server, cleanup := newServer(tt.meshX, tt.role)
			defer func() {
				hits := cleanup()
				if tt.wantForward {
					require.Equal(t, 1, hits)
				} else {
					require.Zero(t, hits)
				}
			}()
			path := "/api/v1/repository-access-tokens"
			if tt.name == "admin internal lifecycle forwarded" {
				path = "/api/v1/internal/repository-access-tokens/agents/agt_1/reconcile"
			}
			request, err := http.NewRequest(http.MethodGet, server.URL+expectedWorkspacePath(path), nil)
			require.NoError(t, err)
			request.Header.Set("Authorization", "Bearer agentteam-session")
			request.Header.Set(api.HeaderWorkspaceID, "wsp_1")
			response, err := server.Client().Do(request)
			require.NoError(t, err)
			defer response.Body.Close()
			body, _ := io.ReadAll(response.Body)
			require.Equal(t, tt.wantStatus, response.StatusCode, string(body))
			if tt.wantCode != "" {
				require.Contains(t, string(body), `"code":"`+tt.wantCode+`"`)
			}
		})
	}
}

func TestPersonalAccessTokenForwarderRequiresMeshXGateButAllowsWorkspaceMembers(t *testing.T) {
	gin.SetMode(gin.ReleaseMode)

	type seenRequest struct {
		Path        string
		Query       string
		WorkspaceID string
		TrustedJWT  string
	}
	newServer := func(meshXEnabled bool, role string) (*httptest.Server, func() []seenRequest) {
		var seen []seenRequest
		upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			seen = append(seen, seenRequest{
				Path:        r.URL.Path,
				Query:       r.URL.RawQuery,
				WorkspaceID: r.Header.Get(api.HeaderWorkspaceID),
				TrustedJWT:  strings.TrimPrefix(r.Header.Get(trustedJWTHeader), "Bearer "),
			})
			w.WriteHeader(http.StatusNoContent)
		}))
		router := gin.New()
		RegisterRoutes(router, Dependencies{
			AuthStore:      fakeAuthStore{authenticated: identity.AuthenticatedUser{User: model.User{ID: "usr_1"}}},
			WorkspaceStore: fakeWorkspaceStore{workspace: workspace.Summary{WorkspaceID: "wsp_1", WorkspaceRole: role}},
			BaseURL:        upstream.URL,
			MeshXEnabled:   meshXEnabled,
		})
		server := httptest.NewServer(router)
		return server, func() []seenRequest {
			server.Close()
			upstream.Close()
			return seen
		}
	}

	for _, tt := range []struct {
		name        string
		meshX       bool
		role        string
		method      string
		path        string
		wantStatus  int
		wantCode    string
		wantPath    string
		wantQuery   string
		wantForward bool
	}{
		{
			name:       "meshx disabled",
			meshX:      false,
			role:       "member",
			method:     http.MethodGet,
			path:       "/api/v1/personal-access-tokens",
			wantStatus: http.StatusForbidden,
			wantCode:   "meshx_gate_required",
		},
		{
			name:        "member list forwarded",
			meshX:       true,
			role:        "member",
			method:      http.MethodGet,
			path:        "/api/v1/personal-access-tokens?usage=kb_mirror",
			wantStatus:  http.StatusNoContent,
			wantPath:    "/api/v1/personal-access-tokens",
			wantQuery:   "usage=kb_mirror",
			wantForward: true,
		},
		{
			name:        "member revoke forwarded",
			meshX:       true,
			role:        "member",
			method:      http.MethodPost,
			path:        "/api/v1/personal-access-tokens/pat_1/revoke",
			wantStatus:  http.StatusNoContent,
			wantPath:    "/api/v1/personal-access-tokens/pat_1/revoke",
			wantForward: true,
		},
	} {
		t.Run(tt.name, func(t *testing.T) {
			server, cleanup := newServer(tt.meshX, tt.role)
			defer func() {
				seen := cleanup()
				if !tt.wantForward {
					require.Empty(t, seen)
					return
				}
				require.Len(t, seen, 1)
				require.Equal(t, tt.wantPath, seen[0].Path)
				require.Equal(t, tt.wantQuery, seen[0].Query)
				require.Equal(t, "wsp_1", seen[0].WorkspaceID)
				claims := decodeClaims(t, seen[0].TrustedJWT)
				require.Equal(t, []any{"wsp_1"}, claims["workspace_ids"])
			}()

			request, err := http.NewRequest(tt.method, server.URL+expectedWorkspacePath(tt.path), nil)
			require.NoError(t, err)
			request.Header.Set("Authorization", "Bearer agentteam-session")
			request.Header.Set(api.HeaderWorkspaceID, "wsp_1")
			response, err := server.Client().Do(request)
			require.NoError(t, err)
			defer response.Body.Close()
			body, _ := io.ReadAll(response.Body)
			require.Equal(t, tt.wantStatus, response.StatusCode, string(body))
			if tt.wantCode != "" {
				require.Contains(t, string(body), `"code":"`+tt.wantCode+`"`)
			}
		})
	}
}

func TestForwarderHandlesAgentTeamOwnedAgentThreadSessionChannel(t *testing.T) {
	gin.SetMode(gin.ReleaseMode)

	var upstreamHits int
	upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		upstreamHits++
		w.WriteHeader(http.StatusTeapot)
	}))
	defer upstream.Close()

	sessionStore := &fakeAgentThreadSessionChannelStore{}
	router := gin.New()
	RegisterRoutes(router, Dependencies{
		AuthStore:           fakeAuthStore{authenticated: identity.AuthenticatedUser{User: model.User{ID: "usr_1"}}},
		WorkspaceStore:      fakeWorkspaceStore{workspace: workspace.Summary{WorkspaceID: "wsp_1", WorkspaceRole: "member"}},
		BaseURL:             upstream.URL,
		AgentThreadSessions: sessionStore,
	})
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()

	channelRequest, err := http.NewRequest(http.MethodGet, server.URL+expectedWorkspacePath("/api/v1/agent-thread/session-channel"), nil)
	require.NoError(t, err)
	channelRequest.Header.Set("Authorization", "Bearer agentteam-session")
	channelResponse, err := client.Do(channelRequest)
	require.NoError(t, err)
	defer channelResponse.Body.Close()
	require.Equal(t, http.StatusOK, channelResponse.StatusCode)
	var channel map[string]any
	require.NoError(t, json.NewDecoder(channelResponse.Body).Decode(&channel))
	require.True(t, sessionStore.ensured)
	require.Equal(t, "chn_agent_session", channel["channelId"])
	require.Equal(t, 0, upstreamHits)

	legacyRequest, err := http.NewRequest(http.MethodPost, server.URL+expectedWorkspacePath("/api/v1/kb/"+"retrieval-"+"sessions"), strings.NewReader(`{"query":"what changed?"}`))
	require.NoError(t, err)
	legacyRequest.Header.Set("Authorization", "Bearer agentteam-session")
	legacyRequest.Header.Set(api.HeaderWorkspaceID, "wsp_1")
	legacyResponse, err := client.Do(legacyRequest)
	require.NoError(t, err)
	defer legacyResponse.Body.Close()
	require.Equal(t, http.StatusTeapot, legacyResponse.StatusCode)
	require.Equal(t, 1, upstreamHits)
}

func TestForwarderProxiesKnowledgeBaseGatewayAndLegacyChannelRoutes(t *testing.T) {
	gin.SetMode(gin.ReleaseMode)

	type seenRequest struct {
		Method      string
		Path        string
		Query       string
		WorkspaceID string
		TrustedJWT  string
		Body        string
	}
	var seen []seenRequest
	upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		body, _ := io.ReadAll(r.Body)
		seen = append(seen, seenRequest{
			Method:      r.Method,
			Path:        r.URL.Path,
			Query:       r.URL.RawQuery,
			WorkspaceID: r.Header.Get(api.HeaderWorkspaceID),
			TrustedJWT:  strings.TrimPrefix(r.Header.Get(trustedJWTHeader), "Bearer "),
			Body:        string(body),
		})
		w.Header().Set("Content-Type", "application/json")
		_, _ = w.Write([]byte(`{"ok":true}`))
	}))
	defer upstream.Close()

	router := gin.New()
	RegisterRoutes(router, Dependencies{
		AuthStore:      fakeAuthStore{authenticated: identity.AuthenticatedUser{User: model.User{ID: "usr_1"}}},
		WorkspaceStore: fakeWorkspaceStore{workspace: workspace.Summary{WorkspaceID: "wsp_1", WorkspaceRole: "member"}},
		AgentStore: fakeOwnedAgentStore{agents: []agent.AppAgent{
			{ID: "agt_alice", Name: "alice", DisplayName: "Alice Agent"},
		}},
		BaseURL: upstream.URL,
	})
	server := httptest.NewServer(router)
	defer server.Close()

	tests := []struct {
		method     string
		path       string
		body       string
		wantMethod string
		wantPath   string
		wantQuery  string
		wantBody   string
	}{
		{method: http.MethodGet, path: "/api/v1/kb", wantMethod: http.MethodGet, wantPath: "/api/v1/kb"},
		{method: http.MethodGet, path: "/api/v1/kb/kb_1/sources/src_1/tree", wantMethod: http.MethodGet, wantPath: "/api/v1/kb/kb_1/sources/src_1/tree"},
		{method: http.MethodGet, path: "/api/v1/kb-publication-reviews/appr_1", wantMethod: http.MethodGet, wantPath: "/api/v1/kb-publication-reviews/appr_1"},
		{method: http.MethodPost, path: "/api/v1/kb-publication-reviews/appr_1/decision", body: `{"expectedVersion":1,"decision":"approved"}`, wantMethod: http.MethodPost, wantPath: "/api/v1/kb-publication-reviews/appr_1/decision", wantBody: `{"expectedVersion":1,"decision":"approved"}`},
		{method: http.MethodGet, path: "/api/v1/kb/agents/agt_alice/accessible", wantMethod: http.MethodGet, wantPath: "/api/v1/kb/agents/agt_alice/accessible"},
	}

	client := server.Client()
	for _, tt := range tests {
		request, err := http.NewRequest(tt.method, server.URL+expectedWorkspacePath(tt.path), strings.NewReader(tt.body))
		require.NoError(t, err)
		request.Header.Set("Authorization", "Bearer agentteam-session")
		request.Header.Set(api.HeaderWorkspaceID, "wsp_1")
		if tt.body != "" {
			request.Header.Set("Content-Type", "application/json")
		}
		response, err := client.Do(request)
		require.NoError(t, err, tt.path)
		_ = response.Body.Close()
		require.Equal(t, http.StatusOK, response.StatusCode, tt.path)
	}

	require.Len(t, seen, len(tests))
	for i, tt := range tests {
		require.Equal(t, tt.wantMethod, seen[i].Method, tt.path)
		require.Equal(t, tt.wantPath, seen[i].Path, tt.path)
		require.Equal(t, tt.wantQuery, seen[i].Query, tt.path)
		require.Equal(t, "wsp_1", seen[i].WorkspaceID, tt.path)
		claims := decodeClaims(t, seen[i].TrustedJWT)
		require.Equal(t, []any{"wsp_1"}, claims["workspace_ids"], tt.path)
		if strings.HasPrefix(tt.path, "/api/v1/kb/agents/") {
			require.Equal(t, "wsp_1", claims["agent_ownership_workspace_id"], tt.path)
			require.Equal(t, []any{"agt_alice"}, claims["owned_agent_ids"], tt.path)
		} else {
			require.NotContains(t, claims, "agent_ownership_workspace_id", tt.path)
			require.NotContains(t, claims, "owned_agent_ids", tt.path)
		}
		require.Equal(t, tt.wantBody, seen[i].Body, tt.path)
	}
}

func TestForwarderKnowledgeBaseRoutesUsePathWorkspace(t *testing.T) {
	gin.SetMode(gin.ReleaseMode)

	upstreamHits := 0
	upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		upstreamHits++
		w.WriteHeader(http.StatusOK)
	}))
	defer upstream.Close()

	router := gin.New()
	RegisterRoutes(router, Dependencies{
		AuthStore:      fakeAuthStore{authenticated: identity.AuthenticatedUser{User: model.User{ID: "usr_1"}}},
		WorkspaceStore: fakeWorkspaceStore{workspace: workspace.Summary{WorkspaceID: "wsp_1", WorkspaceRole: "member"}},
		BaseURL:        upstream.URL,
	})
	server := httptest.NewServer(router)
	defer server.Close()

	request, err := http.NewRequest(http.MethodGet, server.URL+expectedWorkspacePath("/api/v1/kb"), nil)
	require.NoError(t, err)
	request.Header.Set("Authorization", "Bearer agentteam-session")
	response, err := server.Client().Do(request)
	require.NoError(t, err)
	defer response.Body.Close()

	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, 1, upstreamHits)
}

func TestForwarderForwardsAGSPathsAndSmartHTTP(t *testing.T) {
	gin.SetMode(gin.ReleaseMode)

	var paths []string
	upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		paths = append(paths, r.URL.Path)
		w.WriteHeader(http.StatusNoContent)
	}))
	defer upstream.Close()

	router := gin.New()
	deps := Dependencies{
		AuthStore: fakeAuthStore{authenticated: identity.AuthenticatedUser{User: model.User{ID: "usr_1"}}},
		BaseURL:   upstream.URL,
	}
	RegisterRoutes(router, deps)
	router.NoRoute(SmartHTTPNoRouteHandler(deps, func(ctx *gin.Context) {
		ctx.JSON(http.StatusNotFound, gin.H{"error": "not found"})
	}))

	server := httptest.NewServer(router)
	defer server.Close()

	for _, path := range []string{"/api/v3/repos/acme/repo", "/api/graphql", "/acme/repo.git/info/refs"} {
		request, err := http.NewRequest(http.MethodGet, server.URL+path, nil)
		require.NoError(t, err)
		request.Header.Set("Authorization", "Bearer agentteam-session")
		response, err := server.Client().Do(request)
		require.NoError(t, err, path)
		response.Body.Close()
		require.Equal(t, http.StatusNoContent, response.StatusCode, path)
	}

	require.Equal(t, []string{"/api/v3/repos/acme/repo", "/api/graphql", "/acme/repo.git/info/refs"}, paths)
}

func TestForwarderPassesRepositoryCredentialsThroughForSmartHTTP(t *testing.T) {
	gin.SetMode(gin.ReleaseMode)

	var seenAuthorization string
	var seenTrustedJWT string
	upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		seenAuthorization = r.Header.Get("Authorization")
		seenTrustedJWT = r.Header.Get(trustedJWTHeader)
		w.WriteHeader(http.StatusNoContent)
	}))
	defer upstream.Close()

	authStore := &recordingAuthStore{user: model.User{
		ID:           "usr_1",
		Email:        "alice@example.com",
		Name:         "alice",
		PasswordHash: "unused",
	}}
	deps := Dependencies{
		AuthStore:          authStore,
		BaseURL:            upstream.URL,
		TrustedJWTAudience: "meshx",
		Now:                func() time.Time { return time.Unix(1700000000, 0) },
	}
	router := gin.New()
	router.NoRoute(SmartHTTPNoRouteHandler(deps, func(ctx *gin.Context) {
		ctx.JSON(http.StatusNotFound, gin.H{"error": "not found"})
	}))

	server := httptest.NewServer(router)
	defer server.Close()

	request, err := http.NewRequest(http.MethodGet, server.URL+"/acme/repo.git/info/refs?service=git-upload-pack", nil)
	require.NoError(t, err)
	request.SetBasicAuth("git", "ags_rat_secret")
	response, err := server.Client().Do(request)
	require.NoError(t, err)
	response.Body.Close()

	require.Equal(t, http.StatusNoContent, response.StatusCode)
	require.Empty(t, authStore.token)
	require.Empty(t, authStore.identifier)
	require.Equal(t, "Basic "+base64.StdEncoding.EncodeToString([]byte("git:ags_rat_secret")), seenAuthorization)
	require.Empty(t, seenTrustedJWT)
}

func TestForwarderPassesRepositoryCredentialsThroughForAGSRepoREST(t *testing.T) {
	gin.SetMode(gin.ReleaseMode)

	var seenAuthorization string
	var seenTrustedJWT string
	upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		seenAuthorization = r.Header.Get("Authorization")
		seenTrustedJWT = r.Header.Get(trustedJWTHeader)
		w.WriteHeader(http.StatusNoContent)
	}))
	defer upstream.Close()

	authStore := &recordingAuthStore{authenticated: identity.AuthenticatedUser{User: model.User{ID: "usr_1"}}}
	deps := Dependencies{
		AuthStore: authStore,
		BaseURL:   upstream.URL,
	}
	router := gin.New()
	RegisterRoutes(router, deps)

	server := httptest.NewServer(router)
	defer server.Close()

	for _, test := range []struct {
		path          string
		authorization string
	}{
		{path: "/api/v3/repos/acme/repo/contents/README.md", authorization: "Basic " + base64.StdEncoding.EncodeToString([]byte("git:ags_rat_secret"))},
		{path: "/api/v3/repos/acme/repo/contents/README.md", authorization: "token existing-ags-token"},
		{path: "/api/v3/repos/acme/repo/contents/README.md", authorization: "Bearer ags_rat_secret"},
		{path: "/api/ext/v1/repos/acme/repo/contents/README.md", authorization: "token existing-ags-token"},
	} {
		seenAuthorization = ""
		seenTrustedJWT = ""
		request, err := http.NewRequest(http.MethodGet, server.URL+test.path, nil)
		require.NoError(t, err)
		request.Header.Set("Authorization", test.authorization)
		response, err := server.Client().Do(request)
		require.NoError(t, err)
		response.Body.Close()

		require.Equal(t, http.StatusNoContent, response.StatusCode, test)
		require.Empty(t, authStore.token, test)
		require.Empty(t, authStore.identifier, test)
		require.Equal(t, test.authorization, seenAuthorization)
		require.Empty(t, seenTrustedJWT, test)
	}
}

func TestForwarderPassesGenericAGSCredentialThroughForExtensionAndGraphQL(t *testing.T) {
	gin.SetMode(gin.ReleaseMode)

	var seen []struct {
		path          string
		authorization string
		trustedJWT    string
	}
	upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		seen = append(seen, struct {
			path          string
			authorization string
			trustedJWT    string
		}{r.URL.Path, r.Header.Get("Authorization"), r.Header.Get(trustedJWTHeader)})
		w.WriteHeader(http.StatusNoContent)
	}))
	defer upstream.Close()

	authStore := &recordingAuthStore{err: identity.ErrUnauthorized}
	router := gin.New()
	RegisterRoutes(router, Dependencies{AuthStore: authStore, BaseURL: upstream.URL})
	server := httptest.NewServer(router)
	defer server.Close()

	for _, request := range []struct {
		path          string
		authorization string
	}{
		{path: "/api/ext/v1/agents", authorization: "Bearer ordinary-ags-token"},
		{path: "/api/graphql", authorization: "Bearer ordinary-ags-token"},
		{path: "/api/ext/v1/user", authorization: "token ordinary-ags-token"},
	} {
		req, err := http.NewRequest(http.MethodGet, server.URL+request.path, nil)
		require.NoError(t, err)
		req.Header.Set("Authorization", request.authorization)
		response, err := server.Client().Do(req)
		require.NoError(t, err)
		response.Body.Close()
		require.Equal(t, http.StatusNoContent, response.StatusCode)
	}

	require.Len(t, seen, 3)
	for index, request := range seen {
		require.Equal(t, []string{"/api/ext/v1/agents", "/api/graphql", "/api/ext/v1/user"}[index], request.path)
		require.Equal(t, []string{"Bearer ordinary-ags-token", "Bearer ordinary-ags-token", "token ordinary-ags-token"}[index], request.authorization)
		require.Empty(t, request.trustedJWT)
	}
}

func TestForwarderForwardsAnonymousSmartHTTPToAGS(t *testing.T) {
	gin.SetMode(gin.ReleaseMode)

	var upstreamHits int
	upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		upstreamHits++
		w.WriteHeader(http.StatusNoContent)
	}))
	defer upstream.Close()

	deps := Dependencies{
		AuthStore: &recordingAuthStore{},
		BaseURL:   upstream.URL,
	}
	router := gin.New()
	router.NoRoute(SmartHTTPNoRouteHandler(deps, func(ctx *gin.Context) {
		ctx.JSON(http.StatusNotFound, gin.H{"error": "not found"})
	}))

	server := httptest.NewServer(router)
	defer server.Close()

	request, err := http.NewRequest(http.MethodGet, server.URL+"/acme/repo.git/info/refs?service=git-upload-pack", nil)
	require.NoError(t, err)
	response, err := server.Client().Do(request)
	require.NoError(t, err)
	response.Body.Close()

	require.Equal(t, http.StatusNoContent, response.StatusCode)
	require.Equal(t, 1, upstreamHits)
}

func TestForwarderDirectorPreservesEscapedSlashInPath(t *testing.T) {
	handler := &Handler{
		baseURL: &url.URL{Scheme: "http", Host: "meshx.local"},
	}
	request := &http.Request{
		Host:       "agentteam.local",
		Header:     make(http.Header),
		RemoteAddr: "127.0.0.1:1234",
		URL: &url.URL{
			Scheme:  "http",
			Host:    "agentteam.local",
			Path:    "/api/v3/repos/s-workspace/anna2/wiki/pages/analysis/characters",
			RawPath: "/api/v3/repos/s-workspace/anna2/wiki/pages/analysis%2Fcharacters",
		},
	}

	handler.direct(request)

	require.Equal(t, "/api/v3/repos/s-workspace/anna2/wiki/pages/analysis/characters", request.URL.Path)
	require.Equal(t, "/api/v3/repos/s-workspace/anna2/wiki/pages/analysis%2Fcharacters", request.URL.EscapedPath())
}

func TestForwarderServesRepositoryRawContentThroughContentsAPI(t *testing.T) {
	gin.SetMode(gin.ReleaseMode)

	var seenPath string
	var seenQuery string
	var seenAuthorization string
	upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		seenPath = r.URL.Path
		seenQuery = r.URL.RawQuery
		seenAuthorization = r.Header.Get("Authorization")
		require.Equal(t, "/api/v3/repos/acme/repo/contents/docs/README.md", r.URL.Path)
		require.Equal(t, "ref=main", r.URL.RawQuery)
		w.Header().Set("Content-Type", "application/json")
		_, _ = w.Write([]byte(`{"type":"file","name":"README.md","encoding":"base64","content":"SGVsbG8gUkFXCg=="}`))
	}))
	defer upstream.Close()

	router := gin.New()
	RegisterRoutes(router, Dependencies{
		AuthStore: fakeAuthStore{authenticated: identity.AuthenticatedUser{
			User:    model.User{ID: "usr_1", Email: "alice@example.com", Name: "alice"},
			Session: model.UserSession{ID: "sess_1", UserID: "usr_1"},
		}},
		BaseURL:            upstream.URL,
		TrustedJWTAudience: "meshx",
		Now:                func() time.Time { return time.Unix(1700000000, 0) },
	})

	server := httptest.NewServer(router)
	defer server.Close()

	request, err := http.NewRequest(http.MethodGet, server.URL+"/acme/repo/raw/main/docs/README.md", nil)
	require.NoError(t, err)
	request.Header.Set("Authorization", "Bearer agentteam-session")
	response, err := server.Client().Do(request)
	require.NoError(t, err)
	defer response.Body.Close()
	body, err := io.ReadAll(response.Body)
	require.NoError(t, err)

	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, "Hello RAW\n", string(body))
	require.Equal(t, "/api/v3/repos/acme/repo/contents/docs/README.md", seenPath)
	require.Equal(t, "ref=main", seenQuery)
	require.True(t, strings.HasPrefix(seenAuthorization, "Bearer "))
}

func TestForwarderServesRepositoryRawContentWithRepositoryCredential(t *testing.T) {
	gin.SetMode(gin.ReleaseMode)

	var seenAuthorization string
	var seenRouteWorkspaceID string
	upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		seenAuthorization = r.Header.Get("Authorization")
		seenRouteWorkspaceID = r.Header.Get(api.HeaderRouteWorkspaceID)
		w.Header().Set("Content-Type", "application/json")
		_, _ = w.Write([]byte(`{"type":"file","name":"README.md","encoding":"base64","content":"SGVsbG8gUkFXCg=="}`))
	}))
	defer upstream.Close()

	authStore := &recordingAuthStore{}
	router := gin.New()
	RegisterRoutes(router, Dependencies{AuthStore: authStore, BaseURL: upstream.URL})
	server := httptest.NewServer(router)
	defer server.Close()

	request, err := http.NewRequest(http.MethodGet, server.URL+"/acme/repo/raw/main/README.md", nil)
	require.NoError(t, err)
	request.Header.Set("Authorization", "token existing-ags-token")
	request.Header.Set(api.HeaderRouteWorkspaceID, "wsp_owner")
	response, err := server.Client().Do(request)
	require.NoError(t, err)
	defer response.Body.Close()
	body, err := io.ReadAll(response.Body)
	require.NoError(t, err)

	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, "Hello RAW\n", string(body))
	require.Empty(t, authStore.token)
	require.Empty(t, authStore.identifier)
	require.Equal(t, "token existing-ags-token", seenAuthorization)
	require.Equal(t, "wsp_owner", seenRouteWorkspaceID)
}

func decodeClaims(t *testing.T, token string) map[string]any {
	t.Helper()
	parts := strings.Split(token, ".")
	require.Len(t, parts, 3)
	payload, err := base64.RawURLEncoding.DecodeString(parts[1])
	require.NoError(t, err)
	var claims map[string]any
	require.NoError(t, json.Unmarshal(payload, &claims))
	return claims
}

// TestForwarderForwardsMemoryRoutesToMeshX pins the forwarder boundary for
// /api/v1/memory/* and the agent-access batch endpoint that Desktop calls
// during Memory integration. The route must always proxy to MeshX when the
// user is authenticated, regardless of MeshXEnabled gate (the gate currently
// only locks down repository-access-tokens and automations, not memory reads).
// Desktop's Memory batch pull relies on this pathway being live.
func TestForwarderForwardsMemoryRoutesToMeshX(t *testing.T) {
	gin.SetMode(gin.ReleaseMode)

	var seenPaths []string
	var seenMethods []string
	var seenTrustedJWTs []string
	upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		seenPaths = append(seenPaths, r.URL.Path)
		seenMethods = append(seenMethods, r.Method)
		seenTrustedJWTs = append(seenTrustedJWTs, strings.TrimPrefix(r.Header.Get(trustedJWTHeader), "Bearer "))
		w.Header().Set("Content-Type", "application/json")
		_, _ = io.WriteString(w, `{"ok":true}`)
	}))
	defer upstream.Close()

	router := gin.New()
	RegisterRoutes(router, Dependencies{
		AuthStore:      fakeAuthStore{authenticated: identity.AuthenticatedUser{User: model.User{ID: "usr_1"}}},
		WorkspaceStore: fakeWorkspaceStore{workspace: workspace.Summary{WorkspaceID: "wsp_1", WorkspaceRole: "member"}},
		AgentStore: fakeOwnedAgentStore{agents: []agent.AppAgent{
			{ID: "agt_alice", Name: "alice", DisplayName: "Alice Agent"},
		}},
		BaseURL:      upstream.URL,
		MeshXEnabled: true,
	})
	server := httptest.NewServer(router)
	defer server.Close()

	// Cover the routes Desktop actually depends on: list memories, get one,
	// and the agent-access batch endpoint Phase 1 wires up.
	cases := []struct {
		method string
		path   string
	}{
		{method: http.MethodGet, path: "/api/v1/memory"},
		{method: http.MethodGet, path: "/api/v1/memory/mem_alpha"},
		{method: http.MethodGet, path: "/api/v1/memory/agents/bindings"},
		{method: http.MethodPost, path: "/api/v1/memory/agents/access:batch"},
	}
	for _, request := range cases {
		req, err := http.NewRequest(request.method, server.URL+expectedWorkspacePath(request.path), nil)
		require.NoError(t, err)
		req.Header.Set("Authorization", "Bearer agentteam-session")
		req.Header.Set(api.HeaderWorkspaceID, "wsp_1")
		response, err := server.Client().Do(req)
		require.NoError(t, err, request.path)
		response.Body.Close()
		require.Equal(t, http.StatusOK, response.StatusCode, request.path)
	}

	require.Equal(t, []string{
		"/api/v1/memory",
		"/api/v1/memory/mem_alpha",
		"/api/v1/memory/agents/bindings",
		"/api/v1/memory/agents/access:batch",
	}, seenPaths)
	require.Equal(t, []string{
		http.MethodGet,
		http.MethodGet,
		http.MethodGet,
		http.MethodPost,
	}, seenMethods)
	require.NotEmpty(t, seenTrustedJWTs)
	claims := decodeClaims(t, seenTrustedJWTs[2])
	require.Equal(t, "wsp_1", claims["agent_ownership_workspace_id"])
	require.Equal(t, []any{"agt_alice"}, claims["owned_agent_ids"])
	require.Equal(t, []any{map[string]any{"id": "agt_alice", "name": "alice", "display_name": "Alice Agent"}}, claims["owned_agents"])
}

func TestForwarderSkipsMeshXResourceQuotaWhenBillingDisabled(t *testing.T) {
	gin.SetMode(gin.ReleaseMode)

	var seenPaths []string
	upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		seenPaths = append(seenPaths, r.URL.Path)
		w.Header().Set("Content-Type", "application/json")
		_, _ = io.WriteString(w, `{"ok":true}`)
	}))
	defer upstream.Close()

	limits := &fakeWorkspaceResourceLimitStore{limit: plan.WorkspaceResourceLimit{Limit: 0}}
	router := gin.New()
	RegisterRoutes(router, Dependencies{
		AuthStore:      fakeAuthStore{authenticated: identity.AuthenticatedUser{User: model.User{ID: "usr_1"}}},
		WorkspaceStore: fakeWorkspaceStore{workspace: workspace.Summary{WorkspaceID: "wsp_1", WorkspaceRole: "member"}},
		BaseURL:        upstream.URL,
		BillingEnabled: false,
		PlanStore:      limits,
	})
	server := httptest.NewServer(router)
	defer server.Close()

	request, err := http.NewRequest(http.MethodPost, server.URL+expectedWorkspacePath("/api/v1/memory"), strings.NewReader(`{"name":"Runbooks"}`))
	require.NoError(t, err)
	request.Header.Set("Authorization", "Bearer agentteam-session")
	response, err := server.Client().Do(request)
	require.NoError(t, err)
	defer response.Body.Close()

	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, []string{"/api/v1/memory"}, seenPaths)
	require.Zero(t, limits.called)
}

func TestForwarderBlocksMeshXMemoryCreateWhenQuotaExceeded(t *testing.T) {
	gin.SetMode(gin.ReleaseMode)

	var seenPaths []string
	upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		seenPaths = append(seenPaths, r.URL.Path)
		w.Header().Set("Content-Type", "application/json")
		if r.URL.Path == "/api/v1/internal/memory/count" {
			_, _ = io.WriteString(w, `{"total":10}`)
			return
		}
		_, _ = io.WriteString(w, `{"ok":true}`)
	}))
	defer upstream.Close()

	limits := &fakeWorkspaceResourceLimitStore{limit: plan.WorkspaceResourceLimit{Limit: 10}}
	router := gin.New()
	RegisterRoutes(router, Dependencies{
		AuthStore:      fakeAuthStore{authenticated: identity.AuthenticatedUser{User: model.User{ID: "usr_1"}}},
		WorkspaceStore: fakeWorkspaceStore{workspace: workspace.Summary{WorkspaceID: "wsp_1", WorkspaceRole: "member"}},
		BaseURL:        upstream.URL,
		BillingEnabled: true,
		PlanStore:      limits,
	})
	server := httptest.NewServer(router)
	defer server.Close()

	request, err := http.NewRequest(http.MethodPost, server.URL+expectedWorkspacePath("/api/v1/memory"), strings.NewReader(`{"name":"Runbooks"}`))
	require.NoError(t, err)
	request.Header.Set("Authorization", "Bearer agentteam-session")
	response, err := server.Client().Do(request)
	require.NoError(t, err)
	defer response.Body.Close()
	body, _ := io.ReadAll(response.Body)

	require.Equal(t, http.StatusForbidden, response.StatusCode, string(body))
	require.Contains(t, string(body), `"code":"quota_exceeded"`)
	require.Contains(t, string(body), `"metric":"managed_memories"`)
	require.Equal(t, []string{"/api/v1/internal/memory/count"}, seenPaths)
	require.Equal(t, 1, limits.called)
	require.Equal(t, "wsp_1", limits.workspace)
	require.Equal(t, "usr_1", limits.owner)
}

func TestForwarderSkipsMeshXResourceCountWhenPlanUnlimited(t *testing.T) {
	gin.SetMode(gin.ReleaseMode)

	var seenPaths []string
	upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		seenPaths = append(seenPaths, r.URL.Path)
		w.Header().Set("Content-Type", "application/json")
		if r.URL.Path == "/api/v1/internal/memory/count" {
			http.Error(w, "count should not be called", http.StatusInternalServerError)
			return
		}
		_, _ = io.WriteString(w, `{"ok":true}`)
	}))
	defer upstream.Close()

	limits := &fakeWorkspaceResourceLimitStore{limit: plan.WorkspaceResourceLimit{Limit: -1}}
	router := gin.New()
	RegisterRoutes(router, Dependencies{
		AuthStore:      fakeAuthStore{authenticated: identity.AuthenticatedUser{User: model.User{ID: "usr_1"}}},
		WorkspaceStore: fakeWorkspaceStore{workspace: workspace.Summary{WorkspaceID: "wsp_1", WorkspaceRole: "member"}},
		BaseURL:        upstream.URL,
		BillingEnabled: true,
		PlanStore:      limits,
	})
	server := httptest.NewServer(router)
	defer server.Close()

	request, err := http.NewRequest(http.MethodPost, server.URL+expectedWorkspacePath("/api/v1/memory"), strings.NewReader(`{"name":"Runbooks"}`))
	require.NoError(t, err)
	request.Header.Set("Authorization", "Bearer agentteam-session")
	request.Header.Set(api.HeaderWorkspaceID, "wsp_1")
	response, err := server.Client().Do(request)
	require.NoError(t, err)
	defer response.Body.Close()

	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, []string{"/api/v1/memory"}, seenPaths)
	require.Equal(t, 1, limits.called)
}

func TestForwarderChecksMeshXSkillCreateQuotaAndThenForwards(t *testing.T) {
	gin.SetMode(gin.ReleaseMode)

	var seenPaths []string
	upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		seenPaths = append(seenPaths, r.URL.Path)
		w.Header().Set("Content-Type", "application/json")
		if r.URL.Path == "/api/v1/internal/skills/count" {
			_, _ = io.WriteString(w, `{"total":9}`)
			return
		}
		_, _ = io.WriteString(w, `{"ok":true}`)
	}))
	defer upstream.Close()

	limits := &fakeWorkspaceResourceLimitStore{limit: plan.WorkspaceResourceLimit{Limit: 10}}
	router := gin.New()
	RegisterRoutes(router, Dependencies{
		AuthStore:      fakeAuthStore{authenticated: identity.AuthenticatedUser{User: model.User{ID: "usr_1"}}},
		WorkspaceStore: fakeWorkspaceStore{workspace: workspace.Summary{WorkspaceID: "wsp_1", WorkspaceRole: "member"}},
		BaseURL:        upstream.URL,
		BillingEnabled: true,
		PlanStore:      limits,
	})
	server := httptest.NewServer(router)
	defer server.Close()

	request, err := http.NewRequest(http.MethodPost, server.URL+expectedWorkspacePath("/api/v1/skills"), strings.NewReader(`{"name":"Reviewer"}`))
	require.NoError(t, err)
	request.Header.Set("Authorization", "Bearer agentteam-session")
	request.Header.Set(api.HeaderWorkspaceID, "wsp_1")
	response, err := server.Client().Do(request)
	require.NoError(t, err)
	defer response.Body.Close()

	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, []string{"/api/v1/internal/skills/count", "/api/v1/skills"}, seenPaths)
	require.Equal(t, 1, limits.called)
}

func TestForwarderForwardsSkillRoutesToMeshXWithWorkspaceAndOwnedAgents(t *testing.T) {
	gin.SetMode(gin.ReleaseMode)

	var seenPaths []string
	var seenWorkspaceIDs []string
	var seenTrustedJWTs []string
	upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		seenPaths = append(seenPaths, r.URL.Path)
		seenWorkspaceIDs = append(seenWorkspaceIDs, r.Header.Get(api.HeaderWorkspaceID))
		seenTrustedJWTs = append(seenTrustedJWTs, strings.TrimPrefix(r.Header.Get(trustedJWTHeader), "Bearer "))
		w.Header().Set("Content-Type", "application/json")
		_, _ = io.WriteString(w, `{"ok":true}`)
	}))
	defer upstream.Close()

	router := gin.New()
	RegisterRoutes(router, Dependencies{
		AuthStore:      fakeAuthStore{authenticated: identity.AuthenticatedUser{User: model.User{ID: "usr_1"}}},
		WorkspaceStore: fakeWorkspaceStore{workspace: workspace.Summary{WorkspaceID: "wsp_1", WorkspaceRole: "member"}},
		AgentStore: fakeOwnedAgentStore{agents: []agent.AppAgent{
			{ID: "agt_alice", Name: "alice", DisplayName: "Alice Agent"},
		}},
		BaseURL:      upstream.URL,
		MeshXEnabled: true,
	})
	server := httptest.NewServer(router)
	defer server.Close()

	for _, forwarded := range []struct {
		method string
		path   string
	}{
		{method: http.MethodGet, path: "/api/v1/skills"},
		{method: http.MethodGet, path: "/api/v1/skills/agents/bindings"},
		{method: http.MethodGet, path: "/api/v1/skills/agents/agt_alice/bindings"},
		{method: http.MethodPost, path: "/api/v1/skills/agents/access:batch"},
	} {
		request, err := http.NewRequest(forwarded.method, server.URL+expectedWorkspacePath(forwarded.path), nil)
		require.NoError(t, err)
		request.Header.Set("Authorization", "Bearer agentteam-session")
		request.Header.Set(api.HeaderWorkspaceID, "wsp_1")
		response, err := server.Client().Do(request)
		require.NoError(t, err, forwarded.path)
		response.Body.Close()
		require.Equal(t, http.StatusOK, response.StatusCode, forwarded.path)
	}

	require.Equal(t, []string{
		"/api/v1/skills",
		"/api/v1/skills/agents/bindings",
		"/api/v1/skills/agents/agt_alice/bindings",
		"/api/v1/skills/agents/access:batch",
	}, seenPaths)
	require.Equal(t, []string{"wsp_1", "wsp_1", "wsp_1", "wsp_1"}, seenWorkspaceIDs)
	require.NotEmpty(t, seenTrustedJWTs)
	claims := decodeClaims(t, seenTrustedJWTs[1])
	require.Equal(t, []any{"wsp_1"}, claims["workspace_ids"])
	require.Equal(t, "wsp_1", claims["agent_ownership_workspace_id"])
	require.Equal(t, []any{"agt_alice"}, claims["owned_agent_ids"])
	require.Equal(t, []any{map[string]any{"id": "agt_alice", "name": "alice", "display_name": "Alice Agent"}}, claims["owned_agents"])
}

func TestForwarderSkillRoutesUsePathWorkspace(t *testing.T) {
	gin.SetMode(gin.ReleaseMode)

	upstreamHits := 0
	upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		upstreamHits++
		w.WriteHeader(http.StatusOK)
	}))
	defer upstream.Close()

	router := gin.New()
	RegisterRoutes(router, Dependencies{
		AuthStore:      fakeAuthStore{authenticated: identity.AuthenticatedUser{User: model.User{ID: "usr_1"}}},
		WorkspaceStore: fakeWorkspaceStore{workspace: workspace.Summary{WorkspaceID: "wsp_1", WorkspaceRole: "member"}},
		BaseURL:        upstream.URL,
		MeshXEnabled:   true,
	})
	server := httptest.NewServer(router)
	defer server.Close()

	request, err := http.NewRequest(http.MethodGet, server.URL+expectedWorkspacePath("/api/v1/skills/agents/bindings"), nil)
	require.NoError(t, err)
	request.Header.Set("Authorization", "Bearer agentteam-session")
	response, err := server.Client().Do(request)
	require.NoError(t, err)
	defer response.Body.Close()

	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, 1, upstreamHits)
}

func TestForwarderLogsUpstreamServerErrors(t *testing.T) {
	gin.SetMode(gin.ReleaseMode)

	upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusInternalServerError)
		_, _ = io.WriteString(w, `{"error":"boom"}`)
	}))
	defer upstream.Close()

	var logs bytes.Buffer
	router := gin.New()
	RegisterRoutes(router, Dependencies{
		AuthStore:      fakeAuthStore{authenticated: identity.AuthenticatedUser{User: model.User{ID: "usr_1"}}},
		WorkspaceStore: fakeWorkspaceStore{workspace: workspace.Summary{WorkspaceID: "wsp_1", WorkspaceRole: "member"}},
		BaseURL:        upstream.URL,
		Logger:         slog.New(slog.NewTextHandler(&logs, nil)),
		MeshXEnabled:   true,
	})
	server := httptest.NewServer(router)
	defer server.Close()

	request, err := http.NewRequest(http.MethodGet, server.URL+expectedWorkspacePath("/api/v1/skills/agents/bindings"), nil)
	require.NoError(t, err)
	request.Header.Set("Authorization", "Bearer agentteam-session")
	request.Header.Set(api.HeaderWorkspaceID, "wsp_1")
	response, err := server.Client().Do(request)
	require.NoError(t, err)
	defer response.Body.Close()
	body, err := io.ReadAll(response.Body)
	require.NoError(t, err)

	require.Equal(t, http.StatusInternalServerError, response.StatusCode)
	require.JSONEq(t, `{"error":"boom"}`, string(body))
	logOutput := logs.String()
	require.Contains(t, logOutput, "API forwarder upstream returned error")
	require.Contains(t, logOutput, "status=500")
	require.Contains(t, logOutput, "path=/api/v1/skills/agents/bindings")
	require.Contains(t, logOutput, "boom")
}

// TestForwarderMemoryRoutesRequireAuthentication pins the boundary: memory
// routes must NOT be accessible without AgentTeam session auth. This is the
// forwarder-side guard that complements MeshX's own permission checks.
func TestForwarderMemoryRoutesRequireAuthentication(t *testing.T) {
	gin.SetMode(gin.ReleaseMode)

	upstreamHits := 0
	upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		upstreamHits++
		w.WriteHeader(http.StatusOK)
	}))
	defer upstream.Close()

	router := gin.New()
	RegisterRoutes(router, Dependencies{
		AuthStore:      fakeAuthStore{authenticated: identity.AuthenticatedUser{User: model.User{ID: "usr_1"}}},
		WorkspaceStore: fakeWorkspaceStore{workspace: workspace.Summary{WorkspaceID: "wsp_1", WorkspaceRole: "member"}},
		BaseURL:        upstream.URL,
		MeshXEnabled:   true,
	})
	server := httptest.NewServer(router)
	defer server.Close()

	// No Authorization header at all — must be rejected before hitting upstream.
	req, err := http.NewRequest(http.MethodGet, server.URL+expectedWorkspacePath("/api/v1/memory"), nil)
	require.NoError(t, err)
	response, err := server.Client().Do(req)
	require.NoError(t, err)
	response.Body.Close()
	require.Equal(t, http.StatusUnauthorized, response.StatusCode)
	require.Zero(t, upstreamHits, "unauthenticated memory request must not reach MeshX")
}

func TestForwarderSelfDemotionGuardBlocksUnconfirmedRoleDowngrades(t *testing.T) {
	gin.SetMode(gin.ReleaseMode)

	cases := []struct {
		name            string
		method          string
		path            string
		body            string
		permissionsPath string
		permissionsBody string
	}{
		{
			name:            "memory manage to view",
			method:          http.MethodPatch,
			path:            "/api/v1/memory/mem_1/permissions/grants/grant_self",
			body:            `{"subjectType":"user","subjectId":"usr_1","role":"view"}`,
			permissionsPath: "/api/v1/memory/mem_1/permissions",
			permissionsBody: `{"myRole":"manage","canManage":true,"members":[{"id":"grant_self","subjectType":"user","subjectId":"usr_1","role":"manage"}]}`,
		},
		{
			name:            "skill manage to view",
			method:          http.MethodPatch,
			path:            "/api/v1/skills/skl_1/permissions/grants/grant_self",
			body:            `{"subjectType":"user","subjectId":"usr_1","role":"view"}`,
			permissionsPath: "/api/v1/skills/skl_1/permissions",
			permissionsBody: `{"myRole":"manage","canManage":true,"members":[{"id":"grant_self","subjectType":"user","subjectId":"usr_1","role":"manage"}]}`,
		},
		{
			name:            "kb manage to view",
			method:          http.MethodPatch,
			path:            "/api/v1/kb/kb_1/permissions/grants/grant_self",
			body:            `{"subjectType":"user","subjectId":"usr_1","role":"view"}`,
			permissionsPath: "/api/v1/kb/kb_1/permissions",
			permissionsBody: `{"myRole":"manage","canManage":true,"members":[{"id":"grant_self","subjectType":"user","subjectId":"usr_1","role":"manage"}],"agents":[]}`,
		},
		{
			name:            "kb workspace default manage to view",
			method:          http.MethodPatch,
			path:            "/api/v1/kb/kb_1",
			body:            `{"name":"Runbooks","description":"","defaultWorkspaceMemberRole":"view"}`,
			permissionsPath: "/api/v1/kb/kb_1/permissions",
			permissionsBody: `{"myRole":"manage","canManage":true,"members":[],"agents":[]}`,
		},
	}

	for _, tt := range cases {
		t.Run(tt.name, func(t *testing.T) {
			var seen []string
			upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
				seen = append(seen, r.Method+" "+r.URL.Path)
				if r.URL.Path == tt.permissionsPath {
					w.Header().Set("Content-Type", "application/json")
					_, _ = io.WriteString(w, tt.permissionsBody)
					return
				}
				w.WriteHeader(http.StatusNoContent)
			}))
			defer upstream.Close()

			server := newSelfDemotionGuardAgentTeamServer(t, upstream.URL)
			defer server.Close()

			request, err := http.NewRequest(tt.method, server.URL+expectedWorkspacePath(tt.path), strings.NewReader(tt.body))
			require.NoError(t, err)
			request.Header.Set("Authorization", "Bearer agentteam-session")
			response, err := server.Client().Do(request)
			require.NoError(t, err)
			defer response.Body.Close()
			body, err := io.ReadAll(response.Body)
			require.NoError(t, err)

			require.Equal(t, http.StatusConflict, response.StatusCode)
			require.Contains(t, string(body), selfDemotionRequiredCode)
			require.Equal(t, []string{http.MethodGet + " " + tt.permissionsPath}, seen)
		})
	}
}

func TestForwarderSelfDemotionGuardBlocksUnconfirmedViewToNone(t *testing.T) {
	gin.SetMode(gin.ReleaseMode)

	cases := []struct {
		name            string
		method          string
		path            string
		body            string
		permissionsPath string
		permissionsBody string
		detailPath      string
		detailBody      string
	}{
		{
			name:            "memory delete own view grant",
			method:          http.MethodDelete,
			path:            "/api/v1/memory/mem_1/permissions/grants/grant_self",
			permissionsPath: "/api/v1/memory/mem_1/permissions",
			permissionsBody: `{"myRole":"view","canManage":true,"members":[{"id":"grant_self","subjectType":"user","subjectId":"usr_1","role":"view"}]}`,
		},
		{
			name:            "skill delete own view grant",
			method:          http.MethodDelete,
			path:            "/api/v1/skills/skl_1/permissions/grants/grant_self",
			permissionsPath: "/api/v1/skills/skl_1/permissions",
			permissionsBody: `{"myRole":"view","canManage":true,"members":[{"id":"grant_self","subjectType":"user","subjectId":"usr_1","role":"view"}]}`,
		},
		{
			name:            "kb explicit view to none",
			method:          http.MethodPatch,
			path:            "/api/v1/kb/kb_1/permissions/grants/grant_self",
			body:            `{"subjectType":"user","subjectId":"usr_1","role":"none"}`,
			permissionsPath: "/api/v1/kb/kb_1/permissions",
			permissionsBody: `{"myRole":"view","canManage":true,"members":[{"id":"grant_self","subjectType":"user","subjectId":"usr_1","role":"view","repositoryAccessProfile":"none"}],"agents":[]}`,
		},
		{
			name:            "kb workspace default view to none",
			method:          http.MethodPatch,
			path:            "/api/v1/kb/kb_1",
			body:            `{"name":"Runbooks","description":"","defaultWorkspaceMemberRole":"none"}`,
			permissionsPath: "/api/v1/kb/kb_1/permissions",
			permissionsBody: `{"myRole":"view","canManage":true,"members":[],"agents":[]}`,
		},
	}

	for _, tt := range cases {
		t.Run(tt.name, func(t *testing.T) {
			var seen []string
			upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
				seen = append(seen, r.Method+" "+r.URL.Path)
				if r.URL.Path == tt.permissionsPath {
					w.Header().Set("Content-Type", "application/json")
					_, _ = io.WriteString(w, tt.permissionsBody)
					return
				}
				if tt.detailPath != "" && r.URL.Path == tt.detailPath {
					w.Header().Set("Content-Type", "application/json")
					_, _ = io.WriteString(w, tt.detailBody)
					return
				}
				w.WriteHeader(http.StatusNoContent)
			}))
			defer upstream.Close()

			server := newSelfDemotionGuardAgentTeamServer(t, upstream.URL)
			defer server.Close()

			request, err := http.NewRequest(tt.method, server.URL+expectedWorkspacePath(tt.path), strings.NewReader(tt.body))
			require.NoError(t, err)
			request.Header.Set("Authorization", "Bearer agentteam-session")
			request.Header.Set(api.HeaderWorkspaceID, "wsp_1")
			response, err := server.Client().Do(request)
			require.NoError(t, err)
			defer response.Body.Close()
			body, err := io.ReadAll(response.Body)
			require.NoError(t, err)

			require.Equal(t, http.StatusConflict, response.StatusCode)
			require.Contains(t, string(body), selfDemotionRequiredCode)
			require.Equal(t, http.MethodGet+" "+tt.permissionsPath, seen[0])
		})
	}
}

func TestForwarderSelfDemotionGuardBlocksUnconfirmedKBDefaultRepositoryDowngrade(t *testing.T) {
	gin.SetMode(gin.ReleaseMode)

	var seen []string
	upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		seen = append(seen, r.Method+" "+r.URL.Path)
		switch r.URL.Path {
		case "/api/v1/kb/kb_1/permissions":
			w.Header().Set("Content-Type", "application/json")
			_, _ = io.WriteString(w, `{"myRole":"manage","canManage":true,"members":[],"agents":[]}`)
		case "/api/v1/kb/kb_1":
			w.Header().Set("Content-Type", "application/json")
			_, _ = io.WriteString(w, `{"defaultWorkspaceMemberRole":"manage","defaultRepositoryAccessProfile":"write"}`)
		default:
			w.WriteHeader(http.StatusNoContent)
		}
	}))
	defer upstream.Close()

	server := newSelfDemotionGuardAgentTeamServer(t, upstream.URL)
	defer server.Close()

	request, err := http.NewRequest(
		http.MethodPatch,
		server.URL+expectedWorkspacePath("/api/v1/kb/kb_1"),
		strings.NewReader(`{"name":"Runbooks","description":"","defaultRepositoryAccessProfile":"read"}`),
	)
	require.NoError(t, err)
	request.Header.Set("Authorization", "Bearer agentteam-session")
	request.Header.Set(api.HeaderWorkspaceID, "wsp_1")
	response, err := server.Client().Do(request)
	require.NoError(t, err)
	defer response.Body.Close()
	body, err := io.ReadAll(response.Body)
	require.NoError(t, err)

	require.Equal(t, http.StatusConflict, response.StatusCode)
	require.Contains(t, string(body), selfDemotionRequiredCode)
	require.Equal(t, []string{
		http.MethodGet + " /api/v1/kb/kb_1/permissions",
		http.MethodGet + " /api/v1/kb/kb_1",
	}, seen)
}

func TestForwarderSelfDemotionGuardBlocksUnconfirmedKBRepositoryMaterializationDowngrade(t *testing.T) {
	gin.SetMode(gin.ReleaseMode)

	var seen []string
	upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		seen = append(seen, r.Method+" "+r.URL.Path)
		if r.URL.Path == "/api/v1/kb/kb_1/permissions" {
			w.Header().Set("Content-Type", "application/json")
			_, _ = io.WriteString(w, `{"myRole":"manage","canManage":true,"members":[{"id":"grant_self","subjectType":"user","subjectId":"usr_1","role":"manage","repositoryAccessProfile":"write","sourceRepositoryAccess":[{"sourceId":"src_1","repoRole":"primary","inheritedRepositoryAccessProfile":"write","effectiveRepositoryAccessProfile":"write"}]}],"agents":[]}`)
			return
		}
		w.WriteHeader(http.StatusNoContent)
	}))
	defer upstream.Close()

	server := newSelfDemotionGuardAgentTeamServer(t, upstream.URL)
	defer server.Close()

	request, err := http.NewRequest(
		http.MethodPatch,
		server.URL+expectedWorkspacePath("/api/v1/kb/kb_1/permissions/grants/grant_self/repository-overrides"),
		strings.NewReader(`{"items":[{"sourceId":"src_1","repoRole":"primary","repositoryAccessProfile":"read"}]}`),
	)
	require.NoError(t, err)
	request.Header.Set("Authorization", "Bearer agentteam-session")
	request.Header.Set(api.HeaderWorkspaceID, "wsp_1")
	response, err := server.Client().Do(request)
	require.NoError(t, err)
	defer response.Body.Close()
	body, err := io.ReadAll(response.Body)
	require.NoError(t, err)

	require.Equal(t, http.StatusConflict, response.StatusCode)
	require.Contains(t, string(body), selfDemotionRequiredCode)
	require.Equal(t, []string{http.MethodGet + " /api/v1/kb/kb_1/permissions"}, seen)
}

func TestForwarderSelfDemotionGuardAllowsConfirmedDowngradeAndStripsFlag(t *testing.T) {
	gin.SetMode(gin.ReleaseMode)

	var seenPath string
	var seenBody string
	upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		body, _ := io.ReadAll(r.Body)
		seenPath = r.URL.Path
		seenBody = string(body)
		w.WriteHeader(http.StatusNoContent)
	}))
	defer upstream.Close()

	server := newSelfDemotionGuardAgentTeamServer(t, upstream.URL)
	defer server.Close()

	request, err := http.NewRequest(
		http.MethodPatch,
		server.URL+expectedWorkspacePath("/api/v1/memory/mem_1/permissions/grants/grant_self"),
		strings.NewReader(`{"subjectType":"user","subjectId":"usr_1","role":"view","selfDemotionConfirmed":true}`),
	)
	require.NoError(t, err)
	request.Header.Set("Authorization", "Bearer agentteam-session")
	request.Header.Set(api.HeaderWorkspaceID, "wsp_1")
	response, err := server.Client().Do(request)
	require.NoError(t, err)
	defer response.Body.Close()

	require.Equal(t, http.StatusNoContent, response.StatusCode)
	require.Equal(t, "/api/v1/memory/mem_1/permissions/grants/grant_self", seenPath)
	require.JSONEq(t, `{"role":"view","subjectId":"usr_1","subjectType":"user"}`, seenBody)
}

func TestForwarderSelfDemotionGuardForwardsNonSelfPermissionWritesWithoutLookup(t *testing.T) {
	gin.SetMode(gin.ReleaseMode)

	var seen []string
	upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		seen = append(seen, r.Method+" "+r.URL.Path)
		w.WriteHeader(http.StatusNoContent)
	}))
	defer upstream.Close()

	server := newSelfDemotionGuardAgentTeamServer(t, upstream.URL)
	defer server.Close()

	request, err := http.NewRequest(
		http.MethodPatch,
		server.URL+expectedWorkspacePath("/api/v1/memory/mem_1/permissions/grants/grant_other"),
		strings.NewReader(`{"subjectType":"user","subjectId":"usr_2","role":"view"}`),
	)
	require.NoError(t, err)
	request.Header.Set("Authorization", "Bearer agentteam-session")
	request.Header.Set(api.HeaderWorkspaceID, "wsp_1")
	response, err := server.Client().Do(request)
	require.NoError(t, err)
	defer response.Body.Close()

	require.Equal(t, http.StatusNoContent, response.StatusCode)
	require.Equal(t, []string{http.MethodPatch + " /api/v1/memory/mem_1/permissions/grants/grant_other"}, seen)
}

func newSelfDemotionGuardAgentTeamServer(t *testing.T, upstreamURL string) *httptest.Server {
	t.Helper()
	router := gin.New()
	RegisterRoutes(router, Dependencies{
		AuthStore: fakeAuthStore{authenticated: identity.AuthenticatedUser{
			User:    model.User{ID: "usr_1"},
			Session: model.UserSession{ID: "sess_1", UserID: "usr_1"},
		}},
		WorkspaceStore: fakeWorkspaceStore{workspace: workspace.Summary{WorkspaceID: "wsp_1", WorkspaceRole: "member"}},
		BaseURL:        upstreamURL,
		MeshXEnabled:   true,
	})
	return httptest.NewServer(router)
}
