package runner

import (
	"context"
	"encoding/json"
	"fmt"
	"slices"
	"strings"
	"time"

	"changkun.de/x/wallfacer/internal/agents"
	"changkun.de/x/wallfacer/internal/constants"
	"changkun.de/x/wallfacer/internal/envconfig"
	"changkun.de/x/wallfacer/internal/harness"
	"changkun.de/x/wallfacer/internal/logger"
	"changkun.de/x/wallfacer/internal/store"
	"github.com/google/uuid"
)

// GenerateOversight produces a high-level aggregated summary of a task's agent
// execution trace and persists it as oversight.json in the task directory.
//
// For "waiting" transitions it is called synchronously so that the oversight is
// always in a terminal state (ready or failed) by the time the task status
// becomes visible as "waiting". For "done" transitions it is launched as a
// tracked background goroutine via backgroundWg.
//
// The summary is generated by running a lightweight agent container that
// receives a pre-processed compact activity log extracted from the raw NDJSON
// turn output files. The agent groups the activities into logical phases and
// produces a structured JSON response.
//
// Concurrent calls for the same task are serialized via a per-task mutex so
// that periodic and terminal invocations never interleave.
func (r *Runner) GenerateOversight(taskID uuid.UUID) {
	mu := r.oversightLock(taskID)
	mu.Lock()
	defer mu.Unlock()
	r.generateOversightLocked(taskID)
}

// generateOversightLocked is the implementation of GenerateOversight.
// The caller must hold the per-task oversightLock before calling this function.
func (r *Runner) generateOversightLocked(taskID uuid.UUID) {
	ctx := r.shutdownCtx

	// Mark as generating so the UI can show a loading state.
	_ = r.taskStore(taskID).SaveOversight(taskID, store.TaskOversight{
		Status: store.OversightStatusGenerating,
	})

	log, err := r.buildActivityLog(ctx, taskID, 1)
	if err != nil {
		logger.Runner.Warn("oversight: build activity log", "task", taskID, "error", err)
		_ = r.taskStore(taskID).SaveOversight(taskID, store.TaskOversight{
			Status: store.OversightStatusFailed,
			Error:  fmt.Sprintf("build activity log: %v", err),
		})
		return
	}

	if len(log) == 0 {
		// No turns yet — nothing to summarize.
		_ = r.taskStore(taskID).SaveOversight(taskID, store.TaskOversight{
			Status: store.OversightStatusFailed,
			Error:  "no agent activity found",
		})
		return
	}

	phases, err := r.runOversightAgent(taskID, store.SandboxActivityOversight, log)
	if err != nil {
		logger.Runner.Warn("oversight: agent failed", "task", taskID, "error", err)
		_ = r.taskStore(taskID).SaveOversight(taskID, store.TaskOversight{
			Status: store.OversightStatusFailed,
			Error:  fmt.Sprintf("oversight agent: %v", err),
		})
		return
	}

	if err := r.taskStore(taskID).SaveOversight(taskID, store.TaskOversight{
		Status:      store.OversightStatusReady,
		GeneratedAt: time.Now(),
		Phases:      phases,
	}); err != nil {
		logger.Runner.Warn("oversight: save failed", "task", taskID, "error", err)
	}
}

// GenerateTestOversight produces a high-level aggregated summary of the test
// agent's execution turns and persists it as oversight-test.json in the task
// directory. fromTurn is the implementation's last turn count; only turns
// strictly after this boundary (fromTurn+1 onwards) are included.
//
// It is called synchronously after a test run transitions the task back to
// "waiting", so the summary is always in a terminal state when the task
// becomes visible as "waiting".
//
// Concurrent calls for the same task are serialized via a per-task mutex.
func (r *Runner) GenerateTestOversight(taskID uuid.UUID, fromTurn int) {
	mu := r.oversightLock(taskID)
	mu.Lock()
	defer mu.Unlock()
	r.generateTestOversightLocked(taskID, fromTurn)
}

// generateTestOversightLocked is the implementation of GenerateTestOversight.
// The caller must hold the per-task oversightLock before calling this function.
func (r *Runner) generateTestOversightLocked(taskID uuid.UUID, fromTurn int) {
	ctx := r.shutdownCtx

	_ = r.taskStore(taskID).SaveTestOversight(taskID, store.TaskOversight{
		Status: store.OversightStatusGenerating,
	})

	log, err := r.buildActivityLog(ctx, taskID, fromTurn+1)
	if err != nil {
		logger.Runner.Warn("test oversight: build activity log", "task", taskID, "error", err)
		_ = r.taskStore(taskID).SaveTestOversight(taskID, store.TaskOversight{
			Status: store.OversightStatusFailed,
			Error:  fmt.Sprintf("build activity log: %v", err),
		})
		return
	}

	if len(log) == 0 {
		_ = r.taskStore(taskID).SaveTestOversight(taskID, store.TaskOversight{
			Status: store.OversightStatusFailed,
			Error:  "no test agent activity found",
		})
		return
	}

	phases, err := r.runOversightAgent(taskID, store.SandboxActivityOversightTest, log)
	if err != nil {
		logger.Runner.Warn("test oversight: agent failed", "task", taskID, "error", err)
		_ = r.taskStore(taskID).SaveTestOversight(taskID, store.TaskOversight{
			Status: store.OversightStatusFailed,
			Error:  fmt.Sprintf("oversight agent: %v", err),
		})
		return
	}

	if err := r.taskStore(taskID).SaveTestOversight(taskID, store.TaskOversight{
		Status:      store.OversightStatusReady,
		GeneratedAt: time.Now(),
		Phases:      phases,
	}); err != nil {
		logger.Runner.Warn("test oversight: save failed", "task", taskID, "error", err)
	}
}

// oversightIntervalFromEnv reads WALLFACER_OVERSIGHT_INTERVAL from the env file
// and returns it as a time.Duration. Returns 0 if the file is missing, unset,
// or set to 0 (disabled). Mirrors the pattern used by titleModelFromEnv.
func (r *Runner) oversightIntervalFromEnv() time.Duration {
	if r.envFile == "" {
		return 0
	}
	cfg, err := envconfig.Parse(r.envFile)
	if err != nil {
		return 0
	}
	if cfg.OversightInterval <= 0 {
		return 0
	}
	return time.Duration(cfg.OversightInterval) * time.Minute
}

// periodicOversightWorker generates oversight summaries on a fixed interval
// while the task context (Run's execution context) is alive. It skips a tick
// if generation is already in progress (TryLock fails) and re-reads the
// configured interval on each tick so operator changes take effect live.
func (r *Runner) periodicOversightWorker(ctx context.Context, taskID uuid.UUID) {
	interval := r.oversightIntervalFromEnv()
	if interval == 0 {
		return // disabled; exit immediately
	}

	ticker := time.NewTicker(interval)
	defer ticker.Stop()

	for {
		select {
		case <-ctx.Done():
			return
		case <-ticker.C:
			newInterval := r.oversightIntervalFromEnv()
			if newInterval == 0 {
				return // disabled at runtime; stop
			}
			if newInterval != interval {
				interval = newInterval
				ticker.Reset(interval)
			}

			// Skip if a generation is already in progress for this task.
			mu := r.oversightLock(taskID)
			if !mu.TryLock() {
				continue
			}

			// Skip if there is no agent output yet.
			keys, err := r.taskStore(taskID).ListBlobs(taskID, "outputs/turn-")
			if err == nil && len(keys) > 0 {
				r.generateOversightLocked(taskID)
			}
			mu.Unlock()
		}
	}
}

// turnActivity is a pre-processed per-turn summary used to build the oversight prompt.
type turnActivity struct {
	Turn      int
	Timestamp time.Time
	TextNotes []string // assistant text snippets (≤200 chars each)
	ToolCalls []string // "ToolName(input)" format
}

// buildActivityLog reads all turn NDJSON files and produces a compact per-turn
// activity summary, cross-referencing event timestamps. fromTurn is the first
// turn number to include (1-indexed); pass 1 to include all turns.
func (r *Runner) buildActivityLog(ctx context.Context, taskID uuid.UUID, fromTurn int) ([]turnActivity, error) {
	keys, err := r.taskStore(taskID).ListBlobs(taskID, "outputs/turn-")
	if err != nil {
		return nil, nil
	}

	// Filter to .json turn files only (not stderr).
	var turnKeys []string
	for _, key := range keys {
		if strings.HasSuffix(key, ".json") {
			turnKeys = append(turnKeys, key)
		}
	}
	if len(turnKeys) == 0 {
		return nil, nil
	}
	slices.Sort(turnKeys)

	// Build a turn→timestamp index from output events.
	events, err := r.taskStore(taskID).GetEvents(ctx, taskID)
	if err != nil {
		logger.Runner.Warn("oversight: GetEvents failed", "task", taskID, "error", err)
	}
	turnTimestamps := buildTurnTimestamps(events)

	var activities []turnActivity
	for i, key := range turnKeys {
		turnNum := i + 1
		if turnNum < fromTurn {
			continue // Skip turns before the requested boundary.
		}
		raw, err := r.taskStore(taskID).ReadBlob(taskID, key)
		if err != nil {
			continue
		}
		act := parseTurnActivity(raw, turnNum)
		if ts, ok := turnTimestamps[turnNum]; ok {
			act.Timestamp = ts
		}
		activities = append(activities, act)
	}
	return activities, nil
}

// buildTurnTimestamps maps turn number → timestamp by scanning span_start
// events for the "agent_turn" phase. Using span_start times (when the
// container began) rather than output event times (when the container
// finished) ensures that phase timestamps align with span.StartedAt in the
// flamegraph, preventing the first span of each phase from being
// misattributed to the preceding phase.
func buildTurnTimestamps(events []store.TaskEvent) map[int]time.Time {
	result := make(map[int]time.Time)
	turn := 0
	for _, e := range events {
		if e.EventType != store.EventTypeSpanStart {
			continue
		}
		var data store.SpanData
		if err := json.Unmarshal(e.Data, &data); err != nil {
			continue
		}
		if data.Phase != "agent_turn" {
			continue
		}
		turn++
		result[turn] = e.CreatedAt
	}
	return result
}

// ndjsonLine is used to parse individual NDJSON messages from agent output.
type ndjsonLine struct {
	Type    string          `json:"type"`
	Message json.RawMessage `json:"message"`
	Item    *ndjsonItem     `json:"item"`
}

type ndjsonMessage struct {
	Content json.RawMessage `json:"content"`
}

type ndjsonContent struct {
	Type  string          `json:"type"`
	Text  string          `json:"text"`
	Name  string          `json:"name"`
	Tool  string          `json:"tool_name"`
	Input json.RawMessage `json:"input"`
}

type ndjsonItem struct {
	ID               string          `json:"id"`
	Type             string          `json:"type"`
	Text             string          `json:"text"`
	Command          string          `json:"command"`
	Name             string          `json:"name"`
	Tool             string          `json:"tool"`
	ToolName         string          `json:"tool_name"`
	Input            json.RawMessage `json:"input"`
	AggregatedOutput string          `json:"aggregated_output"`
	ExitCode         *int            `json:"exit_code"`
}

// parseTurnActivity extracts tool calls and text snippets from a raw turn NDJSON file.
// The function handles two NDJSON formats:
//   - Claude format: {"type":"assistant","message":{"content":[...]}} messages
//   - Codex format: {"type":"item.started"/"item.completed","item":{...}} events
//
// seenCodexCommands deduplicates Codex items that emit both started and completed
// events for the same command ID.
func parseTurnActivity(raw []byte, turnNum int) turnActivity {
	act := turnActivity{Turn: turnNum}
	seenCodexCommands := map[string]bool{}

	for line := range strings.SplitSeq(string(raw), "\n") {
		line = strings.TrimSpace(line)
		if len(line) == 0 || line[0] != '{' {
			continue
		}
		var msg ndjsonLine
		if err := json.Unmarshal([]byte(line), &msg); err != nil {
			continue
		}
		if (msg.Type == "item.started" || msg.Type == "item.completed") && msg.Item != nil {
			item := msg.Item
			switch item.Type {
			case "agent_message":
				if t := strings.TrimSpace(item.Text); t != "" {
					if len(t) > 200 {
						t = t[:200] + "…"
					}
					act.TextNotes = append(act.TextNotes, t)
				}
			case "command_execution", "bash":
				command := normalizeCodexCommand(item.Command)
				if len(command) > 120 {
					command = command[:120] + "…"
				}
				if command != "" {
					if msg.Type == "item.started" {
						if item.ID != "" && seenCodexCommands[item.ID] {
							break
						}
						act.ToolCalls = append(act.ToolCalls, inferCodexToolName(command)+"("+command+")")
						if item.ID != "" {
							seenCodexCommands[item.ID] = true
						}
					} else if item.ID == "" || !seenCodexCommands[item.ID] {
						act.ToolCalls = append(act.ToolCalls, inferCodexToolName(command)+"("+command+")")
						if item.ID != "" {
							seenCodexCommands[item.ID] = true
						}
					}
				}
			default:
				tool, input := codexToolFromItem(item)
				if tool == "" {
					continue
				}
				if len(input) > 120 {
					input = input[:120] + "…"
				}
				call := tool
				if input != "" {
					call += "(" + input + ")"
				}
				if msg.Type == "item.started" {
					if item.ID != "" && seenCodexCommands[item.ID] {
						break
					}
					act.ToolCalls = append(act.ToolCalls, call)
					if item.ID != "" {
						seenCodexCommands[item.ID] = true
					}
				} else if item.ID == "" || !seenCodexCommands[item.ID] {
					act.ToolCalls = append(act.ToolCalls, call)
					if item.ID != "" {
						seenCodexCommands[item.ID] = true
					}
				}
			}
			continue
		}
		if msg.Type != "assistant" || len(msg.Message) == 0 {
			continue
		}
		var m ndjsonMessage
		if err := json.Unmarshal(msg.Message, &m); err != nil {
			continue
		}
		for _, block := range parseContentBlocks(m.Content) {
			switch block.Type {
			case "text":
				if t := strings.TrimSpace(block.Text); t != "" {
					if len(t) > 200 {
						t = t[:200] + "…"
					}
					act.TextNotes = append(act.TextNotes, t)
				}
			case "tool_use", "tool_call":
				name := canonicalizeToolName(block.Name)
				if name == "" {
					name = canonicalizeToolName(block.Tool)
				}
				if name == "" {
					continue
				}
				input := extractToolInputGo(name, parseRawInput(block.Input))
				if len(input) > 120 {
					input = input[:120] + "…"
				}
				entry := name
				if input != "" {
					entry += "(" + input + ")"
				}
				act.ToolCalls = append(act.ToolCalls, entry)
			}
		}
	}
	return act
}

// parseContentBlocks decodes a JSON content field that may be either an array
// of content blocks or a single content block object.
func parseContentBlocks(raw json.RawMessage) []ndjsonContent {
	if len(raw) == 0 {
		return nil
	}
	var blocks []ndjsonContent
	if err := json.Unmarshal(raw, &blocks); err == nil {
		return blocks
	}
	var single ndjsonContent
	if err := json.Unmarshal(raw, &single); err == nil {
		return []ndjsonContent{single}
	}
	return nil
}

// canonicalizeToolName maps various tool name spellings (bash, command_execution,
// read_file, etc.) to their display-friendly canonical forms (Bash, Read, Write, etc.).
func canonicalizeToolName(name string) string {
	switch strings.ToLower(strings.TrimSpace(name)) {
	case "bash", "command_execution":
		return "Bash"
	case "read", "read_file", "read_file_tool", "file_read", "readfile":
		return "Read"
	case "write", "write_file", "write_file_tool", "file_write", "writefile":
		return "Write"
	case "edit", "edit_file", "modify_file":
		return "Edit"
	case "glob":
		return "Glob"
	case "grep", "search", "find":
		return "Grep"
	case "websearch", "web_search":
		return "WebSearch"
	case "webfetch", "web_fetch":
		return "WebFetch"
	case "task":
		return "Task"
	default:
		return strings.TrimSpace(name)
	}
}

// codexToolFromItem extracts the canonicalized tool name and input summary
// from a Codex-format NDJSON item, checking Type, Name, ToolName, and Tool fields.
func codexToolFromItem(item *ndjsonItem) (string, string) {
	toolName := canonicalizeToolName(item.Type)
	if toolName == "" || toolName == item.Type {
		if item.Name != "" {
			toolName = canonicalizeToolName(item.Name)
		}
	}
	if toolName == "" && item.ToolName != "" {
		toolName = canonicalizeToolName(item.ToolName)
	}
	if toolName == "" && item.Tool != "" {
		toolName = canonicalizeToolName(item.Tool)
	}
	return toolName, extractToolInputGo(toolName, parseRawInput(item.Input))
}

// inferCodexToolName classifies a shell command string as Read, Write, or Bash
// based on the first word (cat→Read, git commit→Write, etc.). Used when
// Codex items report commands without an explicit tool type.
func inferCodexToolName(command string) string {
	cmd := strings.ToLower(strings.TrimSpace(command))
	if cmd == "" {
		return "Bash"
	}
	trim := func(s string) string {
		s = strings.TrimSpace(s)
		if s == "" {
			return s
		}
		s = strings.TrimPrefix(s, "sudo ")
		s = strings.TrimSpace(s)
		return s
	}
	command = trim(cmd)
	if strings.Contains(command, "| tee ") || strings.Contains(command, " > ") || strings.Contains(command, ">>") || strings.HasPrefix(command, "cat >") || strings.HasPrefix(command, "cat>>") {
		return "Write"
	}

	fields := strings.Fields(command)
	if len(fields) == 0 {
		return "Bash"
	}
	first := fields[0]
	switch first {
	case "cat", "sed", "head", "tail", "less", "stat", "pwd", "find", "grep", "rg":
		return "Read"
	case "git", "apply_patch", "cp", "mv", "rm", "mkdir", "rmdir", "touch", "chmod", "chown", "tee":
		if first == "git" && len(fields) >= 2 {
			switch fields[1] {
			case "add", "commit", "mv", "rm", "reset", "restore", "checkout", "switch":
				return "Write"
			case "show", "status", "diff", "log":
				return "Read"
			}
		}
		return "Write"
	default:
		return "Bash"
	}
}

// extractToolInputGo returns a short representative string for a tool call
// given the tool name and its input map. Mirrors the logic in modal.js.
func extractToolInputGo(name string, input map[string]interface{}) string {
	name = canonicalizeToolName(name)
	if input == nil {
		return ""
	}
	str := func(key string) string {
		if v, ok := input[key]; ok {
			if s, ok := v.(string); ok {
				return s
			}
		}
		return ""
	}
	switch name {
	case "Bash":
		return str("command")
	case "Read":
		return str("file_path")
	case "Write":
		return str("file_path")
	case "Edit":
		return str("file_path")
	case "Glob":
		return str("pattern")
	case "Grep":
		return str("pattern")
	case "WebFetch":
		return str("url")
	case "WebSearch":
		return str("query")
	case "Task":
		if p := str("prompt"); p != "" {
			if len(p) > 120 {
				return p[:120]
			}
			return p
		}
		return ""
	default:
		for _, key := range []string{"file_path", "command", "pattern", "query", "path"} {
			if v := str(key); v != "" {
				return v
			}
		}
		return ""
	}
}

// parseRawInput decodes a JSON raw input into a map for extractToolInputGo.
func parseRawInput(raw json.RawMessage) map[string]interface{} {
	if len(raw) == 0 {
		return nil
	}
	// Try string first (some formats encode input as a JSON string).
	var s string
	if json.Unmarshal(raw, &s) == nil {
		var m map[string]interface{}
		_ = json.Unmarshal([]byte(s), &m)

		return m
	}
	var m map[string]interface{}
	_ = json.Unmarshal(raw, &m)

	return m
}

// normalizeCodexCommand strips the "/bin/bash -lc" wrapper and surrounding
// quotes that Codex prepends to shell commands, returning the inner command.
func normalizeCodexCommand(command string) string {
	cmd := strings.TrimSpace(command)
	const prefix = "/bin/bash -lc "
	if !strings.HasPrefix(cmd, prefix) {
		return cmd
	}
	rest := strings.TrimSpace(strings.TrimPrefix(cmd, prefix))
	if len(rest) >= 2 {
		if (rest[0] == '\'' && rest[len(rest)-1] == '\'') || (rest[0] == '"' && rest[len(rest)-1] == '"') {
			rest = rest[1 : len(rest)-1]
		}
	}
	rest = strings.ReplaceAll(rest, "\\'", "'")
	rest = strings.ReplaceAll(rest, "\\\"", "\"")
	return rest
}

// formatActivityLog renders the pre-processed activities as a human-readable text block for the prompt.
func formatActivityLog(activities []turnActivity) string {
	var sb strings.Builder
	for _, a := range activities {
		ts := ""
		if !a.Timestamp.IsZero() {
			ts = a.Timestamp.Format("2006-01-02 15:04:05")
		}
		fmt.Fprintf(&sb, "[Turn %d%s]\n", a.Turn, ifStr(ts != "", " – "+ts, ""))
		for _, note := range a.TextNotes {
			// Indent and prefix with dash; only include if meaningful.
			note = strings.ReplaceAll(strings.TrimSpace(note), "\n", " ")
			fmt.Fprintf(&sb, "  TEXT: %s\n", note)
		}
		for _, call := range a.ToolCalls {
			fmt.Fprintf(&sb, "  TOOL: %s\n", call)
		}
		sb.WriteString("\n")
	}
	return sb.String()
}

// ifStr returns a when cond is true, b otherwise. A ternary helper for strings.
func ifStr(cond bool, a, b string) string {
	if cond {
		return a
	}
	return b
}

// oversightPhaseRaw is the JSON shape for a single phase as the agent produces it.
type oversightPhaseRaw struct {
	Timestamp string   `json:"timestamp"`
	Title     string   `json:"title"`
	Summary   string   `json:"summary"`
	ToolsUsed []string `json:"tools_used"`
	Commands  []string `json:"commands"`
	Actions   []string `json:"actions"`
}

// oversightResult is the JSON structure the agent produces.
type oversightResult struct {
	Phases []oversightPhaseRaw `json:"phases"`
}

// roleOversight is the headless-tier descriptor for the oversight-
// summary sub-agent. Parses the post-run event timeline and returns a
// structured []OversightPhase; the caller then fills in missing phase
// timestamps from the real activity log. Both the regular and the
// test-run oversight share this descriptor; callers pass the
// activity-specific label via runAgentOpts.ActivityOverride so usage
// is split correctly in the cost dashboard.
// parseOversightAgentResult decodes the structured JSON the oversight
// agent emits into a typed store.OversightPhase list. The runner-side
// binding wires this in agent_bindings.go.
func parseOversightAgentResult(o *agentOutput) (any, error) {
	phases, err := parseOversightResult(o.Result)
	if err != nil {
		return nil, fmt.Errorf("parse oversight JSON: %w (raw: %s)", err, truncate(o.Result, 400))
	}
	return phases, nil
}

// runOversightAgent runs a lightweight agent container with the activity log and
// parses the structured JSON it produces. agent is the sub-agent activity used for
// usage attribution (SandboxActivityOversight or SandboxActivityOversightTest).
func (r *Runner) runOversightAgent(taskID uuid.UUID, agent store.SandboxActivity, activities []turnActivity) ([]store.OversightPhase, error) {
	log := formatActivityLog(activities)

	// Cap total log size to avoid exceeding prompt limits.
	if len(log) > constants.MaxOversightLogBytes {
		log = log[:constants.MaxOversightLogBytes] + "\n[...truncated...]"
	}

	prompt := r.promptsMgr.Oversight(log)

	task, err := r.taskStore(taskID).GetTask(r.shutdownCtx, taskID)
	if err != nil {
		logger.Runner.Warn("oversight: get task", "task", taskID, "error", err)
	}

	res, err := r.runAgent(r.shutdownCtx, agents.Oversight, task, prompt, runAgentOpts{
		EmitSpanEvents:   true,
		TrackUsage:       true,
		Turn:             1,
		ActivityOverride: agent,
		// Oversight historically uses the same "small model" env var
		// as title so operators can route both to a cheaper model.
		ModelResolver: func(sb harness.ID) string { return r.titleModelFromEnvForSandbox(sb) },
	})
	if err != nil {
		logger.Runner.Warn("oversight: agent container failed",
			"task", taskID, "agent", agent, "error", err)
		return nil, err
	}
	phases, _ := res.Parsed.([]store.OversightPhase)
	phases = fillMissingPhaseTimestamps(phases, activities)
	return phases, nil
}

// fillMissingPhaseTimestamps assigns proportional timestamps to oversight
// phases when the model-provided timestamps are clearly unusable:
//   - every phase timestamp is zero (missing), or
//   - multiple phases all carry the same non-zero timestamp.
//
// In those cases, phases are distributed evenly across the available turn
// timestamps: phase i is anchored to the first turn in its proportional slice
// of the activity log.
//
// If the phase timestamps contain multiple distinct non-zero values, the slice
// is returned unchanged so valid model output is preserved.
func fillMissingPhaseTimestamps(phases []store.OversightPhase, activities []turnActivity) []store.OversightPhase {
	if len(phases) == 0 || len(activities) == 0 {
		return phases
	}
	firstNonZero := time.Time{}
	hasDistinctNonZero := false
	hasNonZero := false
	zeroCount := 0
	for _, p := range phases {
		if p.Timestamp.IsZero() {
			zeroCount++
			continue
		}
		if !hasNonZero {
			firstNonZero = p.Timestamp
			hasNonZero = true
			continue
		}
		if !p.Timestamp.Equal(firstNonZero) {
			hasDistinctNonZero = true
			break
		}
	}
	if hasDistinctNonZero {
		return phases
	}
	if hasNonZero && zeroCount > 0 {
		return phases
	}

	numPhases := len(phases)
	numTurns := len(activities)
	for i := range phases {
		turnIdx := i * numTurns / numPhases
		if turnIdx < numTurns && !activities[turnIdx].Timestamp.IsZero() {
			phases[i].Timestamp = activities[turnIdx].Timestamp
		}
	}
	return phases
}

// parseOversightResult extracts structured phases from Claude's text response.
// Claude is asked to produce raw JSON but may occasionally wrap it in markdown;
// we strip fences and try multiple JSON extraction strategies.
func parseOversightResult(result string) ([]store.OversightPhase, error) {
	result = strings.TrimSpace(result)
	if result == "" {
		return []store.OversightPhase{}, nil
	}

	// Strip markdown code fences if present.
	if idx := strings.Index(result, "```"); idx != -1 {
		// Find the content between the first ``` and last ```.
		start := strings.Index(result, "\n")
		end := strings.LastIndex(result, "```")
		if start != -1 && end > start {
			result = strings.TrimSpace(result[start+1 : end])
		}
	}

	// Detect the JSON shape and parse accordingly.
	var rawPhases []oversightPhaseRaw
	switch {
	case strings.HasPrefix(result, "["):
		// Bare array of phase objects: [{...}, {...}]
		if err := json.Unmarshal([]byte(result), &rawPhases); err != nil {
			return nil, err
		}
	case strings.HasPrefix(result, "{"):
		// Object with a "phases" key: {"phases": [{...}]}
		var r oversightResult
		if err := json.Unmarshal([]byte(result), &r); err != nil {
			return nil, err
		}
		rawPhases = r.Phases
	default:
		// Fall back: skip preamble text before the first '{'.
		i := strings.Index(result, "{")
		if i < 0 {
			return nil, fmt.Errorf("no JSON object found in oversight result")
		}
		result = result[i:]
		var r oversightResult
		if err := json.Unmarshal([]byte(result), &r); err != nil {
			return nil, err
		}
		rawPhases = r.Phases
	}

	phases := make([]store.OversightPhase, 0, len(rawPhases))
	for _, p := range rawPhases {
		phase := store.OversightPhase{
			Title:     strings.TrimSpace(p.Title),
			Summary:   strings.TrimSpace(p.Summary),
			ToolsUsed: p.ToolsUsed,
			Commands:  p.Commands,
			Actions:   p.Actions,
		}
		if p.Timestamp != "" {
			// Try several common formats Claude might emit.
			for _, layout := range []string{time.RFC3339, "2006-01-02 15:04:05", "2006-01-02T15:04:05"} {
				if t, err := time.Parse(layout, p.Timestamp); err == nil {
					phase.Timestamp = t
					break
				}
			}
		}
		phases = append(phases, phase)
	}
	return phases, nil
}
