package app

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

	"github.com/tidbcloud/AgentTeam/internal/core/domain"
	"github.com/tidbcloud/AgentTeam/internal/coredb"
	"github.com/tidbcloud/AgentTeam/internal/store/access"
	"github.com/tidbcloud/AgentTeam/internal/store/app/installation"
	"github.com/tidbcloud/AgentTeam/internal/store/app/registry"
	"github.com/tidbcloud/AgentTeam/internal/store/coordinator"
	"github.com/tidbcloud/AgentTeam/internal/store/message"
	"github.com/tidbcloud/AgentTeam/internal/store/rewind"
	"github.com/tidbcloud/AgentTeam/internal/store/serviceactor"
)

// channelArchivedForMember reads a member's personal channel archive state.
func channelArchivedForMember(ctx context.Context, queryer coredb.QueryRower, memberID string, channelID string) (bool, error) {
	row := queryer.QueryRow(ctx, `
SELECT archived_at IS NOT NULL
FROM scope_memberships
WHERE scope_id = $1
  AND member_id = $2
  AND visibility = 'public'
  AND removed_at IS NULL
  AND effective_to_seq IS NULL
`, channelID, memberID)

	var archived bool
	if err := row.Scan(&archived); err != nil {
		if coredb.IsNotFound(err) {
			return false, nil
		}
		return false, fmt.Errorf("load channel archived state: %w", err)
	}
	return archived, nil
}

func channelPinnedAtForMember(ctx context.Context, queryer coredb.QueryRower, memberID string, channelID string) (*time.Time, error) {
	row := queryer.QueryRow(ctx, `
SELECT pinned_at
FROM conversation_member_states
WHERE scope_id = $1
  AND member_id = $2
`, channelID, memberID)

	var pinnedAt *time.Time
	if err := row.Scan(&pinnedAt); err != nil {
		if coredb.IsNotFound(err) {
			return nil, nil
		}
		return nil, fmt.Errorf("load channel pinned state: %w", err)
	}
	return pinnedAt, nil
}

func normalizeChannelProfileVisibility(raw string) (string, error) {
	visibility := strings.ToLower(strings.TrimSpace(raw))
	switch visibility {
	case channelAgentEntryVisibilityPrivate, channelAgentEntryVisibilityPublic:
		return visibility, nil
	default:
		return "", badRequest("visibility must be public or private")
	}
}

func (store *Store) UpdateChannelForUser(ctx context.Context, userID string, workspaceID string, channelID string, name *string, description *string, visibility *string) (message.ChannelSummary, error) {
	membership, err := store.requireWorkspaceAccess(ctx, userID, workspaceID)
	if err != nil {
		return message.ChannelSummary{}, err
	}
	var summary message.ChannelSummary
	if err := coredb.WithTx(ctx, store.db, func(tx coredb.Tx) error {
		updated, _, updateErr := updateChannelProfileInTx(ctx, store, tx, workspaceID, channelID, userID, BrowserChannelUpdate, nil, name, description, visibility)
		if updateErr != nil {
			return updateErr
		}
		summary = updated
		return nil
	}); err != nil {
		return message.ChannelSummary{}, err
	}
	archived, err := channelArchivedForMember(ctx, store.db, membership.Member.ID, channelID)
	if err != nil {
		return message.ChannelSummary{}, err
	}
	pinnedAt, err := channelPinnedAtForMember(ctx, store.db, membership.Member.ID, channelID)
	if err != nil {
		return message.ChannelSummary{}, err
	}
	summary.Archived = archived
	summary.PinnedAt = pinnedAt
	return summary, nil
}

// ChannelUpdatePolicy selects the carrier/guard rules the canonical update
// primitive enforces AFTER locking the profile row, so carrier/owner cannot
// drift between a caller's precheck and the UPDATE (no TOCTOU).
type ChannelUpdatePolicy int

const (
	// BrowserChannelUpdate preserves the browser update contract: plain IM and
	// product carriers (tutorial/KB/project) may be updated with their existing
	// guards (#all cannot be renamed or change visibility; tutorial cannot be
	// renamed). Team channels are rejected via the manage-members authority.
	BrowserChannelUpdate ChannelUpdatePolicy = iota
	// ManagedIMExecUpdate is the Agent Exec V1 policy: only plain IM channels
	// owned by the resolved Human owner; #all, team, KB/project/tutorial and any
	// non-IM carrier are rejected after the lock.
	ManagedIMExecUpdate
)

// ChannelMemberExecReference identifies one member target for an Agent Exec
// batch member mutation. Exactly one of UserID / AgentID must be set.
type ChannelMemberExecReference struct {
	UserID  string
	AgentID string
}

// ChannelMemberAuthorityPolicy selects the authority rule enforced AFTER the
// channel scope is locked. Browser uses CanInvite (any joined member);
// Agent Exec uses IsOwner + IM-only carrier.
type ChannelMemberAuthorityPolicy int

const (
	// BrowserInviteAuthority preserves the browser invite contract: any joined
	// non-team member with CanInvite may add/remove members.
	BrowserInviteAuthority ChannelMemberAuthorityPolicy = iota
	// ManagedOwnerAuthority is the Agent Exec policy: only the channel owner,
	// and only plain IM channels.
	ManagedOwnerAuthority
)

// PreProfileLockHookForTest is a test-only seam invoked after the channel
// access precheck and before the profile row is locked, so deterministic race
// tests can prove a caller reached the lock point and block it there. It is nil
// in production.
var PreProfileLockHookForTest func(context.Context)

// preChannelMemberProfileLockHookForTest is a deterministic test seam between
// the active scope lock and the channel profile lock. Production leaves it nil.
var preChannelMemberProfileLockHookForTest func(context.Context)

// preChannelOwnershipUpdateHookForTest is a deterministic test seam after the
// legacy ownership-transfer precheck and before its compare-and-set UPDATE.
var preChannelOwnershipUpdateHookForTest func(context.Context)

// preChannelMemberCleanupUpdateHookForTest is a deterministic test seam fired
// inside the canonical cleanup helpers AFTER the capture SELECT and BEFORE the
// retirement UPDATE, so race tests can inject a concurrent child-membership
// write precisely between capture and UPDATE. Production leaves it nil.
var preChannelMemberCleanupUpdateHookForTest func(context.Context)

// updateChannelProfileInTx is the single canonical channel profile update, a
// package-level primitive re-exposed as UpdateChannelProfileInTx by
// ChannelManagementPort (it is deliberately NOT a *Store method, to keep the
// app-ecosystem root Store method ceiling). It locks the profile row
// and then re-validates actor ownership, channel type, carrier (team/subsystem,
// #all, tutorial) and (when expectedVersion != nil) version from the locked
// snapshot, applying policy before computing the patch and UPDATE. expectedVersion
// == nil skips the CAS fence (browser legacy path). The returned summary carries
// the updated core fields plus access-derived display fields and the new version;
// callers enrich it with their own read-specific data (archived/pinned).
func updateChannelProfileInTx(ctx context.Context, store *Store, tx coredb.Tx, workspaceID string, channelID string, actorUserID string, policy ChannelUpdatePolicy, expectedVersion *int64, name *string, description *string, visibility *string) (message.ChannelSummary, int64, error) {
	locked, err := loadLockedChannelMemberAuthority(ctx, tx, actorUserID, workspaceID, channelID)
	if err != nil {
		return message.ChannelSummary{}, 0, err
	}
	loaded := locked.Access
	if !loaded.Joined {
		return message.ChannelSummary{}, 0, ErrNotFound
	}
	if loaded.Channel.Type != "channel" {
		return message.ChannelSummary{}, 0, badRequest("thread channels cannot be updated")
	}
	slug := loaded.Channel.Slug
	displayName := loaded.Channel.DisplayName
	currentDescription := loaded.Channel.Description
	currentVisibility := loaded.Channel.Visibility
	subsystem := loaded.Channel.Subsystem
	version := locked.ProfileVersion
	isAll := strings.TrimSpace(slug) == "all"
	isTutorial := subsystem == message.ChannelSubsystemTutorial
	isActiveTeam := strings.TrimSpace(loaded.Channel.TeamID) != ""
	// Active team channels are rejected by policy BEFORE the owner check so the
	// team fence is exercised (not masked by an incidental owner mismatch). The
	// locked owner_member_id is the sole owner determination.
	if isActiveTeam {
		if policy == BrowserChannelUpdate {
			return message.ChannelSummary{}, 0, ErrForbidden
		}
		return message.ChannelSummary{}, 0, ErrNotFound
	}
	if policy == ManagedIMExecUpdate {
		if isAll {
			return message.ChannelSummary{}, 0, badRequest("the #all channel cannot be updated")
		}
		if strings.TrimSpace(subsystem) != "im" {
			return message.ChannelSummary{}, 0, ErrNotFound
		}
	}
	if !loaded.IsOwner {
		return message.ChannelSummary{}, 0, ErrForbidden
	}
	if expectedVersion != nil && version != *expectedVersion {
		return message.ChannelSummary{}, 0, conflict("channel version changed before update")
	}
	nextDisplayName := displayName
	if name != nil {
		trimmedName := strings.TrimSpace(*name)
		if trimmedName == "" {
			return message.ChannelSummary{}, 0, badRequest("channel name is required")
		}
		if isTutorial {
			return message.ChannelSummary{}, 0, badRequest("the Quick Start channel cannot be renamed")
		}
		if isAll {
			if trimmedName != displayName && trimmedName != slug {
				return message.ChannelSummary{}, 0, badRequest("The #all channel cannot be renamed")
			}
			// #all: input matches the current name or slug -> keep the original
			// display name (no-op), preserving legacy browser behavior.
		} else {
			nextDisplayName = trimmedName
		}
	}
	nextDescription := currentDescription
	if description != nil {
		nextDescription = strings.TrimSpace(*description)
	}
	nextVisibility := strings.TrimSpace(currentVisibility)
	if nextVisibility == "" {
		nextVisibility = channelAgentEntryVisibilityPrivate
	}
	if visibility != nil {
		nextVisibility, err = normalizeChannelProfileVisibility(*visibility)
		if err != nil {
			return message.ChannelSummary{}, 0, err
		}
		if isAll && nextVisibility != strings.TrimSpace(currentVisibility) {
			return message.ChannelSummary{}, 0, badRequest("The #all channel visibility cannot be changed")
		}
	}
	if expectedVersion != nil {
		result, execErr := tx.Exec(ctx, `
UPDATE scope_channel_profiles
SET display_name = $3,
    description = $4,
    visibility = $5,
    version = version + 1,
    updated_at = NOW()
WHERE workspace_id = $1 AND scope_id = $2 AND version = $6
`, workspaceID, channelID, nextDisplayName, nextDescription, nextVisibility, *expectedVersion)
		if execErr != nil {
			return message.ChannelSummary{}, 0, fmt.Errorf("update channel profile: %w", execErr)
		}
		if result.RowsAffected() == 0 {
			return message.ChannelSummary{}, 0, conflict("channel version changed before update")
		}
	} else {
		if _, execErr := tx.Exec(ctx, `
UPDATE scope_channel_profiles
SET display_name = $3,
    description = $4,
    visibility = $5,
    version = version + 1,
    updated_at = NOW()
WHERE workspace_id = $1 AND scope_id = $2
`, workspaceID, channelID, nextDisplayName, nextDescription, nextVisibility); execErr != nil {
			return message.ChannelSummary{}, 0, fmt.Errorf("update channel profile: %w", execErr)
		}
	}
	return message.ChannelSummary{
		ChannelID:   channelID,
		Slug:        slug,
		DisplayName: nextDisplayName,
		Description: nextDescription,
		Visibility:  nextVisibility,
		Tutorial:    isTutorial,
		Joined:      loaded.Joined,
		CanInvite:   loaded.CanInvite,
		CanManage:   loaded.IsOwner,
	}, version + 1, nil
}

func (store *Store) ArchiveChannelForUser(ctx context.Context, userID string, workspaceID string, channelID string) (message.ChannelSummary, error) {
	membership, err := store.requireWorkspaceAccess(ctx, userID, workspaceID)
	if err != nil {
		return message.ChannelSummary{}, err
	}

	channel, err := store.channelByID(ctx, store.db, workspaceID, channelID)
	if err != nil {
		return message.ChannelSummary{}, err
	}
	if channel.Type != "channel" {
		return message.ChannelSummary{}, badRequest("channel cannot be archived")
	}
	if channel.Slug == "all" {
		return message.ChannelSummary{}, badRequest("cannot archive #all")
	}
	if strings.TrimSpace(channel.TeamID) != "" {
		return message.ChannelSummary{}, badRequest("agent team channels are managed by the team")
	}

	joined, err := store.isMemberInChannel(ctx, store.db, membership.Member.ID, channelID)
	if err != nil {
		return message.ChannelSummary{}, fmt.Errorf("archive channel: %w", err)
	}
	if !joined {
		return message.ChannelSummary{}, badRequest("channel must be joined to archive")
	}
	now := time.Now().UTC()
	if err := store.setScopeParticipantArchivedState(ctx, store.db, channelID, membership.Member.ID, &now); err != nil {
		return message.ChannelSummary{}, err
	}
	if _, err := store.db.Exec(ctx, `
DELETE FROM scope_notification_settings
WHERE scope_id = $1 AND member_id = $2
`, channelID, membership.Member.ID); err != nil {
		return message.ChannelSummary{}, fmt.Errorf("archive channel notification setting: %w", err)
	}

	channelAccess, err := store.loadAuthorizedHumanChannelAccess(ctx, store.db, userID, workspaceID, channelID, channelReadAction)
	if err != nil {
		return message.ChannelSummary{}, err
	}
	pinnedAt, err := channelPinnedAtForMember(ctx, store.db, membership.Member.ID, channelID)
	if err != nil {
		return message.ChannelSummary{}, err
	}

	return message.ChannelSummary{
		ChannelID:   channel.ChannelID,
		Slug:        channel.Slug,
		DisplayName: channel.DisplayName,
		Description: channel.Description,
		Visibility:  channel.Visibility,
		Tutorial:    channel.Subsystem == message.ChannelSubsystemTutorial,
		Joined:      true,
		CanInvite:   channelAccess.CanInvite,
		CanManage:   channelAccess.IsOwner,
		Archived:    true,
		PinnedAt:    pinnedAt,
	}, nil
}

func (store *Store) UnarchiveChannelForUser(ctx context.Context, userID string, workspaceID string, channelID string) (message.ChannelSummary, error) {
	membership, err := store.requireWorkspaceAccess(ctx, userID, workspaceID)
	if err != nil {
		return message.ChannelSummary{}, err
	}

	channel, err := store.channelByID(ctx, store.db, workspaceID, channelID)
	if err != nil {
		return message.ChannelSummary{}, err
	}
	if channel.Type != "channel" {
		return message.ChannelSummary{}, badRequest("channel cannot be unarchived")
	}
	if channel.Slug == "all" {
		return message.ChannelSummary{}, badRequest("cannot unarchive #all")
	}
	if strings.TrimSpace(channel.TeamID) != "" {
		return message.ChannelSummary{}, badRequest("agent team channels are managed by the team")
	}

	joined, err := store.isMemberInChannel(ctx, store.db, membership.Member.ID, channelID)
	if err != nil {
		return message.ChannelSummary{}, fmt.Errorf("unarchive channel: %w", err)
	}
	if !joined {
		return message.ChannelSummary{}, badRequest("channel must be joined to unarchive")
	}
	now := time.Now().UTC()
	if err := store.ensureChannelScopeParticipantProjection(ctx, store.db, channelID, membership.Member.ID, "no-wake", now); err != nil {
		return message.ChannelSummary{}, err
	}
	if err := store.setScopeParticipantArchivedState(ctx, store.db, channelID, membership.Member.ID, nil); err != nil {
		return message.ChannelSummary{}, err
	}

	channelAccess, err := store.loadAuthorizedHumanChannelAccess(ctx, store.db, userID, workspaceID, channelID, channelReadAction)
	if err != nil {
		return message.ChannelSummary{}, err
	}
	pinnedAt, err := channelPinnedAtForMember(ctx, store.db, membership.Member.ID, channelID)
	if err != nil {
		return message.ChannelSummary{}, err
	}

	return message.ChannelSummary{
		ChannelID:   channel.ChannelID,
		Slug:        channel.Slug,
		DisplayName: channel.DisplayName,
		Description: channel.Description,
		Visibility:  channel.Visibility,
		Tutorial:    channel.Subsystem == message.ChannelSubsystemTutorial,
		Joined:      true,
		CanInvite:   channelAccess.CanInvite,
		CanManage:   channelAccess.IsOwner,
		Archived:    false,
		PinnedAt:    pinnedAt,
	}, nil
}

func (store *Store) DeleteChannelOrDMForUser(ctx context.Context, userID string, workspaceID string, channelID string) error {
	channelAccess, err := store.requireChannelMembershipManagement(ctx, userID, workspaceID, channelID)
	switch {
	case err == nil:
		if channelAccess.Channel.Type == "thread" {
			return badRequest("thread channels cannot be deleted")
		}
		if channelAccess.Channel.Slug == "all" {
			return badRequest("channel all is reserved")
		}
		return coredb.WithTx(ctx, store.db, func(tx coredb.Tx) error {
			if err := deleteReplyThreadChannelsForParentScope(ctx, tx, workspaceID, channelAccess.Channel.ChannelID); err != nil {
				return err
			}
			return store.archiveScopeProjection(ctx, tx, channelID, time.Now().UTC())
		})
	case !errors.Is(err, ErrNotFound):
		return err
	}

	dmAccess, err := store.loadAuthorizedHumanDMAccess(ctx, store.db, userID, workspaceID, channelID, dmReadAction)
	if err != nil {
		return err
	}

	return coredb.WithTx(ctx, store.db, func(tx coredb.Tx) error {
		if err := deleteReplyThreadChannelsForParentScope(ctx, tx, workspaceID, dmAccess.DMThreadID); err != nil {
			return err
		}
		return store.archiveScopeProjection(ctx, tx, dmAccess.DMThreadID, time.Now().UTC())
	})
}

func deleteReplyThreadChannelsForParentScope(ctx context.Context, tx coredb.Tx, workspaceID string, parentScopeID string) error {
	trimmedWorkspaceID := strings.TrimSpace(workspaceID)
	trimmedParentScopeID := strings.TrimSpace(parentScopeID)
	if trimmedWorkspaceID == "" || trimmedParentScopeID == "" {
		return nil
	}

	rows, err := tx.Query(ctx, `
SELECT child.id
FROM scopes parent
JOIN scopes child
  ON child.workspace_id = parent.workspace_id
 AND child.parent_scope_id = parent.id
 AND child.type = 'thread'
WHERE parent.workspace_id = $1
  AND parent.id = $2
  AND parent.status = 'active'
ORDER BY child.id ASC
`, trimmedWorkspaceID, trimmedParentScopeID)
	if err != nil {
		return fmt.Errorf("list child reply thread scopes: %w", err)
	}
	defer rows.Close()

	targets := make([]rewind.CascadeReplyThread, 0)
	for rows.Next() {
		var threadScopeID string
		if err := rows.Scan(&threadScopeID); err != nil {
			return fmt.Errorf("scan child reply thread scope: %w", err)
		}
		threadScopeID = strings.TrimSpace(threadScopeID)
		targets = append(targets, rewind.CascadeReplyThread{ScopeID: threadScopeID})
	}
	if err := rows.Err(); err != nil {
		return fmt.Errorf("iterate child reply thread scopes: %w", err)
	}
	if len(targets) == 0 {
		return nil
	}

	threadScopeIDs := make([]string, 0, len(targets))
	for i := range targets {
		threadScopeIDs = append(threadScopeIDs, targets[i].ScopeID)
	}
	messagesByScopeID, err := loadReplyThreadChannelMessageIDsForDelete(ctx, tx, trimmedWorkspaceID, threadScopeIDs)
	if err != nil {
		return err
	}

	for i := range targets {
		targets[i].MessageIDs = messagesByScopeID[targets[i].ScopeID]
		if err := rewind.DeleteCascadeReplyThread(ctx, tx, trimmedWorkspaceID, targets[i]); err != nil {
			return err
		}
	}
	return nil
}

func loadReplyThreadChannelMessageIDsForDelete(ctx context.Context, queryer interface {
	Query(context.Context, string, ...any) (coredb.Rows, error)
}, workspaceID string, threadScopeIDs []string) (map[string][]string, error) {
	trimmedWorkspaceID := strings.TrimSpace(workspaceID)
	normalizedThreadScopeIDs := make([]string, 0, len(threadScopeIDs))
	messagesByScopeID := make(map[string][]string, len(threadScopeIDs))
	seen := make(map[string]struct{}, len(threadScopeIDs))
	for _, scopeID := range threadScopeIDs {
		trimmedScopeID := strings.TrimSpace(scopeID)
		if trimmedScopeID == "" {
			continue
		}
		if _, exists := seen[trimmedScopeID]; exists {
			continue
		}
		seen[trimmedScopeID] = struct{}{}
		normalizedThreadScopeIDs = append(normalizedThreadScopeIDs, trimmedScopeID)
		messagesByScopeID[trimmedScopeID] = nil
	}
	if trimmedWorkspaceID == "" || len(normalizedThreadScopeIDs) == 0 {
		return messagesByScopeID, nil
	}

	threadScopeIDsCTE, threadScopeIDArgs, _, err := coredb.BuildStringValuesCTE("reply_thread_scope_ids", "thread_scope_id", normalizedThreadScopeIDs, 2)
	if err != nil {
		return nil, fmt.Errorf("build reply thread message scope ids: %w", err)
	}
	query := fmt.Sprintf(`
WITH %s
SELECT requested.thread_scope_id,
       m.id AS message_id
FROM reply_thread_scope_ids requested
JOIN scope_messages m
  ON m.workspace_id = $1
 AND m.scope_id = requested.thread_scope_id
ORDER BY requested.thread_scope_id ASC, m.seq ASC
`, threadScopeIDsCTE)
	args := append([]any{trimmedWorkspaceID}, threadScopeIDArgs...)
	rows, err := queryer.Query(ctx, query, args...)
	if err != nil {
		return nil, fmt.Errorf("load child reply thread messages: %w", err)
	}
	defer rows.Close()

	for rows.Next() {
		var scopeID string
		var messageID string
		if err := rows.Scan(&scopeID, &messageID); err != nil {
			return nil, fmt.Errorf("scan child reply thread message: %w", err)
		}
		trimmedScopeID := strings.TrimSpace(scopeID)
		if trimmedScopeID == "" {
			continue
		}
		messagesByScopeID[trimmedScopeID] = append(messagesByScopeID[trimmedScopeID], strings.TrimSpace(messageID))
	}
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("iterate child reply thread messages: %w", err)
	}
	return messagesByScopeID, nil
}

func (store *Store) ListDMChannelsForUserWithLimit(ctx context.Context, userID string, workspaceID string, limit int) ([]message.DMChannel, error) {
	membership, err := store.requireWorkspaceAccess(ctx, userID, workspaceID)
	if err != nil {
		return nil, err
	}

	dms, err := message.ListDMChannelsForMemberWithAgentOwnerLimit(ctx, store.db, membership.Member.ID, workspaceID, userID, limit)
	if err != nil {
		return nil, mapMessagingError(err)
	}
	return dms, nil
}

func (store *Store) ListDMParticipantUsers(ctx context.Context, workspaceID string, dmThreadID string) ([]message.DMParticipantUser, error) {
	participants, err := message.ListDMParticipantUsers(ctx, store.db, workspaceID, dmThreadID)
	if err != nil {
		return nil, mapMessagingError(err)
	}
	return participants, nil
}

func (store *Store) GetDMChannelForUser(ctx context.Context, userID string, workspaceID string, channelID string) (message.DMChannel, error) {
	membership, err := store.requireWorkspaceAccess(ctx, userID, workspaceID)
	if err != nil {
		return message.DMChannel{}, err
	}

	dm, err := message.LoadDMChannelByID(ctx, store.db, workspaceID, membership.Member.ID, strings.TrimSpace(channelID))
	if err != nil {
		return message.DMChannel{}, mapMessagingError(err)
	}
	if dm.PeerType == "agent" {
		if _, err := store.requireAgentWorkspaceRead(ctx, userID, workspaceID, dm.PeerID); err != nil {
			return message.DMChannel{}, err
		}
	}
	return dm, nil
}

func (store *Store) OpenDMChannelWithAgent(ctx context.Context, userID string, workspaceID string, agentID string) (message.DMChannel, error) {
	agentAccess, err := store.requireAgentWorkspaceRead(ctx, userID, workspaceID, agentID)
	if err != nil {
		return message.DMChannel{}, err
	}

	var txResult message.DMChannel
	if err := coredb.WithTx(ctx, store.db, func(tx coredb.Tx) error {

		dm, err := message.OpenDMChannelForMember(ctx, tx, message.MemberActor{
			WorkspaceID: workspaceID,
			MemberID:    agentAccess.WorkspaceMember.Member.ID,
		}, agentAccess.Agent.MemberID)
		if err != nil {
			return mapMessagingError(err)
		}
		if _, err := store.ensureDMScopeProjection(ctx, tx, dm.ID); err != nil {
			return err
		}

		txResult = dm
		return nil
	}); err != nil {
		return message.DMChannel{}, err
	}
	return txResult, nil
}

func (store *Store) OpenDMChannelWithUser(ctx context.Context, userID string, workspaceID string, peerUserID string) (message.DMChannel, error) {
	membership, err := store.requireWorkspaceAccess(ctx, userID, workspaceID)
	if err != nil {
		return message.DMChannel{}, err
	}

	row := store.db.QueryRow(ctx, `
SELECT id
FROM members
WHERE workspace_id = $1 AND type = 'human' AND user_id = $2 AND status = 'active'
`, workspaceID, strings.TrimSpace(peerUserID))
	var peerMemberID string
	if err := row.Scan(&peerMemberID); err != nil {
		if coredb.IsNotFound(err) {
			return message.DMChannel{}, ErrNotFound
		}
		return message.DMChannel{}, fmt.Errorf("load peer member: %w", err)
	}

	var txResult message.DMChannel
	if err := coredb.WithTx(ctx, store.db, func(tx coredb.Tx) error {

		dm, err := message.OpenDMChannelForMember(ctx, tx, message.MemberActor{
			WorkspaceID: workspaceID,
			MemberID:    membership.Member.ID,
		}, peerMemberID)
		if err != nil {
			return mapMessagingError(err)
		}
		if _, err := store.ensureDMScopeProjection(ctx, tx, dm.ID); err != nil {
			return err
		}

		txResult = dm
		return nil
	}); err != nil {
		return message.DMChannel{}, err
	}
	return txResult, nil
}

func (store *Store) ChannelMembersForUser(ctx context.Context, userID string, workspaceID string, channelID string) (message.ChannelMemberList, error) {
	return store.ChannelMembersForUserPage(ctx, userID, workspaceID, channelID, message.ChannelMemberListOptions{Limit: message.MaxChannelMemberListLimit})
}

func (store *Store) ChannelMembersForUserPage(ctx context.Context, userID string, workspaceID string, channelID string, options message.ChannelMemberListOptions) (message.ChannelMemberList, error) {
	channelAccess, err := store.loadAuthorizedHumanChannelAccess(ctx, store.db, userID, workspaceID, channelID, channelReadAction)
	if err != nil {
		return message.ChannelMemberList{}, err
	}
	if channelAccess.Channel.Type != "channel" {
		return message.ChannelMemberList{}, badRequest("thread channels do not have direct members")
	}
	// Team channel projections are maintained by Team mutation paths.
	// Do not reconcile them while listing members. This read runs alongside
	// conversation actions; a reconciliation transaction would lock scopes,
	// memberships and notification settings in the opposite order.
	// Reading the already-materialized projection keeps this endpoint read-only.

	list, err := message.ListChannelMembersPage(ctx, store.db, channelID, options)
	if err != nil {
		return message.ChannelMemberList{}, mapMessagingError(err)
	}
	if err := annotateChannelServiceRemovePermissions(ctx, store.db, &list, workspaceID, channelID, channelAccess.WorkspaceMember, channelAccess.IsOwner); err != nil {
		return message.ChannelMemberList{}, err
	}
	return list, nil
}

func annotateChannelServiceRemovePermissions(ctx context.Context, queryer coredb.Queryer, list *message.ChannelMemberList, workspaceID string, channelID string, actor access.HumanWorkspaceMember, canManageChannel bool) error {
	if list == nil || len(list.Services) == 0 {
		return nil
	}
	removable, err := loadChannelServiceMemberIDsCreatedByMember(ctx, queryer, workspaceID, channelID, actor.Member.ID)
	if err != nil {
		return err
	}
	attributedAppAuthorizations, err := loadAttributedChannelAppAuthorizations(ctx, queryer, workspaceID, channelID, actor)
	if err != nil {
		return err
	}
	for i := range list.Services {
		memberID := strings.TrimSpace(list.Services[i].MemberID)
		if memberID == "" {
			memberID = strings.TrimSpace(list.Services[i].ID)
		}
		if authorization, hasAttribution := attributedAppAuthorizations[memberID]; hasAttribution {
			_, addedByActor := removable[memberID]
			list.Services[i].CanRemove = authorization.CanManage ||
				(authorization.IsPublic && (canManageChannel || addedByActor))
			continue
		}
		if canManageChannel {
			list.Services[i].CanRemove = true
			continue
		}
		_, list.Services[i].CanRemove = removable[memberID]
	}
	return nil
}

// loadAttributedChannelAppAuthorizations returns active attributed Apps with
// their independent management and public-audience facts. Public audience lets
// a channel owner or original inviter remove the App from that channel; private
// attributed Apps remain removable only by a App manager/workspace admin.
func loadAttributedChannelAppAuthorizations(
	ctx context.Context,
	queryer coredb.Queryer,
	workspaceID string,
	channelID string,
	actor access.HumanWorkspaceMember,
) (map[string]channelAppCatalogAuthorization, error) {
	rows, err := queryer.Query(ctx, `
SELECT service_member.member_id,
	   attribution.catalog_visibility,
       EXISTS (
           SELECT 1
           FROM extension_definition_managers manager
           WHERE manager.workspace_id = installation.workspace_id
             AND manager.service_definition_id = installation.service_definition_id
             AND manager.member_id = $4
             AND manager.status = 'active'
             AND manager.ended_at IS NULL
       ) AS can_manage
FROM scope_memberships channel_member
JOIN service_members service_member
  ON service_member.member_id = channel_member.member_id
 AND service_member.workspace_id = $1
 AND service_member.service_kind = $3
 AND service_member.status = 'active'
JOIN members visible_member
  ON visible_member.id = service_member.member_id
 AND visible_member.workspace_id = service_member.workspace_id
 AND visible_member.type = 'service'
 AND visible_member.status = 'active'
JOIN service_installations installation
  ON installation.id = service_member.service_installation_id
 AND installation.workspace_id = service_member.workspace_id
 AND installation.service_definition_id = service_member.service_definition_id
 AND installation.service_kind = service_member.service_kind
 AND installation.status = 'active'
JOIN extension_catalog_attributions attribution
  ON attribution.workspace_id = installation.workspace_id
 AND attribution.service_definition_id = installation.service_definition_id
WHERE channel_member.scope_id = $2
  AND channel_member.visibility = 'public'
  AND channel_member.removed_at IS NULL
  AND channel_member.archived_at IS NULL
  AND channel_member.effective_to_seq IS NULL
	`, strings.TrimSpace(workspaceID), strings.TrimSpace(channelID), serviceactor.KindApp, strings.TrimSpace(actor.Member.ID))
	if err != nil {
		return nil, fmt.Errorf("list channel App permissions: %w", err)
	}
	defer rows.Close()

	authorizations := make(map[string]channelAppCatalogAuthorization)
	isWorkspaceAdmin := access.HasWorkspaceAdminRole(domain.WorkspaceRole(actor.WorkspaceRole))
	for rows.Next() {
		var serviceMemberID, catalogVisibility string
		var canManage bool
		if err := rows.Scan(&serviceMemberID, &catalogVisibility, &canManage); err != nil {
			return nil, fmt.Errorf("scan channel App permission: %w", err)
		}
		serviceMemberID = strings.TrimSpace(serviceMemberID)
		if serviceMemberID == "" {
			continue
		}
		authorizations[serviceMemberID] = channelAppCatalogAuthorization{
			HasAttribution: true,
			CanManage:      isWorkspaceAdmin || canManage,
			IsPublic:       strings.ToLower(strings.TrimSpace(catalogVisibility)) == registry.CatalogVisibilityPublic,
		}
	}
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("iterate channel App permissions: %w", err)
	}
	return authorizations, nil
}

func loadChannelServiceMemberIDsCreatedByMember(ctx context.Context, queryer coredb.Queryer, workspaceID string, channelID string, actorMemberID string) (map[string]struct{}, error) {
	rows, err := queryer.Query(ctx, `
SELECT sm.member_id
FROM scope_memberships sm
JOIN members m
  ON m.id = sm.member_id
 AND m.workspace_id = $1
 AND m.type = 'service'
 AND m.status = 'active'
WHERE sm.scope_id = $2
  AND sm.visibility = 'public'
  AND sm.owner_member_id = $3
  AND sm.removed_at IS NULL
  AND sm.archived_at IS NULL
  AND sm.effective_to_seq IS NULL
UNION
SELECT service_member.member_id
FROM service_target_bindings binding
JOIN service_members service_member
  ON service_member.id = binding.service_member_id
 AND service_member.workspace_id = binding.workspace_id
 AND service_member.status = 'active'
JOIN scope_memberships sm
  ON sm.scope_id = binding.target_ref
 AND sm.member_id = service_member.member_id
 AND sm.visibility = 'public'
 AND COALESCE(sm.owner_member_id, '') = ''
 AND sm.removed_at IS NULL
 AND sm.archived_at IS NULL
 AND sm.effective_to_seq IS NULL
WHERE binding.workspace_id = $1
  AND binding.target_kind = 'channel'
  AND binding.target_ref = $2
  AND binding.status = 'active'
  AND binding.removed_at IS NULL
  AND binding.created_by_member_id = $3
`, strings.TrimSpace(workspaceID), strings.TrimSpace(channelID), strings.TrimSpace(actorMemberID))
	if err != nil {
		return nil, fmt.Errorf("list removable channel service members: %w", err)
	}
	defer rows.Close()

	ids := make(map[string]struct{})
	for rows.Next() {
		var memberID string
		if err := rows.Scan(&memberID); err != nil {
			return nil, fmt.Errorf("scan removable channel service member: %w", err)
		}
		trimmed := strings.TrimSpace(memberID)
		if trimmed != "" {
			ids[trimmed] = struct{}{}
		}
	}
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("iterate removable channel service members: %w", err)
	}
	return ids, nil
}

func canRemoveChannelServiceMember(ctx context.Context, queryer coredb.Queryer, workspaceID string, channelID string, serviceMemberID string, actorMemberID string) (bool, error) {
	removable, err := loadChannelServiceMemberIDsCreatedByMember(ctx, queryer, workspaceID, channelID, actorMemberID)
	if err != nil {
		return false, err
	}
	_, ok := removable[strings.TrimSpace(serviceMemberID)]
	return ok, nil
}

// channelServiceTarget is an optimistic active installation/member snapshot.
// It selects the stable definition fence but never grants mutation authority;
// the current tuple and pinned App capability are re-locked before every write.
type channelServiceTarget struct {
	Installation serviceactor.Installation
	Member       serviceactor.Member
	IsApp        bool
}

type channelAppCatalogAuthorization struct {
	HasAttribution bool
	CanManage      bool
	IsPublic       bool
	IsProduct      bool
}

func loadEnabledChannelServiceTarget(
	ctx context.Context,
	queryer coredb.QueryRower,
	workspaceID string,
	serviceMemberID string,
) (channelServiceTarget, error) {
	installation, member, found, isApp, err := loadActiveAppServiceInstallationByServiceMemberID(
		ctx,
		queryer,
		workspaceID,
		serviceMemberID,
		false,
	)
	if err != nil {
		return channelServiceTarget{}, err
	}
	if !found {
		return channelServiceTarget{}, ErrNotFound
	}
	return channelServiceTarget{Installation: installation, Member: member, IsApp: isApp}, nil
}

func lockCurrentChannelServiceTargetForUpdate(
	ctx context.Context,
	queryer coredb.QueryRower,
	expected channelServiceTarget,
) (channelServiceTarget, error) {
	installation, member, found, isApp, err := loadActiveAppServiceInstallationByServiceMemberID(
		ctx,
		queryer,
		expected.Installation.WorkspaceID,
		expected.Member.MemberID,
		true,
	)
	if err != nil {
		return channelServiceTarget{}, err
	}
	current := channelServiceTarget{Installation: installation, Member: member, IsApp: isApp}
	if !found || !channelServiceTargetMatches(current, expected) {
		return channelServiceTarget{}, conflict("Channel service installation changed before membership mutation.")
	}
	return current, nil
}

func channelServiceTargetMatches(current channelServiceTarget, expected channelServiceTarget) bool {
	return strings.TrimSpace(current.Installation.ID) == strings.TrimSpace(expected.Installation.ID) &&
		strings.TrimSpace(current.Installation.ID) != "" &&
		strings.TrimSpace(current.Installation.WorkspaceID) == strings.TrimSpace(expected.Installation.WorkspaceID) &&
		strings.TrimSpace(current.Installation.WorkspaceID) != "" &&
		strings.TrimSpace(current.Installation.ServiceDefinitionID) == strings.TrimSpace(expected.Installation.ServiceDefinitionID) &&
		strings.TrimSpace(current.Installation.ServiceDefinitionID) != "" &&
		strings.TrimSpace(current.Member.ID) == strings.TrimSpace(expected.Member.ID) &&
		strings.TrimSpace(current.Member.ID) != "" &&
		strings.TrimSpace(current.Member.WorkspaceID) == strings.TrimSpace(expected.Member.WorkspaceID) &&
		strings.TrimSpace(current.Member.WorkspaceID) == strings.TrimSpace(current.Installation.WorkspaceID) &&
		strings.TrimSpace(current.Member.ServiceDefinitionID) == strings.TrimSpace(expected.Member.ServiceDefinitionID) &&
		strings.TrimSpace(current.Member.ServiceDefinitionID) == strings.TrimSpace(current.Installation.ServiceDefinitionID) &&
		strings.TrimSpace(current.Member.ServiceInstallationID) == strings.TrimSpace(expected.Member.ServiceInstallationID) &&
		strings.TrimSpace(current.Member.ServiceInstallationID) == strings.TrimSpace(current.Installation.ID) &&
		strings.TrimSpace(current.Member.MemberID) == strings.TrimSpace(expected.Member.MemberID) &&
		strings.TrimSpace(current.Member.MemberID) != "" &&
		current.IsApp == expected.IsApp
}

func lockChannelAppManagerAuthorizationForUpdate(
	ctx context.Context,
	queryer coredb.QueryRower,
	workspaceID string,
	serviceDefinitionID string,
	actor access.HumanWorkspaceMember,
) (channelAppCatalogAuthorization, error) {
	var catalogVisibility string
	err := queryer.QueryRow(ctx, `
SELECT catalog_visibility
FROM extension_catalog_attributions
WHERE workspace_id = $1
  AND service_definition_id = $2
LIMIT 1
FOR UPDATE
	`, strings.TrimSpace(workspaceID), strings.TrimSpace(serviceDefinitionID)).Scan(&catalogVisibility)
	if err != nil && !coredb.IsNotFound(err) {
		return channelAppCatalogAuthorization{}, fmt.Errorf("lock channel App workspace attribution: %w", err)
	}
	if coredb.IsNotFound(err) {
		isProduct, err := channelAppDefinitionIsProductApp(ctx, queryer, serviceDefinitionID)
		if err != nil {
			return channelAppCatalogAuthorization{}, err
		}
		return channelAppCatalogAuthorization{IsProduct: isProduct}, nil
	}
	authorization := channelAppCatalogAuthorization{
		HasAttribution: true,
		IsPublic:       strings.ToLower(strings.TrimSpace(catalogVisibility)) == registry.CatalogVisibilityPublic,
	}
	if access.HasWorkspaceAdminRole(domain.WorkspaceRole(actor.WorkspaceRole)) {
		authorization.CanManage = true
		return authorization, nil
	}
	managed, err := hasActiveAppManager(
		ctx,
		queryer,
		workspaceID,
		serviceDefinitionID,
		actor.Member.ID,
		true,
	)
	if err != nil {
		return channelAppCatalogAuthorization{}, err
	}
	authorization.CanManage = managed
	return authorization, nil
}

func channelAppDefinitionIsProductApp(
	ctx context.Context,
	queryer coredb.QueryRower,
	serviceDefinitionID string,
) (bool, error) {
	var appID, appSlug string
	if err := queryer.QueryRow(ctx, `
SELECT id, slug
FROM service_definitions
WHERE id = $1
  AND service_kind = $2
LIMIT 1
`, strings.TrimSpace(serviceDefinitionID), serviceactor.KindApp).Scan(&appID, &appSlug); err != nil {
		if coredb.IsNotFound(err) {
			return false, nil
		}
		return false, fmt.Errorf("load channel App product identity: %w", err)
	}
	return registry.IsProductApp(appID, appSlug), nil
}

// removeAppFromAllChannelsForVisibilityChangeInTx removes the App's active
// channel memberships and every mention target whose authority depends on one
// of those channels. The caller owns the catalog-visibility mutation and must
// already hold the definition fence and workspace attribution lock.
//
// This cleanup intentionally does not lock channel scope rows or append channel
// system messages. Channel membership mutations take scope -> definition; a
// visibility mutation already holds definition, so taking a scope lock here
// would reverse that hierarchy and can deadlock with an in-flight add/remove.
func removeAppFromAllChannelsForVisibilityChangeInTx(
	ctx context.Context,
	tx coredb.Tx,
	workspaceID string,
	actorMemberID string,
	installation serviceactor.Installation,
	memberID string,
	now time.Time,
) ([]string, error) {
	return executeAppVisibilityChannelCleanup(
		ctx, tx, workspaceID, actorMemberID, installation, memberID, now,
	)
}

func (store *Store) revalidateChannelServiceAddActorForUpdate(
	ctx context.Context,
	queryer coredb.QueryRower,
	userID string,
	workspaceID string,
	channelID string,
	expectedActorMemberID string,
) (access.HumanChannelAccess, error) {
	currentMember, err := store.loadAuthorizedHumanWorkspaceMemberForUpdate(
		ctx,
		queryer,
		userID,
		workspaceID,
		workspaceAccessAction,
	)
	if err != nil {
		return access.HumanChannelAccess{}, err
	}
	if strings.TrimSpace(currentMember.Member.ID) != strings.TrimSpace(expectedActorMemberID) {
		return access.HumanChannelAccess{}, ErrForbidden
	}
	currentAccess, err := store.loadAuthorizedHumanChannelAccess(
		ctx,
		queryer,
		userID,
		workspaceID,
		channelID,
		channelInviteMembersAction,
	)
	if err != nil {
		return access.HumanChannelAccess{}, err
	}
	if strings.TrimSpace(currentAccess.WorkspaceMember.Member.ID) != strings.TrimSpace(currentMember.Member.ID) {
		return access.HumanChannelAccess{}, ErrForbidden
	}
	currentAccess.WorkspaceMember = currentMember
	if currentAccess.Channel.Type != "channel" {
		return access.HumanChannelAccess{}, badRequest("thread channels do not have direct members")
	}
	return currentAccess, nil
}

func (store *Store) revalidateChannelServiceRemovalActorForUpdate(
	ctx context.Context,
	tx coredb.Tx,
	userID string,
	workspaceID string,
	channelID string,
	serviceMemberID string,
	expectedActorMemberID string,
) (access.HumanChannelAccess, bool, error) {
	currentMember, err := store.loadAuthorizedHumanWorkspaceMemberForUpdate(
		ctx,
		tx,
		userID,
		workspaceID,
		workspaceAccessAction,
	)
	if err != nil {
		return access.HumanChannelAccess{}, false, err
	}
	if strings.TrimSpace(currentMember.Member.ID) != strings.TrimSpace(expectedActorMemberID) {
		return access.HumanChannelAccess{}, false, ErrForbidden
	}
	currentAccess, err := store.loadAuthorizedHumanChannelAccess(
		ctx,
		tx,
		userID,
		workspaceID,
		channelID,
		channelReadAction,
	)
	if err != nil {
		return access.HumanChannelAccess{}, false, err
	}
	if strings.TrimSpace(currentAccess.WorkspaceMember.Member.ID) != strings.TrimSpace(currentMember.Member.ID) {
		return access.HumanChannelAccess{}, false, ErrForbidden
	}
	currentAccess.WorkspaceMember = currentMember
	if currentAccess.Channel.Type != "channel" {
		return access.HumanChannelAccess{}, false, badRequest("thread channels do not have direct members")
	}
	if currentAccess.Channel.Slug == "all" {
		return access.HumanChannelAccess{}, false, badRequest("cannot remove members from #all")
	}
	if currentAccess.IsOwner {
		return currentAccess, true, nil
	}

	addedByActor, err := canRemoveChannelServiceMember(
		ctx,
		tx,
		workspaceID,
		channelID,
		serviceMemberID,
		currentMember.Member.ID,
	)
	if err != nil {
		return access.HumanChannelAccess{}, false, err
	}
	return currentAccess, addedByActor, nil
}

func (store *Store) AddChannelAgentForUser(ctx context.Context, userID string, workspaceID string, channelID string, agentID string) error {
	_, err := store.AddChannelAgentForUserWithEvent(ctx, userID, workspaceID, channelID, agentID)
	return err
}

func (store *Store) AddChannelPrivateAgentForUser(ctx context.Context, userID string, workspaceID string, channelID string, agentID string) error {
	_, err := store.AddChannelPrivateAgentForUserWithEvent(ctx, userID, workspaceID, channelID, agentID)
	return err
}

func (store *Store) AddChannelHumanForUser(ctx context.Context, userID string, workspaceID string, channelID string, humanUserID string) error {
	_, err := store.AddChannelHumanForUserWithEvent(ctx, userID, workspaceID, channelID, humanUserID)
	return err
}

func (store *Store) AddChannelServiceForUser(ctx context.Context, userID string, workspaceID string, channelID string, serviceMemberID string) error {
	_, err := store.AddChannelServiceForUserWithEvent(ctx, userID, workspaceID, channelID, serviceMemberID)
	return err
}

func (store *Store) AddChannelMembersForUserWithEvents(ctx context.Context, userID string, workspaceID string, channelID string, members []message.ChannelMemberAddRequest) ([]message.ChannelMemberEventResult, error) {
	if len(members) == 0 {
		return nil, nil
	}
	channelAccess, err := store.requireChannelInvite(ctx, userID, workspaceID, channelID)
	if err != nil {
		return nil, err
	}
	if channelAccess.Channel.Type != "channel" {
		return nil, badRequest("thread channels do not have direct members")
	}
	now := time.Now().UTC()
	var results []message.ChannelMemberEventResult
	if err := coordinator.New(store.db).Run(ctx, "batch add channel members with member events", func(tx coredb.Tx) error {
		results = nil
		r, err := batchAddChannelMembersInTx(ctx, store, tx, workspaceID, channelAccess.Channel.ChannelID, userID, channelAccess.WorkspaceMember.Member.ID, members, now, BrowserInviteAuthority, nil)
		results = r
		return err
	}); err != nil {
		return nil, err
	}
	return results, nil
}

// batchAddChannelMembersInTx is the canonical caller-tx batch add primitive, a
// package-level function re-exposed as BatchAddChannelMembersInTx by
// ChannelManagementPort (deliberately NOT a *Store method).
// It locks the channel scope, revalidates authority (authorityUserID) + carrier
// from the locked snapshot, resolves/locks targets (stable order), runs the
// canonical add mutation helpers, and appends the aggregate system message with
// actorMemberID as author. The service never sees access.HumanChannelAccess —
// authority is fully internal to prevent stale-authority TOCTOU. No-op (all
// already added) returns an empty slice; the caller gates post-commit effects
// on len(results) > 0.
func batchAddChannelMembersInTx(ctx context.Context, store *Store, tx coredb.Tx, workspaceID string, channelID string, authorityUserID string, actorMemberID string, members []message.ChannelMemberAddRequest, now time.Time, authorityPolicy ChannelMemberAuthorityPolicy, effects *ChannelMemberMutationEffects) ([]message.ChannelMemberEventResult, error) {
	locked, err := loadLockedChannelMemberAuthority(ctx, tx, authorityUserID, workspaceID, channelID)
	if err != nil {
		return nil, err
	}
	loaded := locked.Access
	if !loaded.Joined {
		return nil, ErrNotFound
	}
	if loaded.Channel.Type != "channel" {
		return nil, badRequest("thread channels do not have direct members")
	}
	switch authorityPolicy {
	case BrowserInviteAuthority:
		if !loaded.CanInvite {
			return nil, ErrForbidden
		}
	case ManagedOwnerAuthority:
		if !loaded.IsOwner {
			return nil, ErrForbidden
		}
		if strings.TrimSpace(loaded.Channel.TeamID) != "" || strings.TrimSpace(loaded.Channel.Subsystem) != "im" {
			return nil, ErrNotFound
		}
		if strings.TrimSpace(loaded.Channel.Slug) == "all" {
			return nil, badRequest("the #all channel cannot be mutated")
		}
	default:
		return nil, ErrForbidden
	}
	sortedMembers := stableChannelMemberAddRequests(members)
	preparedServiceTargets, err := lockBatchChannelAppDefinitions(ctx, tx, workspaceID, sortedMembers)
	if err != nil {
		return nil, err
	}
	targets := make([]channelMemberEventTarget, 0, len(sortedMembers))
	for _, member := range sortedMembers {
		var mutation channelMemberEventMutation
		serviceMemberID := strings.TrimSpace(member.ServiceMemberID)
		if serviceMemberID != "" {
			preparedTarget, ok := preparedServiceTargets[serviceMemberID]
			if !ok {
				return nil, conflict("Channel App target changed before batch mutation.")
			}
			mutation, err = store.addChannelServiceTargetMutationInTx(ctx, tx, authorityUserID, workspaceID, loaded, preparedTarget, now)
		} else {
			mutation, err = store.addChannelMemberMutationInTx(ctx, tx, authorityUserID, workspaceID, loaded, member, now)
		}
		if err != nil {
			return nil, err
		}
		if mutation.Changed {
			targets = append(targets, mutation.Target)
			if effects != nil {
				effects.AppendDirectMember(DirectMember{
					MemberID: mutation.Target.MemberID, Kind: mutation.Target.Kind, DisplayName: mutation.Target.DisplayName,
				})
				effects.AppendMembershipChange(channelID, ChannelMembershipChange{
					MemberID: mutation.Target.MemberID, Kind: mutation.Target.Kind, Action: message.ChannelMemberEventActionAdded, UserID: member.UserID, AgentID: member.AgentID,
				})
				if mutation.Target.Kind == message.ChannelMemberEventTargetHuman && member.UserID != "" {
					effects.AppendHumanSubscription(member.UserID, message.ChannelMemberEventActionAdded, actorMemberID, authorityUserID)
				}
			}
		}
	}
	if len(targets) == 0 {
		return nil, nil
	}
	result, err := store.appendChannelMemberEventMessage(ctx, tx, workspaceID, loaded, actorMemberID, message.ChannelMemberEventActionAdded, aggregateChannelMemberEventTarget(targets), targets...)
	if err != nil {
		return nil, err
	}
	result.ChangedCount = len(targets)
	result.AffectedMemberIDs = channelMemberEventTargetMemberIDs(targets)
	if effects != nil {
		effects.EventResults = []message.ChannelMemberEventResult{result}
		effects.Normalize()
	}
	return []message.ChannelMemberEventResult{result}, nil
}

// lockBatchChannelAppDefinitions takes all stable definition fences before the
// batch locks its actor or any installation. Sorting prevents two cross-channel
// multi-App batches from acquiring the same definitions in opposite order.
func lockBatchChannelAppDefinitions(ctx context.Context, tx coredb.Tx, workspaceID string, members []message.ChannelMemberAddRequest) (map[string]channelServiceTarget, error) {
	definitionIDs := make(map[string]struct{})
	preparedTargets := make(map[string]channelServiceTarget)
	for _, member := range members {
		serviceMemberID := strings.TrimSpace(member.ServiceMemberID)
		if serviceMemberID == "" {
			continue
		}
		target, err := loadEnabledChannelServiceTarget(ctx, tx, workspaceID, serviceMemberID)
		if err != nil {
			return nil, err
		}
		if !target.IsApp {
			return nil, badRequest("only App App services can be added to channels")
		}
		definitionID := strings.TrimSpace(target.Installation.ServiceDefinitionID)
		if definitionID == "" {
			return nil, ErrNotFound
		}
		preparedTargets[serviceMemberID] = target
		definitionIDs[definitionID] = struct{}{}
	}
	orderedDefinitionIDs := make([]string, 0, len(definitionIDs))
	for definitionID := range definitionIDs {
		orderedDefinitionIDs = append(orderedDefinitionIDs, definitionID)
	}
	sort.Strings(orderedDefinitionIDs)
	for _, definitionID := range orderedDefinitionIDs {
		if err := installation.LockAppInstallationMutationScope(ctx, tx, definitionID); err != nil {
			return nil, err
		}
	}
	return preparedTargets, nil
}

func (store *Store) RemoveChannelAgentForUser(ctx context.Context, userID string, workspaceID string, channelID string, agentID string) error {
	_, err := store.RemoveChannelAgentForUserWithEvent(ctx, userID, workspaceID, channelID, agentID)
	return err
}

func (store *Store) RemoveChannelServiceForUser(ctx context.Context, userID string, workspaceID string, channelID string, serviceMemberID string) error {
	_, err := store.RemoveChannelServiceForUserWithEvent(ctx, userID, workspaceID, channelID, serviceMemberID)
	return err
}

func (store *Store) RemoveChannelHumanForUser(ctx context.Context, userID string, workspaceID string, channelID string, humanUserID string) error {
	_, err := store.RemoveChannelHumanForUserWithEvent(ctx, userID, workspaceID, channelID, humanUserID)
	return err
}

func runChannelMemberEventTx(ctx context.Context, txBeginner coredb.TxBeginner, operation string, channelID string, fn func(coredb.Tx) error) error {
	return coordinator.New(txBeginner).Run(ctx, operation, func(tx coredb.Tx) error {
		if err := lockChannelMemberEventScope(ctx, tx, channelID); err != nil {
			return err
		}
		return fn(tx)
	})
}

// batchRemoveChannelMembersInTx is the canonical caller-tx batch remove
// primitive, mirroring batchAddChannelMembersInTx and re-exposed as
// BatchRemoveChannelMembersInTx by ChannelManagementPort (NOT a *Store method).
// It locks the channel scope,
// revalidates authority + carrier, resolves targets, runs the canonical remove
// cleanup (membership projection + private entries + agent entries + wake
// subscriptions), and appends the aggregate system message with actorMemberID.
func batchRemoveChannelMembersInTx(ctx context.Context, store *Store, tx coredb.Tx, workspaceID string, channelID string, authorityUserID string, actorMemberID string, refs []ChannelMemberExecReference, now time.Time, effects *ChannelMemberMutationEffects) ([]message.ChannelMemberEventResult, error) {
	locked, err := loadLockedChannelMemberAuthority(ctx, tx, authorityUserID, workspaceID, channelID)
	if err != nil {
		return nil, err
	}
	loaded := locked.Access
	if !loaded.Joined {
		return nil, ErrNotFound
	}
	if !loaded.IsOwner {
		return nil, ErrForbidden
	}
	if loaded.Channel.Type != "channel" || strings.TrimSpace(loaded.Channel.Slug) == "all" {
		return nil, badRequest("cannot remove members from this channel")
	}
	if strings.TrimSpace(loaded.Channel.TeamID) != "" || strings.TrimSpace(loaded.Channel.Subsystem) != "im" {
		return nil, ErrNotFound
	}
	targets := make([]channelMemberEventTarget, 0, len(refs))
	// changedRows collects every (scope, member) the canonical cleanup helpers
	// retire. changedRowsOut is non-nil ONLY when effects is requested, so the
	// browser/test path (effects == nil) pays no capture SELECT / FOR UPDATE and
	// its lock graph is unchanged; helpers skip capture entirely when nil.
	var changedRows []scopeMembershipRow
	var changedRowsOut *[]scopeMembershipRow
	if effects != nil {
		changedRowsOut = &changedRows
	}
	// Stable sort refs before resolving/mutating to prevent deadlock from
	// reverse-order concurrent batches.
	sortedRefs := append([]ChannelMemberExecReference(nil), refs...)
	sort.SliceStable(sortedRefs, func(i, j int) bool {
		leftKind, leftID := "human", strings.TrimSpace(sortedRefs[i].UserID)
		if leftID == "" {
			leftKind, leftID = "agent", strings.TrimSpace(sortedRefs[i].AgentID)
		}
		rightKind, rightID := "human", strings.TrimSpace(sortedRefs[j].UserID)
		if rightID == "" {
			rightKind, rightID = "agent", strings.TrimSpace(sortedRefs[j].AgentID)
		}
		if leftKind != rightKind {
			return leftKind < rightKind
		}
		return leftID < rightID
	})
	for _, ref := range sortedRefs {
		userID := strings.TrimSpace(ref.UserID)
		agentID := strings.TrimSpace(ref.AgentID)
		if (userID == "") == (agentID == "") {
			return nil, badRequest("each member reference must have exactly one of userId or agentId")
		}
		if userID != "" {
			var memberID string
			if err := tx.QueryRow(ctx, `
SELECT id
FROM members
WHERE workspace_id = $1
  AND type = 'human'
  AND user_id = $2
  AND status = 'active'
FOR UPDATE
`, workspaceID, userID).Scan(&memberID); err != nil {
				if coredb.IsNotFound(err) {
					return nil, ErrNotFound
				}
				return nil, fmt.Errorf("resolve human member: %w", err)
			}
			if loaded.IsOwner && memberID == loaded.WorkspaceMember.Member.ID {
				return nil, badRequest("channel owner must transfer ownership before leaving")
			}
			wasActive, err := activeChannelMemberEventMembership(ctx, tx, channelID, memberID, channelAgentEntryVisibilityPublic, "")
			if err != nil {
				return nil, err
			}
			if err := store.markScopeParticipantProjectionRemoved(ctx, tx, channelID, memberID, now, changedRowsOut); err != nil {
				return nil, err
			}
			if err := store.removePrivateEntriesForOwnerHostChannel(ctx, tx, memberID, channelID, now, changedRowsOut); err != nil {
				return nil, err
			}
			if wasActive {
				target, err := store.channelMemberEventTargetByMemberID(ctx, tx, workspaceID, message.ChannelMemberEventTargetHuman, memberID)
				if err != nil {
					return nil, err
				}
				targets = append(targets, target)
				if effects != nil {
					effects.AppendDirectMember(DirectMember{MemberID: memberID, Kind: message.ChannelMemberEventTargetHuman, DisplayName: target.DisplayName})
					effects.AppendHumanSubscription(userID, message.ChannelMemberEventActionRemoved, actorMemberID, authorityUserID)
				}
			}
		} else {
			agentEntry, err := store.loadAgentEntryOwner(ctx, tx, workspaceID, agentID)
			if err != nil {
				return nil, err
			}
			memberID := agentEntry.AgentMemberID
			entry, hasEntry, entryErr := store.loadActiveChannelAgentEntry(ctx, tx, channelID, memberID)
			if entryErr != nil {
				return nil, entryErr
			}
			if hasEntry && strings.TrimSpace(entry.Visibility) == channelAgentEntryVisibilityPrivate {
				var entryOwnerMemberID string
				if entry.OwnerMemberID.Valid {
					entryOwnerMemberID = strings.TrimSpace(entry.OwnerMemberID.String)
				}
				canonicalOwner := strings.TrimSpace(agentEntry.OwnerMemberID)
				if canonicalOwner == "" ||
					canonicalOwner != strings.TrimSpace(loaded.WorkspaceMember.Member.ID) ||
					entryOwnerMemberID != canonicalOwner {
					return nil, ErrForbidden
				}
				wasActive, err := activeChannelMemberEventMembership(ctx, tx, channelID, memberID, channelAgentEntryVisibilityPrivate, entryOwnerMemberID)
				if err != nil {
					return nil, err
				}
				if err := markPrivateAgentScopeMembershipRemoved(ctx, tx, channelID, memberID, entryOwnerMemberID, now, changedRowsOut); err != nil {
					return nil, err
				}
				if err := store.deletePrivateAgentWakeSubscriptionsForHostChannel(ctx, tx, channelID, memberID); err != nil {
					return nil, err
				}
				if wasActive {
					target, err := store.channelMemberEventTargetByMemberID(ctx, tx, workspaceID, message.ChannelMemberEventTargetAgent, memberID)
					if err != nil {
						return nil, err
					}
					targets = append(targets, target)
					if effects != nil {
						effects.AppendDirectMember(DirectMember{MemberID: memberID, Kind: message.ChannelMemberEventTargetAgent, DisplayName: target.DisplayName})
					}
				}
			} else {
				wasActive, err := activeChannelMemberEventMembership(ctx, tx, channelID, memberID, channelAgentEntryVisibilityPublic, "")
				if err != nil {
					return nil, err
				}
				if err := store.markScopeParticipantProjectionRemoved(ctx, tx, channelID, memberID, now, changedRowsOut); err != nil {
					return nil, err
				}
				if err := store.markPublicChannelAgentEntryProjectionRemoved(ctx, tx, channelID, memberID, now, changedRowsOut); err != nil {
					return nil, err
				}
				if err := store.deletePrivateAgentWakeSubscriptionsForHostChannel(ctx, tx, channelID, memberID); err != nil {
					return nil, err
				}
				if wasActive {
					target, err := store.channelMemberEventTargetByMemberID(ctx, tx, workspaceID, message.ChannelMemberEventTargetAgent, memberID)
					if err != nil {
						return nil, err
					}
					targets = append(targets, target)
					if effects != nil {
						effects.AppendDirectMember(DirectMember{MemberID: memberID, Kind: message.ChannelMemberEventTargetAgent, DisplayName: target.DisplayName})
					}
				}
			}
		}
	}
	if effects != nil && len(changedRows) > 0 {
		principals, err := resolveChannelMemberPrincipals(ctx, tx, workspaceID, changedRows)
		if err != nil {
			return nil, err
		}
		// A changed membership row MUST resolve to a complete principal. Silently
		// skipping it would drop a real change from the effects; emitting it
		// half-empty would leave downstream wake/unread without a principal. Both
		// break the "only real, fully-attributed changes" invariant, so fail-closed.
		for _, row := range changedRows {
			principal, ok := principals[row.MemberID]
			if !ok || principal.Kind == "" {
				return nil, fmt.Errorf("channel member effects: changed membership %s on scope %s has no resolvable principal", row.MemberID, row.ScopeID)
			}
			switch principal.Kind {
			case message.ChannelMemberEventTargetHuman:
				if principal.UserID == "" {
					return nil, fmt.Errorf("channel member effects: changed human membership %s on scope %s has no userID", row.MemberID, row.ScopeID)
				}
			case message.ChannelMemberEventTargetAgent:
				if principal.AgentID == "" {
					return nil, fmt.Errorf("channel member effects: changed agent membership %s on scope %s has no agentID", row.MemberID, row.ScopeID)
				}
			default:
				return nil, fmt.Errorf("channel member effects: changed membership %s on scope %s has unsupported kind %q", row.MemberID, row.ScopeID, principal.Kind)
			}
			effects.AppendMembershipChange(row.ScopeID, ChannelMembershipChange{
				MemberID: row.MemberID, Kind: principal.Kind, Action: message.ChannelMemberEventActionRemoved, UserID: principal.UserID, AgentID: principal.AgentID,
			})
		}
	}
	if effects != nil && effects.hasHumanRemoval() {
		roomIDs, err := captureChannelRealtimeRoomIDsInTx(ctx, tx, workspaceID, channelID)
		if err != nil {
			return nil, err
		}
		effects.RealtimeRoomIDs = roomIDs
	}
	if len(targets) == 0 {
		// Cleanup-only: no direct target changed, but the canonical helpers may
		// still have retired cascade/private memberships. Canonicalize so the
		// committed MembershipChanges are deterministic and HasChanges honors the
		// cleanup-only contract.
		if effects != nil {
			effects.Normalize()
		}
		return nil, nil
	}
	sort.SliceStable(targets, func(i, j int) bool {
		if targets[i].Kind != targets[j].Kind {
			return targets[i].Kind < targets[j].Kind
		}
		return targets[i].MemberID < targets[j].MemberID
	})
	result, err := store.appendChannelMemberEventMessage(ctx, tx, workspaceID, loaded, actorMemberID, message.ChannelMemberEventActionRemoved, aggregateChannelMemberEventTarget(targets), targets...)
	if err != nil {
		return nil, err
	}
	result.ChangedCount = len(targets)
	result.AffectedMemberIDs = channelMemberEventTargetMemberIDs(targets)
	if effects != nil {
		effects.EventResults = []message.ChannelMemberEventResult{result}
		effects.Normalize()
	}
	return []message.ChannelMemberEventResult{result}, nil
}

type lockedChannelMemberAuthority struct {
	Access         access.HumanChannelAccess
	ProfileVersion int64
}

// loadLockedChannelMemberAuthority is the canonical channel profile/member
// authority fence. Lock order is deliberately member -> scope -> profile/team
// -> membership. Every authority/carrier decision is built from these locking
// current reads; callers must not follow the fence with a plain access lookup,
// because a transaction may already have an older repeatable-read snapshot.
func loadLockedChannelMemberAuthority(ctx context.Context, tx coredb.Tx, authorityUserID string, workspaceID string, channelID string) (lockedChannelMemberAuthority, error) {
	trimmedWorkspaceID := strings.TrimSpace(workspaceID)
	trimmedChannelID := strings.TrimSpace(channelID)
	if trimmedWorkspaceID == "" {
		return lockedChannelMemberAuthority{}, badRequest("workspaceId is required")
	}
	if trimmedChannelID == "" {
		return lockedChannelMemberAuthority{}, badRequest("channelId is required")
	}

	workspaceMember, err := access.LoadHumanWorkspaceMemberForUpdate(
		ctx,
		tx,
		strings.TrimSpace(authorityUserID),
		trimmedWorkspaceID,
	)
	if err != nil {
		if errors.Is(err, access.ErrForbidden) {
			return lockedChannelMemberAuthority{}, ErrForbidden
		}
		return lockedChannelMemberAuthority{}, fmt.Errorf("lock channel member authority: %w", err)
	}

	var scopeType, scopeStatus string
	var scopeArchived bool
	if err := tx.QueryRow(ctx, `
SELECT type, status, archived_at IS NOT NULL
FROM scopes
WHERE workspace_id = $1
  AND id = $2
FOR UPDATE
`, trimmedWorkspaceID, trimmedChannelID).Scan(&scopeType, &scopeStatus, &scopeArchived); err != nil {
		if coredb.IsNotFound(err) {
			return lockedChannelMemberAuthority{}, ErrNotFound
		}
		return lockedChannelMemberAuthority{}, fmt.Errorf("lock channel member authority scope: %w", err)
	}
	if scopeStatus != "active" || scopeArchived {
		return lockedChannelMemberAuthority{}, ErrNotFound
	}

	if preChannelMemberProfileLockHookForTest != nil {
		preChannelMemberProfileLockHookForTest(ctx)
	}
	if PreProfileLockHookForTest != nil {
		PreProfileLockHookForTest(ctx)
	}

	locked := lockedChannelMemberAuthority{}
	channel := access.Channel{
		ChannelID: trimmedChannelID,
		ThreadID:  trimmedChannelID,
		Type:      scopeType,
	}
	if err := tx.QueryRow(ctx, `
SELECT slug,
       COALESCE(NULLIF(display_name, ''), slug),
       COALESCE(description, ''),
	       COALESCE(visibility, 'private'),
	       COALESCE(NULLIF(subsystem, ''), 'im'),
       owner_member_id,
       version
FROM scope_channel_profiles
WHERE workspace_id = $1
  AND scope_id = $2
FOR UPDATE
`, trimmedWorkspaceID, trimmedChannelID).Scan(
		&channel.Slug,
		&channel.DisplayName,
		&channel.Description,
		&channel.Visibility,
		&channel.Subsystem,
		&channel.OwnerMemberID,
		&locked.ProfileVersion,
	); err != nil {
		if coredb.IsNotFound(err) {
			return lockedChannelMemberAuthority{}, ErrNotFound
		}
		return lockedChannelMemberAuthority{}, fmt.Errorf("lock channel member authority profile: %w", err)
	}

	var teamStatus string
	err = tx.QueryRow(ctx, `
SELECT id, handle, display_name, status
FROM workspace_teams
WHERE workspace_id = $1
  AND channel_id = $2
FOR UPDATE
`, trimmedWorkspaceID, trimmedChannelID).Scan(
		&channel.TeamID,
		&channel.TeamHandle,
		&channel.TeamDisplayName,
		&teamStatus,
	)
	if err != nil && !coredb.IsNotFound(err) {
		return lockedChannelMemberAuthority{}, fmt.Errorf("lock channel member authority team: %w", err)
	}
	if err == nil && strings.TrimSpace(teamStatus) != "active" {
		channel.TeamID = ""
		channel.TeamHandle = ""
		channel.TeamDisplayName = ""
	}
	if channel.Slug != "all" &&
		channel.OwnerMemberID == nil &&
		strings.TrimSpace(channel.TeamID) == "" {
		return lockedChannelMemberAuthority{}, ErrNotFound
	}

	joined := false
	var membershipMarker int
	err = tx.QueryRow(ctx, `
SELECT 1
FROM scope_memberships
WHERE scope_id = $1
  AND member_id = $2
  AND visibility = 'public'
  AND removed_at IS NULL
  AND effective_to_seq IS NULL
ORDER BY effective_from_seq DESC
LIMIT 1
FOR UPDATE
`, trimmedChannelID, workspaceMember.Member.ID).Scan(&membershipMarker)
	switch {
	case err == nil:
		joined = true
	case coredb.IsNotFound(err):
		joined = false
	default:
		return lockedChannelMemberAuthority{}, fmt.Errorf("lock channel member authority membership: %w", err)
	}

	teamManaged := strings.TrimSpace(channel.TeamID) != ""
	isOwner := joined &&
		!teamManaged &&
		channel.OwnerMemberID != nil &&
		strings.TrimSpace(*channel.OwnerMemberID) == strings.TrimSpace(workspaceMember.Member.ID)

	locked.Access = access.HumanChannelAccess{
		WorkspaceMember: workspaceMember,
		Channel:         channel,
		Joined:          joined,
		CanInvite:       joined && !teamManaged,
		IsOwner:         isOwner,
	}
	return locked, nil
}

func lockChannelMemberEventScope(ctx context.Context, tx coredb.Tx, channelID string) error {
	trimmedChannelID := strings.TrimSpace(channelID)
	if trimmedChannelID == "" {
		return badRequest("channelId is required")
	}
	var exists int
	if err := tx.QueryRow(ctx, `
SELECT 1
FROM scopes
WHERE id = $1
  AND type = 'channel'
  AND status = 'active'
  AND archived_at IS NULL
FOR UPDATE
`, trimmedChannelID).Scan(&exists); err != nil {
		if coredb.IsNotFound(err) {
			return ErrNotFound
		}
		return fmt.Errorf("lock channel member event scope: %w", err)
	}
	if preChannelMemberProfileLockHookForTest != nil {
		preChannelMemberProfileLockHookForTest(ctx)
	}
	// Also lock the channel profile row: owner/carrier live in
	// scope_channel_profiles, and TransferChannelOwnershipForUser / carrier flips
	// UPDATE it directly. Without this lock a concurrent transfer can commit a
	// new authority between this fence and the post-lock LoadHumanChannelAccess
	// revalidation, letting a stale owner continue mutating. A missing profile
	// row is not fatal: such a channel has no owner/carrier to revalidate, and
	// the mutation fails authority downstream.
	var profileExists int
	if err := tx.QueryRow(ctx, `
SELECT 1
FROM scope_channel_profiles
WHERE scope_id = $1
FOR UPDATE
`, trimmedChannelID).Scan(&profileExists); err != nil {
		if !coredb.IsNotFound(err) {
			return fmt.Errorf("lock channel member event scope profile: %w", err)
		}
	}
	return nil
}

type channelMemberEventMutation struct {
	Changed bool
	Target  channelMemberEventTarget
}

func stableChannelMemberAddRequests(members []message.ChannelMemberAddRequest) []message.ChannelMemberAddRequest {
	sorted := append([]message.ChannelMemberAddRequest(nil), members...)
	sort.SliceStable(sorted, func(i, j int) bool {
		leftKind, leftID, leftVisibility := channelMemberAddRequestSortKey(sorted[i])
		rightKind, rightID, rightVisibility := channelMemberAddRequestSortKey(sorted[j])
		if leftKind != rightKind {
			return leftKind < rightKind
		}
		if leftID != rightID {
			return leftID < rightID
		}
		return leftVisibility < rightVisibility
	})
	return sorted
}

func channelMemberAddRequestSortKey(member message.ChannelMemberAddRequest) (string, string, string) {
	switch {
	case strings.TrimSpace(member.AgentID) != "":
		return "agent", strings.TrimSpace(member.AgentID), strings.ToLower(strings.TrimSpace(member.Visibility))
	case strings.TrimSpace(member.ServiceMemberID) != "":
		return "service", strings.TrimSpace(member.ServiceMemberID), ""
	case strings.TrimSpace(member.UserID) != "":
		return "human", strings.TrimSpace(member.UserID), ""
	default:
		return "", "", ""
	}
}

func aggregateChannelMemberEventTarget(targets []channelMemberEventTarget) channelMemberEventTarget {
	if len(targets) == 1 {
		return targets[0]
	}
	return channelMemberEventTarget{
		Kind:        "batch",
		DisplayName: channelMemberEventAggregateTargetDisplayName(targets),
	}
}

func channelMemberEventTargetMemberIDs(targets []channelMemberEventTarget) []string {
	if len(targets) == 0 {
		return nil
	}
	seen := make(map[string]struct{}, len(targets))
	memberIDs := make([]string, 0, len(targets))
	for _, target := range targets {
		memberID := strings.TrimSpace(target.MemberID)
		if memberID == "" {
			continue
		}
		if _, ok := seen[memberID]; ok {
			continue
		}
		seen[memberID] = struct{}{}
		memberIDs = append(memberIDs, memberID)
	}
	return memberIDs
}

func channelMemberEventAggregateTargetDisplayName(targets []channelMemberEventTarget) string {
	switch len(targets) {
	case 0:
		return "members"
	case 1:
		return targets[0].DisplayName
	case 2:
		return targets[0].DisplayName + " and " + targets[1].DisplayName
	case 3:
		return targets[0].DisplayName + ", " + targets[1].DisplayName + ", and " + targets[2].DisplayName
	default:
		return fmt.Sprintf("%s, %s, and %d others", targets[0].DisplayName, targets[1].DisplayName, len(targets)-2)
	}
}

func (store *Store) addChannelMemberWithEventInTx(ctx context.Context, tx coredb.Tx, userID string, workspaceID string, channelAccess access.HumanChannelAccess, member message.ChannelMemberAddRequest, now time.Time) (message.ChannelMemberEventResult, error) {
	mutation, err := store.addChannelMemberMutationInTx(ctx, tx, userID, workspaceID, channelAccess, member, now)
	if err != nil {
		return message.ChannelMemberEventResult{}, err
	}
	if !mutation.Changed {
		return message.ChannelMemberEventResult{}, nil
	}
	return store.appendChannelMemberEventMessage(ctx, tx, workspaceID, channelAccess, channelAccess.WorkspaceMember.Member.ID, message.ChannelMemberEventActionAdded, mutation.Target)
}

func (store *Store) addChannelMemberMutationInTx(ctx context.Context, tx coredb.Tx, userID string, workspaceID string, channelAccess access.HumanChannelAccess, member message.ChannelMemberAddRequest, now time.Time) (channelMemberEventMutation, error) {
	switch {
	case strings.TrimSpace(member.AgentID) != "":
		visibility := strings.ToLower(strings.TrimSpace(member.Visibility))
		if visibility == "public" {
			return store.addPublicChannelAgentMutationInTx(ctx, tx, userID, workspaceID, channelAccess, strings.TrimSpace(member.AgentID), now)
		}
		return store.addPrivateChannelAgentMutationInTx(ctx, tx, workspaceID, channelAccess, strings.TrimSpace(member.AgentID), now)
	case strings.TrimSpace(member.ServiceMemberID) != "":
		return store.addChannelServiceMutationInTx(ctx, tx, userID, workspaceID, channelAccess, strings.TrimSpace(member.ServiceMemberID), now)
	case strings.TrimSpace(member.UserID) != "":
		return store.addChannelHumanMutationInTx(ctx, tx, workspaceID, channelAccess, strings.TrimSpace(member.UserID), now)
	}
	return channelMemberEventMutation{}, badRequest("agentId, userId, or serviceMemberId is required for channel member batch")
}

func (store *Store) addPublicChannelAgentWithEventInTx(ctx context.Context, tx coredb.Tx, userID string, workspaceID string, channelAccess access.HumanChannelAccess, agentID string, now time.Time) (message.ChannelMemberEventResult, error) {
	mutation, err := store.addPublicChannelAgentMutationInTx(ctx, tx, userID, workspaceID, channelAccess, agentID, now)
	if err != nil {
		return message.ChannelMemberEventResult{}, err
	}
	if !mutation.Changed {
		return message.ChannelMemberEventResult{}, nil
	}
	return store.appendChannelMemberEventMessage(ctx, tx, workspaceID, channelAccess, channelAccess.WorkspaceMember.Member.ID, message.ChannelMemberEventActionAdded, mutation.Target)
}

func (store *Store) addPublicChannelAgentMutationInTx(ctx context.Context, tx coredb.Tx, userID string, workspaceID string, channelAccess access.HumanChannelAccess, agentID string, now time.Time) (channelMemberEventMutation, error) {
	if strings.TrimSpace(channelAccess.Channel.Slug) == "all" {
		return channelMemberEventMutation{}, conflict("agents in #all must be private")
	}
	agent, err := store.loadAgentEntryOwner(ctx, tx, workspaceID, agentID)
	if err != nil {
		return channelMemberEventMutation{}, err
	}
	if strings.TrimSpace(agent.OwnerUserID) != strings.TrimSpace(userID) {
		return channelMemberEventMutation{}, ErrForbidden
	}
	memberID := agent.AgentMemberID
	if err := store.ensureMemberInWorkspace(ctx, tx, workspaceID, memberID); err != nil {
		return channelMemberEventMutation{}, err
	}
	channelID := strings.TrimSpace(channelAccess.Channel.ChannelID)
	if err := store.ensurePublicChannelAgentEntryAllowed(ctx, tx, channelID, memberID); err != nil {
		return channelMemberEventMutation{}, err
	}
	alreadyActive, err := activeChannelMemberEventMembership(ctx, tx, channelID, memberID, channelAgentEntryVisibilityPublic, "")
	if err != nil {
		return channelMemberEventMutation{}, err
	}
	if err := store.ensureChannelScopeParticipantProjection(ctx, tx, channelID, memberID, "no-wake", now); err != nil {
		return channelMemberEventMutation{}, err
	}
	if err := store.ensurePublicChannelAgentEntryProjection(ctx, tx, workspaceID, channelID, memberID, now); err != nil {
		return channelMemberEventMutation{}, err
	}
	if alreadyActive {
		return channelMemberEventMutation{}, nil
	}
	target, err := store.channelMemberEventTargetByMemberID(ctx, tx, workspaceID, message.ChannelMemberEventTargetAgent, memberID)
	if err != nil {
		return channelMemberEventMutation{}, err
	}
	return channelMemberEventMutation{Changed: true, Target: target}, nil
}

func (store *Store) addPrivateChannelAgentWithEventInTx(ctx context.Context, tx coredb.Tx, userID string, workspaceID string, channelAccess access.HumanChannelAccess, agentID string, now time.Time) (message.ChannelMemberEventResult, error) {
	mutation, err := store.addPrivateChannelAgentMutationInTx(ctx, tx, workspaceID, channelAccess, agentID, now)
	if err != nil {
		return message.ChannelMemberEventResult{}, err
	}
	if !mutation.Changed {
		return message.ChannelMemberEventResult{}, nil
	}
	return store.appendChannelMemberEventMessage(ctx, tx, workspaceID, channelAccess, channelAccess.WorkspaceMember.Member.ID, message.ChannelMemberEventActionAdded, mutation.Target)
}

func (store *Store) addPrivateChannelAgentMutationInTx(ctx context.Context, tx coredb.Tx, workspaceID string, channelAccess access.HumanChannelAccess, agentID string, now time.Time) (channelMemberEventMutation, error) {
	agent, err := store.loadAgentEntryOwner(ctx, tx, workspaceID, agentID)
	if err != nil {
		return channelMemberEventMutation{}, err
	}
	if strings.TrimSpace(agent.OwnerMemberID) != strings.TrimSpace(channelAccess.WorkspaceMember.Member.ID) {
		return channelMemberEventMutation{}, forbidden("only the agent owner can add it as private")
	}
	channelID := strings.TrimSpace(channelAccess.Channel.ChannelID)
	entry, ok, err := store.loadActiveChannelAgentEntry(ctx, tx, channelID, agent.AgentMemberID)
	if err != nil {
		return channelMemberEventMutation{}, err
	}
	if ok {
		if strings.TrimSpace(entry.Visibility) != channelAgentEntryVisibilityPrivate {
			return channelMemberEventMutation{}, conflict("agent already has an active public entry in this channel")
		}
		if strings.TrimSpace(entry.OwnerMemberID.String) != strings.TrimSpace(agent.OwnerMemberID) {
			return channelMemberEventMutation{}, conflict("agent already has an active private entry for another owner")
		}
	}
	alreadyActive, err := activeChannelMemberEventMembership(ctx, tx, channelID, agent.AgentMemberID, channelAgentEntryVisibilityPrivate, agent.OwnerMemberID)
	if err != nil {
		return channelMemberEventMutation{}, err
	}
	if err := store.ensurePrivateChannelAgentEntryProjection(ctx, tx, workspaceID, channelID, agent, now); err != nil {
		return channelMemberEventMutation{}, err
	}
	if alreadyActive {
		return channelMemberEventMutation{}, nil
	}
	target, err := store.channelMemberEventTargetByMemberID(ctx, tx, workspaceID, message.ChannelMemberEventTargetAgent, agent.AgentMemberID)
	if err != nil {
		return channelMemberEventMutation{}, err
	}
	return channelMemberEventMutation{Changed: true, Target: target}, nil
}

func (store *Store) addChannelHumanWithEventInTx(ctx context.Context, tx coredb.Tx, userID string, workspaceID string, channelAccess access.HumanChannelAccess, humanUserID string, now time.Time) (message.ChannelMemberEventResult, error) {
	mutation, err := store.addChannelHumanMutationInTx(ctx, tx, workspaceID, channelAccess, humanUserID, now)
	if err != nil {
		return message.ChannelMemberEventResult{}, err
	}
	if !mutation.Changed {
		return message.ChannelMemberEventResult{}, nil
	}
	return store.appendChannelMemberEventMessage(ctx, tx, workspaceID, channelAccess, channelAccess.WorkspaceMember.Member.ID, message.ChannelMemberEventActionAdded, mutation.Target)
}

func (store *Store) addChannelHumanMutationInTx(ctx context.Context, tx coredb.Tx, workspaceID string, channelAccess access.HumanChannelAccess, humanUserID string, now time.Time) (channelMemberEventMutation, error) {
	if subsystem := strings.TrimSpace(channelAccess.Channel.Subsystem); subsystem != "" && subsystem != message.ChannelSubsystemIM {
		// Product-owned subsystem carriers (including the "kb" channel that
		// contains owner-only Knowledge Q&A threads) are not collaboration
		// surfaces. A generic membership mutation must not expose every private
		// thread under the carrier to another human.
		return channelMemberEventMutation{}, badRequest("subsystem channels cannot be shared with human members")
	}
	row := tx.QueryRow(ctx, `
SELECT id
FROM members
WHERE workspace_id = $1 AND type = 'human' AND user_id = $2 AND status = 'active'
FOR UPDATE
`, workspaceID, humanUserID)
	var memberID string
	if err := row.Scan(&memberID); err != nil {
		if coredb.IsNotFound(err) {
			return channelMemberEventMutation{}, ErrNotFound
		}
		return channelMemberEventMutation{}, fmt.Errorf("load human member id: %w", err)
	}
	if err := store.ensureMemberInWorkspace(ctx, tx, workspaceID, memberID); err != nil {
		return channelMemberEventMutation{}, err
	}
	channelID := strings.TrimSpace(channelAccess.Channel.ChannelID)
	alreadyActive, err := activeChannelMemberEventMembership(ctx, tx, channelID, memberID, channelAgentEntryVisibilityPublic, "")
	if err != nil {
		return channelMemberEventMutation{}, err
	}
	if err := store.ensureChannelScopeParticipantProjection(ctx, tx, channelID, memberID, "no-wake", now); err != nil {
		return channelMemberEventMutation{}, err
	}
	if alreadyActive {
		return channelMemberEventMutation{}, nil
	}
	target, err := store.channelMemberEventTargetByMemberID(ctx, tx, workspaceID, message.ChannelMemberEventTargetHuman, memberID)
	if err != nil {
		return channelMemberEventMutation{}, err
	}
	return channelMemberEventMutation{Changed: true, Target: target}, nil
}

func (store *Store) addChannelServiceWithEventInTx(ctx context.Context, tx coredb.Tx, userID string, workspaceID string, channelAccess access.HumanChannelAccess, serviceMemberID string, now time.Time) (message.ChannelMemberEventResult, error) {
	mutation, err := store.addChannelServiceMutationInTx(ctx, tx, userID, workspaceID, channelAccess, serviceMemberID, now)
	if err != nil {
		return message.ChannelMemberEventResult{}, err
	}
	if !mutation.Changed {
		return message.ChannelMemberEventResult{}, nil
	}
	return store.appendChannelMemberEventMessage(ctx, tx, workspaceID, channelAccess, channelAccess.WorkspaceMember.Member.ID, message.ChannelMemberEventActionAdded, mutation.Target)
}

func (store *Store) addChannelServiceMutationInTx(ctx context.Context, tx coredb.Tx, userID string, workspaceID string, channelAccess access.HumanChannelAccess, serviceMemberID string, now time.Time) (channelMemberEventMutation, error) {
	expectedTarget, err := loadEnabledChannelServiceTarget(ctx, tx, workspaceID, serviceMemberID)
	if err != nil {
		return channelMemberEventMutation{}, err
	}
	return store.addChannelServiceTargetMutationInTx(ctx, tx, userID, workspaceID, channelAccess, expectedTarget, now)
}

func (store *Store) addChannelServiceTargetMutationInTx(ctx context.Context, tx coredb.Tx, userID string, workspaceID string, channelAccess access.HumanChannelAccess, expectedTarget channelServiceTarget, now time.Time) (channelMemberEventMutation, error) {
	memberID := expectedTarget.Member.MemberID
	installation, currentAccess, err := store.authorizeAppChannelServiceAdd(ctx, tx, userID, workspaceID, expectedTarget, channelAccess)
	if err != nil {
		return channelMemberEventMutation{}, err
	}
	if err := store.ensureMemberInWorkspace(ctx, tx, workspaceID, memberID); err != nil {
		return channelMemberEventMutation{}, err
	}
	channelID := strings.TrimSpace(channelAccess.Channel.ChannelID)
	alreadyActive, err := activeChannelMemberEventMembership(ctx, tx, channelID, memberID, channelAgentEntryVisibilityPublic, "")
	if err != nil {
		return channelMemberEventMutation{}, err
	}
	if !alreadyActive {
		if err := store.ensureChannelServiceScopeParticipantProjection(ctx, tx, channelID, memberID, currentAccess.WorkspaceMember.Member.ID, "no-wake", now); err != nil {
			return channelMemberEventMutation{}, err
		}
	}
	if err := store.ensureAppChannelMentionTargetForServiceMemberInTx(ctx, tx, userID, workspaceID, currentAccess, channelID, installation, now); err != nil {
		return channelMemberEventMutation{}, err
	}
	if alreadyActive {
		return channelMemberEventMutation{}, nil
	}
	target, err := store.channelMemberEventTargetByMemberID(ctx, tx, workspaceID, message.ChannelMemberEventTargetService, memberID)
	if err != nil {
		return channelMemberEventMutation{}, err
	}
	return channelMemberEventMutation{Changed: true, Target: target}, nil
}

func (store *Store) AddChannelAgentForUserWithEvent(ctx context.Context, userID string, workspaceID string, channelID string, agentID string) (message.ChannelMemberEventResult, error) {
	channelAccess, err := store.requireChannelInvite(ctx, userID, workspaceID, channelID)
	if err != nil {
		return message.ChannelMemberEventResult{}, err
	}
	if channelAccess.Channel.Type != "channel" {
		return message.ChannelMemberEventResult{}, badRequest("thread channels do not have direct members")
	}
	if strings.TrimSpace(channelAccess.Channel.Slug) == "all" {
		return message.ChannelMemberEventResult{}, conflict("agents in #all must be private")
	}

	row := store.db.QueryRow(ctx, `
SELECT a.member_id, mac.created_by_user_id
FROM agents a
JOIN machines mac
  ON mac.workspace_id = a.workspace_id
 AND mac.id = a.machine_id
WHERE a.workspace_id = $1 AND a.id = $2
  AND a.status <> 'archived'
  AND mac.status = 'active'
`, workspaceID, strings.TrimSpace(agentID))
	var memberID string
	var ownerUserID string
	if err := row.Scan(&memberID, &ownerUserID); err != nil {
		if coredb.IsNotFound(err) {
			return message.ChannelMemberEventResult{}, ErrNotFound
		}
		return message.ChannelMemberEventResult{}, fmt.Errorf("load agent member id: %w", err)
	}
	if strings.TrimSpace(ownerUserID) != strings.TrimSpace(userID) {
		return message.ChannelMemberEventResult{}, ErrForbidden
	}

	if err := store.ensureMemberInWorkspace(ctx, store.db, workspaceID, memberID); err != nil {
		return message.ChannelMemberEventResult{}, err
	}

	now := time.Now().UTC()
	var result message.ChannelMemberEventResult
	if err := runChannelMemberEventTx(ctx, store.db, "add public channel agent with member event", channelID, func(tx coredb.Tx) error {
		result = message.ChannelMemberEventResult{}
		if err := store.ensurePublicChannelAgentEntryAllowed(ctx, tx, channelID, memberID); err != nil {
			return err
		}
		alreadyActive, err := activeChannelMemberEventMembership(ctx, tx, channelID, memberID, channelAgentEntryVisibilityPublic, "")
		if err != nil {
			return err
		}
		if err := store.ensureChannelScopeParticipantProjection(ctx, tx, channelID, memberID, "no-wake", now); err != nil {
			return err
		}
		if err := store.ensurePublicChannelAgentEntryProjection(ctx, tx, workspaceID, channelID, memberID, now); err != nil {
			return err
		}
		if alreadyActive {
			return nil
		}
		target, err := store.channelMemberEventTargetByMemberID(ctx, tx, workspaceID, message.ChannelMemberEventTargetAgent, memberID)
		if err != nil {
			return err
		}
		eventResult, err := store.appendChannelMemberEventMessage(ctx, tx, workspaceID, channelAccess, channelAccess.WorkspaceMember.Member.ID, message.ChannelMemberEventActionAdded, target)
		if err != nil {
			return err
		}
		result = eventResult
		return nil
	}); err != nil {
		return message.ChannelMemberEventResult{}, err
	}
	return result, nil
}

func (store *Store) AddChannelPrivateAgentForUserWithEvent(ctx context.Context, userID string, workspaceID string, channelID string, agentID string) (message.ChannelMemberEventResult, error) {
	channelAccess, err := store.requireChannelInvite(ctx, userID, workspaceID, channelID)
	if err != nil {
		return message.ChannelMemberEventResult{}, err
	}
	if channelAccess.Channel.Type != "channel" {
		return message.ChannelMemberEventResult{}, badRequest("thread channels do not have direct members")
	}

	agent, err := store.loadAgentEntryOwner(ctx, store.db, workspaceID, agentID)
	if err != nil {
		return message.ChannelMemberEventResult{}, err
	}
	if strings.TrimSpace(agent.OwnerMemberID) != strings.TrimSpace(channelAccess.WorkspaceMember.Member.ID) {
		return message.ChannelMemberEventResult{}, forbidden("only the agent owner can add it as private")
	}

	now := time.Now().UTC()
	var result message.ChannelMemberEventResult
	if err := runChannelMemberEventTx(ctx, store.db, "add private channel agent with member event", channelID, func(tx coredb.Tx) error {
		result = message.ChannelMemberEventResult{}
		entry, ok, err := store.loadActiveChannelAgentEntry(ctx, tx, channelID, agent.AgentMemberID)
		if err != nil {
			return err
		}
		if ok {
			if strings.TrimSpace(entry.Visibility) != channelAgentEntryVisibilityPrivate {
				return conflict("agent already has an active public entry in this channel")
			}
			if strings.TrimSpace(entry.OwnerMemberID.String) != strings.TrimSpace(agent.OwnerMemberID) {
				return conflict("agent already has an active private entry for another owner")
			}
		}
		alreadyActive, err := activeChannelMemberEventMembership(ctx, tx, channelID, agent.AgentMemberID, channelAgentEntryVisibilityPrivate, agent.OwnerMemberID)
		if err != nil {
			return err
		}
		if err := store.ensurePrivateChannelAgentEntryProjection(ctx, tx, workspaceID, channelID, agent, now); err != nil {
			return err
		}
		if alreadyActive {
			return nil
		}
		target, err := store.channelMemberEventTargetByMemberID(ctx, tx, workspaceID, message.ChannelMemberEventTargetAgent, agent.AgentMemberID)
		if err != nil {
			return err
		}
		eventResult, err := store.appendChannelMemberEventMessage(ctx, tx, workspaceID, channelAccess, channelAccess.WorkspaceMember.Member.ID, message.ChannelMemberEventActionAdded, target)
		if err != nil {
			return err
		}
		result = eventResult
		return nil
	}); err != nil {
		return message.ChannelMemberEventResult{}, err
	}
	return result, nil
}

func (store *Store) UpdateChannelAgentVisibilityForUserWithEvent(ctx context.Context, userID string, workspaceID string, channelID string, agentID string, visibility string) (message.ChannelMemberEventResult, error) {
	normalizedVisibility := strings.ToLower(strings.TrimSpace(visibility))
	switch normalizedVisibility {
	case channelAgentEntryVisibilityPublic, channelAgentEntryVisibilityPrivate:
	default:
		return message.ChannelMemberEventResult{}, badRequest("agent visibility must be public or private")
	}

	channelAccess, err := store.requireChannelInvite(ctx, userID, workspaceID, channelID)
	if err != nil {
		return message.ChannelMemberEventResult{}, err
	}
	if channelAccess.Channel.Type != "channel" {
		return message.ChannelMemberEventResult{}, badRequest("thread channels do not have direct members")
	}
	if strings.TrimSpace(channelAccess.Channel.Slug) == "all" && normalizedVisibility == channelAgentEntryVisibilityPublic {
		return message.ChannelMemberEventResult{}, conflict("agents in #all must be private")
	}

	agent, err := store.loadAgentEntryOwner(ctx, store.db, workspaceID, agentID)
	if err != nil {
		return message.ChannelMemberEventResult{}, err
	}
	if strings.TrimSpace(agent.OwnerMemberID) != strings.TrimSpace(channelAccess.WorkspaceMember.Member.ID) {
		return message.ChannelMemberEventResult{}, forbidden("only the agent owner can update its channel visibility")
	}

	now := time.Now().UTC()
	var result message.ChannelMemberEventResult
	if err := runChannelMemberEventTx(ctx, store.db, "update channel agent visibility with member event", channelID, func(tx coredb.Tx) error {
		result = message.ChannelMemberEventResult{}
		entry, ok, err := store.loadActiveChannelAgentEntry(ctx, tx, channelID, agent.AgentMemberID)
		if err != nil {
			return err
		}
		if !ok {
			return ErrNotFound
		}
		if strings.TrimSpace(entry.Visibility) == channelAgentEntryVisibilityPrivate && strings.TrimSpace(entry.OwnerMemberID.String) != strings.TrimSpace(agent.OwnerMemberID) {
			return conflict("agent already has an active private entry for another owner")
		}
		if strings.TrimSpace(entry.Visibility) == normalizedVisibility {
			if normalizedVisibility != channelAgentEntryVisibilityPrivate || strings.TrimSpace(entry.OwnerMemberID.String) == strings.TrimSpace(agent.OwnerMemberID) {
				return nil
			}
		}

		switch normalizedVisibility {
		case channelAgentEntryVisibilityPublic:
			if err := store.deletePrivateAgentWakeSubscriptionsForHostChannel(ctx, tx, channelID, agent.AgentMemberID); err != nil {
				return err
			}
			if err := markPrivateAgentScopeMembershipRemoved(ctx, tx, channelID, agent.AgentMemberID, agent.OwnerMemberID, now, nil); err != nil {
				return err
			}
			if err := store.ensureChannelScopeParticipantProjection(ctx, tx, channelID, agent.AgentMemberID, "no-wake", now); err != nil {
				return err
			}
			if err := store.ensurePublicChannelAgentEntryProjection(ctx, tx, workspaceID, channelID, agent.AgentMemberID, now); err != nil {
				return err
			}
		case channelAgentEntryVisibilityPrivate:
			if err := store.markPublicChannelAgentEntryProjectionRemoved(ctx, tx, channelID, agent.AgentMemberID, now, nil); err != nil {
				return err
			}
			if err := store.ensurePrivateChannelAgentEntryProjection(ctx, tx, workspaceID, channelID, agent, now); err != nil {
				return err
			}
		}

		target, err := store.channelMemberEventTargetByMemberID(ctx, tx, workspaceID, message.ChannelMemberEventTargetAgent, agent.AgentMemberID)
		if err != nil {
			return err
		}
		eventResult, err := store.appendChannelMemberEventMessage(ctx, tx, workspaceID, channelAccess, channelAccess.WorkspaceMember.Member.ID, message.ChannelMemberEventActionUpdated, target)
		if err != nil {
			return err
		}
		result = eventResult
		return nil
	}); err != nil {
		return message.ChannelMemberEventResult{}, err
	}
	return result, nil
}

func (store *Store) AddChannelHumanForUserWithEvent(ctx context.Context, userID string, workspaceID string, channelID string, humanUserID string) (message.ChannelMemberEventResult, error) {
	channelAccess, err := store.requireChannelInvite(ctx, userID, workspaceID, channelID)
	if err != nil {
		return message.ChannelMemberEventResult{}, err
	}
	if channelAccess.Channel.Type != "channel" {
		return message.ChannelMemberEventResult{}, badRequest("thread channels do not have direct members")
	}

	row := store.db.QueryRow(ctx, `
SELECT id
FROM members
WHERE workspace_id = $1 AND type = 'human' AND user_id = $2 AND status = 'active'
`, workspaceID, strings.TrimSpace(humanUserID))
	var memberID string
	if err := row.Scan(&memberID); err != nil {
		if coredb.IsNotFound(err) {
			return message.ChannelMemberEventResult{}, ErrNotFound
		}
		return message.ChannelMemberEventResult{}, fmt.Errorf("load human member id: %w", err)
	}

	if err := store.ensureMemberInWorkspace(ctx, store.db, workspaceID, memberID); err != nil {
		return message.ChannelMemberEventResult{}, err
	}

	now := time.Now().UTC()
	var result message.ChannelMemberEventResult
	if err := runChannelMemberEventTx(ctx, store.db, "add channel human with member event", channelID, func(tx coredb.Tx) error {
		result = message.ChannelMemberEventResult{}
		alreadyActive, err := activeChannelMemberEventMembership(ctx, tx, channelID, memberID, channelAgentEntryVisibilityPublic, "")
		if err != nil {
			return err
		}
		if err := store.ensureChannelScopeParticipantProjection(ctx, tx, channelID, memberID, "no-wake", now); err != nil {
			return err
		}
		if alreadyActive {
			return nil
		}
		target, err := store.channelMemberEventTargetByMemberID(ctx, tx, workspaceID, message.ChannelMemberEventTargetHuman, memberID)
		if err != nil {
			return err
		}
		eventResult, err := store.appendChannelMemberEventMessage(ctx, tx, workspaceID, channelAccess, channelAccess.WorkspaceMember.Member.ID, message.ChannelMemberEventActionAdded, target)
		if err != nil {
			return err
		}
		result = eventResult
		return nil
	}); err != nil {
		return message.ChannelMemberEventResult{}, err
	}
	return result, nil
}

func (store *Store) AddChannelServiceForUserWithEvent(ctx context.Context, userID string, workspaceID string, channelID string, serviceMemberID string) (message.ChannelMemberEventResult, error) {
	channelAccess, err := store.requireChannelInvite(ctx, userID, workspaceID, channelID)
	if err != nil {
		return message.ChannelMemberEventResult{}, err
	}
	if channelAccess.Channel.Type != "channel" {
		return message.ChannelMemberEventResult{}, badRequest("thread channels do not have direct members")
	}

	expectedTarget, err := loadEnabledChannelServiceTarget(ctx, store.db, workspaceID, serviceMemberID)
	if err != nil {
		return message.ChannelMemberEventResult{}, err
	}
	memberID := expectedTarget.Member.MemberID

	if err := store.ensureMemberInWorkspace(ctx, store.db, workspaceID, memberID); err != nil {
		return message.ChannelMemberEventResult{}, err
	}

	now := time.Now().UTC()
	var result message.ChannelMemberEventResult
	if err := runChannelMemberEventTx(ctx, store.db, "add channel service with member event", channelID, func(tx coredb.Tx) error {
		result = message.ChannelMemberEventResult{}
		installation, currentAccess, err := store.authorizeAppChannelServiceAdd(ctx, tx, userID, workspaceID, expectedTarget, channelAccess)
		if err != nil {
			return err
		}
		alreadyActive, err := activeChannelMemberEventMembership(ctx, tx, channelID, memberID, channelAgentEntryVisibilityPublic, "")
		if err != nil {
			return err
		}
		if !alreadyActive {
			if err := store.ensureChannelServiceScopeParticipantProjection(ctx, tx, channelID, memberID, currentAccess.WorkspaceMember.Member.ID, "no-wake", now); err != nil {
				return err
			}
		}
		if err := store.ensureAppChannelMentionTargetForServiceMemberInTx(ctx, tx, userID, workspaceID, currentAccess, channelID, installation, now); err != nil {
			return err
		}
		if alreadyActive {
			return nil
		}
		target, err := store.channelMemberEventTargetByMemberID(ctx, tx, workspaceID, message.ChannelMemberEventTargetService, memberID)
		if err != nil {
			return err
		}
		eventResult, err := store.appendChannelMemberEventMessage(ctx, tx, workspaceID, currentAccess, currentAccess.WorkspaceMember.Member.ID, message.ChannelMemberEventActionAdded, target)
		if err != nil {
			return err
		}
		result = eventResult
		return nil
	}); err != nil {
		return message.ChannelMemberEventResult{}, err
	}
	return result, nil
}

func (store *Store) authorizeAppChannelServiceAdd(
	ctx context.Context,
	queryer coredb.QueryRower,
	userID string,
	workspaceID string,
	expected channelServiceTarget,
	channelAccess access.HumanChannelAccess,
) (serviceactor.Installation, access.HumanChannelAccess, error) {
	if !expected.IsApp {
		return serviceactor.Installation{}, access.HumanChannelAccess{}, badRequest("only App App services can be added to channels")
	}
	// Keep the mutation hierarchy stable: definition, current human/channel
	// authority, current workspace attribution, then installation/member tuple.
	if err := installation.LockAppInstallationMutationScope(ctx, queryer, expected.Installation.ServiceDefinitionID); err != nil {
		return serviceactor.Installation{}, access.HumanChannelAccess{}, err
	}
	currentAccess, err := store.revalidateChannelServiceAddActorForUpdate(
		ctx,
		queryer,
		userID,
		workspaceID,
		channelAccess.Channel.ChannelID,
		channelAccess.WorkspaceMember.Member.ID,
	)
	if err != nil {
		return serviceactor.Installation{}, access.HumanChannelAccess{}, err
	}
	appAuthorization, err := lockChannelAppManagerAuthorizationForUpdate(
		ctx,
		queryer,
		workspaceID,
		expected.Installation.ServiceDefinitionID,
		currentAccess.WorkspaceMember,
	)
	if err != nil {
		return serviceactor.Installation{}, access.HumanChannelAccess{}, err
	}
	currentTarget, err := lockCurrentChannelServiceTargetForUpdate(ctx, queryer, expected)
	if err != nil {
		return serviceactor.Installation{}, access.HumanChannelAccess{}, err
	}
	if !appAuthorization.HasAttribution && !appAuthorization.IsProduct {
		return serviceactor.Installation{}, access.HumanChannelAccess{}, ErrNotFound
	}
	if appAuthorization.HasAttribution && !appAuthorization.CanManage && !appAuthorization.IsPublic {
		return serviceactor.Installation{}, access.HumanChannelAccess{}, ErrNotFound
	}
	isAllChannel, err := appMentionDeliveryScopeIsAllChannel(ctx, queryer, workspaceID, currentAccess.Channel.ChannelID)
	if err != nil {
		return serviceactor.Installation{}, access.HumanChannelAccess{}, err
	}
	if isAllChannel {
		return serviceactor.Installation{}, access.HumanChannelAccess{}, badRequest("Apps cannot be added to #all")
	}
	return currentTarget.Installation, currentAccess, nil
}

func (store *Store) ensureAppChannelMentionTargetForServiceMemberInTx(
	ctx context.Context,
	tx coredb.Tx,
	userID string,
	workspaceID string,
	channelAccess access.HumanChannelAccess,
	channelID string,
	installation serviceactor.Installation,
	now time.Time,
) error {
	target, err := ResolveServiceEventTargetForScope(ctx, tx, workspaceID, channelAccess.WorkspaceMember.Member.ID, channelID)
	if err != nil {
		return err
	}
	if _, err := store.bindAppInstallationTargetInTx(ctx, tx, bindAppTargetParams{
		WorkspaceID:        workspaceID,
		ActorMemberID:      channelAccess.WorkspaceMember.Member.ID,
		ActorType:          "human",
		ActorID:            userID,
		InstallationID:     installation.ID,
		Target:             target,
		CreatedVia:         AppMentionCreatedViaChannel,
		EnsureMembership:   false,
		SubscriptionStatus: serviceactor.EventSubscriptionStatusActive,
		Now:                now,
	}); err != nil {
		return err
	}
	return nil
}

func (store *Store) RemoveChannelAgentForUserWithEvent(ctx context.Context, userID string, workspaceID string, channelID string, agentID string) (message.ChannelMemberEventResult, error) {
	agent, err := store.loadAgentEntryOwner(ctx, store.db, workspaceID, agentID)
	if err != nil {
		return message.ChannelMemberEventResult{}, err
	}
	entry, ok, err := store.loadActiveChannelAgentEntry(ctx, store.db, channelID, agent.AgentMemberID)
	if err != nil {
		return message.ChannelMemberEventResult{}, err
	}
	if ok && strings.TrimSpace(entry.Visibility) == channelAgentEntryVisibilityPrivate {
		return store.removeChannelPrivateAgentForUserWithEvent(ctx, userID, workspaceID, channelID, agent)
	}

	channelAccess, err := store.requireChannelMembershipManagement(ctx, userID, workspaceID, channelID)
	if err != nil {
		return message.ChannelMemberEventResult{}, err
	}
	if channelAccess.Channel.Type != "channel" {
		return message.ChannelMemberEventResult{}, badRequest("thread channels do not have direct members")
	}
	if channelAccess.Channel.Slug == "all" {
		return message.ChannelMemberEventResult{}, badRequest("cannot remove members from #all")
	}

	var result message.ChannelMemberEventResult
	if err := runChannelMemberEventTx(ctx, store.db, "remove public channel agent with member event", channelID, func(tx coredb.Tx) error {
		result = message.ChannelMemberEventResult{}
		now := time.Now().UTC()
		wasActive, err := activeChannelMemberEventMembership(ctx, tx, channelID, agent.AgentMemberID, channelAgentEntryVisibilityPublic, "")
		if err != nil {
			return err
		}
		if err := store.markScopeParticipantProjectionRemoved(ctx, tx, channelID, agent.AgentMemberID, now, nil); err != nil {
			return err
		}
		if err := store.markPublicChannelAgentEntryProjectionRemoved(ctx, tx, channelID, agent.AgentMemberID, now, nil); err != nil {
			return err
		}
		if !wasActive {
			return nil
		}
		target, err := store.channelMemberEventTargetByMemberID(ctx, tx, workspaceID, message.ChannelMemberEventTargetAgent, agent.AgentMemberID)
		if err != nil {
			return err
		}
		eventResult, err := store.appendChannelMemberEventMessage(ctx, tx, workspaceID, channelAccess, channelAccess.WorkspaceMember.Member.ID, message.ChannelMemberEventActionRemoved, target)
		if err != nil {
			return err
		}
		result = eventResult
		return nil
	}); err != nil {
		return message.ChannelMemberEventResult{}, err
	}
	return result, nil
}

func (store *Store) removeChannelPrivateAgentForUserWithEvent(ctx context.Context, userID string, workspaceID string, channelID string, agent agentEntryOwner) (message.ChannelMemberEventResult, error) {
	channelAccess, err := store.loadAuthorizedHumanChannelAccess(ctx, store.db, userID, workspaceID, channelID, channelReadAction)
	if err != nil {
		return message.ChannelMemberEventResult{}, err
	}
	if channelAccess.Channel.Type != "channel" {
		return message.ChannelMemberEventResult{}, badRequest("thread channels do not have direct members")
	}
	if channelAccess.Channel.Slug == "all" {
		return message.ChannelMemberEventResult{}, badRequest("cannot remove members from #all")
	}
	if strings.TrimSpace(agent.OwnerMemberID) != strings.TrimSpace(channelAccess.WorkspaceMember.Member.ID) {
		return message.ChannelMemberEventResult{}, forbidden("only the agent owner can remove it as private")
	}

	var result message.ChannelMemberEventResult
	if err := runChannelMemberEventTx(ctx, store.db, "remove private channel agent with member event", channelID, func(tx coredb.Tx) error {
		result = message.ChannelMemberEventResult{}
		now := time.Now().UTC()
		entry, ok, err := store.loadActiveChannelAgentEntry(ctx, tx, channelID, agent.AgentMemberID)
		if err != nil {
			return err
		}
		if !ok || strings.TrimSpace(entry.Visibility) != channelAgentEntryVisibilityPrivate || strings.TrimSpace(entry.OwnerMemberID.String) != strings.TrimSpace(agent.OwnerMemberID) {
			return ErrNotFound
		}
		wasActive, err := activeChannelMemberEventMembership(ctx, tx, channelID, agent.AgentMemberID, channelAgentEntryVisibilityPrivate, agent.OwnerMemberID)
		if err != nil {
			return err
		}
		if err := store.deletePrivateAgentWakeSubscriptionsForHostChannel(ctx, tx, channelID, agent.AgentMemberID); err != nil {
			return err
		}
		if err := markPrivateAgentScopeMembershipRemoved(ctx, tx, channelID, agent.AgentMemberID, agent.OwnerMemberID, now, nil); err != nil {
			return err
		}
		if !wasActive {
			return nil
		}
		target, err := store.channelMemberEventTargetByMemberID(ctx, tx, workspaceID, message.ChannelMemberEventTargetAgent, agent.AgentMemberID)
		if err != nil {
			return err
		}
		eventResult, err := store.appendChannelMemberEventMessage(ctx, tx, workspaceID, channelAccess, channelAccess.WorkspaceMember.Member.ID, message.ChannelMemberEventActionRemoved, target)
		if err != nil {
			return err
		}
		result = eventResult
		return nil
	}); err != nil {
		return message.ChannelMemberEventResult{}, err
	}
	return result, nil
}

func (store *Store) RemoveChannelServiceForUserWithEvent(ctx context.Context, userID string, workspaceID string, channelID string, serviceMemberID string) (message.ChannelMemberEventResult, error) {
	channelAccess, err := store.loadAuthorizedHumanChannelAccess(ctx, store.db, userID, workspaceID, channelID, channelReadAction)
	if err != nil {
		return message.ChannelMemberEventResult{}, err
	}
	if channelAccess.Channel.Type != "channel" {
		return message.ChannelMemberEventResult{}, badRequest("thread channels do not have direct members")
	}
	if channelAccess.Channel.Slug == "all" {
		return message.ChannelMemberEventResult{}, badRequest("cannot remove members from #all")
	}

	expectedTarget, err := loadEnabledChannelServiceTarget(ctx, store.db, workspaceID, serviceMemberID)
	if err != nil {
		return message.ChannelMemberEventResult{}, err
	}
	memberID := expectedTarget.Member.MemberID

	var result message.ChannelMemberEventResult
	if err := runChannelMemberEventTx(ctx, store.db, "remove channel service with member event", channelID, func(tx coredb.Tx) error {
		result = message.ChannelMemberEventResult{}
		// Apps use definition -> current human/channel authority -> current
		// attribution -> installation/member. Ordinary services retain the
		// channel-owner/original-adder path.
		if expectedTarget.IsApp {
			if err := installation.LockAppInstallationMutationScope(ctx, tx, expectedTarget.Installation.ServiceDefinitionID); err != nil {
				return err
			}
		}
		currentAccess, ownerOrAdder, err := store.revalidateChannelServiceRemovalActorForUpdate(
			ctx,
			tx,
			userID,
			workspaceID,
			channelID,
			memberID,
			channelAccess.WorkspaceMember.Member.ID,
		)
		if err != nil {
			return err
		}
		canRemove := ownerOrAdder
		if expectedTarget.IsApp {
			appAuthorization, authorizationErr := lockChannelAppManagerAuthorizationForUpdate(
				ctx,
				tx,
				workspaceID,
				expectedTarget.Installation.ServiceDefinitionID,
				currentAccess.WorkspaceMember,
			)
			if authorizationErr != nil {
				return authorizationErr
			}
			if appAuthorization.HasAttribution {
				canRemove = appAuthorization.CanManage || (appAuthorization.IsPublic && ownerOrAdder)
			}
		}
		currentTarget, err := lockCurrentChannelServiceTargetForUpdate(ctx, tx, expectedTarget)
		if err != nil {
			return err
		}
		if !canRemove {
			return ErrForbidden
		}
		wasActive, err := activeChannelMemberEventMembership(ctx, tx, channelID, memberID, channelAgentEntryVisibilityPublic, "")
		if err != nil {
			return err
		}
		now := time.Now().UTC()
		if err := store.markScopeParticipantProjectionRemoved(ctx, tx, channelID, memberID, now, nil); err != nil {
			return err
		}
		if currentTarget.IsApp {
			target, err := ResolveServiceEventTargetForScope(ctx, tx, workspaceID, currentAccess.WorkspaceMember.Member.ID, channelID)
			if err != nil {
				return err
			}
			if err := store.removeLockedAppInstallationTargetInTx(ctx, tx, workspaceID, currentAccess.WorkspaceMember.Member.ID, currentTarget.Installation, target, AppMentionRemovedViaChannel, now); err != nil {
				return err
			}
		}
		if !wasActive {
			return nil
		}
		target, err := store.channelMemberEventTargetByMemberID(ctx, tx, workspaceID, message.ChannelMemberEventTargetService, memberID)
		if err != nil {
			return err
		}
		eventResult, err := store.appendChannelMemberEventMessage(ctx, tx, workspaceID, currentAccess, currentAccess.WorkspaceMember.Member.ID, message.ChannelMemberEventActionRemoved, target)
		if err != nil {
			return err
		}
		result = eventResult
		return nil
	}); err != nil {
		return message.ChannelMemberEventResult{}, err
	}
	return result, nil
}

func (store *Store) RemoveChannelHumanForUserWithEvent(ctx context.Context, userID string, workspaceID string, channelID string, humanUserID string) (message.ChannelMemberEventResult, error) {
	channelAccess, err := store.requireChannelMembershipManagement(ctx, userID, workspaceID, channelID)
	if err != nil {
		return message.ChannelMemberEventResult{}, err
	}
	if channelAccess.Channel.Type != "channel" {
		return message.ChannelMemberEventResult{}, badRequest("thread channels do not have direct members")
	}
	if channelAccess.Channel.Slug == "all" {
		return message.ChannelMemberEventResult{}, badRequest("cannot remove members from #all")
	}

	row := store.db.QueryRow(ctx, `
SELECT id
FROM members
WHERE workspace_id = $1 AND type = 'human' AND user_id = $2 AND status = 'active'
`, workspaceID, strings.TrimSpace(humanUserID))
	var memberID string
	if err := row.Scan(&memberID); err != nil {
		if coredb.IsNotFound(err) {
			return message.ChannelMemberEventResult{}, ErrNotFound
		}
		return message.ChannelMemberEventResult{}, fmt.Errorf("load human member id: %w", err)
	}
	if channelAccess.IsOwner && memberID == channelAccess.WorkspaceMember.Member.ID {
		return message.ChannelMemberEventResult{}, badRequest("channel owner must transfer ownership before leaving")
	}

	var result message.ChannelMemberEventResult
	if err := runChannelMemberEventTx(ctx, store.db, "remove channel human with member event", channelID, func(tx coredb.Tx) error {
		result = message.ChannelMemberEventResult{}
		removedAt := time.Now().UTC()
		wasActive, err := activeChannelMemberEventMembership(ctx, tx, channelID, memberID, channelAgentEntryVisibilityPublic, "")
		if err != nil {
			return err
		}
		if err := store.markScopeParticipantProjectionRemoved(ctx, tx, channelID, memberID, removedAt, nil); err != nil {
			return err
		}
		if err := store.removePrivateEntriesForOwnerHostChannel(ctx, tx, memberID, channelID, removedAt, nil); err != nil {
			return err
		}
		if !wasActive {
			return nil
		}
		target, err := store.channelMemberEventTargetByMemberID(ctx, tx, workspaceID, message.ChannelMemberEventTargetHuman, memberID)
		if err != nil {
			return err
		}
		eventResult, err := store.appendChannelMemberEventMessage(ctx, tx, workspaceID, channelAccess, channelAccess.WorkspaceMember.Member.ID, message.ChannelMemberEventActionRemoved, target)
		if err != nil {
			return err
		}
		result = eventResult
		return nil
	}); err != nil {
		return message.ChannelMemberEventResult{}, err
	}
	return result, nil
}

func (store *Store) TransferChannelOwnershipForUser(ctx context.Context, userID string, workspaceID string, channelID string, humanUserID string) error {
	channelAccess, err := store.requireChannelMembershipManagement(ctx, userID, workspaceID, channelID)
	if err != nil {
		return err
	}
	if channelAccess.Channel.Type != "channel" {
		return badRequest("thread channels do not have direct owners")
	}
	if channelAccess.Channel.Slug == "all" {
		return badRequest("cannot transfer ownership of #all")
	}

	row := store.db.QueryRow(ctx, `
SELECT m.id
FROM members m
JOIN scope_memberships sm
  ON sm.member_id = m.id
 AND sm.scope_id = $2
 AND sm.visibility = 'public'
 AND sm.removed_at IS NULL
 AND sm.archived_at IS NULL
 AND sm.effective_to_seq IS NULL
WHERE m.workspace_id = $1
  AND m.type = 'human'
  AND m.user_id = $3
  AND m.status = 'active'
`, workspaceID, channelID, strings.TrimSpace(humanUserID))

	var nextOwnerMemberID string
	if err := row.Scan(&nextOwnerMemberID); err != nil {
		if coredb.IsNotFound(err) {
			return badRequest("new owner must already be joined to the channel")
		}
		return fmt.Errorf("load next owner member id: %w", err)
	}

	if preChannelOwnershipUpdateHookForTest != nil {
		preChannelOwnershipUpdateHookForTest(ctx)
	}
	result, err := store.db.Exec(ctx, `
UPDATE scope_channel_profiles
SET owner_member_id = $3, updated_at = NOW()
WHERE workspace_id = $1
  AND scope_id = $2
  AND owner_member_id = $4
`, workspaceID, channelID, nextOwnerMemberID, channelAccess.WorkspaceMember.Member.ID)
	if err != nil {
		return fmt.Errorf("transfer channel ownership scope projection: %w", err)
	}
	if result.RowsAffected() == 0 {
		return ErrForbidden
	}
	return nil
}
