using System.Net;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using ZB.MOM.WW.Overview.Observability;
using ZB.MOM.WW.Overview.Polling;

namespace ZB.MOM.WW.Overview.Tests;

/// <summary>
/// Boots the REAL <c>Program.cs</c> and asks the questions no in-process test can: does every
/// endpoint answer without a login, and does a bad registry stop the host rather than produce a
/// dashboard full of nothing.
/// </summary>
/// <remarks>
/// The registry is supplied through environment variables rather than
/// <c>ConfigureAppConfiguration</c>. <c>ConfigPreflight</c> runs against
/// <c>builder.Configuration</c> in the top-level statements, before <c>Build()</c> — which is when
/// the factory's configuration callbacks are applied — so a callback-supplied registry would arrive
/// too late for the very check these tests are about. Environment variables are read by
/// <c>CreateBuilder</c> itself, so they are in place from the first line.
///
/// <para>
/// Environment variables are process-global, so every test here lives in one class: xunit runs a
/// class's tests sequentially, which is what keeps two hosts from fighting over the same keys.
/// </para>
/// </remarks>
public sealed class BootTests : IDisposable
{
    private readonly List<string> _keys = [];

    private void Set(string key, string? value)
    {
        _keys.Add(key);
        Environment.SetEnvironmentVariable(key, value);
    }

    /// <summary>Restores the environment so a later-running test class sees a clean process.</summary>
    public void Dispose()
    {
        foreach (var key in _keys)
            Environment.SetEnvironmentVariable(key, null);
    }

    /// <summary>
    /// A registry with one instance pointing at a port nothing listens on. The dashboard must boot
    /// and serve regardless of whether anything it watches is up — that is the entire premise of
    /// rendering from cache.
    /// </summary>
    private void SetValidRegistry()
    {
        Set("ASPNETCORE_ENVIRONMENT", "Testing");
        Set("Overview__PollIntervalSeconds", "10");
        Set("Overview__TimeoutSeconds", "1");
        Set("Overview__StaleAfterSeconds", "45");
        Set("Overview__Applications__0__Name", "TestApp");
        Set("Overview__Applications__0__ManagementLabel", "UI");
        Set("Overview__Applications__0__Instances__0__Name", "node-a");
        Set("Overview__Applications__0__Instances__0__BaseUrl", "http://127.0.0.1:1");
        Set("Overview__Applications__0__Instances__0__HasActiveRole", "false");
    }

    private static WebApplicationFactory<Program> Factory() => new();

    [Fact]
    public async Task Page_IsServedAnonymously()
    {
        SetValidRegistry();
        using var factory = Factory();

        // AllowAutoRedirect off so an auth redirect would surface as a 302 here rather than being
        // silently followed to a login page that then returns 200.
        using var client = factory.CreateClient(new WebApplicationFactoryClientOptions
        {
            AllowAutoRedirect = false,
        });

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

        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
        Assert.Contains("SCADA Overview", await response.Content.ReadAsStringAsync(), StringComparison.Ordinal);
    }

    [Fact]
    public async Task Page_RendersEvenThoughNothingItWatchesIsReachable()
    {
        // Cache-first, stated as a test: the registry above points at a dead port, and the page
        // must still paint its full layout instead of waiting on a sweep.
        SetValidRegistry();
        using var factory = Factory();
        using var client = factory.CreateClient();

        var html = await client.GetStringAsync("/");

        Assert.Contains("data-testid=\"instance-card\"", html, StringComparison.Ordinal);
        Assert.Contains("node-a", html, StringComparison.Ordinal);
    }

    [Theory]
    [InlineData("/health/ready")]
    [InlineData("/health/active")]
    [InlineData("/healthz")]
    [InlineData("/metrics")]
    public async Task HealthAndMetrics_AreServedAnonymously(string path)
    {
        SetValidRegistry();
        using var factory = Factory();
        using var client = factory.CreateClient(new WebApplicationFactoryClientOptions
        {
            AllowAutoRedirect = false,
        });

        using var response = await client.GetAsync(path);

        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
    }

    [Fact]
    public async Task Ready_ReportsTheDashboardsOwnChecks()
    {
        SetValidRegistry();
        using var factory = Factory();
        using var client = factory.CreateClient();

        var body = await client.GetStringAsync("/health/ready");

        Assert.Contains(ObservabilityServiceCollectionExtensions.RegistryLoadedCheckName, body, StringComparison.Ordinal);
        Assert.Contains(ObservabilityServiceCollectionExtensions.PollCycleCheckName, body, StringComparison.Ordinal);
    }

    [Fact]
    public async Task Metrics_ExportsTheAppsOwnMeter()
    {
        // The Meters allowlist in AddZbTelemetry is silent when it is wrong: a missing name drops
        // the app's whole instrument surface from /metrics with no warning anywhere. Only an
        // end-to-end scrape catches that.
        SetValidRegistry();
        using var factory = Factory();
        using var client = factory.CreateClient();

        // Force a sweep so the observable gauge has a probed instance to report. The instance is
        // unreachable, which is a perfectly good status to publish.
        var poller = factory.Services.GetServices<IHostedService>().OfType<OverviewPollerService>().Single();
        await poller.SweepAsync();

        var body = await client.GetStringAsync("/metrics");

        Assert.Contains("overview_instance_status", body, StringComparison.Ordinal);
        Assert.Contains("node=\"node-a\"", body, StringComparison.Ordinal);
    }

    [Fact]
    public async Task MissingRegistry_FailsToBootAndNamesTheKey()
    {
        // appsettings.json ships an EMPTY Applications list precisely so this is the outcome when a
        // deployment forgets its registry — an empty dashboard that boots would look like a healthy
        // fleet of nothing.
        Set("ASPNETCORE_ENVIRONMENT", "Testing");

        using var factory = Factory();

        var error = await Record.ExceptionAsync(() => factory.CreateClient().GetAsync("/"));

        Assert.NotNull(error);
        Assert.Contains("Overview:Applications:0:Name", Flatten(error), StringComparison.Ordinal);
    }

    [Fact]
    public async Task StaleWindowInsideThePollInterval_FailsToBootWithTheValidatorMessage()
    {
        // Past preflight and into the validator: a staleness window shorter than the poll interval
        // would mark every instance Stale between two perfectly healthy polls.
        SetValidRegistry();
        Set("Overview__StaleAfterSeconds", "5");

        using var factory = Factory();

        var error = await Record.ExceptionAsync(() => factory.CreateClient().GetAsync("/"));

        Assert.NotNull(error);
        Assert.Contains("StaleAfterSeconds", Flatten(error), StringComparison.Ordinal);
    }

    private static string Flatten(Exception exception)
    {
        var text = new System.Text.StringBuilder();

        for (var current = exception; current is not null; current = current.InnerException)
            text.AppendLine(current.Message);

        return text.ToString();
    }
}
