namespace TeleWave.Domain.Broadcast.Scheduling;
///
/// Чистая эфирная математика: разворачивает конфигурацию канала в последовательность записей встык
/// от до . Без БД, ФС и
/// ffmpeg — полностью юнит-тестируемо (см. SchedulePlannerTests).
///
/// Инварианты: серии одного шоу идут по порядку (курсор ),
/// на конце сериала — заворот на первую серию; выбор шоу — взвешенно-случайный; override на окне
/// заменяет базовую ротацию; реклама вставляется по политике канала.
///
public static class SchedulePlanner
{
private const int IterationBackstop = 1_000_000;
public static PlannerResult Plan(PlannerInput input, IRandomSource random)
{
var entries = new List();
var byShowId = input.Shows.ToDictionary(s => s.ShowId);
var nextEpisode = input.Shows.ToDictionary(s => s.ChannelShowId, s => s.NextEpisodeIndex);
var nextAd = input.NextAdIndex;
var nextBumper = input.NextBumperIndex;
// Есть ли вообще из чего строить эфир.
var anyPlayable = input.Shows.Any(s => s.Weight > 0 && s.EpisodeAssetIds.Count > 0);
if (!anyPlayable)
return new PlannerResult(entries, nextEpisode, nextAd, nextBumper);
var cursor = input.StartTime;
var iterations = 0;
Guid? prevShowId = null;
DateTimeOffset? lastBumperAt = null;
while (cursor < input.HorizonEnd && iterations++ < IterationBackstop)
{
var candidates = ResolvePolicy(cursor, input, byShowId);
if (candidates.Count == 0)
break;
var pick = WeightedPick(candidates, random);
// ТВ-заставка на переходе. Из подходящих подблоков (по правилу показа vs контексту)
// резервируем слот выбранного блока — ассет подставит оркестратор. Само появление
// ограничено мин. интервалом и вероятностью для типа перехода (смена шоу / между блоками).
if (
prevShowId is { } prev
&& input.Bumpers is { Enabled: true } bumper
&& (
bumper.MinInterval <= TimeSpan.Zero
|| lastBumperAt is not { } last
|| cursor - last >= bumper.MinInterval
)
)
{
var isShowChange = prev != pick.ShowId;
var chance = isShowChange ? bumper.ShowChangeChance : bumper.EpisodeChangeChance;
if (RollChance(chance, random))
{
var bumperStart = cursor;
if (
TryPlaceBumper(
entries,
bumper,
prev,
pick.ShowId,
random,
ref nextBumper,
ref cursor
)
)
lastBumperAt = bumperStart;
}
}
var blockStart = cursor;
var block = CollectBlock(pick, nextEpisode, input, cursor);
foreach (var episode in block)
{
var duration = DurationOf(episode.AssetId, input);
var end = cursor + duration;
entries.Add(
new PlannedEntry(
episode.AssetId,
ScheduleEntryKind.Program,
cursor,
end,
pick.ShowId,
episode.Index
)
);
cursor = end;
if (input.AdInsertion == AdInsertion.BetweenEpisodes)
cursor = InsertAds(entries, input, cursor, ref nextAd);
}
if (input.AdInsertion == AdInsertion.BetweenBlocks)
cursor = InsertAds(entries, input, cursor, ref nextAd);
// Защита от зацикливания, если длительности нулевые/отсутствуют — эфир не сдвинулся.
if (cursor <= blockStart)
break;
prevShowId = pick.ShowId;
}
return new PlannerResult(entries, nextEpisode, nextAd, nextBumper);
}
///
/// Ставит на переходе заставку выбранного подблока: из подходящих по правилу показа (контекст —
/// сменилось ли шоу) выбирает один по стратегии канала и резервирует слот длины его блока. Оставляет
/// плейсхолдер с парой шоу + id блока/варианта (ассет отрендерит оркестратор). Возвращает true, если
/// заставка добавлена (курсор сдвинут).
///
private static bool TryPlaceBumper(
List entries,
PlannerBumperConfig bumper,
Guid fromShowId,
Guid toShowId,
IRandomSource random,
ref int nextBumper,
ref DateTimeOffset cursor
)
{
var isShowChange = fromShowId != toShowId;
var eligible = bumper
.Variants.Where(v =>
v.Duration > TimeSpan.Zero && MatchesTrigger(v.Trigger, isShowChange)
)
.ToList();
if (eligible.Count == 0)
return false;
PlannerBumperVariant variant;
switch (bumper.Selection)
{
case BumperSelection.Random:
variant = eligible[random.Next(eligible.Count)];
break;
case BumperSelection.WeightedRandom:
variant = WeightedPickVariant(eligible, random);
break;
case BumperSelection.AlwaysFirst:
variant = eligible[0];
break;
default: // Rotation
var idx = ((nextBumper % eligible.Count) + eligible.Count) % eligible.Count;
variant = eligible[idx];
nextBumper++;
break;
}
var end = cursor + variant.Duration;
entries.Add(
new PlannedEntry(
Guid.Empty,
ScheduleEntryKind.Bumper,
cursor,
end,
toShowId,
null,
fromShowId,
toShowId,
variant.TemplateId,
variant.VariantId
)
);
cursor = end;
return true;
}
private static bool MatchesTrigger(BumperTrigger trigger, bool isShowChange) =>
trigger switch
{
BumperTrigger.OnShowChange => isShowChange,
BumperTrigger.BetweenEpisodes => !isShowChange,
_ => true,
};
/// Прошла ли проверка вероятности появления (chance 0..1). 1 — всегда, 0 — никогда.
private static bool RollChance(double chance, IRandomSource random)
{
if (chance >= 1.0)
return true;
if (chance <= 0.0)
return false;
return random.Next(10000) < (int)Math.Round(chance * 10000);
}
/// Взвешенный случайный выбор подблока по (нулевые веса → равновероятно).
private static PlannerBumperVariant WeightedPickVariant(
List eligible,
IRandomSource random
)
{
var total = eligible.Sum(v => (long)Math.Max(0, v.Weight));
if (total <= 0)
return eligible[random.Next(eligible.Count)];
var roll = random.Next((int)Math.Min(total, int.MaxValue));
long acc = 0;
foreach (var v in eligible)
{
acc += Math.Max(0, v.Weight);
if (roll < acc)
return v;
}
return eligible[^1];
}
private static List<(PlannerShow Show, int Weight)> ResolvePolicy(
DateTimeOffset moment,
PlannerInput input,
IReadOnlyDictionary byShowId
)
{
var ovr = input.Overrides.FirstOrDefault(o => o.Covers(moment));
if (ovr is not null)
{
var overridden = new List<(PlannerShow, int)>();
foreach (var os in ovr.Shows)
{
if (
!byShowId.TryGetValue(os.ShowId, out var show)
|| show.EpisodeAssetIds.Count == 0
)
continue;
var weight = ovr.Mode == OverrideMode.Exclusive ? 1 : os.Weight;
if (weight > 0)
overridden.Add((show, weight));
}
if (overridden.Count > 0)
return overridden;
// Override ссылается на пустые/неготовые шоу — откатываемся к базовой ротации.
}
var hour = moment.UtcDateTime.Hour;
return input
.Shows.Where(s => s.Weight > 0 && s.EpisodeAssetIds.Count > 0)
.Select(s => (s, EffectiveWeight(s, hour)))
.ToList();
}
/// Вес шоу с учётом предпочтительных часов: в окне — усиливается множителем, иначе базовый.
private static int EffectiveWeight(PlannerShow show, int hour)
{
if (
show.PreferredWeightMultiplier > 1
&& show.PreferredHours is { Count: > 0 } windows
&& windows.Any(w => w.Contains(hour))
)
// long-умножение + clamp: экстремальные вес/множитель не переполняют int
// (иначе отрицательный total молча вырождает взвешенный выбор в первого кандидата).
return (int)Math.Min((long)show.Weight * show.PreferredWeightMultiplier, int.MaxValue);
return show.Weight;
}
private static PlannerShow WeightedPick(
List<(PlannerShow Show, int Weight)> candidates,
IRandomSource random
)
{
var total = candidates.Sum(c => (long)c.Weight);
if (total <= 0)
return candidates[0].Show;
var roll = random.Next((int)Math.Min(total, int.MaxValue));
long acc = 0;
foreach (var (show, weight) in candidates)
{
acc += weight;
if (roll < acc)
return show;
}
return candidates[^1].Show;
}
private static List<(Guid AssetId, int Index)> CollectBlock(
PlannerShow show,
Dictionary nextEpisode,
PlannerInput input,
DateTimeOffset cursor
)
{
var result = new List<(Guid, int)>();
var count = show.EpisodeAssetIds.Count;
var idx = ((nextEpisode[show.ChannelShowId] % count) + count) % count;
if (show.BlockMode == BlockMode.Count)
{
var n = Math.Max(1, show.BlockValue);
for (var i = 0; i < n; i++)
{
result.Add((show.EpisodeAssetIds[idx], idx));
idx = (idx + 1) % count;
}
}
else
{
var budget = TimeSpan.FromMinutes(Math.Max(1, show.BlockValue));
var accumulated = TimeSpan.Zero;
var guard = 0;
do
{
var assetId = show.EpisodeAssetIds[idx];
result.Add((assetId, idx));
accumulated += DurationOf(assetId, input);
idx = (idx + 1) % count;
guard++;
} while (
accumulated < budget
&& cursor + accumulated < input.HorizonEnd
&& guard < IterationBackstop
);
}
nextEpisode[show.ChannelShowId] = idx;
return result;
}
private static DateTimeOffset InsertAds(
List entries,
PlannerInput input,
DateTimeOffset cursor,
ref int nextAd
)
{
if (input.AdPool.Count == 0 || input.AdsPerBreak <= 0)
return cursor;
for (var i = 0; i < input.AdsPerBreak; i++)
{
var assetId = input.AdPool[
((nextAd % input.AdPool.Count) + input.AdPool.Count) % input.AdPool.Count
];
nextAd++;
var end = cursor + DurationOf(assetId, input);
entries.Add(new PlannedEntry(assetId, ScheduleEntryKind.Ad, cursor, end, null, null));
cursor = end;
}
return cursor;
}
private static TimeSpan DurationOf(Guid assetId, PlannerInput input) =>
input.Durations.TryGetValue(assetId, out var duration) ? duration : TimeSpan.Zero;
}