package tui

import (
	"math"
	"slices"
	"strings"

	"github.com/charmbracelet/x/ansi"
	"github.com/rivo/uniseg"
)

// Row marks are APC strings, which draw nothing: Bubble Tea drops them on
// the way to the screen, and widths and ansi.Strip skip them. They tell a
// copy what a row is beyond its text.
const (
	// markChrome labels the transcript rather than being part of it: who
	// speaks, a turn's signoff, tool glances.
	markChrome = "\x1b_albedo:chrome\x1b\\"
	// markWrap continues the row above, which broke at a space, and
	// markSplit continues it mid-word.
	markWrap  = "\x1b_albedo:wrap\x1b\\"
	markSplit = "\x1b_albedo:split\x1b\\"
)

// chrome marks every row of s.
func chrome(s string) string {
	return markChrome + strings.ReplaceAll(s, "\n", "\n"+markChrome)
}

// wrapJoiner is what goes between a row and the row above it continues.
func wrapJoiner(line string) (string, bool) {
	if strings.Contains(line, markWrap) {
		return " ", true
	}
	return "", strings.Contains(line, markSplit)
}

// markChunks marks the rows line wrapped into as continuing the one before,
// and gives them the line's chrome.
func markChunks(line string, chunks []string) []string {
	carried := ""
	if strings.Contains(line, markChrome) {
		carried = markChrome
	}
	for i := 1; i < len(chunks); i++ {
		mark := markSplit
		if strings.HasSuffix(ansi.Strip(chunks[i-1]), " ") {
			mark = markWrap
		}
		chunks[i] = carried + mark + chunks[i]
	}
	return chunks
}

// markWraps marks the rows of wrapped, markdown rendered to a width, that
// continue the row above. flat is the same markdown unwrapped: a run of
// wrapped rows that spells one of its lines, in order, is that line. A row
// that spells none, like a table's, stays a line of its own.
func markWraps(wrapped, flat string) string {
	rows := strings.Split(wrapped, "\n")
	var lines []string
	for line := range strings.SplitSeq(flat, "\n") {
		lines = append(lines, rowText(line))
	}
	// rest is what the line being spelled has left, after a space when spaced
	next, rest, spaced := 0, "", false
	for i, row := range rows {
		text := rowText(row)
		if cont := strings.TrimLeft(text, " "); rest != "" && cont != "" && strings.HasPrefix(rest, cont) {
			mark := markSplit
			if spaced {
				mark = markWrap
			}
			rows[i] = mark + row
			rest, spaced = spell(rest, cont)
			continue
		}
		rest = ""
		// rows that spell nothing hold the search back, so it looks ahead
		// past the flat lines they stand for
		for k := next; k < min(len(lines), next+16); k++ {
			if lines[k] == text || text != "" && strings.HasPrefix(lines[k], text) {
				next = k + 1
				rest, spaced = spell(lines[k], text)
				break
			}
		}
	}
	return strings.Join(rows, "\n")
}

// spell is what line has left after text, and whether spaces came first.
func spell(line, text string) (string, bool) {
	after := line[len(text):]
	rest := strings.TrimLeft(after, " ")
	return rest, len(rest) < len(after)
}

// rowText is what a row says: its plain text without quote bars or padding.
func rowText(line string) string {
	_, text, _ := selectedRange(line, 0, math.MaxInt, quoteColumns(line))
	return strings.TrimRight(text, " ")
}

type Point struct {
	Row int
	Col int
}
type Selection struct {
	Anchor Point
	Head   Point
	// Gutter is the decoration at the start of every row, which neither
	// the highlight nor the copied text includes.
	Gutter int
}

func (s Selection) IsEmpty() bool { return s.Anchor == s.Head }
func (s Selection) Normalized() (Point, Point) {
	if s.Anchor.Row < s.Head.Row || (s.Anchor.Row == s.Head.Row && s.Anchor.Col <= s.Head.Col) {
		return s.Anchor, s.Head
	}
	return s.Head, s.Anchor
}

// selectedRange splits line around the columns from to to. Columns in skip
// are left out of the selection.
func selectedRange(line string, from, to int, skip map[int]bool) (before, selected, after string) {
	plain := ansi.Strip(line)
	var chosen, prefix, suffix strings.Builder
	col := 0
	for len(plain) > 0 {
		cluster, rest, _, _ := uniseg.FirstGraphemeClusterInString(plain, -1)
		width := ansi.StringWidth(cluster)
		switch {
		case col+width > from && col < to:
			if !skip[col] {
				chosen.WriteString(cluster)
			}
		case col < from:
			prefix.WriteString(cluster)
		default:
			suffix.WriteString(cluster)
		}
		plain = rest
		col += width
	}
	return prefix.String(), chosen.String(), suffix.String()
}

// selBounds is the column range of sel on one row of lines.
func selBounds(line string, sel Selection, start, end Point, row int) (int, int) {
	from, to := sel.Gutter, ansi.StringWidth(line)
	if row == start.Row {
		from = max(from, start.Col)
	}
	if row == end.Row {
		to = end.Col
	}
	return from, to
}

// SelectedText is the text under sel: what the rows say, without what the
// transcript draws around it. Code block frames and quote bars are left out,
// and so are chrome rows unless nothing else is selected; wrapped rows join
// back into their lines.
func SelectedText(lines []string, sel Selection) string {
	if sel.IsEmpty() || len(lines) == 0 {
		return ""
	}
	start, end := sel.Normalized()
	type piece struct {
		text          string
		joiner        string
		chrome, joins bool
	}
	var picked []piece
	onlyChrome := true
	for row := max(0, start.Row); row <= end.Row && row < len(lines); row++ {
		line := lines[row]
		if strings.Contains(line, codeFence(fenceOpen)) {
			continue
		}
		// a code block's closing frame also spaces the block from what
		// follows, so it copies as a blank row
		if strings.Contains(line, codeFence(fenceClose)) {
			picked = append(picked, piece{})
			continue
		}
		from, to := selBounds(line, sel, start, end, row)
		_, text, _ := selectedRange(line, from, to, quoteColumns(line))
		if act, ok := actionOf(line); ok && act.verb == verbOpen {
			// the toggle is a control, not part of what the row says
			text = strings.TrimPrefix(strings.TrimPrefix(text, toggleClosed), toggleOpen)
		}
		// rows are padded out to the viewport's width
		p := piece{text: strings.TrimRight(text, " "), chrome: strings.Contains(line, markChrome)}
		if row > start.Row {
			p.joiner, p.joins = wrapJoiner(line)
		}
		onlyChrome = onlyChrome && (p.chrome || p.text == "")
		picked = append(picked, p)
	}
	var rows []string
	// joinable says the last row kept is the row above; dropped says chrome
	// was left out since the last text, so a blank row after it is spare
	joinable, dropped := false, false
	for _, p := range picked {
		switch {
		case p.chrome && !onlyChrome:
			joinable, dropped = false, true
		case p.joins && joinable:
			rows[len(rows)-1] += p.joiner + strings.TrimLeft(p.text, " ")
		case p.text == "" && dropped && len(rows) > 0 && rows[len(rows)-1] == "":
			joinable = false
		default:
			rows = append(rows, p.text)
			joinable, dropped = true, dropped && p.text == ""
		}
	}
	// and the viewport's height, so blank rows at either end are padding too
	for len(rows) > 0 && rows[0] == "" {
		rows = rows[1:]
	}
	for len(rows) > 0 && rows[len(rows)-1] == "" {
		rows = rows[:len(rows)-1]
	}
	return strings.Join(rows, "\n")
}

// quoteColumns are the columns of line under quote bars.
func quoteColumns(line string) map[int]bool {
	bar := quoteBar()
	skip := map[int]bool{}
	for at := 0; ; at += len(bar) {
		i := strings.Index(line[at:], bar)
		if i < 0 {
			return skip
		}
		at += i
		col := ansi.StringWidth(line[:at])
		skip[col], skip[col+1] = true, true
	}
}

func HighlightSelection(lines []string, sel Selection) []string {
	if sel.IsEmpty() {
		return lines
	}
	start, end := sel.Normalized()
	out := slices.Clone(lines)
	for row := max(0, start.Row); row <= end.Row && row < len(out); row++ {
		from, to := selBounds(out[row], sel, start, end, row)
		before, text, after := selectedRange(out[row], from, to, nil)
		if text != "" {
			out[row] = before + DefaultStyles.Cursor.Render(text) + after
		}
	}
	return out
}
