using System.Diagnostics.CodeAnalysis;
using System.Security.Claims;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Auth.Abstractions.Ldap;
using ZB.MOM.WW.Auth.Ldap;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Dashboard;
using ZB.MOM.WW.MxGateway.Server.Sessions;
using LibraryLdapOptions = ZB.MOM.WW.Auth.Abstractions.Ldap.LdapOptions;

namespace ZB.MOM.WW.MxGateway.IntegrationTests;

[Collection(LiveResourcesCollection.Name)]
[Trait("Category", "LiveLdap")]
public sealed class DashboardLdapLiveTests
{
    /// <summary>
    /// The shared dev/test directory issues every human tester the same well-known password, so
    /// the fixtures name it once rather than repeating a literal that drifts per test. This is a
    /// published dev credential (see <c>glauth.md</c> and <c>scadaproj/infra/glauth/config.toml</c>),
    /// not a secret — unlike the service-account bind password, which is never in source and must
    /// arrive via <c>MxGateway__Ldap__ServiceAccountPassword</c>.
    /// </summary>
    private const string SharedDirectoryPassword = "password";

    /// <summary>
    /// Dashboard visibility tags (SEC-25) used by the ACL scenarios below. They are operator-chosen
    /// labels that exist only in this fixture's configuration and in the fake sessions' owner-tag
    /// list — nothing in the shared directory carries them.
    /// </summary>
    private const string TeamATag = "team-a";
    private const string TeamBTag = "team-b";

    private const string TeamASessionId = "session-team-a";
    private const string TeamBSessionId = "session-team-b";
    private const string UntaggedSessionId = "session-untagged";

    /// <summary>
    /// Verifies that <c>admin</c> — a shared-directory user whose <c>othergroups</c> include
    /// GwAdmin (gid 5610) — authenticates successfully and is granted the Admin dashboard role.
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [LiveLdapFact]
    public async Task AuthenticateAsync_AdminInGwAdminGroup_Succeeds()
    {
        DashboardAuthenticator authenticator = CreateAuthenticator();

        DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
            "admin",
            SharedDirectoryPassword,
            CancellationToken.None);

        Assert.True(result.Succeeded);
        Assert.NotNull(result.Principal);
        Assert.Equal("admin", result.Principal.FindFirst(ClaimTypes.NameIdentifier)?.Value);
        Assert.Contains(result.Principal.Claims, claim =>
            claim.Type == DashboardAuthenticationDefaults.LdapGroupClaimType
            && claim.Value.Contains("GwAdmin", StringComparison.OrdinalIgnoreCase));

        Assert.Contains(result.Principal.Claims, claim =>
            claim.Type == ClaimTypes.Role
            && claim.Value == DashboardRoles.Admin);
    }

    /// <summary>
    /// Verifies that <c>gw-viewer</c> — a shared-directory user whose only group is GwReader
    /// (gid 5611), which this suite's GroupToRole map deliberately leaves unmapped — is denied
    /// even though its bind succeeds, and that the denial is indistinguishable from the
    /// unknown-user denial.
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [LiveLdapFact]
    public async Task AuthenticateAsync_ViewerMissingGwAdminGroup_FailsIndistinguishably()
    {
        DashboardAuthenticator authenticator = CreateAuthenticator();

        DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
            "gw-viewer",
            SharedDirectoryPassword,
            CancellationToken.None);

        Assert.False(result.Succeeded);
        Assert.Null(result.Principal);

        // This test used to assert the failure message did not echo the credential literal.
        // That check cannot survive the move to the shared directory: the real password is the
        // word "password", which legitimately occurs in the generic denial text ("The username
        // or password is invalid, ..."), so the assertion would fail for the wrong reason. The
        // no-leak property is still covered — with a distinctive literal — by
        // AuthenticateAsync_AdminWithWrongPassword_FailsWithoutLeakingPassword below. What is
        // asserted here instead is the property this fixture is actually uniquely able to prove:
        // an authorization failure (valid credentials, no mapped role) must be reported with the
        // same message as an authentication failure, so the response cannot be used to enumerate
        // valid accounts.
        DashboardAuthenticationResult unknownUserResult = await authenticator.AuthenticateAsync(
            "no-such-user-9f3c1",
            "irrelevant-password",
            CancellationToken.None);

        Assert.False(string.IsNullOrWhiteSpace(result.FailureMessage));
        Assert.Equal(unknownUserResult.FailureMessage, result.FailureMessage);
    }

    /// <summary>Verifies that authentication with wrong password fails without leaking the password.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [LiveLdapFact]
    public async Task AuthenticateAsync_AdminWithWrongPassword_FailsWithoutLeakingPassword()
    {
        // Exercises the user-bind-failure branch: the user exists and the service
        // account search succeeds, but the candidate bind is rejected.
        const string wrongPassword = "definitely-not-the-admin-password";
        DashboardAuthenticator authenticator = CreateAuthenticator();

        DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
            "admin",
            wrongPassword,
            CancellationToken.None);

        Assert.False(result.Succeeded);
        Assert.Null(result.Principal);
        Assert.DoesNotContain(wrongPassword, result.FailureMessage, StringComparison.Ordinal);
    }

    /// <summary>Verifies that authentication with unknown username fails.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [LiveLdapFact]
    public async Task AuthenticateAsync_UnknownUsername_Fails()
    {
        // Exercises the user-not-found branch: the service-account search returns no
        // entry, so no candidate bind is attempted.
        DashboardAuthenticator authenticator = CreateAuthenticator();

        DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
            "no-such-user-9f3c1",
            "irrelevant-password",
            CancellationToken.None);

        Assert.False(result.Succeeded);
        Assert.Null(result.Principal);
    }

    /// <summary>Verifies that authentication fails gracefully when the server is unreachable.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [LiveLdapFact]
    public async Task AuthenticateAsync_ServerUnreachable_FailsWithoutThrowing()
    {
        // Exercises the connect-failure path: overriding only the port keeps whatever host
        // the run targets (localhost by default, the shared GLAuth under the
        // MxGateway__Ldap__Server override) while pointing at a port nothing listens on, so
        // the connection error the shared LdapAuthService must absorb into a Fail result —
        // rather than propagate as an exception to the dashboard — is reproduced either way.
        DashboardAuthenticator authenticator = CreateAuthenticator(LibraryOptions() with
        {
            // 1 is a reserved port number that no LDAP server listens on.
            Port = 1,
        });

        DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
            "admin",
            SharedDirectoryPassword,
            CancellationToken.None);

        Assert.False(result.Succeeded);
        Assert.Null(result.Principal);
    }

    /// <summary>
    /// Verifies the SEC-25 tag grant end-to-end from a real LDAP bind: <c>gw-viewer</c>'s only
    /// group (GwReader) is mapped to the <c>team-a</c> visibility tag by <c>Dashboard:GroupToTag</c>,
    /// and the principal that bind produces is admitted by <see cref="IDashboardSessionAcl"/> for a
    /// <c>team-a</c>-tagged session but refused for a <c>team-b</c>-tagged one.
    /// </summary>
    /// <remarks>
    /// The mapping under test is entirely config-side: no GLAuth entry, group, or membership was
    /// added for it — the shared directory's existing GwReader group is simply named as a key in
    /// this fixture's <c>GroupToTag</c> map. What only a live bind can prove is that the group
    /// names <c>ILdapAuthService</c> actually returns from the shared directory (short RDN values,
    /// not DNs) are the ones <c>GroupToTag</c> keys match, which a fabricated principal cannot show.
    /// The denial half is the load-bearing assertion: before the ACL, every Viewer saw every session.
    /// </remarks>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [LiveLdapFact]
    public async Task AuthenticateAsync_ViewerWithGroupToTagGrant_SeesOnlyItsOwnTaggedSession()
    {
        DashboardAuthenticator authenticator = CreateAuthenticator(LibraryOptions(), TaggedDashboardOptions());

        DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
            "gw-viewer",
            SharedDirectoryPassword,
            CancellationToken.None);

        Assert.True(result.Succeeded);
        Assert.NotNull(result.Principal);
        Assert.True(result.Principal.IsInRole(DashboardRoles.Viewer));
        Assert.False(result.Principal.IsInRole(DashboardRoles.Admin));
        Assert.Contains(result.Principal.Claims, claim =>
            claim.Type == DashboardAuthenticationDefaults.DashboardTagClaimType
            && claim.Value == TeamATag);

        IDashboardSessionAcl acl = CreateAcl();

        Assert.True(acl.CanViewSession(result.Principal, TeamASessionId));
        Assert.False(acl.CanViewSession(result.Principal, TeamBSessionId));

        // Untagged sessions stay Admin-only under the shipped default, so the Viewer's grant does
        // not silently widen to sessions whose owning key declared no tags.
        Assert.False(acl.CanViewSession(result.Principal, UntaggedSessionId));
    }

    /// <summary>
    /// Verifies that <c>multi-role</c> — an Administrator in the shared directory — reaches every
    /// session regardless of tags.
    /// </summary>
    /// <remarks>
    /// The bypass is proved by the two sessions the account's own grant does <em>not</em> cover.
    /// <c>multi-role</c> is a member of GwReader as well as GwAdmin, so this fixture's
    /// <c>GroupToTag</c> map grants it <c>team-a</c> — the <c>team-a</c> allow would therefore hold
    /// even with the bypass removed and proves nothing on its own. <c>team-b</c> (a tag it does not
    /// hold) and the untagged session (Admin-only under the shipped default) are the assertions
    /// that fail if the Administrator branch is ever dropped.
    /// </remarks>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [LiveLdapFact]
    public async Task AuthenticateAsync_Administrator_BypassesTagCheckForEverySession()
    {
        DashboardAuthenticator authenticator = CreateAuthenticator(LibraryOptions(), TaggedDashboardOptions());

        DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
            "multi-role",
            SharedDirectoryPassword,
            CancellationToken.None);

        Assert.True(result.Succeeded);
        Assert.NotNull(result.Principal);
        Assert.True(result.Principal.IsInRole(DashboardRoles.Admin));

        IDashboardSessionAcl acl = CreateAcl();

        Assert.True(acl.CanViewSession(result.Principal, TeamASessionId));
        Assert.True(acl.CanViewSession(result.Principal, TeamBSessionId));
        Assert.True(acl.CanViewSession(result.Principal, UntaggedSessionId));
    }

    private static DashboardAuthenticator CreateAuthenticator() => CreateAuthenticator(LibraryOptions());

    private static DashboardAuthenticator CreateAuthenticator(LibraryLdapOptions ldapOptions) =>
        CreateAuthenticator(ldapOptions, AdminOnlyDashboardOptions());

    private static DashboardAuthenticator CreateAuthenticator(
        LibraryLdapOptions ldapOptions,
        DashboardOptions dashboardOptions)
    {
        GatewayOptions gatewayOptions = new() { Dashboard = dashboardOptions };

        return new DashboardAuthenticator(
            new LdapAuthService(ldapOptions),
            new DashboardGroupRoleMapper(Options.Create(gatewayOptions)),
            Options.Create(gatewayOptions),
            NullLogger<DashboardAuthenticator>.Instance);
    }

    /// <summary>
    /// The historical fixture map: GwAdmin is the only mapped group, so GwReader members are denied
    /// login outright. Kept for the tests that assert that denial.
    /// </summary>
    private static DashboardOptions AdminOnlyDashboardOptions() => new()
    {
        GroupToRole = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
        {
            ["GwAdmin"] = DashboardRoles.Admin,
        },
    };

    /// <summary>
    /// The SEC-25 fixture map: GwReader is admitted as a Viewer and granted <c>team-a</c>. Both keys
    /// name groups that already exist in the shared directory — the tag layer is config-only.
    /// </summary>
    private static DashboardOptions TaggedDashboardOptions() => new()
    {
        GroupToRole = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
        {
            ["GwAdmin"] = DashboardRoles.Admin,
            ["GwReader"] = DashboardRoles.Viewer,
        },
        GroupToTag = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
        {
            ["GwReader"] = [TeamATag],
        },
    };

    private static DashboardSessionAcl CreateAcl() => new(
        new FixedSessionManager(
            [
                CreateSession(TeamASessionId, [TeamATag]),
                CreateSession(TeamBSessionId, [TeamBTag]),
                CreateSession(UntaggedSessionId, tags: null),
            ]),
        Options.Create(new GatewayOptions
        {
            // Explicit rather than defaulted: the untagged assertions above read this value.
            Dashboard = new DashboardOptions
            {
                UntaggedSessionVisibility = UntaggedSessionVisibility.AdminOnly,
            },
        }));

    private static GatewaySession CreateSession(string sessionId, string[]? tags) => new(
        sessionId: sessionId,
        backendName: "backend",
        pipeName: $"pipe-{sessionId}",
        nonce: "nonce",
        clientIdentity: "client",
        ownerKeyId: "key-1",
        clientSessionName: "client-session",
        clientCorrelationId: "correlation",
        commandTimeout: TimeSpan.FromSeconds(5),
        startupTimeout: TimeSpan.FromSeconds(5),
        shutdownTimeout: TimeSpan.FromSeconds(5),
        leaseDuration: TimeSpan.FromMinutes(30),
        openedAt: DateTimeOffset.UnixEpoch,
        ownerDashboardTags: tags);

    /// <summary>
    /// Builds the shared library <see cref="LibraryLdapOptions"/> by binding the real
    /// <c>MxGateway:Ldap</c> configuration section the same way production does in
    /// <c>AddZbLdapAuth(configuration, "MxGateway:Ldap")</c>, rather than hand-copying the
    /// gateway shadow <c>LdapOptions</c> defaults field by field.
    /// Binding the section directly onto the shared type means the live tests exercise the
    /// exact option-binding path production uses, pick up every shared field (including
    /// <see cref="LibraryLdapOptions.ConnectionTimeoutMs"/>, which governs the
    /// unreachable-server test's timing) at whatever value the operator configured, and
    /// cannot silently drop a field added to the shared type. The gateway's
    /// <c>appsettings.json</c> seeds the dev directory connection (port 3893, plaintext,
    /// AllowInsecure) but ships <c>Server=localhost</c>, so a run against the shared GLAuth
    /// needs the <c>MxGateway__Ldap__Server=10.100.0.35</c> environment override that the
    /// <c>AddEnvironmentVariables()</c> layer below applies.
    /// </summary>
    private static LibraryLdapOptions LibraryOptions()
    {
        string repositoryRoot = IntegrationTestEnvironment.ResolveRepositoryRoot(AppContext.BaseDirectory);
        string appSettingsPath = Path.Combine(
            repositoryRoot,
            "src",
            "ZB.MOM.WW.MxGateway.Server",
            "appsettings.json");

        IConfiguration configuration = new ConfigurationBuilder()
            .AddJsonFile(appSettingsPath, optional: false)
            .AddEnvironmentVariables()
            .Build();

        // Same section production binds in AddZbLdapAuth(configuration, "MxGateway:Ldap").
        // Get<T> returns null only when the section is absent; appsettings.json always
        // carries it, so fall back to shared defaults defensively rather than throw.
        LibraryLdapOptions options = configuration.GetSection("MxGateway:Ldap").Get<LibraryLdapOptions>()
            ?? new LibraryLdapOptions();

        // appsettings.json now ships the bind password as the unexpanded
        // "${secret:ldap/mxgateway/bind}" token (resolved at gateway startup by the
        // pre-host secrets expander, which this bare ConfigurationBuilder does not run).
        // AddEnvironmentVariables() above lets MxGateway__Ldap__ServiceAccountPassword
        // override the token with the real password. Fail loud rather than silently
        // binding with the literal token string, which would make every live test in
        // this file fail with a confusing LDAP bind error instead of an actionable one.
        if (string.IsNullOrEmpty(options.ServiceAccountPassword)
            || options.ServiceAccountPassword.StartsWith("${secret:", StringComparison.Ordinal))
        {
            throw new InvalidOperationException(
                "Live LDAP tests require the real bind password via the "
                + "MxGateway__Ldap__ServiceAccountPassword environment variable "
                + "(appsettings now ships a ${secret:} token that this suite does not expand). "
                + "Set it before running.");
        }

        return options;
    }

    /// <summary>
    /// Registry double serving a fixed set of sessions. The ACL only ever calls
    /// <see cref="TryGetSession"/>; the remaining members exist to satisfy the interface and are
    /// never reached by these tests.
    /// </summary>
    /// <param name="sessions">The sessions this registry resolves.</param>
    private sealed class FixedSessionManager(IReadOnlyList<GatewaySession> sessions) : ISessionManager
    {
        /// <inheritdoc />
        public Task<GatewaySession> OpenSessionAsync(
            SessionOpenRequest request,
            string? clientIdentity,
            string? ownerKeyId,
            CancellationToken cancellationToken) => throw new NotSupportedException();

        /// <inheritdoc />
        public bool TryGetSession(string sessionId, [MaybeNullWhen(false)] out GatewaySession session)
        {
            session = sessions.FirstOrDefault(candidate => candidate.SessionId == sessionId);

            return session is not null;
        }

        /// <inheritdoc />
        public Task<WorkerCommandReply> InvokeAsync(
            string sessionId,
            WorkerCommand command,
            CancellationToken cancellationToken) => throw new NotSupportedException();

        /// <inheritdoc />
        public Task<SessionCloseResult> CloseSessionAsync(
            string sessionId,
            CancellationToken cancellationToken) => throw new NotSupportedException();

        /// <inheritdoc />
        public Task<SessionCloseResult> KillWorkerAsync(
            string sessionId,
            string reason,
            CancellationToken cancellationToken) => throw new NotSupportedException();

        /// <inheritdoc />
        public Task<int> CloseExpiredLeasesAsync(
            DateTimeOffset now,
            CancellationToken cancellationToken) => Task.FromResult(0);

        /// <inheritdoc />
        public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
    }
}
