using System.Diagnostics.CodeAnalysis;
using System.Security.Claims;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Dashboard;
using ZB.MOM.WW.MxGateway.Server.Sessions;

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

/// <summary>
/// Covers <see cref="DashboardSessionAcl"/>, the single decision both dashboard subscribe seams
/// consult (SEC-25 / TST-15).
/// </summary>
/// <remarks>
/// Every branch is asserted in its denying direction as well as its allowing one, because the
/// pre-ACL behaviour was "allow everything": an assertion that a permitted caller is permitted
/// cannot distinguish a working gate from no gate at all.
/// </remarks>
public sealed class DashboardSessionAclTests
{
    private const string TaggedSessionId = "session-tagged";
    private const string UntaggedSessionId = "session-untagged";

    /// <summary>An Administrator bypasses the tag check entirely, including for a tag they hold none of.</summary>
    [Fact]
    public void CanViewSession_Administrator_BypassesTagCheck()
    {
        DashboardSessionAcl acl = CreateAcl();

        Assert.True(acl.CanViewSession(Principal(roles: [DashboardRoles.Admin]), TaggedSessionId));
        Assert.True(acl.CanViewSession(Principal(roles: [DashboardRoles.Admin]), UntaggedSessionId));
    }

    /// <summary>
    /// The admin bypass is what keeps <c>Dashboard:DisableLogin</c> auto-login (which stamps both
    /// roles and no tags) working exactly as before this change.
    /// </summary>
    [Fact]
    public void CanViewSession_AutoLoginStyleBothRolesNoTags_Allowed()
    {
        DashboardSessionAcl acl = CreateAcl();

        Assert.True(acl.CanViewSession(
            Principal(roles: [DashboardRoles.Admin, DashboardRoles.Viewer]),
            TaggedSessionId));
    }

    /// <summary>
    /// The decision table's order is load-bearing at exactly one corner: an Administrator naming
    /// a session id the registry does not have is ALLOWED, because the admin bypass is checked
    /// before the lookup. Pinned deliberately — reordering the two checks (a plausible "look the
    /// session up first, it reads better" refactor) would flip this to a denial and quietly change
    /// what an Administrator's hub join does for a session that closed a moment ago.
    /// </summary>
    [Fact]
    public void CanViewSession_AdministratorAndUnknownSession_Allowed()
    {
        DashboardSessionAcl acl = CreateAcl();

        Assert.True(acl.CanViewSession(Principal(roles: [DashboardRoles.Admin]), "session-does-not-exist"));
    }

    /// <summary>
    /// An unknown session id is denied for a non-Admin even when they hold every configured tag:
    /// no subscription is created for a session the registry does not have. The Administrator
    /// counterpart above is the deliberate exception.
    /// </summary>
    [Fact]
    public void CanViewSession_UnknownSession_Denied()
    {
        DashboardSessionAcl acl = CreateAcl();

        Assert.False(acl.CanViewSession(
            Principal(roles: [DashboardRoles.Viewer], tags: ["team-a", "team-b"]),
            "session-does-not-exist"));
    }

    /// <summary>A blank session id is denied without consulting anything.</summary>
    [Theory]
    [InlineData("")]
    [InlineData("   ")]
    public void CanViewSession_BlankSessionId_Denied(string sessionId)
    {
        DashboardSessionAcl acl = CreateAcl();

        Assert.False(acl.CanViewSession(Principal(roles: [DashboardRoles.Admin]), sessionId));
    }

    /// <summary>A null principal denies — the fail-closed reading of an unauthenticated hub context.</summary>
    [Fact]
    public void CanViewSession_NullPrincipal_Denied()
    {
        DashboardSessionAcl acl = CreateAcl();

        Assert.False(acl.CanViewSession(null, UntaggedSessionId));
    }

    /// <summary>
    /// Untagged sessions follow <c>Dashboard:UntaggedSessionVisibility</c>: hidden from Viewers
    /// under the shipped <see cref="UntaggedSessionVisibility.AdminOnly"/> default, visible under
    /// the opt-in <see cref="UntaggedSessionVisibility.AllViewers"/>.
    /// </summary>
    /// <param name="visibility">The configured untagged-session visibility.</param>
    /// <param name="expected">Whether a tagless Viewer may observe the untagged session.</param>
    [Theory]
    [InlineData(UntaggedSessionVisibility.AdminOnly, false)]
    [InlineData(UntaggedSessionVisibility.AllViewers, true)]
    public void CanViewSession_UntaggedSession_FollowsConfiguredVisibility(
        UntaggedSessionVisibility visibility,
        bool expected)
    {
        DashboardSessionAcl acl = CreateAcl(visibility);

        Assert.Equal(
            expected,
            acl.CanViewSession(Principal(roles: [DashboardRoles.Viewer]), UntaggedSessionId));
    }

    /// <summary>
    /// A Viewer whose grant intersects the session's tags is allowed; the comparison is
    /// ordinal-ignore-case, matching the session's tag set and the config map.
    /// </summary>
    /// <param name="grantedTag">The single tag the Viewer holds.</param>
    [Theory]
    [InlineData("team-a")]
    [InlineData("TEAM-A")]
    public void CanViewSession_ViewerGrantIntersectsSessionTags_Allowed(string grantedTag)
    {
        DashboardSessionAcl acl = CreateAcl();

        Assert.True(acl.CanViewSession(
            Principal(roles: [DashboardRoles.Viewer], tags: [grantedTag]),
            TaggedSessionId));
    }

    /// <summary>A Viewer holding only another tenant's tag is denied — the load-bearing negative.</summary>
    [Fact]
    public void CanViewSession_ViewerGrantDisjointFromSessionTags_Denied()
    {
        DashboardSessionAcl acl = CreateAcl();

        Assert.False(acl.CanViewSession(
            Principal(roles: [DashboardRoles.Viewer], tags: ["team-b"]),
            TaggedSessionId));
    }

    /// <summary>
    /// A principal carrying no tag claims — the anonymous-localhost / empty-grant Viewer of
    /// SEC-02 — sees a tagged session never, and an untagged one only when the operator opted
    /// into <see cref="UntaggedSessionVisibility.AllViewers"/>.
    /// </summary>
    [Fact]
    public void CanViewSession_NoTagClaims_IsEmptyGrantViewer()
    {
        ClaimsPrincipal anonymous = new(new ClaimsIdentity());

        Assert.False(CreateAcl().CanViewSession(anonymous, TaggedSessionId));
        Assert.False(CreateAcl(UntaggedSessionVisibility.AdminOnly).CanViewSession(anonymous, UntaggedSessionId));
        Assert.True(CreateAcl(UntaggedSessionVisibility.AllViewers).CanViewSession(anonymous, UntaggedSessionId));
    }

    /// <summary>
    /// An unauthenticated principal that nonetheless carries an Administrator role claim does not
    /// get the bypass: the bypass requires a real authenticated identity, as elsewhere in the
    /// dashboard (<c>DashboardSessionAdminService.CanManage</c>).
    /// </summary>
    [Fact]
    public void CanViewSession_UnauthenticatedAdminRoleClaim_DoesNotBypass()
    {
        // No authentication type => IsAuthenticated is false.
        ClaimsPrincipal principal = new(new ClaimsIdentity(
            [new Claim(ClaimTypes.Role, DashboardRoles.Admin)],
            authenticationType: null,
            nameType: ClaimTypes.Name,
            roleType: ClaimTypes.Role));

        Assert.False(CreateAcl().CanViewSession(principal, TaggedSessionId));
    }

    private static DashboardSessionAcl CreateAcl(
        UntaggedSessionVisibility visibility = UntaggedSessionVisibility.AdminOnly)
    {
        GatewayOptions options = new()
        {
            Dashboard = new DashboardOptions { UntaggedSessionVisibility = visibility },
        };

        return new DashboardSessionAcl(
            new TwoSessionManager(
                CreateSession(TaggedSessionId, ["team-a"]),
                CreateSession(UntaggedSessionId, tags: null)),
            Options.Create(options));
    }

    private static ClaimsPrincipal Principal(string[] roles, string[]? tags = null)
    {
        List<Claim> claims = [new Claim(ClaimTypes.Name, "viewer-user")];
        claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role)));
        claims.AddRange((tags ?? []).Select(tag => new Claim(
            DashboardAuthenticationDefaults.DashboardTagClaimType,
            tag)));

        return new ClaimsPrincipal(new ClaimsIdentity(
            claims,
            authenticationType: "test",
            nameType: ClaimTypes.Name,
            roleType: ClaimTypes.Role));
    }

    private static GatewaySession CreateSession(string sessionId, string[]? tags)
    {
        return new GatewaySession(
            sessionId: sessionId,
            backendName: "backend",
            pipeName: $"pipe-{sessionId}",
            nonce: "nonce",
            clientIdentity: "client",
            ownerKeyId: "key-1",
            clientSessionName: "client-session",
            clientCorrelationId: "correlation",
            commandTimeout: TimeSpan.FromSeconds(5),
            startupTimeout: TimeSpan.FromSeconds(5),
            shutdownTimeout: TimeSpan.FromSeconds(5),
            leaseDuration: TimeSpan.FromMinutes(30),
            openedAt: DateTimeOffset.UnixEpoch,
            ownerDashboardTags: tags);
    }

    /// <summary>Registry double serving exactly the two sessions the ACL cases need.</summary>
    private sealed class TwoSessionManager(GatewaySession tagged, GatewaySession untagged) : ISessionManager
    {
        /// <inheritdoc />
        public Task<GatewaySession> OpenSessionAsync(
            SessionOpenRequest request,
            string? clientIdentity,
            string? ownerKeyId,
            CancellationToken cancellationToken) => Task.FromResult(tagged);

        /// <inheritdoc />
        public bool TryGetSession(string sessionId, [MaybeNullWhen(false)] out GatewaySession session)
        {
            session = sessionId switch
            {
                TaggedSessionId => tagged,
                UntaggedSessionId => untagged,
                _ => null,
            };

            return session is not null;
        }

        /// <inheritdoc />
        public Task<WorkerCommandReply> InvokeAsync(
            string sessionId,
            WorkerCommand command,
            CancellationToken cancellationToken) => Task.FromResult(new WorkerCommandReply());

        /// <inheritdoc />
        public Task<SessionCloseResult> CloseSessionAsync(
            string sessionId,
            CancellationToken cancellationToken) =>
            Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));

        /// <inheritdoc />
        public Task<SessionCloseResult> KillWorkerAsync(
            string sessionId,
            string reason,
            CancellationToken cancellationToken) =>
            Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));

        /// <inheritdoc />
        public Task<int> CloseExpiredLeasesAsync(
            DateTimeOffset now,
            CancellationToken cancellationToken) => Task.FromResult(0);

        /// <inheritdoc />
        public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
    }
}
