import time
from typing import List, Type  # noqa: UP035

from django.db.models import F, Model, QuerySet
from haystack import indexes

try:
    from opensearchpy.exceptions import TransportError
except ImportError:
    from elasticsearch.exceptions import (  # type: ignore[import-not-found] # pyright: ignore[reportMissingImports]
        TransportError,
    )

from .logging import logger
from .models import Project, SphinxPage, Version


class SphinxPageIndex(indexes.SearchIndex, indexes.Indexable):
    """Search index for SphinxPage model."""

    #: The text of the SphinxPage
    text = indexes.CharField(document=True, use_template=True)
    #: The ID of the project
    project_id = indexes.CharField(model_attr="version__project__id", faceted=True)
    #: The machine name of the project
    project = indexes.CharField(model_attr="version__project__machine_name")
    #: The ID of the version
    version_id = indexes.CharField(model_attr="version__id")
    #: The classifiers of the SphinxPage
    classifiers = indexes.MultiValueField(faceted=True)
    #: The modified date of the SphinxPage
    modified = indexes.DateTimeField(model_attr="modified")

    def get_model(self) -> Type[Model]:
        """Return the SphinxPage model class."""
        return SphinxPage

    def prepare_classifiers(self, obj: SphinxPage) -> List[str]:
        """
        Prepare the classifiers for the SphinxPage.

        Args:
            obj: The SphinxPage object being indexed.

        Returns:
            List of classifier names.

        """
        return [classifier.name for classifier in obj.version.project.classifiers.all()]  # type: ignore[attr-defined]

    def index_queryset(self, using: str | None = None) -> QuerySet:  # noqa: ARG002
        """
        Used when the entire index for model is updated.

        Keyword Args:
            using: The alias of the database to use. (unused)

        Returns:
            QuerySet of SphinxPage objects.

        """
        return (
            self.get_model()
            .objects.filter(searchable=True)
            .filter(version__project__latest_version=F("version"))
        )

    def remove_version(self, version: Version) -> None:
        """
        Remove all pages for a version from the index.

        Args:
            version: The version whose pages we want to remove from the index.

        """
        qs = (
            self.get_model()
            .objects.filter(version__id=version.pk)
            .filter(searchable=True)
        )
        logger.info(
            "Removing %d pages from the search index for version %s",
            qs.count(),
            version,
        )
        for obj in qs:
            self.remove_object(obj)

    def reindex_project(self, project: Project) -> None:  # type: ignore[note]
        """
        Reindex all pages for a project.  This happens when we get a new
        latest_version for the project.

        .. note::
            If we have a lot of pages in our latest version for this project,
            this could take a while.  We should probably look into a way to
            do this asynchronously.

        Args:
            project: The project whose pages we want to reindex.

        """
        # This should only ever return a QuerySet of SphinxPage objects
        # that match the latest version of a project.

        # self.index_queryset() is a queryset of SphinxPage objects, so
        # our filters are on SphinxPage fields.
        qs = self.index_queryset().filter(version__project=project)
        backend = self.get_backend(None)
        if backend is not None:
            batch_size: int = backend.batch_size
            total: int = qs.count()
            # We need to update the index in batches because we can run into
            # backend transport errors if we try to update too many documents at
            # once.
            for start in range(0, total, batch_size):
                end = min(start + batch_size, total)
                while True:
                    try:
                        backend.update(self, qs[start:end])
                    except TransportError as e:  # noqa: PERF203
                        # We're using the OpenSearch backend, check the status_code
                        # from the exception to see if we can recover from it.
                        #
                        if e.status_code == 429:  # noqa: PLR2004
                            # We're being rate limited, so sleep for a bit and
                            # try again.  The problem here is we could sleep so
                            # long we exceed our gunicorn, nginx, or other
                            # timeout.  We should probably look into a way to do
                            # this asynchronously.
                            logger.warning(
                                "OpenSearch rate limit reached.  Sleeping for 5 "
                                "seconds."
                            )
                            time.sleep(5)
                    else:
                        break
