using System.Text.Json;
using MBN_STOCK_WEBVIEW.Core.Playout;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
using MBN_STOCK_WEBVIEW.Infrastructure;
using MBN_STOCK_WEBVIEW.Infrastructure.Diagnostics;
using MBN_STOCK_WEBVIEW.Playout;
using MBN_STOCK_WEBVIEW.Playout.Configuration;

namespace MBN_STOCK_WEBVIEW;

public sealed partial class MainWindow
{
    private const int MaximumPlayoutRequestIdLength = 128;
    private const string BrowserCorrelationQuarantineMessage =
        "Browser correlation was lost before the playout result could be confirmed. Verify PGM output and restart the app before issuing another command.";

    private readonly SemaphoreSlim _playoutCommandGate = new(1, 1);
    private IPlayoutEngine? _playoutEngine;
    private LegacyPlayoutWorkflow? _playoutWorkflow;
    private PlayoutOptions? _playoutOptions;
    private MutableLegacySceneCueCompositionOptionsSource? _legacyCompositionOptionsSource;
    private string? _playoutInitializationError;
    private int _playoutCommandInFlight;
    private int _playoutShutdownStarted;
    private int _browserCorrelationQuarantined;
    private int _operatorMutationQuarantined;
    private string? _operatorMutationQuarantineMessage;
    private Task? _legacyRefreshTask;
    private readonly LegacyRefreshEpoch<LegacyRefreshRuntimeStatus> _legacyRefreshEpoch =
        new(LegacyRefreshRuntimeStatus.Empty);

    private void InitializePlayoutRuntime()
    {
        try
        {
            var options = PlayoutOptionsLoader.Load();
            var compositionOptions = PlayoutSceneCompositionFactory.Create(options);
            _playoutOptions = options;
            _legacyCompositionOptionsSource =
                new MutableLegacySceneCueCompositionOptionsSource(compositionOptions);
            _playoutEngine = PlayoutEngineFactory.Create(options);
            ResetLegacyRefreshStatus();
            _playoutEngine.StatusChanged += OnPlayoutStatusChanged;
            ObserveNativeTornadoState(_playoutEngine.Status.State);
            if (_databaseRuntime is not null)
            {
                _playoutWorkflow = LegacySceneRuntimeFactory.Create(
                    _playoutEngine,
                    _databaseRuntime.Executor,
                    compositionOptionsSource: _legacyCompositionOptionsSource,
                    trustedManualSource: _manualNetSellStore,
                    trustedViSource: _viManualListStore);
            }
        }
        catch (Exception exception)
        {
            ObserveNativeTornadoState(PlayoutConnectionState.Faulted);
            _playoutInitializationError = exception is PlayoutConfigurationException
                ? exception.Message
                : "Tornado 송출 어댑터를 초기화하지 못했습니다.";
        }
    }

    private async Task ConnectPlayoutAsync(CancellationToken cancellationToken)
    {
        var engine = _playoutEngine;
        if (engine is null || cancellationToken.IsCancellationRequested)
        {
            QueuePlayoutStatus();
            return;
        }

        try
        {
            await engine.ConnectAsync(cancellationToken);
        }
        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
        {
            return;
        }
        catch
        {
            _playoutInitializationError = "Tornado 송출 엔진에 연결하지 못했습니다.";
        }

        QueuePlayoutStatus();
    }

    private void OnPlayoutStatusChanged(object? sender, PlayoutStatusChangedEventArgs args)
    {
        ObserveNativeTornadoState(args.Current.State);
        _playoutWorkflow?.ObserveEngineStatus(args.Current);
        if (string.IsNullOrWhiteSpace(args.Current.PreparedSceneName) &&
            string.IsNullOrWhiteSpace(args.Current.OnAirSceneName))
        {
            StopLegacyRefreshLoop();
            ResetLegacyRefreshStatus();
        }
        if (Volatile.Read(ref _playoutCommandInFlight) != 0 ||
            Volatile.Read(ref _playoutShutdownStarted) != 0)
        {
            return;
        }

        QueuePlayoutStatus();
    }

    private void QueuePlayoutStatus()
    {
        if (DispatcherQueue.HasThreadAccess)
        {
            PostPlayoutStatus();
            return;
        }

        DispatcherQueue.TryEnqueue(() => PostPlayoutStatus());
    }

    private void HandlePlayoutStatusRequest(JsonElement payload)
    {
        if (payload.ValueKind != JsonValueKind.Object ||
            !HasOnlyProperties(payload, "requestId") ||
            !TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out var requestId))
        {
            PostMessage("bridge-error", new { message = "올바르지 않은 송출 상태 요청입니다." });
            return;
        }

        PostPlayoutStatus(requestId);
    }

    private async Task HandlePlayoutCommandAsync(JsonElement payload)
    {
        var parsed = PlayoutBridgeProtocol.ParseWorkflowCommand(payload);
        var request = parsed.Request;
        if (!parsed.IsValid)
        {
            PostPlayoutCommandError(
                request.RequestId,
                request.Command,
                "INVALID_REQUEST",
                parsed.Error,
                retryable: false,
                outcomeUnknown: false);
            return;
        }

        if (IsBrowserCorrelationQuarantined())
        {
            PostPlayoutCommandError(
                request.RequestId,
                request.Command,
                "OUTCOME_UNKNOWN",
                BrowserCorrelationQuarantineMessage,
                retryable: false,
                outcomeUnknown: true);
            return;
        }

        var engine = _playoutEngine;
        var workflow = _playoutWorkflow;
        if (engine is null || workflow is null)
        {
            PostPlayoutCommandError(
                request.RequestId,
                request.Command,
                "PLAYOUT_UNAVAILABLE",
                _playoutInitializationError ?? "Tornado 송출 어댑터를 사용할 수 없습니다.",
                retryable: false,
                outcomeUnknown: false);
            return;
        }

        if (!await _playoutCommandGate.WaitAsync(0))
        {
            PostPlayoutCommandError(
                request.RequestId,
                request.Command,
                "PLAYOUT_BUSY",
                "다른 송출 명령을 처리하고 있습니다.",
                retryable: true,
                outcomeUnknown: false);
            return;
        }

        var databaseActivityEntered = false;
        try
        {
            LogNativePlayoutCommandRequested(request.Command);
            Interlocked.Exchange(ref _playoutCommandInFlight, 1);
            if (IsBrowserCorrelationQuarantined())
            {
                LogNativePlayoutOutcomeUnknown(request.Command);
                PostPlayoutCommandError(
                    request.RequestId,
                    request.Command,
                    "OUTCOME_UNKNOWN",
                    BrowserCorrelationQuarantineMessage,
                    retryable: false,
                    outcomeUnknown: true);
                return;
            }

            var refreshBeforeCommand = GetLegacyRefreshStatus();
            if (refreshBeforeCommand.IsFaulted && request.Command != "take-out")
            {
                LogNativePlayoutCommandCompleted(
                    request.Command,
                    NativeOperatorPlayoutCompletion.Rejected);
                PostPlayoutCommandError(
                    request.RequestId,
                    request.Command,
                    "REFRESH_FAULT",
                    refreshBeforeCommand.FaultMessage ??
                        "자동 장면 갱신이 중단되었습니다. TAKE OUT 후 상태를 확인하세요.",
                    retryable: false,
                    outcomeUnknown: false);
                return;
            }

            var nextKindBeforeCommand = workflow.State.NextKind;
            // MainForm stops timer1 before every operator command. TAKE IN and a
            // successful playlist NEXT start it again; Page NEXT intentionally does not.
            StopLegacyRefreshLoop();
            // Do not wait or enter the workflow while a prior Play callback is
            // pending. NEXT fails closed immediately so the browser request and both
            // gates are released, leaving an emergency TAKE OUT actionable.
            if (request.Command == "next" && engine.Status.IsPlayCompletionPending)
            {
                LogNativePlayoutCommandCompleted(
                    request.Command,
                    NativeOperatorPlayoutCompletion.Rejected);
                PostPlayoutCommandError(
                    request.RequestId,
                    request.Command,
                    "PLAY_CALLBACK_PENDING",
                    "Tornado play completion is still pending. Wait for the status update or use TAKE OUT.",
                    retryable: true,
                    outcomeUnknown: false);
                return;
            }

            CancelActiveMarketDataRequest();
            CancelActiveDatabaseHealthCheck();
            await _databaseActivityGate.WaitAsync(_lifetimeCancellation.Token);
            databaseActivityEntered = true;
            if (IsBrowserCorrelationQuarantined())
            {
                LogNativePlayoutOutcomeUnknown(request.Command);
                PostPlayoutCommandError(
                    request.RequestId,
                    request.Command,
                    "OUTCOME_UNKNOWN",
                    BrowserCorrelationQuarantineMessage,
                    retryable: false,
                    outcomeUnknown: true);
                return;
            }

            var result = await ExecutePlayoutCommandAsync(
                workflow,
                request,
                _lifetimeCancellation.Token);
            LogNativePlayoutCommandCompleted(request.Command, result.Code);
            var status = engine.Status;
            var workflowState = workflow.State;

            if (!result.IsSuccess)
            {
                PostPlayoutCommandError(
                    request.RequestId,
                    request.Command,
                    ToWireValue(result.Code),
                    result.Message,
                    IsRetryable(result.Code),
                    result.Code is PlayoutResultCode.OutcomeUnknown or PlayoutResultCode.TimedOut);
                return;
            }

            if (request.Command == "take-in" ||
                (request.Command == "next" &&
                    nextKindBeforeCommand == LegacyWorkflowNextKind.PlaylistNext))
            {
                StartLegacyRefreshLoop(workflow);
            }
            else if (request.Command == "take-out")
            {
                ResetLegacyRefreshStatus();
            }

            var refreshStatus = GetLegacyRefreshStatus();

            PostMessage("playout-command-result", new
            {
                requestId = request.RequestId,
                command = request.Command,
                succeeded = true,
                dryRun = result.IsDryRun,
                state = ToPlayoutPhase(status),
                message = result.Message,
                preparedCode = status.PreparedSceneName,
                onAirCode = status.OnAirSceneName,
                playCompletionPending = status.IsPlayCompletionPending,
                currentCueIndex = workflowState.CurrentCueIndexZeroBased,
                currentEntryId = workflowState.CurrentEntryId,
                builderKey = workflowState.BuilderKey,
                pageIndex = workflowState.PageIndexZeroBased,
                pageCount = workflowState.PageCount,
                pageSize = workflowState.PageSize,
                itemCount = workflowState.ItemCount,
                currentPageItemCount = workflowState.CurrentPageItemCount,
                isLastPage = workflowState.IsLastPage,
                nextKind = ToWireValue(workflowState.NextKind),
                previewFields = workflowState.PreviewFields,
                refreshActive = refreshStatus.IsActive,
                refreshNextAt = refreshStatus.NextAtUtc,
                refreshLastSuccessAt = refreshStatus.LastSuccessAtUtc,
                refreshFaulted = refreshStatus.IsFaulted,
                refreshFaultCode = refreshStatus.FaultCode,
                refreshFaultMessage = refreshStatus.FaultMessage,
                refreshCompletedCount = refreshStatus.CompletedRefreshCount,
                refreshMaximumCount = refreshStatus.MaximumRefreshCount,
                refreshLimitReached = refreshStatus.IsLimitReached
            });
        }
        catch (OperationCanceledException)
        {
            LogNativePlayoutCommandCompleted(
                request.Command,
                NativeOperatorPlayoutCompletion.Cancelled);
            PostPlayoutCommandError(
                request.RequestId,
                request.Command,
                "CANCELLED",
                "송출 요청이 취소되었습니다.",
                retryable: true,
                outcomeUnknown: false);
        }
        catch (DatabaseOperationException exception)
        {
            LogNativePlayoutCommandCompleted(
                request.Command,
                NativeOperatorPlayoutCompletion.Unavailable);
            PostPlayoutCommandError(
                request.RequestId,
                request.Command,
                "DATABASE_UNAVAILABLE",
                exception.Message,
                retryable: true,
                outcomeUnknown: false);
        }
        catch (Exception exception) when (
            exception is LegacySceneDataException or ArgumentException)
        {
            LogNativePlayoutCommandCompleted(
                request.Command,
                NativeOperatorPlayoutCompletion.Rejected);
            PostPlayoutCommandError(
                request.RequestId,
                request.Command,
                "SCENE_DATA_INVALID",
                exception.Message,
                retryable: false,
                outcomeUnknown: false);
        }
        catch
        {
            LogNativePlayoutOutcomeUnknown(request.Command);
            PostPlayoutCommandError(
                request.RequestId,
                request.Command,
                "PLAYOUT_FAILED",
                "송출 엔진 명령을 완료하지 못했습니다.",
                retryable: false,
                outcomeUnknown: true);
        }
        finally
        {
            Interlocked.Exchange(ref _playoutCommandInFlight, 0);
            if (databaseActivityEntered)
            {
                _databaseActivityGate.Release();
            }
            _playoutCommandGate.Release();
            // A browser-side timeout may have discarded the correlated result while the
            // native operation was still completing. Always publish the authoritative
            // engine state after the command leaves the serialization gate.
            QueuePlayoutStatus();
        }
    }

    private async Task HandlePlayoutTimeoutQuarantineAsync(JsonElement payload)
    {
        var parsed = PlayoutBridgeProtocol.ParseTimeoutQuarantine(payload);
        if (!parsed.IsValid)
        {
            PostMessage("bridge-error", new { message = parsed.Error });
            return;
        }

        await QuarantineForBrowserCorrelationLossAsync();
    }

    private async Task QuarantineForBrowserCorrelationLossAsync()
    {

        if (Interlocked.Exchange(ref _browserCorrelationQuarantined, 1) != 0)
        {
            QueuePlayoutStatus();
            return;
        }

        // Latch before the first await so a reload or a second WebView message can
        // never race ahead of native quarantine. The latch is process-lifetime only.
        LogCurrentNativePlayoutOutcomeUnknown();
        StopLegacyRefreshLoop();
        QueuePlayoutStatus();

        var engine = _playoutEngine;
        if (engine is not null)
        {
            try
            {
                await engine.QuarantineAsync();
            }
            catch
            {
                // The native latch remains authoritative even if abandoning the vendor
                // session cannot be confirmed. Retrying any command would be unsafe.
            }
        }

        QueuePlayoutStatus();
    }

    private bool IsBrowserCorrelationQuarantined() =>
        Volatile.Read(ref _browserCorrelationQuarantined) != 0;

    private static Task<PlayoutResult> ExecutePlayoutCommandAsync(
        LegacyPlayoutWorkflow workflow,
        PlayoutBridgeWorkflowCommand request,
        CancellationToken cancellationToken) => request.Command switch
    {
        "prepare" => workflow.PrepareAsync(
            request.Playlist!,
            request.SelectedIndexZeroBased,
            cancellationToken),
        "take-in" => workflow.TakeInAsync(cancellationToken),
        "next" => workflow.NextAsync(cancellationToken),
        // Preserve the legacy MainForm TAKE OUT behavior (StopAll on the selected
        // scene player). Test/Live safety gates are enforced inside the engine.
        "take-out" => workflow.TakeOutAsync(PlayoutTakeOutScope.All, cancellationToken),
        _ => throw new InvalidOperationException("검증되지 않은 송출 명령입니다.")
    };

    private void PostPlayoutStatus(string? requestId = null)
    {
        var status = _playoutEngine?.Status;
        var browserCorrelationQuarantined = IsBrowserCorrelationQuarantined();
        if (status is null)
        {
            var changedAt = DateTimeOffset.UtcNow;
            if (requestId is null)
            {
                PostMessage("playout-status", new
                {
                    mode = "disabled",
                    state = "IDLE",
                    connectionState = browserCorrelationQuarantined
                        ? "outcome-unknown"
                        : "unavailable",
                    processDetected = false,
                    connected = false,
                    lastKtapConnectState = "not-attempted",
                    ktapConnectAttempted = false,
                    ktapConnectAccepted = false,
                    ktapHelloObserved = (bool?)null,
                    networkMonitoringRecordExpected = false,
                    networkMonitoringCheckRequired = false,
                    commandAvailable = false,
                    liveTakeInAllowed = false,
                    playCompletionPending = false,
                    outcomeUnknown = browserCorrelationQuarantined,
                    browserCorrelationQuarantined,
                    operationTimeoutMilliseconds = 5000,
                    message = _playoutInitializationError ?? "Tornado 송출 어댑터가 비활성화되어 있습니다.",
                    preparedCode = (string?)null,
                    onAirCode = (string?)null,
                    currentCueIndex = -1,
                    currentEntryId = (string?)null,
                    builderKey = (string?)null,
                    pageIndex = 0,
                    pageCount = 0,
                    pageSize = 0,
                    itemCount = 0,
                    currentPageItemCount = 0,
                    isLastPage = true,
                    nextKind = "none",
                    previewFields = Array.Empty<LegacyScenePreviewField>(),
                    refreshActive = false,
                    refreshNextAt = (DateTimeOffset?)null,
                    refreshLastSuccessAt = (DateTimeOffset?)null,
                    refreshFaulted = false,
                    refreshFaultCode = (string?)null,
                    refreshFaultMessage = (string?)null,
                    refreshCompletedCount = 0,
                    refreshMaximumCount = (int?)null,
                    refreshLimitReached = false,
                    changedAt
                });
            }
            else
            {
                PostMessage("playout-status", new
                {
                    requestId,
                    mode = "disabled",
                    state = "IDLE",
                    connectionState = browserCorrelationQuarantined
                        ? "outcome-unknown"
                        : "unavailable",
                    processDetected = false,
                    connected = false,
                    lastKtapConnectState = "not-attempted",
                    ktapConnectAttempted = false,
                    ktapConnectAccepted = false,
                    ktapHelloObserved = (bool?)null,
                    networkMonitoringRecordExpected = false,
                    networkMonitoringCheckRequired = false,
                    commandAvailable = false,
                    liveTakeInAllowed = false,
                    playCompletionPending = false,
                    outcomeUnknown = browserCorrelationQuarantined,
                    browserCorrelationQuarantined,
                    operationTimeoutMilliseconds = 5000,
                    message = _playoutInitializationError ?? "Tornado 송출 어댑터가 비활성화되어 있습니다.",
                    preparedCode = (string?)null,
                    onAirCode = (string?)null,
                    currentCueIndex = -1,
                    currentEntryId = (string?)null,
                    builderKey = (string?)null,
                    pageIndex = 0,
                    pageCount = 0,
                    pageSize = 0,
                    itemCount = 0,
                    currentPageItemCount = 0,
                    isLastPage = true,
                    nextKind = "none",
                    previewFields = Array.Empty<LegacyScenePreviewField>(),
                    refreshActive = false,
                    refreshNextAt = (DateTimeOffset?)null,
                    refreshLastSuccessAt = (DateTimeOffset?)null,
                    refreshFaulted = false,
                    refreshFaultCode = (string?)null,
                    refreshFaultMessage = (string?)null,
                    refreshCompletedCount = 0,
                    refreshMaximumCount = (int?)null,
                    refreshLimitReached = false,
                    changedAt
                });
            }
            return;
        }

        if (requestId is null)
        {
            PostMessage("playout-status", CreatePlayoutStatusPayload(status));
        }
        else
        {
            var workflowState = _playoutWorkflow?.State;
            var refreshStatus = GetLegacyRefreshStatus();
            PostMessage("playout-status", new
            {
                requestId,
                mode = ToWireValue(status.Mode),
                state = ToPlayoutPhase(status),
                connectionState = browserCorrelationQuarantined
                    ? "outcome-unknown"
                    : ToWireValue(status.State),
                processDetected = status.IsProcessRunning,
                connected = status.IsConnected,
                lastKtapConnectState = ToWireValue(status.LastKtapConnectState),
                ktapConnectAttempted = status.KtapConnectAttempted,
                ktapConnectAccepted = status.KtapConnectAccepted,
                ktapHelloObserved = status.KtapHelloObserved,
                networkMonitoringRecordExpected = status.NetworkMonitoringRecordExpected,
                networkMonitoringCheckRequired = status.NetworkMonitoringCheckRequired,
                commandAvailable = status.IsCommandAvailable && !browserCorrelationQuarantined,
                liveTakeInAllowed = status.LiveTakeInAllowed && !browserCorrelationQuarantined,
                playCompletionPending = status.IsPlayCompletionPending,
                outcomeUnknown = browserCorrelationQuarantined ||
                    status.State == PlayoutConnectionState.OutcomeUnknown,
                browserCorrelationQuarantined,
                operationTimeoutMilliseconds = status.OperationTimeoutMilliseconds,
                message = browserCorrelationQuarantined
                    ? BrowserCorrelationQuarantineMessage
                    : status.Message,
                preparedCode = status.PreparedSceneName,
                onAirCode = status.OnAirSceneName,
                currentCueIndex = workflowState?.CurrentCueIndexZeroBased ?? -1,
                currentEntryId = workflowState?.CurrentEntryId,
                builderKey = workflowState?.BuilderKey,
                pageIndex = workflowState?.PageIndexZeroBased ?? 0,
                pageCount = workflowState?.PageCount ?? 0,
                pageSize = workflowState?.PageSize ?? 0,
                itemCount = workflowState?.ItemCount ?? 0,
                currentPageItemCount = workflowState?.CurrentPageItemCount ?? 0,
                isLastPage = workflowState?.IsLastPage ?? true,
                nextKind = ToWireValue(workflowState?.NextKind ?? LegacyWorkflowNextKind.None),
                previewFields = workflowState?.PreviewFields ??
                    Array.Empty<LegacyScenePreviewField>(),
                refreshActive = refreshStatus.IsActive,
                refreshNextAt = refreshStatus.NextAtUtc,
                refreshLastSuccessAt = refreshStatus.LastSuccessAtUtc,
                refreshFaulted = refreshStatus.IsFaulted,
                refreshFaultCode = refreshStatus.FaultCode,
                refreshFaultMessage = refreshStatus.FaultMessage,
                refreshCompletedCount = refreshStatus.CompletedRefreshCount,
                refreshMaximumCount = refreshStatus.MaximumRefreshCount,
                refreshLimitReached = refreshStatus.IsLimitReached,
                changedAt = status.ChangedAtUtc
            });
        }
    }

    private object CreatePlayoutStatusPayload(PlayoutStatus status)
    {
        var workflowState = _playoutWorkflow?.State;
        var refreshStatus = GetLegacyRefreshStatus();
        var browserCorrelationQuarantined = IsBrowserCorrelationQuarantined();
        return new
        {
            mode = ToWireValue(status.Mode),
            state = ToPlayoutPhase(status),
            connectionState = browserCorrelationQuarantined
                ? "outcome-unknown"
                : ToWireValue(status.State),
            processDetected = status.IsProcessRunning,
            connected = status.IsConnected,
            lastKtapConnectState = ToWireValue(status.LastKtapConnectState),
            ktapConnectAttempted = status.KtapConnectAttempted,
            ktapConnectAccepted = status.KtapConnectAccepted,
            ktapHelloObserved = status.KtapHelloObserved,
            networkMonitoringRecordExpected = status.NetworkMonitoringRecordExpected,
            networkMonitoringCheckRequired = status.NetworkMonitoringCheckRequired,
            commandAvailable = status.IsCommandAvailable && !browserCorrelationQuarantined,
            liveTakeInAllowed = status.LiveTakeInAllowed && !browserCorrelationQuarantined,
            playCompletionPending = status.IsPlayCompletionPending,
            outcomeUnknown = browserCorrelationQuarantined ||
                status.State == PlayoutConnectionState.OutcomeUnknown,
            browserCorrelationQuarantined,
            operationTimeoutMilliseconds = status.OperationTimeoutMilliseconds,
            message = browserCorrelationQuarantined
                ? BrowserCorrelationQuarantineMessage
                : status.Message,
            preparedCode = status.PreparedSceneName,
            onAirCode = status.OnAirSceneName,
            currentCueIndex = workflowState?.CurrentCueIndexZeroBased ?? -1,
            currentEntryId = workflowState?.CurrentEntryId,
            builderKey = workflowState?.BuilderKey,
            pageIndex = workflowState?.PageIndexZeroBased ?? 0,
            pageCount = workflowState?.PageCount ?? 0,
            pageSize = workflowState?.PageSize ?? 0,
            itemCount = workflowState?.ItemCount ?? 0,
            currentPageItemCount = workflowState?.CurrentPageItemCount ?? 0,
            isLastPage = workflowState?.IsLastPage ?? true,
            nextKind = ToWireValue(workflowState?.NextKind ?? LegacyWorkflowNextKind.None),
            previewFields = workflowState?.PreviewFields ??
                Array.Empty<LegacyScenePreviewField>(),
            refreshActive = refreshStatus.IsActive,
            refreshNextAt = refreshStatus.NextAtUtc,
            refreshLastSuccessAt = refreshStatus.LastSuccessAtUtc,
            refreshFaulted = refreshStatus.IsFaulted,
            refreshFaultCode = refreshStatus.FaultCode,
            refreshFaultMessage = refreshStatus.FaultMessage,
            refreshCompletedCount = refreshStatus.CompletedRefreshCount,
            refreshMaximumCount = refreshStatus.MaximumRefreshCount,
            refreshLimitReached = refreshStatus.IsLimitReached,
            changedAt = status.ChangedAtUtc
        };
    }

    private static string ToWireValue(LegacyWorkflowNextKind value) => value switch
    {
        LegacyWorkflowNextKind.None => "none",
        LegacyWorkflowNextKind.PageNext => "page-next",
        LegacyWorkflowNextKind.PlaylistNext => "playlist-next",
        LegacyWorkflowNextKind.EndOfPlaylist => "end-of-playlist",
        _ => "none"
    };

    private void PostPlayoutCommandError(
        string requestId,
        string command,
        string code,
        string message,
        bool retryable,
        bool outcomeUnknown)
    {
        PostMessage("playout-command-error", new
        {
            requestId,
            command,
            code,
            message,
            retryable,
            outcomeUnknown
        });
    }

    private static bool TryGetSafeToken(
        JsonElement payload,
        string propertyName,
        int maximumLength,
        out string value)
    {
        value = GetString(payload, propertyName) ?? string.Empty;
        return value.Length is > 0 && value.Length <= maximumLength &&
            value.All(character => char.IsAsciiLetterOrDigit(character) || character is '_' or '-');
    }

    private static bool HasOnlyProperties(JsonElement payload, params string[] allowed)
    {
        var seen = new HashSet<string>(StringComparer.Ordinal);
        foreach (var property in payload.EnumerateObject())
        {
            if (!seen.Add(property.Name) ||
                !allowed.Contains(property.Name, StringComparer.Ordinal))
            {
                return false;
            }
        }

        return true;
    }

    private static string ToPlayoutPhase(PlayoutStatus status)
    {
        if (!string.IsNullOrWhiteSpace(status.OnAirSceneName))
        {
            return "PROGRAM";
        }

        return !string.IsNullOrWhiteSpace(status.PreparedSceneName) ? "PREPARED" : "IDLE";
    }

    private static string ToWireValue(PlayoutMode mode) => mode switch
    {
        PlayoutMode.Disabled => "disabled",
        PlayoutMode.DryRun => "dry-run",
        PlayoutMode.Test => "test",
        PlayoutMode.Live => "live",
        _ => "disabled"
    };

    private static string ToWireValue(PlayoutConnectionState state) => state switch
    {
        PlayoutConnectionState.Disabled => "disabled",
        PlayoutConnectionState.DryRunReady => "dry-run-ready",
        PlayoutConnectionState.Disconnected => "disconnected",
        PlayoutConnectionState.Connecting => "connecting",
        PlayoutConnectionState.Connected => "connected",
        PlayoutConnectionState.Reconnecting => "reconnecting",
        PlayoutConnectionState.Disconnecting => "disconnecting",
        PlayoutConnectionState.Faulted => "faulted",
        PlayoutConnectionState.OutcomeUnknown => "outcome-unknown",
        PlayoutConnectionState.Disposed => "disposed",
        _ => "unavailable"
    };

    private static string ToWireValue(PlayoutKtapConnectState state) => state switch
    {
        PlayoutKtapConnectState.NotAttempted => "not-attempted",
        PlayoutKtapConnectState.Attempted => "attempted",
        PlayoutKtapConnectState.AcceptedUnconfirmed => "accepted-unconfirmed",
        PlayoutKtapConnectState.Failed => "failed",
        _ => "not-attempted"
    };

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

    private static bool IsRetryable(PlayoutResultCode code) => code is
        PlayoutResultCode.Cancelled or
        PlayoutResultCode.Unavailable or
        PlayoutResultCode.Failed;

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

        var cancellation = CancellationTokenSource.CreateLinkedTokenSource(
            _lifetimeCancellation.Token);
        var scheduler = new LegacyRefreshScheduler(
            interval,
            LegacySceneRefreshPolicy.RecurringInterval,
            _playoutOptions?.MaximumAutomaticRefreshesPerTakeIn);
        if (scheduler.HasReachedMaximum)
        {
            cancellation.Dispose();
            _legacyRefreshTask = null;
            _legacyRefreshEpoch.Stop(_ => new LegacyRefreshRuntimeStatus(
                false,
                null,
                null,
                false,
                null,
                null,
                scheduler.CompletedRefreshCount,
                scheduler.MaximumRefreshCount,
                scheduler.IsMaximumCallbackDrained));
            QueuePlayoutStatus();
            return;
        }

        _legacyRefreshEpoch.Replace(
            cancellation,
            _ => new LegacyRefreshRuntimeStatus(
                true,
                null,
                null,
                false,
                null,
                null,
                scheduler.CompletedRefreshCount,
                scheduler.MaximumRefreshCount,
                false));
        _legacyRefreshTask = RunLegacyRefreshLoopAsync(
            workflow,
            scheduler,
            cancellation);
    }

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

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

                if (scheduler.HasReachedMaximum)
                {
                    if (!await scheduler.DrainMaximumCallbackAsync(
                        cancellationToken => WaitForPlayCompletionAsync(
                            engine,
                            cancellationToken),
                        token))
                    {
                        TrySetLegacyRefreshFault(
                            cancellation,
                            "PLAY_CALLBACK_TIMEOUT",
                            "The configured automatic refresh limit was reached, but the final Tornado play callback was not observed in time. Verify PGM output before TAKE OUT.");
                        QueuePlayoutStatus();
                        return;
                    }

                    _legacyRefreshEpoch.TryUpdate(
                        cancellation,
                        status => status with
                        {
                            IsActive = false,
                            NextAtUtc = null,
                            CompletedRefreshCount = scheduler.CompletedRefreshCount,
                            MaximumRefreshCount = scheduler.MaximumRefreshCount,
                            IsLimitReached = scheduler.IsMaximumCallbackDrained
                        });
                    QueuePlayoutStatus();
                    return;
                }

                // The original WinForms timer coalesced non-reentrant UI ticks but did
                // not wait for the vendor callback. The migration's safety invariant is
                // stricter: observe OnScenePlayed and then start a complete one-shot
                // cooldown. This intentionally lengthens the effective cadence while
                // keeping the command gate free for a real operator quiescent window.
                if (!await scheduler.WaitForNextRefreshAsync(
                    cancellationToken => WaitForPlayCompletionAsync(
                        engine,
                        cancellationToken),
                    scheduledDelay =>
                    {
                        if (_legacyRefreshEpoch.TryUpdate(
                            cancellation,
                            status => status.IsActive
                                ? status with
                                {
                                    NextAtUtc = DateTimeOffset.UtcNow.Add(scheduledDelay)
                                }
                                : status))
                        {
                            QueuePlayoutStatus();
                        }
                    },
                    token))
                {
                    TrySetLegacyRefreshFault(
                        cancellation,
                        "PLAY_CALLBACK_TIMEOUT",
                        "Tornado 재생 완료 이벤트를 제한 시간 안에 확인하지 못해 자동 장면 갱신을 중단했습니다. TAKE OUT 후 실제 화면을 확인하세요.");
                    QueuePlayoutStatus();
                    return;
                }

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

                await _playoutCommandGate.WaitAsync(token);
                var databaseActivityEntered = false;
                try
                {
                    if (!_legacyRefreshEpoch.IsCurrent(cancellation) ||
                        Volatile.Read(ref _playoutShutdownStarted) != 0)
                    {
                        return;
                    }

                    Interlocked.Exchange(ref _playoutCommandInFlight, 1);
                    CancelActiveMarketDataRequest();
                    CancelActiveDatabaseHealthCheck();
                    await _databaseActivityGate.WaitAsync(token);
                    databaseActivityEntered = true;
                    var result = await scheduler.ExecuteRefreshAndTrackSuccessAsync(
                        workflow.RefreshOnAirAsync,
                        static result => result.IsSuccess,
                        token);
                    // A failed, timed-out, or unknown update never repeats. The operator
                    // must reconcile native/PGM state before starting another command.
                    if (!result.IsSuccess)
                    {
                        TrySetLegacyRefreshFault(
                            cancellation,
                            ToWireValue(result.Code),
                            "자동 장면 갱신이 중단되었습니다. TAKE OUT 후 실제 화면과 데이터를 확인하세요.");
                        return;
                    }

                    // MainForm changes timer1.Interval to 3000 after the first
                    // successful same-scene refresh. The next interval does not
                    // begin until this Play's completion callback is observed.
                    if (!_legacyRefreshEpoch.TryUpdate(
                        cancellation,
                        _ => new LegacyRefreshRuntimeStatus(
                            true,
                            null,
                            DateTimeOffset.UtcNow,
                            false,
                            null,
                            null,
                            scheduler.CompletedRefreshCount,
                            scheduler.MaximumRefreshCount,
                            false)))
                    {
                        return;
                    }
                }
                catch (OperationCanceledException) when (token.IsCancellationRequested)
                {
                    return;
                }
                catch (DatabaseOperationException)
                {
                    TrySetLegacyRefreshFault(
                        cancellation,
                        "DATABASE_UNAVAILABLE",
                        "자동 장면 갱신 중 데이터베이스 조회가 실패했습니다. TAKE OUT 후 확인하세요.");
                    return;
                }
                catch (LegacySceneDataException)
                {
                    TrySetLegacyRefreshFault(
                        cancellation,
                        "SCENE_DATA_INVALID",
                        "자동 장면 갱신 데이터가 유효하지 않습니다. TAKE OUT 후 확인하세요.");
                    return;
                }
                catch
                {
                    TrySetLegacyRefreshFault(
                        cancellation,
                        "REFRESH_FAILED",
                        "자동 장면 갱신을 완료하지 못했습니다. TAKE OUT 후 확인하세요.");
                    return;
                }
                finally
                {
                    Interlocked.Exchange(ref _playoutCommandInFlight, 0);
                    if (databaseActivityEntered)
                    {
                        _databaseActivityGate.Release();
                    }
                    _playoutCommandGate.Release();
                    QueuePlayoutStatus();
                }
            }
        }
        catch (OperationCanceledException) when (token.IsCancellationRequested)
        {
            // Operator commands and shutdown cancel the legacy timer deterministically.
        }
        finally
        {
            var completedCurrentEpoch = _legacyRefreshEpoch.TryComplete(
                cancellation,
                status => status with
                {
                    IsActive = false,
                    NextAtUtc = null
                });
            cancellation.Dispose();
            if (completedCurrentEpoch)
            {
                QueuePlayoutStatus();
            }
        }
    }

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

        var timeoutMilliseconds = Math.Clamp(
            engine.Status.OperationTimeoutMilliseconds,
            100,
            300_000);
        using var timeoutCancellation = CancellationTokenSource.CreateLinkedTokenSource(
            cancellationToken);
        timeoutCancellation.CancelAfter(timeoutMilliseconds);

        while (engine.Status.IsPlayCompletionPending)
        {
            var statusChanged = new TaskCompletionSource(
                TaskCreationOptions.RunContinuationsAsynchronously);

            void OnStatusChanged(object? sender, PlayoutStatusChangedEventArgs args)
            {
                if (!args.Current.IsPlayCompletionPending)
                {
                    statusChanged.TrySetResult();
                }
            }

            engine.StatusChanged += OnStatusChanged;
            try
            {
                // Close the subscribe-after-read race without causing an engine call.
                if (!engine.Status.IsPlayCompletionPending)
                {
                    return true;
                }

                try
                {
                    await statusChanged.Task.WaitAsync(timeoutCancellation.Token);
                }
                catch (OperationCanceledException)
                    when (!cancellationToken.IsCancellationRequested)
                {
                    return false;
                }
            }
            finally
            {
                engine.StatusChanged -= OnStatusChanged;
            }
        }

        return true;
    }

    private LegacyRefreshRuntimeStatus GetLegacyRefreshStatus() =>
        _legacyRefreshEpoch.ReadState();

    private bool TrySetLegacyRefreshFault(
        CancellationTokenSource cancellation,
        string code,
        string message) =>
        _legacyRefreshEpoch.TryUpdate(
            cancellation,
            status => new LegacyRefreshRuntimeStatus(
                false,
                null,
                status.LastSuccessAtUtc,
                true,
                code,
                message,
                status.CompletedRefreshCount,
                status.MaximumRefreshCount,
                status.IsLimitReached));

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

    /// <summary>
    /// Provides the single fail-closed playout boundary used by every operator-data
    /// mutation. A missing engine/workflow is not an idle state: it means native
    /// state cannot be proven, so a write must not start.
    /// </summary>
    private bool CanStartOperatorMutation(bool familyWriteQuarantined)
    {
        var engine = _playoutEngine;
        var workflow = _playoutWorkflow;
        var status = engine?.Status;
        var workflowState = workflow?.State;
        var refreshStatus = GetLegacyRefreshStatus();
        var snapshot = new OperatorMutationGateSnapshot(
            IsFamilyWriteQuarantined: familyWriteQuarantined,
            IsGlobalWriteQuarantined: IsOperatorMutationQuarantined(),
            IsLifetimeCancellationRequested: _lifetimeCancellation.IsCancellationRequested,
            IsPlayoutCommandInFlight: Volatile.Read(ref _playoutCommandInFlight) != 0,
            IsPlayoutShutdownStarted: Volatile.Read(ref _playoutShutdownStarted) != 0,
            IsBrowserCorrelationQuarantined: IsBrowserCorrelationQuarantined(),
            IsEngineAvailable: engine is not null,
            IsWorkflowAvailable: workflow is not null,
            IsCommandAvailable: status?.IsCommandAvailable ?? false,
            IsPlayCompletionPending: status?.IsPlayCompletionPending ?? true,
            PreparedSceneName: status?.PreparedSceneName,
            OnAirSceneName: status?.OnAirSceneName,
            PreparedCutCode: workflowState?.PreparedCutCode,
            OnAirCutCode: workflowState?.OnAirCutCode,
            IsRefreshActive: refreshStatus.IsActive,
            IsRefreshTaskRunning: _legacyRefreshTask is { IsCompleted: false });

        return OperatorMutationGate.CanStart(snapshot);
    }

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

    private string GetOperatorMutationQuarantineMessage(string fallback) =>
        Volatile.Read(ref _operatorMutationQuarantineMessage) ?? fallback;

    private void LatchOperatorMutationQuarantine(string message)
    {
        if (Interlocked.CompareExchange(ref _operatorMutationQuarantined, 1, 0) == 0)
        {
            Volatile.Write(
                ref _operatorMutationQuarantineMessage,
                string.IsNullOrWhiteSpace(message)
                    ? "An operator-data mutation has an unknown outcome. Restart and reconcile every operator-data store before writing again."
                    : message);
        }
    }

    private void ShutdownPlayoutRuntime()
    {
        if (Interlocked.Exchange(ref _playoutShutdownStarted, 1) != 0)
        {
            return;
        }

        StopLegacyRefreshLoop();
        var engine = Interlocked.Exchange(ref _playoutEngine, null);
        _playoutWorkflow = null;
        if (engine is null)
        {
            return;
        }

        engine.StatusChanged -= OnPlayoutStatusChanged;
        using var disconnectCancellation = new CancellationTokenSource(TimeSpan.FromSeconds(3));
        try
        {
            var disconnect = engine.DisconnectAsync(disconnectCancellation.Token);
            if (!disconnect.Wait(TimeSpan.FromSeconds(4)))
            {
                disconnectCancellation.Cancel();
            }
        }
        catch
        {
            // App shutdown continues even when the external playout process is unavailable.
        }

        try
        {
            engine.DisposeAsync().AsTask().Wait(TimeSpan.FromSeconds(2));
        }
        catch
        {
            // Disposal is best-effort and bounded during window shutdown.
        }
    }

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