package mcp

import (
	"context"
	"errors"
	"fmt"
	"sort"
	"strings"
	"sync"
	"time"

	"gokin/internal/logging"
	"gokin/internal/tools"
)

// HealthChangeCallback is called when an MCP server's health status changes.
// name is the server name, healthy is the new status.
type HealthChangeCallback func(name string, healthy bool)

// ToolsChangedCallback is called after a server's tool list is refreshed,
// whether triggered manually via RefreshTools or by a server-initiated
// notifications/tools/list_changed notification. Fires in its own
// goroutine — Manager does not wait for it to return.
type ToolsChangedCallback func(name string)

// Manager manages multiple MCP server connections.
type Manager struct {
	servers map[string]*ServerConfig // Server configurations
	clients map[string]*Client       // Active client connections
	tools   []tools.Tool             // All registered tools from all servers
	health  map[string]*ServerHealth
	mu      sync.RWMutex

	// Auto-healing
	healMu        sync.Mutex // protects healingCancel and healingDone (separate from mu to avoid deadlock)
	healingCancel context.CancelFunc
	healingDone   chan struct{}

	// Status notifications
	onHealthChange HealthChangeCallback
	onToolsChanged ToolsChangedCallback
}

// ServerHealth tracks health status of an MCP server.
type ServerHealth struct {
	Healthy            bool
	LastCheck          time.Time
	ConsecutiveFails   int
	ReconnectAttempts  int
	MaxReconnects      int
	LastReconnectError string
}

// NewManager creates a new MCP manager.
func NewManager(servers []*ServerConfig) *Manager {
	m := &Manager{
		servers: make(map[string]*ServerConfig),
		clients: make(map[string]*Client),
		tools:   make([]tools.Tool, 0),
		health:  make(map[string]*ServerHealth),
	}

	for _, cfg := range servers {
		m.servers[cfg.Name] = cfg
	}

	return m
}

// SetHealthChangeCallback sets a callback invoked when a server's health changes.
func (m *Manager) SetHealthChangeCallback(cb HealthChangeCallback) {
	m.mu.Lock()
	defer m.mu.Unlock()
	m.onHealthChange = cb
}

// SetToolsChangedCallback sets a callback invoked after a server's tool list
// is refreshed. Use it to re-register tools with the upstream registry and
// push updated declarations to the LLM client.
func (m *Manager) SetToolsChangedCallback(cb ToolsChangedCallback) {
	m.mu.Lock()
	defer m.mu.Unlock()
	m.onToolsChanged = cb
}

// wireClientNotifications attaches the manager's notification dispatcher to
// a freshly-connected client. Safe to call without holding m.mu —
// SetNotificationHandler acquires its own lock internally.
func (m *Manager) wireClientNotifications(name string, client *Client) {
	if client == nil {
		return
	}
	client.SetNotificationHandler(func(method string, params any) {
		m.handleNotification(name, method, params)
	})
}

// handleNotification dispatches a server-initiated notification for the
// given server. Currently handles only tools/list_changed — the MCP spec
// lists other list_changed variants (resources/prompts) which we can add as
// those subsystems grow user surface.
func (m *Manager) handleNotification(serverName, method string, _ any) {
	switch method {
	case "notifications/tools/list_changed":
		// Run with a fresh context — the server's receiveLoop is our caller
		// via a goroutine, so ctx.Cancel on the original caller would not
		// make sense. Cap at 15s to match defaultServerTimeout.
		ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
		defer cancel()
		if err := m.RefreshTools(ctx, serverName); err != nil {
			logging.Warn("MCP tools/list_changed refresh failed",
				"server", serverName, "error", err)
		}
	}
}

// defaultServerTimeout is the per-server connection timeout.
const defaultServerTimeout = 15 * time.Second

// serverResult holds the result of a single server connection attempt.
type serverResult struct {
	name   string
	client *Client
	tools  []tools.Tool
	err    error
}

// ConnectAll connects to all servers configured for auto-connect.
// Connections are made in parallel with per-server timeouts.
func (m *Manager) ConnectAll(ctx context.Context) error {
	// Collect auto-connect servers under read lock
	m.mu.RLock()
	var toConnect []*ServerConfig
	for name, cfg := range m.servers {
		if !cfg.AutoConnect {
			logging.Debug("MCP server skipped (auto_connect=false)", "name", name)
			continue
		}
		toConnect = append(toConnect, cfg)
	}
	m.mu.RUnlock()

	if len(toConnect) == 0 {
		return nil
	}

	// Connect to all servers in parallel
	results := make(chan serverResult, len(toConnect))
	var wg sync.WaitGroup

	for _, cfg := range toConnect {
		wg.Add(1)
		go func(cfg *ServerConfig) {
			defer wg.Done()

			serverCtx, cancel := context.WithTimeout(ctx, defaultServerTimeout)
			defer cancel()

			res := serverResult{name: cfg.Name}

			// Create client (no lock needed — pure network I/O)
			client, err := NewClient(serverCtx, cfg)
			if err != nil {
				res.err = fmt.Errorf("failed to create client: %w", err)
				results <- res
				return
			}

			// Initialize connection
			if err := client.Initialize(serverCtx); err != nil {
				_ = client.Close()
				res.err = fmt.Errorf("initialization failed: %w", err)
				results <- res
				return
			}

			// List tools
			mcpTools, err := client.ListTools(serverCtx)
			if err != nil {
				_ = client.Close()
				res.err = fmt.Errorf("failed to list tools: %w", err)
				results <- res
				return
			}

			// Create tool wrappers
			for _, t := range mcpTools {
				tool := NewMCPTool(client, cfg.Name, cfg.ToolPrefix, t)
				res.tools = append(res.tools, tool)
			}

			res.client = client
			results <- res
		}(cfg)
	}

	// Close results channel when all goroutines complete
	go func() {
		defer func() {
			if r := recover(); r != nil {
				logging.Warn("MCP result collector recovered from panic", "error", r)
			}
		}()
		wg.Wait()
		close(results)
	}()

	// Collect results under write lock
	var errs []error
	var adopted []struct {
		name   string
		client *Client
	}
	m.mu.Lock()
	for res := range results {
		if res.err != nil {
			errs = append(errs, fmt.Errorf("%s: %w", res.name, res.err))
			continue
		}

		m.clients[res.name] = res.client
		m.tools = append(m.tools, res.tools...)
		adopted = append(adopted, struct {
			name   string
			client *Client
		}{res.name, res.client})

		logging.Info("MCP server connected",
			"name", res.name,
			"tools", len(res.tools))
	}
	m.mu.Unlock()

	// Install notification handlers outside the lock — SetNotificationHandler
	// acquires client.mu which has its own ordering rules.
	for _, a := range adopted {
		m.wireClientNotifications(a.name, a.client)
	}

	if len(errs) > 0 {
		return errors.Join(errs...)
	}

	return nil
}

// connectServer connects to a single server and registers its tools.
// Must be called with m.mu held. Notification-handler wiring happens after
// the caller releases the lock (see Connect).
func (m *Manager) connectServer(ctx context.Context, cfg *ServerConfig) error {
	// Create client
	client, err := NewClient(ctx, cfg)
	if err != nil {
		return fmt.Errorf("failed to create client: %w", err)
	}

	// Initialize connection
	if err := client.Initialize(ctx); err != nil {
		_ = client.Close()
		return fmt.Errorf("initialization failed: %w", err)
	}

	// List tools
	mcpTools, err := client.ListTools(ctx)
	if err != nil {
		_ = client.Close()
		return fmt.Errorf("failed to list tools: %w", err)
	}

	// Store client
	m.clients[cfg.Name] = client

	// Create tool wrappers
	for _, t := range mcpTools {
		tool := NewMCPTool(client, cfg.Name, cfg.ToolPrefix, t)
		m.tools = append(m.tools, tool)
	}

	logging.Info("MCP server connected",
		"name", cfg.Name,
		"tools", len(mcpTools))

	return nil
}

// Connect connects to a specific server by name.
func (m *Manager) Connect(ctx context.Context, name string) error {
	m.mu.Lock()
	cfg, exists := m.servers[name]
	if !exists {
		m.mu.Unlock()
		return fmt.Errorf("unknown server: %s", name)
	}

	// Check if already connected
	if _, connected := m.clients[name]; connected {
		m.mu.Unlock()
		return nil
	}

	if err := m.connectServer(ctx, cfg); err != nil {
		m.mu.Unlock()
		return err
	}
	client := m.clients[name]
	m.mu.Unlock()

	// Install notification handler outside the lock.
	m.wireClientNotifications(name, client)
	return nil
}

// Disconnect disconnects from a specific server.
func (m *Manager) Disconnect(name string) error {
	m.mu.Lock()
	defer m.mu.Unlock()

	client, exists := m.clients[name]
	if !exists {
		return nil
	}

	// Remove tools from this server
	newTools := make([]tools.Tool, 0, len(m.tools))
	for _, t := range m.tools {
		if mcpTool, ok := t.(*MCPTool); ok {
			if mcpTool.GetServerName() != name {
				newTools = append(newTools, t)
			}
		} else {
			newTools = append(newTools, t)
		}
	}
	m.tools = newTools

	// Close client
	delete(m.clients, name)
	if err := client.Close(); err != nil {
		return fmt.Errorf("failed to close client: %w", err)
	}

	logging.Info("MCP server disconnected", "name", name)
	return nil
}

// GetTools returns all tools from all connected servers.
func (m *Manager) GetTools() []tools.Tool {
	m.mu.RLock()
	defer m.mu.RUnlock()

	result := make([]tools.Tool, len(m.tools))
	copy(result, m.tools)
	return result
}

// MCPResource pairs a resource with the server that exposes it.
type MCPResource struct {
	Server      string
	URI         string
	Name        string
	Description string
	MIMEType    string
}

// namedMCPClient snapshots a client + its server name so the network round-trip
// happens outside the manager lock.
type namedMCPClient struct {
	name   string
	client *Client
}

func (m *Manager) snapshotClients() []namedMCPClient {
	m.mu.RLock()
	out := make([]namedMCPClient, 0, len(m.clients))
	for name, c := range m.clients {
		out = append(out, namedMCPClient{name: name, client: c})
	}
	m.mu.RUnlock()
	// Deterministic order. Load-bearing for GetPrompt/ReadResource first-success
	// resolution: prompt NAMES are not server-namespaced, so with two servers
	// exposing the same name, map-iteration order would pick a different owner
	// per call — prompt_args silently rendered against the wrong template.
	sort.Slice(out, func(i, j int) bool { return out[i].name < out[j].name })
	return out
}

// ListResources aggregates resources across all connected servers, tagging each
// with its owning server. Network I/O runs OUTSIDE m.mu (clients are snapshotted
// under the lock first), per the manager's "no network I/O under lock" invariant.
func (m *Manager) ListResources(ctx context.Context) []MCPResource {
	return fanOutCatalog(m.snapshotClients(), "list-resources",
		func(ctx context.Context, nc namedMCPClient) ([]MCPResource, error) {
			resources, err := nc.client.ListResources(ctx)
			if err != nil {
				return nil, err
			}
			items := make([]MCPResource, 0, len(resources))
			for _, r := range resources {
				if r == nil {
					continue
				}
				items = append(items, MCPResource{
					Server:      nc.name,
					URI:         r.URI,
					Name:        r.Name,
					Description: r.Description,
					MIMEType:    r.MIMEType,
				})
			}
			return items, nil
		}, ctx)
}

// fanOutCatalog queries every connected server CONCURRENTLY and flattens the
// per-server results in snapshot (sorted-server-name) order. Serial iteration
// let one hung stdio server consume the caller's whole timeout budget, so every
// server after it in the loop silently vanished from the catalog (review
// finding) — fan-out makes total latency max(server), not sum(server). A
// server erroring (e.g. capability not implemented) is skipped with a Debug
// log, not surfaced.
func fanOutCatalog[T any](clients []namedMCPClient, op string, query func(context.Context, namedMCPClient) ([]T, error), ctx context.Context) []T {
	perServer := make([][]T, len(clients))
	var wg sync.WaitGroup
	for i, nc := range clients {
		wg.Add(1)
		go func(i int, nc namedMCPClient) {
			defer wg.Done()
			items, err := query(ctx, nc)
			if err != nil {
				logging.Debug("MCP "+op+" skipped", "server", nc.name, "error", err)
				return
			}
			perServer[i] = items
		}(i, nc)
	}
	wg.Wait()

	var out []T
	for _, items := range perServer {
		out = append(out, items...)
	}
	return out
}

// ReadResource fetches a resource by URI from whichever connected server owns
// it. MCP servers error on URIs they don't expose, so we try each connected
// server and return the first success. Returns (text, owningServer, error).
func (m *Manager) ReadResource(ctx context.Context, uri string) (string, string, error) {
	clients := m.snapshotClients()
	if len(clients) == 0 {
		return "", "", fmt.Errorf("no connected MCP servers")
	}

	// Accumulate every server's failure so the final error explains WHY each
	// one declined (timeout vs unknown-URI vs not-implemented) instead of
	// masking all but the last.
	var errs []string
	for _, nc := range clients {
		res, err := nc.client.ReadResource(ctx, uri)
		if err != nil {
			errs = append(errs, fmt.Sprintf("%s: %v", nc.name, err))
			continue
		}
		var sb strings.Builder
		for _, content := range res.Contents {
			if content == nil {
				continue
			}
			switch {
			case content.Text != "":
				sb.WriteString(content.Text)
			case content.Blob != "":
				fmt.Fprintf(&sb, "[binary resource %s, %d base64 bytes]", content.MIMEType, len(content.Blob))
			}
		}
		return sb.String(), nc.name, nil
	}
	return "", "", fmt.Errorf("resource %q not served by any connected MCP server (%s)", uri, strings.Join(errs, "; "))
}

// MCPPrompt pairs a prompt template with the server that exposes it.
type MCPPrompt struct {
	Server      string
	Name        string
	Description string
	Arguments   []*PromptArgument
}

// ListPrompts aggregates prompt templates across all connected servers, tagging
// each with its owning server. Network I/O runs OUTSIDE m.mu (clients snapshotted
// under the lock first), per the manager's "no network I/O under lock" invariant.
func (m *Manager) ListPrompts(ctx context.Context) []MCPPrompt {
	return fanOutCatalog(m.snapshotClients(), "list-prompts",
		func(ctx context.Context, nc namedMCPClient) ([]MCPPrompt, error) {
			prompts, err := nc.client.ListPrompts(ctx)
			if err != nil {
				// A server may simply not implement prompts — skip, don't surface.
				return nil, err
			}
			items := make([]MCPPrompt, 0, len(prompts))
			for _, p := range prompts {
				if p == nil {
					continue
				}
				items = append(items, MCPPrompt{
					Server:      nc.name,
					Name:        p.Name,
					Description: p.Description,
					Arguments:   p.Arguments,
				})
			}
			return items, nil
		}, ctx)
}

// GetPrompt fetches a prompt by name from whichever connected server owns it.
// MCP servers error on prompt names they don't expose, so we try each connected
// server and return the first success. Returns (renderedText, owningServer, error).
func (m *Manager) GetPrompt(ctx context.Context, name string, arguments map[string]any) (string, string, error) {
	clients := m.snapshotClients()
	if len(clients) == 0 {
		return "", "", fmt.Errorf("no connected MCP servers")
	}

	var errs []string
	for _, nc := range clients {
		res, err := nc.client.GetPrompt(ctx, name, arguments)
		if err != nil {
			errs = append(errs, fmt.Sprintf("%s: %v", nc.name, err))
			continue
		}
		var sb strings.Builder
		if res.Description != "" {
			sb.WriteString(res.Description)
			sb.WriteString("\n\n")
		}
		for _, msg := range res.Messages {
			if msg == nil {
				continue
			}
			role := msg.Role
			if role == "" {
				role = "user"
			}
			text := ""
			if msg.Content != nil {
				text = msg.Content.Text
			}
			fmt.Fprintf(&sb, "[%s]\n%s\n\n", role, text)
		}
		return strings.TrimSpace(sb.String()), nc.name, nil
	}
	return "", "", fmt.Errorf("prompt %q not served by any connected MCP server (%s)", name, strings.Join(errs, "; "))
}

// GetClient returns the client for a specific server.
func (m *Manager) GetClient(name string) (*Client, bool) {
	m.mu.RLock()
	defer m.mu.RUnlock()

	client, ok := m.clients[name]
	return client, ok
}

// GetConnectedServers returns the names of all connected servers.
func (m *Manager) GetConnectedServers() []string {
	m.mu.RLock()
	defer m.mu.RUnlock()

	names := make([]string, 0, len(m.clients))
	for name := range m.clients {
		names = append(names, name)
	}
	return names
}

// GetServerConfig returns the configuration for a server.
func (m *Manager) GetServerConfig(name string) (*ServerConfig, bool) {
	m.mu.RLock()
	defer m.mu.RUnlock()

	cfg, ok := m.servers[name]
	return cfg, ok
}

// GetAllServerConfigs returns all server configurations.
func (m *Manager) GetAllServerConfigs() []*ServerConfig {
	m.mu.RLock()
	defer m.mu.RUnlock()

	configs := make([]*ServerConfig, 0, len(m.servers))
	for _, cfg := range m.servers {
		configs = append(configs, cfg)
	}
	return configs
}

// AddServer adds a new server configuration.
func (m *Manager) AddServer(cfg *ServerConfig) error {
	m.mu.Lock()
	defer m.mu.Unlock()

	if _, exists := m.servers[cfg.Name]; exists {
		return fmt.Errorf("server already exists: %s", cfg.Name)
	}

	m.servers[cfg.Name] = cfg
	return nil
}

// RemoveServer removes a server configuration and disconnects if connected.
func (m *Manager) RemoveServer(name string) error {
	m.mu.Lock()
	defer m.mu.Unlock()

	// Disconnect if connected
	if client, exists := m.clients[name]; exists {
		_ = client.Close()
		delete(m.clients, name)
	}

	// Remove configuration
	delete(m.servers, name)
	return nil
}

// Shutdown disconnects from all servers and stops background processes.
func (m *Manager) Shutdown(ctx context.Context) error {
	m.StopHealthCheck()

	m.mu.Lock()
	defer m.mu.Unlock()

	var lastErr error
	for name, client := range m.clients {
		if err := client.Close(); err != nil {
			logging.Warn("MCP client close error", "name", name, "error", err)
			lastErr = err
		}
	}

	m.clients = make(map[string]*Client)
	m.tools = nil

	logging.Debug("MCP manager shutdown complete")
	return lastErr
}

// CheckHealth checks the health of all connected servers.
func (m *Manager) CheckHealth(ctx context.Context) {
	// Read client fail counts under read lock to avoid blocking tool execution.
	m.mu.RLock()
	type healthUpdate struct {
		name  string
		fails int
	}
	updates := make([]healthUpdate, 0, len(m.clients))
	for name, client := range m.clients {
		updates = append(updates, healthUpdate{name: name, fails: client.ConsecutiveFails()})
	}
	m.mu.RUnlock()

	// Write health state under write lock.
	m.mu.Lock()
	defer m.mu.Unlock()
	for _, u := range updates {
		h, ok := m.health[u.name]
		if !ok {
			h = &ServerHealth{Healthy: true}
			m.health[u.name] = h
		}

		h.ConsecutiveFails = u.fails
		h.LastCheck = time.Now()

		wasHealthy := h.Healthy
		if u.fails >= 3 {
			if h.Healthy {
				logging.Warn("MCP server marked unhealthy", "name", u.name, "fails", u.fails)
			}
			h.Healthy = false
		} else {
			h.Healthy = true
		}

		// Notify on health transition
		if wasHealthy != h.Healthy && m.onHealthChange != nil {
			go m.onHealthChange(u.name, h.Healthy)
		}
	}
}

// IsHealthy returns whether a server is healthy.
func (m *Manager) IsHealthy(name string) bool {
	m.mu.RLock()
	defer m.mu.RUnlock()

	h, ok := m.health[name]
	if !ok {
		return true // Unknown servers are considered healthy
	}
	return h.Healthy
}

// ServerStatus contains status information about an MCP server.
type ServerStatus struct {
	Name       string
	Connected  bool
	Healthy    bool
	ServerInfo *ServerInfo
	ToolCount  int
	ToolNames  []string
}

// GetServerStatus returns status information for all servers.
func (m *Manager) GetServerStatus() []*ServerStatus {
	m.mu.RLock()
	defer m.mu.RUnlock()

	statuses := make([]*ServerStatus, 0, len(m.servers))

	for name, cfg := range m.servers {
		status := &ServerStatus{
			Name:      name,
			Connected: false,
		}

		if client, ok := m.clients[name]; ok {
			status.Connected = true
			if h, ok := m.health[name]; ok {
				status.Healthy = h.Healthy
			} else {
				status.Healthy = true
			}
			status.ServerInfo = client.GetServerInfo()

			// Count tools from this server
			for _, t := range m.tools {
				if mcpTool, ok := t.(*MCPTool); ok {
					if mcpTool.GetServerName() == name {
						status.ToolCount++
						status.ToolNames = append(status.ToolNames, mcpTool.Name())
					}
				}
			}
		} else {
			status.Healthy = false
		}

		// Use config name if server info not available
		if status.ServerInfo == nil {
			status.ServerInfo = &ServerInfo{Name: cfg.Name}
		}

		statuses = append(statuses, status)
	}

	return statuses
}

// RefreshTools refreshes the tool list from a specific server. The manager
// lock is released during the network call so parallel reads (GetTools,
// GetServerStatus) are not blocked by a slow server.
func (m *Manager) RefreshTools(ctx context.Context, name string) error {
	m.mu.RLock()
	client, clientOk := m.clients[name]
	cfg, cfgOk := m.servers[name]
	m.mu.RUnlock()

	if !clientOk {
		return fmt.Errorf("server not connected: %s", name)
	}
	if !cfgOk {
		return fmt.Errorf("server config not found: %s", name)
	}

	// Network call outside the lock.
	mcpTools, err := client.ListTools(ctx)
	if err != nil {
		return fmt.Errorf("failed to list tools: %w", err)
	}

	// Commit the new tool list + invoke callback under lock. Between the
	// RUnlock above and this Lock, another thread may have called
	// Disconnect(name) — closing our captured client. Re-verify that the
	// client we listed against is still the one registered; otherwise we'd
	// overwrite m.tools with wrappers pointing at a closed client.
	m.mu.Lock()
	if current, stillOk := m.clients[name]; !stillOk || current != client {
		m.mu.Unlock()
		return fmt.Errorf("server %s was disconnected or reconnected during refresh", name)
	}
	newTools := make([]tools.Tool, 0, len(m.tools))
	for _, t := range m.tools {
		if mcpTool, ok := t.(*MCPTool); ok {
			if mcpTool.GetServerName() != name {
				newTools = append(newTools, t)
			}
		} else {
			newTools = append(newTools, t)
		}
	}
	for _, t := range mcpTools {
		tool := NewMCPTool(client, name, cfg.ToolPrefix, t)
		newTools = append(newTools, tool)
	}
	m.tools = newTools
	cb := m.onToolsChanged
	m.mu.Unlock()

	if cb != nil {
		// Run callback asynchronously so slow upstream work (e.g. SetTools
		// pushing a new declaration to the LLM client) doesn't stall the
		// receiveLoop that triggered this refresh.
		go func() {
			defer func() {
				if r := recover(); r != nil {
					logging.Warn("MCP onToolsChanged panic",
						"server", name, "recover", r)
				}
			}()
			cb(name)
		}()
	}

	logging.Debug("MCP tools refreshed", "name", name, "tools", len(mcpTools))
	return nil
}

// StartHealthCheck starts a background goroutine that periodically checks
// server health and attempts to reconnect unhealthy servers.
func (m *Manager) StartHealthCheck(ctx context.Context, interval time.Duration) {
	m.healMu.Lock()
	defer m.healMu.Unlock()

	// Stop existing health check if running
	if m.healingCancel != nil {
		m.healingCancel()
		if m.healingDone != nil {
			<-m.healingDone
		}
	}

	if interval <= 0 {
		interval = 30 * time.Second
	}

	healCtx, cancel := context.WithCancel(ctx)
	m.healingCancel = cancel
	m.healingDone = make(chan struct{})

	done := m.healingDone
	go m.healthCheckLoop(healCtx, interval, done)
}

// StopHealthCheck stops the background health check goroutine.
func (m *Manager) StopHealthCheck() {
	m.healMu.Lock()
	cancel := m.healingCancel
	done := m.healingDone
	m.healingCancel = nil
	m.healingDone = nil
	m.healMu.Unlock()

	if cancel != nil {
		cancel()
		if done != nil {
			select {
			case <-done:
			case <-time.After(10 * time.Second):
				logging.Warn("health check loop did not exit in time")
			}
		}
	}
}

const maxReconnectAttempts = 10

// healthCheckLoop runs periodically to check health and reconnect unhealthy servers.
func (m *Manager) healthCheckLoop(ctx context.Context, interval time.Duration, done chan struct{}) {
	defer close(done)

	ticker := time.NewTicker(interval)
	defer ticker.Stop()

	for {
		select {
		case <-ctx.Done():
			return
		case <-ticker.C:
			m.CheckHealth(ctx)
			m.tryReconnectUnhealthy(ctx)
		}
	}
}

// reconnectCandidate holds the info needed to reconnect a single unhealthy server.
type reconnectCandidate struct {
	name      string
	cfg       *ServerConfig
	health    *ServerHealth
	oldClient *Client
}

// tryReconnectUnhealthy attempts to reconnect servers marked as unhealthy.
// Network I/O is performed outside the lock to avoid blocking other operations.
func (m *Manager) tryReconnectUnhealthy(ctx context.Context) {
	// Phase 1: collect candidates and clean up old state under lock.
	m.mu.Lock()
	var candidates []reconnectCandidate
	for name, h := range m.health {
		if h.Healthy {
			continue
		}
		if h.MaxReconnects == 0 {
			h.MaxReconnects = maxReconnectAttempts
		}
		if h.ReconnectAttempts >= h.MaxReconnects {
			continue
		}
		cfg, exists := m.servers[name]
		if !exists {
			continue
		}

		h.ReconnectAttempts++
		logging.Info("MCP auto-reconnect attempt",
			"name", name,
			"attempt", h.ReconnectAttempts,
			"max", h.MaxReconnects)

		c := reconnectCandidate{name: name, cfg: cfg, health: h}

		// Detach old client so we can close it outside the lock.
		if oldClient, ok := m.clients[name]; ok {
			c.oldClient = oldClient
			delete(m.clients, name)

			// Remove old tools from this server
			newTools := make([]tools.Tool, 0, len(m.tools))
			for _, t := range m.tools {
				if mcpTool, ok := t.(*MCPTool); ok {
					if mcpTool.GetServerName() != name {
						newTools = append(newTools, t)
					}
				} else {
					newTools = append(newTools, t)
				}
			}
			m.tools = newTools
		}

		candidates = append(candidates, c)
	}
	m.mu.Unlock()

	if len(candidates) == 0 {
		return
	}

	// Phase 2: perform network I/O without holding the lock.
	for _, c := range candidates {
		if c.oldClient != nil {
			_ = c.oldClient.Close()
		}

		client, err := NewClient(ctx, c.cfg)
		if err != nil {
			m.mu.Lock()
			c.health.LastReconnectError = err.Error()
			m.mu.Unlock()
			logging.Warn("MCP auto-reconnect failed", "name", c.name, "error", err)
			continue
		}

		if err := client.Initialize(ctx); err != nil {
			_ = client.Close()
			m.mu.Lock()
			c.health.LastReconnectError = err.Error()
			m.mu.Unlock()
			logging.Warn("MCP auto-reconnect failed", "name", c.name, "error", err)
			continue
		}

		mcpTools, err := client.ListTools(ctx)
		if err != nil {
			_ = client.Close()
			m.mu.Lock()
			c.health.LastReconnectError = err.Error()
			m.mu.Unlock()
			logging.Warn("MCP auto-reconnect failed", "name", c.name, "error", err)
			continue
		}

		// Phase 3: commit results under lock.
		m.mu.Lock()
		m.clients[c.name] = client
		for _, t := range mcpTools {
			tool := NewMCPTool(client, c.cfg.Name, c.cfg.ToolPrefix, t)
			m.tools = append(m.tools, tool)
		}
		c.health.Healthy = true
		c.health.ConsecutiveFails = 0
		c.health.ReconnectAttempts = 0
		c.health.LastReconnectError = ""
		m.mu.Unlock()

		// Wire notification handler on the fresh client so tools/list_changed
		// from the reconnected server still flows through our dispatcher.
		m.wireClientNotifications(c.name, client)

		logging.Info("MCP server auto-reconnected successfully", "name", c.name, "tools", len(mcpTools))
	}
}
