package im

import (
	"net/http"
	"net/http/httptest"
	"testing"
	"time"

	"github.com/gin-gonic/gin"
	"github.com/stretchr/testify/require"

	"github.com/tidbcloud/AgentTeam/internal/api"
	"github.com/tidbcloud/AgentTeam/internal/api/dto"
	"github.com/tidbcloud/AgentTeam/internal/store/identity"
	storeim "github.com/tidbcloud/AgentTeam/internal/store/im"
	"github.com/tidbcloud/AgentTeam/internal/store/message"
	"github.com/tidbcloud/AgentTeam/internal/store/workspace"
	"github.com/tidbcloud/AgentTeam/internal/unread"
)

func TestConversationsListRepairsMissingIndexTitleFromAuthoritativeStore(t *testing.T) {
	gin.SetMode(gin.TestMode)
	lastAt := time.Date(2026, 7, 15, 1, 30, 0, 0, time.UTC)
	index := &fakeConversationIndexStore{page: storeim.ConversationIndexPage{
		Items: []storeim.ConversationIndexItem{{
			ConversationID: "channel:chn_general",
			ScopeID:        "chn_general",
			Kind:           message.ConversationKindChannel,
			SortAt:         lastAt,
			Projection: map[string]any{
				"channelId": "chn_general",
				"content":   "latest message",
			},
		}},
	}}
	handler := &ConversationsHandler{
		conversationIndex: index,
		conversations: fakeConversationStore{item: message.ConversationItem{
			Kind:           message.ConversationKindChannel,
			ScopeID:        "chn_general",
			Title:          "general",
			Preview:        "latest message",
			LastActivityAt: &lastAt,
		}},
	}
	request := imWorkspaceRequest()
	request.Gin.Request = httptest.NewRequest(http.MethodGet, "/?source=index&kinds=channel", nil)

	response, err := handler.List(request)

	require.NoError(t, err)
	require.Equal(t, http.StatusOK, response.Status)
	page := response.Body.(dto.ConversationPage)
	require.Len(t, page.Items, 1)
	require.Equal(t, "general", page.Items[0].Title)
	require.Len(t, index.upserted, 1)
	require.Equal(t, "general", index.upserted[0].Projection["title"])
}

func TestConversationsListOverlaysMutableTaskMetadataFromAuthoritativeStore(t *testing.T) {
	gin.SetMode(gin.TestMode)
	lastAt := time.Date(2026, 7, 29, 2, 20, 0, 0, time.UTC)
	index := &fakeConversationIndexStore{page: storeim.ConversationIndexPage{
		Items: []storeim.ConversationIndexItem{{
			ConversationID: "task:tsk_scope_1",
			ScopeID:        "tsk_scope_1",
			Kind:           message.ConversationKindTask,
			SortAt:         lastAt,
			Projection: map[string]any{
				"id":      "task:tsk_scope_1",
				"kind":    "task",
				"scopeId": "tsk_scope_1",
				"route":   "/channels/chn_1/tasks/1",
				"title":   "#t1 stale title",
				"preview": "existing attention preview",
				"task": map[string]any{
					"id":        "tsk_1",
					"scopeId":   "tsk_scope_1",
					"channelId": "chn_1",
					"number":    int64(1),
					"title":     "stale title",
					"status":    "todo",
					"route":     "/channels/chn_1/tasks/1",
					"assigneeProfile": map[string]any{
						"actorType":   "human",
						"principalId": "usr_old",
						"memberId":    "mem_old",
						"displayName": "Old assignee",
					},
				},
			},
		}},
	}}
	currentAssignee := message.AuthorProfile{
		ActorType:   message.AuthorActorTypeAgent,
		PrincipalID: "agt_new",
		MemberID:    "mem_new",
		TargetID:    "reviewer",
		DisplayName: "Current assignee",
	}
	handler := &ConversationsHandler{
		conversationIndex: index,
		conversations: fakeConversationStore{item: message.ConversationItem{
			Kind:           message.ConversationKindTask,
			ScopeID:        "tsk_scope_1",
			Title:          "#t1 current title",
			Preview:        "existing attention preview",
			LastActivityAt: &lastAt,
			Task: &message.ConversationTaskMetadata{
				ID:              "tsk_1",
				ScopeID:         "tsk_scope_1",
				ChannelID:       "chn_1",
				Number:          1,
				Title:           "current title",
				Status:          "in_progress",
				AssigneeProfile: &currentAssignee,
				Route:           "/channels/chn_1/tasks/1",
			},
		}},
	}
	request := imWorkspaceRequest()
	request.Gin.Request = httptest.NewRequest(http.MethodGet, "/?kinds=task", nil)

	response, err := handler.List(request)

	require.NoError(t, err)
	require.Equal(t, http.StatusOK, response.Status)
	page := response.Body.(dto.ConversationPage)
	require.Len(t, page.Items, 1)
	require.Equal(t, "#t1 current title", page.Items[0].Title)
	require.Equal(t, "existing attention preview", page.Items[0].Preview)
	require.NotNil(t, page.Items[0].Task)
	require.Equal(t, "current title", page.Items[0].Task.Title)
	require.Equal(t, "in_progress", page.Items[0].Task.Status)
	require.NotNil(t, page.Items[0].Task.AssigneeProfile)
	require.Equal(t, "mem_new", page.Items[0].Task.AssigneeProfile.MemberID)
	require.Empty(t, index.upserted, "read-time resolved overlays must not rewrite recipient-local Inbox state")
}

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

	store := &fakeConversationStore{
		page: message.ConversationPage{Items: []message.ConversationItem{
			{Kind: message.ConversationKindChannel, ScopeID: "chn_1", UnreadCount: 99, ManualUnread: true},
			{Kind: message.ConversationKindTask, ScopeID: "tsk_1", UnreadCount: 42, ManualUnread: false},
			{Kind: "", ScopeID: "tsk_2", UnreadCount: 77, ManualUnread: false},
		}},
	}
	runtime := &fakeConversationUnreadRuntime{states: map[string]unread.ScopeUnreadState{
		"chn_1": {ScopeID: "chn_1", MessageUnreadCount: 2, ServingState: unread.ServingStateReady},
		"tsk_1": {ScopeID: "tsk_1", ManualUnread: true, ServingState: unread.ServingStateReady},
		"tsk_2": {ScopeID: "tsk_2", ScopeType: unread.ScopeTypeTask, MessageUnreadCount: 1, ActivityUnreadCount: 9, ServingState: unread.ServingStateSnapshotServing},
	}}
	handler := &ConversationsHandler{conversations: store, unreadRuntime: runtime}

	response, err := handler.List(conversationWorkspaceRequest(""))
	require.NoError(t, err)

	page, ok := response.Body.(dto.ConversationPage)
	require.True(t, ok)
	require.Len(t, page.Items, 3)
	require.Equal(t, int64(2), page.Items[0].UnreadCount)
	require.False(t, page.Items[0].ManualUnread)
	require.Equal(t, int64(0), page.Items[1].UnreadCount)
	require.True(t, page.Items[1].ManualUnread)
	require.Equal(t, int64(10), page.Items[2].UnreadCount, "task rows aggregate targeted message and typed-activity unread even when kind is missing from projection")
	require.False(t, page.Items[2].ManualUnread)
	require.Equal(t, [][]string{{"chn_1", "tsk_1", "tsk_2"}}, runtime.scopeStateCalls)
}

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

	store := &fakeConversationStore{
		item: message.ConversationItem{
			Kind:                        message.ConversationKindTask,
			ScopeID:                     "tsk_1",
			UnreadCount:                 99,
			ManualUnread:                true,
			LastReadSeq:                 7,
			ManualUnreadGeneration:      42,
			ManualUnreadGenerationKnown: true,
		},
	}
	realtime := &fakeConversationRealtime{}
	runtime := &fakeConversationUnreadRuntime{
		states: map[string]unread.ScopeUnreadState{
			"tsk_1": {WorkspaceID: "wsp_1", MemberID: "mem_1", ScopeID: "tsk_1", ServingState: unread.ServingStateReady},
		},
	}
	handler := &ConversationsHandler{conversations: store, unreadRuntime: runtime, unreadSink: realtime}

	response, err := handler.ActionJSON(conversationWorkspaceJSON(conversationActionRequest{Action: message.ConversationActionMarkRead}, gin.Param{Key: "conversationId", Value: "task:tsk_1"}))
	require.NoError(t, err)

	item, ok := response.Body.(dto.ConversationItem)
	require.True(t, ok)
	require.Equal(t, int64(0), item.UnreadCount)
	require.False(t, item.ManualUnread)
	require.Equal(t, []unread.ReadCursorAdvanced{{
		WorkspaceID:                 "wsp_1",
		MemberID:                    "mem_1",
		ScopeID:                     "tsk_1",
		LastReadSeq:                 7,
		ManualUnreadGeneration:      42,
		ManualUnreadGenerationKnown: true,
	}}, runtime.readEvents)
	require.Len(t, realtime.unreadUpdated, 1)
	require.Equal(t, []unread.UnreadDelta{{
		WorkspaceID:        "wsp_1",
		UserID:             "usr_1",
		MemberID:           "mem_1",
		ScopeID:            "tsk_1",
		ScopeType:          unread.ScopeTypeTask,
		Count:              0,
		ManualUnread:       false,
		HasUnread:          false,
		WorkspaceHasUnread: false,
		ServingState:       unread.ServingStateReady,
	}}, realtime.unreadUpdated)
}

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

	store := &fakeConversationStore{
		item: message.ConversationItem{
			Kind:                     message.ConversationKindTask,
			ScopeID:                  "tsk_1",
			HiddenThroughSeq:         17,
			HiddenThroughActivitySeq: 23,
			SidebarHidden:            true,
		},
	}
	runtime := &fakeConversationUnreadRuntime{}
	handler := &ConversationsHandler{
		conversations: store,
		unreadRuntime: runtime,
	}

	response, err := handler.ActionJSON(conversationWorkspaceJSON(
		conversationActionRequest{Action: message.ConversationActionHide},
		gin.Param{Key: "conversationId", Value: "task:tsk_1"},
	))
	require.NoError(t, err)
	require.Equal(t, http.StatusOK, response.Status)
	require.Equal(t, []unread.ScopeInvalidated{{
		WorkspaceID:      "wsp_1",
		ScopeID:          "tsk_1",
		MemberIDs:        []string{"mem_1"},
		Reason:           "conversation_action",
		Frontier:         17,
		ActivityFrontier: 23,
		FrontiersKnown:   true,
	}}, runtime.invalidations)
}

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

	store := &fakeConversationStore{item: message.ConversationItem{
		Kind: message.ConversationKindChannel, ScopeID: "chn_1", LastReadSeq: 7, LastActivityReadSeq: 42,
	}}
	realtime := &fakeConversationRealtime{}
	runtime := &fakeConversationUnreadRuntime{states: map[string]unread.ScopeUnreadState{
		"chn_1": {WorkspaceID: "wsp_1", MemberID: "mem_1", ScopeID: "chn_1", ActivityUnreadCount: 1, ServingState: unread.ServingStateReady},
	}}
	handler := &ConversationsHandler{conversations: store, unreadRuntime: runtime, unreadSink: realtime}

	_, err := handler.ActionJSON(conversationWorkspaceJSON(conversationActionRequest{Action: message.ConversationActionMarkRead}, gin.Param{Key: "conversationId", Value: "channel:chn_1"}))
	require.NoError(t, err)
	require.Equal(t, []unread.ActivityReadCursorAdvanced{{
		WorkspaceID: "wsp_1", MemberID: "mem_1", ScopeID: "chn_1", LastReadSeq: 42,
	}}, runtime.activityReadEvents)
	require.Equal(t, int64(0), runtime.states["chn_1"].ActivityUnreadCount)
}

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

	store := &fakeConversationStore{
		item: message.ConversationItem{
			Kind:                   message.ConversationKindChannel,
			ScopeID:                "chn_1",
			UnreadCount:            99,
			ManualUnread:           false,
			ManualUnreadGeneration: 43,
		},
	}
	realtime := &fakeConversationRealtime{}
	runtime := &fakeConversationUnreadRuntime{
		workspaceHasUnread: true,
		states: map[string]unread.ScopeUnreadState{
			"chn_1": {WorkspaceID: "wsp_1", MemberID: "mem_1", ScopeID: "chn_1", ManualUnread: true, ServingState: unread.ServingStateReady},
		},
	}
	handler := &ConversationsHandler{conversations: store, unreadRuntime: runtime, unreadSink: realtime}

	response, err := handler.ActionJSON(conversationWorkspaceJSON(conversationActionRequest{Action: message.ConversationActionMarkUnread}, gin.Param{Key: "conversationId", Value: "channel:chn_1"}))
	require.NoError(t, err)

	item, ok := response.Body.(dto.ConversationItem)
	require.True(t, ok)
	require.Equal(t, int64(0), item.UnreadCount)
	require.True(t, item.ManualUnread)
	require.Equal(t, []unread.ManualUnreadChanged{{
		WorkspaceID:            "wsp_1",
		MemberID:               "mem_1",
		ScopeID:                "chn_1",
		ManualUnread:           true,
		ManualUnreadGeneration: 43,
	}}, runtime.manualEvents)
	require.Len(t, realtime.unreadUpdated, 1)
	require.Equal(t, []unread.UnreadDelta{{
		WorkspaceID:        "wsp_1",
		UserID:             "usr_1",
		MemberID:           "mem_1",
		ScopeID:            "chn_1",
		ScopeType:          unread.ScopeTypeChannel,
		Count:              0,
		ManualUnread:       true,
		HasUnread:          true,
		WorkspaceHasUnread: true,
		ServingState:       unread.ServingStateReady,
	}}, realtime.unreadUpdated)
}

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

	store := &fakeConversationStore{
		item: message.ConversationItem{
			Kind:         message.ConversationKindTask,
			ScopeID:      "tsk_1",
			UnreadCount:  99,
			ManualUnread: false,
		},
	}
	realtime := &fakeConversationRealtime{}
	runtime, err := unread.New(unread.Config{})
	require.NoError(t, err)
	require.NoError(t, runtime.HydrateSnapshot(unread.Snapshot{
		WorkspaceID:          "wsp_1",
		MemberID:             "mem_1",
		ScopeID:              "tsk_1",
		ScopeType:            unread.ScopeTypeTask,
		MembershipVisibility: "public",
		ServingState:         unread.ServingStateSnapshotServing,
	}))
	adapter := &realConversationUnreadRuntime{runtime: runtime}
	handler := &ConversationsHandler{conversations: store, unreadRuntime: adapter, unreadSink: realtime}

	response, err := handler.ActionJSON(conversationWorkspaceJSON(conversationActionRequest{Action: message.ConversationActionMarkUnread}, gin.Param{Key: "conversationId", Value: "task:tsk_1"}))
	require.NoError(t, err)

	item, ok := response.Body.(dto.ConversationItem)
	require.True(t, ok)
	require.Equal(t, int64(0), item.UnreadCount, "manual task unread must not fabricate a message count")
	require.True(t, item.ManualUnread)
	require.True(t, runtime.WorkspaceHasUnread("wsp_1", "mem_1"))
	require.Equal(t, int64(1), runtime.WorkspaceUnreadConversationCount("wsp_1", "mem_1"))
	require.Len(t, realtime.unreadUpdated, 1)
	require.Positive(t, realtime.unreadUpdated[0].StateVersion)
	realtime.unreadUpdated[0].StateVersion = 0
	require.Equal(t, []unread.UnreadDelta{{
		WorkspaceID:                      "wsp_1",
		UserID:                           "usr_1",
		MemberID:                         "mem_1",
		ScopeID:                          "tsk_1",
		ScopeType:                        unread.ScopeTypeTask,
		Count:                            0,
		ManualUnread:                     true,
		HasUnread:                        true,
		WorkspaceHasUnread:               true,
		WorkspaceUnreadConversationCount: 1,
		ServingState:                     unread.ServingStateReady,
	}}, realtime.unreadUpdated)
}

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

	store := &fakeConversationStore{
		item: message.ConversationItem{
			Kind:        message.ConversationKindTask,
			ScopeID:     "tsk_1",
			UnreadCount: 1,
			LastReadSeq: 9,
		},
	}
	realtime := &fakeConversationRealtime{}
	runtime, err := unread.New(unread.Config{})
	require.NoError(t, err)
	require.NoError(t, runtime.WarmScopeRoster("wsp_1", "tsk_1", unread.ScopeTypeTask, []unread.RosterMember{
		{MemberID: "mem_1", UserID: "usr_1", Visibility: "public", NotificationState: "active"},
	}))
	_, err = runtime.ApplyMessage(unread.MessageStored{
		WorkspaceID:    "wsp_1",
		ScopeID:        "tsk_1",
		ScopeType:      unread.ScopeTypeTask,
		Seq:            9,
		AuthorMemberID: "mem_author",
		RecipientMemberIDs: []string{
			"mem_1",
		},
	})
	require.NoError(t, err)
	require.True(t, runtime.WorkspaceHasUnread("wsp_1", "mem_1"))
	require.Equal(t, int64(1), runtime.WorkspaceUnreadConversationCount("wsp_1", "mem_1"))

	handler := &ConversationsHandler{conversations: store, unreadRuntime: &realConversationUnreadRuntime{runtime: runtime}, unreadSink: realtime}
	response, err := handler.ActionJSON(conversationWorkspaceJSON(conversationActionRequest{Action: message.ConversationActionMarkRead}, gin.Param{Key: "conversationId", Value: "task:tsk_1"}))
	require.NoError(t, err)

	item, ok := response.Body.(dto.ConversationItem)
	require.True(t, ok)
	require.Equal(t, int64(0), item.UnreadCount)
	require.False(t, item.ManualUnread)
	require.False(t, runtime.WorkspaceHasUnread("wsp_1", "mem_1"))
	require.Equal(t, int64(0), runtime.WorkspaceUnreadConversationCount("wsp_1", "mem_1"))
	require.Len(t, realtime.unreadUpdated, 1)
	require.Positive(t, realtime.unreadUpdated[0].StateVersion)
	realtime.unreadUpdated[0].StateVersion = 0
	require.Equal(t, []unread.UnreadDelta{{
		WorkspaceID:        "wsp_1",
		UserID:             "usr_1",
		MemberID:           "mem_1",
		ScopeID:            "tsk_1",
		ScopeType:          unread.ScopeTypeTask,
		Count:              0,
		ManualUnread:       false,
		HasUnread:          false,
		WorkspaceHasUnread: false,
		ServingState:       unread.ServingStateReady,
	}}, realtime.unreadUpdated)
}

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

	store := &fakeConversationStore{
		item: message.ConversationItem{
			Kind:         message.ConversationKindTask,
			ScopeID:      "tsk_1",
			UnreadCount:  3,
			ManualUnread: true,
		},
	}
	realtime := &fakeConversationRealtime{}
	runtime := &fakeConversationUnreadRuntime{
		states: map[string]unread.ScopeUnreadState{
			"tsk_1": {
				WorkspaceID:         "wsp_1",
				MemberID:            "mem_1",
				ScopeID:             "tsk_1",
				ScopeType:           unread.ScopeTypeTask,
				MessageUnreadCount:  2,
				ActivityUnreadCount: 1,
				ManualUnread:        true,
				ServingState:        unread.ServingStateReady,
			},
		},
	}
	handler := &ConversationsHandler{conversations: store, unreadRuntime: runtime, unreadSink: realtime}

	response, err := handler.ActionJSON(conversationWorkspaceJSON(conversationActionRequest{Action: message.ConversationActionMute}, gin.Param{Key: "conversationId", Value: "task:tsk_1"}))
	require.NoError(t, err)

	item, ok := response.Body.(dto.ConversationItem)
	require.True(t, ok)
	require.Equal(t, int64(3), item.UnreadCount)
	require.True(t, item.ManualUnread)
	require.Empty(t, runtime.invalidations, "Task Unwatch must not suppress or clear unread runtime facts")
	require.Len(t, realtime.unreadUpdated, 1)
	require.Equal(t, int64(3), realtime.unreadUpdated[0].Count)
	require.True(t, realtime.unreadUpdated[0].ManualUnread)
}

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

	store := &fakeConversationStore{
		item: message.ConversationItem{
			Kind:         message.ConversationKindChannel,
			ScopeID:      "chn_1",
			UnreadCount:  3,
			ManualUnread: true,
		},
	}
	realtime := &fakeConversationRealtime{}
	runtime := &fakeConversationUnreadRuntime{
		workspaceHasUnread: true,
	}
	handler := &ConversationsHandler{conversations: store, unreadRuntime: runtime, unreadSink: realtime}

	response, err := handler.ActionJSON(conversationWorkspaceJSON(conversationActionRequest{Action: message.ConversationActionMute}, gin.Param{Key: "conversationId", Value: "channel:chn_1"}))
	require.NoError(t, err)

	item, ok := response.Body.(dto.ConversationItem)
	require.True(t, ok)
	require.Equal(t, int64(0), item.UnreadCount)
	require.False(t, item.ManualUnread)
	require.Equal(t, []unread.ScopeInvalidated{{
		WorkspaceID: "wsp_1",
		ScopeID:     "chn_1",
		MemberIDs:   []string{"mem_1"},
		Reason:      "conversation_action",
	}}, runtime.invalidations)
	require.Equal(t, []unread.UnreadDelta{{
		WorkspaceID:        "wsp_1",
		UserID:             "usr_1",
		MemberID:           "mem_1",
		ScopeID:            "chn_1",
		ScopeType:          unread.ScopeTypeChannel,
		Count:              0,
		ManualUnread:       false,
		HasUnread:          false,
		WorkspaceHasUnread: true,
		ServingState:       unread.ServingStateStale,
	}}, realtime.unreadUpdated)
}

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

	store := &fakeConversationStore{
		bulkResult: message.ConversationBulkActionResult{
			UpdatedCount: 2,
			ReadScopes: []message.ConversationReadScope{
				{ScopeID: "chn_1", Kind: message.ConversationKindChannel, LastReadSeq: 5, ManualUnreadGeneration: 51, ManualUnreadGenerationKnown: true},
				{ScopeID: "tsk_1", Kind: message.ConversationKindTask, LastReadSeq: 7, ManualUnreadGeneration: 52, ManualUnreadGenerationKnown: true},
			},
		},
	}
	realtime := &fakeConversationRealtime{}
	runtime := &fakeConversationUnreadRuntime{
		states: map[string]unread.ScopeUnreadState{
			"chn_1": {WorkspaceID: "wsp_1", MemberID: "mem_1", ScopeID: "chn_1", ServingState: unread.ServingStateReady},
			"tsk_1": {WorkspaceID: "wsp_1", MemberID: "mem_1", ScopeID: "tsk_1", ServingState: unread.ServingStateReady},
		},
	}
	handler := &ConversationsHandler{conversations: store, unreadRuntime: runtime, unreadSink: realtime}

	response, err := handler.BulkAction(conversationWorkspaceJSON(conversationsBulkActionRequest{Action: message.ConversationActionMarkRead}))
	require.NoError(t, err)

	result, ok := response.Body.(dto.ConversationsBulkActionResponse)
	require.True(t, ok)
	require.Equal(t, int64(2), result.UpdatedCount)
	require.Equal(t, []unread.ReadCursorAdvanced{
		{WorkspaceID: "wsp_1", MemberID: "mem_1", ScopeID: "chn_1", LastReadSeq: 5, ManualUnreadGeneration: 51, ManualUnreadGenerationKnown: true},
		{WorkspaceID: "wsp_1", MemberID: "mem_1", ScopeID: "tsk_1", LastReadSeq: 7, ManualUnreadGeneration: 52, ManualUnreadGenerationKnown: true},
	}, runtime.readEvents)
	require.Len(t, realtime.unreadUpdated, 2)
	require.Equal(t, "chn_1", realtime.unreadUpdated[0].ScopeID)
	require.Equal(t, "tsk_1", realtime.unreadUpdated[1].ScopeID)
}

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

	newerManualRuntime, err := unread.New(unread.Config{})
	require.NoError(t, err)
	require.NoError(t, newerManualRuntime.HydrateSnapshot(unread.Snapshot{
		WorkspaceID:            "wsp_1",
		MemberID:               "mem_1",
		ScopeID:                "tsk_1",
		ScopeType:              unread.ScopeTypeTask,
		ManualUnreadGeneration: 11,
		MembershipVisibility:   "public",
		ServingState:           unread.ServingStateSnapshotServing,
	}))
	require.NoError(t, newerManualRuntime.ApplyManualUnread(unread.ManualUnreadChanged{
		WorkspaceID:            "wsp_1",
		MemberID:               "mem_1",
		ScopeID:                "tsk_1",
		ManualUnread:           true,
		ManualUnreadGeneration: 12,
	}))
	readHandler := &ConversationsHandler{
		conversations: &fakeConversationStore{item: message.ConversationItem{
			Kind:                        message.ConversationKindTask,
			ScopeID:                     "tsk_1",
			LastReadSeq:                 10,
			ManualUnreadGeneration:      11,
			ManualUnreadGenerationKnown: true,
		}},
		unreadRuntime: &realConversationUnreadRuntime{runtime: newerManualRuntime},
	}

	response, err := readHandler.ActionJSON(conversationWorkspaceJSON(
		conversationActionRequest{Action: message.ConversationActionMarkRead},
		gin.Param{Key: "conversationId", Value: "task:tsk_1"},
	))
	require.NoError(t, err)
	require.True(t, response.Body.(dto.ConversationItem).ManualUnread, "an older mark-read callback must preserve a newer committed mark-unread")

	newerReadRuntime, err := unread.New(unread.Config{})
	require.NoError(t, err)
	require.NoError(t, newerReadRuntime.HydrateSnapshot(unread.Snapshot{
		WorkspaceID:            "wsp_1",
		MemberID:               "mem_1",
		ScopeID:                "tsk_1",
		ScopeType:              unread.ScopeTypeTask,
		ManualUnread:           true,
		ManualUnreadGeneration: 11,
		MembershipVisibility:   "public",
		ServingState:           unread.ServingStateSnapshotServing,
	}))
	require.NoError(t, newerReadRuntime.ApplyReadCursor(unread.ReadCursorAdvanced{
		WorkspaceID:                 "wsp_1",
		MemberID:                    "mem_1",
		ScopeID:                     "tsk_1",
		LastReadSeq:                 10,
		ManualUnreadGeneration:      12,
		ManualUnreadGenerationKnown: true,
	}))
	unreadHandler := &ConversationsHandler{
		conversations: &fakeConversationStore{item: message.ConversationItem{
			Kind:                   message.ConversationKindTask,
			ScopeID:                "tsk_1",
			ManualUnread:           true,
			ManualUnreadGeneration: 11,
		}},
		unreadRuntime: &realConversationUnreadRuntime{runtime: newerReadRuntime},
	}

	response, err = unreadHandler.ActionJSON(conversationWorkspaceJSON(
		conversationActionRequest{Action: message.ConversationActionMarkUnread},
		gin.Param{Key: "conversationId", Value: "task:tsk_1"},
	))
	require.NoError(t, err)
	require.False(t, response.Body.(dto.ConversationItem).ManualUnread, "an older mark-unread callback must not resurrect state cleared by a newer committed read")
}

type realConversationUnreadRuntime struct {
	runtime *unread.Runtime
}

func (adapter *realConversationUnreadRuntime) ScopeStates(workspaceID string, memberID string, scopeIDs []string) map[string]unread.ScopeUnreadState {
	return adapter.runtime.ScopeStates(workspaceID, memberID, scopeIDs)
}

func (adapter *realConversationUnreadRuntime) WorkspaceStates(workspaceID string, memberID string) map[string]unread.ScopeUnreadState {
	return adapter.runtime.ScopeStates(workspaceID, memberID, nil)
}

func (adapter *realConversationUnreadRuntime) WorkspaceHasUnread(workspaceID string, memberID string) bool {
	return adapter.runtime.WorkspaceHasUnread(workspaceID, memberID)
}

func (adapter *realConversationUnreadRuntime) WorkspaceUnreadConversationCount(workspaceID string, memberID string) int64 {
	return adapter.runtime.WorkspaceUnreadConversationCount(workspaceID, memberID)
}

func (adapter *realConversationUnreadRuntime) ApplyReadCursor(event unread.ReadCursorAdvanced) error {
	return adapter.runtime.ApplyReadCursor(event)
}

func (adapter *realConversationUnreadRuntime) ApplyActivityReadCursor(event unread.ActivityReadCursorAdvanced) error {
	return adapter.runtime.ApplyActivityReadCursor(event)
}

func (adapter *realConversationUnreadRuntime) ApplyManualUnread(event unread.ManualUnreadChanged) error {
	return adapter.runtime.ApplyManualUnread(event)
}

func (adapter *realConversationUnreadRuntime) ApplyScopeInvalidated(event unread.ScopeInvalidated) error {
	return adapter.runtime.ApplyScopeInvalidated(event)
}

func (adapter *realConversationUnreadRuntime) ApplyScopeInvalidatedWithDeltas(event unread.ScopeInvalidated) ([]unread.UnreadDelta, error) {
	return adapter.runtime.ApplyScopeInvalidatedWithDeltas(event)
}

func (adapter *realConversationUnreadRuntime) ApplyNotificationMuted(event unread.ScopeInvalidated) error {
	_, err := adapter.runtime.ApplyNotificationMuted(event)
	return err
}

func (adapter *realConversationUnreadRuntime) RefreshScopeRoster(workspaceID string, scopeID string, scopeType unread.ScopeType, memberIDs []string) error {
	return adapter.runtime.InvalidateScopeRosterForRefresh(workspaceID, scopeID, scopeType, memberIDs)
}

type fakeConversationUnreadRuntime struct {
	states             map[string]unread.ScopeUnreadState
	workspaceHasUnread bool
	scopeStateCalls    [][]string
	readEvents         []unread.ReadCursorAdvanced
	activityReadEvents []unread.ActivityReadCursorAdvanced
	manualEvents       []unread.ManualUnreadChanged
	invalidations      []unread.ScopeInvalidated
}

func (fake *fakeConversationUnreadRuntime) ScopeStates(_ string, _ string, scopeIDs []string) map[string]unread.ScopeUnreadState {
	copied := append([]string(nil), scopeIDs...)
	fake.scopeStateCalls = append(fake.scopeStateCalls, copied)
	result := make(map[string]unread.ScopeUnreadState)
	for _, scopeID := range scopeIDs {
		if state, ok := fake.states[scopeID]; ok {
			result[scopeID] = state
		}
	}
	return result
}

func (fake *fakeConversationUnreadRuntime) WorkspaceStates(workspaceID string, memberID string) map[string]unread.ScopeUnreadState {
	return fake.ScopeStates(workspaceID, memberID, nil)
}

func (fake *fakeConversationUnreadRuntime) WorkspaceHasUnread(string, string) bool {
	return fake.workspaceHasUnread
}

func (fake *fakeConversationUnreadRuntime) WorkspaceUnreadConversationCount(string, string) int64 {
	return 0
}

func (fake *fakeConversationUnreadRuntime) ApplyReadCursor(event unread.ReadCursorAdvanced) error {
	fake.readEvents = append(fake.readEvents, event)
	return nil
}

func (fake *fakeConversationUnreadRuntime) ApplyActivityReadCursor(event unread.ActivityReadCursorAdvanced) error {
	fake.activityReadEvents = append(fake.activityReadEvents, event)
	state := fake.states[event.ScopeID]
	state.ActivityUnreadCount = 0
	fake.states[event.ScopeID] = state
	return nil
}

func (fake *fakeConversationUnreadRuntime) ApplyManualUnread(event unread.ManualUnreadChanged) error {
	fake.manualEvents = append(fake.manualEvents, event)
	return nil
}

func (fake *fakeConversationUnreadRuntime) ApplyScopeInvalidated(event unread.ScopeInvalidated) error {
	fake.invalidations = append(fake.invalidations, event)
	return nil
}

func (fake *fakeConversationUnreadRuntime) ApplyScopeInvalidatedWithDeltas(event unread.ScopeInvalidated) ([]unread.UnreadDelta, error) {
	fake.invalidations = append(fake.invalidations, event)
	return nil, nil
}

func (fake *fakeConversationUnreadRuntime) ApplyNotificationMuted(event unread.ScopeInvalidated) error {
	fake.invalidations = append(fake.invalidations, event)
	return nil
}

func (fake *fakeConversationUnreadRuntime) RefreshScopeRoster(string, string, unread.ScopeType, []string) error {
	return nil
}

type fakeConversationRealtime struct {
	unreadUpdated []unread.UnreadDelta
}

func (fake *fakeConversationRealtime) EmitUnreadUpdated(_ string, delta unread.UnreadDelta) {
	fake.unreadUpdated = append(fake.unreadUpdated, delta)
}

func conversationWorkspaceRequest(rawQuery string, params ...gin.Param) api.WorkspaceScopedRequest {
	ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
	path := "/"
	if rawQuery != "" {
		path += "?" + rawQuery
	}
	ctx.Request = httptest.NewRequest(http.MethodGet, path, nil)
	ctx.Params = params
	return api.WorkspaceScopedRequest{
		AuthenticatedRequest: api.AuthenticatedRequest{Gin: ctx, User: identity.AuthenticatedUser{User: identity.User{ID: "usr_1"}}},
		Workspace:            workspace.Summary{WorkspaceID: "wsp_1", MemberID: "mem_1"},
	}
}

func conversationWorkspaceJSON[T any](body T, params ...gin.Param) api.WorkspaceScopedJSON[T] {
	return api.WorkspaceScopedJSON[T]{WorkspaceScopedRequest: conversationWorkspaceRequest("", params...), Body: body}
}
