// Package channeladmin is the transport-neutral application service for
// `agentteam exec channel`. It owns the Agent-scoped channel management lifecycle:
// resolve the Agent's Human execution owner as the authority principal, run
// direct in-transaction mutations, record a terminal idempotency receipt in the
// same transaction as the mutation, and emit realtime after commit. It has no
// Human-approval gate and no worker: channel CRUD is synchronous and low-stakes
// relative to the Role/Agent/Team approval domains.
package channeladmin

import (
	"context"
	"crypto/sha256"
	"encoding/json"
	"errors"
	"fmt"
	"strconv"
	"strings"
	"time"
	"unicode/utf8"

	"github.com/tidbcloud/AgentTeam/internal/coredb"
	"github.com/tidbcloud/AgentTeam/internal/store/access"
	"github.com/tidbcloud/AgentTeam/internal/store/agent"
	"github.com/tidbcloud/AgentTeam/internal/store/app"
	"github.com/tidbcloud/AgentTeam/internal/store/channel"
	receiptstore "github.com/tidbcloud/AgentTeam/internal/store/channeladmin"
	"github.com/tidbcloud/AgentTeam/internal/store/message"
	"github.com/tidbcloud/AgentTeam/internal/store/plan"
	"github.com/tidbcloud/AgentTeam/internal/store/storeerr"
	"github.com/tidbcloud/AgentTeam/internal/store/workspace"
	"github.com/tidbcloud/AgentTeam/internal/unread"
)

// ChannelRealtime is the narrow realtime port the service emits after a
// committed channel mutation. Replay of a cached receipt never emits.
type ChannelRealtime interface {
	EmitChannelUpdated(workspaceID string)
	EmitChannelMembersUpdated(workspaceID string, channelID string)
	SubscribeScopeForUser(workspaceID string, userID string, scopeID string)
	UnsubscribeScopeForUser(workspaceID string, userID string, scopeID string)
	ClearAgentWakeStatusesForChannel(workspaceID string, channelID string, agentID string)
	EmitUnreadUpdated(workspaceID string, delta unread.UnreadDelta)
}

// UnreadInvalidator invalidates cached unread state for a (scope, member) after a
// membership change and returns the computed deltas so the caller can emit them.
// *unreadruntime.Service satisfies this.
type UnreadInvalidator interface {
	ApplyScopeInvalidatedWithDeltas(event unread.ScopeInvalidated) ([]unread.UnreadDelta, error)
}

// AgentOwnerResolver resolves the Human user that manages an Agent (its
// execution owner). The owner is the authority principal and the recorded
// channel owner for Agent-driven channel mutations.
type AgentOwnerResolver interface {
	AgentManagedByUser(ctx context.Context, workspaceID string, agentID string) (string, error)
}

// WorkspaceMembershipResolver resolves the workspace membership for a user.
// *app.Store satisfies this via GetWorkspaceForUser.
type WorkspaceMembershipResolver interface {
	GetWorkspaceForUser(ctx context.Context, userID string, workspaceID string) (workspace.Summary, error)
}

// prePrincipalLockHookForTest is a test-only seam invoked inside resolveOwnerInTx
// after owner resolution and immediately before the Agent principal is
// locked+revalidated. It receives the caller's tx so deterministic tests can
// prove the receipt-first fence: the idempotency receipt placeholder must
// already be reserved on the tx before the principal lock is taken (so the
// receipt primary key — not the principal lock — is the first concurrency
// fence, and an exact replay after owner transfer/offboard returns the stored
// result without ever reaching the principal lock). It is nil in production.
var prePrincipalLockHookForTest func(context.Context, coredb.Tx)

// ChannelStore is the narrow channel-store seam the service needs. The concrete
// *channel.Store satisfies it; tests inject a fake to observe mutation counts.
type ChannelStore interface {
	CreateChannelInTx(ctx context.Context, tx coredb.Tx, workspaceID string, ownerMemberID string, name string, description string, visibility string) (message.ChannelSummary, error)
	ListManagedIMChannelsForUserPage(ctx context.Context, userID string, workspaceID string, options channel.ManagedIMChannelListOptions) (channel.ManagedIMChannelPage, error)
}

// ChannelAppStore is the narrow app-store seam the service needs for member
// reads and the canonical profile update. The concrete *app.Store satisfies it.
type ChannelAppStore interface {
	ChannelMembersForUserPage(ctx context.Context, userID string, workspaceID string, channelID string, options message.ChannelMemberListOptions) (message.ChannelMemberList, error)
	UpdateChannelProfileInTx(ctx context.Context, tx coredb.Tx, workspaceID string, channelID string, actorUserID string, policy app.ChannelUpdatePolicy, expectedVersion *int64, name *string, description *string, visibility *string) (message.ChannelSummary, int64, error)
	BatchAddChannelMembersInTx(ctx context.Context, tx coredb.Tx, workspaceID string, channelID string, authorityUserID string, actorMemberID string, members []message.ChannelMemberAddRequest, now time.Time, authorityPolicy app.ChannelMemberAuthorityPolicy, effects *app.ChannelMemberMutationEffects) ([]message.ChannelMemberEventResult, error)
	BatchRemoveChannelMembersInTx(ctx context.Context, tx coredb.Tx, workspaceID string, channelID string, authorityUserID string, actorMemberID string, refs []app.ChannelMemberExecReference, now time.Time, effects *app.ChannelMemberMutationEffects) ([]message.ChannelMemberEventResult, error)
	ListRealtimeRoomIDsForChannel(ctx context.Context, workspaceID string, channelID string) ([]string, error)
}

// Dependencies are the collaborators the Channel management service needs.
type Dependencies struct {
	DB         coredb.DB
	Channels   ChannelStore
	Apps       ChannelAppStore
	Owners     AgentOwnerResolver
	Workspaces WorkspaceMembershipResolver
	Realtime   ChannelRealtime
	// MessageFanout, when set, projects committed system messages after a mutation.
	MessageFanout app.CommittedMessageFanout
	// Unread, when set, invalidates cached unread state for changed scopes/members.
	Unread UnreadInvalidator
}

// Service is the Channel management application service.
type Service struct{ Dependencies }

// New constructs the Channel management service.
func New(deps Dependencies) *Service { return &Service{Dependencies: deps} }

// ChannelAction labels a Channel Exec mutation for the idempotency receipt.
type ChannelAction string

const (
	ActionChannelCreate       ChannelAction = "create"
	ActionChannelUpdate       ChannelAction = "update"
	ActionChannelMemberAdd    ChannelAction = "member_add"
	ActionChannelMemberRemove ChannelAction = "member_remove"
)

// maxChannelAgentsPerMutation caps a single agent-add/remove batch. The
// Desktop Exec contract mirrors this via JSONSchema maxItems; the service check
// is the authoritative server-side cap for direct Agent API callers that bypass
// the Desktop client.
const maxChannelAgentsPerMutation = 100

// Owner is the resolved Human execution owner of an Agent.
type Owner struct {
	UserID   string
	MemberID string
}

// ChannelView is the service-level channel projection returned to the handler.
type ChannelView struct {
	ChannelID   string
	Slug        string
	DisplayName string
	Description string
	Visibility  string
	Version     int64
}

// MemberView is the service-level channel member projection.
type MemberView struct {
	MemberID    string
	DisplayName string
	Kind        string
}

// ListResult is the result of List.
type ListResult struct {
	Channels   []ChannelView
	HasMore    bool
	NextCursor string
	TotalCount int
}

// GetResult is the result of Get.
type GetResult struct {
	Channel ChannelView
}

// MembersResult is the result of Members.
type MembersResult struct {
	ChannelID  string
	Members    []MemberView
	HasMore    bool
	NextCursor string
	TotalCount int
}

// CreateResult is the result of Create.
type CreateResult struct {
	Channel ChannelView
}

// UpdateResult is the result of Update.
type UpdateResult struct {
	Channel ChannelView
}

// MemberMutationResult is the result of AgentAdd / AgentRemove.
type MemberMutationResult struct {
	ChannelID string
	Members   []MemberView
}

// PageInput is the bounded continuation input shared by channel list and
// members. Cursor is an opaque transport string whose V1 encoding is a
// non-negative decimal offset.
type PageInput struct {
	Limit  int
	Cursor string
}

// CreateInput is the input to Create.
type CreateInput struct {
	RequestID   string
	DisplayName string
	Description *string
	Visibility  string
}

// UpdateInput is the input to Update.
type UpdateInput struct {
	RequestID       string
	ChannelID       string
	ExpectedVersion int64
	DisplayName     *string
	Description     *string
	Visibility      *string
}

// resolveOwner resolves the Agent's Human execution owner.
func (s *Service) resolveOwner(ctx context.Context, agent agent.AuthenticatedAgent) (Owner, error) {
	ownerUserID, err := s.Owners.AgentManagedByUser(ctx, agent.WorkspaceID, agent.AgentID)
	if err != nil {
		return Owner{}, mapOwnerError(err)
	}
	ownerUserID = strings.TrimSpace(ownerUserID)
	if ownerUserID == "" {
		return Owner{}, ErrAgentOwnerMissing
	}
	membership, err := s.Workspaces.GetWorkspaceForUser(ctx, ownerUserID, agent.WorkspaceID)
	if err != nil {
		return Owner{}, mapOwnerError(err)
	}
	memberID := strings.TrimSpace(membership.MemberID)
	if memberID == "" || strings.TrimSpace(membership.WorkspaceID) != strings.TrimSpace(agent.WorkspaceID) {
		return Owner{}, ErrAgentOwnerMissing
	}
	return Owner{UserID: ownerUserID, MemberID: memberID}, nil
}

// resolveOwnerInTx resolves the Agent's Human execution owner within the caller's
// transaction and locks+revalidates the full Agent principal (Human manager
// member, machine, agent, agent member) in canonical offboarding lock order via
// access.LockAndRevalidateAgentPrincipalManager. This fences concurrent
// offboard/archive/transfer so a stale principal cannot mutate. Mutations use
// this tx-scoped resolver; reads keep the non-tx resolveOwner.
func (s *Service) resolveOwnerInTx(ctx context.Context, tx coredb.Tx, agent agent.AuthenticatedAgent) (Owner, error) {
	ownerUserID, err := access.AgentManagedByUser(ctx, tx, agent.WorkspaceID, agent.AgentID)
	if err != nil {
		return Owner{}, mapOwnerError(err)
	}
	ownerUserID = strings.TrimSpace(ownerUserID)
	if ownerUserID == "" {
		return Owner{}, ErrAgentOwnerMissing
	}
	membership, err := access.LoadHumanWorkspaceMember(ctx, tx, ownerUserID, agent.WorkspaceID)
	if err != nil {
		return Owner{}, mapOwnerError(err)
	}
	ownerMemberID := strings.TrimSpace(membership.Member.ID)
	if ownerMemberID == "" {
		return Owner{}, ErrAgentOwnerMissing
	}
	if prePrincipalLockHookForTest != nil {
		prePrincipalLockHookForTest(ctx, tx)
	}
	if err := access.LockAndRevalidateAgentPrincipalManager(ctx, tx,
		access.AuthenticatedAgentPrincipal{WorkspaceID: agent.WorkspaceID, AgentID: agent.AgentID, MemberID: agent.MemberID, MachineID: agent.MachineID},
		access.HumanManager{UserID: ownerUserID, MemberID: ownerMemberID},
	); err != nil {
		if errors.Is(err, storeerr.ErrForbidden) || errors.Is(err, storeerr.ErrNotFound) {
			return Owner{}, ErrForbidden
		}
		return Owner{}, err
	}
	return Owner{UserID: ownerUserID, MemberID: ownerMemberID}, nil
}

// List returns the channels visible to the Agent's owner.
func (s *Service) List(ctx context.Context, agent agent.AuthenticatedAgent, input PageInput) (ListResult, error) {
	limit, _, err := normalizeChannelPageInput(input)
	if err != nil {
		return ListResult{}, err
	}
	owner, err := s.resolveOwner(ctx, agent)
	if err != nil {
		return ListResult{}, err
	}
	page, err := s.Channels.ListManagedIMChannelsForUserPage(ctx, owner.UserID, agent.WorkspaceID, channel.ManagedIMChannelListOptions{
		Limit:  limit,
		Cursor: strings.TrimSpace(input.Cursor),
	})
	if err != nil {
		return ListResult{}, err
	}
	views := make([]ChannelView, 0, len(page.Channels))
	for _, summary := range page.Channels {
		views = append(views, s.channelViewFromSummary(ctx, summary))
	}
	return ListResult{
		Channels:   views,
		HasMore:    page.HasMore,
		NextCursor: page.NextCursor,
		TotalCount: page.TotalCount,
	}, nil
}

// Get returns one channel's profile and optimistic-concurrency version.
func (s *Service) Get(ctx context.Context, agent agent.AuthenticatedAgent, channelID string) (GetResult, error) {
	owner, err := s.resolveOwner(ctx, agent)
	if err != nil {
		return GetResult{}, err
	}
	view, err := s.loadChannelViewForOwner(ctx, agent.WorkspaceID, strings.TrimSpace(channelID), owner)
	if err != nil {
		return GetResult{}, err
	}
	return GetResult{Channel: view}, nil
}

// Members returns the members of one channel.
func (s *Service) Members(ctx context.Context, agent agent.AuthenticatedAgent, channelID string, input PageInput) (MembersResult, error) {
	limit, _, err := normalizeChannelPageInput(input)
	if err != nil {
		return MembersResult{}, err
	}
	owner, err := s.resolveOwner(ctx, agent)
	if err != nil {
		return MembersResult{}, err
	}
	trimmedChannelID := strings.TrimSpace(channelID)
	if _, err := requireManagedIMChannelForOwner(ctx, s.DB, agent.WorkspaceID, trimmedChannelID, owner, true); err != nil {
		return MembersResult{}, err
	}
	list, err := s.Apps.ChannelMembersForUserPage(ctx, owner.UserID, agent.WorkspaceID, trimmedChannelID, message.ChannelMemberListOptions{
		Limit:  limit,
		Cursor: strings.TrimSpace(input.Cursor),
	})
	if err != nil {
		return MembersResult{}, err
	}
	members := make([]MemberView, 0, len(list.Agents)+len(list.Humans)+len(list.Services)+len(list.PrivateAgents))
	for _, member := range list.Agents {
		members = append(members, MemberView{MemberID: member.MemberID, DisplayName: member.DisplayName, Kind: "agent"})
	}
	for _, member := range list.Humans {
		members = append(members, MemberView{MemberID: member.MemberID, DisplayName: member.DisplayName, Kind: "human"})
	}
	for _, member := range list.Services {
		members = append(members, MemberView{MemberID: member.MemberID, DisplayName: member.DisplayName, Kind: "service"})
	}
	for _, member := range list.PrivateAgents {
		members = append(members, MemberView{MemberID: member.MemberID, DisplayName: member.DisplayName, Kind: "agent"})
	}
	return MembersResult{
		ChannelID:  trimmedChannelID,
		Members:    members,
		HasMore:    list.HasMore,
		NextCursor: list.NextCursor,
		TotalCount: list.TotalCount,
	}, nil
}

func normalizeChannelPageInput(input PageInput) (limit int, offset int, err error) {
	limit = input.Limit
	if limit == 0 {
		limit = message.DefaultChannelMemberListLimit
	}
	if limit < 1 || limit > message.MaxChannelMemberListLimit {
		return 0, 0, ErrValidation
	}
	cursor := strings.TrimSpace(input.Cursor)
	if cursor == "" {
		return limit, 0, nil
	}
	offset, parseErr := strconv.Atoi(cursor)
	if parseErr != nil || offset < 0 {
		return 0, 0, ErrValidation
	}
	return limit, offset, nil
}

// receiptReplayOutcome classifies a committed-receipt lookup, shared by the
// pre-lookup (before the mutation tx) and the loser-lookup (after a receipt PK
// conflict) so the two paths cannot drift.
type receiptReplayOutcome int

const (
	receiptReplayMiss     receiptReplayOutcome = iota // not committed yet -> proceed with the mutation tx
	receiptReplayExact                                // committed with same action+digest -> replay stored result, skip the tx
	receiptReplayConflict                             // committed with a different action/digest -> ErrRequestIdConflict
)

// classifyCommittedReceipt is the single judgment shared by the pre-lookup and
// the loser-lookup. A lookup error or a corrupt stored result is an internal
// error (it must NOT surface as 409, or a client retry with a fresh request id
// would risk a duplicate mutation); only a found receipt whose action or digest
// differs is a request-id conflict.
func classifyCommittedReceipt(prev receiptstore.Receipt, found bool, lookupErr error, action string, digest []byte) (receiptReplayOutcome, error) {
	if lookupErr != nil {
		return 0, fmt.Errorf("lookup idempotency receipt: %w", lookupErr)
	}
	if !found {
		return receiptReplayMiss, nil
	}
	if prev.Action != action || !equalDigest(prev.RequestDigest, digest) {
		return receiptReplayConflict, nil
	}
	return receiptReplayExact, nil
}

// classifyReceipt performs the read-only receipt lookup + shared classification
// used by both the pre-lookup and the loser-lookup.
func (s *Service) classifyReceipt(ctx context.Context, workspaceID string, agentID string, requestID string, action string, digest []byte) (receiptReplayOutcome, receiptstore.Receipt, error) {
	prev, found, lookupErr := receiptstore.LookupReceipt(ctx, s.DB, workspaceID, agentID, requestID)
	outcome, err := classifyCommittedReceipt(prev, found, lookupErr, action, digest)
	return outcome, prev, err
}

// Create creates a new channel owned by the Agent's owner.
func (s *Service) Create(ctx context.Context, agent agent.AuthenticatedAgent, input CreateInput) (CreateResult, error) {
	requestID := strings.TrimSpace(input.RequestID)
	if err := validateRequestID(requestID); err != nil {
		return CreateResult{}, err
	}
	if err := validateDisplayName(input.DisplayName); err != nil {
		return CreateResult{}, err
	}
	visibility, visibilityErr := validateVisibility(input.Visibility)
	if visibilityErr != nil {
		return CreateResult{}, visibilityErr
	}
	description := derefString(input.Description)
	digest := digestCreate(input)
	action := string(ActionChannelCreate)

	// Receipt-first: a committed receipt for (agent, requestID) is the FIRST
	// concurrency fence, checked BEFORE the mutation tx — so an exact replay
	// never reaches the principal lock and still returns the stored result after
	// an owner transfer/offboard. ownership.go requires the exact-replay check
	// precede LockAndRevalidateAgentPrincipalManager.
	outcome, prev, err := s.classifyReceipt(ctx, agent.WorkspaceID, agent.AgentID, requestID, action, digest)
	if err != nil {
		return CreateResult{}, err
	}
	switch outcome {
	case receiptReplayExact:
		var replayed ChannelView
		if unmarshalErr := json.Unmarshal([]byte(prev.ResultJSON), &replayed); unmarshalErr != nil {
			return CreateResult{}, fmt.Errorf("decode replayed create receipt: %w", unmarshalErr)
		}
		return CreateResult{Channel: replayed}, nil
	case receiptReplayConflict:
		return CreateResult{}, ErrRequestIdConflict
	}

	var (
		mutated     bool
		createdView ChannelView
		ownerUserID string
	)
	err = coredb.WithTx(ctx, s.DB, func(tx coredb.Tx) error {
		// First write in the tx: reserve the idempotency key with a placeholder.
		// A concurrent duplicate collides on the receipt PK BEFORE any principal
		// lock is taken, so the receipt (not the principal lock) is the first
		// fence. The placeholder rolls back with the tx if anything downstream
		// fails, leaving no orphan receipt.
		if reserveErr := receiptstore.RecordReceipt(ctx, tx, receiptstore.Receipt{
			WorkspaceID: agent.WorkspaceID, ApplicantAgentID: agent.AgentID, RequestID: requestID,
			Action: action, RequestDigest: digest, ResultJSON: "{}", CreatedAt: time.Now().UTC(),
		}); reserveErr != nil {
			if coredb.IsUniqueViolation(reserveErr) {
				return errReceiptPKConflict
			}
			return reserveErr
		}
		// This tx owns the key. Re-resolve the Human owner inside the tx (locks +
		// revalidates the Agent principal), run the mutation, then persist result.
		owner, err := s.resolveOwnerInTx(ctx, tx, agent)
		if err != nil {
			return err
		}
		ownerUserID = owner.UserID
		summary, createErr := s.Channels.CreateChannelInTx(ctx, tx, agent.WorkspaceID, owner.MemberID, input.DisplayName, description, visibility)
		if createErr != nil {
			return mapCreateError(createErr)
		}
		createdView = s.channelViewFromSummary(ctx, summary)
		createdView.Version = 1
		encoded, encodeErr := json.Marshal(createdView)
		if encodeErr != nil {
			return encodeErr
		}
		if err := receiptstore.SetReceiptResult(ctx, tx, agent.WorkspaceID, agent.AgentID, requestID, string(encoded)); err != nil {
			return err
		}
		mutated = true
		return nil
	})
	if err != nil {
		if errors.Is(err, errReceiptPKConflict) {
			// Loser: a concurrent same-key winner committed first. Replay its result
			// via the shared classification (lookup/decode errors propagate as
			// internal, not 409; only a found mismatched receipt is a conflict).
			loserOutcome, loserPrev, lookupErr := s.classifyReceipt(ctx, agent.WorkspaceID, agent.AgentID, requestID, action, digest)
			if lookupErr != nil {
				return CreateResult{}, lookupErr
			}
			switch loserOutcome {
			case receiptReplayExact:
				var replayed ChannelView
				if unmarshalErr := json.Unmarshal([]byte(loserPrev.ResultJSON), &replayed); unmarshalErr != nil {
					return CreateResult{}, fmt.Errorf("decode replayed create receipt: %w", unmarshalErr)
				}
				return CreateResult{Channel: replayed}, nil
			case receiptReplayConflict:
				return CreateResult{}, ErrRequestIdConflict
			case receiptReplayMiss:
				return CreateResult{}, errReceiptReservedNotVisible
			}
		}
		return CreateResult{}, err
	}
	if mutated && s.Realtime != nil {
		s.Realtime.SubscribeScopeForUser(agent.WorkspaceID, ownerUserID, createdView.ChannelID)
		s.Realtime.EmitChannelUpdated(agent.WorkspaceID)
	}
	return CreateResult{Channel: createdView}, nil
}

// Update updates one channel's profile or visibility with optimistic concurrency.
func (s *Service) Update(ctx context.Context, agent agent.AuthenticatedAgent, input UpdateInput) (UpdateResult, error) {
	channelID := strings.TrimSpace(input.ChannelID)
	requestID := strings.TrimSpace(input.RequestID)
	if channelID == "" {
		return UpdateResult{}, ErrValidation
	}
	if err := validateRequestID(requestID); err != nil {
		return UpdateResult{}, err
	}
	if input.ExpectedVersion <= 0 {
		return UpdateResult{}, ErrValidation
	}
	if input.DisplayName == nil && input.Description == nil && input.Visibility == nil {
		return UpdateResult{}, ErrValidation
	}
	if input.DisplayName != nil {
		if err := validateDisplayName(*input.DisplayName); err != nil {
			return UpdateResult{}, err
		}
	}
	if input.Visibility != nil {
		if _, visibilityErr := validateVisibility(*input.Visibility); visibilityErr != nil {
			return UpdateResult{}, visibilityErr
		}
	}
	digest := digestUpdate(input)
	action := string(ActionChannelUpdate)

	// Receipt-first pre-lookup before the tx (see Create).
	outcome, prev, err := s.classifyReceipt(ctx, agent.WorkspaceID, agent.AgentID, requestID, action, digest)
	if err != nil {
		return UpdateResult{}, err
	}
	switch outcome {
	case receiptReplayExact:
		var replayed ChannelView
		if unmarshalErr := json.Unmarshal([]byte(prev.ResultJSON), &replayed); unmarshalErr != nil {
			return UpdateResult{}, fmt.Errorf("decode replayed update receipt: %w", unmarshalErr)
		}
		return UpdateResult{Channel: replayed}, nil
	case receiptReplayConflict:
		return UpdateResult{}, ErrRequestIdConflict
	}

	var (
		mutated     bool
		updatedView ChannelView
	)
	err = coredb.WithTx(ctx, s.DB, func(tx coredb.Tx) error {
		// Reserve the idempotency key FIRST (see Create): the receipt PK, not the
		// principal lock, is the first fence.
		if reserveErr := receiptstore.RecordReceipt(ctx, tx, receiptstore.Receipt{
			WorkspaceID: agent.WorkspaceID, ApplicantAgentID: agent.AgentID, RequestID: requestID,
			Action: action, RequestDigest: digest, ResultJSON: "{}", CreatedAt: time.Now().UTC(),
		}); reserveErr != nil {
			if coredb.IsUniqueViolation(reserveErr) {
				return errReceiptPKConflict
			}
			return reserveErr
		}
		owner, err := s.resolveOwnerInTx(ctx, tx, agent)
		if err != nil {
			return err
		}
		expectedVersion := input.ExpectedVersion
		summary, newVersion, updateErr := s.Apps.UpdateChannelProfileInTx(ctx, tx, agent.WorkspaceID, channelID, owner.UserID, app.ManagedIMExecUpdate, &expectedVersion, input.DisplayName, input.Description, input.Visibility)
		if updateErr != nil {
			return mapAppUpdateError(updateErr)
		}
		updatedView = ChannelView{
			ChannelID: summary.ChannelID, Slug: summary.Slug, DisplayName: summary.DisplayName,
			Description: summary.Description, Visibility: summary.Visibility, Version: newVersion,
		}
		encoded, encodeErr := json.Marshal(updatedView)
		if encodeErr != nil {
			return encodeErr
		}
		if err := receiptstore.SetReceiptResult(ctx, tx, agent.WorkspaceID, agent.AgentID, requestID, string(encoded)); err != nil {
			return err
		}
		mutated = true
		return nil
	})
	if err != nil {
		if errors.Is(err, errReceiptPKConflict) {
			loserOutcome, loserPrev, lookupErr := s.classifyReceipt(ctx, agent.WorkspaceID, agent.AgentID, requestID, action, digest)
			if lookupErr != nil {
				return UpdateResult{}, lookupErr
			}
			switch loserOutcome {
			case receiptReplayExact:
				var replayed ChannelView
				if unmarshalErr := json.Unmarshal([]byte(loserPrev.ResultJSON), &replayed); unmarshalErr != nil {
					return UpdateResult{}, fmt.Errorf("decode replayed update receipt: %w", unmarshalErr)
				}
				return UpdateResult{Channel: replayed}, nil
			case receiptReplayConflict:
				return UpdateResult{}, ErrRequestIdConflict
			case receiptReplayMiss:
				return UpdateResult{}, errReceiptReservedNotVisible
			}
		}
		return UpdateResult{}, err
	}
	if mutated && s.Realtime != nil {
		s.Realtime.EmitChannelUpdated(agent.WorkspaceID)
	}
	return UpdateResult{Channel: updatedView}, nil
}

// AgentAdd adds agent members to one channel via the canonical batch primitive
// with receipt-first idempotency. The Agent member is the event author; the
// resolved Human owner provides authority. Human members are intentionally not
// addable by an Agent — humans join channels through the share-card self-service
// flow, never by direct Agent mutation.
func (s *Service) AgentAdd(ctx context.Context, agent agent.AuthenticatedAgent, channelID string, requestID string, agents []AgentReference) (MemberMutationResult, error) {
	return s.mutateAgents(ctx, agent, channelID, requestID, agents, message.ChannelMemberEventActionAdded)
}

// AgentRemove removes agent members from one channel via the canonical batch
// primitive with receipt-first idempotency.
func (s *Service) AgentRemove(ctx context.Context, agent agent.AuthenticatedAgent, channelID string, requestID string, agents []AgentReference) (MemberMutationResult, error) {
	return s.mutateAgents(ctx, agent, channelID, requestID, agents, message.ChannelMemberEventActionRemoved)
}

func (s *Service) mutateAgents(ctx context.Context, agent agent.AuthenticatedAgent, channelID string, requestID string, agents []AgentReference, action string) (MemberMutationResult, error) {
	channelID = strings.TrimSpace(channelID)
	requestID = strings.TrimSpace(requestID)
	if channelID == "" {
		return MemberMutationResult{}, ErrValidation
	}
	if err := validateRequestID(requestID); err != nil {
		return MemberMutationResult{}, err
	}
	if len(agents) == 0 {
		return MemberMutationResult{}, ErrValidation
	}
	if len(agents) > maxChannelAgentsPerMutation {
		return MemberMutationResult{}, ErrValidation
	}
	for _, a := range agents {
		if strings.TrimSpace(a.AgentID) == "" {
			return MemberMutationResult{}, ErrValidation
		}
	}
	// The receipt action stays on the canonical member_add/member_remove labels
	// (not the public agent-add/agent-remove action name): they are the values
	// allowed by the channel_operation_receipts.action CHECK constraint and keep
	// the idempotency-receipt namespace stable across the agent-only rename.
	receiptAction := string(ActionChannelMemberAdd)
	if action == message.ChannelMemberEventActionRemoved {
		receiptAction = string(ActionChannelMemberRemove)
	}
	digest := digestAgentMutation(requestID, channelID, receiptAction, agents)

	// Receipt-first pre-lookup before the tx (see Create).
	outcome, prev, err := s.classifyReceipt(ctx, agent.WorkspaceID, agent.AgentID, requestID, receiptAction, digest)
	if err != nil {
		return MemberMutationResult{}, err
	}
	switch outcome {
	case receiptReplayExact:
		var replayed MemberMutationResult
		if unmarshalErr := json.Unmarshal([]byte(prev.ResultJSON), &replayed); unmarshalErr != nil {
			return MemberMutationResult{}, fmt.Errorf("decode replayed member-mutation receipt: %w", unmarshalErr)
		}
		return replayed, nil
	case receiptReplayConflict:
		return MemberMutationResult{}, ErrRequestIdConflict
	}

	var mutationEffects *app.ChannelMemberMutationEffects
	err = coredb.WithTx(ctx, s.DB, func(tx coredb.Tx) error {
		// Reserve the idempotency key FIRST (see Create): the receipt PK, not the
		// principal lock, is the first fence.
		if reserveErr := receiptstore.RecordReceipt(ctx, tx, receiptstore.Receipt{
			WorkspaceID: agent.WorkspaceID, ApplicantAgentID: agent.AgentID, RequestID: requestID,
			Action: receiptAction, RequestDigest: digest, ResultJSON: "{}", CreatedAt: time.Now().UTC(),
		}); reserveErr != nil {
			if coredb.IsUniqueViolation(reserveErr) {
				return errReceiptPKConflict
			}
			return reserveErr
		}
		owner, err := s.resolveOwnerInTx(ctx, tx, agent)
		if err != nil {
			return err
		}
		if action == message.ChannelMemberEventActionAdded {
			addReqs := make([]message.ChannelMemberAddRequest, 0, len(agents))
			for _, a := range agents {
				addReqs = append(addReqs, message.ChannelMemberAddRequest{AgentID: a.AgentID, Visibility: "public"})
			}
			// BatchAdd locks the channel scope + revalidates authority/carrier
			// internally (no stale-authority TOCTOU). authorityUserID is the
			// resolved Human owner; actorMemberID is the Agent event author.
			mutationEffects = &app.ChannelMemberMutationEffects{}
			_, mutateErr := s.Apps.BatchAddChannelMembersInTx(ctx, tx, agent.WorkspaceID, channelID, owner.UserID, agent.MemberID, addReqs, time.Now().UTC(), app.ManagedOwnerAuthority, mutationEffects)
			if mutateErr != nil {
				return mapMemberMutationError(mutateErr)
			}
		} else {
			removeRefs := make([]app.ChannelMemberExecReference, 0, len(agents))
			for _, a := range agents {
				removeRefs = append(removeRefs, app.ChannelMemberExecReference{AgentID: a.AgentID})
			}
			mutationEffects = &app.ChannelMemberMutationEffects{}
			_, mutateErr := s.Apps.BatchRemoveChannelMembersInTx(ctx, tx, agent.WorkspaceID, channelID, owner.UserID, agent.MemberID, removeRefs, time.Now().UTC(), mutationEffects)
			if mutateErr != nil {
				return mapMemberMutationError(mutateErr)
			}
		}

		resultJSON, encodeErr := json.Marshal(MemberMutationResult{ChannelID: channelID, Members: buildMemberViewsFromDirectMembers(mutationEffects.DirectMembers)})
		if encodeErr != nil {
			return encodeErr
		}
		if err := receiptstore.SetReceiptResult(ctx, tx, agent.WorkspaceID, agent.AgentID, requestID, string(resultJSON)); err != nil {
			return err
		}
		return nil
	})
	if err != nil {
		if errors.Is(err, errReceiptPKConflict) {
			loserOutcome, loserPrev, lookupErr := s.classifyReceipt(ctx, agent.WorkspaceID, agent.AgentID, requestID, receiptAction, digest)
			if lookupErr != nil {
				return MemberMutationResult{}, lookupErr
			}
			switch loserOutcome {
			case receiptReplayExact:
				var replayed MemberMutationResult
				if unmarshalErr := json.Unmarshal([]byte(loserPrev.ResultJSON), &replayed); unmarshalErr != nil {
					return MemberMutationResult{}, fmt.Errorf("decode replayed member-mutation receipt: %w", unmarshalErr)
				}
				return replayed, nil
			case receiptReplayConflict:
				return MemberMutationResult{}, ErrRequestIdConflict
			case receiptReplayMiss:
				return MemberMutationResult{}, errReceiptReservedNotVisible
			}
		}
		return MemberMutationResult{}, err
	}
	if mutationEffects != nil && mutationEffects.HasChanges() {
		postCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
		defer cancel()
		// Side effects run iff the mutation produced real effects (HasChanges), so
		// cleanup-only mutations (empty direct result but real cascade membership
		// changes) still fan out; replay/no-op/rollback have effects==nil /
		// HasChanges()==false and skip entirely. Each port nil-gates independently
		// so one missing dependency does not swallow the others. Side effects use
		// effects captured INSIDE the tx, not re-queried after commit.
		emitChannelMemberMutationEffects(postCtx, s.Dependencies, agent.WorkspaceID, channelID, mutationEffects)
	}
	return MemberMutationResult{ChannelID: channelID, Members: buildMemberViewsFromDirectMembers(mutationEffects.DirectMembers)}, nil
}

// buildMemberViewsFromDirectMembers projects the captured DirectMembers (which
// carry memberId/kind/displayName resolved in the mutation tx) into the wire
// MemberView. The receipt/wire MUST use these so the published Exec JSONSchema
// (kind enum + all fields required) is satisfied; flat affected ids drop
// kind/displayName and violate it.
func buildMemberViewsFromDirectMembers(members []app.DirectMember) []MemberView {
	views := make([]MemberView, 0, len(members))
	for _, member := range members {
		views = append(views, MemberView{MemberID: member.MemberID, Kind: member.Kind, DisplayName: member.DisplayName})
	}
	return views
}

// emitChannelMemberMutationEffects fans out the captured effects of a committed
// channel member mutation. Each port nil-gates independently. Order: project
// committed system messages, then per changed (scope, member) invalidate unread
// and (for a removed Agent) clear wake on the exact scope, then apply human
// subscribe/unsubscribe (rooms resolved once, parent always merged), then the
// members/channel updated signals.
func emitChannelMemberMutationEffects(ctx context.Context, deps Dependencies, workspaceID string, channelID string, effects *app.ChannelMemberMutationEffects) {
	if deps.MessageFanout != nil {
		for _, result := range effects.EventResults {
			if result.Changed {
				deps.MessageFanout.EmitCommittedMessageProjectionOnly(ctx, workspaceID, result.Message, result.Delivery)
			}
		}
	}
	for _, change := range effects.MembershipChanges {
		if deps.Unread != nil {
			deltas, invalidErr := deps.Unread.ApplyScopeInvalidatedWithDeltas(unread.ScopeInvalidated{
				WorkspaceID: workspaceID,
				ScopeID:     change.ScopeID,
				MemberIDs:   []string{change.MemberID},
				Reason:      "channel_member_mutation",
			})
			if invalidErr == nil && deps.Realtime != nil {
				for _, delta := range deltas {
					deps.Realtime.EmitUnreadUpdated(workspaceID, delta)
				}
			}
		}
		// Clear agent wake only on removal: an add/revive must not clear the wake
		// state the add itself may depend on.
		if deps.Realtime != nil && change.Action == message.ChannelMemberEventActionRemoved && change.Kind == message.ChannelMemberEventTargetAgent && change.AgentID != "" {
			deps.Realtime.ClearAgentWakeStatusesForChannel(workspaceID, change.ScopeID, change.AgentID)
		}
	}
	if deps.Realtime != nil {
		// Resolve realtime rooms only when a human is being removed (the only case
		// that unsubscribes); agent-only / add-only mutations skip the room query.
		hasHumanRemove := false
		for _, sub := range effects.HumanSubscriptions {
			if sub.Action == message.ChannelMemberEventActionRemoved {
				hasHumanRemove = true
				break
			}
		}
		var roomIDs []string
		if hasHumanRemove {
			// Agent Exec human removals must arrive with the stable room set
			// captured in the mutation transaction. Never re-query topology after
			// commit: a failed/partial read there would leave child live rooms
			// subscribed with no transaction left to roll back.
			roomIDs = effects.RealtimeRoomIDs
		}
		for _, sub := range effects.HumanSubscriptions {
			if sub.Action == message.ChannelMemberEventActionAdded {
				deps.Realtime.SubscribeScopeForUser(workspaceID, sub.UserID, channelID)
				continue
			}
			if sub.Action != message.ChannelMemberEventActionRemoved {
				continue
			}
			for _, roomID := range roomIDs {
				deps.Realtime.UnsubscribeScopeForUser(workspaceID, sub.UserID, roomID)
			}
		}
		deps.Realtime.EmitChannelMembersUpdated(workspaceID, channelID)
		deps.Realtime.EmitChannelUpdated(workspaceID)
	}
}

func digestAgentMutation(requestID string, channelID string, action string, agents []AgentReference) []byte {
	payload, _ := json.Marshal(struct {
		Action    string           `json:"action"`
		ChannelID string           `json:"channelId"`
		RequestID string           `json:"requestId"`
		Agents    []AgentReference `json:"agents"`
	}{Action: action, ChannelID: channelID, RequestID: requestID, Agents: agents})
	sum := sha256.Sum256(payload)
	return sum[:]
}

// AgentReference identifies one agent member target for an agent-add/remove
// mutation. Human members are not addable by an Agent.
type AgentReference struct {
	AgentID string
}

// channelViewFromSummary projects a message.ChannelSummary into the service view.
// The version is loaded lazily for the detail path; list omits it.
func (s *Service) channelViewFromSummary(ctx context.Context, summary message.ChannelSummary) ChannelView {
	return ChannelView{
		ChannelID:   summary.ChannelID,
		Slug:        summary.Slug,
		DisplayName: summary.DisplayName,
		Description: summary.Description,
		Visibility:  summary.Visibility,
	}
}

// requireManagedIMChannelForOwner is the shared V1 owner-scoped access gate for
// a managed channel. It enforces, through the shared access primitive on the
// caller's queryer: the resolved owner must be Joined; the channel must be a
// plain IM channel (team/KB/project/tutorial carriers are hidden); and #all is
// rejected for mutations (allowAll=false). It is the single gate reused by
// get/members/update/member ops so the plain-IM policy cannot diverge.
func requireManagedIMChannelForOwner(ctx context.Context, queryer coredb.QueryRower, workspaceID string, channelID string, owner Owner, allowAll bool) (access.HumanChannelAccess, error) {
	loaded, err := access.LoadHumanChannelAccess(ctx, queryer, owner.UserID, workspaceID, channelID)
	if err != nil {
		if errors.Is(err, access.ErrNotFound) {
			return access.HumanChannelAccess{}, ErrNotFound
		}
		return access.HumanChannelAccess{}, err
	}
	// LoadHumanChannelAccess is a legacy-private-channel guard, not a read gate:
	// a normal private channel with an owner is returned even when the caller is
	// not joined. Require Joined so an owner cannot read another owner's private
	// IM channel.
	if !loaded.Joined {
		return access.HumanChannelAccess{}, ErrNotFound
	}
	if strings.TrimSpace(loaded.Channel.TeamID) != "" || strings.TrimSpace(loaded.Channel.Subsystem) != "im" {
		return access.HumanChannelAccess{}, ErrNotFound
	}
	if !allowAll && strings.TrimSpace(loaded.Channel.Slug) == "all" {
		return access.HumanChannelAccess{}, ErrValidation
	}
	return loaded, nil
}

// loadChannelViewForOwner loads one channel's profile + version inside a single
// transaction so the Joined gate and the version read share one consistent
// snapshot (no TOCTOU). V1 exposes only plain IM channels the owner has joined.
func (s *Service) loadChannelViewForOwner(ctx context.Context, workspaceID string, channelID string, owner Owner) (ChannelView, error) {
	var view ChannelView
	err := coredb.WithTx(ctx, s.DB, func(tx coredb.Tx) error {
		loaded, err := requireManagedIMChannelForOwner(ctx, tx, workspaceID, channelID, owner, true)
		if err != nil {
			return err
		}
		var version int64
		if err := tx.QueryRow(ctx, `
SELECT version FROM scope_channel_profiles WHERE workspace_id = $1 AND scope_id = $2
`, workspaceID, channelID).Scan(&version); err != nil {
			if coredb.IsNotFound(err) {
				return ErrNotFound
			}
			return fmt.Errorf("load channel version: %w", err)
		}
		view = ChannelView{
			ChannelID:   loaded.Channel.ChannelID,
			Slug:        loaded.Channel.Slug,
			DisplayName: loaded.Channel.DisplayName,
			Description: loaded.Channel.Description,
			Visibility:  loaded.Channel.Visibility,
			Version:     version,
		}
		return nil
	})
	if err != nil {
		return ChannelView{}, err
	}
	return view, nil
}

// mapOwnerError closes dependency-specific owner/offboarding errors into the
// service's stable forbidden classification. Read paths use non-transactional
// resolvers, whose concrete app/access implementations may return either
// storeerr or access sentinels when the Agent, its manager, or the manager's
// workspace membership is inactive.
func mapOwnerError(err error) error {
	switch {
	case err == nil:
		return nil
	case errors.Is(err, ErrAgentOwnerMissing):
		return ErrAgentOwnerMissing
	case errors.Is(err, storeerr.ErrNotFound),
		errors.Is(err, storeerr.ErrForbidden),
		errors.Is(err, access.ErrNotFound),
		errors.Is(err, access.ErrForbidden):
		return ErrAgentOwnerMissing
	default:
		return err
	}
}

// mapCreateError classifies Create-only failures without reusing the update
// CAS mapper. A slug/generic store conflict is not a profile version conflict,
// and plan quota errors are typed errors that do not unwrap to storeerr.
func mapCreateError(err error) error {
	var quotaErr plan.QuotaExceededError
	switch {
	case err == nil:
		return nil
	case channel.IsChannelSlugConflict(err):
		return ErrSlugConflict
	case errors.As(err, &quotaErr):
		return ErrQuotaExceeded
	case errors.Is(err, storeerr.ErrNotFound):
		return ErrNotFound
	case errors.Is(err, storeerr.ErrForbidden):
		return ErrForbidden
	case errors.Is(err, storeerr.ErrConflict):
		return ErrConflict
	case errors.Is(err, storeerr.ErrBadRequest):
		return ErrValidation
	default:
		return err
	}
}

// mapAppUpdateError translates the canonical update primitive's storeerr errors
// into channelmanagement service sentinels so the handler's closed error
// whitelist can classify them.
func mapAppUpdateError(err error) error {
	switch {
	case err == nil:
		return nil
	case errors.Is(err, storeerr.ErrNotFound):
		return ErrNotFound
	case errors.Is(err, storeerr.ErrForbidden):
		return ErrForbidden
	case errors.Is(err, storeerr.ErrConflict):
		return ErrVersionConflict
	case errors.Is(err, storeerr.ErrBadRequest):
		return ErrValidation
	default:
		return err
	}
}

// mapMemberMutationError maps canonical member mutation errors to
// member-appropriate service sentinels. Member conflicts (agent entry,
// duplicate, already-added) map to ErrMemberConflict, not the update CAS
// specific ErrVersionConflict.
func mapMemberMutationError(err error) error {
	switch {
	case err == nil:
		return nil
	case errors.Is(err, storeerr.ErrNotFound):
		return ErrNotFound
	case errors.Is(err, storeerr.ErrForbidden):
		return ErrForbidden
	case errors.Is(err, storeerr.ErrConflict):
		return ErrMemberConflict
	case errors.Is(err, storeerr.ErrBadRequest):
		return ErrValidation
	default:
		return err
	}
}

func normalizeVisibility(raw string) string {
	switch strings.ToLower(strings.TrimSpace(raw)) {
	case "public":
		return "public"
	default:
		return "private"
	}
}

// validateVisibility normalizes an optional channel visibility value. Empty
// resolves to private; any non-empty value must be exactly private or public.
// The raw Agent API is not protected by the Desktop JSONSchema, so the service
// validates strictly instead of silently coercing.
func validateVisibility(raw string) (string, error) {
	switch strings.ToLower(strings.TrimSpace(raw)) {
	case "", "private":
		return "private", nil
	case "public":
		return "public", nil
	default:
		return "", ErrValidation
	}
}

// validateRequestID checks the idempotency key shape used by every mutation.
// Length is counted by Unicode code point (not bytes) to match the Desktop
// JSONSchema maxLength semantics.
func validateRequestID(requestID string) error {
	trimmed := strings.TrimSpace(requestID)
	if trimmed == "" || utf8.RuneCountInString(trimmed) > 128 {
		return ErrValidation
	}
	return nil
}

// validateDisplayName checks the display name before it reaches the store so an
// empty/oversized name is a stable 400 rather than a store error. Length is
// counted by Unicode code point so a valid Chinese name within varchar(255) is
// not rejected by a byte count; this matches the Desktop JSONSchema maxLength.
func validateDisplayName(name string) error {
	trimmed := strings.TrimSpace(name)
	if trimmed == "" || utf8.RuneCountInString(trimmed) > 255 {
		return ErrValidation
	}
	return nil
}

// normalizedVisibilityPointer preserves pointer presence (nil stays nil) while
// normalizing a non-nil visibility to its canonical lowercase form, keeping the
// request digest stable across equivalent inputs (e.g. "PUBLIC" vs "public").
func normalizedVisibilityPointer(value *string) *string {
	if value == nil {
		return nil
	}
	normalized := normalizeVisibility(*value)
	return &normalized
}

func digestCreate(input CreateInput) []byte {
	payload, _ := json.Marshal(struct {
		Action      string  `json:"action"`
		DisplayName string  `json:"displayName"`
		Description *string `json:"description"`
		Visibility  string  `json:"visibility"`
	}{
		Action: string(ActionChannelCreate), DisplayName: strings.TrimSpace(input.DisplayName),
		Description: trimmedStringPointer(input.Description), Visibility: normalizeVisibility(input.Visibility),
	})
	sum := sha256.Sum256(payload)
	return sum[:]
}

func digestUpdate(input UpdateInput) []byte {
	payload, _ := json.Marshal(struct {
		Action          string  `json:"action"`
		ChannelID       string  `json:"channelId"`
		ExpectedVersion int64   `json:"expectedVersion"`
		DisplayName     *string `json:"displayName"`
		Description     *string `json:"description"`
		Visibility      *string `json:"visibility"`
	}{
		Action: string(ActionChannelUpdate), ChannelID: strings.TrimSpace(input.ChannelID),
		ExpectedVersion: input.ExpectedVersion,
		DisplayName:     trimmedStringPointer(input.DisplayName),
		Description:     trimmedStringPointer(input.Description),
		Visibility:      normalizedVisibilityPointer(input.Visibility),
	})
	sum := sha256.Sum256(payload)
	return sum[:]
}

// trimmedStringPointer returns a pointer-preserving, trimmed copy of value: nil
// stays nil (field absent/omitted intent) and a non-nil pointer carries the
// trimmed string, including the empty string (explicit clear). This keeps the
// request digest sensitive to null-vs-empty for *string patch fields.
func trimmedStringPointer(value *string) *string {
	if value == nil {
		return nil
	}
	trimmed := strings.TrimSpace(*value)
	return &trimmed
}

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

func equalDigest(a, b []byte) bool {
	if len(a) != len(b) {
		return false
	}
	for i := range a {
		if a[i] != b[i] {
			return false
		}
	}
	return true
}
