package handler

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"net/http"
	"regexp"
	"strconv"
	"strings"
	"time"

	"changkun.de/x/wallfacer/internal/harness"
	"changkun.de/x/wallfacer/internal/logger"
	"changkun.de/x/wallfacer/internal/pkg/httpjson"
	"changkun.de/x/wallfacer/internal/pkg/statemachine"
	"changkun.de/x/wallfacer/internal/store"
	"github.com/google/uuid"
)

// SearchTasks handles GET /api/tasks/search?q=<text>.
// Returns a JSON array of store.TaskSearchResult (at most 50).
// q must be at least 2 runes; returns 400 otherwise.
func (h *Handler) SearchTasks(w http.ResponseWriter, r *http.Request) {
	q := strings.TrimSpace(r.URL.Query().Get("q"))
	if len([]rune(q)) < 2 {
		http.Error(w, "q must be at least 2 characters", http.StatusBadRequest)
		return
	}
	s, ok := h.requireStore(w)
	if !ok {
		return
	}
	results, err := s.SearchTasks(r.Context(), q)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	if results == nil {
		results = []store.TaskSearchResult{}
	}
	httpjson.Write(w, http.StatusOK, results)
}

// ListSummaries returns all immutable task summaries (one per completed task).
// Unlike ListTasks, it reads summary.json files directly without loading the
// full task.json, making it efficient for cost dashboards and analytics.
// Tasks that completed before summary.json was introduced are omitted.
func (h *Handler) ListSummaries(w http.ResponseWriter, _ *http.Request) {
	s, ok := h.requireStore(w)
	if !ok {
		return
	}
	summaries, err := s.ListSummaries()
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	if summaries == nil {
		summaries = []store.TaskSummary{}
	}
	httpjson.Write(w, http.StatusOK, summaries)
}

// ListTasks returns all tasks, optionally including archived ones.
func (h *Handler) ListTasks(w http.ResponseWriter, r *http.Request) {
	// Capture the store once so all operations below are scoped to the same
	// workspace group, regardless of concurrent workspace switches.
	s, ok := h.requireStore(w)
	if !ok {
		return
	}
	includeArchived := r.URL.Query().Get("include_archived") == "true"
	pageSizeRaw := strings.TrimSpace(r.URL.Query().Get("archived_page_size"))
	if pageSizeRaw != "" {
		if !includeArchived {
			http.Error(w, "include_archived=true is required with archived_page_size", http.StatusBadRequest)
			return
		}
		pageSize, err := strconv.Atoi(pageSizeRaw)
		if err != nil {
			http.Error(w, "invalid archived_page_size", http.StatusBadRequest)
			return
		}
		if pageSize < 1 {
			pageSize = 1
		}
		if pageSize > 200 {
			pageSize = 200
		}
		var beforeID *uuid.UUID
		beforeRaw := strings.TrimSpace(r.URL.Query().Get("archived_before"))
		if beforeRaw != "" {
			parsed, err := uuid.Parse(beforeRaw)
			if err != nil {
				http.Error(w, "invalid archived_before", http.StatusBadRequest)
				return
			}
			beforeID = &parsed
		}
		var afterID *uuid.UUID
		afterRaw := strings.TrimSpace(r.URL.Query().Get("archived_after"))
		if afterRaw != "" {
			parsed, err := uuid.Parse(afterRaw)
			if err != nil {
				http.Error(w, "invalid archived_after", http.StatusBadRequest)
				return
			}
			afterID = &parsed
		}
		page, total, hasMoreBefore, hasMoreAfter, err := s.ListArchivedTasksPage(r.Context(), pageSize, beforeID, afterID)
		if err != nil {
			http.Error(w, err.Error(), http.StatusBadRequest)
			return
		}
		resp := struct {
			Tasks         []store.Task `json:"tasks"`
			TotalArchived int          `json:"total_archived"`
			HasMoreBefore bool         `json:"has_more_before"`
			HasMoreAfter  bool         `json:"has_more_after"`
			BeforeCursor  string       `json:"before_cursor,omitempty"`
			AfterCursor   string       `json:"after_cursor,omitempty"`
		}{
			Tasks:         page,
			TotalArchived: total,
			HasMoreBefore: hasMoreBefore,
			HasMoreAfter:  hasMoreAfter,
		}
		if len(page) > 0 {
			resp.AfterCursor = page[0].ID.String()
			resp.BeforeCursor = page[len(page)-1].ID.String()
		}
		httpjson.Write(w, http.StatusOK, resp)
		return
	}
	// GET /api/tasks applies org-scoped filtering in cloud mode when the
	// request carried JWT claims; local-mode callers (principal == nil)
	// get the unfiltered list identical to today's behavior.
	tasks := s.TasksForPrincipal(r.Context(), principalFromRequest(r), includeArchived)
	if tasks == nil {
		tasks = []store.Task{}
	}
	if q := r.URL.Query().Get("failure_category"); q != "" {
		category, ok2 := store.ParseFailureCategory(q)
		if !ok2 {
			http.Error(w, "invalid failure_category", http.StatusBadRequest)
			return
		}
		tasks = filterByFailureCategory(tasks, category)
	}
	httpjson.Write(w, http.StatusOK, tasks)
}

// filterByFailureCategory returns only those tasks whose FailureCategory
// matches cat. The input slice is not modified; a new slice is returned.
func filterByFailureCategory(tasks []store.Task, cat store.FailureCategory) []store.Task {
	filtered := make([]store.Task, 0, len(tasks))
	for _, t := range tasks {
		if t.FailureCategory == cat {
			filtered = append(filtered, t)
		}
	}
	return filtered
}

// CreateTask creates a new task in backlog status.
//
// The deprecated `sandbox` and `sandbox_by_activity` fields are no
// longer accepted on the POST body. Harness (Claude vs Codex) is
// now selected by pinning the agent a flow step references, and
// the per-task workspace default can still be changed via PATCH
// /api/tasks/{id} after creation. Requests that include either
// deprecated field get a 400 with a pointer to the new model so
// callers can migrate.
func (h *Handler) CreateTask(w http.ResponseWriter, r *http.Request) {
	req, ok := httpjson.DecodeBody[struct {
		Prompt             string                               `json:"prompt"`
		Timeout            int                                  `json:"timeout"`
		MountWorktrees     bool                                 `json:"mount_worktrees"`
		Sandbox            *harness.ID                          `json:"sandbox,omitempty"`
		SandboxByActivity  map[store.SandboxActivity]harness.ID `json:"sandbox_by_activity,omitempty"`
		Kind               store.TaskKind                       `json:"kind"`
		Flow               string                               `json:"flow"`
		Tags               []string                             `json:"tags"`
		MaxCostUSD         float64                              `json:"max_cost_usd"`
		MaxInputTokens     int                                  `json:"max_input_tokens"`
		Model              string                               `json:"model"`
		ScheduledAt        *time.Time                           `json:"scheduled_at,omitempty"`
		CustomPassPatterns []string                             `json:"custom_pass_patterns,omitempty"`
		CustomFailPatterns []string                             `json:"custom_fail_patterns,omitempty"`
	}](w, r)
	if !ok {
		return
	}
	if req.Sandbox != nil {
		http.Error(w,
			"the \"sandbox\" field is no longer accepted on POST /api/tasks; pin the harness on the agent a flow step references (Agents tab → Clone → Harness: codex), or set the task's sandbox via PATCH /api/tasks/{id} after creation",
			http.StatusBadRequest)
		return
	}
	if len(req.SandboxByActivity) > 0 {
		http.Error(w,
			"the \"sandbox_by_activity\" field is no longer accepted on POST /api/tasks; per-activity routing lives on the agent definition now (Agents tab → Harness)",
			http.StatusBadRequest)
		return
	}
	// Brainstorm tasks (legacy Kind="idea-agent" or new Flow="brainstorm")
	// are the one case where an empty prompt is allowed: the agent
	// derives the topic from the workspace itself.
	isBrainstorm := req.Flow == "brainstorm" || req.Kind == store.TaskKindIdeaAgent
	if strings.TrimSpace(req.Prompt) == "" && !isBrainstorm {
		http.Error(w, "prompt is required", http.StatusBadRequest)
		return
	}
	if err := validateCustomPatterns(req.CustomPassPatterns, req.CustomFailPatterns); err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
	s, ok2 := h.requireStore(w)
	if !ok2 {
		return
	}

	opts := store.TaskCreateOptions{
		Prompt:             req.Prompt,
		Timeout:            req.Timeout,
		Tags:               req.Tags,
		MountWorktrees:     req.MountWorktrees,
		Kind:               req.Kind,
		FlowID:             req.Flow,
		MaxCostUSD:         req.MaxCostUSD,
		MaxInputTokens:     req.MaxInputTokens,
		ModelOverride:      req.Model,
		ScheduledAt:        req.ScheduledAt,
		CustomPassPatterns: req.CustomPassPatterns,
		CustomFailPatterns: req.CustomFailPatterns,
	}
	if p := principalFromRequest(r); p != nil {
		opts.CreatedBy = p.Sub
		opts.OrgID = p.OrgID
	}
	task, err := s.CreateTaskWithOptions(r.Context(), opts)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}

	h.insertEventOrLog(r.Context(), task.ID, store.EventTypeStateChange,
		store.NewStateChangeData("", store.TaskStatusBacklog, store.TriggerUser, nil))

	if task.Kind != store.TaskKindIdeaAgent {
		h.runner.GenerateTitleBackground(task.ID, task.Prompt)
	}

	httpjson.Write(w, http.StatusCreated, task)
}

// batchTaskInput describes a single task in a BatchCreateTasks request.
//
// Sandbox + SandboxByActivity are typed as pointers so the handler
// can distinguish "not sent" (nil) from "explicitly empty" and
// reject callers that still pass the deprecated fields rather than
// silently dropping them.
type batchTaskInput struct {
	Ref               string                                `json:"ref"`
	Prompt            string                                `json:"prompt"`
	Timeout           int                                   `json:"timeout"`
	Tags              []string                              `json:"tags"`
	Sandbox           *harness.ID                           `json:"sandbox,omitempty"`
	SandboxByActivity *map[store.SandboxActivity]harness.ID `json:"sandbox_by_activity,omitempty"`
	Flow              string                                `json:"flow"`
	Kind              store.TaskKind                        `json:"kind"`
	MountWorktrees    bool                                  `json:"mount_worktrees"`
	DependsOnRefs     []string                              `json:"depends_on_refs"`
	SpecSourcePath    string                                `json:"spec_source_path"`
}

type batchCreateRequest struct {
	Tasks []batchTaskInput `json:"tasks"`
}

// batchRefLabel returns a human-readable label for a batch task in error
// messages: its caller-supplied ref, or "<index N>" when no ref was given.
func batchRefLabel(ref string, i int) string {
	if ref != "" {
		return ref
	}
	return fmt.Sprintf("<index %d>", i)
}

// BatchCreateTasks creates multiple tasks atomically with dependency wiring via
// symbolic ref names declared within the batch. The handler runs a full preflight
// validation phase before any persistence: duplicate refs, empty prompts, sandbox
// validity, existence of external dependency UUIDs, self-dependencies, and cycle
// detection against the combined graph of existing tasks plus proposed edges.
// If validation fails the store is untouched and the handler returns 400 or 422.
// On success it returns 201 Created with tasks (in input order) and ref_to_id.
func (h *Handler) BatchCreateTasks(w http.ResponseWriter, r *http.Request) {
	req, ok := httpjson.DecodeBody[batchCreateRequest](w, r)
	if !ok {
		return
	}

	if len(req.Tasks) == 0 {
		http.Error(w, "tasks must not be empty", http.StatusBadRequest)
		return
	}
	if len(req.Tasks) > 50 {
		http.Error(w, "tasks must not exceed 50 items", http.StatusBadRequest)
		return
	}

	// Capture the store once so all validation and persistence use the same
	// workspace group, regardless of concurrent workspace switches.
	s, ok2 := h.requireStore(w)
	if !ok2 {
		return
	}

	n := len(req.Tasks)

	// =========================================================================
	// PREFLIGHT VALIDATION — zero side effects; all checks run before any write
	// =========================================================================

	// 1. Collect refs and validate uniqueness.
	refToIdx := make(map[string]int, n)
	for i, t := range req.Tasks {
		if t.Ref == "" {
			continue
		}
		if _, dup := refToIdx[t.Ref]; dup {
			http.Error(w, fmt.Sprintf("duplicate ref: %q", t.Ref), http.StatusBadRequest)
			return
		}
		refToIdx[t.Ref] = i
	}

	// 2. Validate prompts.
	for _, t := range req.Tasks {
		if strings.TrimSpace(t.Prompt) == "" && t.Kind != store.TaskKindIdeaAgent {
			ref := t.Ref
			if ref == "" {
				ref = "<unnamed>"
			}
			http.Error(w, fmt.Sprintf("ref %q: prompt is required", ref), http.StatusBadRequest)
			return
		}
	}

	// 3. Reject the deprecated sandbox / sandbox_by_activity fields.
	// Batch callers migrate the same way POST /api/tasks callers do:
	// pin the harness on the agent a flow step references, or set
	// the per-task sandbox via PATCH /api/tasks/{id} after the
	// batch lands.
	for i, t := range req.Tasks {
		if t.Sandbox != nil {
			ref := batchRefLabel(t.Ref, i)
			http.Error(w, fmt.Sprintf("ref %q: \"sandbox\" is no longer accepted on POST /api/tasks/batch; pin the harness on the agent a flow step references", ref), http.StatusBadRequest)
			return
		}
		if t.SandboxByActivity != nil {
			ref := batchRefLabel(t.Ref, i)
			http.Error(w, fmt.Sprintf("ref %q: \"sandbox_by_activity\" is no longer accepted on POST /api/tasks/batch", ref), http.StatusBadRequest)
			return
		}
	}

	// 4. Validate each depends_on_refs entry is a known batch ref or a valid UUID syntax.
	for i, t := range req.Tasks {
		for _, dep := range t.DependsOnRefs {
			if _, ok := refToIdx[dep]; !ok {
				if _, err := uuid.Parse(dep); err != nil {
					ref := batchRefLabel(t.Ref, i)
					http.Error(w, fmt.Sprintf("ref %q: unknown ref in depends_on_refs: %q", ref, dep), http.StatusBadRequest)
					return
				}
			}
		}
	}

	// 5. Topological sort on batch-internal edges (Kahn's algorithm) to detect
	//    internal cycles and compute creation order. Tasks are created in this
	//    order so that a task's dependencies always exist before the task itself.
	inDegree := make([]int, n)
	// batchAdj[i] holds the indices of tasks that depend on task i (forward edges).
	batchAdj := make([][]int, n)
	for i, t := range req.Tasks {
		for _, dep := range t.DependsOnRefs {
			if depIdx, ok := refToIdx[dep]; ok {
				batchAdj[depIdx] = append(batchAdj[depIdx], i)
				inDegree[i]++
			}
		}
	}
	// Seed the queue with tasks that have no batch-internal dependencies.
	queue := make([]int, 0, n)
	for i := 0; i < n; i++ {
		if inDegree[i] == 0 {
			queue = append(queue, i)
		}
	}
	// Process: when a task is emitted, decrement its dependents' in-degree.
	topoOrder := make([]int, 0, n)
	for len(queue) > 0 {
		curr := queue[0]
		queue = queue[1:]
		topoOrder = append(topoOrder, curr)
		for _, next := range batchAdj[curr] {
			inDegree[next]--
			if inDegree[next] == 0 {
				queue = append(queue, next)
			}
		}
	}
	// If not all tasks were emitted, the remaining ones form a cycle.
	if len(topoOrder) != n {
		processed := make(map[int]bool, len(topoOrder))
		for _, idx := range topoOrder {
			processed[idx] = true
		}
		var cycleRefs []string
		for i, t := range req.Tasks {
			if !processed[i] {
				ref := batchRefLabel(t.Ref, i)
				cycleRefs = append(cycleRefs, ref)
			}
		}
		httpjson.Write(w, http.StatusUnprocessableEntity, map[string]any{
			"error": "cycle detected",
			"cycle": cycleRefs,
		})
		return
	}

	// 6. Pre-assign UUIDs so external-dep existence checks and full-graph cycle
	//    detection can reason about the complete post-creation dependency graph.
	preAssignedIDs := make([]uuid.UUID, n)
	for i := range req.Tasks {
		preAssignedIDs[i] = uuid.New()
	}

	// 7. Verify every external UUID dep actually exists in the store.
	//    Self-deps via symbolic ref are already caught by Kahn's above (self-loop
	//    leaves inDegree > 0). We also explicitly guard against a task listing its
	//    own pre-assigned UUID, though callers cannot know it in practice.
	for i, t := range req.Tasks {
		for _, dep := range t.DependsOnRefs {
			if _, ok := refToIdx[dep]; ok {
				// Batch-internal ref — already validated by Kahn's.
				if refToIdx[dep] == i {
					// Self-dep through batch ref (redundant guard; Kahn's catches it).
					ref := batchRefLabel(t.Ref, i)
					http.Error(w, fmt.Sprintf("ref %q: task cannot depend on itself", ref), http.StatusBadRequest)
					return
				}
				continue
			}
			// External UUID — verify it exists.
			depID, _ := uuid.Parse(dep) // already confirmed parseable in step 4
			if _, err := s.GetTask(r.Context(), depID); err != nil {
				ref := batchRefLabel(t.Ref, i)
				httpjson.Write(w, http.StatusUnprocessableEntity, map[string]any{
					"error": fmt.Sprintf("ref %q: dependency task not found: %s", ref, dep),
				})
				return
			}
		}
	}

	// 8. Full combined-graph cycle check: build an adjacency map that merges
	//    existing task edges with the proposed batch edges (using pre-assigned
	//    UUIDs) and confirm no new cycle is introduced.
	//    Note: existing tasks cannot reference the freshly pre-assigned UUIDs, so
	//    cycles through external deps are mathematically impossible here; this
	//    check is defence-in-depth and guards against any future store changes.
	allTasks, _ := s.ListTasks(r.Context(), true)
	combinedAdj := make(map[uuid.UUID][]uuid.UUID, len(allTasks)+n)
	for _, t := range allTasks {
		for _, depStr := range t.DependsOn {
			if depID, err := uuid.Parse(depStr); err == nil {
				combinedAdj[t.ID] = append(combinedAdj[t.ID], depID)
			}
		}
	}
	for i, t := range req.Tasks {
		myID := preAssignedIDs[i]
		for _, dep := range t.DependsOnRefs {
			var depID uuid.UUID
			if depIdx, ok := refToIdx[dep]; ok {
				depID = preAssignedIDs[depIdx]
			} else {
				var parseErr error
				depID, parseErr = uuid.Parse(dep)
				if parseErr != nil {
					logger.Handler.Warn("batch create: invalid dependency ref UUID", "ref", dep, "error", parseErr)
				}
			}
			combinedAdj[myID] = append(combinedAdj[myID], depID)
		}
	}
	for i, t := range req.Tasks {
		myID := preAssignedIDs[i]
		for _, depID := range combinedAdj[myID] {
			if taskReachableInAdj(combinedAdj, depID, myID) {
				ref := batchRefLabel(t.Ref, i)
				httpjson.Write(w, http.StatusUnprocessableEntity, map[string]any{
					"error": fmt.Sprintf("ref %q: dependency would create a cycle", ref),
				})
				return
			}
		}
	}

	// =========================================================================
	// PERSISTENCE — all validation passed; create tasks in topological order
	// =========================================================================

	// refToID maps each batch ref to its created UUID for dependency resolution.
	refToID := make(map[string]uuid.UUID, n)

	for _, idx := range topoOrder {
		t := req.Tasks[idx]

		// Resolve depends_on_refs to UUID strings before the store call so the
		// task is persisted in final form (no post-create UpdateTaskDependsOn).
		depStrs := make([]string, 0, len(t.DependsOnRefs))
		for _, dep := range t.DependsOnRefs {
			if depID, ok := refToID[dep]; ok {
				depStrs = append(depStrs, depID.String())
			} else {
				depStrs = append(depStrs, dep)
			}
		}

		batchOpts := store.TaskCreateOptions{
			ID:             preAssignedIDs[idx],
			Prompt:         t.Prompt,
			Timeout:        t.Timeout,
			Tags:           t.Tags,
			MountWorktrees: t.MountWorktrees,
			Kind:           t.Kind,
			FlowID:         t.Flow,
			DependsOn:      depStrs,
			SpecSourcePath: t.SpecSourcePath,
		}
		if p := principalFromRequest(r); p != nil {
			batchOpts.CreatedBy = p.Sub
			batchOpts.OrgID = p.OrgID
		}
		task, err := s.CreateTaskWithOptions(r.Context(), batchOpts)
		if err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}

		h.insertEventOrLog(r.Context(), task.ID, store.EventTypeStateChange,
			store.NewStateChangeData("", store.TaskStatusBacklog, store.TriggerUser, nil))

		if t.Kind != store.TaskKindIdeaAgent {
			h.runner.GenerateTitleBackground(task.ID, task.Prompt)
		}

		if t.Ref != "" {
			refToID[t.Ref] = task.ID
		}
	}

	// Return tasks in input order.
	finalTasks := make([]store.Task, n)
	for i := range req.Tasks {
		updated, err := s.GetTask(r.Context(), preAssignedIDs[i])
		if err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}
		finalTasks[i] = *updated
	}

	refToIDStr := make(map[string]string, len(refToID))
	for ref, id := range refToID {
		refToIDStr[ref] = id.String()
	}

	httpjson.Write(w, http.StatusCreated, map[string]any{
		"tasks":     finalTasks,
		"ref_to_id": refToIDStr,
	})
}

// UpdateTask handles PATCH requests: status transitions, position, prompt, etc.
func (h *Handler) UpdateTask(w http.ResponseWriter, r *http.Request, id uuid.UUID) {
	req, ok := httpjson.DecodeBody[struct {
		Status            *store.TaskStatus                     `json:"status"`
		Position          *int                                  `json:"position"`
		Prompt            *string                               `json:"prompt"`
		Timeout           *int                                  `json:"timeout"`
		FreshStart        *bool                                 `json:"fresh_start"`
		MountWorktrees    *bool                                 `json:"mount_worktrees"`
		Sandbox           *harness.ID                           `json:"sandbox"`
		SandboxByActivity *map[store.SandboxActivity]harness.ID `json:"sandbox_by_activity"`
		DependsOn         *[]string                             `json:"depends_on"`
		Tags              *[]string                             `json:"tags"`
		// Archived flips the archived flag (replaces POST /archive,/unarchive).
		Archived *bool `json:"archived"`
		// Deleted=false restores a soft-deleted task (replaces POST /restore).
		// Soft-delete itself uses DELETE /api/tasks/{id}, not PATCH.
		Deleted        *bool    `json:"deleted"`
		MaxCostUSD     *float64 `json:"max_cost_usd"`
		MaxInputTokens *int     `json:"max_input_tokens"`
		// Model sets the per-task model override; empty string clears it.
		Model *string `json:"model"`
		// ScheduledAt uses json.RawMessage so we can distinguish "absent" (nil)
		// from explicitly-sent "null" (clear the schedule) or a valid time (set it).
		ScheduledAt        json.RawMessage `json:"scheduled_at"`
		CustomPassPatterns []string        `json:"custom_pass_patterns,omitempty"`
		CustomFailPatterns []string        `json:"custom_fail_patterns,omitempty"`
	}](w, r)
	if !ok {
		return
	}

	s, ok2 := h.requireStore(w)
	if !ok2 {
		return
	}

	// Restore (deleted=false) operates on a tombstoned task, which the
	// GetTask lookup below would not find. Handle it up front. Only
	// deleted=false is accepted; soft-delete uses DELETE /api/tasks/{id}.
	if req.Deleted != nil {
		if *req.Deleted {
			http.Error(w, "soft-delete uses DELETE /api/tasks/{id}, not PATCH", http.StatusBadRequest)
			return
		}
		if err := h.applyRestore(r.Context(), id); err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}
		restored, err := s.GetTask(r.Context(), id)
		if err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}
		httpjson.Write(w, http.StatusOK, restored)
		return
	}

	task, err := s.GetTask(r.Context(), id)
	if err != nil {
		http.Error(w, "task not found", http.StatusNotFound)
		return
	}

	// Archived flag flip (replaces POST /archive and /unarchive). It is a
	// standalone mutation, independent of status, so it short-circuits.
	if req.Archived != nil {
		if *req.Archived && task.Status != store.TaskStatusDone && task.Status != store.TaskStatusCancelled {
			http.Error(w, "only done or cancelled tasks can be archived", http.StatusBadRequest)
			return
		}
		if err := h.applyArchive(r.Context(), *task, *req.Archived); err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}
		updated, err := s.GetTask(r.Context(), id)
		if err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}
		httpjson.Write(w, http.StatusOK, updated)
		return
	}

	// Allow editing prompt, timeout, fresh_start, mount_worktrees, sandbox, model, budget, and custom patterns for backlog tasks.
	if task.Status == store.TaskStatusBacklog && (req.Prompt != nil || req.Timeout != nil || req.FreshStart != nil || req.MountWorktrees != nil || req.Sandbox != nil || req.SandboxByActivity != nil || req.MaxCostUSD != nil || req.MaxInputTokens != nil || req.Model != nil || req.CustomPassPatterns != nil || req.CustomFailPatterns != nil) {
		sandbox := task.Sandbox
		if req.Sandbox != nil {
			sandbox = *req.Sandbox
		}
		activity := task.SandboxByActivity
		if req.SandboxByActivity != nil {
			activity = *req.SandboxByActivity
		}
		if err := h.validateRequestedSandboxes(sandbox, activity); err != nil {
			http.Error(w, err.Error(), http.StatusBadRequest)
			return
		}
		if err := validateCustomPatterns(req.CustomPassPatterns, req.CustomFailPatterns); err != nil {
			http.Error(w, err.Error(), http.StatusBadRequest)
			return
		}
		if err := s.UpdateTaskBacklog(r.Context(), id, req.Prompt, req.Timeout, req.FreshStart, req.MountWorktrees, req.SandboxByActivity, req.MaxCostUSD, req.MaxInputTokens); err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}
		if req.Sandbox != nil {
			if err := s.UpdateTaskSandbox(r.Context(), id, *req.Sandbox); err != nil {
				http.Error(w, err.Error(), http.StatusInternalServerError)
				return
			}
		}
		if req.Model != nil {
			if err := s.UpdateTaskModelOverride(r.Context(), id, *req.Model); err != nil {
				http.Error(w, err.Error(), http.StatusInternalServerError)
				return
			}
		}
		if req.CustomPassPatterns != nil || req.CustomFailPatterns != nil {
			passP := req.CustomPassPatterns
			failP := req.CustomFailPatterns
			if passP == nil {
				passP = task.CustomPassPatterns
			}
			if failP == nil {
				failP = task.CustomFailPatterns
			}
			if err := s.UpdateTaskCustomPatterns(r.Context(), id, passP, failP); err != nil {
				http.Error(w, err.Error(), http.StatusInternalServerError)
				return
			}
		}
	}

	// Allow setting/clearing scheduled_at for backlog tasks.
	// req.ScheduledAt is nil when the field was absent from the JSON body (no-op).
	// When present it is either "null" (clear) or an ISO 8601 timestamp (set).
	if task.Status == store.TaskStatusBacklog && len(req.ScheduledAt) > 0 {
		var scheduledAt *time.Time
		// "null" clears the schedule; any other value is parsed as a time.
		if string(req.ScheduledAt) != "null" {
			var t time.Time
			if err := json.Unmarshal(req.ScheduledAt, &t); err != nil {
				http.Error(w, "invalid scheduled_at: "+err.Error(), http.StatusBadRequest)
				return
			}
			if !t.IsZero() {
				scheduledAt = &t
			}
		}
		if err := s.UpdateTaskScheduledAt(r.Context(), id, scheduledAt); err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}
	}

	// Allow raising budget limits for waiting tasks (so users can continue a paused task).
	if task.Status == store.TaskStatusWaiting && (req.MaxCostUSD != nil || req.MaxInputTokens != nil) {
		if err := s.UpdateTaskBudget(r.Context(), id, req.MaxCostUSD, req.MaxInputTokens); err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}
	}

	if req.Position != nil {
		if err := s.UpdateTaskPosition(r.Context(), id, *req.Position); err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}
	}

	if req.DependsOn != nil {
		parsedDeps := make([]uuid.UUID, 0, len(*req.DependsOn))
		for _, depStr := range *req.DependsOn {
			depID, err := uuid.Parse(depStr)
			if err != nil {
				http.Error(w, fmt.Sprintf("invalid dependency UUID %q: %v", depStr, err), http.StatusBadRequest)
				return
			}
			if depID == id {
				http.Error(w, "task cannot depend on itself", http.StatusBadRequest)
				return
			}
			if _, err := s.GetTask(r.Context(), depID); err != nil {
				http.Error(w, fmt.Sprintf("dependency task not found: %s", depStr), http.StatusBadRequest)
				return
			}
			parsedDeps = append(parsedDeps, depID)
		}
		// Cycle detection using full graph including archived tasks.
		allTasks, _ := s.ListTasks(r.Context(), true)
		for _, depID := range parsedDeps {
			if taskReachable(allTasks, depID, id) {
				http.Error(w, fmt.Sprintf("dependency on %s would create a cycle", depID), http.StatusBadRequest)
				return
			}
		}
		strs := make([]string, len(parsedDeps))
		for i, d := range parsedDeps {
			strs[i] = d.String()
		}
		if err := s.UpdateTaskDependsOn(r.Context(), id, strs); err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}
	}

	if req.Tags != nil {
		if err := s.UpdateTaskTags(r.Context(), id, *req.Tags); err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}
	}

	if req.Status != nil {
		oldStatus := task.Status
		newStatus := *req.Status

		// Cancel is a side-effecting transition (kill container, clean
		// worktrees, cascade to routine children) that bypasses the state
		// machine. Intercept it before the routine guard and planner-lock
		// check below so cancelling a routine card still runs its cascade,
		// matching the old POST /api/tasks/{id}/cancel behaviour.
		if newStatus == store.TaskStatusCancelled {
			if !cancellableStatuses[oldStatus] {
				http.Error(w, "task cannot be cancelled in its current status", http.StatusBadRequest)
				return
			}
			if err := h.applyCancel(r.Context(), *task); err != nil {
				http.Error(w, err.Error(), http.StatusInternalServerError)
				return
			}
			updated, err := s.GetTask(r.Context(), id)
			if err != nil {
				http.Error(w, err.Error(), http.StatusInternalServerError)
				return
			}
			httpjson.Write(w, http.StatusOK, updated)
			return
		}

		// Routine cards are schedule templates, not executable work. They
		// live outside the normal lifecycle and must stay in backlog; any
		// attempt to move them via the generic PATCH is a programmer
		// error on the client.
		if task.IsRoutine() {
			http.Error(w, "routine tasks cannot change status; use /api/routines endpoints", http.StatusUnprocessableEntity)
			return
		}

		// Reject status changes on tasks that have an in-flight plan turn.
		if oldStatus != newStatus {
			if locked, threadID := h.isTaskLockedByPlanner(id.String()); locked {
				httpjson.Write(w, http.StatusConflict, map[string]string{
					"error":     "task is locked by an in-flight plan turn",
					"thread_id": threadID,
				})
				return
			}
		}

		// Handle retry: done/failed/waiting/cancelled → backlog
		if newStatus == store.TaskStatusBacklog && (oldStatus == store.TaskStatusDone || oldStatus == store.TaskStatusFailed || oldStatus == store.TaskStatusCancelled || oldStatus == store.TaskStatusWaiting) {
			newPrompt := task.Prompt
			if req.Prompt != nil {
				newPrompt = *req.Prompt
			}
			// Default to resuming the previous session; the client can opt out by sending fresh_start=true.
			freshStart := false
			if req.FreshStart != nil {
				freshStart = *req.FreshStart
			}
			// Only delete the worktree directory and branch on fresh_start.
			// For normal retries the branch holds Claude's committed work;
			// ensureTaskWorktrees will reattach it on the next run.
			if freshStart && len(task.WorktreePaths) > 0 {
				h.runner.CleanupWorktrees(id, task.WorktreePaths, task.BranchName)
			}
			if err := s.ResetTaskForRetry(r.Context(), id, newPrompt, freshStart); err != nil {
				http.Error(w, err.Error(), http.StatusInternalServerError)
				return
			}
			h.insertEventOrLog(r.Context(), id, store.EventTypeStateChange,
				store.NewStateChangeData(oldStatus, store.TaskStatusBacklog, store.TriggerUser, nil))
			h.diffCache.invalidate(id)

			updated, err := s.GetTask(r.Context(), id)
			if err != nil {
				http.Error(w, err.Error(), http.StatusInternalServerError)
				return
			}
			httpjson.Write(w, http.StatusOK, updated)
			return
		}
		// Enforce concurrency limit for manual backlog → in_progress transitions.
		if newStatus == store.TaskStatusInProgress && oldStatus == store.TaskStatusBacklog && !task.IsTestRun {
			if !h.checkConcurrencyAndUpdateStatus(r.Context(), w, id, newStatus) {
				return
			}
			h.insertEventOrLog(r.Context(), id, store.EventTypeStateChange,
				store.NewStateChangeData(oldStatus, newStatus, store.TriggerUser, nil))
			h.diffCache.invalidate(id)
			h.cascadeArchiveThreadsForTask(id.String())
			sessionID := ""
			if !task.FreshStart && task.SessionID != nil {
				sessionID = *task.SessionID
			}
			h.runner.RunBackground(id, task.Prompt, sessionID, false)
			updated, err := s.GetTask(r.Context(), id)
			if err != nil {
				http.Error(w, err.Error(), http.StatusInternalServerError)
				return
			}
			httpjson.Write(w, http.StatusOK, updated)
			return
		}

		// Also block any direct in_progress transition that is not marked as
		// a test run. This protects API callers that PATCH waiting/failed →
		// in_progress from bypassing the concurrency limit.
		if newStatus == store.TaskStatusInProgress && !task.IsTestRun {
			if !h.checkConcurrencyAndUpdateStatus(r.Context(), w, id, newStatus) {
				return
			}
			h.insertEventOrLog(r.Context(), id, store.EventTypeStateChange,
				store.NewStateChangeData(oldStatus, newStatus, store.TriggerUser, nil))
			h.diffCache.invalidate(id)
			if oldStatus == store.TaskStatusWaiting {
				h.cascadeArchiveThreadsForTask(id.String())
			}
			updated, err := s.GetTask(r.Context(), id)
			if err != nil {
				http.Error(w, err.Error(), http.StatusInternalServerError)
				return
			}
			httpjson.Write(w, http.StatusOK, updated)
			return
		}

		if err := s.UpdateTaskStatus(r.Context(), id, newStatus); err != nil {
			if errors.Is(err, statemachine.ErrInvalidTransition) {
				http.Error(w, err.Error(), http.StatusBadRequest)
			} else {
				http.Error(w, err.Error(), http.StatusInternalServerError)
			}
			return
		}
		h.insertEventOrLog(r.Context(), id, store.EventTypeStateChange,
			store.NewStateChangeData(oldStatus, newStatus, store.TriggerUser, nil))
		h.diffCache.invalidate(id)
		if oldStatus == store.TaskStatusBacklog || oldStatus == store.TaskStatusWaiting {
			h.cascadeArchiveThreadsForTask(id.String())
		}

		if newStatus == store.TaskStatusInProgress && oldStatus == store.TaskStatusBacklog {
			sessionID := ""
			if !task.FreshStart && task.SessionID != nil {
				sessionID = *task.SessionID
			}
			h.runner.RunBackground(id, task.Prompt, sessionID, false)
		}

	}

	updated, err := s.GetTask(r.Context(), id)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	httpjson.Write(w, http.StatusOK, updated)
}

// DeleteTask soft-deletes a task by writing a tombstone. The task data is
// retained on disk for the configured retention period so it can be restored.
func (h *Handler) DeleteTask(w http.ResponseWriter, r *http.Request, id uuid.UUID) {
	// reason is optional; an empty or absent body is fine.
	req, ok := httpjson.DecodeOptionalBody[struct {
		Reason string `json:"reason"`
	}](w, r)
	if !ok {
		return
	}
	s, ok2 := h.requireStore(w)
	if !ok2 {
		return
	}
	if task, err := s.GetTask(r.Context(), id); err == nil {
		if len(task.WorktreePaths) > 0 {
			h.runner.CleanupWorktrees(id, task.WorktreePaths, task.BranchName)
		}
	}
	if err := s.DeleteTask(r.Context(), id, req.Reason); err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	h.cascadeArchiveThreadsForTask(id.String())
	w.WriteHeader(http.StatusNoContent)
}

// ListDeletedTasks returns all soft-deleted (tombstoned) tasks.
func (h *Handler) ListDeletedTasks(w http.ResponseWriter, r *http.Request) {
	s, ok := h.requireStore(w)
	if !ok {
		return
	}
	tasks, err := s.ListDeletedTasks(r.Context())
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	if tasks == nil {
		tasks = []store.Task{}
	}
	httpjson.Write(w, http.StatusOK, tasks)
}

// applyRestore removes the tombstone from a soft-deleted task, making it
// active again, and reverses the lifecycle-cascade thread archiving.
// Invoked from the PATCH deleted=false path (UpdateTask).
func (h *Handler) applyRestore(ctx context.Context, id uuid.UUID) error {
	if err := h.store.RestoreTask(ctx, id); err != nil {
		return err
	}
	h.cascadeUnarchiveThreadsForTask(id.String())
	return nil
}

// validateCustomPatterns compiles each pattern to ensure it is valid RE2 syntax.
// Returns a 400-friendly error message that includes the offending pattern.
func validateCustomPatterns(passPatterns, failPatterns []string) error {
	for _, p := range passPatterns {
		if _, err := regexp.Compile(p); err != nil {
			return fmt.Errorf("invalid custom_pass_pattern %q: %v", p, err)
		}
	}
	for _, p := range failPatterns {
		if _, err := regexp.Compile(p); err != nil {
			return fmt.Errorf("invalid custom_fail_pattern %q: %v", p, err)
		}
	}
	return nil
}
