"""
Template rendering.

Package provides primitives for Jinja2 template rendering, plus our
custom filters.
"""

import re
import shlex
import textwrap
from re import Match
from typing import (
    TYPE_CHECKING,
    Any,
    Callable,
    Optional,
    cast,
)

import fmf.utils
import jinja2
import jinja2.exceptions

from tmt.utils import GeneralError, Path, to_yaml
from tmt.utils.git import web_git_url

if TYPE_CHECKING:
    from tmt.guest import Guest
    from tmt.result import BaseResult


def _template_filter_basename(  # type: ignore[reportUnusedFunction,unused-ignore]
    pathlike: str,
) -> str:
    """
    Return the last component of the given path.

    .. code-block:: jinja

        # "/etc/fstab" -> "fstab"
        {{ "/etc/fstab" | basename }}

        # "/var/log/" -> "log"
        {{ "/var/log/" | basename }}
    """

    return Path(pathlike).name


def _template_filter_match(  # type: ignore[reportUnusedFunction,unused-ignore]
    s: str, pattern: str
) -> Optional[Match[str]]:
    """
    Return `re.Match`__ if the string matches a given pattern.

    Pattern is tested in "match" mode, i.e. it must match from the
    beginning of the string. See :ref:`regular-expressions` description
    for more details.

    __ https://docs.python.org/3.9/library/re.html#match-objects

    .. code-block:: jinja

         # 'foo/bar' -> 'foo/bar'
        {{ 'foo/bar' | match('foo/.*').group() }}

        # 'foo/bar' -> ''
        {{ 'foo/bar' | match('foo/(.+?)/(.*)') }}

        # 'foo/bar/baz' -> 'bar'
        {{ 'foo/bar' | match('foo/(.+?)/.*').group(1) }}
    """

    return re.match(pattern, s)


def _template_filter_search(  # type: ignore[reportUnusedFunction,unused-ignore]
    s: str, pattern: str
) -> Optional[Match[str]]:
    """
    Return `re.Match`__ if the string matches a given pattern.

    Pattern is tested in "search" mode, i.e. it can match anywhere
    in the string. See :ref:`regular-expressions` description for more
    details.

    __ https://docs.python.org/3.9/library/re.html#match-objects

    .. code-block:: jinja

         # 'baz/foo/bar' -> 'foo/bar'
        {{ 'baz/foo/bar' | search('foo/.*').group() }}

        # 'baz/foo/bar' -> ''
        {{ 'baz/foo/bar' | search('foo/(.+?)/(.*)') }}

        # 'baz/foo/bar/baz' -> 'bar'
        {{ 'baz/foo/bar' | search('foo/(.+?)/.*').group(1) }}
    """

    return re.search(pattern, s)


def _template_filter_regex_findall(  # type: ignore[reportUnusedFunction,unused-ignore]
    s: str, pattern: str
) -> list[str]:
    """
    Return a list of all non-overlapping matches in the string.

    If one or more capturing groups are present in the pattern, return
    a list of groups; this will be a list of tuples if the pattern
    has more than one group.

    Empty matches are included in the result.

    .. code-block:: jinja

        # '/var/log/mail.log' => ['/', '/', '/']
        {{ '/var/log/mail.log' | regex_findall('/') }}
    """

    return re.findall(pattern, s)


def _template_filter_regex_match(  # type: ignore[reportUnusedFunction,unused-ignore]
    s: str, pattern: str
) -> str:
    """
    Return string matching a given pattern.

    Pattern is tested in "match" mode, i.e. it must match from the
    beginning of the string. See :ref:`regular-expressions` description
    for more details.

    If the string matches and pattern contains capture groups, the
    first group is returned. If the string matches, but patterns
    contains no capture group, the whole match is returned.
    Otherwise, an empty string is returned.

    .. code-block:: jinja

        # 'foo/bar' -> 'foo/bar'
        {{ 'foo/bar' | regex_match('foo/.*') }}

        # 'foo/bar' -> ''
        {{ 'foo/bar' | regex_match('foo/(.+?)/(.*)') }}

        # 'foo/bar/baz' -> 'bar'
        {{ 'foo/bar/baz' | regex_match('foo/(.+?)/.*') }}
    """

    match = re.match(pattern, s)

    if match is None:
        return ''

    if not match.groups():
        return match.group()

    return match.group(1)


def _template_filter_regex_search(  # type: ignore[reportUnusedFunction,unused-ignore]
    s: str, pattern: str
) -> str:
    """
    Return string matching a given pattern.

    Pattern is tested in "search" mode, i.e. it can match anywhere
    in the string. See :ref:`regular-expressions` description for more
    details.

    If the string matches and pattern contains capture groups, the
    first group is returned. If the string matches, but patterns
    contains no capture group, the whole match is returned.
    Otherwise, an empty string is returned.

    .. code-block:: jinja

        # 'baz/foo/bar' -> 'foo/bar'
        {{ 'baz/foo/bar' | regex_search('foo/.*') }}

        # 'baz/foo/bar' -> ''
        {{ 'baz/foo/bar' | regex_search('foo/(.+?)/(.*)') }}

        # 'baz/foo/bar/baz' -> 'bar'
        {{ 'baz/foo/bar/baz' | regex_search('foo/(.+?)/.*') }}
    """

    match = re.search(pattern, s)

    if match is None:
        return ''

    if not match.groups():
        return match.group()

    return match.group(1)


def _template_filter_regex_replace(  # type: ignore[reportUnusedFunction,unused-ignore]
    s: str, pattern: str, repl: str
) -> str:
    """
    Replace a substring defined by a regular expression with another string.

    Return the string obtained by replacing the leftmost
    non-overlapping occurrences of pattern in string by the replacement.
    If the pattern isn't found, string is returned unchanged.

    Backreferences in the replacement string are replaced with the
    substring matched by a group in the pattern.

    .. code-block:: jinja

        # 'foo/bar' -> 'foo/baz'
        {{ 'foo/bar' | regex_replace('(.+)/bar', '\1/baz') }}

        # 'foo/bar' -> 'foo/bar'
        {{ 'foo/bar' | regex_replace('(.+)/baz', '\1/') }}
    """

    return re.sub(pattern, repl, s)


def _template_filter_dedent(s: str) -> str:  # type: ignore[reportUnusedFunction,unused-ignore]
    """
    Remove any common leading whitespace from every line in the string.

    .. code-block:: jinja

        # "foo bar" -> "foo bar"
        {{ "foo bar" | dedent }}

        # '''
        #    foo
        #    bar
        #        baz
        # '''
        #  ->
        # '''
        # foo
        # bar
        #    baz
        # '''
        {{ "\\n    foo\\n    bar\\n        baz\\n" | dedent }}
    """

    return textwrap.dedent(s)


def _template_filter_listed(  # type: ignore[reportUnusedFunction,unused-ignore]
    items: list[Any],
    singular: Optional[str] = None,
    plural: Optional[str] = None,
    max: Optional[int] = None,
    quote: str = "",
    join: str = "and",
) -> str:
    """
    Return a nice, human readable description of an iterable.

    .. code-block:: jinja

        # [0] -> "0"
        {{ [0] | listed }}

        # [0, 1] -> "0 and 1"
        {{ [0, 1] | listed }}

        # [0, 1, 2] -> "0, 1, or 2"
        {{ [0, 1, 2] | listed(join='or') }}

        # [0, 1, 2] -> '"0", "1" and "2"'
        {{ [0, 1, 2] | listed(quote='"') }}

        # [0, 1, 2, 3] -> "0, 1, 2 and 1 more"
        {{ [0, 1, 2, 3] | listed(max=3) }}

        # [0, 1, 2, 3, 4] -> "0, 1, 2 and 2 more numbers"
        {{ [0, 1, 2, 3, 4, 5] | listed('number', max=3) }}

        # [0, 1, 2, 3, 4, 5] -> "6 categories"
        {{ [0, 1, 2, 3, 4, 5] | listed('category') }}

        # [0, 1, 2, 3, 4, 5, 6] -> "7 leaves"
        {{ [0, 1, 2, 3, 4, 5, 6] | listed("leaf", "leaves") }}
    """

    # cast: for some reason, mypy sees `listed` as `Any`
    return cast(
        str,
        fmf.utils.listed(items, singular=singular, plural=plural, max=max, quote=quote, join=join),
    )


def _template_filter_web_git_url(  # type: ignore[reportUnusedFunction,unused-ignore]
    path_str: str,
    url: str,
    ref: str,
) -> str:
    """
    Sanitize git url using :py:meth:`tmt.utils.web_git_url`

    .. code-block:: jinja

        {{ "/path/to/the/code.py" | web_git_url(STORY.fmf_id.url, STORY.fmf_id.ref) }}

        {{ "/tmt/base.py" | web_git_url("https://github.com/teemtee/tmt.git", "main") }}
        -> https://github.com/teemtee/tmt/tree/main/tmt/base.py

    """

    path = Path(path_str) if path_str else None
    return web_git_url(url, ref, path)


def _template_filter_shell_quote(  # type: ignore[reportUnusedFunction,unused-ignore]
    s: str,
) -> str:
    """
    Return a shell-escaped version of the string.

    .. code-block:: jinja

        # "foo bar" -> "'foo bar'"
        {{ "foo bar" | shell_quote }}
    """

    return shlex.quote(s)


def _template_filter_style(  # type: ignore[reportUnusedFunction,unused-ignore]
    s: str,
    fg: Optional[str] = None,
    bold: Optional[bool] = None,
    underline: Optional[bool] = None,
) -> str:
    """
    Evaluate terminal-style colorization tags supported by Click.

    Implemented by passing all arguments to :py:func:`click.style`.
    """

    from tmt.utils.themes import style

    return style(s, fg=fg, bold=bold, underline=underline)


def _template_filter_guest_full_name(  # type: ignore[reportUnusedFunction,unused-ignore]
    guest: 'Guest',
) -> str:
    """
    Render guest's "full name".

    Implemented by calling :py:func:`format_guest_full_name`.

    .. code-block:: jinja

        # {"name": "foo", "role": None} -> 'foo'
        {{ {"name": "foo", "role": None} | guest_full_name }}

        # {"name": "foo", "role": "bar"} -> 'foo (bar)'
        {{ {"name": "foo", "role": "bar"} | guest_full_name }}
    """

    from tmt.guest import format_guest_full_name

    return format_guest_full_name(guest.name, guest.role)


def _template_filter_format_duration(  # type: ignore[reportUnusedFunction,unused-ignore]
    result: 'BaseResult',
) -> str:
    """
    Render result duration in the ``hh:mm:ss`` format.

    If the duration is not defined, return a placeholder marker instead.

    .. code-block:: jinja

        # {"duration": None} -> '..:..:..'
        {{ {"duration": None} | format_duration }}

        # {"duration": "12:34:56"} -> '12:34:56'
        {{ {"duration": "12:34:56"} | format_duration }}
    """

    return result.duration or '..:..:..'


def _template_filter_to_yaml(  # type: ignore[reportUnusedFunction,unused-ignore]
    value: Any,
) -> str:
    """
    Render a given object as a YAML snippet.

    .. code-block:: jinja

        # {"foo": "bar", "baz": false} -> foo: bar\\nbaz: false
        {{ {"foo": "bar", "baz": false} | to_yaml }}
    """

    return to_yaml(value)


def _template_filter_prefix(  # type: ignore[reportUnusedFunction,unused-ignore]
    s: str, prefix: str
) -> str:
    """
    Add prefix to each line of a string.

    .. code-block:: jinja

        # "foo\\nbar" -> "baz foo\\nbaz bar"
        {{ "foo\\nbar" | prefix("baz") }}
    """

    return textwrap.indent(s, prefix)


TEMPLATE_FILTERS: dict[str, Callable[..., Any]] = {
    _name.replace('_template_filter_', ''): _obj
    for _name, _obj in locals().items()
    if callable(_obj) and _name.startswith('_template_filter_')
}


def _template_test_unit(value: Any) -> bool:  # type: ignore[reportUnusedFunction,unused-ignore]
    """
    Return true if the object is a unit.

    .. code-block:: jinja

        {% if value is unit %}
            Value is a Pint's ``Quantity`` instance.
        {% endif %}
    """

    from pint import Quantity

    return isinstance(value, Quantity)


TEMPLATE_TESTS: dict[str, Callable[..., Any]] = {
    _name.replace('_template_test_', ''): _obj
    for _name, _obj in locals().items()
    if callable(_obj) and _name.startswith('_template_test_')
}


def default_template_environment() -> jinja2.Environment:
    """
    Create a Jinja2 environment with default settings.

    Adds common filters, and enables block trimming and left strip.
    """

    # S701: `autoescape=False` is dangerous and can lead to XSS.
    # As there can be many different template file formats, used to render various formats,
    # we need to explicitly set autoescape=False, as default might change in the future.
    # Potential improvements are being tracked in /teemtee/tmt/issues/2873

    environment = jinja2.Environment()  # noqa: S701

    environment.filters.update(TEMPLATE_FILTERS)
    environment.tests.update(TEMPLATE_TESTS)

    environment.trim_blocks = True
    environment.lstrip_blocks = True

    return environment


def render_template(
    template: str,
    template_filepath: Optional[Path] = None,
    environment: Optional[jinja2.Environment] = None,
    **variables: Any,
) -> str:
    """
    Render a template.

    :param template: template to render.
    :param template_filepath: path to the template file, if any.
    :param environment: Jinja2 environment to use.
    :param variables: variables to pass to the template.
    """

    environment = environment or default_template_environment()

    def raise_error(message: str) -> None:
        """
        An in-template helper for raising exceptions
        """

        raise Exception(message)

    if 'raise_error' not in variables:
        variables['raise_error'] = raise_error

    try:
        return environment.from_string(template).render(**variables).strip()

    except jinja2.exceptions.TemplateSyntaxError as error:
        if template_filepath:
            raise GeneralError(f"Could not parse template '{template_filepath}'.") from error
        raise GeneralError("Could not parse template.") from error

    except jinja2.exceptions.TemplateError as error:
        if template_filepath:
            raise GeneralError(f"Could not render template '{template_filepath}'.") from error
        raise GeneralError("Could not render template.") from error


def render_template_file(
    template_filepath: Path,
    environment: Optional[jinja2.Environment] = None,
    **variables: Any,
) -> str:
    """
    Render a template from a file.

    :param template_filepath: path to the template file.
    :param environment: Jinja2 environment to use.
    :param variables: variables to pass to the template.
    """

    try:
        return render_template(
            template_filepath.read_text(), template_filepath, environment, **variables
        )

    except FileNotFoundError as error:
        raise GeneralError(f"Could not open template '{template_filepath}'.") from error


def render_template_file_into_file(
    input_filepath: Path,
    output_filepath: Path,
    environment: Optional[jinja2.Environment] = None,
    **variables: Any,
) -> None:
    """
    Render a template from a file, and write the result into a file.

    Combines :py:func:`render_template_file` and
    :py:meth:`Path.write_text`, but makes sure the file ends with an
    empty line.

    :param input_filepath: path to the template file.
    :param output_filepath: path to the file in which the rendering
        should be saved.
    :param environment: Jinja2 environment to use.
    :param variables: variables to pass to the template.
    """

    output_filepath.write_text(
        render_template_file(input_filepath, environment=environment, **variables)
    )

    output_filepath.append_text('\n')
