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

namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests;

public sealed class LegacyOperatorControllerTests
{
    [Fact]
    public async Task IndustryTabLoadsSelectorRotatesPairAndAddsTrustedActions()
    {
        var industries = Enumerable.Range(1, 11)
            .Select(index => new IndustrySelection(
                IndustryMarket.Kospi,
                $"업종{index}",
                index.ToString("D3", System.Globalization.CultureInfo.InvariantCulture)))
            .ToArray();
        var workflow = new LegacyIndustrySelectionWorkflow(
            new StaticIndustryService(industries));
        var controller = new LegacyOperatorController(
            Lookup(),
            industryWorkflow: workflow);

        var loaded = await controller.SelectTabAsync(LegacyOperatorTabId.KospiIndustry);
        Assert.Equal(11, loaded.Industry?.Industries.Count);

        controller.SelectIndustry(0, doubleClick: true);
        var pair = controller.SelectIndustry(1, doubleClick: true);
        Assert.Equal("업종2", pair.Industry?.FirstIndustry?.IndustryName);
        Assert.Equal("업종1", pair.Industry?.SecondIndustry?.IndustryName);

        var twoColumn = controller.ActivateIndustryAction("two-column");
        var pairRow = Assert.Single(twoColumn.Playlist);
        Assert.Equal("8018", pairRow.ToPlayoutEntry().CutCode);
        Assert.Equal("업종2-업종1", pairRow.StockName);

        var paged = controller.ActivateIndustryAction("five-row");
        Assert.Equal("1/3", paged.Playlist[1].PageText);
        Assert.Equal("5074", paged.Playlist[1].ToPlayoutEntry().CutCode);
    }

    [Fact]
    public async Task IndustryComparisonEditChangesOnlyTwoColumnSubjectAndNeverMutatesPlaylistByItself()
    {
        var industries = new[]
        {
            new IndustrySelection(IndustryMarket.Kospi, "전기전자", "013")
        };
        var controller = new LegacyOperatorController(
            Lookup(),
            industryWorkflow: new LegacyIndustrySelectionWorkflow(
                new StaticIndustryService(industries)));
        await controller.SelectTabAsync(LegacyOperatorTabId.KospiIndustry);
        controller.SelectIndustry(0, doubleClick: false);

        var firstEdit = controller.SetIndustryComparisonText(
            LegacyIndustryComparisonSlot.First,
            "직접 첫째");
        var secondEdit = controller.SetIndustryComparisonText(
            LegacyIndustryComparisonSlot.Second,
            "직접 둘째");

        Assert.Empty(firstEdit.Playlist);
        Assert.Empty(secondEdit.Playlist);
        Assert.Equal("전기전자",
            controller.ActivateIndustryAction("one-column").Playlist[0].StockName);

        var pair = controller.ActivateIndustryAction("two-column");
        var pairRow = pair.Playlist[^1];
        Assert.Equal("직접 첫째-직접 둘째", pairRow.StockName);
        Assert.Equal("직접 첫째-직접 둘째", pairRow.PlayoutSelection?.Subject);
    }

    [Fact]
    public async Task IndustryRootDoubleClickMatchesLegacySilentNoOpWhileChildStillAdds()
    {
        var industries = Enumerable.Range(1, 3)
            .Select(index => new IndustrySelection(
                IndustryMarket.Kospi,
                $"업종{index}",
                index.ToString("D3", System.Globalization.CultureInfo.InvariantCulture)))
            .ToArray();
        var controller = new LegacyOperatorController(
            Lookup(),
            industryWorkflow: new LegacyIndustrySelectionWorkflow(
                new StaticIndustryService(industries)));
        await controller.SelectTabAsync(LegacyOperatorTabId.KospiIndustry);
        controller.SelectIndustry(0, doubleClick: true);
        controller.SelectIndustry(1, doubleClick: true);

        var rootDoubleClick = controller.ActivateIndustrySection(0);

        Assert.Empty(rootDoubleClick.Playlist);
        Assert.Null(rootDoubleClick.Dialog);

        var childDoubleClick = controller.ActivateIndustryAction("one-column");
        Assert.Equal("5001", Assert.Single(childDoubleClick.Playlist).SceneAlias);
    }

    [Fact]
    public async Task ThemeTabSearchPreviewSortAndActionRemainCSharpAuthoritative()
    {
        var identity = new ThemeSelectionIdentity(
            ThemeMarket.Krx,
            ThemeSession.NotApplicable,
            "00000001",
            "AI 테마");
        var service = new StaticThemeService(
            identity,
            Enumerable.Range(0, 13)
                .Select(index => new ThemeItemPreview(
                    index,
                    $"P{index + 1:000000}",
                    $"종목{index + 1}"))
                .ToArray());
        var controller = new LegacyOperatorController(
            Lookup(),
            themeWorkflow: new LegacyThemeWorkflow(service));
        await controller.SelectTabAsync(LegacyOperatorTabId.Theme);

        var searched = await controller.SearchThemesAsync("AI", ThemeSession.PreMarket);
        var resultId = Assert.Single(searched.Theme!.SearchResults).ResultId;
        var selected = await controller.SelectThemeAsync(resultId);
        Assert.Equal(13, selected.Theme?.PreviewItems.Count);

        controller.SetThemeSort("GAIN_DESC");
        var added = controller.ActivateThemeAction("five-row-current");
        var row = Assert.Single(added.Playlist);
        Assert.Equal("5074", row.ToPlayoutEntry().CutCode);
        Assert.Equal("1/3", row.PageText);
        Assert.Equal("테마", row.MarketText);
        Assert.Equal("AI 테마", row.StockName);
        Assert.Equal("5단 표그래프", row.GraphicType);
        Assert.Equal("테마-현재가(상승률순)", row.Subtype);
        Assert.Equal("테마-현재가(상승률순)", row.RawCutLabel);
        Assert.Equal(
            new MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection(
                "PAGED_THEME",
                "AI 테마",
                "GAIN_DESC",
                "CURRENT",
                "00000001",
                "AI 테마"),
            Assert.IsType<MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection>(
                row.ToPlayoutEntry().Selection));
    }

    [Fact]
    public async Task ThemeSearch_ReportsTheFiniteFiveThousandRowTruncationToTheOperator()
    {
        var controller = new LegacyOperatorController(
            Lookup(),
            themeWorkflow: new LegacyThemeWorkflow(new TruncatedThemeService()));
        await controller.SelectTabAsync(LegacyOperatorTabId.Theme);

        var searched = await controller.SearchThemesAsync("Theme");

        Assert.Equal(5_000, searched.Theme!.SearchResults.Count);
        Assert.True(searched.Theme.SearchIsTruncated);
        Assert.Equal(LegacyOperatorStatusKind.Warning, searched.StatusKind);
        Assert.Contains("5,000", searched.StatusMessage, StringComparison.Ordinal);
        Assert.Contains("검색어", searched.StatusMessage, StringComparison.Ordinal);
    }

    [Fact]
    public async Task NxtExpectedThemeAction_RemainsClickableAndShowsExactWarningWithoutAdding()
    {
        var identity = new ThemeSelectionIdentity(
            ThemeMarket.Nxt,
            ThemeSession.PreMarket,
            "00000002",
            "AI 테마");
        var service = new StaticThemeService(
            identity,
            [new ThemeItemPreview(0, "X005930", "삼성전자")]);
        var controller = new LegacyOperatorController(
            Lookup(),
            timeProvider: new FixedTimeProvider(new DateTimeOffset(
                2026, 7, 18, 10, 0, 0, TimeSpan.FromHours(9))),
            themeWorkflow: new LegacyThemeWorkflow(
                service,
                () => new DateTimeOffset(2026, 7, 18, 10, 0, 0, TimeSpan.FromHours(9))));
        await controller.SelectTabAsync(LegacyOperatorTabId.Theme);
        var searched = await controller.SearchThemesAsync("AI", ThemeSession.AfterMarket);
        Assert.Equal(
            [ThemeSession.PreMarket, ThemeSession.PreMarket],
            service.SearchSessions);
        var resultId = Assert.Single(searched.Theme!.SearchResults).ResultId;
        var selected = await controller.SelectThemeAsync(resultId);
        Assert.True(Assert.Single(
            selected.Theme!.ActionStates,
            state => state.ActionId == "five-row-expected").IsAvailable);

        var warned = controller.ActivateThemeAction("five-row-expected");

        Assert.Empty(warned.Playlist);
        Assert.Equal("NXT는 데이터가 없습니다.", warned.Dialog?.Message);
    }

    [Theory]
    [InlineData(0, ThemeSession.PreMarket)]
    [InlineData(12, ThemeSession.PreMarket)]
    [InlineData(13, ThemeSession.AfterMarket)]
    [InlineData(23, ThemeSession.AfterMarket)]
    public async Task ThemeActivationAndSearch_DeriveNxtSessionFromControllerLocalTime(
        int localHour,
        ThemeSession expectedSession)
    {
        var identity = new ThemeSelectionIdentity(
            ThemeMarket.Nxt,
            ThemeSession.PreMarket,
            "00000002",
            "AI 테마");
        var service = new StaticThemeService(
            identity,
            [new ThemeItemPreview(0, "X005930", "삼성전자")]);
        var controller = new LegacyOperatorController(
            Lookup(),
            timeProvider: new FixedTimeProvider(new DateTimeOffset(
                2026, 7, 18, localHour, 0, 0, TimeSpan.FromHours(9))),
            themeWorkflow: new LegacyThemeWorkflow(service));

        await controller.SelectTabAsync(LegacyOperatorTabId.Theme);
        await controller.SearchThemesAsync("AI");

        Assert.Equal([expectedSession, expectedSession], service.SearchSessions);
    }

    [Fact]
    public async Task NxtCurrentThemeAction_ProjectsOriginalVisibleColumnsAndKeepsTypedSelection()
    {
        var identity = new ThemeSelectionIdentity(
            ThemeMarket.Nxt,
            ThemeSession.PreMarket,
            "00000002",
            "AI 테마");
        var controller = new LegacyOperatorController(
            Lookup(),
            timeProvider: new FixedTimeProvider(new DateTimeOffset(
                2026, 7, 18, 10, 0, 0, TimeSpan.FromHours(9))),
            themeWorkflow: new LegacyThemeWorkflow(
                new StaticThemeService(
                    identity,
                    [new ThemeItemPreview(0, "X005930", "삼성전자")]),
                () => new DateTimeOffset(2026, 7, 18, 10, 0, 0, TimeSpan.FromHours(9))));
        var loaded = await controller.SelectTabAsync(LegacyOperatorTabId.Theme);
        await controller.SelectThemeAsync(Assert.Single(loaded.Theme!.SearchResults).ResultId);

        var added = controller.ActivateThemeAction("six-row-current");

        var row = Assert.Single(added.Playlist);
        Assert.Equal("테마_NXT", row.MarketText);
        Assert.Equal("AI 테마(NXT)", row.StockName);
        Assert.Equal("6종목 현재가", row.GraphicType);
        Assert.Equal("테마-현재가(입력순)", row.Subtype);
        Assert.Equal("테마-현재가(입력순)", row.RawCutLabel);
        Assert.Equal(
            new MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection(
                "PAGED_NXT_THEME",
                "AI 테마(NXT)",
                "PRE_MARKET",
                "INPUT_ORDER",
                "00000002",
                "AI 테마"),
            Assert.IsType<MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection>(
                row.ToPlayoutEntry().Selection));
    }

    [Fact]
    public async Task Theme_result_double_click_awaits_preview_and_adds_fixed_current_action_once()
    {
        var identity = new ThemeSelectionIdentity(
            ThemeMarket.Krx,
            ThemeSession.NotApplicable,
            "00000001",
            "AI theme");
        var service = new StaticThemeService(
            identity,
            Enumerable.Range(0, 7)
                .Select(index => new ThemeItemPreview(
                    index,
                    $"P{index + 1:000000}",
                    $"Stock {index + 1}"))
                .ToArray());
        var controller = new LegacyOperatorController(
            Lookup(),
            themeWorkflow: new LegacyThemeWorkflow(service));
        var loaded = await controller.SelectTabAsync(LegacyOperatorTabId.Theme);
        var resultId = Assert.Single(loaded.Theme!.SearchResults).ResultId;

        var activated = await controller.ActivateThemeResultAsync(resultId);

        Assert.Equal(1, service.PreviewCalls);
        Assert.Equal(resultId, activated.Theme?.SelectedResultId);
        var row = Assert.Single(activated.Playlist);
        Assert.Equal("5074", row.ToPlayoutEntry().CutCode);
        Assert.Equal("1/2", row.PageText);
        Assert.Equal("INPUT_ORDER", Assert.IsType<
            MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection>(
                row.ToPlayoutEntry().Selection).GraphicType);
        Assert.Equal("activate-theme-result", activated.CommandResult?.Command);
        Assert.True(activated.CommandResult?.Succeeded);
    }

    [Fact]
    public async Task ExpertTabSearchPreviewAndPagedActionAddOriginalRowWithNullBuyAmount()
    {
        var controller = new LegacyOperatorController(
            Lookup(),
            expertWorkflow: new LegacyExpertWorkflowController(new StaticExpertService()));
        await controller.SelectTabAsync(LegacyOperatorTabId.Expert);

        var search = await controller.SearchExpertsAsync("전문가");
        var selectionId = Assert.Single(search.Expert!.Search.Results).SelectionId;
        await controller.SelectExpertAsync(selectionId);
        var added = controller.ActivateExpertAction(
            LegacyExpertWorkflowController.ActionId);

        var row = Assert.Single(added.Playlist);
        Assert.Equal("5074", row.ToPlayoutEntry().CutCode);
        Assert.Equal("1/2", row.PageText);
        Assert.Null(added.Expert!.Preview.Items[0].BuyAmount);
        Assert.Equal("전문가", row.MarketText);
    }

    [Fact]
    public async Task TradingHaltTabAddsCurrentAndCandleWithGlobalMovingAverages()
    {
        var controller = new LegacyOperatorController(
            Lookup(),
            tradingHaltWorkflow: new LegacyTradingHaltWorkflowController(
                new StaticTradingHaltService()));
        await controller.SelectTabAsync(LegacyOperatorTabId.TradingHalt);
        var search = await controller.SearchTradingHaltsAsync("");
        var selectionId = Assert.Single(search.TradingHalt!.Results).SelectionId;
        controller.SelectTradingHalt(selectionId);

        var current = controller.ActivateTradingHaltAction(
            LegacyTradingHaltWorkflowController.CurrentActionId);
        Assert.Equal("5001", Assert.Single(current.Playlist).ToPlayoutEntry().CutCode);

        controller.SetMovingAverages(ma5: true, ma20: false);
        var candle = controller.ActivateTradingHaltAction(
            LegacyTradingHaltWorkflowController.CandleActionId);
        var candleRow = candle.Playlist[1];
        Assert.Equal("8051", candleRow.ToPlayoutEntry().CutCode);
        Assert.Equal(
            string.Empty,
            Assert.IsType<MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection>(
                candleRow.ToPlayoutEntry().Selection).GraphicType);
        Assert.Equal(
            "MA5",
            controller.CreatePlayoutRequest().Playlist[1].Selection!.GraphicType);
    }

    [Fact]
    public void Tabs_StartInOriginalOrderAndSwapByIdentity()
    {
        var controller = new LegacyOperatorController(Lookup());

        Assert.Equal(
            ["해외", "환율", "지수", "코스피", "코스닥", "비교", "테마", "해외시장", "전문가", "거래정지"],
            controller.Current.Tabs!.Select(tab => tab.Label));
        Assert.Equal(
            LegacyOperatorTabId.Overseas,
            Assert.Single(controller.Current.Tabs!, tab => tab.IsActive).Id);

        var selected = controller.SelectTab(LegacyOperatorTabId.Theme);
        Assert.Equal(
            LegacyOperatorTabId.Theme,
            Assert.Single(selected.Tabs!, tab => tab.IsActive).Id);

        var swapped = controller.SwapTabs(
            LegacyOperatorTabId.Theme,
            LegacyOperatorTabId.Exchange);
        Assert.Equal(LegacyOperatorTabId.Theme, swapped.Tabs![1].Id);
        Assert.Equal(LegacyOperatorTabId.Exchange, swapped.Tabs[6].Id);
        Assert.Equal(
            LegacyOperatorTabId.Theme,
            Assert.Single(swapped.Tabs, tab => tab.IsActive).Id);
    }

    [Fact]
    public async Task TabHoverSwapUsesExpectedPositionsSoDuplicateDeliveryCannotSwapBack()
    {
        var controller = new LegacyOperatorController(Lookup());

        var first = await controller.SwapTabsAtExpectedPositionsAsync(
            LegacyOperatorTabId.Theme,
            LegacyOperatorTabId.Exchange,
            expectedSourceIndex: 6,
            expectedTargetIndex: 1);
        Assert.Equal(LegacyOperatorTabId.Theme, first.Tabs![1].Id);
        Assert.Equal(LegacyOperatorTabId.Exchange, first.Tabs[6].Id);

        var revision = first.Revision;
        var duplicate = await controller.SwapTabsAtExpectedPositionsAsync(
            LegacyOperatorTabId.Theme,
            LegacyOperatorTabId.Exchange,
            expectedSourceIndex: 6,
            expectedTargetIndex: 1);
        Assert.Equal(revision, duplicate.Revision);
        Assert.Equal(LegacyOperatorTabId.Theme, duplicate.Tabs![1].Id);
        Assert.Equal(LegacyOperatorTabId.Exchange, duplicate.Tabs[6].Id);
    }

    [Fact]
    public async Task PlaylistKeyboardCommandsUseActiveRowAndBoundaries()
    {
        var controller = await ControllerWithPlaylistAsync(0, 1, 2);
        var rows = controller.Current.Playlist;
        controller.SelectPlaylistRow(rows[0].RowId, control: false, shift: false);
        controller.SelectPlaylistRow(rows[1].RowId, control: true, shift: false);

        var last = controller.SelectPlaylistBoundary(last: true);
        Assert.True(last.Playlist[0].IsEnabled);
        Assert.True(last.Playlist[2].IsActive);
        Assert.Equal(
            rows.Take(2).Select(row => row.RowId),
            last.Playlist.Where(row => row.IsSelected).Select(row => row.RowId));

        var toggled = controller.ToggleActivePlaylistRowEnabled();
        Assert.False(toggled.Playlist[2].IsEnabled);

        var first = controller.SelectPlaylistBoundary(last: false);
        Assert.True(first.Playlist[0].IsActive);
        Assert.Equal(
            rows.Take(2).Select(row => row.RowId),
            first.Playlist.Where(row => row.IsSelected).Select(row => row.RowId));
    }

    [Fact]
    public async Task FixedCatalogLeafAndSectionAreMaterializedOnlyByCSharp()
    {
        var controller = new LegacyOperatorController(Lookup());
        var first = LegacyFixedActionCatalog.Default.GetActions(LegacyFixedMarket.Overseas)[0];

        var leaf = controller.ActivateFixedAction(first.Id);

        var row = Assert.Single(leaf.Playlist);
        var expected = OriginalFixedVisibleSelection(first);
        Assert.Equal(expected.GroupCode, row.MarketText);
        Assert.Equal(expected.Subject, row.StockName);
        Assert.Equal(expected.GraphicType, row.GraphicType);
        Assert.Equal(expected.Subtype, row.Subtype);
        Assert.Equal(expected.DataCode, row.StockCode);
        Assert.Equal(first.Code, row.ToPlayoutEntry().CutCode);
        Assert.Equal(
            first.Selection.Subject,
            Assert.IsType<MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection>(
                row.ToPlayoutEntry().Selection).Subject);

        var firstSection = LegacyFixedActionCatalog.Default
            .GetSections(LegacyFixedMarket.Overseas)[0];
        var section = await controller.ActivateFixedSection(0);
        Assert.Equal(1 + firstSection.Actions.Count, section.Playlist.Count);
        Assert.Equal(
            firstSection.Actions.Select(action =>
                OriginalFixedVisibleSelection(action).Subject),
            section.Playlist.Skip(1).Select(item => item.StockName));
    }

    [Fact]
    public async Task EveryAvailableFixedActionUsesOriginalVisibleProjectionCanonicalPlayoutAndInitialPageN()
    {
        var catalog = LegacyFixedActionCatalog.Default;
        var localNow = new DateTimeOffset(2026, 7, 22, 10, 0, 0, TimeSpan.FromHours(9));
        var pagePlans = new RecordingPagePlanProvider();
        var availableActions = catalog.Actions
            .Where(candidate => candidate.Available)
            .ToArray();
        Assert.Equal(322, availableActions.Length);
        var visibleIdentities = new HashSet<
            (string GroupCode, string Subject, string GraphicType, string Subtype, string DataCode)>();

        foreach (var action in availableActions)
        {
            var controller = new LegacyOperatorController(
                Lookup(),
                timeProvider: new FixedTimeProvider(localNow),
                fixedPagePlanProvider: pagePlans);
            controller.SelectTab(action.Market switch
            {
                LegacyFixedMarket.Overseas => LegacyOperatorTabId.Overseas,
                LegacyFixedMarket.Exchange => LegacyOperatorTabId.Exchange,
                LegacyFixedMarket.Index => LegacyOperatorTabId.Index,
                _ => throw new ArgumentOutOfRangeException()
            });

            var state = await controller.ActivateFixedActionAsync(action.Id);

            var row = Assert.Single(state.Playlist);
            var expectedVisible = OriginalFixedVisibleSelection(action);
            Assert.Equal(expectedVisible.GroupCode, row.MarketText);
            Assert.Equal(expectedVisible.Subject, row.StockName);
            Assert.Equal(expectedVisible.GraphicType, row.GraphicType);
            Assert.Equal(expectedVisible.Subtype, row.Subtype);
            Assert.Equal(expectedVisible.DataCode, row.StockCode);
            Assert.Equal(action.Code switch
            {
                "5074" => "1/20",
                "5077" or "5088" => "1/3",
                _ => "1/1"
            }, row.PageText);
            Assert.Equal(action.Label, row.RawCutLabel);
            Assert.Equal(action.Code, row.SceneAlias);
            Assert.True(
                visibleIdentities.Add((
                    row.MarketText,
                    row.StockName,
                    row.GraphicType,
                    row.Subtype,
                    row.StockCode)),
                $"Duplicate original fixed selection: {action.Id}");

            var expectedPlayout = catalog.Materialize(action.Id, localNow).Selection;
            var actualPlayout = Assert.IsType<
                MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection>(
                    row.PlayoutSelection);
            Assert.Equal(expectedPlayout.GroupCode, actualPlayout.GroupCode);
            Assert.Equal(expectedPlayout.Subject, actualPlayout.Subject);
            Assert.Equal(expectedPlayout.GraphicType, actualPlayout.GraphicType);
            Assert.Equal(expectedPlayout.Subtype, actualPlayout.Subtype);
            Assert.Equal(expectedPlayout.DataCode, actualPlayout.DataCode);
        }

        Assert.Equal(availableActions.Length, visibleIdentities.Count);
        Assert.Equal(78, pagePlans.Entries.Count);
        Assert.Equal(
            78,
            pagePlans.Entries.Select(entry => entry.EntryId).Distinct(StringComparer.Ordinal).Count());
    }

    [Fact]
    public async Task FixedPagedActionPreservesOriginalOneSlashZeroForEmptyOrFailedPageRead()
    {
        var action = LegacyFixedActionCatalog.Default.Actions
            .First(candidate => candidate.Available && candidate.Code == "5074");

        var emptyPlans = new RecordingPagePlanProvider { FiveRowItemCount = 0 };
        var emptyController = new LegacyOperatorController(
            Lookup(),
            fixedPagePlanProvider: emptyPlans);
        emptyController.SelectTab(LegacyOperatorTabId.Index);
        var empty = await emptyController.ActivateFixedActionAsync(action.Id);
        Assert.Equal("1/0", Assert.Single(empty.Playlist).PageText);
        Assert.Equal(LegacyOperatorStatusKind.Information, empty.StatusKind);

        var failedPlans = new RecordingPagePlanProvider
        {
            Failure = new IOException("page read failed")
        };
        var failedController = new LegacyOperatorController(
            Lookup(),
            fixedPagePlanProvider: failedPlans);
        failedController.SelectTab(LegacyOperatorTabId.Index);
        var failed = await failedController.ActivateFixedActionAsync(action.Id);
        Assert.Equal("1/0", Assert.Single(failed.Playlist).PageText);
        Assert.Equal(LegacyOperatorStatusKind.Warning, failed.StatusKind);
        Assert.Null(failed.Dialog);
    }

    [Fact]
    public async Task FixedPagedActionCancellationDoesNotAppendAPlaylistRow()
    {
        var action = LegacyFixedActionCatalog.Default.Actions
            .First(candidate => candidate.Available && candidate.Code == "5077");
        var controller = new LegacyOperatorController(
            Lookup(),
            fixedPagePlanProvider: new RecordingPagePlanProvider());
        controller.SelectTab(LegacyOperatorTabId.Index);
        using var cancellation = new CancellationTokenSource();
        cancellation.Cancel();

        await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
            controller.ActivateFixedActionAsync(action.Id, cancellation.Token));

        Assert.Empty(controller.Current.Playlist);
    }

    [Fact]
    public void Fixed8035UsesOriginalIndexFieldsWithoutChangingCanonicalSelection()
    {
        var controller = new LegacyOperatorController(Lookup());
        controller.SelectTab(LegacyOperatorTabId.Index);

        var state = controller.ActivateFixedAction("fixed-136");

        var row = Assert.Single(state.Playlist);
        Assert.Equal("코스피", row.MarketText);
        Assert.Equal("지수", row.StockName);
        Assert.Equal("캔들그래프", row.GraphicType);
        Assert.Equal("일봉", row.Subtype);
        Assert.Equal(string.Empty, row.StockCode);
        Assert.Equal("8035", row.SceneAlias);
        var selection = Assert.IsType<
            MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection>(
                row.PlayoutSelection);
        Assert.Equal("KOSPI_INDEX", selection.GroupCode);
        Assert.Equal("INDEX", selection.Subject);
        Assert.Equal(string.Empty, selection.GraphicType);
        Assert.Equal("PRICE", selection.Subtype);
        Assert.Equal(string.Empty, selection.DataCode);
    }

    [Fact]
    public void FixedCatalogRejectsCrossTabAndUnavailableActionsAtomically()
    {
        var controller = new LegacyOperatorController(Lookup());
        var exchange = LegacyFixedActionCatalog.Default
            .GetActions(LegacyFixedMarket.Exchange)[0];

        var crossTab = controller.ActivateFixedAction(exchange.Id);
        Assert.Empty(crossTab.Playlist);
        Assert.Equal(LegacyOperatorStatusKind.Error, crossTab.StatusKind);

        var unavailable = LegacyFixedActionCatalog.Default.Actions
            .First(action => !action.Available);
        controller.SelectTab(unavailable.Market switch
        {
            LegacyFixedMarket.Overseas => LegacyOperatorTabId.Overseas,
            LegacyFixedMarket.Exchange => LegacyOperatorTabId.Exchange,
            LegacyFixedMarket.Index => LegacyOperatorTabId.Index,
            _ => throw new InvalidOperationException()
        });
        var blocked = controller.ActivateFixedAction(unavailable.Id);
        Assert.Empty(blocked.Playlist);
        Assert.Equal(unavailable.Prerequisite, blocked.Dialog?.Message);
    }

    [Fact]
    public void FixedCandleIdentityStaysStableWhileF8SamplesCurrentMovingAverages()
    {
        var controller = new LegacyOperatorController(Lookup());
        var candle = LegacyFixedActionCatalog.Default
            .GetActions(LegacyFixedMarket.Index)
            .First(action => action.SupportsMovingAverages);
        controller.SelectTab(LegacyOperatorTabId.Index);
        controller.SetMovingAverages(ma5: false, ma20: true);

        var state = controller.ActivateFixedAction(candle.Id);

        Assert.Equal(
            string.Empty,
            Assert.IsType<MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection>(
                Assert.Single(state.Playlist).ToPlayoutEntry().Selection).GraphicType);
        Assert.Equal(
            "MA20",
            Assert.Single(controller.CreatePlayoutRequest().Playlist).Selection!.GraphicType);
        Assert.False(state.MovingAverages.Ma5);
        Assert.True(state.MovingAverages.Ma20);

        controller.SetMovingAverages(ma5: true, ma20: false);

        Assert.Equal(
            "MA5",
            Assert.Single(controller.CreatePlayoutRequest().Playlist).Selection!.GraphicType);
        Assert.Equal(
            string.Empty,
            Assert.Single(controller.Current.Playlist).PlayoutSelection!.GraphicType);
    }

    [Fact]
    public async Task EmptyButtonIsNoOpButEmptyEnterSearches()
    {
        var lookup = Lookup();
        var controller = new LegacyOperatorController(lookup);

        await controller.SearchStocksAsync(string.Empty, LegacySearchTrigger.Button);
        Assert.Equal(0, lookup.CallCount);

        await controller.SearchStocksAsync(string.Empty, LegacySearchTrigger.Enter);
        Assert.Equal(1, lookup.CallCount);
    }

    [Fact]
    public async Task ExactPointerDoubleClick_AppendsOneLegacyRow()
    {
        var controller = new LegacyOperatorController(Lookup());
        await controller.SearchStocksAsync("삼성", LegacySearchTrigger.Button);
        controller.SelectStock(0);

        controller.CutPointerDown(1, false, false, 20, 45, 1_000);
        var state = controller.CutPointerDown(1, false, false, 20, 45, 1_400);

        var row = Assert.Single(state.Playlist);
        Assert.Equal("코스피", row.MarketText);
        Assert.Equal("삼성전자", row.StockName);
        Assert.Equal("1열판기본", row.GraphicType);
        Assert.Equal("현재가", row.Subtype);
        Assert.Equal("005930", row.StockCode);
        Assert.Equal("5001", row.ToPlayoutEntry().CutCode);
        Assert.True(row.IsActive);
        Assert.False(row.IsSelected);
        Assert.Equal(0, controller.CreatePlayoutRequest().SelectedIndexZeroBased);
    }

    [Fact]
    public async Task ExactPointerDoubleClick_AllowsZeroBasedWebTimestamp()
    {
        var controller = new LegacyOperatorController(Lookup());
        await controller.SearchStocksAsync("삼성", LegacySearchTrigger.Button);
        controller.SelectStock(0);

        controller.CutPointerDown(1, false, false, 20, 45, 0);
        var state = controller.CutPointerDown(1, false, false, 20, 45, 499);

        Assert.Single(state.Playlist);
    }

    [Fact]
    public async Task PointerSelectionWithoutAppendPermission_NeverAppendsAndResetsDoubleClick()
    {
        var controller = new LegacyOperatorController(Lookup());
        await controller.SearchStocksAsync("삼성", LegacySearchTrigger.Button);
        controller.SelectStock(0);

        controller.CutPointerDown(
            1, false, false, 20, 45, 1_000, allowAppend: true);
        var onAirSelection = controller.CutPointerDown(
            1, false, false, 20, 45, 1_200, allowAppend: false);

        Assert.Empty(onAirSelection.Playlist);
        Assert.Equal(
            1,
            Assert.Single(onAirSelection.CutRows, row => row.IsSelected).PhysicalIndex);
        Assert.Equal(
            1,
            Assert.Single(onAirSelection.CutRows, row => row.IsFocused).PhysicalIndex);

        var firstIdleClick = controller.CutPointerDown(
            1, false, false, 20, 45, 1_400, allowAppend: true);
        Assert.Empty(firstIdleClick.Playlist);

        var secondIdleClick = controller.CutPointerDown(
            1, false, false, 20, 45, 1_600, allowAppend: true);
        Assert.Single(secondIdleClick.Playlist);
    }

    [Fact]
    public async Task DifferentPointerPosition_DoesNotCountAsLegacyDoubleClick()
    {
        var controller = new LegacyOperatorController(Lookup());
        await controller.SearchStocksAsync("삼성", LegacySearchTrigger.Button);
        controller.SelectStock(0);

        controller.CutPointerDown(1, false, false, 20, 45, 1_000);
        var state = controller.CutPointerDown(1, false, false, 21, 45, 1_200);

        Assert.Empty(state.Playlist);
    }

    [Fact]
    public async Task NativeSelectionAndLegacyDoubleClickRowsStaySeparateAtShiftEdges()
    {
        var controller = new LegacyOperatorController(Lookup());
        await controller.SearchStocksAsync("삼성", LegacySearchTrigger.Button);
        controller.SelectStock(0);

        var firstShift = controller.CutPointerDown(2, false, true, 20, 69, 1_000);
        Assert.Equal(
            [0, 1, 2],
            firstShift.CutRows.Where(row => row.IsSelected).Select(row => row.PhysicalIndex));

        var noDoubleClick = controller.CutPointerDown(2, false, false, 20, 69, 1_400);
        Assert.Empty(noDoubleClick.Playlist);

        var sameRowShift = controller.CutPointerDown(2, false, true, 21, 69, 2_000);
        Assert.Equal(
            [2],
            sameRowShift.CutRows.Where(row => row.IsSelected).Select(row => row.PhysicalIndex));

        var stillNoDoubleClick = controller.CutPointerDown(2, false, false, 21, 69, 2_400);
        Assert.Empty(stillNoDoubleClick.Playlist);
    }

    [Fact]
    public async Task ControlAndShiftSelection_DropsInPhysicalOrderAndKeepsDuplicates()
    {
        var controller = new LegacyOperatorController(Lookup());
        await controller.SearchStocksAsync("삼성", LegacySearchTrigger.Button);
        controller.SelectStock(0);
        controller.CutPointerDown(0, false, false, 1, 1, 1_000);
        controller.CutPointerDown(2, true, false, 1, 47, 2_000);
        controller.CutPointerDown(1, true, true, 1, 24, 3_000);

        var first = controller.DropSelectedCutsOnPlaylist();
        var second = controller.DropSelectedCutsOnPlaylist();

        Assert.Equal(3, first.Playlist.Count);
        Assert.Equal(6, second.Playlist.Count);
        Assert.Equal(
            ["예상체결가", "현재가", "시간외단일가"],
            first.Playlist.Select(row => row.Subtype));
    }

    [Fact]
    public async Task SameRowShiftStillDropsTheNativeListViewSelection()
    {
        var controller = new LegacyOperatorController(Lookup());
        await controller.SearchStocksAsync("삼성", LegacySearchTrigger.Button);
        controller.SelectStock(0);
        controller.CutPointerDown(1, false, false, 1, 24, 1_000);
        controller.CutPointerDown(1, false, true, 2, 24, 2_000);

        var state = controller.DropSelectedCutsOnPlaylist();

        Assert.Equal("현재가", Assert.Single(state.Playlist).Subtype);
    }

    [Fact]
    public async Task NxtOnlyAllowsCurrentAndShowsOriginalWarning()
    {
        var controller = new LegacyOperatorController(Lookup());
        await controller.SearchStocksAsync("넥스트", LegacySearchTrigger.Button);
        controller.SelectStock(1);
        controller.CutPointerDown(0, false, false, 1, 1, 1_000);
        var warning = controller.DropSelectedCutsOnPlaylist();

        Assert.Empty(warning.Playlist);
        Assert.Equal("NXT는 제공하지 않습니다.", warning.Dialog?.Message);

        controller.DismissDialog();
        controller.CutPointerDown(1, false, false, 1, 24, 2_000);
        var accepted = controller.DropSelectedCutsOnPlaylist();
        Assert.Equal("N5001", Assert.Single(accepted.Playlist).ToPlayoutEntry().CutCode);
    }

    [Fact]
    public async Task NxtMultiSelection_PausesAtEachOriginalModalBeforeContinuing()
    {
        var controller = new LegacyOperatorController(Lookup());
        await controller.SearchStocksAsync("넥스트", LegacySearchTrigger.Button);
        controller.SelectStock(1);
        controller.CutPointerDown(0, false, false, 1, 1, 1_000);
        controller.CutPointerDown(1, true, false, 1, 24, 2_000);
        controller.CutPointerDown(2, true, false, 1, 47, 3_000);

        var firstWarning = controller.DropSelectedCutsOnPlaylist();
        Assert.Empty(firstWarning.Playlist);
        Assert.Equal("NXT는 제공하지 않습니다.", firstWarning.Dialog?.Message);

        var secondWarning = controller.DismissDialog();
        Assert.Equal("N5001", Assert.Single(secondWarning.Playlist).ToPlayoutEntry().CutCode);
        Assert.Equal("NXT는 제공하지 않습니다.", secondWarning.Dialog?.Message);

        var finished = controller.DismissDialog();
        Assert.Single(finished.Playlist);
        Assert.Null(finished.Dialog);
    }

    [Fact]
    public async Task NxtSeparatorStillShowsOriginalWarning()
    {
        var controller = new LegacyOperatorController(Lookup());
        await controller.SearchStocksAsync("넥스트", LegacySearchTrigger.Button);
        controller.SelectStock(1);
        controller.CutPointerDown(3, false, false, 1, 70, 1_000);

        var warning = controller.DropSelectedCutsOnPlaylist();

        Assert.Empty(warning.Playlist);
        Assert.Equal("NXT는 제공하지 않습니다.", warning.Dialog?.Message);
    }

    [Fact]
    public async Task NewSearchClearsStockSelectionButNotCutCatalogOrPlaylist()
    {
        var controller = new LegacyOperatorController(Lookup());
        await controller.SearchStocksAsync("삼성", LegacySearchTrigger.Button);
        controller.SelectStock(0);
        controller.CutPointerDown(1, false, false, 1, 24, 1_000);
        controller.DropSelectedCutsOnPlaylist();

        var state = await controller.SearchStocksAsync("다시", LegacySearchTrigger.Button);

        Assert.Null(state.SelectedStockIndex);
        Assert.Equal(33, state.CutRows.Count);
        Assert.Single(state.Playlist);
    }

    [Fact]
    public async Task PlaylistPlainControlAndControlShiftSelectionMatchMainFormRules()
    {
        var controller = await ControllerWithPlaylistAsync(0, 1, 2, 4, 5);
        var ids = controller.Current.Playlist.Select(row => row.RowId).ToArray();

        controller.SelectPlaylistRow(ids[1], control: false, shift: false);
        controller.SelectPlaylistRow(ids[3], control: true, shift: false);
        controller.SelectPlaylistRow(ids[3], control: true, shift: false);
        var state = controller.SelectPlaylistRow(ids[4], control: true, shift: true);

        Assert.Equal(
            [ids[1], ids[3], ids[4]],
            state.Playlist.Where(row => row.IsSelected).Select(row => row.RowId));
    }

    [Fact]
    public async Task PlaylistShiftUsesPreviousActiveRowAndMovesItsRangeAnchor()
    {
        var controller = await ControllerWithPlaylistAsync(0, 1, 2, 4, 5);
        var ids = controller.Current.Playlist.Select(row => row.RowId).ToArray();

        controller.SelectPlaylistRow(ids[1], control: false, shift: false);
        var firstRange = controller.SelectPlaylistRow(ids[3], control: false, shift: true);
        Assert.Equal(
            ids[1..4],
            firstRange.Playlist.Where(row => row.IsSelected).Select(row => row.RowId));

        var movedRange = controller.SelectPlaylistRow(ids[4], control: false, shift: true);
        Assert.Equal(
            ids[3..5],
            movedRange.Playlist.Where(row => row.IsSelected).Select(row => row.RowId));
    }

    [Fact]
    public async Task FirstPlaylistShiftStartsAtOriginalZeroCursor()
    {
        var controller = await ControllerWithPlaylistAsync(0, 1, 2);
        var ids = controller.Current.Playlist.Select(row => row.RowId).ToArray();

        var state = controller.SelectPlaylistRow(ids[2], control: false, shift: true);

        Assert.All(state.Playlist, row => Assert.True(row.IsSelected));
    }

    [Fact]
    public async Task CheckboxActivationChangesF8CandidateWithoutReplacingDeleteSelection()
    {
        var controller = await ControllerWithPlaylistAsync(0, 1, 2);
        var ids = controller.Current.Playlist.Select(row => row.RowId).ToArray();
        controller.SelectPlaylistRow(ids[0], control: false, shift: false);
        controller.SelectPlaylistRow(ids[1], control: true, shift: false);

        var state = controller.ActivatePlaylistRow(ids[2]);

        Assert.Equal(
            ids[..2],
            state.Playlist.Where(row => row.IsSelected).Select(row => row.RowId));
        Assert.Equal(ids[2], Assert.Single(state.Playlist, row => row.IsActive).RowId);
        Assert.Equal(2, controller.CreatePlayoutRequest().SelectedIndexZeroBased);
    }

    [Fact]
    public async Task CheckboxActivationDoesNotMoveTheFollowingShiftSelectionAnchor()
    {
        var controller = await ControllerWithPlaylistAsync(0, 1, 2, 4);
        var ids = controller.Current.Playlist.Select(row => row.RowId).ToArray();
        controller.SelectPlaylistRow(ids[1], control: false, shift: false);

        controller.ActivatePlaylistRow(ids[3]);
        var range = controller.SelectPlaylistRow(ids[2], control: false, shift: true);

        Assert.Equal(
            ids[1..3],
            range.Playlist.Where(row => row.IsSelected).Select(row => row.RowId));
        Assert.Equal(ids[2], Assert.Single(range.Playlist, row => row.IsActive).RowId);
    }

    [Fact]
    public async Task HomeAndEndChangeOnlyTheF8CandidateAndKeepProgramPageProjection()
    {
        var controller = await ControllerWithPlaylistAsync(0, 1, 2, 4);
        var rows = controller.Current.Playlist;
        controller.SelectPlaylistRow(rows[1].RowId, control: false, shift: false);
        controller.SelectPlaylistRow(rows[3].RowId, control: true, shift: false);
        controller.ReflectSuccessfulPlayout(
            rows[1].RowId,
            pageIndexZeroBased: 1,
            pageCount: 3,
            synchronizeOperatorSelection: false);

        var home = controller.SelectPlaylistBoundary(last: false);
        Assert.Equal(0, controller.CreatePlayoutRequest().SelectedIndexZeroBased);
        Assert.Equal("2/3", home.Playlist[1].PageText);
        Assert.True(home.Playlist[0].IsActive);
        Assert.Equal(
            [rows[1].RowId, rows[3].RowId],
            home.Playlist.Where(row => row.IsSelected).Select(row => row.RowId));

        var end = controller.SelectPlaylistBoundary(last: true);
        Assert.Equal(3, controller.CreatePlayoutRequest().SelectedIndexZeroBased);
        Assert.Equal("2/3", end.Playlist[1].PageText);
        Assert.True(end.Playlist[3].IsActive);

        var shift = controller.SelectPlaylistRow(
            rows[2].RowId,
            control: false,
            shift: true);
        Assert.Equal(
            [rows[1].RowId, rows[2].RowId],
            shift.Playlist.Where(row => row.IsSelected).Select(row => row.RowId));
    }

    [Fact]
    public async Task FirstAppendedRowIsAnF8CandidateButNotAnImplicitDeleteSelection()
    {
        var controller = await ControllerWithPlaylistAsync(0);
        var before = controller.Current;

        Assert.True(Assert.Single(before.Playlist).IsActive);
        Assert.DoesNotContain(before.Playlist, row => row.IsSelected);
        Assert.Equal(0, controller.CreatePlayoutRequest().SelectedIndexZeroBased);

        var after = controller.DeleteSelectedPlaylistRows();
        Assert.Equal(before.Revision, after.Revision);
        Assert.Single(after.Playlist);
    }

    [Fact]
    public async Task PlaylistEnabledSupportsIndividualToggleAndAllRows()
    {
        var controller = await ControllerWithPlaylistAsync(0, 1, 2);
        var ids = controller.Current.Playlist.Select(row => row.RowId).ToArray();

        controller.SetPlaylistRowEnabled(ids[0], false);
        var toggled = controller.TogglePlaylistRowEnabled(ids[1]);
        Assert.False(toggled.Playlist[0].IsEnabled);
        Assert.False(toggled.Playlist[1].IsEnabled);
        Assert.True(toggled.Playlist[2].IsEnabled);

        var disabled = controller.SetAllPlaylistRowsEnabled(false);
        Assert.All(disabled.Playlist, row => Assert.False(row.IsEnabled));

        var enabled = controller.SetAllPlaylistRowsEnabled(true);
        Assert.All(enabled.Playlist, row => Assert.True(row.IsEnabled));
    }

    [Fact]
    public async Task DeleteSelectedRowsPreservesRemainingPhysicalOrderAndClearsSelection()
    {
        var controller = await ControllerWithPlaylistAsync(0, 1, 2, 4);
        var initial = controller.Current.Playlist.ToArray();

        controller.SelectPlaylistRow(initial[1].RowId, control: false, shift: false);
        controller.SelectPlaylistRow(initial[3].RowId, control: true, shift: false);
        var state = controller.DeleteSelectedPlaylistRows();

        Assert.Equal(
            [initial[0].RowId, initial[2].RowId],
            state.Playlist.Select(row => row.RowId));
        Assert.DoesNotContain(state.Playlist, row => row.IsSelected);
        Assert.Equal(initial[2].RowId, Assert.Single(state.Playlist, row => row.IsActive).RowId);
        Assert.Equal(1, controller.CreatePlayoutRequest().SelectedIndexZeroBased);
    }

    [Fact]
    public async Task DeletePreservesAnIndependentCheckboxCandidateByStableIdentity()
    {
        var controller = await ControllerWithPlaylistAsync(0, 1, 2, 4);
        var initial = controller.Current.Playlist.ToArray();
        controller.SelectPlaylistRow(initial[0].RowId, control: false, shift: false);
        controller.SelectPlaylistRow(initial[1].RowId, control: true, shift: false);
        controller.ActivatePlaylistRow(initial[3].RowId);

        var state = controller.DeleteSelectedPlaylistRows();

        Assert.Equal(
            [initial[2].RowId, initial[3].RowId],
            state.Playlist.Select(row => row.RowId));
        Assert.DoesNotContain(state.Playlist, row => row.IsSelected);
        Assert.Equal(initial[3].RowId, Assert.Single(state.Playlist, row => row.IsActive).RowId);
        Assert.Equal(1, controller.CreatePlayoutRequest().SelectedIndexZeroBased);
    }

    [Fact]
    public async Task DeleteActiveCandidateChoosesTheNextSurvivorThenThePreviousAtEnd()
    {
        var controller = await ControllerWithPlaylistAsync(0, 1, 2, 4);
        var initial = controller.Current.Playlist.ToArray();
        controller.SelectPlaylistRow(initial[1].RowId, control: false, shift: false);

        var afterMiddle = controller.DeleteSelectedPlaylistRows();

        Assert.Equal(initial[2].RowId, Assert.Single(afterMiddle.Playlist, row => row.IsActive).RowId);
        Assert.Equal(1, controller.CreatePlayoutRequest().SelectedIndexZeroBased);

        controller.SelectPlaylistRow(initial[3].RowId, control: false, shift: false);
        var afterLast = controller.DeleteSelectedPlaylistRows();

        Assert.Equal(initial[2].RowId, Assert.Single(afterLast.Playlist, row => row.IsActive).RowId);
        Assert.Equal(1, controller.CreatePlayoutRequest().SelectedIndexZeroBased);
    }

    [Fact]
    public async Task ShiftSelectionAndDeleteAreNotLimitedToTheOriginalHundredRowBuffers()
    {
        var controller = new LegacyOperatorController(Lookup());
        await controller.SearchStocksAsync("삼성", LegacySearchTrigger.Button);
        controller.SelectStock(0);
        controller.CutPointerDown(1, false, false, 1, 24, 1_000);
        for (var index = 0; index < 101; index++)
        {
            controller.DropSelectedCutsOnPlaylist();
        }

        var rows = controller.Current.Playlist;
        Assert.Equal(101, rows.Count);
        controller.SelectPlaylistRow(rows[0].RowId, control: false, shift: false);
        var selected = controller.SelectPlaylistRow(
            rows[^1].RowId,
            control: false,
            shift: true);
        Assert.Equal(101, selected.Playlist.Count(row => row.IsSelected));

        var deleted = controller.DeleteSelectedPlaylistRows();

        Assert.Empty(deleted.Playlist);
        Assert.Throws<InvalidOperationException>(() => controller.CreatePlayoutRequest());
    }

    [Fact]
    public async Task DeleteAllRowsClearsPlaylistAndSelectionState()
    {
        var controller = await ControllerWithPlaylistAsync(0, 1, 2);
        controller.SelectPlaylistRow(
            controller.Current.Playlist[1].RowId,
            control: false,
            shift: false);

        var requested = controller.DeleteAllPlaylistRows();
        Assert.Equal("송출 리스트를 모두 삭제하겠습니까?", requested.Dialog?.Message);
        Assert.Equal("MmoneyCoder", requested.Dialog?.Caption);
        Assert.True(requested.Dialog?.IsConfirmation);
        Assert.Equal(3, requested.Playlist.Count);

        var canceled = controller.CancelDialog();
        Assert.Null(canceled.Dialog);
        Assert.Equal(3, canceled.Playlist.Count);

        controller.DeleteAllPlaylistRows();
        var empty = await controller.ConfirmDialogAsync();
        Assert.Null(empty.Dialog);
        Assert.Empty(empty.Playlist);

        var unchangedRevision = empty.Revision;
        var requestedEmpty = controller.DeleteAllPlaylistRows();
        Assert.True(requestedEmpty.Dialog?.IsConfirmation);
        var stillEmpty = await controller.ConfirmDialogAsync();
        Assert.Empty(stillEmpty.Playlist);
        Assert.True(stillEmpty.Revision > unchangedRevision);
    }

    [Fact]
    public async Task FirstAppendAfterDeleteAllReestablishesTheVisibleF8Candidate()
    {
        var controller = await ControllerWithPlaylistAsync(0, 1);
        controller.DeleteAllPlaylistRows();
        await controller.ConfirmDialogAsync();

        controller.CutPointerDown(2, false, false, 1, 47, 10_000);
        var state = controller.DropSelectedCutsOnPlaylist();

        var row = Assert.Single(state.Playlist);
        Assert.True(row.IsActive);
        Assert.False(row.IsSelected);
        Assert.Equal(0, controller.CreatePlayoutRequest().SelectedIndexZeroBased);
    }

    [Fact]
    public async Task MoveUsesOnlyActiveRowAndCollapsesMultiSelectionToMovedRow()
    {
        var controller = await ControllerWithPlaylistAsync(0, 1, 2, 4);
        var initial = controller.Current.Playlist.ToArray();

        controller.SelectPlaylistRow(initial[0].RowId, control: false, shift: false);
        controller.SelectPlaylistRow(initial[2].RowId, control: true, shift: false);
        var moved = controller.MoveSelectedPlaylistRows(LegacyPlaylistMoveDirection.Up);

        Assert.Equal(
            [initial[0].RowId, initial[2].RowId, initial[1].RowId, initial[3].RowId],
            moved.Playlist.Select(row => row.RowId));
        Assert.Equal(
            initial[2].RowId,
            Assert.Single(moved.Playlist, row => row.IsSelected).RowId);
        Assert.Equal(initial[2].ToPlayoutEntry(), moved.Playlist[1].ToPlayoutEntry());
        Assert.Equal(initial[2].PageText, moved.Playlist[1].PageText);
        Assert.Equal(initial[2].RawCutLabel, moved.Playlist[1].RawCutLabel);
        Assert.Equal(1, controller.CreatePlayoutRequest().SelectedIndexZeroBased);

        var restored = controller.MoveSelectedPlaylistRows(LegacyPlaylistMoveDirection.Down);
        Assert.Equal(initial.Select(row => row.RowId), restored.Playlist.Select(row => row.RowId));
        Assert.Equal(
            initial[2].RowId,
            Assert.Single(restored.Playlist, row => row.IsSelected).RowId);
        Assert.Equal(2, controller.CreatePlayoutRequest().SelectedIndexZeroBased);
    }

    [Fact]
    public async Task MoveAtBoundaryIsNoOp()
    {
        var controller = await ControllerWithPlaylistAsync(0, 1);
        var first = controller.Current.Playlist[0];
        controller.SelectPlaylistRow(first.RowId, control: false, shift: false);
        var before = controller.Current;

        var after = controller.MoveSelectedPlaylistRows(LegacyPlaylistMoveDirection.Up);

        Assert.Equal(before.Revision, after.Revision);
        Assert.Equal(
            before.Playlist.Select(row => row.RowId),
            after.Playlist.Select(row => row.RowId));
    }

    [Fact]
    public async Task PlaylistDragMovesOnlySourceRowCollapsesSelectionAndIsIdempotent()
    {
        var controller = await ControllerWithPlaylistAsync(0, 1, 2, 4, 5, 6);
        var initial = controller.Current.Playlist.ToArray();

        controller.SelectPlaylistRow(initial[1].RowId, control: false, shift: false);
        controller.SelectPlaylistRow(initial[3].RowId, control: true, shift: false);
        var moved = controller.ReorderPlaylistRows(
            initial[3].RowId,
            initial[5].RowId,
            LegacyDragDropPosition.Before);

        Assert.Equal(
            [
                initial[0].RowId,
                initial[1].RowId,
                initial[2].RowId,
                initial[4].RowId,
                initial[3].RowId,
                initial[5].RowId
            ],
            moved.Playlist.Select(row => row.RowId));
        Assert.Equal(
            initial[3].RowId,
            Assert.Single(moved.Playlist, row => row.IsSelected).RowId);
        Assert.Equal(
            initial[3].RowId,
            Assert.Single(moved.Playlist, row => row.IsActive).RowId);

        var revision = moved.Revision;
        var duplicate = controller.ReorderPlaylistRows(
            initial[3].RowId,
            initial[5].RowId,
            LegacyDragDropPosition.Before);
        Assert.Equal(revision, duplicate.Revision);
        Assert.Equal(
            moved.Playlist.Select(row => row.RowId),
            duplicate.Playlist.Select(row => row.RowId));
        Assert.Equal(
            initial[3].RowId,
            Assert.Single(duplicate.Playlist, row => row.IsSelected).RowId);
    }

    [Fact]
    public async Task PlaylistDragFromUnselectedRowMovesOnlyThatRowAndSelectsIt()
    {
        var controller = await ControllerWithPlaylistAsync(0, 1, 2, 4);
        var initial = controller.Current.Playlist.ToArray();
        controller.SelectPlaylistRow(initial[0].RowId, control: false, shift: false);
        controller.SelectPlaylistRow(initial[1].RowId, control: true, shift: false);

        var moved = controller.ReorderPlaylistRows(
            initial[3].RowId,
            initial[0].RowId,
            LegacyDragDropPosition.Before);

        Assert.Equal(
            [initial[3].RowId, initial[0].RowId, initial[1].RowId, initial[2].RowId],
            moved.Playlist.Select(row => row.RowId));
        Assert.Equal(
            initial[3].RowId,
            Assert.Single(moved.Playlist, row => row.IsSelected).RowId);
    }

    private static LegacyFixedSelection OriginalFixedVisibleSelection(
        LegacyFixedAction action)
    {
        var separator = action.Label.IndexOf('_');
        var first = separator < 0 ? action.Label : action.Label[..separator];
        var remainder = separator < 0
            ? string.Empty
            : action.Label[(separator + 1)..];
        if (action.Market is LegacyFixedMarket.Overseas or LegacyFixedMarket.Exchange)
        {
            return new LegacyFixedSelection(
                action.Market == LegacyFixedMarket.Overseas ? "해외지수" : "환율",
                first,
                action.Section,
                remainder,
                string.Empty);
        }

        var indexGroup = action.Label.Contains("코스피_NXT", StringComparison.Ordinal)
            ? "코스피_NXT"
            : action.Label.Contains("코스닥_NXT", StringComparison.Ordinal)
                ? "코스닥_NXT"
                : action.Label.Contains("코스피", StringComparison.Ordinal)
                    ? "코스피"
                    : action.Label.Contains("코스닥", StringComparison.Ordinal)
                        ? "코스닥"
                        : "전체";
        if (action.Section is "5단 표그래프" or "6종목 현재가" or "12종목 현재가")
        {
            return new LegacyFixedSelection(
                indexGroup,
                "지수",
                action.Section,
                action.Label,
                string.Empty);
        }

        if (string.Equals(action.Section, "매매동향", StringComparison.Ordinal))
        {
            return new LegacyFixedSelection(
                indexGroup,
                "지수",
                first,
                remainder,
                string.Empty);
        }

        return new LegacyFixedSelection(
            action.Section,
            "지수",
            first,
            remainder,
            string.Empty);
    }

    private static async Task<LegacyOperatorController> ControllerWithPlaylistAsync(
        params int[] physicalCutRows)
    {
        var controller = new LegacyOperatorController(Lookup());
        await controller.SearchStocksAsync("삼성", LegacySearchTrigger.Button);
        controller.SelectStock(0);

        var timestamp = 1_000L;
        foreach (var physicalCutRow in physicalCutRows)
        {
            controller.CutPointerDown(
                physicalCutRow,
                control: false,
                shift: false,
                x: 1,
                y: physicalCutRow * 23,
                timestampMilliseconds: timestamp);
            controller.DropSelectedCutsOnPlaylist();
            timestamp += 1_000;
        }

        return controller;
    }

    private static FakeLookup Lookup() => new(new LegacyStockSearchData(
        "query",
        ["삼성전자", "넥스트(NXT)"],
        [
            new LegacyStockIdentity(LegacyDomesticStockMarket.Kospi, "삼성전자", "005930"),
            new LegacyStockIdentity(LegacyDomesticStockMarket.NxtKospi, "넥스트(NXT)", "A00001")
        ]));

    private sealed class RecordingPagePlanProvider : ILegacyScenePagePlanProvider
    {
        public List<LegacyPlaylistEntry> Entries { get; } = [];

        public int FiveRowItemCount { get; init; } = 101;

        public int SixRowItemCount { get; init; } = 13;

        public int TwelveRowItemCount { get; init; } = 25;

        public Exception? Failure { get; init; }

        public Task<LegacyScenePagePlan> CreatePagePlanAsync(
            LegacyPlaylistEntry entry,
            CancellationToken cancellationToken = default)
        {
            cancellationToken.ThrowIfCancellationRequested();
            Entries.Add(entry);
            if (Failure is not null)
            {
                return Task.FromException<LegacyScenePagePlan>(Failure);
            }

            var (builderKey, pageSize, itemCount) = entry.CutCode switch
            {
                "5074" => ("s5074", ScenePageSize.Five, FiveRowItemCount),
                "5077" => ("s5077", ScenePageSize.Six, SixRowItemCount),
                "5088" => ("s5088", ScenePageSize.Twelve, TwelveRowItemCount),
                _ => throw new InvalidOperationException(
                    "Only a paged fixed action may request an initial page plan.")
            };
            var plan = Assert.IsType<ScenePagingPlan>(
                ScenePaging.CreatePlan(itemCount, pageSize).Plan);
            return Task.FromResult(new LegacyScenePagePlan(
                builderKey,
                itemCount,
                pageSize,
                plan.TotalPages,
                plan.AccessibleItemCount,
                plan.ExcludedItemCount > 0));
        }
    }

    private sealed class FakeLookup : ILegacyStockLookup
    {
        private readonly LegacyStockSearchData _result;

        public FakeLookup(LegacyStockSearchData result)
        {
            _result = result;
        }

        public int CallCount { get; private set; }

        public Task<LegacyStockSearchData> SearchAsync(
            string query,
            CancellationToken cancellationToken = default)
        {
            CallCount++;
            return Task.FromResult(_result);
        }
    }

    private sealed class FixedTimeProvider(DateTimeOffset localNow) : TimeProvider
    {
        private static readonly TimeZoneInfo KoreaStandardTime = TimeZoneInfo.CreateCustomTimeZone(
            "FixedKst",
            TimeSpan.FromHours(9),
            "Fixed KST",
            "Fixed KST");

        public override DateTimeOffset GetUtcNow() => localNow.ToUniversalTime();

        public override TimeZoneInfo LocalTimeZone => KoreaStandardTime;
    }

    private sealed class StaticIndustryService : IIndustrySelectionService
    {
        private readonly IReadOnlyList<IndustrySelection> _rows;

        public StaticIndustryService(IReadOnlyList<IndustrySelection> rows)
        {
            _rows = rows;
        }

        public Task<IReadOnlyList<IndustrySelection>> GetAsync(
            IndustryMarket market,
            CancellationToken cancellationToken = default) => Task.FromResult(_rows);
    }

    private sealed class StaticThemeService : IThemeSelectionService
    {
        private readonly ThemeSelectionIdentity _identity;
        private readonly IReadOnlyList<ThemeItemPreview> _items;

        public StaticThemeService(
            ThemeSelectionIdentity identity,
            IReadOnlyList<ThemeItemPreview> items)
        {
            _identity = identity;
            _items = items;
        }

        public int PreviewCalls { get; private set; }

        public List<ThemeSession> SearchSessions { get; } = [];

        public Task<ThemeSearchResult> SearchAsync(
            string query,
            ThemeSession nxtSession,
            int maximumResults = LegacyThemeSelectionService.DefaultMaximumResults,
            CancellationToken cancellationToken = default)
        {
            SearchSessions.Add(nxtSession);
            var identity = _identity.Market == ThemeMarket.Nxt
                ? _identity with { Session = nxtSession }
                : _identity;
            return Task.FromResult(
                new ThemeSearchResult(
                    query,
                    nxtSession,
                    DateTimeOffset.UtcNow,
                    [new ThemeSelectorItem(
                        identity,
                        identity.Market == ThemeMarket.Krx
                             ? DataSourceKind.Oracle
                             : DataSourceKind.MariaDb)],
                    false));
        }

        public Task<ThemePreviewResult> PreviewAsync(
            ThemeSelectionIdentity identity,
            int maximumItems = LegacyThemeSelectionService.DefaultMaximumPreviewItems,
            CancellationToken cancellationToken = default)
        {
            PreviewCalls++;
            return Task.FromResult(
                new ThemePreviewResult(
                    identity,
                    DateTimeOffset.UtcNow,
                    _items,
                    false));
        }
    }

    private sealed class TruncatedThemeService : IThemeSelectionService
    {
        public Task<ThemeSearchResult> SearchAsync(
            string query,
            ThemeSession nxtSession,
            int maximumResults = LegacyThemeSelectionService.DefaultMaximumResults,
            CancellationToken cancellationToken = default)
        {
            Assert.Equal(5_000, maximumResults);
            var rows = Enumerable.Range(0, maximumResults)
                .Select(index => new ThemeSelectorItem(
                    new ThemeSelectionIdentity(
                        ThemeMarket.Krx,
                        ThemeSession.NotApplicable,
                        index.ToString("D8", System.Globalization.CultureInfo.InvariantCulture),
                        $"Theme {index}"),
                    DataSourceKind.Oracle))
                .ToArray();
            return Task.FromResult(new ThemeSearchResult(
                query,
                nxtSession,
                DateTimeOffset.UtcNow,
                rows,
                IsTruncated: true));
        }

        public Task<ThemePreviewResult> PreviewAsync(
            ThemeSelectionIdentity identity,
            int maximumItems = LegacyThemeSelectionService.DefaultMaximumPreviewItems,
            CancellationToken cancellationToken = default) =>
            Task.FromResult(new ThemePreviewResult(
                identity,
                DateTimeOffset.UtcNow,
                [],
                IsTruncated: false));
    }

    private sealed class StaticExpertService : IExpertSelectionService
    {
        private static readonly ExpertSelectionIdentity Identity = new("0001", "전문가A");

        public Task<ExpertSearchResult> SearchAsync(
            string query = "",
            int maximumResults = LegacyExpertSelectionService.DefaultMaximumResults,
            CancellationToken cancellationToken = default) => Task.FromResult(
                new ExpertSearchResult(
                    query.Trim(),
                    DateTimeOffset.UtcNow,
                    [new ExpertSelectorItem(Identity, DataSourceKind.Oracle)],
                    false));

        public Task<ExpertPreviewResult> PreviewAsync(
            ExpertSelectionIdentity identity,
            int maximumItems = LegacyExpertSelectionService.DefaultMaximumPreviewItems,
            CancellationToken cancellationToken = default) => Task.FromResult(
                new ExpertPreviewResult(
                    Identity,
                    DateTimeOffset.UtcNow,
                    Enumerable.Range(1, 6)
                        .Select(index => new ExpertRecommendationPreview(
                            index,
                            index.ToString("D6", System.Globalization.CultureInfo.InvariantCulture),
                            $"추천{index}",
                            index == 1 ? null : 10_000m + index))
                        .ToArray(),
                    false));
    }

    private sealed class StaticTradingHaltService : ITradingHaltSelectionService
    {
        public Task<TradingHaltSelectionResult> SearchAsync(
            string query = "",
            int maximumResults = LegacyTradingHaltSelectionService.DefaultMaximumResults,
            CancellationToken cancellationToken = default) => Task.FromResult(
                new TradingHaltSelectionResult(
                    query.Trim(),
                    DateTimeOffset.UtcNow,
                    [new TradingHaltSelection(TradingHaltMarket.Kospi, "005930", "삼성전자")],
                    false));
    }
}
