"""
Node Searching.

.. note:: You can speed-up node searching, by installing https://pypi.org/project/fastcache/ and
          using :any:`cachedsearch`.
"""

from anytree.iterators import PreOrderIter


def findall(node, filter_=None, stop=None, maxlevel=None, mincount=None, maxcount=None):
    """
    Search nodes matching `filter_` but stop at `maxlevel` or `stop`.

    Return tuple with matching nodes.

    Args:
        node: top node, start searching.

    Keyword Args:
        filter_: function called with every `node` as argument, `node` is returned if `True`.
        stop: stop iteration at `node` if `stop` function returns `True` for `node`.
        maxlevel (int): maximum descending in the node hierarchy.
        mincount (int): minimum number of nodes.
        maxcount (int): maximum number of nodes.

    Example tree:

    >>> from anytree import Node, RenderTree, AsciiStyle
    >>> f = Node("f")
    >>> b = Node("b", parent=f)
    >>> a = Node("a", parent=b)
    >>> d = Node("d", parent=b)
    >>> c = Node("c", parent=d)
    >>> e = Node("e", parent=d)
    >>> g = Node("g", parent=f)
    >>> i = Node("i", parent=g)
    >>> h = Node("h", parent=i)
    >>> print(RenderTree(f, style=AsciiStyle()).by_attr())
    f
    |-- b
    |   |-- a
    |   +-- d
    |       |-- c
    |       +-- e
    +-- g
        +-- i
            +-- h

    >>> findall(f, filter_=lambda node: node.name in ("a", "b"))
    (Node('/f/b'), Node('/f/b/a'))
    >>> findall(f, filter_=lambda node: d in node.path)
    (Node('/f/b/d'), Node('/f/b/d/c'), Node('/f/b/d/e'))

    The number of matches can be limited:

    >>> findall(f, filter_=lambda node: d in node.path, mincount=4)  # doctest: +ELLIPSIS
    Traceback (most recent call last):
      ...
    anytree.search.CountError: Expecting at least 4 elements, but found 3. ... Node('/f/b/d/e'))
    >>> findall(f, filter_=lambda node: d in node.path, maxcount=2)  # doctest: +ELLIPSIS
    Traceback (most recent call last):
      ...
    anytree.search.CountError: Expecting 2 elements at maximum, but found 3. ... Node('/f/b/d/e'))
    """
    return _findall(node, filter_=filter_, stop=stop,
                    maxlevel=maxlevel, mincount=mincount, maxcount=maxcount)


def findall_by_attr(node, value, name="name", maxlevel=None, mincount=None, maxcount=None):
    """
    Search nodes with attribute `name` having `value` but stop at `maxlevel`.

    Return tuple with matching nodes.

    Args:
        node: top node, start searching.
        value: value which need to match

    Keyword Args:
        name (str): attribute name need to match
        maxlevel (int): maximum descending in the node hierarchy.
        mincount (int): minimum number of nodes.
        maxcount (int): maximum number of nodes.

    Example tree:

    >>> from anytree import Node, RenderTree, AsciiStyle
    >>> f = Node("f")
    >>> b = Node("b", parent=f)
    >>> a = Node("a", parent=b)
    >>> d = Node("d", parent=b)
    >>> c = Node("c", parent=d)
    >>> e = Node("e", parent=d)
    >>> g = Node("g", parent=f)
    >>> i = Node("i", parent=g)
    >>> h = Node("h", parent=i)
    >>> print(RenderTree(f, style=AsciiStyle()).by_attr())
    f
    |-- b
    |   |-- a
    |   +-- d
    |       |-- c
    |       +-- e
    +-- g
        +-- i
            +-- h

    >>> findall_by_attr(f, "d")
    (Node('/f/b/d'),)
    """
    return _findall(node, filter_=lambda n: _filter_by_name(n, name, value),
                    maxlevel=maxlevel, mincount=mincount, maxcount=maxcount)


def find(node, filter_=None, stop=None, maxlevel=None):
    """
    Search for *single* node matching `filter_` but stop at `maxlevel` or `stop`.

    Return matching node.

    Args:
        node: top node, start searching.

    Keyword Args:
        filter_: function called with every `node` as argument, `node` is returned if `True`.
        stop: stop iteration at `node` if `stop` function returns `True` for `node`.
        maxlevel (int): maximum descending in the node hierarchy.

    Example tree:

    >>> from anytree import Node, RenderTree, AsciiStyle
    >>> f = Node("f")
    >>> b = Node("b", parent=f)
    >>> a = Node("a", parent=b)
    >>> d = Node("d", parent=b)
    >>> c = Node("c", parent=d)
    >>> e = Node("e", parent=d)
    >>> g = Node("g", parent=f)
    >>> i = Node("i", parent=g)
    >>> h = Node("h", parent=i)
    >>> print(RenderTree(f, style=AsciiStyle()).by_attr())
    f
    |-- b
    |   |-- a
    |   +-- d
    |       |-- c
    |       +-- e
    +-- g
        +-- i
            +-- h

    >>> find(f, lambda node: node.name == "d")
    Node('/f/b/d')
    >>> find(f, lambda node: node.name == "z")
    >>> find(f, lambda node: b in node.path)  # doctest: +ELLIPSIS
    Traceback (most recent call last):
        ...
    anytree.search.CountError: Expecting 1 elements at maximum, but found 5. (Node('/f/b')... Node('/f/b/d/e'))
    """
    return _find(node, filter_=filter_, stop=stop, maxlevel=maxlevel)


def find_by_attr(node, value, name="name", maxlevel=None):
    """
    Search for *single* node with attribute `name` having `value` but stop at `maxlevel`.

    Return tuple with matching nodes.

    Args:
        node: top node, start searching.
        value: value which need to match


    Keyword Args:
        name (str): attribute name need to match
        maxlevel (int): maximum descending in the node hierarchy.

    Example tree:

    >>> from anytree import Node, RenderTree, AsciiStyle
    >>> f = Node("f")
    >>> b = Node("b", parent=f)
    >>> a = Node("a", parent=b)
    >>> d = Node("d", parent=b)
    >>> c = Node("c", parent=d, foo=4)
    >>> e = Node("e", parent=d)
    >>> g = Node("g", parent=f)
    >>> i = Node("i", parent=g)
    >>> h = Node("h", parent=i)
    >>> print(RenderTree(f, style=AsciiStyle()).by_attr())
    f
    |-- b
    |   |-- a
    |   +-- d
    |       |-- c
    |       +-- e
    +-- g
        +-- i
            +-- h

    >>> find_by_attr(f, "d")
    Node('/f/b/d')
    >>> find_by_attr(f, name="foo", value=4)
    Node('/f/b/d/c', foo=4)
    >>> find_by_attr(f, name="foo", value=8)
    """
    return _find(node, filter_=lambda n: _filter_by_name(n, name, value),
                 maxlevel=maxlevel)


def _find(node, filter_, stop=None, maxlevel=None):
    items = _findall(node, filter_, stop=stop, maxlevel=maxlevel, maxcount=1)
    return items[0] if items else None


def _findall(node, filter_, stop=None, maxlevel=None, mincount=None, maxcount=None):
    result = tuple(PreOrderIter(node, filter_, stop, maxlevel))
    resultlen = len(result)
    if mincount is not None and resultlen < mincount:
        msg = "Expecting at least %d elements, but found %d."
        raise CountError(msg % (mincount, resultlen), result)
    if maxcount is not None and resultlen > maxcount:
        msg = "Expecting %d elements at maximum, but found %d."
        raise CountError(msg % (maxcount, resultlen), result)
    return result


def _filter_by_name(node, name, value):
    try:
        return getattr(node, name) == value
    except AttributeError:
        return False


class CountError(RuntimeError):

    def __init__(self, msg, result):
        """Error raised on `mincount` or `maxcount` mismatch."""
        if result:
            msg += " " + repr(result)
        super(CountError, self).__init__(msg)
