package roleproposal

import (
	"context"
	"crypto/sha256"
	"database/sql"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"sort"
	"strings"
	"time"
	"unicode/utf8"

	"github.com/tidbcloud/AgentTeam/internal/core/identifiers"
	"github.com/tidbcloud/AgentTeam/internal/coredb"
	"github.com/tidbcloud/AgentTeam/internal/store/agent"
	"github.com/tidbcloud/AgentTeam/internal/store/approval"
	"github.com/tidbcloud/AgentTeam/internal/store/storeerr"
)

const (
	maxDescriptionChars = 32768
	maxRoleLabelChars   = 128
	maxOverlayChars     = 8192
	staleDecisionReason = "stale_subject"
)

// Service implements the team_role_proposal Approval subject without a
// Team-specific proposal table.
type Service struct {
	db        coredb.DB
	approvals *approval.Store
}

func New(db coredb.DB) *Service { return &Service{db: db, approvals: approval.New(db)} }

func (service *Service) validate() error {
	if service == nil || service.db == nil || service.approvals == nil {
		return fmt.Errorf("team role proposal service is not configured")
	}
	return nil
}

// Submit creates or replays one generic Approval instance. The Team row lock
// serializes subject-version allocation. Team role proposals intentionally do
// not set a pending key, so multiple immutable proposals may await decisions.
func (service *Service) Submit(ctx context.Context, authenticated agent.AuthenticatedAgent, input SubmitInput) (Result, error) {
	if err := service.validate(); err != nil {
		return Result{}, err
	}
	prepared, err := prepareSubmit(authenticated, input)
	if err != nil {
		return Result{}, err
	}
	var result Result
	err = coredb.WithTx(ctx, service.db, func(tx coredb.Tx) error {
		if err := requireAuthenticatedAgent(ctx, tx, prepared); err != nil {
			return err
		}
		team, members, err := loadTeamState(
			ctx, tx, prepared.workspaceID, prepared.teamID, true,
		)
		if err != nil {
			return err
		}
		leader, err := requireAuthenticatedLeader(prepared, team, members)
		if err != nil {
			return err
		}
		idempotencyKey := digestStrings(
			"approval-submission-v1", prepared.workspaceID, SubjectType,
			prepared.teamID, prepared.memberID, prepared.clientRequestID,
		)
		if existing, loadErr := service.approvals.InTx(tx).LoadByIdempotencyKey(
			ctx, prepared.workspaceID, prepared.memberID, idempotencyKey,
		); loadErr == nil {
			if err := requireSubmitReplayMatch(existing, prepared); err != nil {
				return err
			}
			return service.loadSubmitReplay(ctx, tx, existing, &result)
		} else if !errors.Is(loadErr, storeerr.ErrNotFound) {
			return loadErr
		}

		_, snapshot, payloadDigest, err := canonicalPayload(prepared, team, members, leader)
		if err != nil {
			return err
		}
		proposalSequence, err := nextProposalSequence(
			ctx, tx, team.WorkspaceID, team.TeamID,
		)
		if err != nil {
			return err
		}
		approvalID, err := identifiers.NewEntityID("appr")
		if err != nil {
			return fmt.Errorf("generate team role proposal approval id: %w", err)
		}
		instance, assignments, created, err := service.approvals.InTx(tx).Create(ctx, approval.CreateParams{
			ID: approvalID, WorkspaceID: team.WorkspaceID, SubjectType: SubjectType,
			SubjectRef: team.TeamID, SubjectVersion: proposalSequence,
			Payload: snapshot, PayloadDigest: payloadDigest,
			IdempotencyKey:      idempotencyKey,
			ApplicantMemberID:   prepared.memberID,
			AllowAgentApplicant: true,
			ApproverMemberIDs:   []string{team.CreatedByMemberID},
			CorrelationID:       prepared.correlationID, Now: prepared.now,
		})
		if err != nil {
			return err
		}
		result = Result{Proposal: proposalFrom(instance), Approval: instance, Assignments: assignments, Created: created}
		return nil
	})
	return result, err
}

func requireSubmitReplayMatch(
	existing approval.Instance,
	prepared preparedSubmit,
) error {
	if existing.SubjectType != SubjectType || existing.SubjectRef != prepared.teamID {
		return storeerr.Conflict("team role proposal client request id was reused with another payload")
	}
	payload, err := decodePayload(existing)
	if err != nil {
		return err
	}
	if payload.ClientRequestID != prepared.clientRequestID ||
		payload.Applicant.AgentID != prepared.agentID ||
		payload.Applicant.MemberID != prepared.memberID ||
		!equalOptionalString(payload.Proposed.Description, prepared.description) ||
		len(payload.Proposed.Members) != len(prepared.members) {
		return storeerr.Conflict("team role proposal client request id was reused with another payload")
	}
	members := append([]MemberInput(nil), prepared.members...)
	sort.Slice(members, func(i, j int) bool {
		return members[i].TeamMemberID < members[j].TeamMemberID
	})
	for index, member := range members {
		proposed := payload.Proposed.Members[index]
		if proposed.TeamMemberID != member.TeamMemberID ||
			!equalOptionalString(proposed.RoleLabel, member.RoleLabel) ||
			!equalOptionalString(proposed.RoleOverlayPrompt, member.RoleOverlayPrompt) ||
			!equalOptionalBool(proposed.WakeOnTeamMention, member.WakeOnTeamMention) {
			return storeerr.Conflict("team role proposal client request id was reused with another payload")
		}
	}
	return nil
}

// Withdraw lets the authenticated current Team leader cancel their own pending
// proposal. The runtime principal is authoritative; no caller-supplied identity
// participates in authorization.
func (service *Service) Withdraw(ctx context.Context, authenticated agent.AuthenticatedAgent, input WithdrawInput) (WithdrawResult, error) {
	if err := service.validate(); err != nil {
		return WithdrawResult{}, err
	}
	workspaceID := strings.TrimSpace(authenticated.WorkspaceID)
	agentID := strings.TrimSpace(authenticated.AgentID)
	memberID := strings.TrimSpace(authenticated.MemberID)
	approvalID := strings.TrimSpace(input.ApprovalID)
	clientRequestID := strings.TrimSpace(input.ClientRequestID)
	if workspaceID == "" || agentID == "" || memberID == "" || approvalID == "" ||
		input.ExpectedVersion <= 0 || clientRequestID == "" {
		return WithdrawResult{}, storeerr.BadRequest("authenticated agent, approvalId, expectedVersion, and clientRequestId are required")
	}
	if len(clientRequestID) > 128 {
		return WithdrawResult{}, storeerr.BadRequest("team role proposal withdrawal identifier is too long")
	}
	now := normalizedNow(input.Now)
	var result WithdrawResult
	err := coredb.WithTx(ctx, service.db, func(tx coredb.Tx) error {
		instance, err := service.approvals.InTx(tx).Load(ctx, workspaceID, approvalID)
		if err != nil {
			return err
		}
		if instance.SubjectType != SubjectType {
			return storeerr.NotFound("team role proposal not found")
		}
		prepared := preparedSubmit{
			workspaceID: workspaceID, agentID: agentID, memberID: memberID,
			teamID: instance.SubjectRef, now: now,
		}
		if err := requireAuthenticatedAgent(ctx, tx, prepared); err != nil {
			return err
		}
		team, members, err := loadTeamState(ctx, tx, workspaceID, instance.SubjectRef, true)
		if err != nil {
			return err
		}
		if _, err := requireAuthenticatedLeader(prepared, team, members); err != nil {
			return err
		}
		if instance.ApplicantMemberID != memberID {
			return storeerr.Forbidden("only the Team role proposal applicant may withdraw it")
		}
		cancelled, err := service.approvals.InTx(tx).CancelByApplicant(ctx, approval.ApplicantCancelParams{
			WorkspaceID: workspaceID, ApprovalID: approvalID, ActorMemberID: memberID,
			ExpectedVersion: input.ExpectedVersion, ClientCancellationID: clientRequestID,
			Reason: "withdrawn_by_leader", Now: now,
		})
		if err != nil {
			return err
		}
		result = WithdrawResult{
			Proposal: proposalFrom(cancelled.Instance), Approval: cancelled,
			AlreadyClosed: cancelled.AlreadyApplied,
		}
		return nil
	})
	return result, err
}

// Decide atomically settles generic Approval and applies the Team payload.
// Multiple proposals may be pending for one Team. Approvals are serialized by
// the Team/member row locks and overlapping fields use last-approved-wins
// semantics; the immutable base remains audit context, not a value fence.
func (service *Service) Decide(ctx context.Context, params approval.DecideParams) (DecisionResult, error) {
	if err := service.validate(); err != nil {
		return DecisionResult{}, err
	}
	decision, err := service.approvals.DecideWithEffect(ctx, params, func(ctx context.Context, tx coredb.Tx, instance approval.Instance, requested string) (approval.DecisionEffectResult, error) {
		if instance.SubjectType != SubjectType {
			return approval.DecisionEffectResult{}, storeerr.Conflict("Approval is not a Team role proposal")
		}
		payload, err := decodePayload(instance)
		if err != nil {
			return approval.DecisionEffectResult{}, err
		}
		if payload.Applicant.MemberID != instance.ApplicantMemberID || instance.SubjectRef == "" {
			return approval.DecisionEffectResult{}, storeerr.Conflict("Team role proposal identity does not match Approval")
		}
		if requested == approval.DecisionRejected {
			return approval.DecisionEffectResult{}, nil
		}
		team, members, err := loadTeamState(ctx, tx, instance.WorkspaceID, instance.SubjectRef, true)
		if err != nil {
			if errors.Is(err, storeerr.ErrNotFound) || errors.Is(err, storeerr.ErrConflict) {
				return approval.DecisionEffectResult{TerminalState: approval.StateCancelled, DecisionReason: staleDecisionReason}, nil
			}
			return approval.DecisionEffectResult{}, err
		}
		leader, err := requireAuthenticatedLeader(preparedSubmit{memberID: payload.Applicant.MemberID}, team, members)
		if err != nil || leader.TeamMemberID != payload.Applicant.TeamMemberID {
			return approval.DecisionEffectResult{TerminalState: approval.StateCancelled, DecisionReason: staleDecisionReason}, nil
		}
		if !proposalTargetsStillValid(payload.Base, payload.Proposed, members) {
			return approval.DecisionEffectResult{TerminalState: approval.StateCancelled, DecisionReason: staleDecisionReason}, nil
		}
		if err := applyPayload(ctx, tx, team, members, payload.Proposed, normalizedNow(params.Now)); err != nil {
			return approval.DecisionEffectResult{}, err
		}
		return approval.DecisionEffectResult{}, nil
	})
	if err != nil {
		return DecisionResult{}, err
	}
	proposal := proposalFrom(decision.Instance)
	return DecisionResult{
		Proposal: proposal, Approval: decision, Changed: !decision.AlreadyApplied,
		Applied: decision.Instance.State == approval.StateApproved,
		Stale:   decision.Instance.State == approval.StateCancelled && decision.Instance.DecisionReason == staleDecisionReason,
	}, nil
}

func (service *Service) Load(ctx context.Context, workspaceID, approvalID string) (Proposal, error) {
	if err := service.validate(); err != nil {
		return Proposal{}, err
	}
	instance, err := service.approvals.Load(ctx, workspaceID, approvalID)
	if err != nil {
		return Proposal{}, err
	}
	if instance.SubjectType != SubjectType {
		return Proposal{}, storeerr.NotFound("team role proposal not found")
	}
	return proposalFrom(instance), nil
}

type preparedSubmit struct {
	workspaceID, agentID, memberID, teamID string
	clientRequestID, correlationID         string
	description                            *string
	members                                []MemberInput
	now                                    time.Time
}

func prepareSubmit(authenticated agent.AuthenticatedAgent, input SubmitInput) (preparedSubmit, error) {
	p := preparedSubmit{
		workspaceID: strings.TrimSpace(authenticated.WorkspaceID), agentID: strings.TrimSpace(authenticated.AgentID),
		memberID: strings.TrimSpace(authenticated.MemberID), teamID: strings.TrimSpace(input.TeamID),
		clientRequestID: strings.TrimSpace(input.ClientRequestID), correlationID: strings.TrimSpace(input.CorrelationID),
		description: trimOptionalString(input.Description),
		members:     append([]MemberInput(nil), input.Members...), now: normalizedNow(input.Now),
	}
	if p.workspaceID == "" || p.agentID == "" || p.memberID == "" || p.teamID == "" || p.clientRequestID == "" {
		return preparedSubmit{}, storeerr.BadRequest("authenticated agent, teamId, and clientRequestId are required")
	}
	if len(p.clientRequestID) > 128 || len(p.correlationID) > 255 {
		return preparedSubmit{}, storeerr.BadRequest("team role proposal identifier is too long")
	}
	if p.description != nil && utf8.RuneCountInString(*p.description) > maxDescriptionChars {
		return preparedSubmit{}, storeerr.BadRequest("team role proposal description is too large")
	}
	for i := range p.members {
		p.members[i].TeamMemberID = strings.TrimSpace(p.members[i].TeamMemberID)
		p.members[i].RoleLabel = trimOptionalString(p.members[i].RoleLabel)
		p.members[i].RoleOverlayPrompt = trimOptionalString(p.members[i].RoleOverlayPrompt)
		if p.members[i].TeamMemberID == "" ||
			(p.members[i].RoleLabel == nil && p.members[i].RoleOverlayPrompt == nil && p.members[i].WakeOnTeamMention == nil) {
			return preparedSubmit{}, storeerr.BadRequest("team role proposal member id and one editable field are required")
		}
		if p.members[i].RoleLabel != nil && utf8.RuneCountInString(*p.members[i].RoleLabel) > maxRoleLabelChars {
			return preparedSubmit{}, storeerr.BadRequest("team role proposal member roleLabel is too large")
		}
		if p.members[i].RoleOverlayPrompt != nil && utf8.RuneCountInString(*p.members[i].RoleOverlayPrompt) > maxOverlayChars {
			return preparedSubmit{}, storeerr.BadRequest("team role proposal member roleOverlayPrompt is too large")
		}
	}
	if p.description == nil && len(p.members) == 0 {
		return preparedSubmit{}, storeerr.BadRequest("team role proposal requires description or members")
	}
	return p, nil
}

func canonicalPayload(p preparedSubmit, team teamState, members []memberState, leader memberState) (Payload, []byte, string, error) {
	base := Base{Team: BaseTeam{Description: team.Description}}
	payload := Payload{
		SchemaVersion: PayloadSchemaVersion, ClientRequestID: p.clientRequestID,
		Applicant: Applicant{AgentID: p.agentID, MemberID: p.memberID, TeamMemberID: leader.TeamMemberID},
		Base:      base, Proposed: Proposed{Description: cloneOptionalString(p.description)},
	}
	memberByID := make(map[string]memberState, len(members))
	for _, member := range members {
		memberByID[member.TeamMemberID] = member
	}
	for _, member := range p.members {
		trusted, ok := memberByID[member.TeamMemberID]
		if !ok {
			return Payload{}, nil, "", storeerr.BadRequest("team role proposal target member is not active")
		}
		base.Members = append(base.Members, BaseMember{
			TeamMemberID: trusted.TeamMemberID, MemberName: trusted.MemberName,
			MemberID: trusted.MemberID, Role: trusted.Role, RoleLabel: trusted.RoleLabel,
			RoleOverlayPrompt: trusted.RoleOverlayPrompt, RoleOverlayVersion: trusted.OverlayVersion,
			WakeOnTeamMention: trusted.WakeOnTeamMention,
		})
		payload.Proposed.Members = append(payload.Proposed.Members, MemberPayload{
			TeamMemberID: member.TeamMemberID, MemberName: trusted.MemberName,
			RoleLabel: cloneOptionalString(member.RoleLabel), RoleOverlayPrompt: cloneOptionalString(member.RoleOverlayPrompt),
			WakeOnTeamMention: cloneOptionalBool(member.WakeOnTeamMention),
		})
	}
	sort.Slice(base.Members, func(i, j int) bool {
		return base.Members[i].TeamMemberID < base.Members[j].TeamMemberID
	})
	base.Digest = digestBase(base)
	payload.Base = base
	return normalizePayload(payload)
}

func normalizePayload(payload Payload) (Payload, []byte, string, error) {
	if payload.SchemaVersion != PayloadSchemaVersion {
		return Payload{}, nil, "", storeerr.BadRequest("unsupported team role proposal payload schemaVersion")
	}
	payload.ClientRequestID = strings.TrimSpace(payload.ClientRequestID)
	payload.Applicant.AgentID = strings.TrimSpace(payload.Applicant.AgentID)
	payload.Applicant.MemberID = strings.TrimSpace(payload.Applicant.MemberID)
	payload.Applicant.TeamMemberID = strings.TrimSpace(payload.Applicant.TeamMemberID)
	payload.Proposed.Description = trimOptionalString(payload.Proposed.Description)
	if payload.ClientRequestID == "" || payload.Applicant.AgentID == "" || payload.Applicant.MemberID == "" || payload.Applicant.TeamMemberID == "" {
		return Payload{}, nil, "", storeerr.BadRequest("team role proposal payload identity is required")
	}
	if payload.Proposed.Description != nil && utf8.RuneCountInString(*payload.Proposed.Description) > maxDescriptionChars {
		return Payload{}, nil, "", storeerr.BadRequest("team role proposal payload is too large")
	}
	seen := map[string]struct{}{}
	for i := range payload.Proposed.Members {
		payload.Proposed.Members[i].TeamMemberID = strings.TrimSpace(payload.Proposed.Members[i].TeamMemberID)
		payload.Proposed.Members[i].MemberName = strings.TrimSpace(payload.Proposed.Members[i].MemberName)
		payload.Proposed.Members[i].RoleLabel = trimOptionalString(payload.Proposed.Members[i].RoleLabel)
		payload.Proposed.Members[i].RoleOverlayPrompt = trimOptionalString(payload.Proposed.Members[i].RoleOverlayPrompt)
		if payload.Proposed.Members[i].TeamMemberID == "" || payload.Proposed.Members[i].MemberName == "" ||
			(payload.Proposed.Members[i].RoleLabel == nil && payload.Proposed.Members[i].RoleOverlayPrompt == nil && payload.Proposed.Members[i].WakeOnTeamMention == nil) ||
			(payload.Proposed.Members[i].RoleLabel != nil && utf8.RuneCountInString(*payload.Proposed.Members[i].RoleLabel) > maxRoleLabelChars) ||
			(payload.Proposed.Members[i].RoleOverlayPrompt != nil && utf8.RuneCountInString(*payload.Proposed.Members[i].RoleOverlayPrompt) > maxOverlayChars) {
			return Payload{}, nil, "", storeerr.BadRequest("team role proposal member payload is invalid")
		}
		if _, ok := seen[payload.Proposed.Members[i].TeamMemberID]; ok {
			return Payload{}, nil, "", storeerr.BadRequest("team role proposal contains a duplicate Team member")
		}
		seen[payload.Proposed.Members[i].TeamMemberID] = struct{}{}
	}
	for i := range payload.Base.Members {
		payload.Base.Members[i].TeamMemberID = strings.TrimSpace(payload.Base.Members[i].TeamMemberID)
		payload.Base.Members[i].MemberName = strings.TrimSpace(payload.Base.Members[i].MemberName)
		payload.Base.Members[i].MemberID = strings.TrimSpace(payload.Base.Members[i].MemberID)
		payload.Base.Members[i].Role = strings.TrimSpace(payload.Base.Members[i].Role)
		payload.Base.Members[i].RoleLabel = strings.TrimSpace(payload.Base.Members[i].RoleLabel)
		payload.Base.Members[i].RoleOverlayPrompt = strings.TrimSpace(payload.Base.Members[i].RoleOverlayPrompt)
		if payload.Base.Members[i].TeamMemberID == "" || payload.Base.Members[i].MemberName == "" ||
			payload.Base.Members[i].MemberID == "" {
			return Payload{}, nil, "", storeerr.BadRequest("team role proposal base member snapshot is invalid")
		}
	}
	sort.Slice(payload.Proposed.Members, func(i, j int) bool {
		return payload.Proposed.Members[i].TeamMemberID < payload.Proposed.Members[j].TeamMemberID
	})
	sort.Slice(payload.Base.Members, func(i, j int) bool {
		return payload.Base.Members[i].TeamMemberID < payload.Base.Members[j].TeamMemberID
	})
	if payload.Proposed.Description == nil && len(payload.Proposed.Members) == 0 {
		return Payload{}, nil, "", storeerr.BadRequest("team role proposal payload has no changes")
	}
	if len(payload.Proposed.Members) != len(payload.Base.Members) {
		return Payload{}, nil, "", storeerr.BadRequest("team role proposal base member set is invalid")
	}
	for index := range payload.Proposed.Members {
		if payload.Proposed.Members[index].TeamMemberID != payload.Base.Members[index].TeamMemberID {
			return Payload{}, nil, "", storeerr.BadRequest("team role proposal base member set is invalid")
		}
	}
	if payload.Base.Digest != digestBase(payload.Base) {
		return Payload{}, nil, "", storeerr.Conflict("team role proposal base digest is invalid")
	}
	snapshot, err := json.Marshal(payload)
	if err != nil {
		return Payload{}, nil, "", fmt.Errorf("marshal team role proposal payload: %w", err)
	}
	var canonical any
	decoder := json.NewDecoder(strings.NewReader(string(snapshot)))
	decoder.UseNumber()
	if err := decoder.Decode(&canonical); err != nil {
		return Payload{}, nil, "", fmt.Errorf("canonicalize team role proposal payload: %w", err)
	}
	snapshot, err = json.Marshal(canonical)
	if err != nil {
		return Payload{}, nil, "", fmt.Errorf("marshal canonical team role proposal payload: %w", err)
	}
	return payload, snapshot, digestBytes(snapshot), nil
}

func decodePayload(instance approval.Instance) (Payload, error) {
	if len(instance.Payload) == 0 {
		return Payload{}, storeerr.Conflict("team role proposal payload is invalid")
	}
	var payload Payload
	if err := json.Unmarshal(instance.Payload, &payload); err != nil {
		return Payload{}, fmt.Errorf("decode team role proposal payload: %w", err)
	}
	normalized, _, digest, err := normalizePayload(payload)
	if err != nil || digest != instance.PayloadDigest {
		return Payload{}, storeerr.Conflict("team role proposal payload is invalid")
	}
	return normalized, nil
}

type baseDigestMember struct {
	TeamMemberID       string `json:"teamMemberId"`
	MemberID           string `json:"memberId"`
	Role               string `json:"role"`
	RoleLabel          string `json:"roleLabel"`
	RoleOverlayPrompt  string `json:"roleOverlayPrompt"`
	RoleOverlayVersion int64  `json:"roleOverlayVersion"`
	WakeOnTeamMention  bool   `json:"wakeOnTeamMention"`
}

func digestBase(base Base) string {
	base.Digest = ""
	encoded, _ := json.Marshal(struct {
		Team    BaseTeam           `json:"team"`
		Members []baseDigestMember `json:"members"`
	}{
		Team: base.Team,
		Members: func() []baseDigestMember {
			members := make([]baseDigestMember, 0, len(base.Members))
			for _, member := range base.Members {
				members = append(members, baseDigestMember{
					TeamMemberID: member.TeamMemberID, MemberID: member.MemberID,
					Role: member.Role, RoleLabel: member.RoleLabel, RoleOverlayPrompt: member.RoleOverlayPrompt,
					RoleOverlayVersion: member.RoleOverlayVersion,
					WakeOnTeamMention:  member.WakeOnTeamMention,
				})
			}
			return members
		}(),
	})
	return digestBytes(encoded)
}
func digestBytes(value []byte) string      { sum := sha256.Sum256(value); return hex.EncodeToString(sum[:]) }
func digestStrings(parts ...string) string { return digestBytes([]byte(strings.Join(parts, "\x00"))) }
func trimOptionalString(value *string) *string {
	if value == nil {
		return nil
	}
	trimmed := strings.TrimSpace(*value)
	return &trimmed
}
func cloneOptionalString(value *string) *string {
	if value == nil {
		return nil
	}
	cloned := *value
	return &cloned
}
func cloneOptionalBool(value *bool) *bool {
	if value == nil {
		return nil
	}
	cloned := *value
	return &cloned
}
func equalOptionalString(left, right *string) bool {
	return (left == nil && right == nil) || (left != nil && right != nil && *left == *right)
}
func equalOptionalBool(left, right *bool) bool {
	return (left == nil && right == nil) || (left != nil && right != nil && *left == *right)
}
func normalizedNow(value time.Time) time.Time {
	if value.IsZero() {
		return time.Now().UTC()
	}
	return value.UTC()
}

func proposalTargetsStillValid(base Base, proposed Proposed, members []memberState) bool {
	currentByID := make(map[string]memberState, len(members))
	for _, member := range members {
		currentByID[member.TeamMemberID] = member
	}
	baseByID := make(map[string]BaseMember, len(base.Members))
	for _, member := range base.Members {
		baseByID[member.TeamMemberID] = member
	}
	for _, change := range proposed.Members {
		current, currentOK := currentByID[change.TeamMemberID]
		before, baseOK := baseByID[change.TeamMemberID]
		if !currentOK || !baseOK || current.MemberID != before.MemberID {
			return false
		}
	}
	return true
}

func requireAuthenticatedLeader(p preparedSubmit, team teamState, members []memberState) (memberState, error) {
	if team.Status != "active" {
		return memberState{}, storeerr.Conflict("team is archived")
	}
	for _, member := range members {
		if member.Role == "leader" && member.MemberID == p.memberID {
			return member, nil
		}
	}
	return memberState{}, storeerr.Forbidden("only the current active Team leader may submit a role proposal")
}

func requireAuthenticatedAgent(ctx context.Context, q coredb.QueryRower, p preparedSubmit) error {
	var count int
	if err := q.QueryRow(ctx, `SELECT COUNT(*) FROM agents a JOIN members m ON m.workspace_id=a.workspace_id AND m.id=a.member_id WHERE a.workspace_id=$1 AND a.id=$2 AND a.member_id=$3 AND m.type='agent' AND m.status='active'`, p.workspaceID, p.agentID, p.memberID).Scan(&count); err != nil {
		return fmt.Errorf("validate authenticated Team leader Agent: %w", err)
	}
	if count != 1 {
		return storeerr.Forbidden("authenticated Agent does not match the active Team leader member")
	}
	return nil
}

// nextProposalSequence allocates this handler's next proposal sequence.
// The generic subject_version remains handler-defined: Service Account maps a
// source request version, while Team role proposals use a per-Team sequence.
func nextProposalSequence(
	ctx context.Context,
	q coredb.QueryRower,
	workspaceID, teamID string,
) (int64, error) {
	var sequence int64
	if err := q.QueryRow(ctx, `SELECT COALESCE(MAX(subject_version),0)+1 FROM approval_instances WHERE workspace_id=$1 AND subject_type=$2 AND subject_ref=$3`, workspaceID, SubjectType, teamID).Scan(&sequence); err != nil {
		return 0, fmt.Errorf("allocate Team role proposal sequence: %w", err)
	}
	return sequence, nil
}

func applyPayload(ctx context.Context, tx coredb.Tx, team teamState, members []memberState, proposed Proposed, now time.Time) error {
	if proposed.Description != nil {
		updated, err := tx.Exec(ctx, `UPDATE workspace_teams SET description=$3, updated_at=$4 WHERE workspace_id=$1 AND id=$2 AND status='active'`, team.WorkspaceID, team.TeamID, *proposed.Description, now)
		if err != nil {
			return fmt.Errorf("apply Team role proposal: %w", err)
		}
		if updated.RowsAffected() != 1 {
			return storeerr.Conflict("team role proposal Team is no longer active")
		}
	}
	byID := make(map[string]memberState, len(members))
	for _, m := range members {
		byID[m.TeamMemberID] = m
	}
	for _, member := range proposed.Members {
		current := byID[member.TeamMemberID]
		roleLabel := current.RoleLabel
		if member.RoleLabel != nil {
			roleLabel = *member.RoleLabel
		}
		overlay := current.RoleOverlayPrompt
		overlayVersion := current.OverlayVersion
		if member.RoleOverlayPrompt != nil {
			if *member.RoleOverlayPrompt != overlay {
				overlay = *member.RoleOverlayPrompt
				overlayVersion++
			}
		}
		wake := current.WakeOnTeamMention
		if member.WakeOnTeamMention != nil {
			wake = *member.WakeOnTeamMention
		}
		res, err := tx.Exec(ctx, `UPDATE workspace_team_members SET role_label=$3, role_overlay_prompt=$4, role_overlay_version=$5, wake_on_team_mention=$6, updated_at=$7 WHERE workspace_id=$1 AND id=$2 AND removed_at IS NULL`, team.WorkspaceID, member.TeamMemberID, roleLabel, overlay, overlayVersion, wake, now)
		if err != nil {
			return fmt.Errorf("apply Team member role proposal: %w", err)
		}
		if res.RowsAffected() != 1 {
			return storeerr.Conflict("team role proposal member is no longer active")
		}
	}
	return nil
}

func proposalFrom(instance approval.Instance) Proposal {
	proposal := Proposal{ID: instance.ID, WorkspaceID: instance.WorkspaceID, TeamID: instance.SubjectRef, ApplicantMemberID: instance.ApplicantMemberID, Status: instance.State, Version: instance.Version, Payload: append([]byte(nil), instance.Payload...), PayloadDigest: instance.PayloadDigest, CreatedAt: instance.CreatedAt, UpdatedAt: instance.UpdatedAt, DecidedAt: instance.DecidedAt}
	if payload, err := decodePayload(instance); err == nil {
		proposal.ApplicantAgentID = payload.Applicant.AgentID
		proposal.ApplicantMemberID = payload.Applicant.MemberID
		proposal.ApplicantTeamMemberID = payload.Applicant.TeamMemberID
		proposal.ClientRequestID = payload.ClientRequestID
	}
	return proposal
}

func (service *Service) loadSubmitReplay(ctx context.Context, tx coredb.Tx, instance approval.Instance, result *Result) error {
	assignments, err := service.approvals.InTx(tx).ListAssignments(ctx, instance.WorkspaceID, instance.ID)
	if err != nil {
		return err
	}
	*result = Result{Proposal: proposalFrom(instance), Approval: instance, Assignments: assignments, Created: false}
	return nil
}

func loadTeamState(ctx context.Context, q coredb.Queryer, workspaceID, teamID string, forUpdate bool) (teamState, []memberState, error) {
	query := `SELECT workspace_id,id,description,created_by_member_id,status FROM workspace_teams WHERE workspace_id=$1 AND id=$2`
	if forUpdate {
		query += ` FOR UPDATE`
	}
	var team teamState
	if err := q.QueryRow(ctx, query, strings.TrimSpace(workspaceID), strings.TrimSpace(teamID)).Scan(&team.WorkspaceID, &team.TeamID, &team.Description, &team.CreatedByMemberID, &team.Status); err != nil {
		if coredb.IsNotFound(err) || errors.Is(err, sql.ErrNoRows) {
			return teamState{}, nil, storeerr.NotFound("team not found")
		}
		return teamState{}, nil, fmt.Errorf("load Team proposal base: %w", err)
	}
	memberQuery := `
SELECT tm.id,
       tm.actor_id,
       COALESCE(NULLIF(TRIM(a.name), ''), NULLIF(TRIM(m.display_name), ''), tm.actor_id),
       tm.role,
       tm.role_label,
       tm.role_overlay_prompt,
       tm.role_overlay_version,
       tm.wake_on_team_mention
FROM workspace_team_members tm
LEFT JOIN members m
  ON m.workspace_id = tm.workspace_id
 AND m.id = tm.actor_id
 AND tm.actor_kind = 'agent'
LEFT JOIN agents a
  ON a.workspace_id = tm.workspace_id
 AND a.member_id = tm.actor_id
 AND tm.actor_kind = 'agent'
WHERE tm.workspace_id=$1
  AND tm.team_id=$2
  AND tm.removed_at IS NULL
ORDER BY tm.id`
	if forUpdate {
		memberQuery += ` FOR UPDATE`
	}
	rows, err := q.Query(ctx, memberQuery, team.WorkspaceID, team.TeamID)
	if err != nil {
		return teamState{}, nil, fmt.Errorf("load Team proposal roster: %w", err)
	}
	defer rows.Close()
	members := []memberState{}
	for rows.Next() {
		var m memberState
		if err := rows.Scan(&m.TeamMemberID, &m.MemberID, &m.MemberName, &m.Role, &m.RoleLabel, &m.RoleOverlayPrompt, &m.OverlayVersion, &m.WakeOnTeamMention); err != nil {
			return teamState{}, nil, err
		}
		members = append(members, m)
	}
	if err := rows.Err(); err != nil {
		return teamState{}, nil, err
	}
	if len(members) == 0 {
		return teamState{}, nil, storeerr.Conflict("Team has no active members")
	}
	return team, members, nil
}
