using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Auth.Abstractions.Roles;
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
using ZB.MOM.WW.OtOpcUa.Security.Ldap;

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

/// <summary>
/// Production <see cref="IOpcUaUserAuthenticator"/> adapter that bridges OPC UA UserName
/// tokens to the same <see cref="ILdapAuthService"/> the Admin UI cookie/JWT flows use, so a
/// single LDAP source-of-truth governs both control-plane (Admin) and data-plane (OPC UA)
/// session identities. Roles are resolved through the shared
/// <see cref="IGroupRoleMapper{TRole}"/> seam from the LDAP groups returned by the directory —
/// the same seam the login endpoint uses — and the resolved set is attached to the OPC UA
/// session identity for the downstream data-plane ACL evaluator.
/// </summary>
/// <remarks>
/// <para>
/// This authenticator is registered as a singleton, but <see cref="IGroupRoleMapper{TRole}"/>
/// (and its DbContext-backed mapping service) is scoped. A per-call DI scope is opened to
/// resolve the mapper so the singleton never captures a scoped dependency.
/// </para>
/// <para>
/// <b>Resilience (arch-review 03/S2).</b> The OPC UA SDK raises the impersonation callback that
/// reaches here <em>synchronously</em>, inside a <c>SessionManager</c>-wide event lock (verified
/// against <c>OPCFoundation.NetStandard.Opc.Ua.Server</c> 1.5.378 —
/// <c>SessionManager.ActivateSessionAsync</c> holds <c>m_eventLock</c> while invoking the
/// <c>void</c> impersonation delegate). A slow / hung LDAP bind inside that callback therefore does
/// not merely stall one SDK thread — it serializes <em>every</em> session activation server-wide
/// (Anonymous and X509 included). The callback contract is irreducibly synchronous (there is no
/// async impersonation callback), so the fix lives entirely at this boundary: a hard whole-flow
/// wall-clock bound (<see cref="LdapOptions.AuthTimeoutMs"/>) and a bounded in-flight concurrency cap
/// (<see cref="LdapOptions.MaxConcurrentAuthentications"/>), both fail-closed. All new paths deny.
/// </para>
/// </remarks>
public sealed class LdapOpcUaUserAuthenticator : IOpcUaUserAuthenticator
{
    private readonly ILdapAuthService _ldap;
    private readonly IServiceScopeFactory _scopeFactory;
    private readonly ILogger<LdapOpcUaUserAuthenticator> _logger;
    private readonly LdapOptions _options;

    /// <summary>Bounds in-flight authentication flows. A boundary timeout abandons its core task, which
    /// keeps holding its slot until it actually completes — so an outage cannot accumulate unbounded
    /// orphan binds.</summary>
    private readonly SemaphoreSlim _gate;

    /// <summary>Guards the outage-circuit state (<see cref="_consecutiveSystemFailures"/> +
    /// <see cref="_circuitOpenUntilTicks"/>).</summary>
    private readonly object _circuitLock = new();

    /// <summary>Count of CONSECUTIVE system-side failures; reset by any success / user-side deny.</summary>
    private int _consecutiveSystemFailures;

    /// <summary><see cref="Environment.TickCount64"/> the circuit stays open until; <c>0</c> = closed.</summary>
    private long _circuitOpenUntilTicks;

    /// <summary>Constructs the authenticator from its dependencies and the bound LDAP options.</summary>
    /// <param name="ldap">The app LDAP auth service (shared with the Admin UI login flow).</param>
    /// <param name="scopeFactory">Opens a per-call DI scope to resolve the scoped role mapper.</param>
    /// <param name="options">The bound <see cref="LdapOptions"/> (timeout + concurrency knobs).</param>
    /// <param name="logger">The logger.</param>
    public LdapOpcUaUserAuthenticator(
        ILdapAuthService ldap,
        IServiceScopeFactory scopeFactory,
        IOptions<LdapOptions> options,
        ILogger<LdapOpcUaUserAuthenticator> logger)
    {
        _ldap = ldap;
        _scopeFactory = scopeFactory;
        _options = options.Value;
        _logger = logger;
        _gate = new SemaphoreSlim(Math.Max(1, _options.MaxConcurrentAuthentications));
    }

    /// <inheritdoc />
    public async Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(string username, string password, CancellationToken ct)
    {
        var authTimeout = TimeSpan.FromMilliseconds(Math.Max(1, _options.AuthTimeoutMs));

        // Directory-outage circuit: after a run of consecutive system-side failures, fail fast WITHOUT
        // touching LDAP for the cooldown window — so a sustained outage cannot serialize every session
        // activation behind a per-call stall (the SDK raises this callback under a server-wide lock).
        if (IsCircuitOpen())
        {
            _logger.LogDebug(
                "LDAP outage circuit open — fast-denying OPC UA user {User} without a directory call", username);
            return OpcUaUserAuthResult.Deny("Authentication backend unavailable");
        }

        // Bounded in-flight concurrency: acquire a slot within our own wall-clock budget. All slots
        // busy is LOCAL backpressure, not directory evidence — deny "busy" (fail-closed) and do NOT
        // feed the outage circuit.
        if (!await _gate.WaitAsync(authTimeout, ct).ConfigureAwait(false))
        {
            _logger.LogWarning(
                "LDAP authentication backend busy (concurrency cap {Cap}) — denying OPC UA user {User}",
                _options.MaxConcurrentAuthentications, username);
            return OpcUaUserAuthResult.Deny("Authentication backend busy");
        }

        // Release the slot when the CORE task completes — deliberately NOT in a finally here. A boundary
        // timeout below abandons the core task (it runs to completion in the background; the underlying
        // library is documented never-throw, so there are no unobserved exceptions), and it must keep
        // counting against the cap until it finishes. The point of the boundary is to release the SDK
        // thread (and m_eventLock), not to cancel the bind.
        var core = AuthenticateCoreAsync(username, password, ct);
        _ = core.ContinueWith(
            static (_, state) => ((SemaphoreSlim)state!).Release(),
            _gate,
            CancellationToken.None,
            TaskContinuationOptions.None,
            TaskScheduler.Default);

        try
        {
            var outcome = await core.WaitAsync(authTimeout, ct).ConfigureAwait(false);
            RecordOutcome(outcome.SystemFailure, username);
            return outcome.Result;
        }
        catch (TimeoutException)
        {
            // A boundary timeout is directory evidence — it feeds the circuit.
            RecordOutcome(systemFailure: true, username);
            _logger.LogWarning(
                "LDAP authentication timed out after {Timeout} for OPC UA user {User} — denying (fail-closed)",
                authTimeout, username);
            return OpcUaUserAuthResult.Deny("Authentication timed out");
        }
    }

    /// <summary>Whether the outage circuit is currently open (fast-deny window active). Always false when
    /// the circuit is disabled (<see cref="LdapOptions.OutageFailureThreshold"/> ≤ 0).</summary>
    private bool IsCircuitOpen()
    {
        if (_options.OutageFailureThreshold <= 0) return false;
        lock (_circuitLock)
        {
            return _circuitOpenUntilTicks > Environment.TickCount64;
        }
    }

    /// <summary>
    /// Updates the outage-circuit state from one authentication outcome. A system-side failure advances
    /// the consecutive-failure streak and opens the circuit once it reaches
    /// <see cref="LdapOptions.OutageFailureThreshold"/>; any non-system outcome (success or user-side
    /// deny) resets the streak and closes the circuit. Half-open behaviour is implicit: once the cooldown
    /// elapses <see cref="IsCircuitOpen"/> lets the next call through with the streak still at threshold,
    /// so a single further system failure re-opens immediately.
    /// </summary>
    /// <param name="systemFailure">Whether this outcome was a system-side (directory) failure.</param>
    /// <param name="username">The login name, for diagnostics.</param>
    private void RecordOutcome(bool systemFailure, string username)
    {
        if (_options.OutageFailureThreshold <= 0) return; // circuit disabled

        lock (_circuitLock)
        {
            if (systemFailure)
            {
                _consecutiveSystemFailures++;
                if (_consecutiveSystemFailures >= _options.OutageFailureThreshold)
                {
                    var wasOpen = _circuitOpenUntilTicks > Environment.TickCount64;
                    _circuitOpenUntilTicks =
                        Environment.TickCount64 + (long)Math.Max(1, _options.OutageCooldownSeconds) * 1000L;
                    if (!wasOpen)
                    {
                        _logger.LogWarning(
                            "LDAP outage circuit OPENED after {Failures} consecutive system-side failures — " +
                            "fast-denying for {Cooldown}s (last user {User})",
                            _consecutiveSystemFailures, _options.OutageCooldownSeconds, username);
                    }
                }
            }
            else
            {
                if (_consecutiveSystemFailures >= _options.OutageFailureThreshold)
                {
                    _logger.LogInformation(
                        "LDAP outage circuit CLOSED — directory recovered (probe succeeded for {User})", username);
                }
                _consecutiveSystemFailures = 0;
                _circuitOpenUntilTicks = 0;
            }
        }
    }

    /// <summary>
    /// The unbounded core authenticate flow (bind + role-map) that the outer
    /// <see cref="AuthenticateUserNameAsync"/> wraps in a wall-clock bound + concurrency cap. Fail-closed:
    /// any backend throw becomes a denial rather than escaping into the SDK. The returned
    /// <see cref="CoreOutcome.SystemFailure"/> flags directory-side faults (a system-side
    /// <see cref="LdapAuthResult.IsSystemFailure"/> deny, or an unexpected throw) so the caller can feed
    /// the outage circuit; a user-credential deny is NOT a system failure.
    /// </summary>
    /// <param name="username">The username to authenticate.</param>
    /// <param name="password">The cleartext password.</param>
    /// <param name="ct">Cancellation token.</param>
    private async Task<CoreOutcome> AuthenticateCoreAsync(string username, string password, CancellationToken ct)
    {
        try
        {
            var result = await _ldap.AuthenticateAsync(username, password, ct).ConfigureAwait(false);
            if (!result.Success)
            {
                return new CoreOutcome(
                    OpcUaUserAuthResult.Deny(result.Error ?? "Invalid credentials"), result.IsSystemFailure);
            }

            var roles = await ResolveRolesAsync(result.Groups, result.Roles, username, ct).ConfigureAwait(false);
            return new CoreOutcome(OpcUaUserAuthResult.Allow(result.DisplayName ?? username, roles), SystemFailure: false);
        }
        catch (Exception ex) when (ex is not OperationCanceledException)
        {
            _logger.LogWarning(ex, "LDAP authentication threw for OPC UA user {User}", username);
            return new CoreOutcome(OpcUaUserAuthResult.Deny("Authentication backend error"), SystemFailure: true);
        }
    }

    /// <summary>The result of the core authenticate flow plus whether it was a system-side (directory)
    /// failure — the signal that drives the outage circuit.</summary>
    /// <param name="Result">The auth result to return to the SDK.</param>
    /// <param name="SystemFailure">Whether the outcome was a directory-side fault.</param>
    private readonly record struct CoreOutcome(OpcUaUserAuthResult Result, bool SystemFailure);

    /// <summary>
    /// Resolves the user's roles from their LDAP groups via the scoped
    /// <see cref="IGroupRoleMapper{TRole}"/>, unioned with any pre-resolved roles (the DevStub
    /// FleetAdmin grant). A mapper fault (e.g. a DB outage) must not deny an otherwise-authenticated
    /// session: it falls back to the pre-resolved roles, matching the login endpoint's behaviour.
    /// </summary>
    /// <param name="groups">The LDAP groups returned by the directory.</param>
    /// <param name="preResolved">Pre-resolved roles (empty on the real path; FleetAdmin under DevStub).</param>
    /// <param name="username">The login name, for diagnostics.</param>
    /// <param name="ct">Cancellation token.</param>
    private async Task<IReadOnlyList<string>> ResolveRolesAsync(
        IReadOnlyList<string> groups, IReadOnlyList<string> preResolved, string username, CancellationToken ct)
    {
        try
        {
            await using var scope = _scopeFactory.CreateAsyncScope();
            var mapper = scope.ServiceProvider.GetRequiredService<IGroupRoleMapper<string>>();
            var mapping = await mapper.MapAsync(groups, ct).ConfigureAwait(false);

            var roles = new HashSet<string>(preResolved, StringComparer.OrdinalIgnoreCase);
            foreach (var role in mapping.Roles)
                roles.Add(role);
            return [.. roles];
        }
        catch (Exception ex) when (ex is not OperationCanceledException)
        {
            _logger.LogWarning(ex,
                "Role-map lookup failed for OPC UA user {User}; using pre-resolved baseline roles", username);
            return preResolved;
        }
    }
}
