using System.Threading.Channels;
using ErsatzTV.Application;
using ErsatzTV.Application.Plex;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Plex;
using LanguageExt;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using static LanguageExt.Prelude;
using Unit = LanguageExt.Unit;

namespace ErsatzTV.Tests.Application.Plex;

[TestFixture]
public class PlexHandlerTests
{
    // ----- TryCompletePlexPinFlowHandler: release ONLY on non-handoff exits (§C1.6 / finding 5) -----

    [Test]
    public async Task PinFlow_Should_Release_Lock_On_Thrown_Cancellation()
    {
        IPlexTvApiClient plexTvApiClient = Substitute.For<IPlexTvApiClient>();
        var channel = Substitute.For<ChannelWriter<IPlexBackgroundServiceRequest>>();
        IEntityLocker entityLocker = Substitute.For<IEntityLocker>();

        using var externalCts = new CancellationTokenSource();
        // poll returns false, then cancels the flow's token so Task.Delay THROWS (the real timeout path)
        plexTvApiClient.TryCompletePinFlow(Arg.Any<PlexAuthPin>())
            .Returns(_ =>
            {
                externalCts.Cancel();
                return false;
            });

        var handler = new TryCompletePlexPinFlowHandler(plexTvApiClient, channel, entityLocker);

        await Should.ThrowAsync<OperationCanceledException>(() =>
            handler.Handle(new TryCompletePlexPinFlow(Pin()), externalCts.Token));

        entityLocker.Received(1).UnlockPlex();
    }

    [Test]
    public async Task PinFlow_Should_Release_Lock_On_Poll_Exception()
    {
        IPlexTvApiClient plexTvApiClient = Substitute.For<IPlexTvApiClient>();
        var channel = Substitute.For<ChannelWriter<IPlexBackgroundServiceRequest>>();
        IEntityLocker entityLocker = Substitute.For<IEntityLocker>();

        plexTvApiClient.TryCompletePinFlow(Arg.Any<PlexAuthPin>())
            .Returns<bool>(_ => throw new InvalidOperationException("poll boom"));

        var handler = new TryCompletePlexPinFlowHandler(plexTvApiClient, channel, entityLocker);

        await Should.ThrowAsync<InvalidOperationException>(() =>
            handler.Handle(new TryCompletePlexPinFlow(Pin()), CancellationToken.None));

        entityLocker.Received(1).UnlockPlex();
    }

    [Test]
    public async Task PinFlow_Should_Release_Lock_On_Enqueue_Exception()
    {
        IPlexTvApiClient plexTvApiClient = Substitute.For<IPlexTvApiClient>();
        var channel = Substitute.For<ChannelWriter<IPlexBackgroundServiceRequest>>();
        IEntityLocker entityLocker = Substitute.For<IEntityLocker>();

        plexTvApiClient.TryCompletePinFlow(Arg.Any<PlexAuthPin>()).Returns(true);
        channel.WriteAsync(Arg.Any<IPlexBackgroundServiceRequest>(), Arg.Any<CancellationToken>())
            .Returns<ValueTask>(_ => throw new InvalidOperationException("enqueue boom"));

        var handler = new TryCompletePlexPinFlowHandler(plexTvApiClient, channel, entityLocker);

        await Should.ThrowAsync<InvalidOperationException>(() =>
            handler.Handle(new TryCompletePlexPinFlow(Pin()), CancellationToken.None));

        entityLocker.Received(1).UnlockPlex();
    }

    [Test]
    public async Task PinFlow_Success_Should_Not_Release_Lock_In_Handler()
    {
        IPlexTvApiClient plexTvApiClient = Substitute.For<IPlexTvApiClient>();
        var channel = Substitute.For<ChannelWriter<IPlexBackgroundServiceRequest>>();
        IEntityLocker entityLocker = Substitute.For<IEntityLocker>();

        plexTvApiClient.TryCompletePinFlow(Arg.Any<PlexAuthPin>()).Returns(true);

        var handler = new TryCompletePlexPinFlowHandler(plexTvApiClient, channel, entityLocker);

        Either<BaseError, bool> result =
            await handler.Handle(new TryCompletePlexPinFlow(Pin()), CancellationToken.None);

        result.IsRight.ShouldBeTrue();
        // the lock is handed off to SynchronizePlexMediaSources — the handler must NOT release it
        entityLocker.DidNotReceive().UnlockPlex();
        await channel.Received(1).WriteAsync(
            Arg.Any<SynchronizePlexMediaSources>(),
            Arg.Any<CancellationToken>());
    }

    // ----- SignOutOfPlexHandler: unconditional finally-release (§C6 / finding 7) -----

    [Test]
    public async Task SignOut_Should_Release_Lock_When_Dependency_Throws()
    {
        IMediaSourceRepository mediaSourceRepository = Substitute.For<IMediaSourceRepository>();
        IPlexSecretStore plexSecretStore = Substitute.For<IPlexSecretStore>();
        IEntityLocker entityLocker = Substitute.For<IEntityLocker>();
        ISearchIndex searchIndex = Substitute.For<ISearchIndex>();

        mediaSourceRepository.DeleteAllPlex()
            .Returns<List<int>>(_ => throw new InvalidOperationException("delete boom"));

        var handler = new SignOutOfPlexHandler(mediaSourceRepository, plexSecretStore, entityLocker, searchIndex);

        await Should.ThrowAsync<InvalidOperationException>(() =>
            handler.Handle(new SignOutOfPlex(), CancellationToken.None));

        entityLocker.Received(1).UnlockPlex();
    }

    [Test]
    public async Task SignOut_Should_Release_Lock_On_Success()
    {
        IMediaSourceRepository mediaSourceRepository = Substitute.For<IMediaSourceRepository>();
        IPlexSecretStore plexSecretStore = Substitute.For<IPlexSecretStore>();
        IEntityLocker entityLocker = Substitute.For<IEntityLocker>();
        ISearchIndex searchIndex = Substitute.For<ISearchIndex>();

        mediaSourceRepository.DeleteAllPlex().Returns(new List<int> { 1, 2 });

        var handler = new SignOutOfPlexHandler(mediaSourceRepository, plexSecretStore, entityLocker, searchIndex);

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

        result.IsRight.ShouldBeTrue();
        entityLocker.Received(1).UnlockPlex();
    }

    // ----- UpdatePlexPathReplacementsHandler: ownership + nonblank + null validation (§C4b / findings 2c/8) -----

    [Test]
    public async Task PathReplacements_Should_Reject_CrossSource_Id_Without_Mutating()
    {
        IMediaSourceRepository mediaSourceRepository = Substitute.For<IMediaSourceRepository>();
        mediaSourceRepository.GetPlex(1, Arg.Any<CancellationToken>())
            .Returns(Option<PlexMediaSource>.Some(SourceWithReplacement(1, replacementId: 5)));

        var handler = new UpdatePlexPathReplacementsHandler(mediaSourceRepository);

        // id 99 is not owned by source 1 (its only replacement is id 5) → must not touch the repo
        Either<BaseError, Unit> result = await handler.Handle(
            new UpdatePlexPathReplacements(1, [new PlexPathReplacementItem(99, "/remote", "/local")]),
            CancellationToken.None);

        result.IsLeft.ShouldBeTrue();
        await mediaSourceRepository.DidNotReceive().UpdatePathReplacements(
            Arg.Any<int>(),
            Arg.Any<List<PlexPathReplacement>>(),
            Arg.Any<List<PlexPathReplacement>>(),
            Arg.Any<List<PlexPathReplacement>>());
    }

    [Test]
    public async Task PathReplacements_Should_Reject_Blank_Rows_Without_Mutating()
    {
        IMediaSourceRepository mediaSourceRepository = Substitute.For<IMediaSourceRepository>();
        mediaSourceRepository.GetPlex(1, Arg.Any<CancellationToken>())
            .Returns(Option<PlexMediaSource>.Some(SourceWithReplacement(1, replacementId: 5)));

        var handler = new UpdatePlexPathReplacementsHandler(mediaSourceRepository);

        Either<BaseError, Unit> result = await handler.Handle(
            new UpdatePlexPathReplacements(1, [new PlexPathReplacementItem(0, "  ", "/local")]),
            CancellationToken.None);

        result.IsLeft.ShouldBeTrue();
        await mediaSourceRepository.DidNotReceive().UpdatePathReplacements(
            Arg.Any<int>(),
            Arg.Any<List<PlexPathReplacement>>(),
            Arg.Any<List<PlexPathReplacement>>(),
            Arg.Any<List<PlexPathReplacement>>());
    }

    [Test]
    public async Task PathReplacements_Should_Reject_Null_Items_Without_Mutating()
    {
        IMediaSourceRepository mediaSourceRepository = Substitute.For<IMediaSourceRepository>();
        mediaSourceRepository.GetPlex(1, Arg.Any<CancellationToken>())
            .Returns(Option<PlexMediaSource>.Some(SourceWithReplacement(1, replacementId: 5)));

        var handler = new UpdatePlexPathReplacementsHandler(mediaSourceRepository);

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

        result.IsLeft.ShouldBeTrue();
        await mediaSourceRepository.DidNotReceive().UpdatePathReplacements(
            Arg.Any<int>(),
            Arg.Any<List<PlexPathReplacement>>(),
            Arg.Any<List<PlexPathReplacement>>(),
            Arg.Any<List<PlexPathReplacement>>());
    }

    [Test]
    public async Task PathReplacements_Should_Accept_Owned_Rows_And_Mutate()
    {
        IMediaSourceRepository mediaSourceRepository = Substitute.For<IMediaSourceRepository>();
        mediaSourceRepository.GetPlex(1, Arg.Any<CancellationToken>())
            .Returns(Option<PlexMediaSource>.Some(SourceWithReplacement(1, replacementId: 5)));
        mediaSourceRepository.UpdatePathReplacements(
                Arg.Any<int>(),
                Arg.Any<List<PlexPathReplacement>>(),
                Arg.Any<List<PlexPathReplacement>>(),
                Arg.Any<List<PlexPathReplacement>>())
            .Returns(Unit.Default);

        var handler = new UpdatePlexPathReplacementsHandler(mediaSourceRepository);

        Either<BaseError, Unit> result = await handler.Handle(
            new UpdatePlexPathReplacements(1, [new PlexPathReplacementItem(5, "/remote", "/local")]),
            CancellationToken.None);

        result.IsRight.ShouldBeTrue();
        await mediaSourceRepository.Received(1).UpdatePathReplacements(
            1,
            Arg.Any<List<PlexPathReplacement>>(),
            Arg.Any<List<PlexPathReplacement>>(),
            Arg.Any<List<PlexPathReplacement>>());
    }

    private static PlexAuthPin Pin() => new(1, "code", "client-id");

    private static PlexMediaSource SourceWithReplacement(int sourceId, int replacementId) =>
        new()
        {
            Id = sourceId,
            PathReplacements =
            [
                new PlexPathReplacement
                {
                    Id = replacementId,
                    PlexPath = "/existing-remote",
                    LocalPath = "/existing-local",
                    PlexMediaSourceId = sourceId
                }
            ]
        };
}
