using Bunit;
using ZB.MOM.WW.Overview.Components.Widgets;
using ZB.MOM.WW.Overview.Polling;

namespace ZB.MOM.WW.Overview.Tests;

/// <summary>
/// The instance card is where a reading becomes a decision, so the tests pin what an operator can
/// actually distinguish at a glance: which of the four states is shown, whether it is still
/// current, and whether the raw evidence behind it is visible.
/// </summary>
public class InstanceCardTests : TestContext
{
    private static readonly DateTimeOffset Now = new(2026, 7, 24, 12, 0, 0, TimeSpan.Zero);

    private static InstanceSnapshot Card(
        InstanceStatus? status = InstanceStatus.Up,
        ActiveState active = ActiveState.NotApplicable,
        DateTimeOffset? lastPolled = null,
        DateTimeOffset? lastReachable = null,
        TimeSpan? staleAfter = null,
        string? error = null,
        ZbHealthReport? report = null,
        string? managementUrl = null) =>
        new()
        {
            Application = "ScadaBridge",
            Instance = "site-a-a",
            Group = "site-a",
            BaseUrl = "http://site-a-a:8084",
            ManagementUrl = managementUrl,
            ManagementLabel = "AdminUI",
            HasActiveRole = active != ActiveState.NotApplicable,
            Status = status,
            Active = active,
            LastPolledUtc = lastPolled ?? Now,
            LastReachableUtc = lastReachable ?? Now,
            Latency = TimeSpan.FromMilliseconds(18),
            Error = error,
            Report = report,
            StaleAfter = staleAfter ?? TimeSpan.FromSeconds(45),
        };

    private IRenderedComponent<InstanceCard> Render(InstanceSnapshot instance, bool isLeader = false) =>
        RenderComponent<InstanceCard>(p => p
            .Add(c => c.Instance, instance)
            .Add(c => c.Now, Now)
            .Add(c => c.IsLeader, isLeader));

    [Theory]
    [InlineData(InstanceStatus.Up, "up", "Up")]
    [InlineData(InstanceStatus.Degraded, "degraded", "Degraded")]
    [InlineData(InstanceStatus.Down, "down", "Down")]
    [InlineData(InstanceStatus.Unreachable, "unreachable", "Unreachable")]
    public void Card_CarriesItsStatusAsBothClassAndLabel(InstanceStatus status, string cssClass, string label)
    {
        var card = Render(Card(status));

        Assert.Contains(cssClass, card.Find("article").ClassList, StringComparer.Ordinal);
        Assert.Contains(label, card.Markup, StringComparison.Ordinal);
    }

    [Fact]
    public void UnreachableAndDown_AreVisuallyDistinct_NotJustDifferentWords()
    {
        // The CSS gives Unreachable a dashed border precisely because both are red. If the class
        // did not differ, the two would be indistinguishable on a wall display.
        var down = Render(Card(InstanceStatus.Down)).Find("article").ClassList;
        var unreachable = Render(Card(InstanceStatus.Unreachable)).Find("article").ClassList;

        Assert.NotEqual(down, unreachable);
    }

    [Fact]
    public void NeverPolled_RendersPending_NotHealthy()
    {
        var card = Render(Card(status: null, lastPolled: null));

        Assert.Contains("pending", card.Find("article").ClassList, StringComparer.Ordinal);
        Assert.Contains("Pending", card.Markup, StringComparison.Ordinal);
        Assert.DoesNotContain(">Up<", card.Markup, StringComparison.Ordinal);
    }

    [Fact]
    public void StaleCard_IsMarkedStaleButKeepsItsLastKnownStatusClass()
    {
        // Both facts matter: the reading was Up, and it is no longer current. Dropping either one
        // loses information the operator needs.
        var card = Render(Card(lastPolled: Now.AddMinutes(-5)));

        var classes = card.Find("article").ClassList;
        Assert.Contains("stale", classes, StringComparer.Ordinal);
        Assert.Contains("up", classes, StringComparer.Ordinal);
        Assert.Contains("Stale", card.Markup, StringComparison.Ordinal);
    }

    [Fact]
    public void StaleCard_CountsFromLastContact_NotFromTheLastPollAttempt()
    {
        // The poller kept trying for ten minutes and kept failing. "Stale since 5 s ago" would
        // suggest the instance was fine moments ago; it has actually been gone for ten minutes.
        var card = Render(Card(
            status: InstanceStatus.Unreachable,
            lastPolled: Now.AddSeconds(-5),
            lastReachable: Now.AddMinutes(-10),
            staleAfter: TimeSpan.FromSeconds(1)));

        Assert.Contains("Stale since", card.Markup, StringComparison.Ordinal);
        Assert.Contains("10 min ago", card.Markup, StringComparison.Ordinal);
    }

    [Theory]
    [InlineData(ActiveState.Active, "Active")]
    [InlineData(ActiveState.Standby, "Standby")]
    [InlineData(ActiveState.Unknown, "Role ?")]
    public void ActiveState_IsBadged(ActiveState state, string expected)
    {
        Assert.Contains(expected, Render(Card(active: state)).Markup, StringComparison.Ordinal);
    }

    [Fact]
    public void NoActiveRole_ShowsNoActivityBadgeAtAll()
    {
        // A single-instance gateway has no active/standby concept; a badge would invent one.
        var markup = Render(Card(active: ActiveState.NotApplicable)).Markup;

        Assert.DoesNotContain("Standby", markup, StringComparison.Ordinal);
        Assert.DoesNotContain("Role ?", markup, StringComparison.Ordinal);
        Assert.DoesNotContain("· active", markup, StringComparison.Ordinal);
    }

    [Fact]
    public void LeaderBadge_IsShownOnlyWhenTheCardIsTheLeader()
    {
        Assert.Contains("Leader", Render(Card(), isLeader: true).Markup, StringComparison.Ordinal);
        Assert.DoesNotContain("Leader", Render(Card(), isLeader: false).Markup, StringComparison.Ordinal);
    }

    [Fact]
    public void Checks_AreListedWithAHealthyCount()
    {
        var card = Render(Card(report: Report(("localdb", "Healthy", null), ("akka-cluster", "Unhealthy", null))));

        Assert.Contains("1 / 2 healthy", card.Markup, StringComparison.Ordinal);
        Assert.Equal(2, card.FindAll(".check-row").Count);
    }

    [Fact]
    public void ClusterData_IsRenderedAsRawEvidence()
    {
        var card = Render(Card(report: Report(("akka-cluster", "Healthy", "akka.tcp://scadabridge@site-a-a:8082"))));

        var data = card.Find(".check-data");
        Assert.Contains("akka.tcp://scadabridge@site-a-a:8082", data.TextContent, StringComparison.Ordinal);
        Assert.Contains("leader", data.TextContent, StringComparison.Ordinal);
    }

    [Fact]
    public void ClusterData_FromAnInstanceWithoutIt_IsOmittedEntirely()
    {
        // Every pre-0.2.0 node, and every check that publishes no data. The card must simply not
        // show the line rather than showing an empty one.
        var card = Render(Card(report: Report(("localdb", "Healthy", null))));

        Assert.Empty(card.FindAll(".check-data"));
    }

    [Fact]
    public void ClusterData_IsHtmlEscaped()
    {
        // The value is remote input from another application. Operationally trusted, but a health
        // check that ever emits markup must not be able to inject it into this page.
        var card = Render(Card(report: Report(("akka-cluster", "Healthy", "<img src=x onerror=alert(1)>"))));

        var data = card.Find(".check-data");
        Assert.Empty(data.QuerySelectorAll("img"));
        Assert.Contains("<img src=x onerror=alert(1)>", data.TextContent, StringComparison.Ordinal);
    }

    [Fact]
    public void ManagementLink_OpensInANewTabWithNoopener()
    {
        var link = Render(Card(managementUrl: "http://site-a-a:9000/")).Find(".panel-foot a");

        Assert.Equal("http://site-a-a:9000/", link.GetAttribute("href"));
        Assert.Equal("_blank", link.GetAttribute("target"));
        Assert.Equal("noopener", link.GetAttribute("rel"));
        Assert.Contains("AdminUI", link.TextContent, StringComparison.Ordinal);
    }

    [Fact]
    public void NoManagementUrl_RendersNoDeadLink()
    {
        var card = Render(Card(managementUrl: null));

        Assert.Empty(card.FindAll(".panel-foot a"));
        Assert.Contains("no management link", card.Markup, StringComparison.Ordinal);
    }

    [Theory]
    [InlineData(InstanceStatus.Up, ActiveState.Active, "ready 200 · active 200")]
    [InlineData(InstanceStatus.Up, ActiveState.Standby, "ready 200 · active 503")]
    [InlineData(InstanceStatus.Down, ActiveState.NotApplicable, "ready 503")]
    [InlineData(InstanceStatus.Unreachable, ActiveState.NotApplicable, "ready fail")]
    public void RawSignalLine_ShowsWhatWasActuallyReceived(
        InstanceStatus status, ActiveState active, string expected)
    {
        // The footer is the escape hatch from every derived label above it: when the dashboard and
        // a curl disagree, this line is what reconciles them.
        Assert.Contains(
            expected,
            Render(Card(status, active)).Find("[data-testid=raw-signals]").TextContent,
            StringComparison.Ordinal);
    }

    [Fact]
    public void ProbeError_IsSurfacedOnTheCard()
    {
        var card = Render(Card(InstanceStatus.Unreachable, error: "Connection refused"));

        Assert.Contains("Connection refused", card.Markup, StringComparison.Ordinal);
    }

    [Fact]
    public void Host_IsShownWithoutTheScheme()
    {
        Assert.Contains("site-a-a:8084", Render(Card()).Find(".inst-host").TextContent, StringComparison.Ordinal);
    }

    [Theory]
    [InlineData(4, "4 s ago")]
    [InlineData(90, "2 min ago")]
    [InlineData(7200, "2 h ago")]
    [InlineData(172800, "2 d ago")]
    public void Relative_ScalesTheUnit(int seconds, string expected)
    {
        Assert.Equal(expected, InstanceCard.Relative(TimeSpan.FromSeconds(seconds)));
    }

    [Fact]
    public void Relative_NegativeAge_ReadsAsNow_NotAsTheFuture()
    {
        // Clock skew between this host and a probed one is routine; "-3 s ago" is nonsense.
        Assert.Equal("0 s ago", InstanceCard.Relative(TimeSpan.FromSeconds(-3)));
    }

    private static ZbHealthReport Report(params (string Name, string Status, string? Leader)[] entries) =>
        System.Text.Json.JsonSerializer.Deserialize<ZbHealthReport>(HealthBody.Ready("Healthy", entries))!;
}
