// SPDX-License-Identifier: Unlicense OR MIT

package layout

import (
	"image"
	"math"

	"gioui.org/gesture"
	"gioui.org/io/pointer"
	"gioui.org/op"
	"gioui.org/op/clip"
)

type scrollChild struct {
	size image.Point
	call op.CallOp
}

// List displays a subsection of a potentially infinitely
// large underlying list. List accepts user input to scroll
// the subsection.
type List struct {
	Axis Axis
	// ScrollToEnd instructs the list to stay scrolled to the far end position
	// once reached. A List with ScrollToEnd == true and Position.BeforeEnd ==
	// false draws its content with the last item at the bottom of the list
	// area.
	ScrollToEnd bool
	// Alignment is the cross axis alignment of list elements.
	Alignment Alignment

	cs          Constraints
	scroll      gesture.Scroll
	scrollDelta int

	// Position is updated during Layout. To save the list scroll position,
	// just save Position after Layout finishes. To scroll the list
	// programmatically, update Position (e.g. restore it from a saved value)
	// before calling Layout.
	Position Position

	len int

	// maxSize is the total size of visible children.
	maxSize  int
	children []scrollChild
	dir      iterationDir
}

// ListElement is a function that computes the dimensions of
// a list element.
type ListElement func(gtx Context, index int) Dimensions

type iterationDir uint8

// Position is a List scroll offset represented as an offset from the top edge
// of a child element.
type Position struct {
	// BeforeEnd tracks whether the List position is before the very end. We
	// use "before end" instead of "at end" so that the zero value of a
	// Position struct is useful.
	//
	// When laying out a list, if ScrollToEnd is true and BeforeEnd is false,
	// then First and Offset are ignored, and the list is drawn with the last
	// item at the bottom. If ScrollToEnd is false then BeforeEnd is ignored.
	BeforeEnd bool
	// First is the index of the first visible child.
	First int
	// Offset is the distance in pixels from the leading edge to the child at index
	// First.
	Offset int
	// OffsetLast is the signed distance in pixels from the trailing edge to the
	// bottom edge of the child at index First+Count.
	OffsetLast int
	// Count is the number of visible children.
	Count int
	// Length is the estimated total size of all children, measured in pixels.
	Length int
}

const (
	iterateNone iterationDir = iota
	iterateForward
	iterateBackward
)

const inf = 1e6

// init prepares the list for iterating through its children with next.
func (l *List) init(gtx Context, len int) {
	if l.more() {
		panic("unfinished child")
	}
	l.cs = gtx.Constraints
	l.maxSize = 0
	l.children = l.children[:0]
	l.len = len
	l.update(gtx)
	if l.Position.First < 0 {
		l.Position.Offset = 0
		l.Position.First = 0
	}
	if l.scrollToEnd() || l.Position.First > len {
		l.Position.Offset = 0
		l.Position.First = len
	}
}

// Layout a List of len items, where each item is implicitly defined
// by the callback w. Layout can handle very large lists because it only calls
// w to fill its viewport and the distance scrolled, if any.
func (l *List) Layout(gtx Context, len int, w ListElement) Dimensions {
	l.init(gtx, len)
	crossMin, crossMax := l.Axis.crossConstraint(gtx.Constraints)
	gtx.Constraints = l.Axis.constraints(0, inf, crossMin, crossMax)
	macro := op.Record(gtx.Ops)
	laidOutTotalLength := 0
	numLaidOut := 0

	for l.next(); l.more(); l.next() {
		child := op.Record(gtx.Ops)
		dims := w(gtx, l.index())
		call := child.Stop()
		l.end(dims, call)
		laidOutTotalLength += l.Axis.Convert(dims.Size).X
		numLaidOut++
	}

	if numLaidOut > 0 {
		l.Position.Length = laidOutTotalLength * len / numLaidOut
	} else {
		l.Position.Length = 0
	}
	return l.layout(gtx.Ops, macro)
}

func (l *List) scrollToEnd() bool {
	return l.ScrollToEnd && !l.Position.BeforeEnd
}

// Dragging reports whether the List is being dragged.
func (l *List) Dragging() bool {
	return l.scroll.State() == gesture.StateDragging
}

func (l *List) update(gtx Context) {
	min, max := int(-inf), int(inf)
	if l.Position.First == 0 {
		// Use the size of the invisible part as scroll boundary.
		min = -l.Position.Offset
		if min > 0 {
			min = 0
		}
	}
	if l.Position.First+l.Position.Count == l.len {
		max = -l.Position.OffsetLast
		if max < 0 {
			max = 0
		}
	}
	xrange := pointer.ScrollRange{Min: min, Max: max}
	yrange := pointer.ScrollRange{}
	if l.Axis == Vertical {
		xrange, yrange = yrange, xrange
	}
	d := l.scroll.Update(gtx.Metric, gtx.Source, gtx.Now, gesture.Axis(l.Axis), xrange, yrange)
	l.scrollDelta = d
	l.Position.Offset += d
}

// next advances to the next child.
func (l *List) next() {
	l.dir = l.nextDir()
	// The user scroll offset is applied after scrolling to
	// list end.
	if l.scrollToEnd() && !l.more() && l.scrollDelta < 0 {
		l.Position.BeforeEnd = true
		l.Position.Offset += l.scrollDelta
		l.dir = l.nextDir()
	}
}

// index is current child's position in the underlying list.
func (l *List) index() int {
	switch l.dir {
	case iterateBackward:
		return l.Position.First - 1
	case iterateForward:
		return l.Position.First + len(l.children)
	default:
		panic("Index called before Next")
	}
}

// more reports whether more children are needed.
func (l *List) more() bool {
	return l.dir != iterateNone
}

func (l *List) nextDir() iterationDir {
	_, vsize := l.Axis.mainConstraint(l.cs)
	last := l.Position.First + len(l.children)
	// Clamp offset.
	if l.maxSize-l.Position.Offset < vsize && last == l.len {
		l.Position.Offset = l.maxSize - vsize
	}
	if l.Position.Offset < 0 && l.Position.First == 0 {
		l.Position.Offset = 0
	}
	// Lay out an extra (invisible) child at each end to enable focus to
	// move to them, triggering automatic scroll.
	firstSize, lastSize := 0, 0
	if len(l.children) > 0 {
		if l.Position.First > 0 {
			firstChild := l.children[0]
			firstSize = l.Axis.Convert(firstChild.size).X
		}
		if last < l.len {
			lastChild := l.children[len(l.children)-1]
			lastSize = l.Axis.Convert(lastChild.size).X
		}
	}
	switch {
	case len(l.children) == l.len:
		return iterateNone
	case l.maxSize-l.Position.Offset-lastSize < vsize:
		return iterateForward
	case l.Position.Offset-firstSize < 0:
		return iterateBackward
	}
	return iterateNone
}

// End the current child by specifying its dimensions.
func (l *List) end(dims Dimensions, call op.CallOp) {
	child := scrollChild{dims.Size, call}
	mainSize := l.Axis.Convert(child.size).X
	l.maxSize += mainSize
	switch l.dir {
	case iterateForward:
		l.children = append(l.children, child)
	case iterateBackward:
		l.children = append(l.children, scrollChild{})
		copy(l.children[1:], l.children)
		l.children[0] = child
		l.Position.First--
		l.Position.Offset += mainSize
	default:
		panic("call Next before End")
	}
	l.dir = iterateNone
}

// Layout the List and return its dimensions.
func (l *List) layout(ops *op.Ops, macro op.MacroOp) Dimensions {
	if l.more() {
		panic("unfinished child")
	}
	mainMin, mainMax := l.Axis.mainConstraint(l.cs)
	children := l.children
	var first scrollChild
	// Skip invisible children.
	for len(children) > 0 {
		child := children[0]
		sz := child.size
		mainSize := l.Axis.Convert(sz).X
		if l.Position.Offset < mainSize {
			// First child is partially visible.
			break
		}
		l.Position.First++
		l.Position.Offset -= mainSize
		first = child
		children = children[1:]
	}
	size := -l.Position.Offset
	var maxCross int
	var last scrollChild
	for i, child := range children {
		sz := l.Axis.Convert(child.size)
		if c := sz.Y; c > maxCross {
			maxCross = c
		}
		size += sz.X
		if size >= mainMax {
			if i < len(children)-1 {
				last = children[i+1]
			}
			children = children[:i+1]
			break
		}
	}
	l.Position.Count = len(children)
	l.Position.OffsetLast = mainMax - size
	// ScrollToEnd lists are end aligned.
	if space := l.Position.OffsetLast; l.ScrollToEnd && space > 0 {
		l.Position.Offset -= space
	}
	pos := -l.Position.Offset
	layout := func(child scrollChild) {
		sz := l.Axis.Convert(child.size)
		var cross int
		switch l.Alignment {
		case End:
			cross = maxCross - sz.Y
		case Middle:
			cross = (maxCross - sz.Y) / 2
		}
		childSize := sz.X
		min := pos
		if min < 0 {
			min = 0
		}
		pt := l.Axis.Convert(image.Pt(pos, cross))
		trans := op.Offset(pt).Push(ops)
		child.call.Add(ops)
		trans.Pop()
		pos += childSize
	}
	// Lay out leading invisible child.
	if first != (scrollChild{}) {
		sz := l.Axis.Convert(first.size)
		pos -= sz.X
		layout(first)
	}
	for _, child := range children {
		layout(child)
	}
	// Lay out trailing invisible child.
	if last != (scrollChild{}) {
		layout(last)
	}
	atStart := l.Position.First == 0 && l.Position.Offset <= 0
	atEnd := l.Position.First+len(children) == l.len && mainMax >= pos
	if atStart && l.scrollDelta < 0 || atEnd && l.scrollDelta > 0 {
		l.scroll.Stop()
	}
	l.Position.BeforeEnd = !atEnd
	if pos < mainMin {
		pos = mainMin
	}
	if pos > mainMax {
		pos = mainMax
	}
	if crossMin, crossMax := l.Axis.crossConstraint(l.cs); maxCross < crossMin {
		maxCross = crossMin
	} else if maxCross > crossMax {
		maxCross = crossMax
	}
	dims := l.Axis.Convert(image.Pt(pos, maxCross))
	call := macro.Stop()
	defer clip.Rect(image.Rectangle{Max: dims}).Push(ops).Pop()

	l.scroll.Add(ops)

	call.Add(ops)
	return Dimensions{Size: dims}
}

// ScrollBy scrolls the list by a relative amount of items.
//
// Fractional scrolling may be inaccurate for items of differing
// dimensions. This includes scrolling by integer amounts if the current
// l.Position.Offset is non-zero.
func (l *List) ScrollBy(num float32) {
	// Split number of items into integer and fractional parts
	i, f := math.Modf(float64(num))

	// Scroll by integer amount of items
	l.Position.First += int(i)

	// Adjust Offset to account for fractional items. If Offset gets so large that it amounts to an entire item, then
	// the layout code will handle that for us and adjust First and Offset accordingly.
	itemHeight := float64(l.Position.Length) / float64(l.len)
	l.Position.Offset += int(math.Round(itemHeight * f))

	// First and Offset can go out of bounds, but the layout code knows how to handle that.

	// Ensure that the list pays attention to the Offset field when the scrollbar drag
	// is started while the bar is at the end of the list. Without this, the scrollbar
	// cannot be dragged away from the end.
	l.Position.BeforeEnd = true
}

// ScrollTo scrolls to the specified item.
func (l *List) ScrollTo(n int) {
	l.Position.First = n
	l.Position.Offset = 0
	l.Position.BeforeEnd = true
}
