using System.Data;
using MBN_STOCK_WEBVIEW.Core.Playout;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
using MMoneyCoderSharp.Data;

namespace MBN_STOCK_WEBVIEW.Core.Tests;

public sealed class ComparisonAndYieldSceneDataLoadersTests
{
    public static TheoryData<ComparisonGraphPeriod, int> ComparisonPeriods => new()
    {
        { ComparisonGraphPeriod.Daily, 1 },
        { ComparisonGraphPeriod.FiveDays, 5 },
        { ComparisonGraphPeriod.OneMonth, 20 },
        { ComparisonGraphPeriod.ThreeMonths, 60 },
        { ComparisonGraphPeriod.SixMonths, 120 },
        { ComparisonGraphPeriod.TwelveMonths, 240 }
    };

    public static TheoryData<YieldGraphPeriod, int> YieldPeriods => new()
    {
        { YieldGraphPeriod.FiveDays, 5 },
        { YieldGraphPeriod.TwentyDays, 20 },
        { YieldGraphPeriod.SixtyDays, 60 },
        { YieldGraphPeriod.OneHundredTwentyDays, 120 },
        { YieldGraphPeriod.TwoHundredFortyDays, 240 }
    };

    [Fact]
    public async Task S5026_UsesTwoOrderedParameterizedOracleQueriesAndBuildsGoldenCandleProperties()
    {
        const string firstName = "First Sensitive Stock";
        const string secondName = "Second Sensitive Stock";
        var executor = new QueueExecutor(
            CandleTable(firstName, 1000m, "2"),
            CandleTable(secondName, 2000m, "5"));

        var data = await new S5026SceneDataLoader(executor).LoadAsync(
            PairRequest(
                ComparisonGraphPeriod.FiveDays,
                firstName,
                ComparisonEquityMarket.Kospi,
                secondName,
                ComparisonEquityMarket.Kosdaq));
        var mutations = new S5026SceneMutationBuilder().Build(data);

        Assert.Equal(2, executor.Calls.Count);
        Assert.All(executor.Calls, call => Assert.Equal(DataSourceKind.Oracle, call.Source));
        Assert.Equal("SCENE_5026_SERIES_1", executor.Calls[0].TableName);
        Assert.Equal("SCENE_5026_SERIES_2", executor.Calls[1].TableName);
        Assert.Contains("FROM t_online1", executor.Calls[0].Spec.Sql, StringComparison.Ordinal);
        Assert.Contains("FROM t_kosdaq_online1", executor.Calls[1].Spec.Sql, StringComparison.Ordinal);
        Assert.DoesNotContain(firstName, executor.Calls[0].Spec.Sql, StringComparison.Ordinal);
        Assert.DoesNotContain(secondName, executor.Calls[1].Spec.Sql, StringComparison.Ordinal);
        Assert.Equal(new[] { "stockName", "stockCode", "rowLimit" },
            executor.Calls[0].Spec.Parameters.Select(parameter => parameter.Name));
        Assert.Equal(new object?[] { firstName, "FIRST001", 5 },
            executor.Calls[0].Spec.Parameters.Select(parameter => parameter.Value));
        Assert.Contains("b.f_stock_code = s.STOCK_CODE", executor.Calls[0].Spec.Sql, StringComparison.Ordinal);
        Assert.Equal(new[] { ComparisonSeriesTarget.First, ComparisonSeriesTarget.Second },
            data.Series.Select(series => series.Quote.Series));
        Assert.Equal(new[] { firstName, secondName },
            data.Series.Select(series => series.Quote.StockName));
        Assert.All(data.Series, series => Assert.Equal(5, series.Candles.Count));
        Assert.Equal(ComparisonChangeDirection.Up, data.Series[0].Quote.Direction);
        Assert.Equal(ComparisonChangeDirection.Down, data.Series[1].Quote.Direction);
        Assert.Contains(new PlayoutSetValue("title1", firstName), mutations);
        Assert.Contains(new PlayoutSetValue("title2", secondName), mutations);
        Assert.Equal(20, mutations.OfType<PlayoutSetCropKey>().Count());
        Assert.Equal(0, executor.StringCalls);
    }

    [Theory]
    [MemberData(nameof(ComparisonPeriods))]
    public async Task S5029_BindsEveryClosedPeriodLimitAndPreservesTwoSeriesOrder(
        ComparisonGraphPeriod period,
        int rowLimit)
    {
        var executor = new QueueExecutor(
            ComparisonYieldTable("First", 1000m, rowLimit),
            ComparisonYieldTable("Second", 2000m, rowLimit));

        var data = await new S5029SceneDataLoader(executor).LoadAsync(
            PairRequest(period, "First", ComparisonEquityMarket.Kospi,
                "Second", ComparisonEquityMarket.Kosdaq));
        var mutations = new S5029SceneMutationBuilder().Build(data);

        Assert.Equal(new[] { ComparisonSeriesTarget.First, ComparisonSeriesTarget.Second },
            data.Series.Select(series => series.Quote.Series));
        Assert.All(data.Series, series => Assert.Equal(rowLimit, series.Points.Count));
        Assert.All(executor.Calls, call =>
        {
            Assert.Equal(DataSourceKind.Oracle, call.Source);
            Assert.Equal(rowLimit, call.Spec.Parameters.Single(p => p.Name == "rowLimit").Value);
            Assert.Contains(":stockName", call.Spec.Sql, StringComparison.Ordinal);
            Assert.Contains(":stockCode", call.Spec.Sql, StringComparison.Ordinal);
            Assert.Contains(":rowLimit", call.Spec.Sql, StringComparison.Ordinal);
        });
        Assert.Contains("t_candle_history", executor.Calls[0].Spec.Sql, StringComparison.Ordinal);
        Assert.Contains("t_kosdaq_candle_history", executor.Calls[1].Spec.Sql, StringComparison.Ordinal);
        Assert.DoesNotContain("First", executor.Calls[0].Spec.Sql, StringComparison.Ordinal);
        Assert.DoesNotContain("Second", executor.Calls[1].Spec.Sql, StringComparison.Ordinal);
        Assert.Equal(4, mutations.OfType<PlayoutSetPathShapePoints>().Count());
        AssertFinite(mutations);
        Assert.Equal(0, executor.StringCalls);
    }

    [Fact]
    public async Task S5087_IsFiveDayOnlyAndUsesItsLoadedDtoForDistinctGraphMutations()
    {
        var validExecutor = new QueueExecutor(
            ComparisonYieldTable("First", 1000m, 5),
            ComparisonYieldTable("Second", 2000m, 5));
        var request = PairRequest(
            ComparisonGraphPeriod.FiveDays,
            "First",
            ComparisonEquityMarket.Kospi,
            "Second",
            ComparisonEquityMarket.Kosdaq);

        var data = await new S5087SceneDataLoader(validExecutor).LoadAsync(request);
        var mutations = new S5087SceneMutationBuilder().Build(data);

        Assert.Equal(2, validExecutor.Calls.Count);
        Assert.Contains(
            mutations.OfType<PlayoutSetPathShapePoints>(),
            item => item.ObjectName == "plusGraph1" && item.Points[^1].X == 700f);
        AssertFinite(mutations);

        var invalidExecutor = new QueueExecutor(ComparisonYieldTable("unused", 1m, 5));
        await Assert.ThrowsAsync<LegacySceneDataException>(() =>
            new S5087SceneDataLoader(invalidExecutor).LoadAsync(
                request with { Period = ComparisonGraphPeriod.OneMonth }));
        Assert.Empty(invalidExecutor.Calls);
    }

    [Fact]
    public async Task ComparisonLoadersFailClosedForBadSchemaCountOrderQuoteAndValues()
    {
        var extraColumn = CandleTable("First", 1000m, "2");
        extraColumn.Columns.Add("EXTRA", typeof(object));

        var wrongOrder = CandleTable("First", 1000m, "2");
        wrongOrder.Rows[1]["OPEN_DAY"] = wrongOrder.Rows[0]["OPEN_DAY"];

        var changedQuote = CandleTable("First", 1000m, "2");
        changedQuote.Rows[1]["CURRENT_PRICE"] = 9999m;

        var nonFinite = CandleTable("First", 1000m, "2");
        nonFinite.Rows[0]["HIGH_VALUE"] = double.NaN;
        var badDirection = CandleTable("First", 1000m, "sideways");

        foreach (var invalid in new[]
        {
            CandleTable("First", 1000m, "2", rowCount: 4),
            extraColumn,
            wrongOrder,
            changedQuote,
            nonFinite,
            badDirection
        })
        {
            var executor = new QueueExecutor(invalid, CandleTable("Second", 2000m, "2"));
            await Assert.ThrowsAsync<LegacySceneDataException>(() =>
                new S5026SceneDataLoader(executor).LoadAsync(
                    PairRequest(
                        ComparisonGraphPeriod.FiveDays,
                        "First",
                        ComparisonEquityMarket.Kospi,
                        "Second",
                        ComparisonEquityMarket.Kosdaq)));
        }
    }

    [Fact]
    public async Task ComparisonYieldRejectsFractionalIntegerPointsAndDescendingOrderViolations()
    {
        var fractional = ComparisonYieldTable("First", 1000m, 5);
        fractional.Rows[0]["VALUE"] = 1000.5m;
        var duplicateDate = ComparisonYieldTable("First", 1000m, 5);
        duplicateDate.Rows[1]["DATA_DAY"] = duplicateDate.Rows[0]["DATA_DAY"];

        foreach (var invalid in new[]
        {
            ComparisonYieldTable("First", 1000m, 4),
            fractional,
            duplicateDate
        })
        {
            var executor = new QueueExecutor(invalid, ComparisonYieldTable("Second", 2000m, 5));
            await Assert.ThrowsAsync<LegacySceneDataException>(() =>
                new S5029SceneDataLoader(executor).LoadAsync(
                    PairRequest(
                        ComparisonGraphPeriod.FiveDays,
                        "First",
                        ComparisonEquityMarket.Kospi,
                        "Second",
                        ComparisonEquityMarket.Kosdaq)));
        }
    }

    [Theory]
    [InlineData(YieldGraphInstrumentKind.WonDollar, null, "t_week_index", "A09")]
    [InlineData(YieldGraphInstrumentKind.WonYen, null, "t_week_index", "A10")]
    [InlineData(YieldGraphInstrumentKind.WonYuan, null, "t_week_index", "A90")]
    [InlineData(YieldGraphInstrumentKind.WonEuro, null, "t_week_index", "A91")]
    [InlineData(YieldGraphInstrumentKind.KospiIndex, null, "t_index_his_day", null)]
    [InlineData(YieldGraphInstrumentKind.KosdaqIndex, null, "t_kosdaq_index_his_day", null)]
    [InlineData(YieldGraphInstrumentKind.Kospi200Index, null, "t_200_index_his_day", null)]
    [InlineData(YieldGraphInstrumentKind.Krx100Index, null, "t_krx100_index_his_day", null)]
    [InlineData(YieldGraphInstrumentKind.KospiIndustry, "Semiconductor", "t_part", null)]
    [InlineData(YieldGraphInstrumentKind.KosdaqIndustry, "Software", "t_kosdaq_part", null)]
    [InlineData(YieldGraphInstrumentKind.KospiStock, "Stock A", "t_candle_history", null)]
    [InlineData(YieldGraphInstrumentKind.KosdaqStock, "Stock B", "t_kosdaq_candle_history", null)]
    public async Task S5086_EveryClosedInstrumentUsesAParameterizedOracleQueryAndBuildsMutations(
        YieldGraphInstrumentKind kind,
        string? name,
        string queryEvidence,
        string? inputCode)
    {
        var executor = new QueueExecutor(SingleYieldTable(5, _ => 100d));
        var instrument = new YieldGraphInstrument(kind, name);

        var data = await new S5086SceneDataLoader(executor).LoadAsync(
            new YieldSceneLoadRequest(YieldGraphPeriod.FiveDays, instrument));
        var mutations = new S5086SceneMutationBuilder().Build(data);

        var call = Assert.Single(executor.Calls);
        Assert.Equal(DataSourceKind.Oracle, call.Source);
        Assert.Equal("SCENE_5086_YIELD", call.TableName);
        Assert.Contains(queryEvidence, call.Spec.Sql, StringComparison.Ordinal);
        Assert.Contains(":rowLimit", call.Spec.Sql, StringComparison.Ordinal);
        Assert.Equal(5, call.Spec.Parameters.Single(parameter => parameter.Name == "rowLimit").Value);
        if (name is not null)
        {
            Assert.DoesNotContain(name, call.Spec.Sql, StringComparison.Ordinal);
            Assert.Equal(name, call.Spec.Parameters.Single(
                parameter => parameter.Name == "instrumentName").Value);
        }

        if (inputCode is not null)
        {
            Assert.DoesNotContain(inputCode, call.Spec.Sql, StringComparison.Ordinal);
            Assert.Equal(inputCode, call.Spec.Parameters.Single(
                parameter => parameter.Name == "inputCode").Value);
        }

        if (kind is YieldGraphInstrumentKind.KospiStock or YieldGraphInstrumentKind.KosdaqStock)
        {
            Assert.Contains("LEFT JOIN STOCK_HISTORY", call.Spec.Sql, StringComparison.Ordinal);
        }

        if (kind is YieldGraphInstrumentKind.KospiIndustry or YieldGraphInstrumentKind.KosdaqIndustry)
        {
            Assert.Contains("open_day || '1535'", call.Spec.Sql, StringComparison.Ordinal);
        }

        Assert.Same(instrument, data.Instrument);
        Assert.Equal(5, data.Points.Count);
        Assert.Contains(mutations.OfType<PlayoutSetValue>(), item => item.ObjectName == "price");
        Assert.Equal(2, mutations.OfType<PlayoutSetPathShapePoints>().Count());
        AssertFinite(mutations);
        Assert.Equal(0, executor.StringCalls);
    }

    [Theory]
    [MemberData(nameof(YieldPeriods))]
    public async Task S5086_BindsExactFiveTwentySixtyOneTwentyTwoFortyLimits(
        YieldGraphPeriod period,
        int rowLimit)
    {
        var executor = new QueueExecutor(SingleYieldTable(rowLimit, _ => 25d));

        var data = await new S5086SceneDataLoader(executor).LoadAsync(
            new YieldSceneLoadRequest(
                period,
                new YieldGraphInstrument(YieldGraphInstrumentKind.WonDollar)));
        var mutations = new S5086SceneMutationBuilder().Build(data);

        Assert.Equal(rowLimit, data.Points.Count);
        var call = Assert.Single(executor.Calls);
        Assert.Equal(rowLimit, call.Spec.Parameters.Single(
            parameter => parameter.Name == "rowLimit").Value);
        AssertFinite(mutations);
    }

    [Fact]
    public async Task S5086_LaterMissingValuesReachDocumentedForwardFillAndEqualRangeStaysFinite()
    {
        var values = new double?[] { 100d, null, 100d, 100d, 100d };
        var executor = new QueueExecutor(SingleYieldTable(5, index => values[index]));

        var data = await new S5086SceneDataLoader(executor).LoadAsync(
            new YieldSceneLoadRequest(
                YieldGraphPeriod.FiveDays,
                new YieldGraphInstrument(YieldGraphInstrumentKind.KospiStock, "Stock")));
        var mutations = new S5086SceneMutationBuilder().Build(data);

        Assert.Null(data.Points[1].Value);
        AssertFinite(mutations);
        Assert.All(
            mutations.OfType<PlayoutSetPathShapePoints>().SelectMany(item => item.Points),
            point => Assert.Equal(0f, point.Y));
    }

    [Fact]
    public async Task S5086_FailsClosedForFirstMissingNonFiniteWrongOrderAndUnexpectedSchema()
    {
        var firstMissing = SingleYieldTable(5, index => index == 0 ? null : 1d);
        var nonFinite = SingleYieldTable(5, index => index == 2 ? double.PositiveInfinity : 1d);
        var wrongOrder = SingleYieldTable(5, _ => 1d);
        wrongOrder.Rows[1]["DATA_DAY"] = wrongOrder.Rows[0]["DATA_DAY"];
        var extraColumn = SingleYieldTable(5, _ => 1d);
        extraColumn.Columns.Add("EXTRA", typeof(object));

        foreach (var invalid in new[]
        {
            SingleYieldTable(4, _ => 1d),
            firstMissing,
            nonFinite,
            wrongOrder,
            extraColumn
        })
        {
            var executor = new QueueExecutor(invalid);
            await Assert.ThrowsAsync<LegacySceneDataException>(() =>
                new S5086SceneDataLoader(executor).LoadAsync(
                    new YieldSceneLoadRequest(
                        YieldGraphPeriod.FiveDays,
                        new YieldGraphInstrument(YieldGraphInstrumentKind.WonDollar))));
        }
    }

    [Theory]
    [InlineData(YieldGraphInstrumentKind.WonDollar, "A09", "+", YieldQuoteDirection.Up, "up")]
    [InlineData(YieldGraphInstrumentKind.WonYen, "A10", "", YieldQuoteDirection.Flat, "flat")]
    [InlineData(YieldGraphInstrumentKind.WonYuan, "A90", "-", YieldQuoteDirection.Down, "down")]
    [InlineData(YieldGraphInstrumentKind.WonEuro, "A91", "2", YieldQuoteDirection.Up, "up")]
    public async Task S50860_MapsOnlySupportedFxQuoteShapeAndGoldenDirectionMutations(
        YieldGraphInstrumentKind kind,
        string inputCode,
        string sourceDirection,
        YieldQuoteDirection expectedDirection,
        string activeObject)
    {
        var table = QuotedFxTable(
            5,
            changePrice: -12.34d,
            rate: 1.25d,
            direction: sourceDirection);
        if (sourceDirection.Length == 0)
        {
            table.Rows[0]["DIRECTION"] = DBNull.Value;
        }

        var executor = new QueueExecutor(table);

        var data = await new S50860SceneDataLoader(executor).LoadAsync(
            new YieldSceneLoadRequest(
                YieldGraphPeriod.FiveDays,
                new YieldGraphInstrument(kind)));
        var mutations = new S50860SceneMutationBuilder().Build(data);

        var call = Assert.Single(executor.Calls);
        Assert.Equal(DataSourceKind.Oracle, call.Source);
        Assert.Equal("SCENE_50860_YIELD", call.TableName);
        Assert.DoesNotContain(inputCode, call.Spec.Sql, StringComparison.Ordinal);
        Assert.Equal(inputCode, call.Spec.Parameters.Single(
            parameter => parameter.Name == "inputCode").Value);
        Assert.Equal(5, call.Spec.Parameters.Single(
            parameter => parameter.Name == "rowLimit").Value);
        Assert.Equal(expectedDirection, data.Direction);
        Assert.Equal(-12.34d, data.ChangePrice);
        Assert.Equal(1.25d, data.Rate);
        Assert.Contains(new PlayoutSetValue("changePrice", "12.34"), mutations);
        Assert.Contains(new PlayoutSetVisible(activeObject, true), mutations);
        AssertFinite(mutations);
        Assert.Equal(0, executor.StringCalls);
    }

    [Theory]
    [MemberData(nameof(YieldPeriods))]
    public async Task S50860_BindsTheSameClosedPeriodLimitsAsItsFxLineSource(
        YieldGraphPeriod period,
        int rowLimit)
    {
        var executor = new QueueExecutor(QuotedFxTable(rowLimit, 1d, 0.1d, "+"));

        var data = await new S50860SceneDataLoader(executor).LoadAsync(
            new YieldSceneLoadRequest(
                period,
                new YieldGraphInstrument(YieldGraphInstrumentKind.WonDollar)));

        Assert.Equal(rowLimit, data.Points.Count);
        Assert.Equal(rowLimit, Assert.Single(executor.Calls).Spec.Parameters.Single(
            parameter => parameter.Name == "rowLimit").Value);
    }

    [Fact]
    public async Task S50860_RejectsSourceBranchesWhoseLegacyCellShapeCannotSupplyQuoteMetadata()
    {
        var unsupported = Enum.GetValues<YieldGraphInstrumentKind>()
            .Where(kind => kind is not (
                YieldGraphInstrumentKind.WonDollar or
                YieldGraphInstrumentKind.WonYen or
                YieldGraphInstrumentKind.WonYuan or
                YieldGraphInstrumentKind.WonEuro));

        foreach (var kind in unsupported)
        {
            var name = kind is
                YieldGraphInstrumentKind.KospiIndustry or
                YieldGraphInstrumentKind.KosdaqIndustry or
                YieldGraphInstrumentKind.KospiStock or
                YieldGraphInstrumentKind.KosdaqStock
                    ? "Named"
                    : null;
            var executor = new QueueExecutor(QuotedFxTable(5, 1d, 1d, "+"));
            await Assert.ThrowsAsync<LegacySceneDataException>(() =>
                new S50860SceneDataLoader(executor).LoadAsync(
                    new YieldSceneLoadRequest(
                        YieldGraphPeriod.FiveDays,
                        new YieldGraphInstrument(kind, name))));
            Assert.Empty(executor.Calls);
        }
    }

    [Fact]
    public async Task S50860_LoadedZeroRangeAndZeroQuoteStayFinite()
    {
        var table = QuotedFxTable(5, 0d, 0d, "");
        foreach (DataRow row in table.Rows)
        {
            row["VALUE"] = 0d;
        }
        table.Rows[0]["DIRECTION"] = DBNull.Value;
        var executor = new QueueExecutor(table);

        var data = await new S50860SceneDataLoader(executor).LoadAsync(
            new YieldSceneLoadRequest(
                YieldGraphPeriod.FiveDays,
                new YieldGraphInstrument(YieldGraphInstrumentKind.WonDollar)));
        var mutations = new S50860SceneMutationBuilder().Build(data);

        Assert.Equal(YieldQuoteDirection.Flat, data.Direction);
        AssertFinite(mutations);
        Assert.All(
            mutations.OfType<PlayoutSetPathShapePoints>().SelectMany(item => item.Points),
            point => Assert.Equal(0f, point.Y));
    }

    [Fact]
    public async Task S50860_FailsClosedForBadCountSchemaDateValueQuoteAndDirection()
    {
        var extraColumn = QuotedFxTable(5, 1d, 1d, "+");
        extraColumn.Columns.Add("EXTRA", typeof(object));
        var duplicateDate = QuotedFxTable(5, 1d, 1d, "+");
        duplicateDate.Rows[1]["DATA_DAY"] = duplicateDate.Rows[0]["DATA_DAY"];
        var nullValue = QuotedFxTable(5, 1d, 1d, "+");
        nullValue.Rows[2]["VALUE"] = DBNull.Value;
        var nonFiniteChange = QuotedFxTable(5, 1d, 1d, "+");
        nonFiniteChange.Rows[0]["CHANGE_PRICE"] = double.NaN;
        var nonFiniteRate = QuotedFxTable(5, 1d, 1d, "+");
        nonFiniteRate.Rows[0]["RATE"] = double.PositiveInfinity;
        var badDirection = QuotedFxTable(5, 1d, 1d, "sideways");

        foreach (var invalid in new[]
        {
            QuotedFxTable(4, 1d, 1d, "+"),
            extraColumn,
            duplicateDate,
            nullValue,
            nonFiniteChange,
            nonFiniteRate,
            badDirection
        })
        {
            var executor = new QueueExecutor(invalid);
            await Assert.ThrowsAsync<LegacySceneDataException>(() =>
                new S50860SceneDataLoader(executor).LoadAsync(
                    new YieldSceneLoadRequest(
                        YieldGraphPeriod.FiveDays,
                        new YieldGraphInstrument(YieldGraphInstrumentKind.WonDollar))));
        }
    }

    [Fact]
    public async Task InvalidClosedRequestsFailBeforeExecutingSql()
    {
        var invalidPairRequests = new[]
        {
            PairRequest(
                (ComparisonGraphPeriod)99,
                "First",
                ComparisonEquityMarket.Kospi,
                "Second",
                ComparisonEquityMarket.Kosdaq),
            PairRequest(
                ComparisonGraphPeriod.FiveDays,
                "First",
                (ComparisonEquityMarket)99,
                "Second",
                ComparisonEquityMarket.Kosdaq),
            new ComparisonPairSceneLoadRequest(
                ComparisonGraphPeriod.FiveDays,
                null!,
                new ComparisonStockSelection(ComparisonEquityMarket.Kosdaq, "Second", "SECOND002")),
            new ComparisonPairSceneLoadRequest(
                ComparisonGraphPeriod.FiveDays,
                new ComparisonStockSelection(ComparisonEquityMarket.Kospi, "First", "bad/code"),
                new ComparisonStockSelection(ComparisonEquityMarket.Kosdaq, "Second", "SECOND002")),
            new ComparisonPairSceneLoadRequest(
                ComparisonGraphPeriod.FiveDays,
                new ComparisonStockSelection(ComparisonEquityMarket.Kospi, "First", null!),
                new ComparisonStockSelection(ComparisonEquityMarket.Kosdaq, "Second", "SECOND002"))
        };
        foreach (var request in invalidPairRequests)
        {
            var executor = new QueueExecutor(ComparisonYieldTable("unused", 1m, 5));
            await Assert.ThrowsAsync<LegacySceneDataException>(() =>
                new S5029SceneDataLoader(executor).LoadAsync(request));
            Assert.Empty(executor.Calls);
        }

        var invalidYieldRequests = new[]
        {
            new YieldSceneLoadRequest(
                (YieldGraphPeriod)99,
                new YieldGraphInstrument(YieldGraphInstrumentKind.WonDollar)),
            new YieldSceneLoadRequest(
                YieldGraphPeriod.FiveDays,
                new YieldGraphInstrument((YieldGraphInstrumentKind)99)),
            new YieldSceneLoadRequest(
                YieldGraphPeriod.FiveDays,
                new YieldGraphInstrument(YieldGraphInstrumentKind.KospiStock)),
            new YieldSceneLoadRequest(
                YieldGraphPeriod.FiveDays,
                new YieldGraphInstrument(YieldGraphInstrumentKind.WonDollar, "ignored"))
        };
        foreach (var request in invalidYieldRequests)
        {
            var executor = new QueueExecutor(SingleYieldTable(5, _ => 1d));
            await Assert.ThrowsAsync<LegacySceneDataException>(() =>
                new S5086SceneDataLoader(executor).LoadAsync(request));
            Assert.Empty(executor.Calls);
        }
    }

    private static ComparisonPairSceneLoadRequest PairRequest(
        ComparisonGraphPeriod period,
        string firstName,
        ComparisonEquityMarket firstMarket,
        string secondName,
        ComparisonEquityMarket secondMarket) => new(
            period,
            new ComparisonStockSelection(firstMarket, firstName, "FIRST001"),
            new ComparisonStockSelection(secondMarket, secondName, "SECOND002"));

    private static DataTable CandleTable(
        string name,
        decimal currentPrice,
        string direction,
        int rowCount = 5)
    {
        var table = Table(
            "STOCK_NAME", "CURRENT_PRICE", "NET_CHANGE", "RATE", "DIRECTION",
            "OPEN_DAY", "HIGH_VALUE", "LOW_VALUE", "OPEN_VALUE", "CLOSE_VALUE");
        var start = new DateOnly(2026, 1, 1);
        for (var index = 0; index < rowCount; index++)
        {
            var center = currentPrice + index;
            table.Rows.Add(
                name,
                currentPrice,
                10m,
                direction is "4" or "5" ? -1.25d : 1.25d,
                direction,
                start.AddDays(index).ToString("yyyyMMdd"),
                center + 10m,
                center - 10m,
                center - 5m,
                center + 5m);
        }

        return table;
    }

    private static DataTable ComparisonYieldTable(
        string name,
        decimal currentPrice,
        int rowCount)
    {
        var table = Table(
            "STOCK_NAME", "CURRENT_PRICE", "NET_CHANGE", "RATE", "DIRECTION",
            "DATA_DAY", "VALUE");
        var latest = new DateOnly(2026, 12, 31);
        for (var index = 0; index < rowCount; index++)
        {
            table.Rows.Add(
                name,
                currentPrice,
                10m,
                1.25d,
                "2",
                latest.AddDays(-index).ToString("yyyyMMdd"),
                checked((int)currentPrice) - index);
        }

        return table;
    }

    private static DataTable SingleYieldTable(
        int rowCount,
        Func<int, double?> valueFactory)
    {
        var table = Table("DATA_DAY", "VALUE");
        var latest = new DateOnly(2026, 12, 31);
        for (var index = 0; index < rowCount; index++)
        {
            table.Rows.Add(
                latest.AddDays(-index).ToString("yyyyMMdd"),
                valueFactory(index) is { } value ? value : DBNull.Value);
        }

        return table;
    }

    private static DataTable QuotedFxTable(
        int rowCount,
        double changePrice,
        double rate,
        string direction)
    {
        var table = Table("DATA_DAY", "VALUE", "CHANGE_PRICE", "RATE", "DIRECTION");
        var latest = new DateOnly(2026, 12, 31);
        for (var index = 0; index < rowCount; index++)
        {
            table.Rows.Add(
                latest.AddDays(-index).ToString("yyyyMMdd"),
                1300d - index,
                changePrice,
                rate,
                direction);
        }

        return table;
    }

    private static DataTable Table(params string[] columns)
    {
        var table = new DataTable();
        foreach (var column in columns)
        {
            table.Columns.Add(column, typeof(object));
        }

        return table;
    }

    private static void AssertFinite(IEnumerable<PlayoutMutation> mutations)
    {
        foreach (var position in mutations.OfType<PlayoutSetPosition>())
        {
            Assert.True(float.IsFinite(position.X));
            Assert.True(float.IsFinite(position.Y));
            Assert.True(float.IsFinite(position.Z));
        }

        foreach (var crop in mutations.OfType<PlayoutSetCropKey>())
        {
            Assert.True(float.IsFinite(crop.Left));
            Assert.True(float.IsFinite(crop.Top));
            Assert.True(float.IsFinite(crop.Right));
            Assert.True(float.IsFinite(crop.Bottom));
        }

        foreach (var point in mutations
            .OfType<PlayoutSetPathShapePoints>()
            .SelectMany(item => item.Points))
        {
            Assert.True(float.IsFinite(point.X));
            Assert.True(float.IsFinite(point.Y));
            Assert.True(float.IsFinite(point.Z));
        }
    }

    private sealed record QueryCall(
        DataSourceKind Source,
        string TableName,
        DataQuerySpec Spec);

    private sealed class QueueExecutor : IDataQueryExecutor
    {
        private readonly Queue<DataTable> _results;

        public QueueExecutor(params DataTable[] results)
        {
            _results = new Queue<DataTable>(results);
        }

        public List<QueryCall> Calls { get; } = [];

        public int StringCalls { get; private set; }

        public DataTable Execute(DataSourceKind source, string tableName, string query)
        {
            StringCalls++;
            throw new InvalidOperationException("String SQL fallback is forbidden.");
        }

        public Task<DataTable> ExecuteAsync(
            DataSourceKind source,
            string tableName,
            string query,
            CancellationToken cancellationToken = default)
        {
            StringCalls++;
            throw new InvalidOperationException("String SQL fallback is forbidden.");
        }

        public Task<DataTable> ExecuteAsync(
            DataSourceKind source,
            string tableName,
            DataQuerySpec query,
            CancellationToken cancellationToken = default)
        {
            cancellationToken.ThrowIfCancellationRequested();
            query.ValidateFor(source);
            Calls.Add(new QueryCall(source, tableName, query));
            if (_results.Count == 0)
            {
                throw new InvalidOperationException("The test did not queue a result.");
            }

            return Task.FromResult(_results.Dequeue().Copy());
        }
    }
}
