using MBN_STOCK_WEBVIEW.Core.Playout;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
using MBN_STOCK_WEBVIEW.Infrastructure;
using MBN_STOCK_WEBVIEW.LegacyApplication;
using MBN_STOCK_WEBVIEW.LegacyBridge;
using MBN_STOCK_WEBVIEW.Playout;
using MBN_STOCK_WEBVIEW.Playout.Configuration;
using MBN_STOCK_WEBVIEW.Playout.Safety;
using Microsoft.UI.Xaml;

namespace MBN_STOCK_WEBVIEW.LegacyParityApp;

public sealed partial class MainWindow
{
    private readonly SemaphoreSlim _playoutCommandGate = new(1, 1);
    private IPlayoutEngine? _playoutEngine;
    private LegacyPlayoutWorkflow? _playoutWorkflow;
    private PlayoutOptions? _playoutOptions;
    private MutableLegacySceneCueCompositionOptionsSource? _compositionOptions;
    private MutableLegacySceneCueCompositionOptionsSource? _stagedCompositionOptions;
    private string? _playoutInitializationError;
    private string? _playoutInitializationWarning;
    private Task? _refreshTask;
    private readonly LegacyRefreshEpoch<LegacyRefreshRuntimeStatus> _refreshEpoch =
        new(LegacyRefreshRuntimeStatus.Empty);
    private int _playoutBusy;
    private int _playoutQuarantined;
    private int _playoutShutdownStarted;
    private int _operatorMutationQuarantined;

    private void InitializePlayoutRuntime()
    {
        try
        {
            if (_developmentLiveStartupBlocked)
            {
                _playoutInitializationError =
                    App.DevelopmentLiveStartupFailureMessage ??
                    "Live playout startup validation failed.";
                return;
            }

            var explicitLocalConfigurationExists = File.Exists(
                PlayoutOptionsLoader.DefaultPath);
            _playoutOptions =
                _isDevelopmentLiveLaunch
                ? PlayoutOptionsLoader.LoadDevelopmentLive(
                    baseDirectory: AppContext.BaseDirectory,
                    operatorSceneDirectory:
                        _appliedOperatorSettings.SceneDirectory)
                : PlayoutOptionsLoader.Load(
                    operatorSceneDirectory: _appliedOperatorSettings.SceneDirectory,
                    operatorBackgroundDirectory:
                        _appliedOperatorSettings.BackgroundDirectory);
            if (_playoutOptions.Mode != PlayoutMode.Live)
            {
                throw new PlayoutConfigurationException(
                    "The application requires a validated Live playout configuration. " +
                    "DryRun fallback is disabled.");
            }

            ResetRefreshState();
            var startupComposition = LegacyParityStartupCompositionResolver.Resolve(
                _playoutOptions,
                explicitLocalConfigurationExists);
            var initialComposition = startupComposition.Composition;
            _playoutInitializationWarning = startupComposition.HasWarning
                ? startupComposition.WarningMessage
                : null;
            _compositionOptions = new MutableLegacySceneCueCompositionOptionsSource(
                initialComposition);
            _stagedCompositionOptions = new MutableLegacySceneCueCompositionOptionsSource(
                initialComposition);
            _playoutEngine = PlayoutEngineFactory.Create(
                _playoutOptions,
                _playoutLaunchAuthorization);
            _playoutEngine.StatusChanged += OnPlayoutStatusChanged;
            if (_databaseRuntime is not null)
            {
                _playoutWorkflow = global::MBN_STOCK_WEBVIEW.LegacySceneRuntimeFactory.Create(
                    _playoutEngine,
                    _databaseRuntime.Executor,
                    compositionOptionsSource: _compositionOptions,
                    trustedManualSource: _manualNetSellStore,
                    trustedViSource: _manualViStore);
            }
        }
        catch (Exception exception)
        {
            _developmentLiveStartupBlocked = true;
            _playoutInitializationWarning = null;
            _playoutInitializationError = exception is PlayoutConfigurationException
                ? exception.Message
                : "Tornado 송출 어댑터를 초기화하지 못했습니다.";
        }
    }

    private async Task ConnectPlayoutAsync(CancellationToken cancellationToken)
    {
        var engine = _playoutEngine;
        if (engine is null)
        {
            QueueOperatorState();
            return;
        }

        try
        {
            var result = await engine.ConnectAsync(cancellationToken).ConfigureAwait(false);
            if (!result.IsSuccess)
            {
                _playoutInitializationWarning = null;
                _playoutInitializationError = result.Message;
            }
        }
        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
        {
        }
        catch
        {
            _playoutInitializationWarning = null;
            _playoutInitializationError = "Tornado 송출 엔진에 연결하지 못했습니다.";
        }

        QueueOperatorState();
    }

    private void OnPlayoutStatusChanged(object? sender, PlayoutStatusChangedEventArgs args)
    {
        _playoutWorkflow?.ObserveEngineStatus(args.Current);
        if (string.IsNullOrWhiteSpace(args.Current.PreparedSceneName) &&
            string.IsNullOrWhiteSpace(args.Current.OnAirSceneName))
        {
            if (args.Current.State == PlayoutConnectionState.OutcomeUnknown ||
                Volatile.Read(ref _playoutQuarantined) != 0)
            {
                StopRefreshLoop();
            }
            else
            {
                ResetRefreshState();
            }
        }

        QueueOperatorState();
    }

    private async Task<LegacyOperatorSnapshot> ExecuteOperatorPlayoutAsync(
        LegacyOperatorPlayoutCommand command,
        CancellationToken cancellationToken,
        bool gateAPrepareClaimed = false,
        LegacyOperatorPlayoutRequest? gateAPrepareRequest = null)
    {
        if (_playoutLaunchAuthorization.IsGateA &&
            (!gateAPrepareClaimed ||
             command != LegacyOperatorPlayoutCommand.Prepare ||
             gateAPrepareRequest is null))
        {
            return _controller.ReportError(
                "Gate A 검증 회차에서는 승인된 5001 PREPARE 한 번만 실행할 수 있습니다.");
        }

        var engine = _playoutEngine;
        var workflow = _playoutWorkflow;
        if (engine is null || workflow is null)
        {
            return _controller.ReportError(
                _playoutInitializationError ?? "Tornado 송출 기능을 사용할 수 없습니다.");
        }

        if (Volatile.Read(ref _playoutQuarantined) != 0 ||
            engine.Status.State == PlayoutConnectionState.OutcomeUnknown)
        {
            return _controller.ReportError(
                "송출 결과를 확인할 수 없는 상태입니다. PGM과 Network Monitoring을 확인한 뒤 앱을 다시 시작하세요.");
        }

        if (!await _playoutCommandGate.WaitAsync(0, cancellationToken).ConfigureAwait(false))
        {
            return _controller.ReportError("다른 송출 명령을 처리하고 있습니다.");
        }

        try
        {
            Interlocked.Exchange(ref _playoutBusy, 1);
            var nextKindBefore = workflow.State.NextKind;
            var pageNavigationEnabledBefore = workflow.State.IsPageNavigationEnabled;
            if (engine.Status.IsTakeOutCompletionPending)
            {
                return _controller.ReportError(
                    "Tornado TAKE OUT 완료 이벤트를 기다리는 동안에는 송출 명령을 실행할 수 없습니다.");
            }

            if (command != LegacyOperatorPlayoutCommand.TakeOut &&
                engine.Status.IsPlayCompletionPending)
            {
                return _controller.ReportError(
                    "Tornado 재생 완료 이벤트를 기다리는 중입니다. 결과가 확인될 때까지 TAKE OUT만 사용할 수 있습니다.");
            }

            StopRefreshLoop();
            if (command is LegacyOperatorPlayoutCommand.Prepare or
                LegacyOperatorPlayoutCommand.TakeIn or
                LegacyOperatorPlayoutCommand.Next)
            {
                ApplyStagedCompositionForNextCue();
            }
            PlayoutResult result;
            switch (command)
            {
                case LegacyOperatorPlayoutCommand.Prepare:
                    var request = gateAPrepareRequest ??
                        await CreateFreshPagedPlayoutRequestAsync(
                            workflow,
                            cancellationToken).ConfigureAwait(false);
                    result = await workflow.PrepareAsync(
                        request.Playlist,
                        request.SelectedIndexZeroBased,
                        cancellationToken).ConfigureAwait(false);
                    break;
                case LegacyOperatorPlayoutCommand.TakeIn:
                    // MainForm.btnTake_Click always copied m_rowidx (the operator's
                    // candidate) to m_crow and ran ONAirMode + Play, even while another
                    // row was already on air. Re-read the current selection on every F8;
                    // a stale explicit PREPARE must never override a later row click.
                    var takeInRequest = await CreateFreshPagedPlayoutRequestAsync(
                        workflow,
                        cancellationToken).ConfigureAwait(false);
                    result = await workflow.TakeSelectedAsync(
                        takeInRequest.Playlist,
                        takeInRequest.SelectedIndexZeroBased,
                        cancellationToken).ConfigureAwait(false);
                    break;
                case LegacyOperatorPlayoutCommand.Next:
                    var livePlaylist = _controller.CreatePlayoutRequest(
                        _compositionOptions!.Current.FadeDuration).Playlist;
                    result = await workflow.NextAsync(
                        livePlaylist,
                        cancellationToken).ConfigureAwait(false);
                    break;
                case LegacyOperatorPlayoutCommand.TakeOut:
                    result = await workflow.TakeOutAsync(
                        PlayoutTakeOutScope.All,
                        cancellationToken).ConfigureAwait(false);
                    break;
                default:
                    throw new ArgumentOutOfRangeException(nameof(command));
            }

            if (result.IsSuccess &&
                command != LegacyOperatorPlayoutCommand.TakeOut &&
                workflow.State.CurrentEntryId is { Length: > 0 } currentEntryId &&
                workflow.State.PageCount > 0)
            {
                // FarPoint column 5 was mutable operator state: F8/PREPARE reset a
                // paged cut to 1/N, while Page NEXT left 2/N, 3/N, ... in the row.
                // NEXT must not replace the independently selected F8 candidate.
                _controller.ReflectSuccessfulPlayout(
                    currentEntryId,
                    workflow.State.PageIndexZeroBased,
                    workflow.State.PageCount,
                    synchronizeOperatorSelection:
                        command is LegacyOperatorPlayoutCommand.Prepare or
                            LegacyOperatorPlayoutCommand.TakeIn);
            }

            if (result.Code is PlayoutResultCode.OutcomeUnknown or PlayoutResultCode.TimedOut)
            {
                await QuarantinePlayoutAsync().ConfigureAwait(false);
                return _controller.ReportError(
                    "송출 명령 결과가 불명확합니다. 명령을 반복하지 말고 PGM과 Network Monitoring을 확인하세요.");
            }

            if (!result.IsSuccess)
            {
                return _controller.ReportError(result.Message);
            }

            if (command == LegacyOperatorPlayoutCommand.TakeOut)
            {
                ResetRefreshState();
            }

            if (command == LegacyOperatorPlayoutCommand.TakeIn ||
                command == LegacyOperatorPlayoutCommand.Next &&
                nextKindBefore == LegacyWorkflowNextKind.PlaylistNext &&
                !pageNavigationEnabledBefore)
            {
                StartRefreshLoop(workflow);
            }

            return _controller.ReportInformation(result.Message);
        }
        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
        {
            return _controller.ReportError("송출 요청이 취소되었습니다.");
        }
        catch (DatabaseOperationException exception)
        {
            return _controller.ReportError(exception.Message);
        }
        catch (Exception exception) when (
            exception is LegacySceneDataException or ArgumentException or InvalidOperationException)
        {
            return _controller.ReportError(exception.Message);
        }
        catch
        {
            await QuarantinePlayoutAsync().ConfigureAwait(false);
            return _controller.ReportError(
                "송출 엔진 명령의 결과를 확인할 수 없습니다. 명령을 반복하지 마세요.");
        }
        finally
        {
            Interlocked.Exchange(ref _playoutBusy, 0);
            _playoutCommandGate.Release();
            QueueOperatorState();
        }
    }

    private async Task<LegacyOperatorPlayoutRequest> CreateFreshPagedPlayoutRequestAsync(
        LegacyPlayoutWorkflow workflow,
        CancellationToken cancellationToken)
    {
        var fadeDuration = _compositionOptions!.Current.FadeDuration;
        var request = _controller.CreatePlayoutRequest(fadeDuration);

        // The first PREPARE/TAKE IN freezes the complete playlist. Resolve every
        // operator PageN row from a fresh read-only DB preflight before that snapshot
        // is handed to the workflow. A later F8 while PREPARED/PROGRAM reuses the
        // already-frozen 1/N values and must not query a second navigation contract.
        if (workflow.State.CurrentEntryId is not null)
        {
            return request;
        }

        var pagedEntries = request.Playlist
            .Where(static entry => entry.PageNavigation?.PageSize.HasValue == true)
            .ToArray();
        if (pagedEntries.Length == 0)
        {
            return request;
        }

        var plans = await workflow.PreflightPagePlansAsync(
            Array.AsReadOnly(pagedEntries),
            cancellationToken).ConfigureAwait(false);
        if (plans.Count != pagedEntries.Length)
        {
            throw new LegacySceneDataException(
                "The playlist page preflight did not return every PageN entry.");
        }

        foreach (var plan in plans)
        {
            var entry = pagedEntries.SingleOrDefault(candidate =>
                string.Equals(candidate.EntryId, plan.EntryId, StringComparison.Ordinal));
            if (entry?.PageNavigation?.PageSize is not { } expectedPageSize ||
                plan.PageSize != (int)expectedPageSize ||
                plan.PageCount is < 1 or > ScenePaging.MaximumPageCount)
            {
                throw new LegacySceneDataException(
                    "The playlist page preflight does not match the operator PageN row.");
            }

            _controller.ReflectSuccessfulPlayout(
                plan.EntryId,
                pageIndexZeroBased: 0,
                pageCount: plan.PageCount,
                synchronizeOperatorSelection: false);
        }

        return _controller.CreatePlayoutRequest(fadeDuration);
    }

    private async Task<LegacyOperatorSnapshot> ExecuteGateAPrepareAsync(
        string capability,
        CancellationToken cancellationToken)
    {
        if (!_playoutLaunchAuthorization.IsGateA ||
            !_playoutLaunchAuthorization.TryClaimGateAPrepare(capability))
        {
            return _controller.ReportError(
                "Gate A PREPARE 승인이 없거나 이미 사용되었습니다.");
        }

        try
        {
            if (!TryCreateExactGateAPrepareRequest(out var request))
            {
                return _controller.ReportError(
                    "Gate A PREPARE 직전 상태가 승인된 삼성전자 5001 page 1 조건과 일치하지 않습니다.");
            }

            return await ExecuteOperatorPlayoutAsync(
                LegacyOperatorPlayoutCommand.Prepare,
                cancellationToken,
                gateAPrepareClaimed: true,
                gateAPrepareRequest: request).ConfigureAwait(false);
        }
        finally
        {
            // Success, rejection, cancellation and unknown outcome all consume the
            // one process-lifetime capability. There is deliberately no retry path.
            _playoutLaunchAuthorization.CompleteGateAPrepare();
        }
    }

    private bool TryCreateExactGateAPrepareRequest(
        out LegacyOperatorPlayoutRequest? request)
    {
        request = null;
        var snapshot = _controller.Current;
        if (snapshot.Playlist.Count != 1 ||
            _compositionOptions?.Current is not { } composition ||
            composition.FadeDuration != 6 ||
            composition.BackgroundKind != LegacySceneBackgroundKind.None)
        {
            return false;
        }

        var row = snapshot.Playlist[0];
        if (!row.IsEnabled || !row.IsActive ||
            !string.Equals(row.MarketText, "코스피", StringComparison.Ordinal) ||
            !string.Equals(row.StockName, "삼성전자", StringComparison.Ordinal) ||
            !string.Equals(row.GraphicType, "1열판기본", StringComparison.Ordinal) ||
            !string.Equals(row.Subtype, "현재가", StringComparison.Ordinal) ||
            !string.Equals(row.PageText, "1/1", StringComparison.Ordinal) ||
            !string.Equals(row.StockCode, "005930", StringComparison.Ordinal))
        {
            return false;
        }

        LegacyOperatorPlayoutRequest candidate;
        try
        {
            candidate = _controller.CreatePlayoutRequest(6);
        }
        catch (InvalidOperationException)
        {
            return false;
        }

        if (candidate.SelectedIndexZeroBased != 0 ||
            candidate.Playlist.Count != 1)
        {
            return false;
        }

        var entry = candidate.Playlist[0];
        var selection = entry.Selection;
        if (!entry.IsEnabled ||
            !string.Equals(entry.EntryId, row.RowId, StringComparison.Ordinal) ||
            !string.Equals(entry.CutCode, "5001", StringComparison.Ordinal) ||
            entry.FadeDuration != 6 ||
            entry.PageNavigation?.IsEnabled == true ||
            entry.MovingAverageSelectionSource is not null ||
            selection is null ||
            !string.Equals(selection.GroupCode, "코스피", StringComparison.Ordinal) ||
            !string.Equals(selection.Subject, "삼성전자", StringComparison.Ordinal) ||
            !string.Equals(selection.GraphicType, "1열판기본", StringComparison.Ordinal) ||
            !string.Equals(selection.Subtype, "현재가", StringComparison.Ordinal) ||
            !string.Equals(selection.DataCode, "005930", StringComparison.Ordinal))
        {
            return false;
        }

        request = candidate;
        return true;
    }

    private async Task<LegacyOperatorSnapshot> SetFadeDurationAsync(
        int fadeDuration,
        CancellationToken cancellationToken)
    {
        if (!_playoutLaunchAuthorization.AllowsCompositionMutation)
        {
            return _controller.ReportError(
                "Gate A 검증 회차에서는 Fade 설정을 변경할 수 없습니다.");
        }

        if (fadeDuration is < 0 or > 19)
        {
            return _controller.ReportError("DissolveTime 값이 올바르지 않습니다.");
        }

        if (!await _playoutCommandGate.WaitAsync(0, cancellationToken).ConfigureAwait(false))
        {
            return _controller.ReportError("송출 명령 처리 중에는 Fade를 변경할 수 없습니다.");
        }

        try
        {
            if (!CanChangeComposition())
            {
                return _controller.ReportError("DissolveTime은 IDLE 상태에서만 변경할 수 있습니다.");
            }

            var source = _compositionOptions!;
            var updated = source.Current with { FadeDuration = fadeDuration };
            source.Update(updated);
            _stagedCompositionOptions!.Update(updated);
            return _controller.ReportInformation(
                $"DissolveTime {fadeDuration + 1}을 다음 PREPARE부터 적용합니다.");
        }
        finally
        {
            _playoutCommandGate.Release();
        }
    }

    private LegacyOperatorSnapshot SelectPlaylistBoundaryAndStopRefresh(bool last)
    {
        // MainForm.ProcessCmdKey stopped timer1 before applying either Home or End.
        // Ordinary row selection deliberately does not use this path.
        StopRefreshLoop();
        return _controller.SelectPlaylistBoundary(last);
    }

    private async Task<LegacyOperatorSnapshot> ToggleBackgroundAsync(
        CancellationToken cancellationToken)
    {
        if (!_playoutLaunchAuthorization.AllowsCompositionMutation)
        {
            return _controller.ReportError(
                "Gate A 검증 회차에서는 배경 설정을 변경할 수 없습니다.");
        }

        if (!await _playoutCommandGate.WaitAsync(0, cancellationToken).ConfigureAwait(false))
        {
            return _controller.ReportError("송출 명령 처리 중에는 배경을 변경할 수 없습니다.");
        }

        try
        {
            if (!CanStageBackground())
            {
                return _controller.ReportError(
                    "송출 결과가 확정되지 않은 동안에는 다음 장면 배경을 변경할 수 없습니다.");
            }

            var source = _stagedCompositionOptions!;
            if (source.Current.BackgroundKind == LegacySceneBackgroundKind.None)
            {
                source.Update(PlayoutSceneCompositionFactory.CreateLegacyDefault(
                    _playoutOptions!,
                    source.Current.FadeDuration));
                _playoutInitializationWarning = null;
                return _controller.ReportInformation(
                    "기본 배경을 다음 PREPARE부터 적용합니다.");
            }

            source.Update(new LegacySceneCueCompositionOptions(
                source.Current.FadeDuration,
                LegacySceneBackgroundKind.None));
            return _controller.ReportInformation("배경 사용을 해제했습니다.");
        }
        catch (PlayoutConfigurationException exception)
        {
            return _controller.ReportError(exception.Message);
        }
        finally
        {
            _playoutCommandGate.Release();
        }
    }

    private async Task<LegacyOperatorSnapshot> ChooseBackgroundAsync(
        CancellationToken cancellationToken)
    {
        if (!_playoutLaunchAuthorization.AllowsCompositionMutation)
        {
            return _controller.ReportError(
                "Gate A 검증 회차에서는 배경 파일을 선택할 수 없습니다.");
        }

        if (!await _playoutCommandGate.WaitAsync(0, cancellationToken).ConfigureAwait(false))
        {
            return _controller.ReportError("송출 명령 처리 중에는 배경을 변경할 수 없습니다.");
        }

        try
        {
            if (!CanStageBackground())
            {
                return _controller.ReportError(
                    "송출 결과가 확정되지 않은 동안에는 다음 장면 배경을 변경할 수 없습니다.");
            }

            // MainForm.btnback_Click assigns 배경\기본.vrv before opening the
            // picker. Preserve that observable behavior when the trusted default is
            // present, while still allowing the operator to choose another asset if it
            // is not.
            var source = _stagedCompositionOptions!;
            try
            {
                source.Update(PlayoutSceneCompositionFactory.CreateLegacyDefault(
                    _playoutOptions!,
                    source.Current.FadeDuration));
                _playoutInitializationWarning = null;
            }
            catch (PlayoutConfigurationException)
            {
            }

            if (!PlayoutSceneCompositionFactory.TryGetTrustedBackgroundDirectory(
                    _playoutOptions!,
                    out var trustedBackgroundDirectory))
            {
                return _controller.ReportError(
                    "설정에서 사용할 배경 폴더를 먼저 지정해 주세요.");
            }

            var selectedPath = await PickBackgroundFileAsync(
                trustedBackgroundDirectory).ConfigureAwait(false);
            if (selectedPath is null)
            {
                return _controller.Current;
            }

            if (!CanStageBackground())
            {
                return _controller.ReportError(
                    "파일 선택 중 송출 상태가 바뀌어 배경 변경을 취소했습니다.");
            }

            source.Update(PlayoutSceneCompositionFactory.CreateOperatorSelection(
                _playoutOptions!,
                source.Current.FadeDuration,
                selectedPath));
            _playoutInitializationWarning = null;
            return _controller.ReportInformation(
                "선택한 배경을 다음 PREPARE부터 적용합니다.");
        }
        catch (Exception exception) when (
            exception is ArgumentException or IOException or UnauthorizedAccessException or
                PlayoutConfigurationException)
        {
            return _controller.ReportError(
                "신뢰 배경 폴더 안의 vrv/jpg/jpeg/png 파일만 선택할 수 있습니다.");
        }
        finally
        {
            _playoutCommandGate.Release();
        }
    }

    private bool CanChangeComposition()
    {
        var status = _playoutEngine?.Status;
        var workflow = _playoutWorkflow?.State;
        var refresh = _refreshEpoch.ReadState();
        return _playoutOptions is not null && _compositionOptions is not null &&
            _stagedCompositionOptions is not null &&
            status is not null && workflow is not null &&
            Volatile.Read(ref _playoutBusy) == 0 &&
            Volatile.Read(ref _playoutQuarantined) == 0 &&
            !status.IsPlayCompletionPending &&
            !status.IsTakeOutCompletionPending &&
            string.IsNullOrWhiteSpace(status.PreparedSceneName) &&
            string.IsNullOrWhiteSpace(status.OnAirSceneName) &&
            string.IsNullOrWhiteSpace(workflow.PreparedCutCode) &&
            string.IsNullOrWhiteSpace(workflow.OnAirCutCode) &&
            !refresh.IsActive &&
            (_refreshTask is null || _refreshTask.IsCompleted);
    }

    private bool CanStageBackground()
    {
        var status = _playoutEngine?.Status;
        var workflow = _playoutWorkflow?.State;
        return _playoutOptions is not null && _compositionOptions is not null &&
            _stagedCompositionOptions is not null && status is not null &&
            workflow is not null &&
            Volatile.Read(ref _playoutBusy) == 0 &&
            Volatile.Read(ref _playoutQuarantined) == 0 &&
            Volatile.Read(ref _playoutShutdownStarted) == 0 &&
            status.State != PlayoutConnectionState.OutcomeUnknown &&
            !status.IsPlayCompletionPending &&
            !status.IsTakeOutCompletionPending;
    }

    private void ApplyStagedCompositionForNextCue()
    {
        var active = _compositionOptions ??
            throw new InvalidOperationException("The active composition source is unavailable.");
        var staged = _stagedCompositionOptions ??
            throw new InvalidOperationException("The staged composition source is unavailable.");
        active.Update(staged.Current);
    }

    private bool CanAcceptOperatorMutation(
        LegacyUiIntent intent,
        out string rejectionMessage)
    {
        var status = _playoutEngine?.Status;
        var workflow = _playoutWorkflow?.State;
        var refresh = _refreshEpoch.ReadState();
        if (status is null || workflow is null)
        {
            // A disabled or unavailable playout runtime must not prevent the operator
            // from composing a playlist. PREPARE will still fail closed later.
            rejectionMessage = string.Empty;
            return true;
        }

        // A cut double-click only appends a local row at the safe playlist tail.
        // Once the refresh has released the shared intent gate, that append is
        // independent of the scheduler remaining active between refreshes. All
        // uncertain/pending/busy states below remain fail-closed.
        var isSafeCutTailAppend = intent is
            LegacyCutPointerDownIntent { AllowAppend: true } or
            LegacyDropSelectedCutsIntent;
        // MainForm kept its Space and "all" enabled-state gestures available while
        // PROGRAM was active. They only change the native operator playlist; NEXT
        // stops the refresh loop before the workflow adopts those flags. Therefore
        // they are independent between refresh ticks just like a safe tail append.
        var isSafeProgramEnabledStateMutation = intent is
            LegacySetAllPlaylistEnabledIntent or
            LegacyToggleActivePlaylistEnabledIntent;
        // Deleting selected rows is also independent of the refresh scheduler, but
        // only after the exact current row and every selected index are checked by
        // CanDeleteSelectedPlaylistTail below. The one-slot ordered UI gate keeps
        // this request behind its preceding row selection without allowing repeats.
        var isSafeProgramTailDelete = intent is
            LegacyDeleteSelectedPlaylistRowsIntent;
        // PList did not stop or replace the active scene when it created, saved,
        // overwrote, or deleted a named DB playlist. These writes only persist a
        // snapshot/definition and are serialized with the automatic refresh by the
        // shared intent and database gates. Loading remains an idle-only structural
        // replacement below.
        var isSafeNamedPlaylistPersistenceMutation = intent is
            LegacyCreateNamedPlaylistIntent or
            LegacySaveCurrentNamedPlaylistIntent or
            LegacySaveCurrentNamedPlaylistToIntent or
            LegacyDeleteSelectedNamedPlaylistIntent;
        var refreshAllowsMutation = isSafeCutTailAppend ||
            isSafeProgramEnabledStateMutation ||
            isSafeProgramTailDelete ||
            isSafeNamedPlaylistPersistenceMutation ||
            (!refresh.IsActive && (_refreshTask is null || _refreshTask.IsCompleted));
        var runtimeAcceptsIndependentMutation = Volatile.Read(ref _playoutBusy) == 0 &&
            Volatile.Read(ref _playoutQuarantined) == 0 &&
            Volatile.Read(ref _playoutShutdownStarted) == 0 &&
            status.State != PlayoutConnectionState.OutcomeUnknown &&
            !status.IsPlayCompletionPending &&
            !status.IsTakeOutCompletionPending &&
            refreshAllowsMutation;
        if (!runtimeAcceptsIndependentMutation)
        {
            rejectionMessage =
                "송출 명령 처리 중이거나 결과를 확인할 수 없는 상태에서는 편성 및 운영 데이터를 변경할 수 없습니다.";
            return false;
        }

        if (isSafeCutTailAppend &&
            !CanAppendCutToPlaylistTail(status, workflow))
        {
            rejectionMessage =
                "PROGRAM 상태에서는 현재 송출 행을 확인할 수 있을 때만 새 컷을 대기열 끝에 추가할 수 있습니다.";
            return false;
        }

        if (RequiresIdlePlaylistMutation(intent) && !IsPlaylistMutationIdle(status, workflow))
        {
            rejectionMessage = intent is
                LegacyLoadSelectedNamedPlaylistIntent or LegacyLoadNamedPlaylistByIdIntent
                ? "송출 중에는 다른 DB 재생목록으로 교체할 수 없습니다. TAKE OUT 후 다시 시도하세요."
                : "송출 중에는 행 이동·마우스 개별 체크·전체 삭제를 할 수 없습니다. Space/전체 활성 체크, 새 컷 추가와 안전한 후속 행 삭제는 계속 사용할 수 있습니다.";
            return false;
        }

        if (isSafeProgramEnabledStateMutation &&
            !CanMutatePlaylistEnabledState(status, workflow))
        {
            rejectionMessage =
                "플레이리스트 활성 체크는 대기 또는 PROGRAM 상태에서만 변경할 수 있습니다.";
            return false;
        }

        if (intent is LegacyDeleteSelectedPlaylistRowsIntent &&
            !CanDeleteSelectedPlaylistTail(status, workflow))
        {
            rejectionMessage =
                "송출 중에는 현재 송출 행과 그 이전 행을 삭제할 수 없습니다. 현재 행 뒤의 선택 행만 삭제할 수 있습니다.";
            return false;
        }

        rejectionMessage = string.Empty;
        return true;
    }

    private bool CanAppendCutToPlaylistTail(
        PlayoutStatus status,
        LegacyPlayoutWorkflowState workflow)
    {
        if (IsPlaylistMutationIdle(status, workflow))
        {
            return true;
        }

        // SetPlayList in MainForm always inserted after RowCount. Keep that PROGRAM
        // behavior only while the native playlist still has one exact on-air anchor;
        // the newly appended row is then guaranteed to be in the future tail.
        if (string.IsNullOrWhiteSpace(status.OnAirSceneName) ||
            string.IsNullOrWhiteSpace(workflow.OnAirCutCode) ||
            string.IsNullOrWhiteSpace(workflow.OnAirEntryId) ||
            workflow.OnAirCueIndexZeroBased < 0)
        {
            return false;
        }

        var playlist = _controller.Current.Playlist;
        var currentIndex = workflow.OnAirCueIndexZeroBased;
        return currentIndex < playlist.Count &&
            string.Equals(
                playlist[currentIndex].RowId,
                workflow.OnAirEntryId,
                StringComparison.Ordinal) &&
            playlist.Count(row => string.Equals(
                row.RowId,
                workflow.OnAirEntryId,
                StringComparison.Ordinal)) == 1;
    }

    private static bool RequiresIdlePlaylistMutation(LegacyUiIntent intent) => intent is
        LegacySetPlaylistEnabledIntent or
        LegacyMoveSelectedPlaylistRowsIntent or
        LegacyReorderPlaylistRowsIntent or
        LegacyDeleteAllPlaylistRowsIntent or
        LegacyLoadSelectedNamedPlaylistIntent or
        LegacyLoadNamedPlaylistByIdIntent;

    private static bool IsPlaylistMutationIdle(
        PlayoutStatus status,
        LegacyPlayoutWorkflowState workflow) =>
        string.IsNullOrWhiteSpace(status.PreparedSceneName) &&
        string.IsNullOrWhiteSpace(status.OnAirSceneName) &&
        string.IsNullOrWhiteSpace(workflow.PreparedCutCode) &&
        string.IsNullOrWhiteSpace(workflow.OnAirCutCode) &&
        string.IsNullOrWhiteSpace(workflow.CurrentEntryId);

    private bool CanDeleteSelectedPlaylistTail(
        PlayoutStatus status,
        LegacyPlayoutWorkflowState workflow)
    {
        if (IsPlaylistMutationIdle(status, workflow))
        {
            return true;
        }

        if (string.IsNullOrWhiteSpace(workflow.OnAirEntryId) ||
            workflow.OnAirCueIndexZeroBased < 0)
        {
            return false;
        }

        var playlist = _controller.Current.Playlist;
        var currentMatches = playlist
            .Select(static (row, index) => (row, index))
            .Where(candidate => string.Equals(
                candidate.row.RowId,
                workflow.OnAirEntryId,
                StringComparison.Ordinal))
            .Select(static candidate => candidate.index)
            .Take(2)
            .ToArray();
        if (currentMatches.Length != 1)
        {
            return false;
        }

        var currentIndex = currentMatches[0];
        if (currentIndex != workflow.OnAirCueIndexZeroBased)
        {
            return false;
        }

        return playlist
            .Select(static (row, index) => (row, index))
            .Where(static candidate => candidate.row.IsSelected)
            .All(candidate => candidate.index > currentIndex);
    }

    private bool CanMutatePlaylistEnabledState(
        PlayoutStatus status,
        LegacyPlayoutWorkflowState workflow)
    {
        if (IsPlaylistMutationIdle(status, workflow))
        {
            return true;
        }

        // PREPARED is deliberately not opened: unlike PROGRAM, the original parity
        // evidence does not establish a safe checkbox contract for that phase.
        if (string.IsNullOrWhiteSpace(status.OnAirSceneName) ||
            string.IsNullOrWhiteSpace(workflow.OnAirCutCode) ||
            string.IsNullOrWhiteSpace(workflow.OnAirEntryId) ||
            workflow.OnAirCueIndexZeroBased < 0)
        {
            return false;
        }

        var playlist = _controller.Current.Playlist;
        var currentIndex = workflow.OnAirCueIndexZeroBased;
        return currentIndex < playlist.Count &&
            string.Equals(
                playlist[currentIndex].RowId,
                workflow.OnAirEntryId,
                StringComparison.Ordinal) &&
            playlist.Count(row => string.Equals(
                row.RowId,
                workflow.OnAirEntryId,
                StringComparison.Ordinal)) == 1;
    }

    private static bool IsOperatorMutationIntent(LegacyUiIntent intent) => intent is
        LegacyCutPointerDownIntent { AllowAppend: true } or
        LegacyDropSelectedCutsIntent or
        LegacySetPlaylistEnabledIntent or
        LegacySetAllPlaylistEnabledIntent or
        LegacyMoveSelectedPlaylistRowsIntent or
        LegacyReorderPlaylistRowsIntent or
        LegacyDeleteSelectedPlaylistRowsIntent or
        LegacyDeleteAllPlaylistRowsIntent or
        LegacyToggleActivePlaylistEnabledIntent or
        LegacyActivateFixedActionIntent or
        LegacyActivateFixedSectionIntent or
        LegacyActivateIndustryActionIntent or
        LegacyActivateIndustrySectionIntent or
        LegacyActivateOverseasActionIntent or
        LegacyAddComparisonPairIntent or
        LegacyImportComparisonPairsIntent or
        LegacyMoveComparisonPairIntent or
        LegacyDeleteSelectedComparisonPairIntent or
        LegacyDeleteAllComparisonPairsIntent or
        LegacyActivateComparisonActionIntent or
        LegacyActivateComparisonSectionIntent or
        LegacyActivateThemeActionIntent or
        LegacyActivateThemeResultIntent or
        LegacyActivateExpertActionIntent or
        LegacyActivateTradingHaltActionIntent or
        LegacyBeginUc4ThemeCatalogIntent or
        LegacyEditUc4SelectedThemeIntent or
        LegacyDeleteUc4SelectedThemeIntent or
        LegacyBeginUc6ExpertCatalogIntent or
        LegacyEditUc6SelectedExpertIntent or
        LegacyDeleteUc6SelectedExpertIntent or
        LegacyBeginCreateThemeCatalogIntent or
        LegacyChangeCreateThemeMarketIntent or
        LegacyBeginCreateExpertCatalogIntent or
        LegacyAddOperatorCatalogStockIntent or
        LegacyActivateOperatorCatalogStockIntent or
        LegacyMoveOperatorCatalogDraftRowIntent or
        LegacyReorderOperatorCatalogDraftRowIntent or
        LegacyRemoveOperatorCatalogDraftRowIntent or
        LegacyRemoveActiveOperatorCatalogDraftRowIntent or
        LegacyClearOperatorCatalogDraftRowsIntent or
        LegacySetOperatorCatalogDraftBuyAmountIntent or
        LegacySaveOperatorCatalogIntent or
        LegacyConfirmNativeDialogIntent or
        LegacyLoadSelectedNamedPlaylistIntent or
        LegacyLoadNamedPlaylistByIdIntent or
        LegacyCreateNamedPlaylistIntent or
        LegacySaveCurrentNamedPlaylistIntent or
        LegacySaveCurrentNamedPlaylistToIntent or
        LegacyDeleteSelectedNamedPlaylistIntent or
        LegacySaveManualFinancialIntent or
        LegacySaveManualFinancialRawIntent or
        LegacyDeleteManualFinancialIntent or
        LegacyDeleteAllManualFinancialIntent or
        LegacyActivateManualFinancialActionIntent or
        LegacyActivateManualFinancialResultIntent or
        LegacySetManualNetSellCellIntent or
        LegacySaveManualNetSellIntent or
        LegacyAddManualNetSellCutIntent or
        LegacyAddManualViResultIntent or
        LegacyMoveManualViItemIntent or
        LegacyDeleteManualViItemIntent or
        LegacyDeleteAllManualViItemsIntent or
        LegacySaveManualViIntent or
        LegacyAddManualViCutIntent or
        LegacyConfirmManualListIntent or
        LegacyImportManualListsIntent;

    private static bool IsPersistentWriteIntent(LegacyUiIntent intent) => intent is
        LegacyDeleteUc4SelectedThemeIntent or
        LegacyDeleteUc6SelectedExpertIntent or
        LegacySaveOperatorCatalogIntent or
        LegacyConfirmNativeDialogIntent or
        LegacyAddComparisonPairIntent or
        LegacyMoveComparisonPairIntent or
        LegacyDeleteSelectedComparisonPairIntent or
        LegacyDeleteAllComparisonPairsIntent or
        LegacySaveManualFinancialIntent or
        LegacySaveManualFinancialRawIntent or
        LegacyDeleteManualFinancialIntent or
        LegacyDeleteAllManualFinancialIntent or
        LegacySaveManualNetSellIntent or
        LegacySaveManualViIntent or
        LegacyConfirmManualListIntent or
        LegacyImportManualListsIntent or
        LegacyCreateNamedPlaylistIntent or
        LegacySaveCurrentNamedPlaylistIntent or
        LegacySaveCurrentNamedPlaylistToIntent or
        LegacyDeleteSelectedNamedPlaylistIntent or
        LegacyChooseOperatorFolderIntent or
        LegacyResetOperatorFolderIntent or
        LegacySetOperatorNavigationExpandedIntent or
        LegacySetOperatorAppearanceIntent;

    private static bool IsFixedSectionBatchMutationIntent(LegacyUiIntent intent) => intent is
        LegacySetManualNetSellCellIntent or
        LegacyAddManualViResultIntent or
        LegacyMoveManualViItemIntent or
        LegacyDeleteManualViItemIntent or
        LegacyDeleteAllManualViItemsIntent or
        LegacyConfirmManualListIntent or
        LegacyImportManualListsIntent;

    private void ObserveOperatorMutationQuarantine(LegacyOperatorSnapshot snapshot)
    {
        if (snapshot.ManualFinancial?.WritesQuarantined == true ||
            snapshot.NamedPlaylist?.IsWriteQuarantined == true ||
            snapshot.ManualLists?.WritesQuarantined == true ||
            snapshot.OperatorCatalog?.WritesQuarantined == true)
        {
            Interlocked.Exchange(ref _operatorMutationQuarantined, 1);
        }
    }

    private bool IsOperatorMutationQuarantined() =>
        Volatile.Read(ref _operatorMutationQuarantined) != 0;

    private LegacyWorkflowNextKind ResolveOperatorNextKind(
        LegacyOperatorPlayoutPhase phase,
        LegacyPlayoutWorkflowState? workflow)
    {
        var retainedKind = workflow?.NextKind ?? LegacyWorkflowNextKind.None;
        if (phase != LegacyOperatorPlayoutPhase.Program || workflow is null)
        {
            return retainedKind;
        }

        // The workflow snapshot is intentionally frozen until NEXT. MainForm's
        // PROGRAM Space/"all" gestures, however, immediately changed which future
        // row NEXT would choose. Derive the visible/available NEXT state from the
        // live operator flags without mutating the scene retained by the engine.
        if (!workflow.IsLastPage)
        {
            return LegacyWorkflowNextKind.PageNext;
        }

        var currentIndex = workflow.CurrentCueIndexZeroBased;
        var playlist = _controller.Current.Playlist;
        if (currentIndex < 0 || currentIndex >= playlist.Count ||
            string.IsNullOrWhiteSpace(workflow.CurrentEntryId) ||
            !string.Equals(
                playlist[currentIndex].RowId,
                workflow.CurrentEntryId,
                StringComparison.Ordinal))
        {
            return LegacyWorkflowNextKind.None;
        }

        return playlist
            .Skip(currentIndex + 1)
            .Any(static row => row.IsEnabled)
                ? LegacyWorkflowNextKind.PlaylistNext
                : LegacyWorkflowNextKind.EndOfPlaylist;
    }

    private LegacyOperatorPlayoutSnapshot CreatePlayoutSnapshot()
    {
        var status = _playoutEngine?.Status;
        var workflow = _playoutWorkflow?.State;
        var refresh = _refreshEpoch.ReadState();
        var composition = _stagedCompositionOptions?.Current ??
            _compositionOptions?.Current ??
            LegacySceneCueCompositionOptions.Default;
        var quarantined = Volatile.Read(ref _playoutQuarantined) != 0 ||
            status?.State == PlayoutConnectionState.OutcomeUnknown;
        var phase = !string.IsNullOrWhiteSpace(status?.OnAirSceneName)
            ? LegacyOperatorPlayoutPhase.Program
            : !string.IsNullOrWhiteSpace(status?.PreparedSceneName)
                ? LegacyOperatorPlayoutPhase.Prepared
                : LegacyOperatorPlayoutPhase.Idle;
        var nextKind = ResolveOperatorNextKind(phase, workflow);
        var backgroundEnabled =
            composition.BackgroundKind != LegacySceneBackgroundKind.None;
        var snapshot = new LegacyOperatorPlayoutSnapshot(
            status?.Mode ?? PlayoutMode.Disabled,
            quarantined
                ? PlayoutConnectionState.OutcomeUnknown
                : status?.State ?? PlayoutConnectionState.Disabled,
            phase,
            status?.IsProcessRunning ?? false,
            status?.IsConnected ?? false,
            (status?.IsCommandAvailable ?? false) && !quarantined,
            (status?.LiveTakeInAllowed ?? false) && !quarantined,
            status?.IsPlayCompletionPending ?? false,
            quarantined,
            status?.PreparedSceneName,
            status?.OnAirSceneName,
            workflow?.CurrentCueIndexZeroBased ?? -1,
            workflow?.CurrentEntryId,
            workflow?.BuilderKey,
            workflow?.PageIndexZeroBased ?? 0,
            workflow?.PageCount ?? 0,
            workflow?.PageSize ?? 0,
            workflow?.ItemCount ?? 0,
            workflow?.CurrentPageItemCount ?? 0,
            workflow?.IsLastPage ?? true,
            nextKind,
            Volatile.Read(ref _playoutBusy) != 0,
            refresh.IsActive,
            refresh.CompletedRefreshCount,
            refresh.MaximumRefreshCount,
            refresh.IsLimitReached,
            composition.FadeDuration,
            backgroundEnabled,
            backgroundEnabled
                ? Path.GetFileName(composition.BackgroundAssetPath) ?? string.Empty
                : string.Empty,
            CanStageBackground(),
            quarantined
                ? "결과 불명확 상태입니다. 명령을 반복하지 마세요."
                : refresh.Message.Length > 0
                    ? refresh.Message
                    : _playoutInitializationWarning ?? status?.Message ??
                        _playoutInitializationError ??
                        "송출 어댑터를 사용할 수 없습니다.");
        return snapshot with
        {
            OnAirCueIndexZeroBased = workflow?.OnAirCueIndexZeroBased ?? -1,
            OnAirEntryId = workflow?.OnAirEntryId,
            IsTakeOutCompletionPending = status?.IsTakeOutCompletionPending ?? false,
            RefreshNextAtUtc = refresh.NextAtUtc,
            RefreshLastSuccessAtUtc = refresh.LastSuccessAtUtc,
            RefreshFaultCode = refresh.FaultCode
        };
    }

    private void StartRefreshLoop(LegacyPlayoutWorkflow workflow)
    {
        ResetRefreshState();
        var cutCode = workflow.State.OnAirCutCode;
        if (!LegacySceneRefreshPolicy.TryGetInterval(cutCode, out var firstInterval) ||
            firstInterval <= TimeSpan.Zero ||
            Volatile.Read(ref _playoutShutdownStarted) != 0)
        {
            return;
        }

        var scheduler = new LegacyRefreshScheduler(
            firstInterval,
            LegacySceneRefreshPolicy.RecurringInterval,
            _playoutOptions?.MaximumAutomaticRefreshesPerTakeIn);
        if (scheduler.HasReachedMaximum)
        {
            _refreshTask = Task.CompletedTask;
            _refreshEpoch.Stop(_ => LegacyRefreshRuntimeStatus.Empty with
            {
                MaximumRefreshCount = scheduler.MaximumRefreshCount,
                IsLimitReached = true,
                Message = $"자동 갱신 상한 {scheduler.MaximumRefreshCount}회가 설정되어 자동 갱신을 시작하지 않았습니다."
            });
            QueueOperatorState();
            return;
        }

        var cancellation = CancellationTokenSource.CreateLinkedTokenSource(
            _lifetimeCancellation.Token);
        _refreshEpoch.Replace(
            cancellation,
            _ => new LegacyRefreshRuntimeStatus(
                true,
                null,
                null,
                false,
                null,
                string.Empty,
                scheduler.CompletedRefreshCount,
                scheduler.MaximumRefreshCount,
                false));
        _refreshTask = RunRefreshLoopAsync(workflow, scheduler, cancellation);
    }

    private void StopRefreshLoop() =>
        _refreshEpoch.Stop(status => status with
        {
            IsActive = false,
            NextAtUtc = null
        });

    private void ResetRefreshState() =>
        _refreshEpoch.Stop(_ => LegacyRefreshRuntimeStatus.Empty with
        {
            MaximumRefreshCount = _playoutOptions?.MaximumAutomaticRefreshesPerTakeIn
        });

    private async Task RunRefreshLoopAsync(
        LegacyPlayoutWorkflow workflow,
        LegacyRefreshScheduler scheduler,
        CancellationTokenSource cancellation)
    {
        var token = cancellation.Token;
        try
        {
            while (true)
            {
                var engine = _playoutEngine;
                if (engine is null ||
                    !ReferenceEquals(_playoutWorkflow, workflow) ||
                    !_refreshEpoch.IsCurrent(cancellation) ||
                    Volatile.Read(ref _playoutShutdownStarted) != 0)
                {
                    return;
                }

                if (engine.Status.State == PlayoutConnectionState.OutcomeUnknown)
                {
                    TrySetRefreshFault(
                        cancellation,
                        "PLAYOUT_OUTCOME_UNKNOWN",
                        "송출 상태가 불명확하여 자동 갱신을 시작하지 않았습니다.");
                    await QuarantinePlayoutAsync().ConfigureAwait(false);
                    return;
                }

                if (scheduler.HasReachedMaximum)
                {
                    if (!await scheduler.DrainMaximumCallbackAsync(
                            callbackToken => WaitForPlayCompletionAsync(
                                engine,
                                callbackToken),
                            token).ConfigureAwait(false))
                    {
                        TrySetRefreshFault(
                            cancellation,
                            "PLAY_CALLBACK_TIMEOUT",
                            "자동 갱신 상한에 도달했지만 마지막 재생 완료 이벤트를 확인하지 못했습니다. 결과가 불명확하므로 PGM을 확인하세요.");
                        await QuarantinePlayoutAsync().ConfigureAwait(false);
                        return;
                    }

                    if (engine.Status.State == PlayoutConnectionState.OutcomeUnknown)
                    {
                        TrySetRefreshFault(
                            cancellation,
                            "PLAY_CALLBACK_OUTCOME_UNKNOWN",
                            "마지막 재생 완료 상태가 불명확합니다. PGM과 Network Monitoring을 확인하세요.");
                        await QuarantinePlayoutAsync().ConfigureAwait(false);
                        return;
                    }

                    _refreshEpoch.TryUpdate(
                        cancellation,
                        status => status with
                        {
                            IsActive = false,
                            NextAtUtc = null,
                            CompletedRefreshCount = scheduler.CompletedRefreshCount,
                            MaximumRefreshCount = scheduler.MaximumRefreshCount,
                            IsLimitReached = true,
                            Message = $"자동 갱신 상한 {scheduler.MaximumRefreshCount}회에 도달했습니다."
                        });
                    QueueOperatorState();
                    return;
                }

                if (!await scheduler.WaitForNextRefreshAsync(
                        callbackToken => WaitForPlayCompletionAsync(
                            engine,
                            callbackToken),
                        scheduledDelay =>
                        {
                            if (_refreshEpoch.TryUpdate(
                                    cancellation,
                                    status => status.IsActive
                                        ? status with
                                        {
                                            NextAtUtc = DateTimeOffset.UtcNow.Add(scheduledDelay)
                                        }
                                        : status))
                            {
                                QueueOperatorState();
                            }
                        },
                        token).ConfigureAwait(false))
                {
                    TrySetRefreshFault(
                        cancellation,
                        "PLAY_CALLBACK_TIMEOUT",
                        "재생 완료 이벤트를 확인하지 못해 자동 갱신을 중단했습니다. 결과가 불명확하므로 PGM을 확인하세요.");
                    await QuarantinePlayoutAsync().ConfigureAwait(false);
                    return;
                }

                if (!ReferenceEquals(_playoutWorkflow, workflow) ||
                    !_refreshEpoch.IsCurrent(cancellation) ||
                    Volatile.Read(ref _playoutShutdownStarted) != 0)
                {
                    return;
                }

                if (engine.Status.State == PlayoutConnectionState.OutcomeUnknown)
                {
                    TrySetRefreshFault(
                        cancellation,
                        "PLAY_CALLBACK_OUTCOME_UNKNOWN",
                        "재생 완료 상태가 불명확하여 자동 갱신을 시작하지 않았습니다.");
                    await QuarantinePlayoutAsync().ConfigureAwait(false);
                    return;
                }

                var intentGateEntered = false;
                var databaseGateEntered = false;
                var playoutGateEntered = false;
                try
                {
                    // Match MainForm's single UI thread: Web DB work and the timer
                    // refresh are serialized in the same order as ProcessIntentAsync.
                    await _intentGate.WaitAsync(token).ConfigureAwait(false);
                    intentGateEntered = true;
                    _intentBusy = true;
                    QueueOperatorState();

                    await AcquirePriorityDatabaseActivityAsync(token).ConfigureAwait(false);
                    databaseGateEntered = true;

                    await _playoutCommandGate.WaitAsync(token).ConfigureAwait(false);
                    playoutGateEntered = true;
                    Interlocked.Exchange(ref _playoutBusy, 1);
                    QueueOperatorState();

                    if (!_refreshEpoch.IsCurrent(cancellation) ||
                        Volatile.Read(ref _playoutShutdownStarted) != 0 ||
                        engine.Status.State == PlayoutConnectionState.OutcomeUnknown)
                    {
                        if (engine.Status.State == PlayoutConnectionState.OutcomeUnknown)
                        {
                            TrySetRefreshFault(
                                cancellation,
                                "REFRESH_OUTCOME_UNKNOWN",
                                "송출 상태가 불명확하여 자동 갱신을 시작하지 않았습니다.");
                            await QuarantinePlayoutAsync().ConfigureAwait(false);
                        }
                        return;
                    }

                    var result = await scheduler.ExecuteRefreshAndTrackSuccessAsync(
                        workflow.RefreshOnAirAsync,
                        static refreshResult => refreshResult.IsSuccess,
                        token).ConfigureAwait(false);
                    if (!result.IsSuccess)
                    {
                        TrySetRefreshFault(
                            cancellation,
                            RefreshFaultCode(result.Code),
                            "자동 장면 갱신을 중단했습니다. TAKE OUT 전 실제 데이터와 PGM을 확인하세요.");
                        if (result.Code is PlayoutResultCode.OutcomeUnknown or
                            PlayoutResultCode.TimedOut)
                        {
                            await QuarantinePlayoutAsync().ConfigureAwait(false);
                        }

                        return;
                    }

                    if (!_refreshEpoch.TryUpdate(
                            cancellation,
                            _ => new LegacyRefreshRuntimeStatus(
                                true,
                                null,
                                DateTimeOffset.UtcNow,
                                false,
                                null,
                                string.Empty,
                                scheduler.CompletedRefreshCount,
                                scheduler.MaximumRefreshCount,
                                false)))
                    {
                        return;
                    }
                }
                catch (OperationCanceledException) when (token.IsCancellationRequested)
                {
                    return;
                }
                catch (DatabaseInfrastructureException)
                {
                    TrySetRefreshFault(
                        cancellation,
                        "DATABASE_UNAVAILABLE",
                        "자동 장면 갱신 중 데이터베이스 조회가 실패했습니다. TAKE OUT 후 확인하세요.");
                    return;
                }
                catch (LegacySceneDataException)
                {
                    TrySetRefreshFault(
                        cancellation,
                        "SCENE_DATA_INVALID",
                        "자동 장면 갱신 데이터가 유효하지 않습니다. TAKE OUT 후 확인하세요.");
                    return;
                }
                catch (ArgumentException)
                {
                    TrySetRefreshFault(
                        cancellation,
                        "SCENE_ARGUMENT_INVALID",
                        "자동 장면 갱신 요청 데이터가 유효하지 않습니다. TAKE OUT 후 확인하세요.");
                    return;
                }
                catch
                {
                    TrySetRefreshFault(
                        cancellation,
                        "REFRESH_OUTCOME_UNKNOWN",
                        "자동 갱신 결과가 불명확합니다. 명령을 반복하지 마세요.");
                    await QuarantinePlayoutAsync().ConfigureAwait(false);
                    return;
                }
                finally
                {
                    if (playoutGateEntered)
                    {
                        Interlocked.Exchange(ref _playoutBusy, 0);
                        _playoutCommandGate.Release();
                    }

                    if (databaseGateEntered)
                    {
                        _databaseActivityGate.Release();
                    }

                    if (intentGateEntered)
                    {
                        _intentBusy = false;
                        _intentGate.Release();
                    }

                    QueueOperatorState();
                }
            }
        }
        catch (OperationCanceledException) when (token.IsCancellationRequested)
        {
        }
        catch
        {
            TrySetRefreshFault(
                cancellation,
                "REFRESH_SCHEDULER_UNKNOWN",
                "자동 갱신 예약 상태를 확인할 수 없습니다. 명령을 반복하지 마세요.");
            await QuarantinePlayoutAsync().ConfigureAwait(false);
        }
        finally
        {
            var completedCurrentEpoch = _refreshEpoch.TryComplete(
                cancellation,
                status => status with
                {
                    IsActive = false,
                    NextAtUtc = null
                });
            cancellation.Dispose();
            if (completedCurrentEpoch)
            {
                QueueOperatorState();
            }
        }
    }

    private bool TrySetRefreshFault(
        CancellationTokenSource cancellation,
        string code,
        string message) =>
        _refreshEpoch.TryUpdate(
            cancellation,
            status => status with
            {
                IsActive = false,
                NextAtUtc = null,
                IsFaulted = true,
                FaultCode = code,
                Message = message
            });

    private static string RefreshFaultCode(PlayoutResultCode code) => code switch
    {
        PlayoutResultCode.OutcomeUnknown => "OUTCOME_UNKNOWN",
        PlayoutResultCode.TimedOut => "TIMED_OUT",
        PlayoutResultCode.Rejected => "REJECTED",
        PlayoutResultCode.Cancelled => "CANCELLED",
        PlayoutResultCode.Unavailable => "UNAVAILABLE",
        PlayoutResultCode.Failed => "FAILED",
        _ => "REFRESH_FAILED"
    };

    private static async Task<bool> WaitForPlayCompletionAsync(
        IPlayoutEngine engine,
        CancellationToken cancellationToken)
    {
        if (!engine.Status.IsPlayCompletionPending)
        {
            return true;
        }

        var timeout = Math.Clamp(
            engine.Status.OperationTimeoutMilliseconds,
            100,
            300_000);
        using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
        linked.CancelAfter(timeout);
        while (engine.Status.IsPlayCompletionPending)
        {
            var completion = new TaskCompletionSource(
                TaskCreationOptions.RunContinuationsAsynchronously);
            void OnStatus(object? sender, PlayoutStatusChangedEventArgs args)
            {
                if (!args.Current.IsPlayCompletionPending)
                {
                    completion.TrySetResult();
                }
            }

            engine.StatusChanged += OnStatus;
            try
            {
                if (!engine.Status.IsPlayCompletionPending)
                {
                    return true;
                }

                await completion.Task.WaitAsync(linked.Token).ConfigureAwait(false);
            }
            catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
            {
                return false;
            }
            finally
            {
                engine.StatusChanged -= OnStatus;
            }
        }

        return true;
    }

    private async ValueTask QuarantinePlayoutAsync()
    {
        if (Interlocked.Exchange(ref _playoutQuarantined, 1) != 0)
        {
            return;
        }

        StopRefreshLoop();
        if (_playoutEngine is not null)
        {
            try
            {
                await _playoutEngine.QuarantineAsync().ConfigureAwait(false);
            }
            catch
            {
            }
        }
    }

    private void QueueOperatorState()
    {
        if (_closing || !_webViewReady)
        {
            return;
        }

        DispatcherQueue.TryEnqueue(() =>
        {
            if (!_closing)
            {
                PostState(_controller.Current);
            }
        });
    }

    private async Task ShutdownPlayoutRuntimeAsync()
    {
        if (Interlocked.Exchange(ref _playoutShutdownStarted, 1) != 0)
        {
            return;
        }

        StopRefreshLoop();
        var refresh = _refreshTask;
        if (refresh is not null)
        {
            try
            {
                await refresh.WaitAsync(TimeSpan.FromSeconds(2));
            }
            catch
            {
            }
        }

        var engine = _playoutEngine;
        _playoutEngine = null;
        _playoutWorkflow = null;
        if (engine is null)
        {
            return;
        }

        engine.StatusChanged -= OnPlayoutStatusChanged;
        try
        {
            if (Volatile.Read(ref _playoutQuarantined) == 0 &&
                engine.Status.State != PlayoutConnectionState.OutcomeUnknown &&
                string.IsNullOrWhiteSpace(engine.Status.OnAirSceneName) &&
                !engine.Status.IsPlayCompletionPending &&
                !engine.Status.IsTakeOutCompletionPending)
            {
                using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
                await engine.DisconnectAsync(timeout.Token).ConfigureAwait(false);
            }
        }
        catch
        {
        }

        await engine.DisposeAsync().ConfigureAwait(false);
    }

    private void ShutdownPlayoutRuntime()
    {
        try
        {
            ShutdownPlayoutRuntimeAsync()
                .Wait(TimeSpan.FromSeconds(8));
        }
        catch
        {
            // Window shutdown is bounded and best-effort. The engine itself keeps an
            // outcome-unknown session quarantined and will not send a duplicate BYE.
        }
    }

    private sealed record LegacyRefreshRuntimeStatus(
        bool IsActive,
        DateTimeOffset? NextAtUtc,
        DateTimeOffset? LastSuccessAtUtc,
        bool IsFaulted,
        string? FaultCode,
        string Message,
        int CompletedRefreshCount,
        int? MaximumRefreshCount,
        bool IsLimitReached)
    {
        internal static LegacyRefreshRuntimeStatus Empty { get; } = new(
            false,
            null,
            null,
            false,
            null,
            string.Empty,
            0,
            null,
            false);
    }
}
