package message

import (
	"context"
	"testing"
	"time"

	"github.com/stretchr/testify/require"

	"github.com/tidbcloud/AgentTeam/internal/coredb"
	"github.com/tidbcloud/AgentTeam/internal/coredb/testkit"
	"github.com/tidbcloud/AgentTeam/internal/store/model"
)

// TestMaterializeTaskConversationForMemberIntegration proves the authoritative
// Task Watch Inbox materialization boundary against a real TiDB:
//  1. A member with Task-scope membership + an active Task scope gets an
//     owner-local im_conversation_index row with the authoritative task projection.
//  2. The materializer is idempotent: a second call preserves the row and the
//     authoritative facts (no duplicate, no regressed sort_at/projection).
//  3. Fail-closed: a member without Task-scope membership gets no row, so
//     permission/workspace/deletion guarantees are preserved.
func TestMaterializeTaskConversationForMemberIntegration(t *testing.T) {
	setupCtx, setupCancel := context.WithTimeout(t.Context(), 3*time.Minute)
	defer setupCancel()

	dsn := testkit.StartInitializedTiDBWithRootDSN(t, messagingTiDB.RootDSN(t))
	sqlDB, err := testkit.OpenTiDBContext(setupCtx, dsn)
	require.NoError(t, err)
	t.Cleanup(func() { _ = sqlDB.Close() })
	sqlDB.SetMaxOpenConns(8)
	sqlDB.SetMaxIdleConns(8)
	db := coredb.WrapSQLDB(sqlDB)

	ctx, cancel := context.WithTimeout(t.Context(), 20*time.Second)
	defer cancel()

	fixture := seedTaskScopeLifecycleHierarchy(t, ctx, db, "watch_inbox")
	now := time.Now().UTC().Truncate(time.Second)
	memberID := fixture.memberID
	taskScopeID := fixture.parent.scopeID

	// The hierarchy fixture seeds the channel/task scopes and tasks but not the
	// member's task-scope membership. Ensure it, mirroring the Watch path's
	// EnsureScopeMembership.
	mustExecMessaging(t, ctx, db, `
INSERT INTO scope_memberships (
    scope_id, member_id, visibility, owner_member_id, last_read_seq,
    last_read_global_seq, last_activity_global_seq, owner_context_dismissed_through_global_seq,
    effective_from_seq, effective_to_seq, created_at, updated_at, removed_at, archived_at
)
VALUES ($1, $2, 'public', NULL, 0, 0, 0, 0, 0, NULL, $3, $3, NULL, NULL)
`, taskScopeID, memberID, now)

	require.NoError(t, MaterializeTaskConversationForMemberTx(ctx, db, fixture.workspaceID, memberID, taskScopeID))

	// The owner-local Inbox conversation row exists with authoritative facts.
	requireConversationRow(t, ctx, db, fixture.workspaceID, memberID, taskScopeID, fixture.parent.taskID)

	// Idempotent: a second materialization preserves a single row with the same
	// authoritative projection.
	require.NoError(t, MaterializeTaskConversationForMemberTx(ctx, db, fixture.workspaceID, memberID, taskScopeID))
	requireConversationRow(t, ctx, db, fixture.workspaceID, memberID, taskScopeID, fixture.parent.taskID)
	var rowCount int64
	require.NoError(t, db.QueryRow(ctx, `
SELECT COUNT(*)
FROM im_conversation_index
WHERE workspace_id = $1 AND member_id = $2 AND scope_id = $3 AND kind = 'task'
`, fixture.workspaceID, memberID, taskScopeID).Scan(&rowCount))
	require.Equal(t, int64(1), rowCount, "the materializer must be idempotent")

	// Fail-closed: a member without Task-scope membership gets no row.
	suffix := fixture.workspaceID[len(fixture.workspaceID)-8:]
	nonMemberUserID := "usr_no_member_" + suffix
	nonMemberID := "mem_no_member_" + suffix
	insertMessagingUser(t, ctx, db, nonMemberUserID, "no_member_"+suffix+"@example.com", now)
	insertMessagingHumanMember(t, ctx, db, nonMemberID, fixture.workspaceID, nonMemberUserID, now)
	require.NoError(t, MaterializeTaskConversationForMemberTx(ctx, db, fixture.workspaceID, nonMemberID, taskScopeID))
	require.NoError(t, db.QueryRow(ctx, `
SELECT COUNT(*)
FROM im_conversation_index
WHERE workspace_id = $1 AND member_id = $2 AND scope_id = $3 AND kind = 'task'
`, fixture.workspaceID, nonMemberID, taskScopeID).Scan(&rowCount))
	require.Equal(t, int64(0), rowCount, "a member without Task-scope membership must not get a materialized row (fail-closed)")
}

func requireConversationRow(t *testing.T, ctx context.Context, db coredb.QueryExecer, workspaceID string, memberID string, taskScopeID string, taskID string) {
	t.Helper()
	var conversationID string
	var kind string
	var scopeID string
	var projectionRaw []byte
	require.NoError(t, db.QueryRow(ctx, `
SELECT conversation_id, kind, scope_id, projection_json
FROM im_conversation_index
WHERE workspace_id = $1 AND member_id = $2 AND scope_id = $3 AND kind = 'task'
`, workspaceID, memberID, taskScopeID).Scan(&conversationID, &kind, &scopeID, &projectionRaw))
	require.Equal(t, "task:"+taskScopeID, conversationID)
	require.Equal(t, ConversationKindTask, kind)
	require.Equal(t, taskScopeID, scopeID)
	require.NotEmpty(t, projectionRaw, "the materialized row must carry the authoritative task conversation projection")
	_, err := model.UnmarshalTaskConversationProjection(projectionRaw)
	require.NoError(t, err)
	_ = taskID
}

// TestMaterializePreservesExistingConversationOnRewatch proves the insert-if-
// missing semantic: once a Task conversation row exists (with history preview,
// sort_at, last_event_seq, pinned_at, archived_at, projection), a duplicate Watch
// or unwatch->rewatch at an equal sort_at must preserve the existing row entirely
// — the generic upsert's nil-overlay would otherwise clear pin/hide and replace
// projection_json.
func TestMaterializePreservesExistingConversationOnRewatch(t *testing.T) {
	setupCtx, setupCancel := context.WithTimeout(t.Context(), 3*time.Minute)
	defer setupCancel()

	dsn := testkit.StartInitializedTiDBWithRootDSN(t, messagingTiDB.RootDSN(t))
	sqlDB, err := testkit.OpenTiDBContext(setupCtx, dsn)
	require.NoError(t, err)
	t.Cleanup(func() { _ = sqlDB.Close() })
	sqlDB.SetMaxOpenConns(8)
	sqlDB.SetMaxIdleConns(8)
	db := coredb.WrapSQLDB(sqlDB)

	ctx, cancel := context.WithTimeout(t.Context(), 20*time.Second)
	defer cancel()

	fixture := seedTaskScopeLifecycleHierarchy(t, ctx, db, "rewatch_preserve")
	now := time.Now().UTC().Truncate(time.Second)
	memberID := fixture.memberID
	taskScopeID := fixture.parent.scopeID
	mustExecMessaging(t, ctx, db, `
INSERT INTO scope_memberships (
    scope_id, member_id, visibility, owner_member_id, last_read_seq,
    last_read_global_seq, last_activity_global_seq, owner_context_dismissed_through_global_seq,
    effective_from_seq, effective_to_seq, created_at, updated_at, removed_at, archived_at
)
VALUES ($1, $2, 'public', NULL, 0, 0, 0, 0, 0, NULL, $3, $3, NULL, NULL)
`, taskScopeID, memberID, now)

	// An existing row with history facts (preview, pin, hide) already present.
	require.NoError(t, MaterializeTaskConversationForMemberTx(ctx, db, fixture.workspaceID, memberID, taskScopeID))
	pinnedAt := now.Add(5 * time.Minute)
	archivedAt := now.Add(10 * time.Minute)
	mustExecMessaging(t, ctx, db, `
UPDATE im_conversation_index
SET pinned_at = $4, archived_at = $5,
    last_event_seq = 7,
    projection_json = JSON_OBJECT('preview', 'existing history preview', 'lastAuthorName', 'someone', 'kind', 'task', 'scopeId', $3, 'id', CONCAT('task:', $3))
WHERE workspace_id = $1 AND member_id = $2 AND scope_id = $3 AND kind = 'task'
`, fixture.workspaceID, memberID, taskScopeID, pinnedAt, archivedAt)

	// Duplicate Watch / unwatch->rewatch: equal-sort materialization must not
	// regress the row.
	require.NoError(t, MaterializeTaskConversationForMemberTx(ctx, db, fixture.workspaceID, memberID, taskScopeID))

	var sortAt time.Time
	var lastEventSeq int64
	var storedPinnedAt, storedArchivedAt *time.Time
	var projectionRaw []byte
	require.NoError(t, db.QueryRow(ctx, `
SELECT sort_at, last_event_seq, pinned_at, archived_at, projection_json
FROM im_conversation_index
WHERE workspace_id = $1 AND member_id = $2 AND scope_id = $3 AND kind = 'task'
`, fixture.workspaceID, memberID, taskScopeID).Scan(&sortAt, &lastEventSeq, &storedPinnedAt, &storedArchivedAt, &projectionRaw))
	require.Equal(t, int64(7), lastEventSeq, "existing last_event_seq must be preserved on rewatch")
	require.NotNil(t, storedPinnedAt, "existing pin must be preserved on rewatch")
	require.True(t, storedPinnedAt.Equal(pinnedAt), "existing pin value must be preserved")
	require.NotNil(t, storedArchivedAt, "existing hide must be preserved on rewatch")
	require.True(t, storedArchivedAt.Equal(archivedAt), "existing hide value must be preserved")
	require.Contains(t, string(projectionRaw), "existing history preview", "existing projection_json must be preserved on rewatch")
	require.Contains(t, string(projectionRaw), "someone", "existing lastAuthor must be preserved on rewatch")
}

func TestTaskConversationMaterializeFactsProjectionShape(t *testing.T) {
	// The authoritative projection shape must match the event-time Task conversation
	// projection so realtime/Web/miniapp consumers stay coherent.
	facts := taskConversationMaterializeFacts{
		TaskID:             "tsk_1",
		TaskScopeID:        "task_scope_1",
		ChannelID:          "chn_1",
		TaskNumber:         42,
		Title:              "Watch projection",
		Status:             "todo",
		SortAt:             time.Date(2026, 8, 16, 12, 0, 0, 0, time.UTC),
		AssigneeMemberType: AuthorActorTypeHuman,
		AssigneeMemberID:   "mem_assignee",
		AssigneeUserID:     "usr_assignee",
		AssigneeUserName:   "assignee",
	}
	item := taskConversationIndexItemFromFacts(facts)
	require.Equal(t, "task:task_scope_1", item.ConversationID)
	require.Equal(t, ConversationKindTask, item.Kind)
	require.Equal(t, "task_scope_1", item.ScopeID)
	require.Equal(t, facts.SortAt, item.SortAt)
	require.Equal(t, "#t42 Watch projection", item.Projection["title"])
	require.Equal(t, "/channels/chn_1/tasks/42", item.Projection["route"])
	require.Equal(t, "task:task_scope_1", item.Projection["id"])
	taskProjection, ok := item.Projection["task"].(map[string]any)
	require.True(t, ok)
	require.Equal(t, "tsk_1", taskProjection["id"])
	require.Equal(t, int64(42), taskProjection["number"])
	require.Contains(t, taskProjection, "assigneeProfile")
}
