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

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

/// <summary>
///     Core-011 regression coverage for <see cref="PermissionTrieBuilder.Build"/>'s
///     <c>Descend</c> helper:
///     <list type="bullet">
///         <item>With a <c>scopePaths</c> lookup the row must land at the correct multi-level
///             trie node — a deep <see cref="NodeAclScopeKind.UnsLine"/> grant must be visible
///             ONLY when the requested scope walks the same namespace/area/line chain.</item>
///         <item>Without a <c>scopePaths</c> entry the row falls back to a direct child of
///             the namespace root keyed on the row's <c>ScopeId</c>. The builder must surface
///             this fallback (warning callback) so callers know a grant was placed where the
///             walker can't reach it for production hierarchies — silently dropping the grant
///             is the Core-011 production hazard.</item>
///     </list>
/// </summary>
[Trait("Category", "Unit")]
public sealed class PermissionTrieBuilderTests
{
    private static NodeAcl Row(string group, NodeAclScopeKind scope, string? scopeId, NodePermissions flags, string clusterId = "c1") =>
        new()
        {
            NodeAclRowId = Guid.NewGuid(),
            NodeAclId = $"acl-{Guid.NewGuid():N}",
            ClusterId = clusterId,
            LdapGroup = group,
            ScopeKind = scope,
            ScopeId = scopeId,
            PermissionFlags = flags,
        };

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

    /// <summary>Verifies that Build with ScopePaths places UnsLine row at correct multi-level node.</summary>
    [Fact]
    public void Build_With_ScopePaths_Places_UnsLine_Row_At_Correct_Multi_Level_Node()
    {
        // Scope path mirrors the production hierarchy: namespace → area → line.
        var paths = new Dictionary<string, NodeAclPath>(StringComparer.OrdinalIgnoreCase)
        {
            ["line-42"] = new(new[] { "ns", "area-1", "line-42" }),
        };
        var rows = new[] { Row("cn=ops", NodeAclScopeKind.UnsLine, "line-42", NodePermissions.Read) };

        var trie = PermissionTrieBuilder.Build("c1", 1, rows, paths);

        // Walk through the same chain — the grant must be reachable.
        var matchOnLine = trie.CollectMatches(
            EquipmentTag("c1", "ns", "area-1", "line-42", "eq-A", "tag-A"),
            ["cn=ops"]);
        matchOnLine.Count.ShouldBe(1, "row must land at the correct multi-level trie node");

        // A different line under the same area must not pick up the grant.
        var matchOnOtherLine = trie.CollectMatches(
            EquipmentTag("c1", "ns", "area-1", "line-99", "eq-A", "tag-A"),
            ["cn=ops"]);
        matchOnOtherLine.ShouldBeEmpty(
            "grant anchored at line-42 must not leak to sibling line-99 under the same area");
    }

    /// <summary>Verifies that Build without ScopePaths falls back to root child for deterministic tests.</summary>
    [Fact]
    public void Build_Without_ScopePaths_Falls_Back_To_Root_Child_For_Tests()
    {
        // Fallback path — deterministic tests pass without a scope-path lookup. The row
        // is placed as a direct child of the trie root keyed by ScopeId.
        var rows = new[] { Row("cn=ops", NodeAclScopeKind.UnsLine, "line-42", NodePermissions.Read) };

        var trie = PermissionTrieBuilder.Build("c1", 1, rows);

        // Root has one child — "line-42".
        trie.Root.Children.ShouldContainKey("line-42");
        var node = trie.Root.Children["line-42"];
        node.Grants.Count.ShouldBe(1);
    }

    /// <summary>
    ///     Core-011 regression: when a sub-cluster row's ScopeId is not in the supplied
    ///     <c>scopePaths</c>, the fallback diagnostic callback must fire so the caller can
    ///     surface a warning. Silently dropping the grant under the wrong trie level is the
    ///     production hazard the finding flagged.
    /// </summary>
    [Fact]
    public void Build_Missing_ScopePath_Entry_Invokes_Diagnostic_Callback()
    {
        var paths = new Dictionary<string, NodeAclPath>(StringComparer.OrdinalIgnoreCase)
        {
            ["line-known"] = new(new[] { "ns", "area-1", "line-known" }),
        };
        // Row references a line that is NOT in the path lookup.
        var rows = new[]
        {
            Row("cn=ops", NodeAclScopeKind.UnsLine, "line-orphan", NodePermissions.Read),
            Row("cn=ops", NodeAclScopeKind.UnsLine, "line-known", NodePermissions.Read),
        };
        var diagnostics = new List<PermissionTrieBuildDiagnostic>();

        var trie = PermissionTrieBuilder.Build("c1", 1, rows, paths, diagnostics.Add);

        diagnostics.Count.ShouldBe(1, "exactly one row had no matching scope-path entry");
        diagnostics[0].ScopeId.ShouldBe("line-orphan");
        diagnostics[0].ScopeKind.ShouldBe(NodeAclScopeKind.UnsLine);
        diagnostics[0].Reason.ShouldBe(PermissionTrieBuildDiagnosticReason.MissingScopePath);
    }

    /// <summary>Verifies that no diagnostic is emitted when all sub-cluster rows have ScopePaths.</summary>
    [Fact]
    public void Build_No_Diagnostic_When_All_Sub_Cluster_Rows_Have_ScopePaths()
    {
        var paths = new Dictionary<string, NodeAclPath>(StringComparer.OrdinalIgnoreCase)
        {
            ["line-A"] = new(new[] { "ns", "area-1", "line-A" }),
            ["line-B"] = new(new[] { "ns", "area-1", "line-B" }),
        };
        var rows = new[]
        {
            Row("cn=ops", NodeAclScopeKind.Cluster, null, NodePermissions.Read),  // cluster-level — no descent
            Row("cn=ops", NodeAclScopeKind.UnsLine, "line-A", NodePermissions.Read),
            Row("cn=ops", NodeAclScopeKind.UnsLine, "line-B", NodePermissions.Read),
        };
        var diagnostics = new List<PermissionTrieBuildDiagnostic>();

        PermissionTrieBuilder.Build("c1", 1, rows, paths, diagnostics.Add);

        diagnostics.ShouldBeEmpty("no rows are missing a scope-path entry");
    }

    /// <summary>Verifies that diagnostic callback is optional when ScopePaths is null.</summary>
    [Fact]
    public void Build_Diagnostic_Callback_Optional_When_ScopePaths_Null()
    {
        // No diagnostics callback should fire when scopePaths itself is null — that's the
        // "deterministic-test fallback" mode, not a production drop.
        var rows = new[] { Row("cn=ops", NodeAclScopeKind.UnsLine, "line-42", NodePermissions.Read) };
        var diagnostics = new List<PermissionTrieBuildDiagnostic>();

        PermissionTrieBuilder.Build("c1", 1, rows, scopePaths: null, diagnostic: diagnostics.Add);

        diagnostics.ShouldBeEmpty(
            "scopePaths=null is the explicit test-fallback mode and must not emit per-row warnings");
    }
}
