using System.Text.Encodings.Web;
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Authentication;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Dashboard.Components;
using ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;

namespace ZB.MOM.WW.MxGateway.Server.Dashboard;

/// <summary>Endpoint extensions for registering the gateway dashboard routes.</summary>
public static class DashboardEndpointRouteBuilderExtensions
{
    /// <summary>
    /// The named rate-limiter policy applied to the <c>POST /auth/login</c> route. Registered
    /// in <c>GatewayApplication</c> via <see cref="GetLoginRateLimitPartition"/>.
    /// </summary>
    internal const string LoginRateLimiterPolicy = "dashboard-login";

    /// <summary>
    /// Builds the fixed-window rate-limit partition for a login request, keyed on the remote IP.
    /// Shared by the production policy registration and the tests so both exercise identical limits.
    /// </summary>
    /// <param name="httpContext">The inbound request.</param>
    /// <param name="security">The bound security options carrying the login-limit knobs.</param>
    /// <returns>The per-IP fixed-window partition.</returns>
    internal static RateLimitPartition<string> GetLoginRateLimitPartition(
        HttpContext httpContext,
        SecurityOptions security)
    {
        string partitionKey = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
        return RateLimitPartition.GetFixedWindowLimiter(
            partitionKey,
            _ => new FixedWindowRateLimiterOptions
            {
                PermitLimit = security.LoginRateLimitPermitLimit,
                Window = TimeSpan.FromSeconds(security.LoginRateLimitWindowSeconds),
                QueueLimit = 0,
                AutoReplenishment = true,
            });
    }

    /// <summary>
    /// Test seam: builds a standalone partitioned limiter over the production partition logic, so a
    /// test can acquire leases and assert the (permit+1)th is rejected without an HTTP server.
    /// </summary>
    /// <param name="security">The bound security options carrying the login-limit knobs.</param>
    /// <returns>A partitioned rate limiter keyed the same way as the production login policy.</returns>
    internal static PartitionedRateLimiter<HttpContext> CreateLoginRateLimiter(SecurityOptions security)
        => PartitionedRateLimiter.Create<HttpContext, string>(
            ctx => GetLoginRateLimitPartition(ctx, security));

    /// <summary>Maps all gateway dashboard routes including login, logout, and Razor components.</summary>
    /// <param name="endpoints">The endpoint route builder.</param>
    /// <returns>The route builder for chaining.</returns>
    public static IEndpointRouteBuilder MapGatewayDashboard(this IEndpointRouteBuilder endpoints)
    {
        IConfiguration configuration = endpoints.ServiceProvider.GetRequiredService<IConfiguration>();
        IConfigurationSection dashboardSection = configuration
            .GetSection($"{GatewayOptions.SectionName}:Dashboard");

        if (bool.TryParse(dashboardSection["Enabled"], out bool enabled) && !enabled)
        {
            return endpoints;
        }

        // GET /login is served by the [AllowAnonymous] Blazor <Login> component
        // (Components/Pages/Login.razor → @page "/login"), which renders the shared
        // kit's <LoginCard>. Its [AllowAnonymous] attribute overrides the
        // RequireAuthorization(ViewerPolicy) that MapRazorComponents<App>() applies,
        // so the cookie scheme's LoginPath="/login" redirect resolves for anonymous users.
        //
        // The credential POST is mapped to /auth/login, NOT /login. The @page "/login"
        // Razor Components endpoint matches ALL HTTP methods, so a MapPost("/login") shared
        // the "/login" route with it and every POST threw AmbiguousMatchException (HTTP 500).
        // A distinct /auth/login path (as ScadaBridge does) keeps the GET page and the POST
        // handler on separate routes. The <LoginCard Action="/auth/login"> form posts here.
        endpoints.MapPost(
                "/auth/login",
                (HttpContext httpContext, IAntiforgery antiforgery, IDashboardAuthenticator authenticator) =>
                    PostLoginAsync(httpContext, antiforgery, authenticator))
            .AllowAnonymous()
            .RequireRateLimiting(LoginRateLimiterPolicy)
            .WithName("DashboardLoginPost");

        endpoints.MapPost(
                "/logout",
                (HttpContext httpContext, IAntiforgery antiforgery) => PostLogoutAsync(httpContext, antiforgery))
            .AllowAnonymous()
            .WithName("DashboardLogout");

        // Browsers that navigate directly to /logout issue a GET. Sign out and
        // redirect to /login so the URL works as users expect. Logout is
        // self-destructive, so the GET path intentionally skips antiforgery.
        endpoints.MapGet(
                "/logout",
                (Delegate)(static (HttpContext httpContext) => GetLogoutAsync(httpContext)))
            .AllowAnonymous()
            .WithName("DashboardLogoutGet");

        endpoints.MapGet("/denied", () => Results.Content(
                RenderPage("Access denied", "<p>The signed-in user is not authorized for dashboard access.</p>"),
                "text/html"))
            .AllowAnonymous()
            .WithName("DashboardAccessDenied");

        // SignalR hubs. Authorization is enforced on the hub class via
        // [Authorize(Policy = HubClientsPolicy)] so the policy accepts either
        // the dashboard cookie or a HubToken bearer.
        endpoints.MapHub<DashboardSnapshotHub>("/hubs/snapshot");
        endpoints.MapHub<AlarmsHub>("/hubs/alarms");
        endpoints.MapHub<EventsHub>("/hubs/events");

        // Bearer-token mint endpoint. The cookie-authenticated browser hits
        // this from JS before opening a hub connection; the hub then accepts
        // the returned token via the HubToken scheme. Restricting access to
        // the cookie scheme keeps the bearer issuance path from being
        // self-bootstrapped from a previous bearer.
        endpoints.MapGet(
                "/hubs/token",
                (HttpContext httpContext, HubTokenService tokens) =>
                {
                    string token = tokens.Issue(httpContext.User);
                    return Results.Json(new { token });
                })
            .RequireAuthorization(DashboardAuthenticationDefaults.ViewerPolicy)
            .WithName("DashboardHubToken");

        // Every dashboard Razor component requires a viewer-or-admin role. The
        // login/logout/denied endpoints above opt out via AllowAnonymous(); an
        // unauthenticated request to a component route is challenged by the
        // cookie scheme and redirected to /login.
        endpoints.MapRazorComponents<App>()
            .AddInteractiveServerRenderMode()
            .AddAdditionalAssemblies(typeof(ZB.MOM.WW.Secrets.Ui.SecretsPage).Assembly)
            .RequireAuthorization(DashboardAuthenticationDefaults.ViewerPolicy);

        return endpoints;
    }

    private static async Task<IResult> PostLoginAsync(
        HttpContext httpContext,
        IAntiforgery antiforgery,
        IDashboardAuthenticator authenticator)
    {
        await antiforgery.ValidateRequestAsync(httpContext).ConfigureAwait(false);

        IFormCollection form = await httpContext.Request
            .ReadFormAsync(httpContext.RequestAborted)
            .ConfigureAwait(false);
        string returnUrl = SanitizeReturnUrl(form["returnUrl"].ToString());

        DashboardAuthenticationResult result = await authenticator
            .AuthenticateAsync(
                form["username"].ToString(),
                form["password"].ToString(),
                httpContext.RequestAborted)
            .ConfigureAwait(false);

        if (!result.Succeeded || result.Principal is null)
        {
            // Round-trip the failure back to the anonymous Blazor /login page, carrying
            // the (sanitized) returnUrl so a successful retry still lands on the target.
            string failureMessage = result.FailureMessage
                ?? "The username or password is invalid, or the user is not authorized.";
            return Results.Redirect(
                $"/login?error={Uri.EscapeDataString(failureMessage)}"
                + $"&returnUrl={Uri.EscapeDataString(returnUrl)}");
        }

        await httpContext
            .SignInAsync(DashboardAuthenticationDefaults.AuthenticationScheme, result.Principal)
            .ConfigureAwait(false);

        return Results.LocalRedirect(returnUrl);
    }

    private static async Task<IResult> PostLogoutAsync(
        HttpContext httpContext,
        IAntiforgery antiforgery)
    {
        await antiforgery.ValidateRequestAsync(httpContext).ConfigureAwait(false);
        await httpContext
            .SignOutAsync(DashboardAuthenticationDefaults.AuthenticationScheme)
            .ConfigureAwait(false);

        return Results.LocalRedirect("/login");
    }

    private static async Task<IResult> GetLogoutAsync(HttpContext httpContext)
    {
        await httpContext
            .SignOutAsync(DashboardAuthenticationDefaults.AuthenticationScheme)
            .ConfigureAwait(false);

        return Results.LocalRedirect("/login");
    }

    private static string RenderPage(string title, string body)
        => RenderPage(title, heading: title, body);

    private static string RenderPage(string title, string? heading, string body)
    {
        string headingHtml = string.IsNullOrEmpty(heading)
            ? string.Empty
            : $"""
                <div class="dashboard-page-header">
                    <h1>{HtmlEncoder.Default.Encode(heading)}</h1>
                </div>
            """;

        return $"""
            <!doctype html>
            <html lang="en">
            <head>
                <meta charset="utf-8" />
                <meta name="viewport" content="width=device-width, initial-scale=1" />
                <title>{HtmlEncoder.Default.Encode(title)}</title>
                <link rel="stylesheet" href="/lib/bootstrap/css/bootstrap.min.css" />
                <link rel="stylesheet" href="/_content/ZB.MOM.WW.Theme/css/theme.css" />
                <link rel="stylesheet" href="/_content/ZB.MOM.WW.Theme/css/layout.css" />
                <link rel="stylesheet" href="/css/site.css" />
            </head>
            <body class="dashboard-body">
                <header class="app-bar">
                    <span class="brand"><span class="mark">&#9646;</span> MXAccess Gateway</span>
                </header>
                <main class="page">
                    {headingHtml}
                    {body}
                </main>
                <script src="/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
            </body>
            </html>
            """;
    }

    private static string SanitizeReturnUrl(string? returnUrl)
    {
        if (string.IsNullOrWhiteSpace(returnUrl)
            || !returnUrl.StartsWith("/", StringComparison.Ordinal)
            || returnUrl.StartsWith("//", StringComparison.Ordinal)
            || Uri.TryCreate(returnUrl, UriKind.Absolute, out _))
        {
            return "/";
        }

        return returnUrl;
    }
}
