import types

import sshpilot.sidebar as sidebar_module
import sshpilot.window as window_module
from sshpilot.sidebar import (
    GroupRow,
    _DROP_BAR_INSET_LEFT,
    _DROP_BAR_INSET_RIGHT,
    _DROP_BAR_THICKNESS,
    _calculate_autoscroll_velocity,
    _collect_group_subtree_rows,
    _drag_bar_geometry,
    _group_into_decision,
    _group_reorder_position_from_y,
    _group_reorder_seam_at_y,
    _listbox_reposition_row,
    _placeholder_insert_index,
    _pointer_over_group_header,
    _pointer_over_group_row,
    _record_group_reorder_tree_target,
    _resolve_group_color_by_id,
    _row_at_y_or_nearest,
    _set_group_tree_drop_target,
    _sync_group_member_rows,
    _tree_target_insert_before,
    reset_connection_list_drag_session,
)
from sshpilot.window import MainWindow


class _VisibleStub:
    def __init__(self):
        self.visible = None

    def set_visible(self, visible):
        self.visible = bool(visible)


def test_group_row_apply_descendant_visibility_recurses():
    parent = GroupRow.__new__(GroupRow)
    parent.group_info = {"expanded": True}
    parent._member_rows = [_VisibleStub(), _VisibleStub()]

    child = GroupRow.__new__(GroupRow)
    child.group_info = {"expanded": False}
    child._member_rows = [_VisibleStub()]
    child._child_group_rows = []
    child.set_visible = lambda visible: setattr(child, "visible", bool(visible))

    parent._child_group_rows = [child]

    parent.apply_descendant_visibility(True)

    assert [row.visible for row in parent._member_rows] == [True, True]
    assert child.visible is True
    assert child._member_rows[0].visible is False

    parent.group_info["expanded"] = False
    parent.apply_descendant_visibility(True)

    assert [row.visible for row in parent._member_rows] == [False, False]
    assert child.visible is False
    assert child._member_rows[0].visible is False


def test_group_toggle_updates_visibility_without_rebuild():
    selected = []

    class _ToggleStub:
        def __init__(self):
            self.calls = []

        def apply_descendant_visibility(self, visible):
            self.calls.append(bool(visible))

    row = _ToggleStub()
    row.group_id = "g1"

    window = MainWindow.__new__(MainWindow)
    window.connection_list = [row]
    window._select_only_row = lambda selected_row: selected.append(selected_row)
    window.rebuild_connection_list = lambda: (_ for _ in ()).throw(
        AssertionError("should not rebuild on group toggle")
    )

    window._on_group_toggled(row, "g1", False)

    assert row.calls == [True]
    assert selected == [row]


def test_group_activation_toggles_via_single_row_activated_path():
    called = []

    class _GroupRowStub:
        group_id = "g1"

        def _toggle_expand(self):
            called.append("toggle")

    window = MainWindow.__new__(MainWindow)
    window._return_to_tab_view_if_welcome = lambda: None

    window.on_connection_activated(None, _GroupRowStub())

    assert "toggle" in called


def test_build_grouped_list_registers_and_hides_nested_groups(monkeypatch):
    """A nested child group is registered and hidden when the parent collapses."""
    created = {}

    class _FakeGroupRow:
        # Reuse the real visibility recursion under test.
        apply_descendant_visibility = GroupRow.apply_descendant_visibility

        def __init__(self, group_info, group_manager, connections_dict=None):
            self.group_info = group_info
            self.group_id = group_info["id"]
            self._member_rows = []
            self._child_group_rows = []
            self.visible = True
            created[self.group_id] = self

        def connect(self, *args, **kwargs):
            pass

        def set_indentation(self, level):
            self.indent = level

        def add_member_row(self, row):
            self._member_rows.append(row)

        def add_child_group_row(self, row):
            self._child_group_rows.append(row)

        def set_visible(self, visible):
            self.visible = bool(visible)

    monkeypatch.setattr(window_module, "GroupRow", _FakeGroupRow)

    window = MainWindow.__new__(MainWindow)
    window.group_manager = object()
    window.connection_list = []
    window.add_connection_row = (
        lambda conn, level, display_group_id=None: _VisibleStub()
    )

    hierarchy = [
        {
            "id": "parent",
            "expanded": True,
            "connections": ["c1"],
            "children": [
                {
                    "id": "child",
                    "expanded": False,
                    "connections": ["c2"],
                    "children": [],
                }
            ],
        }
    ]
    connections_dict = {"c1": object(), "c2": object()}

    returned = window._build_grouped_list(hierarchy, connections_dict, 0)

    parent = created["parent"]
    child = created["child"]

    # Top-level rows are returned; the child is registered under the parent.
    assert returned == [parent]
    assert parent._child_group_rows == [child]
    assert parent.indent == 0 and child.indent == 1
    assert len(parent._member_rows) == 1 and len(child._member_rows) == 1

    # Collapsing the parent hides the child group row and its members.
    parent.group_info["expanded"] = False
    parent.apply_descendant_visibility(True)
    assert child.visible is False
    assert child._member_rows[0].visible is False
    assert parent._member_rows[0].visible is False


def test_resolve_group_color_retain_own_else_inherit(monkeypatch):
    # The harness stubs GTK, so parse the colour string identity-style and
    # exercise the walk-up logic (retain own colour, else inherit parent's).
    import sshpilot.sidebar as sidebar_module

    monkeypatch.setattr(sidebar_module, "_parse_color", lambda value: value or None)

    class _FakeManager:
        groups = {
            "parent": {"id": "parent", "parent_id": None, "color": "red"},
            "child": {"id": "child", "parent_id": "parent", "color": None},
            "child_own": {"id": "child_own", "parent_id": "parent", "color": "green"},
            "orphan": {"id": "orphan", "parent_id": None, "color": None},
        }

    mgr = _FakeManager()

    # Colourless child inherits the parent's colour.
    assert _resolve_group_color_by_id(mgr, "child") == "red"
    # Child with its own colour keeps it.
    assert _resolve_group_color_by_id(mgr, "child_own") == "green"
    # No colour anywhere up the chain → None.
    assert _resolve_group_color_by_id(mgr, "orphan") is None


def test_group_row_indentation_honors_display_mode():
    """Nested group headers follow the fullwidth/nested Group Layout setting."""

    class _Box:
        def __init__(self):
            self.margin = None

        def set_margin_start(self, value):
            self.margin = value

    class _Config:
        def __init__(self, mode):
            self.mode = mode

        def get_setting(self, key, default=None):
            return self.mode

    class _Manager:
        def __init__(self, mode):
            self.config = _Config(mode)

    def _make(mode):
        row = GroupRow.__new__(GroupRow)
        row.group_manager = _Manager(mode)
        row._content = _Box()
        row._content_margin_base = 12
        row._indent_level = 0
        row._group_display_mode = None
        row.widget_margin = None
        row.set_margin_start = lambda value: setattr(row, "widget_margin", value)
        return row

    # nested → the whole header card shifts right; content margin unchanged.
    nested = _make("nested")
    nested.set_indentation(2)
    assert nested.widget_margin == 2 * 20
    assert nested._content.margin == 12

    # fullwidth → only the header content indents; row stays full width.
    full = _make("fullwidth")
    full.set_indentation(2)
    assert full.widget_margin == 0
    assert full._content.margin == 12 + 2 * 20

    # root level → no indentation regardless of mode.
    root = _make("nested")
    root.set_indentation(0)
    assert root.widget_margin == 0
    assert root._content.margin == 12


def test_drag_bar_geometry():
    width, height = 200, 10
    bar_x, bar_y, bar_w, bar_h, cap_cx, cap_cy, cap_r = _drag_bar_geometry(width, height)

    # Bar is inset from both edges and vertically centered.
    assert bar_x == _DROP_BAR_INSET_LEFT
    assert bar_w == width - _DROP_BAR_INSET_LEFT - _DROP_BAR_INSET_RIGHT
    assert bar_h == _DROP_BAR_THICKNESS
    assert bar_y == (height - bar_h) / 2

    # Cap is a node centered on the leading (left) end of the bar.
    assert cap_cx == bar_x
    assert cap_cy == height / 2
    assert cap_r > 0

    # Degenerate narrow widget clamps the bar width to non-negative.
    _, _, narrow_w, _, _, _, _ = _drag_bar_geometry(4, 10)
    assert narrow_w == 0


def test_group_into_decision():
    groups = {
        "a": {"id": "a", "parent_id": None},
        "b": {"id": "b", "parent_id": None},
        "c": {"id": "c", "parent_id": "b"},   # c nested in b
    }
    # Dragging c over its current parent b → reorder out (not a no-op nest).
    assert _group_into_decision(groups, "c", "b") == "reorder"
    # Dragging c over an unrelated root group a → a real nest.
    assert _group_into_decision(groups, "c", "a") == "nest"
    # Dragging b over its own descendant c → cycle → invalid.
    assert _group_into_decision(groups, "b", "c") == "invalid"
    # Onto itself → invalid.
    assert _group_into_decision(groups, "c", "c") == "invalid"


def test_group_reorder_position_from_y():
    row = _AllocRow(100, 80, header_height=40)  # header y=100..140, row taller
    lb = row._listbox
    assert _group_reorder_position_from_y(row, 115, lb) == "above"   # above header mid
    assert _group_reorder_position_from_y(row, 125, lb) == "below"   # below header mid
    assert _group_reorder_position_from_y(row, 160, lb) == "below"   # past header, row margin


def test_pointer_over_group_row():
    row = _AllocRow(100, 80, header_height=40)
    lb = _StubListBox(row)
    row.bind_listbox(lb)
    assert _pointer_over_group_row(row, 120, lb) is True
    assert _pointer_over_group_row(row, 179, lb) is True
    assert _pointer_over_group_row(row, 93, lb) is False   # above row + margin slack
    assert _pointer_over_group_row(row, 186, lb) is False  # below row + margin slack


def test_pointer_over_group_header():
    row = _AllocRow(100, 80, header_height=40)
    lb = row._listbox
    assert _pointer_over_group_header(row, 120, lb) is True
    assert _pointer_over_group_header(row, 110, lb) is True
    assert _pointer_over_group_header(row, 139, lb) is True
    assert _pointer_over_group_header(row, 145, lb) is False   # past header content
    assert _pointer_over_group_header(row, 95, lb) is False    # above header content


def test_pointer_over_group_header_not_top_row():
    """Header hit-test must use listbox coordinates, not parent-relative content y."""
    row = _AllocRow(200, 52, header_height=40)  # second root group
    lb = row._listbox
    assert _pointer_over_group_header(row, 220, lb) is True
    assert _pointer_over_group_header(row, 50, lb) is False


def test_group_reorder_seam_between_siblings(monkeypatch):
    """Seam between collapsed sibling groups resolves to reorder above the lower one."""
    a = _AllocRow(0, 48, group_id="a", header_height=40)
    b = _AllocRow(56, 48, group_id="b", header_height=40)  # 8px gap after A
    c = _AllocRow(112, 48, group_id="c", header_height=40)
    listbox = object()
    for row in (a, b, c):
        row.bind_listbox(listbox)
        row._member_rows = []
        row._child_group_rows = []

    class _Manager:
        groups = {
            "a": {"id": "a", "parent_id": None},
            "b": {"id": "b", "parent_id": None},
            "c": {"id": "c", "parent_id": None},
        }

    window = types.SimpleNamespace()
    window.group_manager = _Manager()
    window.connection_list = listbox

    monkeypatch.setattr(
        sidebar_module,
        "_iter_host_group_rows",
        lambda w: iter([a, b, c]),
    )
    # Midpoint of the gap between A (ends ~48) and B (starts 56) is 52.
    seam = _group_reorder_seam_at_y(window, 52, "c")
    assert seam == (b, "above")
    # Centre of B's header should not match the A–B seam.
    assert _group_reorder_seam_at_y(window, 72, "c") is None


def test_group_reorder_seam_contiguous_rows(monkeypatch):
    """Seam still resolves when rows touch (no inter-row gap)."""
    a = _AllocRow(0, 48, group_id="a", header_height=40)
    b = _AllocRow(48, 48, group_id="b", header_height=40)  # flush with A
    c = _AllocRow(96, 48, group_id="c", header_height=40)
    listbox = _StubListBox(a, b, c)
    for row in (a, b, c):
        row.bind_listbox(listbox)
        row._member_rows = []
        row._child_group_rows = []

    class _Manager:
        groups = {
            "a": {"id": "a", "parent_id": None},
            "b": {"id": "b", "parent_id": None},
            "c": {"id": "c", "parent_id": None},
        }

    window = types.SimpleNamespace()
    window.group_manager = _Manager()
    window.connection_list = listbox

    monkeypatch.setattr(
        sidebar_module,
        "_iter_host_group_rows",
        lambda w: iter([a, b, c]),
    )
    # Top of B's row (direct_row would be B, not None).
    seam = _group_reorder_seam_at_y(window, 50, "c")
    assert seam == (b, "above")


def test_group_motion_shows_reorder_at_sibling_seam(monkeypatch):
    """Dragging C near the seam between A and B shows a reorder gap above B."""
    shown = []
    monkeypatch.setattr(
        sidebar_module,
        "_show_drop_indicator",
        lambda w, row, position: shown.append((row.group_id, position)),
    )
    monkeypatch.setattr(sidebar_module, "_show_drop_indicator_on_group", lambda w, r: None)
    monkeypatch.setattr(sidebar_module, "_show_ungrouped_area", lambda w: None)
    monkeypatch.setattr(sidebar_module, "_update_connection_autoscroll", lambda w, y: None)
    monkeypatch.setattr(sidebar_module.GLib, "get_monotonic_time", lambda: 100_000)

    a = _AllocRow(0, 48, group_id="a", header_height=40)
    b = _AllocRow(56, 48, group_id="b", header_height=40)
    c_row = _AllocRow(112, 48, group_id="c", header_height=40)
    for row in (a, b, c_row):
        row.is_tag_group = False
        row.show_drop_indicator = lambda top: None
        row.hide_drop_indicators = lambda: None
        row._member_rows = []
        row._child_group_rows = []

    class _Manager:
        groups = {
            "a": {"id": "a", "parent_id": None},
            "b": {"id": "b", "parent_id": None},
            "c": {"id": "c", "parent_id": None},
        }

    class _ConnList(_StubListBox):
        def __init__(self):
            super().__init__(a, b, c_row)

    window = types.SimpleNamespace()
    window._dragged_group_id = "c"
    window._drag_in_progress = True
    window._drop_indicator_row = None
    window._drop_indicator_position = None
    window.group_manager = _Manager()
    window.connection_list = _ConnList()
    for row in (a, b, c_row):
        row.bind_listbox(window.connection_list)

    monkeypatch.setattr(
        sidebar_module, "_iter_host_group_rows", lambda w: iter([a, b, c_row])
    )

    sidebar_module._on_connection_list_motion(window, None, 0, 52)

    assert shown == [("b", "above")]


def test_group_motion_reorder_on_contiguous_row_boundary(monkeypatch):
    """Reorder above B when y hits B's row at the A/B boundary (no list gap)."""
    shown = []
    monkeypatch.setattr(
        sidebar_module,
        "_show_drop_indicator",
        lambda w, row, position: shown.append((row.group_id, position)),
    )
    monkeypatch.setattr(sidebar_module, "_show_drop_indicator_on_group", lambda w, r: None)
    monkeypatch.setattr(sidebar_module, "_show_ungrouped_area", lambda w: None)
    monkeypatch.setattr(sidebar_module, "_update_connection_autoscroll", lambda w, y: None)
    monkeypatch.setattr(sidebar_module.GLib, "get_monotonic_time", lambda: 100_000)

    a = _AllocRow(0, 48, group_id="a", header_height=40)
    b = _AllocRow(48, 48, group_id="b", header_height=40)
    c_row = _AllocRow(96, 48, group_id="c", header_height=40)
    for row in (a, b, c_row):
        row.is_tag_group = False
        row.show_drop_indicator = lambda top: None
        row.hide_drop_indicators = lambda: None
        row._member_rows = []
        row._child_group_rows = []

    class _Manager:
        groups = {
            "a": {"id": "a", "parent_id": None},
            "b": {"id": "b", "parent_id": None},
            "c": {"id": "c", "parent_id": None},
        }

    window = types.SimpleNamespace()
    window._dragged_group_id = "c"
    window._drag_in_progress = True
    window._drop_indicator_row = b
    window._drop_indicator_position = "on_group"  # was nesting on B
    window.group_manager = _Manager()
    window.connection_list = _StubListBox(a, b, c_row)
    for row in (a, b, c_row):
        row.bind_listbox(window.connection_list)
    monkeypatch.setattr(
        sidebar_module, "_iter_host_group_rows", lambda w: iter([a, b, c_row])
    )

    sidebar_module._on_connection_list_motion(window, None, 0, 50)

    assert shown == [("b", "above")]


def test_group_motion_shows_reorder_at_gap_not_inside_row(monkeypatch):
    """Dragging a group into the gap between siblings shows reorder, not nest."""
    shown = []
    monkeypatch.setattr(
        sidebar_module,
        "_show_drop_indicator",
        lambda w, row, position: shown.append((row.group_id, position)),
    )
    monkeypatch.setattr(sidebar_module, "_show_drop_indicator_on_group", lambda w, r: None)
    monkeypatch.setattr(sidebar_module, "_show_ungrouped_area", lambda w: None)
    monkeypatch.setattr(sidebar_module, "_update_connection_autoscroll", lambda w, y: None)
    monkeypatch.setattr(sidebar_module.GLib, "get_monotonic_time", lambda: 100_000)

    a = _AllocRow(0, 48, group_id="a", header_height=40)
    row = _AllocRow(56, 48, group_id="b", header_height=40)
    c_row = _AllocRow(112, 48, group_id="c", header_height=40)
    for r in (a, row, c_row):
        r.is_tag_group = False
        r.show_drop_indicator = lambda top: None
        r.hide_drop_indicators = lambda: None
        r._member_rows = []
        r._child_group_rows = []

    class _Manager:
        groups = {
            "a": {"id": "a", "parent_id": None},
            "b": {"id": "b", "parent_id": None},
            "c": {"id": "c", "parent_id": None},
        }

    window = types.SimpleNamespace()
    window._dragged_group_id = "c"
    window._drag_in_progress = True
    window._drop_indicator_row = None
    window._drop_indicator_position = None
    window.group_manager = _Manager()
    window.connection_list = _StubListBox(a, row, c_row)
    for r in (a, row, c_row):
        r.bind_listbox(window.connection_list)
    monkeypatch.setattr(
        sidebar_module, "_iter_host_group_rows", lambda w: iter([a, row, c_row])
    )

    sidebar_module._on_connection_list_motion(window, None, 0, 52)

    assert shown == [("b", "above")]


def test_placeholder_insert_index():
    # 'above' lands at the target's own slot; 'below' one past it.
    assert _placeholder_insert_index(3, "above") == 3
    assert _placeholder_insert_index(3, "below") == 4
    assert _placeholder_insert_index(0, "above") == 0
    assert _placeholder_insert_index(0, "below") == 1


class _StubListBox:
    """Minimal listbox stub with allocation-based ``get_row_at_y``."""

    def __init__(self, *rows):
        self._rows = list(rows)

    def get_row_at_y(self, y):
        for row in self._rows:
            alloc = row.get_allocation()
            if alloc.y <= int(y) < alloc.y + alloc.height:
                return row
        return None

    def set_selection_mode(self, mode):
        pass


class _AllocRow:
    def __init__(self, y, height, group_id="dst", header_height=None):
        self.group_id = group_id
        self._alloc = types.SimpleNamespace(y=y, height=height)
        h = height if header_height is None else header_height
        self._listbox = object()
        self._header_y = y
        self._header_h = h
        self._content = _AllocRowContent(self)

    def get_parent(self):
        return self._listbox

    def get_allocation(self):
        return self._alloc

    def translate_coordinates(self, dest, _x, _y):
        if dest is self._listbox:
            return True, 0, self._alloc.y
        return False, 0, 0

    def bind_listbox(self, listbox) -> None:
        """Use the real connection list so translate_coordinates targets match."""
        self._listbox = listbox


class _AllocRowContent:
    def __init__(self, row):
        self._row = row

    def get_allocation(self):
        # Parent-relative: header sits at top of row child area.
        return types.SimpleNamespace(y=0, height=self._row._header_h)

    def translate_coordinates(self, dest, _x, _y):
        row = self._row
        if dest is row._listbox:
            return True, 0, row._header_y
        if dest is row:
            return True, 0, row._header_y - row._alloc.y
        return False, 0, 0


def test_row_at_y_or_nearest_bridges_margin_gap():
    sentinel = object()

    class _GapListBox:
        # y=50 is a margin gap; the real row sits at y+4.
        def get_row_at_y(self, y):
            return sentinel if y == 54 else None

    window = types.SimpleNamespace(connection_list=_GapListBox())
    assert _row_at_y_or_nearest(window, 50) is sentinel

    class _EmptyListBox:
        def get_row_at_y(self, y):
            return None

    window = types.SimpleNamespace(connection_list=_EmptyListBox())
    assert _row_at_y_or_nearest(window, 50) is None


def test_group_drop_follows_captured_indicator(monkeypatch):
    """The drop performs the highlighted action regardless of the drop y."""
    monkeypatch.setattr(sidebar_module, "_clear_drop_indicator", lambda w: None)
    monkeypatch.setattr(sidebar_module, "_hide_ungrouped_area", lambda w: None)
    monkeypatch.setattr(sidebar_module, "_stop_connection_autoscroll", lambda w: None)

    placed = []

    class _Client:
        def place_group(self, request):
            placed.append((request.group_id, request.parent_id, request.index))
            return True

    class _Manager:
        def __init__(self):
            self.groups = {
                "src": {"id": "src", "parent_id": None},
                "dst": {"id": "dst", "parent_id": None},
            }

        def get_ordered_siblings(self, parent_id):
            if parent_id == "dst":
                return []
            return ["src", "dst"]

        def place_group(self, group_id, parent_id, index):
            placed.append((group_id, parent_id, index))
            return True

    def _make_window(position, parent_id=None, index=None, tree_set=False):
        window = types.SimpleNamespace()
        indicator_row = _AllocRow(0, 40, group_id="dst")
        window._drop_indicator_row = indicator_row
        window._drop_indicator_position = position
        window._drop_group_parent_id = parent_id
        window._drop_group_index = index
        window._drop_group_tree_target_set = tree_set
        window.connection_list = object()
        indicator_row.bind_listbox(window.connection_list)
        window.group_manager = _Manager()
        window.client = _Client()
        window.connection_manager = types.SimpleNamespace(snapshot=lambda: None)
        window.rebuilt = []
        window.rebuild_connection_list = lambda: window.rebuilt.append(True)
        return window

    value = {"type": "group", "group_id": "src"}

    # 'on_group' highlight → nest at end of dst's children.
    window = _make_window("on_group")
    assert sidebar_module._on_connection_list_drop(window, None, value, 0, 99999) is True
    assert placed == [("src", "dst", 0)]

    # Reorder uses captured tree coordinates, not visible row indices.
    placed.clear()
    window = _make_window("above", parent_id=None, index=1, tree_set=True)
    assert sidebar_module._on_connection_list_drop(window, None, value, 0, 99999) is True
    assert placed == [("src", None, 1)]


def test_group_drop_carries_projection_generation(monkeypatch):
    """Group drops attach the authoritative projection generation."""
    monkeypatch.setattr(sidebar_module, "_clear_drop_indicator", lambda w: None)
    monkeypatch.setattr(sidebar_module, "_hide_ungrouped_area", lambda w: None)
    monkeypatch.setattr(sidebar_module, "_stop_connection_autoscroll", lambda w: None)

    placed = []

    class _Client:
        def place_group(self, request):
            placed.append(request)
            return True

    class _Manager:
        def __init__(self):
            self.groups = {
                "src": {"id": "src", "parent_id": None},
                "dst": {"id": "dst", "parent_id": None},
            }

        def get_ordered_siblings(self, parent_id):
            if parent_id == "dst":
                return []
            return ["src", "dst"]

        def sibling_index(self, group_id):
            return None, 0

    def _make_window(position, parent_id=None, index=None, tree_set=False,
                     drop_generation=None, projection_generation=None):
        window = types.SimpleNamespace()
        indicator_row = _AllocRow(0, 40, group_id="dst")
        window._drop_indicator_row = indicator_row
        window._drop_indicator_position = position
        window._drop_group_parent_id = parent_id
        window._drop_group_index = index
        window._drop_group_tree_target_set = tree_set
        window._drop_group_generation = drop_generation
        window.connection_list = object()
        indicator_row.bind_listbox(window.connection_list)
        window.group_manager = _Manager()
        window.client = _Client()
        snapshot = (
            types.SimpleNamespace(generation=projection_generation)
            if projection_generation is not None
            else None
        )
        window.connection_manager = types.SimpleNamespace(snapshot=lambda: snapshot)
        return window

    value = {"type": "group", "group_id": "src"}

    # on_group nest uses the authoritative projection generation.
    window = _make_window("on_group", projection_generation=5)
    assert sidebar_module._on_connection_list_drop(window, None, value, 0, 99999) is True
    assert placed[-1].group_id == "src"
    assert placed[-1].parent_id == "dst"
    assert placed[-1].index == 0
    assert placed[-1].expected_generation == 5

    # Tree-set reorder uses the generation captured with the drop target, even
    # when the projection has since moved on.
    placed.clear()
    window = _make_window(
        "above",
        parent_id=None,
        index=1,
        tree_set=True,
        drop_generation=7,
        projection_generation=5,
    )
    assert sidebar_module._on_connection_list_drop(window, None, value, 0, 99999) is True
    assert placed[-1].expected_generation == 7

    # above/below recompute uses the authoritative projection generation.
    placed.clear()
    window = _make_window("above", projection_generation=9)
    assert sidebar_module._on_connection_list_drop(window, None, value, 0, 99999) is True
    assert placed[-1].expected_generation == 9


def test_group_on_group_drop_uses_captured_nest_target(monkeypatch):
    """A nest drop submits the tuple captured with the preview, not a recompute.

    The nest preview is calculated at motion time and its generation captured.
    If the authoritative projection advances before the drop, the request must
    still describe the projection the user saw (WYSIWYG)."""
    monkeypatch.setattr(sidebar_module, "_clear_drop_indicator", lambda w: None)
    monkeypatch.setattr(sidebar_module, "_hide_ungrouped_area", lambda w: None)
    monkeypatch.setattr(sidebar_module, "_stop_connection_autoscroll", lambda w: None)

    placed = []

    class _Client:
        def place_group(self, request):
            placed.append(request)
            return True

    class _Manager:
        def __init__(self):
            self.groups = {
                "src": {"id": "src", "parent_id": None},
                "dst": {"id": "dst", "parent_id": None},
            }

        def get_ordered_siblings(self, parent_id):
            if parent_id == "dst":
                return ["existing"]
            return ["src", "dst"]

    window = types.SimpleNamespace()
    indicator_row = _AllocRow(0, 40, group_id="dst")
    window._drop_indicator_row = indicator_row
    window._drop_indicator_position = "on_group"
    # Captured at motion time: nest "src" into "dst" before "existing" (index 0),
    # when the projection was generation 7.
    window._drop_group_parent_id = "dst"
    window._drop_group_index = 0
    window._drop_group_tree_target_set = True
    window._drop_group_generation = 7
    window.connection_list = object()
    indicator_row.bind_listbox(window.connection_list)
    window.group_manager = _Manager()
    window.client = _Client()
    # The authoritative projection has advanced to generation 8 by drop time.
    window.connection_manager = types.SimpleNamespace(
        snapshot=lambda: types.SimpleNamespace(generation=8)
    )

    value = {"type": "group", "group_id": "src"}
    assert sidebar_module._on_connection_list_drop(window, None, value, 0, 99999) is True
    assert placed[-1].group_id == "src"
    assert placed[-1].parent_id == "dst"
    assert placed[-1].index == 0
    assert placed[-1].expected_generation == 7


def test_set_group_tree_drop_target_captures_projection_generation():
    """The captured drop target records the projection generation at motion time."""

    class _Manager:
        groups = {"a": {"id": "a", "parent_id": None}}

    window = types.SimpleNamespace()
    window.group_manager = _Manager()
    window.connection_manager = types.SimpleNamespace(
        snapshot=lambda: types.SimpleNamespace(generation=11)
    )

    _set_group_tree_drop_target(window, "a", 2)
    assert window._drop_group_generation == 11
    assert window._drop_group_parent_id == "a"
    assert window._drop_group_index == 2
    assert window._drop_group_tree_target_set is True


def test_group_dnd_unsupported_capability_has_no_local_fallback(monkeypatch):
    """An unsupported group capability must not fall back to a local mutation."""
    from sshpilot.api import Capability
    from sshpilot.api.errors import UnsupportedCapabilityError

    monkeypatch.setattr(sidebar_module, "_clear_drop_indicator", lambda w: None)
    monkeypatch.setattr(sidebar_module, "_hide_ungrouped_area", lambda w: None)
    monkeypatch.setattr(sidebar_module, "_stop_connection_autoscroll", lambda w: None)

    local_places = []
    request_seen = []

    class _Client:
        def place_group(self, request):
            request_seen.append(request)
            raise UnsupportedCapabilityError(
                "daemon does not support group changes",
                Capability.CONNECTIONS_GROUPS,
            )

    class _Manager:
        def __init__(self):
            self.groups = {
                "src": {"id": "src", "parent_id": None},
                "dst": {"id": "dst", "parent_id": None},
            }

        def get_ordered_siblings(self, parent_id):
            if parent_id == "dst":
                return []
            return ["src", "dst"]

        def place_group(self, group_id, parent_id, index):
            local_places.append((group_id, parent_id, index))
            return True

    window = types.SimpleNamespace()
    indicator_row = _AllocRow(0, 40, group_id="dst")
    window._drop_indicator_row = indicator_row
    window._drop_indicator_position = "on_group"
    window._drop_group_parent_id = None
    window._drop_group_index = None
    window._drop_group_tree_target_set = False
    window._drop_group_generation = 3
    window.connection_list = object()
    indicator_row.bind_listbox(window.connection_list)
    window.group_manager = _Manager()
    window.client = _Client()
    window.connection_manager = types.SimpleNamespace(
        snapshot=lambda: types.SimpleNamespace(generation=3)
    )

    value = {"type": "group", "group_id": "src"}
    assert sidebar_module._on_connection_list_drop(window, None, value, 0, 99999) is False
    assert local_places == []
    assert len(request_seen) == 1
    assert request_seen[0].expected_generation == 3


def test_record_group_reorder_tree_target_before_root_sibling():
    """Insert before root group B → (root, index(B)), not a visible row index."""

    class _Manager:
        groups = {
            "a": {"id": "a", "parent_id": None, "order": 0},
            "b": {"id": "b", "parent_id": None, "order": 1},
            "c": {"id": "c", "parent_id": None, "order": 2},
        }

        def sibling_index(self, group_id):
            order = sorted(
                self.groups,
                key=lambda gid: self.groups[gid].get("order", 0),
            )
            return None, order.index(group_id)

    window = types.SimpleNamespace()
    window.group_manager = _Manager()
    window._dragged_group_id = "c"

    _record_group_reorder_tree_target(window, "b", "above")
    assert window._drop_group_parent_id is None
    assert window._drop_group_index == 1
    assert window._drop_group_tree_target_set is True

    _record_group_reorder_tree_target(window, "b", "below")
    assert window._drop_group_index == 2


def test_tree_target_insert_before_nested_child():
    """Insert before X under A → (A, 0)."""

    class _Manager:
        groups = {
            "a": {"id": "a", "parent_id": None, "children": ["x", "y"]},
            "x": {"id": "x", "parent_id": "a"},
            "y": {"id": "y", "parent_id": "a"},
        }

        def sibling_index(self, group_id):
            parent = self.groups[group_id]["parent_id"]
            siblings = self.groups[parent]["children"]
            return parent, siblings.index(group_id)

    manager = _Manager()
    assert _tree_target_insert_before(manager, "x") == ("a", 0)
    assert _tree_target_insert_before(manager, "y") == ("a", 1)


def test_group_motion_shows_reorder_in_gap_before_sibling(monkeypatch):
    """Dragging C into the gap before B shows reorder above B (tree index 1)."""
    shown = []
    monkeypatch.setattr(
        sidebar_module,
        "_show_drop_indicator",
        lambda w, row, position: shown.append((row.group_id, position)),
    )
    monkeypatch.setattr(sidebar_module, "_show_drop_indicator_on_group", lambda w, r: None)
    monkeypatch.setattr(sidebar_module, "_show_ungrouped_area", lambda w: None)
    monkeypatch.setattr(sidebar_module, "_update_connection_autoscroll", lambda w, y: None)
    monkeypatch.setattr(sidebar_module.GLib, "get_monotonic_time", lambda: 100_000)

    a = _AllocRow(0, 48, group_id="a", header_height=40)
    row = _AllocRow(56, 48, group_id="b", header_height=40)
    c_row = _AllocRow(112, 48, group_id="c", header_height=40)
    for r in (a, row, c_row):
        r.is_tag_group = False
        r.show_drop_indicator = lambda top: None
        r.hide_drop_indicators = lambda: None
        r._member_rows = []
        r._child_group_rows = []

    class _Manager:
        groups = {
            "a": {"id": "a", "parent_id": None, "order": 0},
            "b": {"id": "b", "parent_id": None, "order": 1},
            "c": {"id": "c", "parent_id": None, "order": 2},
        }

        def sibling_index(self, group_id):
            order = sorted(
                self.groups,
                key=lambda gid: self.groups[gid].get("order", 0),
            )
            return None, order.index(group_id)

    window = types.SimpleNamespace()
    window._dragged_group_id = "c"
    window._drag_in_progress = True
    window._drop_indicator_row = None
    window._drop_indicator_position = None
    window.group_manager = _Manager()
    window.connection_list = _StubListBox(a, row, c_row)
    for r in (a, row, c_row):
        r.bind_listbox(window.connection_list)
    monkeypatch.setattr(
        sidebar_module, "_iter_host_group_rows", lambda w: iter([a, row, c_row])
    )

    # Seam between A and B (midpoint ~52, within hit px of B header top 56).
    sidebar_module._on_connection_list_motion(window, None, 0, 52)

    assert shown == [("b", "above")]


def test_group_motion_shows_nest_inside_row(monkeypatch):
    """Dragging a group anywhere inside a folder row shows Add to Group."""
    nested = []
    monkeypatch.setattr(
        sidebar_module,
        "_show_drop_indicator_on_group",
        lambda w, row: nested.append(row.group_id),
    )
    monkeypatch.setattr(sidebar_module, "_show_drop_indicator", lambda w, row, pos: None)
    monkeypatch.setattr(sidebar_module, "_show_ungrouped_area", lambda w: None)
    monkeypatch.setattr(sidebar_module, "_update_connection_autoscroll", lambda w, y: None)
    monkeypatch.setattr(sidebar_module.GLib, "get_monotonic_time", lambda: 100_000)

    row = _AllocRow(100, 40, group_id="b", header_height=40)
    a = _AllocRow(0, 48, group_id="a", header_height=40)
    c_row = _AllocRow(200, 48, group_id="c", header_height=40)
    for r in (a, row, c_row):
        r.is_tag_group = False
        r.show_drop_indicator = lambda top: None
        r.hide_drop_indicators = lambda: None
        r._member_rows = []
        r._child_group_rows = []

    class _Manager:
        groups = {
            "a": {"id": "a", "parent_id": None},
            "b": {"id": "b", "parent_id": None},
            "c": {"id": "c", "parent_id": None},
        }

    window = types.SimpleNamespace()
    window._dragged_group_id = "c"
    window._drag_in_progress = True
    window._drop_indicator_row = None
    window._drop_indicator_position = None
    window.group_manager = _Manager()
    window.connection_list = _StubListBox(a, row, c_row)
    for r in (a, row, c_row):
        r.bind_listbox(window.connection_list)
    monkeypatch.setattr(
        sidebar_module, "_iter_host_group_rows", lambda w: iter([a, row, c_row])
    )

    sidebar_module._on_connection_list_motion(window, None, 0, 120)

    assert nested == ["b"]


def test_group_drop_ignores_connection_target(monkeypatch):
    """A group drop whose target is a connection row does nothing."""
    monkeypatch.setattr(sidebar_module, "_clear_drop_indicator", lambda w: None)
    monkeypatch.setattr(sidebar_module, "_hide_ungrouped_area", lambda w: None)
    monkeypatch.setattr(sidebar_module, "_stop_connection_autoscroll", lambda w: None)

    placed = []

    class _ConnRow:
        connection = object()

    class _Manager:
        def __init__(self):
            self.groups = {"src": {"id": "src", "parent_id": None}}

        def place_group(self, *args):
            placed.append(args)
            return True

    conn_row = _ConnRow()
    monkeypatch.setattr(sidebar_module, "_row_at_y_or_nearest", lambda w, y: conn_row)

    window = types.SimpleNamespace()
    window._drop_indicator_row = conn_row
    window._drop_indicator_position = None
    window.group_manager = _Manager()
    window.rebuilt = []
    window.rebuild_connection_list = lambda: window.rebuilt.append(True)

    value = {"type": "group", "group_id": "src"}
    assert sidebar_module._on_connection_list_drop(window, None, value, 0, 50) is False
    assert placed == []
    assert window.rebuilt == []


def test_reset_drag_session_clears_leaked_group_id(monkeypatch):
    monkeypatch.setattr(sidebar_module, "_clear_drop_indicator", lambda w: None)
    monkeypatch.setattr(sidebar_module, "_stop_connection_autoscroll", lambda w: None)

    class _List:
        def __init__(self):
            self.selection_mode = None

        def set_selection_mode(self, mode):
            self.selection_mode = mode

    window = types.SimpleNamespace()
    window._dragged_group_id = "leaked"
    window._ungrouped_area_visible = True
    window._ungrouped_area_row = None
    window._drag_in_progress = True
    window.connection_list = _List()

    reset_connection_list_drag_session(window)

    assert not hasattr(window, "_dragged_group_id")
    assert window._ungrouped_area_visible is False
    assert window._drag_in_progress is False


class _MockListBox:
    def __init__(self, rows):
        self._rows = list(rows)
        for idx, row in enumerate(self._rows):
            row._parent = self
            row._index = idx
            row._next = self._rows[idx + 1] if idx + 1 < len(self._rows) else None

    def get_first_child(self):
        return self._rows[0] if self._rows else None

    def remove(self, row):
        if row in self._rows:
            self._rows.remove(row)
            row._parent = None
            self._reindex()

    def insert(self, row, index):
        if row in self._rows:
            self._rows.remove(row)
        self._rows.insert(index, row)
        row._parent = self
        self._reindex()

    def _reindex(self):
        for idx, row in enumerate(self._rows):
            row._index = idx
            row._next = self._rows[idx + 1] if idx + 1 < len(self._rows) else None


class _MockRow:
    def __init__(self, label):
        self.label = label
        self._parent = None
        self._index = -1
        self._next = None

    def get_parent(self):
        return self._parent

    def get_index(self):
        return self._index

    def get_next_sibling(self):
        return self._next


def test_collect_group_subtree_rows_depth_first():
    parent = _MockRow("parent")
    parent.group_id = "p"
    parent._member_rows = [_MockRow("m1"), _MockRow("m2")]
    child = _MockRow("child")
    child.group_id = "c"
    child._member_rows = [_MockRow("m3")]
    child._child_group_rows = []
    parent._child_group_rows = [child]

    labels = [row.label for row in _collect_group_subtree_rows(parent)]
    assert labels == ["parent", "m1", "m2", "child", "m3"]


def test_listbox_reposition_row_moves_widget():
    rows = [_MockRow("a"), _MockRow("b"), _MockRow("c")]
    listbox = _MockListBox(rows)

    _listbox_reposition_row(listbox, rows[2], 0)

    assert [row.label for row in listbox._rows] == ["c", "a", "b"]


def test_sync_group_member_rows_reorders_without_rebuild():
    group_row = _MockRow("group")
    group_row.group_id = "g1"
    group_row._indent_level = 0
    group_row._member_rows = []

    conn_a = types.SimpleNamespace(nickname="a")
    conn_b = types.SimpleNamespace(nickname="b")
    row_a = _MockRow("row-a")
    row_a.connection = conn_a
    row_b = _MockRow("row-b")
    row_b.connection = conn_b
    row_a.set_display_group_id = lambda gid: None
    row_b.set_display_group_id = lambda gid: None
    row_a.set_indentation = lambda level: None
    row_b.set_indentation = lambda level: None

    group_row._member_rows = [row_a, row_b]
    listbox = _MockListBox([group_row, row_b, row_a])

    class _Manager:
        groups = {"g1": {"connections": ["a", "b"]}}

        def get_connection_group(self, nickname):
            return "g1"

    class _ConnManager:
        @staticmethod
        def find_connection_by_nickname(nickname):
            return {"a": conn_a, "b": conn_b}[nickname]

    window = types.SimpleNamespace()
    window._sidebar_view = "hosts"
    window.search_entry = types.SimpleNamespace(get_text=lambda: "")
    window.group_manager = _Manager()
    window.connection_manager = _ConnManager()
    window.connection_list = listbox
    window._rows_for_connection = lambda conn: [row_a if conn is conn_a else row_b]

    assert _sync_group_member_rows(window, "g1") is True
    assert [row.label for row in listbox._rows] == ["group", "row-a", "row-b"]
    assert [row.connection.nickname for row in group_row._member_rows] == ["a", "b"]


def test_connection_drop_skips_rebuild_when_inplace_succeeds(monkeypatch):
    monkeypatch.setattr(sidebar_module, "_clear_drop_indicator", lambda w: None)
    monkeypatch.setattr(sidebar_module, "_hide_ungrouped_area", lambda w: None)
    monkeypatch.setattr(sidebar_module, "_stop_connection_autoscroll", lambda w: None)

    applied = []
    monkeypatch.setattr(
        sidebar_module,
        "_apply_connection_dnd_in_place",
        lambda w, nicks: applied.append(nicks) or True,
    )

    class _Conn:
        nickname = "host-a"

    class _RefConn:
        nickname = "host-b"

    target_row = types.SimpleNamespace()
    target_row.connection = _RefConn()
    target_row._group_id = "g1"
    target_row._in_tag_section = False
    target_row.get_parent = lambda: object()

    class _Manager:
        controller = None

    class _Client:
        def __init__(self):
            self.requests = []

        def move_connections(self, request):
            self.requests.append(request)
            return True

    window = types.SimpleNamespace()
    window._drop_indicator_row = target_row
    window._drop_indicator_position = "below"
    window._drag_in_progress = True
    window.group_manager = _Manager()
    window.client = _Client()
    window.connection_manager = types.SimpleNamespace(snapshot=lambda: None)
    window.connection_list = types.SimpleNamespace(
        set_selection_mode=lambda mode: None,
        get_row_at_y=lambda y: target_row,
    )
    window.rebuilt = []
    window.rebuild_connection_list = lambda: window.rebuilt.append(True)

    value = {
        "type": "connection",
        "connection_nickname": "host-a",
        "source_group_id": "g1",
        "source_group_coherent": True,
    }
    assert sidebar_module._on_connection_list_drop(window, None, value, 0, 50) is True
    assert applied == []
    assert len(window.client.requests) == 1
    assert window.client.requests[0].connection_ids == ("host-a",)
    assert window.client.requests[0].target_connection_id == "host-b"
    assert window.client.requests[0].source_group_id == "g1"
    assert window.client.requests[0].target_group_id == "g1"
    assert window.client.requests[0].mode.value == "preserve"
    assert window.rebuilt == []


def test_connection_tag_drop_submits_one_atomic_mutation(monkeypatch):
    monkeypatch.setattr(sidebar_module, "_clear_drop_indicator", lambda w: None)
    monkeypatch.setattr(sidebar_module, "_hide_ungrouped_area", lambda w: None)
    monkeypatch.setattr(sidebar_module, "_stop_connection_autoscroll", lambda w: None)

    class _Manager:
        controller = None

    class _Client:
        def __init__(self):
            self.tag_requests = []
            self.metadata_calls = []

        def add_tag_to_connections(self, request):
            self.tag_requests.append(request)
            return 3

        def update_connection_metadata(self, *args):
            self.metadata_calls.append(args)
            raise AssertionError("tag DnD must not patch metadata per connection")

    target_row = types.SimpleNamespace(
        is_tag_group=True,
        group_info={"name": "prod", "untagged": False},
        get_parent=lambda: object(),
    )
    window = types.SimpleNamespace(
        _drop_indicator_row=target_row,
        _drop_indicator_position="on_group",
        _drag_in_progress=True,
        group_manager=_Manager(),
        client=_Client(),
        connection_manager=types.SimpleNamespace(
            snapshot=lambda: types.SimpleNamespace(generation=9)
        ),
        connection_list=types.SimpleNamespace(
            set_selection_mode=lambda mode: None,
            get_row_at_y=lambda y: target_row,
        ),
    )
    value = {
        "type": "connection",
        "connection_nicknames": ["a", "b", "c"],
        "source_group_id": "web",
        "source_group_coherent": True,
    }
    assert sidebar_module._on_connection_list_drop(window, None, value, 0, 50) is True
    assert len(window.client.tag_requests) == 1
    request = window.client.tag_requests[0]
    assert request.connection_ids == ("a", "b", "c")
    assert request.tag == "prod"
    assert request.expected_generation == 9
    assert window.client.metadata_calls == []


def test_group_drop_reorder_skips_rebuild_when_inplace_succeeds(monkeypatch):
    monkeypatch.setattr(sidebar_module, "_clear_drop_indicator", lambda w: None)
    monkeypatch.setattr(sidebar_module, "_hide_ungrouped_area", lambda w: None)
    monkeypatch.setattr(sidebar_module, "_stop_connection_autoscroll", lambda w: None)

    applied = []
    monkeypatch.setattr(
        sidebar_module,
        "_apply_group_dnd_in_place",
        lambda w, gid, **kwargs: applied.append((gid, kwargs)) or True,
    )

    class _Manager:
        def __init__(self):
            self.groups = {
                "src": {"id": "src", "parent_id": None},
                "dst": {"id": "dst", "parent_id": None},
            }

    class _Client:
        def __init__(self):
            self.requests = []

        def place_group(self, request):
            self.requests.append(request)
            return True

    window = types.SimpleNamespace()
    window._drop_indicator_row = _AllocRow(0, 40, group_id="dst")
    window._drop_indicator_position = "above"
    window._drop_group_parent_id = None
    window._drop_group_index = 1
    window._drop_group_tree_target_set = True
    window.group_manager = _Manager()
    window.client = _Client()
    window.connection_manager = types.SimpleNamespace(snapshot=lambda: None)
    window.rebuilt = []
    window.rebuild_connection_list = lambda: window.rebuilt.append(True)

    value = {"type": "group", "group_id": "src"}
    assert sidebar_module._on_connection_list_drop(window, None, value, 0, 99999) is True
    assert applied == []
    assert len(window.client.requests) == 1
    assert window.client.requests[0].group_id == "src"
    assert window.client.requests[0].index == 1
    assert window.rebuilt == []


# ── autoscroll velocity (pure ramp used during drag near the list edges) ──────

def test_autoscroll_velocity_ramps_and_clamps():
    # No depth into the margin → no scroll.
    assert _calculate_autoscroll_velocity(0.0, 48.0, 10.0) == 0.0
    # Half-way into the margin → half max velocity.
    assert _calculate_autoscroll_velocity(24.0, 48.0, 10.0) == 5.0
    # At/over the margin the ratio is clamped to 1.0 (never exceeds max).
    assert _calculate_autoscroll_velocity(48.0, 48.0, 10.0) == 10.0
    assert _calculate_autoscroll_velocity(96.0, 48.0, 10.0) == 10.0
    # Negative distance is floored at 0 (no reverse scroll).
    assert _calculate_autoscroll_velocity(-5.0, 48.0, 10.0) == 0.0
