using Bunit;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;

namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared;

/// <summary>
/// bUnit tests for <c>TreeView</c>'s WAI-ARIA tree keyboard navigation
/// (https://www.w3.org/WAI/ARIA/apg/patterns/treeview/). Covers the roving
/// tabindex (exactly one tabbable treeitem), Arrow/Home/End movement over the
/// flattened <em>visible</em> node order, ArrowRight/ArrowLeft expand-collapse
/// semantics, Enter/Space activation in both selection modes, and the
/// <c>aria-level</c> / <c>aria-posinset</c> / <c>aria-setsize</c> structure.
///
/// bUnit moves no real browser focus, so "focus" is asserted through the rendered
/// <c>tabindex</c> attribute (the roving-tabindex target) and through the
/// selection callbacks, not through a focused element.
/// </summary>
public class TreeViewKeyboardNavigationTests : BunitContext
{
    private record TestNode(string Key, string Label, List<TestNode> Children);

    // Alpha (branch)
    //   ├── Alpha-1 (leaf)
    //   └── Alpha-2 (branch)
    //         └── Alpha-2-X (leaf)
    // Beta (leaf)
    private static List<TestNode> SimpleRoots() => new()
    {
        new("a", "Alpha", new()
        {
            new("a1", "Alpha-1", new()),
            new("a2", "Alpha-2", new()
            {
                new("a2x", "Alpha-2-X", new())
            })
        }),
        new("b", "Beta", new()),
    };

    private IRenderedComponent<TreeView<TestNode>> RenderTreeView(
        List<TestNode>? items = null,
        Func<TestNode, bool>? initiallyExpanded = null,
        bool selectable = false,
        object? selectedKey = null,
        Action<object?>? onSelectedKeyChanged = null,
        TreeViewSelectionMode selectionMode = TreeViewSelectionMode.Single,
        HashSet<object>? selectedKeys = null,
        Action<HashSet<object>>? onSelectedKeysChanged = null)
    {
        if (selectionMode == TreeViewSelectionMode.Checkbox)
        {
            // Checkbox mode pushes the tri-state via JS interop after every render;
            // stub it so bUnit's strict mode doesn't blow up on the unmocked call.
            JSInterop.SetupVoid("treeviewStorage.setIndeterminate", _ => true);
        }

        return Render<TreeView<TestNode>>(parameters =>
        {
            parameters
                .Add(p => p.Items, items ?? SimpleRoots())
                .Add(p => p.ChildrenSelector, n => n.Children)
                .Add(p => p.HasChildrenSelector, n => n.Children.Count > 0)
                .Add(p => p.KeySelector, n => n.Key)
                .Add(p => p.NodeContent, node => builder =>
                {
                    builder.AddMarkupContent(0, $"<span class=\"node-label\">{node.Label}</span>");
                })
                .Add(p => p.InitiallyExpanded, initiallyExpanded)
                .Add(p => p.Selectable, selectable)
                .Add(p => p.SelectedKey, selectedKey)
                .Add(p => p.SelectionMode, selectionMode)
                .Add(p => p.SelectedKeys, selectedKeys);

            if (onSelectedKeyChanged != null)
            {
                parameters.Add(p => p.SelectedKeyChanged, onSelectedKeyChanged);
            }

            if (onSelectedKeysChanged != null)
            {
                parameters.Add(p => p.SelectedKeysChanged, onSelectedKeysChanged);
            }
        });
    }

    // `li[role=treeitem]` elements come back in document order, which for a nested
    // <ul>/<li> tree is exactly the flattened visible order the arrow keys walk.
    // So an index into this list is an index into the visible node sequence.
    private static IReadOnlyList<AngleSharp.Dom.IElement> TreeItems(
        IRenderedComponent<TreeView<TestNode>> cut)
        => cut.FindAll("li[role='treeitem']").ToList();

    /// <summary>Index of the single tabbable node; also asserts that exactly one exists.</summary>
    private static int TabbableIndex(IRenderedComponent<TreeView<TestNode>> cut)
    {
        var items = TreeItems(cut);
        var found = -1;
        for (var i = 0; i < items.Count; i++)
        {
            var tabindex = items[i].GetAttribute("tabindex");
            if (tabindex == "0")
            {
                Assert.Equal(-1, found);
                found = i;
            }
            else
            {
                // Every other node must be explicitly removed from the tab order.
                Assert.Equal("-1", tabindex);
            }
        }

        Assert.NotEqual(-1, found);
        return found;
    }

    private static void PressKey(
        IRenderedComponent<TreeView<TestNode>> cut, int nodeIndex, string key)
        => TreeItems(cut)[nodeIndex].KeyDown(new KeyboardEventArgs { Key = key });

    // ── Roving tabindex ────────────────────────────────────────────────────────

    [Fact]
    public void InitialRender_ExactlyOneNodeIsTabbable_AndItIsTheFirst()
    {
        var cut = RenderTreeView();

        // Two collapsed roots; Alpha (index 0) owns the tab stop.
        Assert.Equal(2, TreeItems(cut).Count);
        Assert.Equal(0, TabbableIndex(cut));
    }

    [Fact]
    public void InitialRender_SelectedNode_OwnsTheTabStop()
    {
        // With a selection present the tab stop defaults to it, not to the first node.
        var cut = RenderTreeView(selectable: true, selectedKey: "b");

        Assert.Equal(1, TabbableIndex(cut));
    }

    [Fact]
    public void TabStop_FallsBackToFirstNode_WhenFocusedNodeIsCollapsedAway()
    {
        var cut = RenderTreeView(initiallyExpanded: n => n.Key == "a");

        // Visible: Alpha, Alpha-1, Alpha-2, Beta. Move onto Alpha-1.
        PressKey(cut, 0, "ArrowDown");
        Assert.Equal(1, TabbableIndex(cut));

        // Collapsing Alpha removes Alpha-1 from the visible set; the tab stop must
        // not vanish with it.
        cut.InvokeAsync(() => cut.Instance.CollapseAll());
        Assert.Equal(0, TabbableIndex(cut));
    }

    // ── ArrowDown / ArrowUp ────────────────────────────────────────────────────

    [Fact]
    public void ArrowDown_MovesToNextVisibleNode()
    {
        var cut = RenderTreeView(initiallyExpanded: n => n.Key == "a");

        // Visible order: Alpha(0), Alpha-1(1), Alpha-2(2), Beta(3).
        Assert.Equal(4, TreeItems(cut).Count);

        PressKey(cut, 0, "ArrowDown");
        Assert.Equal(1, TabbableIndex(cut));

        PressKey(cut, 1, "ArrowDown");
        Assert.Equal(2, TabbableIndex(cut));
    }

    [Fact]
    public void ArrowDown_SkipsChildrenOfCollapsedBranch()
    {
        var cut = RenderTreeView();

        // Alpha is collapsed, so the next visible node is Beta — not Alpha-1.
        PressKey(cut, 0, "ArrowDown");

        Assert.Equal(2, TreeItems(cut).Count);
        Assert.Equal(1, TabbableIndex(cut));
    }

    [Fact]
    public void ArrowDown_AtLastVisibleNode_IsNoOp()
    {
        var cut = RenderTreeView();

        // Walk onto Beta (the last visible node) first, then try to go past it.
        PressKey(cut, 0, "ArrowDown");
        PressKey(cut, 1, "ArrowDown");

        Assert.Equal(1, TabbableIndex(cut));
    }

    [Fact]
    public void ArrowUp_MovesToPreviousVisibleNode()
    {
        var cut = RenderTreeView(initiallyExpanded: n => n.Key == "a");

        PressKey(cut, 3, "ArrowUp");
        Assert.Equal(2, TabbableIndex(cut));

        PressKey(cut, 2, "ArrowUp");
        Assert.Equal(1, TabbableIndex(cut));
    }

    [Fact]
    public void ArrowUp_AtFirstVisibleNode_IsNoOp()
    {
        var cut = RenderTreeView();

        PressKey(cut, 0, "ArrowUp");

        Assert.Equal(0, TabbableIndex(cut));
    }

    // ── ArrowRight ─────────────────────────────────────────────────────────────

    [Fact]
    public void ArrowRight_OnCollapsedBranch_Expands_WithoutMovingFocus()
    {
        var cut = RenderTreeView();

        PressKey(cut, 0, "ArrowRight");

        var items = TreeItems(cut);
        Assert.Equal("true", items[0].GetAttribute("aria-expanded"));
        Assert.Equal(4, items.Count); // Alpha, Alpha-1, Alpha-2, Beta
        Assert.Equal(0, TabbableIndex(cut));
    }

    [Fact]
    public void ArrowRight_OnExpandedBranch_MovesToFirstChild()
    {
        var cut = RenderTreeView();

        PressKey(cut, 0, "ArrowRight"); // expand
        PressKey(cut, 0, "ArrowRight"); // move onto Alpha-1

        Assert.Equal(1, TabbableIndex(cut));
    }

    [Fact]
    public void ArrowRight_OnLeaf_IsNoOp()
    {
        var cut = RenderTreeView();

        // Beta is a leaf: no expansion, no movement, no new nodes.
        PressKey(cut, 0, "ArrowDown");
        PressKey(cut, 1, "ArrowRight");

        var items = TreeItems(cut);
        Assert.Equal(2, items.Count);
        Assert.Null(items[1].GetAttribute("aria-expanded"));
        Assert.Equal(1, TabbableIndex(cut));
    }

    // ── ArrowLeft ──────────────────────────────────────────────────────────────

    [Fact]
    public void ArrowLeft_OnExpandedBranch_Collapses_WithoutMovingFocus()
    {
        var cut = RenderTreeView(initiallyExpanded: n => n.Key == "a");

        PressKey(cut, 0, "ArrowLeft");

        var items = TreeItems(cut);
        Assert.Equal("false", items[0].GetAttribute("aria-expanded"));
        Assert.Equal(2, items.Count);
        Assert.Equal(0, TabbableIndex(cut));
    }

    [Fact]
    public void ArrowLeft_OnChildNode_MovesToParent()
    {
        var cut = RenderTreeView(initiallyExpanded: n => n.Key == "a");

        // Alpha-1 (index 1) is a collapsed leaf → ArrowLeft goes up to Alpha.
        PressKey(cut, 1, "ArrowLeft");

        Assert.Equal(0, TabbableIndex(cut));
        // The parent must NOT have collapsed as a side effect.
        Assert.Equal("true", TreeItems(cut)[0].GetAttribute("aria-expanded"));
    }

    [Fact]
    public void ArrowLeft_OnCollapsedBranchChild_MovesToParent()
    {
        var cut = RenderTreeView(initiallyExpanded: n => n.Key == "a");

        // Alpha-2 (index 2) is a branch but collapsed → move to its parent Alpha.
        PressKey(cut, 2, "ArrowLeft");

        Assert.Equal(0, TabbableIndex(cut));
    }

    [Fact]
    public void ArrowLeft_OnRootLeaf_IsNoOp()
    {
        var cut = RenderTreeView();

        PressKey(cut, 0, "ArrowDown");
        PressKey(cut, 1, "ArrowLeft"); // Beta — root level, nothing to collapse

        Assert.Equal(2, TreeItems(cut).Count);
        Assert.Equal(1, TabbableIndex(cut));
    }

    // ── Home / End ─────────────────────────────────────────────────────────────

    [Fact]
    public void Home_MovesToFirstVisibleNode()
    {
        var cut = RenderTreeView(initiallyExpanded: n => n.Key == "a");

        PressKey(cut, 3, "Home");

        Assert.Equal(0, TabbableIndex(cut));
    }

    [Fact]
    public void End_MovesToLastVisibleNode()
    {
        var cut = RenderTreeView(initiallyExpanded: n => n.Key == "a" || n.Key == "a2");

        // Visible: Alpha(0), Alpha-1(1), Alpha-2(2), Alpha-2-X(3), Beta(4).
        Assert.Equal(5, TreeItems(cut).Count);

        PressKey(cut, 0, "End");

        Assert.Equal(4, TabbableIndex(cut));
    }

    // ── Enter / Space activation ───────────────────────────────────────────────

    [Fact]
    public void Enter_SelectsNode_InSingleMode()
    {
        object? selected = null;
        var cut = RenderTreeView(
            initiallyExpanded: n => n.Key == "a",
            selectable: true,
            onSelectedKeyChanged: k => selected = k);

        PressKey(cut, 1, "Enter"); // Alpha-1

        Assert.Equal("a1", selected);
    }

    [Fact]
    public void Space_SelectsNode_InSingleMode()
    {
        object? selected = null;
        var cut = RenderTreeView(
            selectable: true,
            onSelectedKeyChanged: k => selected = k);

        PressKey(cut, 1, " "); // Beta

        Assert.Equal("b", selected);
    }

    [Fact]
    public void Enter_WhenNotSelectable_RaisesNoCallback()
    {
        object? selected = null;
        var cut = RenderTreeView(selectable: false, onSelectedKeyChanged: k => selected = k);

        PressKey(cut, 0, "Enter");

        Assert.Null(selected);
    }

    [Fact]
    public void Space_InCheckboxMode_TogglesTheNode()
    {
        HashSet<object>? captured = null;
        var cut = RenderTreeView(
            initiallyExpanded: _ => true,
            selectionMode: TreeViewSelectionMode.Checkbox,
            selectedKeys: new HashSet<object>(),
            onSelectedKeysChanged: keys => captured = keys);

        // Visible: Alpha(0), Alpha-1(1), Alpha-2(2), Alpha-2-X(3), Beta(4).
        // Space on Alpha-1 selects that one leaf.
        PressKey(cut, 1, " ");

        Assert.NotNull(captured);
        Assert.Equal(new HashSet<object> { "a1" }, captured);
    }

    [Fact]
    public void Enter_InCheckboxMode_OnFolder_CascadesToDescendantLeaves()
    {
        HashSet<object>? captured = null;
        var cut = RenderTreeView(
            initiallyExpanded: _ => true,
            selectionMode: TreeViewSelectionMode.Checkbox,
            selectedKeys: new HashSet<object>(),
            onSelectedKeysChanged: keys => captured = keys);

        PressKey(cut, 0, "Enter"); // Alpha → leaves a1 + a2x

        Assert.NotNull(captured);
        Assert.Equal(2, captured!.Count);
        Assert.Contains((object)"a1", captured);
        Assert.Contains((object)"a2x", captured);
    }

    [Fact]
    public void Keydown_OnNestedNode_DoesNotAlsoActivateItsAncestor()
    {
        // The treeitem stops keydown propagation, so a key pressed on a child must
        // not be handled a second time by every enclosing <li>.
        object? selected = null;
        var cut = RenderTreeView(
            initiallyExpanded: n => n.Key == "a",
            selectable: true,
            onSelectedKeyChanged: k => selected = k);

        PressKey(cut, 1, "Enter"); // Alpha-1, nested inside Alpha

        Assert.Equal("a1", selected);
    }

    // ── Existing chevron behaviour (regression) ────────────────────────────────

    [Fact]
    public void ChevronToggle_EnterKey_StillExpands_AndDoesNotSelect()
    {
        object? selected = null;
        var cut = RenderTreeView(selectable: true, onSelectedKeyChanged: k => selected = k);

        cut.Find(".tv-toggle").KeyDown(new KeyboardEventArgs { Key = "Enter" });

        Assert.Equal("true", TreeItems(cut)[0].GetAttribute("aria-expanded"));
        // The chevron stops propagation, so the treeitem never sees the Enter.
        Assert.Null(selected);
    }

    [Fact]
    public void ChevronToggle_SpaceKey_StillExpands()
    {
        var cut = RenderTreeView();

        cut.Find(".tv-toggle").KeyDown(new KeyboardEventArgs { Key = " " });

        Assert.Equal("true", TreeItems(cut)[0].GetAttribute("aria-expanded"));
    }

    // ── ARIA structure ─────────────────────────────────────────────────────────

    [Fact]
    public void TreeItems_CarryOneBasedAriaLevel()
    {
        var cut = RenderTreeView(initiallyExpanded: n => n.Key == "a" || n.Key == "a2");

        var items = TreeItems(cut);
        // Alpha, Alpha-1, Alpha-2, Alpha-2-X, Beta
        Assert.Equal("1", items[0].GetAttribute("aria-level"));
        Assert.Equal("2", items[1].GetAttribute("aria-level"));
        Assert.Equal("2", items[2].GetAttribute("aria-level"));
        Assert.Equal("3", items[3].GetAttribute("aria-level"));
        Assert.Equal("1", items[4].GetAttribute("aria-level"));
    }

    [Fact]
    public void TreeItems_CarryAriaPosInSetAndSetSize()
    {
        var cut = RenderTreeView(initiallyExpanded: n => n.Key == "a");

        var items = TreeItems(cut);

        // Two roots.
        Assert.Equal("1", items[0].GetAttribute("aria-posinset"));
        Assert.Equal("2", items[0].GetAttribute("aria-setsize"));

        // Alpha's two children.
        Assert.Equal("1", items[1].GetAttribute("aria-posinset"));
        Assert.Equal("2", items[1].GetAttribute("aria-setsize"));
        Assert.Equal("2", items[2].GetAttribute("aria-posinset"));
        Assert.Equal("2", items[2].GetAttribute("aria-setsize"));

        // Beta — second of the two roots.
        Assert.Equal("2", items[3].GetAttribute("aria-posinset"));
        Assert.Equal("2", items[3].GetAttribute("aria-setsize"));
    }

    [Fact]
    public void TreeStructure_UsesRoleTree_RoleGroup_AndRoleTreeitem()
    {
        var cut = RenderTreeView(initiallyExpanded: n => n.Key == "a");

        Assert.NotNull(cut.Find("ul[role='tree']"));
        Assert.Single(cut.FindAll("ul[role='group']"));
        Assert.Equal(4, TreeItems(cut).Count);
    }

    [Fact]
    public void AriaSelected_IsPresentOnSelectableNodes_AndTracksSelection()
    {
        var cut = RenderTreeView(selectable: true, selectedKey: "a");

        var items = TreeItems(cut);
        Assert.Equal("true", items[0].GetAttribute("aria-selected"));
        Assert.Equal("false", items[1].GetAttribute("aria-selected"));
    }

    [Fact]
    public void RootTree_SuppressesBrowserScrollDefaults_ForNavigationKeys()
    {
        // Space/Arrow/Home/End would otherwise scroll the page while a treeitem holds
        // focus. bUnit runs no scripts, so this locks the native guard's presence and
        // the key set it covers — not its runtime effect.
        var cut = RenderTreeView();

        var handler = cut.Find("ul[role='tree']").GetAttribute("onkeydown");

        Assert.NotNull(handler);
        Assert.Contains("preventDefault", handler!);
        // Scoped to the treeitem itself so a consumer's buttons inside NodeContent keep
        // their Enter/Space activation.
        Assert.Contains("'treeitem'", handler);
        foreach (var key in new[] { "' '", "'ArrowUp'", "'ArrowDown'", "'ArrowLeft'", "'ArrowRight'", "'Home'", "'End'" })
        {
            Assert.Contains(key, handler);
        }
    }

    [Fact]
    public void AriaSelected_IsAbsent_WhenTreeIsNotSelectable()
    {
        var cut = RenderTreeView(selectable: false);

        Assert.Null(TreeItems(cut)[0].GetAttribute("aria-selected"));
    }
}
