using System.Threading.Channels;
using ErsatzTV.Application;
using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Application.Search;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Sqlite.Data;
using ErsatzTV.Tests.Support;
using LanguageExt;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using Unit = LanguageExt.Unit;

namespace ErsatzTV.Tests.Application.MediaCollections;

/// <summary>
///     #308: two concurrent adds of the same item both membership-check it absent, both insert the same
///     <c>CollectionItem</c> composite key, and the loser used to 500 on the unhandled
///     <see cref="DbUpdateException" /> (SQLite 19 / MySQL 1062) that <c>SaveChangesForcingVersion</c>
///     does not catch. The fix (<c>TrySaveChangesForcingVersion</c>) makes the loser an idempotent no-op
///     for the single-item handlers, and for the bulk handler retries against fresh membership so the
///     non-colliding items are not dropped.
/// </summary>
[TestFixture]
public class AddToCollectionIdempotencyConcurrencyTests
{
    private InMemoryTvContext _db = null!;

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

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

    private static async Task SeedCollectionWithMovies(Func<TvContext> createContext, int collectionId, params int[] movieIds)
    {
        await using TvContext context = createContext();

        var library = new LocalLibrary
        {
            Id = 1, Name = "Library", MediaKind = LibraryMediaKind.Movies, Paths = []
        };
        var path = new LibraryPath { Id = 1, Path = "/media", Library = library, LibraryFolders = [], MediaItems = [] };
        library.Paths.Add(path);

        foreach (int movieId in movieIds)
        {
            context.Movies.Add(new Movie
            {
                Id = movieId,
                LibraryPath = path,
                Collections = [],
                CollectionItems = [],
                TraktListItems = [],
                MovieMetadata =
                [
                    new MovieMetadata
                    {
                        Title = $"Movie {movieId}", SortTitle = $"Movie {movieId}", Artwork = [], Genres = [],
                        Tags = [], Studios = [], Actors = [], Guids = [], Subtitles = [], Directors = [], Writers = []
                    }
                ],
                MediaVersions = [new MediaVersion { Duration = TimeSpan.FromMinutes(30) }]
            });
        }

        context.Collections.Add(new Collection
        {
            Id = collectionId, Name = "Manual", Version = 1, MediaItems = [], CollectionItems = []
        });

        await context.SaveChangesAsync();
    }

    private static async Task<int> CollectionItemCount(Func<TvContext> createContext, int collectionId, int mediaItemId)
    {
        await using TvContext context = createContext();
        return await context.CollectionItems
            .CountAsync(ci => ci.CollectionId == collectionId && ci.MediaItemId == mediaItemId);
    }

    private static (IMediaCollectionRepository repo, Channel<IBackgroundServiceRequest> worker,
        ChannelWriter<ISearchIndexBackgroundServiceRequest> search) Deps()
    {
        IMediaCollectionRepository repo = Substitute.For<IMediaCollectionRepository>();
        repo.PlayoutIdsUsingCollection(Arg.Any<int>()).Returns([1]);
        Channel<IBackgroundServiceRequest> worker =
            System.Threading.Channels.Channel.CreateUnbounded<IBackgroundServiceRequest>();
        ChannelWriter<ISearchIndexBackgroundServiceRequest> search =
            System.Threading.Channels.Channel.CreateUnbounded<ISearchIndexBackgroundServiceRequest>().Writer;
        return (repo, worker, search);
    }

    // Simulates the concurrent "winner": exactly once, on a SEPARATE connection, insert the same composite
    // key and commit — AFTER the intercepted context has read (stale) membership but BEFORE its own INSERT
    // runs. This is what interposes the race deterministically.
    private sealed class InsertConflictingItemOnce(SharedCacheTvContext db, int collectionId, int mediaItemId)
        : SaveChangesInterceptor
    {
        private int _fired;

        public override async ValueTask<InterceptionResult<int>> SavingChangesAsync(
            DbContextEventData eventData,
            InterceptionResult<int> result,
            CancellationToken cancellationToken = default)
        {
            if (Interlocked.Exchange(ref _fired, 1) == 0)
            {
                await using SqliteConnection connection = db.OpenConnection();
                await using SqliteCommand command = connection.CreateCommand();
                command.CommandText =
                    "INSERT INTO \"CollectionItem\" (\"CollectionId\", \"MediaItemId\", \"CustomIndex\") " +
                    $"VALUES ({collectionId}, {mediaItemId}, NULL)";
                await command.ExecuteNonQueryAsync(cancellationToken);
            }

            return result;
        }
    }

    // ----- Non-vacuous anchor + extension-level unit tests (single shared connection) -----

    // Proves the race really does throw a DbUpdateException the classifier recognizes — i.e. the pre-fix
    // 500 was real and the fix's catch filter is not a no-op. This is the negative control anchoring the
    // extension tests below.
    [Test]
    public async Task Duplicate_CollectionItem_Insert_Throws_A_Classified_UniqueViolation()
    {
        await SeedCollectionWithMovies(_db.CreateContext, 1, 10);

        await using TvContext stale = _db.CreateContext();
        Collection tracked = await stale.Collections.Include(c => c.MediaItems).SingleAsync(c => c.Id == 1);
        Movie movie = await stale.Movies.SingleAsync(m => m.Id == 10);

        // A racer inserts the (1, 10) CollectionItem and commits, then the stale context tries the same key.
        await InsertRow(_db.CreateContext, 1, 10);
        tracked.MediaItems.Add(movie);

        DbUpdateException ex = await Should.ThrowAsync<DbUpdateException>(() => stale.SaveChangesAsync());
        SqliteErrorClassifier.IsUniqueConstraintViolation(ex).ShouldBeTrue();

        // And the classifier does not blanket-return true for an unrelated failure.
        SqliteErrorClassifier.IsUniqueConstraintViolation(
            new DbUpdateException("nope", new InvalidOperationException())).ShouldBeFalse();
    }

    [Test]
    public async Task TrySaveChangesForcingVersion_Swallows_The_Duplicate_And_Returns_False()
    {
        await SeedCollectionWithMovies(_db.CreateContext, 1, 10);

        await using TvContext stale = _db.CreateContext();
        Collection tracked = await stale.Collections.Include(c => c.MediaItems).SingleAsync(c => c.Id == 1);
        Movie movie = await stale.Movies.SingleAsync(m => m.Id == 10);

        await InsertRow(_db.CreateContext, 1, 10);
        tracked.MediaItems.Add(movie);
        tracked.Version++;

        bool committed = await stale.TrySaveChangesForcingVersion(CancellationToken.None);

        committed.ShouldBeFalse(); // the loser's save was swallowed, not thrown
        (await CollectionItemCount(_db.CreateContext, 1, 10)).ShouldBe(1); // exactly one row, no corruption
    }

    [Test]
    public async Task TrySaveChangesForcingVersion_Rethrows_When_The_Provider_Does_Not_Classify_It()
    {
        // Guard against an over-broad catch: with the classifier saying "not a unique violation", the
        // duplicate must still propagate rather than be silently swallowed.
        Func<DbUpdateException, bool> original = TvContext.IsUniqueConstraintViolation;
        try
        {
            await SeedCollectionWithMovies(_db.CreateContext, 1, 10);

            await using TvContext stale = _db.CreateContext();
            Collection tracked = await stale.Collections.Include(c => c.MediaItems).SingleAsync(c => c.Id == 1);
            Movie movie = await stale.Movies.SingleAsync(m => m.Id == 10);

            await InsertRow(_db.CreateContext, 1, 10);
            tracked.MediaItems.Add(movie);
            tracked.Version++;

            TvContext.IsUniqueConstraintViolation = _ => false;
            await Should.ThrowAsync<DbUpdateException>(() => stale.TrySaveChangesForcingVersion(CancellationToken.None));
        }
        finally
        {
            TvContext.IsUniqueConstraintViolation = original;
        }
    }

    // ----- End-to-end handler tests over a real cross-connection race (shared cache) -----

    [Test]
    public async Task SingleItem_Handler_Losing_The_Cross_Connection_Race_Is_A_NoOp_Not_A_500()
    {
        await using SharedCacheTvContext db = await SharedCacheTvContext.CreateAsync("etv308-single");
        await SeedCollectionWithMovies(db.CreateContext, 1, 10);

        (IMediaCollectionRepository repo, Channel<IBackgroundServiceRequest> worker,
            ChannelWriter<ISearchIndexBackgroundServiceRequest> search) = Deps();

        // The interceptor inserts (1, 10) on another connection during this handler's save.
        var racer = new InsertConflictingItemOnce(db, 1, 10);
        var handler = new AddMovieToCollectionHandler(db.Factory(racer), repo, worker.Writer, search);

        Either<BaseError, Unit> result = await handler.Handle(new AddMovieToCollection(1, 10), CancellationToken.None);

        result.IsRight.ShouldBeTrue(); // no 500
        (await CollectionItemCount(db.CreateContext, 1, 10)).ShouldBe(1); // one row, no duplicate
        worker.Reader.Count.ShouldBe(0); // the loser does not fan out a rebuild (the winner already did)
    }

    [Test]
    public async Task Bulk_Handler_Losing_The_Cross_Connection_Race_Retries_And_Keeps_NonColliding_Items()
    {
        await using SharedCacheTvContext db = await SharedCacheTvContext.CreateAsync("etv308-bulk");
        await SeedCollectionWithMovies(db.CreateContext, 1, 10, 11);

        (IMediaCollectionRepository repo, Channel<IBackgroundServiceRequest> worker,
            ChannelWriter<ISearchIndexBackgroundServiceRequest> search) = Deps();

        var movieRepo = Substitute.For<IMovieRepository>();
        movieRepo.AllMoviesExist(Arg.Any<List<int>>()).Returns(true);
        var tvRepo = Substitute.For<ITelevisionRepository>();
        tvRepo.AllShowsExist(Arg.Any<List<int>>()).Returns(true);
        tvRepo.AllSeasonsExist(Arg.Any<List<int>>()).Returns(true);
        tvRepo.AllEpisodesExist(Arg.Any<List<int>>()).Returns(true);

        // Item 10 is inserted by the racer during the first attempt's save, colliding and rolling back the
        // whole [10, 11] batch; the retry must recompute membership and still add 11.
        var racer = new InsertConflictingItemOnce(db, 1, 10);
        var handler = new AddItemsToCollectionHandler(db.Factory(racer), repo, movieRepo, tvRepo, worker.Writer, search);

        var request = new AddItemsToCollection(1, [10, 11], [], [], [], [], [], [], [], [], []);
        Either<BaseError, Unit> result = await handler.Handle(request, CancellationToken.None);

        result.IsRight.ShouldBeTrue();
        (await CollectionItemCount(db.CreateContext, 1, 10)).ShouldBe(1); // the racer's row — not dropped/duped
        (await CollectionItemCount(db.CreateContext, 1, 11)).ShouldBe(1); // NOT dropped by the rolled-back attempt
    }

    private static async Task InsertRow(Func<TvContext> createContext, int collectionId, int mediaItemId)
    {
        await using TvContext context = createContext();
        context.Add(new CollectionItem { CollectionId = collectionId, MediaItemId = mediaItemId, CustomIndex = null });
        await context.SaveChangesAsync();
    }
}
