using TeleWave.Domain.Library;

namespace TeleWave.Domain.Programming.Planning;

/// <summary>
/// Единица воспроизведения — серия или фильм с готовым ассетом. Планировщик оперирует ими, а не
/// шоу: у фильма единица одна, у сериала их столько же, сколько серий, у коллекции — сумма по частям.
/// </summary>
public sealed record PlanningUnit(Guid MediaAssetId, TimeSpan Duration, Guid ShowId, int UnitIndex);

/// <summary>
/// Элемент группы, развёрнутый в последовательность единиц. <see cref="LastPlayedUtc"/> — когда он
/// в последний раз выходил в этом канале; по нему работает остывание.
/// </summary>
public sealed record PlanningElement(
    GroupElementKind Kind,
    Guid ElementId,
    int Weight,
    int Position,
    IReadOnlyList<PlanningUnit> Units,
    DateTimeOffset? LastPlayedUtc = null,
    /// <summary>Категория аудитории (у коллекции — строжайшая из частей); по ней работает детское время.</summary>
    ShowAudience? Audience = null,
    /// <summary>Старты недавних показов в этом канале — по ним считается потолок повторов за период.</summary>
    IReadOnlyList<DateTimeOffset>? RecentPlaysUtc = null
);

/// <summary>Потолок повторов: не чаще <paramref name="Max"/> раз за <paramref name="WindowDays"/> суток.</summary>
public sealed record RepeatLimit(int WindowDays, int Max);

/// <summary>Стратегия выбора элемента, приведённая к виду, понятному чистому планировщику.</summary>
public sealed record PlanningStrategy(
    SlotStrategyKind Kind,
    bool RestartOnEnd = true,
    int CooldownDays = 0,
    bool IgnoreCooldownWhenExhausted = false,
    Guid? FixedElementId = null
);

/// <summary>Как слот выбирает элемент. Дублирует прикладной enum, чтобы домен не зависел от Application.</summary>
public enum SlotStrategyKind
{
    Sequential = 0,
    RandomWithCooldown = 1,
    Fixed = 2,
}

/// <summary>Где остановились в текущем элементе на момент начала прогона.</summary>
public sealed record PlanningCursor(
    GroupElementKind? ElementKind,
    Guid? ElementId,
    int NextUnitIndex
);

/// <summary>
/// Слот, привязанный к конкретному моменту эфира. Применимость слоёв уже разрешена: сюда попадают
/// только те слоты, которые реально действуют в эти сутки, каждый со своим целевым временем в UTC.
/// </summary>
public sealed record PlanningSlot(
    Guid SlotId,
    DateTimeOffset TargetStartUtc,
    int TargetDurationMinutes,
    SlotKind SlotKind,
    bool IsAnchor,
    int MaxDriftMinutes,
    int? SnapToMinutes,
    SlotBlockMode BlockMode,
    int BlockValue,
    OverflowPolicy OverflowPolicy,
    PlanningStrategy Strategy,
    IReadOnlyList<PlanningElement> Elements,
    PlanningCursor? Cursor,
    /// <summary>
    /// Готовые записи для <see cref="SlotKind.Repeat"/> — что играло в источнике повтора по уже
    /// записанной ленте. Покрывает только прошлое до начала прогона; то, что попадает в окно уже
    /// внутри прогона, планировщик добирает сам по <see cref="RepeatWindow"/>.
    /// </summary>
    IReadOnlyList<PlanningUnit>? RepeatUnits = null,
    /// <summary>Отрезок ленты, который повторяет слот (null — слот не повтор).</summary>
    PlanningRepeatWindow? RepeatWindow = null,
    /// <summary>Врезки между единицами внутри блока.</summary>
    PlanningJunction? JunctionBetween = null,
    /// <summary>Врезки в конце блока.</summary>
    PlanningJunction? JunctionAfter = null,
    /// <summary>Возрастной потолок в это время суток (null — без ограничения). Жёсткий фильтр.</summary>
    ShowAudience? MaxAudience = null,
    /// <summary>Потолок повторов за период (null — без ограничения). Жёсткий фильтр.</summary>
    RepeatLimit? RepeatLimit = null
)
{
    public DateTimeOffset TargetEndUtc => TargetStartUtc.AddMinutes(TargetDurationMinutes);
}

/// <summary>
/// Отрезок ленты, который повторяет слот-повтор. Планировщику он нужен отдельно от готовых единиц:
/// первая генерация строит весь горизонт одним прогоном, и вечер понедельника, который повторяют во
/// вторник утром, в этот момент существует только внутри прогона — в базе его ещё нет.
/// </summary>
public sealed record PlanningRepeatWindow(DateTimeOffset FromUtc, DateTimeOffset ToUtc);

/// <summary>Окно времени суток в часах канала; допускает переход через полночь (22:00 → 06:00).</summary>
public sealed record PlanningTimeWindow(TimeOnly From, TimeOnly To)
{
    public bool Contains(TimeOnly moment) =>
        From <= To ? moment >= From && moment < To : moment >= From || moment < To;
}

/// <summary>
/// Врезка стыка, развёрнутая для планировщика: единицы уже подобраны оркестратором, домену остаётся
/// решить, сколько их поставить и влезают ли они.
/// </summary>
public sealed record PlanningJunctionElement(
    Guid ElementId,
    JunctionElementKind Kind,
    IReadOnlyList<PlanningUnit> Units,
    JunctionAmountMode AmountMode,
    int AmountValue,
    bool IsRequired,
    /// <summary>Ставить только при смене элемента (иначе — и между единицами одного).</summary>
    bool OnlyOnElementChange = false,
    /// <summary>Не ставить чаще, чем раз в N минут (0 — без ограничения).</summary>
    int MinMinutesBetween = 0,
    /// <summary>Вероятность показа в процентах (100 — всегда).</summary>
    int Chance = 100,
    /// <summary>Окно времени суток, вне которого врезка не ставится (null — всегда).</summary>
    PlanningTimeWindow? TimeWindow = null,
    /// <summary>Допуск от круглого часа в минутах; 0 — врезка не привязана к часу.</summary>
    int NearHourMinutes = 0,
    /// <summary>Метка развилки: из врезок с одной меткой ставится одна, выбранная по весам.</summary>
    string? ChoiceKey = null,
    int ChoiceWeight = 1,
    /// <summary>Блок заставки — ассет рендерится позже, планировщик резервирует длительность.</summary>
    Guid? BumperTemplateId = null,
    /// <summary>Конкретный подблок заставки; null — выберет резолвер по триггеру и весам.</summary>
    Guid? BumperVariantId = null,
    /// <summary>Длительность резерва под заставку.</summary>
    TimeSpan BumperDuration = default
);

/// <summary>Стык: последовательность врезок между программами.</summary>
public sealed record PlanningJunction(
    Guid JunctionId,
    IReadOnlyList<PlanningJunctionElement> Elements,
    /// <summary>Потолок длины стыка целиком (null — ограничен только якорем).</summary>
    TimeSpan? MaxTotal = null
);

/// <summary>Полный вход одного прогона генератора.</summary>
public sealed record PlanningInput(
    Guid ChannelId,
    DateTimeOffset StartUtc,
    DateTimeOffset HorizonEndUtc,
    IReadOnlyList<PlanningSlot> Slots,
    /// <summary>
    /// Аварийный запас: зацикленный филлер канала и группа-подстраховка шаблона. Последняя ступень,
    /// когда даже фоновый слой не дал контента.
    /// </summary>
    IReadOnlyList<PlanningUnit> FallbackUnits,
    int SegmentSeconds,
    /// <summary>Смещение времени канала от UTC — по нему считаются окна суток у врезок стыка.</summary>
    int UtcOffsetMinutes = 0,
    /// <summary>
    /// Слоты фонового слоя, включая перекрытые слоями выше. Ими закрываются паузы: остаток слота
    /// короче одной серии, добор до якоря, пустой слот-повтор. Отдельным списком, потому что
    /// в основном перекрытых слотов нет — там побеждает слой с большим приоритетом.
    /// </summary>
    IReadOnlyList<PlanningSlot> BackgroundSlots = null!,
    /// <summary>
    /// Чем добивается шов до круглой отметки и до якоря: ролики и анонсы стыка по умолчанию.
    /// На настоящем ТВ остаток до 20:00 занимает реклама, а не ещё одна серия.
    /// </summary>
    IReadOnlyList<PadUnit> PadUnits = null!,
    /// <summary>
    /// Потолок одного добора рекламой. Шов в десять минут — это блок роликов, а двухчасовая дыра —
    /// уже эфирное время, и заливать её рекламой нельзя.
    /// </summary>
    TimeSpan MaxPadDuration = default
)
{
    public IReadOnlyList<PlanningSlot> BackgroundSlots { get; init; } = BackgroundSlots ?? [];
    public IReadOnlyList<PadUnit> PadUnits { get; init; } = PadUnits ?? [];

    public TimeSpan MaxPadDuration { get; init; } =
        MaxPadDuration > TimeSpan.Zero ? MaxPadDuration : DefaultMaxPad;

    /// <summary>Сколько рекламы подряд допустимо в шве, если стык не задал свой потолок.</summary>
    public static readonly TimeSpan DefaultMaxPad = TimeSpan.FromMinutes(10);
}

/// <summary>Единица добора шва: ролик и чем он значится в ленте — рекламой или анонсом.</summary>
public sealed record PadUnit(PlanningUnit Unit, PlannedItemKind Kind);

/// <summary>Одна запись будущей ленты. Трейс пишется здесь же — восстановить его потом невозможно.</summary>
public sealed record PlannedItem(
    Guid MediaAssetId,
    DateTimeOffset StartsAtUtc,
    DateTimeOffset EndsAtUtc,
    Guid? ShowId,
    int? UnitIndex,
    Guid? SlotId,
    PlannedItemKind Kind,
    PlanTrace? Trace = null,
    /// <summary>Для заставки: блок, пара «из/в» и место под ассет, который отрендерят позже.</summary>
    Guid? BumperTemplateId = null,
    Guid? FromShowId = null,
    Guid? ToShowId = null,
    /// <summary>Подблок заставки, если врезка задала его жёстко; null — выберет резолвер.</summary>
    Guid? BumperVariantId = null,
    /// <summary>Коллекция, частью которой шла единица (null — шоу играло само по себе).</summary>
    Guid? CollectionId = null
);

public enum PlannedItemKind
{
    Program = 0,
    Fallback = 1,
    SignOff = 2,
    Ad = 3,
    Promo = 4,

    /// <summary>
    /// Заставка-переход. Ассет пуст: он зависит от пары соседей и рендерится после того, как лента
    /// собрана, — планировщик лишь резервирует под неё длительность.
    /// </summary>
    Bumper = 5,
}

/// <summary>Цепочка происхождения записи — питает экран «почему это здесь».</summary>
public sealed record PlanTrace(
    Guid? SlotId,
    SlotKind SlotKind,
    GroupElementKind? ElementKind,
    Guid? ElementId,
    SlotStrategyKind? Strategy,
    /// <summary>Сколько кандидатов осталось после остывания (null — выбор без остывания).</summary>
    int? CandidatesAfterCooldown,
    /// <summary>Насколько фактический старт разошёлся с целевым, минуты.</summary>
    int DriftMinutes,
    /// <summary>Старт сдвинут вперёд округлением до круглого времени.</summary>
    bool Snapped
);

/// <summary>Новое состояние слота после прогона — оркестратор сохраняет его в БД.</summary>
public sealed record PlanningCursorUpdate(
    Guid SlotId,
    GroupElementKind? ElementKind,
    Guid? ElementId,
    int NextUnitIndex
);

/// <summary>Результат прогона: лента, новые курсоры и предупреждения для админа.</summary>
public sealed record PlanningResult(
    IReadOnlyList<PlannedItem> Items,
    IReadOnlyList<PlanningCursorUpdate> Cursors,
    IReadOnlyList<PlanningWarning> Warnings
);

/// <summary>Предупреждение по результату генерации: не ошибка, но админу это видеть нужно.</summary>
public sealed record PlanningWarning(PlanningWarningKind Kind, Guid? SlotId, string Details);

public enum PlanningWarningKind
{
    /// <summary>Слот не дал контента — место закрыл фон.</summary>
    SlotEmpty = 0,

    /// <summary>Фактический старт ушёл дальше допуска.</summary>
    DriftExceeded = 1,

    /// <summary>Остывание отсекло всех кандидатов.</summary>
    CooldownExhausted = 2,

    /// <summary>Не нашлось, что повторить.</summary>
    RepeatSourceEmpty = 3,

    /// <summary>Пусто даже в фоне — в ленте образуется дыра.</summary>
    FallbackEmpty = 4,

    /// <summary>Жёсткие фильтры (детское время, потолок повторов) не оставили ни одного кандидата.</summary>
    CandidatesFiltered = 5,

    // ── Пост-проверки: считаются по готовой ленте и ничего не переигрывают (см. 3.8). ──

    /// <summary>Врезок в часе больше заданного потолка.</summary>
    BreakLimitExceeded = 6,

    /// <summary>Доля одного жанра за сутки выше заданной.</summary>
    GenreShareExceeded = 7,

    /// <summary>Фон занял больше эфира, чем считается нормой.</summary>
    FallbackShareExceeded = 8,
}
