"""Improve the Django model docstrings.

For example:
* Automatically mention all model fields as parameters in the model construction.
* Mention form fields.
* Improve field representations in the documentation.

Based on:

* https://gist.github.com/abulka/48b54ea4cbc7eb014308
* https://gist.github.com/codingjoe/314bda5a07ff3b41f247
"""
import re

import django
from django import forms
from django.apps import apps
from django.db import models
from django.db.models.fields import related_descriptors
from django.db.models.fields.files import FileDescriptor
from django.db.models.manager import ManagerDescriptor
from django.db.models.query_utils import DeferredAttribute
from django.utils.encoding import force_text
from django.utils.html import strip_tags
from django.utils.module_loading import import_string
from sphinxcontrib_django import config

_FIELD_DESCRIPTORS = (FileDescriptor,)
RE_GET_FOO_DISPLAY = re.compile(r"\.get_(?P<field>[a-zA-Z0-9_]+)_display$")
RE_GET_NEXT_BY = re.compile(r"\.get_next_by_(?P<field>[a-zA-Z0-9_]+)$")
RE_GET_PREVIOUS_BY = re.compile(r"\.get_previous_by_(?P<field>[a-zA-Z0-9_]+)$")


# Support for some common third party fields
try:
    from phonenumber_field.modelfields import PhoneNumberDescriptor

    _FIELD_DESCRIPTORS += (PhoneNumberDescriptor,)
except ImportError:
    PhoneNumberDescriptor = None


def setup(app):
    """Allow this package to be used as Sphinx extension.

    This is also called from the top-level ``__init__.py``.

    :type app: sphinx.application.Sphinx
    """
    from .patches import patch_django_for_autodoc

    # When running, make sure Django doesn't execute querysets
    patch_django_for_autodoc()

    # Generate docstrings for Django model fields
    # Register the docstring processor with sphinx
    app.connect("autodoc-process-docstring", improve_model_docstring)

    # influence skip rules
    app.connect("autodoc-skip-member", autodoc_skip)


def autodoc_skip(app, what, name, obj, skip, options):
    """Hook to tell autodoc to include or exclude certain fields.

    Sadly, it doesn't give a reference to the parent object,
    so only the ``name`` can be used for referencing.

    :type app: sphinx.application.Sphinx
    :param what: The parent type, ``class`` or ``module``
    :type what: str
    :param name: The name of the child method/attribute.
    :type name: str
    :param obj: The child value (e.g. a method, dict, or module reference)
    :param options: The current autodoc settings.
    :type options: dict

    .. seealso:: http://www.sphinx-doc.org/en/stable/ext/autodoc.html#event-autodoc-skip-member
    """
    if name in config.EXCLUDE_MEMBERS:
        return True

    if name in config.INCLUDE_MEMBERS:
        return False

    return skip


def improve_model_docstring(app, what, name, obj, options, lines):
    """Hook to improve the autodoc docstrings for Django models.

    :type app: sphinx.application.Sphinx
    :param what: The parent type, ``class`` or ``module``
    :type what: str
    :param name: The dotted path to the child method/attribute.
    :type name: str
    :param obj: The Python object that i s being documented.
    :param options: The current autodoc settings.
    :type options: dict
    :param lines: The current documentation lines
    :type lines: list
    """
    if what == "class":
        _improve_class_docs(app, obj, lines)
    elif what == "attribute":
        _improve_attribute_docs(obj, name, lines)
    elif what == "method":
        _improve_method_docs(obj, name, lines)

    # Return the extended docstring
    return lines


def _improve_class_docs(app, cls, lines):
    """Improve the documentation of a class."""
    if issubclass(cls, models.Model):
        _add_model_fields_as_params(app, cls, lines)
    elif issubclass(cls, forms.Form):
        _add_form_fields(cls, lines)


def _add_model_fields_as_params(app, obj, lines):
    """Improve the documentation of a Django model subclass.

    This adds all model fields as parameters to the ``__init__()`` method.

    :type app: sphinx.application.Sphinx
    :type lines: list
    """
    param_offset = len(":param ")
    type_offset = len(":type ")
    predefined_params = [
        line[param_offset : line.find(":", param_offset)]
        for line in lines
        if line.startswith(":param ") and ":" in line[param_offset:]
    ]
    predefined_types = [
        line[type_offset : line.find(":", type_offset)]
        for line in lines
        if line.startswith(":type ") and ":" in line[type_offset:]
    ]

    for field in obj._meta.get_fields():
        try:
            help_text = strip_tags(force_text(field.help_text))
            verbose_name = force_text(field.verbose_name).capitalize()
        except AttributeError:
            # e.g. ManyToOneRel
            continue

        # Add parameter
        if field.name not in predefined_params:
            if help_text:
                if verbose_name:
                    if not verbose_name.strip().endswith("."):
                        verbose_name += "."
                    help_text = verbose_name + " " + help_text
                lines.append(u":param %s: %s" % (field.name, help_text))
            else:
                lines.append(u":param %s: %s" % (field.name, verbose_name))

        # Add type
        if field.name not in predefined_types:
            lines.append(_get_field_type(field))

    if (
        "sphinx.ext.inheritance_diagram" in app.extensions
        and "sphinx.ext.graphviz" in app.extensions
        and not any("inheritance-diagram::" in line for line in lines)
    ):
        lines.append(".. inheritance-diagram::")


def _add_form_fields(obj, lines):
    """Improve the documentation of a Django Form class.

    This highlights the available fields in the form.
    """
    lines.append("**Form fields:**")
    lines.append("")
    for name, field in obj.base_fields.items():
        field_type = "{}.{}".format(
            field.__class__.__module__, field.__class__.__name__
        )
        tpl = "* ``{name}``: {label} (:class:`~{field_type}`)"
        lines.append(
            tpl.format(
                name=name,
                field=field,
                label=field.label or name.replace("_", " ").title(),
                field_type=field_type,
            )
        )


def _get_field_type(field):
    if isinstance(field, models.ForeignKey):
        if django.VERSION >= (2, 0):
            to = field.remote_field.model
            if isinstance(to, str):
                to = _resolve_model(field, to)
        else:
            to = field.rel.to
            if isinstance(to, str):
                to = _resolve_model(field, to)

        return u":type %s: %s to :class:`~%s.%s`" % (
            field.name,
            type(field).__name__,
            to.__module__,
            to.__name__,
        )
    else:
        return u":type %s: %s" % (field.name, type(field).__name__)


def _resolve_model(field, to):
    if "." in to:
        return apps.get_model(to)
    elif to == "self":
        return field.model
    else:
        return apps.get_model(field.model._meta.app_label, to)


def _improve_attribute_docs(obj, name, lines):
    """Improve the documentation of various attributes.

    This improves the navigation between related objects.

    :param obj: the instance of the object to document.
    :param name: full dotted path to the object.
    :param lines: expected documentation lines.
    """
    if obj is None:
        # Happens with form attributes.
        return

    if isinstance(obj, DeferredAttribute):
        # This only points to a field name, not a field.
        # Get the field by importing the name.
        cls_path, field_name = name.rsplit(".", 1)
        model = import_string(cls_path)
        field = model._meta.get_field(field_name)

        del lines[:]  # lines.clear() is Python 3 only
        lines.append("**Model field:** {label}".format(label=field.verbose_name))
    elif isinstance(obj, _FIELD_DESCRIPTORS):
        # These
        del lines[:]
        lines.append("**Model field:** {label}".format(label=obj.field.verbose_name))

        if isinstance(obj, FileDescriptor):
            lines.append(
                "**Return type:** :class:`~django.db.models.fields.files.FieldFile`"
            )
        elif PhoneNumberDescriptor is not None and isinstance(
            obj, PhoneNumberDescriptor
        ):
            lines.append(
                "**Return type:** :class:`~phonenumber_field.phonenumber.PhoneNumber`"
            )
    elif isinstance(obj, related_descriptors.ForwardManyToOneDescriptor):
        # Display a reasonable output for forward descriptors.
        related_model = obj.field.remote_field.model
        if isinstance(related_model, str):
            cls_path = related_model
        else:
            cls_path = "{}.{}".format(related_model.__module__, related_model.__name__)
        del lines[:]
        lines.append(
            "**Model field:** {label}, "
            "accesses the :class:`~{cls_path}` model.".format(
                label=obj.field.verbose_name, cls_path=cls_path
            )
        )
    elif isinstance(obj, related_descriptors.ReverseOneToOneDescriptor):
        related_model = obj.related.related_model
        if isinstance(related_model, str):
            cls_path = related_model
        else:
            cls_path = "{}.{}".format(related_model.__module__, related_model.__name__)
        del lines[:]
        lines.append(
            "**Model field:** {label}, "
            "accesses the :class:`~{cls_path}` model.".format(
                label=obj.related.field.verbose_name, cls_path=cls_path
            )
        )
    elif isinstance(obj, related_descriptors.ReverseManyToOneDescriptor):
        related_model = obj.rel.related_model
        if isinstance(related_model, str):
            cls_path = related_model
        else:
            cls_path = "{}.{}".format(related_model.__module__, related_model.__name__)
        del lines[:]
        lines.append(
            "**Model field:** {label}, "
            "accesses the M2M :class:`~{cls_path}` model.".format(
                label=obj.field.verbose_name, cls_path=cls_path
            )
        )
    elif isinstance(obj, (models.Manager, ManagerDescriptor)):
        # Somehow the 'objects' manager doesn't pass through the docstrings.
        module, cls_name, field_name = name.rsplit(".", 2)
        lines.append("Django manager to access the ORM")
        tpl = "Use ``{cls_name}.objects.all()`` to fetch all objects."
        lines.append(tpl.format(cls_name=cls_name))


def _improve_method_docs(obj, name, lines):
    """Improve the documentation of various methods.

    :param obj: the instance of the method to document.
    :param name: full dotted path to the object.
    :param lines: expected documentation lines.
    """
    if not lines:
        # Not doing obj.__module__ lookups to avoid performance issues.
        if name.endswith("_display"):
            match = RE_GET_FOO_DISPLAY.search(name)
            if match is not None:
                # Django get_..._display method
                lines.append(
                    "**Autogenerated:** Shows the label of the :attr:`{field}`".format(
                        field=match.group("field")
                    )
                )
        elif ".get_next_by_" in name:
            match = RE_GET_NEXT_BY.search(name)
            if match is not None:
                lines.append(
                    "**Autogenerated:** Finds next instance"
                    " based on :attr:`{field}`.".format(field=match.group("field"))
                )
        elif ".get_previous_by_" in name:
            match = RE_GET_PREVIOUS_BY.search(name)
            if match is not None:
                lines.append(
                    "**Autogenerated:** Finds previous instance"
                    " based on :attr:`{field}`.".format(field=match.group("field"))
                )