using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;

namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Authorization;

[Trait("Category", "Unit")]
public sealed class TriePermissionEvaluatorTests
{
    private static readonly DateTime Now = new(2026, 4, 19, 12, 0, 0, DateTimeKind.Utc);
    private readonly FakeTimeProvider _time = new();

    private sealed class FakeTimeProvider : TimeProvider
    {
        /// <summary>Gets or sets the current UTC time.</summary>
        public DateTime Utc { get; set; } = Now;

        /// <inheritdoc />
        public override DateTimeOffset GetUtcNow() => new(Utc, TimeSpan.Zero);
    }

    private static NodeAcl Row(string group, NodeAclScopeKind scope, string? scopeId, NodePermissions flags) =>
        new()
        {
            NodeAclRowId = Guid.NewGuid(),
            NodeAclId = $"acl-{Guid.NewGuid():N}",
            ClusterId = "c1",
            LdapGroup = group,
            ScopeKind = scope,
            ScopeId = scopeId,
            PermissionFlags = flags,
        };

    private static UserAuthorizationState Session(
        string[] groups, DateTime? resolvedUtc = null, string clusterId = "c1", long authGenerationId = 1) =>
        new()
        {
            SessionId = "sess",
            ClusterId = clusterId,
            LdapGroups = groups,
            MembershipResolvedUtc = resolvedUtc ?? Now,
            AuthGenerationId = authGenerationId,
            MembershipVersion = 1,
        };

    private static NodeScope Scope(string cluster = "c1") =>
        new()
        {
            ClusterId = cluster,
            NamespaceId = "ns",
            UnsAreaId = "area",
            UnsLineId = "line",
            EquipmentId = "eq",
            TagId = "tag",
            Kind = NodeHierarchyKind.Equipment,
        };

    private TriePermissionEvaluator MakeEvaluator(NodeAcl[] rows)
    {
        var cache = new PermissionTrieCache();
        cache.Install(PermissionTrieBuilder.Build("c1", 1, rows));
        return new TriePermissionEvaluator(cache, _time);
    }

    /// <summary>Verifies that authorization is allowed when required permission flags are matched.</summary>
    [Fact]
    public void Allow_When_RequiredFlag_Matched()
    {
        var evaluator = MakeEvaluator([Row("cn=ops", NodeAclScopeKind.Cluster, null, NodePermissions.Read)]);

        var decision = evaluator.Authorize(Session(["cn=ops"]), OpcUaOperation.Read, Scope());

        decision.Verdict.ShouldBe(AuthorizationVerdict.Allow);
        decision.Provenance.Count.ShouldBe(1);
    }

    /// <summary>Verifies that authorization is denied when no matching group is found.</summary>
    [Fact]
    public void NotGranted_When_NoMatchingGroup()
    {
        var evaluator = MakeEvaluator([Row("cn=ops", NodeAclScopeKind.Cluster, null, NodePermissions.Read)]);

        var decision = evaluator.Authorize(Session(["cn=unrelated"]), OpcUaOperation.Read, Scope());

        decision.Verdict.ShouldBe(AuthorizationVerdict.NotGranted);
        decision.Provenance.ShouldBeEmpty();
    }

    /// <summary>Verifies that authorization is denied when permission flags are insufficient.</summary>
    [Fact]
    public void NotGranted_When_FlagsInsufficient()
    {
        var evaluator = MakeEvaluator([Row("cn=ops", NodeAclScopeKind.Cluster, null, NodePermissions.Read)]);

        var decision = evaluator.Authorize(Session(["cn=ops"]), OpcUaOperation.WriteOperate, Scope());

        decision.Verdict.ShouldBe(AuthorizationVerdict.NotGranted);
    }

    /// <summary>Verifies that HistoryRead permission requires its own flag and is not implied by Read.</summary>
    [Fact]
    public void HistoryRead_Requires_Its_Own_Bit()
    {
        // User has Read but not HistoryRead
        var evaluator = MakeEvaluator([Row("cn=ops", NodeAclScopeKind.Cluster, null, NodePermissions.Read)]);

        var liveRead = evaluator.Authorize(Session(["cn=ops"]), OpcUaOperation.Read, Scope());
        var historyRead = evaluator.Authorize(Session(["cn=ops"]), OpcUaOperation.HistoryRead, Scope());

        liveRead.IsAllowed.ShouldBeTrue();
        historyRead.IsAllowed.ShouldBeFalse("HistoryRead uses its own NodePermissions flag, not Read");
    }

    /// <summary>Verifies that cross-cluster sessions are denied.</summary>
    [Fact]
    public void CrossCluster_Session_Denied()
    {
        var evaluator = MakeEvaluator([Row("cn=ops", NodeAclScopeKind.Cluster, null, NodePermissions.Read)]);
        var otherSession = Session(["cn=ops"], clusterId: "c-other");

        var decision = evaluator.Authorize(otherSession, OpcUaOperation.Read, Scope(cluster: "c1"));

        decision.Verdict.ShouldBe(AuthorizationVerdict.NotGranted);
    }

    /// <summary>Verifies that stale sessions fail closed.</summary>
    [Fact]
    public void StaleSession_FailsClosed()
    {
        var evaluator = MakeEvaluator([Row("cn=ops", NodeAclScopeKind.Cluster, null, NodePermissions.Read)]);
        var session = Session(["cn=ops"], resolvedUtc: Now);
        _time.Utc = Now.AddMinutes(20); // well past the 15-min AuthCacheMaxStaleness default

        var decision = evaluator.Authorize(session, OpcUaOperation.Read, Scope());

        decision.Verdict.ShouldBe(AuthorizationVerdict.NotGranted);
    }

    /// <summary>Verifies that authorization is denied when no cached trie exists for the cluster.</summary>
    [Fact]
    public void NoCachedTrie_ForCluster_Denied()
    {
        var cache = new PermissionTrieCache();  // empty cache
        var evaluator = new TriePermissionEvaluator(cache, _time);

        var decision = evaluator.Authorize(Session(["cn=ops"]), OpcUaOperation.Read, Scope());

        decision.Verdict.ShouldBe(AuthorizationVerdict.NotGranted);
    }

    /// <summary>Verifies that stale generations evaluate against their bound session generation.</summary>
    [Fact]
    public void StaleGeneration_EvaluatesAgainst_SessionBoundGeneration()
    {
        // Core-002 regression: session is bound to generation 1 (Read granted). Another node
        // publishes generation 2 with the grant removed and it becomes the cache "current".
        // The evaluator must still honour the session's bound generation 1, not generation 2.
        var gen1Row = Row("cn=ops", NodeAclScopeKind.Cluster, null, NodePermissions.Read);
        var cache = new PermissionTrieCache();
        cache.Install(PermissionTrieBuilder.Build("c1", 1, [gen1Row]));
        cache.Install(PermissionTrieBuilder.Build("c1", 2, [])); // gen 2 — grant revoked, now current
        var evaluator = new TriePermissionEvaluator(cache, _time);

        var decision = evaluator.Authorize(
            Session(["cn=ops"], authGenerationId: 1), OpcUaOperation.Read, Scope());

        decision.Verdict.ShouldBe(AuthorizationVerdict.Allow,
            "the session bound to generation 1 must evaluate against generation 1, not the newer current generation");
        decision.Provenance.Count.ShouldBe(1);
    }

    /// <summary>Verifies that stale generations fail closed when the bound generation is pruned.</summary>
    [Fact]
    public void StaleGeneration_FailsClosed_WhenBoundGenerationPruned()
    {
        // Core-002 regression: session is bound to a generation no longer in the cache.
        // The evaluator must fail closed rather than silently using the current generation.
        var gen5Row = Row("cn=ops", NodeAclScopeKind.Cluster, null, NodePermissions.Read);
        var cache = new PermissionTrieCache();
        cache.Install(PermissionTrieBuilder.Build("c1", 5, [gen5Row]));
        var evaluator = new TriePermissionEvaluator(cache, _time);

        // Session bound to generation 1, which was never installed / has been pruned.
        var decision = evaluator.Authorize(
            Session(["cn=ops"], authGenerationId: 1), OpcUaOperation.Read, Scope());

        decision.Verdict.ShouldBe(AuthorizationVerdict.NotGranted,
            "a session bound to a generation absent from the cache must fail closed");
    }

    /// <summary>Verifies that a HistoryRead-only grant does NOT authorize HistoryUpdate.</summary>
    [Fact]
    public void HistoryRead_grant_does_not_authorize_HistoryUpdate()
    {
        // Before the fix HistoryUpdate was mapped to the HistoryRead bit, so a read-only grant
        // would wrongly authorise a write operation.
        var evaluator = MakeEvaluator([Row("cn=ops", NodeAclScopeKind.Cluster, null, NodePermissions.HistoryRead)]);

        var decision = evaluator.Authorize(Session(["cn=ops"]), OpcUaOperation.HistoryUpdate, Scope());

        decision.IsAllowed.ShouldBeFalse("HistoryRead grant must NOT imply HistoryUpdate");
    }

    /// <summary>Verifies that a HistoryUpdate grant authorizes HistoryUpdate.</summary>
    [Fact]
    public void HistoryUpdate_grant_authorizes_HistoryUpdate()
    {
        var evaluator = MakeEvaluator([Row("cn=ops", NodeAclScopeKind.Cluster, null, NodePermissions.HistoryUpdate)]);

        var decision = evaluator.Authorize(Session(["cn=ops"]), OpcUaOperation.HistoryUpdate, Scope());

        decision.IsAllowed.ShouldBeTrue("HistoryUpdate grant must authorize HistoryUpdate operation");
    }

    /// <summary>Verifies that the HistoryUpdate bit is 1&lt;&lt;12 and does not collide with MethodCall.</summary>
    [Fact]
    public void HistoryUpdate_bit_value_and_no_collision()
    {
        ((int)NodePermissions.HistoryUpdate).ShouldBe(1 << 12);
        (NodePermissions.HistoryUpdate & NodePermissions.MethodCall).ShouldBe(NodePermissions.None);
    }

    /// <summary>Verifies that the operation-to-permission mapping is total.</summary>
    [Fact]
    public void OperationToPermission_Mapping_IsTotal()
    {
        foreach (var op in Enum.GetValues<OpcUaOperation>())
        {
            // Must not throw — every OpcUaOperation needs a mapping or the compliance-check
            // "every operation wired" fails.
            TriePermissionEvaluator.MapOperationToPermission(op);
        }
    }
}
