package message

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

	"github.com/stretchr/testify/require"

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

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

	ctx := context.Background()
	db := startMessagingTestPool(t, ctx)
	now := time.Date(2026, 7, 10, 9, 0, 0, 0, time.UTC)
	const (
		workspaceID           = "wsp_bot_dm_projection"
		userID                = "usr_bot_dm_projection"
		humanMemberID         = "mem_bot_dm_human"
		authorMemberID        = "mem_bot_dm_service"
		serviceDefinitionID   = "svcdef_bot_dm_projection"
		serviceInstallationID = "svcinst_bot_dm_projection"
		serviceMemberRowID    = "svcmem_bot_dm_projection"
	)

	insertMessagingWorkspace(t, ctx, db, workspaceID, now)
	insertMessagingUser(t, ctx, db, userID, "app-dm-human@example.com", now)
	insertMessagingHumanMember(t, ctx, db, humanMemberID, workspaceID, userID, now)
	insertMessagingServiceMember(t, ctx, db, authorMemberID, workspaceID, "Approval member", now)
	insertMessagingAppServiceActor(t, ctx, db, workspaceID, humanMemberID, serviceDefinitionID, serviceInstallationID, serviceMemberRowID, authorMemberID, "approval-app", now)

	first, err := EnsureDMThread(ctx, db, MemberActor{WorkspaceID: workspaceID, MemberID: authorMemberID}, humanMemberID)
	require.NoError(t, err)
	second, err := EnsureDMThread(ctx, db, MemberActor{WorkspaceID: workspaceID, MemberID: authorMemberID}, humanMemberID)
	require.NoError(t, err)
	require.Equal(t, first.DMThreadID, second.DMThreadID)

	var membershipCount, settingCount int
	require.NoError(t, db.QueryRow(ctx, `
SELECT COUNT(*)
FROM scope_memberships
WHERE scope_id = $1
  AND member_id IN ($2, $3)
  AND removed_at IS NULL
  AND archived_at IS NULL
  AND effective_to_seq IS NULL
`, first.DMThreadID, humanMemberID, authorMemberID).Scan(&membershipCount))
	require.Equal(t, 2, membershipCount)
	require.NoError(t, db.QueryRow(ctx, `
SELECT COUNT(*)
FROM scope_notification_settings
WHERE scope_id = $1
  AND member_id IN ($2, $3)
  AND state = 'active'
`, first.DMThreadID, humanMemberID, authorMemberID).Scan(&settingCount))
	require.Equal(t, 2, settingCount)

	loaded, err := LoadDMChannelByID(ctx, db, workspaceID, humanMemberID, first.DMThreadID)
	require.NoError(t, err)
	require.Equal(t, "service", loaded.PeerType)
	require.Equal(t, serviceInstallationID, loaded.PeerID)
	require.Equal(t, "approval-app", loaded.PeerName)
	require.Equal(t, "审批助手", loaded.PeerDisplayName)
	require.Equal(t, "https://example.test/approval-app.png", loaded.PeerAvatarURL)

	listed, err := ListDMChannelsForMember(ctx, db, humanMemberID, workspaceID)
	require.NoError(t, err)
	require.Len(t, listed, 1)
	require.Equal(t, loaded, listed[0])

	target, err := ResolveDMThreadByIDForMember(ctx, db, humanMemberID, workspaceID, first.DMThreadID)
	require.NoError(t, err)
	require.Equal(t, "approval-app", target.ChannelName)

	conversationStore := New(db, nil, 30, Options{})
	page, err := conversationStore.ListConversationsForUserFiltered(ctx, userID, workspaceID, 20, ConversationCursor{}, ConversationKindFilter{Kinds: []string{ConversationKindDM}})
	require.NoError(t, err)
	require.Len(t, page.Items, 1)
	require.Equal(t, first.DMThreadID, page.Items[0].ScopeID)
	require.Equal(t, "审批助手", page.Items[0].Title)
	require.Equal(t, "approval-app", page.Items[0].Subtitle)
	require.Equal(t, "https://example.test/approval-app.png", page.Items[0].AvatarURL)
	require.Equal(t, "service", page.Items[0].PeerType)
	require.Equal(t, serviceInstallationID, page.Items[0].PeerID)
}

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

	require.NoError(t, SidecarKindAppNotificationCard.Validate())
	require.Equal(t, SidecarKindAppNotificationCard, normalizeSidecarKind(SidecarKindAppNotificationCard))
	require.Equal(t, ScopeVisibilityRequestHistory, sidecarVisibilityRequestKind(SidecarKindAppNotificationCard))
	require.True(t, sidecarKindHiddenAfterRecall(SidecarKindAppNotificationCard))
}

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

	ctx := context.Background()
	db := startMessagingTestPool(t, ctx)
	now := time.Date(2026, 7, 10, 10, 0, 0, 0, time.UTC)
	const (
		workspaceID           = "wsp_bot_message_mutation"
		userID                = "usr_bot_message_mutation"
		humanMemberID         = "mem_bot_message_human"
		authorMemberID        = "mem_bot_message_service"
		serviceDefinitionID   = "svcdef_bot_message_mutation"
		serviceInstallationID = "svcinst_bot_message_mutation"
		serviceMemberRowID    = "svcmem_bot_message_mutation"
		messageID             = "msg_bot_notification_card"
	)

	insertMessagingWorkspace(t, ctx, db, workspaceID, now)
	insertMessagingUser(t, ctx, db, userID, "app-message-human@example.com", now)
	insertMessagingHumanMember(t, ctx, db, humanMemberID, workspaceID, userID, now)
	insertMessagingServiceMember(t, ctx, db, authorMemberID, workspaceID, "Approval member", now)
	insertMessagingAppServiceActor(t, ctx, db, workspaceID, humanMemberID, serviceDefinitionID, serviceInstallationID, serviceMemberRowID, authorMemberID, "approval-app-mutation", now)
	target, err := EnsureDMThread(ctx, db, MemberActor{WorkspaceID: workspaceID, MemberID: authorMemberID}, humanMemberID)
	require.NoError(t, err)

	repos := NewScopeRepositories(db)
	messageRecord := ScopeMessageRecord{
		ID:                         messageID,
		WorkspaceID:                workspaceID,
		ScopeID:                    target.DMThreadID,
		Seq:                        1,
		WorkspaceGlobalSeq:         1,
		AuthorMemberID:             authorMemberID,
		AuthorType:                 "service",
		NotifyPrincipalMemberID:    authorMemberID,
		IdempotencySubjectMemberID: authorMemberID,
		ClientMessageID:            "app-notification:btn_test",
		IdempotencyPayloadDigest:   strings.Repeat("a", 64),
		MessageType:                MessageTypeAppInteractiveCard,
		Content:                    "待审批",
		Metadata:                   []byte(`{"kind":"approval"}`),
		CreatedAt:                  now,
		UpdatedAt:                  now,
	}
	require.NoError(t, repos.Messages.Insert(ctx, messageRecord))
	require.NoError(t, repos.Search.InsertDoc(ctx, ScopeSearchDocRecord{
		MessageID: messageID, WorkspaceID: workspaceID, ScopeID: target.DMThreadID,
		GlobalSeq: 1, Content: "待审批", CreatedAt: now,
	}))
	fanoutPayload := appNotificationFanoutPayloadForTest(t, messageRecord, "审批助手")
	require.NoError(t, repos.Outbox.Insert(ctx, BuildScopeMessageFanoutOutboxRecord(ScopeMessageFanoutOutboxRecordParams{
		WorkspaceID: workspaceID, ScopeID: target.DMThreadID, MessageID: messageID,
		Payload: fanoutPayload, CreatedAt: now, NextAttemptAt: now,
	})))
	cardRead, err := NewScopeAPIServiceForQueryer(db).AuthorizeSidecarRead(ctx, SidecarReadRequest{
		WorkspaceID: workspaceID, MessageID: messageID, ViewerMemberID: humanMemberID,
		ViewerType: ScopeActorHuman, Kind: SidecarKindAppNotificationCard,
	})
	require.NoError(t, err)
	require.True(t, cardRead.Allowed)

	var humanEditErr error
	require.NoError(t, coredb.WithTx(ctx, db, func(tx coredb.Tx) error {
		_, humanEditErr = UpdateScopeMessageContentAndSearchDoc(ctx, tx, ScopeMessageContentUpdate{
			WorkspaceID: workspaceID, ScopeID: target.DMThreadID, MessageID: messageID,
			Content: "human must not edit", Metadata: []byte(`{}`), EditedByMemberID: humanMemberID, EditedAt: now.Add(time.Minute),
		})
		return nil
	}))
	require.ErrorIs(t, humanEditErr, ErrNotFound)

	var wrongOwnerErr error
	require.NoError(t, coredb.WithTx(ctx, db, func(tx coredb.Tx) error {
		_, wrongOwnerErr = UpdateServiceNotificationMessageTx(ctx, tx, ServiceNotificationMessageUpdate{
			WorkspaceID: workspaceID, ScopeID: target.DMThreadID, MessageID: messageID,
			AuthorMemberID: "mem_other_service", Content: "wrong owner", Metadata: []byte(`{}`), UpdatedAt: now.Add(time.Minute),
		})
		return nil
	}))
	require.ErrorIs(t, wrongOwnerErr, ErrNotFound)

	expectedRevision := int64(0)
	var updated ServiceNotificationMessageMutationResult
	require.NoError(t, coredb.WithTx(ctx, db, func(tx coredb.Tx) error {
		var updateErr error
		updated, updateErr = UpdateServiceNotificationMessageTx(ctx, tx, ServiceNotificationMessageUpdate{
			WorkspaceID: workspaceID, ScopeID: target.DMThreadID, MessageID: messageID,
			AuthorMemberID: authorMemberID, ExpectedEditRevision: &expectedRevision,
			Content: "审批信息已更新", Metadata: []byte(`{"kind":"approval","revision":2}`), UpdatedAt: now.Add(2 * time.Minute),
		})
		return updateErr
	}))
	require.True(t, updated.Changed)
	require.Equal(t, int64(1), updated.Message.EditRevision)
	require.True(t, updated.CreationOutboxRefreshed)
	require.Equal(t, authorMemberID, updated.Message.EditedByMemberID)
	require.Equal(t, "审批信息已更新", updated.Message.Content)
	var searchContent string
	require.NoError(t, db.QueryRow(ctx, `SELECT content FROM scope_message_search_docs WHERE message_id = $1`, messageID).Scan(&searchContent))
	require.Equal(t, "审批信息已更新", searchContent)
	var refreshedPayload []byte
	require.NoError(t, db.QueryRow(ctx, `SELECT payload FROM scope_fanout_outbox WHERE message_id = $1`, messageID).Scan(&refreshedPayload))
	refreshed, err := UnmarshalScopeFanoutPayload(refreshedPayload)
	require.NoError(t, err)
	require.Equal(t, "审批信息已更新", refreshed.AppMessage.Content)
	require.Equal(t, int64(1), refreshed.AppMessage.EditRevision)
	require.Equal(t, "审批信息已更新", refreshed.Delivery.Message.Content)

	expectedRevision = 1
	var withdrawn ServiceNotificationMessageMutationResult
	require.NoError(t, coredb.WithTx(ctx, db, func(tx coredb.Tx) error {
		var withdrawErr error
		withdrawn, withdrawErr = WithdrawServiceNotificationMessageTx(ctx, tx, ServiceNotificationMessageWithdrawal{
			WorkspaceID: workspaceID, ScopeID: target.DMThreadID, MessageID: messageID,
			AuthorMemberID: authorMemberID, ExpectedEditRevision: &expectedRevision,
			PlaceholderContent: "此通知已撤回", Reason: "request cancelled", WithdrawnAt: now.Add(3 * time.Minute),
		})
		return withdrawErr
	}))
	require.True(t, withdrawn.Changed)
	require.Equal(t, int64(2), withdrawn.Message.EditRevision)
	require.Equal(t, "此通知已撤回", withdrawn.Message.Content)
	withdrawnMetadata, err := DecodeMessageMetadata(withdrawn.Message.Metadata)
	require.NoError(t, err)
	require.True(t, MessageMetadataIsTombstone(withdrawnMetadata))
	var searchCount int
	require.NoError(t, db.QueryRow(ctx, `SELECT COUNT(*) FROM scope_message_search_docs WHERE message_id = $1`, messageID).Scan(&searchCount))
	require.Zero(t, searchCount)
	var outboxStatus, suppressionReason string
	require.NoError(t, db.QueryRow(ctx, `SELECT status, suppression_reason FROM scope_fanout_outbox WHERE message_id = $1`, messageID).Scan(&outboxStatus, &suppressionReason))
	require.Equal(t, "suppressed", outboxStatus)
	require.Equal(t, "service_notification_withdrawn", suppressionReason)
	cardRead, err = NewScopeAPIServiceForQueryer(db).AuthorizeSidecarRead(ctx, SidecarReadRequest{
		WorkspaceID: workspaceID, MessageID: messageID, ViewerMemberID: humanMemberID,
		ViewerType: ScopeActorHuman, Kind: SidecarKindAppNotificationCard,
	})
	require.NoError(t, err)
	require.False(t, cardRead.Allowed)
	require.Equal(t, "message_recalled", cardRead.Reason)

	var replay ServiceNotificationMessageMutationResult
	require.NoError(t, coredb.WithTx(ctx, db, func(tx coredb.Tx) error {
		var replayErr error
		replay, replayErr = WithdrawServiceNotificationMessageTx(ctx, tx, ServiceNotificationMessageWithdrawal{
			WorkspaceID: workspaceID, ScopeID: target.DMThreadID, MessageID: messageID,
			AuthorMemberID: authorMemberID, PlaceholderContent: "此通知已撤回", WithdrawnAt: now.Add(4 * time.Minute),
		})
		return replayErr
	}))
	require.False(t, replay.Changed)
	require.Equal(t, int64(2), replay.Message.EditRevision)
}

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

	ctx := context.Background()
	db := startMessagingTestPool(t, ctx)
	fixture := seedScopeStoreFixture(t, ctx, db, "bot_notification_update_lease")
	const authorMemberID = "mem_bot_notification_update_lease"
	insertMessagingServiceMember(t, ctx, db, authorMemberID, fixture.workspaceID, "Lease App", fixture.now)
	record := ScopeMessageRecord{
		ID: "msg_bot_notification_update_lease", WorkspaceID: fixture.workspaceID, ScopeID: fixture.scopeID,
		Seq: 1, WorkspaceGlobalSeq: 10, AuthorMemberID: authorMemberID, AuthorType: "service",
		MessageType: MessageTypeAppInteractiveCard, Content: "old pending content", Metadata: []byte(`{"state":"pending"}`),
		CreatedAt: fixture.now, UpdatedAt: fixture.now,
	}
	repos := NewScopeRepositories(db)
	require.NoError(t, repos.Messages.Insert(ctx, record))
	require.NoError(t, repos.Search.InsertDoc(ctx, ScopeSearchDocRecord{
		MessageID: record.ID, WorkspaceID: record.WorkspaceID, ScopeID: record.ScopeID,
		GlobalSeq: record.WorkspaceGlobalSeq, Content: record.Content, CreatedAt: record.CreatedAt,
	}))
	require.NoError(t, repos.Outbox.Insert(ctx, BuildScopeMessageFanoutOutboxRecord(ScopeMessageFanoutOutboxRecordParams{
		WorkspaceID: record.WorkspaceID, ScopeID: record.ScopeID, MessageID: record.ID,
		Payload: appNotificationFanoutPayloadForTest(t, record, "Lease App"), CreatedAt: fixture.now,
		NextAttemptAt: fixture.now.Add(-time.Minute),
	})))
	leaseNow := fixture.now.Add(time.Hour)
	leased, err := repos.Outbox.LeaseDue(ctx, leaseNow, "worker-app-update", leaseNow.Add(time.Minute), 10)
	require.NoError(t, err)
	require.Len(t, leased, 1)
	staleLeasedPayload, err := UnmarshalScopeFanoutPayload(leased[0].Payload)
	require.NoError(t, err)
	require.Equal(t, "old pending content", staleLeasedPayload.AppMessage.Content)

	expectedRevision := int64(0)
	var updated ServiceNotificationMessageMutationResult
	require.NoError(t, coredb.WithTx(ctx, db, func(tx coredb.Tx) error {
		var updateErr error
		updated, updateErr = UpdateServiceNotificationMessageTx(ctx, tx, ServiceNotificationMessageUpdate{
			WorkspaceID: record.WorkspaceID, ScopeID: record.ScopeID, MessageID: record.ID,
			AuthorMemberID: authorMemberID, ExpectedEditRevision: &expectedRevision,
			Content: "current content after lease", Metadata: []byte(`{"state":"updated"}`), UpdatedAt: leaseNow,
		})
		return updateErr
	}))
	require.True(t, updated.CreationOutboxRefreshed)

	adapter := &scopeFanoutAdapterStub{}
	worker := NewScopeFanoutWorkerWithClock(db, adapter, "worker-app-update", func() time.Time { return leaseNow })
	run, err := worker.processLeased(ctx, leased[0])
	require.NoError(t, err)
	require.Equal(t, ScopeFanoutRunResult{Delivered: 1}, run)
	require.Len(t, adapter.calls, 1)
	deliveredPayload, err := UnmarshalScopeFanoutPayload(adapter.calls[0].Payload)
	require.NoError(t, err)
	require.Equal(t, "current content after lease", deliveredPayload.AppMessage.Content)
	require.Equal(t, int64(1), deliveredPayload.AppMessage.EditRevision)
	require.Equal(t, "current content after lease", deliveredPayload.Delivery.Message.Content)
}

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

	ctx := context.Background()
	db := startMessagingTestPool(t, ctx)
	fixture := seedScopeStoreFixture(t, ctx, db, "bot_notification_withdraw_lease")
	const authorMemberID = "mem_bot_notification_withdraw_lease"
	insertMessagingServiceMember(t, ctx, db, authorMemberID, fixture.workspaceID, "Withdraw App", fixture.now)
	record := ScopeMessageRecord{
		ID: "msg_bot_notification_withdraw_lease", WorkspaceID: fixture.workspaceID, ScopeID: fixture.scopeID,
		Seq: 1, WorkspaceGlobalSeq: 11, AuthorMemberID: authorMemberID, AuthorType: "service",
		MessageType: MessageTypeAppInteractiveCard, Content: "must never replay after withdrawal", Metadata: []byte(`{"state":"pending"}`),
		CreatedAt: fixture.now, UpdatedAt: fixture.now,
	}
	repos := NewScopeRepositories(db)
	require.NoError(t, repos.Messages.Insert(ctx, record))
	require.NoError(t, repos.Search.InsertDoc(ctx, ScopeSearchDocRecord{
		MessageID: record.ID, WorkspaceID: record.WorkspaceID, ScopeID: record.ScopeID,
		GlobalSeq: record.WorkspaceGlobalSeq, Content: record.Content, CreatedAt: record.CreatedAt,
	}))
	require.NoError(t, repos.Outbox.Insert(ctx, BuildScopeMessageFanoutOutboxRecord(ScopeMessageFanoutOutboxRecordParams{
		WorkspaceID: record.WorkspaceID, ScopeID: record.ScopeID, MessageID: record.ID,
		Payload: appNotificationFanoutPayloadForTest(t, record, "Withdraw App"), CreatedAt: fixture.now,
		NextAttemptAt: fixture.now.Add(-time.Minute),
	})))
	leaseNow := fixture.now.Add(time.Hour)
	leased, err := repos.Outbox.LeaseDue(ctx, leaseNow, "worker-app-withdraw", leaseNow.Add(time.Minute), 10)
	require.NoError(t, err)
	require.Len(t, leased, 1)

	expectedRevision := int64(0)
	require.NoError(t, coredb.WithTx(ctx, db, func(tx coredb.Tx) error {
		_, withdrawErr := WithdrawServiceNotificationMessageTx(ctx, tx, ServiceNotificationMessageWithdrawal{
			WorkspaceID: record.WorkspaceID, ScopeID: record.ScopeID, MessageID: record.ID,
			AuthorMemberID: authorMemberID, ExpectedEditRevision: &expectedRevision,
			PlaceholderContent: "此通知已撤回", Reason: "cancelled", WithdrawnAt: leaseNow,
		})
		return withdrawErr
	}))
	adapter := &scopeFanoutAdapterStub{}
	worker := NewScopeFanoutWorkerWithClock(db, adapter, "worker-app-withdraw", func() time.Time { return leaseNow })
	run, err := worker.processLeased(ctx, leased[0])
	require.NoError(t, err)
	require.Equal(t, ScopeFanoutRunResult{Suppressed: 1}, run)
	require.Empty(t, adapter.calls)
	require.Equal(t, int64(1), countScopeRows(t, ctx, db, "scope_fanout_outbox", "message_id = $1 AND status = 'suppressed' AND suppression_reason = 'service_notification_withdrawn'", record.ID))
}

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

	withdrawnAt := time.Date(2026, 7, 10, 9, 30, 0, 0, time.UTC)
	metadata := BuildServiceNotificationWithdrawalMetadata("mem_bot", withdrawnAt, "通知已撤回", "request cancelled")

	require.True(t, MessageMetadataIsRecalled(metadata))
	require.True(t, MessageMetadataIsTombstone(metadata))
	recall, ok := metadata[MessageRecallMetadataKey].(map[string]any)
	require.True(t, ok)
	require.Equal(t, "service_notification_withdrawal", recall["kind"])
	require.Equal(t, "request cancelled", recall["reason"])
	require.Equal(t, "mem_bot", recall["recalledByMemberId"])
}

func insertMessagingAppServiceActor(t *testing.T, ctx context.Context, db coredb.DB, workspaceID string, creatorMemberID string, definitionID string, installationID string, serviceMemberRowID string, memberID string, handle string, now time.Time) {
	t.Helper()
	insertMessagingAutomationServiceActor(t, ctx, db, messagingAutomationServiceActor{
		WorkspaceID:         workspaceID,
		CreatorMemberID:     creatorMemberID,
		DefinitionID:        definitionID,
		InstallationID:      installationID,
		ServiceMemberRowID:  serviceMemberRowID,
		MemberID:            memberID,
		Slug:                handle,
		Handle:              handle,
		DefinitionName:      "Approval",
		MemberDisplayName:   "审批助手",
		DefinitionAvatarURL: "https://example.test/definition.png",
		MemberAvatarURL:     "https://example.test/approval-app.png",
		Now:                 now,
	})
}

func appNotificationFanoutPayloadForTest(t *testing.T, record ScopeMessageRecord, displayName string) []byte {
	t.Helper()
	metadata, err := DecodeMessageMetadata(record.Metadata)
	require.NoError(t, err)
	profile := AuthorProfile{
		ActorType: AuthorActorTypeService, PrincipalID: "svcinst_test", MemberID: record.AuthorMemberID,
		TargetID: "approval-app", DisplayName: displayName, ServiceKind: AuthorServiceKindApp, ServiceSlug: "approval-app",
	}
	appMessage := AppMessage{
		ID: record.ID, Seq: record.Seq, ChannelID: record.ScopeID, ChannelType: "dm",
		AuthorProfile: profile, Content: record.Content, Metadata: metadata, CreatedAt: record.CreatedAt,
		EditedAt: record.EditedAt, EditedByMemberID: record.EditedByMemberID,
		EditRevision: record.EditRevision, MessageType: record.MessageType,
	}
	delivery := MessageDelivery{
		ThreadID: record.ScopeID, Seq: record.Seq, GlobalSeq: record.WorkspaceGlobalSeq, MessageID: record.ID,
		Message: ReceiveMessage{
			MessageID: record.ID, TargetKey: record.ScopeID, TargetSeq: record.Seq, ChannelType: "dm",
			AuthorProfile: profile, MessageType: record.MessageType, Content: record.Content,
			Metadata: metadata, Timestamp: record.CreatedAt,
		},
	}
	payload, err := MarshalScopeFanoutPayload(appMessage, delivery)
	require.NoError(t, err)
	return payload
}
