// Copyright 2014 The gocui Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package gocui

import (
	"fmt"
	"io"
	"strings"
	"sync"
	"unicode"
	"unicode/utf8"

	"github.com/gdamore/tcell/v3"
	"github.com/rivo/uniseg"
)

// Constants for overlapping edges
const (
	TOP    = 1 // view is overlapping at top edge
	BOTTOM = 2 // view is overlapping at bottom edge
	LEFT   = 4 // view is overlapping at left edge
	RIGHT  = 8 // view is overlapping at right edge
)

// A View is a window. It maintains its own internal buffer and cursor
// position.
type View struct {
	name           string
	x0, y0, x1, y1 int      // left top right bottom
	ox, oy         int      // view offsets
	cx, cy         int      // cursor position
	rx, ry         int      // Read() offsets
	wx, wy         int      // Write() offsets
	lines          [][]cell // All the data
	outMode        OutputMode
	// The y position of the first line of a range selection.
	// This is not relative to the view's origin: it is relative to the first line
	// of the view's content, so you can scroll the view and this value will remain
	// the same, unlike the view's cy value.
	// A value of -1 means that there is no range selection.
	// This value can be greater than the selected line index, in the event that
	// a user starts a range select and then moves the cursor up.
	rangeSelectStartY int

	// readBuffer is used for storing unread bytes
	readBuffer []byte

	// tained is true if the viewLines must be updated
	tainted bool

	// the last position that the mouse was hovering over; nil if the mouse is outside of
	// this view, or not hovering over a cell
	lastHoverPosition *pos

	// the location of the hyperlink that the mouse is currently hovering over; nil if none
	hoveredHyperlink *SearchPosition

	// internal representation of the view's buffer. We will keep viewLines around
	// from a previous render until we explicitly set them to nil, allowing us to
	// render the same content twice without flicker. Wherever we want to render
	// something without any chance of old content appearing (e.g. when actually
	// rendering new content or if the view is resized) we should set tainted to
	// true and viewLines to nil
	viewLines []viewLine

	// If the last character written was a newline, we don't write it but
	// instead set pendingNewline to true. If more text is written, we write the
	// newline then. This is to avoid having an extra blank at the end of the view.
	pendingNewline bool

	// writeMutex protects locks the write process
	writeMutex sync.Mutex

	// ei is used to decode ESC sequences on Write
	ei *escapeInterpreter

	// Visible specifies whether the view is visible.
	Visible bool

	// BgColor and FgColor allow to configure the background and foreground
	// colors of the View.
	BgColor, FgColor Attribute

	// SelBgColor and SelFgColor are used to configure the background and
	// foreground colors of the selected line, when it is highlighted.
	SelBgColor, SelFgColor Attribute

	// InactiveViewSelBgColor is used to configure the background color of the
	// selected line, when it is highlighted but the view doesn't have the
	// focus.
	InactiveViewSelBgColor Attribute

	// If Editable is true, keystrokes will be added to the view's internal
	// buffer at the cursor position.
	Editable bool

	// Editor allows to define the editor that manages the editing mode,
	// including keybindings or cursor behaviour. DefaultEditor is used by
	// default.
	Editor Editor

	// Overwrite enables or disables the overwrite mode of the view.
	Overwrite bool

	// If Highlight is true, Sel{Bg,Fg}Colors will be used
	// for the line under the cursor position.
	Highlight bool
	// If HighlightInactive is true, InavtiveViewSel{Bg,Fg}Colors will be used
	// instead of Sel{Bg,Fg}Colors for highlighting selected lines.
	HighlightInactive bool

	// If Frame is true, a border will be drawn around the view.
	Frame bool

	// FrameColor allow to configure the color of the Frame when it is not highlighted.
	FrameColor Attribute

	// FrameRunes allows to define custom runes for the frame edges.
	// The rune slice can be defined with 3 different lengths.
	// If slice doesn't match these lengths, default runes will be used instead of missing one.
	//
	// 2 runes with only horizontal and vertical edges.
	//  []rune{'─', '│'}
	//  []rune{'═','║'}
	// 6 runes with horizontal, vertical edges and top-left, top-right, bottom-left, bottom-right cornes.
	//  []rune{'─', '│', '┌', '┐', '└', '┘'}
	//  []rune{'═','║','╔','╗','╚','╝'}
	// 11 runes which can be used with `gocui.Gui.SupportOverlaps` property.
	//  []rune{'─', '│', '┌', '┐', '└', '┘', '├', '┤', '┬', '┴', '┼'}
	//  []rune{'═','║','╔','╗','╚','╝','╠','╣','╦','╩','╬'}
	FrameRunes []rune

	// If Wrap is true, the content that is written to this View is
	// automatically wrapped when it is longer than its width. If true the
	// view's x-origin will be ignored.
	Wrap bool

	// If Autoscroll is true, the View will automatically scroll down when the
	// text overflows. If true the view's y-origin will be ignored.
	Autoscroll bool

	// If Frame is true, Title allows to configure a title for the view.
	Title string

	// If non-empty, TitlePrefix is prepended to the title of a view regardless on
	// the the currently selected tab (if any.)
	TitlePrefix string

	Tabs     []string
	TabIndex int

	// TitleColor allow to configure the color of title and subtitle for the view.
	TitleColor Attribute

	// If Frame is true, Subtitle allows to configure a subtitle for the view.
	Subtitle string

	// If Mask is true, the View will display the mask instead of the real
	// content
	Mask string

	// Overlaps describes which edges are overlapping with another view's edges
	Overlaps byte

	// ParentView is the view which catches events bubbled up from the given view if there's no matching handler
	ParentView *View

	searcher *searcher

	// KeybindOnEdit should be set to true when you want to execute keybindings even when the view is editable
	// (this is usually not the case)
	KeybindOnEdit bool

	TextArea *TextArea

	// something like '1 of 20' for a list view
	Footer string

	// if true, the user can scroll all the way past the last item until it appears at the top of the view
	CanScrollPastBottom bool

	// if true, the view will automatically recognize https: URLs in the content written to it and render
	// them as hyperlinks
	AutoRenderHyperLinks bool

	// if true, the view will underline hyperlinks only when the cursor is on
	// them; otherwise, they will always be underlined
	UnderlineHyperLinksOnlyOnHover bool

	// number of spaces per \t character, defaults to 4
	TabWidth int
}

type pos struct {
	x, y int
}

// call this in the event of a view resize, or if you want to render new content
// without the chance of old content still appearing, or if you want to remove
// a line from the existing content
func (v *View) clearViewLines() {
	v.tainted = true
	v.viewLines = nil
	v.clearHover()
}

type searcher struct {
	searchString       string
	searchPositions    []SearchPosition
	modelSearchResults []SearchPosition
	currentSearchIndex int
	onSelectItem       func(*View, int)
	renderSearchStatus func(*View, int, int)
}

func (v *View) setRenderSearchStatus(renderSearchStatus func(*View, int, int)) {
	v.searcher.renderSearchStatus = renderSearchStatus
}

func (v *View) setOnSelectResult(onSelectItem func(*View, int)) {
	v.searcher.onSelectItem = onSelectItem
}

func (v *View) renderSearchStatus(index int, itemCount int) {
	if v.searcher.renderSearchStatus != nil {
		v.searcher.renderSearchStatus(v, index, itemCount)
	}
}

func (v *View) gotoNextMatch() error {
	if len(v.searcher.searchPositions) == 0 {
		return nil
	}
	if v.Highlight && v.oy+v.cy < v.searcher.searchPositions[v.searcher.currentSearchIndex].Y {
		// If the selection is before the current match, just jump to the current match and return.
		// This can only happen if the user has moved the cursor to before the first match.
		v.SelectSearchResult(v.searcher.currentSearchIndex)
		return nil
	}
	if v.searcher.currentSearchIndex >= len(v.searcher.searchPositions)-1 {
		v.searcher.currentSearchIndex = 0
	} else {
		v.searcher.currentSearchIndex++
	}
	v.SelectSearchResult(v.searcher.currentSearchIndex)
	return nil
}

func (v *View) gotoPreviousMatch() error {
	if len(v.searcher.searchPositions) == 0 {
		return nil
	}
	if v.Highlight && v.oy+v.cy > v.searcher.searchPositions[v.searcher.currentSearchIndex].Y {
		// If the selection is after the current match, just jump to the current match and return.
		// This happens if the user has moved the cursor down from the current match.
		v.SelectSearchResult(v.searcher.currentSearchIndex)
		return nil
	}
	if v.searcher.currentSearchIndex == 0 {
		if len(v.searcher.searchPositions) > 0 {
			v.searcher.currentSearchIndex = len(v.searcher.searchPositions) - 1
		}
	} else {
		v.searcher.currentSearchIndex--
	}
	v.SelectSearchResult(v.searcher.currentSearchIndex)
	return nil
}

func (v *View) SelectSearchResult(index int) {
	itemCount := len(v.searcher.searchPositions)
	if itemCount == 0 {
		return
	}
	if index > itemCount-1 {
		index = itemCount - 1
	}

	y := v.searcher.searchPositions[index].Y

	v.FocusPoint(v.ox, y, true)
	v.renderSearchStatus(index, itemCount)
	if v.searcher.onSelectItem != nil {
		v.searcher.onSelectItem(v, y)
	}
}

// Returns <current match index>, <total matches>
func (v *View) GetSearchStatus() (int, int) {
	return v.searcher.currentSearchIndex, len(v.searcher.searchPositions)
}

// modelSearchResults is optional; pass nil to search the view. If non-nil,
// these positions will be used for highlighting search results. Even in this
// case the view will still be searched on a per-line basis, so that the caller
// doesn't have to make assumptions where in the rendered line the search result
// is. The XStart and XEnd values in the modelSearchResults are only used in
// case the search string is not found in the given line, which can happen if
// the view renders an abbreviated version of some of the model data.
//
// Mind the difference between nil and empty slice: nil means we're not
// searching the model, empty slice means we *are* searching the model but we
// didn't find any matches.
func (v *View) UpdateSearchResults(str string, modelSearchResults []SearchPosition) {
	v.writeMutex.Lock()
	defer v.writeMutex.Unlock()

	v.searcher.search(str, modelSearchResults)
	v.updateSearchPositions()

	if len(v.searcher.searchPositions) > 0 {
		// get the first result past the current cursor
		currentIndex := 0
		if v.Highlight {
			// ...but only if we're showing the highlighted line
			adjustedY := v.oy + v.cy
			adjustedX := v.ox + v.cx
			for i, pos := range v.searcher.searchPositions {
				if pos.Y > adjustedY || (pos.Y == adjustedY && pos.XStart > adjustedX) {
					currentIndex = i
					break
				}
			}
		}
		v.searcher.currentSearchIndex = currentIndex
	}
}

func (v *View) Search(str string, modelSearchResults []SearchPosition) {
	v.UpdateSearchResults(str, modelSearchResults)

	if len(v.searcher.searchPositions) > 0 {
		v.SelectSearchResult(v.searcher.currentSearchIndex)
	} else {
		v.renderSearchStatus(0, 0)
	}
}

func (v *View) ClearSearch() {
	v.searcher.clearSearch()
}

func (v *View) IsSearching() bool {
	return v.searcher.searchString != ""
}

func (v *View) nearestSearchPosition() int {
	currentLineIndex := v.cy + v.oy
	lastSearchPos := 0
	for i, pos := range v.searcher.searchPositions {
		if pos.Y == currentLineIndex {
			return i
		}
		if pos.Y > currentLineIndex {
			break
		}
		lastSearchPos = i
	}
	return lastSearchPos
}

func (v *View) SetNearestSearchPosition() {
	if len(v.searcher.searchPositions) > 0 {
		newPos := v.nearestSearchPosition()
		if newPos != v.searcher.currentSearchIndex {
			v.searcher.currentSearchIndex = newPos
			v.renderSearchStatus(newPos, len(v.searcher.searchPositions))
		}
	}
}

func (v *View) FocusPoint(cx int, cy int, scrollIntoView bool) {
	v.writeMutex.Lock()
	defer v.writeMutex.Unlock()

	v.refreshViewLinesIfNeeded()
	lineCount := len(v.viewLines)
	if cy < 0 || cy > lineCount {
		return
	}

	if scrollIntoView {
		height := v.InnerHeight()
		v.oy = calculateNewOrigin(cy, v.oy, lineCount, height)
	}

	v.cx = cx
	v.cy = cy - v.oy
}

func (v *View) SetRangeSelectStart(rangeSelectStartY int) {
	v.rangeSelectStartY = rangeSelectStartY
}

func (v *View) CancelRangeSelect() {
	v.rangeSelectStartY = -1
}

func calculateNewOrigin(selectedLine int, oldOrigin int, lineCount int, viewHeight int) int {
	if viewHeight >= lineCount {
		return 0
	} else if selectedLine < oldOrigin || selectedLine >= oldOrigin+viewHeight {
		// If the selected line is outside the visible area, scroll the view so
		// that the selected line is in the middle.
		newOrigin := selectedLine - viewHeight/2

		// However, take care not to overflow if the total line count is less
		// than the view height.
		maxOrigin := lineCount - viewHeight
		if newOrigin > maxOrigin {
			newOrigin = maxOrigin
		}
		if newOrigin < 0 {
			newOrigin = 0
		}

		return newOrigin
	}

	return oldOrigin
}

func (s *searcher) search(str string, modelSearchResults []SearchPosition) {
	s.searchString = str
	s.searchPositions = []SearchPosition{}
	s.modelSearchResults = modelSearchResults
	s.currentSearchIndex = 0
}

func (s *searcher) clearSearch() {
	s.searchString = ""
	s.searchPositions = []SearchPosition{}
	s.currentSearchIndex = 0
}

type SearchPosition struct {
	XStart int
	XEnd   int
	Y      int
}

type viewLine struct {
	linesX, linesY int // coordinates relative to v.lines
	line           []cell
}

type cell struct {
	chr              string // a grapheme cluster
	width            int    // number of terminal cells occupied by chr (always 1 or 2)
	bgColor, fgColor Attribute
	hyperlink        string
}

type lineType []cell

func characterEquals(chr []byte, b byte) bool {
	return len(chr) == 1 && chr[0] == b
}

func isCRLF(chr []byte) bool {
	return len(chr) == 2 && chr[0] == '\r' && chr[1] == '\n'
}

// String returns a string from a given cell slice.
func (l lineType) String() string {
	var str strings.Builder
	for _, c := range l {
		str.WriteString(c.chr)
	}
	return str.String()
}

// NewView returns a new View object.
func NewView(name string, x0, y0, x1, y1 int, mode OutputMode) *View {
	v := &View{
		name:              name,
		x0:                x0,
		y0:                y0,
		x1:                x1,
		y1:                y1,
		Visible:           true,
		Frame:             true,
		Editor:            DefaultEditor,
		tainted:           true,
		outMode:           mode,
		ei:                newEscapeInterpreter(mode),
		searcher:          &searcher{},
		TextArea:          &TextArea{},
		rangeSelectStartY: -1,
		TabWidth:          4,
	}

	v.FgColor, v.BgColor = ColorDefault, ColorDefault
	v.SelFgColor, v.SelBgColor = ColorDefault, ColorDefault
	v.InactiveViewSelBgColor = ColorDefault
	v.TitleColor, v.FrameColor = ColorDefault, ColorDefault
	return v
}

// Dimensions returns the dimensions of the View
func (v *View) Dimensions() (int, int, int, int) {
	return v.x0, v.y0, v.x1, v.y1
}

// Size returns the number of visible columns and rows in the View, including
// the frame if any
func (v *View) Size() (x, y int) {
	return v.Width(), v.Height()
}

// InnerSize returns the number of usable columns and rows in the View, excluding
// the frame if any
func (v *View) InnerSize() (x, y int) {
	return v.InnerWidth(), v.InnerHeight()
}

func (v *View) Width() int {
	return v.x1 - v.x0 + 1
}

func (v *View) Height() int {
	return v.y1 - v.y0 + 1
}

// The writeable area of the view is always two less then the view's size,
// because if it has a frame, we need to subtract that, but if it doesn't, the
// view is made 1 larger on all sides. I'd like to clean this up at some point,
// but for now we live with this weirdness.
func (v *View) InnerWidth() int {
	innerWidth := v.Width() - 2
	if innerWidth < 0 {
		return 0
	}

	return innerWidth
}

func (v *View) InnerHeight() int {
	innerHeight := v.Height() - 2
	if innerHeight < 0 {
		return 0
	}

	return innerHeight
}

// Name returns the name of the view.
func (v *View) Name() string {
	return v.name
}

// setCharacter sets a character (grapheme cluster) at the given point relative to the view. It applies
// the specified colors, taking into account if the cell must be highlighted. Also, it checks if the
// position is valid.
func (v *View) setCharacter(x, y int, ch string, fgColor, bgColor Attribute) {
	maxX, maxY := v.Size()
	if x < 0 || x >= maxX || y < 0 || y >= maxY {
		return
	}

	if v.Mask != "" {
		fgColor = v.FgColor
		bgColor = v.BgColor
		ch = v.Mask
	} else if v.Highlight {
		rangeSelectStart := v.cy
		rangeSelectEnd := v.cy
		if v.rangeSelectStartY != -1 {
			relativeRangeSelectStart := v.rangeSelectStartY - v.oy
			rangeSelectStart = min(relativeRangeSelectStart, v.cy)
			rangeSelectEnd = max(relativeRangeSelectStart, v.cy)
		}

		if y >= rangeSelectStart && y <= rangeSelectEnd {
			// this ensures we use the bright variant of a colour upon highlight
			fgColorComponent := fgColor & ^AttrAll
			if fgColorComponent >= AttrIsValidColor && fgColorComponent < AttrIsValidColor+8 {
				fgColor += 8
			}
			fgColor = fgColor | AttrBold
			if v.HighlightInactive {
				bgColor = (bgColor & AttrStyleBits) | v.InactiveViewSelBgColor
			} else {
				bgColor = (bgColor & AttrStyleBits) | v.SelBgColor
			}
		}
	}

	if matched, selected := v.isPatternMatchedRune(x, y); matched {
		fgColor = ColorBlack
		if selected {
			bgColor = ColorCyan
		} else {
			bgColor = ColorYellow
		}
	}

	if v.isHoveredHyperlink(x, y) {
		fgColor |= AttrUnderline
	}

	// Don't display empty characters
	if ch == "" {
		ch = " "
	}

	tcellSetCell(v.x0+x+1, v.y0+y+1, ch, fgColor, bgColor, v.outMode)
}

// SetCursor sets the cursor position of the view at the given point,
// relative to the view. It is allowed to set the position to a point outside
// the visible portion of the view, or even outside the content of the view.
// Clients are responsible for clamping to valid positions.
func (v *View) SetCursor(x, y int) {
	v.cx = x
	v.cy = y
}

func (v *View) SetCursorX(x int) {
	v.cx = x
}

func (v *View) SetCursorY(y int) {
	v.cy = y
}

// Cursor returns the cursor position of the view.
func (v *View) Cursor() (x, y int) {
	return v.cx, v.cy
}

func (v *View) CursorX() int {
	return v.cx
}

func (v *View) CursorY() int {
	return v.cy
}

// SetOrigin sets the origin position of the view's internal buffer,
// so the buffer starts to be printed from this point, which means that
// it is linked with the origin point of view. It can be used to
// implement Horizontal and Vertical scrolling with just incrementing
// or decrementing ox and oy.
func (v *View) SetOrigin(x, y int) {
	if x < 0 {
		x = 0
	}
	if y < 0 {
		y = 0
	}

	v.ox = x
	v.oy = y
}

func (v *View) SetOriginX(x int) {
	if x < 0 {
		x = 0
	}
	v.ox = x
}

func (v *View) SetOriginY(y int) {
	if y < 0 {
		y = 0
	}
	v.oy = y
}

// Origin returns the origin position of the view.
func (v *View) Origin() (x, y int) {
	return v.OriginX(), v.OriginY()
}

func (v *View) OriginX() int {
	return v.ox
}

func (v *View) OriginY() int {
	return v.oy
}

// SetWritePos sets the write position of the view's internal buffer.
// So the next Write call would write directly to the specified position.
func (v *View) SetWritePos(x, y int) {
	if x < 0 {
		x = 0
	}
	if y < 0 {
		y = 0
	}

	v.wx = x
	v.wy = y

	// Changing the write position makes a pending newline obsolete
	v.pendingNewline = false
}

// WritePos returns the current write position of the view's internal buffer.
func (v *View) WritePos() (x, y int) {
	return v.wx, v.wy
}

// SetReadPos sets the read position of the view's internal buffer.
// So the next Read call would read from the specified position.
func (v *View) SetReadPos(x, y int) {
	if x < 0 {
		x = 0
	}
	if y < 0 {
		y = 0
	}

	v.readBuffer = nil
	v.rx = x
	v.ry = y
}

// ReadPos returns the current read position of the view's internal buffer.
func (v *View) ReadPos() (x, y int) {
	return v.rx, v.ry
}

// makeWriteable creates empty cells if required to make position (x, y) writeable.
func (v *View) makeWriteable(x, y int) {
	// TODO: make this more efficient

	// line `y` must be index-able (that's why `<=`)
	for len(v.lines) <= y {
		if cap(v.lines) > len(v.lines) {
			newLen := cap(v.lines)
			if newLen > y {
				newLen = y + 1
			}
			v.lines = v.lines[:newLen]
		} else {
			v.lines = append(v.lines, nil)
		}
	}
	// cell `x` need not be index-able (that's why `<`)
	// append should be used by `lines[y]` user if he wants to write beyond `x`
	for len(v.lines[y]) < x {
		if cap(v.lines[y]) > len(v.lines[y]) {
			newLen := cap(v.lines[y])
			if newLen > x {
				newLen = x
			}
			v.lines[y] = v.lines[y][:newLen]
		} else {
			v.lines[y] = append(v.lines[y], cell{})
		}
	}
}

// writeCells copies []cell to (v.wx, v.wy), and advances v.wx accordingly.
// !!! caller MUST ensure that specified location (x, y) is writeable by calling makeWriteable
func (v *View) writeCells(cells []cell) {
	var newLen int
	// use maximum len available
	line := v.lines[v.wy][:cap(v.lines[v.wy])]
	maxCopy := len(line) - v.wx
	if maxCopy < len(cells) {
		copy(line[v.wx:], cells[:maxCopy])
		line = append(line, cells[maxCopy:]...)
		newLen = len(line)
	} else { // maxCopy >= len(cells)
		copy(line[v.wx:], cells)
		newLen = v.wx + len(cells)
		if newLen < len(v.lines[v.wy]) {
			newLen = len(v.lines[v.wy])
		}
	}
	v.lines[v.wy] = line[:newLen]
	v.wx += len(cells)
}

// Write appends a byte slice into the view's internal buffer. Because
// View implements the io.Writer interface, it can be passed as parameter
// of functions like fmt.Fprintf, fmt.Fprintln, io.Copy, etc. Clear must
// be called to clear the view's buffer.
func (v *View) Write(p []byte) (n int, err error) {
	v.writeMutex.Lock()
	defer v.writeMutex.Unlock()

	v.write(p)

	return len(p), nil
}

func (v *View) write(p []byte) {
	v.tainted = true
	v.clearHover()

	// Fill with empty cells, if writing outside current view buffer
	v.makeWriteable(v.wx, v.wy)

	finishLine := func() {
		v.autoRenderHyperlinksInCurrentLine()
		if v.wx >= len(v.lines[v.wy]) {
			v.writeCells([]cell{{
				chr:     "",
				width:   0,
				fgColor: 0,
				bgColor: 0,
			}})
		}
	}

	advanceToNextLine := func() {
		v.wx = 0
		v.wy++
		if v.wy >= len(v.lines) {
			v.lines = append(v.lines, nil)
		}
	}

	if v.pendingNewline {
		advanceToNextLine()
		v.pendingNewline = false
	}

	until := len(p)
	if !v.Editable && until > 0 && p[until-1] == '\n' {
		v.pendingNewline = true
		until--
	}

	state := -1
	var chr []byte
	var width int
	remaining := p[:until]

	for len(remaining) > 0 {
		chr, remaining, width, state = uniseg.FirstGraphemeCluster(remaining, state)

		switch {
		case characterEquals(chr, '\n') || isCRLF(chr):
			finishLine()
			advanceToNextLine()
		case characterEquals(chr, '\r'):
			finishLine()
			v.wx = 0
		default:
			truncateLine, cells := v.parseInput(chr, width, v.wx, v.wy)
			if cells == nil {
				continue
			}
			v.writeCells(cells)
			if truncateLine {
				v.lines[v.wy] = v.lines[v.wy][:v.wx]
			}
		}
	}

	if v.pendingNewline {
		finishLine()
	} else {
		v.autoRenderHyperlinksInCurrentLine()
	}

	v.updateSearchPositions()
}

// exported functions use the mutex. Non-exported functions are for internal use
// and a calling function should use a mutex
func (v *View) WriteString(s string) {
	_, _ = v.Write([]byte(s))
}

func (v *View) writeString(s string) {
	v.write([]byte(s))
}

var linkStartChars = []string{"h", "t", "t", "p", "s", ":", "/", "/"}

func findLinkStart(line []cell) int {
	for i := range len(line) - len(linkStartChars) {
		for j := range linkStartChars {
			if line[i+j].chr != linkStartChars[j] {
				break
			}
			if j == len(linkStartChars)-1 {
				return i
			}
		}
	}
	return -1
}

// We need a heuristic to find the end of a hyperlink. Searching for the
// first character that is not a valid URI character is not quite good
// enough, because in markdown it's common to have a hyperlink followed by a
// ')', so we want to stop there. Hopefully URLs containing ')' are uncommon
// enough that this is not a problem.
var lineEndCharacters = map[string]bool{
	"":   true,
	" ":  true,
	"\n": true,
	">":  true,
	"\"": true,
	")":  true,
}

func (v *View) autoRenderHyperlinksInCurrentLine() {
	if !v.AutoRenderHyperLinks {
		return
	}

	line := v.lines[v.wy]
	start := 0
	for {
		linkStart := findLinkStart(line[start:])
		if linkStart == -1 {
			break
		}
		linkStart += start
		var link strings.Builder
		linkEnd := linkStart
		for ; linkEnd < len(line); linkEnd++ {
			if _, ok := lineEndCharacters[line[linkEnd].chr]; ok {
				break
			}
			link.WriteString(line[linkEnd].chr)
		}
		for i := linkStart; i < linkEnd; i++ {
			v.lines[v.wy][i].hyperlink = link.String()
		}
		start = linkEnd
	}
}

// parseInput parses char by char the input written to the View. It returns nil
// while processing ESC sequences. Otherwise, it returns a cell slice that
// contains the processed data.
func (v *View) parseInput(ch []byte, width int, x int, _ int) (bool, []cell) {
	cells := []cell{}
	truncateLine := false

	isEscape, err := v.ei.parseOne(ch)
	if err != nil {
		for _, chr := range v.ei.characters() {
			c := cell{
				fgColor: v.FgColor,
				bgColor: v.BgColor,
				chr:     chr,
				width:   uniseg.StringWidth(chr),
			}
			cells = append(cells, c)
		}
		v.ei.reset()
	} else {
		repeatCount := 1
		if _, ok := v.ei.instruction.(eraseInLineFromCursor); ok {
			// fill rest of line
			v.ei.instructionRead()
			cx := 0
			for _, cell := range v.lines[v.wy][0:v.wx] {
				cx += cell.width
			}
			repeatCount = v.InnerWidth() - cx
			ch = []byte{' '}
			width = 1
			truncateLine = true
		} else if isEscape {
			// do not output anything
			return truncateLine, nil
		} else if characterEquals(ch, '\t') {
			// fill tab-sized space
			tabWidth := v.TabWidth
			if tabWidth < 1 {
				tabWidth = 4
			}
			ch = []byte{' '}
			width = 1
			repeatCount = tabWidth - (x % tabWidth)
		}
		c := cell{
			fgColor:   v.ei.curFgColor,
			bgColor:   v.ei.curBgColor,
			hyperlink: v.ei.hyperlink.String(),
			chr:       string(ch),
			width:     width,
		}
		for range repeatCount {
			cells = append(cells, c)
		}
	}

	return truncateLine, cells
}

// Read reads data into p from the current reading position set by SetReadPos.
// It returns the number of bytes read into p.
// At EOF, err will be io.EOF.
func (v *View) Read(p []byte) (n int, err error) {
	buffer := make([]byte, utf8.UTFMax)
	offset := 0
	if v.readBuffer != nil {
		copy(p, v.readBuffer)
		if len(v.readBuffer) >= len(p) {
			if len(v.readBuffer) > len(p) {
				v.readBuffer = v.readBuffer[len(p):]
			}
			return len(p), nil
		}
		v.readBuffer = nil
	}
	for v.ry < len(v.lines) {
		for v.rx < len(v.lines[v.ry]) {
			s := v.lines[v.ry][v.rx].chr
			count := len(s)
			copy(p[offset:], s)
			v.rx++
			newOffset := offset + count
			if newOffset >= len(p) {
				if newOffset > len(p) {
					v.readBuffer = buffer[newOffset-len(p):]
				}
				return len(p), nil
			}
			offset += count
		}
		v.rx = 0
		v.ry++
	}
	return offset, io.EOF
}

// only use this if the calling function has a lock on writeMutex
func (v *View) clear() {
	v.rewind()
	v.lines = nil
	v.clearViewLines()
}

// Clear empties the view's internal buffer.
// And resets reading and writing offsets.
func (v *View) Clear() {
	v.writeMutex.Lock()
	defer v.writeMutex.Unlock()

	v.clear()
}

func (v *View) SetContent(str string) {
	v.writeMutex.Lock()
	defer v.writeMutex.Unlock()

	v.clear()
	v.writeString(str)
}

func (v *View) CopyContent(from *View) {
	v.writeMutex.Lock()
	defer v.writeMutex.Unlock()

	v.clear()

	v.lines = from.lines
	v.viewLines = from.viewLines
	v.ox = from.ox
	v.oy = from.oy
	v.cx = from.cx
	v.cy = from.cy
}

// Rewind sets read and write pos to (0, 0).
func (v *View) Rewind() {
	v.writeMutex.Lock()
	defer v.writeMutex.Unlock()

	v.rewind()
}

// similar to Rewind but clears lines. Also similar to Clear but doesn't reset
// viewLines
func (v *View) Reset() {
	v.writeMutex.Lock()
	defer v.writeMutex.Unlock()

	v.rewind()
	v.lines = nil
}

// This is for when we've done a restart for the sake of avoiding a flicker and
// we've reached the end of the new content to display: we need to clear the remaining
// content from the previous round. We do this by setting v.viewLines to nil so that
// we just render the new content from v.lines directly
func (v *View) FlushStaleCells() {
	v.writeMutex.Lock()
	defer v.writeMutex.Unlock()

	v.clearViewLines()
}

func (v *View) rewind() {
	v.ei.reset()

	v.SetReadPos(0, 0)
	v.SetWritePos(0, 0)
}

func containsUpcaseChar(str string) bool {
	for _, ch := range str {
		if unicode.IsUpper(ch) {
			return true
		}
	}

	return false
}

func stringToGraphemes(s string) []string {
	var graphemes []string
	state := -1
	for s != "" {
		var chr string
		chr, s, _, state = uniseg.FirstGraphemeClusterInString(s, state)
		graphemes = append(graphemes, chr)
	}
	return graphemes
}

func (v *View) updateSearchPositions() {
	if v.searcher.searchString != "" {
		var normalizeRune func(s string) string
		var normalizedSearchStr string
		// if we have any uppercase characters we'll do a case-sensitive search
		if containsUpcaseChar(v.searcher.searchString) {
			normalizeRune = func(s string) string { return s }
			normalizedSearchStr = v.searcher.searchString
		} else {
			normalizeRune = strings.ToLower
			normalizedSearchStr = strings.ToLower(v.searcher.searchString)
		}

		searchStrGraphemes := stringToGraphemes(normalizedSearchStr)

		v.searcher.searchPositions = []SearchPosition{}

		searchPositionsForLine := func(line []cell, y int) []SearchPosition {
			var result []SearchPosition
			searchStringWidth := uniseg.StringWidth(v.searcher.searchString)
			x := 0
			for startIdx, cell := range line {
				found := true
				for i, c := range searchStrGraphemes {
					if len(line)-1 < startIdx+i {
						found = false
						break
					}
					if normalizeRune(line[startIdx+i].chr) != c {
						found = false
						break
					}
				}
				if found {
					result = append(result, SearchPosition{XStart: x, XEnd: x + searchStringWidth, Y: y})
				}
				x += cell.width
			}
			return result
		}

		if v.searcher.modelSearchResults != nil {
			for _, result := range v.searcher.modelSearchResults {
				// This code only works when v.Wrap is false.

				if result.Y >= len(v.lines) {
					break
				}

				// If a view line exists for this line index:
				if v.lines[result.Y] != nil {
					// search this view line for the search string
					positions := searchPositionsForLine(v.lines[result.Y], result.Y)
					if len(positions) > 0 {
						// If we found any occurrences, add them
						v.searcher.searchPositions = append(v.searcher.searchPositions, positions...)
					} else {
						// Otherwise, the search string was found in the model
						// but not in the view line; this can happen if the view
						// renders only truncated versions of the model strings.
						// In this case, add one search position with what the
						// model search function returned.
						v.searcher.searchPositions = append(v.searcher.searchPositions, result)
					}
				} else {
					// We don't have a view line for this line index. Add a
					// searchPosition anyway, just for the sake of being able to
					// show the "n of m" search status. The X positions don't
					// matter in this case.
					v.searcher.searchPositions = append(v.searcher.searchPositions, SearchPosition{XStart: -1, XEnd: -1, Y: result.Y})
				}
			}
		} else {
			v.refreshViewLinesIfNeeded()
			for y, line := range v.viewLines {
				v.searcher.searchPositions = append(v.searcher.searchPositions, searchPositionsForLine(line.line, y)...)
			}
		}
	}
}

// IsTainted tells us if the view is tainted
func (v *View) IsTainted() bool {
	return v.tainted
}

// draw re-draws the view's contents.
func (v *View) draw() {
	v.writeMutex.Lock()
	defer v.writeMutex.Unlock()

	if !v.Visible {
		return
	}

	v.clearRunes()

	maxX, maxY := v.InnerSize()

	if v.Wrap {
		if maxX == 0 {
			return
		}
		v.ox = 0
	}

	v.refreshViewLinesIfNeeded()

	visibleViewLinesHeight := v.viewLineLengthIgnoringTrailingBlankLines()
	if v.Autoscroll && visibleViewLinesHeight > maxY {
		v.oy = visibleViewLinesHeight - maxY
	}

	if len(v.viewLines) == 0 {
		return
	}

	start := v.oy
	if start > len(v.viewLines)-1 {
		start = len(v.viewLines) - 1
	}

	emptyCell := cell{chr: " ", width: 1, fgColor: ColorDefault, bgColor: ColorDefault}
	var prevFgColor Attribute

	for y, vline := range v.viewLines[start:] {
		if y >= maxY {
			break
		}

		// x tracks the current x position in the view, and cellIdx tracks the
		// index of the cell. If we print a double-sized rune, we increment cellIdx
		// by one but x by two.
		x := -v.ox
		cellIdx := 0

		var c cell
		for x < maxX {
			if x < 0 {
				if cellIdx < len(vline.line) {
					x += uniseg.StringWidth(vline.line[cellIdx].chr)
					cellIdx++
					continue
				}

				// no more characters to write so we're only going to be printing empty cells
				// past this point
				x = 0
			}

			// if we're out of cells to write, we'll just print empty cells.
			if cellIdx > len(vline.line)-1 {
				c = emptyCell
				c.fgColor = prevFgColor
			} else {
				c = vline.line[cellIdx]
				// capturing previous foreground colour so that if we're using the reverse
				// attribute we honour the final character's colour and don't awkwardly switch
				// to a new background colour for the remainder of the line
				prevFgColor = c.fgColor
			}

			fgColor := c.fgColor
			if fgColor == ColorDefault {
				fgColor = v.FgColor
			}
			bgColor := c.bgColor
			if bgColor == ColorDefault {
				bgColor = v.BgColor
			}
			if c.hyperlink != "" && !v.UnderlineHyperLinksOnlyOnHover {
				fgColor |= AttrUnderline
			}

			v.setCharacter(x, y, c.chr, fgColor, bgColor)

			x += c.width
			cellIdx++
		}
	}
}

func (v *View) refreshViewLinesIfNeeded() {
	if v.tainted {
		maxX := v.InnerWidth()
		lineIdx := 0
		lines := v.lines
		for i, line := range lines {
			wrap := 0
			if v.Wrap {
				wrap = maxX
			}

			ls := lineWrap(line, wrap)
			for j := range ls {
				vline := viewLine{linesX: j, linesY: i, line: ls[j]}

				if lineIdx > len(v.viewLines)-1 {
					v.viewLines = append(v.viewLines, vline)
				} else {
					v.viewLines[lineIdx] = vline
				}
				lineIdx++
			}
		}
		v.tainted = false
	}
}

// if autoscroll is enabled but we only have a single row of cells shown to the
// user, we don't want to scroll to the final line if it contains no text. So
// this tells us the view lines height when we ignore any trailing blank lines
func (v *View) viewLineLengthIgnoringTrailingBlankLines() int {
	for i := len(v.viewLines) - 1; i >= 0; i-- {
		if len(v.viewLines[i].line) > 0 {
			return i + 1
		}
	}
	return 0
}

func (v *View) isPatternMatchedRune(x, y int) (bool, bool) {
	for i, pos := range v.searcher.searchPositions {
		adjustedY := y + v.oy
		adjustedX := x + v.ox
		if adjustedY == pos.Y && adjustedX >= pos.XStart && adjustedX < pos.XEnd {
			return true, i == v.searcher.currentSearchIndex
		}
	}
	return false, false
}

func (v *View) isHoveredHyperlink(x, y int) bool {
	if v.UnderlineHyperLinksOnlyOnHover && v.hoveredHyperlink != nil {
		adjustedY := y + v.oy
		adjustedX := x + v.ox
		return adjustedY == v.hoveredHyperlink.Y && adjustedX >= v.hoveredHyperlink.XStart && adjustedX < v.hoveredHyperlink.XEnd
	}
	return false
}

// realPosition returns the position in the internal buffer corresponding to the
// point (x, y) of the view.
func (v *View) realPosition(vx, vy int) (x, y int, ok bool) {
	vx = v.ox + vx
	vy = v.oy + vy

	if vx < 0 || vy < 0 {
		return 0, 0, false
	}

	if len(v.viewLines) == 0 {
		return vx, vy, true
	}

	if vy < len(v.viewLines) {
		vline := v.viewLines[vy]
		x = vline.linesX + vx
		y = vline.linesY
	} else {
		vline := v.viewLines[len(v.viewLines)-1]
		x = vx
		y = vline.linesY + vy - len(v.viewLines) + 1
	}

	return x, y, true
}

// clearRunes erases all the cells in the view.
func (v *View) clearRunes() {
	maxX, maxY := v.InnerSize()
	for x := range maxX {
		for y := range maxY {
			tcellSetCell(v.x0+x+1, v.y0+y+1, " ", v.FgColor, v.BgColor, v.outMode)
		}
	}
}

// BufferLines returns the lines in the view's internal
// buffer.
func (v *View) BufferLines() []string {
	v.writeMutex.Lock()
	defer v.writeMutex.Unlock()

	lines := make([]string, len(v.lines))
	for i, l := range v.lines {
		str := lineType(l).String()
		str = strings.ReplaceAll(str, "\x00", "")
		lines[i] = str
	}
	return lines
}

// Buffer returns a string with the contents of the view's internal
// buffer.
func (v *View) Buffer() string {
	return linesToString(v.lines)
}

// ViewBufferLines returns the lines in the view's internal
// buffer that is shown to the user.
func (v *View) ViewBufferLines() []string {
	v.writeMutex.Lock()
	defer v.writeMutex.Unlock()

	v.refreshViewLinesIfNeeded()

	lines := make([]string, len(v.viewLines))
	for i, l := range v.viewLines {
		str := lineType(l.line).String()
		str = strings.ReplaceAll(str, "\x00", "")
		lines[i] = str
	}
	return lines
}

// LinesHeight is the count of view lines (i.e. lines excluding wrapping)
func (v *View) LinesHeight() int {
	return len(v.lines)
}

// ViewLinesHeight is the count of view lines (i.e. lines including wrapping)
func (v *View) ViewLinesHeight() int {
	v.writeMutex.Lock()
	defer v.writeMutex.Unlock()

	v.refreshViewLinesIfNeeded()
	return len(v.viewLines)
}

// ViewBuffer returns a string with the contents of the view's buffer that is
// shown to the user.
func (v *View) ViewBuffer() string {
	lines := make([][]cell, len(v.viewLines))
	for i := range v.viewLines {
		lines[i] = v.viewLines[i].line
	}

	return linesToString(lines)
}

// Line returns a string with the line of the view's internal buffer
// at the position corresponding to the point (x, y).
func (v *View) Line(y int) (string, bool) {
	_, y, ok := v.realPosition(0, y)
	if !ok {
		return "", false
	}

	if y < 0 || y >= len(v.lines) {
		return "", false
	}

	return lineType(v.lines[y]).String(), true
}

// Word returns a string with the word of the view's internal buffer
// at the position corresponding to the point (x, y).
func (v *View) Word(x, y int) (string, bool) {
	x, y, ok := v.realPosition(x, y)
	if !ok {
		return "", false
	}

	if x < 0 || y < 0 || y >= len(v.lines) || x >= len(v.lines[y]) {
		return "", false
	}

	str := lineType(v.lines[y]).String()

	nl := strings.LastIndexFunc(str[:x], indexFunc)
	if nl == -1 {
		nl = 0
	} else {
		nl = nl + 1
	}
	nr := strings.IndexFunc(str[x:], indexFunc)
	if nr == -1 {
		nr = len(str)
	} else {
		nr = nr + x
	}
	return str[nl:nr], true
}

// indexFunc allows to split lines by words taking into account spaces
// and 0.
func indexFunc(r rune) bool {
	return r == ' ' || r == 0
}

// SetHighlight toggles highlighting of separate lines, for custom lists
// or multiple selection in views.
func (v *View) SetHighlight(y int, on bool) {
	if y < 0 || y >= len(v.lines) {
		return
	}

	line := v.lines[y]
	cells := make([]cell, 0)
	for _, c := range line {
		if on {
			c.bgColor = v.SelBgColor
			c.fgColor = v.SelFgColor
		} else {
			c.bgColor = v.BgColor
			c.fgColor = v.FgColor
		}
		cells = append(cells, c)
	}
	v.tainted = true
	v.lines[y] = cells
	v.clearHover()
}

func lineWrap(line []cell, columns int) [][]cell {
	if columns == 0 {
		return [][]cell{line}
	}

	var n int
	var offset int
	lastWhitespaceIndex := -1
	lines := make([][]cell, 0, 1)
	for i := range line {
		currChr := line[i].chr
		rw := uniseg.StringWidth(currChr)
		n += rw
		// if currChr == 'g' {
		// 	panic(n)
		// }
		if n > columns {
			// This code is convoluted but we've got comprehensive tests so feel free to do whatever you want
			// to the code to simplify it so long as our tests still pass.
			if currChr == " " {
				// if the line ends in a space, we'll omit it. This means there'll be no
				// way to distinguish between a clean break and a mid-word break, but
				// I think it's worth it.
				lines = append(lines, line[offset:i])
				offset = i + 1
				n = 0
			} else if currChr == "-" {
				// if the last character is hyphen and the width of line is equal to the columns
				lines = append(lines, line[offset:i])
				offset = i
				n = rw
			} else if lastWhitespaceIndex != -1 {
				// if there is a space in the line and the line is not breaking at a space/hyphen
				if line[lastWhitespaceIndex].chr == "-" {
					// if break occurs at hyphen, we'll retain the hyphen
					lines = append(lines, line[offset:lastWhitespaceIndex+1])
				} else {
					// if break occurs at space, we'll omit the space
					lines = append(lines, line[offset:lastWhitespaceIndex])
				}
				// Either way, continue *after* the break
				offset = lastWhitespaceIndex + 1
				n = 0
				for _, c := range line[offset : i+1] {
					n += c.width
				}
			} else {
				// in this case we're breaking mid-word
				lines = append(lines, line[offset:i])
				offset = i
				n = rw
			}
			lastWhitespaceIndex = -1
		} else if line[i].chr == " " || line[i].chr == "-" {
			lastWhitespaceIndex = i
		}
	}

	lines = append(lines, line[offset:])
	return lines
}

func linesToString(lines [][]cell) string {
	str := make([]string, len(lines))
	for i := range lines {
		rns := make([]rune, 0, len(lines[i]))
		line := lineType(lines[i]).String()
		for _, c := range line {
			if c != '\x00' {
				rns = append(rns, c)
			}
		}
		str[i] = string(rns)
	}

	return strings.Join(str, "\n")
}

// GetClickedTabIndex tells us which tab was clicked
func (v *View) GetClickedTabIndex(x int) int {
	if len(v.Tabs) <= 1 {
		return 0
	}

	charX := len(v.TitlePrefix) + 1
	if v.TitlePrefix != "" {
		charX += 1
	}
	if x <= charX {
		return -1
	}
	for i, tab := range v.Tabs {
		charX += uniseg.StringWidth(tab)
		if x <= charX {
			return i
		}
		charX += uniseg.StringWidth(" - ")
		if x <= charX {
			return -1
		}
	}

	return -1
}

func (v *View) SelectedLineIdx() int {
	_, seletedLineIdx := v.SelectedPoint()
	return seletedLineIdx
}

// expected to only be used in tests
func (v *View) SelectedLine() string {
	v.writeMutex.Lock()
	defer v.writeMutex.Unlock()

	if len(v.lines) == 0 {
		return ""
	}

	return v.lineContentAtIdx(v.SelectedLineIdx())
}

// expected to only be used in tests
func (v *View) SelectedLines() []string {
	v.writeMutex.Lock()
	defer v.writeMutex.Unlock()

	if len(v.lines) == 0 {
		return nil
	}

	startIdx, endIdx := v.SelectedLineRange()

	lines := make([]string, 0, endIdx-startIdx+1)
	for i := startIdx; i <= endIdx; i++ {
		lines = append(lines, v.lineContentAtIdx(i))
	}

	return lines
}

func (v *View) lineContentAtIdx(idx int) string {
	line := v.lines[idx]
	str := lineType(line).String()
	return strings.ReplaceAll(str, "\x00", "")
}

func (v *View) SelectedPoint() (int, int) {
	cx, cy := v.Cursor()
	ox, oy := v.Origin()
	return cx + ox, cy + oy
}

func (v *View) SelectedLineRange() (int, int) {
	_, cy := v.Cursor()
	_, oy := v.Origin()

	start := cy + oy

	if v.rangeSelectStartY == -1 {
		return start, start
	}

	end := v.rangeSelectStartY

	if start > end {
		return end, start
	}

	return start, end
}

func (v *View) RenderTextArea() {
	v.Clear()
	fmt.Fprint(v, v.TextArea.GetContent())
	cursorX, cursorY := v.TextArea.GetCursorXY()
	prevOriginX, prevOriginY := v.Origin()
	width, height := v.InnerWidth(), v.InnerHeight()

	newViewCursorX, newOriginX := updatedCursorAndOrigin(prevOriginX, width, cursorX)
	newViewCursorY, newOriginY := updatedCursorAndOrigin(prevOriginY, height, cursorY)

	v.SetCursor(newViewCursorX, newViewCursorY)
	v.SetOrigin(newOriginX, newOriginY)
}

func updatedCursorAndOrigin(prevOrigin int, size int, cursor int) (int, int) {
	var newViewCursor int
	newOrigin := prevOrigin
	usableSize := size - 1

	if cursor > prevOrigin+usableSize {
		newOrigin = cursor - usableSize
		newViewCursor = usableSize
	} else if cursor < prevOrigin {
		newOrigin = cursor
		newViewCursor = 0
	} else {
		newViewCursor = cursor - prevOrigin
	}

	return newViewCursor, newOrigin
}

func (v *View) ClearTextArea() {
	v.Clear()

	v.writeMutex.Lock()
	defer v.writeMutex.Unlock()

	v.TextArea.Clear()
	v.SetOrigin(0, 0)
	v.SetCursor(0, 0)
}

func (v *View) overwriteLines(y int, content string) {
	// break by newline, then for each line, write it, then add that erase command
	v.wx = 0
	v.wy = y
	v.clearViewLines()

	lines := strings.ReplaceAll(content, "\n", "\x1b[K\n")
	// If the last line doesn't end with a linefeed, add the erase command at
	// the end too
	if !strings.HasSuffix(lines, "\n") {
		lines += "\x1b[K"
	}
	v.writeString(lines)
}

// only call this function if you don't care where v.wx and v.wy end up
func (v *View) OverwriteLines(y int, content string) {
	v.writeMutex.Lock()
	defer v.writeMutex.Unlock()

	v.overwriteLines(y, content)
}

// only call this function if you don't care where v.wx and v.wy end up
func (v *View) OverwriteLinesAndClearEverythingElse(lineCount int, y int, content string) {
	v.writeMutex.Lock()
	defer v.writeMutex.Unlock()

	v.setContentLineCount(lineCount)

	v.overwriteLines(y, content)

	for i := range y {
		v.lines[i] = nil
	}

	for i := v.wy + 1; i < len(v.lines); i += 1 {
		v.lines[i] = nil
	}
}

func (v *View) setContentLineCount(lineCount int) {
	if lineCount > 0 {
		v.makeWriteable(0, lineCount-1)
	}
	v.lines = v.lines[:lineCount]
}

// If the current search result is no longer visible after a scroll up, select the last search
// result that is visible in the view, if any, or the first one that is below the view if none is
// visible.
func (v *View) selectVisibleSearchResultAfterScrollUp() {
	if !v.Highlight && len(v.searcher.searchPositions) != 0 {
		windowBottom := v.oy + v.InnerHeight()
		if v.searcher.searchPositions[v.searcher.currentSearchIndex].Y >= windowBottom {
			newSearchIndex := v.searcher.currentSearchIndex
			for newSearchIndex > 0 &&
				v.searcher.searchPositions[newSearchIndex-1].Y >= v.oy {
				newSearchIndex--
				if v.searcher.searchPositions[newSearchIndex].Y < windowBottom {
					break
				}
			}
			if v.searcher.currentSearchIndex != newSearchIndex {
				v.searcher.currentSearchIndex = newSearchIndex
				v.renderSearchStatus(newSearchIndex, len(v.searcher.searchPositions))
			}
		}
	}
}

// If the current search result is no longer visible after a scroll down, select the first search
// result that is visible in the view, if any, or the last one that is above the view if none is
// visible.
func (v *View) selectVisibleSearchResultAfterScrollDown() {
	if !v.Highlight && len(v.searcher.searchPositions) != 0 {
		if v.searcher.searchPositions[v.searcher.currentSearchIndex].Y < v.oy {
			newSearchIndex := v.searcher.currentSearchIndex
			windowBottom := v.oy + v.InnerHeight()
			for newSearchIndex+1 < len(v.searcher.searchPositions) &&
				v.searcher.searchPositions[newSearchIndex+1].Y < windowBottom {
				newSearchIndex++
				if v.searcher.searchPositions[newSearchIndex].Y >= v.oy {
					break
				}
			}
			if v.searcher.currentSearchIndex != newSearchIndex {
				v.searcher.currentSearchIndex = newSearchIndex
				v.renderSearchStatus(newSearchIndex, len(v.searcher.searchPositions))
			}
		}
	}
}

func (v *View) ScrollUp(amount int) {
	if amount > v.oy {
		amount = v.oy
	}

	if amount != 0 {
		v.oy -= amount
		v.cy += amount

		v.clearHover()
		v.selectVisibleSearchResultAfterScrollUp()
	}
}

// ensures we don't scroll past the end of the view's content
func (v *View) ScrollDown(amount int) {
	adjustedAmount := v.adjustDownwardScrollAmount(amount)
	if adjustedAmount > 0 {
		v.oy += adjustedAmount
		v.cy -= adjustedAmount

		v.clearHover()
		v.selectVisibleSearchResultAfterScrollDown()
	}
}

func (v *View) ScrollLeft(amount int) {
	newOx := v.ox - amount
	if newOx < 0 {
		newOx = 0
	}
	if newOx != v.ox {
		v.ox = newOx

		v.clearHover()
	}
}

// not applying any limits to this
func (v *View) ScrollRight(amount int) {
	v.ox += amount

	v.clearHover()
}

func (v *View) adjustDownwardScrollAmount(scrollHeight int) int {
	_, oy := v.Origin()
	y := oy
	if !v.CanScrollPastBottom {
		sy := v.InnerHeight()
		y += sy
	}
	scrollableLines := v.ViewLinesHeight() - y
	if scrollableLines < 0 {
		return 0
	}

	margin := v.scrollMargin()
	if scrollableLines-margin < scrollHeight {
		scrollHeight = scrollableLines - margin
	}
	if oy+scrollHeight < 0 {
		return 0
	}

	return scrollHeight
}

// scrollMargin is about how many lines must still appear if you scroll
// all the way down. We'll subtract this from the total amount of scrollable lines
func (v *View) scrollMargin() int {
	if v.CanScrollPastBottom {
		// Setting to 2 because of the newline at the end of the file that we're likely showing.
		// If we want to scroll past bottom outside the context of reading a file's contents,
		// we should make this into a field on the view to be configured by the client.
		// For now we're hardcoding it.
		return 2
	}

	return 0
}

// Returns true if the view contains a line containing the given text with the given
// foreground color
func (v *View) ContainsColoredText(fgColor string, text string) bool {
	for _, line := range v.lines {
		if containsColoredTextInLine(fgColor, text, line) {
			return true
		}
	}

	return false
}

func containsColoredTextInLine(fgColorStr string, text string, line []cell) bool {
	fgColor := tcell.GetColor(fgColorStr)

	currentMatch := ""
	for i := range line {
		cell := line[i]

		// stripping attributes by converting to and from hex
		cellColor := tcell.NewHexColor(cell.fgColor.Hex())

		if cellColor == fgColor {
			currentMatch += cell.chr
		} else if currentMatch != "" {
			if strings.Contains(currentMatch, text) {
				return true
			}
			currentMatch = ""
		}
	}

	return strings.Contains(currentMatch, text)
}

func (v *View) onMouseMove(x int, y int) {
	if v.Editable || !v.UnderlineHyperLinksOnlyOnHover {
		return
	}

	// newCx and newCy are relative to the view port, i.e. to the visible area of the view
	newCx := x - v.x0 - 1
	newCy := y - v.y0 - 1
	// newX and newY are relative to the view's content, independent of its scroll position
	newX := newCx + v.ox
	newY := newCy + v.oy

	if newY >= 0 && newY <= len(v.viewLines)-1 && newX >= 0 && newX <= len(v.viewLines[newY].line)-1 {
		if v.lastHoverPosition == nil || v.lastHoverPosition.x != newX || v.lastHoverPosition.y != newY {
			v.hoveredHyperlink = v.findHyperlinkAt(newX, newY)
		}
		v.lastHoverPosition = &pos{x: newX, y: newY}
	} else {
		v.lastHoverPosition = nil
		v.hoveredHyperlink = nil
	}
}

func (v *View) findHyperlinkAt(x, y int) *SearchPosition {
	linkStr := v.viewLines[y].line[x].hyperlink
	if linkStr == "" {
		return nil
	}

	xStart := x
	for xStart > 0 && v.viewLines[y].line[xStart-1].hyperlink == linkStr {
		xStart--
	}
	xEnd := x + 1
	for xEnd < len(v.viewLines[y].line) && v.viewLines[y].line[xEnd].hyperlink == linkStr {
		xEnd++
	}

	return &SearchPosition{XStart: xStart, XEnd: xEnd, Y: y}
}

func (v *View) clearHover() {
	v.hoveredHyperlink = nil
	v.lastHoverPosition = nil
}
