using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Shouldly;
using Xunit;
using ZB.MOM.WW.LocalDb;
using ZB.MOM.WW.OtOpcUa.Host.Configuration;

namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;

/// <summary>
///     LocalDb Phase 1 (Task 2) — pins the deployment-cache schema and the load-bearing
///     <c>DDL → RegisterReplicated → writes</c> ordering inside <see cref="LocalDbSetup.OnReady"/>.
/// </summary>
/// <remarks>
///     <para>
///         The ordering assertion is the point of this file. <c>RegisterReplicated</c> installs the
///         capture triggers; rows written <b>before</b> it are never captured into the oplog and
///         therefore never reach the peer — silently, and forever. A reordered <c>OnReady</c> passes
///         every schema-shape test while shipping a replication path that quietly moves nothing.
///     </para>
///     <para>
///         Built against a real temp-file <see cref="ILocalDb"/> rather than a hand-written schema:
///         the library has no in-memory mode, and asserting against a schema the test itself wrote
///         would only prove the test agrees with itself.
///     </para>
/// </remarks>
public sealed class LocalDbSetupTests : IDisposable
{
    private readonly string _dbPath =
        Path.Combine(Path.GetTempPath(), $"otopcua-localdb-setup-{Guid.NewGuid():N}.db");

    private ServiceProvider? _provider;

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

        _provider = new ServiceCollection()
            .AddZbLocalDb(configuration, db => LocalDbSetup.OnReady(db, configuration))
            .BuildServiceProvider();

        return _provider.GetRequiredService<ILocalDb>();
    }

    [Fact]
    public void OnReady_RegistersExactlyTheFourReplicatedTables()
    {
        // Both directions are load-bearing. "No fewer" catches a dropped RegisterReplicated call
        // (that table then never replicates). "No more" catches an accidental registration —
        // every replicated table costs three triggers and oplog volume on every write.
        var db = BuildDb();

        db.ReplicatedTables.Keys.OrderBy(k => k, StringComparer.Ordinal)
            .ShouldBe(["alarm_condition_state", "alarm_sf_events", "deployment_artifacts", "deployment_pointer"]);
    }

    [Fact]
    public void DeploymentArtifacts_PkIsDeploymentIdPlusChunkIndex()
    {
        // The composite PK is what makes an artifact chunkable. A single-column PK here would
        // make every chunk of a deployment collide onto one row under LWW.
        var db = BuildDb();

        db.ReplicatedTables["deployment_artifacts"].PkColumns
            .ShouldBe(["deployment_id", "chunk_index"]);
    }

    [Fact]
    public void DeploymentPointer_PkIsClusterId()
    {
        // One current-deployment pointer per cluster: the pair's two nodes converge on the same
        // row rather than each keeping its own.
        var db = BuildDb();

        db.ReplicatedTables["deployment_pointer"].PkColumns.ShouldBe(["cluster_id"]);
    }

    [Fact]
    public void AlarmSfEvents_PkIsTheAppMintedId()
    {
        // A single TEXT PK the application mints. The legacy queue this table replaces keyed on
        // `RowId INTEGER PRIMARY KEY AUTOINCREMENT`, which LWW cannot replicate: two nodes would
        // independently allocate rowid 7 for different alarms and silently overwrite each other.
        var db = BuildDb();

        db.ReplicatedTables["alarm_sf_events"].PkColumns.ShouldBe(["id"]);
    }

    [Fact]
    public void AlarmConditionState_PkIsTheScriptedAlarmId()
    {
        // One condition-state row per alarm identity. The pair's two nodes converge on the same row
        // under LWW rather than each keeping its own, and Save is an unconditional upsert onto it.
        var db = BuildDb();

        db.ReplicatedTables["alarm_condition_state"].PkColumns.ShouldBe(["scripted_alarm_id"]);
    }

    [Fact]
    public async Task AlarmSfEventRows_EnterTheOplog()
    {
        // Same ordering assertion as the deployment-pointer test below, for the table Phase 2 adds.
        // Worth its own case because the alarm table is registered by a different call site, and a
        // registration added ahead of its DDL would fail loudly while one added after the migrator
        // would fail silently.
        var db = BuildDb();

        await db.ExecuteAsync(
            """
            INSERT INTO alarm_sf_events
                (id, alarm_id, enqueued_at_utc, payload_json, attempt_count)
            VALUES (@Id, @AlarmId, @EnqueuedAtUtc, @PayloadJson, 0)
            """,
            new
            {
                Id = new string('c', 64),
                AlarmId = "equip/alarm-1",
                EnqueuedAtUtc = "2026-07-21T00:00:00.0000000Z",
                PayloadJson = """{"AlarmId":"equip/alarm-1"}""",
            },
            TestContext.Current.CancellationToken);

        var oplogRows = await db.QueryAsync(
            "SELECT COUNT(*) FROM __localdb_oplog WHERE table_name = 'alarm_sf_events'",
            r => r.GetInt32(0),
            parameters: null,
            TestContext.Current.CancellationToken);

        oplogRows[0].ShouldBe(1);
    }

    [Fact]
    public async Task RowsWrittenAfterOnReady_EnterTheOplog()
    {
        // THE ordering assertion. If DDL and RegisterReplicated were swapped — or a write were
        // added to OnReady ahead of registration — the triggers would not exist at write time and
        // this count would stay 0 while every other test in this file still passed.
        var db = BuildDb();

        await db.ExecuteAsync(
            """
            INSERT INTO deployment_pointer
                (cluster_id, deployment_id, revision_hash, artifact_sha256, applied_at_utc)
            VALUES (@ClusterId, @DeploymentId, @RevisionHash, @Sha, @AppliedAtUtc)
            """,
            new
            {
                ClusterId = "SITE-A",
                DeploymentId = "0123456789abcdef0123456789abcdef",
                RevisionHash = new string('a', 64),
                Sha = new string('b', 64),
                AppliedAtUtc = "2026-07-20T00:00:00.0000000Z",
            },
            TestContext.Current.CancellationToken);

        var oplogRows = await db.QueryAsync(
            "SELECT COUNT(*) FROM __localdb_oplog",
            r => r.GetInt32(0),
            parameters: null,
            TestContext.Current.CancellationToken);

        oplogRows[0].ShouldBeGreaterThanOrEqualTo(1);
    }

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

        // No in-memory mode, so these are real files. Pooled connections keep a handle open past
        // dispose; clearing the pools first 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);
        }
    }
}
