package extension

import (
	"bytes"
	"context"
	"crypto/sha256"
	"encoding/base64"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"strings"
	"sync"
	"testing"

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

	"github.com/tidbcloud/AgentTeam/internal/extension/registry"
	"github.com/tidbcloud/AgentTeam/internal/service/extensionflow"
	"github.com/tidbcloud/AgentTeam/internal/store/agent"
)

// stubCatalog is a CatalogSource returning a fixed extension list. The fixture
// carries a server-only ExternalBinding with distinctive secret markers so the
// HTTP no-secrets test exercises the real handlers end-to-end.
type stubCatalog struct {
	extensions []registry.RegisteredExtension
}

type stubInvocationService struct {
	mu      sync.Mutex
	calls   int
	request extensionflow.InvokeParams
	result  extensionflow.InvokeResult
	err     error
}

func (service *stubInvocationService) Invoke(
	_ context.Context,
	request extensionflow.InvokeParams,
) (extensionflow.InvokeResult, error) {
	service.mu.Lock()
	defer service.mu.Unlock()
	service.calls++
	service.request = request
	result := service.result
	if len(result.Data) == 0 {
		result = extensionflow.InvokeResult{
			Domain: request.ExtensionName,
			Action: request.Action,
			Data:   json.RawMessage(`{"ok":true}`),
		}
	}
	return result, service.err
}

func (s stubCatalog) List() []registry.RegisteredExtension { return s.extensions }

// stubAuthenticator authenticates exactly one (agentID, token) pair and binds it
// to a configurable workspace. Any other credential is unauthorized.
type stubAuthenticator struct {
	agentID   string
	token     string
	workspace string
	memberID  string
	machineID string
}

func (a stubAuthenticator) AuthenticateAgent(_ context.Context, agentID string, bearerToken string) (agent.AuthenticatedAgent, error) {
	if agentID != a.agentID || bearerToken != a.token {
		return agent.AuthenticatedAgent{}, agent.ErrUnauthorized
	}
	return agent.AuthenticatedAgent{
		AgentID:     a.agentID,
		WorkspaceID: a.workspace,
		MemberID:    a.memberID,
		MachineID:   a.machineID,
	}, nil
}

const (
	testWorkspace = "ws_1"
	testAgent     = "ag_1"
	testMember    = "mem_1"
	testMachine   = "mach_1"
	testToken     = "bearer-token-secret"
)

func newExtensionRouter(t *testing.T, auth stubAuthenticator) *gin.Engine {
	t.Helper()
	return newRouterWithExtensions(t, auth, []registry.RegisteredExtension{extensionWithBinding()})
}

// newRouterWithExtensions builds an Extension runtime router backed by an
// arbitrary extension list, so adversarial tests can seed multi-version or
// multi-domain Registries.
func newRouterWithExtensions(t *testing.T, auth stubAuthenticator, extensions []registry.RegisteredExtension) *gin.Engine {
	t.Helper()
	return newRouterWithInvoker(t, auth, extensions, &stubInvocationService{})
}

func newRouterWithInvoker(
	t *testing.T,
	auth stubAuthenticator,
	extensions []registry.RegisteredExtension,
	invoker InvocationService,
) *gin.Engine {
	t.Helper()
	gin.SetMode(gin.ReleaseMode)
	router := gin.New()
	router.HandleMethodNotAllowed = true
	catalog := stubCatalog{extensions: extensions}
	RegisterRoutes(router, Dependencies{
		Auth:    auth,
		Catalog: catalog,
		Access:  &permissiveCatalogAccess{catalog: catalog},
		Invoker: invoker,
	})
	return router
}

// permissiveCatalogAccess is a test AuthorizationSource that authorizes all
// domains + all actions from the catalog. Used by projection tests.
type permissiveCatalogAccess struct {
	catalog CatalogSource
}

func (a *permissiveCatalogAccess) ActiveAgentDomains(_ context.Context, _, _ string) ([]AgentDomainAccess, error) {
	effective := effectiveDomains(a.catalog.List())
	result := make([]AgentDomainAccess, 0, len(effective))
	for _, ext := range effective {
		actions := make([]string, 0, len(ext.Definition.Actions))
		for _, action := range ext.Definition.Actions {
			actions = append(actions, action.Name)
		}
		result = append(result, AgentDomainAccess{
			Domain:            ext.Definition.Domain,
			DefinitionVersion: ext.Definition.Version,
			DefinitionDigest:  ext.Definition.Digest,
			ExtensionName:     ext.Definition.Domain,
			Actions:           actions,
		})
	}
	return result, nil
}

type fixedCatalogAccess struct {
	domains []AgentDomainAccess
}

func (a fixedCatalogAccess) ActiveAgentDomains(_ context.Context, _, _ string) ([]AgentDomainAccess, error) {
	return a.domains, nil
}

func authenticator() stubAuthenticator {
	return stubAuthenticator{
		agentID: testAgent, token: testToken, workspace: testWorkspace,
		memberID: testMember, machineID: testMachine,
	}
}

func do(t *testing.T, router *gin.Engine, method, target, token string, body any) *httptest.ResponseRecorder {
	t.Helper()
	var reader *bytes.Reader
	if body != nil {
		raw, err := json.Marshal(body)
		require.NoError(t, err)
		reader = bytes.NewReader(raw)
	} else {
		reader = bytes.NewReader(nil)
	}
	req := httptest.NewRequest(method, target, reader)
	if token != "" {
		req.Header.Set("Authorization", "Bearer "+token)
	}
	if body != nil {
		req.Header.Set("Content-Type", "application/json")
	}
	recorder := httptest.NewRecorder()
	router.ServeHTTP(recorder, req)
	return recorder
}

// doRaw issues a request with an already-serialized body, so adversarial tests
// can send malformed envelopes (trailing JSON, oversized payloads, scalar
// inputs) that the typed do helper would re-marshal.
func doRaw(t *testing.T, router *gin.Engine, method, target, token, body string) *httptest.ResponseRecorder {
	t.Helper()
	req := httptest.NewRequest(method, target, bytes.NewReader([]byte(body)))
	if token != "" {
		req.Header.Set("Authorization", "Bearer "+token)
	}
	if body != "" {
		req.Header.Set("Content-Type", "application/json")
	}
	recorder := httptest.NewRecorder()
	router.ServeHTTP(recorder, req)
	return recorder
}

// TestRoutes_RequireAgentRuntimePrincipal asserts every Extension route
// rejects an unauthenticated request with 401 before any handler/body work.
func TestRoutes_RequireAgentRuntimePrincipal(t *testing.T) {
	router := newExtensionRouter(t, authenticator())
	targets := []struct {
		method string
		path   string
	}{
		{http.MethodGet, RuntimeBase},
		{http.MethodPost, RuntimeBase + "/github/actions/create-issue"},
	}
	for _, route := range targets {
		t.Run(route.method+" "+route.path, func(t *testing.T) {
			// No Authorization header at all.
			recorder := do(t, router, route.method, route.path, "", nil)
			require.Equal(t, http.StatusUnauthorized, recorder.Code, "unauthenticated %s must be 401", route.method)
			// Wrong token is also rejected.
			recorder = do(t, router, route.method, route.path, "wrong-token", bodyFor(route.method))
			require.Equal(t, http.StatusUnauthorized, recorder.Code, "bad token %s must be 401", route.method)
		})
	}
}

func bodyFor(method string) any {
	if method == http.MethodPost {
		return invokeRequest{Input: json.RawMessage(`{"title":"x"}`)}
	}
	return nil
}

// TestRoutes_WorkspaceBindingEnforcedBeforeBody asserts a valid token bound to a
// different workspace than the path is rejected (421) before the POST body is
// read. The path workspace is authoritative alongside the principal.
func TestRoutes_WorkspaceBindingEnforcedBeforeBody(t *testing.T) {
	// Authenticate agent ag_1 under ws_1, but call the route path with ws_other.
	router := newExtensionRouter(t, authenticator())
	target := strings.ReplaceAll(RuntimeBase, ":workspaceId", "ws_other")
	target = strings.ReplaceAll(target, ":agentId", testAgent)
	target += "/github/actions/create-issue"
	recorder := do(t, router, http.MethodPost, target, testToken, invokeRequest{Input: json.RawMessage(`{"title":"x"}`)})
	require.True(t, recorder.Code == http.StatusMisdirectedRequest || recorder.Code == http.StatusUnauthorized,
		"workspace/path mismatch must be rejected, got %d body=%s", recorder.Code, recorder.Body.String())
}

// TestRoutes_AgentIDBindingEnforced asserts the path agentId must match the
// authenticated principal's agentId (a token for ag_1 used on path ag_other is
// rejected).
func TestRoutes_AgentIDBindingEnforced(t *testing.T) {
	router := newExtensionRouter(t, authenticator())
	target := strings.ReplaceAll(RuntimeBase, ":workspaceId", testWorkspace)
	target = strings.ReplaceAll(target, ":agentId", "ag_other")
	recorder := do(t, router, http.MethodGet, target, testToken, nil)
	require.Equal(t, http.StatusUnauthorized, recorder.Code)
}

// TestCatalogGetReturnsCompleteContentAndSupportsConditionalGET asserts the
// single GET returns every descriptor with a content-derived validator.
func TestCatalogGetReturnsCompleteContentAndSupportsConditionalGET(t *testing.T) {
	router := newExtensionRouter(t, authenticator())

	rootTarget := strings.ReplaceAll(RuntimeBase, ":workspaceId", testWorkspace)
	rootTarget = strings.ReplaceAll(rootTarget, ":agentId", testAgent)

	recorder := do(t, router, http.MethodGet, rootTarget, testToken, nil)
	require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
	var root rootResponse
	require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &root))
	require.Len(t, root.Extensions, 1)
	require.Equal(t, "github", root.Extensions[0].ExtensionName)
	require.Len(t, root.Extensions[0].Actions, 2)
	require.NotNil(t, root.Extensions[0].Actions[0].InputSchema)
	etag := recorder.Header().Get("ETag")
	require.NotEmpty(t, etag)
	sum := sha256.Sum256(recorder.Body.Bytes())
	require.Equal(
		t,
		`"ext-v1-`+base64.RawURLEncoding.EncodeToString(sum[:])+`"`,
		etag,
	)
	require.Equal(
		t,
		"private, no-cache, no-transform",
		recorder.Header().Get("Cache-Control"),
	)
	require.Equal(t, "Authorization", recorder.Header().Get("Vary"))

	request := httptest.NewRequest(http.MethodGet, rootTarget, nil)
	request.Header.Set("Authorization", "Bearer "+testToken)
	request.Header.Set("If-None-Match", "W/"+etag)
	notModified := httptest.NewRecorder()
	router.ServeHTTP(notModified, request)
	require.Equal(t, http.StatusNotModified, notModified.Code)
	require.Empty(t, notModified.Body.String())
	require.Equal(t, etag, notModified.Header().Get("ETag"))
}

func TestCatalogGetFiltersUnauthorizedActions(t *testing.T) {
	gin.SetMode(gin.ReleaseMode)
	router := gin.New()
	catalog := stubCatalog{extensions: []registry.RegisteredExtension{extensionWithBinding()}}
	RegisterRoutes(router, Dependencies{
		Auth:    authenticator(),
		Catalog: catalog,
		Access: fixedCatalogAccess{domains: []AgentDomainAccess{{
			Domain:            "github",
			DefinitionVersion: 1,
			DefinitionDigest:  "sha256:definition-digest-marker",
			ExtensionName:     "github",
			Actions:           []string{"list-issues"},
		}}},
		Invoker: &stubInvocationService{},
	})

	rootTarget := strings.ReplaceAll(RuntimeBase, ":workspaceId", testWorkspace)
	rootTarget = strings.ReplaceAll(rootTarget, ":agentId", testAgent)
	recorder := do(t, router, http.MethodGet, rootTarget, testToken, nil)
	require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())

	var root rootResponse
	require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &root))
	require.Len(t, root.Extensions, 1)
	require.Len(t, root.Extensions[0].Actions, 1)
	require.Equal(t, "list-issues", root.Extensions[0].Actions[0].Name)
	require.NotContains(t, recorder.Body.String(), "create-issue")

	// The validator belongs to the final authorization-projected response, not
	// the underlying Registry. The same Registry with a broader action grant
	// must therefore produce a different ETag, and that validator must not turn
	// this narrower representation into a false 304.
	full := do(
		t,
		newExtensionRouter(t, authenticator()),
		http.MethodGet,
		rootTarget,
		testToken,
		nil,
	)
	require.Equal(t, http.StatusOK, full.Code, full.Body.String())
	require.NotEqual(t, full.Header().Get("ETag"), recorder.Header().Get("ETag"))

	request := httptest.NewRequest(http.MethodGet, rootTarget, nil)
	request.Header.Set("Authorization", "Bearer "+testToken)
	request.Header.Set("If-None-Match", full.Header().Get("ETag"))
	notModified := httptest.NewRecorder()
	router.ServeHTTP(notModified, request)
	require.Equal(t, http.StatusOK, notModified.Code)
	require.Equal(t, recorder.Header().Get("ETag"), notModified.Header().Get("ETag"))
}

// TestCatalogGet_NoSecretsLeak is the end-to-end no-secrets assertion: the
// raw HTTP response body must not contain any of the
// distinctive secret binding markers carried by the fixture's ExternalBinding.
func TestCatalogGet_NoSecretsLeak(t *testing.T) {
	router := newExtensionRouter(t, authenticator())
	rootTarget := strings.ReplaceAll(RuntimeBase, ":workspaceId", testWorkspace)
	rootTarget = strings.ReplaceAll(rootTarget, ":agentId", testAgent)

	recorder := do(t, router, http.MethodGet, rootTarget, testToken, nil)
	require.Equal(t, http.StatusOK, recorder.Code)
	body := recorder.Body.String()
	for _, marker := range secretBindingMarkers {
		assert.NotContainsf(t, body, marker, "Catalog leaked secret binding marker %q", marker)
	}
	assert.NotContains(t, body, "\"kind\"")
}

// TestRemovedAndUnknownRoutesNotFound asserts old granular GETs and unknown
// invocation action identities are rejected.
func TestRemovedAndUnknownRoutesNotFound(t *testing.T) {
	router := newExtensionRouter(t, authenticator())
	rootTarget := strings.ReplaceAll(RuntimeBase, ":workspaceId", testWorkspace)
	rootTarget = strings.ReplaceAll(rootTarget, ":agentId", testAgent)

	recorder := do(t, router, http.MethodGet, rootTarget+"/github", testToken, nil)
	require.Equal(t, http.StatusNotFound, recorder.Code)

	recorder = do(t, router, http.MethodPost, rootTarget+"/github/actions/missing", testToken, invokeRequest{Input: json.RawMessage(`{}`)})
	require.Equal(t, http.StatusNotFound, recorder.Code)
}

// TestInvoke_WiredReturnsBoundedResult asserts the POST route is registered,
// authenticated, body-read, and delegated to the invocation service.
func TestInvoke_WiredReturnsBoundedResult(t *testing.T) {
	router := newExtensionRouter(t, authenticator())
	rootTarget := strings.ReplaceAll(RuntimeBase, ":workspaceId", testWorkspace)
	rootTarget = strings.ReplaceAll(rootTarget, ":agentId", testAgent)

	recorder := do(t, router, http.MethodPost, rootTarget+"/github/actions/create-issue", testToken,
		invokeRequest{Input: json.RawMessage(`{"title":"x"}`)})
	require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
	require.NotContains(t, recorder.Body.String(), `"upstreamStatus"`)
	require.Contains(t, recorder.Body.String(), `"ok":true`)
}

func TestInvokePrecheckUsesSelectedDefinitionIdentity(t *testing.T) {
	v1 := extensionWithBinding()
	v2 := extensionWithBinding()
	v2.Definition.Version = 2
	v2.Definition.Digest = "sha256:v2"
	v2.Definition.Actions = v2.Definition.Actions[1:]

	invoker := &stubInvocationService{}
	gin.SetMode(gin.ReleaseMode)
	router := gin.New()
	RegisterRoutes(router, Dependencies{
		Auth:    authenticator(),
		Catalog: stubCatalog{extensions: []registry.RegisteredExtension{v1, v2}},
		Access: fixedCatalogAccess{domains: []AgentDomainAccess{{
			Domain:            v1.Definition.Domain,
			DefinitionVersion: v1.Definition.Version,
			DefinitionDigest:  v1.Definition.Digest,
			ExtensionName:     "github",
			Actions:           []string{"create-issue"},
		}}},
		Invoker: invoker,
	})
	rootTarget := strings.ReplaceAll(RuntimeBase, ":workspaceId", testWorkspace)
	rootTarget = strings.ReplaceAll(rootTarget, ":agentId", testAgent)

	recorder := do(
		t,
		router,
		http.MethodPost,
		rootTarget+"/github/actions/create-issue",
		testToken,
		invokeRequest{Input: json.RawMessage(`{"title":"x"}`)},
	)
	require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
	require.Equal(t, 1, invoker.calls)
}

func TestInvokePassesAuthenticatedPrincipalAndInput(t *testing.T) {
	invoker := &stubInvocationService{}
	router := newRouterWithInvoker(
		t,
		authenticator(),
		[]registry.RegisteredExtension{extensionWithBinding()},
		invoker,
	)
	rootTarget := strings.ReplaceAll(RuntimeBase, ":workspaceId", testWorkspace)
	rootTarget = strings.ReplaceAll(rootTarget, ":agentId", testAgent)
	recorder := doRaw(
		t,
		router,
		http.MethodPost,
		rootTarget+"/github/actions/create-issue",
		testToken,
		`{"input":{"title":"x"}}`,
	)
	require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
	invoker.mu.Lock()
	defer invoker.mu.Unlock()
	require.Equal(t, 1, invoker.calls)
	require.Equal(t, testWorkspace, invoker.request.WorkspaceID)
	require.Equal(t, testAgent, invoker.request.AgentID)
	require.Equal(t, testMember, invoker.request.AgentMemberID)
	require.Equal(t, "github", invoker.request.ExtensionName)
	require.Equal(t, "create-issue", invoker.request.Action)
	require.JSONEq(t, `{"title":"x"}`, string(invoker.request.Input))
}

func TestInvokeMapsClosedExecutionErrors(t *testing.T) {
	tests := []struct {
		code   extensionflow.InvocationErrorCode
		status int
	}{
		{extensionflow.InvocationValidation, http.StatusBadRequest},
		{extensionflow.InvocationPermissionDenied, http.StatusForbidden},
		{extensionflow.InvocationNotFound, http.StatusNotFound},
		{extensionflow.InvocationConflict, http.StatusConflict},
		{extensionflow.InvocationAgentCatalogStale, http.StatusConflict},
		{extensionflow.InvocationProviderAuthUnavailable, http.StatusServiceUnavailable},
		{extensionflow.InvocationRateLimited, http.StatusTooManyRequests},
		{extensionflow.InvocationUpstreamFailure, http.StatusBadGateway},
		{extensionflow.InvocationInternal, http.StatusInternalServerError},
	}
	for _, test := range tests {
		t.Run(string(test.code), func(t *testing.T) {
			invoker := &stubInvocationService{err: &extensionflow.InvocationError{
				Code: test.code, Message: "safe execution failure",
			}}
			router := newRouterWithInvoker(
				t,
				authenticator(),
				[]registry.RegisteredExtension{extensionWithBinding()},
				invoker,
			)
			rootTarget := strings.ReplaceAll(RuntimeBase, ":workspaceId", testWorkspace)
			rootTarget = strings.ReplaceAll(rootTarget, ":agentId", testAgent)
			recorder := doRaw(
				t,
				router,
				http.MethodPost,
				rootTarget+"/github/actions/create-issue",
				testToken,
				`{"input":{}}`,
			)
			require.Equal(t, test.status, recorder.Code, recorder.Body.String())
			require.Contains(t, recorder.Body.String(), `"`+string(test.code)+`"`)
			require.NotContains(t, recorder.Body.String(), "provider-secret")
		})
	}
}

// TestInvoke_UnknownActionIsNotFound asserts POST to an unknown action is 404,
// not the not-implemented stub, proving path identities resolve first.
func TestInvoke_UnknownActionIsNotFound(t *testing.T) {
	router := newExtensionRouter(t, authenticator())
	rootTarget := strings.ReplaceAll(RuntimeBase, ":workspaceId", testWorkspace)
	rootTarget = strings.ReplaceAll(rootTarget, ":agentId", testAgent)

	recorder := do(t, router, http.MethodPost, rootTarget+"/github/actions/missing", testToken,
		invokeRequest{Input: json.RawMessage(`{}`)})
	require.Equal(t, http.StatusNotFound, recorder.Code)
}

// TestInvoke_BodyReadAfterAuth asserts POST with a body but no token is 401 —
// authentication precedes body decoding (spec §6.2).
func TestInvoke_BodyReadAfterAuth(t *testing.T) {
	router := newExtensionRouter(t, authenticator())
	rootTarget := strings.ReplaceAll(RuntimeBase, ":workspaceId", testWorkspace)
	rootTarget = strings.ReplaceAll(rootTarget, ":agentId", testAgent)

	recorder := do(t, router, http.MethodPost, rootTarget+"/github/actions/create-issue", "",
		invokeRequest{Input: json.RawMessage(`{"title":"x"}`)})
	require.Equal(t, http.StatusUnauthorized, recorder.Code)
}

// TestUnknownDescendants_FailClosed asserts that descendants of the Extension
// runtime path beyond the four registered routes are not matched and fail
// closed with 404 (gin NoRoute), so caller-controlled path segments cannot
// discover an unregistered capability.
func TestUnknownDescendants_FailClosed(t *testing.T) {
	router := newExtensionRouter(t, authenticator())
	rootTarget := strings.ReplaceAll(RuntimeBase, ":workspaceId", testWorkspace)
	rootTarget = strings.ReplaceAll(rootTarget, ":agentId", testAgent)

	unknown := []string{
		rootTarget + "/github/actions/create-issue/extra",
		rootTarget + "/github/extra",
		rootTarget + "/github/actions",
	}
	for _, path := range unknown {
		recorder := do(t, router, http.MethodGet, path, testToken, nil)
		require.Equalf(t, http.StatusNotFound, recorder.Code, "unknown descendant %s must fail closed 404", path)
	}
}

// TestRegisterRoutes_NilAuthFailsClosed asserts that wiring without an
// authenticator fail-closes: every route rejects with 401 rather than serving
// unauthenticated traffic.
func TestRegisterRoutes_NilAuthFailsClosed(t *testing.T) {
	gin.SetMode(gin.ReleaseMode)
	router := gin.New()
	RegisterRoutes(router, Dependencies{
		Auth:    nil,
		Catalog: stubCatalog{extensions: []registry.RegisteredExtension{extensionWithBinding()}},
	})
	recorder := do(t, router, http.MethodGet, RuntimeBase, testToken, nil)
	require.Equal(t, http.StatusUnauthorized, recorder.Code)
}

// TestCatalog_EffectiveDomainProjection_MultiVersion is the adversarial
// effective-domain assertion (spec §6.2): when the Registry holds the same
// domain at v1 and v2, root uses one effective-domain projection and surfaces
// only the latest action content.
func TestCatalog_EffectiveDomainProjection_MultiVersion(t *testing.T) {
	v1 := extensionWithBinding()
	v2 := extensionWithBinding()
	v2.Definition.Version = 2
	v2.Definition.Digest = "sha256:v2-digest-marker"
	router := newRouterWithExtensions(t, authenticator(), []registry.RegisteredExtension{v1, v2})

	rootTarget := strings.ReplaceAll(RuntimeBase, ":workspaceId", testWorkspace)
	rootTarget = strings.ReplaceAll(rootTarget, ":agentId", testAgent)

	recorder := do(t, router, http.MethodGet, rootTarget, testToken, nil)
	require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
	var root rootResponse
	require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &root))
	require.Len(t, root.Extensions, 1, "root must project one entry per domain")
	require.Equal(t, "github", root.Extensions[0].ExtensionName)
	require.Len(t, root.Extensions[0].Actions, 2)
	require.NotContains(t, recorder.Body.String(), "v2-digest-marker")
}

// TestInvokePathIdentity_WhitespaceRejected asserts invocation domain/action
// identities are exact.
func TestPathIdentity_WhitespaceRejected(t *testing.T) {
	router := newExtensionRouter(t, authenticator())
	rootTarget := strings.ReplaceAll(RuntimeBase, ":workspaceId", testWorkspace)
	rootTarget = strings.ReplaceAll(rootTarget, ":agentId", testAgent)

	recorder := do(t, router, http.MethodPost, rootTarget+"/github%20/actions/create-issue", testToken, invokeRequest{Input: json.RawMessage(`{}`)})
	require.Equal(t, http.StatusNotFound, recorder.Code, "trailing-space domain must 404")

	recorder = do(t, router, http.MethodPost, rootTarget+"/github/actions/create-issue%20", testToken, invokeRequest{Input: json.RawMessage(`{}`)})
	require.Equal(t, http.StatusNotFound, recorder.Code, "trailing-space action must 404")

	require.Equal(t, http.StatusOK, do(t, router, http.MethodPost, rootTarget+"/github/actions/create-issue", testToken, invokeRequest{Input: json.RawMessage(`{}`)}).Code)
}

// TestInvoke_TrailingJSONRejected is the adversarial single-document assertion
// (spec §6.4): the invoke body must be exactly ONE JSON document; trailing JSON
// after the envelope is rejected with 400, not decoded to 501.
func TestInvoke_TrailingJSONRejected(t *testing.T) {
	router := newExtensionRouter(t, authenticator())
	rootTarget := strings.ReplaceAll(RuntimeBase, ":workspaceId", testWorkspace)
	rootTarget = strings.ReplaceAll(rootTarget, ":agentId", testAgent)

	body := `{"input":{"title":"x"}} {"extra":true}`
	recorder := doRaw(t, router, http.MethodPost, rootTarget+"/github/actions/create-issue", testToken, body)
	require.Equal(t, http.StatusBadRequest, recorder.Code, recorder.Body.String())
}

// TestInvoke_OversizeBodyRejected is the adversarial body-cap assertion (spec
// §6.4): an invoke body over the transport envelope cap is rejected with 413
// Payload Too Large after auth and before decode, not decoded to 501.
func TestInvoke_OversizeBodyRejected(t *testing.T) {
	router := newExtensionRouter(t, authenticator())
	rootTarget := strings.ReplaceAll(RuntimeBase, ":workspaceId", testWorkspace)
	rootTarget = strings.ReplaceAll(rootTarget, ":agentId", testAgent)

	padding := strings.Repeat("x", invokeEnvelopeCapBytes+1)
	body := `{"input":"` + padding + `"}`
	recorder := doRaw(t, router, http.MethodPost, rootTarget+"/github/actions/create-issue", testToken, body)
	require.Equal(t, http.StatusRequestEntityTooLarge, recorder.Code, recorder.Body.String())
}

// TestInvoke_InputAcceptsAnyJSONValue is the adversarial input-type assertion
// (spec §6.4): input is a json.RawMessage, so any valid JSON value (scalar,
// array, or object) is accepted losslessly and does not 400; schema validation
// is deferred to the execution stage. A valid path+envelope still reaches the
// wired invocation service (200 in this transport-only test).
func TestInvoke_InputAcceptsAnyJSONValue(t *testing.T) {
	router := newExtensionRouter(t, authenticator())
	rootTarget := strings.ReplaceAll(RuntimeBase, ":workspaceId", testWorkspace)
	rootTarget = strings.ReplaceAll(rootTarget, ":agentId", testAgent)
	target := rootTarget + "/github/actions/create-issue"

	for _, input := range []string{`"hello"`, `[1,2,3]`, `42`, `true`, `{"title":"x"}`} {
		body := `{"input":` + input + `}`
		recorder := doRaw(t, router, http.MethodPost, target, testToken, body)
		require.NotEqualf(t, http.StatusBadRequest, recorder.Code, "input %s must not be rejected as 400", input)
		require.Equalf(t, http.StatusOK, recorder.Code, "input %s must reach the invocation service, got %d", input, recorder.Code)
	}
}

// TestInvoke_StrayBracketRejected is the adversarial single-document assertion
// (spec §6.4): a valid envelope followed by a stray `]` or `}` must be rejected
// with 400, not slip through to the stub. decoder.More() only peeks for the next
// value-start token and treats a stray `]`/`}` as "no more", which would let
// `{envelope} ]` and `{envelope} }` bypass the boundary; the second-Decode EOF
// check catches them as malformed trailing input.
func TestInvoke_StrayBracketRejected(t *testing.T) {
	router := newExtensionRouter(t, authenticator())
	rootTarget := strings.ReplaceAll(RuntimeBase, ":workspaceId", testWorkspace)
	rootTarget = strings.ReplaceAll(rootTarget, ":agentId", testAgent)
	target := rootTarget + "/github/actions/create-issue"

	for _, body := range []string{
		`{"input":{"title":"x"}}]`,
		`{"input":{"title":"x"}}}`,
	} {
		recorder := doRaw(t, router, http.MethodPost, target, testToken, body)
		require.Equalf(t, http.StatusBadRequest, recorder.Code, "stray trailing bracket must be 400, body=%s", body)
	}
}

// TestInvoke_MissingRequiredFields is the adversarial canonical-envelope
// assertion (spec §6.4): input is required. Removed caller-supplied identities
// are rejected as unknown fields.
func TestInvoke_MissingRequiredFields(t *testing.T) {
	router := newExtensionRouter(t, authenticator())
	rootTarget := strings.ReplaceAll(RuntimeBase, ":workspaceId", testWorkspace)
	rootTarget = strings.ReplaceAll(rootTarget, ":agentId", testAgent)
	target := rootTarget + "/github/actions/create-issue"

	cases := []struct {
		name string
		body string
	}{
		{"missing input", `{}`},
		{"removed definitionDigest", `{"definitionDigest":"sha256:x","input":{}}`},
		{"removed catalogVersion", `{"catalogVersion":1,"input":{}}`},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			recorder := doRaw(t, router, http.MethodPost, target, testToken, tc.body)
			require.Equalf(t, http.StatusBadRequest, recorder.Code, "missing required field must be 400, body=%s", tc.body)
		})
	}
}
