using System.Diagnostics;
using System.Text;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Opc.Ua;
using Opc.Ua.Server;
using Shouldly;
using Xunit;
using ZB.MOM.WW.Auth.Abstractions.Roles;
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
using LdapTransport = ZB.MOM.WW.Auth.Abstractions.Ldap.LdapTransport;

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

/// <summary>
/// Resilience guard for <see cref="LdapOpcUaUserAuthenticator"/> (arch-review 03/S2). The OPC UA SDK
/// raises the impersonation callback synchronously under a <c>SessionManager</c>-wide event lock, so a
/// slow / hung LDAP bind inside it does not merely stall one SDK thread — it serializes EVERY session
/// activation server-wide. These tests prove the authenticator enforces a hard wall-clock bound,
/// bounded in-flight concurrency, and a directory-outage circuit at its own boundary (the SDK callback
/// being irreducibly synchronous), all fail-closed. No real LDAP server is used — a delaying fake
/// reproduces the outage shape offline.
/// </summary>
public sealed class LdapAuthResilienceTests
{
    /// <summary>
    /// RED repro (03/S2): a backend that takes 5 s must NOT hold the caller for 5 s — the authenticator's
    /// wall-clock bound denies (fail-closed) well before then. On current code (no bound) this fails: the
    /// call returns Allow after the full 5 s.
    /// </summary>
    [Fact]
    public async Task Authenticate_slow_backend_denies_within_AuthTimeout()
    {
        var ldap = new DelayingFakeLdap(
            TimeSpan.FromSeconds(5),
            new LdapAuthResult(true, "Alice", "alice", new[] { "configeditor" }, Array.Empty<string>(), null));
        var mapper = new FakeMapper(g => g.ToArray());
        var sut = Build(ldap, mapper, new LdapOptions { AuthTimeoutMs = 200 });

        var sw = Stopwatch.StartNew();
        var result = await sut
            .AuthenticateUserNameAsync("alice", "secret", CancellationToken.None)
            .WaitAsync(TimeSpan.FromSeconds(10), CancellationToken.None);
        sw.Stop();

        result.Success.ShouldBeFalse();
        result.Error.ShouldNotBeNull();
        result.Error!.ShouldContain("timed out");
        sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(1.5));
    }

    /// <summary>A fast, healthy backend is unaffected by the resilience wrapping — success + roles pass
    /// through untouched (03/S2).</summary>
    [Fact]
    public async Task Authenticate_fast_success_unaffected()
    {
        var ldap = new DelayingFakeLdap(
            TimeSpan.Zero,
            new LdapAuthResult(true, "Alice", "alice", new[] { "configeditor" }, Array.Empty<string>(), null));
        var mapper = new FakeMapper(g => g.Select(x => x == "configeditor" ? "Designer" : x).ToArray());
        var sut = Build(ldap, mapper, new LdapOptions());

        var result = await sut
            .AuthenticateUserNameAsync("alice", "secret", CancellationToken.None)
            .WaitAsync(TimeSpan.FromSeconds(10), CancellationToken.None);

        result.Success.ShouldBeTrue();
        result.DisplayName.ShouldBe("Alice");
        result.Roles.ShouldBe(new[] { "Designer" });
    }

    /// <summary>With the concurrency cap saturated (a parked in-flight bind holding the only slot), a
    /// second call is denied "backend busy" within its own budget — it never waits behind the parked
    /// bind indefinitely (03/S2).</summary>
    [Fact]
    public async Task Authenticate_saturated_concurrency_denies_busy()
    {
        var ldap = new GatedFakeLdap(
            new LdapAuthResult(true, "Alice", "alice", new[] { "configeditor" }, Array.Empty<string>(), null));
        var mapper = new FakeMapper(g => g.ToArray());
        // MaxConcurrentAuthentications = 1 → the second call cannot get a slot; small AuthTimeoutMs so the
        // "busy" denial is quick and the first call's boundary also trips fast (its core stays parked,
        // holding the slot).
        var sut = Build(ldap, mapper, new LdapOptions { MaxConcurrentAuthentications = 1, AuthTimeoutMs = 300 });

        // First call: acquires the only slot; its core parks in the gated fake.
        var first = sut.AuthenticateUserNameAsync("alice", "secret", CancellationToken.None);

        // Wait until the fake has actually been entered (slot acquired, core parked).
        var spin = Stopwatch.StartNew();
        while (ldap.Entered == 0 && spin.Elapsed < TimeSpan.FromSeconds(5))
            await Task.Delay(10, TestContext.Current.CancellationToken);
        ldap.Entered.ShouldBe(1);

        // Second concurrent call: the slot is held by the parked core, so acquisition times out → busy.
        var second = await sut
            .AuthenticateUserNameAsync("bob", "secret", CancellationToken.None)
            .WaitAsync(TimeSpan.FromSeconds(10), CancellationToken.None);

        second.Success.ShouldBeFalse();
        second.Error.ShouldNotBeNull();
        second.Error!.ShouldContain("busy");
        ldap.Entered.ShouldBe(1, "the second call must be denied without ever reaching the backend");

        // Release the parked bind so the first call's core completes cleanly (no orphaned resources).
        ldap.Release();
        await first.WaitAsync(TimeSpan.FromSeconds(10), CancellationToken.None);
    }

    /// <summary>After <c>OutageFailureThreshold</c> consecutive system-side failures the circuit opens and
    /// the next call is fast-denied "backend unavailable" WITHOUT touching LDAP (03/S2).</summary>
    [Fact]
    public async Task Consecutive_system_failures_open_circuit_and_fast_deny()
    {
        var ldap = new DelayingFakeLdap(TimeSpan.Zero, SystemFailure());
        var mapper = new FakeMapper(g => g.ToArray());
        var sut = Build(ldap, mapper,
            new LdapOptions { OutageFailureThreshold = 3, OutageCooldownSeconds = 60, AuthTimeoutMs = 5000 });

        for (var i = 0; i < 3; i++)
        {
            var r = await sut.AuthenticateUserNameAsync("u", "p", CancellationToken.None)
                .WaitAsync(TimeSpan.FromSeconds(10), CancellationToken.None);
            r.Success.ShouldBeFalse();
        }

        ldap.Calls.ShouldBe(3);

        var sw = Stopwatch.StartNew();
        var blocked = await sut.AuthenticateUserNameAsync("u", "p", CancellationToken.None)
            .WaitAsync(TimeSpan.FromSeconds(10), CancellationToken.None);
        sw.Stop();

        blocked.Success.ShouldBeFalse();
        blocked.Error.ShouldNotBeNull();
        blocked.Error!.ShouldContain("unavailable");
        ldap.Calls.ShouldBe(3, "an open circuit must fast-deny without reaching the backend");
        sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(1));
    }

    /// <summary>A user-side (credential) deny between system failures resets the streak, so the circuit
    /// never opens — user denies are not directory evidence (03/S2).</summary>
    [Fact]
    public async Task User_side_deny_resets_the_system_failure_streak()
    {
        var ldap = new ScriptedFakeLdap(
            SystemFailure(), SystemFailure(), UserDeny(), SystemFailure(), SystemFailure());
        var mapper = new FakeMapper(g => g.ToArray());
        var sut = Build(ldap, mapper,
            new LdapOptions { OutageFailureThreshold = 3, OutageCooldownSeconds = 60, AuthTimeoutMs = 5000 });

        // 5 scripted calls: sys, sys, user(reset), sys, sys → streak ends at 2 (< 3), circuit stays closed.
        for (var i = 0; i < 5; i++)
        {
            await sut.AuthenticateUserNameAsync("u", "p", CancellationToken.None)
                .WaitAsync(TimeSpan.FromSeconds(10), CancellationToken.None);
        }

        // A 6th call must still REACH the backend (circuit never opened).
        await sut.AuthenticateUserNameAsync("u", "p", CancellationToken.None)
            .WaitAsync(TimeSpan.FromSeconds(10), CancellationToken.None);
        ldap.Calls.ShouldBe(6);
    }

    /// <summary>After the cooldown elapses the circuit half-opens: the next call probes the backend and a
    /// success closes the circuit (03/S2).</summary>
    [Fact]
    public async Task Half_open_probe_after_cooldown_closes_on_success()
    {
        var ldap = new ScriptedFakeLdap(
            SystemFailure(), SystemFailure(), SystemFailure(),
            new LdapAuthResult(true, "Alice", "alice", new[] { "configeditor" }, Array.Empty<string>(), null));
        var mapper = new FakeMapper(g => g.ToArray());
        var sut = Build(ldap, mapper,
            new LdapOptions { OutageFailureThreshold = 3, OutageCooldownSeconds = 1, AuthTimeoutMs = 5000 });

        for (var i = 0; i < 3; i++)
            await sut.AuthenticateUserNameAsync("u", "p", CancellationToken.None)
                .WaitAsync(TimeSpan.FromSeconds(10), CancellationToken.None);
        ldap.Calls.ShouldBe(3);

        // Circuit open: this call is fast-denied without touching the backend.
        var blocked = await sut.AuthenticateUserNameAsync("u", "p", CancellationToken.None)
            .WaitAsync(TimeSpan.FromSeconds(10), CancellationToken.None);
        blocked.Error!.ShouldContain("unavailable");
        ldap.Calls.ShouldBe(3);

        // Wait past the cooldown, then a probe reaches the backend and succeeds → circuit closes.
        await Task.Delay(TimeSpan.FromMilliseconds(1200), TestContext.Current.CancellationToken);

        var probe = await sut.AuthenticateUserNameAsync("alice", "secret", CancellationToken.None)
            .WaitAsync(TimeSpan.FromSeconds(10), CancellationToken.None);
        probe.Success.ShouldBeTrue();
        ldap.Calls.ShouldBe(4, "the half-open probe reaches the backend");
    }

    /// <summary><c>OutageFailureThreshold = 0</c> disables the circuit — it never opens, every call reaches
    /// the backend (03/S2).</summary>
    [Fact]
    public async Task Circuit_disabled_never_opens()
    {
        var ldap = new DelayingFakeLdap(TimeSpan.Zero, SystemFailure());
        var mapper = new FakeMapper(g => g.ToArray());
        var sut = Build(ldap, mapper,
            new LdapOptions { OutageFailureThreshold = 0, OutageCooldownSeconds = 60, AuthTimeoutMs = 5000 });

        for (var i = 0; i < 6; i++)
            await sut.AuthenticateUserNameAsync("u", "p", CancellationToken.None)
                .WaitAsync(TimeSpan.FromSeconds(10), CancellationToken.None);

        ldap.Calls.ShouldBe(6, "a disabled circuit never fast-denies");
    }

    /// <summary>
    /// Outage-sim (offline-safe): the REAL <see cref="OtOpcUaLdapAuthService"/> + real shared library
    /// pointed at an UNROUTABLE host (a TCP blackhole — the same outage shape as "stop the GLAuth
    /// container"), driven through the REAL internal <see cref="OpcUaApplicationHost.HandleImpersonation"/>
    /// block-bridge. The activation must fail bounded (well under the SDK's default ~10-20 s stall) with
    /// <see cref="StatusCodes.BadIdentityTokenRejected"/> and no granted identity (03/S2).
    /// </summary>
    [Fact]
    public async Task Activation_with_unreachable_directory_fails_within_bound()
    {
        var options = new LdapOptions
        {
            Enabled = true,
            DevStubMode = false,
            Server = "10.255.255.1", // RFC-blackhole: SYN goes nowhere
            Port = 3893,
            Transport = LdapTransport.None,
            AllowInsecure = true,
            SearchBase = "dc=zb,dc=local",
            ConnectionTimeoutMs = 500,
            AuthTimeoutMs = 1000,
        };

        var ldap = new OtOpcUaLdapAuthService(Options.Create(options), NullLogger<OtOpcUaLdapAuthService>.Instance);
        var mapper = new FakeMapper(g => g.ToArray());
        var authenticator = new LdapOpcUaUserAuthenticator(
            ldap, ScopeFactoryWith(mapper), Options.Create(options),
            NullLogger<LdapOpcUaUserAuthenticator>.Instance);

        var token = new UserNameIdentityToken { UserName = "alice", DecryptedPassword = Encoding.UTF8.GetBytes("secret") };
        var policy = new UserTokenPolicy(UserTokenType.UserName) { PolicyId = "username_basic256sha256" };
        var args = new ImpersonateEventArgs(token, policy, new EndpointDescription());

        var sw = Stopwatch.StartNew();
        await Task.Run(() => OpcUaApplicationHost.HandleImpersonation(
                authenticator, args, NullLogger<object>.Instance), TestContext.Current.CancellationToken)
            .WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
        sw.Stop();

        args.Identity.ShouldBeNull();
        args.IdentityValidationError.ShouldNotBeNull();
        args.IdentityValidationError.Code.ShouldBe(Opc.Ua.StatusCodes.BadIdentityTokenRejected);
        sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(3));
    }

    private static LdapAuthResult SystemFailure() =>
        new(false, null, "u", Array.Empty<string>(), Array.Empty<string>(), "Unexpected authentication error")
        { IsSystemFailure = true };

    private static LdapAuthResult UserDeny() =>
        new(false, null, "u", Array.Empty<string>(), Array.Empty<string>(), "Invalid username or password")
        { IsSystemFailure = false };

    private static LdapOpcUaUserAuthenticator Build(
        ILdapAuthService ldap, IGroupRoleMapper<string> mapper, LdapOptions options) =>
        new(ldap, ScopeFactoryWith(mapper), Options.Create(options),
            NullLogger<LdapOpcUaUserAuthenticator>.Instance);

    /// <summary>Builds an IServiceScopeFactory whose scopes resolve the supplied mapper.</summary>
    private static IServiceScopeFactory ScopeFactoryWith(IGroupRoleMapper<string> mapper)
    {
        var services = new ServiceCollection();
        services.AddScoped(_ => mapper);
        return services.BuildServiceProvider().GetRequiredService<IServiceScopeFactory>();
    }

    /// <summary>
    /// A fake <see cref="ILdapAuthService"/> that awaits a configurable delay (observing the token) before
    /// returning a fixed result — the offline stand-in for a slow / hung directory. Counts invocations so
    /// tests can assert the outage circuit denies WITHOUT reaching the backend.
    /// </summary>
    private sealed class DelayingFakeLdap : ILdapAuthService
    {
        private readonly TimeSpan _delay;
        private readonly LdapAuthResult _then;
        private int _calls;

        /// <summary>Initializes the fake with a per-call delay and the result to return afterwards.</summary>
        /// <param name="delay">How long each authenticate call blocks before returning.</param>
        /// <param name="then">The result returned once the delay elapses.</param>
        public DelayingFakeLdap(TimeSpan delay, LdapAuthResult then)
        {
            _delay = delay;
            _then = then;
        }

        /// <summary>The number of times the backend was actually invoked.</summary>
        public int Calls => Volatile.Read(ref _calls);

        /// <summary>Awaits the configured delay, then returns the canned result.</summary>
        /// <param name="username">The username (ignored).</param>
        /// <param name="password">The password (ignored).</param>
        /// <param name="ct">Cancellation token, observed by the delay.</param>
        public async Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default)
        {
            Interlocked.Increment(ref _calls);
            await Task.Delay(_delay, ct).ConfigureAwait(false);
            return _then;
        }
    }

    /// <summary>
    /// A fake <see cref="ILdapAuthService"/> that parks ASYNCHRONOUSLY (awaits a
    /// <see cref="TaskCompletionSource"/>) until the test calls <see cref="Release"/> — used to hold an
    /// in-flight bind so the concurrency cap is deterministically saturated WITHOUT blocking a thread.
    /// Records how many calls have entered (past the slot acquire).
    /// </summary>
    private sealed class GatedFakeLdap : ILdapAuthService
    {
        private readonly TaskCompletionSource _park = new(TaskCreationOptions.RunContinuationsAsynchronously);
        private readonly LdapAuthResult _then;
        private int _entered;

        /// <summary>Initializes the fake with the result to return once released.</summary>
        /// <param name="then">The result returned once <see cref="Release"/> is called.</param>
        public GatedFakeLdap(LdapAuthResult then) => _then = then;

        /// <summary>Number of authenticate calls that reached the backend body.</summary>
        public int Entered => Volatile.Read(ref _entered);

        /// <summary>Releases the parked bind so its awaiting call can complete.</summary>
        public void Release() => _park.TrySetResult();

        /// <summary>Marks entry, awaits the parking gate, then returns the canned result.</summary>
        /// <param name="username">The username (ignored).</param>
        /// <param name="password">The password (ignored).</param>
        /// <param name="ct">Cancellation token, observed while parked.</param>
        public async Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default)
        {
            Interlocked.Increment(ref _entered);
            await _park.Task.WaitAsync(ct).ConfigureAwait(false);
            return _then;
        }
    }

    /// <summary>
    /// A fake <see cref="ILdapAuthService"/> that returns a scripted sequence of results (one per call;
    /// the last entry repeats once exhausted). Counts calls so the outage-circuit tests can assert the
    /// backend was — or was NOT — reached on a given call.
    /// </summary>
    private sealed class ScriptedFakeLdap : ILdapAuthService
    {
        private readonly IReadOnlyList<LdapAuthResult> _script;
        private int _calls;

        /// <summary>Initializes the fake with the scripted results, returned in order.</summary>
        /// <param name="script">The results to return, one per call; the last repeats once exhausted.</param>
        public ScriptedFakeLdap(params LdapAuthResult[] script) => _script = script;

        /// <summary>The number of times the backend was invoked.</summary>
        public int Calls => Volatile.Read(ref _calls);

        /// <summary>Returns the next scripted result (clamped to the last entry).</summary>
        /// <param name="username">The username (ignored).</param>
        /// <param name="password">The password (ignored).</param>
        /// <param name="ct">The cancellation token (ignored).</param>
        public Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default)
        {
            var idx = Interlocked.Increment(ref _calls) - 1;
            return Task.FromResult(_script[Math.Min(idx, _script.Count - 1)]);
        }
    }

    /// <summary>Test group→role mapper driven by a delegate over the supplied groups.</summary>
    private sealed class FakeMapper(Func<IReadOnlyList<string>, IReadOnlyList<string>> map) : IGroupRoleMapper<string>
    {
        /// <summary>Maps groups to roles via the configured delegate; Scope is always null.</summary>
        /// <param name="groups">The LDAP groups to map.</param>
        /// <param name="ct">The cancellation token.</param>
        public Task<GroupRoleMapping<string>> MapAsync(IReadOnlyList<string> groups, CancellationToken ct)
            => Task.FromResult(new GroupRoleMapping<string>(map(groups), Scope: null));
    }
}
