package converter

import (
	"encoding/json"
	"strings"
	"time"

	"github.com/tidbcloud/AgentTeam/internal/api/dto"
	"github.com/tidbcloud/AgentTeam/internal/core/domain"
	"github.com/tidbcloud/AgentTeam/internal/store/agent"
	"github.com/tidbcloud/AgentTeam/internal/store/message"
)

// ToReceiveMessage converts one store receive message to its API DTO shape.
func ToReceiveMessage(message message.ReceiveMessage) dto.ReceiveMessage {
	legacySenderType, legacySenderName := legacyDaemonSenderFromAuthorProfile(message.AuthorProfile)
	return dto.ReceiveMessage{
		MessageID:                message.MessageID,
		TargetKey:                message.TargetKey,
		TargetSeq:                message.TargetSeq,
		InterruptPolicy:          message.InterruptPolicy,
		ChannelType:              message.ChannelType,
		ChannelName:              message.ChannelName,
		ChannelDisplayName:       message.ChannelDisplayName,
		ParentChannelType:        message.ParentChannelType,
		ParentChannelName:        message.ParentChannelName,
		ParentChannelDisplayName: message.ParentChannelDisplayName,
		SenderType:               legacySenderType,
		SenderName:               legacySenderName,
		AuthorProfile:            ToAuthorProfile(message.AuthorProfile),
		MessageType:              message.MessageType,
		Source:                   ToContributionSource(message.Source),
		Content:                  message.Content,
		Metadata:                 toExternalMessageMetadata(message.Metadata),
		Timestamp:                message.Timestamp,
		Attachments:              ToAppAttachments(message.Attachments),
		TeamContexts:             ToReceiveTeamContexts(message.TeamContexts),
		ActiveTaskTarget: dto.ReceiveActiveTaskTarget{
			Channel:        message.ActiveTaskTarget.Channel,
			TaskNumber:     message.ActiveTaskTarget.TaskNumber,
			Source:         message.ActiveTaskTarget.Source,
			ThreadTarget:   message.ActiveTaskTarget.ThreadTarget,
			Role:           message.ActiveTaskTarget.Role,
			RootTaskNumber: message.ActiveTaskTarget.RootTaskNumber,
		},
	}
}

// ToOldDaemonReceiveMessage preserves the legacy daemon receive-message wire
// shape while normalizing the canonical contribution source at the DTO owner.
func ToOldDaemonReceiveMessage(receiveMessage message.ReceiveMessage) dto.ReceiveMessage {
	receiveMessage.Source = message.ResolveContributionSource(receiveMessage.MessageID, receiveMessage.Source).Source.Normalize()
	return ToReceiveMessage(receiveMessage)
}

func ToReceiveTeamContexts(contexts []message.ReceiveTeamContext) []dto.ReceiveTeamContext {
	if len(contexts) == 0 {
		return nil
	}
	items := make([]dto.ReceiveTeamContext, 0, len(contexts))
	for _, context := range contexts {
		items = append(items, dto.ReceiveTeamContext{
			TeamID:            context.TeamID,
			TeamHandle:        context.TeamHandle,
			TeamDisplayName:   context.TeamDisplayName,
			TeamDescription:   context.TeamDescription,
			TeamMemberID:      context.TeamMemberID,
			Role:              context.Role,
			RoleLabel:         context.RoleLabel,
			RoleOverlayPrompt: context.RoleOverlayPrompt,
			MemberRoster:      ToReceiveTeamMemberContexts(context.MemberRoster),
			Workflow:          context.Workflow,
		})
	}
	return items
}

func ToReceiveTeamMemberContexts(members []message.ReceiveTeamMemberContext) []dto.ReceiveTeamMemberContext {
	if len(members) == 0 {
		return nil
	}
	items := make([]dto.ReceiveTeamMemberContext, 0, len(members))
	for _, member := range members {
		items = append(items, dto.ReceiveTeamMemberContext{
			TeamMemberID:      member.TeamMemberID,
			ActorKind:         member.ActorKind,
			ActorID:           member.ActorID,
			DisplayName:       member.DisplayName,
			Handle:            member.Handle,
			Role:              member.Role,
			RoleLabel:         member.RoleLabel,
			RoleOverlayPrompt: member.RoleOverlayPrompt,
			WakeOnTeamMention: member.WakeOnTeamMention,
			DeliveryKind:      member.DeliveryKind,
			Self:              member.Self,
		})
	}
	return items
}

// ToHistoryMessages converts store history messages to API DTO history messages.
// Preserves the original agent presenter behavior: empty input returns an empty (non-nil) slice.
func ToHistoryMessages(messages []message.HistoryMessage) []dto.HistoryMessage {
	if len(messages) == 0 {
		return []dto.HistoryMessage{}
	}
	items := make([]dto.HistoryMessage, 0, len(messages))
	for _, message := range messages {
		legacySenderType, legacySenderName := legacyDaemonSenderFromAuthorProfile(message.AuthorProfile)
		items = append(items, dto.HistoryMessage{
			Seq:           message.Seq,
			TargetKey:     message.TargetKey,
			TargetSeq:     message.TargetSeq,
			ID:            message.ID,
			SenderType:    legacySenderType,
			SenderName:    legacySenderName,
			AuthorProfile: ToAuthorProfile(message.AuthorProfile),
			Source:        ToContributionSource(message.Source),
			Content:       message.Content,
			Metadata:      toExternalMessageMetadata(message.Metadata),
			CreatedAt:     message.CreatedAt,
			Attachments:   ToAppAttachments(message.Attachments),
		})
	}
	return items
}

// ToAppMessage converts a store app message to the web app API DTO shape.
func ToAppMessage(message message.AppMessage) dto.AppMessage {
	authorProfile := ToAuthorProfile(message.EffectiveAuthorProfile())
	return dto.AppMessage{
		ID:                         message.ID,
		Seq:                        message.Seq,
		ChannelID:                  message.ChannelID,
		ChannelType:                message.ChannelType,
		ClientMessageID:            message.ClientMessageID,
		AuthorProfile:              authorProfile,
		Source:                     ToContributionSource(message.Source),
		Content:                    message.Content,
		ResolvedMentions:           ToResolvedMentions(message.ResolvedMentions),
		Metadata:                   toExternalMessageMetadata(message.Metadata),
		Reactions:                  ToMessageReactionAggregates(message.Reactions),
		Attachments:                ToAppAttachments(message.Attachments),
		CreatedAt:                  message.CreatedAt,
		EditedAt:                   message.EditedAt,
		EditedByMemberID:           message.EditedByMemberID,
		EditRevision:               message.EditRevision,
		MessageType:                message.MessageType,
		DeliverySummaryPreview:     ToAppMessageDeliverySummaryPreview(message.DeliverySummaryPreview),
		DeliveryDiagnosticsEnabled: message.DeliveryDiagnosticsEnabled,
		ParentChannelID:            message.ParentChannelID,
		ParentChannelType:          message.ParentChannelType,
		ParentMessageID:            message.ParentMessageID,
		TaskContext:                ToAppMessageTaskContext(message.TaskContext),
		PinState:                   ToMessagePinState(message.PinState),
		AppNotificationCard:        toAppNotificationCardSummary(message.AppNotificationCard),
	}
}

// toAppNotificationCardSummary scrubs the raw interactive-card render payload
// (carried as raw bytes by the store) into the client-safe DTO projection. It
// extracts only the safe display keys (schemaVersion, template, controlled
// presentation, titleKey, textKey, status, fields) and re-encodes the actions array keeping only
// {actionKey, label, semantics} per entry, dropping control fields such as
// inputSchema, type, request, and approval. A nil or blank payload returns a
// nil summary without error. Malformed JSON is tolerated: individual fields
// that fail to decode are treated as empty rather than aborting the whole scrub.
func toAppNotificationCardSummary(raw []byte) *dto.AppNotificationCardSummary {
	if len(raw) == 0 || len(strings.TrimSpace(string(raw))) == 0 {
		return nil
	}
	var decoded map[string]json.RawMessage
	if err := json.Unmarshal(raw, &decoded); err != nil {
		return nil
	}
	summary := &dto.AppNotificationCardSummary{
		SchemaVersion: decodeStringFieldFromRawJSON(decoded, "schemaVersion"),
		Template:      decodeStringFieldFromRawJSON(decoded, "template"),
		TitleKey:      decodeStringFieldFromRawJSON(decoded, "titleKey"),
		TextKey:       decodeStringFieldFromRawJSON(decoded, "textKey"),
		Status:        decodeStringFieldFromRawJSON(decoded, "status"),
	}
	if rawPresentation, ok := decoded["presentation"]; ok {
		if _, err := domain.DecodeMessagePresentation(rawPresentation); err == nil {
			summary.Presentation = append(json.RawMessage(nil), rawPresentation...)
		}
	}
	if rawFields, ok := decoded["fields"]; ok {
		summary.Fields = append(json.RawMessage(nil), rawFields...)
	}
	if rawActions, ok := decoded["actions"]; ok {
		summary.Actions = scrubAppNotificationCardActions(rawActions)
	}
	return summary
}

// decodeStringFieldFromRawJSON returns the string value at key, tolerating a
// missing key, a JSON null, or a non-string value (treated as empty).
func decodeStringFieldFromRawJSON(fields map[string]json.RawMessage, key string) string {
	raw, ok := fields[key]
	if !ok || len(raw) == 0 {
		return ""
	}
	var value string
	if err := json.Unmarshal(raw, &value); err != nil {
		return ""
	}
	return value
}

// scrubAppNotificationCardActions re-encodes the actions array keeping only
// {actionKey, label, semantics} per entry. A null or non-array actions value is
// treated as absent (nil) rather than an error.
func scrubAppNotificationCardActions(raw json.RawMessage) json.RawMessage {
	if len(raw) == 0 || len(strings.TrimSpace(string(raw))) == 0 {
		return nil
	}
	var actions []map[string]json.RawMessage
	if err := json.Unmarshal(raw, &actions); err != nil {
		return nil
	}
	type summaryAction struct {
		ActionKey string `json:"actionKey"`
		Label     string `json:"label,omitempty"`
		Semantics string `json:"semantics,omitempty"`
	}
	scrubbed := make([]summaryAction, 0, len(actions))
	for _, action := range actions {
		scrubbed = append(scrubbed, summaryAction{
			ActionKey: decodeStringFieldFromRawJSON(action, "actionKey"),
			Label:     decodeStringFieldFromRawJSON(action, "label"),
			Semantics: decodeStringFieldFromRawJSON(action, "semantics"),
		})
	}
	encoded, err := json.Marshal(scrubbed)
	if err != nil {
		return nil
	}
	return encoded
}

func ToAppMessageTaskContext(context *message.AppMessageTaskContext) *dto.AppMessageTaskContext {
	if context == nil {
		return nil
	}
	channelID := strings.TrimSpace(context.ChannelID)
	if channelID == "" || context.TaskNumber <= 0 {
		return nil
	}
	return &dto.AppMessageTaskContext{
		ChannelID:  channelID,
		TaskNumber: context.TaskNumber,
	}
}

// legacyDaemonSenderFromAuthorProfile derives the retained old daemon/desktop
// wire fields from canonical AuthorProfile at the DTO boundary. Store/runtime
// models must not carry sender-shaped fields.
func legacyDaemonSenderFromAuthorProfile(profile message.AuthorProfile) (string, string) {
	profile = profile.Normalize()
	actorType := strings.TrimSpace(profile.ActorType)
	switch actorType {
	case message.AuthorActorTypeHuman, message.AuthorActorTypeAgent, message.AuthorActorTypeService:
	default:
		actorType = ""
	}
	return actorType, firstNonEmpty(profile.TargetID, profile.DisplayName, profile.MemberID, profile.PrincipalID)
}

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

func ToAuthorProfile(profile message.AuthorProfile) *dto.AuthorProfile {
	profile = profile.Normalize()
	if !profile.Valid() {
		return nil
	}
	return &dto.AuthorProfile{
		ActorType:      profile.ActorType,
		PrincipalID:    profile.PrincipalID,
		MemberID:       profile.MemberID,
		TargetID:       profile.TargetID,
		DisplayName:    profile.DisplayName,
		AvatarURL:      profile.AvatarURL,
		ServiceKind:    profile.ServiceKind,
		ServiceSlug:    profile.ServiceSlug,
		ServiceBuiltin: profile.ServiceBuiltin,
		Badge:          profile.Badge,
	}
}

// ToAuthorProfiles converts a presentation roster while preserving its
// store-provided stable order. The returned slice is always non-nil so API
// callers receive [] rather than null when no profiles are visible.
func ToAuthorProfiles(profiles []message.AuthorProfile) []dto.AuthorProfile {
	result := make([]dto.AuthorProfile, 0, len(profiles))
	for _, profile := range profiles {
		converted := ToAuthorProfile(profile)
		if converted == nil {
			continue
		}
		result = append(result, *converted)
	}
	return result
}

func ToContributionSource(source message.ContributionSource) *dto.ContributionSource {
	source = message.ResolveContributionSource("", source).Source.Normalize()
	if source.IsZero() {
		return nil
	}
	return &dto.ContributionSource{
		Kind:      source.Kind,
		RefType:   source.RefType,
		RefID:     source.RefID,
		RunID:     source.RunID,
		EventID:   source.EventID,
		EventType: source.EventType,
		Label:     source.Label,
	}
}

func toExternalMessageMetadata(metadata map[string]any) map[string]any {
	if len(metadata) == 0 {
		return nil
	}
	filtered := make(map[string]any, len(metadata))
	for key, value := range metadata {
		if key == message.MentionMetadataKey {
			continue
		}
		filtered[key] = value
	}
	if len(filtered) == 0 {
		return nil
	}
	return filtered
}

func ToResolvedMention(mention message.ResolvedMention) dto.ResolvedMention {
	return dto.ResolvedMention{
		Token:         mention.Token,
		Type:          mention.Type,
		ID:            mention.ID,
		DisplayName:   mention.DisplayName,
		ResolvedToken: mention.ResolvedToken,
	}
}

func ToResolvedMentions(mentions []message.ResolvedMention) []dto.ResolvedMention {
	if len(mentions) == 0 {
		return nil
	}
	items := make([]dto.ResolvedMention, 0, len(mentions))
	for _, mention := range mentions {
		items = append(items, ToResolvedMention(mention))
	}
	return items
}

func ToAppMessages(messages []message.AppMessage) []dto.AppMessage {
	if len(messages) == 0 {
		return []dto.AppMessage{}
	}
	items := make([]dto.AppMessage, 0, len(messages))
	for _, message := range messages {
		items = append(items, ToAppMessage(message))
	}
	return items
}

// ToMessageReaction converts one concrete store reaction row to its realtime DTO shape.
func ToMessageReaction(reaction message.MessageReaction) dto.MessageReaction {
	return dto.MessageReaction{
		ID:            reaction.ID,
		Emoji:         reaction.Emoji,
		AuthorProfile: ToAuthorProfile(reaction.AuthorProfile),
		CreatedAt:     reaction.CreatedAt,
	}
}

// ToAppMessagePage converts a store browser message page to its API DTO shape.
func ToAppMessagePage(page message.AppMessagePage) dto.AppMessagePage {
	replyThreadSummaries := make(map[string]dto.ReplyThreadSummary, len(page.ReplyThreadSummaries))
	for key, summary := range page.ReplyThreadSummaries {
		replyThreadSummaries[key] = ToReplyThreadSummary(summary)
	}
	if len(replyThreadSummaries) == 0 {
		replyThreadSummaries = nil
	}
	return dto.AppMessagePage{
		Messages:                              ToAppMessages(page.Messages),
		ReplyThreadSummaries:                  replyThreadSummaries,
		ParticipationStats:                    ToMessageParticipationStats(page.ParticipationStats),
		HasMore:                               page.HasMore,
		NextCursor:                            page.NextCursor,
		HistoryLimited:                        page.HistoryLimited,
		OwnerContextDismissedThroughGlobalSeq: page.OwnerContextDismissedThroughGlobalSeq,
	}
}

func ToMessageParticipationStats(stats *message.MessageParticipationStats) *dto.MessageParticipationStats {
	if stats == nil {
		return nil
	}
	return &dto.MessageParticipationStats{Total: stats.Total, Agent: stats.Agent, Human: stats.Human}
}

// ToHistoryPage converts a store agent history page to its daemon-compatible DTO shape.
func ToHistoryPage(page message.HistoryPage) dto.HistoryPage {
	return dto.HistoryPage{
		Messages:            ToHistoryMessages(page.Messages),
		HasMore:             page.HasMore,
		HistoryLimited:      page.HistoryLimited,
		HistoryLimitMessage: page.HistoryLimitMessage,
		LastReadSeq:         page.LastReadSeq,
	}
}

// ToMessageSearchResult converts a store message search result to its API DTO shape.
func ToMessageSearchResult(result message.MessageSearchResult) dto.MessageSearchResult {
	return dto.MessageSearchResult{
		Messages:   ToAppMessages(result.Messages),
		TotalCount: result.TotalCount,
		HasMore:    result.HasMore,
		NextCursor: ToMessageSearchCursorToken(result.NextCursor),
	}
}

func ToMessagePinState(state *message.MessagePinState) *dto.MessagePinState {
	if state == nil || !state.IsPinned {
		return nil
	}
	var pinnedAt *time.Time
	if !state.PinnedAt.IsZero() {
		normalized := state.PinnedAt.UTC()
		pinnedAt = &normalized
	}
	return &dto.MessagePinState{
		IsPinned:         state.IsPinned,
		PinID:            strings.TrimSpace(state.PinID),
		PinnedAt:         pinnedAt,
		PinnedByMemberID: strings.TrimSpace(state.PinnedByMemberID),
	}
}

func ToMessagePin(pin message.MessagePin) dto.MessagePin {
	return dto.MessagePin{
		ID:               pin.ID,
		WorkspaceID:      pin.WorkspaceID,
		ScopeID:          pin.ScopeID,
		MessageID:        pin.MessageID,
		PinnedByMemberID: pin.PinnedByMemberID,
		PinnedBy:         ToAuthorProfile(pin.PinnedBy),
		PinnedAt:         pin.PinnedAt,
		Message:          ToAppMessage(pin.Message),
		ViewerState:      dto.MessagePinViewerState{},
		ViewerCapabilities: dto.MessagePinViewerCapabilities{
			CanJump:             pin.ViewerCapabilities.CanJump,
			CanUnpinForEveryone: pin.ViewerCapabilities.CanUnpinForEveryone,
		},
	}
}

func ToMessagePins(pins []message.MessagePin) []dto.MessagePin {
	if len(pins) == 0 {
		return []dto.MessagePin{}
	}
	items := make([]dto.MessagePin, 0, len(pins))
	for _, pin := range pins {
		items = append(items, ToMessagePin(pin))
	}
	return items
}

func ToMessagePinPtr(pin *message.MessagePin) *dto.MessagePin {
	if pin == nil {
		return nil
	}
	dtoPin := ToMessagePin(*pin)
	return &dtoPin
}

func ToMessagePinList(list message.MessagePinList) dto.MessagePinListResponse {
	return dto.MessagePinListResponse{
		ScopeID:                list.ScopeID,
		Pins:                   ToMessagePins(list.Pins),
		LatestPin:              ToMessagePinPtr(list.LatestPin),
		NextCursor:             list.NextCursor,
		PinRevision:            list.PinRevision,
		ViewerPinStateRevision: list.ViewerPinStateRevision,
	}
}

func ToMessagePinMutationResponse(result message.MessagePinMutationResult) dto.MessagePinMutationResponse {
	return dto.MessagePinMutationResponse{
		Changed: result.Changed,
		Pin:     ToMessagePinPtr(result.Pin),
		List:    ToMessagePinList(result.List),
	}
}

func ToMessageTopNotice(notice message.MessageTopNotice) dto.MessageTopNotice {
	return dto.MessageTopNotice{
		ID:            notice.ID,
		WorkspaceID:   notice.WorkspaceID,
		ScopeID:       notice.ScopeID,
		MessageID:     notice.MessageID,
		SetByMemberID: notice.SetByMemberID,
		SetBy:         ToAuthorProfile(notice.SetBy),
		SetAt:         notice.SetAt,
		Message:       ToAppMessage(notice.Message),
		ViewerState: dto.MessageTopNoticeViewerState{
			HiddenFromBannerForMe: notice.ViewerState.HiddenFromBannerForMe,
		},
		ViewerCapabilities: dto.MessageTopNoticeViewerCapabilities{
			CanJump:                notice.ViewerCapabilities.CanJump,
			CanRemoveForEveryone:   notice.ViewerCapabilities.CanRemoveForEveryone,
			CanHideFromBannerForMe: notice.ViewerCapabilities.CanHideFromBannerForMe,
			CanShowInBannerAgain:   notice.ViewerCapabilities.CanShowInBannerAgain,
		},
	}
}

func ToMessageTopNoticePtr(notice *message.MessageTopNotice) *dto.MessageTopNotice {
	if notice == nil {
		return nil
	}
	dtoNotice := ToMessageTopNotice(*notice)
	return &dtoNotice
}

func ToMessageTopNoticeState(state message.MessageTopNoticeState) dto.MessageTopNoticeResponse {
	return dto.MessageTopNoticeResponse{
		ScopeID:                 state.ScopeID,
		TopNotice:               ToMessageTopNoticePtr(state.TopNotice),
		TopNoticeRevision:       state.TopNoticeRevision,
		ViewerTopNoticeRevision: state.ViewerTopNoticeRevision,
	}
}

func ToMessageTopNoticeMutationResponse(result message.MessageTopNoticeMutationResult) dto.MessageTopNoticeMutationResponse {
	return dto.MessageTopNoticeMutationResponse{
		Changed:   result.Changed,
		TopNotice: ToMessageTopNoticePtr(result.TopNotice),
		State:     ToMessageTopNoticeState(result.State),
	}
}

// ToScopeNotificationPanel converts a store notification policy panel to its API DTO shape.
func ToScopeNotificationPanel(panel message.ScopeNotificationPanel) dto.ScopeNotificationPanel {
	agents := make([]dto.ScopeNotificationAgentRow, 0, len(panel.Agents))
	for _, agent := range panel.Agents {
		agents = append(agents, dto.ScopeNotificationAgentRow{
			AgentID:       agent.AgentID,
			AgentMemberID: agent.AgentMemberID,
			Name:          agent.Name,
			DisplayName:   agent.DisplayName,
			Visibility:    agent.Visibility,
			WakeState:     agent.WakeState,
		})
	}
	return dto.ScopeNotificationPanel{
		ScopeID:         panel.ScopeID,
		ScopeType:       panel.ScopeType,
		ReadOnly:        panel.ReadOnly,
		ReadOnlyReason:  panel.ReadOnlyReason,
		Self:            dto.ScopeNotificationSelfRow{State: panel.Self.State},
		Agents:          agents,
		AgentNextCursor: panel.AgentNextCursor,
		AgentHasMore:    panel.AgentHasMore,
	}
}

// ToAppMessageDeliverySummaryPreview converts an optional store delivery summary preview.
func ToReplyThreadSummary(summary message.ReplyThreadSummary) dto.ReplyThreadSummary {
	return dto.ReplyThreadSummary{
		ThreadChannelID:        summary.ThreadChannelID,
		RootMessageID:          summary.RootMessageID,
		ParentMessagePreview:   summary.ParentMessagePreview,
		ParentMessageMetadata:  summary.ParentMessageMetadata,
		ParentMessageCreatedAt: summary.ParentMessageCreatedAt,
		SubscriptionState:      summary.SubscriptionState,
		ReplyCount:             summary.ReplyCount,
		LastReplyAt:            summary.LastReplyAt,
		LastReplyPreview:       summary.LastReplyPreview,
		LastReplyAuthor:        summary.LastReplyAuthor,
		LastReplyAuthorID:      summary.LastReplyAuthorID,
		LastReplyAuthorType:    summary.LastReplyAuthorType,
		ParticipantIDs:         append([]string{}, summary.ParticipantIDs...),
		Participants:           ToReplyThreadParticipants(summary.Participants),
		ParticipantCount:       len(summary.Participants),
		ReplyAuthorIDs:         append([]string{}, summary.ReplyAuthorIDs...),
		ReplyAuthors:           ToReplyThreadParticipants(summary.ReplyAuthors),
	}
}

func ToOpenReplyThreadResponse(summary message.ReplyThreadSummary, conversation *message.ConversationItem) dto.OpenReplyThreadResponse {
	response := dto.OpenReplyThreadResponse{
		ThreadChannelID:  summary.ThreadChannelID,
		ReplyCount:       summary.ReplyCount,
		LastReplyAt:      summary.LastReplyAt,
		ParticipantIDs:   append([]string{}, summary.ParticipantIDs...),
		Participants:     ToReplyThreadParticipants(summary.Participants),
		ParticipantCount: len(summary.Participants),
		ReplyAuthorIDs:   append([]string{}, summary.ReplyAuthorIDs...),
		ReplyAuthors:     ToReplyThreadParticipants(summary.ReplyAuthors),
	}
	if conversation != nil && strings.TrimSpace(conversation.ScopeID) != "" {
		item := ToConversationItem(*conversation)
		response.Conversation = &item
	}
	return response
}

func ToThreadSubscribeResponse(conversation message.ConversationItem) dto.ThreadSubscribeResponse {
	item := ToConversationItem(conversation)
	return dto.ThreadSubscribeResponse{Conversation: &item}
}

func ToReplyThreadParticipant(participant message.ReplyThreadParticipant) dto.ReplyThreadParticipant {
	return dto.ReplyThreadParticipant{
		ID:            participant.ID,
		Type:          participant.Type,
		DisplayName:   participant.DisplayName,
		AvatarURL:     participant.AvatarURL,
		MemberID:      participant.MemberID,
		OwnerMemberID: participant.OwnerMemberID,
		OwnerUserID:   participant.OwnerUserID,
		Visibility:    participant.Visibility,
	}
}

func ToReplyThreadParticipants(participants []message.ReplyThreadParticipant) []dto.ReplyThreadParticipant {
	if len(participants) == 0 {
		return []dto.ReplyThreadParticipant{}
	}
	items := make([]dto.ReplyThreadParticipant, 0, len(participants))
	for _, participant := range participants {
		items = append(items, ToReplyThreadParticipant(participant))
	}
	return items
}

func ToAppMessageDeliverySummaryPreview(preview *message.AppMessageDeliverySummaryPreview) *dto.AppMessageDeliverySummaryPreview {
	if preview == nil {
		return nil
	}
	return &dto.AppMessageDeliverySummaryPreview{
		TargetedAgentCount: preview.TargetedAgentCount,
		ExcludedAgentCount: preview.ExcludedAgentCount,
		ActiveNowCount:     preview.ActiveNowCount,
	}
}

// ToMessageReactionAggregates converts store reaction aggregates to API DTO reaction aggregates.
// Preserves the original presenter behavior: empty input returns an empty (non-nil) slice.
func ToMessageReactionAggregates(reactions []message.MessageReactionAggregate) []dto.MessageReactionAggregate {
	if len(reactions) == 0 {
		return []dto.MessageReactionAggregate{}
	}
	items := make([]dto.MessageReactionAggregate, 0, len(reactions))
	for _, reaction := range reactions {
		items = append(items, ToMessageReactionAggregate(reaction))
	}
	return items
}

// ToMessageReactionAggregate converts one store reaction aggregate to its API DTO shape.
func ToMessageReactionAggregate(reaction message.MessageReactionAggregate) dto.MessageReactionAggregate {
	reactors := make([]dto.MessageReactionReactor, 0, len(reaction.Reactors))
	for _, reactor := range reaction.Reactors {
		reactors = append(reactors, dto.MessageReactionReactor{
			AuthorProfile: ToAuthorProfile(reactor.AuthorProfile),
		})
	}
	return dto.MessageReactionAggregate{Emoji: reaction.Emoji, Count: reaction.Count, Reactors: reactors}
}

// ToDMChannel converts one store direct-message channel row to its API DTO shape.
func ToDMChannel(channel message.DMChannel) dto.DMChannel {
	return dto.DMChannel{
		ID:              channel.ID,
		PeerType:        channel.PeerType,
		PeerID:          channel.PeerID,
		PeerName:        channel.PeerName,
		PeerDisplayName: channel.PeerDisplayName,
		PeerAvatarURL:   channel.PeerAvatarURL,
		LastMessageAt:   channel.LastMessageAt,
		SidebarHidden:   channel.SidebarHidden,
	}
}

func ToDMChannels(channels []message.DMChannel) []dto.DMChannel {
	if len(channels) == 0 {
		return []dto.DMChannel{}
	}
	items := make([]dto.DMChannel, 0, len(channels))
	for _, channel := range channels {
		items = append(items, ToDMChannel(channel))
	}
	return items
}

func ToChannelMemberSummary(member message.ChannelMemberSummary) dto.ChannelMemberSummary {
	return dto.ChannelMemberSummary{
		ID:               member.ID,
		Name:             member.Name,
		DisplayName:      member.DisplayName,
		MemberID:         member.MemberID,
		LegacyTargetKeys: member.LegacyTargetKeys,
		AvatarURL:        member.AvatarURL,
		OwnerProfile:     ToAuthorProfile(member.OwnerProfile),
		OwnerUserID:      member.OwnerUserID,
		Model:            member.Model,
		Visibility:       member.Visibility,
		CanRemove:        member.CanRemove,
	}
}

func ToChannelMemberSummaries(members []message.ChannelMemberSummary) []dto.ChannelMemberSummary {
	if len(members) == 0 {
		return []dto.ChannelMemberSummary{}
	}
	items := make([]dto.ChannelMemberSummary, 0, len(members))
	for _, member := range members {
		items = append(items, ToChannelMemberSummary(member))
	}
	return items
}

func ToChannelPrivateAgentSummary(agent message.ChannelPrivateAgentSummary) dto.ChannelPrivateAgentSummary {
	ownerProfile := ToAuthorProfile(agent.OwnerProfile)
	ownerMemberID, ownerUserID, ownerName, ownerDisplayName := channelPrivateAgentOwnerCompatFromAuthorProfile(ownerProfile, agent)
	return dto.ChannelPrivateAgentSummary{
		ID:               agent.ID,
		Name:             agent.Name,
		DisplayName:      agent.DisplayName,
		MemberID:         agent.MemberID,
		AvatarURL:        agent.AvatarURL,
		Model:            agent.Model,
		Visibility:       agent.Visibility,
		OwnerProfile:     ownerProfile,
		OwnerMemberID:    ownerMemberID,
		OwnerUserID:      ownerUserID,
		OwnerName:        ownerName,
		OwnerDisplayName: ownerDisplayName,
	}
}

func channelPrivateAgentOwnerCompatFromAuthorProfile(profile *dto.AuthorProfile, fallback message.ChannelPrivateAgentSummary) (string, string, string, string) {
	if profile == nil {
		return fallback.OwnerMemberID, fallback.OwnerUserID, fallback.OwnerName, fallback.OwnerDisplayName
	}
	return firstNonEmpty(profile.MemberID, fallback.OwnerMemberID),
		firstNonEmpty(profile.PrincipalID, fallback.OwnerUserID),
		firstNonEmpty(profile.DisplayName, fallback.OwnerName),
		firstNonEmpty(profile.DisplayName, fallback.OwnerDisplayName)
}

func ToChannelPrivateAgentSummaries(agents []message.ChannelPrivateAgentSummary) []dto.ChannelPrivateAgentSummary {
	if len(agents) == 0 {
		return []dto.ChannelPrivateAgentSummary{}
	}
	items := make([]dto.ChannelPrivateAgentSummary, 0, len(agents))
	for _, agent := range agents {
		items = append(items, ToChannelPrivateAgentSummary(agent))
	}
	return items
}

func ToChannelMemberList(list message.ChannelMemberList) dto.ChannelMemberList {
	return dto.ChannelMemberList{
		Agents:        ToChannelMemberSummaries(list.Agents),
		Humans:        ToChannelMemberSummaries(list.Humans),
		Services:      ToChannelMemberSummaries(list.Services),
		PrivateAgents: ToChannelPrivateAgentSummaries(list.PrivateAgents),
		TotalCount:    list.TotalCount,
		HasMore:       list.HasMore,
		NextCursor:    list.NextCursor,
	}
}

func ToSubscribedThread(thread message.SubscribedThread) dto.SubscribedThread {
	return dto.SubscribedThread{
		ThreadChannelID:               thread.ThreadChannelID,
		ParentChannelID:               thread.ParentChannelID,
		ParentChannelType:             thread.ParentChannelType,
		ParentLabel:                   thread.ParentLabel,
		ParentMessageID:               thread.ParentMessageID,
		ParentMessageShortID:          thread.ParentMessageShortID,
		ParentMessagePreview:          thread.ParentMessagePreview,
		ParentMessageMetadata:         toExternalMessageMetadata(thread.ParentMessageMetadata),
		ParentMessageResolvedMentions: ToResolvedMentions(thread.ParentMessageResolvedMentions),
		ParentMessageAuthor:           thread.ParentMessageAuthor,
		Title:                         message.DeriveSubscribedThreadTitle(thread),
		SubscriptionState:             thread.SubscriptionState,
		ParticipantIDs:                append([]string{}, thread.ParticipantIDs...),
		Participants:                  ToReplyThreadParticipants(thread.Participants),
		ParticipantCount:              thread.ParticipantCount,
		ReplyAuthorIDs:                append([]string{}, thread.ReplyAuthorIDs...),
		ReplyAuthors:                  ToReplyThreadParticipants(thread.ReplyAuthors),
		ReplyCount:                    thread.ReplyCount,
		LastReplyAt:                   thread.LastReplyAt,
		LastActivityAt:                thread.LastActivityAt,
	}
}

func ToSubscribedThreads(threads []message.SubscribedThread) []dto.SubscribedThread {
	if len(threads) == 0 {
		return []dto.SubscribedThread{}
	}
	items := make([]dto.SubscribedThread, 0, len(threads))
	for _, thread := range threads {
		items = append(items, ToSubscribedThread(thread))
	}
	return items
}

func ToThreadInboxPage(page message.ThreadInboxPage) dto.ThreadInboxPage {
	return dto.ThreadInboxPage{
		Threads:    ToSubscribedThreads(page.Threads),
		HasMore:    page.HasMore,
		NextCursor: ToThreadInboxCursorToken(page.NextCursor),
	}
}

func ToThreadInboxCount(count message.ThreadInboxCount) dto.ThreadInboxCount {
	return dto.ThreadInboxCount{Count: count.Count}
}

func ToThreadMembership(membership message.ThreadMembership) dto.ThreadMembership {
	return dto.ThreadMembership{
		ThreadChannelID:   membership.ThreadChannelID,
		Subscribed:        membership.Subscribed,
		SubscriptionState: membership.SubscriptionState,
	}
}

func ToReplyThreadSummaryMap(summaries map[string]message.ReplyThreadSummary) map[string]dto.ReplyThreadSummary {
	if len(summaries) == 0 {
		return map[string]dto.ReplyThreadSummary{}
	}
	items := make(map[string]dto.ReplyThreadSummary, len(summaries))
	for key, summary := range summaries {
		items[key] = ToReplyThreadSummary(summary)
	}
	return items
}

func ToChannelShareCardView(card message.ChannelShareCardView) dto.ChannelShareCardView {
	return dto.ChannelShareCardView{
		MessageID:     card.MessageID,
		CardID:        card.CardID,
		CardChannelID: card.CardChannelID,
		ChannelID:     card.ChannelID,
		Name:          card.Name,
		Description:   card.Description,
		Visibility:    card.Visibility,
		Status:        card.Status,
		Joined:        card.Joined,
		CanJoin:       card.CanJoin,
		CanOpen:       card.CanOpen,
		CanRevoke:     card.CanRevoke,
		Revoked:       card.Revoked,
		RevokedAt:     card.RevokedAt,
	}
}

func ToThreadShareCardView(card message.ThreadShareCardView) dto.ThreadShareCardView {
	messages := make([]dto.ThreadShareSnapshotMessage, 0, len(card.Messages))
	for _, snapshotMessage := range card.Messages {
		messages = append(messages, dto.ThreadShareSnapshotMessage{
			ID:         snapshotMessage.ID,
			AuthorName: snapshotMessage.AuthorName,
			Content:    snapshotMessage.Content,
			CreatedAt:  snapshotMessage.CreatedAt,
		})
	}
	return dto.ThreadShareCardView{
		MessageID:                  card.MessageID,
		SourceParentChannelID:      card.SourceParentChannelID,
		SourceParentMessageID:      card.SourceParentMessageID,
		SourceParentMessageShortID: card.SourceParentMessageShortID,
		SourceLabel:                card.SourceLabel,
		Title:                      card.Title,
		Preview:                    card.Preview,
		MessageCount:               card.MessageCount,
		ParticipantCount:           card.ParticipantCount,
		CanOpenSource:              card.CanOpenSource,
		Messages:                   messages,
	}
}

func ToMessageDeliveryDiagnostics(diagnostics message.MessageDeliveryDiagnostics) dto.MessageDeliveryDiagnostics {
	agents := make([]dto.MessageDeliveryDiagnosticsAgent, 0, len(diagnostics.Agents))
	for _, agent := range diagnostics.Agents {
		agents = append(agents, dto.MessageDeliveryDiagnosticsAgent{
			AgentID:                 agent.AgentID,
			AgentName:               agent.AgentName,
			MemberID:                agent.MemberID,
			MachineID:               agent.MachineID,
			MachineName:             agent.MachineName,
			AgentStatus:             agent.AgentStatus,
			AgentActivity:           agent.AgentActivity,
			AgentActivityDetail:     agent.AgentActivityDetail,
			AutoWakeEnabled:         agent.AutoWakeEnabled,
			SubscriptionState:       agent.SubscriptionState,
			MachineOnline:           agent.MachineOnline,
			ScopeStatus:             agent.ScopeStatus,
			ScopeExplanation:        agent.ScopeExplanation,
			DeliveryStatus:          agent.DeliveryStatus,
			DeliveryReason:          agent.DeliveryReason,
			DeliveryExplanation:     agent.DeliveryExplanation,
			ExecutionResult:         agent.ExecutionResult,
			OwnerPrivateState:       agent.OwnerPrivateState,
			OwnerPrivateExplanation: agent.OwnerPrivateExplanation,
			WakeStatus:              agent.WakeStatus,
			WakeExplanation:         agent.WakeExplanation,
		})
	}
	return dto.MessageDeliveryDiagnostics{
		Message: dto.MessageDeliveryDiagnosticsMessage{
			ID:                      diagnostics.Message.ID,
			ChannelID:               diagnostics.Message.ChannelID,
			ChannelType:             diagnostics.Message.ChannelType,
			ParentChannelID:         diagnostics.Message.ParentChannelID,
			ParentChannelType:       diagnostics.Message.ParentChannelType,
			MessageType:             diagnostics.Message.MessageType,
			Content:                 diagnostics.Message.Content,
			CreatedAt:               diagnostics.Message.CreatedAt,
			RoutingMode:             diagnostics.Message.RoutingMode,
			RoutingExplanation:      diagnostics.Message.RoutingExplanation,
			ContextScope:            diagnostics.Message.ContextScope,
			ContextScopeExplanation: diagnostics.Message.ContextScopeExplanation,
		},
		WakeInferenceBasis:       diagnostics.WakeInferenceBasis,
		WakeInferenceExplanation: diagnostics.WakeInferenceExplanation,
		Summary: dto.MessageDeliveryDiagnosticsSummary{
			TargetedAgentCount:      diagnostics.Summary.TargetedAgentCount,
			ExcludedAgentCount:      diagnostics.Summary.ExcludedAgentCount,
			WakeDeliverRunningCount: diagnostics.Summary.WakeDeliverRunningCount,
			WakeStartCount:          diagnostics.Summary.WakeStartCount,
			WakeBlockedCount:        diagnostics.Summary.WakeBlockedCount,
			OwnerPrivateRequired:    diagnostics.Summary.OwnerPrivateRequired,
		},
		Agents: agents,
	}
}

// ToWorkspaceChannels converts agent server channel rows to API DTO channels.
// Preserves the original agent presenter behavior: empty input returns an empty (non-nil) slice.
func ToWorkspaceChannels(channels []agent.WorkspaceChannel) []dto.WorkspaceChannel {
	if len(channels) == 0 {
		return []dto.WorkspaceChannel{}
	}
	items := make([]dto.WorkspaceChannel, 0, len(channels))
	for _, channel := range channels {
		items = append(items, dto.WorkspaceChannel{
			Name:        channel.Name,
			DisplayName: channel.DisplayName,
			Description: channel.Description,
			Joined:      channel.Joined,
		})
	}
	return items
}

// ToWorkspaceActors converts agent server actor rows to API DTO actors.
// Preserves the original agent presenter behavior: empty input returns an empty (non-nil) slice.
func ToWorkspaceActors(actors []agent.WorkspaceActor) []dto.WorkspaceActor {
	if len(actors) == 0 {
		return []dto.WorkspaceActor{}
	}
	items := make([]dto.WorkspaceActor, 0, len(actors))
	for _, actor := range actors {
		items = append(items, dto.WorkspaceActor{
			Name:           actor.Name,
			DisplayName:    actor.DisplayName,
			Status:         actor.Status,
			Activity:       actor.Activity,
			ActivityDetail: actor.ActivityDetail,
			MachineName:    actor.MachineName,
			MachineHost:    actor.MachineHost,
			MachineID:      actor.MachineID,
			OwnerName:      actor.OwnerName,
		})
	}
	return items
}
