using MBN_STOCK_WEBVIEW.Core.Playout;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;

namespace MBN_STOCK_WEBVIEW.Core.Tests;

public sealed class ComparisonAndYieldSceneBuildersTests
{
    [Fact]
    public void S5026_matches_legacy_header_direction_and_two_candle_series_order()
    {
        var actual = new S5026SceneMutationBuilder().Build(new S5026SceneData(
            ComparisonGraphPeriod.FiveDays,
            [CandleSeries(ComparisonSeriesTarget.Second), CandleSeries(ComparisonSeriesTarget.First)]))
            .Select(Describe)
            .ToArray();

        var expected = ExpectedComparisonHeader(ComparisonHeaderStyle.S5026)
            .Concat(ExpectedCandleSeries(1))
            .Concat(ExpectedCandleSeries(2));
        Assert.Equal(expected, actual);
    }

    [Fact]
    public void S5026_equal_range_is_finite_and_missing_or_nonfinite_candles_fail_closed()
    {
        var equal = Enumerable.Range(1, 5)
            .Select(index => new ComparisonCandlePoint(
                new DateOnly(2026, 1, index),
                100,
                100,
                100,
                100))
            .ToArray();
        var first = CandleSeries(ComparisonSeriesTarget.First) with { Candles = equal };
        var second = CandleSeries(ComparisonSeriesTarget.Second) with { Candles = equal };
        var builder = new S5026SceneMutationBuilder();
        var mutations = builder.Build(new S5026SceneData(
            ComparisonGraphPeriod.FiveDays,
            [first, second]));

        AssertFinite(mutations);
        Assert.All(
            mutations.OfType<PlayoutSetCropKey>().Where(item => item.ObjectName.Contains("candlebar")),
            crop =>
            {
                Assert.Equal(0, crop.Top);
                Assert.Equal(0, crop.Bottom);
            });

        Assert.Throws<LegacySceneDataException>(() => builder.Build(new S5026SceneData(
            ComparisonGraphPeriod.FiveDays,
            [first with { Candles = equal.Take(4).ToArray() }, second])));
        Assert.Throws<LegacySceneDataException>(() => builder.Build(new S5026SceneData(
            ComparisonGraphPeriod.FiveDays,
            [
                first with
                {
                    Candles = equal.Select((item, index) => index == 0
                        ? item with { High = double.NaN }
                        : item).ToArray()
                },
                second
            ])));
    }

    [Fact]
    public void S5029_matches_legacy_two_series_dates_tags_baselines_and_shapes()
    {
        var actual = new S5029SceneMutationBuilder().Build(new S5029SceneData(
            ComparisonGraphPeriod.OneMonth,
            [YieldSeries(ComparisonSeriesTarget.First), YieldSeries(ComparisonSeriesTarget.Second)]))
            .Select(Describe)
            .ToArray();

        var expected = ExpectedComparisonHeader(ComparisonHeaderStyle.S5029)
            .Concat(ExpectedYieldSeries(1, 812f, 100f))
            .Concat(ExpectedYieldSeries(2, 812f, -50f));
        Assert.Equal(expected, actual);
    }

    [Fact]
    public void S5087_preserves_its_distinct_graph_geometry_and_omits_absent_asset_guides()
    {
        var actual = new S5087SceneMutationBuilder().Build(new S5087SceneData(
            ComparisonGraphPeriod.FiveDays,
            [YieldSeries(ComparisonSeriesTarget.Second), YieldSeries(ComparisonSeriesTarget.First)]))
            .Select(Describe)
            .ToArray();

        var expected = ExpectedComparisonHeader(ComparisonHeaderStyle.S5087)
            .Concat(ExpectedS5087YieldSeries(1, 700f, -20f))
            .Concat(ExpectedS5087YieldSeries(2, 700f, -170f));
        Assert.Equal(expected, actual);
        Assert.DoesNotContain(actual, item =>
            item.StartsWith("V:date1_2=", StringComparison.Ordinal) ||
            item.StartsWith("V:date1_4=", StringComparison.Ordinal) ||
            item.StartsWith("V:date2_2=", StringComparison.Ordinal) ||
            item.StartsWith("V:date2_4=", StringComparison.Ordinal) ||
            item.StartsWith("P:baseline", StringComparison.Ordinal) ||
            item.StartsWith("P:highTag", StringComparison.Ordinal) ||
            item.StartsWith("P:lowTag", StringComparison.Ordinal) ||
            item.StartsWith("V:highPrice", StringComparison.Ordinal) ||
            item.StartsWith("V:lowPrice", StringComparison.Ordinal));
    }

    [Fact]
    public void Comparison_yield_one_point_and_equal_values_correct_source_denominators_to_finite_zero()
    {
        var first = YieldSeries(ComparisonSeriesTarget.First) with
        {
            Points = [new(new DateOnly(2026, 1, 1), 10)]
        };
        var second = YieldSeries(ComparisonSeriesTarget.Second) with
        {
            Points = [new(new DateOnly(2026, 1, 1), -10)]
        };

        var mutations = new S5029SceneMutationBuilder().Build(new S5029SceneData(
            ComparisonGraphPeriod.Daily,
            [first, second]));

        AssertFinite(mutations);
        Assert.Equal(-50, mutations.OfType<PlayoutSetPosition>()
            .Single(item => item.ObjectName == "baseline1").Y);
        Assert.All(
            mutations.OfType<PlayoutSetPathShapePoints>().SelectMany(item => item.Points),
            point => Assert.True(float.IsFinite(point.X) && float.IsFinite(point.Y)));

        Assert.Throws<LegacySceneDataException>(() =>
            new S5029SceneMutationBuilder().Build(new S5029SceneData(
                ComparisonGraphPeriod.Daily,
                [first with { Points = [] }, second])));
    }

    [Fact]
    public void Comparison_yield_sign_crossing_adds_the_same_finite_zero_intersection_to_both_shapes()
    {
        var crossing = YieldSeries(ComparisonSeriesTarget.First) with
        {
            Points =
            [
                new(new DateOnly(2026, 1, 1), 100),
                new(new DateOnly(2026, 1, 2), 0),
                new(new DateOnly(2026, 1, 3), 150),
                new(new DateOnly(2026, 1, 4), 50),
                new(new DateOnly(2026, 1, 5), 100)
            ]
        };
        var mutations = new S5029SceneMutationBuilder().Build(new S5029SceneData(
            ComparisonGraphPeriod.FiveDays,
            [crossing, YieldSeries(ComparisonSeriesTarget.Second)]));
        var plus = mutations.OfType<PlayoutSetPathShapePoints>()
            .Single(item => item.ObjectName == "plusGraph1");
        var minus = mutations.OfType<PlayoutSetPathShapePoints>()
            .Single(item => item.ObjectName == "minusGraph1");

        Assert.Contains(plus.Points, point => point.X == 304.5f && point.Y == 0);
        Assert.Contains(minus.Points, point => point.X == 304.5f && point.Y == 0);
        AssertFinite(mutations);
    }

    [Fact]
    public void S5086_matches_legacy_fx_header_quote_dates_tags_and_graph_order()
    {
        var actual = new S5086SceneMutationBuilder().Build(new S5086SceneData(
            YieldGraphPeriod.TwentyDays,
            new YieldGraphInstrument(YieldGraphInstrumentKind.WonDollar),
            SingleYieldPoints()))
            .Select(Describe)
            .ToArray();

        Assert.Equal(
        [
            "V:subtitle=1개월", "V:unit=단위 : 원", "V:title=원달러 수익률",
            "V:date5=01/01", "V:date4=01/02", "V:date3=01/03",
            "V:date2=01/04", "V:date1=01/05",
            "V:price=0.00", "V:referencePrice=100.00", "V:rate=-100.00",
            "S:bg1=0", "S:bg2=0", "S:bg3=1",
            "P:baseline:0:150:0:2",
            "P:highTag:-647.5:0:0:1", "V:highPrice=100.00",
            "P:lowTag:653.79:0:0:1", "V:lowPrice=0.00",
            "P:minusGraph:-659.82:150:0:7",
            "H:minusGraph=0,0,0|0,-0,0|333.25,-56.25,0|666.5,-112.5,0|999.75,-168.75,0|1333,-225,0|1333,0,0",
            "P:plusGraph:-659.82:150:0:7",
            "H:plusGraph=0,0,0|0,0,0|1333,0,0"
        ], actual);
    }

    [Fact]
    public void S50860_matches_legacy_slash_title_quote_direction_color_and_graph_order()
    {
        var actual = new S50860SceneMutationBuilder().Build(new S50860SceneData(
            YieldGraphPeriod.TwentyDays,
            new YieldGraphInstrument(YieldGraphInstrumentKind.WonDollar),
            -12.34,
            1.25,
            YieldQuoteDirection.Up,
            SingleYieldPoints()))
            .Select(Describe)
            .ToArray();

        Assert.Equal(
        [
            "V:subtitle=1개월", "V:unit=단위 : 원", "V:title=원/달러",
            "V:date5=01/01", "V:date4=01/02", "V:date3=01/03",
            "V:date2=01/04", "V:date1=01/05",
            "V:price=0.00", "V:changePrice=12.34", "V:rate=1.25",
            "S:up=0", "S:flat=0", "S:down=0", "S:up=1",
            "C:bg:170:0:0:255",
            "P:baseline:0:150:0:2",
            "P:highTag:-647.5:0:0:1", "V:highPrice=100.00",
            "P:lowTag:653.79:0:0:1", "V:lowPrice=0.00",
            "P:minusGraph:-659.82:150:0:7",
            "H:minusGraph=0,0,0|0,-0,0|333.25,-56.25,0|666.5,-112.5,0|999.75,-168.75,0|1333,-225,0|1333,0,0",
            "P:plusGraph:-659.82:150:0:7",
            "H:plusGraph=0,0,0|0,0,0|1333,0,0"
        ], actual);
    }

    [Fact]
    public void S5086_missing_rows_apply_documented_forward_fill_but_first_missing_fails_closed()
    {
        var builder = new S5086SceneMutationBuilder();
        var corrected = builder.Build(new S5086SceneData(
            YieldGraphPeriod.FiveDays,
            new YieldGraphInstrument(YieldGraphInstrumentKind.KospiStock, "A사"),
            [
                new(new DateOnly(2026, 1, 1), 10),
                new(new DateOnly(2026, 1, 2), null),
                new(new DateOnly(2026, 1, 3), 20)
            ]));

        AssertFinite(corrected);
        Assert.Equal("V:rate=-50.00", Describe(corrected.OfType<PlayoutSetValue>()
            .Single(item => item.ObjectName == "rate")));
        Assert.Throws<LegacySceneDataException>(() => builder.Build(new S5086SceneData(
            YieldGraphPeriod.FiveDays,
            new YieldGraphInstrument(YieldGraphInstrumentKind.KospiStock, "A사"),
            [new(new DateOnly(2026, 1, 1), null)])));
        Assert.Throws<LegacySceneDataException>(() => builder.Build(new S5086SceneData(
            YieldGraphPeriod.FiveDays,
            new YieldGraphInstrument(YieldGraphInstrumentKind.KospiStock, "A사"),
            [new(new DateOnly(2026, 1, 1), double.NegativeInfinity)])));
    }

    [Fact]
    public void Single_yield_zero_range_zero_reference_and_one_point_are_explicitly_finite()
    {
        var point = new YieldGraphPoint(new DateOnly(2026, 1, 1), 0);
        var s5086 = new S5086SceneMutationBuilder().Build(new S5086SceneData(
            YieldGraphPeriod.FiveDays,
            new YieldGraphInstrument(YieldGraphInstrumentKind.KosdaqIndex),
            [point]));
        var s50860 = new S50860SceneMutationBuilder().Build(new S50860SceneData(
            YieldGraphPeriod.FiveDays,
            new YieldGraphInstrument(YieldGraphInstrumentKind.KosdaqIndex),
            0,
            0,
            YieldQuoteDirection.Flat,
            [point]));

        AssertFinite(s5086);
        AssertFinite(s50860);
        Assert.Equal("V:rate=0.00", Describe(s5086.OfType<PlayoutSetValue>()
            .Single(item => item.ObjectName == "rate")));
        Assert.All(
            s5086.Concat(s50860).OfType<PlayoutSetPosition>()
                .Where(item => item.ObjectName == "baseline"),
            position => Assert.Equal(-75, position.Y));

        var signed = new S5086SceneMutationBuilder().Build(new S5086SceneData(
            YieldGraphPeriod.FiveDays,
            new YieldGraphInstrument(YieldGraphInstrumentKind.KospiStock, "A사"),
            [
                new(new DateOnly(2026, 1, 1), -10),
                new(new DateOnly(2026, 1, 2), 0),
                new(new DateOnly(2026, 1, 3), 10)
            ]));
        AssertFinite(signed);
    }

    [Fact]
    public void S50860_rejects_missing_or_nonfinite_rows_and_quote_values()
    {
        var builder = new S50860SceneMutationBuilder();
        var instrument = new YieldGraphInstrument(YieldGraphInstrumentKind.KospiStock, "A사");
        Assert.Throws<LegacySceneDataException>(() => builder.Build(new S50860SceneData(
            YieldGraphPeriod.FiveDays,
            instrument,
            0,
            0,
            YieldQuoteDirection.Flat,
            [new(new DateOnly(2026, 1, 1), null)])));
        Assert.Throws<LegacySceneDataException>(() => builder.Build(new S50860SceneData(
            YieldGraphPeriod.FiveDays,
            instrument,
            double.PositiveInfinity,
            0,
            YieldQuoteDirection.Flat,
            [new(new DateOnly(2026, 1, 1), 1)])));
    }

    [Fact]
    public void Closed_period_market_series_instrument_and_direction_mappings_reject_unknown_values()
    {
        Assert.Throws<LegacySceneDataException>(() =>
            new S5026SceneMutationBuilder().Build(new S5026SceneData(
                (ComparisonGraphPeriod)99,
                [CandleSeries(ComparisonSeriesTarget.First), CandleSeries(ComparisonSeriesTarget.Second)])));

        var badMarket = CandleSeries(ComparisonSeriesTarget.First) with
        {
            Quote = Quote(ComparisonSeriesTarget.First) with
            {
                Market = (ComparisonEquityMarket)99
            }
        };
        Assert.Throws<LegacySceneDataException>(() =>
            new S5026SceneMutationBuilder().Build(new S5026SceneData(
                ComparisonGraphPeriod.FiveDays,
                [badMarket, CandleSeries(ComparisonSeriesTarget.Second)])));

        var badDirection = CandleSeries(ComparisonSeriesTarget.First) with
        {
            Quote = Quote(ComparisonSeriesTarget.First) with
            {
                Direction = (ComparisonChangeDirection)99
            }
        };
        Assert.Throws<LegacySceneDataException>(() =>
            new S5026SceneMutationBuilder().Build(new S5026SceneData(
                ComparisonGraphPeriod.FiveDays,
                [badDirection, CandleSeries(ComparisonSeriesTarget.Second)])));

        var badSeries = YieldSeries(ComparisonSeriesTarget.First) with
        {
            Quote = Quote((ComparisonSeriesTarget)99)
        };
        Assert.Throws<LegacySceneDataException>(() =>
            new S5029SceneMutationBuilder().Build(new S5029SceneData(
                ComparisonGraphPeriod.FiveDays,
                [badSeries, YieldSeries(ComparisonSeriesTarget.Second)])));

        Assert.Throws<LegacySceneDataException>(() =>
            new S5086SceneMutationBuilder().Build(new S5086SceneData(
                YieldGraphPeriod.FiveDays,
                new YieldGraphInstrument((YieldGraphInstrumentKind)99),
                [new(new DateOnly(2026, 1, 1), 1)])));
        Assert.Throws<LegacySceneDataException>(() =>
            new S5086SceneMutationBuilder().Build(new S5086SceneData(
                (YieldGraphPeriod)99,
                new YieldGraphInstrument(YieldGraphInstrumentKind.KospiIndex),
                [new(new DateOnly(2026, 1, 1), 1)])));
        Assert.Throws<LegacySceneDataException>(() =>
            new S50860SceneMutationBuilder().Build(new S50860SceneData(
                YieldGraphPeriod.FiveDays,
                new YieldGraphInstrument(YieldGraphInstrumentKind.KospiIndex),
                0,
                0,
                (YieldQuoteDirection)99,
                [new(new DateOnly(2026, 1, 1), 1)])));
    }

    [Fact]
    public void Builders_expose_closed_scene_keys()
    {
        Assert.Equal("s5026", new S5026SceneMutationBuilder().BuilderKey);
        Assert.Equal("s5029", new S5029SceneMutationBuilder().BuilderKey);
        Assert.Equal("s5086", new S5086SceneMutationBuilder().BuilderKey);
        Assert.Equal("s50860", new S50860SceneMutationBuilder().BuilderKey);
        Assert.Equal("s5087", new S5087SceneMutationBuilder().BuilderKey);
    }

    private static ComparisonCandleSeriesData CandleSeries(ComparisonSeriesTarget target) => new(
        Quote(target),
        [
            new(new DateOnly(2026, 1, 1), 200, 100, 150, 180),
            new(new DateOnly(2026, 1, 2), 190, 110, 180, 140),
            new(new DateOnly(2026, 1, 3), 180, 120, 150, 150),
            new(new DateOnly(2026, 1, 4), 170, 130, 140, 160),
            new(new DateOnly(2026, 1, 5), 160, 140, 150, 145)
        ]);

    private static ComparisonYieldSeriesData YieldSeries(ComparisonSeriesTarget target)
    {
        var values = target == ComparisonSeriesTarget.First
            ? new[] { 0, 25, 50, 75, 100 }
            : new[] { 100, 75, 50, 25, 0 };
        return new ComparisonYieldSeriesData(
            Quote(target),
            values.Select((value, index) => new ComparisonYieldPoint(
                new DateOnly(2026, 1, index + 1),
                value)).ToArray());
    }

    private static ComparisonQuoteData Quote(ComparisonSeriesTarget target) => new(
        target,
        target == ComparisonSeriesTarget.First
            ? ComparisonEquityMarket.Kospi
            : ComparisonEquityMarket.Kosdaq,
        target == ComparisonSeriesTarget.First ? "Alpha" : "Beta",
        target == ComparisonSeriesTarget.First ? 1000 : 2000,
        target == ComparisonSeriesTarget.First ? 10 : -20,
        target == ComparisonSeriesTarget.First ? 1.25 : -2.5,
        target == ComparisonSeriesTarget.First
            ? ComparisonChangeDirection.Up
            : ComparisonChangeDirection.Down);

    private static YieldGraphPoint[] SingleYieldPoints() =>
    [
        new(new DateOnly(2026, 1, 1), 0),
        new(new DateOnly(2026, 1, 2), 25),
        new(new DateOnly(2026, 1, 3), 50),
        new(new DateOnly(2026, 1, 4), 75),
        new(new DateOnly(2026, 1, 5), 100)
    ];

    private static IEnumerable<string> ExpectedComparisonHeader(ComparisonHeaderStyle style)
    {
        yield return "V:title1=Alpha";
        yield return "V:title2=Beta";
        yield return "V:price1=1,000";
        yield return "V:price2=2,000";
        yield return "V:changePrice1=10";
        yield return "V:changePrice2=-20";
        yield return "V:rate1=1.25";
        yield return "V:rate2=-2.50";

        var orders = style switch
        {
            ComparisonHeaderStyle.S5026 => new[]
            {
                new[] { "up", "flat", "down" },
                new[] { "up", "flat", "down" }
            },
            ComparisonHeaderStyle.S5029 => new[]
            {
                new[] { "upup", "up", "flat", "downdown", "down" },
                new[] { "upup", "up", "flat", "downdown", "down" }
            },
            ComparisonHeaderStyle.S5087 => new[]
            {
                new[] { "up", "upup", "flat", "down", "downdown" },
                new[] { "upup", "up", "flat", "down", "downdown" }
            },
            _ => throw new InvalidOperationException()
        };
        for (var series = 1; series <= 2; series++)
        {
            foreach (var name in orders[series - 1])
            {
                yield return $"S:{name}{series}=0";
            }
        }

        foreach (var value in ExpectedDirection(1, "up", 1))
        {
            yield return value;
        }

        foreach (var value in ExpectedDirection(2, "down", 3))
        {
            yield return value;
        }
    }

    private static IEnumerable<string> ExpectedDirection(int series, string active, int background)
    {
        foreach (var name in new[] { "upup", "up", "flat", "down", "downdown" })
        {
            yield return $"S:{name}{series}=0";
        }

        yield return $"S:{active}{series}=1";
        for (var variant = 1; variant <= 3; variant++)
        {
            yield return $"S:bg{series}_{variant}={(variant == background ? 1 : 0)}";
        }
    }

    private static IEnumerable<string> ExpectedCandleSeries(int series)
    {
        yield return $"V:date{series}_1=01/01";
        yield return $"R:candlebar{series}_1:1:0:0:0:100:10";
        yield return $"R:candlebody{series}_1:1:0:20:0:50:10";
        yield return $"C:candlebody{series}_1:170:0:0:255";
        yield return $"C:candlebar{series}_1:170:0:0:255";
        yield return $"R:candlebar{series}_2:1:0:10:0:90:10";
        yield return $"R:candlebody{series}_2:1:0:20:0:60:10";
        yield return $"C:candlebody{series}_2:15:99:189:255";
        yield return $"C:candlebar{series}_2:15:99:189:255";
        yield return $"V:date{series}_3=01/03";
        yield return $"R:candlebar{series}_3:1:0:20:0:80:10";
        yield return $"R:candlebody{series}_3:1:0:49.9:0:53:10";
        yield return $"C:candlebody{series}_3:95:95:95:255";
        yield return $"C:candlebar{series}_3:95:95:95:255";
        yield return $"R:candlebar{series}_4:1:0:30:0:70:10";
        yield return $"R:candlebody{series}_4:1:0:40:0:60:10";
        yield return $"C:candlebody{series}_4:170:0:0:255";
        yield return $"C:candlebar{series}_4:170:0:0:255";
        yield return $"V:date{series}_5=01/05";
        yield return $"R:candlebar{series}_5:1:0:40:0:60:10";
        yield return $"R:candlebody{series}_5:1:0:50:0:55:10";
        yield return $"C:candlebody{series}_5:15:99:189:255";
        yield return $"C:candlebar{series}_5:15:99:189:255";
    }

    private static IEnumerable<string> ExpectedYieldSeries(
        int series,
        float maximumX,
        float baseline)
    {
        yield return $"V:date{series}_5=01/01";
        yield return $"V:date{series}_4=01/02";
        yield return $"V:date{series}_3=01/03";
        yield return $"V:date{series}_2=01/04";
        yield return $"V:date{series}_1=01/05";
        yield return $"P:baseline{series}:0:{F(baseline)}:0:2";

        var step = maximumX / 4f;
        if (series == 1)
        {
            yield return $"P:highTag1:-383.48:0:0:1";
            yield return "V:highPrice1=100";
            yield return $"P:lowTag1:359.28:0:0:1";
            yield return "V:lowPrice1=0";
            yield return $"P:minusGraph1:0:{F(baseline)}:0:2";
            yield return "H:minusGraph1=0,0,0|0,-0,0|" +
                $"{F(step)},-37.5,0|{F(step * 2)},-75,0|" +
                $"{F(step * 3)},-112.5,0|{F(maximumX)},-150,0|{F(maximumX)},0,0";
            yield return $"P:plusGraph1:0:{F(baseline)}:0:2";
            yield return $"H:plusGraph1=0,0,0|0,0,0|{F(maximumX)},0,0";
        }
        else
        {
            yield return "P:lowTag2:-383.48:0:0:1";
            yield return "V:lowPrice2=0";
            yield return "P:highTag2:359.28:0:0:1";
            yield return "V:highPrice2=100";
            yield return $"P:minusGraph2:0:{F(baseline)}:0:2";
            yield return $"H:minusGraph2=0,0,0|0,-0,0|{F(maximumX)},0,0";
            yield return $"P:plusGraph2:0:{F(baseline)}:0:2";
            yield return "H:plusGraph2=0,0,0|0,0,0|" +
                $"{F(step)},-37.5,0|{F(step * 2)},-75,0|" +
                $"{F(step * 3)},-112.5,0|{F(maximumX)},-150,0|{F(maximumX)},0,0";
        }
    }

    private static IEnumerable<string> ExpectedS5087YieldSeries(
        int series,
        float maximumX,
        float baseline) => ExpectedYieldSeries(series, maximumX, baseline)
        .Where(item =>
            !item.StartsWith($"V:date{series}_2=", StringComparison.Ordinal) &&
            !item.StartsWith($"V:date{series}_4=", StringComparison.Ordinal) &&
            !item.StartsWith($"P:baseline{series}:", StringComparison.Ordinal) &&
            !item.StartsWith($"P:highTag{series}:", StringComparison.Ordinal) &&
            !item.StartsWith($"P:lowTag{series}:", StringComparison.Ordinal) &&
            !item.StartsWith($"V:highPrice{series}=", StringComparison.Ordinal) &&
            !item.StartsWith($"V:lowPrice{series}=", StringComparison.Ordinal));

    private static void AssertFinite(IEnumerable<PlayoutMutation> mutations)
    {
        foreach (var mutation in mutations)
        {
            switch (mutation)
            {
                case PlayoutSetPosition position:
                    Assert.True(float.IsFinite(position.X));
                    Assert.True(float.IsFinite(position.Y));
                    Assert.True(float.IsFinite(position.Z));
                    break;
                case PlayoutSetCropKey crop:
                    Assert.True(float.IsFinite(crop.Left));
                    Assert.True(float.IsFinite(crop.Top));
                    Assert.True(float.IsFinite(crop.Right));
                    Assert.True(float.IsFinite(crop.Bottom));
                    break;
                case PlayoutSetPathShapePoints path:
                    Assert.All(path.Points, point =>
                    {
                        Assert.True(float.IsFinite(point.X));
                        Assert.True(float.IsFinite(point.Y));
                        Assert.True(float.IsFinite(point.Z));
                    });
                    break;
                case PlayoutSetValue value:
                    Assert.DoesNotContain("NaN", value.Value, StringComparison.OrdinalIgnoreCase);
                    Assert.DoesNotContain("Infinity", value.Value, StringComparison.OrdinalIgnoreCase);
                    break;
            }
        }
    }

    private static string Describe(PlayoutMutation mutation) => mutation switch
    {
        PlayoutSetValue value => $"V:{value.ObjectName}={value.Value}",
        PlayoutSetVisible visible => $"S:{visible.ObjectName}={(visible.IsVisible ? 1 : 0)}",
        PlayoutSetFaceColor color =>
            $"C:{color.ObjectName}:{color.Red}:{color.Green}:{color.Blue}:{color.Alpha}",
        PlayoutSetPosition position =>
            $"P:{position.ObjectName}:{F(position.X)}:{F(position.Y)}:{F(position.Z)}:{(int)position.Components}",
        PlayoutSetCropKey crop =>
            $"R:{crop.ObjectName}:{crop.KeyIndex}:{F(crop.Left)}:{F(crop.Top)}:{F(crop.Right)}:{F(crop.Bottom)}:{(int)crop.Edges}",
        PlayoutSetPathShapePoints path => "H:" + path.ObjectName + "=" + string.Join(
            "|",
            path.Points.Select(point => $"{F(point.X)},{F(point.Y)},{F(point.Z)}")),
        _ => mutation.GetType().Name
    };

    private static string F(float value) =>
        value.ToString("R", System.Globalization.CultureInfo.InvariantCulture);

    private enum ComparisonHeaderStyle
    {
        S5026,
        S5029,
        S5087
    }
}
