package profile

import (
	"bytes"
	"context"
	"crypto/sha256"
	"encoding/hex"
	"errors"
	"fmt"
	"log"
	"log/slog"
	"os"
	"path/filepath"
	"reflect"
	"strings"
	"sync"
	"time"

	"github.com/pingcap/failpoint"
	"go.yaml.in/yaml/v3"

	"github.com/tidbcloud/AgentTeam/desktop/internal/types"
)

const (
	serversFileName        = "servers.yaml"
	legacyChannelsFileName = "channels.yaml"
	serverReconcileTimeout = 10 * time.Second
)

var errServerRevisionChanged = errors.New("servers file revision changed")

// ServerManager provides CRUD operations for AgentTeam server auth configurations.
type ServerManager struct {
	mgr     *Manager
	runtime *serverRuntimeControl
	locksMu sync.Mutex
	locks   map[string]*sync.Mutex
}

// Get returns the AgentTeam server auth with the given ID.
func (sm *ServerManager) Get(id string) (AgentTeamServer, bool) {
	sm.mgr.mu.RLock()
	defer sm.mgr.mu.RUnlock()
	for _, server := range sm.mgr.servers {
		if server.ID == id {
			return server, true
		}
	}
	return AgentTeamServer{}, false
}

// All returns all configured AgentTeam server auth entries.
func (sm *ServerManager) All() []AgentTeamServer {
	sm.mgr.mu.RLock()
	defer sm.mgr.mu.RUnlock()
	result := make([]AgentTeamServer, len(sm.mgr.servers))
	copy(result, sm.mgr.servers)
	return result
}

// Add adds a new AgentTeam server auth configuration.
func (sm *ServerManager) Add(server *AgentTeamServer) error {
	if server == nil {
		return fmt.Errorf("server is required")
	}
	if err := normalizeAgentTeamServerForSave(server); err != nil {
		return err
	}
	if server.ID == "" {
		server.ID = generateServerID()
	}
	unlock := sm.lockServer(server.ID)
	defer unlock()
	ctx, err := sm.mutationContext()
	if err != nil {
		return err
	}
	defer ctx.release()

	current := *server
	if ctx.mode == ServerLifecycleOffline {
		if err := sm.persistServerValue(&current, false); err != nil {
			return err
		}
		*server = current
		return nil
	}
	if err := sm.commitRuntimeMutation(
		ctx.participant,
		nil,
		&current,
		func() error { return sm.persistServerValue(&current, false) },
	); err != nil {
		return err
	}
	*server = current
	return nil
}

// Update updates an existing AgentTeam server auth configuration.
func (sm *ServerManager) Update(server AgentTeamServer) error {
	if err := normalizeAgentTeamServerForSave(&server); err != nil {
		return err
	}
	ctx, err := sm.mutationContext()
	if err != nil {
		return err
	}
	ctx.release()
	if ctx.mode != ServerLifecycleOffline {
		return ErrOnlineServerSnapshotUpdate
	}
	unlock := sm.lockServer(server.ID)
	defer unlock()
	ctx, err = sm.mutationContext()
	if err != nil {
		return err
	}
	defer ctx.release()
	return sm.persistServerValue(&server, true)
}

// Patch updates one server from the current value read inside its transaction.
// It is the canonical daemon-online mutation surface.
func (sm *ServerManager) Patch(id string, update ServerUpdate) (AgentTeamServer, error) {
	id = strings.TrimSpace(id)
	if id == "" {
		return AgentTeamServer{}, fmt.Errorf("server is required")
	}
	unlock := sm.lockServer(id)
	defer unlock()
	ctx, err := sm.mutationContext()
	if err != nil {
		return AgentTeamServer{}, err
	}
	defer ctx.release()

	current, ok := sm.Get(id)
	if !ok {
		return AgentTeamServer{}, fmt.Errorf("server %q not found", id)
	}
	if ctx.mode == ServerLifecycleActive && sm.ServerRuntimeHalted(id) {
		return AgentTeamServer{}, ErrServerRuntimeHalted
	}
	next := applyServerUpdate(current, update)
	if err := normalizeAgentTeamServerForSave(&next); err != nil {
		return AgentTeamServer{}, err
	}
	if ctx.mode == ServerLifecycleOffline {
		return next, sm.persistServerValue(&next, true)
	}
	if err := sm.commitRuntimeMutation(
		ctx.participant,
		&current,
		&next,
		func() error { return sm.persistServerValue(&next, true) },
	); err != nil {
		return AgentTeamServer{}, err
	}
	return next, nil
}

// PatchConnectionStatus atomically updates only the local reconnect marker.
// It never reconciles the runtime or fences gateway work.
func (sm *ServerManager) PatchConnectionStatus(id, status string) (AgentTeamServer, error) {
	id = strings.TrimSpace(id)
	unlock := sm.lockServer(id)
	defer unlock()
	ctx, err := sm.mutationContext()
	if err != nil {
		return AgentTeamServer{}, err
	}
	defer ctx.release()

	current, ok := sm.Get(id)
	if !ok {
		return AgentTeamServer{}, fmt.Errorf("server %q not found", id)
	}
	if ctx.mode == ServerLifecycleActive && sm.ServerRuntimeHalted(id) {
		return AgentTeamServer{}, ErrServerRuntimeHalted
	}
	current.ConnectionStatus = strings.TrimSpace(status)
	// ConnectionStatus is the authoritative value for this focused patch. Rebuild
	// the compatibility payload first so normalizeAgentTeamServerForSave cannot restore
	// the stale marker from the previous payload when the status is being cleared.
	current.Config = agentteamServerCompatConfig(current)
	if err := normalizeAgentTeamServerForSave(&current); err != nil {
		return AgentTeamServer{}, err
	}
	if err := sm.persistServerValue(&current, true); err != nil {
		return AgentTeamServer{}, err
	}
	return current, nil
}

// UpdateRuntimeWorkspaceBinding atomically persists a workspace binding learned
// by the running Protocol and applies it to that Protocol without restarting its
// own run agentteam.
func (sm *ServerManager) UpdateRuntimeWorkspaceBinding(id, workspaceID string) (AgentTeamServer, error) {
	id = strings.TrimSpace(id)
	workspaceID = strings.TrimSpace(workspaceID)
	unlock := sm.lockServer(id)
	defer unlock()
	ctx, err := sm.mutationContext()
	if err != nil {
		return AgentTeamServer{}, err
	}
	defer ctx.release()

	current, ok := sm.Get(id)
	if !ok {
		return AgentTeamServer{}, fmt.Errorf("server %q not found", id)
	}
	if ctx.mode == ServerLifecycleActive && sm.ServerRuntimeHalted(id) {
		return AgentTeamServer{}, ErrServerRuntimeHalted
	}
	if strings.TrimSpace(current.WorkspaceID) == workspaceID {
		return current, nil
	}
	next := current
	next.WorkspaceID = workspaceID
	if err := normalizeAgentTeamServerForSave(&next); err != nil {
		return AgentTeamServer{}, err
	}
	if ctx.mode == ServerLifecycleOffline {
		if err := sm.persistServerValue(&next, true); err != nil {
			return AgentTeamServer{}, err
		}
		return next, nil
	}
	if err := sm.commitRuntimeMutation(
		ctx.participant,
		&current,
		&next,
		func() error { return sm.persistServerValue(&next, true) },
	); err != nil {
		return AgentTeamServer{}, err
	}
	return next, nil
}

// Delete removes a AgentTeam server auth entry by ID and deletes all agents that were
// synced from this server (i.e., agents with Source.Type == "agentteam" and Source.ID == id).
func (sm *ServerManager) Delete(id string) error {
	id = strings.TrimSpace(id)
	unlock := sm.lockServer(id)
	defer unlock()
	ctx, err := sm.mutationContext()
	if err != nil {
		return err
	}
	defer ctx.release()
	current, ok := sm.Get(id)
	if !ok {
		return fmt.Errorf("server %q not found", id)
	}
	if ctx.mode == ServerLifecycleActive && sm.ServerRuntimeHalted(id) {
		return ErrServerRuntimeHalted
	}
	if ctx.mode == ServerLifecycleOffline {
		if err := sm.persistServerDelete(id); err != nil {
			return err
		}
	} else {
		if err := sm.commitRuntimeMutation(
			ctx.participant,
			&current,
			nil,
			func() error { return sm.persistServerDelete(id) },
		); err != nil {
			return err
		}
	}
	return sm.deleteServerAgents(id)
}

// SetEnabled enables or disables a AgentTeam server auth entry.
func (sm *ServerManager) SetEnabled(id string, enabled bool) error {
	_, err := sm.Patch(id, ServerUpdate{Enabled: &enabled})
	return err
}

func (sm *ServerManager) commitRuntimeMutation(
	participant ServerRuntimeParticipant,
	previous, current *AgentTeamServer,
	persist func() error,
) error {
	var transition ServerTransition
	if serverRuntimeChanged(previous, current) {
		serverID := ""
		if previous != nil {
			serverID = previous.ID
		} else if current != nil {
			serverID = current.ID
		}
		sm.markServerRuntimeTransitioning(serverID)
		transition = participant.BeginServerTransition(serverID)
	}
	if err := persist(); err != nil {
		if transition != nil {
			transition.Complete()
			sm.clearServerRuntimeTransitioning(serverIDForMutation(previous, current))
		}
		return err
	}
	if err := reconcileServerRuntime(
		participant,
		cloneAgentTeamServerPointer(previous),
		cloneAgentTeamServerPointer(current),
	); err == nil {
		if transition != nil {
			transition.Complete()
			sm.clearServerRuntimeTransitioning(serverIDForMutation(previous, current))
		}
		if current == nil || !current.Enabled {
			participant.InvalidateServer(previous.ID)
		}
		return nil
	} else {
		mutationErr := err
		if restoreErr := sm.restoreServerByID(previous, current); restoreErr != nil {
			if transition != nil {
				transition.FailClosed()
			}
			serverID := serverIDForMutation(previous, current)
			sm.markServerRuntimeHalted(serverID)
			sm.clearServerRuntimeTransitioning(serverID)
			participant.InvalidateServer(serverID)
			return fmt.Errorf("%w (config rollback failed: %v)", mutationErr, restoreErr)
		}
		if runtimeErr := reconcileServerRuntime(
			participant,
			cloneAgentTeamServerPointer(current),
			cloneAgentTeamServerPointer(previous),
		); runtimeErr != nil {
			if transition != nil {
				transition.FailClosed()
			}
			serverID := serverIDForMutation(previous, current)
			sm.markServerRuntimeHalted(serverID)
			sm.clearServerRuntimeTransitioning(serverID)
			participant.InvalidateServer(serverID)
			return fmt.Errorf("%w (runtime rollback failed: %v)", mutationErr, runtimeErr)
		}
		if transition != nil {
			transition.Complete()
			sm.clearServerRuntimeTransitioning(serverIDForMutation(previous, current))
		}
		return mutationErr
	}
}

func reconcileServerRuntime(
	participant ServerRuntimeParticipant,
	previous, current *AgentTeamServer,
) error {
	ctx, cancel := context.WithTimeout(context.Background(), serverReconcileTimeout)
	defer cancel()
	return participant.ReconcileServer(ctx, previous, current)
}

func serverIDForMutation(previous, current *AgentTeamServer) string {
	if previous != nil {
		return previous.ID
	}
	if current != nil {
		return current.ID
	}
	return ""
}

// RepairServerRuntime explicitly repairs a quarantined server. It fences the
// server, reconciles the live runtime from the current persisted identity, and
// clears quarantine only after success.
func (sm *ServerManager) RepairServerRuntime(id string) error {
	id = strings.TrimSpace(id)
	unlock := sm.lockServer(id)
	defer unlock()
	ctx, err := sm.mutationContext()
	if err != nil {
		return err
	}
	defer ctx.release()
	if ctx.mode != ServerLifecycleActive || !sm.ServerRuntimeHalted(id) {
		return ErrServerRuntimeNotHalted
	}
	current, ok := sm.Get(id)
	if !ok {
		return fmt.Errorf("server %q not found", id)
	}
	transition := ctx.participant.BeginServerTransition(id)
	sm.markServerRuntimeTransitioning(id)
	repairCtx, cancel := context.WithTimeout(context.Background(), serverReconcileTimeout)
	defer cancel()
	if err := ctx.participant.RepairServer(repairCtx, current); err != nil {
		transition.FailClosed()
		sm.clearServerRuntimeTransitioning(id)
		ctx.participant.InvalidateServer(id)
		return fmt.Errorf("repair server runtime: %w", err)
	}
	sm.clearServerRuntimeHalted(id)
	transition.Complete()
	sm.clearServerRuntimeTransitioning(id)
	return nil
}

func (sm *ServerManager) persistServerValue(server *AgentTeamServer, requireExisting bool) error {
	if server == nil {
		return fmt.Errorf("server is required")
	}
	sm.mgr.mu.Lock()
	defer sm.mgr.mu.Unlock()
	return withConfigDirLock(sm.mgr.configDir, func() error {
		servers, err := loadServersForMutation(sm.mgr)
		if err != nil {
			return err
		}
		found := false
		for i := range servers {
			if servers[i].ID != server.ID {
				continue
			}
			if !requireExisting {
				return fmt.Errorf("server %q already exists", server.ID)
			}
			server.Revision = nextServerRevision(servers[i].Revision)
			servers[i] = *server
			found = true
			break
		}
		if !found {
			if requireExisting {
				return fmt.Errorf("server %q not found", server.ID)
			}
			server.Revision = nextServerRevision(0)
			servers = append(servers, *server)
		}
		if err := sm.saveServersSnapshotLocked(servers); err != nil {
			return err
		}
		sm.mgr.servers = servers
		return nil
	})
}

func (sm *ServerManager) persistServerDelete(id string) error {
	sm.mgr.mu.Lock()
	defer sm.mgr.mu.Unlock()
	return withConfigDirLock(sm.mgr.configDir, func() error {
		servers, err := loadServersForMutation(sm.mgr)
		if err != nil {
			return err
		}
		for i := range servers {
			if servers[i].ID != id {
				continue
			}
			servers = append(servers[:i], servers[i+1:]...)
			if err := sm.saveServersSnapshotLocked(servers); err != nil {
				return err
			}
			sm.mgr.servers = servers
			return nil
		}
		return fmt.Errorf("server %q not found", id)
	})
}

// ErrServerRevisionConflict means a caller tried to replace a server snapshot
// after another durable mutation had already advanced that server's revision.
// Used by the revision-fenced UpdateSnapshot/CompareAndSwap/DeleteIfRevision
// APIs below; the runtime-fenced Patch/PatchConnectionStatus/
// UpdateRuntimeWorkspaceBinding path does not surface this error because it
// holds a single per-server transaction across the entire mutation.
var ErrServerRevisionConflict = errors.New("server configuration revision conflict")

// UpdateSnapshot conditionally updates an existing AgentTeam server auth
// configuration and returns the exact committed snapshot. The caller must
// pass the revision obtained from Get/All/Add; a stale revision is rejected
// rather than overwriting a concurrent mutation. Returning the committed
// value avoids a second Get race in callers that must reconcile this exact
// transition (notably the wire layer's mergeServerSnapshot retry agentteam).
func (sm *ServerManager) UpdateSnapshot(server AgentTeamServer) (AgentTeamServer, error) {
	if err := normalizeAgentTeamServerForSave(&server); err != nil {
		return AgentTeamServer{}, err
	}
	sm.mgr.mu.Lock()
	defer sm.mgr.mu.Unlock()
	err := withConfigDirLock(sm.mgr.configDir, func() error {
		servers, err := loadServersForMutation(sm.mgr)
		if err != nil {
			return err
		}
		cacheDurableSnapshotLocked(sm.mgr, servers)
		for i, existing := range servers {
			if existing.ID != server.ID {
				continue
			}
			if existing.Revision != server.Revision {
				return fmt.Errorf("%w for server %q: current=%d expected=%d", ErrServerRevisionConflict, server.ID, existing.Revision, server.Revision)
			}
			server.Revision = nextServerRevision(existing.Revision)
			servers[i] = server
			if err := sm.saveServersSnapshotLocked(servers); err != nil {
				return err
			}
			sm.mgr.servers = servers
			return nil
		}
		return fmt.Errorf("server %q not found", server.ID)
	})
	if err != nil {
		return AgentTeamServer{}, err
	}
	return server, nil
}

// CompareAndSwap replaces one server configuration only when the durable
// current value still matches expected. Used by API rollback paths: a failed
// older runtime reconciliation must not restore stale configuration over a
// newer concurrent update, including an ABA sequence that returns to the same
// user-visible value. A missing or mismatched entry returns swapped=false
// without mutating the file.
func (sm *ServerManager) CompareAndSwap(expected, replacement AgentTeamServer) (swapped bool, err error) {
	if err := normalizeAgentTeamServerForSave(&expected); err != nil {
		return false, err
	}
	if err := normalizeAgentTeamServerForSave(&replacement); err != nil {
		return false, err
	}
	if strings.TrimSpace(expected.ID) == "" || expected.ID != replacement.ID {
		return false, fmt.Errorf("server compare-and-swap requires one matching non-empty ID")
	}

	sm.mgr.mu.Lock()
	defer sm.mgr.mu.Unlock()
	err = withConfigDirLock(sm.mgr.configDir, func() error {
		servers, loadErr := loadServersForMutation(sm.mgr)
		if loadErr != nil {
			return loadErr
		}
		cacheDurableSnapshotLocked(sm.mgr, servers)
		for i, current := range servers {
			if current.ID != expected.ID {
				continue
			}
			if !reflect.DeepEqual(current, expected) {
				return nil
			}
			replacement.Revision = nextServerRevision(current.Revision)
			servers[i] = replacement
			if saveErr := sm.saveServersSnapshotLocked(servers); saveErr != nil {
				return saveErr
			}
			sm.mgr.servers = servers
			swapped = true
			return nil
		}
		return nil
	})
	return swapped, err
}

// DeleteIfRevision removes a server only when the durable snapshot still has
// expectedRevision. Dashboard delete transactions use this fence so an older
// request cannot delete a concurrently updated server configuration.
func (sm *ServerManager) DeleteIfRevision(id string, expectedRevision uint64) error {
	sm.mgr.mu.Lock()
	defer sm.mgr.mu.Unlock()
	return withConfigDirLock(sm.mgr.configDir, func() error {
		servers, err := loadServersForMutation(sm.mgr)
		if err != nil {
			return err
		}
		cacheDurableSnapshotLocked(sm.mgr, servers)
		for i, existing := range servers {
			if existing.ID != id {
				continue
			}
			if existing.Revision != expectedRevision {
				return fmt.Errorf("%w for server %q: current=%d expected=%d", ErrServerRevisionConflict, id, existing.Revision, expectedRevision)
			}
			originalServers := append([]AgentTeamServer(nil), servers...)
			servers = append(servers[:i], servers[i+1:]...)
			if err := sm.saveServersSnapshotLocked(servers); err != nil {
				return err
			}
			sm.mgr.servers = servers
			if err := sm.mgr.agentMgr.deleteBySourceLocked(id); err != nil {
				if restoreErr := sm.saveServersSnapshotLocked(originalServers); restoreErr != nil {
					return fmt.Errorf("delete synced agents: %w (server config rollback failed: %v)", err, restoreErr)
				}
				sm.mgr.servers = originalServers
				return err
			}
			return nil
		}
		return fmt.Errorf("server %q not found", id)
	})
}

func nextServerRevision(current uint64) uint64 {
	if current == ^uint64(0) {
		// Saturation would reintroduce ABA. Wrap to one instead; equality
		// against the immediately previous maximum still fails, while zero
		// remains the legacy/unversioned sentinel.
		return 1
	}
	return current + 1
}

// cacheDurableSnapshotLocked refreshes Get/All from the file snapshot read
// under the config-dir mutation lock. Keep an independent slice so subsequent
// candidate edits cannot expose an uncommitted value if the following save
// fails. This refresh is also required on CAS conflicts: callers retry by Get,
// and another daemon process may have advanced the durable revision without
// sharing this Manager's in-memory cache.
func cacheDurableSnapshotLocked(m *Manager, servers []AgentTeamServer) {
	m.servers = append([]AgentTeamServer(nil), servers...)
}

func (sm *ServerManager) restoreServerByID(previous, current *AgentTeamServer) error {
	if value, err := failpoint.Eval("profile-server-persist-rollback"); err == nil {
		message, _ := value.(string)
		if strings.TrimSpace(message) == "" {
			message = "injected server config rollback failure"
		}
		return errors.New(message)
	}
	sm.mgr.mu.Lock()
	defer sm.mgr.mu.Unlock()
	return withConfigDirLock(sm.mgr.configDir, func() error {
		servers, err := loadServersForMutation(sm.mgr)
		if err != nil {
			return err
		}
		index := -1
		id := serverIDForMutation(previous, current)
		for i := range servers {
			if servers[i].ID == id {
				index = i
				break
			}
		}
		switch {
		case previous == nil && current != nil:
			if index < 0 {
				return nil
			}
			if runtimeIdentityDiffersFromEither(servers[index], *current, *current) {
				return fmt.Errorf("server %q changed before rollback", id)
			}
			servers = append(servers[:index], servers[index+1:]...)
		case previous != nil && current == nil:
			if index >= 0 {
				return fmt.Errorf("server %q was recreated before rollback", id)
			}
			rollback := *previous
			rollback.Revision = nextServerRevision(0)
			servers = append(servers, rollback)
		case previous != nil && current != nil:
			if index < 0 {
				return fmt.Errorf("server %q missing before rollback", id)
			}
			latest := servers[index]
			if runtimeIdentityDiffersFromEither(latest, *previous, *current) {
				return fmt.Errorf("server %q identity changed before rollback", id)
			}
			rollback := *previous
			if latest.Name != current.Name {
				rollback.Name = latest.Name
			}
			if latest.Description != current.Description {
				rollback.Description = latest.Description
			}
			if latest.ConnectionStatus != current.ConnectionStatus {
				rollback.ConnectionStatus = latest.ConnectionStatus
			}
			rollback.Revision = nextServerRevision(latest.Revision)
			rollback.Config = agentteamServerCompatConfig(rollback)
			servers[index] = rollback
		default:
			return errors.New("server rollback has no identity")
		}
		if err := sm.saveServersSnapshotLocked(servers); err != nil {
			return err
		}
		sm.mgr.servers = servers
		return nil
	})
}

func runtimeIdentityDiffersFromEither(candidate, first, second AgentTeamServer) bool {
	matches := func(reference AgentTeamServer) bool {
		return candidate.Enabled == reference.Enabled &&
			!ServerGatewayIdentityChanged(candidate, reference)
	}
	return !matches(first) && !matches(second)
}

func (sm *ServerManager) deleteServerAgents(id string) error {
	sm.mgr.mu.Lock()
	defer sm.mgr.mu.Unlock()
	return sm.mgr.agentMgr.deleteBySourceLocked(id)
}

func (sm *ServerManager) saveServersSnapshotLocked(servers []AgentTeamServer) error {
	return saveYAMLFileLocked(serversFile{Servers: servers}, sm.mgr.filePath(serversFileName), sm.mgr)
}

func loadServersForMutation(m *Manager) ([]AgentTeamServer, error) {
	if err := migrateChannelsToServersIfNeeded(m); err != nil {
		return nil, err
	}
	sf := &serversFile{}
	if err := loadYAMLFile(m.filePath(serversFileName), sf); err != nil {
		return nil, err
	}
	backfillServerIDs(sf.Servers)
	hydrateAgentTeamServerCompatFields(sf.Servers)
	return sf.Servers, nil
}

// reloadServers loads servers.yaml into the Manager's servers slice, migrating
// a legacy channels.yaml first when needed.
func reloadServers(m *Manager) {
	if err := migrateChannelsToServersIfNeeded(m); err != nil {
		log.Printf("profile: migrate channels to servers: %v", err)
	}
	sf := &serversFile{}
	if err := loadYAMLFile(m.filePath(serversFileName), sf); err != nil {
		log.Printf("profile: load servers: %v", err)
	}
	backfilled := backfillServerIDs(sf.Servers)
	hydrateAgentTeamServerCompatFields(sf.Servers)
	if backfilled {
		if err := saveYAMLFile(sf, m.filePath(serversFileName), m); err != nil {
			log.Printf("profile: backfill server IDs: %v", err)
		}
	}
	m.servers = sf.Servers
}

// ReloadServers reloads only servers.yaml from disk.
func (m *Manager) ReloadServers() {
	_ = m.ReloadServersChecked()
}

// ReloadServersChecked applies external metadata-only changes while the daemon
// is active. Identity/lifecycle changes are rejected and the canonical file is
// restored only if its content revision still matches the rejected snapshot.
func (m *Manager) ReloadServersChecked() error {
	for {
		err := m.reloadServersCheckedOnce()
		if errors.Is(err, errServerRevisionChanged) {
			continue
		}
		return err
	}
}

func (m *Manager) reloadServersCheckedOnce() error {
	manager := m.Servers()
	initialCandidate, _, _, err := m.loadServerReloadCandidate()
	if err != nil {
		return err
	}
	unlockServers := manager.lockServers(serverIDsForReload(manager.All(), initialCandidate))
	defer unlockServers()
	candidate, candidateData, backfilled, err := m.loadServerReloadCandidate()
	if err != nil {
		return err
	}
	if !sameServerIDSet(initialCandidate, candidate) {
		return errServerRevisionChanged
	}

	ctx, err := manager.mutationContext()
	if err != nil {
		return err
	}
	defer ctx.release()

	if ctx.mode == ServerLifecycleOffline {
		if err := migrateChannelsToServersIfNeeded(m); err != nil {
			slog.Warn("profile watcher: failed to migrate channels to servers", "error", err)
			return err
		}
	}

	path := m.filePath(serversFileName)
	m.mu.Lock()
	err = withConfigDirLock(m.configDir, func() error {
		currentData, readErr := os.ReadFile(path)
		if readErr != nil && !os.IsNotExist(readErr) {
			return readErr
		}
		if !bytes.Equal(currentData, candidateData) {
			return errServerRevisionChanged
		}
		current := append([]AgentTeamServer(nil), m.servers...)
		if ctx.mode == ServerLifecycleActive && externalServerIdentityChanged(current, candidate) {
			if err := m.restoreRejectedServersRevisionLocked(path, candidateData, current); err != nil {
				if errors.Is(err, errServerRevisionChanged) {
					return err
				}
				return fmt.Errorf("%w (restore canonical file: %v)", ErrExternalServerIdentityChange, err)
			}
			return ErrExternalServerIdentityChange
		}
		if backfilled {
			if err := saveYAMLFileLocked(serversFile{Servers: candidate}, path, m); err != nil {
				return fmt.Errorf("profile watcher: backfill server IDs: %w", err)
			}
		}
		m.servers = candidate
		return nil
	})
	m.mu.Unlock()
	if err != nil {
		if errors.Is(err, errServerRevisionChanged) {
			return err
		}
		if errors.Is(err, ErrExternalServerIdentityChange) {
			return err
		}
		return err
	}

	slog.Info("profile watcher: servers reloaded", "count", len(m.servers))
	return nil
}

func (m *Manager) loadServerReloadCandidate() ([]AgentTeamServer, []byte, bool, error) {
	path := m.filePath(serversFileName)
	data, err := os.ReadFile(path)
	if err != nil && !os.IsNotExist(err) {
		return nil, nil, false, err
	}
	sf := &serversFile{}
	if len(data) > 0 {
		if err := yaml.Unmarshal(data, sf); err != nil {
			slog.Warn("profile watcher: failed to reload servers", "error", err)
			return nil, nil, false, err
		}
	}
	backfilled := backfillServerIDs(sf.Servers)
	hydrateAgentTeamServerCompatFields(sf.Servers)
	return sf.Servers, data, backfilled, nil
}

func (m *Manager) restoreRejectedServersRevision(
	path string,
	rejectedData []byte,
	canonical []AgentTeamServer,
) error {
	return withConfigDirLock(m.configDir, func() error {
		return m.restoreRejectedServersRevisionLocked(path, rejectedData, canonical)
	})
}

func (m *Manager) restoreRejectedServersRevisionLocked(
	path string,
	rejectedData []byte,
	canonical []AgentTeamServer,
) error {
	rejectedHash := sha256.Sum256(rejectedData)
	currentData, err := os.ReadFile(path)
	if err != nil {
		return err
	}
	currentHash := sha256.Sum256(currentData)
	if currentHash != rejectedHash || !bytes.Equal(currentData, rejectedData) {
		// A later external H2 won the race. Re-evaluate it rather than
		// overwriting it with the stale canonical snapshot for H.
		return errServerRevisionChanged
	}

	backupPath := rejectedServersBackupPath(path, rejectedHash)
	if err := writeFileAtomic(backupPath, rejectedData, 0o600); err != nil {
		return fmt.Errorf("preserve rejected servers revision: %w", err)
	}
	if err := os.Chmod(backupPath, 0o600); err != nil {
		return fmt.Errorf("secure rejected servers revision: %w", err)
	}
	return saveYAMLFileLocked(serversFile{Servers: canonical}, path, m)
}

func rejectedServersBackupPath(path string, revision [sha256.Size]byte) string {
	suffix := hex.EncodeToString(revision[:8])
	return filepath.Join(
		filepath.Dir(path),
		filepath.Base(path)+".rejected-"+suffix,
	)
}

func backfillServerIDs(servers []AgentTeamServer) bool {
	backfilled := false
	for i := range servers {
		if strings.TrimSpace(servers[i].ID) == "" {
			servers[i].ID = generateServerID()
			backfilled = true
		}
	}
	return backfilled
}

func normalizeAgentTeamServerForSave(server *AgentTeamServer) error {
	if server == nil {
		return nil
	}
	if len(server.Config) > 0 {
		var cfg legacyAgentTeamConfig
		if err := yaml.Unmarshal(server.Config, &cfg); err != nil {
			return fmt.Errorf("unmarshal legacy agentteam config: %w", err)
		}
		cfgKeys := legacyAgentTeamConfigKeys(server.Config)
		if cfgKeys["server_url"] || server.ServerURL == "" {
			server.ServerURL = strings.TrimSpace(cfg.ServerURL)
		}
		if cfgKeys["api_key"] || server.APIKey == "" {
			server.APIKey = strings.TrimSpace(cfg.APIKey)
		}
		if cfgKeys["workspace_id"] || server.WorkspaceID == "" {
			server.WorkspaceID = strings.TrimSpace(cfg.WorkspaceID)
		}
		if cfgKeys["machine_id"] || server.MachineID == "" {
			server.MachineID = strings.TrimSpace(cfg.MachineID)
		}
		if cfgKeys["agent_id"] || server.AgentID == "" {
			server.AgentID = strings.TrimSpace(cfg.AgentID)
		}
		if cfgKeys["auth_token"] || server.AuthToken == "" {
			server.AuthToken = strings.TrimSpace(cfg.AuthToken)
		}
		if cfgKeys["connection_status"] || server.ConnectionStatus == "" {
			server.ConnectionStatus = strings.TrimSpace(cfg.ConnectionStatus)
		}
	}
	server.Config = agentteamServerCompatConfig(*server)
	return nil
}

// NormalizeAgentTeamServer returns the persisted/compatibility representation used
// by ServerManager without mutating the caller's value.
func NormalizeAgentTeamServer(server AgentTeamServer) (AgentTeamServer, error) {
	if err := normalizeAgentTeamServerForSave(&server); err != nil {
		return AgentTeamServer{}, err
	}
	return server, nil
}

func legacyAgentTeamConfigKeys(raw types.RawMessage) map[string]bool {
	var values map[string]interface{}
	if err := yaml.Unmarshal(raw, &values); err != nil {
		return nil
	}
	keys := make(map[string]bool, len(values))
	for key := range values {
		keys[strings.TrimSpace(key)] = true
	}
	return keys
}

func hydrateAgentTeamServerCompatFields(servers []AgentTeamServer) {
	for i := range servers {
		servers[i].Config = agentteamServerCompatConfig(servers[i])
	}
}

func agentteamServerCompatConfig(server AgentTeamServer) types.RawMessage {
	cfg := legacyAgentTeamConfig{
		ServerURL:        strings.TrimSpace(server.ServerURL),
		APIKey:           strings.TrimSpace(server.APIKey),
		WorkspaceID:      strings.TrimSpace(server.WorkspaceID),
		MachineID:        strings.TrimSpace(server.MachineID),
		AgentID:          strings.TrimSpace(server.AgentID),
		AuthToken:        strings.TrimSpace(server.AuthToken),
		ConnectionStatus: strings.TrimSpace(server.ConnectionStatus),
	}
	data, err := yaml.Marshal(cfg)
	if err != nil {
		return nil
	}
	return types.RawMessage(data)
}

type legacyChannelsFile struct {
	Channels []legacyChannel `yaml:"channels"`
}

type legacyChannel struct {
	ID          string           `yaml:"id,omitempty"`
	Enabled     bool             `yaml:"enabled"`
	Name        string           `yaml:"name,omitempty"`
	Description string           `yaml:"description,omitempty"`
	AgentID     string           `yaml:"agent_id,omitempty"`
	Config      types.RawMessage `yaml:"config,omitempty"`
}

type legacyAgentTeamConfig struct {
	ServerURL        string `yaml:"server_url"`
	APIKey           string `yaml:"api_key"`
	WorkspaceID      string `yaml:"workspace_id,omitempty"`
	MachineID        string `yaml:"machine_id,omitempty"`
	AgentID          string `yaml:"agent_id"`
	AuthToken        string `yaml:"auth_token,omitempty"`
	ConnectionStatus string `yaml:"connection_status,omitempty"`
}

func migrateChannelsToServersIfNeeded(m *Manager) error {
	serversPath := m.filePath(serversFileName)
	channelsPath := m.filePath(legacyChannelsFileName)

	serversExists, err := fileExists(serversPath)
	if err != nil {
		return err
	}
	channelsExists, err := fileExists(channelsPath)
	if err != nil {
		return err
	}
	if serversExists {
		if channelsExists {
			slog.Warn("profile: servers.yaml exists; ignoring legacy channels.yaml")
		}
		return nil
	}
	if !channelsExists {
		return nil
	}

	return withConfigDirLock(m.configDir, func() error {
		// Re-check under the config lock in case another process migrated first.
		serversExists, err := fileExists(serversPath)
		if err != nil {
			return err
		}
		if serversExists {
			return nil
		}

		legacy := &legacyChannelsFile{}
		if err := loadYAMLFile(channelsPath, legacy); err != nil {
			return err
		}
		servers := make([]AgentTeamServer, 0, len(legacy.Channels))
		for _, ch := range legacy.Channels {
			servers = append(servers, legacyChannelToAgentTeamServer(ch))
		}
		if err := saveYAMLFileLocked(serversFile{Servers: servers}, serversPath, m); err != nil {
			return err
		}
		bakPath := channelsPath + ".bak"
		if err := os.Rename(channelsPath, bakPath); err != nil {
			return fmt.Errorf("backup legacy channels.yaml: %w", err)
		}
		slog.Info("profile: migrated channels.yaml to servers.yaml", "servers", len(servers), "backup", bakPath)
		return nil
	})
}

func legacyChannelToAgentTeamServer(ch legacyChannel) AgentTeamServer {
	cfg := legacyAgentTeamConfig{}
	if len(ch.Config) > 0 {
		_ = yaml.Unmarshal(ch.Config, &cfg)
	}
	agentID := strings.TrimSpace(ch.AgentID)
	if agentID == "" {
		agentID = strings.TrimSpace(cfg.AgentID)
	}
	return AgentTeamServer{
		ID:               strings.TrimSpace(ch.ID),
		Enabled:          ch.Enabled,
		Name:             strings.TrimSpace(ch.Name),
		Description:      strings.TrimSpace(ch.Description),
		AgentID:          agentID,
		ServerURL:        strings.TrimSpace(cfg.ServerURL),
		WorkspaceID:      strings.TrimSpace(cfg.WorkspaceID),
		MachineID:        strings.TrimSpace(cfg.MachineID),
		APIKey:           strings.TrimSpace(cfg.APIKey),
		AuthToken:        strings.TrimSpace(cfg.AuthToken),
		ConnectionStatus: strings.TrimSpace(cfg.ConnectionStatus),
	}
}

func fileExists(path string) (bool, error) {
	_, err := os.Stat(path)
	if err == nil {
		return true, nil
	}
	if os.IsNotExist(err) {
		return false, nil
	}
	return false, err
}
