package taskboard

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

	"github.com/tidbcloud/AgentTeam/internal/coredb"
	"github.com/tidbcloud/AgentTeam/internal/store/access"
	"github.com/tidbcloud/AgentTeam/internal/store/taskcompat"
)

func (txs TxStore) CreateForAgent(ctx context.Context, agent AgentActor, board BoardRef, inputs []TaskCreateInput, metadata CreateTaskMetadata) (string, []CreatedTask, error) {
	channelID, err := txs.resolveAccessibleAgentBoard(ctx, txs.tx, agent, board)
	if err != nil {
		return "", nil, err
	}
	_, created, err := txs.createTasksInTx(ctx, txs.tx, agent.WorkspaceID, channelID, agent.MemberID, inputs, metadata, true, agentTaskFencerFor(agent))
	if err != nil {
		return "", nil, err
	}
	return channelID, created, nil
}

// claimLockedTaskState captures the locked Task row state for one task number in
// a multi-task claim batch. The notFound flag marks a number that resolved to no
// task on the board (a non-enumerating per-number failure).
type claimLockedTaskState struct {
	taskID           string
	status           string
	assigneeMemberID string
	notFound         bool
}

func (txs TxStore) ClaimForAgent(ctx context.Context, agent AgentActor, boardRef BoardRef, taskNumbers []int64) (string, []ClaimResult, []int64, error) {
	if len(taskNumbers) == 0 {
		return "", nil, nil, badRequest("at least one task number is required")
	}

	var channelID string
	var results []ClaimResult
	var changedTaskNumbers []int64
	channelID, err := txs.resolveAccessibleAgentBoard(ctx, txs.tx, agent, boardRef)
	if err != nil {
		return "", nil, nil, err
	}
	board, err := txs.loadTaskBoardByChannel(ctx, txs.tx, agent.WorkspaceID, channelID)
	if errors.Is(err, ErrNotFound) {
		results = make([]ClaimResult, 0, len(taskNumbers))
		for _, taskNumber := range taskNumbers {
			results = append(results, ClaimResult{TaskNumber: taskNumber, Success: false, Reason: "task not found"})
		}
		return channelID, results, nil, nil
	}
	if err != nil {
		return "", nil, nil, err
	}

	// Phase 1: lock every target Task row FOR UPDATE in ascending task-number
	// order (a deterministic order for multi-task claim batches), capturing the
	// locked state. This closes the task set before any membership lock is taken.
	sortedNumbers := make([]int64, len(taskNumbers))
	copy(sortedNumbers, taskNumbers)
	sort.Slice(sortedNumbers, func(i, j int) bool { return sortedNumbers[i] < sortedNumbers[j] })
	lockedStates := make(map[int64]claimLockedTaskState, len(taskNumbers))
	for _, taskNumber := range sortedNumbers {
		taskID, status, assigneeMemberID, err := txs.loadAgentTaskState(ctx, txs.tx, board, taskNumber)
		if err != nil {
			if errors.Is(err, ErrNotFound) {
				lockedStates[taskNumber] = claimLockedTaskState{notFound: true}
				continue
			}
			return "", nil, nil, err
		}
		lockedStates[taskNumber] = claimLockedTaskState{
			taskID:           taskID,
			status:           status,
			assigneeMemberID: assigneeMemberID,
		}
	}

	// Phase 2: TOCTOU fence — lock the agent's active membership on the canonical
	// board Channel AFTER all Task rows are locked (consistent lock order:
	// Task rows first, then membership), so a concurrent board-membership revoke
	// serializes deterministically and cannot interleave after the fence.
	if err := txs.lockActiveAgentBoardChannelAuthority(ctx, txs.tx, agent.WorkspaceID, board.ChannelID, agent.MemberID); err != nil {
		return "", nil, nil, err
	}

	results = make([]ClaimResult, 0, len(taskNumbers))
	changedTaskNumbers = make([]int64, 0, len(taskNumbers))
	for _, taskNumber := range taskNumbers {
		locked := lockedStates[taskNumber]
		if locked.notFound {
			results = append(results, ClaimResult{TaskNumber: taskNumber, Success: false, Reason: "task not found"})
			continue
		}
		if locked.status == string(StatusDone) {
			results = append(results, ClaimResult{TaskNumber: taskNumber, Success: false, Reason: "task is done"})
			continue
		}
		if locked.assigneeMemberID == agent.MemberID {
			results = append(results, ClaimResult{TaskNumber: taskNumber, Success: true})
			continue
		}
		if locked.assigneeMemberID != "" {
			results = append(results, ClaimResult{TaskNumber: taskNumber, Success: false, Reason: "task is assigned to another member"})
			continue
		}

		now := time.Now().UTC()
		if _, err := txs.tx.Exec(ctx, `
UPDATE tasks
SET assignee_member_id = $3,
    assigned_at = $4,
    claimed_by_member_id = $3,
    claimed_by_ref = $3,
    claimed_at = $4,
    current_agent_ref = NULL,
    suggested_assignee_ref = '',
    reviewer_ref = '',
    last_activity_at = $4,
    updated_at = $4
WHERE task_number = $2
  AND board_id = $1
`, board.ID, taskNumber, agent.MemberID, now); err != nil {
			return "", nil, nil, fmt.Errorf("assign task: %w", err)
		}
		if err := applyTaskTypedEventPlan(ctx, txs.tx, taskTypedEventIntent(locked.taskID, agent.MemberID, taskEventAssigned, agent.MemberID, now)); err != nil {
			return "", nil, nil, err
		}
		taskScopeID, err := loadTaskScopeIDForParticipantNotification(ctx, txs.tx, locked.taskID)
		if err != nil {
			return "", nil, nil, err
		}
		if err := ensureTaskScopeParticipantsDefaultUnwatched(ctx, txs.tx, taskScopeID, []string{agent.MemberID}, now); err != nil {
			return "", nil, nil, err
		}
		results = append(results, ClaimResult{TaskNumber: taskNumber, Success: true})
		changedTaskNumbers = append(changedTaskNumbers, taskNumber)
	}
	return channelID, results, changedTaskNumbers, nil
}

func (txs TxStore) UnclaimForAgent(ctx context.Context, agent AgentActor, boardRef BoardRef, taskNumber int64) (Task, error) {
	channelID, err := txs.resolveAccessibleAgentBoard(ctx, txs.tx, agent, boardRef)
	if err != nil {
		return Task{}, err
	}
	board, err := txs.loadTaskBoardByChannel(ctx, txs.tx, agent.WorkspaceID, channelID)
	if err != nil {
		return Task{}, err
	}
	taskID, status, assigneeMemberID, err := txs.loadAgentTaskState(ctx, txs.tx, board, taskNumber)
	if err != nil {
		return Task{}, err
	}
	if status == string(StatusDone) {
		return Task{}, conflict("cannot unclaim done task")
	}
	if assigneeMemberID == "" {
		return Task{}, conflict("task is not assigned")
	}
	if assigneeMemberID != agent.MemberID {
		return Task{}, conflict("task is assigned to another member")
	}

	// TOCTOU fence: the Task row is already locked (loadAgentTaskState FOR
	// UPDATE); lock the agent's active membership on the canonical board Channel
	// before writing, so a concurrent board-membership revoke serializes
	// deterministically.
	if err := txs.lockActiveAgentBoardChannelAuthority(ctx, txs.tx, agent.WorkspaceID, board.ChannelID, agent.MemberID); err != nil {
		return Task{}, err
	}

	now := time.Now().UTC()
	if _, err := txs.tx.Exec(ctx, `
		UPDATE tasks
		SET assignee_member_id = NULL,
		    assigned_at = NULL,
		    claimed_by_member_id = NULL,
		    claimed_by_ref = NULL,
		    claimed_at = NULL,
		    current_agent_ref = NULL,
		    suggested_assignee_ref = '',
		    reviewer_ref = '',
		    last_activity_at = $3,
		    updated_at = $3
		WHERE task_number = $2
		  AND board_id = $1
		`, board.ID, taskNumber, now); err != nil {
		return Task{}, fmt.Errorf("unassign task: %w", err)
	}
	task, err := txs.taskByID(ctx, txs.tx, agent.WorkspaceID, taskID)
	if err != nil {
		return Task{}, err
	}
	if err := applyTaskTypedEventPlan(ctx, txs.tx, taskTypedEventIntent(task.ID, agent.MemberID, taskEventUnassigned, agent.MemberID, now)); err != nil {
		return Task{}, err
	}
	return task, nil
}

type DeleteForAgentRequest struct {
	Agent       AgentActor
	Board       BoardRef
	TaskNumber  int64
	SubtaskMode DeleteTaskSubtaskMode
}

func (txs TxStore) DeleteForAgent(ctx context.Context, request DeleteForAgentRequest) (DeleteTaskResponse, error) {
	channelID, err := txs.resolveAccessibleAgentBoard(ctx, txs.tx, request.Agent, request.Board)
	if err != nil {
		return DeleteTaskResponse{}, err
	}
	board, err := txs.loadTaskBoardByChannel(ctx, txs.tx, request.Agent.WorkspaceID, channelID)
	if err != nil {
		return DeleteTaskResponse{}, err
	}
	taskID, _, _, err := txs.loadAgentTaskState(ctx, txs.tx, board, request.TaskNumber)
	if err != nil {
		return DeleteTaskResponse{}, err
	}
	// TOCTOU fence: lock the agent's active membership on the canonical board
	// Channel so a concurrent board-membership revoke serializes deterministically
	// with this delete. The task row is already locked by loadAgentTaskState.
	if err := txs.lockActiveAgentBoardChannelAuthority(ctx, txs.tx, request.Agent.WorkspaceID, channelID, request.Agent.MemberID); err != nil {
		return DeleteTaskResponse{}, err
	}
	return txs.deleteLockedTask(
		ctx,
		request.Agent.WorkspaceID,
		channelID,
		taskID,
		request.SubtaskMode,
		func(taskIDs []string) (bool, error) {
			return txs.tasksCreatedByMember(ctx, txs.tx, request.Agent.MemberID, taskIDs)
		},
	)
}

func (txs TxStore) resolveAccessibleAgentChannel(ctx context.Context, queryer coredb.QueryRower, agent AgentActor, target string) (string, error) {
	channelSlug, err := parseChannelTarget(target)
	if err != nil {
		return "", err
	}

	channel, err := access.ResolveAccessibleChannelForMember(ctx, queryer, agent.WorkspaceID, agent.MemberID, channelSlug)
	if errors.Is(err, access.ErrForbidden) {
		channelID, privateErr := txs.resolveAgentEntryChannel(ctx, queryer, agent.WorkspaceID, agent.MemberID, channelSlug)
		if privateErr != nil {
			return "", privateErr
		}
		return channelID, nil
	}
	if err != nil {
		return "", fmt.Errorf("resolve accessible task channel: %w", err)
	}
	return channel.ChannelID, nil
}

func (txs TxStore) resolveAccessibleAgentBoard(
	ctx context.Context,
	queryer coredb.QueryRower,
	agent AgentActor,
	board BoardRef,
) (string, error) {
	if channelID := strings.TrimSpace(board.ChannelID); channelID != "" {
		// The ID-based path authorizes the resolved channel through the single
		// canonical Agent Task-channel authorizer (the slug path resolves the
		// channel through the member-accessible channel resolver, which already
		// validates the Agent's active channel membership).
		if err := txs.authorizeAccessibleAgentChannelID(ctx, queryer, agent, channelID); err != nil {
			return "", err
		}
		return channelID, nil
	}
	channel, err := board.requireChannelTarget()
	if err != nil {
		return "", err
	}
	return txs.resolveAccessibleAgentChannel(ctx, queryer, agent, channel)
}

// authorizeAccessibleAgentChannelID is the canonical-scope counterpart to
// resolveAccessibleAgentChannel. Resolved board targets and task-id reads
// already hold a stored channel id and must not feed it back through the
// author-facing #channel parser. It verifies the same-workspace active
// Agent+Machine+Member currently holds active membership on the channel scope,
// so a client-supplied channel/taskScope is only a location/hint and never the
// authorization fact.
func (txs TxStore) authorizeAccessibleAgentChannelID(ctx context.Context, queryer coredb.QueryRower, agent AgentActor, channelID string) error {
	return txs.ensureAgentCanAccessTaskChannel(ctx, queryer, agent, channelID)
}

// ensureAgentCanAccessTaskChannel is the single canonical Agent Task-channel
// authorizer used by every taskboard Agent entry point. It delegates to the
// shared access.AuthorizeActiveAgentChannelMembership so the app store and the
// message read surfaces use the same SQL ACL. Given a channel id resolved
// server-side from the active Task/board authority, it verifies the
// same-workspace active Agent+Machine+Member currently holds active membership
// on that channel scope. Orphaned/legacy boards, cross-workspace, other-channel,
// deleted/archived/unresolvable, and kind-mismatch cases all fail closed and
// non-enumerating. Client-supplied channel/taskScope values are only a
// location/consistency hint and never the authorization fact.
func (txs TxStore) ensureAgentCanAccessTaskChannel(ctx context.Context, queryer coredb.QueryRower, agent AgentActor, channelID string) error {
	if err := access.AuthorizeActiveAgentChannelMembership(ctx, queryer, agent.WorkspaceID, agent.MemberID, channelID); err != nil {
		if errors.Is(err, access.ErrForbidden) || errors.Is(err, access.ErrNotFound) {
			return ErrForbidden
		}
		return err
	}
	return nil
}

func (txs TxStore) resolveAgentEntryChannel(ctx context.Context, queryer coredb.QueryRower, workspaceID string, agentMemberID string, channelSlug string) (string, error) {
	row := queryer.QueryRow(ctx, `
SELECT scp.scope_id
FROM scope_channel_profiles scp
JOIN scopes s
  ON s.id = scp.scope_id
 AND s.workspace_id = scp.workspace_id
 AND s.type = 'channel'
 AND s.status = 'active'
 AND s.archived_at IS NULL
JOIN scope_memberships sm
  ON sm.scope_id = scp.scope_id
 AND sm.member_id = $2
 AND sm.visibility IN ('public', 'private')
 AND sm.removed_at IS NULL
WHERE scp.workspace_id = $1
  AND scp.slug = $3
LIMIT 1
`, strings.TrimSpace(workspaceID), strings.TrimSpace(agentMemberID), strings.TrimSpace(channelSlug))

	var channelID string
	if err := row.Scan(&channelID); err != nil {
		if coredb.IsNotFound(err) {
			return "", ErrForbidden
		}
		return "", fmt.Errorf("resolve agent entry task channel: %w", err)
	}
	return channelID, nil
}

func (txs TxStore) loadAgentTaskState(ctx context.Context, queryer coredb.QueryRower, board taskBoardRecord, taskNumber int64) (string, string, string, error) {
	row := queryer.QueryRow(ctx, `
SELECT id, status, COALESCE(`+taskcompat.EffectiveAssigneeRefSQL("tasks")+`, '')
FROM tasks
WHERE task_number = $2
  AND board_id = $1
FOR UPDATE
`, board.ID, taskNumber)
	var taskID string
	var status string
	var assigneeMemberID string
	if err := row.Scan(&taskID, &status, &assigneeMemberID); err != nil {
		if coredb.IsNotFound(err) {
			return "", "", "", ErrNotFound
		}
		return "", "", "", fmt.Errorf("load task state: %w", err)
	}
	return taskID, status, assigneeMemberID, nil
}
