package tui

import (
	"cmp"
	"fmt"
	"path"
	"regexp"
	"slices"
	"strings"

	"albedo/cli/internal/daemon"
	"github.com/charmbracelet/x/ansi"
)

// A burst is the work between two pieces of prose: a run of compact entries,
// tool calls and thinking spells, drawn as one summary row. It renders once
// something else ends it; until then it is drawn live, so settled rows stay
// append-only.

// trailingBurst is the run of compact entries that ends entries.
func trailingBurst(entries []HistoryEntry, flags DisplayFlags) []HistoryEntry {
	i := len(entries)
	for i > 0 && Compact(entries[i-1], flags) {
		i--
	}
	return entries[i:]
}

// burstNamed is the span of entries holding the burst that key names, or
// false when none is retained.
func burstNamed(entries []HistoryEntry, key string, flags DisplayFlags) (start, end int, ok bool) {
	for i, entry := range entries {
		if !Compact(entry, flags) || i > 0 && Compact(entries[i-1], flags) || factsOf(entry).key != key {
			continue
		}
		end = i + 1
		for end < len(entries) && Compact(entries[end], flags) {
			end++
		}
		return i, end, true
	}
	return 0, 0, false
}

// tally keeps labels in the order they first came, with how often each did.
type tally struct {
	counts map[string]int
	keys   []string
}

func (t *tally) add(key string) {
	if key == "" {
		return
	}
	if t.counts == nil {
		t.counts = map[string]int{}
	}
	if t.counts[key] == 0 {
		t.keys = append(t.keys, key)
	}
	t.counts[key]++
}

// dedup keeps first order and drops repeats naming produced.
func dedup(keys []string) []string {
	seen := make(map[string]bool, len(keys))
	kept := keys[:0:0]
	for _, key := range keys {
		if !seen[key] {
			seen[key] = true
			kept = append(kept, key)
		}
	}
	return kept
}

func (t *tally) addAll(keys []string) {
	for _, key := range keys {
		t.add(key)
	}
}

// counted labels each key, marking repeats: `go test ×4`.
func (t tally) counted() []string {
	labels := make([]string, len(t.keys))
	for i, key := range t.keys {
		labels[i] = key
		if n := t.counts[key]; n > 1 {
			labels[i] += fmt.Sprintf(" ×%d", n)
		}
	}
	return labels
}

// entryFacts is what one entry contributes to a burst, computed once when the
// entry settles. Targets stay as the trace recorded them; naming them for the
// workspace happens at render time, so a workspace change re-names old rows.
type entryFacts struct {
	// key is the entry's entryKey, which names the burst it starts
	key       string
	read      []string
	edited    []string
	searched  []string
	ran       []string
	py        []string
	used      []string
	changes   []daemon.FileChange
	thoughtMs int64
	added     int
	removed   int
	untimed   bool
	failed    bool
}

// factsOf reads one entry's contribution, using what append already computed
// when it can.
func factsOf(entry HistoryEntry) entryFacts {
	if entry.facts != nil {
		return *entry.facts
	}
	f := entryFacts{key: entryKey(entry)}
	if entry.Kind == EntryThinking {
		f.thoughtMs, f.untimed = entry.ElapsedMs, entry.ElapsedMs <= 0
		return f
	}
	if entry.Kind != EntryTool {
		return f
	}
	f.failed = toolFailed(entry)
	if trace := entry.ToolTrace; trace != nil && (len(trace.Activities) > 0 || len(trace.Changes) > 0) {
		for _, change := range trace.Changes {
			f.edited = append(f.edited, prepareTarget(change.Path))
			f.added += change.Added
			f.removed += change.Removed
			f.changes = append(f.changes, change)
		}
		for _, activity := range trace.Activities {
			switch activity.Kind {
			case "read":
				f.read = append(f.read, activity.Target)
			case "list":
				f.read = append(f.read, strings.TrimSuffix(activity.Target, "/")+"/")
			case "search":
				f.searched = append(f.searched, oneLine(activity.Target))
			case "run":
				f.ran = append(f.ran, commandName(activity.Target))
			}
		}
		return f
	}
	arg := func(key string) string {
		s, _ := entry.ToolArgs[key].(string)
		return s
	}
	switch entry.ToolName {
	case "python":
		f.py = append(f.py, cmp.Or(pyLabel(arg("code")), "cell"))
	case "shell", "bash":
		f.ran = append(f.ran, cmp.Or(commandName(arg("command")), entry.ToolName))
	case "read_file":
		f.read = append(f.read, prepareTarget(arg("path")))
	case "write_file", "edit_file":
		f.edited = append(f.edited, prepareTarget(arg("path")))
	default:
		f.used = append(f.used, entry.ToolName)
	}
	return f
}

type burst struct {
	read      tally
	edited    tally
	searched  tally
	ran       tally
	py        tally
	used      tally
	changes   []daemon.FileChange
	failed    []HistoryEntry
	thoughtMs int64
	thoughts  int
	added     int
	removed   int
	untimed   bool // a thought from before this client watched has no duration
}

// merge folds one entry's facts into a burst.
func (b *burst) merge(f entryFacts, entry HistoryEntry) {
	if f.thoughtMs != 0 || f.untimed {
		b.thoughts++
		b.thoughtMs += f.thoughtMs
		b.untimed = b.untimed || f.untimed
	}
	b.read.addAll(f.read)
	b.edited.addAll(f.edited)
	b.searched.addAll(f.searched)
	b.ran.addAll(f.ran)
	b.py.addAll(f.py)
	b.used.addAll(f.used)
	b.added += f.added
	b.removed += f.removed
	b.changes = append(b.changes, f.changes...)
	if f.failed {
		b.failed = append(b.failed, entry)
	}
}

// collect merges every entry's facts; entries settled through the chat carry
// theirs already, so this only pays for tool results once per entry.
func collect(entries []HistoryEntry) burst {
	var b burst
	for _, entry := range entries {
		b.merge(factsOf(entry), entry)
	}
	return b
}

// pyLabel is what a python cell that ran nothing external was doing: the
// first line of it, folded and capped.
func pyLabel(code string) string {
	line := oneLine(firstLine(code))
	if len(line) > 48 {
		line = line[:45] + "…"
	}
	return line
}

// clause is a verb and what it acted on: n things, noun in the plural,
// listed as items. shown items fit, the rest count; none shown is a count.
type clause struct {
	verb, noun string
	items      []string
	n          int
	shown      int
}

func (c clause) String() string {
	if c.shown == 0 {
		noun := c.noun
		if c.n == 1 {
			noun = strings.TrimSuffix(noun, "s")
		}
		return fmt.Sprintf("%s %d %s", c.verb, c.n, noun)
	}
	text := c.verb + " " + strings.Join(c.items[:c.shown], ", ")
	if hidden := len(c.items) - c.shown; hidden > 0 {
		text += fmt.Sprintf(" +%d", hidden)
	}
	return text
}

// summary is the burst's one row: what it thought, read, changed, and ran,
// with the lists shortened until the row fits width.
func (r TranscriptRenderer) summary(b burst, width int, n namer) string {
	var head []string
	if b.thoughts > 0 {
		thought := "thought"
		if !b.untimed && b.thoughtMs >= 1000 {
			thought += " " + formatElapsed(b.thoughtMs)
		}
		head = append(head, thought)
	}
	// naming can make two recorded paths the same ("cli/a.go" and the absolute
	// form), so lists dedupe after naming, and reads defer to edits in that
	// same named space
	edited := dedup(n.all(b.edited.keys))
	reads := slices.DeleteFunc(dedup(n.all(b.read.keys)), func(p string) bool { return slices.Contains(edited, p) })
	var clauses []*clause
	for _, c := range []*clause{
		{verb: "read", noun: "files", n: len(reads), items: pathGroups(reads)},
		{verb: "searched", noun: "patterns", n: len(b.searched.keys), items: dedup(n.all(b.searched.keys))},
		{verb: "edited", noun: "files", n: len(edited), items: pathGroups(edited)},
		{verb: "ran", noun: "commands", n: len(b.ran.keys), items: b.ran.counted()},
		{verb: "python", noun: "cells", n: len(b.py.keys), items: b.py.counted()},
		{verb: "used", noun: "tools", n: len(b.used.keys), items: b.used.counted()},
	} {
		if len(c.items) > 0 {
			c.shown = len(c.items)
			clauses = append(clauses, c)
		}
	}
	line := func() string {
		parts := slices.Clone(head)
		for _, c := range clauses {
			parts = append(parts, c.String())
		}
		return strings.Join(parts, " · ")
	}
	tail := ""
	if n := len(b.failed); n > 0 {
		tail += r.Styles.Error.Render(fmt.Sprintf(" · %d failed", n))
	}
	if b.added+b.removed > 0 {
		tail += "  " + r.diffCounts(daemon.FileChange{Added: b.added, Removed: b.removed})
	}
	// shorten the longest list first; once each shows one item, the lists
	// become counts, what was read before what was edited
	room := width - ansi.StringWidth(tail)
	for ansi.StringWidth(line()) > room && shorten(clauses) {
	}
	return fit(r.Styles.Faint.Render(line()), tail, width)
}

// countedFirst ranks which list turns into a count first.
var countedFirst = map[string]int{"read": 0, "searched": 1, "used": 2, "ran": 3, "python": 4, "edited": 5}

// shorten hides one more item, and reports whether any was left to hide.
func shorten(clauses []*clause) bool {
	var pick *clause
	for _, c := range clauses {
		if c.shown > 1 && (pick == nil || c.shown > pick.shown) {
			pick = c
		}
	}
	if pick == nil {
		for _, c := range clauses {
			if c.shown == 1 && (pick == nil || countedFirst[c.verb] < countedFirst[pick.verb]) {
				pick = c
			}
		}
	}
	if pick == nil {
		return false
	}
	pick.shown--
	return true
}

// A burst's summary leads with a triangle when it has calls to list.
const (
	toggleClosed = "▸ "
	toggleOpen   = "▾ "
)

// RenderBurst is a burst's summary row, its steps when it is open, then the
// row of each call that failed, and with diffs shown, each change.
func (r TranscriptRenderer) RenderBurst(entries []HistoryEntry, flags DisplayFlags, width int) string {
	b, n := collect(entries), r.namer()
	key := factsOf(entries[0]).key
	opens := slices.ContainsFunc(entries, func(e HistoryEntry) bool { return e.Kind == EntryTool })
	open := opens && r.Open[key]
	head, room := markChrome, width
	if opens {
		// the triangle says the row opens, and which way it is now
		toggle := toggleClosed
		if open {
			toggle = toggleOpen
		}
		head += rowAction{verbOpen, key}.mark() + r.Styles.Faint.Render(toggle)
		room -= ansi.StringWidth(toggle)
	}
	rows := []string{head + r.summary(b, room, n)}
	if open {
		// failed calls are among the steps
		for _, step := range r.steps(entries, width-2) {
			rows = append(rows, "  "+step)
		}
	} else {
		for _, entry := range b.failed {
			rows = append(rows, markChrome+r.Styles.Error.Render(toolRow(entry, true, "", width)))
		}
	}
	if flags.Diffs {
		for _, change := range b.changes {
			if change.Kind == "diff" {
				rows = append(rows, r.renderDiffPath(change.Diff, change.Path, width))
			} else {
				rows = append(rows, r.Styles.Faint.Render(n.name(prepareTarget(change.Path))+": "+change.Reason))
			}
		}
	}
	return strings.Join(rows, "\n")
}

// steps is one row for each thing a burst's calls did, in order: the full
// command, pattern, or path, which the summary row only counts.
func (r TranscriptRenderer) steps(entries []HistoryEntry, width int) []string {
	var rows []string
	for _, entry := range entries {
		if entry.Kind != EntryTool {
			continue
		}
		trace := entry.ToolTrace
		if trace == nil || len(trace.Activities)+len(trace.Changes) == 0 {
			failed, style := toolFailed(entry), r.Styles.Faint
			if failed {
				style = r.Styles.Error
			}
			rows = append(rows, style.Render(toolRow(entry, failed, "", width)))
			continue
		}
		for _, act := range trace.Activities {
			style := r.Styles.Faint
			if act.Failed {
				style = r.Styles.Error
			}
			rows = append(rows, style.Render(r.traceLine(stepLabel(act.Kind), act.Target, isPath(act), width)))
		}
		for _, change := range trace.Changes {
			counts := ""
			if change.Kind == "diff" {
				counts = "  " + r.diffCounts(change)
			}
			line := r.traceLine(stepLabel("edit"), change.Path, true, width-ansi.StringWidth(counts))
			rows = append(rows, fit(r.Styles.Faint.Render(line), counts, width))
		}
	}
	return rows
}

// stepLabel pads a step's kind so the targets after it line up.
func stepLabel(kind string) string {
	return fmt.Sprintf("%-6s", kind)
}

// pathGroups names paths by their parent and name, and paths that share a
// parent together: `tui/{chat,transcript}.go`.
func pathGroups(paths []string) []string {
	var dirs []string
	names := map[string][]string{}
	for _, p := range paths {
		dir, name := path.Split(strings.TrimSuffix(p, "/"))
		if strings.HasSuffix(p, "/") {
			name += "/"
		}
		if _, seen := names[dir]; !seen {
			dirs = append(dirs, dir)
		}
		names[dir] = append(names[dir], name)
	}
	labels := make([]string, len(dirs))
	parents := map[string]int{}
	for _, dir := range dirs {
		parents[parentName(dir)]++
	}
	for i, dir := range dirs {
		parent := parentName(dir)
		if parents[parent] > 1 {
			parent = dir
		}
		labels[i] = parent + braced(names[dir])
	}
	return labels
}

// parentName is the last directory of dir with its slash, or nothing at the root.
func parentName(dir string) string {
	if dir = strings.TrimSuffix(dir, "/"); dir == "" {
		return ""
	}
	parent := path.Base(dir) + "/"
	if strings.HasPrefix(dir, "/") {
		parent = "/" + parent
	}
	return parent
}

// braced joins names in braces, a shared extension outside them.
func braced(names []string) string {
	if len(names) == 1 {
		return names[0]
	}
	ext := path.Ext(names[0])
	stems := make([]string, len(names))
	for i, name := range names {
		if ext == "" || path.Ext(name) != ext || strings.HasSuffix(name, "/") {
			return "{" + strings.Join(names, ",") + "}"
		}
		stems[i] = strings.TrimSuffix(name, ext)
	}
	return "{" + strings.Join(stems, ",") + "}" + ext
}

var (
	commandBreak = regexp.MustCompile(`&&|\|\||[;|\n]`)
	assignment   = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*=`)
	subcommand   = regexp.MustCompile(`^[a-z][a-z0-9-]*$`)
	// setup that comes before the command a line is about
	preamble = wordSet("cd", "export", "set", "source", ".")
	// programs whose first word names what they do; for the rest it is data,
	// like grep's pattern
	subcommanded = wordSet("git", "jj", "go", "cargo", "gleam", "npm", "pnpm", "yarn", "bun", "uv",
		"pip", "nix", "brew", "docker", "kubectl", "systemctl", "tg", "gh", "make", "just")
)

func wordSet(names ...string) map[string]bool {
	m := make(map[string]bool, len(names))
	for _, name := range names {
		m[name] = true
	}
	return m
}

// commandName is what a command line runs, the program and its subcommand
// when it has one: `cd cli && go test ./...` ran `go test`.
func commandName(command string) string {
	for _, part := range commandBreak.Split(strings.ReplaceAll(command, "\\\n", ""), -1) {
		fields := strings.Fields(part)
		for len(fields) > 0 && (assignment.MatchString(fields[0]) || fields[0] == "exec" || fields[0] == "time") {
			fields = fields[1:]
		}
		if len(fields) == 0 || preamble[fields[0]] {
			continue
		}
		name := fields[0]
		if strings.HasPrefix(name, "/") {
			name = path.Base(name)
		}
		if len(fields) > 1 && subcommanded[name] && subcommand.MatchString(fields[1]) {
			name += " " + fields[1]
		}
		return name
	}
	return oneLine(command)
}

// thinkingLine follows the newest nonempty line, even when thinking arrives
// in fragments or ends in a newline; one-line summaries still show their header.
func thinkingLine(text string) string {
	for text != "" {
		at := strings.LastIndexByte(text, '\n')
		line := strings.TrimLeft(strings.TrimSpace(text[at+1:]), "#> ")
		if line = oneLine(strings.NewReplacer("**", "", "__", "").Replace(line)); line != "" {
			return line
		}
		if at < 0 {
			break
		}
		text = text[:at]
	}
	return ""
}
