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;

/// <summary>
/// Загружает сетку из файла. Ссылки в файле — имена, поэтому импорт их разрешает: незнакомое имя
/// не валит загрузку, а становится замечанием, и админ видит списком, чего в библиотеке не нашлось.
///
/// Недостающие группы и коллекции файл может принести с собой: сетку, собранную под ещё не
/// разобранную библиотеку, иначе некуда положить — слот ссылается на группу, а групп нет. Шоу при
/// этом не создаются никогда: их приносит загрузка медиа, и выдумать их импорт не может.
///
/// Эфир при этом не двигается — как и любая правка сетки, импорт только помечает шаблон изменённым.
/// </summary>
public sealed class ImportGridCommandHandler(
    IAppDbContext dbContext,
    SlotWriter writer,
    GroupStatsService stats,
    InterstitialGroups interstitials
) : ICommandHandler<ImportGridCommand, Result<GridImportResultDto>>
{
    public async Task<Result<GridImportResultDto>> 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<GridImportResultDto>(ChannelErrors.TemplateNotFound);

        var junctions = await NamesAsync(
            dbContext.JunctionTemplates.AsNoTracking().Select(j => new NamedRef(j.Id, j.Name)),
            cancellationToken
        );

        var warnings = new List<string>();

        // Библиотеку наполняем до слотов: слот ссылается на группу по имени, и группы из файла
        // обязаны существовать к моменту, когда до него дойдёт очередь.
        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
            )
        );
    }

    /// <summary>
    /// Пишет слоты одного слоя. Возвращает, сколько встало и сколько пропущено: слот, который
    /// не прошёл проверки, замечанием и остаётся — валить весь файл из-за одной строки нельзя.
    /// </summary>
    private async Task<(int Added, int Dropped)> WriteSlotsAsync(
        GridLayer layer,
        GridConfigLayer configLayer,
        IReadOnlyDictionary<string, Guid> groups,
        IReadOnlyDictionary<string, Guid> junctions,
        List<string> 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);
    }

    /// <summary>
    /// Заводит коллекции файла, которых нет в библиотеке. Существующая по имени не трогается: имя —
    /// это ключ узнаваемости, и дописывать в чужую коллекцию свои шоу импорт не вправе.
    /// </summary>
    private async Task<(Dictionary<string, Guid> Names, int Created)> EnsureCollectionsAsync(
        GridConfig config,
        List<string> 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);
    }

    /// <summary>
    /// Заводит группы файла, которых нет в библиотеке, и возвращает справочник имён для слотов.
    /// Существующая группа переиспользуется как есть: состав чужой группы импорт не переписывает,
    /// иначе один принесённый файл молча перекроил бы и соседний канал, где та же группа стоит
    /// в слотах.
    /// </summary>
    private async Task<(Dictionary<string, Guid> Names, int Created)> EnsureGroupsAsync(
        GridConfig config,
        IReadOnlyDictionary<string, Guid> collections,
        List<string> 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);
    }

    /// <summary>
    /// Собирает состав группы из имён файла: что нашлось — в позиции, чего нет — в замечания.
    /// Группа, где не нашлось ничего, всё равно создаётся, но с оговоркой: пустая группа — это
    /// дыра в эфире, и узнать о ней надо на импорте, а не после применения сетки.
    /// </summary>
    private static Group BuildGroup(
        GridConfigGroup configGroup,
        IReadOnlyDictionary<string, Guid> shows,
        IReadOnlyDictionary<string, Guid> collections,
        List<string> 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;
    }

    /// <summary>Шоу по названиям. Одинаковые названия схлопываются в первое найденное.</summary>
    private Task<Dictionary<string, Guid>> ShowNamesAsync(CancellationToken cancellationToken) =>
        NamesAsync(
            dbContext.Shows.AsNoTracking().Select(s => new NamedRef(s.Id, s.Name)),
            cancellationToken
        );

    /// <summary>Снимает все слоты шаблона и возвращает их число — режим замены.</summary>
    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;
    }

    /// <summary>
    /// Слой под слоты файла. Существующий берётся по имени: повторный импорт того же файла должен
    /// давать ту же сетку, а не «Основная сетка (2)». Фоновый слой по имени не ищется — он один
    /// и создаётся вместе с шаблоном.
    /// </summary>
    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;
    }

    /// <summary>Настройки шаблона из файла. Пустые поля не трогают то, что уже стоит на канале.</summary>
    private async Task ApplyAsync(
        ScheduleTemplate template,
        GridConfig config,
        IReadOnlyDictionary<string, Guid> groups,
        IReadOnlyDictionary<string, Guid> junctions,
        List<string> 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<string, Guid> groups,
        IReadOnlyDictionary<string, Guid> junctions,
        List<string> 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<string, Guid> known,
        string subject,
        List<string> 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;
    }

    /// <summary>Справочник «имя → идентификатор». Одинаковые имена схлопываются в первое найденное.</summary>
    private static async Task<Dictionary<string, Guid>> NamesAsync(
        IQueryable<NamedRef> source,
        CancellationToken cancellationToken
    )
    {
        var items = await source.ToListAsync(cancellationToken);
        var map = new Dictionary<string, Guid>(StringComparer.OrdinalIgnoreCase);

        foreach (var item in items)
            map.TryAdd(item.Name.Trim(), item.Id);

        return map;
    }

    private sealed record NamedRef(Guid Id, string Name);
}
