using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
using ZB.MOM.WW.Telemetry;
using ZB.MOM.WW.LocalDb;
using ZB.MOM.WW.LocalDb.Replication;
using ZB.MOM.WW.ScadaBridge.Host;
using ZB.MOM.WW.ScadaBridge.Host.Actors;

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

/// <summary>
/// LocalDb Phase 1 (Task 3) — locks the consolidated site database into the REAL site
/// composition root.
/// <para>
/// These tests build the actual container from
/// <see cref="SiteServiceRegistration.Configure"/> rather than asserting on a
/// hand-assembled <c>ServiceCollection</c>. That is deliberate and load-bearing: this
/// family has now shipped three wiring defects that only a container-built graph would
/// have caught — the Secrets Akka replicator that registered into a no-op sink (0.2.0),
/// the singleton cycle that deadlocked a real host (0.2.2), and Secrets.Ui's missing
/// <c>IAuditWriter</c> that 500'd only behind a login wall (ScadaBridge#22). A test that
/// merely checks "AddZbLocalDb was called" would have passed in every one of those cases.
/// </para>
/// </summary>
public class SiteLocalDbWiringTests : IDisposable
{
    private readonly WebApplication _host;
    private readonly string _tempDbPath;
    private readonly string _tempSiteDbPath;
    private readonly string _tempTrackingDbPath;

    public SiteLocalDbWiringTests()
    {
        var stamp = Guid.NewGuid();
        _tempDbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_localdb_{stamp}.db");
        _tempSiteDbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_site_{stamp}.db");
        _tempTrackingDbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_track_{stamp}.db");

        var builder = WebApplication.CreateBuilder();
        builder.Configuration.Sources.Clear();
        builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
        {
            ["ScadaBridge:Node:Role"] = "Site",
            ["ScadaBridge:Node:NodeName"] = "node-a",
            ["ScadaBridge:Node:NodeHostname"] = "test-site",
            ["ScadaBridge:Node:SiteId"] = "TestSite",
            ["ScadaBridge:Node:RemotingPort"] = "0",
            ["ScadaBridge:Node:GrpcPort"] = "0",
            ["ScadaBridge:Database:SiteDbPath"] = _tempSiteDbPath,
            ["ScadaBridge:OperationTracking:ConnectionString"] = $"Data Source={_tempTrackingDbPath}",
            ["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@localhost:2551",
            ["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@localhost:2552",

            // The consolidated site database, configured exactly like the PASSIVE half of
            // the rig's replicated pair (site-a node-b): an ApiKey and no PeerAddress.
            //
            // The key is what makes this a replicating node, and therefore what makes the
            // CDC registration assertions below apply at all — capture is installed only
            // when replication is configured. The absent PeerAddress keeps this fixture the
            // default-OFF pin at the same time: nothing dials, so the engine must still
            // resolve and idle rather than throw or report a connection.
            ["LocalDb:Path"] = _tempDbPath,
            ["LocalDb:Replication:ApiKey"] = "wiring-test-localdb-sync-key",
        });

        builder.Services.AddGrpc();
        builder.Services.AddSingleton<ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteStreamGrpcServer>();

        SiteServiceRegistration.Configure(builder.Services, builder.Configuration);

        AkkaHostedServiceRemover.RemoveAkkaHostedServiceOnly(builder.Services);

        _host = builder.Build();
    }

    public void Dispose()
    {
        (_host as IDisposable)?.Dispose();
        foreach (var path in new[] { _tempDbPath, _tempSiteDbPath, _tempTrackingDbPath })
        {
            try { File.Delete(path); } catch { /* best effort */ }
        }

        GC.SuppressFinalize(this);
    }

    [Fact]
    public void Site_Resolves_ILocalDb()
    {
        Assert.NotNull(_host.Services.GetService<ILocalDb>());
    }

    [Fact]
    public void Site_LocalDb_RegistersBothReplicatedTables()
    {
        // The whole point of Phase 1. If onReady silently failed to register these,
        // storage would still work and every other test would pass — the pair would
        // just never replicate anything. Assert on the library's own view of what is
        // registered, not on our belief that we called it.
        var db = _host.Services.GetRequiredService<ILocalDb>();

        Assert.Contains("OperationTracking", db.ReplicatedTables.Keys);
        Assert.Contains("site_events", db.ReplicatedTables.Keys);
    }

    [Fact]
    public void Site_LocalDb_ReplicatedTablesHaveExplicitPrimaryKeys()
    {
        // RegisterReplicated refuses a table with no explicit PK, so reaching this
        // assertion at all proves the DDL ran before registration. The PK names are
        // pinned because they are what last-writer-wins conflict resolution keys on.
        var db = _host.Services.GetRequiredService<ILocalDb>();

        Assert.Equal(
            ["TrackedOperationId"], db.ReplicatedTables["OperationTracking"].PkColumns);
        Assert.Equal(["id"], db.ReplicatedTables["site_events"].PkColumns);
    }

    [Fact]
    public void Site_LocalDb_IsASingleton()
    {
        // AddZbLocalDb is one-DB-per-process and first-registration-wins. Two
        // instances would mean two SQLite handles and two trigger installations
        // against the same file.
        var first = _host.Services.GetRequiredService<ILocalDb>();
        var second = _host.Services.GetRequiredService<ILocalDb>();

        Assert.Same(first, second);
    }

    [Fact]
    public void Site_Replication_IsRegistered_AndInertWithNoPeer()
    {
        // Task 7's default-OFF pin. This fixture configures NO
        // LocalDb:Replication:PeerAddress, which is the shipping default, so the
        // engine must resolve cleanly and sit idle rather than throw, retry, or
        // report a connection. "Default-off" here means inert, NOT unregistered —
        // the services are always in the graph.
        var status = _host.Services.GetService<ISyncStatus>();

        Assert.NotNull(status);
        Assert.False(status!.Connected);
        Assert.Null(status.PeerNodeId);
        Assert.Null(status.LastSyncUtc);
    }

    [Fact]
    public void Site_Replication_OplogBacklog_IsZero_NotNull_OnAHealthyIdleNode()
    {
        // OplogBacklog is deliberately nullable: null means "unknown" (no provider
        // wired, or the poll failed) and must never be reported as a healthy 0. On a
        // node whose local database is present and readable, a real 0 proves the
        // backlog provider is actually wired to the oplog — a null here would mean
        // Task 9's health signal is about to publish "unknown" forever.
        var status = _host.Services.GetRequiredService<ISyncStatus>();

        Assert.Equal(0L, status.OplogBacklog);
    }

    [Fact]
    public void Site_Replication_HealthBridge_IsRegisteredAsAHostedService()
    {
        // Task 9. Registered via a factory lambda, so ImplementationType is null and the
        // only honest assertion is on the resolved instance.
        var hosted = _host.Services.GetServices<IHostedService>();

        Assert.Contains(hosted, h => h is LocalDbReplicationStatusReporter);
    }

    [Fact]
    public void Site_Replication_HealthBridge_PublishesStatusOntoTheHealthReport()
    {
        // End-to-end through the REAL collector: probe once, then collect. This is what
        // catches a bridge that is registered but wired to the wrong provider — the
        // registration test above would pass either way.
        var reporter = _host.Services.GetServices<IHostedService>()
            .OfType<LocalDbReplicationStatusReporter>()
            .Single();
        var collector = _host.Services.GetRequiredService<ISiteHealthCollector>();

        reporter.Probe();
        var report = collector.CollectReport("TestSite");

        // No peer configured: not connected, and a REAL 0 backlog rather than null.
        Assert.False(report.LocalDbReplicationConnected);
        Assert.Equal(0L, report.LocalDbOplogBacklog);
    }

    [Fact]
    public void Site_HealthReport_LocalDbFields_AreNull_BeforeTheReporterHasRun()
    {
        // Null means "no data yet", and must be distinguishable from a real
        // "disconnected, zero backlog". A collector that defaulted these to false/0 would
        // report an unwired node as a healthy connected one.
        var collector = new SiteHealthCollector();

        var report = collector.CollectReport("TestSite");

        Assert.Null(report.LocalDbReplicationConnected);
        Assert.Null(report.LocalDbOplogBacklog);
    }

    [Fact]
    public void Site_Telemetry_Allowlists_TheLocalDbReplicationMeter()
    {
        // ZbTelemetryOptions.Meters is an ALLOWLIST: an unlisted Meter's instruments are
        // never observed, with no error and no missing-metric warning anywhere. The
        // replication meter was silently absent from the rig's /metrics scrape until the
        // live gate looked for it, so pin the allowlist rather than trusting the next
        // reader to remember.
        //
        // This asserts on the allowlist constant, not on OTel's observed-meter set:
        // AddZbTelemetry applies its options to a local instance and never registers
        // IOptions, so there is nothing resolvable to interrogate. The end-to-end proof
        // that localdb_* actually reaches /metrics is the Task 12 live gate; this test
        // exists to stop the entry being dropped again.
        Assert.Contains(LocalDbMetrics.MeterName, SiteServiceRegistration.ObservedMeters);
    }

    [Fact]
    public void Site_LocalDb_CreatesThePhase2Tables_InTheConsolidatedFile()
    {
        // Phase 2 (Task 7). The nine configuration tables and the store-and-forward buffer
        // now live in the consolidated file rather than in scadabridge.db and
        // store-and-forward.db. Asserting through the REAL composition root is what proves
        // OnReady applies both schemas — the stores themselves also call Apply, so a test
        // that went through a store would pass even if OnReady never touched them.
        var db = _host.Services.GetRequiredService<ILocalDb>();

        var tables = TableNames(db);

        // One from each Phase 1 schema, then every Phase 2 table.
        Assert.Contains("OperationTracking", tables);
        Assert.Contains("site_events", tables);
        Assert.Contains("sf_messages", tables);
        foreach (var table in new[]
        {
            "deployed_configurations", "static_attribute_overrides", "shared_scripts",
            "external_systems", "database_connections", "notification_lists",
            "data_connection_definitions", "smtp_configurations", "native_alarm_state",
        })
        {
            Assert.Contains(table, tables);
        }
    }

    [Fact]
    public void Site_LocalDb_RegistersExactlyTheTenReplicatedTables()
    {
        // The Phase 2 cutover. This is an EXACTLY check rather than a Contains in both
        // directions, and both directions are load-bearing.
        //
        // Too few: the table silently stops replicating. Storage still works, every other
        // test still passes, and the pair just quietly diverges — the failure mode Phase 1
        // existed to end.
        //
        // Too many: notification_lists and smtp_configurations must NOT be here. They are
        // permanently empty by design, and registering them would open a standing
        // replication channel whose only historical payload was plaintext SMTP passwords.
        // A Contains-based test would never catch that.
        var db = _host.Services.GetRequiredService<ILocalDb>();

        Assert.Equal(
            [
                // Ordinal order: '_' (0x5F) sorts before 'b' (0x62), so
                // data_connection_definitions precedes database_connections.
                "OperationTracking", "data_connection_definitions", "database_connections",
                "deployed_configurations", "external_systems", "native_alarm_state",
                "sf_messages", "shared_scripts", "site_events", "static_attribute_overrides",
            ],
            db.ReplicatedTables.Keys.OrderBy(k => k, StringComparer.Ordinal).ToArray());
    }

    [Fact]
    public void Site_LocalDb_DoesNotReplicateTheCentralOnlyNotificationTables()
    {
        // Stated separately from the exact-set test above because this one is a security
        // property, not a wiring property, and deserves to fail with its own name. The
        // tables exist (SiteStorageSchema creates them) — they simply must never be
        // captured. See SiteLocalDbSetup.OnReady for the rationale.
        var db = _host.Services.GetRequiredService<ILocalDb>();

        Assert.DoesNotContain("notification_lists", db.ReplicatedTables.Keys);
        Assert.DoesNotContain("smtp_configurations", db.ReplicatedTables.Keys);
    }

    [Fact]
    public void Site_LocalDb_ReplicatedPhase2TablesHaveTheirExpectedPrimaryKeys()
    {
        // RegisterReplicated refuses a table with no explicit PK, so reaching these
        // assertions proves the DDL ran before registration. The composite keys are the
        // interesting ones: LWW conflict resolution keys on the FULL PK, so a wrong or
        // truncated key set would silently collapse distinct rows into one.
        var db = _host.Services.GetRequiredService<ILocalDb>();

        Assert.Equal(["id"], db.ReplicatedTables["sf_messages"].PkColumns);
        Assert.Equal(
            ["instance_unique_name"], db.ReplicatedTables["deployed_configurations"].PkColumns);
        Assert.Equal(
            ["instance_unique_name", "attribute_name"],
            db.ReplicatedTables["static_attribute_overrides"].PkColumns);
        Assert.Equal(
            ["instance_unique_name", "source_canonical_name", "source_reference"],
            db.ReplicatedTables["native_alarm_state"].PkColumns);
    }

    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;
    }

    [Fact]
    public void Site_LocalDb_CreatesTheConfiguredFile()
    {
        // Resolving is what triggers onReady; the file should exist afterwards at the
        // configured path and not somewhere CWD-relative.
        _ = _host.Services.GetRequiredService<ILocalDb>();

        Assert.True(File.Exists(_tempDbPath),
            $"Expected the consolidated site database at '{_tempDbPath}'.");
    }
}
