#nullable enable

using System.Globalization;

namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes;

/// <summary>
/// Closed query targets shared by the legacy two-column market scenes. Query loaders
/// translate these values to vendor/database identifiers; no raw symbol reaches a DTO.
/// </summary>
public enum LegacyMarketQuoteTarget
{
    Kospi,
    Kosdaq,
    Kospi200,
    Futures,
    Krx100,
    WonDollar,
    WonYen,
    WonYuan,
    WonEuro,
    Dow,
    Nasdaq,
    Sp500,
    GermanyDax,
    UnitedKingdomFtse,
    FranceCac,
    Nikkei,
    ShanghaiComposite,
    HangSeng,
    TaiwanWeighted,
    SingaporeStraitsTimes,
    ThailandSet,
    PhilippinesComposite,
    MalaysiaKlse,
    IndonesiaComposite
}

public enum S5001Branch
{
    BalticDryIndex,
    Commodity,
    DomesticIndex,
    ForeignIndex,
    ExchangeRate,
    DomesticIndustry,
    ForeignIndustry,
    ForeignStock,
    DomesticStock
}

public enum S5001CommodityTarget
{
    FeedWheat,
    Corn,
    Wheat,
    Soybean,
    Bean,
    BrownRice,
    Coffee,
    Cocoa,
    Sugar,
    RawCotton,
    Cotton,
    LiveCattle,
    FeederCattle,
    LeanHogs,
    Copper,
    IronOre,
    Nickel,
    NaturalGas,
    Platinum,
    Palladium,
    Lead,
    Zinc,
    Tin,
    InternationalGold,
    InternationalSilver,
    DomesticGold,
    DomesticSilver
}

public enum S5001DomesticIndexTarget
{
    Kospi,
    Kosdaq,
    Kospi200,
    Futures,
    Krx100
}

public enum S5001IndexMode
{
    Current,
    Expected
}

public enum S5001ForeignIndexTarget
{
    Dow,
    Nasdaq,
    Sp500,
    GermanyDax,
    UnitedKingdomFtse,
    FranceCac,
    Nikkei,
    ShanghaiComposite,
    HangSeng,
    TaiwanWeighted,
    SingaporeStraitsTimes,
    ThailandSet,
    PhilippinesComposite,
    MalaysiaKlse,
    IndonesiaComposite
}

public enum S5001ExchangeRateTarget
{
    WonDollar,
    WonYen,
    WonYuan,
    WonEuro
}

public enum S5001DomesticStockMode
{
    Current,
    ExpectedOpening,
    AfterHoursSinglePrice
}

public enum S5001NxtBadge
{
    None,
    PreMarket,
    AfterMarket
}

public abstract record S5001SceneData : ILegacySceneData
{
    private protected S5001SceneData()
    {
    }

    public abstract S5001Branch Branch { get; }
}

public sealed record S5001BalticDryIndexSceneData(
    string Title,
    ScenePriceDirection Direction,
    long Price,
    long NetChange,
    decimal Rate) : S5001SceneData
{
    public override S5001Branch Branch => S5001Branch.BalticDryIndex;
}

public sealed record S5001CommoditySceneData(
    S5001CommodityTarget Target,
    string Title,
    ScenePriceDirection Direction,
    decimal Price,
    decimal NetChange,
    decimal Rate) : S5001SceneData
{
    public override S5001Branch Branch => S5001Branch.Commodity;
}

public sealed record S5001DomesticIndexSceneData(
    S5001DomesticIndexTarget Target,
    S5001IndexMode Mode,
    string Title,
    ScenePriceDirection Direction,
    decimal Price,
    decimal NetChange,
    decimal Rate) : S5001SceneData
{
    public override S5001Branch Branch => S5001Branch.DomesticIndex;
}

public sealed record S5001ForeignIndexSceneData(
    S5001ForeignIndexTarget Target,
    string Title,
    ScenePriceDirection Direction,
    decimal Price,
    decimal NetChange,
    decimal Rate) : S5001SceneData
{
    public override S5001Branch Branch => S5001Branch.ForeignIndex;
}

public sealed record S5001ExchangeRateSceneData(
    S5001ExchangeRateTarget Target,
    string Title,
    ScenePriceDirection Direction,
    decimal Price,
    decimal NetChange,
    decimal Rate) : S5001SceneData
{
    public override S5001Branch Branch => S5001Branch.ExchangeRate;
}

public sealed record S5001DomesticIndustrySceneData(
    LegacyDomesticEquityMarket Market,
    string Title,
    ScenePriceDirection Direction,
    decimal Price,
    decimal NetChange,
    decimal Rate) : S5001SceneData
{
    public override S5001Branch Branch => S5001Branch.DomesticIndustry;
}

public sealed record S5001ForeignIndustrySceneData(
    string Title,
    ScenePriceDirection Direction,
    decimal Price,
    decimal NetChange,
    decimal Rate) : S5001SceneData
{
    public override S5001Branch Branch => S5001Branch.ForeignIndustry;
}

public sealed record S5001ForeignStockSceneData(
    string Title,
    ScenePriceDirection Direction,
    decimal Price,
    decimal NetChange,
    decimal Rate) : S5001SceneData
{
    public override S5001Branch Branch => S5001Branch.ForeignStock;
}

public sealed record S5001DomesticStockSceneData(
    LegacyDomesticEquityMarket Market,
    S5001DomesticStockMode Mode,
    S5001NxtBadge Badge,
    string StockName,
    ScenePriceDirection Direction,
    long Price,
    long NetChange,
    decimal Rate) : S5001SceneData
{
    public override S5001Branch Branch => S5001Branch.DomesticStock;
}

public sealed class S5001SceneMutationBuilder : ILegacySceneMutationBuilder<S5001SceneData>
{
    public string BuilderKey => "s5001";

    public IReadOnlyList<PlayoutMutation> Build(S5001SceneData data)
    {
        ArgumentNullException.ThrowIfNull(data);
        var mutations = new List<PlayoutMutation>(48)
        {
            new PlayoutSetVisible("mart", false)
        };

        switch (data)
        {
            case S5001BalticDryIndexSceneData bdi:
                AddBalticDryIndex(mutations, bdi);
                break;
            case S5001CommoditySceneData commodity:
                AddCommodity(mutations, commodity);
                break;
            case S5001DomesticIndexSceneData domesticIndex:
                AddDomesticIndex(mutations, domesticIndex);
                break;
            case S5001ForeignIndexSceneData foreignIndex:
                AddForeignIndex(mutations, foreignIndex);
                break;
            case S5001ExchangeRateSceneData exchangeRate:
                AddDecimalQuote(mutations, exchangeRate.Title, exchangeRate.Direction,
                    exchangeRate.Price, exchangeRate.NetChange, exchangeRate.Rate, "단위 : 원");
                RequireDefined(exchangeRate.Target, nameof(exchangeRate.Target));
                break;
            case S5001DomesticIndustrySceneData industry:
                AddDomesticIndustry(mutations, industry);
                break;
            case S5001ForeignIndustrySceneData foreignIndustry:
                AddDecimalQuote(mutations, foreignIndustry.Title, foreignIndustry.Direction,
                    foreignIndustry.Price, foreignIndustry.NetChange, foreignIndustry.Rate, "단위 : p");
                break;
            case S5001ForeignStockSceneData foreignStock:
                AddDecimalQuote(mutations, foreignStock.Title, foreignStock.Direction,
                    foreignStock.Price, foreignStock.NetChange, foreignStock.Rate, "단위 : 달러");
                break;
            case S5001DomesticStockSceneData stock:
                AddDomesticStock(mutations, stock);
                break;
            default:
                throw new LegacySceneDataException("Unsupported s5001 branch DTO.");
        }

        return mutations;
    }

    private static void AddBalticDryIndex(
        List<PlayoutMutation> mutations,
        S5001BalticDryIndexSceneData data)
    {
        var title = LegacySceneBuilderGuard.Text(data.Title, nameof(data.Title));
        var direction = RequireDirection(data.Direction);
        var variant = DirectionVariant(direction);
        mutations.Add(new PlayoutSetValue("title", title));
        mutations.Add(new PlayoutSetValue("price", FormatInteger(data.Price)));
        mutations.Add(new PlayoutSetValue("changePrice" + variant, FormatInteger(Math.Abs(data.NetChange))));
        mutations.Add(new PlayoutSetValue("rate" + variant, FormatRate(data.Rate)));
        mutations.Add(new PlayoutSetValue("wonText" + variant, string.Empty));
        mutations.Add(new PlayoutSetValue("unitText", "단위 : 달러"));
        mutations.AddRange(ExactLegacyPriceDirectionMutations.Build(direction, 0));
    }

    private static void AddCommodity(
        List<PlayoutMutation> mutations,
        S5001CommoditySceneData data)
    {
        RequireDefined(data.Target, nameof(data.Target));
        var title = LegacySceneBuilderGuard.Text(data.Title, nameof(data.Title));
        var direction = RequireDirection(data.Direction);
        var variant = DirectionVariant(direction);
        mutations.Add(new PlayoutSetValue("title", title));
        mutations.Add(new PlayoutSetValue("price", FormatRawDecimal(data.Price)));
        mutations.Add(new PlayoutSetValue("changePrice" + variant, FormatRawDecimal(Math.Abs(data.NetChange))));
        mutations.Add(new PlayoutSetValue("rate" + variant, FormatRate(data.Rate)));
        mutations.Add(new PlayoutSetValue("wonText" + variant, string.Empty));
        mutations.Add(new PlayoutSetValue("unitText", "단위 : 달러"));
        mutations.AddRange(ExactLegacyPriceDirectionMutations.Build(direction, 0));
    }

    private static void AddDomesticIndex(
        List<PlayoutMutation> mutations,
        S5001DomesticIndexSceneData data)
    {
        RequireDefined(data.Target, nameof(data.Target));
        RequireDefined(data.Mode, nameof(data.Mode));
        AddDecimalQuote(mutations, data.Title, data.Direction,
            data.Price, data.NetChange, data.Rate, "단위 : p");
    }

    private static void AddForeignIndex(
        List<PlayoutMutation> mutations,
        S5001ForeignIndexSceneData data)
    {
        RequireDefined(data.Target, nameof(data.Target));
        AddDecimalQuote(mutations, data.Title, data.Direction,
            data.Price, data.NetChange, data.Rate, "단위 : p");
    }

    private static void AddDomesticIndustry(
        List<PlayoutMutation> mutations,
        S5001DomesticIndustrySceneData data)
    {
        RequireDomesticMarket(data.Market, allowNxt: true);
        var prefix = data.Market is LegacyDomesticEquityMarket.Kospi or LegacyDomesticEquityMarket.NxtKospi
            ? "코스피 - "
            : "코스닥 - ";
        var title = prefix + LegacySceneBuilderGuard.Text(data.Title, nameof(data.Title));
        AddDecimalQuote(mutations, title, data.Direction,
            data.Price, data.NetChange, data.Rate, "단위 : p");
    }

    private static void AddDecimalQuote(
        List<PlayoutMutation> mutations,
        string rawTitle,
        ScenePriceDirection rawDirection,
        decimal price,
        decimal netChange,
        decimal rate,
        string unit)
    {
        var title = LegacySceneBuilderGuard.Text(rawTitle, nameof(rawTitle));
        var direction = RequireDirection(rawDirection);
        var variant = DirectionVariant(direction);
        mutations.Add(new PlayoutSetValue("title", title));
        mutations.Add(new PlayoutSetValue("price", FormatDecimal2(price)));
        mutations.Add(new PlayoutSetValue("changePrice" + variant, FormatDecimal2(Math.Abs(netChange))));
        mutations.Add(new PlayoutSetValue("rate" + variant, FormatDirectedRate(rate, direction)));
        mutations.Add(new PlayoutSetValue("wonText" + variant, string.Empty));
        mutations.Add(new PlayoutSetValue("unitText", unit));
        mutations.AddRange(ExactLegacyPriceDirectionMutations.Build(direction, 0));
    }

    private static void AddDomesticStock(
        List<PlayoutMutation> mutations,
        S5001DomesticStockSceneData data)
    {
        RequireDomesticMarket(data.Market, allowNxt: true);
        RequireDefined(data.Mode, nameof(data.Mode));
        RequireDefined(data.Badge, nameof(data.Badge));
        var direction = RequireDirection(data.Direction);
        var variant = DirectionVariant(direction);
        var rawName = LegacySceneBuilderGuard.Text(data.StockName, nameof(data.StockName));
        var isNxt = data.Market is LegacyDomesticEquityMarket.NxtKospi or LegacyDomesticEquityMarket.NxtKosdaq;

        if (data.Mode != S5001DomesticStockMode.Current && isNxt)
        {
            throw new LegacySceneDataException("s5001 expected/after-hours stock branches do not accept NXT markets.");
        }

        if (data.Mode == S5001DomesticStockMode.Current)
        {
            if (isNxt == (data.Badge == S5001NxtBadge.None))
            {
                throw new LegacySceneDataException("s5001 current NXT stocks require a closed session badge; KRX stocks require None.");
            }
        }
        else if (data.Badge != S5001NxtBadge.None)
        {
            throw new LegacySceneDataException("s5001 non-current stock branches cannot carry a market badge.");
        }

        // The legacy constructor writes this before entering any stock sub-branch.
        mutations.Add(new PlayoutSetValue("unitText", "단위 : 원"));

        switch (data.Mode)
        {
            case S5001DomesticStockMode.ExpectedOpening:
                mutations.Add(new PlayoutSetValue("title", rawName + " 예상체결가"));
                AddStockValues(mutations, data, variant, forceDownMagnitude: true);
                break;
            case S5001DomesticStockMode.AfterHoursSinglePrice:
                mutations.Add(new PlayoutSetValue("title", rawName));
                mutations.Add(new PlayoutSetValue("title", rawName + " 시간외단일가"));
                AddStockValues(mutations, data, variant, forceDownMagnitude: false);
                break;
            case S5001DomesticStockMode.Current:
                mutations.Add(new PlayoutSetValue(
                    "title",
                    rawName.Replace("(NXT)", string.Empty, StringComparison.Ordinal)));
                AddStockValues(mutations, data, variant, forceDownMagnitude: false);
                if (isNxt)
                {
                    var asset = data.Badge == S5001NxtBadge.PreMarket
                        ? @"Images\프리마켓.png"
                        : @"Images\애프터마켓.png";
                    mutations.Add(new PlayoutSetAssetValue("mart", asset));
                    mutations.Add(new PlayoutSetVisible("mart", true));
                }
                else
                {
                    mutations.Add(new PlayoutSetVisible("mart", false));
                }

                break;
            default:
                throw new LegacySceneDataException("Unsupported s5001 stock mode.");
        }

        mutations.AddRange(ExactLegacyPriceDirectionMutations.Build(direction, 0));
    }

    private static void AddStockValues(
        List<PlayoutMutation> mutations,
        S5001DomesticStockSceneData data,
        string variant,
        bool forceDownMagnitude)
    {
        var change = forceDownMagnitude && data.Direction is ScenePriceDirection.Down or ScenePriceDirection.LimitDown
            ? Math.Abs(data.NetChange)
            : data.NetChange;
        mutations.Add(new PlayoutSetValue("price", FormatInteger(data.Price)));
        mutations.Add(new PlayoutSetValue("changePrice" + variant, FormatInteger(change)));
        mutations.Add(new PlayoutSetValue("rate" + variant, FormatDirectedRate(data.Rate, data.Direction)));
        mutations.Add(new PlayoutSetValue("wonText" + variant, string.Empty));
        mutations.Add(new PlayoutSetValue("unitText", "단위 : 원"));
    }

    private static string FormatInteger(long value) =>
        value.ToString("#,##0", CultureInfo.InvariantCulture);

    private static string FormatDecimal2(decimal value) =>
        value.ToString("#,##0.00", CultureInfo.InvariantCulture);

    private static string FormatRate(decimal value) =>
        value.ToString("##0.00", CultureInfo.InvariantCulture);

    private static string FormatRawDecimal(decimal value) =>
        value.ToString(CultureInfo.InvariantCulture);

    internal static string FormatDirectedRate(decimal rate, ScenePriceDirection direction)
    {
        var value = direction is ScenePriceDirection.Down or ScenePriceDirection.LimitDown
            ? -Math.Abs(rate)
            : rate;
        return value.ToString("#,##0.00", CultureInfo.InvariantCulture);
    }

    internal static string DirectionVariant(ScenePriceDirection direction) => direction switch
    {
        ScenePriceDirection.LimitUp or ScenePriceDirection.Up => "1_",
        ScenePriceDirection.Flat => "2_",
        ScenePriceDirection.Down or ScenePriceDirection.LimitDown => "3_",
        _ => throw new LegacySceneDataException("A known scene price direction is required.")
    };

    internal static ScenePriceDirection RequireDirection(ScenePriceDirection direction) => direction switch
    {
        ScenePriceDirection.LimitUp or ScenePriceDirection.Up or ScenePriceDirection.Flat or
            ScenePriceDirection.Down or ScenePriceDirection.LimitDown => direction,
        _ => throw new LegacySceneDataException("A known scene price direction is required.")
    };

    internal static void RequireDomesticMarket(LegacyDomesticEquityMarket market, bool allowNxt)
    {
        var valid = market is LegacyDomesticEquityMarket.Kospi or LegacyDomesticEquityMarket.Kosdaq ||
                    (allowNxt && market is LegacyDomesticEquityMarket.NxtKospi or LegacyDomesticEquityMarket.NxtKosdaq);
        if (!valid)
        {
            throw new LegacySceneDataException("A supported closed domestic market is required.");
        }
    }

    internal static void RequireDefined<TEnum>(TEnum value, string name)
        where TEnum : struct, Enum
    {
        if (!Enum.IsDefined(value))
        {
            throw new LegacySceneDataException($"Scene enum field '{name}' is invalid.");
        }
    }
}

public enum S5032Branch
{
    IndexAndFutures,
    DomesticStockPair,
    DomesticIndustryPair
}

public enum S5032CompanionSignal
{
    Positive,
    Flat,
    Negative
}

public sealed record S5032PrimaryQuote(
    string Title,
    decimal Price,
    decimal NetChange,
    decimal Rate,
    ScenePriceDirection Direction);

public sealed record S5032CompanionQuote(
    string Title,
    decimal Price,
    decimal NetChange,
    S5032CompanionSignal Signal,
    decimal OpenInterest,
    decimal TradingValue,
    decimal Volume);

public sealed record S5032StockQuote(
    string Title,
    long Price,
    long NetChange,
    decimal Rate,
    ScenePriceDirection Direction);

public abstract record S5032SceneData : ILegacySceneData
{
    private protected S5032SceneData()
    {
    }

    public abstract S5032Branch Branch { get; }
}

public sealed record S5032IndexAndFuturesSceneData(
    LegacyMarketQuoteTarget PrimaryTarget,
    LegacyMarketQuoteTarget CompanionTarget,
    S5032PrimaryQuote Primary,
    S5032CompanionQuote Companion) : S5032SceneData
{
    public override S5032Branch Branch => S5032Branch.IndexAndFutures;
}

public sealed record S5032DomesticIndustryPairSceneData(
    LegacyDomesticEquityMarket Market,
    S5032PrimaryQuote Primary,
    S5032CompanionQuote Companion) : S5032SceneData
{
    public override S5032Branch Branch => S5032Branch.DomesticIndustryPair;
}

public sealed record S5032DomesticStockPairSceneData(
    LegacyDomesticEquityMarket FirstMarket,
    LegacyDomesticEquityMarket SecondMarket,
    S5032StockQuote First,
    S5032StockQuote Second) : S5032SceneData
{
    public override S5032Branch Branch => S5032Branch.DomesticStockPair;
}

public sealed class S5032SceneMutationBuilder : ILegacySceneMutationBuilder<S5032SceneData>
{
    public string BuilderKey => "s5032";

    public IReadOnlyList<PlayoutMutation> Build(S5032SceneData data)
    {
        ArgumentNullException.ThrowIfNull(data);
        return data switch
        {
            S5032IndexAndFuturesSceneData comparison => BuildComparison(comparison),
            S5032DomesticIndustryPairSceneData industry => BuildIndustry(industry),
            S5032DomesticStockPairSceneData stocks => BuildStocks(stocks),
            _ => throw new LegacySceneDataException("Unsupported s5032 branch DTO.")
        };
    }

    private static IReadOnlyList<PlayoutMutation> BuildComparison(
        S5032IndexAndFuturesSceneData data)
    {
        S5001SceneMutationBuilder.RequireDefined(data.PrimaryTarget, nameof(data.PrimaryTarget));
        S5001SceneMutationBuilder.RequireDefined(data.CompanionTarget, nameof(data.CompanionTarget));
        return BuildComparisonCore(data.Primary, data.Companion, null);
    }

    private static IReadOnlyList<PlayoutMutation> BuildIndustry(
        S5032DomesticIndustryPairSceneData data)
    {
        S5001SceneMutationBuilder.RequireDomesticMarket(data.Market, allowNxt: false);
        // Unlike s5001/s8018, s5032's Data_View writes both DB titles verbatim.
        return BuildComparisonCore(data.Primary, data.Companion, null);
    }

    private static IReadOnlyList<PlayoutMutation> BuildComparisonCore(
        S5032PrimaryQuote? rawPrimary,
        S5032CompanionQuote? rawCompanion,
        string? titlePrefix)
    {
        var primary = LegacySceneBuilderGuard.NotNull(rawPrimary, nameof(rawPrimary));
        var companion = LegacySceneBuilderGuard.NotNull(rawCompanion, nameof(rawCompanion));
        var primaryTitle = LegacySceneBuilderGuard.Text(primary.Title, nameof(primary.Title));
        var companionTitle = LegacySceneBuilderGuard.Text(companion.Title, nameof(companion.Title));
        var direction = S5001SceneMutationBuilder.RequireDirection(primary.Direction);
        S5001SceneMutationBuilder.RequireDefined(companion.Signal, nameof(companion.Signal));

        var mutations = new List<PlayoutMutation>(52)
        {
            new PlayoutSetValue("title", (titlePrefix ?? string.Empty) + primaryTitle),
            new PlayoutSetValue("price", FormatDecimal2(primary.Price)),
            new PlayoutSetValue("changePrice", FormatDecimal2(Math.Abs(primary.NetChange))),
            new PlayoutSetValue("rate", S5001SceneMutationBuilder.FormatDirectedRate(primary.Rate, direction))
        };
        mutations.AddRange(ExactLegacyPriceDirectionMutations.Build(direction, 3));
        mutations.Add(new PlayoutSetValue("title1", (titlePrefix ?? string.Empty) + companionTitle));
        mutations.Add(new PlayoutSetValue("price1", FormatRawDecimal(companion.Price)));
        mutations.Add(new PlayoutSetValue("changePrice1", FormatRawDecimal(companion.NetChange)));
        AddCompanionDirection(mutations, companion.Signal);
        mutations.Add(new PlayoutSetValue("r_value1", FormatWhole(companion.OpenInterest)));
        mutations.Add(new PlayoutSetValue("r_value2", FormatWhole(companion.TradingValue / 1_000_000m)));
        mutations.Add(new PlayoutSetValue("r_value3", FormatWhole(companion.Volume)));
        return mutations;
    }

    private static IReadOnlyList<PlayoutMutation> BuildStocks(S5032DomesticStockPairSceneData data)
    {
        S5001SceneMutationBuilder.RequireDomesticMarket(data.FirstMarket, allowNxt: false);
        S5001SceneMutationBuilder.RequireDomesticMarket(data.SecondMarket, allowNxt: false);
        var first = ValidateStock(data.First, nameof(data.First));
        var second = ValidateStock(data.Second, nameof(data.Second));
        var mutations = new List<PlayoutMutation>(72)
        {
            new PlayoutSetValue("title1", first.Title),
            new PlayoutSetValue("title2", second.Title),
            new PlayoutSetValue("price1", FormatInteger(first.Price)),
            new PlayoutSetValue("price2", FormatInteger(second.Price)),
            new PlayoutSetValue("changePrice1", FormatInteger(first.NetChange)),
            new PlayoutSetValue("changePrice2", FormatInteger(second.NetChange)),
            new PlayoutSetValue("rate1", FormatRate(first.Rate)),
            new PlayoutSetValue("rate2", FormatRate(second.Rate)),
            new PlayoutSetValue("wonText1", string.Empty),
            new PlayoutSetValue("wonText2", string.Empty),
            new PlayoutSetValue("unitText1", "단위 : 원"),
            new PlayoutSetValue("unitText2", "단위 : 원")
        };
        mutations.AddRange(ExactLegacyPriceDirectionMutations.Build(first.Direction, 1));
        mutations.AddRange(ExactLegacyPriceDirectionMutations.Build(second.Direction, 2));
        return mutations;
    }

    private static S5032StockQuote ValidateStock(S5032StockQuote? quote, string name)
    {
        var value = LegacySceneBuilderGuard.NotNull(quote, name);
        var title = LegacySceneBuilderGuard.Text(value.Title, name + ".Title");
        var direction = S5001SceneMutationBuilder.RequireDirection(value.Direction);
        return value with { Title = title, Direction = direction };
    }

    private static void AddCompanionDirection(
        ICollection<PlayoutMutation> mutations,
        S5032CompanionSignal signal)
    {
        var up = signal == S5032CompanionSignal.Positive;
        var down = signal == S5032CompanionSignal.Negative;
        mutations.Add(new PlayoutSetVisible("up1", up));
        mutations.Add(new PlayoutSetVisible("upup1", false));
        mutations.Add(new PlayoutSetVisible("down1", down));
        mutations.Add(new PlayoutSetVisible("downdown1", false));
        mutations.Add(new PlayoutSetVisible("flat1", !up && !down));
    }

    private static string FormatInteger(long value) => value.ToString("#,##0", CultureInfo.InvariantCulture);
    private static string FormatDecimal2(decimal value) => value.ToString("#,##0.00", CultureInfo.InvariantCulture);
    private static string FormatRate(decimal value) => value.ToString("##0.00", CultureInfo.InvariantCulture);
    private static string FormatWhole(decimal value) => value.ToString("#,##0", CultureInfo.InvariantCulture);
    private static string FormatRawDecimal(decimal value) => value.ToString(CultureInfo.InvariantCulture);
}

public enum S8018Branch
{
    MarketPair,
    MixedMarketAndStock,
    ExpectedStockPair,
    CurrentStockPair,
    DomesticIndustryPair
}

public enum S8018PairSide
{
    First,
    Second
}

public enum S8018StockMarket
{
    Kospi,
    Kosdaq,
    NxtKospi,
    NxtKosdaq,
    World
}

public enum S8018SessionPhase
{
    BeforeOrAtKrxClose,
    AfterKrxClose
}

public sealed record S8018MarketQuote(
    string Title,
    decimal Price,
    decimal NetChange,
    decimal Rate,
    ScenePriceDirection Direction);

public sealed record S8018MixedStockQuote(
    LegacyDomesticEquityMarket Market,
    string Title,
    long Price,
    long NetChange,
    decimal Rate,
    ScenePriceDirection Direction);

public sealed record S8018ExpectedStockQuote(
    LegacyDomesticEquityMarket Market,
    string Title,
    long Price,
    long NetChange,
    decimal Rate,
    ScenePriceDirection Direction);

public sealed record S8018CurrentStockQuote(
    S8018StockMarket Market,
    string Title,
    decimal Price,
    decimal NetChange,
    decimal Rate,
    ScenePriceDirection Direction);

public abstract record S8018SceneData : ILegacySceneData
{
    private protected S8018SceneData()
    {
    }

    public abstract S8018Branch Branch { get; }
}

public sealed record S8018MarketPairSceneData(
    LegacyMarketQuoteTarget FirstTarget,
    LegacyMarketQuoteTarget SecondTarget,
    S8018MarketQuote First,
    S8018MarketQuote Second) : S8018SceneData
{
    public override S8018Branch Branch => S8018Branch.MarketPair;
}

public sealed record S8018MixedMarketAndStockSceneData(
    S8018PairSide StockSide,
    LegacyMarketQuoteTarget MarketTarget,
    S8018MarketQuote MarketQuote,
    S8018MixedStockQuote StockQuote) : S8018SceneData
{
    public override S8018Branch Branch => S8018Branch.MixedMarketAndStock;
}

public sealed record S8018ExpectedStockPairSceneData(
    S8018ExpectedStockQuote First,
    S8018ExpectedStockQuote Second) : S8018SceneData
{
    public override S8018Branch Branch => S8018Branch.ExpectedStockPair;
}

public sealed record S8018CurrentStockPairSceneData(
    S8018SessionPhase Session,
    S8018CurrentStockQuote First,
    S8018CurrentStockQuote Second) : S8018SceneData
{
    public override S8018Branch Branch => S8018Branch.CurrentStockPair;
}

public sealed record S8018DomesticIndustryPairSceneData(
    LegacyDomesticEquityMarket Market,
    S8018MarketQuote First,
    S8018MarketQuote Second) : S8018SceneData
{
    public override S8018Branch Branch => S8018Branch.DomesticIndustryPair;
}

public sealed class S8018SceneMutationBuilder : ILegacySceneMutationBuilder<S8018SceneData>
{
    public string BuilderKey => "s8018";

    public IReadOnlyList<PlayoutMutation> Build(S8018SceneData data)
    {
        ArgumentNullException.ThrowIfNull(data);
        var mutations = InitialBadgeHides();
        switch (data)
        {
            case S8018MarketPairSceneData pair:
                S5001SceneMutationBuilder.RequireDefined(pair.FirstTarget, nameof(pair.FirstTarget));
                S5001SceneMutationBuilder.RequireDefined(pair.SecondTarget, nameof(pair.SecondTarget));
                AddMarketPair(mutations, pair.First, pair.Second, null);
                break;
            case S8018DomesticIndustryPairSceneData industries:
                S5001SceneMutationBuilder.RequireDomesticMarket(industries.Market, allowNxt: false);
                var prefix = industries.Market == LegacyDomesticEquityMarket.Kospi ? "코스피 - " : "코스닥 - ";
                AddMarketPair(mutations, industries.First, industries.Second, prefix);
                break;
            case S8018MixedMarketAndStockSceneData mixed:
                AddMixedPair(mutations, mixed);
                break;
            case S8018ExpectedStockPairSceneData expected:
                AddExpectedStocks(mutations, expected);
                break;
            case S8018CurrentStockPairSceneData current:
                AddCurrentStocks(mutations, current);
                break;
            default:
                throw new LegacySceneDataException("Unsupported s8018 branch DTO.");
        }

        return mutations;
    }

    private static List<PlayoutMutation> InitialBadgeHides() =>
    [
        new PlayoutSetVisible("mart1", false),
        new PlayoutSetVisible("mart2", false),
        new PlayoutSetVisible("end1", false),
        new PlayoutSetVisible("end2", false)
    ];

    private static void AddMarketPair(
        List<PlayoutMutation> mutations,
        S8018MarketQuote? rawFirst,
        S8018MarketQuote? rawSecond,
        string? prefix)
    {
        var first = ValidateMarketQuote(rawFirst, nameof(rawFirst));
        var second = ValidateMarketQuote(rawSecond, nameof(rawSecond));
        mutations.Add(new PlayoutSetValue("title1", (prefix ?? string.Empty) + first.Title));
        mutations.Add(new PlayoutSetValue("title2", (prefix ?? string.Empty) + second.Title));
        mutations.Add(new PlayoutSetValue("price1", FormatDecimal2(first.Price)));
        mutations.Add(new PlayoutSetValue("price2", FormatDecimal2(second.Price)));
        AddComparisonChangeValues(mutations, first, 1);
        AddComparisonChangeValues(mutations, second, 2);
        AddComparisonRateValues(mutations, first, 1);
        AddComparisonRateValues(mutations, second, 2);
        mutations.Add(new PlayoutSetValue("wonText1", string.Empty));
        mutations.Add(new PlayoutSetValue("wonText2", string.Empty));
        mutations.Add(new PlayoutSetValue("unitText1", "단위 : p"));
        mutations.Add(new PlayoutSetValue("unitText2", "단위 : p"));
        mutations.AddRange(ExactLegacyPriceDirectionMutations.Build(first.Direction, 1));
        mutations.AddRange(ExactLegacyPriceDirectionMutations.Build(second.Direction, 2));
    }

    private static void AddMixedPair(
        List<PlayoutMutation> mutations,
        S8018MixedMarketAndStockSceneData data)
    {
        S5001SceneMutationBuilder.RequireDefined(data.StockSide, nameof(data.StockSide));
        S5001SceneMutationBuilder.RequireDefined(data.MarketTarget, nameof(data.MarketTarget));
        var market = ValidateMarketQuote(data.MarketQuote, nameof(data.MarketQuote));
        var stock = ValidateMixedStock(data.StockQuote);

        if (data.StockSide == S8018PairSide.First)
        {
            AddSingleMarketSide(mutations, market, 2, addUnit: true);
            AddSimpleStockSide(mutations, stock, 1);
        }
        else
        {
            // Data_View contains a duplicated "index == 1" condition; consequently
            // the original does not write unitText1 for this partial side.
            AddSingleMarketSide(mutations, market, 1, addUnit: false);
            AddSimpleStockSide(mutations, stock, 2);
        }
    }

    private static void AddSingleMarketSide(
        List<PlayoutMutation> mutations,
        S8018MarketQuote quote,
        int side,
        bool addUnit)
    {
        mutations.Add(new PlayoutSetValue("title" + side, quote.Title));
        mutations.Add(new PlayoutSetValue("price" + side, FormatDecimal2(quote.Price)));
        AddComparisonChangeValues(mutations, quote, side);
        AddComparisonRateValues(mutations, quote, side);
        mutations.Add(new PlayoutSetValue("wonText1", string.Empty));
        mutations.Add(new PlayoutSetValue("wonText2", string.Empty));
        if (addUnit)
        {
            mutations.Add(new PlayoutSetValue("unitText" + side, "단위 : p"));
        }

        mutations.AddRange(ExactLegacyPriceDirectionMutations.Build(quote.Direction, side));
    }

    private static void AddSimpleStockSide(
        List<PlayoutMutation> mutations,
        S8018MixedStockQuote quote,
        int side)
    {
        mutations.Add(new PlayoutSetValue("title" + side, quote.Title));
        mutations.Add(new PlayoutSetValue("price" + side, FormatInteger(quote.Price)));
        mutations.Add(new PlayoutSetValue("changePrice" + side, FormatInteger(quote.NetChange)));
        mutations.Add(new PlayoutSetValue("rate" + side, FormatRate(quote.Rate)));
        mutations.Add(new PlayoutSetValue("wonText" + side, string.Empty));
        mutations.Add(new PlayoutSetValue("unitText" + side, "단위 : 원"));
        mutations.AddRange(ExactLegacyPriceDirectionMutations.Build(quote.Direction, side));
    }

    private static void AddExpectedStocks(
        List<PlayoutMutation> mutations,
        S8018ExpectedStockPairSceneData data)
    {
        var first = ValidateExpectedStock(data.First, nameof(data.First));
        var second = ValidateExpectedStock(data.Second, nameof(data.Second));
        mutations.Add(new PlayoutSetValue("title1", first.Title + " 예상체결"));
        mutations.Add(new PlayoutSetValue("title2", second.Title + " 예상체결"));
        mutations.Add(new PlayoutSetValue("price1", FormatInteger(first.Price)));
        mutations.Add(new PlayoutSetValue("price2", FormatInteger(second.Price)));
        for (var variant = 1; variant <= 3; variant++)
        {
            mutations.Add(new PlayoutSetValue($"changePrice{variant}_1", FormatInteger(Math.Abs(first.NetChange))));
            mutations.Add(new PlayoutSetValue($"changePrice{variant}_2", FormatInteger(Math.Abs(second.NetChange))));
        }

        for (var variant = 1; variant <= 3; variant++)
        {
            mutations.Add(new PlayoutSetValue($"rate{variant}_1", FormatRate(first.Rate)));
            mutations.Add(new PlayoutSetValue($"rate{variant}_2", FormatRate(second.Rate)));
        }

        mutations.Add(new PlayoutSetValue("wonText1", string.Empty));
        mutations.Add(new PlayoutSetValue("wonText2", string.Empty));
        mutations.Add(new PlayoutSetValue("unitText1", "단위 : 원"));
        mutations.Add(new PlayoutSetValue("unitText2", "단위 : 원"));
        mutations.AddRange(ExactLegacyPriceDirectionMutations.Build(first.Direction, 1));
        mutations.AddRange(ExactLegacyPriceDirectionMutations.Build(second.Direction, 2));
    }

    private static void AddCurrentStocks(
        List<PlayoutMutation> mutations,
        S8018CurrentStockPairSceneData data)
    {
        S5001SceneMutationBuilder.RequireDefined(data.Session, nameof(data.Session));
        var first = ValidateCurrentStock(data.First, nameof(data.First));
        var second = ValidateCurrentStock(data.Second, nameof(data.Second));
        mutations.Add(new PlayoutSetValue("unitText1", UnitFor(first.Market)));
        mutations.Add(new PlayoutSetValue("unitText2", UnitFor(second.Market)));
        mutations.Add(new PlayoutSetValue("title1", first.Title));
        mutations.Add(new PlayoutSetValue("title2", second.Title));
        AddVenue(mutations, first.Market, data.Session, 1);
        AddVenue(mutations, second.Market, data.Session, 2);

        if (IsKrx(first.Market) && IsKrx(second.Market))
        {
            mutations.Add(new PlayoutSetVisible("mart1", false));
            mutations.Add(new PlayoutSetVisible("mart2", false));
            mutations.Add(new PlayoutSetVisible("end1", false));
            mutations.Add(new PlayoutSetVisible("end2", false));
        }

        mutations.Add(new PlayoutSetValue("price1", FormatLegacyStockDecimal(first.Price)));
        mutations.Add(new PlayoutSetValue("price2", FormatLegacyStockDecimal(second.Price)));
        for (var variant = 1; variant <= 3; variant++)
        {
            mutations.Add(new PlayoutSetValue(
                $"changePrice{variant}_1",
                FormatLegacyStockDecimal(first.NetChange)));
            mutations.Add(new PlayoutSetValue(
                $"changePrice{variant}_2",
                FormatLegacyStockDecimal(second.NetChange)));
        }

        for (var variant = 1; variant <= 3; variant++)
        {
            mutations.Add(new PlayoutSetValue($"rate{variant}_1", FormatRate(first.Rate)));
            mutations.Add(new PlayoutSetValue($"rate{variant}_2", FormatRate(second.Rate)));
        }

        mutations.Add(new PlayoutSetValue("wonText1", string.Empty));
        mutations.Add(new PlayoutSetValue("wonText2", string.Empty));
        mutations.AddRange(ExactLegacyPriceDirectionMutations.Build(first.Direction, 1));
        mutations.AddRange(ExactLegacyPriceDirectionMutations.Build(second.Direction, 2));
    }

    private static void AddVenue(
        ICollection<PlayoutMutation> mutations,
        S8018StockMarket market,
        S8018SessionPhase session,
        int side)
    {
        if (market == S8018StockMarket.World)
        {
            mutations.Add(new PlayoutSetVisible("mart" + side, false));
            mutations.Add(new PlayoutSetVisible("end" + side, false));
            return;
        }

        var krx = IsKrx(market);
        mutations.Add(new PlayoutSetAssetValue(
            "mart" + side,
            krx ? @"Images\KRX.png" : @"Images\NXT.png"));
        mutations.Add(new PlayoutSetVisible("mart" + side, true));
        if (session == S8018SessionPhase.BeforeOrAtKrxClose)
        {
            mutations.Add(new PlayoutSetVisible("end" + side, false));
        }
        else if (krx)
        {
            mutations.Add(new PlayoutSetVisible("end" + side, true));
        }
    }

    private static void AddComparisonChangeValues(
        ICollection<PlayoutMutation> mutations,
        S8018MarketQuote quote,
        int side)
    {
        var value = FormatDecimal2(Math.Abs(quote.NetChange));
        for (var variant = 1; variant <= 3; variant++)
        {
            mutations.Add(new PlayoutSetValue($"changePrice{variant}_{side}", value));
        }
    }

    private static void AddComparisonRateValues(
        ICollection<PlayoutMutation> mutations,
        S8018MarketQuote quote,
        int side)
    {
        var value = S5001SceneMutationBuilder.FormatDirectedRate(quote.Rate, quote.Direction);
        for (var variant = 1; variant <= 3; variant++)
        {
            mutations.Add(new PlayoutSetValue($"rate{variant}_{side}", value));
        }
    }

    private static S8018MarketQuote ValidateMarketQuote(S8018MarketQuote? quote, string name)
    {
        var value = LegacySceneBuilderGuard.NotNull(quote, name);
        var title = LegacySceneBuilderGuard.Text(value.Title, name + ".Title");
        var direction = S5001SceneMutationBuilder.RequireDirection(value.Direction);
        return value with { Title = title, Direction = direction };
    }

    private static S8018MixedStockQuote ValidateMixedStock(S8018MixedStockQuote? quote)
    {
        var value = LegacySceneBuilderGuard.NotNull(quote, nameof(quote));
        S5001SceneMutationBuilder.RequireDomesticMarket(value.Market, allowNxt: true);
        var title = LegacySceneBuilderGuard.Text(value.Title, nameof(value.Title));
        var direction = S5001SceneMutationBuilder.RequireDirection(value.Direction);
        return value with { Title = title, Direction = direction };
    }

    private static S8018ExpectedStockQuote ValidateExpectedStock(
        S8018ExpectedStockQuote? quote,
        string name)
    {
        var value = LegacySceneBuilderGuard.NotNull(quote, name);
        S5001SceneMutationBuilder.RequireDomesticMarket(value.Market, allowNxt: false);
        var title = LegacySceneBuilderGuard.Text(value.Title, name + ".Title");
        var direction = S5001SceneMutationBuilder.RequireDirection(value.Direction);
        return value with { Title = title, Direction = direction };
    }

    private static S8018CurrentStockQuote ValidateCurrentStock(
        S8018CurrentStockQuote? quote,
        string name)
    {
        var value = LegacySceneBuilderGuard.NotNull(quote, name);
        S5001SceneMutationBuilder.RequireDefined(value.Market, name + ".Market");
        var title = LegacySceneBuilderGuard.Text(value.Title, name + ".Title");
        var direction = S5001SceneMutationBuilder.RequireDirection(value.Direction);
        return value with { Title = title, Direction = direction };
    }

    private static bool IsKrx(S8018StockMarket market) =>
        market is S8018StockMarket.Kospi or S8018StockMarket.Kosdaq;

    private static string UnitFor(S8018StockMarket market) =>
        market == S8018StockMarket.World ? "단위 : 달러" : "단위 : 원";

    private static string FormatInteger(long value) => value.ToString("#,##0", CultureInfo.InvariantCulture);
    private static string FormatDecimal2(decimal value) => value.ToString("#,##0.00", CultureInfo.InvariantCulture);
    private static string FormatRate(decimal value) => value.ToString("##0.00", CultureInfo.InvariantCulture);

    private static string FormatLegacyStockDecimal(decimal value)
    {
        var scale = (decimal.GetBits(value)[3] >> 16) & 0x7f;
        return scale > 0
            ? value.ToString(CultureInfo.InvariantCulture)
            : value.ToString("#,##0", CultureInfo.InvariantCulture);
    }
}

public enum S8001Market
{
    Kospi,
    Kosdaq
}

public enum S8001Sector
{
    Communications,
    Finance,
    Construction,
    Machinery,
    Distribution,
    PaperAndWood,
    TransportationEquipment,
    TransportationWarehouse,
    FoodAndBeverage,
    SteelAndMetal,
    MedicalPrecision,
    TextilesAndApparel,
    ElectricalAndElectronics,
    Chemicals,
    Pharmaceuticals,
    MachineryAndEquipment,
    ItComponents,
    Transportation,
    Semiconductor,
    Internet
}

public enum S8001SectorSignal
{
    Positive,
    Flat,
    Negative
}

public sealed record S8001SectorQuote(
    S8001Sector Sector,
    S8001SectorSignal Signal,
    decimal Magnitude);

public sealed record S8001SceneData(
    S8001Market Market,
    IReadOnlyList<S8001SectorQuote> Sectors) : ILegacySceneData;

public sealed class S8001SceneMutationBuilder : ILegacySceneMutationBuilder<S8001SceneData>
{
    public string BuilderKey => "s8001";

    public IReadOnlyList<PlayoutMutation> Build(S8001SceneData data)
    {
        ArgumentNullException.ThrowIfNull(data);
        S5001SceneMutationBuilder.RequireDefined(data.Market, nameof(data.Market));
        var rows = LegacySceneBuilderGuard.Range(data.Sectors, 1, 18, nameof(data.Sectors));
        var seen = new HashSet<S8001Sector>();
        var mutations = new List<PlayoutMutation>(rows.Count * 2);
        foreach (var row in rows)
        {
            if (row is null)
            {
                throw new LegacySceneDataException("s8001 sector rows cannot contain null.");
            }

            S5001SceneMutationBuilder.RequireDefined(row.Sector, nameof(row.Sector));
            S5001SceneMutationBuilder.RequireDefined(row.Signal, nameof(row.Signal));
            if (!seen.Add(row.Sector))
            {
                throw new LegacySceneDataException("s8001 sector rows must be unique.");
            }

            if (!BelongsToMarket(row.Sector, data.Market))
            {
                throw new LegacySceneDataException("s8001 sector does not belong to the selected closed market.");
            }

            if (row.Magnitude < 0)
            {
                throw new LegacySceneDataException("s8001 sector magnitude cannot be negative.");
            }

            var objectPrefix = SectorObjectName(row.Sector);
            var value = row.Magnitude.ToString(CultureInfo.InvariantCulture);
            mutations.Add(new PlayoutSetValue(
                objectPrefix + "_등락",
                row.Signal == S8001SectorSignal.Negative ? "-" + value : value));

            var color = ColorFor(row.Signal, row.Magnitude);
            if (color is { } rgb)
            {
                mutations.Add(new PlayoutSetFaceColor(
                    objectPrefix + "_큐브",
                    rgb.Red,
                    rgb.Green,
                    rgb.Blue));
            }
        }

        return mutations;
    }

    private static bool BelongsToMarket(S8001Sector sector, S8001Market market) => market switch
    {
        S8001Market.Kospi => sector is
            S8001Sector.Communications or S8001Sector.Finance or S8001Sector.Construction or
            S8001Sector.Machinery or S8001Sector.Distribution or S8001Sector.PaperAndWood or
            S8001Sector.TransportationEquipment or S8001Sector.TransportationWarehouse or
            S8001Sector.FoodAndBeverage or S8001Sector.SteelAndMetal or
            S8001Sector.MedicalPrecision or S8001Sector.TextilesAndApparel or
            S8001Sector.ElectricalAndElectronics or S8001Sector.Chemicals or
            S8001Sector.Pharmaceuticals,
        S8001Market.Kosdaq => sector is
            S8001Sector.MachineryAndEquipment or S8001Sector.ItComponents or
            S8001Sector.Transportation or S8001Sector.PaperAndWood or
            S8001Sector.Semiconductor or S8001Sector.Internet,
        _ => false
    };

    private static string SectorObjectName(S8001Sector sector) => sector switch
    {
        S8001Sector.Communications => "통신",
        S8001Sector.Finance => "금융",
        S8001Sector.Construction => "건설",
        S8001Sector.Machinery => "기계",
        S8001Sector.Distribution => "유통",
        S8001Sector.PaperAndWood => "종이목재",
        S8001Sector.TransportationEquipment => "운수장비",
        S8001Sector.TransportationWarehouse => "운수창고",
        S8001Sector.FoodAndBeverage => "음식료",
        S8001Sector.SteelAndMetal => "철강금속",
        S8001Sector.MedicalPrecision => "의료정밀",
        S8001Sector.TextilesAndApparel => "섬유의복",
        S8001Sector.ElectricalAndElectronics => "전기전자",
        S8001Sector.Chemicals => "화학",
        S8001Sector.Pharmaceuticals => "의약품",
        S8001Sector.MachineryAndEquipment => "기계장비",
        S8001Sector.ItComponents => "IT부품",
        S8001Sector.Transportation => "운송",
        S8001Sector.Semiconductor => "반도체",
        S8001Sector.Internet => "인터넷",
        _ => throw new LegacySceneDataException("Unsupported s8001 sector target.")
    };

    private static (int Red, int Green, int Blue)? ColorFor(
        S8001SectorSignal signal,
        decimal magnitude)
    {
        if (signal == S8001SectorSignal.Flat)
        {
            return null;
        }

        if (signal == S8001SectorSignal.Positive)
        {
            return magnitude switch
            {
                < 1m => (206, 61, 59),
                < 3m => (181, 28, 39),
                < 5m => (154, 28, 39),
                < 10m => (131, 28, 39),
                _ => (174, 0, 0)
            };
        }

        return magnitude switch
        {
            < 1m => (66, 128, 185),
            < 3m => (52, 117, 157),
            < 5m => (52, 78, 126),
            < 10m => (42, 61, 124),
            _ => (42, 47, 124)
        };
    }
}

/// <summary>
/// Exact ordered form of MainForm.setObjectStockChange for the parameterized scenes.
/// Limit-up intentionally writes its pattern before unit visibility, unlike the newer
/// shared helper, because that is the order in the original MainForm.
/// </summary>
internal static class ExactLegacyPriceDirectionMutations
{
    public static IReadOnlyList<PlayoutMutation> Build(
        ScenePriceDirection rawDirection,
        int legacyVariantIndex)
    {
        if (legacyVariantIndex is < 0 or > 3)
        {
            throw new ArgumentOutOfRangeException(nameof(legacyVariantIndex));
        }

        var direction = S5001SceneMutationBuilder.RequireDirection(rawDirection);
        var pattern = direction switch
        {
            ScenePriceDirection.LimitUp or ScenePriceDirection.Up => @"Images\그림_빨강.png",
            ScenePriceDirection.Flat => @"Images\그림_검정.png",
            ScenePriceDirection.Down or ScenePriceDirection.LimitDown => @"Images\그림_파랑.png",
            _ => throw new LegacySceneDataException("A known scene visual variant is required.")
        };
        var mutations = LegacyPriceDirectionMutations.Build(direction, legacyVariantIndex)
            .Select(mutation => mutation is PlayoutSetAssetValue asset
                ? new PlayoutSetAssetValue(asset.ObjectName, pattern)
                : mutation)
            .ToList();

        // MainForm writes the limit-up pattern before unit1/2/3. The shared
        // helper writes units first, so move only this one mutation for cnt 1/2/3.
        if (direction == ScenePriceDirection.LimitUp && legacyVariantIndex != 0)
        {
            var assetIndex = mutations.FindIndex(static mutation => mutation is PlayoutSetAssetValue);
            var asset = mutations[assetIndex];
            mutations.RemoveAt(assetIndex);
            mutations.Insert(assetIndex - 3, asset);
        }

        return mutations;
    }
}
