package message

import (
	"context"
	"fmt"
	"testing"
	"time"

	"github.com/stretchr/testify/require"

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

func TestVisibleTaskScopeUsesCanonicalParentAuthorityRelation(t *testing.T) {
	require.Contains(t, visibleScopeReadBatchQuery, taskLiveParentAuthoritySQL)
	require.NotContains(t, visibleScopeReadBatchQuery, "task_execution.scope_type")
	require.Contains(t, taskLiveParentAuthoritySQL, "CURRENT_TIMESTAMP")
	require.NotContains(t, taskLiveParentAuthoritySQL, "task_parent_authority_grant")
	require.NotContains(t, taskLiveParentAuthoritySQL, "task_parent_authority_participant")
	require.Contains(t, taskHistoricalParentAuthoritySQL, "task_parent_authority_grant")
	require.Contains(t, taskHistoricalParentAuthoritySQL, "task_parent_authority_participant")
	require.NotContains(t, taskLiveParentAuthoritySQL, "TRUE OR")
	require.NotContains(t, taskHistoricalParentAuthoritySQL, "TRUE OR")
}

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

	ctx := context.Background()
	db := startMessagingTestPool(t, ctx)
	now := time.Date(2026, 7, 30, 8, 0, 0, 0, time.UTC)
	const (
		workspaceID     = "wsp_visible_task"
		ownerUserID     = "usr_visible_task_owner"
		ownerMemberID   = "mem_visible_task_owner"
		viewerUserID    = "usr_visible_task_viewer"
		viewerMemberID  = "mem_visible_task_viewer"
		outsideUserID   = "usr_visible_task_outside"
		outsideMemberID = "mem_visible_task_outside"
		channelID       = "chn_visible_task"
		taskID          = "tsk_visible_task"
		taskScopeID     = "tsk_scope_visible_task"
	)

	insertMessagingWorkspace(t, ctx, db, workspaceID, now)
	insertMessagingUser(t, ctx, db, ownerUserID, "visible-task-owner@example.com", now)
	insertMessagingUser(t, ctx, db, viewerUserID, "visible-task-viewer@example.com", now)
	insertMessagingUser(t, ctx, db, outsideUserID, "visible-task-outside@example.com", now)
	insertMessagingHumanMember(t, ctx, db, ownerMemberID, workspaceID, ownerUserID, now)
	insertMessagingHumanMember(t, ctx, db, viewerMemberID, workspaceID, viewerUserID, now)
	insertMessagingHumanMember(t, ctx, db, outsideMemberID, workspaceID, outsideUserID, now)
	insertMessagingChannel(t, ctx, db, channelID, workspaceID, "visible-task", ownerMemberID, "", now)
	require.NoError(t, EnsureScopeMembership(
		ctx,
		db,
		channelID,
		viewerMemberID,
		"public",
		"",
		"active",
		0,
		0,
		now,
	))
	insertConversationTaskForTest(t, ctx, db, conversationTaskFixture{
		WorkspaceID:       workspaceID,
		TaskID:            taskID,
		TaskScopeID:       taskScopeID,
		BoardID:           "tbd_visible_task",
		ChannelID:         channelID,
		TaskNumber:        1,
		Title:             "Visible task",
		Status:            "todo",
		CreatedByMemberID: ownerMemberID,
		OwnerMemberID:     ownerMemberID,
		Now:               now,
	})

	authorizer := NewSQLVisibleScopeAuthorizer(db)

	// The creator can read the Task through its source Channel.
	readable, err := authorizer.CanReadVisibleScope(ctx, workspaceID, ownerMemberID, taskScopeID)
	require.NoError(t, err)
	require.True(t, readable)

	// A source Channel member can read the authoritative Task scope without
	// becoming a durable Task participant, so workflow related-scope
	// authorization must use the same source-Channel boundary.
	readable, err = authorizer.CanReadVisibleScope(ctx, workspaceID, viewerMemberID, taskScopeID)
	require.NoError(t, err)
	require.True(t, readable)

	// Workspace membership alone is not Task visibility.
	readable, err = authorizer.CanReadVisibleScope(ctx, workspaceID, outsideMemberID, taskScopeID)
	require.NoError(t, err)
	require.False(t, readable)

	// Task membership alone must not make the realtime gate broader than the
	// Task Detail read boundary, which remains owned by source Channel access.
	require.NoError(t, EnsureScopeMembership(
		ctx,
		db,
		taskScopeID,
		outsideMemberID,
		"public",
		"",
		"active",
		0,
		0,
		now,
	))
	readable, err = authorizer.CanReadVisibleScope(ctx, workspaceID, outsideMemberID, taskScopeID)
	require.NoError(t, err)
	require.False(t, readable)

	readable, err = authorizer.CanReadVisibleScope(ctx, "wsp_other", ownerMemberID, taskScopeID)
	require.NoError(t, err)
	require.False(t, readable)
}

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

	ctx := context.Background()
	db := startMessagingTestPool(t, ctx)
	fixture := seedVisibleScopeBatchFixture(t, ctx, db)
	authorizer := NewSQLVisibleScopeAuthorizer(db)

	t.Run("mixed readable scopes", func(t *testing.T) {
		scopeIDs := []string{
			fixture.ChannelID,
			fixture.DMID,
			fixture.ChannelThreadID,
			fixture.DMThreadID,
			fixture.TaskScopeID,
		}
		access, err := authorizer.CanReadVisibleScopes(
			ctx,
			fixture.WorkspaceID,
			fixture.ViewerMemberID,
			scopeIDs,
		)
		require.NoError(t, err)
		require.Equal(t, map[string]VisibleScopeReadAccess{
			fixture.ChannelID:       {ScopeType: "channel", Readable: true},
			fixture.DMID:            {ScopeType: "dm", Readable: true},
			fixture.ChannelThreadID: {ScopeType: "thread", Readable: true},
			fixture.DMThreadID:      {ScopeType: "thread", Readable: true},
			fixture.TaskScopeID:     {ScopeType: "task", Readable: true},
		}, access)
	})

	t.Run("scope-specific child membership gates", func(t *testing.T) {
		access, err := authorizer.CanReadVisibleScopes(
			ctx,
			fixture.WorkspaceID,
			fixture.ChildOnlyMemberID,
			[]string{
				fixture.ChannelThreadID,
				fixture.DMThreadID,
				fixture.TaskScopeID,
			},
		)
		require.NoError(t, err)
		require.Equal(t, map[string]VisibleScopeReadAccess{
			fixture.ChannelThreadID: {ScopeType: "thread", Readable: false},
			fixture.DMThreadID:      {ScopeType: "thread", Readable: true},
			fixture.TaskScopeID:     {ScopeType: "task", Readable: false},
		}, access, "only a DM reply thread accepts child-only membership")
	})

	t.Run("dm reply thread accepts parent membership", func(t *testing.T) {
		readable, err := authorizer.CanReadVisibleScope(
			ctx,
			fixture.WorkspaceID,
			fixture.OwnerMemberID,
			fixture.DMThreadID,
		)
		require.NoError(t, err)
		require.True(t, readable)

		readable, err = authorizer.CanReadVisibleScope(
			ctx,
			fixture.WorkspaceID,
			fixture.NoAccessMemberID,
			fixture.DMThreadID,
		)
		require.NoError(t, err)
		require.False(t, readable)
	})

	t.Run("owner-private Agent visibility is explicit and owner-bound", func(t *testing.T) {
		scopeIDs := []string{
			fixture.ChannelID,
			fixture.ChannelThreadID,
			fixture.TaskScopeID,
		}

		publicOnly, err := authorizer.CanReadVisibleScopes(
			ctx,
			fixture.WorkspaceID,
			fixture.PrivateAgentMemberID,
			scopeIDs,
		)
		require.NoError(t, err)
		require.Equal(t, map[string]VisibleScopeReadAccess{
			fixture.ChannelID:       {ScopeType: "channel", Readable: false},
			fixture.ChannelThreadID: {ScopeType: "thread", Readable: false},
			fixture.TaskScopeID:     {ScopeType: "task", Readable: false},
		}, publicOnly, "the Web visible-scope gate remains public-only")

		ownerPrivate, err := authorizer.CanReadVisibleScopesForAgent(
			ctx,
			fixture.WorkspaceID,
			fixture.PrivateAgentMemberID,
			fixture.PrivateAgentID,
			scopeIDs,
		)
		require.NoError(t, err)
		require.Equal(t, map[string]VisibleScopeReadAccess{
			fixture.ChannelID:       {ScopeType: "channel", Readable: true},
			fixture.ChannelThreadID: {ScopeType: "thread", Readable: true},
			fixture.TaskScopeID:     {ScopeType: "task", Readable: true},
		}, ownerPrivate)

		wrongAgent, err := authorizer.CanReadVisibleScopesForAgent(
			ctx,
			fixture.WorkspaceID,
			fixture.PrivateAgentMemberID,
			"agt_visible_batch_wrong",
			scopeIDs,
		)
		require.NoError(t, err)
		require.Equal(t, publicOnly, wrongAgent)
	})

	t.Run("Agent policy rejects archived public memberships without widening Web policy", func(t *testing.T) {
		_, err := db.Exec(ctx, `
UPDATE scope_memberships
SET visibility = 'public',
    owner_member_id = NULL,
    archived_at = $3,
    updated_at = $3
WHERE scope_id = $1
  AND member_id = $2
  AND removed_at IS NULL
  AND effective_to_seq IS NULL
`, fixture.ChannelID, fixture.PrivateAgentMemberID, time.Now().UTC())
		require.NoError(t, err)

		scopeIDs := []string{
			fixture.ChannelID,
			fixture.ChannelThreadID,
			fixture.TaskScopeID,
		}
		webAccess, err := authorizer.CanReadVisibleScopes(
			ctx,
			fixture.WorkspaceID,
			fixture.PrivateAgentMemberID,
			scopeIDs,
		)
		require.NoError(t, err)
		require.Equal(t, map[string]VisibleScopeReadAccess{
			fixture.ChannelID:       {ScopeType: "channel", Readable: true},
			fixture.ChannelThreadID: {ScopeType: "thread", Readable: true},
			fixture.TaskScopeID:     {ScopeType: "task", Readable: true},
		}, webAccess, "the existing public Web wrapper intentionally ignores membership archival")

		agentAccess, err := authorizer.CanReadVisibleScopesForAgent(
			ctx,
			fixture.WorkspaceID,
			fixture.PrivateAgentMemberID,
			fixture.PrivateAgentID,
			scopeIDs,
		)
		require.NoError(t, err)
		require.Equal(t, map[string]VisibleScopeReadAccess{
			fixture.ChannelID:       {ScopeType: "channel", Readable: false},
			fixture.ChannelThreadID: {ScopeType: "thread", Readable: false},
			fixture.TaskScopeID:     {ScopeType: "task", Readable: false},
		}, agentAccess)
	})

	t.Run("missing and inactive scopes are omitted", func(t *testing.T) {
		access, err := authorizer.CanReadVisibleScopes(
			ctx,
			fixture.WorkspaceID,
			fixture.OwnerMemberID,
			[]string{fixture.MissingScopeID, fixture.InactiveScopeID},
		)
		require.NoError(t, err)
		require.Empty(t, access)

		readable, err := authorizer.CanReadVisibleScope(
			ctx,
			fixture.WorkspaceID,
			fixture.OwnerMemberID,
			fixture.MissingScopeID,
		)
		require.NoError(t, err)
		require.False(t, readable)
	})

	t.Run("single-scope method delegates to one batch query", func(t *testing.T) {
		counting := &visibleScopeCountingQueryer{Queryer: db}
		single := NewSQLVisibleScopeAuthorizer(counting)

		readable, err := single.CanReadVisibleScope(
			ctx,
			fixture.WorkspaceID,
			fixture.ViewerMemberID,
			fixture.ChannelID,
		)
		require.NoError(t, err)
		require.True(t, readable)
		require.Equal(t, 1, counting.queryCount)
	})

	t.Run("invalid active thread parent is an error", func(t *testing.T) {
		_, err := authorizer.CanReadVisibleScopes(
			ctx,
			fixture.WorkspaceID,
			fixture.OwnerMemberID,
			[]string{fixture.InvalidParentThreadID},
		)
		require.ErrorContains(t, err, `invalid visible thread parent scope type "service_account"`)
	})
}

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

	ctx := context.Background()
	db := startMessagingTestPool(t, ctx)
	authorizer := NewSQLVisibleScopeAuthorizer(db)

	scopeIDs := make([]string, MaxVisibleScopesPerBatch+1)
	for index := range scopeIDs {
		scopeIDs[index] = fmt.Sprintf("chn_visible_batch_limit_%02d", index)
	}

	access, err := authorizer.CanReadVisibleScopes(
		ctx,
		"wsp_visible_batch_limit",
		"mem_visible_batch_limit",
		scopeIDs[:MaxVisibleScopesPerBatch],
	)
	require.NoError(t, err)
	require.Empty(t, access)

	_, err = authorizer.CanReadVisibleScopes(
		ctx,
		"wsp_visible_batch_limit",
		"mem_visible_batch_limit",
		scopeIDs,
	)
	require.ErrorContains(t, err, "visible scope batch must not contain more than 64 scopes")
}

type visibleScopeBatchFixture struct {
	WorkspaceID           string
	OwnerMemberID         string
	ViewerMemberID        string
	PrivateAgentID        string
	PrivateAgentMemberID  string
	ChildOnlyMemberID     string
	NoAccessMemberID      string
	ChannelID             string
	DMID                  string
	ChannelThreadID       string
	DMThreadID            string
	TaskScopeID           string
	InactiveScopeID       string
	MissingScopeID        string
	InvalidParentThreadID string
}

func seedVisibleScopeBatchFixture(
	t *testing.T,
	ctx context.Context,
	db coredb.QueryExecer,
) visibleScopeBatchFixture {
	t.Helper()

	now := time.Date(2026, 7, 30, 9, 0, 0, 0, time.UTC)
	fixture := visibleScopeBatchFixture{
		WorkspaceID:           "wsp_visible_batch",
		OwnerMemberID:         "mem_visible_batch_owner",
		ViewerMemberID:        "mem_visible_batch_viewer",
		PrivateAgentID:        "agt_visible_batch_private",
		PrivateAgentMemberID:  "mem_visible_batch_private_agent",
		ChildOnlyMemberID:     "mem_visible_batch_child_only",
		NoAccessMemberID:      "mem_visible_batch_no_access",
		ChannelID:             "chn_visible_batch",
		DMID:                  "dm_visible_batch",
		ChannelThreadID:       "thr_visible_batch_channel",
		DMThreadID:            "thr_visible_batch_dm",
		TaskScopeID:           "tsk_scope_visible_batch",
		InactiveScopeID:       "chn_visible_batch_inactive",
		MissingScopeID:        "chn_visible_batch_missing",
		InvalidParentThreadID: "thr_visible_batch_invalid_parent",
	}
	const (
		ownerUserID        = "usr_visible_batch_owner"
		viewerUserID       = "usr_visible_batch_viewer"
		childOnlyUserID    = "usr_visible_batch_child_only"
		noAccessUserID     = "usr_visible_batch_no_access"
		peerUserID         = "usr_visible_batch_peer"
		peerMemberID       = "mem_visible_batch_peer"
		dmParentID         = "dm_visible_batch_parent"
		invalidParentID    = "svc_visible_batch_invalid_parent"
		channelParentMsgID = "msg_visible_batch_channel_parent"
		dmParentMsgID      = "msg_visible_batch_dm_parent"
		taskID             = "tsk_visible_batch"
		channelParentShort = "visbatch"
		dmParentShort      = "visbdm"
		invalidParentMsgID = "msg_visible_batch_invalid_parent"
		invalidParentShort = "visbinv"
		privateMachineID   = "mac_visible_batch_private"
	)

	insertMessagingWorkspace(t, ctx, db, fixture.WorkspaceID, now)
	insertMessagingUser(t, ctx, db, ownerUserID, "visible-batch-owner@example.com", now)
	insertMessagingUser(t, ctx, db, viewerUserID, "visible-batch-viewer@example.com", now)
	insertMessagingUser(t, ctx, db, childOnlyUserID, "visible-batch-child@example.com", now)
	insertMessagingUser(t, ctx, db, noAccessUserID, "visible-batch-no-access@example.com", now)
	insertMessagingUser(t, ctx, db, peerUserID, "visible-batch-peer@example.com", now)
	insertMessagingHumanMember(t, ctx, db, fixture.OwnerMemberID, fixture.WorkspaceID, ownerUserID, now)
	insertMessagingHumanMember(t, ctx, db, fixture.ViewerMemberID, fixture.WorkspaceID, viewerUserID, now)
	insertMessagingHumanMember(t, ctx, db, fixture.ChildOnlyMemberID, fixture.WorkspaceID, childOnlyUserID, now)
	insertMessagingHumanMember(t, ctx, db, fixture.NoAccessMemberID, fixture.WorkspaceID, noAccessUserID, now)
	insertMessagingHumanMember(t, ctx, db, peerMemberID, fixture.WorkspaceID, peerUserID, now)
	insertMessagingAgentMember(t, ctx, db, fixture.PrivateAgentMemberID, fixture.WorkspaceID, now)
	insertMessagingMachine(t, ctx, db, privateMachineID, fixture.WorkspaceID, ownerUserID, now)
	insertMessagingAgent(
		t,
		ctx,
		db,
		fixture.PrivateAgentID,
		fixture.WorkspaceID,
		privateMachineID,
		fixture.PrivateAgentMemberID,
		"visible-batch-private",
		now,
	)

	insertMessagingChannel(t, ctx, db, fixture.ChannelID, fixture.WorkspaceID, "visible-batch", fixture.OwnerMemberID, "", now)
	require.NoError(t, EnsureScopeMembership(
		ctx, db, fixture.ChannelID, fixture.ViewerMemberID,
		"public", "", "active", 0, 0, now,
	))
	require.NoError(t, EnsureScopeMembership(
		ctx, db, fixture.ChannelID, fixture.PrivateAgentMemberID,
		"private", fixture.OwnerMemberID, "active", 0, 0, now,
	))
	insertMessagingReplyThread(
		t, ctx, db, fixture.ChannelThreadID, fixture.WorkspaceID,
		fixture.ChannelID, channelParentMsgID, channelParentShort, "", now,
	)
	// Fabricate the historical illegal state directly: a child-only membership on
	// a channel reply-thread with NO parent channel access. Production writes this
	// via EnsureScopeMembership, which the channel-reply-thread fence now refuses
	// (parent membership not active); seed it through the repository to bypass the
	// fence so the authorizer can be verified to reject the bad state.
	require.NoError(t, NewScopeRepositories(db).Memberships.Upsert(ctx, ScopeMembershipRecord{
		ScopeID:          fixture.ChannelThreadID,
		MemberID:         fixture.ChildOnlyMemberID,
		Visibility:       "public",
		EffectiveFromSeq: 1,
		CreatedAt:        now,
		UpdatedAt:        now,
	}))

	insertMessagingDMThread(
		t, ctx, db, fixture.DMID, fixture.WorkspaceID,
		fixture.ViewerMemberID, peerMemberID, "", now,
	)
	insertMessagingDMThread(
		t, ctx, db, dmParentID, fixture.WorkspaceID,
		fixture.OwnerMemberID, peerMemberID, "", now,
	)
	insertMessagingReplyThread(
		t, ctx, db, fixture.DMThreadID, fixture.WorkspaceID,
		dmParentID, dmParentMsgID, dmParentShort, "", now,
	)
	for _, memberID := range []string{fixture.ViewerMemberID, fixture.ChildOnlyMemberID} {
		require.NoError(t, EnsureScopeMembership(
			ctx, db, fixture.DMThreadID, memberID,
			"public", "", "active", 0, 0, now,
		))
	}

	insertConversationTaskForTest(t, ctx, db, conversationTaskFixture{
		WorkspaceID:       fixture.WorkspaceID,
		TaskID:            taskID,
		TaskScopeID:       fixture.TaskScopeID,
		BoardID:           "tbd_visible_batch",
		ChannelID:         fixture.ChannelID,
		TaskNumber:        1,
		Title:             "Visible batch task",
		Status:            "todo",
		CreatedByMemberID: fixture.OwnerMemberID,
		OwnerMemberID:     fixture.OwnerMemberID,
		Now:               now,
	})
	require.NoError(t, EnsureScopeMembership(
		ctx, db, fixture.TaskScopeID, fixture.ChildOnlyMemberID,
		"public", "", "active", 0, 0, now,
	))

	insertMessagingChannel(
		t, ctx, db, fixture.InactiveScopeID, fixture.WorkspaceID,
		"visible-batch-inactive", fixture.OwnerMemberID, "", now,
	)
	mustExecMessaging(t, ctx, db, `
UPDATE scopes
SET status = 'archived', archived_at = $2, updated_at = $2
WHERE id = $1
`, fixture.InactiveScopeID, now)

	mustExecMessaging(t, ctx, db, `
INSERT INTO scopes (
    id, workspace_id, type, identity_key, latest_seq, latest_global_seq,
    latest_message_at, archived_until_seq, status, created_by_member_id,
    created_at, updated_at
)
VALUES ($1, $2, 'service_account', $3, 0, 0, NULL, 0, 'active', $4, $5, $5)
`, invalidParentID, fixture.WorkspaceID, "service-account:"+invalidParentID, fixture.OwnerMemberID, now)
	insertMessagingReplyThread(
		t, ctx, db, fixture.InvalidParentThreadID, fixture.WorkspaceID,
		invalidParentID, invalidParentMsgID, invalidParentShort, "", now,
	)

	return fixture
}

type visibleScopeCountingQueryer struct {
	coredb.Queryer
	queryCount int
}

func (queryer *visibleScopeCountingQueryer) Query(
	ctx context.Context,
	query string,
	args ...any,
) (coredb.Rows, error) {
	queryer.queryCount++
	return queryer.Queryer.Query(ctx, query, args...)
}
