"""
Integration tests for Virtual Chassis detection using the mock LibreNMS HTTP server.

These tests verify that detect_virtual_chassis_from_inventory(), get_virtual_chassis_data(),
and prefetch_vc_data_for_devices() work correctly end-to-end through real HTTP calls
to a local mock server — no mocking of the detection logic itself.

Run:
    python -m pytest netbox_librenms_plugin/tests/test_integration_virtual_chassis.py -v
"""

import pytest
from unittest.mock import patch

from netbox_librenms_plugin.tests.mock_librenms_server import librenms_mock_server


@pytest.fixture
def mock_server():
    with librenms_mock_server() as server:
        yield server


def _make_api(url, token="test-token", server_key="test"):
    """Create a LibreNMSAPI instance pointed at the mock server."""
    from netbox_librenms_plugin.librenms_api import LibreNMSAPI

    servers_config = {
        server_key: {
            "librenms_url": url,
            "api_token": token,
            "cache_timeout": 300,
            "verify_ssl": False,
        }
    }

    with patch("netbox_librenms_plugin.librenms_api.get_plugin_config") as mock_cfg:
        mock_cfg.side_effect = lambda _plugin, key: servers_config if key == "servers" else None
        api = LibreNMSAPI(server_key=server_key)
    return api


def _chassis(index, serial, model="WS-C3750X", name="", descr="", position=None, contained_in=None):
    """Build a minimal ENTITY-MIB chassis entry."""
    item = {
        "entPhysicalIndex": index,
        "entPhysicalClass": "chassis",
        "entPhysicalSerialNum": serial,
        "entPhysicalModelName": model,
        "entPhysicalName": name or f"Chassis-{index}",
        "entPhysicalDescr": descr or f"Chassis {index}",
    }
    if position is not None:
        item["entPhysicalParentRelPos"] = position
    if contained_in is not None:
        item["entPhysicalContainedIn"] = contained_in
    return item


def _stack_root(index=1):
    """Build a 'stack' class root entry (e.g., Cisco StackWise)."""
    return {
        "entPhysicalIndex": index,
        "entPhysicalClass": "stack",
        "entPhysicalSerialNum": "",
        "entPhysicalModelName": "",
        "entPhysicalName": "StackSub-0/0",
        "entPhysicalDescr": "Cisco StackWise",
        "entPhysicalContainedIn": 0,
    }


class TestDetectVCCiscoStack:
    """Cisco StackWise topology: root has stack-class entry; children are chassis members."""

    def test_three_member_stack(self, mock_server):
        """3 chassis members under a stack root → is_stack=True, member_count=3."""
        from netbox_librenms_plugin.import_utils.virtual_chassis import detect_virtual_chassis_from_inventory

        api = _make_api(mock_server.url)
        device_id = 10

        mock_server.device_info_response(device_id=device_id, hostname="sw-stack", serial="MASTER")

        root_items = [_stack_root(index=1)]
        member_items = [
            _chassis(100, "SN-A", position=1),
            _chassis(200, "SN-B", position=2),
            _chassis(300, "SN-C", position=3),
        ]
        mock_server.vc_inventory_callable(device_id, root_items, {1: member_items})

        with patch(
            "netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern",
            return_value="{master}-m{position}",
        ):
            result = detect_virtual_chassis_from_inventory(api, device_id)

        assert result is not None
        assert result["is_stack"] is True
        assert result["member_count"] == 3
        serials = [m["serial"] for m in result["members"]]
        assert serials == ["SN-A", "SN-B", "SN-C"]

    def test_members_sorted_by_position(self, mock_server):
        """Members returned in position order regardless of API order."""
        from netbox_librenms_plugin.import_utils.virtual_chassis import detect_virtual_chassis_from_inventory

        api = _make_api(mock_server.url)
        device_id = 11

        mock_server.device_info_response(device_id=device_id, hostname="sw-stack-2")
        root_items = [_stack_root(index=5)]
        # Deliberately out of order: 3, 1, 2
        member_items = [
            _chassis(301, "SN-3", position=3),
            _chassis(101, "SN-1", position=1),
            _chassis(201, "SN-2", position=2),
        ]
        mock_server.vc_inventory_callable(device_id, root_items, {5: member_items})

        with patch(
            "netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern",
            return_value="{master}-m{position}",
        ):
            result = detect_virtual_chassis_from_inventory(api, device_id)

        assert result is not None
        positions = [m["position"] for m in result["members"]]
        assert positions == [1, 2, 3]

    def test_position_zero_falls_back_to_idx_plus_one(self, mock_server):
        """position=0 in entPhysicalParentRelPos → fallback to idx+1 (never 0)."""
        from netbox_librenms_plugin.import_utils.virtual_chassis import detect_virtual_chassis_from_inventory

        api = _make_api(mock_server.url)
        device_id = 12

        mock_server.device_info_response(device_id=device_id, hostname="sw-stack-3")
        root_items = [_stack_root(index=1)]
        member_items = [
            _chassis(100, "SN-X", position=0),  # 0 → fallback to idx+1=1
            _chassis(200, "SN-Y", position=0),  # 0 → fallback to idx+1=2
        ]
        mock_server.vc_inventory_callable(device_id, root_items, {1: member_items})

        with patch(
            "netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern",
            return_value="{master}-m{position}",
        ):
            result = detect_virtual_chassis_from_inventory(api, device_id)

        assert result is not None
        positions = [m["position"] for m in result["members"]]
        # Both had position=0, so they fall back to idx+1: positions [1, 2]
        assert all(p >= 1 for p in positions)

    def test_member_fields_extracted_correctly(self, mock_server):
        """serial, model, name, description all extracted from chassis entries."""
        from netbox_librenms_plugin.import_utils.virtual_chassis import detect_virtual_chassis_from_inventory

        api = _make_api(mock_server.url)
        device_id = 13

        mock_server.device_info_response(device_id=device_id, hostname="sw-stack-4")
        root_items = [_stack_root(index=1)]
        member_items = [
            {
                "entPhysicalIndex": 100,
                "entPhysicalClass": "chassis",
                "entPhysicalSerialNum": "SERIAL-ABC",
                "entPhysicalModelName": "WS-C3750X-48P",
                "entPhysicalName": "Slot 1",
                "entPhysicalDescr": "48-port PoE switch",
                "entPhysicalParentRelPos": 1,
            },
            {
                "entPhysicalIndex": 200,
                "entPhysicalClass": "chassis",
                "entPhysicalSerialNum": "SERIAL-DEF",
                "entPhysicalModelName": "WS-C3750X-24T",
                "entPhysicalName": "Slot 2",
                "entPhysicalDescr": "24-port switch",
                "entPhysicalParentRelPos": 2,
            },
        ]
        mock_server.vc_inventory_callable(device_id, root_items, {1: member_items})

        with patch(
            "netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern",
            return_value="{master}-m{position}",
        ):
            result = detect_virtual_chassis_from_inventory(api, device_id)

        assert result is not None
        m1, m2 = result["members"]
        assert m1["serial"] == "SERIAL-ABC"
        assert m1["model"] == "WS-C3750X-48P"
        assert m1["name"] == "Slot 1"
        assert m1["description"] == "48-port PoE switch"
        assert m2["serial"] == "SERIAL-DEF"

    def test_suggested_name_uses_master_sysname(self, mock_server):
        """suggested_name generated from master device sysName."""
        from netbox_librenms_plugin.import_utils.virtual_chassis import detect_virtual_chassis_from_inventory

        api = _make_api(mock_server.url)
        device_id = 14

        mock_server.device_info_response(device_id=device_id, hostname="sw-master", serial="MASTER01")
        root_items = [_stack_root(index=1)]
        member_items = [
            _chassis(100, "SN-1", position=1),
            _chassis(200, "SN-2", position=2),
        ]
        mock_server.vc_inventory_callable(device_id, root_items, {1: member_items})

        with patch(
            "netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern",
            return_value="{master}-m{position}",
        ):
            result = detect_virtual_chassis_from_inventory(api, device_id)

        assert result is not None
        # Members should have non-empty suggested names
        for member in result["members"]:
            assert "suggested_name" in member
            assert member["suggested_name"]  # non-empty


class TestDetectVCJuniperStyle:
    """Juniper-style: root has chassis-class entry; children are chassis members."""

    def test_two_member_vc(self, mock_server):
        """2 chassis members under a chassis root → is_stack=True, member_count=2."""
        from netbox_librenms_plugin.import_utils.virtual_chassis import detect_virtual_chassis_from_inventory

        api = _make_api(mock_server.url)
        device_id = 20

        mock_server.device_info_response(device_id=device_id, hostname="vc-switch")
        # Root: a chassis entry (not stack)
        root_items = [
            {
                "entPhysicalIndex": 10,
                "entPhysicalClass": "chassis",
                "entPhysicalSerialNum": "",
                "entPhysicalModelName": "",
                "entPhysicalName": "Virtual Chassis",
                "entPhysicalDescr": "EX4300 Virtual Chassis",
                "entPhysicalContainedIn": 0,
            }
        ]
        member_items = [
            _chassis(100, "JN-SN-1", position=0),  # Juniper uses position=0,1 (1-based after fallback)
            _chassis(200, "JN-SN-2", position=1),
        ]
        mock_server.vc_inventory_callable(device_id, root_items, {10: member_items})

        with patch(
            "netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern",
            return_value="{master}-m{position}",
        ):
            result = detect_virtual_chassis_from_inventory(api, device_id)

        assert result is not None
        assert result["is_stack"] is True
        assert result["member_count"] == 2


class TestDetectVCStackPreferredOverChassis:
    """When root has both stack and chassis entries, stack index takes priority."""

    def test_stack_index_used_not_chassis(self, mock_server):
        from netbox_librenms_plugin.import_utils.virtual_chassis import detect_virtual_chassis_from_inventory

        api = _make_api(mock_server.url)
        device_id = 30

        mock_server.device_info_response(device_id=device_id, hostname="sw-mixed")
        # Root has BOTH stack (index=5) and chassis (index=6)
        root_items = [
            {
                "entPhysicalIndex": 5,
                "entPhysicalClass": "stack",
                "entPhysicalName": "Stack-0",
                "entPhysicalSerialNum": "",
                "entPhysicalModelName": "",
                "entPhysicalDescr": "",
                "entPhysicalContainedIn": 0,
            },
            {
                "entPhysicalIndex": 6,
                "entPhysicalClass": "chassis",
                "entPhysicalName": "Chassis-0",
                "entPhysicalSerialNum": "",
                "entPhysicalModelName": "",
                "entPhysicalDescr": "",
                "entPhysicalContainedIn": 0,
            },
        ]
        # Stack index=5 has 2 members, chassis index=6 has 0
        children = {
            5: [_chassis(100, "SN-1", position=1), _chassis(200, "SN-2", position=2)],
            6: [],  # chassis has no children
        }
        mock_server.vc_inventory_callable(device_id, root_items, children)

        with patch(
            "netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern",
            return_value="{master}-m{position}",
        ):
            result = detect_virtual_chassis_from_inventory(api, device_id)

        # Must have detected 2 members (via stack index), not 0 (via chassis index)
        assert result is not None
        assert result["member_count"] == 2


class TestDetectVCSingleDevice:
    """Non-stack device: only 1 chassis child → returns None."""

    def test_single_chassis_child_returns_none(self, mock_server):
        from netbox_librenms_plugin.import_utils.virtual_chassis import detect_virtual_chassis_from_inventory

        api = _make_api(mock_server.url)
        device_id = 40

        mock_server.device_info_response(device_id=device_id, hostname="single-sw")
        root_items = [_stack_root(index=1)]
        # Only 1 chassis child → not a VC
        member_items = [_chassis(100, "SN-ONLY", position=1)]
        mock_server.vc_inventory_callable(device_id, root_items, {1: member_items})

        with patch(
            "netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern",
            return_value="{master}-m{position}",
        ):
            result = detect_virtual_chassis_from_inventory(api, device_id)

        assert result is None

    def test_no_stack_or_chassis_root_returns_none(self, mock_server):
        """Root has only non-stack/chassis entries → returns None."""
        from netbox_librenms_plugin.import_utils.virtual_chassis import detect_virtual_chassis_from_inventory

        api = _make_api(mock_server.url)
        device_id = 41

        mock_server.device_info_response(device_id=device_id, hostname="plain-router")
        root_items = [
            {
                "entPhysicalIndex": 1,
                "entPhysicalClass": "module",
                "entPhysicalName": "Main Module",
                "entPhysicalSerialNum": "SN1",
                "entPhysicalModelName": "ASR1001-X",
                "entPhysicalDescr": "ASR1001-X",
                "entPhysicalContainedIn": 0,
            }
        ]
        # Register root-only, no children needed
        mock_server.vc_inventory_callable(device_id, root_items, {})

        with patch(
            "netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern",
            return_value="{master}-m{position}",
        ):
            result = detect_virtual_chassis_from_inventory(api, device_id)

        assert result is None


class TestDetectVCEdgeCases:
    """API errors and empty responses."""

    def test_empty_root_inventory_returns_none(self, mock_server):
        from netbox_librenms_plugin.import_utils.virtual_chassis import detect_virtual_chassis_from_inventory

        api = _make_api(mock_server.url)
        device_id = 50

        mock_server.device_info_response(device_id=device_id, hostname="empty-sw")
        mock_server.vc_inventory_callable(device_id, [], {})  # empty root

        with patch(
            "netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern",
            return_value="{master}-m{position}",
        ):
            result = detect_virtual_chassis_from_inventory(api, device_id)

        assert result is None

    def test_api_error_on_root_returns_none(self, mock_server):
        """500 error on root inventory → returns None."""
        from netbox_librenms_plugin.import_utils.virtual_chassis import detect_virtual_chassis_from_inventory

        api = _make_api(mock_server.url)
        device_id = 51

        mock_server.device_info_response(device_id=device_id, hostname="error-sw")
        # Register 500 for inventory calls
        mock_server.register(f"/api/v0/inventory/{device_id}", {"status": "error"}, status=500)

        with patch(
            "netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern",
            return_value="{master}-m{position}",
        ):
            result = detect_virtual_chassis_from_inventory(api, device_id)

        assert result is None

    def test_empty_serial_included_in_members(self, mock_server):
        """Members with empty entPhysicalSerialNum are included, not skipped."""
        from netbox_librenms_plugin.import_utils.virtual_chassis import detect_virtual_chassis_from_inventory

        api = _make_api(mock_server.url)
        device_id = 52

        mock_server.device_info_response(device_id=device_id, hostname="nosn-sw")
        root_items = [_stack_root(index=1)]
        member_items = [
            _chassis(100, "", position=1),  # empty serial
            _chassis(200, "SN-B", position=2),
        ]
        mock_server.vc_inventory_callable(device_id, root_items, {1: member_items})

        with patch(
            "netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern",
            return_value="{master}-m{position}",
        ):
            result = detect_virtual_chassis_from_inventory(api, device_id)

        assert result is not None
        assert result["member_count"] == 2
        assert result["members"][0]["serial"] == ""  # empty is preserved

    def test_device_info_failure_still_detects_vc(self, mock_server):
        """get_device_info() returning False → detection still works, suggested_name uses fallback."""
        from netbox_librenms_plugin.import_utils.virtual_chassis import detect_virtual_chassis_from_inventory

        api = _make_api(mock_server.url)
        device_id = 53

        # No device_info registered → 404
        root_items = [_stack_root(index=1)]
        member_items = [
            _chassis(100, "SN-1", position=1),
            _chassis(200, "SN-2", position=2),
        ]
        mock_server.vc_inventory_callable(device_id, root_items, {1: member_items})

        with patch(
            "netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern",
            return_value="{master}-m{position}",
        ):
            result = detect_virtual_chassis_from_inventory(api, device_id)

        # Should still detect VC even without device info
        assert result is not None
        assert result["member_count"] == 2
        # Without master name, suggested_name falls back to "Member-{position}"
        for member in result["members"]:
            assert member["suggested_name"].startswith("Member-")


class TestGetVCDataHTTP:
    """get_virtual_chassis_data() integrating with mock HTTP server and patched cache."""

    def test_cache_miss_fetches_via_http(self, mock_server):
        """Cache miss triggers detect_virtual_chassis_from_inventory via HTTP."""
        from netbox_librenms_plugin.import_utils.virtual_chassis import get_virtual_chassis_data

        api = _make_api(mock_server.url)
        device_id = 60

        mock_server.device_info_response(device_id=device_id, hostname="cached-sw")
        root_items = [_stack_root(index=1)]
        member_items = [
            _chassis(100, "SN-1", position=1),
            _chassis(200, "SN-2", position=2),
        ]
        mock_server.vc_inventory_callable(device_id, root_items, {1: member_items})

        with patch("netbox_librenms_plugin.import_utils.virtual_chassis.cache") as mock_cache:
            mock_cache.get.return_value = None  # cache miss
            with patch(
                "netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern",
                return_value="{master}-m{position}",
            ):
                result = get_virtual_chassis_data(api, device_id)

        # Should have called cache.set to store result
        assert mock_cache.set.called
        assert result is not None
        assert result["is_stack"] is True
        assert result["member_count"] == 2

    def test_cache_hit_returns_without_http(self, mock_server):
        """Cache hit returns immediately without making any HTTP calls."""
        from netbox_librenms_plugin.import_utils.virtual_chassis import get_virtual_chassis_data

        api = _make_api(mock_server.url)
        device_id = 61

        # Include detection_error to match what _clone_virtual_chassis_data adds
        cached_data = {"is_stack": True, "member_count": 3, "members": [], "detection_error": None}

        with patch("netbox_librenms_plugin.import_utils.virtual_chassis.cache") as mock_cache:
            mock_cache.get.return_value = cached_data
            result = get_virtual_chassis_data(api, device_id)

        # cache.set should NOT be called (no new fetch)
        assert not mock_cache.set.called
        assert result["is_stack"] is True
        assert result["member_count"] == 3

    def test_force_refresh_fetches_even_if_cached(self, mock_server):
        """force_refresh=True bypasses cache and fetches from HTTP."""
        from netbox_librenms_plugin.import_utils.virtual_chassis import get_virtual_chassis_data

        api = _make_api(mock_server.url)
        device_id = 62

        mock_server.device_info_response(device_id=device_id, hostname="refresh-sw")
        root_items = [_stack_root(index=1)]
        member_items = [
            _chassis(100, "SN-A", position=1),
            _chassis(200, "SN-B", position=2),
        ]
        mock_server.vc_inventory_callable(device_id, root_items, {1: member_items})

        # Even with cached data, force_refresh should hit the API
        old_cached = {"is_stack": True, "member_count": 1, "members": [{"serial": "OLD"}], "detection_error": None}

        with patch("netbox_librenms_plugin.import_utils.virtual_chassis.cache") as mock_cache:
            mock_cache.get.return_value = old_cached
            with patch(
                "netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern",
                return_value="{master}-m{position}",
            ):
                result = get_virtual_chassis_data(api, device_id, force_refresh=True)

        # Should have fetched fresh data, not used old_cached
        assert result is not None
        assert result["member_count"] == 2  # new data, not old

    def test_non_vc_device_returns_empty_dict(self, mock_server):
        """Single device (not VC) → detect returns None → get_virtual_chassis_data returns empty."""
        from netbox_librenms_plugin.import_utils.virtual_chassis import get_virtual_chassis_data

        api = _make_api(mock_server.url)
        device_id = 63

        mock_server.device_info_response(device_id=device_id, hostname="single-sw")
        root_items = [_stack_root(index=1)]
        member_items = [_chassis(100, "SN-ONLY", position=1)]  # only 1 → not VC
        mock_server.vc_inventory_callable(device_id, root_items, {1: member_items})

        with patch("netbox_librenms_plugin.import_utils.virtual_chassis.cache") as mock_cache:
            mock_cache.get.return_value = None
            with patch(
                "netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern",
                return_value="{master}-m{position}",
            ):
                result = get_virtual_chassis_data(api, device_id)

        assert result is not None
        assert result.get("is_stack") is False


class TestPrefetchVCHTTP:
    """prefetch_vc_data_for_devices() fetches multiple devices in batch."""

    def test_prefetch_multiple_vc_devices(self, mock_server):
        """Three VC devices → cache populated for all three."""
        from netbox_librenms_plugin.import_utils.virtual_chassis import prefetch_vc_data_for_devices

        api = _make_api(mock_server.url)

        for dev_id, hostname in [(70, "sw-70"), (71, "sw-71"), (72, "sw-72")]:
            mock_server.device_info_response(device_id=dev_id, hostname=hostname)
            root_items = [_stack_root(index=1)]
            member_items = [
                _chassis(100, f"SN-{dev_id}-1", position=1),
                _chassis(200, f"SN-{dev_id}-2", position=2),
            ]
            mock_server.vc_inventory_callable(dev_id, root_items, {1: member_items})

        cache_store = {}

        def mock_cache_set(key, val, timeout=None):
            cache_store[key] = val

        def mock_cache_get(key):
            return cache_store.get(key)

        with patch("netbox_librenms_plugin.import_utils.virtual_chassis.cache") as mock_cache:
            mock_cache.get.side_effect = mock_cache_get
            mock_cache.set.side_effect = mock_cache_set
            with patch(
                "netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern",
                return_value="{master}-m{position}",
            ):
                prefetch_vc_data_for_devices(api, [70, 71, 72])

        # Cache should have entries for all 3 VC devices
        assert len(cache_store) >= 3

    def test_prefetch_mix_vc_and_single(self, mock_server):
        """Mix of VC and single devices → VC is cached, single is processed without error."""
        from netbox_librenms_plugin.import_utils.virtual_chassis import prefetch_vc_data_for_devices

        api = _make_api(mock_server.url)

        # Device 80 = 2-member VC
        mock_server.device_info_response(device_id=80, hostname="sw-stack")
        root_items_80 = [_stack_root(index=1)]
        member_items_80 = [_chassis(100, "SN-80-1", position=1), _chassis(200, "SN-80-2", position=2)]
        mock_server.vc_inventory_callable(80, root_items_80, {1: member_items_80})

        # Device 81 = single (no VC)
        mock_server.device_info_response(device_id=81, hostname="sw-single")
        root_items_81 = [_stack_root(index=1)]
        member_items_81 = [_chassis(100, "SN-81", position=1)]  # only 1 → not VC
        mock_server.vc_inventory_callable(81, root_items_81, {1: member_items_81})

        cache_store = {}

        def mock_cache_set(key, val, timeout=None):
            cache_store[key] = val

        def mock_cache_get(key):
            return cache_store.get(key)

        with patch("netbox_librenms_plugin.import_utils.virtual_chassis.cache") as mock_cache:
            mock_cache.get.side_effect = mock_cache_get
            mock_cache.set.side_effect = mock_cache_set
            with patch(
                "netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern",
                return_value="{master}-m{position}",
            ):
                prefetch_vc_data_for_devices(api, [80, 81])

        # Both the VC device (80) and the non-VC device (81) should be cached.
        # Non-VC devices get an empty_virtual_chassis_data() cached so prefetch
        # suppresses repeated API hits on subsequent renders.
        assert len(cache_store) == 2


class TestNegativeVCCaching:
    """Negative results (non-stack, API errors) must be cached to suppress repeated hits."""

    def test_non_vc_device_result_is_cached(self, mock_server):
        """Single device (not a stack) → detect returns None → empty result cached."""
        from netbox_librenms_plugin.import_utils.virtual_chassis import get_virtual_chassis_data

        api = _make_api(mock_server.url)
        device_id = 200

        mock_server.device_info_response(device_id=device_id, hostname="single-sw")
        root_items = [_stack_root(index=1)]
        member_items = [_chassis(100, "SN-ONLY", position=1)]  # 1 chassis only → not VC
        mock_server.vc_inventory_callable(device_id, root_items, {1: member_items})

        cache_store = {}

        def mock_cache_set(key, val, timeout=None):
            cache_store[key] = val

        def mock_cache_get(key):
            return cache_store.get(key)

        with patch("netbox_librenms_plugin.import_utils.virtual_chassis.cache") as mock_cache:
            mock_cache.get.side_effect = mock_cache_get
            mock_cache.set.side_effect = mock_cache_set
            with patch(
                "netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern",
                return_value="{master}-m{position}",
            ):
                result = get_virtual_chassis_data(api, device_id)

        assert result is not None
        assert result.get("is_stack") is False
        assert result.get("member_count") == 0
        # The empty result must have been written to cache so a second call is a hit.
        assert len(cache_store) == 1
        cached = list(cache_store.values())[0]
        assert cached.get("is_stack") is False

    def test_api_error_result_is_cached(self, mock_server):
        """API 500 on inventory → detect returns None → empty result still cached."""
        from netbox_librenms_plugin.import_utils.virtual_chassis import get_virtual_chassis_data

        api = _make_api(mock_server.url)
        device_id = 201

        # Register a 500 for the root inventory call
        mock_server.routes[f"/api/v0/inventory/{device_id}"] = (500, {"status": "error", "message": "internal"})

        cache_store = {}

        def mock_cache_set(key, val, timeout=None):
            cache_store[key] = val

        def mock_cache_get(key):
            return cache_store.get(key)

        with patch("netbox_librenms_plugin.import_utils.virtual_chassis.cache") as mock_cache:
            mock_cache.get.side_effect = mock_cache_get
            mock_cache.set.side_effect = mock_cache_set
            with patch(
                "netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern",
                return_value="{master}-m{position}",
            ):
                result = get_virtual_chassis_data(api, device_id)

        assert result is not None
        assert result.get("is_stack") is False
        # Even API failures get cached to suppress repeated hits until TTL expires.
        assert len(cache_store) == 1

    def test_force_refresh_bypasses_negative_cache(self, mock_server):
        """force_refresh=True re-fetches even when a negative result is cached."""
        from netbox_librenms_plugin.import_utils.virtual_chassis import get_virtual_chassis_data

        api = _make_api(mock_server.url)
        device_id = 202

        mock_server.device_info_response(device_id=device_id, hostname="single-sw-202")
        root_items = [_stack_root(index=1)]
        member_items = [_chassis(100, "SN-202", position=1)]  # 1 chassis → not VC
        mock_server.vc_inventory_callable(device_id, root_items, {1: member_items})

        # Pre-populate cache with an empty (negative) result.
        empty_cached = {"is_stack": False, "member_count": 0, "members": [], "detection_error": None}

        call_count = {"n": 0}

        def mock_cache_get(key):
            return empty_cached  # always returns cached negative

        cache_set_calls = []

        def mock_cache_set(key, val, timeout=None):
            cache_set_calls.append((key, val))
            call_count["n"] += 1

        with patch("netbox_librenms_plugin.import_utils.virtual_chassis.cache") as mock_cache:
            mock_cache.get.side_effect = mock_cache_get
            mock_cache.set.side_effect = mock_cache_set
            with patch(
                "netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern",
                return_value="{master}-m{position}",
            ):
                # Normal call — should use cache, NOT call set again
                result_cached = get_virtual_chassis_data(api, device_id)
                assert call_count["n"] == 0  # no new set; cache hit returned

                # force_refresh=True — must bypass cache and re-fetch + re-cache
                result_fresh = get_virtual_chassis_data(api, device_id, force_refresh=True)
                assert call_count["n"] == 1  # set called once for the re-fetch

        assert result_cached.get("is_stack") is False
        assert result_fresh.get("is_stack") is False


class TestVCPortFetch:
    """Port fetching for VC master: port names with VC member suffixes."""

    def test_ports_with_vc_suffixes_returned_as_is(self, mock_server):
        """Port names like Gi1/0/1 and Gi2/0/1 preserved from API response."""
        api = _make_api(mock_server.url)

        vc_ports = [
            {
                "port_id": 101,
                "ifName": "GigabitEthernet1/0/1",
                "ifDescr": "GigabitEthernet1/0/1",
                "ifType": "ethernetCsmacd",
                "ifSpeed": 1_000_000_000,
                "ifAdminStatus": "up",
                "ifAlias": "uplink-m1",
                "ifPhysAddress": "aa:bb:cc:dd:ee:01",
                "ifMtu": 1500,
                "ifVlan": 1,
                "ifTrunk": 0,
            },
            {
                "port_id": 201,
                "ifName": "GigabitEthernet2/0/1",
                "ifDescr": "GigabitEthernet2/0/1",
                "ifType": "ethernetCsmacd",
                "ifSpeed": 1_000_000_000,
                "ifAdminStatus": "up",
                "ifAlias": "uplink-m2",
                "ifPhysAddress": "aa:bb:cc:dd:ee:02",
                "ifMtu": 1500,
                "ifVlan": 1,
                "ifTrunk": 0,
            },
            {
                "port_id": 301,
                "ifName": "GigabitEthernet1/0/2",
                "ifDescr": "GigabitEthernet1/0/2",
                "ifType": "ethernetCsmacd",
                "ifSpeed": 1_000_000_000,
                "ifAdminStatus": "down",
                "ifAlias": "",
                "ifPhysAddress": "aa:bb:cc:dd:ee:03",
                "ifMtu": 1500,
                "ifVlan": 10,
                "ifTrunk": 0,
            },
        ]
        mock_server.ports_response(device_id=90, ports=vc_ports)

        ok, data = api.get_ports(90)

        assert ok is True
        names = [p["ifName"] for p in data["ports"]]
        assert "GigabitEthernet1/0/1" in names
        assert "GigabitEthernet2/0/1" in names
        assert "GigabitEthernet1/0/2" in names

    def test_all_port_fields_preserved(self, mock_server):
        """ifName, ifDescr, ifAlias, ifSpeed all preserved from LibreNMS response."""
        api = _make_api(mock_server.url)

        ports_data = [
            {
                "port_id": 111,
                "ifName": "GigabitEthernet1/0/1",
                "ifDescr": "GigabitEthernet1/0/1",
                "ifType": "ethernetCsmacd",
                "ifSpeed": 1_000_000_000,
                "ifAdminStatus": "up",
                "ifAlias": "server-link",
                "ifPhysAddress": "aa:bb:cc:00:00:01",
                "ifMtu": 9000,
                "ifVlan": 100,
                "ifTrunk": 1,
            },
        ]
        mock_server.ports_response(device_id=91, ports=ports_data)

        ok, data = api.get_ports(91)

        assert ok is True
        port = data["ports"][0]
        assert port["ifName"] == "GigabitEthernet1/0/1"
        assert port["ifAlias"] == "server-link"
        assert port["ifSpeed"] == 1_000_000_000
        assert port["ifMtu"] == 9000
