using System.Security.Claims;
using Bunit;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.ScadaBridge.Security;
using AlarmSummaryPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Monitoring.AlarmSummary;

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

/// <summary>
/// bUnit rendering tests for the Operator Alarm Summary page — arch-review UA6
/// (sortable-header accessibility). The sortable column headers must be keyboard
/// operable: they carry <c>tabindex="0"</c>, expose their sort direction via
/// <c>aria-sort</c> (<c>ascending</c>/<c>descending</c> on the active column,
/// <c>none</c> otherwise), and toggle the sort when Enter is pressed on them —
/// mirroring the existing pointer-click sort.
/// </summary>
public class AlarmSummaryRenderTests : BunitContext
{
    private readonly IAlarmSummaryService _summary = Substitute.For<IAlarmSummaryService>();
    private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
    private readonly FakeSiteAlarmLiveCache _liveCache = new();

    public AlarmSummaryRenderTests()
    {
        // Two rows whose default sort (severity DESC) orders Zeta (900) before
        // Alpha (500); an ascending sort by instance name flips them.
        var rows = new List<AlarmSummaryRow>
        {
            new("Zeta", new AlarmStateChanged("Zeta", "Z-alarm", AlarmState.Active, 900, DateTimeOffset.UtcNow)),
            new("Alpha", new AlarmStateChanged("Alpha", "A-alarm", AlarmState.Active, 500, DateTimeOffset.UtcNow)),
        };

        _summary.GetSiteAlarmsAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
            .Returns(Task.FromResult(new AlarmSummaryResult(rows, Array.Empty<string>())));
        // BuildFromLiveAlarms (Task 5): flatten the pushed live snapshot the same way
        // the real service does, so the live-path render tests exercise real behavior.
        _summary.BuildFromLiveAlarms(Arg.Any<IReadOnlyList<AlarmStateChanged>>())
            .Returns(ci =>
            {
                var alarms = ci.Arg<IReadOnlyList<AlarmStateChanged>>();
                var built = alarms
                    .Select(a => new AlarmSummaryRow(a.InstanceUniqueName, a))
                    .OrderBy(r => r.InstanceUniqueName, StringComparer.OrdinalIgnoreCase)
                    .ThenBy(r => r.Alarm.AlarmName, StringComparer.OrdinalIgnoreCase)
                    .ToList();
                return new AlarmSummaryResult(built, Array.Empty<string>());
            });
        _summary.ComputeRollup(Arg.Any<IReadOnlyList<AlarmSummaryRow>>())
            .Returns(new AlarmRollup(2, 900, 0, new Dictionary<AlarmKind, int>()));
        Services.AddSingleton(_summary);

        _siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
            .Returns(Task.FromResult<IReadOnlyList<Site>>(new List<Site>
            {
                new("Site 1", "site1") { Id = 1 },
                new("Site 2", "site2") { Id = 2 },
            }));
        Services.AddSingleton(_siteRepo);

        Services.AddSingleton<ISiteAlarmLiveCache>(_liveCache);

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

    // Renders the page and drives the site picker so the alarm table appears.
    private IRenderedComponent<AlarmSummaryPage> RenderWithSiteSelected()
    {
        var cut = Render<AlarmSummaryPage>();
        cut.Find("[data-test='alarm-summary-site']").Change("1");
        cut.WaitForAssertion(() =>
            Assert.NotEmpty(cut.FindAll("tr[data-test='alarm-summary-row']")));
        return cut;
    }

    [Fact]
    public void SortableHeaders_CarryTabindexAndAriaSort()
    {
        var cut = RenderWithSiteSelected();

        var headers = cut.FindAll("thead th");
        // 0=Instance (sortable), 1=Alarm (sortable), 2=State/Kind (not sortable), 3=Severity (sortable, active).
        var instance = headers[0];
        var stateKind = headers[2];
        var severity = headers[3];

        Assert.Equal("0", instance.GetAttribute("tabindex"));
        Assert.Equal("0", severity.GetAttribute("tabindex"));

        // Default sort is severity descending.
        Assert.Equal("descending", severity.GetAttribute("aria-sort"));
        Assert.Equal("none", instance.GetAttribute("aria-sort"));

        // The non-sortable column is not keyboard-focusable and carries no aria-sort.
        Assert.Null(stateKind.GetAttribute("tabindex"));
        Assert.Null(stateKind.GetAttribute("aria-sort"));
    }

    [Fact]
    public void EnterOnInstanceHeader_FlipsRowOrder()
    {
        var cut = RenderWithSiteSelected();

        // Default severity-descending order → Zeta (900) first.
        Assert.Equal("Zeta", FirstRowInstance(cut));

        // Enter on the Instance header sorts ascending by instance → Alpha first.
        cut.FindAll("thead th")[0].TriggerEvent("onkeydown", new KeyboardEventArgs { Key = "Enter" });

        cut.WaitForAssertion(() => Assert.Equal("Alpha", FirstRowInstance(cut)));
        // aria-sort now reflects the active ascending instance column.
        Assert.Equal("ascending", cut.FindAll("thead th")[0].GetAttribute("aria-sort"));
    }

    private static string FirstRowInstance(IRenderedComponent<AlarmSummaryPage> cut) =>
        cut.FindAll("tr[data-test='alarm-summary-row']")[0]
            .QuerySelector("td")!.TextContent.Trim();

    // ── plan #10 Task 5: live-cache-first with poll fallback ───────────────────

    [Fact]
    public void SelectingSite_SubscribesToTheLiveCache()
    {
        RenderWithSiteSelected();

        Assert.Equal(1, _liveCache.SubscribeCount);
        Assert.Equal(1, _liveCache.LastSubscribedSite);
    }

    [Fact]
    public void PollFallbackRenders_WhenCacheNotLiveYet()
    {
        // Cache starts not-live, so the site select falls back to the poll's
        // GetSiteAlarmsAsync snapshot (Zeta + Alpha).
        Assert.False(_liveCache.IsLive(1));

        var cut = RenderWithSiteSelected();

        Assert.Equal(2, cut.FindAll("tr[data-test='alarm-summary-row']").Count);
        Assert.Equal("Zeta", FirstRowInstance(cut)); // severity-desc default
    }

    [Fact]
    public void OnChangedDelta_RebuildsRenderedRowsFromLiveSnapshot()
    {
        var cut = RenderWithSiteSelected();

        // Push a fresh live snapshot: a single new alarm on a new instance.
        _liveCache.PushAlarms(new List<AlarmStateChanged>
        {
            new("Gamma", "G-alarm", AlarmState.Active, 300, DateTimeOffset.UtcNow),
        });

        cut.WaitForAssertion(() =>
        {
            var rows = cut.FindAll("tr[data-test='alarm-summary-row']");
            Assert.Single(rows);
            Assert.Equal("Gamma", FirstRowInstance(cut));
        });
    }

    [Fact]
    public void LeavingSite_UnsubscribesFromTheLiveCache()
    {
        var cut = RenderWithSiteSelected();
        Assert.Equal(0, _liveCache.DisposeCount);

        // Clearing the site picker tears the subscription down.
        cut.Find("[data-test='alarm-summary-site']").Change("");

        Assert.Equal(1, _liveCache.DisposeCount);
    }

    [Fact]
    public void ChangingSite_ResubscribesToTheNewSite()
    {
        var cut = RenderWithSiteSelected(); // site 1

        cut.Find("[data-test='alarm-summary-site']").Change("2");

        Assert.Equal(2, _liveCache.SubscribeCount);   // one per site
        Assert.Equal(1, _liveCache.DisposeCount);     // old subscription disposed
        Assert.Equal(2, _liveCache.LastSubscribedSite);
    }

    [Fact]
    public void DisposingComponent_UnsubscribesFromTheLiveCache()
    {
        var cut = RenderWithSiteSelected();

        cut.Instance.Dispose();

        Assert.True(_liveCache.DisposeCount >= 1);
    }

    // ── arch-review R2 N4: stale-site poll guard ───────────────────────────────

    [Fact]
    public void InFlightPollForPreviousSite_DoesNotOverwriteNewSitesRows()
    {
        // Site 1's fan-out hangs on a TCS; site 2 answers immediately (arch-review R2 N4).
        var site1Tcs = new TaskCompletionSource<AlarmSummaryResult>();
        _summary.GetSiteAlarmsAsync(1, Arg.Any<CancellationToken>()).Returns(site1Tcs.Task);
        _summary.GetSiteAlarmsAsync(2, Arg.Any<CancellationToken>()).Returns(Task.FromResult(
            new AlarmSummaryResult(
                new List<AlarmSummaryRow>
                {
                    new("Site2Instance", new AlarmStateChanged("Site2Instance", "S2-alarm", AlarmState.Active, 100, DateTimeOffset.UtcNow)),
                },
                Array.Empty<string>())));

        var cut = Render<AlarmSummaryPage>();
        cut.Find("[data-test='alarm-summary-site']").Change("1"); // poll in flight, hung
        cut.Find("[data-test='alarm-summary-site']").Change("2"); // site 2 renders
        cut.WaitForAssertion(() => Assert.Equal("Site2Instance", FirstRowInstance(cut)));

        // The stale site-1 fan-out now completes — it must be dropped, not rendered.
        site1Tcs.SetResult(new AlarmSummaryResult(
            new List<AlarmSummaryRow>
            {
                new("StaleSite1", new AlarmStateChanged("StaleSite1", "S1-alarm", AlarmState.Active, 999, DateTimeOffset.UtcNow)),
            },
            new[] { "StaleNotReporting" }));

        cut.WaitForAssertion(() =>
        {
            Assert.Equal("Site2Instance", FirstRowInstance(cut));
            Assert.DoesNotContain("StaleNotReporting", cut.Markup);
        });
    }

    // ── arch-review R2 N5: while live, the poll updates only _notReporting ──────

    [Fact]
    public void PollWhileLive_UpdatesNotReportingOnly_DoesNotRegressLiveRows()
    {
        // Poll snapshot = Zeta/Alpha (constructor stub) + a not-reporting instance.
        _summary.GetSiteAlarmsAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
            .Returns(ci => Task.FromResult(new AlarmSummaryResult(
                new List<AlarmSummaryRow>
                {
                    new("Zeta", new AlarmStateChanged("Zeta", "Z-alarm", AlarmState.Active, 900, DateTimeOffset.UtcNow)),
                },
                new[] { "OfflineInstance" })));

        var cut = RenderWithSiteSelected();

        // Go live with a fresher delta: Gamma only (arch-review R2 N5).
        _liveCache.PushAlarms(new List<AlarmStateChanged>
        {
            new("Gamma", "G-alarm", AlarmState.Active, 300, DateTimeOffset.UtcNow),
        });
        cut.WaitForAssertion(() => Assert.Equal("Gamma", FirstRowInstance(cut)));

        // A poll now completes: it owns _notReporting but must NOT rebuild the rows.
        cut.Find("[data-test='alarm-summary-refresh']").Click();

        cut.WaitForAssertion(() =>
        {
            Assert.Contains("OfflineInstance", cut.Markup);            // notReporting refreshed
            Assert.Equal("Gamma", FirstRowInstance(cut));              // live rows NOT regressed
            Assert.Single(cut.FindAll("tr[data-test='alarm-summary-row']"));
        });
    }

    // ── arch-review R2 N7: disposal guard on the live callback ─────────────────

    [Fact]
    public void LiveCallbackRacingDispose_IsDropped_NoRebuildNoThrow()
    {
        var cut = RenderWithSiteSelected();
        _liveCache.PushAlarms(new List<AlarmStateChanged>
        {
            new("Gamma", "G-alarm", AlarmState.Active, 300, DateTimeOffset.UtcNow),
        });
        cut.WaitForAssertion(() => Assert.Equal("Gamma", FirstRowInstance(cut)));

        // Snapshot the callback BEFORE disposal — this models a delta thread that
        // already read the delegate when Dispose ran (arch-review R2 N7).
        var inFlight = _liveCache.CapturedCallback!;
        cut.Instance.Dispose();

        _liveCache.PushAlarms(new List<AlarmStateChanged>()); // mutate the fake's snapshot
        var ex = Record.Exception(inFlight);
        Assert.Null(ex);
        // The disposed page must not have re-applied the (now empty) live snapshot.
        Assert.Equal("Gamma", FirstRowInstance(cut));
    }

    /// <summary>
    /// Controllable in-memory fake of <see cref="ISiteAlarmLiveCache"/>: records
    /// subscribe/dispose counts and lets a test push a live snapshot (which flips
    /// <see cref="IsLive"/> true and fires the stored onChanged), so the page's
    /// live-delta path can be driven deterministically.
    /// </summary>
    private sealed class FakeSiteAlarmLiveCache : ISiteAlarmLiveCache
    {
        private Action? _onChanged;
        private IReadOnlyList<AlarmStateChanged> _current = Array.Empty<AlarmStateChanged>();
        private bool _live;

        public int SubscribeCount { get; private set; }
        public int DisposeCount { get; private set; }
        public int? LastSubscribedSite { get; private set; }
        public Action? CapturedCallback => _onChanged;

        public IDisposable Subscribe(int siteId, Action onChanged)
        {
            SubscribeCount++;
            LastSubscribedSite = siteId;
            _onChanged = onChanged;
            return new Subscription(this);
        }

        public IReadOnlyList<AlarmStateChanged> GetCurrentAlarms(int siteId) => _current;

        public bool IsLive(int siteId) => _live;

        /// <summary>
        /// The aggregator's own fan-out result. Settable so a test can prove the page renders
        /// the not-reporting list sourced from the live cache while it is serving the site.
        /// </summary>
        public IReadOnlyList<string> NotReporting { get; set; } = Array.Empty<string>();

        public IReadOnlyList<string> GetNotReportingInstances(int siteId) => NotReporting;

        public void PushAlarms(IReadOnlyList<AlarmStateChanged> alarms)
        {
            _current = alarms;
            _live = true;
            _onChanged?.Invoke();
        }

        private sealed class Subscription : IDisposable
        {
            private readonly FakeSiteAlarmLiveCache _owner;
            private bool _disposed;

            public Subscription(FakeSiteAlarmLiveCache owner) => _owner = owner;

            public void Dispose()
            {
                if (_disposed) return;
                _disposed = true;
                _owner.DisposeCount++;
                _owner._onChanged = null;
            }
        }
    }
}
