#nullable enable

using System.Data;
using System.Globalization;
using MMoneyCoderSharp.Data;

namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes;

public enum PagedQuoteMarket
{
    Kospi,
    Kosdaq
}

public enum DomesticPagedQuoteList
{
    ExpectedMarketCapitalization,
    MarketCapitalization,
    Industry,
    AfterHoursGainRate,
    AfterHoursLossRate,
    GainRate,
    LossRate,
    FiftyTwoWeekHigh,
    FiftyTwoWeekLow,
    Kospi200,
    Etf
}

public enum NxtPagedQuoteList
{
    MarketCapitalization,
    AfterHoursGainRate,
    AfterHoursLossRate,
    GainRate,
    LossRate,
    Volume
}

public enum PagedQuoteThemeSort
{
    InputOrder,
    GainRateDescending,
    GainRateAscending
}

public enum PagedQuoteThemeValueKind
{
    Current,
    ExpectedExecution
}

/// <summary>
/// Closed request family for the six mutually exclusive legacy s5074/s5077/s5088
/// data branches. Page numbers are one-based, matching ScenePaging and PageN.
/// </summary>
public abstract record PagedQuoteLoadRequest
{
    private protected PagedQuoteLoadRequest(int pageNumberOneBased)
    {
        PageNumberOneBased = pageNumberOneBased;
    }

    public int PageNumberOneBased { get; }
}

public sealed record DomesticPagedQuoteLoadRequest(
    PagedQuoteMarket Market,
    DomesticPagedQuoteList List,
    DateOnly AsOfDate,
    int PageNumberOneBased = 1) : PagedQuoteLoadRequest(PageNumberOneBased);

public sealed record NxtPagedQuoteLoadRequest(
    PagedQuoteMarket Market,
    NxtPagedQuoteList List,
    NxtMarketSession Session,
    int PageNumberOneBased = 1) : PagedQuoteLoadRequest(PageNumberOneBased);

public sealed record ThemePagedQuoteLoadRequest(
    string ThemeCode,
    string ThemeName,
    PagedQuoteThemeSort Sort,
    PagedQuoteThemeValueKind ValueKind,
    int PageNumberOneBased = 1) : PagedQuoteLoadRequest(PageNumberOneBased);

public sealed record NxtThemePagedQuoteLoadRequest(
    string ThemeCode,
    string ThemeName,
    PagedQuoteThemeSort Sort,
    NxtMarketSession Session,
    int PageNumberOneBased = 1,
    string? ExactThemeName = null) : PagedQuoteLoadRequest(PageNumberOneBased);

public sealed record ExpertPagedQuoteLoadRequest(
    string ExpertCode,
    string ExpertName,
    int PageNumberOneBased = 1) : PagedQuoteLoadRequest(PageNumberOneBased);

public sealed record ViPagedQuoteLoadRequest(
    IReadOnlyList<string> StockNames,
    int PageNumberOneBased = 1) : PagedQuoteLoadRequest(PageNumberOneBased);

public sealed class S5074SceneDataLoader
{
    private readonly PagedQuoteSceneDataLoaderCore _core;

    public S5074SceneDataLoader(IDataQueryExecutor executor)
    {
        _core = new PagedQuoteSceneDataLoaderCore(
            executor,
            ScenePageSize.Five,
            "SCENE_5074_PAGED_QUOTES");
    }

    public Task<PagedQuoteSceneData> LoadAsync(
        PagedQuoteLoadRequest request,
        CancellationToken cancellationToken = default) =>
        _core.LoadAsync(request, cancellationToken);

    public Task<PagedQuoteSceneData> LoadPagePlanAsync(
        PagedQuoteLoadRequest request,
        CancellationToken cancellationToken = default) =>
        _core.LoadAsync(request, cancellationToken, includeTruncationProbe: true);
}

public sealed class S5077SceneDataLoader
{
    private readonly PagedQuoteSceneDataLoaderCore _core;

    public S5077SceneDataLoader(IDataQueryExecutor executor)
    {
        _core = new PagedQuoteSceneDataLoaderCore(
            executor,
            ScenePageSize.Six,
            "SCENE_5077_PAGED_QUOTES");
    }

    public Task<PagedQuoteSceneData> LoadAsync(
        PagedQuoteLoadRequest request,
        CancellationToken cancellationToken = default) =>
        _core.LoadAsync(request, cancellationToken);

    public Task<PagedQuoteSceneData> LoadPagePlanAsync(
        PagedQuoteLoadRequest request,
        CancellationToken cancellationToken = default) =>
        _core.LoadAsync(request, cancellationToken, includeTruncationProbe: true);
}

public sealed class S5088SceneDataLoader
{
    private readonly PagedQuoteSceneDataLoaderCore _core;

    public S5088SceneDataLoader(IDataQueryExecutor executor)
    {
        _core = new PagedQuoteSceneDataLoaderCore(
            executor,
            ScenePageSize.Twelve,
            "SCENE_5088_PAGED_QUOTES");
    }

    public Task<PagedQuoteSceneData> LoadAsync(
        PagedQuoteLoadRequest request,
        CancellationToken cancellationToken = default) =>
        _core.LoadAsync(request, cancellationToken);

    public Task<PagedQuoteSceneData> LoadPagePlanAsync(
        PagedQuoteLoadRequest request,
        CancellationToken cancellationToken = default) =>
        _core.LoadAsync(request, cancellationToken, includeTruncationProbe: true);
}

internal sealed class PagedQuoteSceneDataLoaderCore
{
    private static readonly string[] ResultColumns =
        ["NAME", "PRIMARY_VALUE", "SECONDARY_VALUE", "RATE", "DIRECTION"];
    private static readonly string[] ExpertResultColumns =
        [
            "EXPERT_CODE",
            "EXPERT_NAME",
            "PLAY_INDEX",
            "STOCK_CODE",
            "NAME",
            "PRIMARY_VALUE",
            "SECONDARY_VALUE",
            "RATE",
            "DIRECTION"
        ];

    private readonly IDataQueryExecutor _executor;
    private readonly ScenePageSize _pageSize;
    private readonly string _tableName;

    public PagedQuoteSceneDataLoaderCore(
        IDataQueryExecutor executor,
        ScenePageSize pageSize,
        string tableName)
    {
        _executor = executor ?? throw new ArgumentNullException(nameof(executor));
        _pageSize = pageSize;
        _tableName = tableName;
    }

    public async Task<PagedQuoteSceneData> LoadAsync(
        PagedQuoteLoadRequest request,
        CancellationToken cancellationToken,
        bool includeTruncationProbe = false)
    {
        ArgumentNullException.ThrowIfNull(request);
        if (request.PageNumberOneBased is < 1 or > ScenePaging.MaximumPageCount)
        {
            throw new LegacySceneDataException(
                "The paged quote request must select a page from 1 through 20.");
        }

        var accessibleRows = checked((int)_pageSize * ScenePaging.MaximumPageCount);
        var queryRows = includeTruncationProbe
            ? checked(accessibleRows + 1)
            : accessibleRows;
        var plan = PagedQuoteQueryFactory.Create(request, queryRows);
        var table = await _executor.ExecuteAsync(
            plan.Source,
            _tableName,
            plan.Query,
            cancellationToken).ConfigureAwait(false);
        var rows = request is ExpertPagedQuoteLoadRequest expertRequest
            ? MapExpertRows(
                table,
                queryRows,
                expertRequest,
                allowEmpty: includeTruncationProbe)
            : MapRows(
                table,
                queryRows,
                plan.HeaderStyle,
                allowEmpty: includeTruncationProbe);
        if (includeTruncationProbe && rows.Count == 0)
        {
            return new PagedQuoteSceneData(
                plan.Branch,
                plan.HeaderStyle,
                plan.TitleStyle,
                plan.Session,
                plan.Title,
                rows,
                request.PageNumberOneBased);
        }
        var window = ScenePaging.GetWindow(
            rows.Count,
            _pageSize,
            request.PageNumberOneBased);
        if (!window.IsValid)
        {
            throw new LegacySceneDataException(
                "The requested paged quote page is outside the returned result.");
        }

        return new PagedQuoteSceneData(
            plan.Branch,
            plan.HeaderStyle,
            plan.TitleStyle,
            plan.Session,
            plan.Title,
            rows,
            request.PageNumberOneBased);
    }

    private static IReadOnlyList<PagedQuoteRowData> MapRows(
        DataTable? table,
        int maximumRows,
        PagedQuoteHeaderStyle headerStyle,
        bool allowEmpty)
    {
        if (table is null || (!allowEmpty && table.Rows.Count < 1) ||
            table.Rows.Count > maximumRows)
        {
            throw new LegacySceneDataException(
                "The paged quote query returned an invalid row count.");
        }

        var actualColumns = table.Columns
            .Cast<DataColumn>()
            .Select(column => column.ColumnName)
            .ToHashSet(StringComparer.OrdinalIgnoreCase);
        if (table.Columns.Count != ResultColumns.Length ||
            ResultColumns.Any(column => !actualColumns.Contains(column)))
        {
            throw new LegacySceneDataException(
                "The paged quote query returned an unexpected schema.");
        }

        var result = new PagedQuoteRowData[table.Rows.Count];
        for (var index = 0; index < table.Rows.Count; index++)
        {
            var row = table.Rows[index];
            var mapped = new PagedQuoteRowData(
                RequiredText(row, "NAME"),
                RequiredDecimal(row, "PRIMARY_VALUE"),
                RequiredDecimal(row, "SECONDARY_VALUE"),
                RequiredRate(row, "RATE"),
                RequiredDirection(row, "DIRECTION"));
            if (headerStyle != PagedQuoteHeaderStyle.IndustryPoints &&
                (decimal.Truncate(mapped.PrimaryValue) != mapped.PrimaryValue ||
                 decimal.Truncate(mapped.SecondaryValue) != mapped.SecondaryValue))
            {
                throw InvalidValue();
            }

            result[index] = mapped;
        }

        return result;
    }

    private static IReadOnlyList<PagedQuoteRowData> MapExpertRows(
        DataTable? table,
        int maximumRows,
        ExpertPagedQuoteLoadRequest request,
        bool allowEmpty)
    {
        if (table is null || (!allowEmpty && table.Rows.Count < 1) ||
            table.Rows.Count > maximumRows)
        {
            throw new LegacySceneDataException(
                "The expert paged quote query returned an invalid row count.");
        }

        var actualColumns = table.Columns
            .Cast<DataColumn>()
            .Select(column => column.ColumnName)
            .ToHashSet(StringComparer.OrdinalIgnoreCase);
        if (table.Columns.Count != ExpertResultColumns.Length ||
            ExpertResultColumns.Any(column => !actualColumns.Contains(column)))
        {
            throw new LegacySceneDataException(
                "The expert paged quote query returned an unexpected schema.");
        }

        var result = new PagedQuoteRowData[table.Rows.Count];
        for (var index = 0; index < table.Rows.Count; index++)
        {
            var row = table.Rows[index];
            var expertCode = RequiredText(row, "EXPERT_CODE");
            var expertName = RequiredText(row, "EXPERT_NAME");
            var playIndex = RequiredNonNegativeInteger(row, "PLAY_INDEX");
            var stockCode = RequiredText(row, "STOCK_CODE");
            var stockName = RequiredText(row, "NAME");
            if (!string.Equals(expertCode, request.ExpertCode, StringComparison.Ordinal) ||
                !string.Equals(expertName, request.ExpertName, StringComparison.Ordinal))
            {
                throw new LegacySceneDataException(
                    "The expert paged quote query returned a mismatched expert identity.");
            }

            if (row["PRIMARY_VALUE"] is DBNull)
            {
                throw MissingExpertBuyAmount(
                    expertCode,
                    expertName,
                    playIndex,
                    stockCode,
                    stockName);
            }

            var mapped = new PagedQuoteRowData(
                stockName,
                RequiredDecimal(row, "PRIMARY_VALUE"),
                RequiredDecimal(row, "SECONDARY_VALUE"),
                RequiredRate(row, "RATE"),
                RequiredDirection(row, "DIRECTION"));
            if (decimal.Truncate(mapped.PrimaryValue) != mapped.PrimaryValue ||
                decimal.Truncate(mapped.SecondaryValue) != mapped.SecondaryValue)
            {
                throw InvalidValue();
            }

            result[index] = mapped;
        }

        return result;
    }

    private static int RequiredNonNegativeInteger(DataRow row, string column)
    {
        try
        {
            var value = Convert.ToDecimal(
                RequiredValue(row, column),
                CultureInfo.InvariantCulture);
            return value is >= 0 and <= 9_999 && decimal.Truncate(value) == value
                ? decimal.ToInt32(value)
                : throw InvalidValue();
        }
        catch (Exception exception) when (
            exception is FormatException or InvalidCastException or OverflowException)
        {
            throw InvalidValue();
        }
    }

    private static LegacySceneDataException MissingExpertBuyAmount(
        string expertCode,
        string expertName,
        int playIndex,
        string stockCode,
        string stockName) =>
        new(
            $"전문가 추천 송출을 준비할 수 없습니다. " +
            $"전문가: {expertName} ({expertCode}), " +
            $"PLAYINDEX: {playIndex}, " +
            $"종목: {stockName} ({stockCode}), " +
            "누락 필드: RC_BUY_AMOUNT(매수가). " +
            "전문가 추천 데이터를 수정한 뒤 다시 PREPARE 하세요.");

    private static string RequiredText(DataRow row, string column)
    {
        var value = RequiredValue(row, column);
        try
        {
            return LegacySceneBuilderGuard.Text(
                Convert.ToString(value, CultureInfo.InvariantCulture),
                column);
        }
        catch (Exception exception) when (exception is FormatException or InvalidCastException)
        {
            throw InvalidValue();
        }
    }

    private static decimal RequiredDecimal(DataRow row, string column)
    {
        try
        {
            return Convert.ToDecimal(
                RequiredValue(row, column),
                CultureInfo.InvariantCulture);
        }
        catch (Exception exception) when (
            exception is FormatException or InvalidCastException or OverflowException)
        {
            throw InvalidValue();
        }
    }

    private static double RequiredRate(DataRow row, string column)
    {
        double value;
        try
        {
            value = Convert.ToDouble(
                RequiredValue(row, column),
                CultureInfo.InvariantCulture);
        }
        catch (Exception exception) when (
            exception is FormatException or InvalidCastException or OverflowException)
        {
            throw InvalidValue();
        }

        return double.IsFinite(value) ? value : throw InvalidValue();
    }

    private static ScenePriceDirection RequiredDirection(DataRow row, string column)
    {
        string? value;
        try
        {
            value = Convert.ToString(
                RequiredValue(row, column),
                CultureInfo.InvariantCulture)?.Trim();
        }
        catch (Exception exception) when (exception is FormatException or InvalidCastException)
        {
            throw InvalidValue();
        }

        return value switch
        {
            "1" or "\uC0C1\uD55C" => ScenePriceDirection.LimitUp,
            "2" or "+" or "\uC0C1\uC2B9" => ScenePriceDirection.Up,
            "3" or "" or "\uBCF4\uD569" => ScenePriceDirection.Flat,
            "5" or "-" or "\uD558\uB77D" => ScenePriceDirection.Down,
            "4" or "\uD558\uD55C" => ScenePriceDirection.LimitDown,
            _ => throw InvalidValue()
        };
    }

    private static object RequiredValue(DataRow row, string column) =>
        row[column] is { } value && value is not DBNull
            ? value
            : throw InvalidValue();

    private static LegacySceneDataException InvalidValue() =>
        new("The paged quote query returned an invalid required value.");
}

internal sealed record PagedQuoteQueryPlan(
    DataSourceKind Source,
    DataQuerySpec Query,
    PagedQuoteBranch Branch,
    PagedQuoteHeaderStyle HeaderStyle,
    PagedQuoteTitleStyle TitleStyle,
    NxtMarketSession Session,
    string Title);

internal static class PagedQuoteQueryFactory
{
    public static PagedQuoteQueryPlan Create(
        PagedQuoteLoadRequest request,
        int maximumRows) => request switch
    {
        DomesticPagedQuoteLoadRequest value => Domestic(value, maximumRows),
        NxtPagedQuoteLoadRequest value => Nxt(value, maximumRows),
        ThemePagedQuoteLoadRequest value => Theme(value, maximumRows),
        NxtThemePagedQuoteLoadRequest value => NxtTheme(value, maximumRows),
        ExpertPagedQuoteLoadRequest value => Expert(value, maximumRows),
        ViPagedQuoteLoadRequest value => Vi(value, maximumRows),
        _ => throw new LegacySceneDataException("Unknown paged quote request type.")
    };

    private static PagedQuoteQueryPlan Domestic(
        DomesticPagedQuoteLoadRequest request,
        int maximumRows)
    {
        ValidateMarket(request.Market);
        if (!Enum.IsDefined(request.List))
        {
            throw InvalidRequest();
        }

        if ((request.List is DomesticPagedQuoteList.Kospi200 or DomesticPagedQuoteList.Etf) &&
            request.Market != PagedQuoteMarket.Kospi)
        {
            throw InvalidRequest();
        }

        var parameters = new List<DataQueryParameter>();
        var query = BuildDomesticQuery(request, parameters);
        parameters.Add(new DataQueryParameter("maxRows", maximumRows, DbType.Int32));
        var header = request.List == DomesticPagedQuoteList.Industry
            ? PagedQuoteHeaderStyle.IndustryPoints
            : PagedQuoteHeaderStyle.StandardWon;
        return new PagedQuoteQueryPlan(
            DataSourceKind.Oracle,
            new DataQuerySpec(query, parameters),
            PagedQuoteBranch.Domestic,
            header,
            PagedQuoteTitleStyle.Exact,
            NxtMarketSession.NotApplicable,
            BuildDomesticTitle(request.Market, request.List));
    }

    private static PagedQuoteQueryPlan Nxt(
        NxtPagedQuoteLoadRequest request,
        int maximumRows)
    {
        ValidateMarket(request.Market);
        ValidateNxtSession(request.Session);
        if (!Enum.IsDefined(request.List))
        {
            throw InvalidRequest();
        }

        return new PagedQuoteQueryPlan(
            DataSourceKind.MariaDb,
            new DataQuerySpec(
                BuildNxtQuery(request),
                [new DataQueryParameter("maxRows", maximumRows, DbType.Int32)]),
            PagedQuoteBranch.Nxt,
            PagedQuoteHeaderStyle.StandardWon,
            PagedQuoteTitleStyle.Exact,
            request.Session,
            BuildNxtTitle(request.Market, request.List));
    }

    private static PagedQuoteQueryPlan Theme(
        ThemePagedQuoteLoadRequest request,
        int maximumRows)
    {
        var code = RequiredThemeCode(request.ThemeCode);
        var title = RequiredSelection(request.ThemeName);
        ValidateThemeSort(request.Sort);
        if (!Enum.IsDefined(request.ValueKind))
        {
            throw InvalidRequest();
        }

        return new PagedQuoteQueryPlan(
            DataSourceKind.Oracle,
            new DataQuerySpec(
                BuildThemeQuery(request.Sort, request.ValueKind),
                [
                    new DataQueryParameter("themeCode", code, DbType.String),
                    new DataQueryParameter("themeTitle", title, DbType.String),
                    new DataQueryParameter("maxRows", maximumRows, DbType.Int32)
                ]),
            PagedQuoteBranch.Theme,
            PagedQuoteHeaderStyle.StandardWon,
            request.ValueKind == PagedQuoteThemeValueKind.ExpectedExecution
                ? PagedQuoteTitleStyle.ExpectedExecution
                : PagedQuoteTitleStyle.Exact,
            NxtMarketSession.NotApplicable,
            title);
    }

    private static PagedQuoteQueryPlan NxtTheme(
        NxtThemePagedQuoteLoadRequest request,
        int maximumRows)
    {
        var code = RequiredThemeCode(request.ThemeCode);
        var displayTitle = RequiredSelection(request.ThemeName);
        string title;
        if (request.ExactThemeName is not null)
        {
            // Native selector rows carry the exact SB_LIST key separately. It is
            // intentionally not trimmed or suffix-normalized: stored titles may
            // themselves end in one or more literal "(NXT)" tokens or spaces.
            title = RequiredExactThemeTitle(request.ExactThemeName);
        }
        else
        {
            // Historical seven-field playlists carry only the visible terminal
            // transport suffix. Keep that legacy parsing contract unchanged.
            const string displaySuffix = "(NXT)";
            if (!displayTitle.EndsWith(displaySuffix, StringComparison.Ordinal) ||
                displayTitle.Length == displaySuffix.Length)
            {
                throw InvalidRequest();
            }

            title = RequiredSelection(displayTitle[..^displaySuffix.Length]);
            if (title.Contains(displaySuffix, StringComparison.Ordinal))
            {
                throw InvalidRequest();
            }
        }
        ValidateThemeSort(request.Sort);
        ValidateNxtSession(request.Session);
        return new PagedQuoteQueryPlan(
            DataSourceKind.MariaDb,
            new DataQuerySpec(
                BuildNxtThemeQuery(request.Sort),
                [
                    new DataQueryParameter("themeCode", code, DbType.String),
                    new DataQueryParameter("themeTitle", title, DbType.String),
                    new DataQueryParameter("maxRows", maximumRows, DbType.Int32)
                ]),
            PagedQuoteBranch.NxtTheme,
            PagedQuoteHeaderStyle.StandardWon,
            PagedQuoteTitleStyle.Exact,
            request.Session,
            title);
    }

    private static PagedQuoteQueryPlan Expert(
        ExpertPagedQuoteLoadRequest request,
        int maximumRows)
    {
        var expertCode = RequiredSelection(request.ExpertCode);
        var expertName = RequiredSelection(request.ExpertName);
        if (expertCode.Length != 4 || expertCode.Any(character => !char.IsAsciiDigit(character)))
        {
            throw InvalidRequest();
        }

        return new PagedQuoteQueryPlan(
            DataSourceKind.Oracle,
            new DataQuerySpec(
                ExpertQuery,
                [
                    new DataQueryParameter("expertCode", expertCode, DbType.String),
                    new DataQueryParameter("expertName", expertName, DbType.String),
                    new DataQueryParameter("maxRows", maximumRows, DbType.Int32)
                ]),
            PagedQuoteBranch.Expert,
            PagedQuoteHeaderStyle.ExpertReturns,
            PagedQuoteTitleStyle.Exact,
            NxtMarketSession.NotApplicable,
            expertName);
    }

    private static PagedQuoteQueryPlan Vi(
        ViPagedQuoteLoadRequest request,
        int maximumRows)
    {
        if (request.StockNames is null || request.StockNames.Count is < 1 ||
            request.StockNames.Count > maximumRows)
        {
            throw InvalidRequest();
        }

        var names = request.StockNames
            .Select(RequiredSelection)
            .ToArray();
        var parameters = names
            .Select((name, index) => new DataQueryParameter(
                "stockName" + index.ToString(CultureInfo.InvariantCulture),
                name,
                DbType.String))
            .ToList();
        parameters.Add(new DataQueryParameter("maxRows", maximumRows, DbType.Int32));
        return new PagedQuoteQueryPlan(
            DataSourceKind.Oracle,
            new DataQuerySpec(BuildViQuery(names.Length), parameters),
            PagedQuoteBranch.Vi,
            PagedQuoteHeaderStyle.StandardWon,
            PagedQuoteTitleStyle.Exact,
            NxtMarketSession.NotApplicable,
            "\u0056\u0049 \uBC1C\uB3D9");
    }

    private static string RequiredSelection(string? value) =>
        LegacySceneBuilderGuard.Text(value, "Selection");

    private static string RequiredExactThemeTitle(string? value)
    {
        var title = RequiredSelection(value);
        if (title.Length > 128 || title.Any(character =>
                char.IsSurrogate(character) ||
                character == '\uFFFD' ||
                CharUnicodeInfo.GetUnicodeCategory(character) is
                    UnicodeCategory.Format or
                    UnicodeCategory.LineSeparator or
                    UnicodeCategory.ParagraphSeparator))
        {
            throw InvalidRequest();
        }

        return title;
    }

    private static void ValidateMarket(PagedQuoteMarket market)
    {
        if (!Enum.IsDefined(market))
        {
            throw InvalidRequest();
        }
    }

    private static void ValidateThemeSort(PagedQuoteThemeSort sort)
    {
        if (!Enum.IsDefined(sort))
        {
            throw InvalidRequest();
        }
    }

    private static string RequiredThemeCode(string value)
    {
        if (value is null || value.Length is < 3 or > 8 ||
            value.Any(character => !char.IsAsciiDigit(character)))
        {
            throw InvalidRequest();
        }

        return value;
    }

    private static void ValidateNxtSession(NxtMarketSession session)
    {
        if (session is not (NxtMarketSession.PreMarket or NxtMarketSession.AfterMarket))
        {
            throw InvalidRequest();
        }
    }

    private static LegacySceneDataException InvalidRequest() =>
        new("The paged quote request contains an invalid closed selection.");

    private static string BuildDomesticQuery(
        DomesticPagedQuoteLoadRequest request,
        ICollection<DataQueryParameter> parameters)
    {
        var online = request.Market == PagedQuoteMarket.Kospi
            ? "t_online1"
            : "t_kosdaq_online1";
        var stock = request.Market == PagedQuoteMarket.Kospi
            ? "t_stock"
            : "t_kosdaq_stock";
        var overtime = request.Market == PagedQuoteMarket.Kospi
            ? "t_overtime_sise"
            : "t_kosdaq_overtime_sise";

        string ranked = request.List switch
        {
            DomesticPagedQuoteList.GainRate => StandardDomesticRanked(
                online,
                stock,
                "AND a.f_chg_type IN ('1', '2')",
                "RATE DESC"),
            DomesticPagedQuoteList.LossRate => StandardDomesticRanked(
                online,
                stock,
                "AND a.f_chg_type IN ('4', '5')",
                "RATE"),
            DomesticPagedQuoteList.MarketCapitalization => $"""
                SELECT DISTINCT b.f_stock_wanname NAME,
                       a.f_curr_price PRIMARY_VALUE,
                       a.f_net_chg SECONDARY_VALUE,
                       {OracleRate("a")} RATE,
                       a.f_chg_type DIRECTION,
                       b.f_list_num * a.f_curr_price SORT_VALUE
                FROM {online} a
                JOIN {stock} b ON a.f_stock_code = b.f_stock_code
                WHERE b.f_mkt_halt = 'N'
                  AND b.f_sect_code IN ('ST', 'FS', 'DR', 'RT', 'IF')
                  AND a.f_curr_price <> 0
                ORDER BY SORT_VALUE DESC
                """,
            DomesticPagedQuoteList.ExpectedMarketCapitalization =>
                ExpectedCapitalRanked(request.Market),
            DomesticPagedQuoteList.Industry => IndustryRanked(request.Market),
            DomesticPagedQuoteList.AfterHoursGainRate => OvertimeRanked(
                overtime,
                stock,
                "AND a.f_chg_type IN ('1', '2')",
                "RATE DESC"),
            DomesticPagedQuoteList.AfterHoursLossRate => OvertimeRanked(
                overtime,
                stock,
                "AND a.f_chg_type IN ('4', '5')",
                "RATE"),
            DomesticPagedQuoteList.FiftyTwoWeekHigh => FiftyTwoWeekRanked(
                request,
                parameters,
                online,
                stock,
                high: true),
            DomesticPagedQuoteList.FiftyTwoWeekLow => FiftyTwoWeekRanked(
                request,
                parameters,
                online,
                stock,
                high: false),
            DomesticPagedQuoteList.Kospi200 => $"""
                SELECT DISTINCT b.f_stock_wanname NAME,
                       a.f_curr_price PRIMARY_VALUE,
                       a.f_net_chg SECONDARY_VALUE,
                       {OracleRate("a")} RATE,
                       a.f_chg_type DIRECTION,
                       b.f_list_num * a.f_curr_price SORT_VALUE
                FROM t_online1 a
                JOIN t_stock b ON a.f_stock_code = b.f_stock_code
                WHERE b.f_mkt_halt = 'N'
                  AND b.f_kospi200_gubun <> '0'
                  AND a.f_curr_price <> 0
                ORDER BY SORT_VALUE DESC
                """,
            DomesticPagedQuoteList.Etf => $"""
                SELECT DISTINCT b.f_stock_wanname NAME,
                       a.f_curr_price PRIMARY_VALUE,
                       a.f_net_chg SECONDARY_VALUE,
                       {OracleRate("a")} RATE,
                       a.f_chg_type DIRECTION,
                       b.f_list_num * a.f_curr_price SORT_VALUE
                FROM t_online1 a
                JOIN t_stock b ON a.f_stock_code = b.f_stock_code
                WHERE b.f_mkt_halt = 'N'
                  AND b.f_sect_code = 'EF'
                  AND a.f_curr_price <> 0
                ORDER BY SORT_VALUE DESC
                """,
            _ => throw InvalidRequest()
        };

        return $"""
            SELECT NAME, PRIMARY_VALUE, SECONDARY_VALUE, RATE, DIRECTION
            FROM (
            {ranked}
            )
            WHERE ROWNUM <= :maxRows
            """;
    }

    private static string StandardDomesticRanked(
        string online,
        string stock,
        string extraPredicate,
        string orderBy) => $"""
        SELECT DISTINCT b.f_stock_wanname NAME,
               a.f_curr_price PRIMARY_VALUE,
               a.f_net_chg SECONDARY_VALUE,
               {OracleRate("a")} RATE,
               a.f_chg_type DIRECTION
        FROM {online} a
        JOIN {stock} b ON a.f_stock_code = b.f_stock_code
        WHERE b.f_mkt_halt = 'N'
          AND b.f_sect_code IN ('ST', 'FS', 'DR', 'RT', 'IF')
          AND a.f_curr_price <> 0
          {extraPredicate}
        ORDER BY {orderBy}
        """;

    private static string ExpectedCapitalRanked(PagedQuoteMarket market)
    {
        var call = market == PagedQuoteMarket.Kospi
            ? "t_online1_call"
            : "t_kosdaq_online1_call";
        var stock = market == PagedQuoteMarket.Kospi
            ? "t_stock"
            : "t_kosdaq_stock";
        var batch = market == PagedQuoteMarket.Kospi
            ? "t_batch_day"
            : "t_kosdaq_batch_day";
        return $"""
            SELECT DISTINCT b.f_stock_wanname NAME,
                   a.f_fore_price PRIMARY_VALUE,
                   ABS(a.f_fore_price - c.f_final_price) SECONDARY_VALUE,
                   ROUND(((a.f_fore_price - c.f_final_price) / c.f_final_price) * 100, 2) RATE,
                   CASE WHEN a.f_fore_price - c.f_final_price > 0 THEN '2'
                        WHEN a.f_fore_price - c.f_final_price < 0 THEN '5'
                        ELSE '3' END DIRECTION,
                   b.f_list_num * a.f_fore_price SORT_VALUE
            FROM {call} a
            JOIN {stock} b ON a.f_stock_code = b.f_stock_code
            JOIN {batch} c ON a.f_stock_code = c.f_stock_code
            WHERE b.f_mkt_halt = 'N'
              AND b.f_sect_code IN ('ST', 'FS', 'DR', 'RT', 'IF')
              AND a.f_fore_price <> 0
              AND c.f_final_price <> 0
            ORDER BY SORT_VALUE DESC
            """;
    }

    private static string IndustryRanked(PagedQuoteMarket market)
    {
        var index = market == PagedQuoteMarket.Kospi
            ? "t_index"
            : "t_kosdaq_index";
        var part = market == PagedQuoteMarket.Kospi
            ? "t_part"
            : "t_kosdaq_part";
        return $"""
            SELECT b.f_part_name NAME,
                   a.f_part_idx / 100 PRIMARY_VALUE,
                   a.f_part_chg / 100 SECONDARY_VALUE,
                   ROUND(a.f_part_chg /
                         DECODE(a.f_chg_type,
                                '+', (a.f_part_idx / 100) - (a.f_part_chg / 100),
                                '-', (a.f_part_idx / 100) + (a.f_part_chg / 100)), 2) RATE,
                   a.f_chg_type DIRECTION
            FROM {index} a
            JOIN {part} b ON a.f_part_code = b.f_part_code
            WHERE a.f_part_code <> '001'
            ORDER BY a.f_part_code
            """;
    }

    private static string OvertimeRanked(
        string overtime,
        string stock,
        string extraPredicate,
        string orderBy) => $"""
        SELECT DISTINCT b.f_stock_wanname NAME,
               a.f_curr_price PRIMARY_VALUE,
               a.f_net_chg SECONDARY_VALUE,
               {OracleRate("a")} RATE,
               a.f_chg_type DIRECTION
        FROM {overtime} a
        JOIN {stock} b ON a.f_stock_code = b.f_stock_code
        WHERE b.f_mkt_halt = 'N'
          AND b.f_sect_code = 'ST'
          AND a.f_curr_price <> 0
          {extraPredicate}
        ORDER BY {orderBy}
        """;

    private static string FiftyTwoWeekRanked(
        DomesticPagedQuoteLoadRequest request,
        ICollection<DataQueryParameter> parameters,
        string online,
        string stock,
        bool high)
    {
        if (request.AsOfDate.DayNumber < 364)
        {
            throw InvalidRequest();
        }

        var history = request.Market == PagedQuoteMarket.Kospi
            ? "t_candle_history"
            : "t_kosdaq_candle_history";
        var priceColumn = high ? "f_high_price" : "f_low_price";
        var aggregate = high ? "MAX" : "MAX";
        var comparison = high
            ? $"h.{priceColumn} <= d.f_high_price"
            : $"h.{priceColumn} <= d.f_low_price";
        var order = high
            ? "RATE DESC"
            : request.Market == PagedQuoteMarket.Kospi ? "RATE" : "RATE DESC";
        parameters.Add(new DataQueryParameter(
            "startDate",
            request.AsOfDate.AddDays(-364).ToString("yyyyMMdd", CultureInfo.InvariantCulture),
            DbType.String));
        parameters.Add(new DataQueryParameter(
            "endDate",
            request.AsOfDate.ToString("yyyyMMdd", CultureInfo.InvariantCulture),
            DbType.String));
        return $"""
            SELECT b.f_stock_wanname NAME,
                   d.f_curr_price PRIMARY_VALUE,
                   d.f_net_chg SECONDARY_VALUE,
                   {OracleRate("d")} RATE,
                   d.f_chg_type DIRECTION
            FROM (
                SELECT f_stock_code, {aggregate}({priceColumn}) {priceColumn}
                FROM {history}
                WHERE f_data_date BETWEEN :startDate AND :endDate
                GROUP BY f_stock_code
            ) h
            JOIN {stock} b ON h.f_stock_code = b.f_stock_code
            JOIN {online} d ON h.f_stock_code = d.f_stock_code
            WHERE d.f_mkt_halt = 'N'
              AND b.f_sect_code IN ('ST', 'FS', 'DR', 'RT', 'IF')
              AND d.f_curr_price > 0
              AND {comparison}
            ORDER BY {order}
            """;
    }

    private static string OracleRate(string alias) => $"""
        ROUND(({alias}.f_net_chg /
               DECODE({alias}.f_chg_type,
                      '1', {alias}.f_curr_price - {alias}.f_net_chg,
                      '2', {alias}.f_curr_price - {alias}.f_net_chg,
                      '3', {alias}.f_curr_price,
                      '4', -1 * ({alias}.f_curr_price + {alias}.f_net_chg),
                      '5', -1 * ({alias}.f_curr_price + {alias}.f_net_chg))) * 100, 2)
        """;

    private static string BuildNxtQuery(NxtPagedQuoteLoadRequest request)
    {
        var online = request.Market == PagedQuoteMarket.Kospi
            ? "n_online"
            : "n_kosdaq_online";
        var stock = request.Market == PagedQuoteMarket.Kospi
            ? "n_stock"
            : "n_kosdaq_stock";
        var overtime = request.Market == PagedQuoteMarket.Kospi
            ? "n_overtime"
            : "n_kosdaq_overtime";
        var source = request.List is
            NxtPagedQuoteList.AfterHoursGainRate or
            NxtPagedQuoteList.AfterHoursLossRate
                ? overtime
                : online;
        var predicate = request.List switch
        {
            NxtPagedQuoteList.GainRate or NxtPagedQuoteList.AfterHoursGainRate =>
                "AND a.f_chg_type IN ('1', '2')",
            NxtPagedQuoteList.LossRate or NxtPagedQuoteList.AfterHoursLossRate =>
                "AND a.f_chg_type IN ('4', '5')",
            _ => string.Empty
        };
        var sortValue = request.List switch
        {
            NxtPagedQuoteList.MarketCapitalization =>
                "b.f_list_num * a.f_curr_price",
            NxtPagedQuoteList.Volume => "a.f_net_vol",
            _ => "0"
        };
        var order = request.List switch
        {
            NxtPagedQuoteList.MarketCapitalization =>
                "SORT_VALUE DESC",
            NxtPagedQuoteList.Volume => "SORT_VALUE DESC",
            NxtPagedQuoteList.GainRate or NxtPagedQuoteList.AfterHoursGainRate =>
                "RATE DESC",
            NxtPagedQuoteList.LossRate or NxtPagedQuoteList.AfterHoursLossRate =>
                "RATE",
            _ => throw InvalidRequest()
        };
        return $"""
            SELECT NAME, PRIMARY_VALUE, SECONDARY_VALUE, RATE, DIRECTION
            FROM (
                SELECT DISTINCT b.f_stock_name NAME,
                       a.f_curr_price PRIMARY_VALUE,
                       a.f_net_chg SECONDARY_VALUE,
                       {MariaRate("a")} RATE,
                       a.f_chg_type DIRECTION,
                       {sortValue} SORT_VALUE
                FROM {source} a
                JOIN {stock} b ON a.f_stock_code = b.f_stock_code
                WHERE b.f_stop_gubun = 'N'
                  AND a.f_curr_price != 0
                  {predicate}
                ORDER BY {order}
            ) ranked
            LIMIT @maxRows
            """;
    }

    private static string MariaRate(string alias) => $"""
        ROUND(({alias}.f_net_chg /
               CASE {alias}.f_chg_type
                    WHEN '1' THEN {alias}.f_curr_price - {alias}.f_net_chg
                    WHEN '2' THEN {alias}.f_curr_price - {alias}.f_net_chg
                    WHEN '3' THEN {alias}.f_curr_price
                    WHEN '4' THEN -1 * ({alias}.f_curr_price + {alias}.f_net_chg)
                    WHEN '5' THEN -1 * ({alias}.f_curr_price + {alias}.f_net_chg)
               END) * 100, 2)
        """;

    private static string BuildThemeQuery(
        PagedQuoteThemeSort sort,
        PagedQuoteThemeValueKind valueKind)
    {
        var quoteRows = valueKind switch
        {
            PagedQuoteThemeValueKind.Current => $"""
                SELECT b.f_stock_code STOCK_CODE,
                       b.f_stock_wanname NAME,
                       a.f_curr_price PRIMARY_VALUE,
                       ABS(a.f_net_chg) SECONDARY_VALUE,
                       {OracleRate("a")} RATE,
                       a.f_chg_type DIRECTION
                FROM t_online1 a
                JOIN t_stock b ON a.f_stock_code = b.f_stock_code
                WHERE b.f_mkt_halt = 'N' AND a.f_curr_price <> 0
                UNION ALL
                SELECT b.f_stock_code STOCK_CODE,
                       b.f_stock_wanname NAME,
                       a.f_curr_price PRIMARY_VALUE,
                       a.f_net_chg SECONDARY_VALUE,
                       {OracleRate("a")} RATE,
                       a.f_chg_type DIRECTION
                FROM t_kosdaq_online1 a
                JOIN t_kosdaq_stock b ON a.f_stock_code = b.f_stock_code
                WHERE b.f_mkt_halt = 'N' AND a.f_curr_price <> 0
                """,
            PagedQuoteThemeValueKind.ExpectedExecution => """
                SELECT b.f_stock_code STOCK_CODE,
                       b.f_stock_wanname NAME,
                       a.f_fore_price PRIMARY_VALUE,
                       ABS(a.f_fore_price - c.f_final_price) SECONDARY_VALUE,
                       ROUND(((a.f_fore_price - c.f_final_price) / c.f_final_price) * 100, 2) RATE,
                       CASE WHEN a.f_fore_price - c.f_final_price > 0 THEN '2'
                            WHEN a.f_fore_price - c.f_final_price < 0 THEN '5'
                            ELSE '3' END DIRECTION
                FROM t_online1_call a
                JOIN t_stock b ON a.f_stock_code = b.f_stock_code
                JOIN t_batch_day c ON a.f_stock_code = c.f_stock_code
                WHERE b.f_mkt_halt = 'N'
                  AND a.f_fore_price <> 0
                  AND c.f_final_price <> 0
                UNION ALL
                SELECT b.f_stock_code STOCK_CODE,
                       b.f_stock_wanname NAME,
                       a.f_fore_price PRIMARY_VALUE,
                       ABS(a.f_fore_price - c.f_final_price) SECONDARY_VALUE,
                       ROUND(((a.f_fore_price - c.f_final_price) / c.f_final_price) * 100, 2) RATE,
                       CASE WHEN a.f_fore_price - c.f_final_price > 0 THEN '2'
                            WHEN a.f_fore_price - c.f_final_price < 0 THEN '5'
                            ELSE '3' END DIRECTION
                FROM t_kosdaq_online1_call a
                JOIN t_kosdaq_stock b ON a.f_stock_code = b.f_stock_code
                JOIN t_kosdaq_batch_day c ON a.f_stock_code = c.f_stock_code
                WHERE b.f_mkt_halt = 'N'
                  AND a.f_fore_price <> 0
                  AND c.f_final_price <> 0
                """,
            _ => throw InvalidRequest()
        };
        var orderBy = sort switch
        {
            PagedQuoteThemeSort.InputOrder => "t.SB_I_INDEX",
            PagedQuoteThemeSort.GainRateDescending => "q.RATE DESC",
            PagedQuoteThemeSort.GainRateAscending => "q.RATE",
            _ => throw InvalidRequest()
        };
        return $"""
            WITH THEME_ITEMS AS (
                SELECT i.SB_I_CODE, i.SB_I_INDEX
                FROM SB_LIST l
                JOIN SB_ITEM i ON l.SB_CODE = i.SB_M_CODE
                WHERE l.SB_CODE = :themeCode
                  AND l.SB_TITLE = :themeTitle
                  AND l.SB_MARKET = 'KRX'
            ), QUOTES AS (
            {quoteRows}
            )
            SELECT NAME, PRIMARY_VALUE, SECONDARY_VALUE, RATE, DIRECTION
            FROM (
                SELECT q.NAME, q.PRIMARY_VALUE, q.SECONDARY_VALUE,
                       q.RATE, q.DIRECTION
                FROM THEME_ITEMS t
                JOIN QUOTES q ON SUBSTR(t.SB_I_CODE, 2, 12) = q.STOCK_CODE
                ORDER BY {orderBy}
            )
            WHERE ROWNUM <= :maxRows
            """;
    }

    private static string BuildNxtThemeQuery(PagedQuoteThemeSort sort)
    {
        var orderBy = sort switch
        {
            PagedQuoteThemeSort.InputOrder => "i.SB_I_INDEX",
            PagedQuoteThemeSort.GainRateDescending => "RATE DESC",
            PagedQuoteThemeSort.GainRateAscending => "RATE",
            _ => throw InvalidRequest()
        };
        return $"""
            SELECT NAME, PRIMARY_VALUE, SECONDARY_VALUE, RATE, DIRECTION
            FROM (
                SELECT b.F_STOCK_NAME NAME,
                       b.f_curr_price PRIMARY_VALUE,
                       b.f_net_chg SECONDARY_VALUE,
                       {MariaRate("b")} RATE,
                       b.f_chg_type DIRECTION
                FROM SB_LIST l
                JOIN SB_ITEM i ON l.SB_CODE = i.SB_M_CODE
                JOIN v_all_stock b
                  ON SUBSTRING(i.SB_I_CODE, 2, 12) = b.f_stock_code
                WHERE l.SB_CODE = @themeCode
                  AND l.SB_TITLE = @themeTitle
                  AND l.SB_MARKET = 'NXT'
                  AND b.f_stop_gubun = 'N'
                  AND b.f_curr_price != 0
                ORDER BY {orderBy}
            ) ranked
            LIMIT @maxRows
            """;
    }

    private const string ExpertQuery = """
        WITH QUOTES AS (
            SELECT b.f_stock_code STOCK_CODE,
                   b.f_stock_wanname NAME,
                   a.f_curr_price CURRENT_PRICE
            FROM t_online1 a
            JOIN t_stock b ON a.f_stock_code = b.f_stock_code
            WHERE b.f_mkt_halt = 'N' AND a.f_curr_price <> 0
            UNION ALL
            SELECT b.f_stock_code STOCK_CODE,
                   b.f_stock_wanname NAME,
                   a.f_curr_price CURRENT_PRICE
            FROM t_kosdaq_online1 a
            JOIN t_kosdaq_stock b ON a.f_stock_code = b.f_stock_code
            WHERE b.f_mkt_halt = 'N' AND a.f_curr_price <> 0
        )
        SELECT EXPERT_CODE, EXPERT_NAME, PLAY_INDEX, STOCK_CODE,
               NAME, PRIMARY_VALUE, SECONDARY_VALUE, RATE, DIRECTION
        FROM (
            SELECT e.EP_CODE EXPERT_CODE,
                   e.EP_NAME EXPERT_NAME,
                   r.PLAYINDEX PLAY_INDEX,
                   r.RC_STOCK_CODE STOCK_CODE,
                   r.RC_STOCK_NAME NAME,
                   r.RC_BUY_AMOUNT PRIMARY_VALUE,
                   q.CURRENT_PRICE SECONDARY_VALUE,
                   CASE
                       WHEN r.RC_BUY_AMOUNT IS NULL OR r.RC_BUY_AMOUNT = 0 OR
                            q.CURRENT_PRICE IS NULL THEN NULL
                       ELSE ROUND(((q.CURRENT_PRICE - r.RC_BUY_AMOUNT) /
                                   r.RC_BUY_AMOUNT) * 100, 2)
                   END RATE,
                   CASE WHEN r.RC_BUY_AMOUNT IS NULL OR q.CURRENT_PRICE IS NULL THEN NULL
                        WHEN q.CURRENT_PRICE - r.RC_BUY_AMOUNT > 0 THEN '2'
                        WHEN q.CURRENT_PRICE - r.RC_BUY_AMOUNT < 0 THEN '5'
                        ELSE '3' END DIRECTION
            FROM EXPERT_LIST e
            JOIN RECOMMEND_LIST r ON e.EP_CODE = r.RC_CODE
            LEFT JOIN QUOTES q ON r.RC_STOCK_CODE = q.STOCK_CODE
            WHERE e.EP_CODE = :expertCode
              AND e.EP_NAME = :expertName
              AND (r.RC_BUY_AMOUNT IS NULL OR
                   (r.RC_BUY_AMOUNT <> 0 AND q.STOCK_CODE IS NOT NULL))
            ORDER BY r.PLAYINDEX
        )
        WHERE ROWNUM <= :maxRows
        """;

    private static string BuildViQuery(int nameCount)
    {
        var requestedRows = Enumerable.Range(0, nameCount)
            .Select(index => $"SELECT {index.ToString(CultureInfo.InvariantCulture)} INPUT_ORDER, :stockName{index.ToString(CultureInfo.InvariantCulture)} STOCK_NAME FROM DUAL")
            .ToArray();
        return $"""
            WITH REQUESTED_NAMES AS (
                {string.Join(" UNION ALL ", requestedRows)}
            ), QUOTES AS (
                SELECT b.f_stock_wanname NAME,
                       a.f_curr_price PRIMARY_VALUE,
                       ABS(a.f_net_chg) SECONDARY_VALUE,
                       {OracleRate("a")} RATE,
                       a.f_chg_type DIRECTION
                FROM t_online1 a
                JOIN t_stock b ON a.f_stock_code = b.f_stock_code
                WHERE b.f_mkt_halt = 'N' AND a.f_curr_price <> 0
                UNION ALL
                SELECT b.f_stock_wanname NAME,
                       a.f_curr_price PRIMARY_VALUE,
                       a.f_net_chg SECONDARY_VALUE,
                       {OracleRate("a")} RATE,
                       a.f_chg_type DIRECTION
                FROM t_kosdaq_online1 a
                JOIN t_kosdaq_stock b ON a.f_stock_code = b.f_stock_code
                WHERE b.f_mkt_halt = 'N' AND a.f_curr_price <> 0
            )
            SELECT NAME, PRIMARY_VALUE, SECONDARY_VALUE, RATE, DIRECTION
            FROM (
                SELECT q.NAME, q.PRIMARY_VALUE, q.SECONDARY_VALUE,
                       q.RATE, q.DIRECTION
                FROM REQUESTED_NAMES r
                JOIN QUOTES q ON r.STOCK_NAME = q.NAME
                ORDER BY r.INPUT_ORDER
            )
            WHERE ROWNUM <= :maxRows
            """;
    }

    private static string BuildDomesticTitle(
        PagedQuoteMarket market,
        DomesticPagedQuoteList list)
    {
        var marketName = MarketName(market);
        return list switch
        {
            DomesticPagedQuoteList.ExpectedMarketCapitalization =>
                marketName + " \uC608\uC0C1\uAC00 \uC2DC\uAC00\uCD1D\uC561 \uC0C1\uC704",
            DomesticPagedQuoteList.MarketCapitalization =>
                marketName + " \uC2DC\uAC00\uCD1D\uC561 \uC0C1\uC704",
            DomesticPagedQuoteList.Industry => marketName + " \uC5C5\uC885",
            DomesticPagedQuoteList.AfterHoursGainRate =>
                marketName + " \uC2DC\uAC04\uC678 \uC0C1\uC2B9\uB960 \uC0C1\uC704",
            DomesticPagedQuoteList.AfterHoursLossRate =>
                marketName + " \uC2DC\uAC04\uC678 \uD558\uB77D\uB960 \uC0C1\uC704",
            DomesticPagedQuoteList.GainRate =>
                marketName + " \uC0C1\uC2B9\uB960 \uC0C1\uC704",
            DomesticPagedQuoteList.LossRate =>
                marketName + " \uD558\uB77D\uB960 \uC0C1\uC704",
            DomesticPagedQuoteList.FiftyTwoWeekHigh =>
                marketName + " 52\uC8FC \uC2E0\uACE0\uAC00",
            DomesticPagedQuoteList.FiftyTwoWeekLow =>
                marketName + " 52\uC8FC \uC2E0\uC800\uAC00",
            DomesticPagedQuoteList.Kospi200 => marketName + " 200",
            DomesticPagedQuoteList.Etf => "ETF",
            _ => throw InvalidRequest()
        };
    }

    private static string BuildNxtTitle(
        PagedQuoteMarket market,
        NxtPagedQuoteList list)
    {
        var marketName = MarketName(market);
        return list switch
        {
            NxtPagedQuoteList.MarketCapitalization =>
                marketName + " \uC2DC\uAC00\uCD1D\uC561 \uC0C1\uC704",
            NxtPagedQuoteList.AfterHoursGainRate =>
                marketName + " \uC2DC\uAC04\uC678 \uC0C1\uC2B9\uB960 \uC0C1\uC704",
            NxtPagedQuoteList.AfterHoursLossRate =>
                marketName + " \uC2DC\uAC04\uC678 \uD558\uB77D\uB960 \uC0C1\uC704",
            NxtPagedQuoteList.GainRate =>
                marketName + " \uC0C1\uC2B9\uB960 \uC0C1\uC704",
            NxtPagedQuoteList.LossRate =>
                marketName + " \uD558\uB77D\uB960 \uC0C1\uC704",
            NxtPagedQuoteList.Volume =>
                marketName + " \uAC70\uB798\uB7C9 \uC0C1\uC704",
            _ => throw InvalidRequest()
        };
    }

    private static string MarketName(PagedQuoteMarket market) => market switch
    {
        PagedQuoteMarket.Kospi => "\uCF54\uC2A4\uD53C",
        PagedQuoteMarket.Kosdaq => "\uCF54\uC2A4\uB2E5",
        _ => throw InvalidRequest()
    };
}
