import re
import typing
from collections import OrderedDict

from drf_spectacular.contrib.django_filters import DjangoFilterExtension
from drf_spectacular.extensions import OpenApiSerializerExtension, OpenApiSerializerFieldExtension, _SchemaType
from drf_spectacular.openapi import AutoSchema
from drf_spectacular.plumbing import (
    build_basic_type,
    build_choice_field,
    build_media_type_object,
    build_object_type,
    follow_field_source,
    get_doc,
)
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import Direction, OpenApiParameter

from netbox.api.fields import ChoiceField
from netbox.api.serializers import WritableNestedSerializer
from netbox.api.viewsets import NetBoxModelViewSet

# see netbox.api.routers.NetBoxRouter
BULK_ACTIONS = ("bulk_destroy", "bulk_partial_update", "bulk_update")
WRITABLE_ACTIONS = ("PATCH", "POST", "PUT")


class NetBoxDjangoFilterExtension(DjangoFilterExtension):
    """
    Overrides drf-spectacular's DjangoFilterExtension to fix a regression in v0.29.0 where
    _get_model_field() incorrectly double-appends to_field_name when field_name already ends
    with that value (e.g. field_name='tags__slug', to_field_name='slug' produces the invalid
    path ['tags', 'slug', 'slug']). This caused hundreds of spurious warnings during schema
    generation for filters such as TagFilter, TenancyFilterSet.tenant, and OwnerFilterMixin.owner.

    See: https://github.com/netbox-community/netbox/issues/20787
         https://github.com/tfranzel/drf-spectacular/issues/1475
    """
    priority = 1

    def _get_model_field(self, filter_field, model):
        if not filter_field.field_name:
            return None
        path = filter_field.field_name.split('__')
        to_field_name = filter_field.extra.get('to_field_name')
        if to_field_name is not None and path[-1] != to_field_name:
            path.append(to_field_name)
        return follow_field_source(model, path, emit_warnings=False)


class FixTimeZoneSerializerField(OpenApiSerializerFieldExtension):
    target_class = 'timezone_field.rest_framework.TimeZoneSerializerField'

    def map_serializer_field(self, auto_schema, direction):
        return build_basic_type(OpenApiTypes.STR)


class ChoiceFieldFix(OpenApiSerializerFieldExtension):
    target_class = 'netbox.api.fields.ChoiceField'

    def map_serializer_field(self, auto_schema, direction):
        build_cf = build_choice_field(self.target)

        if direction == 'request':
            return build_cf

        if direction == "response":
            value = build_cf
            label = {
                **build_basic_type(OpenApiTypes.STR),
                "enum": list(OrderedDict.fromkeys(self.target.choices.values()))
            }

            return build_object_type(
                properties={
                    "value": value,
                    "label": label
                }
            )

        # TODO: This function should never implicitly/explicitly return `None`
        # The fallback should be well-defined (drf-spectacular expects request/response naming).
        return None


def viewset_handles_bulk_create(view):
    """Check if view automatically provides list-based bulk create"""
    return isinstance(view, NetBoxModelViewSet)


class NetBoxAutoSchema(AutoSchema):
    """
    Overrides to drf_spectacular.openapi.AutoSchema to fix following issues:
        1. bulk serializers cause operation_id conflicts with non-bulk ones
        2. bulk operations should specify a list
        3. bulk operations don't have filter params
        4. bulk operations don't have pagination
        5. bulk delete should specify input
    """

    writable_serializers = {}

    @property
    def is_bulk_action(self):
        if hasattr(self.view, "action") and self.view.action in BULK_ACTIONS:
            return True
        return False

    def get_operation_id(self):
        """
        bulk serializers cause operation_id conflicts with non-bulk ones
        bulk operations cause id conflicts in spectacular resulting in numerous:
        Warning: operationId "xxx" has collisions [xxx]. "resolving with numeral suffixes"
        code is modified from drf_spectacular.openapi.AutoSchema.get_operation_id
        """
        if self.is_bulk_action:
            tokenized_path = self._tokenize_path()
            # replace dashes as they can be problematic later in code generation
            tokenized_path = [t.replace('-', '_') for t in tokenized_path]

            if self.method == 'GET' and self._is_list_view():
                # this shouldn't happen, but keeping it here to follow base code
                action = 'list'
            else:
                # action = self.method_mapping[self.method.lower()]
                # use bulk name so partial_update -> bulk_partial_update
                action = self.view.action.lower()

            if not tokenized_path:
                tokenized_path.append('root')

            if re.search(r'<drf_format_suffix\w*:\w+>', self.path_regex):
                tokenized_path.append('formatted')

            return '_'.join(tokenized_path + [action])

        # if not bulk - just return normal id
        return super().get_operation_id()

    def get_request_serializer(self) -> typing.Any:
        serializer = super().get_request_serializer()

        # Bulk update/partial-update has a special request shape: a list of
        # writable objects plus a required `id` field. The normal writable
        # serializer omits `id` because it is read-only, so don't use the generic
        # bulk handling for these actions.
        action = getattr(self.view, 'action', None)
        if action in ('bulk_update', 'bulk_partial_update'):
            get_bulk_update_request_serializer = getattr(
                self.view,
                'get_bulk_update_request_serializer',
                None,
            )
            if get_bulk_update_request_serializer is not None:
                return get_bulk_update_request_serializer(
                    partial=(action == 'bulk_partial_update' or self.method == 'PATCH')
                )

        # Bulk creates/deletes should specify a list.
        if self.is_bulk_action:
            return type(serializer)(many=True)

        # handle mapping for Writable serializers - adapted from dansheps original
        # code for drf-yasg.
        if serializer is not None and self.method in WRITABLE_ACTIONS:
            writable_class = self.get_writable_class(serializer)
            if writable_class is not None:
                if hasattr(serializer, "child"):
                    child_serializer = self.get_writable_class(serializer.child)
                    serializer = writable_class(context=serializer.context, child=child_serializer)
                else:
                    serializer = writable_class(context=serializer.context)

        return serializer

    def get_response_serializers(self) -> typing.Any:
        # bulk operations should specify a list
        response_serializers = super().get_response_serializers()

        if self.is_bulk_action:
            return type(response_serializers)(many=True)

        return response_serializers

    def _get_request_for_media_type(self, serializer, direction='request'):
        """
        Override to generate oneOf schema for serializers that support both
        single object and array input (NetBoxModelViewSet POST operations).

        Refs: #20638
        """
        # Get the standard schema first
        schema, required = super()._get_request_for_media_type(serializer, direction)

        # If this serializer supports arrays (marked in get_request_serializer),
        # wrap the schema in oneOf to allow single object OR array
        if (
            direction == 'request' and
            schema is not None and
            getattr(self.view, 'action', None) == 'create' and
            viewset_handles_bulk_create(self.view)
        ):
            return {
                'oneOf': [
                    schema,  # Single object
                    {
                        'type': 'array',
                        'items': schema,  # Array of objects
                    }
                ]
            }, required

        return schema, required

    def _get_serializer_name(self, serializer, direction, bypass_extensions=False) -> str:
        name = super()._get_serializer_name(serializer, direction, bypass_extensions)

        # If this serializer is nested, prepend its name with "Brief"
        if getattr(serializer, 'nested', False):
            name = f'Brief{name}'

        return name

    def get_serializer_ref_name(self, serializer):
        # from drf-yasg.utils
        """Get serializer's ref_name
        :param serializer: Serializer instance
        :return: Serializer's ``ref_name`` or ``None`` for inline serializer
        :rtype: str or None
        """
        serializer_meta = getattr(serializer, 'Meta', None)
        serializer_name = type(serializer).__name__
        if hasattr(serializer_meta, 'ref_name'):
            ref_name = serializer_meta.ref_name
        else:
            ref_name = serializer_name
            if ref_name.endswith('Serializer'):
                ref_name = ref_name[: -len('Serializer')]
        return ref_name

    def get_writable_class(self, serializer):
        properties = {}
        fields = {} if hasattr(serializer, 'child') else serializer.fields
        remove_fields = []

        # If you get a failure here for "AttributeError: 'cached_property' object has no attribute 'items'"
        # it is probably because you are using a viewsets.ViewSet for the API View and are defining a
        # serializer_class. You will also need to define a get_serializer() method like for GenericAPIView.
        for child_name, child in fields.items():
            # read_only fields don't need to be in writable (write only) serializers
            if 'read_only' in dir(child) and child.read_only:
                remove_fields.append(child_name)
            if isinstance(child, (ChoiceField, WritableNestedSerializer)):
                properties[child_name] = None

        if not properties:
            return None

        if type(serializer) not in self.writable_serializers:
            writable_name = 'Writable' + type(serializer).__name__
            meta_class = getattr(type(serializer), 'Meta', None)
            if meta_class:
                ref_name = 'Writable' + self.get_serializer_ref_name(serializer)
                # remove read_only fields from write-only serializers
                fields = list(meta_class.fields)
                for field in remove_fields:
                    fields.remove(field)
                writable_meta = type('Meta', (meta_class,), {'ref_name': ref_name, 'fields': fields})

                properties['Meta'] = writable_meta

            self.writable_serializers[type(serializer)] = type(writable_name, (type(serializer),), properties)

        writable_class = self.writable_serializers[type(serializer)]
        return writable_class

    def get_override_parameters(self):
        params = super().get_override_parameters()
        # Expose the ?fields, ?omit, and ?brief query parameters supported by NetBoxModelViewSet
        # for all non-bulk GET operations (both list and detail).
        if not self.is_bulk_action and self.method == 'GET':
            params = list(params) + [
                OpenApiParameter(
                    name='fields',
                    location=OpenApiParameter.QUERY,
                    required=False,
                    type=OpenApiTypes.STR,
                    description='Comma-separated list of fields to include in the response. Example: `fields=id,name`.',
                ),
                OpenApiParameter(
                    name='omit',
                    location=OpenApiParameter.QUERY,
                    required=False,
                    type=OpenApiTypes.STR,
                    description='Comma-separated list of fields to exclude from the response. '
                                'Example: `omit=description,tags`.',
                ),
                OpenApiParameter(
                    name='brief',
                    location=OpenApiParameter.QUERY,
                    required=False,
                    type=OpenApiTypes.BOOL,
                    description='Return only brief fields for each object.',
                ),
            ]
        return params

    def get_filter_backends(self):
        # bulk operations don't have filter params
        if self.is_bulk_action:
            return []
        return super().get_filter_backends()

    def _get_paginator(self):
        # bulk operations don't have pagination
        if self.is_bulk_action:
            return None
        return super()._get_paginator()

    def _get_request_body(self, direction='request'):
        # bulk delete should specify input
        if (not self.is_bulk_action) or (self.method != 'DELETE'):
            return super()._get_request_body(direction)

        # rest from drf_spectacular.openapi.AutoSchema._get_request_body
        # but remove the unsafe method check

        request_serializer = self.get_request_serializer()

        if isinstance(request_serializer, dict):
            content = []
            request_body_required = True
            for media_type, serializer in request_serializer.items():
                schema, partial_request_body_required = self._get_request_for_media_type(serializer, direction)
                examples = self._get_examples(serializer, direction, media_type)
                if schema is None:
                    continue
                content.append((media_type, schema, examples))
                request_body_required &= partial_request_body_required
        else:
            schema, request_body_required = self._get_request_for_media_type(request_serializer, direction)
            if schema is None:
                return None
            content = [
                (media_type, schema, self._get_examples(request_serializer, direction, media_type))
                for media_type in self.map_parsers()
            ]

        request_body = {
            'content': {
                media_type: build_media_type_object(schema, examples) for media_type, schema, examples in content
            }
        }
        if request_body_required:
            request_body['required'] = request_body_required
        return request_body

    def get_description(self):
        """
        Return a string description for the ViewSet.
        """

        # If a docstring is provided, use it.
        if self.view.__doc__:
            return get_doc(self.view.__class__)

        # When the action method is decorated with @action, use the docstring of the method.
        action_or_method = getattr(self.view, getattr(self.view, 'action', self.method.lower()), None)
        if action_or_method and action_or_method.__doc__:
            return get_doc(action_or_method)

        # Else, generate a description from the class name.
        return self._generate_description()

    def _generate_description(self):
        """
        Generate a docstring for the method. It also takes into account whether the method is for list or detail.
        """
        model_name = self.view.queryset.model._meta.verbose_name

        # Determine if the method is for list or detail.
        if '{id}' in self.path:
            return f"{self.method.capitalize()} a {model_name} object."
        return f"{self.method.capitalize()} a list of {model_name} objects."


class FixSerializedPKRelatedField(OpenApiSerializerFieldExtension):
    target_class = 'netbox.api.fields.SerializedPKRelatedField'

    def map_serializer_field(self, auto_schema, direction):
        if direction == "response":
            component = auto_schema.resolve_serializer(self.target.serializer, direction)
            return component.ref if component else None
        return build_basic_type(OpenApiTypes.INT)


class FixIntegerRangeSerializerSchema(OpenApiSerializerExtension):
    target_class = 'netbox.api.fields.IntegerRangeSerializer'
    match_subclasses = True

    def map_serializer(self, auto_schema: 'AutoSchema', direction: Direction) -> _SchemaType:
        # One range = two integers; many=True will wrap this in an outer array
        return {
            'type': 'array',
            'items': {
                'type': 'integer',
            },
            'minItems': 2,
            'maxItems': 2,
            'example': [10, 20],
        }


# Nested models can be passed by ID in requests
# The logic for this is handled in `BaseModelSerializer.to_internal_value`
class FixWritableNestedSerializerAllowPK(OpenApiSerializerFieldExtension):
    target_class = 'netbox.api.serializers.BaseModelSerializer'
    match_subclasses = True

    def map_serializer_field(self, auto_schema, direction):
        schema = auto_schema._map_serializer_field(self.target, direction, bypass_extensions=True)
        if schema is None:
            return schema
        if direction == 'request' and self.target.nested:
            return {
                'oneOf': [
                    build_basic_type(OpenApiTypes.INT),
                    schema,
                ]
            }
        return schema
