"""
Tests for the Inventory Screen UI

Tests the InventoryScreen class, including filtering functionality,
table population, and user interactions.

As with most of the UI tests, these are absolutely awful and only vaguely
useful since Textual's helpers require full asyncio, and I am not ready to
accept it into my heart just yet.
"""

import pytest
from textual.widgets import DataTable
from textual.widgets.data_table import CellDoesNotExist, RowDoesNotExist

from exosphere import context
from exosphere.data import Update
from exosphere.inventory import FilterMode, Inventory, SortField
from exosphere.objects import Host
from exosphere.ui.inventory import FilterScreen, InventoryScreen, SortScreen


def _wire_query_methods(inventory) -> None:
    """
    Bind the real Inventory filter/sort logic onto a mock inventory.

    The screen delegates filtering and sorting to the inventory, so mock
    inventories used in these tests need working implementations rather
    than auto-generated Mock return values.
    """
    inventory.filter_hosts = lambda mode, hosts=None: Inventory.filter_hosts(
        inventory, mode, hosts
    )
    inventory.sort_hosts = lambda by, hosts=None, reverse=False: Inventory.sort_hosts(
        inventory, by, hosts, reverse
    )


@pytest.fixture
def mock_inventory(mocker, make_host, wire_get_host):
    """
    Create a mock inventory with test hosts.
    """
    inventory = mocker.Mock()

    # Create real Update objects for testing
    update1 = Update(
        name="kernel",
        current_version="5.14.0",
        new_version="5.15.0",
        security=True,
        source="updates",
    )
    update2 = Update(
        name="bash",
        current_version="5.1",
        new_version="5.2",
        security=False,
        source="updates",
    )
    update3 = Update(
        name="openssl",
        current_version="3.0.0",
        new_version="3.0.7",
        security=True,
        source="updates",
    )

    # A series of mock hosts with pre-populated data, covering the
    # attribute surface the inventory screen reads back.
    host1 = make_host(
        "testserver1",
        ip="127.0.0.1",
        port=22,
        os="linux",
        flavor="rhel",
        version="9.3",
        description="Production server",
        online=True,
        supported=True,
        updates=[update1, update2],  # 2 updates
        security_updates=[update1],  # 1 security update
        is_stale=False,
        needs_reboot=None,
        last_refresh=mocker.Mock(),
    )

    host2 = make_host(
        "testserver2",
        ip="127.0.0.2",
        port=22,
        os="linux",
        flavor="debian",
        version="12",
        description="Test server",
        online=True,
        supported=True,
        updates=[],  # No updates
        security_updates=[],
        is_stale=False,
        needs_reboot=True,
        last_refresh=mocker.Mock(),
    )

    host3 = make_host(
        "testserver3",
        ip="127.0.0.3",
        port=22,
        os="freebsd",
        flavor="freebsd",
        version="14.0",
        description=None,
        online=False,
        supported=True,
        updates=[update3],  # 1 update
        security_updates=[update3],  # 1 security update
        is_stale=True,
        needs_reboot=False,
        last_refresh=mocker.Mock(),
    )

    inventory.hosts = [host1, host2, host3]
    wire_get_host(inventory)
    _wire_query_methods(inventory)

    return inventory


@pytest.fixture
def inventory_screen():
    """
    Create an InventoryScreen instance for testing.
    """
    return InventoryScreen()


@pytest.fixture
def setup_inventory_mock(mock_inventory, mocker):
    """
    Setup the global context with mock inventory.
    """
    mocker.patch.object(context, "inventory", mock_inventory)
    return mock_inventory


class TestInventoryScreenInitialization:
    """Test InventoryScreen initialization and basic setup."""

    def test_screen_initialization(self, inventory_screen):
        """
        Test that InventoryScreen initializes with correct defaults.
        """
        assert isinstance(inventory_screen, InventoryScreen)
        assert inventory_screen.current_filter == FilterMode.NONE

    def test_screen_has_bindings(self, inventory_screen):
        """
        Test that screen has expected key bindings.
        (Somewhat. We don't know if they work.)
        """
        from textual.binding import Binding

        bindings = []
        for b in inventory_screen.BINDINGS:
            if isinstance(b, Binding):
                bindings.append(b.key)
            else:
                bindings.append(b[0])

        assert "ctrl+r" in bindings
        assert "ctrl+x" in bindings
        assert "ctrl+f" in bindings


class TestFilteringLogic:
    """Test the filtering logic and get_filtered_hosts method."""

    def test_get_filtered_hosts_none(self, inventory_screen, setup_inventory_mock):
        """
        Test that NONE filter returns all hosts.
        """
        inventory_screen.current_filter = FilterMode.NONE
        hosts = inventory_screen.get_filtered_hosts()

        assert len(hosts) == 3
        assert hosts[0].name == "testserver1"
        assert hosts[1].name == "testserver2"
        assert hosts[2].name == "testserver3"

    def test_get_filtered_hosts_updates_only(
        self, inventory_screen, setup_inventory_mock
    ):
        """
        Test that UPDATES_ONLY filter returns only hosts with updates.
        """
        inventory_screen.current_filter = FilterMode.UPDATES_ONLY
        hosts = inventory_screen.get_filtered_hosts()

        # Should return host1 (2 updates) and host3 (1 update), but not host2 (no updates)
        assert len(hosts) == 2
        assert hosts[0].name == "testserver1"
        assert hosts[1].name == "testserver3"

    def test_get_filtered_hosts_security_only(
        self, inventory_screen, setup_inventory_mock
    ):
        """
        Test that SECURITY_ONLY filter returns only hosts with security updates.
        """
        inventory_screen.current_filter = FilterMode.SECURITY_ONLY
        hosts = inventory_screen.get_filtered_hosts()

        # Should return host1 and host3 (both have security updates), not host2
        assert len(hosts) == 2
        assert hosts[0].name == "testserver1"
        assert hosts[1].name == "testserver3"

    def test_get_filtered_hosts_no_inventory(self, inventory_screen, mocker):
        """
        Test that get_filtered_hosts handles missing inventory gracefully.
        """
        mocker.patch.object(context, "inventory", None)
        hosts = inventory_screen.get_filtered_hosts()

        assert hosts == []

    def test_get_filtered_hosts_empty_inventory(self, inventory_screen, mocker):
        """
        Test filtering with empty inventory.
        PRACTICALLY: The UI will handle this and avoid the codepath entirely,
        but we should still handle it gracefully.
        """
        mock_empty = mocker.Mock()
        mock_empty.hosts = []
        _wire_query_methods(mock_empty)
        mocker.patch.object(context, "inventory", mock_empty)

        inventory_screen.current_filter = FilterMode.UPDATES_ONLY
        hosts = inventory_screen.get_filtered_hosts()

        assert hosts == []


class TestActionFilterView:
    """Test the action_filter_view method and filter selection."""

    def test_action_filter_view_opens_filter_screen(
        self, inventory_screen, setup_inventory_mock, mocker
    ):
        """
        Test that action_filter_view pushes FilterScreen.
        """
        mock_app = mocker.Mock()
        mocker.patch.object(
            type(inventory_screen),
            "app",
            new_callable=mocker.PropertyMock,
            return_value=mock_app,
        )

        inventory_screen.action_filter_view()

        # Verify FilterScreen was pushed
        mock_app.push_screen.assert_called_once()
        args = mock_app.push_screen.call_args[0]
        assert isinstance(args[0], FilterScreen)
        # Second arg should be the callback
        assert callable(args[1])

    def test_action_filter_view_no_hosts(self, inventory_screen, mocker):
        """
        Test that action_filter_view shows error when no hosts available.
        """
        mock_app = mocker.Mock()
        mocker.patch.object(
            type(inventory_screen),
            "app",
            new_callable=mocker.PropertyMock,
            return_value=mock_app,
        )
        mocker.patch.object(context, "inventory", None)

        inventory_screen.action_filter_view()

        # Should push ErrorScreen instead of FilterScreen
        mock_app.push_screen.assert_called_once()
        from exosphere.ui.elements import ErrorScreen

        args = mock_app.push_screen.call_args[0]
        assert isinstance(args[0], ErrorScreen)

    def test_filter_selection_callback_updates_filter(
        self, inventory_screen, setup_inventory_mock, mocker
    ):
        """
        Test that selecting a filter updates current_filter and refreshes.
        """
        mock_app = mocker.Mock()
        mocker.patch.object(
            type(inventory_screen),
            "app",
            new_callable=mocker.PropertyMock,
            return_value=mock_app,
        )

        # Mock only refresh_rows, let label update happen naturally
        mock_refresh = mocker.patch.object(inventory_screen, "refresh_rows")
        mock_filter_label = mocker.Mock()
        mocker.patch.object(
            inventory_screen, "query_one", return_value=mock_filter_label
        )

        # Call action_filter_view to get the callback
        inventory_screen.action_filter_view()
        callback = mock_app.push_screen.call_args[0][1]

        # Simulate filter selection
        callback(FilterMode.UPDATES_ONLY)

        # Verify state was updated
        assert inventory_screen.current_filter == FilterMode.UPDATES_ONLY

        # Verify refresh was called
        mock_refresh.assert_called_once_with("filter")

        # Verify filter label was updated with correct text
        mock_filter_label.update.assert_called_once()
        label_text = mock_filter_label.update.call_args[0][0]
        assert f"Filtered: {FilterMode.UPDATES_ONLY}" in label_text

    def test_filter_selection_security_updates_label(
        self, inventory_screen, setup_inventory_mock, mocker
    ):
        """
        Test that selecting security filter updates label correctly.
        """
        mock_app = mocker.Mock()
        mocker.patch.object(
            type(inventory_screen),
            "app",
            new_callable=mocker.PropertyMock,
            return_value=mock_app,
        )

        mocker.patch.object(inventory_screen, "refresh_rows")  # Avoid side effects
        mock_filter_label = mocker.Mock()
        mocker.patch.object(
            inventory_screen, "query_one", return_value=mock_filter_label
        )

        inventory_screen.action_filter_view()
        callback = mock_app.push_screen.call_args[0][1]

        # Simulate security filter selection
        callback(FilterMode.SECURITY_ONLY)

        # Verify filter label shows security filter text
        label_text = mock_filter_label.update.call_args[0][0]
        assert f"Filtered: {FilterMode.SECURITY_ONLY}" in label_text

    def test_filter_selection_none_shows_base_label(
        self, inventory_screen, setup_inventory_mock, mocker
    ):
        """
        Test that NONE filter clears the filter label.
        """
        mock_app = mocker.Mock()
        mocker.patch.object(
            type(inventory_screen),
            "app",
            new_callable=mocker.PropertyMock,
            return_value=mock_app,
        )

        mocker.patch.object(inventory_screen, "refresh_rows")  # Avoid side effects
        mock_filter_label = mocker.Mock()
        mocker.patch.object(
            inventory_screen, "query_one", return_value=mock_filter_label
        )

        # Set a filter first
        inventory_screen.current_filter = FilterMode.UPDATES_ONLY

        inventory_screen.action_filter_view()
        callback = mock_app.push_screen.call_args[0][1]

        # Clear filter back to NONE
        callback(FilterMode.NONE)

        # Verify filter label is cleared (empty string)
        label_text = mock_filter_label.update.call_args[0][0]
        assert label_text == ""

    def test_filter_selection_callback_none_does_nothing(
        self, inventory_screen, setup_inventory_mock, mocker
    ):
        """
        Test that callback with None (cancel) doesn't change filter.
        """
        mock_app = mocker.Mock()
        mocker.patch.object(
            type(inventory_screen),
            "app",
            new_callable=mocker.PropertyMock,
            return_value=mock_app,
        )
        original_filter = inventory_screen.current_filter

        mock_refresh = mocker.patch.object(inventory_screen, "refresh_rows")

        # Call action_filter_view to get the callback
        inventory_screen.action_filter_view()
        callback = mock_app.push_screen.call_args[0][1]

        # Simulate cancellation (None)
        callback(None)

        # Verify filter was not changed
        assert inventory_screen.current_filter == original_filter

        # Verify refresh was not called
        mock_refresh.assert_not_called()


class TestRefreshRows:
    """Test the refresh_rows method."""

    def test_refresh_rows_clears_and_repopulates(
        self, inventory_screen, setup_inventory_mock, mocker
    ):
        """
        Test that refresh_rows clears table and repopulates with correct data.
        """
        mock_table = mocker.Mock(spec=DataTable)
        mocker.patch.object(inventory_screen, "query_one", return_value=mock_table)
        mock_app = mocker.Mock()
        mocker.patch.object(
            type(inventory_screen),
            "app",
            new_callable=mocker.PropertyMock,
            return_value=mock_app,
        )

        inventory_screen.refresh_rows()

        # Should clear table (but keep columns)
        mock_table.clear.assert_called_once_with(columns=False)

        # Should add rows for all hosts (3 total)
        assert mock_table.add_row.call_count == 3

        # Should notify
        mock_app.notify.assert_called_once()

    def test_refresh_rows_empty_result_notify_does_not_leak(
        self, inventory_screen, setup_inventory_mock, mocker
    ):
        """The empty-result toast respects notify (silent on a quiet refresh)."""
        mock_table = mocker.Mock(spec=DataTable)
        mocker.patch.object(inventory_screen, "query_one", return_value=mock_table)
        mock_app = mocker.Mock()
        mocker.patch.object(
            type(inventory_screen),
            "app",
            new_callable=mocker.PropertyMock,
            return_value=mock_app,
        )
        mocker.patch.object(inventory_screen, "_get_display_hosts", return_value=[])

        # Quiet post-task refresh: no toast even though the view is empty.
        inventory_screen.refresh_rows(notify=False)
        mock_app.notify.assert_not_called()

        # Announced refresh (filter change / resume): empty-result toast fires.
        inventory_screen.refresh_rows(notify=True)
        mock_app.notify.assert_called_once()

    def test_refresh_rows_with_filter(
        self, inventory_screen, setup_inventory_mock, mocker
    ):
        """
        Test that refresh_rows uses filtered hosts and populates correct data.
        """
        mock_table = mocker.Mock(spec=DataTable)
        mocker.patch.object(inventory_screen, "query_one", return_value=mock_table)
        mock_app = mocker.Mock()
        mocker.patch.object(
            type(inventory_screen),
            "app",
            new_callable=mocker.PropertyMock,
            return_value=mock_app,
        )

        # Set filter to updates only
        inventory_screen.current_filter = FilterMode.UPDATES_ONLY
        inventory_screen.refresh_rows("filter")

        # Should only add 2 rows (hosts with updates)
        assert mock_table.add_row.call_count == 2

    def test_refresh_rows_formats_stale_indicator(
        self, inventory_screen, setup_inventory_mock, mocker
    ):
        """
        Test that refresh_rows formats stale hosts with asterisk indicator.
        """
        mock_table = mocker.Mock(spec=DataTable)
        mocker.patch.object(inventory_screen, "query_one", return_value=mock_table)
        mock_app = mocker.Mock()
        mocker.patch.object(
            type(inventory_screen),
            "app",
            new_callable=mocker.PropertyMock,
            return_value=mock_app,
        )

        inventory_screen.refresh_rows()

        # Get the third host (server3) which is stale
        third_call = mock_table.add_row.call_args_list[2]
        row_data = third_call[0]

        # Updates column should have stale indicator
        updates_col = row_data[4]  # 5th column (0-indexed)
        assert "*" in updates_col  # Stale indicator

    def test_refresh_rows_keys_rows_by_host_name(
        self, inventory_screen, setup_inventory_mock, mocker
    ):
        """Rows are keyed by host name so selection resolves via RowKey."""
        mock_table = mocker.Mock(spec=DataTable)
        mocker.patch.object(inventory_screen, "query_one", return_value=mock_table)
        mock_app = mocker.Mock()
        mocker.patch.object(
            type(inventory_screen),
            "app",
            new_callable=mocker.PropertyMock,
            return_value=mock_app,
        )

        inventory_screen.refresh_rows()

        assert context.inventory is not None
        for call, host in zip(
            mock_table.add_row.call_args_list, context.inventory.hosts, strict=True
        ):
            assert call.kwargs["key"] == host.name

    def test_refresh_rows_no_matching_hosts(
        self, inventory_screen, setup_inventory_mock, mocker
    ):
        """
        Test refresh_rows when no hosts match filter.
        """
        mock_table = mocker.Mock(spec=DataTable)
        mocker.patch.object(inventory_screen, "query_one", return_value=mock_table)
        mock_app = mocker.Mock()
        mocker.patch.object(
            type(inventory_screen),
            "app",
            new_callable=mocker.PropertyMock,
            return_value=mock_app,
        )

        # Create inventory with no security updates
        mock_inv = mocker.Mock()
        host = mocker.Mock(spec=Host)
        host.supported = True
        host.updates = []
        host.security_updates = []
        mock_inv.hosts = [host]
        _wire_query_methods(mock_inv)
        mocker.patch.object(context, "inventory", mock_inv)

        # Set filter to security only
        inventory_screen.current_filter = FilterMode.SECURITY_ONLY
        inventory_screen.refresh_rows()

        # Should clear table
        mock_table.clear.assert_called_once_with(columns=False)

        # Should notify with error severity
        assert mock_app.notify.call_count == 1
        notify_kwargs = mock_app.notify.call_args[1]
        assert notify_kwargs.get("severity") == "error"


class TestRunTask:
    """Test the task running features of InventoryScreen."""

    def test_inventory_screen_get_screen_name(self, inventory_screen):
        """Test get_screen_name returns correct identifier."""
        assert inventory_screen.get_screen_name() == "inventory"

    def test_inventory_refresh_data_after_task(
        self, mocker, setup_inventory_mock, inventory_screen
    ):
        """Test refresh_data_after_task calls refresh_rows."""
        mock_refresh = mocker.patch.object(inventory_screen, "refresh_rows")

        inventory_screen.refresh_data_after_task("test_task")

        mock_refresh.assert_called_once_with("test_task", notify=True)

    def test_inventory_refresh_data_after_task_quiet(
        self, mocker, setup_inventory_mock, inventory_screen
    ):
        """Test refresh_data_after_task forwards notify=False to refresh_rows."""
        mock_refresh = mocker.patch.object(inventory_screen, "refresh_rows")

        inventory_screen.refresh_data_after_task("test_task", notify=False)

        mock_refresh.assert_called_once_with("test_task", notify=False)


class TestSortScreen:
    """Test the SortScreen modal selection logic."""

    def test_select_field_dismisses_with_pair(self, mocker):
        """Selecting a field dismisses with (SortField, reverse)."""
        screen = SortScreen()
        mock_checkbox = mocker.Mock()
        mock_checkbox.value = True
        mocker.patch.object(screen, "query_one", return_value=mock_checkbox)
        mock_dismiss = mocker.patch.object(screen, "dismiss")

        event = mocker.Mock()
        event.item.id = "sort-os"
        screen.on_list_view_selected(event)

        mock_dismiss.assert_called_once_with((SortField.OS, True))
        event.stop.assert_called_once()

    def test_select_default_dismisses_with_none_field(self, mocker):
        """Selecting "Default order" dismisses with (None, reverse)."""
        screen = SortScreen()
        mock_checkbox = mocker.Mock()
        mock_checkbox.value = False
        mocker.patch.object(screen, "query_one", return_value=mock_checkbox)
        mock_dismiss = mocker.patch.object(screen, "dismiss")

        event = mocker.Mock()
        event.item.id = "sort-none"
        screen.on_list_view_selected(event)

        mock_dismiss.assert_called_once_with((None, False))

    def test_select_no_id_does_nothing(self, mocker):
        """A selection event with no id is ignored."""
        screen = SortScreen()
        mock_dismiss = mocker.patch.object(screen, "dismiss")

        event = mocker.Mock()
        event.item.id = None
        screen.on_list_view_selected(event)

        mock_dismiss.assert_not_called()

    def test_escape_dismisses_with_none(self, mocker):
        """ESC cancels with None (no change)."""
        screen = SortScreen()
        mock_dismiss = mocker.patch.object(screen, "dismiss")

        event = mocker.Mock()
        event.key = "escape"
        screen.on_key(event)

        mock_dismiss.assert_called_once_with(None)

    @pytest.mark.parametrize(
        "key, expected_field",
        [
            ("h", SortField.HOST),
            ("o", SortField.OS),
            ("f", SortField.FLAVOR),
            ("v", SortField.VERSION),
            ("u", SortField.UPDATES),
            ("s", SortField.SECURITY),
            ("a", SortField.STATUS),
            ("H", SortField.HOST),  # case-insensitive
            ("d", None),  # default/config order
        ],
        ids=[
            "host",
            "os",
            "flavor",
            "version",
            "updates",
            "security",
            "status",
            "host-uppercase",
            "default",
        ],
    )
    def test_quick_key_selects_field_without_dismissing(
        self, mocker, key, expected_field
    ):
        """Each quick-key selects its field and does not dismiss."""
        screen = SortScreen()

        # List will have Default + sortfields, with ids "sort-none" and "sort-{field}"
        item_ids = ["sort-none"] + [f"sort-{field.value}" for field in SortField]
        expected_id = (
            "sort-none" if expected_field is None else f"sort-{expected_field.value}"
        )

        mock_list = mocker.Mock()
        mock_list.children = [mocker.Mock(id=item_id) for item_id in item_ids]
        mocker.patch.object(screen, "query_one", return_value=mock_list)
        mock_dismiss = mocker.patch.object(screen, "dismiss")

        event = mocker.Mock()
        event.key = key
        screen.on_key(event)

        assert mock_list.index == item_ids.index(expected_id)
        mock_list.focus.assert_called_once()
        mock_dismiss.assert_not_called()  # We shouldn't dismiss here
        event.stop.assert_called_once()

    def test_reverse_key_toggles_checkbox_without_dismissing(self, mocker):
        """'r' flips the reverse checkbox and does not dismiss."""
        screen = SortScreen()
        mock_checkbox = mocker.Mock()
        mock_checkbox.value = False
        mocker.patch.object(screen, "query_one", return_value=mock_checkbox)
        mock_dismiss = mocker.patch.object(screen, "dismiss")

        event = mocker.Mock()
        event.key = "r"
        screen.on_key(event)

        assert mock_checkbox.value is True
        mock_dismiss.assert_not_called()

    def test_unmapped_key_is_ignored(self, mocker):
        """A key with no binding doesn't do anything."""
        screen = SortScreen()
        mock_checkbox = mocker.Mock()
        mock_checkbox.value = False
        mocker.patch.object(screen, "query_one", return_value=mock_checkbox)
        mock_dismiss = mocker.patch.object(screen, "dismiss")

        event = mocker.Mock()
        event.key = "z"
        screen.on_key(event)

        mock_dismiss.assert_not_called()
        event.stop.assert_not_called()


class TestActionSortView:
    """Test the action_sort_view method and sort selection."""

    def test_action_sort_view_opens_sort_screen(
        self, inventory_screen, setup_inventory_mock, mocker
    ):
        """action_sort_view pushes a SortScreen with a callback."""
        mock_app = mocker.Mock()
        mocker.patch.object(
            type(inventory_screen),
            "app",
            new_callable=mocker.PropertyMock,
            return_value=mock_app,
        )

        inventory_screen.action_sort_view()

        mock_app.push_screen.assert_called_once()
        args = mock_app.push_screen.call_args[0]
        assert isinstance(args[0], SortScreen)
        assert callable(args[1])

    def test_action_sort_view_no_hosts(self, inventory_screen, mocker):
        """action_sort_view shows error when no hosts available."""
        from exosphere.ui.elements import ErrorScreen

        mock_app = mocker.Mock()
        mocker.patch.object(
            type(inventory_screen),
            "app",
            new_callable=mocker.PropertyMock,
            return_value=mock_app,
        )
        mocker.patch.object(context, "inventory", None)

        inventory_screen.action_sort_view()

        args = mock_app.push_screen.call_args[0]
        assert isinstance(args[0], ErrorScreen)

    def test_sort_selection_applies_sort(
        self, inventory_screen, setup_inventory_mock, mocker
    ):
        """Selecting a field updates state, refreshes, and updates status bar."""
        mock_app = mocker.Mock()
        mocker.patch.object(
            type(inventory_screen),
            "app",
            new_callable=mocker.PropertyMock,
            return_value=mock_app,
        )
        mock_refresh = mocker.patch.object(inventory_screen, "refresh_rows")
        mock_label = mocker.Mock()
        mocker.patch.object(inventory_screen, "query_one", return_value=mock_label)

        inventory_screen.action_sort_view()
        callback = mock_app.push_screen.call_args[0][1]
        callback((SortField.OS, True))

        assert inventory_screen.current_sort == SortField.OS
        assert inventory_screen.sort_reverse is True
        mock_refresh.assert_called_once_with("sort")

        label_text = mock_label.update.call_args[0][0]
        assert "Sorted: OS" in label_text
        assert "↓" in label_text

    def test_sort_selection_clear_restores_default(
        self, inventory_screen, setup_inventory_mock, mocker
    ):
        """Selecting "Default order" clears the active sort."""
        mock_app = mocker.Mock()
        mocker.patch.object(
            type(inventory_screen),
            "app",
            new_callable=mocker.PropertyMock,
            return_value=mock_app,
        )
        mocker.patch.object(inventory_screen, "refresh_rows")
        mocker.patch.object(inventory_screen, "query_one", return_value=mocker.Mock())

        inventory_screen.current_sort = SortField.OS
        inventory_screen.sort_reverse = True

        inventory_screen.action_sort_view()
        callback = mock_app.push_screen.call_args[0][1]
        callback((None, False))

        assert inventory_screen.current_sort is None
        assert inventory_screen.sort_reverse is False

    def test_sort_selection_cancel_does_nothing(
        self, inventory_screen, setup_inventory_mock, mocker
    ):
        """Callback with None (cancel) leaves sort unchanged."""
        mock_app = mocker.Mock()
        mocker.patch.object(
            type(inventory_screen),
            "app",
            new_callable=mocker.PropertyMock,
            return_value=mock_app,
        )
        mock_refresh = mocker.patch.object(inventory_screen, "refresh_rows")

        inventory_screen.action_sort_view()
        callback = mock_app.push_screen.call_args[0][1]
        callback(None)

        assert inventory_screen.current_sort is None
        mock_refresh.assert_not_called()

    def test_get_display_hosts_applies_sort(
        self, inventory_screen, setup_inventory_mock
    ):
        """_get_display_hosts sorts the filtered hosts when a sort is set."""
        inventory_screen.current_sort = SortField.HOST
        inventory_screen.sort_reverse = True

        hosts = inventory_screen._get_display_hosts()

        assert [h.name for h in hosts] == [
            "testserver3",
            "testserver2",
            "testserver1",
        ]

    def test_status_bar_combines_filter_and_sort(self, inventory_screen, mocker):
        """The status bar shows both the active filter and sort."""
        mock_label = mocker.Mock()
        mocker.patch.object(inventory_screen, "query_one", return_value=mock_label)

        inventory_screen.current_filter = FilterMode.UPDATES_ONLY
        inventory_screen.current_sort = SortField.OS
        inventory_screen.sort_reverse = False

        inventory_screen._update_status_bar()

        text = mock_label.update.call_args[0][0]
        assert "Filtered: Updates" in text
        assert "Sorted: OS ↑" in text


class TestSelectedHost:
    """Resolution of the host under the inventory table cursor."""

    def test_get_selected_host_resolves_cursor_row(
        self, mocker, setup_inventory_mock, inventory_screen
    ):
        """The host under the row cursor is resolved from the inventory."""
        from textual.coordinate import Coordinate

        mock_table = mocker.Mock()
        mock_table.row_count = 3
        mock_table.cursor_row = 1
        cell_key = mocker.Mock()
        cell_key.row_key.value = "testserver2"
        mock_table.coordinate_to_cell_key.return_value = cell_key
        mocker.patch.object(inventory_screen, "query_one", return_value=mock_table)

        host = inventory_screen.get_selected_host()

        assert host is setup_inventory_mock.get_host("testserver2")
        mock_table.coordinate_to_cell_key.assert_called_once_with(Coordinate(1, 0))

    def test_get_selected_host_no_inventory(self, mocker, inventory_screen):
        """No inventory yields None."""
        mocker.patch.object(context, "inventory", None)
        assert inventory_screen.get_selected_host() is None

    def test_get_selected_host_empty_table(
        self, mocker, setup_inventory_mock, inventory_screen
    ):
        """An empty table yields None."""
        mock_table = mocker.Mock()
        mock_table.row_count = 0
        mocker.patch.object(inventory_screen, "query_one", return_value=mock_table)
        assert inventory_screen.get_selected_host() is None

    def test_get_selected_host_no_table(
        self, mocker, setup_inventory_mock, inventory_screen
    ):
        """A missing DataTable (e.g. empty inventory view) yields None."""
        from textual.css.query import NoMatches

        mocker.patch.object(
            inventory_screen, "query_one", side_effect=NoMatches("no table")
        )
        assert inventory_screen.get_selected_host() is None

    @pytest.mark.parametrize("exc", [CellDoesNotExist, RowDoesNotExist])
    def test_get_selected_host_invalid_cursor_row(
        self, exc, mocker, setup_inventory_mock, inventory_screen
    ):
        """An out-of-range cursor row yields None rather than raising."""
        mock_table = mocker.Mock()
        mock_table.row_count = 3
        mock_table.cursor_row = 99
        mock_table.coordinate_to_cell_key.side_effect = exc("bad row")
        mocker.patch.object(inventory_screen, "query_one", return_value=mock_table)

        assert inventory_screen.get_selected_host() is None


class TestSaveCursor:
    """The cursor and scroll positions are preserved across table rebuilds."""

    def _table(self, mocker, *, selected: str, scroll: float = 7.0):
        """Build a DataTable mock whose cursor is a selection"""
        table = mocker.Mock(spec=DataTable)
        table.cursor_row = 0
        cell_key = mocker.Mock()
        cell_key.row_key.value = selected
        table.coordinate_to_cell_key.return_value = cell_key
        table.scroll_y = scroll
        return table

    def test_restores_cursor_and_scroll(self, inventory_screen, mocker):
        """The selected host and scroll offset survive a rebuild."""
        table = self._table(mocker, selected="server3", scroll=7.0)
        table.get_row_index.return_value = 5

        with inventory_screen.save_cursor(table):
            table.scroll_y = 0.0  # simulate resetting the scroll

        table.get_row_index.assert_called_once_with("server3")
        table.move_cursor.assert_called_once_with(row=5, scroll=False)
        assert table.scroll_y == 7.0

    def test_host_no_longer_displayed(self, inventory_screen, mocker):
        """A host filtered out by the rebuild leaves the cursor alone."""
        table = self._table(mocker, selected="gone")
        table.get_row_index.side_effect = RowDoesNotExist("gone")

        with inventory_screen.save_cursor(table):
            pass

        table.move_cursor.assert_not_called()

    def test_no_selection(self, inventory_screen, mocker):
        """An empty/invalid cursor before the rebuild restores nothing."""
        table = mocker.Mock(spec=DataTable)
        table.cursor_row = 0
        table.coordinate_to_cell_key.side_effect = CellDoesNotExist("empty")
        table.scroll_y = 0.0

        with inventory_screen.save_cursor(table):
            pass

        table.get_row_index.assert_not_called()
        table.move_cursor.assert_not_called()
