using Microsoft.Data.Sqlite;
using ZB.MOM.WW.LocalDb.Internal;

namespace ZB.MOM.WW.LocalDb.Tests;

public sealed class SqliteLocalDbTests : IDisposable
{
    private readonly string _path = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".db");

    public void Dispose()
    {
        SqliteConnection.ClearAllPools();
        if (File.Exists(_path))
            File.Delete(_path);
    }

    private SqliteLocalDb Create() => new(new LocalDbOptions { Path = _path });

    [Fact]
    public void Open_CreatesTheParentDirectory_WhenItDoesNotExist()
    {
        // SQLite creates the database FILE on demand but never its parent DIRECTORY, and the
        // constructor opens the file eagerly - so before this, a path under a missing
        // directory was a hard startup failure ("SQLite Error 14"), not a first run. Relative
        // defaults like ./data/app.db hit it routinely.
        var root = Path.Combine(Path.GetTempPath(), "localdb-dir-" + Guid.NewGuid().ToString("N"));
        var path = Path.Combine(root, "nested", "deeper", "app.db");

        try
        {
            Assert.False(Directory.Exists(root));

            using (var db = new SqliteLocalDb(new LocalDbOptions { Path = path }))
            {
                Assert.True(File.Exists(path));
            }
        }
        finally
        {
            SqliteConnection.ClearAllPools();
            if (Directory.Exists(root))
                Directory.Delete(root, recursive: true);
        }
    }

    [Fact]
    public void Open_ReportsTheDirectory_WhenItCannotBeCreated()
    {
        // The failure that survives: an unwritable location. It must name the directory and
        // the reason rather than surfacing as SQLite error 14, which was the whole complaint
        // about the previous behaviour.
        var blocker = Path.Combine(Path.GetTempPath(), "localdb-blocker-" + Guid.NewGuid().ToString("N"));

        try
        {
            // A FILE where the directory needs to be. Creating a directory under it fails on
            // every platform, without needing permission manipulation that varies by OS.
            File.WriteAllText(blocker, string.Empty);

            var ex = Assert.Throws<InvalidOperationException>(
                () => new SqliteLocalDb(new LocalDbOptions { Path = Path.Combine(blocker, "sub", "app.db") }));

            Assert.Contains("could not create the directory", ex.Message);
            Assert.NotNull(ex.InnerException);
        }
        finally
        {
            if (File.Exists(blocker))
                File.Delete(blocker);
        }
    }

    [Fact]
    public void Open_CreatesDbFile_WalMode()
    {
        using var db = Create();

        Assert.True(File.Exists(_path));

        using var conn = db.CreateConnection();
        using var cmd = conn.CreateCommand();
        cmd.CommandText = "PRAGMA journal_mode";
        var mode = (string)cmd.ExecuteScalar()!;
        Assert.Equal("wal", mode, ignoreCase: true);
    }

    [Fact]
    public async Task ExecuteAsync_ParameterizedInsert_Works()
    {
        using var db = Create();

        await db.ExecuteAsync("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)");
        await db.ExecuteAsync("INSERT INTO t (id, name) VALUES (@id, @name)", new { id = 1, name = "x" });

        var rows = await db.QueryAsync("SELECT COUNT(*) FROM t", r => r.GetInt64(0));
        Assert.Equal(1L, rows[0]);
    }

    [Fact]
    public async Task QueryAsync_MapsRows()
    {
        using var db = Create();

        await db.ExecuteAsync("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)");
        await db.ExecuteAsync("INSERT INTO t (id, name) VALUES (@id, @name)", new { id = 1, name = "a" });
        await db.ExecuteAsync("INSERT INTO t (id, name) VALUES (@id, @name)", new { id = 2, name = "b" });

        var rows = await db.QueryAsync(
            "SELECT id, name FROM t ORDER BY id",
            r => (Id: r.GetInt64(0), Name: r.GetString(1)));

        Assert.Equal(2, rows.Count);
        Assert.Equal((1L, "a"), rows[0]);
        Assert.Equal((2L, "b"), rows[1]);
    }

    [Fact]
    public async Task Transaction_CommitAndRollback()
    {
        using var db = Create();
        await db.ExecuteAsync("CREATE TABLE t (id INTEGER PRIMARY KEY)");

        await using (var tx = await db.BeginTransactionAsync())
        {
            await tx.ExecuteAsync("INSERT INTO t (id) VALUES (@id)", new { id = 1 });
            await tx.CommitAsync(default);
        }

        await using (var tx = await db.BeginTransactionAsync())
        {
            await tx.ExecuteAsync("INSERT INTO t (id) VALUES (@id)", new { id = 2 });
            await tx.RollbackAsync(default);
        }

        var ids = await db.QueryAsync("SELECT id FROM t ORDER BY id", r => r.GetInt64(0));
        Assert.Equal(new[] { 1L }, ids);
    }

    [Fact]
    public async Task Transaction_DisposeWithoutCommit_RollsBack()
    {
        using var db = Create();
        await db.ExecuteAsync("CREATE TABLE t (id INTEGER PRIMARY KEY)");

        await using (var tx = await db.BeginTransactionAsync())
        {
            await tx.ExecuteAsync("INSERT INTO t (id) VALUES (@id)", new { id = 1 });
        }

        var count = await db.QueryAsync("SELECT COUNT(*) FROM t", r => r.GetInt64(0));
        Assert.Equal(0L, count[0]);
    }

    [Fact]
    public async Task Bind_GuidByteArrayAndNull_RoundTrip()
    {
        using var db = Create();
        await db.ExecuteAsync("CREATE TABLE t (id INTEGER PRIMARY KEY, g TEXT, blob BLOB, missing TEXT)");

        var guid = Guid.NewGuid();
        var bytes = new byte[] { 1, 2, 3 };
        await db.ExecuteAsync(
            "INSERT INTO t (id, g, blob, missing) VALUES (@id, @g, @blob, @missing)",
            new { id = 1, g = guid, blob = bytes, missing = (string?)null });

        var rows = await db.QueryAsync(
            "SELECT g, blob, missing FROM t WHERE id = 1",
            r => (Guid: r.GetGuid(0), Blob: (byte[])r.GetValue(1), MissingIsNull: r.IsDBNull(2)));

        Assert.Equal(guid, rows[0].Guid);
        Assert.Equal(bytes, rows[0].Blob);
        Assert.True(rows[0].MissingIsNull);
    }

    [Fact]
    public async Task Bind_DictionaryParameters_BindByKey()
    {
        using var db = Create();
        await db.ExecuteAsync("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)");

        var parameters = new Dictionary<string, object?> { ["id"] = 7, ["name"] = null };
        await db.ExecuteAsync("INSERT INTO t (id, name) VALUES (@id, @name)", parameters);

        var rows = await db.QueryAsync(
            "SELECT id, name FROM t",
            r => (Id: r.GetInt64(0), NameIsNull: r.IsDBNull(1)));

        Assert.Equal(7L, rows[0].Id);
        Assert.True(rows[0].NameIsNull);
    }

    [Fact]
    public void Ctor_EmptyPath_Throws()
    {
        Assert.Throws<ArgumentException>(() => new SqliteLocalDb(new LocalDbOptions { Path = " " }));
    }

    [Fact]
    public void Ctor_NonPositiveBusyTimeout_Throws()
    {
        Assert.Throws<ArgumentException>(() =>
            new SqliteLocalDb(new LocalDbOptions { Path = _path, BusyTimeoutMs = 0 }));
    }

    [Fact]
    public void Ctor_InvalidSynchronous_Throws()
    {
        Assert.Throws<ArgumentException>(() =>
            new SqliteLocalDb(new LocalDbOptions { Path = _path, Synchronous = "NORMAL; PRAGMA foreign_keys=OFF" }));
    }

    [Fact]
    public void Dispose_IsIdempotent()
    {
        var db = Create();
        db.Dispose();
        db.Dispose();
    }

    [Fact]
    public async Task HlcUdf_RegisteredOnConnection()
    {
        using var db = Create();

        var values = await db.QueryAsync(
            "SELECT zb_hlc_next() UNION ALL SELECT zb_hlc_next()",
            r => r.GetInt64(0));

        Assert.Equal(2, values.Count);
        Assert.True(values[1] > values[0], $"{values[1]} should be greater than {values[0]}");
    }

    [Fact]
    public async Task Hlc_ResumesFromDurableMax_OnReopen()
    {
        const long huge = 9_000_000_000_000_000L;

        using (var db = Create())
        {
            await db.ExecuteAsync(
                "INSERT INTO __localdb_oplog (table_name, pk_json, hlc, node_id) VALUES ('t', '{}', @hlc, 'n')",
                new { hlc = huge });
        }

        SqliteConnection.ClearAllPools();

        using (var db = Create())
        {
            var next = await db.QueryAsync("SELECT zb_hlc_next()", r => r.GetInt64(0));
            Assert.True(next[0] > huge, $"{next[0]} should be greater than {huge}");
        }
    }
}
