# -*- coding: utf-8 -*-

"""Field definitions for the flexible_forms module."""

import json
import logging
import urllib.parse as urlparse
from datetime import date, datetime
from functools import reduce
from operator import add, ior
from typing import (
    TYPE_CHECKING,
    Any,
    Dict,
    Iterable,
    List,
    Optional,
    Sequence,
    Set,
    Tuple,
    Type,
    cast,
)
from urllib.parse import urlencode

import requests
import simpleeval
from django.apps import apps
from django.core.cache import cache
from django.core.exceptions import ImproperlyConfigured
from django.core.paginator import Paginator
from django.core.validators import (
    BaseValidator,
    MaxValueValidator,
    MinValueValidator,
)
from django.db import connections
from django.db.models import Case, F, FileField, ImageField
from django.db.models import Model as DjangoModel
from django.db.models import Sum, When
from django.db.models import fields as model_fields
from django.db.models.query import QuerySet
from django.forms import fields as form_fields
from django.forms import widgets as form_widgets
from django.http import HttpRequest, QueryDict
from django.urls import Resolver404, resolve, reverse
from django.utils.functional import cached_property
from typing_extensions import TypedDict

from flexible_forms.widgets import (
    AutocompleteSelect,
    AutocompleteSelectMultiple,
)

try:
    from django.db.models import JSONField
    from django.forms import JSONField as JSONFormField
except ImportError:  # pragma: no cover
    from django.contrib.postgres.fields import JSONField  # type: ignore
    from django.contrib.postgres.forms import JSONField as JSONFormField  # type: ignore

from flexible_forms.utils import (
    RenderedString,
    check_supports_pg_trgm,
    collect_annotations,
    create_autocomplete_result,
    evaluate_expression,
    get_expression_fields,
    interpolate,
    jp,
)

logger = logging.getLogger(__name__)

##
# _EMPTY_CHOICES
_EMPTY_CHOICES = (("EMPTY", "Select a value."),)

if TYPE_CHECKING:  # pragma: no cover
    from flexible_forms.models import BaseField, BaseRecord


##
# FIELD_TYPES
#
# A dict mapping of field types, where the key is the string representation of
# the field type (usually its class name), and the value is the `FlexibleField`
# class itself.
#
# Built dynamically to include all descendants of `FieldType`.
#
FIELD_TYPES: Dict[str, Type["FieldType"]] = {}


class FieldTypeOptions:
    """A base class for the Meta inner class for FieldType."""

    abstract = False
    force_replacement = False


class FieldTypeMetaclass(type):
    """A metaclass for handling FieldType configuration and registration."""

    label: str

    name: str
    _meta: FieldTypeOptions

    def __new__(
        cls: Type["FieldTypeMetaclass"],
        name: str,
        bases: Tuple[type, ...],
        attrs: Dict[str, Any],
        **kwargs: Any,
    ) -> "FieldTypeMetaclass":
        """Create a new FieldType and register it.

        Args:
            cls: This metaclass.
            name: The name of the new FieldType class.
            bases: The base classes of the new FieldType class.
            attrs: The attributes of the new FieldType class.
            kwargs: Passed to super.

        Returns:
            Type[FieldType]: The new FieldType class, configured and registered.

        Raises:
            ValueError: If a FieldType with the same class name was already
                registered, and the force_replacement meta option is False.
        """
        attrs_meta = attrs.pop("Meta", None)
        attrs["_meta"] = type(
            "Meta", tuple(filter(bool, (attrs_meta, FieldTypeOptions))), {}
        )

        # Set the name from the class name if a name was not provided.
        attrs["name"] = attrs.get("name", name)

        clsobj = super().__new__(cls, name, bases, attrs, **kwargs)

        # Throw an error if a FieldType with the given name was already registered.
        if clsobj.name in FIELD_TYPES and not clsobj._meta.force_replacement:
            raise ValueError(
                f"A FieldType named {name} was already registered ({FIELD_TYPES[name]})."
            )

        # Register the new field type with its class name (if it's not abstract).
        if not clsobj._meta.abstract:
            FIELD_TYPES[name] = cast(Type[FieldType], clsobj)

        return clsobj


class FieldType(metaclass=FieldTypeMetaclass):
    """A prefabricated field to use with flexible forms.

    Provides an interface to emit a model field, form field, or widget.
    """

    # The name is set by the Metaclass when the class is initialized.
    name: str

    class Meta:
        abstract = True

    ##
    # label
    #
    # The human-friendly key of the field type, e.g. "Single-line Text".
    #
    label: str = ""

    ##
    # form_field_class
    #
    # The class to use when creating a new instance of the field for use in a Django
    # form.
    #
    # Default: django.forms.fields.CharField
    #
    form_field_class: Type[form_fields.Field] = form_fields.CharField

    ##
    # form_field_options
    #
    # Keyword arguments to be passed to the form_field_class constructor.
    #
    # Default: {}
    #
    form_field_options: Dict[str, Any] = {}

    ##
    # form_widget_class
    #
    # The class to use to render the form widget to a form template. If unspecified, use
    # the default for the form_field_class.
    #
    # Default: None
    #
    form_widget_class: Optional[Type[form_widgets.Widget]] = None

    ##
    # form_widget_options
    #
    # Keyword arguments to be passed to the form_widget_class constructor.
    #
    # Default: {}
    #
    form_widget_options: Dict[str, Any] = {}

    ##
    # model_field_class
    #
    # The class to use for when using the field to define a Django model.
    #
    # Default: JSONField
    #
    model_field_class: Type[model_fields.Field] = JSONField

    ##
    # model_field_options
    #
    # Keyword arguments to be passed to the model_field_class constructor.
    #
    # Default: {}
    #
    model_field_options: Dict[str, Any] = {}

    def __init__(
        self,
        field: "BaseField",
        record: Optional["BaseRecord"],
        field_values: Optional[Dict[str, Any]] = None,
        modifiers: Sequence[Tuple[str, str]] = (),
        **field_type_options: Any,
    ):
        self.field = field
        self.record = record
        self.field_values = field_values or {}
        self.modifiers = modifiers

        field_type = type(self)
        declared_options = {**collect_annotations(field_type), **field_type.__dict__}
        for attr, value in field_type_options.items():
            if attr not in declared_options:
                raise AttributeError(
                    f"Field type {type(self)} has no attribute {attr}."
                )
            setattr(self, attr, value)

    def as_form_field(self, **form_field_options: Any) -> form_fields.Field:
        """Return an instance of the field for use in a Django form.

        Passes additional kwargs to the form field constructor.

        Args:
            form_field_options: Additional kwargs passed to the Django form
                field constructor.

        Returns:
            form_fields.Field: An instance of the form field.
        """
        # Cascade form field options in precedence order:
        #
        #   * Defaults for the field type.
        #   * Common attributes from the BaseField record.
        #   * Additional options (form_field_options) from the BaseField record.
        #   * Any form_field_options passed to the method directly.
        #
        form_field_options = {
            **self.form_field_options,
            "label": self.field.label,
            "label_suffix": self.field.label_suffix,
            "help_text": self.field.help_text,
            "required": self.field.required,
            "initial": self.field.initial,
            "error_messages": self.field.error_messages,
            "localize": self.field.localize,
            "disabled": self.field.disabled,
            **self.field.form_field_options,
            **form_field_options,
        }

        # If no widget was given explicitly, build a default one.
        form_field_options["widget"] = form_field_options.get(
            "widget", self.as_form_widget()
        )

        # Generate the form field with its appropriate class and widget.
        form_field = self.form_field_class(**form_field_options)

        # Apply any configured modifiers to the field.
        form_field = self.apply_modifiers(form_field)

        return form_field

    def as_form_widget(self, **form_widget_options: Any) -> form_widgets.Widget:
        """Return an instance of the form widget for rendering.

        Passes additional kwargs to the form widget constructor.

        Args:
            form_widget_options: Additional kwargs passed to the Django form
                widget constructor.

        Returns:
            form_widgets.Widget: The configured form widget for the field.
        """
        # If no form_widget_class is configured for the field type, use the
        # default one for the form_field_class.
        widget_cls = self.form_widget_class or cast(
            Type[form_widgets.Widget], self.form_field_class.widget
        )

        return widget_cls(
            **{
                **self.form_widget_options,
                **self.field.form_widget_options,
                **form_widget_options,
            }
        )

    @classmethod
    def as_model_field(cls, **model_field_options: Any) -> model_fields.Field:
        """Return an instance of the field for use in a Django model.

        Receives a dict of kwargs to pass through to the model field constructor.

        Since this method generates a model field (which should only ever be
        used when defining model classes, and should never have dynamic
        attributes), it is a classmethod and does not have access to a field,
        record, or field values.

        Args:
            model_field_options: A dict of kwargs to be passed to the Django
                model field constructor.

        Returns:
            model_fields.Field: An instance of the model field.
        """
        return cls.model_field_class(
            **{
                **cls.model_field_options,
                **model_field_options,
            }
        )

    def apply_modifiers(self, form_field: form_fields.Field) -> form_fields.Field:
        """Apply the given modifiers to the given Django form field.

        Args:
            form_field: The form field to which to apply modifiers.

        Returns:
            form_fields.Field: The given form field, modified using the
                configured modifiers.
        """
        for attribute, expression in self.modifiers:
            expression_context = self.field_values

            if self.record:
                record_variable = (
                    (self.record._meta.verbose_name or "record")
                    .lower()
                    .replace(" ", "_")
                )
                expression_context = {record_variable: self.record, **self.field_values}

            # Evaluate the expression and set the attribute specified by
            # `self.attribute` to the value it returns.
            try:
                expression_value = evaluate_expression(
                    expression, names=expression_context
                )
            except simpleeval.NameNotDefined:
                continue

            # If the caller has implemented a custom apply_ATTRIBUTENAME method
            # to handle application of the attribute, use it.
            custom_applicator = getattr(self, f"apply_{attribute}", None)
            if custom_applicator:
                form_field = custom_applicator(
                    form_field, **{attribute: expression_value}
                )

            # If no custom applicator method is implemented, but the form field
            # has an attribute with the specified name, set its value to the
            # result of the expression.
            elif hasattr(form_field, attribute):
                setattr(form_field, attribute, expression_value)

            # Finally, add the modifier and its value to the applied modifiers
            # dict on the field.
            setattr(
                form_field,
                "_modifiers",
                {
                    **getattr(form_field, "_modifiers", {}),
                    attribute: expression_value,
                },
            )

        return form_field

    def apply_hidden(
        self,
        form_field: form_fields.Field,
        hidden: bool,
        **kwargs: Any,
    ) -> form_fields.Field:
        """Apply the "hidden" attribute.

        If a field modifier specifies that the field should be "hidden", its
        widget is changed to a HiddenInput, and its "required" attribute is
        set to False (to avoid validation errors with fields that can't be
        seen).

        Args:
            form_field: The form field that should be hidden.
            hidden: The new value of "hidden".
            kwargs: Unused.

        Returns:
            form_fields.Field: The given form_field, modified to use the
                given "hidden" value.
        """
        if hidden:
            form_field.widget = form_widgets.HiddenInput()
            form_field.required = False

        return form_field

    def apply_value(
        self,
        form_field: form_fields.Field,
        value: Any,
        **kwargs: Any,
    ) -> form_fields.Field:
        """Apply the "value" field modifier.

        Allows a field modifier to set the value of a field based on an
        expression.

        Args:
            form_field: The form field for which to set the value.
            value: The new value.
            kwargs: Unused.

        Returns:
            form_fields.Field: The given form_field, modified to use the
                given value.
        """
        setattr(form_field, "_value", value)

        return form_field


class SingleLineTextField(FieldType):
    """A field for collecting single-line text values."""

    label = "Single-line Text"

    form_field_class = form_fields.CharField
    model_field_class = model_fields.TextField


class MultiLineTextField(FieldType):
    """A field for collecting multiline text values."""

    label = "Multi-line Text"

    form_field_class = form_fields.CharField
    form_widget_class = form_widgets.Textarea
    model_field_class = model_fields.TextField


class EmailField(FieldType):
    """A field for collecting email addresses."""

    label = "Email Address"

    form_field_class = form_fields.EmailField
    model_field_class = model_fields.EmailField


class URLField(FieldType):
    """A field for collecting URL values."""

    label = "URL"

    form_field_class = form_fields.URLField
    model_field_class = model_fields.URLField
    model_field_options = {"max_length": 2083}


class SensitiveTextField(FieldType):
    """A field for collecting sensitive text values.

    Primarily useful for obfuscating the input when on-screen.
    """

    label = "Sensitive text"

    form_field_class = form_fields.CharField
    form_widget_class = form_widgets.PasswordInput
    model_field_class = model_fields.TextField


class IntegerField(FieldType):
    """A field for collecting integer values."""

    label = "Integer"

    form_field_class = form_fields.IntegerField
    form_field_options = {"min_value": -2147483648, "max_value": 2147483647}
    form_widget_class = form_widgets.NumberInput
    form_widget_options = {"attrs": {"step": "1"}}
    model_field_class = model_fields.IntegerField


class PositiveIntegerField(FieldType):
    """A field for collecting positive integer values."""

    label = "Positive Integer"

    form_field_class = form_fields.IntegerField
    form_field_options = {"min_value": 1, "max_value": 2147483647}
    form_widget_class = form_widgets.NumberInput
    form_widget_options = {"attrs": {"step": "1"}}
    model_field_class = model_fields.PositiveIntegerField


class DecimalField(FieldType):
    """A field for collecting decimal number values."""

    label = "Decimal Number"

    form_field_class = form_fields.DecimalField
    form_field_options = {"max_digits": 15, "decimal_places": 6}
    form_widget_class = form_widgets.NumberInput
    form_widget_options = {"attrs": {"step": "0.01"}}
    model_field_class = model_fields.DecimalField
    model_field_options = {"max_digits": 15, "decimal_places": 6}


class FloatField(FieldType):
    """A field for collecting decimal number values."""

    label = "Floating-point Number"

    form_field_class = form_fields.FloatField
    form_widget_class = form_widgets.NumberInput
    form_widget_options = {"attrs": {"step": "0.1"}}
    model_field_class = model_fields.FloatField


class DateField(FieldType):
    """A field for collecting date data."""

    label = "Date"

    form_field_class = form_fields.DateField
    form_widget_class = form_widgets.DateInput
    form_widget_options = {"attrs": {"type": "date"}}
    model_field_class = model_fields.DateField

    # Field type options that dictate whether the collected date can be in the
    # past or future (both true by default).
    allow_past: bool = True
    allow_future: bool = True

    def as_form_field(self, **form_field_options: Any) -> form_fields.Field:
        """Return a configured form field.

        Adds validators to handle past and future date limitations.

        Args:
            form_field_options: Additional kwargs passed to the Django form
                field constructor.

        Returns:
            form_fields.Field: An instance of the form field.
        """
        return super().as_form_field(
            **{**form_field_options, "validators": self._get_validators()}
        )

    def as_form_widget(self, **form_widget_options: Any) -> form_widgets.Widget:
        """Return an instance of the form widget for rendering.

        Adds widget attributes to handle past and future date limitations.

        Args:
            form_widget_options: Additional kwargs passed to the Django form
                widget constructor.

        Returns:
            form_widgets.Widget: The configured form widget for the field.
        """
        attrs = {
            **self.form_widget_options,
            **self.field.form_widget_options,
            **form_widget_options,
        }.get("attrs", {})

        if not self.allow_past:
            attrs["min"] = date.today().isoformat()

        if not self.allow_future:
            attrs["max"] = date.today().isoformat()

        return super().as_form_widget(**{**form_widget_options, "attrs": attrs})

    def _get_validators(self) -> Tuple[BaseValidator, ...]:
        """Return validators for handling past and future date limitations."""
        validators = cast(Tuple[BaseValidator, ...], ())

        if not self.allow_past:
            validators = (*validators, MinValueValidator(limit_value=date.today))

        if not self.allow_future:
            validators = (*validators, MaxValueValidator(limit_value=date.today))

        return validators


class TimeField(FieldType):
    """A field for collecting time data."""

    label = "Time"

    form_field_class = form_fields.TimeField
    form_widget_class = form_widgets.TimeInput
    form_widget_options = {"attrs": {"type": "time"}}
    model_field_class = model_fields.TimeField


class DateTimeField(FieldType):
    """A field for collecting datetime data."""

    label = "Date & Time"

    form_field_class = form_fields.DateTimeField
    model_field_class = model_fields.DateTimeField

    # Field type options that dictate whether the collected datetime can be in
    # the past or future (both true by default).
    allow_past = True
    allow_future = True

    def as_form_field(self, **form_field_options: Any) -> form_fields.Field:
        """Return a configured form field.

        Adds validators to handle past and future date limitations.

        Args:
            form_field_options: Additional kwargs passed to the Django form
                field constructor.

        Returns:
            form_fields.Field: An instance of the form field.
        """
        return super().as_form_field(
            **{**form_field_options, "validators": self._get_validators()}
        )

    def as_form_widget(self, **form_widget_options: Any) -> form_widgets.Widget:
        """Return an instance of the form widget for rendering.

        Adds widget attributes to handle past and future date limitations.

        Args:
            form_widget_options: Additional kwargs passed to the Django form
                widget constructor.

        Returns:
            form_widgets.Widget: The configured form widget for the field.
        """
        attrs = form_widget_options.get("attrs", {})

        if not self.allow_past:
            attrs["min"] = datetime.now().isoformat()

        if not self.allow_future:
            attrs["max"] = datetime.now().isoformat()

        return super().as_form_widget(**{**form_widget_options, "attrs": attrs})

    def _get_validators(self) -> Tuple[BaseValidator, ...]:
        """Return validators for handling past and future date limitations."""
        validators = cast(Tuple[BaseValidator, ...], ())

        if not self.allow_past:
            validators = (*validators, MinValueValidator(limit_value=datetime.now))

        if not self.allow_future:
            validators = (*validators, MaxValueValidator(limit_value=datetime.now))

        return validators


class DurationField(FieldType):
    """A field for collecting duration data."""

    label = "Duration"

    form_field_class = form_fields.DurationField
    model_field_class = model_fields.DurationField


class CheckboxField(FieldType):
    """A field for collecting a boolean value with a checkbox."""

    label = "Checkbox"

    form_field_class = form_fields.BooleanField
    form_widget_class = form_widgets.CheckboxInput
    form_widget_options = {"attrs": {"value": "true"}}
    model_field_class = model_fields.BooleanField


class YesNoRadioField(FieldType):
    """A field for collecting a yes/no value with radio buttons."""

    label = "Yes/No Radio Buttons"

    form_field_class = form_fields.TypedChoiceField
    form_field_options = {
        "choices": (
            ("True", "Yes"),
            ("False", "No"),
        ),
        "coerce": lambda v: True if v in ("True", True) else False,
    }
    form_widget_class = form_widgets.RadioSelect
    model_field_class = model_fields.BooleanField


class YesNoUnknownRadioField(FieldType):
    """A field for collecting a yes/no/unknown value with radio buttons."""

    label = "Yes/No/Unknown Radio Buttons"

    form_field_class = form_fields.TypedChoiceField
    form_field_options = {
        "choices": (
            ("True", "Yes"),
            ("False", "No"),
            ("None", "Unknown"),
        ),
        "coerce": lambda v: (
            True if v in ("True", True) else False if v in ("False", False) else None
        ),
    }
    form_widget_class = form_widgets.RadioSelect
    model_field_class = model_fields.BooleanField
    model_field_options = {"null": True}


class YesNoSelectField(FieldType):
    """A field for collecting a boolean value with a yes/no select field."""

    label = "Yes/No Dropdown"

    form_field_class = form_fields.TypedChoiceField
    form_field_options = {
        "choices": (
            ("True", "Yes"),
            ("False", "No"),
        ),
        "coerce": lambda v: True if v in ("True", True) else False,
    }
    form_widget_class = form_widgets.Select
    model_field_class = model_fields.BooleanField


class YesNoUnknownSelectField(FieldType):
    """A field for collecting a boolean value with a yes/no select field."""

    label = "Yes/No/Unknown Dropdown"

    form_field_class = form_fields.TypedChoiceField
    form_field_options = {
        "choices": (
            ("True", "Yes"),
            ("False", "No"),
            ("None", "Unknown"),
        ),
        "coerce": lambda v: (
            True if v in ("True", True) else False if v in ("False", False) else None
        ),
    }
    model_field_class = model_fields.BooleanField
    model_field_options = {"null": True}


class SingleChoiceSelectField(FieldType):
    """A field for collecting a single text value from a select list."""

    label = "Single-choice Dropdown"

    form_field_class = form_fields.ChoiceField
    form_field_options = {"choices": _EMPTY_CHOICES}
    model_field_class = model_fields.TextField


class SingleChoiceRadioSelectField(FieldType):
    """A field for collecting a text value from a set of radio buttons."""

    label = "Radio Buttons"

    form_field_class = form_fields.ChoiceField
    form_field_options = {"choices": _EMPTY_CHOICES}
    form_widget_class = form_widgets.RadioSelect
    model_field_class = model_fields.TextField


class MultipleChoiceSelectField(FieldType):
    """A field for collecting multiple text values from a select list."""

    label = "Multiple-choice Dropdown"

    form_field_class = form_fields.MultipleChoiceField
    form_field_options = {"choices": _EMPTY_CHOICES}
    model_field_class = JSONField


class MultipleChoiceCheckboxField(FieldType):
    """A field for collecting multiple text values from a checkbox list."""

    label = "Multiple-choice Checkboxes"

    form_field_class = form_fields.MultipleChoiceField
    form_field_options = {"choices": _EMPTY_CHOICES}
    form_widget_class = form_widgets.CheckboxSelectMultiple
    model_field_class = JSONField


class FileUploadField(FieldType):
    """A field for collecting file uploads."""

    label = "File Upload"

    form_field_class = form_fields.FileField
    model_field_class = FileField


class ImageUploadField(FieldType):
    """A field for collecting image uploads."""

    label = "Image Upload"

    form_field_class = form_fields.ImageField
    model_field_class = ImageField


class HiddenJSONField(FieldType):
    """A field for storing JSON data behind the scenes."""

    label = "Hidden JSON Field"

    form_field_class = JSONFormField
    form_widget_class = form_widgets.HiddenInput
    model_field_class = JSONField


AutocompleteResult = TypedDict("AutocompleteResult", {"id": str, "text": str})
AutocompleteResultMapping = TypedDict(
    "AutocompleteResultMapping",
    {"root": str, "value": str, "text": str, "extra": Dict[str, str]},
    total=False,
)


class AutocompleteJSONField(JSONFormField):
    """A Django form field for storing autocomplete results."""

    EMPTY_VALUES: Tuple[Any, ...] = (None, "null", "[]", [])

    def has_changed(self, initial: Any, data: Any) -> bool:
        """Returns True if the field value has changed.

        Considers empty lists and "null" to be equivalent to None.

        Args:
            initial: The initial value.
            data: The new value.

        Returns:
            bool: True if the field value has changed.
        """
        data = None if data in self.EMPTY_VALUES else data
        return cast(bool, super().has_changed(initial, data))


class BaseAutocompleteSelectField(FieldType):
    """An interface for implementing autocomplete select fields."""

    class Meta:
        abstract = True

    form_field_class = AutocompleteJSONField
    form_widget_class = AutocompleteSelect
    model_field_class = JSONField

    # Autocomplete-specific field_type_options.
    DEFAULT_MAPPING: AutocompleteResultMapping = {
        "root": "@",
        "value": "id",
        "text": "text",
        "extra": {},
    }
    mapping: AutocompleteResultMapping

    def as_form_widget(self, **form_widget_options: Any) -> form_widgets.Widget:
        """Build the autocomplete form widget.

        Replaces the url parameter with a proxy URL to a custom view to allow
        developers to implement fine-grained search behavior.

        Args:
            form_widget_options: Parameters passed to the form widget
                constructor.

        Returns:
            form_widgets.Widget: A configured widget for rendering the
                autocomplete input.
        """
        proxy_url = urlparse.urlparse(
            reverse(
                "flexible_forms:autocomplete",
                kwargs={
                    "app_label": self.field._meta.app_label,
                    "model_name": self.field._meta.model_name,
                    "field_pk": self.field.pk,
                },
            )
        )

        # Parse the query string so we can manipulate it.
        query_params = dict(urlparse.parse_qsl(proxy_url.query))

        # If a specific record instance was supplied, append its ID to the URL
        # so that autocomplete suggestions can be tailored to a particular
        # record's field values.
        if self.record and self.record.pk:
            query_params["record_pk"] = self.record.pk

        # Re-encode the querystring and replace the original one.
        proxy_url = proxy_url._replace(query=urlencode(query_params))

        return super().as_form_widget(
            **{
                **form_widget_options,
                "url": urlparse.urlunparse(proxy_url),
            },
        )

    def autocomplete(
        self, request: HttpRequest
    ) -> Tuple[Iterable[AutocompleteResult], bool]:
        """Return paginated search results for the given search_term.

        Returns an iterable of AutocompleteSearchResult objects and a boolean
        indicating whether there are more to be fetched via pagination.

        Args:
            request: The HTTP request.

        Returns:
            Tuple[Sequence[AutocompleteResult], bool]: A two-tuple containing
                a sequence of zero or more select2-compatible search results, and
                a boolean indicating whether or not there are more results to
                fetch with pagination.
        """
        # Extract GET parameters common to all autocomplete fields.
        search_term = request.GET.get("term", "")
        page = int(request.GET.get("page", "1"))
        per_page = int(request.GET.get("per_page", "50"))

        record = cast("BaseRecord", self.record)
        record_variable = (
            (record._meta.verbose_name or "record").lower().replace(" ", "_")
        )

        # Iterpolate the form_field_options using context from the current request.
        field_type_options = interpolate(
            self.field.field_type_options,
            context={
                "term": search_term,
                "page": page,
                "per_page": per_page,
                "record": record,
                record_variable: record,
                **record._data,
                **self.field_values,
            },
        )

        return self.get_results(
            request,
            search_term=search_term,
            page=page,
            per_page=per_page,
            **field_type_options,
        )

    def get_results(
        self,
        request: HttpRequest,
        search_term: str,
        page: int,
        per_page: int,
        **field_type_options: Any,
    ) -> Tuple[Iterable[AutocompleteResult], bool]:
        """Perform a search and return paginated results.

        Should return an iterable of autocomplete-compatible results and a
        boolean indicating whether there are more results to be fetched via
        pagination.

        Args:
            request: The HTTP request.
            search_term: The term to search for.
            page: The pagination page number.
            per_page: The pagination page size.
            field_type_options: The field's field_type_options after having
                its values interpolated.

        Raises:
            NotImplementedError: If the implementing class doesn't implement
                a get_results() method.
        """
        raise NotImplementedError(  # pragma: no cover
            f"All AutocompleteField types must implement get_results()."
        )


class URLAutocompleteSelectField(BaseAutocompleteSelectField):
    """A field for autocompleting selections from an HTTP endpoint."""

    label = "Autocomplete (URL)"

    # URL autocomplete-specific field type options.
    url: str
    search_fields: List[str]

    def get_results(
        self,
        request: HttpRequest,
        search_term: str,
        page: int,
        per_page: int,
        **field_type_options: Any,
    ) -> Tuple[Iterable[AutocompleteResult], bool]:
        """Perform a search and return paginated results.

        Search is performed via a GET request to the configured URL.

        If the configured URL is path only (e.g., "/search?query={{term}}")
        and that path can be resolved to a view function in the application,
        the view function will be called directly with the current request
        object (modified with the new path and GET parameters).

        Args:
            request: The HTTP request.
            search_term: The term to search for.
            page: The pagination page number.
            per_page: The pagination page size.
            field_type_options: The field's field_type_options after having
                its values interpolated.

        Returns:
            Tuple[Iterable[AutocompleteResult], bool]: An iterable of
                AutocompleteResult dicts, and a boolean indicating whether
                there are more results to be fetched via pagination.
        """
        results = cast(List[AutocompleteResult], [])
        has_more = False

        url: RenderedString = field_type_options["url"]

        if not url:
            logger.warning(
                f"The URL for field '{self.field}' was blank, and so no "
                f"autocomplete search could take place. This may be a "
                f"misconfiguration."
            )
            return results, has_more

        mapping = cast(
            AutocompleteResultMapping,
            {**self.DEFAULT_MAPPING, **field_type_options.get("mapping", {})},
        )
        search_manually: bool = "term" not in url.__context__

        # Get the JSON response from the endpoint.
        response_json = self._get_json_response(request, url)

        # Parse the search response and map each result to a Select2-compatible
        # dict with an "id" and a "text" property.
        raw_results = jp(mapping["root"], response_json, [])
        for raw_result in raw_results:
            result_text = str(jp(mapping["text"], raw_result))

            # If we're configured to search manually, skip results that don't
            # have the search term in the extracted text.
            if search_manually and search_term:
                if search_term.casefold() not in result_text.casefold():
                    continue

            results.append(
                create_autocomplete_result(
                    text=result_text,
                    value=jp(mapping["value"], raw_result),
                    extra={k: jp(v, raw_result) for k, v in mapping["extra"].items()},
                )
            )

        # If we're configured to paginate manually, then paginate.
        paginator = Paginator(results, per_page)
        paginated_page = paginator.page(page)
        results = cast(List[AutocompleteResult], paginated_page.object_list)
        has_more = paginated_page.has_next()

        return results, has_more

    def _get_json_response(self, request: HttpRequest, url: str) -> Any:
        """Make a request to the given URL and return the response JSON.

        Args:
            request: The current HttpRequest
            url: The URL to make a request to.

        Returns:
            Any: The JSON response body.
        """
        parsed_url = urlparse.urlparse(url)
        response_json = None

        # If the configured URL is just a path, we might be able to simply
        # proxy the request to an internal view function instead of making an
        # entirely new HTTP request. This is less expensive and side-steps
        # issues with auth when calling local views.
        if not parsed_url.netloc:
            try:
                resolver_match = resolve(parsed_url.path)
            except Resolver404:  # pragma: no cover
                pass
            else:
                view_func, args, kwargs = resolver_match

                # Invalidate cached properties affected by the request update.
                for attr, value in type(request).__dict__.items():
                    if not isinstance(value, cached_property):
                        continue
                    try:
                        delattr(request, attr)
                    except AttributeError:
                        pass

                # Update the request to point to the configured URL.
                request.META["QUERY_STRING"] = parsed_url.query
                request.GET = QueryDict(parsed_url.query)
                request.path = parsed_url.path
                request.path_info = parsed_url.path
                request.resolver_match = resolver_match

                # Call the view function with the updated request object.
                response = view_func(request, *args, **kwargs)
                response_json = json.loads(
                    response.rendered_content
                    if hasattr(response, "rendered_content")
                    else response.content
                )

        # If we weren't able to call a local view to get our results, we'll
        # make a regular GET request to the URL instead.
        if response_json is None:
            response = requests.get(request.build_absolute_uri(url))
            response.raise_for_status()
            response_json = response.json()

        return response_json


class URLAutocompleteSelectMultipleField(URLAutocompleteSelectField):
    """An autocomplete field that supports multiple selections."""

    label = "Autocomplete Multiple (URL)"
    form_widget_class = AutocompleteSelectMultiple


class QuerysetAutocompleteSelectField(BaseAutocompleteSelectField):
    """An autocomplete field that sources options from a queryset."""

    label = "Autocomplete (QuerySet)"

    model: str
    search_fields: List[str]
    filter: Dict[str, Any]
    exclude: Dict[str, Any]

    def get_results(
        self,
        request: HttpRequest,
        search_term: str,
        page: int,
        per_page: int,
        **field_type_options: Any,
    ) -> Tuple[Iterable[AutocompleteResult], bool]:
        """Perform a search and return paginated results.

        Search is performed with a QuerySet against the configured model
        using basic filters, along with any full-text search capabilities of
        the databse if available. Each model instance is then JSONified so
        that it can be queries with JMESPath by the configured mapping.

        Args:
            request: The HTTP request.
            search_term: The term to search for.
            page: The pagination page number.
            per_page: The pagination page size.
            field_type_options: The field's field_type_options after having
                its values interpolated.

        Returns:
            Tuple[Iterable[AutocompleteResult], bool]: An iterable of
                AutocompleteResult dicts, and a boolean indicating whether
                there are more results to be fetched via pagination.

        Raises:
            ImproperlyConfigured: If the configured search_field is not a
                field on the configured model (i.e. the field doesn't exist or
                is a property method, etc).
        """
        results = cast(List[AutocompleteResult], [])
        has_more = False

        # Extract the relevant configuration from the form widget options.
        model_name: str = field_type_options["model"]
        filter: Dict[str, Any] = field_type_options.get("filter", {})
        exclude: Dict[str, Any] = field_type_options.get("exclude", {})
        mapping = cast(
            AutocompleteResultMapping,
            {**self.DEFAULT_MAPPING, **field_type_options.get("mapping", {})},
        )

        # Build a set of model field names used in the mapping values. This
        # will be used to transform the database records into JSON before
        # applying the mapping.
        expression_fields: Set[str] = set()
        for expr in (*mapping.values(), *mapping["extra"].values()):
            if not isinstance(expr, str):
                continue
            expression_fields.update(get_expression_fields(expr))

        # Resolve the model class from the Django registry and build a queryset
        # with the given parameters.
        model_cls = cast(Type[DjangoModel], apps.get_model(model_name))
        model_fields = frozenset(
            [*(f.name for f in model_cls._meta.get_fields()), "pk"]
        )
        qs = (
            model_cls._default_manager.filter(**(filter or {}))
            .exclude(**(exclude or {}))
            .order_by("pk")
        )

        search_fields = field_type_options.get(
            "search_fields",
            # If no search_fields were configured explicitly, derive them from the
            # fields used in the "text" expression of the configured mapping.
            tuple(
                field_name
                for field_name in get_expression_fields(mapping["text"])
                if field_name in model_fields
            ),
        )

        # If any of the search_fields is not a model field, it can't be
        # used to filter results.
        if any(f.split("__")[0] not in model_fields for f in search_fields):
            raise ImproperlyConfigured(
                f"The search_fields option for {self.name} fields "
                f"must contain only the names of model fields."
            )

        # If no fields were specified to search, log a warning.
        if not search_fields:  # pragma: no cover
            logger.warning(
                f"No search_fields were defined or discovered, and so the "
                f"{self.name} field cannot filter results with a search "
                f"term."
            )

        # If a serch term is given, search for it using case-insensitive
        # queryset filters against any configured search_fields.
        if search_term and search_fields:
            # Look for a vendor-specific method for search, or use the fallback.
            search_method = getattr(
                self, f"_search_{connections[qs.db].vendor}", self._search_fallback
            )
            qs = search_method(qs, search_fields=search_fields, search_term=search_term)

        # Return distinct records (in case filters cause duplicates).
        qs = qs.distinct()

        # Paginate the queryset before mapping, since we don't know how big the
        # queryset could be.
        paginator = Paginator(qs, per_page)
        paginated_page = paginator.page(page)
        raw_results = paginated_page.object_list
        has_more = paginated_page.has_next()

        # Parse the search response and map each result to a Select2-compatible
        # dict with an "id" and a "text" property.
        for instance in raw_results:
            results.append(self.result_from_instance(instance, mapping=mapping))

        return results, has_more

    def result_from_instance(
        self, instance: DjangoModel, mapping: AutocompleteResultMapping
    ) -> AutocompleteResult:
        """Create an autocomplete-compatible result from a model instance.

        Uses the given mapping to generate the result data structure.

        Args:
            instance: The model instance to map to the result.
            mapping: The mapping to follow.

        Returns:
            AutocompleteResult: An autocomplete-compatible result.
        """
        expression_fields: Set[str] = set()
        for expr in (*mapping.values(), *mapping["extra"].values()):
            if not isinstance(expr, str):
                continue
            expression_fields.update(get_expression_fields(expr))

        instance_json = {}
        for expression_field in expression_fields:
            value = getattr(instance, expression_field, None)
            instance_json[expression_field] = str(value() if callable(value) else value)

        return create_autocomplete_result(
            text=str(jp(mapping["text"], instance_json)),
            value=jp(mapping["value"], instance_json),
            extra={k: jp(v, instance_json) for k, v in mapping["extra"].items()},
        )

    def _search_postgresql(
        self,
        queryset: "QuerySet[DjangoModel]",
        search_fields: Tuple[str, ...],
        search_term: str,
    ) -> "QuerySet[DjangoModel]":
        """Perform a search with a postgres backend.

        Uses postgresql-specific search strategies for better search results.

        Args:
            queryset: The queryset to filter with the given search parameters.
            search_fields: The names of the model fields to search.
            search_term: The term to search for.

        Returns:
            QuerySet[DjangoModel]: The filtered QuerySet.
        """
        from django.contrib.postgres.search import (
            SearchQuery,
            SearchRank,
            TrigramSimilarity,
        )

        # Check for trigram support (the pg_trgm PostgreSQL extension).
        db_connection = connections[queryset.db]
        supports_pg_trgm = cache.get_or_set(
            f"flexible_forms:db:{queryset.db}:supports_pg_trgm",
            lambda: check_supports_pg_trgm(db_connection),
        )

        # Annotate the queryset with search scores.
        search_rank_fields: Set[F] = set()
        for field_name in search_fields:
            strict_score_field = F(f"_{field_name}_strict_score")
            similarity_score_field = F(f"_{field_name}_similarity_score")
            search_rank_fields.update([strict_score_field, similarity_score_field])

            # Annotate the queryset with "strict" and "similarity" scores for
            # each field.
            #
            # The "strict" score is how well the search term matches the field
            # value with simple comparisons (iexact, istartswith, and
            # icontains).
            #
            # The "similarity" score uses PostgreSQL full-text search
            # capabilities (trigram similarity if available, otherwise tsvector
            # rankings with prefix search).
            queryset = queryset.annotate(
                **{
                    strict_score_field.name: Case(
                        When(**{f"{field_name}__iexact": search_term, "then": 3}),
                        When(**{f"{field_name}__istartswith": search_term, "then": 2}),
                        When(**{f"{field_name}__icontains": search_term, "then": 1}),
                        output_field=model_fields.IntegerField(),
                        default=0,
                    ),
                    # Annotate the queryset with a similarity score in addition
                    # to the strict matching.
                    similarity_score_field.name: (
                        # Use trigram similarity (pg_trgm) if it's supported.
                        TrigramSimilarity(field_name, search_term)
                        if supports_pg_trgm
                        else
                        # Use SearchRank (to_tsvector) as a fallback.
                        SearchRank(
                            vector=field_name,
                            # Split the search term into words, append a prefix
                            # operator to each one, and IOR them together for
                            # a sort of pseudo-lexeme search ranking.
                            query=reduce(
                                ior,
                                (
                                    SearchQuery(f"{word}:*", search_type="raw")
                                    for word in search_term.split(" ")
                                    if word
                                ),
                            ),
                        )
                    ),
                }
            )

        # Sum up all of the score fields into a single search rank.
        search_rank = Sum(
            reduce(add, search_rank_fields), output_field=model_fields.FloatField()
        )

        # Sum up all of the search scores into an aggregated search rank.
        queryset = queryset.annotate(_search_rank=search_rank)
        # Filter out any results that are total non-matches.
        queryset = queryset.filter(_search_rank__gt=0.0)
        # Order the results by the search rank, highest first.
        queryset = queryset.order_by(f"-_search_rank")

        return queryset

    def _search_fallback(
        self,
        queryset: "QuerySet[DjangoModel]",
        search_fields: Tuple[str, ...],
        search_term: str,
    ) -> "QuerySet[DjangoModel]":
        """Perform a search with a generic backend.

        Args:
            queryset: The queryset to filter with the given search parameters.
            search_fields: The names of the model fields to search.
            search_term: The term to search for.

        Returns:
            QuerySet[DjangoModel]: The filtered QuerySet.
        """
        # Annotate the queryset with search scores.
        search_rank_fields: Set[F] = set()
        for field_name in search_fields:
            strict_score_field = F(f"_{field_name}_strict_score")
            search_rank_fields.add(strict_score_field)

            # Annotate the queryset with a search score based on how well the
            # field value matches the search term.
            queryset = queryset.annotate(
                **{
                    strict_score_field.name: Case(
                        When(**{f"{field_name}__iexact": search_term, "then": 3}),
                        When(**{f"{field_name}__istartswith": search_term, "then": 2}),
                        When(**{f"{field_name}__icontains": search_term, "then": 1}),
                        output_field=model_fields.IntegerField(),
                        default=0,
                    ),
                }
            )

        # Sum up all of the score fields into a single search rank.
        search_rank = Sum(
            reduce(add, search_rank_fields), output_field=model_fields.FloatField()
        )

        # Sum up all of the search scores into an aggregated search rank.
        queryset = queryset.annotate(_search_rank=search_rank)
        # Filter out any results that are total non-matches.
        queryset = queryset.filter(_search_rank__gt=0)
        # Order the results by the search rank, highest first.
        queryset = queryset.order_by(f"-_search_rank")

        return queryset

    def apply_value(
        self,
        form_field: form_fields.Field,
        value: Any,
        **kwargs: Any,
    ) -> form_fields.Field:
        """Set the value of the autocomplete field.

        Given a model instance or primary key as the value, returns a mapped
        autocomplete-compatible result.

        Args:
            form_field: The field for which to set the value.
            value: The model instance or primary key for which to generate an
                autocomplete result.
            kwargs: Unused.

        Returns:
            form_fields.Field: The given form field, modified to include the
                new value.
        """
        model_instance = value if isinstance(value, DjangoModel) else None

        # If the given value isn't a model instance, assume it's the primary key of the instance and look it up.
        if not model_instance:
            model_cls = cast(Type[DjangoModel], apps.get_model(self.model))
            model_instance = model_cls._default_manager.get(pk=value)

        # Map the retrieved model instance to an autocomplete-friendly result dict.
        result = self.result_from_instance(
            model_instance,
            mapping=cast(
                AutocompleteResultMapping,
                {**self.DEFAULT_MAPPING, **self.mapping},
            ),
        )

        return super().apply_value(form_field, result, **kwargs)


class QuerysetAutocompleteSelectMultipleField(QuerysetAutocompleteSelectField):
    """A queryset autocomplete field that supports multiple selections."""

    label = "Autocomplete Multiple (QuerySet)"
    form_widget_class = AutocompleteSelectMultiple
