using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using TeleWave.Application.Broadcast.Scheduling;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Programming.Templates;
using TeleWave.Application.Streaming;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Broadcast.Scheduling;
using TeleWave.Domain.Media;
using TeleWave.Domain.Programming;
using TeleWave.Domain.Programming.Planning;

namespace TeleWave.Application.Programming.Planning;

/// <summary>Что дала генерация: сколько записей добавлено и с какими предупреждениями.</summary>
public sealed record GenerationReport(
    int Added,
    IReadOnlyList<PlanningWarning> Warnings,
    bool ChannelSkipped = false
);

/// <summary>
/// Оркестратор генерации по сетке: собирает конфигурацию канала, вызывает чистый планировщик,
/// материализует ленту и двигает курсоры слотов.
///
/// Генерация одного канала сериализуется advisory-блокировкой: фоновый тик и ручное применение
/// не должны читать одну точку продолжения и оба дописывать хвост.
/// </summary>
public sealed class GridScheduleGenerator(
    IAppDbContext dbContext,
    GroupExpander expander,
    BumperResolver bumperResolver,
    PostCheckRunner postChecks,
    IRandomSource random,
    IOptions<SchedulerOptions> options,
    IOptions<StreamingOptions> streamingOptions
)
{
    private readonly SchedulerOptions _options = options.Value;
    private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds);

    private static readonly JsonSerializerOptions TraceJsonOptions = new()
    {
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
    };

    /// <summary>
    /// Достраивает горизонт канала, а при <paramref name="rebuildFuture"/> — пересобирает будущий
    /// хвост от текущего момента. Прошлое и идущая сейчас запись не трогаются никогда: зритель
    /// не должен обнаружить, что у него из-под носа вырезали программу.
    /// </summary>
    public async Task<GenerationReport> GenerateAsync(
        Guid channelId,
        DateTimeOffset now,
        bool rebuildFuture,
        CancellationToken cancellationToken
    )
    {
        var channel = await dbContext.Channels.FirstOrDefaultAsync(
            c => c.Id == channelId,
            cancellationToken
        );
        if (channel is null)
            return new GenerationReport(0, [], ChannelSkipped: true);

        if (!channel.IsEnabled || channel.TemplateId is null)
            return await SilentChannelAsync(channelId, now, rebuildFuture, cancellationToken);

        await using var transaction = await dbContext.BeginTransactionAsync(cancellationToken);
        await dbContext.AcquireChannelLockAsync(channelId, cancellationToken);

        var template = await dbContext
            .ScheduleTemplates.Include(t => t.Layers)
                .ThenInclude(l => l.Slots)
            .AsSplitQuery()
            .FirstOrDefaultAsync(t => t.Id == channel.TemplateId, cancellationToken);
        if (template is null)
            return new GenerationReport(0, [], ChannelSkipped: true);

        await CleanupAsync(channelId, now, cancellationToken);

        // Пересборка выбрасывает хвост, который курсоры уже «съели»: без отмотки каждое применение
        // проматывало бы библиотеку на горизонт вперёд, теряя серии, которые так и не вышли.
        // null здесь значит «отматывать нечего»: обычный тик ничего не сносил.
        Dictionary<Guid, AiredPosition>? aired = null;
        if (rebuildFuture)
        {
            await DropFutureAsync(channelId, now, cancellationToken);
            aired = await LoadAiredPositionsAsync(channelId, cancellationToken);
            await ResetUnairedCursorsAsync(template, aired, cancellationToken);
        }

        // Точка продолжения — конец последней сохранённой записи. При пересборке будущее уже удалено,
        // поэтому она укажет на границу неизменяемого прошлого.
        var lastEnd = await dbContext
            .ScheduleEntries.Where(e => e.ChannelId == channelId)
            .MaxAsync(e => (DateTimeOffset?)e.EndsAtUtc, cancellationToken);

        var startUtc = lastEnd is { } end && end > now ? end : now;
        var horizonEnd = now.AddDays(Math.Max(1, _options.HorizonDays));

        if (startUtc >= horizonEnd)
        {
            await dbContext.SaveChangesAsync(cancellationToken);
            await transaction.CommitAsync(cancellationToken);
            return new GenerationReport(0, []);
        }

        var input = await BuildInputAsync(
            channel,
            template,
            startUtc,
            horizonEnd,
            aired,
            cancellationToken
        );
        var result = Domain.Programming.Planning.SchedulePlanner.Plan(input, random);

        // Пост-проверки считаются по готовой ленте и только предупреждают — переигрывать что-либо
        // по их итогу мы намеренно не будем (см. 3.8).
        var postWarnings = await postChecks.RunAsync(
            result.Items,
            PlanningRules.FromJson(template.RulesJson),
            channel.UtcOffsetMinutes,
            channel.DayStartTime,
            cancellationToken
        );

        // Заставки резолвятся после сборки ленты: пара соседей известна только теперь.
        var bumperAssets = await bumperResolver.ResolveAsync(
            channel,
            result.Items,
            cancellationToken
        );

        var added = WriteEntries(channel.Id, result.Items, bumperAssets);

        await SaveCursorsAsync(result.Cursors, cancellationToken);

        // Снимок вместе с отметкой: применённое состояние — единственная корректная точка отката,
        // и снимать его надо ровно тогда, когда по нему собрана лента.
        template.MarkApplied(TemplateSnapshot.Capture(template).ToJson());

        await dbContext.SaveChangesAsync(cancellationToken);
        await transaction.CommitAsync(cancellationToken);
        return new GenerationReport(added, [.. result.Warnings, .. postWarnings]);
    }

    /// <summary>
    /// Сухой прогон: считает, каким получился бы эфир по текущим правилам, но ничего не пишет —
    /// ни ленты, ни курсоров слотов, ни отметки о применении. Заставки остаются резервом известной
    /// длины: рендер долгий и может упасть, поэтому он делается только при реальном применении.
    /// </summary>
    public async Task<PlanningResult?> PreviewAsync(
        Guid channelId,
        DateTimeOffset from,
        int days,
        CancellationToken cancellationToken
    )
    {
        var channel = await dbContext
            .Channels.AsNoTracking()
            .FirstOrDefaultAsync(c => c.Id == channelId, cancellationToken);
        if (channel is null || channel.TemplateId is null)
            return null;

        var template = await dbContext
            .ScheduleTemplates.AsNoTracking()
            .Include(t => t.Layers)
                .ThenInclude(l => l.Slots)
            .AsSplitQuery()
            .FirstOrDefaultAsync(t => t.Id == channel.TemplateId, cancellationToken);
        if (template is null)
            return null;

        var horizonEnd = from.AddDays(Math.Clamp(days, 1, Math.Max(1, _options.HorizonDays)));
        // Предпросмотр ничего не сносит, отматывать нечего: симуляция идёт от текущего состояния.
        var input = await BuildInputAsync(
            channel,
            template,
            from,
            horizonEnd,
            null,
            cancellationToken
        );
        var result = Domain.Programming.Planning.SchedulePlanner.Plan(input, random);

        var postWarnings = await postChecks.RunAsync(
            result.Items,
            PlanningRules.FromJson(template.RulesJson),
            channel.UtcOffsetMinutes,
            channel.DayStartTime,
            cancellationToken
        );

        return result with
        {
            Warnings = [.. result.Warnings, .. postWarnings],
        };
    }

    /// <summary>
    /// Канал выключен или без сетки — вещать нечем. По таймеру это просто пропуск, но по кнопке
    /// «Применить» админ ждёт, что лента придёт в соответствие с настройками: раз эфира нет,
    /// не должно остаться и будущих записей. Иначе они висят вечно — пересобрать их нечем,
    /// а удалить шоу, которое в них стоит, невозможно.
    /// </summary>
    private async Task<GenerationReport> SilentChannelAsync(
        Guid channelId,
        DateTimeOffset now,
        bool rebuildFuture,
        CancellationToken cancellationToken
    )
    {
        if (!rebuildFuture)
            return new GenerationReport(0, [], ChannelSkipped: true);

        await DropFutureAsync(channelId, now, cancellationToken);
        return new GenerationReport(0, []);
    }

    /// <summary>Снимает будущий хвост ленты. Прошлое и идущую запись не трогает никогда.</summary>
    private Task DropFutureAsync(
        Guid channelId,
        DateTimeOffset now,
        CancellationToken cancellationToken
    ) =>
        dbContext
            .ScheduleEntries.Where(e => e.ChannelId == channelId && e.StartsAtUtc >= now)
            .ExecuteDeleteAsync(cancellationToken);

    /// <summary>
    /// Переносит собранную ленту в записи расписания. Заставка без отрендеренного ассета
    /// пропускается: запись без файла стала бы дырой в эфире.
    /// </summary>
    private int WriteEntries(
        Guid channelId,
        IReadOnlyList<PlannedItem> items,
        IReadOnlyDictionary<int, Guid> bumperAssets
    )
    {
        var added = 0;

        for (var index = 0; index < items.Count; index++)
        {
            var item = items[index];
            var assetId = item.MediaAssetId;
            if (
                item.Kind == PlannedItemKind.Bumper
                && !bumperAssets.TryGetValue(index, out assetId)
            )
                continue;

            dbContext.ScheduleEntries.Add(
                ScheduleEntry.FromSlot(
                    channelId,
                    assetId,
                    ToEntryKind(item.Kind),
                    item.StartsAtUtc,
                    item.EndsAtUtc,
                    new ScheduleEntryOrigin(
                        item.ShowId,
                        item.UnitIndex,
                        item.SlotId,
                        item.Trace is null
                            ? null
                            : JsonSerializer.Serialize(item.Trace, TraceJsonOptions),
                        item.CollectionId
                    )
                )
            );
            added++;
        }

        return added;
    }

    private Task CleanupAsync(
        Guid channelId,
        DateTimeOffset now,
        CancellationToken cancellationToken
    )
    {
        var cutoff = now.AddDays(-Math.Max(1, _options.RetentionDays));
        return dbContext
            .ScheduleEntries.Where(e => e.ChannelId == channelId && e.EndsAtUtc < cutoff)
            .ExecuteDeleteAsync(cancellationToken);
    }

    /// <summary>
    /// Забывает позиции слотов, за которыми не стоит ни одного отыгранного выхода. Состояние такого
    /// слота набрано целиком из хвоста, который только что снесли, и хранить его дальше нельзя:
    /// прогон его уже игнорирует, но обычный тик потом поднял бы то же самое из базы.
    /// </summary>
    private async Task ResetUnairedCursorsAsync(
        ScheduleTemplate template,
        IReadOnlyDictionary<Guid, AiredPosition> aired,
        CancellationToken cancellationToken
    )
    {
        var slotIds = template.Layers.SelectMany(l => l.Slots).Select(s => s.Id).ToList();
        if (slotIds.Count == 0)
            return;

        var states = await dbContext
            .SlotStates.Where(s => slotIds.Contains(s.SlotId) && s.CurrentElementId != null)
            .ToListAsync(cancellationToken);

        foreach (var state in states.Where(s => !aired.ContainsKey(s.SlotId)))
            state.Reset();
    }

    private async Task SaveCursorsAsync(
        IReadOnlyList<PlanningCursorUpdate> cursors,
        CancellationToken cancellationToken
    )
    {
        if (cursors.Count == 0)
            return;

        var slotIds = cursors.Select(c => c.SlotId).Distinct().ToList();
        var states = await dbContext
            .SlotStates.Where(s => slotIds.Contains(s.SlotId))
            .ToDictionaryAsync(s => s.SlotId, cancellationToken);

        foreach (var cursor in cursors)
        {
            if (!states.TryGetValue(cursor.SlotId, out var state))
            {
                state = SlotState.Create(cursor.SlotId);
                dbContext.SlotStates.Add(state);
                states[cursor.SlotId] = state;
            }

            if (cursor.ElementKind is { } kind && cursor.ElementId is { } elementId)
                state.MoveTo(kind, elementId, cursor.NextUnitIndex);
            else
                state.Reset();
        }
    }

    /// <param name="aired">Куда слот доиграл в уцелевшей ленте — перебивает снимок состояния при
    /// пересборке; null на обычном тике и в предпросмотре, где сносить нечего.</param>
    private async Task<PlanningInput> BuildInputAsync(
        Channel channel,
        ScheduleTemplate template,
        DateTimeOffset startUtc,
        DateTimeOffset horizonEnd,
        IReadOnlyDictionary<Guid, AiredPosition>? aired,
        CancellationToken cancellationToken
    )
    {
        var grid = EffectiveGridBuilder.Build(
            template,
            channel.UtcOffsetMinutes,
            channel.DayStartTime,
            startUtc,
            horizonEnd
        );
        // Перекрытые слоты фонового слоя разворачиваются так же, как обычные: паузы закрываются
        // их контентом, и им нужны и группа, и стратегия, и курсор.
        var scheduled = grid.Slots.Concat(grid.Background).ToList();

        // Стыки грузим целиком: их немного, а группы врезок надо развернуть тем же проходом,
        // что и группы контента. Стыки общие, поэтому фильтра по каналу нет.
        var junctions = await dbContext
            .JunctionTemplates.AsNoTracking()
            .Include(j => j.Elements)
            .ToDictionaryAsync(j => j.Id, cancellationToken);

        // Блоки заставок нужны только длительностью — текст подставит резолвер после сборки ленты.
        var bumperTemplates = await dbContext
            .BumperTemplates.AsNoTracking()
            .ToDictionaryAsync(t => t.Id, cancellationToken);

        var groupIds = scheduled
            .Select(s => s.Slot.GroupId)
            .Where(id => id is not null)
            .Select(id => id!.Value)
            .Concat(
                junctions
                    .Values.SelectMany(j => j.Elements)
                    .Select(e => e.GroupId)
                    .Where(id => id is not null)
                    .Select(id => id!.Value)
            )
            .Distinct()
            .ToList();

        // Правила канала: детское время резолвится на каждый слот по его времени, а под потолок
        // повторов нужна история показов — её глубина и задаётся окном правила.
        var rules = PlanningRules.FromJson(template.RulesJson);
        var repeatLimit = rules?.MaxRepeatsInWindow is { WindowDays: > 0, Max: > 0 } limit
            ? new RepeatLimit(limit.WindowDays, limit.Max)
            : null;

        var elementsByGroup = await expander.ExpandAsync(
            groupIds,
            channel.Id,
            cancellationToken,
            repeatLimit is null ? null : startUtc.AddDays(-repeatLimit.WindowDays)
        );

        var slotIds = scheduled.Select(s => s.Slot.Id).Distinct().ToList();
        var states = await dbContext
            .SlotStates.AsNoTracking()
            .Where(s => slotIds.Contains(s.SlotId))
            .ToDictionaryAsync(s => s.SlotId, cancellationToken);

        var planned = new Dictionary<ScheduledSlot, PlanningSlot>();
        foreach (var item in scheduled)
        {
            var slot = item.Slot;
            var elements =
                slot.GroupId is { } groupId
                && elementsByGroup.TryGetValue(groupId, out var groupElements)
                    ? groupElements
                    : [];

            var repeatWindow =
                slot.SlotKind == SlotKind.Repeat ? BuildRepeatWindow(channel, slot, item) : null;
            var repeatUnits = repeatWindow is null
                ? null
                : await LoadRepeatUnitsAsync(channel.Id, repeatWindow, startUtc, cancellationToken);

            var strategy = ToPlanningStrategy(SlotStrategy.FromJson(slot.StrategyJson));
            var cursor = CursorFor(slot.Id, elements, aired, states);

            planned.Add(
                item,
                new PlanningSlot(
                    slot.Id,
                    item.StartUtc,
                    slot.TargetDurationMinutes,
                    slot.SlotKind,
                    slot.IsAnchor,
                    slot.MaxDriftMinutes,
                    slot.SnapToMinutes,
                    slot.BlockMode,
                    slot.BlockValue,
                    slot.OverflowPolicy,
                    strategy,
                    elements,
                    cursor,
                    repeatUnits,
                    repeatWindow,
                    BuildJunction(
                        slot.JunctionBetweenId,
                        junctions,
                        elementsByGroup,
                        bumperTemplates,
                        slot.Daypart
                    ),
                    BuildJunction(
                        slot.JunctionAfterId ?? template.DefaultJunctionId,
                        junctions,
                        elementsByGroup,
                        bumperTemplates,
                        slot.Daypart
                    ),
                    rules?.AudienceAt(
                        TimeOnly.FromDateTime(
                            item.StartUtc.ToOffset(
                                TimeSpan.FromMinutes(channel.UtcOffsetMinutes)
                            ).DateTime
                        )
                    ),
                    repeatLimit
                )
            );
        }

        var fallback = await LoadFallbackUnitsAsync(channel, template, cancellationToken);

        // Чем добивается шов до круглой отметки: ролики и анонсы стыка канала по умолчанию.
        var padJunction = BuildJunction(
            template.DefaultJunctionId,
            junctions,
            elementsByGroup,
            bumperTemplates,
            Daypart.Day
        );

        // Выход слота, который в уцелевшей ленте уже состоялся, второй раз не планируется. Иначе
        // пересборка посреди длинного фильма открывала бы его слот заново: кино кончилось в 23:14,
        // окно слота идёт до 23:15 — и планировщик честно ставил вторую полнометражку подряд.
        var airedWindows = await LoadAiredSlotWindowsAsync(
            channel.Id,
            grid.Slots.Count > 0 ? grid.Slots.Min(s => s.StartUtc) : startUtc,
            startUtc,
            cancellationToken
        );

        return new PlanningInput(
            channel.Id,
            startUtc,
            horizonEnd,
            grid.Slots.Where(s => !AlreadyAired(s, airedWindows)).Select(s => planned[s]).ToList(),
            fallback,
            _segmentSeconds,
            channel.UtcOffsetMinutes,
            grid.Background.Select(s => planned[s]).ToList(),
            PadUnits(padJunction),
            padJunction?.MaxTotal ?? PlanningInput.DefaultMaxPad
        );
    }

    /// <summary>
    /// Занятые отрезки ленты по слотам: что каждый слот уже успел записать. Берётся только прошлое
    /// до начала прогона — будущее либо ещё не построено, либо только что снесено пересборкой.
    ///
    /// Записи не фильтруются по виду: слот, чьё время закрыл фон или аварийный запас, свой выход
    /// тоже отработал, и открывать его заново — значит выдать программу дважды.
    /// </summary>
    private async Task<
        ILookup<Guid, (DateTimeOffset From, DateTimeOffset To)>
    > LoadAiredSlotWindowsAsync(
        Guid channelId,
        DateTimeOffset from,
        DateTimeOffset until,
        CancellationToken cancellationToken
    )
    {
        var rows = await dbContext
            .ScheduleEntries.AsNoTracking()
            .Where(e =>
                e.ChannelId == channelId
                && e.SlotId != null
                && e.StartsAtUtc < until
                && e.EndsAtUtc > from
            )
            .Select(e => new
            {
                SlotId = e.SlotId!.Value,
                e.StartsAtUtc,
                e.EndsAtUtc,
            })
            .ToListAsync(cancellationToken);

        return rows.ToLookup(r => r.SlotId, r => (r.StartsAtUtc, r.EndsAtUtc));
    }

    /// <summary>
    /// Состоялся ли этот выход слота. Сравниваются отрезки, а не старты: сетка эластичная, и кино
    /// объявленных 21:00 законно начинается в 20:55 — по старту его окно не нашлось бы.
    ///
    /// Фоновых слотов это не касается: они играют не по своему окну, а в чужих паузах, и «выход»
    /// у них не определён.
    /// </summary>
    private static bool AlreadyAired(
        ScheduledSlot scheduled,
        ILookup<Guid, (DateTimeOffset From, DateTimeOffset To)> airedWindows
    )
    {
        var end = scheduled.StartUtc.AddMinutes(scheduled.Slot.TargetDurationMinutes);
        return airedWindows[scheduled.Slot.Id]
            .Any(window => window.From < end && window.To > scheduled.StartUtc);
    }

    /// <summary>
    /// Ролики и анонсы стыка, распрямлённые в плоский список для добора шва. Заставки сюда
    /// не попадают: их ассет рендерится под конкретную пару соседей, а шов — это ровно то место,
    /// где пары ещё нет.
    /// </summary>
    private static IReadOnlyList<PadUnit> PadUnits(PlanningJunction? junction) =>
        junction is null
            ? []
            : junction
                .Elements.Where(e => e.Kind != JunctionElementKind.Bumper && e.Units.Count > 0)
                .SelectMany(e =>
                    e.Units.Select(unit => new PadUnit(
                        unit,
                        e.Kind == JunctionElementKind.Promo
                            ? PlannedItemKind.Promo
                            : PlannedItemKind.Ad
                    ))
                )
                .ToList();

    /// <summary>Разворачивает шаблон стыка для планировщика, включая резерв под заставки.</summary>
    private PlanningJunction? BuildJunction(
        Guid? junctionId,
        IReadOnlyDictionary<Guid, JunctionTemplate> junctions,
        IReadOnlyDictionary<Guid, IReadOnlyList<PlanningElement>> elementsByGroup,
        IReadOnlyDictionary<Guid, BumperTemplate> bumperTemplates,
        Daypart daypart
    )
    {
        if (junctionId is not { } id || !junctions.TryGetValue(id, out var template))
            return null;

        var elements = new List<PlanningJunctionElement>();
        foreach (var element in template.Elements.OrderBy(e => e.Position))
        {
            var conditions =
                JunctionConditions.FromJson(element.ConditionsJson) ?? new JunctionConditions();

            // Дейпарт — свойство слота, а не момента: отсекаем здесь, чтобы домен не знал про сетку.
            if (!conditions.AllowsDaypart(daypart))
                continue;

            var units = ResolveUnits(element, elementsByGroup, bumperTemplates, out var bumper);
            if (units is null)
                continue;

            elements.Add(
                new PlanningJunctionElement(
                    element.Id,
                    element.Kind,
                    units,
                    element.AmountMode,
                    element.AmountValue,
                    element.IsRequired,
                    conditions.OnlyOnElementChange,
                    conditions.MinMinutesBetween,
                    conditions.Chance,
                    conditions.TimeWindow is { } window
                        ? new PlanningTimeWindow(window.From, window.To)
                        : null,
                    conditions.NearHourMinutes,
                    element.ChoiceKey,
                    element.ChoiceWeight,
                    element.BumperTemplateId,
                    element.BumperVariantId,
                    bumper
                )
            );
        }

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

        var cap = template.MaxTotalSeconds is { } seconds
            ? TimeSpan.FromSeconds(seconds)
            : (TimeSpan?)null;
        return new PlanningJunction(id, elements, cap);
    }

    /// <summary>
    /// Что играет во врезке: единицы группы либо резерв под заставку. null — врезка настроена
    /// не до конца (нет группы или блока), и в эфир ей идти нечем.
    /// </summary>
    private IReadOnlyList<PlanningUnit>? ResolveUnits(
        JunctionElement element,
        IReadOnlyDictionary<Guid, IReadOnlyList<PlanningElement>> elementsByGroup,
        IReadOnlyDictionary<Guid, BumperTemplate> bumperTemplates,
        out TimeSpan bumperDuration
    )
    {
        bumperDuration = TimeSpan.Zero;

        if (element.Kind == JunctionElementKind.Bumper)
        {
            // Длительность задаётся блоком (по звуку) и выровнена на сегмент: планировщик
            // резервирует именно её, ассет подставит резолвер после сборки ленты.
            if (
                element.BumperTemplateId is not { } templateId
                || !bumperTemplates.TryGetValue(templateId, out var bumperTemplate)
            )
                return null;

            bumperDuration = TimeSpan.FromSeconds(
                BumperDuration.Aligned(
                    BumperDuration.TemplateSeconds(bumperTemplate),
                    _segmentSeconds
                )
            );
            return [];
        }

        return
            element.GroupId is { } groupId
            && elementsByGroup.TryGetValue(groupId, out var groupElements)
            ? groupElements.SelectMany(e => e.Units).ToList()
            : null;
    }

    /// <summary>
    /// С какой позиции слот продолжает прогон.
    ///
    /// На обычном тике (<paramref name="aired"/> = null) это снимок состояния из базы. При пересборке
    /// правда — только уцелевшая лента: снимок считает пройденным весь снесённый хвост, и доверять
    /// ему нельзя. Слот, у которого в ленте не осталось ни одного отыгранного выхода, начинает
    /// с начала: иначе повторное «Применить» на канале, который ещё не вышел в эфир, каждый раз
    /// проматывало бы библиотеку на горизонт вперёд — сериал прыгал бы через серии, а франшиза
    /// начиналась бы с третьей части.
    /// </summary>
    private static PlanningCursor? CursorFor(
        Guid slotId,
        IReadOnlyList<PlanningElement> elements,
        IReadOnlyDictionary<Guid, AiredPosition>? aired,
        IReadOnlyDictionary<Guid, SlotState> states
    )
    {
        PlanningCursor? stored = states.TryGetValue(slotId, out var state)
            ? new PlanningCursor(
                state.CurrentElementKind,
                state.CurrentElementId,
                state.NextUnitIndex
            )
            : null;

        if (aired is null)
            return stored;

        if (!aired.TryGetValue(slotId, out var position))
            return null;

        // Отыгранное есть, но той единицы в элементе больше нет (серию удалили, ассет перестал быть
        // готовым). Отматывать некуда — пусть решает сохранённое состояние: слот в эфире уже был,
        // и откат в начало группы был бы виден зрителю.
        return RewoundCursor(position, elements) ?? stored;
    }

    /// <summary>Последняя запись слота в уцелевшей ленте: элемент и единица, которой он доиграл.</summary>
    private sealed record AiredPosition(
        GroupElementKind ElementKind,
        Guid ElementId,
        Guid ShowId,
        int UnitIndex
    );

    /// <summary>
    /// Куда каждый слот доиграл к моменту пересборки. Элемент берётся из трейса: в самой записи его
    /// нет, а по шоу его не восстановить — одно шоу играет и само по себе, и внутри коллекции.
    /// </summary>
    private async Task<Dictionary<Guid, AiredPosition>> LoadAiredPositionsAsync(
        Guid channelId,
        CancellationToken cancellationToken
    )
    {
        var maxima = await dbContext
            .ScheduleEntries.AsNoTracking()
            .Where(e =>
                e.ChannelId == channelId
                && e.SlotId != null
                && e.ShowId != null
                && e.Kind == ScheduleEntryKind.Program
            )
            .GroupBy(e => e.SlotId!.Value)
            .Select(g => new { SlotId = g.Key, Last = g.Max(e => e.StartsAtUtc) })
            .ToListAsync(cancellationToken);

        var result = new Dictionary<Guid, AiredPosition>();
        if (maxima.Count == 0)
            return result;

        // Вторым запросом, а не подзапросом на строку: слотов десятки, и «взять запись по (слот,
        // старт)» на такой выборке дешевле, чем оконная функция по всей ленте канала.
        var starts = maxima.Select(m => m.Last).Distinct().ToList();
        var rows = await dbContext
            .ScheduleEntries.AsNoTracking()
            .Where(e =>
                e.ChannelId == channelId && e.SlotId != null && starts.Contains(e.StartsAtUtc)
            )
            .Select(e => new
            {
                SlotId = e.SlotId!.Value,
                e.StartsAtUtc,
                e.ShowId,
                e.EpisodeIndex,
                e.TraceJson,
            })
            .ToListAsync(cancellationToken);

        var bySlotAndStart = rows.ToLookup(r => (r.SlotId, r.StartsAtUtc));
        foreach (var max in maxima)
        {
            var row = bySlotAndStart[(max.SlotId, max.Last)].FirstOrDefault();
            if (row?.ShowId is not { } showId || row.EpisodeIndex is not { } unitIndex)
                continue;

            var trace = ParseTrace(row.TraceJson);
            if (trace?.ElementKind is not { } kind || trace.ElementId is not { } elementId)
                continue;

            result[max.SlotId] = new AiredPosition(kind, elementId, showId, unitIndex);
        }

        return result;
    }

    /// <summary>
    /// Курсор, отмотанный к отыгранному. Позиция ищется в развёрнутом элементе, а не берётся из
    /// <c>EpisodeIndex</c> напрямую: у коллекции серии нумеруются внутри каждой части, и как индекс
    /// в общей последовательности элемента это число не годится.
    /// </summary>
    private static PlanningCursor? RewoundCursor(
        AiredPosition aired,
        IReadOnlyList<PlanningElement> elements
    )
    {
        var element = elements.FirstOrDefault(e =>
            e.Kind == aired.ElementKind && e.ElementId == aired.ElementId
        );
        if (element is null)
            return null;

        for (var i = 0; i < element.Units.Count; i++)
            if (
                element.Units[i].ShowId == aired.ShowId
                && element.Units[i].UnitIndex == aired.UnitIndex
            )
                return new PlanningCursor(element.Kind, element.ElementId, i + 1);

        // Элемент есть, но той единицы в нём больше нет — серию удалили или ассет перестал быть
        // готовым. Отматывать некуда, пусть решает сохранённое состояние.
        return null;
    }

    private static PlanTrace? ParseTrace(string? json)
    {
        if (string.IsNullOrWhiteSpace(json))
            return null;

        try
        {
            return JsonSerializer.Deserialize<PlanTrace>(json, TraceJsonOptions);
        }
        catch (JsonException)
        {
            return null;
        }
    }

    /// <summary>Отрезок ленты, на который ссылается слот-повтор, в UTC.</summary>
    private static PlanningRepeatWindow? BuildRepeatWindow(
        Channel channel,
        Slot slot,
        ScheduledSlot scheduled
    )
    {
        var source = RepeatSource.FromJson(slot.RepeatSourceJson);
        if (source is null)
            return null;

        var offset = TimeSpan.FromMinutes(channel.UtcOffsetMinutes);
        var sourceDate = scheduled.BroadcastDate.AddDays(-Math.Max(1, source.DaysAgo));
        var from = EffectiveGridBuilder.ToUtc(
            sourceDate,
            source.Time,
            offset,
            channel.DayStartTime
        );

        return new PlanningRepeatWindow(from, from.AddMinutes(Math.Max(1, source.DurationMinutes)));
    }

    /// <summary>
    /// Что играло в точке, на которую ссылается слот-повтор, по уже записанной ленте. Берётся только
    /// прошлое до начала прогона: всё, что позже, планировщик найдёт в собственной ленте, и без этой
    /// границы предпросмотр складывал бы реальный эфир с симулированным.
    ///
    /// Условие — пересечение с окном, а не попадание старта в него: старты эластичные, и фильм,
    /// начавшийся на десять минут раньше объявленного времени, по старту не нашёлся бы.
    /// </summary>
    private async Task<IReadOnlyList<PlanningUnit>> LoadRepeatUnitsAsync(
        Guid channelId,
        PlanningRepeatWindow window,
        DateTimeOffset startUtc,
        CancellationToken cancellationToken
    )
    {
        var from = window.FromUtc;
        var to = window.ToUtc;

        var entries = await dbContext
            .ScheduleEntries.AsNoTracking()
            .Where(e =>
                e.ChannelId == channelId
                && e.Kind == ScheduleEntryKind.Program
                && e.StartsAtUtc < to
                && e.EndsAtUtc > from
                && e.StartsAtUtc < startUtc
            )
            .OrderBy(e => e.StartsAtUtc)
            .Select(e => new
            {
                e.MediaAssetId,
                e.ShowId,
                e.EpisodeIndex,
                Duration = e.EndsAtUtc - e.StartsAtUtc,
            })
            .ToListAsync(cancellationToken);

        return entries
            .Select(e => new PlanningUnit(
                e.MediaAssetId,
                e.Duration,
                e.ShowId ?? Guid.Empty,
                e.EpisodeIndex ?? 0
            ))
            .ToList();
    }

    /// <summary>
    /// Чем закрывать паузы. Сначала аварийная группа шаблона, затем филлер канала — последний
    /// зациклен, поэтому короткая единица закрывает любой остаток лучше длинной.
    /// </summary>
    private async Task<IReadOnlyList<PlanningUnit>> LoadFallbackUnitsAsync(
        Channel channel,
        ScheduleTemplate template,
        CancellationToken cancellationToken
    )
    {
        var units = new List<PlanningUnit>();

        if (template.FallbackGroupId is { } groupId)
        {
            var expanded = await expander.ExpandAsync([groupId], channel.Id, cancellationToken);
            if (expanded.TryGetValue(groupId, out var elements))
                units.AddRange(elements.SelectMany(e => e.Units));
        }

        if (channel.FillerAssetId is { } fillerId)
        {
            var filler = await dbContext
                .MediaAssets.AsNoTracking()
                .Where(a =>
                    a.Id == fillerId && a.Status == MediaAssetStatus.Ready && a.Duration != null
                )
                .Select(a => new { a.Id, a.Duration })
                .FirstOrDefaultAsync(cancellationToken);

            if (filler is not null)
                units.Add(new PlanningUnit(filler.Id, filler.Duration!.Value, Guid.Empty, 0));
        }

        // Короткие вперёд: чем короче единица, тем меньше остаётся незакрытым остаток паузы.
        return units.OrderBy(u => u.Duration).ToList();
    }

    private static PlanningStrategy ToPlanningStrategy(SlotStrategy? strategy)
    {
        if (strategy is null)
            return new PlanningStrategy(SlotStrategyKind.Sequential);

        var kind = strategy.Type switch
        {
            SlotStrategyType.RandomWithCooldown => SlotStrategyKind.RandomWithCooldown,
            SlotStrategyType.Fixed => SlotStrategyKind.Fixed,
            _ => SlotStrategyKind.Sequential,
        };

        return new PlanningStrategy(
            kind,
            strategy.RestartOnEnd,
            strategy.CooldownDays,
            strategy.Fallback == CooldownFallback.IgnoreCooldown,
            strategy.FixedElementId
        );
    }

    private static ScheduleEntryKind ToEntryKind(PlannedItemKind kind) =>
        kind switch
        {
            PlannedItemKind.SignOff => ScheduleEntryKind.SignOff,
            PlannedItemKind.Fallback => ScheduleEntryKind.Fallback,
            PlannedItemKind.Ad or PlannedItemKind.Promo => ScheduleEntryKind.Ad,
            PlannedItemKind.Bumper => ScheduleEntryKind.Bumper,
            _ => ScheduleEntryKind.Program,
        };
}
