package message

import (
	"context"
	"strconv"
	"strings"
	"testing"
	"time"

	"github.com/stretchr/testify/require"

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

type searchRowStub struct {
	values []any
}

func (stub searchRowStub) Scan(dest ...any) error {
	for i := range dest {
		switch target := dest[i].(type) {
		case *string:
			*target = stub.values[i].(string)
		case *int64:
			*target = stub.values[i].(int64)
		case *[]byte:
			*target = stub.values[i].([]byte)
		case *time.Time:
			*target = stub.values[i].(time.Time)
		case *bool:
			*target = stub.values[i].(bool)
		}
	}
	return nil
}

type workspaceSearchReadinessTx struct {
	query string
	args  []any
	row   coredb.Row
}

func (tx *workspaceSearchReadinessTx) Database() string { return "tidb" }

func (tx *workspaceSearchReadinessTx) QueryRow(_ context.Context, query string, args ...any) coredb.Row {
	tx.query = query
	tx.args = append([]any(nil), args...)
	return tx.row
}

func (*workspaceSearchReadinessTx) Query(context.Context, string, ...any) (coredb.Rows, error) {
	panic("unexpected Query")
}

func (*workspaceSearchReadinessTx) Exec(context.Context, string, ...any) (coredb.Result, error) {
	panic("unexpected Exec")
}

func (*workspaceSearchReadinessTx) Commit(context.Context) error { return nil }

func (*workspaceSearchReadinessTx) Rollback(context.Context) error { return nil }

func TestSupportsWorkspaceMessageSearchUsesScopedReadiness(t *testing.T) {
	t.Parallel()

	tx := &workspaceSearchReadinessTx{row: searchRowStub{values: []any{
		TiDBMessageSearchFinalizeComplete,
		TiDBMessageSearchFinalizeComplete,
		TiDBMessageSearchFinalizeComplete,
		true,
	}}}

	require.True(t, TxStore{tx: tx}.SupportsWorkspaceMessageSearch(context.Background(), " wsp_ready "))
	require.Equal(t, []any{"wsp_ready", "", TiDBMessageSearchProjectionName}, tx.args)
	require.Contains(t, tx.query, "WHERE workspace_id = $1")
	require.Contains(t, tx.query, "AND scope_id IN ($2, '')")
}

func TestSupportsWorkspaceMessageSearchRejectsUnavailableWorkspace(t *testing.T) {
	t.Parallel()

	tx := &workspaceSearchReadinessTx{row: searchRowStub{values: []any{
		TiDBMessageSearchFinalizeComplete,
		TiDBMessageSearchFinalizeComplete,
		TiDBMessageSearchFinalizeComplete,
		false,
	}}}

	require.False(t, TxStore{tx: tx}.SupportsWorkspaceMessageSearch(context.Background(), "wsp_pending"))
}

func TestScanSearchMessageRowPreservesReplyParentAndMetadata(t *testing.T) {
	createdAt := time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC)
	message, err := ScanSearchMessageRow(searchRowStub{values: []any{
		"msg_1",
		int64(42),
		"thread_channel_1",
		"human",
		"member_1",
		"user_1",
		"public_name",
		"",
		"",
		"Display Name",
		"text",
		"hello",
		[]byte(`{"source":"search"}`),
		createdAt,
		"parent_channel_1",
		"parent_msg_1",
	}})
	require.NoError(t, err)
	require.Equal(t, "msg_1", message.ID)
	require.EqualValues(t, 42, message.Seq)
	require.Equal(t, "thread_channel_1", message.ChannelID)
	require.Equal(t, "human", message.AuthorProfile.ActorType)
	require.Equal(t, "user_1", message.AuthorProfile.PrincipalID)
	require.Equal(t, "member_1", message.AuthorProfile.MemberID)
	require.Equal(t, "public_name", message.AuthorProfile.TargetID)
	require.Equal(t, "Display Name", message.AuthorProfile.DisplayName)
	require.Equal(t, map[string]any{"source": "search"}, message.Metadata)
	require.Equal(t, "parent_channel_1", message.ParentChannelID)
	require.Equal(t, "parent_msg_1", message.ParentMessageID)
	require.Equal(t, createdAt, message.CreatedAt)
}

func TestScopeSearchVisibilityQueryUsesScopeProjection(t *testing.T) {
	t.Parallel()

	query := scopeSearchVisibilityCandidatesQuery.SQL
	descriptor := scopeSearchVisibilityCandidatesQuery

	require.Contains(t, query, "scope_message_search_docs")
	require.Contains(t, query, "JOIN scope_messages m")
	require.Contains(t, query, "JOIN scope_memberships sm")
	require.Contains(t, query, "sm.member_id = $2")
	require.Contains(t, query, "$6 = 0")
	require.Contains(t, query, "OR ($7 = '' AND m.seq < $6)")
	require.Contains(t, query, "OR ($7 = '' AND docs.global_seq < $4)")
	require.Contains(t, query, "OR ($7 <> '' AND (\n           docs.search_rank > $8")
	require.Contains(t, query, "m.seq < $6")
	require.Contains(t, query, "m.seq = $6 AND m.id > $7")
	require.Contains(t, query, "docs.global_seq < $4")
	require.Contains(t, query, "docs.global_seq = $4 AND m.scope_id > $5")
	require.Contains(t, query, "docs.global_seq = $4 AND m.scope_id = $5 AND m.seq < $6")
	require.Contains(t, query, "docs.global_seq = $4 AND m.scope_id = $5 AND m.seq = $6 AND m.id > $7")
	require.Contains(t, query, "docs.search_rank ASC")
	require.Contains(t, query, "docs.search_rank > $8")
	require.Contains(t, query, "__SEARCH_RANK__")
	require.Contains(t, query, "__LIMIT_PLACEHOLDER__")
	require.Contains(t, query, "__MATCH_CONDITION__")
	require.NotContains(t, query, "LOCATE(LOWER(")
	require.Contains(t, query, "CASE WHEN $3 = '' THEN docs.global_seq END DESC")
	require.NotContains(t, query, legacyTableNameForMessageTest("space", "participants"))
	require.NotContains(t, query, legacyTableNameForMessageTest("space", "messages"))
	require.NotContains(t, query, legacyTableNameForMessageTest("targeted", "delivery", "events"))
	require.NotContains(t, query, "COUNT(*)")
	require.Contains(t, descriptor.TargetIndex, "members PRIMARY(id)")
	require.Contains(t, descriptor.TargetIndex, "machines_workspace_created_by_status_created_idx")
	require.Contains(t, descriptor.TargetIndex, "agents PRIMARY(id)")
	require.Contains(t, descriptor.TargetIndex, "agents_machine_id_idx")
	require.Contains(t, descriptor.TargetIndex, "agents UNIQUE(member_id)")
}

func TestBuildScopeSearchRankSQLPrioritizesExactAndAllTerms(t *testing.T) {
	t.Parallel()

	rankSQL, args, limitPlaceholder := buildScopeSearchRankSQL(" AGENTTEAM_DATABASE_CONNECT_TIMEOUT ", 9)

	require.Contains(t, rankSQL, "LOCATE(LOWER($9), LOWER(docs.content)) > 0 THEN 0")
	require.Contains(t, rankSQL, "LOCATE($10, LOWER(docs.content)) > 0")
	require.Contains(t, rankSQL, "LOCATE($11, LOWER(docs.content)) > 0")
	require.Contains(t, rankSQL, "LOCATE($12, LOWER(docs.content)) > 0")
	require.Contains(t, rankSQL, "LOCATE($13, LOWER(docs.content)) > 0")
	require.Equal(t, []any{"AGENTTEAM_DATABASE_CONNECT_TIMEOUT", "agentteam", "database", "connect", "timeout"}, args)
	require.Equal(t, "$14", limitPlaceholder)
}

func TestBuildScopeSearchVisibilityQueryAppliesAuthorFilterBeforeRankingAndLimit(t *testing.T) {
	testCases := []struct {
		name             string
		filter           MessageAuthorFilter
		wantSQL          []string
		wantNotSQL       []string
		wantAuthorArg    any
		wantLimit        string
		filterPositionBy string
	}{
		{
			name:             "unfiltered",
			wantSQL:          []string{"AND TRUE", "LOCATE(LOWER($9)"},
			wantLimit:        "LIMIT $11",
			filterPositionBy: "AND TRUE",
		},
		{
			name:   "human member",
			filter: MessageAuthorFilter{Kind: MessageAuthorFilterMember, ID: "mem_1"},
			wantSQL: []string{
				"m.author_type = 'human' AND EXISTS (",
				"author_member.workspace_id = m.workspace_id",
				"author_member.id = $9",
				"author_member.type = 'human'",
				"author_member.id = m.author_member_id",
				"LOCATE(LOWER($10)",
			},
			wantAuthorArg:    "mem_1",
			wantLimit:        "LIMIT $12",
			filterPositionBy: "author_member.id = $9",
		},
		{
			name:   "agent",
			filter: MessageAuthorFilter{Kind: MessageAuthorFilterAgent, ID: "agt_1"},
			wantSQL: []string{
				"m.author_type = 'agent' AND EXISTS (",
				"author_agent.workspace_id = m.workspace_id",
				"author_agent.id = $9",
				"author_agent.member_id = m.author_member_id",
			},
			wantNotSQL:       []string{"author_agent.status", "owned_machine.status"},
			wantAuthorArg:    "agt_1",
			wantLimit:        "LIMIT $12",
			filterPositionBy: "author_agent.id = $9",
		},
		{
			name:   "member agents",
			filter: MessageAuthorFilter{Kind: MessageAuthorFilterMemberAgents, ID: "mem_owner"},
			wantSQL: []string{
				"m.author_type = 'agent' AND EXISTS (",
				"owner_member.workspace_id = m.workspace_id",
				"owner_member.id = $9",
				"owner_member.type = 'human'",
				"owned_machine.created_by_user_id = owner_member.user_id",
				"owned_agent.machine_id = owned_machine.id",
				"owned_agent.member_id = m.author_member_id",
			},
			wantNotSQL:       []string{"owned_agent.status", "owned_machine.status"},
			wantAuthorArg:    "mem_owner",
			wantLimit:        "LIMIT $12",
			filterPositionBy: "owner_member.id = $9",
		},
	}

	cursor := MessageSearchCursor{Rank: 1, GlobalSeq: 80, ScopeID: "scp_cursor", Seq: 7, MessageID: "msg_cursor"}
	for _, testCase := range testCases {
		t.Run(testCase.name, func(t *testing.T) {
			query, args, err := buildScopeSearchVisibilityQuery("wsp_1", "mem_viewer", "scp_1", "needle", testCase.filter, cursor, 25)
			require.NoError(t, err)
			for _, fragment := range testCase.wantSQL {
				require.Contains(t, query, fragment)
			}
			for _, fragment := range testCase.wantNotSQL {
				require.NotContains(t, query, fragment)
			}
			require.Contains(t, query, testCase.wantLimit)
			require.NotContains(t, query, "__AUTHOR_DOC_FILTER__")
			require.NotContains(t, query, "__SEARCH_RANK__")
			require.NotContains(t, query, "__LIMIT_PLACEHOLDER__")
			require.Less(t, strings.Index(query, testCase.filterPositionBy), strings.Index(query, "LOCATE(LOWER("))
			require.Less(t, strings.Index(query, testCase.filterPositionBy), strings.Index(query, "ORDER BY"))
			require.Less(t, strings.Index(query, "ORDER BY"), strings.Index(query, testCase.wantLimit))
			require.Contains(t, query, scopeReadableMessagePredicateSQL)
			require.Equal(t, []any{"wsp_1", "mem_viewer", "scp_1", int64(80), "scp_cursor", int64(7), "msg_cursor", 1}, args[:8])
			if testCase.wantAuthorArg == nil {
				require.Equal(t, 1, strings.Count(query, "scope_messages m"))
				require.Equal(t, []any{"needle", "needle", 25}, args[8:])
				return
			}
			require.Equal(t, 2, strings.Count(query, "scope_messages m"))
			require.Equal(t, testCase.wantAuthorArg, args[8])
			require.Equal(t, []any{"needle", "needle", 25}, args[9:])
		})
	}
}

func TestMessageAuthorFilterSQLMatchesEligibleAuthorsBeforeLimit(t *testing.T) {
	t.Parallel()

	ctx := context.Background()
	db := startMessagingTestPool(t, ctx)
	now := time.Date(2026, 8, 10, 10, 0, 0, 0, time.UTC)

	workspaceID := "wsp_search_author_filter"
	crossWorkspaceID := "wsp_search_author_filter_cross"
	ownerUserID := "usr_search_author_filter_owner"
	otherUserID := "usr_search_author_filter_other"
	crossUserID := "usr_search_author_filter_cross"
	ownerMemberID := "mem_search_author_filter_owner"
	otherMemberID := "mem_search_author_filter_other"
	crossMemberID := "mem_search_author_filter_cross"
	ownerAgentMemberAID := "mem_search_author_filter_owner_agent_a"
	ownerAgentMemberBID := "mem_search_author_filter_owner_agent_b"
	otherAgentMemberID := "mem_search_author_filter_other_agent"
	crossAgentMemberID := "mem_search_author_filter_cross_agent"
	ownerMachineID := "mac_search_author_filter_owner"
	otherMachineID := "mac_search_author_filter_other"
	crossMachineID := "mac_search_author_filter_cross"
	ownerAgentAID := "agt_search_author_filter_owner_a"
	ownerAgentBID := "agt_search_author_filter_owner_b"
	otherAgentID := "agt_search_author_filter_other"
	crossAgentID := "agt_search_author_filter_cross"
	scopeID := "scp_search_author_filter"

	insertMessagingWorkspace(t, ctx, db, workspaceID, now)
	insertMessagingWorkspace(t, ctx, db, crossWorkspaceID, now)
	insertMessagingUser(t, ctx, db, ownerUserID, "search-author-owner@example.com", now)
	insertMessagingUser(t, ctx, db, otherUserID, "search-author-other@example.com", now)
	insertMessagingUser(t, ctx, db, crossUserID, "search-author-cross@example.com", now)
	insertMessagingHumanMember(t, ctx, db, ownerMemberID, workspaceID, ownerUserID, now)
	insertMessagingHumanMember(t, ctx, db, otherMemberID, workspaceID, otherUserID, now)
	insertMessagingHumanMember(t, ctx, db, crossMemberID, crossWorkspaceID, crossUserID, now)
	insertMessagingAgentMember(t, ctx, db, ownerAgentMemberAID, workspaceID, now)
	insertMessagingAgentMember(t, ctx, db, ownerAgentMemberBID, workspaceID, now)
	insertMessagingAgentMember(t, ctx, db, otherAgentMemberID, workspaceID, now)
	insertMessagingAgentMember(t, ctx, db, crossAgentMemberID, crossWorkspaceID, now)
	insertMessagingMachine(t, ctx, db, ownerMachineID, workspaceID, ownerUserID, now)
	mustExecMessaging(t, ctx, db, `
INSERT INTO machines (id, workspace_id, created_by_user_id, name, api_key, created_at, updated_at)
VALUES ($1, $2, $3, $1, $4, $5, $5)
`, otherMachineID, workspaceID, otherUserID, "machine-key-search-author-other", now)
	mustExecMessaging(t, ctx, db, `
INSERT INTO machines (id, workspace_id, created_by_user_id, name, api_key, created_at, updated_at)
VALUES ($1, $2, $3, $1, $4, $5, $5)
`, crossMachineID, crossWorkspaceID, crossUserID, "machine-key-search-author-cross", now)
	insertMessagingAgent(t, ctx, db, ownerAgentAID, workspaceID, ownerMachineID, ownerAgentMemberAID, "search-author-owner-a", now)
	insertMessagingAgent(t, ctx, db, ownerAgentBID, workspaceID, ownerMachineID, ownerAgentMemberBID, "search-author-owner-b", now)
	insertMessagingAgent(t, ctx, db, otherAgentID, workspaceID, otherMachineID, otherAgentMemberID, "search-author-other", now)
	insertMessagingAgent(t, ctx, db, crossAgentID, crossWorkspaceID, crossMachineID, crossAgentMemberID, "search-author-cross", now)
	insertMessagingChannel(t, ctx, db, scopeID, workspaceID, "search-author-filter", ownerMemberID, "", now)

	insertMessagingHumanScopeMessage(t, ctx, db, "msg_search_author_owner_human", workspaceID, scopeID, "", ownerMemberID, "needle", 7, 7, now.Add(7*time.Second))
	insertMessagingMessage(t, ctx, db, "msg_search_author_owner_agent_a", workspaceID, scopeID, ownerAgentMemberAID, "needle", 8, 8, now.Add(8*time.Second))
	insertMessagingMessage(t, ctx, db, "msg_search_author_owner_agent_b", workspaceID, scopeID, ownerAgentMemberBID, "needle", 9, 9, now.Add(9*time.Second))
	insertMessagingHumanScopeMessage(t, ctx, db, "msg_search_author_other_human", workspaceID, scopeID, "", otherMemberID, "needle", 10, 10, now.Add(10*time.Second))
	insertMessagingMessage(t, ctx, db, "msg_search_author_other_agent", workspaceID, scopeID, otherAgentMemberID, "needle", 11, 11, now.Add(11*time.Second))
	insertMessagingHumanScopeMessage(t, ctx, db, "msg_search_author_wrong_member_type", workspaceID, scopeID, "", ownerAgentMemberAID, "needle", 12, 12, now.Add(12*time.Second))
	mustExecMessaging(t, ctx, db, `UPDATE machines SET status = 'inactive' WHERE id = $1`, ownerMachineID)
	mustExecMessaging(t, ctx, db, `UPDATE agents SET status = 'archived' WHERE id IN ($1, $2)`, ownerAgentAID, ownerAgentBID)

	testCases := []struct {
		name   string
		filter MessageAuthorFilter
		limit  int
		want   []string
	}{
		{
			name:   "member filters before higher-sequence other authors",
			filter: MessageAuthorFilter{Kind: MessageAuthorFilterMember, ID: ownerMemberID},
			limit:  1,
			want:   []string{"msg_search_author_owner_human"},
		},
		{
			name:   "agent includes archived agent on inactive machine",
			filter: MessageAuthorFilter{Kind: MessageAuthorFilterAgent, ID: ownerAgentAID},
			limit:  1,
			want:   []string{"msg_search_author_owner_agent_a"},
		},
		{
			name:   "member agents includes all owned archived agents",
			filter: MessageAuthorFilter{Kind: MessageAuthorFilterMemberAgents, ID: ownerMemberID},
			limit:  2,
			want:   []string{"msg_search_author_owner_agent_b", "msg_search_author_owner_agent_a"},
		},
		{name: "member rejects agent member id", filter: MessageAuthorFilter{Kind: MessageAuthorFilterMember, ID: ownerAgentMemberAID}, limit: 5},
		{name: "unknown member", filter: MessageAuthorFilter{Kind: MessageAuthorFilterMember, ID: "mem_unknown"}, limit: 5},
		{name: "unknown agent", filter: MessageAuthorFilter{Kind: MessageAuthorFilterAgent, ID: "agt_unknown"}, limit: 5},
		{name: "unknown member agents owner", filter: MessageAuthorFilter{Kind: MessageAuthorFilterMemberAgents, ID: "mem_unknown"}, limit: 5},
		{name: "cross workspace member", filter: MessageAuthorFilter{Kind: MessageAuthorFilterMember, ID: crossMemberID}, limit: 5},
		{name: "cross workspace agent", filter: MessageAuthorFilter{Kind: MessageAuthorFilterAgent, ID: crossAgentID}, limit: 5},
		{name: "cross workspace member agents owner", filter: MessageAuthorFilter{Kind: MessageAuthorFilterMemberAgents, ID: crossMemberID}, limit: 5},
	}

	for _, testCase := range testCases {
		t.Run(testCase.name, func(t *testing.T) {
			got := queryMessageIDsByAuthorFilter(t, ctx, db, workspaceID, testCase.filter, testCase.limit)
			require.Equal(t, testCase.want, got)
		})
	}

	mustExecMessaging(t, ctx, db, `DELETE FROM agents WHERE id = $1`, ownerAgentAID)
	require.Empty(t, queryMessageIDsByAuthorFilter(t, ctx, db, workspaceID, MessageAuthorFilter{
		Kind: MessageAuthorFilterAgent,
		ID:   ownerAgentAID,
	}, 5))
	require.Equal(t, []string{"msg_search_author_owner_agent_b"}, queryMessageIDsByAuthorFilter(t, ctx, db, workspaceID, MessageAuthorFilter{
		Kind: MessageAuthorFilterMemberAgents,
		ID:   ownerMemberID,
	}, 5))
}

func queryMessageIDsByAuthorFilter(t *testing.T, ctx context.Context, db coredb.Queryer, workspaceID string, filter MessageAuthorFilter, limit int) []string {
	t.Helper()

	filterSQL, filterArgs, err := buildMessageAuthorFilterSQL(filter, 2)
	require.NoError(t, err)
	query := `
SELECT m.id
FROM scope_messages m
WHERE m.workspace_id = $1
  AND (` + filterSQL + `)
ORDER BY m.seq DESC
LIMIT $` + strconv.Itoa(2+len(filterArgs))
	args := append([]any{workspaceID}, filterArgs...)
	args = append(args, limit)
	rows, err := db.Query(ctx, query, args...)
	require.NoError(t, err)
	defer rows.Close()

	var messageIDs []string
	for rows.Next() {
		var messageID string
		require.NoError(t, rows.Scan(&messageID))
		messageIDs = append(messageIDs, messageID)
	}
	require.NoError(t, rows.Err())
	return messageIDs
}

func TestNormalizeMessageAuthorFilterRejectsInvalidValues(t *testing.T) {
	testCases := []struct {
		name      string
		filter    MessageAuthorFilter
		wantError string
	}{
		{name: "kind only", filter: MessageAuthorFilter{Kind: MessageAuthorFilterMember}, wantError: "author kind and id must be provided together"},
		{name: "id only", filter: MessageAuthorFilter{ID: "mem_1"}, wantError: "author kind and id must be provided together"},
		{name: "unknown", filter: MessageAuthorFilter{Kind: "service", ID: "svc_1"}, wantError: "invalid author kind"},
		{name: "overlong", filter: MessageAuthorFilter{Kind: MessageAuthorFilterMember, ID: strings.Repeat("a", 256)}, wantError: "author id is too long"},
	}

	for _, testCase := range testCases {
		t.Run(testCase.name, func(t *testing.T) {
			_, err := NormalizeMessageAuthorFilter(testCase.filter)
			require.EqualError(t, err, testCase.wantError)
		})
	}
}

func TestSortSearchVisibilityCandidatesRanksExactBeforeRecency(t *testing.T) {
	t.Parallel()

	candidates := []ScopeMessageVisibilityCandidate{
		{SearchRank: 2, Message: ScopeMessageRecord{ID: "partial_new", ScopeID: "scp_1", Seq: 20, WorkspaceGlobalSeq: 200}},
		{SearchRank: 0, Message: ScopeMessageRecord{ID: "exact_old", ScopeID: "scp_1", Seq: 10, WorkspaceGlobalSeq: 100}},
		{SearchRank: 1, Message: ScopeMessageRecord{ID: "all_terms_mid", ScopeID: "scp_1", Seq: 15, WorkspaceGlobalSeq: 150}},
	}

	sortSearchVisibilityCandidates(candidates, false)

	require.Equal(t, []string{"exact_old", "all_terms_mid", "partial_new"}, []string{
		candidates[0].Message.ID,
		candidates[1].Message.ID,
		candidates[2].Message.ID,
	})
}

func TestSearchVisibleFiltersInternalMessagesForHumanViewers(t *testing.T) {
	t.Parallel()

	now := time.Date(2026, 5, 20, 10, 0, 0, 0, time.UTC)
	candidates := []ScopeMessageVisibilityCandidate{
		{
			Message: ScopeMessageRecord{
				ID:                 "msg_task",
				WorkspaceID:        "wsp_1",
				ScopeID:            "chn_1",
				Seq:                3,
				WorkspaceGlobalSeq: 3,
				AuthorMemberID:     "mem_1",
				AuthorType:         ScopeActorHuman,
				MessageType:        MessageTypeTaskDispatch,
				Content:            "needle internal dispatch",
				CreatedAt:          now.Add(3 * time.Second),
			},
			Membership: ScopeMembershipRecord{
				ScopeID:          "chn_1",
				MemberID:         "mem_1",
				Visibility:       "public",
				EffectiveFromSeq: 1,
			},
			Reason: ScopeVisibilityReasonMembership,
		},
		{
			Message: ScopeMessageRecord{
				ID:                 "msg_plain",
				WorkspaceID:        "wsp_1",
				ScopeID:            "chn_1",
				Seq:                2,
				WorkspaceGlobalSeq: 2,
				AuthorMemberID:     "mem_1",
				AuthorType:         ScopeActorHuman,
				Content:            "needle plain",
				CreatedAt:          now.Add(2 * time.Second),
			},
			Membership: ScopeMembershipRecord{
				ScopeID:          "chn_1",
				MemberID:         "mem_1",
				Visibility:       "public",
				EffectiveFromSeq: 1,
			},
			Reason: ScopeVisibilityReasonMembership,
		},
	}

	page, err := searchVisibleWithLoader(context.Background(), NewVisibilityFilter(), ScopeAPISearchRequest{
		WorkspaceID:         "wsp_1",
		ScopeID:             "chn_1",
		ViewerMemberID:      "mem_1",
		ViewerType:          ScopeActorHuman,
		Query:               "needle",
		Limit:               2,
		CandidateBatchLimit: 2,
		MaxCandidateBatches: 1,
	}, 2, func(context.Context, MessageSearchCursor, int) ([]ScopeMessageVisibilityCandidate, error) {
		return candidates, nil
	})

	require.NoError(t, err)
	require.Equal(t, []string{"msg_plain"}, visibleMessageRefIDs(page.Messages))

	agentPage, err := searchVisibleWithLoader(context.Background(), NewVisibilityFilter(), ScopeAPISearchRequest{
		WorkspaceID:         "wsp_1",
		ScopeID:             "chn_1",
		ViewerMemberID:      "mem_1",
		ViewerType:          ScopeActorAgent,
		Query:               "needle",
		Limit:               2,
		CandidateBatchLimit: 2,
		MaxCandidateBatches: 1,
	}, 2, func(context.Context, MessageSearchCursor, int) ([]ScopeMessageVisibilityCandidate, error) {
		return candidates, nil
	})

	require.NoError(t, err)
	require.Equal(t, []string{"msg_task", "msg_plain"}, visibleMessageRefIDs(agentPage.Messages))
}

func TestCollectSearchVisibleRefsForOffsetPagesPastMaxSearchLimit(t *testing.T) {
	t.Parallel()

	refs := make([]VisibleMessageRef, 0, 75)
	for index := 0; index < 75; index++ {
		refs = append(refs, VisibleMessageRef{
			Message: ScopeMessageRecord{ID: "msg_" + strconv.Itoa(index)},
		})
	}

	got, hasMore, err := collectSearchVisibleRefsForOffset(
		context.Background(),
		20,
		50,
		func(_ context.Context, beforeCursor MessageSearchCursor, limit int) (VisibilityFilterResult, error) {
			start := int(beforeCursor.GlobalSeq)
			end := start + limit
			if end > len(refs) {
				end = len(refs)
			}
			page := VisibilityFilterResult{
				Messages: refs[start:end],
				HasMore:  end < len(refs),
			}
			if end < len(refs) {
				page.NextSearchCursor = MessageSearchCursor{GlobalSeq: int64(end), Seq: int64(end)}
			}
			return page, nil
		},
	)

	require.NoError(t, err)
	require.True(t, hasMore)
	require.Equal(t, 21, len(got))
	require.Equal(t, "msg_50", got[0].Message.ID)
	require.Equal(t, "msg_70", got[20].Message.ID)
}

func TestCollectSearchVisibleRefsForCursorUsesScopedSeq(t *testing.T) {
	t.Parallel()

	refs := []VisibleMessageRef{
		{Message: ScopeMessageRecord{ID: "msg_5", ScopeID: "scp_1", Seq: 5, WorkspaceGlobalSeq: 50}},
		{Message: ScopeMessageRecord{ID: "msg_4", ScopeID: "scp_1", Seq: 4, WorkspaceGlobalSeq: 40}},
		{Message: ScopeMessageRecord{ID: "msg_3", ScopeID: "scp_1", Seq: 3, WorkspaceGlobalSeq: 30}},
	}
	var loadedBeforeCursor MessageSearchCursor
	var loadedLimit int

	got, nextCursor, hasMore, err := collectSearchVisibleRefsForCursor(
		context.Background(),
		2,
		MessageSearchCursor{ScopeID: "scp_1", Seq: 6, MessageID: "msg_6"},
		"scp_1",
		func(_ context.Context, beforeCursor MessageSearchCursor, limit int) (VisibilityFilterResult, error) {
			loadedBeforeCursor = beforeCursor
			loadedLimit = limit
			return VisibilityFilterResult{Messages: refs, HasMore: true}, nil
		},
	)

	require.NoError(t, err)
	require.True(t, hasMore)
	require.Equal(t, MessageSearchCursor{ScopeID: "scp_1", Seq: 6, MessageID: "msg_6"}, loadedBeforeCursor)
	require.Equal(t, 3, loadedLimit)
	require.Equal(t, []string{"msg_5", "msg_4"}, visibleMessageRefIDs(got))
	require.Equal(t, MessageSearchCursor{ScopeID: "scp_1", Seq: 4, MessageID: "msg_4"}, nextCursor)
}

func TestCollectSearchVisibleRefsForCursorUsesWorkspaceGlobalSeq(t *testing.T) {
	t.Parallel()

	refs := []VisibleMessageRef{
		{Message: ScopeMessageRecord{ID: "msg_50", ScopeID: "scp_1", Seq: 5, WorkspaceGlobalSeq: 50}},
		{Message: ScopeMessageRecord{ID: "msg_40", ScopeID: "scp_2", Seq: 9, WorkspaceGlobalSeq: 40}},
	}

	got, nextCursor, hasMore, err := collectSearchVisibleRefsForCursor(
		context.Background(),
		2,
		MessageSearchCursor{GlobalSeq: 60, ScopeID: "scp_0", Seq: 1, MessageID: "msg_60"},
		"",
		func(_ context.Context, _ MessageSearchCursor, _ int) (VisibilityFilterResult, error) {
			return VisibilityFilterResult{Messages: refs}, nil
		},
	)

	require.NoError(t, err)
	require.False(t, hasMore)
	require.Equal(t, []string{"msg_50", "msg_40"}, visibleMessageRefIDs(got))
	require.True(t, nextCursor.IsZero())
}

func TestCollectSearchVisibleRefsForCursorReturnsEmptyContinuation(t *testing.T) {
	t.Parallel()

	got, nextCursor, hasMore, err := collectSearchVisibleRefsForCursor(
		context.Background(),
		2,
		MessageSearchCursor{GlobalSeq: 90, ScopeID: "scp_9", Seq: 9, MessageID: "msg_90"},
		"",
		func(_ context.Context, _ MessageSearchCursor, _ int) (VisibilityFilterResult, error) {
			return VisibilityFilterResult{
				HasMore:                  true,
				CandidateBudgetExhausted: true,
				NextSearchCursor:         MessageSearchCursor{GlobalSeq: 75, ScopeID: "scp_7", Seq: 5, MessageID: "msg_75"},
			}, nil
		},
	)

	require.NoError(t, err)
	require.Empty(t, got)
	require.True(t, hasMore)
	require.Equal(t, MessageSearchCursor{GlobalSeq: 75, ScopeID: "scp_7", Seq: 5, MessageID: "msg_75"}, nextCursor)
}

func TestCollectSearchVisibleRefsForCursorKeepsExhaustedCandidateCursor(t *testing.T) {
	t.Parallel()

	got, nextCursor, hasMore, err := collectSearchVisibleRefsForCursor(
		context.Background(),
		2,
		MessageSearchCursor{GlobalSeq: 90, ScopeID: "scp_9", Seq: 9, MessageID: "msg_90"},
		"",
		func(_ context.Context, _ MessageSearchCursor, _ int) (VisibilityFilterResult, error) {
			return VisibilityFilterResult{
				Messages: []VisibleMessageRef{
					{Message: ScopeMessageRecord{ID: "msg_80", ScopeID: "scp_8", Seq: 8, WorkspaceGlobalSeq: 80}},
				},
				CandidateBudgetExhausted: true,
				NextSearchCursor:         MessageSearchCursor{GlobalSeq: 75, ScopeID: "scp_7", Seq: 5, MessageID: "msg_75"},
			}, nil
		},
	)

	require.NoError(t, err)
	require.True(t, hasMore)
	require.Equal(t, []string{"msg_80"}, visibleMessageRefIDs(got))
	require.Equal(t, MessageSearchCursor{GlobalSeq: 75, ScopeID: "scp_7", Seq: 5, MessageID: "msg_75"}, nextCursor)
}

func TestHotMessageRetentionCutoffUsesUTCDays(t *testing.T) {
	localNow := time.Date(2026, 5, 3, 18, 30, 0, 0, time.FixedZone("CST", 8*60*60))
	cutoff := HotMessageRetentionCutoff(7, localNow)
	require.Equal(t, time.Date(2026, 4, 26, 10, 30, 0, 0, time.UTC), cutoff)
}
