using Bunit;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.Overview.Polling;

namespace ZB.MOM.WW.Overview.Tests;

/// <summary>
/// The page as a whole: the KPI strip an operator scans first, the group headers that carry the
/// split-brain warning, and the pause control — which has to freeze the view without pretending
/// the view is still live.
/// </summary>
public class OverviewPageTests : TestContext
{
    private static readonly DateTimeOffset Now = new(2026, 7, 24, 12, 0, 0, TimeSpan.Zero);

    private readonly OverviewSnapshotStore _store = new();
    private readonly FakeTimeProvider _time = new(Now);

    public OverviewPageTests()
    {
        Services.AddSingleton(_store);
        Services.AddSingleton<TimeProvider>(_time);
    }

    private static InstanceSnapshot Node(
        string name,
        InstanceStatus? status,
        string? group = null,
        string? leader = null,
        DateTimeOffset? lastPolled = null) =>
        new()
        {
            Application = "ScadaBridge",
            Instance = name,
            Group = group,
            BaseUrl = $"http://{name}:8084",
            HasActiveRole = group is not null,
            Status = status,
            LastPolledUtc = lastPolled ?? Now,
            LastReachableUtc = lastPolled ?? Now,
            StaleAfter = TimeSpan.FromSeconds(45),
            Report = leader is null
                ? null
                : System.Text.Json.JsonSerializer.Deserialize<ZbHealthReport>(
                    HealthBody.Ready("Healthy", ("akka-cluster", "Healthy", leader))),
        };

    private static OverviewSnapshot Snapshot(params InstanceSnapshot[] instances)
    {
        var applications = instances
            .GroupBy(i => i.Application, StringComparer.Ordinal)
            .Select(g => new ApplicationSnapshot(g.Key, g.ToList(), LeaderResolver.Resolve(g.ToList())))
            .ToList();

        return new OverviewSnapshot(Now, applications);
    }

    private IRenderedComponent<Components.Pages.Overview> RenderPage() =>
        RenderComponent<Components.Pages.Overview>();

    [Fact]
    public void EmptyStore_RendersAnExplicitEmptyState()
    {
        // Before the first sweep. It must say so rather than render a KPI strip full of zeroes,
        // which would read as "the whole fleet is registered and nothing is up".
        var page = RenderPage();

        Assert.NotNull(page.Find("[data-testid=empty-registry]"));
        Assert.Empty(page.FindAll(".agg-grid"));
    }

    [Fact]
    public void KpiStrip_CountsEachStatus()
    {
        _store.Publish(Snapshot(
            Node("a", InstanceStatus.Up),
            Node("b", InstanceStatus.Up),
            Node("c", InstanceStatus.Degraded),
            Node("d", InstanceStatus.Down),
            Node("e", InstanceStatus.Unreachable)));

        var page = RenderPage();

        Assert.Equal("2", page.Find("[data-testid=kpi-up]").TextContent);
        Assert.Equal("1", page.Find("[data-testid=kpi-degraded]").TextContent);
        Assert.Equal("1", page.Find("[data-testid=kpi-down]").TextContent);
        Assert.Equal("1", page.Find("[data-testid=kpi-unreachable]").TextContent);
        Assert.Equal("0", page.Find("[data-testid=kpi-stale]").TextContent);
    }

    [Fact]
    public void StaleInstances_CountAsStaleAndNotAlsoAsUp()
    {
        // Double-counting would make the KPI totals exceed the instance count and would let a
        // frozen dashboard keep reporting a healthy fleet.
        _store.Publish(Snapshot(
            Node("a", InstanceStatus.Up),
            Node("b", InstanceStatus.Up, lastPolled: Now.AddMinutes(-5))));

        var page = RenderPage();

        Assert.Equal("1", page.Find("[data-testid=kpi-up]").TextContent);
        Assert.Equal("1", page.Find("[data-testid=kpi-stale]").TextContent);
    }

    [Fact]
    public void GroupHeader_NamesTheLeader()
    {
        const string leader = "akka.tcp://scadabridge@a:8082";
        _store.Publish(Snapshot(
            Node("a", InstanceStatus.Up, "site-a", leader),
            Node("b", InstanceStatus.Up, "site-a", leader)));

        Assert.Contains("Leader · a", RenderPage().Markup, StringComparison.Ordinal);
    }

    [Fact]
    public void GroupHeader_WarnsOnDisagreement()
    {
        // The one output on this page that means "go look right now".
        _store.Publish(Snapshot(
            Node("a", InstanceStatus.Up, "site-a", "akka.tcp://scadabridge@a:8082"),
            Node("b", InstanceStatus.Up, "site-a", "akka.tcp://scadabridge@b:8082")));

        Assert.Contains("Split brain", RenderPage().Markup, StringComparison.Ordinal);
    }

    [Fact]
    public void AgreeingGroup_ShowsNoWarning()
    {
        const string leader = "akka.tcp://scadabridge@a:8082";
        _store.Publish(Snapshot(
            Node("a", InstanceStatus.Up, "site-a", leader),
            Node("b", InstanceStatus.Up, "site-a", leader)));

        Assert.DoesNotContain("Split brain", RenderPage().Markup, StringComparison.Ordinal);
    }

    [Fact]
    public void PublishedSnapshot_ReRendersThePage()
    {
        _store.Publish(Snapshot(Node("a", InstanceStatus.Up)));
        var page = RenderPage();
        Assert.Equal("1", page.Find("[data-testid=kpi-up]").TextContent);

        _store.Publish(Snapshot(Node("a", InstanceStatus.Down)));

        page.WaitForAssertion(() => Assert.Equal("0", page.Find("[data-testid=kpi-up]").TextContent));
        Assert.Equal("1", page.Find("[data-testid=kpi-down]").TextContent);
    }

    [Fact]
    public void Paused_StopsAdoptingNewSnapshots()
    {
        _store.Publish(Snapshot(Node("a", InstanceStatus.Up)));
        var page = RenderPage();

        page.Find("[data-testid=pause-toggle]").Click();
        _store.Publish(Snapshot(Node("a", InstanceStatus.Down)));

        Assert.Equal("1", page.Find("[data-testid=kpi-up]").TextContent);
        Assert.Contains("Resume auto-refresh", page.Markup, StringComparison.Ordinal);
    }

    [Fact]
    public void Resumed_AdoptsWhateverTheStoreHoldsNow()
    {
        // Resume must not replay the snapshots missed while paused — it must jump to current.
        _store.Publish(Snapshot(Node("a", InstanceStatus.Up)));
        var page = RenderPage();
        page.Find("[data-testid=pause-toggle]").Click();
        _store.Publish(Snapshot(Node("a", InstanceStatus.Down)));

        page.Find("[data-testid=pause-toggle]").Click();

        Assert.Equal("1", page.Find("[data-testid=kpi-down]").TextContent);
    }

    [Fact]
    public void PausedView_AgesIntoStaleOnItsOwn()
    {
        // This is why pause freezes adoption rather than stopping the clock: a paused dashboard
        // greys out by itself, so it cannot be mistaken for a live one.
        _store.Publish(Snapshot(Node("a", InstanceStatus.Up)));
        var page = RenderPage();
        page.Find("[data-testid=pause-toggle]").Click();

        _time.Advance(TimeSpan.FromMinutes(5));
        page.Render();

        Assert.Equal("1", page.Find("[data-testid=kpi-stale]").TextContent);
        Assert.Equal("0", page.Find("[data-testid=kpi-up]").TextContent);
    }

    [Fact]
    public void Disposal_UnsubscribesFromTheStore()
    {
        // A leaked handler would call StateHasChanged on a disposed renderer every sweep, for the
        // life of the process — one leak per page load.
        _store.Publish(Snapshot(Node("a", InstanceStatus.Up)));
        var page = RenderPage();

        page.Instance.Dispose();

        var exception = Record.Exception(() => _store.Publish(Snapshot(Node("a", InstanceStatus.Down))));
        Assert.Null(exception);
    }

    [Fact]
    public void ApplicationSection_CarriesAnAnchorMatchingTheRailLink()
    {
        // The rail's application links are fragment anchors; a mismatch makes every one dead.
        _store.Publish(Snapshot(Node("a", InstanceStatus.Up)));

        var section = RenderPage().Find("[data-testid=application]");

        Assert.Equal(
            ZB.MOM.WW.Overview.Components.Layout.MainLayout.Slug("ScadaBridge"),
            section.GetAttribute("id"));
    }

    [Fact]
    public void OneCardPerRegisteredInstance()
    {
        _store.Publish(Snapshot(
            Node("a", InstanceStatus.Up, "site-a"),
            Node("b", InstanceStatus.Up, "site-a"),
            Node("gw", InstanceStatus.Up)));

        Assert.Equal(3, RenderPage().FindAll("[data-testid=instance-card]").Count);
    }

    [Fact]
    public void UngroupedInstances_RenderAfterTheClusterGroups()
    {
        _store.Publish(Snapshot(
            Node("gw", InstanceStatus.Up),
            Node("a", InstanceStatus.Up, "site-a")));

        // Enumerated, not indexed: bunit 1.40 was built against AngleSharp 1.2 and its
        // RefreshableElementCollection indexer calls an IHtmlCollection member whose signature
        // changed by 1.5.2 — the version this project pins for GHSA reasons. LINQ goes through
        // IEnumerable and is unaffected.
        var cards = RenderPage()
            .FindAll("[data-testid=instance-card]")
            .Select(c => c.GetAttribute("data-instance"))
            .ToList();

        Assert.Equal(["a", "gw"], cards);
    }
}
