"""Coverage tests for views/sync/device_fields.py (target >95%)."""

from unittest.mock import MagicMock, patch


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


def _make_view(ViewClass):
    """Create a view instance bypassing __init__, with a mock LibreNMS API."""
    view = object.__new__(ViewClass)
    view._librenms_api = MagicMock()
    view._librenms_api.server_key = "default"
    view.require_all_permissions = MagicMock(return_value=None)
    return view


def _make_request(post_data=None):
    req = MagicMock()
    req.POST = post_data or {}
    return req


# ---------------------------------------------------------------------------
# UpdateDeviceNameView
# ---------------------------------------------------------------------------


class TestUpdateDeviceNameView:
    def _view(self):
        from netbox_librenms_plugin.views.sync.device_fields import UpdateDeviceNameView

        return _make_view(UpdateDeviceNameView)

    def test_permission_denied_returns_error(self):
        view = self._view()
        error_response = MagicMock()
        view.require_all_permissions = MagicMock(return_value=error_response)

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

        assert result is error_response
        mock_get.assert_not_called()

    def test_no_librenms_id_returns_error(self):
        view = self._view()
        view._librenms_api.get_librenms_id.return_value = None

        mock_device = MagicMock()
        with (
            patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=mock_device),
            patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg,
            patch("netbox_librenms_plugin.views.sync.device_fields.redirect") as mock_redir,
        ):
            view.post(_make_request(), pk=1)

        mock_msg.error.assert_called_once()
        mock_redir.assert_called_once()

    def test_get_device_info_failure(self):
        view = self._view()
        view._librenms_api.get_librenms_id.return_value = 42
        view._librenms_api.get_device_info.return_value = (False, None)

        mock_device = MagicMock()
        with (
            patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=mock_device),
            patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg,
            patch("netbox_librenms_plugin.views.sync.device_fields.redirect"),
        ):
            view.post(_make_request(), pk=1)

        mock_msg.error.assert_called_once()

    def test_get_device_info_empty_dict(self):
        """An empty (falsy) device_info dict triggers the 'Failed to retrieve' error path."""
        view = self._view()
        view._librenms_api.get_librenms_id.return_value = 42
        view._librenms_api.get_device_info.return_value = (True, {})

        mock_device = MagicMock()
        with (
            patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=mock_device),
            patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg,
            patch("netbox_librenms_plugin.views.sync.device_fields.redirect"),
        ):
            view.post(_make_request(), pk=1)

        # empty dict is falsy → triggers "Failed to retrieve device info" error
        mock_msg.error.assert_called_once()

    def test_no_sysname_returns_warning(self):
        view = self._view()
        view._librenms_api.get_librenms_id.return_value = 42
        view._librenms_api.get_device_info.return_value = (True, {"sysName": None})

        mock_device = MagicMock()
        with (
            patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=mock_device),
            patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg,
            patch("netbox_librenms_plugin.views.sync.device_fields.redirect"),
        ):
            view.post(_make_request(), pk=1)

        mock_msg.warning.assert_called_once()

    def test_save_success(self):
        view = self._view()
        view._librenms_api.get_librenms_id.return_value = 42
        view._librenms_api.get_device_info.return_value = (True, {"sysName": "router1"})

        mock_device = MagicMock()
        mock_device.name = "old-name"
        mock_device.virtual_chassis = None
        with (
            patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=mock_device),
            patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg,
            patch("netbox_librenms_plugin.views.sync.device_fields.redirect") as mock_redir,
        ):
            view.post(_make_request(), pk=1)

        mock_device.full_clean.assert_called_once()
        mock_device.save.assert_called_once()
        assert mock_device.name == "router1"
        mock_msg.success.assert_called_once()
        mock_redir.assert_called_once()

    def test_save_validation_error_with_message_dict(self):
        from django.core.exceptions import ValidationError

        view = self._view()
        view._librenms_api.get_librenms_id.return_value = 42
        view._librenms_api.get_device_info.return_value = (True, {"sysName": "router1"})

        mock_device = MagicMock()
        mock_device.name = "old-name"
        mock_device.virtual_chassis = None
        exc = ValidationError({"name": ["duplicate"]})
        mock_device.full_clean.side_effect = exc

        with (
            patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=mock_device),
            patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg,
            patch("netbox_librenms_plugin.views.sync.device_fields.redirect"),
        ):
            view.post(_make_request(), pk=1)

        mock_msg.error.assert_called_once()
        # Name should be restored
        assert mock_device.name == "old-name"

    def test_save_integrity_error_without_message_dict(self):
        from django.db import IntegrityError

        view = self._view()
        view._librenms_api.get_librenms_id.return_value = 42
        view._librenms_api.get_device_info.return_value = (True, {"sysName": "router1"})

        mock_device = MagicMock()
        mock_device.name = "old-name"
        mock_device.virtual_chassis = None
        mock_device.full_clean.side_effect = IntegrityError("duplicate key")

        with (
            patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=mock_device),
            patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg,
            patch("netbox_librenms_plugin.views.sync.device_fields.redirect"),
        ):
            view.post(_make_request(), pk=1)

        mock_msg.error.assert_called_once()


# ---------------------------------------------------------------------------
# UpdateDeviceSerialView
# ---------------------------------------------------------------------------


class TestUpdateDeviceSerialView:
    def _view(self):
        from netbox_librenms_plugin.views.sync.device_fields import UpdateDeviceSerialView

        return _make_view(UpdateDeviceSerialView)

    def test_permission_denied(self):
        view = self._view()
        err = MagicMock()
        view.require_all_permissions = MagicMock(return_value=err)

        with patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404") as mock_get:
            result = view.post(_make_request(), pk=1)
        assert result is err
        mock_get.assert_not_called()

    def test_no_librenms_id(self):
        view = self._view()
        view._librenms_api.get_librenms_id.return_value = None

        with (
            patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=MagicMock()),
            patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg,
            patch("netbox_librenms_plugin.views.sync.device_fields.redirect"),
        ):
            view.post(_make_request(), pk=1)
        mock_msg.error.assert_called_once()

    def test_get_device_info_failure(self):
        view = self._view()
        view._librenms_api.get_librenms_id.return_value = 5
        view._librenms_api.get_device_info.return_value = (False, None)

        with (
            patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=MagicMock()),
            patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg,
            patch("netbox_librenms_plugin.views.sync.device_fields.redirect"),
        ):
            view.post(_make_request(), pk=1)
        mock_msg.error.assert_called_once()

    def test_serial_is_none(self):
        view = self._view()
        view._librenms_api.get_librenms_id.return_value = 5
        view._librenms_api.get_device_info.return_value = (True, {"serial": None})

        with (
            patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=MagicMock()),
            patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg,
            patch("netbox_librenms_plugin.views.sync.device_fields.redirect"),
        ):
            view.post(_make_request(), pk=1)
        mock_msg.warning.assert_called_once()

    def test_serial_is_dash(self):
        view = self._view()
        view._librenms_api.get_librenms_id.return_value = 5
        view._librenms_api.get_device_info.return_value = (True, {"serial": "-"})

        with (
            patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=MagicMock()),
            patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg,
            patch("netbox_librenms_plugin.views.sync.device_fields.redirect"),
        ):
            view.post(_make_request(), pk=1)
        mock_msg.warning.assert_called_once()

    def test_save_success_with_old_serial(self):
        view = self._view()
        view._librenms_api.get_librenms_id.return_value = 5
        view._librenms_api.get_device_info.return_value = (True, {"serial": "SN001"})

        mock_device = MagicMock()
        mock_device.serial = "OLDSERIAL"
        with (
            patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=mock_device),
            patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg,
            patch("netbox_librenms_plugin.views.sync.device_fields.redirect"),
        ):
            view.post(_make_request(), pk=1)
        mock_msg.success.assert_called_once()
        assert "OLDSERIAL" in mock_msg.success.call_args[0][1]
        assert mock_device.serial == "SN001"
        mock_device.full_clean.assert_called_once()
        mock_device.save.assert_called_once()

    def test_save_success_no_old_serial(self):
        view = self._view()
        view._librenms_api.get_librenms_id.return_value = 5
        view._librenms_api.get_device_info.return_value = (True, {"serial": "SN001"})

        mock_device = MagicMock()
        mock_device.serial = ""  # No old serial
        with (
            patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=mock_device),
            patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg,
            patch("netbox_librenms_plugin.views.sync.device_fields.redirect"),
        ):
            view.post(_make_request(), pk=1)
        mock_msg.success.assert_called_once()
        assert "set to" in mock_msg.success.call_args[0][1]
        assert mock_device.serial == "SN001"
        mock_device.full_clean.assert_called_once()
        mock_device.save.assert_called_once()

    def test_save_validation_error_with_message_dict(self):
        from django.core.exceptions import ValidationError

        view = self._view()
        view._librenms_api.get_librenms_id.return_value = 5
        view._librenms_api.get_device_info.return_value = (True, {"serial": "SN001"})

        mock_device = MagicMock()
        mock_device.serial = "OLD"
        mock_device.full_clean.side_effect = ValidationError({"serial": ["err"]})
        with (
            patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=mock_device),
            patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg,
            patch("netbox_librenms_plugin.views.sync.device_fields.redirect"),
        ):
            view.post(_make_request(), pk=1)
        mock_msg.error.assert_called_once()
        assert mock_device.serial == "OLD"

    def test_save_integrity_error(self):
        from django.db import IntegrityError

        view = self._view()
        view._librenms_api.get_librenms_id.return_value = 5
        view._librenms_api.get_device_info.return_value = (True, {"serial": "SN001"})

        mock_device = MagicMock()
        mock_device.serial = "OLD"
        mock_device.full_clean.side_effect = IntegrityError("dup")
        with (
            patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=mock_device),
            patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg,
            patch("netbox_librenms_plugin.views.sync.device_fields.redirect"),
        ):
            view.post(_make_request(), pk=1)
        mock_msg.error.assert_called_once()


# ---------------------------------------------------------------------------
# UpdateDeviceTypeView
# ---------------------------------------------------------------------------


class TestUpdateDeviceTypeView:
    def _view(self):
        from netbox_librenms_plugin.views.sync.device_fields import UpdateDeviceTypeView

        return _make_view(UpdateDeviceTypeView)

    def test_permission_denied(self):
        view = self._view()
        err = MagicMock()
        view.require_all_permissions = MagicMock(return_value=err)
        with patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404") as mock_get:
            result = view.post(_make_request(), pk=1)
        assert result is err
        mock_get.assert_not_called()

    def test_no_librenms_id(self):
        view = self._view()
        view._librenms_api.get_librenms_id.return_value = None
        with (
            patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=MagicMock()),
            patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg,
            patch("netbox_librenms_plugin.views.sync.device_fields.redirect"),
        ):
            view.post(_make_request(), pk=1)
        mock_msg.error.assert_called_once()

    def test_get_device_info_failure(self):
        view = self._view()
        view._librenms_api.get_librenms_id.return_value = 7
        view._librenms_api.get_device_info.return_value = (False, None)
        with (
            patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=MagicMock()),
            patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg,
            patch("netbox_librenms_plugin.views.sync.device_fields.redirect"),
        ):
            view.post(_make_request(), pk=1)
        mock_msg.error.assert_called_once()

    def test_no_hardware(self):
        view = self._view()
        view._librenms_api.get_librenms_id.return_value = 7
        view._librenms_api.get_device_info.return_value = (True, {"hardware": None})
        with (
            patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=MagicMock()),
            patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg,
            patch("netbox_librenms_plugin.views.sync.device_fields.redirect"),
        ):
            view.post(_make_request(), pk=1)
        mock_msg.warning.assert_called_once()

    def test_no_match_result(self):
        view = self._view()
        view._librenms_api.get_librenms_id.return_value = 7
        view._librenms_api.get_device_info.return_value = (True, {"hardware": "Cisco 3750"})
        with (
            patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=MagicMock()),
            patch(
                "netbox_librenms_plugin.views.sync.device_fields.match_librenms_hardware_to_device_type",
                return_value={"matched": False},
            ),
            patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg,
            patch("netbox_librenms_plugin.views.sync.device_fields.redirect"),
        ):
            view.post(_make_request(), pk=1)
        mock_msg.error.assert_called_once()

    def test_save_success(self):
        view = self._view()
        view._librenms_api.get_librenms_id.return_value = 7
        view._librenms_api.get_device_info.return_value = (True, {"hardware": "Cisco 3750"})
        mock_dt = MagicMock()
        mock_device = MagicMock()
        with (
            patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=mock_device),
            patch(
                "netbox_librenms_plugin.views.sync.device_fields.match_librenms_hardware_to_device_type",
                return_value={"matched": True, "device_type": mock_dt},
            ),
            patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg,
            patch("netbox_librenms_plugin.views.sync.device_fields.redirect"),
        ):
            view.post(_make_request(), pk=1)
        mock_device.full_clean.assert_called_once()
        mock_device.save.assert_called_once()
        assert mock_device.device_type is mock_dt
        mock_msg.success.assert_called_once()

    def test_save_validation_error_with_message_dict(self):
        from django.core.exceptions import ValidationError

        view = self._view()
        view._librenms_api.get_librenms_id.return_value = 7
        view._librenms_api.get_device_info.return_value = (True, {"hardware": "Cisco 3750"})
        mock_dt = MagicMock()
        mock_device = MagicMock()
        mock_device.full_clean.side_effect = ValidationError({"device_type": ["err"]})
        with (
            patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=mock_device),
            patch(
                "netbox_librenms_plugin.views.sync.device_fields.match_librenms_hardware_to_device_type",
                return_value={"matched": True, "device_type": mock_dt},
            ),
            patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg,
            patch("netbox_librenms_plugin.views.sync.device_fields.redirect"),
        ):
            view.post(_make_request(), pk=1)
        mock_msg.error.assert_called_once()

    def test_save_integrity_error(self):
        from django.db import IntegrityError

        view = self._view()
        view._librenms_api.get_librenms_id.return_value = 7
        view._librenms_api.get_device_info.return_value = (True, {"hardware": "Cisco 3750"})
        mock_dt = MagicMock()
        mock_device = MagicMock()
        mock_device.full_clean.side_effect = IntegrityError("dup")
        with (
            patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=mock_device),
            patch(
                "netbox_librenms_plugin.views.sync.device_fields.match_librenms_hardware_to_device_type",
                return_value={"matched": True, "device_type": mock_dt},
            ),
            patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg,
            patch("netbox_librenms_plugin.views.sync.device_fields.redirect"),
        ):
            view.post(_make_request(), pk=1)
        mock_msg.error.assert_called_once()


# ---------------------------------------------------------------------------
# UpdateDevicePlatformView
# ---------------------------------------------------------------------------


class TestUpdateDevicePlatformView:
    def _view(self):
        from netbox_librenms_plugin.views.sync.device_fields import UpdateDevicePlatformView

        return _make_view(UpdateDevicePlatformView)

    def test_permission_denied(self):
        view = self._view()
        err = MagicMock()
        view.require_all_permissions = MagicMock(return_value=err)
        with patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404"):
            result = view.post(_make_request(), pk=1)
        assert result is err

    def test_no_librenms_id(self):
        view = self._view()
        view._librenms_api.get_librenms_id.return_value = None
        with (
            patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=MagicMock()),
            patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg,
            patch("netbox_librenms_plugin.views.sync.device_fields.redirect"),
        ):
            view.post(_make_request(), pk=1)
        mock_msg.error.assert_called_once()

    def test_get_device_info_failure(self):
        view = self._view()
        view._librenms_api.get_librenms_id.return_value = 3
        view._librenms_api.get_device_info.return_value = (False, None)
        with (
            patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=MagicMock()),
            patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg,
            patch("netbox_librenms_plugin.views.sync.device_fields.redirect"),
        ):
            view.post(_make_request(), pk=1)
        mock_msg.error.assert_called_once()

    def test_no_os(self):
        view = self._view()
        view._librenms_api.get_librenms_id.return_value = 3
        view._librenms_api.get_device_info.return_value = (True, {"os": None})
        with (
            patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=MagicMock()),
            patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg,
            patch("netbox_librenms_plugin.views.sync.device_fields.redirect"),
        ):
            view.post(_make_request(), pk=1)
        mock_msg.warning.assert_called_once()

    def test_platform_does_not_exist(self):

        view = self._view()
        view._librenms_api.get_librenms_id.return_value = 3
        view._librenms_api.get_device_info.return_value = (True, {"os": "ios"})

        mock_platform_cls = MagicMock()
        mock_platform_cls.DoesNotExist = type("DoesNotExist", (Exception,), {})
        mock_platform_cls.objects.get.side_effect = mock_platform_cls.DoesNotExist()

        with (
            patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=MagicMock()),
            patch("netbox_librenms_plugin.views.sync.device_fields.Platform", mock_platform_cls),
            patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg,
            patch("netbox_librenms_plugin.views.sync.device_fields.redirect"),
        ):
            view.post(_make_request(), pk=1)
        mock_msg.error.assert_called_once()

    def test_save_success_with_old_platform(self):
        view = self._view()
        view._librenms_api.get_librenms_id.return_value = 3
        view._librenms_api.get_device_info.return_value = (True, {"os": "ios"})

        mock_platform = MagicMock()
        mock_platform_cls = MagicMock()
        mock_platform_cls.DoesNotExist = type("DoesNotExist", (Exception,), {})
        mock_platform_cls.objects.get.return_value = mock_platform

        mock_device = MagicMock()
        mock_device.platform = MagicMock()  # old platform exists

        with (
            patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=mock_device),
            patch("netbox_librenms_plugin.views.sync.device_fields.Platform", mock_platform_cls),
            patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg,
            patch("netbox_librenms_plugin.views.sync.device_fields.redirect"),
        ):
            view.post(_make_request(), pk=1)
        mock_msg.success.assert_called_once()
        assert "updated from" in mock_msg.success.call_args[0][1]
        assert mock_device.platform is mock_platform
        mock_device.full_clean.assert_called_once()
        mock_device.save.assert_called_once()

    def test_save_success_no_old_platform(self):
        view = self._view()
        view._librenms_api.get_librenms_id.return_value = 3
        view._librenms_api.get_device_info.return_value = (True, {"os": "ios"})

        mock_platform = MagicMock()
        mock_platform_cls = MagicMock()
        mock_platform_cls.DoesNotExist = type("DoesNotExist", (Exception,), {})
        mock_platform_cls.objects.get.return_value = mock_platform

        mock_device = MagicMock()
        mock_device.platform = None  # no old platform

        with (
            patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=mock_device),
            patch("netbox_librenms_plugin.views.sync.device_fields.Platform", mock_platform_cls),
            patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg,
            patch("netbox_librenms_plugin.views.sync.device_fields.redirect"),
        ):
            view.post(_make_request(), pk=1)
        mock_msg.success.assert_called_once()
        assert "set to" in mock_msg.success.call_args[0][1]
        assert mock_device.platform is mock_platform
        mock_device.full_clean.assert_called_once()
        mock_device.save.assert_called_once()

    def test_save_validation_error(self):
        from django.core.exceptions import ValidationError

        view = self._view()
        view._librenms_api.get_librenms_id.return_value = 3
        view._librenms_api.get_device_info.return_value = (True, {"os": "ios"})

        mock_platform = MagicMock()
        mock_platform_cls = MagicMock()
        mock_platform_cls.DoesNotExist = type("DoesNotExist", (Exception,), {})
        mock_platform_cls.objects.get.return_value = mock_platform

        mock_device = MagicMock()
        mock_device.platform = None
        mock_device.full_clean.side_effect = ValidationError({"platform": ["err"]})

        with (
            patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=mock_device),
            patch("netbox_librenms_plugin.views.sync.device_fields.Platform", mock_platform_cls),
            patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg,
            patch("netbox_librenms_plugin.views.sync.device_fields.redirect"),
        ):
            view.post(_make_request(), pk=1)
        mock_msg.error.assert_called_once()


# ---------------------------------------------------------------------------
# CreateAndAssignPlatformView
# ---------------------------------------------------------------------------


class TestCreateAndAssignPlatformView:
    def _view(self):
        from netbox_librenms_plugin.views.sync.device_fields import CreateAndAssignPlatformView

        return _make_view(CreateAndAssignPlatformView)

    def test_permission_denied(self):
        view = self._view()
        err = MagicMock()
        view.require_all_permissions = MagicMock(return_value=err)
        with patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404"):
            result = view.post(_make_request(), pk=1)
        assert result is err

    def test_no_platform_name(self):
        view = self._view()
        req = _make_request({"platform_name": ""})

        with (
            patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=MagicMock()),
            patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg,
            patch("netbox_librenms_plugin.views.sync.device_fields.redirect"),
        ):
            view.post(req, pk=1)
        mock_msg.error.assert_called_once()
        assert "required" in mock_msg.error.call_args[0][1].lower()

    def test_platform_already_exists(self):
        view = self._view()
        req = _make_request({"platform_name": "ios", "manufacturer": ""})

        mock_platform_cls = MagicMock()
        mock_platform_cls.objects.filter.return_value.exists.return_value = True

        with (
            patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=MagicMock()),
            patch("netbox_librenms_plugin.views.sync.device_fields.Platform", mock_platform_cls),
            patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg,
            patch("netbox_librenms_plugin.views.sync.device_fields.redirect"),
        ):
            view.post(req, pk=1)
        mock_msg.warning.assert_called_once()

    def test_manufacturer_not_found(self):
        """manufacturer_id provided but Manufacturer.DoesNotExist: manufacturer stays None."""
        view = self._view()
        req = _make_request({"platform_name": "ios", "manufacturer": "99"})

        mock_platform_cls = MagicMock()
        mock_platform_cls.objects.filter.return_value.exists.return_value = False
        mock_platform_instance = MagicMock()
        mock_platform_cls.return_value = mock_platform_instance

        mock_manuf_cls = MagicMock()
        mock_manuf_cls.DoesNotExist = type("DoesNotExist", (Exception,), {})
        mock_manuf_cls.objects.get.side_effect = mock_manuf_cls.DoesNotExist()

        mock_device_cls = MagicMock()
        mock_device_cls.DoesNotExist = type("DoesNotExist", (Exception,), {})
        mock_locked = MagicMock()
        mock_device_cls.objects.select_for_update.return_value.get.return_value = mock_locked

        with (
            patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=MagicMock()),
            patch("netbox_librenms_plugin.views.sync.device_fields.Platform", mock_platform_cls),
            patch("netbox_librenms_plugin.views.sync.device_fields.Manufacturer", mock_manuf_cls),
            patch("netbox_librenms_plugin.views.sync.device_fields.Device", mock_device_cls),
            patch("netbox_librenms_plugin.views.sync.device_fields.transaction"),
            patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg,
            patch("netbox_librenms_plugin.views.sync.device_fields.redirect"),
        ):
            view.post(req, pk=1)
        # Should succeed (manufacturer silently ignored)
        mock_msg.success.assert_called_once()
        assert mock_locked.platform == mock_platform_instance
        mock_locked.save.assert_called_once()

    def test_success_no_manufacturer(self):
        view = self._view()
        req = _make_request({"platform_name": "ios", "manufacturer": ""})

        mock_platform_cls = MagicMock()
        mock_platform_cls.objects.filter.return_value.exists.return_value = False
        mock_platform_instance = MagicMock()
        mock_platform_cls.return_value = mock_platform_instance

        mock_device_cls = MagicMock()
        mock_device_cls.DoesNotExist = type("DoesNotExist", (Exception,), {})
        mock_locked = MagicMock()
        mock_device_cls.objects.select_for_update.return_value.get.return_value = mock_locked

        with (
            patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=MagicMock()),
            patch("netbox_librenms_plugin.views.sync.device_fields.Platform", mock_platform_cls),
            patch("netbox_librenms_plugin.views.sync.device_fields.Device", mock_device_cls),
            patch("netbox_librenms_plugin.views.sync.device_fields.transaction"),
            patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg,
            patch("netbox_librenms_plugin.views.sync.device_fields.redirect"),
        ):
            view.post(req, pk=1)
        mock_msg.success.assert_called_once()
        assert mock_locked.platform == mock_platform_instance
        mock_locked.save.assert_called_once()

    def test_platform_validation_error(self):
        from django.core.exceptions import ValidationError

        view = self._view()
        req = _make_request({"platform_name": "ios", "manufacturer": ""})

        mock_platform_cls = MagicMock()
        mock_platform_cls.objects.filter.return_value.exists.return_value = False
        mock_platform_instance = MagicMock()
        mock_platform_instance.full_clean.side_effect = ValidationError({"name": ["err"]})
        mock_platform_cls.return_value = mock_platform_instance

        mock_txn = MagicMock()
        mock_txn.set_rollback = MagicMock()

        with (
            patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=MagicMock()),
            patch("netbox_librenms_plugin.views.sync.device_fields.Platform", mock_platform_cls),
            patch("netbox_librenms_plugin.views.sync.device_fields.transaction", mock_txn),
            patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg,
            patch("netbox_librenms_plugin.views.sync.device_fields.redirect"),
        ):
            view.post(req, pk=1)
        mock_msg.error.assert_called_once()
        mock_txn.set_rollback.assert_called_once_with(True)

    def test_device_does_not_exist_inside_transaction(self):
        view = self._view()
        req = _make_request({"platform_name": "ios", "manufacturer": ""})

        mock_platform_cls = MagicMock()
        mock_platform_cls.objects.filter.return_value.exists.return_value = False
        mock_platform_instance = MagicMock()
        mock_platform_cls.return_value = mock_platform_instance

        DoesNotExist = type("DoesNotExist", (Exception,), {})
        mock_device_cls = MagicMock()
        mock_device_cls.DoesNotExist = DoesNotExist
        mock_device_cls.objects.select_for_update.return_value.get.side_effect = DoesNotExist()

        mock_txn = MagicMock()
        mock_txn.set_rollback = MagicMock()

        with (
            patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=MagicMock()),
            patch("netbox_librenms_plugin.views.sync.device_fields.Platform", mock_platform_cls),
            patch("netbox_librenms_plugin.views.sync.device_fields.Device", mock_device_cls),
            patch("netbox_librenms_plugin.views.sync.device_fields.transaction", mock_txn),
            patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg,
            patch("netbox_librenms_plugin.views.sync.device_fields.redirect"),
        ):
            view.post(req, pk=1)
        mock_msg.error.assert_called_once()
        mock_txn.set_rollback.assert_called_once_with(True)

    def test_device_validation_error(self):
        from django.core.exceptions import ValidationError

        view = self._view()
        req = _make_request({"platform_name": "ios", "manufacturer": ""})

        mock_platform_cls = MagicMock()
        mock_platform_cls.objects.filter.return_value.exists.return_value = False
        mock_platform_instance = MagicMock()
        mock_platform_cls.return_value = mock_platform_instance

        DoesNotExist = type("DoesNotExist", (Exception,), {})
        mock_locked = MagicMock()
        mock_locked.full_clean.side_effect = ValidationError({"platform": ["err"]})

        mock_device_cls = MagicMock()
        mock_device_cls.DoesNotExist = DoesNotExist
        mock_device_cls.objects.select_for_update.return_value.get.return_value = mock_locked

        mock_txn = MagicMock()
        mock_txn.set_rollback = MagicMock()

        with (
            patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=MagicMock()),
            patch("netbox_librenms_plugin.views.sync.device_fields.Platform", mock_platform_cls),
            patch("netbox_librenms_plugin.views.sync.device_fields.Device", mock_device_cls),
            patch("netbox_librenms_plugin.views.sync.device_fields.transaction", mock_txn),
            patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg,
            patch("netbox_librenms_plugin.views.sync.device_fields.redirect"),
        ):
            view.post(req, pk=1)
        mock_msg.error.assert_called_once()
        mock_txn.set_rollback.assert_called_once_with(True)

    def test_integrity_error(self):
        from django.db import IntegrityError

        view = self._view()
        req = _make_request({"platform_name": "ios", "manufacturer": ""})

        mock_platform_cls = MagicMock()
        mock_platform_cls.objects.filter.return_value.exists.return_value = False
        mock_platform_instance = MagicMock()
        # Make save raise IntegrityError
        mock_platform_instance.save.side_effect = IntegrityError("duplicate")
        mock_platform_cls.return_value = mock_platform_instance

        # transaction.atomic().__exit__ must return False so IntegrityError propagates
        mock_atomic_cm = MagicMock()
        mock_atomic_cm.__enter__ = MagicMock(return_value=None)
        mock_atomic_cm.__exit__ = MagicMock(return_value=False)
        mock_txn = MagicMock()
        mock_txn.atomic.return_value = mock_atomic_cm
        mock_txn.set_rollback = MagicMock()

        with (
            patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=MagicMock()),
            patch("netbox_librenms_plugin.views.sync.device_fields.Platform", mock_platform_cls),
            patch("netbox_librenms_plugin.views.sync.device_fields.transaction", mock_txn),
            patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg,
            patch("netbox_librenms_plugin.views.sync.device_fields.redirect"),
        ):
            view.post(req, pk=1)
        mock_msg.error.assert_called_once()
        mock_txn.set_rollback.assert_called_once_with(True)


# ---------------------------------------------------------------------------
# AssignVCSerialView
# ---------------------------------------------------------------------------


class TestAssignVCSerialView:
    def _view(self):
        from netbox_librenms_plugin.views.sync.device_fields import AssignVCSerialView

        return _make_view(AssignVCSerialView)

    def test_permission_denied(self):
        view = self._view()
        err = MagicMock()
        view.require_all_permissions = MagicMock(return_value=err)
        with patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404"):
            result = view.post(_make_request(), pk=1)
        assert result is err

    def test_not_virtual_chassis(self):
        view = self._view()
        mock_device = MagicMock()
        mock_device.virtual_chassis = None
        with (
            patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=mock_device),
            patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg,
            patch("netbox_librenms_plugin.views.sync.device_fields.redirect"),
        ):
            view.post(_make_request(), pk=1)
        mock_msg.error.assert_called_once()

    def test_no_serial_assignments_no_errors(self):
        """Loop doesn't execute — no serial_N keys in POST."""
        view = self._view()
        mock_device = MagicMock()
        mock_device.virtual_chassis = MagicMock()
        with (
            patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=mock_device),
            patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg,
            patch("netbox_librenms_plugin.views.sync.device_fields.redirect"),
        ):
            view.post(_make_request({}), pk=1)
        mock_msg.info.assert_called_once()

    def test_member_id_missing(self):
        """member_id_{N} key is absent → counter incremented, no assignment."""
        view = self._view()
        mock_device = MagicMock()
        mock_device.virtual_chassis = MagicMock()
        # serial_1 exists but member_id_1 is empty
        req = _make_request({"serial_1": "SN100", "member_id_1": ""})
        with (
            patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=mock_device),
            patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg,
            patch("netbox_librenms_plugin.views.sync.device_fields.redirect"),
        ):
            view.post(req, pk=1)
        mock_msg.info.assert_called_once()

    def test_member_not_found(self):
        view = self._view()
        mock_device = MagicMock()
        mock_device.virtual_chassis = MagicMock(pk=10)

        DoesNotExist = type("DoesNotExist", (Exception,), {})
        mock_device_cls = MagicMock()
        mock_device_cls.DoesNotExist = DoesNotExist
        mock_device_cls.objects.get.side_effect = DoesNotExist()

        req = _make_request({"serial_1": "SN100", "member_id_1": "99"})
        with (
            patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=mock_device),
            patch("netbox_librenms_plugin.views.sync.device_fields.Device", mock_device_cls),
            patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg,
            patch("netbox_librenms_plugin.views.sync.device_fields.redirect"),
        ):
            view.post(req, pk=1)
        # Should call error for the missing device
        mock_msg.error.assert_called()

    def test_member_different_chassis(self):
        view = self._view()
        vc = MagicMock(pk=10)
        mock_device = MagicMock()
        mock_device.virtual_chassis = vc

        member = MagicMock()
        member.name = "sw-member"
        member.virtual_chassis = MagicMock(pk=99)  # different VC!

        DoesNotExist = type("DoesNotExist", (Exception,), {})
        mock_device_cls = MagicMock()
        mock_device_cls.DoesNotExist = DoesNotExist
        mock_device_cls.objects.get.return_value = member

        req = _make_request({"serial_1": "SN100", "member_id_1": "5"})
        with (
            patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=mock_device),
            patch("netbox_librenms_plugin.views.sync.device_fields.Device", mock_device_cls),
            patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg,
            patch("netbox_librenms_plugin.views.sync.device_fields.redirect"),
        ):
            view.post(req, pk=1)
        mock_msg.error.assert_called()

    def test_member_save_validation_error(self):
        from django.core.exceptions import ValidationError

        view = self._view()
        vc = MagicMock(pk=10)
        mock_device = MagicMock()
        mock_device.virtual_chassis = vc

        member = MagicMock()
        member.name = "sw-member"
        member.virtual_chassis = vc  # same VC
        member.serial = "OLD"
        member.full_clean.side_effect = ValidationError({"serial": ["err"]})

        DoesNotExist = type("DoesNotExist", (Exception,), {})
        mock_device_cls = MagicMock()
        mock_device_cls.DoesNotExist = DoesNotExist
        mock_device_cls.objects.get.return_value = member

        req = _make_request({"serial_1": "SN100", "member_id_1": "5"})
        with (
            patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=mock_device),
            patch("netbox_librenms_plugin.views.sync.device_fields.Device", mock_device_cls),
            patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg,
            patch("netbox_librenms_plugin.views.sync.device_fields.redirect"),
        ):
            view.post(req, pk=1)
        mock_msg.error.assert_called()

    def test_member_save_success(self):
        view = self._view()
        vc = MagicMock(pk=10)
        mock_device = MagicMock()
        mock_device.virtual_chassis = vc

        member = MagicMock()
        member.name = "sw-member"
        member.virtual_chassis = vc
        member.serial = "OLD"
        member.save = MagicMock()

        DoesNotExist = type("DoesNotExist", (Exception,), {})
        mock_device_cls = MagicMock()
        mock_device_cls.DoesNotExist = DoesNotExist
        mock_device_cls.objects.get.return_value = member

        req = _make_request({"serial_1": "SN100", "member_id_1": "5"})
        with (
            patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=mock_device),
            patch("netbox_librenms_plugin.views.sync.device_fields.Device", mock_device_cls),
            patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg,
            patch("netbox_librenms_plugin.views.sync.device_fields.redirect"),
        ):
            view.post(req, pk=1)
        mock_msg.success.assert_called_once()
        assert member.serial == "SN100"
        member.save.assert_called_once()

    def test_assignments_and_errors_both_reported(self):
        """One success + one error → both messages emitted."""
        from django.core.exceptions import ValidationError

        view = self._view()
        vc = MagicMock(pk=10)
        mock_device = MagicMock()
        mock_device.virtual_chassis = vc

        good_member = MagicMock()
        good_member.name = "sw1"
        good_member.virtual_chassis = vc
        good_member.serial = ""

        bad_member = MagicMock()
        bad_member.name = "sw2"
        bad_member.virtual_chassis = vc
        bad_member.serial = ""
        bad_member.full_clean.side_effect = ValidationError({"serial": ["dup"]})

        DoesNotExist = type("DoesNotExist", (Exception,), {})
        mock_device_cls = MagicMock()
        mock_device_cls.DoesNotExist = DoesNotExist
        mock_device_cls.objects.get.side_effect = [good_member, bad_member]

        req = _make_request(
            {
                "serial_1": "SN001",
                "member_id_1": "1",
                "serial_2": "SN002",
                "member_id_2": "2",
            }
        )
        with (
            patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=mock_device),
            patch("netbox_librenms_plugin.views.sync.device_fields.Device", mock_device_cls),
            patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg,
            patch("netbox_librenms_plugin.views.sync.device_fields.redirect"),
        ):
            view.post(req, pk=1)
        mock_msg.success.assert_called()
        mock_msg.error.assert_called()
        assert good_member.serial == "SN001"
        good_member.save.assert_called_once()


# ---------------------------------------------------------------------------
# RemoveServerMappingView — helper methods
# ---------------------------------------------------------------------------


class TestRemoveServerMappingViewHelpers:
    def _view(self):
        from netbox_librenms_plugin.views.sync.device_fields import RemoveServerMappingView

        view = object.__new__(RemoveServerMappingView)
        view.require_all_permissions = MagicMock(return_value=None)
        return view

    def test_get_object_device(self):
        view = self._view()
        mock_device = MagicMock()

        with patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=mock_device):
            obj, model = view._get_object("device", 1)
        assert obj is mock_device

    def test_get_object_vm(self):
        view = self._view()
        mock_vm = MagicMock()
        with patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=mock_vm):
            obj, model = view._get_object("vm", 1)
        assert obj is mock_vm

    def test_sync_url_name_device(self):
        view = self._view()
        assert view._sync_url_name("device") == "plugins:netbox_librenms_plugin:device_librenms_sync"

    def test_sync_url_name_vm(self):
        view = self._view()
        assert view._sync_url_name("vm") == "plugins:netbox_librenms_plugin:vm_librenms_sync"

    def test_normalize_bool(self):
        view = self._view()
        assert view._normalize_librenms_mapping(True) == {}
        assert view._normalize_librenms_mapping(False) == {}

    def test_normalize_int(self):
        view = self._view()
        assert view._normalize_librenms_mapping(42) == {"default": 42}

    def test_normalize_string_digit(self):
        view = self._view()
        assert view._normalize_librenms_mapping("99") == {"default": 99}

    def test_normalize_dict(self):
        view = self._view()
        d = {"server1": 10}
        assert view._normalize_librenms_mapping(d) == d

    def test_normalize_non_digit_string_returns_empty(self):
        view = self._view()
        assert view._normalize_librenms_mapping("not-a-number") == {}

    def test_normalize_none_returns_empty(self):
        view = self._view()
        assert view._normalize_librenms_mapping(None) == {}


# ---------------------------------------------------------------------------
# RemoveServerMappingView — post()
# ---------------------------------------------------------------------------


class TestRemoveServerMappingViewPost:
    def _view(self):
        from netbox_librenms_plugin.views.sync.device_fields import RemoveServerMappingView

        view = object.__new__(RemoveServerMappingView)
        view.require_all_permissions = MagicMock(return_value=None)
        return view

    def test_invalid_object_type_returns_400(self):
        view = self._view()
        req = _make_request({"object_type": "badtype"})
        result = view.post(req, pk=1)
        assert result.status_code == 400

    def test_virtualmachine_object_type_normalized_to_vm(self):
        """object_type='virtualmachine' is normalised to 'vm'."""
        view = self._view()
        mock_obj = MagicMock()
        mock_obj.custom_field_data = {"librenms_id": {"orphan": 5}}

        req = _make_request({"object_type": "virtualmachine", "server_key": "orphan"})

        DoesNotExist = type("DoesNotExist", (Exception,), {})
        mock_vm_cls = MagicMock()
        mock_vm_cls.DoesNotExist = DoesNotExist
        mock_locked = MagicMock()
        mock_locked.custom_field_data = {"librenms_id": {"orphan": 5}}
        mock_vm_cls.objects.select_for_update.return_value.get.return_value = mock_locked

        mock_cfg = {"netbox_librenms_plugin": {"servers": {}, "librenms_url": ""}}

        with (
            patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=mock_obj),
            patch("netbox_librenms_plugin.views.sync.device_fields.VirtualMachine", mock_vm_cls),
            patch("netbox_librenms_plugin.views.sync.device_fields.transaction"),
            patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg,
            patch("netbox_librenms_plugin.views.sync.device_fields.redirect"),
            patch("django.conf.settings") as mock_settings,
        ):
            mock_settings.PLUGINS_CONFIG = mock_cfg
            view.post(req, pk=1)
        mock_msg.success.assert_called_once()

    def test_permission_denied(self):
        view = self._view()
        err = MagicMock()
        view.require_all_permissions = MagicMock(return_value=err)
        req = _make_request({"object_type": "device", "server_key": "x"})
        result = view.post(req, pk=1)
        assert result is err

    def test_no_server_key(self):
        view = self._view()
        req = _make_request({"object_type": "device", "server_key": ""})

        with (
            patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=MagicMock()),
            patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg,
            patch("netbox_librenms_plugin.views.sync.device_fields.redirect"),
        ):
            view.post(req, pk=1)
        mock_msg.error.assert_called_once()

    def test_mapping_not_found_wrong_type(self):
        """cf_value is not a dict → warning."""
        view = self._view()
        mock_obj = MagicMock()
        mock_obj.custom_field_data = {"librenms_id": None}

        req = _make_request({"object_type": "device", "server_key": "default"})

        with (
            patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=mock_obj),
            patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg,
            patch("netbox_librenms_plugin.views.sync.device_fields.redirect"),
        ):
            view.post(req, pk=1)
        mock_msg.warning.assert_called_once()

    def test_mapping_not_found_missing_key(self):
        """server_key not in cf_value dict → warning."""
        view = self._view()
        mock_obj = MagicMock()
        mock_obj.custom_field_data = {"librenms_id": {"other": 5}}

        req = _make_request({"object_type": "device", "server_key": "default"})

        with (
            patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=mock_obj),
            patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg,
            patch("netbox_librenms_plugin.views.sync.device_fields.redirect"),
        ):
            view.post(req, pk=1)
        mock_msg.warning.assert_called_once()

    def test_configured_servers_non_dict_treated_as_empty(self):
        """servers config is a list (non-dict) → treated as empty dict, orphan key can be removed."""
        view = self._view()
        mock_obj = MagicMock()
        mock_obj.custom_field_data = {"librenms_id": {"orphan": 5}}

        req = _make_request({"object_type": "device", "server_key": "orphan"})

        DoesNotExist = type("DoesNotExist", (Exception,), {})
        mock_locked = MagicMock()
        mock_locked.custom_field_data = {"librenms_id": {"orphan": 5}}

        mock_device_cls = MagicMock()
        mock_device_cls.__name__ = "Device"
        mock_device_cls.DoesNotExist = DoesNotExist
        mock_device_cls.objects.select_for_update.return_value.get.return_value = mock_locked

        # servers is a list (non-dict) → line 496 normalises it to {}
        mock_cfg = {"netbox_librenms_plugin": {"servers": ["not", "a", "dict"], "librenms_url": ""}}
        with (
            patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=mock_obj),
            patch("netbox_librenms_plugin.views.sync.device_fields.Device", mock_device_cls),
            patch("netbox_librenms_plugin.views.sync.device_fields.transaction"),
            patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg,
            patch("netbox_librenms_plugin.views.sync.device_fields.redirect"),
            patch("django.conf.settings") as mock_settings,
        ):
            mock_settings.PLUGINS_CONFIG = mock_cfg
            view.post(req, pk=1)
        mock_msg.success.assert_called_once()

    def test_configured_server_key_in_servers_dict(self):
        """server_key is in configured servers → error."""
        view = self._view()
        mock_obj = MagicMock()
        mock_obj.custom_field_data = {"librenms_id": {"production": 10}}

        req = _make_request({"object_type": "device", "server_key": "production"})

        mock_cfg = {"netbox_librenms_plugin": {"servers": {"production": {}}, "librenms_url": ""}}
        with (
            patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=mock_obj),
            patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg,
            patch("netbox_librenms_plugin.views.sync.device_fields.redirect"),
            patch("django.conf.settings") as mock_settings,
        ):
            mock_settings.PLUGINS_CONFIG = mock_cfg
            view.post(req, pk=1)
        mock_msg.error.assert_called_once()
        assert "Cannot remove" in mock_msg.error.call_args[0][1]

    def test_legacy_default_server_protected(self):
        """Legacy mode with librenms_url set and server_key='default' → error."""
        view = self._view()
        mock_obj = MagicMock()
        mock_obj.custom_field_data = {"librenms_id": {"default": 7}}

        req = _make_request({"object_type": "device", "server_key": "default"})

        mock_cfg = {"netbox_librenms_plugin": {"servers": {}, "librenms_url": "https://librenms.example.com"}}
        with (
            patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=mock_obj),
            patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg,
            patch("netbox_librenms_plugin.views.sync.device_fields.redirect"),
            patch("django.conf.settings") as mock_settings,
        ):
            mock_settings.PLUGINS_CONFIG = mock_cfg
            view.post(req, pk=1)
        mock_msg.error.assert_called_once()

    def test_object_no_longer_exists_inside_transaction(self):
        view = self._view()
        mock_obj = MagicMock()
        mock_obj.custom_field_data = {"librenms_id": {"orphan": 5}}

        req = _make_request({"object_type": "device", "server_key": "orphan"})

        DoesNotExist = type("DoesNotExist", (Exception,), {})
        mock_device_cls = MagicMock()
        mock_device_cls.__name__ = "Device"
        mock_device_cls.DoesNotExist = DoesNotExist
        mock_device_cls.objects.select_for_update.return_value.get.side_effect = DoesNotExist()

        mock_cfg = {"netbox_librenms_plugin": {"servers": {}, "librenms_url": ""}}
        with (
            patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=mock_obj),
            patch("netbox_librenms_plugin.views.sync.device_fields.Device", mock_device_cls),
            patch("netbox_librenms_plugin.views.sync.device_fields.transaction"),
            patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg,
            patch("netbox_librenms_plugin.views.sync.device_fields.redirect"),
            patch("django.conf.settings") as mock_settings,
        ):
            mock_settings.PLUGINS_CONFIG = mock_cfg
            view.post(req, pk=1)
        mock_msg.error.assert_called_once()

    def test_mapping_already_removed_in_lock(self):
        """server_key is gone from the locked object's cf → warning."""
        view = self._view()
        mock_obj = MagicMock()
        mock_obj.custom_field_data = {"librenms_id": {"orphan": 5}}

        req = _make_request({"object_type": "device", "server_key": "orphan"})

        DoesNotExist = type("DoesNotExist", (Exception,), {})
        mock_locked = MagicMock()
        # Key was removed between the first read and the lock
        mock_locked.custom_field_data = {"librenms_id": {}}

        mock_device_cls = MagicMock()
        mock_device_cls.DoesNotExist = DoesNotExist
        mock_device_cls.objects.select_for_update.return_value.get.return_value = mock_locked

        mock_cfg = {"netbox_librenms_plugin": {"servers": {}, "librenms_url": ""}}
        with (
            patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=mock_obj),
            patch("netbox_librenms_plugin.views.sync.device_fields.Device", mock_device_cls),
            patch("netbox_librenms_plugin.views.sync.device_fields.transaction"),
            patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg,
            patch("netbox_librenms_plugin.views.sync.device_fields.redirect"),
            patch("django.conf.settings") as mock_settings,
        ):
            mock_settings.PLUGINS_CONFIG = mock_cfg
            view.post(req, pk=1)
        mock_msg.warning.assert_called_once()

    def test_validation_error_on_save(self):
        from django.core.exceptions import ValidationError

        view = self._view()
        mock_obj = MagicMock()
        mock_obj.custom_field_data = {"librenms_id": {"orphan": 5}}

        req = _make_request({"object_type": "device", "server_key": "orphan"})

        DoesNotExist = type("DoesNotExist", (Exception,), {})
        mock_locked = MagicMock()
        mock_locked.custom_field_data = {"librenms_id": {"orphan": 5}}
        mock_locked.full_clean.side_effect = ValidationError({"librenms_id": ["err"]})

        mock_device_cls = MagicMock()
        mock_device_cls.DoesNotExist = DoesNotExist
        mock_device_cls.objects.select_for_update.return_value.get.return_value = mock_locked

        mock_txn = MagicMock()
        mock_txn.set_rollback = MagicMock()

        mock_cfg = {"netbox_librenms_plugin": {"servers": {}, "librenms_url": ""}}
        with (
            patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=mock_obj),
            patch("netbox_librenms_plugin.views.sync.device_fields.Device", mock_device_cls),
            patch("netbox_librenms_plugin.views.sync.device_fields.transaction", mock_txn),
            patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg,
            patch("netbox_librenms_plugin.views.sync.device_fields.redirect"),
            patch("django.conf.settings") as mock_settings,
        ):
            mock_settings.PLUGINS_CONFIG = mock_cfg
            view.post(req, pk=1)
        mock_msg.error.assert_called_once()
        mock_txn.set_rollback.assert_called_once_with(True)

    def test_unexpected_error_on_save(self):
        view = self._view()
        mock_obj = MagicMock()
        mock_obj.custom_field_data = {"librenms_id": {"orphan": 5}}

        req = _make_request({"object_type": "device", "server_key": "orphan"})

        DoesNotExist = type("DoesNotExist", (Exception,), {})
        mock_locked = MagicMock()
        mock_locked.custom_field_data = {"librenms_id": {"orphan": 5}}
        mock_locked.full_clean.side_effect = RuntimeError("disk full")

        mock_device_cls = MagicMock()
        mock_device_cls.DoesNotExist = DoesNotExist
        mock_device_cls.objects.select_for_update.return_value.get.return_value = mock_locked

        mock_txn = MagicMock()
        mock_txn.set_rollback = MagicMock()

        mock_cfg = {"netbox_librenms_plugin": {"servers": {}, "librenms_url": ""}}
        with (
            patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=mock_obj),
            patch("netbox_librenms_plugin.views.sync.device_fields.Device", mock_device_cls),
            patch("netbox_librenms_plugin.views.sync.device_fields.transaction", mock_txn),
            patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg,
            patch("netbox_librenms_plugin.views.sync.device_fields.redirect"),
            patch("django.conf.settings") as mock_settings,
        ):
            mock_settings.PLUGINS_CONFIG = mock_cfg
            view.post(req, pk=1)
        mock_msg.error.assert_called_once()
        mock_txn.set_rollback.assert_called_once_with(True)

    def test_success_removes_mapping(self):
        """Happy path: mapping removed, last entry → cf set to None."""
        view = self._view()
        mock_obj = MagicMock()
        mock_obj.custom_field_data = {"librenms_id": {"orphan": 5}}

        req = _make_request({"object_type": "device", "server_key": "orphan"})

        DoesNotExist = type("DoesNotExist", (Exception,), {})
        mock_locked = MagicMock()
        mock_locked.custom_field_data = {"librenms_id": {"orphan": 5}}

        mock_device_cls = MagicMock()
        mock_device_cls.DoesNotExist = DoesNotExist
        mock_device_cls.objects.select_for_update.return_value.get.return_value = mock_locked

        mock_cfg = {"netbox_librenms_plugin": {"servers": {}, "librenms_url": ""}}
        with (
            patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=mock_obj),
            patch("netbox_librenms_plugin.views.sync.device_fields.Device", mock_device_cls),
            patch("netbox_librenms_plugin.views.sync.device_fields.transaction"),
            patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg,
            patch("netbox_librenms_plugin.views.sync.device_fields.redirect"),
            patch("django.conf.settings") as mock_settings,
        ):
            mock_settings.PLUGINS_CONFIG = mock_cfg
            view.post(req, pk=1)
        mock_msg.success.assert_called_once()
        mock_locked.full_clean.assert_called_once()
        mock_locked.save.assert_called_once()
        # After deleting the last key, cf should be set to None
        assert mock_locked.custom_field_data["librenms_id"] is None

    def test_success_keeps_remaining_mappings(self):
        """Happy path: mapping removed, other entries remain → cf retains them."""
        view = self._view()
        mock_obj = MagicMock()
        mock_obj.custom_field_data = {"librenms_id": {"orphan": 5, "other": 6}}

        req = _make_request({"object_type": "device", "server_key": "orphan"})

        DoesNotExist = type("DoesNotExist", (Exception,), {})
        mock_locked = MagicMock()
        mock_locked.custom_field_data = {"librenms_id": {"orphan": 5, "other": 6}}

        mock_device_cls = MagicMock()
        mock_device_cls.DoesNotExist = DoesNotExist
        mock_device_cls.objects.select_for_update.return_value.get.return_value = mock_locked

        mock_cfg = {"netbox_librenms_plugin": {"servers": {}, "librenms_url": ""}}
        with (
            patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=mock_obj),
            patch("netbox_librenms_plugin.views.sync.device_fields.Device", mock_device_cls),
            patch("netbox_librenms_plugin.views.sync.device_fields.transaction"),
            patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg,
            patch("netbox_librenms_plugin.views.sync.device_fields.redirect"),
            patch("django.conf.settings") as mock_settings,
        ):
            mock_settings.PLUGINS_CONFIG = mock_cfg
            view.post(req, pk=1)
        mock_msg.success.assert_called_once()
        mock_locked.full_clean.assert_called_once()
        mock_locked.save.assert_called_once()
        assert mock_locked.custom_field_data["librenms_id"] == {"other": 6}


# ---------------------------------------------------------------------------
# ConvertLegacyLibreNMSIdView — helper methods
# ---------------------------------------------------------------------------


class TestConvertLegacyLibreNMSIdViewHelpers:
    def _view(self):
        from netbox_librenms_plugin.views.sync.device_fields import ConvertLegacyLibreNMSIdView

        view = object.__new__(ConvertLegacyLibreNMSIdView)
        view._librenms_api = MagicMock()
        view._librenms_api.server_key = "default"
        view.require_all_permissions = MagicMock(return_value=None)
        return view

    def test_get_model_and_object_device(self):
        view = self._view()
        mock_device = MagicMock()
        with patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=mock_device):
            model, obj = view._get_model_and_object("device", 1)
        assert obj is mock_device

    def test_get_model_and_object_vm(self):
        view = self._view()
        mock_vm = MagicMock()
        with patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=mock_vm):
            model, obj = view._get_model_and_object("vm", 1)
        assert obj is mock_vm

    def test_sync_url_device(self):
        view = self._view()
        with patch("netbox_librenms_plugin.views.sync.device_fields.redirect") as mock_redir:
            view._sync_url("device", 1)
        mock_redir.assert_called_once_with("plugins:netbox_librenms_plugin:device_librenms_sync", pk=1)

    def test_sync_url_vm(self):
        view = self._view()
        with patch("netbox_librenms_plugin.views.sync.device_fields.redirect") as mock_redir:
            view._sync_url("vm", 1)
        mock_redir.assert_called_once_with("plugins:netbox_librenms_plugin:vm_librenms_sync", pk=1)


# ---------------------------------------------------------------------------
# ConvertLegacyLibreNMSIdView — post()
# ---------------------------------------------------------------------------


class TestConvertLegacyLibreNMSIdViewPost:
    def _view(self):
        from netbox_librenms_plugin.views.sync.device_fields import ConvertLegacyLibreNMSIdView

        view = object.__new__(ConvertLegacyLibreNMSIdView)
        view._librenms_api = MagicMock()
        view._librenms_api.server_key = "default"
        view.require_all_permissions = MagicMock(return_value=None)
        return view

    def test_invalid_object_type_returns_400(self):
        view = self._view()
        req = _make_request({"object_type": "badtype"})
        with patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404"):
            result = view.post(req, pk=1)
        assert result.status_code == 400

    def test_virtualmachine_object_type_normalised(self):
        """object_type='virtualmachine' is accepted as 'vm'."""
        view = self._view()
        # Provide a legacy string int as cf_value
        mock_obj = MagicMock()
        mock_obj.custom_field_data = {"librenms_id": "42"}
        mock_obj.serial = "SN-MATCH"

        view._librenms_api.get_device_info.return_value = (True, {"serial": "SN-MATCH"})
        view._librenms_api.server_key = "default"

        DoesNotExist = type("DoesNotExist", (Exception,), {})
        mock_locked = MagicMock()
        mock_locked.custom_field_data = {"librenms_id": "42"}
        mock_locked.serial = "SN-MATCH"

        mock_vm_cls = MagicMock()
        mock_vm_cls.DoesNotExist = DoesNotExist
        mock_vm_cls.objects.select_for_update.return_value.get.return_value = mock_locked

        with (
            patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=mock_obj),
            patch("netbox_librenms_plugin.views.sync.device_fields.VirtualMachine", mock_vm_cls),
            patch("netbox_librenms_plugin.views.sync.device_fields.find_by_librenms_id", return_value=None),
            patch(
                "netbox_librenms_plugin.views.sync.device_fields.migrate_legacy_librenms_id", return_value=True
            ) as mock_migrate,
            patch("netbox_librenms_plugin.views.sync.device_fields.transaction"),
            patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg,
            patch("netbox_librenms_plugin.views.sync.device_fields.redirect"),
        ):
            view.post(_make_request({"object_type": "virtualmachine"}), pk=1)
        mock_msg.success.assert_called_once()
        mock_migrate.assert_called_once()
        mock_locked.full_clean.assert_called_once()
        mock_locked.save.assert_called_once()

    def test_permission_denied(self):
        view = self._view()
        err = MagicMock()
        view.require_all_permissions = MagicMock(return_value=err)
        req = _make_request({"object_type": "device"})
        with patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404"):
            result = view.post(req, pk=1)
        assert result is err

    def test_already_json_format_dict(self):
        view = self._view()
        mock_obj = MagicMock()
        mock_obj.custom_field_data = {"librenms_id": {"default": 5}}

        with (
            patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=mock_obj),
            patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg,
            patch("netbox_librenms_plugin.views.sync.device_fields.redirect"),
        ):
            view.post(_make_request({"object_type": "device"}), pk=1)
        mock_msg.warning.assert_called_once()
        assert "already" in mock_msg.warning.call_args[0][1].lower()

    def test_already_json_format_bool(self):
        view = self._view()
        mock_obj = MagicMock()
        mock_obj.custom_field_data = {"librenms_id": True}

        with (
            patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=mock_obj),
            patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg,
            patch("netbox_librenms_plugin.views.sync.device_fields.redirect"),
        ):
            view.post(_make_request({"object_type": "device"}), pk=1)
        mock_msg.error.assert_called_once()

    def test_non_digit_string_cf_value(self):
        view = self._view()
        mock_obj = MagicMock()
        mock_obj.custom_field_data = {"librenms_id": "not-a-number"}

        with (
            patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=mock_obj),
            patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg,
            patch("netbox_librenms_plugin.views.sync.device_fields.redirect"),
        ):
            view.post(_make_request({"object_type": "device"}), pk=1)
        mock_msg.error.assert_called_once()

    def test_get_device_info_failure(self):
        view = self._view()
        mock_obj = MagicMock()
        mock_obj.custom_field_data = {"librenms_id": 42}
        view._librenms_api.get_device_info.return_value = (False, None)

        with (
            patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=mock_obj),
            patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg,
            patch("netbox_librenms_plugin.views.sync.device_fields.redirect"),
        ):
            view.post(_make_request({"object_type": "device"}), pk=1)
        mock_msg.error.assert_called_once()

    def test_serial_mismatch_empty_netbox_serial(self):
        view = self._view()
        mock_obj = MagicMock()
        mock_obj.custom_field_data = {"librenms_id": 42}
        mock_obj.serial = ""
        view._librenms_api.get_device_info.return_value = (True, {"serial": "SN-ABC"})

        with (
            patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=mock_obj),
            patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg,
            patch("netbox_librenms_plugin.views.sync.device_fields.redirect"),
        ):
            view.post(_make_request({"object_type": "device"}), pk=1)
        mock_msg.error.assert_called_once()
        assert "Serial" in mock_msg.error.call_args[0][1]

    def test_serial_mismatch_different(self):
        view = self._view()
        mock_obj = MagicMock()
        mock_obj.custom_field_data = {"librenms_id": 42}
        mock_obj.serial = "SN-XYZ"
        view._librenms_api.get_device_info.return_value = (True, {"serial": "SN-ABC"})

        with (
            patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=mock_obj),
            patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg,
            patch("netbox_librenms_plugin.views.sync.device_fields.redirect"),
        ):
            view.post(_make_request({"object_type": "device"}), pk=1)
        mock_msg.error.assert_called_once()

    def test_object_no_longer_exists_in_lock(self):
        view = self._view()
        mock_obj = MagicMock()
        mock_obj.custom_field_data = {"librenms_id": 42}
        mock_obj.serial = "SN-MATCH"
        view._librenms_api.get_device_info.return_value = (True, {"serial": "SN-MATCH"})

        DoesNotExist = type("DoesNotExist", (Exception,), {})
        mock_device_cls = MagicMock()
        mock_device_cls.__name__ = "Device"
        mock_device_cls.DoesNotExist = DoesNotExist
        mock_device_cls.objects.select_for_update.return_value.get.side_effect = DoesNotExist()

        with (
            patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=mock_obj),
            patch("netbox_librenms_plugin.views.sync.device_fields.Device", mock_device_cls),
            patch("netbox_librenms_plugin.views.sync.device_fields.transaction"),
            patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg,
            patch("netbox_librenms_plugin.views.sync.device_fields.redirect"),
        ):
            view.post(_make_request({"object_type": "device"}), pk=1)
        mock_msg.error.assert_called_once()

    def test_cf_value_changed_to_json_after_lock(self):
        """Locked row shows cf_value already as dict → warning."""
        view = self._view()
        mock_obj = MagicMock()
        mock_obj.custom_field_data = {"librenms_id": 42}
        mock_obj.serial = "SN-MATCH"
        view._librenms_api.get_device_info.return_value = (True, {"serial": "SN-MATCH"})

        DoesNotExist = type("DoesNotExist", (Exception,), {})
        mock_locked = MagicMock()
        mock_locked.custom_field_data = {"librenms_id": {"default": 42}}  # already dict

        mock_device_cls = MagicMock()
        mock_device_cls.DoesNotExist = DoesNotExist
        mock_device_cls.objects.select_for_update.return_value.get.return_value = mock_locked

        with (
            patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=mock_obj),
            patch("netbox_librenms_plugin.views.sync.device_fields.Device", mock_device_cls),
            patch("netbox_librenms_plugin.views.sync.device_fields.transaction"),
            patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg,
            patch("netbox_librenms_plugin.views.sync.device_fields.redirect"),
        ):
            view.post(_make_request({"object_type": "device"}), pk=1)
        mock_msg.warning.assert_called_once()

    def test_cf_value_not_int_after_lock(self):
        """Locked row shows non-digit string → error: cannot convert."""
        view = self._view()
        mock_obj = MagicMock()
        mock_obj.custom_field_data = {"librenms_id": 42}
        mock_obj.serial = "SN-MATCH"
        view._librenms_api.get_device_info.return_value = (True, {"serial": "SN-MATCH"})

        DoesNotExist = type("DoesNotExist", (Exception,), {})
        mock_locked = MagicMock()
        mock_locked.custom_field_data = {"librenms_id": "not-a-digit"}

        mock_device_cls = MagicMock()
        mock_device_cls.DoesNotExist = DoesNotExist
        mock_device_cls.objects.select_for_update.return_value.get.return_value = mock_locked

        with (
            patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=mock_obj),
            patch("netbox_librenms_plugin.views.sync.device_fields.Device", mock_device_cls),
            patch("netbox_librenms_plugin.views.sync.device_fields.transaction"),
            patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg,
            patch("netbox_librenms_plugin.views.sync.device_fields.redirect"),
        ):
            view.post(_make_request({"object_type": "device"}), pk=1)
        mock_msg.error.assert_called_once()

    def test_data_changed_before_lock(self):
        """locked_id or locked_serial differs → error: aborting."""
        view = self._view()
        mock_obj = MagicMock()
        mock_obj.custom_field_data = {"librenms_id": 42}
        mock_obj.serial = "SN-MATCH"
        view._librenms_api.get_device_info.return_value = (True, {"serial": "SN-MATCH"})

        DoesNotExist = type("DoesNotExist", (Exception,), {})
        mock_locked = MagicMock()
        mock_locked.custom_field_data = {"librenms_id": 99}  # different id
        mock_locked.serial = "SN-MATCH"

        mock_device_cls = MagicMock()
        mock_device_cls.DoesNotExist = DoesNotExist
        mock_device_cls.objects.select_for_update.return_value.get.return_value = mock_locked

        with (
            patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=mock_obj),
            patch("netbox_librenms_plugin.views.sync.device_fields.Device", mock_device_cls),
            patch("netbox_librenms_plugin.views.sync.device_fields.transaction"),
            patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg,
            patch("netbox_librenms_plugin.views.sync.device_fields.redirect"),
        ):
            view.post(_make_request({"object_type": "device"}), pk=1)
        mock_msg.error.assert_called_once()

    def test_conflict_with_another_object(self):
        """Another object already has the same librenms_id for this server."""
        view = self._view()
        mock_obj = MagicMock()
        mock_obj.custom_field_data = {"librenms_id": 42}
        mock_obj.serial = "SN-MATCH"
        view._librenms_api.get_device_info.return_value = (True, {"serial": "SN-MATCH"})
        view._librenms_api.server_key = "default"

        DoesNotExist = type("DoesNotExist", (Exception,), {})
        mock_locked = MagicMock()
        mock_locked.pk = 1
        mock_locked.custom_field_data = {"librenms_id": 42}
        mock_locked.serial = "SN-MATCH"

        other_obj = MagicMock()
        other_obj.pk = 99  # different pk → conflict

        mock_device_cls = MagicMock()
        mock_device_cls.__name__ = "Device"
        mock_device_cls.DoesNotExist = DoesNotExist
        mock_device_cls.objects.select_for_update.return_value.get.return_value = mock_locked

        mock_txn = MagicMock()
        mock_txn.set_rollback = MagicMock()

        with (
            patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=mock_obj),
            patch("netbox_librenms_plugin.views.sync.device_fields.Device", mock_device_cls),
            patch("netbox_librenms_plugin.views.sync.device_fields.find_by_librenms_id", return_value=other_obj),
            patch("netbox_librenms_plugin.views.sync.device_fields.transaction", mock_txn),
            patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg,
            patch("netbox_librenms_plugin.views.sync.device_fields.redirect"),
        ):
            view.post(_make_request({"object_type": "device"}), pk=1)
        mock_msg.error.assert_called_once()
        mock_txn.set_rollback.assert_called_once_with(True)

    def test_migrate_returns_false(self):
        """migrate_legacy_librenms_id returns False → warning."""
        view = self._view()
        mock_obj = MagicMock()
        mock_obj.custom_field_data = {"librenms_id": 42}
        mock_obj.serial = "SN-MATCH"
        view._librenms_api.get_device_info.return_value = (True, {"serial": "SN-MATCH"})
        view._librenms_api.server_key = "default"

        DoesNotExist = type("DoesNotExist", (Exception,), {})
        mock_locked = MagicMock()
        mock_locked.pk = 1
        mock_locked.custom_field_data = {"librenms_id": 42}
        mock_locked.serial = "SN-MATCH"

        mock_device_cls = MagicMock()
        mock_device_cls.DoesNotExist = DoesNotExist
        mock_device_cls.objects.select_for_update.return_value.get.return_value = mock_locked

        with (
            patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=mock_obj),
            patch("netbox_librenms_plugin.views.sync.device_fields.Device", mock_device_cls),
            patch("netbox_librenms_plugin.views.sync.device_fields.find_by_librenms_id", return_value=None),
            patch("netbox_librenms_plugin.views.sync.device_fields.migrate_legacy_librenms_id", return_value=False),
            patch("netbox_librenms_plugin.views.sync.device_fields.transaction"),
            patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg,
            patch("netbox_librenms_plugin.views.sync.device_fields.redirect"),
        ):
            view.post(_make_request({"object_type": "device"}), pk=1)
        mock_msg.warning.assert_called_once()

    def test_validation_error_on_save(self):
        from django.core.exceptions import ValidationError

        view = self._view()
        mock_obj = MagicMock()
        mock_obj.custom_field_data = {"librenms_id": 42}
        mock_obj.serial = "SN-MATCH"
        view._librenms_api.get_device_info.return_value = (True, {"serial": "SN-MATCH"})
        view._librenms_api.server_key = "default"

        DoesNotExist = type("DoesNotExist", (Exception,), {})
        mock_locked = MagicMock()
        mock_locked.pk = 1
        mock_locked.custom_field_data = {"librenms_id": 42}
        mock_locked.serial = "SN-MATCH"
        mock_locked.full_clean.side_effect = ValidationError({"librenms_id": ["err"]})

        mock_device_cls = MagicMock()
        mock_device_cls.DoesNotExist = DoesNotExist
        mock_device_cls.objects.select_for_update.return_value.get.return_value = mock_locked

        mock_txn = MagicMock()
        mock_txn.set_rollback = MagicMock()

        with (
            patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=mock_obj),
            patch("netbox_librenms_plugin.views.sync.device_fields.Device", mock_device_cls),
            patch("netbox_librenms_plugin.views.sync.device_fields.find_by_librenms_id", return_value=None),
            patch("netbox_librenms_plugin.views.sync.device_fields.migrate_legacy_librenms_id", return_value=True),
            patch("netbox_librenms_plugin.views.sync.device_fields.transaction", mock_txn),
            patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg,
            patch("netbox_librenms_plugin.views.sync.device_fields.redirect"),
        ):
            view.post(_make_request({"object_type": "device"}), pk=1)
        mock_msg.error.assert_called_once()
        mock_txn.set_rollback.assert_called_once_with(True)

    def test_unexpected_error_on_save(self):
        view = self._view()
        mock_obj = MagicMock()
        mock_obj.custom_field_data = {"librenms_id": 42}
        mock_obj.serial = "SN-MATCH"
        view._librenms_api.get_device_info.return_value = (True, {"serial": "SN-MATCH"})
        view._librenms_api.server_key = "default"

        DoesNotExist = type("DoesNotExist", (Exception,), {})
        mock_locked = MagicMock()
        mock_locked.pk = 1
        mock_locked.custom_field_data = {"librenms_id": 42}
        mock_locked.serial = "SN-MATCH"
        mock_locked.full_clean.side_effect = RuntimeError("disk full")

        mock_device_cls = MagicMock()
        mock_device_cls.DoesNotExist = DoesNotExist
        mock_device_cls.objects.select_for_update.return_value.get.return_value = mock_locked

        mock_txn = MagicMock()
        mock_txn.set_rollback = MagicMock()

        with (
            patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=mock_obj),
            patch("netbox_librenms_plugin.views.sync.device_fields.Device", mock_device_cls),
            patch("netbox_librenms_plugin.views.sync.device_fields.find_by_librenms_id", return_value=None),
            patch("netbox_librenms_plugin.views.sync.device_fields.migrate_legacy_librenms_id", return_value=True),
            patch("netbox_librenms_plugin.views.sync.device_fields.transaction", mock_txn),
            patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg,
            patch("netbox_librenms_plugin.views.sync.device_fields.redirect"),
        ):
            view.post(_make_request({"object_type": "device"}), pk=1)
        mock_msg.error.assert_called_once()
        mock_txn.set_rollback.assert_called_once_with(True)

    def test_success_integer_cf_value(self):
        """Happy path with integer cf_value → success message."""
        view = self._view()
        mock_obj = MagicMock()
        mock_obj.custom_field_data = {"librenms_id": 42}
        mock_obj.serial = "SN-MATCH"
        view._librenms_api.get_device_info.return_value = (True, {"serial": "SN-MATCH"})
        view._librenms_api.server_key = "default"

        DoesNotExist = type("DoesNotExist", (Exception,), {})
        mock_locked = MagicMock()
        mock_locked.pk = 1
        mock_locked.custom_field_data = {"librenms_id": 42}
        mock_locked.serial = "SN-MATCH"

        mock_device_cls = MagicMock()
        mock_device_cls.DoesNotExist = DoesNotExist
        mock_device_cls.objects.select_for_update.return_value.get.return_value = mock_locked

        with (
            patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=mock_obj),
            patch("netbox_librenms_plugin.views.sync.device_fields.Device", mock_device_cls),
            patch("netbox_librenms_plugin.views.sync.device_fields.find_by_librenms_id", return_value=None),
            patch("netbox_librenms_plugin.views.sync.device_fields.migrate_legacy_librenms_id", return_value=True),
            patch("netbox_librenms_plugin.views.sync.device_fields.transaction"),
            patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg,
            patch("netbox_librenms_plugin.views.sync.device_fields.redirect"),
        ):
            view.post(_make_request({"object_type": "device"}), pk=1)
        mock_msg.success.assert_called_once()
        mock_locked.full_clean.assert_called_once()
        mock_locked.save.assert_called_once()
        assert "42" in mock_msg.success.call_args[0][1]

    def test_success_string_cf_value(self):
        """Happy path with string digit cf_value → success message."""
        view = self._view()
        mock_obj = MagicMock()
        mock_obj.custom_field_data = {"librenms_id": "42"}
        mock_obj.serial = "SN-MATCH"
        view._librenms_api.get_device_info.return_value = (True, {"serial": "SN-MATCH"})
        view._librenms_api.server_key = "default"

        DoesNotExist = type("DoesNotExist", (Exception,), {})
        mock_locked = MagicMock()
        mock_locked.pk = 1
        mock_locked.custom_field_data = {"librenms_id": "42"}
        mock_locked.serial = "SN-MATCH"

        mock_device_cls = MagicMock()
        mock_device_cls.DoesNotExist = DoesNotExist
        mock_device_cls.objects.select_for_update.return_value.get.return_value = mock_locked

        with (
            patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=mock_obj),
            patch("netbox_librenms_plugin.views.sync.device_fields.Device", mock_device_cls),
            patch("netbox_librenms_plugin.views.sync.device_fields.find_by_librenms_id", return_value=None),
            patch("netbox_librenms_plugin.views.sync.device_fields.migrate_legacy_librenms_id", return_value=True),
            patch("netbox_librenms_plugin.views.sync.device_fields.transaction"),
            patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg,
            patch("netbox_librenms_plugin.views.sync.device_fields.redirect"),
        ):
            view.post(_make_request({"object_type": "device"}), pk=1)
        mock_msg.success.assert_called_once()
        mock_locked.full_clean.assert_called_once()
        mock_locked.save.assert_called_once()

    def test_conflict_same_object_is_not_conflict(self):
        """find_by_librenms_id returns the same object → no conflict, proceeds."""
        view = self._view()
        mock_obj = MagicMock()
        mock_obj.custom_field_data = {"librenms_id": 42}
        mock_obj.serial = "SN-MATCH"
        view._librenms_api.get_device_info.return_value = (True, {"serial": "SN-MATCH"})
        view._librenms_api.server_key = "default"

        DoesNotExist = type("DoesNotExist", (Exception,), {})
        mock_locked = MagicMock()
        mock_locked.pk = 1
        mock_locked.custom_field_data = {"librenms_id": 42}
        mock_locked.serial = "SN-MATCH"

        # find_by_librenms_id returns the SAME object → match.pk == locked.pk → no conflict
        same_obj = MagicMock()
        same_obj.pk = 1

        mock_device_cls = MagicMock()
        mock_device_cls.DoesNotExist = DoesNotExist
        mock_device_cls.objects.select_for_update.return_value.get.return_value = mock_locked

        with (
            patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=mock_obj),
            patch("netbox_librenms_plugin.views.sync.device_fields.Device", mock_device_cls),
            patch("netbox_librenms_plugin.views.sync.device_fields.find_by_librenms_id", return_value=same_obj),
            patch("netbox_librenms_plugin.views.sync.device_fields.migrate_legacy_librenms_id", return_value=True),
            patch("netbox_librenms_plugin.views.sync.device_fields.transaction"),
            patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg,
            patch("netbox_librenms_plugin.views.sync.device_fields.redirect"),
        ):
            view.post(_make_request({"object_type": "device"}), pk=1)
        mock_msg.success.assert_called_once()
        mock_locked.full_clean.assert_called_once()
        mock_locked.save.assert_called_once()


# ---------------------------------------------------------------------------
# Wiring assertions — ensure views keep required mixins and permissions
# ---------------------------------------------------------------------------


class TestDeviceFieldsViewWiring:
    """Structural checks: views must retain required mixins and permissions."""

    def test_convert_legacy_id_has_librenms_api_mixin(self):
        from netbox_librenms_plugin.views.mixins import LibreNMSAPIMixin
        from netbox_librenms_plugin.views.sync.device_fields import ConvertLegacyLibreNMSIdView

        assert issubclass(ConvertLegacyLibreNMSIdView, LibreNMSAPIMixin)

    def test_convert_legacy_id_has_required_object_permissions(self):
        from netbox_librenms_plugin.views.sync.device_fields import ConvertLegacyLibreNMSIdView

        assert "POST" in ConvertLegacyLibreNMSIdView.required_object_permissions

    def test_remove_server_mapping_has_required_object_permissions(self):
        from netbox_librenms_plugin.views.sync.device_fields import RemoveServerMappingView

        assert "POST" in RemoveServerMappingView.required_object_permissions
