using MBN_STOCK_WEBVIEW.Core.Playout;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;

namespace MBN_STOCK_WEBVIEW.Core.Tests;

public sealed class LegacyPlayoutWorkflowTests
{
    [Fact]
    public async Task PrepareTakeInAndNext_UpdatesPagesBeforeMovingToNextEnabledCue()
    {
        var engine = new RecordingEngine();
        var provider = new RecordingProvider(new Dictionary<string, (int Count, ScenePageSize? Size)>
        {
            ["5074"] = (6, ScenePageSize.Five),
            ["5001"] = (1, null)
        });
        var workflow = new LegacyPlayoutWorkflow(engine, provider);
        LegacyPlaylistEntry[] playlist =
        [
            new("entry-1", "5074"),
            new("disabled", "5006", false),
            new("entry-3", "5001")
        ];

        Assert.True((await workflow.PrepareAsync(playlist, 0)).IsSuccess);
        Assert.True((await workflow.TakeInAsync()).IsSuccess);
        Assert.Equal(LegacyWorkflowNextKind.PageNext, workflow.State.NextKind);

        Assert.True((await workflow.NextAsync()).IsSuccess);
        Assert.Equal(1, workflow.State.PageIndexZeroBased);
        Assert.Equal(2, workflow.State.PageCount);
        Assert.True(workflow.State.IsLastPage);
        Assert.Equal(LegacyWorkflowNextKind.PlaylistNext, workflow.State.NextKind);

        Assert.True((await workflow.NextAsync()).IsSuccess);
        Assert.Equal(2, workflow.State.CurrentCueIndexZeroBased);
        Assert.Equal("5001", workflow.State.OnAirCutCode);
        Assert.Equal(
        [
            "Prepare:5074:p0", "Prepare:5074:p0", "TakeIn",
            "Next:5074:p1", "Next:5001:p0"
        ], engine.Calls);
        Assert.Equal(
        [
            "5074:0", "5074:0", "5074:1", "5001:0"
        ], provider.Calls);
    }

    [Fact]
    public async Task S8010SamplesCurrentMovingAveragesForPrepareTakeInRefreshAndNext()
    {
        var engine = new RecordingEngine();
        var provider = new RecordingProvider(new Dictionary<string, (int, ScenePageSize?)>
        {
            ["8051"] = (1, null),
            ["8040"] = (1, null)
        });
        var source = new MutableLegacySceneMovingAverageSelectionSource(
            new LegacySceneMovingAverageSelection(false, false));
        var workflow = new LegacyPlayoutWorkflow(engine, provider);
        LegacyPlaylistEntry[] playlist =
        [
            CandleEntry("first", "8051", source),
            CandleEntry("second", "8040", source)
        ];

        Assert.True((await workflow.PrepareAsync(playlist, 0)).IsSuccess);
        source.Update(new LegacySceneMovingAverageSelection(true, false));
        Assert.True((await workflow.TakeInAsync()).IsSuccess);
        source.Update(new LegacySceneMovingAverageSelection(false, true));
        Assert.True((await workflow.RefreshOnAirAsync()).IsSuccess);
        source.Update(new LegacySceneMovingAverageSelection(true, true));
        Assert.True((await workflow.NextAsync()).IsSuccess);

        Assert.Equal(["", "MA5", "MA20", "MA5,MA20"],
            provider.SelectionGraphicTypes);
        Assert.Equal("second", workflow.State.CurrentEntryId);
    }

    [Fact]
    public async Task OperatorPageCountRemainsFrozenAcrossGrowthAndRefresh()
    {
        var definitions = new Dictionary<string, (int Count, ScenePageSize? Size)>
        {
            ["5074"] = (6, ScenePageSize.Five),
            ["5001"] = (1, null)
        };
        var engine = new RecordingEngine();
        var provider = new RecordingProvider(definitions);
        var workflow = new LegacyPlayoutWorkflow(engine, provider);
        LegacyPlaylistEntry[] playlist =
        [
            new(
                "paged",
                "5074",
                PageNavigation: new(ScenePageSize.Five, FrozenPageCount: 2)),
            new("ordinary", "5001", PageNavigation: LegacyPlaylistPageNavigation.Disabled)
        ];

        Assert.True((await workflow.TakeSelectedAsync(playlist, 0)).IsSuccess);
        Assert.Equal(2, workflow.State.PageCount);
        Assert.True(workflow.State.IsPageNavigationEnabled);

        definitions["5074"] = (16, ScenePageSize.Five);
        Assert.True((await workflow.RefreshOnAirAsync()).IsSuccess);
        Assert.Equal(2, workflow.State.PageCount);
        Assert.Equal(LegacyWorkflowNextKind.PageNext, workflow.State.NextKind);

        Assert.True((await workflow.NextAsync()).IsSuccess);
        Assert.Equal(1, workflow.State.PageIndexZeroBased);
        Assert.Equal(2, workflow.State.PageCount);
        Assert.True(workflow.State.IsLastPage);
        Assert.Equal(LegacyWorkflowNextKind.PlaylistNext, workflow.State.NextKind);

        definitions["5074"] = (101, ScenePageSize.Five);
        Assert.True((await workflow.RefreshOnAirAsync()).IsSuccess);
        Assert.Equal(2, workflow.State.PageCount);
        Assert.Equal(5, workflow.State.CurrentPageItemCount);
        Assert.Equal(LegacyWorkflowNextKind.PlaylistNext, workflow.State.NextKind);
    }

    [Fact]
    public async Task OperatorPageCountRemainsFrozenAfterShrinkAndMissingNextPageFailsClosed()
    {
        var definitions = new Dictionary<string, (int Count, ScenePageSize? Size)>
        {
            ["5074"] = (6, ScenePageSize.Five)
        };
        var engine = new RecordingEngine();
        var provider = new RecordingProvider(definitions);
        var workflow = new LegacyPlayoutWorkflow(engine, provider);
        LegacyPlaylistEntry[] playlist =
        [
            new(
                "paged",
                "5074",
                PageNavigation: new(ScenePageSize.Five, FrozenPageCount: 2))
        ];
        Assert.True((await workflow.TakeSelectedAsync(playlist, 0)).IsSuccess);

        definitions["5074"] = (5, ScenePageSize.Five);
        Assert.True((await workflow.RefreshOnAirAsync()).IsSuccess);
        Assert.Equal(2, workflow.State.PageCount);
        Assert.Equal(LegacyWorkflowNextKind.PageNext, workflow.State.NextKind);
        var engineCallCount = engine.Calls.Count;

        await Assert.ThrowsAsync<LegacySceneDataException>(() => workflow.NextAsync());

        Assert.Equal(engineCallCount, engine.Calls.Count);
        Assert.Equal(0, workflow.State.PageIndexZeroBased);
        Assert.Equal(2, workflow.State.PageCount);
        Assert.Equal(LegacyWorkflowNextKind.PageNext, workflow.State.NextKind);
    }

    [Fact]
    public async Task ExplicitlyNonPagedOperatorEntryMovesToNextPlaylistRowDespitePagedProvider()
    {
        var engine = new RecordingEngine();
        var provider = new RecordingProvider(new Dictionary<string, (int, ScenePageSize?)>
        {
            ["5074"] = (11, ScenePageSize.Five),
            ["5001"] = (1, null)
        });
        var workflow = new LegacyPlayoutWorkflow(engine, provider);
        LegacyPlaylistEntry[] playlist =
        [
            new("noncanonical", "5074", PageNavigation: LegacyPlaylistPageNavigation.Disabled),
            new("ordinary", "5001", PageNavigation: LegacyPlaylistPageNavigation.Disabled)
        ];

        Assert.True((await workflow.TakeSelectedAsync(playlist, 0)).IsSuccess);
        Assert.False(workflow.State.IsPageNavigationEnabled);
        Assert.Equal(1, workflow.State.PageCount);
        Assert.Equal(LegacyWorkflowNextKind.PlaylistNext, workflow.State.NextKind);

        Assert.True((await workflow.NextAsync()).IsSuccess);
        Assert.Equal("ordinary", workflow.State.CurrentEntryId);
        Assert.DoesNotContain("5074:1", provider.Calls);
        Assert.Equal("Next:5001:p0", engine.Calls[^1]);
    }

    [Fact]
    public async Task DirectCoreEntryWithoutNavigationMetadataRetainsFreshPlanBehavior()
    {
        var definitions = new Dictionary<string, (int Count, ScenePageSize? Size)>
        {
            ["5074"] = (6, ScenePageSize.Five)
        };
        var workflow = new LegacyPlayoutWorkflow(
            new RecordingEngine(),
            new RecordingProvider(definitions));

        Assert.True((await workflow.TakeSelectedAsync([new("direct", "5074")], 0)).IsSuccess);
        Assert.Equal(2, workflow.State.PageCount);

        definitions["5074"] = (11, ScenePageSize.Five);
        Assert.True((await workflow.RefreshOnAirAsync()).IsSuccess);
        Assert.Equal(3, workflow.State.PageCount);
        Assert.True(workflow.State.IsPageNavigationEnabled);
    }

    [Fact]
    public async Task Next_AtTheEndDoesNotWrapOrDispatchTheEngine()
    {
        var engine = new RecordingEngine();
        var provider = new RecordingProvider(new Dictionary<string, (int, ScenePageSize?)>
        {
            ["5088"] = (12, ScenePageSize.Twelve)
        });
        var workflow = new LegacyPlayoutWorkflow(engine, provider);
        LegacyPlaylistEntry[] playlist = [new("only", "5088")];
        Assert.True((await workflow.PrepareAsync(playlist, 0)).IsSuccess);
        Assert.True((await workflow.TakeInAsync()).IsSuccess);

        var result = await workflow.NextAsync();

        Assert.Equal(PlayoutResultCode.Rejected, result.Code);
        Assert.Equal(LegacyWorkflowNextKind.EndOfPlaylist, workflow.State.NextKind);
        Assert.Equal(["Prepare:5088:p0", "Prepare:5088:p0", "TakeIn"], engine.Calls);
        Assert.Equal(["5088:0", "5088:0"], provider.Calls);
    }

    [Fact]
    public async Task Next_AdoptsAnOperatorTailAppendedAfterTakeIn()
    {
        var engine = new RecordingEngine();
        var provider = new RecordingProvider(new Dictionary<string, (int, ScenePageSize?)>
        {
            ["5001"] = (1, null),
            ["5074"] = (1, null)
        });
        var workflow = new LegacyPlayoutWorkflow(engine, provider);
        LegacyPlaylistEntry[] original = [new("first", "5001")];
        Assert.True((await workflow.TakeSelectedAsync(original, 0)).IsSuccess);

        LegacyPlaylistEntry[] live =
        [
            new("first", "5001"),
            new("appended", "5074")
        ];
        var result = await workflow.NextAsync(live);

        Assert.True(result.IsSuccess);
        Assert.Equal("appended", workflow.State.CurrentEntryId);
        Assert.Equal("5074", workflow.State.OnAirCutCode);
        Assert.Equal("Next:5074:p0", engine.Calls[^1]);
    }

    [Fact]
    public async Task Next_AdoptsDeletionOnlyFromTheTailAfterTheOnAirRow()
    {
        var engine = new RecordingEngine();
        var provider = new RecordingProvider(new Dictionary<string, (int, ScenePageSize?)>
        {
            ["5001"] = (1, null),
            ["5074"] = (1, null),
            ["5088"] = (1, null)
        });
        var workflow = new LegacyPlayoutWorkflow(engine, provider);
        LegacyPlaylistEntry[] original =
        [
            new("first", "5001"),
            new("removed-tail", "5074"),
            new("retained-tail", "5088")
        ];
        Assert.True((await workflow.TakeSelectedAsync(original, 0)).IsSuccess);

        LegacyPlaylistEntry[] live =
        [
            new("first", "5001"),
            new("retained-tail", "5088")
        ];
        var result = await workflow.NextAsync(live);

        Assert.True(result.IsSuccess);
        Assert.Equal("retained-tail", workflow.State.CurrentEntryId);
        Assert.Equal("Next:5088:p0", engine.Calls[^1]);
        Assert.DoesNotContain("Next:5074:p0", engine.Calls);
    }

    [Fact]
    public async Task Next_AdoptsProgramEnabledChangesAndSkipsTheDisabledFutureRow()
    {
        var engine = new RecordingEngine();
        var provider = new RecordingProvider(new Dictionary<string, (int, ScenePageSize?)>
        {
            ["5001"] = (1, null),
            ["5074"] = (1, null),
            ["5088"] = (1, null)
        });
        var workflow = new LegacyPlayoutWorkflow(engine, provider);
        LegacyPlaylistEntry[] original =
        [
            new("first", "5001"),
            new("skip", "5074"),
            new("target", "5088")
        ];
        Assert.True((await workflow.TakeSelectedAsync(original, 0)).IsSuccess);

        LegacyPlaylistEntry[] live =
        [
            original[0] with { IsEnabled = false },
            original[1] with { IsEnabled = false },
            original[2]
        ];
        var result = await workflow.NextAsync(live);

        Assert.True(result.IsSuccess);
        Assert.Equal("target", workflow.State.CurrentEntryId);
        Assert.Equal("5088", workflow.State.OnAirCutCode);
        Assert.Equal("Next:5088:p0", engine.Calls[^1]);
        Assert.DoesNotContain("Next:5074:p0", engine.Calls);
    }

    [Fact]
    public async Task Next_WhenEnabledMiddleCueIsUnplayable_ConsumesOneNextAndFollowingNextPlaysThirdCue()
    {
        var engine = new RecordingEngine();
        var provider = new RecordingProvider(new Dictionary<string, (int, ScenePageSize?)>
        {
            ["5001"] = (1, null),
            [LegacyPlaylistEntry.UnresolvedCutCode] = (1, null),
            ["5088"] = (1, null)
        });
        provider.UnplayableCutCodes.Add(LegacyPlaylistEntry.UnresolvedCutCode);
        var workflow = new LegacyPlayoutWorkflow(engine, provider);
        LegacyPlaylistEntry[] playlist =
        [
            new("first", "5001"),
            new("unplayable", LegacyPlaylistEntry.UnresolvedCutCode),
            new("third", "5088")
        ];
        Assert.True((await workflow.TakeSelectedAsync(playlist, 0)).IsSuccess);

        await Assert.ThrowsAsync<LegacySceneDataException>(() => workflow.NextAsync());

        Assert.Equal(1, workflow.State.CurrentCueIndexZeroBased);
        Assert.Equal("unplayable", workflow.State.CurrentEntryId);
        Assert.Equal(0, workflow.State.OnAirCueIndexZeroBased);
        Assert.Equal("first", workflow.State.OnAirEntryId);
        Assert.Equal("5001", workflow.State.OnAirCutCode);
        Assert.Equal(LegacyWorkflowNextKind.PlaylistNext, workflow.State.NextKind);
        Assert.Equal(
            ["5001:0", LegacyPlaylistEntry.UnresolvedCutCode + ":0"],
            provider.Calls);
        Assert.DoesNotContain(engine.Calls, call => call.StartsWith("Next:", StringComparison.Ordinal));

        var result = await workflow.NextAsync();

        Assert.True(result.IsSuccess);
        Assert.Equal(2, workflow.State.CurrentCueIndexZeroBased);
        Assert.Equal("third", workflow.State.CurrentEntryId);
        Assert.Equal(2, workflow.State.OnAirCueIndexZeroBased);
        Assert.Equal("third", workflow.State.OnAirEntryId);
        Assert.Equal("5088", workflow.State.OnAirCutCode);
        Assert.Equal("Next:5088:p0", engine.Calls[^1]);
    }

    [Fact]
    public async Task Next_WhenOutcomeIsUnknown_DoesNotAdvanceTheProgressCursor()
    {
        var engine = new RecordingEngine();
        engine.NextResults.Enqueue(PlayoutResultCode.OutcomeUnknown);
        var provider = new RecordingProvider(new Dictionary<string, (int, ScenePageSize?)>
        {
            ["5001"] = (1, null),
            ["5088"] = (1, null)
        });
        var workflow = new LegacyPlayoutWorkflow(engine, provider);
        LegacyPlaylistEntry[] playlist =
        [
            new("first", "5001"),
            new("second", "5088")
        ];
        Assert.True((await workflow.TakeSelectedAsync(playlist, 0)).IsSuccess);

        var result = await workflow.NextAsync();

        Assert.Equal(PlayoutResultCode.OutcomeUnknown, result.Code);
        Assert.Equal(0, workflow.State.CurrentCueIndexZeroBased);
        Assert.Equal("first", workflow.State.CurrentEntryId);
        Assert.Equal(0, workflow.State.OnAirCueIndexZeroBased);
        Assert.Equal("first", workflow.State.OnAirEntryId);
        Assert.Equal("5001", workflow.State.OnAirCutCode);
    }

    [Fact]
    public async Task Next_WhenProgramAllRowsAreDisabled_EndsWithoutDispatchingTheEngine()
    {
        var engine = new RecordingEngine();
        var provider = new RecordingProvider(new Dictionary<string, (int, ScenePageSize?)>
        {
            ["5001"] = (1, null),
            ["5074"] = (1, null)
        });
        var workflow = new LegacyPlayoutWorkflow(engine, provider);
        LegacyPlaylistEntry[] original =
        [
            new("first", "5001"),
            new("second", "5074")
        ];
        Assert.True((await workflow.TakeSelectedAsync(original, 0)).IsSuccess);
        var callCount = engine.Calls.Count;

        var result = await workflow.NextAsync(original
            .Select(static entry => entry with { IsEnabled = false })
            .ToArray());

        Assert.Equal(PlayoutResultCode.Rejected, result.Code);
        Assert.Equal(LegacyWorkflowNextKind.EndOfPlaylist, workflow.State.NextKind);
        Assert.Equal(callCount, engine.Calls.Count);
    }

    [Fact]
    public async Task PageNext_RemainsAvailableWhenProgramAllDisablesTheCurrentPagedRow()
    {
        var engine = new RecordingEngine();
        var provider = new RecordingProvider(new Dictionary<string, (int, ScenePageSize?)>
        {
            ["5077"] = (7, ScenePageSize.Six),
            ["5088"] = (1, null)
        });
        var workflow = new LegacyPlayoutWorkflow(engine, provider);
        LegacyPlaylistEntry[] original =
        [
            new("paged", "5077"),
            new("tail", "5088")
        ];
        Assert.True((await workflow.TakeSelectedAsync(original, 0)).IsSuccess);

        var result = await workflow.NextAsync(original
            .Select(static entry => entry with { IsEnabled = false })
            .ToArray());

        Assert.True(result.IsSuccess);
        Assert.Equal("paged", workflow.State.CurrentEntryId);
        Assert.Equal(1, workflow.State.PageIndexZeroBased);
        Assert.Equal(LegacyWorkflowNextKind.EndOfPlaylist, workflow.State.NextKind);
        Assert.Equal("Next:5077:p1", engine.Calls[^1]);
    }

    [Fact]
    public async Task Next_RejectsEveryNonEnabledChangeInTheOnAirPrefix()
    {
        var originalSource = new MutableLegacySceneMovingAverageSelectionSource(
            new LegacySceneMovingAverageSelection(true, false));
        var replacementSource = new MutableLegacySceneMovingAverageSelectionSource(
            new LegacySceneMovingAverageSelection(false, true));
        var originalSelection = new LegacySceneSelection(
            "KOSPI_STOCK",
            "Samsung Electronics",
            "PRICE",
            string.Empty,
            "005930");
        var original = new LegacyPlaylistEntry(
            "first",
            "5001",
            FadeDuration: 1,
            Selection: originalSelection,
            PageNavigation: LegacyPlaylistPageNavigation.Disabled,
            MovingAverageSelectionSource: originalSource);
        LegacyPlaylistEntry[] changedPrefixes =
        [
            original with { EntryId = "changed-id", IsEnabled = false },
            original with { CutCode = "5074", IsEnabled = false },
            original with { FadeDuration = 2, IsEnabled = false },
            original with
            {
                Selection = originalSelection with { DataCode = "000660" },
                IsEnabled = false
            },
            original with
            {
                PageNavigation = new LegacyPlaylistPageNavigation(ScenePageSize.Five, 1),
                IsEnabled = false
            },
            original with
            {
                MovingAverageSelectionSource = replacementSource,
                IsEnabled = false
            }
        ];

        foreach (var changedPrefix in changedPrefixes)
        {
            var engine = new RecordingEngine();
            var provider = new RecordingProvider(new Dictionary<string, (int, ScenePageSize?)>
            {
                ["5001"] = (1, null),
                ["5074"] = (1, null)
            });
            var workflow = new LegacyPlayoutWorkflow(engine, provider);
            LegacyPlaylistEntry[] retained = [original, new("second", "5074")];
            Assert.True((await workflow.TakeSelectedAsync(retained, 0)).IsSuccess);
            var callCount = engine.Calls.Count;

            var result = await workflow.NextAsync(
                [changedPrefix, retained[1]]);

            Assert.Equal(PlayoutResultCode.Rejected, result.Code);
            Assert.Equal(callCount, engine.Calls.Count);
            Assert.Equal("first", workflow.State.CurrentEntryId);
            Assert.Equal("5001", workflow.State.OnAirCutCode);
        }
    }

    [Fact]
    public async Task Next_RejectsAChangedOnAirPrefixWithoutDispatchingTheEngine()
    {
        var engine = new RecordingEngine();
        var provider = new RecordingProvider(new Dictionary<string, (int, ScenePageSize?)>
        {
            ["5001"] = (1, null),
            ["5074"] = (1, null)
        });
        var workflow = new LegacyPlayoutWorkflow(engine, provider);
        LegacyPlaylistEntry[] original =
        [
            new("first", "5001"),
            new("second", "5074")
        ];
        Assert.True((await workflow.TakeSelectedAsync(original, 0)).IsSuccess);
        var callCount = engine.Calls.Count;

        LegacyPlaylistEntry[] changedPrefix =
        [
            new("first", "5074"),
            new("second", "5074")
        ];
        var result = await workflow.NextAsync(changedPrefix);

        Assert.Equal(PlayoutResultCode.Rejected, result.Code);
        Assert.Equal(callCount, engine.Calls.Count);
        Assert.Equal("first", workflow.State.CurrentEntryId);
        Assert.Equal("5001", workflow.State.OnAirCutCode);
    }

    [Fact]
    public async Task PageNext_RejectsProviderThatChangesTheOnAirSceneName()
    {
        var engine = new RecordingEngine();
        var provider = new RecordingProvider(new Dictionary<string, (int, ScenePageSize?)>
        {
            ["5077"] = (7, ScenePageSize.Six)
        })
        {
            SceneNameOverride = (_, page) => page == 0 ? "5077" : "other"
        };
        var workflow = new LegacyPlayoutWorkflow(engine, provider);
        Assert.True((await workflow.PrepareAsync([new("one", "5077")], 0)).IsSuccess);
        Assert.True((await workflow.TakeInAsync()).IsSuccess);

        await Assert.ThrowsAsync<LegacySceneDataException>(() => workflow.NextAsync());

        Assert.DoesNotContain(engine.Calls, call => call == "Next:other:p1");
        Assert.Equal(0, workflow.State.PageIndexZeroBased);
    }

    [Fact]
    public async Task TakeOut_ClearsPageAndPlaylistStateOnlyAfterEngineSuccess()
    {
        var engine = new RecordingEngine();
        var provider = new RecordingProvider(new Dictionary<string, (int, ScenePageSize?)>
        {
            ["5001"] = (1, null)
        });
        var workflow = new LegacyPlayoutWorkflow(engine, provider);
        Assert.True((await workflow.PrepareAsync([new("one", "5001")], 0)).IsSuccess);
        Assert.True((await workflow.TakeInAsync()).IsSuccess);

        Assert.True((await workflow.TakeOutAsync(PlayoutTakeOutScope.All)).IsSuccess);

        Assert.Null(workflow.State.OnAirCutCode);
        Assert.Equal(-1, workflow.State.CurrentCueIndexZeroBased);
        Assert.Equal(LegacyWorkflowNextKind.None, workflow.State.NextKind);
        Assert.Equal("TakeOut:All", engine.Calls[^1]);
    }

    [Fact]
    public async Task Prepare_SkipsDisabledRowsLikeTheLegacyMainForm()
    {
        var engine = new RecordingEngine();
        var provider = new RecordingProvider(new Dictionary<string, (int, ScenePageSize?)>
        {
            ["5001"] = (1, null)
        });
        var workflow = new LegacyPlayoutWorkflow(engine, provider);
        LegacyPlaylistEntry[] playlist =
        [
            new("disabled", "5006", false),
            new("enabled", "5001")
        ];

        var result = await workflow.PrepareAsync(playlist, 0);

        Assert.True(result.IsSuccess);
        Assert.Equal(1, workflow.State.CurrentCueIndexZeroBased);
        Assert.Equal("5001", workflow.State.PreparedCutCode);
        Assert.Equal(["5001:0"], provider.Calls);
        Assert.Equal(["Prepare:5001:p0"], engine.Calls);
    }

    [Fact]
    public async Task Prepare_WithNoEnabledRowAtOrAfterSelection_DoesNotWrapOrDispatch()
    {
        var engine = new RecordingEngine();
        var provider = new RecordingProvider(new Dictionary<string, (int, ScenePageSize?)>());
        var workflow = new LegacyPlayoutWorkflow(engine, provider);
        LegacyPlaylistEntry[] playlist =
        [
            new("enabled-before", "5001"),
            new("disabled", "5006", false)
        ];

        var result = await workflow.PrepareAsync(playlist, 1);

        Assert.Equal(PlayoutResultCode.Rejected, result.Code);
        Assert.Empty(provider.Calls);
        Assert.Empty(engine.Calls);
        Assert.Equal(-1, workflow.State.CurrentCueIndexZeroBased);
    }

    [Fact]
    public async Task Prepare_RejectsControlCharactersInLegacyLookupFieldsBeforeDataAccess()
    {
        var engine = new RecordingEngine();
        var provider = new RecordingProvider(new Dictionary<string, (int, ScenePageSize?)>());
        var workflow = new LegacyPlayoutWorkflow(engine, provider);
        var selection = new LegacySceneSelection(
            "코스피",
            "종목\n이름",
            "1열판 기본",
            string.Empty,
            "005930");

        await Assert.ThrowsAsync<ArgumentException>(() => workflow.PrepareAsync(
            [new LegacyPlaylistEntry("entry", "5001", Selection: selection)],
            0));
        var unsafeExactSelection = selection with
        {
            Subject = "Visible theme title",
            ExactSubject = "stored\ntheme title"
        };
        await Assert.ThrowsAsync<ArgumentException>(() => workflow.PrepareAsync(
            [new LegacyPlaylistEntry("exact-entry", "5001", Selection: unsafeExactSelection)],
            0));

        Assert.Empty(provider.Calls);
        Assert.Empty(engine.Calls);
    }

    [Fact]
    public async Task Prepare_RejectsAnOutOfRangeFadeBeforeDataAccess()
    {
        var engine = new RecordingEngine();
        var provider = new RecordingProvider(new Dictionary<string, (int, ScenePageSize?)>());
        var workflow = new LegacyPlayoutWorkflow(engine, provider);

        await Assert.ThrowsAsync<ArgumentException>(() => workflow.PrepareAsync(
            [new LegacyPlaylistEntry("entry", "5001", FadeDuration: 20)],
            0));

        Assert.Empty(provider.Calls);
        Assert.Empty(engine.Calls);
    }

    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task Prepare_WhenPrepareModeIsAlreadyActive_TogglesThroughLegacyStopAll(
        bool takeInFirst)
    {
        var engine = new RecordingEngine();
        var provider = new RecordingProvider(new Dictionary<string, (int, ScenePageSize?)>
        {
            ["5001"] = (1, null),
            ["5006"] = (1, null)
        });
        var workflow = new LegacyPlayoutWorkflow(engine, provider);
        Assert.True((await workflow.PrepareAsync([new("one", "5001")], 0)).IsSuccess);
        if (takeInFirst)
        {
            Assert.True((await workflow.TakeInAsync()).IsSuccess);
        }

        var toggle = await workflow.PrepareAsync([new("other", "5006")], 0);

        Assert.True(toggle.IsSuccess);
        Assert.Equal(PlayoutOperation.TakeOut, toggle.Operation);
        Assert.Equal("TakeOut:All", engine.Calls[^1]);
        string[] expectedProviderCalls = takeInFirst
            ? ["5001:0", "5001:0"]
            : ["5001:0"];
        Assert.Equal(expectedProviderCalls, provider.Calls);
        Assert.Equal(-1, workflow.State.CurrentCueIndexZeroBased);
        Assert.Null(workflow.State.PreparedCutCode);
        Assert.Null(workflow.State.OnAirCutCode);
    }

    [Fact]
    public async Task TakeIn_RequeriesAndRepreparesTheSelectedPageBeforePlay()
    {
        var engine = new RecordingEngine();
        var provider = new RecordingProvider(new Dictionary<string, (int, ScenePageSize?)>
        {
            ["5001"] = (1, null)
        });
        var workflow = new LegacyPlayoutWorkflow(engine, provider);

        Assert.True((await workflow.PrepareAsync([new("one", "5001")], 0)).IsSuccess);
        Assert.True((await workflow.TakeInAsync()).IsSuccess);

        Assert.Equal(["5001:0", "5001:0"], provider.Calls);
        Assert.Equal(
            ["Prepare:5001:p0", "Prepare:5001:p0", "TakeIn"],
            engine.Calls);
        Assert.Null(workflow.State.PreparedCutCode);
        Assert.Equal("5001", workflow.State.OnAirCutCode);
    }

    [Fact]
    public async Task TakeSelected_LoadsOnceAndCallsPrepareThenTakeIn()
    {
        var engine = new RecordingEngine();
        var provider = new RecordingProvider(new Dictionary<string, (int, ScenePageSize?)>
        {
            ["5001"] = (1, null)
        });
        var workflow = new LegacyPlayoutWorkflow(engine, provider);

        var result = await workflow.TakeSelectedAsync(
            [new("disabled", "5006", false), new("selected", "5001")],
            0);

        Assert.True(result.IsSuccess);
        Assert.Equal(PlayoutOperation.TakeIn, result.Operation);
        Assert.Equal(["5001:0"], provider.Calls);
        Assert.Equal(["Prepare:5001:p0", "TakeIn"], engine.Calls);
        Assert.Equal(1, workflow.State.CurrentCueIndexZeroBased);
        Assert.Null(workflow.State.PreparedCutCode);
        Assert.Equal("5001", workflow.State.OnAirCutCode);
        Assert.Equal("selected", workflow.State.CurrentEntryId);
    }

    [Fact]
    public async Task TakeSelected_WhenTakeInFails_RetainsPreparedStateWithoutRetry()
    {
        var engine = new RecordingEngine();
        engine.TakeInResults.Enqueue(PlayoutResultCode.OutcomeUnknown);
        var provider = new RecordingProvider(new Dictionary<string, (int, ScenePageSize?)>
        {
            ["5074"] = (6, ScenePageSize.Five)
        });
        var workflow = new LegacyPlayoutWorkflow(engine, provider);

        var result = await workflow.TakeSelectedAsync([new("selected", "5074")], 0);

        Assert.Equal(PlayoutResultCode.OutcomeUnknown, result.Code);
        Assert.Equal(["5074:0"], provider.Calls);
        Assert.Equal(["Prepare:5074:p0", "TakeIn"], engine.Calls);
        Assert.Equal("5074", workflow.State.PreparedCutCode);
        Assert.Null(workflow.State.OnAirCutCode);
        Assert.Equal("selected", workflow.State.CurrentEntryId);
        Assert.Equal(LegacyWorkflowNextKind.PageNext, workflow.State.NextKind);
    }

    [Fact]
    public async Task TakeSelected_WhileProgram_PreparesAndPlaysTheCurrentOperatorSelection()
    {
        var engine = new RecordingEngine();
        var provider = new RecordingProvider(new Dictionary<string, (int, ScenePageSize?)>
        {
            ["5001"] = (1, null),
            ["5006"] = (1, null),
            ["5074"] = (1, null)
        });
        var workflow = new LegacyPlayoutWorkflow(engine, provider);
        LegacyPlaylistEntry[] playlist =
        [
            new("program-a", "5001"),
            new("between", "5006"),
            new("candidate-d", "5074")
        ];
        Assert.True((await workflow.TakeSelectedAsync(playlist, 0)).IsSuccess);

        var switchResult = await workflow.TakeSelectedAsync(playlist, 2);

        Assert.True(switchResult.IsSuccess);
        Assert.Equal(
            ["Prepare:5001:p0", "TakeIn", "Prepare:5074:p0", "TakeIn"],
            engine.Calls);
        Assert.Equal(["5001:0", "5074:0"], provider.Calls);
        Assert.Equal(2, workflow.State.CurrentCueIndexZeroBased);
        Assert.Equal("candidate-d", workflow.State.CurrentEntryId);
        Assert.Null(workflow.State.PreparedCutCode);
        Assert.Equal("5074", workflow.State.OnAirCutCode);
    }

    [Fact]
    public async Task ManualCandidateRemainsIndependentAcrossNextUntilTheNextF8()
    {
        var engine = new RecordingEngine();
        var provider = new RecordingProvider(new Dictionary<string, (int, ScenePageSize?)>
        {
            ["5001"] = (1, null),
            ["5006"] = (1, null),
            ["5074"] = (1, null)
        });
        var workflow = new LegacyPlayoutWorkflow(engine, provider);
        LegacyPlaylistEntry[] playlist =
        [
            new("program-a", "5001"),
            new("next-b", "5006"),
            new("candidate-d", "5074")
        ];
        Assert.True((await workflow.TakeSelectedAsync(playlist, 0)).IsSuccess);
        const int operatorCandidateIndex = 2;

        Assert.True((await workflow.NextAsync()).IsSuccess);

        Assert.Equal(1, workflow.State.CurrentCueIndexZeroBased);
        Assert.Equal("next-b", workflow.State.CurrentEntryId);
        Assert.Equal(2, operatorCandidateIndex);

        Assert.True((await workflow.TakeSelectedAsync(
            playlist,
            operatorCandidateIndex)).IsSuccess);

        Assert.Equal(2, workflow.State.CurrentCueIndexZeroBased);
        Assert.Equal("candidate-d", workflow.State.CurrentEntryId);
        Assert.Equal(
            [
                "Prepare:5001:p0", "TakeIn",
                "Next:5006:p0",
                "Prepare:5074:p0", "TakeIn"
            ],
            engine.Calls);
    }

    [Fact]
    public async Task TakeSelected_AfterExplicitPrepare_UsesTheLaterRowSelection()
    {
        var engine = new RecordingEngine();
        var provider = new RecordingProvider(new Dictionary<string, (int, ScenePageSize?)>
        {
            ["5001"] = (1, null),
            ["5074"] = (1, null)
        });
        var workflow = new LegacyPlayoutWorkflow(engine, provider);
        LegacyPlaylistEntry[] playlist =
        [
            new("prepared-a", "5001"),
            new("selected-d", "5074")
        ];
        Assert.True((await workflow.PrepareAsync(playlist, 0)).IsSuccess);

        var result = await workflow.TakeSelectedAsync(playlist, 1);

        Assert.True(result.IsSuccess);
        Assert.Equal(
            ["Prepare:5001:p0", "Prepare:5074:p0", "TakeIn"],
            engine.Calls);
        Assert.Equal(["5001:0", "5074:0"], provider.Calls);
        Assert.Equal(1, workflow.State.CurrentCueIndexZeroBased);
        Assert.Equal("selected-d", workflow.State.CurrentEntryId);
        Assert.Null(workflow.State.PreparedCutCode);
        Assert.Equal("5074", workflow.State.OnAirCutCode);
    }

    [Fact]
    public async Task TakeSelected_WhileProgram_WhenPrepareFails_PreservesCurrentOnAirRow()
    {
        var engine = new RecordingEngine();
        var provider = new RecordingProvider(new Dictionary<string, (int, ScenePageSize?)>
        {
            ["5001"] = (1, null),
            ["5074"] = (1, null)
        });
        var workflow = new LegacyPlayoutWorkflow(engine, provider);
        LegacyPlaylistEntry[] playlist =
        [
            new("program-a", "5001"),
            new("candidate-d", "5074")
        ];
        Assert.True((await workflow.TakeSelectedAsync(playlist, 0)).IsSuccess);
        engine.PrepareResults.Enqueue(PlayoutResultCode.Rejected);

        var result = await workflow.TakeSelectedAsync(playlist, 1);

        Assert.Equal(PlayoutResultCode.Rejected, result.Code);
        Assert.Equal(
            ["Prepare:5001:p0", "TakeIn", "Prepare:5074:p0"],
            engine.Calls);
        Assert.Equal(0, workflow.State.CurrentCueIndexZeroBased);
        Assert.Equal("program-a", workflow.State.CurrentEntryId);
        Assert.Null(workflow.State.PreparedCutCode);
        Assert.Equal("5001", workflow.State.OnAirCutCode);
    }

    [Fact]
    public async Task TakeSelected_WhileProgram_WhenPlayIsUnknown_KeepsOldOnAirAndNewPreparedState()
    {
        var engine = new RecordingEngine();
        var provider = new RecordingProvider(new Dictionary<string, (int, ScenePageSize?)>
        {
            ["5001"] = (1, null),
            ["5074"] = (1, null)
        });
        var workflow = new LegacyPlayoutWorkflow(engine, provider);
        LegacyPlaylistEntry[] playlist =
        [
            new("program-a", "5001"),
            new("candidate-d", "5074")
        ];
        Assert.True((await workflow.TakeSelectedAsync(playlist, 0)).IsSuccess);
        engine.TakeInResults.Enqueue(PlayoutResultCode.OutcomeUnknown);

        var result = await workflow.TakeSelectedAsync(playlist, 1);

        Assert.Equal(PlayoutResultCode.OutcomeUnknown, result.Code);
        Assert.Equal(
            ["Prepare:5001:p0", "TakeIn", "Prepare:5074:p0", "TakeIn"],
            engine.Calls);
        Assert.Equal(0, workflow.State.CurrentCueIndexZeroBased);
        Assert.Equal("program-a", workflow.State.CurrentEntryId);
        Assert.Equal("5074", workflow.State.PreparedCutCode);
        Assert.Equal("5001", workflow.State.OnAirCutCode);
    }

    [Fact]
    public async Task TakeSelected_WhilePlayCompletionIsPending_DoesNotQueryOrDispatch()
    {
        var engine = new RecordingEngine();
        var provider = new RecordingProvider(new Dictionary<string, (int, ScenePageSize?)>
        {
            ["5001"] = (1, null),
            ["5074"] = (1, null)
        });
        var workflow = new LegacyPlayoutWorkflow(engine, provider);
        LegacyPlaylistEntry[] playlist =
        [
            new("program-a", "5001"),
            new("candidate-d", "5074")
        ];
        Assert.True((await workflow.TakeSelectedAsync(playlist, 0)).IsSuccess);
        engine.Status = engine.Status with { IsPlayCompletionPending = true };
        var providerCallCount = provider.Calls.Count;
        var engineCallCount = engine.Calls.Count;

        var result = await workflow.TakeSelectedAsync(playlist, 1);

        Assert.Equal(PlayoutResultCode.Rejected, result.Code);
        Assert.Equal(providerCallCount, provider.Calls.Count);
        Assert.Equal(engineCallCount, engine.Calls.Count);
        Assert.Equal(0, workflow.State.CurrentCueIndexZeroBased);
        Assert.Equal("5001", workflow.State.OnAirCutCode);
    }

    [Fact]
    public async Task TakeIn_WhenFreshPrepareIsOutcomeUnknown_DoesNotPlayOrReplacePreparedState()
    {
        var engine = new RecordingEngine();
        engine.PrepareResults.Enqueue(PlayoutResultCode.Success);
        engine.PrepareResults.Enqueue(PlayoutResultCode.OutcomeUnknown);
        var provider = new RecordingProvider(new Dictionary<string, (int, ScenePageSize?)>
        {
            ["5001"] = (1, null)
        });
        var workflow = new LegacyPlayoutWorkflow(engine, provider);
        Assert.True((await workflow.PrepareAsync([new("one", "5001")], 0)).IsSuccess);
        var before = workflow.State;

        var result = await workflow.TakeInAsync();

        Assert.Equal(PlayoutResultCode.OutcomeUnknown, result.Code);
        Assert.Equal(before, workflow.State);
        Assert.Equal(["5001:0", "5001:0"], provider.Calls);
        Assert.Equal(["Prepare:5001:p0", "Prepare:5001:p0"], engine.Calls);
        Assert.DoesNotContain("TakeIn", engine.Calls);
    }

    [Fact]
    public async Task RefreshOnAir_RequeriesTheCurrentPagedWindowAndUsesOnAirUpdatePath()
    {
        var engine = new RecordingEngine();
        var provider = new RecordingProvider(new Dictionary<string, (int, ScenePageSize?)>
        {
            ["5074"] = (6, ScenePageSize.Five)
        });
        var workflow = new LegacyPlayoutWorkflow(engine, provider);
        Assert.True((await workflow.PrepareAsync([new("one", "5074")], 0)).IsSuccess);
        Assert.True((await workflow.TakeInAsync()).IsSuccess);

        var refresh = await workflow.RefreshOnAirAsync();

        Assert.True(refresh.IsSuccess);
        Assert.Equal(PlayoutOperation.UpdateOnAir, refresh.Operation);
        Assert.Equal(["5074:0", "5074:0", "5074:0"], provider.Calls);
        Assert.Equal(
            ["Prepare:5074:p0", "Prepare:5074:p0", "TakeIn", "UpdateOnAir:5074:p0"],
            engine.Calls);
        Assert.Equal(0, workflow.State.PageIndexZeroBased);
        Assert.Equal(LegacyWorkflowNextKind.PageNext, workflow.State.NextKind);
    }

    [Fact]
    public async Task RefreshOnAir_OutcomeUnknownPreservesTheCurrentOnAirPage()
    {
        var engine = new RecordingEngine();
        var provider = new RecordingProvider(new Dictionary<string, (int, ScenePageSize?)>
        {
            ["5001"] = (1, null)
        });
        var workflow = new LegacyPlayoutWorkflow(engine, provider);
        Assert.True((await workflow.PrepareAsync([new("one", "5001")], 0)).IsSuccess);
        Assert.True((await workflow.TakeInAsync()).IsSuccess);
        var before = workflow.State;
        engine.UpdateOnAirResults.Enqueue(PlayoutResultCode.OutcomeUnknown);

        var result = await workflow.RefreshOnAirAsync();

        Assert.Equal(PlayoutResultCode.OutcomeUnknown, result.Code);
        Assert.Equal(before, workflow.State);
        Assert.Equal("UpdateOnAir:5001:p0", engine.Calls[^1]);
    }

    [Fact]
    public async Task RefreshOnAir_RequiresAnOnAirSceneBeforeQueryingData()
    {
        var engine = new RecordingEngine();
        var provider = new RecordingProvider(new Dictionary<string, (int, ScenePageSize?)>());
        var workflow = new LegacyPlayoutWorkflow(engine, provider);

        var result = await workflow.RefreshOnAirAsync();

        Assert.Equal(PlayoutResultCode.Rejected, result.Code);
        Assert.Equal(PlayoutOperation.UpdateOnAir, result.Operation);
        Assert.Empty(provider.Calls);
        Assert.Empty(engine.Calls);
    }

    [Fact]
    public async Task StopAllOrCutOutStatus_ClearsNativePageStateWithoutDispatchingAgain()
    {
        var engine = new RecordingEngine();
        var provider = new RecordingProvider(new Dictionary<string, (int, ScenePageSize?)>
        {
            ["5074"] = (6, ScenePageSize.Five)
        });
        var workflow = new LegacyPlayoutWorkflow(engine, provider);
        Assert.True((await workflow.PrepareAsync([new("one", "5074")], 0)).IsSuccess);
        Assert.True((await workflow.TakeInAsync()).IsSuccess);

        workflow.ObserveEngineStatus(engine.Status with
        {
            PreparedSceneName = null,
            OnAirSceneName = null,
            Sequence = 2
        });

        Assert.Equal(-1, workflow.State.CurrentCueIndexZeroBased);
        Assert.Equal(0, workflow.State.PageCount);
        Assert.Null(workflow.State.OnAirCutCode);
        var next = await workflow.NextAsync();
        Assert.Equal(PlayoutResultCode.Rejected, next.Code);
        Assert.DoesNotContain(engine.Calls, call => call.StartsWith("UpdateOnAir", StringComparison.Ordinal));
    }

    [Fact]
    public async Task Prepare_EmptyThenPreparedEngineStatusWhileGateIsHeld_PreservesPublishedPageState()
    {
        var engine = new RecordingEngine();
        var provider = new RecordingProvider(new Dictionary<string, (int, ScenePageSize?)>
        {
            ["5074"] = (6, ScenePageSize.Five)
        })
        {
            BuilderKey = "kospi-five-row",
            PreviewFields = [new("text", "title", "KOSPI")]
        };
        var workflow = new LegacyPlayoutWorkflow(engine, provider);
        engine.PrepareCallback = () =>
        {
            workflow.ObserveEngineStatus(engine.Status with
            {
                PreparedSceneName = null,
                OnAirSceneName = null,
                Sequence = 10
            });
            workflow.ObserveEngineStatus(engine.Status with
            {
                PreparedSceneName = "5074",
                OnAirSceneName = null,
                Sequence = 11
            });
        };

        var result = await workflow.PrepareAsync([new("playlist-entry", "5074")], 0);

        Assert.True(result.IsSuccess);
        Assert.Equal("5074", workflow.State.PreparedCutCode);
        Assert.Null(workflow.State.OnAirCutCode);
        Assert.Equal("kospi-five-row", workflow.State.BuilderKey);
        Assert.Equal(0, workflow.State.PageIndexZeroBased);
        Assert.Equal(2, workflow.State.PageCount);
        Assert.Equal(5, workflow.State.PageSize);
        Assert.Equal(6, workflow.State.ItemCount);
        Assert.Equal(5, workflow.State.CurrentPageItemCount);
        Assert.Equal("playlist-entry", workflow.State.CurrentEntryId);
        Assert.Equal(LegacyWorkflowNextKind.PageNext, workflow.State.NextKind);
        Assert.Equal(provider.PreviewFields, workflow.State.PreviewFields);
    }

    [Fact]
    public async Task ObserveEngineStatus_OlderEmptyStatusCannotOverrideNewerPreparedStatus()
    {
        var engine = new RecordingEngine();
        var provider = new RecordingProvider(new Dictionary<string, (int, ScenePageSize?)>
        {
            ["5001"] = (1, null)
        });
        var workflow = new LegacyPlayoutWorkflow(engine, provider);
        Assert.True((await workflow.PrepareAsync([new("one", "5001")], 0)).IsSuccess);

        workflow.ObserveEngineStatus(engine.Status with
        {
            PreparedSceneName = "5001",
            OnAirSceneName = null,
            Sequence = 20
        });
        workflow.ObserveEngineStatus(engine.Status with
        {
            PreparedSceneName = null,
            OnAirSceneName = null,
            Sequence = 19
        });

        Assert.Equal("5001", workflow.State.PreparedCutCode);
        Assert.Equal("one", workflow.State.CurrentEntryId);
    }

    [Fact]
    public async Task PreflightPagePlans_UsesNoEngineCommandAndReturnsEmptyAndCappedPlans()
    {
        var engine = new RecordingEngine();
        var provider = new RecordingProvider(new Dictionary<string, (int, ScenePageSize?)>
        {
            ["5074"] = (101, ScenePageSize.Five),
            ["5077"] = (0, ScenePageSize.Six),
            ["5088"] = (241, ScenePageSize.Twelve)
        });
        var workflow = new LegacyPlayoutWorkflow(engine, provider);

        var plans = await workflow.PreflightPagePlansAsync(
        [
            new("five", "5074"),
            new("six", "5077", IsEnabled: false),
            new("twelve", "5088")
        ]);

        Assert.Empty(engine.Calls);
        Assert.Empty(provider.Calls);
        Assert.Equal(["5074", "5077", "5088"], provider.PlanCalls);
        Assert.Equal(
            new LegacyPlaylistPagePlan("five", 101, 5, 20, 100, true, "s5074"),
            plans[0]);
        Assert.Equal(
            new LegacyPlaylistPagePlan("six", 0, 6, 0, 0, false, "s5077"),
            plans[1]);
        Assert.Equal(
            new LegacyPlaylistPagePlan("twelve", 241, 12, 20, 240, true, "s5088"),
            plans[2]);
        Assert.Equal(-1, workflow.State.CurrentCueIndexZeroBased);
    }

    [Fact]
    public async Task PreflightPagePlans_IsRejectedAfterPrepareWithoutQueryingAnotherDto()
    {
        var engine = new RecordingEngine();
        var provider = new RecordingProvider(new Dictionary<string, (int, ScenePageSize?)>
        {
            ["5074"] = (5, ScenePageSize.Five)
        });
        var workflow = new LegacyPlayoutWorkflow(engine, provider);
        Assert.True((await workflow.PrepareAsync([new("one", "5074")], 0)).IsSuccess);

        await Assert.ThrowsAsync<InvalidOperationException>(() =>
            workflow.PreflightPagePlansAsync([new("other", "5074")]));

        Assert.Empty(provider.PlanCalls);
    }

    private sealed class RecordingProvider(
        IReadOnlyDictionary<string, (int Count, ScenePageSize? Size)> definitions)
        : ILegacySceneCueProvider, ILegacyScenePagePlanProvider
    {
        public List<string> Calls { get; } = [];

        public List<string> PlanCalls { get; } = [];

        public List<string?> SelectionGraphicTypes { get; } = [];

        public HashSet<string> UnplayableCutCodes { get; } = new(StringComparer.Ordinal);

        public Func<string, int, string>? SceneNameOverride { get; init; }

        public string? BuilderKey { get; init; }

        public IReadOnlyList<LegacyScenePreviewField>? PreviewFields { get; init; }

        public Task<LegacySceneCuePage> CreatePageAsync(
            LegacyPlaylistEntry entry,
            int pageIndexZeroBased,
            CancellationToken cancellationToken = default)
        {
            cancellationToken.ThrowIfCancellationRequested();
            Calls.Add($"{entry.CutCode}:{pageIndexZeroBased}");
            SelectionGraphicTypes.Add(entry.Selection?.GraphicType);
            if (UnplayableCutCodes.Contains(entry.CutCode))
            {
                throw new LegacySceneDataException("The selected playlist row is not playable.");
            }

            var definition = definitions[entry.CutCode];
            var name = SceneNameOverride?.Invoke(entry.CutCode, pageIndexZeroBased) ?? entry.CutCode;
            return Task.FromResult(new LegacySceneCuePage(
                new PlayoutCue(
                    name + ".t2s",
                    name,
                    Mutations: [new PlayoutSetValue("page", $"p{pageIndexZeroBased}")]),
                definition.Count,
                definition.Size,
                BuilderKey,
                PreviewFields));
        }

        public Task<LegacyScenePagePlan> CreatePagePlanAsync(
            LegacyPlaylistEntry entry,
            CancellationToken cancellationToken = default)
        {
            cancellationToken.ThrowIfCancellationRequested();
            PlanCalls.Add(entry.CutCode);
            var definition = definitions[entry.CutCode];
            var pageSize = definition.Size ?? throw new LegacySceneDataException(
                "The test entry is not paged.");
            var result = ScenePaging.CreatePlan(definition.Count, pageSize);
            var plan = Assert.IsType<ScenePagingPlan>(result.Plan);
            return Task.FromResult(new LegacyScenePagePlan(
                "s" + entry.CutCode,
                plan.ItemCount,
                plan.PageSize,
                plan.TotalPages,
                plan.AccessibleItemCount,
                plan.ExcludedItemCount > 0));
        }
    }

    private static LegacyPlaylistEntry CandleEntry(
        string entryId,
        string cutCode,
        ILegacySceneMovingAverageSelectionSource source) =>
        new(
            entryId,
            cutCode,
            Selection: new LegacySceneSelection(
                "KOSPI_STOCK",
                "Samsung Electronics",
                "persisted-candle-identity",
                "PRICE",
                "005930"),
            MovingAverageSelectionSource: source);

    private sealed class RecordingEngine : IPlayoutEngine
    {
        public List<string> Calls { get; } = [];

        public Queue<PlayoutResultCode> PrepareResults { get; } = [];

        public Queue<PlayoutResultCode> TakeInResults { get; } = [];

        public Queue<PlayoutResultCode> NextResults { get; } = [];

        public Queue<PlayoutResultCode> UpdateOnAirResults { get; } = [];

        public Action? PrepareCallback { get; set; }

        public PlayoutStatus Status { get; set; } = new(
            PlayoutMode.DryRun,
            PlayoutConnectionState.DryRunReady,
            true,
            false,
            true,
            false,
            null,
            null,
            "test",
            DateTimeOffset.UtcNow,
            0);

        public event EventHandler<PlayoutStatusChangedEventArgs>? StatusChanged
        {
            add { }
            remove { }
        }

        public Task<PlayoutResult> ConnectAsync(CancellationToken cancellationToken = default) =>
            Success(PlayoutOperation.Connect);

        public Task<PlayoutResult> DisconnectAsync(CancellationToken cancellationToken = default) =>
            Success(PlayoutOperation.Disconnect);

        public Task<PlayoutResult> PrepareAsync(
            PlayoutCue cue,
            CancellationToken cancellationToken = default)
        {
            Calls.Add("Prepare:" + Describe(cue));
            PrepareCallback?.Invoke();
            return Complete(PlayoutOperation.Prepare, PrepareResults);
        }

        public Task<PlayoutResult> TakeInAsync(CancellationToken cancellationToken = default)
        {
            Calls.Add("TakeIn");
            return Complete(PlayoutOperation.TakeIn, TakeInResults);
        }

        public Task<PlayoutResult> NextAsync(
            PlayoutCue cue,
            CancellationToken cancellationToken = default)
        {
            Calls.Add("Next:" + Describe(cue));
            return Complete(PlayoutOperation.Next, NextResults);
        }

        public Task<PlayoutResult> UpdateOnAirAsync(
            PlayoutCue cue,
            CancellationToken cancellationToken = default)
        {
            Calls.Add("UpdateOnAir:" + Describe(cue));
            return Complete(PlayoutOperation.UpdateOnAir, UpdateOnAirResults);
        }

        public Task<PlayoutResult> TakeOutAsync(
            PlayoutTakeOutScope scope,
            CancellationToken cancellationToken = default)
        {
            Calls.Add("TakeOut:" + scope);
            return Success(PlayoutOperation.TakeOut);
        }

        public ValueTask QuarantineAsync() => ValueTask.CompletedTask;

        public ValueTask DisposeAsync() => ValueTask.CompletedTask;

        private static string Describe(PlayoutCue cue)
        {
            var page = Assert.IsType<PlayoutSetValue>(Assert.Single(cue.Mutations!)).Value;
            return $"{cue.SceneName}:{page}";
        }

        private static Task<PlayoutResult> Success(PlayoutOperation operation) =>
            Complete(operation, null);

        private static Task<PlayoutResult> Complete(
            PlayoutOperation operation,
            Queue<PlayoutResultCode>? results)
        {
            var code = results is { Count: > 0 }
                ? results.Dequeue()
                : PlayoutResultCode.Success;
            return Task.FromResult(new PlayoutResult(
                operation,
                code,
                true,
                code == PlayoutResultCode.Success ? "ok" : "failed",
                DateTimeOffset.UtcNow));
        }
    }
}
