using System.Reflection;
using System.Threading.Channels;
using SysChannels = System.Threading.Channels;
using ErsatzTV.Application;
using ErsatzTV.Application.Plex;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Services;
using ErsatzTV.Tests.Support;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using NUnit.Framework;
using Shouldly;

namespace ErsatzTV.Tests.Services;

/// <summary>
///     Verifies the scheduler's lock-once-per-provider collection batch (#235): it acquires
///     <c>LockPlexCollections()</c> exactly once, and when the slot is already held (an API scan or a
///     prior tick) it SKIPS the collection enqueue entirely rather than enqueuing an unlocked scan
///     that would cross-release the holder's lock (#250). When it does acquire, only the LAST message
///     carries the release (<c>Unlock: true</c>).
/// </summary>
[TestFixture]
public class SchedulerServiceCollectionLockTests
{
    private InMemoryTvContext _db;

    [SetUp]
    public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync();

    [TearDown]
    public async Task TearDown() => await _db.DisposeAsync();

    [Test]
    public async Task Plex_SkipsCollectionEnqueue_WhenCollectionsLockAlreadyHeld()
    {
        await SeedPlexLibrary(id: 1, mediaSourceId: 7);

        var locker = Substitute.For<IEntityLocker>();
        // isolate the collection path: the library loop enqueues nothing but still records the source id
        locker.LockLibrary(Arg.Any<int>()).Returns(false);
        // the collections slot is already held by another party
        locker.LockPlexCollections().Returns(false);

        (SchedulerService service, SysChannels.Channel<IScannerBackgroundServiceRequest> scannerChannel) = BuildService(locker);

        await InvokeScanPlex(service);
        List<IScannerBackgroundServiceRequest> writes = await Drain(scannerChannel);

        locker.Received(1).LockPlexCollections();
        writes.OfType<SynchronizePlexCollections>().ShouldBeEmpty();
        locker.DidNotReceive().UnlockPlexCollections();
    }

    [Test]
    public async Task Plex_EnqueuesBatch_LastMessageOwnsRelease_WhenLockAcquired()
    {
        await SeedPlexLibrary(id: 1, mediaSourceId: 7);
        await SeedPlexLibrary(id: 2, mediaSourceId: 8);

        var locker = Substitute.For<IEntityLocker>();
        locker.LockLibrary(Arg.Any<int>()).Returns(false);
        locker.LockPlexCollections().Returns(true);

        (SchedulerService service, SysChannels.Channel<IScannerBackgroundServiceRequest> scannerChannel) = BuildService(locker);

        await InvokeScanPlex(service);
        List<IScannerBackgroundServiceRequest> writes = await Drain(scannerChannel);

        locker.Received(1).LockPlexCollections();

        List<SynchronizePlexCollections> collectionScans = writes.OfType<SynchronizePlexCollections>().ToList();
        collectionScans.Count.ShouldBe(2);
        // exactly one message owns the single release, and it is the last one written
        collectionScans.Count(m => m.Unlock).ShouldBe(1);
        collectionScans.Last().Unlock.ShouldBeTrue();
        collectionScans.First().Unlock.ShouldBeFalse();

        // release is handed off to the last message; no compensating unlock on the happy path
        locker.DidNotReceive().UnlockPlexCollections();
    }

    private async Task SeedPlexLibrary(int id, int mediaSourceId)
    {
        await using TvContext context = _db.CreateContext();
        context.PlexLibraries.Add(
            new PlexLibrary
            {
                Id = id,
                Name = $"Plex {id}",
                MediaKind = LibraryMediaKind.Movies,
                MediaSourceId = mediaSourceId,
                ShouldSyncItems = true,
                Key = $"key-{id}"
            });
        await context.SaveChangesAsync();
    }

    private (SchedulerService, SysChannels.Channel<IScannerBackgroundServiceRequest>) BuildService(IEntityLocker locker)
    {
        var provider = Substitute.For<IServiceProvider>();
        provider.GetService(typeof(TvContext)).Returns(_ => _db.CreateContext());

        var scope = Substitute.For<IServiceScope>();
        scope.ServiceProvider.Returns(provider);

        var scopeFactory = Substitute.For<IServiceScopeFactory>();
        scopeFactory.CreateScope().Returns(scope);

        var workerChannel = SysChannels.Channel.CreateUnbounded<IBackgroundServiceRequest>();
        var scannerChannel = SysChannels.Channel.CreateUnbounded<IScannerBackgroundServiceRequest>();

        var startup = new SystemStartup();

        var service = new SchedulerService(
            scopeFactory,
            workerChannel.Writer,
            scannerChannel.Writer,
            locker,
            startup,
            NullLogger<SchedulerService>.Instance);

        return (service, scannerChannel);
    }

    private static async Task<List<IScannerBackgroundServiceRequest>> Drain(
        SysChannels.Channel<IScannerBackgroundServiceRequest> channel)
    {
        channel.Writer.Complete();
        var results = new List<IScannerBackgroundServiceRequest>();
        await foreach (IScannerBackgroundServiceRequest request in channel.Reader.ReadAllAsync())
        {
            results.Add(request);
        }

        return results;
    }

    private static async Task InvokeScanPlex(SchedulerService service)
    {
        MethodInfo method = typeof(SchedulerService)
            .GetMethod("ScanPlexMediaSources", BindingFlags.NonPublic | BindingFlags.Instance)!;
        var task = (Task)method.Invoke(service, [CancellationToken.None])!;
        await task.WaitAsync(TimeSpan.FromSeconds(10));
    }
}
