using Microsoft.AspNetCore.Builder;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.Auth.Abstractions.Roles;
using ZB.MOM.WW.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Services;
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
using ZB.MOM.WW.OtOpcUa.Security.Ldap;

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

/// <summary>
///     Per-cluster mesh Phase 4 — ConfigDb is registered iff the node has the <c>admin</c> role.
///     A driver-only node (Akka role <c>driver</c>, NOT <c>admin</c>) boots with NO <c>ConfigDb</c>
///     connection string at all; its configuration arrives from central via the Phase-3
///     <c>ConfigSource:Mode=FetchAndCache</c> path.
/// </summary>
/// <remarks>
///     <para>
///         These tests replicate the exact ConfigDb gating decision Program.cs makes —
///         <c>if (hasAdmin) services.AddOtOpcUaConfigDb(configuration)</c> — over the real
///         <see cref="ServiceCollectionExtensions.AddOtOpcUaConfigDb"/> method, for both role shapes.
///         They deliberately do NOT boot the full host: Program.cs reads roles from the
///         <c>OTOPCUA_ROLES</c> process env var and starting the host triggers the Akka cluster join
///         (see <c>TwoNodeClusterHarness</c> on why <c>WebApplicationFactory&lt;Program&gt;</c> is not
///         driven here, and <c>LocalDbWiringTests</c> for the same partial-graph harness model). The
///         full driver-only boot is proven by the Phase-4 live gate + the later DI-graph sweep task.
///     </para>
/// </remarks>
public sealed class DriverOnlyNoConfigDbBootTests : IDisposable
{
    private readonly List<WebApplication> _apps = [];

    /// <summary>
    ///     Builds the shared-services graph the way Program.cs gates ConfigDb: it is registered iff
    ///     <paramref name="hasAdmin"/>. A driver-only node passes <c>hasAdmin: false</c> and supplies
    ///     no <c>ConnectionStrings:ConfigDb</c> at all.
    /// </summary>
    private IServiceProvider BuildSharedServicesGraph(bool hasAdmin, string? configDbConnectionString)
    {
        var builder = WebApplication.CreateBuilder(new WebApplicationOptions { Args = [] });
        if (configDbConnectionString is not null)
        {
            builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
            {
                ["ConnectionStrings:ConfigDb"] = configDbConnectionString,
            });
        }

        if (hasAdmin)
            builder.Services.AddOtOpcUaConfigDb(builder.Configuration);

        var app = builder.Build();
        _apps.Add(app);
        return app.Services;
    }

    [Fact]
    public void DriverOnlyNode_NoConnectionString_BuildsWithoutThrowing_AndRegistersNoConfigDbFactory()
    {
        // The core Phase-4 guarantee: a driver-only node holds no ConfigDb connection string and must
        // still build its service graph without throwing — nothing in the shared registrations may
        // reach for the ConfigDb factory.
        IServiceProvider sp = null!;
        Should.NotThrow(() => sp = BuildSharedServicesGraph(hasAdmin: false, configDbConnectionString: null));

        sp.GetService<IDbContextFactory<OtOpcUaConfigDbContext>>().ShouldBeNull();
    }

    [Fact]
    public void AdminNode_WithConnectionString_RegistersTheConfigDbFactory()
    {
        // The fused/admin path is byte-for-byte unaffected: ConfigDb is registered exactly as before.
        var sp = BuildSharedServicesGraph(
            hasAdmin: true,
            configDbConnectionString: "Server=(local);Database=OtOpcUa;Trusted_Connection=True;TrustServerCertificate=True");

        sp.GetService<IDbContextFactory<OtOpcUaConfigDbContext>>().ShouldNotBeNull();
    }

    [Fact]
    public void AddOtOpcUaConfigDb_WithoutConnectionString_Throws()
    {
        // Why the gate exists: the unconditional call Program.cs made throws on a driver-only node,
        // which has no ConfigDb connection string. This pins the exact failure the hasAdmin gate avoids.
        var builder = WebApplication.CreateBuilder(new WebApplicationOptions { Args = [] });

        Should.Throw<InvalidOperationException>(() => builder.Services.AddOtOpcUaConfigDb(builder.Configuration));
    }

    /// <summary>
    ///     Replicates Program.cs's role-mapping DI ordering, top to bottom:
    ///     <list type="number">
    ///         <item><description>
    ///         the <c>hasAdmin</c> block — <c>AddOtOpcUaConfigDb</c>, which plain-<c>AddScoped</c>s
    ///         the real <see cref="LdapGroupRoleMappingService"/> (line ~132 in Program.cs);
    ///         </description></item>
    ///         <item><description>
    ///         <c>AddValidatedOptions&lt;LdapOptions, LdapOptionsValidator&gt;</c>, registered
    ///         unconditionally for every role shape (line ~143);
    ///         </description></item>
    ///         <item><description>
    ///         the <c>hasDriver</c> block's three <c>TryAdd</c> registrations (lines ~333-335):
    ///         <see cref="NullLdapGroupRoleMappingService"/>, <see cref="OtOpcUaGroupRoleMapper"/>.
    ///         </description></item>
    ///     </list>
    ///     On a FUSED node this ordering is exactly what lets the real EF-backed service win the
    ///     <c>TryAdd</c> — a plain <c>AddScoped</c> that ran first already claimed the descriptor slot,
    ///     so the later <c>TryAddScoped</c> is a no-op. Nothing enforces that order beyond Program.cs's
    ///     own top-to-bottom script. Verified by locally inverting the ordering to (3)-then-(1) AND
    ///     switching <c>AddOtOpcUaConfigDb</c>'s <c>ILdapGroupRoleMappingService</c> registration to a
    ///     <c>TryAddScoped</c> (both reverted before commit) — that combination is what flips the
    ///     resolved type to <see cref="NullLdapGroupRoleMappingService"/> and turns this test red;
    ///     either change alone stays green today (an unconditional <c>AddScoped</c> always wins
    ///     last-registered regardless of order, and a <c>TryAdd</c> that runs first still wins). Security:
    ///     Ldap:Enabled is forced false here purely so <see cref="LdapOptionsValidator"/> doesn't demand
    ///     a live Server/SearchBase/Transport — it is orthogonal to what is under test.
    /// </summary>
    private IServiceProvider BuildRoleMappingGraph(bool hasAdmin, string? configDbConnectionString)
    {
        var builder = WebApplication.CreateBuilder(new WebApplicationOptions { Args = [] });
        var config = new Dictionary<string, string?> { ["Security:Ldap:Enabled"] = "false" };
        if (configDbConnectionString is not null)
            config["ConnectionStrings:ConfigDb"] = configDbConnectionString;
        builder.Configuration.AddInMemoryCollection(config);

        // (1) hasAdmin block.
        if (hasAdmin)
            builder.Services.AddOtOpcUaConfigDb(builder.Configuration);

        // (2) unconditional for every role shape.
        builder.Services.AddValidatedOptions<LdapOptions, LdapOptionsValidator>(
            builder.Configuration, LdapOptions.SectionName);

        // (3) hasDriver block's TryAdd registrations — runs AFTER (1), never before it.
        builder.Services.TryAddScoped<ILdapGroupRoleMappingService, NullLdapGroupRoleMappingService>();
        builder.Services.TryAddScoped<IGroupRoleMapper<string>, OtOpcUaGroupRoleMapper>();

        var app = builder.Build();
        _apps.Add(app);
        return app.Services;
    }

    [Fact]
    public void FusedNode_RealLdapGroupRoleMappingService_WinsOverTheDriverBlockTryAdd()
    {
        // The Task 1b review gap: on a fused admin+driver node, AddOtOpcUaConfigDb's plain AddScoped
        // registers the real EF-backed service BEFORE the hasDriver block's TryAddScoped runs, so the
        // TryAdd is a no-op and the real service wins. If AddOtOpcUaConfigDb's registration were ever
        // switched to a TryAdd, or the two blocks reordered, this flips to NullLdapGroupRoleMappingService
        // and every DB-backed role grant central relies on silently stops applying — with no other test
        // catching it.
        var sp = BuildRoleMappingGraph(
            hasAdmin: true,
            configDbConnectionString: "Server=(local);Database=OtOpcUa;Trusted_Connection=True;TrustServerCertificate=True");

        using var scope = sp.CreateScope();
        scope.ServiceProvider.GetRequiredService<ILdapGroupRoleMappingService>()
            .ShouldBeOfType<LdapGroupRoleMappingService>();
    }

    [Fact]
    public void DriverOnlyNode_LdapGroupRoleMapping_ResolvesTheNullImpl_AndNoConfigDbFactory()
    {
        // The other half of the same pin: with no hasAdmin block ever run, the driver block's TryAdd
        // is the ONLY registration, so it wins by default — no ConfigDb, no real EF service reachable.
        var sp = BuildRoleMappingGraph(hasAdmin: false, configDbConnectionString: null);

        sp.GetService<IDbContextFactory<OtOpcUaConfigDbContext>>().ShouldBeNull();

        using var scope = sp.CreateScope();
        scope.ServiceProvider.GetRequiredService<ILdapGroupRoleMappingService>()
            .ShouldBeOfType<NullLdapGroupRoleMappingService>();
    }

    [Fact]
    public async Task DriverOnlyNode_IGroupRoleMapper_ResolvesInScope_AndMapsWithoutThrowing()
    {
        // Task 1b fixed a lazy auth-path break: IGroupRoleMapper<string> depends on
        // ILdapGroupRoleMappingService, which a driver-only node never registered before that fix —
        // the DI graph built fine (nothing eager touches it), and the break surfaced only on the first
        // SCOPED resolve + call, deep in the OPC UA data-plane authenticator. A bare Build() cannot
        // catch that class of bug; this test resolves in a scope and calls MapAsync, mirroring the
        // real call site.
        var sp = BuildRoleMappingGraph(hasAdmin: false, configDbConnectionString: null);

        using var scope = sp.CreateScope();
        var mapper = scope.ServiceProvider.GetRequiredService<IGroupRoleMapper<string>>();
        mapper.ShouldBeOfType<OtOpcUaGroupRoleMapper>();
        scope.ServiceProvider.GetRequiredService<ILdapGroupRoleMappingService>()
            .ShouldBeOfType<NullLdapGroupRoleMappingService>();

        GroupRoleMapping<string> result = null!;
        await Should.NotThrowAsync(async () => result = await mapper.MapAsync(["some-group"], CancellationToken.None));

        result.ShouldNotBeNull();
        result.Scope.ShouldBeNull();
    }

    public void Dispose()
    {
        foreach (var app in _apps)
            ((IDisposable)app).Dispose();
    }
}
