using System.Net;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Jellyfin;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Jellyfin;
using ErsatzTV.Core.Metadata;
using ErsatzTV.Infrastructure.Jellyfin;
using LanguageExt;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using NSubstitute;
using NUnit.Framework;
using Shouldly;

namespace ErsatzTV.Infrastructure.Tests.Jellyfin;

public class JellyfinApiClientTests
{
    [TestFixture]
    public class GetLibraries
    {
        [Test]
        public async Task Should_Project_MusicVideo_Libraries()
        {
            const string response = """
                [
                  {
                    "Name": "Concerts",
                    "CollectionType": "musicvideos",
                    "ItemId": "library-1",
                    "LibraryOptions": {
                      "PathInfos": []
                    }
                  }
                ]
                """;

            var client = new JellyfinApiClient(
                new MemoryCache(new MemoryCacheOptions()),
                Substitute.For<IJellyfinPathReplacementService>(),
                Substitute.For<IFallbackMetadataProvider>(),
                new SingleResponseHttpClientFactory(response),
                Substitute.For<ILogger<JellyfinApiClient>>());

            Either<BaseError, List<JellyfinLibrary>> result =
                await client.GetLibraries("http://jellyfin.example", "MediaBrowser Token=abc");

            result.IsRight.ShouldBeTrue();
            List<JellyfinLibrary> libraries = result.RightToSeq().Single();
            libraries.Count.ShouldBe(1);
            libraries[0].Name.ShouldBe("Concerts");
            libraries[0].ItemId.ShouldBe("library-1");
            libraries[0].MediaKind.ShouldBe(LibraryMediaKind.MusicVideos);
            libraries[0].ShouldSyncItems.ShouldBeFalse();
            libraries[0].Paths.Single().Path.ShouldBe("jellyfin://library-1");
        }

        [Test]
        public async Task Should_Project_Mixed_Libraries()
        {
            const string response = """
                [
                  {
                    "Name": "Music Videos",
                    "CollectionType": "mixed",
                    "ItemId": "library-9",
                    "LibraryOptions": {
                      "PathInfos": []
                    }
                  }
                ]
                """;

            var client = new JellyfinApiClient(
                new MemoryCache(new MemoryCacheOptions()),
                Substitute.For<IJellyfinPathReplacementService>(),
                Substitute.For<IFallbackMetadataProvider>(),
                new SingleResponseHttpClientFactory(response),
                Substitute.For<ILogger<JellyfinApiClient>>());

            Either<BaseError, List<JellyfinLibrary>> result =
                await client.GetLibraries("http://jellyfin.example", "MediaBrowser Token=abc");

            result.IsRight.ShouldBeTrue();
            List<JellyfinLibrary> libraries = result.RightToSeq().Single();
            libraries.Count.ShouldBe(1);
            libraries[0].Name.ShouldBe("Music Videos");
            libraries[0].ItemId.ShouldBe("library-9");
            libraries[0].MediaKind.ShouldBe(LibraryMediaKind.Mixed);
            libraries[0].ShouldSyncItems.ShouldBeFalse();
            libraries[0].Paths.Single().Path.ShouldBe("jellyfin://library-9");
        }

        [Test]
        public async Task Should_Project_Libraries_With_No_CollectionType_As_Mixed()
        {
            const string response = """
                [
                  {
                    "Name": "Standup",
                    "ItemId": "library-10",
                    "LibraryOptions": {
                      "PathInfos": []
                    }
                  }
                ]
                """;

            var client = new JellyfinApiClient(
                new MemoryCache(new MemoryCacheOptions()),
                Substitute.For<IJellyfinPathReplacementService>(),
                Substitute.For<IFallbackMetadataProvider>(),
                new SingleResponseHttpClientFactory(response),
                Substitute.For<ILogger<JellyfinApiClient>>());

            Either<BaseError, List<JellyfinLibrary>> result =
                await client.GetLibraries("http://jellyfin.example", "MediaBrowser Token=abc");

            result.IsRight.ShouldBeTrue();
            List<JellyfinLibrary> libraries = result.RightToSeq().Single();
            libraries.Count.ShouldBe(1);
            libraries[0].Name.ShouldBe("Standup");
            libraries[0].MediaKind.ShouldBe(LibraryMediaKind.Mixed);
        }

        // Jellyfin serializes "no content type" as absent, empty or whitespace depending on version;
        // all three mean mixed content, so all three must project identically.
        [TestCase("\"CollectionType\": \"\",")]
        [TestCase("\"CollectionType\": \"   \",")]
        public async Task Should_Project_Libraries_With_Blank_CollectionType_As_Mixed(string collectionTypeLine)
        {
            string response = $$"""
                [
                  {
                    "Name": "Standup",
                    {{collectionTypeLine}}
                    "ItemId": "library-12",
                    "LibraryOptions": {
                      "PathInfos": []
                    }
                  }
                ]
                """;

            var client = new JellyfinApiClient(
                new MemoryCache(new MemoryCacheOptions()),
                Substitute.For<IJellyfinPathReplacementService>(),
                Substitute.For<IFallbackMetadataProvider>(),
                new SingleResponseHttpClientFactory(response),
                Substitute.For<ILogger<JellyfinApiClient>>());

            Either<BaseError, List<JellyfinLibrary>> result =
                await client.GetLibraries("http://jellyfin.example", "MediaBrowser Token=abc");

            result.IsRight.ShouldBeTrue();
            List<JellyfinLibrary> libraries = result.RightToSeq().Single();
            libraries.Count.ShouldBe(1);
            libraries[0].MediaKind.ShouldBe(LibraryMediaKind.Mixed);
        }

        // Guard: mixed must not become a catch-all. Jellyfin "music" (audio) libraries have no
        // supported scanner, so they must keep falling through to None.
        [Test]
        public async Task Should_Not_Project_Unknown_CollectionTypes()
        {
            const string response = """
                [
                  {
                    "Name": "Explo Discovery",
                    "CollectionType": "music",
                    "ItemId": "library-11",
                    "LibraryOptions": {
                      "PathInfos": []
                    }
                  }
                ]
                """;

            var client = new JellyfinApiClient(
                new MemoryCache(new MemoryCacheOptions()),
                Substitute.For<IJellyfinPathReplacementService>(),
                Substitute.For<IFallbackMetadataProvider>(),
                new SingleResponseHttpClientFactory(response),
                Substitute.For<ILogger<JellyfinApiClient>>());

            Either<BaseError, List<JellyfinLibrary>> result =
                await client.GetLibraries("http://jellyfin.example", "MediaBrowser Token=abc");

            result.IsRight.ShouldBeTrue();
            result.RightToSeq().Single().ShouldBeEmpty();
        }
    }

    // ersatztv#177: Jellyfin-sourced music videos rendered weaker MTV-style credits than local NFO
    // libraries because the projection never mapped Album or Track. Verified against the live server
    // (1437 MusicVideo items): Album comes back on 111 of them and IndexNumber on 4, both WITHOUT being
    // named in the `fields` query param — Album is a plain BaseItemDto property, not an ItemFields value,
    // so no Refit `fields` change is needed (and adding one would be wrong).
    [TestFixture]
    public class GetMusicVideoLibraryItems
    {
        [Test]
        public async Task Should_Project_Album_And_Track()
        {
            MusicVideoMetadata metadata = await ProjectSingleMusicVideo(
                "\"Album\": \"Back in Black\", \"IndexNumber\": 3,");

            metadata.Album.ShouldBe("Back in Black");
            metadata.Track.ShouldBe(3);
        }

        // The overwhelming majority of real items have neither (1326 of 1437 live) — absent must stay
        // null rather than becoming "" or 0, so the credits template can omit the line entirely.
        [Test]
        public async Task Should_Leave_Album_And_Track_Null_When_Absent()
        {
            MusicVideoMetadata metadata = await ProjectSingleMusicVideo(string.Empty);

            metadata.Album.ShouldBeNull();
            metadata.Track.ShouldBeNull();
        }

        private static async Task<MusicVideoMetadata> ProjectSingleMusicVideo(string albumAndTrackJson)
        {
            string response = $$"""
                {
                  "Items": [
                    {
                      "Name": "Hells Bells",
                      "Id": "item-1",
                      "Path": "/data/music/ACDC/hells-bells.mkv",
                      "Type": "MusicVideo",
                      "LocationType": "FileSystem",
                      "RunTimeTicks": 3120000000,
                      "DateCreated": "2026-07-01T00:00:00Z",
                      "Artists": ["AC/DC"],
                      {{albumAndTrackJson}}
                      "ProviderIds": {}
                    }
                  ],
                  "TotalRecordCount": 1
                }
                """;

            var client = new JellyfinApiClient(
                new MemoryCache(new MemoryCacheOptions()),
                Substitute.For<IJellyfinPathReplacementService>(),
                Substitute.For<IFallbackMetadataProvider>(),
                new SingleResponseHttpClientFactory(response),
                Substitute.For<ILogger<JellyfinApiClient>>());

            var library = new JellyfinLibrary
            {
                Id = 42,
                MediaSourceId = 1,
                ItemId = "library-1",

                // no PathInfos => the network-path replacement loop (which would need a MediaSource) is skipped
                PathInfos = null
            };

            List<MusicVideo> musicVideos = [];
            await foreach ((MusicVideo musicVideo, int _) in client.GetMusicVideoLibraryItems(
                               "http://jellyfin.example",
                               "MediaBrowser Token=abc",
                               library))
            {
                musicVideos.Add(musicVideo);
            }

            return musicVideos.Single().MusicVideoMetadata.Single();
        }
    }

    // #484: the projection-failure seam. The api client drops an item for two very different reasons and
    // only ONE of them may suppress the downstream reconciliation sweep, so these tests pin the split at
    // the layer that actually makes the distinction.
    [TestFixture]
    public class ProjectionFailureCounter
    {
        // A STRM file is a deliberate, permanent guard-clause skip. It must NOT be reported as a failure:
        // if it were, one STRM file would disable the library's reconciliation sweep on every scan
        // forever and stale rows would accumulate. This is the single most important assertion here.
        [Test]
        public async Task Deliberate_Skip_Is_Not_Counted_As_A_Failure()
        {
            const string response = """
                {
                  "Items": [
                    {
                      "Name": "Streamed",
                      "Id": "item-strm",
                      "Path": "/data/music/streamed.strm",
                      "Type": "MusicVideo",
                      "LocationType": "FileSystem",
                      "RunTimeTicks": 3120000000,
                      "DateCreated": "2026-07-01T00:00:00Z",
                      "ProviderIds": {}
                    },
                    {
                      "Name": "Virtual",
                      "Id": "item-virtual",
                      "Path": "/data/music/virtual.mkv",
                      "Type": "MusicVideo",
                      "LocationType": "Virtual",
                      "RunTimeTicks": 3120000000,
                      "DateCreated": "2026-07-01T00:00:00Z",
                      "ProviderIds": {}
                    },
                    {
                      "Name": "Hells Bells",
                      "Id": "item-1",
                      "Path": "/data/music/hells-bells.mkv",
                      "Type": "MusicVideo",
                      "LocationType": "FileSystem",
                      "RunTimeTicks": 3120000000,
                      "DateCreated": "2026-07-01T00:00:00Z",
                      "ProviderIds": {}
                    }
                  ],
                  "TotalRecordCount": 3
                }
                """;

            var client = new JellyfinApiClient(
                new MemoryCache(new MemoryCacheOptions()),
                Substitute.For<IJellyfinPathReplacementService>(),
                Substitute.For<IFallbackMetadataProvider>(),
                new SingleResponseHttpClientFactory(response),
                Substitute.For<ILogger<JellyfinApiClient>>());

            // no PathInfos => the network-path replacement loop is skipped and nothing can throw
            var library = new JellyfinLibrary { Id = 42, MediaSourceId = 1, ItemId = "library-1" };

            var counter = new MediaServerProjectionFailureCounter();

            List<MusicVideo> musicVideos = [];
            await foreach ((MusicVideo musicVideo, int _) in client.GetMusicVideoLibraryItems(
                               "http://jellyfin.example",
                               "MediaBrowser Token=abc",
                               library,
                               counter))
            {
                musicVideos.Add(musicVideo);
            }

            musicVideos.Count.ShouldBe(1);
            counter.Count.ShouldBe(0);
        }

        // A projection that THREW was swallowed by the catch and the item silently vanished — but the
        // server did return it. That must be reported so the sweep refuses to treat it as a deletion.
        [Test]
        public async Task Swallowed_Projection_Exception_Is_Counted_As_A_Failure()
        {
            const string response = """
                {
                  "Items": [
                    {
                      "Name": "Hells Bells",
                      "Id": "item-1",
                      "Path": "/network/music/hells-bells.mkv",
                      "Type": "MusicVideo",
                      "LocationType": "FileSystem",
                      "RunTimeTicks": 3120000000,
                      "DateCreated": "2026-07-01T00:00:00Z",
                      "ProviderIds": {}
                    }
                  ],
                  "TotalRecordCount": 1
                }
                """;

            var pathReplacementService = Substitute.For<IJellyfinPathReplacementService>();
            pathReplacementService.ReplaceNetworkPath(
                    Arg.Any<JellyfinMediaSource>(),
                    Arg.Any<string>(),
                    Arg.Any<string>(),
                    Arg.Any<string>())
                .Returns(_ => throw new InvalidOperationException("simulated projection regression"));

            var client = new JellyfinApiClient(
                new MemoryCache(new MemoryCacheOptions()),
                pathReplacementService,
                Substitute.For<IFallbackMetadataProvider>(),
                new SingleResponseHttpClientFactory(response),
                Substitute.For<ILogger<JellyfinApiClient>>());

            var library = new JellyfinLibrary
            {
                Id = 42,
                MediaSourceId = 1,
                ItemId = "library-1",
                PathInfos = [new JellyfinPathInfo { NetworkPath = "/network", Path = "/local" }]
            };

            var counter = new MediaServerProjectionFailureCounter();

            List<MusicVideo> musicVideos = [];
            await foreach ((MusicVideo musicVideo, int _) in client.GetMusicVideoLibraryItems(
                               "http://jellyfin.example",
                               "MediaBrowser Token=abc",
                               library,
                               counter))
            {
                musicVideos.Add(musicVideo);
            }

            // the item is gone from the incoming set (exactly the dangerous case) and it is reported
            musicVideos.ShouldBeEmpty();
            counter.Count.ShouldBe(1);
        }

        // The counter is created per enumeration by the caller, never held by the (singleton) client, so
        // two concurrent library scans cannot leak failures into each other's sweep decision.
        [Test]
        public async Task Counters_Are_Per_Enumeration_And_Do_Not_Leak_Across_Scans()
        {
            const string failingResponse = """
                {
                  "Items": [
                    {
                      "Name": "Hells Bells",
                      "Id": "item-1",
                      "Path": "/network/music/hells-bells.mkv",
                      "Type": "MusicVideo",
                      "LocationType": "FileSystem",
                      "RunTimeTicks": 3120000000,
                      "DateCreated": "2026-07-01T00:00:00Z",
                      "ProviderIds": {}
                    }
                  ],
                  "TotalRecordCount": 1
                }
                """;

            var pathReplacementService = Substitute.For<IJellyfinPathReplacementService>();
            pathReplacementService.ReplaceNetworkPath(
                    Arg.Any<JellyfinMediaSource>(),
                    Arg.Any<string>(),
                    Arg.Any<string>(),
                    Arg.Any<string>())
                .Returns(_ => throw new InvalidOperationException("simulated projection regression"));

            var client = new JellyfinApiClient(
                new MemoryCache(new MemoryCacheOptions()),
                pathReplacementService,
                Substitute.For<IFallbackMetadataProvider>(),
                new SingleResponseHttpClientFactory(failingResponse),
                Substitute.For<ILogger<JellyfinApiClient>>());

            var failingLibrary = new JellyfinLibrary
            {
                Id = 42,
                MediaSourceId = 1,
                ItemId = "library-1",
                PathInfos = [new JellyfinPathInfo { NetworkPath = "/network", Path = "/local" }]
            };

            // same client instance, a second library whose items project cleanly
            var healthyLibrary = new JellyfinLibrary { Id = 43, MediaSourceId = 1, ItemId = "library-2" };

            var failingCounter = new MediaServerProjectionFailureCounter();
            await foreach ((MusicVideo _, int _) in client.GetMusicVideoLibraryItems(
                               "http://jellyfin.example",
                               "MediaBrowser Token=abc",
                               failingLibrary,
                               failingCounter))
            {
                // no items survive the failing projection
            }

            var healthyCounter = new MediaServerProjectionFailureCounter();
            await foreach ((MusicVideo _, int _) in client.GetMusicVideoLibraryItems(
                               "http://jellyfin.example",
                               "MediaBrowser Token=abc",
                               healthyLibrary,
                               healthyCounter))
            {
                // the healthy library has no PathInfos, so nothing throws
            }

            failingCounter.Count.ShouldBe(1);
            healthyCounter.Count.ShouldBe(0);
        }
    }

    private sealed class SingleResponseHttpClientFactory(string response) : IHttpClientFactory
    {
        public HttpClient CreateClient(string name) => new(new SingleResponseHttpMessageHandler(response));
    }

    private sealed class SingleResponseHttpMessageHandler(string response) : HttpMessageHandler
    {
        protected override Task<HttpResponseMessage> SendAsync(
            HttpRequestMessage request,
            CancellationToken cancellationToken) =>
            Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
            {
                Content = new StringContent(response)
            });
    }
}
