using System.Security.Claims;
using System.Text.Json;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.Auth.AspNetCore;
using ZB.MOM.WW.Auth.Abstractions.Roles;
using ZB.MOM.WW.OtOpcUa.Security.Jwt;
using ZB.MOM.WW.OtOpcUa.Security.Ldap;

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

public static class AuthEndpoints
{
    /// <summary>JSON body schema for API-side login callers (kept stable for tests).</summary>
    public sealed record LoginRequest(string Username, string Password);

    /// <summary>Response for a token issue request.</summary>
    public sealed record TokenResponse(string Token);

    /// <summary>Maps OtOpcUa authentication endpoints to the application route builder.</summary>
    /// <param name="app">The endpoint route builder.</param>
    /// <returns>The endpoint route builder for chaining.</returns>
    public static IEndpointRouteBuilder MapOtOpcUaAuth(this IEndpointRouteBuilder app)
    {
        // The login endpoint serves two callers with different ergonomics:
        //   - Browser form POST (application/x-www-form-urlencoded) → redirect dance
        //   - API JSON POST (application/json) → 204 / 401 / 503 status codes
        // DisableAntiforgery: the login form is the entry point — anonymous by definition,
        // no prior session, so XSRF doesn't apply. AllowAnonymous: override the
        // AddOtOpcUaAuth fallback policy that would otherwise 401 the request.
        app.MapPost("/auth/login", (Delegate)LoginAsync).AllowAnonymous().DisableAntiforgery();
        app.MapGet("/auth/ping", (Delegate)Ping).AllowAnonymous();
        app.MapPost("/auth/token", (Delegate)IssueToken).RequireAuthorization();
        app.MapPost("/auth/logout", (Delegate)LogoutAsync).RequireAuthorization();
        return app;
    }

    private static async Task<IResult> LoginAsync(
        HttpContext http,
        ILdapAuthService ldap,
        IGroupRoleMapper<string> roleMapper,
        CancellationToken ct)
    {
        var isForm = http.Request.HasFormContentType;
        string username, password, returnUrl;

        if (isForm)
        {
            var form = await http.Request.ReadFormAsync(ct);
            username = form["username"].ToString();
            password = form["password"].ToString();
            returnUrl = form["returnUrl"].ToString();
        }
        else
        {
            var body = await JsonSerializer.DeserializeAsync<LoginRequest>(
                http.Request.Body,
                new JsonSerializerOptions(JsonSerializerDefaults.Web),
                ct);
            username = body?.Username ?? string.Empty;
            password = body?.Password ?? string.Empty;
            returnUrl = string.Empty;
        }

        LdapAuthResult result;
        try
        {
            result = await ldap.AuthenticateAsync(username, password, ct);
        }
        catch (Exception)
        {
            return Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
        }

        if (!result.Success)
        {
            if (!isForm) return Results.Unauthorized();

            var qs = $"?error={Uri.EscapeDataString(result.Error ?? "Invalid credentials")}";
            if (!string.IsNullOrWhiteSpace(returnUrl))
                qs += $"&returnUrl={Uri.EscapeDataString(returnUrl)}";
            return Results.Redirect("/login" + qs);
        }

        // Role resolution now lives behind the shared IGroupRoleMapper<string> seam
        // (OtOpcUaGroupRoleMapper): it applies the appsettings GroupToRole baseline AND merges
        // system-wide DB grants from the user's LDAP groups. result.Roles is empty on the real
        // LDAP path (the library returns groups, not roles); it is only pre-populated on the
        // DevStub success path (FleetAdmin) — union that pre-resolved set in so the dev grant
        // survives the move to the mapper.
        IReadOnlyList<string> roles = result.Roles;
        try
        {
            var mapping = await roleMapper.MapAsync(result.Groups, ct);
            roles = Union(result.Roles, mapping.Roles);
        }
        catch (Exception ex) when (ex is not OperationCanceledException)
        {
            // A DB hiccup (or any mapper fault) must never block sign-in — fall back to the
            // pre-resolved baseline roles (empty on the real path, FleetAdmin under DevStub).
            // This is intentionally FAIL-CLOSED on the real LDAP path: result.Roles is empty there
            // (the library returns groups, never roles — the mapper is the sole role source), so a
            // mapper fault signs the user in AUTHENTICATED but with ZERO role claims. They can prove
            // identity but are denied every role-gated action until the mapper recovers — strictly
            // safer than failing open with a stale/guessed role set. (See AuthEndpoints test
            // Login_when_role_mapper_throws_signs_in_with_no_role_claims.)
            http.RequestServices.GetService<ILoggerFactory>()?
                .CreateLogger("ZB.MOM.WW.OtOpcUa.Security.AuthEndpoints")
                .LogWarning(ex, "Role-map lookup failed for {User}; using pre-resolved baseline roles", username);
        }

        var claims = new List<Claim>
        {
            // ZbClaimTypes.Name = ClaimTypes.Name — populates Identity.Name canonically.
            new(ZbClaimTypes.Name, result.Username ?? username),
            new(ZbClaimTypes.Username, result.Username ?? username),
            new(ZbClaimTypes.DisplayName, result.DisplayName ?? username),
        };
        foreach (var role in roles)
            // ZbClaimTypes.Role = ClaimTypes.Role — framework [Authorize(Roles=...)] + IsInRole work.
            claims.Add(new Claim(ZbClaimTypes.Role, role));

        var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
        var principal = new ClaimsPrincipal(identity);

        await http.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal);

        if (!isForm) return Results.NoContent();
        // Validate returnUrl is a relative (local) URL before redirecting.
        // An attacker can supply an absolute URL (e.g. https://evil.com) via a crafted link;
        // LoginCard echoes the value verbatim into the hidden form field. Fall back to the
        // app root rather than following the external URL (open-redirect prevention).
        var destination = !string.IsNullOrWhiteSpace(returnUrl) && IsLocalUrl(returnUrl) ? returnUrl : "/";
        return Results.Redirect(destination);
    }

    /// <summary>
    ///     Returns <see langword="true"/> when <paramref name="url"/> is a relative (local) URL
    ///     safe to redirect to after login. Rejects absolute URLs (with a scheme, e.g.
    ///     <c>https://evil.com</c>), protocol-relative URLs (<c>//evil.com</c>), and
    ///     anything that is not a well-formed relative URI — preventing open-redirect attacks
    ///     via a crafted <c>returnUrl</c> query parameter.
    /// </summary>
    /// <param name="url">The candidate redirect target.</param>
    private static bool IsLocalUrl(string url) =>
        Uri.IsWellFormedUriString(url, UriKind.Relative) && !url.StartsWith("//");

    /// <summary>
    ///     Case-insensitive set-union of two role lists, preserving the de-duplication semantics the
    ///     legacy <c>RoleMapper.Merge</c> applied. Used to fold any pre-resolved roles (the DevStub
    ///     FleetAdmin grant) into the mapper-resolved set.
    /// </summary>
    /// <param name="first">The first role set (pre-resolved baseline).</param>
    /// <param name="second">The second role set (mapper output).</param>
    private static IReadOnlyList<string> Union(IReadOnlyList<string> first, IReadOnlyList<string> second)
    {
        var roles = new HashSet<string>(first, StringComparer.OrdinalIgnoreCase);
        foreach (var role in second)
            roles.Add(role);
        return [.. roles];
    }

    private static IResult Ping(HttpContext http) =>
        http.User.Identity?.IsAuthenticated == true ? Results.Ok() : Results.Unauthorized();

    private static IResult IssueToken(HttpContext http, JwtTokenService jwt)
    {
        var user = http.User;
        var username = user.FindFirst(JwtTokenService.UsernameClaimType)?.Value
                       ?? user.Identity?.Name
                       ?? string.Empty;
        var displayName = user.FindFirst(JwtTokenService.DisplayNameClaimType)?.Value ?? username;
        var roles = user.FindAll(ZbClaimTypes.Role).Select(c => c.Value).ToArray();

        return Results.Ok(new TokenResponse(jwt.Issue(displayName, username, roles)));
    }

    private static async Task<IResult> LogoutAsync(HttpContext http)
    {
        await http.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);

        // Browser form POST → redirect to /login so the user lands somewhere visible.
        // API callers that prefer the status-only contract should hit the endpoint with
        // Accept: application/json and we'll hand them a 204 instead.
        var wantsJson = http.Request.Headers.Accept.Any(v =>
            v?.Contains("application/json", StringComparison.OrdinalIgnoreCase) == true);
        if (wantsJson) return Results.NoContent();
        return Results.Redirect("/login");
    }
}
