package http

import (
	"context"
	"errors"
	"log/slog"
	"net/http"

	ghcontext "github.com/github/github-mcp-server/pkg/context"
	"github.com/github/github-mcp-server/pkg/github"
	"github.com/github/github-mcp-server/pkg/http/middleware"
	"github.com/github/github-mcp-server/pkg/http/oauth"
	"github.com/github/github-mcp-server/pkg/inventory"
	"github.com/github/github-mcp-server/pkg/scopes"
	"github.com/github/github-mcp-server/pkg/translations"
	"github.com/github/github-mcp-server/pkg/utils"
	"github.com/go-chi/chi/v5"
	"github.com/modelcontextprotocol/go-sdk/mcp"
)

type InventoryFactoryFunc func(r *http.Request) (*inventory.Inventory, error)

// GitHubMCPServerFactoryFunc is a function type for creating a new MCP Server instance.
// middleware are applied AFTER the default GitHub MCP Server middlewares (like error context injection)
type GitHubMCPServerFactoryFunc func(r *http.Request, deps github.ToolDependencies, inventory *inventory.Inventory, cfg *github.MCPServerConfig) (*mcp.Server, error)

type Handler struct {
	ctx                    context.Context
	config                 *ServerConfig
	deps                   github.ToolDependencies
	logger                 *slog.Logger
	apiHosts               utils.APIHostResolver
	t                      translations.TranslationHelperFunc
	githubMcpServerFactory GitHubMCPServerFactoryFunc
	inventoryFactoryFunc   InventoryFactoryFunc
	oauthCfg               *oauth.Config
	scopeFetcher           scopes.FetcherInterface
	schemaCache            *mcp.SchemaCache
}

type HandlerOptions struct {
	GitHubMcpServerFactory GitHubMCPServerFactoryFunc
	InventoryFactory       InventoryFactoryFunc
	OAuthConfig            *oauth.Config
	ScopeFetcher           scopes.FetcherInterface
	FeatureChecker         inventory.FeatureFlagChecker
}

type HandlerOption func(*HandlerOptions)

func WithScopeFetcher(f scopes.FetcherInterface) HandlerOption {
	return func(o *HandlerOptions) {
		o.ScopeFetcher = f
	}
}

func WithGitHubMCPServerFactory(f GitHubMCPServerFactoryFunc) HandlerOption {
	return func(o *HandlerOptions) {
		o.GitHubMcpServerFactory = f
	}
}

func WithInventoryFactory(f InventoryFactoryFunc) HandlerOption {
	return func(o *HandlerOptions) {
		o.InventoryFactory = f
	}
}

func WithOAuthConfig(cfg *oauth.Config) HandlerOption {
	return func(o *HandlerOptions) {
		o.OAuthConfig = cfg
	}
}

func WithFeatureChecker(checker inventory.FeatureFlagChecker) HandlerOption {
	return func(o *HandlerOptions) {
		o.FeatureChecker = checker
	}
}

func NewHTTPMcpHandler(
	ctx context.Context,
	cfg *ServerConfig,
	deps github.ToolDependencies,
	t translations.TranslationHelperFunc,
	logger *slog.Logger,
	apiHost utils.APIHostResolver,
	options ...HandlerOption) *Handler {
	opts := &HandlerOptions{}
	for _, o := range options {
		o(opts)
	}

	githubMcpServerFactory := opts.GitHubMcpServerFactory
	if githubMcpServerFactory == nil {
		githubMcpServerFactory = DefaultGitHubMCPServerFactory
	}

	scopeFetcher := opts.ScopeFetcher
	if scopeFetcher == nil {
		scopeFetcher = scopes.NewFetcher(apiHost, scopes.FetcherOptions{})
	}

	inventoryFactory := opts.InventoryFactory
	if inventoryFactory == nil {
		inventoryFactory = DefaultInventoryFactory(cfg, t, opts.FeatureChecker, scopeFetcher)
	}

	// Create a shared schema cache to avoid repeated JSON schema reflection
	// when a new MCP Server is created per request in stateless mode.
	schemaCache := mcp.NewSchemaCache()

	return &Handler{
		ctx:                    ctx,
		config:                 cfg,
		deps:                   deps,
		logger:                 logger,
		apiHosts:               apiHost,
		t:                      t,
		githubMcpServerFactory: githubMcpServerFactory,
		inventoryFactoryFunc:   inventoryFactory,
		oauthCfg:               opts.OAuthConfig,
		scopeFetcher:           scopeFetcher,
		schemaCache:            schemaCache,
	}
}

func (h *Handler) RegisterMiddleware(r chi.Router) {
	r.Use(
		middleware.ExtractUserToken(h.oauthCfg),
		middleware.WithRequestConfig,
		middleware.WithMCPParse(),
		middleware.WithPATScopes(h.logger, h.scopeFetcher),
	)

	if h.config.ScopeChallenge {
		r.Use(middleware.WithScopeChallenge(h.oauthCfg, h.scopeFetcher))
	}
}

// RegisterRoutes registers the routes for the MCP server
// URL-based values take precedence over header-based values
func (h *Handler) RegisterRoutes(r chi.Router) {
	// Base routes
	r.Mount("/", h)
	r.With(withReadonly).Mount("/readonly", h)
	r.With(withInsiders).Mount("/insiders", h)
	r.With(withReadonly, withInsiders).Mount("/readonly/insiders", h)

	// Toolset routes
	r.With(withToolset).Mount("/x/{toolset}", h)
	r.With(withToolset, withReadonly).Mount("/x/{toolset}/readonly", h)
	r.With(withToolset, withInsiders).Mount("/x/{toolset}/insiders", h)
	r.With(withToolset, withReadonly, withInsiders).Mount("/x/{toolset}/readonly/insiders", h)
}

// withReadonly is middleware that sets readonly mode in the request context
func withReadonly(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		ctx := ghcontext.WithReadonly(r.Context(), true)
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

// withToolset is middleware that extracts the toolset from the URL and sets it in the request context
func withToolset(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		toolset := chi.URLParam(r, "toolset")
		ctx := ghcontext.WithToolsets(r.Context(), []string{toolset})
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

// withInsiders is middleware that sets insiders mode in the request context
func withInsiders(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		ctx := ghcontext.WithInsidersMode(r.Context(), true)
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	inv, err := h.inventoryFactoryFunc(r)
	if err != nil {
		if errors.Is(err, inventory.ErrUnknownTools) {
			w.WriteHeader(http.StatusBadRequest)
			if _, writeErr := w.Write([]byte(err.Error())); writeErr != nil {
				h.logger.Error("failed to write response", "error", writeErr)
			}
			return
		}

		w.WriteHeader(http.StatusInternalServerError)
		return
	}

	invToUse := inv
	if methodInfo, ok := ghcontext.MCPMethod(r.Context()); ok && methodInfo != nil {
		invToUse = inv.ForMCPRequest(methodInfo.Method, methodInfo.ItemName)
	}

	ghServer, err := h.githubMcpServerFactory(r, h.deps, invToUse, &github.MCPServerConfig{
		Version:           h.config.Version,
		Translator:        h.t,
		ContentWindowSize: h.config.ContentWindowSize,
		Logger:            h.logger,
		RepoAccessTTL:     h.config.RepoAccessCacheTTL,
		// Explicitly set empty capabilities. inv.ForMCPRequest currently returns nothing for Initialize.
		ServerOptions: []github.MCPServerOption{
			func(so *mcp.ServerOptions) {
				so.Capabilities = &mcp.ServerCapabilities{
					Tools:     &mcp.ToolCapabilities{},
					Resources: &mcp.ResourceCapabilities{},
					Prompts:   &mcp.PromptCapabilities{},
				}
				so.SchemaCache = h.schemaCache
			},
		},
	})

	if err != nil {
		w.WriteHeader(http.StatusInternalServerError)
		return
	}

	// Bypass cross-origin protection: this server uses bearer tokens (not
	// cookies), so Sec-Fetch-Site CSRF checks are unnecessary. See PR #2359.
	crossOriginProtection := http.NewCrossOriginProtection()
	crossOriginProtection.AddInsecureBypassPattern("/")

	mcpHandler := mcp.NewStreamableHTTPHandler(func(_ *http.Request) *mcp.Server {
		return ghServer
	}, &mcp.StreamableHTTPOptions{
		Stateless:             true,
		CrossOriginProtection: crossOriginProtection,
	})

	mcpHandler.ServeHTTP(w, r)
}

func DefaultGitHubMCPServerFactory(r *http.Request, deps github.ToolDependencies, inventory *inventory.Inventory, cfg *github.MCPServerConfig) (*mcp.Server, error) {
	return github.NewMCPServer(r.Context(), cfg, deps, inventory)
}

// DefaultInventoryFactory creates the default inventory factory for HTTP mode.
// When the ServerConfig includes static flags (--toolsets, --read-only, etc.),
// a static inventory is built once at factory creation to pre-filter the tool
// universe. Per-request headers can only narrow within these bounds.
func DefaultInventoryFactory(cfg *ServerConfig, t translations.TranslationHelperFunc, featureChecker inventory.FeatureFlagChecker, scopeFetcher scopes.FetcherInterface) InventoryFactoryFunc {
	// Build the static tool/resource/prompt universe from CLI flags.
	// This is done once at startup and captured in the closure.
	staticTools, staticResources, staticPrompts := buildStaticInventory(cfg, t, featureChecker)
	hasStaticFilters := hasStaticConfig(cfg)

	// Pre-compute valid tool names for filtering per-request tool headers.
	// When a request asks for a tool by name that's been excluded from the
	// static universe, we silently drop it rather than returning an error.
	validToolNames := make(map[string]bool, len(staticTools))
	for i := range staticTools {
		validToolNames[staticTools[i].Tool.Name] = true
	}

	return func(r *http.Request) (*inventory.Inventory, error) {
		b := inventory.NewBuilder().
			SetTools(staticTools).
			SetResources(staticResources).
			SetPrompts(staticPrompts).
			WithDeprecatedAliases(github.DeprecatedToolAliases).
			WithFeatureChecker(featureChecker)

		// When static flags constrain the universe, default to showing
		// everything within those bounds (per-request filters narrow further).
		// When no static flags are set, preserve existing behavior where
		// the default toolsets apply.
		if hasStaticFilters {
			b = b.WithToolsets([]string{"all"})
		}

		// Static read-only is an upper bound — enforce before request filters
		if cfg.ReadOnly {
			b = b.WithReadOnly(true)
		}

		// Filter request tool names to only those in the static universe,
		// so requests for statically-excluded tools degrade gracefully.
		if hasStaticFilters {
			r = filterRequestTools(r, validToolNames)
		}

		b = InventoryFiltersForRequest(r, b)
		b = PATScopeFilter(b, r, scopeFetcher)

		b.WithServerInstructions()

		return b.Build()
	}
}

// filterRequestTools returns a shallow copy of the request with any per-request
// tool names (from X-MCP-Tools header) filtered to only include tools that exist
// in validNames. This ensures requests for statically-excluded tools are silently
// ignored rather than causing build errors.
func filterRequestTools(r *http.Request, validNames map[string]bool) *http.Request {
	reqTools := ghcontext.GetTools(r.Context())
	if len(reqTools) == 0 {
		return r
	}

	filtered := make([]string, 0, len(reqTools))
	for _, name := range reqTools {
		if validNames[name] {
			filtered = append(filtered, name)
		}
	}
	ctx := ghcontext.WithTools(r.Context(), filtered)
	return r.WithContext(ctx)
}

// hasStaticConfig returns true if any static filtering flags are set on the ServerConfig.
func hasStaticConfig(cfg *ServerConfig) bool {
	return cfg.ReadOnly ||
		cfg.EnabledToolsets != nil ||
		cfg.EnabledTools != nil ||
		cfg.DynamicToolsets ||
		len(cfg.ExcludeTools) > 0 ||
		cfg.InsidersMode
}

// buildStaticInventory pre-filters the full tool/resource/prompt universe using
// the static CLI flags (--toolsets, --read-only, --exclude-tools, etc.).
// The returned slices serve as the upper bound for per-request inventory builders.
func buildStaticInventory(cfg *ServerConfig, t translations.TranslationHelperFunc, featureChecker inventory.FeatureFlagChecker) ([]inventory.ServerTool, []inventory.ServerResourceTemplate, []inventory.ServerPrompt) {
	if !hasStaticConfig(cfg) {
		return github.AllTools(t), github.AllResources(t), github.AllPrompts(t)
	}

	b := github.NewInventory(t).
		WithFeatureChecker(featureChecker).
		WithReadOnly(cfg.ReadOnly).
		WithToolsets(github.ResolvedEnabledToolsets(cfg.DynamicToolsets, cfg.EnabledToolsets, cfg.EnabledTools))

	if len(cfg.EnabledTools) > 0 {
		b = b.WithTools(github.CleanTools(cfg.EnabledTools))
	}

	if len(cfg.ExcludeTools) > 0 {
		b = b.WithExcludeTools(cfg.ExcludeTools)
	}

	inv, err := b.Build()
	if err != nil {
		// Fall back to all tools if there's an error (e.g. unknown tool names).
		// The error will surface again at per-request time if relevant.
		return github.AllTools(t), github.AllResources(t), github.AllPrompts(t)
	}

	ctx := context.Background()
	return inv.AvailableTools(ctx), inv.AvailableResourceTemplates(ctx), inv.AvailablePrompts(ctx)
}

// InventoryFiltersForRequest applies filters to the inventory builder
// based on the request context and headers.
// MCP Apps UI metadata is handled by the builder via the feature checker —
// no need to check headers here.
func InventoryFiltersForRequest(r *http.Request, builder *inventory.Builder) *inventory.Builder {
	ctx := r.Context()

	if ghcontext.IsReadonly(ctx) {
		builder = builder.WithReadOnly(true)
	}

	toolsets := ghcontext.GetToolsets(ctx)
	tools := ghcontext.GetTools(ctx)

	if len(toolsets) > 0 {
		builder = builder.WithToolsets(github.ResolvedEnabledToolsets(false, toolsets, tools)) // No dynamic toolsets in HTTP mode
	}

	if len(tools) > 0 {
		if len(toolsets) == 0 {
			builder = builder.WithToolsets([]string{})
		}
		builder = builder.WithTools(github.CleanTools(tools))
	}

	if excluded := ghcontext.GetExcludeTools(ctx); len(excluded) > 0 {
		builder = builder.WithExcludeTools(excluded)
	}

	return builder
}

func PATScopeFilter(b *inventory.Builder, r *http.Request, fetcher scopes.FetcherInterface) *inventory.Builder {
	ctx := r.Context()

	tokenInfo, ok := ghcontext.GetTokenInfo(ctx)
	if !ok || tokenInfo == nil {
		return b
	}

	// Scopes should have already been fetched by the WithPATScopes middleware.
	// Only classic PATs (ghp_ prefix) return OAuth scopes via X-OAuth-Scopes header.
	// Fine-grained PATs and other token types don't support this, so we skip filtering.
	if tokenInfo.TokenType == utils.TokenTypePersonalAccessToken {
		// Check if scopes are already in context (should be set by WithPATScopes). If not, fetch them.
		existingScopes, ok := ghcontext.GetTokenScopes(ctx)
		if ok {
			return b.WithFilter(github.CreateToolScopeFilter(existingScopes))
		}

		scopesList, err := fetcher.FetchTokenScopes(ctx, tokenInfo.Token)
		if err != nil {
			return b
		}

		return b.WithFilter(github.CreateToolScopeFilter(scopesList))
	}

	return b
}
