package ui

import (
	"encoding/json"
	"fmt"
	"strconv"
	"strings"
	"time"

	"github.com/Armur-Ai/Pentest-Swarm-AI/internal/pipeline"
	"github.com/charmbracelet/bubbles/spinner"
	"github.com/charmbracelet/bubbles/viewport"
	tea "github.com/charmbracelet/bubbletea"
	"github.com/charmbracelet/lipgloss"
)

// Styles — hero palette (see banner.go) so the live campaign TUI matches the
// README demo GIF: amber pheromone accent, agent purple, execute-green, cyan.
var (
	titleStyle = lipgloss.NewStyle().Bold(true).Foreground(hAmber).Padding(0, 1)

	dimStyle = lipgloss.NewStyle().Foreground(hFaint)

	findingCritical = lipgloss.NewStyle().Foreground(hRed).Bold(true)
	findingHigh     = lipgloss.NewStyle().Foreground(lipgloss.Color("#F97316")).Bold(true)
	findingMedium   = lipgloss.NewStyle().Foreground(hAmber)
	findingLow      = lipgloss.NewStyle().Foreground(hGreen)

	phaseActive  = lipgloss.NewStyle().Background(hAmber).Foreground(hVoid).Padding(0, 1)
	phaseDone    = lipgloss.NewStyle().Background(hGreen).Foreground(hVoid).Padding(0, 1)
	phasePending = lipgloss.NewStyle().Foreground(hFaint).Padding(0, 1)

	footerStyle = lipgloss.NewStyle().Foreground(hFaint).Padding(0, 1)

	// errorBanner is the persistent red strip shown when the run reports errors.
	errorBanner = lipgloss.NewStyle().Foreground(hRed).Bold(true).
			Border(lipgloss.NormalBorder(), false, false, false, true).
			BorderForeground(hRed).PaddingLeft(1)
)

// EventMsg delivers a campaign event to the TUI.
type EventMsg pipeline.CampaignEvent

// TickMsg triggers periodic UI updates.
type TickMsg time.Time

// Model is the bubbletea model for the campaign watch TUI.
type Model struct {
	// Campaign info
	campaignID string
	target     string
	objective  string
	startTime  time.Time

	// Agent status
	agents map[string]AgentStatus

	// Events log
	events   []pipeline.CampaignEvent
	viewport viewport.Model

	// Findings
	findings    []FindingDisplay
	severityMap map[pipeline.Severity]int

	// Phase tracking
	currentPhase string
	phases       []PhaseInfo

	// activity is the latest meaningful action, shown as a prominent
	// "NOW ▸ …" line so the operator always knows what the swarm is doing.
	activity string
	// tallies for the live counters row
	endpoints int
	chains    int
	// chainList holds the live attack chains (planner reasoning + per-step
	// status) rendered in the dedicated chain panel. Capped at chainCap.
	chainList []chainView
	// probes counts adaptive BOLA probe work-units (the exploit fan-out).
	// recentProbes decays each tick so the constellation fan reflects the
	// *current* burst of activity, not the lifetime total.
	probes       int
	recentProbes int

	// spend is the cumulative LLM cost in USD, parsed from the "cost" milestone
	// ("spent $X.XXX so far …"). BudgetUSD, when > 0, is the run's hard spend cap
	// (exported so the launcher can wire --budget through); the spend meter fills
	// toward it, else it grows against a soft rolling ceiling.
	spend     float64
	BudgetUSD float64

	// Live time-series, sampled once per TickMsg and capped to seriesCap so the
	// sparklines scroll rather than grow unbounded. These drive the animated
	// telemetry panels — findings/surface/probe-throughput — so the wait shows
	// visible motion long before the first finding lands.
	findingsSeries []int
	surfaceSeries  []int
	probeSeries    []int
	lastProbeCount int

	// agentPulse is a per-agent recent-activity level (keyed like agents), bumped
	// on each event touching that agent and decayed every tick, so the swarm
	// cluster and activity bars pulse with the *current* burst of work.
	agentPulse map[string]int

	// frame increments every TickMsg and drives all glyph/edge animation so the
	// blackboard, mesh and pheromone trails cycle even between events.
	frame int

	// flash is a short-lived "a serious finding just landed" pulse: set to a few
	// frames on a critical/high finding and decayed each tick, it strobes a
	// full-width banner so a 9.8 feels like an event, not a log line. flashSev is
	// the severity that triggered it (drives the banner color).
	flash    int
	flashSev pipeline.Severity
	// lastFindingFrame is the frame the most recent finding landed on, so the
	// findings reel can briefly highlight it ("slide-in" fresh state).
	lastFindingFrame int

	// progressPct is a smoothly-animated 0..100 completion estimate for the
	// headline progress bar. It eases toward a target derived from phase
	// progress and is nudged forward by live activity so it always moves.
	progressPct float64

	// UI
	spinner spinner.Model
	width   int
	height  int
	// DashboardURL, when set, is the live web dashboard running in parallel;
	// shown in the footer so the user knows the browser view is available.
	DashboardURL string
	quitting     bool
	done         bool
	doneErr      error

	// Error surfacing: errors used to only tint an agent card and vanish. Now
	// we count them and keep the latest so the View can show a persistent
	// banner — e.g. an LLM auth failure (wrong provider/key) is visible in the
	// TUI, not just the web dashboard.
	errCount int
	lastErr  string

	// restart is set when the operator presses 'r' after a run ends; the caller
	// (runCampaignTUI) re-runs the campaign with the same config.
	restart bool
}

// DoneMsg tells the TUI the campaign finished (the swarm run returned). The
// view switches to a "complete — press q" state; err is non-nil if the run
// failed.
type DoneMsg struct{ Err error }

// AgentStatus tracks an agent's display state.
type AgentStatus struct {
	Name   string
	Status string // idle, active, complete, error
	Detail string
}

// FindingDisplay is a finding formatted for display.
type FindingDisplay struct {
	Severity pipeline.Severity
	Title    string
	Target   string
	Verified bool // proven against the live target by an external oracle (--verify)
}

// PhaseInfo tracks phase progress.
type PhaseInfo struct {
	Name   string
	Status string // pending, active, done
}

// chainStepView is one step of a live attack chain in the chain panel.
type chainStepView struct {
	name      string
	technique string // MITRE ATT&CK id
	status    string // pending, ok, fail
}

// chainView is one attack chain the exploit agent built: the planner's chosen
// path, its reasoning for chaining these vulns, and each step's live status —
// the "how the LLM decided to chain attacks" view the chain panel renders.
type chainView struct {
	id        string
	name      string
	reasoning string
	steps     []chainStepView
}

// chainCap bounds how many chains the panel retains (most recent wins) so a
// long campaign can't grow the model unbounded.
const chainCap = 8

// applyChainStep marks the named step of chain id with its execution result.
// It advances the first still-pending step matching the name (falling back to
// the first pending step), mirroring the dashboard's move-by-move progression.
func (m *Model) applyChainStep(id, step string, ok bool) {
	status := "fail"
	if ok {
		status = "ok"
	}
	for ci := range m.chainList {
		if m.chainList[ci].id != id {
			continue
		}
		steps := m.chainList[ci].steps
		idx := -1
		for i := range steps {
			if steps[i].name == step && steps[i].status == "pending" {
				idx = i
				break
			}
		}
		if idx < 0 {
			for i := range steps {
				if steps[i].status == "pending" {
					idx = i
					break
				}
			}
		}
		if idx >= 0 {
			steps[idx].status = status
		}
		return
	}
}

// NewModel creates the TUI model.
func NewModel(campaignID, target, objective string) Model {
	s := spinner.New()
	s.Spinner = spinner.Dot
	s.Style = lipgloss.NewStyle().Foreground(hAmber)

	vp := viewport.New(80, 20)

	return Model{
		campaignID: campaignID,
		target:     target,
		objective:  objective,
		startTime:  time.Now(),
		agents: map[string]AgentStatus{
			"orchestrator": {Name: "Orchestrator", Status: "active", Detail: "Initializing..."},
			"recon":        {Name: "Recon Agent", Status: "idle", Detail: "Waiting"},
			"classifier":   {Name: "Classifier", Status: "idle", Detail: "Waiting"},
			"exploit":      {Name: "Exploit Agent", Status: "idle", Detail: "Waiting"},
			"report":       {Name: "Report Agent", Status: "idle", Detail: "Waiting"},
		},
		severityMap: make(map[pipeline.Severity]int),
		agentPulse:  make(map[string]int),
		phases: []PhaseInfo{
			{Name: "Recon", Status: "pending"},
			{Name: "Classify", Status: "pending"},
			{Name: "Plan", Status: "pending"},
			{Name: "Execute", Status: "pending"},
			{Name: "Report", Status: "pending"},
		},
		currentPhase: "initializing",
		spinner:      s,
		viewport:     vp,
	}
}

// RestartRequested reports whether the operator asked to re-run the campaign
// (pressed 'r' after it finished). The caller re-runs with the same config.
func (m Model) RestartRequested() bool { return m.restart }

func (m Model) Init() tea.Cmd {
	return tea.Batch(
		m.spinner.Tick,
		tickCmd(),
	)
}

func tickCmd() tea.Cmd {
	return tea.Tick(time.Second, func(t time.Time) tea.Msg {
		return TickMsg(t)
	})
}

func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	var cmds []tea.Cmd

	switch msg := msg.(type) {
	case tea.KeyMsg:
		switch msg.String() {
		case "q", "ctrl+c", "s":
			// Stop the run / quit. tea.Quit returns control to runCampaignTUI,
			// which cancels the campaign context so the swarm tears down.
			m.quitting = true
			return m, tea.Quit
		case "r":
			// Restart the campaign with the same config — only once a run has
			// finished (or errored/stopped), so we don't fork a live run.
			if m.done {
				m.restart = true
				m.quitting = true
				return m, tea.Quit
			}
		default:
			// Any other navigation key scrolls the event-log viewport
			// (↑/↓/j/k line, PgUp/PgDn page, Home/End jump, ←/→ pan wide lines).
			var cmd tea.Cmd
			m.viewport, cmd = m.viewport.Update(msg)
			return m, cmd
		}

	case tea.MouseMsg:
		// Wheel scroll over the event log.
		var cmd tea.Cmd
		m.viewport, cmd = m.viewport.Update(msg)
		return m, cmd

	case tea.WindowSizeMsg:
		m.width = msg.Width
		m.height = msg.Height
		m.viewport.SetHorizontalStep(6) // enable ←/→ panning for over-wide lines
		m.resizeViewport()

	case EventMsg:
		event := pipeline.CampaignEvent(msg)
		m.events = append(m.events, event)
		m.handleEvent(event)
		// The error banner / findings panel can change the header height, so
		// re-fit the viewport before (re)wrapping the log content to its width.
		m.resizeViewport()
		m.updateViewport()

	case DoneMsg:
		m.done = true
		m.doneErr = msg.Err
		for id, a := range m.agents {
			if a.Status == "active" {
				a.Status = "complete"
				m.agents[id] = a
			}
		}
		for i := range m.phases {
			m.phases[i].Status = "done"
		}
		m.resizeViewport() // the footer grows (restart hint) when a run ends

	case TickMsg:
		// Decay the recent-probe window so the exploit fan reflects the current
		// burst rather than the lifetime total.
		if m.recentProbes > 0 {
			m.recentProbes = m.recentProbes * 2 / 3
		}
		// Advance the animation clock and sample the live time-series so every
		// telemetry panel scrolls and moves each second.
		m.frame++
		if m.flash > 0 {
			m.flash-- // the critical-finding strobe decays back to calm
			if m.flash == 0 {
				m.resizeViewport() // banner vanished — give the log its row back
			}
		}
		m.sampleSeries()
		// Decay per-agent activity so the swarm pulses reflect current work.
		for id, v := range m.agentPulse {
			if v > 0 {
				m.agentPulse[id] = v * 2 / 3
			}
		}
		cmds = append(cmds, tickCmd())

	case spinner.TickMsg:
		var cmd tea.Cmd
		m.spinner, cmd = m.spinner.Update(msg)
		// The spinner ticks several times a second — advance the headline
		// progress bar here so it moves smoothly and fast, not in 1s steps.
		m.advanceProgress()
		cmds = append(cmds, cmd)
	}

	return m, tea.Batch(cmds...)
}

func (m *Model) handleEvent(event pipeline.CampaignEvent) {
	// Update agent status based on event
	switch event.EventType {
	case pipeline.EventStateChange:
		if strings.Contains(event.Detail, "recon") {
			m.setPhase("Recon")
			m.agents["recon"] = AgentStatus{Name: "Recon Agent", Status: "active", Detail: "Scanning..."}
		} else if strings.Contains(event.Detail, "classif") {
			m.setPhase("Classify")
			m.agents["recon"] = AgentStatus{Name: "Recon Agent", Status: "complete", Detail: "Done"}
			m.agents["classifier"] = AgentStatus{Name: "Classifier", Status: "active", Detail: "Classifying..."}
		} else if strings.Contains(event.Detail, "plan") {
			m.setPhase("Plan")
			m.agents["classifier"] = AgentStatus{Name: "Classifier", Status: "complete", Detail: "Done"}
			m.agents["exploit"] = AgentStatus{Name: "Exploit Agent", Status: "active", Detail: "Building chains..."}
		} else if strings.Contains(event.Detail, "execut") {
			m.setPhase("Execute")
		} else if strings.Contains(event.Detail, "report") {
			m.setPhase("Report")
			m.agents["exploit"] = AgentStatus{Name: "Exploit Agent", Status: "complete", Detail: "Done"}
			m.agents["report"] = AgentStatus{Name: "Report Agent", Status: "active", Detail: "Writing report..."}
		} else if strings.Contains(event.Detail, "complete") {
			m.agents["report"] = AgentStatus{Name: "Report Agent", Status: "complete", Detail: "Done"}
		}

	case pipeline.EventFindingDiscovered:
		// Prefer the structured finding payload the swarm streams
		// ({"severity","title",...}); fall back to text if it's absent.
		title := event.Detail
		var sev pipeline.Severity = pipeline.SeverityMedium
		verified := false
		if len(event.Data) > 0 {
			var d struct {
				Severity string `json:"severity"`
				Title    string `json:"title"`
				Verified bool   `json:"verified"`
			}
			if json.Unmarshal(event.Data, &d) == nil {
				if d.Title != "" {
					title = d.Title
				}
				if d.Severity != "" {
					sev = pipeline.Severity(strings.ToLower(d.Severity))
				}
				verified = d.Verified
			}
		} else {
			switch {
			case strings.Contains(event.Detail, "CRITICAL"):
				sev = pipeline.SeverityCritical
			case strings.Contains(event.Detail, "HIGH"):
				sev = pipeline.SeverityHigh
			case strings.Contains(event.Detail, "LOW"):
				sev = pipeline.SeverityLow
			}
		}
		m.severityMap[sev]++
		m.findings = append(m.findings, FindingDisplay{Severity: sev, Title: title, Verified: verified})
		m.bumpAgent("exploit")
		m.lastFindingFrame = m.frame
		// A serious finding fires the attention strobe — longer for a critical.
		switch sev {
		case pipeline.SeverityCritical:
			m.flash, m.flashSev = 6, sev
		case pipeline.SeverityHigh:
			if m.flash < 4 { // don't let a high stomp a still-strobing critical
				m.flash, m.flashSev = 4, sev
			}
		}

	case pipeline.EventMilestone:
		// The scheduler streams cumulative spend as a "cost" milestone
		// ("spent $X.XXX so far …"); parse the dollar figure into the live meter.
		if event.AgentName == "cost" {
			if v, ok := parseSpend(event.Detail); ok {
				m.spend = v
			}
		}

	case pipeline.EventToolResult:
		m.bumpAgent(event.AgentName)
		if a, ok := m.agents[event.AgentName]; ok {
			a.Status = "active"
			a.Detail = truncateStr(event.Detail, 50)
			m.agents[event.AgentName] = a
		}

	case pipeline.EventError:
		m.errCount++
		if d := strings.TrimSpace(event.Detail); d != "" {
			m.lastErr = d
		}
		if a, ok := m.agents[event.AgentName]; ok {
			a.Status = "error"
			a.Detail = truncateStr(event.Detail, 50)
			m.agents[event.AgentName] = a
		}

	case pipeline.EventThought:
		if event.AgentName != "" {
			m.bumpAgent(event.AgentName)
			if a, ok := m.agents[event.AgentName]; ok {
				a.Detail = truncateStr(event.Detail, 50)
				m.agents[event.AgentName] = a
			}
		}

	case pipeline.EventToolCall:
		if event.AgentName != "" {
			m.bumpAgent(event.AgentName)
			if a, ok := m.agents[event.AgentName]; ok {
				a.Status = "active"
				a.Detail = truncateStr(event.Detail, 50)
				m.agents[event.AgentName] = a
			}
		}

	case pipeline.EventEndpointDiscovered:
		m.endpoints++
		m.bumpAgent("recon")

	case pipeline.EventChainStarted:
		m.chains++
		m.bumpAgent("exploit")
		// Capture the chain so the dedicated chain panel can show the planner's
		// reasoning and each step's live status — not just the counter.
		var c struct {
			ID          string `json:"id"`
			Name        string `json:"name"`
			Description string `json:"description"`
			Reasoning   string `json:"reasoning"`
			Steps       []struct {
				Name      string `json:"name"`
				Technique string `json:"technique"`
			} `json:"steps"`
		}
		if len(event.Data) > 0 && json.Unmarshal(event.Data, &c) == nil && c.ID != "" {
			why := strings.TrimSpace(c.Reasoning)
			if why == "" {
				why = strings.TrimSpace(c.Description)
			}
			cv := chainView{id: c.ID, name: c.Name, reasoning: why}
			for _, s := range c.Steps {
				cv.steps = append(cv.steps, chainStepView{name: s.Name, technique: s.Technique, status: "pending"})
			}
			m.chainList = append(m.chainList, cv)
			if len(m.chainList) > chainCap {
				m.chainList = m.chainList[len(m.chainList)-chainCap:]
			}
		}

	case pipeline.EventChainStep:
		// Mark the step's live result in its chain so the panel animates the
		// chain executing move-by-move.
		var w struct {
			ChainID string `json:"chain_id"`
			Step    string `json:"step"`
			Success bool   `json:"success"`
		}
		if len(event.Data) > 0 && json.Unmarshal(event.Data, &w) == nil {
			m.applyChainStep(w.ChainID, w.Step, w.Success)
			m.bumpAgent("exploit")
		}

	case pipeline.EventProbe:
		// Each probe is one concurrent BOLA work-unit fired by the exploit
		// phase — tally it and keep the exploit agent lit so the fan-out reads
		// as live activity on the EXPLOIT node.
		m.probes++
		m.recentProbes++
		m.bumpAgent("exploit")
		if a, ok := m.agents["exploit"]; ok {
			a.Status = "active"
			a.Detail = truncateStr("probing "+event.Detail, 50)
			m.agents["exploit"] = a
		}
	}

	// Real activity nudges the headline progress bar forward so findings and
	// probes visibly move it (a finding is a bigger jump than a probe).
	switch event.EventType {
	case pipeline.EventFindingDiscovered:
		m.nudgeProgress(0.9)
	case pipeline.EventChainStarted, pipeline.EventChainStep:
		m.nudgeProgress(0.4)
	case pipeline.EventProbe, pipeline.EventEndpointDiscovered:
		m.nudgeProgress(0.12)
	case pipeline.EventToolCall, pipeline.EventToolResult:
		m.nudgeProgress(0.25)
	}

	// Keep a running "what's happening now" line from the most informative
	// events, so the progress area always shows the live action.
	switch event.EventType {
	case pipeline.EventToolCall, pipeline.EventToolResult, pipeline.EventStateChange,
		pipeline.EventChainStarted, pipeline.EventChainStep, pipeline.EventFindingDiscovered:
		if d := strings.TrimSpace(event.Detail); d != "" {
			m.activity = d
		}
	}
}

// seriesCap bounds each time-series so it scrolls instead of growing forever;
// sparklines only ever show the most recent render-width samples anyway.
const seriesCap = 240

// sampleSeries records one sample of each live metric. Cumulative counters
// (findings, surface) climb; probe throughput is the per-tick delta so the
// exploit fan-out reads as bursts. Called once per TickMsg.
func (m *Model) sampleSeries() {
	m.findingsSeries = appendCapped(m.findingsSeries, len(m.findings))
	m.surfaceSeries = appendCapped(m.surfaceSeries, m.endpoints)
	m.probeSeries = appendCapped(m.probeSeries, m.probes-m.lastProbeCount)
	m.lastProbeCount = m.probes
}

func appendCapped(s []int, v int) []int {
	s = append(s, v)
	if len(s) > seriesCap {
		s = s[len(s)-seriesCap:]
	}
	return s
}

// bumpAgent raises an agent's recent-activity pulse (clamped) so the swarm
// cluster and activity bars react immediately to its events.
func (m *Model) bumpAgent(id string) {
	if id == "" {
		return
	}
	if _, ok := m.agents[id]; !ok {
		return
	}
	m.agentPulse[id] += 2
	if m.agentPulse[id] > 12 {
		m.agentPulse[id] = 12
	}
}

// riskScore derives a composite 0..100 risk score: a floor set by the worst
// severity seen, plus a small volume bonus. Returns the score, its band label
// and band color for the RiskMeter gauge.
func (m Model) riskScore() (int, string, lipgloss.Color) {
	c := m.severityMap[pipeline.SeverityCritical]
	h := m.severityMap[pipeline.SeverityHigh]
	med := m.severityMap[pipeline.SeverityMedium]
	l := m.severityMap[pipeline.SeverityLow]
	base, band, col := 0, "NONE", hFaint
	switch {
	case c > 0:
		base, band, col = 85, "CRITICAL", hRed
	case h > 0:
		base, band, col = 65, "HIGH", lipgloss.Color("#F97316")
	case med > 0:
		base, band, col = 42, "MEDIUM", hAmber
	case l > 0:
		base, band, col = 18, "LOW", hGreen
	}
	if base == 0 {
		return 0, band, col
	}
	bonus := (c + h + med + l) * 3
	if bonus > 15 {
		bonus = 15
	}
	score := base + bonus
	if score > 100 {
		score = 100
	}
	return score, band, col
}

// advanceProgress eases the headline progress bar toward a target derived from
// phase completion. Within the active phase it creeps toward (but never fully
// reaches) that phase's ceiling, with a constant drift so the bar always
// visibly moves; completed phases lock in their share, and DoneMsg snaps to
// 100. Live events also nudge it forward (see nudgeProgress) so real work
// pushes the bar.
func (m *Model) advanceProgress() {
	if m.done {
		m.progressPct = 100
		return
	}
	total := len(m.phases)
	if total == 0 {
		total = 1
	}
	done, active := 0, false
	for _, p := range m.phases {
		if p.Status == "done" {
			done++
		}
		if p.Status == "active" {
			active = true
		}
	}
	slice := 100.0 / float64(total)
	target := float64(done) * slice
	if active {
		target += slice * 0.92 // creep to 92% of the active phase's share
	} else {
		// No phase active yet (initializing / booting a lab) — still show clear
		// motion from the moment the run starts, so the bar never sits dead at 0.
		target += slice * 0.35
	}
	if target > 99 {
		target = 99 // reserve 100 for actual completion
	}
	if target <= m.progressPct {
		return // monotonic — never go backwards, hold at the ceiling
	}
	// Ease toward the target, plus a strong constant drift so it moves promptly
	// and assertively rather than crawling.
	m.progressPct += (target-m.progressPct)*0.18 + 0.5
	if m.progressPct > target {
		m.progressPct = target
	}
}

// nudgeProgress bumps the bar forward on real activity so findings and probes
// visibly advance it, capped so it can't run past the active phase.
func (m *Model) nudgeProgress(by float64) {
	cap := m.progressPct + 2
	if cap > 99 {
		cap = 99
	}
	if m.progressPct+by < cap {
		m.progressPct += by
	}
}

func (m *Model) setPhase(name string) {
	for i := range m.phases {
		if m.phases[i].Name == name {
			m.phases[i].Status = "active"
			m.currentPhase = name
		} else if m.phases[i].Status == "active" {
			m.phases[i].Status = "done"
		}
	}
}

func (m *Model) updateViewport() {
	var lines []string
	for _, e := range m.events {
		ts := e.Timestamp.Format("15:04:05")
		prefix := dimStyle.Render(ts)

		switch e.EventType {
		case pipeline.EventThought:
			lines = append(lines, fmt.Sprintf("%s [think] %s", prefix, e.Detail))
		case pipeline.EventToolCall:
			lines = append(lines, fmt.Sprintf("%s   [>>]  %s", prefix, e.Detail))
		case pipeline.EventToolResult:
			lines = append(lines, fmt.Sprintf("%s   [<<]  %s", prefix, e.Detail))
		case pipeline.EventFindingDiscovered:
			lines = append(lines, fmt.Sprintf("%s    [!]  %s", prefix, e.Detail))
		case pipeline.EventError:
			lines = append(lines, fmt.Sprintf("%s  [ERR]  %s", prefix, e.Detail))
		case pipeline.EventMilestone:
			lines = append(lines, fmt.Sprintf("%s [DONE]  %s", prefix, e.Detail))
		default:
			lines = append(lines, fmt.Sprintf("%s   [%s] %s", prefix, e.EventType, e.Detail))
		}
	}
	// Soft-wrap each line to the viewport width so long tool output reflows
	// instead of overflowing the pane (↑/↓ scroll through it; ←/→ pans if a
	// line is still wider than the pane, e.g. a long unbroken URL).
	content := strings.Join(lines, "\n")
	if w := m.viewport.Width; w > 0 {
		content = lipgloss.NewStyle().Width(w).Render(content)
	}
	wasAtBottom := m.viewport.AtBottom()
	m.viewport.SetContent(content)
	// Keep following the tail only while the operator is already at the bottom;
	// if they've scrolled up to read history, don't yank them back down.
	if wasAtBottom {
		m.viewport.GotoBottom()
	}
}

// resizeViewport fits the event-log viewport into the rows left after the
// header and footer, and to the width left after the scrollbar gutter. It is
// called whenever the window, header, or footer height can change, so View can
// simply render head + viewport + footer and have it fill the screen exactly.
func (m *Model) resizeViewport() {
	if m.width <= 0 || m.height <= 0 {
		return
	}
	headH := lipgloss.Height(m.headView())
	footH := lipgloss.Height(m.footerView())
	// head + "\n" + log + "\n" + footer  → two newlines join the three blocks.
	logH := m.height - headH - footH - 2
	if logH < 3 {
		logH = 3
	}
	m.viewport.Height = logH
	w := m.width - 2 // leave one column for the scrollbar gutter (+1 spacer)
	if w < 10 {
		w = 10
	}
	m.viewport.Width = w
}

func (m Model) View() string {
	if m.quitting {
		return ""
	}
	// Three stacked regions: a fixed header, the scrollable event log (sized by
	// resizeViewport to fill the leftover rows) with a scrollbar gutter, and a
	// fixed footer. Everything is width- and height-responsive (see headView).
	head := m.headView()
	foot := m.footerView()
	log := lipgloss.JoinHorizontal(lipgloss.Top, m.viewport.View(), m.scrollbarCol())
	return head + "\n" + log + "\n" + foot
}

// headView renders everything above the event log: the brand, campaign meta,
// progress, live-now line and (as space allows) the architecture diagram and
// panel grid. It degrades with BOTH dimensions — the block wordmark and the
// full blackboard/panels appear only when the window is wide AND tall enough;
// otherwise compact equivalents are used or heavy sections are dropped — so a
// small window shows a clean, fitting layout instead of wrapped noise. It ends
// with the " Event Log" label so the viewport sits directly beneath it.
func (m Model) headView() string {
	full := m.contentWidth()
	var b strings.Builder

	// Brand + campaign meta. The block wordmark needs width AND a few spare
	// rows; otherwise fall back to the one-line mark.
	elapsed := time.Since(m.startTime).Round(time.Second)
	if FitsWordmark(m.width) && m.contentHeight() >= 20 {
		b.WriteString(SwarmWordmark(1) + "\n")
	} else {
		b.WriteString(" " + CompactMark(m.width) + "\n")
	}
	// Build the meta line from plain, pre-truncated pieces (truncating styled
	// text would cut through ANSI escapes) within a width budget, so it fits the
	// real window. The brand shortens and the objective drops as width tightens.
	el := elapsed.String()
	brand := "PENTEST SWARM AI"
	if full < 46 {
		brand = "PSW"
	}
	// Reserve room for the brand, elapsed, and (only when wide) the objective,
	// so the target gets whatever's left and the whole line fits the window.
	obj := ""
	if full >= 80 && strings.TrimSpace(m.objective) != "" {
		obj = "   " + truncateStr(m.objective, 24)
	}
	tgtBudget := full - 1 - lipgloss.Width(brand) - 3 - lipgloss.Width(obj) - 3 - len(el)
	if tgtBudget < 6 {
		tgtBudget = 6
	}
	meta := stAmber.Render(brand) + "   " + stInk.Render(truncateStr(m.target, tgtBudget))
	if obj != "" {
		meta += stMuted.Render(obj)
	}
	meta += "   " + stFaint.Render(el)
	b.WriteString(" " + meta + "\n")

	// Critical/high-finding strobe — a short-lived full-width banner so a serious
	// finding lands as an event, not a log line. Decays after a few ticks.
	if fb := m.flashBanner(full); fb != "" {
		b.WriteString(fb + "\n")
	}

	// Error banner — surfaces LLM/provider/tool errors prominently. The whole
	// message is plain text, so truncate it to fit the banner's border+padding.
	if m.errCount > 0 {
		msg := fmt.Sprintf(" ⚠ %d error", m.errCount)
		if m.errCount != 1 {
			msg += "s"
		}
		if m.lastErr != "" {
			msg += " · " + m.lastErr
		}
		b.WriteString(errorBanner.Render(truncateStr(msg, full-4)) + "\n")
	}

	// Headline progress — the big animated bar when it fits, else a compact
	// percentage so a very narrow pane still shows progress without overflowing.
	done := 0
	for _, p := range m.phases {
		if p.Status == "done" {
			done++
		}
	}
	if full >= 24 {
		barW := full
		if barW > 80 {
			barW = 80
		}
		b.WriteString(" " + BigProgressBar(m.progressPct, barW, m.frame) + "\n")
	} else {
		b.WriteString(" " + stGreen.Render(fmt.Sprintf("%3.0f%%", m.progressPct)) + stFaint.Render(" complete") + "\n")
	}
	// Phase chips are wide; show them only when they fit, else just "phase x/5".
	phaseLine := stFaint.Render(fmt.Sprintf(" phase %d/%d", done, len(m.phases)))
	if full >= 64 {
		var phases []string
		for _, p := range m.phases {
			switch p.Status {
			case "done":
				phases = append(phases, phaseDone.Render(p.Name+" ✓"))
			case "active":
				phases = append(phases, phaseActive.Render(m.spinner.View()+" "+p.Name))
			default:
				phases = append(phases, phasePending.Render(p.Name))
			}
		}
		phaseLine += stFaint.Render("  ") + strings.Join(phases, stFaint.Render(" → "))
	}
	b.WriteString(phaseLine + "\n")

	// Live "what's happening now" line + counters.
	now := m.activity
	if now == "" {
		if m.done {
			now = "campaign complete"
		} else {
			now = "starting up…"
		}
	}
	nowArrow := stGreen.Render("NOW ▸ ")
	if m.frame%2 == 0 && !m.done { // the live cursor pulses to gold
		nowArrow = lipgloss.NewStyle().Foreground(hCrest).Bold(true).Render("NOW ▸ ")
	}
	b.WriteString(" " + nowArrow + stInk.Render(truncateStr(now, full-9)) + "\n")
	counters := fmt.Sprintf("surface %d · chains %d · probes %d · findings %d",
		m.endpoints, m.chains, m.probes, len(m.findings))
	b.WriteString(" " + stFaint.Render(truncateStr(counters, full-2)) + "\n")

	states := map[string]string{
		"recon": m.agents["recon"].Status, "classifier": m.agents["classifier"].Status,
		"exploit": m.agents["exploit"].Status, "report": m.agents["report"].Status,
	}

	// Architecture — the full animated blackboard needs a wide, tall window; a
	// medium window gets the one-line mesh; a small one drops it entirely so the
	// log keeps its rows.
	switch {
	case full >= 58 && m.contentHeight() >= 30:
		b.WriteString(animRule(full, m.frame) + "\n")
		b.WriteString(" " + stCyan.Render("ARCHITECTURE") + stFaint.Render("  ── stigmergic blackboard · live") + "\n")
		b.WriteString(LiveBlackboard(states, len(m.findings)+m.endpoints, m.frame, full) + "\n")
		if fan := ExploitFan(m.recentProbes, m.probes, m.frame, full); fan != "" {
			b.WriteString(fan + "\n")
		}
	case m.contentHeight() >= 18 && full >= 36:
		b.WriteString(CompactArch(states, len(m.findings)+m.endpoints) + "\n")
	}

	// Chain panel — the headline feature: the planner's reasoning for chaining
	// the exploit plus each step's live status. Shown whenever a chain has
	// started and the window has a little vertical room (it's capped to 3
	// reasoning lines + its steps, and resizeViewport gives the log the rest).
	if len(m.chainList) > 0 && m.contentHeight() >= 22 && full >= 40 {
		b.WriteString(animRule(full, m.frame) + "\n")
		b.WriteString(m.renderChains(full) + "\n")
	}

	// Findings reel — a live, newest-first strip for windows that don't get the
	// full findings panel (that panel needs a tall window). Gives medium panes a
	// lively findings view; on a big screen the panel below covers it.
	if len(m.findings) > 0 && m.panelRows() == 0 && m.contentHeight() >= 24 && full >= 40 {
		b.WriteString(animRule(full, m.frame) + "\n")
		b.WriteString(m.findingsReel(full) + "\n")
	}

	// Panel grid — only when the window is tall AND wide enough to afford it
	// (the narrow stacked variant is the tallest, so it needs the most rows).
	// Below that we skip straight to the log, since the counters above already
	// summarize it.
	if m.panelRows() > 0 && full >= 40 {
		b.WriteString(animRule(full, m.frame) + "\n")
		switch {
		case full >= 108:
			colW := (full - 4) / 3
			iw := colW - 4
			b.WriteString(lipgloss.JoinHorizontal(lipgloss.Top,
				panelBox(m.renderSwarm(iw), colW), " ",
				panelBox(m.renderFindings(iw), colW), " ",
				panelBox(m.renderTelemetry(iw), colW)) + "\n")
		case full >= 64:
			colW := (full - 3) / 2
			if colW < 28 {
				colW = 28
			}
			iw := colW - 4
			left := lipgloss.JoinVertical(lipgloss.Left,
				panelBox(m.renderSwarm(iw), colW), panelBox(m.renderTelemetry(iw), colW))
			right := panelBox(m.renderFindings(iw), colW)
			b.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, left, " ", right) + "\n")
		default:
			iw := full - 4
			b.WriteString(panelBox(m.renderSwarm(iw), full) + "\n")
			b.WriteString(panelBox(m.renderFindings(iw), full) + "\n")
		}
	}

	b.WriteString(animRule(full, m.frame) + "\n")
	scroll := ""
	if tc := m.viewport.TotalLineCount(); tc > m.viewport.Height {
		scroll = stFaint.Render(fmt.Sprintf("  %d%%", int(m.viewport.ScrollPercent()*100)))
	}
	b.WriteString(dimStyle.Render(" Event Log") + scroll)
	return b.String()
}

// panelRows reports whether the panel grid should render given the current
// window, returning a nonzero sentinel when it fits. The panels are the first
// thing dropped as the window shrinks vertically, because the counters line
// already carries the same numbers.
func (m Model) panelRows() int {
	if m.contentHeight() >= 34 {
		return 1
	}
	return 0
}

// footerView renders the fixed bottom region: the optional dashboard URL and
// the key hints (which include the live scroll keys now that the log scrolls).
func (m Model) footerView() string {
	full := m.contentWidth()
	narrow := full < 56
	var b strings.Builder
	if m.DashboardURL != "" {
		url := truncateStr(m.DashboardURL, full-18)
		b.WriteString(" " + stCyan.Render("web dashboard") + stFaint.Render(" → ") + stInk.Render(url) + "\n")
	}
	if narrow {
		// Plain, single-tone hint built as text then styled once (so truncation
		// can't cut through an ANSI escape) and clamped to the window width.
		hint := " s:stop  q:quit  ↑↓:scroll"
		if m.done {
			hint = " r:restart  q:quit  ↑↓:scroll"
		}
		b.WriteString(stFaint.Render(truncateStr(hint, full-1)))
		return b.String()
	}
	scroll := "↑↓/PgUp/PgDn/wheel"
	if m.done {
		head := findingLow.Render(" ✓ campaign complete")
		if m.doneErr != nil {
			head = findingHigh.Render(" ✗ failed: " + truncateStr(m.doneErr.Error(), full-40))
		}
		b.WriteString(head + footerStyle.Render("  ") +
			stGreen.Render("r") + footerStyle.Render(":restart  ") +
			stMuted.Render("q") + footerStyle.Render(":quit  ") +
			stMuted.Render(scroll) + footerStyle.Render(":scroll"))
	} else {
		b.WriteString(footerStyle.Render(" ") +
			stMuted.Render("s") + footerStyle.Render(":stop run  ") +
			stMuted.Render("q") + footerStyle.Render(":quit  ") +
			stMuted.Render(scroll) + footerStyle.Render(":scroll"))
	}
	return b.String()
}

// scrollbarCol draws the event log's vertical scrollbar in a one-column gutter:
// a faint track with an amber thumb sized and positioned by the viewport's
// scroll state. It returns a column exactly viewport.Height rows tall so it
// aligns with the log beside it; when everything fits, the track stays faint.
func (m Model) scrollbarCol() string {
	h := m.viewport.Height
	if h < 1 {
		return ""
	}
	total := m.viewport.TotalLineCount()
	track := lipgloss.NewStyle().Foreground(hFaint)
	bar := lipgloss.NewStyle().Foreground(hAmber)
	// Everything visible → no thumb; a quiet full-height track so the gutter
	// still reads as a scrollbar lane rather than empty space.
	pos, thumb := -1, 0
	if total > h {
		thumb = h * h / total
		if thumb < 1 {
			thumb = 1
		}
		pos = int(m.viewport.ScrollPercent() * float64(h-thumb))
		if pos < 0 {
			pos = 0
		}
		if pos > h-thumb {
			pos = h - thumb
		}
	}
	rows := make([]string, h)
	for i := 0; i < h; i++ {
		if pos >= 0 && i >= pos && i < pos+thumb {
			rows[i] = bar.Render("█")
		} else {
			rows[i] = track.Render("│")
		}
	}
	return strings.Join(rows, "\n")
}

// renderChains is the dedicated attack-chain panel: for the most recent chain
// it shows the planner's reasoning for WHY it chained these vulnerabilities and
// then each step with its MITRE technique and live execution status — the
// terminal counterpart to the dashboard's move-by-move chain view. full is the
// available width; every line is truncated to fit. Returns "" when no chain has
// started yet.
func (m Model) renderChains(full int) string {
	if len(m.chainList) == 0 {
		return ""
	}
	c := m.chainList[len(m.chainList)-1]
	var b strings.Builder
	b.WriteString(" " + stCyan.Render("CHAINS") + stFaint.Render("  how the swarm chained the exploit") + "\n")

	done := 0
	for _, s := range c.steps {
		if s.status != "pending" {
			done++
		}
	}
	name := truncateStr(c.name, full-16)
	b.WriteString(" " + stAmber.Render("▸ ") + stInk.Render(name) + "  " +
		stFaint.Render(fmt.Sprintf("%d/%d", done, len(c.steps))) + "\n")

	// ASCII attack-path — a horizontal node-edge map that draws itself as steps
	// land (the TUI mirror of the dashboard's attack-path cinematic).
	if path := attackPathLine(c.steps, m.frame, full); path != "" {
		b.WriteString(path + "\n")
	}

	// Planner reasoning — wrapped to width, capped to 3 lines so one chain can't
	// push the event log off a short screen.
	if c.reasoning != "" {
		const indent = "         " // aligns under "   why ▸ "
		wrapW := full - len(indent)
		if wrapW < 12 {
			wrapW = 12
		}
		wrapped := strings.Split(lipgloss.NewStyle().Width(wrapW).Render(c.reasoning), "\n")
		if len(wrapped) > 3 {
			wrapped = wrapped[:3]
			wrapped[2] = truncateStr(wrapped[2], wrapW-1) + "…"
		}
		for i, ln := range wrapped {
			if i == 0 {
				b.WriteString("   " + stFaint.Render("why ▸ ") + stMuted.Render(ln) + "\n")
			} else {
				b.WriteString(indent + stMuted.Render(ln) + "\n")
			}
		}
	}

	// Steps with live status: ○ pending (▸ amber for the one running now), ✓ ok,
	// ✗ fail.
	firstPending := true
	for i, s := range c.steps {
		g := stFaint.Render("○")
		switch s.status {
		case "ok":
			g = stGreen.Render("✓")
		case "fail":
			g = stRed.Render("✗")
		default:
			if firstPending {
				g = stAmber.Render("▸")
				firstPending = false
			}
		}
		tech := ""
		if s.technique != "" {
			tech = " " + stFaint.Render("["+s.technique+"]")
		}
		nameBudget := full - 10 - lipgloss.Width(tech)
		if nameBudget < 6 {
			nameBudget = 6
		}
		b.WriteString("   " + g + " " + stFaint.Render(fmt.Sprintf("%d.", i+1)) + " " +
			stInk.Render(truncateStr(s.name, nameBudget)) + tech + "\n")
	}

	if len(m.chainList) > 1 {
		b.WriteString(" " + stFaint.Render(fmt.Sprintf("+ %d earlier chain(s)", len(m.chainList)-1)))
	}
	return strings.TrimRight(b.String(), "\n")
}

// sevStyle maps a finding severity to its hero-palette style.
func sevStyle(sev pipeline.Severity) lipgloss.Style {
	switch sev {
	case pipeline.SeverityCritical:
		return findingCritical
	case pipeline.SeverityHigh:
		return findingHigh
	case pipeline.SeverityMedium:
		return findingMedium
	default:
		return findingLow
	}
}

// flashBanner is the short-lived attention strobe shown when a critical/high
// finding lands: a full-width filled bar that alternates between the severity
// color and gold each frame, naming the finding — so a 9.8 reads as an EVENT,
// not a quiet log line. Returns "" once the flash has decayed. Always exactly
// `full` cells wide and a single line (Inline + Width), so it never overflows.
func (m Model) flashBanner(full int) string {
	if m.flash <= 0 {
		return ""
	}
	label, base := "CRITICAL FINDING", hRed
	if m.flashSev == pipeline.SeverityHigh {
		label, base = "HIGH-SEVERITY FINDING", lipgloss.Color("#F97316")
	}
	title := ""
	if len(m.findings) > 0 {
		title = m.findings[len(m.findings)-1].Title
	}
	text := " ⚠  " + label + "  ▸  " + title
	text = truncateStr(text, full-3) // leave slack for wide glyphs before padding
	bg := base
	if m.frame%2 == 0 {
		bg = hCrest // strobe to gold on alternating frames
	}
	return lipgloss.NewStyle().Background(bg).Foreground(hVoid).Bold(true).
		Inline(true).MaxWidth(full).Width(full).Render(text)
}

// findingsReel is the live findings strip: the most recent findings, newest at
// the top, each a severity-colored dot + optional verified ✓ + title, with the
// freshest one briefly highlighted (a gold ▸ cursor) so new findings visibly
// slide in. Bounded to reelCap rows; every line truncated to `full`.
const reelCap = 3

func (m Model) findingsReel(full int) string {
	if len(m.findings) == 0 {
		return ""
	}
	var b strings.Builder
	b.WriteString(" " + stCyan.Render("FINDINGS") + stFaint.Render("  live") + "\n")
	start := 0
	if len(m.findings) > reelCap {
		start = len(m.findings) - reelCap
	}
	fresh := m.frame-m.lastFindingFrame < 3
	hot := lipgloss.NewStyle().Foreground(hCrest).Bold(true)
	// Newest first so a new finding appears at the top of the strip.
	for i := len(m.findings) - 1; i >= start; i-- {
		f := m.findings[i]
		newest := i == len(m.findings)-1
		cur := "   "
		if newest && fresh {
			cur = " " + hot.Render("▸") + " "
		}
		mark, markW := "", 0
		if f.Verified {
			mark, markW = stGreen.Render("✓ "), 2
		}
		budget := full - 6 - markW
		if budget < 8 {
			budget = 8
		}
		title := truncateStr(f.Title, budget)
		tr := stInk.Render(title)
		if newest && fresh {
			tr = hot.Render(title)
		}
		b.WriteString(cur + sevStyle(f.Severity).Render("●") + " " + mark + tr + "\n")
	}
	return strings.TrimRight(b.String(), "\n")
}

// attackPathLine draws the chain as a horizontal node-edge path that "draws" as
// steps land: ◉━━●──○──◆ where completed nodes are green, the running node
// pulses amber, failed go red, pending stay faint, and the final node is the
// objective (◆/◇). Edges fill solid (━, green) once the preceding step is ok.
// Stays within `width` (edge width shrinks to fit); returns "" if there's no
// room or no steps.
func attackPathLine(steps []chainStepView, frame, width int) string {
	n := len(steps)
	if n == 0 {
		return ""
	}
	budget := width - 11 // room for the "   path ▸ " prefix
	if budget < n {
		return "" // not even one cell per node — caller also width-gates
	}
	edgeW := 3
	for edgeW > 1 && n+(n-1)*edgeW > budget {
		edgeW--
	}
	if n+(n-1)*edgeW > budget {
		return ""
	}
	running := -1
	for i := range steps {
		if steps[i].status == "pending" {
			running = i
			break
		}
	}
	var b strings.Builder
	b.WriteString("   " + stFaint.Render("path ▸ "))
	for i, s := range steps {
		last := i == n-1
		var node string
		switch {
		case s.status == "ok":
			g := "●"
			if last {
				g = "◆"
			}
			node = stGreen.Render(g)
		case s.status == "fail":
			node = stRed.Render("✗")
		case i == running:
			g := "◉"
			if frame%2 == 1 {
				g = "●"
			}
			if last {
				g = "◆"
			}
			node = stAmber.Render(g)
		default:
			g := "○"
			if last {
				g = "◇"
			}
			node = stFaint.Render(g)
		}
		b.WriteString(node)
		if !last {
			if s.status == "ok" {
				b.WriteString(stGreen.Render(strings.Repeat("━", edgeW)))
			} else {
				b.WriteString(stFaint.Render(strings.Repeat("─", edgeW)))
			}
		}
	}
	return b.String()
}

// contentWidth is the real drawable width the head must fit into: the live
// terminal width (defaulted before the first WindowSizeMsg), with only a tiny
// floor so the layout stays coherent on a very small pane. Unlike dividerWidth
// it does NOT floor at 40 — head lines are truncated/gated to THIS width so
// nothing overflows a narrow window.
func (m Model) contentWidth() int {
	w := m.width
	if w <= 0 {
		w = 72 // sensible default before the terminal reports its size
	}
	if w < 16 {
		w = 16
	}
	return w
}

// contentHeight is the terminal height used for the head's vertical responsive
// gating. Before the first WindowSizeMsg (height 0) it assumes a normal-sized
// terminal rather than collapsing every rich section, so the initial frame —
// and headless renders in tests — look like a real run, not a tiny pane.
func (m Model) contentHeight() int {
	if m.height <= 0 {
		return 40
	}
	return m.height
}

// dividerWidth is the width for full-width rules and the two-column split:
// the live terminal width, floored so a tiny window doesn't collapse and
// defaulted before the first WindowSizeMsg arrives.
func (m Model) dividerWidth() int {
	w := m.width
	if w <= 0 {
		w = 72 // sensible default before the terminal reports its size
	}
	if w < 40 {
		w = 40
	}
	return w
}

// renderSwarm is the left panel: decentralized swarm imagery (a pheromone mesh
// of agent nodes with live status rows and pulse tails) plus a per-agent
// activity bar chart — replacing the old sequential stacked agent boxes.
func (m Model) renderSwarm(colW int) string {
	var b strings.Builder
	b.WriteString(stCyan.Render(" SWARM") + stFaint.Render("  decentralized agents") + "\n")
	b.WriteString(SwarmCluster(m.agents, m.agentPulse, m.frame, colW) + "\n\n")

	b.WriteString(stFaint.Render(" activity") + "\n")
	bars := []agentBar{
		{"recon", m.agents["recon"].Status, m.agentPulse["recon"], 12},
		{"classify", m.agents["classifier"].Status, m.agentPulse["classifier"], 12},
		{"exploit", m.agents["exploit"].Status, m.agentPulse["exploit"], 12},
		{"report", m.agents["report"].Status, m.agentPulse["report"], 12},
	}
	barW := colW - 16
	if barW < 8 {
		barW = 8
	}
	if barW > 24 {
		barW = 24
	}
	b.WriteString(agentActivityBars(bars, barW))
	return b.String()
}

// renderTelemetry is the charts panel: live sparklines (attack-surface growth,
// probe throughput, cumulative findings), a composite risk gauge and the spend
// meter. Every trace is sampled each tick so the panel animates continuously,
// showing motion during recon/exploit long before the first finding lands.
func (m Model) renderTelemetry(colW int) string {
	var b strings.Builder
	b.WriteString(stCyan.Render(" TELEMETRY") + stFaint.Render("  live traces") + "\n")

	sparkW := colW - 12
	if sparkW < 8 {
		sparkW = 8
	}
	if sparkW > 60 {
		sparkW = 60
	}
	row := func(label string, series []int, col lipgloss.Color) string {
		return "  " + stFaint.Render(padRight(label, 8)) + sparkline(series, sparkW, col)
	}
	b.WriteString(row("surface", m.surfaceSeries, hCyan) + "\n")
	b.WriteString(row("probes", m.probeSeries, hAmber) + "\n")
	b.WriteString(row("finds", m.findingsSeries, hRed) + "\n\n")

	// Meters carry a trailing label (score+band, or the dollar figure), so they
	// get a narrower bar than the sparklines to leave room for it without wrap.
	meterW := colW - 20
	if meterW < 6 {
		meterW = 6
	}
	if meterW > 30 {
		meterW = 30
	}
	score, band, col := m.riskScore()
	b.WriteString(stFaint.Render(" risk score") + "\n")
	b.WriteString(RiskMeter(score, band, col, meterW) + "\n\n")

	b.WriteString(stFaint.Render(" spend vs budget") + "\n")
	b.WriteString(SpendMeter(m.spend, m.BudgetUSD, meterW))
	return b.String()
}

func (m Model) renderFindings(colW int) string {
	var b strings.Builder
	b.WriteString(stCyan.Render(" Findings") + "\n")

	// Severity distribution — horizontal bar chart
	c := m.severityMap[pipeline.SeverityCritical]
	h := m.severityMap[pipeline.SeverityHigh]
	med := m.severityMap[pipeline.SeverityMedium]
	l := m.severityMap[pipeline.SeverityLow]
	b.WriteString(SeverityBars(c, h, med, l) + "\n\n")

	// Recent findings — as many as the column can show titles for.
	titleW := colW - 4
	if titleW < 16 {
		titleW = 16
	}
	start := 0
	if len(m.findings) > 6 {
		start = len(m.findings) - 6
	}
	fresh := m.frame-m.lastFindingFrame < 3
	for i := start; i < len(m.findings); i++ {
		f := m.findings[i]
		style := sevStyle(f.Severity)
		mark := ""
		tw := titleW
		if f.Verified {
			mark = findingLow.Render("✓ ") // green check — proven against the live target
			tw -= 2
		}
		// The freshest finding briefly pops with a gold cursor (slide-in feel).
		lead := " " + style.Render("●") + " "
		if i == len(m.findings)-1 && fresh {
			lead = lipgloss.NewStyle().Foreground(hCrest).Bold(true).Render("▸") + style.Render("●") + " "
			tw--
		}
		b.WriteString(lead + mark + truncateStr(f.Title, tw) + "\n")
	}

	if len(m.findings) == 0 {
		b.WriteString(dimStyle.Render(" No findings yet") + "\n")
	}

	return b.String()
}

// parseSpend pulls the first dollar figure out of a cost milestone detail such
// as "spent $0.123 so far (…)" or "total spent $1.20 (…)". Returns false when
// no "$<number>" is present.
func parseSpend(detail string) (float64, bool) {
	i := strings.IndexByte(detail, '$')
	if i < 0 {
		return 0, false
	}
	j := i + 1
	for j < len(detail) {
		c := detail[j]
		if (c >= '0' && c <= '9') || c == '.' {
			j++
			continue
		}
		break
	}
	num := detail[i+1 : j]
	if num == "" || num == "." {
		return 0, false
	}
	v, err := strconv.ParseFloat(num, 64)
	if err != nil {
		return 0, false
	}
	return v, true
}

// truncateStr shortens s to at most max runes (ellipsised), counting by rune
// so multibyte titles aren't cut mid-character.
func truncateStr(s string, max int) string {
	if max < 1 {
		max = 1
	}
	r := []rune(s)
	if len(r) <= max {
		return s
	}
	if max <= 3 {
		return string(r[:max])
	}
	return string(r[:max-3]) + "..."
}
