using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Jellyfin;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Jellyfin;
using ErsatzTV.Scanner.Application.Jellyfin;
using ErsatzTV.Scanner.Core.Interfaces;
using Microsoft.Extensions.Logging;
using NSubstitute;
using NUnit.Framework;
using Shouldly;

namespace ErsatzTV.Scanner.Tests.Application.Jellyfin;

public class SynchronizeJellyfinLibraryByIdHandlerTests
{
    [TestFixture]
    public class Handle
    {
        [Test]
        public async Task Should_Scan_MusicVideo_Libraries()
        {
            var scannerProxy = Substitute.For<IScannerProxy>();
            var mediaSourceRepository = Substitute.For<IMediaSourceRepository>();
            var jellyfinSecretStore = Substitute.For<IJellyfinSecretStore>();
            var jellyfinMovieLibraryScanner = Substitute.For<IJellyfinMovieLibraryScanner>();
            var jellyfinTelevisionLibraryScanner = Substitute.For<IJellyfinTelevisionLibraryScanner>();
            var jellyfinMusicVideoLibraryScanner = Substitute.For<IJellyfinMusicVideoLibraryScanner>();
            var libraryRepository = Substitute.For<ILibraryRepository>();
            var configElementRepository = Substitute.For<IConfigElementRepository>();

            var library = new JellyfinLibrary
            {
                Id = 42,
                Name = "Concerts",
                MediaKind = LibraryMediaKind.MusicVideos,
                MediaSourceId = 7
            };
            var mediaSource = new JellyfinMediaSource
            {
                Id = 7,
                Connections =
                [
                    new JellyfinConnection
                    {
                        Address = "http://jellyfin.example",
                        JellyfinMediaSourceId = 7
                    }
                ]
            };

            mediaSourceRepository.GetJellyfinByLibraryId(library.Id).Returns(Some(mediaSource).AsTask());
            mediaSourceRepository.GetJellyfinLibrary(library.Id).Returns(Some(library).AsTask());
            jellyfinSecretStore.ReadSecrets().Returns(new JellyfinSecrets
            {
                Address = "http://jellyfin.example",
                ApiKey = "abc"
            });
            configElementRepository.GetValue<int>(
                    Arg.Is<ConfigElementKey>(key => key.Key == ConfigElementKey.LibraryRefreshInterval.Key),
                    Arg.Any<CancellationToken>())
                .Returns(Task.FromResult<Option<int>>(Some(0)));
            jellyfinMusicVideoLibraryScanner.ScanLibrary(
                    Arg.Any<JellyfinConnectionParameters>(),
                    library,
                    true,
                    Arg.Any<CancellationToken>())
                .Returns(Right<BaseError, Unit>(Unit.Default).AsTask());

            var handler = new SynchronizeJellyfinLibraryByIdHandler(
                scannerProxy,
                mediaSourceRepository,
                jellyfinSecretStore,
                jellyfinMovieLibraryScanner,
                jellyfinTelevisionLibraryScanner,
                jellyfinMusicVideoLibraryScanner,
                libraryRepository,
                configElementRepository,
                Substitute.For<ILogger<SynchronizeJellyfinLibraryByIdHandler>>());

            Either<BaseError, string> result = await handler.Handle(
                new SynchronizeJellyfinLibraryById("http://ersatztv.example", library.Id, true, true),
                CancellationToken.None);

            result.LeftToSeq().ShouldBeEmpty();
            result.IsRight.ShouldBeTrue();
            result.RightToSeq().Single().ShouldBe("Concerts");
            await jellyfinMusicVideoLibraryScanner.Received(1).ScanLibrary(
                Arg.Is<JellyfinConnectionParameters>(cp =>
                    cp.Address == "http://jellyfin.example" &&
                    cp.ApiKey == "abc" &&
                    cp.MediaSourceId == 7),
                library,
                true,
                Arg.Any<CancellationToken>());
            await libraryRepository.Received(1).UpdateLastScan(library);
        }

        [Test]
        public async Task Should_Not_Log_Error_When_Scan_Is_Canceled()
        {
            var scannerProxy = Substitute.For<IScannerProxy>();
            var mediaSourceRepository = Substitute.For<IMediaSourceRepository>();
            var jellyfinSecretStore = Substitute.For<IJellyfinSecretStore>();
            var jellyfinMovieLibraryScanner = Substitute.For<IJellyfinMovieLibraryScanner>();
            var jellyfinTelevisionLibraryScanner = Substitute.For<IJellyfinTelevisionLibraryScanner>();
            var jellyfinMusicVideoLibraryScanner = Substitute.For<IJellyfinMusicVideoLibraryScanner>();
            var libraryRepository = Substitute.For<ILibraryRepository>();
            var configElementRepository = Substitute.For<IConfigElementRepository>();
            var logger = Substitute.For<ILogger<SynchronizeJellyfinLibraryByIdHandler>>();

            var library = new JellyfinLibrary
            {
                Id = 42,
                Name = "Concerts",
                MediaKind = LibraryMediaKind.MusicVideos,
                MediaSourceId = 7
            };
            var mediaSource = new JellyfinMediaSource
            {
                Id = 7,
                Connections =
                [
                    new JellyfinConnection
                    {
                        Address = "http://jellyfin.example",
                        JellyfinMediaSourceId = 7
                    }
                ]
            };

            mediaSourceRepository.GetJellyfinByLibraryId(library.Id).Returns(Some(mediaSource).AsTask());
            mediaSourceRepository.GetJellyfinLibrary(library.Id).Returns(Some(library).AsTask());
            jellyfinSecretStore.ReadSecrets().Returns(new JellyfinSecrets
            {
                Address = "http://jellyfin.example",
                ApiKey = "abc"
            });
            configElementRepository.GetValue<int>(
                    Arg.Is<ConfigElementKey>(key => key.Key == ConfigElementKey.LibraryRefreshInterval.Key),
                    Arg.Any<CancellationToken>())
                .Returns(Task.FromResult<Option<int>>(Some(0)));
            jellyfinMusicVideoLibraryScanner.ScanLibrary(
                    Arg.Any<JellyfinConnectionParameters>(),
                    library,
                    true,
                    Arg.Any<CancellationToken>())
                .Returns(Left<BaseError, Unit>(new ScanCanceled()).AsTask());

            var handler = new SynchronizeJellyfinLibraryByIdHandler(
                scannerProxy,
                mediaSourceRepository,
                jellyfinSecretStore,
                jellyfinMovieLibraryScanner,
                jellyfinTelevisionLibraryScanner,
                jellyfinMusicVideoLibraryScanner,
                libraryRepository,
                configElementRepository,
                logger);

            await handler.Handle(
                new SynchronizeJellyfinLibraryById("http://ersatztv.example", library.Id, true, true),
                CancellationToken.None);

            // a user-initiated cancellation is not a failure and must not be logged at ERROR (#410)
            await libraryRepository.DidNotReceive().UpdateLastScan(Arg.Any<Library>());
            // prefix match, matching the mixed-library test below: equality would still be exact for
            // this single-kind path, but a prefix cannot be quietly defeated by a reworded error
            logger.ReceivedCalls()
                .Any(call => call.GetArguments().ElementAtOrDefault(2)?.ToString()
                    ?.StartsWith("Error synchronizing jellyfin library:", StringComparison.Ordinal) == true)
                .ShouldBeFalse();
            logger.ReceivedCalls()
                .Any(call => call.GetArguments().ElementAtOrDefault(2)?.ToString()
                    == "Scan of jellyfin library Concerts was canceled")
                .ShouldBeTrue();
        }

        [Test]
        public async Task Should_Scan_All_Three_Kinds_For_Mixed_Libraries()
        {
            var scannerProxy = Substitute.For<IScannerProxy>();
            var mediaSourceRepository = Substitute.For<IMediaSourceRepository>();
            var jellyfinSecretStore = Substitute.For<IJellyfinSecretStore>();
            var jellyfinMovieLibraryScanner = Substitute.For<IJellyfinMovieLibraryScanner>();
            var jellyfinTelevisionLibraryScanner = Substitute.For<IJellyfinTelevisionLibraryScanner>();
            var jellyfinMusicVideoLibraryScanner = Substitute.For<IJellyfinMusicVideoLibraryScanner>();
            var libraryRepository = Substitute.For<ILibraryRepository>();
            var configElementRepository = Substitute.For<IConfigElementRepository>();

            var library = new JellyfinLibrary
            {
                Id = 42,
                Name = "Music Videos",
                MediaKind = LibraryMediaKind.Mixed,
                MediaSourceId = 7
            };
            var mediaSource = new JellyfinMediaSource
            {
                Id = 7,
                Connections =
                [
                    new JellyfinConnection
                    {
                        Address = "http://jellyfin.example",
                        JellyfinMediaSourceId = 7
                    }
                ]
            };

            mediaSourceRepository.GetJellyfinByLibraryId(library.Id).Returns(Some(mediaSource).AsTask());
            mediaSourceRepository.GetJellyfinLibrary(library.Id).Returns(Some(library).AsTask());
            jellyfinSecretStore.ReadSecrets().Returns(new JellyfinSecrets
            {
                Address = "http://jellyfin.example",
                ApiKey = "abc"
            });
            configElementRepository.GetValue<int>(
                    Arg.Is<ConfigElementKey>(key => key.Key == ConfigElementKey.LibraryRefreshInterval.Key),
                    Arg.Any<CancellationToken>())
                .Returns(Task.FromResult<Option<int>>(Some(0)));

            jellyfinMovieLibraryScanner.ScanLibrary(
                    Arg.Any<JellyfinConnectionParameters>(), library, true, Arg.Any<CancellationToken>())
                .Returns(Right<BaseError, Unit>(Unit.Default).AsTask());
            jellyfinTelevisionLibraryScanner.ScanLibrary(
                    Arg.Any<JellyfinConnectionParameters>(), library, true, Arg.Any<CancellationToken>())
                .Returns(Right<BaseError, Unit>(Unit.Default).AsTask());
            jellyfinMusicVideoLibraryScanner.ScanLibrary(
                    Arg.Any<JellyfinConnectionParameters>(), library, true, Arg.Any<CancellationToken>())
                .Returns(Right<BaseError, Unit>(Unit.Default).AsTask());

            var handler = new SynchronizeJellyfinLibraryByIdHandler(
                scannerProxy,
                mediaSourceRepository,
                jellyfinSecretStore,
                jellyfinMovieLibraryScanner,
                jellyfinTelevisionLibraryScanner,
                jellyfinMusicVideoLibraryScanner,
                libraryRepository,
                configElementRepository,
                Substitute.For<ILogger<SynchronizeJellyfinLibraryByIdHandler>>());

            Either<BaseError, string> result = await handler.Handle(
                new SynchronizeJellyfinLibraryById("http://ersatztv.example", library.Id, true, true),
                CancellationToken.None);

            result.LeftToSeq().ShouldBeEmpty();
            result.IsRight.ShouldBeTrue();
            result.RightToSeq().Single().ShouldBe("Music Videos");

            await jellyfinMovieLibraryScanner.Received(1).ScanLibrary(
                Arg.Any<JellyfinConnectionParameters>(), library, true, Arg.Any<CancellationToken>());
            await jellyfinTelevisionLibraryScanner.Received(1).ScanLibrary(
                Arg.Any<JellyfinConnectionParameters>(), library, true, Arg.Any<CancellationToken>());
            await jellyfinMusicVideoLibraryScanner.Received(1).ScanLibrary(
                Arg.Any<JellyfinConnectionParameters>(), library, true, Arg.Any<CancellationToken>());
            await libraryRepository.Received(1).UpdateLastScan(library);
        }

        [Test]
        public async Task Should_Run_Remaining_Scanners_When_One_Fails_For_Mixed_Libraries()
        {
            var scannerProxy = Substitute.For<IScannerProxy>();
            var mediaSourceRepository = Substitute.For<IMediaSourceRepository>();
            var jellyfinSecretStore = Substitute.For<IJellyfinSecretStore>();
            var jellyfinMovieLibraryScanner = Substitute.For<IJellyfinMovieLibraryScanner>();
            var jellyfinTelevisionLibraryScanner = Substitute.For<IJellyfinTelevisionLibraryScanner>();
            var jellyfinMusicVideoLibraryScanner = Substitute.For<IJellyfinMusicVideoLibraryScanner>();
            var libraryRepository = Substitute.For<ILibraryRepository>();
            var configElementRepository = Substitute.For<IConfigElementRepository>();

            var library = new JellyfinLibrary
            {
                Id = 42,
                Name = "Music Videos",
                MediaKind = LibraryMediaKind.Mixed,
                MediaSourceId = 7
            };
            var mediaSource = new JellyfinMediaSource
            {
                Id = 7,
                Connections =
                [
                    new JellyfinConnection
                    {
                        Address = "http://jellyfin.example",
                        JellyfinMediaSourceId = 7
                    }
                ]
            };

            mediaSourceRepository.GetJellyfinByLibraryId(library.Id).Returns(Some(mediaSource).AsTask());
            mediaSourceRepository.GetJellyfinLibrary(library.Id).Returns(Some(library).AsTask());
            jellyfinSecretStore.ReadSecrets().Returns(new JellyfinSecrets
            {
                Address = "http://jellyfin.example",
                ApiKey = "abc"
            });
            configElementRepository.GetValue<int>(
                    Arg.Is<ConfigElementKey>(key => key.Key == ConfigElementKey.LibraryRefreshInterval.Key),
                    Arg.Any<CancellationToken>())
                .Returns(Task.FromResult<Option<int>>(Some(0)));

            jellyfinMovieLibraryScanner.ScanLibrary(
                    Arg.Any<JellyfinConnectionParameters>(), library, true, Arg.Any<CancellationToken>())
                .Returns(Left<BaseError, Unit>(BaseError.New("movie scan blew up")).AsTask());
            jellyfinTelevisionLibraryScanner.ScanLibrary(
                    Arg.Any<JellyfinConnectionParameters>(), library, true, Arg.Any<CancellationToken>())
                .Returns(Right<BaseError, Unit>(Unit.Default).AsTask());
            jellyfinMusicVideoLibraryScanner.ScanLibrary(
                    Arg.Any<JellyfinConnectionParameters>(), library, true, Arg.Any<CancellationToken>())
                .Returns(Right<BaseError, Unit>(Unit.Default).AsTask());

            var handler = new SynchronizeJellyfinLibraryByIdHandler(
                scannerProxy,
                mediaSourceRepository,
                jellyfinSecretStore,
                jellyfinMovieLibraryScanner,
                jellyfinTelevisionLibraryScanner,
                jellyfinMusicVideoLibraryScanner,
                libraryRepository,
                configElementRepository,
                Substitute.For<ILogger<SynchronizeJellyfinLibraryByIdHandler>>());

            Either<BaseError, string> result = await handler.Handle(
                new SynchronizeJellyfinLibraryById("http://ersatztv.example", library.Id, true, true),
                CancellationToken.None);

            // the movie arm failed, so the library as a whole failed...
            result.IsLeft.ShouldBeTrue();

            // ...but the other two kinds were still ingested
            await jellyfinTelevisionLibraryScanner.Received(1).ScanLibrary(
                Arg.Any<JellyfinConnectionParameters>(), library, true, Arg.Any<CancellationToken>());
            await jellyfinMusicVideoLibraryScanner.Received(1).ScanLibrary(
                Arg.Any<JellyfinConnectionParameters>(), library, true, Arg.Any<CancellationToken>());

            // and LastScan is not stamped, because the scan was not fully successful
            await libraryRepository.DidNotReceive().UpdateLastScan(Arg.Any<Library>());
        }

        [Test]
        public async Task Should_Stop_Scanning_Mixed_Library_When_Canceled()
        {
            var scannerProxy = Substitute.For<IScannerProxy>();
            var mediaSourceRepository = Substitute.For<IMediaSourceRepository>();
            var jellyfinSecretStore = Substitute.For<IJellyfinSecretStore>();
            var jellyfinMovieLibraryScanner = Substitute.For<IJellyfinMovieLibraryScanner>();
            var jellyfinTelevisionLibraryScanner = Substitute.For<IJellyfinTelevisionLibraryScanner>();
            var jellyfinMusicVideoLibraryScanner = Substitute.For<IJellyfinMusicVideoLibraryScanner>();
            var libraryRepository = Substitute.For<ILibraryRepository>();
            var configElementRepository = Substitute.For<IConfigElementRepository>();

            var library = new JellyfinLibrary
            {
                Id = 42,
                Name = "Music Videos",
                MediaKind = LibraryMediaKind.Mixed,
                MediaSourceId = 7
            };
            var mediaSource = new JellyfinMediaSource
            {
                Id = 7,
                Connections =
                [
                    new JellyfinConnection
                    {
                        Address = "http://jellyfin.example",
                        JellyfinMediaSourceId = 7
                    }
                ]
            };

            mediaSourceRepository.GetJellyfinByLibraryId(library.Id).Returns(Some(mediaSource).AsTask());
            mediaSourceRepository.GetJellyfinLibrary(library.Id).Returns(Some(library).AsTask());
            jellyfinSecretStore.ReadSecrets().Returns(new JellyfinSecrets
            {
                Address = "http://jellyfin.example",
                ApiKey = "abc"
            });
            configElementRepository.GetValue<int>(
                    Arg.Is<ConfigElementKey>(key => key.Key == ConfigElementKey.LibraryRefreshInterval.Key),
                    Arg.Any<CancellationToken>())
                .Returns(Task.FromResult<Option<int>>(Some(0)));

            var logger = Substitute.For<ILogger<SynchronizeJellyfinLibraryByIdHandler>>();

            jellyfinMovieLibraryScanner.ScanLibrary(
                    Arg.Any<JellyfinConnectionParameters>(), library, true, Arg.Any<CancellationToken>())
                .Returns(Left<BaseError, Unit>(new ScanCanceled()).AsTask());

            var handler = new SynchronizeJellyfinLibraryByIdHandler(
                scannerProxy,
                mediaSourceRepository,
                jellyfinSecretStore,
                jellyfinMovieLibraryScanner,
                jellyfinTelevisionLibraryScanner,
                jellyfinMusicVideoLibraryScanner,
                libraryRepository,
                configElementRepository,
                logger);

            Either<BaseError, string> result = await handler.Handle(
                new SynchronizeJellyfinLibraryById("http://ersatztv.example", library.Id, true, true),
                CancellationToken.None);

            result.IsLeft.ShouldBeTrue();

            // a user-initiated cancellation aborts the whole library; later kinds must not run
            await jellyfinTelevisionLibraryScanner.DidNotReceive().ScanLibrary(
                Arg.Any<JellyfinConnectionParameters>(),
                Arg.Any<JellyfinLibrary>(),
                Arg.Any<bool>(),
                Arg.Any<CancellationToken>());
            await jellyfinMusicVideoLibraryScanner.DidNotReceive().ScanLibrary(
                Arg.Any<JellyfinConnectionParameters>(),
                Arg.Any<JellyfinLibrary>(),
                Arg.Any<bool>(),
                Arg.Any<CancellationToken>());
            await libraryRepository.DidNotReceive().UpdateLastScan(Arg.Any<Library>());

            // ScanMixedLibrary must return the ScanCanceled INSTANCE unwrapped, not folded into an
            // aggregate BaseError -- otherwise `error is ScanCanceled` in the caller fails and a user
            // cancellation is demoted to an ERROR log (#410).
            logger.ReceivedCalls()
                .Any(call => call.GetArguments().ElementAtOrDefault(2)?.ToString()
                    == "Scan of jellyfin library Music Videos was canceled")
                .ShouldBeTrue();
            // prefix match, not equality: if ScanCanceled were folded into the aggregate error the
            // rendered message becomes "...: Mixed library X had 1 scan error(s): Scan was canceled",
            // which an equality assertion would NOT catch -- making the check vacuous
            logger.ReceivedCalls()
                .Any(call => call.GetArguments().ElementAtOrDefault(2)?.ToString()
                    ?.StartsWith("Error synchronizing jellyfin library:", StringComparison.Ordinal) == true)
                .ShouldBeFalse();
        }
    }
}
