from __future__ import annotations

import re
import textwrap
from typing import TYPE_CHECKING
from xml.etree.ElementTree import tostring

if TYPE_CHECKING:
    import os
    from collections.abc import Callable, Iterable, Sequence
    from xml.etree.ElementTree import Element, ElementTree


def _get_text(node: Element) -> str:
    if node.text is not None:
        # the node has only one text
        return node.text

    # the node has tags and text; gather texts just under the node
    return ''.join(n.tail or '' for n in node)


def _prettify(nodes: Iterable[Element]) -> str:
    def pformat(node: Element) -> str:
        return tostring(node, encoding='unicode', method='html')

    return ''.join(f'(i={index}) {pformat(node)}\n' for index, node in enumerate(nodes))


def check_xpath(
    etree: ElementTree,
    filename: str | os.PathLike[str],
    xpath: str,
    check: str | re.Pattern[str] | Callable[[Sequence[Element]], None] | None,
    be_found: bool = True,
    *,
    min_count: int = 1,
) -> None:
    """Check that one or more nodes satisfy a predicate.

    :param etree: The element tree.
    :param filename: The element tree source name (for errors only).
    :param xpath: An XPath expression to use.
    :param check: Optional regular expression or a predicate the nodes must validate.
    :param be_found: If false, negate the predicate.
    :param min_count: Minimum number of nodes expected to satisfy the predicate.

    * If *check* is empty (``''``), only the minimum count is checked.
    * If *check* is ``None``, no node should satisfy the XPath expression.
    """
    nodes = etree.findall(xpath)
    assert isinstance(nodes, list)

    if check is None:
        # use == to have a nice pytest diff
        assert nodes == [], f'found nodes matching xpath {xpath!r} in file {filename}'
        return

    assert len(nodes) >= min_count, (
        f'expecting at least {min_count} node(s) '
        f'to satisfy {xpath!r} in file {filename}'
    )

    if check == '':
        return

    if callable(check):
        check(nodes)
        return

    # https://github.com/astral-sh/ty/issues/117
    # callable(...) does not currently narrow in ty.
    rex = re.compile(check)  # ty: ignore[no-matching-overload]
    if be_found:
        if any(rex.search(_get_text(node)) for node in nodes):
            return
    else:
        if all(not rex.search(_get_text(node)) for node in nodes):
            return

    ctx = textwrap.indent(_prettify(nodes), ' ' * 2)
    msg = (
        f'{check!r} not found in any node matching {xpath!r} in file {filename}:\n{ctx}'
    )
    raise AssertionError(msg)
