using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
using ZB.MOM.WW.OtOpcUa.Server;

namespace ZB.MOM.WW.OtOpcUa.Server.Tests;

[Trait("Category", "Integration")]
public sealed class HostStatusPublisherTests : IDisposable
{
    private const string DefaultServer = "localhost,14330";
    private const string DefaultSaPassword = "OtOpcUaDev_2026!";

    private readonly string _databaseName = $"OtOpcUaPublisher_{Guid.NewGuid():N}";
    private readonly string _connectionString;
    private readonly ServiceProvider _sp;

    public HostStatusPublisherTests()
    {
        var server = Environment.GetEnvironmentVariable("OTOPCUA_CONFIG_TEST_SERVER") ?? DefaultServer;
        var password = Environment.GetEnvironmentVariable("OTOPCUA_CONFIG_TEST_SA_PASSWORD") ?? DefaultSaPassword;
        _connectionString =
            $"Server={server};Database={_databaseName};User Id=sa;Password={password};TrustServerCertificate=True;Encrypt=False;";

        var services = new ServiceCollection();
        services.AddLogging();
        services.AddDbContext<OtOpcUaConfigDbContext>(o => o.UseSqlServer(_connectionString));
        _sp = services.BuildServiceProvider();

        using var scope = _sp.CreateScope();
        scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>().Database.Migrate();
    }

    public void Dispose()
    {
        _sp.Dispose();
        using var conn = new Microsoft.Data.SqlClient.SqlConnection(
            new Microsoft.Data.SqlClient.SqlConnectionStringBuilder(_connectionString) { InitialCatalog = "master" }.ConnectionString);
        conn.Open();
        using var cmd = conn.CreateCommand();
        cmd.CommandText = $@"
IF DB_ID(N'{_databaseName}') IS NOT NULL
BEGIN
    ALTER DATABASE [{_databaseName}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
    DROP DATABASE [{_databaseName}];
END";
        cmd.ExecuteNonQuery();
    }

    [Fact]
    public async Task Publisher_upserts_one_row_per_host_reported_by_each_probe_driver()
    {
        var driverHost = new DriverHost();
        await driverHost.RegisterAsync(new ProbeStubDriver("driver-a",
            new HostConnectivityStatus("HostA1", HostState.Running, DateTime.UtcNow),
            new HostConnectivityStatus("HostA2", HostState.Stopped, DateTime.UtcNow)),
            "{}", CancellationToken.None);
        await driverHost.RegisterAsync(new NonProbeStubDriver("driver-no-probe"), "{}", CancellationToken.None);

        var nodeOptions = NewNodeOptions("node-a");
        var publisher = new HostStatusPublisher(driverHost, nodeOptions, _sp.GetRequiredService<IServiceScopeFactory>(),
            NullLogger<HostStatusPublisher>.Instance);

        await publisher.PublishOnceAsync(CancellationToken.None);

        using var scope = _sp.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>();
        var rows = await db.DriverHostStatuses.AsNoTracking().ToListAsync();

        rows.Count.ShouldBe(2, "driver-no-probe doesn't implement IHostConnectivityProbe — no rows for it");
        rows.ShouldContain(r => r.HostName == "HostA1" && r.State == DriverHostState.Running && r.DriverInstanceId == "driver-a");
        rows.ShouldContain(r => r.HostName == "HostA2" && r.State == DriverHostState.Stopped && r.DriverInstanceId == "driver-a");
        rows.ShouldAllBe(r => r.NodeId == "node-a");
    }

    [Fact]
    public async Task Second_tick_updates_LastSeenUtc_without_creating_duplicate_rows()
    {
        var driver = new ProbeStubDriver("driver-x",
            new HostConnectivityStatus("HostX", HostState.Running, DateTime.UtcNow));
        var driverHost = new DriverHost();
        await driverHost.RegisterAsync(driver, "{}", CancellationToken.None);

        var publisher = new HostStatusPublisher(driverHost, NewNodeOptions("node-x"),
            _sp.GetRequiredService<IServiceScopeFactory>(),
            NullLogger<HostStatusPublisher>.Instance);

        await publisher.PublishOnceAsync(CancellationToken.None);
        var firstSeen = await SingleRowAsync("node-x", "driver-x", "HostX");
        await Task.Delay(50); // guarantee a later wall-clock value so LastSeenUtc advances
        await publisher.PublishOnceAsync(CancellationToken.None);
        var secondSeen = await SingleRowAsync("node-x", "driver-x", "HostX");

        secondSeen.LastSeenUtc.ShouldBeGreaterThan(firstSeen.LastSeenUtc,
            "heartbeat advances LastSeenUtc so Admin can stale-flag rows from crashed Servers");

        // Still exactly one row — a naive Add-every-tick would have thrown or duplicated.
        using var scope = _sp.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>();
        (await db.DriverHostStatuses.CountAsync(r => r.NodeId == "node-x")).ShouldBe(1);
    }

    [Fact]
    public async Task State_change_between_ticks_updates_State_and_StateChangedUtc()
    {
        var driver = new ProbeStubDriver("driver-y",
            new HostConnectivityStatus("HostY", HostState.Running, DateTime.UtcNow.AddSeconds(-10)));
        var driverHost = new DriverHost();
        await driverHost.RegisterAsync(driver, "{}", CancellationToken.None);

        var publisher = new HostStatusPublisher(driverHost, NewNodeOptions("node-y"),
            _sp.GetRequiredService<IServiceScopeFactory>(),
            NullLogger<HostStatusPublisher>.Instance);

        await publisher.PublishOnceAsync(CancellationToken.None);
        var before = await SingleRowAsync("node-y", "driver-y", "HostY");

        // Swap the driver's reported state to Faulted with a newer transition timestamp.
        var newChange = DateTime.UtcNow;
        driver.Statuses = [new HostConnectivityStatus("HostY", HostState.Faulted, newChange)];
        await publisher.PublishOnceAsync(CancellationToken.None);

        var after = await SingleRowAsync("node-y", "driver-y", "HostY");
        after.State.ShouldBe(DriverHostState.Faulted);
        // datetime2(3) has millisecond precision — DateTime.UtcNow carries up to 100ns ticks,
        // so the stored value rounds down. Compare at millisecond granularity to stay clean.
        after.StateChangedUtc.ShouldBe(newChange, tolerance: TimeSpan.FromMilliseconds(1));
        after.StateChangedUtc.ShouldBeGreaterThan(before.StateChangedUtc,
            "StateChangedUtc must advance when the state actually changed");
        before.State.ShouldBe(DriverHostState.Running);
    }

    [Fact]
    public void MapState_translates_every_HostState_member()
    {
        HostStatusPublisher.MapState(HostState.Running).ShouldBe(DriverHostState.Running);
        HostStatusPublisher.MapState(HostState.Stopped).ShouldBe(DriverHostState.Stopped);
        HostStatusPublisher.MapState(HostState.Faulted).ShouldBe(DriverHostState.Faulted);
        HostStatusPublisher.MapState(HostState.Unknown).ShouldBe(DriverHostState.Unknown);
    }

    private async Task<Configuration.Entities.DriverHostStatus> SingleRowAsync(string node, string driver, string host)
    {
        using var scope = _sp.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>();
        return await db.DriverHostStatuses.AsNoTracking()
            .SingleAsync(r => r.NodeId == node && r.DriverInstanceId == driver && r.HostName == host);
    }

    private static NodeOptions NewNodeOptions(string nodeId) => new()
    {
        NodeId = nodeId,
        ClusterId = "cluster-t",
        ConfigDbConnectionString = "unused-publisher-gets-db-from-scope",
    };

    private sealed class ProbeStubDriver(string id, params HostConnectivityStatus[] initial)
        : IDriver, IHostConnectivityProbe
    {
        public HostConnectivityStatus[] Statuses { get; set; } = initial;
        public string DriverInstanceId => id;
        public string DriverType => "ProbeStub";

        public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;

        public Task InitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
        public Task ReinitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
        public Task ShutdownAsync(CancellationToken ct) => Task.CompletedTask;
        public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
        public long GetMemoryFootprint() => 0;
        public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;

        public IReadOnlyList<HostConnectivityStatus> GetHostStatuses() => Statuses;

        // Keeps the compiler happy — event is part of the interface contract even if unused here.
        internal void Raise(HostStatusChangedEventArgs e) => OnHostStatusChanged?.Invoke(this, e);
    }

    private sealed class NonProbeStubDriver(string id) : IDriver
    {
        public string DriverInstanceId => id;
        public string DriverType => "NonProbeStub";

        public Task InitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
        public Task ReinitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
        public Task ShutdownAsync(CancellationToken ct) => Task.CompletedTask;
        public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
        public long GetMemoryFootprint() => 0;
        public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
    }
}
