using TeleWave.Domain.Broadcast.Scheduling;

namespace TeleWave.Domain.Programming.Planning;

/// <summary>
/// Состояние стыков в рамках прогона: когда какая врезка ставилась последний раз и на какой единице
/// группы она остановилась.
///
/// Ключ — конкретная врезка, а не её вид: две рекламные врезки в разных стыках это разные
/// ограничения, общий счётчик на вид склеил бы их в одно.
/// </summary>
public sealed class JunctionHistory
{
    private readonly Dictionary<Guid, DateTimeOffset> _lastPlaced = [];
    private readonly Dictionary<Guid, int> _nextUnit = [];

    public bool Allows(PlanningJunctionElement element, DateTimeOffset moment)
    {
        if (element.MinMinutesBetween <= 0)
            return true;
        if (!_lastPlaced.TryGetValue(element.ElementId, out var last))
            return true;
        return moment - last >= TimeSpan.FromMinutes(element.MinMinutesBetween);
    }

    public void Record(PlanningJunctionElement element, DateTimeOffset moment) =>
        _lastPlaced[element.ElementId] = moment;

    /// <summary>
    /// С какой единицы группы врезка продолжает набор. Без этого каждый рекламный блок начинался бы
    /// с одного и того же ролика — в эфире это слышно с первой же секунды.
    /// </summary>
    public int NextUnit(PlanningJunctionElement element) =>
        _nextUnit.TryGetValue(element.ElementId, out var index) ? index : 0;

    public void AdvanceUnits(PlanningJunctionElement element, int consumed)
    {
        if (element.Units.Count == 0 || consumed <= 0)
            return;
        _nextUnit[element.ElementId] = (NextUnit(element) + consumed) % element.Units.Count;
    }
}

/// <summary>
/// Где ставится стык и между чем. Собрано в один параметр: по отдельности эти значения ехали
/// сквозь всю раскладку и вместе с курсором, пределом и накопителями раздували сигнатуры.
/// </summary>
/// <param name="FromShowId">Шоу перед стыком, <paramref name="ToShowId"/> — после; заставке нужна
/// именно пара соседей, её ассет рендерится под неё после сборки ленты.</param>
/// <param name="ChannelOffset">Смещение времени канала от UTC — по нему считается окно суток врезки.</param>
public sealed record JunctionPlacement(
    Guid SlotId,
    Guid? FromShowId,
    Guid? ToShowId,
    bool ElementChanged,
    TimeSpan ChannelOffset = default
);

/// <summary>Готовая к постановке врезка: что именно играет и сколько это займёт.</summary>
public sealed record JunctionInsert(
    PlanningJunctionElement Element,
    IReadOnlyList<PlanningUnit> Units,
    TimeSpan Duration
);

/// <summary>
/// Накопители прогона, нужные раскладке стыка: история врезок (интервалы и ротация единиц),
/// жребий и сама собираемая лента. Принадлежат прогону целиком, а не отдельному стыку, поэтому
/// едут одним параметром — плоским списком сигнатура <see cref="JunctionFiller.Fill"/> перестаёт
/// читаться.
/// </summary>
public sealed record JunctionRun(
    JunctionHistory History,
    IRandomSource Random,
    List<PlannedItem> Items
);

/// <summary>
/// Раскладка врезок стыка: реклама, промо, заставка, заполнитель.
///
/// Порядок показа — это <see cref="JunctionElement.Position"/>, и только он. Обязательность
/// (<see cref="PlanningJunctionElement.IsRequired"/>) участвует единственным способом: когда до
/// предела (якорь или горизонт) влезает не всё, с конца отбрасываются необязательные врезки. Иначе
/// галочка «обязательно» молча поднимала бы рекламу перед заставкой, и цепочка в редакторе
/// перестала бы соответствовать эфиру.
/// </summary>
public static class JunctionFiller
{
    /// <summary>Раскладывает стык от <paramref name="cursor"/>. Возвращает курсор после стыка.</summary>
    public static DateTimeOffset Fill(
        PlanningJunction? junction,
        DateTimeOffset cursor,
        DateTimeOffset limit,
        JunctionPlacement placement,
        JunctionRun run,
        PlanTrace? trace
    )
    {
        if (junction is null || junction.Elements.Count == 0)
            return cursor;

        var (history, random, items) = run;

        // Жребий бросается после схлопывания развилки, а не вместе с остальными условиями: из пяти
        // заставок с шансом 60 % каждая бросала бы свой кубик, и «заставка в шести случаях из
        // десяти» превращалось бы в «какая-нибудь заставка почти всегда». Развилка — это один
        // выбор, значит и жребий у неё один.
        var eligible = junction.Elements.Where(e => Passes(e, cursor, placement, history)).ToList();
        var chosen = ResolveChoices(eligible, random).Where(e => Rolls(e, random)).ToList();

        var available = limit - cursor;
        if (junction.MaxTotal is { } cap && cap < available)
            available = cap;
        if (available <= TimeSpan.Zero)
            return cursor;

        var inserts = Trim(chosen.Select(e => Build(e, history)).ToList(), available);

        foreach (var insert in inserts)
        {
            var placedAt = cursor;
            var before = items.Count;
            cursor =
                insert.Element.Kind == JunctionElementKind.Bumper
                    ? PlaceBumper(insert, cursor, limit, placement, items, trace)
                    : PlaceUnits(insert, cursor, limit, placement.SlotId, items, trace);

            if (cursor <= placedAt)
                continue;

            history.Record(insert.Element, placedAt);
            // Двигаем ротацию ровно на поставленное: часть единиц могла не влезть до предела.
            history.AdvanceUnits(insert.Element, items.Count - before);
        }

        return cursor;
    }

    /// <summary>
    /// Проходит ли врезка по своим условиям: смена шоу, интервал, окно суток, круглый час. Жребий
    /// сюда не входит — он бросается после развилки (см. <see cref="Rolls"/>).
    /// </summary>
    private static bool Passes(
        PlanningJunctionElement element,
        DateTimeOffset cursor,
        JunctionPlacement placement,
        JunctionHistory history
    )
    {
        if (element.OnlyOnElementChange && !placement.ElementChanged)
            return false;
        if (!history.Allows(element, cursor))
            return false;

        if (element.TimeWindow is { } window)
        {
            var local = TimeOnly.FromTimeSpan(cursor.ToOffset(placement.ChannelOffset).TimeOfDay);
            if (!window.Contains(local))
                return false;
        }

        return NearHour(element, cursor, placement.ChannelOffset);
    }

    /// <summary>
    /// Выпал ли жребий этой врезке. У развилки жребий один на всю метку: бросает уже выбранный
    /// вариант, и его шанс — это шанс всей развилки.
    ///
    /// Источник — тот же, что и у всего прогона: он зависит от координат генерации, поэтому
    /// пересборка хвоста не перетасовывает врезки на каждое применение.
    /// </summary>
    private static bool Rolls(PlanningJunctionElement element, IRandomSource random) =>
        element.Chance >= 100 || random.Next(100) < Math.Max(0, element.Chance);

    /// <summary>
    /// Рядом ли момент с круглым часом. Сигнал точного времени — «пик-пик-пик» — тем и ценен, что
    /// звучит в :00, поэтому врезка с допуском ставится только в его окрестности, а не «когда-нибудь
    /// в этом часе».
    /// </summary>
    private static bool NearHour(
        PlanningJunctionElement element,
        DateTimeOffset cursor,
        TimeSpan channelOffset
    )
    {
        if (element.NearHourMinutes <= 0)
            return true;

        var time = cursor.ToOffset(channelOffset).TimeOfDay;
        var intoHour = time - TimeSpan.FromHours((int)time.TotalHours);
        // Допуск двусторонний: до часа считаем по остатку, после — по прошедшему.
        var distance = TimeSpan.FromMinutes(
            Math.Min(intoHour.TotalMinutes, 60 - intoHour.TotalMinutes)
        );
        return distance <= TimeSpan.FromMinutes(element.NearHourMinutes);
    }

    /// <summary>
    /// Схлопывает развилки: из врезок с одной меткой остаётся одна, выбранная по весам. Позиция
    /// развилки в цепочке — позиция её первого участника.
    /// </summary>
    private static List<PlanningJunctionElement> ResolveChoices(
        IReadOnlyList<PlanningJunctionElement> elements,
        IRandomSource random
    )
    {
        var result = new List<PlanningJunctionElement>();
        var resolved = new HashSet<string>(StringComparer.Ordinal);

        foreach (var element in elements)
        {
            if (element.ChoiceKey is not { } key)
            {
                result.Add(element);
                continue;
            }

            if (!resolved.Add(key))
                continue;

            var candidates = elements.Where(e => e.ChoiceKey == key).ToList();
            result.Add(WeightedPick(candidates, random));
        }

        return result;
    }

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

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

        return candidates[^1];
    }

    /// <summary>Подбирает единицы врезки по её бюджету, не двигая состояние: ставить или нет — решит <see cref="Trim"/>.</summary>
    private static JunctionInsert Build(PlanningJunctionElement element, JunctionHistory history)
    {
        if (element.Kind == JunctionElementKind.Bumper)
            return new JunctionInsert(element, [], element.BumperDuration);

        var units = new List<PlanningUnit>();
        var accumulated = TimeSpan.Zero;
        var start = history.NextUnit(element);

        for (var taken = 0; taken < element.Units.Count; taken++)
        {
            var enough =
                element.AmountMode == JunctionAmountMode.Count
                    ? units.Count >= Math.Max(1, element.AmountValue)
                    // Последняя единица входит целиком: рекламный блок не разрезается.
                    : accumulated >= TimeSpan.FromMinutes(Math.Max(1, element.AmountValue));
            if (enough)
                break;

            var unit = element.Units[(start + taken) % element.Units.Count];
            if (unit.Duration <= TimeSpan.Zero)
                continue;

            units.Add(unit);
            accumulated += unit.Duration;
        }

        return new JunctionInsert(element, units, accumulated);
    }

    /// <summary>
    /// Ужимает стык под <paramref name="available"/>: с конца отбрасываются сначала лишние единицы
    /// необязательных врезок, потом они сами целиком, и только если этого не хватило — обязательные.
    ///
    /// Ужимаем поштучно, а не целыми врезками: рекламный блок из четырёх роликов, для которого до
    /// якоря осталось место под один, должен поставить один, а не пропасть целиком.
    /// </summary>
    private static List<JunctionInsert> Trim(List<JunctionInsert> inserts, TimeSpan available)
    {
        var kept = inserts.Where(i => i.Duration > TimeSpan.Zero).ToList();
        var total = kept.Aggregate(TimeSpan.Zero, (sum, i) => sum + i.Duration);

        foreach (var required in new[] { false, true })
        {
            for (var i = kept.Count - 1; i >= 0 && total > available; i--)
            {
                if (kept[i].Element.IsRequired != required)
                    continue;

                while (total > available && kept[i].Units.Count > 1)
                {
                    var units = kept[i].Units.Take(kept[i].Units.Count - 1).ToList();
                    var duration = units.Aggregate(TimeSpan.Zero, (sum, u) => sum + u.Duration);
                    total -= kept[i].Duration - duration;
                    kept[i] = kept[i] with { Units = units, Duration = duration };
                }

                if (total <= available)
                    break;

                total -= kept[i].Duration;
                kept.RemoveAt(i);
            }
        }

        return kept;
    }

    /// <summary>
    /// Резервирует место под заставку. Ассет пуст: он рендерится под конкретную пару «из/в» уже после
    /// того, как лента собрана, — до наполнения слотов пара попросту неизвестна.
    /// </summary>
    private static DateTimeOffset PlaceBumper(
        JunctionInsert insert,
        DateTimeOffset cursor,
        DateTimeOffset limit,
        JunctionPlacement placement,
        List<PlannedItem> items,
        PlanTrace? trace
    )
    {
        if (insert.Duration <= TimeSpan.Zero || cursor + insert.Duration > limit)
            return cursor;

        var end = cursor + insert.Duration;
        items.Add(
            new PlannedItem(
                Guid.Empty,
                cursor,
                end,
                placement.ToShowId,
                null,
                placement.SlotId,
                PlannedItemKind.Bumper,
                trace,
                insert.Element.BumperTemplateId,
                placement.FromShowId,
                placement.ToShowId,
                insert.Element.BumperVariantId
            )
        );
        return end;
    }

    /// <summary>Ставит подобранные единицы врезки; те, что не влезают до предела, отбрасываются.</summary>
    private static DateTimeOffset PlaceUnits(
        JunctionInsert insert,
        DateTimeOffset cursor,
        DateTimeOffset limit,
        Guid slotId,
        List<PlannedItem> items,
        PlanTrace? trace
    )
    {
        var kind = insert.Element.Kind switch
        {
            JunctionElementKind.Ad => PlannedItemKind.Ad,
            JunctionElementKind.Promo => PlannedItemKind.Promo,
            _ => PlannedItemKind.Fallback,
        };

        foreach (var unit in insert.Units)
        {
            if (cursor + unit.Duration > limit)
                break;

            items.Add(
                new PlannedItem(
                    unit.MediaAssetId,
                    cursor,
                    cursor + unit.Duration,
                    null,
                    null,
                    slotId,
                    kind,
                    trace
                )
            );
            cursor += unit.Duration;
        }

        return cursor;
    }
}
