package reaction

import (
	"context"
	"errors"
	"fmt"
	"strings"

	"github.com/tidbcloud/AgentTeam/internal/core/domain"
	"github.com/tidbcloud/AgentTeam/internal/core/identifiers"
	"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/message"
	"github.com/tidbcloud/AgentTeam/internal/store/model"
)

type reactionAggregateRow struct {
	MessageID   string `db:"message_id"`
	Emoji       string `db:"emoji"`
	Count       int    `db:"reaction_count"`
	MemberID    string `db:"reactor_member_id"`
	ActorType   string `db:"actor_type"`
	ActorName   string `db:"actor_name"`
	DisplayName string `db:"display_name"`
	AvatarURL   string `db:"avatar_url"`
	ServiceKind string `db:"service_kind"`
	ServiceSlug string `db:"service_slug"`
}

func (row reactionAggregateRow) modelRow() model.ReactionAggregateRow {
	return model.ReactionAggregateRow{
		MessageID:   row.MessageID,
		Emoji:       row.Emoji,
		Count:       row.Count,
		MemberID:    row.MemberID,
		ActorType:   row.ActorType,
		ActorName:   row.ActorName,
		DisplayName: row.DisplayName,
		AvatarURL:   row.AvatarURL,
		ServiceKind: row.ServiceKind,
		ServiceSlug: row.ServiceSlug,
	}
}

func (store *Store) loadReactionActorIdentity(ctx context.Context, queryer reactionQueryRower, workspaceID string, memberID string) (message.ReactionActorIdentity, error) {
	row := queryer.QueryRow(ctx, `
SELECT
    m.id,
    m.type,
    CASE
        WHEN m.type = 'agent' THEN COALESCE(NULLIF(a.name, ''), m.id)
        ELSE m.id
    END AS actor_name
FROM members m
LEFT JOIN agents a ON a.workspace_id = m.workspace_id AND a.member_id = m.id
WHERE m.workspace_id = $1
  AND m.id = $2
`, strings.TrimSpace(workspaceID), strings.TrimSpace(memberID))

	var actor message.ReactionActorIdentity
	if err := row.Scan(&actor.MemberID, &actor.ActorType, &actor.ActorName); err != nil {
		if coredb.IsNotFound(err) {
			return message.ReactionActorIdentity{}, ErrNotFound
		}
		return message.ReactionActorIdentity{}, fmt.Errorf("load reaction actor identity: %w", err)
	}
	if actor.ActorType != string(domain.MemberTypeHuman) && actor.ActorType != string(domain.MemberTypeAgent) {
		return message.ReactionActorIdentity{}, forbidden("reactions are only supported for humans and agents")
	}
	return actor, nil
}

func (store *Store) loadReactionTargetScope(ctx context.Context, queryer reactionQueryRower, workspaceID string, messageID string) (reactionTargetScope, error) {
	row := queryer.QueryRow(ctx, `
SELECT
    '' AS thread_id,
    m.scope_id,
    s.type AS scope_type,
    s.id AS requested_scope_id,
    COALESCE(parent_scope.id, '') AS parent_scope_id,
    COALESCE(parent_scope.type, '') AS parent_scope_type,
    COALESCE(s.root_message_id, '') AS root_message_id,
    COALESCE(s.root_message_short_id, '') AS root_message_short_id,
    COALESCE(scp.slug, '') AS channel_slug,
    COALESCE(task_board.channel_id, '') AS task_board_channel_id,
    COALESCE(scope_task.task_number, 0) AS task_number
FROM scope_messages m
JOIN scopes s ON s.workspace_id = m.workspace_id
             AND s.id = m.scope_id
LEFT JOIN scopes parent_scope ON parent_scope.workspace_id = s.workspace_id
                             AND parent_scope.id = s.parent_scope_id
                             AND parent_scope.status = 'active'
                             AND parent_scope.archived_at IS NULL
LEFT JOIN scope_channel_profiles scp ON scp.workspace_id = s.workspace_id
                                    AND scp.scope_id = s.id
                                    AND s.type = 'channel'
LEFT JOIN tasks scope_task
  ON s.type = 'task'
 AND scope_task.task_scope_id = s.id
LEFT JOIN task_boards task_board
  ON task_board.id = scope_task.board_id
 AND task_board.workspace_id = s.workspace_id
 AND scope_task.channel_id = task_board.channel_id
WHERE m.workspace_id = $1
  AND m.id = $2
  AND s.status = 'active'
  AND s.archived_at IS NULL
`, strings.TrimSpace(workspaceID), strings.TrimSpace(messageID))

	var targetScope reactionTargetScope
	if err := row.Scan(
		&targetScope.ThreadID,
		&targetScope.ScopeID,
		&targetScope.ScopeType,
		&targetScope.RequestedScopeID,
		&targetScope.ParentScopeID,
		&targetScope.ParentScopeType,
		&targetScope.RootMessageID,
		&targetScope.RootMessageShort,
		&targetScope.ChannelSlug,
		&targetScope.TaskBoardChannelID,
		&targetScope.TaskNumber,
	); err != nil {
		if coredb.IsNotFound(err) {
			return reactionTargetScope{}, ErrNotFound
		}
		return reactionTargetScope{}, fmt.Errorf("load reaction target scope: %w", err)
	}
	targetScope.TaskBoardChannelID = strings.TrimSpace(targetScope.TaskBoardChannelID)
	return targetScope, nil
}

func lockActiveTaskReactionScope(ctx context.Context, tx coredb.Tx, workspaceID string, targetScope reactionTargetScope) error {
	if strings.TrimSpace(targetScope.ScopeType) != string(access.ScopeTargetKindTask) {
		return nil
	}
	active, err := message.LockActiveTaskScopeForMutation(ctx, tx, workspaceID, targetScope.ScopeID)
	if err != nil {
		return err
	}
	if !active {
		return ErrNotFound
	}
	return nil
}

func validateReactionWriteScopeTargetContract(workspaceID string, principalID string, target message.ThreadAccess, targetScope reactionTargetScope, actor message.ReactionActorIdentity) error {
	resolved := reactionWriteScopeTargetContract(workspaceID, principalID, targetScope, actor)
	if strings.TrimSpace(message.ScopeIDForThreadAccess(target)) != strings.TrimSpace(targetScope.ScopeID) {
		return ErrForbidden
	}
	if err := resolved.Validate(); err != nil {
		return fmt.Errorf("validate reaction write scope target: %w", err)
	}
	return nil
}

func reactionWriteScopeTargetContract(workspaceID string, principalID string, targetScope reactionTargetScope, actor message.ReactionActorIdentity) access.ResolvedScopeTarget {
	resolved := access.ResolvedScopeTarget{
		WorkspaceID: strings.TrimSpace(workspaceID),
		Operation:   access.ScopeTargetOperationReactionWrite,
		Requested: access.ScopeTargetRef{
			Kind: access.ScopeTargetKind(strings.TrimSpace(targetScope.ScopeType)),
			Ref:  strings.TrimSpace(targetScope.RequestedScopeID),
		},
		Subjects: []access.ScopeAuthoritySubject{{
			Kind:          access.ScopeAuthoritySubjectDurableAuthor,
			PrincipalType: reactionPrincipalType(actor.ActorType),
			PrincipalID:   strings.TrimSpace(principalID),
			MemberID:      strings.TrimSpace(actor.MemberID),
		}},
		StorageScope: access.ScopeRef{
			ScopeID:   strings.TrimSpace(targetScope.ScopeID),
			ScopeType: access.ScopeTargetKind(strings.TrimSpace(targetScope.ScopeType)),
		},
		DeliveryScope: access.ScopeRef{
			ScopeID:   strings.TrimSpace(targetScope.ScopeID),
			ScopeType: access.ScopeTargetKind(strings.TrimSpace(targetScope.ScopeType)),
		},
		ReadScope: access.ScopeRef{
			ScopeID:   strings.TrimSpace(targetScope.ScopeID),
			ScopeType: access.ScopeTargetKind(strings.TrimSpace(targetScope.ScopeType)),
		},
		PermissionBasis: []access.ScopePermissionBasis{reactionWritePermissionBasis(targetScope, actor.MemberID)},
	}
	if parentScope := reactionParentScopeRef(targetScope); !parentScope.Empty() {
		resolved.ParentScope = parentScope
		resolved.RootMessageID = strings.TrimSpace(targetScope.RootMessageID)
		resolved.RootMessageShort = strings.TrimSpace(targetScope.RootMessageShort)
	}
	return resolved
}

func reactionPrincipalType(actorType string) access.PrincipalType {
	switch strings.TrimSpace(actorType) {
	case string(domain.MemberTypeHuman):
		return access.PrincipalTypeUser
	case string(domain.MemberTypeAgent):
		return access.PrincipalTypeAgent
	default:
		return access.PrincipalType(strings.TrimSpace(actorType))
	}
}

func reactionParentScopeRef(targetScope reactionTargetScope) access.ScopeRef {
	parentScopeID := strings.TrimSpace(targetScope.ParentScopeID)
	parentScopeType := access.ScopeTargetKind(strings.TrimSpace(targetScope.ParentScopeType))
	if parentScopeID == "" || parentScopeType == "" {
		return access.ScopeRef{}
	}
	return access.ScopeRef{
		ScopeID:   parentScopeID,
		ScopeType: parentScopeType,
	}
}

func reactionWritePermissionBasis(targetScope reactionTargetScope, actorMemberID string) access.ScopePermissionBasis {
	scope := access.ScopeRef{
		ScopeID:   strings.TrimSpace(targetScope.ScopeID),
		ScopeType: access.ScopeTargetKind(strings.TrimSpace(targetScope.ScopeType)),
	}
	kind := access.ScopePermissionBasisDirectMembership
	decisionSource := access.DecisionSourceChannelMembership
	reason := "reaction actor can read target message scope"

	switch strings.TrimSpace(targetScope.ScopeType) {
	case string(access.ScopeTargetKindDM):
		decisionSource = access.DecisionSourceDMParticipant
	case string(access.ScopeTargetKindThread):
		scope = reactionParentScopeRef(targetScope)
		if scope.ScopeType == access.ScopeTargetKindDM {
			kind = access.ScopePermissionBasisParentDMParticipant
		} else {
			kind = access.ScopePermissionBasisParentMembership
		}
		decisionSource = access.DecisionSourceThreadInherit
		reason = "parent scope authorizes reply-thread reaction"
	case string(access.ScopeTargetKindTask):
		kind = access.ScopePermissionBasisTaskBoardChannel
		decisionSource = access.DecisionSourceChannelMembership
		reason = "task board channel membership authorizes reaction"
		// The permission basis records the actual authoritative board Channel
		// scope (derived from the active Task/board authority), never the Task
		// scope. The Task scope is only target identity; a retained Task
		// membership with no Channel membership grants nothing.
		if boardChannelID := strings.TrimSpace(targetScope.TaskBoardChannelID); boardChannelID != "" {
			scope = access.ScopeRef{ScopeID: boardChannelID, ScopeType: access.ScopeTargetKindChannel}
		}
	case string(access.ScopeTargetKindServiceAccount):
		decisionSource = ""
		reason = "service-account scope membership authorizes reaction"
	}

	return access.ScopePermissionBasis{
		Kind:           kind,
		Scope:          scope,
		DecisionSource: decisionSource,
		MemberID:       strings.TrimSpace(actorMemberID),
		Reason:         reason,
	}
}

func (store *Store) resolveReactionMessageReferenceForAgent(ctx context.Context, queryer coredb.QueryExecer, authenticatedAgent agent.AuthenticatedAgent, messageID string) (string, reactionTargetScope, message.ThreadAccess, error) {
	trimmedMessageID := strings.TrimSpace(messageID)
	if trimmedMessageID == "" {
		return "", reactionTargetScope{}, message.ThreadAccess{}, badRequest("message_id is required")
	}

	resolveAuthorizedCandidate := func(candidateMessageID string) (reactionTargetScope, message.ThreadAccess, error) {
		targetScopeRow, err := store.loadReactionTargetScope(ctx, queryer, authenticatedAgent.WorkspaceID, candidateMessageID)
		if err != nil {
			return reactionTargetScope{}, message.ThreadAccess{}, err
		}
		if err := store.authorizeMessageSidecarReadForMember(ctx, queryer, authenticatedAgent.MemberID, authenticatedAgent.WorkspaceID, candidateMessageID, message.SidecarKindReaction); err != nil {
			return reactionTargetScope{}, message.ThreadAccess{}, err
		}
		target, err := store.loadAgentReactionTarget(ctx, queryer, authenticatedAgent, targetScopeRow)
		if err != nil {
			return reactionTargetScope{}, message.ThreadAccess{}, err
		}
		if strings.TrimSpace(message.ScopeIDForThreadAccess(target)) != strings.TrimSpace(targetScopeRow.ScopeID) {
			return reactionTargetScope{}, message.ThreadAccess{}, ErrForbidden
		}
		return targetScopeRow, target, nil
	}

	resolvedMessageID := trimmedMessageID
	if shortID, ok := normalizeReactionToolMessageShortID(trimmedMessageID); ok {
		rows, err := queryer.Query(ctx, `
SELECT id
FROM scope_messages
WHERE workspace_id = $1
  AND id LIKE $2
ORDER BY id ASC
LIMIT 2
`, strings.TrimSpace(authenticatedAgent.WorkspaceID), shortID+"%")
		if err != nil {
			return "", reactionTargetScope{}, message.ThreadAccess{}, fmt.Errorf("resolve reaction message by short id: %w", err)
		}
		defer rows.Close()

		var matches []string
		for rows.Next() {
			var candidateMessageID string
			if err := rows.Scan(&candidateMessageID); err != nil {
				return "", reactionTargetScope{}, message.ThreadAccess{}, fmt.Errorf("scan reaction message id candidate: %w", err)
			}
			matches = append(matches, strings.TrimSpace(candidateMessageID))
		}
		if err := rows.Err(); err != nil {
			return "", reactionTargetScope{}, message.ThreadAccess{}, fmt.Errorf("iterate reaction message id candidates: %w", err)
		}
		switch len(matches) {
		case 0:
			return "", reactionTargetScope{}, message.ThreadAccess{}, ErrNotFound
		case 1:
			resolvedMessageID = matches[0]
		default:
			return "", reactionTargetScope{}, message.ThreadAccess{}, conflict("message identifier is ambiguous")
		}
	}

	targetScopeRow, target, err := resolveAuthorizedCandidate(resolvedMessageID)
	if err != nil {
		return "", reactionTargetScope{}, message.ThreadAccess{}, err
	}
	return resolvedMessageID, targetScopeRow, target, nil
}

func (store *Store) loadHotReactionMessageInThread(ctx context.Context, queryer reactionQueryRower, workspaceID string, threadID string, messageID string) (hotReactionMessage, error) {
	return store.loadHotReactionMessageInThreadWithLockClause(ctx, queryer, workspaceID, threadID, messageID, "")
}

func (store *Store) loadHotReactionMessageInThreadForUpdate(ctx context.Context, queryer reactionQueryRower, workspaceID string, threadID string, messageID string) (hotReactionMessage, error) {
	return store.loadHotReactionMessageInThreadWithLockClause(ctx, queryer, workspaceID, threadID, messageID, "FOR UPDATE")
}

func (store *Store) loadHotReactionMessageInTargetScope(ctx context.Context, queryer reactionQueryRower, workspaceID string, targetScope reactionTargetScope, messageID string) (hotReactionMessage, error) {
	return store.loadHotReactionMessageInTargetScopeWithLockClause(ctx, queryer, workspaceID, targetScope, messageID, "")
}

func (store *Store) loadHotReactionMessageInTargetScopeForUpdate(ctx context.Context, queryer reactionQueryRower, workspaceID string, targetScope reactionTargetScope, messageID string) (hotReactionMessage, error) {
	return store.loadHotReactionMessageInTargetScopeWithLockClause(ctx, queryer, workspaceID, targetScope, messageID, "FOR UPDATE")
}

func (store *Store) loadHotReactionMessageInTargetScopeWithLockClause(ctx context.Context, queryer reactionQueryRower, workspaceID string, targetScope reactionTargetScope, messageID string, lockClause string) (hotReactionMessage, error) {
	row := queryer.QueryRow(ctx, `
SELECT
    m.id,
    m.scope_id,
    m.seq,
    m.workspace_global_seq,
    m.author_member_id,
    COALESCE(author.user_id, ''),
    m.author_type,
    COALESCE(m.content, '')
FROM scope_messages m
JOIN members author ON author.id = m.author_member_id
                 AND author.workspace_id = m.workspace_id
WHERE m.workspace_id = $1
  AND m.id = $2
  AND m.scope_id = $3
`+lockClause+`
`, strings.TrimSpace(workspaceID), strings.TrimSpace(messageID), strings.TrimSpace(targetScope.ScopeID))

	var message hotReactionMessage
	if err := row.Scan(&message.MessageID, &message.ScopeID, &message.Seq, &message.GlobalSeq, &message.AuthorMemberID, &message.AuthorUserID, &message.AuthorType, &message.Content); err != nil {
		if coredb.IsNotFound(err) {
			return hotReactionMessage{}, ErrNotFound
		}
		return hotReactionMessage{}, fmt.Errorf("load reaction target scope message: %w", err)
	}
	message.ThreadID = strings.TrimSpace(targetScope.ThreadID)
	return message, nil
}

func (store *Store) loadHotReactionMessageInThreadWithLockClause(ctx context.Context, queryer reactionQueryRower, workspaceID string, threadID string, messageID string, lockClause string) (hotReactionMessage, error) {
	row := queryer.QueryRow(ctx, `
WITH requested_scope AS (
    SELECT
        s.id AS thread_id,
        s.id AS scope_id
    FROM scopes s
    WHERE s.workspace_id = $1
      AND s.id = $2
      AND s.type IN ('channel', 'dm', 'thread', 'service_account')
      AND s.status = 'active'
      AND s.archived_at IS NULL
)
SELECT
    m.id,
    requested_scope.thread_id,
    m.scope_id,
    m.seq,
    m.workspace_global_seq,
    m.author_member_id,
    COALESCE(author.user_id, ''),
    m.author_type,
    COALESCE(m.content, '')
FROM requested_scope
JOIN scope_messages m ON m.workspace_id = $1
                     AND m.id = $3
                     AND m.scope_id = requested_scope.scope_id
JOIN scopes s ON s.workspace_id = m.workspace_id
             AND s.id = m.scope_id
             AND s.status = 'active'
             AND s.archived_at IS NULL
JOIN members author ON author.id = m.author_member_id
                   AND author.workspace_id = m.workspace_id
`+lockClause+`
`, strings.TrimSpace(workspaceID), strings.TrimSpace(threadID), strings.TrimSpace(messageID))

	var message hotReactionMessage
	if err := row.Scan(&message.MessageID, &message.ThreadID, &message.ScopeID, &message.Seq, &message.GlobalSeq, &message.AuthorMemberID, &message.AuthorUserID, &message.AuthorType, &message.Content); err != nil {
		if coredb.IsNotFound(err) {
			return hotReactionMessage{}, ErrNotFound
		}
		return hotReactionMessage{}, fmt.Errorf("load hot reaction message: %w", err)
	}
	return message, nil
}

func (store *Store) loadHumanReactionTarget(ctx context.Context, queryer HumanMessageTargetQueryer, userID string, workspaceID string, targetScope reactionTargetScope, requireWrite bool) (access.HumanWorkspaceMember, message.ThreadAccess, error) {
	switch strings.TrimSpace(targetScope.ScopeType) {
	case "channel", "thread", "dm":
		membership, target, err := store.resolveAuthorizedHumanMessageTargetWithQueryer(ctx, queryer, userID, workspaceID, targetScope.RequestedScopeID, channelReadAction, dmReadAction, threadReadAction)
		if err != nil {
			return access.HumanWorkspaceMember{}, message.ThreadAccess{}, err
		}
		if strings.TrimSpace(message.ScopeIDForThreadAccess(target)) != strings.TrimSpace(targetScope.ScopeID) {
			return access.HumanWorkspaceMember{}, message.ThreadAccess{}, ErrForbidden
		}
		return membership, target, nil
	case "task":
		membership, err := access.LoadHumanWorkspaceMember(ctx, queryer, userID, workspaceID)
		if err != nil {
			if errors.Is(err, access.ErrForbidden) || errors.Is(err, access.ErrNotFound) {
				return access.HumanWorkspaceMember{}, message.ThreadAccess{}, ErrForbidden
			}
			return access.HumanWorkspaceMember{}, message.ThreadAccess{}, err
		}
		target, err := loadTaskReactionTargetForMember(ctx, queryer, workspaceID, membership.Member.ID, targetScope.RequestedScopeID)
		if err != nil {
			return access.HumanWorkspaceMember{}, message.ThreadAccess{}, err
		}
		return membership, target, nil
	case "service_account":
		membership, err := access.LoadHumanWorkspaceMember(ctx, queryer, userID, workspaceID)
		if err != nil {
			if errors.Is(err, access.ErrForbidden) || errors.Is(err, access.ErrNotFound) {
				return access.HumanWorkspaceMember{}, message.ThreadAccess{}, ErrForbidden
			}
			return access.HumanWorkspaceMember{}, message.ThreadAccess{}, err
		}
		if requireWrite {
			if err := requireActiveReactionScopeMembership(ctx, queryer, workspaceID, targetScope.ScopeID, membership.Member.ID); err != nil {
				return access.HumanWorkspaceMember{}, message.ThreadAccess{}, err
			}
		}
		return membership, message.ThreadAccess{
			ThreadID:    strings.TrimSpace(targetScope.ScopeID),
			ThreadType:  domain.ThreadTypeChannel,
			ChannelID:   strings.TrimSpace(targetScope.ScopeID),
			ChannelName: strings.TrimSpace(targetScope.ChannelSlug),
		}, nil
	default:
		return access.HumanWorkspaceMember{}, message.ThreadAccess{}, fmt.Errorf("invalid reaction scope type %q", strings.TrimSpace(targetScope.ScopeType))
	}
}

func requireActiveReactionScopeMembership(ctx context.Context, queryer coredb.QueryRower, workspaceID string, scopeID string, memberID string) error {
	row := queryer.QueryRow(ctx, `
SELECT 1
FROM scopes s
JOIN service_accounts service_account
  ON service_account.workspace_id = s.workspace_id
 AND service_account.message_scope_id = s.id
JOIN scope_memberships sm
  ON sm.scope_id = s.id
 AND sm.member_id = $3
 AND sm.removed_at IS NULL
 AND sm.archived_at IS NULL
 AND sm.effective_to_seq IS NULL
 AND sm.visibility = 'public'
WHERE s.workspace_id = $1
  AND s.id = $2
  AND s.type = 'service_account'
  AND s.status = 'active'
  AND s.archived_at IS NULL
LIMIT 1
`, strings.TrimSpace(workspaceID), strings.TrimSpace(scopeID), strings.TrimSpace(memberID))
	var marker int
	if err := row.Scan(&marker); err != nil {
		if coredb.IsNotFound(err) {
			return ErrForbidden
		}
		return fmt.Errorf("check service-account reaction membership: %w", err)
	}
	return nil
}

func (store *Store) loadAgentReactionTarget(ctx context.Context, queryer coredb.QueryRower, authenticatedAgent agent.AuthenticatedAgent, targetScope reactionTargetScope) (message.ThreadAccess, error) {
	switch strings.TrimSpace(targetScope.ScopeType) {
	case "dm":
		target, err := message.ResolveDMThreadByIDForMember(ctx, queryer, authenticatedAgent.MemberID, authenticatedAgent.WorkspaceID, targetScope.RequestedScopeID)
		if err != nil {
			return message.ThreadAccess{}, mapMessagingError(err)
		}
		return message.ThreadAccess(target), nil
	case "thread":
		shape, err := message.LoadReplyThreadShape(ctx, queryer, authenticatedAgent.WorkspaceID, targetScope.RequestedScopeID)
		if err != nil {
			return message.ThreadAccess{}, mapMessagingError(err)
		}
		if shape.ParentThreadType == domain.ThreadTypeDM {
			memberShape, err := message.RequireMemberReplyThreadRead(ctx, queryer, authenticatedAgent.MemberID, authenticatedAgent.WorkspaceID, targetScope.RequestedScopeID)
			if err != nil {
				return message.ThreadAccess{}, mapMessagingError(err)
			}
			shape.PeerMemberID = memberShape.PeerMemberID
		}
		return message.ThreadAccess{
			ThreadID:     shape.ThreadID,
			ThreadType:   shape.ParentThreadType,
			ChannelID:    strings.TrimSpace(targetScope.RequestedScopeID),
			PeerMemberID: shape.PeerMemberID,
			ReplyThread: &message.ReplyThreadTarget{
				ParentMessageID:   shape.ParentMessageID,
				ShortID:           shape.ParentMessageShort,
				ParentChannelID:   shape.ParentChannelID,
				ParentChannelName: shape.ParentChannelName,
			},
		}, nil
	case "channel":
		return message.ThreadAccess{
			ThreadID:    strings.TrimSpace(targetScope.RequestedScopeID),
			ThreadType:  domain.ThreadTypeChannel,
			ChannelID:   strings.TrimSpace(targetScope.RequestedScopeID),
			ChannelName: strings.TrimSpace(targetScope.ChannelSlug),
		}, nil
	case "task":
		return loadTaskReactionTargetForMember(ctx, queryer, authenticatedAgent.WorkspaceID, authenticatedAgent.MemberID, targetScope.RequestedScopeID)
	default:
		return message.ThreadAccess{}, fmt.Errorf("invalid reaction scope type %q", strings.TrimSpace(targetScope.ScopeType))
	}
}

func loadTaskReactionTargetForMember(ctx context.Context, queryer coredb.QueryRower, workspaceID string, memberID string, taskScopeID string) (message.ThreadAccess, error) {
	// Authorize through the authoritative Task->active board Channel boundary: the
	// server resolves the task's unique channel from the active Task/board
	// authority (tasks.board_id -> task_boards, never a denormalized
	// tasks.channel_id alone), then verifies the member holds ACTIVE membership
	// on that channel scope. The membership check is principal-agnostic (the
	// same scope_memberships active-row predicate serves both human and Agent
	// members), so a human reacting to a task discussion is authorized by their
	// board-channel membership exactly as an Agent is — the Task scope is only
	// target identity, and a retained Task membership with no Channel membership
	// grants nothing. Orphaned/legacy boards, cross-workspace, other-channel,
	// deleted/archived/unresolvable, and kind-mismatch cases all fail closed
	// non-enumerating (uniform forbidden).
	resolved, err := access.ResolveActiveTaskBoardChannel(ctx, queryer, workspaceID, taskScopeID)
	if err != nil {
		return message.ThreadAccess{}, err
	}
	active, err := access.IsActiveMemberInScope(ctx, queryer, memberID, resolved.ChannelID)
	if err != nil {
		return message.ThreadAccess{}, err
	}
	if !active {
		return message.ThreadAccess{}, ErrForbidden
	}
	channelName := fmt.Sprintf("#t%d", resolved.TaskNumber)
	if strings.TrimSpace(resolved.Title) != "" {
		channelName = fmt.Sprintf("%s %s", channelName, resolved.Title)
	}
	return message.ThreadAccess{
		ThreadID:    strings.TrimSpace(resolved.TaskScopeID),
		ThreadType:  domain.ThreadTypeChannel,
		ChannelID:   strings.TrimSpace(resolved.TaskScopeID),
		ChannelName: channelName,
	}, nil
}

func (store *Store) loadAgentRecipientByMemberID(ctx context.Context, queryer reactionQueryRower, workspaceID string, agentMemberID string) (message.AgentRecipient, error) {
	row := queryer.QueryRow(ctx, `
SELECT id, machine_id, member_id, auto_wake_enabled
FROM agents
WHERE workspace_id = $1
  AND member_id = $2
`, strings.TrimSpace(workspaceID), strings.TrimSpace(agentMemberID))

	var recipient message.AgentRecipient
	if err := row.Scan(&recipient.AgentID, &recipient.MachineID, &recipient.AgentMemberID, &recipient.AutoWakeEnabled); err != nil {
		if coredb.IsNotFound(err) {
			return message.AgentRecipient{}, ErrNotFound
		}
		return message.AgentRecipient{}, fmt.Errorf("load agent recipient by member id: %w", err)
	}
	return recipient, nil
}

func (store *Store) addReactionForActor(ctx context.Context, tx coredb.Tx, workspaceID string, target message.ThreadAccess, targetScope reactionTargetScope, targetMessage hotReactionMessage, actor message.ReactionActorIdentity, principalID string, emoji string) (message.AddReactionResult, error) {
	normalizedEmoji, err := validateReactionEmoji(emoji)
	if err != nil {
		return message.AddReactionResult{}, err
	}
	if err := validateReactionWriteScopeTargetContract(workspaceID, principalID, target, targetScope, actor); err != nil {
		return message.AddReactionResult{}, err
	}
	notificationContext := reactionNotificationContextForScope(target, targetScope)
	channelType := strings.TrimSpace(notificationContext.ChannelType)

	if _, exists, err := loadExistingActorReaction(ctx, tx, targetMessage.MessageID, normalizedEmoji, actor.MemberID); err != nil {
		return message.AddReactionResult{}, err
	} else if exists {
		return message.AddReactionResult{
			Changed:     false,
			ChannelID:   appChannelIDForTarget(target),
			ChannelType: channelType,
		}, nil
	}

	if err := enforceReactionEmojiKindsLimit(ctx, tx, targetMessage.MessageID, normalizedEmoji); err != nil {
		return message.AddReactionResult{}, err
	}

	createdAt, err := currentDBTimestamp(ctx, tx)
	if err != nil {
		return message.AddReactionResult{}, err
	}
	reactionID, err := identifiers.NewEntityID("rxn")
	if err != nil {
		return message.AddReactionResult{}, fmt.Errorf("generate reaction id: %w", err)
	}

	activitySeq := int64(0)
	if targetMessage.AuthorType == string(domain.MemberTypeAgent) &&
		strings.TrimSpace(targetMessage.AuthorMemberID) != strings.TrimSpace(actor.MemberID) {
		seq, err := message.AllocateNextScopeActivitySeq(ctx, tx, targetMessage.ScopeID)
		if err != nil {
			return message.AddReactionResult{}, err
		}
		activitySeq = seq
	}
	mutation, err := message.NewScopeRepositories(tx).Reactions.Add(ctx, message.ScopeMessageReactionRecord{
		ID:                reactionID,
		WorkspaceID:       workspaceID,
		ScopeID:           targetMessage.ScopeID,
		MessageID:         targetMessage.MessageID,
		Emoji:             normalizedEmoji,
		ReactorMemberID:   actor.MemberID,
		ActivitySeq:       activitySeq,
		ActivityGlobalSeq: activitySeq,
		CreatedAt:         createdAt,
	})
	if err != nil {
		return message.AddReactionResult{}, err
	}
	if !mutation.Changed {
		return message.AddReactionResult{
			Changed:     false,
			ChannelID:   appChannelIDForTarget(target),
			ChannelType: channelType,
		}, nil
	}

	publicNames, err := store.loadPublicMemberNamesByMemberID(ctx, tx, workspaceID, []string{actor.MemberID})
	if err != nil {
		return message.AddReactionResult{}, fmt.Errorf("normalize reactor public name: %w", err)
	}
	actor.ActorName = publicMemberNameForMemberID(actor.MemberID, publicNames)
	actor.AuthorProfile = reactionActorAuthorProfile(actor, principalID)

	result := message.AddReactionResult{
		Changed:     true,
		ChannelID:   appChannelIDForTarget(target),
		ChannelType: channelType,
		Reaction: message.MessageReaction{
			ID:            reactionID,
			Emoji:         normalizedEmoji,
			AuthorProfile: actor.AuthorProfile,
			CreatedAt:     createdAt,
		},
	}

	if targetMessage.AuthorType == string(domain.MemberTypeHuman) &&
		strings.TrimSpace(targetMessage.AuthorUserID) != "" &&
		strings.TrimSpace(targetMessage.AuthorMemberID) != strings.TrimSpace(actor.MemberID) {
		result.Notification = &message.HumanReactionNotification{
			UserID:            strings.TrimSpace(targetMessage.AuthorUserID),
			ReactionID:        reactionID,
			MessageID:         targetMessage.MessageID,
			ChannelID:         result.ChannelID,
			ChannelType:       strings.TrimSpace(notificationContext.ChannelType),
			ParentChannelID:   strings.TrimSpace(notificationContext.ParentChannelID),
			ParentChannelType: strings.TrimSpace(notificationContext.ParentChannelType),
			ParentMessageID:   strings.TrimSpace(notificationContext.ParentMessageID),
			TaskContext:       reactionNotificationTaskContextForScope(targetScope),
			AuthorProfile:     actor.AuthorProfile,
			Emoji:             normalizedEmoji,
			ContentPreview:    truncateReactionNotificationPreview(targetMessage.Content),
		}
	}

	if targetMessage.AuthorType != string(domain.MemberTypeAgent) || strings.TrimSpace(targetMessage.AuthorMemberID) == strings.TrimSpace(actor.MemberID) {
		return result, nil
	}

	recipient, err := store.loadAgentRecipientByMemberID(ctx, tx, workspaceID, targetMessage.AuthorMemberID)
	if err != nil {
		return message.AddReactionResult{}, err
	}
	targetPreview := truncateReactionTargetPreview(targetMessage.Content)
	deliveryID, err := identifiers.NewEntityID("rxact")
	if err != nil {
		return message.AddReactionResult{}, fmt.Errorf("generate reaction activity receive id: %w", err)
	}
	receiveMessage := buildReactionReceiveMessage(target, actor, deliveryID, reactionID, targetMessage.MessageID, targetPreview, normalizedEmoji, createdAt)

	result.Delivery = &message.ReactionDelivery{
		Recipient: recipient,
		Seq:       syntheticReactionDeliverySeq(createdAt),
		Message:   receiveMessage,
	}
	return result, nil
}

func (store *Store) removeReactionForActor(ctx context.Context, tx coredb.Tx, workspaceID string, target message.ThreadAccess, targetScope reactionTargetScope, actor message.ReactionActorIdentity, principalID string, messageID string, emoji string) (message.RemoveReactionResult, error) {
	normalizedEmoji, err := validateReactionEmoji(emoji)
	if err != nil {
		return message.RemoveReactionResult{}, err
	}
	if err := validateReactionWriteScopeTargetContract(workspaceID, principalID, target, targetScope, actor); err != nil {
		return message.RemoveReactionResult{}, err
	}
	channelType := strings.TrimSpace(reactionNotificationContextForScope(target, targetScope).ChannelType)

	reaction, exists, err := loadExistingActorReaction(ctx, tx, messageID, normalizedEmoji, actor.MemberID)
	if err != nil {
		return message.RemoveReactionResult{}, err
	}
	if !exists {
		return message.RemoveReactionResult{
			Changed:     false,
			ChannelID:   appChannelIDForTarget(target),
			ChannelType: channelType,
		}, nil
	}

	suppressedAt, err := currentDBTimestamp(ctx, tx)
	if err != nil {
		return message.RemoveReactionResult{}, err
	}
	removed, err := message.NewScopeRepositories(tx).Reactions.Remove(ctx, messageID, normalizedEmoji, actor.MemberID, suppressedAt)
	if err != nil {
		return message.RemoveReactionResult{}, err
	}
	if !removed.Changed {
		return message.RemoveReactionResult{
			Changed:     false,
			ChannelID:   appChannelIDForTarget(target),
			ChannelType: channelType,
		}, nil
	}
	publicNames, err := store.loadPublicMemberNamesByMemberID(ctx, tx, workspaceID, []string{actor.MemberID})
	if err != nil {
		return message.RemoveReactionResult{}, fmt.Errorf("normalize reactor public name: %w", err)
	}
	actor.ActorName = publicMemberNameForMemberID(actor.MemberID, publicNames)
	actor.AuthorProfile = reactionActorAuthorProfile(actor, principalID)

	return message.RemoveReactionResult{
		Changed:     true,
		ChannelID:   appChannelIDForTarget(target),
		ChannelType: channelType,
		ReactionID:  reaction.ID,
		Reaction: message.MessageReaction{
			ID:            reaction.ID,
			Emoji:         reaction.Emoji,
			AuthorProfile: actor.AuthorProfile,
			CreatedAt:     reaction.CreatedAt,
		},
	}, nil
}

func (store *Store) AddReactionForUser(ctx context.Context, userID string, workspaceID string, messageID string, emoji string) (message.AddReactionResult, error) {
	return withTxValue(ctx, store.db, func(tx coredb.Tx) (message.AddReactionResult, error) {
		targetScopeRow, err := store.loadReactionTargetScope(ctx, tx, workspaceID, messageID)
		if err != nil {
			return message.AddReactionResult{}, err
		}
		if err := lockActiveTaskReactionScope(ctx, tx, workspaceID, targetScopeRow); err != nil {
			return message.AddReactionResult{}, err
		}
		if err := store.authorizeMessageSidecarReadForUser(ctx, tx, userID, workspaceID, messageID, message.SidecarKindReaction); err != nil {
			return message.AddReactionResult{}, err
		}
		membership, target, err := store.loadHumanReactionTarget(ctx, tx, userID, workspaceID, targetScopeRow, true)
		if err != nil {
			return message.AddReactionResult{}, err
		}
		targetMessage, err := store.loadHotReactionMessageInTargetScopeForUpdate(ctx, tx, workspaceID, targetScopeRow, messageID)
		if err != nil {
			return message.AddReactionResult{}, err
		}
		actor, err := store.loadReactionActorIdentity(ctx, tx, workspaceID, membership.Member.ID)
		if err != nil {
			return message.AddReactionResult{}, err
		}
		return store.addReactionForActor(ctx, tx, workspaceID, target, targetScopeRow, targetMessage, actor, userID, emoji)
	})
}

func (store *Store) RemoveReactionForUser(ctx context.Context, userID string, workspaceID string, messageID string, emoji string) (message.RemoveReactionResult, error) {
	return withTxValue(ctx, store.db, func(tx coredb.Tx) (message.RemoveReactionResult, error) {
		targetScopeRow, err := store.loadReactionTargetScope(ctx, tx, workspaceID, messageID)
		if err != nil {
			return message.RemoveReactionResult{}, err
		}
		if err := lockActiveTaskReactionScope(ctx, tx, workspaceID, targetScopeRow); err != nil {
			return message.RemoveReactionResult{}, err
		}
		if err := store.authorizeMessageSidecarReadForUser(ctx, tx, userID, workspaceID, messageID, message.SidecarKindReaction); err != nil {
			return message.RemoveReactionResult{}, err
		}
		membership, target, err := store.loadHumanReactionTarget(ctx, tx, userID, workspaceID, targetScopeRow, true)
		if err != nil {
			return message.RemoveReactionResult{}, err
		}
		if _, err := store.loadHotReactionMessageInTargetScopeForUpdate(ctx, tx, workspaceID, targetScopeRow, messageID); err != nil {
			return message.RemoveReactionResult{}, err
		}
		actor, err := store.loadReactionActorIdentity(ctx, tx, workspaceID, membership.Member.ID)
		if err != nil {
			return message.RemoveReactionResult{}, err
		}
		return store.removeReactionForActor(ctx, tx, workspaceID, target, targetScopeRow, actor, userID, messageID, emoji)
	})
}

func (store *Store) GetMessageReactionsForUser(ctx context.Context, userID string, workspaceID string, messageID string) ([]message.MessageReactionAggregate, error) {
	targetScopeRow, err := store.loadReactionTargetScope(ctx, store.db, workspaceID, messageID)
	if err != nil {
		return nil, err
	}
	if err := store.authorizeMessageSidecarReadForUser(ctx, store.db, userID, workspaceID, messageID, message.SidecarKindReaction); err != nil {
		return nil, err
	}
	membership, _, err := store.loadHumanReactionTarget(ctx, store.db, userID, workspaceID, targetScopeRow, false)
	if err != nil {
		return nil, err
	}
	if _, err := store.loadHotReactionMessageInTargetScope(ctx, store.db, workspaceID, targetScopeRow, messageID); err != nil {
		return nil, err
	}
	return store.loadMessageReactionAggregates(ctx, store.db, workspaceID, membership.Member.ID, []string{messageID})
}

func (store *Store) ResolveReactionMessageIDForAgent(ctx context.Context, authenticatedAgent agent.AuthenticatedAgent, messageID string) (string, error) {
	resolvedMessageID, _, _, err := store.resolveReactionMessageReferenceForAgent(ctx, store.db, authenticatedAgent, messageID)
	if err != nil {
		return "", err
	}
	return resolvedMessageID, nil
}

func (store *Store) AddReactionForAgent(ctx context.Context, authenticatedAgent agent.AuthenticatedAgent, messageID string, emoji string) (message.AddReactionResult, error) {
	return withTxValue(ctx, store.db, func(tx coredb.Tx) (message.AddReactionResult, error) {
		targetScopeRow, err := store.loadReactionTargetScope(ctx, tx, authenticatedAgent.WorkspaceID, messageID)
		if err != nil {
			return message.AddReactionResult{}, err
		}
		if err := lockActiveTaskReactionScope(ctx, tx, authenticatedAgent.WorkspaceID, targetScopeRow); err != nil {
			return message.AddReactionResult{}, err
		}
		if err := store.authorizeMessageSidecarReadForMember(ctx, tx, authenticatedAgent.MemberID, authenticatedAgent.WorkspaceID, messageID, message.SidecarKindReaction); err != nil {
			return message.AddReactionResult{}, err
		}
		target, err := store.loadAgentReactionTarget(ctx, tx, authenticatedAgent, targetScopeRow)
		if err != nil {
			return message.AddReactionResult{}, err
		}
		if strings.TrimSpace(message.ScopeIDForThreadAccess(target)) != strings.TrimSpace(targetScopeRow.ScopeID) {
			return message.AddReactionResult{}, ErrForbidden
		}
		targetMessage, err := store.loadHotReactionMessageInTargetScopeForUpdate(ctx, tx, authenticatedAgent.WorkspaceID, targetScopeRow, messageID)
		if err != nil {
			return message.AddReactionResult{}, err
		}
		actor, err := store.loadReactionActorIdentity(ctx, tx, authenticatedAgent.WorkspaceID, authenticatedAgent.MemberID)
		if err != nil {
			return message.AddReactionResult{}, err
		}
		result, err := store.addReactionForActor(ctx, tx, authenticatedAgent.WorkspaceID, target, targetScopeRow, targetMessage, actor, authenticatedAgent.AgentID, emoji)
		if err != nil {
			return message.AddReactionResult{}, err
		}
		return result, nil
	})
}

func (store *Store) RemoveReactionForAgent(ctx context.Context, authenticatedAgent agent.AuthenticatedAgent, messageID string, emoji string) (message.RemoveReactionResult, error) {
	return withTxValue(ctx, store.db, func(tx coredb.Tx) (message.RemoveReactionResult, error) {
		targetScopeRow, err := store.loadReactionTargetScope(ctx, tx, authenticatedAgent.WorkspaceID, messageID)
		if err != nil {
			return message.RemoveReactionResult{}, err
		}
		if err := lockActiveTaskReactionScope(ctx, tx, authenticatedAgent.WorkspaceID, targetScopeRow); err != nil {
			return message.RemoveReactionResult{}, err
		}
		if err := store.authorizeMessageSidecarReadForMember(ctx, tx, authenticatedAgent.MemberID, authenticatedAgent.WorkspaceID, messageID, message.SidecarKindReaction); err != nil {
			return message.RemoveReactionResult{}, err
		}
		target, err := store.loadAgentReactionTarget(ctx, tx, authenticatedAgent, targetScopeRow)
		if err != nil {
			return message.RemoveReactionResult{}, err
		}
		if strings.TrimSpace(message.ScopeIDForThreadAccess(target)) != strings.TrimSpace(targetScopeRow.ScopeID) {
			return message.RemoveReactionResult{}, ErrForbidden
		}
		if _, err := store.loadHotReactionMessageInTargetScopeForUpdate(ctx, tx, authenticatedAgent.WorkspaceID, targetScopeRow, messageID); err != nil {
			return message.RemoveReactionResult{}, err
		}
		actor, err := store.loadReactionActorIdentity(ctx, tx, authenticatedAgent.WorkspaceID, authenticatedAgent.MemberID)
		if err != nil {
			return message.RemoveReactionResult{}, err
		}
		result, err := store.removeReactionForActor(ctx, tx, authenticatedAgent.WorkspaceID, target, targetScopeRow, actor, authenticatedAgent.AgentID, messageID, emoji)
		if err != nil {
			return message.RemoveReactionResult{}, err
		}
		return result, nil
	})
}

func (store *Store) GetMessageReactionsForAgent(ctx context.Context, authenticatedAgent agent.AuthenticatedAgent, messageID string) ([]message.MessageReactionAggregate, error) {
	targetScopeRow, err := store.loadReactionTargetScope(ctx, store.db, authenticatedAgent.WorkspaceID, messageID)
	if err != nil {
		return nil, err
	}
	if err := store.authorizeMessageSidecarReadForMember(ctx, store.db, authenticatedAgent.MemberID, authenticatedAgent.WorkspaceID, messageID, message.SidecarKindReaction); err != nil {
		return nil, err
	}
	target, err := store.loadAgentReactionTarget(ctx, store.db, authenticatedAgent, targetScopeRow)
	if err != nil {
		return nil, err
	}
	if strings.TrimSpace(message.ScopeIDForThreadAccess(target)) != strings.TrimSpace(targetScopeRow.ScopeID) {
		return nil, ErrForbidden
	}
	if _, err := store.loadHotReactionMessageInTargetScope(ctx, store.db, authenticatedAgent.WorkspaceID, targetScopeRow, messageID); err != nil {
		return nil, err
	}
	return store.loadMessageReactionAggregates(ctx, store.db, authenticatedAgent.WorkspaceID, authenticatedAgent.MemberID, []string{messageID})
}
