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 PagedQuoteSceneDataLoadersTests
{
    public static TheoryData<string, int> SceneSizes => new()
    {
        { "s5074", 5 },
        { "s5077", 6 },
        { "s5088", 12 }
    };

    [Fact]
    public async Task DomesticIndustry_UsesParameterizedOracleAndPreservesPointDecimalsInMutations()
    {
        var executor = new RecordingExecutor(QuoteTable(
            ["Semiconductor", 1234.56m, -12.34m, -0.99m, "-"]));

        var data = await new S5074SceneDataLoader(executor).LoadAsync(
            new DomesticPagedQuoteLoadRequest(
                PagedQuoteMarket.Kospi,
                DomesticPagedQuoteList.Industry,
                new DateOnly(2026, 7, 10)));
        var mutations = new S5074SceneMutationBuilder().Build(data);

        Assert.Equal(DataSourceKind.Oracle, executor.Source);
        Assert.Equal("SCENE_5074_PAGED_QUOTES", executor.TableName);
        Assert.NotNull(executor.Spec);
        Assert.Contains("FROM t_index", executor.Spec.Sql, StringComparison.Ordinal);
        Assert.Contains(":maxRows", executor.Spec.Sql, StringComparison.Ordinal);
        var maxRows = Assert.Single(executor.Spec.Parameters);
        Assert.Equal("maxRows", maxRows.Name);
        Assert.Equal(100, maxRows.Value);
        Assert.Equal(DbType.Int32, maxRows.DbType);
        Assert.Equal(PagedQuoteBranch.Domestic, data.Branch);
        Assert.Equal(PagedQuoteHeaderStyle.IndustryPoints, data.HeaderStyle);
        Assert.Equal(PagedQuoteTitleStyle.Exact, data.TitleStyle);
        Assert.Equal(NxtMarketSession.NotApplicable, data.NxtSession);
        Assert.Equal("\uCF54\uC2A4\uD53C \uC5C5\uC885", data.Title);
        Assert.Equal(1234.56m, data.Rows[0].PrimaryValue);
        Assert.Equal(-12.34m, data.Rows[0].SecondaryValue);
        Assert.Equal(ScenePriceDirection.Down, data.Rows[0].Direction);
        Assert.Contains(new PlayoutSetValue("price1", "1,234.56"), mutations);
        Assert.Contains(new PlayoutSetValue("changePrice1_1", "-12.34"), mutations);
        Assert.Contains(new PlayoutSetValue("unit", "\uB2E8\uC704 : p"), mutations);
        Assert.Equal(1, executor.SpecCalls);
        Assert.Equal(0, executor.StringCalls);
    }

    [Fact]
    public async Task NxtRanking_UsesParameterizedMariaAndMapsAfterMarketPresentation()
    {
        var executor = new RecordingExecutor(QuoteTable(
            ["NXT quote", 1000m, -50m, -5.0m, "5"]));

        var data = await new S5077SceneDataLoader(executor).LoadAsync(
            new NxtPagedQuoteLoadRequest(
                PagedQuoteMarket.Kosdaq,
                NxtPagedQuoteList.Volume,
                NxtMarketSession.AfterMarket));
        var mutations = new S5077SceneMutationBuilder().Build(data);

        Assert.Equal(DataSourceKind.MariaDb, executor.Source);
        Assert.NotNull(executor.Spec);
        Assert.Contains("FROM n_kosdaq_online", executor.Spec.Sql, StringComparison.Ordinal);
        Assert.Contains("LIMIT @maxRows", executor.Spec.Sql, StringComparison.Ordinal);
        var maxRows = Assert.Single(executor.Spec.Parameters);
        Assert.Equal(120, maxRows.Value);
        Assert.Equal(PagedQuoteBranch.Nxt, data.Branch);
        Assert.Equal(NxtMarketSession.AfterMarket, data.NxtSession);
        Assert.Equal("\uCF54\uC2A4\uB2E5 \uAC70\uB798\uB7C9 \uC0C1\uC704", data.Title);
        Assert.Equal(ScenePriceDirection.Down, data.Rows[0].Direction);
        Assert.Contains(
            new PlayoutSetAssetValue("mart", "Images\\\uC560\uD504\uD130\uB9C8\uCF13.png"),
            mutations);
        Assert.Contains(new PlayoutSetVisible("mart", true), mutations);
        Assert.Contains(new PlayoutSetValue("price1", "1,000"), mutations);
        Assert.Equal(0, executor.StringCalls);
    }

    [Fact]
    public async Task ThemeExpected_UsesOracleThemeParameterAndExpectedTitleVariant()
    {
        var executor = new RecordingExecutor(QuoteTable(
            ["Alpha", 110m, 10m, 10d, "+"]));

        var data = await new S5088SceneDataLoader(executor).LoadAsync(
            new ThemePagedQuoteLoadRequest(
                "0001",
                "AI",
                PagedQuoteThemeSort.GainRateDescending,
                PagedQuoteThemeValueKind.ExpectedExecution));
        var mutations = new S5088SceneMutationBuilder().Build(data);

        Assert.Equal(DataSourceKind.Oracle, executor.Source);
        Assert.NotNull(executor.Spec);
        Assert.Contains("l.SB_CODE = :themeCode", executor.Spec.Sql, StringComparison.Ordinal);
        Assert.Contains("l.SB_TITLE = :themeTitle", executor.Spec.Sql, StringComparison.Ordinal);
        Assert.Contains("t_online1_call", executor.Spec.Sql, StringComparison.Ordinal);
        Assert.Contains("ORDER BY q.RATE DESC", executor.Spec.Sql, StringComparison.Ordinal);
        Assert.Equal(new[] { "themeCode", "themeTitle", "maxRows" },
            executor.Spec.Parameters.Select(parameter => parameter.Name));
        Assert.Equal("0001", executor.Spec.Parameters[0].Value);
        Assert.Equal("AI", executor.Spec.Parameters[1].Value);
        Assert.Equal(240, executor.Spec.Parameters[2].Value);
        Assert.Equal(PagedQuoteBranch.Theme, data.Branch);
        Assert.Equal(PagedQuoteTitleStyle.ExpectedExecution, data.TitleStyle);
        Assert.Equal(NxtMarketSession.NotApplicable, data.NxtSession);
        Assert.Contains(
            new PlayoutSetValue("title", "AI \uC608\uC0C1 \uCCB4\uACB0\uAC00"),
            mutations);
    }

    [Fact]
    public async Task NxtTheme_UsesMariaThemeParameterAndPreMarketSession()
    {
        var executor = new RecordingExecutor(QuoteTable(
            ["Beta", 200m, 20m, 11.11d, "2"]));

        var data = await new S5074SceneDataLoader(executor).LoadAsync(
            new NxtThemePagedQuoteLoadRequest(
                "0002",
                "Robotics(NXT)",
                PagedQuoteThemeSort.InputOrder,
                NxtMarketSession.PreMarket));

        Assert.Equal(DataSourceKind.MariaDb, executor.Source);
        Assert.NotNull(executor.Spec);
        Assert.Contains("l.SB_CODE = @themeCode", executor.Spec.Sql, StringComparison.Ordinal);
        Assert.Contains("l.SB_TITLE = @themeTitle", executor.Spec.Sql, StringComparison.Ordinal);
        Assert.Contains("l.SB_MARKET = 'NXT'", executor.Spec.Sql, StringComparison.Ordinal);
        Assert.Contains("ORDER BY i.SB_I_INDEX", executor.Spec.Sql, StringComparison.Ordinal);
        Assert.Equal("0002", executor.Spec.Parameters[0].Value);
        Assert.Equal("Robotics", executor.Spec.Parameters[1].Value);
        Assert.Equal(PagedQuoteBranch.NxtTheme, data.Branch);
        Assert.Equal(NxtMarketSession.PreMarket, data.NxtSession);
        Assert.Equal(PagedQuoteTitleStyle.Exact, data.TitleStyle);
        Assert.Equal("Robotics", data.Title);
    }

    [Theory]
    [InlineData("Robotics(NXT)", "Robotics(NXT)")]
    [InlineData("Robotics(NXT)(NXT)", "Robotics(NXT)(NXT)")]
    [InlineData("  Robotics  (NXT)", "  Robotics  ")]
    public async Task NativeNxtTheme_UsesExactStoredTitleWithoutSuffixOrSpaceRewriting(
        string displayTitle,
        string exactStoredTitle)
    {
        var executor = new RecordingExecutor(QuoteTable(
            ["Beta", 200m, 20m, 11.11d, "2"]));

        var data = await new S5074SceneDataLoader(executor).LoadAsync(
            new NxtThemePagedQuoteLoadRequest(
                "0002",
                displayTitle,
                PagedQuoteThemeSort.InputOrder,
                NxtMarketSession.PreMarket,
                ExactThemeName: exactStoredTitle));

        Assert.NotNull(executor.Spec);
        Assert.Equal(exactStoredTitle, executor.Spec.Parameters[1].Value);
        Assert.Equal(exactStoredTitle, data.Title);
    }

    [Fact]
    public async Task Expert_UsesOracleExpertParameterAndMapsReturnHeader()
    {
        var executor = new RecordingExecutor(ExpertQuoteTable(
            ["0001", "Analyst A", 0, "005930", "Gamma", 900m, 1000m, 11.11d, "2"]));

        var data = await new S5077SceneDataLoader(executor).LoadAsync(
            new ExpertPagedQuoteLoadRequest("0001", "Analyst A"));
        var mutations = new S5077SceneMutationBuilder().Build(data);

        Assert.Equal(DataSourceKind.Oracle, executor.Source);
        Assert.NotNull(executor.Spec);
        Assert.Contains("e.EP_CODE = :expertCode", executor.Spec.Sql, StringComparison.Ordinal);
        Assert.Contains("e.EP_NAME = :expertName", executor.Spec.Sql, StringComparison.Ordinal);
        Assert.Contains("LEFT JOIN QUOTES", executor.Spec.Sql, StringComparison.Ordinal);
        Assert.DoesNotContain("AND r.RC_BUY_AMOUNT <> 0", executor.Spec.Sql, StringComparison.Ordinal);
        Assert.Contains("r.RC_BUY_AMOUNT IS NULL", executor.Spec.Sql, StringComparison.Ordinal);
        Assert.Contains("q.STOCK_CODE IS NOT NULL", executor.Spec.Sql, StringComparison.Ordinal);
        Assert.Equal("0001", executor.Spec.Parameters[0].Value);
        Assert.Equal("Analyst A", executor.Spec.Parameters[1].Value);
        Assert.Equal(PagedQuoteBranch.Expert, data.Branch);
        Assert.Equal(PagedQuoteHeaderStyle.ExpertReturns, data.HeaderStyle);
        Assert.DoesNotContain(mutations, mutation =>
            mutation is PlayoutSetValue { ObjectName: "title" or "unit" or
                "column1" or "column2" or "column3" });
    }

    [Fact]
    public async Task ExpertPagePlanPreflight_BlocksNullBuyAmountWithExactRecommendationDetails()
    {
        var executor = new RecordingExecutor(ExpertQuoteTable(
            ["0001", "Analyst A", 0, "035720", "Kakao", 42_000m, 45_000m, 7.14d, "2"],
            ["0001", "Analyst A", 7, "005930", "Samsung", DBNull.Value, DBNull.Value, DBNull.Value, DBNull.Value]));
        var loader = new S5074SceneDataLoader(executor);

        var exception = await Assert.ThrowsAsync<LegacySceneDataException>(() =>
            loader.LoadPagePlanAsync(new ExpertPagedQuoteLoadRequest("0001", "Analyst A")));

        Assert.Contains("Analyst A", exception.Message, StringComparison.Ordinal);
        Assert.Contains("0001", exception.Message, StringComparison.Ordinal);
        Assert.Contains("PLAYINDEX: 7", exception.Message, StringComparison.Ordinal);
        Assert.Contains("Samsung", exception.Message, StringComparison.Ordinal);
        Assert.Contains("005930", exception.Message, StringComparison.Ordinal);
        Assert.Contains("RC_BUY_AMOUNT", exception.Message, StringComparison.Ordinal);
        Assert.Contains("매수가", exception.Message, StringComparison.Ordinal);
        Assert.Contains("PREPARE", exception.Message, StringComparison.Ordinal);
        Assert.Equal(1, executor.SpecCalls);
    }

    [Fact]
    public async Task Vi_UsesOneOracleParameterPerBoundedNameAndPreservesInputOrderContract()
    {
        var executor = new RecordingExecutor(QuoteTable(
            ["First", 100m, 1m, 1d, "2"],
            ["Second", 200m, 2m, 1d, "2"]));

        var data = await new S5088SceneDataLoader(executor).LoadAsync(
            new ViPagedQuoteLoadRequest(["First", "Second"]));

        Assert.Equal(DataSourceKind.Oracle, executor.Source);
        Assert.NotNull(executor.Spec);
        Assert.Contains(":stockName0", executor.Spec.Sql, StringComparison.Ordinal);
        Assert.Contains(":stockName1", executor.Spec.Sql, StringComparison.Ordinal);
        Assert.Contains("ORDER BY r.INPUT_ORDER", executor.Spec.Sql, StringComparison.Ordinal);
        Assert.Equal(new[] { "stockName0", "stockName1", "maxRows" },
            executor.Spec.Parameters.Select(parameter => parameter.Name));
        Assert.Equal(new object?[] { "First", "Second", 240 },
            executor.Spec.Parameters.Select(parameter => parameter.Value));
        Assert.Equal(PagedQuoteBranch.Vi, data.Branch);
        Assert.Equal("VI \uBC1C\uB3D9", data.Title);
        Assert.Equal(new[] { "First", "Second" }, data.Rows.Select(row => row.Name));
    }

    [Theory]
    [MemberData(nameof(SceneSizes))]
    public async Task SceneLoaders_UseExactFiveSixTwelveWindowsAndTwentyPageCap(
        string scene,
        int pageSize)
    {
        var partialExecutor = new RecordingExecutor(NumberedQuoteTable(pageSize + 1));
        var partial = await LoadAsync(
            scene,
            partialExecutor,
            new DomesticPagedQuoteLoadRequest(
                PagedQuoteMarket.Kospi,
                DomesticPagedQuoteList.GainRate,
                new DateOnly(2026, 7, 10),
                PageNumberOneBased: 2));
        var partialMutations = Build(scene, partial);

        Assert.Equal(pageSize + 1, partial.Rows.Count);
        Assert.Equal(2, partial.PageNumberOneBased);
        Assert.Contains(new PlayoutSetValue("title1", "Name" + pageSize), partialMutations);
        Assert.Contains(new PlayoutSetVisible("row1", true), partialMutations);
        Assert.Contains(new PlayoutSetVisible("row2", false), partialMutations);

        var exactPageExecutor = new RecordingExecutor(NumberedQuoteTable(pageSize));
        await Assert.ThrowsAsync<LegacySceneDataException>(() => LoadAsync(
            scene,
            exactPageExecutor,
            new DomesticPagedQuoteLoadRequest(
                PagedQuoteMarket.Kospi,
                DomesticPagedQuoteList.GainRate,
                new DateOnly(2026, 7, 10),
                PageNumberOneBased: 2)));

        var maximumRows = pageSize * ScenePaging.MaximumPageCount;
        var lastExecutor = new RecordingExecutor(NumberedQuoteTable(maximumRows));
        var last = await LoadAsync(
            scene,
            lastExecutor,
            new DomesticPagedQuoteLoadRequest(
                PagedQuoteMarket.Kospi,
                DomesticPagedQuoteList.GainRate,
                new DateOnly(2026, 7, 10),
                PageNumberOneBased: 20));
        var lastMutations = Build(scene, last);

        Assert.Equal(maximumRows, last.Rows.Count);
        Assert.Contains(
            new PlayoutSetValue("title1", "Name" + (19 * pageSize)),
            lastMutations);
        Assert.Equal(maximumRows, Assert.Single(lastExecutor.Spec!.Parameters).Value);

        var oversizedExecutor = new RecordingExecutor(NumberedQuoteTable(maximumRows + 1));
        await Assert.ThrowsAsync<LegacySceneDataException>(() => LoadAsync(
            scene,
            oversizedExecutor,
            new DomesticPagedQuoteLoadRequest(
                PagedQuoteMarket.Kospi,
                DomesticPagedQuoteList.GainRate,
                new DateOnly(2026, 7, 10))));
    }

    [Theory]
    [MemberData(nameof(SceneSizes))]
    public async Task SceneLoaders_RejectPageTwentyOneBeforeQuery(
        string scene,
        int _)
    {
        var executor = new RecordingExecutor(NumberedQuoteTable(1));

        await Assert.ThrowsAsync<LegacySceneDataException>(() => LoadAsync(
            scene,
            executor,
            new DomesticPagedQuoteLoadRequest(
                PagedQuoteMarket.Kospi,
                DomesticPagedQuoteList.GainRate,
                new DateOnly(2026, 7, 10),
                PageNumberOneBased: 21)));

        Assert.Equal(0, executor.SpecCalls);
    }

    public static TheoryData<DomesticPagedQuoteList, PagedQuoteMarket, string>
        DomesticLists => new()
        {
            { DomesticPagedQuoteList.ExpectedMarketCapitalization, PagedQuoteMarket.Kospi, "t_online1_call" },
            { DomesticPagedQuoteList.MarketCapitalization, PagedQuoteMarket.Kosdaq, "t_kosdaq_online1" },
            { DomesticPagedQuoteList.Industry, PagedQuoteMarket.Kospi, "t_index" },
            { DomesticPagedQuoteList.AfterHoursGainRate, PagedQuoteMarket.Kosdaq, "t_kosdaq_overtime_sise" },
            { DomesticPagedQuoteList.AfterHoursLossRate, PagedQuoteMarket.Kospi, "t_overtime_sise" },
            { DomesticPagedQuoteList.GainRate, PagedQuoteMarket.Kosdaq, "RATE DESC" },
            { DomesticPagedQuoteList.LossRate, PagedQuoteMarket.Kospi, "a.f_chg_type IN ('4', '5')" },
            { DomesticPagedQuoteList.FiftyTwoWeekHigh, PagedQuoteMarket.Kospi, "t_candle_history" },
            { DomesticPagedQuoteList.FiftyTwoWeekLow, PagedQuoteMarket.Kosdaq, "t_kosdaq_candle_history" },
            { DomesticPagedQuoteList.Kospi200, PagedQuoteMarket.Kospi, "f_kospi200_gubun" },
            { DomesticPagedQuoteList.Etf, PagedQuoteMarket.Kospi, "b.f_sect_code = 'EF'" }
        };

    [Theory]
    [MemberData(nameof(DomesticLists))]
    public async Task EveryDomesticLegacySelectionHasAClosedOracleQuery(
        DomesticPagedQuoteList list,
        PagedQuoteMarket market,
        string queryEvidence)
    {
        var executor = new RecordingExecutor(NumberedQuoteTable(1));

        var data = await new S5074SceneDataLoader(executor).LoadAsync(
            new DomesticPagedQuoteLoadRequest(
                market,
                list,
                new DateOnly(2026, 7, 10)));

        Assert.Equal(DataSourceKind.Oracle, executor.Source);
        Assert.NotNull(executor.Spec);
        Assert.Contains(queryEvidence, executor.Spec.Sql, StringComparison.Ordinal);
        Assert.Equal(PagedQuoteBranch.Domestic, data.Branch);
        Assert.Equal(
            list == DomesticPagedQuoteList.Industry
                ? PagedQuoteHeaderStyle.IndustryPoints
                : PagedQuoteHeaderStyle.StandardWon,
            data.HeaderStyle);
        Assert.Equal(0, executor.StringCalls);
    }

    public static TheoryData<NxtPagedQuoteList, string> NxtLists => new()
    {
        { NxtPagedQuoteList.MarketCapitalization, "b.f_list_num * a.f_curr_price" },
        { NxtPagedQuoteList.AfterHoursGainRate, "FROM n_overtime" },
        { NxtPagedQuoteList.AfterHoursLossRate, "a.f_chg_type IN ('4', '5')" },
        { NxtPagedQuoteList.GainRate, "RATE DESC" },
        { NxtPagedQuoteList.LossRate, "a.f_chg_type IN ('4', '5')" },
        { NxtPagedQuoteList.Volume, "a.f_net_vol" }
    };

    [Theory]
    [MemberData(nameof(NxtLists))]
    public async Task EveryNxtLegacySelectionHasAClosedMariaQuery(
        NxtPagedQuoteList list,
        string queryEvidence)
    {
        var executor = new RecordingExecutor(NumberedQuoteTable(1));

        var data = await new S5077SceneDataLoader(executor).LoadAsync(
            new NxtPagedQuoteLoadRequest(
                PagedQuoteMarket.Kospi,
                list,
                NxtMarketSession.PreMarket));

        Assert.Equal(DataSourceKind.MariaDb, executor.Source);
        Assert.NotNull(executor.Spec);
        Assert.Contains(queryEvidence, executor.Spec.Sql, StringComparison.Ordinal);
        Assert.Contains("LIMIT @maxRows", executor.Spec.Sql, StringComparison.Ordinal);
        Assert.Equal(PagedQuoteBranch.Nxt, data.Branch);
        Assert.Equal(0, executor.StringCalls);
    }

    [Theory]
    [InlineData(PagedQuoteThemeSort.InputOrder, "ORDER BY t.SB_I_INDEX")]
    [InlineData(PagedQuoteThemeSort.GainRateDescending, "ORDER BY q.RATE DESC")]
    [InlineData(PagedQuoteThemeSort.GainRateAscending, "ORDER BY q.RATE")]
    public async Task ThemeSortsAreClosedAndGenerateTheLegacyOrder(
        PagedQuoteThemeSort sort,
        string orderBy)
    {
        var executor = new RecordingExecutor(NumberedQuoteTable(1));

        await new S5074SceneDataLoader(executor).LoadAsync(
            new ThemePagedQuoteLoadRequest(
                "123",
                "Theme",
                sort,
                PagedQuoteThemeValueKind.Current));

        Assert.NotNull(executor.Spec);
        Assert.Contains(orderBy, executor.Spec.Sql, StringComparison.Ordinal);
    }

    [Fact]
    public async Task FiftyTwoWeekQueryBindsDeterministicStartEndAndCapParameters()
    {
        var executor = new RecordingExecutor(NumberedQuoteTable(1));

        await new S5088SceneDataLoader(executor).LoadAsync(
            new DomesticPagedQuoteLoadRequest(
                PagedQuoteMarket.Kospi,
                DomesticPagedQuoteList.FiftyTwoWeekHigh,
                new DateOnly(2026, 7, 10)));

        Assert.NotNull(executor.Spec);
        Assert.Equal(new[] { "startDate", "endDate", "maxRows" },
            executor.Spec.Parameters.Select(parameter => parameter.Name));
        Assert.Equal(new object?[] { "20250711", "20260710", 240 },
            executor.Spec.Parameters.Select(parameter => parameter.Value));
        Assert.All(executor.Spec.Parameters.Take(2),
            parameter => Assert.Equal(DbType.String, parameter.DbType));
    }

    [Fact]
    public async Task LoaderFailsClosedForUnexpectedSchemaNullBadDirectionAndNonFiniteValues()
    {
        var extraColumn = NumberedQuoteTable(1);
        extraColumn.Columns.Add("EXTRA", typeof(object));
        extraColumn.Rows[0]["EXTRA"] = "unexpected";

        var nullValue = NumberedQuoteTable(1);
        nullValue.Rows[0]["PRIMARY_VALUE"] = DBNull.Value;

        var badDirection = NumberedQuoteTable(1);
        badDirection.Rows[0]["DIRECTION"] = "sideways";

        var nonFinite = NumberedQuoteTable(1);
        nonFinite.Rows[0]["RATE"] = double.NaN;

        var fractionalWon = NumberedQuoteTable(1);
        fractionalWon.Rows[0]["PRIMARY_VALUE"] = 100.5m;

        foreach (var table in new[]
        {
            new DataTable(),
            extraColumn,
            nullValue,
            badDirection,
            nonFinite,
            fractionalWon
        })
        {
            var executor = new RecordingExecutor(table);
            await Assert.ThrowsAsync<LegacySceneDataException>(() =>
                new S5074SceneDataLoader(executor).LoadAsync(
                    new DomesticPagedQuoteLoadRequest(
                        PagedQuoteMarket.Kospi,
                        DomesticPagedQuoteList.GainRate,
                        new DateOnly(2026, 7, 10))));
        }
    }

    [Fact]
    public void BuilderAlsoFailsClosedForFractionalWonTrustedData()
    {
        var data = new PagedQuoteSceneData(
            PagedQuoteBranch.Domestic,
            PagedQuoteHeaderStyle.StandardWon,
            PagedQuoteTitleStyle.Exact,
            NxtMarketSession.NotApplicable,
            "Title",
            [new PagedQuoteRowData("Name", 100.5m, 1m, 1d, ScenePriceDirection.Up)],
            1);

        Assert.Throws<LegacySceneDataException>(() =>
            new S5074SceneMutationBuilder().Build(data));
    }

    [Fact]
    public async Task InvalidClosedSelectionsAreRejectedBeforeAnyDatabaseCall()
    {
        PagedQuoteLoadRequest[] invalidRequests =
        [
            new DomesticPagedQuoteLoadRequest(
                (PagedQuoteMarket)99,
                DomesticPagedQuoteList.GainRate,
                new DateOnly(2026, 7, 10)),
            new DomesticPagedQuoteLoadRequest(
                PagedQuoteMarket.Kosdaq,
                DomesticPagedQuoteList.Kospi200,
                new DateOnly(2026, 7, 10)),
            new NxtPagedQuoteLoadRequest(
                PagedQuoteMarket.Kospi,
                (NxtPagedQuoteList)99,
                NxtMarketSession.PreMarket),
            new NxtPagedQuoteLoadRequest(
                PagedQuoteMarket.Kospi,
                NxtPagedQuoteList.GainRate,
                NxtMarketSession.NotApplicable),
            new ThemePagedQuoteLoadRequest(
                "123",
                "Theme",
                (PagedQuoteThemeSort)99,
                PagedQuoteThemeValueKind.Current),
            new ThemePagedQuoteLoadRequest(
                "123",
                "Theme",
                PagedQuoteThemeSort.InputOrder,
                (PagedQuoteThemeValueKind)99),
            new NxtThemePagedQuoteLoadRequest(
                "123",
                "Theme",
                PagedQuoteThemeSort.InputOrder,
                NxtMarketSession.NotApplicable),
            new ThemePagedQuoteLoadRequest(
                "12",
                "Theme",
                PagedQuoteThemeSort.InputOrder,
                PagedQuoteThemeValueKind.Current),
            new NxtThemePagedQuoteLoadRequest(
                "12A4",
                "Theme",
                PagedQuoteThemeSort.InputOrder,
                NxtMarketSession.PreMarket),
            new NxtThemePagedQuoteLoadRequest(
                "1234",
                "Theme",
                PagedQuoteThemeSort.InputOrder,
                NxtMarketSession.PreMarket),
            new NxtThemePagedQuoteLoadRequest(
                "1234",
                "Theme(NXT)Copy(NXT)",
                PagedQuoteThemeSort.InputOrder,
                NxtMarketSession.PreMarket),
            new ExpertPagedQuoteLoadRequest("001", "Analyst A"),
            new ViPagedQuoteLoadRequest([])
        ];

        foreach (var request in invalidRequests)
        {
            var executor = new RecordingExecutor(NumberedQuoteTable(1));
            await Assert.ThrowsAsync<LegacySceneDataException>(() =>
                new S5074SceneDataLoader(executor).LoadAsync(request));
            Assert.Equal(0, executor.SpecCalls);
        }
    }

    [Fact]
    public async Task PagePlanProbe_AllowsZeroRowsAndRequestsOneRowBeyondTwentyPages()
    {
        var emptyExecutor = new RecordingExecutor(NumberedQuoteTable(0));
        var request = new DomesticPagedQuoteLoadRequest(
            PagedQuoteMarket.Kospi,
            DomesticPagedQuoteList.MarketCapitalization,
            new DateOnly(2026, 7, 12));

        var empty = await new S5074SceneDataLoader(emptyExecutor)
            .LoadPagePlanAsync(request);

        Assert.Empty(empty.Rows);
        Assert.Equal(101, Assert.Single(emptyExecutor.Spec!.Parameters).Value);

        var cappedExecutor = new RecordingExecutor(NumberedQuoteTable(101));
        var capped = await new S5074SceneDataLoader(cappedExecutor)
            .LoadPagePlanAsync(request);

        Assert.Equal(101, capped.Rows.Count);
        Assert.Equal(101, Assert.Single(cappedExecutor.Spec!.Parameters).Value);
        Assert.Equal(0, cappedExecutor.StringCalls);
    }

    private static Task<PagedQuoteSceneData> LoadAsync(
        string scene,
        IDataQueryExecutor executor,
        PagedQuoteLoadRequest request) => scene switch
    {
        "s5074" => new S5074SceneDataLoader(executor).LoadAsync(request),
        "s5077" => new S5077SceneDataLoader(executor).LoadAsync(request),
        "s5088" => new S5088SceneDataLoader(executor).LoadAsync(request),
        _ => throw new InvalidOperationException("Unknown test scene.")
    };

    private static IReadOnlyList<PlayoutMutation> Build(
        string scene,
        PagedQuoteSceneData data) => scene switch
    {
        "s5074" => new S5074SceneMutationBuilder().Build(data),
        "s5077" => new S5077SceneMutationBuilder().Build(data),
        "s5088" => new S5088SceneMutationBuilder().Build(data),
        _ => throw new InvalidOperationException("Unknown test scene.")
    };

    private static DataTable NumberedQuoteTable(int count)
    {
        var table = QuoteTable();
        for (var index = 0; index < count; index++)
        {
            table.Rows.Add("Name" + index, 1000m + index, 10m + index, 1d, "2");
        }

        return table;
    }

    private static DataTable QuoteTable(params object?[][] rows)
    {
        var table = new DataTable();
        foreach (var column in new[]
        {
            "NAME", "PRIMARY_VALUE", "SECONDARY_VALUE", "RATE", "DIRECTION"
        })
        {
            table.Columns.Add(column, typeof(object));
        }

        foreach (var row in rows)
        {
            table.Rows.Add(row);
        }

        return table;
    }

    private static DataTable ExpertQuoteTable(params object?[][] rows)
    {
        var table = new DataTable();
        foreach (var column in new[]
        {
            "EXPERT_CODE", "EXPERT_NAME", "PLAY_INDEX", "STOCK_CODE",
            "NAME", "PRIMARY_VALUE", "SECONDARY_VALUE", "RATE", "DIRECTION"
        })
        {
            table.Columns.Add(column, typeof(object));
        }

        foreach (var row in rows)
        {
            table.Rows.Add(row);
        }

        return table;
    }

    private sealed class RecordingExecutor(DataTable result) : IDataQueryExecutor
    {
        public DataSourceKind Source { get; private set; }

        public string? TableName { get; private set; }

        public DataQuerySpec? Spec { get; private set; }

        public int SpecCalls { get; private set; }

        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);
            Source = source;
            TableName = tableName;
            Spec = query;
            SpecCalls++;
            return Task.FromResult(result.Copy());
        }
    }
}
