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 CandleSceneDataLoaderTests
{
    private static readonly string[] QuoteColumns =
    [
        "DISPLAY_NAME", "CURRENT_PRICE", "CHANGE_PRICE", "RATE", "DIRECTION_CODE"
    ];

    private static readonly string[] RowColumns =
    [
        "TRADING_DATE", "TRADING_TIME",
        "OPEN_PRICE", "HIGH_PRICE", "LOW_PRICE", "CLOSE_PRICE",
        "VOLUME", "MA5", "MA20"
    ];

    public static TheoryData<CandleCutAlias, int> AliasLimits => new()
    {
        { CandleCutAlias.Cut8035, 78 },
        { CandleCutAlias.Cut8061, 5 },
        { CandleCutAlias.Cut8040, 20 },
        { CandleCutAlias.Cut8046, 60 },
        { CandleCutAlias.Cut8051, 120 },
        { CandleCutAlias.Cut8056, 240 }
    };

    public static TheoryData<RouteCase> RouteCases => new()
    {
        Route(CandleMarketTarget.KospiIndex, CandleChartMode.Price,
            CandleCutAlias.Cut8035, null, "FROM t_index", "FROM t_index_his_5M"),
        Route(CandleMarketTarget.KospiIndex, CandleChartMode.PriceWithVolume,
            CandleCutAlias.Cut8061, null, "FROM t_index", "FROM t_index_his_day", "a.F_PART_VOL VOLUME"),
        Route(CandleMarketTarget.KospiIndex, CandleChartMode.ExpectedExecution,
            CandleCutAlias.Cut8061, null, "FROM t_fo_index", "FROM t_index_his_day"),

        Route(CandleMarketTarget.KosdaqIndex, CandleChartMode.Price,
            CandleCutAlias.Cut8061, null, "FROM t_kosdaq_index", "FROM t_kosdaq_index_his_day"),
        Route(CandleMarketTarget.KosdaqIndex, CandleChartMode.PriceWithVolume,
            CandleCutAlias.Cut8035, null, "FROM t_kosdaq_index", "FROM t_kosdaq_index_his_5M", "a.F_PART_VOL VOLUME"),
        Route(CandleMarketTarget.KosdaqIndex, CandleChartMode.ExpectedExecution,
            CandleCutAlias.Cut8061, null, "FROM t_kosdaq_fo_index", "FROM t_kosdaq_index_his_day"),

        Route(CandleMarketTarget.Kospi200Index, CandleChartMode.Price,
            CandleCutAlias.Cut8061, null, "FROM T_200_INDEX", "FROM t_200_index_his_day"),
        Route(CandleMarketTarget.Kospi200Index, CandleChartMode.PriceWithVolume,
            CandleCutAlias.Cut8035, null, "FROM T_200_INDEX", "FROM t_200_index_his_5M", "a.F_PART_VOL VOLUME"),
        Route(CandleMarketTarget.Kospi200Index, CandleChartMode.ExpectedExecution,
            CandleCutAlias.Cut8061, null, "FROM T_200_FO_INDEX", "FROM t_200_index_his_day"),

        Route(CandleMarketTarget.Krx100Index, CandleChartMode.Price,
            CandleCutAlias.Cut8061, null, "FROM t_krx100_index", "FROM t_krx100_index_his_day"),
        Route(CandleMarketTarget.Krx100Index, CandleChartMode.PriceWithVolume,
            CandleCutAlias.Cut8035, null, "FROM t_krx100_index", "FROM t_krx100_index_his_5M", "a.F_PART_VOL VOLUME"),

        Route(CandleMarketTarget.FuturesIndex, CandleChartMode.Price,
            CandleCutAlias.Cut8035, null, "FROM t_sunmul_online", "FROM t_sunmul_his_5M"),
        Route(CandleMarketTarget.FuturesIndex, CandleChartMode.ExpectedExecution,
            CandleCutAlias.Cut8061, null, "FROM t_sunmul_online", "FROM T_SUNMUL_HIS_DAY"),

        Selected(CandleMarketTarget.KospiIndustry, CandleChartMode.Price,
            CandleCutAlias.Cut8035, "FROM t_index a, t_part b", "FROM t_index_his_5M"),
        Selected(CandleMarketTarget.KospiIndustry, CandleChartMode.PriceWithVolume,
            CandleCutAlias.Cut8061, "FROM t_index a, t_part b", "FROM t_index_his_day", "a.F_PART_VOL VOLUME"),
        Selected(CandleMarketTarget.KosdaqIndustry, CandleChartMode.Price,
            CandleCutAlias.Cut8061, "FROM t_kosdaq_index a, t_kosdaq_part b", "FROM t_kosdaq_index_his_day"),
        Selected(CandleMarketTarget.KosdaqIndustry, CandleChartMode.PriceWithVolume,
            CandleCutAlias.Cut8035, "FROM t_kosdaq_index a, t_kosdaq_part b", "FROM t_kosdaq_index_his_5M", "a.F_PART_VOL VOLUME"),

        Selected(CandleMarketTarget.OverseasIndex, CandleChartMode.Price,
            CandleCutAlias.Cut8061, "FROM t_world_ix_eq_master", "FROM t_world_ix_eq_master"),
        Selected(CandleMarketTarget.OverseasStock, CandleChartMode.Price,
            CandleCutAlias.Cut8061, "FROM t_world_ix_eq_master", "FROM t_world_ix_eq_master"),

        Selected(CandleMarketTarget.KospiStock, CandleChartMode.Price,
            CandleCutAlias.Cut8035, "FROM t_online1", "FROM t_online_his_5M"),
        Selected(CandleMarketTarget.KospiStock, CandleChartMode.PriceWithVolume,
            CandleCutAlias.Cut8061, "FROM t_online1", "FROM t_candle_history", "a.F_NET_VOL VOLUME"),
        Selected(CandleMarketTarget.KospiStock, CandleChartMode.ExpectedExecution,
            CandleCutAlias.Cut8061, "FROM t_online1_call", "FROM t_candle_history"),
        Selected(CandleMarketTarget.KospiStock, CandleChartMode.TradingHalt,
            CandleCutAlias.Cut8061, "FROM t_stop_online1", "FROM t_candle_history"),

        Selected(CandleMarketTarget.KosdaqStock, CandleChartMode.Price,
            CandleCutAlias.Cut8035, "FROM t_kosdaq_online1", "FROM t_kosdaq_online_his_5M"),
        Selected(CandleMarketTarget.KosdaqStock, CandleChartMode.PriceWithVolume,
            CandleCutAlias.Cut8061, "FROM t_kosdaq_online1", "FROM t_kosdaq_candle_history", "a.F_NET_VOL VOLUME"),
        Selected(CandleMarketTarget.KosdaqStock, CandleChartMode.ExpectedExecution,
            CandleCutAlias.Cut8061, "FROM t_kosdaq_online1_call", "FROM t_kosdaq_candle_history"),
        Selected(CandleMarketTarget.KosdaqStock, CandleChartMode.TradingHalt,
            CandleCutAlias.Cut8061, "FROM t_stop_kosdaq_online1", "FROM t_kosdaq_candle_history")
    };

    public static TheoryData<S8010SceneDataRequest> UnsupportedRequests => new()
    {
        Request((CandleCutAlias)9999, CandleMarketTarget.KospiIndex, CandleChartMode.Price, null),
        Request(CandleCutAlias.Cut8061, (CandleMarketTarget)99, CandleChartMode.Price, null),
        Request(CandleCutAlias.Cut8061, CandleMarketTarget.KospiIndex, (CandleChartMode)99, null),
        Request(CandleCutAlias.Cut8061, CandleMarketTarget.KospiStock, CandleChartMode.Price, null),
        Request(CandleCutAlias.Cut8061, CandleMarketTarget.KospiIndex, CandleChartMode.Price, "unexpected"),
        Request(CandleCutAlias.Cut8061, CandleMarketTarget.FuturesIndex, CandleChartMode.PriceWithVolume, null),
        Request(CandleCutAlias.Cut8061, CandleMarketTarget.Krx100Index, CandleChartMode.ExpectedExecution, null),
        Request(CandleCutAlias.Cut8061, CandleMarketTarget.KospiIndustry, CandleChartMode.ExpectedExecution, "Industry"),
        Request(CandleCutAlias.Cut8035, CandleMarketTarget.OverseasIndex, CandleChartMode.Price, "World"),
        Request(CandleCutAlias.Cut8061, CandleMarketTarget.OverseasStock, CandleChartMode.PriceWithVolume, "World"),
        Request(CandleCutAlias.Cut8035, CandleMarketTarget.KospiStock, CandleChartMode.ExpectedExecution, "Stock"),
        Request(CandleCutAlias.Cut8035, CandleMarketTarget.KosdaqStock, CandleChartMode.TradingHalt, "Stock"),
        Request(CandleCutAlias.Cut8061, CandleMarketTarget.KospiIndex, CandleChartMode.TradingHalt, null),
        Request(CandleCutAlias.Cut8061, CandleMarketTarget.KospiStock, CandleChartMode.Price, new string('x', 257)),
        Request(CandleCutAlias.Cut8061, CandleMarketTarget.KospiStock, CandleChartMode.Price, "bad\nname"),
        new(
            CandleCutAlias.Cut8061,
            CandleMarketTarget.OverseasIndex,
            CandleChartMode.Price,
            "World",
            true,
            true),
        Request(
            CandleCutAlias.Cut8061,
            CandleMarketTarget.OverseasStock,
            CandleChartMode.Price,
            "World",
            "NAS@WORLD",
            "DEU"),
        Request(
            CandleCutAlias.Cut8061,
            CandleMarketTarget.OverseasIndex,
            CandleChartMode.Price,
            "World",
            "WORLD@INDEX",
            "us"),
        Request(
            CandleCutAlias.Cut8061,
            CandleMarketTarget.KospiStock,
            CandleChartMode.Price,
            "Stock",
            "INVALID@DOMESTIC",
            "KR")
    };

    [Theory]
    [MemberData(nameof(RouteCases))]
    public async Task Every_supported_market_and_mode_uses_bound_oracle_queries_in_legacy_order(
        RouteCase item)
    {
        var intraday = item.Alias == CandleCutAlias.Cut8035;
        var executor = new RecordingExecutor(QuoteTable(), RowsTable(intraday));

        var data = await new S8010SceneDataLoader(executor).LoadAsync(
            Request(item.Alias, item.Target, item.Mode, item.Selection),
            CancellationToken.None);

        Assert.Equal(item.Target, data.MarketTarget);
        Assert.Equal(item.Mode, data.ChartMode);
        Assert.Equal(0, executor.StringCalls);
        Assert.Collection(
            executor.Calls,
            call =>
            {
                Assert.Equal(DataSourceKind.Oracle, call.Source);
                Assert.Equal("S8010_CURRENT", call.TableName);
                call.Spec.ValidateFor(DataSourceKind.Oracle);
                Assert.Contains(item.CurrentFragment, call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
            },
            call =>
            {
                Assert.Equal(DataSourceKind.Oracle, call.Source);
                Assert.Equal("S8010_ROWS", call.TableName);
                call.Spec.ValidateFor(DataSourceKind.Oracle);
                Assert.Contains(item.RowsFragment, call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
                if (item.RowsMustContain is not null)
                {
                    Assert.Contains(item.RowsMustContain, call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
                }

                var rowLimit = Assert.Single(
                    call.Spec.Parameters,
                    parameter => parameter.Name == "rowLimit");
                Assert.Equal(AliasLimit(item.Alias), rowLimit.Value);
                Assert.Equal(DbType.Int32, rowLimit.DbType);
            });

        Assert.All(
            executor.Calls.SelectMany(call => call.Spec.Parameters),
            parameter => Assert.NotNull(parameter.DbType));
        if (item.Selection is not null)
        {
            Assert.All(executor.Calls, call =>
            {
                Assert.DoesNotContain(item.Selection, call.Spec.Sql, StringComparison.Ordinal);
                Assert.Contains(call.Spec.Parameters, parameter =>
                    parameter.Name == "selection" && Equals(parameter.Value, item.Selection));
            });
        }

        Assert.All(executor.Calls, call =>
            Assert.DoesNotContain("{0}", call.Spec.Sql, StringComparison.Ordinal));
    }

    [Fact]
    public async Task Overseas_candles_scope_master_type_and_preserve_global_index_support()
    {
        var indexExecutor = new RecordingExecutor(QuoteTable(), RowsTable(false));
        await new S8010SceneDataLoader(indexExecutor).LoadAsync(
            Request(
                CandleCutAlias.Cut8061,
                CandleMarketTarget.OverseasIndex,
                CandleChartMode.Price,
                "Germany DAX",
                "XTR@DAX30",
                "DE"));

        Assert.All(indexExecutor.Calls, call =>
        {
            Assert.Contains("a.f_fdtc = '0'", call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
            var type = Assert.Single(
                call.Spec.Parameters,
                parameter => parameter.Name == "instrumentType");
            Assert.Equal("0", type.Value);
            Assert.Equal(
                "XTR@DAX30",
                Assert.Single(
                    call.Spec.Parameters,
                    parameter => parameter.Name == "symbol").Value);
            Assert.Equal(
                "DE",
                Assert.Single(
                    call.Spec.Parameters,
                    parameter => parameter.Name == "nationCode").Value);
        });

        var stockExecutor = new RecordingExecutor(QuoteTable(), RowsTable(false));
        await new S8010SceneDataLoader(stockExecutor).LoadAsync(
            Request(
                CandleCutAlias.Cut8061,
                CandleMarketTarget.OverseasStock,
                CandleChartMode.Price,
                "NVIDIA",
                "NAS@NVDA",
                "US"));

        Assert.All(stockExecutor.Calls, call =>
        {
            Assert.Contains("a.f_fdtc = '1'", call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
            Assert.DoesNotContain(
                "a.f_natc IN ('US', 'TW')",
                call.Spec.Sql,
                StringComparison.OrdinalIgnoreCase);
            var type = Assert.Single(
                call.Spec.Parameters,
                parameter => parameter.Name == "instrumentType");
            Assert.Equal("1", type.Value);
            Assert.Equal(
                "NAS@NVDA",
                Assert.Single(
                    call.Spec.Parameters,
                    parameter => parameter.Name == "symbol").Value);
            Assert.Equal(
                "US",
                Assert.Single(
                    call.Spec.Parameters,
                    parameter => parameter.Name == "nationCode").Value);
        });
    }

    [Fact]
    public async Task Overseas_stock_candle_accepts_an_exact_all_nation_identity()
    {
        var executor = new RecordingExecutor(QuoteTable(), RowsTable(false));

        await new S8010SceneDataLoader(executor).LoadAsync(
            Request(
                CandleCutAlias.Cut8061,
                CandleMarketTarget.OverseasStock,
                CandleChartMode.Price,
                "London Stock",
                "LNS@LON",
                "GB"));

        Assert.All(executor.Calls, call =>
        {
            Assert.DoesNotContain(
                "a.f_natc IN ('US', 'TW')",
                call.Spec.Sql,
                StringComparison.OrdinalIgnoreCase);
            Assert.Equal(
                "GB",
                Assert.Single(
                    call.Spec.Parameters,
                    parameter => parameter.Name == "nationCode").Value);
        });
    }

    [Theory]
    [MemberData(nameof(AliasLimits))]
    public async Task Every_alias_binds_its_legacy_row_bound_and_maps_time_shape(
        CandleCutAlias alias,
        int expectedLimit)
    {
        var intraday = alias == CandleCutAlias.Cut8035;
        var executor = new RecordingExecutor(QuoteTable(), RowsTable(intraday));

        var data = await new S8010SceneDataLoader(executor).LoadAsync(
            Request(alias, CandleMarketTarget.KospiStock, CandleChartMode.Price, "Stock"),
            CancellationToken.None);

        var rowsSpec = executor.Calls[1].Spec;
        var limit = Assert.Single(rowsSpec.Parameters, parameter => parameter.Name == "rowLimit");
        Assert.Equal(expectedLimit, limit.Value);
        Assert.Equal(intraday, data.Rows[0].TradingTime.HasValue);
        Assert.Contains(
            intraday ? "ORDER BY TRADING_DATE, TRADING_TIME" : "ORDER BY TRADING_DATE",
            rowsSpec.Sql,
            StringComparison.OrdinalIgnoreCase);
    }

    [Fact]
    public async Task Explicit_schema_maps_typed_values_and_generates_volume_candle_mutations()
    {
        var quote = QuoteTable("Samsung", 110m, 10m, -8.33m, "5");
        var rows = EmptyRowsTable();
        rows.Rows.Add("20260701", DBNull.Value, 100m, 120m, 90m, 110m, 25m, 105m, DBNull.Value);
        rows.Rows.Add(new DateTime(2026, 7, 2), DBNull.Value, 110m, 125m, 95m, 100m, 0m, 106m, 104m);
        var executor = new RecordingExecutor(quote, rows);

        var data = await new S8010SceneDataLoader(executor).LoadAsync(
            Request(
                CandleCutAlias.Cut8061,
                CandleMarketTarget.KospiStock,
                CandleChartMode.PriceWithVolume,
                "Samsung"),
            CancellationToken.None);

        Assert.Equal("Samsung", data.DisplayName);
        Assert.Equal(ScenePriceDirection.Down, data.Quote.Direction);
        Assert.Equal(new DateOnly(2026, 7, 1), data.Rows[0].TradingDate);
        Assert.Equal(25d, data.Rows[0].Volume);
        Assert.Equal(104d, data.Rows[1].MovingAverage20);

        var mutations = new S8010SceneMutationBuilder().Build(data);
        Assert.Contains(mutations, mutation =>
            mutation is PlayoutSetVisible { ObjectName: "volrumeG", IsVisible: true });
        Assert.Contains(mutations, mutation =>
            mutation is PlayoutSetCropKey { ObjectName: "volume1" });
        Assert.Contains(mutations, mutation =>
            mutation is PlayoutSetFaceColor
            {
                ObjectName: "candlebody2",
                Red: 15,
                Green: 99,
                Blue: 189
            });
    }

    [Theory]
    [MemberData(nameof(UnsupportedRequests))]
    public async Task Unsupported_or_ambiguous_requests_fail_before_database_access(
        S8010SceneDataRequest request)
    {
        var executor = new RecordingExecutor();

        await Assert.ThrowsAsync<LegacySceneDataException>(() =>
            new S8010SceneDataLoader(executor).LoadAsync(request, CancellationToken.None));

        Assert.Empty(executor.Calls);
        Assert.Equal(0, executor.StringCalls);
    }

    [Fact]
    public async Task Quote_schema_is_exact_and_fails_before_rows_query()
    {
        var quote = Table(
            ["CURRENT_PRICE", "DISPLAY_NAME", "CHANGE_PRICE", "RATE", "DIRECTION_CODE"],
            [100, "Stock", 1, 1, "+"]);
        var executor = new RecordingExecutor(quote, RowsTable(false));

        await Assert.ThrowsAsync<LegacySceneDataException>(() =>
            new S8010SceneDataLoader(executor).LoadAsync(
                Request(CandleCutAlias.Cut8061, CandleMarketTarget.KospiStock, CandleChartMode.Price, "Stock"),
                CancellationToken.None));

        Assert.Single(executor.Calls);
    }

    [Fact]
    public async Task Row_schema_count_values_and_order_fail_closed_without_leaking_selection()
    {
        const string selection = "SENSITIVE O'Brien";
        var malformedRows = EmptyRowsTable();
        malformedRows.Rows.Add("20260702", DBNull.Value, 100, 110, 90, 100, DBNull.Value, DBNull.Value, DBNull.Value);
        malformedRows.Rows.Add("20260701", DBNull.Value, 100, 110, 90, 100, DBNull.Value, DBNull.Value, DBNull.Value);
        var executor = new RecordingExecutor(QuoteTable(), malformedRows);

        var exception = await Assert.ThrowsAsync<LegacySceneDataException>(() =>
            new S8010SceneDataLoader(executor).LoadAsync(
                Request(CandleCutAlias.Cut8061, CandleMarketTarget.KospiStock, CandleChartMode.Price, selection),
                CancellationToken.None));

        Assert.DoesNotContain(selection, exception.Message, StringComparison.Ordinal);
        Assert.Equal(2, executor.Calls.Count);
    }

    [Fact]
    public async Task More_rows_than_the_alias_bound_and_nonfinite_values_fail_closed()
    {
        var tooMany = EmptyRowsTable();
        for (var day = 1; day <= 6; day++)
        {
            tooMany.Rows.Add(
                $"202607{day:00}", DBNull.Value,
                100, 110, 90, 100, DBNull.Value, DBNull.Value, DBNull.Value);
        }

        await Assert.ThrowsAsync<LegacySceneDataException>(() =>
            new S8010SceneDataLoader(new RecordingExecutor(QuoteTable(), tooMany)).LoadAsync(
                Request(CandleCutAlias.Cut8061, CandleMarketTarget.KospiStock, CandleChartMode.Price, "Stock"),
                CancellationToken.None));

        var nonfinite = RowsTable(false);
        nonfinite.Rows[0]["HIGH_PRICE"] = double.PositiveInfinity;
        await Assert.ThrowsAsync<LegacySceneDataException>(() =>
            new S8010SceneDataLoader(new RecordingExecutor(QuoteTable(), nonfinite)).LoadAsync(
                Request(CandleCutAlias.Cut8061, CandleMarketTarget.KospiStock, CandleChartMode.Price, "Stock"),
                CancellationToken.None));
    }

    private static RouteCase Route(
        CandleMarketTarget target,
        CandleChartMode mode,
        CandleCutAlias alias,
        string? selection,
        string currentFragment,
        string rowsFragment,
        string? rowsMustContain = null) =>
        new(target, mode, alias, selection, currentFragment, rowsFragment, rowsMustContain);

    private static RouteCase Selected(
        CandleMarketTarget target,
        CandleChartMode mode,
        CandleCutAlias alias,
        string currentFragment,
        string rowsFragment,
        string? rowsMustContain = null) =>
        Route(
            target,
            mode,
            alias,
            "Sensitive O'Brien",
            currentFragment,
            rowsFragment,
            rowsMustContain);

    private static S8010SceneDataRequest Request(
        CandleCutAlias alias,
        CandleMarketTarget target,
        CandleChartMode mode,
        string? selection,
        string? foreignSymbol = null,
        string? foreignNationCode = null)
    {
        if (target is CandleMarketTarget.OverseasIndex or CandleMarketTarget.OverseasStock)
        {
            foreignSymbol ??= target == CandleMarketTarget.OverseasIndex
                ? "TEST@INDEX"
                : "TEST@STOCK";
            foreignNationCode ??= "US";
        }

        return new(
            alias,
            target,
            mode,
            selection,
            true,
            true,
            foreignSymbol,
            foreignNationCode);
    }

    private static int AliasLimit(CandleCutAlias alias) => alias switch
    {
        CandleCutAlias.Cut8035 => 78,
        CandleCutAlias.Cut8061 => 5,
        CandleCutAlias.Cut8040 => 20,
        CandleCutAlias.Cut8046 => 60,
        CandleCutAlias.Cut8051 => 120,
        CandleCutAlias.Cut8056 => 240,
        _ => throw new InvalidOperationException()
    };

    private static DataTable QuoteTable(
        object? displayName = null,
        object? price = null,
        object? change = null,
        object? rate = null,
        object? direction = null) =>
        Table(
            QuoteColumns,
            [
                displayName ?? "Instrument",
                price ?? 100m,
                change ?? 1m,
                rate ?? 1m,
                direction ?? "+"
            ]);

    private static DataTable RowsTable(bool intraday)
    {
        var table = EmptyRowsTable();
        table.Rows.Add(
            "20260710",
            intraday ? "090000" : DBNull.Value,
            100m,
            110m,
            90m,
            105m,
            10m,
            101m,
            99m);
        return table;
    }

    private static DataTable EmptyRowsTable() => Table(RowColumns);

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

        if (row is not null)
        {
            table.Rows.Add(row);
        }

        return table;
    }

    public sealed record RouteCase(
        CandleMarketTarget Target,
        CandleChartMode Mode,
        CandleCutAlias Alias,
        string? Selection,
        string CurrentFragment,
        string RowsFragment,
        string? RowsMustContain);

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

    private sealed class RecordingExecutor(params DataTable[] results) : IDataQueryExecutor
    {
        private readonly Queue<DataTable> _results = new(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("No result was configured for the query.");
            }

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