using System.Runtime.CompilerServices;
using System.Security.Claims;
using System.Threading.Channels;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.RenderTree;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.Web.HtmlRendering;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Server.Dashboard;
using ZB.MOM.WW.MxGateway.Server.Dashboard.Components.Pages;
using ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;

namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;

/// <summary>
/// Covers the ACL gate on the session-details page's in-process subscribe seam (SEC-25 / TST-15).
/// </summary>
/// <remarks>
/// <para>
/// The 2026-08 in-process feed refactor gave the dashboard a second way to subscribe to a
/// session's events — <see cref="IDashboardSessionEventSubscriber"/>, used by this page — so
/// gating the hub join alone would leave the page as an ungated path to the same feed. The
/// denial assertion here is the one that proves the second seam is closed: it asserts that
/// <see cref="IDashboardSessionEventSubscriber.Subscribe"/> is never called, not merely that the
/// panel renders differently.
/// </para>
/// <para>
/// Rendered through the framework's static <see cref="HtmlRenderer"/>, the same idiom
/// <c>SecretsNavRenderTests</c> uses — no component-testing package, because the assertions are
/// about the emitted markup and the calls the lifecycle makes, not about interactivity.
/// </para>
/// </remarks>
public sealed class SessionDetailsPageEventAclTests
{
    private const string SessionId = "session-1";
    // Matched without the trailing possessive so the assertion does not depend on how the
    // renderer escapes the apostrophe.
    private const string DeniedMessage = "Not authorized for this session";
    private const string WaitingMarker = "Waiting for events.";

    /// <summary>A denied caller gets the message and no subscription is opened.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task Page_WhenAclDenies_RendersMessageAndDoesNotSubscribe()
    {
        RecordingEventSubscriber subscriber = new();

        string html = await RenderAsync(subscriber, allow: false);

        Assert.Contains(DeniedMessage, html, StringComparison.Ordinal);
        Assert.DoesNotContain(WaitingMarker, html, StringComparison.Ordinal);
        Assert.Empty(subscriber.SubscribedSessionIds);
    }

    /// <summary>
    /// The control for the denial above: an allowed caller subscribes and sees the ordinary
    /// waiting state. Without this, a page that failed to render its events panel at all would
    /// satisfy the "no subscription" assertion and the suite would report a working gate over a
    /// broken panel.
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task Page_WhenAclAllows_SubscribesAndRendersWaitingState()
    {
        RecordingEventSubscriber subscriber = new();

        string html = await RenderAsync(subscriber, allow: true);

        Assert.Equal([SessionId], subscriber.SubscribedSessionIds);
        Assert.Contains(WaitingMarker, html, StringComparison.Ordinal);
        Assert.DoesNotContain(DeniedMessage, html, StringComparison.Ordinal);
    }

    /// <summary>
    /// The re-entrancy guard on <c>AttachEventsAsync</c>: a rapid A -> B navigation must leave
    /// exactly one live subscription, not two.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Gating the ACL made attach asynchronous — it awaits the authentication state — and that
    /// await is a suspension point the synchronous version did not have. The interleaving this
    /// test forces is the one that window admits: A's attach parks on the auth state, B's whole
    /// parameter set runs to completion behind it, and only then does A resume. A now reads
    /// <c>SessionId</c> as B's and, ungurarded, subscribes to B a SECOND time — overwriting the
    /// fields holding B's first subscription, which is then unreachable: never disposed, its
    /// <c>EventsHubViewerRegistry</c> entry never released (so the mirror keeps cloning events
    /// for it), its pump never cancelled.
    /// </para>
    /// <para>
    /// The assertion is deliberately about subscription COUNT and disposal rather than about the
    /// ACL: the guard is a resource-lifecycle fix, and the second attach was never an
    /// authorization bypass — B had already been cleared by the newer attach.
    /// </para>
    /// <para>
    /// This case needs a renderer that can re-set parameters on the SAME component instance, which
    /// the static <see cref="HtmlRenderer"/> used by the tests above cannot do — it renders a root
    /// component once and exposes no parameter-update seam. Hence the minimal
    /// <see cref="ParameterDrivingRenderer"/> below, which is the smallest thing that can express
    /// a second <c>SetParametersAsync</c> while the first is still suspended.
    /// </para>
    /// </remarks>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task Page_WhenNavigationOvertakesASuspendedAttach_LeavesOneSubscription()
    {
        RecordingEventSubscriber subscriber = new();
        // Call 1 is OnInitializedAsync's CanManage lookup; call 2 is the first session's attach,
        // which is the one that must be caught mid-flight.
        GatedAuthenticationStateProvider auth = new(gateOnCall: 2);

        ServiceCollection services = BuildServices(subscriber, allow: true, authenticationStateProvider: auth);
        await using ServiceProvider provider = services.BuildServiceProvider();
        await using ParameterDrivingRenderer renderer = new(provider, provider.GetRequiredService<ILoggerFactory>());

        SessionDetailsPage page = await renderer.MountAsync<SessionDetailsPage>();

        // Not awaited: it parks inside the first attach, which is the whole point.
        Task first = renderer.SetParametersAsync(page, "session-a");
        await auth.Entered.WaitAsync(TestTimeout);

        // The overtaking navigation completes end to end while the first attach is suspended.
        await renderer.SetParametersAsync(page, "session-b").WaitAsync(TestTimeout);

        auth.Release();
        await first.WaitAsync(TestTimeout);

        Assert.Empty(renderer.Exceptions);
        // Without the generation guard this is ["session-b", "session-b"] and the first of the two
        // is stranded — the exact leak the guard exists to prevent.
        Assert.Equal(["session-b"], subscriber.SubscribedSessionIds);
        Assert.Empty(subscriber.UndisposedAfterReplacement);
    }

    private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(30);

    private static ServiceCollection BuildServices(
        RecordingEventSubscriber subscriber,
        bool allow,
        AuthenticationStateProvider? authenticationStateProvider = null)
    {
        ServiceCollection services = new();
        services.AddLogging();
        services.AddSingleton<IDashboardSnapshotService>(new StubSnapshotService());
        services.AddSingleton<IDashboardSnapshotFeed>(new IdleSnapshotFeed());
        services.AddSingleton<IDashboardSessionAdminService>(new NonManagingSessionAdminService());
        services.AddSingleton<IDashboardSessionEventSubscriber>(subscriber);
        services.AddSingleton<IDashboardSessionAcl>(new StubSessionAcl(allow));
        services.AddSingleton<AuthenticationStateProvider>(
            authenticationStateProvider ?? new StubAuthenticationStateProvider());

        return services;
    }

    private static async Task<string> RenderAsync(RecordingEventSubscriber subscriber, bool allow)
    {
        await using ServiceProvider provider = BuildServices(subscriber, allow).BuildServiceProvider();
        await using HtmlRenderer renderer = new(provider, provider.GetRequiredService<ILoggerFactory>());

        return await renderer.Dispatcher.InvokeAsync(async () =>
        {
            HtmlRootComponent output = await renderer.RenderComponentAsync<SessionDetailsPage>(
                ParameterView.FromDictionary(new Dictionary<string, object?>
                {
                    [nameof(SessionDetailsPage.SessionId)] = SessionId,
                }));

            return output.ToHtmlString();
        });
    }

    private sealed class StubSessionAcl(bool allow) : IDashboardSessionAcl
    {
        /// <inheritdoc />
        public bool CanViewSession(ClaimsPrincipal? principal, string sessionId) => allow;
    }

    private sealed class RecordingEventSubscriber : IDashboardSessionEventSubscriber
    {
        private readonly List<IdleSubscription> _handedOut = [];

        /// <summary>Gets the session ids <see cref="Subscribe"/> was called with, in order.</summary>
        public List<string> SubscribedSessionIds { get; } = [];

        /// <summary>
        /// Gets the subscriptions that were superseded by a later one and never disposed — the
        /// signature of a stranded subscription, whose viewer registration is never released. The
        /// most recent subscription is excluded because the page legitimately still holds it.
        /// </summary>
        public IReadOnlyList<IdleSubscription> UndisposedAfterReplacement =>
            [.. _handedOut.SkipLast(1).Where(subscription => !subscription.IsDisposed)];

        /// <inheritdoc />
        public IDashboardEventSubscription Subscribe(string sessionId)
        {
            SubscribedSessionIds.Add(sessionId);
            IdleSubscription subscription = new();
            _handedOut.Add(subscription);

            return subscription;
        }

        // A subscription whose channel never yields and never completes, so the page's pump parks
        // exactly as it would against a quiet session.
        internal sealed class IdleSubscription : IDashboardEventSubscription
        {
            private readonly Channel<MxEvent> _channel = Channel.CreateUnbounded<MxEvent>();

            /// <summary>Gets a value indicating whether the page released this subscription.</summary>
            public bool IsDisposed { get; private set; }

            /// <inheritdoc />
            public ChannelReader<MxEvent> Reader => _channel.Reader;

            /// <inheritdoc />
            public void Dispose()
            {
                IsDisposed = true;
                _channel.Writer.TryComplete();
            }
        }
    }

    // Gates one nominated call so a test can suspend an attach exactly where the ACL check made it
    // asynchronous, and let a second parameter set overtake it.
    private sealed class GatedAuthenticationStateProvider(int gateOnCall) : AuthenticationStateProvider
    {
        private readonly TaskCompletionSource _entered = new(TaskCreationOptions.RunContinuationsAsynchronously);
        private readonly TaskCompletionSource _release = new(TaskCreationOptions.RunContinuationsAsynchronously);
        private int _calls;

        /// <summary>Completes once the gated call has been entered and is parked.</summary>
        public Task Entered => _entered.Task;

        /// <summary>Lets the parked call finish.</summary>
        public void Release() => _release.TrySetResult();

        /// <inheritdoc />
        public override async Task<AuthenticationState> GetAuthenticationStateAsync()
        {
            if (Interlocked.Increment(ref _calls) == gateOnCall)
            {
                _entered.TrySetResult();
                await _release.Task.ConfigureAwait(false);
            }

            return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity(
                [new Claim(ClaimTypes.Name, "viewer-user"), new Claim(ClaimTypes.Role, DashboardRoles.Viewer)],
                authenticationType: "test",
                nameType: ClaimTypes.Name,
                roleType: ClaimTypes.Role)));
        }
    }

    // The smallest renderer that can drive a SECOND parameter set into an already-mounted
    // component instance. HtmlRenderer renders a root component once and offers no such seam, so
    // the interleaving under test is inexpressible with it; everything here is plumbing around
    // Renderer's protected mount/parameter surface, with no behaviour of its own.
    //
    // BL0006 warns that Microsoft.AspNetCore.Components.RenderTree is not for use outside the
    // Blazor framework because those types may change between releases. Suppressed here and only
    // here: this is test-only scaffolding (the same thing component-testing packages do), it never
    // ships, and the cost of the warning coming true is a compile break in one test file on an SDK
    // bump — not a production defect. Production code must keep honouring BL0006.
#pragma warning disable BL0006
    private sealed class ParameterDrivingRenderer(IServiceProvider services, ILoggerFactory loggerFactory)
        : Renderer(services, loggerFactory)
    {
        /// <summary>Gets exceptions the renderer surfaced, so a test never passes over a swallowed fault.</summary>
        public List<Exception> Exceptions { get; } = [];

        /// <inheritdoc />
        public override Dispatcher Dispatcher { get; } = Dispatcher.CreateDefault();

        /// <summary>Instantiates the component with DI-injected properties and attaches it as a root.</summary>
        /// <typeparam name="TComponent">Component type to mount.</typeparam>
        /// <returns>The mounted component instance.</returns>
        public Task<TComponent> MountAsync<TComponent>()
            where TComponent : IComponent
        {
            return Dispatcher.InvokeAsync(() =>
            {
                TComponent component = (TComponent)InstantiateComponent(typeof(TComponent));
                AssignRootComponentId(component);

                return component;
            });
        }

        /// <summary>Sets the session-id parameter on an already-mounted page.</summary>
        /// <param name="component">The mounted page.</param>
        /// <param name="sessionId">Session id to render.</param>
        /// <returns>The task the component's parameter-set lifecycle returns.</returns>
        public Task SetParametersAsync(IComponent component, string sessionId)
        {
            return Dispatcher.InvokeAsync(() => component.SetParametersAsync(
                ParameterView.FromDictionary(new Dictionary<string, object?>
                {
                    [nameof(SessionDetailsPage.SessionId)] = sessionId,
                })));
        }

        /// <inheritdoc />
        protected override void HandleException(Exception exception) => Exceptions.Add(exception);

        /// <inheritdoc />
        protected override Task UpdateDisplayAsync(in RenderBatch renderBatch) => Task.CompletedTask;
    }
#pragma warning restore BL0006

    private sealed class StubSnapshotService : IDashboardSnapshotService
    {
        /// <inheritdoc />
        public DashboardSnapshot GetSnapshot() => new(
            GeneratedAt: DateTimeOffset.UnixEpoch,
            GatewayStartedAt: DateTimeOffset.UnixEpoch,
            GatewayUptime: TimeSpan.Zero,
            GatewayStatus: "Healthy",
            GatewayVersion: "test",
            Sessions:
            [
                new DashboardSessionSummary(
                    SessionId: SessionId,
                    BackendName: "backend",
                    State: SessionState.Ready,
                    ClientIdentity: "client",
                    ClientSessionName: "client-session",
                    ClientCorrelationId: "correlation",
                    OpenedAt: DateTimeOffset.UnixEpoch,
                    LastClientActivityAt: DateTimeOffset.UnixEpoch,
                    LeaseExpiresAt: null,
                    WorkerProcessId: null,
                    WorkerState: null,
                    LastWorkerHeartbeatAt: null,
                    EventsReceived: 0,
                    LastFault: null),
            ],
            Workers: [],
            Metrics: [],
            Faults: [],
            ApiKeys: [],
            Configuration: null!,
            Galaxy: null!);

        /// <inheritdoc />
        public IAsyncEnumerable<DashboardSnapshot> WatchSnapshotsAsync(CancellationToken cancellationToken) =>
            new IdleSnapshotFeed().WatchAsync(cancellationToken);
    }

    // Parks until the page is disposed, so the base page's watch loop neither spins nor pushes a
    // second snapshot mid-assertion.
    private sealed class IdleSnapshotFeed : IDashboardSnapshotFeed
    {
        /// <inheritdoc />
        public async IAsyncEnumerable<DashboardSnapshot> WatchAsync(
            [EnumeratorCancellation] CancellationToken cancellationToken)
        {
            await Task.Delay(Timeout.Infinite, cancellationToken).ConfigureAwait(false);

            yield break;
        }
    }

    private sealed class NonManagingSessionAdminService : IDashboardSessionAdminService
    {
        /// <inheritdoc />
        public bool CanManage(ClaimsPrincipal user) => false;

        /// <inheritdoc />
        public Task<DashboardSessionAdminResult> CloseSessionAsync(
            ClaimsPrincipal user,
            string sessionId,
            CancellationToken cancellationToken) =>
            Task.FromResult(DashboardSessionAdminResult.Fail("not supported"));

        /// <inheritdoc />
        public Task<DashboardSessionAdminResult> KillWorkerAsync(
            ClaimsPrincipal user,
            string sessionId,
            CancellationToken cancellationToken) =>
            Task.FromResult(DashboardSessionAdminResult.Fail("not supported"));
    }

    private sealed class StubAuthenticationStateProvider : AuthenticationStateProvider
    {
        /// <inheritdoc />
        public override Task<AuthenticationState> GetAuthenticationStateAsync() =>
            Task.FromResult(new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity(
                [new Claim(ClaimTypes.Name, "viewer-user"), new Claim(ClaimTypes.Role, DashboardRoles.Viewer)],
                authenticationType: "test",
                nameType: ClaimTypes.Name,
                roleType: ClaimTypes.Role))));
    }
}
