using System.Net;
using System.Net.Http;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.MxGateway.Server;
using ZB.MOM.WW.MxGateway.Server.Dashboard;
using ZB.MOM.WW.MxGateway.Server.Metrics;

namespace ZB.MOM.WW.MxGateway.Tests.Gateway;

public sealed class GatewayApplicationTests
{
    /// <summary>Verifies that Build maps the canonical three health tiers.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task Build_MapsCanonicalHealthEndpoints()
    {
        await using WebApplication app = GatewayApplication.Build([]);

        var paths = ((IEndpointRouteBuilder)app).DataSources
            .SelectMany(dataSource => dataSource.Endpoints)
            .OfType<RouteEndpoint>()
            .Select(e => e.RoutePattern.RawText)
            .ToHashSet();

        Assert.Contains("/health/ready", paths);
        Assert.Contains("/health/active", paths);
        Assert.Contains("/healthz", paths);
        Assert.DoesNotContain("/health/live", paths);
    }

    /// <summary>Verifies that Build registers Serilog as the host logging provider.</summary>
    [Fact]
    public void Build_UsesSerilogLoggerProvider()
    {
        using var app = GatewayApplication.Build([]);
        var factory = app.Services.GetRequiredService<ILoggerFactory>();
        Assert.Equal("SerilogLoggerFactory", factory.GetType().Name);
    }

    /// <summary>
    /// Verifies that Build registers the gateway's browse-scope provider so it wins over the
    /// shared library's no-op default. The per-API-key browse-subtree scoping
    /// depends on <c>AddSingleton&lt;IGalaxyBrowseScopeProvider, GatewayBrowseScopeProvider&gt;</c>
    /// running before <c>AddZbGalaxyRepository</c>, whose <c>TryAddSingleton</c> default
    /// (NullGalaxyBrowseScopeProvider → full hierarchy) must lose. If this registration order ever
    /// regressed, every metadata-scoped key would silently see the entire Galaxy hierarchy.
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task Build_RegistersGatewayBrowseScopeProviderOverLibraryDefault()
    {
        await using WebApplication app = GatewayApplication.Build([]);

        ZB.MOM.WW.GalaxyRepository.Grpc.IGalaxyBrowseScopeProvider scopeProvider =
            app.Services.GetRequiredService<ZB.MOM.WW.GalaxyRepository.Grpc.IGalaxyBrowseScopeProvider>();

        Assert.IsType<ZB.MOM.WW.MxGateway.Server.Security.Authorization.GatewayBrowseScopeProvider>(scopeProvider);
    }

    /// <summary>Verifies that Build registers the gateway metrics service.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task Build_RegistersGatewayMetrics()
    {
        await using WebApplication app = GatewayApplication.Build([]);

        GatewayMetrics metrics = app.Services.GetRequiredService<GatewayMetrics>();

        Assert.NotNull(metrics);
    }

    /// <summary>Verifies that Build mounts the Prometheus /metrics scrape endpoint.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task Build_MapsMetricsEndpoint()
    {
        // Bind an ephemeral port (:0) — xUnit runs test collections in parallel, so any
        // started-host test must avoid a fixed port to prevent a bind collision. The auth SQLite
        // store path is isolated to a per-process temp file by TestHostEnvironmentInitializer (SEC-33).
        await using WebApplication app = GatewayApplication.Build(["--urls=http://127.0.0.1:0"]);
        await app.StartAsync();
        try
        {
            using var client = new HttpClient { BaseAddress = new Uri(app.Urls.First()) };

            using HttpResponseMessage response = await client.GetAsync("/metrics");

            Assert.Equal(HttpStatusCode.OK, response.StatusCode);
        }
        finally
        {
            await app.StopAsync();
        }
    }

    /// <summary>Verifies that Build maps dashboard and authentication endpoints when the dashboard is enabled.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task Build_WhenDashboardEnabled_MapsBlazorDashboardAndAuthEndpoints()
    {
        await using WebApplication app = GatewayApplication.Build([]);
        IReadOnlyList<RouteEndpoint> endpoints = GetRouteEndpoints(app);

        Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/");
        Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/sessions");
        Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/workers");
        Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/events");
        Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/settings");

        // GET /login is served by the [AllowAnonymous] Blazor <Login> component
        // (Components/Pages/Login.razor → @page "/login"), not a named minimal-API
        // endpoint. The credential POST goes to the DashboardLoginPost endpoint at
        // /auth/login — a DISTINCT route. The Blazor component endpoint matches all HTTP
        // methods, so sharing the "/login" route with MapPost previously made POST /login
        // ambiguous (AmbiguousMatchException → HTTP 500). Pinning the POST to /auth/login
        // is the regression guard for that fix.
        Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/login"
            && endpoint.Metadata.GetMetadata<Microsoft.AspNetCore.Components.Endpoints.ComponentTypeMetadata>() is not null);
        Assert.Contains(endpoints, endpoint =>
            endpoint.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName == "DashboardLoginPost"
            && endpoint.RoutePattern.RawText == "/auth/login");
        Assert.Contains(endpoints, endpoint =>
            endpoint.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName == "DashboardLogout");
    }

    /// <summary>Verifies that the dashboard login, logout, and denied endpoints allow anonymous access.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task Build_WhenDashboardEnabled_AuthEndpointsAllowAnonymousAccess()
    {
        await using WebApplication app = GatewayApplication.Build([]);
        IReadOnlyList<RouteEndpoint> endpoints = GetRouteEndpoints(app);

        string[] anonymousEndpointNames =
            ["DashboardLoginPost", "DashboardLogout", "DashboardLogoutGet", "DashboardAccessDenied"];
        foreach (string endpointName in anonymousEndpointNames)
        {
            RouteEndpoint endpoint = Assert.Single(
                endpoints,
                candidate => candidate.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName == endpointName);

            Assert.NotNull(endpoint.Metadata.GetMetadata<IAllowAnonymous>());
        }

        // GET /login is the [AllowAnonymous] Blazor <Login> component route. Its
        // [AllowAnonymous] attribute overrides the RequireAuthorization(ViewerPolicy)
        // that MapRazorComponents<App>() applies, so the LoginPath="/login" redirect
        // resolves for unauthenticated users instead of looping the cookie challenge.
        RouteEndpoint loginComponent = Assert.Single(
            endpoints,
            candidate => candidate.RoutePattern.RawText == "/login"
                && candidate.Metadata.GetMetadata<Microsoft.AspNetCore.Components.Endpoints.ComponentTypeMetadata>() is not null);
        Assert.NotNull(loginComponent.Metadata.GetMetadata<IAllowAnonymous>());
    }

    /// <summary>Verifies that dashboard Razor component routes require the dashboard viewer policy.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task Build_WhenDashboardEnabled_ComponentRoutesRequireAuthorization()
    {
        await using WebApplication app = GatewayApplication.Build([]);
        IReadOnlyList<RouteEndpoint> endpoints = GetRouteEndpoints(app);

        // Razor-component endpoints are distinguished from minimal-API
        // endpoints registered at the same path by the presence of
        // ComponentTypeMetadata. Filter to those before checking auth.
        string[] componentRoutes = ["/", "/sessions", "/workers", "/events", "/settings"];
        foreach (string route in componentRoutes)
        {
            RouteEndpoint[] matches = endpoints
                .Where(endpoint => endpoint.RoutePattern.RawText == route
                    && endpoint.Metadata.GetMetadata<Microsoft.AspNetCore.Components.Endpoints.ComponentTypeMetadata>() is not null)
                .ToArray();

            Assert.NotEmpty(matches);
            Assert.All(matches, endpoint =>
            {
                IAuthorizeData? authorize = endpoint.Metadata.GetMetadata<IAuthorizeData>();
                Assert.NotNull(authorize);
                Assert.Equal(DashboardAuthenticationDefaults.ViewerPolicy, authorize.Policy);
            });
        }
    }

    /// <summary>Verifies that dashboard routes are registered at root when enabled.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task Build_WhenDashboardEnabled_RegistersDashboardRoutesAtRoot()
    {
        await using WebApplication app = GatewayApplication.Build([]);
        IReadOnlyList<RouteEndpoint> endpoints = GetRouteEndpoints(app);

        string[] canonicalRoutes =
        [
            "/",
            "/sessions",
            "/workers",
            "/events",
            "/settings",
            "/galaxy",
            "/apikeys",
            "/sessions/{SessionId}",

            // Mounted from the ZB.MOM.WW.Secrets.Ui RCL rather than declared here, so it is the
            // one nav destination that a routing regression could remove without touching this
            // repo's own pages. The side rail links to it (role-gated), which makes an unmapped
            // route a visible dead link rather than a silent absence.
            "/admin/secrets",
        ];
        foreach (string canonical in canonicalRoutes)
        {
            Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == canonical
                && endpoint.Metadata.GetMetadata<Microsoft.AspNetCore.Components.Endpoints.ComponentTypeMetadata>() is not null);
        }
    }

    /// <summary>Verifies that dashboard routes are not mapped when disabled.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task Build_WhenDashboardDisabled_DoesNotMapDashboardRoutes()
    {
        await using WebApplication app = GatewayApplication.Build(["--MxGateway:Dashboard:Enabled=false"]);
        IReadOnlyList<RouteEndpoint> endpoints = GetRouteEndpoints(app);

        Assert.DoesNotContain(endpoints, endpoint =>
            endpoint.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName?.StartsWith(
                "Dashboard",
                StringComparison.Ordinal) == true);
    }

    /// <summary>Verifies that StartAsync fails when gateway configuration is invalid.</summary>
    /// <param name="key">Configuration key to override.</param>
    /// <param name="value">Invalid configuration value.</param>
    /// <param name="expectedFailure">Expected validation error message.</param>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Theory]
    [InlineData(
        "MxGateway:Worker:ExecutablePath",
        "worker.dll",
        "MxGateway:Worker:ExecutablePath must point to a .exe file.")]
    [InlineData(
        "MxGateway:Events:QueueCapacity",
        "0",
        "MxGateway:Events:QueueCapacity must be greater than zero.")]
    [InlineData(
        "MxGateway:Authentication:PepperSecretName",
        "",
        "MxGateway:Authentication:PepperSecretName is required")]
    [InlineData(
        "MxGateway:Dashboard:GroupToRole:GwAdmin",
        "BogusRole",
        "MxGateway:Dashboard:GroupToRole['GwAdmin'] must be 'Administrator' or 'Viewer'.")]
    [InlineData(
        "MxGateway:Ldap:AllowInsecure",
        "false",
        "MxGateway:Ldap:AllowInsecure must be true when Transport is None (plaintext).")]
    public async Task StartAsync_InvalidGatewayConfiguration_FailsStartup(
        string key,
        string value,
        string expectedFailure)
    {
        // Bind an ephemeral port (:0) — xUnit runs test collections in parallel, so any
        // WebApplication-building test must avoid a fixed port to prevent a bind collision. The auth
        // store path is isolated by TestHostEnvironmentInitializer (SEC-33), so startup opens a
        // writable store and the injected misconfiguration is what fails validation.
        await using WebApplication app = GatewayApplication.Build(
            [$"--{key}={value}", "--urls=http://127.0.0.1:0"]);

        OptionsValidationException exception = await Assert.ThrowsAsync<OptionsValidationException>(
            () => app.StartAsync());

        Assert.Contains(
            exception.Failures,
            failure => failure.Contains(expectedFailure, StringComparison.Ordinal));
    }

    private static IReadOnlyList<RouteEndpoint> GetRouteEndpoints(WebApplication app)
    {
        return ((IEndpointRouteBuilder)app).DataSources
            .SelectMany(dataSource => dataSource.Endpoints)
            .OfType<RouteEndpoint>()
            .ToArray();
    }
}
