using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Application.Programming.Groups;
using TeleWave.Domain.Library;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Templates.Transfer;
///
/// Загружает сетку из файла. Ссылки в файле — имена, поэтому импорт их разрешает: незнакомое имя
/// не валит загрузку, а становится замечанием, и админ видит списком, чего в библиотеке не нашлось.
///
/// Недостающие группы и коллекции файл может принести с собой: сетку, собранную под ещё не
/// разобранную библиотеку, иначе некуда положить — слот ссылается на группу, а групп нет. Шоу при
/// этом не создаются никогда: их приносит загрузка медиа, и выдумать их импорт не может.
///
/// Эфир при этом не двигается — как и любая правка сетки, импорт только помечает шаблон изменённым.
///
public sealed class ImportGridCommandHandler(
IAppDbContext dbContext,
SlotWriter writer,
GroupStatsService stats,
InterstitialGroups interstitials
) : ICommandHandler>
{
public async Task> Handle(
ImportGridCommand command,
CancellationToken cancellationToken
)
{
var template = await dbContext
.ScheduleTemplates.Include(t => t.Layers)
.ThenInclude(l => l.Slots)
.AsSplitQuery()
.FirstOrDefaultAsync(t => t.ChannelId == command.ChannelId, cancellationToken);
if (template is null)
return Result.Failure(ChannelErrors.TemplateNotFound);
var junctions = await NamesAsync(
dbContext.JunctionTemplates.AsNoTracking().Select(j => new NamedRef(j.Id, j.Name)),
cancellationToken
);
var warnings = new List();
// Библиотеку наполняем до слотов: слот ссылается на группу по имени, и группы из файла
// обязаны существовать к моменту, когда до него дойдёт очередь.
var collections = await EnsureCollectionsAsync(command.Config, warnings, cancellationToken);
var (groups, createdGroups) = await EnsureGroupsAsync(
command.Config,
collections.Names,
warnings,
cancellationToken
);
var removed = command.Replace ? Clear(template) : 0;
var created = 0;
var skipped = 0;
foreach (var configLayer in command.Config.Layers)
{
var layer = EnsureLayer(template, configLayer);
var (added, dropped) = await WriteSlotsAsync(
layer,
configLayer,
groups,
junctions,
warnings,
cancellationToken
);
created += added;
skipped += dropped;
}
await ApplyAsync(template, command.Config, groups, junctions, warnings, cancellationToken);
template.MarkChanged();
return Result.Success(
new GridImportResultDto(
command.Config.Layers.Count,
created,
skipped,
removed,
createdGroups,
collections.Created,
warnings
)
);
}
///
/// Пишет слоты одного слоя. Возвращает, сколько встало и сколько пропущено: слот, который
/// не прошёл проверки, замечанием и остаётся — валить весь файл из-за одной строки нельзя.
///
private async Task<(int Added, int Dropped)> WriteSlotsAsync(
GridLayer layer,
GridConfigLayer configLayer,
IReadOnlyDictionary groups,
IReadOnlyDictionary junctions,
List warnings,
CancellationToken cancellationToken
)
{
var added = 0;
var dropped = 0;
foreach (var configSlot in configLayer.Slots)
{
var input = ToInput(configSlot, groups, junctions, warnings);
if (input is null)
{
dropped++;
continue;
}
var applied = await writer.ApplyAsync(layer, null, input, cancellationToken);
if (applied.IsSuccess)
{
added++;
continue;
}
dropped++;
warnings.Add(
$"Слот «{configSlot.Title}» ({configSlot.Start:HH\\:mm}): {applied.Error.Message}"
);
}
return (added, dropped);
}
///
/// Заводит коллекции файла, которых нет в библиотеке. Существующая по имени не трогается: имя —
/// это ключ узнаваемости, и дописывать в чужую коллекцию свои шоу импорт не вправе.
///
private async Task<(Dictionary Names, int Created)> EnsureCollectionsAsync(
GridConfig config,
List warnings,
CancellationToken cancellationToken
)
{
var names = await NamesAsync(
dbContext.Collections.AsNoTracking().Select(c => new NamedRef(c.Id, c.Name)),
cancellationToken
);
if (config.Collections is not { Count: > 0 } wanted)
return (names, 0);
var shows = await ShowNamesAsync(cancellationToken);
var created = 0;
foreach (var configCollection in wanted)
{
if (string.IsNullOrWhiteSpace(configCollection.Name))
continue;
if (names.ContainsKey(configCollection.Name.Trim()))
continue;
var collection = Collection.Create(configCollection.Name, configCollection.Description);
foreach (var showName in configCollection.Shows ?? [])
{
if (Resolve(showName, shows, "Шоу", warnings) is { } showId)
collection.AddShow(showId);
}
dbContext.Collections.Add(collection);
names[collection.Name] = collection.Id;
created++;
}
return (names, created);
}
///
/// Заводит группы файла, которых нет в библиотеке, и возвращает справочник имён для слотов.
/// Существующая группа переиспользуется как есть: состав чужой группы импорт не переписывает,
/// иначе один принесённый файл молча перекроил бы и соседний канал, где та же группа стоит
/// в слотах.
///
private async Task<(Dictionary Names, int Created)> EnsureGroupsAsync(
GridConfig config,
IReadOnlyDictionary collections,
List warnings,
CancellationToken cancellationToken
)
{
var names = await NamesAsync(
dbContext.Groups.AsNoTracking().Select(g => new NamedRef(g.Id, g.Name)),
cancellationToken
);
if (config.Groups is not { Count: > 0 } wanted)
return (names, 0);
var shows = await ShowNamesAsync(cancellationToken);
var created = 0;
foreach (var configGroup in wanted)
{
if (
string.IsNullOrWhiteSpace(configGroup.Name)
|| names.ContainsKey(configGroup.Name.Trim())
)
continue;
var group = BuildGroup(configGroup, shows, collections, warnings);
dbContext.Groups.Add(group);
// Статистика — кэш для карточки группы. Коллекция, заведённая этим же импортом, ещё не
// сохранена и в счёт не попадёт; цифры сойдутся на первой правке состава.
await stats.RecomputeAsync(group, cancellationToken);
names[group.Name] = group.Id;
created++;
}
return (names, created);
}
///
/// Собирает состав группы из имён файла: что нашлось — в позиции, чего нет — в замечания.
/// Группа, где не нашлось ничего, всё равно создаётся, но с оговоркой: пустая группа — это
/// дыра в эфире, и узнать о ней надо на импорте, а не после применения сетки.
///
private static Group BuildGroup(
GridConfigGroup configGroup,
IReadOnlyDictionary shows,
IReadOnlyDictionary collections,
List warnings
)
{
var group = Group.Create(configGroup.Name, configGroup.Description);
foreach (var showName in configGroup.Shows ?? [])
{
if (Resolve(showName, shows, "Шоу", warnings) is { } showId)
group.AddElement(GroupElementKind.Show, showId);
}
foreach (var collectionName in configGroup.Collections ?? [])
{
if (Resolve(collectionName, collections, "Коллекция", warnings) is { } id)
group.AddElement(GroupElementKind.Collection, id);
}
if (group.Items.Count == 0)
warnings.Add(
$"Группа «{group.Name}» создана пустой — слоты по ней закроет аварийный фон."
);
return group;
}
/// Шоу по названиям. Одинаковые названия схлопываются в первое найденное.
private Task> ShowNamesAsync(CancellationToken cancellationToken) =>
NamesAsync(
dbContext.Shows.AsNoTracking().Select(s => new NamedRef(s.Id, s.Name)),
cancellationToken
);
/// Снимает все слоты шаблона и возвращает их число — режим замены.
private static int Clear(ScheduleTemplate template)
{
var removed = 0;
foreach (var layer in template.Layers)
{
// Идентификаторы снимаем заранее: удаление правит ту самую коллекцию, по которой идём.
var slotIds = layer.Slots.Select(s => s.Id).ToList();
foreach (var slotId in slotIds)
layer.RemoveSlot(slotId);
removed += slotIds.Count;
}
return removed;
}
///
/// Слой под слоты файла. Существующий берётся по имени: повторный импорт того же файла должен
/// давать ту же сетку, а не «Основная сетка (2)». Фоновый слой по имени не ищется — он один
/// и создаётся вместе с шаблоном.
///
private static GridLayer EnsureLayer(ScheduleTemplate template, GridConfigLayer configLayer)
{
if (configLayer.IsBackground && template.Background is { } background)
return background;
var existing = template.Layers.FirstOrDefault(l =>
!l.IsBackground
&& string.Equals(l.Name, configLayer.Name, StringComparison.OrdinalIgnoreCase)
);
if (existing is not null)
return existing;
var layer = template.AddLayer(configLayer.Name, configLayer.Priority);
layer.Update(
configLayer.Name,
configLayer.Priority,
configLayer.Applicability?.ToJson(),
configLayer.IsEnabled
);
return layer;
}
/// Настройки шаблона из файла. Пустые поля не трогают то, что уже стоит на канале.
private async Task ApplyAsync(
ScheduleTemplate template,
GridConfig config,
IReadOnlyDictionary groups,
IReadOnlyDictionary junctions,
List warnings,
CancellationToken cancellationToken
)
{
if (config.Rules is { } rules)
template.SetRules(rules.ToJson());
if (
config.FallbackGroup is { Length: > 0 } fallback
&& Resolve(fallback, groups, "Группа", warnings) is { } groupId
)
{
// Аварийной группой из роликов файл превращает в рекламу каждую паузу эфира. Замечанием,
// а не отказом: остальная сетка из файла корректна, и терять её из-за одного поля нельзя.
if (await interstitials.IsInterstitialAsync(groupId, cancellationToken))
warnings.Add(
$"Аварийная группа «{fallback}» собрана из роликов — она не проставлена: "
+ "паузы эфира стали бы рекламой."
);
else
template.SetFallbackGroup(groupId);
}
if (
config.DefaultJunction is { Length: > 0 } junction
&& Resolve(junction, junctions, "Стык", warnings) is { } junctionId
)
template.SetDefaultJunction(junctionId);
}
private static SlotInput? ToInput(
GridConfigSlot slot,
IReadOnlyDictionary groups,
IReadOnlyDictionary junctions,
List warnings
)
{
if (string.IsNullOrWhiteSpace(slot.Title) || slot.DurationMinutes <= 0)
{
warnings.Add($"Слот «{slot.Title}»: нет названия или длительности.");
return null;
}
Guid? groupId = null;
if (slot.Group is { Length: > 0 } groupName)
{
groupId = Resolve(groupName, groups, "Группа", warnings);
if (groupId is null)
return null;
}
else if (slot.Kind == SlotKind.Content)
{
warnings.Add($"Слот «{slot.Title}»: для содержательного слота нужна группа.");
return null;
}
return new SlotInput(
slot.Title,
slot.Weekday,
slot.Start,
slot.DurationMinutes,
slot.Daypart,
slot.Kind,
groupId,
slot.Strategy,
slot.Repeat,
slot.BlockMode,
slot.BlockValue,
slot.Overflow,
slot.IsAnchor,
slot.MaxDriftMinutes,
slot.SnapToMinutes,
slot.JunctionBetween is { Length: > 0 } between
? Resolve(between, junctions, "Стык", warnings)
: null,
slot.JunctionAfter is { Length: > 0 } after
? Resolve(after, junctions, "Стык", warnings)
: null
);
}
private static Guid? Resolve(
string name,
IReadOnlyDictionary known,
string subject,
List warnings
)
{
if (known.TryGetValue(name.Trim(), out var id))
return id;
var warning = $"{subject} «{name}»: в библиотеке такого нет.";
if (!warnings.Contains(warning, StringComparer.Ordinal))
warnings.Add(warning);
return null;
}
/// Справочник «имя → идентификатор». Одинаковые имена схлопываются в первое найденное.
private static async Task> NamesAsync(
IQueryable source,
CancellationToken cancellationToken
)
{
var items = await source.ToListAsync(cancellationToken);
var map = new Dictionary(StringComparer.OrdinalIgnoreCase);
foreach (var item in items)
map.TryAdd(item.Name.Trim(), item.Id);
return map;
}
private sealed record NamedRef(Guid Id, string Name);
}