using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.LocalDb;

namespace ZB.MOM.WW.ScadaBridge.Host.Tests;

/// <summary>
/// WP1.3 — CDC capture is installed only on a node that actually replicates.
/// </summary>
/// <remarks>
/// <para>
/// These assert on the TRIGGERS in <c>sqlite_master</c> rather than on
/// <c>ILocalDb.ReplicatedTables</c>, because the trigger set is what costs anything: the
/// registry entry is a dictionary lookup, while each trigger runs two extra INSERTs plus a
/// <c>json_object</c> serialization of the full row inside every write transaction on the
/// table. An unreplicated node paid that on every store-and-forward enqueue, every static
/// override, every event log row, forever, for an oplog with no reader.
/// </para>
/// <para>
/// The naming <c>__localdb_{table}_{ai|au|ad}</c> is the library's
/// (<c>TriggerSqlGenerator.TriggerName</c>), matched by prefix here so a fourth trigger kind
/// would be caught rather than quietly ignored.
/// </para>
/// </remarks>
public class SiteLocalDbCdcRegistrationTests : IDisposable
{
    private readonly string _root;
    private readonly List<ServiceProvider> _providers = [];

    public SiteLocalDbCdcRegistrationTests()
    {
        _root = Path.Combine(Path.GetTempPath(), $"localdb-cdc-{Guid.NewGuid():N}");
        Directory.CreateDirectory(_root);
    }

    public void Dispose()
    {
        foreach (var provider in _providers)
        {
            try { provider.Dispose(); } catch { /* best effort */ }
        }

        Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
        try { Directory.Delete(_root, recursive: true); } catch { /* best effort */ }
        GC.SuppressFinalize(this);
    }

    [Fact]
    public void UnreplicatedNode_InstallsNoCaptureTriggers()
    {
        // No LocalDb:Replication section at all — site-b and site-c on the rig.
        var db = BuildDatabase(Config());

        Assert.Empty(CaptureTriggers(db));
    }

    [Fact]
    public void UnreplicatedNode_StillCreatesEveryTable()
    {
        // Skipping registration must not skip the DDL: the node is fully functional
        // standalone, it just has nobody to ship its changes to. A guard placed around the
        // schema block instead of the registration block would fail here and nowhere else.
        var db = BuildDatabase(Config());

        var tables = TableNames(db);

        foreach (var table in AllSiteTables)
            Assert.Contains(table, tables);
    }

    [Fact]
    public void InitiatorNode_InstallsCaptureTriggers()
    {
        // PeerAddress + ApiKey — site-a node-a, the half that dials.
        var db = BuildDatabase(Config(
            peerAddress: "http://peer:8083", apiKey: "cdc-test-key"));

        AssertCaptureTriggersCoverTheReplicatedTables(db);
    }

    [Fact]
    public void PassiveNode_WithApiKeyButNoPeerAddress_StillInstallsCaptureTriggers()
    {
        // The reason the predicate is an OR. Site-a node-b sets ApiKey and nothing else:
        // one bidirectional stream, dialled by one side. Keying on PeerAddress alone would
        // leave this node capturing nothing, so its own writes would never reach the
        // initiator and the pair would converge in one direction only — silently.
        var db = BuildDatabase(Config(apiKey: "cdc-test-key"));

        AssertCaptureTriggersCoverTheReplicatedTables(db);
    }

    [Fact]
    public void ReplicatedNode_InstallsNoCaptureTriggersOnTheCentralOnlyNotificationTables()
    {
        // The security property, restated at the trigger level: notification_lists and
        // smtp_configurations exist but must never be captured, because the only payload
        // they ever historically held was plaintext SMTP passwords.
        var db = BuildDatabase(Config(apiKey: "cdc-test-key"));

        var triggers = CaptureTriggers(db);

        Assert.DoesNotContain(triggers, t => t.StartsWith("__localdb_notification_lists_", StringComparison.Ordinal));
        Assert.DoesNotContain(triggers, t => t.StartsWith("__localdb_smtp_configurations_", StringComparison.Ordinal));
    }

    [Fact]
    public void UnreplicatedNode_DropsTriggersLeftByAPreviouslyReplicatedBuild()
    {
        // WP3.3, the residual WP1.3 could only document: skipping registration never removed
        // what an EARLIER build installed, so a long-lived unreplicated node upgraded in place
        // kept capturing forever. LocalDb 0.2.0's DeregisterReplicated closes it, and this is
        // the exact upgrade shape — the same file, booted first WITH replication configured and
        // then without. The first boot is what makes the assertion meaningful: without it an
        // empty trigger set would prove nothing.
        var replicated = BuildDatabase(Config(apiKey: "cdc-test-key"));
        AssertCaptureTriggersCoverTheReplicatedTables(replicated);
        DisposeProviders();

        var unreplicated = BuildDatabase(Config());

        Assert.Empty(CaptureTriggers(unreplicated));
    }

    [Fact]
    public void UnreplicatedNode_PrunesTheOplogLeftByAPreviouslyReplicatedBuild()
    {
        // Deregistration is not only about the trigger cost: the rows already captured are an
        // oplog nothing will ever drain, so they are pruned with the triggers. Written through
        // the schema's own table so the capture path is the real one.
        var replicated = BuildDatabase(Config(apiKey: "cdc-test-key"));
        InsertSiteEvent(replicated, "evt-1");
        Assert.NotEqual(0, OplogRowCount(replicated));
        DisposeProviders();

        var unreplicated = BuildDatabase(Config());

        Assert.Equal(0, OplogRowCount(unreplicated));

        // The table's own data is never touched — deregistration removes bookkeeping, not rows.
        Assert.Equal(1, SiteEventCount(unreplicated));
    }

    [Fact]
    public void UnreplicatedNode_OverAFileThatWasNeverRegistered_IsANoOp()
    {
        // DeregisterReplicated is idempotent and usable on a table this process never
        // registered, so the clean-up branch must be harmless on the ordinary case: site-b and
        // site-c boot after boot with nothing to clean.
        _ = BuildDatabase(Config());
        DisposeProviders();

        var second = BuildDatabase(Config());

        Assert.Empty(CaptureTriggers(second));
        foreach (var table in AllSiteTables)
            Assert.Contains(table, TableNames(second));
    }

    [Fact]
    public void ReplicatedNode_BaselinesRowsThatPredateRegistration()
    {
        // The other half of the late-opt-in story: a site that has been running unreplicated
        // and then turns replication ON. The snapshot streamer pages from __localdb_row_version,
        // which only the capture triggers populate, so without baselining these rows would be
        // invisible to the peer forever. RegisterReplicated(..., baselineExistingRows: true)
        // seeds them at the LWW floor (HLC 0).
        var unreplicated = BuildDatabase(Config());
        Assert.Empty(CaptureTriggers(unreplicated));
        InsertSiteEvent(unreplicated, "pre-existing");
        Assert.Equal(0, RowVersionCount(unreplicated, "site_events"));
        DisposeProviders();

        var replicated = BuildDatabase(Config(apiKey: "cdc-test-key"));

        Assert.Equal(1, RowVersionCount(replicated, "site_events"));

        // At the floor, so the seeded version loses to any genuine remote write of the same key.
        Assert.Equal(0L, RowVersionHlc(replicated, "site_events"));
    }

    [Fact]
    public void UnreplicatedNode_StillRunsTheLegacyMigrator()
    {
        // The migrator is deliberately outside the guard. It renames the legacy file to
        // ".migrated" on success, which is the observable proof it ran without needing a
        // capture trigger to look at.
        var legacyPath = Path.Combine(_root, "legacy-store-and-forward.db");
        SeedLegacyStoreAndForward(legacyPath);

        _ = BuildDatabase(Config(storeAndForwardPath: legacyPath));

        Assert.False(File.Exists(legacyPath));
        Assert.True(File.Exists(legacyPath + ".migrated"));
    }

    // ---- helpers ----------------------------------------------------------------------

    /// <summary>The ten replicated tables plus the two deliberately-unregistered ones.</summary>
    private static readonly string[] AllSiteTables =
    [
        "OperationTracking", "site_events", "sf_messages", "deployed_configurations",
        "static_attribute_overrides", "shared_scripts", "external_systems",
        "database_connections", "data_connection_definitions", "native_alarm_state",
        "notification_lists", "smtp_configurations",
    ];

    private static readonly string[] ReplicatedTables =
    [
        "OperationTracking", "site_events", "sf_messages", "deployed_configurations",
        "static_attribute_overrides", "shared_scripts", "external_systems",
        "database_connections", "data_connection_definitions", "native_alarm_state",
    ];

    private static void AssertCaptureTriggersCoverTheReplicatedTables(ILocalDb db)
    {
        var triggers = CaptureTriggers(db);

        foreach (var table in ReplicatedTables)
        {
            // All three kinds, so a partial install is a failure rather than a pass.
            foreach (var suffix in new[] { "ai", "au", "ad" })
                Assert.Contains($"__localdb_{table}_{suffix}", triggers);
        }
    }

    /// <summary>
    /// Closes every database built so far and releases the pooled connections, so the NEXT
    /// <see cref="BuildDatabase"/> on the same file opens it fresh — which is what a node
    /// restarting with different replication configuration actually does.
    /// </summary>
    private void DisposeProviders()
    {
        foreach (var provider in _providers) provider.Dispose();
        _providers.Clear();
        Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
    }

    private static void InsertSiteEvent(ILocalDb db, string id)
    {
        using var connection = db.CreateConnection();
        using var cmd = connection.CreateCommand();
        cmd.CommandText =
            """
            INSERT INTO site_events (id, timestamp, event_type, severity, source, message)
            VALUES ($id, '2026-08-14T00:00:00.0000000Z', 'Test', 'Info', 'cdc-test', 'row');
            """;
        cmd.Parameters.AddWithValue("$id", id);
        cmd.ExecuteNonQuery();
    }

    private static long OplogRowCount(ILocalDb db) => Scalar(db, "SELECT COUNT(*) FROM __localdb_oplog");

    private static long SiteEventCount(ILocalDb db) => Scalar(db, "SELECT COUNT(*) FROM site_events");

    private static long RowVersionCount(ILocalDb db, string table) => Scalar(
        db, $"SELECT COUNT(*) FROM __localdb_row_version WHERE table_name = '{table}'");

    private static long RowVersionHlc(ILocalDb db, string table) => Scalar(
        db, $"SELECT MAX(hlc) FROM __localdb_row_version WHERE table_name = '{table}'");

    private static long Scalar(ILocalDb db, string sql)
    {
        using var connection = db.CreateConnection();
        using var cmd = connection.CreateCommand();
        cmd.CommandText = sql;
        return Convert.ToInt64(cmd.ExecuteScalar());
    }

    private ILocalDb BuildDatabase(IConfiguration config)
    {
        var provider = new ServiceCollection()
            .AddZbLocalDb(config, db => SiteLocalDbSetup.OnReady(db, config))
            .BuildServiceProvider();
        _providers.Add(provider);

        return provider.GetRequiredService<ILocalDb>();
    }

    private IConfiguration Config(
        string? peerAddress = null, string? apiKey = null, string? storeAndForwardPath = null)
    {
        var values = new Dictionary<string, string?>
        {
            ["LocalDb:Path"] = Path.Combine(_root, "consolidated.db"),
            ["ScadaBridge:Node:NodeName"] = "node-a",

            // Every legacy path is pinned inside this test's own directory. Left unset they
            // resolve CWD-relative (./data/…), and the migrator RENAMES whatever it finds —
            // so an unlucky run could eat a real file from the test binary's output folder.
            ["ScadaBridge:StoreAndForward:SqliteDbPath"] =
                storeAndForwardPath ?? Path.Combine(_root, "absent-store-and-forward.db"),
            ["ScadaBridge:Database:SiteDbPath"] = Path.Combine(_root, "absent-scadabridge.db"),
            ["ScadaBridge:SiteEventLog:DatabasePath"] = Path.Combine(_root, "absent-events.db"),
            ["ScadaBridge:OperationTracking:ConnectionString"] =
                $"Data Source={Path.Combine(_root, "absent-tracking.db")}",
        };

        if (peerAddress is not null) values["LocalDb:Replication:PeerAddress"] = peerAddress;
        if (apiKey is not null) values["LocalDb:Replication:ApiKey"] = apiKey;

        return new ConfigurationBuilder().AddInMemoryCollection(values).Build();
    }

    private static void SeedLegacyStoreAndForward(string path)
    {
        using var connection = new Microsoft.Data.Sqlite.SqliteConnection($"Data Source={path}");
        connection.Open();
        ZB.MOM.WW.ScadaBridge.StoreAndForward.StoreAndForwardSchema.Apply(connection);
    }

    /// <summary>
    /// Every LocalDb capture trigger in the file. Matched on the library's
    /// <c>__localdb_</c> prefix in C# rather than with SQL <c>LIKE</c>, where the underscores
    /// are single-character wildcards and the pattern would need escaping to mean itself.
    /// </summary>
    private static HashSet<string> CaptureTriggers(ILocalDb db)
    {
        using var connection = db.CreateConnection();
        using var cmd = connection.CreateCommand();
        cmd.CommandText = "SELECT name FROM sqlite_master WHERE type = 'trigger'";
        using var reader = cmd.ExecuteReader();

        var names = new HashSet<string>(StringComparer.Ordinal);
        while (reader.Read())
        {
            var name = reader.GetString(0);
            if (name.StartsWith("__localdb_", StringComparison.Ordinal)) names.Add(name);
        }

        return names;
    }

    private static HashSet<string> TableNames(ILocalDb db)
    {
        using var connection = db.CreateConnection();
        using var cmd = connection.CreateCommand();
        cmd.CommandText = "SELECT name FROM sqlite_master WHERE type = 'table'";
        using var reader = cmd.ExecuteReader();

        var names = new HashSet<string>(StringComparer.Ordinal);
        while (reader.Read()) names.Add(reader.GetString(0));
        return names;
    }
}
