Core/Gradient.cs
using System;
using System.Globalization;
using System.Text;
using Sandbox;
using Sandbox.UI;

namespace Goo;

public enum GradientKind
{
    Linear,
    Radial,
    Conic,
}

public enum LinearGradientDirection
{
    Angle,
    TopLeft,
    TopRight,
    BottomLeft,
    BottomRight,
}

public enum RadialGradientShape
{
    Ellipse,
    Circle,
}

public enum RadialGradientExtent
{
    ClosestSide,
    ClosestCorner,
    FarthestSide,
    FarthestCorner,
}

public readonly struct GradientStop : IEquatable<GradientStop>
{
    public Color Color { get; }
    public Length? Position { get; }

    public GradientStop(Color color, Length? position = null)
    {
        UiCssSerializer.ValidateColor(color, nameof(color));
        if (position.HasValue)
            UiCssSerializer.ValidateLength(position.Value, nameof(position));
        Color = color;
        Position = position;
    }

    public static GradientStop Percent(Color color, float percent)
        => new(color, new Length { Value = percent, Unit = LengthUnit.Percentage });

    public static GradientStop Pixels(Color color, float pixels)
        => new(color, new Length { Value = pixels, Unit = LengthUnit.Pixels });

    public static implicit operator GradientStop(Color color) => new(color);

    public bool Equals(GradientStop other)
        => Color == other.Color && Position == other.Position;

    public override bool Equals(object? obj) => obj is GradientStop other && Equals(other);
    public override int GetHashCode() => HashCode.Combine(Color, Position);
    public static bool operator ==(GradientStop left, GradientStop right) => left.Equals(right);
    public static bool operator !=(GradientStop left, GradientStop right) => !left.Equals(right);
}

public readonly struct Gradient : IEquatable<Gradient>
{
    const int MaxStops = 8;

    readonly GradientStop[]? _stops;

    public GradientKind Kind { get; }
    public float AngleDegrees { get; }
    public LinearGradientDirection LinearDirection { get; }
    public RadialGradientShape RadialShape { get; }
    public RadialGradientExtent RadialExtent { get; }
    public Length CenterX { get; }
    public Length CenterY { get; }
    public int Count => _stops?.Length ?? 0;
    public bool IsEmpty => Count == 0;
    public GradientStop this[int index] => _stops![index];

    Gradient(
        GradientKind kind,
        float angleDegrees,
        LinearGradientDirection linearDirection,
        RadialGradientShape radialShape,
        RadialGradientExtent radialExtent,
        Length centerX,
        Length centerY,
        GradientStop[] stops)
    {
        Kind = kind;
        AngleDegrees = angleDegrees;
        LinearDirection = linearDirection;
        RadialShape = radialShape;
        RadialExtent = radialExtent;
        CenterX = centerX;
        CenterY = centerY;
        _stops = stops;
    }

    public static Gradient Linear(params GradientStop[] stops)
        => Linear(180f, stops);

    public static Gradient Linear(float angleDegrees, params GradientStop[] stops)
        => Create(GradientKind.Linear, angleDegrees, RadialGradientShape.Ellipse, RadialGradientExtent.FarthestCorner, stops);

    public static Gradient LinearTo(LinearGradientDirection direction, params GradientStop[] stops)
    {
        if (direction == LinearGradientDirection.Angle || !Enum.IsDefined(direction))
            throw new ArgumentOutOfRangeException(nameof(direction));
        return Create(GradientKind.Linear, 180f, RadialGradientShape.Ellipse, RadialGradientExtent.FarthestCorner, stops, direction);
    }

    public static Gradient Radial(params GradientStop[] stops)
        => Create(GradientKind.Radial, 0f, RadialGradientShape.Ellipse, RadialGradientExtent.FarthestCorner, stops);

    public static Gradient RadialCircle(params GradientStop[] stops)
        => Create(GradientKind.Radial, 0f, RadialGradientShape.Circle, RadialGradientExtent.FarthestCorner, stops);

    public static Gradient Conic(params GradientStop[] stops)
        => Conic(0f, stops);

    public static Gradient Conic(float angleDegrees, params GradientStop[] stops)
        => Create(GradientKind.Conic, angleDegrees, RadialGradientShape.Ellipse, RadialGradientExtent.FarthestCorner, stops);

    public Gradient At(Length? x, Length? y)
    {
        if (Kind == GradientKind.Linear)
            throw new InvalidOperationException("Linear gradients do not have a center.");
        if (!x.HasValue || !y.HasValue)
            throw new ArgumentException("Gradient centers require both coordinates.");
        UiCssSerializer.ValidateLength(x.Value, nameof(x));
        UiCssSerializer.ValidateLength(y.Value, nameof(y));
        return Copy(centerX: x.Value, centerY: y.Value);
    }

    public Gradient At(Vector2 unitPosition)
        => At(
            new Length { Value = unitPosition.x * 100f, Unit = LengthUnit.Percentage },
            new Length { Value = unitPosition.y * 100f, Unit = LengthUnit.Percentage });

    public Gradient WithRadialShape(RadialGradientShape shape)
    {
        RequireRadial();
        return Copy(radialShape: shape);
    }

    public Gradient WithExtent(RadialGradientExtent extent)
    {
        RequireRadial();
        return Copy(radialExtent: extent);
    }

    static Gradient Create(
        GradientKind kind,
        float angleDegrees,
        RadialGradientShape radialShape,
        RadialGradientExtent radialExtent,
        GradientStop[]? stops,
        LinearGradientDirection linearDirection = LinearGradientDirection.Angle)
    {
        if (!float.IsFinite(angleDegrees))
            throw new ArgumentOutOfRangeException(nameof(angleDegrees));
        if (stops is null || stops.Length < 2 || stops.Length > MaxStops)
            throw new ArgumentException($"Gradients require 2 to {MaxStops} stops.", nameof(stops));

        var copy = new GradientStop[stops.Length];
        Array.Copy(stops, copy, stops.Length);
        var center = new Length { Value = 50f, Unit = LengthUnit.Percentage };
        return new Gradient(kind, angleDegrees, linearDirection, radialShape, radialExtent, center, center, copy);
    }

    Gradient Copy(
        RadialGradientShape? radialShape = null,
        RadialGradientExtent? radialExtent = null,
        Length? centerX = null,
        Length? centerY = null)
        => new(
            Kind,
            AngleDegrees,
            LinearDirection,
            radialShape ?? RadialShape,
            radialExtent ?? RadialExtent,
            centerX ?? CenterX,
            centerY ?? CenterY,
            _stops!);

    void RequireRadial()
    {
        if (Kind != GradientKind.Radial)
            throw new InvalidOperationException("This option applies only to radial gradients.");
    }

    internal string ToCss()
    {
        if (IsEmpty) return "none";

        var css = new StringBuilder();
        switch (Kind)
        {
            case GradientKind.Linear:
                css.Append("linear-gradient(");
                if (LinearDirection == LinearGradientDirection.Angle)
                    css.Append(UiCssSerializer.Number(AngleDegrees)).Append("deg, ");
                else
                    css.Append(LinearDirection switch
                    {
                        LinearGradientDirection.TopLeft => "to top left, ",
                        LinearGradientDirection.TopRight => "to top right, ",
                        LinearGradientDirection.BottomLeft => "to bottom left, ",
                        LinearGradientDirection.BottomRight => "to bottom right, ",
                        _ => throw new InvalidOperationException("Invalid linear gradient direction."),
                    });
                break;
            case GradientKind.Radial:
                css.Append("radial-gradient(")
                    .Append(RadialShape == RadialGradientShape.Circle ? "circle " : "ellipse ")
                    .Append(RadialExtent switch
                    {
                        RadialGradientExtent.ClosestSide => "closest-side",
                        RadialGradientExtent.ClosestCorner => "closest-corner",
                        RadialGradientExtent.FarthestSide => "farthest-side",
                        _ => "farthest-corner",
                    })
                    .Append(" at ").Append(UiCssSerializer.Length(CenterX))
                    .Append(' ').Append(UiCssSerializer.Length(CenterY)).Append(", ");
                break;
            case GradientKind.Conic:
                css.Append("conic-gradient(from ").Append(UiCssSerializer.Number(AngleDegrees))
                    .Append("deg at ").Append(UiCssSerializer.Length(CenterX))
                    .Append(' ').Append(UiCssSerializer.Length(CenterY)).Append(", ");
                break;
            default:
                return "none";
        }

        for (int i = 0; i < Count; i++)
        {
            if (i > 0) css.Append(", ");
            var stop = this[i];
            css.Append(UiCssSerializer.Color(stop.Color));
            if (stop.Position.HasValue)
                css.Append(' ').Append(UiCssSerializer.Length(stop.Position.Value));
        }
        return css.Append(')').ToString();
    }

    public bool Equals(Gradient other)
    {
        if (IsEmpty || other.IsEmpty) return IsEmpty == other.IsEmpty;
        if (Kind != other.Kind || AngleDegrees != other.AngleDegrees || LinearDirection != other.LinearDirection
            || RadialShape != other.RadialShape || RadialExtent != other.RadialExtent
            || CenterX != other.CenterX || CenterY != other.CenterY || Count != other.Count)
            return false;
        for (int i = 0; i < Count; i++)
            if (this[i] != other[i]) return false;
        return true;
    }

    public override bool Equals(object? obj) => obj is Gradient other && Equals(other);

    public override int GetHashCode()
    {
        if (IsEmpty) return 0;
        var hash = new HashCode();
        hash.Add(Kind);
        hash.Add(AngleDegrees);
        hash.Add(LinearDirection);
        hash.Add(RadialShape);
        hash.Add(RadialExtent);
        hash.Add(CenterX);
        hash.Add(CenterY);
        for (int i = 0; i < Count; i++) hash.Add(this[i]);
        return hash.ToHashCode();
    }

    public static bool operator ==(Gradient left, Gradient right) => left.Equals(right);
    public static bool operator !=(Gradient left, Gradient right) => !left.Equals(right);
}

internal static class UiCssSerializer
{
    internal static string Number(float value)
        => value.ToString("R", CultureInfo.InvariantCulture);

    internal static string Length(Length value)
        => Number(value.Value) + (value.Unit == LengthUnit.Percentage ? "%" : "px");

    internal static string Color(Color value)
        => $"rgba({Number(value.r * 255f)},{Number(value.g * 255f)},{Number(value.b * 255f)},{Number(value.a)})";

    internal static void ValidateLength(Length value, string paramName)
    {
        if (!float.IsFinite(value.Value) || value.Unit is not (LengthUnit.Pixels or LengthUnit.Percentage))
            throw new ArgumentOutOfRangeException(paramName, "Only finite pixel and percentage lengths are supported.");
    }

    internal static void ValidateColor(Color value, string paramName)
    {
        if (!float.IsFinite(value.r) || !float.IsFinite(value.g) || !float.IsFinite(value.b) || !float.IsFinite(value.a)
            || !float.IsFinite(value.r * 255f) || !float.IsFinite(value.g * 255f) || !float.IsFinite(value.b * 255f))
            throw new ArgumentOutOfRangeException(paramName, "Color channels must be finite.");
    }
}