package main

import (
	"context"
	"fmt"
	"io"
	"os"
	"os/exec"
	"path/filepath"
	"regexp"
	"sort"
	"strings"
	"time"

	aweb "github.com/awebai/aw"
	"github.com/awebai/aw/awconfig"
	"github.com/awebai/aw/awid"
	"github.com/spf13/cobra"
)

var workspaceCmd = &cobra.Command{
	Use:   "workspace",
	Short: "Manage repo-local coordination workspaces",
}

var workspaceStatusCmd = &cobra.Command{
	Use:   "status",
	Short: "Show coordination status for the current workspace/identity and team",
	RunE:  runWorkspaceStatus,
}

var workspaceAddWorktreeCmd = &cobra.Command{
	Use:   "add-worktree [role]",
	Short: "Create a sibling git worktree and initialize a new coordination workspace in it",
	Args:  cobra.RangeArgs(0, 1),
	RunE:  runWorkspaceAddWorktree,
}

var workspaceMigrateMultiTeamCmd = &cobra.Command{
	Use:   "migrate-multi-team",
	Short: "Rewrite a legacy single-team workspace into the canonical multi-team shape",
	RunE:  runWorkspaceMigrateMultiTeam,
}

var (
	workspaceStatusLimit int
	workspaceStatusAll   bool
	workspaceAddAlias    string
)

type workspaceStatusOutput struct {
	SelectedTeam       string                            `json:"selected_team"`
	Memberships        []workspaceTeamMembershipItem     `json:"memberships,omitempty"`
	Workspace          aweb.WorkspaceInfo                `json:"workspace"`
	ContextKind        string                            `json:"context_kind"`
	Locks              []aweb.ReservationView            `json:"locks,omitempty"`
	Team               []aweb.WorkspaceInfo              `json:"team,omitempty"`
	TeamLocks          map[string][]aweb.ReservationView `json:"team_locks,omitempty"`
	EscalationsPending int                               `json:"escalations_pending"`
	ConflictCount      int                               `json:"conflict_count"`
}

type workspaceAddWorktreeOutput struct {
	Alias        string `json:"alias"`
	Role         string `json:"role"`
	Branch       string `json:"branch"`
	WorktreePath string `json:"worktree_path"`
}

type workspaceTeamMembershipItem struct {
	TeamID      string `json:"team_id"`
	Alias       string `json:"alias"`
	RoleName    string `json:"role_name,omitempty"`
	WorkspaceID string `json:"workspace_id,omitempty"`
	Active      bool   `json:"active"`
}

type workspaceMigrateMultiTeamOutput struct {
	Status      string `json:"status"`
	ActiveTeam  string `json:"active_team"`
	CertPath    string `json:"cert_path,omitempty"`
	Workspace   string `json:"workspace_path"`
	LegacyMoved bool   `json:"legacy_cert_moved"`
}

var saveWorktreeWorkspaceTo = awconfig.SaveWorktreeWorkspaceTo

func init() {
	workspaceStatusCmd.Flags().IntVar(&workspaceStatusLimit, "limit", 15, "Maximum team workspaces to show")
	workspaceStatusCmd.Flags().BoolVar(&workspaceStatusAll, "all", false, "Show all local team memberships in addition to the selected team status")
	workspaceAddWorktreeCmd.Flags().StringVar(&workspaceAddAlias, "alias", "", "Override the default alias")

	workspaceCmd.AddCommand(workspaceStatusCmd)
	workspaceCmd.AddCommand(workspaceAddWorktreeCmd)
	workspaceCmd.AddCommand(workspaceMigrateMultiTeamCmd)
	rootCmd.AddCommand(workspaceCmd)
}

func runWorkspaceStatus(cmd *cobra.Command, args []string) error {
	loadDotenvBestEffort()

	workingDir, _ := os.Getwd()
	client, sel, err := resolveClientSelectionForDir(workingDir)
	if err != nil {
		return err
	}
	// Team-bound workspaces need a local alias/workspace identity to function.
	hasIdentity := strings.TrimSpace(sel.WorkspaceID) != "" ||
		strings.TrimSpace(sel.DID) != "" ||
		strings.TrimSpace(sel.Alias) != ""
	if !hasIdentity {
		return usageError("selected account has no identity; run 'aw init' first")
	}

	state, teamState, _, err := awconfig.LoadWorkspaceAndTeamState(workingDir)
	if err != nil {
		if !(state == nil && os.IsNotExist(err)) {
			return fmt.Errorf("load workspace state: %w", err)
		}
	}

	workspaceID := strings.TrimSpace(sel.WorkspaceID)
	if state != nil {
		if membership, err := workspaceMembershipForSelection(state, sel); err == nil && membership != nil && strings.TrimSpace(membership.WorkspaceID) != "" {
			workspaceID = strings.TrimSpace(membership.WorkspaceID)
		}
	}

	ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
	defer cancel()

	teamResp, err := client.WorkspaceTeam(ctx, aweb.WorkspaceTeamParams{
		IncludeClaims:            true,
		IncludePresence:          true,
		OnlyWithClaims:           false,
		AlwaysIncludeWorkspaceID: workspaceID,
		Limit:                    workspaceStatusLimit,
	})
	if err != nil {
		return err
	}

	locksResp, err := client.ReservationList(ctx, "")
	if err != nil {
		return err
	}

	statusResp, err := client.CoordinationStatus(ctx, "")
	if err != nil {
		return err
	}

	locksByWorkspace := map[string][]aweb.ReservationView{}
	for _, reservation := range locksResp.Reservations {
		holder := strings.TrimSpace(reservation.HolderAgentID)
		if holder == "" {
			continue
		}
		locksByWorkspace[holder] = append(locksByWorkspace[holder], reservation)
	}
	for holder := range locksByWorkspace {
		sort.Slice(locksByWorkspace[holder], func(i, j int) bool {
			return locksByWorkspace[holder][i].ResourceKey < locksByWorkspace[holder][j].ResourceKey
		})
	}

	var self aweb.WorkspaceInfo
	team := make([]aweb.WorkspaceInfo, 0, len(teamResp.Workspaces))
	for _, workspace := range teamResp.Workspaces {
		if workspace.WorkspaceID == workspaceID {
			self = workspace
			continue
		}
		team = append(team, workspace)
	}

	if self.WorkspaceID == "" {
		self = fallbackWorkspaceInfo(sel, state)
	}

	teamLocks := map[string][]aweb.ReservationView{}
	for _, workspace := range team {
		if locks := locksByWorkspace[workspace.WorkspaceID]; len(locks) > 0 {
			teamLocks[workspace.WorkspaceID] = locks
		}
	}

	printOutput(workspaceStatusOutput{
		SelectedTeam:       strings.TrimSpace(sel.TeamID),
		Memberships:        membershipItemsForWorkspaceState(state, teamState, strings.TrimSpace(sel.TeamID), workspaceStatusAll),
		Workspace:          self,
		ContextKind:        inferWorkspaceContextKind(self, state),
		Locks:              locksByWorkspace[workspaceID],
		Team:               team,
		TeamLocks:          teamLocks,
		EscalationsPending: statusResp.EscalationsPending,
		ConflictCount:      len(statusResp.Conflicts),
	}, formatWorkspaceStatus)

	// Opportunistically clean up workspaces whose directories have disappeared.
	if gone := detectGoneWorkspaces(client, workspaceID); len(gone) > 0 {
		fmt.Fprint(os.Stderr, formatGoneWorkspaces(gone))
	}
	return nil
}

func runWorkspaceAddWorktree(cmd *cobra.Command, args []string) error {
	loadDotenvBestEffort()

	workingDir, _ := os.Getwd()
	root, err := currentGitWorktreeRootFromDir(workingDir)
	if err != nil {
		return usageError("workspace add-worktree requires a git worktree")
	}
	if err := ensureAwebRuntimeUntrackedForAddWorktree(root); err != nil {
		return err
	}
	if err := ensureAwebRuntimeGitIgnored(root); err != nil {
		return err
	}

	client, _, err := resolveClientSelectionForDir(workingDir)
	if err != nil {
		return err
	}

	requested := ""
	if len(args) > 0 {
		requested = strings.TrimSpace(args[0])
	}
	role, err := resolveRole(client, requested, isTTY() && requested == "", os.Stdin, os.Stderr)
	if err != nil {
		return err
	}
	role = normalizeWorkspaceRole(role)
	if role != "" && !isValidWorkspaceRole(role) {
		return usageError("invalid role: use 1-2 words (letters/numbers) with hyphens/underscores allowed; max 50 chars")
	}

	state, teamState, _, err := awconfig.LoadWorkspaceAndTeamState(workingDir)
	if err != nil {
		return fmt.Errorf("load workspace binding: %w", err)
	}
	if !state.HasTeamBinding() {
		return usageError("current worktree is missing team binding; run `aw init` first")
	}

	activeMembership := awconfig.ActiveMembershipFor(state, teamState)
	if activeMembership == nil {
		return usageError("current worktree is missing active_team membership; run `aw init` first")
	}
	teamID := strings.TrimSpace(activeMembership.TeamID)
	if teamID == "" {
		return usageError("current worktree is missing team_id; run `aw init` first")
	}
	sourceServerURL := strings.TrimSpace(state.AwebURL)
	if sourceServerURL == "" {
		return usageError("current worktree is missing aweb_url; run `aw init` first")
	}

	alias := strings.TrimSpace(workspaceAddAlias)
	aliasExplicit := alias != ""
	if aliasExplicit && !isValidWorkspaceAlias(alias) {
		return usageError("invalid alias %q: must start with an alphanumeric and contain only alphanumerics, dashes, or underscores (max 64 chars)", alias)
	}

	if aliasExplicit {
		teamAliases, err := fetchWorkspaceTeamAliases(client, strings.TrimSpace(activeMembership.WorkspaceID))
		if err != nil {
			return err
		}
		if teamAliases[strings.ToLower(alias)] {
			return usageError("alias %q is already in use by this team", alias)
		}
	} else {
		ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
		suggestion, err := client.SuggestAliasPrefix(ctx)
		cancel()
		if err != nil {
			return fmt.Errorf("suggest next alias from server: %w", err)
		}
		alias = strings.TrimSpace(suggestion.NamePrefix)
		if !isValidSuggestedAliasPrefix(alias) {
			return fmt.Errorf("server returned invalid alias suggestion %q", alias)
		}
	}

	branchName := alias
	worktreePath, err := deriveWorkspaceAddWorktreePath(root, branchName)
	if err != nil {
		return fmt.Errorf("security error: %w", err)
	}
	if _, err := os.Stat(worktreePath); err == nil {
		return fmt.Errorf("directory %s already exists", worktreePath)
	} else if !os.IsNotExist(err) {
		return fmt.Errorf("stat %s: %w", worktreePath, err)
	}

	if !jsonFlag {
		fmt.Fprintf(os.Stderr, "Creating worktree for branch %q...\n", branchName)
		fmt.Fprintf(os.Stderr, "  Main repo: %s\n", root)
		fmt.Fprintf(os.Stderr, "  Worktree:  %s\n", worktreePath)
		fmt.Fprintf(os.Stderr, "  Role:      %s\n", role)
		fmt.Fprintf(os.Stderr, "  Alias:     %s\n\n", alias)
		fmt.Fprintln(os.Stderr, "Creating git worktree...")
	}

	branchCreated, err := createWorkspaceGitWorktree(root, worktreePath, branchName, jsonFlag)
	if err != nil {
		return fmt.Errorf("failed to create worktree: %w", err)
	}
	if err := ensureAwebRuntimeGitIgnored(worktreePath); err != nil {
		cleanupWorkspaceWorktree(root, worktreePath, branchName, branchCreated)
		return err
	}

	if !jsonFlag {
		fmt.Fprintln(os.Stderr, "Bootstrapping team certificate...")
	}

	teamDomain, teamName, err := awid.ParseTeamID(teamID)
	if err != nil {
		cleanupWorkspaceWorktree(root, worktreePath, branchName, branchCreated)
		return fmt.Errorf("invalid team_id in workspace.yaml: %w", err)
	}

	// When the team controller key is available locally (BYOD), mint the
	// certificate directly.  When it is not (hosted/API-key bootstrapped
	// workspaces), delegate certificate issuance to the cloud via the
	// parent workspace's API key.
	hasTeamKey, err := awconfig.TeamKeyExists(teamDomain, teamName)
	if err != nil {
		cleanupWorkspaceWorktree(root, worktreePath, branchName, branchCreated)
		return fmt.Errorf("check team key: %w", err)
	}

	if hasTeamKey {
		_, err = addWorktreeViaLocalTeamKey(
			worktreePath, root, branchName, branchCreated,
			teamID, teamDomain, teamName, sourceServerURL, workingDir,
			alias, role, state,
		)
	} else {
		_, err = addWorktreeViaCloudBootstrap(
			worktreePath, root, branchName, branchCreated,
			sourceServerURL, alias, role, state,
		)
	}
	if err != nil {
		return err
	}

	output := workspaceAddWorktreeOutput{
		Alias:        alias,
		Role:         role,
		Branch:       branchName,
		WorktreePath: worktreePath,
	}
	if jsonFlag {
		printJSON(output)
	} else {
		fmt.Print(formatWorkspaceAddWorktree(output))
	}
	return nil
}

func addWorktreeViaLocalTeamKey(
	worktreePath, root, branchName string, branchCreated bool,
	teamID, teamDomain, teamName, sourceServerURL, workingDir string,
	alias, role string, state *awconfig.WorktreeWorkspace,
) (connectOutput, error) {
	registryURL, err := resolveWorkspaceTeamRegistryURL(workingDir, sourceServerURL, teamDomain)
	if err != nil {
		cleanupWorkspaceWorktree(root, worktreePath, branchName, branchCreated)
		return connectOutput{}, err
	}

	_, inviteToken, err := createTeamInviteToken(teamDomain, teamName, registryURL, sourceServerURL, true)
	if err != nil {
		cleanupWorkspaceWorktree(root, worktreePath, branchName, branchCreated)
		return connectOutput{}, fmt.Errorf("create local team invite for %s: %w", teamID, err)
	}
	acceptedInvite, err := acceptTeamInviteWithDetails(worktreePath, inviteToken, alias, "")
	if err != nil {
		cleanupWorkspaceWorktree(root, worktreePath, branchName, branchCreated)
		return connectOutput{}, fmt.Errorf("accept team invite in new worktree: %w", err)
	}

	rollback := func(step string, cause error) (connectOutput, error) {
		rollbackErr := revokeAcceptedTeamCertificate(acceptedInvite)
		cleanupWorkspaceWorktree(root, worktreePath, branchName, branchCreated)
		if rollbackErr != nil {
			return connectOutput{}, fmt.Errorf("%s: %w (rollback revoke failed: %v)", step, cause, rollbackErr)
		}
		return connectOutput{}, fmt.Errorf("%s: %w", step, cause)
	}

	fmt.Fprintln(os.Stderr, "Connecting new workspace...")
	connectResult, err := initCertificateConnectWithOptions(worktreePath, sourceServerURL, certificateConnectOptions{
		Role:      role,
		HumanName: strings.TrimSpace(state.HumanName),
		AgentType: strings.TrimSpace(state.AgentType),
	})
	if err != nil {
		return rollback("connect new worktree", err)
	}
	if strings.TrimSpace(connectResult.Alias) != "" && !strings.EqualFold(strings.TrimSpace(connectResult.Alias), alias) {
		return rollback(
			"validate new worktree alias",
			fmt.Errorf("new workspace connected as alias %q, expected %q", strings.TrimSpace(connectResult.Alias), alias),
		)
	}
	return connectResult, nil
}

func addWorktreeViaCloudBootstrap(
	worktreePath, root, branchName string, branchCreated bool,
	sourceServerURL, alias, role string, state *awconfig.WorktreeWorkspace,
) (connectOutput, error) {
	apiKey := strings.TrimSpace(state.APIKey)
	if apiKey == "" {
		cleanupWorkspaceWorktree(root, worktreePath, branchName, branchCreated)
		return connectOutput{}, usageError(
			"no local team key and no workspace API key; " +
				"run `aw id team create` to manage teams locally, " +
				"or re-initialize with an API key from the dashboard",
		)
	}

	awebURL, err := normalizeAPIKeyBootstrapBaseURL(sourceServerURL)
	if err != nil {
		cleanupWorkspaceWorktree(root, worktreePath, branchName, branchCreated)
		return connectOutput{}, err
	}

	fmt.Fprintln(os.Stderr, "Requesting certificate from cloud...")
	result, err := runAPIKeyBootstrapInit(apiKeyInitRequest{
		WorkingDir: worktreePath,
		AwebURL:    awebURL,
		APIKey:     apiKey,
		Alias:      alias,
		Role:       role,
		HumanName:  strings.TrimSpace(state.HumanName),
		AgentType:  strings.TrimSpace(state.AgentType),
		Persistent: false,
	})
	if err != nil {
		cleanupWorkspaceWorktree(root, worktreePath, branchName, branchCreated)
		return connectOutput{}, fmt.Errorf("cloud bootstrap for new worktree: %w", err)
	}
	if strings.TrimSpace(result.Alias) != "" && !strings.EqualFold(strings.TrimSpace(result.Alias), alias) {
		cleanupWorkspaceWorktree(root, worktreePath, branchName, branchCreated)
		return connectOutput{}, fmt.Errorf("new workspace connected as alias %q, expected %q", strings.TrimSpace(result.Alias), alias)
	}
	return result, nil
}

func runWorkspaceMigrateMultiTeam(cmd *cobra.Command, args []string) error {
	workingDir, err := os.Getwd()
	if err != nil {
		return err
	}
	workspacePath, err := awconfig.FindWorktreeWorkspacePath(workingDir)
	if err != nil {
		if os.IsNotExist(err) {
			return usageError("current worktree is missing .aw/workspace.yaml")
		}
		return err
	}

	if workspace, err := awconfig.LoadWorktreeWorkspaceFrom(workspacePath); err == nil && workspace != nil {
		teamState, teamStateErr := awconfig.LoadTeamState(workingDir)
		if teamStateErr != nil {
			return teamStateErr
		}
		output := workspaceMigrateMultiTeamOutput{
			Status:      "already_multi_team",
			ActiveTeam:  strings.TrimSpace(teamState.ActiveTeam),
			Workspace:   workspacePath,
			LegacyMoved: false,
		}
		if activeMembership := awconfig.ActiveMembershipFor(workspace, teamState); activeMembership != nil {
			output.CertPath = strings.TrimSpace(activeMembership.CertPath)
		}
		printOutput(output, formatWorkspaceMigrateMultiTeam)
		return nil
	} else if err != nil && !strings.Contains(err.Error(), awconfig.LegacyWorkspaceSingleTeamError()) {
		return err
	}

	output, err := migrateLegacyWorkspaceToMultiTeam(workingDir, workspacePath)
	if err != nil {
		return err
	}
	printOutput(output, formatWorkspaceMigrateMultiTeam)
	return nil
}

func migrateLegacyWorkspaceToMultiTeam(workingDir, workspacePath string) (workspaceMigrateMultiTeamOutput, error) {
	legacy, err := awconfig.LoadLegacySingleTeamWorkspaceFrom(workspacePath)
	if err != nil {
		return workspaceMigrateMultiTeamOutput{}, err
	}
	legacyCertPath := filepath.Join(workingDir, ".aw", "team-cert.pem")
	cert, err := awid.LoadTeamCertificate(legacyCertPath)
	if err != nil {
		return workspaceMigrateMultiTeamOutput{}, fmt.Errorf("load legacy team certificate %s: %w", legacyCertPath, err)
	}
	if strings.TrimSpace(cert.Team) != strings.TrimSpace(legacy.TeamID) {
		return workspaceMigrateMultiTeamOutput{}, fmt.Errorf("legacy team certificate team_id %q does not match workspace.yaml team_id %q", cert.Team, legacy.TeamID)
	}
	certPath, err := awconfig.SaveTeamCertificateForTeam(workingDir, legacy.TeamID, cert)
	if err != nil {
		return workspaceMigrateMultiTeamOutput{}, err
	}
	state := awconfig.WorktreeWorkspace{
		AwebURL: legacy.AwebURL,
		Memberships: []awconfig.WorktreeMembership{{
			TeamID:      legacy.TeamID,
			Alias:       legacy.Alias,
			RoleName:    legacy.RoleName,
			WorkspaceID: legacy.WorkspaceID,
			CertPath:    certPath,
			JoinedAt:    firstNonEmpty(strings.TrimSpace(cert.IssuedAt), strings.TrimSpace(legacy.UpdatedAt)),
		}},
		HumanName:       legacy.HumanName,
		AgentType:       legacy.AgentType,
		RepoID:          legacy.RepoID,
		CanonicalOrigin: legacy.CanonicalOrigin,
		Hostname:        legacy.Hostname,
		WorkspacePath:   legacy.WorkspacePath,
		UpdatedAt:       firstNonEmpty(strings.TrimSpace(legacy.UpdatedAt), time.Now().UTC().Format(time.RFC3339)),
	}
	if err := saveWorktreeWorkspaceTo(workspacePath, &state); err != nil {
		return workspaceMigrateMultiTeamOutput{}, err
	}
	teamState := &awconfig.TeamState{
		ActiveTeam: legacy.TeamID,
		Memberships: []awconfig.TeamMembership{{
			TeamID:   legacy.TeamID,
			Alias:    legacy.Alias,
			CertPath: certPath,
			JoinedAt: firstNonEmpty(strings.TrimSpace(cert.IssuedAt), strings.TrimSpace(legacy.UpdatedAt)),
		}},
	}
	if err := awconfig.SaveTeamState(workingDir, teamState); err != nil {
		return workspaceMigrateMultiTeamOutput{}, err
	}
	if err := os.Remove(legacyCertPath); err != nil && !os.IsNotExist(err) {
		return workspaceMigrateMultiTeamOutput{}, err
	}

	return workspaceMigrateMultiTeamOutput{
		Status:      "migrated",
		ActiveTeam:  legacy.TeamID,
		CertPath:    certPath,
		Workspace:   workspacePath,
		LegacyMoved: true,
	}, nil
}

func currentGitWorktreeRoot() (string, error) {
	wd, err := os.Getwd()
	if err != nil {
		return "", err
	}
	return currentGitWorktreeRootFromDir(wd)
}

func currentGitWorktreeRootFromDir(workingDir string) (string, error) {
	cmd := exec.Command("git", "-C", workingDir, "rev-parse", "--show-toplevel")
	out, err := cmd.Output()
	if err != nil {
		return "", err
	}
	root := strings.TrimSpace(string(out))
	if root == "" {
		return "", fmt.Errorf("git returned empty worktree root")
	}
	return root, nil
}

func resolveWorkspaceRepoOrigin(root, explicit string) (string, error) {
	if strings.TrimSpace(explicit) != "" {
		return strings.TrimSpace(explicit), nil
	}
	cmd := exec.Command("git", "-C", root, "remote", "get-url", "origin")
	out, err := cmd.Output()
	if err != nil {
		return "", usageError("missing git remote origin; use --repo-origin to register this workspace")
	}
	origin := strings.TrimSpace(string(out))
	if origin == "" {
		return "", usageError("missing git remote origin; use --repo-origin to register this workspace")
	}
	return origin, nil
}

func fallbackWorkspaceInfo(sel *awconfig.Selection, state *awconfig.WorktreeWorkspace) aweb.WorkspaceInfo {
	info := aweb.WorkspaceInfo{
		WorkspaceID: sel.WorkspaceID,
		Alias:       sel.Alias,
		Status:      "offline",
	}
	if state == nil {
		return info
	}
	if membership, err := workspaceMembershipForSelection(state, sel); err == nil && membership != nil {
		if strings.TrimSpace(membership.WorkspaceID) != "" {
			info.WorkspaceID = strings.TrimSpace(membership.WorkspaceID)
		}
		if strings.TrimSpace(membership.Alias) != "" {
			info.Alias = strings.TrimSpace(membership.Alias)
		}
		if strings.TrimSpace(membership.RoleName) != "" {
			info.Role = stringPtr(strings.TrimSpace(membership.RoleName))
		}
	}
	if strings.TrimSpace(state.HumanName) != "" {
		info.HumanName = stringPtr(strings.TrimSpace(state.HumanName))
	}
	if strings.TrimSpace(state.Hostname) != "" {
		info.Hostname = stringPtr(strings.TrimSpace(state.Hostname))
	}
	if strings.TrimSpace(state.WorkspacePath) != "" {
		info.WorkspacePath = stringPtr(strings.TrimSpace(state.WorkspacePath))
	}
	if strings.TrimSpace(state.CanonicalOrigin) != "" {
		info.Repo = stringPtr(strings.TrimSpace(state.CanonicalOrigin))
	}
	return info
}

func inferWorkspaceContextKind(info aweb.WorkspaceInfo, state *awconfig.WorktreeWorkspace) string {
	if kind := derefString(info.ContextKind); kind != "" {
		return kind
	}
	if state != nil {
		if strings.TrimSpace(state.CanonicalOrigin) != "" || strings.TrimSpace(state.WorkspacePath) != "" {
			return "repo_worktree"
		}
	}
	if derefString(info.Repo) != "" || derefString(info.Branch) != "" || derefString(info.WorkspacePath) != "" {
		return "repo_worktree"
	}
	return "none"
}

func stringPtr(v string) *string {
	if strings.TrimSpace(v) == "" {
		return nil
	}
	return &v
}

func formatWorkspaceAddWorktree(v any) string {
	out := v.(workspaceAddWorktreeOutput)
	var sb strings.Builder
	sb.WriteString(fmt.Sprintf("New agent worktree created at %s\n", abbreviateUserHome(out.WorktreePath)))
	sb.WriteString(fmt.Sprintf("Alias:      %s\n", out.Alias))
	sb.WriteString(fmt.Sprintf("Role:       %s\n", out.Role))
	sb.WriteString(fmt.Sprintf("Branch:     %s\n", out.Branch))
	sb.WriteString(fmt.Sprintf("Workspace:  this worktree is now agent %s\n", out.Alias))
	sb.WriteString("State:      .aw/ in that worktree stores the local identity and workspace binding\n")
	sb.WriteString("\nTo use:\n")
	sb.WriteString(fmt.Sprintf("  cd %s\n", abbreviateUserHome(out.WorktreePath)))
	sb.WriteString("  Tell your agent: please read https://aweb.ai/docs/cli-tutorial.md\n")
	return sb.String()
}

func formatWorkspaceMigrateMultiTeam(v any) string {
	out := v.(workspaceMigrateMultiTeamOutput)
	var sb strings.Builder
	switch out.Status {
	case "already_multi_team":
		sb.WriteString("Workspace already uses the canonical multi-team shape.\n")
	default:
		sb.WriteString("Workspace migrated to the canonical multi-team shape.\n")
	}
	sb.WriteString(fmt.Sprintf("Active team: %s\n", out.ActiveTeam))
	if strings.TrimSpace(out.CertPath) != "" {
		sb.WriteString(fmt.Sprintf("Certificate: %s\n", out.CertPath))
	}
	sb.WriteString(fmt.Sprintf("Workspace:   %s\n", abbreviateUserHome(out.Workspace)))
	return sb.String()
}

var (
	workspaceAliasPattern    = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$`)
	workspaceRoleWordPattern = regexp.MustCompile(`^[a-z0-9_-]+$`)
)

func isValidWorkspaceAlias(alias string) bool {
	return workspaceAliasPattern.MatchString(strings.TrimSpace(alias))
}

func isValidSuggestedAliasPrefix(alias string) bool {
	return isValidWorkspaceAlias(alias)
}

func normalizeWorkspaceRole(role string) string {
	fields := strings.Fields(strings.TrimSpace(role))
	if len(fields) == 0 {
		return ""
	}
	return strings.ToLower(strings.Join(fields, " "))
}

func isValidWorkspaceRole(role string) bool {
	normalized := normalizeWorkspaceRole(role)
	if normalized == "" || len(normalized) > 50 {
		return false
	}
	words := strings.Split(normalized, " ")
	if len(words) > 2 {
		return false
	}
	for _, word := range words {
		if !workspaceRoleWordPattern.MatchString(word) {
			return false
		}
	}
	return true
}

// fetchAvailableRoles returns the available roles from the team roles bundle.
// This is the single source of truth for role lists.
func fetchAvailableRoles(client *aweb.Client) ([]string, error) {
	ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
	defer cancel()

	resp, err := client.ActiveTeamRoles(ctx, aweb.ActiveTeamRolesParams{OnlySelected: false})
	if err != nil {
		return nil, fmt.Errorf("fetching team roles: %w", err)
	}

	roles := make([]string, 0, len(resp.Roles))
	for name := range resp.Roles {
		roles = append(roles, name)
	}
	sort.Strings(roles)
	return roles, nil
}

// resolveRole fetches available roles from the team roles bundle, validates
// the requested role against them, and optionally prompts the user to
// choose. This is the single entry point for role resolution.
func resolveRole(client *aweb.Client, requested string, allowPrompt bool, in io.Reader, out io.Writer) (string, error) {
	roles, err := fetchAvailableRoles(client)
	if err != nil {
		debugLog("fetch roles: %v", err)
		return normalizeWorkspaceRole(requested), nil
	}
	if len(roles) == 0 {
		return normalizeWorkspaceRole(requested), nil
	}
	return selectRoleFromAvailableRoles(requested, roles, allowPrompt, in, out)
}

func selectRoleFromAvailableRoles(requested string, roles []string, allowPrompt bool, in io.Reader, out io.Writer) (string, error) {
	if len(roles) == 0 {
		return "", usageError("no roles defined in the active team roles")
	}

	normalizedRoles := make(map[string]string, len(roles))
	for _, role := range roles {
		normalized := normalizeWorkspaceRole(role)
		if normalized != "" {
			normalizedRoles[normalized] = role
		}
	}

	requested = normalizeWorkspaceRole(requested)
	if requested != "" {
		if role, ok := normalizedRoles[requested]; ok {
			return role, nil
		}
		return "", usageError("invalid role %q; available roles: %s", requested, strings.Join(roles, ", "))
	}

	if !allowPrompt {
		return "", usageError("no role specified; available roles: %s", strings.Join(roles, ", "))
	}

	role, err := promptIndexedChoice("Role", roles, -1, in, out)
	if err != nil {
		return "", err
	}
	role = normalizeWorkspaceRole(role)
	if selected, ok := normalizedRoles[role]; ok {
		return selected, nil
	}
	return "", usageError("invalid role %q; available roles: %s", role, strings.Join(roles, ", "))
}

func fetchWorkspaceTeamAliases(client *aweb.Client, workspaceID string) (map[string]bool, error) {
	ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
	defer cancel()

	resp, err := client.WorkspaceTeam(ctx, aweb.WorkspaceTeamParams{
		IncludeClaims:            false,
		IncludePresence:          false,
		AlwaysIncludeWorkspaceID: strings.TrimSpace(workspaceID),
		Limit:                    200,
	})
	if err != nil {
		return nil, fmt.Errorf("list team aliases: %w", err)
	}
	if resp.HasMore {
		return nil, usageError("team has more than 200 workspaces; specify --alias explicitly")
	}

	aliases := make(map[string]bool, len(resp.Workspaces))
	for _, workspace := range resp.Workspaces {
		alias := strings.ToLower(strings.TrimSpace(workspace.Alias))
		if alias != "" {
			aliases[alias] = true
		}
	}
	return aliases, nil
}

func resolveWorkspaceTeamRegistryURL(workingDir, awebURL, teamDomain string) (string, error) {
	meta, err := awconfig.LoadControllerMeta(teamDomain)
	if err == nil && meta != nil {
		if registryURL := strings.TrimSpace(meta.RegistryURL); registryURL != "" {
			return registryURL, nil
		}
	}
	if err != nil && !os.IsNotExist(err) {
		return "", fmt.Errorf("load controller metadata for %s: %w", teamDomain, err)
	}
	if teamState, err := awconfig.LoadTeamState(workingDir); err == nil && teamState != nil {
		for _, membership := range teamState.Memberships {
			membershipDomain, _, parseErr := awid.ParseTeamID(strings.TrimSpace(membership.TeamID))
			if parseErr != nil {
				continue
			}
			if strings.EqualFold(membershipDomain, awconfig.NormalizeDomain(teamDomain)) {
				if registryURL := strings.TrimSpace(membership.RegistryURL); registryURL != "" {
					return registryURL, nil
				}
			}
		}
	} else if err != nil && !os.IsNotExist(err) {
		return "", fmt.Errorf("load team state: %w", err)
	}
	if identity, _, err := awconfig.LoadWorktreeIdentityFromDir(workingDir); err == nil && identity != nil {
		if registryURL := strings.TrimSpace(identity.RegistryURL); registryURL != "" {
			return registryURL, nil
		}
	} else if err != nil && !os.IsNotExist(err) {
		return "", fmt.Errorf("load worktree identity: %w", err)
	}
	if strings.TrimSpace(awebURL) != "" {
		if discovered, err := discoverOnboardingServiceURLs(awebURL); err == nil {
			if registryURL := strings.TrimSpace(discovered.RegistryURL); registryURL != "" {
				return registryURL, nil
			}
		}
		return "", usageError("current worktree is missing identity registry_url; run `aw init` again or restore .aw/identity.yaml")
	}
	return "", usageError("current worktree is missing registry configuration for %s", teamDomain)
}

func deriveWorkspaceAddWorktreePath(mainRepo, branchName string) (string, error) {
	repoName := filepath.Base(mainRepo)
	parentDir := filepath.Dir(mainRepo)
	worktreePath := filepath.Join(parentDir, repoName+"-"+branchName)

	cleanPath := filepath.Clean(worktreePath)
	rel, err := filepath.Rel(parentDir, cleanPath)
	if err != nil {
		return "", fmt.Errorf("invalid worktree path: %w", err)
	}
	if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
		return "", fmt.Errorf("invalid worktree path: path traversal detected")
	}
	return cleanPath, nil
}

func workspaceBranchExists(repoPath, branch string) bool {
	cmd := exec.Command("git", "-C", repoPath, "rev-parse", "--verify", branch)
	return cmd.Run() == nil
}

func createWorkspaceGitWorktree(repoPath, worktreePath, branchName string, quiet bool) (branchCreated bool, err error) {
	if workspaceBranchExists(repoPath, branchName) {
		if !quiet {
			fmt.Fprintf(os.Stderr, "  Using existing branch %q\n", branchName)
		}
		cmd := exec.Command("git", "-C", repoPath, "worktree", "add", worktreePath, branchName)
		if !quiet {
			cmd.Stdout = os.Stderr
			cmd.Stderr = os.Stderr
		}
		return false, cmd.Run()
	}

	if !quiet {
		fmt.Fprintf(os.Stderr, "  Creating new branch %q\n", branchName)
	}
	cmd := exec.Command("git", "-C", repoPath, "worktree", "add", worktreePath, "-b", branchName)
	if !quiet {
		cmd.Stdout = os.Stderr
		cmd.Stderr = os.Stderr
	}
	return true, cmd.Run()
}

func cleanupWorkspaceWorktree(repoPath, worktreePath, branchName string, deleteBranch bool) {
	cmd := exec.Command("git", "-C", repoPath, "worktree", "remove", worktreePath, "--force")
	if err := cmd.Run(); err != nil {
		fmt.Fprintf(os.Stderr, "Warning: git worktree remove failed: %v\n", err)
	}

	if _, err := os.Stat(worktreePath); err == nil {
		if err := os.RemoveAll(worktreePath); err != nil {
			fmt.Fprintf(os.Stderr, "Warning: failed to remove directory %s: %v\n", worktreePath, err)
		}
	}

	if deleteBranch && workspaceBranchExists(repoPath, branchName) {
		inUse, err := workspaceBranchInUse(repoPath, branchName)
		if err != nil {
			fmt.Fprintf(os.Stderr, "Warning: failed to inspect worktree list for branch %s: %v\n", branchName, err)
		}
		if !inUse {
			deleteCmd := exec.Command("git", "-C", repoPath, "branch", "-D", branchName)
			if err := deleteCmd.Run(); err != nil {
				fmt.Fprintf(os.Stderr, "Warning: failed to delete branch %s: %v\n", branchName, err)
			}
		}
	}
}

func workspaceBranchInUse(repoPath, branchName string) (bool, error) {
	cmd := exec.Command("git", "-C", repoPath, "worktree", "list", "--porcelain")
	out, err := cmd.Output()
	if err != nil {
		return false, err
	}
	for _, line := range strings.Split(string(out), "\n") {
		line = strings.TrimSpace(line)
		if line == "" {
			continue
		}
		if strings.HasPrefix(line, "branch ") && strings.TrimPrefix(line, "branch ") == "refs/heads/"+branchName {
			return true, nil
		}
	}
	return false, nil
}

func formatWorkspaceStatus(v any) string {
	out := v.(workspaceStatusOutput)
	var sb strings.Builder
	now := time.Now()

	sb.WriteString("## Self\n")
	if strings.TrimSpace(out.SelectedTeam) != "" {
		sb.WriteString(fmt.Sprintf("- Team: %s\n", out.SelectedTeam))
	}
	sb.WriteString(fmt.Sprintf("- Alias: %s\n", out.Workspace.Alias))
	sb.WriteString(fmt.Sprintf("- Context: %s\n", out.ContextKind))
	if out.Workspace.Role != nil && strings.TrimSpace(*out.Workspace.Role) != "" {
		sb.WriteString(fmt.Sprintf("- Role: %s\n", strings.TrimSpace(*out.Workspace.Role)))
	}
	sb.WriteString(fmt.Sprintf("- Status: %s\n", out.Workspace.Status))
	if out.Workspace.Hostname != nil && strings.TrimSpace(*out.Workspace.Hostname) != "" {
		sb.WriteString(fmt.Sprintf("- Hostname: %s\n", strings.TrimSpace(*out.Workspace.Hostname)))
	}
	if out.Workspace.WorkspacePath != nil && strings.TrimSpace(*out.Workspace.WorkspacePath) != "" {
		sb.WriteString(fmt.Sprintf("- Path: %s\n", abbreviateUserHome(strings.TrimSpace(*out.Workspace.WorkspacePath))))
	}
	if out.Workspace.Repo != nil && strings.TrimSpace(*out.Workspace.Repo) != "" {
		sb.WriteString(fmt.Sprintf("- Repo: %s\n", strings.TrimSpace(*out.Workspace.Repo)))
	}
	if out.Workspace.Branch != nil && strings.TrimSpace(*out.Workspace.Branch) != "" {
		sb.WriteString(fmt.Sprintf("- Branch: %s\n", strings.TrimSpace(*out.Workspace.Branch)))
	}
	sb.WriteString(fmt.Sprintf("- Focus: %s\n", formatWorkspaceFocus(out.Workspace)))
	if apexLine := formatWorkspaceApexLine(out.Workspace); apexLine != "" {
		sb.WriteString(fmt.Sprintf("- %s\n", apexLine))
	}
	sb.WriteString(fmt.Sprintf("- Claims: %s\n", formatWorkspaceClaimsSummary(out.Workspace.Claims)))
	sb.WriteString(fmt.Sprintf("- Locks: %s\n", formatWorkspaceLocksSummary(out.Locks, now, 0)))
	if len(out.Memberships) > 0 {
		sb.WriteString(fmt.Sprintf("- Memberships: %s\n", formatWorkspaceMembershipSummary(out.Memberships)))
	}

	sb.WriteString("\n## Team\n")
	if len(out.Team) == 0 {
		sb.WriteString("No other workspaces.\n")
	} else {
		for _, workspace := range out.Team {
			line := workspace.Alias
			if workspace.Role != nil && strings.TrimSpace(*workspace.Role) != "" {
				line += " (" + strings.TrimSpace(*workspace.Role) + ")"
			}
			line += " — " + workspace.Status
			if lastSeen := derefString(workspace.LastSeen); lastSeen != "" {
				line += ", seen " + formatTimeAgo(lastSeen)
			}
			sb.WriteString(line + "\n")
			if hostPathLine := formatWorkspaceHostPath(workspace); hostPathLine != "" {
				sb.WriteString(fmt.Sprintf("  %s\n", hostPathLine))
			}
			if repoLine := formatWorkspaceRepoBranch(workspace, true); repoLine != "" {
				sb.WriteString(fmt.Sprintf("  %s\n", repoLine))
			}
			sb.WriteString(fmt.Sprintf("  Focus: %s\n", formatWorkspaceFocus(workspace)))
			if apexLine := formatWorkspaceApexLine(workspace); apexLine != "" {
				sb.WriteString(fmt.Sprintf("  %s\n", apexLine))
			}
			sb.WriteString(fmt.Sprintf("  Claims: %s\n", formatWorkspaceClaimsSummary(workspace.Claims)))
			sb.WriteString(fmt.Sprintf("  Locks: %s\n", formatWorkspaceLocksSummary(out.TeamLocks[workspace.WorkspaceID], now, 3)))
		}
	}

	sb.WriteString(fmt.Sprintf("\nEscalations pending: %d\n", out.EscalationsPending))
	if out.ConflictCount > 0 {
		sb.WriteString(fmt.Sprintf("Claim conflicts: %d\n", out.ConflictCount))
	}
	return sb.String()
}

func membershipItemsForWorkspaceState(state *awconfig.WorktreeWorkspace, teamState *awconfig.TeamState, selectedTeam string, includeAll bool) []workspaceTeamMembershipItem {
	if state == nil {
		return nil
	}
	activeTeam := ""
	if teamState != nil {
		activeTeam = strings.TrimSpace(teamState.ActiveTeam)
	}
	if !includeAll {
		if membership := state.Membership(selectedTeam); membership != nil {
			return []workspaceTeamMembershipItem{{
				TeamID:      strings.TrimSpace(membership.TeamID),
				Alias:       strings.TrimSpace(membership.Alias),
				RoleName:    strings.TrimSpace(membership.RoleName),
				WorkspaceID: strings.TrimSpace(membership.WorkspaceID),
				Active:      strings.EqualFold(strings.TrimSpace(membership.TeamID), activeTeam),
			}}
		}
		return nil
	}

	items := make([]workspaceTeamMembershipItem, 0, len(state.Memberships))
	for _, membership := range state.Memberships {
		items = append(items, workspaceTeamMembershipItem{
			TeamID:      strings.TrimSpace(membership.TeamID),
			Alias:       strings.TrimSpace(membership.Alias),
			RoleName:    strings.TrimSpace(membership.RoleName),
			WorkspaceID: strings.TrimSpace(membership.WorkspaceID),
			Active:      strings.EqualFold(strings.TrimSpace(membership.TeamID), activeTeam),
		})
	}
	sort.Slice(items, func(i, j int) bool {
		if items[i].Active != items[j].Active {
			return items[i].Active
		}
		return items[i].TeamID < items[j].TeamID
	})
	return items
}

func formatWorkspaceMembershipSummary(items []workspaceTeamMembershipItem) string {
	if len(items) == 0 {
		return "none"
	}
	parts := make([]string, 0, len(items))
	for _, item := range items {
		label := item.TeamID
		if strings.TrimSpace(item.RoleName) != "" {
			label += " (" + strings.TrimSpace(item.RoleName) + ")"
		}
		if item.Active {
			label += " [active]"
		}
		parts = append(parts, label)
	}
	return strings.Join(parts, ", ")
}

func formatWorkspaceHostPath(workspace aweb.WorkspaceInfo) string {
	hostname := strings.TrimSpace(derefString(workspace.Hostname))
	path := strings.TrimSpace(derefString(workspace.WorkspacePath))
	if path != "" {
		path = abbreviateUserHome(path)
	}
	switch {
	case hostname != "" && path != "":
		return fmt.Sprintf("Host: %s  Path: %s", hostname, path)
	case hostname != "":
		return fmt.Sprintf("Host: %s", hostname)
	case path != "":
		return fmt.Sprintf("Path: %s", path)
	default:
		return ""
	}
}

func formatWorkspaceRepoBranch(workspace aweb.WorkspaceInfo, hideDefaultBranch bool) string {
	repo := strings.TrimSpace(derefString(workspace.Repo))
	branch := strings.TrimSpace(derefString(workspace.Branch))
	if hideDefaultBranch && isDefaultBranch(branch) {
		branch = ""
	}
	switch {
	case repo != "" && branch != "":
		return fmt.Sprintf("Repo: %s  Branch: %s", repo, branch)
	case repo != "":
		return fmt.Sprintf("Repo: %s", repo)
	case branch != "":
		return fmt.Sprintf("Branch: %s", branch)
	default:
		return ""
	}
}

func formatWorkspaceFocus(workspace aweb.WorkspaceInfo) string {
	focusRef := strings.TrimSpace(derefString(workspace.FocusTaskRef))
	if focusRef == "" {
		return "none"
	}
	if focusTitle := strings.TrimSpace(derefString(workspace.FocusTaskTitle)); focusTitle != "" {
		return fmt.Sprintf("%s \"%s\"", focusRef, focusTitle)
	}
	return focusRef
}

func formatWorkspaceApexLine(workspace aweb.WorkspaceInfo) string {
	apexID := strings.TrimSpace(derefString(workspace.ApexID))
	if apexID == "" {
		return ""
	}
	prefix := "Working on"
	if strings.EqualFold(derefString(workspace.ApexType), "epic") {
		prefix = "Epic"
	}
	if apexTitle := strings.TrimSpace(derefString(workspace.ApexTitle)); apexTitle != "" {
		return fmt.Sprintf("%s: %s (%s)", prefix, apexID, apexTitle)
	}
	return fmt.Sprintf("%s: %s", prefix, apexID)
}

func formatWorkspaceClaimsSummary(claims []aweb.WorkspaceClaim) string {
	if len(claims) == 0 {
		return "none"
	}
	parts := make([]string, 0, len(claims))
	for _, claim := range claims {
		part := strings.TrimSpace(claim.TaskRef)
		if part == "" {
			part = strings.TrimSpace(claim.BeadID)
		}
		if title := strings.TrimSpace(derefString(claim.Title)); title != "" {
			part += fmt.Sprintf(" \"%s\"", title)
		}
		if strings.TrimSpace(claim.ClaimedAt) != "" {
			part += fmt.Sprintf(" (%s)", formatTimeAgo(claim.ClaimedAt))
			if isClaimStale(claim.ClaimedAt) {
				part += " [stale]"
			}
		}
		parts = append(parts, part)
	}
	return strings.Join(parts, ", ")
}

func formatWorkspaceLocksSummary(locks []aweb.ReservationView, now time.Time, limit int) string {
	if len(locks) == 0 {
		return "none"
	}
	display := locks
	if limit > 0 && len(display) > limit {
		display = display[:limit]
	}
	parts := make([]string, 0, len(display)+1)
	for _, lock := range display {
		part := fmt.Sprintf("%s (TTL: %s", lock.ResourceKey, formatDuration(ttlRemainingSeconds(lock.ExpiresAt, now)))
		if reason := formatWorkspaceLockReason(lock.Metadata); reason != "" {
			part += fmt.Sprintf(", reason: %s", reason)
		}
		part += ")"
		parts = append(parts, part)
	}
	if limit > 0 && len(locks) > limit {
		parts = append(parts, fmt.Sprintf("...%d more", len(locks)-limit))
	}
	return strings.Join(parts, ", ")
}

func formatWorkspaceLockReason(metadata map[string]any) string {
	if len(metadata) == 0 {
		return ""
	}
	reason, _ := metadata["reason"].(string)
	return strings.TrimSpace(reason)
}

func derefString(v *string) string {
	if v == nil {
		return ""
	}
	return strings.TrimSpace(*v)
}

func abbreviateUserHome(path string) string {
	path = strings.TrimSpace(path)
	if path == "" {
		return ""
	}
	home, err := os.UserHomeDir()
	if err != nil {
		return path
	}
	home = filepath.Clean(home)
	path = filepath.Clean(path)
	if path == home {
		return "~"
	}
	prefix := home + string(filepath.Separator)
	if strings.HasPrefix(path, prefix) {
		return "~" + string(filepath.Separator) + strings.TrimPrefix(path, prefix)
	}
	return path
}
