using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.Auth.Abstractions.Ldap;
using ZB.MOM.WW.Auth.Abstractions.Roles;
using ZB.MOM.WW.ScadaBridge.Security;

namespace ZB.MOM.WW.ScadaBridge.CentralUI.Auth;

/// <summary>
/// Minimal API endpoints for login/logout. These run outside Blazor Server (standard HTTP POST).
/// On success, signs in via ASP.NET Core cookie authentication and redirects to dashboard.
/// </summary>
public static class AuthEndpoints
{
    /// <summary>Registers the <c>/auth/login</c>, <c>/auth/logout</c>, and <c>/auth/ping</c> endpoints on the given route builder.</summary>
    /// <param name="endpoints">The route builder to add the endpoints to.</param>
    /// <returns>The same <paramref name="endpoints"/> instance, for call chaining.</returns>
    public static IEndpointRouteBuilder MapAuthEndpoints(this IEndpointRouteBuilder endpoints)
    {
        endpoints.MapPost("/auth/login", async (HttpContext context) =>
        {
            var form = await context.Request.ReadFormAsync();
            var username = form["username"].ToString();
            var password = form["password"].ToString();

            if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
            {
                context.Response.Redirect("/login?error=Username+and+password+are+required.");
                return;
            }

            // Login throttle: refuse a bind while this username+IP key is locked out
            // (fixed-window password-spray guard, arch-review P1/UA5).
            var throttle = context.RequestServices.GetRequiredService<LoginThrottle>();
            var remoteIp = context.Connection.RemoteIpAddress?.ToString();
            if (throttle.IsLockedOut(username, remoteIp!))
            {
                context.Response.Redirect("/login?error=Too+many+failed+attempts.+Try+again+later.");
                return;
            }

            var ldapAuth = context.RequestServices.GetRequiredService<ILdapAuthService>();
            var roleMapper = context.RequestServices.GetRequiredService<IGroupRoleMapper<string>>();

            var authResult = await ldapAuth.AuthenticateAsync(username, password, context.RequestAborted);
            if (!authResult.Succeeded)
            {
                throttle.RecordFailure(username, remoteIp!);
                var errorMsg = Uri.EscapeDataString(LdapAuthFailureMessages.ToMessage(authResult.Failure));
                context.Response.Redirect($"/login?error={errorMsg}");
                return;
            }

            throttle.RecordSuccess(username, remoteIp!);

            // Map LDAP groups to roles via the shared IGroupRoleMapper<string> seam
            // (ScadaBridgeGroupRoleMapper, wrapping the DB-backed RoleMapper).
            // The full RoleMappingResult — including PermittedSiteIds and the
            // system-wide flag — is carried in the mapping's opaque Scope so the
            // site-scope→SiteId claims below are built exactly as before.
            var roleMapping = await roleMapper.MapAsync(authResult.Groups, context.RequestAborted);

            // The ScadaBridge mapper carries the full RoleMappingResult in the seam's
            // opaque Scope (see ScadaBridgeGroupRoleMapper). Guard the unwrap (review I4):
            // a future/alternate IGroupRoleMapper<string> could leave Scope null or set a
            // different type. Rather than throw InvalidCastException mid-login, fall back to
            // the most restrictive interpretation — not a system-wide deployment and no
            // permitted sites — so no SiteId claims are stamped (deny-by-omission). The real
            // ScadaBridge mapper always supplies a RoleMappingResult, so behaviour is unchanged.
            var scope = roleMapping.Scope is RoleMappingResult mapped
                ? mapped
                : new RoleMappingResult(roleMapping.Roles, [], IsSystemWideDeployment: false);

            // Build claims from LDAP auth + role mapping.
            // No fixed "expires_at" absolute-cap claim is stamped
            // — session expiry is owned by the cookie middleware's sliding window
            // (ZB.MOM.WW.ScadaBridge.Security AddCookie: ExpireTimeSpan = idle timeout,
            // SlidingExpiration = true). A frozen absolute claim would contradict
            // the documented sliding-refresh policy.
            var displayName = string.IsNullOrEmpty(authResult.DisplayName) ? username : authResult.DisplayName;
            var resolvedUsername = string.IsNullOrEmpty(authResult.Username) ? username : authResult.Username;

            // Build the cookie principal through the shared
            // SessionClaimBuilder — the SINGLE source of truth that the mid-session
            // OnValidatePrincipal role-refresh path ALSO uses, so login and refresh can
            // never drift. It stamps the canonical identity/role/scope claims (with
            // roleType/nameType pinned for IsInRole), PLUS these additions: one
            // zb:group claim per raw LDAP group (the durable input the mid-session
            // RoleMapper re-run consumes) and a zb:lastrolerefresh anchor (login time,
            // UTC) that also seeds the LastActivity idle anchor. The refresh timestamp is
            // the login instant, so the first role refresh is due RoleRefreshThresholdMinutes
            // later — not immediately.
            var principal = SessionClaimBuilder.Build(
                resolvedUsername,
                displayName,
                authResult.Groups,
                scope,
                DateTimeOffset.UtcNow);

            await context.SignInAsync(
                CookieAuthenticationDefaults.AuthenticationScheme,
                principal,
                BuildSignInProperties());

            context.Response.Redirect("/");
        }).DisableAntiforgery();

        endpoints.MapPost("/auth/token", async (HttpContext context) =>
        {
            var form = await context.Request.ReadFormAsync();
            var username = form["username"].ToString();
            var password = form["password"].ToString();

            if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
            {
                return Results.Json(new { error = "Username and password are required." }, statusCode: 400);
            }

            // Login throttle: refuse a bind while this username+IP key is locked out
            // (fixed-window password-spray guard, arch-review P1/UA5).
            var throttle = context.RequestServices.GetRequiredService<LoginThrottle>();
            var remoteIp = context.Connection.RemoteIpAddress?.ToString();
            if (throttle.IsLockedOut(username, remoteIp!))
            {
                return Results.Json(
                    new { error = "Too many failed authentication attempts. Try again later.", code = "AUTH_THROTTLED" },
                    statusCode: 429);
            }

            var ldapAuth = context.RequestServices.GetRequiredService<ILdapAuthService>();
            var jwtService = context.RequestServices.GetRequiredService<JwtTokenService>();
            var roleMapper = context.RequestServices.GetRequiredService<IGroupRoleMapper<string>>();

            var authResult = await ldapAuth.AuthenticateAsync(username, password, context.RequestAborted);
            if (!authResult.Succeeded)
            {
                throttle.RecordFailure(username, remoteIp!);
                return Results.Json(
                    new { error = LdapAuthFailureMessages.ToMessage(authResult.Failure) },
                    statusCode: 401);
            }

            throttle.RecordSuccess(username, remoteIp!);

            var roleMapping = await roleMapper.MapAsync(authResult.Groups, context.RequestAborted);

            // Guard the opaque-Scope unwrap (review I4); see the matching note on
            // /auth/login. Fall back to no site-scope rather than throwing if a future
            // mapper leaves Scope null or sets a different type.
            var scope = roleMapping.Scope is RoleMappingResult mapped
                ? mapped
                : new RoleMappingResult(roleMapping.Roles, [], IsSystemWideDeployment: false);

            var displayName = string.IsNullOrEmpty(authResult.DisplayName) ? username : authResult.DisplayName;
            var resolvedUsername = string.IsNullOrEmpty(authResult.Username) ? username : authResult.Username;

            var token = jwtService.GenerateToken(
                displayName,
                resolvedUsername,
                roleMapping.Roles,
                scope.IsSystemWideDeployment ? null : scope.PermittedSiteIds);

            return Results.Json(new
            {
                access_token = token,
                token_type = "Bearer",
                username = resolvedUsername,
                display_name = displayName,
                roles = roleMapping.Roles,
            });
        }).DisableAntiforgery();

        // Logout is a state-changing authenticated action: it
        // keeps antiforgery validation enabled so it cannot be triggered
        // cross-site. The NavMenu sign-out form includes the antiforgery token
        // (rendered by the <AntiforgeryToken /> component). There is deliberately
        // no GET /logout route — a state-changing GET is itself a CSRF vector
        // (an <img src="/logout"> would forcibly log a user out).
        endpoints.MapPost("/auth/logout", async (HttpContext context) =>
        {
            await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
            context.Response.Redirect("/login");
        });

        // Liveness probe for the client-side idle-logout check.
        // The Blazor circuit's CookieAuthenticationStateProvider serves a frozen
        // constructor-time principal, so a circuit can never
        // observe a server-side cookie expiry by polling the auth state.
        // SessionExpiry instead polls this endpoint via fetch(): being a normal
        // HTTP request, the cookie middleware re-validates (and slides) the
        // cookie on every hit. It deliberately does NOT use RequireAuthorization
        // — that would make the middleware answer a lapsed request with a 302 to
        // /login, which fetch() follows transparently and reads as a 200 login
        // page. Allowing anonymous access and returning 200/401 ourselves gives
        // the client an unambiguous expiry signal.
        endpoints.MapGet("/auth/ping", HandlePing);

        return endpoints;
    }

    /// <summary>
    /// Handler for <c>GET /auth/ping</c>. Returns <c>200</c> while the caller's
    /// cookie session is still valid and <c>401</c> once it has lapsed
    /// server-side.
    /// </summary>
    /// <param name="context">The current HTTP context used to check authentication state and write the response.</param>
    /// <returns>A task that represents the asynchronous operation.</returns>
    public static Task HandlePing(HttpContext context)
    {
        context.Response.StatusCode = context.User.Identity?.IsAuthenticated == true
            ? StatusCodes.Status200OK
            : StatusCodes.Status401Unauthorized;
        return Task.CompletedTask;
    }

    /// <summary>
    /// Builds the <see cref="AuthenticationProperties"/> for the login sign-in.
    /// Deliberately does <b>not</b> set <see cref="AuthenticationProperties.ExpiresUtc"/>.
    /// Session expiry is owned by the cookie authentication middleware's sliding
    /// window (configured in <c>ZB.MOM.WW.ScadaBridge.Security</c>'s <c>AddCookie</c>:
    /// <c>ExpireTimeSpan</c> = the idle timeout, <c>SlidingExpiration = true</c>).
    /// Setting a fixed <c>ExpiresUtc</c> here would re-impose a hard absolute cap
    /// that overrides the sliding window and contradicts the documented
    /// "sliding refresh, 30-minute idle timeout" policy. <see cref="AuthenticationProperties.IsPersistent"/>
    /// is true so the cookie survives a browser restart within the idle window;
    /// <see cref="AuthenticationProperties.AllowRefresh"/> is left unset (null)
    /// so the middleware is free to slide the expiry on activity.
    /// </summary>
    /// <returns>An <see cref="AuthenticationProperties"/> instance with <see cref="AuthenticationProperties.IsPersistent"/> set to <c>true</c> and no fixed expiry.</returns>
    public static AuthenticationProperties BuildSignInProperties() => new()
    {
        IsPersistent = true
    };
}
