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.Validate;

public sealed class ValidateTemplateQueryHandler(
    IAppDbContext dbContext,
    DynamicGroupResolver dynamicResolver
) : IQueryHandler<ValidateTemplateQuery, Result<IReadOnlyList<TemplateIssueDto>>>
{
    public async Task<Result<IReadOnlyList<TemplateIssueDto>>> Handle(
        ValidateTemplateQuery query,
        CancellationToken cancellationToken
    )
    {
        var channel = await dbContext
            .Channels.AsNoTracking()
            .FirstOrDefaultAsync(c => c.Id == query.ChannelId, cancellationToken);
        if (channel is null)
            return Result.Failure<IReadOnlyList<TemplateIssueDto>>(ChannelErrors.NotFound);

        var template = await dbContext
            .ScheduleTemplates.AsNoTracking()
            .Include(t => t.Layers)
                .ThenInclude(l => l.Slots)
            .AsSplitQuery()
            .FirstOrDefaultAsync(t => t.ChannelId == channel.Id, cancellationToken);
        if (template is null)
            return Result.Failure<IReadOnlyList<TemplateIssueDto>>(ChannelErrors.TemplateNotFound);

        var layers = template.Layers.Where(l => l.IsEnabled).ToList();
        var groupIds = layers
            .SelectMany(l => l.Slots)
            .Select(s => s.GroupId)
            .Where(id => id is not null)
            .Select(id => id!.Value)
            .Distinct()
            .ToList();

        // Состав считаем, а не читаем кэш ItemCount: у группы по правилу кэш отстаёт от библиотеки,
        // и проверка «группа пуста» врала бы в обе стороны — и пугала бы зря, и пропускала бы
        // действительно опустевшую группу.
        var loaded = await dbContext
            .Groups.AsNoTracking()
            .Include(g => g.Items)
            .Where(g => groupIds.Contains(g.Id))
            .ToListAsync(cancellationToken);

        var composition = new Dictionary<Guid, IReadOnlyList<ResolvedElement>>();
        foreach (var group in loaded)
            composition[group.Id] = await dynamicResolver.ResolveAsync(group, cancellationToken);

        var groups = loaded.ToDictionary(
            g => g.Id,
            g => new GroupStats(g.Id, g.Name, composition[g.Id].Count)
        );

        var rules = PlanningRules.FromJson(template.RulesJson);
        var strictest = await LoadStrictestAudienceAsync(composition, cancellationToken);
        var dayStart = channel.DayStartTime;

        var issues = new List<TemplateIssueDto>();

        foreach (var layer in layers)
        {
            issues.AddRange(FindOverlaps(layer, dayStart));

            foreach (var slot in layer.Slots.Where(s => s.SlotKind == SlotKind.Content))
                issues.AddRange(CheckSlot(layer, slot, groups, strictest, rules));
        }

        issues.AddRange(FindGaps(layers, dayStart));

        return Result.Success<IReadOnlyList<TemplateIssueDto>>(issues);
    }

    private sealed record GroupStats(Guid Id, string Name, int ItemCount);

    private static IEnumerable<TemplateIssueDto> CheckSlot(
        GridLayer layer,
        Slot slot,
        IReadOnlyDictionary<Guid, GroupStats> groups,
        IReadOnlyDictionary<Guid, ShowAudience> strictest,
        PlanningRules? rules
    )
    {
        if (slot.GroupId is not { } groupId || !groups.TryGetValue(groupId, out var group))
        {
            yield return new TemplateIssueDto(
                TemplateIssueKind.GroupMissing,
                TemplateIssueSeverity.Error,
                layer.Id,
                slot.Id,
                $"У слота «{slot.Title}» не выбрана группа — место закроет фон."
            );
            yield break;
        }

        if (group.ItemCount == 0)
        {
            yield return new TemplateIssueDto(
                TemplateIssueKind.GroupEmpty,
                TemplateIssueSeverity.Error,
                layer.Id,
                slot.Id,
                $"Группа «{group.Name}» пуста — слот «{slot.Title}» заполнит фон."
            );
            yield break;
        }

        var perWeek = OccurrencesPerWeek(slot);
        if (group.ItemCount < perWeek)
            yield return new TemplateIssueDto(
                TemplateIssueKind.GroupTooSmall,
                TemplateIssueSeverity.Warning,
                layer.Id,
                slot.Id,
                $"В группе «{group.Name}» {group.ItemCount} позиций при {perWeek} выходах в неделю — "
                    + "повторы пойдут чаще, чем раз в неделю."
            );

        // Остывание считается по числу выходов: за N дней слот выйдет N × (выходов в день) раз,
        // и если это больше состава группы, отсекать будет некого.
        var strategy = SlotStrategy.FromJson(slot.StrategyJson);
        if (
            strategy is { Type: SlotStrategyType.RandomWithCooldown, CooldownDays: > 0 }
            && strategy.CooldownDays * perWeek / 7.0 > group.ItemCount
        )
            yield return new TemplateIssueDto(
                TemplateIssueKind.CooldownUnreachable,
                TemplateIssueSeverity.Warning,
                layer.Id,
                slot.Id,
                $"Остывание {strategy.CooldownDays} дней невыполнимо при {group.ItemCount} позициях "
                    + $"в группе «{group.Name}»."
            );

        if (
            rules?.AudienceAt(slot.TargetStart) is { } maxAudience
            && strictest.TryGetValue(groupId, out var groupAudience)
            && groupAudience > maxAudience
        )
            yield return new TemplateIssueDto(
                TemplateIssueKind.AudienceConflict,
                TemplateIssueSeverity.Warning,
                layer.Id,
                slot.Id,
                $"В группе «{group.Name}» есть контент категории «{groupAudience}», а слот "
                    + $"«{slot.Title}» стоит во времени не строже «{maxAudience}»."
            );
    }

    /// <summary>Сколько раз слот выходит в неделю: без дня недели — каждый день.</summary>
    private static int OccurrencesPerWeek(Slot slot) => slot.Weekday is null ? 7 : 1;

    /// <summary>
    /// Пересечения слотов внутри одного слоя. Внутри слоя приоритетов нет, поэтому сыграет первый
    /// по времени, а второй молча пропадёт — это стоит показать до генерации.
    /// </summary>
    private static IEnumerable<TemplateIssueDto> FindOverlaps(GridLayer layer, TimeOnly dayStart)
    {
        var slots = layer
            .Slots.OrderBy(s => GridCoverage.OffsetInDay(s.TargetStart, dayStart))
            .ToList();

        // Внешний цикл в фигурных скобках намеренно: без них вложенный for выглядит как соседний,
        // и «телом» внешнего цикла его читает только компилятор.
        for (var i = 0; i < slots.Count; i++)
        {
            for (var j = i + 1; j < slots.Count; j++)
            {
                var a = slots[i];
                var b = slots[j];
                if (!SameDays(a, b))
                    continue;

                var aFrom = GridCoverage.OffsetInDay(a.TargetStart, dayStart);
                var bFrom = GridCoverage.OffsetInDay(b.TargetStart, dayStart);
                if (
                    aFrom < bFrom + b.TargetDurationMinutes
                    && bFrom < aFrom + a.TargetDurationMinutes
                )
                    yield return new TemplateIssueDto(
                        TemplateIssueKind.SlotOverlap,
                        TemplateIssueSeverity.Warning,
                        layer.Id,
                        b.Id,
                        $"«{a.Title}» и «{b.Title}» пересекаются в слое «{layer.Name}»."
                    );
            }
        }
    }

    /// <summary>Слот без дня недели идёт каждый день, поэтому пересекается с любым.</summary>
    private static bool SameDays(Slot a, Slot b) =>
        a.Weekday is null || b.Weekday is null || a.Weekday == b.Weekday;

    /// <summary>
    /// Интервалы вещательных суток, не покрытые ни одним слотом. Проверяется по каждому дню недели:
    /// дыра во вторник ночью не видна, если смотреть на неделю целиком.
    /// </summary>
    private static IEnumerable<TemplateIssueDto> FindGaps(
        IReadOnlyList<GridLayer> layers,
        TimeOnly dayStart
    )
    {
        var slots = layers.SelectMany(l => l.Slots).ToList();

        return from weekday in GridCoverage.Week
            from gap in GridCoverage.Gaps(slots, weekday, dayStart)
            select Gap(weekday, gap.From, gap.To, dayStart);
    }

    private static TemplateIssueDto Gap(int weekday, int from, int to, TimeOnly dayStart)
    {
        var day = new[] { "вс", "пн", "вт", "ср", "чт", "пт", "сб" }[weekday];
        // Сутки целиком: «00:00–00:00» читалось бы как пустой интервал, а это ровно наоборот.
        var interval =
            to - from >= GridCoverage.MinutesInDay
                ? "весь день"
                : $"{Clock(from, dayStart)}–{Clock(to, dayStart)}";

        return new TemplateIssueDto(
            TemplateIssueKind.GridGap,
            TemplateIssueSeverity.Error,
            null,
            null,
            $"Не покрыто: {day} {interval}."
        );
    }

    private static string Clock(int offsetMinutes, TimeOnly dayStart)
    {
        var time = GridCoverage.AtOffset(offsetMinutes, dayStart);
        return $"{time.Hour:00}:{time.Minute:00}";
    }

    /// <summary>
    /// Строжайший рейтинг среди позиций каждой группы: именно он конфликтует с детским временем.
    /// Коллекция берётся по строжайшей части — франшиза идёт целиком. Шоу без рейтинга в расчёт не
    /// идут: планировщик их не отсекает, значит и предупреждать не о чем.
    /// </summary>
    /// <summary>
    /// Самый строгий рейтинг в каждой группе — по вычисленному составу, а не по строкам таблицы:
    /// иначе у динамической группы в расчёт попало бы исключённое и не попало бы найденное правилом.
    /// </summary>
    private async Task<Dictionary<Guid, ShowAudience>> LoadStrictestAudienceAsync(
        IReadOnlyDictionary<Guid, IReadOnlyList<ResolvedElement>> composition,
        CancellationToken cancellationToken
    )
    {
        var items = composition
            .SelectMany(pair =>
                pair.Value.Select(e => new
                {
                    GroupId = pair.Key,
                    e.Kind,
                    e.Id,
                })
            )
            .ToList();
        if (items.Count == 0)
            return [];

        var showIds = items.Where(i => i.Kind == GroupElementKind.Show).Select(i => i.Id).ToList();
        var collectionIds = items
            .Where(i => i.Kind == GroupElementKind.Collection)
            .Select(i => i.Id)
            .ToList();

        var partsByCollection = await dbContext
            .CollectionItems.AsNoTracking()
            .Where(i => collectionIds.Contains(i.CollectionId))
            .Select(i => new { i.CollectionId, i.ShowId })
            .ToListAsync(cancellationToken);

        // Список id собираем до запроса: проекция по материализованной коллекции внутри дерева
        // выражений заставляет EF пересобирать её на каждый вызов.
        var neededShowIds = showIds
            .Concat(partsByCollection.Select(p => p.ShowId))
            .Distinct()
            .ToList();

        var audiences = await dbContext
            .Shows.AsNoTracking()
            .Where(s => neededShowIds.Contains(s.Id))
            .Select(s => new { s.Id, s.Audience })
            .ToDictionaryAsync(s => s.Id, s => s.Audience, cancellationToken);

        var result = new Dictionary<Guid, ShowAudience>();
        foreach (var item in items)
        {
            var candidates =
                item.Kind == GroupElementKind.Show
                    ? [item.Id]
                    : partsByCollection
                        .Where(p => p.CollectionId == item.Id)
                        .Select(p => p.ShowId)
                        .ToList();

            foreach (var showId in candidates)
            {
                if (audiences.GetValueOrDefault(showId) is not { } audience)
                    continue;
                if (!result.TryGetValue(item.GroupId, out var current) || audience > current)
                    result[item.GroupId] = audience;
            }
        }

        return result;
    }
}
