using System.Collections.Concurrent;
using System.Runtime.InteropServices;
using MBN_STOCK_WEBVIEW.Playout.Configuration;
using MBN_STOCK_WEBVIEW.Playout.Diagnostics;
using MBN_STOCK_WEBVIEW.Playout.Interop;
using MBN_STOCK_WEBVIEW.Playout.Runtime;
using MBN_STOCK_WEBVIEW.Playout.Safety;

namespace MBN_STOCK_WEBVIEW.Playout.Tests;

public sealed class DynamicK3dSessionTests
{
    [Theory]
    [InlineData(0)]
    [InlineData(-1)]
    public async Task Connect_WhenKtapDoesNotReturnOne_FailsClosed(int connectResult)
    {
        using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
        var options = ValidatedPlayoutOptions.Create(TestOptions(scenes.Path));
        var log = new FakeComLog();
        var engine = new FakeEngine(
            log,
            new FakePlayer(log),
            new FakeScene(log),
            connectResult);
        var activator = new FakeActivator(log, engine);
        var releaser = new FakeReleaser(
            log,
            (engine, "Engine"),
            (activator.EventHandler, "EventHandler"));
        await using var dispatcher = new StaDispatcher(capacity: 1);

        var exception = await Assert.ThrowsAnyAsync<Exception>(
            () => dispatcher.InvokeAsync(
                () =>
                {
                    using var session = new DynamicK3dSession(activator, releaser);
                    try
                    {
                        session.Connect(options);
                    }
                    catch
                    {
                        Assert.False(session.IsConnected);
                        Assert.Equal(
                            PlayoutKtapConnectState.Failed,
                            session.LastKtapConnectState);
                        throw;
                    }

                    return true;
                },
                TimeSpan.FromSeconds(5),
                CancellationToken.None));

        Assert.DoesNotContain(options.Host, exception.Message, StringComparison.OrdinalIgnoreCase);
        Assert.DoesNotContain(scenes.Path, exception.Message, StringComparison.OrdinalIgnoreCase);
        Assert.DoesNotContain(connectResult.ToString(), exception.Message, StringComparison.Ordinal);
        Assert.DoesNotContain("GetScenePlayer", log.Names);
        Assert.Equal(
            new[]
            {
                $"Create:{K3dComConstants.KaEventHandlerClassGuid:B}",
                $"Create:{K3dComConstants.KaEngineClassGuid:B}",
                "KTAPConnect:1:127.0.0.1:30001:0",
                "Disconnect",
                "Release:Engine",
                "Release:EventHandler"
            },
            log.Names);
        Assert.Equal(1, log.Names.Count(name => name == "Disconnect"));
        Assert.Single(log.ThreadIds.Distinct());
        Assert.All(log.ApartmentStates, state => Assert.Equal(ApartmentState.STA, state));
    }

    [Fact]
    public async Task Connect_WhenPlayerInitializationFails_DisconnectsOnceAndPreservesOriginalFailure()
    {
        using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
        var options = ValidatedPlayoutOptions.Create(TestOptions(scenes.Path));
        var log = new FakeComLog();
        var originalFailure = new InvalidOperationException("fake original player failure");
        var engine = new FakeEngine(
            log,
            new FakePlayer(log),
            new FakeScene(log),
            playerFailure: originalFailure,
            disconnectFailure: new InvalidOperationException("fake cleanup failure"));
        var activator = new FakeActivator(log, engine);
        var releaser = new FakeReleaser(
            log,
            (engine, "Engine"),
            (activator.EventHandler, "EventHandler"));
        await using var dispatcher = new StaDispatcher(capacity: 1);
        var lastKtapConnectState = PlayoutKtapConnectState.NotAttempted;

        var exception = await Assert.ThrowsAsync<InvalidOperationException>(
            () => dispatcher.InvokeAsync(
                () =>
                {
                    using var session = new DynamicK3dSession(activator, releaser);
                    try
                    {
                        session.Connect(options);
                    }
                    finally
                    {
                        lastKtapConnectState = session.LastKtapConnectState;
                    }

                    return true;
                },
                TimeSpan.FromSeconds(5),
                CancellationToken.None));

        Assert.Same(originalFailure, exception);
        Assert.Equal(PlayoutKtapConnectState.AcceptedUnconfirmed, lastKtapConnectState);
        Assert.Equal(
            new[]
            {
                $"Create:{K3dComConstants.KaEventHandlerClassGuid:B}",
                $"Create:{K3dComConstants.KaEngineClassGuid:B}",
                "KTAPConnect:1:127.0.0.1:30001:0",
                "GetScenePlayerOnChannel:9",
                "Disconnect",
                "Release:Engine",
                "Release:EventHandler"
            },
            log.Names);
        Assert.Equal(1, log.Names.Count(name => name == "Disconnect"));
        Assert.Single(log.ThreadIds.Distinct());
        Assert.All(log.ApartmentStates, state => Assert.Equal(ApartmentState.STA, state));
    }

    [Fact]
    public async Task Connect_WhenKtapInvocationThrows_AttemptsDisconnectAndPreservesOriginalFailure()
    {
        using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
        var options = ValidatedPlayoutOptions.Create(TestOptions(scenes.Path));
        var log = new FakeComLog();
        var originalFailure = new InvalidOperationException("fake original KTAP failure");
        var engine = new FakeEngine(
            log,
            new FakePlayer(log),
            new FakeScene(log),
            connectFailure: originalFailure);
        var activator = new FakeActivator(log, engine);
        var releaser = new FakeReleaser(
            log,
            (engine, "Engine"),
            (activator.EventHandler, "EventHandler"));
        await using var dispatcher = new StaDispatcher(capacity: 1);
        var lastKtapConnectState = PlayoutKtapConnectState.NotAttempted;

        var exception = await Assert.ThrowsAsync<InvalidOperationException>(
            () => dispatcher.InvokeAsync(
                () =>
                {
                    using var session = new DynamicK3dSession(activator, releaser);
                    try
                    {
                        session.Connect(options);
                    }
                    finally
                    {
                        lastKtapConnectState = session.LastKtapConnectState;
                    }

                    return true;
                },
                TimeSpan.FromSeconds(5),
                CancellationToken.None));

        Assert.Same(originalFailure, exception);
        Assert.Equal(PlayoutKtapConnectState.Failed, lastKtapConnectState);
        Assert.Equal(
            new[]
            {
                $"Create:{K3dComConstants.KaEventHandlerClassGuid:B}",
                $"Create:{K3dComConstants.KaEngineClassGuid:B}",
                "KTAPConnect:1:127.0.0.1:30001:0",
                "Disconnect",
                "Release:Engine",
                "Release:EventHandler"
            },
            log.Names);
        Assert.Equal(1, log.Names.Count(name => name == "Disconnect"));
    }

    [Fact]
    public async Task GuardedConnect_WhenFinalTargetCheckFails_BlocksKtapAndNeverDisconnects()
    {
        using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
        var options = ValidatedPlayoutOptions.Create(TestOptions(scenes.Path));
        var log = new FakeComLog();
        var engine = new FakeEngine(log, new FakePlayer(log), new FakeScene(log));
        var activator = new FakeActivator(log, engine);
        var releaser = new FakeReleaser(
            log,
            (engine, "Engine"),
            (activator.EventHandler, "EventHandler"));
        await using var dispatcher = new StaDispatcher(capacity: 1);

        await dispatcher.InvokeAsync(
            () =>
            {
                using var session = new DynamicK3dSession(activator, releaser);
                Assert.Throws<K3dConnectSafetyException>(() => session.ConnectGuarded(
                    options,
                    () =>
                    {
                        log.Add("FinalSafetyCheck");
                        return false;
                    },
                    new FakeK3dSdkDispatchLatch()));
                Assert.Equal(
                    PlayoutKtapConnectState.NotAttempted,
                    session.LastKtapConnectState);
                return true;
            },
            TimeSpan.FromSeconds(5),
            CancellationToken.None);

        Assert.Equal(
            new[]
            {
                $"Create:{K3dComConstants.KaEventHandlerClassGuid:B}",
                $"Create:{K3dComConstants.KaEngineClassGuid:B}",
                "FinalSafetyCheck",
                "Release:Engine",
                "Release:EventHandler"
            },
            log.Names);
        Assert.DoesNotContain(log.Names, name =>
            name.StartsWith("KTAPConnect:", StringComparison.Ordinal));
        Assert.DoesNotContain("Disconnect", log.Names);
    }

    [Fact]
    public async Task GuardedConnect_WhenKtapThrows_ReleasesWithoutSdkDisconnect()
    {
        using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
        var options = ValidatedPlayoutOptions.Create(TestOptions(scenes.Path));
        var log = new FakeComLog();
        var failure = new COMException("fake ambiguous guarded connect failure");
        var engine = new FakeEngine(
            log,
            new FakePlayer(log),
            new FakeScene(log),
            connectFailure: failure);
        var activator = new FakeActivator(log, engine);
        var releaser = new FakeReleaser(
            log,
            (engine, "Engine"),
            (activator.EventHandler, "EventHandler"));
        await using var dispatcher = new StaDispatcher(capacity: 1);

        await dispatcher.InvokeAsync(
            () =>
            {
                using var session = new DynamicK3dSession(activator, releaser);
                var exception = Assert.Throws<COMException>(
                    () => session.ConnectGuarded(
                        options,
                        () => true,
                        new FakeK3dSdkDispatchLatch()));
                Assert.Same(failure, exception);
                return true;
            },
            TimeSpan.FromSeconds(5),
            CancellationToken.None);

        Assert.Contains("KTAPConnect:1:127.0.0.1:30001:0", log.Names);
        Assert.DoesNotContain("Disconnect", log.Names);
        Assert.Equal(1, log.Names.Count(name => name == "Release:Engine"));
        Assert.Equal(1, log.Names.Count(name => name == "Release:EventHandler"));
    }

    [Theory]
    [InlineData(PlayoutTakeOutScope.All, "StopAll")]
    [InlineData(PlayoutTakeOutScope.Layout, "CutOut:10")]
    public async Task FakeLateBoundCom_UsesLegacyCallOrderOnOneStaThread(
        PlayoutTakeOutScope takeOutScope,
        string expectedTakeOutCall)
    {
        using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
        var options = ValidatedPlayoutOptions.Create(TestOptions(scenes.Path));
        var cue = options.ResolveCue(new PlayoutCue(
            "test-scene.t2s",
            "test-scene",
            [
                new PlayoutField("headline", "safe value", true),
                new PlayoutField("badge", null, false)
            ],
            FadeDuration: 12));
        var log = new FakeComLog();
        var player = new FakePlayer(log);
        var scene = new FakeScene(log);
        var engine = new FakeEngine(log, player, scene);
        var activator = new FakeActivator(log, engine);
        await using var dispatcher = new StaDispatcher(capacity: 4);

        await dispatcher.InvokeAsync(
            () =>
            {
                using var session = new DynamicK3dSession(activator);
                session.Connect(options);
                Assert.Equal(
                    PlayoutKtapConnectState.AcceptedUnconfirmed,
                    session.LastKtapConnectState);
                session.Prepare(cue, options.LayoutIndex);
                session.Play(options.LayoutIndex);
                session.TakeOut(options.LayoutIndex, takeOutScope);
                session.Disconnect();
                return true;
            },
            TimeSpan.FromSeconds(5),
            CancellationToken.None);

        Assert.Equal(
            new[]
            {
                $"Create:{K3dComConstants.KaEventHandlerClassGuid:B}",
                $"Create:{K3dComConstants.KaEngineClassGuid:B}",
                "KTAPConnect:1:127.0.0.1:30001:0",
                "GetScenePlayerOnChannel:9",
                "LoadScene:test-scene",
                "SetOutputChannelIndex:9",
                "SetSceneEffectType:10:7:12",
                "BeginTransaction",
                "GetObject:headline",
                "SetValue:headline:safe value",
                "SetVisible:headline:1",
                "GetObject:badge",
                "SetVisible:badge:0",
                "QueryVariables",
                "EndTransactionOnChannel:9",
                "Prepare:10",
                "Play:10",
                expectedTakeOutCall,
                "Disconnect"
            },
            log.Names);
        Assert.Single(log.ThreadIds.Distinct());
        Assert.All(log.ApartmentStates, state => Assert.Equal(ApartmentState.STA, state));
    }

    [Fact]
    public async Task Prepare_AppliesEveryLegacyMutationInOriginalTransactionOrder()
    {
        using var scenes = TemporarySceneDirectory.Create(
            "test-scene.t2s",
            "Images\\badge.png",
            "Video\\background.vrv");
        var options = ValidatedPlayoutOptions.Create(TestOptions(scenes.Path));
        var cue = options.ResolveCue(new PlayoutCue(
            "test-scene.t2s",
            "test-scene",
            FadeDuration: 4,
            Mutations:
            [
                new PlayoutUseBackground(true),
                new PlayoutSetBackgroundTexture("Video/background.vrv"),
                new PlayoutSetBackgroundVideo("Video/background.vrv", 2, 10),
                new PlayoutSetValue("headline", "value"),
                new PlayoutSetBackgroundVideo(
                    "Video/background.vrv",
                    3,
                    1,
                    PlayoutMutationTiming.InTransaction),
                new PlayoutSetAssetValue("badge", "Images/badge.png"),
                new PlayoutSetVisible("badge", false),
                new PlayoutSetFaceColor("headline", 1, 2, 3, 4),
                new PlayoutSetPosition("position", 1, 2, 3, PlayoutVectorComponents.XYZ),
                new PlayoutSetPositionKey("position", 5, 6, 7, 8, PlayoutVectorComponents.Y),
                new PlayoutSetScale("scale", 9, 10, 11, PlayoutVectorComponents.X),
                new PlayoutSetCropKey("crop", 1, 12, 13, 14, 15, PlayoutCropEdges.Vertical),
                new PlayoutSetCircleAngleKey("circle", 2, 16, 17, PlayoutAngleComponents.All),
                new PlayoutSetPathPoints(
                    "path",
                    [new PlayoutPoint(18, 19, 20), new PlayoutPoint(21, 22, 23)]),
                new PlayoutSetPathShapePoints(
                    "shape",
                    [new PlayoutPoint(24, 25, 26)])
            ]));
        var log = new FakeComLog();
        var scene = new FakeScene(log);
        var activator = new FakeActivator(
            log,
            new FakeEngine(log, new FakePlayer(log), scene));
        await using var dispatcher = new StaDispatcher(capacity: 2);

        await dispatcher.InvokeAsync(
            () =>
            {
                using var session = new DynamicK3dSession(activator);
                session.Connect(options);
                session.Prepare(cue, options.LayoutIndex);
                session.Disconnect();
                return true;
            },
            TimeSpan.FromSeconds(5),
            CancellationToken.None);

        Assert.Equal(
            new[]
            {
                "LoadScene:test-scene",
                "SetOutputChannelIndex:9",
                "SetSceneEffectType:10:7:4",
                "UseBackground:1",
                "SetBackgroundTexture:background.vrv",
                "SetBackgroundVideo:background.vrv:2:10",
                "BeginTransaction",
                "GetObject:headline",
                "SetValue:headline:value",
                "SetBackgroundVideo:background.vrv:3:1",
                "GetObject:badge",
                "SetValue:badge:badge.png",
                "GetObject:badge",
                "SetVisible:badge:0",
                "GetObject:headline",
                "SetFaceColor:headline:1:2:3:4",
                "GetObject:position",
                "SetPosition:position:1:2:3:7",
                "GetObject:position",
                "SetPositionKey:position:5:6:7:8:2",
                "GetObject:scale",
                "SetScale:scale:9:10:11:1",
                "GetObject:crop",
                "SetCropKey:crop:1:12:13:14:15:10",
                "GetObject:circle",
                "SetCircleAngleKey:circle:2:16:17:3",
                "GetObject:path",
                "BeginPathPoint:path",
                "ClearPathPoints:path",
                "AddPathPoint:path:18:19:20",
                "AddPathPoint:path:21:22:23",
                "EndPathPoint:path",
                "GetObject:shape",
                "BeginPathShapePoint:shape",
                "ClearPathShapePoints:shape",
                "AddPathShapePoint:shape:24:25:26",
                "EndPathShapePoint:shape",
                "QueryVariables",
                "EndTransactionOnChannel:9",
                "Prepare:10"
            },
            log.Names.Where(name =>
                name.StartsWith("LoadScene:", StringComparison.Ordinal) ||
                name.StartsWith("Set", StringComparison.Ordinal) ||
                name.StartsWith("Use", StringComparison.Ordinal) ||
                name.StartsWith("Begin", StringComparison.Ordinal) ||
                name.StartsWith("Clear", StringComparison.Ordinal) ||
                name.StartsWith("Add", StringComparison.Ordinal) ||
                name.StartsWith("End", StringComparison.Ordinal) ||
                name.StartsWith("GetObject:", StringComparison.Ordinal) ||
                name == "QueryVariables" || name == "Prepare:10"));
    }

    [Fact]
    public async Task UpdateOnAir_ReusesRetainedSceneAndIgnoresInvalidPlayingSceneProxy()
    {
        using var scenes = TemporarySceneDirectory.Create(
            "test-scene.t2s",
            "Video\\background.vrv");
        var options = ValidatedPlayoutOptions.Create(TestOptions(scenes.Path));
        var initialCue = SceneCue(options, "test-scene");
        var updateCue = options.ResolveCue(new PlayoutCue(
            "test-scene.t2s",
            "test-scene",
            [new PlayoutField("headline", "page two", true)],
            FadeDuration: 19,
            Mutations:
            [
                new PlayoutUseBackground(false),
                new PlayoutSetBackgroundTexture("Video/background.vrv"),
                new PlayoutSetBackgroundVideo("Video/background.vrv", 4, 10),
                new PlayoutSetBackgroundVideo(
                    "Video/background.vrv",
                    8,
                    1,
                    PlayoutMutationTiming.InTransaction),
                new PlayoutSetVisible("badge", false)
            ]));
        var log = new FakeComLog();
        var scene = new FakeScene(log, "test-scene");
        var player = new FakePlayer(log) { PlayingScene = new object() };
        var activator = new FakeActivator(log, new FakeEngine(log, player, scene));
        await using var dispatcher = new StaDispatcher(capacity: 2);
        var updateStart = 0;
        var updateEnd = 0;

        await dispatcher.InvokeAsync(
            () =>
            {
                using var session = new DynamicK3dSession(activator);
                session.Connect(options);
                session.Prepare(initialCue, options.LayoutIndex);
                session.Play(options.LayoutIndex);

                updateStart = log.Names.Length;
                session.UpdateOnAir(updateCue, options.LayoutIndex);
                updateEnd = log.Names.Length;
                session.Disconnect();
                return true;
            },
            TimeSpan.FromSeconds(5),
            CancellationToken.None);

        var updateCalls = log.Names
            .Skip(updateStart)
            .Take(updateEnd - updateStart)
            .ToArray();
        Assert.Equal(
            new[]
            {
                "UseBackground:0",
                "SetBackgroundTexture:background.vrv",
                "SetBackgroundVideo:background.vrv:4:10",
                "BeginTransaction",
                "GetObject:headline",
                "SetValue:headline:page two",
                "SetVisible:headline:1",
                "SetBackgroundVideo:background.vrv:8:1",
                "GetObject:badge",
                "SetVisible:badge:0",
                "QueryVariables",
                "EndTransactionOnChannel:9",
                "Prepare:10",
                "Play:10"
            },
            updateCalls);
        Assert.Equal(1, updateCalls.Count(call => call == "Play:10"));
        Assert.DoesNotContain("GetPlayingScene:10", log.Names);
        Assert.DoesNotContain(updateCalls, call =>
            call.StartsWith("LoadScene:", StringComparison.Ordinal) ||
            call.StartsWith("SetSceneEffectType:", StringComparison.Ordinal));
    }

    [Fact]
    public async Task UpdateOnAir_MismatchedRetainedSceneFailsBeforeAnySdkMutation()
    {
        using var scenes = TemporarySceneDirectory.Create(
            "5001.t2s",
            "5006.t2s",
            "Video\\background.vrv");
        var options = SceneOptions(scenes.Path, "5001", "5006");
        var initialCue = SceneCue(options, "5001");
        var mismatchedCue = options.ResolveCue(new PlayoutCue(
            "5006.t2s",
            "5006",
            [new PlayoutField("headline", "must not be sent", true)],
            Mutations:
            [
                new PlayoutUseBackground(false),
                new PlayoutSetBackgroundTexture("Video/background.vrv")
            ]));
        var log = new FakeComLog();
        var retainedScene = new FakeScene(log, "5001");
        var player = new FakePlayer(log) { PlayingScene = new object() };
        var activator = new FakeActivator(
            log,
            new FakeEngine(log, player, retainedScene));
        await using var dispatcher = new StaDispatcher(capacity: 2);
        string[] updateCalls = [];

        await dispatcher.InvokeAsync(
            () =>
            {
                using var session = new DynamicK3dSession(activator);
                session.Connect(options);
                session.Prepare(initialCue, options.LayoutIndex);
                session.Play(options.LayoutIndex);

                var updateStart = log.Names.Length;
                Assert.Throws<InvalidOperationException>(
                    () => session.UpdateOnAir(mismatchedCue, options.LayoutIndex));
                updateCalls = log.Names.Skip(updateStart).ToArray();
                session.Disconnect();
                return true;
            },
            TimeSpan.FromSeconds(5),
            CancellationToken.None);

        Assert.Empty(updateCalls);
        Assert.DoesNotContain("GetPlayingScene:10", log.Names);
    }

    [Fact]
    public async Task UpdateOnAir_WithoutRetainedCurrentSceneFailsBeforeAnySdkMutation()
    {
        using var scenes = TemporarySceneDirectory.Create("5001.t2s");
        var options = SceneOptions(scenes.Path, "5001");
        var cue = SceneCue(options, "5001");
        var log = new FakeComLog();
        var player = new FakePlayer(log) { PlayingScene = new object() };
        var scene = new FakeScene(log, "5001");
        var activator = new FakeActivator(log, new FakeEngine(log, player, scene));
        await using var dispatcher = new StaDispatcher(capacity: 2);
        string[] updateCalls = [];

        await dispatcher.InvokeAsync(
            () =>
            {
                using var session = new DynamicK3dSession(activator);
                session.Connect(options);

                var updateStart = log.Names.Length;
                Assert.Throws<InvalidOperationException>(
                    () => session.UpdateOnAir(cue, options.LayoutIndex));
                updateCalls = log.Names.Skip(updateStart).ToArray();
                session.Disconnect();
                return true;
            },
            TimeSpan.FromSeconds(5),
            CancellationToken.None);

        Assert.Empty(updateCalls);
        Assert.DoesNotContain("GetPlayingScene:10", log.Names);
    }

    [Fact]
    public async Task UpdateOnAir_WithAnotherPreparedSceneFailsBeforeAnySdkMutation()
    {
        using var scenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s");
        var options = SceneOptions(scenes.Path, "5001", "5006");
        var cue5001 = SceneCue(options, "5001");
        var log = new FakeComLog();
        var player = new FakePlayer(log) { PlayingScene = new object() };
        var scene5001 = new FakeScene(log, "5001");
        var scene5006 = new FakeScene(log, "5006");
        var engine = new FakeEngine(log, player, scene5001)
        {
            SceneResolver = name => name == "5006" ? scene5006 : scene5001
        };
        var activator = new FakeActivator(log, engine);
        await using var dispatcher = new StaDispatcher(capacity: 2);
        string[] updateCalls = [];

        await dispatcher.InvokeAsync(
            () =>
            {
                using var session = new DynamicK3dSession(activator);
                session.Connect(options);
                session.Prepare(cue5001, options.LayoutIndex);
                session.Play(options.LayoutIndex);
                session.Prepare(SceneCue(options, "5006"), options.LayoutIndex);

                var updateStart = log.Names.Length;
                Assert.Throws<InvalidOperationException>(
                    () => session.UpdateOnAir(cue5001, options.LayoutIndex));
                updateCalls = log.Names.Skip(updateStart).ToArray();
                session.Disconnect();
                return true;
            },
            TimeSpan.FromSeconds(5),
            CancellationToken.None);

        Assert.Empty(updateCalls);
        Assert.DoesNotContain("GetPlayingScene:10", log.Names);
    }

    [Fact]
    public async Task UpdateOnAir_WithPendingInitialPlayFailsBeforeAnySdkMutation()
    {
        using var scenes = TemporarySceneDirectory.Create("5001.t2s");
        var options = SceneOptions(scenes.Path, "5001");
        var cue = SceneCue(options, "5001");
        var log = new FakeComLog();
        var scene = new FakeScene(log, "5001");
        var player = new FakePlayer(log) { PlayingScene = new object() };
        var engine = new FakeEngine(log, player, scene);
        var activator = new FakeActivator(log, engine);
        var callbacks = new RecordingEventHandlerFactory();
        var releaser = new FakeReleaser(
            log,
            (scene, "5001"),
            (player, "Player"),
            (engine, "Engine"),
            (callbacks.Handler, "EventHandler"));
        await using var dispatcher = new StaDispatcher(capacity: 2);
        string[] updateCalls = [];

        await dispatcher.InvokeAsync(
            () =>
            {
                using var session = new DynamicK3dSession(
                    activator,
                    releaser,
                    new InstalledK3dInteropMethodInvoker(),
                    callbacks);
                session.Connect(options);
                session.Prepare(cue, options.LayoutIndex);
                session.Play(options.LayoutIndex);
                Assert.True(session.HasPendingPlayCallbacks);

                var updateStart = log.Names.Length;
                Assert.Throws<InvalidOperationException>(
                    () => session.UpdateOnAir(cue, options.LayoutIndex));
                updateCalls = log.Names.Skip(updateStart).ToArray();

                callbacks.Fire("OnScenePlayed", 1, 9, options.LayoutIndex);
                session.ProcessPendingCallbacks(options.LayoutIndex);
                Assert.False(session.HasPendingPlayCallbacks);
                session.Disconnect();
                return true;
            },
            TimeSpan.FromSeconds(5),
            CancellationToken.None);

        Assert.Empty(updateCalls);
        Assert.DoesNotContain("GetPlayingScene:10", log.Names);
        Assert.Equal(1, log.Names.Count(name => name == "Release:5001"));
    }

    [Fact]
    public async Task UpdateOnAir_TransactionFailureRollsBackAndRetainsSceneForStopAllCleanup()
    {
        using var scenes = TemporarySceneDirectory.Create("5001.t2s");
        var options = SceneOptions(scenes.Path, "5001");
        var initialCue = SceneCue(options, "5001");
        var updateCue = options.ResolveCue(new PlayoutCue(
            "5001.t2s",
            "5001",
            [new PlayoutField("headline", "refresh", true)],
            Mutations: [new PlayoutUseBackground(false)]));
        var log = new FakeComLog();
        var scene = new FakeScene(log, "5001");
        var player = new FakePlayer(log) { PlayingScene = new object() };
        var engine = new FakeEngine(log, player, scene);
        var activator = new FakeActivator(log, engine);
        var callbacks = new RecordingEventHandlerFactory();
        var releaser = new FakeReleaser(
            log,
            (scene, "5001"),
            (player, "Player"),
            (engine, "Engine"),
            (callbacks.Handler, "EventHandler"));
        await using var dispatcher = new StaDispatcher(capacity: 2);

        await dispatcher.InvokeAsync(
            () =>
            {
                using var session = new DynamicK3dSession(
                    activator,
                    releaser,
                    new InstalledK3dInteropMethodInvoker(),
                    callbacks);
                session.Connect(options);
                session.Prepare(initialCue, options.LayoutIndex);
                session.Play(options.LayoutIndex);
                callbacks.Fire("OnScenePlayed", 1, 9, options.LayoutIndex);
                session.ProcessPendingCallbacks(options.LayoutIndex);

                scene.QueryVariablesFailure = new InvalidOperationException("fake refresh failure");
                var updateStart = log.Names.Length;
                Assert.Throws<InvalidOperationException>(
                    () => session.UpdateOnAir(updateCue, options.LayoutIndex));
                var updateCalls = log.Names.Skip(updateStart).ToArray();
                Assert.Equal(
                    new[]
                    {
                        "UseBackground:0",
                        "BeginTransaction",
                        "GetObject:headline",
                        "SetValue:headline:refresh",
                        "SetVisible:headline:1",
                        "QueryVariables",
                        "RollbackTransaction"
                    },
                    updateCalls);
                Assert.DoesNotContain("Release:5001", log.Names);
                Assert.DoesNotContain("Unload:5001", log.Names);

                scene.QueryVariablesFailure = null;
                session.TakeOut(options.LayoutIndex, PlayoutTakeOutScope.All);
                Assert.True(session.HasPendingTakeOutCallbacks);
                callbacks.Fire("OnStopAll", 1);
                Assert.Equal(
                    1,
                    session.ProcessPendingCallbacks(options.LayoutIndex).UnloadedSceneCount);
                session.Disconnect();
                return true;
            },
            TimeSpan.FromSeconds(5),
            CancellationToken.None);

        Assert.Equal(1, log.Names.Count(name => name == "Unload:5001"));
        Assert.Equal(1, log.Names.Count(name => name == "Release:5001"));
        Assert.DoesNotContain("GetPlayingScene:10", log.Names);
    }

    [Fact]
    public async Task UpdateOnAir_CallbackSession_TracksPlayAndRetainsSceneUntilStopAll()
    {
        using var scenes = TemporarySceneDirectory.Create("5001.t2s");
        var options = SceneOptions(scenes.Path, "5001");
        var cue = SceneCue(options, "5001");
        var log = new FakeComLog();
        var scene = new FakeScene(log, "5001");
        var player = new FakePlayer(log) { PlayingScene = new object() };
        var engine = new FakeEngine(log, player, scene);
        var activator = new FakeActivator(log, engine);
        var callbacks = new RecordingEventHandlerFactory();
        var releaser = new FakeReleaser(
            log,
            (scene, "5001"),
            (player, "Player"),
            (engine, "Engine"),
            (callbacks.Handler, "EventHandler"));
        await using var dispatcher = new StaDispatcher(capacity: 2);

        await dispatcher.InvokeAsync(
            () =>
            {
                using var session = new DynamicK3dSession(
                    activator,
                    releaser,
                    new InstalledK3dInteropMethodInvoker(),
                    callbacks);
                session.Connect(options);
                session.Prepare(cue, options.LayoutIndex);
                session.Play(options.LayoutIndex);
                callbacks.Fire("OnScenePlayed", 1, 8, 10);
                session.ProcessPendingCallbacks(options.LayoutIndex);
                Assert.True(session.HasPendingLifecycleCallbacks);

                callbacks.Fire("OnScenePlayed", 1, 9, 10);
                session.ProcessPendingCallbacks(options.LayoutIndex);
                Assert.False(session.HasPendingLifecycleCallbacks);

                session.UpdateOnAir(cue, options.LayoutIndex);
                Assert.True(session.HasPendingLifecycleCallbacks);
                Assert.DoesNotContain("Release:5001", log.Names);
                Assert.DoesNotContain("Unload:5001", log.Names);

                callbacks.Fire("OnScenePlayed", 1, 8, 10);
                session.ProcessPendingCallbacks(options.LayoutIndex);
                Assert.True(session.HasPendingLifecycleCallbacks);

                callbacks.Fire("OnScenePlayed", 1, 9, 10);
                session.ProcessPendingCallbacks(options.LayoutIndex);
                Assert.False(session.HasPendingLifecycleCallbacks);

                session.TakeOut(options.LayoutIndex, PlayoutTakeOutScope.All);
                callbacks.Fire("OnStopAll", 1);
                Assert.Equal(
                    1,
                    session.ProcessPendingCallbacks(options.LayoutIndex).UnloadedSceneCount);
                Assert.False(session.HasPendingLifecycleCallbacks);
                Assert.False(session.HasPendingTakeOutCallbacks);
                session.Disconnect();
                return true;
            },
            TimeSpan.FromSeconds(5),
            CancellationToken.None);

        Assert.Equal(2, log.Names.Count(name => name == "Play:10"));
        Assert.DoesNotContain("GetPlayingScene:10", log.Names);
        Assert.Equal(1, log.Names.Count(name => name == "Unload:5001"));
        Assert.Equal(1, log.Names.Count(name => name == "Release:5001"));
    }

    [Fact]
    public async Task Prepare_SamePreparedScene_ReusesSceneWithoutSecondLoad()
    {
        using var scenes = TemporarySceneDirectory.Create("5001.t2s");
        var options = SceneOptions(scenes.Path, "5001");
        var log = new FakeComLog();
        var player = new FakePlayer(log);
        var scene = new FakeScene(log, "5001");
        var engine = new FakeEngine(log, player, scene);
        var activator = new FakeActivator(log, engine);
        var releaser = new FakeReleaser(
            log,
            (scene, "5001"),
            (player, "Player"),
            (engine, "Engine"),
            (activator.EventHandler, "EventHandler"));
        await using var dispatcher = new StaDispatcher(capacity: 2);

        await dispatcher.InvokeAsync(
            () =>
            {
                using var session = new DynamicK3dSession(activator, releaser);
                session.Connect(options);
                session.Prepare(SceneCue(options, "5001"), options.LayoutIndex);
                session.Prepare(SceneCue(options, "5001"), options.LayoutIndex);
                session.Disconnect();
                return true;
            },
            TimeSpan.FromSeconds(5),
            CancellationToken.None);

        Assert.Equal(1, log.Names.Count(name => name == "LoadScene:5001"));
        Assert.Equal(2, log.Names.Count(name => name == "Prepare:10"));
        Assert.DoesNotContain("Unload:5001", log.Names);
        Assert.Equal(1, log.Names.Count(name => name == "Release:5001"));
    }

    [Fact]
    public async Task LifecycleCallbacks_UnloadOnlyRetiredScenesAfterMatchingSuccess()
    {
        using var scenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s");
        var options = SceneOptions(scenes.Path, "5001", "5006");
        var log = new FakeComLog();
        var player = new FakePlayer(log);
        var scene5001 = new FakeScene(log, "5001");
        var scene5006 = new FakeScene(log, "5006");
        var engine = new FakeEngine(log, player, scene5001)
        {
            SceneResolver = name => name == "5006" ? scene5006 : scene5001
        };
        var activator = new FakeActivator(log, engine);
        var callbacks = new RecordingEventHandlerFactory();
        var releaser = new FakeReleaser(
            log,
            (scene5001, "5001"),
            (scene5006, "5006"),
            (player, "Player"),
            (engine, "Engine"),
            (callbacks.Handler, "EventHandler"));
        await using var dispatcher = new StaDispatcher(capacity: 3);

        await dispatcher.InvokeAsync(
            () =>
            {
                using var session = new DynamicK3dSession(
                    activator,
                    releaser,
                    new InstalledK3dInteropMethodInvoker(),
                    callbacks);
                session.Connect(options);
                Assert.True(session.SupportsLifecycleCallbacks);
                Assert.False(session.KtapHelloObserved);

                callbacks.Fire("OnHello");
                session.Prepare(SceneCue(options, "5001"), options.LayoutIndex);
                session.Play(options.LayoutIndex);
                callbacks.Fire("OnScenePlayed", 1, 9, 10);
                var first = session.ProcessPendingCallbacks(options.LayoutIndex);
                Assert.True(first.HelloObserved);
                Assert.Equal(0, first.UnloadedSceneCount);

                session.Prepare(SceneCue(options, "5006"), options.LayoutIndex);
                session.Play(options.LayoutIndex);
                Assert.DoesNotContain("Unload:5001", log.Names);

                callbacks.Fire("OnScenePlayed", 1, 8, 10);
                Assert.Equal(
                    0,
                    session.ProcessPendingCallbacks(options.LayoutIndex).UnloadedSceneCount);
                Assert.DoesNotContain("Unload:5001", log.Names);

                callbacks.Fire("OnScenePlayed", 1, 9, 10);
                Assert.Equal(
                    1,
                    session.ProcessPendingCallbacks(options.LayoutIndex).UnloadedSceneCount);
                Assert.Contains("Unload:5001", log.Names);
                Assert.Contains("Release:5001", log.Names);

                session.TakeOut(options.LayoutIndex, PlayoutTakeOutScope.All);
                Assert.True(session.HasPendingLifecycleCallbacks);
                callbacks.Fire("OnScenePlayed", 1, 9, 10);
                Assert.Equal(
                    0,
                    session.ProcessPendingCallbacks(options.LayoutIndex).UnloadedSceneCount);
                Assert.DoesNotContain("Unload:5006", log.Names);

                callbacks.Fire("OnStopAll", 1);
                Assert.Equal(
                    1,
                    session.ProcessPendingCallbacks(options.LayoutIndex).UnloadedSceneCount);
                Assert.Contains("Unload:5006", log.Names);
                Assert.Contains("Release:5006", log.Names);
                Assert.False(session.HasPendingLifecycleCallbacks);
                session.Disconnect();
                return true;
            },
            TimeSpan.FromSeconds(5),
            CancellationToken.None);

        Assert.Equal(1, log.Names.Count(name => name == "Unload:5001"));
        Assert.Equal(1, log.Names.Count(name => name == "Unload:5006"));
    }

    [Fact]
    public async Task LifecycleCallbacks_CurrentSameNamePrepare_ReusesActiveSceneUntilStopAll()
    {
        using var scenes = TemporarySceneDirectory.Create("5001.t2s");
        var options = SceneOptions(scenes.Path, "5001");
        var log = new FakeComLog();
        var player = new FakePlayer(log);
        var activeScene = new FakeScene(log, "5001");
        var engine = new FakeEngine(log, player, activeScene);
        var activator = new FakeActivator(log, engine);
        var callbacks = new RecordingEventHandlerFactory();
        var releaser = new FakeReleaser(
            log,
            (activeScene, "5001"),
            (player, "Player"),
            (engine, "Engine"),
            (callbacks.Handler, "EventHandler"));
        await using var dispatcher = new StaDispatcher(capacity: 3);

        await dispatcher.InvokeAsync(
            () =>
            {
                using var session = new DynamicK3dSession(
                    activator,
                    releaser,
                    new InstalledK3dInteropMethodInvoker(),
                    callbacks);
                session.Connect(options);

                session.Prepare(SceneCue(options, "5001"), options.LayoutIndex);
                session.Play(options.LayoutIndex);
                callbacks.Fire("OnScenePlayed", 1, 9, options.LayoutIndex);
                Assert.Equal(
                    0,
                    session.ProcessPendingCallbacks(options.LayoutIndex).UnloadedSceneCount);

                session.Prepare(SceneCue(options, "5001"), options.LayoutIndex);
                session.Play(options.LayoutIndex);
                callbacks.Fire("OnScenePlayed", 1, 9, options.LayoutIndex);
                Assert.Equal(
                    0,
                    session.ProcessPendingCallbacks(options.LayoutIndex).UnloadedSceneCount);
                Assert.DoesNotContain("Unload:5001", log.Names);
                Assert.DoesNotContain("Release:5001", log.Names);
                Assert.Equal(1, log.Names.Count(name => name == "LoadScene:5001"));

                session.TakeOut(options.LayoutIndex, PlayoutTakeOutScope.All);
                callbacks.Fire("OnStopAll", 1);
                Assert.Equal(
                    1,
                    session.ProcessPendingCallbacks(options.LayoutIndex).UnloadedSceneCount);
                Assert.Equal(1, log.Names.Count(name => name == "Unload:5001"));
                Assert.Contains("Release:5001", log.Names);
                session.Disconnect();
                return true;
            },
            TimeSpan.FromSeconds(5),
            CancellationToken.None);

        Assert.Equal(1, log.Names.Count(name => name == "LoadScene:5001"));
        Assert.Equal(1, log.Names.Count(name => name == "Unload:5001"));
        Assert.Equal(1, log.Names.Count(name => name == "Release:5001"));
    }

    [Fact]
    public async Task Prepare_CurrentSceneReuse_IgnoresSceneNameCasing()
    {
        using var scenes = TemporarySceneDirectory.Create("SceneA.t2s");
        var options = SceneOptions(scenes.Path, "SceneA");
        var log = new FakeComLog();
        var player = new FakePlayer(log);
        var activeScene = new FakeScene(log, "SceneA");
        var engine = new FakeEngine(log, player, activeScene);
        var activator = new FakeActivator(log, engine);
        var callbacks = new RecordingEventHandlerFactory();
        var releaser = new FakeReleaser(
            log,
            (activeScene, "SceneA"),
            (player, "Player"),
            (engine, "Engine"),
            (callbacks.Handler, "EventHandler"));
        await using var dispatcher = new StaDispatcher(capacity: 3);

        await dispatcher.InvokeAsync(
            () =>
            {
                using var session = new DynamicK3dSession(
                    activator,
                    releaser,
                    new InstalledK3dInteropMethodInvoker(),
                    callbacks);
                session.Connect(options);

                session.Prepare(SceneCue(options, "SceneA"), options.LayoutIndex);
                session.Play(options.LayoutIndex);
                callbacks.Fire("OnScenePlayed", 1, 9, options.LayoutIndex);
                session.ProcessPendingCallbacks(options.LayoutIndex);

                session.Prepare(SceneCue(options, "scenea"), options.LayoutIndex);
                session.Play(options.LayoutIndex);
                callbacks.Fire("OnScenePlayed", 1, 9, options.LayoutIndex);
                Assert.Equal(
                    0,
                    session.ProcessPendingCallbacks(options.LayoutIndex).UnloadedSceneCount);
                Assert.DoesNotContain("Unload:SceneA", log.Names);
                Assert.DoesNotContain("Release:SceneA", log.Names);
                Assert.Equal(1, log.Names.Count(name => name == "LoadScene:SceneA"));
                Assert.DoesNotContain("LoadScene:scenea", log.Names);

                session.TakeOut(options.LayoutIndex, PlayoutTakeOutScope.All);
                callbacks.Fire("OnStopAll", 1);
                Assert.Equal(
                    1,
                    session.ProcessPendingCallbacks(options.LayoutIndex).UnloadedSceneCount);
                session.Disconnect();
                return true;
            },
            TimeSpan.FromSeconds(5),
            CancellationToken.None);

        Assert.Equal(1, log.Names.Count(name => name == "LoadScene:SceneA"));
        Assert.DoesNotContain("LoadScene:scenea", log.Names);
        Assert.Equal(1, log.Names.Count(name => name == "Unload:SceneA"));
        Assert.Equal(1, log.Names.Count(name => name == "Release:SceneA"));
    }

    [Fact]
    public async Task StopAllSuccess_ClearsInterruptedPlayBeforeDisconnect()
    {
        using var scenes = TemporarySceneDirectory.Create("5001.t2s");
        var options = SceneOptions(scenes.Path, "5001");
        var log = new FakeComLog();
        var scene = new FakeScene(log, "5001");
        var player = new FakePlayer(log);
        var engine = new FakeEngine(log, player, scene);
        var activator = new FakeActivator(log, engine);
        var callbacks = new RecordingEventHandlerFactory();
        var releaser = new FakeReleaser(
            log,
            (scene, "5001"),
            (player, "Player"),
            (engine, "Engine"),
            (callbacks.Handler, "EventHandler"));
        await using var dispatcher = new StaDispatcher(capacity: 2);

        await dispatcher.InvokeAsync(
            () =>
            {
                using var session = new DynamicK3dSession(
                    activator,
                    releaser,
                    new InstalledK3dInteropMethodInvoker(),
                    callbacks);
                session.Connect(options);
                session.Prepare(SceneCue(options, "5001"), options.LayoutIndex);
                session.Play(options.LayoutIndex);
                Assert.True(session.HasPendingPlayCallbacks);

                session.TakeOut(options.LayoutIndex, PlayoutTakeOutScope.All);
                callbacks.Fire("OnStopAll", 1);
                var drained = session.ProcessPendingCallbacks(options.LayoutIndex);

                Assert.Equal(1, drained.UnloadedSceneCount);
                Assert.False(session.HasPendingPlayCallbacks);
                Assert.False(session.HasPendingLifecycleCallbacks);
                session.Disconnect();
                return true;
            },
            TimeSpan.FromSeconds(5),
            CancellationToken.None);

        Assert.Contains("StopAll", log.Names);
        Assert.Contains("Unload:5001", log.Names);
        Assert.Contains("Disconnect", log.Names);
        Assert.Equal(1, log.Names.Count(name => name == "Unload:5001"));
    }

    [Fact]
    public async Task CutOutSuccess_ClearsOnlyAfterMatchingOutputBeforeDisconnect()
    {
        using var scenes = TemporarySceneDirectory.Create("5001.t2s");
        var options = SceneOptions(scenes.Path, "5001");
        var log = new FakeComLog();
        var scene = new FakeScene(log, "5001");
        var player = new FakePlayer(log);
        var engine = new FakeEngine(log, player, scene);
        var activator = new FakeActivator(log, engine);
        var callbacks = new RecordingEventHandlerFactory();
        var releaser = new FakeReleaser(
            log,
            (scene, "5001"),
            (player, "Player"),
            (engine, "Engine"),
            (callbacks.Handler, "EventHandler"));
        await using var dispatcher = new StaDispatcher(capacity: 2);

        await dispatcher.InvokeAsync(
            () =>
            {
                using var session = new DynamicK3dSession(
                    activator,
                    releaser,
                    new InstalledK3dInteropMethodInvoker(),
                    callbacks);
                session.Connect(options);
                session.Prepare(SceneCue(options, "5001"), options.LayoutIndex);
                session.Play(options.LayoutIndex);
                session.TakeOut(options.LayoutIndex, PlayoutTakeOutScope.Layout);

                callbacks.Fire("OnCutOut", 1, 8, options.LayoutIndex);
                Assert.Equal(
                    0,
                    session.ProcessPendingCallbacks(options.LayoutIndex).UnloadedSceneCount);
                Assert.True(session.HasPendingPlayCallbacks);
                Assert.True(session.HasPendingLifecycleCallbacks);
                Assert.DoesNotContain("Unload:5001", log.Names);

                callbacks.Fire("OnCutOut", 1, 9, options.LayoutIndex);
                Assert.Equal(
                    1,
                    session.ProcessPendingCallbacks(options.LayoutIndex).UnloadedSceneCount);
                Assert.False(session.HasPendingPlayCallbacks);
                Assert.False(session.HasPendingLifecycleCallbacks);
                session.Disconnect();
                return true;
            },
            TimeSpan.FromSeconds(5),
            CancellationToken.None);

        Assert.Contains("CutOut:10", log.Names);
        Assert.Contains("Unload:5001", log.Names);
        Assert.Contains("Disconnect", log.Names);
        Assert.Equal(1, log.Names.Count(name => name == "Unload:5001"));
    }

    [Fact]
    public async Task LifecycleCallbackFailure_DoesNotUnloadAndFailsClosed()
    {
        using var scenes = TemporarySceneDirectory.Create("5001.t2s");
        var options = SceneOptions(scenes.Path, "5001");
        var log = new FakeComLog();
        var player = new FakePlayer(log);
        var scene = new FakeScene(log, "5001");
        var engine = new FakeEngine(log, player, scene);
        var activator = new FakeActivator(log, engine);
        var callbacks = new RecordingEventHandlerFactory();
        var releaser = new FakeReleaser(
            log,
            (scene, "5001"),
            (player, "Player"),
            (engine, "Engine"),
            (callbacks.Handler, "EventHandler"));
        await using var dispatcher = new StaDispatcher(capacity: 2);

        await dispatcher.InvokeAsync(
            () =>
            {
                using var session = new DynamicK3dSession(
                    activator,
                    releaser,
                    new InstalledK3dInteropMethodInvoker(),
                    callbacks);
                session.Connect(options);
                session.Prepare(SceneCue(options, "5001"), options.LayoutIndex);
                session.Play(options.LayoutIndex);
                session.TakeOut(options.LayoutIndex, PlayoutTakeOutScope.All);
                callbacks.Fire("OnStopAll", 0);

                Assert.Throws<InvalidOperationException>(
                    () => session.ProcessPendingCallbacks(options.LayoutIndex));
                Assert.DoesNotContain("Unload:5001", log.Names);
                session.Abandon();
                return true;
            },
            TimeSpan.FromSeconds(5),
            CancellationToken.None);
    }

    [Fact]
    public async Task Prepare_WithoutOutputChannel_UsesDefaultPlayerAndDoesNotSetSceneChannel()
    {
        using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
        var unchanneledOptions = TestOptions(scenes.Path);
        unchanneledOptions.Mode = PlayoutMode.Live;
        unchanneledOptions.OutputChannel = null;
        var options = ValidatedPlayoutOptions.Create(unchanneledOptions);
        var cue = options.ResolveCue(new PlayoutCue(
            "test-scene.t2s",
            "test-scene",
            [],
            FadeDuration: 12));
        var log = new FakeComLog();
        var player = new FakePlayer(log);
        var scene = new FakeScene(log);
        var engine = new FakeEngine(log, player, scene);
        var activator = new FakeActivator(log, engine);
        await using var dispatcher = new StaDispatcher(capacity: 2);

        await dispatcher.InvokeAsync(
            () =>
            {
                using var session = new DynamicK3dSession(activator);
                session.Connect(options);
                session.Prepare(cue, options.LayoutIndex);
                session.Disconnect();
                return true;
            },
            TimeSpan.FromSeconds(5),
            CancellationToken.None);

        Assert.Equal(
            new[]
            {
                $"Create:{K3dComConstants.KaEventHandlerClassGuid:B}",
                $"Create:{K3dComConstants.KaEngineClassGuid:B}",
                "KTAPConnect:1:127.0.0.1:30001:0",
                "GetScenePlayer",
                "LoadScene:test-scene",
                "SetSceneEffectType:10:7:12",
                "BeginTransaction",
                "QueryVariables",
                "EndTransaction",
                "Prepare:10",
                "Disconnect"
            },
            log.Names);
        Assert.DoesNotContain(log.Names, name => name.StartsWith("SetOutputChannelIndex:", StringComparison.Ordinal));
    }

    [Fact]
    public async Task SceneReplacement_5001To5006_RetainsAllScenesUntilDisconnect()
    {
        using var scenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s");
        var options = SceneOptions(scenes.Path, "5001", "5006");
        var cue5001 = SceneCue(options, "5001");
        var cue5006 = SceneCue(options, "5006");
        var log = new FakeComLog();
        var player = new FakePlayer(log);
        var scene5001 = new FakeScene(log, "5001");
        var scene5006 = new FakeScene(log, "5006");
        var engine = new FakeEngine(log, player, scene5001)
        {
            SceneResolver = name => name == "5006" ? scene5006 : scene5001
        };
        var activator = new FakeActivator(log, engine);
        var releaser = new FakeReleaser(
            log,
            (scene5001, "5001"),
            (scene5006, "5006"),
            (player, "Player"),
            (engine, "Engine"),
            (activator.EventHandler, "EventHandler"));
        await using var dispatcher = new StaDispatcher(capacity: 4);

        await dispatcher.InvokeAsync(
            () =>
            {
                using var session = new DynamicK3dSession(activator, releaser);
                session.Connect(options);
                session.Prepare(cue5001, options.LayoutIndex);
                session.Play(options.LayoutIndex);
                session.Prepare(cue5006, options.LayoutIndex);
                Assert.DoesNotContain("Unload:5001", log.Names);
                session.Play(options.LayoutIndex);
                session.TakeOut(options.LayoutIndex, PlayoutTakeOutScope.All);
                Assert.DoesNotContain(log.Names, name =>
                    name.StartsWith("Release:500", StringComparison.Ordinal));
                session.Disconnect();
                return true;
            },
            TimeSpan.FromSeconds(5),
            CancellationToken.None);

        Assert.Equal(
            new[]
            {
                "LoadScene:5001",
                "Prepare:10",
                "Play:10",
                "LoadScene:5006",
                "Prepare:10",
                "Play:10",
                "StopAll",
                "Disconnect",
                "Release:5001",
                "Release:5006"
            },
            SceneLifecycleCalls(log));
    }

    [Fact]
    public async Task Prepare_ReplacingUnplayedScene_RetainsBothScenesUntilDisconnect()
    {
        using var scenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s");
        var options = SceneOptions(scenes.Path, "5001", "5006");
        var log = new FakeComLog();
        var player = new FakePlayer(log);
        var scene5001 = new FakeScene(log, "5001");
        var scene5006 = new FakeScene(log, "5006");
        var engine = new FakeEngine(log, player, scene5001)
        {
            SceneResolver = name => name == "5006" ? scene5006 : scene5001
        };
        var activator = new FakeActivator(log, engine);
        var releaser = new FakeReleaser(
            log,
            (scene5001, "5001"),
            (scene5006, "5006"),
            (player, "Player"),
            (engine, "Engine"),
            (activator.EventHandler, "EventHandler"));
        await using var dispatcher = new StaDispatcher(capacity: 2);

        await dispatcher.InvokeAsync(
            () =>
            {
                using var session = new DynamicK3dSession(activator, releaser);
                session.Connect(options);
                session.Prepare(SceneCue(options, "5001"), options.LayoutIndex);
                session.Prepare(SceneCue(options, "5006"), options.LayoutIndex);
                Assert.DoesNotContain(log.Names, name =>
                    name.StartsWith("Release:500", StringComparison.Ordinal));
                session.Disconnect();
                return true;
            },
            TimeSpan.FromSeconds(5),
            CancellationToken.None);

        Assert.Equal(
            new[]
            {
                "LoadScene:5001",
                "Prepare:10",
                "LoadScene:5006",
                "Prepare:10",
                "Disconnect",
                "Release:5006",
                "Release:5001"
            },
            SceneLifecycleCalls(log));
        Assert.DoesNotContain("Unload:5006", log.Names);
    }

    [Fact]
    public async Task Prepare_WhenReplacementFails_RetainsFailedAndOnAirScenesUntilDisconnect()
    {
        using var scenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s");
        var options = SceneOptions(scenes.Path, "5001", "5006");
        var originalFailure = new COMException("fake replacement failure");
        var log = new FakeComLog();
        var player = new FakePlayer(log);
        var scene5001 = new FakeScene(log, "5001");
        var scene5006 = new FakeScene(
            log,
            "5006",
            queryVariablesFailure: originalFailure);
        var engine = new FakeEngine(log, player, scene5001)
        {
            SceneResolver = name => name == "5006" ? scene5006 : scene5001
        };
        var activator = new FakeActivator(log, engine);
        var releaser = new FakeReleaser(
            log,
            (scene5001, "5001"),
            (scene5006, "5006"),
            (player, "Player"),
            (engine, "Engine"),
            (activator.EventHandler, "EventHandler"));
        await using var dispatcher = new StaDispatcher(capacity: 3);

        await dispatcher.InvokeAsync(
            () =>
            {
                using var session = new DynamicK3dSession(activator, releaser);
                session.Connect(options);
                session.Prepare(SceneCue(options, "5001"), options.LayoutIndex);
                session.Play(options.LayoutIndex);

                var exception = Assert.Throws<COMException>(
                    () => session.Prepare(SceneCue(options, "5006"), options.LayoutIndex));
                Assert.Same(originalFailure, exception);
                Assert.DoesNotContain("Unload:5001", log.Names);
                Assert.DoesNotContain(log.Names, name =>
                    name.StartsWith("Release:500", StringComparison.Ordinal));

                session.TakeOut(options.LayoutIndex, PlayoutTakeOutScope.All);
                Assert.DoesNotContain(log.Names, name =>
                    name.StartsWith("Release:500", StringComparison.Ordinal));
                session.Disconnect();
                return true;
            },
            TimeSpan.FromSeconds(5),
            CancellationToken.None);

        Assert.Equal(
            new[]
            {
                "LoadScene:5001",
                "Prepare:10",
                "Play:10",
                "LoadScene:5006",
                "RollbackTransaction",
                "StopAll",
                "Disconnect",
                "Release:5006",
                "Release:5001"
            },
            SceneLifecycleCalls(log));
    }

    [Fact]
    public async Task AsyncOutputSequence_DoesNotUnloadAndReleasesScenesOnlyAfterDisconnect()
    {
        using var scenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s");
        var options = SceneOptions(scenes.Path, "5001", "5006");
        var log = new FakeComLog();
        var player = new FakePlayer(log);
        var scene5001 = new FakeScene(log, "5001");
        var scene5006 = new FakeScene(log, "5006");
        var engine = new FakeEngine(log, player, scene5001)
        {
            SceneResolver = name => name == "5006" ? scene5006 : scene5001
        };
        var activator = new FakeActivator(log, engine);
        var releaser = new FakeReleaser(
            log,
            (scene5001, "5001"),
            (scene5006, "5006"),
            (player, "Player"),
            (engine, "Engine"),
            (activator.EventHandler, "EventHandler"));
        await using var dispatcher = new StaDispatcher(capacity: 4);

        await dispatcher.InvokeAsync(
            () =>
            {
                using var session = new DynamicK3dSession(activator, releaser);
                session.Connect(options);
                session.Prepare(SceneCue(options, "5001"), options.LayoutIndex);
                session.Play(options.LayoutIndex);
                session.Prepare(SceneCue(options, "5006"), options.LayoutIndex);
                session.Play(options.LayoutIndex);
                session.TakeOut(options.LayoutIndex, PlayoutTakeOutScope.All);
                Assert.DoesNotContain(log.Names, name =>
                    name.StartsWith("Release:500", StringComparison.Ordinal));
                session.Disconnect();
                return true;
            },
            TimeSpan.FromSeconds(5),
            CancellationToken.None);

        Assert.Equal(2, log.Names.Count(name => name == "Play:10"));
        Assert.Equal(1, log.Names.Count(name => name == "StopAll"));
        Assert.DoesNotContain(log.Names, name => name.StartsWith("Unload", StringComparison.Ordinal));
        Assert.Equal(1, log.Names.Count(name => name == "Release:5001"));
        Assert.Equal(1, log.Names.Count(name => name == "Release:5006"));
    }

    [Fact]
    public void LateBoundActivatorContract_AcceptsOnlyAClsid()
    {
        var create = typeof(ILateBoundComActivator).GetMethod(nameof(ILateBoundComActivator.Create));

        Assert.NotNull(create);
        Assert.Equal(typeof(Guid), Assert.Single(create.GetParameters()).ParameterType);
        Assert.Equal(typeof(object), create.ReturnType);
    }

    [Fact]
    public async Task Connect_WhenComActivationFails_ReportsKtapNotAttempted()
    {
        using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
        var options = ValidatedPlayoutOptions.Create(TestOptions(scenes.Path));
        var originalFailure = new InvalidOperationException("fake activation failure");
        var lastKtapConnectState = PlayoutKtapConnectState.Failed;
        await using var dispatcher = new StaDispatcher(capacity: 1);

        var exception = await Assert.ThrowsAsync<InvalidOperationException>(
            () => dispatcher.InvokeAsync(
                () =>
                {
                    using var session = new DynamicK3dSession(
                        new ThrowingActivator(originalFailure));
                    try
                    {
                        session.Connect(options);
                    }
                    finally
                    {
                        lastKtapConnectState = session.LastKtapConnectState;
                    }

                    return true;
                },
                TimeSpan.FromSeconds(5),
                CancellationToken.None));

        Assert.Same(originalFailure, exception);
        Assert.Equal(PlayoutKtapConnectState.NotAttempted, lastKtapConnectState);
    }

    [Fact]
    public async Task PreventKtapConnect_DuringActivation_BlocksLateDispatch()
    {
        using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
        using var activationStarted = new ManualResetEventSlim();
        using var releaseActivation = new ManualResetEventSlim();
        var options = ValidatedPlayoutOptions.Create(TestOptions(scenes.Path));
        var log = new FakeComLog();
        var engine = new FakeEngine(log, new FakePlayer(log), new FakeScene(log));
        var activator = new BlockingEngineActivator(
            log,
            engine,
            activationStarted,
            releaseActivation);
        var session = new DynamicK3dSession(activator);
        await using var dispatcher = new StaDispatcher(capacity: 1);

        try
        {
            var connectTask = dispatcher.InvokeAsync(
                () =>
                {
                    using (session)
                    {
                        session.Connect(options);
                    }

                    return true;
                },
                TimeSpan.FromSeconds(5),
                CancellationToken.None);
            Assert.True(activationStarted.Wait(TimeSpan.FromSeconds(2)));

            Assert.True(session.TryPreventKtapConnect());
            releaseActivation.Set();

            await Assert.ThrowsAsync<InvalidOperationException>(() => connectTask);
            Assert.Equal(
                PlayoutKtapConnectState.NotAttempted,
                session.LastKtapConnectState);
            Assert.DoesNotContain(log.Names, name =>
                name.StartsWith("KTAPConnect:", StringComparison.Ordinal));
        }
        finally
        {
            releaseActivation.Set();
        }
    }

    [Fact]
    public async Task Abandon_AfterUnknownPlayOutcome_ReleasesReferencesWithoutSdkCleanup()
    {
        using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
        var options = ValidatedPlayoutOptions.Create(TestOptions(scenes.Path));
        var cue = SceneCue(options, "test-scene");
        var playFailure = new COMException("fake ambiguous play failure");
        var log = new FakeComLog();
        var player = new FakePlayer(log, playFailure);
        var scene = new FakeScene(log);
        var engine = new FakeEngine(log, player, scene);
        var activator = new FakeActivator(log, engine);
        var releaser = new FakeReleaser(
            log,
            (scene, "Scene"),
            (player, "Player"),
            (engine, "Engine"),
            (activator.EventHandler, "EventHandler"));
        await using var dispatcher = new StaDispatcher(capacity: 1);

        await dispatcher.InvokeAsync(
            () =>
            {
                using var session = new DynamicK3dSession(activator, releaser);
                session.Connect(options);
                session.Prepare(cue, options.LayoutIndex);
                var exception = Assert.Throws<COMException>(
                    () => session.Play(options.LayoutIndex));
                Assert.Same(playFailure, exception);
                session.Abandon();
                return true;
            },
            TimeSpan.FromSeconds(5),
            CancellationToken.None);

        Assert.DoesNotContain("Disconnect", log.Names);
        Assert.DoesNotContain(log.Names, name => name.StartsWith("Unload", StringComparison.Ordinal));
        Assert.Equal(1, log.Names.Count(name => name == "Play:10"));
        Assert.Equal(1, log.Names.Count(name => name == "Release:Scene"));
        Assert.Equal(1, log.Names.Count(name => name == "Release:Player"));
        Assert.Equal(1, log.Names.Count(name => name == "Release:Engine"));
        Assert.Equal(1, log.Names.Count(name => name == "Release:EventHandler"));
    }

    [Theory]
    [InlineData("mutation")]
    [InlineData("query")]
    [InlineData("end")]
    public async Task GateA_PrepareStageFailureNeverRollsBackDisconnectsOrRetries(
        string failureStage)
    {
        const string capability =
            "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF";
        using var scenes = TemporarySceneDirectory.Create("5001.t2s");
        var rawOptions = new PlayoutOptions
        {
            Mode = PlayoutMode.Live,
            Host = "127.0.0.1",
            Port = 30001,
            TcpMode = 1,
            LayoutIndex = 10,
            SceneDirectory = scenes.Path,
            TestSceneAllowlist = ["5001"],
            TrustedLiveOutputEnabled = true,
            ReconnectEnabled = false,
            MaximumReconnectAttempts = 0,
            MaximumAutomaticRefreshesPerTakeIn = 0,
            LegacySceneFadeDuration = 6,
            ConnectTimeoutMilliseconds = 500,
            OperationTimeoutMilliseconds = 500,
            DisconnectTimeoutMilliseconds = 500,
            ProcessPollIntervalMilliseconds = 100
        };
        var options = ValidatedPlayoutOptions.Create(rawOptions);
        var failure = new COMException($"fake Gate A {failureStage} failure");
        var log = new FakeComLog();
        var scene = new FakeScene(log, "5001");
        var player = new FakePlayer(log);
        var vendorEngine = new FakeEngine(log, player, scene);
        switch (failureStage)
        {
            case "mutation":
                scene.GetObjectFailure = failure;
                break;
            case "query":
                scene.QueryVariablesFailure = failure;
                break;
            case "end":
                vendorEngine.EndTransactionFailure = failure;
                break;
            default:
                throw new InvalidOperationException("Unknown test failure stage.");
        }

        var activator = new FakeActivator(log, vendorEngine);
        var releaser = new FakeReleaser(
            log,
            (scene, "5001"),
            (player, "Player"),
            (vendorEngine, "Engine"),
            (activator.EventHandler, "EventHandler"));
        var sessionFactory = new DelegateK3dSessionFactory(() =>
            new DynamicK3dSession(
                activator,
                releaser,
                new InstalledK3dInteropMethodInvoker(),
                new ActivatorK3dEventHandlerFactory(activator),
                rollbackOnPrepareFailure: false));
        using var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(
            capability);
        var dispatcher = new StaDispatcher(options.QueueCapacity);
        var engine = new TornadoPlayoutEngine(
            options,
            sessionFactory,
            new FakeRegistrationProbe(),
            new FakeTornadoProcessProbe(
                ProcessSnapshot("approved-pgm"),
                ProcessSnapshot("approved-pgm")),
            dispatcher,
            authorization,
            TimeProvider.System,
            startMonitor: false,
            finalConnectSafetyCheck: () => true,
            abandonOnTargetMismatch: true,
            beforeSdkDispatchClaim: authorization.EnsureGateASdkDispatchAuthorized,
            launchAuthorization: authorization);

        try
        {
            Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
            Assert.True(authorization.TryClaimGateAPrepare(capability));
            var cue = new PlayoutCue(
                "5001.t2s",
                "5001",
                [new PlayoutField("headline", "test", true)],
                FadeDuration: 6,
                Mutations: [new PlayoutUseBackground(false)]);

            var result = await engine.PrepareAsync(cue, CancellationToken.None);
            var retry = await engine.PrepareAsync(cue, CancellationToken.None);

            Assert.Equal(PlayoutResultCode.OutcomeUnknown, result.Code);
            Assert.Equal(PlayoutResultCode.OutcomeUnknown, retry.Code);
        }
        finally
        {
            authorization.CompleteGateAPrepare();
            await engine.DisposeAsync();
        }

        Assert.Equal(1, log.Names.Count(name => name == "BeginTransaction"));
        Assert.DoesNotContain("RollbackTransaction", log.Names);
        Assert.DoesNotContain("Disconnect", log.Names);
        Assert.DoesNotContain("Prepare:10", log.Names);
        Assert.Equal(1, log.Names.Count(name => name == "Release:5001"));
        Assert.Equal(1, log.Names.Count(name => name == "Release:Player"));
        Assert.Equal(1, log.Names.Count(name => name == "Release:Engine"));
        Assert.Equal(1, log.Names.Count(name => name == "Release:EventHandler"));
    }

    private static TornadoProcessSnapshot ProcessSnapshot(string generation) =>
        new(1, 1, 0)
        {
            EligibleProcessGeneration = new TornadoProcessGeneration(generation)
        };

    private static PlayoutOptions TestOptions(string sceneDirectory) => new()
    {
        Mode = PlayoutMode.Test,
        SceneDirectory = sceneDirectory,
        OutputChannel = 9,
        TestProcessWindowTitlePattern = "^Tornado2 TEST$",
        TestSceneAllowlist = ["test-scene"]
    };

    private static ValidatedPlayoutOptions SceneOptions(
        string sceneDirectory,
        params string[] sceneNames)
    {
        var options = TestOptions(sceneDirectory);
        options.TestSceneAllowlist = [.. sceneNames];
        return ValidatedPlayoutOptions.Create(options);
    }

    private static PlayoutCue SceneCue(ValidatedPlayoutOptions options, string sceneName) =>
        options.ResolveCue(new PlayoutCue(
            $"{sceneName}.t2s",
            sceneName,
            [],
            FadeDuration: 0));

    private static string[] SceneLifecycleCalls(FakeComLog log) =>
        log.Names.Where(name =>
            name.StartsWith("LoadScene:", StringComparison.Ordinal) ||
            name == "Prepare:10" ||
            name == "Play:10" ||
            name == "StopAll" ||
            name == "CutOut:10" ||
            name == "Disconnect" ||
            name == "RollbackTransaction" ||
            name.StartsWith("Unload:", StringComparison.Ordinal) ||
            name.StartsWith("Release:500", StringComparison.Ordinal)).ToArray();

    private sealed class DelegateK3dSessionFactory(Func<IK3dSession> create)
        : IK3dSessionFactory
    {
        public IK3dSession Create() => create();
    }

    public sealed class FakeComLog
    {
        private readonly ConcurrentQueue<string> _names = new();
        private readonly ConcurrentQueue<int> _threadIds = new();
        private readonly ConcurrentQueue<ApartmentState> _apartmentStates = new();

        public string[] Names => _names.ToArray();

        public int[] ThreadIds => _threadIds.ToArray();

        public ApartmentState[] ApartmentStates => _apartmentStates.ToArray();

        public void Add(string name)
        {
            _names.Enqueue(name);
            _threadIds.Enqueue(Environment.CurrentManagedThreadId);
            _apartmentStates.Enqueue(Thread.CurrentThread.GetApartmentState());
        }
    }

    private sealed class FakeActivator : ILateBoundComActivator
    {
        private readonly FakeComLog _log;
        private readonly FakeEngine _engine;
        private readonly object _eventHandler = new();

        public FakeActivator(FakeComLog log, FakeEngine engine)
        {
            _log = log;
            _engine = engine;
        }

        public object EventHandler => _eventHandler;

        public object Create(Guid classId)
        {
            _log.Add($"Create:{classId:B}");
            return classId switch
            {
                var value when value == K3dComConstants.KaEventHandlerClassGuid => _eventHandler,
                var value when value == K3dComConstants.KaEngineClassGuid => _engine,
                _ => throw new InvalidOperationException("Unexpected fake CLSID.")
            };
        }
    }

    private sealed class ThrowingActivator : ILateBoundComActivator
    {
        private readonly Exception _exception;

        public ThrowingActivator(Exception exception)
        {
            _exception = exception;
        }

        public object Create(Guid classId) => throw _exception;
    }

    private sealed class RecordingEventHandlerFactory : IK3dEventHandlerFactory
    {
        private IK3dEventSink? _sink;

        public bool SupportsCallbacks => true;

        public object Handler { get; } = new();

        public object Create(IK3dEventSink sink)
        {
            _sink = sink;
            return Handler;
        }

        public void Fire(string methodName, params object?[] arguments) =>
            (_sink ?? throw new InvalidOperationException("Callback sink is not connected."))
                .OnCallback(methodName, arguments);
    }

    private sealed class BlockingEngineActivator : ILateBoundComActivator
    {
        private readonly FakeComLog _log;
        private readonly FakeEngine _engine;
        private readonly ManualResetEventSlim _activationStarted;
        private readonly ManualResetEventSlim _releaseActivation;
        private readonly object _eventHandler = new();

        public BlockingEngineActivator(
            FakeComLog log,
            FakeEngine engine,
            ManualResetEventSlim activationStarted,
            ManualResetEventSlim releaseActivation)
        {
            _log = log;
            _engine = engine;
            _activationStarted = activationStarted;
            _releaseActivation = releaseActivation;
        }

        public object Create(Guid classId)
        {
            _log.Add($"Create:{classId:B}");
            if (classId == K3dComConstants.KaEventHandlerClassGuid)
            {
                return _eventHandler;
            }

            if (classId != K3dComConstants.KaEngineClassGuid)
            {
                throw new InvalidOperationException("Unexpected fake CLSID.");
            }

            _activationStarted.Set();
            if (!_releaseActivation.Wait(TimeSpan.FromSeconds(5)))
            {
                throw new TimeoutException("fake activation coordination timeout");
            }

            return _engine;
        }
    }

    public sealed class FakeEngine
    {
        private readonly FakeComLog _log;
        private readonly FakePlayer _player;
        private readonly FakeScene _scene;
        private readonly int _connectResult;
        private readonly Exception? _playerFailure;
        private readonly Exception? _disconnectFailure;
        private readonly Exception? _connectFailure;

        public FakeEngine(
            FakeComLog log,
            FakePlayer player,
            FakeScene scene,
            int connectResult = 1,
            Exception? playerFailure = null,
            Exception? disconnectFailure = null,
            Exception? connectFailure = null)
        {
            _log = log;
            _player = player;
            _scene = scene;
            _connectResult = connectResult;
            _playerFailure = playerFailure;
            _disconnectFailure = disconnectFailure;
            _connectFailure = connectFailure;
        }

        public Func<string, FakeScene>? SceneResolver { get; init; }

        public Exception? EndTransactionFailure { get; set; }

        public int KTAPConnect(
            int tcpMode,
            string host,
            int hostPort,
            int clientPort,
            object eventHandler)
        {
            Assert.NotNull(eventHandler);
            _log.Add($"KTAPConnect:{tcpMode}:{host}:{hostPort}:{clientPort}");
            if (_connectFailure is not null)
            {
                throw _connectFailure;
            }

            return _connectResult;
        }

        public object GetScenePlayerOnChannel(int channel)
        {
            _log.Add($"GetScenePlayerOnChannel:{channel}");
            if (_playerFailure is not null)
            {
                throw _playerFailure;
            }

            return _player;
        }

        public object GetScenePlayer()
        {
            _log.Add("GetScenePlayer");
            if (_playerFailure is not null)
            {
                throw _playerFailure;
            }

            return _player;
        }

        public object LoadScene(string sceneFile, string sceneName)
        {
            Assert.True(System.IO.Path.IsPathFullyQualified(sceneFile));
            _log.Add($"LoadScene:{sceneName}");
            return SceneResolver?.Invoke(sceneName) ?? _scene;
        }

        public void BeginTransaction() => _log.Add("BeginTransaction");

        public void EndTransaction()
        {
            _log.Add("EndTransaction");
            if (EndTransactionFailure is not null)
            {
                throw EndTransactionFailure;
            }
        }

        public void EndTransactionOnChannel(int channel)
        {
            _log.Add($"EndTransactionOnChannel:{channel}");
            if (EndTransactionFailure is not null)
            {
                throw EndTransactionFailure;
            }
        }

        public void RollbackTransaction() => _log.Add("RollbackTransaction");

        public void Disconnect()
        {
            _log.Add("Disconnect");
            if (_disconnectFailure is not null)
            {
                throw _disconnectFailure;
            }
        }
    }

    private sealed class FakeReleaser : ILateBoundComObjectReleaser
    {
        private readonly FakeComLog _log;
        private readonly (object Value, string Name)[] _objects;

        public FakeReleaser(
            FakeComLog log,
            params (object Value, string Name)[] objects)
        {
            _log = log;
            _objects = objects;
        }

        public void Release(object value)
        {
            var match = Assert.Single(_objects, item => ReferenceEquals(item.Value, value));
            _log.Add($"Release:{match.Name}");
        }
    }

    public sealed class FakePlayer
    {
        private readonly FakeComLog _log;
        private readonly Exception? _playFailure;

        public FakePlayer(FakeComLog log, Exception? playFailure = null)
        {
            _log = log;
            _playFailure = playFailure;
        }

        public object? PlayingScene { get; set; }

        public object GetPlayingScene(int layoutIndex)
        {
            _log.Add($"GetPlayingScene:{layoutIndex}");
            return PlayingScene
                ?? throw new InvalidOperationException("No fake playing scene is configured.");
        }

        public void Prepare(int layoutIndex, object scene)
        {
            Assert.NotNull(scene);
            _log.Add($"Prepare:{layoutIndex}");
        }

        public void Play(int layoutIndex)
        {
            _log.Add($"Play:{layoutIndex}");
            if (_playFailure is not null)
            {
                throw _playFailure;
            }
        }

        public void StopAll() => _log.Add("StopAll");

        public void CutOut(int layoutIndex) => _log.Add($"CutOut:{layoutIndex}");
    }

    public sealed class FakeScene
    {
        private readonly FakeComLog _log;
        private readonly string? _name;
        private Exception? _queryVariablesFailure;
        private readonly Exception? _unloadFailure;

        public FakeScene(
            FakeComLog log,
            string? name = null,
            Exception? queryVariablesFailure = null,
            Exception? unloadFailure = null)
        {
            _log = log;
            _name = name;
            _queryVariablesFailure = queryVariablesFailure;
            _unloadFailure = unloadFailure;
        }

        public void SetOutputChannelIndex(int channel) =>
            _log.Add($"SetOutputChannelIndex:{channel}");

        public void SetSceneEffectType(int inEffect, int effectType, int duration) =>
            _log.Add($"SetSceneEffectType:{inEffect}:{effectType}:{duration}");

        public void UseBackground(int enabled) => _log.Add($"UseBackground:{enabled}");

        public void SetBackgroundTexture(string path) =>
            _log.Add($"SetBackgroundTexture:{System.IO.Path.GetFileName(path)}");

        public void SetBackgroundVideo(string path, int loopCount, int loopInfinite) =>
            _log.Add($"SetBackgroundVideo:{System.IO.Path.GetFileName(path)}:{loopCount}:{loopInfinite}");

        public object GetObject(string objectName)
        {
            _log.Add($"GetObject:{objectName}");
            if (GetObjectFailure is not null)
            {
                throw GetObjectFailure;
            }

            return new FakeSceneObject(_log, objectName);
        }

        public void QueryVariables()
        {
            _log.Add("QueryVariables");
            if (_queryVariablesFailure is not null)
            {
                throw _queryVariablesFailure;
            }
        }

        public Exception? QueryVariablesFailure
        {
            get => _queryVariablesFailure;
            set => _queryVariablesFailure = value;
        }

        public Exception? GetObjectFailure { get; set; }

        public void Unload()
        {
            _log.Add(_name is null ? "Unload" : $"Unload:{_name}");
            if (_unloadFailure is not null)
            {
                throw _unloadFailure;
            }
        }
    }

    public sealed class FakeSceneObject
    {
        private readonly FakeComLog _log;
        private readonly string _name;

        public FakeSceneObject(FakeComLog log, string name)
        {
            _log = log;
            _name = name;
        }

        public void SetValue(string value) => _log.Add(
            $"SetValue:{_name}:{(System.IO.Path.IsPathFullyQualified(value) ? System.IO.Path.GetFileName(value) : value)}");

        public void SetVisible(int visible) => _log.Add($"SetVisible:{_name}:{visible}");

        public void SetFaceColor(int red, int green, int blue, int alpha) =>
            _log.Add($"SetFaceColor:{_name}:{red}:{green}:{blue}:{alpha}");

        public void SetPosition(float x, float y, float z, int components) =>
            _log.Add(FormattableString.Invariant($"SetPosition:{_name}:{x}:{y}:{z}:{components}"));

        public void SetPositionKey(int keyIndex, float x, float y, float z, int components) =>
            _log.Add(FormattableString.Invariant(
                $"SetPositionKey:{_name}:{keyIndex}:{x}:{y}:{z}:{components}"));

        public void SetScale(float x, float y, float z, int components) =>
            _log.Add(FormattableString.Invariant($"SetScale:{_name}:{x}:{y}:{z}:{components}"));

        public void SetCropKey(
            int keyIndex,
            float left,
            float top,
            float right,
            float bottom,
            int edges) =>
            _log.Add(FormattableString.Invariant(
                $"SetCropKey:{_name}:{keyIndex}:{left}:{top}:{right}:{bottom}:{edges}"));

        public void SetCircleAngleKey(
            int keyIndex,
            float start,
            float end,
            int components) =>
            _log.Add(FormattableString.Invariant(
                $"SetCircleAngleKey:{_name}:{keyIndex}:{start}:{end}:{components}"));

        public void BeginPathPoint() => _log.Add($"BeginPathPoint:{_name}");

        public void ClearPathPoints() => _log.Add($"ClearPathPoints:{_name}");

        public void AddPathPoint(float x, float y, float z) =>
            _log.Add(FormattableString.Invariant($"AddPathPoint:{_name}:{x}:{y}:{z}"));

        public void EndPathPoint() => _log.Add($"EndPathPoint:{_name}");

        public void BeginPathShapePoint() => _log.Add($"BeginPathShapePoint:{_name}");

        public void ClearPathShapePoints() => _log.Add($"ClearPathShapePoints:{_name}");

        public void AddPathShapePoint(float x, float y, float z) =>
            _log.Add(FormattableString.Invariant($"AddPathShapePoint:{_name}:{x}:{y}:{z}"));

        public void EndPathShapePoint() => _log.Add($"EndPathShapePoint:{_name}");
    }
}
