package router_test

import (
	"context"
	"crypto/sha256"
	"encoding/base64"
	"io"
	"log/slog"
	"net/http"
	"net/http/httptest"
	"os"
	"path/filepath"
	"testing"

	"github.com/stretchr/testify/require"

	"github.com/tidbcloud/AgentTeam/internal/api"
	"github.com/tidbcloud/AgentTeam/internal/api/extension"
	"github.com/tidbcloud/AgentTeam/internal/extension/providers/arxiv"
	"github.com/tidbcloud/AgentTeam/internal/extension/registry"
	"github.com/tidbcloud/AgentTeam/internal/forwarder"
	"github.com/tidbcloud/AgentTeam/internal/router"
	"github.com/tidbcloud/AgentTeam/internal/service/extensionflow"
	"github.com/tidbcloud/AgentTeam/internal/store/agent"
	"github.com/tidbcloud/AgentTeam/internal/workspace/owner"
)

// extensionRuntimeRoutes are the two Agent-visible Extension runtime routes:
// one complete Catalog GET and one invocation POST. They belong ONLY to
// ProfileExtension and the aggregate ProfileAll.
var extensionRuntimeRoutes = []string{
	"GET /api/v1/workspaces/:workspaceId/agents/:agentId/runtime/ext",
	"POST /api/v1/workspaces/:workspaceId/agents/:agentId/runtime/ext/:extensionName/actions/:action",
}

func extensionProfileDependencies() router.Dependencies {
	logger := slog.New(slog.NewTextHandler(io.Discard, nil))
	return router.Dependencies{
		Logger: logger,
		Extension: router.ExtensionDependencies{
			Registry: registry.New(),
			Invoker:  extensionflow.New(nil, nil, nil, extensionflow.Options{}),
		},
	}
}

type extensionProfileAuthenticator struct{}

func (extensionProfileAuthenticator) AuthenticateAgent(
	_ context.Context,
	agentID string,
	bearerToken string,
) (agent.AuthenticatedAgent, error) {
	if agentID != "ag_1" || bearerToken != "agent-token" {
		return agent.AuthenticatedAgent{}, agent.ErrUnauthorized
	}
	return agent.AuthenticatedAgent{
		AgentID:     agentID,
		WorkspaceID: "ws_1",
	}, nil
}

type extensionProfileAccess struct {
	domain extension.AgentDomainAccess
}

func (access extensionProfileAccess) ActiveAgentDomains(
	context.Context,
	string,
	string,
) ([]extension.AgentDomainAccess, error) {
	return []extension.AgentDomainAccess{access.domain}, nil
}

type extensionProfileExecutor struct{}

func (extensionProfileExecutor) Strategy() string { return "test" }

func (extensionProfileExecutor) Invoke(
	context.Context,
	registry.BuiltinInvocation,
) (registry.BuiltinResult, error) {
	return registry.BuiltinResult{}, nil
}

// TestExtensionCatalogIdentityETagBypassesGzip exercises the production router
// middleware chain, not a handler-only router. The Catalog's strong ETag names
// the exact response bytes, so a gzip-capable request must still receive the
// identity representation and the matching 304 must advertise the same Vary.
func TestExtensionCatalogIdentityETagBypassesGzip(t *testing.T) {
	t.Parallel()
	extensionRegistry := registry.New()
	registered, err := extensionRegistry.RegisterBuiltin(
		arxiv.Definition(),
		extensionProfileExecutor{},
		arxiv.Revision,
	)
	require.NoError(t, err)
	actionNames := make([]string, 0, len(registered.Definition.Actions))
	for _, action := range registered.Definition.Actions {
		actionNames = append(actionNames, action.Name)
	}

	dependencies := extensionProfileDependencies()
	dependencies.Agent.Auth = extensionProfileAuthenticator{}
	dependencies.Extension.Registry = extensionRegistry
	dependencies.Extension.Access = extensionProfileAccess{
		domain: extension.AgentDomainAccess{
			Domain:            registered.Definition.Domain,
			DefinitionVersion: registered.Definition.Version,
			DefinitionDigest:  registered.Definition.Digest,
			ExtensionName:     registered.Definition.Domain,
			Actions:           actionNames,
		},
	}
	httpRouter := router.NewRouterForProfile(
		router.ProfileExtension,
		dependencies,
	)
	target := "/api/v1/workspaces/ws_1/agents/ag_1/runtime/ext"

	firstRequest := httptest.NewRequest(http.MethodGet, target, nil)
	firstRequest.Header.Set("Authorization", "Bearer agent-token")
	firstRequest.Header.Set("Accept-Encoding", "gzip")
	first := httptest.NewRecorder()
	httpRouter.ServeHTTP(first, firstRequest)

	require.Equal(t, http.StatusOK, first.Code, first.Body.String())
	require.Empty(t, first.Header().Get("Content-Encoding"))
	require.Greater(t, first.Body.Len(), 1024)
	require.Equal(t, []string{"Authorization"}, first.Header().Values("Vary"))
	require.Equal(
		t,
		"private, no-cache, no-transform",
		first.Header().Get("Cache-Control"),
	)
	etag := first.Header().Get("ETag")
	sum := sha256.Sum256(first.Body.Bytes())
	require.Equal(
		t,
		`"ext-v1-`+base64.RawURLEncoding.EncodeToString(sum[:])+`"`,
		etag,
	)

	secondRequest := httptest.NewRequest(http.MethodGet, target, nil)
	secondRequest.Header.Set("Authorization", "Bearer agent-token")
	secondRequest.Header.Set("Accept-Encoding", "gzip")
	secondRequest.Header.Set("If-None-Match", etag)
	second := httptest.NewRecorder()
	httpRouter.ServeHTTP(second, secondRequest)

	require.Equal(t, http.StatusNotModified, second.Code)
	require.Empty(t, second.Body.String())
	require.Empty(t, second.Header().Get("Content-Encoding"))
	require.Equal(t, first.Header().Values("Vary"), second.Header().Values("Vary"))
	require.Equal(t, first.Header().Get("Cache-Control"), second.Header().Get("Cache-Control"))
	require.Equal(t, etag, second.Header().Get("ETag"))
}

// TestExtensionRoutes_RegisteredOnlyUnderExtensionAndAll is the core route
// ownership assertion (spec §7): the two /runtime/ext routes are registered by
// ProfileExtension and by the aggregate ProfileAll, and are NOT registered by
// ProfileCentral or ProfileWorkspace.
func TestExtensionRoutes_RegisteredOnlyUnderExtensionAndAll(t *testing.T) {
	t.Parallel()
	dependencies := extensionProfileDependencies()

	extensionCounts := routeCounts(router.NewRouterForProfile(router.ProfileExtension, dependencies))
	allCounts := routeCounts(router.NewRouterForProfile(router.ProfileAll, dependencies))
	centralCounts := routeCounts(router.NewRouterForProfile(router.ProfileCentral, dependencies))
	workspaceCounts := routeCounts(router.NewRouterForProfile(router.ProfileWorkspace, dependencies))

	for _, route := range extensionRuntimeRoutes {
		require.Equalf(t, 1, extensionCounts[route], "extension profile must register %s", route)
		require.Equalf(t, 1, allCounts[route], "all profile must register %s", route)
		require.Zerof(t, centralCounts[route], "central profile must NOT register %s", route)
		require.Zerof(t, workspaceCounts[route], "workspace profile must NOT register %s", route)
	}
}

// TestExtensionProfile_RegistersOnlyExtensionAndHealthRoutes asserts the
// extension profile is a narrow runtime (spec §6.1): it registers the two ext
// routes plus the common health routes, and does NOT register representative
// Web/IM/daemon/LLM/workflow/Workspace routes.
func TestExtensionProfile_RegistersOnlyExtensionAndHealthRoutes(t *testing.T) {
	t.Parallel()
	counts := routeCounts(router.NewRouterForProfile(router.ProfileExtension, extensionProfileDependencies()))

	// Extension runtime + common health routes are present.
	for _, route := range extensionRuntimeRoutes {
		require.Equalf(t, 1, counts[route], "expected extension route %s", route)
	}
	require.Equal(t, 1, counts["GET /healthz"])
	require.Equal(t, 1, counts["GET /readyz"])

	// Workspace / runtime-compatibility / control routes MUST be absent.
	for _, route := range []string{
		// Workspace Agent runtime (IM-style).
		"GET /api/v1/workspaces/:workspaceId/agents/:agentId/runtime/workspace",
		"POST /api/v1/workspaces/:workspaceId/agents/:agentId/runtime/send",
		// Daemon WebSocket.
		"GET /daemon",
		// LLM.
		"POST /api/llm/proxy",
		// Workflow human routes (workspace REST).
		"GET /api/v1/workspaces/:workspaceId/workflow/plans",
		// Control-plane auth.
		"POST /api/auth/login",
	} {
		require.Zerof(t, counts[route], "extension profile must NOT register non-extension route %s", route)
	}
}

// TestUnknownProfile_FailClosedRegistersNoDomainRoutes asserts that an unknown
// profile registers NO domain routes — only the common health/observability
// routes that NewRouterForProfile always installs. This prevents a typo or
// future profile from accidentally inheriting the full route set.
func TestUnknownProfile_FailClosedRegistersNoDomainRoutes(t *testing.T) {
	t.Parallel()
	counts := routeCounts(router.NewRouterForProfile(router.Profile("bogus-unknown"), extensionProfileDependencies()))

	require.Equal(t, 1, counts["GET /healthz"])
	require.Equal(t, 1, counts["GET /readyz"])
	for _, route := range extensionRuntimeRoutes {
		require.Zerof(t, counts[route], "unknown profile must NOT register %s", route)
	}
	require.Zerof(t, counts["POST /api/auth/login"], "unknown profile must NOT register control routes")
}

// TestValidateForProfile_ExtensionRequiresAuthRegistryAndInvoker asserts the extension
// profile's hard dependencies are the Agent Runtime authenticator and the
// Extension Registry (plus common health deps) — and that control/workspace/
// runtime-compatibility deps are NOT required.
func TestValidateForProfile_ExtensionRequiresAuthRegistryAndInvoker(t *testing.T) {
	t.Parallel()
	logger := slog.New(slog.NewTextHandler(io.Discard, nil))

	// Empty extension deps: must report Agent.Auth and Extension.Registry (and
	// common health), but NOT workspace/control/runtime deps.
	empty := router.Dependencies{Logger: logger}
	err := empty.ValidateForProfile(router.ProfileExtension)
	require.Error(t, err)
	require.Contains(t, err.Error(), "Agent.Auth")
	require.Contains(t, err.Error(), "Extension.Registry")
	require.Contains(t, err.Error(), "Extension.Invoker")
	require.Contains(t, err.Error(), "Health.CoreDB")
	require.NotContains(t, err.Error(), "API.Workspace")
	require.NotContains(t, err.Error(), "Daemon.Hub")
	require.NotContains(t, err.Error(), "Agent.WorkflowPlans")

	// Supplying the Registry leaves Agent.Auth (and common health) missing, and
	// still does not require workspace/control deps.
	withRegistry := router.Dependencies{Logger: logger, Extension: router.ExtensionDependencies{Registry: registry.New()}}
	err = withRegistry.ValidateForProfile(router.ProfileExtension)
	require.Error(t, err)
	require.Contains(t, err.Error(), "Agent.Auth")
	require.NotContains(t, err.Error(), "Extension.Registry")
	require.Contains(t, err.Error(), "Extension.Invoker")
	require.NotContains(t, err.Error(), "API.Workspace")
}

// TestExtensionProfile_NeverServesSPA is the adversarial profile-isolation
// assertion (spec §6.1): the Extension profile MUST NOT serve the web SPA via
// NoRoute, REGARDLESS of the Web config the caller supplied. Isolation is
// enforced by the router profile itself, so even ServeWeb=true (with a real dist
// dir) falls back to the JSON 404 handler rather than shipping the SPA HTML.
func TestExtensionProfile_NeverServesSPA(t *testing.T) {
	t.Parallel()
	distDir := t.TempDir()
	require.NoError(t, os.WriteFile(filepath.Join(distDir, "index.html"), []byte("<html>agentteam app</html>"), 0o644))

	dependencies := extensionProfileDependencies()
	dependencies.Web = router.WebDependencies{ServeWeb: true, DistDir: distDir}
	httpRouter := router.NewRouterForProfile(router.ProfileExtension, dependencies)

	response := httptest.NewRecorder()
	httpRouter.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/channels/general", nil))
	require.Equal(t, http.StatusNotFound, response.Code, "extension profile must not serve the SPA")
	require.NotContains(t, response.Body.String(), "agentteam app")
	require.Contains(t, response.Header().Get("Content-Type"), "application/json")
}

// TestExtensionProfile_NeverForwardsToAPIForwarder is the adversarial
// profile-isolation assertion (spec §6.1): when APIForwarder is configured, the
// Extension profile's NoRoute handler MUST still return a JSON 404 and MUST NOT
// delegate to the global smart-HTTP forwarder for AGS git paths
// (/{owner}/{repo}.git/...). The Extension profile is isolated from the
// APIForwarder regardless of its config.
func TestExtensionProfile_NeverForwardsToAPIForwarder(t *testing.T) {
	t.Parallel()
	upstreamReached := false
	upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		upstreamReached = true
		w.WriteHeader(http.StatusOK)
		_, _ = w.Write([]byte("UPSTREAM-REACHED"))
	}))
	defer upstream.Close()

	dependencies := extensionProfileDependencies()
	// Configure APIForwarder against a reachable upstream and an all-owning
	// managed set plus the route-workspace header, so the smart-HTTP wrapper
	// WOULD forward the git path to the upstream if it were active for this
	// profile. Profile isolation is the only thing preventing the forward.
	dependencies.ManagedWorkspaces = owner.NewAllSet()
	dependencies.API.APIForwarder = forwarder.Dependencies{BaseURL: upstream.URL}
	server := httptest.NewServer(router.NewRouterForProfile(router.ProfileExtension, dependencies))
	defer server.Close()

	request, err := http.NewRequest(http.MethodGet, server.URL+"/acme/repo.git/info/refs", nil)
	require.NoError(t, err)
	request.Header.Set(api.HeaderRouteWorkspaceID, "ws_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.StatusNotFound, response.StatusCode, "extension profile must return JSON 404, not forward")
	require.False(t, upstreamReached, "extension profile must not reach the APIForwarder upstream")
	require.Contains(t, string(body), "not found")
	require.NotContains(t, string(body), "UPSTREAM-REACHED")
}

// TestExtensionProfile_NeverServesSPA_NeverForwards is the combined stage-3
// round-3 adversarial assertion: ProfileExtension with Web.ServeWeb=true AND a
// configured, reachable APIForwarder upstream MUST return a JSON 404 for every
// unmatched path — neither the SPA HTML nor the upstream response may leak, and
// both isolation vectors must hold simultaneously. The SPA-only and forward-only
// aspects are also pinned individually by TestExtensionProfile_NeverServesSPA
// and TestExtensionProfile_NeverForwardsToAPIForwarder.
func TestExtensionProfile_NeverServesSPA_NeverForwards(t *testing.T) {
	t.Parallel()
	distDir := t.TempDir()
	require.NoError(t, os.WriteFile(filepath.Join(distDir, "index.html"), []byte("<html>agentteam app</html>"), 0o644))

	upstreamReached := false
	upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		upstreamReached = true
		w.WriteHeader(http.StatusOK)
		_, _ = w.Write([]byte("UPSTREAM-REACHED"))
	}))
	defer upstream.Close()

	dependencies := extensionProfileDependencies()
	dependencies.Web = router.WebDependencies{ServeWeb: true, DistDir: distDir}
	// Configure APIForwarder against a reachable upstream plus an all-owning
	// managed set, so the smart-HTTP wrapper WOULD forward a git path to the
	// upstream if it were active for this profile. Profile isolation is the only
	// thing preventing the forward.
	dependencies.ManagedWorkspaces = owner.NewAllSet()
	dependencies.API.APIForwarder = forwarder.Dependencies{BaseURL: upstream.URL}
	server := httptest.NewServer(router.NewRouterForProfile(router.ProfileExtension, dependencies))
	defer server.Close()

	// Non-git unmatched path: MUST be a JSON 404, NOT the SPA HTML.
	spaResponse, err := server.Client().Get(server.URL + "/channels/general")
	require.NoError(t, err)
	spaBody, err := io.ReadAll(spaResponse.Body)
	require.NoError(t, err)
	spaResponse.Body.Close()
	require.Equal(t, http.StatusNotFound, spaResponse.StatusCode, "extension profile must not serve the SPA")
	require.NotContains(t, string(spaBody), "agentteam app", "extension profile must not serve SPA HTML")
	require.Contains(t, string(spaBody), "not found")

	// AGS Smart-HTTP git path: MUST be a JSON 404, MUST NOT reach the upstream.
	gitRequest, err := http.NewRequest(http.MethodGet, server.URL+"/acme/repo.git/info/refs", nil)
	require.NoError(t, err)
	gitRequest.Header.Set(api.HeaderRouteWorkspaceID, "ws_1")
	gitResponse, err := server.Client().Do(gitRequest)
	require.NoError(t, err)
	gitBody, err := io.ReadAll(gitResponse.Body)
	require.NoError(t, err)
	gitResponse.Body.Close()
	require.Equal(t, http.StatusNotFound, gitResponse.StatusCode, "extension profile must return JSON 404, not forward")
	require.False(t, upstreamReached, "extension profile must not reach the APIForwarder upstream")
	require.Contains(t, string(gitBody), "not found")
	require.NotContains(t, string(gitBody), "UPSTREAM-REACHED")
}

// TestUnknownProfile_NeverServesSPA_NeverForwards is the stage-3 round-3
// adversarial assertion that the NoRoute handler is a CLOSED ALLOWLIST. An
// unknown profile with Web.ServeWeb=true AND a configured, reachable
// APIForwarder upstream MUST still return a JSON 404 for every unmatched path:
// it MUST NOT serve the SPA, and it MUST NOT forward AGS Smart-HTTP git paths to
// the upstream. Under the round-2 denylist this leaked (only ProfileExtension
// was special-cased, so the default case still served SPA and forwarded); the
// closed allowlist fails the unknown profile closed regardless of config.
func TestUnknownProfile_NeverServesSPA_NeverForwards(t *testing.T) {
	t.Parallel()
	distDir := t.TempDir()
	require.NoError(t, os.WriteFile(filepath.Join(distDir, "index.html"), []byte("<html>agentteam app</html>"), 0o644))

	upstreamReached := false
	upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		upstreamReached = true
		w.WriteHeader(http.StatusOK)
		_, _ = w.Write([]byte("UPSTREAM-REACHED"))
	}))
	defer upstream.Close()

	dependencies := extensionProfileDependencies()
	dependencies.Web = router.WebDependencies{ServeWeb: true, DistDir: distDir}
	dependencies.ManagedWorkspaces = owner.NewAllSet()
	dependencies.API.APIForwarder = forwarder.Dependencies{BaseURL: upstream.URL}
	server := httptest.NewServer(router.NewRouterForProfile(router.Profile("bogus-unknown"), dependencies))
	defer server.Close()

	// Non-git unmatched path: MUST be a JSON 404, NOT the SPA HTML.
	spaResponse, err := server.Client().Get(server.URL + "/channels/general")
	require.NoError(t, err)
	spaBody, err := io.ReadAll(spaResponse.Body)
	require.NoError(t, err)
	spaResponse.Body.Close()
	require.Equal(t, http.StatusNotFound, spaResponse.StatusCode, "unknown profile must not serve the SPA")
	require.NotContains(t, string(spaBody), "agentteam app", "unknown profile must not serve SPA HTML")
	require.Contains(t, string(spaBody), "not found")

	// AGS Smart-HTTP git path: MUST be a JSON 404, MUST NOT reach the upstream.
	gitRequest, err := http.NewRequest(http.MethodGet, server.URL+"/acme/repo.git/info/refs", nil)
	require.NoError(t, err)
	gitRequest.Header.Set(api.HeaderRouteWorkspaceID, "ws_1")
	gitResponse, err := server.Client().Do(gitRequest)
	require.NoError(t, err)
	gitBody, err := io.ReadAll(gitResponse.Body)
	require.NoError(t, err)
	gitResponse.Body.Close()
	require.Equal(t, http.StatusNotFound, gitResponse.StatusCode, "unknown profile must return JSON 404, not forward")
	require.False(t, upstreamReached, "unknown profile must not reach the APIForwarder upstream")
	require.Contains(t, string(gitBody), "not found")
	require.NotContains(t, string(gitBody), "UPSTREAM-REACHED")
}

// TestCentralProfile_SPAStillWorks pins the browser fallback while ensuring
// protocol and AGS compatibility namespaces never become SPA responses or
// reach the configured APIForwarder in a split profile.
func TestCentralProfile_SPAStillWorks(t *testing.T) {
	t.Parallel()
	distDir := t.TempDir()
	require.NoError(t, os.WriteFile(filepath.Join(distDir, "index.html"), []byte("<html>agentteam app</html>"), 0o644))

	upstreamReached := false
	upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		upstreamReached = true
		w.WriteHeader(http.StatusOK)
		_, _ = w.Write([]byte("UPSTREAM-REACHED"))
	}))
	defer upstream.Close()

	dependencies := router.Dependencies{
		Logger:            slog.New(slog.NewTextHandler(io.Discard, nil)),
		Web:               router.WebDependencies{ServeWeb: true, DistDir: distDir},
		ManagedWorkspaces: owner.NewAllSet(),
		API: router.APIDependencies{
			APIForwarder: forwarder.Dependencies{BaseURL: upstream.URL},
		},
	}
	server := httptest.NewServer(router.NewRouterForProfile(router.ProfileCentral, dependencies))
	defer server.Close()

	// Non-git unmatched browser path: SPA HTML is served (allowlist positive case).
	for _, path := range []string{"/channels/general", "/login"} {
		spaResponse, err := server.Client().Get(server.URL + path)
		require.NoError(t, err)
		spaBody, err := io.ReadAll(spaResponse.Body)
		require.NoError(t, err)
		spaResponse.Body.Close()
		require.Equal(t, http.StatusOK, spaResponse.StatusCode, path)
		require.Contains(t, string(spaBody), "agentteam app", path)
	}

	for _, item := range []struct{ method, path string }{
		{http.MethodGet, "/api/v3"},
		{http.MethodGet, "/api/v3/"},
		{http.MethodGet, "/api/v3/openapi.json"},
		{http.MethodGet, "/api/ext/v1"},
		{http.MethodGet, "/api/ext/v1/"},
		{http.MethodGet, "/api/ext/v1/openapi.json"},
		{http.MethodPost, "/api/v3/openapi.json"},
		{http.MethodGet, "/login/device/code"},
		{http.MethodGet, "/login/oauth/authorize"},
		{http.MethodGet, "/auth/connected/login"},
		{http.MethodGet, "/avatars/alice"},
		{http.MethodGet, "/acme/repo/raw/main/README.md"},
		{http.MethodGet, "/acme/repo/raw"},
		{http.MethodGet, "/acme/repo.git"},
		{http.MethodGet, "/acme/repo.git/"},
		{http.MethodGet, "/acme/repo.git/info/refs"},
		{http.MethodGet, "/acme/repo.git/unknown"},
	} {
		request, err := http.NewRequest(item.method, server.URL+item.path, nil)
		require.NoError(t, err)
		request.Header.Set(api.HeaderRouteWorkspaceID, "ws_1")
		response, err := server.Client().Do(request)
		require.NoError(t, err)
		body, err := io.ReadAll(response.Body)
		require.NoError(t, err)
		response.Body.Close()
		require.Equal(t, http.StatusNotFound, response.StatusCode, item.path)
		require.Contains(t, string(body), "not found", item.path)
	}
	require.False(t, upstreamReached, "central profile must not reach the AGS APIForwarder upstream")
}
