using ErsatzTV.Application.FFmpegProfiles;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using LanguageExt;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using static LanguageExt.Prelude;
using Unit = LanguageExt.Unit;

namespace ErsatzTV.Tests.Application.FFmpegProfiles;

[TestFixture]
public class FFmpegProfileHandlerTests
{
    private InMemoryTvContext _db = null!;
    private IConfigElementRepository _configElementRepository = null!;
    private ISearchTargets _searchTargets = null!;

    [SetUp]
    public async Task SetUp()
    {
        _db = await InMemoryTvContext.CreateAsync();
        _configElementRepository = Substitute.For<IConfigElementRepository>();
        _configElementRepository.GetValue<int>(Arg.Any<ConfigElementKey>(), Arg.Any<CancellationToken>())
            .Returns(Option<int>.None);
        _searchTargets = Substitute.For<ISearchTargets>();
    }

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

    [Test]
    public async Task Create_Should_Return_NotFoundError_When_Resolution_Missing()
    {
        var handler = new CreateFFmpegProfileHandler(_db.Factory, _searchTargets);

        Either<BaseError, CreateFFmpegProfileResult> result =
            await handler.Handle(MakeCreate(resolutionId: 999), CancellationToken.None);

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

    [Test]
    public async Task Update_Should_Return_NotFoundError_When_Profile_Missing()
    {
        var handler = new UpdateFFmpegProfileHandler(_db.Factory, _searchTargets);

        Either<BaseError, UpdateFFmpegProfileResult> result =
            await handler.Handle(MakeUpdate(999), CancellationToken.None);

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

    [Test]
    public async Task Update_Should_Return_NotFoundError_When_Resolution_Missing()
    {
        await SeedProfile(1);
        var handler = new UpdateFFmpegProfileHandler(_db.Factory, _searchTargets);

        Either<BaseError, UpdateFFmpegProfileResult> result =
            await handler.Handle(MakeUpdate(1, resolutionId: 999), CancellationToken.None);

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

    [Test]
    public async Task Delete_Should_Return_NotFoundError_When_Profile_Missing()
    {
        var handler = new DeleteFFmpegProfileHandler(_db.Factory, _configElementRepository, _searchTargets);

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

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

    [Test]
    public async Task Update_Should_Reject_Null_Name()
    {
        // Issue #172 FIX A: the handler guarded a client-nullable Name with a bare Name.Length
        // check, so a null name threw a NullReferenceException (HTTP 500). The guard now returns a
        // validation failure (Left) instead of throwing.
        await SeedProfile(1);
        await SeedResolution(1);
        var handler = new UpdateFFmpegProfileHandler(_db.Factory, _searchTargets);

        // Resolution seeded so the resolution-exists gate passes and the null name is the sole
        // failure: the guard must return a Left (validation failure) rather than throwing an NRE.
        Either<BaseError, UpdateFFmpegProfileResult> result =
            await handler.Handle(MakeUpdate(1) with { Name = null! }, CancellationToken.None);

        LeftOf(result).ShouldBeAssignableTo<BaseError>();
    }

    [Test]
    public async Task Create_Should_Persist_QsvPreferNativeDecoder_False()
    {
        // Guards the EF nullable-bool gotcha: QsvPreferNativeDecoder is `bool?` on the domain
        // entity with null-means-ON semantics, so an explicit `false` must not get coerced back to
        // null/true anywhere between the command and the persisted row.
        await SeedResolution(1);
        var handler = new CreateFFmpegProfileHandler(_db.Factory, _searchTargets);

        Either<BaseError, CreateFFmpegProfileResult> result =
            await handler.Handle(MakeCreate(1, qsvPreferNativeDecoder: false), CancellationToken.None);

        CreateFFmpegProfileResult created = RightOf(result);

        await using TvContext context = _db.CreateContext();
        FFmpegProfile persisted = await context.FFmpegProfiles.FindAsync(created.FFmpegProfileId);
        persisted.QsvPreferNativeDecoder.ShouldBe(false);
    }

    [Test]
    public async Task Update_Should_Persist_QsvPreferNativeDecoder_False()
    {
        await SeedProfile(1);
        await SeedResolution(1);
        var handler = new UpdateFFmpegProfileHandler(_db.Factory, _searchTargets);

        Either<BaseError, UpdateFFmpegProfileResult> result =
            await handler.Handle(MakeUpdate(1, qsvPreferNativeDecoder: false), CancellationToken.None);

        RightOf(result);

        await using TvContext context = _db.CreateContext();
        FFmpegProfile persisted = await context.FFmpegProfiles.FindAsync(1);
        persisted.QsvPreferNativeDecoder.ShouldBe(false);
    }

    // ersatztv#529: a stored 0 reached ffmpeg as hwupload=extra_hw_frames=0, leaving the QSV pool no
    // headroom; FFmpegState floors it at render time, and these pin that the stored row converges too
    // so the profile never keeps displaying a value the pipeline would override.
    [TestCase(0, 64)]
    [TestCase(-8, 64)]
    [TestCase(63, 64)]
    [TestCase(64, 64)]
    [TestCase(128, 128)]
    public async Task Create_Should_Floor_QsvExtraHardwareFrames(int configured, int expected)
    {
        await SeedResolution(1);
        var handler = new CreateFFmpegProfileHandler(_db.Factory, _searchTargets);

        Either<BaseError, CreateFFmpegProfileResult> result = await handler.Handle(
            MakeCreate(1, qsvExtraHardwareFrames: configured),
            CancellationToken.None);

        CreateFFmpegProfileResult created = RightOf(result);

        await using TvContext context = _db.CreateContext();
        FFmpegProfile persisted = await context.FFmpegProfiles.FindAsync(created.FFmpegProfileId);
        persisted.QsvExtraHardwareFrames.ShouldBe(expected);
    }

    [TestCase(0, 64)]
    [TestCase(-8, 64)]
    [TestCase(128, 128)]
    public async Task Update_Should_Floor_QsvExtraHardwareFrames(int configured, int expected)
    {
        await SeedProfile(1);
        await SeedResolution(1);
        var handler = new UpdateFFmpegProfileHandler(_db.Factory, _searchTargets);

        Either<BaseError, UpdateFFmpegProfileResult> result = await handler.Handle(
            MakeUpdate(1, qsvExtraHardwareFrames: configured),
            CancellationToken.None);

        RightOf(result);

        await using TvContext context = _db.CreateContext();
        FFmpegProfile persisted = await context.FFmpegProfiles.FindAsync(1);
        persisted.QsvExtraHardwareFrames.ShouldBe(expected);
    }

    // null means "unconfigured" and FFmpegState already resolves it to the same 64; it must stay
    // null rather than being silently materialized into a stored value
    [Test]
    public async Task Create_Should_Leave_Null_QsvExtraHardwareFrames_Null()
    {
        await SeedResolution(1);
        var handler = new CreateFFmpegProfileHandler(_db.Factory, _searchTargets);

        Either<BaseError, CreateFFmpegProfileResult> result =
            await handler.Handle(MakeCreate(1), CancellationToken.None);

        CreateFFmpegProfileResult created = RightOf(result);

        await using TvContext context = _db.CreateContext();
        FFmpegProfile persisted = await context.FFmpegProfiles.FindAsync(created.FFmpegProfileId);
        persisted.QsvExtraHardwareFrames.ShouldBeNull();
    }

    private static TR RightOf<TR>(Either<BaseError, TR> either) =>
        either.Match(Left: e => throw new AssertionException($"Expected a Right result, got {e}"), Right: r => r);

    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 SeedResolution(int id)
    {
        await using TvContext context = _db.CreateContext();
        context.Resolutions.Add(new Resolution { Id = id, Name = "1920x1080", Width = 1920, Height = 1080 });
        await context.SaveChangesAsync();
    }

    private async Task SeedProfile(int id)
    {
        await using TvContext context = _db.CreateContext();
        context.FFmpegProfiles.Add(new FFmpegProfile
        {
            Id = id,
            Name = "Default",
            ThreadCount = 1,
            NormalizeAudio = true,
            NormalizeVideo = true,
            HardwareAcceleration = HardwareAccelerationKind.None,
            VaapiDisplay = "drm",
            VaapiDriver = VaapiDriver.Default,
            VaapiDevice = "/dev/dri/renderD128",
            ResolutionId = 1,
            ScalingBehavior = ScalingBehavior.ScaleAndPad,
            PadMode = FilterMode.Software,
            VideoFormat = FFmpegProfileVideoFormat.H264,
            VideoProfile = string.Empty,
            VideoPreset = string.Empty,
            BitDepth = FFmpegProfileBitDepth.EightBit,
            VideoBitrate = 2_000,
            VideoBufferSize = 4_000,
            TonemapAlgorithm = FFmpegProfileTonemapAlgorithm.Linear,
            AudioFormat = FFmpegProfileAudioFormat.Aac,
            AudioBitrate = 192,
            AudioBufferSize = 384,
            NormalizeLoudnessMode = NormalizeLoudnessMode.Off,
            AudioChannels = 2,
            AudioSampleRate = 48_000,
            NormalizeFramerate = false,
            NormalizeColors = false,
            DeinterlaceVideo = false
        });
        await context.SaveChangesAsync();
    }

    private static CreateFFmpegProfile MakeCreate(
        int resolutionId,
        bool qsvPreferNativeDecoder = true,
        int? qsvExtraHardwareFrames = null) =>
        new(
            "Default",
            1,
            true,
            true,
            HardwareAccelerationKind.None,
            "drm",
            VaapiDriver.Default,
            "/dev/dri/renderD128",
            qsvExtraHardwareFrames,
            resolutionId,
            ScalingBehavior.ScaleAndPad,
            FilterMode.Software,
            FFmpegProfileVideoFormat.H264,
            string.Empty,
            string.Empty,
            false,
            FFmpegProfileBitDepth.EightBit,
            2_000,
            4_000,
            FFmpegProfileTonemapAlgorithm.Linear,
            FFmpegProfileAudioFormat.Aac,
            192,
            384,
            NormalizeLoudnessMode.Off,
            null,
            2,
            48_000,
            false,
            false,
            false,
            qsvPreferNativeDecoder);

    private static UpdateFFmpegProfile MakeUpdate(
        int id,
        int resolutionId = 1,
        bool qsvPreferNativeDecoder = true,
        int? qsvExtraHardwareFrames = null) =>
        new(
            id,
            "Default",
            1,
            true,
            true,
            HardwareAccelerationKind.None,
            "drm",
            VaapiDriver.Default,
            "/dev/dri/renderD128",
            qsvExtraHardwareFrames,
            resolutionId,
            ScalingBehavior.ScaleAndPad,
            FilterMode.Software,
            FFmpegProfileVideoFormat.H264,
            string.Empty,
            string.Empty,
            false,
            FFmpegProfileBitDepth.EightBit,
            2_000,
            4_000,
            FFmpegProfileTonemapAlgorithm.Linear,
            FFmpegProfileAudioFormat.Aac,
            192,
            384,
            NormalizeLoudnessMode.Off,
            null,
            2,
            48_000,
            false,
            false,
            false,
            qsvPreferNativeDecoder);
}
