// Package anim provides an animated spinner.
package anim

import (
	"image/color"
	"math/rand/v2"
	"strings"
	"sync/atomic"
	"time"

	tea "github.com/charmbracelet/bubbletea/v2"
	"github.com/charmbracelet/lipgloss/v2"
	"github.com/lucasb-eyer/go-colorful"
)

const (
	fps           = 20
	initialChar   = '.'
	labelGap      = " "
	labelGapWidth = 1

	// Periods of ellipsis animation speed in steps.
	//
	// If the FPS is 20 (50 milliseconds) this means that the ellipsis will
	// change every 8 frames (400 milliseconds).
	ellipsisAnimSpeed = 8

	// The maximum amount of time that can pass before a character appears.
	// This is used to create a staggered entrance effect.
	maxBirthOffset = time.Second

	// Number of frames to prerender for the animation. After this number
	// of frames, the animation will loop. This only applies when color
	// cycling is disabled.
	prerenderedFrames = 10

	// Default number of cycling chars.
	defaultNumCyclingChars = 10
)

// Default colors for gradient.
var (
	defaultGradColorA = color.RGBA{R: 0xff, G: 0, B: 0, A: 0xff}
	defaultGradColorB = color.RGBA{R: 0, G: 0, B: 0xff, A: 0xff}
	defaultLabelColor = color.RGBA{R: 0xcc, G: 0xcc, B: 0xcc, A: 0xff}
)

var (
	availableRunes = []rune("0123456789abcdefABCDEF~!@#$£€%^&*()+=_")
	ellipsisFrames = []string{".", "..", "...", ""}
)

// Internal ID management. Used during animating to ensure that frame messages
// are received only by spinner components that sent them.
var lastID int64

func nextID() int {
	return int(atomic.AddInt64(&lastID, 1))
}

// StepMsg is a message type used to trigger the next step in the animation.
type StepMsg struct{ id int }

// Settings defines settings for the animation.
type Settings struct {
	Size        int
	Label       string
	LabelColor  color.Color
	GradColorA  color.Color
	GradColorB  color.Color
	CycleColors bool
}

// Default settings.
const ()

// Anim is a Bubble for an animated spinner.
type Anim struct {
	width            int
	cyclingCharWidth int
	label            []string
	labelWidth       int
	startTime        time.Time
	birthOffsets     []time.Duration
	initialFrames    [][]string // frames for the initial characters
	initialized      bool
	cyclingFrames    [][]string // frames for the cycling characters
	step             int        // current main frame step
	ellipsisStep     int        // current ellipsis frame step
	ellipsisFrames   []string   // ellipsis animation frames
	id               int
}

// New creates a new Anim instance with the specified width and label.
func New(opts Settings) (a Anim) {
	// Validate settings.
	if opts.Size < 1 {
		opts.Size = defaultNumCyclingChars
	}
	if colorIsUnset(opts.GradColorA) {
		opts.GradColorA = defaultGradColorA
	}
	if colorIsUnset(opts.GradColorB) {
		opts.GradColorB = defaultGradColorB
	}
	if colorIsUnset(opts.LabelColor) {
		opts.LabelColor = defaultLabelColor
	}

	a.id = nextID()

	a.startTime = time.Now()
	a.cyclingCharWidth = opts.Size
	a.labelWidth = lipgloss.Width(opts.Label)

	// Total width of anim, in cells.
	a.width = opts.Size
	if opts.Label != "" {
		a.width += labelGapWidth + lipgloss.Width(opts.Label)
	}

	if a.labelWidth > 0 {
		// Pre-render the label.
		// XXX: We should really get the graphemes for the label, not the runes.
		labelRunes := []rune(opts.Label)
		a.label = make([]string, len(labelRunes))
		for i := range a.label {
			a.label[i] = lipgloss.NewStyle().
				Foreground(opts.LabelColor).
				Render(string(labelRunes[i]))
		}

		// Pre-render the ellipsis frames which come after the label.
		a.ellipsisFrames = make([]string, len(ellipsisFrames))
		for i, frame := range ellipsisFrames {
			a.ellipsisFrames[i] = lipgloss.NewStyle().
				Foreground(opts.LabelColor).
				Render(frame)
		}
	}

	// Pre-generate gradient.
	var ramp []color.Color
	numFrames := prerenderedFrames
	if opts.CycleColors {
		ramp = makeGradientRamp(a.width*3, opts.GradColorA, opts.GradColorB, opts.GradColorA, opts.GradColorB)
		numFrames = a.width * 2
	} else {
		ramp = makeGradientRamp(a.width, opts.GradColorA, opts.GradColorB)
	}

	// Pre-render initial characters.
	a.initialFrames = make([][]string, numFrames)
	offset := 0
	for i := range a.initialFrames {
		a.initialFrames[i] = make([]string, a.width+labelGapWidth+a.labelWidth)
		for j := range a.initialFrames[i] {
			if j+offset >= len(ramp) {
				continue // skip if we run out of colors
			}

			var c color.Color
			if j <= a.cyclingCharWidth {
				c = ramp[j+offset]
			} else {
				c = opts.LabelColor
			}

			// Also prerender the initial character with Lip Gloss to avoid
			// processing in the render loop.
			a.initialFrames[i][j] = lipgloss.NewStyle().
				Foreground(c).
				Render(string(initialChar))
		}
		if opts.CycleColors {
			offset++
		}
	}

	// Prerender scrambled rune frames for the animation.
	a.cyclingFrames = make([][]string, numFrames)
	offset = 0
	for i := range a.cyclingFrames {
		a.cyclingFrames[i] = make([]string, a.width)
		for j := range a.cyclingFrames[i] {
			if j+offset >= len(ramp) {
				continue // skip if we run out of colors
			}

			// Also prerender the color with Lip Gloss here to avoid processing
			// in the render loop.
			r := availableRunes[rand.IntN(len(availableRunes))]
			a.cyclingFrames[i][j] = lipgloss.NewStyle().
				Foreground(ramp[j+offset]).
				Render(string(r))
		}
		if opts.CycleColors {
			offset++
		}
	}

	// Random assign a birth to each character for a stagged entrance effect.
	a.birthOffsets = make([]time.Duration, a.width)
	for i := range a.birthOffsets {
		a.birthOffsets[i] = time.Duration(rand.N(int64(maxBirthOffset))) * time.Nanosecond
	}

	return a
}

// Width returns the total width of the animation.
func (a Anim) Width() (w int) {
	w = a.width
	if a.labelWidth > 0 {
		w += labelGapWidth + a.labelWidth

		var widestEllipsisFrame int
		for _, f := range ellipsisFrames {
			fw := lipgloss.Width(f)
			if fw > widestEllipsisFrame {
				widestEllipsisFrame = fw
			}
		}
		w += widestEllipsisFrame
	}
	return w
}

// Init starts the animation.
func (a Anim) Init() tea.Cmd {
	return a.Step()
}

// Update processes animation steps (or not).
func (a Anim) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	switch msg := msg.(type) {
	case StepMsg:
		if msg.id != a.id {
			// Reject messages that are not for this instance.
			return a, nil
		}

		a.step++
		if a.step >= len(a.cyclingFrames) {
			a.step = 0
		}

		if a.initialized && a.labelWidth > 0 {
			// Manage the ellipsis animation.
			a.ellipsisStep++
			if a.ellipsisStep >= ellipsisAnimSpeed*len(ellipsisFrames) {
				a.ellipsisStep = 0
			}
		} else if !a.initialized && time.Since(a.startTime) >= maxBirthOffset {
			a.initialized = true
		}
		return a, a.Step()
	default:
		return a, nil
	}
}

// View renders the current state of the animation.
func (a Anim) View() string {
	var b strings.Builder
	for i := range a.width {
		switch {
		case !a.initialized && time.Since(a.startTime) < a.birthOffsets[i]:
			// Birth offset not reached: render initial character.
			b.WriteString(a.initialFrames[a.step][i])
		case i < a.cyclingCharWidth:
			// Render a cycling character.
			b.WriteString(a.cyclingFrames[a.step][i])
		case i == a.cyclingCharWidth:
			// Render label gap.
			b.WriteString(labelGap)
		case i > a.cyclingCharWidth:
			// Label.
			b.WriteString(a.label[i-a.cyclingCharWidth-labelGapWidth])
		}
	}
	// Render animated ellipsis at the end of the label if all characters
	// have been initialized.
	if a.initialized && a.labelWidth > 0 {
		b.WriteString(a.ellipsisFrames[a.ellipsisStep/ellipsisAnimSpeed])
	}

	return b.String()
}

// Step is a command that triggers the next step in the animation.
func (a Anim) Step() tea.Cmd {
	return tea.Tick(time.Second/time.Duration(fps), func(t time.Time) tea.Msg {
		return StepMsg{id: a.id}
	})
}

// makeGradientRamp() returns a slice of colors blended between the given keys.
// Blending is done as Hcl to stay in gamut.
func makeGradientRamp(size int, stops ...color.Color) []color.Color {
	if len(stops) < 2 {
		return nil
	}

	points := make([]colorful.Color, len(stops))
	for i, k := range stops {
		points[i], _ = colorful.MakeColor(k)
	}

	numSegments := len(stops) - 1
	if numSegments == 0 {
		return nil
	}
	blended := make([]color.Color, 0, size)

	// Calculate how many colors each segment should have.
	segmentSizes := make([]int, numSegments)
	baseSize := size / numSegments
	remainder := size % numSegments

	// Distribute the remainder across segments.
	for i := range numSegments {
		segmentSizes[i] = baseSize
		if i < remainder {
			segmentSizes[i]++
		}
	}

	// Generate colors for each segment.
	for i := range numSegments {
		c1 := points[i]
		c2 := points[i+1]
		segmentSize := segmentSizes[i]

		for j := range segmentSize {
			if segmentSize == 0 {
				continue
			}
			t := float64(j) / float64(segmentSize)
			c := c1.BlendHcl(c2, t)
			blended = append(blended, c)
		}
	}

	return blended
}

func colorIsUnset(c color.Color) bool {
	if c == nil {
		return true
	}
	_, _, _, a := c.RGBA()
	return a == 0
}
