package tui

import (
	"albedo/cli/internal/daemon"
	"cmp"
	"fmt"
	"math"
	"path"
	"slices"
	"strings"
	"time"

	"charm.land/lipgloss/v2"
	"github.com/charmbracelet/x/ansi"
)

// folderLayout splits the screen below the prompt: the list, and the
// preview beside it when there is room.
type folderLayout struct {
	body, list, pane int
}

func (m FolderPicker) layout() folderLayout {
	width, height := cmp.Or(m.Width, 80), cmp.Or(m.Height, 24)
	l := folderLayout{body: max(1, height-6), list: width}
	if width >= 96 && height >= 14 {
		l.list = width / 2
		l.pane = width - l.list - ansi.StringWidth(svSep())
	}
	return l
}

// visibleRows is the range of rows the list window shows, the way
// scrollWindow cuts it under the section rule.
func (m FolderPicker) visibleRows() (first, last int) {
	h, n := m.layout().body, len(m.rows)+1
	if n <= h {
		return 0, len(m.rows)
	}
	start := min(max(0, m.cursor+1-h/2), n-h)
	return max(0, start-1), min(len(m.rows), start+h-1)
}

func (m FolderPicker) View() string {
	width, height := cmp.Or(m.Width, 80), cmp.Or(m.Height, 24)
	l := m.layout()
	heading, title := "sessions by folder", ""
	if !m.browse {
		heading, title = "move session", DefaultStyles.Faint.Render(sessionTitle(m.session))
	}
	lines := []string{
		" " + titleRule(width-1, brand("albedo")+" "+DefaultStyles.Muted.Render(heading), title), "",
		" " + promptLead() + m.input.View(), "",
	}
	list := m.list(l.list, l.body)
	if l.pane > 0 {
		pane := m.preview(l.pane-1, l.body)
		for i := range list {
			list[i] += svSep() + svFit(pane[i], l.pane-1) + " "
		}
	}
	lines = append(lines, list...)
	lines = append(lines, "", m.footer(width))
	if len(lines) > height {
		lines = append(lines[:max(0, height-1)], lines[len(lines)-1])
	}
	for i, line := range lines {
		lines[i] = ansi.Truncate(line, width, "…")
	}
	return strings.Join(lines, "\n")
}

func (m FolderPicker) footer(width int) string {
	left := " " + keyHints(m.hints()...)
	room := width - ansi.StringWidth(left) - 4
	if room < 24 {
		left = ""
		if m.sessionsError != "" {
			// Keep recovery discoverable when the full navigation help cannot fit.
			action := "retry"
			if m.sessionsLoading {
				action = "retrying…"
			}
			left = ansi.Truncate(" "+keyHints(hint{"ctrl+r", action}), max(0, width-1), "")
		}
		room = max(0, width-ansi.StringWidth(left)-2)
	}
	var right string
	switch {
	case m.moving:
		right = DefaultStyles.Faint.Render("moving…")
	case m.notice != "":
		right = DefaultStyles.Warning.Render(tailFit(m.notice, room))
	case m.deadHighlighted() != "":
		right = DefaultStyles.Error.Render(ansi.Truncate(m.deadHighlighted(), room, "…"))
	case m.sessionsError != "":
		right = DefaultStyles.Warning.Render(ansi.Truncate("Recent sessions: "+m.sessionsError, room, "…"))
	case m.retry != nil:
		right = DefaultStyles.Warning.Render("Folder not found: ") + DefaultStyles.Muted.Render(tailFit(m.homed(m.retry.Missing), room-ansi.StringWidth("Folder not found: ")))
	case m.browse: // nothing moves, so there is no "now in"
	default:
		right = DefaultStyles.Faint.Render("now in ") + DefaultStyles.Muted.Render(tailFit(m.homed(m.workspace()), room-7))
	}
	return left + strings.Repeat(" ", max(1, width-ansi.StringWidth(left)-ansi.StringWidth(right)-1)) + right
}

// hints are the footer's keys for where the cursor is; → shows only when
// the highlighted folder has sessions to step into.
func (m FolderPicker) hints() []hint {
	var hints []hint
	if m.sessionsError != "" {
		action := "retry sessions"
		if m.sessionsLoading {
			action = "retrying…"
		}
		hints = append(hints, hint{"ctrl+r", action})
	}
	if m.inSessions {
		return append(hints, hint{"↑↓", "move"}, hint{"enter", "open"}, hint{"←", "folders"})
	}
	hints = append(hints, hint{"↑↓", "move"}, hint{"tab", "open"}, hint{"⇧tab", "up"})
	row, ok := m.highlighted()
	if ok && len(m.sessionsIn(row.path)) > 0 {
		hints = append(hints, hint{"→", "sessions"})
	}
	if ok && m.probe(row.hostKey).State == "needs_auth" {
		hints = append(hints, hint{"ctrl+l", "sign in"})
	}
	action := "move here"
	if m.browse {
		action = "new session"
	}
	return append(hints, hint{"enter", action}, hint{"esc", "back"})
}

// tailFit keeps the end of plain text, where a path names its folder.
func tailFit(s string, w int) string {
	if n := ansi.StringWidth(s); n > w {
		return "…" + ansi.TruncateLeft(s, n-w+1, "")
	}
	return s
}

// list is the section rule over the rows, at exactly width × height.
func (m FolderPicker) list(width, height int) []string {
	all := []string{sectionRule(m.section, len(m.rows), width)}
	here, latest := m.workspace(), m.latestOther()
	now := time.Now()
	for i, row := range m.rows {
		all = append(all, m.row(row, i == m.cursor, row.path == here, row.path == latest, width, now))
	}
	if len(m.rows) == 0 {
		all = append(all, DefaultStyles.Faint.Render("   "+m.emptyNote()))
	}
	return scrollWindow(all, m.cursor+1, width, height)
}

// typedHost is the host a remote listing is typed for, as a row.
func (m FolderPicker) typedHost() folderRow {
	if host, _, remote := splitHost(m.listed); remote {
		return folderRow{hostKey: m.canonical(host), hostRow: true}
	}
	return folderRow{}
}

// deadHighlighted is why the highlighted row's host cannot be reached, in
// full, or empty when it can.
func (m FolderPicker) deadHighlighted() string {
	row, _ := m.highlighted()
	if row.hostKey == "" {
		row = m.typedHost()
	}
	if p := m.probe(row.hostKey); p.State == "unreachable" || p.State == "unsupported" {
		return m.labelOf(row.hostKey) + ": " + cmp.Or(p.Detail, p.State)
	}
	return ""
}

func (m FolderPicker) emptyNote() string {
	l := m.listings[m.listed]
	host, _, remote := splitHost(m.listed)
	switch {
	case m.listed == "" && m.section == "hosts":
		return "no known hosts match; type host: to browse one"
	case m.listed == "":
		return "no matches"
	case l == nil && remote && m.probe(m.canonical(host)).State == "warming":
		label := m.labelOf(host)
		return reachingText(label, m.probe(m.canonical(host)).Step) + " " + DefaultStyles.Agent.Render(connectingFace(label, m.frame))
	case l == nil:
		return "loading…"
	case l.Err != nil:
		return l.Err.Error()
	case len(l.List.Entries) == 0:
		return "No folders here"
	}
	return "no matches"
}

// latestOther is the most recently used recent folder besides this one.
func (m FolderPicker) latestOther() string {
	best, at := "", int64(0)
	for _, row := range m.rows {
		if row.recent && row.age != nil && *row.age > at && row.path != m.workspace() {
			best, at = row.path, *row.age
		}
	}
	return best
}

// where is the folder a row is in, led by its host when it is remote; a
// host row says where the host is known from.
func (m FolderPicker) where(row folderRow) string {
	switch {
	case row.hostRow && row.source == "config":
		return "ssh config"
	case row.hostRow:
		return "recent host"
	}
	host, p := daemon.SplitLocation(row.path)
	if host == "" {
		return strings.Join(crumbParts(m.homed(path.Dir(p))), " › ")
	}
	// the host leads the first crumb, as in the preview's rule
	crumbs := crumbParts(underHome(path.Dir(p), m.homes[m.canonical(host)]))
	crumbs[0] = cmp.Or(row.host, m.labelOf(host)) + ":" + crumbs[0]
	return strings.Join(crumbs, " › ")
}

// reach is a remote row's host state where its head and age would go:
// nothing once ready or while warming (the row is faint then), what is
// wrong otherwise, and the way in when a person must sign in.
func (m FolderPicker) reach(row folderRow) (note string, style lipgloss.Style, dim bool) {
	p := m.probe(row.hostKey)
	switch p.State {
	case "warming":
		return "", DefaultStyles.Faint, true
	case "needs_auth":
		return "sign in · ctrl+l", DefaultStyles.Warning, false
	case "unreachable", "unsupported":
		return cmp.Or(p.Detail, p.State), DefaultStyles.Error, false
	}
	return "", DefaultStyles.Faint, false
}

// row is a folder in the sessions view's grammar: bar, glyph, name with
// the matched letters lit, then quiet columns for where, head and age.
func (m FolderPicker) row(row folderRow, selected, here, latest bool, width int, now time.Time) string {
	glyph, glyphStyle := "· ", DefaultStyles.Faint
	switch {
	case selected:
		glyph, glyphStyle = "◆ ", DefaultStyles.Agent
	case here:
		glyph = "○ "
	case latest:
		glyph, glyphStyle = "● ", DefaultStyles.Success
	}
	whereW, tagW, ageW := 0, 12, 4
	if (row.recent || row.hostRow) && width >= 48 {
		whereW = 16
	}
	nameW := min(18, max(4, width-4-(whereW+2)-(tagW+2)-(ageW+2)))
	bar := " "
	if selected {
		bar = selectBar()
	}
	note, noteStyle, dim := m.reach(row)
	base := lipgloss.NewStyle()
	switch {
	case dim:
		base = DefaultStyles.Faint
	case selected:
		base = DefaultStyles.Bold
	}
	line := bar + glyphStyle.Render(glyph) + " " + litName(row.name, row.matched, nameW, base)
	if whereW > 0 {
		line += "  " + DefaultStyles.Faint.Render(svCell(m.where(row), whereW, false))
	}
	if note != "" {
		line += "  " + noteStyle.Render(svCell(note, tagW+2+ageW, false))
		if selected {
			return selectedLine(line, width)
		}
		return line
	}
	line += "  " + DefaultStyles.Faint.Render(svCell(repoTag(m.repos[row.path]), tagW, false))
	age := ""
	if here {
		age = "here"
	} else if row.age != nil {
		age = svCompactAge(row.age, now)
	}
	line += "  " + DefaultStyles.Faint.Render(svCell(age, ageW, true))
	if selected {
		return selectedLine(line, width)
	}
	return line
}

// litName fits name into w columns in base, its fuzzy-matched letters lit.
func litName(name string, matched []int, w int, base lipgloss.Style) string {
	lit := DefaultStyles.Prompt.Bold(true).Underline(true)
	fitted := ansi.Truncate(name, w, "…")
	var b strings.Builder
	for i, r := range fitted {
		style := base
		if slices.Contains(matched, i) {
			style = lit
		}
		b.WriteString(style.Render(string(r)))
	}
	return b.String() + strings.Repeat(" ", max(0, w-ansi.StringWidth(fitted)))
}

// crumbParts splits a homed path into the folders it passes through.
func crumbParts(p string) []string {
	if p == "/" {
		return []string{"/"}
	}
	if strings.HasPrefix(p, "/") {
		return append([]string{"/"}, strings.Split(p[1:], "/")...)
	}
	return strings.Split(p, "/")
}

// repoTag is a list row's head: a git branch or detached commit, or a jj
// bookmark or change.
func repoTag(r *daemon.Repo) string {
	switch {
	case r == nil:
		return ""
	case r.Kind == "jj" && r.Bookmark != nil:
		return "⚑ " + r.Bookmark.Name
	case r.Kind == "jj":
		if r.Change != "" {
			return "@ " + r.Change
		}
		return ""
	}
	if r.Branch != "" || r.Commit != "" {
		return "⎇ " + cmp.Or(r.Branch, r.Commit)
	}
	return ""
}

// ── preview ────────────────────────────────────────────────────────────────

// preview is the highlighted folder in exactly height rows: its path
// over its languages, the repository as the root of a two-level tree, then
// the sessions working there.
func (m FolderPicker) preview(width, height int) []string {
	row, ok := m.highlighted()
	if !ok {
		if row = m.typedHost(); row.hostKey == "" {
			return make([]string, height)
		}
	}
	if row.hostRow || !m.reachable(row) {
		return m.hostPane(row, width, height)
	}
	c := m.previews[row.path]
	var body []string
	var languages []daemon.LanguageShare
	var tree *daemon.FolderPreview
	switch {
	case c == nil || c.loading:
		body = []string{DefaultStyles.Faint.Render("looking…")}
	case c.err:
		body = []string{DefaultStyles.Faint.Render("Could not open this folder")}
	default:
		languages, tree = c.Languages, &c.FolderPreview
		if c.Repo != nil {
			body = append(body, repoLine(c.Repo, time.Now()))
		}
	}
	// The tree fits around a third of the pane kept for the sessions, then
	// the sessions fill whatever the tree left.
	sessions := m.sessionsIn(row.path)
	if tree != nil {
		kept := 0
		if len(sessions) > 0 {
			kept = sessionHead + min(len(sessions), max(3, height/3))
		}
		body = append(body, fitTree(*tree, height-2-len(body)-kept)...)
	}
	lines := []string{m.paneRule(row.path, languages, width), ""}
	for _, l := range body {
		lines = append(lines, "  "+l)
	}
	lines = append(lines, m.sessionBlock(sessions, width, height-len(lines)-sessionHead)...)
	return append(lines, make([]string, max(0, height-len(lines)))...)[:height]
}

// hostPane is a host, or a folder on a host not reached yet: how the host
// answers, then what it runs and where ~ is once it is ready.
func (m FolderPicker) hostPane(row folderRow, width, height int) []string {
	p := m.probe(row.hostKey)
	label := m.labelOf(row.hostKey)
	var body []string
	switch p.State {
	case "", "warming":
		body = []string{DefaultStyles.Faint.Render(reachingText(label, p.Step)+" ") + DefaultStyles.Agent.Render(connectingFace(label, m.frame))}
	case "ready":
		if p.OS != "" {
			body = append(body, DefaultStyles.Muted.Render(strings.TrimSpace(p.OS+" "+p.Arch)))
		}
		if home := m.homes[m.canonical(row.hostKey)]; home != "" {
			body = append(body, DefaultStyles.Faint.Render("~ is ")+DefaultStyles.Muted.Render(home))
		}
	case "needs_auth":
		body = []string{DefaultStyles.Warning.Render(cmp.Or(p.Detail, "needs a person to sign in")), DefaultStyles.Faint.Render("ctrl+l signs in here")}
	default:
		body = []string{DefaultStyles.Error.Render(cmp.Or(p.Detail, p.State))}
	}
	lines := []string{DefaultStyles.Decor.Render("─ ") + hostStyle(label).Render(label) + " " + DefaultStyles.Decor.Render(strings.Repeat("─", max(0, width-3-ansi.StringWidth(label)))), ""}
	for _, l := range body {
		lines = append(lines, "  "+ansi.Truncate(l, max(0, width-2), "…"))
	}
	if !row.hostRow {
		lines = append(lines, m.sessionBlock(m.sessionsIn(row.path), width, height-len(lines)-sessionHead)...)
	}
	return append(lines, make([]string, max(0, height-len(lines)))...)[:height]
}

// sessionHead is the rows above the sessions: their rule between blanks.
const sessionHead = 3

// sessionBlock is the sessions working in the previewed folder under their
// rule, in at most room rows. Stepped into, it follows the cursor.
func (m FolderPicker) sessionBlock(sessions []daemon.Session, width, room int) []string {
	if len(sessions) == 0 || room <= 0 {
		return nil
	}
	now := time.Now()
	rows := make([]string, len(sessions))
	for i, s := range sessions {
		selected := m.inSessions && i == m.sessionCursor
		glyph, style := "· ", DefaultStyles.Faint
		switch {
		case selected:
			glyph, style = "◆ ", DefaultStyles.Agent
		case s.LastAssistantAt != nil && now.Sub(time.Unix(*s.LastAssistantAt, 0)) < time.Hour:
			glyph, style = "● ", DefaultStyles.Success
		}
		titleStyle := DefaultStyles.Muted
		bar := " "
		if selected {
			titleStyle = DefaultStyles.Bold
			bar = selectBar()
		}
		title := titleStyle.Render(sessionTitle(s))
		line := bar + " " + style.Render(glyph) + title
		if s.LastAssistantAt != nil {
			line += DefaultStyles.Faint.Render("  " + svCompactAge(s.LastAssistantAt, now))
		}
		if selected {
			line = selectedLine(line, width)
		}
		rows[i] = line
	}
	if len(rows) > room && !m.inSessions {
		rows = append(rows[:room-1], DefaultStyles.Faint.Render(fmt.Sprintf("   … %d more", len(sessions)-room+1)))
	}
	return append([]string{"", plainRule("sessions", width), ""}, scrollWindow(rows, m.sessionCursor, width, min(room, len(rows)))...)
}

func (m FolderPicker) sessionsIn(p string) []daemon.Session {
	return slices.DeleteFunc(slices.Clone(m.sessions), func(s daemon.Session) bool { return s.Workspace != p })
}

// paneRule heads the preview like sectionRule heads the list: the folder's
// breadcrumbs, then a rule split by language share, then the language that
// leads.
func (m FolderPicker) paneRule(p string, languages []daemon.LanguageShare, w int) string {
	parts := crumbParts(m.homed(p))
	lead := func() string { return DefaultStyles.Decor.Render("─ ") + crumbs(parts) + " " }
	tail := ""
	if len(languages) > 0 {
		tail = " " + languageStyle(languages[0].Name, languages[0].Color).Render(strings.ToLower(languages[0].Name)) + " "
	}
	for len(parts) > 2 && ansi.StringWidth(lead()+tail) > w-8 {
		parts = append([]string{"…"}, parts[2:]...)
	}
	n := max(0, w-ansi.StringWidth(lead())-ansi.StringWidth(tail))
	var rule strings.Builder
	used, sum := 0, 0.0
	for _, l := range languages {
		sum += l.Share
		k := min(n, int(float64(n)*sum+0.5)) - used
		rule.WriteString(languageStyle(l.Name, l.Color).Render(strings.Repeat("─", k)))
		used += k
	}
	rule.WriteString(DefaultStyles.Decor.Render(strings.Repeat("─", n-used)))
	return lead() + rule.String() + tail
}

// crumbs is a path as soft breadcrumbs, the leaf in bold.
func crumbs(parts []string) string {
	out := make([]string, len(parts))
	for i, p := range parts {
		out[i] = DefaultStyles.Muted.Bold(i == len(parts)-1).Render(p)
	}
	return strings.Join(out, DefaultStyles.Decor.Render(" › "))
}

// plainRule is a section rule without a count.
func plainRule(label string, w int) string {
	head := " " + label + " "
	return DefaultStyles.Decor.Render("─") + DefaultStyles.Muted.Bold(true).Render(head) + DefaultStyles.Decor.Render(strings.Repeat("─", max(0, w-1-ansi.StringWidth(head))))
}

// repoLine is the repository at the root of the tree: the head, how many
// files changed when any did, and when it was last touched.
func repoLine(r *daemon.Repo, now time.Time) string {
	var parts []string
	switch {
	case r.Kind == "jj":
		head := ""
		if r.Bookmark != nil {
			head = DefaultStyles.Agent.Render("⚑ " + r.Bookmark.Name)
			if r.Bookmark.Ahead > 0 {
				head += DefaultStyles.Faint.Render(fmt.Sprintf("+%d", r.Bookmark.Ahead))
			}
			parts = append(parts, head)
		}
		if r.Change != "" {
			parts = append(parts, DefaultStyles.Faint.Render("@ ")+DefaultStyles.Muted.Render(r.Change))
		}
	case r.Branch != "" || r.Commit != "":
		parts = append(parts, DefaultStyles.Agent.Render("⎇ "+cmp.Or(r.Branch, r.Commit)))
	}
	if r.Changed != nil && *r.Changed > 0 {
		parts = append(parts, DefaultStyles.Warning.Render(fmt.Sprintf("✎ %d", *r.Changed)))
	}
	if r.Touched != nil {
		parts = append(parts, DefaultStyles.Faint.Render(svCompactAge(r.Touched, now)))
	}
	return strings.Join(parts, "  ")
}

// fitTree is the preview's tree in at most rows lines. When the two levels
// do not fit, directories show fewer children, then none, and last the
// top level itself is cut short.
func fitTree(p daemon.FolderPreview, rows int) []string {
	for _, children := range []int{math.MaxInt, 2, 0} {
		if out := folderTree(p, children, len(p.Tree)); len(out) <= rows {
			return out
		}
	}
	for top := len(p.Tree) - 1; top > 0; top-- {
		if out := folderTree(p, 0, top); len(out) <= rows {
			return out
		}
	}
	return nil
}

// folderTree draws the preview's two levels, at most children under each
// directory and top entries in all: directories muted with a slash, files
// in their language's color, changes noted beside them. With no children
// shown, directories stand alone.
func folderTree(p daemon.FolderPreview, children, top int) []string {
	colors := map[string]string{}
	for _, l := range p.Languages {
		colors[l.Name] = l.Color
	}
	branch := func(last bool) string {
		text := "├─ "
		if last {
			text = "╰─ "
		}
		return DefaultStyles.Decor.Render(text)
	}
	more := func(n int) string { return DefaultStyles.Faint.Render(fmt.Sprintf("… %d more", n)) }
	name := func(n daemon.FolderNode) string {
		s := DefaultStyles.Muted.Render(n.Name) + DefaultStyles.Decor.Render("/")
		if !n.Dir {
			s = languageStyle(n.Language, colors[n.Language]).Render(n.Name)
		}
		if n.Changed > 0 {
			s += "  " + DefaultStyles.Faint.Render(fmt.Sprintf("✎ %d", n.Changed))
		}
		return s
	}
	tree, hidden := p.Tree[:min(top, len(p.Tree))], p.More+max(0, len(p.Tree)-top)
	var out []string
	for i, entry := range tree {
		last := i == len(tree)-1 && hidden == 0
		out = append(out, branch(last)+name(entry))
		if children == 0 {
			continue
		}
		indentText := "│  "
		if last {
			indentText = "   "
		}
		indent := DefaultStyles.Decor.Render(indentText)
		shown := entry.Children[:min(children, len(entry.Children))]
		rest := entry.More + len(entry.Children) - len(shown)
		for j, child := range shown {
			out = append(out, indent+branch(j == len(shown)-1 && rest == 0)+name(child))
		}
		if rest > 0 {
			out = append(out, indent+branch(true)+more(rest))
		}
	}
	if hidden > 0 {
		out = append(out, branch(true)+more(hidden))
	}
	return out
}
