using Opc.Ua;
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;
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
using ZB.MOM.WW.OtOpcUa.Server.Security;

namespace ZB.MOM.WW.OtOpcUa.Server.Tests;

/// <summary>
///     End-to-end authz regression test for the ADR-001 Task B close-out of task #195.
///     Walks the full dispatch flow for a read against an Equipment / Identification
///     property: ScopePathIndexBuilder → NodeScopeResolver → AuthorizationGate → PermissionTrie.
///     Proves the contract the IdentificationFolderBuilder docstring promises — a user
///     without the Equipment-scope grant gets denied on the Identification sub-folder the
///     same way they would be denied on the Equipment node itself, because they share the
///     Equipment ScopeId (no new scope level for Identification per the builder's remark
///     section).
/// </summary>
[Trait("Category", "Unit")]
public sealed class EquipmentIdentificationAuthzTests
{
    private const string Cluster = "c-warsaw";
    private const string Namespace = "ns-plc";

    [Fact]
    public void Authorized_Group_Read_Granted_On_Identification_Property()
    {
        var (gate, resolver) = BuildEvaluator(equipmentGrantGroup: "cn=line-a-operators");
        var scope = resolver.Resolve("plcaddr-manufacturer");

        var identity = new FakeIdentity("alice", ["cn=line-a-operators"]);
        gate.IsAllowed(identity, OpcUaOperation.Read, scope).ShouldBeTrue();
    }

    [Fact]
    public void Unauthorized_Group_Read_Denied_On_Identification_Property()
    {
        // The contract in task #195 + the IdentificationFolderBuilder docstring: "a user
        // without the grant gets BadUserAccessDenied on both the Equipment node + its
        // Identification variables." This test proves the evaluator side of that contract;
        // the BadUserAccessDenied surfacing happens in the DriverNodeManager dispatch that
        // already wires AuthorizationGate.IsAllowed → StatusCodes.BadUserAccessDenied.
        var (gate, resolver) = BuildEvaluator(equipmentGrantGroup: "cn=line-a-operators");
        var scope = resolver.Resolve("plcaddr-manufacturer");

        var identity = new FakeIdentity("bob", ["cn=other-team"]);
        gate.IsAllowed(identity, OpcUaOperation.Read, scope).ShouldBeFalse();
    }

    [Fact]
    public void Equipment_Grant_Cascades_To_Its_Identification_Properties()
    {
        // Identification properties share their parent Equipment's ScopeId (no new scope
        // level). An Equipment-scope grant must therefore read both — the Equipment's tag
        // AND its Identification properties — via the same evaluator call path.
        var (gate, resolver) = BuildEvaluator(equipmentGrantGroup: "cn=line-a-operators");

        var tagScope = resolver.Resolve("plcaddr-temperature");
        var identityScope = resolver.Resolve("plcaddr-manufacturer");

        var identity = new FakeIdentity("alice", ["cn=line-a-operators"]);
        gate.IsAllowed(identity, OpcUaOperation.Read, tagScope).ShouldBeTrue();
        gate.IsAllowed(identity, OpcUaOperation.Read, identityScope).ShouldBeTrue();
    }

    [Fact]
    public void Different_Equipment_Grant_Does_Not_Leak_Across_Equipment_Boundary()
    {
        // Grant on oven-3; test reading a tag on press-7 (different equipment). Must deny
        // so per-Equipment isolation holds at the dispatch layer — the ADR-001 Task B
        // motivation for populating the full UNS path at resolve time.
        var (gate, resolver) = BuildEvaluator(
            equipmentGrantGroup: "cn=oven-3-operators",
            equipmentIdForGrant: "eq-oven-3");

        var pressScope = resolver.Resolve("plcaddr-press-7-temp"); // belongs to eq-press-7

        var identity = new FakeIdentity("charlie", ["cn=oven-3-operators"]);
        gate.IsAllowed(identity, OpcUaOperation.Read, pressScope).ShouldBeFalse();
    }

    // ----- harness -----

    /// <summary>
    ///     Build the AuthorizationGate + NodeScopeResolver pair for a fixture with two
    ///     Equipment rows (oven-3 + press-7) under one UNS line, one NodeAcl grant at
    ///     Equipment scope for <paramref name="equipmentGrantGroup"/>, and a ScopePathIndex
    ///     populated via ScopePathIndexBuilder from the same Config-DB row set the
    ///     EquipmentNodeWalker would consume at address-space build.
    /// </summary>
    private static (AuthorizationGate Gate, NodeScopeResolver Resolver) BuildEvaluator(
        string equipmentGrantGroup,
        string equipmentIdForGrant = "eq-oven-3")
    {
        var (content, scopeIndex) = BuildFixture();
        var resolver = new NodeScopeResolver(Cluster, scopeIndex);

        var aclRow = new NodeAcl
        {
            NodeAclRowId = Guid.NewGuid(),
            NodeAclId = Guid.NewGuid().ToString(),
            GenerationId = 1,
            ClusterId = Cluster,
            LdapGroup = equipmentGrantGroup,
            ScopeKind = NodeAclScopeKind.Equipment,
            ScopeId = equipmentIdForGrant,
            PermissionFlags = NodePermissions.Browse | NodePermissions.Read,
        };
        var paths = new Dictionary<string, NodeAclPath>
        {
            [equipmentIdForGrant] = new NodeAclPath(new[] { Namespace, "area-1", "line-a", equipmentIdForGrant }),
        };

        var cache = new PermissionTrieCache();
        cache.Install(PermissionTrieBuilder.Build(Cluster, 1, [aclRow], paths));
        var evaluator = new TriePermissionEvaluator(cache);
        var gate = new AuthorizationGate(evaluator, strictMode: true);

        _ = content;
        return (gate, resolver);
    }

    private static (EquipmentNamespaceContent, IReadOnlyDictionary<string, NodeScope>) BuildFixture()
    {
        var area = new UnsArea { UnsAreaId = "area-1", ClusterId = Cluster, Name = "warsaw", GenerationId = 1 };
        var line = new UnsLine { UnsLineId = "line-a", UnsAreaId = "area-1", Name = "line-a", GenerationId = 1 };

        var oven = new Equipment
        {
            EquipmentRowId = Guid.NewGuid(), GenerationId = 1,
            EquipmentId = "eq-oven-3", EquipmentUuid = Guid.NewGuid(),
            DriverInstanceId = "drv", UnsLineId = "line-a", Name = "oven-3",
            MachineCode = "MC-oven-3", Manufacturer = "Trumpf",
        };
        var press = new Equipment
        {
            EquipmentRowId = Guid.NewGuid(), GenerationId = 1,
            EquipmentId = "eq-press-7", EquipmentUuid = Guid.NewGuid(),
            DriverInstanceId = "drv", UnsLineId = "line-a", Name = "press-7",
            MachineCode = "MC-press-7",
        };

        // Two tags for oven-3, one for press-7. Use Tag.TagConfig as the driver-side full
        // reference the dispatch layer passes to NodeScopeResolver.Resolve.
        var tempTag = NewTag("tag-1", "Temperature", "Int32", "plcaddr-temperature", "eq-oven-3");
        var mfgTag = NewTag("tag-2", "Manufacturer", "String", "plcaddr-manufacturer", "eq-oven-3");
        var pressTempTag = NewTag("tag-3", "PressTemp", "Int32", "plcaddr-press-7-temp", "eq-press-7");

        var content = new EquipmentNamespaceContent(
            Areas: [area],
            Lines: [line],
            Equipment: [oven, press],
            Tags: [tempTag, mfgTag, pressTempTag]);

        var index = ScopePathIndexBuilder.Build(Cluster, Namespace, content);
        return (content, index);
    }

    private static Tag NewTag(string tagId, string name, string dataType, string address, string equipmentId) => new()
    {
        TagRowId = Guid.NewGuid(), GenerationId = 1, TagId = tagId,
        DriverInstanceId = "drv", EquipmentId = equipmentId, Name = name,
        DataType = dataType, AccessLevel = TagAccessLevel.ReadWrite, TagConfig = address,
    };

    private sealed class FakeIdentity : UserIdentity, ILdapGroupsBearer
    {
        public FakeIdentity(string name, IReadOnlyList<string> groups)
        {
            DisplayName = name;
            LdapGroups = groups;
        }
        public new string DisplayName { get; }
        public IReadOnlyList<string> LdapGroups { get; }
    }
}
