package runtime

import (
	"context"
	"fmt"
	"strings"
	"time"

	"go.uber.org/zap"

	"github.com/smart-mcp-proxy/mcpproxy-go/internal/config"
	"github.com/smart-mcp-proxy/mcpproxy-go/internal/oauth"
	"github.com/smart-mcp-proxy/mcpproxy-go/internal/runtime/configsvc"
	"github.com/smart-mcp-proxy/mcpproxy-go/internal/runtime/supervisor"
)

const connectAttemptTimeout = 3*time.Minute + 15*time.Second // Must exceed per-server Docker timeout (3min)

// StartBackgroundInitialization kicks off configuration sync and background loops.
func (r *Runtime) StartBackgroundInitialization() {
	// Start activity service for persisting tool call events
	if r.activityService != nil {
		// Set event emitter for sensitive data detection events (Spec 026)
		r.activityService.SetEventEmitter(r)
		go r.activityService.Start(r.appCtx, r)
		r.logger.Info("Activity service started for event logging")
	}

	// Start update checker for background version checking
	if r.updateChecker != nil {
		go r.updateChecker.Start(r.appCtx)
		r.logger.Info("Update checker background process started")
	}

	// Start telemetry service for anonymous usage heartbeats (Spec 036)
	if r.telemetryService != nil {
		go r.telemetryService.Start(r.appCtx)
		r.logger.Info("Telemetry service background process started")
	}

	// Clean up orphaned OAuth tokens before starting the refresh manager
	// This removes tokens for servers that were deleted while mcpproxy was not running
	if r.storageManager != nil {
		r.mu.RLock()
		validServerNames := make([]string, 0, len(r.cfg.Servers))
		for _, server := range r.cfg.Servers {
			validServerNames = append(validServerNames, server.Name)
		}
		r.mu.RUnlock()

		if deleted, err := r.storageManager.CleanupOrphanedOAuthTokens(validServerNames); err != nil {
			r.logger.Warn("Failed to cleanup orphaned OAuth tokens", zap.Error(err))
		} else if deleted > 0 {
			r.logger.Info("Cleaned up orphaned OAuth tokens during startup",
				zap.Int("deleted", deleted))
		}
	}

	// Start proactive OAuth token refresh manager
	if r.refreshManager != nil {
		r.refreshManager.SetRuntime(r)
		r.refreshManager.SetEventEmitter(r)
		if err := r.refreshManager.Start(r.appCtx); err != nil {
			r.logger.Error("Failed to start OAuth refresh manager", zap.Error(err))
		} else {
			r.logger.Info("OAuth refresh manager started")
		}

		// Register token saved callback to wire PersistentTokenStore -> RefreshManager
		oauth.GetTokenStoreManager().SetTokenSavedCallback(func(serverName string, expiresAt time.Time) {
			r.logger.Info("OnTokenSaved callback fired - scheduling proactive refresh",
				zap.String("server", serverName),
				zap.Time("expires_at", expiresAt),
				zap.Duration("valid_for", time.Until(expiresAt)))
			r.refreshManager.OnTokenSaved(serverName, expiresAt)
		})
		r.logger.Info("Token saved callback registered for proactive refresh")
	}

	// Phase 6: Start Supervisor for state reconciliation and lock-free reads
	if r.supervisor != nil {
		r.supervisor.Start()
		r.logger.Info("Supervisor started for state reconciliation")

		// Set up reactive tool discovery callback with deduplication
		r.supervisor.SetOnServerConnectedCallback(func(serverName string) {
			// Spec 044 (T040): activation funnel — mark first-ever successful
			// upstream connect. Monotonic; cheap; safe to call on every event.
			r.MarkFirstConnectedServerForActivation()

			// Deduplication: Check if discovery is already in progress for this server
			if _, loaded := r.discoveryInProgress.LoadOrStore(serverName, struct{}{}); loaded {
				r.logger.Debug("Tool discovery already in progress for server, skipping duplicate",
					zap.String("server", serverName))
				return
			}

			// Ensure we clean up the in-progress marker
			defer r.discoveryInProgress.Delete(serverName)

			ctx, cancel := context.WithTimeout(r.AppContext(), 30*time.Second)
			defer cancel()

			r.logger.Info("Reactive tool discovery triggered", zap.String("server", serverName))
			if err := r.DiscoverAndIndexToolsForServer(ctx, serverName); err != nil {
				r.logger.Error("Failed to discover tools for connected server",
					zap.String("server", serverName),
					zap.Error(err))
			}
		})
		r.logger.Info("Reactive tool discovery callback registered")

		// Subscribe to supervisor events and emit servers.changed for Web UI updates
		go r.supervisorEventForwarder()
	}

	// Set up tool discovery callback on upstream manager for notifications/tools/list_changed
	// This enables reactive tool re-indexing when upstream servers change their available tools
	if r.upstreamManager != nil {
		r.upstreamManager.SetToolDiscoveryCallback(func(ctx context.Context, serverName string) error {
			// Deduplication: Check if discovery is already in progress for this server
			if _, loaded := r.discoveryInProgress.LoadOrStore(serverName, struct{}{}); loaded {
				r.logger.Debug("Tool discovery already in progress for server (notification), skipping duplicate",
					zap.String("server", serverName))
				return nil
			}

			// Ensure we clean up the in-progress marker
			defer r.discoveryInProgress.Delete(serverName)

			r.logger.Info("Tool discovery triggered by notification", zap.String("server", serverName))
			return r.DiscoverAndIndexToolsForServer(ctx, serverName)
		})
		r.logger.Info("Tool discovery callback registered on upstream manager")
	}

	go r.backgroundInitialization()
}

func (r *Runtime) backgroundInitialization() {
	if r.CurrentPhase() == PhaseInitializing {
		r.UpdatePhase(PhaseLoading, "Loading configuration...")
	} else {
		r.UpdatePhaseMessage("Loading configuration...")
	}

	appCtx := r.AppContext()

	// Load configured servers - saves to storage synchronously (fast ~100-200ms),
	// then starts connections asynchronously (slow 30s+)
	// We do this synchronously to ensure API /servers endpoint has data immediately
	if err := r.LoadConfiguredServers(nil); err != nil {
		r.logger.Error("Failed to load configured servers", zap.Error(err))
		// Don't set error phase - servers can be loaded later via config reload
	}

	// Mark as ready - storage is now populated with server configs
	switch r.CurrentPhase() {
	case PhaseInitializing, PhaseLoading, PhaseReady:
		r.UpdatePhase(PhaseReady, "Server is ready (upstream servers connecting in background)")
	default:
		r.UpdatePhaseMessage("Server is ready (upstream servers connecting in background)")
	}

	// Start connection retry attempts in background
	go r.backgroundConnections(appCtx)

	// Start tool indexing with reduced delay
	go r.backgroundToolIndexing(appCtx)

	// Start session inactivity cleanup
	go r.backgroundSessionCleanup(appCtx)
}

func (r *Runtime) backgroundConnections(ctx context.Context) {
	r.connectAllWithRetry(ctx)

	ticker := time.NewTicker(60 * time.Second)
	defer ticker.Stop()

	for {
		select {
		case <-ticker.C:
			r.connectAllWithRetry(ctx)
		case <-ctx.Done():
			r.logger.Info("Background connections stopped due to context cancellation")
			return
		}
	}
}

func (r *Runtime) connectAllWithRetry(ctx context.Context) {
	if r.upstreamManager == nil {
		return
	}

	stats := r.upstreamManager.GetStats()
	connectedCount := 0
	totalCount := 0

	if serverStats, ok := stats["servers"].(map[string]interface{}); ok {
		totalCount = len(serverStats)
		for _, serverStat := range serverStats {
			if stat, ok := serverStat.(map[string]interface{}); ok {
				if connected, ok := stat["connected"].(bool); ok && connected {
					connectedCount++
				}
			}
		}
	}

	if connectedCount < totalCount {
		r.UpdatePhaseMessage(fmt.Sprintf("Connected to %d/%d servers, retrying...", connectedCount, totalCount))

		connectCtx, cancel := context.WithTimeout(ctx, connectAttemptTimeout)
		defer cancel()

		if err := r.upstreamManager.ConnectAll(connectCtx); err != nil {
			r.logger.Warn("Some upstream servers failed to connect", zap.Error(err))
		}
	}
}

// toolDiscoveryDisabledRecheckInterval is how long the indexing loop sleeps
// between re-checks when the periodic sweep is disabled (resolved interval
// <= 0). It is NOT a sweep — connect-time discovery and reactive
// notifications/tools/list_changed still keep the index fresh; this only lets a
// later config hot-reload re-enable the sweep without a restart (spec 074).
const toolDiscoveryDisabledRecheckInterval = 5 * time.Minute

// planToolDiscoveryCycle decides one iteration of the indexing loop: whether to
// run a periodic sweep and how long to wait first. When at least one server (or
// the global default) has a positive resolved interval the loop ticks at the
// smallest such cadence (tick) and sweeps; when every interval is disabled
// (anyEnabled=false) the loop waits the re-check window without sweeping so a
// later hot-reload can re-enable it.
func planToolDiscoveryCycle(tick time.Duration, anyEnabled bool, disabledRecheck time.Duration) (sweep bool, wait time.Duration) {
	if !anyEnabled || tick <= 0 {
		return false, disabledRecheck
	}
	return true, tick
}

func (r *Runtime) backgroundToolIndexing(ctx context.Context) {
	r.cleanupOrphanedIndexEntries()

	select {
	case <-time.After(2 * time.Second):
		_ = r.DiscoverAndIndexTools(ctx)
	case <-ctx.Done():
		r.logger.Info("Background tool indexing stopped during initial delay")
		return
	}

	// Re-resolve the tool-discovery cadence every cycle so a config hot-reload
	// changes the sweep interval (or disables it) without a restart (spec 074,
	// FR-012). The loop ticks at the smallest per-server cadence and the sweep
	// itself (DiscoverToolsDue) only re-lists servers whose own interval has
	// elapsed, so per-server overrides take effect (US3/SC-006/FR-005). The tick
	// is resolved from the upstream manager's thread-safe per-client config
	// snapshots — iterating r.Config().Servers here would race in-place config
	// mutation (the shared snapshot is copy-on-write; see runtime.Config()).
	for {
		tick, anyEnabled := r.upstreamManager.ResolveToolDiscoverySweepTick(r.Config())
		sweep, wait := planToolDiscoveryCycle(tick, anyEnabled, toolDiscoveryDisabledRecheckInterval)
		timer := time.NewTimer(wait)
		select {
		case <-timer.C:
			if sweep {
				_ = r.discoverAndIndexTools(ctx, true)
			}
		case <-ctx.Done():
			timer.Stop()
			r.logger.Info("Background tool indexing stopped due to context cancellation")
			return
		}
	}
}

// backgroundSessionCleanup periodically closes sessions that haven't had activity.
// This handles the HTTP transport limitation where OnUnregisterSession is never called.
func (r *Runtime) backgroundSessionCleanup(ctx context.Context) {
	// Session inactivity timeout: 30 minutes
	// MCP clients may have gaps between tool calls (e.g., user reading results).
	// 5 minutes was too aggressive and caused sessions to appear stale.
	const sessionInactivityTimeout = 30 * time.Minute

	// Check every minute for inactive sessions
	ticker := time.NewTicker(1 * time.Minute)
	defer ticker.Stop()

	for {
		select {
		case <-ticker.C:
			if r.storageManager != nil {
				closedCount, err := r.storageManager.CloseInactiveSessions(sessionInactivityTimeout)
				if err != nil {
					r.logger.Warn("Failed to close inactive sessions", zap.Error(err))
				} else if closedCount > 0 {
					r.logger.Debug("Closed inactive sessions",
						zap.Int("count", closedCount),
						zap.Duration("timeout", sessionInactivityTimeout))
				}
			}
		case <-ctx.Done():
			r.logger.Info("Background session cleanup stopped due to context cancellation")
			return
		}
	}
}

// DiscoverAndIndexTools discovers tools from ALL connected upstream servers and
// indexes them. Used by event-driven callers (boot, server reload, manual
// refresh) that want a full sweep regardless of per-server cadence.
func (r *Runtime) DiscoverAndIndexTools(ctx context.Context) error {
	return r.discoverAndIndexTools(ctx, false)
}

// discoverAndIndexTools discovers tools and updates the index. When dueOnly is
// true (the periodic spec-074 sweep) only servers whose per-server
// tool_discovery_interval has elapsed are re-listed; servers omitted from the
// sweep keep their last-good index snapshot, so the index does not shrink.
func (r *Runtime) discoverAndIndexTools(ctx context.Context, dueOnly bool) error {
	if r.upstreamManager == nil || r.indexManager == nil {
		return fmt.Errorf("runtime managers not initialized")
	}

	r.logger.Info("Discovering and indexing tools...", zap.Bool("due_only", dueOnly))

	var tools []*config.ToolMetadata
	var err error
	if dueOnly {
		tools, err = r.upstreamManager.DiscoverToolsDue(ctx)
	} else {
		tools, err = r.upstreamManager.DiscoverTools(ctx)
	}
	if err != nil {
		return fmt.Errorf("failed to discover tools: %w", err)
	}

	if len(tools) == 0 {
		r.logger.Warn("No tools discovered from upstream servers")
		return nil
	}

	// Group tools by server name for differential updates
	toolsByServer := make(map[string][]*config.ToolMetadata)
	for _, tool := range tools {
		toolsByServer[tool.ServerName] = append(toolsByServer[tool.ServerName], tool)
	}

	// Snapshot the set of currently-known servers so we can prune entries for
	// servers that have been removed from config and avoid an unbounded map.
	knownServers := r.upstreamManager.GetAllServerNames()
	knownServerSet := make(map[string]struct{}, len(knownServers))
	for _, name := range knownServers {
		knownServerSet[name] = struct{}{}
	}

	// Persist fresh snapshots for discovered servers and prune stale entries.
	// ToolMetadata values are treated as immutable post-discovery; if that ever
	// changes, switch to a deep copy here.
	r.lastGoodToolsMu.Lock()
	for serverName, serverTools := range toolsByServer {
		cp := make([]*config.ToolMetadata, len(serverTools))
		copy(cp, serverTools)
		r.lastGoodTools[serverName] = cp
	}
	for serverName := range r.lastGoodTools {
		if _, ok := knownServerSet[serverName]; !ok {
			delete(r.lastGoodTools, serverName)
		}
	}
	r.lastGoodToolsMu.Unlock()

	// Apply differential update for each server with fallback to last-good snapshots
	processedServers := make(map[string]struct{}, len(toolsByServer))
	for serverName, serverTools := range toolsByServer {
		processedServers[serverName] = struct{}{}
		if err := r.applyDifferentialToolUpdate(ctx, serverName, serverTools); err != nil {
			r.logger.Error("Failed to apply differential update for server",
				zap.String("server", serverName),
				zap.Error(err))
			// Continue with other servers instead of failing completely
		}
	}

	// For connected servers that were temporarily missing from discovery results,
	// re-apply last-good snapshot to avoid transient index shrink.
	for _, serverName := range knownServers {
		if _, ok := processedServers[serverName]; ok {
			continue
		}

		client, ok := r.upstreamManager.GetClient(serverName)
		if !ok || client == nil || !client.IsConnected() {
			continue
		}

		r.lastGoodToolsMu.RLock()
		snapshot, hasSnapshot := r.lastGoodTools[serverName]
		r.lastGoodToolsMu.RUnlock()
		if !hasSnapshot || len(snapshot) == 0 {
			continue
		}

		// Logged at Info: this can fire repeatedly during reconnect storms and
		// is benign self-healing, not a warning condition.
		r.logger.Info("Server missing from discovery result; reusing last-good tool snapshot",
			zap.String("server", serverName),
			zap.Int("snapshot_tools", len(snapshot)))

		if err := r.applyDifferentialToolUpdate(ctx, serverName, snapshot); err != nil {
			r.logger.Error("Failed to apply last-good snapshot for server",
				zap.String("server", serverName),
				zap.Error(err))
		}
	}

	// Invalidate tool count caches since tools may have changed
	r.upstreamManager.InvalidateAllToolCountCaches()

	// Update StateView with discovered tools
	if r.supervisor != nil {
		if err := r.supervisor.RefreshToolsFromDiscovery(tools); err != nil {
			r.logger.Warn("Failed to refresh tools in StateView", zap.Error(err))
			// Don't fail the entire operation if StateView update fails
		} else {
			r.logger.Debug("Successfully refreshed tools in StateView", zap.Int("tool_count", len(tools)))
		}
	}

	// Profiles v2 (Spec 057, T1): reconcile per-profile indexes against the
	// current config — build new profiles, rebuild those whose membership changed
	// and drop removed ones. Unchanged profiles are left untouched.
	r.reconcileProfileIndexes()

	r.logger.Info("Successfully indexed tools", zap.Int("count", len(tools)))
	return nil
}

// DiscoverAndIndexToolsForServer discovers and indexes tools for a single server.
// This is used for reactive tool discovery when a server connects.
// Implements retry logic with exponential backoff for robustness.
func (r *Runtime) DiscoverAndIndexToolsForServer(ctx context.Context, serverName string) error {
	if r.upstreamManager == nil || r.indexManager == nil {
		return fmt.Errorf("runtime managers not initialized")
	}

	r.logger.Info("Discovering and indexing tools for server", zap.String("server", serverName))

	// Get the upstream client for this server
	client, ok := r.upstreamManager.GetClient(serverName)
	if !ok {
		return fmt.Errorf("client not found for server %s", serverName)
	}

	// Retry logic: Sometimes connection events fire slightly before the server is fully ready
	// We retry up to 3 times with exponential backoff (500ms, 1s, 2s)
	var tools []*config.ToolMetadata
	var err error
	maxRetries := 3
	baseDelay := 500 * time.Millisecond

	for attempt := 0; attempt < maxRetries; attempt++ {
		if attempt > 0 {
			delay := baseDelay * time.Duration(1<<uint(attempt-1)) // Exponential backoff
			r.logger.Debug("Retrying tool discovery after delay",
				zap.String("server", serverName),
				zap.Int("attempt", attempt+1),
				zap.Duration("delay", delay))

			select {
			case <-time.After(delay):
			case <-ctx.Done():
				return fmt.Errorf("context cancelled during retry backoff: %w", ctx.Err())
			}
		}

		// Discover tools from this server
		tools, err = client.ListTools(ctx)
		if err == nil {
			break // Success!
		}

		// Log the error for debugging
		r.logger.Warn("Tool discovery attempt failed",
			zap.String("server", serverName),
			zap.Int("attempt", attempt+1),
			zap.Int("max_retries", maxRetries),
			zap.Error(err))

		// Don't retry on context cancellation
		if ctx.Err() != nil {
			return fmt.Errorf("context cancelled during tool discovery: %w", ctx.Err())
		}
	}

	// After all retries, check if we still have an error
	if err != nil {
		return fmt.Errorf("failed to list tools for server %s after %d attempts: %w", serverName, maxRetries, err)
	}

	if len(tools) == 0 {
		r.logger.Warn("No tools discovered from server", zap.String("server", serverName))
		return nil
	}

	// Apply differential update: compare new tools with existing indexed tools
	if err := r.applyDifferentialToolUpdate(ctx, serverName, tools); err != nil {
		return fmt.Errorf("failed to apply differential tool update for server %s: %w", serverName, err)
	}

	// Invalidate tool count caches since tools may have changed
	r.upstreamManager.InvalidateAllToolCountCaches()

	// Update StateView with discovered tools
	if r.supervisor != nil {
		if err := r.supervisor.RefreshToolsFromDiscovery(tools); err != nil {
			r.logger.Warn("Failed to refresh tools in StateView for server",
				zap.String("server", serverName),
				zap.Error(err))
		} else {
			r.logger.Debug("Successfully refreshed tools in StateView for server",
				zap.String("server", serverName),
				zap.Int("tool_count", len(tools)))
		}
	}

	r.logger.Info("Successfully indexed tools for server",
		zap.String("server", serverName),
		zap.Int("count", len(tools)))
	return nil
}

// applyDifferentialToolUpdate performs differential update of tools for a server.
// It compares new tools with existing indexed tools and applies only the changes:
// - Removed tools are deleted from the index
// - Added tools are indexed (unless blocked by tool-level quarantine)
// - Modified tools (different hash) are re-indexed (unless blocked by tool-level quarantine)
// - Tools blocked by quarantine are removed from the index if previously indexed
func (r *Runtime) applyDifferentialToolUpdate(ctx context.Context, serverName string, newTools []*config.ToolMetadata) error {
	// Check tool-level quarantine approvals before indexing
	approvalResult, err := r.checkToolApprovals(serverName, newTools)
	if err != nil {
		r.logger.Warn("Failed to check tool approvals, proceeding without quarantine",
			zap.String("server", serverName),
			zap.Error(err))
		approvalResult = &ToolApprovalResult{BlockedTools: make(map[string]bool)}
	}

	// Query existing tools from the index
	existingTools, err := r.indexManager.GetToolsByServer(serverName)
	if err != nil {
		r.logger.Warn("Failed to query existing tools, performing full re-index",
			zap.String("server", serverName),
			zap.Error(err))
		// Filter out blocked tools before full batch index
		allowedTools := filterBlockedTools(newTools, approvalResult.BlockedTools)
		if err := r.indexManager.BatchIndexTools(allowedTools); err != nil {
			return err
		}
		// The shared index changed for this server; refresh dependent profiles.
		r.reindexAffectedProfiles(serverName)
		return nil
	}

	// Build maps for efficient lookup
	// Extract tool name without server prefix for comparison
	oldToolsMap := make(map[string]*config.ToolMetadata)
	for _, tool := range existingTools {
		toolName := tool.Name
		// Remove server prefix if present (format: "server:tool")
		if idx := strings.Index(tool.Name, ":"); idx != -1 {
			toolName = tool.Name[idx+1:]
		}
		oldToolsMap[toolName] = tool
	}

	newToolsMap := make(map[string]*config.ToolMetadata)
	for _, tool := range newTools {
		toolName := tool.Name
		// Remove server prefix if present
		if idx := strings.Index(tool.Name, ":"); idx != -1 {
			toolName = tool.Name[idx+1:]
		}
		newToolsMap[toolName] = tool
	}

	// Detect changes
	var addedTools []*config.ToolMetadata
	var modifiedTools []*config.ToolMetadata
	var removedTools []string

	// Find added and modified tools
	for toolName, newTool := range newToolsMap {
		oldTool, exists := oldToolsMap[toolName]
		if !exists {
			// Tool is new
			addedTools = append(addedTools, newTool)
		} else if oldTool.Hash != newTool.Hash {
			// Tool exists but has changed (different hash)
			modifiedTools = append(modifiedTools, newTool)
		}
		// else: tool unchanged, no action needed
	}

	// Find removed tools
	for toolName := range oldToolsMap {
		if _, exists := newToolsMap[toolName]; !exists {
			removedTools = append(removedTools, toolName)
		}
	}

	// Log the changes
	if len(addedTools) > 0 || len(modifiedTools) > 0 || len(removedTools) > 0 {
		r.logger.Info("Tool changes detected for server",
			zap.String("server", serverName),
			zap.Int("added", len(addedTools)),
			zap.Int("modified", len(modifiedTools)),
			zap.Int("removed", len(removedTools)))
	} else {
		r.logger.Debug("No tool changes detected for server",
			zap.String("server", serverName),
			zap.Int("tool_count", len(newTools)))
	}

	// Apply changes

	// 1. Delete removed tools
	for _, toolName := range removedTools {
		r.logger.Info("Removing tool from index",
			zap.String("server", serverName),
			zap.String("tool", toolName))

		if err := r.indexManager.DeleteTool(serverName, toolName); err != nil {
			r.logger.Error("Failed to delete tool from index",
				zap.String("server", serverName),
				zap.String("tool", toolName),
				zap.Error(err))
		}

		// Clean up hash storage
		fullToolName := fmt.Sprintf("%s:%s", serverName, toolName)
		if r.storageManager != nil {
			if err := r.storageManager.DeleteToolHash(fullToolName); err != nil {
				r.logger.Debug("Failed to delete tool hash",
					zap.String("tool", fullToolName),
					zap.Error(err))
			}
		}

		// Clean up tool approval records for removed tools
		if r.storageManager != nil {
			if err := r.storageManager.DeleteToolApproval(serverName, toolName); err != nil {
				r.logger.Debug("Failed to delete tool approval for removed tool",
					zap.String("tool", fullToolName),
					zap.Error(err))
			}
		}
	}

	// 2. Remove blocked tools from index if previously indexed
	for blockedToolName := range approvalResult.BlockedTools {
		if _, wasIndexed := oldToolsMap[blockedToolName]; wasIndexed {
			r.logger.Info("Removing blocked tool from index (quarantine)",
				zap.String("server", serverName),
				zap.String("tool", blockedToolName))
			if err := r.indexManager.DeleteTool(serverName, blockedToolName); err != nil {
				r.logger.Error("Failed to remove blocked tool from index",
					zap.String("server", serverName),
					zap.String("tool", blockedToolName),
					zap.Error(err))
			}
		}
	}

	// 3. Index added tools (excluding blocked)
	allowedAddedTools := filterBlockedTools(addedTools, approvalResult.BlockedTools)
	if len(allowedAddedTools) > 0 {
		r.logger.Info("Indexing new tools",
			zap.String("server", serverName),
			zap.Int("count", len(allowedAddedTools)),
			zap.Int("blocked", len(addedTools)-len(allowedAddedTools)))

		if err := r.indexManager.BatchIndexTools(allowedAddedTools); err != nil {
			return fmt.Errorf("failed to index added tools: %w", err)
		}
	}

	// 4. Re-index modified tools (excluding blocked)
	allowedModifiedTools := filterBlockedTools(modifiedTools, approvalResult.BlockedTools)
	if len(allowedModifiedTools) > 0 {
		r.logger.Info("Re-indexing modified tools",
			zap.String("server", serverName),
			zap.Int("count", len(allowedModifiedTools)),
			zap.Int("blocked", len(modifiedTools)-len(allowedModifiedTools)))

		for _, tool := range allowedModifiedTools {
			r.logger.Debug("Tool schema changed",
				zap.String("server", serverName),
				zap.String("tool", tool.Name),
				zap.String("old_hash", oldToolsMap[extractToolName(tool.Name)].Hash),
				zap.String("new_hash", tool.Hash))
		}

		if err := r.indexManager.BatchIndexTools(allowedModifiedTools); err != nil {
			return fmt.Errorf("failed to re-index modified tools: %w", err)
		}
	}

	// If the shared index changed for this server, refresh the per-profile indexes
	// that include it (Profiles v2, Spec 057). Profiles without this server are
	// untouched. Skipped when nothing changed to avoid churn on idle sweeps.
	changed := len(addedTools) > 0 || len(modifiedTools) > 0 || len(removedTools) > 0 ||
		len(approvalResult.BlockedTools) > 0
	if changed {
		r.reindexAffectedProfiles(serverName)
	}

	return nil
}

// filterBlockedTools removes tools that are blocked by quarantine from the list.
func filterBlockedTools(tools []*config.ToolMetadata, blocked map[string]bool) []*config.ToolMetadata {
	if len(blocked) == 0 {
		return tools
	}
	var allowed []*config.ToolMetadata
	for _, tool := range tools {
		toolName := extractToolName(tool.Name)
		if !blocked[toolName] {
			allowed = append(allowed, tool)
		}
	}
	return allowed
}

// extractToolName removes the server prefix from a tool name if present
func extractToolName(fullName string) string {
	if idx := strings.Index(fullName, ":"); idx != -1 {
		return fullName[idx+1:]
	}
	return fullName
}

// LoadConfiguredServers synchronizes storage and upstream manager from the given or current config.
// If cfg is nil, it will use the current runtime configuration.
//
//nolint:unparam // maintained for parity with previous implementation
func (r *Runtime) LoadConfiguredServers(cfg *config.Config) error {
	if cfg == nil {
		cfg = r.Config()
		if cfg == nil {
			return fmt.Errorf("runtime configuration is not available")
		}
	}

	if r.storageManager == nil || r.upstreamManager == nil || r.indexManager == nil {
		return fmt.Errorf("runtime managers not initialized")
	}

	r.logger.Info("Synchronizing servers from configuration (config as source of truth)")

	currentUpstreams := r.upstreamManager.GetAllServerNames()
	storedServers, err := r.storageManager.ListUpstreamServers()
	if err != nil {
		r.logger.Error("Failed to get stored servers for sync", zap.Error(err))
		storedServers = []*config.ServerConfig{}
	}

	configuredServers := make(map[string]*config.ServerConfig)
	storedServerMap := make(map[string]*config.ServerConfig)
	var changed bool

	for _, serverCfg := range cfg.Servers {
		configuredServers[serverCfg.Name] = serverCfg
	}

	for _, storedServer := range storedServers {
		storedServerMap[storedServer.Name] = storedServer
	}

	// GC orphaned tool-approval records (MCP-1002): drop approvals whose server
	// is no longer configured. Configured-but-disabled servers are preserved so
	// a later re-enable doesn't re-quarantine their previously-approved tools.
	// Guard against a transient empty config nuking every approval — explicit
	// server deletion already cleans up via DeleteServerToolApprovals.
	if len(configuredServers) > 0 {
		configuredNames := make([]string, 0, len(configuredServers))
		for name := range configuredServers {
			configuredNames = append(configuredNames, name)
		}
		if pruned, perr := r.storageManager.PruneOrphanToolApprovals(configuredNames); perr != nil {
			r.logger.Warn("Failed to prune orphan tool approvals", zap.Error(perr))
		} else if pruned > 0 {
			r.logger.Info("Pruned orphan tool-approval records", zap.Int("removed", pruned))
		}
	}

	// Add/remove servers asynchronously to prevent blocking on slow connections
	// All server operations now happen in background goroutines with timeouts

	// FIRST: Save all servers to storage in one batch (fast, synchronous)
	// This ensures API /servers endpoint can return data immediately
	r.logger.Debug("Starting synchronous storage save phase", zap.Int("total_servers", len(cfg.Servers)))
	for _, serverCfg := range cfg.Servers {
		storedServer, existsInStorage := storedServerMap[serverCfg.Name]

		// Check if OAuth config changed (requires reconnection)
		oauthChanged := existsInStorage && config.OAuthConfigChanged(storedServer.OAuth, serverCfg.OAuth)

		hasChanged := !existsInStorage ||
			storedServer.Enabled != serverCfg.Enabled ||
			storedServer.Quarantined != serverCfg.Quarantined ||
			storedServer.URL != serverCfg.URL ||
			storedServer.Command != serverCfg.Command ||
			storedServer.Protocol != serverCfg.Protocol ||
			oauthChanged

		if hasChanged {
			changed = true
			r.logger.Info("Server configuration changed, updating storage",
				zap.String("server", serverCfg.Name),
				zap.Bool("new", !existsInStorage),
				zap.Bool("enabled_changed", existsInStorage && storedServer.Enabled != serverCfg.Enabled),
				zap.Bool("quarantined_changed", existsInStorage && storedServer.Quarantined != serverCfg.Quarantined),
				zap.Bool("oauth_changed", oauthChanged))

			// Clear OAuth state if OAuth config changed
			if oauthChanged && r.storageManager != nil {
				r.logger.Info("OAuth config changed, clearing cached OAuth state",
					zap.String("server", serverCfg.Name))
				if err := r.storageManager.ClearOAuthState(serverCfg.Name); err != nil {
					r.logger.Warn("Failed to clear OAuth state",
						zap.String("server", serverCfg.Name),
						zap.Error(err))
				}
			}
		}

		// Save synchronously to ensure storage is populated for API queries
		r.logger.Debug("Saving server to storage", zap.String("server", serverCfg.Name), zap.Bool("exists", existsInStorage))
		if err := r.storageManager.SaveUpstreamServer(serverCfg); err != nil {
			r.logger.Error("Failed to save/update server in storage", zap.Error(err), zap.String("server", serverCfg.Name))
			continue
		}
		r.logger.Debug("Successfully saved server to storage", zap.String("server", serverCfg.Name))
	}
	r.logger.Debug("Completed synchronous storage save phase")

	// SECOND: Manage upstream connections asynchronously (slow, can take 30s+)
	for _, serverCfg := range cfg.Servers {
		if serverCfg.Enabled {
			// Add server asynchronously to prevent blocking on connections
			go func(cfg *config.ServerConfig, cfgPath string) {
				if err := r.upstreamManager.AddServer(cfg.Name, cfg); err != nil {
					r.logger.Error("Failed to add/update upstream server", zap.Error(err), zap.String("server", cfg.Name))
				} else {
					// Register server identity for tool call tracking
					if _, err := r.storageManager.RegisterServerIdentity(cfg, cfgPath); err != nil {
						r.logger.Warn("Failed to register server identity",
							zap.Error(err),
							zap.String("server", cfg.Name))
					}
				}

				if cfg.Quarantined {
					r.logger.Info("Server is quarantined but kept connected for security inspection", zap.String("server", cfg.Name))
				}
			}(serverCfg, r.cfgPath)
		} else {
			// Remove server asynchronously to prevent blocking
			go func(name string) {
				r.upstreamManager.RemoveServer(name)
				r.logger.Info("Server is disabled, removing from active connections", zap.String("server", name))
			}(serverCfg.Name)
		}
	}

	serversToRemove := []string{}

	for _, serverName := range currentUpstreams {
		if _, exists := configuredServers[serverName]; !exists {
			serversToRemove = append(serversToRemove, serverName)
		}
	}

	for _, storedServer := range storedServers {
		if _, exists := configuredServers[storedServer.Name]; !exists {
			found := false
			for _, name := range serversToRemove {
				if name == storedServer.Name {
					found = true
					break
				}
			}
			if !found {
				serversToRemove = append(serversToRemove, storedServer.Name)
			}
		}
	}

	// Remove servers asynchronously to prevent blocking
	for _, serverName := range serversToRemove {
		changed = true
		go func(name string) {
			r.logger.Info("Removing server no longer in config", zap.String("server", name))
			r.upstreamManager.RemoveServer(name)
			if err := r.storageManager.DeleteUpstreamServer(name); err != nil {
				r.logger.Error("Failed to delete server from storage", zap.Error(err), zap.String("server", name))
			}
			if err := r.indexManager.DeleteServerTools(name); err != nil {
				r.logger.Error("Failed to delete server tools from index", zap.Error(err), zap.String("server", name))
			} else {
				r.logger.Info("Removed server tools from search index", zap.String("server", name))
			}
		}(serverName)
	}

	if len(serversToRemove) > 0 {
		r.logger.Info("Comprehensive server cleanup completed",
			zap.Int("removed_count", len(serversToRemove)),
			zap.Strings("removed_servers", serversToRemove))
	}

	r.logger.Info("Server synchronization completed",
		zap.Int("configured_servers", len(cfg.Servers)),
		zap.Int("removed_servers", len(serversToRemove)))

	if changed {
		r.emitServersChanged("sync", map[string]any{
			"configured": len(cfg.Servers),
			"removed":    len(serversToRemove),
		})
	}

	return nil
}

// SaveConfiguration persists the runtime configuration to disk.
func (r *Runtime) SaveConfiguration() error {
	latestServers, err := r.storageManager.ListUpstreamServers()
	if err != nil {
		r.logger.Error("Failed to get latest server list from storage for saving", zap.Error(err))
		return err
	}

	// Get current snapshot (lock-free)
	snapshot := r.ConfigSnapshot()
	if snapshot.Config == nil {
		return fmt.Errorf("runtime configuration is not available")
	}

	if snapshot.Path == "" {
		r.logger.Warn("Configuration file path is not available, cannot save configuration")
		return fmt.Errorf("configuration file path is not available")
	}

	// Create a copy of config to avoid mutations
	configCopy := snapshot.Clone()
	if configCopy == nil {
		return fmt.Errorf("failed to clone configuration")
	}

	// Update servers with latest from storage
	configCopy.Servers = latestServers

	r.logger.Debug("Saving configuration to disk",
		zap.Int("server_count", len(latestServers)),
		zap.String("config_path", snapshot.Path),
		zap.Bool("using_config_service", r.configSvc != nil))

	// Use ConfigService to save (doesn't hold locks, handles file I/O)
	if r.configSvc != nil {
		// Update the config service with latest servers first
		if err := r.configSvc.Update(configCopy, configsvc.UpdateTypeModify, "save_configuration"); err != nil {
			r.logger.Error("Failed to update config service", zap.Error(err))
			return err
		}
		// Then persist to disk
		if err := r.configSvc.SaveToFile(); err != nil {
			r.logger.Error("Failed to save config to file via config service", zap.Error(err))
			return err
		}
		r.logger.Debug("Config saved to disk via config service")
	} else {
		// Fallback to legacy save
		if err := config.SaveConfig(configCopy, snapshot.Path); err != nil {
			r.logger.Error("Failed to save config to file (legacy path)", zap.Error(err))
			return err
		}
		r.logger.Debug("Config saved to disk via legacy path")
	}

	// Update in-memory config (applies to both configSvc and legacy paths)
	r.logger.Debug("Updating in-memory config with latest servers",
		zap.Int("server_count", len(latestServers)))

	r.mu.Lock()
	oldServerCount := len(r.cfg.Servers)
	r.cfg.Servers = latestServers
	r.mu.Unlock()

	r.logger.Debug("Configuration saved and in-memory config updated",
		zap.Int("old_server_count", oldServerCount),
		zap.Int("new_server_count", len(latestServers)),
		zap.String("config_path", snapshot.Path))

	// Emit config.saved event to notify subscribers (Web UI, tray, etc.)
	r.emitConfigSaved(snapshot.Path)

	return nil
}

// ReloadConfiguration reloads the configuration from disk and resyncs state.
func (r *Runtime) ReloadConfiguration() error {
	r.logger.Info("Reloading configuration from disk")

	// Get current snapshot before reload
	oldSnapshot := r.ConfigSnapshot()
	oldServerCount := oldSnapshot.ServerCount()
	dataDir := oldSnapshot.Config.DataDir

	cfgPath := config.GetConfigPath(dataDir)

	// Use ConfigService for file reload (handles disk I/O without holding locks)
	var newSnapshot *configsvc.Snapshot
	var err error
	if r.configSvc != nil {
		newSnapshot, err = r.configSvc.ReloadFromFile()
	} else {
		// Fallback to legacy path
		newConfig, loadErr := config.LoadFromFile(cfgPath)
		if loadErr != nil {
			return fmt.Errorf("failed to reload config: %w", loadErr)
		}
		r.UpdateConfig(newConfig, cfgPath)
		newSnapshot = r.ConfigSnapshot()
	}

	if err != nil {
		return fmt.Errorf("failed to reload config: %w", err)
	}

	if err := r.LoadConfiguredServers(nil); err != nil {
		r.logger.Error("loadConfiguredServers failed", zap.Error(err))
		return fmt.Errorf("failed to reload servers: %w", err)
	}

	// MCP-2482: detect a telemetry enabled->disabled flip across the reload and
	// fire the one-time opt-out beacon. This covers config changes that arrive
	// via a disk reload (there is no fsnotify auto-watcher, so this is the
	// manual/triggered-reload path). nil-safe + fire-and-forget.
	if r.telemetryService != nil {
		r.telemetryService.NotifyConfigChanged(newSnapshot.Config)
	}

	// Spec 079 FR-012: re-gate the update checker on the disk-reload path too
	// (ApplyConfig covers the API path). SetConfig no-ops when unchanged.
	r.applyUpdateCheckConfig(newSnapshot.Config)

	go r.postConfigReload()

	r.logger.Info("Configuration reload completed",
		zap.String("path", newSnapshot.Path),
		zap.Int64("version", newSnapshot.Version),
		zap.Int("old_server_count", oldServerCount),
		zap.Int("new_server_count", newSnapshot.ServerCount()),
		zap.Int("server_delta", newSnapshot.ServerCount()-oldServerCount))

	r.emitConfigReloaded(newSnapshot.Path)

	return nil
}

func (r *Runtime) postConfigReload() {
	ctx := r.AppContext()
	if ctx == nil {
		r.logger.Error("Application context is nil, cannot trigger reconnection")
		return
	}

	r.logger.Info("Triggering immediate reconnection after config reload")

	connectCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
	defer cancel()

	if err := r.upstreamManager.ConnectAll(connectCtx); err != nil {
		r.logger.Warn("Some servers failed to reconnect after config reload", zap.Error(err))
	}

	select {
	case <-time.After(2 * time.Second):
		if err := r.DiscoverAndIndexTools(ctx); err != nil {
			r.logger.Error("Failed to re-index tools after config reload", zap.Error(err))
		}
	case <-ctx.Done():
		r.logger.Info("Tool re-indexing cancelled during config reload")
	}
}

// EnableServer enables or disables a server and persists the change.
func (r *Runtime) EnableServer(serverName string, enabled bool) error {
	r.logger.Info("Request to change server enabled state",
		zap.String("server", serverName),
		zap.Bool("enabled", enabled))

	if err := r.storageManager.EnableUpstreamServer(serverName, enabled); err != nil {
		r.logger.Error("Failed to update server enabled state in storage", zap.Error(err))
		return fmt.Errorf("failed to update server '%s' in storage: %w", serverName, err)
	}

	// Save configuration synchronously to ensure changes are persisted before returning
	if err := r.SaveConfiguration(); err != nil {
		r.logger.Error("Failed to save configuration after state change", zap.Error(err))
		return fmt.Errorf("failed to save configuration: %w", err)
	}

	// Emit config change activity for audit trail (Spec 024)
	action := "server_disabled"
	if enabled {
		action = "server_enabled"
	}
	r.EmitActivityConfigChange(action, serverName, "api", []string{"enabled"}, map[string]interface{}{"enabled": !enabled}, map[string]interface{}{"enabled": enabled})

	// Perform heavy operations (server reload, reconnection, reindexing) asynchronously
	// so the HTTP handler returns immediately after the storage write.
	// The SSE event is emitted after completion so the UI updates.
	go func() {
		if err := r.LoadConfiguredServers(nil); err != nil {
			r.logger.Error("Failed to synchronize runtime after enable toggle", zap.Error(err))
		}

		// When disabling a server, remove its tools from the search index
		// This ensures disabled server tools don't appear in search results
		if !enabled && r.indexManager != nil {
			if err := r.indexManager.DeleteServerTools(serverName); err != nil {
				r.logger.Warn("Failed to remove disabled server tools from index",
					zap.String("server", serverName),
					zap.Error(err))
			} else {
				r.logger.Info("Removed disabled server tools from search index",
					zap.String("server", serverName))
				// Refresh per-profile indexes that include this now-disabled server.
				r.reindexAffectedProfiles(serverName)
			}
		}

		r.HandleUpstreamServerChange(r.AppContext())

		r.emitServersChanged("enable_toggle", map[string]any{
			"server":  serverName,
			"enabled": enabled,
		})
	}()

	return nil
}

// QuarantineServer updates the quarantine state and persists the change.
// Security: When quarantining a server, all its tools are removed from the index
// to prevent Tool Poisoning Attacks (TPA) from exposing potentially malicious tool descriptions.
func (r *Runtime) QuarantineServer(serverName string, quarantined bool) error {
	r.logger.Info("Request to change server quarantine state",
		zap.String("server", serverName),
		zap.Bool("quarantined", quarantined))

	if err := r.storageManager.QuarantineUpstreamServer(serverName, quarantined); err != nil {
		r.logger.Error("Failed to update server quarantine state in storage", zap.Error(err))
		return fmt.Errorf("failed to update quarantine state for server '%s' in storage: %w", serverName, err)
	}

	// Security: When quarantining a server, immediately remove its tools from the index
	// to prevent TPA exposure through search results
	if quarantined && r.indexManager != nil {
		if err := r.indexManager.DeleteServerTools(serverName); err != nil {
			r.logger.Warn("Failed to remove quarantined server tools from index",
				zap.String("server", serverName),
				zap.Error(err))
			// Continue even if deletion fails - the server is still quarantined
		} else {
			r.logger.Info("Removed quarantined server tools from index",
				zap.String("server", serverName))
			// Refresh per-profile indexes that include this now-quarantined server.
			r.reindexAffectedProfiles(serverName)
		}
	}

	// Save configuration synchronously to ensure changes are persisted before returning
	if err := r.SaveConfiguration(); err != nil {
		r.logger.Error("Failed to save configuration after quarantine state change", zap.Error(err))
		return fmt.Errorf("failed to save configuration: %w", err)
	}

	// Reload configuration synchronously to ensure server state is updated before returning
	if err := r.LoadConfiguredServers(nil); err != nil {
		r.logger.Error("Failed to synchronize runtime after quarantine toggle", zap.Error(err))
		return fmt.Errorf("failed to reload configuration: %w", err)
	}

	// On unquarantine/approval, baseline-trust the server's CURRENT tool
	// snapshot: promote its pending (never-reviewed) tool records to approved.
	// Tool-level quarantine then guards only status=changed (rug-pull) records.
	// (Spec 032, MCP-2100; trust model confirmed in MCP-2081.) Done before the
	// re-index in HandleUpstreamServerChange so the newly-trusted tools become
	// immediately searchable. Best-effort: a promotion failure must not abort
	// the unquarantine the user already requested and that is already persisted.
	if !quarantined {
		if err := r.approveBaselineToolsForServer(serverName); err != nil {
			r.logger.Warn("Failed to baseline-approve tools on server unquarantine",
				zap.String("server", serverName),
				zap.Error(err))
		}
	}

	r.emitServersChanged("quarantine_toggle", map[string]any{
		"server":      serverName,
		"quarantined": quarantined,
	})

	// Emit activity event for quarantine state change
	reason := "Server unquarantined by administrator"
	if quarantined {
		reason = "Server quarantined for security review"
	}
	r.EmitActivityQuarantineChange(serverName, quarantined, reason)

	r.HandleUpstreamServerChange(r.AppContext())

	r.logger.Info("Successfully persisted server quarantine state change",
		zap.String("server", serverName),
		zap.Bool("quarantined", quarantined))

	return nil
}

// BulkEnableServers toggles the enabled state for multiple servers in a single
// storage/config save to avoid repeated file writes. Returns a map of per-server
// errors for operations that could not be applied.
func (r *Runtime) BulkEnableServers(serverNames []string, enabled bool) (map[string]error, error) {
	resultErrs := make(map[string]error)
	if len(serverNames) == 0 {
		return resultErrs, nil
	}

	servers, err := r.storageManager.ListUpstreamServers()
	if err != nil {
		return nil, fmt.Errorf("failed to list servers: %w", err)
	}
	serversByName := make(map[string]*config.ServerConfig, len(servers))
	for _, srv := range servers {
		serversByName[srv.Name] = srv
	}

	var changed []string
	for _, name := range serverNames {
		cfg, ok := serversByName[name]
		if !ok {
			resultErrs[name] = fmt.Errorf("server '%s' not found", name)
			continue
		}
		if cfg.Enabled == enabled {
			r.logger.Debug("Skipping server already in desired enabled state",
				zap.String("server", name),
				zap.Bool("enabled", enabled))
			continue
		}
		if err := r.storageManager.EnableUpstreamServer(name, enabled); err != nil {
			resultErrs[name] = fmt.Errorf("failed to update server '%s' in storage: %w", name, err)
			continue
		}
		changed = append(changed, name)
	}

	// Nothing changed; return collected errors (if any)
	if len(changed) == 0 {
		return resultErrs, nil
	}

	// Persist once and reload once for all changes
	if err := r.SaveConfiguration(); err != nil {
		return resultErrs, fmt.Errorf("failed to save configuration: %w", err)
	}

	if err := r.LoadConfiguredServers(nil); err != nil {
		return resultErrs, fmt.Errorf("failed to reload configuration: %w", err)
	}

	r.emitServersChanged("bulk_enable_toggle", map[string]any{
		"enabled": enabled,
		"count":   len(changed),
	})

	r.HandleUpstreamServerChange(r.AppContext())

	return resultErrs, nil
}

// lookupServerConfigForRestart returns the named server's config, preferring
// the on-disk mcp_config.json over the BoltDB cache. Falls back to BoltDB
// when the disk file is unreadable, malformed, or missing the named server.
//
// On a successful disk read, the resolved config is also written back to
// BoltDB so subsequent restarts (and any other read-from-storage code path)
// see the same value. Without this, only the synchronous restart that did
// the disk read would see the edit; the next one would replay storage and
// regress. See issue #467 for context.
func (r *Runtime) lookupServerConfigForRestart(serverName string) *config.ServerConfig {
	r.mu.RLock()
	cfgPath := r.cfgPath
	r.mu.RUnlock()

	if cfgPath != "" {
		diskCfg, err := config.LoadFromFile(cfgPath)
		if err != nil {
			r.logger.Warn("Failed to re-read config from disk during restart, falling back to storage",
				zap.String("path", cfgPath),
				zap.String("server", serverName),
				zap.Error(err))
		} else {
			for _, srv := range diskCfg.Servers {
				if srv != nil && srv.Name == serverName {
					if r.storageManager != nil {
						if saveErr := r.storageManager.SaveUpstreamServer(srv); saveErr != nil {
							r.logger.Warn("Failed to persist disk-loaded config to storage during restart",
								zap.String("server", serverName),
								zap.Error(saveErr))
						}
					}
					return srv
				}
			}
		}
	}

	if r.storageManager == nil {
		return nil
	}
	servers, err := r.storageManager.ListUpstreamServers()
	if err != nil {
		r.logger.Error("Failed to list servers during restart fallback",
			zap.String("server", serverName),
			zap.Error(err))
		return nil
	}
	for _, srv := range servers {
		if srv.Name == serverName {
			return srv
		}
	}
	return nil
}

// RestartServer restarts an upstream server by disconnecting and reconnecting it.
// Validation and disconnect are synchronous; reconnection and reindexing happen
// asynchronously so the caller (HTTP handler) returns immediately.
func (r *Runtime) RestartServer(serverName string) error {
	r.logger.Info("Request to restart server", zap.String("server", serverName))

	// Issue #467: pull the latest server config from disk before falling
	// back to BoltDB. There is no fsnotify-style auto file-watcher, so a
	// user who edits mcp_config.json and then triggers a restart would
	// otherwise replay stale env / headers / args / isolation data — only
	// the live REST PATCH path used to update them. Disk-first here closes
	// that gap for the (much more common) edit-then-restart UX.
	serverConfig := r.lookupServerConfigForRestart(serverName)
	if serverConfig == nil {
		return fmt.Errorf("server '%s' not found in configuration", serverName)
	}

	// If server is not enabled, enable it first (EnableServer is already async-safe)
	if !serverConfig.Enabled {
		r.logger.Info("Server is disabled, enabling it",
			zap.String("server", serverName))
		return r.EnableServer(serverName, true)
	}

	// Get the client to restart
	client, exists := r.upstreamManager.GetClient(serverName)
	if !exists {
		// Server is enabled but client doesn't exist, add it asynchronously
		r.logger.Info("Server client not found, attempting to create and connect",
			zap.String("server", serverName))
		go func() {
			if err := r.upstreamManager.AddServer(serverName, serverConfig); err != nil {
				r.logger.Error("Failed to add server during restart",
					zap.String("server", serverName),
					zap.Error(err))
				return
			}
			r.logger.Info("Successfully added server", zap.String("server", serverName))
			r.emitServersChanged("restart", map[string]any{
				"server": serverName,
				"reason": "server_added",
			})
		}()
		return nil
	}

	// CRITICAL FIX: Remove and recreate the client to pick up new secrets
	// Simply reconnecting reuses the old client with old (unresolved) secrets
	r.logger.Info("Removing existing client to recreate with fresh secret resolution",
		zap.String("server", serverName))

	// Disconnect and remove the old client synchronously (fast operation)
	if err := client.Disconnect(); err != nil {
		r.logger.Warn("Error disconnecting server during restart",
			zap.String("server", serverName),
			zap.Error(err))
	}

	// Remove the client from the manager (this will clean up resources)
	r.upstreamManager.RemoveServer(serverName)

	// Recreate the client and reindex tools asynchronously
	go func() {
		r.logger.Info("Creating new client with fresh secret resolution",
			zap.String("server", serverName))

		if err := r.upstreamManager.AddServer(serverName, serverConfig); err != nil {
			r.logger.Error("Failed to recreate server after restart",
				zap.String("server", serverName),
				zap.Error(err))
			return
		}

		r.logger.Info("Successfully recreated server with fresh secrets",
			zap.String("server", serverName))

		// Trigger tool reindexing and emit SSE event AFTER completion
		// to ensure frontend receives accurate tool counts
		if err := r.DiscoverAndIndexTools(r.AppContext()); err != nil {
			r.logger.Error("Failed to reindex tools after restart", zap.Error(err))
		}

		// Emit event AFTER tool discovery completes so frontend gets fresh stats
		r.emitServersChanged("restart", map[string]any{
			"server": serverName,
			"reason": "tool_discovery_complete",
		})
	}()

	r.logger.Info("Server restart initiated asynchronously", zap.String("server", serverName))
	return nil
}

// ForceReconnectAllServers triggers reconnection attempts for all managed servers.
func (r *Runtime) ForceReconnectAllServers(reason string) error {
	if r.upstreamManager == nil {
		return fmt.Errorf("upstream manager not initialized")
	}

	if r.logger != nil {
		r.logger.Info("Force reconnect requested for all upstream servers",
			zap.String("reason", reason))
	}

	result := r.upstreamManager.ForceReconnectAll(reason)

	if r.logger != nil {
		r.logger.Info("Force reconnect completed",
			zap.Int("total_servers", result.TotalServers),
			zap.Int("attempted", result.AttemptedServers),
			zap.Int("successful", len(result.SuccessfulServers)),
			zap.Int("failed", len(result.FailedServers)),
			zap.Int("skipped", len(result.SkippedServers)))
	}

	return nil
}

// HandleUpstreamServerChange should be called when upstream servers change.
func (r *Runtime) HandleUpstreamServerChange(ctx context.Context) {
	if ctx == nil {
		ctx = r.AppContext()
	}

	r.logger.Info("Upstream server configuration changed, triggering comprehensive update")

	phase := r.CurrentStatus().Phase
	r.UpdatePhase(phase, "Upstream servers updated")

	// Tool discovery runs in background goroutine, and SSE event is emitted
	// AFTER discovery completes to ensure frontend receives accurate tool counts.
	// This fixes the race condition where stale stats were returned because
	// the event was emitted before StateView was updated.
	go func() {
		if err := r.DiscoverAndIndexTools(ctx); err != nil {
			r.logger.Error("Failed to update tool index after upstream change", zap.Error(err))
		}
		r.cleanupOrphanedIndexEntries()

		// Emit event AFTER tool discovery completes so frontend gets fresh stats
		r.emitServersChanged("tools_indexed", map[string]any{
			"phase":  phase,
			"reason": "tool_discovery_complete",
		})
	}()
}

func (r *Runtime) cleanupOrphanedIndexEntries() {
	if r.indexManager == nil || r.upstreamManager == nil {
		return
	}

	r.logger.Debug("Checking for orphaned index entries")

	activeServers := r.upstreamManager.GetAllServerNames()
	activeServerMap := make(map[string]bool)
	for _, serverName := range activeServers {
		activeServerMap[serverName] = true
	}

	indexedServers, err := r.indexManager.GetAllIndexedServerNames()
	if err != nil {
		r.logger.Warn("Failed to retrieve indexed server names for orphan cleanup", zap.Error(err))
		return
	}

	var removedCount int
	for _, indexedServer := range indexedServers {
		if !activeServerMap[indexedServer] {
			r.logger.Info("Removing orphaned index entries for server no longer in config",
				zap.String("server", indexedServer))
			if err := r.indexManager.DeleteServerTools(indexedServer); err != nil {
				r.logger.Warn("Failed to delete orphaned index entries",
					zap.String("server", indexedServer),
					zap.Error(err))
			} else {
				removedCount++
			}
		}
	}

	r.logger.Debug("Orphaned index cleanup completed",
		zap.Int("active_servers", len(activeServers)),
		zap.Int("indexed_servers", len(indexedServers)),
		zap.Int("orphans_removed", removedCount))
}

// supervisorEventForwarder subscribes to supervisor events and emits runtime events
// to notify Web UI via SSE when server connection state changes.
func (r *Runtime) supervisorEventForwarder() {
	eventCh := r.supervisor.Subscribe()
	defer r.supervisor.Unsubscribe(eventCh)

	r.logger.Info("Supervisor event forwarder started - will emit servers.changed on connection state changes")

	// Get app context once with proper locking
	appCtx := r.AppContext()

	for {
		select {
		case event, ok := <-eventCh:
			if !ok {
				r.logger.Info("Supervisor event channel closed, stopping event forwarder")
				return
			}

			// Emit servers.changed event for connection state changes
			// This triggers Web UI to refresh server list via SSE
			switch event.Type {
			case supervisor.EventServerConnected:
				r.logger.Info("Server connected - emitting servers.changed event",
					zap.String("server", event.ServerName))
				r.emitServersChanged("server_connected", map[string]any{
					"server": event.ServerName,
				})

			case supervisor.EventServerDisconnected:
				r.logger.Info("Server disconnected - emitting servers.changed event",
					zap.String("server", event.ServerName))
				r.emitServersChanged("server_disconnected", map[string]any{
					"server": event.ServerName,
				})

			case supervisor.EventServerStateChanged:
				r.logger.Debug("Server state changed - emitting servers.changed event",
					zap.String("server", event.ServerName))
				r.emitServersChanged("server_state_changed", map[string]any{
					"server": event.ServerName,
				})
			}

		case <-appCtx.Done():
			r.logger.Info("App context cancelled, stopping supervisor event forwarder")
			return
		}
	}
}
