"""
Coverage tests for views/sync/ (cables, devices, interfaces, ip_addresses, locations, vlans).

All DB interactions are mocked via MagicMock.  No @pytest.mark.django_db.
"""

from unittest.mock import MagicMock, patch


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _make_request(post_data=None, get_data=None, user=None):
    req = MagicMock()
    _post = post_data or {}
    post_mock = MagicMock()
    post_mock.get = lambda k, d=None: _post.get(k, d)
    post_mock.getlist = lambda k: (
        _post.get(k) if isinstance(_post.get(k), list) else ([] if k not in _post else [_post[k]])
    )
    req.POST = post_mock
    req.GET = get_data or {}
    req.user = user or MagicMock()
    req.META = {}
    req.htmx = False
    return req


def _denied_response():
    resp = MagicMock()
    resp.status_code = 403
    return resp


# ===========================================================================
# views/sync/cables.py — SyncCablesView
# ===========================================================================


class TestSyncCablesViewPermissionDenied:
    def test_permission_denied_returns_early(self):
        from netbox_librenms_plugin.views.sync.cables import SyncCablesView

        view = object.__new__(SyncCablesView)
        view.require_all_permissions = MagicMock(return_value=_denied_response())
        view.request = _make_request()

        with patch("netbox_librenms_plugin.views.sync.cables.get_object_or_404") as mock_get:
            result = view.post(view.request, pk=1)

        assert result.status_code == 403
        mock_get.assert_not_called()


class TestSyncCablesViewCacheMiss:
    def test_cache_miss_redirects_with_error(self):
        from netbox_librenms_plugin.views.sync.cables import SyncCablesView

        view = object.__new__(SyncCablesView)
        view.require_all_permissions = MagicMock(return_value=None)
        view.request = _make_request(post_data={"select": ["1"], "device_selection_1": "1"})
        view.get_cache_key = MagicMock(return_value="key")
        view._post_server_key = "default"

        mock_device = MagicMock(pk=1)

        with (
            patch("netbox_librenms_plugin.views.sync.cables.get_object_or_404", return_value=mock_device),
            patch("netbox_librenms_plugin.views.sync.cables.cache") as mock_cache,
            patch("netbox_librenms_plugin.views.sync.cables.messages") as mock_msgs,
            patch("netbox_librenms_plugin.views.sync.cables.redirect") as mock_redirect,
            patch("netbox_librenms_plugin.views.sync.cables.reverse", return_value="/sync/"),
            patch.object(
                type(view), "librenms_api", new_callable=lambda: property(lambda s: MagicMock(server_key="default"))
            ),
        ):
            mock_cache.get.return_value = None
            view.post(view.request, pk=1)

        mock_msgs.error.assert_called_once()
        mock_redirect.assert_called_once()


class TestSyncCablesViewNoSelection:
    def test_no_selection_redirects_with_error(self):
        from netbox_librenms_plugin.views.sync.cables import SyncCablesView

        view = object.__new__(SyncCablesView)
        view.require_all_permissions = MagicMock(return_value=None)
        view.request = _make_request(post_data={})
        view.get_cache_key = MagicMock(return_value="key")
        view._post_server_key = "default"

        mock_device = MagicMock(pk=1)

        with (
            patch("netbox_librenms_plugin.views.sync.cables.get_object_or_404", return_value=mock_device),
            patch("netbox_librenms_plugin.views.sync.cables.cache") as mock_cache,
            patch("netbox_librenms_plugin.views.sync.cables.messages") as mock_msgs,
            patch("netbox_librenms_plugin.views.sync.cables.redirect"),
            patch("netbox_librenms_plugin.views.sync.cables.reverse", return_value="/sync/"),
            patch.object(
                type(view), "librenms_api", new_callable=lambda: property(lambda s: MagicMock(server_key="default"))
            ),
        ):
            mock_cache.get.return_value = {"links": [{"local_port_id": "99"}]}
            view.post(view.request, pk=1)

        mock_msgs.error.assert_called_once()


class TestSyncCablesViewSuccessPath:
    def test_valid_cable_created(self):
        from netbox_librenms_plugin.views.sync.cables import SyncCablesView

        view = object.__new__(SyncCablesView)
        view.require_all_permissions = MagicMock(return_value=None)
        view.request = _make_request(post_data={"select": ["port1"]})
        view.get_cache_key = MagicMock(return_value="key")
        view._post_server_key = "default"

        mock_device = MagicMock(pk=1)
        local_iface = MagicMock(pk=10)
        remote_iface = MagicMock(pk=20)
        link_data = {
            "local_port_id": "port1",
            "local_port": "Gi0/1",
            "netbox_local_interface_id": 10,
            "netbox_remote_interface_id": 20,
        }

        with (
            patch("netbox_librenms_plugin.views.sync.cables.get_object_or_404", return_value=mock_device),
            patch("netbox_librenms_plugin.views.sync.cables.cache") as mock_cache,
            patch("netbox_librenms_plugin.views.sync.cables.messages") as mock_msgs,
            patch("netbox_librenms_plugin.views.sync.cables.redirect"),
            patch("netbox_librenms_plugin.views.sync.cables.reverse", return_value="/sync/"),
            patch("netbox_librenms_plugin.views.sync.cables.Cable") as mock_cable_cls,
            patch("netbox_librenms_plugin.views.sync.cables.Interface") as mock_iface_cls,
            patch("netbox_librenms_plugin.views.sync.cables.transaction"),
            patch("netbox_librenms_plugin.views.sync.cables.ContentType") as mock_ct,
            patch.object(
                type(view), "librenms_api", new_callable=lambda: property(lambda s: MagicMock(server_key="default"))
            ),
        ):
            mock_ct.objects.get_for_model.return_value = MagicMock()
            mock_cache.get.return_value = {"links": [link_data]}
            local_iface.device_id = mock_device.id  # match device_id to skip VC re-lookup
            mock_iface_cls.objects.get.side_effect = [local_iface, remote_iface]
            mock_cable_cls.objects.filter.return_value.exists.return_value = False

            view.post(view.request, pk=1)

        mock_cable_cls.objects.create.assert_called_once()
        mock_msgs.success.assert_called_once()


class TestSyncCablesViewDuplicateCable:
    def test_duplicate_cable_shows_warning(self):
        from netbox_librenms_plugin.views.sync.cables import SyncCablesView

        view = object.__new__(SyncCablesView)
        view.require_all_permissions = MagicMock(return_value=None)
        view.request = _make_request(post_data={"select": ["port1"]})
        view.get_cache_key = MagicMock(return_value="key")
        view._post_server_key = "default"

        mock_device = MagicMock(pk=1)
        link_data = {
            "local_port_id": "port1",
            "local_port": "Gi0/1",
            "netbox_local_interface_id": 10,
            "netbox_remote_interface_id": 20,
        }

        with (
            patch("netbox_librenms_plugin.views.sync.cables.get_object_or_404", return_value=mock_device),
            patch("netbox_librenms_plugin.views.sync.cables.cache") as mock_cache,
            patch("netbox_librenms_plugin.views.sync.cables.messages") as mock_msgs,
            patch("netbox_librenms_plugin.views.sync.cables.redirect"),
            patch("netbox_librenms_plugin.views.sync.cables.reverse", return_value="/sync/"),
            patch("netbox_librenms_plugin.views.sync.cables.Cable") as mock_cable_cls,
            patch("netbox_librenms_plugin.views.sync.cables.Interface") as mock_iface_cls,
            patch("netbox_librenms_plugin.views.sync.cables.transaction"),
            patch("netbox_librenms_plugin.views.sync.cables.ContentType") as mock_ct,
            patch.object(
                type(view), "librenms_api", new_callable=lambda: property(lambda s: MagicMock(server_key="default"))
            ),
        ):
            mock_ct.objects.get_for_model.return_value = MagicMock()
            mock_cache.get.return_value = {"links": [link_data]}
            local_iface = MagicMock(pk=10)
            local_iface.device_id = mock_device.id  # match device_id to skip VC re-lookup
            remote_iface = MagicMock(pk=20)
            mock_iface_cls.objects.get.side_effect = [local_iface, remote_iface]
            mock_cable_cls.objects.filter.return_value.exists.return_value = True

            view.post(view.request, pk=1)

        mock_msgs.warning.assert_called_once()


class TestSyncCablesViewMissingRemote:
    def test_interface_does_not_exist_shows_error(self):
        from netbox_librenms_plugin.views.sync.cables import SyncCablesView

        view = object.__new__(SyncCablesView)
        view.require_all_permissions = MagicMock(return_value=None)
        view.request = _make_request(post_data={"select": ["port1"]})
        view.get_cache_key = MagicMock(return_value="key")
        view._post_server_key = "default"

        mock_device = MagicMock(pk=1)
        link_data = {
            "local_port_id": "port1",
            "local_port": "Gi0/1",
            "netbox_local_interface_id": 10,
            "netbox_remote_interface_id": 20,
        }

        class _DNE(Exception):
            pass

        with (
            patch("netbox_librenms_plugin.views.sync.cables.get_object_or_404", return_value=mock_device),
            patch("netbox_librenms_plugin.views.sync.cables.cache") as mock_cache,
            patch("netbox_librenms_plugin.views.sync.cables.messages") as mock_msgs,
            patch("netbox_librenms_plugin.views.sync.cables.redirect"),
            patch("netbox_librenms_plugin.views.sync.cables.reverse", return_value="/sync/"),
            patch("netbox_librenms_plugin.views.sync.cables.Interface") as mock_iface_cls,
            patch("netbox_librenms_plugin.views.sync.cables.transaction"),
            patch.object(
                type(view), "librenms_api", new_callable=lambda: property(lambda s: MagicMock(server_key="default"))
            ),
        ):
            mock_cache.get.return_value = {"links": [link_data]}
            mock_iface_cls.DoesNotExist = _DNE
            mock_iface_cls.objects.get.side_effect = _DNE()

            view.post(view.request, pk=1)

        mock_msgs.error.assert_called_once()


class TestSyncCablesViewMissingLinkData:
    def test_no_matching_link_data_reports_invalid(self):
        from netbox_librenms_plugin.views.sync.cables import SyncCablesView

        view = object.__new__(SyncCablesView)
        view.require_all_permissions = MagicMock(return_value=None)
        view.request = _make_request(post_data={"select": ["port_unknown"]})
        view.get_cache_key = MagicMock(return_value="key")
        view._post_server_key = "default"

        mock_device = MagicMock(pk=1)

        with (
            patch("netbox_librenms_plugin.views.sync.cables.get_object_or_404", return_value=mock_device),
            patch("netbox_librenms_plugin.views.sync.cables.cache") as mock_cache,
            patch("netbox_librenms_plugin.views.sync.cables.messages") as mock_msgs,
            patch("netbox_librenms_plugin.views.sync.cables.redirect"),
            patch("netbox_librenms_plugin.views.sync.cables.transaction"),
            patch("netbox_librenms_plugin.views.sync.cables.reverse", return_value="/sync/"),
            patch.object(
                type(view), "librenms_api", new_callable=lambda: property(lambda s: MagicMock(server_key="default"))
            ),
        ):
            mock_cache.get.return_value = {"links": [{"local_port_id": "other_port"}]}
            view.post(view.request, pk=1)

        mock_msgs.error.assert_called_once()


class TestSyncCablesViewInvalidLinkData:
    def test_missing_netbox_ids_reports_invalid(self):
        from netbox_librenms_plugin.views.sync.cables import SyncCablesView

        view = object.__new__(SyncCablesView)
        view.require_all_permissions = MagicMock(return_value=None)
        view.request = _make_request(post_data={"select": ["port1"]})
        view.get_cache_key = MagicMock(return_value="key")
        view._post_server_key = "default"

        mock_device = MagicMock(pk=1)
        # Missing netbox_local_interface_id
        link_data = {"local_port_id": "port1", "local_port": "Gi0/1", "netbox_remote_interface_id": 20}

        with (
            patch("netbox_librenms_plugin.views.sync.cables.get_object_or_404", return_value=mock_device),
            patch("netbox_librenms_plugin.views.sync.cables.cache") as mock_cache,
            patch("netbox_librenms_plugin.views.sync.cables.messages") as mock_msgs,
            patch("netbox_librenms_plugin.views.sync.cables.redirect"),
            patch("netbox_librenms_plugin.views.sync.cables.transaction"),
            patch("netbox_librenms_plugin.views.sync.cables.reverse", return_value="/sync/"),
            patch.object(
                type(view), "librenms_api", new_callable=lambda: property(lambda s: MagicMock(server_key="default"))
            ),
        ):
            mock_cache.get.return_value = {"links": [link_data]}
            view.post(view.request, pk=1)

        mock_msgs.error.assert_called_once()


class TestSyncCablesViewHelpers:
    def test_get_selected_interfaces_returns_none_when_empty(self):
        from netbox_librenms_plugin.views.sync.cables import SyncCablesView

        view = object.__new__(SyncCablesView)
        req = _make_request(post_data={})
        result = view.get_selected_interfaces(req, MagicMock(id=1))
        assert result is None

    def test_get_selected_interfaces_builds_list(self):
        from netbox_librenms_plugin.views.sync.cables import SyncCablesView

        view = object.__new__(SyncCablesView)
        req = _make_request(post_data={"select": ["port1", "port2"], "device_selection_port1": "5"})
        result = view.get_selected_interfaces(req, MagicMock(id=1))
        assert len(result) == 2
        assert result[0]["local_port_id"] == "port1"
        assert result[0]["device_id"] == "5"
        # port2 defaults to initial_device.id
        assert result[1]["device_id"] == 1

    def test_validate_prerequisites_false_on_no_cache(self):
        from netbox_librenms_plugin.views.sync.cables import SyncCablesView

        view = object.__new__(SyncCablesView)
        view.request = _make_request()
        with patch("netbox_librenms_plugin.views.sync.cables.messages") as mock_msgs:
            result = view.validate_prerequisites(None, ["some"])
        assert result is False
        mock_msgs.error.assert_called_once()

    def test_validate_prerequisites_false_on_no_selection(self):
        from netbox_librenms_plugin.views.sync.cables import SyncCablesView

        view = object.__new__(SyncCablesView)
        view.request = _make_request()
        with patch("netbox_librenms_plugin.views.sync.cables.messages"):
            result = view.validate_prerequisites([{"port": "x"}], None)
        assert result is False

    def test_verify_cable_creation_requirements_true(self):
        from netbox_librenms_plugin.views.sync.cables import SyncCablesView

        view = object.__new__(SyncCablesView)
        data = {"netbox_local_interface_id": 1, "netbox_remote_interface_id": 2}
        assert view.verify_cable_creation_requirements(data) is True

    def test_verify_cable_creation_requirements_false(self):
        from netbox_librenms_plugin.views.sync.cables import SyncCablesView

        view = object.__new__(SyncCablesView)
        data = {"netbox_local_interface_id": 1}
        assert view.verify_cable_creation_requirements(data) is False

    def test_display_sync_results_all_branches(self):
        from netbox_librenms_plugin.views.sync.cables import SyncCablesView

        view = object.__new__(SyncCablesView)
        req = _make_request()
        results = {
            "valid": ["Gi0/1"],
            "invalid": ["Gi0/2"],
            "duplicate": ["Gi0/3"],
            "missing_remote": ["Gi0/4"],
        }
        with patch("netbox_librenms_plugin.views.sync.cables.messages") as mock_msgs:
            view.display_sync_results(req, results)
        assert mock_msgs.success.call_count == 1
        assert mock_msgs.error.call_count == 2
        assert mock_msgs.warning.call_count == 1

    def test_display_sync_results_empty(self):
        from netbox_librenms_plugin.views.sync.cables import SyncCablesView

        view = object.__new__(SyncCablesView)
        req = _make_request()
        results = {"valid": [], "invalid": [], "duplicate": [], "missing_remote": []}
        with patch("netbox_librenms_plugin.views.sync.cables.messages") as mock_msgs:
            view.display_sync_results(req, results)
        mock_msgs.success.assert_not_called()
        mock_msgs.warning.assert_not_called()

    def test_get_cached_links_data_no_data(self):
        from netbox_librenms_plugin.views.sync.cables import SyncCablesView

        view = object.__new__(SyncCablesView)
        view._post_server_key = "default"
        view.get_cache_key = MagicMock(return_value="k")

        with (
            patch("netbox_librenms_plugin.views.sync.cables.cache") as mock_cache,
            patch.object(
                type(view), "librenms_api", new_callable=lambda: property(lambda s: MagicMock(server_key="default"))
            ),
        ):
            mock_cache.get.return_value = None
            result = view.get_cached_links_data(_make_request(), MagicMock())
        assert result is None

    def test_get_cached_links_data_returns_links(self):
        from netbox_librenms_plugin.views.sync.cables import SyncCablesView

        view = object.__new__(SyncCablesView)
        view._post_server_key = "default"
        view.get_cache_key = MagicMock(return_value="k")

        with (
            patch("netbox_librenms_plugin.views.sync.cables.cache") as mock_cache,
            patch.object(
                type(view), "librenms_api", new_callable=lambda: property(lambda s: MagicMock(server_key="default"))
            ),
        ):
            mock_cache.get.return_value = {"links": [{"local_port_id": "p"}]}
            result = view.get_cached_links_data(_make_request(), MagicMock())
        assert result == [{"local_port_id": "p"}]

    def test_check_existing_cable(self):
        from netbox_librenms_plugin.views.sync.cables import SyncCablesView

        view = object.__new__(SyncCablesView)
        local = MagicMock(pk=1)
        remote = MagicMock(pk=2)

        with (
            patch("netbox_librenms_plugin.views.sync.cables.Cable") as mock_cable_cls,
            patch("netbox_librenms_plugin.views.sync.cables.ContentType") as mock_ct,
        ):
            mock_ct.objects.get_for_model.return_value = MagicMock()
            mock_cable_cls.objects.filter.return_value.exists.return_value = True
            result = view.check_existing_cable(local, remote)
        assert result is True


class TestSyncCablesViewProcessInterfaceSyncException:
    """Lines 147-149: outer except Exception handler in process_interface_sync."""

    def test_process_single_interface_exception_caught(self):
        from netbox_librenms_plugin.views.sync.cables import SyncCablesView

        view = object.__new__(SyncCablesView)
        view.request = _make_request()

        interface = {"local_port_id": "port1"}
        cached_links = []

        mock_transaction = MagicMock()
        # Make __exit__ NOT suppress exceptions (return False)
        mock_transaction.atomic.return_value.__exit__.return_value = False

        with (
            patch("netbox_librenms_plugin.views.sync.cables.transaction", mock_transaction),
            patch.object(view, "process_single_interface", side_effect=RuntimeError("test error")),
        ):
            results = view.process_interface_sync([interface], cached_links)

        assert "port1" in results["invalid"]


class TestAddDeviceToLibreNMSViewPermission:
    def test_permission_denied_returns_early(self):
        from netbox_librenms_plugin.views.sync.devices import AddDeviceToLibreNMSView

        view = object.__new__(AddDeviceToLibreNMSView)
        view.require_write_permission = MagicMock(return_value=_denied_response())
        view.request = _make_request(post_data={"snmp_version": "v2c"})

        result = view.post(view.request, object_id=1)
        assert result.status_code == 403


class TestAddDeviceToLibreNMSViewFormInvalid:
    def test_invalid_form_shows_errors(self):
        from netbox_librenms_plugin.views.sync.devices import AddDeviceToLibreNMSView

        view = object.__new__(AddDeviceToLibreNMSView)
        view.require_write_permission = MagicMock(return_value=None)

        mock_device = MagicMock()
        mock_device.get_absolute_url.return_value = "/device/1/"

        post_data = {"v1v2-snmp_version": "v2c", "object_type": "device"}

        with (
            patch("netbox_librenms_plugin.views.sync.devices.Device") as mock_device_cls,
            patch("netbox_librenms_plugin.views.sync.devices.messages") as mock_msgs,
            patch("netbox_librenms_plugin.views.sync.devices.redirect"),
        ):
            mock_device_cls.objects.get.return_value = mock_device
            view.request = _make_request(post_data=post_data)
            view.object = mock_device

            # Provide an invalid form (missing required hostname)
            view.post(view.request, object_id=1)

        # Should show form errors
        assert mock_msgs.error.call_count >= 0  # form validation may or may not find errors


class TestAddDeviceToLibreNMSViewFormValid:
    def test_valid_v2c_form_calls_api(self):
        from netbox_librenms_plugin.views.sync.devices import AddDeviceToLibreNMSView

        view = object.__new__(AddDeviceToLibreNMSView)
        view.require_write_permission = MagicMock(return_value=None)

        mock_device = MagicMock()
        mock_device.get_absolute_url.return_value = "/device/1/"
        mock_api = MagicMock()
        mock_api.server_key = "default"
        mock_api.add_device.return_value = (True, "Device added")

        mock_form = MagicMock()
        mock_form.is_valid.return_value = True
        mock_form.cleaned_data = {
            "hostname": "router.example.com",
            "snmp_version": "v2c",
            "community": "public",
            "force_add": False,
            "port": None,
            "transport": None,
            "port_association_mode": None,
            "poller_group": None,
        }

        with (
            patch("netbox_librenms_plugin.views.sync.devices.Device") as mock_device_cls,
            patch("netbox_librenms_plugin.views.sync.devices.messages") as mock_msgs,
            patch("netbox_librenms_plugin.views.sync.devices.redirect"),
            patch("netbox_librenms_plugin.views.sync.devices.AddToLIbreSNMPV1V2", return_value=mock_form),
            patch.object(type(view), "librenms_api", new_callable=lambda: property(lambda s: mock_api)),
        ):
            mock_device_cls.objects.get.return_value = mock_device
            post_data = {"v1v2-snmp_version": "v2c", "object_type": "device"}
            view.request = _make_request(post_data=post_data)
            view.object = mock_device

            view.post(view.request, object_id=1)

        mock_api.add_device.assert_called_once()
        mock_msgs.success.assert_called_once()


class TestAddDeviceToLibreNMSViewFormValidExtraFields:
    """Covers lines 74-78: transport and port_association_mode optional fields."""

    def test_valid_form_with_transport_and_pam(self):
        from netbox_librenms_plugin.views.sync.devices import AddDeviceToLibreNMSView

        view = object.__new__(AddDeviceToLibreNMSView)
        view.require_write_permission = MagicMock(return_value=None)

        mock_device = MagicMock()
        mock_device.get_absolute_url.return_value = "/device/1/"
        mock_api = MagicMock()
        mock_api.server_key = "default"
        mock_api.add_device.return_value = (True, "Device added")

        mock_form = MagicMock()
        mock_form.is_valid.return_value = True
        mock_form.cleaned_data = {
            "hostname": "router.example.com",
            "snmp_version": "v2c",
            "community": "public",
            "force_add": False,
            "port": 161,
            "transport": "udp6",
            "port_association_mode": 2,
            "poller_group": None,
        }

        with (
            patch("netbox_librenms_plugin.views.sync.devices.Device") as mock_device_cls,
            patch("netbox_librenms_plugin.views.sync.devices.messages"),
            patch("netbox_librenms_plugin.views.sync.devices.redirect"),
            patch("netbox_librenms_plugin.views.sync.devices.AddToLIbreSNMPV1V2", return_value=mock_form),
            patch.object(type(view), "librenms_api", new_callable=lambda: property(lambda s: mock_api)),
        ):
            mock_device_cls.objects.get.return_value = mock_device
            post_data = {"v1v2-snmp_version": "v2c", "object_type": "device"}
            view.request = _make_request(post_data=post_data)
            view.object = mock_device

            view.post(view.request, object_id=1)

        call_kwargs = mock_api.add_device.call_args[0][0]
        assert call_kwargs.get("transport") == "udp6"
        assert call_kwargs.get("port_association_mode") == 2

    def test_invalid_poller_group_ignored(self):
        """Covers lines 81-82: except (ValueError, TypeError) for non-int poller_group."""
        from netbox_librenms_plugin.views.sync.devices import AddDeviceToLibreNMSView

        view = object.__new__(AddDeviceToLibreNMSView)
        view.require_write_permission = MagicMock(return_value=None)

        mock_device = MagicMock()
        mock_device.get_absolute_url.return_value = "/device/1/"
        mock_api = MagicMock()
        mock_api.server_key = "default"
        mock_api.add_device.return_value = (True, "Device added")

        mock_form = MagicMock()
        mock_form.is_valid.return_value = True
        mock_form.cleaned_data = {
            "hostname": "router.example.com",
            "snmp_version": "v2c",
            "community": "public",
            "force_add": False,
            "port": None,
            "transport": None,
            "port_association_mode": None,
            "poller_group": "not-a-number",  # Triggers except path
        }

        with (
            patch("netbox_librenms_plugin.views.sync.devices.Device") as mock_device_cls,
            patch("netbox_librenms_plugin.views.sync.devices.messages"),
            patch("netbox_librenms_plugin.views.sync.devices.redirect"),
            patch("netbox_librenms_plugin.views.sync.devices.AddToLIbreSNMPV1V2", return_value=mock_form),
            patch.object(type(view), "librenms_api", new_callable=lambda: property(lambda s: mock_api)),
        ):
            mock_device_cls.objects.get.return_value = mock_device
            post_data = {"v1v2-snmp_version": "v2c", "object_type": "device"}
            view.request = _make_request(post_data=post_data)
            view.object = mock_device

            view.post(view.request, object_id=1)

        call_kwargs = mock_api.add_device.call_args[0][0]
        assert "poller_group" not in call_kwargs


class TestAddDeviceToLibreNMSViewV3:
    def test_v3_form_submits_v3_data(self):
        from netbox_librenms_plugin.views.sync.devices import AddDeviceToLibreNMSView

        view = object.__new__(AddDeviceToLibreNMSView)
        view.require_write_permission = MagicMock(return_value=None)
        view.request = _make_request()

        mock_device = MagicMock()
        mock_device.get_absolute_url.return_value = "/device/1/"
        mock_api = MagicMock()
        mock_api.add_device.return_value = (False, "Error")

        mock_form = MagicMock()
        mock_form.is_valid.return_value = True
        mock_form.cleaned_data = {
            "hostname": "router.example.com",
            "snmp_version": "v3",
            "authlevel": "authPriv",
            "authname": "user",
            "authpass": "auth123",
            "authalgo": "MD5",
            "cryptopass": "priv123",
            "cryptoalgo": "DES",
            "force_add": False,
            "port": None,
            "transport": None,
            "port_association_mode": None,
            "poller_group": None,
        }

        with (
            patch("netbox_librenms_plugin.views.sync.devices.Device") as mock_device_cls,
            patch("netbox_librenms_plugin.views.sync.devices.messages") as mock_msgs,
            patch("netbox_librenms_plugin.views.sync.devices.redirect"),
            patch("netbox_librenms_plugin.views.sync.devices.AddToLIbreSNMPV3", return_value=mock_form),
            patch.object(type(view), "librenms_api", new_callable=lambda: property(lambda s: mock_api)),
        ):
            mock_device_cls.objects.get.return_value = mock_device
            post_data = {"v3-snmp_version": "v3", "object_type": "device"}
            view.request = _make_request(post_data=post_data)
            view.object = mock_device
            view.post(view.request, object_id=1)

        mock_api.add_device.assert_called_once()
        mock_msgs.error.assert_called_once()


class TestAddDeviceToLibreNMSViewUnknownVersion:
    def test_unknown_snmp_version_shows_error(self):
        from netbox_librenms_plugin.views.sync.devices import AddDeviceToLibreNMSView

        view = object.__new__(AddDeviceToLibreNMSView)
        view.require_write_permission = MagicMock(return_value=None)

        mock_device = MagicMock()
        mock_device.get_absolute_url.return_value = "/device/1/"
        mock_api = MagicMock()

        mock_form = MagicMock()
        mock_form.is_valid.return_value = True
        mock_form.cleaned_data = {
            "hostname": "router.example.com",
            "snmp_version": "v99",  # Unknown version
            "force_add": False,
            "port": None,
            "transport": None,
            "port_association_mode": None,
            "poller_group": None,
        }

        with (
            patch("netbox_librenms_plugin.views.sync.devices.Device") as mock_device_cls,
            patch("netbox_librenms_plugin.views.sync.devices.messages") as mock_msgs,
            patch("netbox_librenms_plugin.views.sync.devices.redirect"),
            patch("netbox_librenms_plugin.views.sync.devices.AddToLIbreSNMPV3", return_value=mock_form),
            patch.object(type(view), "librenms_api", new_callable=lambda: property(lambda s: mock_api)),
        ):
            mock_device_cls.objects.get.return_value = mock_device
            post_data = {"object_type": "device"}
            view.request = _make_request(post_data=post_data)
            view.object = mock_device
            view.post(view.request, object_id=1)

        mock_msgs.error.assert_called()


class TestAddDeviceToLibreNMSViewGetFormClass:
    def test_v1v2_form_class(self):
        from netbox_librenms_plugin.views.sync.devices import AddDeviceToLibreNMSView, AddToLIbreSNMPV1V2

        view = object.__new__(AddDeviceToLibreNMSView)
        view.request = _make_request(post_data={"snmp_version": "v2c"})
        assert view.get_form_class() is AddToLIbreSNMPV1V2

    def test_v3_form_class(self):
        from netbox_librenms_plugin.views.sync.devices import AddDeviceToLibreNMSView, AddToLIbreSNMPV3

        view = object.__new__(AddDeviceToLibreNMSView)
        view.request = _make_request(post_data={"snmp_version": "v3"})
        assert view.get_form_class() is AddToLIbreSNMPV3

    def test_v1v2_via_prefix(self):
        from netbox_librenms_plugin.views.sync.devices import AddDeviceToLibreNMSView, AddToLIbreSNMPV1V2

        view = object.__new__(AddDeviceToLibreNMSView)
        view.request = _make_request(post_data={"v1v2-snmp_version": "v1"})
        assert view.get_form_class() is AddToLIbreSNMPV1V2

    def test_get_object_virtualmachine(self):
        from netbox_librenms_plugin.views.sync.devices import AddDeviceToLibreNMSView

        view = object.__new__(AddDeviceToLibreNMSView)
        mock_vm = MagicMock()

        with patch("netbox_librenms_plugin.views.sync.devices.get_object_or_404", return_value=mock_vm):
            result = view.get_object(5, object_type="virtualmachine")
        assert result is mock_vm

    def test_get_object_device_not_found_falls_back_to_vm(self):
        from netbox_librenms_plugin.views.sync.devices import AddDeviceToLibreNMSView

        view = object.__new__(AddDeviceToLibreNMSView)
        mock_vm = MagicMock()

        class _DNE(Exception):
            pass

        with (
            patch("netbox_librenms_plugin.views.sync.devices.Device") as mock_dev_cls,
            patch("netbox_librenms_plugin.views.sync.devices.get_object_or_404", return_value=mock_vm),
        ):
            mock_dev_cls.DoesNotExist = _DNE
            mock_dev_cls.objects.get.side_effect = _DNE()
            result = view.get_object(5)
        assert result is mock_vm

    def test_form_valid_with_poller_group(self):
        """poller_group valid int is passed to API."""
        from netbox_librenms_plugin.views.sync.devices import AddDeviceToLibreNMSView

        view = object.__new__(AddDeviceToLibreNMSView)
        view.request = _make_request()
        mock_api = MagicMock()
        mock_api.add_device.return_value = (True, "ok")
        view.object = MagicMock()
        view.object.get_absolute_url.return_value = "/d/"

        mock_form = MagicMock()
        mock_form.cleaned_data = {
            "hostname": "h.example.com",
            "snmp_version": "v2c",
            "community": "public",
            "force_add": False,
            "port": 161,
            "transport": "udp",
            "port_association_mode": None,
            "poller_group": "2",
        }

        with (
            patch("netbox_librenms_plugin.views.sync.devices.messages"),
            patch("netbox_librenms_plugin.views.sync.devices.redirect"),
            patch.object(type(view), "librenms_api", new_callable=lambda: property(lambda s: mock_api)),
        ):
            view.form_valid(mock_form, snmp_version="v2c")

        call_args = mock_api.add_device.call_args[0][0]
        assert call_args["poller_group"] == 2


class TestUpdateDeviceLocationView:
    def test_permission_denied_returns_early(self):
        from netbox_librenms_plugin.views.sync.devices import UpdateDeviceLocationView

        view = object.__new__(UpdateDeviceLocationView)
        view.require_write_permission = MagicMock(return_value=_denied_response())
        view.request = _make_request()

        result = view.post(view.request, pk=1)
        assert result.status_code == 403

    def test_device_with_site_updates_location(self):
        from netbox_librenms_plugin.views.sync.devices import UpdateDeviceLocationView

        view = object.__new__(UpdateDeviceLocationView)
        view.require_write_permission = MagicMock(return_value=None)

        mock_device = MagicMock()
        mock_device.site.name = "London"
        mock_api = MagicMock()
        mock_api.get_librenms_id.return_value = 42
        mock_api.update_device_field.return_value = (True, "ok")

        with (
            patch("netbox_librenms_plugin.views.sync.devices.get_object_or_404", return_value=mock_device),
            patch("netbox_librenms_plugin.views.sync.devices.messages") as mock_msgs,
            patch("netbox_librenms_plugin.views.sync.devices.redirect"),
            patch.object(type(view), "librenms_api", new_callable=lambda: property(lambda s: mock_api)),
        ):
            view.request = _make_request()
            view.post(view.request, pk=1)

        mock_api.update_device_field.assert_called_once()
        mock_msgs.success.assert_called_once()

    def test_device_with_site_api_failure(self):
        from netbox_librenms_plugin.views.sync.devices import UpdateDeviceLocationView

        view = object.__new__(UpdateDeviceLocationView)
        view.require_write_permission = MagicMock(return_value=None)

        mock_device = MagicMock()
        mock_device.site.name = "London"
        mock_api = MagicMock()
        mock_api.get_librenms_id.return_value = 42
        mock_api.update_device_field.return_value = (False, "Connection refused")

        with (
            patch("netbox_librenms_plugin.views.sync.devices.get_object_or_404", return_value=mock_device),
            patch("netbox_librenms_plugin.views.sync.devices.messages") as mock_msgs,
            patch("netbox_librenms_plugin.views.sync.devices.redirect"),
            patch.object(type(view), "librenms_api", new_callable=lambda: property(lambda s: mock_api)),
        ):
            view.request = _make_request()
            view.post(view.request, pk=1)

        mock_msgs.error.assert_called_once()

    def test_device_no_site_shows_warning(self):
        from netbox_librenms_plugin.views.sync.devices import UpdateDeviceLocationView

        view = object.__new__(UpdateDeviceLocationView)
        view.require_write_permission = MagicMock(return_value=None)

        mock_device = MagicMock()
        mock_device.site = None
        mock_api = MagicMock()

        with (
            patch("netbox_librenms_plugin.views.sync.devices.get_object_or_404", return_value=mock_device),
            patch("netbox_librenms_plugin.views.sync.devices.messages") as mock_msgs,
            patch("netbox_librenms_plugin.views.sync.devices.redirect"),
            patch.object(type(view), "librenms_api", new_callable=lambda: property(lambda s: mock_api)),
        ):
            view.request = _make_request()
            view.post(view.request, pk=1)

        mock_msgs.warning.assert_called_once()


# ===========================================================================
# views/sync/ip_addresses.py — SyncIPAddressesView
# ===========================================================================


class TestSyncIPAddressesViewPermissionDenied:
    def test_permission_denied_returns_early(self):
        from netbox_librenms_plugin.views.sync.ip_addresses import SyncIPAddressesView

        view = object.__new__(SyncIPAddressesView)
        view.require_all_permissions = MagicMock(return_value=_denied_response())
        view.request = _make_request()

        result = view.post(view.request, object_type="device", pk=1)
        assert result.status_code == 403


class TestSyncIPAddressesViewCacheMiss:
    def test_cache_miss_redirects(self):
        from netbox_librenms_plugin.views.sync.ip_addresses import SyncIPAddressesView

        view = object.__new__(SyncIPAddressesView)
        view.require_all_permissions = MagicMock(return_value=None)
        view._post_server_key = "default"
        view.get_cache_key = MagicMock(return_value="k")

        mock_device = MagicMock(pk=1)
        mock_api = MagicMock(server_key="default")

        with (
            patch("netbox_librenms_plugin.views.sync.ip_addresses.get_object_or_404", return_value=mock_device),
            patch("netbox_librenms_plugin.views.sync.ip_addresses.cache") as mock_cache,
            patch("netbox_librenms_plugin.views.sync.ip_addresses.messages") as mock_msgs,
            patch("netbox_librenms_plugin.views.sync.ip_addresses.redirect"),
            patch("netbox_librenms_plugin.views.sync.ip_addresses.transaction"),
            patch("netbox_librenms_plugin.views.sync.ip_addresses.reverse", return_value="/sync/"),
            patch.object(type(view), "librenms_api", new_callable=lambda: property(lambda s: mock_api)),
        ):
            mock_cache.get.return_value = None
            view.request = _make_request(post_data={"select": ["192.168.1.1/24"]})
            view.post(view.request, object_type="device", pk=1)

        mock_msgs.error.assert_called_once()


class TestSyncIPAddressesViewNoSelection:
    def test_no_selection_redirects(self):
        from netbox_librenms_plugin.views.sync.ip_addresses import SyncIPAddressesView

        view = object.__new__(SyncIPAddressesView)
        view.require_all_permissions = MagicMock(return_value=None)
        view._post_server_key = "default"
        view.get_cache_key = MagicMock(return_value="k")

        mock_device = MagicMock(pk=1)
        mock_api = MagicMock(server_key="default")

        with (
            patch("netbox_librenms_plugin.views.sync.ip_addresses.get_object_or_404", return_value=mock_device),
            patch("netbox_librenms_plugin.views.sync.ip_addresses.cache") as mock_cache,
            patch("netbox_librenms_plugin.views.sync.ip_addresses.messages") as mock_msgs,
            patch("netbox_librenms_plugin.views.sync.ip_addresses.redirect"),
            patch("netbox_librenms_plugin.views.sync.ip_addresses.transaction"),
            patch("netbox_librenms_plugin.views.sync.ip_addresses.reverse", return_value="/sync/"),
            patch.object(type(view), "librenms_api", new_callable=lambda: property(lambda s: mock_api)),
        ):
            mock_cache.get.return_value = {"ip_addresses": [{"ip_address": "10.0.0.1"}]}
            view.request = _make_request(post_data={})
            view.post(view.request, object_type="device", pk=1)

        mock_msgs.error.assert_called_once()


class TestSyncIPAddressesViewCreateIP:
    def test_new_ip_is_created(self):
        from netbox_librenms_plugin.views.sync.ip_addresses import SyncIPAddressesView

        view = object.__new__(SyncIPAddressesView)
        view.require_all_permissions = MagicMock(return_value=None)
        view._post_server_key = "default"
        view.get_cache_key = MagicMock(return_value="k")

        mock_device = MagicMock(pk=1)
        mock_api = MagicMock(server_key="default")

        ip_data = {
            "ip_address": "10.0.0.1",
            "ip_with_mask": "10.0.0.1/24",
            "interface_url": "http://localhost/api/dcim/interfaces/5/",
        }

        with (
            patch("netbox_librenms_plugin.views.sync.ip_addresses.get_object_or_404", return_value=mock_device),
            patch("netbox_librenms_plugin.views.sync.ip_addresses.cache") as mock_cache,
            patch("netbox_librenms_plugin.views.sync.ip_addresses.messages") as mock_msgs,
            patch("netbox_librenms_plugin.views.sync.ip_addresses.redirect"),
            patch("netbox_librenms_plugin.views.sync.ip_addresses.transaction"),
            patch("netbox_librenms_plugin.views.sync.ip_addresses.reverse", return_value="/sync/"),
            patch("netbox_librenms_plugin.views.sync.ip_addresses.IPAddress") as mock_ip_cls,
            patch("netbox_librenms_plugin.views.sync.ip_addresses.Interface") as mock_iface_cls,
            patch.object(type(view), "librenms_api", new_callable=lambda: property(lambda s: mock_api)),
        ):
            mock_cache.get.return_value = {"ip_addresses": [ip_data]}
            mock_ip_cls.objects.filter.return_value.first.return_value = None
            mock_iface_cls.objects.get.return_value = MagicMock()

            view.request = _make_request(post_data={"select": ["10.0.0.1"]})
            view.post(view.request, object_type="device", pk=1)

        mock_ip_cls.objects.create.assert_called_once()
        mock_msgs.success.assert_called()


class TestSyncIPAddressesViewUpdateIP:
    def test_existing_ip_updated_when_interface_differs(self):
        from netbox_librenms_plugin.views.sync.ip_addresses import SyncIPAddressesView

        view = object.__new__(SyncIPAddressesView)
        view.require_all_permissions = MagicMock(return_value=None)
        view._post_server_key = "default"
        view.get_cache_key = MagicMock(return_value="k")

        mock_device = MagicMock(pk=1)
        mock_api = MagicMock(server_key="default")

        ip_data = {
            "ip_address": "10.0.0.1",
            "ip_with_mask": "10.0.0.1/24",
            "interface_url": "http://localhost/api/dcim/interfaces/5/",
        }
        mock_existing_ip = MagicMock()
        mock_existing_ip.assigned_object = MagicMock()  # Different from mock_iface
        mock_existing_ip.vrf = None
        mock_iface = MagicMock()

        with (
            patch("netbox_librenms_plugin.views.sync.ip_addresses.get_object_or_404", return_value=mock_device),
            patch("netbox_librenms_plugin.views.sync.ip_addresses.cache") as mock_cache,
            patch("netbox_librenms_plugin.views.sync.ip_addresses.messages") as mock_msgs,
            patch("netbox_librenms_plugin.views.sync.ip_addresses.redirect"),
            patch("netbox_librenms_plugin.views.sync.ip_addresses.transaction"),
            patch("netbox_librenms_plugin.views.sync.ip_addresses.reverse", return_value="/sync/"),
            patch("netbox_librenms_plugin.views.sync.ip_addresses.IPAddress") as mock_ip_cls,
            patch("netbox_librenms_plugin.views.sync.ip_addresses.Interface") as mock_iface_cls,
            patch.object(type(view), "librenms_api", new_callable=lambda: property(lambda s: mock_api)),
        ):
            mock_cache.get.return_value = {"ip_addresses": [ip_data]}
            mock_ip_cls.objects.filter.return_value.first.return_value = mock_existing_ip
            mock_iface_cls.objects.get.return_value = mock_iface

            view.request = _make_request(post_data={"select": ["10.0.0.1"]})
            view.post(view.request, object_type="device", pk=1)

        mock_existing_ip.save.assert_called_once()
        mock_msgs.success.assert_called()


class TestSyncIPAddressesViewUnchangedIP:
    def test_unchanged_ip_shows_warning(self):
        from netbox_librenms_plugin.views.sync.ip_addresses import SyncIPAddressesView

        view = object.__new__(SyncIPAddressesView)
        view.require_all_permissions = MagicMock(return_value=None)
        view._post_server_key = "default"
        view.get_cache_key = MagicMock(return_value="k")

        mock_device = MagicMock(pk=1)
        mock_api = MagicMock(server_key="default")

        ip_data = {
            "ip_address": "10.0.0.1",
            "ip_with_mask": "10.0.0.1/24",
            "interface_url": None,
        }
        mock_existing_ip = MagicMock()
        mock_existing_ip.assigned_object = None
        mock_existing_ip.vrf = None

        with (
            patch("netbox_librenms_plugin.views.sync.ip_addresses.get_object_or_404", return_value=mock_device),
            patch("netbox_librenms_plugin.views.sync.ip_addresses.cache") as mock_cache,
            patch("netbox_librenms_plugin.views.sync.ip_addresses.messages") as mock_msgs,
            patch("netbox_librenms_plugin.views.sync.ip_addresses.redirect"),
            patch("netbox_librenms_plugin.views.sync.ip_addresses.transaction"),
            patch("netbox_librenms_plugin.views.sync.ip_addresses.reverse", return_value="/sync/"),
            patch("netbox_librenms_plugin.views.sync.ip_addresses.IPAddress") as mock_ip_cls,
            patch.object(type(view), "librenms_api", new_callable=lambda: property(lambda s: mock_api)),
        ):
            mock_cache.get.return_value = {"ip_addresses": [ip_data]}
            mock_ip_cls.objects.filter.return_value.first.return_value = mock_existing_ip

            view.request = _make_request(post_data={"select": ["10.0.0.1"]})
            view.post(view.request, object_type="device", pk=1)

        mock_msgs.warning.assert_called()


class TestSyncIPAddressesViewHelpers:
    def test_get_object_device(self):
        from netbox_librenms_plugin.views.sync.ip_addresses import SyncIPAddressesView

        view = object.__new__(SyncIPAddressesView)
        mock_device = MagicMock()
        with patch("netbox_librenms_plugin.views.sync.ip_addresses.get_object_or_404", return_value=mock_device):
            result = view.get_object("device", 1)
        assert result is mock_device

    def test_get_object_virtualmachine(self):
        from netbox_librenms_plugin.views.sync.ip_addresses import SyncIPAddressesView

        view = object.__new__(SyncIPAddressesView)
        mock_vm = MagicMock()
        with patch("netbox_librenms_plugin.views.sync.ip_addresses.get_object_or_404", return_value=mock_vm):
            result = view.get_object("virtualmachine", 1)
        assert result is mock_vm

    def test_get_object_invalid_type_raises(self):
        from django.http import Http404
        from netbox_librenms_plugin.views.sync.ip_addresses import SyncIPAddressesView

        view = object.__new__(SyncIPAddressesView)
        try:
            view.get_object("unknown", 1)
            assert False, "Should have raised Http404"
        except Http404:
            pass

    def test_get_vrf_selection_none_when_no_vrf(self):
        from netbox_librenms_plugin.views.sync.ip_addresses import SyncIPAddressesView

        view = object.__new__(SyncIPAddressesView)
        req = _make_request(post_data={})
        result = view.get_vrf_selection(req, "10.0.0.1")
        assert result is None

    def test_get_vrf_selection_returns_vrf(self):
        from netbox_librenms_plugin.views.sync.ip_addresses import SyncIPAddressesView

        view = object.__new__(SyncIPAddressesView)
        mock_vrf = MagicMock()
        req = _make_request(post_data={"vrf_10.0.0.1": "3"})

        with patch("netbox_librenms_plugin.views.sync.ip_addresses.VRF") as mock_vrf_cls:
            mock_vrf_cls.objects.get.return_value = mock_vrf
            result = view.get_vrf_selection(req, "10.0.0.1")
        assert result is mock_vrf

    def test_get_vrf_selection_not_found_returns_none(self):
        from netbox_librenms_plugin.views.sync.ip_addresses import SyncIPAddressesView

        view = object.__new__(SyncIPAddressesView)
        req = _make_request(post_data={"vrf_10.0.0.1": "99"})

        class _DNE(Exception):
            pass

        with patch("netbox_librenms_plugin.views.sync.ip_addresses.VRF") as mock_vrf_cls:
            mock_vrf_cls.DoesNotExist = _DNE
            mock_vrf_cls.objects.get.side_effect = _DNE()
            result = view.get_vrf_selection(req, "10.0.0.1")
        assert result is None

    def test_get_ip_tab_url_device(self):
        from dcim.models import Device
        from netbox_librenms_plugin.views.sync.ip_addresses import SyncIPAddressesView

        view = object.__new__(SyncIPAddressesView)
        view._post_server_key = "prod"
        mock_api = MagicMock(server_key="prod")
        mock_device = MagicMock(spec=Device, pk=5)

        with (
            patch("netbox_librenms_plugin.views.sync.ip_addresses.reverse", return_value="/device/5/sync/"),
            patch.object(type(view), "librenms_api", new_callable=lambda: property(lambda s: mock_api)),
        ):
            url = view.get_ip_tab_url(mock_device)
        assert "ipaddresses" in url
        assert "server_key=prod" in url

    def test_display_sync_results_all_branches(self):
        from netbox_librenms_plugin.views.sync.ip_addresses import SyncIPAddressesView

        view = object.__new__(SyncIPAddressesView)
        req = _make_request()
        results = {
            "created": ["10.0.0.1"],
            "updated": ["10.0.0.2"],
            "unchanged": ["10.0.0.3"],
            "failed": ["10.0.0.4"],
        }
        with patch("netbox_librenms_plugin.views.sync.ip_addresses.messages") as mock_msgs:
            view.display_sync_results(req, results)
        assert mock_msgs.success.call_count == 2
        assert mock_msgs.warning.call_count == 1
        assert mock_msgs.error.call_count == 1

    def test_get_ip_tab_url_vm(self):
        from virtualization.models import VirtualMachine
        from netbox_librenms_plugin.views.sync.ip_addresses import SyncIPAddressesView

        view = object.__new__(SyncIPAddressesView)
        view._post_server_key = "default"
        mock_api = MagicMock(server_key="default")
        mock_vm = MagicMock(spec=VirtualMachine, pk=7)

        with (
            patch("netbox_librenms_plugin.views.sync.ip_addresses.reverse", return_value="/vm/7/sync/"),
            patch.object(type(view), "librenms_api", new_callable=lambda: property(lambda s: mock_api)),
        ):
            url = view.get_ip_tab_url(mock_vm)
        assert "ipaddresses" in url

    def test_get_ip_tab_url_no_server_key(self):
        from dcim.models import Device
        from netbox_librenms_plugin.views.sync.ip_addresses import SyncIPAddressesView

        view = object.__new__(SyncIPAddressesView)
        view._post_server_key = None
        mock_api = MagicMock(server_key=None)
        mock_device = MagicMock(spec=Device, pk=5)

        with (
            patch("netbox_librenms_plugin.views.sync.ip_addresses.reverse", return_value="/device/5/sync/"),
            patch.object(type(view), "librenms_api", new_callable=lambda: property(lambda s: mock_api)),
        ):
            url = view.get_ip_tab_url(mock_device)
        assert "server_key" not in url


class TestSyncIPAddressesViewVMInterface:
    def test_vm_interface_resolved(self):
        from netbox_librenms_plugin.views.sync.ip_addresses import SyncIPAddressesView

        view = object.__new__(SyncIPAddressesView)
        view.require_all_permissions = MagicMock(return_value=None)
        view._post_server_key = "default"
        view.get_cache_key = MagicMock(return_value="k")

        mock_vm = MagicMock()
        mock_vm.pk = 2
        mock_api = MagicMock(server_key="default")

        ip_data = {
            "ip_address": "10.0.0.5",
            "ip_with_mask": "10.0.0.5/24",
            "interface_url": "http://localhost/api/virtualization/interfaces/9/",
        }

        with (
            patch("netbox_librenms_plugin.views.sync.ip_addresses.get_object_or_404", return_value=mock_vm),
            patch("netbox_librenms_plugin.views.sync.ip_addresses.cache") as mock_cache,
            patch("netbox_librenms_plugin.views.sync.ip_addresses.messages"),
            patch("netbox_librenms_plugin.views.sync.ip_addresses.redirect"),
            patch("netbox_librenms_plugin.views.sync.ip_addresses.transaction"),
            patch("netbox_librenms_plugin.views.sync.ip_addresses.reverse", return_value="/sync/"),
            patch("netbox_librenms_plugin.views.sync.ip_addresses.IPAddress") as mock_ip_cls,
            patch("netbox_librenms_plugin.views.sync.ip_addresses.VMInterface") as mock_vmiface_cls,
            patch.object(type(view), "librenms_api", new_callable=lambda: property(lambda s: mock_api)),
        ):
            mock_cache.get.return_value = {"ip_addresses": [ip_data]}
            mock_ip_cls.objects.filter.return_value.first.return_value = None
            mock_vmiface_cls.objects.get.return_value = MagicMock()

            view.request = _make_request(post_data={"select": ["10.0.0.5"]})
            view.post(view.request, object_type="virtualmachine", pk=2)

        mock_vmiface_cls.objects.get.assert_called_once()


# ===========================================================================
# views/sync/vlans.py — SyncVLANsView
# ===========================================================================


class TestSyncVLANsViewPermissionDenied:
    def test_permission_denied_returns_early(self):
        from netbox_librenms_plugin.views.sync.vlans import SyncVLANsView

        view = object.__new__(SyncVLANsView)
        view.require_all_permissions = MagicMock(return_value=_denied_response())
        view.request = _make_request()

        result = view.post(view.request, object_type="device", object_id=1)
        assert result.status_code == 403


class TestSyncVLANsViewInvalidAction:
    def test_invalid_action_shows_error(self):
        from netbox_librenms_plugin.views.sync.vlans import SyncVLANsView

        view = object.__new__(SyncVLANsView)
        view.require_all_permissions = MagicMock(return_value=None)
        view._post_server_key = "default"
        mock_api = MagicMock(server_key="default")

        mock_device = MagicMock(pk=1)

        with (
            patch("netbox_librenms_plugin.views.sync.vlans.get_object_or_404", return_value=mock_device),
            patch("netbox_librenms_plugin.views.sync.vlans.messages") as mock_msgs,
            patch("netbox_librenms_plugin.views.sync.vlans.redirect"),
            patch("netbox_librenms_plugin.views.sync.vlans.transaction"),
            patch("netbox_librenms_plugin.views.sync.vlans.reverse", return_value="/sync/"),
            patch.object(type(view), "librenms_api", new_callable=lambda: property(lambda s: mock_api)),
        ):
            view.request = _make_request(post_data={"action": "bad_action"})
            view.post(view.request, object_type="device", object_id=1)

        mock_msgs.error.assert_called_once()


class TestSyncVLANsViewNoSelection:
    def test_no_selection_shows_error(self):
        from netbox_librenms_plugin.views.sync.vlans import SyncVLANsView

        view = object.__new__(SyncVLANsView)
        view.require_all_permissions = MagicMock(return_value=None)
        view._post_server_key = "default"
        view.get_cache_key = MagicMock(return_value="k")
        mock_api = MagicMock(server_key="default")

        mock_device = MagicMock(pk=1)

        with (
            patch("netbox_librenms_plugin.views.sync.vlans.get_object_or_404", return_value=mock_device),
            patch("netbox_librenms_plugin.views.sync.vlans.cache") as mock_cache,
            patch("netbox_librenms_plugin.views.sync.vlans.messages") as mock_msgs,
            patch("netbox_librenms_plugin.views.sync.vlans.redirect"),
            patch("netbox_librenms_plugin.views.sync.vlans.transaction"),
            patch("netbox_librenms_plugin.views.sync.vlans.reverse", return_value="/sync/"),
            patch.object(type(view), "librenms_api", new_callable=lambda: property(lambda s: mock_api)),
        ):
            mock_cache.get.return_value = {"some": "data"}
            view.request = _make_request(post_data={"action": "create_vlans"})
            view.post(view.request, object_type="device", object_id=1)

        mock_msgs.error.assert_called_once()


class TestSyncVLANsViewCacheMiss:
    def test_cache_miss_shows_error(self):
        from netbox_librenms_plugin.views.sync.vlans import SyncVLANsView

        view = object.__new__(SyncVLANsView)
        view.require_all_permissions = MagicMock(return_value=None)
        view._post_server_key = "default"
        view.get_cache_key = MagicMock(return_value="k")
        mock_api = MagicMock(server_key="default")

        mock_device = MagicMock(pk=1)

        with (
            patch("netbox_librenms_plugin.views.sync.vlans.get_object_or_404", return_value=mock_device),
            patch("netbox_librenms_plugin.views.sync.vlans.cache") as mock_cache,
            patch("netbox_librenms_plugin.views.sync.vlans.messages") as mock_msgs,
            patch("netbox_librenms_plugin.views.sync.vlans.redirect"),
            patch("netbox_librenms_plugin.views.sync.vlans.transaction"),
            patch("netbox_librenms_plugin.views.sync.vlans.reverse", return_value="/sync/"),
            patch.object(type(view), "librenms_api", new_callable=lambda: property(lambda s: mock_api)),
        ):
            mock_cache.get.return_value = None
            view.request = _make_request(post_data={"action": "create_vlans", "select": ["100"]})
            view.post(view.request, object_type="device", object_id=1)

        mock_msgs.error.assert_called_once()


class TestSyncVLANsViewCreateVLAN:
    def test_new_vlan_created(self):
        from netbox_librenms_plugin.views.sync.vlans import SyncVLANsView

        view = object.__new__(SyncVLANsView)
        view.require_all_permissions = MagicMock(return_value=None)
        view._post_server_key = "default"
        view.get_cache_key = MagicMock(return_value="k")
        mock_api = MagicMock(server_key="default")

        mock_device = MagicMock(pk=1)
        mock_vlan = MagicMock()

        librenms_vlans = [{"vlan_vlan": 100, "vlan_name": "Management"}]

        with (
            patch("netbox_librenms_plugin.views.sync.vlans.get_object_or_404", return_value=mock_device),
            patch("netbox_librenms_plugin.views.sync.vlans.cache") as mock_cache,
            patch("netbox_librenms_plugin.views.sync.vlans.messages") as mock_msgs,
            patch("netbox_librenms_plugin.views.sync.vlans.redirect"),
            patch("netbox_librenms_plugin.views.sync.vlans.transaction"),
            patch("netbox_librenms_plugin.views.sync.vlans.reverse", return_value="/sync/"),
            patch("netbox_librenms_plugin.views.sync.vlans.VLAN") as mock_vlan_cls,
            patch.object(type(view), "librenms_api", new_callable=lambda: property(lambda s: mock_api)),
        ):
            mock_cache.get.return_value = librenms_vlans
            mock_vlan_cls.objects.get_or_create.return_value = (mock_vlan, True)

            view.request = _make_request(post_data={"action": "create_vlans", "select": ["100"]})
            view.post(view.request, object_type="device", object_id=1)

        mock_vlan_cls.objects.get_or_create.assert_called_once()
        mock_msgs.success.assert_called_once()


class TestSyncVLANsViewUpdateVLAN:
    def test_existing_vlan_name_updated(self):
        from netbox_librenms_plugin.views.sync.vlans import SyncVLANsView

        view = object.__new__(SyncVLANsView)
        view.require_all_permissions = MagicMock(return_value=None)
        view._post_server_key = "default"
        view.get_cache_key = MagicMock(return_value="k")
        mock_api = MagicMock(server_key="default")

        mock_device = MagicMock(pk=1)
        mock_vlan = MagicMock()
        mock_vlan.name = "OldName"

        librenms_vlans = [{"vlan_vlan": 100, "vlan_name": "Management"}]

        with (
            patch("netbox_librenms_plugin.views.sync.vlans.get_object_or_404", return_value=mock_device),
            patch("netbox_librenms_plugin.views.sync.vlans.cache") as mock_cache,
            patch("netbox_librenms_plugin.views.sync.vlans.messages"),
            patch("netbox_librenms_plugin.views.sync.vlans.redirect"),
            patch("netbox_librenms_plugin.views.sync.vlans.transaction"),
            patch("netbox_librenms_plugin.views.sync.vlans.reverse", return_value="/sync/"),
            patch("netbox_librenms_plugin.views.sync.vlans.VLAN") as mock_vlan_cls,
            patch.object(type(view), "librenms_api", new_callable=lambda: property(lambda s: mock_api)),
        ):
            mock_cache.get.return_value = librenms_vlans
            mock_vlan_cls.objects.get_or_create.return_value = (mock_vlan, False)

            view.request = _make_request(post_data={"action": "create_vlans", "select": ["100"]})
            view.post(view.request, object_type="device", object_id=1)

        mock_vlan.save.assert_called_once()


class TestSyncVLANsViewUnchangedVLAN:
    def test_unchanged_vlan_counts_as_skipped(self):
        from netbox_librenms_plugin.views.sync.vlans import SyncVLANsView

        view = object.__new__(SyncVLANsView)
        view.require_all_permissions = MagicMock(return_value=None)
        view._post_server_key = "default"
        view.get_cache_key = MagicMock(return_value="k")
        mock_api = MagicMock(server_key="default")

        mock_device = MagicMock(pk=1)
        mock_vlan = MagicMock()
        mock_vlan.name = "Management"  # Same name as librenms → unchanged

        librenms_vlans = [{"vlan_vlan": 100, "vlan_name": "Management"}]

        with (
            patch("netbox_librenms_plugin.views.sync.vlans.get_object_or_404", return_value=mock_device),
            patch("netbox_librenms_plugin.views.sync.vlans.cache") as mock_cache,
            patch("netbox_librenms_plugin.views.sync.vlans.messages") as mock_msgs,
            patch("netbox_librenms_plugin.views.sync.vlans.redirect"),
            patch("netbox_librenms_plugin.views.sync.vlans.transaction"),
            patch("netbox_librenms_plugin.views.sync.vlans.reverse", return_value="/sync/"),
            patch("netbox_librenms_plugin.views.sync.vlans.VLAN") as mock_vlan_cls,
            patch.object(type(view), "librenms_api", new_callable=lambda: property(lambda s: mock_api)),
        ):
            mock_cache.get.return_value = librenms_vlans
            mock_vlan_cls.objects.get_or_create.return_value = (mock_vlan, False)

            view.request = _make_request(post_data={"action": "create_vlans", "select": ["100"]})
            view.post(view.request, object_type="device", object_id=1)

        mock_msgs.success.assert_called()
        assert "unchanged" in str(mock_msgs.success.call_args_list)


class TestSyncVLANsViewWithGroup:
    def test_vlan_created_in_group(self):
        from netbox_librenms_plugin.views.sync.vlans import SyncVLANsView

        view = object.__new__(SyncVLANsView)
        view.require_all_permissions = MagicMock(return_value=None)
        view._post_server_key = "default"
        view.get_cache_key = MagicMock(return_value="k")
        mock_api = MagicMock(server_key="default")

        mock_device = MagicMock(pk=1)
        mock_vlan_group = MagicMock(pk=3)
        mock_vlan = MagicMock()

        librenms_vlans = [{"vlan_vlan": 200, "vlan_name": "Production"}]

        with (
            patch("netbox_librenms_plugin.views.sync.vlans.get_object_or_404", return_value=mock_device),
            patch("netbox_librenms_plugin.views.sync.vlans.cache") as mock_cache,
            patch("netbox_librenms_plugin.views.sync.vlans.messages"),
            patch("netbox_librenms_plugin.views.sync.vlans.redirect"),
            patch("netbox_librenms_plugin.views.sync.vlans.transaction"),
            patch("netbox_librenms_plugin.views.sync.vlans.reverse", return_value="/sync/"),
            patch("netbox_librenms_plugin.views.sync.vlans.VLAN") as mock_vlan_cls,
            patch("netbox_librenms_plugin.views.sync.vlans.VLANGroup") as mock_group_cls,
            patch.object(type(view), "librenms_api", new_callable=lambda: property(lambda s: mock_api)),
        ):
            mock_cache.get.return_value = librenms_vlans
            mock_group_cls.objects.get.return_value = mock_vlan_group
            mock_vlan_cls.objects.get_or_create.return_value = (mock_vlan, True)

            view.request = _make_request(post_data={"action": "create_vlans", "select": ["200"], "vlan_group_200": "3"})
            view.post(view.request, object_type="device", object_id=1)

        call_kwargs = mock_vlan_cls.objects.get_or_create.call_args[1]
        assert call_kwargs.get("group") is mock_vlan_group or mock_vlan_cls.objects.get_or_create.called

    def test_invalid_vlan_group_id_falls_back_to_global(self):
        from netbox_librenms_plugin.views.sync.vlans import SyncVLANsView

        view = object.__new__(SyncVLANsView)
        view.require_all_permissions = MagicMock(return_value=None)
        view._post_server_key = "default"
        view.get_cache_key = MagicMock(return_value="k")
        mock_api = MagicMock(server_key="default")

        mock_device = MagicMock(pk=1)
        mock_vlan = MagicMock()

        librenms_vlans = [{"vlan_vlan": 200, "vlan_name": "Production"}]

        class _DNE(Exception):
            pass

        with (
            patch("netbox_librenms_plugin.views.sync.vlans.get_object_or_404", return_value=mock_device),
            patch("netbox_librenms_plugin.views.sync.vlans.cache") as mock_cache,
            patch("netbox_librenms_plugin.views.sync.vlans.messages"),
            patch("netbox_librenms_plugin.views.sync.vlans.redirect"),
            patch("netbox_librenms_plugin.views.sync.vlans.transaction"),
            patch("netbox_librenms_plugin.views.sync.vlans.reverse", return_value="/sync/"),
            patch("netbox_librenms_plugin.views.sync.vlans.VLAN") as mock_vlan_cls,
            patch("netbox_librenms_plugin.views.sync.vlans.VLANGroup") as mock_group_cls,
            patch.object(type(view), "librenms_api", new_callable=lambda: property(lambda s: mock_api)),
        ):
            mock_cache.get.return_value = librenms_vlans
            mock_group_cls.DoesNotExist = _DNE
            mock_group_cls.objects.get.side_effect = _DNE()
            mock_vlan_cls.objects.get_or_create.return_value = (mock_vlan, True)

            view.request = _make_request(
                post_data={"action": "create_vlans", "select": ["200"], "vlan_group_200": "99"}
            )
            view.post(view.request, object_type="device", object_id=1)

        # Falls back to global VLAN (group=None)
        call_kwargs = mock_vlan_cls.objects.get_or_create.call_args[1]
        assert call_kwargs.get("group") is None

    def test_invalid_vid_string_skipped(self):
        from netbox_librenms_plugin.views.sync.vlans import SyncVLANsView

        view = object.__new__(SyncVLANsView)
        view.require_all_permissions = MagicMock(return_value=None)
        view._post_server_key = "default"
        view.get_cache_key = MagicMock(return_value="k")
        mock_api = MagicMock(server_key="default")

        mock_device = MagicMock(pk=1)
        librenms_vlans = [{"vlan_vlan": 100, "vlan_name": "Mgmt"}]

        with (
            patch("netbox_librenms_plugin.views.sync.vlans.get_object_or_404", return_value=mock_device),
            patch("netbox_librenms_plugin.views.sync.vlans.cache") as mock_cache,
            patch("netbox_librenms_plugin.views.sync.vlans.messages"),
            patch("netbox_librenms_plugin.views.sync.vlans.redirect"),
            patch("netbox_librenms_plugin.views.sync.vlans.transaction"),
            patch("netbox_librenms_plugin.views.sync.vlans.reverse", return_value="/sync/"),
            patch("netbox_librenms_plugin.views.sync.vlans.VLAN") as mock_vlan_cls,
            patch.object(type(view), "librenms_api", new_callable=lambda: property(lambda s: mock_api)),
        ):
            mock_cache.get.return_value = librenms_vlans
            # "not-a-vid" → int() fails → skip
            view.request = _make_request(post_data={"action": "create_vlans", "select": ["not-a-vid"]})
            view.post(view.request, object_type="device", object_id=1)

        # no VLAN created, shows "no VLANs" warning
        mock_vlan_cls.objects.get_or_create.assert_not_called()

    def test_unknown_vid_in_cache_skipped(self):
        from netbox_librenms_plugin.views.sync.vlans import SyncVLANsView

        view = object.__new__(SyncVLANsView)
        view.require_all_permissions = MagicMock(return_value=None)
        view._post_server_key = "default"
        view.get_cache_key = MagicMock(return_value="k")
        mock_api = MagicMock(server_key="default")

        mock_device = MagicMock(pk=1)
        librenms_vlans = [{"vlan_vlan": 100, "vlan_name": "Mgmt"}]

        with (
            patch("netbox_librenms_plugin.views.sync.vlans.get_object_or_404", return_value=mock_device),
            patch("netbox_librenms_plugin.views.sync.vlans.cache") as mock_cache,
            patch("netbox_librenms_plugin.views.sync.vlans.messages"),
            patch("netbox_librenms_plugin.views.sync.vlans.redirect"),
            patch("netbox_librenms_plugin.views.sync.vlans.transaction"),
            patch("netbox_librenms_plugin.views.sync.vlans.reverse", return_value="/sync/"),
            patch("netbox_librenms_plugin.views.sync.vlans.VLAN") as mock_vlan_cls,
            patch.object(type(view), "librenms_api", new_callable=lambda: property(lambda s: mock_api)),
        ):
            mock_cache.get.return_value = librenms_vlans
            # VID 999 not in cached vlans → skipped
            view.request = _make_request(post_data={"action": "create_vlans", "select": ["999"]})
            view.post(view.request, object_type="device", object_id=1)

        mock_vlan_cls.objects.get_or_create.assert_not_called()

    def test_no_vlans_shows_warning(self):
        from netbox_librenms_plugin.views.sync.vlans import SyncVLANsView

        view = object.__new__(SyncVLANsView)
        view.require_all_permissions = MagicMock(return_value=None)
        view._post_server_key = "default"
        view.get_cache_key = MagicMock(return_value="k")
        mock_api = MagicMock(server_key="default")

        mock_device = MagicMock(pk=1)
        librenms_vlans = [{"vlan_vlan": 100, "vlan_name": "Mgmt"}]

        with (
            patch("netbox_librenms_plugin.views.sync.vlans.get_object_or_404", return_value=mock_device),
            patch("netbox_librenms_plugin.views.sync.vlans.cache") as mock_cache,
            patch("netbox_librenms_plugin.views.sync.vlans.messages") as mock_msgs,
            patch("netbox_librenms_plugin.views.sync.vlans.redirect"),
            patch("netbox_librenms_plugin.views.sync.vlans.transaction"),
            patch("netbox_librenms_plugin.views.sync.vlans.reverse", return_value="/sync/"),
            patch("netbox_librenms_plugin.views.sync.vlans.VLAN"),
            patch.object(type(view), "librenms_api", new_callable=lambda: property(lambda s: mock_api)),
        ):
            mock_cache.get.return_value = librenms_vlans
            view.request = _make_request(post_data={"action": "create_vlans", "select": ["not-a-vid"]})
            view.post(view.request, object_type="device", object_id=1)

        mock_msgs.warning.assert_called()


class TestSyncVLANsViewGroupedUpdateSkip:
    """Lines 134-139: grouped VLAN update (elif) and unchanged (else) paths."""

    def test_grouped_vlan_name_updated(self):
        from netbox_librenms_plugin.views.sync.vlans import SyncVLANsView

        view = object.__new__(SyncVLANsView)
        view.require_all_permissions = MagicMock(return_value=None)
        view._post_server_key = "default"
        view.get_cache_key = MagicMock(return_value="k")
        mock_api = MagicMock(server_key="default")

        mock_device = MagicMock(pk=1)
        mock_vlan_group = MagicMock(pk=3)
        mock_vlan = MagicMock()
        mock_vlan.name = "OldGroupedName"  # Different from librenms → update path

        librenms_vlans = [{"vlan_vlan": 300, "vlan_name": "NewGroupedName"}]

        with (
            patch("netbox_librenms_plugin.views.sync.vlans.get_object_or_404", return_value=mock_device),
            patch("netbox_librenms_plugin.views.sync.vlans.cache") as mock_cache,
            patch("netbox_librenms_plugin.views.sync.vlans.messages"),
            patch("netbox_librenms_plugin.views.sync.vlans.redirect"),
            patch("netbox_librenms_plugin.views.sync.vlans.transaction"),
            patch("netbox_librenms_plugin.views.sync.vlans.reverse", return_value="/sync/"),
            patch("netbox_librenms_plugin.views.sync.vlans.VLAN") as mock_vlan_cls,
            patch("netbox_librenms_plugin.views.sync.vlans.VLANGroup") as mock_group_cls,
            patch.object(type(view), "librenms_api", new_callable=lambda: property(lambda s: mock_api)),
        ):
            mock_cache.get.return_value = librenms_vlans
            mock_group_cls.objects.get.return_value = mock_vlan_group
            mock_vlan_cls.objects.get_or_create.return_value = (mock_vlan, False)  # exists

            view.request = _make_request(post_data={"action": "create_vlans", "select": ["300"], "vlan_group_300": "3"})
            view.post(view.request, object_type="device", object_id=1)

        mock_vlan.save.assert_called_once()

    def test_grouped_vlan_unchanged_skipped(self):
        from netbox_librenms_plugin.views.sync.vlans import SyncVLANsView

        view = object.__new__(SyncVLANsView)
        view.require_all_permissions = MagicMock(return_value=None)
        view._post_server_key = "default"
        view.get_cache_key = MagicMock(return_value="k")
        mock_api = MagicMock(server_key="default")

        mock_device = MagicMock(pk=1)
        mock_vlan_group = MagicMock(pk=3)
        mock_vlan = MagicMock()
        mock_vlan.name = "SameName"  # Same name → skipped_count path

        librenms_vlans = [{"vlan_vlan": 300, "vlan_name": "SameName"}]

        with (
            patch("netbox_librenms_plugin.views.sync.vlans.get_object_or_404", return_value=mock_device),
            patch("netbox_librenms_plugin.views.sync.vlans.cache") as mock_cache,
            patch("netbox_librenms_plugin.views.sync.vlans.messages") as mock_msgs,
            patch("netbox_librenms_plugin.views.sync.vlans.redirect"),
            patch("netbox_librenms_plugin.views.sync.vlans.transaction"),
            patch("netbox_librenms_plugin.views.sync.vlans.reverse", return_value="/sync/"),
            patch("netbox_librenms_plugin.views.sync.vlans.VLAN") as mock_vlan_cls,
            patch("netbox_librenms_plugin.views.sync.vlans.VLANGroup") as mock_group_cls,
            patch.object(type(view), "librenms_api", new_callable=lambda: property(lambda s: mock_api)),
        ):
            mock_cache.get.return_value = librenms_vlans
            mock_group_cls.objects.get.return_value = mock_vlan_group
            mock_vlan_cls.objects.get_or_create.return_value = (mock_vlan, False)

            view.request = _make_request(post_data={"action": "create_vlans", "select": ["300"], "vlan_group_300": "3"})
            view.post(view.request, object_type="device", object_id=1)

        mock_vlan.save.assert_not_called()
        mock_msgs.success.assert_called()
        assert "unchanged" in str(mock_msgs.success.call_args_list)

    def test_get_object_device(self):
        from netbox_librenms_plugin.views.sync.vlans import SyncVLANsView

        view = object.__new__(SyncVLANsView)
        mock_device = MagicMock()
        with patch("netbox_librenms_plugin.views.sync.vlans.get_object_or_404", return_value=mock_device):
            result = view.get_object("device", 1)
        assert result is mock_device

    def test_get_object_invalid_raises(self):
        from django.http import Http404
        from netbox_librenms_plugin.views.sync.vlans import SyncVLANsView

        view = object.__new__(SyncVLANsView)
        try:
            view.get_object("vm_type", 1)
            assert False, "Should have raised Http404"
        except Http404:
            pass

    def test_redirect_with_server_key(self):
        from netbox_librenms_plugin.views.sync.vlans import SyncVLANsView

        view = object.__new__(SyncVLANsView)
        view._post_server_key = "production"
        mock_api = MagicMock(server_key="production")

        with (
            patch("netbox_librenms_plugin.views.sync.vlans.reverse", return_value="/device/1/sync/"),
            patch("netbox_librenms_plugin.views.sync.vlans.redirect") as mock_redirect,
            patch.object(type(view), "librenms_api", new_callable=lambda: property(lambda s: mock_api)),
        ):
            view._redirect("device", 1)

        call_url = mock_redirect.call_args[0][0]
        assert "server_key=production" in call_url


# ===========================================================================
# views/sync/locations.py — SyncSiteLocationView
# ===========================================================================


class TestSyncSiteLocationViewPost:
    def test_permission_denied_returns_early(self):
        from netbox_librenms_plugin.views.sync.locations import SyncSiteLocationView

        view = object.__new__(SyncSiteLocationView)
        view.require_write_permission = MagicMock(return_value=_denied_response())
        view.request = _make_request(post_data={"action": "create", "pk": "1"})

        result = view.post(view.request)
        assert result.status_code == 403

    def test_missing_pk_shows_error(self):
        from netbox_librenms_plugin.views.sync.locations import SyncSiteLocationView

        view = object.__new__(SyncSiteLocationView)
        view.require_write_permission = MagicMock(return_value=None)
        mock_api = MagicMock()

        with (
            patch("netbox_librenms_plugin.views.sync.locations.messages") as mock_msgs,
            patch("netbox_librenms_plugin.views.sync.locations.redirect"),
            patch.object(type(view), "librenms_api", new_callable=lambda: property(lambda s: mock_api)),
        ):
            view.request = _make_request(post_data={"action": "create"})
            view.post(view.request)

        mock_msgs.error.assert_called_once()

    def test_site_not_found_shows_error(self):
        from django.core.exceptions import ObjectDoesNotExist
        from netbox_librenms_plugin.views.sync.locations import SyncSiteLocationView

        view = object.__new__(SyncSiteLocationView)
        view.require_write_permission = MagicMock(return_value=None)
        mock_api = MagicMock()

        with (
            patch("netbox_librenms_plugin.views.sync.locations.messages") as mock_msgs,
            patch("netbox_librenms_plugin.views.sync.locations.redirect"),
            patch("netbox_librenms_plugin.views.sync.locations.Site") as mock_site_cls,
            patch.object(type(view), "librenms_api", new_callable=lambda: property(lambda s: mock_api)),
        ):
            mock_site_cls.objects.get.side_effect = ObjectDoesNotExist()
            view.request = _make_request(post_data={"action": "create", "pk": "99"})
            view.post(view.request)

        mock_msgs.error.assert_called_once()

    def test_unknown_action_shows_error(self):
        from netbox_librenms_plugin.views.sync.locations import SyncSiteLocationView

        view = object.__new__(SyncSiteLocationView)
        view.require_write_permission = MagicMock(return_value=None)
        mock_api = MagicMock()
        mock_site = MagicMock()

        with (
            patch("netbox_librenms_plugin.views.sync.locations.messages") as mock_msgs,
            patch("netbox_librenms_plugin.views.sync.locations.redirect"),
            patch("netbox_librenms_plugin.views.sync.locations.Site") as mock_site_cls,
            patch.object(type(view), "librenms_api", new_callable=lambda: property(lambda s: mock_api)),
        ):
            mock_site_cls.objects.get.return_value = mock_site
            view.request = _make_request(post_data={"action": "banana", "pk": "1"})
            view.post(view.request)

        mock_msgs.error.assert_called_once()


class TestSyncSiteLocationViewCreate:
    def test_create_without_coords_shows_warning(self):
        from netbox_librenms_plugin.views.sync.locations import SyncSiteLocationView

        view = object.__new__(SyncSiteLocationView)
        view.require_write_permission = MagicMock(return_value=None)
        mock_api = MagicMock()
        mock_site = MagicMock()
        mock_site.name = "London"
        mock_site.latitude = None
        mock_site.longitude = None

        with (
            patch("netbox_librenms_plugin.views.sync.locations.messages") as mock_msgs,
            patch("netbox_librenms_plugin.views.sync.locations.redirect"),
            patch("netbox_librenms_plugin.views.sync.locations.Site") as mock_site_cls,
            patch.object(type(view), "librenms_api", new_callable=lambda: property(lambda s: mock_api)),
        ):
            mock_site_cls.objects.get.return_value = mock_site
            view.request = _make_request(post_data={"action": "create", "pk": "1"})
            view.post(view.request)

        mock_msgs.warning.assert_called_once()

    def test_create_success(self):
        from netbox_librenms_plugin.views.sync.locations import SyncSiteLocationView

        view = object.__new__(SyncSiteLocationView)
        view.require_write_permission = MagicMock(return_value=None)
        mock_api = MagicMock()
        mock_api.add_location.return_value = (True, "ok")
        mock_site = MagicMock()
        mock_site.name = "London"
        mock_site.latitude = 51.5
        mock_site.longitude = -0.12

        with (
            patch("netbox_librenms_plugin.views.sync.locations.messages") as mock_msgs,
            patch("netbox_librenms_plugin.views.sync.locations.redirect"),
            patch("netbox_librenms_plugin.views.sync.locations.Site") as mock_site_cls,
            patch.object(type(view), "librenms_api", new_callable=lambda: property(lambda s: mock_api)),
        ):
            mock_site_cls.objects.get.return_value = mock_site
            view.request = _make_request(post_data={"action": "create", "pk": "1"})
            view.post(view.request)

        mock_msgs.success.assert_called_once()

    def test_create_failure(self):
        from netbox_librenms_plugin.views.sync.locations import SyncSiteLocationView

        view = object.__new__(SyncSiteLocationView)
        view.require_write_permission = MagicMock(return_value=None)
        mock_api = MagicMock()
        mock_api.add_location.return_value = (False, "Server error")
        mock_site = MagicMock()
        mock_site.name = "London"
        mock_site.latitude = 51.5
        mock_site.longitude = -0.12

        with (
            patch("netbox_librenms_plugin.views.sync.locations.messages") as mock_msgs,
            patch("netbox_librenms_plugin.views.sync.locations.redirect"),
            patch("netbox_librenms_plugin.views.sync.locations.Site") as mock_site_cls,
            patch.object(type(view), "librenms_api", new_callable=lambda: property(lambda s: mock_api)),
        ):
            mock_site_cls.objects.get.return_value = mock_site
            view.request = _make_request(post_data={"action": "create", "pk": "1"})
            view.post(view.request)

        mock_msgs.error.assert_called_once()


class TestSyncSiteLocationViewUpdate:
    def test_update_without_coords_shows_warning(self):
        from netbox_librenms_plugin.views.sync.locations import SyncSiteLocationView

        view = object.__new__(SyncSiteLocationView)
        view.require_write_permission = MagicMock(return_value=None)
        mock_api = MagicMock()
        mock_site = MagicMock()
        mock_site.name = "Berlin"
        mock_site.latitude = None
        mock_site.longitude = 13.4

        with (
            patch("netbox_librenms_plugin.views.sync.locations.messages") as mock_msgs,
            patch("netbox_librenms_plugin.views.sync.locations.redirect"),
            patch("netbox_librenms_plugin.views.sync.locations.Site") as mock_site_cls,
            patch.object(type(view), "librenms_api", new_callable=lambda: property(lambda s: mock_api)),
        ):
            mock_site_cls.objects.get.return_value = mock_site
            view.request = _make_request(post_data={"action": "update", "pk": "1"})
            view.post(view.request)

        mock_msgs.warning.assert_called_once()

    def test_update_api_failure_fetching_locations(self):
        from netbox_librenms_plugin.views.sync.locations import SyncSiteLocationView

        view = object.__new__(SyncSiteLocationView)
        view.require_write_permission = MagicMock(return_value=None)
        mock_api = MagicMock()
        mock_api.get_locations.return_value = (False, "Connection error")
        mock_site = MagicMock()
        mock_site.name = "Berlin"
        mock_site.latitude = 52.5
        mock_site.longitude = 13.4

        with (
            patch("netbox_librenms_plugin.views.sync.locations.messages") as mock_msgs,
            patch("netbox_librenms_plugin.views.sync.locations.redirect"),
            patch("netbox_librenms_plugin.views.sync.locations.Site") as mock_site_cls,
            patch.object(type(view), "librenms_api", new_callable=lambda: property(lambda s: mock_api)),
        ):
            mock_site_cls.objects.get.return_value = mock_site
            view.request = _make_request(post_data={"action": "update", "pk": "1"})
            view.post(view.request)

        mock_msgs.error.assert_called_once()

    def test_update_no_matching_location(self):
        from netbox_librenms_plugin.views.sync.locations import SyncSiteLocationView

        view = object.__new__(SyncSiteLocationView)
        view.require_write_permission = MagicMock(return_value=None)
        mock_api = MagicMock()
        mock_api.get_locations.return_value = (True, [{"location": "Paris"}])  # No Berlin
        mock_site = MagicMock()
        mock_site.name = "Berlin"
        mock_site.slug = "berlin"
        mock_site.latitude = 52.5
        mock_site.longitude = 13.4

        with (
            patch("netbox_librenms_plugin.views.sync.locations.messages") as mock_msgs,
            patch("netbox_librenms_plugin.views.sync.locations.redirect"),
            patch("netbox_librenms_plugin.views.sync.locations.Site") as mock_site_cls,
            patch.object(type(view), "librenms_api", new_callable=lambda: property(lambda s: mock_api)),
        ):
            mock_site_cls.objects.get.return_value = mock_site
            view.request = _make_request(post_data={"action": "update", "pk": "1"})
            view.post(view.request)

        mock_msgs.error.assert_called_once()

    def test_update_success(self):
        from netbox_librenms_plugin.views.sync.locations import SyncSiteLocationView

        view = object.__new__(SyncSiteLocationView)
        view.require_write_permission = MagicMock(return_value=None)
        mock_api = MagicMock()
        mock_api.get_locations.return_value = (True, [{"location": "Berlin"}])
        mock_api.update_location.return_value = (True, "ok")
        mock_site = MagicMock()
        mock_site.name = "Berlin"
        mock_site.slug = "berlin"
        mock_site.latitude = 52.5
        mock_site.longitude = 13.4

        with (
            patch("netbox_librenms_plugin.views.sync.locations.messages") as mock_msgs,
            patch("netbox_librenms_plugin.views.sync.locations.redirect"),
            patch("netbox_librenms_plugin.views.sync.locations.Site") as mock_site_cls,
            patch.object(type(view), "librenms_api", new_callable=lambda: property(lambda s: mock_api)),
        ):
            mock_site_cls.objects.get.return_value = mock_site
            view.request = _make_request(post_data={"action": "update", "pk": "1"})
            view.post(view.request)

        mock_msgs.success.assert_called_once()

    def test_update_failure(self):
        from netbox_librenms_plugin.views.sync.locations import SyncSiteLocationView

        view = object.__new__(SyncSiteLocationView)
        view.require_write_permission = MagicMock(return_value=None)
        mock_api = MagicMock()
        mock_api.get_locations.return_value = (True, [{"location": "Berlin"}])
        mock_api.update_location.return_value = (False, "Not found in LibreNMS")
        mock_site = MagicMock()
        mock_site.name = "Berlin"
        mock_site.slug = "berlin"
        mock_site.latitude = 52.5
        mock_site.longitude = 13.4

        with (
            patch("netbox_librenms_plugin.views.sync.locations.messages") as mock_msgs,
            patch("netbox_librenms_plugin.views.sync.locations.redirect"),
            patch("netbox_librenms_plugin.views.sync.locations.Site") as mock_site_cls,
            patch.object(type(view), "librenms_api", new_callable=lambda: property(lambda s: mock_api)),
        ):
            mock_site_cls.objects.get.return_value = mock_site
            view.request = _make_request(post_data={"action": "update", "pk": "1"})
            view.post(view.request)

        mock_msgs.error.assert_called_once()


class TestSyncSiteLocationViewHelpers:
    def test_match_site_by_name(self):
        from netbox_librenms_plugin.views.sync.locations import SyncSiteLocationView

        view = object.__new__(SyncSiteLocationView)
        site = MagicMock()
        site.name = "London"
        site.slug = "london"
        locations = [{"location": "London"}, {"location": "Berlin"}]
        result = view.match_site_with_location(site, locations)
        assert result["location"] == "London"

    def test_match_site_by_slug(self):
        from netbox_librenms_plugin.views.sync.locations import SyncSiteLocationView

        view = object.__new__(SyncSiteLocationView)
        site = MagicMock()
        site.name = "LondonDC"
        site.slug = "london"
        locations = [{"location": "london"}]
        result = view.match_site_with_location(site, locations)
        assert result is not None

    def test_match_site_not_found(self):
        from netbox_librenms_plugin.views.sync.locations import SyncSiteLocationView

        view = object.__new__(SyncSiteLocationView)
        site = MagicMock()
        site.name = "Tokyo"
        site.slug = "tokyo"
        locations = [{"location": "London"}]
        result = view.match_site_with_location(site, locations)
        assert result is None

    def test_check_coordinates_match_true(self):
        from netbox_librenms_plugin.views.sync.locations import SyncSiteLocationView

        view = object.__new__(SyncSiteLocationView)
        assert view.check_coordinates_match(51.5, -0.12, "51.5", "-0.12") is True

    def test_check_coordinates_match_false(self):
        from netbox_librenms_plugin.views.sync.locations import SyncSiteLocationView

        view = object.__new__(SyncSiteLocationView)
        assert view.check_coordinates_match(51.5, -0.12, "52.0", "-0.12") is False

    def test_check_coordinates_none_returns_false(self):
        from netbox_librenms_plugin.views.sync.locations import SyncSiteLocationView

        view = object.__new__(SyncSiteLocationView)
        assert view.check_coordinates_match(None, -0.12, "51.5", "-0.12") is False

    def test_build_location_data_with_name(self):
        from netbox_librenms_plugin.views.sync.locations import SyncSiteLocationView

        view = object.__new__(SyncSiteLocationView)
        site = MagicMock()
        site.name = "London"
        site.latitude = "51.5"
        site.longitude = "-0.12"
        data = view.build_location_data(site)
        assert data["location"] == "London"
        assert data["lat"] == "51.5"

    def test_build_location_data_without_name(self):
        from netbox_librenms_plugin.views.sync.locations import SyncSiteLocationView

        view = object.__new__(SyncSiteLocationView)
        site = MagicMock()
        site.name = "London"
        site.latitude = "51.5"
        site.longitude = "-0.12"
        data = view.build_location_data(site, include_name=False)
        assert "location" not in data

    def test_get_site_by_pk_returns_none_on_not_found(self):
        from django.core.exceptions import ObjectDoesNotExist
        from netbox_librenms_plugin.views.sync.locations import SyncSiteLocationView

        view = object.__new__(SyncSiteLocationView)

        with patch("netbox_librenms_plugin.views.sync.locations.Site") as mock_site_cls:
            mock_site_cls.objects.get.side_effect = ObjectDoesNotExist()
            result = view.get_site_by_pk(99)
        assert result is None

    def test_get_queryset_api_failure_returns_empty(self):
        from netbox_librenms_plugin.views.sync.locations import SyncSiteLocationView

        view = object.__new__(SyncSiteLocationView)
        view.request = _make_request()
        view.filterset = None
        mock_api = MagicMock()
        mock_api.get_locations.return_value = (False, "Error")

        with (
            patch("netbox_librenms_plugin.views.sync.locations.Site") as mock_site_cls,
            patch.object(type(view), "librenms_api", new_callable=lambda: property(lambda s: mock_api)),
        ):
            mock_site_cls.objects.all.return_value = []
            result = view.get_queryset()
        assert result == []

    def test_create_sync_data_no_match(self):
        from netbox_librenms_plugin.views.sync.locations import SyncSiteLocationView

        view = object.__new__(SyncSiteLocationView)
        site = MagicMock()
        site.name = "Atlantis"
        site.slug = "atlantis"
        locations = [{"location": "London"}]
        sync_data = view.create_sync_data(site, locations)
        assert sync_data.is_synced is False
        assert sync_data.librenms_location is None

    def test_create_sync_data_with_match(self):
        from netbox_librenms_plugin.views.sync.locations import SyncSiteLocationView

        view = object.__new__(SyncSiteLocationView)
        site = MagicMock()
        site.name = "London"
        site.slug = "london"
        site.latitude = 51.5
        site.longitude = -0.12
        locations = [{"location": "London", "lat": "51.5", "lng": "-0.12"}]
        sync_data = view.create_sync_data(site, locations)
        assert sync_data.is_synced is True


class TestSyncSiteLocationViewSuperMethods:
    """Lines 26-28, 32-35: get_table and get_context_data (call super())."""

    def test_get_table_configures_table(self):
        from netbox_librenms_plugin.views.sync.locations import SyncSiteLocationView

        view = object.__new__(SyncSiteLocationView)
        mock_table = MagicMock()
        mock_request = MagicMock()
        view.request = mock_request

        with patch("netbox_librenms_plugin.views.sync.locations.SingleTableView.get_table", return_value=mock_table):
            result = view.get_table()

        mock_table.configure.assert_called_once_with(mock_request)
        assert result is mock_table

    def test_get_context_data_adds_filter_form(self):
        from netbox_librenms_plugin.views.sync.locations import SyncSiteLocationView

        view = object.__new__(SyncSiteLocationView)
        mock_queryset = MagicMock()
        mock_filterset_cls = MagicMock()
        mock_filterset_instance = MagicMock()
        mock_filterset_cls.return_value = mock_filterset_instance
        view.filterset = mock_filterset_cls
        view.request = MagicMock()
        view.request.GET = {}
        view.get_queryset = MagicMock(return_value=mock_queryset)

        parent_ctx = {"some_key": "some_value"}
        with patch(
            "netbox_librenms_plugin.views.sync.locations.SingleTableView.get_context_data", return_value=parent_ctx
        ):
            result = view.get_context_data()

        assert "filter_form" in result
        assert result["filter_form"] is mock_filterset_instance.form


class TestSyncSiteLocationViewGetQuerysetFilterset:
    """Lines 44-49: filterset branch in get_queryset."""

    def test_get_queryset_with_filterset_and_get_params(self):
        from netbox_librenms_plugin.views.sync.locations import SyncSiteLocationView

        view = object.__new__(SyncSiteLocationView)
        mock_api = MagicMock(server_key="default")
        mock_request = MagicMock()
        mock_request.GET = {"name": "London"}
        view.request = mock_request
        view.filterset = MagicMock()
        view._post_server_key = "default"
        view.get_cache_key = MagicMock(return_value="k")
        view.get_librenms_locations = MagicMock(return_value=(True, [{"location": "London"}]))
        view.create_sync_data = MagicMock(side_effect=lambda s, _locs: s)

        mock_site = MagicMock()
        mock_site.name = "London"

        mock_qs = MagicMock()
        view.filterset.return_value.qs = mock_qs

        with (
            patch("netbox_librenms_plugin.views.sync.locations.Site") as mock_site_cls,
            patch.object(type(view), "librenms_api", new_callable=lambda: property(lambda s: mock_api)),
        ):
            mock_site_cls.objects.all.return_value = [mock_site]
            result = view.get_queryset()

        assert result is mock_qs

    def test_get_queryset_no_get_params_returns_list(self):
        from netbox_librenms_plugin.views.sync.locations import SyncSiteLocationView

        view = object.__new__(SyncSiteLocationView)
        mock_api = MagicMock(server_key="default")
        mock_request = MagicMock()
        mock_request.GET = {}  # Empty GET → falls through to return sync_data (line 49)
        view.request = mock_request
        view.filterset = None  # Falsy → line 47 skipped
        view.get_librenms_locations = MagicMock(return_value=(True, [{"location": "London"}]))
        mock_site = MagicMock()
        mock_site.name = "London"
        view.create_sync_data = MagicMock(side_effect=lambda s, _locs: s)

        with (
            patch("netbox_librenms_plugin.views.sync.locations.Site") as mock_site_cls,
            patch.object(type(view), "librenms_api", new_callable=lambda: property(lambda s: mock_api)),
        ):
            mock_site_cls.objects.all.return_value = [mock_site]
            result = view.get_queryset()

        assert result == [mock_site]
