using ErsatzTV.Application.Channels;
using ErsatzTV.Core;
using LanguageExt;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using Microsoft.EntityFrameworkCore;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using static LanguageExt.Prelude;

namespace ErsatzTV.Tests.Application.Channels;

[TestFixture]
public class UpdateChannelHandlerTests : ChannelHandlerTestBase
{
    private UpdateChannelHandler MakeHandler() => new(Worker, Db.Factory, SearchTargets, RemoteLogoCacher);

    [Test]
    public async Task Should_Return_NotFoundError_When_Channel_Missing()
    {
        Either<BaseError, ChannelViewModel> result =
            await MakeHandler().Handle(MakeUpdate(999, number: "5"), CancellationToken.None);

        BaseError error = LeftOf(result);
        error.ShouldBeOfType<NotFoundError>();
    }

    [Test]
    public async Task Should_Update_Existing_Channel()
    {
        await SeedFFmpegProfile();
        await SeedChannel(1, "5", "Old Name");

        Either<BaseError, ChannelViewModel> result =
            await MakeHandler().Handle(MakeUpdate(1, number: "5", name: "New Name"), CancellationToken.None);

        result.IsRight.ShouldBeTrue();

        await using TvContext context = Db.CreateContext();
        var channel = await context.Channels.SingleAsync(c => c.Id == 1);
        channel.Name.ShouldBe("New Name");
    }

    [Test]
    public async Task Should_Allow_Keeping_Own_Number()
    {
        await SeedFFmpegProfile();
        await SeedChannel(1, "5", "Old Name");

        Either<BaseError, ChannelViewModel> result =
            await MakeHandler().Handle(MakeUpdate(1, number: "5", name: "Renamed"), CancellationToken.None);

        result.IsRight.ShouldBeTrue();
    }

    [Test]
    public async Task Should_Reject_Number_Used_By_Another_Channel()
    {
        await SeedFFmpegProfile();
        await SeedChannel(1, "5");
        await SeedChannel(2, "6");

        Either<BaseError, ChannelViewModel> result =
            await MakeHandler().Handle(MakeUpdate(1, number: "6"), CancellationToken.None);

        BaseError error = LeftOf(result);
        error.ShouldNotBeOfType<NotFoundError>();
        error.Value.ShouldContain("unique");
    }

    [Test]
    public async Task Should_Reject_ShowInEpg_When_Disabled()
    {
        await SeedFFmpegProfile();
        await SeedChannel(1, "5");

        Either<BaseError, ChannelViewModel> result =
            await MakeHandler().Handle(
                MakeUpdate(1, number: "5", isEnabled: false, showInEpg: true),
                CancellationToken.None);

        BaseError error = LeftOf(result);
        error.ShouldNotBeOfType<NotFoundError>();
        error.Value.ShouldContain("EPG");
    }

    [Test]
    public async Task Should_Reject_Empty_Group()
    {
        await SeedFFmpegProfile();
        await SeedChannel(1, "5");

        Either<BaseError, ChannelViewModel> result =
            await MakeHandler().Handle(MakeUpdate(1, number: "5", group: ""), CancellationToken.None);

        BaseError error = LeftOf(result);
        error.ShouldNotBeOfType<NotFoundError>();
        error.Value.ShouldContain("group");
    }

    [Test]
    public async Task Should_Reject_Nonexistent_FFmpegProfile()
    {
        await SeedFFmpegProfile();
        await SeedChannel(1, "5");

        Either<BaseError, ChannelViewModel> result =
            await MakeHandler().Handle(
                MakeUpdate(1, number: "5", ffmpegProfileId: 999),
                CancellationToken.None);

        BaseError error = LeftOf(result);
        error.ShouldNotBeOfType<NotFoundError>();
        error.Value.ShouldContain("FFmpegProfile");
    }

    // the applicative validation accumulates every failure; the 400 body must carry all of them,
    // not just the first (regression guard for the #525 handler refactor — errors.Join, not .Head).
    [Test]
    public async Task Should_Report_All_Validation_Errors_Not_Just_The_First()
    {
        await SeedFFmpegProfile();
        await SeedChannel(1, "5");

        Either<BaseError, ChannelViewModel> result =
            await MakeHandler().Handle(
                MakeUpdate(1, number: "5", group: "", ffmpegProfileId: 999),
                CancellationToken.None);

        BaseError error = LeftOf(result);
        error.Value.ShouldContain("group");
        error.Value.ShouldContain("FFmpegProfile");
    }

    [Test]
    public async Task Should_Reject_Mirror_Transition_When_Channel_Has_Playout()
    {
        await SeedFFmpegProfile();
        await SeedChannel(1, "5"); // has a playout below
        await SeedChannel(2, "6"); // valid mirror source (Generated, no playouts of its own)
        await SeedPlayout(1, channelId: 1);

        UpdateChannel update = MakeUpdate(1, number: "5") with
        {
            PlayoutSource = ChannelPlayoutSource.Mirror,
            MirrorSourceChannelId = 2
        };

        Either<BaseError, ChannelViewModel> result = await MakeHandler().Handle(update, CancellationToken.None);

        BaseError error = LeftOf(result);
        error.ShouldNotBeOfType<NotFoundError>();
        error.Value.ShouldContain("Mirror");

        // the channel must NOT have been silently coerced/saved as Generated (issue #401: no
        // silent 200, the caller's requested transition is rejected outright)
        await using TvContext context = Db.CreateContext();
        var channel = await context.Channels.SingleAsync(c => c.Id == 1);
        channel.PlayoutSource.ShouldBe(ChannelPlayoutSource.Generated);
        channel.MirrorSourceChannelId.ShouldBeNull();
    }

    [Test]
    public async Task Should_Allow_GetPut_Roundtrip_Of_Generated_Channel_With_Playout()
    {
        await SeedFFmpegProfile();
        await SeedChannel(1, "5");
        await SeedPlayout(1, channelId: 1);

        // client GETs the channel (PlayoutSource: Generated) and PUTs the same value back
        // unchanged; this must still succeed even though the channel has a playout.
        Either<BaseError, ChannelViewModel> result =
            await MakeHandler().Handle(MakeUpdate(1, number: "5", name: "Renamed"), CancellationToken.None);

        result.IsRight.ShouldBeTrue();
    }

    [Test]
    public async Task Should_Null_Mirror_Only_Fields_When_Saving_Generated_Channel_With_Playout()
    {
        await SeedFFmpegProfile();
        await SeedChannel(1, "5"); // has a playout below
        await SeedChannel(2, "6"); // stray reference target
        await SeedPlayout(1, channelId: 1);

        // request keeps PlayoutSource: Generated but carries stray Mirror-only fields — e.g. a
        // client that never cleared the fields after flipping the UI back from Mirror. These
        // must never persist onto a Generated channel.
        UpdateChannel update = MakeUpdate(1, number: "5") with
        {
            MirrorSourceChannelId = 2,
            PlayoutOffset = TimeSpan.FromHours(1)
        };

        Either<BaseError, ChannelViewModel> result = await MakeHandler().Handle(update, CancellationToken.None);

        result.IsRight.ShouldBeTrue();

        await using TvContext context = Db.CreateContext();
        var channel = await context.Channels.SingleAsync(c => c.Id == 1);
        channel.PlayoutSource.ShouldBe(ChannelPlayoutSource.Generated);
        channel.MirrorSourceChannelId.ShouldBeNull();
        channel.PlayoutOffset.ShouldBeNull();
    }

    [Test]
    public async Task Should_Download_External_Url_Logo_And_Store_Cache_Name()
    {
        await SeedFFmpegProfile();
        Channel channel = await SeedChannel(1, "5");
        RemoteLogoCacher.CacheFromUrl(Arg.Any<Uri>(), Arg.Any<CancellationToken>())
            .Returns(Right<BaseError, string>("cachedhash"));

        Either<BaseError, ChannelViewModel> result = await MakeHandler().Handle(
            MakeUpdate(channel.Id, number: "5", logoPath: "https://example.com/logo.png"),
            CancellationToken.None);

        result.IsRight.ShouldBeTrue();
        await using TvContext db = Db.CreateContext();
        Artwork logo = db.Channels.Include(c => c.Artwork).Single(c => c.Id == channel.Id)
            .Artwork.Single(a => a.ArtworkKind == ArtworkKind.Logo);
        logo.Path.ShouldBe("cachedhash");
        logo.IsExternalUrl().ShouldBeFalse();
    }

    [Test]
    public async Task Should_Fail_The_Save_When_The_Logo_Download_Fails()
    {
        await SeedFFmpegProfile();
        Channel channel = await SeedChannel(1, "5");
        RemoteLogoCacher.CacheFromUrl(Arg.Any<Uri>(), Arg.Any<CancellationToken>())
            .Returns(Left<BaseError, string>(BaseError.New("Could not download logo: host timed out")));

        Either<BaseError, ChannelViewModel> result = await MakeHandler().Handle(
            MakeUpdate(channel.Id, number: "5", logoPath: "https://example.com/logo.png"),
            CancellationToken.None);

        result.IsLeft.ShouldBeTrue();
        LeftOf(result).Value.ShouldContain("Could not download logo");
    }

    [Test]
    public async Task Should_Not_Call_The_Cacher_For_An_Uploaded_Logo_Path()
    {
        await SeedFFmpegProfile();
        Channel channel = await SeedChannel(1, "5");
        await MakeHandler().Handle(
            MakeUpdate(channel.Id, number: "5", logoPath: "iptv/logos/deadbeef"),
            CancellationToken.None);
        await RemoteLogoCacher.DidNotReceive().CacheFromUrl(Arg.Any<Uri>(), Arg.Any<CancellationToken>());
    }

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