package widget

import (
	"fyne.io/fyne/v2"
	"fyne.io/fyne/v2/canvas"
	"fyne.io/fyne/v2/driver/desktop"
	"fyne.io/fyne/v2/internal/cache"
	"fyne.io/fyne/v2/theme"
)

// ScrollDirection represents the directions in which a Scroll can scroll its child content.
type ScrollDirection int

// Constants for valid values of ScrollDirection.
const (
	// ScrollBoth supports horizontal and vertical scrolling.
	ScrollBoth ScrollDirection = iota
	// ScrollHorizontalOnly specifies the scrolling should only happen left to right.
	ScrollHorizontalOnly
	// ScrollVerticalOnly specifies the scrolling should only happen top to bottom.
	ScrollVerticalOnly
	// ScrollNone turns off scrolling for this container.
	//
	// Since: 2.0
	ScrollNone
)

type scrollBarOrientation int

// We default to vertical as 0 due to that being the original orientation offered
const (
	scrollBarOrientationVertical   scrollBarOrientation = 0
	scrollBarOrientationHorizontal scrollBarOrientation = 1
	scrollContainerMinSize                              = float32(32) // TODO consider the smallest useful scroll view?
)

type scrollBarRenderer struct {
	BaseRenderer
	scrollBar  *scrollBar
	background *canvas.Rectangle
	minSize    fyne.Size
}

func (r *scrollBarRenderer) Layout(size fyne.Size) {
	r.background.Resize(size)
}

func (r *scrollBarRenderer) MinSize() fyne.Size {
	return r.minSize
}

func (r *scrollBarRenderer) Refresh() {
	th := theme.CurrentForWidget(r.scrollBar)
	v := fyne.CurrentApp().Settings().ThemeVariant()

	r.background.FillColor = th.Color(theme.ColorNameScrollBar, v)
	r.background.CornerRadius = th.Size(theme.SizeNameScrollBarRadius)
	r.background.Refresh()
}

var _ desktop.Hoverable = (*scrollBar)(nil)
var _ fyne.Draggable = (*scrollBar)(nil)

type scrollBar struct {
	Base
	area            *scrollBarArea
	draggedDistance float32
	dragStart       float32
	orientation     scrollBarOrientation
}

func (b *scrollBar) CreateRenderer() fyne.WidgetRenderer {
	th := theme.CurrentForWidget(b)
	v := fyne.CurrentApp().Settings().ThemeVariant()

	background := canvas.NewRectangle(th.Color(theme.ColorNameScrollBar, v))
	background.CornerRadius = th.Size(theme.SizeNameScrollBarRadius)
	r := &scrollBarRenderer{
		scrollBar:  b,
		background: background,
	}
	r.SetObjects([]fyne.CanvasObject{background})
	return r
}

func (b *scrollBar) Cursor() desktop.Cursor {
	return desktop.DefaultCursor
}

func (b *scrollBar) DragEnd() {
	b.area.isDragging = false

	if fyne.CurrentDevice().IsMobile() {
		b.area.MouseOut()
		return
	}
	b.area.Refresh()
}

func (b *scrollBar) Dragged(e *fyne.DragEvent) {
	if !b.area.isDragging {
		b.area.isDragging = true
		b.area.MouseIn(nil)

		switch b.orientation {
		case scrollBarOrientationHorizontal:
			b.dragStart = b.Position().X
		case scrollBarOrientationVertical:
			b.dragStart = b.Position().Y
		}
		b.draggedDistance = 0
	}

	switch b.orientation {
	case scrollBarOrientationHorizontal:
		b.draggedDistance += e.Dragged.DX
	case scrollBarOrientationVertical:
		b.draggedDistance += e.Dragged.DY
	}
	b.area.moveBar(b.draggedDistance+b.dragStart, b.Size())
}

func (b *scrollBar) MouseIn(e *desktop.MouseEvent) {
	b.area.MouseIn(e)
}

func (b *scrollBar) MouseMoved(*desktop.MouseEvent) {
}

func (b *scrollBar) MouseOut() {
	b.area.MouseOut()
}

func newScrollBar(area *scrollBarArea) *scrollBar {
	b := &scrollBar{area: area, orientation: area.orientation}
	b.ExtendBaseWidget(b)
	return b
}

func (a *scrollBarArea) isLarge() bool {
	return a.isMouseIn || a.isDragging
}

type scrollBarAreaRenderer struct {
	BaseRenderer
	area *scrollBarArea
	bar  *scrollBar
}

func (r *scrollBarAreaRenderer) Layout(_ fyne.Size) {
	var barHeight, barWidth, barX, barY float32
	switch r.area.orientation {
	case scrollBarOrientationHorizontal:
		barWidth, barHeight, barX, barY = r.barSizeAndOffset(r.area.scroll.Offset.X, r.area.scroll.Content.Size().Width, r.area.scroll.Size().Width)
	default:
		barHeight, barWidth, barY, barX = r.barSizeAndOffset(r.area.scroll.Offset.Y, r.area.scroll.Content.Size().Height, r.area.scroll.Size().Height)
	}
	r.bar.Move(fyne.NewPos(barX, barY))
	r.bar.Resize(fyne.NewSize(barWidth, barHeight))
}

func (r *scrollBarAreaRenderer) MinSize() fyne.Size {
	th := theme.CurrentForWidget(r.area)

	barSize := th.Size(theme.SizeNameScrollBar)
	min := barSize
	if !r.area.isLarge() {
		min = th.Size(theme.SizeNameScrollBarSmall) * 2
	}
	switch r.area.orientation {
	case scrollBarOrientationHorizontal:
		return fyne.NewSize(barSize, min)
	default:
		return fyne.NewSize(min, barSize)
	}
}

func (r *scrollBarAreaRenderer) Refresh() {
	r.bar.Refresh()
	r.Layout(r.area.Size())
	canvas.Refresh(r.bar)
}

func (r *scrollBarAreaRenderer) barSizeAndOffset(contentOffset, contentLength, scrollLength float32) (length, width, lengthOffset, widthOffset float32) {
	th := theme.CurrentForWidget(r.area)

	scrollBarSize := th.Size(theme.SizeNameScrollBar)
	if scrollLength < contentLength {
		portion := scrollLength / contentLength
		length = float32(int(scrollLength)) * portion
		length = fyne.Max(length, scrollBarSize)
	} else {
		length = scrollLength
	}
	if contentOffset != 0 {
		lengthOffset = (scrollLength - length) * (contentOffset / (contentLength - scrollLength))
	}
	if r.area.isLarge() {
		width = scrollBarSize
	} else {
		widthOffset = th.Size(theme.SizeNameScrollBarSmall)
		width = widthOffset
	}
	return
}

var _ desktop.Hoverable = (*scrollBarArea)(nil)

type scrollBarArea struct {
	Base

	isDragging  bool
	isMouseIn   bool
	scroll      *Scroll
	orientation scrollBarOrientation
}

func (a *scrollBarArea) CreateRenderer() fyne.WidgetRenderer {
	bar := newScrollBar(a)
	return &scrollBarAreaRenderer{BaseRenderer: NewBaseRenderer([]fyne.CanvasObject{bar}), area: a, bar: bar}
}

func (a *scrollBarArea) MouseIn(*desktop.MouseEvent) {
	a.isMouseIn = true
	a.scroll.Refresh()
}

func (a *scrollBarArea) MouseMoved(*desktop.MouseEvent) {
}

func (a *scrollBarArea) MouseOut() {
	a.isMouseIn = false
	if a.isDragging {
		return
	}

	a.scroll.Refresh()
}

func (a *scrollBarArea) moveBar(offset float32, barSize fyne.Size) {
	oldX := a.scroll.Offset.X
	oldY := a.scroll.Offset.Y
	switch a.orientation {
	case scrollBarOrientationHorizontal:
		a.scroll.Offset.X = a.computeScrollOffset(barSize.Width, offset, a.scroll.Size().Width, a.scroll.Content.Size().Width)
	default:
		a.scroll.Offset.Y = a.computeScrollOffset(barSize.Height, offset, a.scroll.Size().Height, a.scroll.Content.Size().Height)
	}
	if f := a.scroll.OnScrolled; f != nil && (a.scroll.Offset.X != oldX || a.scroll.Offset.Y != oldY) {
		f(a.scroll.Offset)
	}
	a.scroll.refreshWithoutOffsetUpdate()
}

func (a *scrollBarArea) computeScrollOffset(length, offset, scrollLength, contentLength float32) float32 {
	maxOffset := scrollLength - length
	if offset < 0 {
		offset = 0
	} else if offset > maxOffset {
		offset = maxOffset
	}
	ratio := offset / maxOffset
	scrollOffset := ratio * (contentLength - scrollLength)
	return scrollOffset
}

func newScrollBarArea(scroll *Scroll, orientation scrollBarOrientation) *scrollBarArea {
	a := &scrollBarArea{scroll: scroll, orientation: orientation}
	a.ExtendBaseWidget(a)
	return a
}

type scrollContainerRenderer struct {
	BaseRenderer
	scroll                  *Scroll
	vertArea                *scrollBarArea
	horizArea               *scrollBarArea
	leftShadow, rightShadow *Shadow
	topShadow, bottomShadow *Shadow
	oldMinSize              fyne.Size
}

func (r *scrollContainerRenderer) layoutBars(size fyne.Size) {
	scrollerSize := r.scroll.size.Load()
	if r.scroll.Direction == ScrollVerticalOnly || r.scroll.Direction == ScrollBoth {
		r.vertArea.Resize(fyne.NewSize(r.vertArea.MinSize().Width, size.Height))
		r.vertArea.Move(fyne.NewPos(scrollerSize.Width-r.vertArea.Size().Width, 0))
		r.topShadow.Resize(fyne.NewSize(size.Width, 0))
		r.bottomShadow.Resize(fyne.NewSize(size.Width, 0))
		r.bottomShadow.Move(fyne.NewPos(0, scrollerSize.Height))
	}

	if r.scroll.Direction == ScrollHorizontalOnly || r.scroll.Direction == ScrollBoth {
		r.horizArea.Resize(fyne.NewSize(size.Width, r.horizArea.MinSize().Height))
		r.horizArea.Move(fyne.NewPos(0, scrollerSize.Height-r.horizArea.Size().Height))
		r.leftShadow.Resize(fyne.NewSize(0, size.Height))
		r.rightShadow.Resize(fyne.NewSize(0, size.Height))
		r.rightShadow.Move(fyne.NewPos(scrollerSize.Width, 0))
	}

	r.updatePosition()
}

func (r *scrollContainerRenderer) Layout(size fyne.Size) {
	c := r.scroll.Content
	c.Resize(c.MinSize().Max(size))

	r.layoutBars(size)
}

func (r *scrollContainerRenderer) MinSize() fyne.Size {
	return r.scroll.MinSize()
}

func (r *scrollContainerRenderer) Refresh() {
	r.horizArea.Refresh()
	r.vertArea.Refresh()
	r.leftShadow.Refresh()
	r.topShadow.Refresh()
	r.rightShadow.Refresh()
	r.bottomShadow.Refresh()

	if len(r.BaseRenderer.Objects()) == 0 || r.BaseRenderer.Objects()[0] != r.scroll.Content {
		// push updated content object to baseRenderer
		r.BaseRenderer.Objects()[0] = r.scroll.Content
	}
	size := r.scroll.Size()
	newMin := r.scroll.Content.MinSize()
	if r.oldMinSize == newMin && r.oldMinSize == r.scroll.Content.Size() &&
		(size.Width <= r.oldMinSize.Width && size.Height <= r.oldMinSize.Height) {
		r.layoutBars(size)
		return
	}

	r.oldMinSize = newMin
	r.Layout(size)
}

func (r *scrollContainerRenderer) handleAreaVisibility(contentSize, scrollSize float32, area *scrollBarArea) {
	if contentSize <= scrollSize {
		area.Hide()
	} else if r.scroll.Visible() {
		area.Show()
	}
}

func (r *scrollContainerRenderer) handleShadowVisibility(offset, contentSize, scrollSize float32, shadowStart fyne.CanvasObject, shadowEnd fyne.CanvasObject) {
	if !r.scroll.Visible() {
		return
	}
	if offset > 0 {
		shadowStart.Show()
	} else {
		shadowStart.Hide()
	}
	if offset < contentSize-scrollSize {
		shadowEnd.Show()
	} else {
		shadowEnd.Hide()
	}
}

func (r *scrollContainerRenderer) updatePosition() {
	if r.scroll.Content == nil {
		return
	}
	scrollSize := r.scroll.size.Load()
	contentSize := r.scroll.Content.Size()

	r.scroll.Content.Move(fyne.NewPos(-r.scroll.Offset.X, -r.scroll.Offset.Y))

	if r.scroll.Direction == ScrollVerticalOnly || r.scroll.Direction == ScrollBoth {
		r.handleAreaVisibility(contentSize.Height, scrollSize.Height, r.vertArea)
		r.handleShadowVisibility(r.scroll.Offset.Y, contentSize.Height, scrollSize.Height, r.topShadow, r.bottomShadow)
		cache.Renderer(r.vertArea).Layout(scrollSize)
	} else {
		r.vertArea.Hide()
		r.topShadow.Hide()
		r.bottomShadow.Hide()
	}
	if r.scroll.Direction == ScrollHorizontalOnly || r.scroll.Direction == ScrollBoth {
		r.handleAreaVisibility(contentSize.Width, scrollSize.Width, r.horizArea)
		r.handleShadowVisibility(r.scroll.Offset.X, contentSize.Width, scrollSize.Width, r.leftShadow, r.rightShadow)
		cache.Renderer(r.horizArea).Layout(scrollSize)
	} else {
		r.horizArea.Hide()
		r.leftShadow.Hide()
		r.rightShadow.Hide()
	}

	if r.scroll.Direction != ScrollHorizontalOnly {
		canvas.Refresh(r.vertArea) // this is required to force the canvas to update, we have no "Redraw()"
	} else {
		canvas.Refresh(r.horizArea) // this is required like above but if we are horizontal
	}
}

// Scroll defines a container that is smaller than the Content.
// The Offset is used to determine the position of the child widgets within the container.
type Scroll struct {
	Base
	minSize   fyne.Size
	Direction ScrollDirection
	Content   fyne.CanvasObject
	Offset    fyne.Position
	// OnScrolled can be set to be notified when the Scroll has changed position.
	// You should not update the Scroll.Offset from this method.
	//
	// Since: 2.0
	OnScrolled func(fyne.Position) `json:"-"`
}

// CreateRenderer is a private method to Fyne which links this widget to its renderer
func (s *Scroll) CreateRenderer() fyne.WidgetRenderer {
	scr := &scrollContainerRenderer{
		BaseRenderer: NewBaseRenderer([]fyne.CanvasObject{s.Content}),
		scroll:       s,
	}
	scr.vertArea = newScrollBarArea(s, scrollBarOrientationVertical)
	scr.topShadow = NewShadow(ShadowBottom, SubmergedContentLevel)
	scr.bottomShadow = NewShadow(ShadowTop, SubmergedContentLevel)
	scr.horizArea = newScrollBarArea(s, scrollBarOrientationHorizontal)
	scr.leftShadow = NewShadow(ShadowRight, SubmergedContentLevel)
	scr.rightShadow = NewShadow(ShadowLeft, SubmergedContentLevel)
	scr.SetObjects(append(scr.Objects(), scr.topShadow, scr.bottomShadow, scr.leftShadow, scr.rightShadow,
		scr.vertArea, scr.horizArea))
	scr.updatePosition()

	return scr
}

// ScrollToBottom will scroll content to container bottom - to show latest info which end user just added
func (s *Scroll) ScrollToBottom() {
	s.scrollBy(0, -1*(s.Content.MinSize().Height-s.Size().Height-s.Offset.Y))
	s.Refresh()
}

// ScrollToTop will scroll content to container top
func (s *Scroll) ScrollToTop() {
	s.scrollBy(0, -s.Offset.Y)
}

// DragEnd will stop scrolling on mobile has stopped
func (s *Scroll) DragEnd() {
}

// Dragged will scroll on any drag - bar or otherwise - for mobile
func (s *Scroll) Dragged(e *fyne.DragEvent) {
	if !fyne.CurrentDevice().IsMobile() {
		return
	}

	if s.updateOffset(e.Dragged.DX, e.Dragged.DY) {
		s.refreshWithoutOffsetUpdate()
	}
}

// MinSize returns the smallest size this widget can shrink to
func (s *Scroll) MinSize() fyne.Size {
	min := fyne.NewSize(scrollContainerMinSize, scrollContainerMinSize).Max(s.minSize)
	switch s.Direction {
	case ScrollHorizontalOnly:
		min.Height = fyne.Max(min.Height, s.Content.MinSize().Height)
	case ScrollVerticalOnly:
		min.Width = fyne.Max(min.Width, s.Content.MinSize().Width)
	case ScrollNone:
		return s.Content.MinSize()
	}
	return min
}

// SetMinSize specifies a minimum size for this scroll container.
// If the specified size is larger than the content size then scrolling will not be enabled
// This can be helpful to appear larger than default if the layout is collapsing this widget.
func (s *Scroll) SetMinSize(size fyne.Size) {
	s.minSize = size
}

// Refresh causes this widget to be redrawn in it's current state
func (s *Scroll) Refresh() {
	s.updateOffset(0, 0)
	s.refreshWithoutOffsetUpdate()
}

// Resize is called when this scroller should change size. We refresh to ensure the scroll bars are updated.
func (s *Scroll) Resize(sz fyne.Size) {
	if sz == s.Size() {
		return
	}

	s.Base.Resize(sz)
	s.Refresh()
}

func (s *Scroll) refreshWithoutOffsetUpdate() {
	s.Base.Refresh()
}

// Scrolled is called when an input device triggers a scroll event
func (s *Scroll) Scrolled(ev *fyne.ScrollEvent) {
	if s.Direction != ScrollNone {
		s.scrollBy(ev.Scrolled.DX, ev.Scrolled.DY)
	}
}

func (s *Scroll) scrollBy(dx, dy float32) {
	min := s.Content.MinSize()
	size := s.Size()
	if size.Width < min.Width && size.Height >= min.Height && dx == 0 {
		dx, dy = dy, dx
	}
	if s.updateOffset(dx, dy) {
		s.refreshWithoutOffsetUpdate()
	}
}

func (s *Scroll) updateOffset(deltaX, deltaY float32) bool {
	size := s.Size()
	contentSize := s.Content.Size()
	if contentSize.Width <= size.Width && contentSize.Height <= size.Height {
		if s.Offset.X != 0 || s.Offset.Y != 0 {
			s.Offset.X = 0
			s.Offset.Y = 0
			return true
		}
		return false
	}
	oldX := s.Offset.X
	oldY := s.Offset.Y
	min := s.Content.MinSize()
	s.Offset.X = computeOffset(s.Offset.X, -deltaX, size.Width, min.Width)
	s.Offset.Y = computeOffset(s.Offset.Y, -deltaY, size.Height, min.Height)
	if f := s.OnScrolled; f != nil && (s.Offset.X != oldX || s.Offset.Y != oldY) {
		f(s.Offset)
	}
	return true
}

func computeOffset(start, delta, outerWidth, innerWidth float32) float32 {
	offset := start + delta
	if offset+outerWidth >= innerWidth {
		offset = innerWidth - outerWidth
	}

	return fyne.Max(offset, 0)
}

// NewScroll creates a scrollable parent wrapping the specified content.
// Note that this may cause the MinSize to be smaller than that of the passed object.
func NewScroll(content fyne.CanvasObject) *Scroll {
	s := newScrollContainerWithDirection(ScrollBoth, content)
	s.ExtendBaseWidget(s)
	return s
}

// NewHScroll create a scrollable parent wrapping the specified content.
// Note that this may cause the MinSize.Width to be smaller than that of the passed object.
func NewHScroll(content fyne.CanvasObject) *Scroll {
	s := newScrollContainerWithDirection(ScrollHorizontalOnly, content)
	s.ExtendBaseWidget(s)
	return s
}

// NewVScroll create a scrollable parent wrapping the specified content.
// Note that this may cause the MinSize.Height to be smaller than that of the passed object.
func NewVScroll(content fyne.CanvasObject) *Scroll {
	s := newScrollContainerWithDirection(ScrollVerticalOnly, content)
	s.ExtendBaseWidget(s)
	return s
}

func newScrollContainerWithDirection(direction ScrollDirection, content fyne.CanvasObject) *Scroll {
	s := &Scroll{
		Direction: direction,
		Content:   content,
	}
	s.ExtendBaseWidget(s)
	return s
}
