using TeleWave.Domain.Broadcast.Scheduling;

namespace TeleWave.Domain.Programming.Planning;

/// <summary>
/// Чистая эфирная математика: разворачивает сетку в непрерывную ленту от <see cref="PlanningInput.StartUtc"/>
/// до горизонта. Без БД, ФС и ffmpeg — полностью юнит-тестируемо.
///
/// Сетка эластичная: времена слотов — цели, а не границы. Контент идёт встык, слот исчерпывается по
/// бюджету, расхождение переносится дальше. Опорные точки держат якоря (жёсткий старт, перед которым
/// не начинают то, что через него перелезет) и мягкое округление (сдвиг старта до круглого времени).
/// </summary>
public static class SchedulePlanner
{
    private const int IterationBackstop = 100_000;

    /// <summary>
    /// Накопители одного прогона: собираемая лента, новые курсоры слотов, предупреждения, история
    /// врезок и шоу последней поставленной единицы. Всё это протаскивалось через сигнатуры десятком
    /// параметров (включая <c>ref</c>), хотя принадлежит прогону целиком, а не отдельному шагу.
    ///
    /// История врезок общая на прогон намеренно: «не чаще раза в полчаса» должно работать и через
    /// границу слота.
    /// </summary>
    private sealed class PlanningRun(PlanningInput input, IRandomSource random)
    {
        public PlanningInput Input { get; } = input;
        public IRandomSource Random { get; } = random;
        public List<PlannedItem> Items { get; } = [];
        public List<PlanningWarning> Warnings { get; } = [];
        public JunctionHistory Junctions { get; } = new();

        /// <summary>Позиция слотов: одна на слот, а не на каждые сутки его выхода.</summary>
        public SlotCursors Cursors { get; } = new();

        /// <summary>Что этот прогон уже поставил — по нему работают остывание и потолок повторов.</summary>
        public RunPlayHistory Plays { get; } = new();

        /// <summary>
        /// Кладёт запись в ленту, попутно отмечая показ. Через этот метод идут все программы: правила
        /// «не чаще раза в N дней» обязаны видеть и фоновые выходы, а они добавляются другим путём.
        /// </summary>
        public void Add(PlannedItem item)
        {
            Items.Add(item);
            if (item.Kind == PlannedItemKind.Program && item.ShowId is { } showId)
                Plays.Record(showId, item.StartsAtUtc);
        }

        private JunctionRun? _junctionRun;

        /// <summary>Накопители, которые нужны раскладке стыка: история, жребий и сама лента.</summary>
        public JunctionRun JunctionRun => _junctionRun ??= new(Junctions, Random, Items);

        /// <summary>Смещение времени канала — врезки со своим окном суток считают его по нему.</summary>
        public TimeSpan ChannelOffset { get; } = TimeSpan.FromMinutes(input.UtcOffsetMinutes);

        /// <summary>Шоу последней поставленной единицы — по нему стык понимает, сменился ли элемент.</summary>
        public Guid? PreviousShowId { get; set; }

        /// <summary>Аварийный запас: кольцевой перебор, общий на прогон.</summary>
        public UnitRotation<PlanningUnit> Fallback { get; } =
            new(input.FallbackUnits, unit => unit.Duration);

        /// <summary>Ролики и анонсы для добора шва — тоже по кругу, чтобы не повторяться.</summary>
        public UnitRotation<PadUnit> Pad { get; } = new(input.PadUnits, pad => pad.Unit.Duration);

        private BackgroundFiller? _background;

        /// <summary>
        /// Фоновый слой: чем закрываются паузы до того, как дело дойдёт до аварийного запаса. Ведёт
        /// те же слоты, что и основной цикл, поэтому получает и общее хранилище позиций, и общую
        /// историю показов — два независимых счётчика одного слота переигрывали бы серии.
        /// </summary>
        public BackgroundFiller Background =>
            _background ??= new(Input.BackgroundSlots, Random, Cursors, Plays);
    }

    public static PlanningResult Plan(PlanningInput input, IRandomSource random)
    {
        var run = new PlanningRun(input, random);

        var slots = input.Slots.OrderBy(s => s.TargetStartUtc).ToList();
        var cursor = input.StartUtc;
        var iterations = 0;

        for (var i = 0; i < slots.Count && cursor < input.HorizonEndUtc; i++)
        {
            if (iterations++ > IterationBackstop)
                break;

            var slot = slots[i];

            // Слот, чьё окно целиком в прошлом относительно курсора, пропускаем: догонять уже нечего,
            // а поставив его сейчас, мы сдвинули бы всё последующее ещё дальше.
            if (slot.TargetEndUtc <= cursor)
                continue;

            var nextAnchor = FindNextAnchor(slots, i + 1);

            cursor = OpenSlot(slot, cursor, run, out var trace);
            if (cursor >= input.HorizonEndUtc)
                break;

            cursor = FillSlot(slot, cursor, nextAnchor, trace, run);
        }

        // Хвост до горизонта закрывает фон: лента обязана быть непрерывной, иначе живой край
        // упрётся в дыру.
        if (cursor < input.HorizonEndUtc)
            cursor = FillGap(cursor, input.HorizonEndUtc, run, null);

        if (cursor < input.HorizonEndUtc && input.FallbackUnits.Count == 0)
            run.Warnings.Add(
                new PlanningWarning(
                    PlanningWarningKind.FallbackEmpty,
                    null,
                    "Нет ни одной единицы для заполнения пауз — в ленте останутся дыры."
                )
            );

        return new PlanningResult(run.Items, run.Cursors.Updates, run.Warnings);
    }

    /// <summary>
    /// Подводит курсор к старту слота: добирает фоном до якоря либо до круглой отметки. Возвращает
    /// фактический старт и заполняет трейс сведениями о дрейфе.
    /// </summary>
    private static DateTimeOffset OpenSlot(
        PlanningSlot slot,
        DateTimeOffset cursor,
        PlanningRun run,
        out PlanTrace trace
    )
    {
        var snapped = false;

        if (cursor < slot.TargetStartUtc)
        {
            // До целевого времени ещё есть место — закрываем его фоном. Для якоря это обязательно,
            // для обычного слота тоже: иначе он начнётся раньше объявленного в программе времени.
            cursor = FillGap(cursor, slot.TargetStartUtc, run, slot.SlotId, padFirst: true);
        }
        else if (slot.SnapToMinutes is { } snap && snap > 0)
        {
            // Слот опаздывает. Округление мягкое: если добирать пришлось бы дольше допуска, лучше
            // начать в 19:47, чем девять минут крутить фон.
            var target = RoundUp(cursor, TimeSpan.FromMinutes(snap));
            if (target - cursor <= TimeSpan.FromMinutes(slot.MaxDriftMinutes))
            {
                var afterFill = FillGap(cursor, target, run, slot.SlotId, padFirst: true);
                snapped = afterFill > cursor;
                cursor = afterFill;
            }
        }

        var drift = (int)Math.Round((cursor - slot.TargetStartUtc).TotalMinutes);
        if (Math.Abs(drift) > slot.MaxDriftMinutes)
            run.Warnings.Add(
                new PlanningWarning(
                    PlanningWarningKind.DriftExceeded,
                    slot.SlotId,
                    $"Фактический старт разошёлся с целевым на {drift} мин."
                )
            );

        trace = new PlanTrace(slot.SlotId, slot.SlotKind, null, null, null, null, drift, snapped);
        return cursor;
    }

    /// <summary>Наполняет слот по его типу и возвращает курсор после него.</summary>
    private static DateTimeOffset FillSlot(
        PlanningSlot slot,
        DateTimeOffset cursor,
        DateTimeOffset? nextAnchor,
        PlanTrace trace,
        PlanningRun run
    )
    {
        var limit = Min(run.Input.HorizonEndUtc, nextAnchor);

        switch (slot.SlotKind)
        {
            case SlotKind.SignOff:
                // Конец вещания: место занимает зацикленный фон, но в программе это помечено особо.
                return FillWithFallback(
                    cursor,
                    Min(slot.TargetEndUtc, limit),
                    run,
                    slot.SlotId,
                    PlannedItemKind.SignOff,
                    trace
                );

            case SlotKind.Repeat:
                return FillRepeat(slot, cursor, limit, trace, run);

            default:
                return FillContent(slot, cursor, limit, trace, run);
        }
    }

    private static DateTimeOffset FillRepeat(
        PlanningSlot slot,
        DateTimeOffset cursor,
        DateTimeOffset limit,
        PlanTrace trace,
        PlanningRun run
    )
    {
        var units = CollectRepeatUnits(slot, run);
        if (units.Count == 0)
        {
            run.Warnings.Add(
                new PlanningWarning(
                    PlanningWarningKind.RepeatSourceEmpty,
                    slot.SlotId,
                    "В источнике повтора ничего не нашлось — слот отдан фоновому слою."
                )
            );
            return FillGap(cursor, Min(slot.TargetEndUtc, limit), run, slot.SlotId);
        }

        var slotEnd = Min(slot.TargetEndUtc, limit);
        var placed = 0;
        foreach (var unit in units)
        {
            if (cursor + unit.Duration > slotEnd)
                break;
            run.Add(Program(unit, cursor, slot.SlotId, trace));
            cursor += unit.Duration;
            placed++;
        }

        // Источник есть, но не влез ни одной единицей — слот короче того, что повторяет. Молчать
        // здесь нельзя: снаружи это неотличимо от пустого источника, а чинится совсем другим.
        if (placed == 0)
            run.Warnings.Add(
                new PlanningWarning(
                    PlanningWarningKind.RepeatSourceEmpty,
                    slot.SlotId,
                    "Источник повтора не помещается в слот целиком — слот отдан фоновому слою."
                )
            );

        return cursor;
    }

    /// <summary>
    /// Что повторять. Сначала уже записанная лента — её подобрал оркестратор, — затем то, что
    /// спланировано в этом же прогоне: горизонт строится целиком, и источник повтора обычно
    /// рождается на несколько слотов раньше самого повтора, ещё не дойдя до базы.
    ///
    /// Единицы берутся по пересечению с окном, а не по попаданию старта в него: сетка эластичная,
    /// и вечерний фильм законно начинается на десять минут раньше объявленного времени — по старту
    /// он бы в своё же окно не попал.
    /// </summary>
    private static List<PlanningUnit> CollectRepeatUnits(PlanningSlot slot, PlanningRun run)
    {
        var units = new List<PlanningUnit>(slot.RepeatUnits ?? []);
        if (slot.RepeatWindow is not { } window)
            return units;

        // Лента прогона хронологична, а записанное прошлое всё раньше её начала — порядок сохраняется
        // сам собой, пересортировывать нечего.
        units.AddRange(
            run.Items.Where(item =>
                    item.Kind == PlannedItemKind.Program
                    && item.StartsAtUtc < window.ToUtc
                    && item.EndsAtUtc > window.FromUtc
                )
                .Select(item => new PlanningUnit(
                    item.MediaAssetId,
                    item.EndsAtUtc - item.StartsAtUtc,
                    item.ShowId ?? Guid.Empty,
                    item.UnitIndex ?? 0
                ))
        );

        return units;
    }

    private static DateTimeOffset FillContent(
        PlanningSlot scheduled,
        DateTimeOffset cursor,
        DateTimeOffset limit,
        PlanTrace trace,
        PlanningRun run
    )
    {
        // Позиция берётся из хранилища прогона, а не из снимка в слоте: слот приходит по экземпляру
        // на каждые свои сутки, и все они несут один курсор из базы. Момент важен — брать её нужно
        // именно здесь: паузу перед стартом слота только что закрывал фон, а он ведёт те же слоты.
        var slot = run.Cursors.Apply(scheduled);

        var pick = ElementSelector.Select(slot, cursor, run.Random, run.Plays);
        if (pick is null)
        {
            run.Warnings.Add(NoCandidatesWarning(slot));
            return FillGap(cursor, Min(slot.TargetEndUtc, limit), run, slot.SlotId);
        }

        if (pick.CooldownExhausted)
            run.Warnings.Add(
                new PlanningWarning(
                    PlanningWarningKind.CooldownExhausted,
                    slot.SlotId,
                    "Остывание отсекло всех кандидатов — взят самый давний."
                )
            );

        var element = pick.Element;
        var slotTrace = trace with
        {
            ElementKind = element.Kind,
            ElementId = element.ElementId,
            Strategy = slot.Strategy.Kind,
            CandidatesAfterCooldown = pick.CandidatesAfterCooldown,
        };

        var unitIndex = pick.StartUnitIndex;
        var budgetEnd = Min(slot.TargetEndUtc, limit);
        var placed = 0;
        var accumulated = TimeSpan.Zero;

        // SkipIfNotFits решается до постановки: если элемент целиком не помещается, слот не начинают.
        if (
            slot.OverflowPolicy == OverflowPolicy.SkipIfNotFits
            && !FitsEntirely(element, unitIndex, cursor, budgetEnd)
        )
        {
            run.Cursors.Advance(slot.SlotId, element, unitIndex);
            return FillGap(cursor, budgetEnd, run, slot.SlotId);
        }

        while (unitIndex < element.Units.Count && cursor < limit)
        {
            var unit = element.Units[unitIndex];

            // Врезки между единицами: перед каждой, кроме первой в блоке.
            if (placed > 0)
                cursor = JunctionFiller.Fill(
                    slot.JunctionBetween,
                    cursor,
                    limit,
                    new JunctionPlacement(
                        slot.SlotId,
                        run.PreviousShowId,
                        unit.ShowId,
                        ElementChanged: run.PreviousShowId != unit.ShowId,
                        run.ChannelOffset
                    ),
                    run.JunctionRun,
                    slotTrace
                );

            // Через якорь не перелезаем: то, что не влезает до него, не начинают вовсе.
            if (cursor + unit.Duration > limit)
                break;

            if (!WithinBudget(slot, placed, accumulated, cursor, unit, budgetEnd))
                break;

            run.Add(Program(unit, cursor, slot.SlotId, slotTrace, CollectionOf(element)));
            cursor += unit.Duration;
            accumulated += unit.Duration;
            unitIndex++;
            placed++;
            run.PreviousShowId = unit.ShowId;
        }

        // Врезки в конце блока ставятся до добора фоном: иначе реклама оказалась бы после заполнителя.
        if (placed > 0)
            cursor = JunctionFiller.Fill(
                slot.JunctionAfter,
                cursor,
                limit,
                new JunctionPlacement(
                    slot.SlotId,
                    run.PreviousShowId,
                    null,
                    ElementChanged: true,
                    run.ChannelOffset
                ),
                run.JunctionRun,
                slotTrace
            );

        run.Cursors.Advance(slot.SlotId, element, unitIndex);

        // Недобор до целевого конца закрываем фоном — только для слотов, чей бюджет привязан ко времени.
        if (slot.BlockMode == SlotBlockMode.FillSlot && cursor < budgetEnd)
            cursor = FillGap(cursor, budgetEnd, run, slot.SlotId);

        return cursor;
    }

    /// <summary>
    /// Почему слот остался без контента. Пустая группа и отсечённая фильтром — разные беды: во втором
    /// случае контент есть, но не подходит по правилам, и админу надо чинить правило, а не состав
    /// группы.
    /// </summary>
    private static PlanningWarning NoCandidatesWarning(PlanningSlot slot)
    {
        var hasPlayable = slot.Elements.Any(e => e.Units.Count > 0);
        var hasAllowed = slot.Elements.Any(e =>
            e.Units.Count > 0 && ElementSelector.IsAllowedByAudience(slot, e)
        );

        return hasPlayable && !hasAllowed
            ? new PlanningWarning(
                PlanningWarningKind.CandidatesFiltered,
                slot.SlotId,
                "Возрастной потолок отсёк всех кандидатов — место закрыл фон."
            )
            : new PlanningWarning(
                PlanningWarningKind.SlotEmpty,
                slot.SlotId,
                "Слот не дал контента — место закрыл фон."
            );
    }

    /// <summary>
    /// Влезает ли ещё одна единица в бюджет слота. <see cref="OverflowPolicy.ExtendSlot"/> бюджет
    /// игнорирует: элемент доигрывается целиком, а разбег подберёт ближайший якорь.
    /// </summary>
    private static bool WithinBudget(
        PlanningSlot slot,
        int placed,
        TimeSpan accumulated,
        DateTimeOffset cursor,
        PlanningUnit unit,
        DateTimeOffset budgetEnd
    )
    {
        if (slot.OverflowPolicy == OverflowPolicy.ExtendSlot)
            return true;

        return slot.BlockMode switch
        {
            SlotBlockMode.Count => placed < Math.Max(1, slot.BlockValue),
            // Последняя единица входит целиком: обрезать видеофайл нельзя.
            SlotBlockMode.Duration => accumulated
                < TimeSpan.FromMinutes(Math.Max(1, slot.BlockValue)),
            _ => cursor + unit.Duration <= budgetEnd,
        };
    }

    private static bool FitsEntirely(
        PlanningElement element,
        int fromUnitIndex,
        DateTimeOffset cursor,
        DateTimeOffset budgetEnd
    )
    {
        var total = element
            .Units.Skip(fromUnitIndex)
            .Aggregate(TimeSpan.Zero, (sum, unit) => sum + unit.Duration);
        return cursor + total <= budgetEnd;
    }

    /// <summary>
    /// Закрывает паузу: сначала фоновым слоем, и только потом аварийным запасом. Порядок здесь —
    /// это и есть замысел фонового слоя: остаток слота, добор до якоря и пустой повтор должны
    /// играть настоящую программу своего времени суток, а зацикленный филлер в эфире выглядит
    /// поломкой и остаётся последней ступенью.
    /// </summary>
    private static DateTimeOffset FillGap(
        DateTimeOffset from,
        DateTimeOffset until,
        PlanningRun run,
        Guid? slotId,
        bool padFirst = false
    )
    {
        // Шов до круглой отметки и до якоря по замыслу занимает реклама: «ещё одна серия вместо
        // рекламы» — первое, что выдаёт в эфире плейлист вместо телеканала.
        var maxPad = run.Input.MaxPadDuration;
        var cursor = padFirst ? FillWithPad(from, until, run, slotId, maxPad) : from;

        var afterBackground = FillWithBackground(cursor, until, run);

        // Остаток, куда программа уже не влезает, — снова шов, и он тоже рекламный. Потолок считается
        // от последней программы, а не от каждого добора по отдельности: без фона оба добора сходятся
        // встык, и в эфир вместо десяти минут рекламы уходит двадцать.
        var left = afterBackground > cursor ? maxPad : maxPad - (cursor - from);
        cursor = FillWithPad(afterBackground, until, run, slotId, left);
        return FillWithFallback(cursor, until, run, slotId);
    }

    /// <summary>Ставит ролики и анонсы, пока влезают и пока не исчерпан <paramref name="budget"/>.</summary>
    private static DateTimeOffset FillWithPad(
        DateTimeOffset from,
        DateTimeOffset until,
        PlanningRun run,
        Guid? slotId,
        TimeSpan budget
    )
    {
        if (budget <= TimeSpan.Zero)
            return from;

        var limit = Min(until, from + budget);
        var cursor = from;
        var guard = 0;

        while (cursor < limit && guard++ < IterationBackstop)
        {
            if (run.Pad.Next(limit - cursor) is not { } pad)
                break;

            run.Add(
                new PlannedItem(
                    pad.Unit.MediaAssetId,
                    cursor,
                    cursor + pad.Unit.Duration,
                    null,
                    null,
                    slotId,
                    pad.Kind
                )
            );
            cursor += pad.Unit.Duration;
        }

        return cursor;
    }

    /// <summary>Закрывает паузу программами фонового слоя — настоящим контентом своего времени суток.</summary>
    private static DateTimeOffset FillWithBackground(
        DateTimeOffset from,
        DateTimeOffset until,
        PlanningRun run
    )
    {
        var cursor = from;
        var guard = 0;

        while (cursor < until && guard++ < IterationBackstop)
        {
            if (run.Background.Next(cursor, until - cursor) is not { } pick)
                break;

            run.Add(
                new PlannedItem(
                    pick.Unit.MediaAssetId,
                    cursor,
                    cursor + pick.Unit.Duration,
                    pick.Unit.ShowId,
                    pick.Unit.UnitIndex,
                    // Слот в записи — фоновый: «почему это здесь» должно показывать его, а не тот,
                    // чей остаток закрывали.
                    pick.Slot.SlotId,
                    PlannedItemKind.Program,
                    new PlanTrace(
                        pick.Slot.SlotId,
                        pick.Slot.SlotKind,
                        pick.Element.Kind,
                        pick.Element.ElementId,
                        pick.Strategy,
                        null,
                        0,
                        false
                    )
                )
            );
            cursor += pick.Unit.Duration;
            run.PreviousShowId = pick.Unit.ShowId;
        }

        return cursor;
    }

    /// <summary>
    /// Закрывает интервал единицами аварийного запаса. Ставит только те, что влезают целиком:
    /// обрезать нельзя, а перехлёст сдвинул бы следующий якорь. Остаток короче самой короткой
    /// единицы остаётся незакрытым — раздача покажет там аварийный филлер канала.
    /// </summary>
    private static DateTimeOffset FillWithFallback(
        DateTimeOffset from,
        DateTimeOffset until,
        PlanningRun run,
        Guid? slotId,
        PlannedItemKind kind = PlannedItemKind.Fallback,
        PlanTrace? trace = null
    )
    {
        var cursor = from;
        var guard = 0;

        while (cursor < until && guard++ < IterationBackstop)
        {
            if (run.Fallback.Next(until - cursor) is not { } unit)
                break;

            run.Add(
                new PlannedItem(
                    unit.MediaAssetId,
                    cursor,
                    cursor + unit.Duration,
                    // Аварийная группа — это тоже программы, и в ленте они обязаны быть подписаны:
                    // без шоу зритель и админ видят на их месте прочерк. Филлер канала шоу не имеет.
                    unit.ShowId == Guid.Empty
                        ? null
                        : unit.ShowId,
                    unit.ShowId == Guid.Empty ? null : unit.UnitIndex,
                    slotId,
                    kind,
                    trace
                )
            );
            cursor += unit.Duration;
        }

        return cursor;
    }

    /// <summary>Коллекция элемента или null, если в эфир шло отдельное шоу.</summary>
    private static Guid? CollectionOf(PlanningElement element) =>
        element.Kind == GroupElementKind.Collection ? element.ElementId : null;

    private static PlannedItem Program(
        PlanningUnit unit,
        DateTimeOffset start,
        Guid slotId,
        PlanTrace trace,
        Guid? collectionId = null
    ) =>
        new(
            unit.MediaAssetId,
            start,
            start + unit.Duration,
            unit.ShowId,
            unit.UnitIndex,
            slotId,
            PlannedItemKind.Program,
            trace,
            CollectionId: collectionId
        );

    /// <summary>Ближайший якорь среди последующих слотов — до него нельзя перелезать контентом.</summary>
    private static DateTimeOffset? FindNextAnchor(IReadOnlyList<PlanningSlot> slots, int fromIndex)
    {
        for (var i = fromIndex; i < slots.Count; i++)
            if (slots[i].IsAnchor)
                return slots[i].TargetStartUtc;
        return null;
    }

    private static DateTimeOffset Min(DateTimeOffset value, DateTimeOffset? other) =>
        other is { } o && o < value ? o : value;

    private static DateTimeOffset Min(DateTimeOffset a, DateTimeOffset b) => a < b ? a : b;

    /// <summary>Округление момента вверх до кратного шага — от начала суток UTC.</summary>
    private static DateTimeOffset RoundUp(DateTimeOffset moment, TimeSpan step)
    {
        if (step <= TimeSpan.Zero)
            return moment;

        var ticks = step.Ticks;
        var remainder = moment.UtcTicks % ticks;
        return remainder == 0 ? moment : moment.AddTicks(ticks - remainder);
    }
}
