using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Streaming;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using PlayoutMapper = ErsatzTV.Application.Playouts.Mapper;

namespace ErsatzTV.Application.Channels;

public class GetChannelStatesForApiHandler(
    IDbContextFactory<TvContext> dbContextFactory,
    IFFmpegSegmenterService ffmpegSegmenterService,
    IDirectStreamSessionTracker directStreamSessionTracker)
    : IRequestHandler<GetChannelStatesForApi, List<ChannelStateResponseModel>>
{
    // a guide entry (program + surrounding filler) never spans anywhere near a day; the time
    // bound also protects against GuideGroup values recycling (mod 10000) elsewhere in a playout
    private static readonly TimeSpan GuideEntryBound = TimeSpan.FromDays(1);

    public async Task<List<ChannelStateResponseModel>> Handle(
        GetChannelStatesForApi request,
        CancellationToken cancellationToken)
    {
        await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);

        List<Channel> channels = await dbContext.Channels
            .AsNoTracking()
            .OrderBy(c => c.SortNumber)
            .ThenBy(c => c.Number)
            .ToListAsync(cancellationToken);

        if (channels.Count == 0)
        {
            return [];
        }

        Dictionary<int, (int SourceChannelId, TimeSpan Offset, DateTime LookupTime)> channelLookup = channels
            .ToDictionary(
                c => c.Id,
                c =>
                {
                    TimeSpan offset = c.PlayoutOffset ?? TimeSpan.Zero;
                    return (c.MirrorSourceChannelId ?? c.Id, offset, request.Now - offset);
                });

        // one covering-item query per distinct lookup time (i.e. per distinct playout offset,
        // typically just one) with a cheap projection; metadata is hydrated in a second query
        // for only the covering items' guide groups
        var coveringBySourceAndTime = new Dictionary<(int SourceChannelId, DateTime LookupTime), CoveringItem>();
        foreach (IGrouping<DateTime, (int SourceChannelId, TimeSpan Offset, DateTime LookupTime)> timeGroup in
                 channelLookup.Values.GroupBy(v => v.LookupTime))
        {
            DateTime lookupTime = timeGroup.Key;
            int[] sourceChannelIds = timeGroup.Map(v => v.SourceChannelId).Distinct().ToArray();

            List<CoveringItem> covering = await dbContext.PlayoutItems
                .AsNoTracking()
                .Where(pi => sourceChannelIds.Contains(pi.Playout.ChannelId))
                .Where(pi => pi.Start <= lookupTime && pi.Finish > lookupTime)
                .Select(pi => new CoveringItem(pi.Id, pi.PlayoutId, pi.Playout.ChannelId, pi.GuideGroup, pi.Start))
                .ToListAsync(cancellationToken);

            foreach (CoveringItem item in covering.OrderBy(ci => ci.Start))
            {
                coveringBySourceAndTime.TryAdd((item.ChannelId, lookupTime), item);
            }
        }

        Dictionary<(int PlayoutId, int GuideGroup), List<PlayoutItem>> itemsByGuideEntry = [];
        if (coveringBySourceAndTime.Count > 0)
        {
            int[] playoutIds = coveringBySourceAndTime.Values.Map(ci => ci.PlayoutId).Distinct().ToArray();
            int[] guideGroups = coveringBySourceAndTime.Values.Map(ci => ci.GuideGroup).Distinct().ToArray();
            DateTime windowStart = channelLookup.Values.Min(v => v.LookupTime) - GuideEntryBound;
            DateTime windowFinish = channelLookup.Values.Max(v => v.LookupTime) + GuideEntryBound;

            List<PlayoutItem> guideEntryItems = await dbContext.PlayoutItems
                .AsNoTracking()
                .Include(pi => pi.MediaItem)
                .ThenInclude(mi => (mi as Episode).EpisodeMetadata)
                .Include(pi => pi.MediaItem)
                .ThenInclude(mi => (mi as Episode).Season)
                .ThenInclude(s => s.Show)
                .ThenInclude(s => s.ShowMetadata)
                .Include(pi => pi.MediaItem)
                .ThenInclude(mi => (mi as Movie).MovieMetadata)
                .Include(pi => pi.MediaItem)
                .ThenInclude(mi => (mi as MusicVideo).MusicVideoMetadata)
                .Include(pi => pi.MediaItem)
                .ThenInclude(mi => (mi as MusicVideo).Artist)
                .ThenInclude(a => a.ArtistMetadata)
                .Include(pi => pi.MediaItem)
                .ThenInclude(mi => (mi as OtherVideo).OtherVideoMetadata)
                .Include(pi => pi.MediaItem)
                .ThenInclude(mi => (mi as Song).SongMetadata)
                .Include(pi => pi.MediaItem)
                .ThenInclude(mi => (mi as Image).ImageMetadata)
                .Include(pi => pi.MediaItem)
                .ThenInclude(mi => (mi as RemoteStream).RemoteStreamMetadata)
                .Where(pi => playoutIds.Contains(pi.PlayoutId) && guideGroups.Contains(pi.GuideGroup))
                .Where(pi => pi.Start <= windowFinish && pi.Finish > windowStart)
                .ToListAsync(cancellationToken);

            itemsByGuideEntry = guideEntryItems
                .GroupBy(pi => (pi.PlayoutId, pi.GuideGroup))
                .ToDictionary(g => g.Key, g => g.OrderBy(pi => pi.Start).ToList());
        }

        return channels
            .Map(channel =>
            {
                (int sourceChannelId, TimeSpan offset, DateTime lookupTime) = channelLookup[channel.Id];
                ChannelNowPlayingResponseModel nowPlaying = null;

                if (coveringBySourceAndTime.TryGetValue((sourceChannelId, lookupTime), out CoveringItem covering) &&
                    itemsByGuideEntry.TryGetValue(
                        (covering.PlayoutId, covering.GuideGroup),
                        out List<PlayoutItem> guideEntry))
                {
                    // like the XMLTV guide, surface the program rather than its filler: use the
                    // covering item itself when it is a program part, otherwise the guide entry's
                    // first program item; an entry with no program item (e.g. offline fallback
                    // filler) stays null
                    PlayoutItem coveringItem = guideEntry.Find(pi => pi.Id == covering.Id);
                    PlayoutItem displayItem = coveringItem?.FillerKind == FillerKind.None
                        ? coveringItem
                        : guideEntry.Find(pi => pi.FillerKind == FillerKind.None);

                    if (displayItem is not null)
                    {
                        DateTime start = guideEntry[0].Start;
                        DateTime finish = displayItem.GuideFinish ?? guideEntry.Max(pi => pi.Finish);

                        nowPlaying = new ChannelNowPlayingResponseModel(
                            PlayoutMapper.GetDisplayTitle(displayItem.MediaItem, Optional(displayItem.ChapterTitle)),
                            new DateTimeOffset(start + offset, TimeSpan.Zero),
                            new DateTimeOffset(finish + offset, TimeSpan.Zero));
                    }
                }

                return new ChannelStateResponseModel(
                    channel.Id,
                    channel.Number,
                    ffmpegSegmenterService.IsActive(channel.Number) ||
                    directStreamSessionTracker.IsActive(channel.Number),
                    nowPlaying);
            })
            .ToList();
    }

    private sealed record CoveringItem(int Id, int PlayoutId, int ChannelId, int GuideGroup, DateTime Start);
}
