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

namespace ErsatzTV.Scanner.Tests.Application.MediaSources;

public class ScanLocalLibraryHandlerTests
{
    [TestFixture]
    public class Handle
    {
        [SetUp]
        public void SetUp()
        {
            // the handler validates that the configured ffmpeg/ffprobe paths exist on disk
            _ffmpegPath = Path.GetTempFileName();
            _ffprobePath = Path.GetTempFileName();

            _scannerProxy = Substitute.For<IScannerProxy>();
            _libraryRepository = Substitute.For<ILibraryRepository>();
            _configElementRepository = Substitute.For<IConfigElementRepository>();
            _movieFolderScanner = Substitute.For<IMovieFolderScanner>();
            _logger = Substitute.For<ILogger<ScanLocalLibraryHandler>>();

            _library = new LocalLibrary
            {
                Id = 42,
                Name = "Movies",
                MediaKind = LibraryMediaKind.Movies,
                MediaSourceId = 1,
                Paths = [new LibraryPath { Id = 1, Path = "/movies" }]
            };

            _libraryRepository.GetLibrary(_library.Id).Returns(Some<Library>(_library).AsTask());

            ConfigValue(ConfigElementKey.FFmpegPath, _ffmpegPath);
            ConfigValue(ConfigElementKey.FFprobePath, _ffprobePath);
            ConfigValue(ConfigElementKey.LibraryRefreshInterval, 0);
        }

        [TearDown]
        public void TearDown()
        {
            File.Delete(_ffmpegPath);
            File.Delete(_ffprobePath);
        }

        private string _ffmpegPath;
        private string _ffprobePath;
        private IScannerProxy _scannerProxy;
        private ILibraryRepository _libraryRepository;
        private IConfigElementRepository _configElementRepository;
        private IMovieFolderScanner _movieFolderScanner;
        private ILogger<ScanLocalLibraryHandler> _logger;
        private LocalLibrary _library;

        [Test]
        public async Task Should_Set_Library_LastScan_After_Successful_Scan()
        {
            ScanResult(Right<BaseError, Unit>(Unit.Default));

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

            result.IsRight.ShouldBeTrue();

            // the regression: only the path-level LastScan was written, so the API's
            // library-level scan time stayed null and the SPA showed "Never scanned" forever
            _library.LastScan.ShouldNotBeNull();
            await _libraryRepository.Received(1).UpdateLastScan(_library);
            await _libraryRepository.Received(1).UpdateLastScan(_library.Paths[0]);
        }

        [Test]
        public async Task Should_Not_Set_Library_LastScan_When_A_Path_Scan_Fails()
        {
            ScanResult(Left<BaseError, Unit>(new BaseError("scan failed")));

            await Handler().Handle(
                new ScanLocalLibrary("http://ersatztv.example", _library.Id, true),
                CancellationToken.None);

            _library.LastScan.ShouldBeNull();
            await _libraryRepository.DidNotReceive().UpdateLastScan(Arg.Any<Library>());

            // a failed path suppresses the library-level scan time, reproducing the #264 symptom;
            // without this log the user would have nothing explaining why
            ShouldHaveLogged("Error scanning local library path /movies: scan failed");
        }

        [Test]
        public async Task Should_Not_Log_Error_When_A_Path_Scan_Is_Canceled()
        {
            ScanResult(Left<BaseError, Unit>(new ScanCanceled()));

            await Handler().Handle(
                new ScanLocalLibrary("http://ersatztv.example", _library.Id, true),
                CancellationToken.None);

            // a user-initiated cancellation is not a failure and must not be logged at ERROR (#410);
            // it's still correctly excluded from the "last scan" stamp, same as any other failure
            _library.LastScan.ShouldBeNull();
            await _libraryRepository.DidNotReceive().UpdateLastScan(Arg.Any<Library>());

            _logger.ReceivedCalls()
                .Any(call => call.GetArguments().ElementAtOrDefault(2)?.ToString()
                    == "Error scanning local library path /movies: Scan was canceled")
                .ShouldBeFalse();
            ShouldHaveLogged("Scan of local library path /movies was canceled");
        }

        [Test]
        public async Task Should_Not_Set_Library_LastScan_When_A_Later_Path_Fails()
        {
            var goodPath = new LibraryPath { Id = 1, Path = "/movies" };
            var badPath = new LibraryPath { Id = 2, Path = "/more-movies" };
            _library.Paths = [goodPath, badPath];

            _movieFolderScanner.ScanFolder(
                    goodPath,
                    _ffmpegPath,
                    _ffprobePath,
                    Arg.Any<decimal>(),
                    Arg.Any<decimal>(),
                    Arg.Any<CancellationToken>())
                .Returns(Right<BaseError, Unit>(Unit.Default).AsTask());
            _movieFolderScanner.ScanFolder(
                    badPath,
                    _ffmpegPath,
                    _ffprobePath,
                    Arg.Any<decimal>(),
                    Arg.Any<decimal>(),
                    Arg.Any<CancellationToken>())
                .Returns(Left<BaseError, Unit>(new BaseError("scan failed")).AsTask());

            await Handler().Handle(
                new ScanLocalLibrary("http://ersatztv.example", _library.Id, true),
                CancellationToken.None);

            // a partially-scanned library must not claim a successful scan time
            _library.LastScan.ShouldBeNull();
            await _libraryRepository.DidNotReceive().UpdateLastScan(Arg.Any<Library>());
            await _libraryRepository.Received(1).UpdateLastScan(goodPath);
        }

        [Test]
        public async Task Should_Not_Set_Library_LastScan_When_Scan_Is_Skipped()
        {
            ScanResult(Right<BaseError, Unit>(Unit.Default));
            _library.Paths[0].LastScan = DateTime.UtcNow;
            ConfigValue(ConfigElementKey.LibraryRefreshInterval, 6);

            await Handler().Handle(
                new ScanLocalLibrary("http://ersatztv.example", _library.Id, false),
                CancellationToken.None);

            // nothing was scanned, so there is no new scan time to record
            _library.LastScan.ShouldBeNull();
            await _libraryRepository.DidNotReceive().UpdateLastScan(Arg.Any<Library>());
        }

        private void ConfigValue<T>(ConfigElementKey key, T value) =>
            _configElementRepository.GetValue<T>(
                    Arg.Is<ConfigElementKey>(k => k.Key == key.Key),
                    Arg.Any<CancellationToken>())
                .Returns(Task.FromResult<Option<T>>(Some(value)));

        private void ScanResult(Either<BaseError, Unit> result) =>
            _movieFolderScanner.ScanFolder(
                    Arg.Any<LibraryPath>(),
                    _ffmpegPath,
                    _ffprobePath,
                    Arg.Any<decimal>(),
                    Arg.Any<decimal>(),
                    Arg.Any<CancellationToken>())
                .Returns(result.AsTask());

        private ScanLocalLibraryHandler Handler() =>
            new(
                _scannerProxy,
                _libraryRepository,
                _configElementRepository,
                _movieFolderScanner,
                Substitute.For<ITelevisionFolderScanner>(),
                Substitute.For<IMusicVideoFolderScanner>(),
                Substitute.For<IOtherVideoFolderScanner>(),
                Substitute.For<ISongFolderScanner>(),
                Substitute.For<IImageFolderScanner>(),
                Substitute.For<IRemoteStreamFolderScanner>(),
                _logger);

        // asserts on the substituted ILogger.Log call the LogError extension dispatches to
        // (same idiom as ErsatzTV.Tests' ReleaseMemoryHandlerTests.ShouldHaveLogged)
        private void ShouldHaveLogged(string expectedMessage) =>
            _logger.ReceivedCalls()
                .Any(call => call.GetArguments().ElementAtOrDefault(2)?.ToString() == expectedMessage)
                .ShouldBeTrue();
    }
}
