package main

import (
	"context"
	"crypto/ed25519"
	"encoding/base64"
	"fmt"
	"net/http"
	"os"
	"path/filepath"
	"strings"
	"time"

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

// --- output types ---

type teamCreateOutput struct {
	Status      string `json:"status"`
	TeamID      string `json:"team_id"`
	TeamDIDKey  string `json:"team_did_key"`
	TeamKeyPath string `json:"team_key_path"`
	RegistryURL string `json:"registry_url,omitempty"`
}

type teamInviteOutput struct {
	Status   string `json:"status"`
	InviteID string `json:"invite_id"`
	Token    string `json:"token"`
}

type teamAcceptInviteOutput struct {
	Status   string `json:"status"`
	TeamID   string `json:"team_id"`
	Alias    string `json:"alias"`
	CertPath string `json:"cert_path"`
}

type teamAddMemberOutput struct {
	Status        string `json:"status"`
	Member        string `json:"member"`
	TeamID        string `json:"team_id"`
	MemberAddress string `json:"member_address,omitempty"`
	CertificateID string `json:"certificate_id"`
	FetchCommand  string `json:"fetch_command,omitempty"`
}

type teamFetchCertOutput struct {
	Status        string `json:"status"`
	TeamID        string `json:"team_id"`
	Alias         string `json:"alias"`
	CertificateID string `json:"certificate_id"`
	CertPath      string `json:"cert_path"`
}

type teamRemoveMemberOutput struct {
	Status        string `json:"status"`
	TeamID        string `json:"team_id"`
	MemberAddress string `json:"member_address"`
}

type teamImportRequestOutput struct {
	Status              string         `json:"status"`
	AWIDTeamID          string         `json:"awid_team_id"`
	DryRun              bool           `json:"dry_run"`
	AccessMode          string         `json:"access_mode"`
	Timestamp           string         `json:"timestamp"`
	ControllerDID       string         `json:"controller_did"`
	ControllerSignature string         `json:"controller_signature"`
	CanonicalPayload    string         `json:"canonical_payload"`
	RequestBody         map[string]any `json:"request_body"`
}

type teamAddOutput struct {
	Status   string `json:"status"`
	TeamID   string `json:"team_id"`
	Alias    string `json:"alias"`
	CertPath string `json:"cert_path"`
}

type teamSwitchOutput struct {
	Status     string `json:"status"`
	ActiveTeam string `json:"active_team"`
}

type teamListItem struct {
	TeamID        string `json:"team_id"`
	Alias         string `json:"alias"`
	IdentityScope string `json:"identity_scope,omitempty"`
	IssuedAt      string `json:"issued_at,omitempty"`
	Active        bool   `json:"active"`
}

type teamListOutput struct {
	ActiveTeam  string         `json:"active_team"`
	Memberships []teamListItem `json:"memberships"`
}

type teamLeaveOutput struct {
	Status     string `json:"status"`
	TeamID     string `json:"team_id"`
	ActiveTeam string `json:"active_team"`
}

type certShowOutput struct {
	TeamID        string `json:"team_id"`
	Alias         string `json:"alias"`
	MemberDIDKey  string `json:"member_did_key"`
	MemberDIDAW   string `json:"member_did_aw,omitempty"`
	MemberAddress string `json:"member_address,omitempty"`
	TeamDIDKey    string `json:"team_did_key"`
	IdentityScope string `json:"identity_scope"`
	IssuedAt      string `json:"issued_at"`
	CertificateID string `json:"certificate_id"`
}

type localTeamRegistration struct {
	TeamID      string
	TeamDIDKey  string
	TeamKey     ed25519.PrivateKey
	TeamKeyPath string
}

type localTeamBootstrapResult struct {
	TeamID      string
	TeamDIDKey  string
	TeamKeyPath string
	Certificate *awid.TeamCertificate
}

type acceptedTeamInvite struct {
	Output      *teamAcceptInviteOutput
	Certificate *awid.TeamCertificate
	RegistryURL string
	AwebURL     string
	Domain      string
	TeamName    string
}

// --- flags ---

var (
	teamCreateName        string
	teamCreateNamespace   string
	teamCreateDisplayName string
	teamCreateRegistryURL string

	teamInviteTeam       string
	teamInviteNamespace  string
	teamInviteEphemeral  bool
	teamInvitePersistent bool
	teamInviteLocal      bool
	teamInviteGlobal     bool

	teamAcceptAlias   string
	teamAcceptAddress string
	teamAddAlias      string
	teamAddAddress    string

	teamAddTeam           string
	teamAddNamespace      string
	teamAddMember         string
	teamAddMemberDID      string
	teamAddMemberAlias    string
	teamAddMemberLifetime string
	teamAddMemberLocal    bool
	teamAddMemberGlobal   bool
	teamAddMemberDIDAW    string
	teamAddMemberAddress  string

	teamFetchCertTeam      string
	teamFetchCertNamespace string
	teamFetchCertID        string
	teamFetchCertRegistry  string
	teamFetchCertForce     bool

	teamRemoveTeam        string
	teamRemoveNamespace   string
	teamRemoveMember      string
	teamRemoveRegistryURL string

	teamImportRequestTeam           string
	teamImportRequestNamespace      string
	teamImportRequestOrganizationID string
	teamImportRequestCloudTeamID    string
	teamImportRequestAccessMode     string
	teamImportRequestTimestamp      string
	teamImportRequestApply          bool
)

// --- commands ---

var teamCmd = &cobra.Command{
	Use:   "team",
	Short: "Team management (create, invite, membership)",
}

var teamCreateCmd = &cobra.Command{
	Use:   "create",
	Short: "Create a team at awid",
	RunE:  runTeamCreate,
}

var teamInviteCmd = &cobra.Command{
	Use:   "invite",
	Short: "Generate an invite token for a team",
	Long: "Generate an invite token for a team.\n\n" +
		"Defaults to the active local team when --team and --namespace are omitted.\n" +
		"Invites create local workspace members unless --global is set. Hosted teams use cloud\n" +
		"invite authority; local-controller teams use the local team controller key.",
	RunE: runTeamInvite,
}

var teamAcceptInviteCmd = &cobra.Command{
	Use:   "accept-invite <token>",
	Short: "Accept a team invite and receive a membership certificate",
	Long: "Accept a team invite and receive a membership certificate.\n\n" +
		"Hosted aw_inv_ tokens are redeemed through the cloud, generate a fresh local\n" +
		"identity, and refuse to overwrite an existing .aw identity in the target\n" +
		"directory. After accepting, run `aw init` in that directory to connect the\n" +
		"workspace.\n\n" +
		"Local-controller invite tokens are same-machine helpers: they require the\n" +
		"local invite record and local team controller key. For cross-machine BYOT\n" +
		"joins, use `aw id team request`, have the controller run\n" +
		"`aw id team add-member`, then install with `aw id team fetch-cert` on the\n" +
		"joining machine.",
	Args: cobra.ExactArgs(1),
	RunE: runTeamAcceptInvite,
}

var teamAddCmd = &cobra.Command{
	Use:   "add <invite-token>",
	Short: "Join another team with the current identity",
	Args:  cobra.ExactArgs(1),
	RunE:  runTeamAdd,
}

var teamSwitchCmd = &cobra.Command{
	Use:   "switch <team_id>",
	Short: "Switch the active team for this identity",
	Args:  cobra.ExactArgs(1),
	RunE:  runTeamSwitch,
}

var teamListCmd = &cobra.Command{
	Use:   "list",
	Short: "List team memberships for this identity",
	RunE:  runTeamList,
}

var teamLeaveCmd = &cobra.Command{
	Use:   "leave <team_id>",
	Short: "Remove a team membership from this identity",
	Args:  cobra.ExactArgs(1),
	RunE:  runTeamLeave,
}

var teamAddMemberCmd = &cobra.Command{
	Use:   "add-member",
	Short: "Add a member directly to a team (controller signs certificate)",
	RunE:  runTeamAddMember,
}

var teamFetchCertCmd = &cobra.Command{
	Use:   "fetch-cert",
	Short: "Fetch and install an approved team certificate",
	RunE:  runTeamFetchCert,
}

var teamRemoveMemberCmd = &cobra.Command{
	Use:   "remove-member",
	Short: "Remove a member from a team (revoke certificate)",
	RunE:  runTeamRemoveMember,
}

var teamImportRequestCmd = &cobra.Command{
	Use:   "import-request",
	Short: "Create a signed BYOT import request for aweb cloud",
	Long: "Create a signed BYOT import request for aweb cloud.\n\n" +
		"This command signs the canonical import payload with your local BYOT team\n" +
		"controller key. It prints the request body expected by\n" +
		"POST /api/v1/teams/byoidt/import. It never uploads or prints namespace or\n" +
		"team controller private keys. The cloud import endpoint accepts the signed\n" +
		"timestamp for five minutes; regenerate the request body after it expires.",
	RunE: runTeamImportRequest,
}

var certShowCmd = &cobra.Command{
	Use:   "show",
	Short: "Show the current team certificate",
	RunE:  runCertShow,
}

var certCmd = &cobra.Command{
	Use:   "cert",
	Short: "Team certificate operations",
}

func init() {
	teamCreateCmd.Flags().StringVar(&teamCreateName, "name", "", "Team name")
	teamCreateCmd.Flags().StringVar(&teamCreateNamespace, "namespace", "", "Namespace domain")
	teamCreateCmd.Flags().StringVar(&teamCreateDisplayName, "display-name", "", "Team display name")
	teamCreateCmd.Flags().StringVar(&teamCreateRegistryURL, "registry", "", "Registry origin override")
	teamCmd.AddCommand(teamCreateCmd)

	teamInviteCmd.Flags().StringVar(&teamInviteTeam, "team", "", "Team name")
	teamInviteCmd.Flags().StringVar(&teamInviteNamespace, "namespace", "", "Namespace domain")
	teamInviteCmd.Flags().BoolVar(&teamInviteLocal, "local", false, "Create local workspace member invite (default)")
	teamInviteCmd.Flags().BoolVar(&teamInviteGlobal, "global", false, "Create global member invite")
	teamInviteCmd.Flags().BoolVar(&teamInviteEphemeral, "ephemeral", false, "Compatibility alias for --local")
	teamInviteCmd.Flags().BoolVar(&teamInvitePersistent, "persistent", false, "Compatibility alias for --global")
	_ = teamInviteCmd.Flags().MarkHidden("ephemeral")
	_ = teamInviteCmd.Flags().MarkHidden("persistent")
	teamCmd.AddCommand(teamInviteCmd)

	teamAcceptInviteCmd.Flags().StringVar(&teamAcceptAlias, "alias", "", "Alias for the accepting agent (defaults to identity name)")
	teamAcceptInviteCmd.Flags().StringVar(&teamAcceptAddress, "address", "", "Registered address to place in the global member certificate")
	teamCmd.AddCommand(teamAcceptInviteCmd)

	teamAddCmd.Flags().StringVar(&teamAddAlias, "alias", "", "Alias for the added team membership (defaults to the current identity name)")
	teamAddCmd.Flags().StringVar(&teamAddAddress, "address", "", "Registered address to place in the global member certificate")
	teamCmd.AddCommand(teamAddCmd)
	teamCmd.AddCommand(teamSwitchCmd)
	teamCmd.AddCommand(teamListCmd)
	teamCmd.AddCommand(teamLeaveCmd)

	teamAddMemberCmd.Flags().StringVar(&teamAddTeam, "team", "", "Team name")
	teamAddMemberCmd.Flags().StringVar(&teamAddNamespace, "namespace", "", "Namespace domain")
	teamAddMemberCmd.Flags().StringVar(&teamAddMember, "member", "", "Member address (e.g. acme.com/alice)")
	teamAddMemberCmd.Flags().StringVar(&teamAddMemberDID, "did", "", "Member did:key for direct certificate issuance")
	teamAddMemberCmd.Flags().StringVar(&teamAddMemberAlias, "alias", "", "Alias to use with --did")
	teamAddMemberCmd.Flags().BoolVar(&teamAddMemberLocal, "local", false, "Issue a local workspace member certificate for --did (default)")
	teamAddMemberCmd.Flags().BoolVar(&teamAddMemberGlobal, "global", false, "Issue a global member certificate for --did")
	teamAddMemberCmd.Flags().StringVar(&teamAddMemberLifetime, "lifetime", awid.LifetimeEphemeral, "Compatibility alias: certificate storage lifetime for --did")
	_ = teamAddMemberCmd.Flags().MarkHidden("lifetime")
	teamAddMemberCmd.Flags().StringVar(&teamAddMemberDIDAW, "did-aw", "", "Optional stable did:aw when using --did")
	teamAddMemberCmd.Flags().StringVar(&teamAddMemberAddress, "address", "", "Global member address when using --did; must resolve to --did-aw")
	teamCmd.AddCommand(teamAddMemberCmd)

	teamFetchCertCmd.Flags().StringVar(&teamFetchCertTeam, "team", "", "Team name")
	teamFetchCertCmd.Flags().StringVar(&teamFetchCertNamespace, "namespace", "", "Namespace domain")
	teamFetchCertCmd.Flags().StringVar(&teamFetchCertID, "cert-id", "", "Certificate id")
	teamFetchCertCmd.Flags().StringVar(&teamFetchCertRegistry, "registry", "", "Registry origin override")
	teamFetchCertCmd.Flags().BoolVar(&teamFetchCertForce, "force", false, "Overwrite an existing local certificate for the team")
	teamCmd.AddCommand(teamFetchCertCmd)

	teamRemoveMemberCmd.Flags().StringVar(&teamRemoveTeam, "team", "", "Team name")
	teamRemoveMemberCmd.Flags().StringVar(&teamRemoveNamespace, "namespace", "", "Namespace domain")
	teamRemoveMemberCmd.Flags().StringVar(&teamRemoveMember, "member", "", "Member address (e.g. acme.com/alice)")
	teamRemoveMemberCmd.Flags().StringVar(&teamRemoveRegistryURL, "registry", "", "Registry origin override")
	teamCmd.AddCommand(teamRemoveMemberCmd)

	teamImportRequestCmd.Flags().StringVar(&teamImportRequestTeam, "team", "", "Team name")
	teamImportRequestCmd.Flags().StringVar(&teamImportRequestNamespace, "namespace", "", "Namespace domain")
	teamImportRequestCmd.Flags().StringVar(&teamImportRequestOrganizationID, "organization-id", "", "AC organization id for a new imported team")
	teamImportRequestCmd.Flags().StringVar(&teamImportRequestCloudTeamID, "cloud-team-id", "", "Existing AC team id to sync")
	teamImportRequestCmd.Flags().StringVar(&teamImportRequestAccessMode, "access-mode", "open", "Access mode for imported members")
	teamImportRequestCmd.Flags().StringVar(&teamImportRequestTimestamp, "timestamp", "", "RFC3339 timestamp to sign (defaults to now; accepted for five minutes by cloud)")
	teamImportRequestCmd.Flags().BoolVar(&teamImportRequestApply, "apply", false, "Create an apply request instead of the default dry-run request")
	teamCmd.AddCommand(teamImportRequestCmd)

	identityCmd.AddCommand(teamCmd)

	certCmd.AddCommand(certShowCmd)
	identityCmd.AddCommand(certCmd)
}

// --- implementations ---

func runTeamCreate(cmd *cobra.Command, args []string) error {
	name := strings.ToLower(strings.TrimSpace(teamCreateName))
	domain := awconfig.NormalizeDomain(teamCreateNamespace)
	if name == "" {
		return usageError("--name is required")
	}
	if domain == "" {
		return usageError("--namespace is required")
	}

	// Load namespace controller key for auth
	controllerKey, err := awconfig.LoadControllerKey(domain)
	if err != nil {
		return fmt.Errorf("load controller key for %s: %w (run `aw id create --domain %s` first)", domain, err, domain)
	}

	registry, err := newConfiguredRegistryClient(nil, "")
	if err != nil {
		return err
	}
	if strings.TrimSpace(teamCreateRegistryURL) != "" {
		if err := registry.SetFallbackRegistryURL(teamCreateRegistryURL); err != nil {
			return fmt.Errorf("invalid --registry: %w", err)
		}
	}

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

	registration, err := ensureLocalTeamRegistered(
		ctx,
		registry,
		strings.TrimSpace(registry.DefaultRegistryURL),
		domain,
		name,
		strings.TrimSpace(teamCreateDisplayName),
		controllerKey,
	)
	if err != nil {
		return err
	}
	printOutput(teamCreateOutput{
		Status:      "created",
		TeamID:      registration.TeamID,
		TeamDIDKey:  registration.TeamDIDKey,
		TeamKeyPath: registration.TeamKeyPath,
		RegistryURL: strings.TrimSpace(registry.DefaultRegistryURL),
	}, formatTeamCreate)
	return nil
}

func runTeamInvite(cmd *cobra.Command, args []string) error {
	workingDir, err := os.Getwd()
	if err != nil {
		return err
	}
	global := teamInviteGlobal || teamInvitePersistent
	local := teamInviteLocal || teamInviteEphemeral
	if global && local {
		return usageError("--global and --local cannot be used together")
	}
	team, domain, registryURL, awebURL, err := resolveTeamInviteTarget(workingDir)
	if err != nil {
		return err
	}

	localInvite := !global
	hasTeamKey, err := awconfig.TeamKeyExists(domain, team)
	if err != nil {
		return err
	}
	var inviteID, token string
	if hasTeamKey {
		inviteID, token, err = createTeamInviteToken(domain, team, registryURL, awebURL, localInvite)
		if err != nil {
			return err
		}
	} else {
		inviteID, token, err = createHostedTeamInviteToken(workingDir, awid.BuildTeamID(domain, team), localInvite)
		if err != nil {
			return err
		}
	}

	printOutput(teamInviteOutput{
		Status:   "created",
		InviteID: inviteID,
		Token:    token,
	}, formatTeamInvite)
	return nil
}

func resolveTeamInviteTarget(workingDir string) (team, domain, registryURL, awebURL string, err error) {
	team = strings.ToLower(strings.TrimSpace(teamInviteTeam))
	domain = awconfig.NormalizeDomain(teamInviteNamespace)

	rootDir := strings.TrimSpace(workingDir)
	if team == "" || domain == "" {
		teamState, loadedRoot, loadErr := loadTeamStateForInvite(workingDir)
		if loadErr != nil {
			missing := "--team and --namespace"
			if team != "" {
				missing = "--namespace"
			} else if domain != "" {
				missing = "--team"
			}
			return "", "", "", "", usageError("%s required when no active team can be inferred from this workspace: %v", missing, loadErr)
		}
		rootDir = loadedRoot
		activeDomain, activeTeam, parseErr := awid.ParseTeamID(strings.TrimSpace(teamState.ActiveTeam))
		if parseErr != nil {
			return "", "", "", "", fmt.Errorf("invalid active team %q: %w", teamState.ActiveTeam, parseErr)
		}
		if team == "" {
			team = activeTeam
		}
		if domain == "" {
			domain = activeDomain
		}
	}
	if team == "" {
		return "", "", "", "", usageError("--team is required")
	}
	if domain == "" {
		return "", "", "", "", usageError("--namespace is required")
	}
	awebURL = awebURLForTeamInvite(rootDir, awid.BuildTeamID(domain, team))
	registryURL = registryURLForTeamInvite(rootDir, domain, awebURL)
	return team, domain, registryURL, awebURL, nil
}

func loadTeamStateForInvite(workingDir string) (*awconfig.TeamState, string, error) {
	_, teamState, rootDir, err := awconfig.LoadWorkspaceAndTeamState(workingDir)
	if err == nil {
		return teamState, rootDir, nil
	}
	teamState, stateErr := awconfig.LoadTeamState(workingDir)
	if stateErr == nil {
		return teamState, workingDir, nil
	}
	return nil, "", err
}

func registryURLForTeamInvite(workingDir, domain, awebURL string) string {
	if meta, err := awconfig.LoadControllerMeta(domain); err == nil && meta != nil {
		if registryURL := strings.TrimSpace(meta.RegistryURL); registryURL != "" {
			return registryURL
		}
	}
	if identity, _, err := awconfig.LoadWorktreeIdentityFromDir(workingDir); err == nil && identity != nil {
		if registryURL := strings.TrimSpace(identity.RegistryURL); registryURL != "" {
			return registryURL
		}
	}
	if strings.TrimSpace(awebURL) != "" {
		if discovered, err := discoverOnboardingServiceURLs(awebURL); err == nil {
			return strings.TrimSpace(discovered.RegistryURL)
		}
	}
	return ""
}

func awebURLForTeamInvite(workingDir, teamID string) string {
	if workspace, teamState, _, err := awconfig.LoadWorkspaceAndTeamState(workingDir); err == nil && workspace != nil {
		if membership := workspace.Membership(teamID); membership != nil {
			if awebURL := strings.TrimSpace(workspace.AwebURL); awebURL != "" {
				return awebURL
			}
		}
		if awconfig.ActiveMembershipFor(workspace, teamState) != nil {
			return strings.TrimSpace(workspace.AwebURL)
		}
	}
	return ""
}

func runTeamAcceptInvite(cmd *cobra.Command, args []string) error {
	workingDir, err := os.Getwd()
	if err != nil {
		return err
	}
	accepted, err := acceptTeamInviteWithDetails(workingDir, args[0], teamAcceptAlias, teamAcceptAddress)
	if err != nil {
		return err
	}
	if err := upsertAcceptedTeamMembershipState(workingDir, accepted.Output, accepted.Certificate, accepted.RegistryURL, accepted.AwebURL, true); err != nil {
		return err
	}
	printOutput(*accepted.Output, formatTeamAcceptInvite)
	return nil
}

func runTeamAdd(cmd *cobra.Command, args []string) error {
	workingDir, err := os.Getwd()
	if err != nil {
		return err
	}
	teamState, err := requireTeamStateForMembership(workingDir)
	if err != nil {
		if !os.IsNotExist(err) {
			return err
		}
		teamState = &awconfig.TeamState{}
	}

	accepted, err := acceptTeamInviteWithDetails(workingDir, args[0], teamAddAlias, teamAddAddress)
	if err != nil {
		return err
	}
	if teamState.Membership(accepted.Output.TeamID) != nil {
		return rollbackAddedTeamCertificate(workingDir, accepted, usageError("team %q is already present in local team memberships", accepted.Output.TeamID))
	}
	if err := upsertAcceptedTeamMembershipState(workingDir, accepted.Output, accepted.Certificate, accepted.RegistryURL, accepted.AwebURL, false); err != nil {
		return rollbackAddedTeamCertificate(workingDir, accepted, err)
	}
	printOutput(teamAddOutput{
		Status:   "added",
		TeamID:   accepted.Output.TeamID,
		Alias:    accepted.Output.Alias,
		CertPath: accepted.Output.CertPath,
	}, formatTeamAdd)
	return nil
}

func runTeamSwitch(cmd *cobra.Command, args []string) error {
	workingDir, err := os.Getwd()
	if err != nil {
		return err
	}
	teamState, err := requireTeamStateForMembership(workingDir)
	if err != nil {
		return err
	}
	teamID := strings.TrimSpace(args[0])
	if teamState.Membership(teamID) == nil {
		return usageError("team %q is not present in local team memberships; available: %s", teamID, strings.Join(teamState.AvailableTeamIDs(), ", "))
	}
	if strings.EqualFold(strings.TrimSpace(teamState.ActiveTeam), teamID) {
		printOutput(teamSwitchOutput{
			Status:     "already_active",
			ActiveTeam: strings.TrimSpace(teamState.ActiveTeam),
		}, formatTeamSwitch)
		return nil
	}
	teamState.ActiveTeam = teamID
	if err := awconfig.SaveTeamState(workingDir, teamState); err != nil {
		return err
	}
	printOutput(teamSwitchOutput{
		Status:     "switched",
		ActiveTeam: teamState.ActiveTeam,
	}, formatTeamSwitch)
	return nil
}

func runTeamList(cmd *cobra.Command, args []string) error {
	workingDir, err := os.Getwd()
	if err != nil {
		return err
	}
	teamState, err := requireTeamStateForMembership(workingDir)
	if err != nil {
		return err
	}

	items := make([]teamListItem, 0, len(teamState.Memberships))
	for _, membership := range teamState.Memberships {
		item := teamListItem{
			TeamID: strings.TrimSpace(membership.TeamID),
			Alias:  strings.TrimSpace(membership.Alias),
			Active: strings.EqualFold(strings.TrimSpace(membership.TeamID), strings.TrimSpace(teamState.ActiveTeam)),
		}
		if cert, err := awconfig.LoadTeamCertificateForTeam(workingDir, membership.TeamID); err == nil && cert != nil {
			item.IdentityScope = awid.NormalizeIdentityScope(firstNonEmpty(cert.IdentityScope, cert.Lifetime))
			item.IssuedAt = strings.TrimSpace(cert.IssuedAt)
		}
		items = append(items, item)
	}
	printOutput(teamListOutput{
		ActiveTeam:  strings.TrimSpace(teamState.ActiveTeam),
		Memberships: items,
	}, formatTeamList)
	return nil
}

func runTeamLeave(cmd *cobra.Command, args []string) error {
	workingDir, err := os.Getwd()
	if err != nil {
		return err
	}
	teamState, err := requireTeamStateForMembership(workingDir)
	if err != nil {
		return err
	}
	teamID := strings.TrimSpace(args[0])
	if teamState.Membership(teamID) == nil {
		return usageError("team %q is not present in local team memberships; available: %s", teamID, strings.Join(teamState.AvailableTeamIDs(), ", "))
	}
	if len(teamState.Memberships) == 1 {
		return usageError("cannot leave the only team; remove the workspace instead")
	}

	teamState.RemoveMembership(teamID)
	if err := awconfig.SaveTeamState(workingDir, teamState); err != nil {
		return err
	}
	certPath := awconfig.TeamCertificatePath(workingDir, teamID)
	if err := os.Remove(certPath); err != nil && !os.IsNotExist(err) {
		return err
	}
	printOutput(teamLeaveOutput{
		Status:     "left",
		TeamID:     teamID,
		ActiveTeam: strings.TrimSpace(teamState.ActiveTeam),
	}, formatTeamLeave)
	return nil
}

func createTeamInviteToken(domain, team, registryURL, awebURL string, ephemeral bool) (string, string, error) {
	domain = awconfig.NormalizeDomain(domain)
	team = strings.ToLower(strings.TrimSpace(team))
	registryURL = strings.TrimSpace(registryURL)
	if domain == "" {
		return "", "", fmt.Errorf("domain is required")
	}
	if team == "" {
		return "", "", fmt.Errorf("team is required")
	}

	exists, err := awconfig.TeamKeyExists(domain, team)
	if err != nil {
		return "", "", err
	}
	if !exists {
		return "", "", usageError("no team key for %s/%s; run `aw id team create` first", domain, team)
	}

	inviteID, err := awid.GenerateUUID4()
	if err != nil {
		return "", "", err
	}
	secret, err := awconfig.GenerateInviteSecret()
	if err != nil {
		return "", "", err
	}
	invite := &awconfig.TeamInvite{
		InviteID:    inviteID,
		Domain:      domain,
		TeamName:    team,
		Ephemeral:   ephemeral,
		Secret:      secret,
		RegistryURL: registryURL,
		AwebURL:     awebURL,
		CreatedAt:   time.Now().UTC().Format(time.RFC3339),
	}
	if err := awconfig.SaveTeamInvite(invite); err != nil {
		return "", "", err
	}
	token, err := awconfig.EncodeInviteToken(invite)
	if err != nil {
		return "", "", err
	}
	return inviteID, token, nil
}

func createHostedTeamInviteToken(workingDir, teamID string, ephemeral bool) (string, string, error) {
	if !ephemeral {
		return "", "", usageError("--global is not supported for hosted team invites")
	}
	client, _, err := resolveClientSelectionForDirWithTeamOverride(workingDir, teamID)
	if err != nil {
		return "", "", fmt.Errorf(
			"no local team controller key for %s and cloud-hosted invite authority is unavailable: %w",
			teamID,
			err,
		)
	}
	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()
	created, err := client.CreateSpawnInvite(ctx, &awid.SpawnCreateInviteRequest{
		AccessMode: "open",
		MaxUses:    1,
	})
	if err != nil {
		return "", "", fmt.Errorf("create hosted team invite: %w", err)
	}
	if strings.TrimSpace(created.Token) == "" {
		return "", "", fmt.Errorf("hosted team invite response is missing token")
	}
	return strings.TrimSpace(created.InviteID), strings.TrimSpace(created.Token), nil
}

func acceptTeamInviteWithDetails(workingDir, token, aliasHint, addressOverride string) (*acceptedTeamInvite, error) {
	if awid.IsHostedSpawnInviteToken(token) {
		return acceptHostedTeamInviteWithDetails(workingDir, token, aliasHint, addressOverride)
	}

	decoded, err := awconfig.DecodeInviteToken(token)
	if err != nil {
		return nil, err
	}

	invite, err := awconfig.LoadTeamInvite(decoded.InviteID)
	if err != nil {
		return nil, fmt.Errorf("invite not found (token may be invalid or expired): %w", err)
	}
	if invite.Secret != decoded.Secret {
		return nil, fmt.Errorf("invalid invite token: secret mismatch")
	}

	teamID := awid.BuildTeamID(invite.Domain, invite.TeamName)

	teamKey, err := awconfig.LoadTeamKey(invite.Domain, invite.TeamName)
	if err != nil {
		return nil, fmt.Errorf("local team controller key for %s not found: %w (cross-machine joins should use `aw id team request`, controller-side `aw id team add-member`, then invitee-side `aw id team fetch-cert`)", teamID, err)
	}

	memberDIDKey, err := resolveOrGenerateMemberDIDKey(workingDir, invite.Ephemeral)
	if err != nil {
		return nil, err
	}

	alias := strings.TrimSpace(aliasHint)
	if alias == "" {
		alias = resolveAliasFromIdentity(workingDir)
	}
	if alias == "" {
		return nil, usageError("--alias is required (no identity found to derive alias from)")
	}

	lifetime := awid.LifetimePersistent
	if invite.Ephemeral {
		if strings.TrimSpace(addressOverride) != "" {
			return nil, usageError("--address is only valid for global invites")
		}
		lifetime = awid.LifetimeEphemeral
	}

	var memberDIDAW, memberAddress string
	if !invite.Ephemeral {
		memberDIDAW, memberAddress = resolveIdentityFieldsForCert(workingDir)
		if strings.TrimSpace(addressOverride) != "" {
			memberAddress = strings.TrimSpace(addressOverride)
		}
	}

	registry, err := newConfiguredRegistryClient(nil, "")
	if err != nil {
		return nil, err
	}
	registryURL := strings.TrimSpace(decoded.RegistryURL)
	if registryURL == "" {
		registryURL = strings.TrimSpace(registry.DefaultRegistryURL)
	}
	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()

	lookupSigningKey, err := loadOptionalWorktreeSigningKey(workingDir)
	if err != nil {
		return nil, err
	}
	if err := validateMemberAddressForCertificate(ctx, registry, registryURL, memberAddress, memberDIDAW, memberDIDKey, lookupSigningKey); err != nil {
		return nil, err
	}

	// Sign certificate
	cert, err := awid.SignTeamCertificate(teamKey, awid.TeamCertificateFields{
		Team:          teamID,
		MemberDIDKey:  memberDIDKey,
		MemberDIDAW:   memberDIDAW,
		MemberAddress: memberAddress,
		Alias:         alias,
		Lifetime:      lifetime,
	})
	if err != nil {
		return nil, err
	}

	if err := registry.RegisterCertificate(ctx, registryURL, invite.Domain, invite.TeamName, cert, teamKey); err != nil {
		return nil, fmt.Errorf("register certificate at registry: %w", err)
	}

	certPath, err := awconfig.SaveTeamCertificateForTeam(workingDir, teamID, cert)
	if err != nil {
		return nil, err
	}

	if err := awconfig.DeleteTeamInvite(invite.InviteID); err != nil {
		fmt.Fprintf(os.Stderr, "warning: failed to delete consumed invite %s: %v\n", invite.InviteID, err)
	}

	return &acceptedTeamInvite{
		Output: &teamAcceptInviteOutput{
			Status:   "accepted",
			TeamID:   teamID,
			Alias:    alias,
			CertPath: certPath,
		},
		Certificate: cert,
		RegistryURL: registryURL,
		AwebURL:     strings.TrimSpace(decoded.AwebURL),
		Domain:      invite.Domain,
		TeamName:    invite.TeamName,
	}, nil
}

func acceptHostedTeamInviteWithDetails(workingDir, token, aliasHint, addressOverride string) (*acceptedTeamInvite, error) {
	if strings.TrimSpace(addressOverride) != "" {
		return nil, usageError("--address is only valid for global invites")
	}
	if err := ensureConnectTargetClean(workingDir); err != nil {
		return nil, err
	}
	alias := strings.TrimSpace(aliasHint)
	if alias == "" {
		return nil, usageError("--alias is required for hosted team invites")
	}

	pub, signingKey, err := awid.GenerateKeypair()
	if err != nil {
		return nil, err
	}
	didKey := awid.ComputeDIDKey(pub)
	awebURL := strings.TrimSpace(resolveInitAwebURLOverride())
	if awebURL == "" {
		awebURL = DefaultAwebURL
	}
	awebURL, err = normalizeAwebBaseURL(awebURL)
	if err != nil {
		return nil, err
	}
	client, err := awid.NewWithIdentity(awebURL, signingKey, didKey)
	if err != nil {
		return nil, err
	}

	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()
	resp, err := client.AcceptSpawnInvite(ctx, &awid.SpawnAcceptInviteRequest{
		Token:     strings.TrimSpace(token),
		Alias:     alias,
		DID:       didKey,
		PublicKey: base64.StdEncoding.EncodeToString(pub),
		Custody:   awid.CustodySelf,
		Lifetime:  awid.LifetimeEphemeral,
	})
	if err != nil {
		return nil, fmt.Errorf("accept hosted team invite: %w", err)
	}
	cert, serverURL, err := validateHostedTeamInviteAcceptResponse(resp, didKey, alias)
	if err != nil {
		return nil, err
	}
	if err := persistLocalSigningKeyAndCertificate(workingDir, signingKey, cert); err != nil {
		return nil, err
	}

	return &acceptedTeamInvite{
		Output: &teamAcceptInviteOutput{
			Status:   "accepted",
			TeamID:   cert.Team,
			Alias:    strings.TrimSpace(cert.Alias),
			CertPath: awconfig.TeamCertificateRelativePath(cert.Team),
		},
		Certificate: cert,
		AwebURL:     serverURL,
		Domain:      strings.TrimSpace(resp.Namespace),
		TeamName:    strings.TrimSpace(resp.TeamSlug),
	}, nil
}

func validateHostedTeamInviteAcceptResponse(resp *awid.SpawnAcceptInviteResponse, didKey, requestedAlias string) (*awid.TeamCertificate, string, error) {
	if resp == nil {
		return nil, "", fmt.Errorf("missing hosted team invite response")
	}
	serverURL, err := normalizeBootstrapServerURL(strings.TrimSpace(resp.ServerURL))
	if err != nil {
		return nil, "", fmt.Errorf("invalid hosted team invite server_url: %w", err)
	}
	encodedCert := strings.TrimSpace(resp.TeamCert)
	if encodedCert == "" {
		return nil, "", fmt.Errorf("hosted team invite response is missing team_cert")
	}
	cert, err := awid.DecodeTeamCertificateHeader(encodedCert)
	if err != nil {
		return nil, "", fmt.Errorf("decode hosted team invite certificate: %w", err)
	}
	teamPub, err := awid.ExtractPublicKey(strings.TrimSpace(cert.TeamDIDKey))
	if err != nil {
		return nil, "", fmt.Errorf("hosted team invite certificate has invalid team_did_key %q: %w", cert.TeamDIDKey, err)
	}
	if err := awid.VerifyTeamCertificate(cert, teamPub); err != nil {
		return nil, "", fmt.Errorf("hosted team invite certificate signature verification failed: %w", err)
	}
	if strings.TrimSpace(cert.MemberDIDKey) != strings.TrimSpace(didKey) {
		return nil, "", fmt.Errorf("hosted team invite certificate member_did_key %q does not match generated did:key %q", cert.MemberDIDKey, didKey)
	}
	if strings.TrimSpace(cert.Alias) != strings.TrimSpace(requestedAlias) {
		return nil, "", fmt.Errorf("hosted team invite certificate alias %q does not match requested alias %q", cert.Alias, requestedAlias)
	}
	if awid.NormalizeIdentityScope(firstNonEmpty(cert.IdentityScope, cert.Lifetime)) != awid.IdentityModeLocal {
		return nil, "", fmt.Errorf("hosted team invite certificate identity_scope %q does not match %q", awid.NormalizeIdentityScope(firstNonEmpty(cert.IdentityScope, cert.Lifetime)), awid.IdentityModeLocal)
	}
	if strings.TrimSpace(cert.MemberDIDAW) != "" || strings.TrimSpace(cert.MemberAddress) != "" {
		return nil, "", fmt.Errorf("hosted local team invite certificate unexpectedly contains global identity fields")
	}
	return cert, serverURL, nil
}

func revokeAcceptedTeamCertificate(accepted *acceptedTeamInvite) error {
	if accepted == nil || accepted.Certificate == nil {
		return nil
	}

	registry, err := newConfiguredRegistryClient(nil, "")
	if err != nil {
		return err
	}
	registryURL := strings.TrimSpace(accepted.RegistryURL)
	if registryURL != "" {
		if err := registry.SetFallbackRegistryURL(registryURL); err != nil {
			return fmt.Errorf("invalid registry url %q: %w", registryURL, err)
		}
	} else {
		registryURL = strings.TrimSpace(registry.DefaultRegistryURL)
	}

	teamKey, err := awconfig.LoadTeamKey(accepted.Domain, accepted.TeamName)
	if err != nil {
		return fmt.Errorf("load team key for %s/%s: %w", accepted.Domain, accepted.TeamName, err)
	}

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

	if err := registry.RevokeCertificate(
		ctx,
		registryURL,
		accepted.Domain,
		accepted.TeamName,
		accepted.Certificate.CertificateID,
		teamKey,
	); err != nil {
		return fmt.Errorf("revoke certificate %s: %w", accepted.Certificate.CertificateID, err)
	}
	return nil
}

func isAwebHostedNamespace(domain string) bool {
	normalized := awconfig.NormalizeDomain(domain)
	return normalized == "aweb.ai" || strings.HasSuffix(normalized, ".aweb.ai")
}

func teamKeyLoadError(teamID, domain string, err error) error {
	if isAwebHostedNamespace(domain) {
		return fmt.Errorf(
			"local team controller key for %s was not found: %w\n\nThis looks like an aweb.ai hosted namespace. Hosted teams keep the team controller key in cloud, so this raw AWID command cannot sign the add-member operation locally. Use the hosted dashboard Add existing agent action for hosted teams. Use `aw id team add-member` only for BYOIDT/BYOD teams where you hold ~/.config/aw/team-keys/<namespace>/<team>.key",
			teamID,
			err,
		)
	}
	return fmt.Errorf(
		"local team controller key for %s was not found: %w (this command is for BYOIDT/BYOD teams where you hold ~/.config/aw/team-keys/<namespace>/<team>.key; hosted aweb.ai teams should use the dashboard Add existing agent action)",
		teamID,
		err,
	)
}

func resolveTeamAddMemberLifetime(cmd *cobra.Command) (string, error) {
	global := teamAddMemberGlobal
	local := teamAddMemberLocal
	if global && local {
		return "", usageError("--global and --local cannot be used together")
	}
	if global {
		return awid.LifetimePersistent, nil
	}
	if local {
		return awid.LifetimeEphemeral, nil
	}
	if cmd != nil && cmd.Flags().Changed("lifetime") {
		switch awid.NormalizeLifetime(teamAddMemberLifetime) {
		case awid.LifetimePersistent:
			return awid.LifetimePersistent, nil
		case awid.LifetimeEphemeral:
			return awid.LifetimeEphemeral, nil
		default:
			return "", usageError("invalid --lifetime %q (compatibility values: %q or %q; canonical flags: --global or --local)",
				teamAddMemberLifetime,
				awid.LifetimePersistent,
				awid.LifetimeEphemeral,
			)
		}
	}
	return awid.LifetimeEphemeral, nil
}

func runTeamAddMember(cmd *cobra.Command, args []string) error {
	team := strings.ToLower(strings.TrimSpace(teamAddTeam))
	domain := awconfig.NormalizeDomain(teamAddNamespace)
	member := strings.TrimSpace(teamAddMember)
	memberDID := strings.TrimSpace(teamAddMemberDID)
	memberAlias := strings.TrimSpace(teamAddMemberAlias)
	lifetime, err := resolveTeamAddMemberLifetime(cmd)
	if err != nil {
		return err
	}
	memberDIDAW := strings.TrimSpace(teamAddMemberDIDAW)
	memberAddress := strings.TrimSpace(teamAddMemberAddress)
	if team == "" {
		return usageError("--team is required")
	}
	if domain == "" {
		return usageError("--namespace is required")
	}
	if member == "" && memberDID == "" {
		return usageError("one of --member or --did is required")
	}
	if member != "" && memberDID != "" {
		return usageError("--member and --did are mutually exclusive")
	}
	if memberDID != "" {
		if memberAlias == "" {
			return usageError("--alias is required when using --did")
		}
		if memberAddress != "" {
			if lifetime != awid.LifetimePersistent {
				return usageError("--address requires --global when using --did")
			}
			if memberDIDAW == "" {
				return usageError("--did-aw is required when --address is set")
			}
		}
	}

	teamID := awid.BuildTeamID(domain, team)

	// Load team key
	teamKey, err := awconfig.LoadTeamKey(domain, team)
	if err != nil {
		return teamKeyLoadError(teamID, domain, err)
	}

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

	registry, err := newConfiguredRegistryClient(nil, "")
	if err != nil {
		return err
	}

	if member != "" {
		// Resolve member's did:key from their address via awid.
		memberDomain, memberName, err := parseAddress(member)
		if err != nil {
			return err
		}

		workingDir, err := os.Getwd()
		if err != nil {
			return err
		}
		lookupSigningKey, err := loadOptionalWorktreeSigningKey(workingDir)
		if err != nil {
			return err
		}
		var address *awid.RegistryAddress
		if lookupSigningKey != nil {
			address, _, err = registry.GetNamespaceAddressAtSigned(ctx, strings.TrimSpace(registry.DefaultRegistryURL), memberDomain, memberName, lookupSigningKey)
		} else {
			address, _, err = registry.GetNamespaceAddressAt(ctx, strings.TrimSpace(registry.DefaultRegistryURL), memberDomain, memberName)
		}
		if err != nil {
			return fmt.Errorf("resolve member address %s: %w", member, err)
		}

		memberDID = address.CurrentDIDKey
		memberDIDAW = address.DIDAW
		memberAddress = member
		memberAlias = memberName
		// Local/global flags only apply to the direct --did path; address-backed members are always global.
		lifetime = awid.LifetimePersistent
	}

	if member == "" && memberDID != "" && memberAddress != "" {
		workingDir, err := os.Getwd()
		if err != nil {
			return err
		}
		lookupSigningKey, err := loadOptionalWorktreeSigningKey(workingDir)
		if err != nil {
			return err
		}
		if err := validateMemberAddressForCertificate(ctx, registry, strings.TrimSpace(registry.DefaultRegistryURL), memberAddress, memberDIDAW, memberDID, lookupSigningKey); err != nil {
			return err
		}
	}

	cert, err := awid.SignTeamCertificate(teamKey, awid.TeamCertificateFields{
		Team:          teamID,
		MemberDIDKey:  memberDID,
		MemberDIDAW:   memberDIDAW,
		MemberAddress: memberAddress,
		Alias:         memberAlias,
		Lifetime:      lifetime,
	})
	if err != nil {
		return err
	}

	// Register at awid
	if err := registry.RegisterCertificate(ctx, strings.TrimSpace(registry.DefaultRegistryURL), domain, team, cert, teamKey); err != nil {
		return fmt.Errorf("register certificate: %w", err)
	}

	fetchCommand := teamFetchCertificateCommand(domain, team, cert.CertificateID, "")
	printOutput(teamAddMemberOutput{
		Status:        "added",
		Member:        firstNonEmpty(memberAddress, memberDID),
		TeamID:        teamID,
		MemberAddress: memberAddress,
		CertificateID: cert.CertificateID,
		FetchCommand:  fetchCommand,
	}, formatTeamAddMember)
	return nil
}

func runTeamFetchCert(cmd *cobra.Command, args []string) error {
	team := strings.ToLower(strings.TrimSpace(teamFetchCertTeam))
	domain := awconfig.NormalizeDomain(teamFetchCertNamespace)
	certificateID := strings.TrimSpace(teamFetchCertID)
	if team == "" {
		return usageError("--team is required")
	}
	if domain == "" {
		return usageError("--namespace is required")
	}
	if certificateID == "" {
		return usageError("--cert-id is required")
	}
	teamID := awid.BuildTeamID(domain, team)

	workingDir, err := os.Getwd()
	if err != nil {
		return err
	}
	certPathAbs := awconfig.TeamCertificatePath(workingDir, teamID)
	existingCert, existingErr := awconfig.LoadTeamCertificateForTeam(workingDir, teamID)
	if existingErr == nil && existingCert != nil {
		if strings.TrimSpace(existingCert.CertificateID) == certificateID {
			certPath := awconfig.TeamCertificateRelativePath(teamID)
			if err := upsertAcceptedTeamMembershipState(workingDir, &teamAcceptInviteOutput{
				Status:   "already_installed",
				TeamID:   teamID,
				Alias:    strings.TrimSpace(existingCert.Alias),
				CertPath: certPath,
			}, existingCert, "", "", false); err != nil {
				return err
			}
			printOutput(teamFetchCertOutput{
				Status:        "already_installed",
				TeamID:        teamID,
				Alias:         strings.TrimSpace(existingCert.Alias),
				CertificateID: strings.TrimSpace(existingCert.CertificateID),
				CertPath:      certPath,
			}, formatTeamFetchCert)
			return nil
		}
		if !teamFetchCertForce {
			return usageError("team certificate already exists at %s with certificate_id %s; use --force to overwrite", certPathAbs, existingCert.CertificateID)
		}
	} else if !os.IsNotExist(existingErr) {
		if !teamFetchCertForce {
			return fmt.Errorf("existing team certificate at %s could not be read: %w (use --force to overwrite)", certPathAbs, existingErr)
		}
	}
	signingKey, err := awid.LoadSigningKey(awconfig.WorktreeSigningKeyPath(workingDir))
	if err != nil {
		return fmt.Errorf("load local signing key: %w (run `aw id create` first on this machine)", err)
	}

	registry, err := newConfiguredRegistryClient(nil, "")
	if err != nil {
		return err
	}
	registryURL := strings.TrimSpace(teamFetchCertRegistry)
	if registryURL != "" {
		if err := registry.SetFallbackRegistryURL(registryURL); err != nil {
			return fmt.Errorf("invalid --registry: %w", err)
		}
	} else {
		registryURL = strings.TrimSpace(registry.DefaultRegistryURL)
	}

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

	cert, err := registry.FetchTeamCertificate(ctx, registryURL, domain, team, certificateID, signingKey)
	if err != nil {
		return fmt.Errorf("fetch certificate: %w", err)
	}
	if strings.TrimSpace(cert.Team) != teamID {
		return fmt.Errorf("fetched certificate team_id %q does not match requested %q", cert.Team, teamID)
	}

	certPath, err := awconfig.SaveTeamCertificateForTeam(workingDir, teamID, cert)
	if err != nil {
		return err
	}
	if err := upsertAcceptedTeamMembershipState(workingDir, &teamAcceptInviteOutput{
		Status:   "installed",
		TeamID:   teamID,
		Alias:    strings.TrimSpace(cert.Alias),
		CertPath: certPath,
	}, cert, registryURL, "", false); err != nil {
		return err
	}

	printOutput(teamFetchCertOutput{
		Status:        "installed",
		TeamID:        teamID,
		Alias:         strings.TrimSpace(cert.Alias),
		CertificateID: strings.TrimSpace(cert.CertificateID),
		CertPath:      certPath,
	}, formatTeamFetchCert)
	return nil
}

func runTeamRemoveMember(cmd *cobra.Command, args []string) error {
	team := strings.ToLower(strings.TrimSpace(teamRemoveTeam))
	domain := awconfig.NormalizeDomain(teamRemoveNamespace)
	member := strings.TrimSpace(teamRemoveMember)
	if team == "" {
		return usageError("--team is required")
	}
	if domain == "" {
		return usageError("--namespace is required")
	}
	if member == "" {
		return usageError("--member is required")
	}

	teamID := awid.BuildTeamID(domain, team)

	teamKey, err := awconfig.LoadTeamKey(domain, team)
	if err != nil {
		return fmt.Errorf("load team key for %s: %w", teamID, err)
	}

	registry, err := newConfiguredRegistryClient(nil, "")
	if err != nil {
		return err
	}

	// Resolve registry URL: explicit flag → identity.yaml → default.
	registryURL := strings.TrimSpace(teamRemoveRegistryURL)
	if registryURL == "" {
		if workingDir, wdErr := os.Getwd(); wdErr == nil {
			if identity, _, idErr := awconfig.LoadWorktreeIdentityFromDir(workingDir); idErr == nil && identity != nil {
				registryURL = strings.TrimSpace(identity.RegistryURL)
			}
		}
	}
	if registryURL == "" {
		registryURL = strings.TrimSpace(registry.DefaultRegistryURL)
	}

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

	_, memberName, err := parseAddress(member)
	if err != nil {
		return err
	}
	memberRef, err := registry.ResolveTeamMember(ctx, registryURL, domain, team, memberName)
	if err != nil {
		return fmt.Errorf("resolve team member %s in %s: %w", memberName, teamID, err)
	}

	if err := registry.RevokeCertificate(ctx, registryURL, domain, team, memberRef.CertificateID, teamKey); err != nil {
		return fmt.Errorf("revoke certificate: %w", err)
	}

	printOutput(teamRemoveMemberOutput{
		Status:        "removed",
		TeamID:        teamID,
		MemberAddress: member,
	}, formatTeamRemoveMember)
	return nil
}

func runTeamImportRequest(cmd *cobra.Command, args []string) error {
	team := strings.ToLower(strings.TrimSpace(teamImportRequestTeam))
	domain := awconfig.NormalizeDomain(teamImportRequestNamespace)
	organizationID := strings.TrimSpace(teamImportRequestOrganizationID)
	cloudTeamID := strings.TrimSpace(teamImportRequestCloudTeamID)
	accessMode := strings.TrimSpace(teamImportRequestAccessMode)
	timestamp := strings.TrimSpace(teamImportRequestTimestamp)
	if team == "" {
		return usageError("--team is required")
	}
	if domain == "" {
		return usageError("--namespace is required")
	}
	if isAwebHostedNamespace(domain) {
		return usageError("namespace %s is hosted by aweb.ai; use the hosted dashboard flow instead of BYOT import-request", domain)
	}
	if organizationID != "" && cloudTeamID != "" {
		return usageError("--organization-id and --cloud-team-id are mutually exclusive")
	}
	if accessMode == "" {
		accessMode = "open"
	}
	if timestamp == "" {
		timestamp = time.Now().UTC().Format(time.RFC3339)
	}

	teamID := awid.BuildTeamID(domain, team)
	teamKey, err := awconfig.LoadTeamKey(domain, team)
	if err != nil {
		return teamKeyLoadError(teamID, domain, err)
	}
	output, err := buildTeamImportRequestOutput(
		teamKey,
		teamID,
		organizationID,
		cloudTeamID,
		!teamImportRequestApply,
		accessMode,
		timestamp,
	)
	if err != nil {
		return err
	}
	printOutput(output, formatTeamImportRequest)
	return nil
}

func buildTeamImportRequestOutput(
	teamKey ed25519.PrivateKey,
	awidTeamID string,
	organizationID string,
	cloudTeamID string,
	dryRun bool,
	accessMode string,
	timestamp string,
) (teamImportRequestOutput, error) {
	payload := map[string]any{
		"operation":       "byoidt_import",
		"awid_team_id":    strings.TrimSpace(awidTeamID),
		"organization_id": strings.TrimSpace(organizationID),
		"team_id":         strings.TrimSpace(cloudTeamID),
		"dry_run":         dryRun,
		"access_mode":     strings.TrimSpace(accessMode),
		"timestamp":       strings.TrimSpace(timestamp),
	}
	canonical, err := awid.CanonicalJSONValue(payload)
	if err != nil {
		return teamImportRequestOutput{}, err
	}
	controllerDID := awid.ComputeDIDKey(teamKey.Public().(ed25519.PublicKey))
	signature := base64.RawStdEncoding.EncodeToString(ed25519.Sign(teamKey, []byte(canonical)))
	body := map[string]any{
		"awid_team_id":         strings.TrimSpace(awidTeamID),
		"organization_id":      nullableString(strings.TrimSpace(organizationID)),
		"team_id":              nullableString(strings.TrimSpace(cloudTeamID)),
		"dry_run":              dryRun,
		"access_mode":          strings.TrimSpace(accessMode),
		"timestamp":            strings.TrimSpace(timestamp),
		"controller_signature": signature,
	}
	return teamImportRequestOutput{
		Status:              "signed",
		AWIDTeamID:          strings.TrimSpace(awidTeamID),
		DryRun:              dryRun,
		AccessMode:          strings.TrimSpace(accessMode),
		Timestamp:           strings.TrimSpace(timestamp),
		ControllerDID:       controllerDID,
		ControllerSignature: signature,
		CanonicalPayload:    canonical,
		RequestBody:         body,
	}, nil
}

func nullableString(value string) any {
	if strings.TrimSpace(value) == "" {
		return nil
	}
	return strings.TrimSpace(value)
}

func runCertShow(cmd *cobra.Command, args []string) error {
	workingDir, err := os.Getwd()
	if err != nil {
		return err
	}
	cert, _, err := loadCurrentTeamCertificate(workingDir)
	if err != nil {
		return fmt.Errorf("load certificate: %w", err)
	}

	printOutput(certShowOutput{
		TeamID:        cert.Team,
		Alias:         cert.Alias,
		MemberDIDKey:  cert.MemberDIDKey,
		MemberDIDAW:   cert.MemberDIDAW,
		MemberAddress: cert.MemberAddress,
		TeamDIDKey:    cert.TeamDIDKey,
		IdentityScope: awid.NormalizeIdentityScope(firstNonEmpty(cert.IdentityScope, cert.Lifetime)),
		IssuedAt:      cert.IssuedAt,
		CertificateID: cert.CertificateID,
	}, formatCertShow)
	return nil
}

func loadCurrentTeamCertificate(workingDir string) (*awid.TeamCertificate, string, error) {
	if teamState, err := awconfig.LoadTeamState(workingDir); err == nil && teamState != nil {
		selectedTeamID := strings.TrimSpace(teamFlag)
		selectedMembership := teamState.Membership(selectedTeamID)
		if selectedMembership == nil {
			selectedMembership = teamState.ActiveMembership()
		}
		if selectedMembership == nil {
			return nil, "", fmt.Errorf("teams state is missing selected team membership")
		}
		certPath := filepath.Join(workingDir, ".aw", filepath.FromSlash(strings.TrimSpace(selectedMembership.CertPath)))
		cert, err := awid.LoadTeamCertificate(certPath)
		if err != nil {
			return nil, "", fmt.Errorf("load active team certificate %s: %w", certPath, err)
		}
		return cert, certPath, nil
	}

	stored, err := awconfig.ListTeamCertificates(workingDir)
	if err != nil {
		return nil, "", err
	}
	if len(stored) == 0 {
		return nil, "", fmt.Errorf("no team certificate found under %s", awconfig.TeamCertificatesDir(workingDir))
	}
	if len(stored) > 1 {
		return nil, "", fmt.Errorf("multiple team certificates found under %s; set an active team first", awconfig.TeamCertificatesDir(workingDir))
	}
	return stored[0].Certificate, filepath.Join(workingDir, ".aw", filepath.FromSlash(stored[0].CertPath)), nil
}

func requireTeamStateForMembership(workingDir string) (*awconfig.TeamState, error) {
	teamState, err := awconfig.LoadTeamState(workingDir)
	if err != nil {
		if os.IsNotExist(err) {
			return nil, usageError("current identity is missing .aw/teams.yaml; join a team first")
		}
		return nil, err
	}
	return teamState, nil
}

func rollbackAddedTeamCertificate(workingDir string, accepted *acceptedTeamInvite, cause error) error {
	revokeErr := revokeAcceptedTeamCertificate(accepted)
	if accepted != nil && accepted.Output != nil && strings.TrimSpace(accepted.Output.CertPath) != "" {
		_ = os.Remove(filepath.Join(workingDir, ".aw", filepath.FromSlash(strings.TrimSpace(accepted.Output.CertPath))))
	}
	if revokeErr != nil {
		return fmt.Errorf("%w (rollback revoke failed: %v)", cause, revokeErr)
	}
	return cause
}

func upsertAcceptedTeamMembershipState(workingDir string, output *teamAcceptInviteOutput, cert *awid.TeamCertificate, registryURL, awebURL string, setActive bool) error {
	if output == nil || cert == nil {
		return fmt.Errorf("accepted team membership is required")
	}
	teamState, err := loadOptionalTeamState(workingDir)
	if err != nil {
		return err
	}
	if teamState == nil {
		teamState = &awconfig.TeamState{}
	}
	if existing := teamState.Membership(strings.TrimSpace(output.TeamID)); existing != nil {
		if strings.TrimSpace(registryURL) == "" {
			registryURL = strings.TrimSpace(existing.RegistryURL)
		}
		if strings.TrimSpace(awebURL) == "" {
			awebURL = strings.TrimSpace(existing.AwebURL)
		}
	}
	teamState.AddMembership(awconfig.TeamMembership{
		TeamID:      strings.TrimSpace(output.TeamID),
		Alias:       strings.TrimSpace(output.Alias),
		CertPath:    filepath.ToSlash(strings.TrimSpace(output.CertPath)),
		JoinedAt:    strings.TrimSpace(cert.IssuedAt),
		RegistryURL: strings.TrimSpace(registryURL),
		AwebURL:     strings.TrimSpace(awebURL),
	})
	if setActive || strings.TrimSpace(teamState.ActiveTeam) == "" {
		teamState.ActiveTeam = strings.TrimSpace(output.TeamID)
	}
	return awconfig.SaveTeamState(workingDir, teamState)
}

func loadOptionalTeamState(workingDir string) (*awconfig.TeamState, error) {
	teamState, err := awconfig.LoadTeamState(workingDir)
	if err == nil {
		return teamState, nil
	}
	if os.IsNotExist(err) {
		return nil, nil
	}
	return nil, err
}

func upsertWorkspaceMembershipCache(workspace *awconfig.WorktreeWorkspace, membership awconfig.WorktreeMembership) {
	if workspace == nil {
		return
	}
	membership.TeamID = strings.TrimSpace(membership.TeamID)
	membership.Alias = strings.TrimSpace(membership.Alias)
	membership.RoleName = strings.TrimSpace(membership.RoleName)
	membership.WorkspaceID = strings.TrimSpace(membership.WorkspaceID)
	membership.CertPath = filepath.ToSlash(strings.TrimSpace(membership.CertPath))
	membership.JoinedAt = strings.TrimSpace(membership.JoinedAt)
	if membership.TeamID == "" {
		return
	}
	if existing := workspace.Membership(membership.TeamID); existing != nil {
		if strings.TrimSpace(existing.RoleName) != "" && strings.TrimSpace(membership.RoleName) == "" {
			membership.RoleName = strings.TrimSpace(existing.RoleName)
		}
		if strings.TrimSpace(existing.WorkspaceID) != "" && strings.TrimSpace(membership.WorkspaceID) == "" {
			membership.WorkspaceID = strings.TrimSpace(existing.WorkspaceID)
		}
		*existing = membership
		return
	}
	workspace.Memberships = append(workspace.Memberships, membership)
}

// --- helpers ---

func ensureLocalTeamRegistered(
	ctx context.Context,
	registry *awid.RegistryClient,
	registryURL, domain, name, displayName string,
	controllerKey ed25519.PrivateKey,
) (*localTeamRegistration, error) {
	domain = awconfig.NormalizeDomain(domain)
	name = strings.ToLower(strings.TrimSpace(name))
	registryURL = strings.TrimSpace(registryURL)
	if registry == nil {
		return nil, fmt.Errorf("registry client is required")
	}
	if domain == "" {
		return nil, fmt.Errorf("domain is required")
	}
	if name == "" {
		return nil, fmt.Errorf("team name is required")
	}
	if controllerKey == nil {
		return nil, fmt.Errorf("controller signing key is required")
	}
	if registryURL == "" {
		registryURL = strings.TrimSpace(registry.DefaultRegistryURL)
	}

	var teamPriv ed25519.PrivateKey
	exists, err := awconfig.TeamKeyExists(domain, name)
	if err != nil {
		return nil, err
	}
	if exists {
		teamPriv, err = awconfig.LoadTeamKey(domain, name)
		if err != nil {
			return nil, err
		}
	} else {
		_, teamPriv, err = awid.GenerateKeypair()
		if err != nil {
			return nil, err
		}
	}
	teamDIDKey := awid.ComputeDIDKey(teamPriv.Public().(ed25519.PublicKey))

	_, err = registry.CreateTeam(ctx, registryURL, domain, name, strings.TrimSpace(displayName), teamDIDKey, controllerKey)
	if err != nil {
		if code, ok := registryStatusCode(err); !ok || code != http.StatusConflict {
			return nil, fmt.Errorf("create team at registry: %w", err)
		}
		existingTeam, getErr := registry.GetTeam(ctx, registryURL, domain, name)
		if getErr != nil {
			return nil, fmt.Errorf("create team at registry: %w", err)
		}
		if strings.TrimSpace(existingTeam.TeamDIDKey) != teamDIDKey {
			return nil, fmt.Errorf("team %s/%s is already pinned to %s", domain, name, existingTeam.TeamDIDKey)
		}
	}

	if !exists {
		if err := awconfig.SaveTeamKey(domain, name, teamPriv); err != nil {
			return nil, err
		}
	}
	teamKeyPath, err := awconfig.TeamKeyPath(domain, name)
	if err != nil {
		return nil, err
	}
	return &localTeamRegistration{
		TeamID:      awid.BuildTeamID(domain, name),
		TeamDIDKey:  teamDIDKey,
		TeamKey:     teamPriv,
		TeamKeyPath: teamKeyPath,
	}, nil
}

func bootstrapFirstLocalTeamMember(
	ctx context.Context,
	registry *awid.RegistryClient,
	registryURL, domain, teamName, displayName string,
	controllerKey, memberKey ed25519.PrivateKey,
	memberDIDAW, memberAddress, alias string,
) (*localTeamBootstrapResult, error) {
	return bootstrapLocalTeamMemberWithLifetime(
		ctx,
		registry,
		registryURL,
		domain,
		teamName,
		displayName,
		controllerKey,
		memberKey,
		memberDIDAW,
		memberAddress,
		alias,
		awid.LifetimePersistent,
	)
}

func bootstrapLocalTeamMemberWithLifetime(
	ctx context.Context,
	registry *awid.RegistryClient,
	registryURL, domain, teamName, displayName string,
	controllerKey, memberKey ed25519.PrivateKey,
	memberDIDAW, memberAddress, alias, lifetime string,
) (*localTeamBootstrapResult, error) {
	if memberKey == nil {
		return nil, fmt.Errorf("member signing key is required")
	}
	resolvedRegistryURL := strings.TrimSpace(registryURL)
	if resolvedRegistryURL == "" && registry != nil {
		resolvedRegistryURL = strings.TrimSpace(registry.DefaultRegistryURL)
	}
	lifetime = strings.TrimSpace(lifetime)
	if lifetime == "" {
		lifetime = awid.LifetimePersistent
	}
	registration, err := ensureLocalTeamRegistered(ctx, registry, resolvedRegistryURL, domain, teamName, displayName, controllerKey)
	if err != nil {
		return nil, err
	}
	memberDIDKey := awid.ComputeDIDKey(memberKey.Public().(ed25519.PublicKey))
	cert, err := awid.SignTeamCertificate(registration.TeamKey, awid.TeamCertificateFields{
		Team:          registration.TeamID,
		MemberDIDKey:  memberDIDKey,
		MemberDIDAW:   strings.TrimSpace(memberDIDAW),
		MemberAddress: strings.TrimSpace(memberAddress),
		Alias:         strings.TrimSpace(alias),
		Lifetime:      strings.TrimSpace(lifetime),
	})
	if err != nil {
		return nil, err
	}
	if err := registry.RegisterCertificate(ctx, resolvedRegistryURL, awconfig.NormalizeDomain(domain), strings.ToLower(strings.TrimSpace(teamName)), cert, registration.TeamKey); err != nil {
		return nil, fmt.Errorf("register certificate at registry: %w", err)
	}
	return &localTeamBootstrapResult{
		TeamID:      registration.TeamID,
		TeamDIDKey:  registration.TeamDIDKey,
		TeamKeyPath: registration.TeamKeyPath,
		Certificate: cert,
	}, nil
}

func validateMemberAddressForCertificate(
	ctx context.Context,
	registry *awid.RegistryClient,
	registryURL, memberAddress, memberDIDAW, memberDIDKey string,
	signingKey ed25519.PrivateKey,
) error {
	memberAddress = strings.TrimSpace(memberAddress)
	if memberAddress == "" {
		return nil
	}
	memberDIDAW = strings.TrimSpace(memberDIDAW)
	memberDIDKey = strings.TrimSpace(memberDIDKey)
	if memberDIDAW == "" {
		return usageError("member address %q requires member_did_aw", memberAddress)
	}
	if memberDIDKey == "" {
		return usageError("member address %q requires member_did_key", memberAddress)
	}
	if registry == nil {
		return fmt.Errorf("registry client is required")
	}
	registryURL = strings.TrimSpace(registryURL)
	if registryURL == "" {
		registryURL = strings.TrimSpace(registry.DefaultRegistryURL)
	}
	domain, name, err := parseAddress(memberAddress)
	if err != nil {
		return err
	}
	var address *awid.RegistryAddress
	if signingKey != nil {
		address, _, err = registry.GetNamespaceAddressAtSigned(ctx, registryURL, domain, name, signingKey)
	} else {
		address, _, err = registry.GetNamespaceAddressAt(ctx, registryURL, domain, name)
	}
	if err != nil {
		return fmt.Errorf("validate member address %s: %w", memberAddress, err)
	}
	resolvedDIDAW := strings.TrimSpace(address.DIDAW)
	if resolvedDIDAW != memberDIDAW {
		return fmt.Errorf("member address %s belongs to %s, not %s", memberAddress, resolvedDIDAW, memberDIDAW)
	}
	resolvedDIDKey := strings.TrimSpace(address.CurrentDIDKey)
	if resolvedDIDKey == "" {
		return fmt.Errorf("member address %s registry record is missing current_did_key", memberAddress)
	}
	if resolvedDIDKey != memberDIDKey {
		return fmt.Errorf("member address %s resolves to did:key %s, not %s", memberAddress, resolvedDIDKey, memberDIDKey)
	}
	return nil
}

func resolveOrGenerateMemberDIDKey(workingDir string, ephemeral bool) (string, error) {
	// Try to load existing identity
	identityPath := filepath.Join(workingDir, awconfig.DefaultWorktreeIdentityRelativePath())
	identity, err := awconfig.LoadWorktreeIdentityFrom(identityPath)
	if err == nil && strings.TrimSpace(identity.DID) != "" {
		return strings.TrimSpace(identity.DID), nil
	}

	// Try to load existing signing key
	signingKeyPath := awconfig.WorktreeSigningKeyPath(workingDir)
	signingKey, err := awid.LoadSigningKey(signingKeyPath)
	if err == nil {
		return awid.ComputeDIDKey(signingKey.Public().(ed25519.PublicKey)), nil
	}

	if !ephemeral {
		return "", usageError("no identity found; run `aw id create` first, or use --local invite")
	}

	// Generate local keypair
	pub, priv, err := awid.GenerateKeypair()
	if err != nil {
		return "", err
	}
	if err := ensureAwebRuntimeGitIgnored(workingDir); err != nil {
		return "", err
	}
	if err := awid.SaveSigningKey(signingKeyPath, priv); err != nil {
		return "", err
	}
	return awid.ComputeDIDKey(pub), nil
}

// resolveIdentityFieldsForCert reads stable identity fields from .aw/identity.yaml.
// Returns empty strings for local agents that have no identity.yaml.
func resolveIdentityFieldsForCert(workingDir string) (didAW, address string) {
	identityPath := filepath.Join(workingDir, awconfig.DefaultWorktreeIdentityRelativePath())
	identity, err := awconfig.LoadWorktreeIdentityFrom(identityPath)
	if err != nil {
		return "", ""
	}
	return strings.TrimSpace(identity.StableID), strings.TrimSpace(identity.Address)
}

func resolveAliasFromIdentity(workingDir string) string {
	identityPath := filepath.Join(workingDir, awconfig.DefaultWorktreeIdentityRelativePath())
	identity, err := awconfig.LoadWorktreeIdentityFrom(identityPath)
	if err != nil || strings.TrimSpace(identity.Address) == "" {
		return ""
	}
	// Address is domain/name — extract name
	parts := strings.SplitN(identity.Address, "/", 2)
	if len(parts) == 2 {
		return parts[1]
	}
	return ""
}

func parseAddress(address string) (domain, name string, err error) {
	parts := strings.SplitN(strings.TrimSpace(address), "/", 2)
	if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
		return "", "", usageError("invalid address %q (expected domain/name)", address)
	}
	return awconfig.NormalizeDomain(parts[0]), strings.ToLower(strings.TrimSpace(parts[1])), nil
}

func teamFetchCertificateCommand(domain, team, certificateID, registryURL string) string {
	parts := []string{
		"aw", "id", "team", "fetch-cert",
		"--namespace", awconfig.NormalizeDomain(domain),
		"--team", strings.ToLower(strings.TrimSpace(team)),
		"--cert-id", strings.TrimSpace(certificateID),
	}
	if strings.TrimSpace(registryURL) != "" {
		parts = append(parts, "--registry", strings.TrimSpace(registryURL))
	}
	return strings.Join(parts, " ")
}
