package app

import (
	"crypto/rand"
	"encoding/hex"
	"fmt"
	"strings"
	"time"

	"github.com/correx/tui-go/internal/protocol"
)

func nowMillis() int64 { return time.Now().UnixMilli() }

func newSessionID() string {
	b := make([]byte, 16)
	_, _ = rand.Read(b)
	return hex.EncodeToString(b)
}

func containsFold(haystack, needle string) bool {
	return strings.Contains(strings.ToLower(haystack), strings.ToLower(needle))
}

// formatTime renders an event/activity instant as a local wall-clock time, matching the
// operator's clock (and the local date shown in the session list). It was previously forced
// to UTC, so every event timestamp read hours off the user's actual time.
func formatTime(epochMillis int64) string {
	return time.UnixMilli(epochMillis).Format("15:04:05")
}

// inferCategory maps an event type string to a display category, matching the
// Kotlin inferCategory heuristic.
func inferCategory(t string) string {
	lt := strings.ToLower(t)
	switch {
	case strings.Contains(lt, "approval"):
		return "Approval"
	case strings.Contains(lt, "infer"):
		return "Inference"
	case strings.Contains(lt, "tool"):
		return "Tool"
	case strings.Contains(lt, "context"):
		return "Context"
	case strings.Contains(lt, "stage"), strings.Contains(lt, "lifecycle"):
		return "Lifecycle"
	case strings.Contains(lt, "session"), strings.Contains(lt, "artifact"):
		return "Domain"
	default:
		return "Lifecycle"
	}
}

// narrationLine maps a progress frame to a dim router-feed line, or "" if the
// frame is not a narration trigger.
func narrationLine(msg protocol.ServerMessage) string {
	switch msg.Type {
	case protocol.TypeStageStarted:
		return "→ " + msg.StageID
	case protocol.TypeStageCompleted:
		return "✓ " + msg.StageID
	case protocol.TypeStageFailed:
		if msg.Reason != "" {
			return "⚠ " + msg.StageID + ": " + msg.Reason
		}
		return "⚠ " + msg.StageID
	case protocol.TypeSessionPaused:
		return "⏸ paused"
	case protocol.TypeSessionResumed:
		return "▶ resumed"
	case protocol.TypeSessionCompleted:
		return "✓ workflow complete"
	case protocol.TypeSessionFailed:
		if msg.Reason != "" {
			return "✗ workflow failed: " + msg.Reason
		}
		return "✗ workflow failed"
	default:
		return ""
	}
}

// applyServer mutates the model for a single (non-buffered) server message.
func (m *Model) applyServer(msg protocol.ServerMessage) {
	debugLog("SRV type=%s session=%s reason=%s", msg.Type, msg.SessionID, msg.Reason)
	// Auto-vivify: any event for an unknown session creates it, so session
	// existence is derived from the event stream, not a dedicated control frame.
	if msg.IsEventBearing() && msg.SessionID != "" {
		m.ensureSession(msg.SessionID)
	}
	// Inject dim narration lines for progress frames. Runs in addition to the
	// per-type handling below (which feeds the events panel separately).
	if line := narrationLine(msg); line != "" {
		m.appendRouter(msg.SessionID, RouterEntry{Role: "narration", Content: line})
	}
	switch msg.Type {
	case protocol.TypeSessionAnnounced:
		m.onSessionAnnounced(msg)
	case protocol.TypeSessionRenamed:
		m.onSessionRenamed(msg)
	case protocol.TypeRouterNarration:
		entry := RouterEntry{Role: "narration_llm", Content: msg.Content}
		if msg.LatencyMs != nil && msg.TotalTokens != nil {
			entry.Metrics = &TurnMetrics{LatencyMs: *msg.LatencyMs, TotalTokens: *msg.TotalTokens}
		}
		m.appendRouter(msg.SessionID, entry)
	case protocol.TypeChatTurn:
		m.routerConnected = true
		role := "router"
		if msg.Role == "USER" {
			role = "user"
		}
		entry := RouterEntry{Role: role, Content: msg.Content}
		if msg.LatencyMs != nil && msg.TotalTokens != nil {
			entry.Metrics = &TurnMetrics{LatencyMs: *msg.LatencyMs, TotalTokens: *msg.TotalTokens}
		}
		m.appendRouter(msg.SessionID, entry)
		if s := m.session(msg.SessionID); s != nil {
			s.LastEventAt = nowMillis()
		}
	case protocol.TypeSessionPaused:
		label := "PAUSED"
		switch msg.Reason {
		case "APPROVAL_PENDING":
			label = "PAUSED awaiting approval"
		case "CLARIFICATION_PENDING":
			label = "PAUSED awaiting answer"
		case "ABANDONED_STALE":
			label = "PAUSED (stale)"
		}
		m.touch(msg.SessionID, label)
		if s := m.session(msg.SessionID); s != nil {
			s.Active = false
		}
	case protocol.TypeSessionResumed:
		if s := m.session(msg.SessionID); s != nil {
			s.Status = "ACTIVE"
			s.clearApprovals()
			s.Clar = nil
			s.LastEventAt = nowMillis()
		}
		if msg.SessionID == m.selectedID {
			m.clarResetState()
		}
	case protocol.TypeSessionCompleted:
		m.touch(msg.SessionID, "COMPLETED")
		if s := m.session(msg.SessionID); s != nil {
			s.Active = false
			s.Clar = nil
			s.Propose = nil
			s.clearApprovals()
		}
	case protocol.TypeSessionFailed:
		m.touch(msg.SessionID, "FAILED")
		if s := m.session(msg.SessionID); s != nil {
			s.Active = false
			s.Clar = nil
			s.Propose = nil
			s.clearApprovals()
		}
	case protocol.TypeStageStarted:
		if s := m.session(msg.SessionID); s != nil {
			s.CurrentStage = msg.StageID
			s.Tools = nil
			s.StageTokensUsed = 0
			s.addEvent(msg.OccurredAt, "StageStarted", msg.StageID)
			for i := range s.PlanStages {
				if s.PlanStages[i].ID == msg.StageID {
					s.PlanStages[i].Status = PlanRunning
					break
				}
			}
		}
	case protocol.TypeStageCompleted:
		if s := m.session(msg.SessionID); s != nil {
			s.CurrentStage = ""
			s.addEvent(msg.OccurredAt, "StageCompleted", msg.StageID)
			for i := range s.PlanStages {
				if s.PlanStages[i].ID == msg.StageID {
					s.PlanStages[i].Status = PlanCompleted
					break
				}
			}
		}
	case protocol.TypeStageFailed:
		if s := m.session(msg.SessionID); s != nil {
			s.CurrentStage = ""
			s.addEvent(msg.OccurredAt, "StageFailed", msg.StageID)
			for i := range s.PlanStages {
				if s.PlanStages[i].ID == msg.StageID {
					s.PlanStages[i].Status = PlanFailed
					break
				}
			}
		}
	case protocol.TypeInferenceStarted:
		m.lastReasoning = ""
		if s := m.session(msg.SessionID); s != nil {
			s.Active = true
			s.addEvent(nowMillis(), "InferenceStarted", msg.StageID)
		}
	case protocol.TypeInferenceDone:
		if s := m.session(msg.SessionID); s != nil {
			s.Active = false
			s.LastOutput = msg.Summary
			if msg.Response != "" {
				s.LastResponse = msg.Response
			}
			if msg.TotalTokens != nil {
				s.StageTokensUsed = *msg.TotalTokens
			}
			s.addEvent(msg.OccurredAt, "InferenceCompleted", msg.StageID)
			// Thread the model's reasoning trace into the transcript as a collapsed "thinking"
			// row (revealed via the palette). Skipped when the model emits no separate channel.
			if strings.TrimSpace(msg.Reasoning) != "" {
				m.appendRouter(msg.SessionID, RouterEntry{Role: "thinking", Content: msg.Reasoning})
				m.lastReasoning = msg.Reasoning
			}
		}
	case protocol.TypeInferenceTimeout:
		if s := m.session(msg.SessionID); s != nil {
			s.Active = false
			s.addEvent(nowMillis(), "InferenceTimedOut", msg.StageID)
		}
	case protocol.TypeInferenceFailed:
		if s := m.session(msg.SessionID); s != nil {
			s.Active = false
			s.addEvent(nowMillis(), "InferenceFailed", msg.StageID+": "+msg.Reason)
		}
	case protocol.TypeInferenceRetry:
		if s := m.session(msg.SessionID); s != nil {
			s.addEvent(nowMillis(), "RetryAttempted",
				fmt.Sprintf("%s (%d/%d): %s", msg.StageID, msg.AttemptNumber, msg.MaxAttempts, msg.FailureReason))
		}
	case protocol.TypeToolStarted:
		if s := m.session(msg.SessionID); s != nil {
			s.Active = true
			s.Tools = append(s.Tools, ToolRecord{Name: msg.ToolName, Status: ToolStarted, Params: msg.Params})
			if len(s.Tools) > 8 {
				s.Tools = s.Tools[len(s.Tools)-8:]
			}
			s.LastEventAt = nowMillis()
		}
		// Tool-start is not surfaced inline (it doubles up with the ✓/✎ result row); it
		// stays in the tool list + EVENTS panel.
	case protocol.TypeToolCompleted:
		if s := m.session(msg.SessionID); s != nil {
			s.Active = false
			s.markTool(msg.ToolName, ToolCompleted)
			s.LastOutput = msg.ToolName + ": " + msg.Summary
			s.addEvent(msg.OccurredAt, "ToolCompleted", msg.ToolName)
		}
		if msg.Diff != nil && *msg.Diff != "" {
			// A diff = a write/edit: name the file + the line delta, then keep the
			// existing collapsed diff row (^x opens the full diff).
			path, add, del := diffSummary(*msg.Diff)
			m.appendAction(msg.SessionID, "✎", "wrote "+path+countSuffix(add, del))
			m.appendRouter(msg.SessionID, RouterEntry{Role: "tool", Content: *msg.Diff, Reasoning: m.lastReasoning})
		} else {
			label := msg.ToolName
			// Prefer the actual call args (path=…, command="…") over the affected-entities
			// fallback: params show *what was asked*, not just what got touched.
			if s := m.session(msg.SessionID); s != nil {
				if suffix := paramSuffix(lastToolParams(s, msg.ToolName)); suffix != "" {
					label += suffix
				} else if len(msg.AffectedEntities) > 0 {
					label += " " + strings.Join(msg.AffectedEntities, ", ")
				}
			} else if len(msg.AffectedEntities) > 0 {
				label += " " + strings.Join(msg.AffectedEntities, ", ")
			}
			m.appendAction(msg.SessionID, "✓", actionToolText(label, msg.Summary))
		}
	case protocol.TypeReviewFindings:
		if s := m.session(msg.SessionID); s != nil {
			findings := make([]ReviewFinding, 0, len(msg.ReviewFindings))
			for _, f := range msg.ReviewFindings {
				fix := ""
				if f.SuggestedFix != nil {
					fix = *f.SuggestedFix
				}
				findings = append(findings, ReviewFinding{
					Severity:     f.Severity,
					Confidence:   f.Confidence,
					Category:     f.Category,
					Target:       f.Target,
					Message:      f.Message,
					SuggestedFix: fix,
					Correctness:  f.Correctness,
				})
			}
			s.LastReview = &ReviewResult{
				StageID:  msg.StageID,
				Verdict:  msg.ReviewVerdict,
				Findings: findings,
				Blocked:  msg.ReviewBlocked,
			}
			detail := msg.ReviewVerdict
			if len(findings) > 0 {
				detail += " (" + plural(len(findings), "finding") + ")"
			}
			if msg.ReviewBlocked {
				detail += " · blocked"
			}
			s.LastOutput = "review: " + detail
			s.addEvent(nowMillis(), "ReviewFindings", msg.StageID+": "+detail)
			icon := "✓"
			if msg.ReviewVerdict == "FAIL" {
				icon = "✕"
			} else if msg.ReviewVerdict == "WARN" {
				icon = "▲"
			}
			m.appendAction(msg.SessionID, icon, "review "+detail)
		}
	case protocol.TypeToolAssessed:
		if s := m.session(msg.SessionID); s != nil {
			if msg.Disposition != "PROCEED" || len(msg.AssessedIssues) > 0 {
				detail := msg.Disposition
				if len(msg.AssessedIssues) > 0 {
					detail += " [" + msg.AssessedIssues[0].Code + "]"
				}
				s.LastOutput = msg.ToolName + " assessed: " + detail
				s.addEvent(msg.OccurredAt, "ToolAssessed", detail)
			}
		}
	case protocol.TypeToolFailed:
		if s := m.session(msg.SessionID); s != nil {
			s.markTool(msg.ToolName, ToolFailed)
			s.LastEventAt = msg.OccurredAt
		}
		m.appendAction(msg.SessionID, "✗", actionToolText(msg.ToolName+" failed", msg.Reason))
	case protocol.TypeToolRejected:
		if s := m.session(msg.SessionID); s != nil {
			s.markTool(msg.ToolName, ToolRejected)
			s.LastEventAt = nowMillis()
		}
		m.appendAction(msg.SessionID, "✕", actionToolText(msg.ToolName+" blocked", msg.Reason))
	case protocol.TypeArtifactCreated:
		if s := m.session(msg.SessionID); s != nil {
			s.addEvent(nowMillis(), "ArtifactCreated", msg.ArtifactID)
		}
	case protocol.TypeArtifactValid:
		if s := m.session(msg.SessionID); s != nil {
			s.addEvent(nowMillis(), "ArtifactValidated", msg.ArtifactID)
		}
	case protocol.TypePlanLocked:
		if s := m.session(msg.SessionID); s != nil {
			s.addEvent(nowMillis(), "PlanLocked", strings.Join(msg.StageIDs, " → "))
			s.LastOutput = "plan locked: " + strings.Join(msg.StageIDs, " → ")
			s.PlanGoal = msg.Content
			s.PlanStages = make([]PlanStage, 0, len(msg.StageIDs))
			for _, id := range msg.StageIDs {
				s.PlanStages = append(s.PlanStages, PlanStage{ID: id, Status: PlanPending})
			}
		}
	case protocol.TypeWorkspaceBound:
		if s := m.session(msg.SessionID); s != nil {
			s.WorkspaceRoot = msg.WorkspaceRoot
		}
	case protocol.TypeApprovalRequired:
		m.onApprovalRequired(msg)
	case protocol.TypeClarification:
		m.onClarificationRequired(msg)
	case protocol.TypeWorkflowProposed:
		m.onWorkflowProposed(msg)
	case protocol.TypeApprovalResolved:
		if s := m.session(msg.SessionID); s != nil {
			// The resolved gate names no tool on the wire; recover it from the pending
			// queue before dropping the gate, for the inline row.
			tool, preview := "", ""
			for _, a := range s.PendingQueue {
				if a.RequestID == msg.RequestID {
					tool = a.ToolName
					preview = a.Preview
					break
				}
			}
			// Extract the target (file path for diffs, command for shell) from the
			// preview so the action row reads "approved file_write → healthcheck.sh".
			target := ""
			if tool == "file_write" && isUnifiedDiff(preview) {
				target = diffTarget(preview)
			} else if tool == "shell" && strings.HasPrefix(preview, "{") {
				target = previewTarget(preview)
			}
			// Drop just the resolved gate; any others stay queued and the band
			// advances to the next rather than vanishing entirely.
			s.removeApproval(msg.RequestID)
			detail := msg.Outcome
			if msg.Reason != "" {
				detail += " — " + msg.Reason
			}
			s.addEvent(msg.OccurredAt, "ApprovalResolved", detail)
			s.LastEventAt = nowMillis()
			m.appendAction(msg.SessionID, approvalIcon(msg.Outcome), approvalActionText(msg.Outcome, tool, target, msg.Reason))
		}
	case protocol.TypeSessionSnapshot:
		m.onSnapshot(msg)
	case protocol.TypeStageManifest:
		if s := m.session(msg.SessionID); s != nil {
			byStage := map[string][]ManifestTool{}
			budgetByStage := map[string]int{}
			for _, st := range msg.Stages {
				tools := make([]ManifestTool, 0, len(st.Tools))
				for _, td := range st.Tools {
					tools = append(tools, ManifestTool{Name: td.Name, Tier: td.Tier})
				}
				byStage[st.StageID] = tools
				if st.TokenBudget != nil {
					budgetByStage[st.StageID] = *st.TokenBudget
				}
			}
			s.ToolsByStage = byStage
			s.TokenBudgetByStage = budgetByStage
		}
	case protocol.TypeProjectProfile:
		errMsg := ""
		if msg.ConfigError != nil {
			errMsg = *msg.ConfigError
		}
		m.applyProjectProfileSnapshot(msg.About, msg.Conventions, msg.Commands, errMsg)
	case protocol.TypeOperatorProfile:
		errMsg := ""
		if msg.ConfigError != nil {
			errMsg = *msg.ConfigError
		}
		proposed := ""
		if msg.ProposedAdaptation != nil {
			proposed = *msg.ProposedAdaptation
		}
		m.applyOperatorProfileSnapshot(msg.About, msg.ApprovalMode, msg.PreferredModels, msg.Conventions, proposed, errMsg)
	case protocol.TypeProviderStatus:
		m.currentModel = msg.ProviderID
		if containsFold(msg.ProviderID, "llama") || containsFold(msg.ProviderID, "local") {
			m.providerType = "LOCAL"
		} else {
			m.providerType = "REMOTE"
		}
	case protocol.TypeWorkflowList:
		m.workflows = m.workflows[:0]
		for _, w := range msg.Workflows {
			m.workflows = append(m.workflows, Workflow{ID: w.WorkflowID, Description: w.Description})
		}
	case protocol.TypeModelChanged:
		if msg.Loaded {
			m.currentModel = msg.ModelID
			m.providerType = "LOCAL"
		}
	case protocol.TypeModelList:
		m.availableModels = append(m.availableModels[:0], msg.Models...)
		if msg.Current != "" {
			m.currentModel = msg.Current
			m.providerType = "LOCAL"
		}
	case protocol.TypeResourceStatus:
		m.gpuUsedMB = msg.GpuMemoryUsedMb
		m.gpuTotalMB = msg.GpuMemoryTotalMb
		m.gpuUtil = msg.GpuUtilizationPct
		m.ramMB = msg.ProcessRssMb
		m.sysRamUsedMB = msg.SystemRamUsedMb
		m.sysRamTotalMB = msg.SystemRamTotalMb
	case protocol.TypeArtifactList:
		m.artifacts = msg.Artifacts
		m.artifactsFor = msg.SessionID
		m.artifactsLoading = false
		m.artifactScroll = 0
		if m.artifactsIndex >= len(m.artifacts) {
			m.artifactsIndex = 0
		}
	case protocol.TypeSessionStats:
		m.stats = msg.Stats
		m.statsFor = msg.SessionID
		m.statsLoading = false
	case protocol.TypeIdeaList:
		m.ideas = msg.Ideas
		m.ideasLoading = false
		if m.ideasIndex >= len(m.ideas) {
			m.ideasIndex = 0
		}
	case protocol.TypeHealthChecks:
		m.health = msg.Health
		m.healthLoading = false
	case protocol.TypeFileList:
		m.files = msg.Paths
		m.filesFor = msg.SessionID
		m.filesLoading = false
		if m.fileIndex >= len(m.filteredFiles()) {
			m.fileIndex = 0
		}
	case protocol.TypeGrantList:
		m.grants = msg.Grants
		m.grantsLoading = false
		if m.grantIndex >= len(m.grants) {
			m.grantIndex = 0
		}
	case protocol.TypeConfigSnapshot:
		m.configFields = msg.ConfigFields
		m.configRestart = msg.ConfigRestartRequired
		m.configLoading = false
		if msg.ConfigError != nil {
			m.configError = *msg.ConfigError
		} else {
			// A clean snapshot means the staged edits were accepted (or this is a fresh fetch).
			m.configError = ""
			m.configStaged = map[string]string{}
		}
		if m.configIndex >= len(m.configFields) {
			m.configIndex = 0
		}
	case protocol.TypeRouterResponse, protocol.TypeProtocolError:
		// Recognized but intentionally not rendered here: router.response is
		// superseded by chat.turn events on the global stream, and protocol_error is
		// a transport diagnostic. Explicit no-op so the default below means *unknown*.
	default:
		// Unknown-event raw fallback (TUI-requirements §2): a session-scoped event
		// type this client doesn't recognize is surfaced as a raw row in the event
		// stream, never silently dropped — the frontend must not lie by omission.
		if msg.SessionID != "" {
			s := m.ensureSession(msg.SessionID)
			at := msg.OccurredAt
			if at == 0 {
				at = nowMillis()
			}
			s.addEvent(at, msg.Type, "(raw — unrendered event)")
		}
	}

	// Background-update badge for non-selected sessions.
	if sid := sessionIDOf(msg); sid != "" && sid != m.selectedID {
		m.bgUpdates++
	}
}

func sessionIDOf(msg protocol.ServerMessage) string {
	switch msg.Type {
	case protocol.TypeStageManifest, protocol.TypeSnapshotComplete,
		protocol.TypeProtocolError, protocol.TypeProviderStatus,
		protocol.TypeWorkflowList, protocol.TypeRouterResponse,
		protocol.TypeModelChanged, protocol.TypeModelList, protocol.TypeResourceStatus,
		protocol.TypeArtifactList, protocol.TypeConfigSnapshot, protocol.TypeSessionStats,
		protocol.TypeIdeaList, protocol.TypeHealthChecks,
		protocol.TypeFileList, protocol.TypeGrantList, protocol.TypeOperatorProfile:
		return ""
	default:
		return msg.SessionID
	}
}

// --- inline action-row helpers (external-feedback events surfaced in OUTPUT) ---

// diffSummary extracts the written path and +/- line counts from a unified diff for the
// inline write row. Falls back to "file" when no `+++` header is present.
func diffSummary(diff string) (path string, added, removed int) {
	path = "file"
	for _, ln := range strings.Split(diff, "\n") {
		switch {
		case strings.HasPrefix(ln, "+++ "):
			p := strings.TrimSpace(strings.TrimPrefix(ln, "+++ "))
			p = strings.TrimPrefix(p, "b/")
			if p != "" && p != "/dev/null" {
				path = p
			}
		case strings.HasPrefix(ln, "+") && !strings.HasPrefix(ln, "+++ "):
			added++
		case strings.HasPrefix(ln, "-") && !strings.HasPrefix(ln, "--- "):
			removed++
		}
	}
	return path, added, removed
}

// countSuffix renders the "  (+a −b)" delta, or "" when nothing changed.
func countSuffix(added, removed int) string {
	if added == 0 && removed == 0 {
		return ""
	}
	return "  (+" + itoa(added) + " −" + itoa(removed) + ")"
}

// lastToolParams returns the call args of the most recent invocation of the named tool. The ReAct
// loop runs one tool at a time per session, so the last started record is the one now completing.
func lastToolParams(s *Session, name string) []string {
	for i := len(s.Tools) - 1; i >= 0; i-- {
		if s.Tools[i].Name == name {
			return s.Tools[i].Params
		}
	}
	return nil
}

// paramSuffix renders pretty call args as a " (k=v · k=v)" suffix, or "" when there are none.
// The clip is generous (200 cols) so real tool arguments like shell commands, grep patterns,
// and file paths are legible — the panel width is the real limit.
func paramSuffix(params []string) string {
	if len(params) == 0 {
		return ""
	}
	return " (" + clip(strings.Join(params, " · "), 200) + ")"
}

// actionToolText joins a tool label with a short, clipped result summary.
func actionToolText(label, summary string) string {
	// Collapse to a single line first: tool summaries (dir listings, file heads)
	// often carry newlines, and a multi-line action row paints a background stripe
	// per line with the raw content leaking underneath.
	s := strings.Join(strings.Fields(summary), " ")
	if s == "" {
		return label
	}
	return label + " · " + clip(s, 48)
}

func approvalIcon(outcome string) string {
	if outcome == "REJECTED" {
		return "✕"
	}
	return "⌘"
}

// approvalActionText renders the inline approval row, noting an auto-approval that fired via a
// standing grant (reason "grant:<id>").
func approvalActionText(outcome, tool, target, reason string) string {
	verb := "approved"
	switch outcome {
	case "REJECTED":
		verb = "rejected"
	case "AUTO_APPROVED":
		verb = "auto-approved"
	}
	txt := verb
	if tool != "" {
		txt = verb + " " + tool
	}
	if target != "" {
		txt += " → " + target
	}
	if strings.HasPrefix(reason, "grant:") {
		txt += " · via grant"
	}
	return txt
}

// previewTarget extracts a short target label from a JSON preview payload
// (e.g. {"argv":["bash","-c","curl ..."]} → the last argv element, truncated).
func previewTarget(preview string) string {
	// Naive extraction: find "argv" array and return the last element
	if i := strings.Index(preview, `"argv":`); i >= 0 {
		rest := preview[i+len(`"argv":`):]
		if j := strings.IndexByte(rest, '['); j >= 0 {
			argv := rest[j:]
			if k := strings.IndexByte(argv, ']'); k >= 0 {
				argv = argv[:k+1]
			}
			// Parse comma-separated strings, grab the last non-empty
			parts := strings.Split(strings.Trim(argv, "[]"), ",")
			for i := len(parts) - 1; i >= 0; i-- {
				candidate := strings.Trim(strings.Trim(parts[i], ` "`), `"`)
				if candidate != "" {
					return clip(candidate, 40)
				}
			}
		}
	}
	return ""
}

// onSessionAnnounced fills in a session's workflow identity (the announce is the
// only event carrying workflowId) and applies auto-focus. The session entry itself
// was already created by the auto-vivify path in applyServer.
func (m *Model) onSessionAnnounced(msg protocol.ServerMessage) {
	s := m.ensureSession(msg.SessionID)
	s.Status = "ACTIVE"
	if msg.WorkflowID != "" {
		s.WorkflowID = msg.WorkflowID
		if !s.named {
			s.Name = msg.WorkflowID
		}
	}
	s.LastEventAt = nowMillis()
	if m.pendingWorkflowFocus {
		m.selectedID = msg.SessionID
		m.sessionEntered = true
		m.pendingWorkflowFocus = false
	} else if m.selectedID == "" {
		m.selectedID = msg.SessionID
	}
}

// onSessionRenamed applies the intent-derived title from session.renamed, replacing the
// opaque workflow id shown until the naming inference completed. The named flag pins it so a
// later announce (e.g. on reconnect replay) doesn't revert the display back to the workflow id.
func (m *Model) onSessionRenamed(msg protocol.ServerMessage) {
	if msg.Name == "" {
		return
	}
	s := m.ensureSession(msg.SessionID)
	s.Name = msg.Name
	s.named = true
	s.LastEventAt = nowMillis()
}

func (m *Model) onApprovalRequired(msg protocol.ServerMessage) {
	risk := "unknown"
	var rationale []string
	if msg.RiskSummary != nil {
		risk = msg.RiskSummary.Level
		rationale = msg.RiskSummary.Rationale
	}
	info := &Approval{
		RequestID: msg.RequestID,
		SessionID: msg.SessionID,
		Tier:      msg.Tier,
		Risk:      risk,
		ToolName:  deref(&msg.ToolName),
		Preview:   derefp(msg.Preview),
		Rationale: rationale,
	}
	if s := m.session(msg.SessionID); s != nil {
		s.enqueueApproval(info)
	}
}

func (m *Model) onClarificationRequired(msg protocol.ServerMessage) {
	qs := make([]ClarQuestion, 0, len(msg.Questions))
	for _, q := range msg.Questions {
		qs = append(qs, ClarQuestion{
			ID: q.ID, Prompt: q.Prompt, Header: q.Header,
			Options: q.Options, MultiSelect: q.MultiSelect,
		})
	}
	c := &Clarification{
		RequestID: msg.RequestID, SessionID: msg.SessionID,
		StageID: msg.StageID, Questions: qs,
	}
	if s := m.session(msg.SessionID); s != nil {
		s.Clar = c
	}
	// A newly-arrived clarification must always surface. clarDismissed is model-global, and
	// clarInitState (which clears it) only runs for the selected session — so a clarification
	// arriving for an unselected session while the flag was left set would stay silently hidden.
	m.clarDismissed = false
	if msg.SessionID == m.selectedID {
		m.clarInitState(len(qs))
	}
}

func (m *Model) onWorkflowProposed(msg protocol.ServerMessage) {
	cands := make([]ProposeCandidate, 0, len(msg.Candidates))
	for _, c := range msg.Candidates {
		cands = append(cands, ProposeCandidate{WorkflowID: c.WorkflowID, Reason: c.Reason})
	}
	p := &Proposal{
		ProposalID: msg.ProposalID, SessionID: msg.SessionID,
		Prompt: msg.Prompt, OriginalRequest: msg.OriginalRequest, Candidates: cands,
	}
	if s := m.session(msg.SessionID); s != nil {
		s.Propose = p
	}
	if msg.SessionID == m.selectedID {
		m.proposeInitState()
	}
}

func (m *Model) onSnapshot(msg protocol.ServerMessage) {
	var queue []*Approval
	for _, a := range msg.PendingAppr {
		queue = append(queue, &Approval{
			RequestID: a.RequestID, SessionID: msg.SessionID, Tier: a.Tier,
			Risk: "unknown", ToolName: derefp(a.ToolName), Preview: derefp(a.Preview),
		})
	}
	status := "running"
	if msg.State != nil {
		status = msg.State.Status
	}
	if len(queue) > 0 {
		status = "PAUSED awaiting approval"
	}
	sess := Session{
		ID: msg.SessionID, Status: status, WorkflowID: msg.WorkflowID,
		Name: msg.WorkflowID, LastEventAt: nowMillis(), PendingQueue: queue,
	}
	sess.syncPending()
	if msg.State != nil && msg.State.CurrentStageID != nil {
		sess.CurrentStage = *msg.State.CurrentStageID
	}
	sess.LastOutput = msg.LastOutput
	sess.LastResponse = msg.LastResponse
	sess.WorkspaceRoot = msg.WorkspaceRoot
	for _, e := range msg.RecentEvents {
		sess.Events = append(sess.Events, EventEntry{formatTime(e.Timestamp), e.Type, e.Detail})
	}
	for _, t := range msg.Tools {
		sess.Tools = append(sess.Tools, ToolRecord{Name: t.Name, Tier: t.Tier, Status: toolStatusOf(t.Status)})
	}
	// Replace existing session with same id, else append.
	replaced := false
	for i := range m.sessions {
		if m.sessions[i].ID == msg.SessionID {
			m.sessions[i] = sess
			replaced = true
			break
		}
	}
	if !replaced {
		m.sessions = append(m.sessions, sess)
	}
	if m.selectedID == "" {
		m.selectedID = msg.SessionID
	}
	m.restoreTranscript(msg.SessionID, msg.Transcript)
}

// restoreTranscript rebuilds the OUTPUT transcript for a reopened session from the snapshot's raw
// source rows, reusing the same formatters the live path uses (diffSummary/paramSuffix/…). Replaces
// (not appends) so a reconnect with an existing transcript doesn't duplicate rows.
func (m *Model) restoreTranscript(sid string, rows []protocol.TranscriptRowDto) {
	if len(rows) == 0 {
		return
	}
	out := make([]RouterEntry, 0, len(rows))
	for _, r := range rows {
		switch r.Kind {
		case "chat":
			role := "router"
			if r.Role == "USER" {
				role = "user"
			}
			e := RouterEntry{Role: role, Content: r.Content}
			if r.LatencyMs != nil && r.TotalTokens != nil {
				e.Metrics = &TurnMetrics{LatencyMs: *r.LatencyMs, TotalTokens: *r.TotalTokens}
			}
			out = append(out, e)
		case "narration":
			e := RouterEntry{Role: "narration_llm", Content: r.Content}
			if r.LatencyMs != nil && r.TotalTokens != nil {
				e.Metrics = &TurnMetrics{LatencyMs: *r.LatencyMs, TotalTokens: *r.TotalTokens}
			}
			out = append(out, e)
		case "thinking":
			if strings.TrimSpace(r.Content) != "" {
				out = append(out, RouterEntry{Role: "thinking", Content: r.Content})
			}
		case "tool":
			out = append(out, toolTranscriptRows(r)...)
		}
	}
	m.routerMessages[sid] = out
}

// toolTranscriptRows mirrors the live TypeToolCompleted/Failed/Rejected handling: a diff becomes a
// ✎ write row + the collapsed raw-diff row; otherwise a single ✓/✗/✕ action row.
func toolTranscriptRows(r protocol.TranscriptRowDto) []RouterEntry {
	if r.Diff != nil && *r.Diff != "" {
		path, add, del := diffSummary(*r.Diff)
		return []RouterEntry{
			{Role: "action", Icon: "✎", Content: "wrote " + path + countSuffix(add, del)},
			{Role: "tool", Content: *r.Diff},
		}
	}
	switch r.Status {
	case "failed":
		return []RouterEntry{{Role: "action", Icon: "✗", Content: actionToolText(r.ToolName+" failed", r.Summary)}}
	case "rejected":
		return []RouterEntry{{Role: "action", Icon: "✕", Content: actionToolText(r.ToolName+" blocked", r.Summary)}}
	default:
		label := r.ToolName
		if suffix := paramSuffix(r.Params); suffix != "" {
			label += suffix
		} else if len(r.AffectedEntities) > 0 {
			label += " " + strings.Join(r.AffectedEntities, ", ")
		}
		return []RouterEntry{{Role: "action", Icon: "✓", Content: actionToolText(label, r.Summary)}}
	}
}

func (m *Model) touch(id, status string) {
	if s := m.session(id); s != nil {
		s.Status = status
		s.LastEventAt = nowMillis()
	}
}

// --- Session helpers ---

// maxSessionEvents bounds retained per-session history (effectively "all" for a
// session, while keeping memory bounded on very long runs). The EVENTS panel
// shows the tail that fits; the `e` inspector scrolls the full retained list.
const maxSessionEvents = 1000

func (s *Session) addEvent(epochMillis int64, typ, detail string) {
	s.Events = append(s.Events, EventEntry{formatTime(epochMillis), typ, detail})
	if len(s.Events) > maxSessionEvents {
		s.Events = s.Events[len(s.Events)-maxSessionEvents:]
	}
	s.LastEventAt = epochMillis
}

func (s *Session) markTool(name string, status ToolStatus) {
	for i := range s.Tools {
		if s.Tools[i].Name == name && s.Tools[i].Status == ToolStarted {
			s.Tools[i].Status = status
		}
	}
}

func toolStatusOf(s string) ToolStatus {
	switch s {
	case "COMPLETED":
		return ToolCompleted
	case "FAILED":
		return ToolFailed
	case "REJECTED":
		return ToolRejected
	default:
		return ToolStarted
	}
}

func deref(s *string) string {
	if s == nil {
		return ""
	}
	return *s
}

func derefp(s *string) string { return deref(s) }
