import logging
import urllib.parse

import requests
from django.core.cache import cache
from netbox.plugins import get_plugin_config

# HTTP request timeout constants (in seconds)
DEFAULT_API_TIMEOUT = 10
EXTENDED_API_TIMEOUT = 20  # For endpoints that may take longer (e.g., device listing)

logger = logging.getLogger(__name__)


class LibreNMSAPI:
    """
    Client to interact with the LibreNMS API and retrieve interface data for devices.
    """

    def __init__(self, server_key=None):
        """
        Initialize LibreNMS API client with support for multiple servers.

        Args:
            server_key: Key for specific server configuration. If None, uses selected server or default.
        """
        # If no server_key is provided, try to get the selected server from settings
        if not server_key:
            try:
                from netbox_librenms_plugin.models import LibreNMSSettings

                settings = LibreNMSSettings.objects.first()
                if settings:
                    server_key = settings.selected_server
            except (ImportError, AttributeError):
                pass

        # Default to 'default' if still no server_key
        server_key = server_key or "default"

        # Get server configuration
        servers_config = get_plugin_config("netbox_librenms_plugin", "servers")

        # If the requested server_key doesn't exist but there are configured servers,
        # only fall back to the first available server when using the auto-default key.
        # If a specific (non-default) server_key was requested but not found, raise
        # immediately to avoid silently using the wrong LibreNMS instance.
        if servers_config and isinstance(servers_config, dict) and server_key not in servers_config:
            if server_key != "default":
                available = list(servers_config.keys())
                raise KeyError(
                    f"Server '{server_key}' not found in LibreNMS plugin configuration. Available servers: {available}"
                )
            first_key = next(iter(servers_config), None)
            if first_key:
                logger.info(
                    "Server '%s' not found in config, falling back to '%s'",
                    server_key,
                    first_key,
                )
                server_key = first_key

        self.server_key = server_key

        if servers_config and isinstance(servers_config, dict) and server_key in servers_config:
            # Multi-server configuration
            config = servers_config[server_key]
            self.librenms_url = config["librenms_url"]
            self.api_token = config["api_token"]
            self.cache_timeout = config.get("cache_timeout", 300)
            self.verify_ssl = config.get("verify_ssl", True)
        else:
            # Fallback to legacy single-server configuration
            self.librenms_url = get_plugin_config("netbox_librenms_plugin", "librenms_url")
            self.api_token = get_plugin_config("netbox_librenms_plugin", "api_token")
            self.cache_timeout = get_plugin_config("netbox_librenms_plugin", "cache_timeout", 300)
            self.verify_ssl = get_plugin_config("netbox_librenms_plugin", "verify_ssl", True)

        if not self.librenms_url or not self.api_token:
            raise ValueError(f"LibreNMS URL or API token is not configured for server '{server_key}'.")

        self.headers = {"X-Auth-Token": self.api_token}

    def test_connection(self):
        """
        Test connection to LibreNMS server by calling the /system endpoint.

        Returns:
            dict: System information if successful, error dict if failed
        """
        try:
            response = requests.get(
                f"{self.librenms_url}/api/v0/system",
                headers=self.headers,
                verify=self.verify_ssl,
                timeout=DEFAULT_API_TIMEOUT,
            )

            if response.status_code == 200:
                data = response.json()
                if data.get("status") == "ok" and data.get("system"):
                    return data["system"][0] if data["system"] else None

            # Handle different HTTP status codes with user-friendly messages
            if response.status_code == 401:
                return {
                    "error": True,
                    "message": "Authentication failed - check API token",
                }
            elif response.status_code == 403:
                return {
                    "error": True,
                    "message": "Access forbidden - check API token permissions",
                }
            elif response.status_code == 404:
                return {
                    "error": True,
                    "message": "API endpoint not found - check LibreNMS URL",
                }
            elif response.status_code >= 500:
                return {
                    "error": True,
                    "message": "LibreNMS server error - check server status",
                }
            else:
                return {
                    "error": True,
                    "message": f"HTTP {response.status_code} - unexpected server response",
                }

        except requests.exceptions.SSLError:
            return {
                "error": True,
                "message": "SSL certificate verification failed - try setting verify_ssl to false",
            }
        except requests.exceptions.ConnectionError:
            return {
                "error": True,
                "message": "Connection failed - check server URL and network connectivity",
            }
        except requests.exceptions.Timeout:
            return {
                "error": True,
                "message": "Connection timeout - server may be slow or unreachable",
            }
        except Exception as e:
            return {"error": True, "message": f"Unexpected error: {str(e)}"}

    @classmethod
    def get_available_servers(cls):
        """
        Get list of available server configurations.

        Returns:
            dict: Dictionary of server keys and their display names
        """
        servers_config = get_plugin_config("netbox_librenms_plugin", "servers")

        if servers_config and isinstance(servers_config, dict):
            # Multi-server configuration
            result = {}
            for key, config in servers_config.items():
                display_name = config.get("display_name", key)
                result[key] = display_name
            return result
        else:
            # Legacy single-server configuration
            legacy_url = get_plugin_config("netbox_librenms_plugin", "librenms_url")
            if legacy_url:
                return {"default": f"Default Server ({legacy_url})"}
            return {"default": "Default Server"}

    def get_librenms_id(self, obj):
        """
        Args:
            obj: NetBox device or VM object

        Returns:
            int: LibreNMS device ID if found, None otherwise

        Notes:
            Lookup order:
            1. Custom field 'librenms_id' on object
            2. Cached librenms_id value
            3. API lookup using:
                a. primary_ip
                b. primary IP's DNS name
                c. hostname if FQDN

            If found via API, stores ID in custom field if available,
            otherwise caches the value.
        """
        from netbox_librenms_plugin.utils import get_librenms_device_id

        librenms_id = get_librenms_device_id(obj, self.server_key, auto_save=False)
        if librenms_id is not None:
            return librenms_id

        # Check cache
        cache_key = self._get_cache_key(obj)
        librenms_id = cache.get(cache_key)
        if librenms_id is not None:
            return librenms_id

        # Determine dynamically from API
        ip_address = obj.primary_ip.address.ip if obj.primary_ip else None
        dns_name = obj.primary_ip.dns_name if obj.primary_ip else None
        hostname = obj.name if obj.name else None

        # Try IP address
        if ip_address:
            librenms_id = self._normalize_librenms_id(self.get_device_id_by_ip(ip_address))
            if librenms_id is not None:
                self._store_librenms_id(obj, librenms_id)
                return librenms_id

        # Try primary IP's DNS name
        if dns_name:
            librenms_id = self._normalize_librenms_id(self.get_device_id_by_hostname(dns_name))
            if librenms_id is not None:
                self._store_librenms_id(obj, librenms_id)
                return librenms_id

        # Try hostname if FQDN
        if hostname:
            librenms_id = self._normalize_librenms_id(self.get_device_id_by_hostname(hostname))
            if librenms_id is not None:
                self._store_librenms_id(obj, librenms_id)
                return librenms_id

        return None

    @staticmethod
    def _normalize_librenms_id(value):
        """Coerce a raw LibreNMS ID value to int or None.

        Booleans are rejected because bool is a subclass of int in Python,
        so int(True) silently becomes 1 — a valid-looking device ID.
        """
        if value is None or isinstance(value, bool):
            return None
        try:
            return int(value)
        except (ValueError, TypeError):
            return None

    def _get_cache_key(self, obj):
        """
        Generate a unique cache key for an object.

        Args:
            obj: NetBox device or VM object

        Returns:
            str: Cache key
        """
        object_type = obj._meta.model_name
        server_key = getattr(self, "server_key", "default")
        return f"librenms_device_id_{object_type}_{obj.pk}_{server_key}"

    def _store_librenms_id(self, obj, librenms_id):
        """
        Store in custom field if available

        Args:
            obj: NetBox device or VM object
            librenms_id: LibreNMS device ID

        Returns:
            None
        """
        if "librenms_id" in obj.cf:
            from netbox_librenms_plugin.utils import set_librenms_device_id

            set_librenms_device_id(obj, librenms_id, self.server_key)
            obj.save(update_fields=["custom_field_data"])
        else:
            # Use cache as fallback
            cache_key = self._get_cache_key(obj)
            cache.set(cache_key, librenms_id, timeout=self.cache_timeout)

    def get_device_id_by_ip(self, ip_address):
        """
        Retrieve the device ID using the device's IP address.

        Args:
            ip_address: Device IP address

        Retruns:
            int: LibreNMS device ID if found, None otherwise
        """
        try:
            response = requests.get(
                f"{self.librenms_url}/api/v0/devices/{ip_address}",
                headers=self.headers,
                timeout=DEFAULT_API_TIMEOUT,
                verify=self.verify_ssl,
            )
            response.raise_for_status()
            device_data = response.json()["devices"][0]
            return device_data["device_id"]
        except (requests.exceptions.RequestException, ValueError, IndexError, KeyError, TypeError):
            return None

    def get_device_id_by_hostname(self, hostname):
        """
        Retrieve the device ID using the device's hostname.

        Args:
            hostname: Device hostname

        Returns:
            int: LibreNMS device ID if found, None otherwise
        """
        try:
            response = requests.get(
                f"{self.librenms_url}/api/v0/devices/{hostname}",
                headers=self.headers,
                timeout=DEFAULT_API_TIMEOUT,
                verify=self.verify_ssl,
            )
            response.raise_for_status()
            device_data = response.json()["devices"][0]
            return device_data["device_id"]
        except (requests.exceptions.RequestException, ValueError, IndexError, KeyError, TypeError):
            return None

    def get_device_info(self, device_id):
        """
        Fetch device information from LibreNMS using its primary IP.

        Args:
            device_id: LibreNMS device ID

        Returns:
            tuple: (success: bool, data: dict)
        """

        try:
            response = requests.get(
                f"{self.librenms_url}/api/v0/devices/{device_id}",
                headers=self.headers,
                timeout=DEFAULT_API_TIMEOUT,
                verify=self.verify_ssl,
            )
            if response.status_code == 200:
                device_data = response.json()["devices"][0]
                if not isinstance(device_data, dict):
                    return False, None
                return True, device_data
            return False, None
        except (requests.exceptions.RequestException, ValueError, IndexError, KeyError, TypeError):
            return False, None

    def get_ports(self, device_id, with_vlans=True):
        """
        Fetch ports data from LibreNMS for a device using its primary IP.

        Includes VLAN assignment data (ifVlan, ifTrunk) for interface VLAN sync.
        When with_vlans=True, includes detailed VLAN associations (tagged/untagged)
        for all ports in a single API call (requires LibreNMS 24.2.0+).

        Args:
            device_id: LibreNMS device ID
            with_vlans: Include detailed VLAN data for all ports (default: True)

        Returns:
            tuple: (success: bool, data: dict)
        """
        try:
            params = {
                "columns": "port_id,ifName,ifType,ifSpeed,ifAdminStatus,ifDescr,ifAlias,ifPhysAddress,ifMtu,ifVlan,ifTrunk"
            }
            if with_vlans:
                params["with"] = "vlans"

            response = requests.get(
                f"{self.librenms_url}/api/v0/devices/{device_id}/ports",
                headers=self.headers,
                params=params,
                timeout=DEFAULT_API_TIMEOUT,
                verify=self.verify_ssl,
            )
            response.raise_for_status()
            data = response.json()
            return True, data
        except requests.exceptions.HTTPError as e:
            if e.response.status_code == 404:
                return False, "Device not found in LibreNMS"
            return False, f"HTTP error: {str(e)}"
        except requests.exceptions.RequestException as e:
            return False, f"Error connecting to LibreNMS: {str(e)}"

    def add_device(self, data):
        """
        Add a device to LibreNMS.

        Args:
            Dictionary containing device data including:
                - hostname: Device hostname or IP
                - snmp_version: SNMP version (v1, v2c, or v3)
                - force_add: Skip checks for duplicate device and SNMP reachability (optional, default False)
                - port: SNMP port (optional, defaults to config value)
                - transport: SNMP transport protocol (optional: udp, tcp, udp6, tcp6)
                - port_association_mode: Port identification method (optional: ifIndex, ifName, ifDescr, ifAlias)
                - poller_group: Poller group ID (optional, defaults to 0)
                - community: SNMP community string (for v1 or v2c)
                - authlevel, authname, authpass, authalgo, cryptopass, cryptoalgo: SNMP v3 parameters

        Returns:
            tuple: (success: bool, message: str)
        """
        payload = {
            "hostname": data["hostname"],
            "snmpver": data["snmp_version"],
            "force_add": data.get("force_add", False),
        }

        # Add optional common fields if provided
        if data.get("port"):
            payload["port"] = data["port"]
        if data.get("transport"):
            payload["transport"] = data["transport"]
        if data.get("port_association_mode"):
            payload["port_association_mode"] = data["port_association_mode"]
        if data.get("poller_group") is not None:
            payload["poller_group"] = data["poller_group"]

        if data["snmp_version"] in ("v1", "v2c"):
            payload["community"] = data["community"]
        elif data["snmp_version"] == "v3":
            payload.update(
                {
                    "authlevel": data["authlevel"],
                    "authname": data["authname"],
                    "authpass": data["authpass"],
                    "authalgo": data["authalgo"],
                    "cryptopass": data["cryptopass"],
                    "cryptoalgo": data["cryptoalgo"],
                }
            )

        try:
            response = requests.post(
                f"{self.librenms_url}/api/v0/devices",
                headers=self.headers,
                json=payload,
                timeout=EXTENDED_API_TIMEOUT,
                verify=self.verify_ssl,
            )
            response.raise_for_status()
            result = response.json()
            if result.get("status") == "ok":
                return True, "Device added successfully."
            else:
                return False, result.get("message", "Unknown error.")
        except requests.exceptions.RequestException as e:
            return False, str(e)

    def update_device_field(self, device_id, field_data):
        """
        Update a specific field for a device in LibreNMS.

        Args:
            device_id: LibreNMS device ID
            field_data: Dictionary containing field name and value

            e.g {
                    "field": ["location", "override_sysLocation"],
                    "data": [device.site.name, "1"]

        Returns:
            tuple (success: bool, message: str)
        """
        try:
            response = requests.patch(
                f"{self.librenms_url}/api/v0/devices/{device_id}",
                headers=self.headers,
                json=field_data,
                timeout=DEFAULT_API_TIMEOUT,
                verify=self.verify_ssl,
            )
            response.raise_for_status()

            result = response.json()

            if result.get("status") == "ok":
                return True, "Device fields updated successfully"
            else:
                return False, result.get("message", "Unknown error occurred")
        except requests.exceptions.RequestException as e:
            error_message = str(e)
            if hasattr(e.response, "json"):
                error_details = e.response.json()
                error_message = error_details.get("message", error_message)
            return False, error_message

    def get_locations(self):
        """
        Fetch locations data from LibreNMS.

        Args:
            None

        Returns:
            tuple: (success: bool, data: dict)
        """
        try:
            response = requests.get(
                f"{self.librenms_url}/api/v0/resources/locations",
                headers=self.headers,
                timeout=EXTENDED_API_TIMEOUT,
                verify=self.verify_ssl,
            )
            response.raise_for_status()
            result = response.json()

            if "locations" in result:
                return True, result["locations"]
            else:
                return False, "No locations found or unexpected response format"
        except requests.exceptions.RequestException as e:
            error_message = str(e)
            return False, error_message

    def add_location(self, location_data):
        """
        Add a location to LibreNMS.

        Args:
            location_data: Dictionary containing location data

            e.g location_data = {
                    "location": site.name,
                    "lat": str(site.latitude),
                    "lng": str(site.longitude)
                }

        Return:
            tuple: (success: bool, message: str)
        """
        try:
            response = requests.post(
                f"{self.librenms_url}/api/v0/locations",
                headers=self.headers,
                json=location_data,
                timeout=DEFAULT_API_TIMEOUT,
                verify=self.verify_ssl,
            )
            response.raise_for_status()

            result = response.json()
            if result.get("status") == "ok":
                location_id = result["message"].split("#")[-1]
                return True, {"id": location_id, "message": result["message"]}
            else:
                return False, result.get("message") or "Unexpected response format"
        except requests.exceptions.RequestException as e:
            error_message = str(e)
            if hasattr(e.response, "json"):
                error_details = e.response.json()
                error_message = error_details.get("message", error_message)
            return False, error_message

    def update_location(self, location_name, location_data):
        """
        Update a location in LibreNMS.

        Args:
            location_name: LibreNMS Location name
            location_data: Dictionary containing location data

            e.g location_data = {
                    "lat": str(site.latitude),
                    "lng": str(site.longitude)
                }

        Returns:
            tuple: (success: bool, message: str)
        """
        try:
            encoded_location_name = urllib.parse.quote(location_name)
            response = requests.patch(
                f"{self.librenms_url}/api/v0/locations/{encoded_location_name}",
                headers=self.headers,
                json=location_data,
                timeout=DEFAULT_API_TIMEOUT,
                verify=self.verify_ssl,
            )
            response.raise_for_status()
            result = response.json()
            if result.get("status") == "ok":
                return True, result["message"]
            else:
                return False, result.get("message") or "Unexpected response format"
        except requests.exceptions.RequestException as e:
            error_message = str(e)
            if hasattr(e.response, "json"):
                error_details = e.response.json()
                error_message = error_details.get("message", error_message)
            return False, error_message

    def get_device_links(self, device_id):
        """
        Get links for a specific device from LibreNMS.

        Args:
            hostname: LibreNMS Device ID

        Returns:
            tuple: (success: bool, data: dict)
        """
        try:
            response = requests.get(
                f"{self.librenms_url}/api/v0/devices/{device_id}/links",
                headers=self.headers,
                timeout=DEFAULT_API_TIMEOUT,
                verify=self.verify_ssl,
            )
            response.raise_for_status()
            return True, response.json()
        except requests.exceptions.RequestException as e:
            return False, str(e)

    def get_device_ips(self, device_id):
        """
        Fetch IP address data for a specific device from LibreNMS.

        Args:
            device_id: LibreNMS Device ID

        Returns:
            tuple: (success: bool, data: dict)
        """
        try:
            response = requests.get(
                f"{self.librenms_url}/api/v0/devices/{device_id}/ip",
                headers=self.headers,
                timeout=DEFAULT_API_TIMEOUT,
                verify=self.verify_ssl,
            )
            response.raise_for_status()
            data = response.json()
            addresses = data.get("addresses") if isinstance(data, dict) else None
            if not isinstance(addresses, list):
                message = data.get("message") if isinstance(data, dict) else None
                return False, message or "Unexpected response format: 'addresses' must be a list"
            return True, addresses
        except (requests.exceptions.RequestException, ValueError) as e:
            return False, str(e)

    def get_port_by_id(self, port_id):
        """
        Fetch specific port data from LibreNMS using port ID.

        Args:
            port_id: LibreNMS Port ID

        Returns:
            tuple: (success: bool, data: dict)
        """
        try:
            response = requests.get(
                f"{self.librenms_url}/api/v0/ports/{port_id}",
                headers=self.headers,
                timeout=DEFAULT_API_TIMEOUT,
                verify=self.verify_ssl,
            )
            response.raise_for_status()
            return True, response.json()
        except requests.exceptions.RequestException as e:
            return False, str(e)

    def get_device_inventory(self, device_id):
        """
        Fetch complete inventory for a device from LibreNMS.
        Useful for getting component details like chassis serial numbers for Virtual Chassis.

        Route: /api/v0/inventory/{device_id}/all

        Args:
            device_id: LibreNMS device ID

        Returns:
            tuple: (success: bool, data: list)

        Example inventory item:
            {
                "entPhysicalDescr": "Chassis Component",
                "entPhysicalClass": "chassis",
                "entPhysicalSerialNum": "ABC123456",
                "entPhysicalModelName": "EX4300-48P",
                ...
            }
        """
        try:
            response = requests.get(
                f"{self.librenms_url}/api/v0/inventory/{device_id}/all",
                headers=self.headers,
                timeout=DEFAULT_API_TIMEOUT,
                verify=self.verify_ssl,
            )
            response.raise_for_status()
            inventory_data = response.json()
            inventory = inventory_data.get("inventory") if isinstance(inventory_data, dict) else None
            if not isinstance(inventory, list) or any(not isinstance(item, dict) for item in inventory):
                msg = inventory_data.get("message", "") if isinstance(inventory_data, dict) else ""
                logger.warning(f"Unexpected inventory response for device {device_id}: {inventory_data}")
                return False, msg or "Unexpected response format: invalid 'inventory' payload"
            return True, inventory
        except (requests.exceptions.RequestException, ValueError) as e:
            return False, str(e)

    def get_poller_groups(self):
        """
        Fetch all poller groups from LibreNMS.

        Route: /api/v0/poller_group

        Returns:
            tuple: (success: bool, data: list)

        Example poller group:
            {
                "id": 1,
                "group_name": "test",
                "descr": "test group"
            }
        """
        try:
            response = requests.get(
                f"{self.librenms_url}/api/v0/poller_group",
                headers=self.headers,
                timeout=DEFAULT_API_TIMEOUT,
                verify=self.verify_ssl,
            )
            response.raise_for_status()
            result = response.json()
            if isinstance(result, dict) and result.get("status") == "ok":
                poller_groups = result.get("get_poller_group")
                if not isinstance(poller_groups, list):
                    return False, result.get("message") or "Unexpected response format: missing 'get_poller_group' list"
                if not all(isinstance(item, dict) for item in poller_groups):
                    return False, "Unexpected response format: invalid item shape in 'get_poller_group'"
                return True, poller_groups
            if isinstance(result, dict):
                return False, result.get("message") or "Unexpected response format"
            return False, "Unexpected response format: non-object JSON"
        except (requests.exceptions.RequestException, ValueError) as e:
            return False, str(e)

    def get_inventory_filtered(self, device_id, ent_physical_class=None, ent_physical_contained_in=None):
        """
        Fetch filtered inventory from LibreNMS with optional filtering.
        Uses query parameters if supported, falls back to client-side filtering.

        Route: /api/v0/inventory/{device_id}

        Args:
            device_id: LibreNMS device ID
            ent_physical_class: Filter by entPhysicalClass (e.g., 'chassis', 'stack')
            ent_physical_contained_in: Filter by entPhysicalContainedIn (0=root, 1=first level, etc.)

        Returns:
            tuple: (success: bool, inventory: list)

        Example:
            >>> api.get_inventory_filtered(22, ent_physical_class='chassis', ent_physical_contained_in=1)
            (True, [{'entPhysicalClass': 'chassis', ...}, ...])
        """
        logger.debug(
            f"get_inventory_filtered: device={device_id}, "
            f"class={ent_physical_class}, contained_in={ent_physical_contained_in}"
        )
        try:
            # Build query parameters for API filtering
            params = {}
            if ent_physical_class is not None:
                params["entPhysicalClass"] = ent_physical_class
            if ent_physical_contained_in is not None:
                params["entPhysicalContainedIn"] = str(ent_physical_contained_in)

            # Try the filtered endpoint first (non-/all)
            response = requests.get(
                f"{self.librenms_url}/api/v0/inventory/{device_id}",
                headers=self.headers,
                params=params,
                timeout=DEFAULT_API_TIMEOUT,
                verify=self.verify_ssl,
            )
            response.raise_for_status()

            data = response.json()
            if isinstance(data, dict) and data.get("status") == "ok":
                inventory = data.get("inventory")
                if not isinstance(inventory, list) or any(not isinstance(item, dict) for item in inventory):
                    msg = data.get("message")
                    return False, msg or "Unexpected response format: invalid 'inventory' payload"
                logger.debug(f"API returned {len(inventory)} items")

                # If we got results or didn't specify filters, return
                if inventory or not params:
                    return True, inventory

            # If filtered endpoint returned empty but we have filters,
            # try /all endpoint and filter client-side
            if params:
                logger.debug("Filtered inventory API returned no results, falling back to client-side filtering")
                success, all_inventory = self.get_device_inventory(device_id)

                if not success:
                    return False, all_inventory

                # Apply client-side filters
                filtered = all_inventory
                if ent_physical_class:
                    filtered = [item for item in filtered if item.get("entPhysicalClass") == ent_physical_class]
                if ent_physical_contained_in is not None:
                    filtered = [
                        item
                        for item in filtered
                        if str(item.get("entPhysicalContainedIn")) == str(ent_physical_contained_in)
                    ]

                return True, filtered

            # LibreNMS API v0 always returns JSON objects, so data is always
            # a dict here; the isinstance guard is purely defensive.
            if isinstance(data, dict):
                return False, data.get("message") or "Unexpected response format"
            return False, "Unexpected response format"

        except (requests.exceptions.RequestException, ValueError) as e:
            logger.warning(f"Failed to fetch filtered inventory: {e}")
            return False, str(e)

    def list_devices(self, filters=None):
        """
        List all devices from LibreNMS with optional filtering.

        Route: /api/v0/devices

        Args:
            filters (dict, optional): Filter parameters:
                - type: Device type filter (e.g., 'network', 'server', 'storage')
                - location_id: Location ID filter (numeric ID from LibreNMS)
                - hostname: Hostname filter (partial match)
                - os: Operating system filter
                - version: OS version filter
                - hardware: Hardware model filter
                - features: Features filter
                - device_id: Specific device ID
                - query: Search query (searches across multiple fields)

        Returns:
            tuple: (success: bool, data: list)

        Example device:
            {
                "device_id": 1,
                "hostname": "router01.example.com",
                "sysName": "router01",
                "ip": "192.168.1.1",
                "hardware": "Cisco C9300-48P",
                "version": "IOS 16.9.4",
                "location": "Datacenter 1",
                "status": 1,
                "status_reason": "",
                "ignore": 0,
                "disabled": 0,
                "uptime": 3153600,
                "os": "ios",
                "type": "network",
                "serial": "ABC123456789",
                "icon": "cisco.svg",
                ...
            }
        """
        try:
            params = {}
            if filters:
                # Build query parameters from filters
                for key, value in filters.items():
                    if value is not None and value != "":
                        params[key] = value

            response = requests.get(
                f"{self.librenms_url}/api/v0/devices",
                headers=self.headers,
                params=params,
                timeout=EXTENDED_API_TIMEOUT,
                verify=self.verify_ssl,
            )
            response.raise_for_status()
            result = response.json()
            if isinstance(result, dict) and result.get("status") == "ok":
                devices = result.get("devices")
                if not isinstance(devices, list):
                    msg = result.get("message")
                    return False, msg or "Unexpected response format: missing 'devices' list"
                if not all(isinstance(item, dict) for item in devices):
                    return False, "Unexpected response format: invalid item shape in 'devices'"
                return True, devices

            # LibreNMS API v0 always returns JSON objects, so result is always
            # a dict here; the isinstance guard is purely defensive.
            if isinstance(result, dict):
                return False, result.get("message") or "Unexpected response format"
            return False, "Unexpected response format"
        except (requests.exceptions.RequestException, ValueError) as e:
            return False, str(e)

    # =========================================================================
    # VLAN Methods
    # =========================================================================

    def get_device_vlans(self, device_id: int) -> tuple[bool, list | str]:
        """
        Fetch all VLANs configured on a device using the resources endpoint.

        This method uses /api/v0/resources/vlans which includes the vlan_id
        primary key, unlike /api/v0/devices/{device_id}/vlans which omits it.

        Route: /api/v0/resources/vlans

        Args:
            device_id: LibreNMS device ID

        Returns:
            tuple: (success: bool, data: list of VLAN dicts or error string)

        Example VLAN:
            {
                "vlan_id": 123,
                "device_id": 1,
                "vlan_vlan": 50,
                "vlan_domain": 1,
                "vlan_name": "ORG_DATA",
                "vlan_type": "ethernet",
                "vlan_state": 1
            }
        """
        try:
            response = requests.get(
                f"{self.librenms_url}/api/v0/resources/vlans",
                headers=self.headers,
                timeout=DEFAULT_API_TIMEOUT,
                verify=self.verify_ssl,
            )
            response.raise_for_status()

            result = response.json()
            if isinstance(result, dict) and result.get("status") == "ok":
                all_vlans = result.get("vlans")
                if not isinstance(all_vlans, list):
                    msg = result.get("message")
                    return False, msg or "Unexpected response format: missing 'vlans' list"
                # Filter VLANs by device_id since resources endpoint returns all VLANs
                device_vlans = [
                    v for v in all_vlans if isinstance(v, dict) and str(v.get("device_id")) == str(device_id)
                ]
                return True, device_vlans
            if isinstance(result, dict):
                return False, result.get("message") or "Unexpected response format"
            return False, "Unexpected response format"
        except requests.exceptions.HTTPError as e:
            if e.response.status_code == 404:
                return False, "VLANs resource not found"
            return False, f"HTTP error: {str(e)}"
        except (requests.exceptions.RequestException, ValueError) as e:
            return False, f"Error connecting to LibreNMS: {str(e)}"

    def get_port_vlan_details(self, port_id: int) -> tuple[bool, dict | str]:
        """
        Fetch detailed VLAN associations for a single port.
        Required for trunk ports to get the tagged VLANs list.

        Route: /api/v0/ports/{port_id}?with=vlans

        Args:
            port_id: LibreNMS port ID

        Returns:
            tuple: (success: bool, data: port dict with vlans array or error string)

        Example port:
            {
                "port_id": 227011,
                "ifName": "Te1/1/1",
                "ifVlan": "90",
                "ifTrunk": "dot1Q",
                "vlans": [
                    {"vlan": 90, "untagged": 1, "state": "unknown"},
                    {"vlan": 50, "untagged": 0, "state": "forwarding"}
                ]
            }
        """
        try:
            response = requests.get(
                f"{self.librenms_url}/api/v0/ports/{port_id}",
                headers=self.headers,
                params={"with": "vlans"},
                timeout=DEFAULT_API_TIMEOUT,
                verify=self.verify_ssl,
            )
            response.raise_for_status()

            if response.status_code == 200:
                result = response.json()
                if not isinstance(result, dict):
                    return False, "Unexpected response format"
                port_data = result.get("port")
                if not isinstance(port_data, list):
                    return False, result.get("message", "Unexpected response format: missing 'port' list")
                if not port_data:
                    return False, "Port not found"
                if not isinstance(port_data[0], dict):
                    return False, "Unexpected response format: invalid 'port' entry"
                return True, port_data[0]

            return False, f"HTTP {response.status_code}"
        except requests.exceptions.HTTPError as e:
            if e.response.status_code == 404:
                return False, "Port not found in LibreNMS"
            return False, f"HTTP error: {str(e)}"
        except (requests.exceptions.RequestException, ValueError) as e:
            return False, f"Error connecting to LibreNMS: {str(e)}"

    def parse_port_vlan_data(self, port_data: dict, interface_name_field: str = "ifName") -> dict:
        """
        Transform LibreNMS port VLAN data into normalized structure.

        Args:
            port_data: Raw port dict from LibreNMS API
            interface_name_field: Field to use for interface name ('ifName' or 'ifDescr')

        Returns:
            dict: Normalized structure with:
                - port_id: int
                - interface_name: str (value from interface_name_field)
                - ifName: str (always included for reference)
                - ifDescr: str (always included for reference)
                - mode: 'access' | 'tagged' | None
                - untagged_vlan: int | None
                - tagged_vlans: list[int]
        """
        port_id = port_data.get("port_id")
        if_name = port_data.get("ifName", "")
        if_descr = port_data.get("ifDescr", "")
        interface_name = port_data.get(interface_name_field, "") or if_name
        if_vlan = port_data.get("ifVlan", "")
        if_trunk = port_data.get("ifTrunk")

        # Determine 802.1Q mode
        if not if_vlan:
            mode = None
        elif if_trunk == "dot1Q":
            mode = "tagged"
        else:
            mode = "access"

        # Parse VLAN assignments from vlans array if present
        vlans_data = port_data.get("vlans", [])
        untagged_vlan = None
        tagged_vlans = []

        if isinstance(vlans_data, list) and vlans_data:
            # Parse from detailed vlans array
            for vlan_entry in vlans_data:
                if not isinstance(vlan_entry, dict):
                    continue
                vlan_id = vlan_entry.get("vlan")
                if vlan_id is None:
                    continue
                try:
                    vlan_id = int(vlan_id)
                except (ValueError, TypeError):
                    continue
                if vlan_entry.get("untagged") == 1:
                    untagged_vlan = vlan_id
                else:
                    tagged_vlans.append(vlan_id)
        elif if_vlan:
            # Fallback to ifVlan field for basic port info
            try:
                untagged_vlan = int(if_vlan)
            except (ValueError, TypeError):
                pass

        return {
            "port_id": port_id,
            "interface_name": interface_name,
            "ifName": if_name,
            "ifDescr": if_descr,
            "mode": mode,
            "untagged_vlan": untagged_vlan,
            "tagged_vlans": sorted(tagged_vlans),
        }
