package privateagent

import (
	"bytes"
	"context"
	"encoding/base64"
	"encoding/json"
	"fmt"
	"io"
	"strings"
	"time"

	"github.com/tidbcloud/AgentTeam/internal/coredb"
	"github.com/tidbcloud/AgentTeam/internal/store/message"
)

func (store *Store) GetChannelNotificationPanelForUser(ctx context.Context, userID string, workspaceID string, channelID string, agentCursor string, agentLimit int) (message.ScopeNotificationPanel, error) {
	access, err := store.loadAuthorizedHumanChannelAccess(ctx, store.db, userID, workspaceID, channelID, channelReadAction)
	if err != nil {
		return message.ScopeNotificationPanel{}, err
	}
	if access.Channel.Type != "channel" {
		return message.ScopeNotificationPanel{}, badRequest("thread channels do not have direct notifications")
	}
	status, hostChannelID, err := store.loadScopeStatusAndHostChannel(ctx, store.db, workspaceID, channelID)
	if err != nil {
		return message.ScopeNotificationPanel{}, err
	}
	selfState, err := store.loadScopeNotificationSelfState(ctx, store.db, channelID, access.WorkspaceMember.Member.ID)
	if err != nil {
		return message.ScopeNotificationPanel{}, err
	}
	agents, nextCursor, hasMore, err := store.loadOwnedChannelNotificationAgents(ctx, store.db, workspaceID, hostChannelID, access.WorkspaceMember.Member.ID, agentCursor, agentLimit)
	if err != nil {
		return message.ScopeNotificationPanel{}, err
	}
	readOnly := strings.TrimSpace(status) != "active"
	readOnlyReason := ""
	if readOnly {
		readOnlyReason = scopeNotificationReadOnlyReasonArchived
	}
	return message.ScopeNotificationPanel{
		ScopeID:         strings.TrimSpace(channelID),
		ScopeType:       "channel",
		ReadOnly:        readOnly,
		ReadOnlyReason:  readOnlyReason,
		Self:            message.ScopeNotificationSelfRow{State: selfState},
		Agents:          agents,
		AgentNextCursor: nextCursor,
		AgentHasMore:    hasMore,
	}, nil
}

func (store *Store) SetChannelNotificationSelfStateForUser(ctx context.Context, userID string, workspaceID string, channelID string, state string) (message.ScopeNotificationPanel, error) {
	normalizedState, err := message.NormalizeHumanScopeSubscriptionState(state)
	if err != nil {
		return message.ScopeNotificationPanel{}, err
	}

	tx, err := store.db.BeginTx(ctx, coredb.TxOptions{})
	if err != nil {
		return message.ScopeNotificationPanel{}, fmt.Errorf("begin set channel notification state tx: %w", err)
	}
	defer rollback(tx, ctx)

	access, err := store.loadAuthorizedHumanChannelAccess(ctx, tx, userID, workspaceID, channelID, channelReadAction)
	if err != nil {
		return message.ScopeNotificationPanel{}, err
	}
	if access.Channel.Type != "channel" {
		return message.ScopeNotificationPanel{}, badRequest("thread channels do not have direct notifications")
	}
	status, _, err := store.loadScopeStatusAndHostChannel(ctx, tx, workspaceID, channelID)
	if err != nil {
		return message.ScopeNotificationPanel{}, err
	}
	if strings.TrimSpace(status) != "active" {
		return message.ScopeNotificationPanel{}, badRequest("channel is archived")
	}
	now := time.Now().UTC()
	if err := message.UpsertScopeNotificationState(ctx, tx, access.WorkspaceMember.Member.ID, channelID, normalizedState, now); err != nil {
		return message.ScopeNotificationPanel{}, err
	}
	if err := tx.Commit(ctx); err != nil {
		return message.ScopeNotificationPanel{}, fmt.Errorf("commit set channel notification state: %w", err)
	}
	return store.GetChannelNotificationPanelForUser(ctx, userID, workspaceID, channelID, "", defaultScopeNotificationAgentPageSize)
}

func (store *Store) SetChannelNotificationAgentWakeStateForUser(ctx context.Context, userID string, workspaceID string, channelID string, agentID string, wakeState string) (message.ScopeNotificationPanel, error) {
	normalizedWakeState, err := normalizeAgentWakeState(wakeState)
	if err != nil {
		return message.ScopeNotificationPanel{}, err
	}

	tx, err := store.db.BeginTx(ctx, coredb.TxOptions{})
	if err != nil {
		return message.ScopeNotificationPanel{}, fmt.Errorf("begin set channel public agent wake state tx: %w", err)
	}
	defer rollback(tx, ctx)

	access, err := store.loadAuthorizedHumanChannelAccess(ctx, tx, userID, workspaceID, channelID, channelReadAction)
	if err != nil {
		return message.ScopeNotificationPanel{}, err
	}
	if access.Channel.Type != "channel" {
		return message.ScopeNotificationPanel{}, badRequest("thread channels do not have direct notifications")
	}
	status, _, err := store.loadScopeStatusAndHostChannel(ctx, tx, workspaceID, channelID)
	if err != nil {
		return message.ScopeNotificationPanel{}, err
	}
	if strings.TrimSpace(status) != "active" {
		return message.ScopeNotificationPanel{}, badRequest("channel is archived")
	}

	agent, err := store.loadAgentEntryOwner(ctx, tx, workspaceID, agentID)
	if err != nil {
		return message.ScopeNotificationPanel{}, err
	}
	if strings.TrimSpace(agent.OwnerMemberID) != strings.TrimSpace(access.WorkspaceMember.Member.ID) {
		return message.ScopeNotificationPanel{}, ErrForbidden
	}
	entry, ok, err := store.loadActiveChannelAgentEntry(ctx, tx, channelID, agent.AgentMemberID)
	if err != nil {
		return message.ScopeNotificationPanel{}, err
	}
	if !ok {
		return message.ScopeNotificationPanel{}, ErrNotFound
	}

	now := time.Now().UTC()
	switch strings.TrimSpace(entry.Visibility) {
	case channelAgentEntryVisibilityPublic:
		if err := upsertAgentWakeStateWithExecer(ctx, tx, agent.AgentMemberID, channelID, normalizedWakeState, now); err != nil {
			return message.ScopeNotificationPanel{}, err
		}
	case channelAgentEntryVisibilityPrivate:
		if strings.TrimSpace(entry.OwnerMemberID.String) != strings.TrimSpace(access.WorkspaceMember.Member.ID) {
			return message.ScopeNotificationPanel{}, ErrForbidden
		}
		if err := upsertAgentWakeStateWithExecer(ctx, tx, agent.AgentMemberID, channelID, normalizedWakeState, now); err != nil {
			return message.ScopeNotificationPanel{}, err
		}
	default:
		return message.ScopeNotificationPanel{}, ErrNotFound
	}
	if err := tx.Commit(ctx); err != nil {
		return message.ScopeNotificationPanel{}, fmt.Errorf("commit set channel agent wake state: %w", err)
	}
	return store.GetChannelNotificationPanelForUser(ctx, userID, workspaceID, channelID, "", defaultScopeNotificationAgentPageSize)
}

func (store *Store) GetThreadNotificationPanelForUser(ctx context.Context, userID string, workspaceID string, threadChannelID string, agentCursor string, agentLimit int) (message.ScopeNotificationPanel, error) {
	membership, err := store.loadHumanWorkspaceMember(ctx, store.db, userID, workspaceID)
	if err != nil {
		return message.ScopeNotificationPanel{}, err
	}
	if _, err := message.RequireMemberReplyThreadRead(ctx, store.db, membership.Member.ID, workspaceID, threadChannelID); err != nil {
		return message.ScopeNotificationPanel{}, err
	}
	status, hostChannelID, err := store.loadScopeStatusAndHostChannel(ctx, store.db, workspaceID, threadChannelID)
	if err != nil {
		return message.ScopeNotificationPanel{}, err
	}
	parentArchived, err := loadChannelArchivedForMember(ctx, store.db, membership.Member.ID, hostChannelID)
	if err != nil {
		return message.ScopeNotificationPanel{}, err
	}
	selfState, err := store.loadScopeNotificationSelfState(ctx, store.db, threadChannelID, membership.Member.ID)
	if err != nil {
		return message.ScopeNotificationPanel{}, err
	}
	agents, nextCursor, hasMore, err := store.loadOwnedThreadNotificationAgents(ctx, store.db, workspaceID, threadChannelID, hostChannelID, membership.Member.ID, agentCursor, agentLimit)
	if err != nil {
		return message.ScopeNotificationPanel{}, err
	}
	readOnly := strings.TrimSpace(status) != "active" || parentArchived
	readOnlyReason := ""
	if readOnly {
		readOnlyReason = scopeNotificationReadOnlyReasonArchived
	}
	return message.ScopeNotificationPanel{
		ScopeID:         strings.TrimSpace(threadChannelID),
		ScopeType:       "thread",
		ReadOnly:        readOnly,
		ReadOnlyReason:  readOnlyReason,
		Self:            message.ScopeNotificationSelfRow{State: selfState},
		Agents:          agents,
		AgentNextCursor: nextCursor,
		AgentHasMore:    hasMore,
	}, nil
}

func (store *Store) SetThreadNotificationSelfStateForUser(ctx context.Context, userID string, workspaceID string, threadChannelID string, state string) (message.ScopeNotificationPanel, error) {
	normalizedState, err := message.NormalizeHumanScopeSubscriptionState(state)
	if err != nil {
		return message.ScopeNotificationPanel{}, err
	}

	tx, err := store.db.BeginTx(ctx, coredb.TxOptions{})
	if err != nil {
		return message.ScopeNotificationPanel{}, fmt.Errorf("begin set thread notification state tx: %w", err)
	}
	defer rollback(tx, ctx)

	membership, err := store.loadHumanWorkspaceMember(ctx, tx, userID, workspaceID)
	if err != nil {
		return message.ScopeNotificationPanel{}, err
	}
	if _, err := message.RequireMemberReplyThreadRead(ctx, tx, membership.Member.ID, workspaceID, threadChannelID); err != nil {
		return message.ScopeNotificationPanel{}, err
	}
	status, hostChannelID, err := store.loadScopeStatusAndHostChannel(ctx, tx, workspaceID, threadChannelID)
	if err != nil {
		return message.ScopeNotificationPanel{}, err
	}
	parentArchived, err := loadChannelArchivedForMember(ctx, tx, membership.Member.ID, hostChannelID)
	if err != nil {
		return message.ScopeNotificationPanel{}, err
	}
	if strings.TrimSpace(status) != "active" || parentArchived {
		return message.ScopeNotificationPanel{}, badRequest("thread is archived")
	}
	now := time.Now().UTC()
	if err := message.UpsertScopeNotificationState(ctx, tx, membership.Member.ID, threadChannelID, normalizedState, now); err != nil {
		return message.ScopeNotificationPanel{}, err
	}
	if err := tx.Commit(ctx); err != nil {
		return message.ScopeNotificationPanel{}, fmt.Errorf("commit set thread notification state: %w", err)
	}
	return store.GetThreadNotificationPanelForUser(ctx, userID, workspaceID, threadChannelID, "", defaultScopeNotificationAgentPageSize)
}

func (store *Store) SetThreadNotificationAgentWakeStateForUser(ctx context.Context, userID string, workspaceID string, threadChannelID string, agentID string, wakeState string) (message.ScopeNotificationPanel, error) {
	normalizedWakeState, err := normalizeAgentWakeState(wakeState)
	if err != nil {
		return message.ScopeNotificationPanel{}, err
	}

	tx, err := store.db.BeginTx(ctx, coredb.TxOptions{})
	if err != nil {
		return message.ScopeNotificationPanel{}, fmt.Errorf("begin set thread public agent wake state tx: %w", err)
	}
	defer rollback(tx, ctx)

	membership, err := store.loadHumanWorkspaceMember(ctx, tx, userID, workspaceID)
	if err != nil {
		return message.ScopeNotificationPanel{}, err
	}
	if _, err := message.RequireMemberReplyThreadRead(ctx, tx, membership.Member.ID, workspaceID, threadChannelID); err != nil {
		return message.ScopeNotificationPanel{}, err
	}
	status, hostChannelID, err := store.loadScopeStatusAndHostChannel(ctx, tx, workspaceID, threadChannelID)
	if err != nil {
		return message.ScopeNotificationPanel{}, err
	}
	parentArchived, err := loadChannelArchivedForMember(ctx, tx, membership.Member.ID, hostChannelID)
	if err != nil {
		return message.ScopeNotificationPanel{}, err
	}
	if strings.TrimSpace(status) != "active" || parentArchived {
		return message.ScopeNotificationPanel{}, badRequest("thread is archived")
	}
	if strings.TrimSpace(hostChannelID) == "" {
		return message.ScopeNotificationPanel{}, ErrNotFound
	}

	agent, err := store.loadAgentEntryOwner(ctx, tx, workspaceID, agentID)
	if err != nil {
		return message.ScopeNotificationPanel{}, err
	}
	if strings.TrimSpace(agent.OwnerMemberID) != strings.TrimSpace(membership.Member.ID) {
		return message.ScopeNotificationPanel{}, ErrForbidden
	}
	entry, ok, err := store.loadActiveChannelAgentEntry(ctx, tx, hostChannelID, agent.AgentMemberID)
	if err != nil {
		return message.ScopeNotificationPanel{}, err
	}
	if !ok {
		return message.ScopeNotificationPanel{}, ErrNotFound
	}

	now := time.Now().UTC()
	switch strings.TrimSpace(entry.Visibility) {
	case channelAgentEntryVisibilityPublic:
		found, err := store.publicAgentParticipatesInThread(ctx, tx, workspaceID, threadChannelID, hostChannelID, agent.AgentMemberID)
		if err != nil {
			return message.ScopeNotificationPanel{}, err
		}
		if !found {
			return message.ScopeNotificationPanel{}, ErrNotFound
		}
		if err := upsertAgentWakeStateWithExecer(ctx, tx, agent.AgentMemberID, threadChannelID, normalizedWakeState, now); err != nil {
			return message.ScopeNotificationPanel{}, err
		}
	case channelAgentEntryVisibilityPrivate:
		if strings.TrimSpace(entry.OwnerMemberID.String) != strings.TrimSpace(membership.Member.ID) {
			return message.ScopeNotificationPanel{}, ErrForbidden
		}
		found, err := store.privateAgentSubscribedToThread(ctx, tx, workspaceID, threadChannelID, hostChannelID, membership.Member.ID, agent.AgentMemberID)
		if err != nil {
			return message.ScopeNotificationPanel{}, err
		}
		if !found {
			return message.ScopeNotificationPanel{}, ErrNotFound
		}
		if err := upsertAgentWakeStateWithExecer(ctx, tx, agent.AgentMemberID, threadChannelID, normalizedWakeState, now); err != nil {
			return message.ScopeNotificationPanel{}, err
		}
	default:
		return message.ScopeNotificationPanel{}, ErrNotFound
	}
	if err := tx.Commit(ctx); err != nil {
		return message.ScopeNotificationPanel{}, fmt.Errorf("commit set thread agent wake state: %w", err)
	}
	return store.GetThreadNotificationPanelForUser(ctx, userID, workspaceID, threadChannelID, "", defaultScopeNotificationAgentPageSize)
}

func (store *Store) GetTaskNotificationPanelForUser(ctx context.Context, userID string, workspaceID string, taskScopeID string) (message.ScopeNotificationPanel, error) {
	membership, err := store.loadHumanWorkspaceMember(ctx, store.db, userID, workspaceID)
	if err != nil {
		return message.ScopeNotificationPanel{}, err
	}
	status, err := loadTaskNotificationScopeStatus(ctx, store.db, workspaceID, taskScopeID)
	if err != nil {
		return message.ScopeNotificationPanel{}, err
	}
	selfState, err := store.loadTaskScopeNotificationSelfState(ctx, store.db, taskScopeID, membership.Member.ID)
	if err != nil {
		return message.ScopeNotificationPanel{}, err
	}
	agents, err := store.loadOwnedTaskNotificationAgents(ctx, store.db, workspaceID, taskScopeID, membership.Member.ID)
	if err != nil {
		return message.ScopeNotificationPanel{}, err
	}
	watcherPage, err := message.LoadVisibleTaskWatcherPage(
		ctx,
		store.db,
		workspaceID,
		taskScopeID,
		"",
		message.TaskWatcherPreviewSize,
	)
	if err != nil {
		return message.ScopeNotificationPanel{}, err
	}
	readOnly := strings.TrimSpace(status) != "active"
	readOnlyReason := ""
	if readOnly {
		readOnlyReason = scopeNotificationReadOnlyReasonArchived
	}
	return message.ScopeNotificationPanel{
		ScopeID:        strings.TrimSpace(taskScopeID),
		ScopeType:      "task",
		ReadOnly:       readOnly,
		ReadOnlyReason: readOnlyReason,
		Self: message.ScopeNotificationSelfRow{
			State: selfState,
		},
		Agents:             agents,
		TaskWatcherPreview: watcherPage.Watchers,
		TaskWatcherCount:   watcherPage.WatcherCount,
		TaskMemberCount:    watcherPage.MemberCount,
	}, nil
}

// GetTaskWatchersForUser loads the explicit roster after the caller has
// established source-Task readability through taskboard.GetReadableTaskScope.
// A Task scope ID does not itself carry the source Channel needed to repeat
// that authorization here, so this method defensively rechecks both active
// workspace membership and the workspace-owned Task scope before projecting
// identities.
func (store *Store) GetTaskWatchersForUser(
	ctx context.Context,
	userID string,
	workspaceID string,
	taskScopeID string,
	cursor string,
	limit int,
) (message.TaskWatcherPage, error) {
	if _, err := store.loadHumanWorkspaceMember(ctx, store.db, userID, workspaceID); err != nil {
		return message.TaskWatcherPage{}, err
	}
	if _, err := loadTaskNotificationScopeStatus(ctx, store.db, workspaceID, taskScopeID); err != nil {
		return message.TaskWatcherPage{}, err
	}
	return message.LoadVisibleTaskWatcherPage(
		ctx,
		store.db,
		workspaceID,
		taskScopeID,
		cursor,
		limit,
	)
}

func (store *Store) SetTaskNotificationSelfStateForUser(ctx context.Context, userID string, workspaceID string, taskScopeID string, state string) (message.ScopeNotificationPanel, error) {
	normalizedState, err := normalizeTaskHumanSubscriptionState(state)
	if err != nil {
		return message.ScopeNotificationPanel{}, err
	}

	tx, err := store.db.BeginTx(ctx, coredb.TxOptions{})
	if err != nil {
		return message.ScopeNotificationPanel{}, fmt.Errorf("begin set task notification state tx: %w", err)
	}
	defer rollback(tx, ctx)

	membership, err := store.loadHumanWorkspaceMember(ctx, tx, userID, workspaceID)
	if err != nil {
		return message.ScopeNotificationPanel{}, err
	}
	active, err := message.LockActiveTaskScopeForMutation(ctx, tx, workspaceID, taskScopeID)
	if err != nil {
		return message.ScopeNotificationPanel{}, err
	}
	if !active {
		return message.ScopeNotificationPanel{}, ErrNotFound
	}
	status, err := loadTaskNotificationScopeStatus(ctx, tx, workspaceID, taskScopeID)
	if err != nil {
		return message.ScopeNotificationPanel{}, err
	}
	if strings.TrimSpace(status) != "active" {
		return message.ScopeNotificationPanel{}, badRequest("task scope is archived")
	}
	now := time.Now().UTC()
	if err := message.EnsureScopeMembership(ctx, tx, taskScopeID, membership.Member.ID, "public", "", "", 0, 0, now); err != nil {
		return message.ScopeNotificationPanel{}, fmt.Errorf("ensure task notification membership: %w", err)
	}
	if err := message.UpsertTaskWatchNotificationState(ctx, tx, membership.Member.ID, taskScopeID, normalizedState, now); err != nil {
		return message.ScopeNotificationPanel{}, err
	}
	if normalizedState == message.HumanScopeSubscriptionStateActive {
		// Task Watch must immediately materialize the owner-local Inbox
		// conversation, atomically with the watch-state change. The materializer is
		// idempotent and never fabricates a discussion message, activity event,
		// InboxMessage, or Agent wake; an existing row is preserved (greatest
		// sort_at wins). Mute/unwatch leave any existing row untouched, so
		// mute/unwatch/rewatch semantics are unchanged.
		if err := message.MaterializeTaskConversationForMemberTx(ctx, tx, workspaceID, membership.Member.ID, taskScopeID); err != nil {
			return message.ScopeNotificationPanel{}, err
		}
	}
	if err := tx.Commit(ctx); err != nil {
		return message.ScopeNotificationPanel{}, fmt.Errorf("commit set task notification state: %w", err)
	}
	return store.GetTaskNotificationPanelForUser(ctx, userID, workspaceID, taskScopeID)
}

func normalizeTaskHumanSubscriptionState(state string) (string, error) {
	switch strings.TrimSpace(state) {
	case message.HumanScopeSubscriptionStateActive:
		return message.HumanScopeSubscriptionStateActive, nil
	case message.HumanScopeSubscriptionStateMute:
		return message.HumanScopeSubscriptionStateMute, nil
	default:
		return "", badRequest("task notification state must be active or mute")
	}
}

func (store *Store) SetTaskNotificationAgentWakeStateForUser(ctx context.Context, userID string, workspaceID string, taskScopeID string, agentID string, wakeState string) (message.ScopeNotificationPanel, error) {
	normalizedWakeState, err := normalizeAgentWakeState(wakeState)
	if err != nil {
		return message.ScopeNotificationPanel{}, err
	}

	tx, err := store.db.BeginTx(ctx, coredb.TxOptions{})
	if err != nil {
		return message.ScopeNotificationPanel{}, fmt.Errorf("begin set task agent wake state tx: %w", err)
	}
	defer rollback(tx, ctx)

	membership, err := store.loadHumanWorkspaceMember(ctx, tx, userID, workspaceID)
	if err != nil {
		return message.ScopeNotificationPanel{}, err
	}
	active, err := message.LockActiveTaskScopeForMutation(ctx, tx, workspaceID, taskScopeID)
	if err != nil {
		return message.ScopeNotificationPanel{}, err
	}
	if !active {
		return message.ScopeNotificationPanel{}, ErrNotFound
	}
	status, err := loadTaskNotificationScopeStatus(ctx, tx, workspaceID, taskScopeID)
	if err != nil {
		return message.ScopeNotificationPanel{}, err
	}
	if strings.TrimSpace(status) != "active" {
		return message.ScopeNotificationPanel{}, badRequest("task scope is archived")
	}

	agent, err := store.loadAgentEntryOwner(ctx, tx, workspaceID, agentID)
	if err != nil {
		return message.ScopeNotificationPanel{}, err
	}
	if strings.TrimSpace(agent.OwnerMemberID) != strings.TrimSpace(membership.Member.ID) {
		return message.ScopeNotificationPanel{}, ErrForbidden
	}
	found, err := taskAgentParticipatesInTaskScope(ctx, tx, taskScopeID, membership.Member.ID, agent.AgentMemberID)
	if err != nil {
		return message.ScopeNotificationPanel{}, err
	}
	if !found {
		return message.ScopeNotificationPanel{}, ErrNotFound
	}

	now := time.Now().UTC()
	if err := message.UpsertTaskWatchNotificationState(ctx, tx, agent.AgentMemberID, taskScopeID, normalizedWakeState, now); err != nil {
		return message.ScopeNotificationPanel{}, err
	}
	if err := tx.Commit(ctx); err != nil {
		return message.ScopeNotificationPanel{}, fmt.Errorf("commit set task agent wake state: %w", err)
	}
	return store.GetTaskNotificationPanelForUser(ctx, userID, workspaceID, taskScopeID)
}

// SetNotificationAgentsWakeStateForUser bulk-updates the owned agents visible
// in a channel, thread, or task notification panel.
func (store *Store) SetNotificationAgentsWakeStateForUser(ctx context.Context, userID, workspaceID, scopeType, scopeID, wakeState string) (message.ScopeNotificationPanel, error) {
	normalizedWakeState, err := normalizeAgentWakeState(wakeState)
	if err != nil {
		return message.ScopeNotificationPanel{}, err
	}
	tx, err := store.db.BeginTx(ctx, coredb.TxOptions{})
	if err != nil {
		return message.ScopeNotificationPanel{}, fmt.Errorf("begin set notification agents wake state tx: %w", err)
	}
	defer rollback(tx, ctx)
	var memberID string
	switch scopeType {
	case "channel":
		access, accessErr := store.loadAuthorizedHumanChannelAccess(ctx, tx, userID, workspaceID, scopeID, channelReadAction)
		if accessErr != nil {
			return message.ScopeNotificationPanel{}, accessErr
		}
		if access.Channel.Type != "channel" {
			return message.ScopeNotificationPanel{}, badRequest("thread channels do not have direct notifications")
		}
		status, _, statusErr := store.loadScopeStatusAndHostChannel(ctx, tx, workspaceID, scopeID)
		if statusErr != nil {
			return message.ScopeNotificationPanel{}, statusErr
		}
		if strings.TrimSpace(status) != "active" {
			return message.ScopeNotificationPanel{}, badRequest("channel is archived")
		}
		memberID = access.WorkspaceMember.Member.ID
	case "thread":
		membership, membershipErr := store.loadHumanWorkspaceMember(ctx, tx, userID, workspaceID)
		if membershipErr != nil {
			return message.ScopeNotificationPanel{}, membershipErr
		}
		memberID = membership.Member.ID
		if _, err = message.RequireMemberReplyThreadRead(ctx, tx, memberID, workspaceID, scopeID); err != nil {
			return message.ScopeNotificationPanel{}, err
		}
		status, hostChannelID, statusErr := store.loadScopeStatusAndHostChannel(ctx, tx, workspaceID, scopeID)
		if statusErr != nil {
			return message.ScopeNotificationPanel{}, statusErr
		}
		parentArchived, archiveErr := loadChannelArchivedForMember(ctx, tx, memberID, hostChannelID)
		if archiveErr != nil {
			return message.ScopeNotificationPanel{}, archiveErr
		}
		if strings.TrimSpace(status) != "active" || parentArchived {
			return message.ScopeNotificationPanel{}, badRequest("thread is archived")
		}
		if strings.TrimSpace(hostChannelID) == "" {
			return message.ScopeNotificationPanel{}, ErrNotFound
		}
	case "task":
		membership, membershipErr := store.loadHumanWorkspaceMember(ctx, tx, userID, workspaceID)
		if membershipErr != nil {
			return message.ScopeNotificationPanel{}, membershipErr
		}
		memberID = membership.Member.ID
		active, lockErr := message.LockActiveTaskScopeForMutation(ctx, tx, workspaceID, scopeID)
		if lockErr != nil {
			return message.ScopeNotificationPanel{}, lockErr
		}
		if !active {
			return message.ScopeNotificationPanel{}, ErrNotFound
		}
		status, statusErr := loadTaskNotificationScopeStatus(ctx, tx, workspaceID, scopeID)
		if statusErr != nil {
			return message.ScopeNotificationPanel{}, statusErr
		}
		if strings.TrimSpace(status) != "active" {
			return message.ScopeNotificationPanel{}, badRequest("task scope is archived")
		}
	default:
		return message.ScopeNotificationPanel{}, badRequest("unsupported notification scope type")
	}
	now := time.Now().UTC()
	if err := upsertOwnedScopeAgentWakeStates(ctx, tx, workspaceID, scopeID, memberID, normalizedWakeState, scopeType == "task", now); err != nil {
		return message.ScopeNotificationPanel{}, err
	}
	if err := tx.Commit(ctx); err != nil {
		return message.ScopeNotificationPanel{}, fmt.Errorf("commit set notification agents wake state: %w", err)
	}
	switch scopeType {
	case "channel":
		return store.GetChannelNotificationPanelForUser(ctx, userID, workspaceID, scopeID, "", defaultScopeNotificationAgentPageSize)
	case "thread":
		return store.GetThreadNotificationPanelForUser(ctx, userID, workspaceID, scopeID, "", defaultScopeNotificationAgentPageSize)
	default:
		return store.GetTaskNotificationPanelForUser(ctx, userID, workspaceID, scopeID)
	}
}

const (
	scopeNotificationReadOnlyReasonArchived = "archived"
	// scopeNotificationAgentSafetyCap bounds the per-scope owned-agent SQL
	// query so a runaway workspace cannot pull unbounded rows. Cursor pagination
	// further slices this set in Go; this cap only defends the database.
	scopeNotificationAgentSafetyCap = 10000
	// defaultScopeNotificationAgentPageSize is the page size used when the
	// caller does not specify one (e.g. mutation handlers that re-return the
	// panel after a state change).
	defaultScopeNotificationAgentPageSize = 100
	// maxScopeNotificationAgentPageSize caps client-requested page sizes so a
	// single response cannot ask for the entire safety cap in one shot.
	maxScopeNotificationAgentPageSize = 500
)

func (store *Store) loadScopeStatusAndHostChannel(ctx context.Context, queryer interface {
	QueryRow(context.Context, string, ...any) coredb.Row
}, workspaceID string, scopeID string) (string, string, error) {
	row := queryer.QueryRow(ctx, `
SELECT source_scope.status,
       CASE
         WHEN source_scope.type = 'channel' THEN source_scope.id
         WHEN source_scope.type = 'thread' AND parent_scope.type = 'channel' THEN parent_scope.id
         ELSE ''
       END
FROM scopes source_scope
LEFT JOIN scopes parent_scope
  ON parent_scope.id = source_scope.parent_scope_id
WHERE source_scope.workspace_id = $1
  AND source_scope.id = $2
`, strings.TrimSpace(workspaceID), strings.TrimSpace(scopeID))

	var status string
	var hostChannelID string
	if err := row.Scan(&status, &hostChannelID); err != nil {
		if coredb.IsNotFound(err) {
			return "", "", ErrNotFound
		}
		return "", "", fmt.Errorf("load scope projection status: %w", err)
	}
	return strings.TrimSpace(status), strings.TrimSpace(hostChannelID), nil
}

func loadTaskNotificationScopeStatus(ctx context.Context, queryer interface {
	QueryRow(context.Context, string, ...any) coredb.Row
}, workspaceID string, taskScopeID string) (string, error) {
	row := queryer.QueryRow(ctx, `
SELECT task_scope.status
FROM scopes task_scope
WHERE task_scope.workspace_id = $1
  AND task_scope.id = $2
  AND task_scope.type = 'task'
LIMIT 1
`, strings.TrimSpace(workspaceID), strings.TrimSpace(taskScopeID))

	var status string
	if err := row.Scan(&status); err != nil {
		if coredb.IsNotFound(err) {
			return "", ErrNotFound
		}
		return "", fmt.Errorf("load task notification scope status: %w", err)
	}
	return strings.TrimSpace(status), nil
}

func (store *Store) loadTaskScopeNotificationSelfState(ctx context.Context, queryer interface {
	QueryRow(context.Context, string, ...any) coredb.Row
}, taskScopeID string, memberID string) (string, error) {
	row := queryer.QueryRow(ctx, `
SELECT COALESCE(task_watch_state, '')
FROM scope_notification_settings
WHERE scope_id = $1
  AND member_id = $2
LIMIT 1
`, strings.TrimSpace(taskScopeID), strings.TrimSpace(memberID))

	var state string
	if err := row.Scan(&state); err != nil {
		if !coredb.IsNotFound(err) {
			return "", fmt.Errorf("load task scope notification self state: %w", err)
		}
		return message.HumanScopeSubscriptionStateMute, nil
	}
	if strings.TrimSpace(state) == message.HumanScopeSubscriptionStateActive {
		return message.HumanScopeSubscriptionStateActive, nil
	}
	// Task has exactly two Human states. A missing v2 authority (including a
	// row created later by an old binary) is surfaced as Unwatch.
	return message.HumanScopeSubscriptionStateMute, nil
}

func loadChannelArchivedForMember(ctx context.Context, queryer interface {
	QueryRow(context.Context, string, ...any) coredb.Row
}, memberID string, channelID string) (bool, error) {
	if strings.TrimSpace(channelID) == "" {
		return false, nil
	}
	row := queryer.QueryRow(ctx, `
SELECT archived_at IS NOT NULL
FROM scope_memberships
WHERE scope_id = $1
  AND member_id = $2
  AND removed_at IS NULL
ORDER BY effective_from_seq DESC
LIMIT 1
`, strings.TrimSpace(channelID), strings.TrimSpace(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 (store *Store) loadScopeNotificationSelfState(ctx context.Context, queryer interface {
	QueryRow(context.Context, string, ...any) coredb.Row
}, scopeID string, memberID string) (string, error) {
	row := queryer.QueryRow(ctx, `
SELECT state
FROM scope_notification_settings
WHERE scope_id = $1
  AND member_id = $2
LIMIT 1
`, strings.TrimSpace(scopeID), strings.TrimSpace(memberID))

	var state string
	if err := row.Scan(&state); err != nil {
		if coredb.IsNotFound(err) {
			return message.HumanScopeSubscriptionStateActive, nil
		}
		return "", fmt.Errorf("load scope notification self state: %w", err)
	}
	normalizedState, err := message.NormalizeHumanScopeSubscriptionState(state)
	if err != nil {
		return message.HumanScopeSubscriptionStateActive, nil
	}
	return normalizedState, nil
}

// scopeNotificationAgentCursorKey is the stable ordering key used for cursor
// pagination. The DisplayName field is the lower-cased trimmed display name
// (falling back to agent name); AgentID is the agent's stable ID. The pair
// matches the SQL `ORDER BY LOWER(display_name), agent_id` ordering.
type scopeNotificationAgentCursorKey struct {
	DisplayName string
	AgentID     string
}

const (
	scopeNotificationAgentCursorVersion = 1
	// maxScopeNotificationAgentCursorBytes caps the raw cursor payload length
	// (base64-url-safe characters) we are willing to decode. Generous relative
	// to legitimate cursor JSON, tight enough to reject abuse.
	maxScopeNotificationAgentCursorBytes = 1024
	// maxScopeNotificationAgentCursorName / Agent cap the decoded field
	// lengths to bound SQL parameter size.
	maxScopeNotificationAgentCursorName  = 256
	maxScopeNotificationAgentCursorAgent = 64
)

// scopeNotificationAgentCursorPayload is the JSON payload wrapped by the
// opaque cursor string. Field tags use short versioned keys to keep the wire
// representation compact and explicit about schema version.
type scopeNotificationAgentCursorPayload struct {
	Version int    `json:"v"`
	Name    string `json:"d"`
	Agent   string `json:"a"`
}

func scopeNotificationAgentCursorKeyForRow(row message.ScopeNotificationAgentRow, sortKey string) scopeNotificationAgentCursorKey {
	return scopeNotificationAgentCursorKey{
		DisplayName: sortKey,
		AgentID:     row.AgentID,
	}
}

func encodeScopeNotificationAgentCursor(key scopeNotificationAgentCursorKey) string {
	if key.DisplayName == "" && key.AgentID == "" {
		return ""
	}
	payload, err := json.Marshal(scopeNotificationAgentCursorPayload{
		Version: scopeNotificationAgentCursorVersion,
		Name:    key.DisplayName,
		Agent:   key.AgentID,
	})
	if err != nil {
		return ""
	}
	return base64.RawURLEncoding.EncodeToString(payload)
}

// errInvalidScopeNotificationAgentCursor is the sentinel returned by the
// decoder for any malformed cursor. Callers map it to a 400 via badRequest().
var errInvalidScopeNotificationAgentCursor = badRequest("invalid agent cursor")

func decodeScopeNotificationAgentCursor(raw string) (scopeNotificationAgentCursorKey, error) {
	trimmed := strings.TrimSpace(raw)
	if trimmed == "" {
		return scopeNotificationAgentCursorKey{}, nil
	}
	if len(trimmed) > maxScopeNotificationAgentCursorBytes {
		return scopeNotificationAgentCursorKey{}, errInvalidScopeNotificationAgentCursor
	}
	decoded, err := base64.RawURLEncoding.DecodeString(trimmed)
	if err != nil {
		return scopeNotificationAgentCursorKey{}, errInvalidScopeNotificationAgentCursor
	}
	if len(decoded) > maxScopeNotificationAgentCursorBytes {
		return scopeNotificationAgentCursorKey{}, errInvalidScopeNotificationAgentCursor
	}
	var payload scopeNotificationAgentCursorPayload
	dec := json.NewDecoder(bytes.NewReader(decoded))
	dec.DisallowUnknownFields()
	if err := dec.Decode(&payload); err != nil {
		return scopeNotificationAgentCursorKey{}, errInvalidScopeNotificationAgentCursor
	}
	// Reject any top-level trailing token after the cursor object. json.Decoder
	// treats the next token as a fresh decode, so we ask for EOF explicitly;
	// anything else (number, string, object, array, or another key) is abuse.
	var throwaway any
	if err := dec.Decode(&throwaway); err != io.EOF {
		return scopeNotificationAgentCursorKey{}, errInvalidScopeNotificationAgentCursor
	}
	if payload.Version != scopeNotificationAgentCursorVersion {
		return scopeNotificationAgentCursorKey{}, errInvalidScopeNotificationAgentCursor
	}
	// The DB is the canonical source of the sort key (LOWER(TRIM(display_name)))
	// and the encoder writes the DB-produced value verbatim. We only validate
	// shape here: non-empty + length bounds. We do NOT re-derive canonical form
	// in Go, because the DB's case-folding/trimming rules are the source of
	// truth and any Go-side canonicalization could diverge (Unicode edge cases,
	// locale-dependent lower-casing, etc.) and shift the cursor.
	if payload.Name == "" {
		return scopeNotificationAgentCursorKey{}, errInvalidScopeNotificationAgentCursor
	}
	if strings.TrimSpace(payload.Agent) != payload.Agent || payload.Agent == "" {
		return scopeNotificationAgentCursorKey{}, errInvalidScopeNotificationAgentCursor
	}
	if len(payload.Name) > maxScopeNotificationAgentCursorName || len(payload.Agent) > maxScopeNotificationAgentCursorAgent {
		return scopeNotificationAgentCursorKey{}, errInvalidScopeNotificationAgentCursor
	}
	return scopeNotificationAgentCursorKey{DisplayName: payload.Name, AgentID: payload.Agent}, nil
}

// clampScopeNotificationAgentPageSize normalizes caller-provided page sizes:
// non-positive picks the default; values above the cap collapse to the cap.
func clampScopeNotificationAgentPageSize(limit int) int {
	if limit <= 0 {
		return defaultScopeNotificationAgentPageSize
	}
	if limit > maxScopeNotificationAgentPageSize {
		return maxScopeNotificationAgentPageSize
	}
	return limit
}

// finalizeScopeNotificationAgentPage trims the LIMIT pageSize+1 sentinel row
// and produces the next cursor when additional rows remain. The cursor is
// built from the DB-produced sortKey of the last row on the page so the
// encoder never re-derives the comparison key.
func finalizeScopeNotificationAgentPageWithSortKey(rows []message.ScopeNotificationAgentRow, sortKeys []string, pageSize int) ([]message.ScopeNotificationAgentRow, string, bool) {
	hasMore := len(rows) > pageSize
	if !hasMore {
		return rows, "", false
	}
	page := rows[:pageSize]
	pageKeys := sortKeys[:pageSize]
	if len(page) == 0 {
		return page, "", hasMore
	}
	nextCursor := encodeScopeNotificationAgentCursor(scopeNotificationAgentCursorKeyForRow(page[len(page)-1], pageKeys[len(pageKeys)-1]))
	return page, nextCursor, hasMore
}

func displayName(displayName string, fallback string) string {
	if trimmed := strings.TrimSpace(displayName); trimmed != "" {
		return trimmed
	}
	return strings.TrimSpace(fallback)
}

func (store *Store) loadOwnedTaskNotificationAgents(ctx context.Context, queryer coredb.Queryer, workspaceID string, taskScopeID string, ownerMemberID string) ([]message.ScopeNotificationAgentRow, error) {
	rows, err := queryer.Query(ctx, `
SELECT
    a.id,
    a.member_id,
    a.name,
    COALESCE(NULLIF(m.display_name, ''), a.name),
    COALESCE(NULLIF(ss.task_watch_state, ''), ''),
    sm.visibility
FROM scope_memberships sm
JOIN scopes task_scope
  ON task_scope.id = sm.scope_id
 AND task_scope.workspace_id = $1
 AND task_scope.type = 'task'
JOIN agents a
  ON a.member_id = sm.member_id
 AND a.workspace_id = $1
 AND a.status <> 'archived'
JOIN members m
  ON m.id = a.member_id
 AND m.status = 'active'
JOIN machines mac
  ON mac.workspace_id = a.workspace_id
 AND mac.id = a.machine_id
 AND mac.status = 'active'
JOIN members owner_member
  ON owner_member.workspace_id = a.workspace_id
 AND owner_member.user_id = mac.created_by_user_id
 AND owner_member.type = 'human'
 AND owner_member.status = 'active'
LEFT JOIN scope_notification_settings ss
  ON ss.scope_id = sm.scope_id
 AND ss.member_id = a.member_id
WHERE sm.scope_id = $2
  AND sm.removed_at IS NULL
  AND sm.archived_at IS NULL
  AND sm.effective_to_seq IS NULL
  AND sm.visibility IN ('public', 'private')
  AND (sm.visibility = 'public' OR sm.owner_member_id = $3)
  AND owner_member.id = $3
ORDER BY a.name ASC, a.id ASC
LIMIT $4
	`, strings.TrimSpace(workspaceID), strings.TrimSpace(taskScopeID), strings.TrimSpace(ownerMemberID), scopeNotificationAgentSafetyCap)
	if err != nil {
		return nil, fmt.Errorf("load owned task notification agents: %w", err)
	}
	defer rows.Close()

	return scanTaskScopeNotificationAgents(rows, agentWakeStateNoWake)
}

// loadOwnedChannelNotificationAgents returns one cursor-paginated page of the
// current user's owned agents (public + private) for a channel scope. The
// SQL unions the two ownership halves, applies the (sort_key, agent_id) cursor
// predicate — where sort_key = LOWER(TRIM(display_name)) is computed once by
// the database — and limits to pageSize+1 so the Go wrapper can detect
// hasMore without a second round trip. The DB-produced sort_key is threaded
// through the scanner into the cursor encoder so Go never re-derives the
// comparison key (which would risk divergence in whitespace or Unicode
// case-folding rules).
func (store *Store) loadOwnedChannelNotificationAgents(ctx context.Context, queryer coredb.Queryer, workspaceID string, channelID string, ownerMemberID string, cursor string, limit int) ([]message.ScopeNotificationAgentRow, string, bool, error) {
	cursorKey, err := decodeScopeNotificationAgentCursor(cursor)
	if err != nil {
		return nil, "", false, err
	}
	pageSize := clampScopeNotificationAgentPageSize(limit)

	rows, err := queryer.Query(ctx, `
WITH owned_public AS (
    SELECT
        a.id AS agent_id,
        a.member_id AS agent_member_id,
        a.name AS name,
        COALESCE(NULLIF(m.display_name, ''), a.name) AS display_name,
        COALESCE(NULLIF(ss.state, ''), '') AS wake_state,
        'public' AS visibility
    FROM scope_memberships sm
    JOIN agents a
      ON a.member_id = sm.member_id
     AND a.workspace_id = $1
     AND a.status <> 'archived'
    JOIN members m
      ON m.id = a.member_id
    JOIN machines mac
      ON mac.workspace_id = a.workspace_id
     AND mac.id = a.machine_id
     AND mac.status = 'active'
    JOIN members owner_member
      ON owner_member.workspace_id = a.workspace_id
     AND owner_member.user_id = mac.created_by_user_id
     AND owner_member.type = 'human'
     AND owner_member.status = 'active'
    LEFT JOIN scope_notification_settings ss
      ON ss.scope_id = $2
     AND ss.member_id = a.member_id
    WHERE 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
      AND owner_member.id = $3
),
owned_private AS (
    SELECT
        a.id AS agent_id,
        a.member_id AS agent_member_id,
        a.name AS name,
        COALESCE(NULLIF(m.display_name, ''), a.name) AS display_name,
        COALESCE(NULLIF(ss.state, ''), '') AS wake_state,
        'private' AS visibility
    FROM scope_memberships sm
    JOIN agents a
      ON a.member_id = sm.member_id
     AND a.workspace_id = $1
     AND a.status <> 'archived'
    JOIN members m
      ON m.id = a.member_id
    LEFT JOIN scope_notification_settings ss
      ON ss.scope_id = $2
     AND ss.member_id = a.member_id
    WHERE sm.scope_id = $2
      AND sm.owner_member_id = $3
      AND sm.visibility = 'private'
      AND sm.removed_at IS NULL
      AND sm.archived_at IS NULL
      AND sm.effective_to_seq IS NULL
),
owned AS (
    SELECT agent_id, agent_member_id, name, display_name, wake_state, visibility,
           LOWER(TRIM(display_name)) AS sort_key
    FROM owned_public
    UNION ALL
    SELECT agent_id, agent_member_id, name, display_name, wake_state, visibility,
           LOWER(TRIM(display_name)) AS sort_key
    FROM owned_private
)
SELECT agent_id, agent_member_id, name, display_name, wake_state, visibility, sort_key
FROM owned
WHERE $4 = ''
   OR (sort_key > $4
       OR (sort_key = $4 AND agent_id > $5))
ORDER BY sort_key ASC, agent_id ASC
LIMIT $6
	`, strings.TrimSpace(workspaceID), strings.TrimSpace(channelID), strings.TrimSpace(ownerMemberID), cursorKey.DisplayName, cursorKey.AgentID, pageSize+1)
	if err != nil {
		return nil, "", false, fmt.Errorf("load owned channel notification agents: %w", err)
	}
	defer rows.Close()

	agents, sortKeys, err := scanScopeNotificationAgentsWithVisibilityAndSortKey(rows, agentWakeStateNoWake)
	if err != nil {
		return nil, "", false, err
	}
	page, nextCursor, hasMore := finalizeScopeNotificationAgentPageWithSortKey(agents, sortKeys, pageSize)
	return page, nextCursor, hasMore, nil
}

// loadOwnedThreadNotificationAgents mirrors loadOwnedChannelNotificationAgents
// for reply-thread scopes. It returns the empty page when the thread has no
// host channel (defensive — callers already gate on this). See the channel
// loader for why sort_key is computed by the database.
func (store *Store) loadOwnedThreadNotificationAgents(ctx context.Context, queryer coredb.Queryer, workspaceID string, threadChannelID string, hostChannelID string, ownerMemberID string, cursor string, limit int) ([]message.ScopeNotificationAgentRow, string, bool, error) {
	if strings.TrimSpace(hostChannelID) == "" {
		return []message.ScopeNotificationAgentRow{}, "", false, nil
	}
	cursorKey, err := decodeScopeNotificationAgentCursor(cursor)
	if err != nil {
		return nil, "", false, err
	}
	pageSize := clampScopeNotificationAgentPageSize(limit)

	rows, err := queryer.Query(ctx, `
WITH owned_public AS (
    SELECT
        a.id AS agent_id,
        a.member_id AS agent_member_id,
        a.name AS name,
        COALESCE(NULLIF(m.display_name, ''), a.name) AS display_name,
        COALESCE(NULLIF(ss.state, ''), '') AS wake_state,
        'public' AS visibility
    FROM scope_memberships sm
    JOIN agents a
      ON a.member_id = sm.member_id
     AND a.workspace_id = $1
     AND a.status <> 'archived'
    JOIN members m
      ON m.id = a.member_id
    JOIN machines mac
      ON mac.workspace_id = a.workspace_id
     AND mac.id = a.machine_id
     AND mac.status = 'active'
    JOIN members owner_member
      ON owner_member.workspace_id = a.workspace_id
     AND owner_member.user_id = mac.created_by_user_id
     AND owner_member.type = 'human'
     AND owner_member.status = 'active'
    LEFT JOIN scope_notification_settings ss
      ON ss.scope_id = $2
     AND ss.member_id = a.member_id
    WHERE 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
      AND owner_member.id = $3
),
owned_private AS (
    SELECT
        a.id AS agent_id,
        a.member_id AS agent_member_id,
        a.name AS name,
        COALESCE(NULLIF(m.display_name, ''), a.name) AS display_name,
        COALESCE(NULLIF(ss.state, ''), '') AS wake_state,
        'private' AS visibility
    FROM scope_memberships sm
    JOIN agents a
      ON a.member_id = sm.member_id
     AND a.workspace_id = $1
     AND a.status <> 'archived'
    JOIN members m
      ON m.id = a.member_id
    LEFT JOIN scope_notification_settings ss
      ON ss.scope_id = $2
     AND ss.member_id = a.member_id
    WHERE sm.scope_id = $2
      AND sm.owner_member_id = $3
      AND sm.visibility = 'private'
      AND sm.removed_at IS NULL
      AND sm.archived_at IS NULL
      AND sm.effective_to_seq IS NULL
),
owned AS (
    SELECT agent_id, agent_member_id, name, display_name, wake_state, visibility,
           LOWER(TRIM(display_name)) AS sort_key
    FROM owned_public
    UNION ALL
    SELECT agent_id, agent_member_id, name, display_name, wake_state, visibility,
           LOWER(TRIM(display_name)) AS sort_key
    FROM owned_private
)
SELECT agent_id, agent_member_id, name, display_name, wake_state, visibility, sort_key
FROM owned
WHERE $4 = ''
   OR (sort_key > $4
       OR (sort_key = $4 AND agent_id > $5))
ORDER BY sort_key ASC, agent_id ASC
LIMIT $6
	`, strings.TrimSpace(workspaceID), strings.TrimSpace(threadChannelID), strings.TrimSpace(ownerMemberID), cursorKey.DisplayName, cursorKey.AgentID, pageSize+1)
	if err != nil {
		return nil, "", false, fmt.Errorf("load owned thread notification agents: %w", err)
	}
	defer rows.Close()

	agents, sortKeys, err := scanScopeNotificationAgentsWithVisibilityAndSortKey(rows, agentWakeStateAutoWake)
	if err != nil {
		return nil, "", false, err
	}
	page, nextCursor, hasMore := finalizeScopeNotificationAgentPageWithSortKey(agents, sortKeys, pageSize)
	return page, nextCursor, hasMore, nil
}

// upsertOwnedScopeAgentWakeStates applies one wake state to exactly the
// public and private agents the owner can toggle on a channel or thread
// scope. The INSERT ... SELECT keeps the bulk action to one database
// round-trip regardless of the number of agents.
func upsertOwnedScopeAgentWakeStates(ctx context.Context, execer commandTagExecer, workspaceID string, scopeID string, ownerMemberID string, wakeState string, taskScoped bool, updatedAt time.Time) error {
	if _, err := execer.Exec(ctx, `
INSERT INTO scope_notification_settings (
    scope_id, member_id, state, task_watch_state,
    created_at, updated_at, unread_generation
)
SELECT $2, owned.agent_member_id, $4, CASE WHEN $6 THEN $4 ELSE NULL END, $5, $5, 1
FROM (
    SELECT a.member_id AS agent_member_id
    FROM scope_memberships sm
    JOIN agents a
      ON a.member_id = sm.member_id
     AND a.workspace_id = $1
     AND a.status <> 'archived'
    JOIN members m
      ON m.id = a.member_id
    JOIN machines mac
      ON mac.workspace_id = a.workspace_id
     AND mac.id = a.machine_id
     AND mac.status = 'active'
    JOIN members owner_member
      ON owner_member.workspace_id = a.workspace_id
     AND owner_member.user_id = mac.created_by_user_id
     AND owner_member.type = 'human'
     AND owner_member.status = 'active'
    WHERE 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
      AND owner_member.id = $3
    UNION
    SELECT a.member_id AS agent_member_id
    FROM scope_memberships sm
    JOIN agents a
      ON a.member_id = sm.member_id
     AND a.workspace_id = $1
     AND a.status <> 'archived'
    JOIN members m
      ON m.id = a.member_id
    WHERE sm.scope_id = $2
      AND sm.owner_member_id = $3
      AND sm.visibility = 'private'
      AND sm.removed_at IS NULL
      AND sm.archived_at IS NULL
      AND sm.effective_to_seq IS NULL
) AS owned
ON DUPLICATE KEY UPDATE
    unread_generation = CASE
      WHEN state <> VALUES(state) OR ($6 AND COALESCE(task_watch_state, '') <> VALUES(task_watch_state)) THEN unread_generation + 1
      ELSE unread_generation
    END,
    updated_at = CASE
      WHEN state <> VALUES(state) OR ($6 AND COALESCE(task_watch_state, '') <> VALUES(task_watch_state)) THEN VALUES(updated_at)
      ELSE updated_at
    END,
    state = VALUES(state),
    task_watch_state = CASE WHEN $6 THEN VALUES(task_watch_state) ELSE task_watch_state END
	`,
		strings.TrimSpace(workspaceID),
		strings.TrimSpace(scopeID),
		strings.TrimSpace(ownerMemberID),
		strings.TrimSpace(wakeState),
		updatedAt.UTC(),
		taskScoped,
	); err != nil {
		return fmt.Errorf("upsert owned scope agent wake states: %w", err)
	}
	return nil
}
