package im

import (
	"encoding/json"
	"errors"
	"net/http"
	"strings"
	"time"

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

// ConversationsHandler owns workspace-prefixed conversation list and row actions.
type ConversationsHandler struct {
	conversations     ConversationStore
	conversationIndex ConversationIndexStore
	streamWriter      StreamEventWriter
	unreadRuntime     UnreadRuntime
	unreadSink        UnreadDeltaSink
}

type conversationActionRequest struct {
	Action string `json:"action"`
}

type conversationsBulkActionRequest struct {
	Action string   `json:"action"`
	Kinds  []string `json:"kinds"`
}

// List handles GET /api/v1/workspaces/:workspaceId/conversations.
func (h *ConversationsHandler) List(r api.WorkspaceScopedRequest) (api.Response, error) {
	limit, err := parseLimit(r.Gin.Query("limit"), defaultIMListLimit, maxIMListLimit)
	if err != nil {
		return api.Response{}, err
	}
	filter, err := parseConversationKindFilterFromQuery(r.Gin.Query("kind"), r.Gin.Query("kinds"))
	if err != nil {
		return api.Response{}, err
	}
	if useIMConversationIndex(r) || conversationFilterIncludesTask(filter) {
		return h.listFromConversationIndex(r, limit, filter)
	}
	return h.listFromAuthoritativeStore(r, limit, filter)
}

func conversationFilterIncludesTask(filter message.ConversationKindFilter) bool {
	for _, kind := range filter.Kinds {
		if strings.TrimSpace(kind) == message.ConversationKindTask {
			return true
		}
	}
	return false
}

func (h *ConversationsHandler) listFromAuthoritativeStore(r api.WorkspaceScopedRequest, limit int, filter message.ConversationKindFilter) (api.Response, error) {
	if h.conversations == nil {
		return api.OK(dto.ConversationPage{Items: []dto.ConversationItem{}}), nil
	}
	cursor, err := converter.FromConversationCursorToken(r.Gin.Query("cursor"))
	if err != nil {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, err.Error())
	}
	page, err := h.conversations.ListConversationsForUserFiltered(r.Context(), r.UserID(), r.WorkspaceID(), limit, cursor, filter)
	if err != nil {
		return api.Response{}, err
	}
	h.overlayStoreUnread(r.WorkspaceID(), r.Workspace.MemberID, page.Items)
	return api.OK(converter.ToConversationPage(page)), nil
}

// BulkAction handles POST /api/v1/workspaces/:workspaceId/conversations/actions.
func (h *ConversationsHandler) BulkAction(r api.WorkspaceScopedJSON[conversationsBulkActionRequest]) (api.Response, error) {
	if h.conversations == nil {
		return api.Response{}, api.NewHTTPError(http.StatusInternalServerError, "Internal server error")
	}
	action := strings.TrimSpace(r.Body.Action)
	if action == "" {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "action is required")
	}
	if action != message.ConversationActionMarkRead {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "unsupported conversation bulk action")
	}
	filter, err := parseConversationKindFilter(r.Body.Kinds)
	if err != nil {
		return api.Response{}, err
	}
	result, err := h.conversations.MarkConversationsReadForUser(r.Context(), r.UserID(), r.WorkspaceID(), filter)
	if err != nil {
		return api.Response{}, err
	}
	taskReadScopes := h.applyBulkReadUnread(r.WorkspaceID(), r.UserID(), r.Workspace.MemberID, result.ReadScopes)
	defer func() {
		// The durable event carries the exact committed scope set, so a Task
		// first materialized after the bulk transaction is never cleared by
		// kind. Re-publish each committed Task scope after the durable event to
		// repair a newer event on one of those same scopes.
		for _, scope := range taskReadScopes {
			h.emitCurrentUnreadState(
				r.WorkspaceID(),
				r.UserID(),
				r.Workspace.MemberID,
				scope.ScopeID,
				scope.Kind,
			)
		}
	}()
	dtoResult := converter.ToConversationsBulkActionResponse(result)
	workspaceUnreadConversationCount := h.workspaceUnreadConversationCount(r.WorkspaceID(), r.Workspace.MemberID)
	if err := publishConversationBulkActionEvent(
		r.Context(),
		h.streamWriter,
		r.WorkspaceID(),
		r.Workspace.MemberID,
		action,
		r.Body.Kinds,
		dtoResult,
		result.ReadScopes,
		workspaceUnreadConversationCount,
	); err != nil {
		return api.Response{}, err
	}
	return api.OK(dtoResult), nil
}

// Lookup handles POST /api/v1/workspaces/:workspaceId/conversations/lookup.
// It returns presentation projections for at most 50 exact member-visible
// conversation IDs without enumerating the Inbox or hydrating rows one by one.
func (h *ConversationsHandler) Lookup(r api.WorkspaceScopedJSON[dto.ConversationLookupRequest]) (api.Response, error) {
	if h.conversationIndex == nil {
		return api.Response{}, api.ErrNotFound
	}
	if len(r.Body.ConversationIDs) > storeim.MaxConversationLookupIDs {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "too many conversationIds")
	}
	conversationIDs, valid := normalizedConversationLookupIDs(r.Body.ConversationIDs)
	if !valid {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "invalid conversationId")
	}
	if len(conversationIDs) == 0 {
		return api.OK(dto.ConversationPage{Items: []dto.ConversationItem{}}), nil
	}
	indexed, err := h.conversationIndex.LookupMemberVisibleConversationProjectionsByIDs(
		r.Context(),
		r.WorkspaceID(),
		r.Workspace.MemberID,
		conversationIDs,
	)
	if err != nil {
		return api.Response{}, err
	}
	items := make([]dto.ConversationItem, 0, len(indexed))
	for _, item := range indexed {
		// Exact resource-label lookup is intentionally projection-only. Current
		// authorization is enforced by the requested-ID-first index query; a
		// legacy incomplete projection stays unresolved instead of causing one
		// source hydration per requested row.
		if projected, complete := conversationDTOFromIndexProjection(item); complete {
			items = append(items, projected)
		}
	}
	return api.OK(dto.ConversationPage{Items: items}), nil
}

// Get handles GET /api/v1/workspaces/:workspaceId/conversations/:conversationId.
func (h *ConversationsHandler) Get(r api.WorkspaceScopedRequest) (api.Response, error) {
	conversationID := routeConversationID(r)
	if conversationID == "" {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "conversationId is required")
	}
	if h.conversationIndex == nil {
		return api.Response{}, api.NewHTTPError(http.StatusNotImplemented, "conversation index is not wired")
	}
	item, err := h.conversationIndex.GetConversation(r.Context(), r.WorkspaceID(), r.Workspace.MemberID, conversationID)
	if err != nil {
		return api.Response{}, err
	}
	dtoItem, ok := h.conversationItemFromIndex(r, item)
	if !ok {
		return api.Response{}, api.NewHTTPError(http.StatusInternalServerError, "conversation projection is incomplete")
	}
	items := []dto.ConversationItem{dtoItem}
	h.overlayDTOUnread(r.WorkspaceID(), r.Workspace.MemberID, items)
	return api.OK(items[0]), nil
}

// Action returns a handler for one row-level conversation operation.
func (h *ConversationsHandler) Action(action string) api.WorkspaceScopedHandler {
	handler := func(r api.WorkspaceScopedRequest) (api.Response, error) {
		response, err := h.action(r, action)
		if err != nil {
			return api.Response{}, err
		}
		return response, nil
	}
	return handler
}

// ActionJSON handles POST /api/v1/workspaces/:workspaceId/conversations/:conversationId/actions.
func (h *ConversationsHandler) ActionJSON(r api.WorkspaceScopedJSON[conversationActionRequest]) (api.Response, error) {
	action := strings.TrimSpace(r.Body.Action)
	if action == "" {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "action is required")
	}
	return h.action(r.WorkspaceScopedRequest, action)
}

func (h *ConversationsHandler) action(r api.WorkspaceScopedRequest, action string) (api.Response, error) {
	if h.conversations == nil {
		return api.Response{}, api.NewHTTPError(http.StatusInternalServerError, "Internal server error")
	}
	conversationID := routeConversationID(r)
	if conversationID == "" {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "conversationId is required")
	}
	item, err := h.conversations.PerformConversationActionForUser(r.Context(), r.UserID(), r.WorkspaceID(), conversationID, action)
	if err != nil {
		if strings.TrimSpace(action) == message.ConversationActionHide && errors.Is(err, message.ErrNotFound) {
			return h.archiveIndexedConversation(r, conversationID)
		}
		return api.Response{}, err
	}
	isTaskAction := strings.TrimSpace(item.Kind) == message.ConversationKindTask
	if isTaskAction {
		// A Task action's durable member event may be appended concurrently with
		// newly-accounted Task attention. Publish exactly one owner-local unread
		// repair after that durable event, so the member stream cannot finish on
		// an older action snapshot. Task action mutations below intentionally do
		// not emit an earlier duplicate.
		defer func() {
			if strings.TrimSpace(action) == message.ConversationActionHide {
				h.emitSuppressedUnreadState(
					r.WorkspaceID(),
					r.UserID(),
					r.Workspace.MemberID,
					item.ScopeID,
					item.Kind,
					unread.ServingStateStale,
				)
				return
			}
			h.emitCurrentUnreadState(
				r.WorkspaceID(),
				r.UserID(),
				r.Workspace.MemberID,
				item.ScopeID,
				item.Kind,
			)
		}()
	}
	h.applyActionUnread(r.WorkspaceID(), r.UserID(), r.Workspace.MemberID, action, item)
	items := []message.ConversationItem{item}
	h.overlayStoreUnread(r.WorkspaceID(), r.Workspace.MemberID, items)
	item = items[0]
	if err := publishConversationActionEvent(r.Context(), h.streamWriter, r.WorkspaceID(), r.Workspace.MemberID, action, item); err != nil {
		if action == message.ConversationActionMarkRead {
			return api.OK(converter.ToConversationItem(item)), nil
		}
		if strings.TrimSpace(action) == message.ConversationActionHide && coredb.IsNotFound(err) {
			// The source can be deleted after its action transaction commits but
			// before the stream projection transaction starts. Finish against the
			// exact member-local index row instead of returning a false failure or
			// swallowing the missing-scope error.
			return h.archiveIndexedConversation(r, conversationID)
		}
		return api.Response{}, err
	}
	return api.OK(converter.ToConversationItem(item)), nil
}

// archiveIndexedConversation handles the one lifecycle state that the
// source-backed conversation store cannot: an Inbox projection whose source
// object has already been hard-deleted. The exact member-local index row is the
// authority, and the stream/index writer archives it atomically.
func (h *ConversationsHandler) archiveIndexedConversation(r api.WorkspaceScopedRequest, conversationID string) (api.Response, error) {
	if h.streamWriter == nil {
		return api.Response{}, message.ErrNotFound
	}
	_, indexed, err := h.streamWriter.ArchiveIndexedConversation(r.Context(), storeim.IndexedConversationArchiveRequest{
		WorkspaceID:    r.WorkspaceID(),
		MemberID:       r.Workspace.MemberID,
		ConversationID: strings.TrimSpace(conversationID),
		Event: storeim.StreamEvent{
			Type: imEventTypeConversationArchived,
			Payload: map[string]any{
				"action": message.ConversationActionHide,
			},
			CreatedAt: time.Now().UTC(),
		},
	})
	if err != nil {
		if coredb.IsNotFound(err) {
			return api.Response{}, message.ErrNotFound
		}
		return api.Response{}, err
	}
	dtoItem, _ := conversationDTOFromIndexProjection(indexed)
	if strings.TrimSpace(dtoItem.ID) == "" {
		dtoItem.ID = strings.TrimSpace(indexed.ConversationID)
	}
	if strings.TrimSpace(dtoItem.ScopeID) == "" {
		dtoItem.ScopeID = strings.TrimSpace(indexed.ScopeID)
	}
	if strings.TrimSpace(dtoItem.Kind) == "" {
		dtoItem.Kind = frontendConversationKindFromIndex(indexed, indexed.Projection)
	}
	dtoItem.Route = converter.InboxConversationRoute(conversationRouteFactsFromIndexProjection(indexed, indexed.Projection))
	if strings.TrimSpace(dtoItem.Title) == "" {
		dtoItem.Title = firstNonEmptyString(dtoItem.ScopeID, dtoItem.ID)
	}
	dtoItem.SidebarHidden = true
	dtoItem.Archived = true
	h.applyActionUnread(r.WorkspaceID(), r.UserID(), r.Workspace.MemberID, message.ConversationActionHide, message.ConversationItem{
		Kind:    strings.TrimSpace(indexed.Kind),
		ScopeID: strings.TrimSpace(indexed.ScopeID),
	})
	return api.OK(dtoItem), nil
}

func (h *ConversationsHandler) listFromConversationIndex(r api.WorkspaceScopedRequest, limit int, filter message.ConversationKindFilter) (api.Response, error) {
	if h.conversationIndex == nil {
		return h.listFromAuthoritativeStore(r, limit, filter)
	}
	cursorToken := strings.TrimSpace(r.Gin.Query("cursor"))
	cursor, err := converter.FromIMConversationCursorToken(cursorToken)
	if err != nil {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, err.Error())
	}
	page, err := h.conversationIndex.ListConversations(r.Context(), r.WorkspaceID(), r.Workspace.MemberID, limit, cursor, filter.Kinds)
	if err != nil {
		return api.Response{}, err
	}
	items := make([]dto.ConversationItem, 0, len(page.Items))
	for _, item := range page.Items {
		dtoItem, ok := h.conversationItemFromIndex(r, item)
		if !ok {
			continue
		}
		items = append(items, dtoItem)
	}
	h.overlayDTOUnread(r.WorkspaceID(), r.Workspace.MemberID, items)
	return api.OK(dto.ConversationPage{Items: items, HasMore: page.HasMore, NextCursor: converter.ToIMConversationCursorToken(page.NextCursor)}), nil
}

func (h *ConversationsHandler) overlayStoreUnread(workspaceID string, memberID string, items []message.ConversationItem) {
	if len(items) == 0 {
		return
	}
	scopeIDs := make([]string, 0, len(items))
	seen := make(map[string]struct{}, len(items))
	for index := range items {
		items[index].UnreadCount = 0
		items[index].ManualUnread = false
		scopeID := strings.TrimSpace(items[index].ScopeID)
		if scopeID == "" {
			continue
		}
		if _, ok := seen[scopeID]; ok {
			continue
		}
		seen[scopeID] = struct{}{}
		scopeIDs = append(scopeIDs, scopeID)
	}
	if h == nil || h.unreadRuntime == nil || len(scopeIDs) == 0 {
		return
	}
	states := h.unreadRuntime.ScopeStates(workspaceID, memberID, scopeIDs)
	for index := range items {
		state, ok := states[strings.TrimSpace(items[index].ScopeID)]
		if !ok {
			continue
		}
		items[index].UnreadCount = conversationRuntimeUnreadCount(items[index].Kind, state)
		items[index].ManualUnread = state.ManualUnread
	}
}

func (h *ConversationsHandler) overlayDTOUnread(workspaceID string, memberID string, items []dto.ConversationItem) {
	if len(items) == 0 {
		return
	}
	scopeIDs := make([]string, 0, len(items))
	seen := make(map[string]struct{}, len(items))
	for index := range items {
		items[index].UnreadCount = 0
		items[index].ManualUnread = false
		scopeID := strings.TrimSpace(items[index].ScopeID)
		if scopeID == "" {
			continue
		}
		if _, ok := seen[scopeID]; ok {
			continue
		}
		seen[scopeID] = struct{}{}
		scopeIDs = append(scopeIDs, scopeID)
	}
	if h == nil || h.unreadRuntime == nil || len(scopeIDs) == 0 {
		return
	}
	states := h.unreadRuntime.ScopeStates(workspaceID, memberID, scopeIDs)
	for index := range items {
		state, ok := states[strings.TrimSpace(items[index].ScopeID)]
		if !ok {
			continue
		}
		items[index].UnreadCount = conversationRuntimeUnreadCount(items[index].Kind, state)
		items[index].ManualUnread = state.ManualUnread
	}
}

func conversationRuntimeUnreadCount(_ string, state unread.ScopeUnreadState) int64 {
	return state.Count()
}

func conversationUnreadScopeType(kind string) unread.ScopeType {
	switch strings.TrimSpace(kind) {
	case message.ConversationKindChannel:
		return unread.ScopeTypeChannel
	case message.ConversationKindDM:
		return unread.ScopeTypeDM
	case message.ConversationKindThread:
		return unread.ScopeTypeThread
	case message.ConversationKindTask:
		return unread.ScopeTypeTask
	case message.ConversationKindServiceAccount:
		return unread.ScopeTypeServiceAccount
	default:
		return ""
	}
}

func (h *ConversationsHandler) applyActionUnread(workspaceID string, userID string, memberID string, action string, item message.ConversationItem) {
	if h == nil || h.unreadRuntime == nil {
		return
	}
	scopeID := strings.TrimSpace(item.ScopeID)
	if scopeID == "" {
		return
	}
	isTask := strings.TrimSpace(item.Kind) == message.ConversationKindTask
	switch strings.TrimSpace(action) {
	case message.ConversationActionMarkRead:
		scope := message.ConversationReadScope{
			ScopeID:                     scopeID,
			Kind:                        item.Kind,
			LastReadSeq:                 item.LastReadSeq,
			LastActivityReadSeq:         item.LastActivityReadSeq,
			ManualUnreadGeneration:      item.ManualUnreadGeneration,
			ManualUnreadGenerationKnown: item.ManualUnreadGenerationKnown,
		}
		if isTask {
			h.applyReadScopeUnreadState(workspaceID, memberID, scope)
			return
		}
		h.applyReadScopeUnread(workspaceID, userID, memberID, scope)
	case message.ConversationActionMarkUnread:
		_ = h.unreadRuntime.ApplyManualUnread(unread.ManualUnreadChanged{
			WorkspaceID:            workspaceID,
			MemberID:               memberID,
			ScopeID:                scopeID,
			ManualUnread:           true,
			ManualUnreadGeneration: item.ManualUnreadGeneration,
		})
		if isTask {
			return
		}
		h.emitCurrentUnreadState(workspaceID, userID, memberID, scopeID, item.Kind)
	case message.ConversationActionMute, message.ConversationActionUnmute:
		// Task Watch/Unwatch controls future event routing only. Existing Task
		// unread was frozen into the event's recipient snapshot and must survive
		// a later subscription change; only an explicit read action clears it.
		if isTask {
			return
		}
		_ = h.unreadRuntime.ApplyScopeInvalidated(unread.ScopeInvalidated{
			WorkspaceID: workspaceID,
			ScopeID:     scopeID,
			MemberIDs:   []string{memberID},
			Reason:      "conversation_action",
		})
		h.emitSuppressedUnreadState(workspaceID, userID, memberID, scopeID, item.Kind, unread.ServingStateStale)
	case message.ConversationActionHide:
		_ = h.unreadRuntime.ApplyScopeInvalidated(unread.ScopeInvalidated{
			WorkspaceID:      workspaceID,
			ScopeID:          scopeID,
			MemberIDs:        []string{memberID},
			Reason:           "conversation_action",
			Frontier:         item.HiddenThroughSeq,
			ActivityFrontier: item.HiddenThroughActivitySeq,
			FrontiersKnown:   isTask,
		})
		if isTask {
			return
		}
		h.emitSuppressedUnreadState(workspaceID, userID, memberID, scopeID, item.Kind, unread.ServingStateStale)
	}
}

func (h *ConversationsHandler) applyBulkReadUnread(workspaceID string, userID string, memberID string, readScopes []message.ConversationReadScope) []message.ConversationReadScope {
	if h == nil || h.unreadRuntime == nil {
		return nil
	}
	taskReadScopes := make([]message.ConversationReadScope, 0)
	for _, scope := range readScopes {
		if strings.TrimSpace(scope.Kind) == message.ConversationKindTask {
			h.applyReadScopeUnreadState(workspaceID, memberID, scope)
			taskReadScopes = append(taskReadScopes, scope)
			continue
		}
		h.applyReadScopeUnread(workspaceID, userID, memberID, scope)
	}
	return taskReadScopes
}

func (h *ConversationsHandler) workspaceUnreadConversationCount(workspaceID string, memberID string) int64 {
	if h == nil || h.unreadRuntime == nil {
		return 0
	}
	return h.unreadRuntime.WorkspaceUnreadConversationCount(workspaceID, memberID)
}

func (h *ConversationsHandler) applyReadScopeUnread(workspaceID string, userID string, memberID string, scope message.ConversationReadScope) {
	scopeID := strings.TrimSpace(scope.ScopeID)
	if scopeID == "" {
		return
	}
	h.applyReadScopeUnreadState(workspaceID, memberID, scope)
	h.emitCurrentUnreadState(workspaceID, userID, memberID, scopeID, scope.Kind)
}

func (h *ConversationsHandler) applyReadScopeUnreadState(workspaceID string, memberID string, scope message.ConversationReadScope) {
	scopeID := strings.TrimSpace(scope.ScopeID)
	if scopeID == "" {
		return
	}
	_ = h.unreadRuntime.ApplyReadCursor(unread.ReadCursorAdvanced{
		WorkspaceID:                 workspaceID,
		MemberID:                    memberID,
		ScopeID:                     scopeID,
		LastReadSeq:                 scope.LastReadSeq,
		ManualUnreadGeneration:      scope.ManualUnreadGeneration,
		ManualUnreadGenerationKnown: scope.ManualUnreadGenerationKnown,
	})
	_ = h.unreadRuntime.ApplyActivityReadCursor(unread.ActivityReadCursorAdvanced{
		WorkspaceID: workspaceID,
		MemberID:    memberID,
		ScopeID:     scopeID,
		LastReadSeq: scope.LastActivityReadSeq,
	})
}

func (h *ConversationsHandler) emitCurrentUnreadState(workspaceID string, userID string, memberID string, scopeID string, kind string) {
	if h == nil || h.unreadSink == nil || h.unreadRuntime == nil {
		return
	}
	fallbackScopeType := conversationUnreadScopeType(kind)
	if fallbackScopeType == "" {
		return
	}
	if reader, ok := h.unreadRuntime.(interface {
		CurrentUnreadDelta(workspaceID, userID, memberID, scopeID string, fallbackScopeType unread.ScopeType) (unread.UnreadDelta, bool)
	}); ok {
		if delta, found := reader.CurrentUnreadDelta(
			workspaceID,
			userID,
			memberID,
			scopeID,
			fallbackScopeType,
		); found {
			h.unreadSink.EmitUnreadUpdated(workspaceID, delta)
		}
		return
	}
	states := h.unreadRuntime.ScopeStates(workspaceID, memberID, []string{scopeID})
	state, ok := states[strings.TrimSpace(scopeID)]
	if !ok {
		return
	}
	scopeType := state.ScopeType
	if scopeType == "" {
		scopeType = fallbackScopeType
	}
	count := conversationRuntimeUnreadCount(kind, state)
	h.unreadSink.EmitUnreadUpdated(workspaceID, unread.UnreadDelta{
		WorkspaceID:                      workspaceID,
		UserID:                           userID,
		MemberID:                         memberID,
		ScopeID:                          scopeID,
		ScopeType:                        scopeType,
		Count:                            count,
		ManualUnread:                     state.ManualUnread,
		HasUnread:                        count > 0 || state.ManualUnread,
		WorkspaceHasUnread:               h.unreadRuntime.WorkspaceHasUnread(workspaceID, memberID),
		WorkspaceUnreadConversationCount: h.unreadRuntime.WorkspaceUnreadConversationCount(workspaceID, memberID),
		ServingState:                     state.ServingState,
		StateVersion:                     state.StateVersion,
	})
}

func (h *ConversationsHandler) emitSuppressedUnreadState(workspaceID string, userID string, memberID string, scopeID string, kind string, state unread.ServingState) {
	if h == nil || h.unreadSink == nil {
		return
	}
	scopeType := conversationUnreadScopeType(kind)
	if scopeType == "" {
		return
	}
	workspaceHasUnread := false
	workspaceUnreadConversationCount := int64(0)
	if h.unreadRuntime != nil {
		workspaceHasUnread = h.unreadRuntime.WorkspaceHasUnread(workspaceID, memberID)
		workspaceUnreadConversationCount = h.unreadRuntime.WorkspaceUnreadConversationCount(workspaceID, memberID)
	}
	h.unreadSink.EmitUnreadUpdated(workspaceID, unread.UnreadDelta{
		WorkspaceID:                      workspaceID,
		UserID:                           userID,
		MemberID:                         memberID,
		ScopeID:                          scopeID,
		ScopeType:                        scopeType,
		Count:                            0,
		ManualUnread:                     false,
		HasUnread:                        false,
		WorkspaceHasUnread:               workspaceHasUnread,
		WorkspaceUnreadConversationCount: workspaceUnreadConversationCount,
		ServingState:                     state,
	})
}

func routeConversationID(r api.WorkspaceScopedRequest) string {
	conversationID := strings.TrimSpace(r.Gin.Param("conversationId"))
	if conversationID == "" {
		return ""
	}
	return conversationID
}

func useIMConversationIndex(r api.WorkspaceScopedRequest) bool {
	source := strings.TrimSpace(r.Gin.Query("source"))
	if source == "" {
		return false
	}
	return strings.EqualFold(source, "index")
}

func normalizedConversationLookupIDs(values []string) ([]string, bool) {
	normalized := make([]string, 0, len(values))
	seen := make(map[string]struct{}, len(values))
	for _, value := range values {
		value = strings.TrimSpace(value)
		if value == "" || len(value) > 255 {
			return nil, false
		}
		if _, exists := seen[value]; exists {
			continue
		}
		seen[value] = struct{}{}
		normalized = append(normalized, value)
	}
	return normalized, true
}

func (h *ConversationsHandler) conversationItemFromIndex(r api.WorkspaceScopedRequest, item storeim.ConversationIndexItem) (dto.ConversationItem, bool) {
	// Task event projection owns stable Inbox facts (preview, sort position,
	// row visibility), but mutable Task metadata must come from the Task source
	// on every read. A reassignment or title/status edit deliberately notifies
	// only its frozen attention audience; using the old projection directly
	// would leave existing non-recipient rows stale indefinitely.
	if strings.TrimSpace(item.Kind) == message.ConversationKindTask && h.conversations != nil {
		storeItem, err := h.conversations.LoadConversationForUser(
			r.Context(),
			r.UserID(),
			r.WorkspaceID(),
			item.ScopeID,
			item.ArchivedAt != nil,
		)
		if err != nil {
			return dto.ConversationItem{}, false
		}
		return converter.ToConversationItem(storeItem), true
	}
	if dtoItem, ok := conversationDTOFromIndexProjection(item); ok {
		return dtoItem, true
	}
	if h.conversations == nil {
		return dto.ConversationItem{}, false
	}
	storeItem, err := h.conversations.LoadConversationForUser(r.Context(), r.UserID(), r.WorkspaceID(), item.ScopeID, false)
	if err != nil {
		return dto.ConversationItem{}, false
	}
	dtoItem := converter.ToConversationItem(storeItem)
	if h.conversationIndex != nil {
		if projection := conversationIndexFromConversation(dtoItem); projection != nil {
			_ = h.conversationIndex.UpsertConversation(r.Context(), r.WorkspaceID(), r.Workspace.MemberID, *projection)
		}
	}
	return dtoItem, true
}

func conversationDTOFromIndexProjection(item storeim.ConversationIndexItem) (dto.ConversationItem, bool) {
	projection := make(map[string]any, len(item.Projection)+8)
	for key, value := range item.Projection {
		projection[key] = value
	}
	delete(projection, "unreadCount")
	delete(projection, "hasUnread")
	delete(projection, "manualUnread")
	delete(projection, "pinnedAt")
	projection["id"] = strings.TrimSpace(item.ConversationID)
	projection["scopeId"] = strings.TrimSpace(item.ScopeID)
	if kind := frontendConversationKindFromIndex(item, projection); kind != "" {
		projection["kind"] = kind
	}
	if strings.TrimSpace(item.Kind) == message.ConversationKindTask {
		subscriptionState := strings.TrimSpace(item.SubscriptionState)
		if subscriptionState != "active" {
			subscriptionState = "mute"
		}
		projection["subscriptionState"] = subscriptionState
	}
	if item.PinnedAt != nil {
		projection["pinnedAt"] = item.PinnedAt.UTC().Format(time.RFC3339Nano)
	}
	projection["archived"] = item.ArchivedAt != nil
	if _, ok := projection["lastActivityAt"]; !ok && !item.SortAt.IsZero() {
		projection["lastActivityAt"] = item.SortAt.UTC().Format(time.RFC3339Nano)
	}
	if _, ok := projection["lastMessageAt"]; !ok && !item.SortAt.IsZero() && strings.TrimSpace(item.Kind) != message.ConversationKindTask {
		projection["lastMessageAt"] = item.SortAt.UTC().Format(time.RFC3339Nano)
	}
	if route := converter.InboxConversationRoute(conversationRouteFactsFromIndexProjection(item, projection)); route != "" {
		projection["route"] = route
	}
	encoded, err := json.Marshal(projection)
	if err != nil {
		return dto.ConversationItem{}, false
	}
	var dtoItem dto.ConversationItem
	if err := json.Unmarshal(encoded, &dtoItem); err != nil {
		return dto.ConversationItem{}, false
	}
	if strings.TrimSpace(dtoItem.ID) == "" ||
		strings.TrimSpace(dtoItem.ScopeID) == "" ||
		strings.TrimSpace(dtoItem.Kind) == "" ||
		strings.TrimSpace(dtoItem.Route) == "" ||
		strings.TrimSpace(dtoItem.Title) == "" {
		return dtoItem, false
	}
	return dtoItem, true
}

func frontendConversationKindFromIndex(item storeim.ConversationIndexItem, projection map[string]any) string {
	indexKind := strings.TrimSpace(item.Kind)
	if indexKind == message.ConversationKindTask {
		return "task"
	}
	if kind, _ := projection["kind"].(string); kind != "" {
		projectionKind := strings.TrimSpace(kind)
		if projectionKind != "task" {
			switch projectionKind {
			case "channel", "replyThread", "humanDm", "agentDm", "serviceDm", "serviceAccount":
				return projectionKind
			case "thread":
				return "replyThread"
			}
		}
	}
	switch indexKind {
	case "channel":
		return "channel"
	case "thread":
		return "replyThread"
	case message.ConversationKindServiceAccount, "serviceAccount":
		return "serviceAccount"
	case "dm":
		switch strings.TrimSpace(stringFromProjection(projection, "peerType")) {
		case "human", "user":
			return "humanDm"
		case "agent":
			return "agentDm"
		case "service":
			return "serviceDm"
		}
	}
	return ""
}

func conversationRouteFactsFromIndexProjection(item storeim.ConversationIndexItem, projection map[string]any) message.ConversationItem {
	facts := message.ConversationItem{
		Kind:            strings.TrimSpace(item.Kind),
		ScopeID:         strings.TrimSpace(item.ScopeID),
		ParentScopeType: stringFromProjection(projection, "parentScopeType"),
		Subsystem:       stringFromProjection(projection, "subsystem"),
		TeamID:          stringFromProjection(projection, "teamId"),
		Subtitle:        stringFromProjection(projection, "subtitle"),
		PeerType:        stringFromProjection(projection, "peerType"),
		PeerID:          stringFromProjection(projection, "peerId"),
	}
	if facts.Kind == message.ConversationKindTask {
		facts.Task = conversationTaskMetadataFromIndexProjection(projection)
	}
	return facts
}

func conversationTaskMetadataFromIndexProjection(projection map[string]any) *message.ConversationTaskMetadata {
	raw, ok := projection["task"]
	if !ok || raw == nil {
		return nil
	}
	encoded, err := json.Marshal(raw)
	if err != nil {
		return nil
	}
	var task dto.ConversationTaskMetadata
	if err := json.Unmarshal(encoded, &task); err != nil {
		return nil
	}
	var assigneeProfile *message.AuthorProfile
	if task.AssigneeProfile != nil {
		assigneeProfile = &message.AuthorProfile{
			ActorType:      task.AssigneeProfile.ActorType,
			PrincipalID:    task.AssigneeProfile.PrincipalID,
			MemberID:       task.AssigneeProfile.MemberID,
			TargetID:       task.AssigneeProfile.TargetID,
			DisplayName:    task.AssigneeProfile.DisplayName,
			AvatarURL:      task.AssigneeProfile.AvatarURL,
			ServiceKind:    task.AssigneeProfile.ServiceKind,
			ServiceSlug:    task.AssigneeProfile.ServiceSlug,
			ServiceBuiltin: task.AssigneeProfile.ServiceBuiltin,
			Badge:          task.AssigneeProfile.Badge,
		}
	}
	return &message.ConversationTaskMetadata{
		ID:                          strings.TrimSpace(task.ID),
		ScopeID:                     strings.TrimSpace(task.ScopeID),
		ChannelID:                   strings.TrimSpace(task.ChannelID),
		Number:                      task.Number,
		Title:                       strings.TrimSpace(task.Title),
		Status:                      strings.TrimSpace(task.Status),
		ParentTaskRef:               strings.TrimSpace(task.ParentTaskRef),
		SourceThreadID:              strings.TrimSpace(task.SourceThreadID),
		SourceThreadParentChannelID: strings.TrimSpace(task.SourceThreadParentChannelID),
		SourceThreadParentMessageID: strings.TrimSpace(task.SourceThreadParentMessageID),
		SourceThreadRef:             strings.TrimSpace(task.SourceThreadRef),
		AssigneeProfile:             assigneeProfile,
		Route:                       strings.TrimSpace(task.Route),
	}
}

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

func parseConversationKindFilterFromQuery(kind string, kinds string) (message.ConversationKindFilter, error) {
	values := make([]string, 0)
	if strings.TrimSpace(kind) != "" {
		values = append(values, kind)
	}
	if strings.TrimSpace(kinds) != "" {
		values = append(values, strings.Split(kinds, ",")...)
	}
	return parseConversationKindFilter(values)
}

func parseConversationKindFilter(values []string) (message.ConversationKindFilter, error) {
	normalized, err := message.NormalizeConversationKindFilter(message.ConversationKindFilter{Kinds: values})
	if err != nil {
		return message.ConversationKindFilter{}, api.NewHTTPError(http.StatusBadRequest, err.Error())
	}
	return normalized, nil
}
