using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Programming.Groups;
using TeleWave.Application.Programming.Templates;
using TeleWave.Application.Programming.Templates.Generate;
using TeleWave.Application.Tests.Support;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Library;
using TeleWave.Domain.Media;
using TeleWave.Domain.Programming;
using Xunit;

namespace TeleWave.Application.Tests.Programming;

/// <summary>
/// Автосборка сетки: раскладка по полосам профиля, подбор групп под рейтинг и тип, заполнение
/// только дыр и пересборка с нуля. Главное свойство — предпросмотр и создание считают одно и то же.
/// </summary>
public class GenerateGridTests
{
    private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);

    /// <summary>Шоу с готовыми сериями: без длительности группа считалась бы пустой.</summary>
    private static (Show Show, List<MediaAsset> Assets) Playable(
        string name,
        ShowKind kind,
        int episodes,
        int minutes,
        ShowAudience? audience = null
    )
    {
        var show = Show.Create(name, kind);
        if (audience is { } value)
            show.SetAudience(value);

        var assets = new List<MediaAsset>();
        for (var i = 0; i < episodes; i++)
        {
            var asset = MediaAsset.Register($"{name}-{i}.mkv", ".mkv", MediaSource.Upload);
            asset.MarkProcessing();
            asset.MarkReady(
                new MediaReadyInfo(
                    TimeSpan.FromMinutes(minutes),
                    6,
                    minutes * 10,
                    1920,
                    1080,
                    "h264",
                    "aac",
                    $"assets/{name}-{i}"
                )
            );
            assets.Add(asset);
            show.AddEpisode(asset.Id);
        }

        return (show, assets);
    }

    /// <summary>Несколько полнометражек: у одиночного шоу серия ровно одна, поэтому кино — это N шоу.</summary>
    private static (List<Show> Shows, List<MediaAsset> Assets) Films(
        int count,
        int minutes,
        ShowAudience? audience = null
    )
    {
        var shows = new List<Show>();
        var assets = new List<MediaAsset>();

        for (var i = 0; i < count; i++)
        {
            var (show, made) = Playable($"Фильм {i}", ShowKind.Single, 1, minutes, audience);
            shows.Add(show);
            assets.AddRange(made);
        }

        return (shows, assets);
    }

    private static Group GroupOf(string name, params Guid[] showIds)
    {
        var group = Group.Create(name);
        foreach (var id in showIds)
            group.AddElement(GroupElementKind.Show, id);
        return group;
    }

    /// <summary>Канал с пустой сеткой: один фоновый слой без слотов.</summary>
    private static async Task<(Guid ChannelId, ScheduleTemplate Template)> SeedChannelAsync(
        TestDb fixture,
        IEnumerable<Show> shows,
        IEnumerable<MediaAsset> assets,
        IEnumerable<Group> groups
    )
    {
        var channel = Channel.Create("Первый", "first", T0);
        var template = ScheduleTemplate.Create(channel.Id, "Сетка");
        channel.SetTemplate(template.Id);

        await using var seed = fixture.New();
        seed.MediaAssets.AddRange(assets);
        seed.Shows.AddRange(shows);
        seed.Groups.AddRange(groups);
        seed.Channels.Add(channel);
        seed.ScheduleTemplates.Add(template);
        await seed.SaveChangesAsync(CancellationToken.None);

        return (channel.Id, template);
    }

    private static GridPlanner Planner(Common.Interfaces.IAppDbContext db) => new(db, Catalog(db));

    private static GroupCatalog Catalog(Common.Interfaces.IAppDbContext db) =>
        new(db, GroupServices.Dynamic(db), new GroupElementResolver(db));

    private static async Task<GridPlan> PlanAsync(
        TestDb fixture,
        Guid channelId,
        GridProfileKind profile = GridProfileKind.Mixed,
        GridGenerationMode mode = GridGenerationMode.FillGaps
    )
    {
        await using var db = fixture.New();
        var result = await Planner(db).BuildAsync(channelId, profile, mode, CancellationToken.None);
        Assert.True(result.IsSuccess);
        return result.Value;
    }

    [Fact]
    public async Task EmptyGrid_IsCoveredEntirely_AndCollapsesIntoEveryDaySlots()
    {
        var fixture = new TestDb();
        var series = Playable("Сериал", ShowKind.Series, 40, 25);
        var films = Films(6, 100);
        // Ночному блоку 18+ нужен строгий рейтинг, иначе полосу закрыть будет нечем.
        var adult = Playable("Ночное", ShowKind.Series, 10, 45, ShowAudience.R);
        var (channelId, _) = await SeedChannelAsync(
            fixture,
            [series.Show, adult.Show, .. films.Shows],
            [.. series.Assets, .. adult.Assets, .. films.Assets],
            [
                GroupOf("Сериалы", series.Show.Id),
                GroupOf("Ночное", adult.Show.Id),
                GroupOf("Фильмы", [.. films.Shows.Select(s => s.Id)]),
            ]
        );

        var plan = await PlanAsync(fixture, channelId);

        // Пустая сетка свободна всю неделю, и профиль обязан закрыть её целиком.
        Assert.Equal(7 * GridCoverage.MinutesInDay, plan.FreeMinutes);
        Assert.Equal(plan.FreeMinutes, plan.CoveredMinutes);
        // Полосы без ротации свободны во все семь дней — они идут «каждый день», а не семью копиями.
        Assert.All(
            plan.Slots.Where(s => s.Start < new TimeOnly(14, 0)),
            slot => Assert.Null(slot.Weekday)
        );
        // Дневной блок и прайм чередуются по дням недели: иначе неделя выглядит одним повторяющимся
        // днём. У них дни проставлены явно.
        Assert.All(
            plan.Slots.Where(s => s.Start >= new TimeOnly(14, 0) && s.Start < new TimeOnly(18, 0)),
            slot => Assert.NotNull(slot.Weekday)
        );
        Assert.All(
            plan.Slots.Where(s => s.Start == new TimeOnly(20, 0)),
            slot => Assert.NotNull(slot.Weekday)
        );
        Assert.Equal(
            5,
            plan.Slots.Count(s => s.Start == new TimeOnly(20, 0) && s.Weekday is >= 1 and <= 5)
        );
    }

    [Fact]
    public async Task NeighbouringSlots_DoNotShareTheSameGroup()
    {
        // Две группы и целые сутки полос: раскладка обязана их чередовать, а не ставить одну
        // и ту же встык — два блока подряд из одной группы читаются как один длинный.
        var fixture = new TestDb();
        var first = Playable("Первый сериал", ShowKind.Series, 60, 25);
        var second = Playable("Второй сериал", ShowKind.Series, 60, 25);
        var (channelId, _) = await SeedChannelAsync(
            fixture,
            [first.Show, second.Show],
            [.. first.Assets, .. second.Assets],
            [GroupOf("Первые", first.Show.Id), GroupOf("Вторые", second.Show.Id)]
        );

        var plan = await PlanAsync(fixture, channelId);

        var byDay = plan
            .Slots.Where(s => s.GroupId is not null)
            .GroupBy(s => s.Weekday)
            .Select(g => g.OrderBy(s => s.Start).ToList());

        foreach (var day in byDay)
        {
            for (var i = 1; i < day.Count; i++)
                Assert.NotEqual(day[i - 1].GroupId, day[i].GroupId);
        }
    }

    [Fact]
    public async Task PrimeGetsFeature_AndMorningGetsSeries()
    {
        var fixture = new TestDb();
        var series = Playable("Сериал", ShowKind.Series, 40, 25);
        var films = Films(8, 100);
        var (channelId, _) = await SeedChannelAsync(
            fixture,
            [series.Show, .. films.Shows],
            [.. series.Assets, .. films.Assets],
            [
                GroupOf("Сериалы", series.Show.Id),
                GroupOf("Фильмы", [.. films.Shows.Select(s => s.Id)]),
            ]
        );

        var plan = await PlanAsync(fixture, channelId);

        var prime = plan.Slots.First(s => s.Start == new TimeOnly(20, 0));
        Assert.Equal("Фильмы", prime.GroupName);
        // Кино ставится по одной единице и с якорем: объявленное время начала — обещание зрителю.
        Assert.Equal(SlotBlockMode.Count, prime.BlockMode);
        Assert.Equal(1, prime.BlockValue);
        Assert.True(prime.IsAnchor);

        var morning = plan.Slots.First(s => s.Daypart == Daypart.Morning);
        Assert.Equal("Сериалы", morning.GroupName);
    }

    [Fact]
    public async Task InterstitialGroup_GoesNeitherIntoSlots_NorIntoFallback()
    {
        // Группа рекламы — материал для стыков. По размеру она бьёт любую библиотеку, поэтому
        // раньше выигрывала каждую полосу без предпочтения по типу и становилась аварийной:
        // в эфир уходила полоса роликов, подписанная как программа.
        var fixture = new TestDb();
        var series = Playable("Сериал", ShowKind.Series, 40, 25);
        var films = Films(8, 100);
        var ads = Enumerable
            .Range(0, 60)
            .Select(i => Playable($"Ролик {i}", ShowKind.Interstitial, 1, 1))
            .ToList();

        var adsGroup = GroupOf("Реклама", [.. ads.Select(a => a.Show.Id)]);
        var (channelId, _) = await SeedChannelAsync(
            fixture,
            [series.Show, .. films.Shows, .. ads.Select(a => a.Show)],
            [.. series.Assets, .. films.Assets, .. ads.SelectMany(a => a.Assets)],
            [
                GroupOf("Сериалы", series.Show.Id),
                GroupOf("Фильмы", [.. films.Shows.Select(s => s.Id)]),
                adsGroup,
            ]
        );

        var plan = await PlanAsync(
            fixture,
            channelId,
            GridProfileKind.Mixed,
            GridGenerationMode.Rebuild
        );

        Assert.DoesNotContain(plan.Slots, s => s.GroupId == adsGroup.Id);
        Assert.NotNull(plan.FallbackGroupId);
        Assert.NotEqual(adsGroup.Id, plan.FallbackGroupId);
    }

    [Fact]
    public async Task ChildrenBand_SkipsAdultGroup()
    {
        var fixture = new TestDb();
        var adult = Playable("Взрослое", ShowKind.Series, 20, 25, ShowAudience.R);
        var kids = Playable("Детское", ShowKind.Series, 20, 25, ShowAudience.G);
        var (channelId, _) = await SeedChannelAsync(
            fixture,
            [adult.Show, kids.Show],
            [.. adult.Assets, .. kids.Assets],
            [GroupOf("Взрослое", adult.Show.Id), GroupOf("Детское", kids.Show.Id)]
        );

        var plan = await PlanAsync(fixture, channelId, GridProfileKind.Kids);

        // Детский профиль не пускает строгое никуда, а ночь у него — конец вещания.
        Assert.DoesNotContain(plan.Slots, s => s.GroupName == "Взрослое");
        Assert.Contains(plan.Slots, s => s.SlotKind == SlotKind.SignOff);
    }

    [Fact]
    public async Task AdultNightBand_NeedsRatedContent()
    {
        var fixture = new TestDb();
        // Рейтинга нет ни у чего: ночной блок 18+ ставить не из чего, и это должно быть сказано.
        var series = Playable("Сериал", ShowKind.Series, 40, 25);
        var (channelId, _) = await SeedChannelAsync(
            fixture,
            [series.Show],
            series.Assets,
            [GroupOf("Сериалы", series.Show.Id)]
        );

        var plan = await PlanAsync(fixture, channelId);

        // Взрослого контента нет — ночной блок закрывается повтором вечернего, а не остаётся дырой.
        Assert.Contains(plan.Notes, note => note.Text.Contains("Ночной блок 18+"));
        Assert.Contains(
            plan.Slots,
            s => s.SlotKind == SlotKind.Repeat && s.Title == "Ночной блок 18+"
        );
        Assert.Equal(plan.FreeMinutes, plan.CoveredMinutes);
    }

    [Fact]
    public async Task FillGaps_LeavesExistingSlotsAlone()
    {
        var fixture = new TestDb();
        var series = Playable("Сериал", ShowKind.Series, 40, 25);
        var group = GroupOf("Сериалы", series.Show.Id);
        var (channelId, template) = await SeedChannelAsync(
            fixture,
            [series.Show],
            series.Assets,
            [group]
        );

        // Занятая полоса: вторник с 20:00 на два часа.
        await using (var db = fixture.New())
        {
            var tracked = await db
                .ScheduleTemplates.Include(t => t.Layers)
                    .ThenInclude(l => l.Slots)
                .FirstAsync(t => t.Id == template.Id, CancellationToken.None);
            var slot = tracked.Layers[0].AddSlot("Ручной", new TimeOnly(20, 0), 120, weekday: 2);
            slot.UpdateContent(
                new SlotContent(
                    "Ручной",
                    SlotKind.Content,
                    group.Id,
                    null,
                    null,
                    SlotBlockMode.FillSlot,
                    1,
                    OverflowPolicy.ContinueNext
                )
            );
            await db.SaveChangesAsync(CancellationToken.None);
        }

        var plan = await PlanAsync(fixture, channelId);

        Assert.Equal(0, plan.SlotsToRemove);
        Assert.Equal(7 * GridCoverage.MinutesInDay - 120, plan.FreeMinutes);
        // Занятое время не застраивается: во вторник в 20:00 нового слота нет.
        Assert.DoesNotContain(
            plan.Slots,
            s => s.Weekday == 2 && s.Start >= new TimeOnly(20, 0) && s.Start < new TimeOnly(22, 0)
        );
        // Остальные дни в это время свободны, поэтому слот «каждый день» туда уже не схлопнется.
        Assert.Contains(plan.Slots, s => s.Weekday is not null);
    }

    [Fact]
    public async Task Rebuild_CountsExistingSlotsAsRemoved_AndSeparatesWeekend()
    {
        var fixture = new TestDb();
        var series = Playable("Сериал", ShowKind.Series, 40, 25);
        var films = Films(8, 100);
        var group = GroupOf("Сериалы", series.Show.Id);
        var (channelId, template) = await SeedChannelAsync(
            fixture,
            [series.Show, .. films.Shows],
            [.. series.Assets, .. films.Assets],
            [group, GroupOf("Фильмы", [.. films.Shows.Select(s => s.Id)])]
        );

        await using (var db = fixture.New())
        {
            var tracked = await db
                .ScheduleTemplates.Include(t => t.Layers)
                    .ThenInclude(l => l.Slots)
                .FirstAsync(t => t.Id == template.Id, CancellationToken.None);
            tracked.Layers[0].AddSlot("Ручной", new TimeOnly(20, 0), 120, weekday: 2);
            await db.SaveChangesAsync(CancellationToken.None);
        }

        var plan = await PlanAsync(
            fixture,
            channelId,
            GridProfileKind.Mixed,
            GridGenerationMode.Rebuild
        );

        Assert.Equal(1, plan.SlotsToRemove);
        Assert.Contains(plan.Slots, s => s.Layer == GridPlanLayer.Weekend && s.Weekday == 6);
        Assert.Contains(plan.Slots, s => s.Layer == GridPlanLayer.Main && s.Weekday is null);
    }

    [Fact]
    public async Task Rebuild_BuildsSeasonLayer_WithAnnualApplicability()
    {
        var fixture = new TestDb();
        var series = Playable("Сериал", ShowKind.Series, 40, 25);
        var films = Films(8, 100);
        var (channelId, _) = await SeedChannelAsync(
            fixture,
            [series.Show, .. films.Shows],
            [.. series.Assets, .. films.Assets],
            [
                GroupOf("Сериалы", series.Show.Id),
                GroupOf("Фильмы", [.. films.Shows.Select(s => s.Id)]),
            ]
        );

        await using (var db = fixture.New())
        {
            var result = await new GenerateGridCommandHandler(
                db,
                Planner(db),
                GroupServices.Slots(db)
            ).Handle(
                new GenerateGridCommand(
                    channelId,
                    GridProfileKind.Mixed,
                    GridGenerationMode.Rebuild
                ),
                CancellationToken.None
            );

            Assert.True(result.IsSuccess);
            await db.SaveChangesAsync(CancellationToken.None);
        }

        await using var check = fixture.New();
        var stored = await check
            .ScheduleTemplates.Include(t => t.Layers)
                .ThenInclude(l => l.Slots)
            .FirstAsync(CancellationToken.None);

        var season = GridProfiles.Get(GridProfileKind.Mixed).Season;
        Assert.NotNull(season);
        var layer = Assert.Single(stored.Layers, l => l.Name == season.Name);

        // Праздничный слой обязан перекрывать и будни, и выходные — иначе 1 января пройдёт как
        // обычное воскресенье.
        var otherPriority = stored.Layers.Where(l => l.Id != layer.Id).Max(l => l.Priority);
        Assert.True(layer.Priority > otherPriority);
        Assert.NotEmpty(layer.Slots);

        // Слой действует по ежегодному периоду: заводить его заново каждый декабрь не нужно.
        var applicability = LayerApplicability.FromJson(layer.ApplicabilityJson);
        Assert.NotNull(applicability);
        Assert.Equal([season.Range], applicability.AnnualRanges);
        Assert.True(applicability.Covers(new DateOnly(2027, 1, 1)));
        Assert.False(applicability.Covers(new DateOnly(2027, 3, 1)));
    }

    [Fact]
    public async Task Season_DoesNotSpendTheWeeksBudget()
    {
        // Праздничная неделя идёт вместо обычной, а не вдобавок к ней: её слоты не должны попадать
        // в подсчёт закрытого времени, иначе генератор решит, что неделя перекрыта с запасом.
        var fixture = new TestDb();
        var series = Playable("Сериал", ShowKind.Series, 40, 25);
        var films = Films(8, 100);
        var (channelId, _) = await SeedChannelAsync(
            fixture,
            [series.Show, .. films.Shows],
            [.. series.Assets, .. films.Assets],
            [
                GroupOf("Сериалы", series.Show.Id),
                GroupOf("Фильмы", [.. films.Shows.Select(s => s.Id)]),
            ]
        );

        var plan = await PlanAsync(
            fixture,
            channelId,
            GridProfileKind.Mixed,
            GridGenerationMode.Rebuild
        );

        Assert.Contains(plan.Slots, s => s.Layer == GridPlanLayer.Season);
        Assert.All(
            plan.Slots.Where(s => s.Layer == GridPlanLayer.Season),
            s => Assert.Null(s.Weekday)
        );
        Assert.True(plan.CoveredMinutes <= plan.FreeMinutes);
    }

    [Fact]
    public void Sitcoms_AirInPairs()
    {
        // Ситком — это пара серий и рекламный шов между парами. Три-четыре подряд читаются как
        // марафон, а марафон у ситкомового канала бывает по выходным, а не каждый будний день.
        var sitcom = GridProfiles.Get(GridProfileKind.Sitcom);
        var blocks = sitcom
            .Weekdays.Where(b => b.PreferKind == ShowKind.Series && b.UnitsPerBlock > 0)
            .ToList();

        Assert.NotEmpty(blocks);
        Assert.All(blocks, b => Assert.Equal(2, b.UnitsPerBlock));
    }

    [Fact]
    public async Task SlotLength_FollowsContent_RoundedToTheGrid()
    {
        // Серии по 25 минут: двухчасовая полоса должна разложиться на блоки под серии, а не
        // отдать десятки минут фону. Длина при этом округляется вверх до четверти часа —
        // программа передач состоит из круглых времён, а разницу занимает рекламный шов.
        var fixture = new TestDb();
        var series = Playable("Сериал", ShowKind.Series, 60, 25);
        var (channelId, _) = await SeedChannelAsync(
            fixture,
            [series.Show],
            series.Assets,
            [GroupOf("Сериалы", series.Show.Id)]
        );

        var plan = await PlanAsync(fixture, channelId);

        var morning = plan.Slots.Where(s => s.Daypart == Daypart.Morning && s.GroupId is not null);
        Assert.All(
            morning,
            slot =>
            {
                var content = slot.BlockValue * 25;
                // Слот не короче своего контента — иначе блок перелезет через объявленное время.
                Assert.True(slot.DurationMinutes >= content);
                // И не длиннее его больше, чем на шаг решётки: остаток — это шов, а не пустота.
                Assert.True(slot.DurationMinutes - content < 15);
                Assert.Equal(0, slot.DurationMinutes % 15);
                Assert.Equal(SlotBlockMode.Count, slot.BlockMode);
            }
        );
    }

    [Fact]
    public async Task ExhaustedGroup_IsReplacedByRepeatOfPrime()
    {
        // Контента на неделю заведомо мало: сетка должна закрыть день повторами, а не крутить
        // одно и то же по третьему кругу молча.
        var fixture = new TestDb();
        var tiny = Playable("Мало", ShowKind.Series, 3, 25);
        var (channelId, _) = await SeedChannelAsync(
            fixture,
            [tiny.Show],
            tiny.Assets,
            [GroupOf("Мало", tiny.Show.Id)]
        );

        var plan = await PlanAsync(fixture, channelId);

        Assert.Contains(plan.Slots, s => s.SlotKind == SlotKind.Repeat);
        Assert.Contains(plan.Notes, n => n.Text.Contains("повтор вечернего блока"));
        // Повтор ссылается на вечерний блок прошлых суток — с начала прайма профиля.
        var repeat = plan.Slots.First(s => s.RepeatSource is not null);
        Assert.Equal(1, repeat.RepeatSource!.DaysAgo);
        Assert.Equal(new TimeOnly(18, 0), repeat.RepeatSource.Time);
    }

    [Fact]
    public async Task PrimeRotatesAcrossWeekdays()
    {
        var fixture = new TestDb();
        var first = Films(6, 100);
        var second = Playable("Сериал", ShowKind.Series, 40, 25);
        var (channelId, _) = await SeedChannelAsync(
            fixture,
            [second.Show, .. first.Shows],
            [.. second.Assets, .. first.Assets],
            [
                GroupOf("Кино А", first.Shows[0].Id, first.Shows[1].Id, first.Shows[2].Id),
                GroupOf("Кино Б", first.Shows[3].Id, first.Shows[4].Id, first.Shows[5].Id),
                GroupOf("Сериалы", second.Show.Id),
            ]
        );

        var plan = await PlanAsync(
            fixture,
            channelId,
            GridProfileKind.Mixed,
            GridGenerationMode.Rebuild
        );

        var prime = plan
            .Slots.Where(s => s.Start == new TimeOnly(20, 0) && s.Weekday is >= 1 and <= 5)
            .ToList();

        Assert.Equal(5, prime.Count);
        // Всю неделю один и тот же фильм в прайме — это не сетка, а один повторяющийся день.
        Assert.True(prime.Select(s => s.GroupName).Distinct().Count() > 1);
    }

    [Fact]
    public async Task WeeklyShortage_IsReportedWithNumbers()
    {
        var fixture = new TestDb();
        var series = Playable("Сериал", ShowKind.Series, 4, 25);
        var (channelId, _) = await SeedChannelAsync(
            fixture,
            [series.Show],
            series.Assets,
            [GroupOf("Сериалы", series.Show.Id)]
        );

        var plan = await PlanAsync(fixture, channelId);

        // Проверки считаются по тому же бюджету, по которому раскладывалось, — до нажатия кнопки.
        Assert.Contains(plan.Notes, n => n.Text.Contains("выходах в неделю"));
    }

    [Fact]
    public async Task Fails_WhenNothingToPlanFrom()
    {
        var fixture = new TestDb();
        // Группа есть, но её шоу без готового медиа — эфира из неё не получится.
        var show = Show.Create("Пусто", ShowKind.Series);
        var (channelId, _) = await SeedChannelAsync(
            fixture,
            [show],
            [],
            [GroupOf("Пустая", show.Id)]
        );

        await using var db = fixture.New();
        var result = await Planner(db)
            .BuildAsync(
                channelId,
                GridProfileKind.Mixed,
                GridGenerationMode.FillGaps,
                CancellationToken.None
            );

        Assert.False(result.IsSuccess);
        Assert.Equal(TemplateErrors.NoGroupsToGenerate, result.Error);
    }

    [Fact]
    public async Task Generate_WritesSlots_AndMarksTemplateChanged()
    {
        var fixture = new TestDb();
        var series = Playable("Сериал", ShowKind.Series, 40, 25);
        var films = Films(8, 100);
        var (channelId, _) = await SeedChannelAsync(
            fixture,
            [series.Show, .. films.Shows],
            [.. series.Assets, .. films.Assets],
            [
                GroupOf("Сериалы", series.Show.Id),
                GroupOf("Фильмы", [.. films.Shows.Select(s => s.Id)]),
            ]
        );

        int planned;
        await using (var db = fixture.New())
        {
            var result = await new GenerateGridCommandHandler(
                db,
                Planner(db),
                GroupServices.Slots(db)
            ).Handle(
                new GenerateGridCommand(
                    channelId,
                    GridProfileKind.Mixed,
                    GridGenerationMode.FillGaps
                ),
                CancellationToken.None
            );

            Assert.True(result.IsSuccess);
            planned = result.Value.Created;
            Assert.Equal(0, result.Value.Removed);
            await db.SaveChangesAsync(CancellationToken.None);
        }

        await using var check = fixture.New();
        var stored = await check
            .ScheduleTemplates.Include(t => t.Layers)
                .ThenInclude(l => l.Slots)
            .FirstAsync(CancellationToken.None);

        Assert.Equal(planned, stored.Layers.Sum(l => l.Slots.Count));
        Assert.Contains(stored.Layers, l => l.Name == GridPlanner.MainLayerName);
        // Правка правил эфир не двигает — она только помечает шаблон изменённым.
        Assert.True(stored.HasPendingChanges);
        // Аварийная группа проставляется, если своей не было: иначе паузы останутся пустыми.
        Assert.NotNull(stored.FallbackGroupId);
    }

    [Fact]
    public async Task Generate_Twice_ReusesLayer_AndAddsNothingNew()
    {
        var fixture = new TestDb();
        var series = Playable("Сериал", ShowKind.Series, 40, 25);
        var films = Films(8, 100);
        var (channelId, _) = await SeedChannelAsync(
            fixture,
            [series.Show, .. films.Shows],
            [.. series.Assets, .. films.Assets],
            [
                GroupOf("Сериалы", series.Show.Id),
                GroupOf("Фильмы", [.. films.Shows.Select(s => s.Id)]),
            ]
        );

        for (var run = 0; run < 2; run++)
        {
            await using var db = fixture.New();
            var result = await new GenerateGridCommandHandler(
                db,
                Planner(db),
                GroupServices.Slots(db)
            ).Handle(
                new GenerateGridCommand(
                    channelId,
                    GridProfileKind.Mixed,
                    GridGenerationMode.FillGaps
                ),
                CancellationToken.None
            );
            Assert.True(result.IsSuccess);
            // Второй прогон видит сетку закрытой и не создаёт ничего: дыр больше нет.
            Assert.True(run == 0 ? result.Value.Created > 0 : result.Value.Created == 0);
            await db.SaveChangesAsync(CancellationToken.None);
        }

        await using var check = fixture.New();
        var layers = await check.ScheduleTemplates.Include(t => t.Layers).FirstAsync();
        Assert.Single(layers.Layers, l => l.Name == GridPlanner.MainLayerName);
    }
}
