using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;

namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;

[Trait("Category", "Unit")]
public sealed class LiteDbConfigCacheTests : IDisposable
{
    private readonly string _dbPath = Path.Combine(Path.GetTempPath(), $"otopcua-cache-test-{Guid.NewGuid():N}.db");

    /// <summary>Cleans up the temporary database file.</summary>
    public void Dispose()
    {
        if (File.Exists(_dbPath)) File.Delete(_dbPath);
    }

    private GenerationSnapshot Snapshot(string cluster, long gen) => new()
    {
        ClusterId = cluster,
        GenerationId = gen,
        CachedAt = DateTime.UtcNow,
        PayloadJson = $"{{\"g\":{gen}}}",
    };

    /// <summary>Verifies that payload is preserved through a write-then-read cycle.</summary>
    [Fact]
    public async Task Roundtrip_preserves_payload()
    {
        using var cache = new LiteDbConfigCache(_dbPath);
        var put = Snapshot("c-1", 42);
        await cache.PutAsync(put);

        var got = await cache.GetMostRecentAsync("c-1");
        got.ShouldNotBeNull();
        got!.GenerationId.ShouldBe(42);
        got.PayloadJson.ShouldBe(put.PayloadJson);
    }

    /// <summary>Verifies that GetMostRecentAsync returns the latest generation when multiple exist.</summary>
    [Fact]
    public async Task GetMostRecent_returns_latest_when_multiple_generations_present()
    {
        using var cache = new LiteDbConfigCache(_dbPath);
        foreach (var g in new long[] { 10, 20, 15 })
            await cache.PutAsync(Snapshot("c-1", g));

        var got = await cache.GetMostRecentAsync("c-1");
        got!.GenerationId.ShouldBe(20);
    }

    /// <summary>Verifies that GetMostRecentAsync returns null for an unknown cluster.</summary>
    [Fact]
    public async Task GetMostRecent_returns_null_for_unknown_cluster()
    {
        using var cache = new LiteDbConfigCache(_dbPath);
        (await cache.GetMostRecentAsync("ghost")).ShouldBeNull();
    }

    /// <summary>Verifies that Prune keeps the latest N generations and drops older ones.</summary>
    [Fact]
    public async Task Prune_keeps_latest_N_and_drops_older()
    {
        using var cache = new LiteDbConfigCache(_dbPath);
        for (long g = 1; g <= 15; g++)
            await cache.PutAsync(Snapshot("c-1", g));

        await cache.PruneOldGenerationsAsync("c-1", keepLatest: 10);

        (await cache.GetMostRecentAsync("c-1"))!.GenerationId.ShouldBe(15);

        // Drop them one by one and count — should be exactly 10 remaining
        var count = 0;
        while (await cache.GetMostRecentAsync("c-1") is not null)
        {
            count++;
            await cache.PruneOldGenerationsAsync("c-1", keepLatest: Math.Max(0, 10 - count));
            if (count > 20) break; // safety
        }
        count.ShouldBe(10);
    }

    /// <summary>Verifies that writing the same cluster/generation twice replaces rather than duplicates.</summary>
    [Fact]
    public async Task Put_same_cluster_generation_twice_replaces_not_duplicates()
    {
        using var cache = new LiteDbConfigCache(_dbPath);
        var first = Snapshot("c-1", 1);
        first.PayloadJson = "{\"v\":1}";
        await cache.PutAsync(first);

        var second = Snapshot("c-1", 1);
        second.PayloadJson = "{\"v\":2}";
        await cache.PutAsync(second);

        (await cache.GetMostRecentAsync("c-1"))!.PayloadJson.ShouldBe("{\"v\":2}");
    }

    // ------------------------------------------------------------------------------------
    // Configuration-005 — concurrent PutAsync for the same (ClusterId, GenerationId) must
    // not produce duplicate rows. The original find-then-insert was non-atomic so two racing
    // callers could both observe `existing is null` and both Insert.
    // ------------------------------------------------------------------------------------
    /// <summary>Verifies that concurrent PutAsync calls for the same cluster and generation do not create duplicates.</summary>
    [Fact]
    public async Task PutAsync_concurrent_for_same_cluster_and_generation_does_not_duplicate()
    {
        using var cache = new LiteDbConfigCache(_dbPath);
        // Pre-seed gen=99 so prune keepLatest:1 has a sentinel that survives independent of
        // any potential duplicate (gen=42) row count.
        await cache.PutAsync(Snapshot("c-1", 99));

        // Many parallel writes for the same key. Without serialization, racing find-then-insert
        // would Insert multiple rows for the same (ClusterId, GenerationId=42).
        var tasks = Enumerable.Range(0, 64).Select(_ => Task.Run(async () =>
        {
            var s = Snapshot("c-1", 42);
            await cache.PutAsync(s);
        })).ToArray();

        await Task.WhenAll(tasks);

        // Count rows for gen=42 directly by inspecting the LiteDB file via a fresh handle.
        cache.Dispose();
        using var verify = new LiteDB.LiteDatabase(_dbPath);
        var col = verify.GetCollection<GenerationSnapshot>("generations");
        var gen42Count = col.Find(s => s.ClusterId == "c-1" && s.GenerationId == 42).Count();
        gen42Count.ShouldBe(1,
            $"PutAsync must upsert atomically — found {gen42Count} rows for (c-1, gen=42) after 64 concurrent puts");
    }

    // ------------------------------------------------------------------------------------
    // Configuration-012 — the per-instance _writeGate (Configuration-005) does not protect
    // against LiteDB's process-wide BsonMapper.Global lazy-init race. Many cache INSTANCES
    // constructed + driven concurrently corrupt the shared global mapper, surfacing as
    // "Member ClusterId not found on BsonMapper" or a bogus "duplicate key _id = 0". A private
    // per-database mapper with the entity pre-registered fixes it.
    // ------------------------------------------------------------------------------------
    /// <summary>Verifies that many cache instances constructed and driven concurrently do not
    /// corrupt LiteDB's shared global BsonMapper — each Put/Get round-trips its own payload and
    /// no insert throws a member-not-found or duplicate-_id exception.</summary>
    [Fact]
    public async Task Concurrent_cache_instances_do_not_race_the_shared_bson_mapper()
    {
        var paths = new List<string>();
        try
        {
            var outer = Enumerable.Range(0, 24).Select(i => Task.Run(async () =>
            {
                var path = Path.Combine(Path.GetTempPath(), $"otopcua-cache-mapperrace-{Guid.NewGuid():N}.db");
                lock (paths) paths.Add(path);

                using var cache = new LiteDbConfigCache(path);
                // Pre-seed a sentinel, then hammer one (cluster, gen) from many threads.
                await cache.PutAsync(Snapshot($"c-{i}", 99));
                var inner = Enumerable.Range(0, 16)
                    .Select(_ => Task.Run(() => cache.PutAsync(Snapshot($"c-{i}", 42))))
                    .ToArray();
                await Task.WhenAll(inner);

                var got = await cache.GetMostRecentAsync($"c-{i}");
                got.ShouldNotBeNull();
                got!.GenerationId.ShouldBe(99); // 99 > 42, latest by GenerationId
            })).ToArray();

            // The unfixed code throws LiteException / NotSupportedException out of these tasks under
            // the global-mapper race; the fixed code completes cleanly.
            await Task.WhenAll(outer);
        }
        finally
        {
            foreach (var p in paths)
                if (File.Exists(p)) File.Delete(p);
        }
    }

    /// <summary>Verifies that a corrupted cache file surfaces as LocalConfigCacheCorruptException.</summary>
    [Fact]
    public void Corrupt_file_surfaces_as_LocalConfigCacheCorruptException()
    {
        // Write a file large enough to look like a LiteDB page but with garbage contents so page
        // deserialization fails on the first read probe.
        File.WriteAllBytes(_dbPath, new byte[8192]);
        Array.Fill<byte>(File.ReadAllBytes(_dbPath), 0xAB);
        using (var fs = File.OpenWrite(_dbPath))
        {
            fs.Write(new byte[8192].Select(_ => (byte)0xAB).ToArray());
        }

        Should.Throw<LocalConfigCacheCorruptException>(() => new LiteDbConfigCache(_dbPath));
    }
}
