using System.Security.Claims;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Sessions;

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

/// <summary>
/// Tag-intersection implementation of <see cref="IDashboardSessionAcl"/>. Fails closed on
/// every branch: an unknown session, an empty tag grant, and an untagged session under the
/// default <see cref="UntaggedSessionVisibility.AdminOnly"/> all deny.
/// </summary>
/// <remarks>
/// <para>
/// Decision order (first match wins):
/// </para>
/// <list type="number">
/// <item><description>Authenticated caller in <see cref="DashboardRoles.Admin"/> → allow. Admin
/// already reaches every destructive surface, so event-metadata visibility is strictly weaker.</description></item>
/// <item><description>Session not present in <see cref="ISessionManager"/> → deny. No subscription
/// is created for a phantom id.</description></item>
/// <item><description>Session carries no tags → allow only when
/// <c>MxGateway:Dashboard:UntaggedSessionVisibility</c> is
/// <see cref="UntaggedSessionVisibility.AllViewers"/>.</description></item>
/// <item><description>Otherwise allow iff the session's tags intersect the caller's granted tags
/// (ordinal-ignore-case).</description></item>
/// </list>
/// <para>
/// Granted tags are read from the caller's <see cref="DashboardAuthenticationDefaults.DashboardTagClaimType"/>
/// claims, stamped at login (<see cref="DashboardAuthenticator"/>) or at hub-token mint
/// (<see cref="HubTokenService"/>). A principal with no such claims — anonymous localhost included —
/// is an empty-grant Viewer.
/// </para>
/// <para>
/// This sits on the <em>subscribe</em> path, not the per-event path, and must stay cheap enough to
/// keep it there: the only per-call work is the claim scan plus a session-registry lookup, with no
/// intermediate collection built. A per-event re-check is deliberately not needed — a joined SignalR
/// group and an in-process subscription are both per-session, and <see cref="GatewaySession.Tags"/>
/// is immutable for the session's life, so the decision taken at subscribe time cannot go stale
/// while the subscription lives.
/// </para>
/// </remarks>
/// <param name="sessionManager">Registry the session id is resolved against.</param>
/// <param name="options">Gateway options supplying <c>Dashboard:UntaggedSessionVisibility</c>.</param>
public sealed class DashboardSessionAcl(
    ISessionManager sessionManager,
    IOptions<GatewayOptions> options) : IDashboardSessionAcl
{
    /// <inheritdoc />
    public bool CanViewSession(ClaimsPrincipal? principal, string sessionId)
    {
        if (principal is null || string.IsNullOrWhiteSpace(sessionId))
        {
            return false;
        }

        if (principal.Identity?.IsAuthenticated == true && principal.IsInRole(DashboardRoles.Admin))
        {
            return true;
        }

        if (!sessionManager.TryGetSession(sessionId, out GatewaySession? session))
        {
            return false;
        }

        if (session.Tags.Count == 0)
        {
            return options.Value.Dashboard.UntaggedSessionVisibility == UntaggedSessionVisibility.AllViewers;
        }

        // Session.Tags is an ordinal-ignore-case set, so the containment test carries the
        // comparison; scanning the claims (rather than materializing the grant) keeps this
        // allocation-free beyond the claim enumerator.
        foreach (Claim tagClaim in principal.FindAll(DashboardAuthenticationDefaults.DashboardTagClaimType))
        {
            if (session.Tags.Contains(tagClaim.Value))
            {
                return true;
            }
        }

        return false;
    }
}
