import re
from contextlib import suppress
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, Final, List, TypeAlias, cast  # noqa: UP035
from urllib.parse import unquote, urlparse

import lxml.html
from django.conf import settings
from django.db import models
from django.urls import reverse
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from django_extensions.db.models import TimeStampedModel
from lxml import etree
from lxml.html import HtmlElement
from wildewidgets.models import ViewSetMixin

from .fields import MachineNameField
from .settings import MAX_GLOBAL_TOC_TREE_DEPTH
from .validators import NoHTMLValidator

F: TypeAlias = models.Field
M2M: TypeAlias = models.ManyToManyField
FK: TypeAlias = models.ForeignKey


# --------------------------
# Dataclasses
# --------------------------


@dataclass
class TreeNode:
    """
    A :py:class:`dataclass` that we use with :py:class:`SphinxPageTree`
    to build out the global navigation structure for a set of documentation for
    a :py:class:`Version`.
    """

    #: The page title
    title: str
    #: This page
    page: "SphinxPage | None" = None
    #: The :py:class:`SphinxPage` after this one
    prev: "SphinxPage | None" = None
    #: The :py:class:`SphinxPage` before this one
    next: "SphinxPage | None" = None
    #: The :py:class:`SphinxPage` that is this page's parent
    parent: "SphinxPage | None" = None
    #: The :py:class:`TreeNode` objects that are this page's children
    children: list["TreeNode"] = field(default_factory=list)

    @classmethod
    def from_page(cls, page: "SphinxPage") -> "TreeNode":
        """
        Build a :py:class:`TreeNode` from ``page``.

        Note:
            This does not populate :py:attr:`children`; :py:class:`SphinxPageTree`
            will populate it as appropriate as it ingests pages.

        Args:
            page: the :py:class:`SphinxPage` from which to build a node

        Returns:
            A configured node.

        """
        return cls(
            page=page,
            title=page.title,
            next=page.next_page,  # type: ignore[arg-type]
            prev=page.previous_page.first(),
            parent=page.parent,  # type: ignore[arg-type]
        )


@dataclass
class ClassifierNode:
    # The title of the classifier node
    title: str
    # The classifier for the classifier node
    classifier: "Classifier | None" = None
    # The items for the classifier node
    items: dict[str, "ClassifierNode"] = field(default_factory=dict)


# --------------------------
# Helper classes
# --------------------------


class SphinxPageTree:
    """
    A class that holds the page hierarchy for the set of :py:class:`SphinxPage`
    pages in a :py:class:`Version`.as a linked set of :py:class:`TreeNode`
    objects.

    The page heirarchy is built by starting at :py:attr:`Version.head` and
    following the page linkages by looking at :py:attr:`SphinxPage.next_page`,
    stopping the traversal when we find a :py:attr:`SphinxPage.next_page` that
    is ``None``.

    As we traverse, if a :py:attr:`SphinxPage.parent` is not ``None``, find the
    :py:class:`TreeNode` for that parent, and add the page to
    :py:attr:`TreeNode.children`.

    For pages who have no :py:attr:`SpinxPage.parent`, assume they are top level
    children of the set, and make them children of :py:class:`Version.head`.

    Load it like so::

        >>> project = Project.objects.get(machine_name='my-project')
        >>> version = project.versions.get(version='1.0.0')
        >>> tree = SphinxPageTree(version)

    You can then traverse the built hierarchy by starting at
    :py:attr:`SphinxPageTree.head`, looking at its children, then looking at
    their children, etc..

    Args:
        version: the :py:class:`Version` to build the tree for

    """

    def __init__(self, version: "Version") -> None:
        #: The :py:class:`Version` that this tree examines
        self.version: Version = version
        self.nodes: Dict[int, TreeNode] = {}
        self.nodes[self.version.head.id] = TreeNode.from_page(self.version.head)  # type: ignore[attr-defined, arg-type]
        #: The top page in the page hierarchy
        self.head: TreeNode = self.nodes[self.version.head.id]  # type: ignore[attr-defined]
        self.build(version)

    def build(self, version: "Version"):
        self.add_page(version.head)  # type: ignore[arg-type]

    def add_page(self, page: "SphinxPage"):
        node = TreeNode.from_page(page)
        self.nodes[page.id] = node
        if node.parent:
            if node.parent.id in self.nodes:
                self.nodes[node.parent.id].children.append(node)
        elif node != self.head:
            # The top level pages that are not head will not have any parent
            # because Sphinx doesn't think that way
            self.head.children.append(node)
        if node.next:
            self.add_page(node.next)

    def _traverse_level(self, pages, nodes):
        for node in nodes:
            if node.page:
                pages.append(node.page)
                if node.children:
                    self._traverse_level(pages, node.children)

    def traverse(self) -> list["SphinxPage"]:
        """
        Return a list of the pages represented in this tree.
        """
        pages: List["SphinxPage"] = [cast("SphinxPage", self.head.page)]  # noqa: UP037
        self._traverse_level(pages, self.head.children)
        return pages


class SphinxPageTreeProcessor:
    def build_item(self, node: TreeNode) -> Dict[str, Any]:
        """
        Build a :py:class:`wildewdigets.MenuItem` compatible
        dict representing ``node``.

        Args:
            node: the current node in our page tree

        Returns:
            A dict suitable for loading into a :py:class:`wildewidgets.MenuItem`.

        """
        item: Dict[str, Any] = {
            "text": node.title,
            "url": None,
            "icon": None,
            "items": [],
        }
        if node.page:
            item["url"] = node.page.get_absolute_url()
        return item

    def build(self, items: List[Dict[str, Any]], node: TreeNode) -> None:
        """
        Build a :py:class:`wildewdigets.MenuItem` compatible
        dict representing ``node``, and append it to ``items``.

        if ``node`` has children, recurse into those children, building out
        our submenus.

        Args:
            items: the current list of ``MenuItem`` compatible dicts for the
                current level of the menu
            node: the current node in our page tree

        """
        item = self.build_item(node)
        if node.children:
            for child in node.children:
                self.build(item["items"], child)
        items.append(item)

    def run(self, version: "Version") -> List[Dict[str, Any]]:
        """
        Parse the :py:func:`Version.page_tree` and return a struct that works
        with
        :py:class:`sphinx_hosting.wildewidgets.SphinxPageGlobalTableOfContentsMenu.parse_obj`

        The returned struct should look something like this::

            [
                {'text': 'foo'},
                {'text': 'bar', 'url': '/foo', 'icon': None}
                {'text': 'bar', 'url': '/foo', 'icon': None, items: [{'text': 'blah' ...} ...]}
                ...
            ]

        Args:
            version: the version whose global table of contents we are parsing

        Returns:
            A list of dicts representing the global menu structure

        """  # noqa: E501
        self.sphinx_tree = version.page_tree
        items: List[Dict[str, Any]] = []
        items.append(self.build_item(self.sphinx_tree.head))
        for child in self.sphinx_tree.head.children:
            self.build(items, child)
        return items


class SphinxGlobalTOCHTMLProcessor:
    """
    **Usage**: ``SphinxGlobalTOCHTMLProcessor().run(version, globaltoc_html)```

    This importer is used to parse the ``globaltoc`` key in JSON output of
    Sphinx pages built with the `sphinxcontrib-jsonglobaltoc
    <https://github.com/caltechads/sphinxcontrib-jsonglobaltoc>`_ extension.

    Sphinx uses your ``.. toctree:`` declarations in your ``.rst`` files to
    build site navigation for your document tree, and
    ``sphinxcontrib-jsonglobaltoc`` saves the Sphinx HTML produced by those
    ``..toctree`` as the ``globaltoc`` key in the `.fjson` output.

    Note:
        Sphinx ``.. toctree:`` are ad-hoc -- they're up to how the author wants
        to organize their content, and may not reflect how files are filled out
        in the filesystem.

    """

    def __init__(self, max_level: int = 2) -> None:
        #: Generate at most this many levels of menus and submenus
        self.max_level: int = max_level

    def fix_href(self, href: str) -> str:
        p = urlparse(unquote(href))
        url = reverse(
            "sphinx_hosting:sphinxpage--detail",
            kwargs={
                "project_slug": self.version.project.machine_name,  # type: ignore[attr-defined]
                "version": self.version.version,
                "path": p.path.strip("/"),
            },
        )
        if p.fragment:
            url += f"#{p.fragment}"
        return url

    def parse_ul(self, html: HtmlElement, level: int = 1) -> List[Dict[str, Any]]:
        """
        Process ``html``, an ``lxml`` parsed set of elements representing the contents
        of a ``<ul>`` from a Sphinx table of contents and return a list of
        :py:class:`sphinx_hosting.wildewidgets.MenuItem` objects.

        Any ``href`` in links found will be converted to its full
        ``django-sphinx-hosting`` path.

        If we find another ``<ul>`` inside ``html``, process it by passing its
        contents to :py:meth:`parse_ul` again, incrementing the menu level.

        If ``level`` is greater than :py:attr:`max_level`, return an empty list,
        stopping our recursion.

        Args:
            html: the list of elements that are the contents of the parent ``<ul>``

        Keyword Args:
            level: the current menu level

        Returns:
            The ``<ul>`` contents as a list of dicts

        """
        items: List[Dict[str, Any]] = []
        if level <= self.max_level:
            for li in html.iterchildren():
                item: Dict[str, Any] = {
                    "text": "placeholder",
                    "url": None,
                    "icon": None,
                    "items": [],
                }
                for elem in li:
                    if elem.tag == "a":
                        item["text"] = elem.text_content()
                        item["url"] = self.fix_href(elem.attrib["href"])
                    if elem.tag == "ul":
                        item["items"].extend(self.parse_ul(elem, level=level + 1))
                items.append(item)
        return items

    def parse_globaltoc(self, html: HtmlElement) -> List[Dict[str, Any]]:
        """
        Parse our global table of contents HTML blob and return a list of
        :py:class:`sphinx_hosting.wildewidgets.MenuItem` objects.

        Add a first node that points to the root doc, also.  The root doc can't
        add itself to its ``toctree`` blocks, so we need to do it ourselves.

        How our mapping works:

        * Multiple top level ``<ul>`` tags separated by ``<p class="caption">``
          tags will be merged into a single list.
        * ``<p class="caption ...">CONTENTS</p>`` becomes ``{'text': 'CONTENTS'}```
        * Any ``href`` will be converted to its full ``django-sphinx-hosting`` path

        Args:
            version: the version whose global table of contents we are parsing
            html: the lxml parsed HTML of the global table of contents from
                Sphinx

        """
        root_url = reverse(
            "sphinx_hosting:sphinxpage--detail",
            kwargs={
                "project_slug": self.version.project.machine_name,  # type: ignore[attr-defined]
                "version": self.version.version,
                "path": self.version.head.relative_path,  # type: ignore[attr-defined]
            },
        )
        items: List[Dict[str, Any]] = [
            {"text": "Home", "url": root_url, "icon": None, "items": []}
        ]
        for elem in html.iterchildren():
            if elem.tag == "p" and "caption" in elem.classes:
                # Captions only appear at the top level, even if you assign
                # captions in your toctree declaration in your sub levels with
                # :caption:, so we only process them here.
                items.append({"text": elem.text_content()})
            if elem.tag == "ul":
                items.extend(self.parse_ul(elem))
        return items

    def run(self, version: "Version", verbose: bool = False) -> List[Dict[str, Any]]:
        """
        Parse the global table of contents found as
        ``version.head.orig_global_toc`` into a data struct suitable for use
        with
        :py:class:`sphinx_hosting.wildewidgets.SphinxPageGlobalTableOfContentsMenu.parse_obj`
        and return it.

        How our mapping works:

        * Multiple top level ``<ul>`` tags separated by ``<p class="caption">``
          tags will be merged into a single list.
        * ``<p class="caption ...">CONTENTS</p>`` becomes ``{'text': 'CONTENTS'}```
        * Any ``href`` for links found will be converted to its full
          ``django-sphinx-hosting`` path

        The returned struct should look something like this::

            [
                {'text': 'foo'},
                {'text': 'bar', 'url': '/project/version/foo', 'icon': None}
                {'text': 'bar', 'url': '/project/version/bar', 'icon': None, items: [{'text': 'blah' ...} ...]}
                ...
            ]

        Args:
            version: the version whose global table of contents we are parsing

        Keyword Args:
            verbose: if ``True``, pretty print the HTML of the globaltoc

        Returns:
            A list of dicts representing the global menu structure

        """  # noqa: E501
        self.version = version
        if self.version.head.orig_global_toc:  # type: ignore[attr-defined]
            # This is really only necessary to get the pretty printer below to
            # work properly.  If the \n chars are not removed, lxml won't indent
            # properly
            global_toc_html = re.sub(r"\n", "", self.version.head.orig_global_toc)  # type: ignore[attr-defined]
            html = lxml.html.fromstring(global_toc_html)
            if verbose:
                print(
                    etree.tostring(  # pylint: disable=c-extension-no-member
                        html, method="xml", encoding="unicode", pretty_print=True
                    )
                )
            return self.parse_globaltoc(html)
        return []


# --------------------------
# FileField upload functions
# --------------------------


def sphinx_image_upload_to(instance: "SphinxImage", filename: str) -> str:
    """
    Set the upload path within our ``MEDIA_ROOT`` for any images used by our Sphinx
    documentation to be::

        {project machine_name}/{version}/images/{image basename}

    Args:
        instance: the :py:class:`SphinxImage` object
        filename: the original path to the file

    Returns:
        The properly formatted path to the file

    """
    path = (
        Path(instance.version.project.machine_name)  # type: ignore[attr-defined]
        / Path(instance.version.version)  # type: ignore[attr-defined]
        / "images"
    )
    path = path / Path(filename).name
    return str(path)


def sphinx_document_upload_to(instance: "SphinxDocument", filename: str) -> str:
    """
    Set the upload path within our ``MEDIA_ROOT`` for any images used by our Sphinx
    documentation to be::

        {project machine_name}/{version}/document/{image basename}

    Args:
        instance: the :py:class:`SphinxDocument` object
        filename: the original path to the file

    Returns:
        The properly formatted path to the file

    """
    path = (
        Path(instance.version.project.machine_name)  # type: ignore[attr-defined]
        / Path(instance.version.version)  # type: ignore[attr-defined]
        / "documents"
    )
    path = path / Path(filename).name
    return str(path)


# --------------------------
# Managers
# --------------------------


class ClassifierManager(models.Manager):
    """
    Manager for :py:class:`Classifier` models.
    """

    def tree(self) -> Dict[str, ClassifierNode]:
        """
        Given our classifiers, which are ``::`` separated lists of terms
        like::

            Section :: Subsection :: Name
            Section :: Subsection :: Name2
            Section :: Subsection :: Name3
            Section :: Subsection

        Return a tree-like data structure that looks like::

            {
                'Section': ClassifierNode(
                    title='Section'
                    items={
                        'Subsection': ClassifierNode(
                            title='Subsection',
                            classifier=Classifier(name="Section :: Subsection"),
                            items: {
                                'Name': ClassifierNode(
                                    title='Name',
                                    classifier=Classifier(
                                        name='Section :: Subsection :: Name'
                                    )
                                ),
                                ...
                            }
                        )
                    }
                )
            }
        """
        nodes: Dict[str, ClassifierNode] = {}
        current: ClassifierNode | None = None
        for classifier in self.get_queryset().all():
            parts = classifier.name.split(" :: ")
            if parts[0] not in nodes:
                nodes[parts[0]] = ClassifierNode(title=parts[0])
            current = nodes[parts[0]]
            if len(parts) > 1:
                for part in parts[1:]:
                    if part not in current.items:
                        current.items[part] = ClassifierNode(title=part)
                    current = current.items[part]
            current.classifier = classifier
        return nodes


# --------------------------
# Models
# --------------------------


class Classifier(ViewSetMixin, models.Model):
    """
    A :py:class:`Project` can be tagged with one or more :py:class:`Classifier`
    tags.  This allows you to group projects by ecosystem, or type, for example.

    Use `PyPI <www.pypi.org>`_ classifiers as an example of how to use a single
    field for classifying across many dimensions.

    Examples::

        Ecosystem :: CMS
        Language :: Python
        Owner :: DevOps :: AWS
    """

    objects = ClassifierManager()

    name: F = models.CharField(
        "Classifier Name",
        help_text=_(
            'The classifier spec for this classifier, e.g. "Language :: Python"'
        ),
        max_length=255,
        unique=True,
    )

    def save(self, *args, **kwargs) -> None:
        """
        Overrides :py:meth:`django.db.models.Model.save`.

        Override save to create any missing classifiers in our chain.  For example,
        if we want to create this classifier::

            Foo :: Bar :: Baz

        But ``Foo :: Bar`` does not yet exist in the database, create that before
        creating ``Foo :: Bar :: Baz``.  We do this so that when we filter our projects
        by classifier, we can filter by ``Foo :: Bar`` and ``Foo :: Bar :: Baz``.

        Args:
            args: the arguments to pass to :py:meth:`django.db.models.Model.save`
            kwargs: the keyword arguments to pass to
                :py:meth:`django.db.models.Model.save`

        """
        parts = [p.strip() for p in self.name.split("::")]
        if len(parts) > 2:  # noqa: PLR2004
            name = parts[0]
            for part in parts[1:-1]:
                name = f"{name} :: {part}"
                if not Classifier.objects.filter(name=name).exists():
                    new_classifiier = Classifier(name=name)
                    new_classifiier.save(
                        using=kwargs.get("using", settings.DEFAULT_DB_ALIAS)
                    )
        # Rejoin our parts to ensure we always get a classifier that looks like
        # "part :: part :: part" instead of "part:: part::part"
        self.name = " :: ".join(parts)
        super().save(*args, **kwargs)

    def __str__(self) -> str:
        return str(self.name)

    class Meta:
        verbose_name = _("classifier")
        verbose_name_plural = _("classifiers")


class ProjectPermissionGroup(ViewSetMixin, TimeStampedModel, models.Model):
    """
    A :py:class:`Project` can be assigned to one or more
    :py:class:`ProjectPermissionGroup` groups.  This restricts viewing of the
    project to users which belong to those groups.

    Examples:
        .. code-block:: text

            Ecosystem :: CMS
            Language :: Python
            Owner :: DevOps :: AWS

    """

    name: F = models.CharField(
        "Permission Group Name",
        help_text=_("The name for this permission group"),
        max_length=100,
        unique=True,
    )
    description: F = models.CharField(
        "Brief Description",
        max_length=256,
        null=True,
        blank=True,
        help_text=_("A brief description of this permission group"),
        validators=[NoHTMLValidator()],
    )

    users: M2M = models.ManyToManyField(
        settings.AUTH_USER_MODEL, related_name="project_permission_groups"
    )

    class Meta:
        verbose_name = _("project permission group")
        verbose_name_plural = _("project permission groups")


class Project(ViewSetMixin, TimeStampedModel, models.Model):
    """
    A Project is what a set of Sphinx docs describes: an application, a library, etc.

    Projects have versions (:py:class:`Version`) and versions have Sphinx pages
    (:py:class:`SphinxPage`).
    """

    title: F = models.CharField(
        "Project Name", help_text=_("The human name for this project"), max_length=100
    )
    description: F = models.CharField(
        "Brief Description",
        max_length=256,
        null=True,
        blank=True,
        help_text=_("A brief description of this project"),
        validators=[NoHTMLValidator()],
    )
    machine_name: F = MachineNameField(
        "Machine Name",
        unique=True,
        help_text=_(
            """Must be unique.  Set this to the slugified value of "project" in """
            """Sphinx's. conf.py"""
        ),
    )

    latest_version: FK = models.ForeignKey(
        "Version",
        help_text=_(
            "The latest version of this project.  "
            "This is the version that will be shown when you click "
            '"Read Docs" on the project page.'
        ),
        null=True,
        blank=True,
        related_name="+",
        on_delete=models.SET_NULL,
    )

    permission_groups: M2M = models.ManyToManyField(
        ProjectPermissionGroup,
        related_name="projects",
    )
    classifiers: M2M = models.ManyToManyField(
        Classifier,
        related_name="projects",
    )

    def __str__(self) -> str:  # pylint: disable=invalid-str-returned
        return self.title

    def get_absolute_url(self) -> str:
        return reverse("sphinx_hosting:project--detail", args=[self.machine_name])

    def get_update_url(self) -> str:
        return reverse("sphinx_hosting:project--update", args=[self.machine_name])

    def get_latest_version_url(self) -> str | None:
        if self.latest_version:
            return reverse(
                "sphinx_hosting:sphinxpage--detail",
                args=[
                    self.machine_name,
                    self.latest_version.version,  # type: ignore[attr-defined]
                    self.latest_version.head.relative_path,  # type: ignore[attr-defined]
                ],
            )
        return None

    class Meta:
        verbose_name = _("project")
        verbose_name_plural = _("projects")


class Version(TimeStampedModel, models.Model):
    """
    A ``Version`` is a specific version of a :py:class:`Project`. Versions own
    :py:class:`SphinxPage` objects.
    """

    project: FK = models.ForeignKey(
        Project,
        on_delete=models.CASCADE,
        related_name="versions",
        help_text=_("The Project to which this Version belongs"),
    )
    version: F = models.CharField(
        "Version",
        max_length=64,
        null=False,
        help_text=_("The version number for this release of the Project"),
    )
    sphinx_version: F = models.CharField(
        "Sphinx Version",
        max_length=64,
        null=True,
        blank=True,
        default=None,
        help_text=_("The version of Sphinx used to create this documentation set"),
    )
    archived: F = models.BooleanField(
        "Archived?",
        default=False,
        help_text=_("Whether this version should be excluded from search indexes"),
    )

    head: FK = models.OneToOneField(
        "SphinxPage",
        on_delete=models.SET_NULL,
        null=True,
        related_name="+",  # disable our related_name for this one
        help_text=_(
            "The top page of the documentation set for this version of our project"
        ),
    )

    def __str__(self) -> str:
        return f"{self.project.title}-{self.version}"  # type: ignore[attr-defined]

    @property
    def is_latest(self) -> bool:
        return self == self.project.latest_version  # type: ignore[attr-defined]

    @property
    def page_tree(self) -> SphinxPageTree:
        """
        Return the page hierarchy for the set of :py:class:`SphinxPage` pages in
        this version.

        The page hierarchy is build by traversing the pages in the set, starting
        with :py:attr:`head`.

        Returns:
            The page hierarchy for this version.

        """
        return SphinxPageTree(self)

    def mark_searchable_pages(self) -> None:
        """
        Set the :py:attr:`SphinxPage.searchable` flag on
        the searchable pages in this version.

        Searchable pages are ones that:

        * Are not in :py:attr:`SphinxPage.SPECIAL_PAGES`
        * Do not have a part of their relative path that starts with ``_``.

        Go through the pages in this version, and set
        :py:attr:`SphinxPage.searchable` to ``True`` for all those which meet the above
        requirements, ``False`` otherwise.
        """
        ignored_paths = list(SphinxPage.SPECIAL_PAGES.keys())
        for page in self.pages.all():
            if (
                page.relative_path not in ignored_paths
                and not page.relative_path.startswith("_")
                and "/_" not in page.relative_path
            ):
                page.searchable = True
            else:
                page.searchable = False
            page.save()

    @cached_property
    def globaltoc(self) -> Dict[str, List[Dict[str, Any]]]:
        """
        Build a struct that looks like this::

            {
                items: [
                    {'text': 'foo'},
                    {'text': 'bar', 'url': '/foo', 'icon': None}
                    {'text': 'bar', 'url': '/foo', 'icon': None, items: [{'text': 'blah' ...} ...]}
                    ...
                ]
            }

        suitable for constructing a
        :py:class:`sphinx_hosting.wildewidgets.SphinxPageGlobalTableOfContentsMenu`
        for this :py:class:`Version`.

        """  # noqa: E501
        items = SphinxGlobalTOCHTMLProcessor(max_level=MAX_GLOBAL_TOC_TREE_DEPTH).run(
            self
        )
        if not items:
            items = SphinxPageTreeProcessor().run(self)
        return {"items": items}

    def purge_cached_globaltoc(self) -> None:
        """
        Purge the cached output from our :py:meth:`globaltoc` property.
        """
        with suppress(AttributeError):
            # If we get AttributeError, this means self.globaltoc hasn't been
            # accessed yet
            del self.globaltoc

    def get_absolute_url(self) -> str:
        return reverse(
            "sphinx_hosting:version--detail",
            args=[self.project.machine_name, self.version],  # type: ignore[attr-defined]
        )

    def save(self, *args, **kwargs):
        """
        Overriding :py:meth:`django.db.models.Model.save` here so that we can
        purge our cached global table of contents.
        """
        super().save(*args, **kwargs)
        self.purge_cached_globaltoc()


class SphinxPage(TimeStampedModel, models.Model):
    """
    A ``SphinxPage`` is a single page of a set of Sphinx documentation.
    ``SphinxPage`` objects are owned by :py:class:`Version` objects, which are
    in turn owned by :py:class:`Project` objects.
    """

    #: This is a mapping between filename and title that identifies the
    #: special pages that Sphinx produces on its own and gives them
    #: reasonable titles.  These pages have no ``title`` key in their
    #: json data, but ``title`` is required for pages
    SPECIAL_PAGES: Final[Dict[str, str]] = {
        "genindex": "General Index",
        "py-modindex": "Module Index",
        "np-modindex": "Module Index",
        "search": "Search",
        "_modules/index": "Module code",
    }

    version: FK = models.ForeignKey(
        Version,
        on_delete=models.CASCADE,
        related_name="pages",
        help_text=_("The Version to which this page belongs"),
    )
    relative_path: F = models.CharField(
        "Relative page path",
        help_text=_("The path to the page under our top slug"),
        max_length=255,
    )
    content: F = models.TextField(
        "Content", help_text=_("The full JSON payload for the page")
    )
    title: F = models.CharField(
        "Title",
        max_length=255,
        help_text=_("Just the title for the page, extracted from the page JSON"),
    )
    orig_body: F = models.TextField(
        "Body (Original)",
        blank=True,
        help_text=_(
            "The original body for the page, extracted from the page JSON. Some pages "
            "have no body. We save this here in case we need to reprocess the body at "
            "some later date."
        ),
    )
    body: F = models.TextField(
        "Body",
        blank=True,
        help_text=_(
            "The body for the page, extracted from the page JSON, and modified to "
            "suit us. Some pages have no body.  The body is actually stored as a "
            "Django template."
        ),
    )
    orig_local_toc: F = models.TextField(
        "Local Table of Contents (original)",
        blank=True,
        null=True,
        default=None,
        help_text=_(
            "The original table of contents for headings in this page."
            "We save this here in case we need to reprocess the table of contents "
            "at some later date."
        ),
    )
    local_toc: F = models.TextField(
        "Local Table of Contents",
        blank=True,
        null=True,
        default=None,
        help_text=_(
            "Table of Contents for headings in this page, modified to work in "
            "our templates"
        ),
    )
    orig_global_toc: F = models.TextField(
        "Global Table of Contents (original)",
        blank=True,
        null=True,
        default=None,
        help_text=_(
            "The original global table of contents HTML attached to this page, if any. "
            ' This will only be present if you had "sphinxcontrib-jsonglobaltoc"'
            'installed in your "extensions" in the Sphinx conf.py'
        ),
    )
    searchable: F = models.BooleanField(
        "Searchable",
        default=False,
        help_text=_("Should this page be included in the search index?"),
    )

    parent: FK = models.ForeignKey(
        "SphinxPage",
        on_delete=models.CASCADE,
        null=True,
        related_name="children",
        help_text=_("The parent page of this page"),
    )

    # This has to be a ForeignKey here and not a OneToOneField becuase
    # more than one page can have the same next_page.
    next_page: FK = models.ForeignKey(
        "SphinxPage",
        on_delete=models.CASCADE,
        null=True,
        related_name="previous_page",
        help_text=_("The next page in the documentation set"),
    )

    def __str__(self) -> str:
        return (
            f"{self.version.project.title}-{self.version.version}: {self.relative_path}"  # type: ignore[attr-defined]
        )

    def get_absolute_url(self) -> str:
        return reverse(
            "sphinx_hosting:sphinxpage--detail",
            args=[
                self.version.project.machine_name,  # type: ignore[attr-defined]
                self.version.version,  # type: ignore[attr-defined]
                self.relative_path,
            ],
        )

    def get_permalink(self) -> str:
        """
        Return the permalink for this page.  This is the URL for the page
        with the version number replaced with "latest".

        Returns:
            The permalink for this page.

        """
        return reverse(
            "sphinx_hosting:sphinxpage--detail",
            args=[self.version.project.machine_name, "latest", self.relative_path],  # type: ignore[attr-defined]
        )

    class Meta:
        verbose_name = _("sphinx page")
        verbose_name_plural = _("sphinx pages")
        unique_together = ("version", "relative_path")


class SphinxImage(TimeStampedModel, models.Model):
    """
    A ``SphinxImage`` is an image file referenced in a Sphinx document.  When
    importing documenation packages, we extract all images from the package,
    upload them into Django storage and update the Sphinx HTML in
    :py:attr:`SphinxPage.body` to reference the URL for the uploaded image
    instead of its original url.
    """

    version: FK = models.ForeignKey(
        Version,
        on_delete=models.CASCADE,
        related_name="images",
        help_text=_(
            "The version of our project documentation with which this image is "
            "associated"
        ),
    )
    orig_path: F = models.CharField(
        _("Original Path"),
        max_length=256,
        help_text=_(
            "The original path to this file in the Sphinx documentation package"
        ),
    )
    file: F = models.FileField(
        _("An image file"),
        upload_to=sphinx_image_upload_to,
        help_text=_("The actual image file"),
    )

    class Meta:
        verbose_name = _("sphinx image")
        verbose_name_plural = _("sphinx images")
        unique_together = ("version", "orig_path")


class SphinxDocument(TimeStampedModel, models.Model):
    """
    A ``SphinxDocument`` is an downloadable document file referenced in a Sphinx
    document.  When importing documenation packages, we extract all such
    document from the package, upload them into Django storage and update the
    Sphinx HTML in :py:attr:`SphinxPage.body` to reference the URL for the
    uploaded document instead of its original url.
    """

    version: FK = models.ForeignKey(
        Version,
        on_delete=models.CASCADE,
        related_name="documents",
        help_text=_(
            "The version of our project documentation with which this document is "
            "associated"
        ),
    )
    orig_path: F = models.CharField(
        _("Original Path"),
        max_length=256,
        help_text=_(
            "The original path to this file in the Sphinx documentation package"
        ),
    )
    file: F = models.FileField(
        _("An image file"),
        upload_to=sphinx_document_upload_to,
        help_text=_("The actual document file"),
    )

    class Meta:
        verbose_name = _("sphinx document")
        verbose_name_plural = _("sphinx documents")
        unique_together = ("version", "orig_path")


class ProjectRelatedLink(TimeStampedModel, models.Model):
    """
    A ``ProjectRelatedLink`` is a link to an external resource that is related
    to a :py:class:`Project`.
    """

    title: F = models.CharField(
        "Link Title", max_length=100, help_text=_("The title for this link")
    )
    uri: F = models.URLField(
        "Link URL", max_length=256, help_text=_("The URL for this link")
    )
    project: FK = models.ForeignKey(
        Project,
        on_delete=models.CASCADE,
        related_name="related_links",
        help_text=_("The project to which this link is related"),
    )

    def get_update_url(self) -> str:
        return reverse(
            "sphinx_hosting:projectrelatedlink--update", kwargs={"pk": self.pk}
        )

    def get_delete_url(self) -> str:
        return reverse(
            "sphinx_hosting:projectrelatedlink--delete", kwargs={"pk": self.pk}
        )

    class Meta:
        verbose_name = _("project related link")
        verbose_name_plural = _("project related links")
        unique_together = ("project", "uri")
        ordering = ("title",)
