package roleproposal

import (
	"context"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"os"
	"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/agent"
	"github.com/tidbcloud/AgentTeam/internal/store/approval"
	"github.com/tidbcloud/AgentTeam/internal/store/storeerr"
)

var proposalTiDB testkit.TiDBCoreDBFixture

func TestMain(m *testing.M) {
	os.Exit(testkit.RunTestsWithTiDBCoreDBFixture(m, "teamroleproposal", &proposalTiDB))
}

type proposalFixture struct {
	WorkspaceID       string
	OwnerMemberID     string
	LeaderAgentID     string
	LeaderMemberID    string
	LeaderTeamMember  string
	WorkerAgentID     string
	WorkerMemberID    string
	WorkerTeamMember  string
	TeamID            string
	AuthenticatedLead agent.AuthenticatedAgent
}

func TestSubmitCreatesProposalAndApprovalAndReplaysClientRequest(t *testing.T) {
	t.Parallel()
	ctx := context.Background()
	db := startProposalRollbackDB(t, ctx)
	now := time.Date(2026, 7, 18, 8, 0, 0, 0, time.UTC)
	fixture := seedProposalFixture(t, ctx, db, "submit", now)
	service := New(db)
	input := SubmitInput{
		TeamID:          fixture.TeamID,
		ClientRequestID: " client-submit ",
		Description:     proposalString("  Proposed description  "),
		Members: []MemberInput{
			{TeamMemberID: fixture.WorkerTeamMember, RoleOverlayPrompt: proposalString(" Worker overlay v2 ")},
			{TeamMemberID: fixture.LeaderTeamMember, RoleOverlayPrompt: proposalString(" Leader overlay v2 ")},
		},
		CorrelationID: "cor-submit",
		Now:           now.Add(time.Minute),
	}
	// Caller input deliberately has no display-name field. Names in the durable
	// payload must come from the trusted Team/Agent facts loaded by Submit.

	result, err := service.Submit(ctx, fixture.AuthenticatedLead, input)
	require.NoError(t, err)
	require.True(t, result.Created)
	require.Equal(t, StatusPending, result.Proposal.Status)
	require.Equal(t, fixture.LeaderAgentID, result.Proposal.ApplicantAgentID)
	require.Equal(t, fixture.LeaderMemberID, result.Proposal.ApplicantMemberID)
	require.Equal(t, fixture.LeaderTeamMember, result.Proposal.ApplicantTeamMemberID)
	require.Equal(t, fixture.OwnerMemberID, result.Assignments[0].ApproverMemberID)
	require.Equal(t, fixture.TeamID, result.Approval.SubjectRef)
	require.Equal(t, SubjectType, result.Approval.SubjectType)
	require.Equal(t, result.Proposal.PayloadDigest, result.Approval.PayloadDigest)
	require.Empty(t, result.Approval.PendingKey)

	var payload Payload
	require.NoError(t, json.Unmarshal(result.Proposal.Payload, &payload))
	require.Equal(t, PayloadSchemaVersion, payload.SchemaVersion)
	require.Equal(t, fixture.LeaderMemberID, payload.Applicant.MemberID)
	require.Equal(t, "Proposed description", *payload.Proposed.Description)
	require.Equal(t, fixture.LeaderAgentID, payload.Base.Members[0].MemberName)
	require.Equal(t, fixture.LeaderMemberID, payload.Base.Members[0].MemberID)
	require.Equal(t, fixture.WorkerAgentID, payload.Base.Members[1].MemberName)
	require.Equal(t, fixture.WorkerMemberID, payload.Base.Members[1].MemberID)
	require.Equal(t, []MemberPayload{
		{TeamMemberID: fixture.LeaderTeamMember, MemberName: fixture.LeaderAgentID, RoleOverlayPrompt: proposalString("Leader overlay v2")},
		{TeamMemberID: fixture.WorkerTeamMember, MemberName: fixture.WorkerAgentID, RoleOverlayPrompt: proposalString("Worker overlay v2")},
	}, payload.Proposed.Members)
	var expectedPayloadValue any
	require.NoError(t, json.Unmarshal(result.Proposal.Payload, &expectedPayloadValue))
	require.Equal(t, sha256Hex(mustJSON(t, expectedPayloadValue)), result.Proposal.PayloadDigest)

	replay, err := service.Submit(ctx, fixture.AuthenticatedLead, input)
	require.NoError(t, err)
	require.False(t, replay.Created)
	require.Equal(t, result.Proposal.ID, replay.Proposal.ID)
	require.Equal(t, result.Approval.ID, replay.Approval.ID)

	changed := input
	changed.Description = proposalString("different")
	_, err = service.Submit(ctx, fixture.AuthenticatedLead, changed)
	require.ErrorIs(t, err, storeerr.ErrConflict)

	reordered := input
	reordered.Members = []MemberInput{input.Members[1], input.Members[0]}
	reordered.Members[0].RoleOverlayPrompt = proposalString("Leader overlay v2")
	reordered.Members[1].RoleOverlayPrompt = proposalString("Worker overlay v2")
	replay, err = service.Submit(ctx, fixture.AuthenticatedLead, reordered)
	require.NoError(t, err)
	require.False(t, replay.Created)
	require.Equal(t, result.Proposal.ID, replay.Proposal.ID)

	// Simulate a pending proposal created by a pre-upgrade server. Its fixed
	// non-NULL key must not block a new proposal created by this version.
	mustExecProposal(t, ctx, db, `
UPDATE approval_instances SET pending_key=$2 WHERE id=$1
`, result.Approval.ID, sha256Hex([]byte("legacy-team-role-pending-key")))
	concurrent := input
	concurrent.ClientRequestID = "client-submit-concurrent"
	concurrent.Description = proposalString("Concurrent pending proposal")
	concurrentResult, err := service.Submit(ctx, fixture.AuthenticatedLead, concurrent)
	require.NoError(t, err)
	require.True(t, concurrentResult.Created)
	require.Equal(t, approval.StatePending, concurrentResult.Approval.State)
	require.Empty(t, concurrentResult.Approval.PendingKey)
	require.NotEqual(t, result.Approval.ID, concurrentResult.Approval.ID)
	require.NotEqual(t, result.Approval.SubjectVersion, concurrentResult.Approval.SubjectVersion)

	mustExecProposal(t, ctx, db, `
UPDATE agents SET name='Renamed after submit' WHERE id=$1
`, fixture.WorkerAgentID)
	loaded, err := service.Load(ctx, fixture.WorkspaceID, result.Approval.ID)
	require.NoError(t, err)
	var historical Payload
	require.NoError(t, json.Unmarshal(loaded.Payload, &historical))
	require.Equal(t, fixture.WorkerAgentID, historical.Base.Members[1].MemberName)
	require.Equal(t, fixture.WorkerAgentID, historical.Proposed.Members[1].MemberName)
}

func TestMemberDisplayNameDoesNotParticipateInStaleFenceDigest(t *testing.T) {
	t.Parallel()
	base := Base{
		Team: BaseTeam{
			Description: "description",
		},
		Members: []BaseMember{{
			TeamMemberID: "wtm_member", MemberName: "Name at submission",
			MemberID: "mem_agent", Role: "member",
			RoleOverlayPrompt: "role", RoleOverlayVersion: 3,
		}},
	}
	first := digestBase(base)
	base.Members[0].MemberName = "Renamed later"
	require.Equal(t, first, digestBase(base))
}

func TestSubmitClientRequestIDIsScopedToTeamSubject(t *testing.T) {
	t.Parallel()
	ctx := context.Background()
	db := startProposalRollbackDB(t, ctx)
	now := time.Date(2026, 7, 18, 8, 30, 0, 0, time.UTC)
	first := seedProposalFixture(t, ctx, db, "submission-scope-a", now)
	second := first
	second.TeamID = "team_trp_submission_scope_b"
	second.LeaderTeamMember = "wtm_trp_leader_submission_scope_b"
	second.WorkerTeamMember = "wtm_trp_worker_submission_scope_b"
	mustExecProposal(t, ctx, db, `
INSERT INTO workspace_teams (
 id, workspace_id, handle, display_name, description, avatar_url, visibility, status,
 created_by_member_id,
 created_at, updated_at, archived_at, active_handle_key
)
SELECT $1, workspace_id, $2, $2, description, avatar_url, visibility, status,
       created_by_member_id,
       created_at, updated_at, archived_at, CONCAT(workspace_id, ':', $2)
FROM workspace_teams WHERE id=$3
`, second.TeamID, "team-submission-scope-b", first.TeamID)
	mustExecProposal(t, ctx, db, `
INSERT INTO workspace_team_members (
 id, workspace_id, team_id, actor_kind, actor_id, role, role_label,
 role_overlay_prompt, role_overlay_version, wake_on_team_mention, sort_order,
 added_by_member_id, added_at, updated_at, removed_at, active_member_key,
 active_agent_team_key, active_leader_key
)
SELECT CASE role WHEN 'leader' THEN $1 ELSE $2 END, workspace_id, $3,
       actor_kind, actor_id, role, role_label, role_overlay_prompt,
       role_overlay_version, wake_on_team_mention, sort_order, added_by_member_id,
       added_at, updated_at, removed_at,
       CONCAT($3, ':agent:', actor_id), CONCAT(workspace_id, ':', actor_id, ':', $3),
       CASE role WHEN 'leader' THEN CONCAT(workspace_id, ':', $3) ELSE NULL END
FROM workspace_team_members WHERE team_id=$4
`, second.LeaderTeamMember, second.WorkerTeamMember, second.TeamID, first.TeamID)
	service := New(db)
	input := func(fixture proposalFixture) SubmitInput {
		return SubmitInput{
			TeamID: fixture.TeamID, ClientRequestID: "shared-client-id",
			Description: proposalString("description"),
			Members: []MemberInput{
				{TeamMemberID: fixture.LeaderTeamMember, RoleOverlayPrompt: proposalString("leader")},
				{TeamMemberID: fixture.WorkerTeamMember, RoleOverlayPrompt: proposalString("worker")},
			},
			Now: now.Add(time.Minute),
		}
	}

	firstResult, err := service.Submit(ctx, first.AuthenticatedLead, input(first))
	require.NoError(t, err)
	secondResult, err := service.Submit(ctx, second.AuthenticatedLead, input(second))
	require.NoError(t, err)
	require.NotEqual(t, firstResult.Approval.ID, secondResult.Approval.ID)
	require.Equal(t, first.TeamID, firstResult.Approval.SubjectRef)
	require.Equal(t, second.TeamID, secondResult.Approval.SubjectRef)
}

func TestWithdrawRequiresApplicantCurrentLeaderAndIsIdempotent(t *testing.T) {
	t.Parallel()
	ctx := context.Background()
	db := startProposalRollbackDB(t, ctx)
	now := time.Date(2026, 7, 18, 8, 45, 0, 0, time.UTC)
	fixture := seedProposalFixture(t, ctx, db, "withdraw", now)
	service := New(db)
	submitted := submitProposal(t, ctx, service, fixture, "withdraw-submit", now)

	withdrawn, err := service.Withdraw(ctx, fixture.AuthenticatedLead, WithdrawInput{
		ApprovalID: submitted.Approval.ID, ExpectedVersion: submitted.Approval.Version,
		ClientRequestID: "withdraw-1", Now: now.Add(2 * time.Minute),
	})
	require.NoError(t, err)
	require.False(t, withdrawn.AlreadyClosed)
	require.Equal(t, approval.StateCancelled, withdrawn.Approval.Instance.State)
	require.Equal(t, "withdrawn_by_leader", withdrawn.Approval.Instance.DecisionReason)
	require.Empty(t, withdrawn.Approval.Instance.PendingKey)

	replay, err := service.Withdraw(ctx, fixture.AuthenticatedLead, WithdrawInput{
		ApprovalID: submitted.Approval.ID, ExpectedVersion: submitted.Approval.Version,
		ClientRequestID: "withdraw-1", Now: now.Add(3 * time.Minute),
	})
	require.NoError(t, err)
	require.True(t, replay.AlreadyClosed)

	versionFixture := seedProposalFixture(t, ctx, db, "withdraw-version", now)
	versionProposal := submitProposal(t, ctx, service, versionFixture, "withdraw-version-submit", now)
	_, err = service.Withdraw(ctx, versionFixture.AuthenticatedLead, WithdrawInput{
		ApprovalID: versionProposal.Approval.ID, ExpectedVersion: versionProposal.Approval.Version + 1,
		ClientRequestID: "withdraw-stale-version",
	})
	require.ErrorIs(t, err, storeerr.ErrConflict)
	_, err = service.Decide(ctx, approval.DecideParams{
		WorkspaceID: versionFixture.WorkspaceID, ApprovalID: versionProposal.Approval.ID,
		AssignmentID: versionProposal.Assignments[0].ID, ActorMemberID: versionFixture.OwnerMemberID,
		ExpectedVersion: versionProposal.Approval.Version, ClientDecisionID: "reject-before-withdraw",
		Decision: approval.DecisionRejected, Now: now.Add(2 * time.Minute),
	})
	require.NoError(t, err)
	_, err = service.Withdraw(ctx, versionFixture.AuthenticatedLead, WithdrawInput{
		ApprovalID: versionProposal.Approval.ID, ExpectedVersion: versionProposal.Approval.Version,
		ClientRequestID: "withdraw-terminal",
	})
	require.ErrorIs(t, err, storeerr.ErrConflict)

	other := seedProposalFixture(t, ctx, db, "withdraw-other", now)
	otherProposal := submitProposal(t, ctx, service, other, "withdraw-other-submit", now)
	forged := other.AuthenticatedLead
	forged.MemberID = other.WorkerMemberID
	forged.AgentID = other.WorkerAgentID
	_, err = service.Withdraw(ctx, forged, WithdrawInput{
		ApprovalID: otherProposal.Approval.ID, ExpectedVersion: otherProposal.Approval.Version,
		ClientRequestID: "withdraw-forged",
	})
	require.ErrorIs(t, err, storeerr.ErrForbidden)

	mustExecProposal(t, ctx, db, `
UPDATE workspace_team_members
SET role='member', active_leader_key=NULL
WHERE id=$1
`, other.LeaderTeamMember)
	_, err = service.Withdraw(ctx, other.AuthenticatedLead, WithdrawInput{
		ApprovalID: otherProposal.Approval.ID, ExpectedVersion: otherProposal.Approval.Version,
		ClientRequestID: "withdraw-no-longer-leader",
	})
	require.ErrorIs(t, err, storeerr.ErrForbidden)
}

func TestSubmitRequiresCurrentActiveLeaderAndAcceptsSparseMemberUpdates(t *testing.T) {
	t.Parallel()
	ctx := context.Background()
	db := startProposalRollbackDB(t, ctx)
	now := time.Date(2026, 7, 18, 9, 0, 0, 0, time.UTC)
	fixture := seedProposalFixture(t, ctx, db, "auth", now)
	service := New(db)

	input := SubmitInput{
		TeamID: fixture.TeamID, ClientRequestID: "missing-member",
		Description: proposalString("description"),
		Members:     []MemberInput{{TeamMemberID: fixture.LeaderTeamMember, RoleOverlayPrompt: proposalString("leader")}},
		Now:         now.Add(time.Minute),
	}
	_, err := service.Submit(ctx, fixture.AuthenticatedLead, input)
	require.NoError(t, err)

	worker := agent.AuthenticatedAgent{
		AgentID: fixture.WorkerAgentID, WorkspaceID: fixture.WorkspaceID, MemberID: fixture.WorkerMemberID,
	}
	input.ClientRequestID = "worker-submit"
	input.Members = append(input.Members, MemberInput{TeamMemberID: fixture.WorkerTeamMember, RoleOverlayPrompt: proposalString("worker")})
	_, err = service.Submit(ctx, worker, input)
	require.ErrorIs(t, err, storeerr.ErrForbidden)

	spoofed := fixture.AuthenticatedLead
	spoofed.AgentID = fixture.WorkerAgentID
	input.ClientRequestID = "spoofed-agent"
	_, err = service.Submit(ctx, spoofed, input)
	require.ErrorIs(t, err, storeerr.ErrForbidden)
}

func TestSubmitReplayRequiresCurrentActiveTeamLeader(t *testing.T) {
	t.Parallel()
	ctx := context.Background()
	db := startProposalRollbackDB(t, ctx)
	now := time.Date(2026, 7, 18, 9, 30, 0, 0, time.UTC)
	service := New(db)

	formerLeader := seedProposalFixture(t, ctx, db, "replay-former-leader", now)
	submitProposal(t, ctx, service, formerLeader, "replay-former-leader", now)
	mustExecProposal(t, ctx, db, `
UPDATE workspace_team_members
SET role='member', active_leader_key=NULL
WHERE id=$1
`, formerLeader.LeaderTeamMember)
	_, err := service.Submit(
		ctx, formerLeader.AuthenticatedLead,
		proposalSubmitInput(formerLeader, "replay-former-leader", now),
	)
	require.ErrorIs(t, err, storeerr.ErrForbidden)

	archived := seedProposalFixture(t, ctx, db, "replay-archived", now)
	submitProposal(t, ctx, service, archived, "replay-archived", now)
	mustExecProposal(t, ctx, db, `
UPDATE workspace_teams
SET status='archived', active_handle_key=NULL, archived_at=$2
WHERE id=$1
`, archived.TeamID, now.Add(2*time.Minute))
	_, err = service.Submit(
		ctx, archived.AuthenticatedLead,
		proposalSubmitInput(archived, "replay-archived", now),
	)
	require.ErrorIs(t, err, storeerr.ErrConflict)
}

func TestDecideRejectTerminatesWithoutMutatingTeam(t *testing.T) {
	t.Parallel()
	ctx := context.Background()
	db := startProposalRollbackDB(t, ctx)
	now := time.Date(2026, 7, 18, 10, 0, 0, 0, time.UTC)
	fixture := seedProposalFixture(t, ctx, db, "reject", now)
	service := New(db)
	submitted := submitProposal(t, ctx, service, fixture, "reject", now)

	result, err := service.Decide(ctx, approval.DecideParams{
		WorkspaceID: fixture.WorkspaceID, ApprovalID: submitted.Approval.ID,
		AssignmentID: submitted.Assignments[0].ID, ActorMemberID: fixture.OwnerMemberID,
		ExpectedVersion: submitted.Approval.Version, ClientDecisionID: "reject-decision",
		Decision: approval.DecisionRejected, Reason: "not now", Now: now.Add(2 * time.Minute),
	})
	require.NoError(t, err)
	require.True(t, result.Changed)
	require.False(t, result.Applied)
	require.Equal(t, StatusRejected, result.Proposal.Status)
	assertTeamState(t, ctx, db, fixture, "Base description", "Leader overlay", 1, "Worker overlay", 1)

	replay, err := service.Decide(ctx, approval.DecideParams{
		WorkspaceID: fixture.WorkspaceID, ApprovalID: submitted.Approval.ID,
		AssignmentID: submitted.Assignments[0].ID, ActorMemberID: fixture.OwnerMemberID,
		ExpectedVersion: submitted.Approval.Version, ClientDecisionID: "reject-decision",
		Decision: approval.DecisionRejected, Reason: "not now", Now: now.Add(3 * time.Minute),
	})
	require.NoError(t, err)
	require.False(t, replay.Changed)
	require.True(t, replay.Approval.AlreadyApplied)

	sameRequest, err := service.Submit(
		ctx,
		fixture.AuthenticatedLead,
		proposalSubmitInput(fixture, "reject", now.Add(4*time.Minute)),
	)
	require.NoError(t, err)
	require.False(t, sameRequest.Created)
	require.Equal(t, submitted.Approval.ID, sameRequest.Approval.ID)
	require.Equal(t, StatusRejected, sameRequest.Proposal.Status)

	revised, err := service.Submit(
		ctx,
		fixture.AuthenticatedLead,
		proposalSubmitInput(fixture, "reject-revised", now.Add(5*time.Minute)),
	)
	require.NoError(t, err)
	require.True(t, revised.Created)
	require.NotEqual(t, submitted.Approval.ID, revised.Approval.ID)
	require.Equal(t, StatusPending, revised.Proposal.Status)
}

func TestDecideApproveAtomicallyAppliesTeamAndMemberVersions(t *testing.T) {
	t.Parallel()
	ctx := context.Background()
	db := startProposalRollbackDB(t, ctx)
	now := time.Date(2026, 7, 18, 11, 0, 0, 0, time.UTC)
	fixture := seedProposalFixture(t, ctx, db, "approve", now)
	service := New(db)
	submitted := submitProposal(t, ctx, service, fixture, "approve", now)

	result, err := service.Decide(ctx, approval.DecideParams{
		WorkspaceID: fixture.WorkspaceID, ApprovalID: submitted.Approval.ID,
		AssignmentID: submitted.Assignments[0].ID, ActorMemberID: fixture.OwnerMemberID,
		ExpectedVersion: submitted.Approval.Version, ClientDecisionID: "approve-decision",
		Decision: approval.DecisionApproved, Reason: "ship it", Now: now.Add(2 * time.Minute),
	})
	require.NoError(t, err)
	require.True(t, result.Changed)
	require.True(t, result.Applied)
	require.Equal(t, StatusApproved, result.Proposal.Status)
	assertTeamState(t, ctx, db, fixture, "New description", "New leader overlay", 2, "New worker overlay", 2)

	replay, err := service.Decide(ctx, approval.DecideParams{
		WorkspaceID: fixture.WorkspaceID, ApprovalID: submitted.Approval.ID,
		AssignmentID: submitted.Assignments[0].ID, ActorMemberID: fixture.OwnerMemberID,
		ExpectedVersion: submitted.Approval.Version, ClientDecisionID: "approve-decision",
		Decision: approval.DecisionApproved, Reason: "ship it", Now: now.Add(3 * time.Minute),
	})
	require.NoError(t, err)
	require.False(t, replay.Changed)
	require.True(t, replay.Applied)
	assertTeamState(t, ctx, db, fixture, "New description", "New leader overlay", 2, "New worker overlay", 2)

	submitReplay, err := service.Submit(
		ctx, fixture.AuthenticatedLead,
		proposalSubmitInput(fixture, "approve", now),
	)
	require.NoError(t, err)
	require.False(t, submitReplay.Created)
	require.Equal(t, submitted.Approval.ID, submitReplay.Approval.ID)
	require.Equal(t, approval.StateApproved, submitReplay.Approval.State)
}

func TestSubmitReplaysRejectedAndCancelledTerminalApprovals(t *testing.T) {
	t.Parallel()
	ctx := context.Background()
	db := startProposalRollbackDB(t, ctx)
	now := time.Date(2026, 7, 18, 11, 15, 0, 0, time.UTC)
	service := New(db)

	rejectedFixture := seedProposalFixture(t, ctx, db, "submit-replay-rejected", now)
	rejected := submitProposal(
		t, ctx, service, rejectedFixture, "submit-replay-rejected", now,
	)
	_, err := service.Decide(ctx, approval.DecideParams{
		WorkspaceID: rejectedFixture.WorkspaceID, ApprovalID: rejected.Approval.ID,
		AssignmentID: rejected.Assignments[0].ID, ActorMemberID: rejectedFixture.OwnerMemberID,
		ExpectedVersion: rejected.Approval.Version, ClientDecisionID: "reject-replay",
		Decision: approval.DecisionRejected, Now: now.Add(2 * time.Minute),
	})
	require.NoError(t, err)
	rejectedReplay, err := service.Submit(
		ctx, rejectedFixture.AuthenticatedLead,
		proposalSubmitInput(rejectedFixture, "submit-replay-rejected", now),
	)
	require.NoError(t, err)
	require.False(t, rejectedReplay.Created)
	require.Equal(t, approval.StateRejected, rejectedReplay.Approval.State)

	cancelledFixture := seedProposalFixture(t, ctx, db, "submit-replay-cancelled", now)
	cancelled := submitProposal(
		t, ctx, service, cancelledFixture, "submit-replay-cancelled", now,
	)
	_, err = service.Withdraw(ctx, cancelledFixture.AuthenticatedLead, WithdrawInput{
		ApprovalID: cancelled.Approval.ID, ExpectedVersion: cancelled.Approval.Version,
		ClientRequestID: "cancel-replay", Now: now.Add(2 * time.Minute),
	})
	require.NoError(t, err)
	cancelledReplay, err := service.Submit(
		ctx, cancelledFixture.AuthenticatedLead,
		proposalSubmitInput(cancelledFixture, "submit-replay-cancelled", now),
	)
	require.NoError(t, err)
	require.False(t, cancelledReplay.Created)
	require.Equal(t, approval.StateCancelled, cancelledReplay.Approval.State)
}

func TestDecideApproveDoesNotIncrementUnchangedPromptOrOverlays(t *testing.T) {
	t.Parallel()
	ctx := context.Background()
	db := startProposalRollbackDB(t, ctx)
	now := time.Date(2026, 7, 18, 11, 30, 0, 0, time.UTC)
	fixture := seedProposalFixture(t, ctx, db, "unchanged", now)
	service := New(db)
	submitted, err := service.Submit(ctx, fixture.AuthenticatedLead, SubmitInput{
		TeamID: fixture.TeamID, ClientRequestID: "unchanged",
		Description: proposalString("Description only"),
		Members: []MemberInput{
			{TeamMemberID: fixture.LeaderTeamMember, RoleOverlayPrompt: proposalString("Leader overlay")},
			{TeamMemberID: fixture.WorkerTeamMember, RoleOverlayPrompt: proposalString("Worker overlay")},
		},
		Now: now.Add(time.Minute),
	})
	require.NoError(t, err)

	_, err = service.Decide(ctx, approval.DecideParams{
		WorkspaceID: fixture.WorkspaceID, ApprovalID: submitted.Approval.ID,
		AssignmentID: submitted.Assignments[0].ID, ActorMemberID: fixture.OwnerMemberID,
		ExpectedVersion: submitted.Approval.Version, ClientDecisionID: "unchanged-decision",
		Decision: approval.DecisionApproved, Now: now.Add(2 * time.Minute),
	})
	require.NoError(t, err)
	assertTeamState(t, ctx, db, fixture, "Description only", "Leader overlay", 1, "Worker overlay", 1)
}

func TestDecideConcurrentApprovalsUseLastApprovedWins(t *testing.T) {
	for _, test := range []struct {
		name          string
		order         []int
		wantSuffix    string
		fixtureSuffix string
	}{
		{name: "submission order", order: []int{0, 1}, wantSuffix: "B", fixtureSuffix: "last-approved-b"},
		{name: "reverse order", order: []int{1, 0}, wantSuffix: "A", fixtureSuffix: "last-approved-a"},
	} {
		test := test
		t.Run(test.name, func(t *testing.T) {
			t.Parallel()
			ctx := context.Background()
			db := startProposalRollbackDB(t, ctx)
			now := time.Date(2026, 7, 18, 12, 0, 0, 0, time.UTC)
			fixture := seedProposalFixture(t, ctx, db, test.fixtureSuffix, now)
			service := New(db)
			submitted := make([]Result, 0, 2)
			for _, suffix := range []string{"A", "B"} {
				result, err := service.Submit(ctx, fixture.AuthenticatedLead, SubmitInput{
					TeamID: fixture.TeamID, ClientRequestID: "concurrent-" + suffix,
					Description: proposalString("Description " + suffix),
					Members: []MemberInput{
						{TeamMemberID: fixture.LeaderTeamMember, RoleOverlayPrompt: proposalString("Leader overlay " + suffix)},
						{TeamMemberID: fixture.WorkerTeamMember, RoleOverlayPrompt: proposalString("Worker overlay " + suffix)},
					},
					Now: now.Add(time.Minute),
				})
				require.NoError(t, err)
				submitted = append(submitted, result)
			}

			for decisionIndex, proposalIndex := range test.order {
				current := submitted[proposalIndex]
				result, err := service.Decide(ctx, approval.DecideParams{
					WorkspaceID: fixture.WorkspaceID, ApprovalID: current.Approval.ID,
					AssignmentID: current.Assignments[0].ID, ActorMemberID: fixture.OwnerMemberID,
					ExpectedVersion:  current.Approval.Version,
					ClientDecisionID: "approve-" + test.fixtureSuffix + "-" + string(rune('0'+decisionIndex)),
					Decision:         approval.DecisionApproved,
					Now:              now.Add(time.Duration(decisionIndex+2) * time.Minute),
				})
				require.NoError(t, err)
				require.True(t, result.Applied)
				require.False(t, result.Stale)
				require.Equal(t, approval.StateApproved, result.Approval.Instance.State)
			}
			assertTeamState(
				t, ctx, db, fixture,
				"Description "+test.wantSuffix,
				"Leader overlay "+test.wantSuffix, 3,
				"Worker overlay "+test.wantSuffix, 3,
			)
		})
	}
}

func TestDecideConcurrentApprovalsSerializeWithoutMixedState(t *testing.T) {
	t.Parallel()
	ctx := context.Background()
	db := startProposalConcurrentDB(t, ctx)
	now := time.Date(2026, 7, 18, 12, 15, 0, 0, time.UTC)
	fixture := seedProposalFixture(t, ctx, db, "concurrent-approve", now)
	service := New(db)
	submitted := make([]Result, 0, 2)
	for _, suffix := range []string{"A", "B"} {
		result, err := service.Submit(ctx, fixture.AuthenticatedLead, SubmitInput{
			TeamID: fixture.TeamID, ClientRequestID: "concurrent-approve-" + suffix,
			Description: proposalString("Description " + suffix),
			Members: []MemberInput{
				{TeamMemberID: fixture.LeaderTeamMember, RoleOverlayPrompt: proposalString("Leader overlay " + suffix)},
				{TeamMemberID: fixture.WorkerTeamMember, RoleOverlayPrompt: proposalString("Worker overlay " + suffix)},
			},
			Now: now.Add(time.Minute),
		})
		require.NoError(t, err)
		submitted = append(submitted, result)
	}

	type outcome struct {
		result DecisionResult
		err    error
	}
	start := make(chan struct{})
	outcomes := make(chan outcome, len(submitted))
	for index, proposal := range submitted {
		index, proposal := index, proposal
		go func() {
			<-start
			result, err := service.Decide(ctx, approval.DecideParams{
				WorkspaceID: fixture.WorkspaceID, ApprovalID: proposal.Approval.ID,
				AssignmentID: proposal.Assignments[0].ID, ActorMemberID: fixture.OwnerMemberID,
				ExpectedVersion:  proposal.Approval.Version,
				ClientDecisionID: "concurrent-approve-decision-" + string(rune('A'+index)),
				Decision:         approval.DecisionApproved,
				Now:              now.Add(time.Duration(index+2) * time.Minute),
			})
			outcomes <- outcome{result: result, err: err}
		}()
	}
	close(start)
	for range submitted {
		got := <-outcomes
		require.NoError(t, got.err)
		require.True(t, got.result.Applied)
		require.Equal(t, approval.StateApproved, got.result.Approval.Instance.State)
	}

	var description, leaderOverlay, workerOverlay string
	var leaderVersion, workerVersion int64
	require.NoError(t, db.QueryRow(ctx, `
SELECT description FROM workspace_teams WHERE id=$1
`, fixture.TeamID).Scan(&description))
	require.NoError(t, db.QueryRow(ctx, `
SELECT role_overlay_prompt, role_overlay_version
FROM workspace_team_members WHERE id=$1
`, fixture.LeaderTeamMember).Scan(&leaderOverlay, &leaderVersion))
	require.NoError(t, db.QueryRow(ctx, `
SELECT role_overlay_prompt, role_overlay_version
FROM workspace_team_members WHERE id=$1
`, fixture.WorkerTeamMember).Scan(&workerOverlay, &workerVersion))
	require.Contains(t, []string{"Description A", "Description B"}, description)
	suffix := description[len(description)-1:]
	require.Equal(t, "Leader overlay "+suffix, leaderOverlay)
	require.Equal(t, "Worker overlay "+suffix, workerOverlay)
	require.Equal(t, int64(3), leaderVersion)
	require.Equal(t, int64(3), workerVersion)
}

func TestDecideSparseConcurrentProposalsPreserveOrthogonalFields(t *testing.T) {
	t.Parallel()
	ctx := context.Background()
	db := startProposalRollbackDB(t, ctx)
	now := time.Date(2026, 7, 18, 12, 20, 0, 0, time.UTC)
	fixture := seedProposalFixture(t, ctx, db, "sparse-orthogonal", now)
	service := New(db)
	description, err := service.Submit(ctx, fixture.AuthenticatedLead, SubmitInput{
		TeamID: fixture.TeamID, ClientRequestID: "sparse-description",
		Description: proposalString("Sparse description"), Now: now.Add(time.Minute),
	})
	require.NoError(t, err)
	member, err := service.Submit(ctx, fixture.AuthenticatedLead, SubmitInput{
		TeamID: fixture.TeamID, ClientRequestID: "sparse-member",
		Members: []MemberInput{{
			TeamMemberID:      fixture.WorkerTeamMember,
			RoleOverlayPrompt: proposalString("Sparse worker overlay"),
		}},
		Now: now.Add(time.Minute),
	})
	require.NoError(t, err)

	for index, proposal := range []Result{member, description} {
		result, err := service.Decide(ctx, approval.DecideParams{
			WorkspaceID: fixture.WorkspaceID, ApprovalID: proposal.Approval.ID,
			AssignmentID: proposal.Assignments[0].ID, ActorMemberID: fixture.OwnerMemberID,
			ExpectedVersion:  proposal.Approval.Version,
			ClientDecisionID: "sparse-orthogonal-" + string(rune('0'+index)),
			Decision:         approval.DecisionApproved,
			Now:              now.Add(time.Duration(index+2) * time.Minute),
		})
		require.NoError(t, err)
		require.True(t, result.Applied)
	}
	assertTeamState(
		t, ctx, db, fixture,
		"Sparse description", "Leader overlay", 1, "Sparse worker overlay", 2,
	)
}

func TestDecideApproveCancelsWhenTargetMemberWasRemoved(t *testing.T) {
	t.Parallel()
	ctx := context.Background()
	db := startProposalRollbackDB(t, ctx)
	now := time.Date(2026, 7, 18, 12, 30, 0, 0, time.UTC)
	fixture := seedProposalFixture(t, ctx, db, "removed-target", now)
	service := New(db)
	submitted := submitProposal(t, ctx, service, fixture, "removed-target", now)

	mustExecProposal(t, ctx, db, `
UPDATE workspace_team_members
SET removed_at=$2, active_member_key=NULL, active_agent_team_key=NULL, updated_at=$2
WHERE id=$1
`, fixture.WorkerTeamMember, now.Add(time.Minute))
	result, err := service.Decide(ctx, approval.DecideParams{
		WorkspaceID: fixture.WorkspaceID, ApprovalID: submitted.Approval.ID,
		AssignmentID: submitted.Assignments[0].ID, ActorMemberID: fixture.OwnerMemberID,
		ExpectedVersion: submitted.Approval.Version, ClientDecisionID: "removed-target-decision",
		Decision: approval.DecisionApproved, Now: now.Add(2 * time.Minute),
	})
	require.NoError(t, err)
	require.True(t, result.Stale)
	require.False(t, result.Applied)
	require.Equal(t, approval.StateCancelled, result.Approval.Instance.State)
	require.Equal(t, staleDecisionReason, result.Approval.Instance.DecisionReason)
	assertTeamState(t, ctx, db, fixture, "Base description", "Leader overlay", 1, "Worker overlay", 1)
}

func TestDecideApproveCancelsWhenApplicantWasReaddedAsLeader(t *testing.T) {
	t.Parallel()
	ctx := context.Background()
	db := startProposalRollbackDB(t, ctx)
	now := time.Date(2026, 7, 18, 12, 45, 0, 0, time.UTC)
	fixture := seedProposalFixture(t, ctx, db, "readded-leader", now)
	service := New(db)
	submitted, err := service.Submit(ctx, fixture.AuthenticatedLead, SubmitInput{
		TeamID: fixture.TeamID, ClientRequestID: "readded-leader",
		Description: proposalString("Must not apply"),
		Now:         now.Add(time.Minute),
	})
	require.NoError(t, err)

	readdedTeamMemberID := fixture.LeaderTeamMember + "_replacement"
	mustExecProposal(t, ctx, db, `
UPDATE workspace_team_members
SET removed_at=$2, active_member_key=NULL, active_agent_team_key=NULL,
    active_leader_key=NULL, updated_at=$2
WHERE id=$1
`, fixture.LeaderTeamMember, now.Add(2*time.Minute))
	mustExecProposal(t, ctx, db, `
INSERT INTO workspace_team_members (
 id, workspace_id, team_id, actor_kind, actor_id, role, role_label,
 role_overlay_prompt, role_overlay_version, wake_on_team_mention, sort_order,
 added_by_member_id, added_at, updated_at, removed_at, active_member_key,
 active_agent_team_key, active_leader_key
) VALUES (
 $1,$2,$3,'agent',$4,'leader','','Replacement leader overlay',1,TRUE,10,
 $5,$6,$6,NULL,$7,$8,$9
)
`, readdedTeamMemberID, fixture.WorkspaceID, fixture.TeamID, fixture.LeaderMemberID,
		fixture.OwnerMemberID, now.Add(2*time.Minute),
		fixture.TeamID+":agent:"+fixture.LeaderMemberID,
		fixture.WorkspaceID+":agent:"+fixture.LeaderMemberID,
		fixture.WorkspaceID+":"+fixture.TeamID)

	result, err := service.Decide(ctx, approval.DecideParams{
		WorkspaceID: fixture.WorkspaceID, ApprovalID: submitted.Approval.ID,
		AssignmentID: submitted.Assignments[0].ID, ActorMemberID: fixture.OwnerMemberID,
		ExpectedVersion: submitted.Approval.Version, ClientDecisionID: "readded-leader-decision",
		Decision: approval.DecisionApproved, Now: now.Add(3 * time.Minute),
	})
	require.NoError(t, err)
	require.True(t, result.Stale)
	require.False(t, result.Applied)
	require.Equal(t, approval.StateCancelled, result.Approval.Instance.State)
	require.Equal(t, staleDecisionReason, result.Approval.Instance.DecisionReason)

	var description string
	require.NoError(t, db.QueryRow(ctx, `
SELECT description FROM workspace_teams WHERE id=$1
`, fixture.TeamID).Scan(&description))
	require.Equal(t, "Base description", description)
}

func TestLoadReturnsProjectorFacts(t *testing.T) {
	t.Parallel()
	ctx := context.Background()
	db := startProposalRollbackDB(t, ctx)
	now := time.Date(2026, 7, 18, 13, 0, 0, 0, time.UTC)
	fixture := seedProposalFixture(t, ctx, db, "loads", now)
	service := New(db)
	submitted := submitProposal(t, ctx, service, fixture, "loads", now)

	byID, err := service.Load(ctx, fixture.WorkspaceID, submitted.Proposal.ID)
	require.NoError(t, err)
	require.Equal(t, submitted.Proposal, byID)
}

func submitProposal(t *testing.T, ctx context.Context, service *Service, fixture proposalFixture, clientID string, now time.Time) Result {
	t.Helper()
	result, err := service.Submit(
		ctx, fixture.AuthenticatedLead, proposalSubmitInput(fixture, clientID, now),
	)
	require.NoError(t, err)
	return result
}

func proposalSubmitInput(
	fixture proposalFixture,
	clientID string,
	now time.Time,
) SubmitInput {
	return SubmitInput{
		TeamID: fixture.TeamID, ClientRequestID: clientID,
		Description: proposalString("New description"),
		Members: []MemberInput{
			{TeamMemberID: fixture.LeaderTeamMember, RoleOverlayPrompt: proposalString("New leader overlay")},
			{TeamMemberID: fixture.WorkerTeamMember, RoleOverlayPrompt: proposalString("New worker overlay")},
		},
		Now: now.Add(time.Minute),
	}
}

func startProposalRollbackDB(t *testing.T, ctx context.Context) coredb.DB {
	t.Helper()
	return proposalTiDB.RollbackDB(t, ctx)
}

func startProposalConcurrentDB(t *testing.T, ctx context.Context) coredb.DB {
	t.Helper()
	dsn := testkit.StartInitializedTiDBWithRootDSN(t, proposalTiDB.RootDSN(t))
	sqlDB, err := testkit.OpenTiDBContext(ctx, dsn)
	require.NoError(t, err)
	t.Cleanup(func() {
		require.NoError(t, sqlDB.Close())
	})
	return coredb.WrapSQLDB(sqlDB)
}

func seedProposalFixture(t *testing.T, ctx context.Context, db coredb.DB, suffix string, now time.Time) proposalFixture {
	t.Helper()
	fixture := proposalFixture{
		WorkspaceID: "wsp_trp_" + suffix, OwnerMemberID: "mem_trp_owner_" + suffix,
		LeaderAgentID: "agt_trp_leader_" + suffix, LeaderMemberID: "mem_trp_leader_" + suffix, LeaderTeamMember: "wtm_trp_leader_" + suffix,
		WorkerAgentID: "agt_trp_worker_" + suffix, WorkerMemberID: "mem_trp_worker_" + suffix, WorkerTeamMember: "wtm_trp_worker_" + suffix,
		TeamID: "team_trp_" + suffix,
	}
	ownerUserID := "usr_trp_owner_" + suffix
	orgID := "org_trp_" + suffix
	machineID := "mac_trp_" + suffix
	mustExecProposal(t, ctx, db, `
INSERT INTO users (id, email, name, display_name, password_hash, created_at, updated_at)
VALUES ($1,$2,$3,$3,'hash',$4,$4)
`, ownerUserID, ownerUserID+"@example.com", ownerUserID, now)
	mustExecProposal(t, ctx, db, `
INSERT INTO organizations (id, slug, name, created_by_user_id, created_at, updated_at)
VALUES ($1,$2,$2,$3,$4,$4)
`, orgID, "trp-"+suffix, ownerUserID, now)
	mustExecProposal(t, ctx, db, `
INSERT INTO workspaces (id, organization_id, slug, name, created_at, updated_at)
VALUES ($1,$2,$3,$3,$4,$4)
`, fixture.WorkspaceID, orgID, "trp-"+suffix, now)
	mustExecProposal(t, ctx, db, `
INSERT INTO members (id, workspace_id, type, display_name, user_id, workspace_role, status, created_at, updated_at)
VALUES
 ($1,$4,'human',$1,$5,'owner','active',$6,$6),
 ($2,$4,'agent',$2,NULL,NULL,'active',$6,$6),
 ($3,$4,'agent',$3,NULL,NULL,'active',$6,$6)
`, fixture.OwnerMemberID, fixture.LeaderMemberID, fixture.WorkerMemberID, fixture.WorkspaceID, ownerUserID, now)
	mustExecProposal(t, ctx, db, `
INSERT INTO machines (id, workspace_id, created_by_user_id, name, api_key, created_at, updated_at)
VALUES ($1,$2,$3,$1,$4,$5,$5)
`, machineID, fixture.WorkspaceID, ownerUserID, "key-"+machineID, now)
	for _, agent := range []struct{ id, member string }{{fixture.LeaderAgentID, fixture.LeaderMemberID}, {fixture.WorkerAgentID, fixture.WorkerMemberID}} {
		mustExecProposal(t, ctx, db, `
INSERT INTO agents (id, workspace_id, machine_id, member_id, name, avatar_url, runtime, model, description, auth_token_hash, activity_detail, created_at, updated_at)
VALUES ($1,$2,$3,$4,$1,'','codex','gpt-5','',NULL,'',$5,$5)
`, agent.id, fixture.WorkspaceID, machineID, agent.member, now)
	}
	mustExecProposal(t, ctx, db, `
INSERT INTO workspace_teams (
 id, workspace_id, handle, display_name, description, avatar_url, visibility, status,
 created_by_member_id,
 created_at, updated_at, archived_at, active_handle_key
) VALUES ($1,$2,$3,$3,'Base description','','private','active',$5,$4,$4,NULL,$6)
`, fixture.TeamID, fixture.WorkspaceID, "team-"+suffix, now, fixture.OwnerMemberID, fixture.WorkspaceID+":team-"+suffix)
	mustExecProposal(t, ctx, db, `
INSERT INTO workspace_team_members (
 id, workspace_id, team_id, actor_kind, actor_id, role, role_label,
 role_overlay_prompt, role_overlay_version, wake_on_team_mention, sort_order,
 added_by_member_id, added_at, updated_at, removed_at, active_member_key,
 active_agent_team_key, active_leader_key
) VALUES
 ($1,$5,$6,'agent',$7,'leader','','Leader overlay',1,TRUE,10,$9,$10,$10,NULL,$11,$12,$13),
 ($2,$5,$6,'agent',$8,'member','','Worker overlay',1,TRUE,20,$9,$10,$10,NULL,$14,$15,NULL)
`, fixture.LeaderTeamMember, fixture.WorkerTeamMember, fixture.LeaderMemberID, fixture.WorkerMemberID,
		fixture.WorkspaceID, fixture.TeamID, fixture.LeaderMemberID, fixture.WorkerMemberID, fixture.OwnerMemberID, now,
		fixture.TeamID+":agent:"+fixture.LeaderMemberID, fixture.WorkspaceID+":agent:"+fixture.LeaderMemberID, fixture.WorkspaceID+":"+fixture.TeamID,
		fixture.TeamID+":agent:"+fixture.WorkerMemberID, fixture.WorkspaceID+":agent:"+fixture.WorkerMemberID)
	fixture.AuthenticatedLead = agent.AuthenticatedAgent{
		AgentID: fixture.LeaderAgentID, WorkspaceID: fixture.WorkspaceID, MemberID: fixture.LeaderMemberID, MachineID: machineID,
	}
	return fixture
}

func assertTeamState(
	t *testing.T, ctx context.Context, db coredb.QueryRower, fixture proposalFixture,
	description string,
	leaderOverlay string, leaderVersion int64, workerOverlay string, workerVersion int64,
) {
	t.Helper()
	var gotDescription string
	require.NoError(t, db.QueryRow(ctx, `
SELECT description FROM workspace_teams WHERE id=$1
`, fixture.TeamID).Scan(&gotDescription))
	require.Equal(t, description, gotDescription)
	for _, expected := range []struct {
		id, overlay string
		version     int64
	}{{fixture.LeaderTeamMember, leaderOverlay, leaderVersion}, {fixture.WorkerTeamMember, workerOverlay, workerVersion}} {
		var overlay string
		var version int64
		require.NoError(t, db.QueryRow(ctx, `
SELECT role_overlay_prompt, role_overlay_version FROM workspace_team_members WHERE id=$1
`, expected.id).Scan(&overlay, &version))
		require.Equal(t, expected.overlay, overlay)
		require.Equal(t, expected.version, version)
	}
}

func mustExecProposal(t testing.TB, ctx context.Context, db coredb.QueryExecer, query string, args ...any) {
	t.Helper()
	_, err := db.Exec(ctx, query, args...)
	require.NoError(t, err)
}

func proposalString(value string) *string { return &value }

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

func mustJSON(t testing.TB, value any) []byte {
	t.Helper()
	encoded, err := json.Marshal(value)
	require.NoError(t, err)
	return encoded
}
