Code/Animation/Timeline.cs
using System;
using System.Collections.Immutable;

namespace Goo.Animation;

public readonly record struct Segment
{
    public float StartTime { get; init; }
    public float Duration  { get; init; }
    public Tween Tween     { get; init; }
}

public readonly record struct TimelineSample
{
    public int   SegmentIndex { get; init; }
    public float Value        { get; init; }
}

public readonly record struct Timeline
{
    public ImmutableArray<Segment> Segments { get; init; }
    public int   Iterations { get; init; }
    public float Duration   { get; init; }

    /// <summary>Auto-concatenated: segment N starts where N-1 ends. Skips the internal sort because the input is ascending by construction.</summary>
    public static Timeline Sequence(params Tween[] tweens)
    {
        if (tweens == null || tweens.Length == 0)
            throw new ArgumentException("Timeline requires at least one segment.", nameof(tweens));

        var builder  = ImmutableArray.CreateBuilder<Segment>(tweens.Length);
        float cursor = 0f;
        for (int i = 0; i < tweens.Length; i++)
        {
            if (tweens[i].Duration <= 0f)
                throw new ArgumentException(
                    $"Timeline segment {i} has duration {tweens[i].Duration}; durations must be > 0.",
                    nameof(tweens));
            builder.Add(new Segment
            {
                StartTime = cursor,
                Duration  = tweens[i].Duration,
                Tween     = tweens[i],
            });
            cursor += tweens[i].Duration;
        }
        return new Timeline
        {
            Segments   = builder.MoveToImmutable(),
            Iterations = 1,
            Duration   = cursor,
        };
    }

    /// <summary>Explicit timing: each entry is (absolute startTime, tween), gaps allowed; sorts internally so stored Segments are ascending by StartTime.</summary>
    public static Timeline At(params (float startTime, Tween tween)[] entries)
    {
        if (entries == null || entries.Length == 0)
            throw new ArgumentException("Timeline requires at least one segment.", nameof(entries));

        var arr = new Segment[entries.Length];
        for (int i = 0; i < entries.Length; i++)
        {
            if (entries[i].startTime < 0f)
                throw new ArgumentException(
                    $"Timeline segment {i} has StartTime {entries[i].startTime}; StartTime must be >= 0.",
                    nameof(entries));
            if (entries[i].tween.Duration <= 0f)
                throw new ArgumentException(
                    $"Timeline segment {i} has duration {entries[i].tween.Duration}; durations must be > 0.",
                    nameof(entries));
            arr[i] = new Segment
            {
                StartTime = entries[i].startTime,
                Duration  = entries[i].tween.Duration,
                Tween     = entries[i].tween,
            };
        }
        Array.Sort(arr, static (x, y) => x.StartTime.CompareTo(y.StartTime));

        for (int i = 1; i < arr.Length; i++)
        {
            float prevEnd = arr[i - 1].StartTime + arr[i - 1].Duration;
            if (prevEnd > arr[i].StartTime)
                throw new ArgumentException(
                    $"Timeline segments {i - 1} and {i} overlap: segment {i - 1} ends at {prevEnd} but segment {i} starts at {arr[i].StartTime}.",
                    nameof(entries));
        }

        float duration = arr[arr.Length - 1].StartTime + arr[arr.Length - 1].Duration;
        return new Timeline
        {
            Segments   = ImmutableArray.Create(arr),
            Iterations = 1,
            Duration   = duration,
        };
    }

    public TimelineSample Eval(float elapsedSec)
    {
        if (Iterations <= 0)
        {
            // Loop forever. Wrap into [0, Duration) using positive-result modulo.
            if (elapsedSec < 0f) elapsedSec = 0f;
            elapsedSec %= Duration;
        }
        else
        {
            float totalPlayTime = Duration * Iterations;
            if (elapsedSec >= totalPlayTime) elapsedSec = Duration;
            else if (elapsedSec > Duration)
            {
                // Mid-play, cycle 2..Iterations: wrap into the canonical [0, Duration) range.
                elapsedSec %= Duration;
            }
        }

        int idx = -1;
        for (int i = 0; i < Segments.Length; i++)
        {
            if (Segments[i].StartTime <= elapsedSec) idx = i;
            else break;
        }

        if (idx < 0)
            return new TimelineSample { SegmentIndex = -1, Value = 0f };

        var seg     = Segments[idx];
        float local = elapsedSec - seg.StartTime;
        if (local > seg.Duration) local = seg.Duration;

        return new TimelineSample
        {
            SegmentIndex = idx,
            Value        = seg.Tween.Eval(local),
        };
    }
}

public static class TimelineExtensions
{
    public static Timeline Loop(this Timeline tl)         => tl with { Iterations = -1 };
    public static Timeline Times(this Timeline tl, int n) => tl with { Iterations = n };
}