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

namespace ErsatzTV.Scanner.Tests.Core.Metadata;

// #476: a show/season gone from the media server is absent from the incoming list, so the per-parent
// loop never visits it and the descendant sweeps never run for it. These tests assert the scanner
// cascades the FileNotFound flag to descendants via the repository, using a substituted repository.
// #477: an empty incoming list is treated as a suspect (mid-restore / emptied) fetch and the sweep is
// skipped instead of nuking the whole library — so the #476 cascade is now exercised with a survivor
// present (a genuine partial deletion), and the empty case asserts nothing is flagged.
public class MediaServerTelevisionLibraryScannerTests
{
    [TestFixture]
    public class CleanupFileNotFoundItems
    {
        [Test]
        public async Task Removed_Show_Cascades_FileNotFound_To_Seasons_And_Episodes()
        {
            var televisionRepository = Substitute.For<IJellyfinTelevisionRepository>();
            var scannerProxy = Substitute.For<IScannerProxy>();

            var library = new JellyfinLibrary { Id = 42, Name = "TV Shows" };

            // two shows exist; the survivor is still in the (non-empty) incoming list, so this is a
            // genuine partial deletion — "show-6366" is gone upstream and must be flagged + cascaded.
            // (#477: an EMPTY incoming would instead skip the sweep — see the guard test below.)
            televisionRepository.GetExistingShows(library, Arg.Any<CancellationToken>())
                .Returns(new List<JellyfinItemEtag>
                {
                    new() { ItemId = "show-keep", State = MediaItemState.Normal },
                    new() { ItemId = "show-6366", State = MediaItemState.FileNotFound }
                });

            // the survivor short-circuits to Left so the per-item metadata path (unsupported in this
            // harness) is never entered; it is still recorded as incoming, so it is not swept.
            televisionRepository.GetOrAdd(library, Arg.Any<JellyfinShow>(), Arg.Any<CancellationToken>())
                .Returns(Left<BaseError, MediaItemScanResult<JellyfinShow>>(BaseError.New("skip metadata in test")));

            // the per-item loop reports progress; an unstubbed substitute returns false => ScanCanceled
            scannerProxy.UpdateProgress(Arg.Any<decimal>(), Arg.Any<CancellationToken>()).Returns(true);

            televisionRepository
                .FlagFileNotFoundShows(library, Arg.Is<List<string>>(l => l.Contains("show-6366")),
                    Arg.Any<CancellationToken>())
                .Returns(new List<int> { 100 });
            televisionRepository
                .FlagFileNotFoundSeasonsForShows(Arg.Is<List<int>>(l => l.SequenceEqual(new[] { 100 })),
                    Arg.Any<CancellationToken>())
                .Returns(new List<int> { 200, 201 });
            televisionRepository
                .FlagFileNotFoundEpisodesForSeasons(Arg.Is<List<int>>(l => l.SequenceEqual(new[] { 200, 201 })),
                    Arg.Any<CancellationToken>())
                .Returns(new List<int> { 300, 301, 302 });
            scannerProxy.ReindexMediaItems(Arg.Any<int[]>(), Arg.Any<CancellationToken>()).Returns(true);

            var scanner = new TestTelevisionLibraryScanner(scannerProxy);

            Either<BaseError, Unit> result = await scanner.Scan(
                televisionRepository,
                new JellyfinConnectionParameters("http://jellyfin.example", "abc", 7),
                library,
                Shows(new JellyfinShow
                {
                    ItemId = "show-keep",
                    ShowMetadata = new List<ShowMetadata> { new() { Title = "Keeper" } }
                }));

            result.IsRight.ShouldBeTrue();

            await televisionRepository.Received(1).FlagFileNotFoundShows(
                library,
                Arg.Is<List<string>>(l => l.Count == 1 && l.Contains("show-6366")),
                Arg.Any<CancellationToken>());
            await televisionRepository.Received(1).FlagFileNotFoundSeasonsForShows(
                Arg.Is<List<int>>(l => l.SequenceEqual(new[] { 100 })),
                Arg.Any<CancellationToken>());
            await televisionRepository.Received(1).FlagFileNotFoundEpisodesForSeasons(
                Arg.Is<List<int>>(l => l.SequenceEqual(new[] { 200, 201 })),
                Arg.Any<CancellationToken>());

            // every affected item (show + seasons + episodes) is reindexed so search reflects the new state
            await scannerProxy.Received(1).ReindexMediaItems(
                Arg.Is<int[]>(a => new[] { 100, 200, 201, 300, 301, 302 }.All(id => a.Contains(id))),
                Arg.Any<CancellationToken>());
        }

        [Test]
        public async Task Empty_Incoming_With_Existing_Shows_Does_Not_Flag()
        {
            var televisionRepository = Substitute.For<IJellyfinTelevisionRepository>();
            var scannerProxy = Substitute.For<IScannerProxy>();

            var library = new JellyfinLibrary { Id = 42, Name = "TV Shows" };

            // shows exist locally, but a successful fetch returned ZERO items (server mid-restore or the
            // library was emptied upstream). #477: flagging here would nuke the entire library, so the
            // sweep must be skipped and nothing flagged or reindexed.
            televisionRepository.GetExistingShows(library, Arg.Any<CancellationToken>())
                .Returns(new List<JellyfinItemEtag>
                {
                    new() { ItemId = "show-1", State = MediaItemState.Normal },
                    new() { ItemId = "show-2", State = MediaItemState.Normal }
                });

            var scanner = new TestTelevisionLibraryScanner(scannerProxy);

            Either<BaseError, Unit> result = await scanner.Scan(
                televisionRepository,
                new JellyfinConnectionParameters("http://jellyfin.example", "abc", 7),
                library,
                EmptyShows());

            result.IsRight.ShouldBeTrue();

            await televisionRepository.DidNotReceive().FlagFileNotFoundShows(
                Arg.Any<JellyfinLibrary>(), Arg.Any<List<string>>(), Arg.Any<CancellationToken>());
            await televisionRepository.DidNotReceive().FlagFileNotFoundSeasonsForShows(
                Arg.Any<List<int>>(), Arg.Any<CancellationToken>());
            await televisionRepository.DidNotReceive().FlagFileNotFoundEpisodesForSeasons(
                Arg.Any<List<int>>(), Arg.Any<CancellationToken>());
            await scannerProxy.DidNotReceive().ReindexMediaItems(
                Arg.Any<int[]>(), Arg.Any<CancellationToken>());
        }

        // #484: the same partial deletion as Removed_Show_Cascades_..., but the api client silently dropped
        // an item whose projection threw. "show-6366" is missing from the incoming list only because we
        // failed to build it, not because the server stopped reporting it — flagging it would trash a
        // healthy show (and, via the #476 cascade, all of its seasons and episodes). Paired with the
        // cascade test above as a positive control: identical arrangement, only the counter differs.
        [Test]
        public async Task Projection_Failure_Suppresses_The_Partial_Deletion_Sweep()
        {
            var televisionRepository = Substitute.For<IJellyfinTelevisionRepository>();
            var scannerProxy = Substitute.For<IScannerProxy>();

            var library = new JellyfinLibrary { Id = 42, Name = "TV Shows" };

            televisionRepository.GetExistingShows(library, Arg.Any<CancellationToken>())
                .Returns(new List<JellyfinItemEtag>
                {
                    new() { ItemId = "show-keep", State = MediaItemState.Normal },
                    new() { ItemId = "show-6366", State = MediaItemState.Normal }
                });

            televisionRepository.GetOrAdd(library, Arg.Any<JellyfinShow>(), Arg.Any<CancellationToken>())
                .Returns(Left<BaseError, MediaItemScanResult<JellyfinShow>>(BaseError.New("skip metadata in test")));

            scannerProxy.UpdateProgress(Arg.Any<decimal>(), Arg.Any<CancellationToken>()).Returns(true);

            var projectionFailures = new MediaServerProjectionFailureCounter();
            projectionFailures.RecordFailure();

            var scanner = new TestTelevisionLibraryScanner(scannerProxy);

            Either<BaseError, Unit> result = await scanner.Scan(
                televisionRepository,
                new JellyfinConnectionParameters("http://jellyfin.example", "abc", 7),
                library,
                Shows(new JellyfinShow
                {
                    ItemId = "show-keep",
                    ShowMetadata = new List<ShowMetadata> { new() { Title = "Keeper" } }
                }),
                projectionFailures);

            result.IsRight.ShouldBeTrue();

            await televisionRepository.DidNotReceive().FlagFileNotFoundShows(
                Arg.Any<JellyfinLibrary>(), Arg.Any<List<string>>(), Arg.Any<CancellationToken>());
            await televisionRepository.DidNotReceive().FlagFileNotFoundSeasonsForShows(
                Arg.Any<List<int>>(), Arg.Any<CancellationToken>());
            await televisionRepository.DidNotReceive().FlagFileNotFoundEpisodesForSeasons(
                Arg.Any<List<int>>(), Arg.Any<CancellationToken>());
        }

        // #484 finding 3: the public ScanLibrary creates the counter and must hand the SAME instance to
        // GetShowLibraryItems and to the sweep. A refactor that passed a fresh counter to the api client
        // would keep every other test green and silently kill the protection; this one goes red.
        [Test]
        public async Task Public_ScanLibrary_Joins_The_Api_Counter_To_The_Sweep()
        {
            var televisionRepository = Substitute.For<IJellyfinTelevisionRepository>();
            var scannerProxy = Substitute.For<IScannerProxy>();
            var library = new JellyfinLibrary { Id = 42, Name = "TV Shows" };

            televisionRepository.GetExistingShows(library, Arg.Any<CancellationToken>())
                .Returns(new List<JellyfinItemEtag>
                {
                    new() { ItemId = "show-keep", State = MediaItemState.Normal },
                    new() { ItemId = "show-6366", State = MediaItemState.Normal }
                });
            televisionRepository.GetOrAdd(library, Arg.Any<JellyfinShow>(), Arg.Any<CancellationToken>())
                .Returns(Left<BaseError, MediaItemScanResult<JellyfinShow>>(BaseError.New("skip metadata in test")));
            scannerProxy.UpdateProgress(Arg.Any<decimal>(), Arg.Any<CancellationToken>()).Returns(true);

            var scanner = new TestTelevisionLibraryScanner(scannerProxy)
            {
                ShowEntries =
                [
                    new JellyfinShow
                    {
                        ItemId = "show-keep",
                        ShowMetadata = new List<ShowMetadata> { new() { Title = "Keeper" } }
                    }
                ],
                ShowProjectionFailures = 1
            };

            Either<BaseError, Unit> result = await scanner.ScanFromApi(
                televisionRepository,
                new JellyfinConnectionParameters("http://jellyfin.example", "abc", 7),
                library);

            result.IsRight.ShouldBeTrue();

            await televisionRepository.DidNotReceive().FlagFileNotFoundShows(
                Arg.Any<JellyfinLibrary>(), Arg.Any<List<string>>(), Arg.Any<CancellationToken>());
        }

        // #484: the per-show SEASON sweep. A projection failure is systematic, so this fires for every
        // show at once — the blast radius is the library's whole season tree plus, via #476, its episodes.
        [TestCase(1, false, TestName = "Season_Sweep_Is_Skipped_When_The_Season_Projection_Failed")]
        [TestCase(0, true, TestName = "Season_Sweep_Still_Runs_With_No_Projection_Failures")]
        public async Task Season_Sweep_Respects_Projection_Failures(int failures, bool expectFlag)
        {
            var televisionRepository = Substitute.For<IJellyfinTelevisionRepository>();
            var scannerProxy = Substitute.For<IScannerProxy>();
            var library = new JellyfinLibrary { Id = 42, Name = "TV Shows" };

            var show = new JellyfinShow
            {
                ItemId = "show-1",
                ShowMetadata = new List<ShowMetadata> { new() { Title = "Keeper" } }
            };

            televisionRepository.GetExistingShows(library, Arg.Any<CancellationToken>())
                .Returns(new List<JellyfinItemEtag> { new() { ItemId = "show-1", State = MediaItemState.Normal } });
            televisionRepository.GetOrAdd(library, Arg.Any<JellyfinShow>(), Arg.Any<CancellationToken>())
                .Returns(Right<BaseError, MediaItemScanResult<JellyfinShow>>(new MediaItemScanResult<JellyfinShow>(show)));
            scannerProxy.UpdateProgress(Arg.Any<decimal>(), Arg.Any<CancellationToken>()).Returns(true);

            // two seasons exist, only one comes back — a partial deletion at the season level
            televisionRepository.GetExistingSeasons(library, Arg.Any<JellyfinShow>(), Arg.Any<CancellationToken>())
                .Returns(new List<JellyfinItemEtag>
                {
                    new() { ItemId = "season-keep", State = MediaItemState.Normal },
                    new() { ItemId = "season-gone", State = MediaItemState.Normal }
                });

            // Left short-circuits the per-season path so ScanEpisodes is never entered
            televisionRepository.GetOrAdd(library, Arg.Any<JellyfinSeason>(), Arg.Any<CancellationToken>())
                .Returns(Left<BaseError, MediaItemScanResult<JellyfinSeason>>(BaseError.New("skip season in test")));

            // an unstubbed substitute returns null, which would NRE in the sweep's Concat
            televisionRepository.FlagFileNotFoundSeasons(
                    Arg.Any<JellyfinLibrary>(), Arg.Any<List<string>>(), Arg.Any<CancellationToken>())
                .Returns(new List<int> { 200 });
            televisionRepository.FlagFileNotFoundEpisodesForSeasons(
                    Arg.Any<List<int>>(), Arg.Any<CancellationToken>())
                .Returns(new List<int> { 300 });
            televisionRepository.FlagFileNotFoundShows(
                    Arg.Any<JellyfinLibrary>(), Arg.Any<List<string>>(), Arg.Any<CancellationToken>())
                .Returns(new List<int>());
            televisionRepository.FlagFileNotFoundSeasonsForShows(
                    Arg.Any<List<int>>(), Arg.Any<CancellationToken>())
                .Returns(new List<int>());
            scannerProxy.ReindexMediaItems(Arg.Any<int[]>(), Arg.Any<CancellationToken>()).Returns(true);

            var scanner = new TestTelevisionLibraryScanner(scannerProxy)
            {
                SeasonEntries = [new JellyfinSeason { ItemId = "season-keep", SeasonNumber = 1 }],
                SeasonProjectionFailures = failures
            };

            Either<BaseError, Unit> result = await scanner.Scan(
                televisionRepository,
                new JellyfinConnectionParameters("http://jellyfin.example", "abc", 7),
                library,
                Shows(show));

            result.IsRight.ShouldBeTrue();

            if (expectFlag)
            {
                await televisionRepository.Received(1).FlagFileNotFoundSeasons(
                    library,
                    Arg.Is<List<string>>(l => l.Count == 1 && l.Contains("season-gone")),
                    Arg.Any<CancellationToken>());
            }
            else
            {
                await televisionRepository.DidNotReceive().FlagFileNotFoundSeasons(
                    Arg.Any<JellyfinLibrary>(), Arg.Any<List<string>>(), Arg.Any<CancellationToken>());
            }
        }

        // #484: the per-season EPISODE sweep — the highest-stakes one. A ProjectToEpisode regression makes
        // every season enumerate zero episodes, so existing.Except([]) is the entire episode library.
        [TestCase(1, false, TestName = "Episode_Sweep_Is_Skipped_When_The_Episode_Projection_Failed")]
        [TestCase(0, true, TestName = "Episode_Sweep_Still_Runs_With_No_Projection_Failures")]
        public async Task Episode_Sweep_Respects_Projection_Failures(int failures, bool expectFlag)
        {
            var televisionRepository = Substitute.For<IJellyfinTelevisionRepository>();
            var scannerProxy = Substitute.For<IScannerProxy>();
            var library = new JellyfinLibrary { Id = 42, Name = "TV Shows" };

            var show = new JellyfinShow
            {
                ItemId = "show-1",
                ShowMetadata = new List<ShowMetadata> { new() { Title = "Keeper" } }
            };
            var season = new JellyfinSeason { ItemId = "season-1", SeasonNumber = 1 };

            televisionRepository.GetExistingShows(library, Arg.Any<CancellationToken>())
                .Returns(new List<JellyfinItemEtag> { new() { ItemId = "show-1", State = MediaItemState.Normal } });
            televisionRepository.GetOrAdd(library, Arg.Any<JellyfinShow>(), Arg.Any<CancellationToken>())
                .Returns(Right<BaseError, MediaItemScanResult<JellyfinShow>>(new MediaItemScanResult<JellyfinShow>(show)));
            scannerProxy.UpdateProgress(Arg.Any<decimal>(), Arg.Any<CancellationToken>()).Returns(true);

            // the season comes back, so the season sweep flags nothing and cannot mask the episode assertion
            televisionRepository.GetExistingSeasons(library, Arg.Any<JellyfinShow>(), Arg.Any<CancellationToken>())
                .Returns(new List<JellyfinItemEtag> { new() { ItemId = "season-1", State = MediaItemState.Normal } });
            televisionRepository.GetOrAdd(library, Arg.Any<JellyfinSeason>(), Arg.Any<CancellationToken>())
                .Returns(Right<BaseError, MediaItemScanResult<JellyfinSeason>>(new MediaItemScanResult<JellyfinSeason>(season)));

            // two episodes exist, only one comes back — a partial deletion at the episode level
            televisionRepository.GetExistingEpisodes(library, Arg.Any<JellyfinSeason>(), Arg.Any<CancellationToken>())
                .Returns(new List<JellyfinItemEtag>
                {
                    new() { ItemId = "ep-keep", Etag = "e1", State = MediaItemState.Normal },
                    new() { ItemId = "ep-gone", Etag = "e2", State = MediaItemState.Normal }
                });

            // an unstubbed substitute returns null, which would NRE in the sweeps
            televisionRepository.FlagFileNotFoundEpisodes(
                    Arg.Any<JellyfinLibrary>(), Arg.Any<List<string>>(), Arg.Any<CancellationToken>())
                .Returns(new List<int> { 300 });
            televisionRepository.FlagFileNotFoundSeasons(
                    Arg.Any<JellyfinLibrary>(), Arg.Any<List<string>>(), Arg.Any<CancellationToken>())
                .Returns(new List<int>());
            televisionRepository.FlagFileNotFoundEpisodesForSeasons(
                    Arg.Any<List<int>>(), Arg.Any<CancellationToken>())
                .Returns(new List<int>());
            televisionRepository.FlagFileNotFoundShows(
                    Arg.Any<JellyfinLibrary>(), Arg.Any<List<string>>(), Arg.Any<CancellationToken>())
                .Returns(new List<int>());
            televisionRepository.FlagFileNotFoundSeasonsForShows(
                    Arg.Any<List<int>>(), Arg.Any<CancellationToken>())
                .Returns(new List<int>());
            scannerProxy.ReindexMediaItems(Arg.Any<int[]>(), Arg.Any<CancellationToken>()).Returns(true);

            var scanner = new TestTelevisionLibraryScanner(scannerProxy)
            {
                SeasonEntries = [season],

                // matching etag + missing local file => ShouldScanItem short-circuits before the metadata
                // path, but the id is already recorded as incoming
                EpisodeEntries = [new JellyfinEpisode { ItemId = "ep-keep", Etag = "e1" }],
                EpisodeProjectionFailures = failures
            };

            Either<BaseError, Unit> result = await scanner.Scan(
                televisionRepository,
                new JellyfinConnectionParameters("http://jellyfin.example", "abc", 7),
                library,
                Shows(show));

            result.IsRight.ShouldBeTrue();

            if (expectFlag)
            {
                await televisionRepository.Received(1).FlagFileNotFoundEpisodes(
                    library,
                    Arg.Is<List<string>>(l => l.Count == 1 && l.Contains("ep-gone")),
                    Arg.Any<CancellationToken>());
            }
            else
            {
                await televisionRepository.DidNotReceive().FlagFileNotFoundEpisodes(
                    Arg.Any<JellyfinLibrary>(), Arg.Any<List<string>>(), Arg.Any<CancellationToken>());
            }

            // the season counter is a separate instance and stays clean either way
            await televisionRepository.DidNotReceive().FlagFileNotFoundSeasons(
                Arg.Any<JellyfinLibrary>(),
                Arg.Is<List<string>>(l => l.Count > 0),
                Arg.Any<CancellationToken>());
        }

        private static async IAsyncEnumerable<Tuple<JellyfinShow, int>> EmptyShows()
        {
            await Task.CompletedTask;
            yield break;
        }

        private static async IAsyncEnumerable<Tuple<JellyfinShow, int>> Shows(params JellyfinShow[] shows)
        {
            await Task.CompletedTask;
            foreach (JellyfinShow show in shows)
            {
                yield return new Tuple<JellyfinShow, int>(show, shows.Length);
            }
        }
    }

    // Minimal concrete subclass that exposes the abstract scanner's cleanup path. The incoming show list
    // is supplied directly (empty = "all shows removed"). #484 extended it to also drive the NESTED season
    // and episode sweeps: SeasonEntries/EpisodeEntries supply those enumerations, and
    // SeasonProjectionFailures/EpisodeProjectionFailures make the enumeration report failures into the
    // counter the SCANNER handed it — so these tests also pin same-instance wiring at both nested levels.
    private sealed class TestTelevisionLibraryScanner : MediaServerTelevisionLibraryScanner<
        JellyfinConnectionParameters, JellyfinLibrary, JellyfinShow, JellyfinSeason, JellyfinEpisode,
        JellyfinItemEtag>
    {
        public TestTelevisionLibraryScanner(IScannerProxy scannerProxy)
            : base(
                scannerProxy,
                Substitute.For<IFileSystem>(),
                Substitute.For<ILocalChaptersProvider>(),
                Substitute.For<IMetadataRepository>(),
                Substitute.For<ILogger>())
        {
        }

        // #484: incoming shows for the top-level (public) ScanLibrary entry point, plus how many
        // projection failures that enumeration should report into the counter the scanner created.
        public JellyfinShow[] ShowEntries { get; init; } = [];
        public int ShowProjectionFailures { get; init; }

        public JellyfinSeason[] SeasonEntries { get; init; } = [];
        public int SeasonProjectionFailures { get; init; }

        public JellyfinEpisode[] EpisodeEntries { get; init; } = [];
        public int EpisodeProjectionFailures { get; init; }

        public Task<Either<BaseError, Unit>> Scan(
            IMediaServerTelevisionRepository<JellyfinLibrary, JellyfinShow, JellyfinSeason, JellyfinEpisode,
                JellyfinItemEtag> televisionRepository,
            JellyfinConnectionParameters connectionParameters,
            JellyfinLibrary library,
            IAsyncEnumerable<Tuple<JellyfinShow, int>> showEntries,
            MediaServerProjectionFailureCounter? projectionFailures = null) =>
            ScanLibrary(
                televisionRepository,
                connectionParameters,
                library,
                _ => string.Empty,
                showEntries,
                projectionFailures ?? new MediaServerProjectionFailureCounter(),
                false,
                CancellationToken.None);

        // #484 finding 3: the PUBLIC entry point, which creates the counter itself and must hand the SAME
        // instance to both GetShowLibraryItems and the sweep. Nothing else exercises that join.
        public Task<Either<BaseError, Unit>> ScanFromApi(
            IMediaServerTelevisionRepository<JellyfinLibrary, JellyfinShow, JellyfinSeason, JellyfinEpisode,
                JellyfinItemEtag> televisionRepository,
            JellyfinConnectionParameters connectionParameters,
            JellyfinLibrary library) =>
            ScanLibrary(
                televisionRepository,
                connectionParameters,
                library,
                _ => string.Empty,
                false,
                CancellationToken.None);

        protected override IAsyncEnumerable<Tuple<JellyfinShow, int>> GetShowLibraryItems(
            JellyfinConnectionParameters connectionParameters,
            JellyfinLibrary library,
            MediaServerProjectionFailureCounter projectionFailures) =>
            Enumerate(ShowEntries, ShowProjectionFailures, projectionFailures);

        protected override string MediaServerItemId(JellyfinShow show) => show.ItemId;
        protected override string MediaServerItemId(JellyfinSeason season) => season.ItemId;
        protected override string MediaServerItemId(JellyfinEpisode episode) => episode.ItemId;
        protected override string MediaServerEtag(JellyfinShow show) => show.Etag;
        protected override string MediaServerEtag(JellyfinSeason season) => season.Etag;
        protected override string MediaServerEtag(JellyfinEpisode episode) => episode.Etag;

        protected override IAsyncEnumerable<Tuple<JellyfinSeason, int>> GetSeasonLibraryItems(
            JellyfinLibrary library, JellyfinConnectionParameters connectionParameters, JellyfinShow show,
            MediaServerProjectionFailureCounter projectionFailures) =>
            Enumerate(SeasonEntries, SeasonProjectionFailures, projectionFailures);

        protected override IAsyncEnumerable<Tuple<JellyfinEpisode, int>> GetEpisodeLibraryItems(
            JellyfinLibrary library, JellyfinConnectionParameters connectionParameters, JellyfinShow show,
            JellyfinSeason season, bool isNewSeason,
            MediaServerProjectionFailureCounter projectionFailures) =>
            Enumerate(EpisodeEntries, EpisodeProjectionFailures, projectionFailures);

        // mirrors production: failures are recorded DURING enumeration, into the counter the caller
        // supplied, and are only read after the enumeration completes.
        private static async IAsyncEnumerable<Tuple<T, int>> Enumerate<T>(
            T[] items,
            int projectionFailureCount,
            MediaServerProjectionFailureCounter projectionFailures)
        {
            await Task.CompletedTask;
            for (var i = 0; i < projectionFailureCount; i++)
            {
                projectionFailures.RecordFailure();
            }

            foreach (T item in items)
            {
                yield return new Tuple<T, int>(item, items.Length + projectionFailureCount);
            }
        }

        // returning None makes the base's UpdateMetadata a passthrough, so the per-item metadata path is
        // never entered and the nested sweeps can be driven without a full metadata harness.
        protected override Task<Option<ShowMetadata>> GetFullMetadata(
            JellyfinConnectionParameters connectionParameters, JellyfinLibrary library,
            MediaItemScanResult<JellyfinShow> result, JellyfinShow incoming, bool deepScan) =>
            Task.FromResult(Option<ShowMetadata>.None);

        protected override Task<Option<SeasonMetadata>> GetFullMetadata(
            JellyfinConnectionParameters connectionParameters, JellyfinLibrary library,
            MediaItemScanResult<JellyfinSeason> result, JellyfinSeason incoming, bool deepScan) =>
            Task.FromResult(Option<SeasonMetadata>.None);

        protected override Task<Option<EpisodeMetadata>> GetFullMetadata(
            JellyfinConnectionParameters connectionParameters, JellyfinLibrary library,
            MediaItemScanResult<JellyfinEpisode> result, JellyfinEpisode incoming, bool deepScan) =>
            throw new NotSupportedException();

        protected override Task<Option<Tuple<EpisodeMetadata, MediaVersion>>> GetFullMetadataAndStatistics(
            JellyfinConnectionParameters connectionParameters, JellyfinLibrary library,
            MediaItemScanResult<JellyfinEpisode> result, JellyfinEpisode incoming) =>
            throw new NotSupportedException();

        protected override Task<Either<BaseError, MediaItemScanResult<JellyfinShow>>> UpdateMetadata(
            MediaItemScanResult<JellyfinShow> result, ShowMetadata fullMetadata) =>
            throw new NotSupportedException();

        protected override Task<Either<BaseError, MediaItemScanResult<JellyfinSeason>>> UpdateMetadata(
            MediaItemScanResult<JellyfinSeason> result, SeasonMetadata fullMetadata) =>
            throw new NotSupportedException();

        protected override Task<Either<BaseError, MediaItemScanResult<JellyfinEpisode>>> UpdateMetadata(
            MediaItemScanResult<JellyfinEpisode> result, EpisodeMetadata fullMetadata,
            CancellationToken cancellationToken) =>
            throw new NotSupportedException();
    }
}
