package runner

import (
	"context"
	"fmt"
	"io"
	"math"
	"math/rand"
	"slices"
	"strconv"
	"strings"
	"time"

	"changkun.de/x/wallfacer/internal/agents"
	"changkun.de/x/wallfacer/internal/constants"
	"changkun.de/x/wallfacer/internal/executor"
	"changkun.de/x/wallfacer/internal/harness"
	"changkun.de/x/wallfacer/internal/logger"
	"changkun.de/x/wallfacer/internal/pkg/set"
	"changkun.de/x/wallfacer/internal/prompts"
	"changkun.de/x/wallfacer/internal/store"
	"github.com/google/uuid"
)

// exploreScore returns a value in [0, 1] representing how exploratory the
// ideation agent should be for a given round. Combines a sine wave with a
// ~6-round cycle with random jitter so that successive brainstorm runs
// naturally oscillate between exploitation and exploration. The sine-based
// cycle prevents the agent from settling into a fixed exploitation ratio:
// early rounds emphasize novel ideas, later rounds refine proven categories,
// and the jitter prevents predictable patterns across restarts.
func exploreScore(round int) float64 {
	wave := (math.Sin(float64(round)*2*math.Pi/6-math.Pi/2) + 1) / 2
	jitter := (rand.Float64() - 0.5) * 0.4
	return max(0, min(1, wave+jitter))
}

// modulateExploitRatio adjusts the base exploitation ratio using a cyclic
// explore score. When explore score is high, the effective exploitation ratio
// is pulled down; when low, it stays near the base. The result is clamped to
// [0, 1].
func modulateExploitRatio(baseExploit float64, round int) float64 {
	es := exploreScore(round)
	// Blend: shift the base ratio toward exploration proportionally to es.
	// At es=0 → full base ratio; at es=1 → base ratio shifted down by up to 0.5.
	effective := baseExploit - es*0.5
	return max(0, min(1, effective))
}

// ideationContext aggregates workspace-derived signals used to enrich the
// brainstorm prompt with objective urgency data (failures, churn, TODOs).
type ideationContext struct {
	FailureSignals     []string
	ChurnSignals       []prompts.WorkspaceSignal
	TodoSignals        []prompts.WorkspaceSignal
	FilteredChurnCount int
	FilteredTodoCount  int
}

// ideaCategoryPool is the set of example improvement areas shown to the
// brainstorm agent as inspiration. The agent is not confined to these — it
// may propose ideas in any category it discovers during workspace exploration.
// Sampling 3 unique entries per run seeds the brainstorm with variety while
// leaving the agent free to override any suggestion with something more relevant.
var ideaCategoryPool = []string{
	"product feature",
	"frontend / UX",
	"backend / API",
	"performance optimization",
	"code quality / refactoring",
	"security hardening",
	"observability / debugging",
	"data model / storage",
	"architecture / design",
}

// IdeationCategories returns the full inspiration pool used when generating
// brainstorm prompts.
func (r *Runner) IdeationCategories() []string {
	return slices.Clone(ideaCategoryPool)
}

// IdeationIgnorePatterns returns the ordered list of path prefixes excluded from
// workspace signal collection. This is exposed via GET /api/config so clients can
// understand and reproduce the filtering without reading source code.
func (r *Runner) IdeationIgnorePatterns() []string {
	return slices.Clone(IdeationIgnorePatterns)
}

// pickCategoriesForInspiration returns a broader set of categories for the
// generate-then-rank pipeline. Unlike pickCategories which assigned one
// category per idea slot, this provides a larger pool purely as inspiration.
func pickCategoriesForInspiration() []string {
	pool := slices.Clone(ideaCategoryPool)
	for i := len(pool) - 1; i > 0; i-- {
		j := rand.Intn(i + 1)
		pool[i], pool[j] = pool[j], pool[i]
	}
	// Show 5-6 categories as inspiration, enough variety without overwhelming.
	n := 5
	if len(pool) > 5 {
		n = 5 + rand.Intn(2) // 5 or 6
	}
	if n > len(pool) {
		n = len(pool)
	}
	return pool[:n]
}

// buildIdeationPrompt constructs the full ideation prompt using a
// generate-then-rank pipeline: the agent brainstorms 6 candidates, self-
// critiques them against concrete impact criteria, and outputs the top 3.
// A broad set of inspiration categories is shown but the agent is free to
// propose multiple ideas in the same domain.
// existingTasks lists tasks currently in backlog, in_progress, or waiting state
// so the agent can avoid proposing duplicates or conflicting ideas.
// userFocus is an optional free-form hint the user typed in the composer
// prompt field; when non-empty it is surfaced prominently in the prompt
// so the agent biases every candidate toward the stated direction.
func (r *Runner) buildIdeationPrompt(existingTasks []store.Task, userFocus string, contexts ...ideationContext) string {
	var signals ideationContext
	if len(contexts) > 0 {
		signals = contexts[0]
	}

	cats := pickCategoriesForInspiration()

	var tasks []prompts.IdeationTask
	for _, t := range existingTasks {
		title := t.Title
		if title == "" {
			title = "(untitled)"
		}
		p := strings.TrimSpace(t.Prompt)
		if len(p) > 120 {
			p = p[:120] + "..."
		}
		tasks = append(tasks, prompts.IdeationTask{
			Title:  title,
			Status: string(t.Status),
			Prompt: p,
		})
	}

	var rejectedTitles []string
	round := 0
	if s := r.currentStore(); s != nil {
		if hist, err := LoadHistory(s.DataDir()); err == nil {
			rejectedTitles = hist.RejectedTitles()
			round = hist.Round()
		} else {
			logger.Runner.Warn("buildIdeationPrompt: load history", "error", err)
		}
	}

	effectiveRatio := modulateExploitRatio(r.ideationExploitRatio(), round)

	return r.promptsMgr.Ideation(prompts.IdeationData{
		ExistingTasks:      tasks,
		Categories:         cats,
		FailureSignals:     signals.FailureSignals,
		ChurnHotspots:      signals.ChurnSignals,
		TodoHotspots:       signals.TodoSignals,
		FilteredChurnCount: signals.FilteredChurnCount,
		FilteredTodoCount:  signals.FilteredTodoCount,
		RejectedTitles:     rejectedTitles,
		ExploitRatio:       effectiveRatio,
		UserFocus:          strings.TrimSpace(userFocus),
	})
}

// IdeateResult holds a single idea proposed by the brainstorm agent.
type IdeateResult struct {
	Title       string `json:"title"`
	Category    string `json:"category"`
	Priority    string `json:"priority"`
	ImpactScore int    `json:"impact_score"`
	Scope       string `json:"scope"`
	Rationale   string `json:"rationale"`
	Prompt      string `json:"prompt"`
}

// RunIdeation runs a lightweight read-only container to analyse the workspaces
// and returns up to 3 proposed task ideas together with the raw container
// stdout/stderr and the parsed agent output. The caller is responsible for
// creating backlog tasks from the results and for persisting the raw output.
// taskID, when non-zero, registers the container under that task ID so that
// KillContainer(taskID) and log streaming work through the standard task paths.
// prompt is the full ideation prompt to send to the container; callers should
// generate it with buildIdeationPrompt() and persist it before calling here.
func (r *Runner) RunIdeation(ctx context.Context, taskID uuid.UUID, prompt string) ([]IdeateResult, []ideaRejection, *agentOutput, []byte, []byte, error) { //nolint:revive // ideaRejection is intentionally unexported
	if r.planner != nil {
		return r.runIdeationViaPlanner(ctx, taskID, prompt)
	}
	return r.runIdeationEphemeral(ctx, taskID, prompt)
}

// runIdeationViaPlanner runs ideation through the long-lived planning worker
// container. The planner is auto-started if not already running.
func (r *Runner) runIdeationViaPlanner(ctx context.Context, taskID uuid.UUID, prompt string) ([]IdeateResult, []ideaRejection, *agentOutput, []byte, []byte, error) {
	// Auto-start the planner if needed (first caller creates the container).
	if !r.planner.IsRunning() {
		if err := r.planner.Start(ctx); err != nil {
			return nil, nil, nil, nil, nil, fmt.Errorf("auto-start planner for ideation: %w", err)
		}
	}

	sb := harness.Claude
	if taskID != uuid.Nil {
		if task, err := r.taskStore(taskID).GetTask(r.shutdownCtx, taskID); err == nil {
			sb = r.sandboxForTaskActivity(task, activityIdeaAgent)
		}
	}

	model := r.modelFromEnvForSandbox(sb)
	cmd := buildAgentCmd(prompt, model)

	logger.Runner.Debug("ideate exec via planner", "sandbox", sb)
	if taskID != uuid.Nil {
		_ = r.taskStore(taskID).InsertEvent(ctx, taskID, store.EventTypeSpanStart, store.SpanData{Phase: "container_run", Label: string(store.SandboxActivityIdeaAgent)})
	}

	handle, launchErr := r.planner.Exec(ctx, cmd)
	if launchErr != nil {
		if taskID != uuid.Nil {
			_ = r.taskStore(taskID).InsertEvent(ctx, taskID, store.EventTypeSpanEnd, store.SpanData{Phase: "container_run", Label: string(store.SandboxActivityIdeaAgent)})
		}
		return nil, nil, nil, nil, nil, fmt.Errorf("planner exec ideation: %w", launchErr)
	}

	// Register the handle so kill and log streaming work through standard paths.
	containerName := handle.Name()
	if taskID != uuid.Nil {
		r.taskContainers.Set(taskID, containerName)
		r.taskContainers.SetHandle(taskID, handle, nil)
		defer r.taskContainers.Delete(taskID)
	}
	r.ideateContainer.SetSingleton(containerName)
	r.ideateContainer.SetSingletonHandle(handle, nil)
	defer r.ideateContainer.DeleteSingleton()

	rawStdout, _ := io.ReadAll(handle.Stdout())
	rawStderr, _ := io.ReadAll(handle.Stderr())
	exitCode, waitErr := handle.Wait()
	if taskID != uuid.Nil {
		_ = r.taskStore(taskID).InsertEvent(ctx, taskID, store.EventTypeSpanEnd, store.SpanData{Phase: "container_run", Label: string(store.SandboxActivityIdeaAgent)})
	}

	if ctx.Err() != nil {
		_ = handle.Kill()
		return nil, nil, nil, rawStdout, rawStderr, fmt.Errorf("ideation container terminated: %w", ctx.Err())
	}

	raw := strings.TrimSpace(string(rawStdout))
	if raw == "" {
		if waitErr != nil || exitCode != 0 {
			return nil, nil, nil, rawStdout, rawStderr, fmt.Errorf("container exited %d: stderr=%s", exitCode, string(rawStderr))
		}
		return nil, nil, nil, rawStdout, rawStderr, fmt.Errorf("empty output from ideation container")
	}

	output, parseErr := r.parseAgentStream(sb, raw)
	if parseErr != nil {
		return nil, nil, nil, rawStdout, rawStderr, fmt.Errorf("parse ideation output: %w", parseErr)
	}
	if output == nil || output.Result == "" {
		return nil, nil, nil, rawStdout, rawStderr, fmt.Errorf("no result in ideation output")
	}

	// Skip Codex fallback when running through planner — the planner
	// container uses the Claude sandbox. Codex support is deferred to
	// planning-codex-compat.md.
	if output.IsError && isLikelyTokenLimitError(output.Result, output.Subtype) {
		logger.Runner.Warn("ideation: token limit hit via planner; codex fallback not available for planner containers", "task", taskID)
	}

	ideas, rejections, extractErr := extractIdeas(output.Result)
	if extractErr != nil {
		recovered, recoveredRejections, recoverErr := extractIdeasFromRunOutput(output.Result, rawStdout, rawStderr)
		if recoverErr == nil {
			return recovered, recoveredRejections, output, rawStdout, rawStderr, nil
		}
		if looksLikeNoCodebaseOutput(output.Result) {
			return nil, nil, output, rawStdout, rawStderr, fmt.Errorf("no source code found in workspace — the ideation agent cannot propose improvements for an empty project")
		}
		return nil, nil, output, rawStdout, rawStderr, fmt.Errorf("extract ideas: %w (result: %s)", extractErr, truncate(output.Result, 300))
	}
	return ideas, rejections, output, rawStdout, rawStderr, nil
}

// roleIdeaAgentEphemeral is the inspector-tier descriptor used by the
// ephemeral (non-planner) ideation path. The parsed result returned by
// the agent is the raw JSON-embedded list of ideas; the caller unpacks
// it via extractIdeas so the rejection/recovery heuristics live close
// to the ideation-specific parsing logic.
// roleIdeaAgentEphemeral binds to the agents.IdeaAgent descriptor;
// the runner's dispatch plumbing lives in agent_bindings.go.
var roleIdeaAgentEphemeral = agents.IdeaAgent

// runIdeationEphemeral is the legacy path that launches an ephemeral container
// per ideation run. Used when no planner is configured.
func (r *Runner) runIdeationEphemeral(ctx context.Context, taskID uuid.UUID, prompt string) ([]IdeateResult, []ideaRejection, *agentOutput, []byte, []byte, error) {
	containerName := "wallfacer-ideate-" + uuid.NewString()[:8]

	var task *store.Task
	if taskID != uuid.Nil {
		if t, err := r.taskStore(taskID).GetTask(r.shutdownCtx, taskID); err == nil {
			task = t
		}
	}

	onLaunch := func(name string, handle executor.Handle) {
		if taskID != uuid.Nil {
			r.taskContainers.Set(taskID, name)
			r.taskContainers.SetHandle(taskID, handle, nil)
		}
		r.ideateContainer.SetSingleton(name)
		r.ideateContainer.SetSingletonHandle(handle, nil)
	}
	cleanup := func() {
		if taskID != uuid.Nil {
			r.taskContainers.Delete(taskID)
		}
		r.ideateContainer.DeleteSingleton()
	}

	res, err := r.runAgent(ctx, roleIdeaAgentEphemeral, task, prompt, runAgentOpts{
		ContainerName:  containerName,
		EmitSpanEvents: taskID != uuid.Nil,
		TrackUsage:     taskID != uuid.Nil,
		Turn:           1,
		OnLaunch:       onLaunch,
	})
	cleanup()

	var rawStdout, rawStderr []byte
	if res != nil {
		rawStdout = res.RawStdout
		rawStderr = res.RawStderr
	}
	if err != nil {
		return nil, nil, nil, rawStdout, rawStderr, err
	}
	output := res.Output
	if output == nil || strings.TrimSpace(output.Result) == "" {
		return nil, nil, output, rawStdout, rawStderr, fmt.Errorf("no result in ideation output")
	}

	ideas, rejections, err := extractIdeas(output.Result)
	if err != nil {
		recovered, recoveredRejections, recoverErr := extractIdeasFromRunOutput(output.Result, rawStdout, rawStderr)
		if recoverErr == nil {
			ideas = recovered
			rejections = recoveredRejections
		} else {
			if looksLikeNoCodebaseOutput(output.Result) {
				return nil, nil, output, rawStdout, rawStderr, fmt.Errorf("no source code found in workspace — the ideation agent cannot propose improvements for an empty project")
			}
			return nil, nil, output, rawStdout, rawStderr, fmt.Errorf("extract ideas: %w (result: %s)", err, truncate(output.Result, 300))
		}
	}
	return ideas, rejections, output, rawStdout, rawStderr, nil
}

// BuildIdeationPrompt exposes the ideation prompt construction used by
// the idea-agent runner for testability and for handler-side task
// bootstrap. The user-focus slot is left empty here; callers that want
// to surface a user hint should go through runIdeationTask, which
// reads it from the task's Prompt field.
func (r *Runner) BuildIdeationPrompt(existingTasks []store.Task) string {
	return r.buildIdeationPrompt(existingTasks, "", r.collectIdeationContext(r.shutdownCtx))
}

// runIdeationTask executes the brainstorm agent for an idea-agent task card.
// It runs RunIdeation, creates backlog tasks from the results, and transitions
// the idea-agent task to done. On failure it returns an error so Run() can
// transition the task to failed.
func (r *Runner) runIdeationTask(ctx context.Context, task *store.Task) error {
	bgCtx := r.shutdownCtx
	taskID := task.ID

	// Set a human-readable title on the idea-agent card.
	title := "Brainstorm " + time.Now().Format("Jan 2, 2006 15:04")
	_ = r.taskStore(taskID).UpdateTaskTitle(bgCtx, taskID, title)

	// Collect tasks currently in backlog, in_progress, or waiting so the
	// brainstorm agent can avoid proposing duplicates or conflicting ideas.
	allTasks, _ := r.taskStore(taskID).ListTasks(bgCtx, false)
	var activeTasks []store.Task
	for _, t := range allTasks {
		if t.ID == taskID {
			continue // skip the brainstorm task itself
		}
		if t.Kind == store.TaskKindIdeaAgent {
			continue // skip other brainstorm meta-tasks
		}
		switch t.Status {
		case store.TaskStatusBacklog, store.TaskStatusInProgress, store.TaskStatusWaiting:
			activeTasks = append(activeTasks, t)
		}
	}

	// Generate the ideation prompt (prefer the prebuilt execution prompt
	// stored on the idea-agent card for consistency). When the user typed
	// text into the composer's prompt field we surface it to the agent
	// as a focus hint — the card's Prompt holds the raw user input and
	// is never passed verbatim as the full prompt; the runner always
	// wraps it with the brainstorm template so the agent still gets the
	// workspace signals and the output schema.
	ideationPrompt := strings.TrimSpace(task.ExecutionPrompt)
	if ideationPrompt == "" {
		userFocus := strings.TrimSpace(task.Prompt)
		ideationPrompt = r.buildIdeationPrompt(activeTasks, userFocus, r.collectIdeationContextFromTasks(bgCtx, allTasks))
		if err := r.taskStore(taskID).UpdateTaskExecutionPrompt(bgCtx, taskID, ideationPrompt); err != nil {
			logger.Runner.Warn("ideation task: set execution prompt on brainstorm card", "task", taskID, "error", err)
		}
	}

	_ = r.taskStore(taskID).InsertEvent(bgCtx, taskID, store.EventTypeSystem, map[string]string{

		"result": "Starting brainstorm agent — exploring workspaces to propose ideas...",
	})

	ideas, rejections, output, rawStdout, rawStderr, err := r.RunIdeation(ctx, taskID, ideationPrompt)
	r.emitIdeationRejectionEvents(bgCtx, taskID, rejections)

	// Record rejected ideas to history so they are excluded from future prompts.
	hist, histErr := LoadHistory(r.taskStore(taskID).DataDir())
	if histErr != nil {
		logger.Runner.Warn("ideation task: load history for recording", "task", taskID, "error", histErr)
		hist = nil
	}
	if hist != nil {
		for _, rej := range rejections {
			if rej.Title == "" {
				continue
			}
			he := HistoryEntry{
				Title:      rej.Title,
				Reason:     "rejected_" + string(rej.Reason),
				RecordedAt: time.Now().UTC(),
			}
			if appErr := hist.Append(he); appErr != nil {
				logger.Runner.Warn("ideation task: append rejection to history", "title", rej.Title, "error", appErr)
			}
		}
	}

	// Always persist the raw container output as turn 1 so that the trace and
	// oversight features work the same as for regular implementation tasks.
	if len(rawStdout) > 0 {
		if saveErr := r.taskStore(taskID).SaveTurnOutput(taskID, 1, rawStdout, rawStderr); saveErr != nil {
			logger.Runner.Warn("ideation: save turn output", "task", taskID, "error", saveErr)
		}
		if len(rawStderr) > 0 {
			_ = r.taskStore(taskID).InsertEvent(bgCtx, taskID, store.EventTypeSystem, map[string]string{

				"stderr_file": "turn-0001.stderr.txt",
				"turn":        "1",
			})
		}
	}

	// Emit an output event and persist the agent result so the task card shows
	// the brainstorm summary and the Turns counter is non-zero (enabling oversight).
	if output != nil {
		sessionID := output.SessionID
		_ = r.taskStore(taskID).InsertEvent(bgCtx, taskID, store.EventTypeOutput, map[string]string{

			"result":      output.Result,
			"stop_reason": output.StopReason,
			"session_id":  sessionID,
		})
		_ = r.taskStore(taskID).UpdateTaskResult(bgCtx, taskID, output.Result, sessionID, output.StopReason, 1)

		_ = r.taskStore(taskID).AccumulateSubAgentUsage(bgCtx, taskID, store.SandboxActivityIdeaAgent, store.TaskUsage{

			InputTokens:          output.Usage.InputTokens,
			OutputTokens:         output.Usage.OutputTokens,
			CacheReadInputTokens: output.Usage.CacheReadInputTokens,
			CacheCreationTokens:  output.Usage.CacheCreationInputTokens,
			CostUSD:              output.TotalCostUSD,
		})
		if appErr := r.taskStore(taskID).AppendTurnUsage(taskID, store.TurnUsageRecord{
			Turn:                 1,
			Timestamp:            time.Now().UTC(),
			InputTokens:          output.Usage.InputTokens,
			OutputTokens:         output.Usage.OutputTokens,
			CacheReadInputTokens: output.Usage.CacheReadInputTokens,
			CacheCreationTokens:  output.Usage.CacheCreationInputTokens,
			CostUSD:              output.TotalCostUSD,
			SubAgent:             store.SandboxActivityIdeaAgent,
		}); appErr != nil {
			logger.Runner.Warn("ideation: append turn usage failed", "task", taskID, "error", appErr)
		}
	} else if len(rawStdout) > 0 {
		// No parsed output (e.g. container error before producing JSON); still
		// increment Turns so the trace file is indexed if stdout was non-empty.
		_ = r.taskStore(taskID).UpdateTaskTurns(bgCtx, taskID, 1)

	}

	if err != nil {
		return err
	}

	// Build the summary from parsed ideas and store it as the task result.
	summary := ideaSummaryLines(ideas)
	if len(summary) > 0 {
		var sb strings.Builder
		for _, line := range summary {
			sb.WriteString("- ")
			sb.WriteString(line)
			sb.WriteString("\n")
		}
		_ = r.taskStore(taskID).UpdateTaskResult(bgCtx, taskID, strings.TrimSpace(sb.String()), "", "", 1)

	} else {
		_ = r.taskStore(taskID).UpdateTaskResult(bgCtx, taskID, "Brainstorm complete — no ideas were proposed.", "", "", 1)

	}

	// When auto-submit is enabled, create backlog tasks immediately.
	// Otherwise the brainstorm task will move to waiting for manual review;
	// backlog tasks are created when the user approves (moves to done).
	if r.isAutosubmitEnabled() {
		_ = r.taskStore(taskID).InsertEvent(bgCtx, taskID, store.EventTypeSystem, map[string]string{

			"result": fmt.Sprintf("Brainstorm complete — creating %d idea task(s).", len(ideas)),
		})
		r.createIdeaBacklogTasks(bgCtx, taskID, ideas)
	} else {
		_ = r.taskStore(taskID).InsertEvent(bgCtx, taskID, store.EventTypeSystem, map[string]string{

			"result": fmt.Sprintf("Brainstorm complete — %d idea(s) proposed. Approve to create backlog tasks.", len(ideas)),
		})
	}

	return nil
}

// ideaSummaryLines builds a display label for each idea.
func ideaSummaryLines(ideas []IdeateResult) []string {
	var lines []string
	for _, idea := range ideas {
		label := fmt.Sprintf("[%s %d] %s", idea.Priority, idea.ImpactScore, idea.Title)
		if idea.Priority == "" {
			label = idea.Title
		}
		lines = append(lines, label)
	}
	return lines
}

// createIdeaBacklogTasks creates a backlog task for each proposed idea and
// records accepted ideas in history.
func (r *Runner) createIdeaBacklogTasks(ctx context.Context, parentTaskID uuid.UUID, ideas []IdeateResult) {
	hist, histErr := LoadHistory(r.taskStore(parentTaskID).DataDir())
	if histErr != nil {
		logger.Runner.Warn("ideation: load history for recording", "task", parentTaskID, "error", histErr)
		hist = nil
	}

	for _, idea := range ideas {
		tags := make([]string, 0, 4)
		tags = append(tags, "idea-agent")
		if idea.Category != "" {
			tags = append(tags, idea.Category)
		}
		if idea.Priority != "" {
			tags = append(tags, "priority:"+idea.Priority)
		}
		if idea.ImpactScore > 0 {
			tags = append(tags, "impact:"+strconv.Itoa(idea.ImpactScore))
		}
		cardPrompt := idea.Prompt
		if cardPrompt == "" {
			cardPrompt = idea.Title
		}
		newTask, createErr := r.taskStore(parentTaskID).CreateTaskWithOptions(ctx, store.TaskCreateOptions{Prompt: cardPrompt, Timeout: 60, Kind: store.TaskKindTask, Tags: tags})
		if createErr != nil {
			logger.Runner.Warn("ideation task: create idea task", "task", parentTaskID, "error", createErr)
			continue
		}
		if hist != nil && idea.Title != "" {
			he := HistoryEntry{
				Title:      idea.Title,
				Reason:     "accepted",
				TaskID:     newTask.ID.String(),
				RecordedAt: time.Now().UTC(),
			}
			if appErr := hist.Append(he); appErr != nil {
				logger.Runner.Warn("ideation task: append accepted idea to history", "title", idea.Title, "error", appErr)
			}
		}
		_ = r.taskStore(parentTaskID).InsertEvent(ctx, newTask.ID, store.EventTypeStateChange,

			store.NewStateChangeData("", store.TaskStatusBacklog, "", nil))
		if idea.Title != "" {
			_ = r.taskStore(parentTaskID).UpdateTaskTitle(ctx, newTask.ID, idea.Title)

		}
		if err := r.taskStore(parentTaskID).UpdateTaskExecutionPrompt(ctx, newTask.ID, idea.Prompt); err != nil {
			logger.Runner.Warn("ideation task: set execution prompt", "task", newTask.ID, "error", err)
		}
		label := fmt.Sprintf("[%s %d] %s", idea.Priority, idea.ImpactScore, idea.Title)
		if idea.Priority == "" {
			label = idea.Title
		}
		_ = r.taskStore(parentTaskID).InsertEvent(ctx, parentTaskID, store.EventTypeSystem, map[string]string{

			"result": fmt.Sprintf("Created idea task: %s", label),
		})
	}
}

// CreateIdeaBacklogTasks re-extracts ideas from a completed brainstorm task's
// turn output and creates backlog tasks from them. This is used when the user
// manually approves a brainstorm that was held at waiting status.
func (r *Runner) CreateIdeaBacklogTasks(ctx context.Context, taskID uuid.UUID) error {
	// The raw agent output is stored in the turn output file; the task Result
	// field contains only the summary lines. Re-extract from the turn output.
	rawStdout, readErr := r.taskStore(taskID).ReadBlob(taskID, "outputs/turn-0001.json")
	if readErr != nil {
		return fmt.Errorf("read turn output: %w", readErr)
	}

	var rawStderr []byte
	if data, err := r.taskStore(taskID).ReadBlob(taskID, "outputs/turn-0001.stderr.txt"); err == nil {
		rawStderr = data
	}

	// Re-parse via the same harness that produced the stored output so the
	// read path stays harness-owned.
	sb := harness.Claude
	if task, err := r.taskStore(taskID).GetTask(ctx, taskID); err == nil && task != nil {
		sb = r.sandboxForTaskActivity(task, activityIdeaAgent)
	}
	output, parseErr := r.parseAgentStream(sb, strings.TrimSpace(string(rawStdout)))
	if parseErr != nil {
		return fmt.Errorf("parse output: %w", parseErr)
	}

	ideas, _, extractErr := extractIdeasFromRunOutput(output.Result, rawStdout, rawStderr)
	if extractErr != nil {
		return fmt.Errorf("extract ideas: %w", extractErr)
	}

	_ = r.taskStore(taskID).InsertEvent(ctx, taskID, store.EventTypeSystem, map[string]string{

		"result": fmt.Sprintf("Approved — creating %d idea task(s).", len(ideas)),
	})
	r.createIdeaBacklogTasks(ctx, taskID, ideas)
	return nil
}

// collectIdeationContext returns workspace and task-derived signals for prompt
// construction so ideation suggestions can be prioritized by objective urgency.
func (r *Runner) collectIdeationContext(ctx context.Context) ideationContext {
	tasks, err := r.currentStore().ListTasks(ctx, false)
	if err != nil {
		return r.collectIdeationContextFromTasks(ctx, nil)
	}
	return r.collectIdeationContextFromTasks(ctx, tasks)
}

func (r *Runner) collectIdeationContextFromTasks(ctx context.Context, tasks []store.Task) ideationContext {
	churnSignals, filteredChurn := r.collectWorkspaceChurnSignals(ctx)
	todoSignals, filteredTodo := r.collectWorkspaceTodoSignals(ctx)
	return ideationContext{
		FailureSignals:     collectIdeationFailureSignals(tasks),
		ChurnSignals:       churnSignals,
		TodoSignals:        todoSignals,
		FilteredChurnCount: filteredChurn,
		FilteredTodoCount:  filteredTodo,
	}
}

// collectIdeationFailureSignals extracts human-readable failure summaries from
// tasks that are either in failed status or have a last_test_result of "fail".
// Results are deduplicated by title and capped at MaxIdeationIdeas.
func collectIdeationFailureSignals(tasks []store.Task) []string {
	type failureSignal struct {
		label string
	}
	signals := make([]failureSignal, 0, len(tasks))
	seen := set.New[string]()
	for _, task := range tasks {
		if task.Kind == store.TaskKindIdeaAgent {
			continue
		}
		isFail := strings.EqualFold(task.LastTestResult, "fail") || task.Status == store.TaskStatusFailed
		if !isFail {
			continue
		}

		title := strings.TrimSpace(task.Title)
		if title == "" {
			title = strings.TrimSpace(task.Prompt)
		}
		if title == "" {
			title = "(untitled)"
		}
		if seen.Has(title) {
			continue
		}
		seen.Add(title)
		reason := "failed"
		if strings.EqualFold(task.LastTestResult, "fail") {
			reason = "last test result: fail"
		}
		signals = append(signals, failureSignal{label: fmt.Sprintf("%s (%s)", title, reason)})
		if len(signals) >= constants.MaxIdeationIdeas {
			break
		}
	}
	result := make([]string, 0, len(signals))
	for _, s := range signals {
		result = append(result, s.label)
	}
	return result
}
