#nullable enable

using System.Globalization;

namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes;

public enum ComparisonSeriesTarget
{
    First,
    Second
}

public enum ComparisonEquityMarket
{
    Kospi,
    Kosdaq
}

public enum ComparisonChangeDirection
{
    LimitUp,
    Up,
    Flat,
    Down,
    LimitDown
}

public enum ComparisonGraphPeriod
{
    Daily,
    FiveDays,
    OneMonth,
    ThreeMonths,
    SixMonths,
    TwelveMonths
}

public sealed record ComparisonQuoteData(
    ComparisonSeriesTarget Series,
    ComparisonEquityMarket Market,
    string StockName,
    long CurrentPrice,
    long NetChange,
    double Rate,
    ComparisonChangeDirection Direction);

public sealed record ComparisonCandlePoint(
    DateOnly OpenDay,
    double High,
    double Low,
    double Open,
    double Close);

public sealed record ComparisonCandleSeriesData(
    ComparisonQuoteData Quote,
    IReadOnlyList<ComparisonCandlePoint> Candles);

public sealed record S5026SceneData(
    ComparisonGraphPeriod Period,
    IReadOnlyList<ComparisonCandleSeriesData> Series) : ILegacySceneData;

public sealed class S5026SceneMutationBuilder : ILegacySceneMutationBuilder<S5026SceneData>
{
    public string BuilderKey => "s5026";

    public IReadOnlyList<PlayoutMutation> Build(S5026SceneData data)
    {
        ArgumentNullException.ThrowIfNull(data);
        if (data.Period != ComparisonGraphPeriod.FiveDays)
        {
            throw new LegacySceneDataException("s5026 requires the legacy five-day period.");
        }

        var series = ComparisonSceneGuard.CandleSeries(data.Series);
        var mutations = new List<PlayoutMutation>(80);
        ComparisonSceneMutations.AppendQuoteHeader(
            mutations,
            series[0].Quote,
            series[1].Quote);
        ComparisonSceneMutations.AppendInitialDirectionHides(
            mutations,
            ComparisonInitialHideStyle.S5026);
        ComparisonSceneMutations.AppendDirection(
            mutations,
            series[0].Quote.Series,
            series[0].Quote.Direction);
        ComparisonSceneMutations.AppendDirection(
            mutations,
            series[1].Quote.Series,
            series[1].Quote.Direction);

        foreach (var item in series)
        {
            AppendCandles(mutations, item);
        }

        return mutations;
    }

    private static void AppendCandles(
        ICollection<PlayoutMutation> mutations,
        ComparisonCandleSeriesData series)
    {
        var seriesNumber = ComparisonSceneGuard.SeriesNumber(series.Quote.Series);
        var candles = series.Candles;
        var maximum = candles.Max(item => item.High);
        var minimum = candles.Min(item => item.Low);
        var range = maximum - minimum;
        if (!double.IsFinite(range))
        {
            throw new LegacySceneDataException("Candle range must be finite.");
        }

        // The source divides every crop by range and emits NaN when all candles are equal.
        // RangePercent pins that degenerate chart to zero-percent crop coordinates.
        for (var index = 0; index < candles.Count; index++)
        {
            var candleNumber = index + 1;
            ComparisonSceneMutations.AppendCandleDate(
                mutations,
                seriesNumber,
                candleNumber,
                candles.Count,
                candles[index].OpenDay);

            var candle = candles[index];
            var high = ComparisonGraphSafety.RangePercent(candle.High, maximum, range);
            var low = ComparisonGraphSafety.RangePercent(candle.Low, maximum, range);
            var open = ComparisonGraphSafety.RangePercent(candle.Open, maximum, range);
            var close = ComparisonGraphSafety.RangePercent(candle.Close, maximum, range);
            var bar = $"candlebar{seriesNumber}_{candleNumber}";
            var body = $"candlebody{seriesNumber}_{candleNumber}";
            mutations.Add(new PlayoutSetCropKey(
                bar,
                1,
                0,
                high,
                0,
                low,
                PlayoutCropEdges.Vertical));

            (float Top, float Bottom, int R, int G, int B) bodyStyle;
            if (candle.Open > candle.Close)
            {
                bodyStyle = (open, close, 15, 99, 189);
            }
            else if (candle.Open < candle.Close)
            {
                bodyStyle = (close, open, 170, 0, 0);
            }
            else
            {
                bodyStyle = (close - 0.1f, open + 3f, 95, 95, 95);
            }

            mutations.Add(new PlayoutSetCropKey(
                body,
                1,
                0,
                bodyStyle.Top,
                0,
                bodyStyle.Bottom,
                PlayoutCropEdges.Vertical));
            mutations.Add(new PlayoutSetFaceColor(
                body,
                bodyStyle.R,
                bodyStyle.G,
                bodyStyle.B));
            mutations.Add(new PlayoutSetFaceColor(
                bar,
                bodyStyle.R,
                bodyStyle.G,
                bodyStyle.B));
        }
    }
}

public sealed record ComparisonYieldPoint(DateOnly DataDay, int Value);

public sealed record ComparisonYieldSeriesData(
    ComparisonQuoteData Quote,
    IReadOnlyList<ComparisonYieldPoint> Points);

public sealed record S5029SceneData(
    ComparisonGraphPeriod Period,
    IReadOnlyList<ComparisonYieldSeriesData> Series) : ILegacySceneData;

public sealed class S5029SceneMutationBuilder : ILegacySceneMutationBuilder<S5029SceneData>
{
    public string BuilderKey => "s5029";

    public IReadOnlyList<PlayoutMutation> Build(S5029SceneData data)
    {
        ArgumentNullException.ThrowIfNull(data);
        ComparisonSceneGuard.ValidatePeriod(data.Period);
        return BuildComparisonYieldScene(
            data.Series,
            ComparisonInitialHideStyle.S5029,
            maximumX: 812f,
            baselineOffset: -50f);
    }

    internal static IReadOnlyList<PlayoutMutation> BuildComparisonYieldScene(
        IReadOnlyList<ComparisonYieldSeriesData> source,
        ComparisonInitialHideStyle initialHideStyle,
        float maximumX,
        float baselineOffset)
    {
        var series = ComparisonSceneGuard.YieldSeries(source);
        var mutations = new List<PlayoutMutation>();
        ComparisonSceneMutations.AppendQuoteHeader(
            mutations,
            series[0].Quote,
            series[1].Quote);
        ComparisonSceneMutations.AppendInitialDirectionHides(mutations, initialHideStyle);
        ComparisonSceneMutations.AppendDirection(
            mutations,
            series[0].Quote.Series,
            series[0].Quote.Direction);
        ComparisonSceneMutations.AppendDirection(
            mutations,
            series[1].Quote.Series,
            series[1].Quote.Direction);

        foreach (var item in series)
        {
            AppendYieldSeries(mutations, item, maximumX, baselineOffset);
        }

        return mutations;
    }

    private static void AppendYieldSeries(
        ICollection<PlayoutMutation> mutations,
        ComparisonYieldSeriesData series,
        float maximumX,
        float baselineOffset)
    {
        var seriesNumber = ComparisonSceneGuard.SeriesNumber(series.Quote.Series);
        var points = series.Points;
        for (var index = 0; index < points.Count; index++)
        {
            ComparisonSceneMutations.AppendYieldDate(
                mutations,
                seriesNumber.ToString(CultureInfo.InvariantCulture),
                index,
                points.Count,
                points[index].DataDay);
        }

        var maximum = points.Max(item => item.Value);
        var minimum = points.Min(item => item.Value);
        var range = (double)maximum - minimum;
        var reference = points[^1].Value;
        // The source divides by max-min. Equal values retain its configured baseline offset.
        var baseline = range == 0d
            ? baselineOffset
            : ComparisonGraphSafety.Float(
                ((reference - (double)minimum) / range * 150d) + baselineOffset,
                "Comparison baseline");
        mutations.Add(new PlayoutSetPosition(
            "baseline" + seriesNumber,
            0,
            baseline,
            0,
            PlayoutVectorComponents.Y));

        var reversed = points.Select(item => item.Value).Reverse().ToArray();
        // The original divides by count-1. A single point is pinned at X=0 instead of NaN.
        var graphStep = reversed.Length == 1 ? 0d : maximumX / (reversed.Length - 1d);
        const double tagStart = -383.48d;
        const double tagEnd = 359.28d;
        var tagStep = reversed.Length == 1
            ? 0d
            : (tagEnd - tagStart) / (reversed.Length - 1d);
        var plus = new List<PlayoutPoint>();
        var minus = new List<PlayoutPoint>();
        var previous = 0d;

        for (var index = 0; index < reversed.Length; index++)
        {
            var x = graphStep * index;
            var value = range == 0d
                ? 0d
                : (reference - (double)reversed[index]) / range * 150d;
            ComparisonGraphSafety.AppendSignedPoint(plus, minus, x, value, graphStep, previous);
            previous = value;

            var tagX = ComparisonGraphSafety.Float(
                tagStart + (index * tagStep),
                "Comparison tag X");
            if (reversed[index] == maximum)
            {
                mutations.Add(new PlayoutSetPosition(
                    "highTag" + seriesNumber,
                    tagX,
                    0,
                    0,
                    PlayoutVectorComponents.X));
                mutations.Add(new PlayoutSetValue(
                    "highPrice" + seriesNumber,
                    reversed[index].ToString("#,##0", CultureInfo.InvariantCulture)));
            }

            if (reversed[index] == minimum)
            {
                mutations.Add(new PlayoutSetPosition(
                    "lowTag" + seriesNumber,
                    tagX,
                    0,
                    0,
                    PlayoutVectorComponents.X));
                mutations.Add(new PlayoutSetValue(
                    "lowPrice" + seriesNumber,
                    reversed[index].ToString("#,##0", CultureInfo.InvariantCulture)));
            }
        }

        ComparisonSceneMutations.AppendSignedGraphs(
            mutations,
            seriesNumber.ToString(CultureInfo.InvariantCulture),
            0,
            baseline,
            PlayoutVectorComponents.Y,
            maximumX,
            plus,
            minus);
    }
}

public sealed record S5087SceneData(
    ComparisonGraphPeriod Period,
    IReadOnlyList<ComparisonYieldSeriesData> Series) : ILegacySceneData;

public sealed class S5087SceneMutationBuilder : ILegacySceneMutationBuilder<S5087SceneData>
{
    public string BuilderKey => "s5087";

    public IReadOnlyList<PlayoutMutation> Build(S5087SceneData data)
    {
        ArgumentNullException.ThrowIfNull(data);
        if (data.Period != ComparisonGraphPeriod.FiveDays)
        {
            throw new LegacySceneDataException("s5087 requires the legacy five-day period.");
        }

        return S5029SceneMutationBuilder.BuildComparisonYieldScene(
            data.Series,
            ComparisonInitialHideStyle.S5087,
            maximumX: 700f,
            baselineOffset: -170f)
            .Where(IsAuthoritativeAssetMutation)
            .ToArray();
    }

    private static bool IsAuthoritativeAssetMutation(PlayoutMutation mutation) => mutation switch
    {
        PlayoutSetValue value when value.ObjectName is
            "date1_2" or "date1_4" or "date2_2" or "date2_4" or
            "highPrice1" or "lowPrice1" or "highPrice2" or "lowPrice2" => false,
        PlayoutSetPosition position when position.ObjectName is
            "baseline1" or "baseline2" or
            "highTag1" or "lowTag1" or "highTag2" or "lowTag2" => false,
        _ => true
    };
}

public enum YieldGraphPeriod
{
    FiveDays,
    TwentyDays,
    SixtyDays,
    OneHundredTwentyDays,
    TwoHundredFortyDays
}

public enum YieldGraphInstrumentKind
{
    WonDollar,
    WonYen,
    WonYuan,
    WonEuro,
    KospiIndex,
    KosdaqIndex,
    Kospi200Index,
    Krx100Index,
    KospiIndustry,
    KosdaqIndustry,
    KospiStock,
    KosdaqStock
}

public sealed record YieldGraphInstrument(
    YieldGraphInstrumentKind Kind,
    string? Name = null);

/// <summary>
/// A null value represents a missing DB cell. s5086 explicitly normalizes a later missing
/// value while s50860 rejects it, matching their distinct legacy data paths.
/// </summary>
public sealed record YieldGraphPoint(DateOnly DataDay, double? Value);

public sealed record S5086SceneData(
    YieldGraphPeriod Period,
    YieldGraphInstrument Instrument,
    IReadOnlyList<YieldGraphPoint> Points) : ILegacySceneData;

public sealed class S5086SceneMutationBuilder : ILegacySceneMutationBuilder<S5086SceneData>
{
    public string BuilderKey => "s5086";

    public IReadOnlyList<PlayoutMutation> Build(S5086SceneData data)
    {
        ArgumentNullException.ThrowIfNull(data);
        var points = SingleYieldSceneGuard.NormalizeS5086Points(data.Points);
        var presentation = SingleYieldSceneGuard.Presentation(data.Instrument, slashFxTitle: false);
        var mutations = new List<PlayoutMutation>();
        SingleYieldSceneMutations.AppendHeader(
            mutations,
            SingleYieldSceneGuard.Subtitle(data.Period),
            presentation);
        SingleYieldSceneMutations.AppendDates(mutations, points);

        var price = points[0].Value;
        var reference = points[^1].Value;
        var rate = reference == 0d
            ? 0d
            : (price - reference) / reference * 100d;
        if (!double.IsFinite(rate))
        {
            throw new LegacySceneDataException("s5086 rate must be finite.");
        }
        mutations.Add(new PlayoutSetValue(
            "price",
            SingleYieldSceneGuard.Format(price, presentation.Format)));
        mutations.Add(new PlayoutSetValue(
            "referencePrice",
            SingleYieldSceneGuard.Format(reference, presentation.Format)));
        mutations.Add(new PlayoutSetValue(
            "rate",
            rate.ToString("0.00", CultureInfo.InvariantCulture)));
        if (rate > 0d)
        {
            mutations.Add(new PlayoutSetVisible("bg1", true));
            mutations.Add(new PlayoutSetVisible("bg2", false));
            mutations.Add(new PlayoutSetVisible("bg3", false));
        }
        else if (rate < 0d)
        {
            mutations.Add(new PlayoutSetVisible("bg1", false));
            mutations.Add(new PlayoutSetVisible("bg2", false));
            mutations.Add(new PlayoutSetVisible("bg3", true));
        }
        else
        {
            mutations.Add(new PlayoutSetVisible("bg1", false));
            mutations.Add(new PlayoutSetVisible("bg2", true));
            mutations.Add(new PlayoutSetVisible("bg3", false));
        }

        SingleYieldSceneMutations.AppendGraph(
            mutations,
            points,
            presentation.Format);
        return mutations;
    }
}

public enum YieldQuoteDirection
{
    Up,
    Flat,
    Down
}

public sealed record S50860SceneData(
    YieldGraphPeriod Period,
    YieldGraphInstrument Instrument,
    double ChangePrice,
    double Rate,
    YieldQuoteDirection Direction,
    IReadOnlyList<YieldGraphPoint> Points) : ILegacySceneData;

public sealed class S50860SceneMutationBuilder : ILegacySceneMutationBuilder<S50860SceneData>
{
    public string BuilderKey => "s50860";

    public IReadOnlyList<PlayoutMutation> Build(S50860SceneData data)
    {
        ArgumentNullException.ThrowIfNull(data);
        if (!double.IsFinite(data.ChangePrice) || !double.IsFinite(data.Rate))
        {
            throw new LegacySceneDataException("s50860 quote values must be finite.");
        }

        var points = SingleYieldSceneGuard.RequireCompletePoints(data.Points, "s50860");
        var presentation = SingleYieldSceneGuard.Presentation(data.Instrument, slashFxTitle: true);
        var mutations = new List<PlayoutMutation>();
        SingleYieldSceneMutations.AppendHeader(
            mutations,
            SingleYieldSceneGuard.Subtitle(data.Period),
            presentation);
        SingleYieldSceneMutations.AppendDates(mutations, points);
        mutations.Add(new PlayoutSetValue(
            "price",
            SingleYieldSceneGuard.Format(points[0].Value, presentation.Format)));
        mutations.Add(new PlayoutSetValue(
            "changePrice",
            Math.Abs(data.ChangePrice).ToString("0.00", CultureInfo.InvariantCulture)));
        mutations.Add(new PlayoutSetValue(
            "rate",
            data.Rate.ToString("0.00", CultureInfo.InvariantCulture)));
        mutations.Add(new PlayoutSetVisible("up", false));
        mutations.Add(new PlayoutSetVisible("flat", false));
        mutations.Add(new PlayoutSetVisible("down", false));

        var (active, red, green, blue) = data.Direction switch
        {
            YieldQuoteDirection.Up => ("up", 170, 0, 0),
            YieldQuoteDirection.Flat => ("flat", 95, 95, 95),
            YieldQuoteDirection.Down => ("down", 15, 99, 189),
            _ => throw new LegacySceneDataException("Unknown yield quote direction.")
        };
        mutations.Add(new PlayoutSetVisible(active, true));
        mutations.Add(new PlayoutSetFaceColor("bg", red, green, blue));

        SingleYieldSceneMutations.AppendGraph(
            mutations,
            points,
            presentation.Format);
        return mutations;
    }
}

internal enum ComparisonInitialHideStyle
{
    S5026,
    S5029,
    S5087
}

internal static class ComparisonSceneGuard
{
    public static IReadOnlyList<ComparisonCandleSeriesData> CandleSeries(
        IReadOnlyList<ComparisonCandleSeriesData>? source)
    {
        var input = LegacySceneBuilderGuard.Count(source, 2, "Series");
        var ordered = new ComparisonCandleSeriesData?[2];
        foreach (var item in input)
        {
            var series = LegacySceneBuilderGuard.NotNull(item, "Series");
            ValidateQuote(series.Quote);
            var index = SeriesNumber(series.Quote.Series) - 1;
            if (ordered[index] is not null)
            {
                throw new LegacySceneDataException("Comparison series targets must be unique.");
            }

            var candles = LegacySceneBuilderGuard.Count(series.Candles, 5, "Candles");
            foreach (var candle in candles)
            {
                var point = LegacySceneBuilderGuard.NotNull(candle, "Candles");
                if (!double.IsFinite(point.High) || !double.IsFinite(point.Low) ||
                    !double.IsFinite(point.Open) || !double.IsFinite(point.Close) ||
                    point.High < point.Low)
                {
                    throw new LegacySceneDataException("Candle values are invalid.");
                }
            }

            ordered[index] = series;
        }

        return ordered.Select(item => item!).ToArray();
    }

    public static IReadOnlyList<ComparisonYieldSeriesData> YieldSeries(
        IReadOnlyList<ComparisonYieldSeriesData>? source)
    {
        var input = LegacySceneBuilderGuard.Count(source, 2, "Series");
        var ordered = new ComparisonYieldSeriesData?[2];
        foreach (var item in input)
        {
            var series = LegacySceneBuilderGuard.NotNull(item, "Series");
            ValidateQuote(series.Quote);
            var index = SeriesNumber(series.Quote.Series) - 1;
            if (ordered[index] is not null)
            {
                throw new LegacySceneDataException("Comparison series targets must be unique.");
            }

            _ = LegacySceneBuilderGuard.Range(series.Points, 1, 10_000, "Points");
            foreach (var point in series.Points)
            {
                _ = LegacySceneBuilderGuard.NotNull(point, "Points");
            }

            ordered[index] = series;
        }

        return ordered.Select(item => item!).ToArray();
    }

    public static void ValidatePeriod(ComparisonGraphPeriod period)
    {
        _ = period switch
        {
            ComparisonGraphPeriod.Daily or
            ComparisonGraphPeriod.FiveDays or
            ComparisonGraphPeriod.OneMonth or
            ComparisonGraphPeriod.ThreeMonths or
            ComparisonGraphPeriod.SixMonths or
            ComparisonGraphPeriod.TwelveMonths => true,
            _ => throw new LegacySceneDataException("Unknown comparison graph period.")
        };
    }

    public static int SeriesNumber(ComparisonSeriesTarget series) => series switch
    {
        ComparisonSeriesTarget.First => 1,
        ComparisonSeriesTarget.Second => 2,
        _ => throw new LegacySceneDataException("Unknown comparison series target.")
    };

    private static void ValidateQuote(ComparisonQuoteData? value)
    {
        var quote = LegacySceneBuilderGuard.NotNull(value, "Quote");
        _ = SeriesNumber(quote.Series);
        _ = quote.Market switch
        {
            ComparisonEquityMarket.Kospi or ComparisonEquityMarket.Kosdaq => true,
            _ => throw new LegacySceneDataException("Unknown comparison equity market.")
        };
        _ = quote.Direction switch
        {
            ComparisonChangeDirection.LimitUp or
            ComparisonChangeDirection.Up or
            ComparisonChangeDirection.Flat or
            ComparisonChangeDirection.Down or
            ComparisonChangeDirection.LimitDown => true,
            _ => throw new LegacySceneDataException("Unknown comparison change direction.")
        };
        LegacySceneBuilderGuard.Text(quote.StockName, "StockName");
        if (!double.IsFinite(quote.Rate))
        {
            throw new LegacySceneDataException("Comparison quote rate must be finite.");
        }
    }
}

internal static class ComparisonSceneMutations
{
    public static void AppendQuoteHeader(
        ICollection<PlayoutMutation> mutations,
        ComparisonQuoteData first,
        ComparisonQuoteData second)
    {
        mutations.Add(new PlayoutSetValue("title1", first.StockName));
        mutations.Add(new PlayoutSetValue("title2", second.StockName));
        mutations.Add(new PlayoutSetValue(
            "price1",
            first.CurrentPrice.ToString("#,##0", CultureInfo.InvariantCulture)));
        mutations.Add(new PlayoutSetValue(
            "price2",
            second.CurrentPrice.ToString("#,##0", CultureInfo.InvariantCulture)));
        mutations.Add(new PlayoutSetValue(
            "changePrice1",
            first.NetChange.ToString("#,##0", CultureInfo.InvariantCulture)));
        mutations.Add(new PlayoutSetValue(
            "changePrice2",
            second.NetChange.ToString("#,##0", CultureInfo.InvariantCulture)));
        mutations.Add(new PlayoutSetValue(
            "rate1",
            first.Rate.ToString("#,##0.00", CultureInfo.InvariantCulture)));
        mutations.Add(new PlayoutSetValue(
            "rate2",
            second.Rate.ToString("#,##0.00", CultureInfo.InvariantCulture)));
    }

    public static void AppendInitialDirectionHides(
        ICollection<PlayoutMutation> mutations,
        ComparisonInitialHideStyle style)
    {
        string[][] orders = style switch
        {
            ComparisonInitialHideStyle.S5026 =>
            [
                ["up", "flat", "down"],
                ["up", "flat", "down"]
            ],
            ComparisonInitialHideStyle.S5029 =>
            [
                ["upup", "up", "flat", "downdown", "down"],
                ["upup", "up", "flat", "downdown", "down"]
            ],
            ComparisonInitialHideStyle.S5087 =>
            [
                ["up", "upup", "flat", "down", "downdown"],
                ["upup", "up", "flat", "down", "downdown"]
            ],
            _ => throw new LegacySceneDataException("Unknown comparison visibility style.")
        };

        for (var series = 1; series <= 2; series++)
        {
            foreach (var objectName in orders[series - 1])
            {
                mutations.Add(new PlayoutSetVisible(objectName + series, false));
            }
        }
    }

    public static void AppendDirection(
        ICollection<PlayoutMutation> mutations,
        ComparisonSeriesTarget target,
        ComparisonChangeDirection direction)
    {
        var suffix = ComparisonSceneGuard.SeriesNumber(target)
            .ToString(CultureInfo.InvariantCulture);
        foreach (var name in new[] { "upup", "up", "flat", "down", "downdown" })
        {
            mutations.Add(new PlayoutSetVisible(name + suffix, false));
        }

        var (active, selectedBackground) = direction switch
        {
            ComparisonChangeDirection.LimitUp => ("upup", 1),
            ComparisonChangeDirection.Up => ("up", 1),
            ComparisonChangeDirection.Flat => ("flat", 2),
            ComparisonChangeDirection.Down => ("down", 3),
            ComparisonChangeDirection.LimitDown => ("downdown", 3),
            _ => throw new LegacySceneDataException("Unknown comparison change direction.")
        };
        mutations.Add(new PlayoutSetVisible(active + suffix, true));
        for (var variant = 1; variant <= 3; variant++)
        {
            mutations.Add(new PlayoutSetVisible(
                $"bg{suffix}_{variant}",
                variant == selectedBackground));
        }
    }

    public static void AppendCandleDate(
        ICollection<PlayoutMutation> mutations,
        int seriesNumber,
        int candleNumber,
        int candleCount,
        DateOnly date)
    {
        int? slot = candleNumber switch
        {
            1 => 1,
            _ when candleNumber == candleCount => 5,
            _ when candleNumber == (int)Math.Ceiling(candleCount / 2d) => 3,
            _ => null
        };
        if (slot.HasValue)
        {
            mutations.Add(new PlayoutSetValue(
                $"date{seriesNumber}_{slot.Value}",
                FormatDate(date)));
        }
    }

    public static void AppendYieldDate(
        ICollection<PlayoutMutation> mutations,
        string seriesSuffix,
        int index,
        int count,
        DateOnly date)
    {
        var last = count - 1;
        int? slot = index switch
        {
            0 => 5,
            _ when index == last => 1,
            _ when index == (int)Math.Ceiling(last / 2d) => 3,
            _ when index == (int)Math.Ceiling(last / 4d) => 4,
            _ when index == (int)Math.Ceiling(last * 3d / 4d) => 2,
            _ => null
        };
        if (slot.HasValue)
        {
            var objectName = string.IsNullOrEmpty(seriesSuffix)
                ? "date" + slot.Value
                : "date" + seriesSuffix + "_" + slot.Value;
            mutations.Add(new PlayoutSetValue(
                objectName,
                FormatDate(date)));
        }
    }

    public static void AppendSignedGraphs(
        ICollection<PlayoutMutation> mutations,
        string suffix,
        float positionX,
        float baseline,
        PlayoutVectorComponents positionComponents,
        float maximumX,
        IReadOnlyList<PlayoutPoint> plus,
        IReadOnlyList<PlayoutPoint> minus)
    {
        mutations.Add(new PlayoutSetPosition(
            "minusGraph" + suffix,
            positionX,
            baseline,
            0,
            positionComponents));
        mutations.Add(new PlayoutSetPathShapePoints(
            "minusGraph" + suffix,
            SignedShapePoints(minus, maximumX, invertY: true)));
        mutations.Add(new PlayoutSetPosition(
            "plusGraph" + suffix,
            positionX,
            baseline,
            0,
            positionComponents));
        mutations.Add(new PlayoutSetPathShapePoints(
            "plusGraph" + suffix,
            SignedShapePoints(plus, maximumX, invertY: false)));
    }

    private static IReadOnlyList<PlayoutPoint> SignedShapePoints(
        IReadOnlyList<PlayoutPoint> source,
        float maximumX,
        bool invertY)
    {
        var points = new List<PlayoutPoint>(source.Count + 2) { new(0, 0, 0) };
        points.AddRange(source.Select(point => new PlayoutPoint(
            point.X,
            invertY ? -point.Y : point.Y,
            0)));
        points.Add(new PlayoutPoint(maximumX, 0, 0));
        return points;
    }

    private static string FormatDate(DateOnly date) =>
        date.ToString("MM/dd", CultureInfo.InvariantCulture);
}

internal enum YieldValueFormat
{
    Integer,
    DecimalTwo
}

internal enum YieldHeaderOrder
{
    UnitThenTitle,
    UnitTitleUnit,
    TitleThenUnit
}

internal sealed record YieldInstrumentPresentation(
    string Title,
    string Unit,
    YieldValueFormat Format,
    YieldHeaderOrder HeaderOrder);

internal sealed record NormalizedYieldPoint(DateOnly DataDay, double Value);

internal static class SingleYieldSceneGuard
{
    public static string Subtitle(YieldGraphPeriod period) => period switch
    {
        YieldGraphPeriod.FiveDays => "1주일",
        YieldGraphPeriod.TwentyDays => "1개월",
        YieldGraphPeriod.SixtyDays => "3개월",
        YieldGraphPeriod.OneHundredTwentyDays => "6개월",
        YieldGraphPeriod.TwoHundredFortyDays => "12개월",
        _ => throw new LegacySceneDataException("Unknown yield graph period.")
    };

    public static YieldInstrumentPresentation Presentation(
        YieldGraphInstrument? source,
        bool slashFxTitle)
    {
        var instrument = LegacySceneBuilderGuard.NotNull(source, "Instrument");
        return instrument.Kind switch
        {
            YieldGraphInstrumentKind.WonDollar => Fx("원달러", "원/달러"),
            YieldGraphInstrumentKind.WonYen => Fx("원엔", "원/엔"),
            YieldGraphInstrumentKind.WonYuan => Fx("원위엔", "원/위엔"),
            YieldGraphInstrumentKind.WonEuro => Fx("원유로", "원/유로"),
            YieldGraphInstrumentKind.KospiIndex => Index("코스피 수익률"),
            YieldGraphInstrumentKind.KosdaqIndex => Index("코스닥 수익률"),
            YieldGraphInstrumentKind.Kospi200Index => Index("코스피200 수익률"),
            YieldGraphInstrumentKind.Krx100Index => Index("KRX100 수익률"),
            YieldGraphInstrumentKind.KospiIndustry => Industry("코스피 - "),
            YieldGraphInstrumentKind.KosdaqIndustry => Industry("코스닥 - "),
            YieldGraphInstrumentKind.KospiStock or YieldGraphInstrumentKind.KosdaqStock => Stock(),
            _ => throw new LegacySceneDataException("Unknown yield graph instrument.")
        };

        YieldInstrumentPresentation Fx(string yieldTitle, string slashTitle) => new(
            slashFxTitle ? slashTitle : yieldTitle + " 수익률",
            "단위 : 원",
            YieldValueFormat.DecimalTwo,
            YieldHeaderOrder.UnitThenTitle);

        static YieldInstrumentPresentation Index(string title) => new(
            title,
            "단위 : p",
            YieldValueFormat.DecimalTwo,
            YieldHeaderOrder.UnitTitleUnit);

        YieldInstrumentPresentation Industry(string prefix) => new(
            prefix + LegacySceneBuilderGuard.Text(instrument.Name, "Instrument.Name"),
            "단위 : p",
            YieldValueFormat.DecimalTwo,
            YieldHeaderOrder.TitleThenUnit);

        YieldInstrumentPresentation Stock() => new(
            LegacySceneBuilderGuard.Text(instrument.Name, "Instrument.Name"),
            "단위 : 원",
            YieldValueFormat.Integer,
            YieldHeaderOrder.UnitThenTitle);
    }

    public static IReadOnlyList<NormalizedYieldPoint> NormalizeS5086Points(
        IReadOnlyList<YieldGraphPoint>? source)
    {
        var points = LegacySceneBuilderGuard.Range(source, 1, 10_000, "Points");
        var normalized = new NormalizedYieldPoint[points.Count];
        double? previous = null;
        for (var index = 0; index < points.Count; index++)
        {
            var point = LegacySceneBuilderGuard.NotNull(points[index], $"Points[{index}]");
            if (point.Value is null)
            {
                // The source attempted rows[i-1] and crashes when i==0. Fail closed there;
                // later missing rows are explicitly forward-filled, which is the source comment's intent.
                if (previous is null)
                {
                    throw new LegacySceneDataException("The first s5086 graph value is required.");
                }

                normalized[index] = new NormalizedYieldPoint(point.DataDay, previous.Value);
                continue;
            }

            if (!double.IsFinite(point.Value.Value))
            {
                throw new LegacySceneDataException("Yield graph values must be finite.");
            }

            previous = point.Value.Value;
            normalized[index] = new NormalizedYieldPoint(point.DataDay, previous.Value);
        }

        return normalized;
    }

    public static IReadOnlyList<NormalizedYieldPoint> RequireCompletePoints(
        IReadOnlyList<YieldGraphPoint>? source,
        string scene)
    {
        var points = LegacySceneBuilderGuard.Range(source, 1, 10_000, "Points");
        var normalized = new NormalizedYieldPoint[points.Count];
        for (var index = 0; index < points.Count; index++)
        {
            var point = LegacySceneBuilderGuard.NotNull(points[index], $"Points[{index}]");
            if (point.Value is null || !double.IsFinite(point.Value.Value))
            {
                throw new LegacySceneDataException(scene + " requires every graph value.");
            }

            normalized[index] = new NormalizedYieldPoint(point.DataDay, point.Value.Value);
        }

        return normalized;
    }

    public static string Format(double value, YieldValueFormat format) => format switch
    {
        YieldValueFormat.Integer => value.ToString("#,##0", CultureInfo.InvariantCulture),
        YieldValueFormat.DecimalTwo => value.ToString("#,##0.00", CultureInfo.InvariantCulture),
        _ => throw new LegacySceneDataException("Unknown yield value format.")
    };
}

internal static class SingleYieldSceneMutations
{
    public static void AppendHeader(
        ICollection<PlayoutMutation> mutations,
        string subtitle,
        YieldInstrumentPresentation presentation)
    {
        mutations.Add(new PlayoutSetValue("subtitle", subtitle));
        switch (presentation.HeaderOrder)
        {
            case YieldHeaderOrder.UnitThenTitle:
                mutations.Add(new PlayoutSetValue("unit", presentation.Unit));
                mutations.Add(new PlayoutSetValue("title", presentation.Title));
                break;
            case YieldHeaderOrder.UnitTitleUnit:
                mutations.Add(new PlayoutSetValue("unit", presentation.Unit));
                mutations.Add(new PlayoutSetValue("title", presentation.Title));
                mutations.Add(new PlayoutSetValue("unit", presentation.Unit));
                break;
            case YieldHeaderOrder.TitleThenUnit:
                mutations.Add(new PlayoutSetValue("title", presentation.Title));
                mutations.Add(new PlayoutSetValue("unit", presentation.Unit));
                break;
            default:
                throw new LegacySceneDataException("Unknown yield header order.");
        }
    }

    public static void AppendDates(
        ICollection<PlayoutMutation> mutations,
        IReadOnlyList<NormalizedYieldPoint> points)
    {
        for (var index = 0; index < points.Count; index++)
        {
            ComparisonSceneMutations.AppendYieldDate(
                mutations,
                string.Empty,
                index,
                points.Count,
                points[index].DataDay);
        }
    }

    public static void AppendGraph(
        ICollection<PlayoutMutation> mutations,
        IReadOnlyList<NormalizedYieldPoint> points,
        YieldValueFormat format)
    {
        var maximum = points.Max(item => item.Value);
        var minimum = points.Min(item => item.Value);
        var range = maximum - minimum;
        if (!double.IsFinite(range))
        {
            throw new LegacySceneDataException("Yield graph range must be finite.");
        }

        var reference = points[^1].Value;
        // The source divides by max-min. Equal values retain the fixed -75 baseline.
        var baseline = range == 0d
            ? -75f
            : ComparisonGraphSafety.Float(
                ((reference - minimum) / range * 225d) - 75d,
                "Yield baseline");
        mutations.Add(new PlayoutSetPosition(
            "baseline",
            0,
            baseline,
            0,
            PlayoutVectorComponents.Y));

        var reversed = points.Select(item => item.Value).Reverse().ToArray();
        const float maximumX = 1333f;
        // The source divides by count-1. A single point is pinned at X=0 instead of NaN.
        var graphStep = reversed.Length == 1 ? 0d : maximumX / (reversed.Length - 1d);
        const double tagStart = -647.50d;
        const double tagEnd = 653.79d;
        var tagStep = reversed.Length == 1
            ? 0d
            : (tagEnd - tagStart) / (reversed.Length - 1d);
        var plus = new List<PlayoutPoint>();
        var minus = new List<PlayoutPoint>();
        var previous = 0d;
        for (var index = 0; index < reversed.Length; index++)
        {
            var x = graphStep * index;
            var value = range == 0d
                ? 0d
                : (reference - reversed[index]) / range * 225d;
            ComparisonGraphSafety.AppendSignedPoint(plus, minus, x, value, graphStep, previous);
            previous = value;
            var tagX = ComparisonGraphSafety.Float(
                tagStart + (index * tagStep),
                "Yield tag X");

            if (reversed[index] == maximum)
            {
                mutations.Add(new PlayoutSetPosition(
                    "highTag",
                    tagX,
                    0,
                    0,
                    PlayoutVectorComponents.X));
                mutations.Add(new PlayoutSetValue(
                    "highPrice",
                    SingleYieldSceneGuard.Format(reversed[index], format)));
            }

            if (reversed[index] == minimum)
            {
                mutations.Add(new PlayoutSetPosition(
                    "lowTag",
                    tagX,
                    0,
                    0,
                    PlayoutVectorComponents.X));
                mutations.Add(new PlayoutSetValue(
                    "lowPrice",
                    SingleYieldSceneGuard.Format(reversed[index], format)));
            }
        }

        ComparisonSceneMutations.AppendSignedGraphs(
            mutations,
            string.Empty,
            -659.82f,
            baseline,
            PlayoutVectorComponents.XYZ,
            maximumX,
            plus,
            minus);
    }
}

internal static class ComparisonGraphSafety
{
    public static float RangePercent(double value, double maximum, double range)
    {
        var result = range == 0d
            ? 0d
            : Math.Abs(100d * (value - maximum) / range);
        return Float(result, "Candle crop");
    }

    public static void AppendSignedPoint(
        ICollection<PlayoutPoint> plus,
        ICollection<PlayoutPoint> minus,
        double x,
        double value,
        double step,
        double previous)
    {
        if ((previous > 0d && value < 0d) || (previous < 0d && value > 0d))
        {
            var denominator = Math.Abs(previous - value);
            var crossingX = denominator == 0d
                ? x
                : x - (step * (Math.Abs(value) / denominator));
            var crossing = new PlayoutPoint(Float(crossingX, "Graph crossing X"), 0, 0);
            plus.Add(crossing);
            minus.Add(crossing);
        }

        var point = new PlayoutPoint(
            Float(x, "Graph X"),
            Float(value, "Graph Y"),
            0);
        if (value < 0d)
        {
            plus.Add(point);
        }
        else if (value > 0d)
        {
            minus.Add(point);
        }
        else
        {
            plus.Add(point);
            minus.Add(point);
        }
    }

    public static float Float(double value, string name)
    {
        if (!double.IsFinite(value) || value < -float.MaxValue || value > float.MaxValue)
        {
            throw new LegacySceneDataException(name + " must be a finite float.");
        }

        return (float)value;
    }
}
