import logging
from functools import cached_property

from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
from django.db import router, transaction
from django.db.models import ProtectedError, RestrictedError
from django_pglocks import advisory_lock
from rest_framework import mixins as drf_mixins
from rest_framework import status
from rest_framework.response import Response
from rest_framework.viewsets import GenericViewSet

from netbox.api.serializers.features import ChangeLogMessageSerializer
from netbox.constants import ADVISORY_LOCK_KEYS
from utilities.api import get_annotations_for_serializer, get_prefetches_for_serializer
from utilities.exceptions import AbortRequest, PreconditionFailed
from utilities.query import reapply_model_ordering

from . import mixins

__all__ = (
    'NetBoxModelViewSet',
    'NetBoxReadOnlyModelViewSet',
)

HTTP_ACTIONS = {
    'GET': 'view',
    'OPTIONS': None,
    'HEAD': 'view',
    'POST': 'add',
    'PUT': 'change',
    'PATCH': 'change',
    'DELETE': 'delete',
}


class ETagMixin:
    """
    Adds ETag header support to ViewSets. Generates weak ETags (W/ prefix per
    RFC 7232 §2.1) from `last_updated` (or `created` if unavailable). Weak ETags
    are appropriate here because the tag is derived from a modification timestamp
    rather than a hash of the serialized payload.
    """

    @staticmethod
    def _get_etag(obj):
        """Return a weak ETag string for the given object, or None."""
        if ts := getattr(obj, 'last_updated', None) or getattr(obj, 'created', None):
            return f'W/"{ts.isoformat()}"'
        return None

    @staticmethod
    def _get_if_match(request):
        """Return the list of If-Match header values (if specified)."""
        if (if_match := request.META.get('HTTP_IF_MATCH')) and if_match != '*':
            return [e.strip() for e in if_match.split(',')]
        return []

    def _validate_etag(self, request, instance):
        """Validate the request's ETag"""
        if provided := self._get_if_match(request):
            current_etag = self._get_etag(instance)
            if current_etag and current_etag not in provided:
                raise PreconditionFailed(etag=current_etag)

    def handle_exception(self, exc):
        response = super().handle_exception(exc)
        if isinstance(exc, PreconditionFailed) and exc.etag:
            response['ETag'] = exc.etag
        return response

    def retrieve(self, request, *args, **kwargs):
        instance = self.get_object()
        serializer = self.get_serializer(instance)
        response = Response(serializer.data)
        if etag := self._get_etag(instance):
            response['ETag'] = etag
        return response


class BaseViewSet(GenericViewSet):
    """
    Base class for all API ViewSets. This is responsible for the enforcement of object-based permissions.
    """
    brief = False

    def initial(self, request, *args, **kwargs):
        super().initial(request, *args, **kwargs)

        # Restrict the view's QuerySet to allow only the permitted objects
        if request.user.is_authenticated:
            if action := HTTP_ACTIONS[request.method]:
                self.queryset = self.queryset.restrict(request.user, action)

    def initialize_request(self, request, *args, **kwargs):

        # Annotate whether brief mode is active
        self.brief = request.method == 'GET' and request.GET.get('brief')

        return super().initialize_request(request, *args, **kwargs)

    def get_queryset(self):
        qs = super().get_queryset()
        serializer_class = self.get_serializer_class()

        # Dynamically resolve prefetches for included serializer fields and attach them to the queryset
        if prefetch := get_prefetches_for_serializer(serializer_class, **self.field_kwargs):
            qs = qs.prefetch_related(*prefetch)

        # Dynamically resolve annotations for RelatedObjectCountFields on the serializer and attach them to the queryset
        if annotations := get_annotations_for_serializer(serializer_class, **self.field_kwargs):
            qs = qs.annotate(**annotations)

        return qs

    def get_serializer(self, *args, **kwargs):
        # Pass the fields/omit kwargs (if specified by the request) to the serializer
        kwargs.update(**self.field_kwargs)
        return super().get_serializer(*args, **kwargs)

    @cached_property
    def field_kwargs(self):
        """Return a dictionary of keyword arguments to be passed when instantiating the serializer."""
        # An explicit list of fields was requested
        if requested_fields := self.request.query_params.get('fields'):
            return {'fields': requested_fields.split(',')}

        # An explicit list of fields to omit was requested
        if omit_fields := self.request.query_params.get('omit'):
            return {'omit': omit_fields.split(',')}

        # Brief mode has been enabled for this request
        if self.brief:
            serializer_class = self.get_serializer_class()
            if brief_fields := getattr(serializer_class.Meta, 'brief_fields', None):
                return {'fields': brief_fields}

        return {}


class NetBoxReadOnlyModelViewSet(
    ETagMixin,
    mixins.CustomFieldsMixin,
    mixins.ExportTemplatesMixin,
    drf_mixins.RetrieveModelMixin,
    drf_mixins.ListModelMixin,
    BaseViewSet
):
    pass


class NetBoxModelViewSet(
    ETagMixin,
    mixins.BulkUpdateModelMixin,
    mixins.BulkDestroyModelMixin,
    mixins.ObjectValidationMixin,
    mixins.CustomFieldsMixin,
    mixins.ExportTemplatesMixin,
    drf_mixins.CreateModelMixin,
    drf_mixins.RetrieveModelMixin,
    drf_mixins.UpdateModelMixin,
    drf_mixins.DestroyModelMixin,
    drf_mixins.ListModelMixin,
    BaseViewSet
):
    """
    Extend DRF's ModelViewSet to support bulk update and delete functions.
    """
    def get_object_with_snapshot(self):
        """
        Save a pre-change snapshot of the object immediately after retrieving it. This snapshot will be used to
        record the "before" data in the changelog.
        """
        obj = super().get_object()
        if hasattr(obj, 'snapshot'):
            obj.snapshot()
        return obj

    def get_queryset(self):
        qs = super().get_queryset()
        return reapply_model_ordering(qs)

    def get_serializer(self, *args, **kwargs):
        # If a list of objects has been provided, initialize the serializer with many=True
        if isinstance(kwargs.get('data', {}), list):
            kwargs['many'] = True

        return super().get_serializer(*args, **kwargs)

    def dispatch(self, request, *args, **kwargs):
        logger = logging.getLogger(f'netbox.api.views.{self.__class__.__name__}')

        try:
            return super().dispatch(request, *args, **kwargs)
        except (ProtectedError, RestrictedError) as e:
            if type(e) is ProtectedError:
                protected_objects = list(e.protected_objects)
            else:
                protected_objects = list(e.restricted_objects)
            msg = f'Unable to delete object. {len(protected_objects)} dependent objects were found: '
            msg += ', '.join([f'{obj} ({obj.pk})' for obj in protected_objects])
            logger.warning(msg)
            return self.finalize_response(
                request,
                Response({'detail': msg}, status=409),
                *args,
                **kwargs
            )
        except AbortRequest as e:
            logger.debug(e.message)
            return self.finalize_response(
                request,
                Response({'detail': e.message}, status=400),
                *args,
                **kwargs
            )

    # Creates

    def create(self, request, *args, **kwargs):
        serializer = self.get_serializer(data=request.data)
        serializer.is_valid(raise_exception=True)
        bulk_create = getattr(serializer, 'many', False)
        self.perform_create(serializer)

        # After creating the instance(s), re-initialize the serializer with a queryset
        # to ensure related objects are prefetched.
        if bulk_create:
            instance_pks = [obj.pk for obj in serializer.instance]
            # Order by PK to ensure that the ordering of objects in the response
            # matches the ordering of those in the request.
            qs = self.get_queryset().filter(pk__in=instance_pks).order_by('pk')
        else:
            qs = self.get_queryset().get(pk=serializer.instance.pk)

        # Re-serialize the instance(s) with prefetched data
        serializer = self.get_serializer(qs, many=bulk_create)

        headers = self.get_success_headers(serializer.data)
        response = Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)

        # Add ETag for single-object creation only (bulk returns a list, no single ETag)
        if not bulk_create:
            if etag := self._get_etag(qs):
                response['ETag'] = etag

        return response

    def perform_create(self, serializer):
        model = self.queryset.model
        logger = logging.getLogger(f'netbox.api.views.{self.__class__.__name__}')
        logger.info(f"Creating new {model._meta.verbose_name}")

        # Enforce object-level permissions on save()
        try:
            with transaction.atomic(using=router.db_for_write(model)):
                instance = serializer.save()
                self._validate_objects(instance)
        except ObjectDoesNotExist:
            raise PermissionDenied()

    # Updates

    def update(self, request, *args, **kwargs):
        partial = kwargs.pop('partial', False)
        instance = self.get_object_with_snapshot()

        # Enforce If-Match precondition (RFC 9110 §13.1.1)
        self._validate_etag(self.request, instance)

        serializer = self.get_serializer(instance, data=request.data, partial=partial)
        serializer.is_valid(raise_exception=True)
        self.perform_update(serializer)

        # After updating the instance, re-initialize the serializer with a queryset
        # to ensure related objects are prefetched.
        qs = self.get_queryset().get(pk=serializer.instance.pk)

        # Re-serialize the instance(s) with prefetched data
        serializer = self.get_serializer(qs)
        response = Response(serializer.data)

        if etag := self._get_etag(qs):
            response['ETag'] = etag

        return response

    def perform_update(self, serializer):
        model = self.queryset.model
        logger = logging.getLogger(f'netbox.api.views.{self.__class__.__name__}')
        logger.info(f"Updating {model._meta.verbose_name} {serializer.instance} (PK: {serializer.instance.pk})")

        # Enforce object-level permissions on save()
        try:
            with transaction.atomic(using=router.db_for_write(model)):
                # Re-check the If-Match ETag under a row-level lock to close the TOCTOU window
                # between the initial check in update() and the actual write.
                if self._get_if_match(self.request):
                    locked = model.objects.select_for_update().get(pk=serializer.instance.pk)
                    self._validate_etag(self.request, locked)
                instance = serializer.save()
                self._validate_objects(instance)
        except ObjectDoesNotExist:
            raise PermissionDenied()

    # Deletes

    def destroy(self, request, *args, **kwargs):
        instance = self.get_object_with_snapshot()

        # Enforce If-Match precondition (RFC 9110 §13.1.1)
        self._validate_etag(request, instance)

        # Attach changelog message (if any)
        serializer = ChangeLogMessageSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)
        instance._changelog_message = serializer.validated_data.get('changelog_message')

        self.perform_destroy(instance)

        return Response(status=status.HTTP_204_NO_CONTENT)

    def perform_destroy(self, instance):
        model = self.queryset.model
        logger = logging.getLogger(f'netbox.api.views.{self.__class__.__name__}')
        logger.info(f"Deleting {model._meta.verbose_name} {instance} (PK: {instance.pk})")

        try:
            with transaction.atomic(using=router.db_for_write(model)):
                # Re-check the If-Match ETag under a row-level lock to close the TOCTOU window
                # between the initial check in destroy() and the actual delete.
                if self._get_if_match(self.request):
                    locked = model.objects.select_for_update().get(pk=instance.pk)
                    self._validate_etag(self.request, locked)
                super().perform_destroy(instance)
        except ObjectDoesNotExist:
            raise PermissionDenied()


class MPTTLockedMixin:
    """
    Puts pglock on objects that derive from MPTTModel for parallel API calling.
    Note: If adding this to a view, must add the model name to ADVISORY_LOCK_KEYS
    """

    def create(self, request, *args, **kwargs):
        with advisory_lock(ADVISORY_LOCK_KEYS[self.queryset.model._meta.model_name]):
            return super().create(request, *args, **kwargs)

    def update(self, request, *args, **kwargs):
        with advisory_lock(ADVISORY_LOCK_KEYS[self.queryset.model._meta.model_name]):
            return super().update(request, *args, **kwargs)

    def destroy(self, request, *args, **kwargs):
        with advisory_lock(ADVISORY_LOCK_KEYS[self.queryset.model._meta.model_name]):
            return super().destroy(request, *args, **kwargs)
