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

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

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

    public void Dispose()
    {
        try
        {
            if (!Directory.Exists(_root)) return;
            // Remove ReadOnly attribute first so Directory.Delete can clean sealed files.
            foreach (var f in Directory.EnumerateFiles(_root, "*", SearchOption.AllDirectories))
                File.SetAttributes(f, FileAttributes.Normal);
            Directory.Delete(_root, recursive: true);
        }
        catch { /* best-effort cleanup */ }
    }

    private GenerationSnapshot MakeSnapshot(string clusterId, long generationId, string payload = "{\"sample\":true}") =>
        new()
        {
            ClusterId = clusterId,
            GenerationId = generationId,
            CachedAt = DateTime.UtcNow,
            PayloadJson = payload,
        };

    [Fact]
    public async Task FirstBoot_NoSnapshot_ReadThrows()
    {
        var cache = new GenerationSealedCache(_root);

        await Should.ThrowAsync<GenerationCacheUnavailableException>(
            () => cache.ReadCurrentAsync("cluster-a"));
    }

    [Fact]
    public async Task SealThenRead_RoundTrips()
    {
        var cache = new GenerationSealedCache(_root);
        var snapshot = MakeSnapshot("cluster-a", 42, "{\"hello\":\"world\"}");

        await cache.SealAsync(snapshot);

        var read = await cache.ReadCurrentAsync("cluster-a");
        read.GenerationId.ShouldBe(42);
        read.ClusterId.ShouldBe("cluster-a");
        read.PayloadJson.ShouldBe("{\"hello\":\"world\"}");
    }

    [Fact]
    public async Task SealedFile_IsReadOnly_OnDisk()
    {
        var cache = new GenerationSealedCache(_root);
        await cache.SealAsync(MakeSnapshot("cluster-a", 5));

        var sealedPath = Path.Combine(_root, "cluster-a", "5.db");
        File.Exists(sealedPath).ShouldBeTrue();
        var attrs = File.GetAttributes(sealedPath);
        attrs.HasFlag(FileAttributes.ReadOnly).ShouldBeTrue("sealed file must be read-only");
    }

    [Fact]
    public async Task SealingTwoGenerations_PointerAdvances_ToLatest()
    {
        var cache = new GenerationSealedCache(_root);
        await cache.SealAsync(MakeSnapshot("cluster-a", 1));
        await cache.SealAsync(MakeSnapshot("cluster-a", 2));

        cache.TryGetCurrentGenerationId("cluster-a").ShouldBe(2);
        var read = await cache.ReadCurrentAsync("cluster-a");
        read.GenerationId.ShouldBe(2);
    }

    [Fact]
    public async Task PriorGenerationFile_Survives_AfterNewSeal()
    {
        var cache = new GenerationSealedCache(_root);
        await cache.SealAsync(MakeSnapshot("cluster-a", 1));
        await cache.SealAsync(MakeSnapshot("cluster-a", 2));

        File.Exists(Path.Combine(_root, "cluster-a", "1.db")).ShouldBeTrue(
            "prior generations preserved for audit; pruning is separate");
        File.Exists(Path.Combine(_root, "cluster-a", "2.db")).ShouldBeTrue();
    }

    [Fact]
    public async Task CorruptSealedFile_ReadFailsClosed()
    {
        var cache = new GenerationSealedCache(_root);
        await cache.SealAsync(MakeSnapshot("cluster-a", 7));

        // Corrupt the sealed file: clear read-only, truncate, leave pointer intact.
        var sealedPath = Path.Combine(_root, "cluster-a", "7.db");
        File.SetAttributes(sealedPath, FileAttributes.Normal);
        File.WriteAllBytes(sealedPath, [0x00, 0x01, 0x02]);

        await Should.ThrowAsync<GenerationCacheUnavailableException>(
            () => cache.ReadCurrentAsync("cluster-a"));
    }

    [Fact]
    public async Task MissingSealedFile_ReadFailsClosed()
    {
        var cache = new GenerationSealedCache(_root);
        await cache.SealAsync(MakeSnapshot("cluster-a", 3));

        // Delete the sealed file but leave the pointer — corruption scenario.
        var sealedPath = Path.Combine(_root, "cluster-a", "3.db");
        File.SetAttributes(sealedPath, FileAttributes.Normal);
        File.Delete(sealedPath);

        await Should.ThrowAsync<GenerationCacheUnavailableException>(
            () => cache.ReadCurrentAsync("cluster-a"));
    }

    [Fact]
    public async Task CorruptPointerFile_ReadFailsClosed()
    {
        var cache = new GenerationSealedCache(_root);
        await cache.SealAsync(MakeSnapshot("cluster-a", 9));

        var pointerPath = Path.Combine(_root, "cluster-a", "CURRENT");
        File.WriteAllText(pointerPath, "not-a-number");

        await Should.ThrowAsync<GenerationCacheUnavailableException>(
            () => cache.ReadCurrentAsync("cluster-a"));
    }

    [Fact]
    public async Task SealSameGenerationTwice_IsIdempotent()
    {
        var cache = new GenerationSealedCache(_root);
        await cache.SealAsync(MakeSnapshot("cluster-a", 11));
        await cache.SealAsync(MakeSnapshot("cluster-a", 11, "{\"v\":2}"));

        var read = await cache.ReadCurrentAsync("cluster-a");
        read.PayloadJson.ShouldBe("{\"sample\":true}", "sealed file is immutable; second seal no-ops");
    }

    [Fact]
    public async Task IndependentClusters_DoNotInterfere()
    {
        var cache = new GenerationSealedCache(_root);
        await cache.SealAsync(MakeSnapshot("cluster-a", 1));
        await cache.SealAsync(MakeSnapshot("cluster-b", 10));

        (await cache.ReadCurrentAsync("cluster-a")).GenerationId.ShouldBe(1);
        (await cache.ReadCurrentAsync("cluster-b")).GenerationId.ShouldBe(10);
    }
}
