using System.Security.Claims;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.SignalR;
using ZB.MOM.WW.MxGateway.Server.Dashboard;
using ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;

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

/// <summary>
/// Covers the ACL gate on <see cref="EventsHub.SubscribeSession"/> (SEC-25 / TST-15).
/// </summary>
/// <remarks>
/// The denial assertions are the load-bearing ones — before the gate existed every caller was
/// joined, so "an allowed caller is joined" is indistinguishable from no gate. They assert the
/// absence of BOTH effects of a join: the SignalR group membership and the viewer registration
/// that turns the broadcaster's mirror on for the session. Leaving either behind would keep the
/// event clone running for a caller who may not observe it.
/// </remarks>
public sealed class EventsHubTests
{
    private const string SessionId = "session-1";
    private const string ConnectionId = "connection-1";

    private static readonly ClaimsPrincipal TestPrincipal = new(new ClaimsIdentity(
        [new Claim(ClaimTypes.Name, "viewer-user")],
        authenticationType: "test"));

    /// <summary>An allowed caller joins the group and registers as a viewer.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task SubscribeSession_WhenAclAllows_JoinsGroupAndRegistersViewer()
    {
        EventsHubViewerRegistry registry = new();
        RecordingGroupManager groups = new();
        EventsHub hub = CreateHub(registry, groups, allow: true);

        await hub.SubscribeSession(SessionId);

        Assert.Equal([(ConnectionId, EventsHub.GroupName(SessionId))], groups.Added);
        Assert.True(registry.HasViewers(SessionId));
    }

    /// <summary>
    /// A denied caller gets a <see cref="HubException"/>, is not joined, and is not registered.
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task SubscribeSession_WhenAclDenies_ThrowsAndDoesNotJoin()
    {
        EventsHubViewerRegistry registry = new();
        RecordingGroupManager groups = new();
        EventsHub hub = CreateHub(registry, groups, allow: false);

        HubException error = await Assert.ThrowsAsync<HubException>(() => hub.SubscribeSession(SessionId));

        Assert.Equal("Not authorized for this session.", error.Message);
        Assert.Empty(groups.Added);
        Assert.False(registry.HasViewers(SessionId));
    }

    /// <summary>
    /// A blank session id is still a no-op rather than a denial, so a client that sends one is not
    /// told it lacks authorization for a session it never named.
    /// </summary>
    /// <param name="sessionId">The blank session id supplied by the caller.</param>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Theory]
    [InlineData("")]
    [InlineData("   ")]
    public async Task SubscribeSession_BlankSessionId_IsNoOp(string sessionId)
    {
        EventsHubViewerRegistry registry = new();
        RecordingGroupManager groups = new();
        EventsHub hub = CreateHub(registry, groups, allow: false);

        await hub.SubscribeSession(sessionId);

        Assert.Empty(groups.Added);
    }

    /// <summary>The ACL is asked about the session the caller named, with the caller's own principal.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task SubscribeSession_AsksAclAboutTheRequestedSession()
    {
        StubSessionAcl acl = new(allow: true);
        EventsHub hub = new(new EventsHubViewerRegistry(), acl)
        {
            Groups = new RecordingGroupManager(),
            Context = new StubHubCallerContext(ConnectionId, TestPrincipal),
        };

        await hub.SubscribeSession(SessionId);

        Assert.Equal(SessionId, acl.LastSessionId);
        Assert.Same(TestPrincipal, acl.LastPrincipal);
    }

    private static EventsHub CreateHub(
        EventsHubViewerRegistry registry,
        RecordingGroupManager groups,
        bool allow)
    {
        return new EventsHub(registry, new StubSessionAcl(allow))
        {
            Groups = groups,
            Context = new StubHubCallerContext(ConnectionId, TestPrincipal),
        };
    }

    private sealed class StubSessionAcl(bool allow) : IDashboardSessionAcl
    {
        /// <summary>Gets the principal passed to the most recent call.</summary>
        public ClaimsPrincipal? LastPrincipal { get; private set; }

        /// <summary>Gets the session id passed to the most recent call.</summary>
        public string? LastSessionId { get; private set; }

        /// <inheritdoc />
        public bool CanViewSession(ClaimsPrincipal? principal, string sessionId)
        {
            LastPrincipal = principal;
            LastSessionId = sessionId;

            return allow;
        }
    }

    private sealed class RecordingGroupManager : IGroupManager
    {
        /// <summary>Gets the (connection id, group name) pairs added, in order.</summary>
        public List<(string ConnectionId, string GroupName)> Added { get; } = [];

        /// <inheritdoc />
        public Task AddToGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default)
        {
            Added.Add((connectionId, groupName));

            return Task.CompletedTask;
        }

        /// <inheritdoc />
        public Task RemoveFromGroupAsync(
            string connectionId,
            string groupName,
            CancellationToken cancellationToken = default) => Task.CompletedTask;
    }

    private sealed class StubHubCallerContext(string connectionId, ClaimsPrincipal user) : HubCallerContext
    {
        /// <inheritdoc />
        public override string ConnectionId { get; } = connectionId;

        /// <inheritdoc />
        public override string? UserIdentifier => User?.Identity?.Name;

        /// <inheritdoc />
        public override ClaimsPrincipal? User { get; } = user;

        /// <inheritdoc />
        public override IDictionary<object, object?> Items { get; } = new Dictionary<object, object?>();

        /// <inheritdoc />
        public override IFeatureCollection Features { get; } = new FeatureCollection();

        /// <inheritdoc />
        public override CancellationToken ConnectionAborted => CancellationToken.None;

        /// <inheritdoc />
        public override void Abort()
        {
            // Nothing to abort in a unit-constructed context.
        }
    }
}
