from collections import OrderedDict
from warnings import warn

from django.core.exceptions import FieldDoesNotExist
from django.db import models
from django.db.models import Count
from django.db.models.expressions import Value

from modelsearch.backends.base import (
    BaseSearchBackend,
    BaseSearchQueryCompiler,
    BaseSearchResults,
    FilterFieldError,
)
from modelsearch.query import And, Boost, MatchAll, Not, Or, Phrase, PlainText
from modelsearch.utils import AND, OR


# This file implements a database search backend using basic substring matching, and no
# database-specific full-text search capabilities. It will be used in the following cases:
# * The current default database is SQLite <3.19, or SQLite built without fulltext
#   extensions, or something other than PostgreSQL, MySQL or SQLite
# * 'modelsearch.backends.database.fallback' is specified directly as the search backend


MATCH_ALL = "_ALL_"
MATCH_NONE = "_NONE_"


class DatabaseSearchQueryCompiler(BaseSearchQueryCompiler):
    DEFAULT_OPERATOR = "and"
    OPERATORS = {
        "and": AND,
        "or": OR,
    }
    HANDLES_ORDER_BY_EXPRESSIONS = True

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.fields_names = list(self.get_fields_names())

    def get_fields_names(self):
        model = self.queryset.model
        fields_names = self.fields or [
            field.field_name for field in model.get_searchable_search_fields()
        ]
        # Check if the field exists (this will filter out indexed callables)
        for field_name in fields_names:
            try:
                model._meta.get_field(field_name)
            except FieldDoesNotExist:
                continue
            else:
                yield field_name

    def build_single_term_filter(self, term):
        term_query = models.Q()
        for field_name in self.fields_names:
            term_query |= models.Q(**{field_name + "__icontains": term})
        return term_query

    def check_boost(self, query, boost=1.0):
        if query.boost * boost != 1.0:
            warn(
                "Database search backend does not support term boosting.",
                stacklevel=7,
            )

    def build_database_filter(self, query, boost=1.0):
        if isinstance(query, PlainText):
            self.check_boost(query, boost=boost)

            operator = self.OPERATORS[query.operator]

            return operator(
                [
                    self.build_single_term_filter(term)
                    for term in query.query_string.split()
                ]
            )

        if isinstance(query, Phrase):
            q = models.Q()
            for field_name in self.fields_names:
                q |= models.Q(**{field_name + "__icontains": query.query_string})
            return q

        if isinstance(query, Boost):
            boost *= query.boost
            return self.build_database_filter(query.subquery, boost=boost)

        if isinstance(query, MatchAll):
            return MATCH_ALL

        if isinstance(query, Not):
            q = self.build_database_filter(query.subquery, boost=boost)

            if q == MATCH_ALL:
                return MATCH_NONE

            elif q == MATCH_NONE:
                return MATCH_ALL

            else:
                return ~q

        if isinstance(query, And):
            subqueries = [
                self.build_database_filter(subquery, boost=boost)
                for subquery in query.subqueries
            ]

            # If there's a MATCH_NONE, return MATCH_NONE
            if MATCH_NONE in subqueries:
                return MATCH_NONE

            # Ignore MATCH_ALL
            subqueries = [q for q in subqueries if q != MATCH_ALL]

            return AND(subqueries)

        if isinstance(query, Or):
            subqueries = [
                self.build_database_filter(subquery, boost=boost)
                for subquery in query.subqueries
            ]

            # If there's a MATCH_ALL, return MATCH_ALL
            if MATCH_ALL in subqueries:
                return MATCH_ALL

            # Ignore MATCH_NONE
            subqueries = [q for q in subqueries if q != MATCH_NONE]

            return OR(subqueries)

        raise NotImplementedError(
            f"`{query.__class__.__name__}` is not supported by the database search backend."
        )


class DatabaseAutocompleteQueryCompiler(DatabaseSearchQueryCompiler):
    # The fallback backend doesn't handle word boundaries, so standard searches are
    # essentially equivalent to autocomplete queries anyhow. However, to provide a
    # consistent API with other backends, we provide both endpoints.
    pass


class DatabaseSearchResults(BaseSearchResults):
    iterator_chunk_size = 2000

    def get_queryset(self):
        queryset = self.query_compiler.queryset

        q = self.query_compiler.build_database_filter(self.query_compiler.query)

        if q == MATCH_ALL:
            pass
        elif q == MATCH_NONE:
            queryset = queryset.none()
        else:
            queryset = queryset.filter(q)

        if not self.query_compiler.order_by_relevance and not queryset.query.order_by:
            # Add a default ordering to keep results consistent across pages
            # (see https://github.com/wagtail/wagtail/issues/3729).
            queryset = queryset.order_by("-pk")

        return queryset.distinct()[self.start : self.stop]

    def _do_search(self):
        queryset = self.get_queryset()

        if self._score_field:
            queryset = queryset.annotate(
                **{self._score_field: Value(None, output_field=models.FloatField())}
            )

        return queryset.iterator(self.iterator_chunk_size)

    def _do_count(self):
        return self.get_queryset().count()

    supports_facet = True

    def facet(self, field_name):
        # Get field
        field = self.query_compiler._get_filterable_field(field_name)
        if field is None:
            raise FilterFieldError(
                'Cannot facet search results with field "'
                + field_name
                + "\". Please add index.FilterField('"
                + field_name
                + "') to "
                + self.query_compiler.queryset.model.__name__
                + ".search_fields.",
                field_name=field_name,
            )

        query = self.get_queryset()
        results = (
            query.values(field_name).annotate(count=Count("pk")).order_by("-count")
        )

        return OrderedDict(
            [(result[field_name], result["count"]) for result in results]
        )


class DatabaseSearchBackend(BaseSearchBackend):
    query_compiler_class = DatabaseSearchQueryCompiler
    autocomplete_query_compiler_class = DatabaseSearchQueryCompiler
    results_class = DatabaseSearchResults


# This line allows using 'modelsearch.backends.database.fallback' as the backend, bypassing the automatic selection of the backend that would get run if the user chose 'modelsearch.backends.database'
SearchBackend = DatabaseSearchBackend
