using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Broadcast.Bumpers;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Media;
using TeleWave.Domain.Programming;
using TeleWave.Domain.Programming.Planning;

namespace TeleWave.Application.Programming.Planning;

/// <summary>
/// Данные, которыми подставляются плейсхолдеры заставок одного прогона: названия шоу, годы, жанры,
/// подписи серий, названия слотов.
///
/// Грузится только запрошенное: если ни в одной строке нет <c>{next.genre}</c>, жанры не читаются
/// вовсе. Иначе каждая генерация тянула бы весь справочник ради текста, который никто не написал.
/// </summary>
internal sealed class BumperFacts
{
    private readonly IReadOnlyList<PlannedItem> _items;
    private readonly TimeSpan _offset;
    private readonly Channel _channel;
    private readonly IReadOnlyDictionary<Guid, ShowFact> _shows;
    private readonly IReadOnlyDictionary<Guid, SlotFact> _slots;

    private BumperFacts(
        IReadOnlyList<PlannedItem> items,
        Channel channel,
        IReadOnlyDictionary<Guid, ShowFact> shows,
        IReadOnlyDictionary<Guid, SlotFact> slots
    )
    {
        _items = items;
        _channel = channel;
        _offset = TimeSpan.FromMinutes(channel.UtcOffsetMinutes);
        _shows = shows;
        _slots = slots;
    }

    public static async Task<BumperFacts> LoadAsync(
        IAppDbContext dbContext,
        Channel channel,
        IReadOnlyList<PlannedItem> items,
        IReadOnlySet<string> tokens,
        CancellationToken cancellationToken
    )
    {
        var announces = tokens.Any(token =>
            token.StartsWith("tonight.") || token.StartsWith("tomorrow.")
        );

        // Имена нужны соседям заставки, а при анонсах — и всем программам ленты: анонсировать
        // можно то, что стоит через несколько часов, а не только следующее.
        var showIds = items
            .Where(i =>
                i.Kind == PlannedItemKind.Bumper || (announces && i.Kind == PlannedItemKind.Program)
            )
            .SelectMany(i => new[] { i.FromShowId, i.ToShowId, i.ShowId })
            .Where(id => id is not null && id != Guid.Empty)
            .Select(id => id!.Value)
            .Distinct()
            .ToList();

        var shows =
            showIds.Count == 0
                ? []
                : await LoadShowsAsync(dbContext, showIds, tokens, cancellationToken);

        // Слоты нужны и названию, и анонсам: «сегодня вечером» — это первая программа прайма,
        // а что считать праймом, решает дейпарт слота, проставленный админом.
        var needsSlots = tokens.Any(token =>
            token == "slot" || token.StartsWith("tonight.") || token.StartsWith("tomorrow.")
        );
        var slots = needsSlots ? await LoadSlotsAsync(dbContext, items, cancellationToken) : [];

        return new BumperFacts(items, channel, shows, slots);
    }

    /// <summary>Контекст одной заставки: соседи по ленте, время показа и данные канала.</summary>
    public BumperContext Context(PlannedItem item, int index)
    {
        var from = Show(item.FromShowId);
        var to = Show(item.ToShowId);
        var nextProgram = FindProgram(index, forward: true);
        var previousProgram = FindProgram(index, forward: false);

        return new BumperContext(
            _channel.Name,
            _channel.Number,
            item.StartsAtUtc.ToOffset(_offset),
            from?.Name,
            to?.Name,
            EpisodeOf(from, previousProgram, item.FromShowId),
            EpisodeOf(to, nextProgram, item.ToShowId),
            to?.Year,
            to?.Genre,
            nextProgram is { } next
                ? TimeOnly.FromDateTime(next.StartsAtUtc.ToOffset(_offset).DateTime)
                : null,
            item.SlotId is { } slotId && _slots.TryGetValue(slotId, out var slot)
                ? slot.Title
                : null,
            Announcement(index, item, BroadcastDate(item.StartsAtUtc)),
            Announcement(index, item, BroadcastDate(item.StartsAtUtc).AddDays(1))
        );
    }

    /// <summary>
    /// Что анонсировать на указанные вещательные сутки: первая программа прайма, которая ещё
    /// не началась. Анонс «сегодня в 20:00», сказанный в девять вечера, — хуже, чем его отсутствие,
    /// поэтому смотрим только вперёд по ленте.
    /// </summary>
    private BumperAnnouncement? Announcement(int index, PlannedItem bumper, DateOnly date)
    {
        for (var i = index + 1; i < _items.Count; i++)
        {
            var item = _items[i];
            if (item.Kind != PlannedItemKind.Program || item.ShowId is not { } showId)
                continue;
            if (BroadcastDate(item.StartsAtUtc) != date)
                continue;
            if (item.SlotId is not { } slotId || _slots.GetValueOrDefault(slotId)?.IsPrime != true)
                continue;
            if (item.StartsAtUtc <= bumper.EndsAtUtc)
                continue;

            if (_shows.GetValueOrDefault(showId) is not { } show)
                continue;

            return new BumperAnnouncement(
                show.Name,
                TimeOnly.FromDateTime(item.StartsAtUtc.ToOffset(_offset).DateTime)
            );
        }

        return null;
    }

    /// <summary>
    /// Вещательные сутки момента: ночной блок до <see cref="Channel.DayStartTime"/> относится
    /// к предыдущему дню, иначе «сегодня вечером» в час ночи означало бы уже следующий вечер.
    /// </summary>
    private DateOnly BroadcastDate(DateTimeOffset momentUtc)
    {
        var local = momentUtc.ToOffset(_offset);
        var date = DateOnly.FromDateTime(local.DateTime);
        return TimeOnly.FromDateTime(local.DateTime) >= _channel.DayStartTime
            ? date
            : date.AddDays(-1);
    }

    /// <summary>Подпись серии соседней программы — только если это та же самая программа.</summary>
    private static string? EpisodeOf(ShowFact? show, PlannedItem? neighbour, Guid? showId)
    {
        if (show is null || neighbour is null || neighbour.ShowId != showId)
            return null;
        return neighbour.UnitIndex is { } index && index >= 0 && index < show.Episodes.Count
            ? show.Episodes[index]
            : null;
    }

    private ShowFact? Show(Guid? showId) =>
        showId is { } id && _shows.TryGetValue(id, out var fact) ? fact : null;

    /// <summary>Ближайшая программа по ленте в заданную сторону — стык может быть длиннее одной врезки.</summary>
    private PlannedItem? FindProgram(int index, bool forward)
    {
        var step = forward ? 1 : -1;
        for (var i = index + step; i >= 0 && i < _items.Count; i += step)
            if (_items[i].Kind == PlannedItemKind.Program)
                return _items[i];
        return null;
    }

    private static async Task<Dictionary<Guid, ShowFact>> LoadShowsAsync(
        IAppDbContext dbContext,
        IReadOnlyList<Guid> showIds,
        IReadOnlySet<string> tokens,
        CancellationToken cancellationToken
    )
    {
        var shows = await dbContext
            .Shows.AsNoTracking()
            .Where(s => showIds.Contains(s.Id))
            .Select(s => new
            {
                s.Id,
                s.Name,
                s.Year,
            })
            .ToListAsync(cancellationToken);

        var genres = tokens.Contains("next.genre")
            ? await (
                from link in dbContext.ShowGenres.AsNoTracking()
                join genre in dbContext.Genres.AsNoTracking() on link.GenreId equals genre.Id
                where showIds.Contains(link.ShowId) && link.IsPrimary
                select new { link.ShowId, genre.Name }
            ).ToDictionaryAsync(g => g.ShowId, g => g.Name, cancellationToken)
            : [];

        var episodes =
            tokens.Contains("next.episode") || tokens.Contains("now.episode")
                ? await LoadEpisodesAsync(dbContext, showIds, cancellationToken)
                : [];

        return shows.ToDictionary(
            s => s.Id,
            s => new ShowFact(
                s.Name,
                s.Year,
                genres.GetValueOrDefault(s.Id),
                episodes.GetValueOrDefault(s.Id) ?? []
            )
        );
    }

    /// <summary>
    /// Подписи серий в том же порядке, в каком их разворачивает планировщик: только серии с готовым
    /// ассетом, по позиции. Иначе номер в заставке разошёлся бы с тем, что реально играет.
    /// </summary>
    private static async Task<Dictionary<Guid, List<string?>>> LoadEpisodesAsync(
        IAppDbContext dbContext,
        IReadOnlyList<Guid> showIds,
        CancellationToken cancellationToken
    )
    {
        var rows = await (
            from show in dbContext.Shows.AsNoTracking()
            from episode in show.Episodes
            join asset in dbContext.MediaAssets.AsNoTracking()
                on episode.MediaAssetId equals asset.Id
            where
                showIds.Contains(show.Id)
                && asset.Status == MediaAssetStatus.Ready
                && asset.Duration != null
            orderby episode.Position
            select new
            {
                show.Id,
                episode.Season,
                episode.Episode,
                episode.Title,
            }
        ).ToListAsync(cancellationToken);

        return rows.GroupBy(r => r.Id)
            .ToDictionary(
                g => g.Key,
                g =>
                    g.Select(r => BumperPlaceholders.Episode(r.Season, r.Episode) ?? r.Title)
                        .ToList<string?>()
            );
    }

    private static async Task<Dictionary<Guid, SlotFact>> LoadSlotsAsync(
        IAppDbContext dbContext,
        IReadOnlyList<PlannedItem> items,
        CancellationToken cancellationToken
    )
    {
        var slotIds = items
            .Select(i => i.SlotId)
            .Where(id => id is not null)
            .Select(id => id!.Value)
            .Distinct()
            .ToList();

        return await dbContext
            .Slots.AsNoTracking()
            .Where(s => slotIds.Contains(s.Id))
            .ToDictionaryAsync(
                s => s.Id,
                s => new SlotFact(s.Title, s.Daypart == Daypart.Prime),
                cancellationToken
            );
    }

    /// <summary>Слот глазами заставки: как он называется и прайм ли это.</summary>
    private sealed record SlotFact(string Title, bool IsPrime);

    private sealed record ShowFact(
        string Name,
        int? Year,
        string? Genre,
        IReadOnlyList<string?> Episodes
    );
}
