from datetime import datetime, timedelta, timezone
from typing import Any, Dict, Optional

import pynetbox
from pynetbox.core.query import ContentError, RequestError

from inventory_monitor.helper import stripper
from inventory_monitor.logger import logger
from inventory_monitor.models import NetBoxConfig, load_config_from_env
from inventory_monitor.snmp.base import SERIAL_MIN_LENGTH

# Offset between creation_time and time to ensure ordering in NetBox
_CREATION_TIME_OFFSET_SECONDS = 5

# Fields compared when detecting probe changes
_PROBE_DIFF_FIELDS = (
    "device_descriptor",
    "site_descriptor",
    "location_descriptor",
    "part",
    "name",
    "description",
    "category",
)


class Netbox:
    def __init__(self, config: Optional[NetBoxConfig] = None):
        """Initialize Netbox client with API connection.

        Args:
            config: Optional NetBoxConfig; falls back to env-loaded config if None.
        """
        if config is None:
            config = load_config_from_env().netbox
        if not config.api_token:
            raise ValueError("NETBOX_API_TOKEN is required but not set")
        if not config.instance_url:
            raise ValueError("NETBOX_INSTANCE_URL is required but not set")
        self.netbox_config = config
        self.api = self._api_conn()

    def _api_conn(self) -> pynetbox.core.api.Api:
        """Establish connection to NetBox API using self.netbox_config.

        Returns:
            pynetbox API object
        """
        api = pynetbox.api(
            self.netbox_config.instance_url,
            token=self.netbox_config.api_token,
            threading=True,
        )
        if not self.netbox_config.verify_ssl:
            logger.warning(
                "SSL certificate verification is disabled for NetBox API connections"
            )
        api.http_session.verify = self.netbox_config.verify_ssl
        return api

    def get_latest_probe_by_serial(
        self, serial: str, device_id: int
    ) -> Optional[pynetbox.core.response.Record]:
        """Retrieve the latest probe for a device by serial number.

        Args:
            serial: Serial number of the device
            device_id: ID of the device in NetBox

        Returns:
            Latest probe record or None if not found or error occurs
        """
        try:
            return self.api.plugins.inventory_monitor.probes.get(
                serial=serial, latest_only_per_device=True, device_id=device_id
            )
        except (RequestError, ContentError, ConnectionError) as e:
            logger.error(f"Error retrieving probe for serial {serial}: {str(e)}")
            return None

    @staticmethod
    def _check_probe_item_diff(
        params: Dict[str, Any], nb_probe: pynetbox.core.response.Record
    ) -> bool:
        """Check if there are differences between current probe data and new params.

        Args:
            params: New parameters for the probe
            nb_probe: Existing NetBox probe record

        Returns:
            True if differences exist, False otherwise
        """
        try:
            return any(
                getattr(nb_probe, field) != params[field]
                for field in _PROBE_DIFF_FIELDS
            )
        except (AttributeError, KeyError) as e:
            logger.error(f"Error comparing probe data: {str(e)}")
            # If we can't determine the difference, assume there is one to be safe
            return True

    @staticmethod
    def _set_probe_timestamps(params: Dict[str, Any], for_creation: bool = False) -> None:
        """Set appropriate timestamps for probe creation or update.

        Args:
            params: Dictionary of probe parameters to modify
            for_creation: If True, sets creation_time < time for new probe creation
        """
        if for_creation:
            current_dt = datetime.now(timezone.utc)
            params["creation_time"] = current_dt.isoformat()
            params["time"] = (current_dt + timedelta(seconds=_CREATION_TIME_OFFSET_SECONDS)).isoformat()
        else:
            params["time"] = datetime.now(timezone.utc).isoformat()

    @staticmethod
    def _prepare_probe_params(
        nb_device, snmp_inventory_params: Dict[str, Any]
    ) -> Dict[str, Any]:
        """Prepare parameters for creating or updating a probe in NetBox.

        Args:
            nb_device: pynetbox Device object
            snmp_inventory_params: Dictionary of SNMP inventory parameters

        Returns:
            Dictionary of parameters for NetBox probe creation/update
        """
        # Init inventory name
        if snmp_inventory_params["entPhysicalName"]:
            name = snmp_inventory_params["entPhysicalName"][:64]
        else:
            name = snmp_inventory_params.get("entPhysicalMfgName", "Unknown")

        # Init inventory parameters
        params = {
            "time": None,  # Will be set by _set_probe_timestamps
            "device_descriptor": stripper(nb_device.name),
            "part": stripper(
                snmp_inventory_params.get("entPhysicalModelName", "")[:50]
            ),
            "name": stripper(name),
            "serial": stripper(snmp_inventory_params.get("entPhysicalSerialNum", "")),
            "description": stripper(snmp_inventory_params.get("entPhysicalDescr", "")),
            "device": {"id": nb_device.id},
            "discovered_data": snmp_inventory_params,
            "category": stripper(
                snmp_inventory_params.get("entPhysicalClass", "unknown")
            ),
        }

        # Check and assign Site
        if nb_device.site:
            params["site"] = {"id": nb_device.site.id}
            params["site_descriptor"] = nb_device.site.name
        else:
            params["site"] = None
            params["site_descriptor"] = None

        # Check and assign Location
        if nb_device.location:
            params["location"] = {"id": nb_device.location.id}
            params["location_descriptor"] = nb_device.location.name
        else:
            params["location"] = None
            params["location_descriptor"] = None

        return params

    def create_or_update_probe(
        self, nb_device, snmp_inventory_params: Dict[str, Any]
    ) -> int:
        """Create or update a probe in NetBox based on SNMP inventory data.

        When an existing probe has changed data, a new probe record is
        created (preserving history) rather than updating in place. If data
        is unchanged, only the timestamp is updated on the existing record.

        Args:
            nb_device: pynetbox representation of Device
            snmp_inventory_params: dictionary of SNMP parsed parameters

        Returns:
            0 for success, 1 for failure
        """
        # Check params
        if not nb_device:
            logger.error("create_or_update_probe called with no device, skipping")
            return 1

        if not snmp_inventory_params:
            logger.error(f"{nb_device.name}: No SNMP inventory params provided, skipping probe")
            return 1

        prepared_params = self._prepare_probe_params(nb_device, snmp_inventory_params)

        if not prepared_params["serial"] or len(prepared_params["serial"]) < SERIAL_MIN_LENGTH:
            logger.error(
                f"{nb_device.name}: Missing serial number or wrong format "
                f"(got '{prepared_params.get('serial', '')}'), skipping probe"
            )
            return 1

        nb_probe_item = self.get_latest_probe_by_serial(
            prepared_params["serial"], nb_device.id
        )

        try:
            if nb_probe_item:
                logger.debug(
                    f"Found existing probe for serial {prepared_params['serial']} "
                    f"on device {nb_device.name}, checking for changes"
                )
                if self._check_probe_item_diff(
                    params=prepared_params, nb_probe=nb_probe_item
                ):
                    self._set_probe_timestamps(prepared_params, for_creation=True)
                    self.api.plugins.inventory_monitor.probes.create(prepared_params)
                    logger.info(
                        f"Created new probe for serial {prepared_params['serial']} due to changes"
                    )
                else:
                    self._set_probe_timestamps(prepared_params, for_creation=False)
                    nb_probe_item.update(prepared_params)
                    logger.info(
                        f"Updated existing probe for serial {prepared_params['serial']}"
                    )
            else:
                self._set_probe_timestamps(prepared_params, for_creation=True)
                self.api.plugins.inventory_monitor.probes.create(prepared_params)
                logger.info(f"Created new probe for serial {prepared_params['serial']}")
            return 0
        except (RequestError, ContentError, ConnectionError) as e:
            logger.error(f"Error creating/updating probe: {str(e)}")
            return 1
