namespace TeleWave.Domain.Programming;
///
/// Группа контента — «что может попасть в эфир». Слот планировщика ссылается на группу, стратегия
/// выбирает из неё элемент. Группы общие для всех каналов и переиспользуются.
///
/// Откуда берётся состав, решает :
///
/// • — состав это явный список (), а
/// лишь помогает его наполнить: «применить фильтр» добавляет найденное
/// в список, дальше он правится руками.
///
/// • — состав вычисляет правило в момент обращения, а список хранит
/// только ручные поправки к нему: закреплённое () и исключённое
/// (). Само правило домен по-прежнему не интерпретирует —
/// вычисление живёт в Application.
///
/// Уже материализованное расписание в обоих режимах не переписывается: изменение состава проявится
/// на следующем прогоне для ещё не сгенерированных суток. Иначе добавление одного шоу в библиотеку
/// перетасовывало бы будущий эфир всех каналов.
///
public class Group
{
private readonly List _items = [];
public Guid Id { get; private set; }
public string Name { get; private set; } = string.Empty;
public string? Description { get; private set; }
/// Правило набора (JSON) или null. Домен его не интерпретирует — схема живёт в Application.
public string? FilterJson { get; private set; }
/// Откуда берётся состав: явный список либо вычисление правилом.
public GroupMode Mode { get; private set; }
// ── Кэш статистики: считается при правке состава и фоново, нужен UI («342 позиции · 118 ч») ──
/// Число позиций в группе.
public int ItemCount { get; private set; }
/// Число единиц воспроизведения: у сериала и коллекции их больше одной.
public int UnitCount { get; private set; }
/// Суммарная длительность готовых единиц.
public TimeSpan TotalDuration { get; private set; }
public DateTimeOffset? StatsComputedAt { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
/// Позиции группы (backing-field для EF); порядок — по .
public IReadOnlyList Items => _items;
private Group() { }
public static Group Create(string name, string? description = null) =>
new()
{
Id = Guid.NewGuid(),
Name = name.Trim(),
Description = description,
CreatedAt = DateTimeOffset.UtcNow,
};
public void Rename(string name, string? description)
{
Name = name.Trim();
Description = description;
}
/// Сохранить правило набора (null — снять). Состав при этом не меняется.
public void SetFilter(string? filterJson) =>
FilterJson = string.IsNullOrWhiteSpace(filterJson) ? null : filterJson;
///
/// Сменить режим. Переключения не должны менять того, что уже идёт в эфире, поэтому состав
/// переносится, а не теряется: при переходе в динамический список позиций становится
/// закреплённым (правило добавит к нему остальное), при обратном переходе закреплённое
/// становится обычными позициями, а исключения снимаются — исключать больше нечего.
///
/// Вычисленный правилом состав при переходе в статический режим сюда не переносится: домен
/// правило не интерпретирует. Материализует его вызывающая сторона до смены режима.
///
///
/// Меняет режим набора. Список позиций при этом **сбрасывается**: в двух режимах он означает
/// разное, и переносить его как есть — значит соврать про то, чем группа стала.
///
/// Прежний состав, оставленный закреплённым, отменял бы саму суть перехода: правило считало бы
/// состав заново, но старые позиции оставались бы в нём навсегда, и «по правилу» на деле
/// означало бы «по правилу плюс всё, что было». Сузить набор правилом стало бы невозможно.
///
/// Обратный переход наполняет список вычисленным составом — это делает прикладной слой: правило
/// живёт там, домен его не интерпретирует.
///
public void SetMode(GroupMode mode)
{
if (Mode == mode)
return;
Mode = mode;
_items.Clear();
}
/// Записать пересчитанную статистику.
public void UpdateStats(int itemCount, int unitCount, TimeSpan totalDuration, DateTimeOffset at)
{
ItemCount = itemCount;
UnitCount = unitCount;
TotalDuration = totalDuration;
StatsComputedAt = at;
}
public bool Contains(GroupElementKind kind, Guid elementId) =>
_items.Any(i => i.ElementKind == kind && i.ElementId == elementId);
///
/// Добавляет элемент в конец. Повторное добавление игнорируется — возвращает null. Роль по
/// умолчанию зависит от режима: в динамической группе ручное добавление означает «закрепить»,
/// иначе правило вымело бы позицию на первом же обращении.
///
public GroupItem? AddElement(GroupElementKind kind, Guid elementId, GroupItemRole? role = null)
{
if (Contains(kind, elementId))
return null;
var nextPosition = _items.Count == 0 ? 0 : _items.Max(i => i.Position) + 1;
var item = GroupItem.Create(Id, kind, elementId, nextPosition, role ?? DefaultRole);
_items.Add(item);
return item;
}
private GroupItemRole DefaultRole =>
Mode == GroupMode.Dynamic ? GroupItemRole.Pinned : GroupItemRole.Member;
///
/// Исключить элемент из динамической группы. Закрепление, если оно было, снимается: держать
/// на один элемент обе противоположные поправки нельзя. Возвращает false, если группа
/// статическая — там состав правится списком, а исключать из списка нечего.
///
public bool Exclude(GroupElementKind kind, Guid elementId)
{
if (Mode != GroupMode.Dynamic)
return false;
var existing = _items.FirstOrDefault(i =>
i.ElementKind == kind && i.ElementId == elementId
);
if (existing is not null)
{
existing.SetRole(GroupItemRole.Excluded);
return true;
}
var nextPosition = _items.Count == 0 ? 0 : _items.Max(i => i.Position) + 1;
_items.Add(GroupItem.Create(Id, kind, elementId, nextPosition, GroupItemRole.Excluded));
return true;
}
/// Снять исключение — элемент вернётся в состав, если его находит правило.
public bool RemoveExclusion(GroupElementKind kind, Guid elementId) =>
_items.RemoveAll(i =>
i.Role == GroupItemRole.Excluded && i.ElementKind == kind && i.ElementId == elementId
) > 0;
public bool RemoveItem(Guid itemId)
{
var item = _items.FirstOrDefault(i => i.Id == itemId);
if (item is null)
return false;
_items.Remove(item);
return true;
}
/// Убирает все позиции, ссылающиеся на элемент. Вызывается при удалении шоу/коллекции
/// из библиотеки — внешнего ключа на полиморфную ссылку нет. Возвращает число снятых позиций.
public int RemoveElement(GroupElementKind kind, Guid elementId) =>
_items.RemoveAll(i => i.ElementKind == kind && i.ElementId == elementId);
public bool SetWeight(Guid itemId, int weight)
{
var item = _items.FirstOrDefault(i => i.Id == itemId);
if (item is null)
return false;
item.SetWeight(weight);
return true;
}
///
/// Переставляет позиции в порядке переданных идентификаторов. Не упомянутые остаются после них,
/// сохраняя относительный порядок, а неизвестные игнорируются — так перетаскивание одного
/// элемента не теряет остальные.
///
public void Reorder(IEnumerable itemIdsInOrder)
{
var known = _items.Select(i => i.Id).ToHashSet();
var requested = itemIdsInOrder.Distinct().Where(known.Contains).ToList();
var rest = _items
.Where(i => !requested.Contains(i.Id))
.OrderBy(i => i.Position)
.Select(i => i.Id);
var position = 0;
foreach (var itemId in requested.Concat(rest))
_items.First(i => i.Id == itemId).SetPosition(position++);
}
}