using Dapper;
using ErsatzTV.Infrastructure;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.MySql.Data;
using ErsatzTV.Infrastructure.Sqlite.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using MySqlConnector;
using NUnit.Framework;
using Shouldly;

namespace ErsatzTV.Tests.Integration;

public enum TestProvider
{
    Sqlite,
    MySql
}

/// <summary>
///     ersatztv#491: the unique index on <c>LibraryFolder(LibraryPathId, PathHash)</c> ships with an
///     audit/cleanup of pre-existing duplicate rows (reachable before #488, when the folder lookup read a
///     scan-start in-memory snapshot and could not see a folder created earlier in the same scan). This
///     drives the REAL migration against a database seeded at the previous migration, so the cleanup SQL
///     is exercised rather than restated.
///     <para>
///         Runs against BOTH providers from ONE fixture body. The dedupe deletes rows irreversibly and its
///         correctness turns on string-comparison semantics that differ per provider — two MySQL-only
///         collation defects escaped review in the #491 session (a case-insensitive grouping, then a PAD
///         SPACE one), and neither was reachable from a SQLite-only test, nor from CI's MySQL job, which
///         only applies migrations to a fresh EMPTY database and so executes no dedupe rows at all.
///         Parameterizing one fixture is what makes "the two providers agree" a checked property rather
///         than an assumption; a separate MySQL-only copy would drift and recreate the gap.
///     </para>
///     <para>
///         MySQL needs a live server, supplied via <c>ETV_TEST_MYSQL_CONNECTION</c>. Without it the MySQL
///         fixture <b>ignores</b> — a visible skip, never a silent pass — so local runs need no MySQL. CI
///         sets <c>ETV_REQUIRE_MYSQL_TESTS=1</c>, which turns that skip into a hard failure, so the gate
///         cannot quietly degrade into "connected to nothing and passed".
///     </para>
/// </summary>
[TestFixture(TestProvider.Sqlite)]
[TestFixture(TestProvider.MySql)]
[NonParallelizable]
public class LibraryFolderDedupeMigrationTests(TestProvider provider)
{
    // the migration immediately preceding Add_LibraryFolder_PathHash_UniqueIndex
    private const string PreviousMigration = "Add_Channel_Origin";

    private const string MySqlConnectionVariable = "ETV_TEST_MYSQL_CONNECTION";
    private const string MySqlRequiredVariable = "ETV_REQUIRE_MYSQL_TESTS";

    private string _databasePath = null!;
    private string _mySqlConnectionString;

    // Seeding writes deliberately partial object graphs (a LibraryPath with no Library, a MediaFile with
    // no MediaVersion), so it runs with foreign keys OFF.
    private DbContextOptions<TvContext> _seedOptions = null!;

    // The migration itself runs with foreign keys ON, matching production. This matters: the single most
    // dangerous statement in the #491 migration is DELETE FROM LibraryFolder against two Restrict foreign
    // keys (MediaFile.LibraryFolderId, LibraryFolder.ParentId). With enforcement off, a wrong repoint
    // order would still pass; with it on, the delete fails loudly.
    private DbContextOptions<TvContext> _migrateOptions = null!;

    [SetUp]
    public async Task SetUp()
    {
        if (provider is TestProvider.Sqlite)
        {
            TvContext.IsSqlite = true;
            TvContext.LastInsertedRowId = "last_insert_rowid()";
            TvContext.CaseInsensitiveCollation = "NOCASE";
            TvContext.IsUniqueConstraintViolation = SqliteErrorClassifier.IsUniqueConstraintViolation;

            _databasePath = Path.Combine(Path.GetTempPath(), $"etv491-{Guid.NewGuid():N}.sqlite3");
            _seedOptions = SqliteOptions(foreignKeys: false);
            _migrateOptions = SqliteOptions(foreignKeys: true);
            return;
        }

        string baseConnectionString = Environment.GetEnvironmentVariable(MySqlConnectionVariable);
        if (string.IsNullOrWhiteSpace(baseConnectionString))
        {
            string message =
                $"{MySqlConnectionVariable} is not set, so the MySql half of the #491 dedupe fixture cannot "
                + "run. The dedupe deletes rows irreversibly and its correctness is provider-specific, so "
                + "this coverage is not optional in CI.";

            // A skip is fine locally; in CI it is the very failure mode this fixture exists to prevent.
            if (IsTrue(Environment.GetEnvironmentVariable(MySqlRequiredVariable)))
            {
                Assert.Fail($"{message} {MySqlRequiredVariable} is set, so this is a failure, not a skip.");
            }

            Assert.Ignore($"{message} Set it to run this locally.");
        }

        // Use a database of our own rather than the one the surrounding CI step migrates, rebuilt per test
        // so each starts from nothing.
        var builder = new MySqlConnectionStringBuilder(baseConnectionString);
        var database = $"etv491_{Guid.NewGuid():N}";
        builder.Database = string.Empty;

        await using (var connection = new MySqlConnection(builder.ConnectionString))
        {
            await connection.OpenAsync();
            await connection.ExecuteAsync($"CREATE DATABASE `{database}`");
        }

        builder.Database = database;
        _mySqlConnectionString = builder.ConnectionString;

        TvContext.IsSqlite = false;
        TvContext.LastInsertedRowId = "last_insert_id()";
        TvContext.CaseInsensitiveCollation = "utf8mb4_general_ci";
        TvContext.IsUniqueConstraintViolation = MySqlErrorClassifier.IsUniqueConstraintViolation;

        _seedOptions = MySqlOptions();
        _migrateOptions = MySqlOptions();
    }

    [TearDown]
    public async Task TearDown()
    {
        if (provider is TestProvider.Sqlite)
        {
            Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
            foreach (string path in new[] { _databasePath, $"{_databasePath}-wal", $"{_databasePath}-shm" })
            {
                if (File.Exists(path))
                {
                    File.Delete(path);
                }
            }

            return;
        }

        if (_mySqlConnectionString is null)
        {
            return;
        }

        var builder = new MySqlConnectionStringBuilder(_mySqlConnectionString);
        string database = builder.Database;
        builder.Database = string.Empty;
        _mySqlConnectionString = null;

        await using var connection = new MySqlConnection(builder.ConnectionString);
        await connection.OpenAsync();
        await connection.ExecuteAsync($"DROP DATABASE IF EXISTS `{database}`");
    }

    [Test]
    public async Task Migration_Collapses_Duplicate_Folders_And_Repoints_Their_Dependents()
    {
        await using (TvContext context = SeedContext())
        {
            await context.Database.MigrateAsync(PreviousMigration);
            await using SeedSession seed = await SeedSession.OpenAsync(context, provider);

            // one library path with the SAME folder recorded three times (ids 1, 2, 3), an unrelated
            // folder (4), a child parented on one of the duplicates (5), a case-differing sibling (6),
            // a folder parented on its own duplicate (7/8 — the cycle the ParentId null-out guards), and
            // a sibling differing only by a TRAILING SPACE (9/10). The last two pairs are distinct legal
            // directories on Linux that hash differently, so the unique index accepts both and the dedupe
            // must not collapse them. On MySql a case-insensitive grouping deletes 6, and a PAD SPACE one
            // deletes 10 — utf8mb4_bin, the obvious fix for the first, is itself PAD SPACE, which is why
            // the migration groups on CONVERT(Path USING binary) instead.
            await seed.ExecuteAsync(
                "INSERT INTO LibraryPath (Id, LibraryId, Path) VALUES (1, 1, '/data/music')");
            await seed.ExecuteAsync(
                """
                INSERT INTO LibraryFolder (Id, LibraryPathId, Path, ParentId, Etag) VALUES
                    (1, 1, '/data/music/artist1', NULL, 'etag-keeper'),
                    (2, 1, '/data/music/artist1', NULL, 'etag-dupe-a'),
                    (3, 1, '/data/music/artist1', NULL, 'etag-dupe-b'),
                    (4, 1, '/data/music/artist2', NULL, NULL),
                    (5, 1, '/data/music/artist1/album', 3, NULL),
                    (6, 1, '/data/music/ARTIST2', NULL, NULL),
                    (7, 1, '/data/music/artist3', 8, NULL),
                    (8, 1, '/data/music/artist3', NULL, NULL),
                    (9, 1, '/data/music/pad', NULL, NULL),
                    (10, 1, '/data/music/pad ', NULL, NULL)
                """);

            // a media file on each duplicate and on each half of the trailing-space pair, plus an
            // image-folder-duration on duplicates only
            await seed.ExecuteAsync(
                """
                INSERT INTO MediaFile (Id, Path, PathHash, MediaVersionId, LibraryFolderId) VALUES
                    (1, '/data/music/artist1/a.mkv', 'hash-a', 1, 1),
                    (2, '/data/music/artist1/b.mkv', 'hash-b', 2, 2),
                    (3, '/data/music/artist1/c.mkv', 'hash-c', 3, 3),
                    (4, '/data/music/pad/d.mkv', 'hash-d', 4, 9),
                    (5, '/data/music/pad /e.mkv', 'hash-e', 5, 10)
                """);
            await seed.ExecuteAsync(
                """
                INSERT INTO ImageFolderDuration (Id, LibraryFolderId, DurationSeconds) VALUES
                    (1, 2, 30.0),
                    (2, 3, 45.0)
                """);
        }

        await using (TvContext context = MigrateContext())
        {
            await AssertForeignKeysEnforced(context);
            await context.Database.MigrateAsync();
        }

        await using (TvContext context = MigrateContext())
        {
            // Read the surviving rows once and assert in memory. Deliberately NOT `WHERE Path = '...'`:
            // that predicate is itself collation-dependent (on MySQL it would also match the
            // case-differing and trailing-space siblings), so an assertion written that way would quietly
            // mean something different on each provider — the exact class of bug this fixture guards.
            List<FolderRow> folders = (await context.Connection.QueryAsync<FolderRow>(
                "SELECT Id, Path, ParentId, Etag FROM LibraryFolder ORDER BY Id")).ToList();

            // survivors: keeper 1, artist2 (4), the child (5), the case-differing sibling (6), artist3's
            // keeper (7), and BOTH halves of the trailing-space pair (9, 10). Losers 2, 3 and 8 are gone.
            folders.Select(f => f.Id).ToList().ShouldBe([1, 4, 5, 6, 7, 9, 10]);

            // and their paths survive BYTE-exactly (ordinal comparison here, matching the hash)
            folders.Select(f => f.Path).ToList().ShouldBe(
            [
                "/data/music/artist1",
                "/data/music/artist2",
                "/data/music/artist1/album",
                "/data/music/ARTIST2",
                "/data/music/artist3",
                "/data/music/pad",
                "/data/music/pad "
            ]);

            // the keeper's etag is cleared: which duplicate the scanner was writing to was arbitrary, so
            // MIN(Id)'s etag could suppress the rescan that repairs the collapsed folder
            folders.Single(f => f.Id == 1).Etag.ShouldBeNull();

            // the child folder is reparented off the deleted duplicate onto the keeper
            folders.Single(f => f.Id == 5).ParentId.ShouldBe(1);

            // the folder parented on its own duplicate did not become its own parent
            folders.Single(f => f.Id == 7).ParentId.ShouldBeNull();

            // every media file follows the keeper — nothing orphaned, nothing deleted — while the
            // trailing-space pair's dependents stay attached to their OWN folder
            List<int> mediaFolderIds = (await context.Connection.QueryAsync<int>(
                "SELECT LibraryFolderId FROM MediaFile ORDER BY Id")).ToList();
            mediaFolderIds.ShouldBe([1, 1, 1, 9, 10]);

            // the keeper had no ImageFolderDuration, so exactly one duplicate's setting is promoted to it
            // (the lowest id) and the rest are dropped — the 1:1 unique index cannot hold both
            List<int> durationFolderIds = (await context.Connection.QueryAsync<int>(
                "SELECT LibraryFolderId FROM ImageFolderDuration ORDER BY Id")).ToList();
            durationFolderIds.ShouldBe([1]);

            await AssertHelperTablesDropped(context);
            await AssertUniqueIndexExists(context);
        }
    }

    [Test]
    public async Task Migration_Leaves_A_Database_Without_Duplicates_Alone()
    {
        // positive control: the cleanup must not touch rows that were already unique
        await using (TvContext context = SeedContext())
        {
            await context.Database.MigrateAsync(PreviousMigration);
            await using SeedSession seed = await SeedSession.OpenAsync(context, provider);

            await seed.ExecuteAsync(
                "INSERT INTO LibraryPath (Id, LibraryId, Path) VALUES (1, 1, '/data/music')");
            await seed.ExecuteAsync(
                """
                INSERT INTO LibraryFolder (Id, LibraryPathId, Path, ParentId, Etag) VALUES
                    (1, 1, '/data/music/artist1', NULL, 'etag-1'),
                    (2, 1, '/data/music/artist2', 1, 'etag-2')
                """);
            await seed.ExecuteAsync(
                "INSERT INTO ImageFolderDuration (Id, LibraryFolderId, DurationSeconds) VALUES (1, 2, 30.0)");
        }

        await using (TvContext context = MigrateContext())
        {
            await AssertForeignKeysEnforced(context);
            await context.Database.MigrateAsync();
        }

        await using (TvContext context = MigrateContext())
        {
            List<FolderRow> folders = (await context.Connection.QueryAsync<FolderRow>(
                "SELECT Id, Path, ParentId, Etag FROM LibraryFolder ORDER BY Id")).ToList();

            folders.Select(f => f.Id).ToList().ShouldBe([1, 2]);
            folders.Single(f => f.Id == 1).Etag.ShouldBe("etag-1");
            folders.Single(f => f.Id == 2).Etag.ShouldBe("etag-2");
            folders.Single(f => f.Id == 2).ParentId.ShouldBe(1);

            (await context.Connection.ExecuteScalarAsync<int>(
                "SELECT LibraryFolderId FROM ImageFolderDuration WHERE Id = 1")).ShouldBe(2);

            // existing rows keep a null hash — the index applies because nulls are distinct
            (await context.Connection.ExecuteScalarAsync<int>(
                "SELECT COUNT(*) FROM LibraryFolder WHERE PathHash IS NULL")).ShouldBe(2);
        }
    }

    // Foreign-key enforcement is the whole point of splitting the seed and migrate contexts, so prove it
    // took effect rather than trusting a connection-string keyword: a typo, a pragma reset or a leaked
    // SET FOREIGN_KEY_CHECKS=0 would silently revert this to its weaker form and still pass everything.
    private async Task AssertForeignKeysEnforced(TvContext context)
    {
        string sql = provider is TestProvider.Sqlite
            ? "PRAGMA foreign_keys"
            : "SELECT @@SESSION.foreign_key_checks";

        (await context.Connection.ExecuteScalarAsync<long>(sql)).ShouldBe(
            1,
            "the migration must run with foreign keys ENFORCED — otherwise DELETE FROM LibraryFolder is "
            + "not actually tested against the Restrict constraints it must not violate");
    }

    private async Task AssertHelperTablesDropped(TvContext context)
    {
        string sql = provider is TestProvider.Sqlite
            ? "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name LIKE '__LibraryFolderDedupe%'"
            : "SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() "
              + "AND TABLE_NAME LIKE '\\_\\_LibraryFolderDedupe%'";

        (await context.Connection.ExecuteScalarAsync<int>(sql)).ShouldBe(0, "helper tables were left behind");
    }

    private async Task AssertUniqueIndexExists(TvContext context)
    {
        string sql = provider is TestProvider.Sqlite
            ? "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' "
              + "AND name = 'IX_LibraryFolder_LibraryPathId_PathHash'"
            : "SELECT COUNT(DISTINCT INDEX_NAME) FROM information_schema.STATISTICS "
              + "WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'LibraryFolder' "
              + "AND INDEX_NAME = 'IX_LibraryFolder_LibraryPathId_PathHash'";

        (await context.Connection.ExecuteScalarAsync<int>(sql)).ShouldBe(1, "the unique index was not created");
    }

    private static bool IsTrue(string value) =>
        value is "1" || string.Equals(value, "true", StringComparison.OrdinalIgnoreCase);

    private DbContextOptions<TvContext> SqliteOptions(bool foreignKeys) =>
        new DbContextOptionsBuilder<TvContext>()
            .UseSqlite(
                $"Data Source={_databasePath};Foreign Keys={foreignKeys}",
                o => o.MigrationsAssembly("ErsatzTV.Infrastructure.Sqlite"))
            .Options;

    private DbContextOptions<TvContext> MySqlOptions() =>
        new DbContextOptionsBuilder<TvContext>()
            .UseMySql(
                _mySqlConnectionString,
                ServerVersion.AutoDetect(_mySqlConnectionString),
                o => o.MigrationsAssembly("ErsatzTV.Infrastructure.MySql"))
            .Options;

    private TvContext SeedContext() => Create(_seedOptions);

    private TvContext MigrateContext() => Create(_migrateOptions);

    private static TvContext Create(DbContextOptions<TvContext> options) =>
        new(
            options,
            NullLoggerFactory.Instance,
            new SlowQueryInterceptor(NullLogger<SlowQueryInterceptor>.Instance));

    // Settable properties rather than a positional record: SQLite hands back INTEGER as Int64 while MySQL
    // hands back INT as Int32, and Dapper only narrows for property setters, not constructor matching.
    private sealed class FolderRow
    {
        public int Id { get; set; }
        public string Path { get; set; }
        public int? ParentId { get; set; }
        public string Etag { get; set; }
    }

    /// <summary>
    ///     Holds one connection open for the whole seeding block. SQLite disables foreign keys through a
    ///     connection-string keyword, but MySQL's <c>foreign_key_checks</c> is a SESSION variable — and
    ///     Dapper closes a connection it had to open itself, which would reset it between statements.
    ///     Opening explicitly keeps the session, and therefore the setting, alive across every insert.
    /// </summary>
    private sealed class SeedSession : IAsyncDisposable
    {
        private readonly TvContext _context;

        private SeedSession(TvContext context) => _context = context;

        public static async Task<SeedSession> OpenAsync(TvContext context, TestProvider provider)
        {
            await context.Database.OpenConnectionAsync();
            if (provider is TestProvider.MySql)
            {
                await context.Connection.ExecuteAsync("SET SESSION foreign_key_checks = 0");
            }

            return new SeedSession(context);
        }

        public Task ExecuteAsync(string sql) => _context.Connection.ExecuteAsync(sql);

        public async ValueTask DisposeAsync() => await _context.Database.CloseConnectionAsync();
    }
}
