using System.Security.Claims;
using Akka.Actor;
using Bunit;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Notifications;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.Security;
using NotificationKpisPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Notifications.NotificationKpis;

namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages;

/// <summary>
/// bUnit rendering tests for the Notification KPIs page.
///
/// Testability note: <see cref="CommunicationService"/> is a concrete class with
/// non-virtual methods, so NSubstitute cannot intercept it. Both the global and
/// per-site KPI calls route through an injected <see cref="IActorRef"/> (the
/// notification-outbox proxy), so the tests wire a real, lightweight
/// <see cref="ActorSystem"/> with a scripted <see cref="ReceiveActor"/> that
/// answers both <see cref="NotificationKpiRequest"/> and
/// <see cref="PerSiteNotificationKpiRequest"/> — the same seam
/// <c>SetNotificationOutbox</c> exists for.
/// </summary>
public class NotificationKpisPageTests : BunitContext
{
    private readonly ActorSystem _system = ActorSystem.Create("notif-kpis-tests");
    private readonly CommunicationService _comms;

    // Mutable scripted replies — individual tests can override before rendering.
    private NotificationKpiResponse _kpiReply =
        new("k", true, null, QueueDepth: 7, StuckCount: 2, ParkedCount: 1,
            DeliveredLastInterval: 42, OldestPendingAge: TimeSpan.FromMinutes(9));

    private PerSiteNotificationKpiResponse _perSiteReply =
        new("p", true, null, new List<SiteNotificationKpiSnapshot>
        {
            new("plant-a", QueueDepth: 4, StuckCount: 1, ParkedCount: 0,
                DeliveredLastInterval: 9, OldestPendingAge: TimeSpan.FromMinutes(7)),
        });

    // KPI-history trend service (K13 / T11). Default substitute returns a
    // 3-point series for any (source, metric, scope, …) tuple so every chart
    // renders a polyline; individual tests can re-stub it (e.g. to throw).
    private readonly IKpiHistoryQueryService _kpiHistory = Substitute.For<IKpiHistoryQueryService>();

    private static IReadOnlyList<KpiSeriesPoint> SampleSeries() => new List<KpiSeriesPoint>
    {
        new(DateTime.UtcNow.AddHours(-3), 3),
        new(DateTime.UtcNow.AddHours(-2), 5),
        new(DateTime.UtcNow.AddHours(-1), 4),
    };

    public NotificationKpisPageTests()
    {
        _comms = new CommunicationService(
            Options.Create(new CommunicationOptions()),
            NullLogger<CommunicationService>.Instance);

        var outbox = _system.ActorOf(Props.Create(() => new ScriptedOutboxActor(this)));
        _comms.SetNotificationOutbox(outbox);

        Services.AddSingleton(_comms);

        // arch-review WP2.4 — the page reads the process-level memoized KPI cache
        // rather than querying CommunicationService per circuit. The real cache is
        // registered so the scripted-actor seam above still answers the queries.
        Services.AddSingleton<IKpiSnapshotCache>(sp => new KpiSnapshotCache(
            sp.GetRequiredService<CommunicationService>(),
            sp.GetRequiredService<IServiceScopeFactory>()));

        var siteRepo = Substitute.For<ISiteRepository>();
        siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
            .Returns(Task.FromResult<IReadOnlyList<Site>>(new List<Site>
            {
                new("Plant A", "plant-a") { Id = 1 },
                new("Plant B", "plant-b") { Id = 2 },
            }));
        Services.AddSingleton(siteRepo);

        _kpiHistory.GetSeriesAsync(
                Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
                Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<int?>(), Arg.Any<CancellationToken>())
            .Returns(_ => Task.FromResult(SampleSeries()));
        Services.AddSingleton(_kpiHistory);

        var claims = new[]
        {
            new Claim(JwtTokenService.UsernameClaimType, "tester"),
            new Claim(JwtTokenService.RoleClaimType, "Deployer"),
        };
        var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
        Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
        Services.AddAuthorizationCore();
    }

    [Fact]
    public void Page_RequiresDeploymentPolicy()
    {
        var attr = typeof(NotificationKpisPage)
            .GetCustomAttributes(typeof(AuthorizeAttribute), true)
            .Cast<AuthorizeAttribute>()
            .FirstOrDefault();

        Assert.NotNull(attr);
        Assert.Equal(AuthorizationPolicies.RequireDeployment, attr!.Policy);
    }

    [Fact]
    public void RendersGlobalTilesAndPerSiteRows()
    {
        var cut = Render<NotificationKpisPage>();

        cut.WaitForAssertion(() =>
        {
            Assert.Contains("Queue Depth", cut.Markup);
            Assert.Contains("7", cut.Markup);        // global tile value
            // Per-site row — site identifier "plant-a" resolves to its friendly name.
            Assert.Contains("Plant A", cut.Markup);
        });
    }

    [Fact]
    public void ShowsKpiError_WhenGlobalKpiQueryFails()
    {
        _kpiReply = new NotificationKpiResponse(
            "k", false, "kpi down", 0, 0, 0, 0, null);

        var cut = Render<NotificationKpisPage>();

        cut.WaitForAssertion(() => Assert.Contains("kpi down", cut.Markup));
    }

    [Fact]
    public void ShowsPerSiteError_WhenPerSiteKpiQueryFails()
    {
        // Only the per-site path errors — the global KPI reply stays successful.
        _perSiteReply = new PerSiteNotificationKpiResponse(
            "p", false, "per-site down", new List<SiteNotificationKpiSnapshot>());

        var cut = Render<NotificationKpisPage>();

        cut.WaitForAssertion(() =>
        {
            Assert.Contains("Per-site KPIs unavailable: per-site down", cut.Markup);
            // The two error paths are isolated — the global KPI alert (whose markup
            // opens ">KPIs unavailable:", without the "Per-site " prefix) must not appear.
            Assert.DoesNotContain(">KPIs unavailable:", cut.Markup);
        });
    }

    [Fact]
    public void ShowsPerSiteEmptyState_WhenNoSites()
    {
        _perSiteReply = new PerSiteNotificationKpiResponse(
            "p", true, null, new List<SiteNotificationKpiSnapshot>());

        var cut = Render<NotificationKpisPage>();

        cut.WaitForAssertion(() => Assert.Contains("No per-site activity", cut.Markup));
    }

    [Fact]
    public void RendersTrendCharts_WhenSeriesAvailable()
    {
        var cut = Render<NotificationKpisPage>();

        cut.WaitForAssertion(() =>
        {
            // The Trends section container is present …
            Assert.NotNull(cut.Find("[data-test=\"notification-trends\"]"));
            // … and at least one trend chart renders an actual polyline (a chart
            // needs IsAvailable + >= 2 points, which the substitute supplies).
            Assert.NotEmpty(cut.FindAll("[data-test^=\"kpi-trend-\"]"));
            Assert.Contains("<polyline", cut.Markup);
        });
    }

    [Fact]
    public void PartialTrendFailure_IsolatesToTheFailingMetric()
    {
        // Per-metric isolation (K13 fixup): the substitute returns a known series
        // for two metrics but THROWS for "parkedCount". The two surviving charts
        // must still draw their polylines while only the failing chart degrades to
        // the unavailable placeholder — one metric's failure no longer blanks all
        // three (and does not discard the already-fetched siblings).
        _kpiHistory.GetSeriesAsync(
                Arg.Any<string>(), Arg.Is<string>(m => m == "parkedCount"), Arg.Any<string>(),
                Arg.Any<string?>(), Arg.Any<DateTime>(), Arg.Any<DateTime>(),
                Arg.Any<int?>(), Arg.Any<CancellationToken>())
            .Returns<Task<IReadOnlyList<KpiSeriesPoint>>>(_ => throw new InvalidOperationException("parked metric down"));

        var cut = Render<NotificationKpisPage>();

        cut.WaitForAssertion(() =>
        {
            // The page is still alive — KPI tiles render and the trends section
            // never went fully blank.
            Assert.Contains("Queue Depth", cut.Markup);
            Assert.NotNull(cut.Find("[data-test=\"notification-trends\"]"));

            // The two surviving metrics (queueDepth, deliveredLastInterval) drew
            // their polylines …
            var queueChart = cut.Find("[data-test=\"kpi-trend-queue-depth\"]");
            Assert.Contains("<polyline", queueChart.InnerHtml);
            var deliveredChart = cut.Find("[data-test=\"kpi-trend-delivered-interval\"]");
            Assert.Contains("<polyline", deliveredChart.InnerHtml);

            // … while only the failing "Parked" chart degraded to the unavailable
            // placeholder (no polyline, the short error message instead).
            var parkedChart = cut.Find("[data-test=\"kpi-trend-parked\"]");
            Assert.DoesNotContain("<polyline", parkedChart.InnerHtml);
            Assert.Contains("Trend data unavailable.", parkedChart.InnerHtml);
        });
    }

    [Fact]
    public void TrendQueryFailure_DoesNotBreakPage()
    {
        // Best-effort contract: a throwing trend query must not surface an
        // unhandled exception — the KPI tiles still render and the charts fall
        // back to their unavailable placeholders.
        _kpiHistory.GetSeriesAsync(
                Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
                Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<int?>(), Arg.Any<CancellationToken>())
            .Returns<Task<IReadOnlyList<KpiSeriesPoint>>>(_ => throw new InvalidOperationException("kpi history down"));

        var cut = Render<NotificationKpisPage>();

        cut.WaitForAssertion(() =>
        {
            // Tiles still render.
            Assert.Contains("Queue Depth", cut.Markup);
            Assert.Contains("7", cut.Markup);
            // Trends section + chart placeholders are present; no polyline drawn.
            Assert.NotNull(cut.Find("[data-test=\"notification-trends\"]"));
            Assert.NotEmpty(cut.FindAll("[data-test^=\"kpi-trend-\"]"));
            Assert.DoesNotContain("<polyline", cut.Markup);
        });
    }

    protected override void Dispose(bool disposing)
    {
        if (disposing)
        {
            _system.Terminate().Wait(TimeSpan.FromSeconds(5));
        }
        base.Dispose(disposing);
    }

    /// <summary>
    /// Stand-in for the notification-outbox actor. Replies to each KPI message
    /// type with the test's currently-scripted response. Also handles the per-node
    /// KPI request (T6: M5.2) with an empty-nodes success reply so the page can
    /// complete initialization without a 30-second Ask timeout.
    /// </summary>
    private sealed class ScriptedOutboxActor : ReceiveActor
    {
        public ScriptedOutboxActor(NotificationKpisPageTests test)
        {
            Receive<NotificationKpiRequest>(_ => Sender.Tell(test._kpiReply));
            Receive<PerSiteNotificationKpiRequest>(_ => Sender.Tell(test._perSiteReply));
            Receive<PerNodeNotificationKpiRequest>(req => Sender.Tell(
                new PerNodeNotificationKpiResponse(req.CorrelationId, Success: true, ErrorMessage: null,
                    Nodes: Array.Empty<NodeNotificationKpiSnapshot>())));
        }
    }
}
