using System.Net;
using System.Net.Http.Json;
using System.Security.Claims;
using System.Text.Json;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.TestHost;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Shouldly;
using Xunit;
using ZB.MOM.WW.Auth.AspNetCore;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Configuration.Services;
using ZB.MOM.WW.OtOpcUa.Security.Endpoints;
using ZB.MOM.WW.OtOpcUa.Security.Jwt;
using ZB.MOM.WW.OtOpcUa.Security.Ldap;

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

/// <summary>
/// End-to-end auth contract test: exercises <c>AddOtOpcUaAuth + MapOtOpcUaAuth</c>
/// through an in-memory <c>TestServer</c>. Scope is the auth surface — not the fused
/// <c>OtOpcUa.Host</c> bootstrap (that would entail Akka cluster + role gating, which
/// belongs in the multi-node Task 58 harness). Stub <see cref="ILdapAuthService"/>
/// drives the auth outcomes; <see cref="OtOpcUaConfigDbContext"/> uses EF in-memory so
/// DataProtection can persist keys.
/// </summary>
public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
{
    private IHost _host = null!;
    private TestServer _server = null!;
    private readonly StubLdapGroupRoleMappingService _roleMappings = new();

    private static CancellationToken Ct => TestContext.Current.CancellationToken;

    /// <summary>Initializes the test host and server.</summary>
    public async ValueTask InitializeAsync()
    {
        var dbName = $"auth-int-tests-{Guid.NewGuid():N}";

        _host = new HostBuilder()
            .ConfigureWebHost(web =>
            {
                web.UseTestServer();
                web.ConfigureServices(services =>
                {
                    services.AddDbContextFactory<OtOpcUaConfigDbContext>(opt =>
                        opt.UseInMemoryDatabase(dbName));
                    services.AddDbContext<OtOpcUaConfigDbContext>(opt =>
                        opt.UseInMemoryDatabase(dbName));

                    services.AddRouting();
                    var configuration = new ConfigurationBuilder()
                        .AddInMemoryCollection(new Dictionary<string, string?>
                        {
                            ["Security:Jwt:SigningKey"] = "test-signing-key-with-at-least-32-bytes-of-utf8-content",
                            ["Security:Jwt:Issuer"] = "otopcua-test",
                            ["Security:Jwt:Audience"] = "otopcua-test",
                            // GroupToRole baseline bound onto LdapOptions: the production
                            // OtOpcUaGroupRoleMapper resolves "Viewer" from the LDAP group
                            // "ReadOnly". This exercises the real mapper path — the stub no longer
                            // pre-populates roles, so Viewer can only come from the mapper.
                            ["Security:Ldap:GroupToRole:ReadOnly"] = "Viewer",
                        }).Build();
                    services.AddOtOpcUaAuth(configuration);
                    services.AddSingleton<ILdapAuthService, StubLdapAuthService>();
                    // The login handler now resolves the DB role-map service via DI to merge
                    // DB-backed grants on top of the appsettings baseline. Register the stub so
                    // the minimal-API handler can be constructed; tests drive its rows.
                    services.AddSingleton<ILdapGroupRoleMappingService>(_roleMappings);
                });
                web.Configure(app =>
                {
                    app.UseRouting();
                    app.UseAuthentication();
                    app.UseAuthorization();
                    app.UseEndpoints(e =>
                    {
                        e.MapOtOpcUaAuth();
                        // Protected root used by AuthChallengeTests below — exercises the cookie
                        // scheme's challenge heuristic without depending on the full Razor host.
                        e.MapGet("/", () => Results.Ok("authenticated")).RequireAuthorization();
                        // Canonical-claims probe: returns all claim types+values from the cookie
                        // principal so tests can assert the canonical ZbClaimTypes vocabulary.
                        e.MapGet("/auth/whoami", (HttpContext ctx) =>
                        {
                            var claims = ctx.User.Claims
                                .Select(c => new { c.Type, c.Value })
                                .ToArray();
                            return Results.Ok(claims);
                        }).RequireAuthorization();
                    });
                });
            })
            .Build();

        await _host.StartAsync(Ct);
        _server = _host.GetTestServer();
    }

    /// <summary>Disposes the test host and server.</summary>
    public async ValueTask DisposeAsync()
    {
        await _host.StopAsync(TestContext.Current.CancellationToken);
        _host.Dispose();
    }

    private HttpClient NewClient() => _server.CreateClient();

    /// <summary>Creates a TestServer-backed HttpClient that does NOT auto-follow redirects.
    /// Used by challenge tests so we can assert on the 302 + Location directly.</summary>
    private HttpClient NewClientNoRedirect() => new(_server.CreateHandler())
    {
        BaseAddress = _server.BaseAddress,
    };

    /// <summary>Tests that login with valid credentials returns 204 and sets cookie.</summary>
    [Fact]
    public async Task Login_with_valid_credentials_returns_204_and_sets_cookie()
    {
        var client = NewClient();
        var response = await client.PostAsJsonAsync("/auth/login",
            new AuthEndpoints.LoginRequest("alice", "valid-password"), Ct);

        response.StatusCode.ShouldBe(HttpStatusCode.NoContent);
        response.Headers.GetValues("Set-Cookie").ShouldContain(c => c.StartsWith("ZB.MOM.WW.OtOpcUa.Auth="));
    }

    /// <summary>Tests that login with invalid credentials returns 401.</summary>
    [Fact]
    public async Task Login_with_invalid_credentials_returns_401()
    {
        var client = NewClient();
        var response = await client.PostAsJsonAsync("/auth/login",
            new AuthEndpoints.LoginRequest("alice", "wrong-password"), Ct);

        response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
    }

    /// <summary>Tests that login when LDAP throws returns 503.</summary>
    [Fact]
    public async Task Login_when_ldap_throws_returns_503()
    {
        var client = NewClient();
        var response = await client.PostAsJsonAsync("/auth/login",
            new AuthEndpoints.LoginRequest("ldap-down", "anything"), Ct);

        response.StatusCode.ShouldBe(HttpStatusCode.ServiceUnavailable);
    }

    /// <summary>Tests that ping anonymous returns 401.</summary>
    [Fact]
    public async Task Ping_anonymous_returns_401()
    {
        var client = NewClient();
        var response = await client.GetAsync("/auth/ping", Ct);

        response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
    }

    /// <summary>Tests that ping after cookie login returns 200.</summary>
    [Fact]
    public async Task Ping_after_cookie_login_returns_200()
    {
        var client = NewClient();
        var loginResponse = await client.PostAsJsonAsync("/auth/login",
            new AuthEndpoints.LoginRequest("alice", "valid-password"), Ct);
        loginResponse.EnsureSuccessStatusCode();

        var ping = new HttpRequestMessage(HttpMethod.Get, "/auth/ping");
        AttachCookies(ping, loginResponse);
        var response = await client.SendAsync(ping, Ct);
        response.StatusCode.ShouldBe(HttpStatusCode.OK);
    }

    /// <summary>Tests that token after cookie login returns jwt.</summary>
    [Fact]
    public async Task Token_after_cookie_login_returns_jwt()
    {
        var client = NewClient();
        var loginResponse = await client.PostAsJsonAsync("/auth/login",
            new AuthEndpoints.LoginRequest("alice", "valid-password"), Ct);
        loginResponse.EnsureSuccessStatusCode();

        var tokenReq = new HttpRequestMessage(HttpMethod.Post, "/auth/token");
        AttachCookies(tokenReq, loginResponse);
        var response = await client.SendAsync(tokenReq, Ct);
        response.StatusCode.ShouldBe(HttpStatusCode.OK);

        var payload = await response.Content.ReadFromJsonAsync<JsonElement>(Ct);
        var token = payload.GetProperty("token").GetString();
        token.ShouldNotBeNullOrEmpty();
        token!.Split('.').Length.ShouldBe(3);
    }

    /// <summary>A system-wide DB row for a group the user holds grants an extra role on top of
    /// the appsettings baseline; the merged role surfaces in the issued JWT's Role claims.</summary>
    [Fact]
    public async Task Login_merges_db_role_grant_into_claims()
    {
        // StubLdapAuthService returns Groups ["ReadOnly"] with empty Roles (the real production
        // shape). The mapper resolves the appsettings baseline "ReadOnly" → Viewer, then a
        // system-wide DB row maps "ReadOnly" → Administrator, so the merged set is both.
        _roleMappings.Rows.Add(new LdapGroupRoleMapping
        {
            Id = Guid.NewGuid(),
            LdapGroup = "ReadOnly",
            Role = AdminRole.Administrator,
            IsSystemWide = true,
            ClusterId = null,
        });

        var client = NewClient();
        var loginResponse = await client.PostAsJsonAsync("/auth/login",
            new AuthEndpoints.LoginRequest("alice", "valid-password"), Ct);
        loginResponse.EnsureSuccessStatusCode();

        var tokenReq = new HttpRequestMessage(HttpMethod.Post, "/auth/token");
        AttachCookies(tokenReq, loginResponse);
        var tokenResp = await client.SendAsync(tokenReq, Ct);
        tokenResp.StatusCode.ShouldBe(HttpStatusCode.OK);

        var payload = await tokenResp.Content.ReadFromJsonAsync<JsonElement>(Ct);
        var roles = JwtRoleClaims(payload.GetProperty("token").GetString()!);
        roles.ShouldContain("Viewer");        // appsettings baseline preserved
        roles.ShouldContain("Administrator"); // DB grant merged in
    }

    /// <summary>Fail-closed (review I3): when the role mapper throws on the real production path
    /// (the auth result carries no pre-resolved roles — roles come only from the mapper), sign-in
    /// still SUCCEEDS but the user is granted ZERO role claims. They are authenticated (can prove
    /// identity) yet authorized for nothing role-gated until the mapper recovers — the safe
    /// fail-closed behaviour, not a fail-open with a stale role set.</summary>
    [Fact]
    public async Task Login_when_role_mapper_throws_signs_in_with_no_role_claims()
    {
        // Simulate a mapper fault on the real path. The whole MapAsync throws (the appsettings
        // baseline is computed inside the mapper, so it does NOT survive the throw): the login
        // endpoint falls back to result.Roles, which is empty on the real LDAP path.
        _roleMappings.Throws = true;

        var client = NewClient();
        var loginResponse = await client.PostAsJsonAsync("/auth/login",
            new AuthEndpoints.LoginRequest("alice", "valid-password"), Ct);

        // Login proceeds despite the simulated DB outage — authenticated.
        loginResponse.StatusCode.ShouldBe(HttpStatusCode.NoContent);

        var tokenReq = new HttpRequestMessage(HttpMethod.Post, "/auth/token");
        AttachCookies(tokenReq, loginResponse);
        var tokenResp = await client.SendAsync(tokenReq, Ct);
        tokenResp.StatusCode.ShouldBe(HttpStatusCode.OK);

        // No role claims at all — fail closed.
        var payload = await tokenResp.Content.ReadFromJsonAsync<JsonElement>(Ct);
        var roles = JwtRoleClaims(payload.GetProperty("token").GetString()!);
        roles.ShouldBeEmpty();
    }

    /// <summary>Parses the payload segment of a JWT and returns it as a <see cref="JsonElement"/>.</summary>
    private static JsonElement JwtPayloadJson(string jwt)
    {
        var payloadSegment = jwt.Split('.')[1];
        var padded = payloadSegment.Replace('-', '+').Replace('_', '/');
        padded = (padded.Length % 4) switch
        {
            2 => padded + "==",
            3 => padded + "=",
            _ => padded,
        };
        return JsonDocument.Parse(Convert.FromBase64String(padded)).RootElement;
    }

    /// <summary>Extracts the "Role" claim values from a JWT's payload segment.</summary>
    private static IReadOnlyList<string> JwtRoleClaims(string jwt)
    {
        var payloadSegment = jwt.Split('.')[1];
        var padded = payloadSegment.Replace('-', '+').Replace('_', '/');
        switch (padded.Length % 4)
        {
            case 2: padded += "=="; break;
            case 3: padded += "="; break;
        }
        var json = JsonDocument.Parse(Convert.FromBase64String(padded));
        if (!json.RootElement.TryGetProperty("Role", out var roleProp)) return [];
        return roleProp.ValueKind == JsonValueKind.Array
            ? [.. roleProp.EnumerateArray().Select(e => e.GetString()!)]
            : [roleProp.GetString()!];
    }

    /// <summary>
    /// Task 1.5 — canonical claims contract: after a successful cookie login the authenticated
    /// principal MUST carry the canonical ZbClaimTypes vocabulary:
    /// <list type="bullet">
    ///   <item><see cref="ZbClaimTypes.Name"/> (= ClaimTypes.Name) so Identity.Name resolves.</item>
    ///   <item><see cref="ZbClaimTypes.Username"/> (= "zb:username") — login username.</item>
    ///   <item><see cref="ZbClaimTypes.DisplayName"/> (= "zb:displayname") — human-friendly name.</item>
    ///   <item><see cref="ZbClaimTypes.Role"/> (= ClaimTypes.Role) — at least one role claim.</item>
    /// </list>
    /// Also asserts that the old short-name literals "Username" and "DisplayName" are NOT emitted
    /// (the pre-Task-1.5 strings that would indicate the migration was incomplete).
    /// </summary>
    [Fact]
    public async Task Login_emits_canonical_ZbClaimTypes_on_cookie_principal()
    {
        // Arrange — seed a DB role so the mapper produces a role claim.
        _roleMappings.Rows.Add(new LdapGroupRoleMapping
        {
            Id = Guid.NewGuid(),
            LdapGroup = "ReadOnly",
            Role = AdminRole.Administrator,
            IsSystemWide = true,
            ClusterId = null,
        });

        var client = NewClient();

        // Act — login.
        var loginResp = await client.PostAsJsonAsync("/auth/login",
            new AuthEndpoints.LoginRequest("alice", "valid-password"), Ct);
        loginResp.StatusCode.ShouldBe(HttpStatusCode.NoContent);

        // Call the whoami probe to read back the cookie principal's claims.
        var whoamiReq = new HttpRequestMessage(HttpMethod.Get, "/auth/whoami");
        AttachCookies(whoamiReq, loginResp);
        var whoamiResp = await client.SendAsync(whoamiReq, Ct);
        whoamiResp.StatusCode.ShouldBe(HttpStatusCode.OK);

        var claims = (await whoamiResp.Content.ReadFromJsonAsync<ClaimDto[]>(Ct))!;

        // Assert — canonical name claim (ClaimTypes.Name URI) so Identity.Name resolves.
        claims.ShouldContain(c => c.Type == ZbClaimTypes.Name && c.Value == "alice",
            $"Expected {ZbClaimTypes.Name} claim with value 'alice'");

        // Assert — canonical username claim ("zb:username").
        claims.ShouldContain(c => c.Type == ZbClaimTypes.Username && c.Value == "alice",
            $"Expected {ZbClaimTypes.Username} claim with value 'alice'");

        // Assert — canonical display-name claim ("zb:displayname").
        claims.ShouldContain(c => c.Type == ZbClaimTypes.DisplayName && c.Value == "Alice User",
            $"Expected {ZbClaimTypes.DisplayName} claim with value 'Alice User'");

        // Assert — at least one role claim using canonical ZbClaimTypes.Role (= ClaimTypes.Role).
        claims.ShouldContain(c => c.Type == ZbClaimTypes.Role,
            $"Expected at least one {ZbClaimTypes.Role} claim");

        // Assert — old pre-Task-1.5 short literals must NOT appear.
        claims.ShouldNotContain(c => c.Type == "Username",
            "Old 'Username' literal must not be emitted after Task 1.5 migration");
        claims.ShouldNotContain(c => c.Type == "DisplayName",
            "Old 'DisplayName' literal must not be emitted after Task 1.5 migration");
    }

    /// <summary>
    /// Task 1.5 — JWT payload uses canonical claim keys: after login and token issue the JWT
    /// payload segment MUST contain "zb:username" and "zb:displayname" keys (not the old short
    /// "Username"/"DisplayName" strings), AND the role claim(s) MUST be carried under the key
    /// <see cref="JwtTokenService.RoleClaimType"/> (currently the short "Role" key — intentionally
    /// NOT the long ClaimTypes.Role URI, because OtOpcUa is JWT-issued-only; see
    /// <see cref="JwtTokenService.RoleClaimType"/> docs for the rationale and the caveat that
    /// applies if a JwtBearer scheme is ever added).
    /// </summary>
    [Fact]
    public async Task Token_payload_uses_canonical_zb_claim_keys()
    {
        // Arrange — the appsettings baseline maps group "ReadOnly" → role "Viewer", so alice
        // (whose groups are ["ReadOnly"]) will carry at least one role in the issued JWT.
        // No extra DB rows needed — the appsettings GroupToRole entry is always active.
        var client = NewClient();

        var loginResp = await client.PostAsJsonAsync("/auth/login",
            new AuthEndpoints.LoginRequest("alice", "valid-password"), Ct);
        loginResp.EnsureSuccessStatusCode();

        var tokenReq = new HttpRequestMessage(HttpMethod.Post, "/auth/token");
        AttachCookies(tokenReq, loginResp);
        var tokenResp = await client.SendAsync(tokenReq, Ct);
        tokenResp.StatusCode.ShouldBe(HttpStatusCode.OK);

        var payload = await tokenResp.Content.ReadFromJsonAsync<JsonElement>(Ct);
        var jwt = payload.GetProperty("token").GetString()!;

        var payloadJson = JwtPayloadJson(jwt);

        // Canonical "zb:username" key must be present.
        payloadJson.TryGetProperty("zb:username", out var usernameEl).ShouldBeTrue(
            "JWT payload must carry 'zb:username' claim (canonical ZbClaimTypes.Username)");
        usernameEl.GetString().ShouldBe("alice");

        // Canonical "zb:displayname" key must be present.
        payloadJson.TryGetProperty("zb:displayname", out var displayNameEl).ShouldBeTrue(
            "JWT payload must carry 'zb:displayname' claim (canonical ZbClaimTypes.DisplayName)");
        displayNameEl.GetString().ShouldBe("Alice User");

        // Role claim(s) must be carried under JwtTokenService.RoleClaimType (= "Role").
        // This pins the role-key contract: any future rename of RoleClaimType will be caught here.
        // The appsettings "ReadOnly" → "Viewer" mapping guarantees alice has ≥1 role.
        payloadJson.TryGetProperty(JwtTokenService.RoleClaimType, out var roleEl).ShouldBeTrue(
            $"JWT payload must carry at least one role under JwtTokenService.RoleClaimType " +
            $"(\"{JwtTokenService.RoleClaimType}\")");
        // The role value may be a string (single) or array (multiple); either way it must be non-empty.
        if (roleEl.ValueKind == JsonValueKind.Array)
            roleEl.EnumerateArray().Select(e => e.GetString()).ShouldNotBeEmpty(
                "JWT role array must contain at least one role value");
        else
            roleEl.GetString().ShouldNotBeNullOrEmpty("JWT role value must not be empty");

        // Old short-name literals must NOT be present.
        payloadJson.TryGetProperty("Username", out _).ShouldBeFalse(
            "JWT payload must not carry old 'Username' key after Task 1.5 migration");
        payloadJson.TryGetProperty("DisplayName", out _).ShouldBeFalse(
            "JWT payload must not carry old 'DisplayName' key after Task 1.5 migration");
    }

    /// <summary>Tests that logout clears the cookie.</summary>
    [Fact]
    public async Task Logout_clears_the_cookie()
    {
        var client = NewClient();
        var loginResponse = await client.PostAsJsonAsync("/auth/login",
            new AuthEndpoints.LoginRequest("alice", "valid-password"), Ct);
        loginResponse.EnsureSuccessStatusCode();

        var logoutReq = new HttpRequestMessage(HttpMethod.Post, "/auth/logout");
        logoutReq.Headers.Accept.ParseAdd("application/json");
        AttachCookies(logoutReq, loginResponse);
        var response = await client.SendAsync(logoutReq, Ct);
        response.StatusCode.ShouldBe(HttpStatusCode.NoContent);

        response.Headers.GetValues("Set-Cookie")
            .ShouldContain(c => c.StartsWith("ZB.MOM.WW.OtOpcUa.Auth=") && c.Contains("expires=", StringComparison.OrdinalIgnoreCase));
    }

    /// <summary>Anonymous browser GET of a protected route redirects to /login with a ReturnUrl.</summary>
    [Fact]
    public async Task Root_anonymous_browser_GET_redirects_to_login()
    {
        var client = NewClientNoRedirect();
        var req = new HttpRequestMessage(HttpMethod.Get, "/");
        req.Headers.Accept.ParseAdd("text/html");
        var resp = await client.SendAsync(req, Ct);

        resp.StatusCode.ShouldBe(HttpStatusCode.Found);
        resp.Headers.Location.ShouldNotBeNull();
        resp.Headers.Location!.OriginalString.ShouldContain("/login");
        resp.Headers.Location.OriginalString.ShouldContain("ReturnUrl");
    }

    /// <summary>
    /// Security-001 regression: a successful login with an absolute <c>returnUrl</c> must NOT
    /// redirect to the external URL. An attacker can craft
    /// <c>/login?returnUrl=https://evil.com</c>; the <see cref="LoginCard"/> component echoes
    /// the value into the hidden form field verbatim. The <c>/auth/login</c> endpoint must
    /// validate the URL is local before redirecting; it must fall back to <c>"/"</c> (the app
    /// root) rather than forwarding to an external host.
    /// </summary>
    [Fact]
    public async Task Login_with_absolute_returnUrl_does_not_open_redirect()
    {
        var client = NewClientNoRedirect();

        // POST to /auth/login with valid credentials AND an absolute returnUrl.
        var formContent = new FormUrlEncodedContent(new Dictionary<string, string>
        {
            ["username"] = "alice",
            ["password"] = "valid-password",
            ["returnUrl"] = "https://evil.com/steal-session",
        });
        var resp = await client.PostAsync("/auth/login", formContent, Ct);

        // The endpoint must redirect (302), but NOT to the external URL.
        resp.StatusCode.ShouldBe(HttpStatusCode.Found,
            "a successful form-POST login must always redirect");

        var location = resp.Headers.Location?.OriginalString ?? string.Empty;
        location.Contains("evil.com").ShouldBeFalse(
            "a successful login must not redirect to an externally-supplied absolute URL");

        // The fallback destination must be the app root — not an error page.
        location.ShouldBe("/", "the safe fallback for an invalid returnUrl is the app root");
    }

    /// <summary>Anonymous XHR GET of a protected route returns 401 (caller signaled non-browser
    /// via the <c>X-Requested-With</c> header — the ASP.NET cookie handler's IsAjaxRequest
    /// heuristic). The framework still writes a <c>Location</c> header alongside the 401;
    /// AJAX clients ignore it.</summary>
    [Fact]
    public async Task Root_anonymous_xhr_GET_returns_401()
    {
        var client = NewClientNoRedirect();
        var req = new HttpRequestMessage(HttpMethod.Get, "/");
        req.Headers.Add("X-Requested-With", "XMLHttpRequest");
        var resp = await client.SendAsync(req, Ct);

        resp.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
    }

    private static void AttachCookies(HttpRequestMessage request, HttpResponseMessage prior)
    {
        if (!prior.Headers.TryGetValues("Set-Cookie", out var setCookies)) return;
        var cookiePairs = setCookies
            .Select(c => c.Split(';', 2)[0])
            .ToArray();
        request.Headers.Add("Cookie", string.Join("; ", cookiePairs));
    }

    private sealed class StubLdapAuthService : ILdapAuthService
    {
        /// <summary>Authenticates a user asynchronously using the stub service.</summary>
        /// <param name="username">The username to authenticate.</param>
        /// <param name="password">The password to verify.</param>
        /// <param name="ct">The cancellation token.</param>
        /// <returns>The authentication result.</returns>
        public Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default)
        {
            if (username == "ldap-down")
                throw new InvalidOperationException("simulated LDAP outage");
            if (password == "valid-password")
                return Task.FromResult(new LdapAuthResult(
                    Success: true,
                    DisplayName: "Alice User",
                    Username: username,
                    Groups: ["ReadOnly"],
                    // Roles empty — the real production path returns groups, never roles. Role
                    // resolution is the mapper's job (OtOpcUaGroupRoleMapper applies the
                    // GroupToRole baseline). This proves roles flow through the mapper, not via
                    // pre-population of the auth result.
                    Roles: [],
                    Error: null));
            return Task.FromResult(new LdapAuthResult(
                Success: false,
                DisplayName: null,
                Username: username,
                Groups: [],
                Roles: [],
                Error: "Invalid username or password"));
        }
    }

    /// <summary>
    /// In-memory stub for the DB-backed group→role mapping service. Tests seed <see cref="Rows"/>
    /// and the login handler merges any system-wide row whose group the user holds. Set
    /// <see cref="Throws"/> to simulate a DB outage and exercise the baseline-roles fallback.
    /// </summary>
    private sealed class StubLdapGroupRoleMappingService : ILdapGroupRoleMappingService
    {
        public List<LdapGroupRoleMapping> Rows { get; } = [];
        public bool Throws { get; set; }

        /// <summary>Returns seeded rows whose group matches one of <paramref name="ldapGroups"/>.</summary>
        public Task<IReadOnlyList<LdapGroupRoleMapping>> GetByGroupsAsync(
            IEnumerable<string> ldapGroups, CancellationToken cancellationToken)
        {
            if (Throws) throw new InvalidOperationException("simulated DB outage");
            var groups = new HashSet<string>(ldapGroups, StringComparer.OrdinalIgnoreCase);
            IReadOnlyList<LdapGroupRoleMapping> matched =
                [.. Rows.Where(r => groups.Contains(r.LdapGroup))];
            return Task.FromResult(matched);
        }

        /// <summary>Not exercised by these tests.</summary>
        public Task<IReadOnlyList<LdapGroupRoleMapping>> ListAllAsync(CancellationToken cancellationToken) =>
            throw new NotSupportedException();

        /// <summary>Not exercised by these tests.</summary>
        public Task<LdapGroupRoleMapping> CreateAsync(LdapGroupRoleMapping row, CancellationToken cancellationToken) =>
            throw new NotSupportedException();

        /// <summary>Not exercised by these tests.</summary>
        public Task DeleteAsync(Guid id, CancellationToken cancellationToken) =>
            throw new NotSupportedException();
    }

    /// <summary>
    /// DTO for deserialising the /auth/whoami claim list.
    /// Must match the anonymous projection in the whoami endpoint.
    /// </summary>
    private sealed record ClaimDto(string Type, string Value);
}
