using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Programming.Templates;
using TeleWave.Domain.Programming.Planning;

namespace TeleWave.Application.Programming.Planning;

/// <summary>
/// Пост-проверки по готовой ленте (см. 3.8, 5.2). Только предупреждения: пересборки слота при
/// нарушении нет намеренно — она сделала бы результат зависимым от порядка проверок и обесценила
/// бы трейс «почему это здесь».
/// </summary>
public sealed class PostCheckRunner(IAppDbContext dbContext)
{
    public async Task<IReadOnlyList<PlanningWarning>> RunAsync(
        IReadOnlyList<PlannedItem> items,
        PlanningRules? rules,
        int utcOffsetMinutes,
        TimeOnly dayStartTime,
        CancellationToken cancellationToken
    )
    {
        if (rules is null || items.Count == 0)
            return [];

        var warnings = new List<PlanningWarning>();
        var offset = TimeSpan.FromMinutes(utcOffsetMinutes);

        if (rules.MaxBreakMinutesPerHour is { } breakCap and > 0)
            warnings.AddRange(CheckBreaks(items, offset, breakCap));

        if (rules.MaxFallbackSharePercent is { } fallbackCap and > 0)
            warnings.AddRange(CheckFallbackShare(items, fallbackCap));

        if (rules.MaxGenreSharePercent is { } genreCap and > 0)
            warnings.AddRange(
                await CheckGenreShareAsync(items, offset, dayStartTime, genreCap, cancellationToken)
            );

        return warnings;
    }

    private static IEnumerable<PlanningWarning> CheckBreaks(
        IReadOnlyList<PlannedItem> items,
        TimeSpan offset,
        int capMinutes
    )
    {
        var byHour = items
            .Where(IsBreak)
            .GroupBy(i => Truncate(i.StartsAtUtc + offset, TimeSpan.FromHours(1)))
            .Select(g => new
            {
                Hour = g.Key,
                Minutes = g.Sum(i => (i.EndsAtUtc - i.StartsAtUtc).TotalMinutes),
            })
            .Where(x => x.Minutes > capMinutes)
            .OrderBy(x => x.Hour);

        foreach (var hour in byHour)
            yield return new PlanningWarning(
                PlanningWarningKind.BreakLimitExceeded,
                null,
                $"{hour.Hour:dd.MM HH:mm} — врезок {hour.Minutes:0} мин при потолке {capMinutes}."
            );
    }

    private static IEnumerable<PlanningWarning> CheckFallbackShare(
        IReadOnlyList<PlannedItem> items,
        int capPercent
    )
    {
        var total = items.Sum(i => (i.EndsAtUtc - i.StartsAtUtc).TotalMinutes);
        if (total <= 0)
            yield break;

        // Считается аварийный запас, а не фоновый слой: слоты фона — это программы со своей группой,
        // и их доля ничего не говорит о нехватке. Конец вещания сюда тоже не входит: это осознанная
        // настройка, а не дыра.
        var fallback = items
            .Where(i => i.Kind == PlannedItemKind.Fallback)
            .Sum(i => (i.EndsAtUtc - i.StartsAtUtc).TotalMinutes);

        var share = fallback / total * 100;
        if (share > capPercent)
            yield return new PlanningWarning(
                PlanningWarningKind.FallbackShareExceeded,
                null,
                $"Аварийный запас занял {share:0}% эфира при норме {capPercent}% — контента не хватает."
            );
    }

    /// <summary>
    /// Доля жанра за вещательные сутки. Считается по основному жанру шоу: у контента их бывает
    /// несколько, но «доля боевиков за день» осмысленна только по одному, иначе сумма долей
    /// перевалит за сто процентов и порог потеряет смысл.
    /// </summary>
    private async Task<IReadOnlyList<PlanningWarning>> CheckGenreShareAsync(
        IReadOnlyList<PlannedItem> items,
        TimeSpan offset,
        TimeOnly dayStartTime,
        int capPercent,
        CancellationToken cancellationToken
    )
    {
        var showIds = items
            .Where(i => i.Kind == PlannedItemKind.Program && i.ShowId is not null)
            .Select(i => i.ShowId!.Value)
            .Distinct()
            .ToList();
        if (showIds.Count == 0)
            return [];

        var primaryGenre = await dbContext
            .Shows.AsNoTracking()
            .Where(s => showIds.Contains(s.Id))
            .Select(s => new
            {
                s.Id,
                GenreId = s
                    .Genres.Where(g => g.IsPrimary)
                    .Select(g => (Guid?)g.GenreId)
                    .FirstOrDefault(),
            })
            .Where(s => s.GenreId != null)
            .ToDictionaryAsync(s => s.Id, s => s.GenreId!.Value, cancellationToken);
        if (primaryGenre.Count == 0)
            return [];

        var genreNames = await dbContext
            .Genres.AsNoTracking()
            .Where(g => primaryGenre.Values.Contains(g.Id))
            .ToDictionaryAsync(g => g.Id, g => g.Name, cancellationToken);

        var warnings = new List<PlanningWarning>();
        var days = items
            .Where(i => i.Kind == PlannedItemKind.Program && i.ShowId is not null)
            .GroupBy(i => BroadcastDate(i.StartsAtUtc + offset, dayStartTime));

        // Края горизонта: прогон начинается и кончается посреди вещательных суток, и в этих
        // огрызках один фильм честно занимает больше половины «дня». Доля от неполных суток
        // ничего не значит, а предупреждение по ней приходит на каждое применение.
        var from = items.Min(i => i.StartsAtUtc) + offset;
        var until = items.Max(i => i.EndsAtUtc) + offset;

        foreach (var day in days)
        {
            if (!IsWholeDay(day.Key, dayStartTime, from, until))
                continue;

            var total = day.Sum(i => (i.EndsAtUtc - i.StartsAtUtc).TotalMinutes);
            if (total <= 0)
                continue;

            var byGenre = day.Where(i => primaryGenre.ContainsKey(i.ShowId!.Value))
                .GroupBy(i => primaryGenre[i.ShowId!.Value])
                .Select(g => new
                {
                    GenreId = g.Key,
                    Minutes = g.Sum(i => (i.EndsAtUtc - i.StartsAtUtc).TotalMinutes),
                })
                .Where(g => g.Minutes / total * 100 > capPercent);

            foreach (var genre in byGenre)
                warnings.Add(
                    new PlanningWarning(
                        PlanningWarningKind.GenreShareExceeded,
                        null,
                        $"{day.Key:dd.MM} — «{(genreNames.TryGetValue(genre.GenreId, out var name) ? name : genre.GenreId)}» "
                            + $"занял {genre.Minutes / total * 100:0}% суток при норме {capPercent}%."
                    )
                );
        }

        return warnings;
    }

    private static bool IsBreak(PlannedItem item) =>
        item.Kind is PlannedItemKind.Ad or PlannedItemKind.Promo or PlannedItemKind.Bumper;

    private static DateTimeOffset Truncate(DateTimeOffset value, TimeSpan step) =>
        value.AddTicks(-(value.Ticks % step.Ticks));

    /// <summary>Целиком ли вещательные сутки попали в прогон — только по таким считаются доли.</summary>
    private static bool IsWholeDay(
        DateOnly date,
        TimeOnly dayStartTime,
        DateTimeOffset from,
        DateTimeOffset until
    )
    {
        var start = new DateTimeOffset(date.ToDateTime(dayStartTime), from.Offset);
        return start >= from && start.AddDays(1) <= until;
    }

    /// <summary>Вещательные сутки момента: ночь до <paramref name="dayStartTime"/> — вчерашний день.</summary>
    private static DateOnly BroadcastDate(DateTimeOffset channelTime, TimeOnly dayStartTime) =>
        DateOnly.FromDateTime(
            TimeOnly.FromDateTime(channelTime.DateTime) >= dayStartTime
                ? channelTime.Date
                : channelTime.Date.AddDays(-1)
        );
}
