using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Templates.Transfer;
///
/// Загружает сетку из файла. Ссылки в файле — имена, поэтому импорт их разрешает: незнакомое имя
/// не валит загрузку, а становится замечанием, и админ видит списком, чего в библиотеке не нашлось.
///
/// Эфир при этом не двигается — как и любая правка сетки, импорт только помечает шаблон изменённым.
///
public sealed class ImportGridCommandHandler(IAppDbContext dbContext, SlotWriter writer)
: 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 groups = await NamesAsync(
dbContext.Groups.AsNoTracking().Select(g => new NamedRef(g.Id, g.Name)),
cancellationToken
);
var junctions = await NamesAsync(
dbContext.JunctionTemplates.AsNoTracking().Select(j => new NamedRef(j.Id, j.Name)),
cancellationToken
);
var warnings = new List();
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);
foreach (var configSlot in configLayer.Slots)
{
var input = ToInput(configSlot, groups, junctions, warnings);
if (input is null)
{
skipped++;
continue;
}
var applied = await writer.ApplyAsync(layer, null, input, cancellationToken);
if (applied.IsSuccess)
{
created++;
continue;
}
skipped++;
warnings.Add(
$"Слот «{configSlot.Title}» ({configSlot.Start:HH\\:mm}): {applied.Error.Message}"
);
}
}
Apply(template, command.Config, groups, junctions, warnings);
template.MarkChanged();
return Result.Success(
new GridImportResultDto(
command.Config.Layers.Count,
created,
skipped,
removed,
warnings
)
);
}
/// Снимает все слоты шаблона и возвращает их число — режим замены.
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 static void Apply(
ScheduleTemplate template,
GridConfig config,
IReadOnlyDictionary groups,
IReadOnlyDictionary junctions,
List warnings
)
{
if (config.Rules is { } rules)
template.SetRules(rules.ToJson());
if (config.FallbackGroup is { Length: > 0 } fallback)
{
if (Resolve(fallback, groups, "Группа", warnings) is { } groupId)
template.SetFallbackGroup(groupId);
}
if (config.DefaultJunction is { Length: > 0 } junction)
{
if (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);
}