using System.Threading.Channels;
using ErsatzTV.Application;
using ErsatzTV.Application.ProgramSchedules;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Scheduling;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using LanguageExt;
using MediatR;
using NSubstitute;
using NUnit.Framework;
using Shouldly;

namespace ErsatzTV.Tests.Application.ProgramSchedules;

/// <summary>
///     Release gate for issue #126: proves the flat <see cref="ScheduleItemResponseModel" /> produced by
///     the GET endpoint carries enough information to reconstruct an identical PUT (ReplaceProgramScheduleItems)
///     with no loss — a GET → map → PUT → GET fixed point across every subtype and field family. Also documents
///     the deliberate <c>EnforceProperties</c> normalization (shuffle → Dynamic/One/None).
/// </summary>
[TestFixture]
public class ScheduleItemResponseRoundTripTests
{
    private InMemoryTvContext _db = null!;
    private ChannelWriter<IBackgroundServiceRequest> _worker = null!;

    [SetUp]
    public async Task SetUp()
    {
        _db = await InMemoryTvContext.CreateAsync();
        _worker = System.Threading.Channels.Channel.CreateUnbounded<IBackgroundServiceRequest>().Writer;
    }

    [TearDown]
    public async Task TearDown() => await _db.DisposeAsync();

    [Test]
    public async Task Get_Map_Replace_Get_Should_Be_Lossless_Across_All_Subtypes()
    {
        int scheduleId = await SeedScheduleAndReferences(shuffleScheduleItems: false);

        var replaceHandler = new ReplaceProgramScheduleItemsHandler(_db.Factory, _worker);

        // Arrange: establish the initial items through the real write path.
        Either<BaseError, IEnumerable<ProgramScheduleItemViewModel>> seeded =
            await replaceHandler.Handle(new ReplaceProgramScheduleItems(scheduleId, BuildSeedItems()), CancellationToken.None);
        seeded.IsRight.ShouldBeTrue(seeded.LeftToSeq().HeadOrNone().Match(e => e.Value, () => "unknown"));

        // Act: GET (with durations) → flat DTO envelope A
        ScheduleItemsResponseModel envelopeA = await GetItemsEnvelope(scheduleId);
        envelopeA.Items.Count.ShouldBe(6);

        // Map every response item back into a Replace command exactly as the request mapping would, then PUT.
        // Reconstruct in the item's own (stable) Index order, mirroring how the SPA re-submits the ordered list.
        List<ReplaceProgramScheduleItem> reconstructed = envelopeA.Items
            .OrderBy(item => item.Index)
            .Select((item, index) => ToReplaceCommand(item, index))
            .ToList();
        Either<BaseError, IEnumerable<ProgramScheduleItemViewModel>> replaced =
            await replaceHandler.Handle(new ReplaceProgramScheduleItems(scheduleId, reconstructed), CancellationToken.None);
        replaced.IsRight.ShouldBeTrue(replaced.LeftToSeq().HeadOrNone().Match(e => e.Value, () => "unknown"));

        // GET again → envelope B; A and B must be semantically identical (ignoring regenerated row ids).
        ScheduleItemsResponseModel envelopeB = await GetItemsEnvelope(scheduleId);

        envelopeB.Items.Count.ShouldBe(envelopeA.Items.Count);
        envelopeB.TotalDurationEstimate.ShouldBe(envelopeA.TotalDurationEstimate);

        // The GET does not guarantee row order, so pair items up by their (stable) Index before comparing.
        List<ScheduleItemResponseModel> orderedA = envelopeA.Items.OrderBy(i => i.Index).ToList();
        List<ScheduleItemResponseModel> orderedB = envelopeB.Items.OrderBy(i => i.Index).ToList();
        for (var i = 0; i < orderedA.Count; i++)
        {
            AssertSemanticallyEqual(orderedA[i], orderedB[i]);
        }

        // Spot-check that the display/hydration fields actually populated (proves the mapper is non-vacuous).
        ScheduleItemResponseModel multiple = envelopeA.Items.Single(i => i.PlayoutMode == PlayoutMode.Multiple);
        multiple.CollectionName.ShouldBe("Prime Collection");
        multiple.MultipleMode.ShouldBe(MultipleMode.Count);
        multiple.MultipleCount.ShouldBe("2 + 1");
        multiple.FillWithGroupMode.ShouldBe(FillWithGroupMode.FillWithShuffledGroups);
        multiple.PreRollFillerId.ShouldBe(1);
        multiple.PreRollFillerName.ShouldBe("PreRoll");
        multiple.FallbackFillerId.ShouldBe(5);
        multiple.WatermarkIds.ShouldBe([1, 2]);
        multiple.Watermarks.Select(w => w.Name).ShouldBe(["WM One", "WM Two"]);
        multiple.GraphicsElementIds.ShouldBe([1, 2]);
        multiple.GraphicsElements.Select(g => g.Id).ShouldBe([1, 2]);
        multiple.GraphicsElements.Select(g => g.Name).ShouldAllBe(n => !string.IsNullOrWhiteSpace(n));

        ScheduleItemResponseModel one = envelopeA.Items.Single(i =>
            i.PlayoutMode == PlayoutMode.One && i.CollectionType == CollectionType.Collection);
        one.StartType.ShouldBe(StartType.Fixed);
        one.StartTime.ShouldBe(TimeSpan.FromHours(20));
        one.FixedStartTimeBehavior.ShouldBe(FixedStartTimeBehavior.Flexible);
        one.CustomTitle.ShouldBe("News Hour");
        one.PreferredAudioLanguageCode.ShouldBe("eng");
        one.PreferredAudioTitle.ShouldBe("Director Commentary");
        one.PreferredSubtitleLanguageCode.ShouldBe("fra");
        one.SubtitleMode.ShouldBe(ChannelSubtitleMode.Any);

        ScheduleItemResponseModel duration = envelopeA.Items.Single(i => i.PlayoutMode == PlayoutMode.Duration);
        duration.SmartCollectionName.ShouldBe("Smart Picks");
        duration.PlayoutDuration.ShouldBe(TimeSpan.FromMinutes(45));
        duration.TailMode.ShouldBe(TailMode.Filler);
        duration.TailFillerId.ShouldBe(4);
        duration.PlaybackOrder.ShouldBe(PlaybackOrder.Marathon);
        duration.MarathonGroupBy.ShouldBe(MarathonGroupBy.Show);
        duration.MarathonBatchSize.ShouldBe(4);
        // Seeded with DiscardToFillAttempts = 5, but the write path's FixDiscardToFillAttempts zeroes
        // it for any order other than Random/Shuffle (Marathon here) — a deliberate server-side
        // normalization. Pinning it here keeps the lossless round-trip honest: A already holds 0, so
        // the re-submit maps 0 → 0 and B stays equal.
        duration.DiscardToFillAttempts.ShouldBe(0);

        ScheduleItemResponseModel flood = envelopeA.Items.Single(i => i.PlayoutMode == PlayoutMode.Flood);
        flood.PlaylistName.ShouldBe("My Playlist");
        flood.PlaylistGroupId.ShouldBe(9);
        flood.MarathonShuffleGroups.ShouldBeTrue();

        ScheduleItemResponseModel rerun = envelopeA.Items.Single(i => i.CollectionType == CollectionType.RerunFirstRun);
        rerun.RerunCollectionName.ShouldBe("Rerun Bin");

        ScheduleItemResponseModel search = envelopeA.Items.Single(i => i.CollectionType == CollectionType.SearchQuery);
        search.SearchQuery.ShouldBe("genre:comedy");
        search.Name.ShouldBe("Comedy Search");
    }

    [Test]
    public async Task Get_Should_Apply_EnforceProperties_Normalization_When_Shuffle_Enabled()
    {
        int scheduleId = await SeedScheduleAndReferences(shuffleScheduleItems: true);

        var replaceHandler = new ReplaceProgramScheduleItemsHandler(_db.Factory, _worker);
        Either<BaseError, IEnumerable<ProgramScheduleItemViewModel>> seeded = await replaceHandler.Handle(
            new ReplaceProgramScheduleItems(
                scheduleId,
                [
                    // Fixed-start Flood item on a plain collection.
                    MakeReplace(0, PlayoutMode.Flood, CollectionType.Collection) with
                    {
                        StartTime = TimeSpan.FromHours(6),
                        CollectionId = 1,
                        PlaybackOrder = PlaybackOrder.Chronological
                    },
                    // Playlist item carrying a playback order that must be normalized away.
                    MakeReplace(1, PlayoutMode.One, CollectionType.Playlist) with
                    {
                        PlaylistId = 1,
                        PlaybackOrder = PlaybackOrder.Chronological
                    }
                ]),
            CancellationToken.None);
        seeded.IsRight.ShouldBeTrue();

        List<ScheduleItemResponseModel> items = (await GetItemsEnvelope(scheduleId)).Items;

        ScheduleItemResponseModel floodItem = items.Single(i => i.CollectionType == CollectionType.Collection);
        // Flood → One and Fixed → Dynamic are the documented, deliberate rewrites when ShuffleScheduleItems is on.
        floodItem.PlayoutMode.ShouldBe(PlayoutMode.One);
        floodItem.StartType.ShouldBe(StartType.Dynamic);

        ScheduleItemResponseModel playlistItem = items.Single(i => i.CollectionType == CollectionType.Playlist);
        playlistItem.PlaybackOrder.ShouldBe(PlaybackOrder.None);
    }

    private async Task<ScheduleItemsResponseModel> GetItemsEnvelope(int scheduleId)
    {
        var itemsHandler = new GetProgramScheduleItemsHandler(_db.Factory);
        IMediator mediator = Substitute.For<IMediator>();
        mediator.Send(Arg.Any<GetProgramScheduleItems>(), Arg.Any<CancellationToken>())
            .Returns(ci => itemsHandler.Handle((GetProgramScheduleItems)ci[0], (CancellationToken)ci[1]));

        IMediaCollectionRepository repo = Substitute.For<IMediaCollectionRepository>();
        repo.GetItems(Arg.Any<int>()).Returns(new List<MediaItem>());

        var withDurations = new GetProgramScheduleItemsWithDurationsHandler(mediator, repo);
        ProgramScheduleItemsWithDurationViewModel vm =
            await withDurations.Handle(new GetProgramScheduleItemsWithDurations(scheduleId), CancellationToken.None);
        return ScheduleItemResponseMapper.ProjectToResponseModel(vm);
    }

    private static List<ReplaceProgramScheduleItem> BuildSeedItems() =>
    [
        // 0: Fixed-start One on a plain collection, full preferred-audio/subtitle + custom title.
        MakeReplace(0, PlayoutMode.One, CollectionType.Collection) with
        {
            StartType = StartType.Fixed,
            StartTime = TimeSpan.FromHours(20),
            FixedStartTimeBehavior = FixedStartTimeBehavior.Flexible,
            CollectionId = 1,
            PlaybackOrder = PlaybackOrder.Chronological,
            CustomTitle = "News Hour",
            PreferredAudioLanguageCode = "eng",
            PreferredAudioTitle = "Director Commentary",
            PreferredSubtitleLanguageCode = "fra",
            SubtitleMode = ChannelSubtitleMode.Any
        },
        // 1: Multiple (Count, expression string) on a different collection, group mode + all 5 fillers + 2 wm + 2 ge.
        MakeReplace(1, PlayoutMode.Multiple, CollectionType.Collection) with
        {
            CollectionId = 2,
            PlaybackOrder = PlaybackOrder.Shuffle,
            FillWithGroupMode = FillWithGroupMode.FillWithShuffledGroups,
            MultipleMode = MultipleMode.Count,
            MultipleCount = "2 + 1",
            PreRollFillerId = 1,
            MidRollFillerId = 2,
            PostRollFillerId = 3,
            TailFillerId = 4,
            FallbackFillerId = 5,
            WatermarkIds = [1, 2],
            GraphicsElementIds = [1, 2]
        },
        // 2: Duration on a smart collection, Marathon order + tail filler.
        MakeReplace(2, PlayoutMode.Duration, CollectionType.SmartCollection) with
        {
            SmartCollectionId = 1,
            PlayoutDuration = TimeSpan.FromMinutes(45),
            TailMode = TailMode.Filler,
            TailFillerId = 4,
            DiscardToFillAttempts = 5,
            PlaybackOrder = PlaybackOrder.Marathon,
            MarathonGroupBy = MarathonGroupBy.Show,
            MarathonShuffleGroups = true,
            MarathonShuffleItems = true,
            MarathonBatchSize = 4
        },
        // 3: Flood on a playlist (playback order None; MarathonShuffleGroups doubles as shuffle-playlist-items).
        MakeReplace(3, PlayoutMode.Flood, CollectionType.Playlist) with
        {
            PlaylistId = 1,
            PlaybackOrder = PlaybackOrder.None,
            MarathonShuffleGroups = true
        },
        // 4: Rerun-first-run One item.
        MakeReplace(4, PlayoutMode.One, CollectionType.RerunFirstRun) with
        {
            RerunCollectionId = 1,
            PlaybackOrder = PlaybackOrder.None
        },
        // 5: SearchQuery One item.
        MakeReplace(5, PlayoutMode.One, CollectionType.SearchQuery) with
        {
            SearchTitle = "Comedy Search",
            SearchQuery = "genre:comedy",
            PlaybackOrder = PlaybackOrder.Shuffle
        }
    ];

    private static ReplaceProgramScheduleItem MakeReplace(int index, PlayoutMode playoutMode, CollectionType collectionType) =>
        new(
            null,
            index,
            StartType.Dynamic,
            StartTime: null,
            FixedStartTimeBehavior: null,
            playoutMode,
            collectionType,
            CollectionId: null,
            MultiCollectionId: null,
            SmartCollectionId: null,
            RerunCollectionId: null,
            MediaItemId: null,
            PlaylistId: null,
            SearchTitle: null,
            SearchQuery: null,
            PlaybackOrder: PlaybackOrder.Shuffle,
            MarathonGroupBy: MarathonGroupBy.None,
            MarathonShuffleGroups: false,
            MarathonShuffleItems: false,
            MarathonBatchSize: null,
            FillWithGroupMode: FillWithGroupMode.None,
            MultipleMode: MultipleMode.Count,
            MultipleCount: null,
            PlayoutDuration: null,
            TailMode: TailMode.None,
            DiscardToFillAttempts: null,
            CustomTitle: null,
            GuideMode: GuideMode.Normal,
            PreRollFillerId: null,
            MidRollFillerId: null,
            PostRollFillerId: null,
            TailFillerId: null,
            FallbackFillerId: null,
            WatermarkIds: [],
            GraphicsElementIds: [],
            PreferredAudioLanguageCode: null,
            PreferredAudioTitle: null,
            PreferredSubtitleLanguageCode: null,
            SubtitleMode: null);

    // Mirrors the ScheduleItemRequest → ReplaceProgramScheduleItem controller mapping, but sourced from a
    // response DTO (the SPA does this same field-for-field copy in TypeScript before a PUT).
    private static ReplaceProgramScheduleItem ToReplaceCommand(ScheduleItemResponseModel r, int index) =>
        new(
            r.Id,
            index,
            r.StartType,
            r.StartTime,
            r.FixedStartTimeBehavior,
            r.PlayoutMode,
            r.CollectionType,
            r.CollectionId,
            r.MultiCollectionId,
            r.SmartCollectionId,
            r.RerunCollectionId,
            r.MediaItemId,
            r.PlaylistId,
            r.SearchTitle,
            r.SearchQuery,
            r.PlaybackOrder,
            r.MarathonGroupBy,
            r.MarathonShuffleGroups,
            r.MarathonShuffleItems,
            r.MarathonBatchSize,
            r.FillWithGroupMode,
            r.MultipleMode ?? MultipleMode.Count,
            r.MultipleCount,
            r.PlayoutDuration,
            r.TailMode ?? TailMode.None,
            r.DiscardToFillAttempts,
            r.CustomTitle,
            r.GuideMode,
            r.PreRollFillerId,
            r.MidRollFillerId,
            r.PostRollFillerId,
            r.TailFillerId,
            r.FallbackFillerId,
            r.WatermarkIds,
            r.GraphicsElementIds,
            r.PreferredAudioLanguageCode,
            r.PreferredAudioTitle,
            r.PreferredSubtitleLanguageCode,
            r.SubtitleMode);

    private static void AssertSemanticallyEqual(ScheduleItemResponseModel a, ScheduleItemResponseModel b)
    {
        b.Index.ShouldBe(a.Index);
        b.StartType.ShouldBe(a.StartType);
        b.StartTime.ShouldBe(a.StartTime);
        b.FixedStartTimeBehavior.ShouldBe(a.FixedStartTimeBehavior);
        b.PlayoutMode.ShouldBe(a.PlayoutMode);
        b.CollectionType.ShouldBe(a.CollectionType);
        b.CollectionId.ShouldBe(a.CollectionId);
        b.MultiCollectionId.ShouldBe(a.MultiCollectionId);
        b.SmartCollectionId.ShouldBe(a.SmartCollectionId);
        b.RerunCollectionId.ShouldBe(a.RerunCollectionId);
        b.MediaItemId.ShouldBe(a.MediaItemId);
        b.PlaylistId.ShouldBe(a.PlaylistId);
        b.SearchTitle.ShouldBe(a.SearchTitle);
        b.SearchQuery.ShouldBe(a.SearchQuery);
        b.PlaybackOrder.ShouldBe(a.PlaybackOrder);
        b.MarathonGroupBy.ShouldBe(a.MarathonGroupBy);
        b.MarathonShuffleGroups.ShouldBe(a.MarathonShuffleGroups);
        b.MarathonShuffleItems.ShouldBe(a.MarathonShuffleItems);
        b.MarathonBatchSize.ShouldBe(a.MarathonBatchSize);
        b.FillWithGroupMode.ShouldBe(a.FillWithGroupMode);
        b.MultipleMode.ShouldBe(a.MultipleMode);
        b.MultipleCount.ShouldBe(a.MultipleCount);
        b.PlayoutDuration.ShouldBe(a.PlayoutDuration);
        b.TailMode.ShouldBe(a.TailMode);
        b.DiscardToFillAttempts.ShouldBe(a.DiscardToFillAttempts);
        b.CustomTitle.ShouldBe(a.CustomTitle);
        b.GuideMode.ShouldBe(a.GuideMode);
        b.PreRollFillerId.ShouldBe(a.PreRollFillerId);
        b.MidRollFillerId.ShouldBe(a.MidRollFillerId);
        b.PostRollFillerId.ShouldBe(a.PostRollFillerId);
        b.TailFillerId.ShouldBe(a.TailFillerId);
        b.FallbackFillerId.ShouldBe(a.FallbackFillerId);
        b.WatermarkIds.ShouldBe(a.WatermarkIds);
        b.GraphicsElementIds.ShouldBe(a.GraphicsElementIds);
        b.PreferredAudioLanguageCode.ShouldBe(a.PreferredAudioLanguageCode);
        b.PreferredAudioTitle.ShouldBe(a.PreferredAudioTitle);
        b.PreferredSubtitleLanguageCode.ShouldBe(a.PreferredSubtitleLanguageCode);
        b.SubtitleMode.ShouldBe(a.SubtitleMode);
        b.CollectionName.ShouldBe(a.CollectionName);
        b.MultiCollectionName.ShouldBe(a.MultiCollectionName);
        b.SmartCollectionName.ShouldBe(a.SmartCollectionName);
        b.RerunCollectionName.ShouldBe(a.RerunCollectionName);
        b.PlaylistName.ShouldBe(a.PlaylistName);
        b.PlaylistGroupId.ShouldBe(a.PlaylistGroupId);
        b.MediaItemName.ShouldBe(a.MediaItemName);
        b.PreRollFillerName.ShouldBe(a.PreRollFillerName);
        b.MidRollFillerName.ShouldBe(a.MidRollFillerName);
        b.PostRollFillerName.ShouldBe(a.PostRollFillerName);
        b.TailFillerName.ShouldBe(a.TailFillerName);
        b.FallbackFillerName.ShouldBe(a.FallbackFillerName);
        b.Watermarks.Select(w => (w.Id, w.Name)).ShouldBe(a.Watermarks.Select(w => (w.Id, w.Name)));
        b.GraphicsElements.Select(g => (g.Id, g.Name)).ShouldBe(a.GraphicsElements.Select(g => (g.Id, g.Name)));
        b.Name.ShouldBe(a.Name);
        b.DurationEstimate.ShouldBe(a.DurationEstimate);
    }

    private async Task<int> SeedScheduleAndReferences(bool shuffleScheduleItems)
    {
        await using TvContext context = _db.CreateContext();

        context.Collections.Add(new Collection { Id = 1, Name = "First Collection" });
        context.Collections.Add(new Collection { Id = 2, Name = "Prime Collection" });
        context.SmartCollections.Add(new SmartCollection { Id = 1, Name = "Smart Picks", Query = "*" });
        context.RerunCollections.Add(new RerunCollection
        {
            Id = 1,
            Name = "Rerun Bin",
            CollectionType = CollectionType.Collection
        });

        context.PlaylistGroups.Add(new PlaylistGroup { Id = 9, Name = "Group" });
        context.Playlists.Add(new Playlist { Id = 1, PlaylistGroupId = 9, Name = "My Playlist" });

        context.ChannelWatermarks.Add(new ChannelWatermark { Id = 1, Name = "WM One" });
        context.ChannelWatermarks.Add(new ChannelWatermark { Id = 2, Name = "WM Two" });
        context.GraphicsElements.Add(new GraphicsElement { Id = 1, Name = "GE One", Path = "/ge/1", Kind = GraphicsElementKind.Image });
        context.GraphicsElements.Add(new GraphicsElement { Id = 2, Name = "GE Two", Path = "/ge/2", Kind = GraphicsElementKind.Image });

        AddFiller(context, 1, "PreRoll", FillerKind.PreRoll);
        AddFiller(context, 2, "MidRoll", FillerKind.MidRoll);
        AddFiller(context, 3, "PostRoll", FillerKind.PostRoll);
        AddFiller(context, 4, "Tail", FillerKind.Tail);
        AddFiller(context, 5, "Fallback", FillerKind.Fallback);

        var schedule = new ProgramSchedule
        {
            Name = "Round Trip",
            ShuffleScheduleItems = shuffleScheduleItems,
            Items = [],
            Playouts = [],
            ProgramScheduleAlternates = []
        };
        context.ProgramSchedules.Add(schedule);
        await context.SaveChangesAsync();
        return schedule.Id;
    }

    private static void AddFiller(TvContext context, int id, string name, FillerKind kind) =>
        context.FillerPresets.Add(new FillerPreset
        {
            Id = id,
            Name = name,
            FillerKind = kind,
            FillerMode = FillerMode.Count,
            Count = 1,
            CollectionType = CollectionType.Collection
        });
}
