package tui

import (
	"cmp"
	"context"
	"errors"
	"fmt"
	"hash/fnv"
	"math"
	"slices"
	"strings"
	"time"
	"unicode/utf8"

	"albedo/cli/internal/daemon"

	"charm.land/bubbles/v2/textinput"
	tea "charm.land/bubbletea/v2"
	"github.com/charmbracelet/x/ansi"
)

// The orchestrator view: every agent in the active session's tree as a dot,
// drawn in terminal cells. Running agents jam on their token rate, and mail
// travels the edges as a packet of blocks. The daemon's agents stream feeds
// it; the selected agent's tail sits beside it, and what you type goes to
// that agent as you.

type ChatOpenAgentsMsg struct{}
type AgentsDoneMsg struct{}

// AgentsAttachMsg opens the agent's session in the chat screen.
type AgentsAttachMsg struct{ Session daemon.Session }

type agentsSnapshotMsg struct {
	Err   error
	Root  string
	Nodes []daemon.AgentNode
	Gen   int
}

type agentsEventsMsg struct {
	Events []daemon.AgentEvent
	Gen    int
}

type agentsStreamClosedMsg struct {
	Gen int
	Err error
}
type agentsSnapshotRetryMsg struct{ Gen int }

// agentsSeedMsg is an agent's recent history, so its tail starts from where
// it is rather than empty.
type agentsSeedMsg struct {
	Snapshot int
	ID       string
	Items    []daemon.PreviewItem
	Gen      int
}
type agentsSeedErrMsg struct {
	Snapshot int
	Err      error
	ID       string
	Gen      int
}

type agentsFrameMsg struct{ Gen int }
type agentsSentMsg struct {
	Err    error
	Action string
	Notice string
	Gen    int
}
type agentsReconnectMsg struct{ Gen int }

type agentsGenMsg interface{ gen() int }

func (m agentsSnapshotRetryMsg) gen() int { return m.Gen }
func (m agentsSnapshotMsg) gen() int      { return m.Gen }
func (m agentsSeedMsg) gen() int          { return m.Gen }
func (m agentsSeedErrMsg) gen() int       { return m.Gen }
func (m agentsEventsMsg) gen() int        { return m.Gen }
func (m agentsStreamClosedMsg) gen() int  { return m.Gen }
func (m agentsReconnectMsg) gen() int     { return m.Gen }
func (m agentsFrameMsg) gen() int         { return m.Gen }

type agentMail struct {
	who      string
	kind     string
	incoming bool
}

type agentNode struct {
	session                 daemon.Session
	id, parent, name, model string
	address                 string // how its family mails it; a rename leaves it
	call                    string // the tool call whose arguments are streaming
	mail                    []agentMail
	tail                    agentTail
	preview                 agentPreview
	revision                uint64
	hue                     rgb
	rate                    float64
	flash                   float64
	phase                   float64
	depth                   int
	chars                   int
	x, y                    int
	lineKind                tailKind
	running, closed         bool
	peer                    bool // reached by mail, outside the tree
	seeded                  bool
}

// What a tail line is, which decides how it is drawn.
type tailKind byte

const (
	tailText     tailKind = iota // the agent's reply, as it streams
	tailThinking                 // reasoning, faint
	tailCode                     // a tool call's code as the model writes it
	tailOutput                   // what a tool printed
	tailMeta                     // ▸ tool, ← letter, » progress, ✕ error
)

type tailLine struct {
	text string
	kind tailKind
}

type agentPacket struct {
	to     string
	edge   string
	path   [][2]int
	hue    rgb
	pos    float64
	blocks int
	tokens int
}

type agentFloat struct {
	text string
	hue  rgb
	x, y int
	t    float64
}

type AgentsViewModel struct {
	last             time.Time
	err              error
	heat             map[string]float64
	events           chan tea.Msg
	streamCtx        context.Context
	streamReady      bool
	snapshotInFlight bool
	snapshotDirty    bool
	snapshotRevision int
	cancel           context.CancelFunc
	Conn             *daemon.Connection
	nodes            map[string]*agentNode
	SessionID        string
	root             string
	selected         string

	notice    string
	confirm   string // the agent waiting for y to delete it
	packets   []agentPacket
	floats    []agentFloat
	order     []string
	input     textinput.Model
	rename    renameField
	tailCache agentTailCache
	Width     int
	clock     float64
	Height    int
	Gen       int
	viewGen   int // User operation outcomes survive stream replacement.
	noticeT   float64
	ticking   bool // a frame tick is in flight
}

const (
	agentsYou     = "you"
	agentsFrame   = 70 * time.Millisecond
	agentsSlotW   = 16
	agentsLevelGp = 2
)

var agentsBars = []rune("▁▂▃▄▅▆▇█")

func NewAgentsViewModel(conn *daemon.Connection, sessionID string) AgentsViewModel {
	input := newField()
	input.CharLimit = 4000
	input.Focus()
	return AgentsViewModel{
		Conn:      conn,
		SessionID: sessionID,
		nodes:     map[string]*agentNode{},
		heat:      map[string]float64{},
		selected:  sessionID,
		input:     input,
		last:      time.Now(),
	}
}

func (m *AgentsViewModel) SetSize(w, h int) {
	m.Width = w
	m.Height = h
	m.layout()
	m.refreshTail()
}

func (m *AgentsViewModel) Init() tea.Cmd {
	m.viewGen++
	m.Gen++
	m.ticking = true
	return tea.Batch(m.startStream(m.Gen), m.frameCmd(m.Gen), textinput.Blink)
}

// Close stops the stream and invalidates batches, completions, and timers
// already queued for this view.
func (m *AgentsViewModel) Close() {
	m.viewGen++
	m.stopStream()
}

func (m *AgentsViewModel) stopStream() {
	if m.cancel != nil {
		m.cancel()
		m.cancel = nil
	}
	m.Gen++
	m.streamReady = false
	m.snapshotInFlight, m.snapshotDirty = false, false
}

func (m AgentsViewModel) snapshotCmd(gen int) tea.Cmd {
	conn, id := m.Conn, m.SessionID
	return func() tea.Msg {
		if conn == nil {
			return agentsSnapshotMsg{Gen: gen, Err: errors.New("daemon connection unavailable")}
		}
		tree, err := daemon.GetAgents(m.streamCtx, conn, id)
		return agentsSnapshotMsg{Gen: gen, Root: tree.Root, Nodes: tree.Nodes, Err: err}
	}
}

func (m *AgentsViewModel) startStream(gen int) tea.Cmd {
	if m.Conn == nil {
		return nil
	}
	if m.cancel != nil {
		m.cancel()
	}
	ctx, cancel := context.WithCancel(context.Background())
	m.cancel = cancel
	m.streamCtx = ctx
	m.streamReady = false
	m.snapshotInFlight, m.snapshotDirty = false, false
	events := make(chan tea.Msg, 1)
	m.events = events
	conn := m.Conn
	go func() {
		defer close(events)
		err := daemon.StreamAgents(ctx, conn, func(batch []daemon.AgentEvent) error {
			// Leaving the view stops its consumer. Cancellation must release a
			// producer blocked on a full queue so it can close the HTTP body.
			select {
			case events <- agentsEventsMsg{Gen: gen, Events: batch}:
				return nil
			case <-ctx.Done():
				return ctx.Err()
			}
		})
		select {
		case events <- agentsStreamClosedMsg{Gen: gen, Err: err}:
		case <-ctx.Done():
		}
	}()
	return waitAgents(events, gen)
}

func (m *AgentsViewModel) restartStream() tea.Cmd {
	m.stopStream()
	m.snapshotRevision++
	for _, n := range m.nodes {
		n.preview = agentPreview{}
		n.tail = agentTail{}
		n.call = ""
		n.lineKind = tailText
		n.seeded = false
		n.revision++
	}
	m.packets, m.floats = nil, nil
	m.ticking = false
	return m.startStream(m.Gen)
}

func waitAgents(events <-chan tea.Msg, gen int) tea.Cmd {
	return func() tea.Msg {
		batch, ok := <-events
		if !ok {
			return agentsStreamClosedMsg{Gen: gen}
		}
		return batch
	}
}

func (m AgentsViewModel) frameCmd(gen int) tea.Cmd {
	return tea.Tick(agentsFrame, func(time.Time) tea.Msg { return agentsFrameMsg{Gen: gen} })
}

// Update handles msg, then starts the frame tick again if something began to
// move while it was stopped.
func (m AgentsViewModel) Update(msg tea.Msg) (AgentsViewModel, tea.Cmd) {
	m, cmd := m.update(msg)
	m.refreshTail()
	if m.ticking || !m.moving() {
		return m, cmd
	}
	m.ticking, m.last = true, time.Now()
	return m, tea.Batch(cmd, m.frameCmd(m.Gen))
}

func (m AgentsViewModel) update(msg tea.Msg) (AgentsViewModel, tea.Cmd) {
	if g, ok := msg.(agentsGenMsg); ok && g.gen() != m.Gen {
		return m, nil
	}
	switch msg := msg.(type) {
	case agentsSnapshotMsg:
		m.snapshotInFlight = false
		if msg.Err != nil {
			m.snapshotDirty = false
			m.err = msg.Err
			m.say("Could not refresh agents; retrying: " + msg.Err.Error())
			return m, tea.Tick(time.Second, func(time.Time) tea.Msg { return agentsSnapshotRetryMsg{Gen: m.Gen} })
		}
		if m.snapshotDirty {
			m.snapshotDirty = false
			m.snapshotInFlight = true
			return m, m.snapshotCmd(m.Gen)
		}
		m.err, m.root = nil, msg.Root
		m.snapshotRevision++
		previous := m.nodes
		m.nodes = make(map[string]*agentNode, len(msg.Nodes))
		for _, wire := range msg.Nodes {
			if n := previous[wire.Session.ID]; n != nil {
				m.nodes[wire.Session.ID] = n
			}
			n := m.node(wire.Session.ID, wire.Name)
			n.name = wire.Name
			n.parent, n.address = "", ""
			n.seeded = false
			n.session = wire.Session
			n.model = wire.Session.Model
			n.depth = wire.Depth
			n.running = wire.Running
			n.closed = wire.Closed
			n.peer = false
			if wire.Address != nil {
				n.address = *wire.Address
			}
			if wire.Parent != nil {
				n.parent = *wire.Parent
			}
		}
		if m.nodes[m.selected] == nil {
			m.selected = m.root
		}
		if m.nodes[m.confirm] == nil {
			m.confirm = ""
		}
		if m.nodes[m.rename.id] == nil {
			m.rename = renameField{}
		}
		m.layout()
		return m, m.seedCmd()

	case agentsSeedMsg:
		if msg.Snapshot != m.snapshotRevision {
			return m, nil
		}
		n := m.nodes[msg.ID]
		if n == nil {
			return m, nil
		}
		var seeded agentTail
		for _, item := range msg.Items {
			switch item.Type {
			case "user", "tool":
				prefix := "← "
				if item.Type == "tool" {
					prefix = "▸ "
				}
				seeded.push(tailLine{kind: tailMeta, text: prefix + firstLine(item.Preview)})
			default:
				for line := range strings.SplitSeq(item.Preview, "\n") {
					seeded.push(tailLine{kind: tailText, text: line})
				}
			}
		}
		existing := slices.Collect(n.tail.newest(false))
		for _, line := range slices.Backward(existing) {
			seeded.push(line)
		}
		seeded.omitted = seeded.omitted || n.tail.omitted
		n.tail = seeded
		n.revision++
		return m, nil

	case agentsSeedErrMsg:
		if msg.Snapshot != m.snapshotRevision {
			return m, nil
		}
		// The seed died; unseed its node so the next selection tries again.
		if n := m.nodes[msg.ID]; n != nil {
			n.seeded = false
			m.say("Could not load recent messages for " + m.label(msg.ID, "this agent") + ". Select it again to retry: " + msg.Err.Error())
		}
		return m, nil

	case agentsEventsMsg:
		if len(msg.Events) == 1 && msg.Events[0].Type == "overflow" {
			return m, m.restartStream()
		}
		var snapshot tea.Cmd
		if !m.streamReady {
			m.streamReady = true
			m.snapshotInFlight = true
			snapshot = m.snapshotCmd(m.Gen)
		}
		relayout := false
		for _, event := range msg.Events {
			if m.snapshotInFlight {
				switch event.Type {
				case "spawn", "gone", "running", "renamed", "closed":
					m.snapshotDirty = true
				}
			}
			relayout = m.apply(event) || relayout
		}
		if relayout {
			m.layout()
		}
		return m, tea.Batch(snapshot, waitAgents(m.events, m.Gen))

	case agentsStreamClosedMsg:
		if msg.Err != nil {
			m.err = msg.Err
			m.say("Agent stream failed; ctrl+l retries: " + msg.Err.Error())
			if failure, ok := errors.AsType[*daemon.StreamError](msg.Err); ok && failure.Kind != daemon.StreamTransient {
				m.stopStream()
				m.ticking = false
				return m, nil
			}
		}
		return m, tea.Tick(time.Second, func(time.Time) tea.Msg { return agentsReconnectMsg{Gen: m.Gen} })

	case agentsReconnectMsg:
		return m, m.restartStream()

	case agentsSnapshotRetryMsg:
		if !m.streamReady || m.snapshotInFlight {
			return m, nil
		}
		m.snapshotInFlight = true
		return m, m.snapshotCmd(m.Gen)

	case agentsFrameMsg:
		m.step()
		if m.ticking = m.moving(); !m.ticking {
			return m, nil
		}
		return m, m.frameCmd(m.Gen)

	case agentsSentMsg:
		if msg.Gen != m.viewGen {
			return m, nil
		}
		if msg.Err != nil {
			m.say(operationError(msg.Err, "Could not "+msg.Action+": ", "The request to "+msg.Action+" may have been accepted; check the agent before trying again."))
		} else if msg.Notice != "" {
			m.say(msg.Notice)
		}
		return m, nil

	case sessionRenamedMsg:
		switch {
		case msg.Err != nil:
			m.say(operationError(msg.Err, "Could not rename the session. Try again: ", "Session may have been renamed; refresh before trying again."))
		case msg.Name == "":
			m.say("name cleared")
		default:
			m.say("renamed to " + msg.Name)
		}
		return m, nil

	case tea.KeyPressMsg:
		return m.key(msg)
	}
	var cmd tea.Cmd
	m.input, cmd = m.input.Update(msg)
	return m, cmd
}

func (m AgentsViewModel) key(msg tea.KeyPressMsg) (AgentsViewModel, tea.Cmd) {
	s, empty := msg.String(), m.input.Value() == ""
	if m.rename.active() {
		return m, m.rename.key(msg)
	}
	if m.confirm != "" {
		id := m.confirm
		m.confirm = ""
		if s == "y" {
			m.say("deleting " + m.label(id, "") + "…")
			return m, m.deleteCmd(id)
		}
		m.say("kept")
		return m, nil
	}
	switch {
	case s == "ctrl+l":
		return m, m.restartStream()
	case s == "ctrl+r":
		if n := m.nodes[m.selected]; n != nil && n.id != agentsYou {
			m.rename.open(n.id, n.name, "name this agent")
		}
		return m, nil
	case s == "ctrl+x":
		switch n := m.nodes[m.selected]; {
		case n == nil || n.id == agentsYou:
		case n.id == m.SessionID:
			m.say("this is the session you opened the view from; delete it from the session browser")
		default:
			m.confirm = n.id
		}
		return m, nil
	case s == "esc" || s == "ctrl+c" || s == "ctrl+o":
		if !empty && s == "esc" {
			m.input.SetValue("")
			return m, nil
		}
		return m, func() tea.Msg { return AgentsDoneMsg{} }
	case s == "tab" || (empty && (s == "right" || s == "down")):
		m.cycle(1)
		return m, m.seedCmd()
	case s == "shift+tab" || (empty && (s == "left" || s == "up")):
		m.cycle(-1)
		return m, m.seedCmd()
	case s == "enter":
		n := m.nodes[m.selected]
		if n == nil {
			return m, nil
		}
		text := strings.TrimSpace(m.input.Value())
		if text == "" {
			if n.session.ID == "" {
				n.session = daemon.Session{ID: n.id, Title: n.name, Model: n.model}
			}
			sess := n.session
			return m, func() tea.Msg { return AgentsAttachMsg{Session: sess} }
		}
		m.input.SetValue("")
		if rest, ok := strings.CutPrefix(text, "/spawn "); ok {
			name, task, _ := strings.Cut(strings.TrimSpace(rest), " ")
			return m, m.spawnCmd(n.id, name, strings.TrimSpace(task))
		}
		return m, m.sendCmd(n.id, text)
	}
	var cmd tea.Cmd
	m.input, cmd = m.input.Update(msg)
	return m, cmd
}

// seedCmd loads the selected agent's recent history once; failures retry.
func (m *AgentsViewModel) seedCmd() tea.Cmd {
	n := m.nodes[m.selected]
	if !m.streamReady || m.snapshotInFlight || m.err != nil || n == nil || n.seeded || n.id == agentsYou || m.Conn == nil {
		return nil
	}
	n.seeded = true
	conn, gen, id := m.Conn, m.Gen, n.id
	snapshot, ctx := m.snapshotRevision, m.streamCtx
	if ctx == nil {
		ctx = context.Background()
	}
	return func() tea.Msg {
		preview, err := daemon.GetSessionPreview(ctx, conn, id, 10)
		if err != nil {
			return agentsSeedErrMsg{Gen: gen, Snapshot: snapshot, ID: id, Err: err}
		}
		return agentsSeedMsg{Gen: gen, ID: id, Snapshot: snapshot, Items: preview.Items}
	}
}

// below counts the agents under id.
func (m AgentsViewModel) below(id string) int {
	count := 0
	for _, n := range m.nodes {
		for p := n.parent; p != ""; {
			if p == id {
				count++
				break
			}
			if m.nodes[p] == nil {
				break
			}
			p = m.nodes[p].parent
		}
	}
	return count
}

func (m AgentsViewModel) deleteCmd(id string) tea.Cmd {
	conn, gen, name := m.Conn, m.viewGen, m.label(id, "")
	return func() tea.Msg {
		res, err := daemon.DeleteSession(context.Background(), conn, id, true)
		if err != nil {
			return agentsSentMsg{Gen: gen, Action: "delete " + name, Err: err}
		}
		return agentsSentMsg{Gen: gen, Notice: fmt.Sprintf("deleted %s (%d sessions)", name, res.Deleted)}
	}
}

func (m AgentsViewModel) sendCmd(id, text string) tea.Cmd {
	conn, gen, target := m.Conn, m.viewGen, m.label(id, "agent")
	return func() tea.Msg {
		_, err := daemon.Submit(context.Background(), conn, id, daemon.SubmissionRequest{Content: &text})
		return agentsSentMsg{Gen: gen, Action: "send a message to " + target, Err: err}
	}
}

func (m AgentsViewModel) spawnCmd(parent, name, task string) tea.Cmd {
	conn, gen := m.Conn, m.viewGen
	return func() tea.Msg {
		if name == "" || task == "" {
			return agentsSentMsg{Gen: gen, Action: "start an agent", Err: errors.New("use /spawn <name> <task>")}
		}
		_, err := daemon.CreateChild(context.Background(), conn, parent, daemon.ChildRequest{Name: name, Task: task})
		if err != nil {
			return agentsSentMsg{Gen: gen, Action: "start " + name, Err: err}
		}
		return agentsSentMsg{Gen: gen, Notice: "spawned " + name}
	}
}

// renameBlank is what an agent is called once its given name is cleared.
func renameBlank(n *agentNode) string {
	return cmp.Or(n.address, "its latest message's title")
}

func (m *AgentsViewModel) say(text string) {
	m.notice = text
	m.noticeT = 3
}

func (m *AgentsViewModel) cycle(d int) {
	if n := len(m.order); n > 0 {
		i := max(0, slices.Index(m.order, m.selected))
		m.selected = m.order[(i+d+n)%n]
	}
}

// node finds or makes the dot for a session.
func (m *AgentsViewModel) node(id, name string) *agentNode {
	if n, ok := m.nodes[id]; ok {
		if name != "" {
			n.name = name
		}
		return n
	}
	if name == "" {
		name = shortID(id)
	}
	n := &agentNode{id: id, name: name, hue: hueFor(id), phase: float64(hashOf(id)%628) / 100}
	m.nodes[id] = n
	return n
}

func shortID(id string) string {
	return id[:min(len(id), 8)]
}

func hashOf(s string) uint32 {
	h := fnv.New32a()
	_, _ = h.Write([]byte(s))
	return h.Sum32()
}

func hueFor(id string) rgb {
	hues := agentColors().hues
	return hues[hashOf(id)%uint32(len(hues))]
}

// apply folds one bus event into the view; true when the tree changed shape.
func (m *AgentsViewModel) apply(event daemon.AgentEvent) bool {
	kind := event.Type
	id := event.Session
	switch kind {
	case "spawn":
		parent := event.Parent
		if _, ok := m.nodes[parent]; !ok {
			return false
		}
		n := m.node(id, event.Name)
		n.parent, n.model, n.depth = parent, event.Model, event.Depth
		n.peer, n.flash = false, 1
		return true
	case "gone":
		if _, ok := m.nodes[id]; !ok {
			return false
		}
		delete(m.nodes, id)
		if m.rename.id == id {
			m.rename = renameField{}
		}
		if m.confirm == id {
			m.confirm = ""
		}
		if m.selected == id {
			m.selected = m.root
		}
		return true
	case "mail":
		from, to := event.From, event.To
		fromName := event.FromName
		changed := false
		ensure := func(nodeID, name string) {
			if _, ok := m.nodes[nodeID]; !ok {
				m.node(nodeID, name).peer = true
				changed = true
			}
		}
		ensure(to, "")
		source := agentsYou
		if from != "" {
			ensure(from, fromName)
			source = from
		}
		if changed {
			m.layout()
		}
		bytes, label := event.Bytes, event.Kind
		addMail := func(nodeID string, incoming bool, who string) {
			if n := m.nodes[nodeID]; n != nil {
				n.mail = capped(append(n.mail, agentMail{incoming: incoming, who: who, kind: label}), 12)
			}
		}
		addMail(to, true, m.label(source, fromName))
		addMail(source, false, m.label(to, ""))
		m.send(source, to, max(1, bytes/4))
		return false
	}
	n, ok := m.nodes[id]
	if !ok {
		return false
	}
	switch kind {
	case "running":
		n.running = event.Running
		if !n.running {
			m.flushLine(n)
		}
	case "text", "thinking":
		text := event.Text
		n.chars += utf8.RuneCountInString(text)
		n.rate += float64(len(text))
		tail := tailText
		if kind == "thinking" {
			tail = tailThinking
		}
		m.stream(n, tail, text)
	case "arguments_delta":
		text := event.Text
		n.chars += utf8.RuneCountInString(text)
		n.rate += float64(len(text))
		if call := event.CallID; call != n.call || n.lineKind != tailCode {
			m.flushLine(n)
			n.call = call
		}
		n.lineKind = tailCode
		if n.preview.appendArguments(text) {
			n.revision++
		}
	case "tool_progress":
		progress := event.Progress
		if progress != nil && progress.Phase == "running" {
			m.flushLine(n)
			m.pushTail(n, tailMeta, "▸ "+progress.Name)
		}
	case "tool":
		m.flushLine(n)
		for line := range strings.SplitSeq(event.Output, "\n") {
			if strings.TrimSpace(line) != "" {
				m.pushTail(n, tailOutput, line)
			}
		}
	case "user":
		text := event.Text
		m.flushLine(n)
		m.pushTail(n, tailMeta, "← "+firstLine(text))
		// Mail already travelled as a packet; a person typing is new.
		if event.Source == "chat" {
			m.send(agentsYou, id, max(1, len(text)/4))
		}
	case "message":
		// A root's answer goes back to you.
		if n.parent == "" && !n.peer {
			m.send(id, agentsYou, max(1, len(event.Text)/4))
		}
	case "error":
		m.pushTail(n, tailMeta, "✕ "+event.Text)
	case "interrupted":
		m.flushLine(n)
		m.pushTail(n, tailMeta, "· interrupted")
	case "progress":
		m.flushLine(n)
		m.pushTail(n, tailMeta, "» "+event.Text)
		n.flash = 0.6
	case "renamed":
		n.name = event.Name
	case "closed":
		n.closed, n.running = true, false
	}
	return false
}

func (m *AgentsViewModel) label(id, fallback string) string {
	if id == agentsYou {
		return agentsYou
	}
	if n := m.nodes[id]; n != nil {
		return n.name
	}
	return cmp.Or(fallback, shortID(id))
}

// capped keeps only the newest keep entries of a growing slice.
func capped[S ~[]E, E any](list S, keep int) S {
	if len(list) > keep {
		return list[len(list)-keep:]
	}
	return list
}

// stream appends streamed text of one kind, closing a line at each newline.
func (m *AgentsViewModel) stream(n *agentNode, kind tailKind, text string) {
	if n.lineKind != kind {
		m.flushLine(n)
		n.lineKind = kind
	}
	for {
		head, rest, found := strings.Cut(text, "\n")
		n.preview.lines.write(head, kind)
		n.revision++
		if !found {
			return
		}
		m.flushLine(n)
		n.lineKind = kind
		text = rest
	}
}

// flushLine settles whatever is streaming into the tail: a partial line, or
// the code of a finished tool call.
func (m *AgentsViewModel) flushLine(n *agentNode) {
	if n.lineKind == tailCode {
		n.preview.finish()
		lines := slices.Collect(n.preview.lines.newest(true))
		for _, line := range slices.Backward(lines) {
			m.pushTail(n, tailCode, line.text)
		}
	} else {
		for line := range n.preview.lines.newest(false) {
			if strings.TrimSpace(line.text) != "" {
				m.pushTail(n, n.lineKind, line.text)
			}
		}
	}
	n.tail.omitted = n.tail.omitted || n.preview.lines.omitted
	n.preview = agentPreview{}
	n.call = ""
	n.lineKind = tailText
	n.revision++
}

func (m *AgentsViewModel) pushTail(n *agentNode, kind tailKind, line string) {
	n.tail.push(tailLine{kind: kind, text: line})
	n.revision++
}

// ─── layout ───

func (m *AgentsViewModel) dagWidth() int {
	w := m.paneWidth()
	if w > 0 {
		return m.Width - w - 1
	}
	return m.Width
}

func (m *AgentsViewModel) paneWidth() int {
	if m.Width < 96 {
		return 0
	}
	return min(46, max(34, m.Width/3))
}

// layout places dots by depth, each depth wrapped into rows that fit the
// pane, parents before their children so a family stays together.
func (m *AgentsViewModel) layout() {
	// The app sizes every screen, including this one before it first opens.
	if m.nodes == nil {
		return
	}
	you := m.node(agentsYou, agentsYou)
	you.x, you.y = 2, 2
	children := map[string][]string{}
	var roots, peers []string
	for id, n := range m.nodes {
		switch {
		case id == agentsYou:
		case n.peer:
			peers = append(peers, id)
		case n.parent == "" || m.nodes[n.parent] == nil:
			roots = append(roots, id)
		default:
			children[n.parent] = append(children[n.parent], id)
		}
	}
	for _, list := range children {
		slices.Sort(list)
	}
	slices.Sort(roots)
	slices.Sort(peers)
	levels := [][]string{append(roots, peers...)}
	for {
		var next []string
		for _, id := range levels[len(levels)-1] {
			next = append(next, children[id]...)
		}
		if len(next) == 0 {
			break
		}
		levels = append(levels, next)
	}
	width := max(agentsSlotW, m.dagWidth()-4)
	perRow := max(1, width/agentsSlotW)
	y := 5
	m.order = m.order[:0]
	for _, level := range levels {
		for start := 0; start < len(level); start += perRow {
			row := level[start:min(len(level), start+perRow)]
			used := len(row) * agentsSlotW
			x := 2 + max(0, (width-used)/2)
			for i, id := range row {
				n := m.nodes[id]
				n.x, n.y = x+i*agentsSlotW, y
				m.order = append(m.order, id)
			}
			y += 3
		}
		y += agentsLevelGp
	}
	if m.nodes[m.selected] == nil && len(m.order) > 0 {
		m.selected = m.order[0]
	}
}

// route runs from a to b: down from a, across the row above b, into b. A
// route upward is the same route reversed.
func route(a, b *agentNode) [][2]int {
	if b.y < a.y {
		cells := route(b, a)
		slices.Reverse(cells)
		return cells
	}
	var cells [][2]int
	channel := b.y - 1
	if b.y == a.y {
		channel = a.y - 1
		cells = append(cells, [2]int{a.x, a.y - 1})
	}
	for r := a.y + 1; r <= channel; r++ {
		cells = append(cells, [2]int{a.x, r})
	}
	step := 1
	if b.x < a.x {
		step = -1
	}
	for c := a.x + step; c != b.x+step; c += step {
		cells = append(cells, [2]int{c, channel})
	}
	return cells
}

func edgeKey(a, b string) string { return a + ">" + b }

func (m *AgentsViewModel) send(from, to string, tokens int) {
	a, b := m.nodes[from], m.nodes[to]
	if a == nil || b == nil || from == to {
		return
	}
	path := route(a, b)
	if len(path) == 0 {
		return
	}
	blocks := max(1, min(5, int(math.Round(math.Log2(float64(tokens)/200+1)))))
	var key string
	if a.parent == to {
		key = edgeKey(to, from)
	} else {
		key = edgeKey(from, to)
	}
	m.packets = append(m.packets, agentPacket{path: path, pos: -float64(blocks), blocks: blocks, hue: a.hue, to: to, tokens: tokens, edge: key})
	m.heat[key] = 1
}

// step advances one frame of animation.
func (m *AgentsViewModel) step() {
	now := time.Now()
	dt := math.Min(0.2, now.Sub(m.last).Seconds())
	m.last = now
	m.clock += dt
	m.noticeT = math.Max(0, m.noticeT-dt)
	live := m.packets[:0]
	for _, p := range m.packets {
		p.pos += 30 * dt
		m.heat[p.edge] = 1
		if p.pos-float64(p.blocks) >= float64(len(p.path)-1) {
			if n := m.nodes[p.to]; n != nil {
				n.flash = 1
				m.floats = append(m.floats, agentFloat{x: n.x + 2, y: n.y - 1, text: "▴" + compactCount(p.tokens), hue: p.hue})
			}
			continue
		}
		live = append(live, p)
	}
	m.packets = live
	for key, h := range m.heat {
		if slices.ContainsFunc(m.packets, func(p agentPacket) bool { return p.edge == key }) {
			continue
		}
		h -= dt * 0.8
		if h <= 0 {
			delete(m.heat, key)
			continue
		}
		m.heat[key] = h
	}
	floats := m.floats[:0]
	for _, f := range m.floats {
		f.t += dt
		if f.t < 1.4 {
			floats = append(floats, f)
		}
	}
	m.floats = floats
	for _, n := range m.nodes {
		n.flash = math.Max(0, n.flash-dt*2.5)
		n.rate *= math.Exp(-dt * 1.5)
	}
}

// moving is whether the next frame can differ from this one: running agents
// pulse, and packets, heat, floats, flashes, and notices play out.
func (m AgentsViewModel) moving() bool {
	if len(m.packets) > 0 || len(m.heat) > 0 || len(m.floats) > 0 || m.noticeT > 0 {
		return true
	}
	for _, n := range m.nodes {
		if n.running || n.flash > 0 {
			return true
		}
	}
	return false
}

func compactCount(n int) string {
	if n >= 1000 {
		return fmt.Sprintf("%.1fk", float64(n)/1000)
	}
	return fmt.Sprintf("%d", n)
}

// ─── drawing ───

type agentCell struct {
	hue *rgb
	r   rune
}

type agentCanvas struct {
	cells [][]agentCell
	w, h  int
}

func newCanvas(w, h int) *agentCanvas {
	c := &agentCanvas{w: w, h: h, cells: make([][]agentCell, h)}
	for y := range c.cells {
		c.cells[y] = make([]agentCell, w)
		for x := range c.cells[y] {
			c.cells[y][x] = agentCell{r: ' '}
		}
	}
	return c
}

func (c *agentCanvas) put(x, y int, s string, hue rgb) {
	h := hue
	for _, r := range s {
		if x >= 0 && x < c.w && y >= 0 && y < c.h {
			c.cells[y][x] = agentCell{r: r, hue: &h}
		}
		x++
	}
}

var sgrCache = map[string]string{}

// sgrCached memoizes the escape sequence for one hex color.
func sgrCached(hex string) string {
	seq, ok := sgrCache[hex]
	if !ok {
		seq = sgr(hex, "")
		sgrCache[hex] = seq
	}
	return seq
}

func (c *agentCanvas) line(y int) string {
	var b strings.Builder
	current := ""
	for _, cell := range c.cells[y] {
		seq := ""
		if cell.hue != nil && cell.r != ' ' {
			seq = sgrCached(cell.hue.hex())
		}
		if seq != current {
			if current != "" {
				b.WriteString(ansiReset)
			}
			b.WriteString(seq)
			current = seq
		}
		b.WriteRune(cell.r)
	}
	if current != "" {
		b.WriteString(ansiReset)
	}
	return b.String()
}

var agentGlyphs = map[int]rune{5: '│', 10: '─', 6: '╭', 12: '╮', 3: '╰', 9: '╯', 7: '├', 13: '┤', 14: '┬', 11: '┴', 15: '┼', 1: '│', 4: '│', 2: '─', 8: '─'}

func (m *AgentsViewModel) drawEdges(c *agentCanvas) {
	type mark struct {
		mask   int
		heat   float64
		hue    rgb
		dotted bool
	}
	marks := map[[2]int]*mark{}
	// dir is one bit per compass direction; a cell's mask names the arms meeting in it.
	dir := func(dx, dy int) int {
		switch {
		case dy < 0:
			return 1
		case dy > 0:
			return 4
		case dx > 0:
			return 2
		default:
			return 8
		}
	}
	draw := func(from, to *agentNode, heat float64, dotted bool) {
		cells := append([][2]int{{from.x, from.y}}, route(from, to)...)
		for i := 1; i < len(cells); i++ {
			cell, prev := cells[i], cells[i-1]
			mk := marks[cell]
			if mk == nil {
				mk = &mark{hue: from.hue}
				marks[cell] = mk
			}
			next := [2]int{to.x, to.y}
			if i+1 < len(cells) {
				next = cells[i+1]
			}
			mk.mask |= dir(prev[0]-cell[0], prev[1]-cell[1]) | dir(next[0]-cell[0], next[1]-cell[1])
			if heat >= mk.heat {
				mk.heat, mk.hue = heat, from.hue
			}
			mk.dotted = mk.dotted || dotted
		}
	}
	crowded := len(m.nodes) > 24
	for _, n := range m.nodes {
		parent := m.nodes[n.parent]
		if parent == nil || n.peer {
			continue
		}
		heat := m.heat[edgeKey(n.parent, n.id)]
		if crowded && heat == 0 {
			continue
		}
		draw(parent, n, heat, false)
	}
	if root := m.nodes[m.root]; root != nil {
		draw(m.nodes[agentsYou], root, m.heat[edgeKey(agentsYou, m.root)]+m.heat[edgeKey(m.root, agentsYou)], false)
	}
	for key, heat := range m.heat {
		from, to, _ := strings.Cut(key, ">")
		a, b := m.nodes[from], m.nodes[to]
		if a == nil || b == nil || b.parent == from || (from == agentsYou && to == m.root) || (to == agentsYou && from == m.root) {
			continue
		}
		draw(a, b, heat, true)
	}
	decor := agentColors().decor
	for cell, mk := range marks {
		g := agentGlyphs[mk.mask]
		if g == 0 {
			g = '·'
		}
		if mk.dotted {
			switch {
			case mk.mask&5 != 0 && mk.mask&10 == 0:
				g = '┆'
			case mk.mask&10 != 0 && mk.mask&5 == 0:
				g = '┄'
			}
		}
		c.put(cell[0], cell[1], string(g), decor.mix(mk.hue, math.Min(1, mk.heat*1.1)))
	}
}

func (m *AgentsViewModel) drawNodes(c *agentCanvas) {
	t, colors := m.clock, agentColors()
	for id, n := range m.nodes {
		glyph := "●"
		var hue rgb
		switch {
		case id == agentsYou:
			glyph, hue = "◆", colors.you
		case n.running:
			p := 0.5 + 0.5*math.Sin(t*5+n.phase)
			if p > 0.55 {
				glyph = "◉"
			}
			hue = n.hue.mix(colors.faint, 0.2).mix(n.hue, p)
		case n.closed:
			glyph, hue = "✓", n.hue.mix(colors.faint, 0.5)
		default:
			glyph = "○"
			if n.peer {
				glyph = "◇"
			}
			hue = n.hue.mix(colors.faint, 0.3)
		}
		if n.flash > 0 {
			hue = hue.mix(colors.hi, n.flash*0.7)
		}
		c.put(n.x, n.y, glyph, hue)
		name := n.name
		if w := agentsSlotW - 4; utf8.RuneCountInString(name) > w {
			name = string([]rune(name)[:w-1]) + "…"
		}
		label := n.hue.mix(colors.hi, 0.35)
		if id == m.selected {
			label = colors.hi
			c.put(n.x-1, n.y, "[", n.hue)
			c.put(n.x+2+utf8.RuneCountInString(name), n.y, "]", n.hue)
		}
		c.put(n.x+2, n.y, name, label)
		if id == agentsYou {
			continue
		}
		// The jam: five bars riding the token rate.
		var bars strings.Builder
		level := math.Min(1, n.rate/400)
		for i := range 5 {
			v := 0.0
			if n.running {
				v = 0.15 + level*(0.5+0.35*math.Sin(t*(6+float64(i)*1.7)+n.phase*float64(i+1))) + 0.15*math.Sin(t*2.3+float64(i))
			}
			bars.WriteRune(agentsBars[max(0, min(7, int(math.Round(v*7))))])
		}
		barHue := colors.decor
		if n.running {
			barHue = n.hue
		}
		c.put(n.x+2, n.y+1, bars.String(), barHue)
		if n.chars > 0 {
			c.put(n.x+8, n.y+1, compactCount(n.chars/4), colors.faint)
		}
	}
	for _, p := range m.packets {
		head := int(math.Floor(p.pos))
		for i := range p.blocks {
			idx := head - i
			if idx < 0 || idx >= len(p.path) {
				continue
			}
			glyph, hue := "▪", p.hue
			if i == 0 {
				glyph, hue = "■", p.hue.mix(colors.hi, 0.35)
			}
			c.put(p.path[idx][0], p.path[idx][1], glyph, hue)
		}
	}
	for _, f := range m.floats {
		rise := min(2, int(f.t*2.2))
		c.put(f.x, f.y-rise, f.text, f.hue.mix(colors.decor, math.Min(1, f.t/1.4)))
	}
}

func (m AgentsViewModel) paneHeader() []string {
	// One column goes to the space after the divider.
	width := m.paneWidth() - 1
	rows := make([]string, 0, 12)
	n := m.nodes[m.selected]
	if n == nil || width <= 0 {
		return rows
	}
	colors := agentColors()
	styled := func(hue rgb, s string) string { return sgr(hue.hex(), "") + s + ansiReset }
	state := "idle"
	switch {
	case n.id == agentsYou:
		state = "that's you"
	case n.running:
		state = "running"
	case n.closed:
		state = "closed"
	case n.peer:
		state = "outside this tree"
	}
	rows = append(rows, styled(n.hue, "● ")+DefaultStyles.Bold.Render(n.name)+"  "+DefaultStyles.Faint.Render(state))
	var meta []string
	if n.model != "" {
		meta = append(meta, n.model)
	}
	if n.id != agentsYou {
		meta = append(meta, fmt.Sprintf("depth %d", n.depth))
	}
	if parent := m.nodes[n.parent]; parent != nil {
		meta = append(meta, "parent "+parent.name)
	}
	rows = append(rows, DefaultStyles.Faint.Render(ansi.Truncate(strings.Join(meta, " · "), width, "…")), "")
	if len(n.mail) > 0 {
		rows = append(rows, DefaultStyles.Muted.Render("mail"))
		for _, mail := range n.mail[max(0, len(n.mail)-5):] {
			var arrow string
			if mail.incoming {
				arrow = styled(colors.mail, "← ")
			} else {
				arrow = DefaultStyles.Faint.Render("→ ")
			}
			rows = append(rows, ansi.Truncate(arrow+mail.who+" "+DefaultStyles.Faint.Render(mail.kind), width, "…"))
		}
		rows = append(rows, "")
	}
	live := DefaultStyles.Muted.Render("tail")
	if n.running {
		live += " " + styled(n.hue, "●") + DefaultStyles.Faint.Render(" live")
	}
	if n.tail.omitted || n.preview.lines.omitted {
		live += DefaultStyles.Faint.Render(" · earlier output omitted")
	}
	return append(rows, live)
}

func (m AgentsViewModel) pane(height int) []string {
	rows := m.paneHeader()
	room := max(0, height-len(rows))
	if room == 0 || len(rows) == 0 {
		return rows
	}
	wrapped := m.tailCache.rows
	if len(wrapped) == 0 {
		return append(rows, DefaultStyles.Faint.Render("nothing yet"))
	}
	return append(rows, wrapped[max(0, len(wrapped)-room):]...)
}

// drawTail wraps one tail line to width and styles it by kind.
func drawTail(line tailLine, width, limit int) []string {
	gutter, inner := "", width
	format := func(s ...string) string { return strings.Join(s, "") }
	switch line.kind {
	case tailThinking:
		format = func(s ...string) string { return DefaultStyles.Faint.Render(ansiItalic + s[0]) }
	case tailCode:
		gutter, inner, format = DefaultStyles.Decor.Render("│ "), width-2, DefaultStyles.Muted.Render
	case tailOutput:
		gutter, inner, format = DefaultStyles.Decor.Render("⎿ "), width-2, DefaultStyles.Faint.Render
	case tailMeta:
		format = DefaultStyles.Faint.Render
	}
	wrapped := ansi.Hardwrap(line.text, max(8, inner), true)
	start := len(wrapped)
	for range limit {
		index := strings.LastIndexByte(wrapped[:start], '\n')
		if index < 0 {
			start = 0
			break
		}
		start = index
	}
	if start > 0 {
		start++
	}
	var out []string
	for part := range strings.SplitSeq(wrapped[start:], "\n") {
		// A cached row must not retain the rest of a long wrapped line.
		out = append(out, gutter+format(strings.Clone(part)))
	}
	return out
}

func (m AgentsViewModel) View() string {
	if m.Width <= 0 || m.Height <= 0 {
		return ""
	}
	live, tokens := 0, 0
	for id, n := range m.nodes {
		if id == agentsYou {
			continue
		}
		if n.running {
			live++
		}
		tokens += n.chars / 4
	}
	right := DefaultStyles.Faint.Render(fmt.Sprintf("%d agents · %d running · ▴%s tok", len(m.nodes)-1, live, compactCount(tokens)))
	out := []string{titleRule(m.Width, brand("albedo")+" "+DefaultStyles.Muted.Render("/agents"), right)}

	var confirmRows []string
	if m.confirm != "" {
		what := m.label(m.confirm, "")
		if below := m.below(m.confirm); below == 1 {
			what += " and the agent below it"
		} else if below > 1 {
			what += fmt.Sprintf(" and the %d agents below it", below)
		}
		for line := range strings.SplitSeq(ansi.Wrap("Their transcripts and work will also be deleted. Delete "+what+"?", max(1, m.Width), " "), "\n") {
			confirmRows = append(confirmRows, DefaultStyles.Warning.Render(line))
		}
		confirmRows = append(confirmRows, keyHints(hint{"y", "delete"}, hint{"any key", "keep"}))
	}
	bodyH := max(1, m.Height-3-max(1, len(confirmRows)))
	dagW := m.dagWidth()
	// Scroll so the selected dot stays in view on a tall swarm.
	offset := 0
	if n := m.nodes[m.selected]; n != nil && n.y+3 > bodyH {
		offset = n.y + 3 - bodyH
	}
	canvas := newCanvas(dagW, bodyH+offset)
	m.drawEdges(canvas)
	m.drawNodes(canvas)
	pane := m.pane(bodyH)
	divider := DefaultStyles.Decor.Render("│")
	pw := m.paneWidth()
	for y := range bodyH {
		row := canvas.line(y + offset)
		if pw > 0 {
			side := ""
			if y < len(pane) {
				side = pane[y]
			}
			row += divider + " " + ansi.Truncate(side, pw-1, "…")
		}
		out = append(out, row)
	}

	status := ""
	switch {
	case m.rename.active():
		status = DefaultStyles.Muted.Render("rename ") + DefaultStyles.Bold.Render(m.label(m.rename.id, ""))
		if n := m.nodes[m.rename.id]; n != nil && n.address != "" {
			status += DefaultStyles.Faint.Render(" · its family can still mail it at ") + DefaultStyles.Muted.Render(n.address)
		}
	case m.err != nil:
		status = DefaultStyles.Error.Render(m.err.Error() + " · ctrl+l retries")
	case m.noticeT > 0:
		status = DefaultStyles.Muted.Render(m.notice)
	}
	if len(confirmRows) > 0 {
		out = append(out, confirmRows...)
	} else {
		out = append(out, status)
	}
	target := m.label(m.selected, "agent")
	input := m.input.View()
	if m.input.Value() == "" {
		input = DefaultStyles.Faint.Render("message " + target + "…  or /spawn <name> <task>")
	}
	if n := m.nodes[m.rename.id]; n != nil && m.rename.active() {
		out = append(out, DefaultStyles.Prompt.Render("✎ ")+m.rename.view(m.Width-promptMarkWidth), renameHints("restores "+renameBlank(n)))
	} else {
		out = append(out, promptLead()+input, keyHints(hint{"tab", "next agent"}, hint{"enter", "open or send"}, hint{"ctrl+r", "rename"}, hint{"ctrl+x", "delete"}, hint{"esc", "back"}))
	}
	return strings.Join(out, "\n")
}
