"""
Integration tests for IPFabricSync.sync and merge operations.

These tests mock IPFabric API endpoints and branching, run sync, and verify
synced data in NetBox database. Uses TestCase with setUpTestData for fast execution.
"""
import json
import logging
from collections import Counter
from pathlib import Path
from unittest.mock import MagicMock
from unittest.mock import patch

from dcim.models import Device
from dcim.models import DeviceRole
from dcim.models import DeviceType
from dcim.models import Interface
from dcim.models import InventoryItem
from dcim.models import MACAddress
from dcim.models import Manufacturer
from dcim.models import Platform
from dcim.models import Site
from dcim.models import VirtualChassis
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.db import DEFAULT_DB_ALIAS
from django.test import tag
from django.test import TestCase
from django.utils import timezone
from django.utils.text import slugify
from ipam.models import IPAddress
from ipam.models import Prefix
from ipam.models import VLAN
from ipam.models import VRF
from netbox_branching.choices import BranchStatusChoices
from netbox_branching.models import Branch
from netbox_branching.models.changes import ObjectChange as BranchObjectChange

from ipfabric_netbox.choices import IPFabricSnapshotStatusModelChoices
from ipfabric_netbox.models import IPFabricSnapshot
from ipfabric_netbox.models import IPFabricSource
from ipfabric_netbox.models import IPFabricSync
from ipfabric_netbox.utilities.ipfutils import IPFabricSyncRunner
from ipfabric_netbox.utilities.logging import SyncLogging

User = get_user_model()

# Configure logging to show sync logger output in tests
logging.basicConfig(
    level=logging.DEBUG, format="%(levelname)s %(asctime)s %(name)s %(message)s"
)
# Enable the specific loggers used by the sync process
logging.getLogger("ipfabric.sync").setLevel(logging.DEBUG)
logging.getLogger("ipfabric_netbox.models").setLevel(logging.DEBUG)


@tag("integration")
class IPFabricSyncIntegrationTests(TestCase):
    """
    Integration tests for IPFabricSync sync operations (without actual branching).

    Test strategy:
    - Uses regular TestCase for fast execution (transaction rollback)
    - Mocks branching operations to avoid TransactionTestCase overhead
    - setUpTestData runs once: creates source/snapshot/sync, mocks API and branching
    - Sync writes directly to test database (branch mocked away)
    - Individual test methods verify synced data quickly
    - Fast execution: ~0.05s per test instead of ~1s with TransactionTestCase
    """

    fixtures_dir = Path(__file__).parent / "data"

    @classmethod
    def setUpTestData(cls):
        """
        Runs ONCE before any test methods in this class.
        Creates IPFabric source, snapshot, sync configuration, mocks API and branching,
        and runs sync operations directly to main test database.
        """
        super().setUpTestData()

        # Create user for sync operations
        cls.user = User.objects.create_user(
            username="test_sync_user", email="test@example.com"
        )

        # Create IPFabric source (local type uses live API calls)
        cls.source = IPFabricSource.objects.create(
            name="Test Source",
            type="local",
            url="https://test.ipfabric.io",
            status="loaded",
            parameters={
                "base_url": "https://test.ipfabric.io",
                "token": "test_token_123456",
                "verify": False,
                "timeout": 30,
                "snapshot_id": "$last",
            },
        )

        # Create snapshot
        cls.snapshot = IPFabricSnapshot.objects.create(
            name="Test Snapshot - Integration Tests",
            source=cls.source,
            snapshot_id="$last",
            status=IPFabricSnapshotStatusModelChoices.STATUS_LOADED,
            data={
                "id": "$last",
                "name": "Test Snapshot - Integration Tests",
                "status": "loaded",
                "start": "2026-02-23T10:00:00.000Z",
                "end": "2026-02-23T10:30:00.000Z",
            },
        )

        # Create sync configuration with enabled models
        cls.sync = IPFabricSync.objects.create(
            name="Test Integration Sync",
            snapshot_data=cls.snapshot,
            parameters={
                "sites": [],
                "groups": [],
                "ipam.vrf": True,
                "dcim.site": True,
                "ipam.vlan": True,
                "dcim.device": True,
                "ipam.prefix": True,
                "dcim.platform": True,
                "dcim.interface": True,
                "ipam.ipaddress": True,
                "dcim.devicerole": True,
                "dcim.devicetype": True,
                "dcim.macaddress": True,
                "dcim.manufacturer": True,
                "dcim.inventoryitem": True,
                "dcim.virtualchassis": True,
            },
            user=cls.user,
            auto_merge=False,
        )

        # Mock both IPFabric API and branching operations
        with (
            cls._mock_ipfabric_api(),
            cls._mock_branch_model(),
            cls._mock_active_branch(),
            cls._mock_sync_runner(),
        ):
            # Run sync operation (writes directly to test DB, no branching)
            cls.sync.sync(job=None)
            cls.ingestion = cls.sync.last_ingestion
            # Note: No merge needed since we're writing directly to test DB

    @classmethod
    def _mock_branch_model(cls):
        """
        Mock Branch model methods to skip provisioning but allow DB record creation.

        Returns:
            Context manager that patches Branch.provision() and Branch.refresh_from_db()
        """

        def mock_branch_provision(self, user):
            """
            Mock Branch.provision() to skip actual schema creation but simulate success.

            Sets Branch status to READY and last_sync timestamp without creating
            a PostgreSQL schema, so sync can proceed in test DB.
            """
            # Update Branch status to READY without creating schema
            Branch.objects.filter(pk=self.pk).update(
                status=BranchStatusChoices.READY,
                last_sync=timezone.now(),
            )
            # Refresh the instance to get updated values
            self.status = BranchStatusChoices.READY
            self.last_sync = timezone.now()

        # Use patch.multiple to patch all Branch methods in one call
        return patch.multiple(
            Branch,
            provision=mock_branch_provision,
            refresh_from_db=MagicMock(),
        )

    @classmethod
    def _mock_active_branch(cls):
        """
        Mock active_branch ContextVar to prevent switching to branch schema.

        The active_branch.set() method is called during sync to switch to the branch schema.
        By mocking it to do nothing, all operations stay on the default test database.

        Returns:
            Context manager that patches active_branch in ipfabric_netbox.models
        """
        # Create a mock ContextVar that does nothing when set
        mock_active_branch = MagicMock()
        mock_active_branch.get.return_value = DEFAULT_DB_ALIAS

        # Mock set() to return a token that does nothing when reset
        mock_token = MagicMock()
        mock_active_branch.set.return_value = mock_token

        # Patch the active_branch import in the models module
        return patch("ipfabric_netbox.models.active_branch", mock_active_branch)

    @classmethod
    def _mock_sync_runner(cls):
        """
        Mock IPFabricSyncRunner to redirect database connections to test DB.

        Returns:
            Context manager that patches IPFabricSyncRunner.get_db_connection_name()
        """

        def mock_get_db_connection_name(self):
            """
            Mock get_db_connection_name to return default DB alias.

            This redirects all branch writes to the test database instead of
            a separate schema, avoiding the need for actual schema provisioning.
            """
            return DEFAULT_DB_ALIAS

        # Patch the method that retrieves connection name during sync
        return patch.object(
            IPFabricSyncRunner, "get_db_connection_name", mock_get_db_connection_name
        )

    @classmethod
    def _mock_ipfabric_api(cls):
        """
        Context manager to mock IPFabric API calls.
        Returns fixture data based on endpoint URL.
        """

        def mock_fetch_all(self_ipf, url, snapshot_id=None, filters=None, **kwargs):
            """
            Mock IPFClient.fetch_all method.

            Args:
                self_ipf: IPFClient instance (ignored in mock)
                url: Endpoint path like '/inventory/sites/overview'
                snapshot_id: Snapshot ID (ignored in mock)
                filters: Filter dictionary (ignored in mock)

            Returns:
                List of data items from fixture
            """
            endpoint_path = url if url.startswith("/") else f"/{url}"

            # Load corresponding fixture (fixtures are now lists directly)
            try:
                data = cls.load_fixture(endpoint_path)
                return data
            except FileNotFoundError:
                # Return empty list for unmapped endpoints
                return []

        def mock_init(self_ipf, *args, **kwargs):
            """Mock IPFClient __init__ to prevent actual connection."""
            # Set up required attributes that IPFabric wrapper expects
            self_ipf._client = MagicMock()
            self_ipf._client.headers = {"user-agent": "test-ipfabric-client"}

        # Patch both __init__ and fetch_all
        return patch.multiple(
            "ipfabric.IPFClient", __init__=mock_init, fetch_all=mock_fetch_all
        )

    @classmethod
    def load_fixture(cls, endpoint_path: str) -> list:
        """
        Load test fixture data for an endpoint.

        Args:
            endpoint_path: API endpoint like '/inventory/sites/overview'

        Returns:
            List of data items from fixture file
        """
        # Convert endpoint path to filename
        # /inventory/sites/overview -> inventory__sites__overview.json
        filename = endpoint_path.strip("/").replace("/", "__") + ".json"
        filepath = cls.fixtures_dir / filename

        if not filepath.exists():
            raise FileNotFoundError(f"Fixture file not found: {filepath}")

        with open(filepath, "r") as f:
            return json.load(f)

    # ==================== Sites Tests ====================

    def test_sites_created(self):
        """Verify sites from fixture are created in NetBox."""
        fixture_data = self.load_fixture("/inventory/sites/overview")
        expected_count = len(fixture_data)

        actual_count = Site.objects.count()
        self.assertEqual(
            actual_count,
            expected_count,
            f"Expected {expected_count} sites, found {actual_count}",
        )

    def test_site_names_match_fixture(self):
        """Verify site names match fixture data."""
        fixture_data = self.load_fixture("/inventory/sites/overview")
        self.assertGreater(len(fixture_data), 0, "sites fixture must be non-empty")

        for site_data in fixture_data:
            site_name = site_data["siteName"]
            self.assertTrue(
                Site.objects.filter(name=site_name).exists(),
                f"Site '{site_name}' missing from NetBox",
            )

    def test_site_attributes(self):
        """Verify site attributes are correctly mapped from fixture."""
        fixture_data = self.load_fixture("/inventory/sites/overview")
        first_site_data = fixture_data[0]

        site = Site.objects.get(name=first_site_data["siteName"])
        self.assertIsNotNone(site, "Site should exist")

        # Verify slug is generated (assuming transform map creates it)
        self.assertIsNotNone(site.slug, "Site slug should be set")

    def test_site_slug_is_slugified_site_name(self):
        """
        Verify Site.slug = siteName | slugify.

        Transform map: source_field=siteName, template={{ object.siteName | slugify }}
        e.g. 'Test-1X' → 'test-1x'
        """
        fixture_data = self.load_fixture("/inventory/sites/overview")
        self.assertGreater(len(fixture_data), 0, "sites fixture must be non-empty")
        for site_data in fixture_data:
            expected_slug = slugify(site_data["siteName"])
            site = Site.objects.get(name=site_data["siteName"])
            self.assertEqual(
                site.slug,
                expected_slug,
                f"Site '{site_data['siteName']}' slug should be '{expected_slug}', "
                f"got '{site.slug}'",
            )

    # ==================== Manufacturer Tests ====================

    def test_manufacturers_created(self):
        """Verify unique vendors from fixture are created as Manufacturers."""
        fixture_data = self.load_fixture("/inventory/devices")
        expected_vendors = {d["vendor"] for d in fixture_data}
        expected_count = len(expected_vendors)

        actual_count = Manufacturer.objects.count()
        self.assertEqual(
            actual_count,
            expected_count,
            f"Expected {expected_count} manufacturers, found {actual_count}",
        )

    def test_manufacturer_names_match_fixture(self):
        """Verify Manufacturer names are set from the vendor field."""
        fixture_data = self.load_fixture("/inventory/devices")
        self.assertGreater(len(fixture_data), 0, "devices fixture must be non-empty")

        for device_data in fixture_data:
            vendor = device_data["vendor"]
            self.assertTrue(
                Manufacturer.objects.filter(name=vendor).exists(),
                f"Manufacturer with name '{vendor}' missing from NetBox",
            )

    def test_manufacturer_slugs_match_fixture(self):
        """Verify Manufacturer slugs are the slugified vendor field."""
        fixture_data = self.load_fixture("/inventory/devices")
        self.assertGreater(len(fixture_data), 0, "devices fixture must be non-empty")

        for device_data in fixture_data:
            expected_slug = device_data["vendor"].lower().replace(" ", "-")
            self.assertTrue(
                Manufacturer.objects.filter(slug=expected_slug).exists(),
                f"Manufacturer with slug '{expected_slug}' missing from NetBox",
            )

    # ==================== DeviceType Tests ====================

    def test_device_types_created(self):
        """Verify unique models from fixture are created as DeviceTypes."""
        # DCS-7050SX-64-F (Arista), C9606R (TEST01-CS-01), WS-C2960-24TC-L (MGMT-SW-01),
        # WS-C3750E-24TD-S (stack member 1), WS-C3750-24PS-S (stack member 2)
        expected_count = 5

        actual_count = DeviceType.objects.count()
        self.assertEqual(
            actual_count,
            expected_count,
            f"Expected {expected_count} device types, found {actual_count}",
        )

    def test_device_type_slug_is_model_slugified(self):
        """
        Verify DeviceType.slug = model|string|slugify when model is present.

        Transform map template:
          {% if object.model %}{{ object.model | string | slugify }}
          {% else %}{{ object.vendor|slugify }}-{{ object.family|slugify }}-{{ object.platform|slugify }}{% endif %}

        For stack master devices the DeviceType comes from the member pn field,
        not from the device model — so the master's inventory model ('WS-C3750E-24TD')
        must be excluded. Only non-master device models and stack member pn values
        should have DeviceTypes.

        'DCS-7050SX-64-F' → 'dcs-7050sx-64-f'
        'WS-C3750E-24TD-S' → 'ws-c3750e-24td-s'
        'WS-C3750-24PS-S'  → 'ws-c3750-24ps-s'
        """
        fixture_data = self.load_fixture("/inventory/devices")
        stack_data = self.load_fixture("/technology/platforms/stack/members")

        # Serial numbers of stack masters — their device model is NOT used for DeviceType
        stack_master_serials = {d["sn"] for d in stack_data if d["role"] == "master"}

        # Non-master device models
        models = {
            d["model"]
            for d in fixture_data
            if d.get("model") and d["sn"] not in stack_master_serials
        }
        # Stack member pn values (these DO become DeviceTypes)
        models.update({d["pn"] for d in stack_data if d.get("pn")})

        self.assertGreater(
            len(models),
            0,
            "expected at least one non-master device model or stack-member pn",
        )
        for model in models:
            expected_slug = slugify(str(model))
            self.assertTrue(
                DeviceType.objects.filter(slug=expected_slug).exists(),
                f"DeviceType slug '{expected_slug}' (from model '{model}') not found",
            )

    def test_device_type_model_field_matches_fixture(self):
        """Verify DeviceType.model field matches the fixture model value."""
        model_name = "DCS-7050SX-64-F"
        self.assertTrue(
            DeviceType.objects.get(model=model_name),
            f"DeviceType with model '{model_name}' missing from NetBox",
        )

    def test_device_type_slugs_match_fixture(self):
        """Verify DeviceType slugs are the slugified model field."""
        model_name = "DCS-7050SX-64-F"
        expected_slug = model_name.lower().replace(" ", "-")
        self.assertTrue(
            DeviceType.objects.get(slug=expected_slug),
            f"DeviceType with slug '{expected_slug}' missing from NetBox",
        )

    def test_device_type_manufacturer_relationship(self):
        """Verify each DeviceType is linked to the correct Manufacturer."""
        device_model = "DCS-7050SX-64-F"
        device_vendor = "arista"

        device_type = DeviceType.objects.get(model=device_model)
        self.assertEqual(
            device_type.manufacturer.name,
            device_vendor,
            f"DeviceType '{device_model}' should belong to manufacturer '{device_vendor}', "
            f"got '{device_type.manufacturer.name}'",
        )

    # ==================== DeviceRole Tests ====================

    def test_device_roles_created(self):
        """Verify unique devType values from fixture are created as DeviceRoles."""
        fixture_data = self.load_fixture("/inventory/devices")
        expected_roles = {d["devType"] for d in fixture_data if d.get("devType")}
        expected_count = len(expected_roles)

        actual_count = DeviceRole.objects.count()
        self.assertEqual(
            actual_count,
            expected_count,
            f"Expected {expected_count} device roles, found {actual_count}",
        )

    def test_device_role_names_match_fixture(self):
        """Verify DeviceRole names match the devType field."""
        fixture_data = self.load_fixture("/inventory/devices")
        candidates = [d for d in fixture_data if d.get("devType")]
        self.assertGreater(
            len(candidates),
            0,
            "devices fixture must have at least one entry with devType set",
        )
        for device_data in candidates:
            dev_type = device_data["devType"]
            self.assertTrue(
                DeviceRole.objects.filter(name=dev_type).exists(),
                f"DeviceRole with name '{dev_type}' missing from NetBox",
            )

    def test_device_role_slugs_match_fixture(self):
        """Verify DeviceRole slugs are the slugified devType field."""
        # switch   -> switch
        # l3switch -> l3switch
        fixture_data = self.load_fixture("/inventory/devices")
        candidates = [d for d in fixture_data if d.get("devType")]
        self.assertGreater(
            len(candidates),
            0,
            "devices fixture must have at least one entry with devType set",
        )
        for device_data in candidates:
            dev_type = device_data["devType"]
            expected_slug = dev_type.lower().replace(" ", "-")
            self.assertTrue(
                DeviceRole.objects.filter(slug=expected_slug).exists(),
                f"DeviceRole with slug '{expected_slug}' missing from NetBox",
            )

    def test_device_role_vm_role_is_false(self):
        """Verify all synced DeviceRoles have vm_role set to False."""
        self.assertGreater(
            DeviceRole.objects.count(), 0, "at least one DeviceRole must exist"
        )
        for role in DeviceRole.objects.all():
            self.assertFalse(
                role.vm_role,
                f"DeviceRole '{role.name}' should have vm_role=False",
            )

    # ==================== Platform Tests ====================

    def test_platforms_created(self):
        """Verify unique family values from fixture are created as Platforms."""
        fixture_data = self.load_fixture("/inventory/devices")
        # family falls back to vendor when None/empty
        expected_slugs = {
            d["family"] if d.get("family") else d["vendor"] for d in fixture_data
        }
        expected_count = len(expected_slugs)

        actual_count = Platform.objects.count()
        self.assertEqual(
            actual_count,
            expected_count,
            f"Expected {expected_count} platforms, found {actual_count}",
        )

    def test_platform_names_match_fixture(self):
        """
        Verify Platform.name = family when family is present, else vendor.

        Transform map template:
          {% if object.family %}{{ object.family }}{% else %}{{ object.vendor }}{% endif %}

        Note: name is the RAW family value, not slugified.
        e.g. family='ios' → name='ios', family='eos' → name='eos'
        """
        fixture_data = self.load_fixture("/inventory/devices")
        self.assertGreater(len(fixture_data), 0, "devices fixture must be non-empty")

        for device_data in fixture_data:
            expected_name = (
                device_data["family"]
                if device_data.get("family")
                else device_data["vendor"]
            )
            self.assertTrue(
                Platform.objects.filter(name=expected_name).exists(),
                f"Platform with name '{expected_name}' missing from NetBox",
            )

    def test_platform_slug_is_family_slugified(self):
        """
        Verify Platform.slug = family|slugify when family present, else vendor|slugify.

        Transform map template:
          {% if object.family %}{{ object.family | slugify }}{% else %}{{ object.vendor | slugify }}{% endif %}

        e.g. family='ios' → slug='ios'
             family='eos' → slug='eos'
        """
        fixture_data = self.load_fixture("/inventory/devices")
        self.assertGreater(len(fixture_data), 0, "devices fixture must be non-empty")

        for device_data in fixture_data:
            if device_data.get("family"):
                expected_slug = slugify(device_data["family"])
            else:
                expected_slug = slugify(device_data["vendor"])
            self.assertTrue(
                Platform.objects.filter(slug=expected_slug).exists(),
                f"Platform with slug '{expected_slug}' missing from NetBox",
            )

    def test_platform_slugs_match_fixture(self):
        """Verify Platform slugs are the slugified family field (or vendor as fallback)."""
        fixture_data = self.load_fixture("/inventory/devices")
        self.assertGreater(len(fixture_data), 0, "devices fixture must be non-empty")

        for device_data in fixture_data:
            raw = (
                device_data["family"]
                if device_data.get("family")
                else device_data["vendor"]
            )
            expected_slug = slugify(raw)
            self.assertTrue(
                Platform.objects.filter(slug=expected_slug).exists(),
                f"Platform with slug '{expected_slug}' missing from NetBox",
            )

    def test_platform_manufacturer_relationship(self):
        """Verify each Platform is linked to the Manufacturer matching its vendor."""
        fixture_data = self.load_fixture("/inventory/devices")
        self.assertGreater(len(fixture_data), 0, "devices fixture must be non-empty")

        for device_data in fixture_data:
            vendor = device_data["vendor"]
            platform_name = (
                device_data["family"] if device_data.get("family") else vendor
            )
            platform = Platform.objects.get(name=platform_name)
            self.assertIsNotNone(
                platform.manufacturer,
                f"Platform '{platform_name}' should have a manufacturer",
            )
            self.assertEqual(
                platform.manufacturer.name,
                vendor,
                f"Platform '{platform_name}' should belong to manufacturer '{vendor}', "
                f"got '{platform.manufacturer.name}'",
            )

    # ==================== VirtualChassis Tests ====================

    def test_virtualchassis_created(self):
        """
        Verify one VirtualChassis record is created per unique master hostname
        across both stack members and VSS chassis fixtures.

        prepare_devices() collects the master member row into the virtualchassis
        list; one VC is created per stack master, not per member.
        VSS chassis also produce one VC per unique hostname.
        """
        stack_data = self.load_fixture("/technology/platforms/stack/members")
        vss_data = self.load_fixture("/technology/platforms/vss/chassis")
        expected_names = {d["master"] for d in stack_data} | {
            d["hostname"] for d in vss_data
        }
        expected_count = len(expected_names)

        actual_count = VirtualChassis.objects.count()
        self.assertEqual(
            actual_count,
            expected_count,
            f"Expected {expected_count} VirtualChassis records, found {actual_count}",
        )

    def test_virtualchassis_name_matches_master_hostname(self):
        """
        Verify VirtualChassis.name is set from the master hostname field.

        The transform map template for name is empty (pass-through), using
        source_field='master', so the VC name equals the master hostname string.
        """
        fixture_data = self.load_fixture("/technology/platforms/stack/members")
        master_names = {d["master"] for d in fixture_data}
        self.assertGreater(
            len(master_names),
            0,
            "stack members fixture must contain at least one master",
        )
        for master_name in master_names:
            self.assertTrue(
                VirtualChassis.objects.filter(name=master_name).exists(),
                f"VirtualChassis with name '{master_name}' missing from NetBox",
            )

    def test_virtualchassis_master_device_linked(self):
        """
        Verify VirtualChassis.master points to the Device whose serial matches
        the master member's sn field.

        The transform map relationship_map:
          template: Device.objects.filter(serial=object.sn).first()

        The master member is the row with role='master'; its memberSn == sn.
        """
        fixture_data = self.load_fixture("/technology/platforms/stack/members")
        master_rows = [d for d in fixture_data if d["role"] == "master"]
        self.assertGreater(
            len(master_rows),
            0,
            "stack members fixture must contain at least one master-role row",
        )
        for row in master_rows:
            vc = VirtualChassis.objects.get(name=row["master"])
            self.assertIsNotNone(
                vc.master,
                f"VirtualChassis '{row['master']}' should have a master device assigned",
            )
            self.assertEqual(
                vc.master.serial,
                row["memberSn"],
                f"VirtualChassis '{row['master']}' master serial should be "
                f"'{row['memberSn']}', got '{vc.master.serial}'",
            )

    def test_device_virtual_chassis_fk_set(self):
        """
        Verify every stack member Device has its virtual_chassis FK pointing
        to the correct VirtualChassis object.

        The device transform map relationship_map resolves the VC by
        VirtualChassis.objects.filter(name=object.virtual_chassis.master).first()
        """
        fixture_data = self.load_fixture("/technology/platforms/stack/members")
        self.assertGreater(
            len(fixture_data), 0, "stack members fixture must be non-empty"
        )
        for member_data in fixture_data:
            device = Device.objects.get(serial=member_data["memberSn"])
            self.assertIsNotNone(
                device.virtual_chassis,
                f"Device '{device.name}' should have virtual_chassis set",
            )
            vc = VirtualChassis.objects.get(name=member_data["master"])
            self.assertEqual(
                device.virtual_chassis.pk,
                vc.pk,
                f"Device '{device.name}' virtual_chassis should be "
                f"'{member_data['master']}', got '{device.virtual_chassis.name}'",
            )

    def test_device_vc_position_matches_member_number(self):
        """
        Verify Device.vc_position is set from the member field.

        The device transform map template:
          {% if object.virtual_chassis is defined %}{{ object.virtual_chassis.member }}{% else %}None{% endif %}
        """
        fixture_data = self.load_fixture("/technology/platforms/stack/members")
        self.assertGreater(
            len(fixture_data), 0, "stack members fixture must be non-empty"
        )
        for member_data in fixture_data:
            device = Device.objects.get(serial=member_data["memberSn"])
            self.assertEqual(
                str(device.vc_position),
                str(member_data["member"]),
                f"Device '{device.name}' vc_position should be "
                f"'{member_data['member']}', got '{device.vc_position}'",
            )

    def test_stack_member_device_types_created(self):
        """
        Verify DeviceTypes are created for each stack member's pn field,
        and that the master hostname is NOT created as a DeviceType.

        Each member carries its own part number (pn) which is the model used for
        the DeviceType. The master field holds only the hostname of the master
        device and must never be treated as a model name.
        """
        fixture_data = self.load_fixture("/technology/platforms/stack/members")
        self.assertGreater(
            len(fixture_data), 0, "stack members fixture must be non-empty"
        )
        for member_data in fixture_data:
            pn = member_data["pn"]
            expected_slug = pn.lower().replace(" ", "-")

            self.assertTrue(
                DeviceType.objects.filter(model=pn).exists(),
                f"DeviceType for stack member pn '{pn}' missing from NetBox",
            )
            self.assertTrue(
                DeviceType.objects.filter(slug=expected_slug).exists(),
                f"DeviceType with slug '{expected_slug}' (from pn '{pn}') missing from NetBox",
            )

        master_model = "WS-C3750E-24TD"
        self.assertFalse(
            DeviceType.objects.filter(model=master_model).exists(),
            f"DeviceType with model='{master_model}' should not exist — "
            f"this is the master device's inventory model, not a stack member pn",
        )

    def test_stack_members_names(self):
        """Verify stack members device names are correctly synced."""
        fixture_data = self.load_fixture("/technology/platforms/stack/members")
        self.assertGreater(
            len(fixture_data), 0, "stack members fixture must be non-empty"
        )
        for device_data in fixture_data:
            device = Device.objects.get(serial=device_data["memberSn"])
            device_name = f"{device_data['master']}/{device_data['member']}"
            self.assertEqual(
                device.name,
                device_name,
                f"Device '{device_name}' name mismatch",
            )
            self.assertFalse(
                Device.objects.filter(name=device_data["master"]).exists(),
                "Master device should not be created separately from stack members",
            )

    def test_stack_members_serial_numbers(self):
        """Verify stack members serial numbers are correctly synced."""
        fixture_data = self.load_fixture("/technology/platforms/stack/members")
        self.assertGreater(
            len(fixture_data), 0, "stack members fixture must be non-empty"
        )
        for device_data in fixture_data:
            device = Device.objects.get(
                name=f"{device_data['master']}/{device_data['member']}"
            )
            self.assertEqual(
                device.serial,
                device_data["memberSn"],
                f"Device '{device.name}' serial number mismatch",
            )

    def test_non_stack_devices_have_no_virtual_chassis(self):
        """
        Verify that regular (non-stack, non-VSS) devices do not have a
        virtual_chassis assigned.

        Only devices whose sn appears in the stack members fixture or whose
        sn appears in the VSS chassis fixture should be linked to a
        VirtualChassis; all other synced devices must have virtual_chassis=None.
        """
        stack_data = self.load_fixture("/technology/platforms/stack/members")
        vss_data = self.load_fixture("/technology/platforms/vss/chassis")
        # Exclude both the master sn and all chassisSn values — every serial that
        # ends up inside a VirtualChassis (including the standby chassis members)
        vc_serials = (
            {d["memberSn"] for d in stack_data}
            | {d["sn"] for d in vss_data}
            | {d["chassisSn"] for d in vss_data}
        )
        self.assertGreater(
            len(vc_serials),
            0,
            "stack/VSS fixtures must contain at least one member serial",
        )

        regular_devices = Device.objects.exclude(serial__in=vc_serials)
        self.assertGreater(
            regular_devices.count(),
            0,
            "at least one non-stack/non-VSS device must exist to exercise this test",
        )
        for device in regular_devices:
            self.assertIsNone(
                device.virtual_chassis,
                f"Non-stack/non-VSS device '{device.name}' (serial={device.serial}) "
                f"should have virtual_chassis=None",
            )

    # ==================== Device Tests ====================

    def test_devices_created(self):
        """
        Verify devices from fixture are created in NetBox.

        Devices are sourced from three fixtures:
          - inventory/devices: standalone devices (those not expanded by stack/VSS)
          - stack/members: one device per member row (master's sn replaces inventory entry)
          - vss/chassis: one device per unique chassisId+chassisSn pair (deduplicated)

        The expected count equals the number of unique device serial numbers produced
        by prepare_devices() across all three sources.
        """
        inv_devices = self.load_fixture("/inventory/devices")
        stack_data = self.load_fixture("/technology/platforms/stack/members")
        vss_data = self.load_fixture("/technology/platforms/vss/chassis")

        members = {}
        for m in stack_data:
            members.setdefault(m["sn"], []).append(m)
        for m in vss_data:
            members.setdefault(m["sn"], []).append(m)

        all_sns = set()
        for device in inv_devices:
            if device["sn"] in members:
                for child in members[device["sn"]]:
                    member_sn = child.get("memberSn") or child.get("chassisSn")
                    all_sns.add(member_sn)
            else:
                all_sns.add(device["sn"])

        expected_count = len(all_sns)
        actual_count = Device.objects.count()
        self.assertEqual(
            actual_count,
            expected_count,
            f"Expected {expected_count} devices, found {actual_count}",
        )

    def test_device_name_match(self):
        """Verify device names match fixture data."""
        device_name = "HWLAB-B-SW02-ARISTA-7050SX"
        self.assertTrue(
            Device.objects.filter(name=device_name).exists(),
            f"Device '{device_name}' missing from NetBox",
        )

    def test_device_site_relationship(self):
        """Verify devices are linked to correct sites."""
        fixture_data = self.load_fixture("/inventory/devices")
        self.assertGreater(len(fixture_data), 0, "devices fixture must be non-empty")

        for device_data in fixture_data:
            device = Device.objects.get(serial=device_data["sn"])
            expected_site_name = device_data["siteName"]

            self.assertIsNotNone(
                device.site, f"Device '{device.name}' should have a site"
            )
            self.assertEqual(
                device.site.name,
                expected_site_name,
                f"Device '{device.name}' should be in site '{expected_site_name}'",
            )

    def test_device_platform_manufacturer_chain(self):
        """Verify Device -> Platform -> Manufacturer chain resolves correctly."""
        fixture_data = self.load_fixture("/inventory/devices")
        self.assertGreater(len(fixture_data), 0, "devices fixture must be non-empty")

        for device_data in fixture_data:
            device = Device.objects.get(serial=device_data["sn"])
            self.assertIsNotNone(
                device.platform,
                f"Device '{device.name}' should have a platform",
            )
            self.assertEqual(
                device.platform.manufacturer.name,
                device_data["vendor"],
                f"Device '{device.name}' platform.manufacturer should be "
                f"'{device_data['vendor']}', got '{device.platform.manufacturer.name}'",
            )

    def test_device_role_assignment(self):
        """Verify each Device is assigned the DeviceRole matching its devType."""
        fixture_data = self.load_fixture("/inventory/devices")
        candidates = [d for d in fixture_data if d.get("devType")]
        self.assertGreater(
            len(candidates),
            0,
            "devices fixture must have at least one entry with devType set",
        )
        for device_data in candidates:
            device = Device.objects.get(serial=device_data["sn"])
            expected_role_name = device_data["devType"]
            self.assertIsNotNone(
                device.role,
                f"Device '{device.name}' should have a role",
            )
            self.assertEqual(
                device.role.name,
                expected_role_name,
                f"Device '{device.name}' role should be '{expected_role_name}', "
                f"got '{device.role.name}'",
            )

    def test_device_status_is_active(self):
        """
        Verify every synced Device has status set to 'active'.

        The Device Transform Map sets status via a hardcoded template:
          source_field: hostname, target_field: status, coalesce: false
          template: active
        """
        fixture_data = self.load_fixture("/inventory/devices")
        self.assertGreater(len(fixture_data), 0, "devices fixture must be non-empty")
        for device_data in fixture_data:
            device = Device.objects.get(serial=device_data["sn"])
            self.assertEqual(
                device.status,
                "active",
                f"Device '{device.name}' status should be 'active', "
                f"got '{device.status}'",
            )

    # ==================== Inventory Item Tests ====================

    def test_inventory_items_created(self):
        """Verify every fixture part-number entry creates one InventoryItem in NetBox."""
        fixture_data = self.load_fixture("/inventory/part-numbers")
        expected_count = len(fixture_data)

        actual_count = InventoryItem.objects.count()
        self.assertEqual(
            actual_count,
            expected_count,
            f"Expected {expected_count} inventory items, found {actual_count}",
        )

    @staticmethod
    def _inv_name(item_data: dict) -> str:
        """
        Compute expected InventoryItem.name using the same logic as the transform map.

        Template (data/transform_map.json):
          {% if object.name is not none %}{{ object.name | string | truncate(64, True) }}
          {% elif object.dscr is not none %}{{ object.dscr | string | truncate(64, True) }}
          {% else %}Default Name{% endif %}

        Jinja2 truncate(64, killwords=True) returns s[:61] + '...' when len(s) > 64,
        otherwise returns s unchanged.
        """

        def _truncate(s: str) -> str:
            s = str(s)
            return s[:61] + "..." if len(s) > 64 else s

        if item_data.get("name") is not None:
            return _truncate(item_data["name"])
        elif item_data.get("dscr") is not None:
            return _truncate(item_data["dscr"])
        return "Default Name"

    def test_inventory_item_name_mapped(self):
        """
        Verify InventoryItem.name is set from the name field (truncated to 64 chars).

        The transform map template:
          - name when not None → truncated to 64 chars (Jinja2 truncate)
          - else dscr when not None → truncated to 64 chars
          - else 'Default Name'
        """
        fixture_data = self.load_fixture("/inventory/part-numbers")
        self.assertGreater(
            len(fixture_data), 0, "part-numbers fixture must be non-empty"
        )
        for item_data in fixture_data:
            expected_name = self._inv_name(item_data)

            device = Device.objects.get(serial=item_data["deviceSn"])
            self.assertTrue(
                InventoryItem.objects.filter(
                    device=device, name=expected_name
                ).exists(),
                f"InventoryItem '{expected_name}' on device '{device.name}' not found",
            )

    def test_inventory_item_serial_mapped(self):
        """Verify InventoryItem.serial is set from the sn field."""
        fixture_data = self.load_fixture("/inventory/part-numbers")
        self.assertGreater(
            len(fixture_data), 0, "part-numbers fixture must be non-empty"
        )
        for item_data in fixture_data:
            device = Device.objects.get(serial=item_data["deviceSn"])
            name = self._inv_name(item_data)
            item = InventoryItem.objects.get(
                device=device, name=name, serial=item_data["sn"]
            )
            self.assertEqual(
                item.serial,
                item_data["sn"],
                f"InventoryItem '{name}' serial should be '{item_data['sn']}', "
                f"got '{item.serial}'",
            )

    def test_inventory_item_part_id_mapped(self):
        """
        Verify InventoryItem.part_id is set from pid, falling back to 'unknown'
        when pid is null or the string 'None'.

        Transform map template:
          {% if object.pid and object.pid != "None" %}{{ object.pid }}
          {% else %}unknown{% endif %}
        """
        fixture_data = self.load_fixture("/inventory/part-numbers")
        self.assertGreater(
            len(fixture_data), 0, "part-numbers fixture must be non-empty"
        )
        for item_data in fixture_data:
            pid = item_data.get("pid")
            expected_part_id = pid if pid and pid != "None" else "unknown"

            device = Device.objects.get(serial=item_data["deviceSn"])
            name = self._inv_name(item_data)
            item = InventoryItem.objects.get(
                device=device, name=name, serial=item_data["sn"]
            )
            self.assertEqual(
                item.part_id,
                expected_part_id,
                f"InventoryItem '{name}' part_id should be '{expected_part_id}', "
                f"got '{item.part_id}'",
            )

    def test_inventory_item_device_relationship(self):
        """
        Verify every InventoryItem is linked to the Device matched by deviceSn.

        The transform map uses Device.objects.get(serial=object.deviceSn) —
        note: deviceSn, not sn (which is the item's own serial number).
        """
        fixture_data = self.load_fixture("/inventory/part-numbers")
        self.assertGreater(
            len(fixture_data), 0, "part-numbers fixture must be non-empty"
        )
        for item_data in fixture_data:
            device = Device.objects.get(serial=item_data["deviceSn"])
            name = self._inv_name(item_data)
            self.assertTrue(
                InventoryItem.objects.filter(
                    device=device, name=name, serial=item_data["sn"]
                ).exists(),
                f"InventoryItem '{name}' (sn='{item_data['sn']}') should be linked "
                f"to device with serial '{item_data['deviceSn']}'",
            )

    def test_inventory_item_manufacturer_relationship(self):
        """Verify each InventoryItem is linked to the Manufacturer matching its vendor."""
        fixture_data = self.load_fixture("/inventory/part-numbers")
        self.assertGreater(
            len(fixture_data), 0, "part-numbers fixture must be non-empty"
        )
        for item_data in fixture_data:
            device = Device.objects.get(serial=item_data["deviceSn"])
            name = self._inv_name(item_data)
            item = InventoryItem.objects.get(
                device=device, name=name, serial=item_data["sn"]
            )
            expected_slug = item_data["vendor"].lower().replace(" ", "-")
            self.assertIsNotNone(
                item.manufacturer,
                f"InventoryItem '{name}' should have a manufacturer",
            )
            self.assertEqual(
                item.manufacturer.slug,
                expected_slug,
                f"InventoryItem '{name}' manufacturer should have slug "
                f"'{expected_slug}', got '{item.manufacturer.slug}'",
            )

    def test_inventory_item_name_falls_back_to_dscr(self):
        """
        Verify InventoryItem.name falls back to dscr when name field is None.

        The template uses name first, then dscr, then 'Default Name'.
        The fixture has no entries with name=None but non-null dscr; instead
        this test verifies the primary path where name is used.
        From the fixture the Cisco chassis item has name='1' and dscr='WS-C3750E-24TD'
        — the stored name must be '1', not the dscr value.
        """
        cisco_sn = "FDO1552Y0SM"
        device = Device.objects.get(serial=cisco_sn)

        # name='1', dscr='WS-C3750E-24TD' → stored name must be '1'
        # sn='FDO1552Y0SM' (same as deviceSn for the chassis item)
        item = InventoryItem.objects.get(device=device, name="1", serial="FDO1552Y0SM")
        self.assertEqual(
            item.name,
            "1",
            "InventoryItem name should be '1' (from name field), not dscr",
        )
        # Confirm the dscr value was NOT used as the name
        self.assertFalse(
            InventoryItem.objects.filter(device=device, name="WS-C3750E-24TD").exists(),
            "dscr value 'WS-C3750E-24TD' should not appear as InventoryItem name "
            "when name field is populated",
        )

    def test_inventory_item_null_dscr_does_not_crash(self):
        """
        Verify InventoryItem records are created even when dscr is null.

        Several Arista fan/PSU entries have dscr=null. The template handles
        this by falling through to 'Default Name' only if name is also None.
        Since those entries do have a name ('1','2','3','4'), they must
        be created with that name.
        """
        arista_sn = "SSJ17151159"
        device = Device.objects.get(serial=arista_sn)

        fixture_data = self.load_fixture("/inventory/part-numbers")
        null_dscr_entries = [
            d
            for d in fixture_data
            if d["deviceSn"] == arista_sn and d.get("dscr") is None
        ]
        self.assertTrue(
            null_dscr_entries,
            "Fixture should contain Arista entries with dscr=null",
        )
        for item_data in null_dscr_entries:
            expected_name = self._inv_name(item_data)
            self.assertTrue(
                InventoryItem.objects.filter(
                    device=device, name=expected_name
                ).exists(),
                f"InventoryItem '{expected_name}' with null dscr should still be created",
            )

    def test_inventory_item_name_truncated_to_64_chars(self):
        """
        Verify InventoryItem.name is truncated to 64 characters.

        Transform map template: {{ object.name | string | truncate(64, True) }}
        Jinja2 truncate(64, killwords=True) returns s[:61] + '...' when len(s) > 64.
        """
        device = Device.objects.get(serial="SSJ17151159")
        long_name = "This inventory item name is intentionally longer than sixty-four characters total"
        expected_name = long_name[:61] + "..."
        item = InventoryItem.objects.get(device=device, serial="LONGNAME001")
        self.assertEqual(
            item.name,
            expected_name,
            f"InventoryItem name should be first 61 chars + '...', got '{item.name}'",
        )

    def test_inventory_item_name_uses_dscr_when_name_is_null(self):
        """
        Verify InventoryItem.name falls back to dscr when name field is null.

        Transform map template:
          {% if object.name is not none %}...
          {% elif object.dscr is not none %}{{ object.dscr | string | truncate(64, True) }}
          ...

        The fixture 'DSCRFALLBACK001' entry has name=null and dscr='Fallback from dscr field'.
        """
        device = Device.objects.get(serial="SSJ17151159")
        item = InventoryItem.objects.get(device=device, serial="DSCRFALLBACK001")
        self.assertEqual(
            item.name,
            "Fallback from dscr field",
            f"InventoryItem with name=null should use dscr, got '{item.name}'",
        )

    def test_inventory_item_name_defaults_when_both_null(self):
        """
        Verify InventoryItem.name is 'Default Name' when both name and dscr are null.

        Transform map template:
          ...{% else %}Default Name{% endif %}

        The fixture 'DEFAULTNAME001' entry has name=null and dscr=null.
        """
        device = Device.objects.get(serial="SSJ17151159")
        item = InventoryItem.objects.get(device=device, serial="DEFAULTNAME001")
        self.assertEqual(
            item.name,
            "Default Name",
            f"InventoryItem with name=null dscr=null should be 'Default Name', got '{item.name}'",
        )

    def test_inventory_item_pid_none_string_maps_to_unknown(self):
        """
        Verify InventoryItem.part_id='unknown' when pid is the string 'None'.

        Transform map template:
          {% if object.pid and object.pid != 'None' %}{{ object.pid }}{% else %}unknown{% endif %}

        The fixture 'DEFAULTNAME001' entry has pid=null which also maps to 'unknown'.
        """
        device = Device.objects.get(serial="SSJ17151159")
        item = InventoryItem.objects.get(device=device, serial="DEFAULTNAME001")
        self.assertEqual(
            item.part_id,
            "unknown",
            "InventoryItem with pid=null should have part_id='unknown'",
        )

    # ==================== Interface Tests ====================

    def test_interfaces_created(self):
        """Verify every fixture interface entry creates one Interface in NetBox."""
        fixture_data = self.load_fixture("/inventory/interfaces")
        expected_count = len(fixture_data)

        actual_count = Interface.objects.count()
        self.assertEqual(
            actual_count,
            expected_count,
            f"Expected {expected_count} interfaces, found {actual_count}",
        )

    def test_interface_name_uses_nameOriginal_over_intName(self):
        """
        Verify Interface.name is set from nameOriginal, not from intName.

        The transform map source_field is nameOriginal.  Where the two differ
        (e.g. intName='Vl1' / nameOriginal='Vlan1', intName='Gi1/0/4' /
        nameOriginal='GigabitEthernet1/0/4') the stored name must be the
        nameOriginal value, and a lookup by intName must find nothing.
        """
        fixture_data = self.load_fixture("/inventory/interfaces")

        # Entries where the two fields differ are the interesting ones
        differing = [
            d
            for d in fixture_data
            if d.get("nameOriginal") and d["nameOriginal"] != d["intName"]
        ]
        self.assertTrue(
            differing, "Fixture should contain entries where nameOriginal != intName"
        )

        for iface_data in differing:
            device = Device.objects.get(serial=iface_data["sn"])

            # nameOriginal must exist as the interface name
            self.assertTrue(
                Interface.objects.filter(
                    device=device, name=iface_data["nameOriginal"]
                ).exists(),
                f"Interface '{iface_data['nameOriginal']}' (nameOriginal) not found "
                f"on device '{device.name}'",
            )
            # intName must NOT have been used as the interface name
            self.assertFalse(
                Interface.objects.filter(
                    device=device, name=iface_data["intName"]
                ).exists(),
                f"Interface '{iface_data['intName']}' (intName) should not exist — "
                f"nameOriginal '{iface_data['nameOriginal']}' should have been used instead",
            )

    def test_interface_enabled_reflects_l1_state(self):
        """
        Verify Interface.enabled is True when l1=='up', False otherwise.

        The transform map template: {% if object.l1 == "up" %}True{% else %}False{% endif %}
        """
        fixture_data = self.load_fixture("/inventory/interfaces")

        # Pick one interface that is up and one that is down to keep test fast
        up_iface = next(
            (d for d in fixture_data if d["l1"] == "up" and d.get("nameOriginal")), None
        )
        down_iface = next(
            (d for d in fixture_data if d["l1"] != "up" and d.get("nameOriginal")), None
        )
        self.assertIsNotNone(
            up_iface,
            "interfaces fixture must contain at least one l1='up' entry with nameOriginal",
        )
        self.assertIsNotNone(
            down_iface,
            "interfaces fixture must contain at least one l1!='up' entry with nameOriginal",
        )

        for iface_data, expected_enabled in ((up_iface, True), (down_iface, False)):
            device = Device.objects.get(serial=iface_data["sn"])
            name = iface_data["nameOriginal"]
            iface = Interface.objects.get(device=device, name=name)
            self.assertEqual(
                iface.enabled,
                expected_enabled,
                f"Interface '{name}' enabled should be {expected_enabled} "
                f"(l1='{iface_data['l1']}')",
            )

    def test_interface_description_mapped(self):
        """
        Verify Interface.description is set from dscr, empty string when dscr is null.

        The transform map skips setting description when dscr.lower() == 'none'.
        """
        fixture_data = self.load_fixture("/inventory/interfaces")

        # Interface with a real description (not null and not the string 'none')
        described = next(
            (d for d in fixture_data if d.get("dscr") and d["dscr"].lower() != "none"),
            None,
        )
        self.assertIsNotNone(
            described,
            "interfaces fixture must contain at least one entry with a real dscr value",
        )
        device = Device.objects.get(serial=described["sn"])
        name = described["nameOriginal"] or described["intName"]
        iface = Interface.objects.get(device=device, name=name)
        self.assertEqual(
            iface.description,
            described["dscr"],
            f"Interface '{name}' description should be '{described['dscr']}'",
        )

        # Interface without a description (dscr is null, has a real nameOriginal)
        no_desc = next(
            (
                d
                for d in fixture_data
                if d.get("dscr") is None and d.get("nameOriginal")
            ),
            None,
        )
        self.assertIsNotNone(
            no_desc,
            "interfaces fixture must contain at least one entry with dscr=null and nameOriginal set",
        )
        device = Device.objects.get(serial=no_desc["sn"])
        name = no_desc["nameOriginal"]
        iface = Interface.objects.get(device=device, name=name)
        self.assertEqual(
            iface.description,
            "",
            f"Interface '{name}' description should be empty when dscr is null",
        )

    def test_interface_mtu_mapped(self):
        """Verify Interface.mtu is set directly from the fixture mtu field."""
        fixture_data = self.load_fixture("/inventory/interfaces")
        sample = fixture_data[0]

        device = Device.objects.get(serial=sample["sn"])
        name = sample["nameOriginal"]
        iface = Interface.objects.get(device=device, name=name)

        self.assertEqual(
            iface.mtu,
            sample["mtu"],
            f"Interface '{name}' mtu should be {sample['mtu']}",
        )

    def test_interface_speed_mapped(self):
        """
        Verify Interface.speed is set to speedValue // 1000, None when null.

        The transform map: speedValue|int // 1000  (or None if null/"unknown")
        """
        fixture_data = self.load_fixture("/inventory/interfaces")

        # Interface with a known speedValue (must have a nameOriginal for lookup)
        with_speed = next(
            (d for d in fixture_data if d.get("speedValue") and d.get("nameOriginal")),
            None,
        )
        self.assertIsNotNone(
            with_speed,
            "interfaces fixture must contain at least one entry with speedValue set and nameOriginal",
        )
        device = Device.objects.get(serial=with_speed["sn"])
        name = with_speed["nameOriginal"]
        iface = Interface.objects.get(device=device, name=name)
        expected_speed = with_speed["speedValue"] // 1000
        self.assertEqual(
            iface.speed,
            expected_speed,
            f"Interface '{name}' speed should be {expected_speed}",
        )

        # Interface with null speedValue (must have a nameOriginal for lookup)
        no_speed = next(
            (
                d
                for d in fixture_data
                if d.get("speedValue") is None and d.get("nameOriginal")
            ),
            None,
        )
        self.assertIsNotNone(
            no_speed,
            "interfaces fixture must contain at least one entry with speedValue=null and nameOriginal",
        )
        device = Device.objects.get(serial=no_speed["sn"])
        name = no_speed["nameOriginal"]
        iface = Interface.objects.get(device=device, name=name)
        self.assertIsNone(
            iface.speed,
            f"Interface '{name}' speed should be None when speedValue is null",
        )

    def test_interface_mgmt_only_when_primary_ip_matches_login_ip(self):
        """
        Verify Interface.mgmt_only=True only when primaryIp equals the device loginIp.

        _preprocess_interfaces injects loginIp (the device's loginIpv4) into each
        interface record. The transform map then sets mgmt_only=True when
        primaryIp == loginIp.

        From the fixture:
        - Gi1/0/5 on FDO1552Y0SM has primaryIp=172.16.58.11; loginIpv4=10.0.128.6 → False
        - Vl128 on FDO1552Y0SM has primaryIp=10.0.128.6;  loginIpv4=10.0.128.6 → True
        """
        cisco_sn = "FDO1552Y0SM"
        device = Device.objects.get(serial=cisco_sn)

        # intName="Vl128" → nameOriginal="Vlan128"; primaryIp=10.0.128.6 == loginIpv4
        mgmt_iface = Interface.objects.get(device=device, name="Vlan128")
        self.assertTrue(
            mgmt_iface.mgmt_only,
            "Vlan128 should be mgmt_only=True because its primaryIp matches loginIpv4",
        )

        # intName="Gi1/0/5" → nameOriginal="GigabitEthernet1/0/5"; primaryIp=172.16.58.11 != loginIpv4
        non_mgmt_iface = Interface.objects.get(
            device=device, name="GigabitEthernet1/0/5"
        )
        self.assertFalse(
            non_mgmt_iface.mgmt_only,
            "GigabitEthernet1/0/5 should be mgmt_only=False because its primaryIp differs from loginIpv4",
        )

    def test_interface_name_falls_back_to_intName_when_nameOriginal_null(self):
        """
        Verify Interface.name falls back to intName when nameOriginal is null.

        Transform map template:
          {% if object.nameOriginal %}{{ object.nameOriginal }}{% else %}{{ object.intName }}{% endif %}

        The fixture has a 'NullOriginal1' entry with nameOriginal=null; the stored
        name must be 'NullOriginal1' (the intName value).
        """
        device = Device.objects.get(serial="SSJ17151159")
        self.assertTrue(
            Interface.objects.filter(device=device, name="NullOriginal1").exists(),
            "Interface 'NullOriginal1' (intName fallback when nameOriginal=null) not found",
        )

    def test_interface_type_is_always_1000base_t(self):
        """
        Verify Interface.type is always '1000base-t' regardless of media field.

        The transform map has a hardcoded template: '1000base-t'
        """
        self.assertGreater(
            Interface.objects.count(), 0, "at least one Interface must exist"
        )
        for iface in Interface.objects.all():
            self.assertEqual(
                iface.type,
                "1000base-t",
                f"Interface '{iface.name}' type should be '1000base-t', got '{iface.type}'",
            )

    def test_interface_duplex_unknown_maps_to_none(self):
        """
        Verify Interface.duplex=None when duplex field is 'unknown'.

        Transform map template:
          {% if not object.duplex or object.duplex=='unknown' %}None{% else %}{{ object.duplex }}{% endif %}

        The fixture 'NullOriginal1' entry has duplex='unknown'.
        """
        device = Device.objects.get(serial="SSJ17151159")
        iface = Interface.objects.get(device=device, name="NullOriginal1")
        self.assertIsNone(
            iface.duplex,
            "Interface 'NullOriginal1' with duplex='unknown' should have duplex=None",
        )

    def test_interface_mtu_defaults_to_1500_when_null(self):
        """
        Verify Interface.mtu defaults to 1500 when mtu field is null.

        Transform map template: {{ object.mtu or 1500 }}

        The fixture 'NullOriginal1' entry has mtu=null.
        """
        device = Device.objects.get(serial="SSJ17151159")
        iface = Interface.objects.get(device=device, name="NullOriginal1")
        self.assertEqual(
            iface.mtu,
            1500,
            "Interface 'NullOriginal1' with mtu=null should default to mtu=1500",
        )

    def test_interface_dscr_string_none_maps_to_empty(self):
        """
        Verify Interface.description='' when dscr is the string 'none'.

        Transform map template:
          {% if (object.dscr | lower) != 'none' %}{{ object.dscr or '' }}{% endif %}

        The fixture 'GigabitEthernet1/0/99' entry has dscr='none' (literal string).
        """
        device = Device.objects.get(serial="FDO1552Y0SM")
        iface = Interface.objects.get(device=device, name="GigabitEthernet1/0/99")
        self.assertEqual(
            iface.description,
            "",
            "Interface with dscr='none' (string) should have description=''",
        )

    def test_interface_device_relationship(self):
        """Verify every Interface is linked to the correct Device via sn."""
        fixture_data = self.load_fixture("/inventory/interfaces")
        self.assertGreater(len(fixture_data), 0, "interfaces fixture must be non-empty")

        for iface_data in fixture_data:
            expected_name = (
                iface_data["nameOriginal"]
                if iface_data.get("nameOriginal")
                else iface_data["intName"]
            )
            device = Device.objects.get(serial=iface_data["sn"])
            self.assertTrue(
                Interface.objects.filter(device=device, name=expected_name).exists(),
                f"Interface '{expected_name}' on device '{device.name}' not found",
            )

    # ==================== MAC Address Tests ====================

    def test_mac_addresses_created(self):
        """
        Verify MACAddress records are created only for interfaces with non-null mac.

        _preprocess_interfaces creates a macaddress record for every interface row,
        but the transform map template falls back to 00:00:00:00:00:01 when mac is
        null — so every interface still produces a MACAddress. Count must equal
        total interface fixture entries.
        """
        fixture_data = self.load_fixture("/inventory/interfaces")
        expected_count = len(fixture_data)

        actual_count = MACAddress.objects.count()
        self.assertEqual(
            actual_count,
            expected_count,
            f"Expected {expected_count} MAC addresses, found {actual_count}",
        )

    def test_mac_address_format_colon_two_uppercase(self):
        """
        Verify MAC addresses are stored in colon-separated two-digit uppercase format.

        The transform map template applies mac_to_format(frmt="MAC_COLON_TWO") | upper.
        Example: d4a0.2a06.6604 -> D4:A0:2A:06:66:04
        """
        # Pick the first Cisco interface that has an explicit mac
        cisco_sn = "FDO1552Y0SM"
        device = Device.objects.get(serial=cisco_sn)
        # intName="Gi1/0/4" → nameOriginal="GigabitEthernet1/0/4"; mac="d4a0.2a06.6604"
        iface = Interface.objects.get(device=device, name="GigabitEthernet1/0/4")
        mac_obj = MACAddress.objects.get(
            assigned_object_id=iface.pk,
            assigned_object_type__model="interface",
        )
        self.assertEqual(
            str(mac_obj.mac_address).upper(),
            "D4:A0:2A:06:66:04",
            "MAC address should be formatted as D4:A0:2A:06:66:04, got "
            + str(mac_obj.mac_address),
        )

    def test_mac_address_null_falls_back_to_placeholder(self):
        """
        Verify interfaces with mac=null get the placeholder MAC 00:00:00:00:00:01.

        Lo0 / Lo58 on the Cisco device have mac=null in the fixture; their
        nameOriginal values are Loopback0 / Loopback58.
        The template: {% if object.mac %}...{% else %}{{ "00:00:00:00:00:01" ... }}{% endif %}
        """
        cisco_sn = "FDO1552Y0SM"
        device = Device.objects.get(serial=cisco_sn)

        # intName="Lo0" → nameOriginal="Loopback0"; intName="Lo58" → nameOriginal="Loopback58"
        for loopback in ("Loopback0", "Loopback58"):
            iface = Interface.objects.get(device=device, name=loopback)
            mac_obj = MACAddress.objects.get(
                assigned_object_id=iface.pk,
                assigned_object_type__model="interface",
            )
            self.assertEqual(
                str(mac_obj.mac_address).upper(),
                "00:00:00:00:00:01",
                f"{loopback} with null mac should have placeholder MAC, "
                f"got {mac_obj.mac_address}",
            )

    def test_mac_address_assigned_to_correct_interface(self):
        """Verify each MACAddress is assigned to the interface that owns it."""
        fixture_data = self.load_fixture("/inventory/interfaces")
        candidates = [d for d in fixture_data if d.get("mac") and d.get("nameOriginal")]
        self.assertGreater(
            len(candidates),
            0,
            "interfaces fixture must have at least one entry with both mac and nameOriginal set",
        )
        for iface_data in candidates:
            device = Device.objects.get(serial=iface_data["sn"])
            name = iface_data["nameOriginal"]
            iface = Interface.objects.get(device=device, name=name)
            self.assertTrue(
                MACAddress.objects.filter(
                    assigned_object_id=iface.pk,
                    assigned_object_type__model="interface",
                ).exists(),
                f"Interface '{name}' should have an assigned MACAddress",
            )

    # ==================== VRF Tests ====================

    def test_vrfs_created(self):
        """Verify unique VRF names from fixture are created as VRF records."""
        fixture_data = self.load_fixture("/technology/routing/vrf/detail")
        expected_names = {d["vrf"] for d in fixture_data}
        expected_count = len(expected_names)

        actual_count = VRF.objects.count()
        self.assertEqual(
            actual_count,
            expected_count,
            f"Expected {expected_count} VRFs, found {actual_count}",
        )

    def test_vrf_names_match_fixture(self):
        """Verify VRF.name is set directly from the vrf field."""
        fixture_data = self.load_fixture("/technology/routing/vrf/detail")
        self.assertGreater(len(fixture_data), 0, "vrf fixture must be non-empty")
        for vrf_data in fixture_data:
            self.assertTrue(
                VRF.objects.filter(name=vrf_data["vrf"]).exists(),
                f"VRF '{vrf_data['vrf']}' missing from NetBox",
            )

    def test_vrf_rd_mapped(self):
        """
        Verify VRF.rd is None when the fixture rd field is null.

        Transform map template: source_field=rd, template="" (pass-through).
        """
        fixture_data = self.load_fixture("/technology/routing/vrf/detail")
        null_rd_entries = [d for d in fixture_data if d.get("rd") is None]
        self.assertGreater(
            len(null_rd_entries),
            0,
            "vrf fixture must contain at least one entry with rd=null",
        )
        for vrf_data in null_rd_entries:
            vrf_obj = VRF.objects.get(name=vrf_data["vrf"])
            self.assertIsNone(
                vrf_obj.rd,
                f"VRF '{vrf_data['vrf']}' rd should be None (fixture rd is null)",
            )

    def test_vrf_rd_non_null_value_stored(self):
        """
        Verify VRF.rd is set to the fixture rd value when rd is not null.

        The fixture 'with-rd' VRF has rd='65000:100'.
        """
        vrf_obj = VRF.objects.get(name="with-rd")
        self.assertEqual(
            vrf_obj.rd,
            "65000:100",
            "VRF 'with-rd' rd should be '65000:100', got '" + str(vrf_obj.rd) + "'",
        )

    # ==================== VLAN Tests ====================

    def test_vlans_created(self):
        """Verify every fixture VLAN entry creates one VLAN record in NetBox."""
        fixture_data = self.load_fixture("/technology/vlans/site-summary")
        expected_count = len(fixture_data)

        actual_count = VLAN.objects.count()
        self.assertEqual(
            actual_count,
            expected_count,
            f"Expected {expected_count} VLANs, found {actual_count}",
        )

    def test_vlan_vid_matches_fixture(self):
        """Verify VLAN.vid is set from the vlanId field."""
        fixture_data = self.load_fixture("/technology/vlans/site-summary")
        self.assertGreater(len(fixture_data), 0, "vlans fixture must be non-empty")
        for vlan_data in fixture_data:
            self.assertTrue(
                VLAN.objects.filter(vid=vlan_data["vlanId"]).exists(),
                f"VLAN with vid={vlan_data['vlanId']} missing from NetBox",
            )

    def test_vlan_name_matches_fixture(self):
        """
        Verify VLAN.name is set from vlanName (truncated to 64 chars).

        The transform map skips setting name when vlanName is 'none' or ''.
        """
        fixture_data = self.load_fixture("/technology/vlans/site-summary")
        named_vlans = [
            d
            for d in fixture_data
            if d.get("vlanName") and d["vlanName"].lower() != "none"
        ]
        self.assertGreater(
            len(named_vlans),
            0,
            "vlans fixture must have at least one entry with a real vlanName",
        )
        for vlan_data in named_vlans:
            expected_name = vlan_data["vlanName"][:64]
            vlan = VLAN.objects.get(vid=vlan_data["vlanId"])
            self.assertEqual(
                vlan.name,
                expected_name,
                f"VLAN {vlan_data['vlanId']} name should be '{expected_name}', "
                f"got '{vlan.name}'",
            )

    def test_vlan_description_mapped(self):
        """
        Verify VLAN.description is set from dscr; empty string when dscr is null.

        Transform map template:
          {% if (object.dscr | lower) != 'none' %}{{ object.dscr or '' }}{% endif %}
        """
        fixture_data = self.load_fixture("/technology/vlans/site-summary")
        self.assertGreater(len(fixture_data), 0, "vlans fixture must be non-empty")
        for vlan_data in fixture_data:
            vlan = VLAN.objects.get(vid=vlan_data["vlanId"])
            dscr = vlan_data.get("dscr")
            if dscr:
                self.assertEqual(
                    vlan.description,
                    dscr,
                    f"VLAN {vlan_data['vlanId']} description should be '{dscr}', "
                    f"got '{vlan.description}'",
                )
            else:
                self.assertEqual(
                    vlan.description,
                    "",
                    f"VLAN {vlan_data['vlanId']} description should be '' when dscr=null",
                )

    def test_vlan_name_string_none_stored_as_empty(self):
        """
        Verify VLAN.name is stored as '\"\"' (empty-looking) when vlanName='none'.

        Transform map template:
          {% if object.vlanName is defined and object.vlanName | lower != 'none' %}
            {{ object.vlanName | string | truncate(64, True) }}
          {% else %}\"\"{% endif %}

        VLAN 4000 in the fixture has vlanName='none'.
        """
        vlan = VLAN.objects.get(vid=4000)
        # The template outputs literal '""' — NetBox stores it as the two-char string
        self.assertNotEqual(
            vlan.name.lower(),
            "none",
            "VLAN 4000 name should not be 'none' — template must replace it",
        )

    def test_vlan_site_relationship(self):
        """Verify every VLAN is linked to the Site matching its siteName."""
        fixture_data = self.load_fixture("/technology/vlans/site-summary")
        self.assertGreater(len(fixture_data), 0, "vlans fixture must be non-empty")
        for vlan_data in fixture_data:
            vlan = VLAN.objects.get(vid=vlan_data["vlanId"])
            self.assertIsNotNone(
                vlan.site,
                f"VLAN {vlan_data['vlanId']} should have a site assigned",
            )
            self.assertEqual(
                vlan.site.name,
                vlan_data["siteName"],
                f"VLAN {vlan_data['vlanId']} should be in site '{vlan_data['siteName']}', "
                f"got '{vlan.site.name}'",
            )

    # ==================== Prefix Tests ====================

    def test_prefixes_created(self):
        """
        Verify the correct number of Prefix records are created.

        The Prefix transform map has coalesce=true on the prefix field, so
        multiple fixture entries sharing the same net value are merged into a
        single record. The expected count is the number of unique net values.

        In the fixture, 10.0.128.0/24 appears 3 times (vrfs '__oob-management',
        'system', 'main'), so 14 entries collapse to 12 unique prefixes.
        """
        fixture_data = self.load_fixture("/technology/networks/managed-networks")
        expected_count = len({d["net"] for d in fixture_data})

        actual_count = Prefix.objects.count()
        self.assertEqual(
            actual_count,
            expected_count,
            f"Expected {expected_count} prefixes (unique nets), found {actual_count}",
        )

    def test_prefix_network_matches_fixture(self):
        """Verify a Prefix record exists for every unique net value in the fixture."""
        fixture_data = self.load_fixture("/technology/networks/managed-networks")
        unique_nets = {d["net"] for d in fixture_data}
        self.assertGreater(
            len(unique_nets), 0, "managed-networks fixture must be non-empty"
        )
        for net in unique_nets:
            self.assertTrue(
                Prefix.objects.filter(prefix=net).exists(),
                f"Prefix '{net}' missing from NetBox",
            )

    def test_prefix_site_scope_relationship(self):
        """
        Verify Prefix.scope points to the Site matching its siteName.

        All fixture entries share the same siteName so every prefix must
        have that site as its scope. Duplicated nets are queried with
        filter() to avoid MultipleObjectsReturned.
        """
        fixture_data = self.load_fixture("/technology/networks/managed-networks")
        self.assertGreater(
            len(fixture_data), 0, "managed-networks fixture must be non-empty"
        )
        # Use unique nets only — duplicates coalesce to one record
        seen = set()
        for prefix_data in fixture_data:
            net = prefix_data["net"]
            if net in seen:
                continue
            seen.add(net)

            prefix = Prefix.objects.get(prefix=net)
            self.assertIsNotNone(
                prefix.scope,
                f"Prefix '{net}' should have a scope (site) assigned",
            )
            self.assertEqual(
                prefix.scope.name,
                prefix_data["siteName"],
                f"Prefix '{net}' scope should be site '{prefix_data['siteName']}', "
                f"got '{prefix.scope.name}'",
            )

    def test_prefix_scope_type_is_dcim_site(self):
        """
        Verify Prefix.scope_type ContentType is dcim.site for every prefix.

        Transform map relationship_map:
          template: contenttypes.ContentType.objects.get(app_label="dcim", model="site").pk
        All fixture prefixes have a siteName, so every prefix must have scope_type=dcim.site.
        """
        site_ct = ContentType.objects.get(app_label="dcim", model="site")
        self.assertGreater(Prefix.objects.count(), 0, "at least one Prefix must exist")
        for prefix in Prefix.objects.all():
            self.assertEqual(
                prefix.scope_type,
                site_ct,
                f"Prefix '{prefix.prefix}' scope_type should be dcim.site, "
                f"got '{prefix.scope_type}'",
            )

    def test_prefix_vrf_assigned_for_known_vrfs(self):
        """
        Verify Prefix.vrf is set when the vrf name was synced as a VRF record,
        and is None when the vrf value is in the global list or was not synced.

        The transform map: VRF.objects.filter(name=vrf).first() — returns None
        when the VRF was not synced (e.g. '__oob-management', 'main', 'TestVrf'
        are in the prefix fixture but absent from the VRF fixture).

        For duplicated nets (10.0.128.0/24) only the first unique occurrence is
        checked since coalesce produces a single Prefix record.
        """
        global_vrfs = {"", "system", "0", "global"}
        synced_vrf_names = set(VRF.objects.values_list("name", flat=True))

        fixture_data = self.load_fixture("/technology/networks/managed-networks")
        unique_entries = list({d["net"]: d for d in fixture_data}.values())

        null_vrf_entries = [
            d
            for d in unique_entries
            if str(d.get("vrf", "")) in global_vrfs
            or str(d.get("vrf", "")) not in synced_vrf_names
        ]
        assigned_vrf_entries = [
            d
            for d in unique_entries
            if str(d.get("vrf", "")) not in global_vrfs
            and str(d.get("vrf", "")) in synced_vrf_names
        ]
        self.assertGreater(
            len(null_vrf_entries),
            0,
            "managed-networks fixture must have at least one prefix with a global/unsynced vrf",
        )
        self.assertGreater(
            len(assigned_vrf_entries),
            0,
            "managed-networks fixture must have at least one prefix with a synced non-global vrf",
        )

        seen = set()
        for prefix_data in fixture_data:
            net = prefix_data["net"]
            if net in seen:
                continue
            seen.add(net)

            vrf_value = str(prefix_data.get("vrf", ""))
            prefix = Prefix.objects.get(prefix=net)

            if vrf_value in global_vrfs or vrf_value not in synced_vrf_names:
                self.assertIsNone(
                    prefix.vrf,
                    f"Prefix '{net}' with vrf='{vrf_value}' "
                    f"should have vrf=None (not synced or global)",
                )
            else:
                self.assertIsNotNone(
                    prefix.vrf,
                    f"Prefix '{net}' with vrf='{vrf_value}' should have a VRF assigned",
                )
                self.assertEqual(
                    prefix.vrf.name,
                    vrf_value,
                    f"Prefix '{net}' should be in VRF '{vrf_value}', "
                    f"got '{prefix.vrf.name}'",
                )

    # ==================== IP Address Tests ====================

    def test_ip_addresses_created(self):
        """
        Verify the correct number of IPAddress records are created.

        _preprocess_ipaddresses skips any IP whose sn+intName key has no entry
        in _readable_int_names (i.e. the interface was not synced). Expected count
        is the number of fixture IPs that have a matching interface entry.
        """
        fixture_data = self.load_fixture("/technology/addressing/managed-ip/ipv4")
        iface_fixture = self.load_fixture("/inventory/interfaces")
        readable = {
            f"{i['sn']}_{i['intName']}": i["nameOriginal"] for i in iface_fixture
        }
        expected_count = sum(
            1 for ip in fixture_data if readable.get(f"{ip['sn']}_{ip['intName']}")
        )
        self.assertGreater(
            expected_count, 0, "at least one IP must have a matching interface"
        )

        actual_count = IPAddress.objects.count()
        self.assertEqual(
            actual_count,
            expected_count,
            f"Expected {expected_count} IP addresses, found {actual_count}",
        )

    def test_ip_address_format_includes_prefix_length(self):
        """
        Verify IPAddress.address is stored as ip/mask, taken from net field.

        Transform map template:
          {% if object.net %}{% set MASK = object.net.split('/')[1] %}
          {% else %}{% set MASK = 32 %}{% endif %}{{ object.ip }}/{{ MASK }}

        Only IPs that have a matching interface entry are synced; unmatched ones
        are skipped by _preprocess_ipaddresses and must not be checked here.
        """
        fixture_data = self.load_fixture("/technology/addressing/managed-ip/ipv4")
        iface_fixture = self.load_fixture("/inventory/interfaces")
        readable = {
            f"{i['sn']}_{i['intName']}": i["nameOriginal"] for i in iface_fixture
        }
        matched = [d for d in fixture_data if readable.get(f"{d['sn']}_{d['intName']}")]
        self.assertGreater(
            len(matched), 0, "ipv4 fixture must have at least one matched IP"
        )

        for ip_data in matched:
            mask = ip_data["net"].split("/")[1] if ip_data.get("net") else "32"
            expected_address = f"{ip_data['ip']}/{mask}"
            self.assertTrue(
                IPAddress.objects.filter(address=expected_address).exists(),
                f"IPAddress '{expected_address}' missing from NetBox",
            )

    def test_ip_address_assigned_to_correct_interface(self):
        """
        Verify each IPAddress is assigned to the Interface matched by
        device serial + nameOriginal.
        """
        fixture_data = self.load_fixture("/technology/addressing/managed-ip/ipv4")
        iface_fixture = self.load_fixture("/inventory/interfaces")

        # Build sn+intName -> nameOriginal map (mirrors _readable_int_names)
        readable = {
            f"{i['sn']}_{i['intName']}": i["nameOriginal"] for i in iface_fixture
        }

        candidates = [
            d for d in fixture_data if readable.get(f"{d['sn']}_{d['intName']}")
        ]
        self.assertGreater(
            len(candidates),
            0,
            "ipv4 fixture must have at least one IP whose sn+intName matches an interface",
        )
        for ip_data in candidates:
            name_original = readable[f"{ip_data['sn']}_{ip_data['intName']}"]
            mask = ip_data["net"].split("/")[1] if ip_data.get("net") else "32"
            ip_obj = IPAddress.objects.get(address=f"{ip_data['ip']}/{mask}")

            expected_iface = Interface.objects.get(
                device__serial=ip_data["sn"], name=name_original
            )
            self.assertEqual(
                ip_obj.assigned_object_id,
                expected_iface.pk,
                f"IPAddress '{ip_data['ip']}' should be assigned to interface "
                f"'{name_original}', got pk {ip_obj.assigned_object_id}",
            )

    def test_ip_address_assigned_object_type_is_interface(self):
        """Verify all IPAddresses have assigned_object_type pointing at dcim.interface."""
        interface_ct = ContentType.objects.get(app_label="dcim", model="interface")
        non_interface = IPAddress.objects.exclude(assigned_object_type=interface_ct)
        self.assertEqual(
            non_interface.count(),
            0,
            f"{non_interface.count()} IPAddress records have wrong assigned_object_type",
        )

    def test_ip_address_vrf_assigned_for_non_global_vrfs(self):
        """
        Verify VRF is set for IPs whose vrf field is not in ['', 'system', '0', 'global'].

        Transform map template (data/transform_map.json):
          {% if object.vrf is defined and object.vrf | string not in ["", "system", "0", "global"] %}
            {{ ipam.VRF.objects.filter(name=object.vrf).first().pk }}
          {% else %}None{% endif %}

        The Arista fixture IPs carry named VRFs (default, tech, core, etc.).
        The Cisco IPs have vrf='' so they must have vrf=None.
        """
        fixture_data = self.load_fixture("/technology/addressing/managed-ip/ipv4")
        iface_fixture = self.load_fixture("/inventory/interfaces")
        readable = {
            f"{i['sn']}_{i['intName']}": i["nameOriginal"] for i in iface_fixture
        }
        matched = [d for d in fixture_data if readable.get(f"{d['sn']}_{d['intName']}")]
        global_vrfs = {"", "system", "0", "global"}

        global_vrf_entries = [
            d for d in matched if str(d.get("vrf", "")) in global_vrfs
        ]
        named_vrf_entries = [
            d for d in matched if str(d.get("vrf", "")) not in global_vrfs
        ]
        self.assertGreater(
            len(global_vrf_entries),
            0,
            "ipv4 fixture must have at least one matched IP with a global vrf (empty/system/0/global)",
        )
        self.assertGreater(
            len(named_vrf_entries),
            0,
            "ipv4 fixture must have at least one matched IP with a non-global named vrf",
        )

        for ip_data in matched:
            mask = ip_data["net"].split("/")[1] if ip_data.get("net") else "32"
            ip_obj = IPAddress.objects.get(address=f"{ip_data['ip']}/{mask}")
            vrf_value = str(ip_data.get("vrf", ""))

            if vrf_value in global_vrfs:
                self.assertIsNone(
                    ip_obj.vrf,
                    f"IPAddress '{ip_data['ip']}' with vrf='{vrf_value}' should have vrf=None",
                )
            else:
                self.assertIsNotNone(
                    ip_obj.vrf,
                    f"IPAddress '{ip_data['ip']}' with vrf='{vrf_value}' should have a VRF assigned",
                )
                self.assertEqual(
                    ip_obj.vrf.name,
                    vrf_value,
                    f"IPAddress '{ip_data['ip']}' should be in VRF '{vrf_value}', "
                    f"got '{ip_obj.vrf.name}'",
                )

    def test_ip_skipped_when_no_matching_interface(self):
        """
        Verify _preprocess_ipaddresses skips IPs whose intName has no entry
        in _readable_int_names.

        An IP entry whose sn+intName does not map to any synced interface must
        NOT appear as an IPAddress record.
        """
        fixture_data = self.load_fixture("/technology/addressing/managed-ip/ipv4")
        iface_fixture = self.load_fixture("/inventory/interfaces")

        readable = {
            f"{i['sn']}_{i['intName']}": i["nameOriginal"] for i in iface_fixture
        }

        matched = [d for d in fixture_data if readable.get(f"{d['sn']}_{d['intName']}")]
        unmatched = [
            d for d in fixture_data if not readable.get(f"{d['sn']}_{d['intName']}")
        ]
        self.assertGreater(
            len(matched),
            0,
            "ipv4 fixture must have at least one IP whose sn+intName matches an interface",
        )
        self.assertGreater(
            len(unmatched),
            0,
            "ipv4 fixture must have at least one IP whose sn+intName has NO matching interface "
            "(to exercise the skip branch)",
        )

        for ip_data in fixture_data:
            key = f"{ip_data['sn']}_{ip_data['intName']}"
            mask = ip_data["net"].split("/")[1] if ip_data.get("net") else "32"
            address = f"{ip_data['ip']}/{mask}"

            if readable.get(key):
                self.assertTrue(
                    IPAddress.objects.filter(address=address).exists(),
                    f"IPAddress '{address}' (key '{key}' matched) should exist",
                )
            else:
                self.assertFalse(
                    IPAddress.objects.filter(address=address).exists(),
                    f"IPAddress '{address}' (key '{key}' unmatched) should have been skipped",
                )

    def test_primary_ip_flag_set_for_login_ip(self):
        """
        Verify _preprocess_ipaddresses is_primary=True causes Device.primary_ip4
        to be set to the IP matching the device's login IP.

        _preprocess_devices resolves the login IP as:
            device.get("loginIp") or device.get("loginIpv4")

        From the fixtures:
          - Cisco  FDO1552Y0SM  loginIpv4=10.0.128.6  → Device.primary_ip4 = 10.0.128.6/24
          - Arista SSJ17151159  loginIp=10.0.128.51   → Device.primary_ip4 = 10.0.128.51/24
        """
        devices_fixture = self.load_fixture("/inventory/devices")
        candidates = [
            d for d in devices_fixture if d.get("loginIp") or d.get("loginIpv4")
        ]
        self.assertGreater(
            len(candidates),
            0,
            "devices fixture must have at least one entry with loginIp or loginIpv4 set",
        )
        for device_data in candidates:
            login_ip = device_data.get("loginIp") or device_data["loginIpv4"]
            device = Device.objects.get(serial=device_data["sn"])
            self.assertIsNotNone(
                device.primary_ip4,
                f"Device '{device_data['hostname']}' should have primary_ip4 set",
            )
            self.assertEqual(
                str(device.primary_ip4.address.ip),
                login_ip,
                f"Device '{device_data['hostname']}' primary_ip4 should be "
                f"'{login_ip}', got '{device.primary_ip4.address.ip}'",
            )

    def test_non_primary_ips_not_set_as_device_primary(self):
        """
        Verify that IPs which do not match a device's login IP are never
        assigned as Device.primary_ip4.

        _preprocess_devices resolves the login IP as:
            device.get("loginIp") or device.get("loginIpv4")
        """
        devices_fixture = self.load_fixture("/inventory/devices")
        with_login = [
            d for d in devices_fixture if d.get("loginIp") or d.get("loginIpv4")
        ]
        without_login = [
            d
            for d in devices_fixture
            if not d.get("loginIp") and not d.get("loginIpv4")
        ]
        self.assertGreater(
            len(with_login),
            0,
            "devices fixture must have at least one device with a login IP",
        )
        self.assertGreater(
            len(without_login),
            0,
            "devices fixture must have at least one device without a login IP "
            "(to exercise the primary_ip4=None branch)",
        )

        for device_data in devices_fixture:
            login_ip = device_data.get("loginIp") or device_data.get("loginIpv4")
            device = Device.objects.get(serial=device_data["sn"])

            if login_ip:
                self.assertEqual(
                    str(device.primary_ip4.address.ip),
                    login_ip,
                    f"Device '{device_data['hostname']}' primary_ip4 must be "
                    f"'{login_ip}', got '{device.primary_ip4.address.ip}'",
                )
            else:
                self.assertIsNone(
                    device.primary_ip4,
                    f"Device '{device_data['hostname']}' has no login IP "
                    f"so primary_ip4 should be None",
                )

    def test_ip_address_mask_defaults_to_32_when_net_null(self):
        """
        Verify IPAddress mask defaults to /32 when net field is null/missing.

        Transform map template:
          {% if object.net %}{% set MASK = object.net.split('/')[1] %}
          {% else %}{% set MASK = 32 %}{% endif %}{{ object.ip }}/{{ MASK }}
        """
        # Add a fixture entry with net=null to cover this branch
        fixture_data = self.load_fixture("/technology/addressing/managed-ip/ipv4")
        no_net_entries = [d for d in fixture_data if not d.get("net")]
        self.assertGreater(
            len(no_net_entries),
            0,
            "ipv4 fixture must contain at least one entry with net=null "
            "to exercise the /32 default mask branch",
        )
        for ip_data in no_net_entries:
            expected = f"{ip_data['ip']}/32"
            self.assertTrue(
                IPAddress.objects.filter(address=expected).exists(),
                f"IPAddress '{expected}' with /32 default mask not found",
            )

    # ==================== Statistics Helpers ====================

    def _make_fake_changes(self, ingestion, changes_per_model=2):
        """
        Build a list of fake BranchObjectChange mocks and a matching Counter of
        expected per-model totals, derived from the enabled models in
        ingestion.sync.parameters.  No real branch schema is required.
        """
        enabled_models = [
            m for m, v in ingestion.sync.parameters.items() if v is True and "." in m
        ]
        fake_changes = []
        expected_counts: Counter = Counter()
        for model_string in enabled_models:
            app_label, model_name = model_string.split(".", 1)
            for _ in range(changes_per_model):
                mock_ct = MagicMock()
                mock_ct.app_label = app_label
                mock_ct.model = model_name
                mock_ct.natural_key.return_value = (app_label, model_name)
                mock_ct.model_class.return_value = ContentType.objects.get(
                    app_label=app_label, model=model_name
                ).model_class()
                fc = MagicMock(spec=BranchObjectChange)
                fc.changed_object_type = mock_ct
                fc.apply.return_value = None
                fc.request_id = "test-request-id"
                fc.user = ingestion.sync.user
                fake_changes.append(fc)
            expected_counts[model_string] += changes_per_model
        return fake_changes, expected_counts

    def _make_fake_qs(self, fake_changes):
        """
        Wrap *fake_changes* in a mock queryset whose .order_by() chain satisfies
        the calls merge_branch makes:
          changes = qs.order_by("time")
          changes.count(), changes.values_list(...), for change in changes
        """
        vl_data = [
            (fc.changed_object_type.app_label, fc.changed_object_type.model)
            for fc in fake_changes
        ]
        ordered_qs = MagicMock()
        ordered_qs.count.return_value = len(fake_changes)
        ordered_qs.values_list.return_value = vl_data
        ordered_qs.__iter__ = MagicMock(return_value=iter(fake_changes))
        fake_qs = MagicMock()
        fake_qs.order_by.return_value = ordered_qs
        return fake_qs

    def _run_fake_merge(self, ingestion, branch, merge_logger, fake_qs=None):
        """
        Call ingestion.sync_merge() with all branch/DB interactions mocked out.
        The caller is responsible for setting ingestion.sync.logger beforehand.
        Pass *fake_qs* to override the default queryset (built from 2 changes
        per model).
        """

        def fake_get_job_logs(job):
            return merge_logger.log_data

        if fake_qs is None:
            fake_changes, _ = self._make_fake_changes(ingestion)
            fake_qs = self._make_fake_qs(fake_changes)

        with (
            patch.object(branch, "get_unmerged_changes", return_value=fake_qs),
            patch.object(
                type(branch),
                "ready",
                new_callable=lambda: property(lambda self: True),
            ),
            patch.object(ingestion, "get_job_logs", fake_get_job_logs),
            patch("netbox_branching.models.Branch.objects") as mock_branch_mgr,
            patch("ipfabric_netbox.utilities.merge.BranchEvent.objects") as mock_be,
            patch("ipfabric_netbox.utilities.merge.post_merge"),
            patch.object(branch, "save"),
            patch("ipfabric_netbox.utilities.merge.record_applied_change"),
            patch(
                "ipfabric_netbox.utilities.merge.get_merge_strategy"
            ) as mock_strategy,
            patch("ipfabric_netbox.utilities.merge.connection"),
        ):
            mock_branch_mgr.filter.return_value.update.return_value = None
            mock_be.create.return_value = None
            mock_strategy.return_value.return_value._clean = MagicMock()
            ingestion.sync_merge()

    # ==================== Statistics Tests ====================

    def test_sync_statistics_populated_after_sync(self):
        """
        Verify sync stage statistics are non-empty after sync and each value is a
        valid percentage (0–100).  We read directly from the in-memory logger
        that sync() attached to self.sync so the test is not sensitive to
        cache expiry.
        """
        raw_stats = self.sync.logger.log_data.get("statistics", {})
        self.assertGreater(
            len(raw_stats), 0, "sync stage statistics must be non-empty after sync"
        )
        for model, counts in raw_stats.items():
            total = counts["total"]
            current = counts["current"]
            self.assertGreater(total, 0, f"{model}: total must be > 0")
            self.assertGreaterEqual(current, 0, f"{model}: current must be >= 0")
            self.assertLessEqual(current, total, f"{model}: current must be <= total")

    def test_merge_statistics_empty_before_merge(self):
        """
        Verify statistics for merge stage is empty when no merge job has run yet.
        The ingestion created in setUpTestData has no merge_job.
        """
        data = self.ingestion.get_statistics(stage="merge")
        self.assertEqual(
            data["statistics"],
            {},
            "merge stage statistics must be empty when merge_job is not set",
        )

    def test_merge_statistics_populated_after_merge(self):
        """
        Run sync_merge() with all branch/DB calls mocked, verify that
        merge stage statistics are non-empty and every model reaches 100 %.
        """
        ingestion = self.ingestion
        branch = ingestion.branch
        self.assertIsNotNone(branch, "ingestion must have a branch for merge test")

        merge_logger = SyncLogging(job=99999)
        ingestion.sync.logger = merge_logger

        self._run_fake_merge(ingestion, branch, merge_logger)

        raw_merge = merge_logger.log_data.get("statistics", {})
        self.assertGreater(
            len(raw_merge),
            0,
            "merge stage statistics must be non-empty after sync_merge()",
        )
        for model, counts in raw_merge.items():
            self.assertEqual(
                counts["current"],
                counts["total"],
                f"{model}: current should equal total after full merge, got {counts}",
            )

    def test_merge_statistics_totals_match_branch_changes(self):
        """
        Verify raw merge stage statistics totals match the per-model change counts
        passed to merge_branch via get_unmerged_changes().
        """
        ingestion = self.ingestion
        branch = ingestion.branch
        self.assertIsNotNone(branch, "ingestion must have a branch")

        fake_changes, expected_counts = self._make_fake_changes(
            ingestion, changes_per_model=3
        )
        fake_qs = self._make_fake_qs(fake_changes)

        merge_logger = SyncLogging(job=99998)
        ingestion.sync.logger = merge_logger

        self._run_fake_merge(ingestion, branch, merge_logger, fake_qs=fake_qs)

        raw_merge = merge_logger.log_data.get("statistics", {})
        self.assertGreater(
            len(raw_merge), 0, "merge stage statistics must be non-empty"
        )
        for model_string, expected_total in expected_counts.items():
            self.assertIn(
                model_string,
                raw_merge,
                f"{model_string} missing from merge stage statistics",
            )
            self.assertEqual(
                raw_merge[model_string]["total"],
                expected_total,
                f"{model_string}: expected total={expected_total}, got {raw_merge[model_string]['total']}",
            )

    # ==================== Utility Methods ====================

    def get_synced_object(self, model_class, **lookup_kwargs):
        """
        Helper to retrieve a synced NetBox object.

        Args:
            model_class: Django model class
            **lookup_kwargs: Lookup parameters

        Returns:
            Model instance or None
        """
        try:
            return model_class.objects.get(**lookup_kwargs)
        except model_class.DoesNotExist:
            return None
        except model_class.MultipleObjectsReturned:
            return model_class.objects.filter(**lookup_kwargs).first()
