// Package userauth owns Desktop GUI user sessions for AgentTeam Servers.
//
// These sessions are separate from daemon machine credentials. The CLI and
// headless daemon continue to use profile.AgentTeamServer machine authentication;
// GUI refresh tokens are encrypted at rest in auth.yaml and access tokens stay
// in process memory.
package userauth

import (
	"bytes"
	"context"
	"crypto/rand"
	"encoding/base64"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net"
	"net/http"
	"net/http/cookiejar"
	"net/url"
	"os"
	"path/filepath"
	"sort"
	"strings"
	"sync"
	"time"

	"github.com/gorilla/websocket"
	"go.yaml.in/yaml/v3"

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

const (
	authFileName               = "auth.yaml"
	defaultServerURL           = "https://agentteam.pingcap.com"
	targetPrefix               = "desktop-user"
	requestTimeout             = 30 * time.Second
	probeTimeout               = 5 * time.Second
	maxResponseBytes           = 2 << 20
	maxRedirects               = 3
	persistedSchemaV1          = 1
	gatewayHeaderLimit         = 30 * time.Second
	workspaceChoiceConcurrency = 4
	serverURLErrorInvalid      = "server_url_invalid"
	serverURLErrorUnreachable  = "server_url_unreachable"
	serverURLErrorNotAgentTeam = "server_url_not_agentteam_server"
)

const (
	// SavedLoginErrorReauthenticationRequired means the upstream explicitly rejected credentials.
	SavedLoginErrorReauthenticationRequired = "reauthentication_required"
	// SavedLoginErrorTemporarilyUnavailable means the saved account should be retained for retry.
	SavedLoginErrorTemporarilyUnavailable = "temporarily_unavailable"
)

var (
	errCrossOriginRedirect = errors.New("cross-origin redirect rejected")
	errTooManyRedirects    = errors.New("too many redirects")
)

type User struct {
	ID          string `json:"id" yaml:"id"`
	Email       string `json:"email" yaml:"email"`
	Name        string `json:"name" yaml:"name"`
	DisplayName string `json:"displayName,omitempty" yaml:"display_name,omitempty"`
}

type Workspace struct {
	ID   string `json:"id"`
	Name string `json:"name"`
	Slug string `json:"slug"`
}

type Organization struct {
	ID            string `json:"id"`
	Slug          string `json:"slug"`
	Name          string `json:"name"`
	Role          string `json:"role"`
	DefaultTeamID string `json:"defaultTeamId"`
}

type PublicConfig struct {
	PingCAPAuthEnabled  bool   `json:"pingcapAuthEnabled"`
	PasswordAuthEnabled bool   `json:"passwordAuthEnabled"`
	OpenRegistration    bool   `json:"openRegistration"`
	RegistrationMode    string `json:"registrationMode"`
}

type ServerSummary struct {
	ID          string `json:"id,omitempty"`
	Name        string `json:"name"`
	ServerURL   string `json:"serverUrl"`
	UserEmail   string `json:"userEmail,omitempty"`
	Recommended bool   `json:"recommended,omitempty"`
	Saved       bool   `json:"saved"`
}

type ProbeResult struct {
	Name                string `json:"name"`
	ServerURL           string `json:"serverUrl"`
	PingCAPAuthEnabled  bool   `json:"pingcapAuthEnabled"`
	PasswordAuthEnabled bool   `json:"passwordAuthEnabled"`
	RegistrationMode    string `json:"registrationMode"`
	OpenRegistration    bool   `json:"openRegistration"`
}

type AuthResult struct {
	Server     ServerSummary `json:"server"`
	User       User          `json:"user"`
	Workspaces []Workspace   `json:"workspaces"`
}

type WorkspaceChoice struct {
	AuthID     string        `json:"authId"`
	Server     ServerSummary `json:"server"`
	User       User          `json:"user"`
	Workspaces []Workspace   `json:"workspaces"`
	Error      string        `json:"error,omitempty"`
	ErrorCode  string        `json:"errorCode,omitempty"`
}

type authSessionResponse struct {
	AccessToken  string `json:"accessToken"`
	RefreshToken string `json:"refreshToken"`
	User         User   `json:"user"`
}

type Target struct {
	ID          string
	AuthID      string
	Name        string
	ServerURL   string
	WorkspaceID string
}

type persistedFile struct {
	SchemaVersion    int               `yaml:"schema_version"`
	LastActiveTarget string            `yaml:"last_active_target,omitempty"`
	Servers          []persistedServer `yaml:"servers"`
}

type persistedServer struct {
	ID                    string `yaml:"id"`
	Name                  string `yaml:"name"`
	ServerURL             string `yaml:"server_url"`
	User                  User   `yaml:"user"`
	EncryptedRefreshToken string `yaml:"encrypted_refresh_token"`
}

type runtimeSession struct {
	accessToken string
	client      *http.Client
	mu          sync.Mutex
}

type Manager struct {
	mu               sync.RWMutex
	configDir        string
	lastActiveTarget string
	servers          map[string]persistedServer
	runtimes         map[string]*runtimeSession
	loadErr          error
	saveHook         func() error
	oidcPersistHook  func()
	oidcMu           sync.Mutex
	oidcFlows        map[string]*oidcFlow
}

func New(configDir string) (*Manager, error) {
	manager := &Manager{
		configDir: strings.TrimSpace(configDir),
		servers:   make(map[string]persistedServer),
		runtimes:  make(map[string]*runtimeSession),
		oidcFlows: make(map[string]*oidcFlow),
	}
	if manager.configDir == "" {
		manager.configDir = profile.ConfigDir()
	}
	if err := manager.load(); err != nil {
		manager.loadErr = err
		return manager, err
	}
	return manager, nil
}

func DefaultServerURL() string { return defaultServerURL }

func (m *Manager) Summaries(machineServers []profile.AgentTeamServer) []ServerSummary {
	m.mu.RLock()
	defer m.mu.RUnlock()
	result := make([]ServerSummary, 0, len(m.servers)+len(machineServers)+1)
	seenOrigins := make(map[string]struct{}, len(m.servers)+len(machineServers))
	defaultSeen := false
	for _, server := range m.servers {
		summary := summaryOf(server)
		seenOrigins[originKey(server.ServerURL)] = struct{}{}
		if sameOriginString(server.ServerURL, defaultServerURL) {
			summary.Recommended = true
			defaultSeen = true
		}
		result = append(result, summary)
	}
	for _, server := range machineServers {
		serverURL, err := normalizeServerURL(server.ServerURL)
		if err != nil || !server.Enabled {
			continue
		}
		key := originKey(serverURL)
		if _, exists := seenOrigins[key]; exists {
			continue
		}
		seenOrigins[key] = struct{}{}
		recommended := sameOriginString(serverURL, defaultServerURL)
		defaultSeen = defaultSeen || recommended
		name := strings.TrimSpace(server.Name)
		if name == "" {
			name = serverDisplayName(serverURL)
		}
		result = append(result, ServerSummary{
			Name:        name,
			ServerURL:   serverURL,
			Recommended: recommended,
			Saved:       false,
		})
	}
	sort.Slice(result, func(i, j int) bool {
		if result[i].Name != result[j].Name {
			return result[i].Name < result[j].Name
		}
		return result[i].ServerURL < result[j].ServerURL
	})
	if !defaultSeen {
		result = append([]ServerSummary{{
			Name:        "AgentTeam",
			ServerURL:   defaultServerURL,
			Recommended: true,
			Saved:       false,
		}}, result...)
	}
	return result
}

func (m *Manager) Probe(ctx context.Context, rawServerURL string) (ProbeResult, error) {
	serverURL, err := normalizeServerURL(rawServerURL)
	if err != nil {
		return ProbeResult{}, err
	}
	client, err := newHTTPClient(serverURL)
	if err != nil {
		return ProbeResult{}, err
	}
	probeContext, cancel := context.WithTimeout(ctx, probeTimeout)
	defer cancel()
	var config PublicConfig
	if err := doJSON(probeContext, client, http.MethodGet, serverURL+"/api/v1/public/config", nil, "", &config); err != nil {
		return ProbeResult{}, probeServerError(err)
	}
	registrationMode := strings.TrimSpace(config.RegistrationMode)
	if registrationMode == "" {
		if config.OpenRegistration {
			registrationMode = "open"
		} else {
			registrationMode = "invite_only"
		}
	}
	return ProbeResult{
		Name:                serverDisplayName(serverURL),
		ServerURL:           serverURL,
		PingCAPAuthEnabled:  config.PingCAPAuthEnabled,
		PasswordAuthEnabled: config.PasswordAuthEnabled,
		RegistrationMode:    registrationMode,
		OpenRegistration:    registrationMode != "invite_only",
	}, nil
}

func (m *Manager) Login(ctx context.Context, serverURL, identifier, password string) (AuthResult, error) {
	return m.authenticate(ctx, serverURL, "/api/v1/auth/login", map[string]string{
		"email":    strings.TrimSpace(identifier),
		"password": password,
	})
}

func (m *Manager) Register(ctx context.Context, serverURL, name, email, password string, desktopOnboardingEnabled bool, registrationCutoff string) (AuthResult, error) {
	return m.authenticate(ctx, serverURL, "/api/v1/auth/register", map[string]any{
		"name":                                strings.TrimSpace(name),
		"email":                               strings.TrimSpace(email),
		"password":                            password,
		"desktopOnboardingEnabled":            desktopOnboardingEnabled,
		"desktopOnboardingRegistrationCutoff": strings.TrimSpace(registrationCutoff),
	})
}

func (m *Manager) Resume(ctx context.Context, authID string) (AuthResult, error) {
	server, ok := m.server(authID)
	if !ok {
		return AuthResult{}, fmt.Errorf("saved server login not found")
	}
	accessToken, err := m.RefreshUserToken(ctx, authID, "")
	if err != nil {
		return AuthResult{}, err
	}
	workspaces, err := m.listWorkspaces(ctx, server, accessToken)
	if err != nil {
		return AuthResult{}, err
	}
	return AuthResult{Server: summaryOf(server), User: server.User, Workspaces: workspaces}, nil
}

// Logout removes one saved Desktop GUI login. Upstream revocation is best
// effort: local token destruction and durable removal still proceed when the
// AgentTeam Server is offline or rejects the revoke request.
func (m *Manager) Logout(ctx context.Context, authID string, afterCommit ...func()) error {
	authID = strings.TrimSpace(authID)
	server, ok := m.server(authID)
	if !ok {
		return fmt.Errorf("saved server login not found")
	}
	runtime, ok := m.runtime(authID)
	if !ok {
		return fmt.Errorf("saved server login not found")
	}

	// Serialize with refresh/token reads, but do not mutate or revoke anything
	// until the durable removal has committed successfully.
	runtime.mu.Lock()
	refreshToken, decryptErr := crypto.Decrypt(server.EncryptedRefreshToken)
	accessToken := runtime.accessToken
	m.mu.Lock()
	latestServer, serverExists := m.servers[authID]
	latestRuntime, runtimeExists := m.runtimes[authID]
	previousLastActiveTarget := m.lastActiveTarget
	if !serverExists {
		m.mu.Unlock()
		runtime.mu.Unlock()
		return fmt.Errorf("saved server login not found")
	}
	delete(m.servers, authID)
	delete(m.runtimes, authID)
	if TargetBelongsToAuth(m.lastActiveTarget, authID) {
		m.lastActiveTarget = ""
	}
	if err := m.saveLockedWithHook(); err != nil {
		m.servers[authID] = latestServer
		m.lastActiveTarget = previousLastActiveTarget
		if runtimeExists {
			m.runtimes[authID] = latestRuntime
		}
		m.mu.Unlock()
		runtime.mu.Unlock()
		return fmt.Errorf("remove Desktop login: %w", err)
	}
	m.mu.Unlock()
	runtime.accessToken = ""
	runtime.mu.Unlock()
	for _, callback := range afterCommit {
		if callback != nil {
			callback()
		}
	}

	if decryptErr == nil {
		_ = doJSON(
			ctx,
			runtime.client,
			http.MethodPost,
			server.ServerURL+"/api/v1/auth/logout",
			map[string]string{"refreshToken": refreshToken},
			accessToken,
			nil,
		)
	}
	return nil
}

// WorkspaceChoices resumes every saved GUI login independently so one stale
// server cannot hide workspaces from the other authenticated servers.
func (m *Manager) WorkspaceChoices(ctx context.Context) []WorkspaceChoice {
	m.mu.RLock()
	ids := make([]string, 0, len(m.servers))
	for id := range m.servers {
		ids = append(ids, id)
	}
	m.mu.RUnlock()
	sort.Strings(ids)
	choices := make([]WorkspaceChoice, len(ids))
	semaphore := make(chan struct{}, workspaceChoiceConcurrency)
	var group sync.WaitGroup
	for index, id := range ids {
		group.Add(1)
		go func() {
			defer group.Done()
			select {
			case semaphore <- struct{}{}:
				defer func() { <-semaphore }()
			case <-ctx.Done():
				return
			}
			result, err := m.Resume(ctx, id)
			if err != nil {
				server, ok := m.server(id)
				if ok {
					errorCode := SavedLoginErrorCode(err)
					errorMessage := "This saved server is temporarily unavailable."
					if errorCode == SavedLoginErrorReauthenticationRequired {
						errorMessage = "Sign in again to load this server."
					}
					choices[index] = WorkspaceChoice{
						AuthID:    id,
						Server:    summaryOf(server),
						User:      server.User,
						Error:     errorMessage,
						ErrorCode: errorCode,
					}
				}
				return
			}
			choices[index] = WorkspaceChoice{
				AuthID:     id,
				Server:     result.Server,
				User:       result.User,
				Workspaces: result.Workspaces,
			}
		}()
	}
	group.Wait()
	result := choices[:0]
	for _, choice := range choices {
		if choice.AuthID != "" {
			result = append(result, choice)
		}
	}
	return result
}

// SavedLoginErrorCode exposes the recovery action for one saved GUI login.
// Only an explicit upstream 401 requires reauthentication; every other
// failure keeps the saved account available for an explicit retry.
func SavedLoginErrorCode(err error) string {
	if isServerResponseStatus(err, http.StatusUnauthorized) {
		return SavedLoginErrorReauthenticationRequired
	}
	return SavedLoginErrorTemporarilyUnavailable
}

func (m *Manager) ActivateTarget(ctx context.Context, authID, workspaceID string) (string, error) {
	server, ok := m.server(authID)
	if !ok {
		return "", fmt.Errorf("saved server login not found")
	}
	accessToken, err := m.UserToken(ctx, authID)
	if err != nil {
		return "", err
	}
	workspaces, err := m.listWorkspaces(ctx, server, accessToken)
	if err != nil {
		return "", err
	}
	workspaceID = strings.TrimSpace(workspaceID)
	for _, workspace := range workspaces {
		if workspace.ID == workspaceID {
			return BuildTargetID(authID, workspaceID), nil
		}
	}
	return "", fmt.Errorf("workspace is not available for this login")
}

func (m *Manager) ListOrganizations(ctx context.Context, authID string) ([]Organization, error) {
	server, ok := m.server(authID)
	if !ok {
		return nil, fmt.Errorf("saved server login not found")
	}
	accessToken, err := m.UserToken(ctx, authID)
	if err != nil {
		return nil, err
	}
	return m.listOrganizations(ctx, server, accessToken)
}

func (m *Manager) CreateWorkspace(ctx context.Context, authID, organizationID, name, slug string) (Workspace, error) {
	server, ok := m.server(authID)
	if !ok {
		return Workspace{}, fmt.Errorf("saved server login not found")
	}
	organizationID = strings.TrimSpace(organizationID)
	name = strings.TrimSpace(name)
	slug = strings.TrimSpace(slug)
	if organizationID == "" {
		return Workspace{}, fmt.Errorf("organizationId is required")
	}
	if name == "" {
		return Workspace{}, fmt.Errorf("workspace name is required")
	}
	accessToken, err := m.UserToken(ctx, authID)
	if err != nil {
		return Workspace{}, err
	}
	return m.createWorkspace(ctx, server, accessToken, organizationID, name, slug)
}

func BuildTargetID(authID, workspaceID string) string {
	return targetPrefix + ":" + strings.TrimSpace(authID) + ":" + base64.RawURLEncoding.EncodeToString([]byte(strings.TrimSpace(workspaceID)))
}

// LastActiveTarget returns the most recently selected Desktop target. The
// value is an opaque local target ID and never contains an access or refresh
// token.
func (m *Manager) LastActiveTarget() (string, bool) {
	m.mu.RLock()
	defer m.mu.RUnlock()
	targetID := strings.TrimSpace(m.lastActiveTarget)
	return targetID, targetID != ""
}

// SetLastActiveTarget persists the target that a new Desktop UI session should
// restore during bootstrap.
func (m *Manager) SetLastActiveTarget(targetID string) error {
	targetID = strings.TrimSpace(targetID)
	if targetID == "" {
		return fmt.Errorf("active Desktop target is required")
	}
	m.mu.Lock()
	defer m.mu.Unlock()
	previous := m.lastActiveTarget
	m.lastActiveTarget = targetID
	if err := m.saveLockedWithHook(); err != nil {
		m.lastActiveTarget = previous
		return fmt.Errorf("save active Desktop target: %w", err)
	}
	return nil
}

// ClearLastActiveTarget clears the durable restore target. If targetID is
// supplied, a concurrently selected different target is preserved.
func (m *Manager) ClearLastActiveTarget(targetID string) error {
	targetID = strings.TrimSpace(targetID)
	m.mu.Lock()
	defer m.mu.Unlock()
	if m.lastActiveTarget == "" || (targetID != "" && m.lastActiveTarget != targetID) {
		return nil
	}
	previous := m.lastActiveTarget
	m.lastActiveTarget = ""
	if err := m.saveLockedWithHook(); err != nil {
		m.lastActiveTarget = previous
		return fmt.Errorf("clear active Desktop target: %w", err)
	}
	return nil
}

func (m *Manager) Target(targetID string) (Target, bool) {
	parts := strings.Split(strings.TrimSpace(targetID), ":")
	if len(parts) != 3 || parts[0] != targetPrefix {
		return Target{}, false
	}
	workspaceBytes, err := base64.RawURLEncoding.DecodeString(parts[2])
	if err != nil || strings.TrimSpace(string(workspaceBytes)) == "" {
		return Target{}, false
	}
	server, ok := m.server(parts[1])
	if !ok {
		return Target{}, false
	}
	return Target{
		ID:          targetID,
		AuthID:      server.ID,
		Name:        server.Name,
		ServerURL:   server.ServerURL,
		WorkspaceID: strings.TrimSpace(string(workspaceBytes)),
	}, true
}

// Get returns the public gateway registry view for one GUI target.
func (m *Manager) Get(targetID string) (profile.AgentTeamServer, bool) {
	target, ok := m.Target(targetID)
	if !ok {
		return profile.AgentTeamServer{}, false
	}
	return profile.AgentTeamServer{
		ID:          target.ID,
		Enabled:     true,
		Name:        target.Name,
		ServerURL:   target.ServerURL,
		WorkspaceID: target.WorkspaceID,
	}, true
}

func (m *Manager) ServerAvailableForSelection(targetID string) bool {
	_, ok := m.Target(targetID)
	return ok
}

func TargetBelongsToAuth(targetID, authID string) bool {
	parts := strings.Split(strings.TrimSpace(targetID), ":")
	return len(parts) == 3 && parts[0] == targetPrefix && parts[1] == strings.TrimSpace(authID)
}

func (m *Manager) UserToken(ctx context.Context, authID string) (string, error) {
	runtime, ok := m.runtime(authID)
	if !ok {
		return "", fmt.Errorf("saved server login not found")
	}
	runtime.mu.Lock()
	defer runtime.mu.Unlock()
	if strings.TrimSpace(runtime.accessToken) != "" {
		return runtime.accessToken, nil
	}
	return m.refreshLocked(ctx, authID, runtime)
}

func (m *Manager) RefreshUserToken(ctx context.Context, authID, staleToken string) (string, error) {
	runtime, ok := m.runtime(authID)
	if !ok {
		return "", fmt.Errorf("saved server login not found")
	}
	runtime.mu.Lock()
	defer runtime.mu.Unlock()
	if runtime.accessToken != "" && runtime.accessToken != strings.TrimSpace(staleToken) {
		return runtime.accessToken, nil
	}
	runtime.accessToken = ""
	return m.refreshLocked(ctx, authID, runtime)
}

func (m *Manager) HTTPClient(authID string) (*http.Client, bool) {
	runtime, ok := m.runtime(authID)
	if !ok {
		return nil, false
	}
	return runtime.client, true
}

func (m *Manager) authenticate(ctx context.Context, rawServerURL, path string, body any) (AuthResult, error) {
	if err := m.ready(); err != nil {
		return AuthResult{}, err
	}
	serverURL, err := normalizeServerURL(rawServerURL)
	if err != nil {
		return AuthResult{}, err
	}
	client, err := newHTTPClient(serverURL)
	if err != nil {
		return AuthResult{}, err
	}
	var response authSessionResponse
	if err := doJSON(ctx, client, http.MethodPost, serverURL+path, body, "", &response); err != nil {
		return AuthResult{}, err
	}
	return m.persistAuthSession(ctx, serverURL, client, response)
}

func (m *Manager) persistAuthSession(ctx context.Context, serverURL string, client *http.Client, response authSessionResponse) (AuthResult, error) {
	if err := ctx.Err(); err != nil {
		return AuthResult{}, err
	}
	if strings.TrimSpace(response.AccessToken) == "" || strings.TrimSpace(response.RefreshToken) == "" {
		return AuthResult{}, fmt.Errorf("AgentTeam Server returned an invalid login session")
	}
	encryptedRefresh, err := crypto.Encrypt(response.RefreshToken)
	if err != nil {
		return AuthResult{}, fmt.Errorf("encrypt refresh token: %w", err)
	}
	server := persistedServer{
		Name:                  serverDisplayName(serverURL),
		ServerURL:             serverURL,
		User:                  response.User,
		EncryptedRefreshToken: encryptedRefresh,
	}
	if err := ctx.Err(); err != nil {
		return AuthResult{}, err
	}
	m.mu.Lock()
	for _, existing := range m.servers {
		if sameOriginString(existing.ServerURL, serverURL) {
			server.ID = existing.ID
			break
		}
	}
	if server.ID == "" {
		server.ID, err = newAuthID()
		if err != nil {
			m.mu.Unlock()
			return AuthResult{}, err
		}
	}
	previousServer, hadServer := m.servers[server.ID]
	previousRuntime, hadRuntime := m.runtimes[server.ID]
	m.servers[server.ID] = server
	m.runtimes[server.ID] = &runtimeSession{accessToken: response.AccessToken, client: client}
	if err := m.saveLocked(); err != nil {
		if hadServer {
			m.servers[server.ID] = previousServer
		} else {
			delete(m.servers, server.ID)
		}
		if hadRuntime {
			m.runtimes[server.ID] = previousRuntime
		} else {
			delete(m.runtimes, server.ID)
		}
		m.mu.Unlock()
		return AuthResult{}, fmt.Errorf("save Desktop login: %w", err)
	}
	m.mu.Unlock()
	workspaces, err := m.listWorkspaces(ctx, server, response.AccessToken)
	if err != nil {
		return AuthResult{}, err
	}
	return AuthResult{Server: summaryOf(server), User: response.User, Workspaces: workspaces}, nil
}

func (m *Manager) refreshLocked(ctx context.Context, authID string, runtime *runtimeSession) (string, error) {
	server, ok := m.server(authID)
	if !ok {
		return "", fmt.Errorf("saved server login not found")
	}
	refreshToken, err := crypto.Decrypt(server.EncryptedRefreshToken)
	if err != nil {
		return "", fmt.Errorf("decrypt saved login: %w", err)
	}
	var response struct {
		AccessToken  string `json:"accessToken"`
		RefreshToken string `json:"refreshToken"`
	}
	if err := doJSON(ctx, runtime.client, http.MethodPost, server.ServerURL+"/api/v1/auth/refresh", map[string]string{
		"refreshToken": refreshToken,
	}, "", &response); err != nil {
		return "", fmt.Errorf("refresh saved login: %w", err)
	}
	if response.AccessToken == "" || response.RefreshToken == "" {
		return "", fmt.Errorf("AgentTeam Server returned an invalid refresh session")
	}
	encryptedRefresh, err := crypto.Encrypt(response.RefreshToken)
	if err != nil {
		return "", fmt.Errorf("encrypt rotated refresh token: %w", err)
	}
	m.mu.Lock()
	latest, exists := m.servers[authID]
	if exists {
		latest.EncryptedRefreshToken = encryptedRefresh
		m.servers[authID] = latest
		err = m.saveLocked()
	}
	m.mu.Unlock()
	if !exists {
		return "", fmt.Errorf("saved server login not found")
	}
	if err != nil {
		return "", fmt.Errorf("save rotated login: %w", err)
	}
	runtime.accessToken = response.AccessToken
	return response.AccessToken, nil
}

func (m *Manager) listWorkspaces(ctx context.Context, server persistedServer, accessToken string) ([]Workspace, error) {
	runtime, ok := m.runtime(server.ID)
	if !ok {
		return nil, fmt.Errorf("saved server login not found")
	}
	var workspaces []Workspace
	err := doJSON(ctx, runtime.client, http.MethodGet, server.ServerURL+"/api/v1/workspaces", nil, accessToken, &workspaces)
	if isServerResponseStatus(err, http.StatusUnauthorized) {
		accessToken, err = m.RefreshUserToken(ctx, server.ID, accessToken)
		if err == nil {
			workspaces = nil
			err = doJSON(ctx, runtime.client, http.MethodGet, server.ServerURL+"/api/v1/workspaces", nil, accessToken, &workspaces)
		}
	}
	if err != nil {
		return nil, fmt.Errorf("load workspaces: %w", err)
	}
	return workspaces, nil
}

func (m *Manager) listOrganizations(ctx context.Context, server persistedServer, accessToken string) ([]Organization, error) {
	runtime, ok := m.runtime(server.ID)
	if !ok {
		return nil, fmt.Errorf("saved server login not found")
	}
	var organizations []Organization
	err := doJSON(ctx, runtime.client, http.MethodGet, server.ServerURL+"/api/v1/orgs", nil, accessToken, &organizations)
	if isServerResponseStatus(err, http.StatusUnauthorized) {
		accessToken, err = m.RefreshUserToken(ctx, server.ID, accessToken)
		if err == nil {
			organizations = nil
			err = doJSON(ctx, runtime.client, http.MethodGet, server.ServerURL+"/api/v1/orgs", nil, accessToken, &organizations)
		}
	}
	if err != nil {
		return nil, fmt.Errorf("load organizations: %w", err)
	}
	return organizations, nil
}

func (m *Manager) createWorkspace(ctx context.Context, server persistedServer, accessToken, organizationID, name, slug string) (Workspace, error) {
	runtime, ok := m.runtime(server.ID)
	if !ok {
		return Workspace{}, fmt.Errorf("saved server login not found")
	}
	body := map[string]string{
		"organizationId": organizationID,
		"name":           name,
		"slug":           slug,
	}
	var workspace Workspace
	err := doJSON(ctx, runtime.client, http.MethodPost, server.ServerURL+"/api/v1/workspaces", body, accessToken, &workspace)
	if isServerResponseStatus(err, http.StatusUnauthorized) {
		accessToken, err = m.RefreshUserToken(ctx, server.ID, accessToken)
		if err == nil {
			workspace = Workspace{}
			err = doJSON(ctx, runtime.client, http.MethodPost, server.ServerURL+"/api/v1/workspaces", body, accessToken, &workspace)
		}
	}
	if err != nil {
		return Workspace{}, fmt.Errorf("create workspace: %w", err)
	}
	return workspace, nil
}

func (m *Manager) server(id string) (persistedServer, bool) {
	m.mu.RLock()
	defer m.mu.RUnlock()
	server, ok := m.servers[strings.TrimSpace(id)]
	return server, ok
}

func (m *Manager) runtime(id string) (*runtimeSession, bool) {
	id = strings.TrimSpace(id)
	m.mu.Lock()
	defer m.mu.Unlock()
	server, ok := m.servers[id]
	if !ok {
		return nil, false
	}
	runtime := m.runtimes[id]
	if runtime == nil {
		client, err := newHTTPClient(server.ServerURL)
		if err != nil {
			return nil, false
		}
		runtime = &runtimeSession{client: client}
		m.runtimes[id] = runtime
	}
	return runtime, true
}

func (m *Manager) ready() error {
	m.mu.RLock()
	defer m.mu.RUnlock()
	if m.loadErr != nil {
		return fmt.Errorf("load Desktop logins: %w", m.loadErr)
	}
	if !crypto.KeysSet() {
		return fmt.Errorf("Desktop encryption keys are unavailable")
	}
	return nil
}

func (m *Manager) load() error {
	path := filepath.Join(m.configDir, authFileName)
	data, err := os.ReadFile(path)
	if errors.Is(err, os.ErrNotExist) {
		return nil
	}
	if err != nil {
		return err
	}
	if err := os.Chmod(path, 0o600); err != nil {
		return err
	}
	var file persistedFile
	if err := yaml.Unmarshal(data, &file); err != nil {
		return err
	}
	if file.SchemaVersion != 0 && file.SchemaVersion != persistedSchemaV1 {
		return fmt.Errorf("unsupported auth.yaml schema version %d", file.SchemaVersion)
	}
	loaded := make(map[string]persistedServer, len(file.Servers))
	for _, server := range file.Servers {
		server.ID = strings.TrimSpace(server.ID)
		server.ServerURL, err = normalizeServerURL(server.ServerURL)
		if err != nil || server.ID == "" || !crypto.IsEncrypted(server.EncryptedRefreshToken) {
			return fmt.Errorf("invalid saved Desktop login %q", server.ID)
		}
		if _, exists := loaded[server.ID]; exists {
			return fmt.Errorf("duplicate saved Desktop login %q", server.ID)
		}
		loaded[server.ID] = server
	}
	m.servers = loaded
	m.lastActiveTarget = strings.TrimSpace(file.LastActiveTarget)
	return nil
}

func (m *Manager) saveLocked() error {
	file := persistedFile{
		SchemaVersion:    persistedSchemaV1,
		LastActiveTarget: m.lastActiveTarget,
		Servers:          make([]persistedServer, 0, len(m.servers)),
	}
	for _, server := range m.servers {
		file.Servers = append(file.Servers, server)
	}
	sort.Slice(file.Servers, func(i, j int) bool { return file.Servers[i].ID < file.Servers[j].ID })
	data, err := yaml.Marshal(file)
	if err != nil {
		return err
	}
	lock, err := profile.AcquireLock(m.configDir)
	if err != nil {
		return err
	}
	defer lock.Release()
	return writeAtomic(filepath.Join(m.configDir, authFileName), data, 0o600)
}

func (m *Manager) saveLockedWithHook() error {
	if m.saveHook != nil {
		return m.saveHook()
	}
	return m.saveLocked()
}

func writeAtomic(path string, data []byte, mode os.FileMode) error {
	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
		return err
	}
	tmp, err := os.CreateTemp(filepath.Dir(path), ".auth.yaml.tmp-*")
	if err != nil {
		return err
	}
	tmpPath := tmp.Name()
	defer os.Remove(tmpPath)
	if err := tmp.Chmod(mode); err != nil {
		_ = tmp.Close()
		return err
	}
	if _, err := tmp.Write(data); err != nil {
		_ = tmp.Close()
		return err
	}
	if err := tmp.Sync(); err != nil {
		_ = tmp.Close()
		return err
	}
	if err := tmp.Close(); err != nil {
		return err
	}
	if err := os.Rename(tmpPath, path); err != nil {
		return err
	}
	return os.Chmod(path, mode)
}

type serverURLError struct {
	code    string
	message string
}

func (e *serverURLError) Error() string {
	return e.message
}

// ServerURLErrorCode exposes stable UI-facing error codes while keeping URL
// validation owned by Desktop authentication.
func ServerURLErrorCode(err error) (string, bool) {
	var target *serverURLError
	if errors.As(err, &target) {
		return target.code, true
	}
	return "", false
}

func normalizeServerURL(raw string) (string, error) {
	parsed, err := url.Parse(strings.TrimSpace(raw))
	if err != nil || parsed.User != nil || parsed.Host == "" || (parsed.Scheme != "https" && parsed.Scheme != "http") {
		return "", &serverURLError{
			code:    serverURLErrorInvalid,
			message: "AgentTeam Server URL must start with http:// or https://",
		}
	}
	parsed.Scheme = strings.ToLower(parsed.Scheme)
	parsed.RawQuery = ""
	parsed.Fragment = ""
	parsed.Path = strings.TrimRight(parsed.Path, "/")
	if parsed.Path == "/" {
		parsed.Path = ""
	}
	return parsed.String(), nil
}

func serverDisplayName(serverURL string) string {
	parsed, err := url.Parse(serverURL)
	if err == nil && strings.EqualFold(parsed.Hostname(), "agentteam.pingcap.com") {
		return "AgentTeam"
	}
	if err == nil && parsed.Hostname() != "" {
		return parsed.Hostname()
	}
	return "AgentTeam Server"
}

func summaryOf(server persistedServer) ServerSummary {
	return ServerSummary{
		ID:        server.ID,
		Name:      server.Name,
		ServerURL: server.ServerURL,
		UserEmail: server.User.Email,
		Saved:     true,
	}
}

func sameOriginString(left, right string) bool {
	l, err := url.Parse(left)
	if err != nil {
		return false
	}
	r, err := url.Parse(right)
	return err == nil && strings.EqualFold(l.Scheme, r.Scheme) && strings.EqualFold(l.Host, r.Host)
}

func originKey(serverURL string) string {
	parsed, err := url.Parse(serverURL)
	if err != nil {
		return ""
	}
	return strings.ToLower(parsed.Scheme + "://" + parsed.Host)
}

func newAuthID() (string, error) {
	var value [12]byte
	if _, err := rand.Read(value[:]); err != nil {
		return "", err
	}
	return "uas_" + base64.RawURLEncoding.EncodeToString(value[:]), nil
}

func newHTTPClient(serverURL string) (*http.Client, error) {
	origin, err := url.Parse(serverURL)
	if err != nil {
		return nil, err
	}
	jar, err := cookiejar.New(nil)
	if err != nil {
		return nil, err
	}
	transport, ok := http.DefaultTransport.(*http.Transport)
	if !ok || transport == nil {
		return nil, fmt.Errorf("default HTTP transport is unavailable")
	}
	cloned := transport.Clone()
	cloned.ResponseHeaderTimeout = gatewayHeaderLimit
	return &http.Client{
		Transport: cloned,
		Jar:       jar,
		Timeout:   requestTimeout,
		CheckRedirect: func(request *http.Request, via []*http.Request) error {
			if len(via) >= maxRedirects {
				return errTooManyRedirects
			}
			if request.URL.User != nil || !strings.EqualFold(request.URL.Scheme, origin.Scheme) || !strings.EqualFold(request.URL.Host, origin.Host) {
				return errCrossOriginRedirect
			}
			return nil
		},
	}, nil
}

func probeServerError(err error) error {
	if err == nil {
		return nil
	}
	if errors.Is(err, errCrossOriginRedirect) || errors.Is(err, errTooManyRedirects) {
		return &serverURLError{
			code:    serverURLErrorNotAgentTeam,
			message: "This address is not a AgentTeam Server. Check the server address and port.",
		}
	}
	var responseErr *serverResponseError
	if errors.As(err, &responseErr) {
		if responseErr.status == http.StatusTooManyRequests || responseErr.status >= http.StatusInternalServerError {
			return &serverURLError{
				code:    serverURLErrorUnreachable,
				message: "This AgentTeam Server is temporarily unavailable. Try again later, or check the server address.",
			}
		}
		return &serverURLError{
			code:    serverURLErrorNotAgentTeam,
			message: "This address is reachable, but it is not a AgentTeam Server. Check the server address and port.",
		}
	}
	var syntaxErr *json.SyntaxError
	var typeErr *json.UnmarshalTypeError
	if errors.As(err, &syntaxErr) || errors.As(err, &typeErr) {
		return &serverURLError{
			code:    serverURLErrorNotAgentTeam,
			message: "This address did not return a valid AgentTeam Server response. Check the server address and port.",
		}
	}
	if errors.Is(err, context.Canceled) {
		return err
	}
	if errors.Is(err, context.DeadlineExceeded) {
		return &serverURLError{
			code:    serverURLErrorUnreachable,
			message: "This AgentTeam Server is temporarily unavailable. Try again later, or check the server address.",
		}
	}
	var urlErr *url.Error
	var netErr net.Error
	if errors.As(err, &urlErr) || errors.As(err, &netErr) {
		return &serverURLError{
			code:    serverURLErrorUnreachable,
			message: "This AgentTeam Server is temporarily unavailable. Try again later, or check the server address.",
		}
	}
	return &serverURLError{
		code:    serverURLErrorUnreachable,
		message: "This AgentTeam Server is temporarily unavailable. Try again later, or check the server address.",
	}
}

type serverResponseError struct {
	status  int
	message string
}

func (e *serverResponseError) Error() string { return e.message }

func isServerResponseStatus(err error, status int) bool {
	var responseErr *serverResponseError
	return errors.As(err, &responseErr) && responseErr.status == status
}

func doJSON(ctx context.Context, client *http.Client, method, endpoint string, requestBody any, accessToken string, responseBody any) error {
	var body io.Reader = http.NoBody
	if requestBody != nil {
		data, err := json.Marshal(requestBody)
		if err != nil {
			return err
		}
		body = bytes.NewReader(data)
	}
	request, err := http.NewRequestWithContext(ctx, method, endpoint, body)
	if err != nil {
		return err
	}
	request.Header.Set("Accept", "application/json")
	if requestBody != nil {
		request.Header.Set("Content-Type", "application/json")
	}
	if accessToken != "" {
		request.Header.Set("Authorization", "Bearer "+accessToken)
	}
	response, err := client.Do(request)
	if err != nil {
		return err
	}
	defer response.Body.Close()
	data, err := io.ReadAll(io.LimitReader(response.Body, maxResponseBytes+1))
	if err != nil {
		return err
	}
	if len(data) > maxResponseBytes {
		return fmt.Errorf("AgentTeam Server response is too large")
	}
	if response.StatusCode < 200 || response.StatusCode >= 300 {
		var apiError struct {
			Error string `json:"error"`
		}
		_ = json.Unmarshal(data, &apiError)
		message := strings.TrimSpace(apiError.Error)
		if message == "" {
			message = http.StatusText(response.StatusCode)
		}
		return &serverResponseError{status: response.StatusCode, message: message}
	}
	if responseBody == nil || len(data) == 0 {
		return nil
	}
	if err := json.Unmarshal(data, responseBody); err != nil {
		return fmt.Errorf("decode AgentTeam Server response: %w", err)
	}
	return nil
}

// Connection exposes one GUI-authenticated target through the existing
// Desktop gateway capability without registering it as a daemon channel.
type Connection struct {
	manager *Manager
	target  Target
}

func (m *Manager) Connection(targetID string) (*Connection, bool) {
	target, ok := m.Target(targetID)
	if !ok {
		return nil, false
	}
	return &Connection{manager: m, target: target}, true
}

func (c *Connection) ID() string                  { return c.target.ID }
func (c *Connection) Enabled() bool               { return true }
func (c *Connection) Start(context.Context) error { return nil }
func (c *Connection) Stop(context.Context) error  { return nil }
func (c *Connection) Send(context.Context, types.Metadata, types.OutboundMessage) error {
	return errors.New("GUI user sessions do not provide daemon messaging")
}
func (c *Connection) ServerURL() string { return c.target.ServerURL }
func (c *Connection) GatewayHTTPClient() *http.Client {
	client, _ := c.manager.HTTPClient(c.target.AuthID)
	return client
}
func (c *Connection) UserToken(ctx context.Context) (string, error) {
	return c.manager.UserToken(ctx, c.target.AuthID)
}
func (c *Connection) RefreshUserToken(ctx context.Context, stale string) (string, error) {
	return c.manager.RefreshUserToken(ctx, c.target.AuthID, stale)
}
func (c *Connection) GatewayWebSocketDial(ctx context.Context, targetURL string, header http.Header, subprotocols []string) (*websocket.Conn, *http.Response, error) {
	client, ok := c.manager.HTTPClient(c.target.AuthID)
	if !ok || client == nil {
		return nil, nil, fmt.Errorf("GUI server client is unavailable")
	}
	transport, ok := client.Transport.(*http.Transport)
	if !ok || transport == nil {
		return nil, nil, fmt.Errorf("GUI server transport is unavailable")
	}
	cloned := transport.Clone()
	dialer := websocket.Dialer{
		NetDialContext:    cloned.DialContext,
		Proxy:             cloned.Proxy,
		HandshakeTimeout:  gatewayHeaderLimit,
		Subprotocols:      append([]string(nil), subprotocols...),
		EnableCompression: false,
	}
	if cloned.TLSClientConfig != nil {
		dialer.TLSClientConfig = cloned.TLSClientConfig.Clone()
		dialer.TLSClientConfig.NextProtos = []string{"http/1.1"}
	}
	return dialer.DialContext(ctx, targetURL, header)
}
