using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.LocalDb;
using ZB.MOM.WW.OtOpcUa.Runtime.DeploymentCache;

// NOT "...Tests.Deployment", even though the folder is Deployment/. A child namespace named
// Deployment under ...Runtime.Tests shadows the Deployment EF entity type for every other file in
// this assembly — the same CS0118 trap that forced the production namespace's rename.
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.DeploymentCache;

/// <summary>
///     LocalDb Phase 1 (Task 6) — the chunked deployment-artifact cache.
/// </summary>
/// <remarks>
///     <para>
///         Driven against a real temp-file <see cref="ILocalDb"/> rather than a fake. The behaviours
///         under test — chunk reassembly order, SHA-256 integrity, retention pruning, upsert
///         semantics — live entirely in SQL, so a mocked seam would assert nothing about them.
///     </para>
///     <para>
///         This project cannot reference the Host, so the schema setup here mirrors
///         <c>LocalDbSetup.OnReady</c> rather than calling it. The load-bearing
///         <c>DDL → RegisterReplicated</c> ordering is pinned by the Host's own
///         <c>LocalDbSetupTests</c>; what matters here is only that the tables exist.
///     </para>
/// </remarks>
public sealed class LocalDbDeploymentArtifactCacheTests : IDisposable
{
    private const string ClusterA = "SITE-A";
    private const string ClusterB = "SITE-B";

    private readonly string _dbPath =
        Path.Combine(Path.GetTempPath(), $"otopcua-artifact-cache-{Guid.NewGuid():N}.db");

    private readonly ServiceProvider _provider;
    private readonly ILocalDb _db;

    public LocalDbDeploymentArtifactCacheTests()
    {
        var configuration = new ConfigurationBuilder()
            .AddInMemoryCollection(new Dictionary<string, string?> { ["LocalDb:Path"] = _dbPath })
            .Build();

        _provider = new ServiceCollection()
            .AddZbLocalDb(configuration, db =>
            {
                using (var connection = db.CreateConnection())
                {
                    DeploymentCacheSchema.Apply(connection);
                }

                db.RegisterReplicated(DeploymentCacheSchema.ArtifactsTable);
                db.RegisterReplicated(DeploymentCacheSchema.PointerTable);
            })
            .BuildServiceProvider();

        _db = _provider.GetRequiredService<ILocalDb>();
    }

    private LocalDbDeploymentArtifactCache CreateCache(
        ILogger<LocalDbDeploymentArtifactCache>? logger = null)
        => new(_db, logger ?? NullLogger<LocalDbDeploymentArtifactCache>.Instance);

    private static byte[] RandomArtifact(int length)
    {
        var bytes = new byte[length];
        Random.Shared.NextBytes(bytes);
        return bytes;
    }

    private async Task<int> CountChunksAsync(string? deploymentId = null)
    {
        var sql = deploymentId is null
            ? "SELECT COUNT(*) FROM deployment_artifacts"
            : "SELECT COUNT(*) FROM deployment_artifacts WHERE deployment_id = @DeploymentId";

        var rows = await _db.QueryAsync(sql, r => r.GetInt32(0),
            deploymentId is null ? null : new { DeploymentId = deploymentId });

        return rows[0];
    }

    private async Task<IReadOnlyList<string>> DistinctDeploymentIdsAsync()
        => await _db.QueryAsync(
            "SELECT DISTINCT deployment_id FROM deployment_artifacts ORDER BY deployment_id",
            r => r.GetString(0));

    /// <summary>
    ///     Total capture-trigger rows in the replication oplog — the direct measure of how much
    ///     replication traffic (and, on an unpaired node, how much permanent oplog growth) a store
    ///     costs.
    /// </summary>
    private async Task<long> OplogDepthAsync()
        => (await _db.QueryAsync("SELECT COUNT(*) FROM __localdb_oplog", r => r.GetInt64(0)))[0];

    /// <summary>
    ///     The LWW version stamps of every replicated row, as a stable ordered projection. Comparing
    ///     it across a call proves whether that call touched replicated state at all.
    /// </summary>
    private async Task<IReadOnlyList<string>> RowVersionsAsync()
        => await _db.QueryAsync(
            "SELECT table_name, pk_json, hlc FROM __localdb_row_version ORDER BY table_name, pk_json",
            r => $"{r.GetString(0)}|{r.GetString(1)}|{r.GetInt64(2)}");

    [Fact]
    public async Task StoreThenGetCurrent_RoundTripsAMultiChunkArtifactByteForByte()
    {
        // 300 KiB against a 128 KiB chunk forces three chunks, so this covers the reassembly
        // ordering that a single-chunk artifact would never exercise.
        var artifact = RandomArtifact(300 * 1024);
        var cache = CreateCache();

        await cache.StoreAsync(ClusterA, "dep-1", "rev-1", artifact);

        var cached = await cache.GetCurrentAsync(ClusterA);

        cached.ShouldNotBeNull();
        cached.DeploymentId.ShouldBe("dep-1");
        cached.RevisionHash.ShouldBe("rev-1");
        cached.Artifact.ShouldBe(artifact);
        (await CountChunksAsync("dep-1")).ShouldBe(3);
    }

    [Fact]
    public async Task Store_KeepsOnlyTheTwoNewestDeploymentsPerCluster()
    {
        var cache = CreateCache();

        await cache.StoreAsync(ClusterA, "dep-1", "rev-1", RandomArtifact(64));
        await cache.StoreAsync(ClusterA, "dep-2", "rev-2", RandomArtifact(64));
        await cache.StoreAsync(ClusterA, "dep-3", "rev-3", RandomArtifact(64));

        // Bounded retention is what stops a node that redeploys daily from filling its disk with
        // address spaces nobody will ever roll back to.
        (await DistinctDeploymentIdsAsync()).ShouldBe(["dep-2", "dep-3"]);

        var cached = await cache.GetCurrentAsync(ClusterA);
        cached.ShouldNotBeNull();
        cached.DeploymentId.ShouldBe("dep-3");
    }

    [Fact]
    public async Task Store_NeverPrunesTheDeploymentThePointerNames()
    {
        // Regression: the prune ranks survivors by cached_at_utc and falls through to a
        // deployment_id tiebreak. Real deployment ids are GUIDs, so that tiebreak is effectively
        // random — the deployment just written could lose and have its chunks deleted while the
        // pointer still named it. The result is a cache that misses on every boot until the next
        // deploy: silent, permanent, and precisely the outage this cache exists to survive.
        //
        // Rather than trying to race three stores into one clock tick, this forces the general
        // case: the two older deployments are back-dated INTO THE FUTURE so the newest store loses
        // the ranking outright. The invariant must hold regardless of timestamps.
        var cache = CreateCache();
        var newest = RandomArtifact(1024);

        await cache.StoreAsync(ClusterA, "dep-old-1", "rev-1", RandomArtifact(1024));
        await cache.StoreAsync(ClusterA, "dep-old-2", "rev-2", RandomArtifact(1024));

        await _db.ExecuteAsync(
            "UPDATE deployment_artifacts SET cached_at_utc = @Future",
            new { Future = "2099-01-01T00:00:00.0000000Z" });

        await cache.StoreAsync(ClusterA, "dep-new", "rev-3", newest);

        // The pointer names dep-new, so dep-new's chunks must still be there...
        (await CountChunksAsync("dep-new")).ShouldBeGreaterThan(0);

        // ...and it must actually read back, which is the behaviour that matters.
        var cached = await cache.GetCurrentAsync(ClusterA);
        cached.ShouldNotBeNull();
        cached.DeploymentId.ShouldBe("dep-new");
        cached.Artifact.ShouldBe(newest);
    }

    [Fact]
    public async Task GetCurrent_ReturnsNullWhenAChunkIsCorrupt()
    {
        var cache = CreateCache();
        await cache.StoreAsync(ClusterA, "dep-1", "rev-1", RandomArtifact(300 * 1024));

        // Valid base64 of the wrong bytes: this passes decoding and the chunk-count check, so only
        // the SHA-256 comparison can catch it. That is the check being pinned.
        await _db.ExecuteAsync(
            "UPDATE deployment_artifacts SET chunk_base64 = @Chunk WHERE deployment_id = @DeploymentId AND chunk_index = 1",
            new { Chunk = Convert.ToBase64String(RandomArtifact(128 * 1024)), DeploymentId = "dep-1" });

        (await cache.GetCurrentAsync(ClusterA)).ShouldBeNull();
    }

    [Fact]
    public async Task GetCurrent_ReturnsNullWhenAChunkIsMissing()
    {
        var cache = CreateCache();
        await cache.StoreAsync(ClusterA, "dep-1", "rev-1", RandomArtifact(300 * 1024));

        // A partially replicated artifact is the realistic version of this: the pointer row arrives
        // before the last chunk does. Reassembling what is present would yield a plausible-looking
        // but silently truncated address space.
        await _db.ExecuteAsync(
            "DELETE FROM deployment_artifacts WHERE deployment_id = @DeploymentId AND chunk_index = 2",
            new { DeploymentId = "dep-1" });

        (await cache.GetCurrentAsync(ClusterA)).ShouldBeNull();
    }

    [Fact]
    public async Task GetCurrent_ReturnsNullWhenNoPointerExists()
    {
        var cache = CreateCache();

        (await cache.GetCurrentAsync(ClusterA)).ShouldBeNull();
    }

    [Fact]
    public async Task Store_IsIdempotentForTheSameDeploymentId()
    {
        var cache = CreateCache();

        await cache.StoreAsync(ClusterA, "dep-1", "rev-1", RandomArtifact(300 * 1024));

        // The re-store is smaller, so a delete-before-insert that did not happen would leave the
        // tail chunks of the first artifact behind — orphans that also break the chunk-count check.
        var second = RandomArtifact(200 * 1024);
        await cache.StoreAsync(ClusterA, "dep-1", "rev-1b", second);

        (await CountChunksAsync("dep-1")).ShouldBe(2);
        (await CountChunksAsync()).ShouldBe(2);

        var cached = await cache.GetCurrentAsync(ClusterA);
        cached.ShouldNotBeNull();
        cached.RevisionHash.ShouldBe("rev-1b");
        cached.Artifact.ShouldBe(second);
    }

    [Fact]
    public async Task Store_WritesNothingWhenTheCachedArtifactIsAlreadyIdentical()
    {
        // Phase 1 live gate, check 8: every store rewrote the chunks and the pointer unconditionally,
        // so re-caching an artifact the node already holds — which is what boot-from-cache and
        // RestoreApplied both do on every restart — minted fresh oplog rows for a row nobody changed.
        // On a replicating pair that is pure churn; on a default-OFF node there is no peer to ack
        // them, so they sit in the oplog until the age cap eventually prunes them.
        var cache = CreateCache();
        var artifact = RandomArtifact(300 * 1024);

        await cache.StoreAsync(ClusterA, "dep-1", "rev-1", artifact);

        var oplogDepth = await OplogDepthAsync();
        var rowVersions = await RowVersionsAsync();

        await cache.StoreAsync(ClusterA, "dep-1", "rev-1", artifact);

        (await OplogDepthAsync()).ShouldBe(oplogDepth);
        (await RowVersionsAsync()).ShouldBe(rowVersions);

        // Skipping must not cost readability: the artifact still reads back intact.
        var cached = await cache.GetCurrentAsync(ClusterA);
        cached.ShouldNotBeNull();
        cached.DeploymentId.ShouldBe("dep-1");
        cached.Artifact.ShouldBe(artifact);
    }

    [Fact]
    public async Task Store_RewritesWhenTheCachedChunksAreIncompleteDespiteAMatchingPointer()
    {
        // The dangerous half of skip-if-unchanged. A pointer can name a deployment whose chunks are
        // gone or partial — a half-replicated artifact, or a prune that raced the pointer. Trusting
        // the pointer alone would turn a repairable cache into a permanently unreadable one, because
        // the re-store that would have fixed it is exactly the call being skipped.
        var cache = CreateCache();
        var artifact = RandomArtifact(300 * 1024);

        await cache.StoreAsync(ClusterA, "dep-1", "rev-1", artifact);
        await _db.ExecuteAsync(
            "DELETE FROM deployment_artifacts WHERE deployment_id = @DeploymentId AND chunk_index = 2",
            new { DeploymentId = "dep-1" });
        (await cache.GetCurrentAsync(ClusterA)).ShouldBeNull();

        await cache.StoreAsync(ClusterA, "dep-1", "rev-1", artifact);

        (await CountChunksAsync("dep-1")).ShouldBe(3);
        var cached = await cache.GetCurrentAsync(ClusterA);
        cached.ShouldNotBeNull();
        cached.Artifact.ShouldBe(artifact);
    }

    [Fact]
    public async Task Store_RewritesWhenTheArtifactBytesChangedUnderAnUnchangedRevision()
    {
        // The identity check must be over the bytes, not just the ids. A rebuilt artifact carrying
        // the same deployment id and revision hash is a real case (the same revision re-composed),
        // and silently keeping the stale bytes would serve a boot-from-cache node the wrong address
        // space with every id-shaped signal saying it was correct.
        var cache = CreateCache();

        await cache.StoreAsync(ClusterA, "dep-1", "rev-1", RandomArtifact(1024));

        var rebuilt = RandomArtifact(1024);
        await cache.StoreAsync(ClusterA, "dep-1", "rev-1", rebuilt);

        var cached = await cache.GetCurrentAsync(ClusterA);
        cached.ShouldNotBeNull();
        cached.Artifact.ShouldBe(rebuilt);
    }

    [Fact]
    public async Task GetCurrentUnkeyed_ReturnsTheOnlyCachedArtifact()
    {
        var artifact = RandomArtifact(300 * 1024);
        var cache = CreateCache();

        await cache.StoreAsync(ClusterA, "dep-1", "rev-1", artifact);

        var cached = await cache.GetCurrentUnkeyedAsync();

        cached.ShouldNotBeNull();
        cached.DeploymentId.ShouldBe("dep-1");
        cached.Artifact.ShouldBe(artifact);
    }

    [Fact]
    public async Task GetCurrentUnkeyed_TakesTheNewestPointerAndWarnsNamingBothClusters()
    {
        var newest = RandomArtifact(1024);
        var logger = new CapturingLogger();
        var cache = CreateCache(logger);

        await cache.StoreAsync(ClusterA, "dep-a", "rev-a", RandomArtifact(1024));
        await cache.StoreAsync(ClusterB, "dep-b", "rev-b", newest);

        var cached = await cache.GetCurrentUnkeyedAsync();

        cached.ShouldNotBeNull();
        cached.DeploymentId.ShouldBe("dep-b");
        cached.Artifact.ShouldBe(newest);

        // A re-homed node booting a neighbouring cluster's configuration must be operator-visible.
        // Naming both clusters is what turns "wrong tags appeared" into a one-line diagnosis.
        var warning = logger.Entries.ShouldHaveSingleItem();
        warning.Level.ShouldBe(LogLevel.Warning);
        warning.Message.ShouldContain(ClusterA);
        warning.Message.ShouldContain(ClusterB);
    }

    public void Dispose()
    {
        _provider.Dispose();

        // Real files, and pooled connections outlive the provider — clearing the pools is what
        // makes the delete actually succeed.
        SqliteConnection.ClearAllPools();

        foreach (var path in new[] { _dbPath, $"{_dbPath}-wal", $"{_dbPath}-shm" })
        {
            if (File.Exists(path))
                File.Delete(path);
        }
    }

    /// <summary>Minimal logger that records formatted messages so a test can assert on them.</summary>
    private sealed class CapturingLogger : ILogger<LocalDbDeploymentArtifactCache>
    {
        public List<(LogLevel Level, string Message)> Entries { get; } = [];

        public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;

        public bool IsEnabled(LogLevel logLevel) => true;

        public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
                                Func<TState, Exception?, string> formatter)
            => Entries.Add((logLevel, formatter(state, exception)));
    }
}
