package taskboard

import (
	"context"
	"errors"
	"fmt"
	"strconv"
	"strings"
	"time"

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

const (
	taskReviewQueueDefaultLimit = 20
	taskReviewQueueMaxLimit     = 100
)

func (txs TxStore) RequestTaskReview(ctx context.Context, request RequestTaskReviewRequest) (RequestTaskReviewResponse, error) {
	workspaceID, err := request.Context.requireWorkspaceID()
	if err != nil {
		return RequestTaskReviewResponse{}, err
	}
	if err := validateTaskReviewRequestInput(request.Review); err != nil {
		return RequestTaskReviewResponse{}, err
	}

	switch request.Context.principalType() {
	case PrincipalTypeUser:
		userID, _, err := request.Context.requireUser()
		if err != nil {
			return RequestTaskReviewResponse{}, err
		}
		taskID, err := request.Task.requireTaskID()
		if err != nil {
			return RequestTaskReviewResponse{}, err
		}
		task, review, err := txs.requestTaskReviewForUser(ctx, userID, workspaceID, taskID, request.Review)
		if err != nil {
			return RequestTaskReviewResponse{}, err
		}
		return RequestTaskReviewResponse{Task: task, Review: review}, nil
	case PrincipalTypeAgent:
		agent, err := request.Context.requireAgentActor()
		if err != nil {
			return RequestTaskReviewResponse{}, err
		}
		taskNumber, err := request.Task.requireTaskNumber()
		if err != nil {
			return RequestTaskReviewResponse{}, err
		}
		task, review, err := txs.requestTaskReviewForAgent(ctx, agent, request.Board, taskNumber, request.Review)
		if err != nil {
			return RequestTaskReviewResponse{}, err
		}
		return RequestTaskReviewResponse{Task: task, Review: review}, nil
	default:
		return RequestTaskReviewResponse{}, badRequest("context subject principalType is required")
	}
}

func (txs TxStore) ApproveTaskReview(ctx context.Context, request ApproveTaskReviewRequest) (TaskReviewDecisionResponse, error) {
	workspaceID, err := request.Context.requireWorkspaceID()
	if err != nil {
		return TaskReviewDecisionResponse{}, err
	}
	if err := validateTaskReviewDecisionInput(request.Review); err != nil {
		return TaskReviewDecisionResponse{}, err
	}

	switch request.Context.principalType() {
	case PrincipalTypeUser:
		userID, _, err := request.Context.requireUser()
		if err != nil {
			return TaskReviewDecisionResponse{}, err
		}
		taskID, err := request.Task.requireTaskID()
		if err != nil {
			return TaskReviewDecisionResponse{}, err
		}
		task, review, err := txs.resolveTaskReviewForUser(ctx, userID, workspaceID, taskID, request.Review, taskReviewStatusApproved)
		if err != nil {
			return TaskReviewDecisionResponse{}, err
		}
		return TaskReviewDecisionResponse{Task: task, Review: review}, nil
	case PrincipalTypeAgent:
		agent, err := request.Context.requireAgentActor()
		if err != nil {
			return TaskReviewDecisionResponse{}, err
		}
		taskNumber, err := request.Task.requireTaskNumber()
		if err != nil {
			return TaskReviewDecisionResponse{}, err
		}
		task, review, err := txs.resolveTaskReviewForAgent(ctx, agent, request.Board, taskNumber, request.Review, taskReviewStatusApproved)
		if err != nil {
			return TaskReviewDecisionResponse{}, err
		}
		return TaskReviewDecisionResponse{Task: task, Review: review}, nil
	default:
		return TaskReviewDecisionResponse{}, badRequest("context subject principalType is required")
	}
}

func (txs TxStore) RequestTaskChanges(ctx context.Context, request RequestTaskChangesRequest) (TaskReviewDecisionResponse, error) {
	workspaceID, err := request.Context.requireWorkspaceID()
	if err != nil {
		return TaskReviewDecisionResponse{}, err
	}
	if err := validateTaskReviewDecisionInput(request.Review); err != nil {
		return TaskReviewDecisionResponse{}, err
	}

	switch request.Context.principalType() {
	case PrincipalTypeUser:
		userID, _, err := request.Context.requireUser()
		if err != nil {
			return TaskReviewDecisionResponse{}, err
		}
		taskID, err := request.Task.requireTaskID()
		if err != nil {
			return TaskReviewDecisionResponse{}, err
		}
		task, review, err := txs.resolveTaskReviewForUser(ctx, userID, workspaceID, taskID, request.Review, taskReviewStatusChangesRequested)
		if err != nil {
			return TaskReviewDecisionResponse{}, err
		}
		return TaskReviewDecisionResponse{Task: task, Review: review}, nil
	case PrincipalTypeAgent:
		agent, err := request.Context.requireAgentActor()
		if err != nil {
			return TaskReviewDecisionResponse{}, err
		}
		taskNumber, err := request.Task.requireTaskNumber()
		if err != nil {
			return TaskReviewDecisionResponse{}, err
		}
		task, review, err := txs.resolveTaskReviewForAgent(ctx, agent, request.Board, taskNumber, request.Review, taskReviewStatusChangesRequested)
		if err != nil {
			return TaskReviewDecisionResponse{}, err
		}
		return TaskReviewDecisionResponse{Task: task, Review: review}, nil
	default:
		return TaskReviewDecisionResponse{}, badRequest("context subject principalType is required")
	}
}

func (txs TxStore) ReassignTaskReview(ctx context.Context, request ReassignTaskReviewRequest) (TaskReviewDecisionResponse, error) {
	workspaceID, err := request.Context.requireWorkspaceID()
	if err != nil {
		return TaskReviewDecisionResponse{}, err
	}

	switch request.Context.principalType() {
	case PrincipalTypeUser:
		userID, _, err := request.Context.requireUser()
		if err != nil {
			return TaskReviewDecisionResponse{}, err
		}
		taskID, err := request.Task.requireTaskID()
		if err != nil {
			return TaskReviewDecisionResponse{}, err
		}
		task, review, err := txs.reassignTaskReviewForUser(ctx, userID, workspaceID, taskID, request.Review)
		if err != nil {
			return TaskReviewDecisionResponse{}, err
		}
		return TaskReviewDecisionResponse{Task: task, Review: review}, nil
	case PrincipalTypeAgent:
		agent, err := request.Context.requireAgentActor()
		if err != nil {
			return TaskReviewDecisionResponse{}, err
		}
		taskNumber, err := request.Task.requireTaskNumber()
		if err != nil {
			return TaskReviewDecisionResponse{}, err
		}
		task, review, err := txs.reassignTaskReviewForAgent(ctx, agent, request.Board, taskNumber, request.Review)
		if err != nil {
			return TaskReviewDecisionResponse{}, err
		}
		return TaskReviewDecisionResponse{Task: task, Review: review}, nil
	default:
		return TaskReviewDecisionResponse{}, badRequest("context subject principalType is required")
	}
}

func (txs TxStore) MarkTaskReviewNotReviewable(ctx context.Context, request MarkTaskReviewNotReviewableRequest) (TaskReviewDecisionResponse, error) {
	workspaceID, err := request.Context.requireWorkspaceID()
	if err != nil {
		return TaskReviewDecisionResponse{}, err
	}

	switch request.Context.principalType() {
	case PrincipalTypeUser:
		userID, _, err := request.Context.requireUser()
		if err != nil {
			return TaskReviewDecisionResponse{}, err
		}
		taskID, err := request.Task.requireTaskID()
		if err != nil {
			return TaskReviewDecisionResponse{}, err
		}
		task, review, err := txs.markTaskReviewNotReviewableForUser(ctx, userID, workspaceID, taskID, request.Review)
		if err != nil {
			return TaskReviewDecisionResponse{}, err
		}
		return TaskReviewDecisionResponse{Task: task, Review: review}, nil
	case PrincipalTypeAgent:
		agent, err := request.Context.requireAgentActor()
		if err != nil {
			return TaskReviewDecisionResponse{}, err
		}
		taskNumber, err := request.Task.requireTaskNumber()
		if err != nil {
			return TaskReviewDecisionResponse{}, err
		}
		task, review, err := txs.markTaskReviewNotReviewableForAgent(ctx, agent, request.Board, taskNumber, request.Review)
		if err != nil {
			return TaskReviewDecisionResponse{}, err
		}
		return TaskReviewDecisionResponse{Task: task, Review: review}, nil
	default:
		return TaskReviewDecisionResponse{}, badRequest("context subject principalType is required")
	}
}

func (txs TxStore) ListTaskReviewQueue(ctx context.Context, request ListTaskReviewQueueRequest) (ListTaskReviewQueueResponse, error) {
	workspaceID, err := request.Context.requireWorkspaceID()
	if err != nil {
		return ListTaskReviewQueueResponse{}, err
	}

	switch request.Context.principalType() {
	case PrincipalTypeUser:
		userID, _, err := request.Context.requireUser()
		if err != nil {
			return ListTaskReviewQueueResponse{}, err
		}
		channelID, err := request.Board.requireChannelID()
		if err != nil {
			return ListTaskReviewQueueResponse{}, err
		}
		reviews, limit, hasMore, nextCursor, err := txs.listTaskReviewQueueForUser(ctx, userID, workspaceID, channelID, request.Limit, request.Cursor)
		if err != nil {
			return ListTaskReviewQueueResponse{}, err
		}
		return ListTaskReviewQueueResponse{Reviews: reviews, Limit: limit, HasMore: hasMore, NextCursor: nextCursor}, nil
	case PrincipalTypeAgent:
		agent, err := request.Context.requireAgentActor()
		if err != nil {
			return ListTaskReviewQueueResponse{}, err
		}
		reviews, limit, hasMore, nextCursor, err := txs.listTaskReviewQueueForAgent(ctx, agent, request.Board, request.Limit, request.Cursor)
		if err != nil {
			return ListTaskReviewQueueResponse{}, err
		}
		return ListTaskReviewQueueResponse{Reviews: reviews, Limit: limit, HasMore: hasMore, NextCursor: nextCursor}, nil
	default:
		return ListTaskReviewQueueResponse{}, badRequest("context subject principalType is required")
	}
}

func (txs TxStore) requestTaskReviewForUser(ctx context.Context, userID string, workspaceID string, taskID string, input TaskReviewRequestInput) (Task, TaskReviewRequest, error) {
	tx := txs.tx
	channelID, _, _, err := txs.lockTaskRow(ctx, tx, workspaceID, taskID)
	if err != nil {
		return Task{}, TaskReviewRequest{}, err
	}
	channelAccess, err := txs.loadAuthorizedUserTaskChannelAccess(ctx, tx, userID, workspaceID, channelID, taskWriteChannelAction)
	if err != nil {
		return Task{}, TaskReviewRequest{}, err
	}
	// TOCTOU fence: the Task row is already locked (lockTaskRow FOR UPDATE);
	// lock the user's active membership on the canonical board Channel before
	// writing, so a concurrent board-membership revoke serializes deterministically.
	if err := txs.lockActiveHumanBoardChannelAuthority(ctx, tx, userID, workspaceID, channelID); err != nil {
		return Task{}, TaskReviewRequest{}, err
	}
	task, review, err := txs.requestTaskReviewInTx(ctx, tx, workspaceID, channelID, taskID, channelAccess.WorkspaceMember.Member.ID, input)
	if err != nil {
		return Task{}, TaskReviewRequest{}, err
	}
	ensureTaskChannelID(&task, channelID)
	return task, review, nil
}

func (txs TxStore) requestTaskReviewForAgent(ctx context.Context, agent AgentActor, board BoardRef, taskNumber int64, input TaskReviewRequestInput) (Task, TaskReviewRequest, error) {
	tx := txs.tx
	channelID, taskID, err := txs.loadAccessibleAgentTaskID(ctx, tx, agent, board, taskNumber)
	if err != nil {
		return Task{}, TaskReviewRequest{}, err
	}
	// TOCTOU fence: lock the canonical Task row FOR UPDATE (deriving the board
	// Channel server-side) and the agent's active membership on it in a fixed
	// order, so a concurrent board-membership revoke / Task delete / board move
	// serializes deterministically.
	if _, err := txs.lockTaskBoardChannelAuthority(ctx, tx, agent.WorkspaceID, taskID, agent.MemberID); err != nil {
		return Task{}, TaskReviewRequest{}, err
	}
	task, review, err := txs.requestTaskReviewInTx(ctx, tx, agent.WorkspaceID, channelID, taskID, agent.MemberID, input)
	if err != nil {
		return Task{}, TaskReviewRequest{}, err
	}
	ensureTaskChannelID(&task, channelID)
	return task, review, nil
}

func (txs TxStore) resolveTaskReviewForUser(ctx context.Context, userID string, workspaceID string, taskID string, input TaskReviewDecisionInput, decisionStatus string) (Task, TaskReviewRequest, error) {
	tx := txs.tx
	channelID, _, _, err := txs.lockTaskRow(ctx, tx, workspaceID, taskID)
	if err != nil {
		return Task{}, TaskReviewRequest{}, err
	}
	channelAccess, err := txs.loadAuthorizedUserTaskChannelAccess(ctx, tx, userID, workspaceID, channelID, taskWriteChannelAction)
	if err != nil {
		return Task{}, TaskReviewRequest{}, err
	}
	// TOCTOU fence: the Task row is already locked (lockTaskRow FOR UPDATE);
	// lock the user's active membership on the canonical board Channel before
	// writing, so a concurrent board-membership revoke serializes deterministically.
	if err := txs.lockActiveHumanBoardChannelAuthority(ctx, tx, userID, workspaceID, channelID); err != nil {
		return Task{}, TaskReviewRequest{}, err
	}
	task, review, err := txs.resolveTaskReviewInTx(ctx, tx, workspaceID, channelID, taskID, channelAccess.WorkspaceMember.Member.ID, input, decisionStatus)
	if err != nil {
		return Task{}, TaskReviewRequest{}, err
	}
	ensureTaskChannelID(&task, channelID)
	return task, review, nil
}

func (txs TxStore) resolveTaskReviewForAgent(ctx context.Context, agent AgentActor, board BoardRef, taskNumber int64, input TaskReviewDecisionInput, decisionStatus string) (Task, TaskReviewRequest, error) {
	tx := txs.tx
	channelID, taskID, err := txs.loadAccessibleAgentTaskID(ctx, tx, agent, board, taskNumber)
	if err != nil {
		return Task{}, TaskReviewRequest{}, err
	}
	// TOCTOU fence: lock the canonical Task row FOR UPDATE (deriving the board
	// Channel server-side) and the agent's active membership on it in a fixed
	// order, so a concurrent board-membership revoke / Task delete / board move
	// serializes deterministically.
	if _, err := txs.lockTaskBoardChannelAuthority(ctx, tx, agent.WorkspaceID, taskID, agent.MemberID); err != nil {
		return Task{}, TaskReviewRequest{}, err
	}
	task, review, err := txs.resolveTaskReviewInTx(ctx, tx, agent.WorkspaceID, channelID, taskID, agent.MemberID, input, decisionStatus)
	if err != nil {
		return Task{}, TaskReviewRequest{}, err
	}
	ensureTaskChannelID(&task, channelID)
	return task, review, nil
}

func (txs TxStore) reassignTaskReviewForUser(ctx context.Context, userID string, workspaceID string, taskID string, input TaskReviewReassignmentInput) (Task, TaskReviewRequest, error) {
	tx := txs.tx
	channelID, _, _, err := txs.lockTaskRow(ctx, tx, workspaceID, taskID)
	if err != nil {
		return Task{}, TaskReviewRequest{}, err
	}
	channelAccess, err := txs.loadAuthorizedUserTaskChannelAccess(ctx, tx, userID, workspaceID, channelID, taskWriteChannelAction)
	if err != nil {
		return Task{}, TaskReviewRequest{}, err
	}
	// TOCTOU fence: the Task row is already locked (lockTaskRow FOR UPDATE);
	// lock the user's active membership on the canonical board Channel before
	// writing, so a concurrent board-membership revoke serializes deterministically.
	if err := txs.lockActiveHumanBoardChannelAuthority(ctx, tx, userID, workspaceID, channelID); err != nil {
		return Task{}, TaskReviewRequest{}, err
	}
	task, review, err := txs.reassignTaskReviewInTx(ctx, tx, workspaceID, channelID, taskID, channelAccess.WorkspaceMember.Member.ID, input)
	if err != nil {
		return Task{}, TaskReviewRequest{}, err
	}
	ensureTaskChannelID(&task, channelID)
	return task, review, nil
}

func (txs TxStore) reassignTaskReviewForAgent(ctx context.Context, agent AgentActor, board BoardRef, taskNumber int64, input TaskReviewReassignmentInput) (Task, TaskReviewRequest, error) {
	tx := txs.tx
	channelID, taskID, err := txs.loadAccessibleAgentTaskID(ctx, tx, agent, board, taskNumber)
	if err != nil {
		return Task{}, TaskReviewRequest{}, err
	}
	// TOCTOU fence: lock the canonical Task row FOR UPDATE (deriving the board
	// Channel server-side) and the agent's active membership on it in a fixed
	// order, so a concurrent board-membership revoke / Task delete / board move
	// serializes deterministically.
	if _, err := txs.lockTaskBoardChannelAuthority(ctx, tx, agent.WorkspaceID, taskID, agent.MemberID); err != nil {
		return Task{}, TaskReviewRequest{}, err
	}
	task, review, err := txs.reassignTaskReviewInTx(ctx, tx, agent.WorkspaceID, channelID, taskID, agent.MemberID, input)
	if err != nil {
		return Task{}, TaskReviewRequest{}, err
	}
	ensureTaskChannelID(&task, channelID)
	return task, review, nil
}

func (txs TxStore) markTaskReviewNotReviewableForUser(ctx context.Context, userID string, workspaceID string, taskID string, input TaskReviewCancellationInput) (Task, TaskReviewRequest, error) {
	tx := txs.tx
	channelID, _, _, err := txs.lockTaskRow(ctx, tx, workspaceID, taskID)
	if err != nil {
		return Task{}, TaskReviewRequest{}, err
	}
	channelAccess, err := txs.loadAuthorizedUserTaskChannelAccess(ctx, tx, userID, workspaceID, channelID, taskWriteChannelAction)
	if err != nil {
		return Task{}, TaskReviewRequest{}, err
	}
	// TOCTOU fence: the Task row is already locked (lockTaskRow FOR UPDATE);
	// lock the user's active membership on the canonical board Channel before
	// writing, so a concurrent board-membership revoke serializes deterministically.
	if err := txs.lockActiveHumanBoardChannelAuthority(ctx, tx, userID, workspaceID, channelID); err != nil {
		return Task{}, TaskReviewRequest{}, err
	}
	task, review, err := txs.markTaskReviewNotReviewableInTx(ctx, tx, workspaceID, channelID, taskID, channelAccess.WorkspaceMember.Member.ID, input)
	if err != nil {
		return Task{}, TaskReviewRequest{}, err
	}
	ensureTaskChannelID(&task, channelID)
	return task, review, nil
}

func (txs TxStore) markTaskReviewNotReviewableForAgent(ctx context.Context, agent AgentActor, board BoardRef, taskNumber int64, input TaskReviewCancellationInput) (Task, TaskReviewRequest, error) {
	tx := txs.tx
	channelID, taskID, err := txs.loadAccessibleAgentTaskID(ctx, tx, agent, board, taskNumber)
	if err != nil {
		return Task{}, TaskReviewRequest{}, err
	}
	// TOCTOU fence: lock the canonical Task row FOR UPDATE (deriving the board
	// Channel server-side) and the agent's active membership on it in a fixed
	// order, so a concurrent board-membership revoke / Task delete / board move
	// serializes deterministically.
	if _, err := txs.lockTaskBoardChannelAuthority(ctx, tx, agent.WorkspaceID, taskID, agent.MemberID); err != nil {
		return Task{}, TaskReviewRequest{}, err
	}
	task, review, err := txs.markTaskReviewNotReviewableInTx(ctx, tx, agent.WorkspaceID, channelID, taskID, agent.MemberID, input)
	if err != nil {
		return Task{}, TaskReviewRequest{}, err
	}
	ensureTaskChannelID(&task, channelID)
	return task, review, nil
}

func (txs TxStore) listTaskReviewQueueForUser(ctx context.Context, userID string, workspaceID string, channelID string, limit int, cursor string) ([]TaskReviewRequest, int, bool, string, error) {
	channelAccess, err := txs.loadAuthorizedUserTaskChannelAccess(ctx, txs.db, userID, workspaceID, channelID, taskReadChannelAction)
	if err != nil {
		return nil, 0, false, "", err
	}
	return loadTaskReviewQueue(ctx, txs.db, channelAccess.Channel.ChannelID, channelAccess.WorkspaceMember.Member.ID, limit, cursor)
}

func (txs TxStore) listTaskReviewQueueForAgent(ctx context.Context, agent AgentActor, board BoardRef, limit int, cursor string) ([]TaskReviewRequest, int, bool, string, error) {
	channelID, err := txs.resolveAccessibleAgentBoard(ctx, txs.db, agent, board)
	if err != nil {
		return nil, 0, false, "", err
	}
	return loadTaskReviewQueue(ctx, txs.db, channelID, agent.MemberID, limit, cursor)
}

func (txs TxStore) requestTaskReviewInTx(ctx context.Context, tx coredb.Tx, workspaceID string, channelID string, taskID string, actorMemberID string, input TaskReviewRequestInput) (Task, TaskReviewRequest, error) {
	_, rawStatus, _, err := txs.lockTaskRow(ctx, tx, workspaceID, taskID)
	if err != nil {
		return Task{}, TaskReviewRequest{}, err
	}
	normalizedReview, err := normalizeTaskReviewRequestInput(input)
	if err != nil {
		return Task{}, TaskReviewRequest{}, err
	}
	if normalizedReview.ReviewerRef != "" {
		resolvedReviewerRef, err := resolveOptionalTaskAssigneeRef(ctx, tx, workspaceID, channelID, actorMemberID, normalizedReview.ReviewerRef)
		if err != nil {
			return Task{}, TaskReviewRequest{}, err
		}
		normalizedReview.ReviewerRef = resolvedReviewerRef
	}
	questionsJSON, err := marshalStringList(normalizedReview.Questions)
	if err != nil {
		return Task{}, TaskReviewRequest{}, err
	}
	targetPayloadJSON, err := normalizeJSONDocument(normalizedReview.ReviewTargetPayload, `{}`)
	if err != nil {
		return Task{}, TaskReviewRequest{}, err
	}
	if Status(rawStatus) == StatusInReview {
		existingReview, found, err := loadPendingTaskReviewForUpdate(ctx, tx, taskID)
		if err != nil {
			return Task{}, TaskReviewRequest{}, err
		}
		if found && taskReviewRequestMatches(existingReview, normalizedReview, targetPayloadJSON) {
			task, err := txs.taskByID(ctx, tx, workspaceID, taskID)
			if err != nil {
				return Task{}, TaskReviewRequest{}, err
			}
			return hydratedTaskReviewResponse(ctx, tx, task, existingReview)
		}
	}
	if err := validateStatusTransition(Status(rawStatus), StatusInReview); err != nil {
		return Task{}, TaskReviewRequest{}, err
	}

	now := time.Now().UTC()
	if err := supersedeActiveTaskReviews(ctx, tx, taskID, actorMemberID, now); err != nil {
		return Task{}, TaskReviewRequest{}, err
	}

	reviewID, err := newTaskReviewRequestID()
	if err != nil {
		return Task{}, TaskReviewRequest{}, fmt.Errorf("generate task review id: %w", err)
	}
	if _, err := tx.Exec(ctx, fmt.Sprintf(`
INSERT INTO task_review_requests (
    id,
    task_id,
    status,
    requested_by_ref,
    reviewer_ref,
    summary_md,
    questions_json,
    review_target_type,
    review_target_payload_json,
    requested_at
) VALUES ($1, $2, $3, $4, $5, $6, %s, $8, %s, $10)
`, taskboardJSONParamExpression(tx, 7), taskboardJSONParamExpression(tx, 9)), reviewID, taskID, taskReviewStatusRequested, actorMemberID, normalizedReview.ReviewerRef, normalizedReview.Summary, string(questionsJSON), normalizedReview.ReviewTargetType, string(targetPayloadJSON), now); err != nil {
		return Task{}, TaskReviewRequest{}, fmt.Errorf("insert task review request: %w", err)
	}
	if _, err := tx.Exec(ctx, `
UPDATE tasks
SET status = $2,
    completed_by_member_id = NULL,
    completed_by_ref = NULL,
    completed_at = NULL,
    last_activity_at = $3,
    updated_at = $3
WHERE id = $1
`, taskID, StatusInReview, now); err != nil {
		return Task{}, TaskReviewRequest{}, fmt.Errorf("mark task in review: %w", err)
	}
	if err := txs.rollUpChildReviewsForParentTask(ctx, tx, workspaceID, taskID, actorMemberID, now); err != nil {
		return Task{}, TaskReviewRequest{}, err
	}
	taskScopeID, err := loadTaskScopeIDForParticipantNotification(ctx, tx, taskID)
	if err != nil {
		return Task{}, TaskReviewRequest{}, err
	}
	if err := ensureTaskScopeParticipantsDefaultUnwatched(ctx, tx, taskScopeID, []string{normalizedReview.ReviewerRef}, now); err != nil {
		return Task{}, TaskReviewRequest{}, err
	}
	activityIntents := make([]tasktimeline.TypedActivityIntent, 0, 2)
	if Status(rawStatus) != StatusInReview {
		activityIntents = append(activityIntents, taskTypedEventIntent(taskID, actorMemberID, taskEventStatusChanged, string(StatusInReview), now))
	}
	activityIntents = append(activityIntents, taskTypedEventIntent(taskID, actorMemberID, taskEventReviewRequested, reviewID, now))
	if err := applyTaskTypedEventPlan(ctx, tx, activityIntents...); err != nil {
		return Task{}, TaskReviewRequest{}, err
	}
	task, err := txs.taskByID(ctx, tx, workspaceID, taskID)
	if err != nil {
		return Task{}, TaskReviewRequest{}, err
	}
	review, err := loadTaskReviewByID(ctx, tx, taskID, reviewID)
	if err != nil {
		return Task{}, TaskReviewRequest{}, err
	}
	return task, review, nil
}

func supersedeActiveTaskReviews(ctx context.Context, execer coredb.QueryExecer, taskID string, actorMemberID string, now time.Time) error {
	if _, err := execer.Exec(ctx, `
UPDATE task_review_requests
SET status = $2,
    decided_by_ref = $3,
    decided_at = $4
WHERE task_id = $1
  AND status = $5
`, taskID, taskReviewStatusSuperseded, actorMemberID, now, taskReviewStatusRequested); err != nil {
		return fmt.Errorf("supersede active task reviews: %w", err)
	}
	return nil
}

func (txs TxStore) reassignTaskReviewInTx(ctx context.Context, tx coredb.Tx, workspaceID string, channelID string, taskID string, actorMemberID string, input TaskReviewReassignmentInput) (Task, TaskReviewRequest, error) {
	_, rawStatus, _, err := txs.lockTaskRow(ctx, tx, workspaceID, taskID)
	if err != nil {
		return Task{}, TaskReviewRequest{}, err
	}
	if Status(rawStatus) != StatusInReview {
		return Task{}, TaskReviewRequest{}, conflict("invalid task status transition")
	}

	normalized, err := normalizeTaskReviewReassignmentInput(input)
	if err != nil {
		return Task{}, TaskReviewRequest{}, err
	}
	resolvedReviewerRef, err := resolveOptionalTaskAssigneeRef(ctx, tx, workspaceID, channelID, actorMemberID, normalized.ReviewerRef)
	if err != nil {
		return Task{}, TaskReviewRequest{}, err
	}
	normalized.ReviewerRef = resolvedReviewerRef

	review, err := loadTaskReviewByIDForUpdate(ctx, tx, taskID, normalized.ReviewID)
	if err != nil {
		return Task{}, TaskReviewRequest{}, err
	}
	if review.Status != taskReviewStatusRequested {
		return Task{}, TaskReviewRequest{}, conflict("task review is not pending")
	}
	if strings.TrimSpace(review.ReviewerRef) == normalized.ReviewerRef {
		task, err := txs.taskByID(ctx, tx, workspaceID, taskID)
		if err != nil {
			return Task{}, TaskReviewRequest{}, err
		}
		return hydratedTaskReviewResponse(ctx, tx, task, review)
	}

	now := time.Now().UTC()
	if _, err := tx.Exec(ctx, `
UPDATE task_review_requests
SET reviewer_ref = $3
WHERE id = $1
  AND task_id = $2
`, normalized.ReviewID, taskID, normalized.ReviewerRef); err != nil {
		return Task{}, TaskReviewRequest{}, fmt.Errorf("reassign task review reviewer: %w", err)
	}
	if _, err := tx.Exec(ctx, `
UPDATE tasks
SET last_activity_at = $2,
    updated_at = $2
WHERE id = $1
`, taskID, now); err != nil {
		return Task{}, TaskReviewRequest{}, fmt.Errorf("update task reviewer after reassignment: %w", err)
	}
	taskScopeID, err := loadTaskScopeIDForParticipantNotification(ctx, tx, taskID)
	if err != nil {
		return Task{}, TaskReviewRequest{}, err
	}
	if err := ensureTaskScopeParticipantsDefaultUnwatched(ctx, tx, taskScopeID, []string{normalized.ReviewerRef}, now); err != nil {
		return Task{}, TaskReviewRequest{}, err
	}
	if err := applyTaskTypedEventPlan(ctx, tx,
		taskTypedEventIntent(taskID, actorMemberID, taskEventReviewerAssigned, normalized.ReviewID, now),
	); err != nil {
		return Task{}, TaskReviewRequest{}, err
	}
	task, err := txs.taskByID(ctx, tx, workspaceID, taskID)
	if err != nil {
		return Task{}, TaskReviewRequest{}, err
	}
	review, err = loadTaskReviewByID(ctx, tx, taskID, normalized.ReviewID)
	if err != nil {
		return Task{}, TaskReviewRequest{}, err
	}
	return task, review, nil
}

func hydratedTaskReviewResponse(ctx context.Context, queryer coredb.Queryer, task Task, review TaskReviewRequest) (Task, TaskReviewRequest, error) {
	reviews := []TaskReviewRequest{review}
	if err := hydrateTaskReviewPrincipalProfiles(ctx, queryer, reviews); err != nil {
		return Task{}, TaskReviewRequest{}, err
	}
	return task, reviews[0], nil
}

func (txs TxStore) resolveTaskReviewInTx(ctx context.Context, tx coredb.Tx, workspaceID string, channelID string, taskID string, actorMemberID string, input TaskReviewDecisionInput, decisionStatus string) (Task, TaskReviewRequest, error) {
	_, rawStatus, _, err := txs.lockTaskRow(ctx, tx, workspaceID, taskID)
	if err != nil {
		return Task{}, TaskReviewRequest{}, err
	}

	reviewID := strings.TrimSpace(input.ReviewID)
	if reviewID == "" {
		return Task{}, TaskReviewRequest{}, badRequest("reviewId is required")
	}
	review, err := loadTaskReviewByIDForUpdate(ctx, tx, taskID, reviewID)
	if err != nil {
		return Task{}, TaskReviewRequest{}, err
	}
	if review.Status != taskReviewStatusRequested {
		return Task{}, TaskReviewRequest{}, conflict("task review is not pending")
	}

	currentStatus := Status(rawStatus)
	now := time.Now().UTC()
	switch decisionStatus {
	case taskReviewStatusApproved:
		if err := validateStatusTransition(currentStatus, StatusDone); err != nil {
			return Task{}, TaskReviewRequest{}, err
		}
	case taskReviewStatusChangesRequested:
		if err := validateStatusTransition(currentStatus, StatusInProgress); err != nil {
			return Task{}, TaskReviewRequest{}, err
		}
	default:
		return Task{}, TaskReviewRequest{}, badRequest("invalid review decision status")
	}

	decisionQuestionsJSON, err := marshalStringList(input.Questions)
	if err != nil {
		return Task{}, TaskReviewRequest{}, err
	}
	if _, err := tx.Exec(ctx, fmt.Sprintf(`
UPDATE task_review_requests
SET status = $3,
    decision_md = $4,
    decision_summary_md = $5,
    decision_questions_json = %s,
    decided_by_ref = $7,
    decided_at = $8
WHERE id = $1
  AND task_id = $2
`, taskboardJSONParamExpression(tx, 6)), reviewID, taskID, decisionStatus, strings.TrimSpace(input.Decision), strings.TrimSpace(input.Summary), string(decisionQuestionsJSON), actorMemberID, now); err != nil {
		return Task{}, TaskReviewRequest{}, fmt.Errorf("update task review decision: %w", err)
	}

	eventType := taskEventReviewChangesRequested
	desiredStatus := StatusInProgress
	if decisionStatus == taskReviewStatusApproved {
		eventType = taskEventReviewApproved
		desiredStatus = StatusDone
		if _, err := tx.Exec(ctx, `
UPDATE tasks
SET status = $2,
    completed_by_member_id = $3,
    completed_by_ref = $3,
    completed_at = $4,
    last_activity_at = $4,
    updated_at = $4
WHERE id = $1
`, taskID, desiredStatus, actorMemberID, now); err != nil {
			return Task{}, TaskReviewRequest{}, fmt.Errorf("complete task after approval: %w", err)
		}
		if err := txs.rollUpChildReviewsForParentTask(ctx, tx, workspaceID, taskID, actorMemberID, now); err != nil {
			return Task{}, TaskReviewRequest{}, err
		}
	} else {
		if _, err := tx.Exec(ctx, `
UPDATE tasks
SET status = $2,
    completed_by_member_id = NULL,
    completed_by_ref = NULL,
    completed_at = NULL,
    last_activity_at = $3,
    updated_at = $3
WHERE id = $1
`, taskID, desiredStatus, now); err != nil {
			return Task{}, TaskReviewRequest{}, fmt.Errorf("return task to in progress: %w", err)
		}
	}
	if err := applyTaskTypedEventPlan(ctx, tx,
		taskTypedEventIntent(taskID, actorMemberID, taskEventStatusChanged, string(desiredStatus), now),
		taskTypedEventIntent(taskID, actorMemberID, eventType, reviewID, now),
	); err != nil {
		return Task{}, TaskReviewRequest{}, err
	}
	task, err := txs.taskByID(ctx, tx, workspaceID, taskID)
	if err != nil {
		return Task{}, TaskReviewRequest{}, err
	}
	review, err = loadTaskReviewByID(ctx, tx, taskID, reviewID)
	if err != nil {
		return Task{}, TaskReviewRequest{}, err
	}
	return task, review, nil
}

func (txs TxStore) markTaskReviewNotReviewableInTx(ctx context.Context, tx coredb.Tx, workspaceID string, channelID string, taskID string, actorMemberID string, input TaskReviewCancellationInput) (Task, TaskReviewRequest, error) {
	_, rawStatus, _, err := txs.lockTaskRow(ctx, tx, workspaceID, taskID)
	if err != nil {
		return Task{}, TaskReviewRequest{}, err
	}
	if Status(rawStatus) != StatusInReview {
		return Task{}, TaskReviewRequest{}, conflict("invalid task status transition")
	}

	normalized, err := normalizeTaskReviewCancellationInput(input)
	if err != nil {
		return Task{}, TaskReviewRequest{}, err
	}
	review, err := loadTaskReviewByIDForUpdate(ctx, tx, taskID, normalized.ReviewID)
	if err != nil {
		return Task{}, TaskReviewRequest{}, err
	}
	if review.Status != taskReviewStatusRequested {
		return Task{}, TaskReviewRequest{}, conflict("task review is not pending")
	}

	now := time.Now().UTC()
	if _, err := tx.Exec(ctx, `
UPDATE task_review_requests
SET status = $3,
    decided_by_ref = $4,
    decided_at = $5
WHERE id = $1
  AND task_id = $2
`, normalized.ReviewID, taskID, taskReviewStatusCancelled, actorMemberID, now); err != nil {
		return Task{}, TaskReviewRequest{}, fmt.Errorf("cancel task review: %w", err)
	}
	if _, err := tx.Exec(ctx, `
UPDATE tasks
SET status = $2,
    last_activity_at = $3,
    updated_at = $3
WHERE id = $1
`, taskID, StatusInProgress, now); err != nil {
		return Task{}, TaskReviewRequest{}, fmt.Errorf("return task to active execution: %w", err)
	}
	if err := applyTaskTypedEventPlan(ctx, tx,
		taskTypedEventIntent(taskID, actorMemberID, taskEventStatusChanged, string(StatusInProgress), now),
		taskTypedEventIntent(taskID, actorMemberID, taskEventReviewCancelled, normalized.ReviewID, now),
	); err != nil {
		return Task{}, TaskReviewRequest{}, err
	}
	task, err := txs.taskByID(ctx, tx, workspaceID, taskID)
	if err != nil {
		return Task{}, TaskReviewRequest{}, err
	}
	review, err = loadTaskReviewByID(ctx, tx, taskID, normalized.ReviewID)
	if err != nil {
		return Task{}, TaskReviewRequest{}, err
	}
	return task, review, nil
}

func loadTaskReviewHistory(ctx context.Context, queryer coredb.Queryer, taskID string) ([]TaskReviewRequest, error) {
	return loadTaskReviewHistoryLimit(ctx, queryer, taskID, 0)
}

func loadTaskReviewHistoryLimit(ctx context.Context, queryer coredb.Queryer, taskID string, limit int) ([]TaskReviewRequest, error) {
	limitClause := ""
	args := []any{taskID, taskReviewTargetPayloadSummaryMaxRunes}
	if limit > 0 {
		limitClause = "LIMIT $3"
		args = append(args, limit+1)
	}
	rows, err := queryer.Query(ctx, `
SELECT
    id,
    status,
    requested_by_ref,
    reviewer_ref,
    summary_md,
    questions_json,
    decision_md,
    decision_summary_md,
    decision_questions_json,
    decided_by_ref,
    review_target_type,
    LEFT(review_target_payload_json, $2),
    CASE WHEN LENGTH(review_target_payload_json) > $2 THEN 'true' ELSE 'false' END,
    requested_at,
    decided_at
FROM task_review_requests
WHERE task_id = $1
ORDER BY requested_at ASC, id ASC
`+limitClause, args...)
	if err != nil {
		return nil, fmt.Errorf("query task review history: %w", err)
	}
	defer rows.Close()

	reviews := make([]TaskReviewRequest, 0)
	for rows.Next() {
		review, err := scanTaskReviewSummary(rows)
		if err != nil {
			return nil, err
		}
		reviews = append(reviews, review)
	}
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("iterate task review history: %w", err)
	}
	if limit > 0 && len(reviews) > limit {
		reviews = reviews[:limit]
	}
	if err := hydrateTaskReviewPrincipalProfiles(ctx, queryer, reviews); err != nil {
		return nil, err
	}
	return reviews, nil
}

func loadTaskReviewQueue(ctx context.Context, queryer coredb.Queryer, channelID string, reviewerMemberID string, requestedLimit int, cursor string) ([]TaskReviewRequest, int, bool, string, error) {
	limit := normalizeTaskReviewQueueLimit(requestedLimit)
	offset, err := parseTaskReviewQueueCursor(cursor)
	if err != nil {
		return nil, 0, false, "", err
	}
	rows, err := queryer.Query(ctx, `
SELECT
    r.id,
    r.status,
    r.requested_by_ref,
    r.reviewer_ref,
    r.summary_md,
    r.questions_json,
    r.decision_md,
    r.decision_summary_md,
    r.decision_questions_json,
    r.decided_by_ref,
    r.review_target_type,
    LEFT(r.review_target_payload_json, $4),
    CASE WHEN LENGTH(r.review_target_payload_json) > $4 THEN 'true' ELSE 'false' END,
    r.requested_at,
    r.decided_at
FROM task_review_requests r
JOIN tasks t ON t.id = r.task_id
JOIN task_boards tb
  ON tb.id = t.board_id
 AND tb.channel_id = t.channel_id
JOIN scopes task_scope
  ON task_scope.id = t.task_scope_id
 AND task_scope.workspace_id = tb.workspace_id
 AND task_scope.type = 'task'
 AND task_scope.status = 'active'
 AND task_scope.archived_at IS NULL
JOIN scopes task_channel
  ON task_channel.id = tb.channel_id
 AND task_channel.workspace_id = tb.workspace_id
 AND task_channel.type = 'channel'
 AND task_channel.status = 'active'
 AND task_channel.archived_at IS NULL
WHERE tb.channel_id = $1
  AND r.status = $2
  AND (r.reviewer_ref = '' OR r.reviewer_ref = $3)
ORDER BY r.requested_at ASC, r.id ASC
LIMIT $5 OFFSET $6
	`, channelID, taskReviewStatusRequested, strings.TrimSpace(reviewerMemberID), taskReviewTargetPayloadSummaryMaxRunes, limit+1, offset)
	if err != nil {
		return nil, 0, false, "", fmt.Errorf("query task review queue: %w", err)
	}
	defer rows.Close()

	reviews := make([]TaskReviewRequest, 0)
	for rows.Next() {
		review, err := scanTaskReviewSummary(rows)
		if err != nil {
			return nil, 0, false, "", err
		}
		reviews = append(reviews, review)
	}
	if err := rows.Err(); err != nil {
		return nil, 0, false, "", fmt.Errorf("iterate task review queue: %w", err)
	}
	hasMore := len(reviews) > limit
	if hasMore {
		reviews = reviews[:limit]
	}
	nextCursor := ""
	if hasMore {
		nextCursor = strconv.Itoa(offset + limit)
	}
	if err := hydrateTaskReviewPrincipalProfiles(ctx, queryer, reviews); err != nil {
		return nil, 0, false, "", err
	}
	return reviews, limit, hasMore, nextCursor, nil
}

func normalizeTaskReviewQueueLimit(limit int) int {
	switch {
	case limit <= 0:
		return taskReviewQueueDefaultLimit
	case limit > taskReviewQueueMaxLimit:
		return taskReviewQueueMaxLimit
	default:
		return limit
	}
}

func parseTaskReviewQueueCursor(cursor string) (int, error) {
	trimmed := strings.TrimSpace(cursor)
	if trimmed == "" {
		return 0, nil
	}
	offset, err := strconv.Atoi(trimmed)
	if err != nil || offset < 0 {
		return 0, badRequest("invalid task review queue cursor")
	}
	return offset, nil
}

func loadTaskReviewByID(ctx context.Context, queryer coredb.Queryer, taskID string, reviewID string) (TaskReviewRequest, error) {
	row := queryer.QueryRow(ctx, `
SELECT
    id,
    status,
    requested_by_ref,
    reviewer_ref,
    summary_md,
    questions_json,
    decision_md,
    decision_summary_md,
    decision_questions_json,
    decided_by_ref,
    review_target_type,
    review_target_payload_json,
    requested_at,
    decided_at
FROM task_review_requests
WHERE id = $1
  AND task_id = $2
`, reviewID, taskID)
	review, err := scanTaskReview(row)
	if err != nil {
		return TaskReviewRequest{}, err
	}
	reviews := []TaskReviewRequest{review}
	if err := hydrateTaskReviewPrincipalProfiles(ctx, queryer, reviews); err != nil {
		return TaskReviewRequest{}, err
	}
	return reviews[0], nil
}

func loadTaskReviewByIDForUpdate(ctx context.Context, queryer coredb.QueryRower, taskID string, reviewID string) (TaskReviewRequest, error) {
	row := queryer.QueryRow(ctx, `
SELECT
    id,
    status,
    requested_by_ref,
    reviewer_ref,
    summary_md,
    questions_json,
    decision_md,
    decision_summary_md,
    decision_questions_json,
    decided_by_ref,
    review_target_type,
    review_target_payload_json,
    requested_at,
    decided_at
FROM task_review_requests
WHERE id = $1
  AND task_id = $2
FOR UPDATE
`, reviewID, taskID)
	return scanTaskReview(row)
}

func loadPendingTaskReviewForUpdate(ctx context.Context, queryer coredb.QueryRower, taskID string) (TaskReviewRequest, bool, error) {
	row := queryer.QueryRow(ctx, `
SELECT
    id,
    status,
    requested_by_ref,
    reviewer_ref,
    summary_md,
    questions_json,
    decision_md,
    decision_summary_md,
    decision_questions_json,
    decided_by_ref,
    review_target_type,
    review_target_payload_json,
    requested_at,
    decided_at
FROM task_review_requests
WHERE task_id = $1
  AND status = $2
ORDER BY requested_at DESC, id DESC
LIMIT 1
FOR UPDATE
`, taskID, taskReviewStatusRequested)
	review, err := scanTaskReview(row)
	if err != nil {
		if errors.Is(err, ErrNotFound) {
			return TaskReviewRequest{}, false, nil
		}
		return TaskReviewRequest{}, false, err
	}
	return review, true, nil
}

func taskReviewRequestMatches(existing TaskReviewRequest, normalized TaskReviewRequestInput, targetPayloadJSON []byte) bool {
	if strings.TrimSpace(existing.Status) != taskReviewStatusRequested {
		return false
	}
	if strings.TrimSpace(existing.ReviewerRef) != strings.TrimSpace(normalized.ReviewerRef) {
		return false
	}
	if strings.TrimSpace(existing.Summary) != strings.TrimSpace(normalized.Summary) {
		return false
	}
	if strings.TrimSpace(existing.ReviewTargetType) != strings.TrimSpace(normalized.ReviewTargetType) {
		return false
	}
	if !equalStringSlices(existing.Questions, normalized.Questions) {
		return false
	}
	return string(existing.ReviewTargetPayload) == string(targetPayloadJSON)
}

func equalStringSlices(left, right []string) bool {
	if len(left) != len(right) {
		return false
	}
	for index := range left {
		if strings.TrimSpace(left[index]) != strings.TrimSpace(right[index]) {
			return false
		}
	}
	return true
}

func scanTaskReview(scanner interface{ Scan(...any) error }) (TaskReviewRequest, error) {
	var review TaskReviewRequest
	var questionsJSON []byte
	var decisionQuestionsJSON []byte
	var reviewTargetPayload []byte
	if err := scanner.Scan(
		&review.ID,
		&review.Status,
		&review.RequestedByRef,
		&review.ReviewerRef,
		&review.Summary,
		&questionsJSON,
		&review.Decision,
		&review.DecisionSummary,
		&decisionQuestionsJSON,
		&review.DecidedByRef,
		&review.ReviewTargetType,
		&reviewTargetPayload,
		&review.RequestedAt,
		&review.DecidedAt,
	); err != nil {
		if coredb.IsNotFound(err) {
			return TaskReviewRequest{}, ErrNotFound
		}
		return TaskReviewRequest{}, fmt.Errorf("scan task review: %w", err)
	}

	questions, err := unmarshalStringList(questionsJSON)
	if err != nil {
		return TaskReviewRequest{}, err
	}
	decisionQuestions, err := unmarshalStringList(decisionQuestionsJSON)
	if err != nil {
		return TaskReviewRequest{}, err
	}
	review.Questions = capTaskReviewQuestions(questions)
	review.DecisionQuestions = capTaskReviewQuestions(decisionQuestions)
	if len(reviewTargetPayload) > 0 {
		review.ReviewTargetPayload = append([]byte(nil), reviewTargetPayload...)
	}
	return review, nil
}

func hydrateTaskReviewPrincipalProfiles(ctx context.Context, queryer coredb.Queryer, reviews []TaskReviewRequest) error {
	refs := make([]string, 0, len(reviews)*3)
	for _, review := range reviews {
		refs = append(refs, review.RequestedByRef, review.ReviewerRef, review.DecidedByRef)
	}
	profiles, err := loadTaskPrincipalProfilesByMemberRef(ctx, queryer, refs...)
	if err != nil {
		return err
	}
	for index := range reviews {
		if profile, ok := profiles[strings.TrimSpace(reviews[index].RequestedByRef)]; ok {
			reviews[index].RequestedByProfile = &profile
		}
		if profile, ok := profiles[strings.TrimSpace(reviews[index].ReviewerRef)]; ok {
			reviews[index].ReviewerProfile = &profile
		}
		if profile, ok := profiles[strings.TrimSpace(reviews[index].DecidedByRef)]; ok {
			reviews[index].DecidedByProfile = &profile
		}
	}
	return nil
}

func scanTaskReviewSummary(scanner interface{ Scan(...any) error }) (TaskReviewRequest, error) {
	var review TaskReviewRequest
	var questionsJSON []byte
	var decisionQuestionsJSON []byte
	var payloadSummary string
	var payloadTruncated string
	if err := scanner.Scan(
		&review.ID,
		&review.Status,
		&review.RequestedByRef,
		&review.ReviewerRef,
		&review.Summary,
		&questionsJSON,
		&review.Decision,
		&review.DecisionSummary,
		&decisionQuestionsJSON,
		&review.DecidedByRef,
		&review.ReviewTargetType,
		&payloadSummary,
		&payloadTruncated,
		&review.RequestedAt,
		&review.DecidedAt,
	); err != nil {
		if coredb.IsNotFound(err) {
			return TaskReviewRequest{}, ErrNotFound
		}
		return TaskReviewRequest{}, fmt.Errorf("scan task review summary: %w", err)
	}

	questions, err := unmarshalStringList(questionsJSON)
	if err != nil {
		return TaskReviewRequest{}, err
	}
	decisionQuestions, err := unmarshalStringList(decisionQuestionsJSON)
	if err != nil {
		return TaskReviewRequest{}, err
	}
	review.Questions = capTaskReviewQuestions(questions)
	review.DecisionQuestions = capTaskReviewQuestions(decisionQuestions)
	review.ReviewTargetPayloadSummary = trimTaskRunes(payloadSummary, taskReviewTargetPayloadSummaryMaxRunes)
	review.ReviewTargetPayloadTruncated = strings.EqualFold(strings.TrimSpace(payloadTruncated), "true")
	return review, nil
}

func normalizeTaskReviewRequestInput(input TaskReviewRequestInput) (TaskReviewRequestInput, error) {
	reviewTargetType := strings.TrimSpace(input.ReviewTargetType)
	if reviewTargetType == "" {
		return TaskReviewRequestInput{}, badRequest("reviewTargetType is required")
	}
	reviewerRef, err := normalizeOptionalChannelMemberRef(input.ReviewerRef, "reviewerRef")
	if err != nil {
		return TaskReviewRequestInput{}, err
	}
	return TaskReviewRequestInput{
		ReviewerRef:         reviewerRef,
		Summary:             strings.TrimSpace(input.Summary),
		Questions:           normalizeStringList(input.Questions),
		ReviewTargetType:    reviewTargetType,
		ReviewTargetPayload: strings.TrimSpace(input.ReviewTargetPayload),
	}, nil
}

func normalizeTaskReviewReassignmentInput(input TaskReviewReassignmentInput) (TaskReviewReassignmentInput, error) {
	reviewID := strings.TrimSpace(input.ReviewID)
	if reviewID == "" {
		return TaskReviewReassignmentInput{}, badRequest("reviewId is required")
	}
	reviewerRef, err := normalizeOptionalChannelMemberRef(input.ReviewerRef, "reviewerRef")
	if err != nil {
		return TaskReviewReassignmentInput{}, err
	}
	if reviewerRef == "" {
		return TaskReviewReassignmentInput{}, badRequest("reviewerRef is required")
	}
	return TaskReviewReassignmentInput{
		ReviewID:    reviewID,
		ReviewerRef: reviewerRef,
	}, nil
}

func normalizeTaskReviewCancellationInput(input TaskReviewCancellationInput) (TaskReviewCancellationInput, error) {
	reviewID := strings.TrimSpace(input.ReviewID)
	if reviewID == "" {
		return TaskReviewCancellationInput{}, badRequest("reviewId is required")
	}
	return TaskReviewCancellationInput{ReviewID: reviewID}, nil
}
