package main

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"net/http/httptest"
	"strings"
	"sync/atomic"
	"testing"

	"github.com/stretchr/testify/require"

	"github.com/tidbcloud/AgentTeam/internal/extension/registry"
	"github.com/tidbcloud/AgentTeam/internal/gateway"
	"github.com/tidbcloud/AgentTeam/internal/router"
	"github.com/tidbcloud/AgentTeam/internal/service/extensionflow"
	"github.com/tidbcloud/AgentTeam/internal/store/workspace"
)

func TestRouteContractIncludesWorkflowHumanRoutesWithWorkspaceOwnership(t *testing.T) {
	document := generatedRouteContract(t)
	routes := indexRouteContract(document.Routes)

	expected := []struct {
		method string
		path   string
	}{
		// Plan aggregate, draft, validation, version, and lifecycle routes.
		{http.MethodGet, "/api/v1/workspaces/:workspaceId/workflow/plans"},
		{http.MethodPost, "/api/v1/workspaces/:workspaceId/workflow/plans"},
		{http.MethodPost, "/api/v1/workspaces/:workspaceId/workflow/plans:validate"},
		{http.MethodGet, "/api/v1/workspaces/:workspaceId/workflow/plans/:planId"},
		{http.MethodPatch, "/api/v1/workspaces/:workspaceId/workflow/plans/:planId"},
		{http.MethodPost, "/api/v1/workspaces/:workspaceId/workflow/plans/:planId/archive"},
		{http.MethodPost, "/api/v1/workspaces/:workspaceId/workflow/plans/:planId/restore"},
		{http.MethodGet, "/api/v1/workspaces/:workspaceId/workflow/plans/:planId/draft"},
		{http.MethodPut, "/api/v1/workspaces/:workspaceId/workflow/plans/:planId/draft"},
		{http.MethodPost, "/api/v1/workspaces/:workspaceId/workflow/plans/:planId/draft:validate"},
		{http.MethodPost, "/api/v1/workspaces/:workspaceId/workflow/plans/:planId/versions"},
		{http.MethodGet, "/api/v1/workspaces/:workspaceId/workflow/plans/:planId/versions"},
		{http.MethodGet, "/api/v1/workspaces/:workspaceId/workflow/plans/:planId/versions/:versionId"},
		{http.MethodPost, "/api/v1/workspaces/:workspaceId/workflow/plans/:planId/versions/:versionId/republish"},
		{http.MethodPost, "/api/v1/workspaces/:workspaceId/workflow/plans/:planId/versions/:versionId/make-default"},

		// Binding aggregate, revision, activation, and lifecycle routes.
		{http.MethodGet, "/api/v1/workspaces/:workspaceId/workflow/bindings"},
		{http.MethodPost, "/api/v1/workspaces/:workspaceId/workflow/bindings"},
		{http.MethodPost, "/api/v1/workspaces/:workspaceId/workflow/bindings:validate"},
		{http.MethodGet, "/api/v1/workspaces/:workspaceId/workflow/bindings/:bindingId"},
		{http.MethodPatch, "/api/v1/workspaces/:workspaceId/workflow/bindings/:bindingId"},
		{http.MethodPost, "/api/v1/workspaces/:workspaceId/workflow/bindings/:bindingId/disable"},
		{http.MethodPost, "/api/v1/workspaces/:workspaceId/workflow/bindings/:bindingId/archive"},
		{http.MethodPost, "/api/v1/workspaces/:workspaceId/workflow/bindings/:bindingId/restore"},
		{http.MethodPost, "/api/v1/workspaces/:workspaceId/workflow/bindings/:bindingId/revisions"},
		{http.MethodGet, "/api/v1/workspaces/:workspaceId/workflow/bindings/:bindingId/revisions"},
		{http.MethodGet, "/api/v1/workspaces/:workspaceId/workflow/bindings/:bindingId/revisions/:revision"},
		{http.MethodPost, "/api/v1/workspaces/:workspaceId/workflow/bindings/:bindingId/revisions/:revision/activate"},

		// Immutable Invocation acceptance and read routes.
		{http.MethodPost, "/api/v1/workspaces/:workspaceId/workflow/plans/:planId/invocations"},
		{http.MethodGet, "/api/v1/workspaces/:workspaceId/workflow/invocations"},
		{http.MethodGet, "/api/v1/workspaces/:workspaceId/workflow/invocations/:invocationId"},

		// Immutable Run observation and cancellation routes.
		{http.MethodGet, "/api/v1/workspaces/:workspaceId/workflow/runs"},
		{http.MethodGet, "/api/v1/workspaces/:workspaceId/workflow/runs/:runId"},
		{http.MethodGet, "/api/v1/workspaces/:workspaceId/workflow/runs/:runId/activations"},
		{http.MethodGet, "/api/v1/workspaces/:workspaceId/workflow/runs/:runId/effects"},
		{http.MethodGet, "/api/v1/workspaces/:workspaceId/workflow/runs/:runId/events"},
		{http.MethodPost, "/api/v1/workspaces/:workspaceId/workflow/runs/:runId/cancel"},
	}

	for _, route := range expected {
		assertWorkspaceRouteContract(t, routes, route.method, route.path)
	}
}

func TestRouteContractIncludesAgentRuntimeBusinessRoutesOnly(t *testing.T) {
	document := generatedRouteContract(t)
	routes := indexRouteContract(document.Routes)
	for _, object := range []string{"plan", "trigger", "run", "step"} {
		resolved := "/api/v1/workspaces/:workspaceId/agents/:agentId/runtime/workflow/" + object
		assertWorkspaceRouteContract(t, routes, http.MethodPost, resolved)
	}
	for _, route := range document.Routes {
		require.NotContains(t, route.Path, "/runtime/workflow/invocation", "internal Invocation route leaked into contract: %+v", route)
		require.NotContains(t, route.Path, "/runtime/workflow/binding", "internal Binding route leaked into contract: %+v", route)
		require.NotContains(t, route.Path, "/workflow/run/callbacks", "internal callback route leaked into contract: %+v", route)
	}
}

func TestRouteContractIncludesAgentRuntimePlanAndRunRoutesOnly(t *testing.T) {
	document := generatedRouteContract(t)
	routes := indexRouteContract(document.Routes)

	for _, object := range []string{"plan", "run"} {
		resolved := "/api/v1/workspaces/:workspaceId/agents/:agentId/runtime/workflow/" + object
		assertWorkspaceRouteContract(t, routes, http.MethodPost, resolved)
	}

	for _, route := range document.Routes {
		require.False(t,
			strings.Contains(route.Path, "/workflow-plans/tools/") || strings.Contains(route.Path, "/workflow-runs/tools/"),
			"removed Workflow /tools alias leaked into route contract: %+v", route,
		)
	}
}

func generatedRouteContract(t *testing.T) routeContractDocument {
	t.Helper()
	var output bytes.Buffer
	require.NoError(t, run(&output))

	var document routeContractDocument
	require.NoError(t, json.Unmarshal(output.Bytes(), &document))
	require.Equal(t, 1, document.Version)
	require.NotEmpty(t, document.Routes)
	return document
}

func indexRouteContract(routes []router.EdgeRouteContract) map[string]router.EdgeRouteContract {
	indexed := make(map[string]router.EdgeRouteContract, len(routes))
	for _, route := range routes {
		indexed[route.Profile+" "+route.Method+" "+route.Path] = route
	}
	return indexed
}

func assertWorkspaceRouteContract(t *testing.T, routes map[string]router.EdgeRouteContract, method string, path string) {
	t.Helper()
	key := string(router.ProfileWorkspace) + " " + method + " " + path
	route, found := routes[key]
	require.True(t, found, "missing route contract %s", key)
	require.Equal(t, router.EdgePolicyWorkspaceREST, route.Policy, key)

	centralKey := string(router.ProfileCentral) + " " + method + " " + path
	_, found = routes[centralKey]
	require.False(t, found, "workspace-owned route leaked into central contract: %s", centralKey)
}

// extensionRuntimeRoutePaths are the two Agent-visible Extension runtime route
// paths owned ONLY by ProfileExtension and the aggregate ProfileAll: one
// complete Catalog GET and one invocation POST.
var extensionRuntimeRoutePaths = []struct {
	method string
	path   string
}{
	{http.MethodGet, "/api/v1/workspaces/:workspaceId/agents/:agentId/runtime/ext"},
	{http.MethodPost, "/api/v1/workspaces/:workspaceId/agents/:agentId/runtime/ext/:extensionName/actions/:action"},
}

func extensionRouteContractDependencies() router.Dependencies {
	return router.Dependencies{
		Extension: router.ExtensionDependencies{
			Registry: registry.New(),
			Invoker:  extensionflow.New(nil, nil, nil, extensionflow.Options{}),
		},
	}
}

// routeKeys returns "METHOD path" keys for one router.
func routeKeys(t *testing.T, profile router.Profile) map[string]struct{} {
	t.Helper()
	engine := router.NewRouterForProfile(profile, extensionRouteContractDependencies())
	require.NotNil(t, engine)
	out := make(map[string]struct{}, len(engine.Routes()))
	for _, route := range engine.Routes() {
		out[route.Method+" "+route.Path] = struct{}{}
	}
	return out
}

// TestExtensionRuntimeRoutesOwnedOnlyByExtensionAndAll is the route-contract CI
// assertion (spec §7): the two /runtime/ext routes belong to ProfileExtension
// and to aggregate ProfileAll, and are absent from ProfileCentral and
// ProfileWorkspace.
func TestExtensionRuntimeRoutesOwnedOnlyByExtensionAndAll(t *testing.T) {
	extensionRoutes := routeKeys(t, router.ProfileExtension)
	allRoutes := routeKeys(t, router.ProfileAll)
	centralRoutes := routeKeys(t, router.ProfileCentral)
	workspaceRoutes := routeKeys(t, router.ProfileWorkspace)

	for _, route := range extensionRuntimeRoutePaths {
		key := route.method + " " + route.path
		require.Containsf(t, extensionRoutes, key, "extension profile must own %s", key)
		require.Containsf(t, allRoutes, key, "all profile must own %s", key)
		require.NotContainsf(t, centralRoutes, key, "central profile must NOT own %s", key)
		require.NotContainsf(t, workspaceRoutes, key, "workspace profile must NOT own %s", key)
	}
}

// TestExtensionProfileOwnsNoWorkspaceOrControlRoutes asserts the extension
// profile router exposes no Workspace, control, daemon, or LLM routes — only the
// Extension runtime routes plus common health/observability.
func TestExtensionProfileOwnsNoWorkspaceOrControlRoutes(t *testing.T) {
	extensionRoutes := routeKeys(t, router.ProfileExtension)

	for _, route := range extensionRuntimeRoutePaths {
		require.Contains(t, extensionRoutes, route.method+" "+route.path)
	}
	for _, forbidden := range []string{
		"GET /api/v1/workspaces/:workspaceId/agents/:agentId/runtime/workspace",
		"POST /api/v1/workspaces/:workspaceId/agents/:agentId/runtime/send",
		"GET /daemon",
		"POST /api/llm/proxy",
		"GET /api/v1/workspaces/:workspaceId/workflow/plans",
		"POST /api/auth/login",
	} {
		require.NotContainsf(t, extensionRoutes, forbidden, "extension profile must NOT own %s", forbidden)
	}
}

// routeContractGatewayResolver and routeContractIdentityResolver are inert
// fakes so the route-contract CI can build a real edge Gateway Handler and
// assert Extension classification through the exported surface. They are never
// reached by Extension routes (the Extension server re-authenticates), and they
// intentionally fail if a workspace route somehow invokes them.
type routeContractGatewayResolver struct{}

func (routeContractGatewayResolver) ResolveWorkspaceRouteTarget(context.Context, string) (workspace.WorkspaceRouteTarget, error) {
	return workspace.WorkspaceRouteTarget{}, fmt.Errorf("route contract workspace resolver is inert")
}

type routeContractIdentityResolver struct{}

func (routeContractIdentityResolver) ResolveWorkspaceIdentity(context.Context, gateway.WorkspaceIdentityRequest) (gateway.RouteIdentity, error) {
	return gateway.RouteIdentity{}, fmt.Errorf("route contract identity resolver is inert")
}

// newRouteContractGateway builds a real edge Gateway Handler with three tracking
// upstreams so the route-contract CI can assert which upstream each route
// reaches. It returns the handler and the three hit counters.
func newRouteContractGateway(t *testing.T, extensionUpstream string) (*gateway.Handler, *int32, *int32, *int32) {
	t.Helper()
	var centralHits, workspaceHits, extensionHits int32
	central := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		atomic.AddInt32(&centralHits, 1)
		w.WriteHeader(http.StatusOK)
	}))
	t.Cleanup(central.Close)
	ws := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		atomic.AddInt32(&workspaceHits, 1)
		w.WriteHeader(http.StatusOK)
	}))
	t.Cleanup(ws.Close)
	ext := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		atomic.AddInt32(&extensionHits, 1)
		w.WriteHeader(http.StatusOK)
	}))
	t.Cleanup(ext.Close)

	upstream := extensionUpstream
	if upstream == "" {
		upstream = ext.URL
	}
	handler, err := gateway.NewHandler(routeContractGatewayResolver{}, gateway.Config{
		CentralUpstream:    central.URL,
		WorkspaceUpstreams: map[string]string{"workspace-default": ws.URL},
		IdentityResolver:   routeContractIdentityResolver{},
		ExtensionUpstream:  upstream,
	})
	require.NoError(t, err)
	return handler, &centralHits, &workspaceHits, &extensionHits
}

// concreteExtensionRoutes are the four Agent-visible Extension runtime routes
// with concrete path identities (the gateway matches real paths, not gin params).
var concreteExtensionRoutes = []struct {
	method string
	path   string
}{
	{http.MethodGet, "/api/v1/workspaces/wsp_1/agents/agt_1/runtime/ext"},
	{http.MethodGet, "/api/v1/workspaces/wsp_1/agents/agt_1/runtime/ext/github"},
	{http.MethodGet, "/api/v1/workspaces/wsp_1/agents/agt_1/runtime/ext/github/actions/create-issue"},
	{http.MethodPost, "/api/v1/workspaces/wsp_1/agents/agt_1/runtime/ext/github/actions/create-issue"},
}

// TestGatewayClassifiesEveryExtensionRouteAsExtension is the route-contract CI
// assertion (spec §7): the Gateway classifies every valid Extension route into
// the Extension family, so each reaches ONLY the Extension upstream and never
// the central or workspace upstream.
func TestGatewayClassifiesEveryExtensionRouteAsExtension(t *testing.T) {
	t.Parallel()
	handler, centralHits, workspaceHits, extensionHits := newRouteContractGateway(t, "")

	for _, route := range concreteExtensionRoutes {
		recorder := httptest.NewRecorder()
		handler.ServeHTTP(recorder, httptest.NewRequest(route.method, route.path, nil))
		require.Equalf(t, http.StatusOK, recorder.Code, "%s %s", route.method, route.path)
	}
	require.Equal(t, int32(len(concreteExtensionRoutes)), atomic.LoadInt32(extensionHits), "extension upstream must receive all extension routes")
	require.Zero(t, atomic.LoadInt32(centralHits), "central upstream must never receive extension routes")
	require.Zero(t, atomic.LoadInt32(workspaceHits), "workspace upstream must never receive extension routes")
}

// TestGatewayFailsClosedOnUnknownExtensionDescendants is the route-contract CI
// assertion (spec §7): unknown Extension descendants fail closed at the Gateway
// and never reach any upstream (especially not the workspace family, which would
// otherwise steal the /agents/ prefix).
func TestGatewayFailsClosedOnUnknownExtensionDescendants(t *testing.T) {
	t.Parallel()
	handler, centralHits, workspaceHits, extensionHits := newRouteContractGateway(t, "")

	unknown := []struct {
		method string
		path   string
	}{
		{http.MethodGet, "/api/v1/workspaces/wsp_1/agents/agt_1/runtime/ext/github/actions/create-issue/bogus"},
		{http.MethodGet, "/api/v1/workspaces/wsp_1/agents/agt_1/runtime/ext/github/foo"},
		{http.MethodPut, "/api/v1/workspaces/wsp_1/agents/agt_1/runtime/ext"},
		{http.MethodDelete, "/api/v1/workspaces/wsp_1/agents/agt_1/runtime/ext/github/actions/create-issue"},
	}
	for _, route := range unknown {
		recorder := httptest.NewRecorder()
		handler.ServeHTTP(recorder, httptest.NewRequest(route.method, route.path, nil))
		require.Equalf(t, http.StatusNotFound, recorder.Code, "%s %s must fail closed", route.method, route.path)
	}
	require.Zero(t, atomic.LoadInt32(extensionHits), "unknown descendants must not reach extension upstream")
	require.Zero(t, atomic.LoadInt32(centralHits), "unknown descendants must not reach central upstream")
	require.Zero(t, atomic.LoadInt32(workspaceHits), "unknown descendants must NEVER fall through to workspace upstream")
}

// TestGatewayNeverExposesExtensionUpstreamURLInErrors is the route-contract CI
// diagnostic-redaction assertion (spec §7): when the Extension upstream is
// unreachable, neither the proxied request error nor readiness may expose the
// internal upstream URL.
func TestGatewayNeverExposesExtensionUpstreamURLInErrors(t *testing.T) {
	t.Parallel()
	const unreachable = "http://127.0.0.1:9"
	handler, _, _, _ := newRouteContractGateway(t, unreachable)

	recorder := httptest.NewRecorder()
	handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/workspaces/wsp_1/agents/agt_1/runtime/ext", nil))
	require.Equal(t, http.StatusBadGateway, recorder.Code)
	require.NotContains(t, recorder.Body.String(), "127.0.0.1:9")
	require.NotContains(t, recorder.Body.String(), unreachable)

	readyRecorder := httptest.NewRecorder()
	handler.ServeHTTP(readyRecorder, httptest.NewRequest(http.MethodGet, "/readyz", nil))
	require.Equal(t, http.StatusServiceUnavailable, readyRecorder.Code)
	require.NotContains(t, readyRecorder.Body.String(), "127.0.0.1:9")
	require.NotContains(t, readyRecorder.Body.String(), unreachable)
}

// TestGatewayInteriorSlashNoiseOwnedByExtensionNoSpill is the route-contract CI
// assertion for the systematic ownership normalization (spec §7, stage-4
// round-3): interior slash noise that still preserves the resolved keyword
// spine api/v1/workspaces/{ws}/agents/{agent}/runtime/ext after collapsing is
// owned by the Extension namespace and fails closed (404). Each variant must
// reach ZERO upstreams — not central (which matches the "/api/v1/workspaces/"
// prefix after its own view), not workspace (whose workspaceV1IMRoute recognizes
// "/agents"), and not extension.
//
// Slash noise that would collapse a literal keyword away (/workspaces/ws//
// runtime/ext has no agents slot; /workspaces/ws/agents/agent//ext has no runtime
// keyword) is NOT Extension namespace and is NOT claimed here: collapseSlashes
// removes only empty segments, never a keyword, so those variants collapse to
// workspace/central routes.
func TestGatewayInteriorSlashNoiseOwnedByExtensionNoSpill(t *testing.T) {
	t.Parallel()
	handler, centralHits, workspaceHits, extensionHits := newRouteContractGateway(t, "")

	interiorSlashNoise := []string{
		"/api/v1//workspaces/ws/agents/agent/runtime/ext", // slash before workspaces
		"/api/v1/workspaces//agents/agent/runtime/ext",    // slash before agents (empty ws)
		"/api/v1/workspaces/ws/agents/agent/runtime//ext", // slash before ext (after runtime)
		"/api//v1/workspaces/ws/agents/agent/runtime/ext", // slash between api/v1
	}
	for _, path := range interiorSlashNoise {
		recorder := httptest.NewRecorder()
		handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil))
		require.Equalf(t, http.StatusNotFound, recorder.Code,
			"interior slash noise %q must fail closed as 404", path)
	}
	require.Zero(t, atomic.LoadInt32(extensionHits), "interior slash noise must not reach extension upstream")
	require.Zero(t, atomic.LoadInt32(centralHits), "interior slash noise must NEVER spill to central upstream")
	require.Zero(t, atomic.LoadInt32(workspaceHits), "interior slash noise must NEVER spill to workspace upstream")
}
