package approval

import (
	"context"
	"crypto/sha256"
	"encoding/hex"
	"errors"
	"sync"
	"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/storeerr"
)

type approvalFixture struct {
	WorkspaceID string
	ApplicantID string
	AgentID     string
	ApproverAID string
	ApproverBID string
	OutsiderID  string
}

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

	ctx := context.Background()
	db := startApprovalRollbackDB(t, ctx)
	now := time.Now().UTC().Truncate(time.Second)
	fixture := seedApprovalFixture(t, db, "create", now)
	store := New(db)
	params := createParams(fixture, "appr_create", []string{fixture.ApproverBID, fixture.ApproverAID, fixture.ApproverAID}, now)

	instance, assignments, created, err := store.Create(ctx, params)
	require.NoError(t, err)
	require.True(t, created)
	require.Equal(t, StatePending, instance.State)
	require.Equal(t, int64(1), instance.Version)
	require.Equal(t, []string{fixture.ApproverAID, fixture.ApproverBID}, []string{assignments[0].ApproverMemberID, assignments[1].ApproverMemberID})

	replayed, replayAssignments, replayCreated, err := store.Create(ctx, params)
	require.NoError(t, err)
	require.False(t, replayCreated)
	require.Equal(t, instance.ID, replayed.ID)
	require.Equal(t, []string{assignments[0].ID, assignments[1].ID}, []string{replayAssignments[0].ID, replayAssignments[1].ID})

	differentDigest := params
	differentDigest.ID = "appr_create_other_id"
	differentDigest.PayloadDigest = sha256Hex("other payload")
	_, _, _, err = store.Create(ctx, differentDigest)
	require.ErrorIs(t, err, storeerr.ErrConflict)

	differentApprovers := params
	differentApprovers.ID = "appr_create_other_approvers"
	differentApprovers.ApproverMemberIDs = []string{fixture.ApproverAID}
	_, _, _, err = store.Create(ctx, differentApprovers)
	require.ErrorIs(t, err, storeerr.ErrConflict)

	inactiveApprover := params
	inactiveApprover.ID = "appr_create_inactive"
	inactiveApprover.SubjectRef = "request-inactive"
	inactiveApprover.ApproverMemberIDs = []string{fixture.OutsiderID}
	_, _, _, err = store.Create(ctx, inactiveApprover)
	require.ErrorIs(t, err, storeerr.ErrConflict)
}

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

	ctx := context.Background()
	db := startApprovalRollbackDB(t, ctx)
	now := time.Now().UTC().Truncate(time.Second)
	fixture := seedApprovalFixture(t, db, "generic-payload", now)
	store := New(db)
	params := createParams(fixture, "appr_generic_payload", []string{fixture.ApproverAID}, now)
	params.Payload = []byte(`{"schemaVersion":1,"request":{"teamId":"team-1"}}`)
	resolved, err := deterministicJSON(params.Payload)
	require.NoError(t, err)
	params.PayloadDigest = sha256Hex(string(resolved))
	params.IdempotencyKey = "submit-generic-payload"
	params.PendingKey = sha256Hex("pending:" + fixture.WorkspaceID + ":team_role_proposal:team-1")

	instance, _, created, err := store.Create(ctx, params)
	require.NoError(t, err)
	require.True(t, created)
	require.JSONEq(t, string(params.Payload), string(instance.Payload))
	require.Equal(t, params.IdempotencyKey, instance.IdempotencyKey)
	require.Equal(t, params.PendingKey, instance.PendingKey)

	loaded, err := store.LoadByIdempotencyKey(ctx, fixture.WorkspaceID, fixture.ApplicantID, params.IdempotencyKey)
	require.NoError(t, err)
	require.Equal(t, instance.ID, loaded.ID)

	replayedParams := params
	replayedParams.ID = "appr_generic_payload_replay_id"
	replayed, _, replayCreated, err := store.Create(ctx, replayedParams)
	require.NoError(t, err)
	require.False(t, replayCreated)
	require.Equal(t, instance.ID, replayed.ID)

	conflicting := replayedParams
	conflicting.SubjectRef = "different-subject"
	conflicting.Payload = []byte(`{"schemaVersion":1,"request":{"teamId":"team-2"}}`)
	resolved, err = deterministicJSON(conflicting.Payload)
	require.NoError(t, err)
	conflicting.PayloadDigest = sha256Hex(string(resolved))
	_, _, _, err = store.Create(ctx, conflicting)
	require.ErrorIs(t, err, storeerr.ErrConflict)
}

func TestApprovedApprovalCanReconcileExactStaleSubjectIdempotently(t *testing.T) {
	t.Parallel()
	ctx := context.Background()
	db := startApprovalRollbackDB(t, ctx)
	now := time.Now().UTC().Truncate(time.Second)
	fixture := seedApprovalFixture(t, db, "stale-reconcile", now)
	store := New(db)
	params := createParams(fixture, "appr_stale_reconcile", []string{fixture.ApproverAID}, now)
	params.SubjectType = "kb_wiki_generation"
	instance, assignments, _, err := store.Create(ctx, params)
	require.NoError(t, err)
	decided, err := store.Decide(ctx, DecideParams{
		WorkspaceID: fixture.WorkspaceID, ApprovalID: instance.ID,
		AssignmentID: assignments[0].ID, ActorMemberID: fixture.ApproverAID,
		ExpectedVersion: 1, ClientDecisionID: "decision-stale",
		Decision: DecisionApproved, Now: now.Add(time.Minute),
	})
	require.NoError(t, err)

	reconciled, err := store.ReconcileApprovedDecisionAsStale(ctx, ApprovedStaleParams{
		WorkspaceID: fixture.WorkspaceID, ApprovalID: instance.ID,
		SubjectType: params.SubjectType, ActorMemberID: fixture.ApproverAID,
		ClientDecisionID: "decision-stale", ApprovedVersion: decided.Instance.Version,
		Reason: "stale_subject", Now: now.Add(2 * time.Minute),
	})
	require.NoError(t, err)
	require.False(t, reconciled.AlreadyApplied)
	require.Equal(t, StateCancelled, reconciled.Instance.State)
	require.Equal(t, DecisionCancelled, reconciled.Instance.Decision)
	require.Equal(t, AssignmentCancelled, reconciled.Assignment.Status)
	require.Equal(t, int64(3), reconciled.Instance.Version)
	require.Equal(t, DecisionApproved, reconciled.Receipt.Decision)

	replay, err := store.ReconcileApprovedDecisionAsStale(ctx, ApprovedStaleParams{
		WorkspaceID: fixture.WorkspaceID, ApprovalID: instance.ID,
		SubjectType: params.SubjectType, ActorMemberID: fixture.ApproverAID,
		ClientDecisionID: "decision-stale", ApprovedVersion: decided.Instance.Version,
		Reason: "stale_subject", Now: now.Add(3 * time.Minute),
	})
	require.NoError(t, err)
	require.True(t, replay.AlreadyApplied)
	require.Equal(t, int64(3), replay.Instance.Version)
}

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

	ctx := context.Background()
	db := startApprovalRollbackDB(t, ctx)
	now := time.Now().UTC().Truncate(time.Second)
	fixture := seedApprovalFixture(t, db, "resolved-payload", now)
	store := New(db)
	params := createParams(fixture, "appr_deterministic_payload", []string{fixture.ApproverAID}, now)
	params.Payload = []byte("{\n  \"z\": 2,\n  \"a\": { \"b\": 1 }\n}")
	resolved := `{"a":{"b":1},"z":2}`
	params.PayloadDigest = sha256Hex(resolved)

	instance, _, created, err := store.Create(ctx, params)
	require.NoError(t, err)
	require.True(t, created)
	require.JSONEq(t, resolved, string(instance.Payload))
	require.Equal(t, sha256Hex(resolved), instance.PayloadDigest)

	mismatch := createParams(fixture, "appr_payload_digest_mismatch", []string{fixture.ApproverAID}, now)
	mismatch.SubjectRef = "request-payload-digest-mismatch"
	mismatch.Payload = []byte(`{"schemaVersion":1}`)
	mismatch.PayloadDigest = sha256Hex(`{"schemaVersion":2}`)
	_, _, _, err = store.Create(ctx, mismatch)
	require.ErrorIs(t, err, storeerr.ErrBadRequest)

	legacy := createParams(fixture, "appr_legacy_empty_payload", []string{fixture.ApproverAID}, now)
	legacy.SubjectRef = "request-legacy-empty-payload"
	legacy.Payload = nil
	_, _, created, err = store.Create(ctx, legacy)
	require.NoError(t, err)
	require.True(t, created)
}

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

	ctx := context.Background()
	db := startApprovalRollbackDB(t, ctx)
	now := time.Now().UTC().Truncate(time.Second)
	fixture := seedApprovalFixture(t, db, "pending-key", now)
	store := New(db)
	pendingKey := sha256Hex("pending:" + fixture.WorkspaceID + ":team_role_proposal:team-1")

	firstParams := createParams(fixture, "appr_pending_first", []string{fixture.ApproverAID}, now)
	firstParams.PendingKey = pendingKey
	first, assignments, _, err := store.Create(ctx, firstParams)
	require.NoError(t, err)

	secondParams := createParams(fixture, "appr_pending_second", []string{fixture.ApproverAID}, now)
	secondParams.PendingKey = pendingKey
	_, _, _, err = store.Create(ctx, secondParams)
	require.ErrorIs(t, err, storeerr.ErrConflict)

	decided, err := store.Decide(ctx, DecideParams{
		WorkspaceID: fixture.WorkspaceID, ApprovalID: first.ID,
		AssignmentID: assignments[0].ID, ActorMemberID: fixture.ApproverAID,
		ExpectedVersion: first.Version, ClientDecisionID: "release-pending",
		Decision: DecisionRejected, Reason: "no", Now: now.Add(time.Second),
	})
	require.NoError(t, err)
	require.Empty(t, decided.Instance.PendingKey)

	_, _, created, err := store.Create(ctx, secondParams)
	require.NoError(t, err)
	require.True(t, created)
}

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

	ctx := context.Background()
	db := startApprovalRollbackDB(t, ctx)
	now := time.Now().UTC().Truncate(time.Second)
	fixture := seedApprovalFixture(t, db, "applicant-cancel", now)
	store := New(db)
	params := createParams(fixture, "appr_applicant_cancel", []string{fixture.ApproverAID, fixture.ApproverBID}, now)
	params.PendingKey = sha256Hex("pending-applicant-cancel")
	instance, assignments, _, err := store.Create(ctx, params)
	require.NoError(t, err)

	result, err := store.CancelByApplicant(ctx, ApplicantCancelParams{
		WorkspaceID: fixture.WorkspaceID, ApprovalID: instance.ID,
		ActorMemberID: fixture.ApplicantID, ExpectedVersion: instance.Version,
		ClientCancellationID: "cancel-1", Reason: "withdrawn_by_applicant",
		Now: now.Add(time.Second),
	})
	require.NoError(t, err)
	require.False(t, result.AlreadyApplied)
	require.Equal(t, StateCancelled, result.Instance.State)
	require.Equal(t, StateCancelled, result.Instance.Decision)
	require.Equal(t, "withdrawn_by_applicant", result.Instance.DecisionReason)
	require.Equal(t, fixture.ApplicantID, result.Instance.DecidedByMemberID)
	require.Empty(t, result.Instance.PendingKey)
	require.Equal(t, int64(2), result.Instance.Version)
	require.Equal(t, DecisionCancelled, result.Receipt.Decision)
	require.Equal(t, assignments[0].ID, result.Receipt.AssignmentID)

	closed, err := store.ListAssignments(ctx, fixture.WorkspaceID, instance.ID)
	require.NoError(t, err)
	require.Len(t, closed, 2)
	require.Equal(t, AssignmentCancelled, closed[0].Status)
	require.Equal(t, AssignmentCancelled, closed[1].Status)

	replay, err := store.CancelByApplicant(ctx, ApplicantCancelParams{
		WorkspaceID: fixture.WorkspaceID, ApprovalID: instance.ID,
		ActorMemberID: fixture.ApplicantID, ExpectedVersion: instance.Version,
		ClientCancellationID: "cancel-1", Reason: "withdrawn_by_applicant",
		Now: now.Add(2 * time.Second),
	})
	require.NoError(t, err)
	require.True(t, replay.AlreadyApplied)
	require.Equal(t, result.Receipt.ID, replay.Receipt.ID)

	_, err = store.CancelByApplicant(ctx, ApplicantCancelParams{
		WorkspaceID: fixture.WorkspaceID, ApprovalID: instance.ID,
		ActorMemberID: fixture.OutsiderID, ExpectedVersion: instance.Version,
		ClientCancellationID: "cancel-outsider", Reason: "withdrawn_by_applicant",
	})
	require.ErrorIs(t, err, storeerr.ErrForbidden)
}

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

	ctx := context.Background()
	db := startApprovalRollbackDB(t, ctx)
	now := time.Now().UTC().Truncate(time.Second)
	fixture := seedApprovalFixture(t, db, "domain-cancel", now)
	store := New(db)
	params := createParams(fixture, "appr_domain_cancel", []string{fixture.ApproverAID}, now)
	params.PendingKey = sha256Hex("pending-domain-cancel")
	instance, assignments, _, err := store.Create(ctx, params)
	require.NoError(t, err)

	// The domain owner is not the applicant. The owning service performs that
	// authorization before entering this generic transactional primitive.
	tx, err := db.BeginTx(ctx, coredb.TxOptions{})
	require.NoError(t, err)
	result, err := store.InTx(tx).CancelByDomain(ctx, DomainCancelParams{
		WorkspaceID: fixture.WorkspaceID, ApprovalID: instance.ID,
		ActorMemberID: fixture.ApproverBID, ExpectedVersion: instance.Version,
		ClientCancellationID: "delete-team-workflow", Reason: "subject_deleted",
		Now: now.Add(time.Second),
	})
	require.NoError(t, err)
	require.Equal(t, StateCancelled, result.Instance.State)
	require.Equal(t, fixture.ApproverBID, result.Instance.DecidedByMemberID)
	require.Equal(t, assignments[0].ID, result.Receipt.AssignmentID)
	require.NoError(t, tx.Rollback(ctx))

	reloaded, err := store.Load(ctx, fixture.WorkspaceID, instance.ID)
	require.NoError(t, err)
	require.Equal(t, StatePending, reloaded.State)
	require.Equal(t, params.PendingKey, reloaded.PendingKey)

	tx, err = db.BeginTx(ctx, coredb.TxOptions{})
	require.NoError(t, err)
	committed, err := store.InTx(tx).CancelByDomain(ctx, DomainCancelParams{
		WorkspaceID: fixture.WorkspaceID, ApprovalID: instance.ID,
		ActorMemberID: fixture.ApproverBID, ExpectedVersion: instance.Version,
		ClientCancellationID: "delete-team-workflow", Reason: "subject_deleted",
		Now: now.Add(2 * time.Second),
	})
	require.NoError(t, err)
	require.NoError(t, tx.Commit(ctx))
	require.False(t, committed.AlreadyApplied)

	closed, err := store.ListAssignments(ctx, fixture.WorkspaceID, instance.ID)
	require.NoError(t, err)
	require.Equal(t, AssignmentCancelled, closed[0].Status)
}

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

	ctx := context.Background()
	db := startApprovalRollbackDB(t, ctx)
	now := time.Now().UTC().Truncate(time.Second)
	fixture := seedApprovalFixture(t, db, "agent-applicant", now)
	store := New(db)

	agentApplicant := createParams(fixture, "appr_agent_applicant", []string{fixture.ApproverAID}, now)
	agentApplicant.ApplicantMemberID = fixture.AgentID
	agentApplicant.AllowAgentApplicant = true
	instance, assignments, created, err := store.Create(ctx, agentApplicant)
	require.NoError(t, err)
	require.True(t, created)
	require.Equal(t, fixture.AgentID, instance.ApplicantMemberID)
	require.Equal(t, fixture.ApproverAID, assignments[0].ApproverMemberID)

	agentApplicantWithoutOptIn := createParams(
		fixture,
		"appr_agent_applicant_without_opt_in",
		[]string{fixture.ApproverAID},
		now,
	)
	agentApplicantWithoutOptIn.ApplicantMemberID = fixture.AgentID
	_, _, _, err = store.Create(ctx, agentApplicantWithoutOptIn)
	require.ErrorIs(t, err, storeerr.ErrConflict)

	agentApprover := createParams(fixture, "appr_agent_approver", []string{fixture.AgentID}, now)
	agentApprover.SubjectRef = "request-agent-approver"
	_, _, _, err = store.Create(ctx, agentApprover)
	require.ErrorIs(t, err, storeerr.ErrConflict)
}

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

	ctx := context.Background()
	db := startApprovalRollbackDB(t, ctx)
	now := time.Now().UTC().Truncate(time.Second)
	fixture := seedApprovalFixture(t, db, "decide", now)
	store := New(db)

	approved, approvedAssignments, _, err := store.Create(ctx, createParams(fixture, "appr_decide_approve", []string{fixture.ApproverAID, fixture.ApproverBID}, now))
	require.NoError(t, err)
	result, err := store.Decide(ctx, DecideParams{
		WorkspaceID: fixture.WorkspaceID, ApprovalID: approved.ID,
		AssignmentID: approvedAssignments[0].ID, ActorMemberID: fixture.ApproverAID,
		ExpectedVersion: approved.Version, ClientDecisionID: "decision-approve",
		Decision: DecisionApproved, Reason: "looks good", Now: now.Add(time.Second),
	})
	require.NoError(t, err)
	require.False(t, result.AlreadyApplied)
	require.Equal(t, StateApproved, result.Instance.State)
	require.Equal(t, DecisionApproved, result.Instance.Decision)
	require.Equal(t, int64(2), result.Instance.Version)
	require.Equal(t, AssignmentApproved, result.Assignment.Status)
	require.Equal(t, int64(2), result.Receipt.ResultVersion)
	require.Equal(t, fixture.ApproverAID, result.Instance.DecidedByMemberID)

	allAssignments, err := store.ListAssignments(ctx, fixture.WorkspaceID, approved.ID)
	require.NoError(t, err)
	require.Equal(t, AssignmentApproved, allAssignments[0].Status)
	require.Equal(t, AssignmentSuperseded, allAssignments[1].Status)

	replay, err := store.Decide(ctx, DecideParams{
		WorkspaceID: fixture.WorkspaceID, ApprovalID: approved.ID,
		AssignmentID: approvedAssignments[0].ID, ActorMemberID: fixture.ApproverAID,
		ExpectedVersion: approved.Version, ClientDecisionID: "decision-approve",
		Decision: DecisionApproved, Reason: "looks good", Now: now.Add(2 * time.Second),
	})
	require.NoError(t, err)
	require.True(t, replay.AlreadyApplied)
	require.Equal(t, result.Receipt.ID, replay.Receipt.ID)

	_, err = store.Decide(ctx, DecideParams{
		WorkspaceID: fixture.WorkspaceID, ApprovalID: approved.ID,
		AssignmentID: approvedAssignments[0].ID, ActorMemberID: fixture.ApproverAID,
		ExpectedVersion: approved.Version, ClientDecisionID: "decision-approve",
		Decision: DecisionRejected, Reason: "different", Now: now.Add(3 * time.Second),
	})
	require.ErrorIs(t, err, storeerr.ErrConflict)

	rejectedParams := createParams(fixture, "appr_decide_reject", []string{fixture.ApproverBID}, now)
	rejectedParams.SubjectRef = "request-reject"
	rejected, rejectedAssignments, _, err := store.Create(ctx, rejectedParams)
	require.NoError(t, err)

	_, err = store.Decide(ctx, DecideParams{
		WorkspaceID: fixture.WorkspaceID, ApprovalID: rejected.ID,
		AssignmentID: rejectedAssignments[0].ID, ActorMemberID: fixture.ApproverBID,
		ExpectedVersion: rejected.Version + 1, ClientDecisionID: "decision-stale",
		Decision: DecisionRejected, Reason: "no", Now: now.Add(time.Second),
	})
	require.ErrorIs(t, err, storeerr.ErrConflict)

	rejectedResult, err := store.Decide(ctx, DecideParams{
		WorkspaceID: fixture.WorkspaceID, ApprovalID: rejected.ID,
		AssignmentID: rejectedAssignments[0].ID, ActorMemberID: fixture.ApproverBID,
		ExpectedVersion: rejected.Version, ClientDecisionID: "decision-reject",
		Decision: DecisionRejected, Reason: "not allowed", Now: now.Add(2 * time.Second),
	})
	require.NoError(t, err)
	require.Equal(t, StateRejected, rejectedResult.Instance.State)
	require.Equal(t, AssignmentRejected, rejectedResult.Assignment.Status)
}

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

	ctx := context.Background()
	db := startApprovalRollbackDB(t, ctx)
	now := time.Now().UTC().Truncate(time.Second)
	fixture := seedApprovalFixture(t, db, "decide-rollback", now)
	store := New(db)
	instance, assignments, _, err := store.Create(ctx, createParams(fixture, "appr_decide_rollback", []string{fixture.ApproverAID}, now))
	require.NoError(t, err)

	tx, err := db.BeginTx(ctx, coredb.TxOptions{})
	require.NoError(t, err)
	result, err := store.InTx(tx).Decide(ctx, DecideParams{
		WorkspaceID: fixture.WorkspaceID, ApprovalID: instance.ID,
		AssignmentID: assignments[0].ID, ActorMemberID: fixture.ApproverAID,
		ExpectedVersion: instance.Version, ClientDecisionID: "decision-rollback",
		Decision: DecisionApproved, Reason: "caller will roll back", Now: now.Add(time.Second),
	})
	require.NoError(t, err)
	require.Equal(t, StateApproved, result.Instance.State)
	require.NoError(t, tx.Rollback(ctx))

	reloaded, err := store.Load(ctx, fixture.WorkspaceID, instance.ID)
	require.NoError(t, err)
	require.Equal(t, StatePending, reloaded.State)
	require.Equal(t, int64(1), reloaded.Version)
	reloadedAssignments, err := store.ListAssignments(ctx, fixture.WorkspaceID, instance.ID)
	require.NoError(t, err)
	require.Equal(t, AssignmentPending, reloadedAssignments[0].Status)

	_, err = store.Decide(ctx, DecideParams{
		WorkspaceID: fixture.WorkspaceID, ApprovalID: instance.ID,
		AssignmentID: assignments[0].ID, ActorMemberID: fixture.ApproverAID,
		ExpectedVersion: instance.Version, ClientDecisionID: "decision-rollback",
		Decision: DecisionApproved, Reason: "caller will roll back", Now: now.Add(2 * time.Second),
	})
	require.NoError(t, err)
}

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

	ctx := context.Background()
	db := startApprovalRollbackDB(t, ctx)
	now := time.Now().UTC().Truncate(time.Second)
	fixture := seedApprovalFixture(t, db, "decision-effect", now)
	store := New(db)

	params := createParams(fixture, "appr_effect_error", []string{fixture.ApproverAID}, now)
	params.PendingKey = sha256Hex("pending-effect-error")
	instance, assignments, _, err := store.Create(ctx, params)
	require.NoError(t, err)
	effectErr := errors.New("apply failed")
	_, err = store.DecideWithEffect(ctx, DecideParams{
		WorkspaceID: fixture.WorkspaceID, ApprovalID: instance.ID,
		AssignmentID: assignments[0].ID, ActorMemberID: fixture.ApproverAID,
		ExpectedVersion: instance.Version, ClientDecisionID: "effect-error",
		Decision: DecisionApproved, Now: now.Add(time.Second),
	}, func(context.Context, coredb.Tx, Instance, string) (DecisionEffectResult, error) {
		return DecisionEffectResult{}, effectErr
	})
	require.ErrorIs(t, err, effectErr)
	unchanged, err := store.Load(ctx, fixture.WorkspaceID, instance.ID)
	require.NoError(t, err)
	require.Equal(t, StatePending, unchanged.State)
	require.Equal(t, params.PendingKey, unchanged.PendingKey)

	successParams := createParams(fixture, "appr_effect_success", []string{fixture.ApproverAID}, now)
	successParams.PendingKey = sha256Hex("pending-effect-success")
	success, successAssignments, _, err := store.Create(ctx, successParams)
	require.NoError(t, err)
	successResult, err := store.DecideWithEffect(ctx, DecideParams{
		WorkspaceID: fixture.WorkspaceID, ApprovalID: success.ID,
		AssignmentID: successAssignments[0].ID, ActorMemberID: fixture.ApproverAID,
		ExpectedVersion: success.Version, ClientDecisionID: "effect-success",
		Decision: DecisionApproved, Now: now.Add(2 * time.Second),
	}, func(effectCtx context.Context, tx coredb.Tx, _ Instance, _ string) (DecisionEffectResult, error) {
		_, err := tx.Exec(effectCtx, `
UPDATE members SET display_name='effect-applied' WHERE id=$1
`, fixture.ApplicantID)
		return DecisionEffectResult{}, err
	})
	require.NoError(t, err)
	require.Equal(t, StateApproved, successResult.Instance.State)
	require.Empty(t, successResult.Instance.PendingKey)
	var displayName string
	require.NoError(t, db.QueryRow(ctx, `SELECT display_name FROM members WHERE id=$1`, fixture.ApplicantID).Scan(&displayName))
	require.Equal(t, "effect-applied", displayName)

	staleParams := createParams(fixture, "appr_effect_stale", []string{fixture.ApproverAID}, now)
	staleParams.PendingKey = sha256Hex("pending-effect-stale")
	stale, staleAssignments, _, err := store.Create(ctx, staleParams)
	require.NoError(t, err)
	result, err := store.DecideWithEffect(ctx, DecideParams{
		WorkspaceID: fixture.WorkspaceID, ApprovalID: stale.ID,
		AssignmentID: staleAssignments[0].ID, ActorMemberID: fixture.ApproverAID,
		ExpectedVersion: stale.Version, ClientDecisionID: "effect-stale",
		Decision: DecisionApproved, Reason: "approve requested", Now: now.Add(3 * time.Second),
	}, func(_ context.Context, _ coredb.Tx, got Instance, decision string) (DecisionEffectResult, error) {
		require.Equal(t, stale.ID, got.ID)
		require.Equal(t, DecisionApproved, decision)
		return DecisionEffectResult{TerminalState: StateCancelled, DecisionReason: "stale_subject"}, nil
	})
	require.NoError(t, err)
	require.Equal(t, StateCancelled, result.Instance.State)
	require.Equal(t, StateCancelled, result.Instance.Decision)
	require.Equal(t, "stale_subject", result.Instance.DecisionReason)
	require.Empty(t, result.Instance.PendingKey)
	require.Equal(t, AssignmentCancelled, result.Assignment.Status)
	require.Equal(t, DecisionApproved, result.Receipt.Decision)
	require.Equal(t, "approve requested", result.Receipt.Reason)
	require.Equal(t, result.Instance.Version, result.Receipt.ResultVersion)
}

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

	ctx := context.Background()
	db := startApprovalRollbackDB(t, ctx)
	now := time.Now().UTC().Truncate(time.Second)
	fixture := seedApprovalFixture(t, db, "actor", now)
	store := New(db)
	instance, assignments, _, err := store.Create(ctx, createParams(fixture, "appr_actor", []string{fixture.ApproverAID}, now))
	require.NoError(t, err)

	_, err = store.Decide(ctx, DecideParams{
		WorkspaceID: fixture.WorkspaceID, ApprovalID: instance.ID,
		AssignmentID: assignments[0].ID, ActorMemberID: fixture.ApproverBID,
		ExpectedVersion: instance.Version, ClientDecisionID: "wrong-actor",
		Decision: DecisionApproved, Now: now.Add(time.Second),
	})
	require.ErrorIs(t, err, storeerr.ErrForbidden)
}

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

	ctx := context.Background()
	rootDSN := approvalTiDB.RootDSN(t)
	dsn := testkit.StartInitializedTiDBWithRootDSN(t, rootDSN)
	sqlDB, err := testkit.OpenTiDBContext(ctx, dsn)
	require.NoError(t, err)
	sqlDB.SetMaxOpenConns(4)
	sqlDB.SetMaxIdleConns(4)
	t.Cleanup(func() { require.NoError(t, sqlDB.Close()) })
	db := coredb.WrapSQLDB(sqlDB)
	now := time.Now().UTC().Truncate(time.Second)
	fixture := seedApprovalFixture(t, db, "concurrent", now)
	store := New(db)
	instance, assignments, _, err := store.Create(ctx, createParams(fixture, "appr_concurrent", []string{fixture.ApproverAID, fixture.ApproverBID}, now))
	require.NoError(t, err)

	start := make(chan struct{})
	results := make(chan error, 2)
	var wg sync.WaitGroup
	for index, assignment := range assignments {
		index := index
		assignment := assignment
		wg.Add(1)
		go func() {
			defer wg.Done()
			<-start
			_, err := store.Decide(ctx, DecideParams{
				WorkspaceID: fixture.WorkspaceID, ApprovalID: instance.ID,
				AssignmentID: assignment.ID, ActorMemberID: assignment.ApproverMemberID,
				ExpectedVersion: instance.Version, ClientDecisionID: []string{"concurrent-a", "concurrent-b"}[index],
				Decision: []string{DecisionApproved, DecisionRejected}[index],
				Now:      now.Add(time.Second),
			})
			results <- err
		}()
	}
	close(start)
	wg.Wait()
	close(results)

	successes := 0
	conflicts := 0
	for err := range results {
		switch {
		case err == nil:
			successes++
		case errors.Is(err, storeerr.ErrConflict):
			conflicts++
		default:
			require.NoError(t, err)
		}
	}
	require.Equal(t, 1, successes)
	require.Equal(t, 1, conflicts)

	loaded, err := store.Load(ctx, fixture.WorkspaceID, instance.ID)
	require.NoError(t, err)
	require.Contains(t, []string{StateApproved, StateRejected}, loaded.State)
	require.Equal(t, int64(2), loaded.Version)
}

func createParams(fixture approvalFixture, id string, approvers []string, now time.Time) CreateParams {
	return CreateParams{
		ID: id, WorkspaceID: fixture.WorkspaceID,
		SubjectType: "service_account_create_request", SubjectRef: "request-" + id,
		SubjectVersion: 1, PayloadDigest: sha256Hex("payload-" + id),
		ApplicantMemberID: fixture.ApplicantID, ApproverMemberIDs: approvers,
		CorrelationID: "cor-" + id, Now: now,
	}
}

func sha256Hex(value string) string {
	digest := sha256.Sum256([]byte(value))
	return hex.EncodeToString(digest[:])
}

func seedApprovalFixture(t testing.TB, db coredb.QueryExecer, suffix string, now time.Time) approvalFixture {
	t.Helper()
	fixture := approvalFixture{
		WorkspaceID: "wsp_approval_" + suffix,
		ApplicantID: "mem_approval_applicant_" + suffix,
		AgentID:     "mem_approval_agent_" + suffix,
		ApproverAID: "mem_approval_approver_a_" + suffix,
		ApproverBID: "mem_approval_approver_b_" + suffix,
		OutsiderID:  "mem_approval_inactive_" + suffix,
	}
	userIDs := []string{
		"usr_approval_applicant_" + suffix,
		"usr_approval_approver_a_" + suffix,
		"usr_approval_approver_b_" + suffix,
		"usr_approval_inactive_" + suffix,
	}
	orgID := "org_approval_" + suffix
	for index, userID := range userIDs {
		mustExecApproval(t, db, `
INSERT INTO users (id, email, name, display_name, password_hash, created_at, updated_at)
VALUES ($1, $2, $3, $3, 'hash', $4, $4)
`, userID, userID+"@example.com", "approval-"+suffix+"-"+string(rune('a'+index)), now)
	}
	mustExecApproval(t, db, `
INSERT INTO organizations (id, slug, name, created_by_user_id, created_at, updated_at)
VALUES ($1, $2, $2, $3, $4, $4)
`, orgID, "approval-"+suffix, userIDs[0], now)
	mustExecApproval(t, db, `
INSERT INTO workspaces (id, organization_id, slug, name, created_at, updated_at)
VALUES ($1, $2, $3, $3, $4, $4)
`, fixture.WorkspaceID, orgID, "approval-"+suffix, now)
	for index, memberID := range []string{fixture.ApplicantID, fixture.ApproverAID, fixture.ApproverBID, fixture.OutsiderID} {
		status := "active"
		if memberID == fixture.OutsiderID {
			status = "removed"
		}
		mustExecApproval(t, db, `
INSERT INTO members (id, workspace_id, type, display_name, user_id, workspace_role, status, created_at, updated_at)
VALUES ($1, $2, 'human', $3, $4, $5, $6, $7, $7)
`, memberID, fixture.WorkspaceID, memberID, userIDs[index], []string{"member", "admin", "admin", "member"}[index], status, now)
	}
	mustExecApproval(t, db, `
INSERT INTO members (id, workspace_id, type, display_name, status, created_at, updated_at)
VALUES ($1, $2, 'agent', $1, 'active', $3, $3)
`, fixture.AgentID, fixture.WorkspaceID, now)
	return fixture
}
