package tasktimeline

import (
	"context"
	"fmt"
	"net/url"
	"sort"
	"strconv"
	"strings"
	"time"

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

const taskDiscussionAddedEventType = "discussion_added"

// TaskAttentionSnapshot is the immutable event audience and aggregated Inbox
// projection frozen in the same transaction as a Task message/activity.
//
// Participation is intentionally not attention. The base audience is:
//   - Human Watch (scope notification state active)
//   - Agent Watch (scope notification state auto-wake)
//   - the current canonical assignee
//
// Callers may add direct-mention recipients for a single event. Those additions
// do not mutate the persistent Watch setting.
type TaskAttentionSnapshot struct {
	WorkspaceID             string
	TaskID                  string
	TaskScopeID             string
	HumanRecipientMemberIDs []string
	AgentRecipients         []message.AgentRecipient
	ActorProfile            message.AuthorProfile
	Conversation            message.DeliveryConversationProjection
}

// LoadTaskAttentionSnapshotForScope freezes Task attention for a discussion
// message. directHumanMemberIDs and directAgentRecipients are event-local
// mention penetrations and are unioned with Watch/assignee attention.
func LoadTaskAttentionSnapshotForScope(
	ctx context.Context,
	queryer coredb.QueryExecer,
	workspaceID string,
	taskScopeID string,
	actorMemberID string,
	directHumanMemberIDs []string,
	directAgentRecipients []message.AgentRecipient,
	activityType string,
	preview string,
	createdAt time.Time,
) (TaskAttentionSnapshot, error) {
	var taskID string
	if err := queryer.QueryRow(ctx, `
SELECT t.id
FROM tasks t
JOIN task_boards task_board
  ON task_board.id = t.board_id
 AND task_board.workspace_id = $1
JOIN scopes channel_scope
  ON channel_scope.id = task_board.channel_id
 AND channel_scope.workspace_id = $1
 AND channel_scope.type = 'channel'
 AND channel_scope.status = 'active'
 AND channel_scope.archived_at IS NULL
 AND t.channel_id = task_board.channel_id
WHERE channel_scope.workspace_id = $1
  AND t.task_scope_id = $2
`, strings.TrimSpace(workspaceID), strings.TrimSpace(taskScopeID)).Scan(&taskID); err != nil {
		return TaskAttentionSnapshot{}, fmt.Errorf("load task attention target by scope: %w", err)
	}
	return loadTaskAttentionSnapshot(
		ctx,
		queryer,
		strings.TrimSpace(taskID),
		strings.TrimSpace(actorMemberID),
		directHumanMemberIDs,
		directAgentRecipients,
		activityType,
		preview,
		createdAt,
	)
}

func projectTypedActivityAttention(ctx context.Context, tx coredb.Tx, activities []TypedActivityProjection) error {
	repositories := message.NewScopeRepositories(tx)
	for _, activity := range coalesceTaskAttentionActivities(activities) {
		// The Task discussion message outbox is the sole fanout owner for a
		// discussion. Its paired typed timeline event remains a read model and
		// must never double-increment unread or wake Agents.
		snapshot, err := loadTaskAttentionSnapshot(
			ctx,
			tx,
			activity.TaskID,
			activity.ActorMemberID,
			nil,
			nil,
			activity.ActivityType,
			taskActivityPreview(activity),
			activity.CreatedAt,
		)
		if err != nil {
			return err
		}
		if len(snapshot.HumanRecipientMemberIDs) == 0 && len(snapshot.AgentRecipients) == 0 {
			continue
		}
		receiveMessage := taskActivityReceiveMessage(activity, snapshot)
		intent := message.DeliveryIntent{
			WorkspaceID: snapshot.WorkspaceID,
			ScopeID:     snapshot.TaskScopeID,
			SourceRef: message.DeliverySourceRef{
				Type: message.DeliverySourceRefActivityEvent,
				ID:   activity.EventID,
			},
			FanoutType:              message.ScopeOutboxFanoutScope,
			Recipients:              append([]message.AgentRecipient(nil), snapshot.AgentRecipients...),
			WakeRecipients:          append([]message.AgentRecipient(nil), snapshot.AgentRecipients...),
			HumanRecipientMemberIDs: append([]string(nil), snapshot.HumanRecipientMemberIDs...),
			HumanRecipientsTargeted: true,
			Conversation:            &snapshot.Conversation,
			ReceiveMessage:          receiveMessage,
			RealtimePolicy:          message.DeliveryRealtimeNone,
			AttentionPolicy:         message.DeliveryAttentionWake,
			CreatedAt:               activity.CreatedAt,
			NextAttemptAt:           activity.CreatedAt,
			MaxAttempts:             3,
		}
		record, err := message.BuildScopeDeliveryIntentOutboxRecord(intent)
		if err != nil {
			return fmt.Errorf("build task activity attention outbox: %w", err)
		}
		if err := repositories.Outbox.Insert(ctx, record); err != nil {
			return fmt.Errorf("insert task activity attention outbox: %w", err)
		}
	}
	return nil
}

// coalesceTaskAttentionActivities chooses at most one alert owner per Task in
// one TaskTimelinePlan. A plan is one committed mutation even when it records
// several Activity facts (for example updated + assigned). The timeline keeps
// every fact; unread and Agent wake are emitted once.
func coalesceTaskAttentionActivities(activities []TypedActivityProjection) []TypedActivityProjection {
	if len(activities) == 0 {
		return nil
	}
	chosen := make(map[string]TypedActivityProjection, len(activities))
	order := make([]string, 0, len(activities))
	for _, activity := range activities {
		if activity.SuppressAttention ||
			strings.TrimSpace(activity.EventType) == taskDiscussionAddedEventType ||
			strings.TrimSpace(activity.SourceMessageID) != "" {
			continue
		}
		taskID := strings.TrimSpace(activity.TaskID)
		if taskID == "" {
			continue
		}
		current, exists := chosen[taskID]
		if !exists {
			chosen[taskID] = activity
			order = append(order, taskID)
			continue
		}
		if taskAttentionPriority(activity.EventType) > taskAttentionPriority(current.EventType) ||
			(taskAttentionPriority(activity.EventType) == taskAttentionPriority(current.EventType) && activity.Seq > current.Seq) {
			chosen[taskID] = activity
		}
	}
	result := make([]TypedActivityProjection, 0, len(order))
	for _, taskID := range order {
		result = append(result, chosen[taskID])
	}
	return result
}

func taskAttentionPriority(eventType string) int {
	switch strings.TrimSpace(eventType) {
	case "reviewer_assigned", "review_requested", "review_approved", "review_changes_requested", "review_cancelled":
		return 5
	case "assigned", "unassigned", "blocked", "unblocked", "status_changed":
		return 4
	case "checkpoint_added", "artifact_added", "checklist_updated":
		return 3
	case "created":
		return 2
	case "updated":
		return 1
	default:
		return 2
	}
}

type taskAttentionProjection struct {
	WorkspaceID           string
	TaskID                string
	TaskScopeID           string
	ChannelID             string
	TaskNumber            int64
	Title                 string
	Status                string
	ParentTaskRef         string
	SourceThreadChannelID string
	SourceParentChannelID string
	SourceParentMessageID string
	SourceThreadRef       string
	AssigneeMemberID      string
}

func loadTaskAttentionSnapshot(
	ctx context.Context,
	queryer coredb.QueryExecer,
	taskID string,
	actorMemberID string,
	directHumanMemberIDs []string,
	directAgentRecipients []message.AgentRecipient,
	activityType string,
	preview string,
	createdAt time.Time,
) (TaskAttentionSnapshot, error) {
	task, err := loadTaskAttentionProjection(ctx, queryer, taskID)
	if err != nil {
		return TaskAttentionSnapshot{}, err
	}
	// Assignment is an attention relation even when an old Task predates the
	// canonical Task-scope membership backfill. Materialize the assignee as a
	// participant before freezing the audience so the same transaction also
	// makes the later IM stream and unread-roster projections deliverable.
	// This deliberately does not create or change a Watch setting.
	if err := ensureTaskAssigneeAttentionMembership(ctx, queryer, task, createdAt); err != nil {
		return TaskAttentionSnapshot{}, err
	}
	humanMemberIDs, agentRecipients, err := loadTaskAttentionRecipients(ctx, queryer, task.TaskID, actorMemberID)
	if err != nil {
		return TaskAttentionSnapshot{}, err
	}
	humanMemberIDs = normalizeMemberIDs(append(append([]string(nil), humanMemberIDs...), directHumanMemberIDs...))
	humanMemberIDs = removeMemberID(humanMemberIDs, actorMemberID)
	agentRecipients = mergeTaskAgentRecipients(agentRecipients, directAgentRecipients, actorMemberID)

	profiles, err := loadTaskAttentionProfiles(ctx, queryer, task.WorkspaceID, []string{actorMemberID, task.AssigneeMemberID})
	if err != nil {
		return TaskAttentionSnapshot{}, err
	}
	actorProfile := profiles[strings.TrimSpace(actorMemberID)]
	assigneeProfile, hasAssignee := profiles[strings.TrimSpace(task.AssigneeMemberID)]
	conversation := taskConversationProjection(task, assigneeProfile, hasAssignee, actorProfile, activityType, preview, createdAt)
	return TaskAttentionSnapshot{
		WorkspaceID:             task.WorkspaceID,
		TaskID:                  task.TaskID,
		TaskScopeID:             task.TaskScopeID,
		HumanRecipientMemberIDs: humanMemberIDs,
		AgentRecipients:         agentRecipients,
		ActorProfile:            actorProfile,
		Conversation:            conversation,
	}, nil
}

func ensureTaskAssigneeAttentionMembership(
	ctx context.Context,
	execer coredb.QueryExecer,
	task taskAttentionProjection,
	createdAt time.Time,
) error {
	assigneeMemberID := strings.TrimSpace(task.AssigneeMemberID)
	if assigneeMemberID == "" {
		return nil
	}

	var activeMemberID string
	if err := execer.QueryRow(ctx, `
SELECT id
FROM members
WHERE id = $1
  AND workspace_id = $2
  AND status = 'active'
`, assigneeMemberID, strings.TrimSpace(task.WorkspaceID)).Scan(&activeMemberID); err != nil {
		if coredb.IsNotFound(err) {
			// Rolling legacy writes may briefly expose a stale responsibility
			// ref. Keep the Task mutation available, but do not turn a dangling
			// or cross-workspace ref into a visibility relation.
			return nil
		}
		return fmt.Errorf("load task assignee attention member: %w", err)
	}
	if err := message.EnsureScopeMembership(
		ctx,
		execer,
		strings.TrimSpace(task.TaskScopeID),
		strings.TrimSpace(activeMemberID),
		"public",
		"",
		"",
		0,
		0,
		createdAt,
	); err != nil {
		return fmt.Errorf("ensure task assignee attention membership: %w", err)
	}
	return nil
}

func loadTaskAttentionProjection(ctx context.Context, queryer coredb.Queryer, taskID string) (taskAttentionProjection, error) {
	var item taskAttentionProjection
	if err := queryer.QueryRow(ctx, `
SELECT
  channel_scope.workspace_id,
  t.id,
  t.task_scope_id,
  task_board.channel_id,
  t.task_number,
  t.title,
  t.status,
  t.parent_task_ref,
  t.source_thread_channel_id,
  t.source_thread_parent_channel_id,
  t.source_thread_parent_message_id,
  t.source_thread_ref,
  COALESCE(`+taskcompat.EffectiveAssigneeRefSQL("t")+`, '')
FROM tasks t
JOIN task_boards task_board
  ON task_board.id = t.board_id
JOIN scopes channel_scope
  ON channel_scope.id = task_board.channel_id
 AND channel_scope.workspace_id = task_board.workspace_id
 AND channel_scope.type = 'channel'
 AND channel_scope.status = 'active'
 AND channel_scope.archived_at IS NULL
 AND t.channel_id = task_board.channel_id
WHERE t.id = $1
`, strings.TrimSpace(taskID)).Scan(
		&item.WorkspaceID,
		&item.TaskID,
		&item.TaskScopeID,
		&item.ChannelID,
		&item.TaskNumber,
		&item.Title,
		&item.Status,
		&item.ParentTaskRef,
		&item.SourceThreadChannelID,
		&item.SourceParentChannelID,
		&item.SourceParentMessageID,
		&item.SourceThreadRef,
		&item.AssigneeMemberID,
	); err != nil {
		return taskAttentionProjection{}, fmt.Errorf("load task attention projection: %w", err)
	}
	return item, nil
}

func loadTaskAttentionRecipients(ctx context.Context, queryer coredb.Queryer, taskID string, actorMemberID string) ([]string, []message.AgentRecipient, error) {
	rows, err := queryer.Query(ctx, `
WITH task_attention AS (
  SELECT
    t.task_scope_id,
    channel_scope.workspace_id,
    COALESCE(`+taskcompat.EffectiveAssigneeRefSQL("t")+`, '') AS assignee_member_id
  FROM tasks t
  JOIN task_boards task_board
    ON task_board.id = t.board_id
  JOIN scopes channel_scope
    ON channel_scope.id = task_board.channel_id
   AND channel_scope.workspace_id = task_board.workspace_id
   AND channel_scope.type = 'channel'
   AND channel_scope.status = 'active'
   AND channel_scope.archived_at IS NULL
   AND t.channel_id = task_board.channel_id
  WHERE t.id = $1
)
SELECT
  m.id,
  m.type,
  COALESCE(a.id, ''),
  COALESCE(a.machine_id, ''),
  COALESCE(sns.task_watch_state, ''),
  CASE WHEN task.assignee_member_id = m.id THEN 1 ELSE 0 END
FROM task_attention task
JOIN scope_memberships sm
  ON sm.scope_id = task.task_scope_id
 AND sm.removed_at IS NULL
 AND sm.archived_at IS NULL
 AND sm.effective_to_seq IS NULL
JOIN members m
  ON m.id = sm.member_id
 AND m.workspace_id = task.workspace_id
 AND m.status = 'active'
LEFT JOIN scope_notification_settings sns
  ON sns.scope_id = sm.scope_id
 AND sns.member_id = sm.member_id
LEFT JOIN agents a
  ON a.member_id = m.id
 AND a.status <> 'archived'
LEFT JOIN machines mac
  ON mac.workspace_id = a.workspace_id
 AND mac.id = a.machine_id
 AND mac.status = 'active'
WHERE m.id <> $2
  AND (
    (m.type = 'human' AND sns.task_watch_state = 'active')
    OR (m.type = 'agent' AND sns.task_watch_state = 'auto-wake')
    OR task.assignee_member_id = m.id
  )
  AND (
    m.type = 'human'
    OR (m.type = 'agent' AND a.id IS NOT NULL AND mac.id IS NOT NULL)
  )
ORDER BY m.id ASC
`, strings.TrimSpace(taskID), strings.TrimSpace(actorMemberID))
	if err != nil {
		return nil, nil, fmt.Errorf("load task attention recipients: %w", err)
	}
	defer rows.Close()

	humans := make([]string, 0)
	agents := make([]message.AgentRecipient, 0)
	for rows.Next() {
		var memberID string
		var memberType string
		var agentID string
		var machineID string
		var notificationState string
		var assignee bool
		if err := rows.Scan(&memberID, &memberType, &agentID, &machineID, &notificationState, &assignee); err != nil {
			return nil, nil, fmt.Errorf("scan task attention recipient: %w", err)
		}
		switch strings.TrimSpace(memberType) {
		case "human":
			humans = append(humans, strings.TrimSpace(memberID))
		case "agent":
			reason := message.AgentDeliveryReasonTaskWatch
			if assignee {
				reason = message.AgentDeliveryReasonTaskAssignee
			}
			agents = append(agents, message.AgentRecipient{
				AgentID:         strings.TrimSpace(agentID),
				MachineID:       strings.TrimSpace(machineID),
				AgentMemberID:   strings.TrimSpace(memberID),
				AutoWakeEnabled: strings.TrimSpace(notificationState) == "auto-wake",
				DeliveryReason:  reason,
			})
		}
	}
	if err := rows.Err(); err != nil {
		return nil, nil, fmt.Errorf("iterate task attention recipients: %w", err)
	}
	return normalizeMemberIDs(humans), agents, nil
}

func loadTaskAttentionProfiles(ctx context.Context, queryer coredb.Queryer, workspaceID string, memberIDs []string) (map[string]message.AuthorProfile, error) {
	memberIDs = normalizeMemberIDs(memberIDs)
	profiles := make(map[string]message.AuthorProfile, len(memberIDs))
	if len(memberIDs) == 0 {
		return profiles, nil
	}
	requestedMembersCTE, args, _, err := coredb.BuildStringValuesCTE("requested_members", "member_id", memberIDs, 2)
	if err != nil {
		return nil, err
	}
	rows, err := queryer.Query(ctx, `WITH `+requestedMembersCTE+`
SELECT
  m.id,
  m.type,
  COALESCE(u.id, ''),
  COALESCE(u.name, ''),
  COALESCE(u.display_name, ''),
  COALESCE(u.avatar_url, ''),
  COALESCE(a.id, ''),
  COALESCE(a.name, ''),
  COALESCE(a.avatar_url, ''),
  COALESCE(m.display_name, '')
FROM requested_members requested
JOIN members m
  ON m.id = requested.member_id
 AND m.workspace_id = $1
 AND m.status = 'active'
LEFT JOIN users u
  ON m.type = 'human'
 AND u.id = m.user_id
LEFT JOIN agents a
  ON m.type = 'agent'
 AND a.member_id = m.id
 AND a.status <> 'archived'
ORDER BY m.id ASC
`, append([]any{strings.TrimSpace(workspaceID)}, args...)...)
	if err != nil {
		return nil, fmt.Errorf("load task attention profiles: %w", err)
	}
	defer rows.Close()
	for rows.Next() {
		var memberID, memberType string
		var userID, userName, userDisplayName, userAvatarURL string
		var agentID, agentName, agentAvatarURL, memberDisplayName string
		if err := rows.Scan(
			&memberID,
			&memberType,
			&userID,
			&userName,
			&userDisplayName,
			&userAvatarURL,
			&agentID,
			&agentName,
			&agentAvatarURL,
			&memberDisplayName,
		); err != nil {
			return nil, fmt.Errorf("scan task attention profile: %w", err)
		}
		profile := message.AuthorProfile{MemberID: strings.TrimSpace(memberID)}
		switch strings.TrimSpace(memberType) {
		case message.AuthorActorTypeHuman:
			profile.ActorType = message.AuthorActorTypeHuman
			profile.PrincipalID = strings.TrimSpace(userID)
			profile.TargetID = firstNonEmpty(strings.TrimSpace(userName), profile.MemberID)
			profile.DisplayName = firstNonEmpty(strings.TrimSpace(userDisplayName), strings.TrimSpace(memberDisplayName), strings.TrimSpace(userName), profile.MemberID)
			profile.AvatarURL = strings.TrimSpace(userAvatarURL)
		case message.AuthorActorTypeAgent:
			profile.ActorType = message.AuthorActorTypeAgent
			profile.PrincipalID = strings.TrimSpace(agentID)
			profile.TargetID = firstNonEmpty(strings.TrimSpace(agentName), profile.MemberID)
			profile.DisplayName = firstNonEmpty(strings.TrimSpace(memberDisplayName), strings.TrimSpace(agentName), profile.MemberID)
			profile.AvatarURL = strings.TrimSpace(agentAvatarURL)
		default:
			continue
		}
		profiles[profile.MemberID] = profile.Normalize()
	}
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("iterate task attention profiles: %w", err)
	}
	return profiles, nil
}

// TaskNotificationRouteContext extracts the canonical board Channel and Task
// number from the authorized Task conversation projection used by fanout.
func TaskNotificationRouteContext(
	conversation *message.DeliveryConversationProjection,
) *message.AppMessageTaskContext {
	if conversation == nil || conversation.Kind != message.ConversationKindTask {
		return nil
	}
	task, ok := conversation.Projection["task"].(map[string]any)
	if !ok {
		return nil
	}
	channelID, ok := task["channelId"].(string)
	if !ok || strings.TrimSpace(channelID) == "" {
		return nil
	}
	taskNumber, ok := task["number"].(int64)
	if !ok || taskNumber <= 0 {
		return nil
	}
	return &message.AppMessageTaskContext{
		ChannelID:  strings.TrimSpace(channelID),
		TaskNumber: taskNumber,
	}
}

func taskConversationProjection(
	task taskAttentionProjection,
	assigneeProfile message.AuthorProfile,
	hasAssignee bool,
	actorProfile message.AuthorProfile,
	activityType string,
	preview string,
	createdAt time.Time,
) message.DeliveryConversationProjection {
	if createdAt.IsZero() {
		createdAt = time.Now().UTC()
	} else {
		createdAt = createdAt.UTC()
	}
	route := "/channels/" + url.PathEscape(strings.TrimSpace(task.ChannelID)) + "/tasks/" + url.PathEscape(strconv.FormatInt(task.TaskNumber, 10))
	title := fmt.Sprintf("#t%d", task.TaskNumber)
	if taskTitle := strings.TrimSpace(task.Title); taskTitle != "" {
		title += " " + taskTitle
	}
	taskProjection := map[string]any{
		"id":                          strings.TrimSpace(task.TaskID),
		"scopeId":                     strings.TrimSpace(task.TaskScopeID),
		"channelId":                   strings.TrimSpace(task.ChannelID),
		"number":                      task.TaskNumber,
		"title":                       strings.TrimSpace(task.Title),
		"status":                      strings.TrimSpace(task.Status),
		"parentTaskRef":               strings.TrimSpace(task.ParentTaskRef),
		"sourceThreadId":              strings.TrimSpace(task.SourceThreadChannelID),
		"sourceThreadParentChannelId": strings.TrimSpace(task.SourceParentChannelID),
		"sourceThreadParentMessageId": strings.TrimSpace(task.SourceParentMessageID),
		"sourceThreadRef":             strings.TrimSpace(task.SourceThreadRef),
		"route":                       route,
	}
	if hasAssignee {
		taskProjection["assigneeProfile"] = authorProfileProjection(assigneeProfile)
	}
	projection := map[string]any{
		"id":             "task:" + strings.TrimSpace(task.TaskScopeID),
		"kind":           "task",
		"scopeId":        strings.TrimSpace(task.TaskScopeID),
		"route":          route,
		"title":          title,
		"preview":        truncatePreview(preview),
		"lastActivityAt": createdAt,
		"task":           taskProjection,
	}
	if displayName := strings.TrimSpace(actorProfile.DisplayName); displayName != "" {
		projection["lastAuthorName"] = displayName
	}
	if activityType = strings.TrimSpace(activityType); activityType != "" {
		projection["lastActivityType"] = activityType
	}
	return message.DeliveryConversationProjection{
		ConversationID: "task:" + strings.TrimSpace(task.TaskScopeID),
		ScopeID:        strings.TrimSpace(task.TaskScopeID),
		Kind:           "task",
		SortAt:         createdAt,
		Projection:     projection,
	}
}

func taskActivityReceiveMessage(activity TypedActivityProjection, snapshot TaskAttentionSnapshot) message.ReceiveMessage {
	return message.ReceiveMessage{
		MessageID:          activity.EventID,
		TargetKey:          snapshot.TaskScopeID,
		ChannelType:        "task",
		ChannelName:        snapshot.TaskScopeID,
		ChannelDisplayName: stringFromProjection(snapshot.Conversation.Projection, "title"),
		AuthorProfile:      snapshot.ActorProfile,
		MessageType:        activity.ActivityType,
		Source: message.ContributionSource{
			RefType:   message.ContributionSourceRefTypeTaskActivity,
			RefID:     activity.EventID,
			EventID:   activity.EventID,
			EventType: activity.ActivityType,
			Label:     "Task activity",
		},
		Content:   taskActivityPreview(activity),
		Metadata:  copyProjection(snapshot.Conversation.Projection),
		Timestamp: activity.CreatedAt,
	}
}

func taskActivityPreview(activity TypedActivityProjection) string {
	payload := strings.TrimSpace(activity.EventPayload)
	switch strings.TrimSpace(activity.EventType) {
	case "created":
		return "Created task"
	case "assigned":
		return "Assigned task"
	case "unassigned":
		return "Unassigned task"
	case "status_changed":
		if payload != "" {
			return "Changed status to " + payload
		}
		return "Changed task status"
	case "blocked":
		if payload != "" {
			return "Blocked: " + payload
		}
		return "Blocked task"
	case "unblocked":
		return "Unblocked task"
	case "checkpoint_added":
		if payload != "" {
			return "Checkpoint: " + payload
		}
		return "Added checkpoint"
	case "review_requested":
		return "Requested review"
	case "reviewer_assigned":
		return "Assigned reviewer"
	case "review_approved":
		return "Approved review"
	case "review_changes_requested":
		return "Requested review changes"
	case "review_cancelled":
		return "Cancelled review"
	case "artifact_added":
		return "Added artifact"
	case "updated", "checklist_updated":
		return "Updated task"
	default:
		return "Updated task"
	}
}

func mergeTaskAgentRecipients(existing []message.AgentRecipient, additions []message.AgentRecipient, actorMemberID string) []message.AgentRecipient {
	merged := message.MergeAgentRecipients(existing, additions)
	filtered := make([]message.AgentRecipient, 0, len(merged))
	for _, recipient := range merged {
		if strings.TrimSpace(recipient.AgentMemberID) == strings.TrimSpace(actorMemberID) {
			continue
		}
		if strings.TrimSpace(recipient.AgentID) == "" || strings.TrimSpace(recipient.MachineID) == "" || strings.TrimSpace(recipient.AgentMemberID) == "" {
			continue
		}
		if strings.TrimSpace(recipient.DeliveryReason) == "" {
			recipient.DeliveryReason = message.AgentDeliveryReasonDirectMention
		}
		filtered = append(filtered, recipient)
	}
	sort.Slice(filtered, func(i, j int) bool {
		return strings.TrimSpace(filtered[i].AgentMemberID) < strings.TrimSpace(filtered[j].AgentMemberID)
	})
	return filtered
}

func normalizeMemberIDs(values []string) []string {
	seen := make(map[string]struct{}, len(values))
	normalized := make([]string, 0, len(values))
	for _, value := range values {
		value = strings.TrimSpace(value)
		if value == "" {
			continue
		}
		if _, ok := seen[value]; ok {
			continue
		}
		seen[value] = struct{}{}
		normalized = append(normalized, value)
	}
	sort.Strings(normalized)
	return normalized
}

func removeMemberID(values []string, excluded string) []string {
	excluded = strings.TrimSpace(excluded)
	filtered := make([]string, 0, len(values))
	for _, value := range values {
		if strings.TrimSpace(value) == excluded {
			continue
		}
		filtered = append(filtered, value)
	}
	return filtered
}

func authorProfileProjection(profile message.AuthorProfile) map[string]any {
	profile = profile.Normalize()
	return map[string]any{
		"actorType":   profile.ActorType,
		"principalId": profile.PrincipalID,
		"memberId":    profile.MemberID,
		"targetId":    profile.TargetID,
		"displayName": profile.DisplayName,
		"avatarUrl":   profile.AvatarURL,
	}
}

func copyProjection(value map[string]any) map[string]any {
	copied := make(map[string]any, len(value))
	for key, item := range value {
		copied[key] = item
	}
	return copied
}

func stringFromProjection(value map[string]any, key string) string {
	raw, _ := value[key].(string)
	return strings.TrimSpace(raw)
}

func truncatePreview(value string) string {
	const maxRunes = 140
	value = strings.TrimSpace(value)
	runes := []rune(value)
	if len(runes) <= maxRunes {
		return value
	}
	return string(runes[:maxRunes]) + "..."
}

func firstNonEmpty(values ...string) string {
	for _, value := range values {
		if value = strings.TrimSpace(value); value != "" {
			return value
		}
	}
	return ""
}
