package reaction

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"os"
	"strings"
	"testing"
	"time"

	"github.com/go-sql-driver/mysql"
	"github.com/stretchr/testify/require"

	"github.com/tidbcloud/AgentTeam/internal/core/domain"
	"github.com/tidbcloud/AgentTeam/internal/coredb"
	"github.com/tidbcloud/AgentTeam/internal/store/access"
	"github.com/tidbcloud/AgentTeam/internal/store/message"
	"github.com/tidbcloud/AgentTeam/internal/store/model"
)

type scriptedRow struct {
	scan func(dest ...any) error
}

func (row scriptedRow) Scan(dest ...any) error {
	return row.scan(dest...)
}

type scriptedTx struct {
	rows       []coredb.Row
	execErrs   []error
	queryCalls int
	execCalls  int
}

type scriptedExecResult struct {
	rowsAffected int64
}

func (result scriptedExecResult) RowsAffected() int64 {
	return result.rowsAffected
}

func (tx *scriptedTx) QueryRow(context.Context, string, ...any) coredb.Row {
	if tx.queryCalls >= len(tx.rows) {
		return scriptedRow{scan: func(dest ...any) error {
			return fmt.Errorf("unexpected QueryRow call %d", tx.queryCalls+1)
		}}
	}
	row := tx.rows[tx.queryCalls]
	tx.queryCalls++
	return row
}

func (tx *scriptedTx) Query(context.Context, string, ...any) (coredb.Rows, error) {
	return nil, fmt.Errorf("unexpected Query call")
}

func (tx *scriptedTx) Exec(context.Context, string, ...any) (coredb.Result, error) {
	if tx.execCalls < len(tx.execErrs) {
		err := tx.execErrs[tx.execCalls]
		tx.execCalls++
		return nil, err
	}
	tx.execCalls++
	return scriptedExecResult{rowsAffected: 1}, nil
}

type duplicateReactionTx struct {
	scriptedTx
	duplicateErr error
	duplicateHit bool
}

func (tx *duplicateReactionTx) Exec(_ context.Context, query string, _ ...any) (coredb.Result, error) {
	tx.execCalls++
	if strings.Contains(query, "scope_message_reactions") {
		tx.duplicateHit = true
		return nil, tx.duplicateErr
	}
	return scriptedExecResult{rowsAffected: 1}, nil
}

func (tx *scriptedTx) Commit(context.Context) error {
	return nil
}

func (tx *scriptedTx) Rollback(context.Context) error {
	return nil
}

func sourceFromFunctionForReactionTest(t *testing.T, source string, signature string) string {
	t.Helper()

	start := strings.Index(source, signature)
	require.NotEqual(t, -1, start)
	rest := source[start+len(signature):]
	next := strings.Index(rest, "\nfunc ")
	if next == -1 {
		return source[start:]
	}
	return source[start : start+len(signature)+next]
}

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

	sourceBytes, err := os.ReadFile("reactions.go")
	require.NoError(t, err)
	source := string(sourceBytes)

	targetScopeSource := sourceFromFunctionForReactionTest(t, source, "func (store *Store) loadReactionTargetScope")
	require.Contains(t, targetScopeSource, "FROM scope_messages m")
	require.Contains(t, targetScopeSource, "JOIN scopes s")
	require.Contains(t, targetScopeSource, "s.id AS requested_scope_id")
	require.Contains(t, targetScopeSource, "parent_scope.id")
	require.Contains(t, targetScopeSource, "s.status = 'active'")
	require.Contains(t, targetScopeSource, "s.archived_at IS NULL")

	messageSource := sourceFromFunctionForReactionTest(t, source, "func (store *Store) loadHotReactionMessageInThreadWithLockClause")
	require.Contains(t, messageSource, "FROM scopes s")
	require.Contains(t, messageSource, "JOIN scope_messages m")
	require.Contains(t, messageSource, "JOIN scopes s")

	agentTargetSource := sourceFromFunctionForReactionTest(t, source, "func (store *Store) loadAgentReactionTarget")
	require.Contains(t, agentTargetSource, "message.LoadReplyThreadShape")
	require.NotContains(t, agentTargetSource, "ResolveReplyThreadChannelAccess")
	require.NotContains(t, source, "func (store *Store) loadAgentChannelReactionThreadID")
}

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

	sourceBytes, err := os.ReadFile("reactions.go")
	require.NoError(t, err)
	source := string(sourceBytes)

	for _, signature := range []string{
		"func (store *Store) AddReactionForUser",
		"func (store *Store) RemoveReactionForUser",
		"func (store *Store) AddReactionForAgent",
		"func (store *Store) RemoveReactionForAgent",
	} {
		mutationSource := sourceFromFunctionForReactionTest(t, source, signature)
		require.Contains(t, mutationSource, "lockActiveTaskReactionScope(ctx, tx,")
		require.Less(
			t,
			strings.Index(mutationSource, "lockActiveTaskReactionScope(ctx, tx,"),
			strings.Index(mutationSource, "loadHotReactionMessageInTargetScopeForUpdate"),
			signature,
		)
	}
}

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

	target := reactionWriteScopeTargetContract("wsp_1", "agt_1", reactionTargetScope{
		ScopeID:          "thr_reply",
		ScopeType:        "thread",
		RequestedScopeID: "thr_reply",
		ParentScopeID:    "chn_parent",
		ParentScopeType:  "channel",
		RootMessageID:    "msg_root",
		RootMessageShort: "msgroot1",
	}, message.ReactionActorIdentity{
		MemberID:  "mem_agent",
		ActorType: string(domain.MemberTypeAgent),
		ActorName: "agent",
	})

	require.NoError(t, target.Validate())
	require.Equal(t, access.ScopeTargetOperationReactionWrite, target.Operation)
	require.Equal(t, access.ScopeRef{ScopeID: "thr_reply", ScopeType: access.ScopeTargetKindThread}, target.StorageScope)
	require.Equal(t, target.StorageScope, target.DeliveryScope)
	require.Equal(t, target.StorageScope, target.ReadScope)
	require.Equal(t, access.ScopeRef{ScopeID: "chn_parent", ScopeType: access.ScopeTargetKindChannel}, target.ParentScope)
	require.Equal(t, access.ScopeAuthoritySubjectDurableAuthor, target.Subjects[0].Kind)
	require.Equal(t, access.PrincipalTypeAgent, target.Subjects[0].PrincipalType)
	require.Equal(t, "mem_agent", target.Subjects[0].MemberID)
	require.Equal(t, access.ScopePermissionBasisParentMembership, target.PermissionBasis[0].Kind)
	require.Equal(t, target.ParentScope, target.PermissionBasis[0].Scope)
	require.Equal(t, access.DecisionSourceThreadInherit, target.PermissionBasis[0].DecisionSource)
}

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

	receiveMessage := buildReactionReceiveMessage(
		message.ThreadAccess{
			ThreadType:  domain.ThreadTypeChannel,
			ChannelName: "general",
		},
		message.ReactionActorIdentity{
			MemberID:  "mem_legacy",
			ActorType: string(domain.MemberTypeHuman),
			ActorName: "Legacy Reactor",
			AuthorProfile: message.AuthorProfile{
				ActorType:   message.AuthorActorTypeHuman,
				PrincipalID: "usr_profile",
				MemberID:    "mem_profile",
				TargetID:    "profile-target",
				DisplayName: "Profile Reactor",
			},
		},
		"rxact_delivery",
		"rxn_1",
		"msg_target",
		"target preview",
		"👍",
		time.Date(2026, time.May, 29, 1, 20, 0, 0, time.UTC),
	)

	require.Equal(t, message.AuthorProfile{
		ActorType:   message.AuthorActorTypeHuman,
		PrincipalID: "usr_profile",
		MemberID:    "mem_profile",
		TargetID:    "profile-target",
		DisplayName: "Profile Reactor",
	}, receiveMessage.AuthorProfile)
}

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

	context := reactionNotificationTaskContextForScope(reactionTargetScope{
		ScopeType:          string(access.ScopeTargetKindTask),
		TaskBoardChannelID: "chn_task_board",
		TaskNumber:         42,
	})
	require.Equal(t, &message.AppMessageTaskContext{
		ChannelID:  "chn_task_board",
		TaskNumber: 42,
	}, context)
}

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

	context := reactionNotificationContextForScope(message.ThreadAccess{
		ThreadID:    "sasc_1",
		ThreadType:  domain.ThreadTypeChannel,
		ChannelID:   "sasc_1",
		ChannelName: "AgentTeam Official",
	}, reactionTargetScope{
		ScopeID:          "sasc_1",
		ScopeType:        string(access.ScopeTargetKindServiceAccount),
		RequestedScopeID: "sasc_1",
	})
	require.Equal(t, string(access.ScopeTargetKindServiceAccount), context.ChannelType)
}

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

	err := validateReactionWriteScopeTargetContract("wsp_1", "usr_1", message.ThreadAccess{
		ThreadID:    "chn_other",
		ThreadType:  domain.ThreadTypeChannel,
		ChannelID:   "chn_other",
		ChannelName: "other",
	}, reactionTargetScope{
		ScopeID:          "chn_target",
		ScopeType:        "channel",
		RequestedScopeID: "chn_target",
	}, message.ReactionActorIdentity{
		MemberID:  "mem_human",
		ActorType: string(domain.MemberTypeHuman),
		ActorName: "human",
	})
	require.ErrorIs(t, err, ErrForbidden)
}

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

	tests := []struct {
		name     string
		rowType  string
		wantType string
		wantErr  error
	}{
		{
			name:     "human actor",
			rowType:  string(domain.MemberTypeHuman),
			wantType: string(domain.MemberTypeHuman),
		},
		{
			name:     "agent actor",
			rowType:  string(domain.MemberTypeAgent),
			wantType: string(domain.MemberTypeAgent),
		},
		{
			name:    "service actor forbidden",
			rowType: string(domain.MemberTypeService),
			wantErr: ErrForbidden,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()

			store := &Store{}
			tx := &scriptedTx{rows: []coredb.Row{
				scriptedRow{scan: func(dest ...any) error {
					require.Len(t, dest, 3)
					*dest[0].(*string) = "mem_actor"
					*dest[1].(*string) = tt.rowType
					*dest[2].(*string) = "actor"
					return nil
				}},
			}}

			actor, err := store.loadReactionActorIdentity(context.Background(), tx, "wsp_1", "mem_actor")
			if tt.wantErr != nil {
				require.True(t, errors.Is(err, tt.wantErr), "got %v", err)
				return
			}
			require.NoError(t, err)
			require.Equal(t, "mem_actor", actor.MemberID)
			require.Equal(t, tt.wantType, actor.ActorType)
			require.Equal(t, "actor", actor.ActorName)
		})
	}
}

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

	now := time.Date(2026, time.April, 15, 12, 0, 0, 0, time.UTC)
	tx := &duplicateReactionTx{
		duplicateErr: &mysql.MySQLError{Number: 1062, Message: "Duplicate entry 'msg_123-mem_human-:+1:' for key 'scope_message_reactions_message_emoji_member_key'"},
		scriptedTx: scriptedTx{rows: []coredb.Row{
			scriptedRow{scan: func(dest ...any) error {
				return sql.ErrNoRows
			}},
			scriptedRow{scan: func(dest ...any) error {
				require.Len(t, dest, 2)
				*dest[0].(*bool) = false
				*dest[1].(*int64) = 0
				return nil
			}},
			scriptedRow{scan: func(dest ...any) error {
				*dest[0].(*time.Time) = now
				return nil
			}},
			scriptedRow{scan: func(dest ...any) error {
				*dest[0].(*int64) = 42
				return nil
			}},
			scriptedRow{scan: func(dest ...any) error {
				return sql.ErrNoRows
			}},
			scriptedRow{scan: func(dest ...any) error {
				*dest[0].(*string) = "rxn_existing"
				*dest[1].(*string) = "ws_123"
				*dest[2].(*string) = "chn_123"
				*dest[3].(*string) = "msg_123"
				*dest[4].(*string) = ":+1:"
				*dest[5].(*string) = "mem_human"
				*dest[6].(*time.Time) = now
				return nil
			}},
		}},
	}

	store := &Store{}
	result, err := store.addReactionForActor(
		context.Background(),
		tx,
		"ws_123",
		message.ThreadAccess{
			ThreadID:    "thr_123",
			ThreadType:  domain.ThreadTypeChannel,
			ChannelID:   "chn_123",
			ChannelName: "general",
		},
		reactionTargetScope{
			ThreadID:         "thr_123",
			ScopeID:          "chn_123",
			ScopeType:        "channel",
			RequestedScopeID: "chn_123",
			ChannelSlug:      "general",
		},
		hotReactionMessage{
			MessageID:      "msg_123",
			ThreadID:       "thr_123",
			ScopeID:        "chn_123",
			AuthorMemberID: "mem_agent",
			AuthorType:     string(domain.MemberTypeAgent),
			Content:        "Thanks",
		},
		message.ReactionActorIdentity{
			MemberID:  "mem_human",
			ActorType: string(domain.MemberTypeHuman),
			ActorName: "mem_human",
		},
		"usr_human",
		":+1:",
	)
	require.NoError(t, err)
	require.False(t, result.Changed)
	require.Equal(t, "chn_123", result.ChannelID)
	require.Empty(t, result.Reaction.ID)
	require.Nil(t, result.Delivery)
	require.Equal(t, 6, tx.queryCalls)
	require.True(t, tx.duplicateHit)
}

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

	tx := &scriptedTx{
		rows: []coredb.Row{
			scriptedRow{scan: func(dest ...any) error {
				require.Len(t, dest, 2)
				*dest[0].(*bool) = true
				*dest[1].(*int64) = 3
				return nil
			}},
		},
	}

	require.NoError(t, enforceReactionEmojiKindsLimit(context.Background(), tx, "msg_123", "👍"))
	require.Equal(t, 1, tx.queryCalls)
}

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

	query, args, err := buildMessageReactionAggregateRowsQuery("wsp_1", []string{"msg_1", "msg_2"}, "mem_self")
	require.NoError(t, err)

	require.Contains(t, query, "COUNT(*) OVER (PARTITION BY mr.message_id, mr.emoji)")
	require.Contains(t, query, "ROW_NUMBER() OVER (PARTITION BY mr.message_id, mr.emoji")
	require.Contains(t, query, "reactor_rank <= $2")
	require.Contains(t, query, "reactor_member_id = $3")
	require.Contains(t, query, "reactor_user.display_name")
	require.Contains(t, query, "reactor_agent.name")
	require.Contains(t, query, "JOIN service_definitions reactor_app_def")
	require.NotContains(t, query, "reactor_app.display_name")
	require.Equal(t, []any{"wsp_1", maxReactionReactorsPerEmoji, "mem_self", "msg_1", "msg_2"}, args)
}

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

	lookup := reactionAggregateRowsToLookup([]string{"msg_1"}, []model.ReactionAggregateRow{
		{MessageID: "msg_1", Emoji: "👍", Count: 5, MemberID: "mem_a", ActorType: "human", ActorName: "mem_a"},
		{MessageID: "msg_1", Emoji: "👍", Count: 5, MemberID: "mem_self", ActorType: "agent", ActorName: "app"},
	})

	reactions := lookup["msg_1"]
	require.Len(t, reactions, 1)
	require.Equal(t, "👍", reactions[0].Emoji)
	require.Equal(t, 5, reactions[0].Count)
	require.Len(t, reactions[0].Reactors, 2)
	require.Equal(t, "mem_self", reactions[0].Reactors[1].AuthorProfile.MemberID)
}

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

	lookup := reactionAggregateRowsToLookup([]string{"msg_1"}, []model.ReactionAggregateRow{
		{
			MessageID:   "msg_1",
			Emoji:       "👍",
			Count:       1,
			MemberID:    " mem_human ",
			ActorType:   " human ",
			ActorName:   " andc5vq ",
			DisplayName: " 郑少峰 ",
			AvatarURL:   " /avatars/u.png ",
		},
	})

	reactions := lookup["msg_1"]
	require.Len(t, reactions, 1)
	require.Len(t, reactions[0].Reactors, 1)
	require.Equal(t, message.MessageReactionReactor{
		AuthorProfile: message.AuthorProfile{
			ActorType:   message.AuthorActorTypeHuman,
			MemberID:    "mem_human",
			TargetID:    "andc5vq",
			DisplayName: "郑少峰",
			AvatarURL:   "/avatars/u.png",
		},
	}, reactions[0].Reactors[0])
}

// TestReactionWriteScopeTargetContractTaskBasisRecordsBoardChannelScope asserts
// the authoritative Task reaction permission basis: for a task-scoped message, the
// basis kind is task_board_channel and the basis scope is the authoritative
// board Channel scope derived from the active Task/board authority, never the
// Task scope. The Task scope is only target identity; a retained Task membership
// with no Channel membership grants nothing.
func TestReactionWriteScopeTargetContractTaskBasisRecordsBoardChannelScope(t *testing.T) {
	t.Parallel()

	target := reactionWriteScopeTargetContract("wsp_1", "agt_1", reactionTargetScope{
		ScopeID:            "tsk_task_scope",
		ScopeType:          "task",
		RequestedScopeID:   "tsk_task_scope",
		TaskBoardChannelID: "chn_task_board",
	}, message.ReactionActorIdentity{
		MemberID:  "mem_agent",
		ActorType: string(domain.MemberTypeAgent),
		ActorName: "agent",
	})

	require.NoError(t, target.Validate())
	require.Equal(t, access.ScopeTargetOperationReactionWrite, target.Operation)
	// Target identity stays the Task scope.
	require.Equal(t, access.ScopeRef{ScopeID: "tsk_task_scope", ScopeType: access.ScopeTargetKindTask}, target.StorageScope)
	require.Equal(t, target.StorageScope, target.DeliveryScope)
	require.Equal(t, target.StorageScope, target.ReadScope)
	// The permission basis records the actual authoritative board Channel scope.
	require.Len(t, target.PermissionBasis, 1)
	require.Equal(t, access.ScopePermissionBasisTaskBoardChannel, target.PermissionBasis[0].Kind)
	require.Equal(t, access.ScopeRef{ScopeID: "chn_task_board", ScopeType: access.ScopeTargetKindChannel}, target.PermissionBasis[0].Scope)
	require.Equal(t, access.DecisionSourceChannelMembership, target.PermissionBasis[0].DecisionSource)
	require.Equal(t, "mem_agent", target.PermissionBasis[0].MemberID)
}

// TestReactionWritePermissionBasisTaskUsesBoardChannelScope proves the lower
// basis helper records the board Channel scope for a task scope with a
// resolved TaskBoardChannelID, and fails closed (keeps the Task scope) only when
// the board channel is unresolvable.
func TestReactionWritePermissionBasisTaskUsesBoardChannelScope(t *testing.T) {
	t.Parallel()

	withBoard := reactionWritePermissionBasis(reactionTargetScope{
		ScopeID:            "tsk_task_scope",
		ScopeType:          "task",
		TaskBoardChannelID: "chn_task_board",
	}, "mem_agent")
	require.Equal(t, access.ScopePermissionBasisTaskBoardChannel, withBoard.Kind)
	require.Equal(t, access.ScopeRef{ScopeID: "chn_task_board", ScopeType: access.ScopeTargetKindChannel}, withBoard.Scope)
	require.Equal(t, access.DecisionSourceChannelMembership, withBoard.DecisionSource)
	require.Equal(t, "task board channel membership authorizes reaction", withBoard.Reason)

	noBoard := reactionWritePermissionBasis(reactionTargetScope{
		ScopeID:   "tsk_task_scope",
		ScopeType: "task",
	}, "mem_agent")
	require.Equal(t, access.ScopePermissionBasisTaskBoardChannel, noBoard.Kind)
	require.Equal(t, "tsk_task_scope", noBoard.Scope.ScopeID, "unresolvable board keeps target identity, basis kind stays task_board_channel")
}
