package taskboard

import (
	"context"
	"fmt"
	"strings"
	"testing"
	"time"

	"github.com/stretchr/testify/require"

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

func TestTaskDiscussionPersistsEntriesAndProjectsIdentitySafeActors(t *testing.T) {
	ctx := context.Background()
	fixture := seedTaskboardUserFixture(t, ctx)

	createdResponse, err := fixture.service.CreateTasks(ctx, CreateTasksRequest{
		Context: NewUserServiceContext(fixture.workspaceID, fixture.ownerUserID, fixture.ownerDisplayName),
		Board:   BoardRef{ChannelID: fixture.channelID},
		Titles:  []string{"Ship task-native discussion"},
	})
	require.NoError(t, err)
	require.Len(t, createdResponse.Tasks, 1)

	taskID := createdResponse.Tasks[0].ID
	initialWorkspaceMessageSeq := loadTaskboardWorkspaceLatestMessageSeqForTest(t, ctx, fixture.pool, fixture.workspaceID)
	staleActivityAt := time.Date(2026, 4, 13, 8, 45, 0, 0, time.UTC)
	mustExecTaskboard(t, ctx, fixture.pool, `
UPDATE tasks
SET last_activity_at = $2
WHERE id = $1
`, taskID, staleActivityAt)

	firstEntry, err := fixture.service.SendTaskScopeMessage(ctx, SendTaskScopeMessageRequest{
		Context: NewUserServiceContext(fixture.workspaceID, fixture.ownerUserID, fixture.ownerDisplayName),
		Task:    TaskRef{TaskID: taskID},
		Message: TaskScopeMessageInput{
			Content: "Please keep the rollout constrained to the task target.",
		},
	})
	require.NoError(t, err)
	require.Equal(t, int64(1), firstEntry.Task.DiscussionCount)

	secondEntry, err := fixture.service.SendTaskScopeMessage(ctx, SendTaskScopeMessageRequest{
		Context: NewUserServiceContext(fixture.workspaceID, fixture.ownerUserID, fixture.ownerDisplayName),
		Task:    TaskRef{TaskID: taskID},
		Message: TaskScopeMessageInput{
			Content: "@task-detail-agent continue with the narrowed scope and summarize the handoff.",
		},
	})
	require.NoError(t, err)
	require.Equal(t, int64(2), secondEntry.Task.DiscussionCount)
	require.NotNil(t, secondEntry.Task.LastActivityAt)
	require.True(t, secondEntry.Task.LastActivityAt.After(staleActivityAt))
	require.Equal(t, fixture.ownerUserID, secondEntry.Message.CreatedBy.ID)
	require.Equal(t, fixture.ownerDisplayName, secondEntry.Message.CreatedBy.Name)

	detailResponse, err := fixture.service.GetTaskDetail(ctx, GetTaskDetailRequest{
		Context: NewUserServiceContext(fixture.workspaceID, fixture.ownerUserID, fixture.ownerDisplayName),
		Task:    TaskRef{TaskID: taskID},
	})
	require.NoError(t, err)
	require.Equal(t, int64(2), detailResponse.Detail.Task.DiscussionCount)
	require.Len(t, taskDetailDiscussionEntries(detailResponse.Detail), 2)
	require.Equal(t, secondEntry.Message.ID, taskDetailDiscussionEntries(detailResponse.Detail)[0].ID)
	require.Equal(t, firstEntry.Message.ID, taskDetailDiscussionEntries(detailResponse.Detail)[1].ID)
	require.Equal(t, secondEntry.Message.Body, taskDetailDiscussionEntries(detailResponse.Detail)[0].Body)
	require.Equal(t, fixture.ownerUserID, taskDetailDiscussionEntries(detailResponse.Detail)[0].CreatedBy.ID)
	require.Equal(t, fixture.ownerDisplayName, taskDetailDiscussionEntries(detailResponse.Detail)[0].CreatedBy.Name)
	require.Equal(t, message.AuthorActorTypeHuman, taskDetailDiscussionEntries(detailResponse.Detail)[0].AuthorProfile.ActorType)
	require.Equal(t, fixture.ownerUserID, taskDetailDiscussionEntries(detailResponse.Detail)[0].AuthorProfile.PrincipalID)
	require.Equal(t, fixture.ownerMemberID, taskDetailDiscussionEntries(detailResponse.Detail)[0].AuthorProfile.MemberID)
	require.Equal(t, fixture.ownerDisplayName, taskDetailDiscussionEntries(detailResponse.Detail)[0].AuthorProfile.DisplayName)
	require.Equal(t, message.ContributionSourceKindHumanCompose, taskDetailDiscussionEntries(detailResponse.Detail)[0].Source.Kind)
	require.Equal(t, message.ContributionSourceRefTypeMessage, taskDetailDiscussionEntries(detailResponse.Detail)[0].Source.RefType)
	require.Equal(t, secondEntry.Message.ID, taskDetailDiscussionEntries(detailResponse.Detail)[0].Source.RefID)
	timelineResponse, err := fixture.service.GetTaskTimeline(ctx, GetTaskTimelineRequest{
		Context: NewUserServiceContext(fixture.workspaceID, fixture.ownerUserID, fixture.ownerDisplayName),
		Task:    TaskRef{TaskID: taskID},
	})
	require.NoError(t, err)
	timelineDiscussions := taskTimelineDiscussionEntries(timelineResponse)
	require.Len(t, timelineDiscussions, 2)
	require.Equal(t, secondEntry.Message.ID, timelineDiscussions[0].ID)
	require.Equal(t, firstEntry.Message.ID, timelineDiscussions[1].ID)
	require.Equal(t, secondEntry.Message.Body, timelineDiscussions[0].Body)

	agentDetailResponse, err := fixture.service.GetTaskDetail(ctx, GetTaskDetailRequest{
		Context: NewAgentServiceContext(fixture.workspaceID, fixture.agentID, fixture.agentMemberID),
		Board:   BoardRef{Channel: "#all"},
		Task:    TaskRef{TaskNumber: createdResponse.Tasks[0].TaskNumber},
	})
	require.NoError(t, err)
	require.Equal(t, secondEntry.Message.ID, taskDetailDiscussionEntries(agentDetailResponse.Detail)[0].ID)
	require.Equal(t, secondEntry.Message.Body, taskDetailDiscussionEntries(agentDetailResponse.Detail)[0].Body)

	agentDetailByID, err := fixture.service.GetTaskDetail(ctx, GetTaskDetailRequest{
		Context: NewAgentServiceContext(fixture.workspaceID, fixture.agentID, fixture.agentMemberID),
		Task:    TaskRef{TaskID: taskID},
	})
	require.NoError(t, err)
	require.Equal(t, taskID, agentDetailByID.Detail.Task.ID)
	require.Equal(t, secondEntry.Message.ID, taskDetailDiscussionEntries(agentDetailByID.Detail)[0].ID)

	scopeMessages := loadTaskDiscussionScopeMessagesForTest(t, ctx, fixture.pool, fixture.workspaceID, createdResponse.Tasks[0].TaskScopeID)
	require.Len(t, scopeMessages, 2)
	require.Equal(t, firstEntry.Message.ID, scopeMessages[0].ID)
	require.Equal(t, int64(1), scopeMessages[0].Seq)
	require.Equal(t, scopeMessages[0].Seq, scopeMessages[0].WorkspaceGlobalSeq, "task discussion scope messages keep workspace_global_seq as a scope-local compatibility alias")
	require.Equal(t, fixture.ownerMemberID, scopeMessages[0].AuthorMemberID)
	require.Equal(t, "human", scopeMessages[0].AuthorType)
	require.Equal(t, taskScopeMessageTypeDiscussion, scopeMessages[0].MessageType)
	require.Equal(t, firstEntry.Message.Body, scopeMessages[0].Content)
	require.Empty(t, scopeMessages[0].Metadata)
	require.Equal(t, secondEntry.Message.ID, scopeMessages[1].ID)
	require.Equal(t, int64(2), scopeMessages[1].Seq)
	require.Equal(t, scopeMessages[1].Seq, scopeMessages[1].WorkspaceGlobalSeq, "task discussion scope messages keep workspace_global_seq as a scope-local compatibility alias")
	require.Equal(t, secondEntry.Message.Body, scopeMessages[1].Content)
	require.Empty(t, scopeMessages[1].Metadata)

	var latestSeq, latestGlobalSeq, visibleMessageCount int64
	require.NoError(t, fixture.pool.QueryRow(ctx, `
SELECT latest_seq, latest_global_seq, visible_message_count
FROM scopes
WHERE id = $1
`, createdResponse.Tasks[0].TaskScopeID).Scan(&latestSeq, &latestGlobalSeq, &visibleMessageCount))
	require.Equal(t, int64(2), latestSeq)
	require.Equal(t, int64(2), latestGlobalSeq)
	require.Equal(t, int64(2), visibleMessageCount)
	require.Equal(t, initialWorkspaceMessageSeq, loadTaskboardWorkspaceLatestMessageSeqForTest(t, ctx, fixture.pool, fixture.workspaceID), "task discussion scope message writes must not bump the workspace message seq hot row")

	var fanoutCount int64
	require.NoError(t, fixture.pool.QueryRow(ctx, `
SELECT COUNT(*)
FROM scope_fanout_outbox
WHERE scope_id = $1
  AND event_ref_type = 'message'
`, createdResponse.Tasks[0].TaskScopeID).Scan(&fanoutCount))
	require.Equal(t, int64(2), fanoutCount, "task discussion writes should use the normal scope message outbox path")
	var syncDirtyCount int64
	require.NoError(t, fixture.pool.QueryRow(ctx, `
SELECT COUNT(*)
FROM scope_sync_dirty
WHERE workspace_id = $1
  AND scope_id = $2
  AND latest_message_seq = $3
  AND reason = 'message'
`, fixture.workspaceID, createdResponse.Tasks[0].TaskScopeID, scopeMessages[1].Seq).Scan(&syncDirtyCount))
	require.Equal(t, int64(1), syncDirtyCount, "ordinary task discussion writes should dirty message sync in the resolved scope path")
	var searchDocCount int64
	require.NoError(t, fixture.pool.QueryRow(ctx, `
SELECT COUNT(*)
FROM scope_message_search_docs
WHERE scope_id = $1
`, createdResponse.Tasks[0].TaskScopeID).Scan(&searchDocCount))
	require.Equal(t, int64(2), searchDocCount, "task discussion writes should use the normal scope message search projection")
}

func TestTaskDiscussionDoesNotCreateInboxProjectionForPassiveVisibleMembers(t *testing.T) {
	ctx := context.Background()
	fixture := seedTaskboardUserFixture(t, ctx)

	createdResponse, err := fixture.service.CreateTasks(ctx, CreateTasksRequest{
		Context: NewUserServiceContext(fixture.workspaceID, fixture.ownerUserID, fixture.ownerDisplayName),
		Board:   BoardRef{ChannelID: fixture.channelID},
		Titles:  []string{"Task discussion should not fan out Inbox rows"},
	})
	require.NoError(t, err)
	require.Len(t, createdResponse.Tasks, 1)
	task := createdResponse.Tasks[0]

	passiveAt := time.Date(2026, 4, 13, 8, 0, 0, 0, time.UTC)
	mustExecTaskboard(t, ctx, fixture.pool, `
INSERT IGNORE INTO scope_memberships (
  scope_id, member_id, visibility, owner_member_id, last_read_seq, last_read_global_seq,
  owner_context_dismissed_through_global_seq, created_at, updated_at, removed_at, archived_at
) VALUES
  ($1, $2, 'public', NULL, 0, 0, 0, $4, $4, NULL, NULL),
  ($1, $3, 'public', NULL, 0, 0, 0, $4, $4, NULL, NULL)
`, task.TaskScopeID, fixture.reviewerMemberID, fixture.agentMemberID, passiveAt)
	require.Equal(t, int64(0), countConversationMemberStatesForTest(t, ctx, fixture.pool, task.TaskScopeID, fixture.reviewerMemberID))
	require.Equal(t, int64(0), countConversationMemberStatesForTest(t, ctx, fixture.pool, task.TaskScopeID, fixture.agentMemberID))

	response, err := fixture.service.SendTaskScopeMessage(ctx, SendTaskScopeMessageRequest{
		Context: NewUserServiceContext(fixture.workspaceID, fixture.ownerUserID, fixture.ownerDisplayName),
		Task:    TaskRef{TaskID: task.ID},
		Message: TaskScopeMessageInput{
			Content: "A normal task discussion message should use message delivery only.",
		},
	})
	require.NoError(t, err)

	require.Equal(t, int64(0), countConversationMemberStatesForTest(t, ctx, fixture.pool, task.TaskScopeID, fixture.reviewerMemberID), "task discussion messages must not create Task Inbox projection rows for passive visible members")
	require.Equal(t, int64(0), countConversationMemberStatesForTest(t, ctx, fixture.pool, task.TaskScopeID, fixture.agentMemberID), "task discussion messages must not create Task Inbox projection rows for passive visible members")
	require.Equal(t, int64(1), countTaskDiscussionMessageFanoutOutboxesForTest(t, ctx, fixture.pool, task.TaskScopeID, response.Message.ID), "task discussion messages must keep generic message fanout delivery")
}

func TestAgentAddsTaskDiscussionByTaskIDWhenTaskScopeMember(t *testing.T) {
	ctx := context.Background()
	fixture := seedTaskboardUserFixture(t, ctx)

	createdResponse, err := fixture.service.CreateTasks(ctx, CreateTasksRequest{
		Context: NewUserServiceContext(fixture.workspaceID, fixture.ownerUserID, fixture.ownerDisplayName),
		Board:   BoardRef{ChannelID: fixture.channelID},
		Titles:  []string{"Reply through raw task scope target"},
	})
	require.NoError(t, err)
	require.Len(t, createdResponse.Tasks, 1)
	task := createdResponse.Tasks[0]

	_, err = fixture.service.ClaimTasks(ctx, ClaimTasksRequest{
		Context: NewAgentServiceContext(fixture.workspaceID, fixture.agentID, fixture.agentMemberID),
		Board:   BoardRef{Channel: "#all"},
		Tasks:   []TaskRef{{TaskNumber: task.TaskNumber}},
	})
	require.NoError(t, err)

	response, err := fixture.service.SendTaskScopeMessage(ctx, SendTaskScopeMessageRequest{
		Context: NewAgentServiceContext(fixture.workspaceID, fixture.agentID, fixture.agentMemberID),
		Task:    TaskRef{TaskID: task.ID},
		Message: TaskScopeMessageInput{
			Content: "task scope native reply",
		},
	})
	require.NoError(t, err)
	require.Equal(t, task.ID, response.Task.ID)
	require.Equal(t, fixture.agentID, response.Message.CreatedBy.ID)

	scopeMessages := loadTaskDiscussionScopeMessagesForTest(t, ctx, fixture.pool, fixture.workspaceID, task.TaskScopeID)
	require.Len(t, scopeMessages, 1)
	require.Equal(t, response.Message.ID, scopeMessages[0].ID)
	require.Equal(t, "task scope native reply", scopeMessages[0].Content)
	require.Equal(t, fixture.agentMemberID, scopeMessages[0].AuthorMemberID)
	require.Equal(t, "agent", scopeMessages[0].AuthorType)
}

func TestAgentTaskDiscussionFreshnessStates(t *testing.T) {
	ctx := context.Background()
	fixture := seedTaskboardUserFixture(t, ctx)

	createdResponse, err := fixture.service.CreateTasks(ctx, CreateTasksRequest{
		Context: NewUserServiceContext(fixture.workspaceID, fixture.ownerUserID, fixture.ownerDisplayName),
		Board:   BoardRef{ChannelID: fixture.channelID},
		Titles:  []string{"Fresh task discussion"},
	})
	require.NoError(t, err)
	task := createdResponse.Tasks[0]

	_, err = fixture.service.ClaimTasks(ctx, ClaimTasksRequest{
		Context: NewAgentServiceContext(fixture.workspaceID, fixture.agentID, fixture.agentMemberID),
		Board:   BoardRef{Channel: "#all"},
		Tasks:   []TaskRef{{TaskNumber: task.TaskNumber}},
	})
	require.NoError(t, err)

	sent, err := fixture.service.SendTaskScopeMessage(ctx, SendTaskScopeMessageRequest{
		Context:         NewAgentServiceContext(fixture.workspaceID, fixture.agentID, fixture.agentMemberID),
		Task:            TaskRef{TaskID: task.ID},
		ClientMessageID: "client-task-fresh-1",
		Freshness: message.SendMessageFreshness{
			TargetKey:   task.TaskScopeID,
			SeenUpToSeq: 0,
		},
		Message: TaskScopeMessageInput{Content: "fresh task scope reply"},
	})
	require.NoError(t, err)
	require.Equal(t, message.SendStateSent, sent.State)
	require.Equal(t, int64(1), sent.AcceptedSeq)
	require.Equal(t, int64(1), sent.LatestSeq)
	require.Equal(t, int64(1), sent.Task.DiscussionCount)

	replayed, err := fixture.service.SendTaskScopeMessage(ctx, SendTaskScopeMessageRequest{
		Context:         NewAgentServiceContext(fixture.workspaceID, fixture.agentID, fixture.agentMemberID),
		Task:            TaskRef{TaskID: task.ID},
		ClientMessageID: "client-task-fresh-1",
		Freshness: message.SendMessageFreshness{
			TargetKey:   task.TaskScopeID,
			SeenUpToSeq: 0,
		},
		Message: TaskScopeMessageInput{Content: "fresh task scope reply"},
	})
	require.NoError(t, err)
	require.Equal(t, message.SendStateAlreadySent, replayed.State)
	require.Equal(t, sent.MessageID, replayed.MessageID)
	require.Equal(t, int64(1), replayed.AcceptedSeq)

	held, err := fixture.service.SendTaskScopeMessage(ctx, SendTaskScopeMessageRequest{
		Context:         NewAgentServiceContext(fixture.workspaceID, fixture.agentID, fixture.agentMemberID),
		Task:            TaskRef{TaskID: task.ID},
		ClientMessageID: "client-task-fresh-2",
		Freshness: message.SendMessageFreshness{
			TargetKey:   task.TaskScopeID,
			SeenUpToSeq: 0,
		},
		Message: TaskScopeMessageInput{Content: "stale task scope reply"},
	})
	require.NoError(t, err)
	require.Equal(t, message.SendStateHeld, held.State)
	require.Equal(t, int64(1), held.LatestSeq)
	require.Equal(t, int64(1), held.NeedsRefreshUntilSeq)
	require.Empty(t, held.MessageID)

	scopeMessages := loadTaskDiscussionScopeMessagesForTest(t, ctx, fixture.pool, fixture.workspaceID, task.TaskScopeID)
	require.Len(t, scopeMessages, 1)
	require.Equal(t, sent.MessageID, scopeMessages[0].ID)
	require.Equal(t, "client-task-fresh-1", scopeMessages[0].ClientMessageID)
	require.Equal(t, int64(1), scopeMessages[0].Seq)

	detail, err := fixture.service.GetTaskDetail(ctx, GetTaskDetailRequest{
		Context: NewUserServiceContext(fixture.workspaceID, fixture.ownerUserID, fixture.ownerDisplayName),
		Task:    TaskRef{TaskID: task.ID},
	})
	require.NoError(t, err)
	require.Equal(t, int64(1), detail.Detail.Task.DiscussionCount)
	require.Len(t, taskDetailDiscussionEntries(detail.Detail), 1)
}

type taskDiscussionScopeMessageForTest struct {
	ID                 string
	Seq                int64
	WorkspaceGlobalSeq int64
	AuthorMemberID     string
	AuthorType         string
	ClientMessageID    string
	MessageType        string
	Content            string
	Metadata           []byte
}

func loadTaskboardWorkspaceLatestMessageSeqForTest(t *testing.T, ctx context.Context, db coredb.QueryRower, workspaceID string) int64 {
	t.Helper()

	var seq int64
	require.NoError(t, db.QueryRow(ctx, `
SELECT latest_message_seq
FROM workspaces
WHERE id = $1
`, workspaceID).Scan(&seq))
	return seq
}

func loadTaskDiscussionScopeMessagesForTest(t *testing.T, ctx context.Context, db coredb.Queryer, workspaceID string, taskScopeID string) []taskDiscussionScopeMessageForTest {
	t.Helper()

	rows, err := db.Query(ctx, `
SELECT id, seq, workspace_global_seq, author_member_id, author_type,
       COALESCE(client_message_id, ''), message_type, content, metadata
FROM scope_messages
WHERE workspace_id = $1
  AND scope_id = $2
ORDER BY seq ASC
`, workspaceID, taskScopeID)
	require.NoError(t, err)
	defer rows.Close()

	messages := make([]taskDiscussionScopeMessageForTest, 0)
	for rows.Next() {
		var message taskDiscussionScopeMessageForTest
		require.NoError(t, rows.Scan(
			&message.ID,
			&message.Seq,
			&message.WorkspaceGlobalSeq,
			&message.AuthorMemberID,
			&message.AuthorType,
			&message.ClientMessageID,
			&message.MessageType,
			&message.Content,
			&message.Metadata,
		))
		messages = append(messages, message)
	}
	require.NoError(t, rows.Err())
	return messages
}

func countTaskScopeDiscussionMessageReactionsForTest(t *testing.T, ctx context.Context, db coredb.QueryRower, messageID string, emoji string, reactorMemberID string) int64 {
	t.Helper()

	var count int64
	require.NoError(t, db.QueryRow(ctx, `
SELECT COUNT(*)
FROM scope_message_reactions
WHERE message_id = $1
  AND emoji = $2
  AND reactor_member_id = $3
`, messageID, emoji, reactorMemberID).Scan(&count))
	return count
}

func countConversationMemberStatesForTest(t *testing.T, ctx context.Context, db coredb.QueryRower, scopeID string, memberID string) int64 {
	t.Helper()

	var count int64
	require.NoError(t, db.QueryRow(ctx, `
SELECT COUNT(*)
FROM conversation_member_states
WHERE scope_id = $1
  AND member_id = $2
`, scopeID, memberID).Scan(&count))
	return count
}

func countTaskDiscussionMessageFanoutOutboxesForTest(t *testing.T, ctx context.Context, db coredb.QueryRower, scopeID string, messageID string) int64 {
	t.Helper()

	var count int64
	require.NoError(t, db.QueryRow(ctx, `
SELECT COUNT(*)
FROM scope_fanout_outbox
WHERE scope_id = $1
  AND message_id = $2
  AND event_ref_type = 'message'
`, scopeID, messageID).Scan(&count))
	return count
}

func TestTaskDiscussionDirectMentionStaysScopeNative(t *testing.T) {
	ctx := context.Background()
	fixture := seedTaskboardUserFixture(t, ctx)

	createdResponse, err := fixture.service.CreateTasks(ctx, CreateTasksRequest{
		Context: NewUserServiceContext(fixture.workspaceID, fixture.ownerUserID, fixture.ownerDisplayName),
		Board:   BoardRef{ChannelID: fixture.channelID},
		Titles:  []string{"Direct mentions stay as task scope messages"},
	})
	require.NoError(t, err)
	require.Len(t, createdResponse.Tasks, 1)

	taskID := createdResponse.Tasks[0].ID

	response, err := fixture.service.SendTaskScopeMessage(ctx, SendTaskScopeMessageRequest{
		Context: NewUserServiceContext(fixture.workspaceID, fixture.ownerUserID, fixture.ownerDisplayName),
		Task:    TaskRef{TaskID: taskID},
		Message: TaskScopeMessageInput{
			Content: "@task-detail-agent please look at this scope-native task discussion.",
		},
	})
	require.NoError(t, err)
	require.Nil(t, response.Task.AssigneeProfile)
	require.Equal(t, StatusTodo, Status(response.Task.Status))
	require.Equal(t, int64(1), response.Task.DiscussionCount)

	detailResponse, err := fixture.service.GetTaskDetail(ctx, GetTaskDetailRequest{
		Context: NewUserServiceContext(fixture.workspaceID, fixture.ownerUserID, fixture.ownerDisplayName),
		Task:    TaskRef{TaskID: taskID},
	})
	require.NoError(t, err)
	require.Nil(t, detailResponse.Detail.Task.AssigneeProfile)
	require.Equal(t, StatusTodo, Status(detailResponse.Detail.Task.Status))
	require.Len(t, taskDetailDiscussionEntries(detailResponse.Detail), 1)
	require.Equal(t, response.Message.ID, taskDetailDiscussionEntries(detailResponse.Detail)[0].ID)
}

func TestTaskDiscussionAllowsMultipleDirectMentions(t *testing.T) {
	ctx := context.Background()
	fixture := seedTaskboardUserFixture(t, ctx)

	createdResponse, err := fixture.service.CreateTasks(ctx, CreateTasksRequest{
		Context: NewUserServiceContext(fixture.workspaceID, fixture.ownerUserID, fixture.ownerDisplayName),
		Board:   BoardRef{ChannelID: fixture.channelID},
		Titles:  []string{"Multiple direct mentions in task discussion"},
	})
	require.NoError(t, err)
	require.Len(t, createdResponse.Tasks, 1)

	_ = seedTaskboardAdditionalChannelAgent(
		t,
		ctx,
		fixture.pool,
		fixture.workspaceID,
		fixture.channelID,
		fixture.machineID,
		"task-discussion-second-agent",
		"Task Discussion Second Agent",
	)

	response, err := fixture.service.SendTaskScopeMessage(ctx, SendTaskScopeMessageRequest{
		Context: NewUserServiceContext(fixture.workspaceID, fixture.ownerUserID, fixture.ownerDisplayName),
		Task:    TaskRef{TaskID: createdResponse.Tasks[0].ID},
		Message: TaskScopeMessageInput{
			Content: "@task-detail-agent @task-discussion-second-agent both agents can be mentioned normally.",
		},
	})
	require.NoError(t, err)
	require.Equal(t, StatusTodo, Status(response.Task.Status))
	require.Nil(t, response.Task.AssigneeProfile)
	require.Equal(t, int64(1), response.Task.DiscussionCount)
}

func TestTaskDiscussionReadPaginatesOlderEntries(t *testing.T) {
	ctx := context.Background()
	fixture := seedTaskboardUserFixture(t, ctx)

	createdResponse, err := fixture.service.CreateTasks(ctx, CreateTasksRequest{
		Context: NewUserServiceContext(fixture.workspaceID, fixture.ownerUserID, fixture.ownerDisplayName),
		Board:   BoardRef{ChannelID: fixture.channelID},
		Titles:  []string{"Paginate task discussion"},
	})
	require.NoError(t, err)
	require.Len(t, createdResponse.Tasks, 1)

	taskID := createdResponse.Tasks[0].ID
	totalEntries := taskDetailTimelinePageSize + 1
	for index := 1; index <= totalEntries; index++ {
		_, err := fixture.service.SendTaskScopeMessage(ctx, SendTaskScopeMessageRequest{
			Context: NewUserServiceContext(fixture.workspaceID, fixture.ownerUserID, fixture.ownerDisplayName),
			Task:    TaskRef{TaskID: taskID},
			Message: TaskScopeMessageInput{
				Content: fmt.Sprintf("Discussion entry %02d", index),
			},
		})
		require.NoError(t, err)
	}

	firstPage, err := fixture.service.GetTaskTimeline(ctx, GetTaskTimelineRequest{
		Context: NewUserServiceContext(fixture.workspaceID, fixture.ownerUserID, fixture.ownerDisplayName),
		Task:    TaskRef{TaskID: taskID},
	})
	require.NoError(t, err)
	firstPageDiscussions := taskTimelineDiscussionEntries(firstPage)
	require.Len(t, firstPageDiscussions, taskDetailTimelinePageSize)
	require.True(t, firstPage.HasMore)
	require.NotEmpty(t, firstPage.NextCursor)
	require.Equal(t, fmt.Sprintf("Discussion entry %02d", totalEntries), firstPageDiscussions[0].Body)

	secondPage, err := fixture.service.GetTaskTimeline(ctx, GetTaskTimelineRequest{
		Context: NewUserServiceContext(fixture.workspaceID, fixture.ownerUserID, fixture.ownerDisplayName),
		Task:    TaskRef{TaskID: taskID},
		Cursor:  firstPage.NextCursor,
	})
	require.NoError(t, err)
	secondPageDiscussions := taskTimelineDiscussionEntries(secondPage)
	require.Len(t, secondPageDiscussions, 1)
	require.False(t, secondPage.HasMore)
	require.Empty(t, secondPage.NextCursor)
	require.Equal(t, "Discussion entry 01", secondPageDiscussions[0].Body)
}

func TestTaskDiscussionReactionReadUsesScopeMessageReactions(t *testing.T) {
	ctx := context.Background()
	fixture := seedTaskboardUserFixture(t, ctx)

	createdResponse, err := fixture.service.CreateTasks(ctx, CreateTasksRequest{
		Context: NewUserServiceContext(fixture.workspaceID, fixture.ownerUserID, fixture.ownerDisplayName),
		Board:   BoardRef{ChannelID: fixture.channelID},
		Titles:  []string{"Expected discussion reaction read"},
	})
	require.NoError(t, err)
	taskID := createdResponse.Tasks[0].ID

	entryResponse, err := fixture.service.SendTaskScopeMessage(ctx, SendTaskScopeMessageRequest{
		Context: NewUserServiceContext(fixture.workspaceID, fixture.ownerUserID, fixture.ownerDisplayName),
		Task:    TaskRef{TaskID: taskID},
		Message: TaskScopeMessageInput{
			Content: "Discussion entry with resolved reaction read",
		},
	})
	require.NoError(t, err)

	mustExecTaskboard(t, ctx, fixture.pool, `
INSERT INTO scope_message_reactions (id, workspace_id, scope_id, message_id, emoji, reactor_member_id, created_at)
VALUES ('rxn_task_discussion_resolved_read', $1, $2, $3, '👀', $4, NOW(6))
`, fixture.workspaceID, createdResponse.Tasks[0].TaskScopeID, entryResponse.Message.ID, fixture.reviewerMemberID)
	require.Equal(t, int64(1), countTaskScopeDiscussionMessageReactionsForTest(t, ctx, fixture.pool, entryResponse.Message.ID, "👀", fixture.reviewerMemberID))

	timelineResponse, err := fixture.service.GetTaskTimeline(ctx, GetTaskTimelineRequest{
		Context: NewUserServiceContext(fixture.workspaceID, fixture.ownerUserID, fixture.ownerDisplayName),
		Task:    TaskRef{TaskID: taskID},
	})
	require.NoError(t, err)
	timelineDiscussions := taskTimelineDiscussionEntries(timelineResponse)
	require.Len(t, timelineDiscussions, 1)
	require.Len(t, timelineDiscussions[0].Reactions, 1)
	require.Equal(t, "👀", timelineDiscussions[0].Reactions[0].Emoji)
	require.Equal(t, 1, timelineDiscussions[0].Reactions[0].Count)
	require.Equal(t, fixture.reviewerMemberID, timelineDiscussions[0].Reactions[0].Reactors[0].AuthorProfile.MemberID)
}

func TestTaskDiscussionReactionReadBoundsReactorPreview(t *testing.T) {
	ctx := context.Background()
	fixture := seedTaskboardUserFixture(t, ctx)

	createdResponse, err := fixture.service.CreateTasks(ctx, CreateTasksRequest{
		Context: NewUserServiceContext(fixture.workspaceID, fixture.ownerUserID, fixture.ownerDisplayName),
		Board:   BoardRef{ChannelID: fixture.channelID},
		Titles:  []string{"Bound discussion reaction reactors"},
	})
	require.NoError(t, err)
	task := createdResponse.Tasks[0]

	entryResponse, err := fixture.service.SendTaskScopeMessage(ctx, SendTaskScopeMessageRequest{
		Context: NewUserServiceContext(fixture.workspaceID, fixture.ownerUserID, fixture.ownerDisplayName),
		Task:    TaskRef{TaskID: task.ID},
		Message: TaskScopeMessageInput{
			Content: "Discussion entry with many reactors",
		},
	})
	require.NoError(t, err)

	totalReactors := taskDiscussionReactionReactorsPerEmoji + 3
	baseCreatedAt := time.Date(2026, 4, 13, 10, 0, 0, 0, time.UTC)
	for index := 0; index < totalReactors; index++ {
		userID := fmt.Sprintf("usr_task_discussion_reactor_%02d", index)
		memberID := fmt.Sprintf("mem_task_discussion_reactor_%02d", index)
		displayName := fmt.Sprintf("Task Discussion Reactor %02d", index)
		mustExecTaskboard(t, ctx, fixture.pool, `
	INSERT INTO users (id, email, name, display_name, avatar_url, is_admin, password_hash, email_verified_at, created_at, updated_at)
	VALUES ($1, $2, $3, $4, '', FALSE, 'hash', $5, $5, $5)
	`, userID, fmt.Sprintf("task-discussion-reactor-%02d@example.com", index), fmt.Sprintf("task-discussion-reactor-%02d", index), displayName, baseCreatedAt)
		mustExecTaskboard(t, ctx, fixture.pool, `
	INSERT INTO members (id, workspace_id, type, display_name, user_id, workspace_role, status, created_at, updated_at)
	VALUES ($1, $2, 'human', $3, $4, 'member', 'active', $5, $5)
	`, memberID, fixture.workspaceID, displayName, userID, baseCreatedAt)
		mustExecTaskboard(t, ctx, fixture.pool, `
	INSERT INTO scope_memberships (
	  scope_id, member_id, visibility, owner_member_id, last_read_seq, last_read_global_seq,
	  owner_context_dismissed_through_global_seq, created_at, updated_at, removed_at, archived_at
	) VALUES ($1, $2, 'public', NULL, 0, 0, 0, $3, $3, NULL, NULL)
	`, fixture.channelID, memberID, baseCreatedAt)
		mustExecTaskboard(t, ctx, fixture.pool, `
	INSERT INTO scope_message_reactions (id, workspace_id, scope_id, message_id, emoji, reactor_member_id, created_at)
	VALUES ($1, $2, $3, $4, $5, $6, $7)
	`, fmt.Sprintf("rxn_task_discussion_reactor_%02d", index), fixture.workspaceID, task.TaskScopeID, entryResponse.Message.ID, "👀", memberID, baseCreatedAt.Add(time.Duration(index)*time.Second))
	}

	timelineResponse, err := fixture.service.GetTaskTimeline(ctx, GetTaskTimelineRequest{
		Context: NewUserServiceContext(fixture.workspaceID, fixture.ownerUserID, fixture.ownerDisplayName),
		Task:    TaskRef{TaskID: task.ID},
	})
	require.NoError(t, err)
	timelineDiscussions := taskTimelineDiscussionEntries(timelineResponse)
	require.Len(t, timelineDiscussions, 1)
	require.Len(t, timelineDiscussions[0].Reactions, 1)
	require.Equal(t, "👀", timelineDiscussions[0].Reactions[0].Emoji)
	require.Equal(t, totalReactors, timelineDiscussions[0].Reactions[0].Count)
	require.Len(t, timelineDiscussions[0].Reactions[0].Reactors, taskDiscussionReactionReactorsPerEmoji)
	for index, reactor := range timelineDiscussions[0].Reactions[0].Reactors {
		require.Equal(t, fmt.Sprintf("mem_task_discussion_reactor_%02d", index), reactor.AuthorProfile.MemberID)
	}

	detailResponse, err := fixture.service.GetTaskDetail(ctx, GetTaskDetailRequest{
		Context: NewUserServiceContext(fixture.workspaceID, fixture.ownerUserID, fixture.ownerDisplayName),
		Task:    TaskRef{TaskID: task.ID},
	})
	require.NoError(t, err)
	detailDiscussions := taskDetailDiscussionEntries(detailResponse.Detail)
	require.Len(t, detailDiscussions, 1)
	require.Len(t, detailDiscussions[0].Reactions, 1)
	require.Equal(t, totalReactors, detailDiscussions[0].Reactions[0].Count)
	require.Len(t, detailDiscussions[0].Reactions[0].Reactors, taskDiscussionReactionReactorsPerEmoji)
}

func TestTaskActivityReadPaginatesOlderEntries(t *testing.T) {
	ctx := context.Background()
	fixture := seedTaskboardUserFixture(t, ctx)

	createdResponse, err := fixture.service.CreateTasks(ctx, CreateTasksRequest{
		Context: NewUserServiceContext(fixture.workspaceID, fixture.ownerUserID, fixture.ownerDisplayName),
		Board:   BoardRef{ChannelID: fixture.channelID},
		Titles:  []string{"Paginate task activity"},
	})
	require.NoError(t, err)
	require.Len(t, createdResponse.Tasks, 1)

	taskID := createdResponse.Tasks[0].ID
	totalEntries := taskDetailTimelinePageSize + 1
	for index := 1; index <= totalEntries; index++ {
		_, err := fixture.service.SendTaskScopeMessage(ctx, SendTaskScopeMessageRequest{
			Context: NewUserServiceContext(fixture.workspaceID, fixture.ownerUserID, fixture.ownerDisplayName),
			Task:    TaskRef{TaskID: taskID},
			Message: TaskScopeMessageInput{
				Content: fmt.Sprintf("Activity entry %02d", index),
			},
		})
		require.NoError(t, err)
	}

	firstPage, err := fixture.service.GetTaskTimeline(ctx, GetTaskTimelineRequest{
		Context: NewUserServiceContext(fixture.workspaceID, fixture.ownerUserID, fixture.ownerDisplayName),
		Task:    TaskRef{TaskID: taskID},
	})
	require.NoError(t, err)
	require.Len(t, firstPage.Entries, taskDetailTimelinePageSize)
	require.True(t, firstPage.HasMore)
	require.NotEmpty(t, firstPage.NextCursor)
	require.Equal(t, TaskTimelineEntryKindDiscussion, firstPage.Entries[0].Kind)

	secondPage, err := fixture.service.GetTaskTimeline(ctx, GetTaskTimelineRequest{
		Context: NewUserServiceContext(fixture.workspaceID, fixture.ownerUserID, fixture.ownerDisplayName),
		Task:    TaskRef{TaskID: taskID},
		Cursor:  firstPage.NextCursor,
	})
	require.NoError(t, err)
	require.Len(t, secondPage.Entries, 2)
	require.False(t, secondPage.HasMore)
	require.Empty(t, secondPage.NextCursor)
	require.Equal(t, TaskTimelineEntryKindActivity, secondPage.Entries[len(secondPage.Entries)-1].Kind)
	require.NotNil(t, secondPage.Entries[len(secondPage.Entries)-1].Activity)
	require.Equal(t, taskEventCreated, secondPage.Entries[len(secondPage.Entries)-1].Activity.Type)
}

func seedTaskboardAdditionalChannelAgent(t *testing.T, ctx context.Context, db interface {
	Exec(context.Context, string, ...any) (coredb.Result, error)
}, workspaceID string, channelID string, machineID string, agentID string, displayName string) string {
	t.Helper()

	now := time.Date(2026, 4, 13, 9, 30, 0, 0, time.UTC)
	memberID := "mem_" + agentID

	mustExecTaskboard(t, ctx, db, `
INSERT INTO members (id, workspace_id, type, display_name, status, created_at, updated_at)
VALUES ($1, $2, 'agent', $3, 'active', $4, $4)
`, memberID, workspaceID, displayName, now)

	mustExecTaskboard(t, ctx, db, `
INSERT IGNORE INTO scope_memberships (scope_id, member_id, visibility, owner_member_id, last_read_seq, last_read_global_seq, owner_context_dismissed_through_global_seq, created_at, updated_at, removed_at, archived_at)
VALUES ($1, $2, 'public', NULL, 0, 0, 0, $3, $3, NULL, NULL)
`, channelID, memberID, now)

	mustExecTaskboard(t, ctx, db, `
INSERT INTO agents (id, workspace_id, machine_id, member_id, name, avatar_url, runtime, model, reasoning_effort, description, session_id, auto_wake_enabled, status, activity, activity_detail, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, '', 'codex', 'gpt-5.4', '', '', '', TRUE, 'active', 'online', '', $6, $6)
`, agentID, workspaceID, machineID, memberID, strings.ToLower(strings.ReplaceAll(displayName, " ", "-")), now)

	return agentID
}

// The resolved Task->board Channel forward/reverse discussion-write matrix
// (TestAgentWritesTaskDiscussionAsChannelMemberNotTaskMember and
// TestAgentCannotWriteTaskDiscussionWithoutChannelMembership) was removed in
// cycle-17 publishing to bound CI TiDB runtime; the channel-membership
// authority it covered is enforced by the access authorizer and the
// task-channel-authority guardrail.
