using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Scheduling;
using Humanizer;
using LanguageExt.UnsafeValueAccess;
using Microsoft.Extensions.Logging;

namespace ErsatzTV.Core.Scheduling;

[SuppressMessage("Design", "CA1000:Do not declare static members on generic types")]
public abstract class PlayoutModeSchedulerBase<T>(ILogger logger) : IPlayoutModeScheduler<T>
    where T : ProgramScheduleItem
{
    // ReSharper disable once StaticMemberInGenericType
    private static readonly JsonSerializerOptions Options = new()
    {
        Converters = { new JsonStringEnumConverter() },
        DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
        WriteIndented = false
    };

    private readonly Random _random = new();

    protected ILogger Logger { get; } = logger;

    public abstract PlayoutSchedulerResult Schedule(
        PlayoutBuilderState playoutBuilderState,
        Dictionary<CollectionKey, IMediaCollectionEnumerator> collectionEnumerators,
        T scheduleItem,
        ProgramScheduleItem nextScheduleItem,
        DateTimeOffset hardStop,
        Random random,
        CancellationToken cancellationToken);

    public static DateTimeOffset GetFillerStartTimeAfter(
        PlayoutBuilderState state,
        ProgramScheduleItem scheduleItem,
        DateTimeOffset hardStop)
    {
        DateTimeOffset startTime = GetStartTimeAfter(state, scheduleItem, Option<ILogger>.None);

        // filler should always stop at the hard stop
        if (hardStop < startTime)
        {
            startTime = hardStop;
        }

        return startTime;
    }

    public static DateTimeOffset GetStartTimeAfter(
        PlayoutBuilderState state,
        ProgramScheduleItem scheduleItem,
        Option<ILogger> maybeLogger,
        bool isPeek = false)
    {
        DateTimeOffset startTime = state.CurrentTime.ToLocalTime();

        bool isIncomplete = !isPeek &&
                            (scheduleItem is ProgramScheduleItemMultiple && state.MultipleRemaining.IsSome ||
                             scheduleItem is ProgramScheduleItemDuration && state.DurationFinish.IsSome ||
                             scheduleItem is ProgramScheduleItemFlood && state.InFlood ||
                             scheduleItem is ProgramScheduleItemDuration && state.InDurationFiller);

        if (scheduleItem.StartType == StartType.Fixed && !isIncomplete)
        {
            TimeSpan itemStartTime = scheduleItem.StartTime.GetValueOrDefault();

            DateTime date = startTime.Date;
            var withStartTime = new DateTime(
                date.Year,
                date.Month,
                date.Day,
                0,
                0,
                0,
                DateTimeKind.Unspecified).Add(itemStartTime);

            DateTimeOffset result = new DateTimeOffset(withStartTime, TimeZoneInfo.Local.GetUtcOffset(withStartTime));

            // Serilog.Log.Logger.Debug(
            //     "StartTimeOfDay: {StartTimeOfDay} Item Start Time: {ItemStartTime}",
            //     startTime.TimeOfDay.TotalMilliseconds,
            //     itemStartTime.TotalMilliseconds);

            // need to wrap to the next day if appropriate
            FixedStartTimeBehavior? fixedStartTimeBehavior = scheduleItem.FixedStartTimeBehavior;
            if (fixedStartTimeBehavior is null && scheduleItem.ProgramSchedule is not null)
            {
                fixedStartTimeBehavior = scheduleItem.ProgramSchedule.FixedStartTimeBehavior;
            }

            switch (fixedStartTimeBehavior)
            {
                case FixedStartTimeBehavior.Flexible:
                    // if we are peeking from a flood and the flexible time is in the past,
                    // we should use the next day's time to allow the flood to continue.
                    if (isPeek && state.InFlood && startTime > result)
                    {
                        startTime = result.AddDays(1);
                    }
                    // otherwise, only wait for times on the same day
                    else if (result.Day == startTime.Day && result.TimeOfDay > startTime.TimeOfDay)
                    {
                        startTime = result;
                    }

                    break;
                case FixedStartTimeBehavior.Strict:
                default:
                    startTime = startTime.TimeOfDay > itemStartTime ? result.AddDays(1) : result;
                    break;
            }

            TimeSpan gap = startTime - state.CurrentTime;
            if (gap > TimeSpan.FromHours(1) && fixedStartTimeBehavior == FixedStartTimeBehavior.Strict &&
                result.TimeOfDay < state.CurrentTime.ToLocalTime().TimeOfDay)
            {
                foreach (ILogger logger in maybeLogger)
                {
                    logger.LogWarning(
                        "Offline playout gap of {Gap} caused by strict fixed start time {StartTime} before current time {CurrentTime} on schedule {Name}",
                        gap.Humanize(),
                        result.TimeOfDay,
                        state.CurrentTime.TimeOfDay,
                        scheduleItem.ProgramSchedule?.Name ?? "unknown");
                }
            }
        }

        return startTime;
    }

    protected Tuple<PlayoutBuilderState, List<PlayoutItem>> AddTailFiller(
        PlayoutBuilderState playoutBuilderState,
        Dictionary<CollectionKey, IMediaCollectionEnumerator> collectionEnumerators,
        ProgramScheduleItem scheduleItem,
        List<PlayoutItem> playoutItems,
        DateTimeOffset nextItemStart,
        PlayoutBuildWarnings warnings,
        CancellationToken cancellationToken)
    {
        var newItems = new List<PlayoutItem>(playoutItems);
        PlayoutBuilderState nextState = playoutBuilderState;

        if (scheduleItem.TailFiller != null)
        {
            IMediaCollectionEnumerator enumerator =
                collectionEnumerators[CollectionKey.ForFillerPreset(scheduleItem.TailFiller)];

            while (enumerator.Current.IsSome && nextState.CurrentTime < nextItemStart)
            {
                MediaItem mediaItem = enumerator.Current.ValueUnsafe();

                TimeSpan itemDuration = mediaItem.GetDurationForPlayout();
                TimeSpan inPoint = InPointForMediaItem(mediaItem);

                if (nextState.CurrentTime + itemDuration > nextItemStart)
                {
                    warnings.TailFillerTooLong++;
                    break;
                }

                var playoutItem = new PlayoutItem
                {
                    PlayoutId = playoutBuilderState.PlayoutId,
                    MediaItemId = IdForMediaItem(mediaItem),
                    Start = nextState.CurrentTime.UtcDateTime,
                    Finish = nextState.CurrentTime.UtcDateTime + itemDuration,
                    InPoint = inPoint,
                    OutPoint = inPoint + itemDuration,
                    FillerKind = FillerKind.Tail,
                    GuideGroup = nextState.NextGuideGroup,
                    DisableWatermarks = !scheduleItem.TailFiller.AllowWatermarks,
                    ChapterTitle = ChapterTitleForMediaItem(mediaItem),
                    SchedulingContext = GetSchedulingContext(scheduleItem, scheduleItem.TailFillerId, enumerator)
                };

                newItems.Add(playoutItem);

                nextState = nextState with
                {
                    CurrentTime = nextState.CurrentTime + itemDuration
                };

                enumerator.MoveNext(playoutItem.StartOffset);
            }
        }

        return Tuple(nextState, newItems);
    }

    protected Tuple<PlayoutBuilderState, List<PlayoutItem>> AddFallbackFiller(
        PlayoutBuilderState playoutBuilderState,
        Dictionary<CollectionKey, IMediaCollectionEnumerator> collectionEnumerators,
        ProgramScheduleItem scheduleItem,
        List<PlayoutItem> playoutItems,
        DateTimeOffset nextItemStart,
        CancellationToken cancellationToken)
    {
        var newItems = new List<PlayoutItem>(playoutItems);
        PlayoutBuilderState nextState = playoutBuilderState;

        if (scheduleItem.FallbackFiller != null && playoutBuilderState.CurrentTime < nextItemStart)
        {
            IMediaCollectionEnumerator enumerator =
                collectionEnumerators[CollectionKey.ForFillerPreset(scheduleItem.FallbackFiller)];

            foreach (MediaItem mediaItem in enumerator.Current)
            {
                var playoutItem = new PlayoutItem
                {
                    PlayoutId = playoutBuilderState.PlayoutId,
                    MediaItemId = mediaItem.Id,
                    Start = nextState.CurrentTime.UtcDateTime,
                    Finish = nextItemStart.UtcDateTime,
                    InPoint = TimeSpan.Zero,
                    OutPoint = TimeSpan.Zero,
                    GuideGroup = nextState.NextGuideGroup,
                    FillerKind = FillerKind.Fallback,
                    DisableWatermarks = !scheduleItem.FallbackFiller.AllowWatermarks,
                    SchedulingContext = GetSchedulingContext(scheduleItem, scheduleItem.FallbackFillerId, enumerator)
                };

                newItems.Add(playoutItem);

                nextState = nextState with
                {
                    CurrentTime = nextItemStart.UtcDateTime
                };

                enumerator.MoveNext(playoutItem.StartOffset);
            }
        }

        return Tuple(nextState, newItems);
    }

    private static TimeSpan InPointForMediaItem(MediaItem mediaItem) =>
        mediaItem switch
        {
            ChapterMediaItem c => c.MediaVersion.InPoint,
            _ => TimeSpan.Zero
        };

    private static int IdForMediaItem(MediaItem mediaItem) =>
        mediaItem switch
        {
            ChapterMediaItem c => c.MediaItemId,
            _ => mediaItem.Id
        };

    private static string ChapterTitleForMediaItem(MediaItem mediaItem) =>
        mediaItem switch
        {
            ChapterMediaItem c => c.MediaVersion.Title,
            _ => null
        };

    protected static List<MediaChapter> ChaptersForMediaItem(MediaItem mediaItem)
    {
        MediaVersion version = mediaItem.GetHeadVersion();
        return Optional(version.Chapters).Flatten().OrderBy(c => c.StartTime).ToList();
    }

    protected void LogScheduledItem(
        ProgramScheduleItem scheduleItem,
        MediaItem mediaItem,
        DateTimeOffset startTime) =>
        Logger.LogDebug(
            "Scheduling media item: {ScheduleItemNumber} / {CollectionType} / {MediaItemId} - {MediaItemTitle} / {StartTime}",
            scheduleItem.Index,
            scheduleItem.CollectionType,
            mediaItem.Id,
            PlayoutBuilder.DisplayTitle(mediaItem),
            startTime);

    internal (List<PlayoutItem> Items, DateTimeOffset? OfflinePadTarget) AddFiller(
        PlayoutBuilderState playoutBuilderState,
        Dictionary<CollectionKey, IMediaCollectionEnumerator> enumerators,
        ProgramScheduleItem scheduleItem,
        PlayoutItem playoutItem,
        List<MediaChapter> chapters,
        PlayoutBuildWarnings warnings,
        CancellationToken cancellationToken)
    {
        var result = new List<PlayoutItem>();

        DateTimeOffset? offlinePadTarget = null;

        var allFiller = Optional(scheduleItem.PreRollFiller)
            .Append(Optional(scheduleItem.MidRollFiller))
            .Append(Optional(scheduleItem.PostRollFiller))
            .ToList();

        // multiple pad-to-nearest-minute values are invalid; use no filler
        if (allFiller.Count(f => f.FillerMode == FillerMode.Pad && f.PadToNearestMinute.HasValue) > 1)
        {
            Logger.LogError("Multiple pad-to-nearest-minute values are invalid; no filler will be used");
            return ([playoutItem], null);
        }

        // missing pad-to-nearest-minute value is invalid; use no filler
        FillerPreset invalidPadFiller = allFiller
            .FirstOrDefault(f => f.FillerMode == FillerMode.Pad && !f.PadToNearestMinute.HasValue);
        if (invalidPadFiller is not null)
        {
            Logger.LogError(
                "Pad filler ({Filler}) without pad-to-nearest-minute value is invalid; no filler will be used",
                invalidPadFiller.Name);
            return ([playoutItem], null);
        }

        List<MediaChapter> effectiveChapters = chapters;
        if (allFiller.All(fp => fp.FillerKind != FillerKind.MidRoll) || effectiveChapters.Count <= 1)
        {
            effectiveChapters = [];
        }

        // convert mid-roll to post-roll if we have no chapters
        if (allFiller.Any(f => f.FillerKind is FillerKind.MidRoll) && effectiveChapters.Count == 0)
        {
            warnings.MidRollContentWithoutChapters++;

            var toRemove = allFiller.Filter(f => f.FillerKind is FillerKind.MidRoll).ToList();
            allFiller.RemoveAll(toRemove.Contains);

            foreach (FillerPreset midRollFiller in toRemove)
            {
                var clone = new FillerPreset
                {
                    FillerKind = FillerKind.PostRoll,
                    FillerMode = midRollFiller.FillerMode,
                    Duration = midRollFiller.Duration,
                    Count = midRollFiller.Count,
                    PadToNearestMinute = midRollFiller.PadToNearestMinute,
                    AllowWatermarks = midRollFiller.AllowWatermarks,
                    CollectionType = midRollFiller.CollectionType,
                    CollectionId = midRollFiller.CollectionId,
                    Collection = midRollFiller.Collection,
                    MediaItemId = midRollFiller.MediaItemId,
                    MediaItem = midRollFiller.MediaItem,
                    MultiCollectionId = midRollFiller.MultiCollectionId,
                    MultiCollection = midRollFiller.MultiCollection,
                    SmartCollectionId = midRollFiller.SmartCollectionId,
                    SmartCollection = midRollFiller.SmartCollection,
                    PlaylistId = midRollFiller.PlaylistId,
                    Playlist = midRollFiller.Playlist
                };

                allFiller.Add(clone);
            }
        }

        // convert playlist filler
        if (allFiller.Any(f => f.CollectionType is CollectionType.Playlist))
        {
            var toRemove = allFiller.Filter(f => f.CollectionType is CollectionType.Playlist)
                .ToList();
            allFiller.RemoveAll(toRemove.Contains);

            foreach (FillerPreset playlistFiller in toRemove)
            {
                var clone = new FillerPreset
                {
                    FillerKind = playlistFiller.FillerKind,
                    FillerMode = playlistFiller.FillerMode,
                    Duration = playlistFiller.Duration,
                    Count = playlistFiller.Count,
                    PadToNearestMinute = playlistFiller.PadToNearestMinute,
                    AllowWatermarks = playlistFiller.AllowWatermarks,
                    CollectionType = playlistFiller.CollectionType,
                    CollectionId = playlistFiller.CollectionId,
                    Collection = playlistFiller.Collection,
                    MediaItemId = playlistFiller.MediaItemId,
                    MediaItem = playlistFiller.MediaItem,
                    MultiCollectionId = playlistFiller.MultiCollectionId,
                    MultiCollection = playlistFiller.MultiCollection,
                    SmartCollectionId = playlistFiller.SmartCollectionId,
                    SmartCollection = playlistFiller.SmartCollection,
                    PlaylistId = playlistFiller.PlaylistId,
                    Playlist = playlistFiller.Playlist,
                    Expression = playlistFiller.Expression,
                    UseChaptersAsMediaItems = playlistFiller.UseChaptersAsMediaItems
                };

                // if filler count is 2, we need to schedule 2 * (number of items in one full playlist iteration)
                IMediaCollectionEnumerator fillerEnumerator =
                    enumerators[CollectionKey.ForFillerPreset(playlistFiller)];
                if (fillerEnumerator is PlaylistEnumerator playlistEnumerator)
                {
                    clone.Count *= playlistEnumerator.CountForFiller;
                }

                allFiller.Add(clone);
            }
        }

        foreach (FillerPreset filler in allFiller.Filter(f =>
                     f.FillerKind == FillerKind.PreRoll && f.FillerMode != FillerMode.Pad))
        {
            switch (filler.FillerMode)
            {
                case FillerMode.Duration when filler.Duration.HasValue:
                    IMediaCollectionEnumerator e1 = enumerators[CollectionKey.ForFillerPreset(filler)];
                    result.AddRange(
                        AddDurationFiller(
                            playoutBuilderState,
                            e1,
                            filler.Duration.Value,
                            scheduleItem.GuideMode == GuideMode.Filler ? FillerKind.GuideMode : FillerKind.PreRoll,
                            filler.AllowWatermarks,
                            warnings));
                    break;
                case FillerMode.Count when filler.Count.HasValue:
                    IMediaCollectionEnumerator e2 = enumerators[CollectionKey.ForFillerPreset(filler)];
                    result.AddRange(
                        AddCountFiller(
                            playoutBuilderState,
                            scheduleItem,
                            e2,
                            filler.Count.Value,
                            scheduleItem.GuideMode == GuideMode.Filler ? FillerKind.GuideMode : FillerKind.PreRoll,
                            filler.AllowWatermarks,
                            filler.Id,
                            cancellationToken));
                    break;
                case FillerMode.RandomCount when filler.Count.HasValue:
                    IMediaCollectionEnumerator e3 = enumerators[CollectionKey.ForFillerPreset(filler)];
                    result.AddRange(
                        AddRandomCountFiller(
                            playoutBuilderState,
                            scheduleItem,
                            e3,
                            filler.Count.Value,
                            scheduleItem.GuideMode == GuideMode.Filler ? FillerKind.GuideMode : FillerKind.PreRoll,
                            filler.AllowWatermarks,
                            filler.Id,
                            cancellationToken));
                    break;
            }
        }

        if (effectiveChapters.Count <= 1)
        {
            result.Add(playoutItem);
        }
        else
        {
            foreach (FillerPreset filler in allFiller.Filter(f =>
                         f.FillerKind == FillerKind.MidRoll && f.FillerMode != FillerMode.Pad))
            {
                List<MediaChapter> filteredChapters = FillerExpression.FilterChapters(
                    filler.Expression,
                    effectiveChapters,
                    playoutItem);
                if (filteredChapters.Count <= 1)
                {
                    result.Add(playoutItem);
                    continue;
                }

                switch (filler.FillerMode)
                {
                    case FillerMode.Duration when filler.Duration.HasValue:
                        IMediaCollectionEnumerator e1 = enumerators[CollectionKey.ForFillerPreset(filler)];
                        for (var i = 0; i < filteredChapters.Count; i++)
                        {
                            result.Add(playoutItem.ForChapter(filteredChapters[i]));
                            if (i < filteredChapters.Count - 1)
                            {
                                result.AddRange(
                                    AddDurationFiller(
                                        playoutBuilderState,
                                        e1,
                                        filler.Duration.Value,
                                        scheduleItem.GuideMode == GuideMode.Filler
                                            ? FillerKind.GuideMode
                                            : FillerKind.MidRoll,
                                        filler.AllowWatermarks,
                                        warnings));
                            }
                        }

                        break;
                    case FillerMode.Count when filler.Count.HasValue:
                        IMediaCollectionEnumerator e2 = enumerators[CollectionKey.ForFillerPreset(filler)];
                        for (var i = 0; i < filteredChapters.Count; i++)
                        {
                            result.Add(playoutItem.ForChapter(filteredChapters[i]));
                            if (i < filteredChapters.Count - 1)
                            {
                                result.AddRange(
                                    AddCountFiller(
                                        playoutBuilderState,
                                        scheduleItem,
                                        e2,
                                        filler.Count.Value,
                                        scheduleItem.GuideMode == GuideMode.Filler
                                            ? FillerKind.GuideMode
                                            : FillerKind.MidRoll,
                                        filler.AllowWatermarks,
                                        filler.Id,
                                        cancellationToken));
                            }
                        }

                        break;
                    case FillerMode.RandomCount when filler.Count.HasValue:
                        IMediaCollectionEnumerator e3 = enumerators[CollectionKey.ForFillerPreset(filler)];
                        for (var i = 0; i < filteredChapters.Count; i++)
                        {
                            result.Add(playoutItem.ForChapter(filteredChapters[i]));
                            if (i < filteredChapters.Count - 1)
                            {
                                result.AddRange(
                                    AddRandomCountFiller(
                                        playoutBuilderState,
                                        scheduleItem,
                                        e3,
                                        filler.Count.Value,
                                        scheduleItem.GuideMode == GuideMode.Filler
                                            ? FillerKind.GuideMode
                                            : FillerKind.MidRoll,
                                        filler.AllowWatermarks,
                                        filler.Id,
                                        cancellationToken));
                            }
                        }

                        break;
                }
            }
        }

        foreach (FillerPreset filler in allFiller.Filter(f =>
                     f.FillerKind == FillerKind.PostRoll && f.FillerMode != FillerMode.Pad))
        {
            switch (filler.FillerMode)
            {
                case FillerMode.Duration when filler.Duration.HasValue:
                    IMediaCollectionEnumerator e1 = enumerators[CollectionKey.ForFillerPreset(filler)];
                    result.AddRange(
                        AddDurationFiller(
                            playoutBuilderState,
                            e1,
                            filler.Duration.Value,
                            scheduleItem.GuideMode == GuideMode.Filler ? FillerKind.GuideMode : FillerKind.PostRoll,
                            filler.AllowWatermarks,
                            warnings));
                    break;
                case FillerMode.Count when filler.Count.HasValue:
                    IMediaCollectionEnumerator e2 = enumerators[CollectionKey.ForFillerPreset(filler)];
                    result.AddRange(
                        AddCountFiller(
                            playoutBuilderState,
                            scheduleItem,
                            e2,
                            filler.Count.Value,
                            scheduleItem.GuideMode == GuideMode.Filler ? FillerKind.GuideMode : FillerKind.PostRoll,
                            filler.AllowWatermarks,
                            filler.Id,
                            cancellationToken));
                    break;
                case FillerMode.RandomCount when filler.Count.HasValue:
                    IMediaCollectionEnumerator e3 = enumerators[CollectionKey.ForFillerPreset(filler)];
                    result.AddRange(
                        AddRandomCountFiller(
                            playoutBuilderState,
                            scheduleItem,
                            e3,
                            filler.Count.Value,
                            scheduleItem.GuideMode == GuideMode.Filler ? FillerKind.GuideMode : FillerKind.PostRoll,
                            filler.AllowWatermarks,
                            filler.Id,
                            cancellationToken));
                    break;
            }
        }

        // #392: schedule-level clock padding. Applies only when the item has no own Pad filler preset (that
        // wins) and the parent schedule declares a positive divisor. Reuses the existing pad boundary math +
        // FallbackFillerForPad; when no fallback content exists, records an offline target so the caller
        // advances the build clock to the boundary (leaving an offline gap).
        bool itemHasPadFiller =
            allFiller.Any(f => f.FillerMode == FillerMode.Pad && f.PadToNearestMinute.HasValue);
        if (!itemHasPadFiller &&
            scheduleItem.ProgramSchedule?.PadToNearestMinute is int schedulePadMinutes &&
            schedulePadMinutes > 0)
        {
            TimeSpan totalDuration = result.Aggregate(
                TimeSpan.Zero,
                (acc, i) => acc + (i.FinishOffset - i.StartOffset));

            DateTimeOffset targetTime = ComputePadBoundary(
                playoutItem.StartOffset, totalDuration, schedulePadMinutes);

            TimeSpan remainingToFill = targetTime - totalDuration - playoutItem.StartOffset;
            if (remainingToFill > TimeSpan.Zero)
            {
                Option<PlayoutItem> maybeFallback = FallbackFillerForPad(
                    playoutBuilderState,
                    enumerators,
                    scheduleItem,
                    remainingToFill,
                    cancellationToken);

                if (maybeFallback.IsSome)
                {
                    foreach (PlayoutItem fallbackItem in maybeFallback)
                    {
                        result.Add(fallbackItem);
                    }
                }
                else
                {
                    // No fallback content: leave an offline gap up to the boundary.
                    offlinePadTarget = targetTime;
                }
            }
        }

        // after all non-padded filler has been added, figure out padding
        foreach (FillerPreset padFiller in Optional(
                     allFiller.FirstOrDefault(f => f.FillerMode == FillerMode.Pad && f.PadToNearestMinute.HasValue)))
        {
            var totalDuration = TimeSpan.FromTicks(result.Sum(pi => (pi.Finish - pi.Start).Ticks));

            List<MediaChapter> filteredChapters =
                FillerExpression.FilterChapters(padFiller.Expression, effectiveChapters, playoutItem);

            FillerKind fillerKind = padFiller.FillerKind;
            if (filteredChapters.Count <= 1 && effectiveChapters.Count > 1)
            {
                fillerKind = FillerKind.PostRoll;
            }

            // add primary content to totalDuration only if it hasn't already been added
            if (result.All(pi => pi.MediaItemId != playoutItem.MediaItemId))
            {
                totalDuration += TimeSpan.FromTicks(filteredChapters.Sum(c => (c.EndTime - c.StartTime).Ticks));
            }

            // ReSharper disable once PossibleInvalidOperationException
            DateTimeOffset targetTime = ComputePadBoundary(
                playoutItem.StartOffset, totalDuration, padFiller.PadToNearestMinute.Value);

            TimeSpan remainingToFill = targetTime - totalDuration - playoutItem.StartOffset;

            // Logger.LogInformation(
            //     "Total duration {TotalDuration}; need to fill {TimeSpan} to pad properly from {StartTime} to {TargetTime}",
            //     totalDuration,
            //     remainingToFill,
            //     playoutItem.StartOffset,
            //     targetTime);

            switch (fillerKind)
            {
                case FillerKind.PreRoll:
                    IMediaCollectionEnumerator pre1 = enumerators[CollectionKey.ForFillerPreset(padFiller)];
                    result.InsertRange(
                        0,
                        AddDurationFiller(
                            playoutBuilderState,
                            pre1,
                            remainingToFill,
                            scheduleItem.GuideMode == GuideMode.Filler ? FillerKind.GuideMode : FillerKind.PreRoll,
                            padFiller.AllowWatermarks,
                            warnings));
                    totalDuration = TimeSpan.FromTicks(result.Sum(pi => (pi.Finish - pi.Start).Ticks));
                    remainingToFill = targetTime - totalDuration - playoutItem.StartOffset;
                    if (remainingToFill > TimeSpan.Zero)
                    {
                        result.InsertRange(
                            0,
                            FallbackFillerForPad(
                                playoutBuilderState,
                                enumerators,
                                scheduleItem,
                                remainingToFill,
                                cancellationToken));
                    }

                    break;
                case FillerKind.MidRoll:
                    IMediaCollectionEnumerator mid1 = enumerators[CollectionKey.ForFillerPreset(padFiller)];
                    var fillerQueue = new Queue<PlayoutItem>(
                        AddDurationFiller(
                            playoutBuilderState,
                            mid1,
                            remainingToFill,
                            scheduleItem.GuideMode == GuideMode.Filler ? FillerKind.GuideMode : FillerKind.MidRoll,
                            padFiller.AllowWatermarks,
                            warnings));
                    TimeSpan average = filteredChapters.Count <= 1
                        ? remainingToFill
                        : remainingToFill / (filteredChapters.Count - 1);
                    TimeSpan filled = TimeSpan.Zero;

                    // remove post-roll to add after mid-roll/content
                    var postRoll = result.Where(i => i.FillerKind == FillerKind.PostRoll).ToList();
                    result.RemoveAll(i => i.FillerKind == FillerKind.PostRoll);

                    for (var i = 0; i < filteredChapters.Count; i++)
                    {
                        result.Add(playoutItem.ForChapter(filteredChapters[i]));
                        if (i < filteredChapters.Count - 1)
                        {
                            TimeSpan current = TimeSpan.Zero;
                            while (current < average && filled < remainingToFill)
                            {
                                if (fillerQueue.TryDequeue(out PlayoutItem fillerItem))
                                {
                                    result.Add(fillerItem);
                                    current += fillerItem.Finish - fillerItem.Start;
                                    filled += fillerItem.Finish - fillerItem.Start;
                                }
                                else
                                {
                                    TimeSpan leftInThisBreak = average - current;
                                    TimeSpan leftOverall = remainingToFill - filled;

                                    TimeSpan maxThisBreak = leftOverall < leftInThisBreak
                                        ? leftOverall
                                        : leftInThisBreak;

                                    Option<PlayoutItem> maybeFallback = FallbackFillerForPad(
                                        playoutBuilderState,
                                        enumerators,
                                        scheduleItem,
                                        i < filteredChapters.Count - 1 ? maxThisBreak : leftOverall,
                                        cancellationToken);

                                    foreach (PlayoutItem fallback in maybeFallback)
                                    {
                                        current += fallback.Finish - fallback.Start;
                                        filled += fallback.Finish - fallback.Start;
                                        result.Add(fallback);
                                    }
                                }
                            }
                        }
                    }

                    result.AddRange(postRoll);

                    break;
                case FillerKind.PostRoll:
                    IMediaCollectionEnumerator post1 = enumerators[CollectionKey.ForFillerPreset(padFiller)];
                    result.AddRange(
                        AddDurationFiller(
                            playoutBuilderState,
                            post1,
                            remainingToFill,
                            scheduleItem.GuideMode == GuideMode.Filler ? FillerKind.GuideMode : FillerKind.PostRoll,
                            padFiller.AllowWatermarks,
                            warnings));
                    totalDuration = TimeSpan.FromTicks(result.Sum(pi => (pi.Finish - pi.Start).Ticks));
                    remainingToFill = targetTime - totalDuration - playoutItem.StartOffset;
                    if (remainingToFill > TimeSpan.Zero)
                    {
                        result.AddRange(
                            FallbackFillerForPad(
                                playoutBuilderState,
                                enumerators,
                                scheduleItem,
                                remainingToFill,
                                cancellationToken));
                    }

                    break;
            }
        }

        // fix times on each playout item
        DateTimeOffset currentTime = playoutItem.StartOffset;
        for (var i = 0; i < result.Count; i++)
        {
            PlayoutItem item = result[i];
            TimeSpan duration = item.Finish - item.Start;
            item.Start = currentTime.UtcDateTime;
            item.Finish = (currentTime + duration).UtcDateTime;
            currentTime = item.FinishOffset;
        }

        return (result, offlinePadTarget);
    }

    // #392: shared clock-boundary ceiling used by both the item-level Pad filler and the schedule-level pad.
    // Returns the next `padToNearestMinute` boundary at or after (blockStart + totalDuration); when the block
    // already ends exactly on a boundary it advances one full interval (matches the pre-#392 Pad behavior).
    private static DateTimeOffset ComputePadBoundary(
        DateTimeOffset blockStart,
        TimeSpan totalDuration,
        int padToNearestMinute)
    {
        int currentMinute = (blockStart + totalDuration).Minute;
        int targetMinute = (currentMinute + padToNearestMinute - 1) / padToNearestMinute * padToNearestMinute;

        DateTimeOffset almostTargetTime = blockStart + totalDuration -
                                          TimeSpan.FromMinutes(currentMinute) +
                                          TimeSpan.FromMinutes(targetMinute);

        var targetTime = new DateTimeOffset(
            almostTargetTime.Year,
            almostTargetTime.Month,
            almostTargetTime.Day,
            almostTargetTime.Hour,
            almostTargetTime.Minute,
            0,
            almostTargetTime.Offset);

        // ensure filler works for content less than one interval (and for content already on a boundary)
        if (targetTime <= blockStart + totalDuration)
        {
            targetTime = targetTime.AddMinutes(padToNearestMinute);
        }

        return targetTime;
    }

    private List<PlayoutItem> AddCountFiller(
        PlayoutBuilderState playoutBuilderState,
        ProgramScheduleItem scheduleItem,
        IMediaCollectionEnumerator enumerator,
        int count,
        FillerKind fillerKind,
        bool allowWatermarks,
        int fillerPresetId,
        CancellationToken cancellationToken)
    {
        var result = new List<PlayoutItem>();

        for (var i = 0; i < count; i++)
        {
            foreach (MediaItem mediaItem in enumerator.Current)
            {
                TimeSpan itemDuration = mediaItem.GetDurationForPlayout();
                TimeSpan inPoint = InPointForMediaItem(mediaItem);

                var playoutItem = new PlayoutItem
                {
                    PlayoutId = playoutBuilderState.PlayoutId,
                    MediaItemId = IdForMediaItem(mediaItem),
                    Start = new DateTime(2020, 2, 1, 0, 0, 0, DateTimeKind.Utc),
                    Finish = new DateTime(2020, 2, 1, 0, 0, 0, DateTimeKind.Utc) + itemDuration,
                    InPoint = inPoint,
                    OutPoint = inPoint + itemDuration,
                    GuideGroup = playoutBuilderState.NextGuideGroup,
                    FillerKind = fillerKind,
                    DisableWatermarks = !allowWatermarks,
                    ChapterTitle = ChapterTitleForMediaItem(mediaItem),
                    SchedulingContext = GetSchedulingContext(scheduleItem, fillerPresetId, enumerator)
                };

                result.Add(playoutItem);

                // TODO: this won't work with reruns
                enumerator.MoveNext(Option<DateTimeOffset>.None);
            }
        }

        return result;
    }

    private static List<PlayoutItem> AddDurationFiller(
        PlayoutBuilderState playoutBuilderState,
        IMediaCollectionEnumerator enumerator,
        TimeSpan duration,
        FillerKind fillerKind,
        bool allowWatermarks,
        PlayoutBuildWarnings warnings)
    {
        var result = new List<PlayoutItem>();

        TimeSpan remainingToFill = duration;
        while (enumerator.Current.IsSome && remainingToFill > TimeSpan.Zero &&
               remainingToFill >= enumerator.MinimumDuration)
        {
            foreach (MediaItem mediaItem in enumerator.Current)
            {
                TimeSpan itemDuration = mediaItem.GetDurationForPlayout();
                TimeSpan inPoint = InPointForMediaItem(mediaItem);

                if (remainingToFill - itemDuration >= TimeSpan.Zero)
                {
                    var playoutItem = new PlayoutItem
                    {
                        PlayoutId = playoutBuilderState.PlayoutId,
                        MediaItemId = IdForMediaItem(mediaItem),
                        Start = new DateTime(2020, 2, 1, 0, 0, 0, DateTimeKind.Utc),
                        Finish = new DateTime(2020, 2, 1, 0, 0, 0, DateTimeKind.Utc) + itemDuration,
                        InPoint = inPoint,
                        OutPoint = inPoint + itemDuration,
                        GuideGroup = playoutBuilderState.NextGuideGroup,
                        FillerKind = fillerKind,
                        DisableWatermarks = !allowWatermarks,
                        ChapterTitle = ChapterTitleForMediaItem(mediaItem)
                    };

                    remainingToFill -= itemDuration;
                    result.Add(playoutItem);

                    // TODO: this won't work with reruns
                    enumerator.MoveNext(Option<DateTimeOffset>.None);
                }
                else
                {
                    warnings.DurationFillerSkipped++;

                    // TODO: this won't work with reruns
                    enumerator.MoveNext(Option<DateTimeOffset>.None);
                }
            }
        }

        return result;
    }

    private static Option<PlayoutItem> FallbackFillerForPad(
        PlayoutBuilderState playoutBuilderState,
        Dictionary<CollectionKey, IMediaCollectionEnumerator> enumerators,
        ProgramScheduleItem scheduleItem,
        TimeSpan duration,
        CancellationToken cancellationToken)
    {
        if (scheduleItem.FallbackFiller != null)
        {
            IMediaCollectionEnumerator enumerator =
                enumerators[CollectionKey.ForFillerPreset(scheduleItem.FallbackFiller)];

            foreach (MediaItem mediaItem in enumerator.Current)
            {
                var result = new PlayoutItem
                {
                    PlayoutId = playoutBuilderState.PlayoutId,
                    MediaItemId = mediaItem.Id,
                    Start = new DateTime(2020, 2, 1, 0, 0, 0, DateTimeKind.Utc),
                    Finish = new DateTime(2020, 2, 1, 0, 0, 0, DateTimeKind.Utc) + duration,
                    InPoint = TimeSpan.Zero,
                    OutPoint = TimeSpan.Zero,
                    GuideGroup = playoutBuilderState.NextGuideGroup,
                    FillerKind = FillerKind.Fallback,
                    DisableWatermarks = !scheduleItem.FallbackFiller.AllowWatermarks
                };

                // TODO: this won't work with reruns
                enumerator.MoveNext(Option<DateTimeOffset>.None);

                return result;
            }
        }

        return None;
    }

    private List<PlayoutItem> AddRandomCountFiller(
        PlayoutBuilderState playoutBuilderState,
        ProgramScheduleItem scheduleItem,
        IMediaCollectionEnumerator enumerator,
        int count,
        FillerKind fillerKind,
        bool allowWatermarks,
        int fillerPresetId,
        CancellationToken cancellationToken)
    {
        var result = new List<PlayoutItem>();
        // randomCount is from 0 to count.
        int randomCount = _random.Next(count + 1);

        if (randomCount != 0)
        {
            result = AddCountFiller(
                playoutBuilderState,
                scheduleItem,
                enumerator,
                randomCount,
                fillerKind,
                allowWatermarks,
                fillerPresetId,
                cancellationToken);
        }

        return result;
    }

    protected abstract string SchedulingContextName { get; }

    protected string GetSchedulingContext(
        ProgramScheduleItem scheduleItem,
        int? fillerPresetId,
        IMediaCollectionEnumerator enumerator)
    {
        var context = new ClassicSchedulingContext(
            SchedulingContextName,
            scheduleItem.ProgramScheduleId,
            scheduleItem.Id,
            fillerPresetId,
            enumerator.SchedulingContextName,
            enumerator.State.Seed,
            enumerator.State.Index);

        return JsonSerializer.Serialize(context, Options);
    }
}
