using System.IO.Abstractions;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Metadata;
using ErsatzTV.Scanner.Core.Interfaces;
using ErsatzTV.Scanner.Core.Interfaces.Metadata;
using ErsatzTV.Scanner.Core.Plex;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using NUnit.Framework;
using Shouldly;

namespace ErsatzTV.Scanner.Tests.Core.Plex;

// ersatztv#500: the remove-stale + add-new idiom materializes the add set (.ToList()) BEFORE the loop
// mutates existingMetadata, so two identically-named incoming entries both passed the add filter and both
// got inserted — a duplicate row. This is the ORIGINAL of the pattern (#497 mirrored it into the Jellyfin
// music-video scanner, covered in ErsatzTV.Tests/Integration/JellyfinMusicVideoLibraryScannerTests).
// UpdateMetadata is protected, so a minimal test-only subclass exposes it; that is the whole surface under
// test here, which keeps this off the full ScanLibrary path.
public class PlexMovieLibraryScannerTests
{
    [TestFixture]
    public class UpdateMetadata
    {
        // Non-vacuous: dropping the DistinctBy calls in PlexMovieLibraryScanner.UpdateMetadata turns every
        // Received(1) below into 2 calls, and each Count.ShouldBe(1) into 2.
        [Test]
        public async Task Should_Not_Double_Insert_Duplicate_Named_Incoming_Entries()
        {
            var movieRepository = Substitute.For<IMovieRepository>();
            var metadataRepository = Substitute.For<IMetadataRepository>();

            movieRepository.AddGenre(Arg.Any<MovieMetadata>(), Arg.Any<Genre>()).Returns(true);
            movieRepository.AddTag(Arg.Any<MovieMetadata>(), Arg.Any<Tag>()).Returns(true);
            movieRepository.AddStudio(Arg.Any<MovieMetadata>(), Arg.Any<Studio>()).Returns(true);
            movieRepository.AddActor(Arg.Any<MovieMetadata>(), Arg.Any<Actor>()).Returns(true);
            movieRepository.AddDirector(Arg.Any<MovieMetadata>(), Arg.Any<Director>()).Returns(true);
            movieRepository.AddWriter(Arg.Any<MovieMetadata>(), Arg.Any<Writer>()).Returns(true);
            metadataRepository.AddGuid(Arg.Any<ErsatzTV.Core.Domain.Metadata>(), Arg.Any<MetadataGuid>())
                .Returns(true);
            metadataRepository
                .UpdateSubtitles(
                    Arg.Any<ErsatzTV.Core.Domain.Metadata>(),
                    Arg.Any<List<Subtitle>>(),
                    Arg.Any<CancellationToken>())
                .Returns(false);

            // the existing item carries none of the incoming names, so every incoming entry is an "add"
            var existingMetadata = new MovieMetadata
            {
                MetadataKind = MetadataKind.External,
                Genres = [],
                Tags = [],
                Studios = [],
                Actors = [],
                Directors = [],
                Writers = [],
                Guids = [],
                Subtitles = [],
                Artwork = []
            };

            var existing = new PlexMovie { Id = 1, MovieMetadata = [existingMetadata] };

            // Plex reports each name TWICE for the same movie (and the same guid twice)
            var incoming = new MovieMetadata
            {
                MetadataKind = MetadataKind.External,
                ContentRating = existingMetadata.ContentRating,
                Plot = existingMetadata.Plot,
                SortTitle = existingMetadata.SortTitle,
                Genres = [new Genre { Name = "Comedy" }, new Genre { Name = "Comedy" }],
                Tags = [new Tag { Name = "DupTag" }, new Tag { Name = "DupTag" }],
                Studios = [new Studio { Name = "DupStudio" }, new Studio { Name = "DupStudio" }],
                Actors = [new Actor { Name = "DupActor" }, new Actor { Name = "DupActor" }],
                Directors = [new Director { Name = "DupDirector" }, new Director { Name = "DupDirector" }],
                Writers = [new Writer { Name = "DupWriter" }, new Writer { Name = "DupWriter" }],
                Guids = [new MetadataGuid { Guid = "imdb://tt1" }, new MetadataGuid { Guid = "imdb://tt1" }],
                Subtitles = [],
                Artwork = []
            };

            var scanner = new TestPlexMovieLibraryScanner(movieRepository, metadataRepository);

            Either<BaseError, MediaItemScanResult<PlexMovie>> result = await scanner.Update(
                new MediaItemScanResult<PlexMovie>(existing),
                incoming);

            result.IsRight.ShouldBeTrue();

            await movieRepository.Received(1).AddGenre(Arg.Any<MovieMetadata>(), Arg.Is<Genre>(g => g.Name == "Comedy"));
            await movieRepository.Received(1).AddTag(Arg.Any<MovieMetadata>(), Arg.Is<Tag>(t => t.Name == "DupTag"));
            await movieRepository.Received(1)
                .AddStudio(Arg.Any<MovieMetadata>(), Arg.Is<Studio>(s => s.Name == "DupStudio"));
            await movieRepository.Received(1)
                .AddActor(Arg.Any<MovieMetadata>(), Arg.Is<Actor>(a => a.Name == "DupActor"));
            await movieRepository.Received(1)
                .AddDirector(Arg.Any<MovieMetadata>(), Arg.Is<Director>(d => d.Name == "DupDirector"));
            await movieRepository.Received(1)
                .AddWriter(Arg.Any<MovieMetadata>(), Arg.Is<Writer>(w => w.Name == "DupWriter"));
            await metadataRepository.Received(1).AddGuid(
                Arg.Any<ErsatzTV.Core.Domain.Metadata>(),
                Arg.Is<MetadataGuid>(g => g.Guid == "imdb://tt1"));

            // and the in-memory collections mirror the DB: one entry each, not two
            existingMetadata.Genres.Count.ShouldBe(1);
            existingMetadata.Tags.Count.ShouldBe(1);
            existingMetadata.Studios.Count.ShouldBe(1);
            existingMetadata.Actors.Count.ShouldBe(1);
            existingMetadata.Directors.Count.ShouldBe(1);
            existingMetadata.Writers.Count.ShouldBe(1);
            existingMetadata.Guids.Count.ShouldBe(1);
        }

        // POSITIVE CONTROL for the dedup above: distinct incoming entries must still ALL be added, and a
        // stale existing entry must still be removed. Without this, a mis-keyed dedup (or a stray .Take(1))
        // that collapsed genuinely different entries would pass every assertion in the duplicate test.
        [Test]
        public async Task Should_Still_Add_All_Distinct_Entries_And_Remove_Stale_Ones()
        {
            var movieRepository = Substitute.For<IMovieRepository>();
            var metadataRepository = Substitute.For<IMetadataRepository>();

            movieRepository.AddGenre(Arg.Any<MovieMetadata>(), Arg.Any<Genre>()).Returns(true);
            metadataRepository.RemoveGenre(Arg.Any<Genre>()).Returns(true);
            metadataRepository
                .UpdateSubtitles(
                    Arg.Any<ErsatzTV.Core.Domain.Metadata>(),
                    Arg.Any<List<Subtitle>>(),
                    Arg.Any<CancellationToken>())
                .Returns(false);

            var existingMetadata = NewMetadata();
            existingMetadata.Genres = [new Genre { Name = "Keep" }, new Genre { Name = "Stale" }];

            var existing = new PlexMovie { Id = 1, MovieMetadata = [existingMetadata] };

            MovieMetadata incoming = NewMetadata();
            incoming.Genres = [new Genre { Name = "Keep" }, new Genre { Name = "New1" }, new Genre { Name = "New2" }];

            var scanner = new TestPlexMovieLibraryScanner(movieRepository, metadataRepository);

            Either<BaseError, MediaItemScanResult<PlexMovie>> result = await scanner.Update(
                new MediaItemScanResult<PlexMovie>(existing),
                incoming);

            result.IsRight.ShouldBeTrue();

            // both distinct new genres added; the kept one is not re-added; the stale one is removed
            await movieRepository.Received(1).AddGenre(Arg.Any<MovieMetadata>(), Arg.Is<Genre>(g => g.Name == "New1"));
            await movieRepository.Received(1).AddGenre(Arg.Any<MovieMetadata>(), Arg.Is<Genre>(g => g.Name == "New2"));
            await movieRepository.DidNotReceive().AddGenre(Arg.Any<MovieMetadata>(), Arg.Is<Genre>(g => g.Name == "Keep"));
            await metadataRepository.Received(1).RemoveGenre(Arg.Is<Genre>(g => g.Name == "Stale"));
            await metadataRepository.DidNotReceive().RemoveGenre(Arg.Is<Genre>(g => g.Name == "Keep"));

            existingMetadata.Genres.Select(g => g.Name).OrderBy(n => n)
                .ShouldBe(new[] { "Keep", "New1", "New2" });
        }

        // ersatztv#500 review finding: Actors are dedup'd specially. Their REMOVE filter is keyed on
        // (Name, artwork-presence) — that is the mechanism by which an artwork-less actor is dropped and
        // re-added WITH artwork. A bare DistinctBy(a => a.Name) keeps the FIRST duplicate, so if Plex lists
        // the artwork-less copy first the artwork is silently discarded; and because the remove filter would
        // still see the artwork-less duplicate, its upgrade clause stays false, so the stale row is never
        // removed and the artwork never arrives on ANY later scan either. Hence the artwork-preferring dedup
        // shared by both filters.
        // Non-vacuous: reverting to `.DistinctBy(a => a.Name)` in the add chain fails both assertions.
        [Test]
        public async Task Should_Prefer_The_Artwork_Bearing_Duplicate_Actor()
        {
            var movieRepository = Substitute.For<IMovieRepository>();
            var metadataRepository = Substitute.For<IMetadataRepository>();

            movieRepository.AddActor(Arg.Any<MovieMetadata>(), Arg.Any<Actor>()).Returns(true);
            metadataRepository.RemoveActor(Arg.Any<Actor>()).Returns(true);
            metadataRepository
                .UpdateSubtitles(
                    Arg.Any<ErsatzTV.Core.Domain.Metadata>(),
                    Arg.Any<List<Subtitle>>(),
                    Arg.Any<CancellationToken>())
                .Returns(false);

            var existingMetadata = NewMetadata();
            var existing = new PlexMovie { Id = 1, MovieMetadata = [existingMetadata] };

            // Plex lists the same person twice, artwork-less copy FIRST
            MovieMetadata incoming = NewMetadata();
            incoming.Actors =
            [
                new Actor { Name = "Dup", Role = "no-artwork", Artwork = null },
                new Actor
                {
                    Name = "Dup",
                    Role = "with-artwork",
                    Artwork = new Artwork { ArtworkKind = ArtworkKind.Thumbnail, Path = "/art/dup.jpg" }
                }
            ];

            var scanner = new TestPlexMovieLibraryScanner(movieRepository, metadataRepository);

            (await scanner.Update(new MediaItemScanResult<PlexMovie>(existing), incoming)).IsRight.ShouldBeTrue();

            // exactly one actor, and it is the one that carries the artwork (and its Role came along)
            await movieRepository.Received(1).AddActor(Arg.Any<MovieMetadata>(), Arg.Any<Actor>());
            existingMetadata.Actors.Count.ShouldBe(1);
            existingMetadata.Actors[0].Artwork.ShouldNotBeNull();
            existingMetadata.Actors[0].Role.ShouldBe("with-artwork");
        }

        // The follow-on half of the finding: an artwork-less actor ALREADY stored must still be upgraded when
        // the incoming set contains the same name twice. This is the case that used to be permanently stuck.
        [Test]
        public async Task Should_Upgrade_An_Existing_Artworkless_Actor_Despite_Duplicate_Incoming_Names()
        {
            var movieRepository = Substitute.For<IMovieRepository>();
            var metadataRepository = Substitute.For<IMetadataRepository>();

            movieRepository.AddActor(Arg.Any<MovieMetadata>(), Arg.Any<Actor>()).Returns(true);
            metadataRepository.RemoveActor(Arg.Any<Actor>()).Returns(true);
            metadataRepository
                .UpdateSubtitles(
                    Arg.Any<ErsatzTV.Core.Domain.Metadata>(),
                    Arg.Any<List<Subtitle>>(),
                    Arg.Any<CancellationToken>())
                .Returns(false);

            var existingMetadata = NewMetadata();
            existingMetadata.Actors = [new Actor { Name = "Dup", Role = "stale", Artwork = null }];
            var existing = new PlexMovie { Id = 1, MovieMetadata = [existingMetadata] };

            MovieMetadata incoming = NewMetadata();
            incoming.Actors =
            [
                new Actor { Name = "Dup", Role = "no-artwork", Artwork = null },
                new Actor
                {
                    Name = "Dup",
                    Role = "with-artwork",
                    Artwork = new Artwork { ArtworkKind = ArtworkKind.Thumbnail, Path = "/art/dup.jpg" }
                }
            ];

            var scanner = new TestPlexMovieLibraryScanner(movieRepository, metadataRepository);

            (await scanner.Update(new MediaItemScanResult<PlexMovie>(existing), incoming)).IsRight.ShouldBeTrue();

            // the stale artwork-less row is removed and replaced by the artwork-bearing one
            await metadataRepository.Received(1).RemoveActor(Arg.Is<Actor>(a => a.Role == "stale"));
            existingMetadata.Actors.Count.ShouldBe(1);
            existingMetadata.Actors[0].Artwork.ShouldNotBeNull();
        }

        private static MovieMetadata NewMetadata() =>
            new()
            {
                MetadataKind = MetadataKind.External,
                Genres = [],
                Tags = [],
                Studios = [],
                Actors = [],
                Directors = [],
                Writers = [],
                Guids = [],
                Subtitles = [],
                Artwork = []
            };
    }

    // Exposes the protected UpdateMetadata. Every dependency the metadata path does not touch is a
    // substitute; nothing here drives ScanLibrary.
    private sealed class TestPlexMovieLibraryScanner : PlexMovieLibraryScanner
    {
        public TestPlexMovieLibraryScanner(
            IMovieRepository movieRepository,
            IMetadataRepository metadataRepository)
            : base(
                Substitute.For<IScannerProxy>(),
                Substitute.For<IPlexServerApiClient>(),
                movieRepository,
                metadataRepository,
                Substitute.For<IMediaSourceRepository>(),
                Substitute.For<IPlexMovieRepository>(),
                Substitute.For<IPlexMetadataRepository>(),
                Substitute.For<IPlexPathReplacementService>(),
                Substitute.For<IFileSystem>(),
                Substitute.For<ILocalChaptersProvider>(),
                NullLogger<PlexMovieLibraryScanner>.Instance)
        {
        }

        public Task<Either<BaseError, MediaItemScanResult<PlexMovie>>> Update(
            MediaItemScanResult<PlexMovie> result,
            MovieMetadata fullMetadata) =>
            UpdateMetadata(result, fullMetadata, CancellationToken.None);
    }
}
