#nullable enable

namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes;

/// <summary>
/// Native lookup fields corresponding to the legacy playlist's code, subject,
/// graphic type, subtype and hidden data-code columns. They may select parameterized
/// DB data but never carry K3D method names, object names, mutations or asset paths.
/// </summary>
public sealed record LegacySceneSelection(
    string GroupCode,
    string Subject,
    string GraphicType,
    string Subtype,
    string DataCode,
    string? ExactSubject = null);

/// <summary>
/// Current operator checkbox state used by s8010. The original scene constructor read
/// MainForm.ck1/ck2 every time the scene was rebuilt, including TAKE IN, NEXT and the
/// timer refresh path; it was not part of the persisted playlist identity.
/// </summary>
public sealed record LegacySceneMovingAverageSelection(bool Ma5, bool Ma20);

public interface ILegacySceneMovingAverageSelectionSource
{
    LegacySceneMovingAverageSelection Current { get; }
}

/// <summary>
/// Thread-safe native source shared by an operator request and its retained workflow
/// playlist. Updating it changes only a later s8010 cue build.
/// </summary>
public sealed class MutableLegacySceneMovingAverageSelectionSource :
    ILegacySceneMovingAverageSelectionSource
{
    private LegacySceneMovingAverageSelection _current;

    public MutableLegacySceneMovingAverageSelectionSource(
        LegacySceneMovingAverageSelection initial)
    {
        _current = initial ?? throw new ArgumentNullException(nameof(initial));
    }

    public LegacySceneMovingAverageSelection Current => Volatile.Read(ref _current);

    public void Update(LegacySceneMovingAverageSelection value)
    {
        ArgumentNullException.ThrowIfNull(value);
        Interlocked.Exchange(ref _current, value);
    }
}

/// <summary>
/// Applies the current checkbox state only to the closed s8010 alias family. This keeps
/// the playlist selection stable while giving every cue build a fresh native snapshot.
/// Index and industry visibility suppression remains authoritative in the s8010 builder.
/// </summary>
public static class LegacySceneMovingAverageOverlay
{
    private static readonly IReadOnlySet<string> S8010CutCodes = new HashSet<string>(
        ["8035", "8061", "8040", "8046", "8051", "8056"],
        StringComparer.Ordinal);

    public static bool Supports(string? cutCode) =>
        cutCode is not null && S8010CutCodes.Contains(cutCode);

    public static LegacyPlaylistEntry ApplyCurrent(LegacyPlaylistEntry entry)
    {
        ArgumentNullException.ThrowIfNull(entry);
        if (!Supports(entry.CutCode) ||
            entry.Selection is null ||
            entry.MovingAverageSelectionSource is null)
        {
            return entry;
        }

        var current = entry.MovingAverageSelectionSource.Current ??
            throw new InvalidOperationException(
                "The moving-average selection source returned no state.");
        var graphicType = current switch
        {
            { Ma5: true, Ma20: true } => "MA5,MA20",
            { Ma5: true } => "MA5",
            { Ma20: true } => "MA20",
            _ => string.Empty
        };
        return entry with
        {
            Selection = entry.Selection with { GraphicType = graphicType }
        };
    }
}

/// <summary>
/// Explicit operator-grid PageN contract. A null <see cref="LegacyPlaylistEntry.PageNavigation"/>
/// keeps the provider-derived behavior used by direct Core callers. A non-null contract with
/// null values deliberately disables same-row page navigation; otherwise the page size and
/// page-count suffix are frozen for the complete prepare/on-air cycle.
/// </summary>
public sealed record LegacyPlaylistPageNavigation(
    ScenePageSize? PageSize,
    int? FrozenPageCount)
{
    public static LegacyPlaylistPageNavigation Disabled { get; } = new(null, null);

    public bool IsEnabled => PageSize.HasValue && FrozenPageCount.HasValue;
}

public sealed record LegacyPlaylistEntry(
    string EntryId,
    string CutCode,
    bool IsEnabled = true,
    int? FadeDuration = null,
    LegacySceneSelection? Selection = null,
    LegacyPlaylistPageNavigation? PageNavigation = null,
    ILegacySceneMovingAverageSelectionSource? MovingAverageSelectionSource = null)
{
    /// <summary>
    /// Closed sentinel used for a losslessly loaded legacy PLAY_LIST row whose
    /// original visible fields do not name any registered scene. It is a safe
    /// token, not a scene or file name, and is rejected by the data router before
    /// an engine command can be created.
    /// </summary>
    public const string UnresolvedCutCode = "UNRESOLVED_NAMED_PLAYLIST";
}

public sealed record LegacySceneCuePage(
    PlayoutCue Cue,
    int ItemCount,
    ScenePageSize? PageSize = null,
    string? BuilderKey = null,
    IReadOnlyList<LegacyScenePreviewField>? PreviewFields = null);

/// <summary>
/// Native-only data boundary. Implementations query DB data and invoke a registered typed
/// scene builder; Web input can select a catalog code but cannot supply mutations or paths.
/// </summary>
public interface ILegacySceneCueProvider
{
    Task<LegacySceneCuePage> CreatePageAsync(
        LegacyPlaylistEntry entry,
        int pageIndexZeroBased,
        CancellationToken cancellationToken = default);
}

/// <summary>
/// Read-only page-zero DTO boundary used to calculate PageN before PREPARE. Implementations
/// must not build a cue, dispatch an engine command, or touch COM/vendor objects.
/// </summary>
public interface ILegacyScenePagePlanProvider
{
    Task<LegacyScenePagePlan> CreatePagePlanAsync(
        LegacyPlaylistEntry entry,
        CancellationToken cancellationToken = default);
}

public sealed record LegacyScenePagePlan(
    string BuilderKey,
    int ItemCount,
    ScenePageSize PageSize,
    int PageCount,
    int AccessibleItemCount,
    bool IsTruncated);

public sealed record LegacyPlaylistPagePlan(
    string EntryId,
    int ItemCount,
    int PageSize,
    int PageCount,
    int AccessibleItemCount,
    bool IsTruncated,
    string BuilderKey);

public enum LegacyWorkflowNextKind
{
    None,
    PageNext,
    PlaylistNext,
    EndOfPlaylist
}

public sealed record LegacyPlayoutWorkflowState(
    int CurrentCueIndexZeroBased,
    string? PreparedCutCode,
    string? OnAirCutCode,
    int PageIndexZeroBased,
    int PageCount,
    int PageSize,
    int ItemCount,
    bool IsLastPage,
    LegacyWorkflowNextKind NextKind,
    string? CurrentEntryId,
    string? BuilderKey,
    int CurrentPageItemCount,
    IReadOnlyList<LegacyScenePreviewField> PreviewFields,
    bool IsPageNavigationEnabled)
{
    /// <summary>
    /// Stable playlist identity of the scene that is actually on air. NEXT can move
    /// <see cref="CurrentCueIndexZeroBased"/> to a known-unplayable row without changing
    /// this identity.
    /// </summary>
    public int OnAirCueIndexZeroBased { get; init; } = -1;

    public string? OnAirEntryId { get; init; }
}

/// <summary>
/// Reproduces legacy PREPARE/TAKE IN/NEXT/TAKE OUT state transitions above IPlayoutEngine.
/// It is deliberately unaware of COM and serializes DB-to-cue work with engine calls.
/// </summary>
public sealed class LegacyPlayoutWorkflow
{
    private const int MaximumPlaylistItems = 1_000;

    private readonly IPlayoutEngine _engine;
    private readonly ILegacySceneCueProvider _cueProvider;
    private readonly ILegacyScenePagePlanProvider? _pagePlanProvider;
    private readonly SemaphoreSlim _gate = new(1, 1);
    private readonly object _engineStatusSync = new();
    private IReadOnlyList<LegacyPlaylistEntry> _playlist = [];
    private ActivePage? _prepared;
    private ActivePage? _onAir;
    private int _nextCursorIndexZeroBased = -1;
    private string? _nextCursorEntryId;
    private int _engineClearRequested;
    private long _lastObservedEngineStatusSequence = long.MinValue;

    public LegacyPlayoutWorkflow(
        IPlayoutEngine engine,
        ILegacySceneCueProvider cueProvider)
    {
        _engine = engine ?? throw new ArgumentNullException(nameof(engine));
        _cueProvider = cueProvider ?? throw new ArgumentNullException(nameof(cueProvider));
        _pagePlanProvider = cueProvider as ILegacyScenePagePlanProvider;
        State = EmptyState;
    }

    public LegacyPlayoutWorkflowState State { get; private set; }

    /// <summary>
    /// Loads only trusted page-zero database DTOs and returns bounded PageN plans. The
    /// workflow gate makes this mutually exclusive with PREPARE/TAKE IN/NEXT/TAKE OUT,
    /// but no IPlayoutEngine method is called and workflow state is not changed.
    /// </summary>
    public async Task<IReadOnlyList<LegacyPlaylistPagePlan>> PreflightPagePlansAsync(
        IReadOnlyList<LegacyPlaylistEntry> entries,
        CancellationToken cancellationToken = default)
    {
        await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
        try
        {
            if (_prepared is not null || _onAir is not null)
            {
                throw new InvalidOperationException(
                    "Playlist page plans can be queried only while the playout workflow is idle.");
            }

            var provider = _pagePlanProvider ?? throw new InvalidOperationException(
                "The legacy scene provider does not expose read-only page preflight.");
            var snapshot = ValidatePlaylistEntries(entries, nameof(entries));
            var results = new LegacyPlaylistPagePlan[snapshot.Count];
            for (var index = 0; index < snapshot.Count; index++)
            {
                cancellationToken.ThrowIfCancellationRequested();
                var entry = snapshot[index];
                var plan = await provider.CreatePagePlanAsync(entry, cancellationToken)
                    .ConfigureAwait(false);
                ArgumentNullException.ThrowIfNull(plan);
                if (plan.ItemCount < 0 ||
                    plan.PageSize is not (ScenePageSize.Five or ScenePageSize.Six or ScenePageSize.Twelve) ||
                    plan.PageCount is < 0 or > ScenePaging.MaximumPageCount ||
                    plan.AccessibleItemCount < 0 ||
                    plan.AccessibleItemCount > plan.ItemCount ||
                    plan.IsTruncated != (plan.AccessibleItemCount < plan.ItemCount) ||
                    string.IsNullOrWhiteSpace(plan.BuilderKey))
                {
                    throw new LegacySceneDataException(
                        "The scene provider returned an invalid page preflight plan.");
                }

                var expected = ScenePaging.CreatePlan(plan.ItemCount, plan.PageSize);
                if (!expected.IsValid || expected.Plan is null ||
                    expected.Plan.TotalPages != plan.PageCount ||
                    expected.Plan.AccessibleItemCount != plan.AccessibleItemCount)
                {
                    throw new LegacySceneDataException(
                        "The scene provider page preflight does not match PageN rules.");
                }

                results[index] = new LegacyPlaylistPagePlan(
                    entry.EntryId,
                    plan.ItemCount,
                    (int)plan.PageSize,
                    plan.PageCount,
                    plan.AccessibleItemCount,
                    plan.IsTruncated,
                    plan.BuilderKey);
            }

            return Array.AsReadOnly(results);
        }
        finally
        {
            _gate.Release();
        }
    }

    /// <summary>
    /// Reconciles asynchronous OnCutOut/OnStopAll/disconnect status with the native
    /// playlist state. It never dispatches an engine command. If a command currently
    /// owns the workflow gate, reconciliation is applied before that command releases it.
    /// </summary>
    public void ObserveEngineStatus(PlayoutStatus status)
    {
        ArgumentNullException.ThrowIfNull(status);
        var hasActiveScene = !string.IsNullOrWhiteSpace(status.PreparedSceneName) ||
            !string.IsNullOrWhiteSpace(status.OnAirSceneName);
        lock (_engineStatusSync)
        {
            if (status.Sequence <= _lastObservedEngineStatusSequence)
            {
                return;
            }

            _lastObservedEngineStatusSequence = status.Sequence;
            // Availability/OnHello updates can report no scene while PREPARE still owns
            // the workflow gate. A later authoritative PREPARED/PROGRAM status must
            // cancel that deferred clear before the command publishes its page metadata.
            Interlocked.Exchange(ref _engineClearRequested, hasActiveScene ? 0 : 1);
        }

        if (hasActiveScene)
        {
            return;
        }

        if (!_gate.Wait(0))
        {
            return;
        }

        try
        {
            ApplyRequestedEngineClear();
        }
        finally
        {
            _gate.Release();
        }
    }

    public async Task<PlayoutResult> PrepareAsync(
        IReadOnlyList<LegacyPlaylistEntry> playlist,
        int cueIndexZeroBased,
        CancellationToken cancellationToken = default)
    {
        await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
        try
        {
            // MainForm's PREPARE button is a mode toggle. When PREPARE/PROGRAM is
            // already active, a second press follows btnTakeout_Click and StopAll
            // instead of replacing the current scene with another prepare.
            if (_prepared is not null || _onAir is not null)
            {
                var takeOut = await _engine.TakeOutAsync(
                    PlayoutTakeOutScope.All,
                    cancellationToken).ConfigureAwait(false);
                if (takeOut.IsSuccess)
                {
                    _prepared = null;
                    _onAir = null;
                    _playlist = [];
                    ResetNextCursor();
                    State = EmptyState;
                }

                return takeOut;
            }

            var snapshot = ValidatePlaylist(playlist, cueIndexZeroBased);
            var selectedIndex = FindNextEnabledCue(snapshot, cueIndexZeroBased);
            if (selectedIndex is null)
            {
                return Rejected(
                    PlayoutOperation.Prepare,
                    "선택한 위치 이후에 활성화된 플레이리스트 항목이 없습니다.");
            }

            var page = await CreatePageAsync(
                snapshot,
                selectedIndex.Value,
                pageIndexZeroBased: 0,
                cancellationToken).ConfigureAwait(false);
            var result = await _engine.PrepareAsync(page.Page.Cue, cancellationToken)
                .ConfigureAwait(false);
            if (result.IsSuccess)
            {
                _playlist = snapshot;
                _prepared = page;
                SetNextCursor(page.CueIndexZeroBased);
                PublishState();
            }

            return result;
        }
        finally
        {
            ApplyRequestedEngineClear();
            _gate.Release();
        }
    }

    /// <summary>
    /// Reproduces MainForm.btnTake_Click/F8. The row selected by the operator is
    /// independent from the row currently on air: every F8 runs ONAirMode for the
    /// current selection (including the forward disabled-row skip), prepares that
    /// scene, and then plays it. NEXT remains based on <see cref="_onAir"/> and is not
    /// affected merely because the operator selected another row.
    ///
    /// PREPARE and PLAY remain inside one workflow gate. A completed PREPARE becomes
    /// the new prepared state, but the previous on-air state is retained until PLAY is
    /// known to have succeeded. An unknown PLAY outcome is never retried here.
    /// </summary>
    public async Task<PlayoutResult> TakeSelectedAsync(
        IReadOnlyList<LegacyPlaylistEntry> playlist,
        int cueIndexZeroBased,
        CancellationToken cancellationToken = default)
    {
        await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
        try
        {
            if (_engine.Status.IsPlayCompletionPending)
            {
                return Rejected(
                    PlayoutOperation.TakeIn,
                    "The previous SDK Play completion callback is still pending. " +
                    "Only TAKE OUT is allowed until Tornado confirms the result.");
            }

            var snapshot = ValidatePlaylist(playlist, cueIndexZeroBased);
            var selectedIndex = FindNextEnabledCue(snapshot, cueIndexZeroBased);
            if (selectedIndex is null)
            {
                return Rejected(
                    PlayoutOperation.Prepare,
                    "There is no enabled playlist entry at or after the selection.");
            }

            var page = await CreatePageAsync(
                snapshot,
                selectedIndex.Value,
                pageIndexZeroBased: 0,
                cancellationToken).ConfigureAwait(false);
            var prepare = await _engine.PrepareAsync(page.Page.Cue, cancellationToken)
                .ConfigureAwait(false);
            if (!prepare.IsSuccess)
            {
                return prepare;
            }

            // Once PREPARE succeeds the engine owns this scene. Keep that prepared
            // state if TAKE IN fails or its outcome is unknown; never reload or retry.
            var hadOnAirScene = _onAir is not null;
            _playlist = snapshot;
            _prepared = page;
            if (!hadOnAirScene)
            {
                SetNextCursor(page.CueIndexZeroBased);
            }
            PublishState();

            var takeIn = await _engine.TakeInAsync(cancellationToken).ConfigureAwait(false);
            if (takeIn.IsSuccess)
            {
                _onAir = page;
                _prepared = null;
                SetNextCursor(page.CueIndexZeroBased);
                PublishState();
            }

            return takeIn;
        }
        finally
        {
            ApplyRequestedEngineClear();
            _gate.Release();
        }
    }

    /// <summary>
    /// Compatibility alias for callers compiled against the original one-click API.
    /// New operator code should use <see cref="TakeSelectedAsync"/> so that it is clear
    /// this operation always follows the current row selection, even during PROGRAM.
    /// </summary>
    public Task<PlayoutResult> PrepareAndTakeInAsync(
        IReadOnlyList<LegacyPlaylistEntry> playlist,
        int cueIndexZeroBased,
        CancellationToken cancellationToken = default) =>
        TakeSelectedAsync(playlist, cueIndexZeroBased, cancellationToken);

    public async Task<PlayoutResult> TakeInAsync(
        CancellationToken cancellationToken = default)
    {
        await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
        try
        {
            if (_prepared is null)
            {
                return Rejected(PlayoutOperation.TakeIn, "PREPARE가 완료된 장면이 없습니다.");
            }

            // MainForm's TAKE IN path resets m_Super and runs ONAirMode again before
            // Play(10). Recreate the selected page and prepare the fresh database
            // snapshot; never play when that prepare did not succeed.
            var refreshed = await CreatePageAsync(
                _playlist,
                _prepared.CueIndexZeroBased,
                _prepared.PageIndexZeroBased,
                cancellationToken).ConfigureAwait(false);
            RequireSameScene(_prepared, refreshed, "TAKE IN refresh");

            var prepare = await _engine.PrepareAsync(
                refreshed.Page.Cue,
                cancellationToken).ConfigureAwait(false);
            if (!prepare.IsSuccess)
            {
                return prepare;
            }

            // The engine now owns the freshly prepared scene even if Play fails.
            _prepared = refreshed;
            PublishState();

            var result = await _engine.TakeInAsync(cancellationToken).ConfigureAwait(false);
            if (result.IsSuccess)
            {
                _onAir = refreshed;
                _prepared = null;
                SetNextCursor(refreshed.CueIndexZeroBased);
                PublishState();
            }

            return result;
        }
        finally
        {
            ApplyRequestedEngineClear();
            _gate.Release();
        }
    }

    public Task<PlayoutResult> NextAsync(
        CancellationToken cancellationToken = default) =>
        NextAsync(null, cancellationToken);

    /// <summary>
    /// Advances NEXT after adopting only a verified mutable tail from the live
    /// operator playlist. The current/on-air prefix must remain strictly equivalent
    /// except for <see cref="LegacyPlaylistEntry.IsEnabled"/>: MainForm allowed its
    /// PROGRAM Space/all gestures to change that visible flag even on current and
    /// past rows, while NEXT consulted only later rows. Additions or deletions remain
    /// confined after the current row so the scene already owned by the engine cannot
    /// change.
    /// </summary>
    public async Task<PlayoutResult> NextAsync(
        IReadOnlyList<LegacyPlaylistEntry>? operatorPlaylist,
        CancellationToken cancellationToken = default)
    {
        await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
        try
        {
            if (_onAir is null)
            {
                return Rejected(PlayoutOperation.Next, "TAKE IN 이후에만 NEXT를 실행할 수 있습니다.");
            }

            if (operatorPlaylist is not null && !TryAdoptOperatorPlaylistTail(operatorPlaylist))
            {
                return Rejected(
                    PlayoutOperation.Next,
                    "송출 중 변경된 플레이리스트의 현재 행 이전 구간을 안전하게 확인할 수 없습니다.");
            }

            if (_onAir.Plan is { } currentPlan &&
                _onAir.PageIndexZeroBased + 1 < currentPlan.TotalPages)
            {
                var nextPage = await CreatePageAsync(
                    _playlist,
                    _onAir.CueIndexZeroBased,
                    _onAir.PageIndexZeroBased + 1,
                    cancellationToken).ConfigureAwait(false);
                if (!string.Equals(
                        nextPage.Page.Cue.SceneName,
                        _onAir.Page.Cue.SceneName,
                        StringComparison.OrdinalIgnoreCase))
                {
                    throw new LegacySceneDataException(
                        "A page update must target the scene already on air.");
                }

                // btnNext_Click calls Next_Scene(0). For all 5/6/12-row branches
                // the idx == 0 path loads and prepares a fresh scene before Play.
                var result = await _engine.NextAsync(
                    nextPage.Page.Cue,
                    cancellationToken).ConfigureAwait(false);
                if (result.IsSuccess)
                {
                    _onAir = nextPage;
                    PublishState();
                }

                return result;
            }

            var previousCursorIndex = _nextCursorIndexZeroBased;
            var previousCursorEntryId = _nextCursorEntryId;
            var cursorIndex = ResolveNextCursorIndex(_onAir.CueIndexZeroBased);
            var targetIndex = FindNextEnabledCue(cursorIndex + 1);
            if (targetIndex is null)
            {
                PublishState(LegacyWorkflowNextKind.EndOfPlaylist);
                return Rejected(PlayoutOperation.Next, "플레이리스트의 마지막 장면입니다.");
            }

            // MainForm advanced m_crow before Show_PlayList attempted to build the
            // selected scene. Keep that progress cursor independent from _onAir: a
            // known-unplayable row consumes exactly one NEXT, while the previous
            // scene remains the truthful PGM identity.
            SetNextCursor(targetIndex.Value);
            PublishState();

            ActivePage nextCue;
            try
            {
                nextCue = await CreatePageAsync(
                    _playlist,
                    targetIndex.Value,
                    pageIndexZeroBased: 0,
                    cancellationToken).ConfigureAwait(false);
            }
            catch (LegacySceneDataException)
            {
                // This is a deterministic, pre-dispatch failure for this row. Keep
                // the cursor on it so the following NEXT starts after it.
                throw;
            }
            catch
            {
                RestoreNextCursor(previousCursorIndex, previousCursorEntryId);
                PublishState();
                throw;
            }

            PlayoutResult nextResult;
            try
            {
                nextResult = await _engine.NextAsync(nextCue.Page.Cue, cancellationToken)
                    .ConfigureAwait(false);
            }
            catch
            {
                RestoreNextCursor(previousCursorIndex, previousCursorEntryId);
                PublishState();
                throw;
            }

            if (nextResult.IsSuccess)
            {
                _onAir = nextCue;
                _prepared = null;
                PublishState();
            }
            else if (nextResult.Code != PlayoutResultCode.Rejected)
            {
                // A deterministic rejection means this specific row was not sent and
                // may be left behind. Cancellation, unavailability, failure, timeout,
                // and OutcomeUnknown do not prove a row-specific no-output result.
                RestoreNextCursor(previousCursorIndex, previousCursorEntryId);
                PublishState();
            }

            return nextResult;
        }
        finally
        {
            ApplyRequestedEngineClear();
            _gate.Release();
        }
    }

    private bool TryAdoptOperatorPlaylistTail(
        IReadOnlyList<LegacyPlaylistEntry> operatorPlaylist)
    {
        if (_onAir is null || _onAir.CueIndexZeroBased < 0)
        {
            return false;
        }

        var currentIndex = _onAir.CueIndexZeroBased;
        IReadOnlyList<LegacyPlaylistEntry> candidate;
        try
        {
            candidate = ValidatePlaylist(operatorPlaylist, currentIndex);
        }
        catch (Exception exception) when (
            exception is ArgumentException or InvalidOperationException)
        {
            return false;
        }

        if (candidate.Count <= currentIndex || _playlist.Count <= currentIndex)
        {
            return false;
        }

        for (var index = 0; index <= currentIndex; index++)
        {
            // MainForm allowed the PROGRAM operator to change the active-row
            // checkbox with Space and every checkbox through its "all" control.
            // Those changes included the on-air/past prefix even though NEXT only
            // consulted rows after m_crow.  Accept that one harmless visible field,
            // while keeping every identity, scene, selection, page and live-source
            // field in the retained prefix strictly equal.
            var candidateWithRetainedEnabledState = candidate[index] with
            {
                IsEnabled = _playlist[index].IsEnabled
            };
            if (!Equals(candidateWithRetainedEnabledState, _playlist[index]))
            {
                return false;
            }
        }

        var retainedCursorEntryId = _nextCursorEntryId;
        _playlist = candidate;
        var retainedCursorIndex = retainedCursorEntryId is null
            ? -1
            : candidate
                .Select(static (entry, index) => (entry, index))
                .Where(item => string.Equals(
                    item.entry.EntryId,
                    retainedCursorEntryId,
                    StringComparison.Ordinal))
                .Select(static item => item.index)
                .DefaultIfEmpty(-1)
                .Single();
        if (retainedCursorIndex >= currentIndex)
        {
            SetNextCursor(retainedCursorIndex);
        }
        else
        {
            // If a mutable future tail removed the last attempted row, resume from
            // the still-verified on-air anchor in the newly adopted list.
            SetNextCursor(currentIndex);
        }
        PublishState();
        return true;
    }

    /// <summary>
    /// Reproduces MainForm's timer-driven same-scene refresh. The active non-paged
    /// entry and current page are queried again and updated through
    /// IPlayoutEngine.UpdateOnAirAsync. The vendor adapter updates its retained on-air
    /// scene reference; timer start/stop policy remains an application concern.
    /// </summary>
    public async Task<PlayoutResult> RefreshOnAirAsync(
        CancellationToken cancellationToken = default)
    {
        await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
        try
        {
            if (_onAir is null)
            {
                return Rejected(
                    PlayoutOperation.UpdateOnAir,
                    "A scene must be on air before it can be refreshed.");
            }

            var refreshed = await CreatePageAsync(
                _playlist,
                _onAir.CueIndexZeroBased,
                _onAir.PageIndexZeroBased,
                cancellationToken).ConfigureAwait(false);
            RequireSameScene(_onAir, refreshed, "On-air refresh");

            var result = await _engine.UpdateOnAirAsync(
                refreshed.Page.Cue,
                cancellationToken).ConfigureAwait(false);
            if (result.IsSuccess)
            {
                _onAir = refreshed;
                PublishState();
            }

            return result;
        }
        finally
        {
            ApplyRequestedEngineClear();
            _gate.Release();
        }
    }

    public async Task<PlayoutResult> TakeOutAsync(
        PlayoutTakeOutScope scope,
        CancellationToken cancellationToken = default)
    {
        await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
        try
        {
            var result = await _engine.TakeOutAsync(scope, cancellationToken)
                .ConfigureAwait(false);
            if (result.IsSuccess)
            {
                _prepared = null;
                _onAir = null;
                _playlist = [];
                ResetNextCursor();
                State = EmptyState;
            }

            return result;
        }
        finally
        {
            ApplyRequestedEngineClear();
            _gate.Release();
        }
    }

    private async Task<ActivePage> CreatePageAsync(
        IReadOnlyList<LegacyPlaylistEntry> playlist,
        int cueIndexZeroBased,
        int pageIndexZeroBased,
        CancellationToken cancellationToken)
    {
        // s8010 sampled MainForm.ck1/ck2 inside every scene constructor call. Resolve
        // the current native state here so PREPARE, TAKE IN, NEXT and refresh all use
        // the same request-time rule without mutating the retained playlist identity.
        var entry = LegacySceneMovingAverageOverlay.ApplyCurrent(
            playlist[cueIndexZeroBased]);
        var page = await _cueProvider.CreatePageAsync(
            entry,
            pageIndexZeroBased,
            cancellationToken).ConfigureAwait(false);
        ArgumentNullException.ThrowIfNull(page);
        ArgumentNullException.ThrowIfNull(page.Cue);

        ScenePagingPlan? plan = null;
        var pageNavigationEnabled = false;
        var navigation = entry.PageNavigation;
        if (page.PageSize is { } pageSize)
        {
            var planResult = ScenePaging.CreatePlan(page.ItemCount, pageSize);
            if (!planResult.IsValid || planResult.Plan is null)
            {
                throw new LegacySceneDataException("The scene provider returned invalid paging data.");
            }

            var window = ScenePaging.GetWindow(
                page.ItemCount,
                pageSize,
                pageIndexZeroBased + 1);
            if (!window.IsValid || window.Window is null)
            {
                throw new LegacySceneDataException("The requested scene page is outside the available range.");
            }

            if (navigation is null)
            {
                // Backwards compatibility for direct Core callers: without explicit
                // operator metadata, the current provider result remains authoritative.
                plan = planResult.Plan;
                pageNavigationEnabled = true;
            }
            else if (!navigation.IsEnabled)
            {
                if (pageIndexZeroBased != 0)
                {
                    throw new LegacySceneDataException(
                        "This playlist entry does not allow same-row page navigation.");
                }

                plan = new ScenePagingPlan(
                    page.ItemCount,
                    pageSize,
                    totalPages: 1,
                    accessibleItemCount: Math.Min(page.ItemCount, (int)pageSize));
            }
            else
            {
                if (navigation.PageSize != pageSize)
                {
                    throw new LegacySceneDataException(
                        "The scene provider page size does not match the operator PageN contract.");
                }

                var frozenPageCount = navigation.FrozenPageCount!.Value;
                if (pageIndexZeroBased >= frozenPageCount)
                {
                    throw new LegacySceneDataException(
                        "The requested scene page is outside the frozen operator PageN range.");
                }

                plan = new ScenePagingPlan(
                    page.ItemCount,
                    pageSize,
                    frozenPageCount,
                    Math.Min(page.ItemCount, frozenPageCount * (int)pageSize));
                pageNavigationEnabled = true;
            }
        }
        else if (pageIndexZeroBased != 0 || page.ItemCount < 0)
        {
            throw new LegacySceneDataException("A non-paged scene returned invalid page data.");
        }
        else if (navigation?.IsEnabled == true)
        {
            throw new LegacySceneDataException(
                "The scene provider did not return the page size required by the operator PageN contract.");
        }

        return new ActivePage(
            cueIndexZeroBased,
            pageIndexZeroBased,
            page,
            plan,
            pageNavigationEnabled);
    }

    private static IReadOnlyList<LegacyPlaylistEntry> ValidatePlaylist(
        IReadOnlyList<LegacyPlaylistEntry>? playlist,
        int cueIndexZeroBased)
    {
        var entries = ValidatePlaylistEntries(playlist, nameof(playlist));
        if (cueIndexZeroBased < 0 || cueIndexZeroBased >= entries.Count)
        {
            throw new ArgumentException("The playlist selection is invalid.", nameof(playlist));
        }

        return entries;
    }

    private static IReadOnlyList<LegacyPlaylistEntry> ValidatePlaylistEntries(
        IReadOnlyList<LegacyPlaylistEntry>? playlist,
        string parameterName)
    {
        if (playlist is null || playlist.Count is < 1 or > MaximumPlaylistItems)
        {
            throw new ArgumentException("The playlist selection is invalid.", parameterName);
        }

        var entries = playlist.ToArray();
        var ids = new HashSet<string>(StringComparer.Ordinal);
        foreach (var entry in entries)
        {
            if (entry is null || !IsSafeToken(entry.EntryId) || !IsSafeToken(entry.CutCode) ||
                !ids.Add(entry.EntryId) ||
                entry.FadeDuration is < 0 or > 19 ||
                !IsSafeSelection(entry.Selection) ||
                !IsSafePageNavigation(entry.PageNavigation))
            {
                throw new ArgumentException("The playlist contains an invalid entry.", parameterName);
            }
        }

        return Array.AsReadOnly(entries);
    }

    private int ResolveNextCursorIndex(int fallbackIndex)
    {
        if (_nextCursorEntryId is not null &&
            _nextCursorIndexZeroBased >= 0 &&
            _nextCursorIndexZeroBased < _playlist.Count &&
            string.Equals(
                _playlist[_nextCursorIndexZeroBased].EntryId,
                _nextCursorEntryId,
                StringComparison.Ordinal))
        {
            return _nextCursorIndexZeroBased;
        }

        SetNextCursor(fallbackIndex);
        return _nextCursorIndexZeroBased;
    }

    private void SetNextCursor(int index)
    {
        if (index < 0 || index >= _playlist.Count)
        {
            throw new InvalidOperationException("The NEXT cursor is outside the retained playlist.");
        }

        _nextCursorIndexZeroBased = index;
        _nextCursorEntryId = _playlist[index].EntryId;
    }

    private void RestoreNextCursor(int index, string? entryId)
    {
        if (entryId is not null &&
            index >= 0 &&
            index < _playlist.Count &&
            string.Equals(_playlist[index].EntryId, entryId, StringComparison.Ordinal))
        {
            _nextCursorIndexZeroBased = index;
            _nextCursorEntryId = entryId;
            return;
        }

        ResetNextCursor();
    }

    private void ResetNextCursor()
    {
        _nextCursorIndexZeroBased = -1;
        _nextCursorEntryId = null;
    }

    private int? FindNextEnabledCue(int startIndex)
        => FindNextEnabledCue(_playlist, startIndex);

    private static int? FindNextEnabledCue(
        IReadOnlyList<LegacyPlaylistEntry> playlist,
        int startIndex)
    {
        for (var index = startIndex; index < playlist.Count; index++)
        {
            if (playlist[index].IsEnabled)
            {
                return index;
            }
        }

        return null;
    }

    private void PublishState(LegacyWorkflowNextKind? forcedNextKind = null)
    {
        var active = _onAir ?? _prepared;
        if (active is null)
        {
            State = EmptyState;
            return;
        }

        var pageCount = active.Plan?.TotalPages ?? 1;
        var pageSize = active.Page.PageSize is { } size ? (int)size : 0;
        var isLastPage = active.PageIndexZeroBased + 1 >= pageCount;
        var cursorIndex = ResolveNextCursorIndex(active.CueIndexZeroBased);
        var nextKind = forcedNextKind ?? (!isLastPage
            ? LegacyWorkflowNextKind.PageNext
            : FindNextEnabledCue(cursorIndex + 1).HasValue
                ? LegacyWorkflowNextKind.PlaylistNext
                : LegacyWorkflowNextKind.EndOfPlaylist);
        State = new LegacyPlayoutWorkflowState(
            cursorIndex,
            _prepared?.Page.Cue.SceneName,
            _onAir?.Page.Cue.SceneName,
            active.PageIndexZeroBased,
            pageCount,
            pageSize,
            active.Page.ItemCount,
            isLastPage,
            nextKind,
            _playlist[cursorIndex].EntryId,
            active.Page.BuilderKey,
            active.Plan is null
                ? active.Page.ItemCount
                : Math.Min(
                    pageSize,
                    Math.Max(0, active.Page.ItemCount - (active.PageIndexZeroBased * pageSize))),
            active.Page.PreviewFields ?? Array.Empty<LegacyScenePreviewField>(),
            active.IsPageNavigationEnabled)
        {
            OnAirCueIndexZeroBased = _onAir?.CueIndexZeroBased ?? -1,
            OnAirEntryId = _onAir is null
                ? null
                : _playlist[_onAir.CueIndexZeroBased].EntryId
        };
    }

    private static bool IsSafeToken(string? value) =>
        !string.IsNullOrWhiteSpace(value) &&
        value.Length <= 128 &&
        value.All(character => char.IsAsciiLetterOrDigit(character) || character is '_' or '-');

    private static bool IsSafeSelection(LegacySceneSelection? selection)
    {
        if (selection is null)
        {
            return true;
        }

        return IsSafeLookupText(selection.GroupCode) &&
            IsSafeLookupText(selection.Subject) &&
            IsSafeLookupText(selection.GraphicType) &&
            IsSafeLookupText(selection.Subtype) &&
            IsSafeLookupText(selection.DataCode) &&
            (selection.ExactSubject is null || IsSafeLookupText(selection.ExactSubject));
    }

    private static bool IsSafePageNavigation(LegacyPlaylistPageNavigation? navigation)
    {
        if (navigation is null)
        {
            return true;
        }

        if (!navigation.PageSize.HasValue && !navigation.FrozenPageCount.HasValue)
        {
            return true;
        }

        return navigation.PageSize is
                ScenePageSize.Five or ScenePageSize.Six or ScenePageSize.Twelve &&
            navigation.FrozenPageCount is >= 1 and <= ScenePaging.MaximumPageCount;
    }

    private static bool IsSafeLookupText(string? value) =>
        value is not null &&
        value.Length <= 256 &&
        !value.Any(char.IsControl);

    private static void RequireSameScene(
        ActivePage current,
        ActivePage refreshed,
        string operation)
    {
        if (!string.Equals(
                refreshed.Page.Cue.SceneName,
                current.Page.Cue.SceneName,
                StringComparison.OrdinalIgnoreCase))
        {
            throw new LegacySceneDataException(
                $"{operation} must target the scene that is already selected.");
        }
    }

    private static PlayoutResult Rejected(PlayoutOperation operation, string message) =>
        new(operation, PlayoutResultCode.Rejected, false, message, DateTimeOffset.UtcNow);

    private void ApplyRequestedEngineClear()
    {
        lock (_engineStatusSync)
        {
            if (Interlocked.Exchange(ref _engineClearRequested, 0) == 0)
            {
                return;
            }

            _prepared = null;
            _onAir = null;
            _playlist = [];
            ResetNextCursor();
            State = EmptyState;
        }
    }

    private static readonly LegacyPlayoutWorkflowState EmptyState = new(
        -1,
        null,
        null,
        0,
        0,
        0,
        0,
        true,
        LegacyWorkflowNextKind.None,
        null,
        null,
        0,
        Array.Empty<LegacyScenePreviewField>(),
        false);

    private sealed record ActivePage(
        int CueIndexZeroBased,
        int PageIndexZeroBased,
        LegacySceneCuePage Page,
        ScenePagingPlan? Plan,
        bool IsPageNavigationEnabled);
}
