using System.Threading.Channels;
using ErsatzTV.Application;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Application.Scheduling;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using LanguageExt;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using Testably.Abstractions.Testing;
using Unit = LanguageExt.Unit;
using Channel = System.Threading.Channels.Channel;
using static LanguageExt.Prelude;

namespace ErsatzTV.Tests.Application.Playouts;

[TestFixture]
public class PlayoutHandlerTests
{
    private InMemoryTvContext _db = null!;
    private ChannelWriter<IBackgroundServiceRequest> _worker = null!;

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

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

    [Test]
    public async Task CreateClassic_Should_Return_NotFoundError_When_Channel_Missing()
    {
        var handler = new CreateClassicPlayoutHandler(_worker, _db.Factory);

        Either<BaseError, CreatePlayoutResponse> result =
            await handler.Handle(new CreateClassicPlayout(999, 1), CancellationToken.None);

        LeftOf(result).ShouldBeOfType<NotFoundError>();
    }

    [Test]
    public async Task CreateClassic_Should_Return_NotFoundError_When_Schedule_Missing()
    {
        int channelId = await SeedChannel();
        var handler = new CreateClassicPlayoutHandler(_worker, _db.Factory);

        Either<BaseError, CreatePlayoutResponse> result =
            await handler.Handle(new CreateClassicPlayout(channelId, 999), CancellationToken.None);

        LeftOf(result).ShouldBeOfType<NotFoundError>();
    }

    [Test]
    public async Task Delete_Should_Return_NotFoundError_When_Playout_Missing()
    {
        var handler = new DeletePlayoutHandler(
            _worker,
            _db.Factory,
            new MockFileSystem(),
            Substitute.For<IMediator>());

        Either<BaseError, Unit> result = await handler.Handle(new DeletePlayout(999), CancellationToken.None);

        LeftOf(result).ShouldBeOfType<NotFoundError>();
    }

    [Test]
    public async Task Delete_Should_Remove_Guide_Cache_File_Through_FileSystem_Abstraction()
    {
        int channelId = await SeedChannel();
        int playoutId;
        await using (TvContext context = _db.CreateContext())
        {
            var playout = new Playout { ChannelId = channelId, Items = [] };
            context.Playouts.Add(playout);
            await context.SaveChangesAsync();
            playoutId = playout.Id;
        }

        var fileSystem = new MockFileSystem();
        string cacheFile = fileSystem.Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, "101.xml");
        fileSystem.Directory.CreateDirectory(FileSystemLayout.ChannelGuideCacheFolder);
        await fileSystem.File.WriteAllTextAsync(cacheFile, "<tv/>");

        var handler = new DeletePlayoutHandler(
            _worker,
            _db.Factory,
            fileSystem,
            Substitute.For<IMediator>());

        Either<BaseError, Unit> result = await handler.Handle(new DeletePlayout(playoutId), CancellationToken.None);

        result.IsRight.ShouldBeTrue();
        // routed through the abstraction (not static File.Delete), so the MockFileSystem observes the removal
        fileSystem.File.Exists(cacheFile).ShouldBeFalse();
    }

    [Test]
    public async Task ReplaceAlternateScheduleItems_Should_Reject_Empty_List()
    {
        var handler = new ReplacePlayoutAlternateScheduleItemsHandler(
            _db.Factory,
            _worker,
            NullLogger<ReplacePlayoutAlternateScheduleItemsHandler>.Instance);

        Either<BaseError, Unit> result = await handler.Handle(
            new ReplacePlayoutAlternateScheduleItems(1, []),
            CancellationToken.None);

        LeftOf(result).Value.ShouldContain("must not be empty");
    }

    // ---- #253 optimistic concurrency (alternate schedules #7 + templates #8, shared Playout.Version) ----

    private static ReplacePlayoutAlternateSchedule AltItem(int programScheduleId = 10) =>
        new(0, 0, programScheduleId, [], [], [], false, 1, 1, null, 12, 31, null);

    private static ReplacePlayoutTemplate TemplateItem(int templateId = 20) =>
        new(0, 0, templateId, null, [], [], [], false, 1, 1, null, 12, 31, null);

    private async Task SeedPlayout(int id, int version, int? programScheduleId = 10)
    {
        await using TvContext context = _db.CreateContext();
        context.Playouts.Add(
            new Playout
            {
                Id = id,
                ChannelId = 1,
                ProgramScheduleId = programScheduleId,
                Version = version,
                Items = [],
                ProgramScheduleAlternates = [],
                Templates = []
            });
        await context.SaveChangesAsync();
    }

    private async Task<int> ReadPlayoutVersion(int id)
    {
        await using TvContext context = _db.CreateContext();
        return await context.Playouts.Where(p => p.Id == id).Select(p => p.Version).SingleAsync();
    }

    [Test]
    public async Task ReplaceAlternateSchedules_Stale_If_Match_Should_Fail_Precondition_And_Not_Mutate()
    {
        await SeedPlayout(1, version: 2);
        var handler = new ReplacePlayoutAlternateScheduleItemsHandler(
            _db.Factory,
            _worker,
            NullLogger<ReplacePlayoutAlternateScheduleItemsHandler>.Instance);

        Either<BaseError, Unit> result = await handler.Handle(
            new ReplacePlayoutAlternateScheduleItems(1, [AltItem()], Some(new[] { 1 }.ToSeq())),
            CancellationToken.None);

        // Standalone-Either pre-check (not via Apply) preserves the subtype → 412 rather than a flattened 422.
        LeftOf(result).ShouldBeOfType<PreconditionFailedError>();
        (await ReadPlayoutVersion(1)).ShouldBe(2);
    }

    [Test]
    public async Task ReplaceAlternateSchedules_Matching_If_Match_Should_Succeed_And_Bump()
    {
        await SeedPlayout(1, version: 2);
        var handler = new ReplacePlayoutAlternateScheduleItemsHandler(
            _db.Factory,
            _worker,
            NullLogger<ReplacePlayoutAlternateScheduleItemsHandler>.Instance);

        Either<BaseError, Unit> result = await handler.Handle(
            new ReplacePlayoutAlternateScheduleItems(1, [AltItem()], Some(new[] { 2 }.ToSeq())),
            CancellationToken.None);

        result.IsRight.ShouldBeTrue();
        (await ReadPlayoutVersion(1)).ShouldBe(3);
    }

    [Test]
    public async Task ReplaceAlternateSchedules_Absent_If_Match_Should_Force_Write_And_Bump()
    {
        await SeedPlayout(1, version: 2);
        var handler = new ReplacePlayoutAlternateScheduleItemsHandler(
            _db.Factory,
            _worker,
            NullLogger<ReplacePlayoutAlternateScheduleItemsHandler>.Instance);

        Either<BaseError, Unit> result = await handler.Handle(
            new ReplacePlayoutAlternateScheduleItems(1, [AltItem()], None),
            CancellationToken.None);

        result.IsRight.ShouldBeTrue();
        (await ReadPlayoutVersion(1)).ShouldBe(3);
    }

    [Test]
    public async Task ReplaceTemplates_Stale_If_Match_Should_Fail_Precondition_And_Not_Mutate()
    {
        await SeedPlayout(1, version: 2);
        var handler = new ReplacePlayoutTemplateItemsHandler(
            _db.Factory,
            NullLogger<ReplacePlayoutTemplateItemsHandler>.Instance);

        Option<BaseError> result = await handler.Handle(
            new ReplacePlayoutTemplateItems(1, [TemplateItem()], Some(new[] { 1 }.ToSeq())),
            CancellationToken.None);

        // Some(PreconditionFailedError): the pre-check escapes the handler's catch(Exception) as a 412
        // rather than being reshaped into a bare BaseError → 422 (§9/H1).
        result.IfNone(() => throw new AssertionException("Expected a Some(error)"))
            .ShouldBeOfType<PreconditionFailedError>();
        (await ReadPlayoutVersion(1)).ShouldBe(2);
    }

    [Test]
    public async Task ReplaceTemplates_Matching_If_Match_Should_Succeed_And_Bump()
    {
        await SeedPlayout(1, version: 2);
        var handler = new ReplacePlayoutTemplateItemsHandler(
            _db.Factory,
            NullLogger<ReplacePlayoutTemplateItemsHandler>.Instance);

        Option<BaseError> result = await handler.Handle(
            new ReplacePlayoutTemplateItems(1, [TemplateItem()], Some(new[] { 2 }.ToSeq())),
            CancellationToken.None);

        result.IsNone.ShouldBeTrue();
        (await ReadPlayoutVersion(1)).ShouldBe(3);
    }

    [Test]
    public async Task Racing_Playout_Save_Should_Map_Concurrency_Failure_To_412()
    {
        await SeedPlayout(1, version: 1);

        // Backstop mechanic on the Playout token: two writers load the same version and both bump-and-save;
        // IsConcurrencyToken on Playout keys the loser's UPDATE on the original version → 0 rows →
        // DbUpdateConcurrencyException → PreconditionFailedError. Non-vacuous: drop .IsConcurrencyToken()
        // on Playout and the loser silently overwrites the winner instead.
        await using TvContext ctxWinner = _db.CreateContext();
        await using TvContext ctxLoser = _db.CreateContext();

        Playout winner = await ctxWinner.Playouts.SingleAsync(p => p.Id == 1);
        Playout loser = await ctxLoser.Playouts.SingleAsync(p => p.Id == 1);

        winner.Version++;
        (await ctxWinner.SaveChangesWithConcurrencyGuard(CancellationToken.None)).IsRight.ShouldBeTrue();

        loser.Version++;
        Either<BaseError, Unit> loserResult = await ctxLoser.SaveChangesWithConcurrencyGuard(CancellationToken.None);

        loserResult.Match<BaseError?>(Right: _ => null, Left: e => e).ShouldBeOfType<PreconditionFailedError>();
        (await ReadPlayoutVersion(1)).ShouldBe(2);
    }

    [Test]
    public async Task SaveChangesForcingVersion_Should_Force_Write_Past_A_Concurrent_Bump_Without_Reverting_It()
    {
        await SeedPlayout(1, version: 1);

        await using TvContext ctxSettings = _db.CreateContext();
        await using TvContext ctxBumper = _db.CreateContext();

        Playout settings = await ctxSettings.Playouts.SingleAsync(p => p.Id == 1);
        Playout bumper = await ctxBumper.Playouts.SingleAsync(p => p.Id == 1);

        // A replace-all editor bumps the version in another context after the settings writer loaded.
        bumper.Version++;
        await ctxBumper.SaveChangesAsync();

        // The non-participating settings writer changes a root scalar against the now-stale version. Plain
        // SaveChangesAsync would throw DbUpdateConcurrencyException (proven by the racing test above); the
        // forcing helper must land the write instead (Phase-1 force-write) and NOT revert the bump.
        settings.DailyRebuildTime = TimeSpan.FromHours(3);
        await ctxSettings.SaveChangesForcingVersion(CancellationToken.None);

        await using TvContext verify = _db.CreateContext();
        Playout result = await verify.Playouts.SingleAsync(p => p.Id == 1);
        result.DailyRebuildTime.ShouldBe(TimeSpan.FromHours(3));
        result.Version.ShouldBe(2);
    }

    private static BaseError LeftOf<TR>(Either<BaseError, TR> either) =>
        either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result"));

    private async Task<int> SeedChannel()
    {
        await using TvContext context = _db.CreateContext();
        var channel = new ErsatzTV.Core.Domain.Channel(Guid.NewGuid())
        {
            Number = "101",
            SortNumber = 101,
            Name = "Handler",
            Group = string.Empty,
            Categories = string.Empty,
            StreamingMode = StreamingMode.HttpLiveStreamingSegmenter,
            Playouts = [],
            Artwork = [],
            StreamSelector = string.Empty,
            PreferredAudioLanguageCode = string.Empty,
            PreferredAudioTitle = string.Empty,
            PreferredSubtitleLanguageCode = string.Empty,
            MusicVideoCreditsTemplate = string.Empty,
            PlayoutSource = ChannelPlayoutSource.Generated,
            PlayoutMode = ChannelPlayoutMode.Continuous,
            IsEnabled = true,
            ShowInEpg = true
        };
        context.Channels.Add(channel);
        await context.SaveChangesAsync();
        return channel.Id;
    }
}
