using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Notifications;
using ErsatzTV.Infrastructure.Locking;
using MediatR;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using NUnit.Framework;
using Shouldly;

namespace ErsatzTV.Infrastructure.Tests.Locking;

// Verifies the atomicity + single-owner release contract from docs/decisions.md 2026-07-11 (issue #231).
// These run against the real EntityLocker (not a mock) so the Interlocked/ConcurrentDictionary state is exercised.
[TestFixture]
public class EntityLockerTests
{
    private const int ParallelCallers = 64;

    private static EntityLocker CreateLocker() =>
        new(Substitute.For<IMediator>(), NullLogger<EntityLocker>.Instance);

    // Every exclusive single-slot kind, adapted to a uniform bool lock/unlock/is-locked shape.
    // Library bakes in a fixed id; RemoteMediaSource a concrete media-source type. Playout is async
    // (tested separately) and so is not in this source.
    private static IEnumerable<LockKind> ExclusiveWinnerKinds()
    {
        foreach (LockKind kind in EventCountingKinds())
        {
            yield return kind;
        }

        yield return new LockKind
        {
            Name = "RemoteMediaSource",
            Lock = l => l.LockRemoteMediaSource<PlexMediaSource>(),
            Unlock = l => l.UnlockRemoteMediaSource<PlexMediaSource>(),
            IsLocked = l => l.IsRemoteMediaSourceLocked<PlexMediaSource>()
        };
    }

    // The kinds whose change event is a plain EventHandler, so a subscriber can count transitions.
    private static IEnumerable<LockKind> EventCountingKinds()
    {
        yield return new LockKind
        {
            Name = "Plex",
            Lock = l => l.LockPlex(),
            Unlock = l => l.UnlockPlex(),
            IsLocked = l => l.IsPlexLocked(),
            Subscribe = (l, h) => l.OnPlexChanged += h
        };
        yield return new LockKind
        {
            Name = "Trakt",
            Lock = l => l.LockTrakt(),
            Unlock = l => l.UnlockTrakt(),
            IsLocked = l => l.IsTraktLocked(),
            Subscribe = (l, h) => l.OnTraktChanged += h
        };
        yield return new LockKind
        {
            Name = "EmbyCollections",
            Lock = l => l.LockEmbyCollections(),
            Unlock = l => l.UnlockEmbyCollections(),
            IsLocked = l => l.AreEmbyCollectionsLocked(),
            Subscribe = (l, h) => l.OnEmbyCollectionsChanged += h
        };
        yield return new LockKind
        {
            Name = "JellyfinCollections",
            Lock = l => l.LockJellyfinCollections(),
            Unlock = l => l.UnlockJellyfinCollections(),
            IsLocked = l => l.AreJellyfinCollectionsLocked(),
            Subscribe = (l, h) => l.OnJellyfinCollectionsChanged += h
        };
        yield return new LockKind
        {
            Name = "PlexCollections",
            Lock = l => l.LockPlexCollections(),
            Unlock = l => l.UnlockPlexCollections(),
            IsLocked = l => l.ArePlexCollectionsLocked(),
            Subscribe = (l, h) => l.OnPlexCollectionsChanged += h
        };
        yield return new LockKind
        {
            Name = "TroubleshootingPlayback",
            Lock = l => l.LockTroubleshootingPlayback(),
            Unlock = l => l.UnlockTroubleshootingPlayback(),
            IsLocked = l => l.IsTroubleshootingPlaybackLocked(),
            Subscribe = (l, h) => l.OnTroubleshootingPlaybackChanged += h
        };
        yield return new LockKind
        {
            Name = "Library",
            Lock = l => l.LockLibrary(1),
            Unlock = l => l.UnlockLibrary(1),
            IsLocked = l => l.IsLibraryLocked(1),
            Subscribe = (l, h) => l.OnLibraryChanged += h
        };
    }

    // Stress the acquire race over many rounds: a single-round 64-thread barrier does NOT reliably
    // collide the non-atomic check-then-set window on fast hardware (verified: it green-lit a
    // deliberately broken flag), so this hammers Lock* for thousands of rounds — each round every
    // thread races one acquire, exactly one must win, then the winner releases to reset. Any round
    // with a winner count != 1 fails the whole test. Against the atomic CAS this is invariant;
    // against a non-atomic flag some round double-acquires.
    [Test]
    [TestCaseSource(nameof(ExclusiveWinnerKinds))]
    public void Lock_ParallelCallers_ExactlyOneWinner(LockKind kind)
    {
        const int threads = 8;
        const int rounds = 20_000;

        EntityLocker locker = CreateLocker();
        var winners = 0;
        var badRounds = 0;

        using var startRound = new Barrier(threads);
        using var endRound = new Barrier(
            threads,
            _ =>
            {
                if (Volatile.Read(ref winners) != 1)
                {
                    Interlocked.Increment(ref badRounds);
                }

                // reset for the next round: release the (single) held lock and clear the counter
                kind.Unlock(locker);
                Volatile.Write(ref winners, 0);
            });

        var workers = new Thread[threads];
        for (var t = 0; t < threads; t++)
        {
            workers[t] = new Thread(() =>
            {
                for (var r = 0; r < rounds; r++)
                {
                    startRound.SignalAndWait();
                    if (kind.Lock(locker))
                    {
                        Interlocked.Increment(ref winners);
                    }

                    endRound.SignalAndWait();
                }
            });
            workers[t].Start();
        }

        foreach (Thread worker in workers)
        {
            worker.Join();
        }

        badRounds.ShouldBe(0);
        kind.IsLocked(locker).ShouldBeFalse();
    }

    [Test]
    public async Task LockPlayout_ParallelCallers_ExactlyOneWinner_PublishesOnce()
    {
        var mediator = Substitute.For<IMediator>();
        var locker = new EntityLocker(mediator, NullLogger<EntityLocker>.Instance);
        const int playoutId = 7;
        using var barrier = new Barrier(ParallelCallers);

        Task<bool>[] tasks = Enumerable.Range(0, ParallelCallers)
            .Select(_ => Task.Run(async () =>
            {
                barrier.SignalAndWait();
                return await locker.LockPlayout(playoutId);
            }))
            .ToArray();

        bool[] results = await Task.WhenAll(tasks);

        results.Count(r => r).ShouldBe(1);
        locker.IsPlayoutLocked(playoutId).ShouldBeTrue();
        await mediator.Received(1).Publish(
            Arg.Is<PlayoutUpdatedNotification>(n => n.PlayoutId == playoutId && n.IsLocked),
            Arg.Any<CancellationToken>());
    }

    [Test]
    [TestCaseSource(nameof(EventCountingKinds))]
    public void Lock_WhenAlreadyLocked_ReturnsFalse_AndFiresNoSecondEvent(LockKind kind)
    {
        EntityLocker locker = CreateLocker();
        var eventCount = 0;
        kind.Subscribe!(locker, (_, _) => eventCount++);

        kind.Lock(locker).ShouldBeTrue();
        kind.Lock(locker).ShouldBeFalse();

        kind.IsLocked(locker).ShouldBeTrue();
        eventCount.ShouldBe(1);
    }

    [Test]
    [TestCaseSource(nameof(EventCountingKinds))]
    public void Unlock_WhenNotLocked_ReturnsFalse_AndFiresNoEvent(LockKind kind)
    {
        EntityLocker locker = CreateLocker();
        var eventCount = 0;
        kind.Subscribe!(locker, (_, _) => eventCount++);

        kind.Unlock(locker).ShouldBeFalse();

        eventCount.ShouldBe(0);
        kind.IsLocked(locker).ShouldBeFalse();
    }

    [Test]
    [TestCaseSource(nameof(EventCountingKinds))]
    public void Unlock_AfterLock_ReturnsTrue_ThenSecondUnlockReturnsFalse(LockKind kind)
    {
        EntityLocker locker = CreateLocker();
        var eventCount = 0;
        kind.Subscribe!(locker, (_, _) => eventCount++);

        kind.Lock(locker).ShouldBeTrue();
        kind.Unlock(locker).ShouldBeTrue();
        kind.Unlock(locker).ShouldBeFalse();

        kind.IsLocked(locker).ShouldBeFalse();
        eventCount.ShouldBe(2); // one lock transition + one unlock transition, the no-op unlock fires nothing
    }

    [Test]
    [TestCaseSource(nameof(EventCountingKinds))]
    public async Task ParallelUnlock_AfterSingleLock_ExactlyOneReleaser(LockKind kind)
    {
        EntityLocker locker = CreateLocker();
        kind.Lock(locker).ShouldBeTrue();

        var unlockEvents = 0;
        kind.Subscribe!(locker, (_, _) => Interlocked.Increment(ref unlockEvents));
        using var barrier = new Barrier(ParallelCallers);

        Task<bool>[] tasks = Enumerable.Range(0, ParallelCallers)
            .Select(_ => Task.Run(() =>
            {
                barrier.SignalAndWait();
                return kind.Unlock(locker);
            }))
            .ToArray();

        bool[] results = await Task.WhenAll(tasks);

        results.Count(r => r).ShouldBe(1);
        kind.IsLocked(locker).ShouldBeFalse();
        unlockEvents.ShouldBe(1);
    }

    public sealed class LockKind
    {
        public required string Name { get; init; }
        public required Func<EntityLocker, bool> Lock { get; init; }
        public required Func<EntityLocker, bool> Unlock { get; init; }
        public required Func<EntityLocker, bool> IsLocked { get; init; }

        // null for kinds whose change event is not a plain EventHandler (RemoteMediaSource is EventHandler<Type>).
        public Action<EntityLocker, EventHandler>? Subscribe { get; init; }

        public override string ToString() => Name;
    }
}
