using TeleWave.Domain.Broadcast.Scheduling;

namespace TeleWave.Domain.Programming.Planning;

/// <summary>Что выбрано и почему — второе нужно для трейса «почему это здесь».</summary>
public sealed record ElementPick(
    PlanningElement Element,
    int StartUnitIndex,
    int? CandidatesAfterCooldown,
    bool CooldownExhausted
);

/// <summary>
/// Выбор элемента группы по стратегии слота. Отвечает только за выбор элемента: единицы внутри
/// элемента всегда идут по порядку от курсора — сериал не должен прыгать по сериям.
/// </summary>
public static class ElementSelector
{
    /// <param name="history">Показы этого прогона; складывается со снимком ленты из базы —
    /// без него остывание и потолок повторов не видели бы собственный горизонт.</param>
    public static ElementPick? Select(
        PlanningSlot slot,
        DateTimeOffset moment,
        IRandomSource random,
        RunPlayHistory? history = null
    )
    {
        // Возрастной потолок — жёсткое «нельзя», поэтому отсекает до любой стратегии и до курсора:
        // начатый в общее время сериал не должен доигрываться в детское.
        var playable = slot
            .Elements.Where(e => e.Units.Count > 0 && IsAllowedByAudience(slot, e))
            .ToList();
        if (playable.Count == 0)
            return null;

        // Курсор указывает на элемент, а не на индекс в группе: удаление позиции не сдвигает всё.
        var current = slot.Cursor?.ElementId is { } currentId
            ? playable.FirstOrDefault(e =>
                e.ElementId == currentId && e.Kind == slot.Cursor.ElementKind
            )
            : null;

        return slot.Strategy.Kind switch
        {
            SlotStrategyKind.Fixed => SelectFixed(slot, playable),
            SlotStrategyKind.Sequential => SelectSequential(slot, playable, current),
            _ => SelectRandom(slot, playable, current, moment, random, history),
        };
    }

    private static ElementPick? SelectFixed(PlanningSlot slot, List<PlanningElement> playable)
    {
        var fixedElement = playable.FirstOrDefault(e =>
            e.ElementId == slot.Strategy.FixedElementId
        );
        return fixedElement is null ? null : Continue(slot, fixedElement);
    }

    /// <summary>
    /// Последовательная стратегия: доигрываем текущий элемент, а исчерпав его — берём следующий
    /// по позиции. Дойдя до конца группы, начинаем сначала либо останавливаемся.
    /// </summary>
    private static ElementPick? SelectSequential(
        PlanningSlot slot,
        List<PlanningElement> playable,
        PlanningElement? current
    )
    {
        var ordered = playable.OrderBy(e => e.Position).ToList();

        if (current is not null && HasUnitsLeft(slot, current))
            return Continue(slot, current);

        var currentIndex = current is null
            ? -1
            : ordered.FindIndex(e => e.ElementId == current.ElementId);
        var nextIndex = currentIndex + 1;

        if (nextIndex >= ordered.Count)
        {
            if (!slot.Strategy.RestartOnEnd)
                return null;
            nextIndex = 0;
        }

        return new ElementPick(ordered[nextIndex], 0, null, false);
    }

    /// <summary>
    /// Случайный выбор с остыванием. Порядок принципиален: сначала остывание отсекает недавно
    /// показанное (жёсткий фильтр «нельзя»), затем взвешенный выбор работает среди оставшихся.
    /// В обратном порядке вес постоянно упирался бы в кулдаун и не работал.
    /// </summary>
    private static ElementPick? SelectRandom(
        PlanningSlot slot,
        List<PlanningElement> playable,
        PlanningElement? current,
        DateTimeOffset moment,
        IRandomSource random,
        RunPlayHistory? history
    )
    {
        // Начатый элемент доигрывается до конца: иначе блок «4 серии подряд» рассыпался бы
        // на серии из разных сериалов.
        if (current is not null && HasUnitsLeft(slot, current))
            return Continue(slot, current);

        // Потолок повторов — тоже жёсткий фильтр, но применяется только здесь: в последовательной
        // стратегии он выбрасывал бы очередную серию сериала и рвал порядок показа.
        var withinLimit = ApplyRepeatLimit(slot, playable, moment, history);

        var cooldown = TimeSpan.FromDays(Math.Max(0, slot.Strategy.CooldownDays));
        var eligible =
            cooldown <= TimeSpan.Zero
                ? withinLimit
                : withinLimit
                    .Where(e =>
                        LastPlayedOf(e, history) is not { } last || moment - last >= cooldown
                    )
                    .ToList();

        var exhausted = eligible.Count == 0;
        if (exhausted)
        {
            // Остывание отсекло всех. Либо игнорируем его на этот выход, либо берём самый давний —
            // пустой эфир хуже раннего повтора в обоих случаях. Откат идёт по уже суженному потолком
            // повторов пулу: иначе более слабое правило воскрешало бы отсечённое более строгим.
            eligible = slot.Strategy.IgnoreCooldownWhenExhausted
                ? withinLimit
                :
                [
                    withinLimit
                        .OrderBy(e => LastPlayedOf(e, history) ?? DateTimeOffset.MinValue)
                        .First(),
                ];
        }

        var picked = WeightedPick(eligible, random);
        return new ElementPick(picked, 0, eligible.Count, exhausted);
    }

    /// <summary>
    /// Проходит ли элемент по возрастному потолку слота. Категории упорядочены по строгости, поэтому
    /// сравнение — обычное «не строже». Элемент без категории не отсекается: неизвестное не значит
    /// «взрослое», а молчаливое выбрасывание такого контента искало бы потом весь вечер.
    /// </summary>
    public static bool IsAllowedByAudience(PlanningSlot slot, PlanningElement element) =>
        slot.MaxAudience is not { } max || element.Audience is not { } audience || audience <= max;

    /// <summary>
    /// Отсекает элементы, выбранные за период чаще потолка. История — старты уже записанных показов;
    /// без неё (правило не задано или лента не загружена) фильтр не работает и никого не выбрасывает.
    /// </summary>
    private static List<PlanningElement> ApplyRepeatLimit(
        PlanningSlot slot,
        List<PlanningElement> playable,
        DateTimeOffset moment,
        RunPlayHistory? history
    )
    {
        if (slot.RepeatLimit is not { } limit || limit.WindowDays <= 0 || limit.Max <= 0)
            return playable;

        var from = moment.AddDays(-limit.WindowDays);
        var filtered = playable.Where(e => PlaysSince(e, from, history) < limit.Max).ToList();

        // Все упёрлись в потолок — правило не должно оставлять слот пустым: пусть решает остывание.
        return filtered.Count == 0 ? playable : filtered;
    }

    /// <summary>Последний показ элемента: позднее из записанной ленты и текущего прогона.</summary>
    private static DateTimeOffset? LastPlayedOf(PlanningElement element, RunPlayHistory? history)
    {
        var inRun = history?.LastPlayed(element);
        if (element.LastPlayedUtc is not { } stored)
            return inRun;

        return inRun is { } fresh && fresh > stored ? fresh : stored;
    }

    /// <summary>Показы элемента за период: записанные плюс поставленные этим прогоном.</summary>
    private static int PlaysSince(
        PlanningElement element,
        DateTimeOffset from,
        RunPlayHistory? history
    ) =>
        (element.RecentPlaysUtc?.Count(p => p >= from) ?? 0)
        + (history?.CountSince(element, from) ?? 0);

    /// <summary>Продолжение текущего элемента с позиции курсора.</summary>
    private static ElementPick Continue(PlanningSlot slot, PlanningElement element)
    {
        var index = slot.Cursor?.ElementId == element.ElementId ? slot.Cursor.NextUnitIndex : 0;
        return new ElementPick(element, Math.Clamp(index, 0, element.Units.Count), null, false);
    }

    private static bool HasUnitsLeft(PlanningSlot slot, PlanningElement element) =>
        slot.Cursor is { } cursor
        && cursor.ElementId == element.ElementId
        && cursor.NextUnitIndex < element.Units.Count;

    private static PlanningElement WeightedPick(
        IReadOnlyList<PlanningElement> candidates,
        IRandomSource random
    )
    {
        var total = candidates.Sum(c => (long)Math.Max(0, c.Weight));
        if (total <= 0)
            return candidates[random.Next(candidates.Count)];

        var roll = random.Next((int)Math.Min(total, int.MaxValue));
        long accumulated = 0;
        foreach (var candidate in candidates)
        {
            accumulated += Math.Max(0, candidate.Weight);
            if (roll < accumulated)
                return candidate;
        }

        return candidates[^1];
    }
}
