using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Health;
using ZB.MOM.WW.Health.Akka;
using ZB.MOM.WW.LocalDb;
using ZB.MOM.WW.LocalDb.Registration;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Health;
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
using ZB.MOM.WW.ScadaBridge.Host.Health;

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

/// <summary>
/// Site-node health endpoints. Site nodes previously served no health surface at all — only gRPC on
/// the HTTP/2 listener and <c>/metrics</c> on the HTTP/1.1 one — so the family overview dashboard
/// had no uniform way to probe them. These tests pin the three checks the site now registers and
/// the tier each one lands in.
/// <para>
/// The registration assertions build the REAL container from
/// <see cref="SiteServiceRegistration.Configure"/> rather than a hand-assembled
/// <c>ServiceCollection</c>, for the reason spelled out in <see cref="SiteLocalDbWiringTests"/>:
/// this family's wiring defects have consistently been ones that only a container-built graph
/// catches. Each registration is additionally ACTIVATED through its factory, so a check that is
/// registered but cannot resolve its dependencies fails here instead of at the first live probe.
/// </para>
/// </summary>
public class SiteHealthCheckTests : IDisposable
{
    private readonly WebApplication _host;
    private readonly string _tempDbPath;
    private readonly string _tempSiteDbPath;
    private readonly string _tempTrackingDbPath;

    public SiteHealthCheckTests()
    {
        var stamp = Guid.NewGuid();
        _tempDbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_health_localdb_{stamp}.db");
        _tempSiteDbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_health_site_{stamp}.db");
        _tempTrackingDbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_health_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",
            ["LocalDb:Path"] = _tempDbPath,
        });

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

    private IReadOnlyList<HealthCheckRegistration> Registrations =>
        _host.Services.GetRequiredService<IOptions<HealthCheckServiceOptions>>().Value.Registrations.ToList();

    [Fact]
    public void Site_RegistersExactlyTheThreeSiteChecks()
    {
        // Exact-set, not "contains": an extra check silently changes what /health/ready means for
        // every consumer, and a site node inheriting a central-only probe is precisely the mistake
        // this asserts against.
        Assert.Equal(
            new[] { "active-node", "akka-cluster", "localdb" },
            Registrations.Select(r => r.Name).OrderBy(n => n, StringComparer.Ordinal).ToArray());
    }

    [Theory]
    [InlineData("akka-cluster", ZbHealthTags.Ready)]
    [InlineData("localdb", ZbHealthTags.Ready)]
    [InlineData("active-node", ZbHealthTags.Active)]
    public void Site_ChecksCarryTheirIntendedTier(string name, string expectedTag)
    {
        var registration = Assert.Single(Registrations, r => r.Name == name);

        // Exactly one tier tag each: MapZbHealth selects by tag, so a check tagged both Ready and
        // Active would put standby-ness into readiness and drop the standby out of readiness too.
        Assert.Equal(new[] { expectedTag }, registration.Tags.ToArray());
    }

    [Theory]
    [InlineData("akka-cluster", typeof(AkkaClusterHealthCheck))]
    [InlineData("localdb", typeof(SiteLocalDbHealthCheck))]
    [InlineData("active-node", typeof(SitePairActiveNodeHealthCheck))]
    public void Site_ChecksActivateToTheIntendedType(string name, Type expected)
    {
        var registration = Assert.Single(Registrations, r => r.Name == name);

        // Running the factory is the point: a type-activated check whose constructor dependencies
        // are not registered on site would throw here rather than on the first probe in production.
        var check = registration.Factory(_host.Services);

        Assert.IsType(expected, check);
    }

    [Fact]
    public void Site_ActiveNodeCheck_IsRoleScoped_NotTheUnscopedCentralCheck()
    {
        // Central registers the shared ActiveNodeHealthCheck UNSCOPED — oldest Up member of the whole
        // cluster. On a mesh carrying more than one site's members that computes "oldest" across the
        // wrong member set, so a site node could report Active purely for being the oldest member
        // overall. A site must answer for its own site-{SiteId} role, which SitePairActiveNodeHealthCheck
        // does by delegating to the site's IClusterNodeProvider (itself now backed by the shared
        // ClusterActiveNode, so the rule is shared even though the scoping is local).
        var check = Assert.Single(Registrations, r => r.Name == "active-node").Factory(_host.Services);

        Assert.IsType<SitePairActiveNodeHealthCheck>(check);
        Assert.IsNotType<ActiveNodeHealthCheck>(check);
    }

    [Theory]
    [InlineData("database")]
    [InlineData("required-singletons")]
    public void Site_DoesNotRegisterCentralOnlyChecks(string centralOnlyName)
    {
        // Positive control first: if the harness ever stopped registering health checks at all,
        // every "is absent" assertion below would pass vacuously and this test would be worthless.
        Assert.Contains(Registrations, r => r.Name == "akka-cluster");

        Assert.DoesNotContain(Registrations, r => r.Name == centralOnlyName);
    }

    [Fact]
    public async Task Site_LocalDbCheck_ReportsHealthyAgainstTheRealSiteDatabase()
    {
        // Behaviour, not wiring: the probe runs against the consolidated site database this
        // composition actually built, so a check that resolves but cannot query fails here.
        var check = (SiteLocalDbHealthCheck)Assert.Single(Registrations, r => r.Name == "localdb")
            .Factory(_host.Services);

        var result = await check.CheckHealthAsync(NewContext("localdb", check));

        Assert.Equal(HealthStatus.Healthy, result.Status);
    }

    [Fact]
    public async Task Site_LocalDbCheck_EnrichesWithReplicationStateWithoutFailingOnDefaultOff()
    {
        // Replication ships default-OFF, so the unreplicated steady state must stay Healthy while
        // still surfacing the state as data — enrichment must never become a verdict.
        var check = (SiteLocalDbHealthCheck)Assert.Single(Registrations, r => r.Name == "localdb")
            .Factory(_host.Services);

        var result = await check.CheckHealthAsync(NewContext("localdb", check));

        Assert.Equal(HealthStatus.Healthy, result.Status);
        Assert.False(Assert.IsType<bool>(result.Data["replicationConnected"]));
        Assert.Equal(0L, Assert.IsType<long>(result.Data["replicationOplogBacklog"]));
    }

    [Fact]
    public async Task Site_LocalDbCheck_ReportsUnhealthyWhenTheStoreIsUnreachable()
    {
        var check = new SiteLocalDbHealthCheck(new ThrowingLocalDb());

        var result = await check.CheckHealthAsync(NewContext("localdb", check));

        Assert.Equal(HealthStatus.Unhealthy, result.Status);
    }

    [Theory]
    [InlineData(true, HealthStatus.Healthy)]
    [InlineData(false, HealthStatus.Unhealthy)]
    public async Task Site_ActiveNodeCheck_SplitsPrimaryFromStandby(bool selfIsPrimary, HealthStatus expected)
    {
        // 200 on the primary / 503 on the standby is the same contract central publishes, so a
        // consumer reads one active-node meaning fleet-wide.
        var check = new SitePairActiveNodeHealthCheck(new StubNodeProvider(selfIsPrimary));

        var result = await check.CheckHealthAsync(NewContext("active-node", check));

        Assert.Equal(expected, result.Status);
    }

    private static HealthCheckContext NewContext(string name, IHealthCheck check) => new()
    {
        Registration = new HealthCheckRegistration(name, check, HealthStatus.Unhealthy, tags: null),
    };

    private sealed class StubNodeProvider(bool selfIsPrimary) : IClusterNodeProvider
    {
        public bool SelfIsPrimary { get; } = selfIsPrimary;

        public IReadOnlyList<NodeStatus> GetClusterNodes() => [];
    }

    private sealed class ThrowingLocalDb : ILocalDb
    {
        public Task<int> ExecuteAsync(string sql, object? parameters = null, CancellationToken ct = default) =>
            throw new InvalidOperationException("store unreachable");

        public Task<IReadOnlyList<T>> QueryAsync<T>(
            string sql,
            Func<Microsoft.Data.Sqlite.SqliteDataReader, T> map,
            object? parameters = null,
            CancellationToken ct = default) =>
            throw new InvalidOperationException("store unreachable");

        public Task<ILocalDbTransaction> BeginTransactionAsync(CancellationToken ct = default) =>
            throw new InvalidOperationException("store unreachable");

        public Microsoft.Data.Sqlite.SqliteConnection CreateConnection() =>
            throw new InvalidOperationException("store unreachable");

        public ReplicatedTable RegisterReplicated(string tableName) =>
            throw new InvalidOperationException("store unreachable");

        public ReplicatedTable RegisterReplicated(string tableName, bool baselineExistingRows) =>
            throw new InvalidOperationException("store unreachable");

        public bool DeregisterReplicated(string tableName) =>
            throw new InvalidOperationException("store unreachable");

        public IReadOnlyDictionary<string, ReplicatedTable> ReplicatedTables =>
            throw new InvalidOperationException("store unreachable");
    }
}
