from abc import ABC, abstractmethod
from collections.abc import Iterable
from typing import Any, Optional

from babel.numbers import format_decimal
from flask import Request, url_for
from flask_babel import get_locale
from w3lib.url import safe_url_string
from werkzeug.datastructures import MultiDict
from whoosh.fields import ID
from whoosh.query import Prefix, Term

from kerko import codecs, extractors, renderers
from kerko.text import slugify, sort_normalize
from kerko.tree import Tree


class ScopeSpec:
    """
    Specifies a scope for keyword searches.

    This is a configuration element, with no effect on the search index schema.
    """

    def __init__(self, key, selector_label, breadbox_label, weight=0, help_text=""):
        self.key = key
        self.selector_label = selector_label
        self.breadbox_label = breadbox_label
        self.weight = weight
        self.help_text = help_text

    def add_keywords(self, value, active_keywords=None):
        """
        Add a value for this scope to active keywords.

        :param string value: The value to add.

        :param MultiDict active_keywords: The active keywords to derive from.

        :return MultiDict: A copy of the keywords with the added value.
        """
        new_keywords = active_keywords.deepcopy() if active_keywords else MultiDict()
        new_keywords.add(self.key, value.strip())
        return new_keywords

    def remove_keywords(self, value, active_keywords):
        """
        Remove a value for this facet from active keywords.

        :param string value: The value to remove.

        :param MultiDict active_keywords: The active keywords to derive from.

        :return MultiDict: A copy of the keywords with the value removed.
        """
        new_keywords = active_keywords.deepcopy()
        if new_keywords:
            new_values = [v for v in new_keywords.poplist(self.key) if v != value]
            if new_values:
                new_keywords.setlist(self.key, new_values)
        return new_keywords


class BaseFieldSpec(ABC):
    def __init__(
        self,
        key,
        field_type,
        extractor,
    ):
        """
        Initialize this field specification.

        :param str key: Unique key for referencing the field in the search index
            schema.

        :param whoosh.fields.FieldType field_type: Instance of the schema field
            type. Set to `None` if the field's value is to be passed along with
            documents, but not added to the schema, e.g., boost factors (see
            `whoosh.writing.IndexWriter.add_document`).

        :param kerko.extractors.Extractor extractor: Instance of the extractor
            that will extract the field value from a Zotero item.
        """
        self.key = key
        self.field_type = field_type
        self.extractor = extractor

    def extract_to_document(self, document, item, library_context):
        """Extract the value of this element from a Zotero item."""
        self.extractor.extract_and_store(document, item, library_context, self)


class FieldSpec(BaseFieldSpec):
    """Specifies a schema field."""

    def __init__(self, codec=None, scopes=None, **kwargs):
        """
        Initialize this field specification.

        :param BaseFieldCodec codec: Value encoder/decoder for this field.

        :param list scopes: list of keys to `ScopeSpec` instances to which this
            field applies. If `None`, the field won't be available for keyword
            search.
        """
        super().__init__(**kwargs)
        self.scopes = scopes or []
        self.codec = codec or codecs.IdentityFieldCodec()

    def encode(self, value):
        return self.codec.encode(value)

    def decode(self, encoded_value):
        return self.codec.decode(encoded_value)


class FacetSpec(BaseFieldSpec):
    """Specifies a facet for search grouping and filtering."""

    def __init__(
        self,
        *,
        title,
        filter_key,
        weight=0,
        initial_limit=0,
        initial_limit_leeway=2,
        codec=None,
        missing_label=None,
        sort_by=None,
        sort_reverse=False,
        item_view=True,
        allow_overlap=True,
        query_class=None,
        renderer=None,
        **kwargs,
    ):
        """
        Initialize this facet specification.

        :param str title: Title of the facet.

        :param str filter_key: Key to use in URLs when filtering with this
            facet.

        :param int weight: Determine the position of this facet relative to the
            others.

        :param int initial_limit: Maximum number of filters to show by default
            under this facet. Excess filters will be shown if the user clicks a
            "view more" button. Defaults to 0 (no limit).

        :param int initial_limit_leeway: If the number of filters under this
            facet exceeds `initial_limit` by this tolerance margin or less, all
            filters will be shown. Defaults to 0 (no tolerance margin).

        :param str missing_label: Label to use for items that do not have any
            value for this facet. Defaults to `None` (show no label at all).

        :param codecs.BaseFacetCodec codec: Value encoder/decoder for this facet.

        :param bool item_view: Show this facet on item view pages.

        :param `renderers.Renderer` renderer: A renderer for this facet. The
            rendering context provides the following variables:

            - `spec`: The `FacetSpec` instance.
            - `items`: The facet values retrieved by the search query.
            - `mode`: A string whose value is one of the following:
                - `'search'`: Displaying the facet on a search results page.
                - `'field'`: Displaying the facet on a full bibliographic record
                  page.
                - `'breadbox'`: Displaying the facet as a search criteria in the
                  breadbox.

        .. seealso: Additional :meth:`BaseFieldSpec.__init__` arguments.
        """
        super().__init__(**kwargs)
        self.title = title
        self.filter_key = filter_key
        self.weight = weight
        self.initial_limit = initial_limit
        self.initial_limit_leeway = initial_limit_leeway
        self.codec = codec or codecs.BaseFacetCodec()
        self.missing_label = missing_label
        self.sort_by = sort_by
        self.sort_reverse = sort_reverse
        self.item_view = item_view
        self.allow_overlap = allow_overlap
        self.query_class = query_class or Term
        self.renderer = renderer or renderers.TemplateResolverRenderer(
            "kerko/_facet_{mode}.html.jinja2"
        )

    def encode(self, value):
        return self.codec.encode(value)

    def decode(self, encoded_value, default_value=None, default_label=None):
        if encoded_value is None:
            return "", ""
        return self.codec.decode(encoded_value, default_value, default_label)

    @abstractmethod
    def add_filter(self, value, active_filters):
        """
        Add a value for this facet to active filters.

        :param string value: The value to add.

        :param MultiDict active_filters: The active filters to derive from.

        :return MultiDict: A copy of the filters with the added value.
        """

    @abstractmethod
    def remove_filter(self, value, active_filters):
        """
        Remove a value for this facet from active filters.

        :param string value: The value to remove.

        :param MultiDict active_filters: The active filters to derive from.

        :return MultiDict: A copy of the filters with the value removed.
        """

    @abstractmethod
    def build(self, results, criteria, active_only=False):
        """
        Construct a facet's items for display.

        :param iterable results: Iterable of (value, count) tuples representing
            the facet's results from the search query.

        :param Criteria criteria: The current search criteria. If None, the
            facet will be built for performing a new search.

        :param active_only: Only build the items that are related to active
            filters, i.e., filters actually present in the search criteria.
        """

    def sort_items(self, items):
        if self.sort_by is None:
            return items

        # Sort items based on multiple-keys.
        return sorted(
            items,
            key=lambda x: (
                # First sort key: show active items first.
                not x["remove_url"],
                # Second sort key: show items with missing labels last.
                bool(x["label"]) if self.sort_reverse else not x["label"],
                # Third sort key: sort items according to the facet's specified
                # sort keys. If 'count' is used as key, multiply the count value
                # by -1 to reverse order (because the desired default when
                # ordering by count is the descending order).
                *[
                    x[k] * -1
                    if k == "count"
                    else (sort_normalize(x[k]) if isinstance(x[k], str) else x[k])
                    for k in self.sort_by
                ],
            ),
            reverse=self.sort_reverse,
        )

    def render(self, items, mode):
        return self.renderer.render(spec=self, items=items, mode=mode)


class FlatFacetSpec(FacetSpec):
    def add_filter(self, value, active_filters):
        if value is None:  # Special case for missing value (None is returned by Whoosh).
            value = ""
        filters = active_filters.deepcopy()
        active_values = filters.getlist(self.filter_key)
        if active_values and value in active_values:
            return None  # Already filtering with value. No add needed.
        filters.setlistdefault(self.filter_key).append(value)
        return filters

    def remove_filter(self, value, active_filters):
        if value is None:  # Special case for missing value (None is returned by Whoosh).
            value = ""
        filters = active_filters.deepcopy()
        active_values = filters.getlist(self.filter_key)
        if not active_values or value not in active_values:
            return None  # Not currently filtering with value. No remove needed.
        active_values.remove(value)
        filters.setlist(self.filter_key, active_values)
        return filters

    def build(self, results, criteria, active_only=False):
        items = []
        for value, count in results.items():
            if value or self.missing_label:
                value, label = self.decode(value, default_value=value, default_label=value)  # noqa: PLW2901
                new_filters = self.remove_filter(value, criteria.filters)
                if new_filters:
                    remove_url = url_for(
                        ".search",
                        **criteria.params(
                            filters=new_filters,
                            options={
                                "page": None,
                                "page-len": None,
                                "id": None,
                            },
                        ),
                    )
                else:
                    remove_url = None
                if remove_url or active_only:
                    add_url = None
                else:
                    new_filters = self.add_filter(value, criteria.filters)
                    if new_filters:
                        add_url = url_for(
                            ".search",
                            **criteria.params(
                                filters=new_filters,
                                options={
                                    "page": None,
                                    "page-len": None,
                                    "id": None,
                                },
                            ),
                        )
                    else:
                        add_url = None
                if remove_url or add_url:  # Only items with an URL get displayed.
                    items.append(
                        {
                            "label": label,
                            "count": count,
                            "count_formatted": format_decimal(count, locale=get_locale()),
                            "remove_url": remove_url,
                            "add_url": add_url,
                        }
                    )
        return self.sort_items(items)


class TreeFacetSpec(FacetSpec):
    def __init__(self, path_separator=".", **kwargs):
        super().__init__(**kwargs)
        self.path_separator = path_separator

    @staticmethod
    def is_ancestor(ancestor, descendant):
        """
        Return True if `ancestor` is an ancestor of `descendant`.

        :param str ancestor: Potential ancestor.

        :param str descendant: Path to look for potential ancestor.
        """
        # True if ancestor is a prefix of descendant. Warning: This simple
        # condition only works for finding ancestors when all path components
        # have the same length, otherwise any partial prefix could match.
        return descendant.find(ancestor) == 0 and ancestor != descendant

    def get_parent(self, value):
        parent = value.rsplit(sep=self.path_separator, maxsplit=1)[0]
        return parent if parent != value else None

    def add_filter(self, value, active_filters):
        if value is None:
            # Special case for missing value (None is returned by Whoosh).
            value = ""
        filters = active_filters.deepcopy()
        active_values = filters.getlist(self.filter_key)
        for i, active_value in enumerate(active_values):
            if value == active_value or self.is_ancestor(value, active_value):
                # Already filtering with value or a descendant. No add needed.
                return None
            elif self.is_ancestor(active_value, value):
                # Active value is ancestor of value. Replace the active value.
                active_values[i] = value
                filters.setlist(self.filter_key, active_values)
                break
        else:
            # This is an all new filter. Add its value.
            filters.setlistdefault(self.filter_key).append(value)
        return filters

    def remove_filter(self, value, active_filters):
        if value is None:
            # Special case for missing value (None is returned by Whoosh).
            value = ""
        filters = active_filters.deepcopy()
        active_values = filters.getlist(self.filter_key)
        if not active_values:
            # Filter not active at all. No remove needed.
            return None
        new_values = []
        change = False
        for active_value in active_values:
            if value == active_value or self.is_ancestor(value, active_value):
                # Currently filtering with value or a descendant. Remove needed.
                change = True
                parent = self.get_parent(value)
                if parent and parent not in new_values:
                    new_values.append(parent)
            else:
                # Filter unrelated to value. Preserve it.
                new_values.append(active_value)
        if not change:
            # Neither filtering with value or a descendant. No remove needed.
            return None
        filters.setlist(self.filter_key, new_values)
        return filters

    def sort_tree(self, tree):
        """
        Convert tree to list, sorting children along the way.

        In the process, each item remains a dict as in a flat facet, but with
        an extra 'children' key.
        """
        lst = []
        for child in tree["children"].values():
            child["node"]["children"] = self.sort_tree(child)
            lst.append(child["node"])
        return self.sort_items(lst)

    def build(self, results, criteria, active_only=False):
        tree = Tree()
        for value, count in results.items():
            if value or self.missing_label:
                value, label = self.decode(value, default_value=value, default_label=value)  # noqa: PLW2901
                new_filters = self.remove_filter(value, criteria.filters)
                if new_filters:
                    remove_url = url_for(
                        ".search",
                        **criteria.params(
                            filters=new_filters,
                            options={
                                "page": None,
                                "page-len": None,
                                "id": None,
                            },
                        ),
                    )
                else:
                    remove_url = None
                if remove_url or active_only:
                    add_url = None
                else:
                    new_filters = self.add_filter(value, criteria.filters)
                    if new_filters:
                        add_url = url_for(
                            ".search",
                            **criteria.params(
                                filters=new_filters,
                                options={
                                    "page": None,
                                    "page-len": None,
                                    "id": None,
                                },
                            ),
                        )
                    else:
                        add_url = None
                if remove_url or add_url:  # Only items with an URL get displayed.
                    path = value.split(sep=self.path_separator)

                    # Build the tree path. Part of the path may or may not already
                    # exist as the facet values are not ordered.
                    node = tree  # Start at tree root.
                    for component in path:
                        node = node["children"][component]
                    # Add data at the leaf.
                    node["node"] = {
                        "id": "-".join(path),
                        "label": label,
                        "count": count,
                        "count_formatted": format_decimal(count, locale=get_locale()),
                        "remove_url": remove_url,
                        "add_url": add_url,
                    }
        return self.sort_tree(tree)


class LanguageFacetSpec(FlatFacetSpec):
    """
    Specifies a facet based on the Zotero language field.
    """

    def __init__(
        self, *, values_separator_re=";", normalize=True, locale="en", allow_invalid=True, **kwargs
    ):
        # Provide some convenient defaults for this type of facet.
        kwargs.setdefault("field_type", ID(stored=True))
        kwargs.setdefault("filter_key", "lang")
        kwargs.setdefault(
            "extractor",
            extractors.LanguageExtractor(
                values_separator_re=values_separator_re,
                normalize=normalize,
                locale=locale,
                allow_invalid=allow_invalid,
            ),
        )
        kwargs.setdefault("codec", codecs.LabelFacetCodec(label_separator=" "))
        kwargs.setdefault("allow_overlap", True)
        kwargs.setdefault("query_class", Prefix)
        super().__init__(**kwargs)


class CollectionFacetSpec(TreeFacetSpec):
    """
    Specifies a facet based on a top-level Zotero collection.

    A top-level Zotero collection can act as a facet when its key matches a
    given `collection_key`. Subcollections become values within the facet.
    """

    def __init__(self, *, collection_key, **kwargs):
        # Provide some convenient defaults for this type of facet.
        kwargs.setdefault("key", f"facet_collection_{collection_key}")
        kwargs.setdefault("field_type", ID(stored=True))
        kwargs.setdefault("filter_key", slugify(str(kwargs.get("title"))))
        kwargs.setdefault("codec", codecs.CollectionFacetCodec())
        kwargs.setdefault("query_class", Prefix)
        kwargs.setdefault("extractor", extractors.CollectionFacetTreeExtractor())
        super().__init__(**kwargs)
        self.collection_key = collection_key


class SortSpec:
    """
    Specifies a sort option.

    This is a configuration element, with no effect on the search index schema.
    """

    def __init__(
        self,
        key,
        label,
        fields,
        weight=0,
        reverse=False,
        is_allowed=True,
    ):
        """
        Initialize a sort option.

        :param str key: Key of this sort option.

        :param str label: Label of this sort option.

        :param list fields: list of `FieldSpec` instances to use when doing
            search queries with this sort option, in order of precedence.

        :param int weight: Determine the position of this option relative to the
            other options.

        :param bool reverse: Whether the fields should be sorted in reverse
            order. To provide per-field reverse settings, an iterable may be
            supplied instead of a bool, in which case it should contain the same
            number of elements as the `fields` parameter.

        :param callable is_allowed: Optional callable to determine if, given a
            criteria object, the sort option should be allowed.
        """
        self.key = key
        self.label = label
        self.fields = fields
        self.weight = weight
        if isinstance(reverse, Iterable):
            if all(reverse):
                self.reverse = True
            elif not any(reverse):
                self.reverse = False
            else:
                self.reverse = reverse
        else:
            self.reverse = reverse
        self._is_allowed = is_allowed

    def is_allowed(self, criteria):
        if callable(self._is_allowed):
            return self._is_allowed(criteria)
        return self._is_allowed

    def get_field_keys(self):
        if self.fields:
            return [spec.key for spec in self.fields]
        return None


class BibFormatSpec:
    """
    Specifies a bibliographic record download format.

    This is a configuration element, with no effect on the search index schema.
    """

    def __init__(
        self,
        key,
        field,
        label,
        help_text,
        weight,
        extension,
        mime_type,
        group_format="{}",
        group_item_delimiter="",
    ):
        """
        Initialize a format.

        :param str key: Key of this format.

        :param FieldSpec field: `FieldSpec` instance associated to this format.

        :param str label: Label of this format.

        :param int weight: Determine the position of this format relative to the
            others in lists.

        :param str extension: File extension of this format.

        :param str mime_type: MIME type of this format.

        :param str group_format: Format string for wrapping multiple entries.

        :param str group_item_delimiter: Delimiter string to insert between
            entries.
        """
        self.key = key
        self.field = field
        self.label = label
        self.help_text = help_text
        self.weight = weight
        self.extension = extension
        self.mime_type = mime_type
        self.group_format = group_format
        self.group_item_delimiter = group_item_delimiter


class RelationSpec:
    """
    Specifies a type of relation between items.

    This is a configuration element, with no effect on the search index schema.
    """

    def __init__(
        self,
        *,
        key,
        field,
        label,
        weight,
        id_fields,
        directed=True,
        reverse=False,
        reverse_key="",
        reverse_field_key="",
        reverse_label="",
    ):
        """
        Initialize a relation type.

        :param str key: Key of this relation type.

        :param FieldSpec field: `FieldSpec` instance associated to this relation
            type. The field will be looked up in the search index to retrieve
            item identifiers related to a given item.

        :param str label: Label of this relation type.

        :param int weight: Determine the position of this relation type
            relatively to other relation types in lists.

        :paras list id_fields: list of `FieldSpec` instances representing the
            fields to search when trying to resolve an item identifier.

        :param bool directed: Whether a relation is directed (i.e.
            bidirectional) or not.

        :param bool reverse: Whether a reverse relation should be exposed. If
            `directed` is `False`, this can only be `False` as well.

        :param str reverse_key: Key of the reverse relation. Should be set only
            if `reverse` is `True`.

        :param str reverse_field_key: Field key to use for storing the reverse
            relation. This isn't a `FieldSpec` as the field won't be looked up
            in the search index. Instead, it will be dynamically populated with
            items whose `field` contain a given item. Should be set only if
            `reverse` is `True`.

        :param str reverse_label: Label of the reverse relation. Should be set
            only if `reverse` is `True`.
        """
        assert not reverse and not directed or directed
        assert not reverse and not reverse_key or reverse
        assert not reverse and not reverse_field_key or reverse
        assert not reverse and not reverse_label or reverse
        self.key = key
        self.field = field
        self.label = label
        self.weight = weight
        self.id_fields = id_fields
        self.directed = directed
        self.reverse = reverse
        self.reverse_key = reverse_key
        self.reverse_field_key = reverse_field_key
        self.reverse_label = reverse_label


class BadgeSpec:
    """
    Specifies a badge.

    Badges may be displayed on items (in search results and on full
    bibliographic record pages).

    This is a configuration element, with no effect on the search index schema.
    """

    def __init__(
        self,
        key,
        field,
        activator,
        renderer,
        weight=0,
    ):
        """
        Initialize this badge specification.

        :param str key: Key of this badge.

        :param FieldSpec field: `FieldSpec` instance required by this badge.

        :param callable activator: Callable which, given a `FieldSpec` instance
            and an item, must return `True` if the badge should be displayed.

        :param `renderers.Renderer` renderer: A renderer for this badge. The
            rendering context provides the following variables:
            - `field`: The `FieldSpec` instance.
            - `item`: The item retrieved from the search index.
            - `mode`: A string whose value is one of the following:
                - `'result':` The item is being viewed in a list of results.
                - `'item'`: Viewing the item's full bibliographic record.

        :param int weight: Determine the position of this badge relative to the
            others.
        """
        self.key = key
        self.field = field
        self.activator = activator
        self.renderer = renderer
        self.weight = weight

    def is_active(self, item):
        """
        Return `True` is this badge is active for the given item.
        """
        return self.activator(self.field, item)

    def render(self, item, mode):
        """
        Render the badge, if necessary, for the given item.

        :return str: The rendered badge, or `''` if the badge is not activated
            on the item.
        """
        if self.is_active(item):
            return self.renderer.render(field=self.field, item=item, mode=mode)
        return ""


class PageSpec:
    """
    Specifies a page whose content is to be extracted from a Zotero note.
    """

    def __init__(self, path: str, item_id: str, title: str):
        self.path = path
        self.item_id = item_id
        self.title = title


class LinkSpec(ABC):
    def __init__(self, *, text: str, new_window=False, weight=0):
        self.text = text
        self.new_window = new_window
        self.weight = weight

    def is_active(self, request: Request) -> bool:  # noqa: ARG002
        return False

    @property
    @abstractmethod
    def url(self) -> str:
        pass


class LinkByURLSpec(LinkSpec):
    def __init__(self, *, url: str, **kwargs):
        super().__init__(**kwargs)
        self._url = url

    @property
    def url(self) -> str:
        return safe_url_string(self._url)


class LinkByEndpointSpec(LinkSpec):
    def __init__(
        self,
        *,
        endpoint: str,
        external: bool = False,
        anchor: Optional[str] = None,
        scheme: Optional[str] = None,
        parameters: Optional[dict[str, Any]] = None,
        **kwargs,
    ):
        super().__init__(**kwargs)
        self.endpoint = endpoint
        self.external = external
        self.anchor = anchor or None  # Replace empty string with None.
        self.scheme = scheme or None  # Replace empty string with None.
        self.parameters = parameters or {}

    def is_active(self, request: Request) -> bool:
        return request.endpoint == self.endpoint

    @property
    def url(self) -> str:
        return url_for(
            self.endpoint,
            _anchor=self.anchor,
            _scheme=self.scheme,
            _external=self.external,
            **self.parameters,
        )


class PageLinkSpec(LinkSpec):
    def __init__(self, *, page: str, **kwargs):
        super().__init__(**kwargs)
        self.endpoint = f"kerko.{page}"

    def is_active(self, request: Request) -> bool:
        return request.endpoint == self.endpoint

    @property
    def url(self) -> str:
        return url_for(self.endpoint)


class LinkGroupSpec:
    def __init__(self, key: str, links: Optional[list[LinkSpec]] = None):
        self.key = key
        self.links = links or []

    def add_item(self, item: LinkSpec):
        self.links.append(item)

    def get_ordered_links(self) -> list[LinkSpec]:
        return sorted(self.links, key=lambda spec: spec.weight)
