using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Application.Programming.Groups;
using TeleWave.Domain.Library;
using TeleWave.Domain.Programming;

namespace TeleWave.Application.Programming.Templates.Generate;

/// <summary>
/// Раскладывает свободное время канала по полосам выбранного профиля и подбирает под каждую полосу
/// группу. Ничего не пишет: результат — план, который показывают в предпросмотре и по которому
/// потом создают слоты. Один и тот же расчёт для обеих операций — иначе показанное и созданное
/// разошлись бы.
///
/// Что делает раскладку осмысленной, а не просто заполненной:
/// длина слота считается из длительности единицы группы (иначе трёхчасовой прайм с одним фильмом
/// на 100 минут отдаёт 80 минут фону); ёмкость групп считается на неделю, а не на сутки (слот
/// «каждый день» выходит семь раз); исчерпанный контент закрывается повтором вечернего блока, как
/// на настоящем ТВ; жанр не занимает подряд две полосы. Подбор при этом остаётся объяснимым —
/// жёсткий отсев по рейтингу, дальше баллы.
/// </summary>
public sealed class GridPlanner(IAppDbContext dbContext, GroupCatalog groups)
{
    public const string MainLayerName = "Основная сетка";
    public const string WeekendLayerName = "Выходные";

    /// <summary>Слот короче этого смысла не имеет — остаток отдаётся соседнему слоту.</summary>
    private const int MinSlotMinutes = 15;

    /// <summary>Длины слотов округляются до пяти минут: сетка должна читаться, а не считаться.</summary>
    /// <summary>
    /// Шаг решётки времени. Программа передач состоит из круглых времён — 19:45, 20:00, 20:30, —
    /// и именно это подсознательно отличает телеканал от выгрузки плейлиста. Длина слота округляется
    /// вверх до шага, а разницу между контентом и отметкой съедает рекламный шов.
    /// </summary>
    private const int SlotStepMinutes = 15;

    /// <summary>
    /// Шаг округления средней длины серии. Мельче решётки времени намеренно: это оценка контента,
    /// а не отметка в программе, и округлив 25-минутную серию до получаса, мы отдали бы шву
    /// пять лишних минут на каждой.
    /// </summary>
    private const int UnitStepMinutes = 5;

    /// <summary>Единица длиннее часа — это кино; короче — серия. Порог грубый и намеренно один.</summary>
    private const int FeatureLengthMinutes = 60;

    /// <summary>Дни, по которым идёт ротация в основном слое. Выходные живут своим слоем.</summary>
    private static readonly int[] RotationDays = [1, 2, 3, 4, 5];

    public async Task<Result<GridPlan>> BuildAsync(
        Guid channelId,
        GridProfileKind profileKind,
        GridGenerationMode mode,
        CancellationToken cancellationToken
    )
    {
        var channel = await dbContext
            .Channels.AsNoTracking()
            .FirstOrDefaultAsync(c => c.Id == channelId, cancellationToken);
        if (channel is null)
            return Result.Failure<GridPlan>(ChannelErrors.NotFound);

        var template = await dbContext
            .ScheduleTemplates.AsNoTracking()
            .Include(t => t.Layers)
                .ThenInclude(l => l.Slots)
            .AsSplitQuery()
            .FirstOrDefaultAsync(t => t.ChannelId == channelId, cancellationToken);
        if (template is null)
            return Result.Failure<GridPlan>(ChannelErrors.TemplateNotFound);

        var candidates = await groups.LoadAsync(cancellationToken);
        if (candidates.Count == 0)
            return Result.Failure<GridPlan>(TemplateErrors.NoGroupsToGenerate);

        var profile = GridProfiles.Get(profileKind);
        var run = new PlanRun(
            profile,
            candidates,
            channel.DayStartTime,
            PlanningRules.FromJson(template.RulesJson)
        );

        // Занятым считается время слотов всех слоёв, включая выключенные: место, которое админ
        // отвёл под слот и временно погасил, — не дыра, и застраивать его генератору нечего.
        var existing = template.Layers.SelectMany(l => l.Slots).ToList();

        var slots =
            mode == GridGenerationMode.Rebuild ? BuildWeek(run) : FillExistingGaps(run, existing);

        var freeMinutes =
            mode == GridGenerationMode.Rebuild
                ? 7 * GridCoverage.MinutesInDay
                : GridCoverage
                    .Week.SelectMany(day => GridCoverage.Gaps(existing, day, run.DayStart))
                    .Sum(gap => gap.To - gap.From);

        // Аварийная группа отдаётся отдельным полем, а не замечанием: замечания — про то, что пошло
        // не так, а это штатное действие, и в предпросмотре у него своя строка. Ролики сюда не идут
        // по той же причине, что и в слот: дыру они закрывают рекламой, подписанной как программа,
        // а по размеру группа роликов почти всегда самая большая на канале.
        var fallback = candidates
            .Where(g => g.DominantKind != ShowKind.Interstitial)
            .MaxBy(g => g.UnitCount);
        var needsFallback = template.FallbackGroupId is null && fallback is not null;

        var everyDaySpan = EveryDaySpan(mode, profile);
        var covered = slots.Sum(s => s.DurationMinutes * (s.Weekday is null ? everyDaySpan : 1));

        // Праздничный слой строится отдельным разбором: это не добавка к неделе, а замена ей на
        // свои даты, и общий бюджет группы он тратить не должен — иначе обычная неделя обеднеет
        // ради недели, которая идёт раз в год.
        var season =
            mode == GridGenerationMode.Rebuild && profile.Season is { } holidays
                ? BuildSeason(holidays, run)
                : [];

        var notes = new List<PlanNote>(run.Notes);
        notes.AddRange(run.WeeklyChecks());
        notes.AddRange(AudienceChecks(slots, run));
        if (covered < freeMinutes)
            notes.Add(
                new PlanNote(
                    true,
                    $"После сборки останется незакрытым {Hours(freeMinutes - covered)} недели — "
                        + "это место в эфире закроет фон."
                )
            );

        return Result.Success(
            new GridPlan(
                profile,
                mode,
                [.. slots, .. season],
                notes,
                mode == GridGenerationMode.Rebuild ? existing.Count : 0,
                needsFallback ? fallback!.Id : null,
                needsFallback ? fallback!.Name : null,
                freeMinutes,
                covered
            )
        );
    }

    /// <summary>
    /// Раскладка праздничных суток. День строится один и ложится слотами «каждый день»: слой
    /// действует несколько дат подряд, и расписывать их по дням недели значило бы получить сетку,
    /// которая зависит от того, на какой день выпало 31 декабря.
    /// </summary>
    private static List<PlannedSlot> BuildSeason(GridSeason season, PlanRun source)
    {
        var run = new PlanRun(source.Profile, source.Groups, source.DayStart, source.Rules);
        run.BeginDay();

        return FillDay(
            season.Bands,
            [(0, GridCoverage.MinutesInDay)],
            new SlotTarget(null, GridPlanLayer.Season),
            run
        );
    }

    /// <summary>Сколько дней в неделю действует слот «каждый день»: выходные могут быть перекрыты.</summary>
    private static int EveryDaySpan(GridGenerationMode mode, GridProfile profile) =>
        mode == GridGenerationMode.Rebuild && profile.Weekend.Count > 0 ? 5 : 7;

    private static string Hours(int minutes) =>
        minutes >= 60 ? $"{minutes / 60} ч {minutes % 60:00} мин" : $"{minutes} мин";

    /// <summary>
    /// Неделя с нуля: будни ложатся слотами «каждый день» в основной слой, выходные — отдельным
    /// слоем поверх. Слой выходных перекрывает будний целиком, поэтому расхождение по субботе
    /// и воскресенью получается без дублирования всей недели по дням.
    ///
    /// Полосы с ротацией — исключение: они раскладываются по дням недели поимённо, иначе в прайме
    /// всю неделю стоял бы один и тот же фильм.
    /// </summary>
    private static List<PlannedSlot> BuildWeek(PlanRun run)
    {
        var wholeDay = new List<(int From, int To)> { (0, GridCoverage.MinutesInDay) };
        var plain = run.Profile.Weekdays.Where(b => !b.Rotate).ToList();
        var rotating = run.Profile.Weekdays.Where(b => b.Rotate).ToList();

        run.BeginDay();
        var slots = FillDay(plain, wholeDay, new SlotTarget(null, GridPlanLayer.Main), run);
        run.SnapshotDay();

        foreach (var weekday in RotationDays)
        {
            run.BeginDay(fromBaseline: true);
            var target = new SlotTarget(weekday, GridPlanLayer.Main);
            slots.AddRange(FillDay(rotating, wholeDay, target, run));
        }

        if (run.Profile.Weekend.Count == 0)
            return slots;

        foreach (var weekday in new[] { 6, 0 })
        {
            run.BeginDay();
            var target = new SlotTarget(weekday, GridPlanLayer.Weekend);
            slots.AddRange(FillDay(run.Profile.Weekend, wholeDay, target, run));
        }

        return slots;
    }

    /// <summary>
    /// Заполнение дыр: считается по каждому дню отдельно, поэтому слоты получают явный день недели.
    /// Интервалы, свободные во все семь дней, схлопываются в один слот «каждый день» — иначе первая
    /// же генерация на пустом канале выдала бы семь одинаковых столбцов.
    ///
    /// Полосы берутся будние на все семь дней: отдельные выходные строятся слоем поверх буднего,
    /// а слой имеет смысл только когда он покрывает день целиком. Заплатка в дыре этого не даёт,
    /// поэтому выходные — дело режима пересборки.
    /// </summary>
    private static List<PlannedSlot> FillExistingGaps(PlanRun run, IReadOnlyList<Slot> existing)
    {
        var perDay = GridCoverage.Week.ToDictionary(
            day => day,
            day => (IReadOnlyList<(int From, int To)>)GridCoverage.Gaps(existing, day, run.DayStart)
        );

        // Дыры, общие для всей недели, закрываются слотами «каждый день»: так сетка остаётся
        // читаемой. Полосы с ротацией сюда не идут — им нужен свой контент на каждый день.
        run.BeginDay();
        var common = FillDay(
            run.Profile.Weekdays.Where(b => !b.Rotate).ToList(),
            GridCoverage.Intersect(perDay.Values),
            new SlotTarget(null, GridPlanLayer.Main),
            run
        );
        run.SnapshotDay();

        var taken = Spans(common, run.DayStart);
        var slots = new List<PlannedSlot>(common);

        foreach (var weekday in GridCoverage.Week)
        {
            run.BeginDay(fromBaseline: true);
            var rest = GridCoverage.Subtract(perDay[weekday], taken);
            var target = new SlotTarget(weekday, GridPlanLayer.Main);
            slots.AddRange(FillDay(run.Profile.Weekdays, rest, target, run));
        }

        return slots;
    }

    /// <summary>Занятые слотами интервалы суток — по ним считается, что дню ещё осталось закрыть.</summary>
    private static List<(int From, int To)> Spans(
        IEnumerable<PlannedSlot> slots,
        TimeOnly dayStart
    ) =>
        [
            .. slots.Select(s =>
            {
                var from = GridCoverage.OffsetInDay(s.Start, dayStart);
                return (From: from, To: from + s.DurationMinutes);
            }),
        ];

    /// <summary>Раскладывает свободные интервалы одних суток по полосам профиля.</summary>
    private static List<PlannedSlot> FillDay(
        IReadOnlyList<GridBand> bands,
        IReadOnlyList<(int From, int To)> free,
        SlotTarget target,
        PlanRun run
    )
    {
        var slots = new List<PlannedSlot>();

        // Что реально предстоит застроить: полосы профиля, пересечённые со свободным временем.
        var pieces =
            from band in bands
            from piece in BandPieces(band, run.DayStart)
            from gap in free
            let start = Math.Max(piece.From, gap.From)
            let end = Math.Min(piece.To, gap.To)
            where end - start >= MinSlotMinutes
            orderby start
            select (Band: band, From: start, To: end);

        foreach (var piece in pieces)
            slots.AddRange(FillPiece(piece.Band, piece.From, piece.To, target, run));

        return [.. slots.OrderBy(s => GridCoverage.OffsetInDay(s.Start, run.DayStart))];
    }

    /// <summary>Куда ложится слот: день недели и слой. Пара ходит вместе через всю раскладку.</summary>
    private readonly record struct SlotTarget(int? Weekday, GridPlanLayer Layer)
    {
        /// <summary>Сколько раз слот выходит за неделю: без дня недели — каждый день.</summary>
        public int AiringsPerWeek => Weekday is null ? 7 : 1;
    }

    /// <summary>
    /// Полоса в смещениях от начала вещательных суток. Начало суток канала произвольно, поэтому
    /// полоса может перевалить через их конец — тогда она разрезается на два куска.
    /// </summary>
    private static IEnumerable<(int From, int To)> BandPieces(GridBand band, TimeOnly dayStart)
    {
        var from = GridCoverage.OffsetInDay(band.From, dayStart);
        var to = from + band.LengthMinutes;

        if (to <= GridCoverage.MinutesInDay)
        {
            yield return (from, to);
            yield break;
        }

        yield return (from, GridCoverage.MinutesInDay);
        yield return (0, to - GridCoverage.MinutesInDay);
    }

    /// <summary>
    /// Режет кусок полосы на слоты. Длину слота задаёт не профиль, а контент: блок из N единиц
    /// занимает столько, сколько эти единицы идут. Профильный <c>BlockMinutes</c> остаётся целью
    /// для полос, которые меряются временем, а не штуками.
    /// </summary>
    private static IEnumerable<PlannedSlot> FillPiece(
        GridBand band,
        int from,
        int to,
        SlotTarget target,
        PlanRun run
    )
    {
        var cursor = from;

        while (to - cursor >= MinSlotMinutes)
        {
            // Якорь — только на первом слоте прайма: жёсткий старт у каждого слота подряд заставил
            // бы генератор крутить фон перед каждым из них.
            var anchor = cursor == from && band.Daypart == Daypart.Prime;
            var slot = Compose(band, target, cursor, to - cursor, anchor, run);
            if (slot is null)
                yield break;

            yield return slot;
            cursor += slot.DurationMinutes;
        }
    }

    /// <summary>
    /// Чем закрыть точку сетки. Решение здесь, сборка — в <see cref="SlotFactory"/>: «что ставим»
    /// и «как это собрать» читаются по отдельности.
    ///
    /// Порядок предпочтений: свежий контент → повтор вечернего блока → контент по второму кругу →
    /// ничего (место закроет фон).
    /// </summary>
    private static PlannedSlot? Compose(
        GridBand band,
        SlotTarget target,
        int offset,
        int available,
        bool anchor,
        PlanRun run
    )
    {
        var factory = new SlotFactory(band, target, offset, anchor, run);

        if (band.SlotKind == SlotKind.SignOff)
            return factory.SignOff(available);

        if (band.SlotKind == SlotKind.Repeat)
            return factory.Repeat(band.RepeatFrom, available);

        var group = Pick(band, target, run, available);
        if (group is not null && !run.Exhausted(group))
            return factory.Content(group, available);

        // Свежего контента нет — либо подходящей группы не нашлось вовсе, либо ёмкость на неделю
        // исчерпана. И то и другое закрывается повтором, как на настоящем ТВ: крутить по третьему
        // кругу одно и то же хуже — зритель видит то же самое, но без честной подписи «повтор».
        if (CanRepeat(band, run))
        {
            run.Note(RepeatReason(band, group));
            return factory.Repeat(run.PrimeStart!.Value, Math.Min(available, RepeatMinutes(band)));
        }

        run.Note(Unfillable(band, group), isError: group is null);
        return group is null ? null : factory.Content(group, available);
    }

    /// <summary>
    /// Сборка слота в известной точке сетки: полоса, день, слой и смещение уже определены, меняется
    /// только наполнение. Отдельный тип, а не набор замыканий внутри <see cref="Compose"/>: там
    /// принимается решение, здесь — оформляется.
    /// </summary>
    private readonly record struct SlotFactory(
        GridBand Band,
        SlotTarget Target,
        int Offset,
        bool Anchor,
        PlanRun Run
    )
    {
        /// <summary>
        /// Длина слота: желаемая, но не больше свободного места. Остаток короче минимального слота
        /// приклеивается к текущему — огрызок в сетке читается как ошибка, а не как решение. Остаток
        /// побольше достанется следующему слоту, и под него подберут группу подходящей длины.
        /// </summary>
        private static int Fit(int wanted, int available)
        {
            var duration = Math.Min(Round(wanted), available);
            return available - duration < MinSlotMinutes
                ? available
                : Math.Max(duration, MinSlotMinutes);
        }

        /// <summary>
        /// Длина слота и число единиц в нём. Блок из N единиц занимает ровно столько, сколько эти
        /// единицы идут, — округление до пяти минут; хвост короче минимального слота приклеивается
        /// к текущему, чтобы в сетке не появлялось огрызков.
        /// </summary>
        private (int Duration, int Units) Measure(GroupCandidate group, int available)
        {
            var unit = UnitMinutes(group);

            // Полоса, которая меряется временем (марафон, ночь), берёт свой кусок целиком.
            if (Band.UnitsPerBlock <= 0 || unit == 0)
                return (Fit(Band.BlockMinutes > 0 ? Band.BlockMinutes : available, available), 0);

            var duration = Fit(Math.Max(1, Band.UnitsPerBlock) * unit, available);
            // Число единиц — сколько их целиком влезает в слот: полоса задаёт замысел, а сколько
            // поместилось, решает контент. Округление вверх до решётки оставляет хвост под рекламу,
            // и брать за него ещё одну серию нельзя — она перелезет через следующую отметку.
            return (duration, Math.Max(1, duration / unit));
        }

        public PlannedSlot Content(GroupCandidate group, int available)
        {
            var (duration, units) = Measure(group, available);
            Run.Reserve(group, units, duration, Target.AiringsPerWeek);

            return Build(
                group.Name,
                group.Id,
                duration,
                Band.UnitsPerBlock > 0 ? SlotBlockMode.Count : SlotBlockMode.FillSlot,
                Math.Max(1, units),
                new SlotStrategy(
                    Band.Strategy,
                    RestartOnEnd: true,
                    CooldownDays: Band.CooldownDays
                ),
                null
            );
        }

        public PlannedSlot Repeat(TimeOnly from, int minutes) =>
            Build(
                Band.Title,
                null,
                minutes,
                SlotBlockMode.FillSlot,
                1,
                null,
                new RepeatSource(Band.RepeatDaysAgo, from, minutes)
            );

        public PlannedSlot SignOff(int minutes) =>
            Build(Band.Title, null, minutes, SlotBlockMode.FillSlot, 1, null, null);

        private PlannedSlot Build(
            string title,
            Guid? groupId,
            int duration,
            SlotBlockMode blockMode,
            int blockValue,
            SlotStrategy? strategy,
            RepeatSource? repeat
        ) =>
            new(
                Target.Layer,
                Target.Weekday,
                GridCoverage.AtOffset(Offset, Run.DayStart),
                duration,
                title,
                Band.Daypart,
                repeat is null ? Band.SlotKind : SlotKind.Repeat,
                groupId,
                groupId is null ? null : title,
                blockMode,
                blockValue,
                strategy,
                repeat,
                Anchor
            );
    }

    /// <summary>Почему полосу закрыл повтор — текст для предпросмотра.</summary>
    private static string RepeatReason(GridBand band, GroupCandidate? group) =>
        group is null
            ? $"Для полосы «{band.Title}» подходящей группы нет — её закрывает повтор вечернего блока."
            : $"Полосу «{band.Title}» закрывает повтор вечернего блока: свежего контента на неделю "
                + "не хватает.";

    /// <summary>Почему полоса осталась без свежего контента — текст для предпросмотра.</summary>
    private static string Unfillable(GridBand band, GroupCandidate? group) =>
        group is null
            ? $"Полоса «{band.Title}» ({band.From:HH\\:mm}–{band.To:HH\\:mm}): подходящей группы "
                + "нет — интервал останется незакрытым."
            : $"В полосе «{band.Title}» контент пойдёт по второму кругу: свежего на неделю не хватает.";

    /// <summary>Повторять вечерний блок можно везде, кроме самого прайма, и только если он есть.</summary>
    private static bool CanRepeat(GridBand band, PlanRun run) =>
        band.Daypart != Daypart.Prime && run.PrimeStart is not null;

    private static int RepeatMinutes(GridBand band) =>
        band.BlockMinutes > 0 ? band.BlockMinutes : 120;

    /// <summary>
    /// Вверх до шага решётки: слот не должен быть короче своего контента, иначе блок перелезет
    /// через объявленное время. Разницу между контентом и отметкой занимает рекламный шов.
    /// </summary>
    private static int Round(int minutes, int step = SlotStepMinutes) =>
        Math.Max(step, (int)Math.Ceiling(minutes / (double)step) * step);

    /// <summary>Средняя длина единицы группы, округлённая до пяти минут (0 — длительностей нет).</summary>
    private static int UnitMinutes(GroupCandidate group) =>
        group.AverageUnitMinutes <= 0
            ? 0
            : Round((int)Math.Round(group.AverageUnitMinutes), UnitStepMinutes);

    /// <summary>
    /// Группа под полосу. Рейтинг — жёсткий отсев: в детское время строгое не ставится, а ночной
    /// взрослый блок без взрослого содержимого не имеет смысла. Остальное — баллы.
    ///
    /// У полосы с ротацией берётся не лучший кандидат, а следующий по кругу: в прайме понедельника
    /// и вторника должно стоять разное, иначе неделя выглядит одним повторяющимся днём.
    /// </summary>
    private static GroupCandidate? Pick(
        GridBand band,
        SlotTarget target,
        PlanRun run,
        int available
    )
    {
        var ranked = run
            .Groups.Where(g => Allowed(band, g))
            .OrderByDescending(g => Score(band, g, run, available))
            .ThenBy(g => g.Name, StringComparer.CurrentCultureIgnoreCase)
            .ToList();

        if (ranked.Count == 0)
            return null;

        if (band.Rotate && target.Weekday is { } weekday)
        {
            // Ротация — это сдвиг по кругу, а не другой порядок: лучший кандидат остаётся лучшим,
            // просто в понедельник он первый, во вторник второй.
            var day = Math.Max(0, Array.IndexOf(GridCoverage.Week, weekday));
            ranked = [.. ranked.Skip(day % ranked.Count), .. ranked.Take(day % ranked.Count)];
        }

        // Та же группа встык допускается только если больше поставить нечего: балл её уже штрафует,
        // но при двух кандидатах штраф проигрывает разнице в размере группы.
        return ranked.FirstOrDefault(g => !run.FollowsSameGroup(g)) ?? ranked[0];
    }

    private static bool Allowed(GridBand band, GroupCandidate group)
    {
        // Ролики — материал для стыков, а не для эфира (см. 6.7): слот по такой группе выдаёт
        // в эфир полосу рекламы, подписанную как программа. Отсев жёсткий, до баллов: по размеру
        // группа роликов бьёт любую библиотеку.
        if (group.DominantKind == ShowKind.Interstitial)
            return false;

        // Группа без рейтингов проходит любой потолок — так же ведут себя фильтр набора и
        // планировщик: источники проставляют рейтинг далеко не всему.
        if (band.MaxAudience is { } max && group.Strictest is { } strictest && strictest > max)
            return false;

        return band.MinAudience is not { } min
            || (group.Strictest is { } audience && audience >= min);
    }

    private static int Score(GridBand band, GroupCandidate group, PlanRun run, int available)
    {
        var score = Math.Min(group.UnitCount, 60);

        // Единица длиннее оставшегося места — до ближайшего якоря она не влезет, и место закроет
        // фон. Такой кандидат хуже любого, который поместится.
        if (UnitMinutes(group) > available)
            score -= 80;

        if (band.PreferKind is { } kind)
            score += group.DominantKind == kind ? 100 : -40;

        var wantsFeature = band.PreferKind == ShowKind.Single;
        var isFeature = group.AverageUnitMinutes >= FeatureLengthMinutes;
        if (band.PreferKind is not null && wantsFeature == isFeature)
            score += 30;

        // Ёмкость на неделю: группа, уже разобранная под другие слоты, уступает нетронутой.
        if (run.Exhausted(group))
            score -= 70;

        // Два блока одного жанра подряд читаются как один длинный — канал теряет ритм.
        if (run.FollowsSameGenre(group))
            score -= 25;

        // Та же группа встык — уже не ритм, а признак нехватки контента: штраф тяжелее жанрового,
        // но не запрет: когда в библиотеке одна группа, ставить всё равно нечего.
        if (run.FollowsSameGroup(group))
            score -= 90;

        if (run.Rules?.MaxGenreSharePercent is { } maxShare && run.GenreShare(group) >= maxShare)
            score -= 60;

        // Одна и та же группа во всех полосах суток — формально валидная, но бессмысленная сетка.
        return score - run.UsedToday(group.Id) * 50;
    }

    /// <summary>
    /// Конфликты с детским временем: слот стоит в окне, где потолок строже, чем контент группы.
    /// Проверка та же, что делают проверки сетки, но по плану — до того, как слоты созданы.
    /// </summary>
    private static IEnumerable<PlanNote> AudienceChecks(
        IReadOnlyList<PlannedSlot> slots,
        PlanRun run
    )
    {
        if (run.Rules is not { } rules)
            yield break;

        var seen = new HashSet<(Guid, ShowAudience)>();

        foreach (var slot in slots)
        {
            if (slot.GroupId is not { } groupId)
                continue;
            if (run.Groups.FirstOrDefault(g => g.Id == groupId) is not { Strictest: { } audience })
                continue;
            if (rules.AudienceAt(slot.Start) is not { } max || audience <= max)
                continue;
            if (!seen.Add((groupId, max)))
                continue;

            yield return new PlanNote(
                false,
                $"В группе «{slot.Title}» есть контент категории «{audience}», а слот "
                    + $"{slot.Start:HH\\:mm} стоит во времени не строже «{max}»."
            );
        }
    }
}
