using TeleWave.Domain.Broadcast.Scheduling;
using TeleWave.Domain.Programming;
using TeleWave.Domain.Programming.Planning;
using Xunit;

namespace TeleWave.Domain.Tests.Programming;

public class SchedulePlannerTests
{
    private static readonly DateTimeOffset T0 = new(2026, 1, 5, 12, 0, 0, TimeSpan.Zero);

    /// <summary>Всегда первый вариант — жребий в тестах не должен добавлять шума.</summary>
    private sealed class FirstAlways : IRandomSource
    {
        public int Next(int maxExclusive) => 0;
    }

    private static PlanningUnit Unit(int minutes, Guid showId, int index) =>
        new(Guid.NewGuid(), TimeSpan.FromMinutes(minutes), showId, index);

    private static PlanningElement Element(
        int unitCount,
        int minutesEach,
        int weight = 1,
        int position = 0,
        DateTimeOffset? lastPlayed = null
    )
    {
        var showId = Guid.NewGuid();
        var units = Enumerable
            .Range(0, unitCount)
            .Select(i => Unit(minutesEach, showId, i))
            .ToList();
        return new PlanningElement(
            GroupElementKind.Show,
            Guid.NewGuid(),
            weight,
            position,
            units,
            lastPlayed
        );
    }

    private static PlanningSlot Slot(
        DateTimeOffset startUtc,
        int durationMinutes,
        IReadOnlyList<PlanningElement> elements,
        SlotBlockMode blockMode = SlotBlockMode.FillSlot,
        int blockValue = 1,
        OverflowPolicy overflow = OverflowPolicy.ContinueNext,
        bool isAnchor = false,
        int? snapToMinutes = null,
        PlanningCursor? cursor = null,
        SlotStrategyKind strategy = SlotStrategyKind.Sequential,
        int cooldownDays = 0
    ) =>
        new(
            Guid.NewGuid(),
            startUtc,
            durationMinutes,
            SlotKind.Content,
            isAnchor,
            MaxDriftMinutes: 30,
            snapToMinutes,
            blockMode,
            blockValue,
            overflow,
            new PlanningStrategy(strategy, CooldownDays: cooldownDays),
            elements,
            cursor
        );

    private static PlanningInput Input(
        IReadOnlyList<PlanningSlot> slots,
        DateTimeOffset? start = null,
        int horizonHours = 6,
        IReadOnlyList<PlanningUnit>? fallback = null,
        IReadOnlyList<PlanningSlot>? background = null,
        IReadOnlyList<PadUnit>? pad = null,
        TimeSpan maxPad = default
    ) =>
        new(
            Guid.NewGuid(),
            start ?? T0,
            (start ?? T0).AddHours(horizonHours),
            slots,
            fallback ?? [Unit(1, Guid.NewGuid(), 0)],
            SegmentSeconds: 2,
            UtcOffsetMinutes: 0,
            background ?? [],
            pad ?? [],
            maxPad
        );

    private static PadUnit Ad(int minutes) => new(Unit(minutes, Guid.Empty, 0), PlannedItemKind.Ad);

    private static PlanningResult Run(PlanningInput input) =>
        TeleWave.Domain.Programming.Planning.SchedulePlanner.Plan(input, new FirstAlways());

    [Fact]
    public void ContinuousTape_HasNoGapsBetweenItems()
    {
        var element = Element(4, 30);
        var result = Run(Input([Slot(T0, 120, [element])]));

        var ordered = result.Items.OrderBy(i => i.StartsAtUtc).ToList();
        Assert.NotEmpty(ordered);
        for (var i = 1; i < ordered.Count; i++)
            Assert.Equal(ordered[i - 1].EndsAtUtc, ordered[i].StartsAtUtc);
    }

    [Fact]
    public void BlockModeCount_PlacesExactlyRequestedUnits()
    {
        var element = Element(10, 20);
        var result = Run(
            Input([Slot(T0, 300, [element], SlotBlockMode.Count, blockValue: 3)], horizonHours: 1)
        );

        Assert.Equal(3, result.Items.Count(i => i.Kind == PlannedItemKind.Program));
    }

    [Fact]
    public void BlockModeDuration_IncludesLastUnitWholly()
    {
        var element = Element(10, 20);
        var result = Run(
            Input(
                [Slot(T0, 300, [element], SlotBlockMode.Duration, blockValue: 50)],
                horizonHours: 2
            )
        );

        // 20+20 < 50, третья добирает до 60 — обрезать видеофайл нельзя.
        Assert.Equal(3, result.Items.Count(i => i.Kind == PlannedItemKind.Program));
    }

    [Fact]
    public void Anchor_DoesNotStartUnitThatCrossesIt()
    {
        var first = Element(10, 25);
        var second = Element(1, 30);

        var slots = new[]
        {
            Slot(T0, 60, [first]),
            Slot(T0.AddMinutes(60), 60, [second], isAnchor: true),
        };
        var result = Run(Input(slots));

        var anchored = result.Items.First(i => i.SlotId == slots[1].SlotId);
        // Третья 25-минутная серия перелезла бы через якорь — её не начинают.
        Assert.True(anchored.StartsAtUtc >= slots[1].TargetStartUtc);
    }

    [Fact]
    public void Snap_MovesStartToRoundTime()
    {
        // Первый слот кончается в 12:47 — второй с округлением до 15 минут стартует в 13:00.
        var first = Element(1, 47);
        var second = Element(1, 30);

        var slots = new[]
        {
            Slot(T0, 47, [first]),
            Slot(T0.AddMinutes(40), 60, [second], snapToMinutes: 15),
        };
        var result = Run(Input(slots));

        var secondItem = result.Items.First(i =>
            i.SlotId == slots[1].SlotId && i.Kind == PlannedItemKind.Program
        );
        Assert.Equal(0, secondItem.StartsAtUtc.Minute % 15);
    }

    [Fact]
    public void Snap_SkippedWhenDriftWouldExceedAllowance()
    {
        var element = Element(1, 30);
        var slot = Slot(T0.AddMinutes(-1), 60, [element], snapToMinutes: 30) with
        {
            MaxDriftMinutes = 2,
        };

        var result = Run(Input([slot]));

        // Округление потребовало бы почти 30 минут фона — лучше начать не в круглое время.
        var program = result.Items.First(i => i.Kind == PlannedItemKind.Program);
        Assert.Equal(T0, program.StartsAtUtc);
    }

    [Fact]
    public void GapBeforeSlot_IsFilledWithFallback()
    {
        var element = Element(1, 30);
        var result = Run(Input([Slot(T0.AddMinutes(10), 30, [element])]));

        Assert.Contains(result.Items, i => i.Kind == PlannedItemKind.Fallback);
        Assert.Equal(T0, result.Items.Min(i => i.StartsAtUtc));
    }

    [Fact]
    public void EmptySlot_WarnsAndFallsBack()
    {
        var result = Run(Input([Slot(T0, 30, [])]));

        Assert.Contains(result.Warnings, w => w.Kind == PlanningWarningKind.SlotEmpty);
        Assert.All(result.Items, i => Assert.NotEqual(PlannedItemKind.Program, i.Kind));
    }

    [Fact]
    public void Cursor_AdvancesToNextUnit()
    {
        var element = Element(5, 20);
        var slot = Slot(T0, 60, [element], SlotBlockMode.Count, blockValue: 2);

        var result = Run(Input([slot], horizonHours: 1));

        var cursor = Assert.Single(result.Cursors);
        Assert.Equal(element.ElementId, cursor.ElementId);
        Assert.Equal(2, cursor.NextUnitIndex);
    }

    [Fact]
    public void Cursor_ResumesMidElement()
    {
        var element = Element(5, 20);
        var slot = Slot(
            T0,
            60,
            [element],
            SlotBlockMode.Count,
            blockValue: 1,
            cursor: new PlanningCursor(GroupElementKind.Show, element.ElementId, 3)
        );

        var result = Run(Input([slot], horizonHours: 1));

        var program = result.Items.First(i => i.Kind == PlannedItemKind.Program);
        Assert.Equal(3, program.UnitIndex);
    }

    [Fact]
    public void SkipIfNotFits_LeavesSlotToFallback()
    {
        // Элемент из трёх часовых частей в часовой слот целиком не влезает.
        var element = Element(3, 60);
        var slot = Slot(T0, 60, [element], overflow: OverflowPolicy.SkipIfNotFits);

        var result = Run(Input([slot], horizonHours: 2));

        Assert.DoesNotContain(result.Items, i => i.Kind == PlannedItemKind.Program);
    }

    [Fact]
    public void ExtendSlot_PlaysElementBeyondBudget()
    {
        var element = Element(3, 60);
        var slot = Slot(T0, 60, [element], overflow: OverflowPolicy.ExtendSlot);

        var result = Run(Input([slot], horizonHours: 5));

        // Трилогия доигрывается целиком, слот залезает на соседей — так и задумано.
        Assert.Equal(3, result.Items.Count(i => i.Kind == PlannedItemKind.Program));
    }

    [Fact]
    public void Cooldown_PrefersElementOutsideWindow()
    {
        var hot = Element(1, 30, position: 0, lastPlayed: T0.AddDays(-1));
        var cold = Element(1, 30, position: 1, lastPlayed: T0.AddDays(-40));

        var slot = Slot(
            T0,
            30,
            [hot, cold],
            SlotBlockMode.Count,
            blockValue: 1,
            strategy: SlotStrategyKind.RandomWithCooldown,
            cooldownDays: 28
        );

        var result = Run(Input([slot], horizonHours: 1));

        var program = result.Items.First(i => i.Kind == PlannedItemKind.Program);
        Assert.Equal(cold.Units[0].ShowId, program.ShowId);
    }

    [Fact]
    public void Cooldown_Exhausted_WarnsAndTakesOldest()
    {
        var recentA = Element(1, 30, position: 0, lastPlayed: T0.AddDays(-2));
        var recentB = Element(1, 30, position: 1, lastPlayed: T0.AddDays(-5));

        var slot = Slot(
            T0,
            30,
            [recentA, recentB],
            SlotBlockMode.Count,
            blockValue: 1,
            strategy: SlotStrategyKind.RandomWithCooldown,
            cooldownDays: 28
        );

        var result = Run(Input([slot], horizonHours: 1));

        Assert.Contains(result.Warnings, w => w.Kind == PlanningWarningKind.CooldownExhausted);
        var program = result.Items.First(i => i.Kind == PlannedItemKind.Program);
        Assert.Equal(recentB.Units[0].ShowId, program.ShowId);
    }

    [Fact]
    public void SignOffSlot_MarksItemsAccordingly()
    {
        var slot = Slot(T0, 60, []) with { SlotKind = SlotKind.SignOff };

        var result = Run(Input([slot], horizonHours: 2));

        Assert.Contains(result.Items, i => i.Kind == PlannedItemKind.SignOff);
    }

    [Fact]
    public void RepeatSlot_ReplaysSourceUnits()
    {
        var showId = Guid.NewGuid();
        var repeat = new[] { Unit(30, showId, 0), Unit(30, showId, 1) };
        var slot = Slot(T0, 60, []) with { SlotKind = SlotKind.Repeat, RepeatUnits = repeat };

        var result = Run(Input([slot], horizonHours: 2));

        Assert.Equal(2, result.Items.Count(i => i.Kind == PlannedItemKind.Program));
    }

    [Fact]
    public void RepeatSlot_WithEmptySource_WarnsAndFallsBack()
    {
        var slot = Slot(T0, 60, []) with { SlotKind = SlotKind.Repeat, RepeatUnits = [] };

        var result = Run(Input([slot], horizonHours: 2));

        Assert.Contains(result.Warnings, w => w.Kind == PlanningWarningKind.RepeatSourceEmpty);
    }

    /// <summary>
    /// Горизонт строится одним прогоном, поэтому источник повтора обычно ещё не в базе, а в этой же
    /// ленте: вечерний фильм понедельника повторяют во вторник утром, и оба спланированы вместе.
    /// </summary>
    [Fact]
    public void RepeatSlot_TakesSourceFromTheSameRun()
    {
        var evening = Slot(T0, 60, [Element(1, 60)]);
        var morning = Slot(T0.AddHours(2), 60, []) with
        {
            SlotKind = SlotKind.Repeat,
            RepeatWindow = new PlanningRepeatWindow(T0, T0.AddHours(1)),
        };

        var result = Run(Input([evening, morning], horizonHours: 4));

        var source = result.Items.First(i => i.StartsAtUtc == T0);
        Assert.Contains(
            result.Items,
            i => i.StartsAtUtc >= T0.AddHours(2) && i.MediaAssetId == source.MediaAssetId
        );
        Assert.DoesNotContain(
            result.Warnings,
            w => w.Kind == PlanningWarningKind.RepeatSourceEmpty
        );
    }

    /// <summary>Источник нашёлся, но слот короче — это другая беда, и молчать о ней нельзя.</summary>
    [Fact]
    public void RepeatSlot_WithSourceTooLongForSlot_Warns()
    {
        var evening = Slot(T0, 60, [Element(1, 60)]);
        var morning = Slot(T0.AddHours(2), 30, []) with
        {
            SlotKind = SlotKind.Repeat,
            RepeatWindow = new PlanningRepeatWindow(T0, T0.AddHours(1)),
        };

        var result = Run(Input([evening, morning], horizonHours: 4));

        Assert.Contains(result.Warnings, w => w.Kind == PlanningWarningKind.RepeatSourceEmpty);
    }

    /// <summary>
    /// Старты эластичные: фильм законно начинается раньше объявленного времени, и окно повтора
    /// обязано ловить его по пересечению, а не по попаданию старта внутрь.
    /// </summary>
    [Fact]
    public void RepeatSlot_CatchesUnitStartedBeforeWindow()
    {
        var evening = Slot(T0, 60, [Element(1, 60)]);
        var morning = Slot(T0.AddHours(2), 60, []) with
        {
            SlotKind = SlotKind.Repeat,
            RepeatWindow = new PlanningRepeatWindow(T0.AddMinutes(10), T0.AddMinutes(70)),
        };

        var result = Run(Input([evening, morning], horizonHours: 4));

        var source = result.Items.First(i => i.StartsAtUtc == T0);
        Assert.Contains(
            result.Items,
            i => i.StartsAtUtc >= T0.AddHours(2) && i.MediaAssetId == source.MediaAssetId
        );
    }

    /// <summary>
    /// Слот приходит по экземпляру на каждые свои сутки, и курсор у всех этих экземпляров один —
    /// снимок из базы. Без общей позиции вторник переигрывал бы понедельник.
    /// </summary>
    [Fact]
    public void SameSlot_ContinuesAcrossItsDays_WithinOneRun()
    {
        var element = Element(4, 30);
        var monday = Slot(T0, 60, [element]);
        var tuesday = monday with { TargetStartUtc = T0.AddHours(3) };

        var result = Run(Input([monday, tuesday], horizonHours: 6));

        var played = result
            .Items.Where(i => i.Kind == PlannedItemKind.Program && i.SlotId == monday.SlotId)
            .Select(i => i.MediaAssetId)
            .ToList();
        Assert.Equal(4, played.Count);
        Assert.Equal(4, played.Distinct().Count());
    }

    /// <summary>
    /// Тот же слот бывает перекрыт в одни сутки и свободен в другие: фон и основной цикл ведут его
    /// вместе, и второй счётчик позиции переигрывал бы уже поставленные серии.
    /// </summary>
    [Fact]
    public void Background_AndMainLoop_ShareOneSlotPosition()
    {
        var element = Element(8, 30);
        var background = Slot(T0, 360, [element]);
        var main = background with { TargetStartUtc = T0.AddHours(2), TargetDurationMinutes = 60 };

        var result = Run(Input([main], horizonHours: 4, fallback: [], background: [background]));

        var played = result
            .Items.Where(i => i.Kind == PlannedItemKind.Program)
            .Select(i => i.MediaAssetId)
            .ToList();
        Assert.Equal(8, played.Count);
        Assert.Equal(8, played.Distinct().Count());
    }

    /// <summary>Остывание обязано видеть то, что прогон поставил сам: горизонт строится разом.</summary>
    [Fact]
    public void Cooldown_CountsPlaysFromTheSameRun()
    {
        var first = Slot(
            T0,
            60,
            [Element(1, 60, position: 0), Element(1, 60, position: 1)],
            strategy: SlotStrategyKind.RandomWithCooldown,
            cooldownDays: 14
        );
        var second = first with { SlotId = Guid.NewGuid(), TargetStartUtc = T0.AddHours(2) };

        var result = Run(Input([first, second], horizonHours: 4));

        var shows = result
            .Items.Where(i => i.Kind == PlannedItemKind.Program)
            .Select(i => i.ShowId)
            .ToList();
        Assert.Equal(2, shows.Count);
        Assert.Equal(2, shows.Distinct().Count());
    }

    /// <summary>Потолок повторов — тот же случай: показы прогона считаются наравне с записанными.</summary>
    [Fact]
    public void RepeatLimit_CountsPlaysFromTheSameRun()
    {
        var first = Slot(
            T0,
            60,
            [Element(1, 60, position: 0), Element(1, 60, position: 1)],
            strategy: SlotStrategyKind.RandomWithCooldown
        ) with
        {
            RepeatLimit = new RepeatLimit(WindowDays: 7, Max: 1),
        };
        var second = first with { SlotId = Guid.NewGuid(), TargetStartUtc = T0.AddHours(2) };

        var result = Run(Input([first, second], horizonHours: 4));

        var shows = result
            .Items.Where(i => i.Kind == PlannedItemKind.Program)
            .Select(i => i.ShowId)
            .ToList();
        Assert.Equal(2, shows.Count);
        Assert.Equal(2, shows.Distinct().Count());
    }

    [Fact]
    public void Trace_IsWrittenWithSlotAndStrategy()
    {
        var element = Element(1, 30);
        var slot = Slot(T0, 30, [element], SlotBlockMode.Count, blockValue: 1);

        var result = Run(Input([slot], horizonHours: 1));

        var program = result.Items.First(i => i.Kind == PlannedItemKind.Program);
        Assert.NotNull(program.Trace);
        Assert.Equal(slot.SlotId, program.Trace!.SlotId);
        Assert.Equal(SlotStrategyKind.Sequential, program.Trace.Strategy);
        Assert.Equal(element.ElementId, program.Trace.ElementId);
    }

    [Fact]
    public void Pad_FillsSeamBeforeSlotWithAds()
    {
        // Слот-якорь начинается через полчаса после конца предыдущего: шов до объявленного времени
        // на настоящем ТВ занимает реклама, а не ещё одна серия.
        var anchor = Slot(T0.AddMinutes(30), 30, [Element(1, 30)], isAnchor: true);

        var result = Run(
            Input(
                [Slot(T0, 15, [Element(1, 15)]), anchor],
                horizonHours: 1,
                pad: [Ad(5)],
                maxPad: TimeSpan.FromMinutes(30)
            )
        );

        var ads = result.Items.Where(i => i.Kind == PlannedItemKind.Ad).ToList();
        Assert.Equal(3, ads.Count);
        Assert.Equal(T0.AddMinutes(15), ads[0].StartsAtUtc);
        // К объявленному времени шов заканчивается: якорь начинается ровно в свои полчаса.
        Assert.Equal(anchor.TargetStartUtc, ads[^1].EndsAtUtc);
    }

    [Fact]
    public void Pad_IsCappedSoLongHolesStayProgramming()
    {
        // Дыра в час: рекламой закрывается только потолок одного добора, остальное — программы.
        var background = Slot(T0, 24 * 60, [Element(6, 10)]);

        var result = Run(
            Input(
                [Slot(T0.AddHours(1), 30, [Element(1, 30)], isAnchor: true)],
                horizonHours: 2,
                pad: [Ad(5)],
                maxPad: TimeSpan.FromMinutes(10),
                background: [background]
            )
        );

        var ads = result.Items.Where(i => i.Kind == PlannedItemKind.Ad).ToList();
        Assert.Equal(
            TimeSpan.FromMinutes(10),
            ads.Aggregate(TimeSpan.Zero, (sum, a) => sum + (a.EndsAtUtc - a.StartsAtUtc))
        );
        Assert.Contains(result.Items, i => i.SlotId == background.SlotId);
    }

    [Fact]
    public void Pad_CapCountsWholeSeam_WhenBackgroundHasNothingToPlay()
    {
        // Часовая дыра и пустой фоновый слой: добор до якоря и остаток перед ним сходятся встык,
        // и без общего потолка в эфир ушло бы двадцать минут рекламы подряд вместо десяти.
        var result = Run(
            Input(
                [Slot(T0.AddHours(1), 30, [Element(1, 30)], isAnchor: true)],
                horizonHours: 2,
                pad: [Ad(5)],
                maxPad: TimeSpan.FromMinutes(10)
            )
        );

        // Считается самый длинный рекламный блок подряд: два добора по разные стороны от фона —
        // это два шва, а сошедшиеся встык — один, и он потолком и ограничен.
        var longest = TimeSpan.Zero;
        var current = TimeSpan.Zero;
        foreach (var item in result.Items.OrderBy(i => i.StartsAtUtc))
        {
            current =
                item.Kind == PlannedItemKind.Ad
                    ? current + (item.EndsAtUtc - item.StartsAtUtc)
                    : TimeSpan.Zero;
            if (current > longest)
                longest = current;
        }

        Assert.Equal(TimeSpan.FromMinutes(10), longest);
    }

    [Fact]
    public void Pad_ClosesTailWhenProgrammeDoesNotFit()
    {
        // Остаток в пять минут: серия фона туда не влезает, и хвост занимает реклама, а не филлер.
        var background = Slot(T0, 24 * 60, [Element(4, 30)]);

        var result = Run(
            Input(
                [Slot(T0, 55, [Element(1, 55)])],
                horizonHours: 1,
                pad: [Ad(5)],
                background: [background]
            )
        );

        Assert.Single(result.Items, i => i.Kind == PlannedItemKind.Ad);
        Assert.DoesNotContain(result.Items, i => i.Kind == PlannedItemKind.Fallback);
    }

    [Fact]
    public void Background_PlaysInLeftoverInsteadOfEmergencyFiller()
    {
        // Слот на полчаса при часовом горизонте: остаток по замыслу принадлежит фоновому слою.
        var backgroundElement = Element(4, 10);
        var background = Slot(T0, 24 * 60, [backgroundElement]);

        var result = Run(
            Input([Slot(T0, 30, [Element(1, 30)])], horizonHours: 1, background: [background])
        );

        var filled = result
            .Items.Where(i => i.SlotId == background.SlotId)
            .OrderBy(i => i.StartsAtUtc)
            .ToList();
        Assert.Equal(3, filled.Count);
        // Фон — это программа своего слота, а не аварийный запас.
        Assert.All(filled, item => Assert.Equal(PlannedItemKind.Program, item.Kind));
        Assert.All(
            filled,
            item => Assert.Equal(backgroundElement.ElementId, item.Trace!.ElementId)
        );
        Assert.DoesNotContain(result.Items, i => i.Kind == PlannedItemKind.Fallback);
    }

    [Fact]
    public void Background_ContinuesAcrossGaps()
    {
        var backgroundElement = Element(6, 10);
        var background = Slot(T0, 24 * 60, [backgroundElement]);

        var result = Run(
            Input(
                [Slot(T0, 20, [Element(1, 20)]), Slot(T0.AddMinutes(40), 20, [Element(1, 20)])],
                horizonHours: 2,
                background: [background]
            )
        );

        var filled = result
            .Items.Where(i => i.SlotId == background.SlotId)
            .OrderBy(i => i.StartsAtUtc)
            .Select(i => i.UnitIndex)
            .ToList();
        // Вторая пауза продолжает сериал с той серии, на которой остановилась первая; исчерпав
        // элемент, последовательная стратегия начинает его заново — это её штатное поведение.
        Assert.Equal([0, 1, 2, 3, 4, 5], filled.Take(6));
    }

    [Fact]
    public void Background_FallsBackToEmergencyWhenEpisodeDoesNotFit()
    {
        var background = Slot(T0, 24 * 60, [Element(4, 30)]);

        var result = Run(
            Input(
                [Slot(T0, 50, [Element(1, 50)])],
                horizonHours: 1,
                fallback: [Unit(2, Guid.Empty, 0)],
                background: [background]
            )
        );

        // Остаток — 10 минут, серия фона длиннее: хвост добирает аварийный запас, а не дыра.
        Assert.DoesNotContain(result.Items, i => i.SlotId == background.SlotId);
        Assert.Equal(5, result.Items.Count(i => i.Kind == PlannedItemKind.Fallback));
    }

    [Fact]
    public void Background_FillsEmptyRepeatSlot()
    {
        var backgroundElement = Element(6, 10);
        var background = Slot(T0, 24 * 60, [backgroundElement]);
        var repeat = Slot(T0, 60, []) with { SlotKind = SlotKind.Repeat, RepeatUnits = [] };

        var result = Run(Input([repeat], horizonHours: 1, background: [background]));

        // Свежему каналу повторять нечего — по замыслу это место отдаётся фоновому слою.
        Assert.Contains(result.Warnings, w => w.Kind == PlanningWarningKind.RepeatSourceEmpty);
        Assert.Equal(6, result.Items.Count(i => i.SlotId == background.SlotId));
        Assert.DoesNotContain(result.Items, i => i.Kind == PlannedItemKind.Fallback);
    }

    [Fact]
    public void Background_CursorIsSavedOnlyForOverlappedSlots()
    {
        var backgroundElement = Element(6, 10);
        var background = Slot(T0, 24 * 60, [backgroundElement]);

        var result = Run(
            Input([Slot(T0, 30, [Element(1, 30)])], horizonHours: 1, background: [background])
        );

        var cursor = Assert.Single(result.Cursors, c => c.SlotId == background.SlotId);
        Assert.Equal(backgroundElement.ElementId, cursor.ElementId);
        Assert.Equal(3, cursor.NextUnitIndex);
    }

    [Fact]
    public void Fallback_KeepsShowOfItsUnits()
    {
        var showId = Guid.NewGuid();
        // Слот на 30 минут при часовом горизонте: остаток закрывает фон.
        var result = Run(
            Input([Slot(T0, 30, [Element(1, 30)])], horizonHours: 1, fallback: [Unit(5, showId, 3)])
        );

        var fallback = result.Items.Where(i => i.Kind == PlannedItemKind.Fallback).ToList();
        Assert.NotEmpty(fallback);
        // Фон — это программы из группы: без шоу в ленте на их месте прочерк.
        Assert.All(fallback, item => Assert.Equal(showId, item.ShowId));
        Assert.All(fallback, item => Assert.Equal(3, item.UnitIndex));
    }

    [Fact]
    public void Fallback_AnonymousFillerHasNoShow()
    {
        // Аварийный филлер канала приходит без шоу — подписывать его нечем и не нужно.
        var result = Run(
            Input(
                [Slot(T0, 30, [Element(1, 30)])],
                horizonHours: 1,
                fallback: [Unit(5, Guid.Empty, 0)]
            )
        );

        Assert.All(
            result.Items.Where(i => i.Kind == PlannedItemKind.Fallback),
            item => Assert.Null(item.ShowId)
        );
    }

    [Fact]
    public void Fallback_RotatesUnitsBetweenGaps()
    {
        var first = Unit(5, Guid.NewGuid(), 0);
        var second = Unit(5, Guid.NewGuid(), 0);

        var result = Run(
            Input(
                [Slot(T0, 30, [Element(1, 30)]), Slot(T0.AddMinutes(40), 30, [Element(1, 30)])],
                horizonHours: 2,
                fallback: [first, second]
            )
        );

        var fallback = result
            .Items.Where(i => i.Kind == PlannedItemKind.Fallback)
            .OrderBy(i => i.StartsAtUtc)
            .ToList();
        // Пауза продолжает перебор с того места, где остановилась прошлая: иначе ночь выглядит
        // одним зациклённым куском.
        Assert.Equal(first.MediaAssetId, fallback[0].MediaAssetId);
        Assert.Equal(second.MediaAssetId, fallback[1].MediaAssetId);
    }

    [Fact]
    public void Fallback_TakesShorterUnitWhenNextDoesNotFit()
    {
        var showId = Guid.NewGuid();
        // Список отсортирован по возрастанию, как его готовит оркестратор.
        var result = Run(
            Input(
                [Slot(T0, 50, [Element(1, 50)])],
                horizonHours: 1,
                fallback: [Unit(2, showId, 0), Unit(30, showId, 1)]
            )
        );

        // Остаток — 10 минут: длинная единица не влезает, и вместо дыры в ленте идут короткие.
        var fallback = result.Items.Where(i => i.Kind == PlannedItemKind.Fallback).ToList();
        Assert.Equal(5, fallback.Count);
        Assert.All(
            fallback,
            item => Assert.Equal(TimeSpan.FromMinutes(2), item.EndsAtUtc - item.StartsAtUtc)
        );
    }

    [Fact]
    public void NoFallbackUnits_ProducesWarning()
    {
        var input = Input([], fallback: []);

        var result = Run(input);

        Assert.Contains(result.Warnings, w => w.Kind == PlanningWarningKind.FallbackEmpty);
    }

    [Fact]
    public void SequentialStrategy_MovesToNextElementWhenCurrentExhausted()
    {
        var first = Element(1, 30, position: 0);
        var second = Element(1, 30, position: 1);

        var slot = Slot(
            T0,
            30,
            [first, second],
            SlotBlockMode.Count,
            blockValue: 1,
            cursor: new PlanningCursor(GroupElementKind.Show, first.ElementId, 1)
        );

        var result = Run(Input([slot], horizonHours: 1));

        var program = result.Items.First(i => i.Kind == PlannedItemKind.Program);
        Assert.Equal(second.Units[0].ShowId, program.ShowId);
    }
}
