package agent

import (
	"fmt"
	"io"
	"strings"

	"reasonix/internal/event"
	"reasonix/internal/provider"
)

// TextSink renders a turn's event stream to ANSI text on an io.Writer. It is
// the reference terminal frontend: a headless `reasonix run` writes to stdout,
// and during the cache-first migration the chat TUI is fed through it too. The
// output is byte-for-byte what the agent used to print directly, now driven by
// typed events instead of inline Fprint calls.
//
// renderer, when non-nil, replaces the streamed raw answer text with styled
// markdown once the text stream completes (a Message event). termWidth is the
// column count used to count how many rows the raw stream occupied before the
// redraw moves the cursor back. A nil renderer keeps the raw stream — correct
// for piped output and for the chat TUI, which renders markdown itself.
type TextSink struct {
	out       io.Writer
	renderer  Renderer
	termWidth int

	// Per-stream state, reset on Message / TurnStarted.
	wroteReasoningHeader bool
	textWritten          bool
	// Per-turn state, reset on TurnStarted. Tracks whether anything has been
	// written this turn so a coordinator Phase marker leads with a blank line
	// only when it follows earlier output.
	wroteAnything bool
}

// NewTextSink builds a TextSink writing to out. renderer/termWidth drive the
// post-stream markdown redraw; pass a nil renderer to keep the raw stream.
func NewTextSink(out io.Writer, renderer Renderer, termWidth int) *TextSink {
	return &TextSink{out: out, renderer: renderer, termWidth: termWidth}
}

// Emit renders one event. Called serially by the run loop.
func (s *TextSink) Emit(e event.Event) {
	switch e.Kind {
	case event.TurnStarted:
		s.wroteReasoningHeader = false
		s.textWritten = false
		s.wroteAnything = false

	case event.Reasoning:
		if !s.wroteReasoningHeader {
			fmt.Fprintln(s.out, dimText("  ▎ thinking"))
			s.wroteReasoningHeader = true
		}
		fmt.Fprint(s.out, dimText(e.Text))
		s.wroteAnything = true

	case event.Text:
		if s.wroteReasoningHeader && !s.textWritten {
			fmt.Fprintln(s.out) // separate the reasoning block from the answer
		}
		fmt.Fprint(s.out, e.Text)
		s.textWritten = true
		s.wroteAnything = true

	case event.Message:
		s.closeTextStream(e.Text, e.Reasoning)

	case event.ToolDispatch:
		fmt.Fprintf(s.out, "  -> %s %s\n", e.Tool.Name, CompactArgs(e.Tool.Args))
		s.wroteAnything = true

	case event.ToolResult:
		// A successful result is silent (it only feeds the model); a blocked
		// call surfaces the same "⊘ name <reason>" line the agent used to print.
		if e.Tool.Err != "" {
			fmt.Fprintf(s.out, "  ⊘ %s %s\n", e.Tool.Name, e.Tool.Err)
			s.wroteAnything = true
		}

	case event.Usage:
		// Close a still-open raw text block (the planner path streams text with
		// no Message redraw) before the usage line, matching the old Fprintln.
		if s.textWritten {
			fmt.Fprintln(s.out)
			s.textWritten = false
		}
		s.usageLine(e.Usage, e.Pricing)

	case event.Notice:
		glyph := "·"
		if e.Level == event.LevelWarn {
			glyph = "!"
		}
		fmt.Fprintf(s.out, "  %s %s\n", glyph, e.Text)
		s.wroteAnything = true

	case event.Phase:
		if s.wroteAnything {
			fmt.Fprintln(s.out)
		}
		fmt.Fprintf(s.out, "[%s]\n", e.Text)
		s.wroteAnything = true

	case event.CompactionStarted:
		fmt.Fprintln(s.out, dimText("  ⋯ compacting conversation…"))
		s.wroteAnything = true

	case event.CompactionDone:
		c := e.Compaction
		if c.Summary == "" {
			break // aborted pass — the caller's Notice already explained why
		}
		fmt.Fprintln(s.out, dimText(fmt.Sprintf("  ⋯ compacted %d messages (%s)", c.Messages, c.Trigger)))
		for _, ln := range strings.Split(strings.TrimRight(c.Summary, "\n"), "\n") {
			fmt.Fprintln(s.out, dimText("    "+ln))
		}
		s.wroteAnything = true
	}
}

// closeTextStream ends the streamed answer. With a renderer wired in and the
// stream short enough to scroll back over, it moves the cursor to where text
// began, clears to end of screen, and re-emits the styled markdown; otherwise
// it just terminates the block with a newline. Reasoning above the text is left
// untouched. Mirrors the old Agent.stream tail exactly.
func (s *TextSink) closeTextStream(text, reasoning string) {
	defer func() {
		s.wroteReasoningHeader = false
		s.textWritten = false
	}()
	if len(text) > 0 {
		s.wroteAnything = true
	}
	if len(text) > 0 && s.renderer != nil {
		if moved := streamedRows(text, s.termWidth); moved < 200 {
			if moved == 0 {
				fmt.Fprint(s.out, "\r\033[0J")
			} else {
				fmt.Fprintf(s.out, "\r\033[%dA\033[0J", moved)
			}
			fmt.Fprint(s.out, s.renderer.Render(text))
			return
		}
	}
	if len(text) > 0 || len(reasoning) > 0 {
		fmt.Fprintln(s.out)
	}
}

// usageLine writes the one-line token/cache summary; no-op when usage is unset.
func (s *TextSink) usageLine(u *provider.Usage, p *provider.Pricing) {
	if line := FormatUsageLine(u, p); line != "" {
		fmt.Fprintln(s.out, line)
		s.wroteAnything = true
	}
}

// FormatUsageLine renders the per-turn token/cache summary — the key signal for
// the cache-first design — as a single line (no trailing newline), or "" when
// usage is unset or empty. Cache is reported as absolute "(N cached / M new)"
// so a turn that adds a lot of fresh content doesn't read as "cache broke" the
// way a falling percentage would; the cached prefix is still hitting, the
// denominator just grew. Reasoning tokens (a subset of completion) show the
// chain-of-thought cost. Shared by TextSink and the chat TUI so both frontends
// render the line identically.
func FormatUsageLine(u *provider.Usage, p *provider.Pricing) string {
	if u == nil || u.TotalTokens == 0 {
		return ""
	}
	cacheCol := ""
	if u.PromptTokens > 0 {
		cached := u.CacheHitTokens
		fresh := u.CacheMissTokens
		if fresh == 0 {
			if d := u.PromptTokens - cached; d > 0 {
				fresh = d
			}
		}
		cacheCol = fmt.Sprintf(" (%d cached / %d new)", cached, fresh)
	}
	reasoning := ""
	if u.ReasoningTokens > 0 {
		reasoning = fmt.Sprintf(" (%d reasoning)", u.ReasoningTokens)
	}
	cost := ""
	if p != nil {
		cost = fmt.Sprintf(" · %s%.4f", p.Symbol(), p.Cost(u))
	}
	return fmt.Sprintf("  · %d tok · in %d%s · out %d%s%s",
		u.TotalTokens, u.PromptTokens, cacheCol, u.CompletionTokens, reasoning, cost)
}

// dimText wraps s in the ANSI dim SGR sequence so reasoning streams visually
// recede from the final answer.
func dimText(s string) string { return "\x1b[2m" + s + "\x1b[0m" }

// CompactArgs trims and caps a tool's raw JSON arguments for the dispatch line.
// Exported so the CLI can reuse the same rendering without duplicating the logic.
func CompactArgs(s string) string {
	s = strings.TrimSpace(s)
	r := []rune(s)
	if len(r) > 120 {
		return string(r[:120]) + "..."
	}
	return s
}
