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

/// <summary>
/// Раскладывает свободное время канала по полосам выбранного профиля и подбирает под каждую полосу
/// группу. Ничего не пишет: результат — план, который показывают в предпросмотре и по которому
/// потом создают слоты. Один и тот же расчёт для обеих операций — иначе показанное и созданное
/// разошлись бы.
///
/// Подбор намеренно объяснимый: жёстко отсекается неподходящее по рейтингу, дальше баллы за тип
/// контента, длину единицы и запас серий, минус за повторное использование в тех же сутках. Админ
/// должен понимать, почему в прайм встала именно эта группа.
/// </summary>
public sealed class GridPlanner(
    IAppDbContext dbContext,
    DynamicGroupResolver dynamicResolver,
    GroupElementResolver elementResolver
)
{
    public const string MainLayerName = "Основная сетка";
    public const string WeekendLayerName = "Выходные";

    /// <summary>Слот короче этого смысла не имеет — остаток отдаётся соседнему слоту.</summary>
    private const int MinSlotMinutes = 15;

    /// <summary>Длины слотов округляются до пяти минут: сетка должна читаться, а не считаться.</summary>
    private const int SlotStepMinutes = 5;

    /// <summary>Единица длиннее часа — это кино; короче — серия. Порог грубый и намеренно один.</summary>
    private const int FeatureLengthMinutes = 60;

    public async Task<Result<GridPlan>> BuildAsync(
        Guid channelId,
        GridProfileKind profileKind,
        GridGenerationMode mode,
        CancellationToken cancellationToken
    )
    {
        var channel = await dbContext
            .Channels.AsNoTracking()
            .FirstOrDefaultAsync(c => c.Id == channelId, cancellationToken);
        if (channel is null)
            return Result.Failure<GridPlan>(ChannelErrors.NotFound);

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

        var groups = await LoadGroupsAsync(cancellationToken);
        if (groups.Count == 0)
            return Result.Failure<GridPlan>(TemplateErrors.NoGroupsToGenerate);

        var profile = GridProfiles.Get(profileKind);
        var run = new PlanRun(profile, groups, channel.DayStartTime);

        // Занятым считается время слотов всех слоёв, включая выключенные: место, которое админ
        // отвёл под слот и временно погасил, — не дыра, и застраивать его генератору нечего.
        var existing = template.Layers.SelectMany(l => l.Slots).ToList();

        var slots =
            mode == GridGenerationMode.Rebuild ? BuildWeek(run) : FillExistingGaps(run, existing);

        var freeMinutes =
            mode == GridGenerationMode.Rebuild
                ? 7 * GridCoverage.MinutesInDay
                : GridCoverage
                    .Week.SelectMany(day => GridCoverage.Gaps(existing, day, run.DayStart))
                    .Sum(gap => gap.To - gap.From);

        // Аварийная группа отдаётся отдельным полем, а не замечанием: замечания — про то, что пошло
        // не так, а это штатное действие, и в предпросмотре у него своя строка.
        var fallback = groups.MaxBy(g => g.UnitCount);
        var needsFallback = template.FallbackGroupId is null && fallback is not null;

        var everyDaySpan = mode == GridGenerationMode.Rebuild && profile.Weekend.Count > 0 ? 5 : 7;
        var covered = slots.Sum(s => s.DurationMinutes * (s.Weekday is null ? everyDaySpan : 1));

        return Result.Success(
            new GridPlan(
                profile,
                mode,
                slots,
                run.Notes,
                mode == GridGenerationMode.Rebuild ? existing.Count : 0,
                needsFallback ? fallback!.Id : null,
                needsFallback ? fallback!.Name : null,
                freeMinutes,
                covered
            )
        );
    }

    /// <summary>
    /// Неделя с нуля: будни ложатся слотами «каждый день» в основной слой, выходные — отдельным
    /// слоем поверх. Слой выходных перекрывает будний целиком, поэтому расхождение по субботе
    /// и воскресенью получается без дублирования всей недели по дням.
    /// </summary>
    private static List<PlannedSlot> BuildWeek(PlanRun run)
    {
        var wholeDay = new List<(int From, int To)> { (0, GridCoverage.MinutesInDay) };
        var slots = FillDay(run.Profile.Weekdays, wholeDay, null, GridPlanLayer.Main, run);

        if (run.Profile.Weekend.Count == 0)
            return slots;

        foreach (var weekday in new[] { 6, 0 })
            slots.AddRange(
                FillDay(run.Profile.Weekend, wholeDay, weekday, GridPlanLayer.Weekend, run)
            );

        return slots;
    }

    /// <summary>
    /// Заполнение дыр: считается по каждому дню отдельно, поэтому слоты получают явный день недели.
    /// Интервалы, свободные во все семь дней, схлопываются в один слот «каждый день» — иначе первая
    /// же генерация на пустом канале выдала бы семь одинаковых столбцов.
    ///
    /// Полосы берутся будние на все семь дней: отдельные выходные строятся слоем поверх буднего,
    /// а слой имеет смысл только когда он покрывает день целиком. Заплатка в дыре этого не даёт,
    /// поэтому выходные — дело режима пересборки.
    /// </summary>
    private static List<PlannedSlot> FillExistingGaps(PlanRun run, IReadOnlyList<Slot> existing)
    {
        var entries = new List<(int Weekday, PlannedSlot Slot)>();

        foreach (var weekday in GridCoverage.Week)
        {
            var gaps = GridCoverage.Gaps(existing, weekday, run.DayStart);
            foreach (
                var slot in FillDay(run.Profile.Weekdays, gaps, weekday, GridPlanLayer.Main, run)
            )
                entries.Add((weekday, slot));
        }

        return Collapse(entries);
    }

    private static List<PlannedSlot> Collapse(List<(int Weekday, PlannedSlot Slot)> entries)
    {
        var result = new List<PlannedSlot>();

        foreach (var same in entries.GroupBy(e => e.Slot with { Weekday = null }))
        {
            var days = same.Select(e => e.Weekday).Distinct().Count();
            if (days == GridCoverage.Week.Length)
                result.Add(same.Key);
            else
                result.AddRange(same.Select(e => e.Slot));
        }

        return result;
    }

    /// <summary>Раскладывает свободные интервалы одних суток по полосам профиля.</summary>
    private static List<PlannedSlot> FillDay(
        IReadOnlyList<GridBand> bands,
        IReadOnlyList<(int From, int To)> free,
        int? weekday,
        GridPlanLayer layer,
        PlanRun run
    )
    {
        // Счётчик использований живёт внутри суток: группа, занятая утром, уступает вечером другой,
        // но на следующий день снова доступна — иначе раскладка зависела бы от порядка дней.
        var used = new Dictionary<Guid, int>();
        var slots = new List<PlannedSlot>();

        foreach (var band in bands)
        foreach (var (bandFrom, bandTo) in BandPieces(band, run.DayStart))
        foreach (var (freeFrom, freeTo) in free)
        {
            var from = Math.Max(bandFrom, freeFrom);
            var to = Math.Min(bandTo, freeTo);
            if (to - from < MinSlotMinutes)
                continue;

            slots.AddRange(FillPiece(band, from, to, weekday, layer, run, used));
        }

        return [.. slots.OrderBy(s => GridCoverage.OffsetInDay(s.Start, run.DayStart))];
    }

    /// <summary>
    /// Полоса в смещениях от начала вещательных суток. Начало суток канала произвольно, поэтому
    /// полоса может перевалить через их конец — тогда она разрезается на два куска.
    /// </summary>
    private static IEnumerable<(int From, int To)> BandPieces(GridBand band, TimeOnly dayStart)
    {
        var from = GridCoverage.OffsetInDay(band.From, dayStart);
        var to = from + band.LengthMinutes;

        if (to <= GridCoverage.MinutesInDay)
        {
            yield return (from, to);
            yield break;
        }

        yield return (from, GridCoverage.MinutesInDay);
        yield return (0, to - GridCoverage.MinutesInDay);
    }

    /// <summary>Режет кусок полосы на слоты целевой длины и подбирает каждому наполнение.</summary>
    private static IEnumerable<PlannedSlot> FillPiece(
        GridBand band,
        int from,
        int to,
        int? weekday,
        GridPlanLayer layer,
        PlanRun run,
        Dictionary<Guid, int> used
    )
    {
        var length = to - from;
        var count =
            band.BlockMinutes <= 0
                ? 1
                : Math.Max(
                    1,
                    (int)
                        Math.Round(
                            length / (double)band.BlockMinutes,
                            MidpointRounding.AwayFromZero
                        )
                );

        var step = Math.Max(MinSlotMinutes, length / count / SlotStepMinutes * SlotStepMinutes);
        var cursor = from;

        while (cursor < to)
        {
            var duration = Math.Min(step, to - cursor);
            // Хвост короче минимального слота приклеиваем к текущему: два слота по семь минут
            // читаются как ошибка сетки, а не как решение.
            if (to - cursor - duration < MinSlotMinutes)
                duration = to - cursor;

            // Якорь — только на первом слоте прайма: жёсткий старт у каждого слота подряд заставил
            // бы генератор крутить фон перед каждым из них.
            var anchor = cursor == from && band.Daypart == Daypart.Prime;
            var slot = Compose(band, weekday, layer, cursor, duration, anchor, run, used);
            if (slot is not null)
                yield return slot;

            cursor += duration;
        }
    }

    private static PlannedSlot? Compose(
        GridBand band,
        int? weekday,
        GridPlanLayer layer,
        int offset,
        int duration,
        bool anchor,
        PlanRun run,
        Dictionary<Guid, int> used
    )
    {
        var start = GridCoverage.AtOffset(offset, run.DayStart);

        if (band.SlotKind == SlotKind.SignOff)
            return New(band.Title, null, SlotBlockMode.FillSlot, 1, null, null);

        if (band.SlotKind == SlotKind.Repeat)
            return New(
                band.Title,
                null,
                SlotBlockMode.FillSlot,
                1,
                null,
                new RepeatSource(band.RepeatDaysAgo, band.RepeatFrom, duration)
            );

        var group = Pick(band, run, used);
        if (group is null)
        {
            run.Note(
                $"Полоса «{band.Title}» ({band.From:HH\\:mm}–{band.To:HH\\:mm}): подходящей группы "
                    + "нет — интервал останется незакрытым."
            );
            return null;
        }

        used[group.Id] = used.GetValueOrDefault(group.Id) + 1;
        run.WarnIfThin(band, group, weekday);

        var blockMode = band.UnitsPerBlock > 0 ? SlotBlockMode.Count : SlotBlockMode.FillSlot;
        return New(
            group.Name,
            group.Id,
            blockMode,
            Math.Max(1, band.UnitsPerBlock),
            new SlotStrategy(band.Strategy, RestartOnEnd: true, CooldownDays: band.CooldownDays),
            null
        );

        PlannedSlot New(
            string title,
            Guid? groupId,
            SlotBlockMode blockMode,
            int blockValue,
            SlotStrategy? strategy,
            RepeatSource? repeat
        ) =>
            new(
                layer,
                weekday,
                start,
                duration,
                title,
                band.Daypart,
                band.SlotKind,
                groupId,
                groupId is null ? null : title,
                blockMode,
                blockValue,
                strategy,
                repeat,
                anchor
            );
    }

    /// <summary>
    /// Группа под полосу. Рейтинг — жёсткий отсев: в детское время строгое не ставится, а ночной
    /// взрослый блок без взрослого содержимого не имеет смысла. Остальное — баллы.
    /// </summary>
    private static GroupCandidate? Pick(GridBand band, PlanRun run, Dictionary<Guid, int> used) =>
        run
            .Groups.Where(g => Allowed(band, g))
            .OrderByDescending(g => Score(band, g, used))
            .ThenBy(g => g.Name, StringComparer.CurrentCultureIgnoreCase)
            .FirstOrDefault();

    private static bool Allowed(GridBand band, GroupCandidate group)
    {
        // Группа без рейтингов проходит любой потолок — так же ведут себя фильтр набора и
        // планировщик: источники проставляют рейтинг далеко не всему.
        if (band.MaxAudience is { } max && group.Strictest is { } strictest && strictest > max)
            return false;

        return band.MinAudience is not { } min
            || (group.Strictest is { } audience && audience >= min);
    }

    private static int Score(GridBand band, GroupCandidate group, Dictionary<Guid, int> used)
    {
        var score = Math.Min(group.UnitCount, 60);

        if (band.PreferKind is { } kind)
            score += group.DominantKind == kind ? 100 : -40;

        var wantsFeature = band.PreferKind == ShowKind.Single;
        var isFeature = group.AverageUnitMinutes >= FeatureLengthMinutes;
        if (band.PreferKind is not null && wantsFeature == isFeature)
            score += 30;

        // Одна и та же группа во всех полосах суток — формально валидная, но бессмысленная сетка.
        return score - used.GetValueOrDefault(group.Id) * 50;
    }

    private async Task<List<GroupCandidate>> LoadGroupsAsync(CancellationToken cancellationToken)
    {
        var groups = await dbContext
            .Groups.AsNoTracking()
            .Include(g => g.Items)
            .ToListAsync(cancellationToken);

        var candidates = new List<GroupCandidate>();

        foreach (var group in groups)
        {
            // Состав считается, а не читается из кэша: у группы по правилу кэш отстаёт от
            // библиотеки, и генератор поставил бы в эфир опустевшую группу.
            var composition = await dynamicResolver.ResolveAsync(group, cancellationToken);
            if (composition.Count == 0)
                continue;

            var info = await elementResolver.ResolveAsync(
                composition.Select(e => (e.Kind, e.Id)),
                cancellationToken
            );

            var units = 0;
            var duration = TimeSpan.Zero;
            var seriesUnits = 0;
            var singleUnits = 0;
            ShowAudience? strictest = null;

            foreach (var element in composition)
            {
                if (!info.TryGetValue((element.Kind, element.Id), out var resolved))
                    continue;

                units += resolved.UnitCount;
                duration += resolved.TotalDuration;

                // У коллекции своего типа нет: франшиза — это почти всегда полнометражки.
                if (resolved.ShowKind == ShowKind.Series)
                    seriesUnits += resolved.UnitCount;
                else
                    singleUnits += resolved.UnitCount;

                if (
                    resolved.Audience is { } audience
                    && (strictest is null || audience > strictest)
                )
                    strictest = audience;
            }

            // Группа без готовых единиц в эфир не годится: слот встал бы, а место закрыл фон.
            if (units == 0)
                continue;

            candidates.Add(
                new GroupCandidate(
                    group.Id,
                    group.Name,
                    units,
                    strictest,
                    seriesUnits >= singleUnits ? ShowKind.Series : ShowKind.Single,
                    duration.TotalMinutes / units
                )
            );
        }

        return candidates;
    }

    /// <summary>Группа глазами раскладки: чем её можно закрыть и насколько её хватит.</summary>
    private sealed record GroupCandidate(
        Guid Id,
        string Name,
        int UnitCount,
        ShowAudience? Strictest,
        ShowKind DominantKind,
        double AverageUnitMinutes
    );

    /// <summary>Общее состояние одного разбора: профиль, кандидаты и накопленные замечания.</summary>
    private sealed class PlanRun(
        GridProfile profile,
        IReadOnlyList<GroupCandidate> groups,
        TimeOnly dayStart
    )
    {
        private readonly List<string> _notes = [];
        private readonly HashSet<string> _seen = [];

        public GridProfile Profile { get; } = profile;
        public IReadOnlyList<GroupCandidate> Groups { get; } = groups;
        public TimeOnly DayStart { get; } = dayStart;
        public IReadOnlyList<string> Notes => _notes;

        public void Note(string text)
        {
            if (_seen.Add(text))
                _notes.Add(text);
        }

        /// <summary>
        /// Предупреждение о нехватке состава — то же, о чём потом скажут проверки сетки. Показать
        /// это до создания слотов честнее, чем дать админу нажать кнопку и получить список ошибок.
        /// </summary>
        public void WarnIfThin(GridBand band, GroupCandidate group, int? weekday)
        {
            var perWeek = (weekday is null ? 7 : 1) * Math.Max(1, band.UnitsPerBlock);
            if (group.UnitCount >= perWeek)
                return;

            Note(
                $"В группе «{group.Name}» {group.UnitCount} единиц при {perWeek} выходах в неделю "
                    + $"в полосе «{band.Title}» — повторы пойдут чаще, чем раз в неделю."
            );
        }
    }
}
