using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;

namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;

/// <summary>
/// SignalR hub for per-session MxEvent push. Clients call
/// <see cref="SubscribeSession"/> to join the group for a specific
/// session; <see cref="DashboardEventBroadcaster"/> sends messages to
/// <c>session:{id}</c> as events arrive from the live gRPC stream.
/// </summary>
/// <remarks>
/// Group membership is mirrored into <see cref="EventsHubViewerRegistry"/>
/// because SignalR does not expose it, and the broadcaster consults the
/// registry to skip all mirror work for sessions nobody is watching.
/// </remarks>
/// <param name="viewerRegistry">Registry tracking which sessions have live subscribers.</param>
/// <param name="sessionAcl">Per-session visibility gate consulted before any group join.</param>
[Authorize(Policy = DashboardAuthenticationDefaults.HubClientsPolicy)]
public sealed class EventsHub(
    EventsHubViewerRegistry viewerRegistry,
    IDashboardSessionAcl sessionAcl) : Hub
{
    /// <summary>Method name used to push individual <c>MxEvent</c> values to clients.</summary>
    public const string EventMessage = "MxEvent";

    /// <summary>Computes the SignalR group name for a given session id.</summary>
    /// <param name="sessionId">The session id.</param>
    /// <returns>The group name for the session.</returns>
    public static string GroupName(string sessionId) => $"session:{sessionId}";

    /// <summary>
    /// Subscribes the calling SignalR connection to the per-session events
    /// group, so that events broadcast by
    /// <see cref="DashboardEventBroadcaster"/> for that session reach this
    /// client.
    /// </summary>
    /// <remarks>
    /// The hub-level <see cref="AuthorizeAttribute"/> (<c>HubClientsPolicy</c>)
    /// only checks that the caller carries one of the dashboard roles, which by
    /// itself would let any Viewer subscribe to any session id they name. The
    /// per-session decision is <see cref="IDashboardSessionAcl"/>'s
    /// (SEC-25 / TST-15). The admin bypass is evaluated first, so an
    /// Administrator joins any session id they name; every check below it
    /// applies to non-Admin callers only. For those: a Viewer sees a session
    /// only when its tags intersect their granted tags, an untagged session
    /// follows <c>Dashboard:UntaggedSessionVisibility</c>, and a session id the
    /// registry does not have is denied outright — the phantom-id denial is
    /// therefore a non-Admin rule, not a universal one.
    /// A denied caller is not joined to the group and is
    /// not registered with <see cref="EventsHubViewerRegistry"/>, so the mirror
    /// stays off for a session nobody is legitimately watching. The same ACL
    /// gates the in-process seam used by the session-details page, so neither
    /// path is the weaker one.
    /// </remarks>
    /// <param name="sessionId">Session id to subscribe the caller to.</param>
    /// <returns>A task representing the subscription operation.</returns>
    /// <exception cref="HubException">The caller may not observe this session.</exception>
    public Task SubscribeSession(string sessionId)
    {
        if (string.IsNullOrWhiteSpace(sessionId))
        {
            return Task.CompletedTask;
        }

        if (!sessionAcl.CanViewSession(Context.User, sessionId))
        {
            // Surfaced rather than swallowed so a client can tell "denied" from "no events yet".
            // The message names neither the session's tags nor the caller's grant.
            throw new HubException("Not authorized for this session.");
        }

        // Register before joining the group: the reverse order would leave a window
        // in which this connection is a group member but the broadcaster's gate still
        // reports the session unwatched, silently dropping events it should receive.
        viewerRegistry.AddViewer(Context.ConnectionId, sessionId);

        return Groups.AddToGroupAsync(Context.ConnectionId, GroupName(sessionId));
    }

    /// <summary>Unsubscribes the calling SignalR connection from the per-session events group.</summary>
    /// <param name="sessionId">Session id to unsubscribe the caller from.</param>
    /// <returns>A task representing the unsubscription operation.</returns>
    public async Task UnsubscribeSession(string sessionId)
    {
        if (string.IsNullOrWhiteSpace(sessionId))
        {
            return;
        }

        // Leave the group first, deregister after — the mirror stays enabled for the
        // brief overlap rather than dropping events still owed to other subscribers.
        await Groups.RemoveFromGroupAsync(Context.ConnectionId, GroupName(sessionId)).ConfigureAwait(false);

        viewerRegistry.RemoveViewer(Context.ConnectionId, sessionId);
    }

    /// <summary>
    /// Releases every session subscription the dropped connection held. A browser
    /// tab that closes never calls <see cref="UnsubscribeSession"/>, so without
    /// this the session would look watched forever and the mirror would keep
    /// cloning and sending events to an empty group.
    /// </summary>
    /// <param name="exception">The exception that terminated the connection, if any.</param>
    /// <returns>A task representing the disconnect handling.</returns>
    public override Task OnDisconnectedAsync(Exception? exception)
    {
        viewerRegistry.ReleaseConnection(Context.ConnectionId);

        return base.OnDisconnectedAsync(exception);
    }
}
