"""Rule for checking content of jinja template strings."""

from __future__ import annotations

import logging
import os
import re
import sys
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, NamedTuple

import black
import jinja2
from ansible.errors import AnsibleError, AnsibleFilterError, AnsibleParserError
from ansible_compat.config import ansible_version
from jinja2.exceptions import TemplateSyntaxError
from packaging.version import Version

from ansiblelint.errors import RuleMatchTransformMeta
from ansiblelint.file_utils import Lintable
from ansiblelint.rules import AnsibleLintRule, TransformMixin
from ansiblelint.runner import get_matches
from ansiblelint.skip_utils import get_rule_skips_from_line
from ansiblelint.text import has_jinja
from ansiblelint.types import AnsibleTemplateSyntaxError
from ansiblelint.utils import (  # type: ignore[attr-defined]
    Templar,
    parse_yaml_from_file,
    template,
)
from ansiblelint.yaml_utils import deannotate, nested_items_path

if TYPE_CHECKING:
    from ruamel.yaml.comments import CommentedMap, CommentedSeq

    from ansiblelint.config import Options
    from ansiblelint.errors import MatchError
    from ansiblelint.utils import Task


_logger = logging.getLogger(__package__)
KEYWORDS_WITH_IMPLICIT_TEMPLATE = ("changed_when", "failed_when", "until", "when")


class Token(NamedTuple):
    """Token."""

    lineno: int
    token_type: str
    value: str


ignored_re = re.compile(
    "|".join(  # noqa: FLY002
        [
            r"^Object of type method is not JSON serializable",
            r"^Unexpected templating type error occurred on",
            r"^obj must be a list of dicts or a nested dict$",
            r"^the template file (.*) could not be found for the lookup$",
            r"could not locate file in lookup",
            r"unable to locate collection",
            r"^Error in (.*)is undefined$",
            r"^Mandatory variable (.*) not defined.$",
            r"is undefined",
            r"Unrecognized type <<class 'ansible.template.AnsibleUndefined'>> for (.*) filter <value>$",
            # https://github.com/ansible/ansible-lint/issues/3155
            r"^The '(.*)' test expects a dictionary$",
            # https://github.com/ansible/ansible-lint/issues/4338
            r"An unhandled exception occurred while templating (.*). Error was a <class 'ansible.errors.AnsibleFilterError'>, original message: The (.*) test expects a dictionary$",
            r"can only concatenate list \(not \"_AnsibleTaggedStr\"\) to list",
            r"can only concatenate str \(not \"_AnsibleTaggedStr\"\) to str",
        ],
    ),
    flags=re.MULTILINE | re.DOTALL,
)


@dataclass(frozen=True)
class JinjaRuleTMetaSpacing(RuleMatchTransformMeta):
    """JinjaRule transform metadata.

    :param key: Key or index within the task
    :param value: Value of the key
    :param path: Path to the key
    :param fixed: Value with spacing fixed
    """

    key: str | int
    value: str | int
    path: tuple[str | int, ...]
    fixed: str

    def __str__(self) -> str:
        """Return string representation."""
        return f"{self.key}={self.value} at {self.path} fixed to {self.fixed}"


class JinjaRule(AnsibleLintRule, TransformMixin):
    """Rule that looks inside jinja2 templates."""

    id = "jinja"
    severity = "LOW"
    tags = ["formatting"]
    version_changed = "6.5.0"
    _ansible_error_re = re.compile(
        (
            r"^(?P<error>.*): (?P<detail>.*)\. String: (?P<string>.*)$"
            r"|An unhandled exception occurred while templating '.*'\. Error was a .*, original message: (?P<nested_error>.*)"
        ),
        flags=re.MULTILINE,
    )

    env = jinja2.Environment(trim_blocks=False)
    _tag2msg = {
        "invalid": "Syntax error in jinja2 template: {value}",
        "spacing": "Jinja2 spacing could be improved: {value} -> {reformatted}",
    }
    _ids = {
        "jinja[invalid]": "Invalid jinja2 syntax",
        "jinja[spacing]": "Jinja2 spacing could be improved",
    }

    def _msg(self, tag: str, value: str, reformatted: str) -> str:
        """Generate error message."""
        return self._tag2msg[tag].format(value=value, reformatted=reformatted)

    # pylint: disable=too-many-locals
    def matchtask(
        self,
        task: Task,
        file: Lintable | None = None,
    ) -> list[MatchError]:
        result = []
        try:
            for key, v, path in nested_items_path(
                task,
                ignored_keys=("block", "ansible.builtin.block", "ansible.legacy.block"),
            ):
                if isinstance(v, str):
                    try:
                        template(
                            basedir=file.path.parent if file else Path(),
                            value=v,
                            variables=deannotate(task.get("vars", {})),
                            fail_on_error=True,  # we later decide which ones to ignore or not
                        )
                    except AnsibleFilterError:
                        bypass = True
                    # ValueError RepresenterError
                    except (AnsibleError, ImportError) as exc:
                        bypass = False
                        orig_exc: BaseException = exc
                        if (
                            isinstance(exc, AnsibleError)
                            and hasattr(exc, "orig_exc")
                            and exc.orig_exc
                        ):
                            orig_exc = exc.orig_exc
                        orig_exc_message = getattr(orig_exc, "message", str(orig_exc))
                        match = self._ansible_error_re.match(
                            getattr(orig_exc, "message", str(orig_exc)),
                        )
                        if ignored_re.search(orig_exc_message) or isinstance(
                            orig_exc,
                            AnsibleParserError | TypeError,
                        ):
                            # An unhandled exception occurred while running the lookup plugin 'template'. Error was a <class 'ansible.errors.AnsibleError'>, original message: the template file ... could not be found for the lookup. the template file ... could not be found for the lookup

                            # ansible@devel (2.14) new behavior:
                            # AnsibleError(TemplateSyntaxError): template error while templating string: Could not load "ipwrap": 'Invalid plugin FQCN (ansible.netcommon.ipwrap): unable to locate collection ansible.netcommon'. String: Foo {{ buildset_registry.host | ipwrap }}. Could not load "ipwrap": 'Invalid plugin FQCN (ansible.netcommon.ipwrap): unable to locate collection ansible.netcommon'
                            bypass = True
                        elif (
                            isinstance(orig_exc, AnsibleError | TemplateSyntaxError)
                            and match
                        ):
                            error = match.group("error")
                            detail = match.group("detail")
                            nested_error = match.group("nested_error")
                            if error and error.startswith(
                                "template error while templating string",
                            ):
                                bypass = False
                            elif detail and detail.startswith(
                                "unable to locate collection",
                            ):
                                _logger.debug("Ignored AnsibleError: %s", exc)
                                bypass = True
                            elif nested_error and nested_error.startswith(
                                "Unexpected templating type error occurred on",
                            ):
                                bypass = True
                            else:
                                bypass = False
                        elif isinstance(exc, ImportError):
                            if self.options and self.options.nodeps:
                                msg = f"Ignored exception {exc} due to running with nodeps mode."
                                _logger.debug(msg)
                                continue
                            bypass = False
                        elif re.match(r"^lookup plugin (.*) not found$", exc.message):
                            # lookup plugin 'template' not found
                            bypass = True
                        elif (
                            exc.message == "A template was resolved to an Omit scalar."
                            or (
                                isinstance(orig_exc, AnsibleTemplateSyntaxError)
                                and re.match(
                                    r"^Syntax error in template: No filter named '.*'.",
                                    exc.message,
                                )
                            )
                        ):
                            bypass = True

                        # AnsibleError: template error while templating string: expected token ':', got '}'. String: {{ {{ '1' }} }}
                        # AnsibleError: template error while templating string: unable to locate collection ansible.netcommon. String: Foo {{ buildset_registry.host | ipwrap }}
                        if not bypass:
                            lineno = task.get_error_line([*path, key])
                            result.append(
                                self.create_matcherror(
                                    message=str(exc),
                                    lineno=lineno,
                                    data=v,
                                    filename=file,
                                    tag=f"{self.id}[invalid]",
                                ),
                            )
                            continue
                    reformatted, details, tag = self.check_whitespace(
                        v,
                        key=key,
                        lintable=file,
                    )
                    if reformatted != v:
                        lineno = task.get_error_line([*path, key])
                        result.append(
                            self.create_matcherror(
                                message=self._msg(
                                    tag=tag,
                                    value=v,
                                    reformatted=reformatted,
                                ),
                                lineno=lineno,
                                data=v,
                                details=details,
                                filename=file,
                                tag=f"{self.id}[{tag}]",
                                transform_meta=JinjaRuleTMetaSpacing(
                                    key=key,
                                    value=v,
                                    path=tuple(path),
                                    fixed=reformatted,
                                ),
                            ),
                        )
        except Exception as exc:
            _logger.info("Exception in JinjaRule.matchtask: %s", exc)
            raise
        return result

    def matchyaml(self, file: Lintable) -> list[MatchError]:
        """Return matches for variables defined in vars files."""
        raw_results: list[MatchError] = []
        results: list[MatchError] = []

        if str(file.kind) == "vars":
            data = parse_yaml_from_file(str(file.path))
            if not isinstance(data, Mapping):
                return results
            for key, v, _path in nested_items_path(data):
                if isinstance(v, str):
                    reformatted, details, tag = self.check_whitespace(
                        v,
                        key=key,
                        lintable=file,
                    )
                    if reformatted != v:
                        results.append(
                            self.create_matcherror(
                                message=self._msg(
                                    tag=tag,
                                    value=v,
                                    reformatted=reformatted,
                                ),
                                data=v,
                                details=details,
                                filename=file,
                                tag=f"{self.id}[{tag}]",
                            ),
                        )
            if raw_results:
                lines = file.content.splitlines()
                for match in raw_results:
                    # lineno starts with 1, not zero
                    skip_list = get_rule_skips_from_line(
                        line=lines[match.lineno - 1],
                        lintable=file,
                    )
                    if match.rule.id not in skip_list and match.tag not in skip_list:
                        results.append(match)
        else:
            results.extend(super().matchyaml(file))
        return results

    def lex(self, text: str) -> list[Token]:
        """Parse jinja template."""
        # https://github.com/pallets/jinja/issues/1711
        self.env.keep_trailing_newline = True

        self.env.lstrip_blocks = False
        self.env.trim_blocks = False
        self.env.autoescape = True
        self.env.newline_sequence = "\n"
        tokens = [
            Token(lineno=t[0], token_type=t[1], value=t[2]) for t in self.env.lex(text)
        ]
        new_text = self.unlex(tokens)
        if text != new_text:
            _logger.debug(
                "Unable to perform full roundtrip lex-unlex on jinja template (expected when '-' modifier is used): {text} -> {new_text}",
            )
        return tokens

    def unlex(
        self, tokens: list[Token], original_line_ending: str | None = None
    ) -> str:
        """Return original text by compiling the lex output."""
        result = ""
        last_lineno = 1
        last_value = ""
        for lineno, _, value in tokens:
            if lineno > last_lineno and "\n" not in last_value:
                result += "\n"
            result += value
            last_lineno = lineno
            last_value = value

        # Preserve original line endings if they were different from \n
        if original_line_ending and original_line_ending != "\n":
            result = result.replace("\n", original_line_ending)

        return result

    # pylint: disable=too-many-locals
    def check_whitespace(
        self,
        text: str,
        key: str,
        lintable: Lintable | None = None,
    ) -> tuple[str, str, str]:
        """Check spacing inside given jinja2 template string.

        We aim to match Python Black formatting rules.
        :raises NotImplementedError: On few cases where valid jinja is not valid Python.

        :returns: (string, string, string)  reformatted text, detailed error, error tag
        """

        def cook(value: str, *, implicit: bool = False) -> str:
            """Prepare an implicit string for jinja parsing when needed."""
            if not implicit:
                return value
            if value.startswith("{{") and value.endswith("}}"):
                # maybe we should make this an error?
                return value
            return f"{{{{ {value} }}}}"

        def uncook(value: str, *, implicit: bool = False) -> str:
            """Restore an string to original form when it was an implicit one."""
            if not implicit:
                return value
            return value[3:-3]

        # Detect original line ending style to preserve it
        # Only preserve \r\n (Windows CRLF), let \r (Mac classic) be normalized to \n
        # as per jinja 3.0.0 behavior (see test case 44)
        original_line_ending = None
        if "\r\n" in text:
            original_line_ending = "\r\n"

        tokens = []
        details = ""
        begin_types = ("variable_begin", "comment_begin", "block_begin")
        end_types = ("variable_end", "comment_end", "block_end")
        implicit = False

        # implicit templates do not have the {{ }} wrapping
        if (
            key in KEYWORDS_WITH_IMPLICIT_TEMPLATE
            and lintable
            and lintable.kind
            in (
                "playbook",
                "task",
            )
        ):
            implicit = True
            text = cook(text, implicit=implicit)

        # don't try to lex strings that have no jinja inside them
        if not has_jinja(text):
            return text, "", "spacing"

        expr_str = None
        expr_type = None
        verb_skipped = True
        lineno = 1
        try:
            for token in self.lex(text):
                if (
                    expr_type
                    and expr_type.startswith("{%")
                    and token.token_type in ("name", "whitespace")
                    and not verb_skipped
                ):
                    # on {% blocks we do not take first word as part of the expression
                    tokens.append(token)
                    if token.token_type != "whitespace":
                        verb_skipped = True
                elif token.token_type in begin_types:
                    tokens.append(token)
                    expr_type = token.value  # such {#, {{, {%
                    expr_str = ""
                    verb_skipped = False
                elif token.token_type in end_types and expr_str is not None:
                    # process expression
                    # pylint: disable=unsupported-membership-test
                    if isinstance(expr_str, str) and "\n" in expr_str:
                        raise NotImplementedError  # noqa: TRY301
                    leading_spaces = " " * (len(expr_str) - len(expr_str.lstrip()))
                    expr_str = leading_spaces + blacken(expr_str.lstrip())
                    if tokens[
                        -1
                    ].token_type != "whitespace" and not expr_str.startswith(" "):
                        expr_str = " " + expr_str
                    if not expr_str.endswith(" "):
                        expr_str += " "
                    tokens.append(Token(lineno, "data", expr_str))
                    tokens.append(token)
                    expr_str = None
                    expr_type = None
                elif expr_str is not None:
                    expr_str += token.value
                else:
                    tokens.append(token)
                lineno = token.lineno

        except jinja2.exceptions.TemplateSyntaxError as exc:
            return "", str(exc.message), "invalid"
        except (NotImplementedError, ValueError) as exc:
            # black is not able to recognize all valid jinja2 templates, so we
            # just ignore InvalidInput errors.
            # NotImplementedError is raised internally for expressions with
            # newlines, as we decided to not touch them yet.
            # These both are documented as known limitations.
            _logger.debug("Ignored jinja internal error %s", exc)
            return uncook(text, implicit=implicit), "", "spacing"

        # finalize
        reformatted = self.unlex(tokens, original_line_ending)
        failed = reformatted != text
        reformatted = uncook(reformatted, implicit=implicit)
        details = (
            f"Jinja2 template rewrite recommendation: `{reformatted}`."
            if failed
            else ""
        )
        return reformatted, details, "spacing"

    def transform(
        self,
        match: MatchError,
        lintable: Lintable,
        data: CommentedMap | CommentedSeq | str,
    ) -> None:
        """Transform jinja2 errors.

        :param match: MatchError instance
        :param lintable: Lintable instance
        :param data: data to transform
        """
        if match.tag == "jinja[spacing]":
            self._transform_spacing(match, data)

    def _transform_spacing(
        self,
        match: MatchError,
        data: CommentedMap | CommentedSeq | str,
    ) -> None:
        """Transform jinja2 spacing errors.

        The match error was found on a normalized task so we cannot compare the path
        instead we only compare the key and value, if the task has 2 identical keys with the
        exact same jinja spacing issue, we may transform them out of order

        :param match: MatchError instance
        :param data: data to transform
        """
        if not isinstance(match.transform_meta, JinjaRuleTMetaSpacing):
            return
        if isinstance(data, str):
            return

        obj = self.seek(match.yaml_path, data)
        if obj is None:
            return

        ignored_keys = ("block", "ansible.builtin.block", "ansible.legacy.block")
        for key, value, path in nested_items_path(
            data_collection=obj,
            ignored_keys=ignored_keys,
        ):
            if key == match.transform_meta.key and value == match.transform_meta.value:
                if not path:
                    continue
                for pth in path[:-1]:
                    try:
                        obj = obj[pth]
                    except (KeyError, TypeError) as exc:
                        err = f"Unable to transform {match.transform_meta}: {exc}"
                        _logger.error(err)  # noqa: TRY400
                        return
                try:
                    obj[path[-1]][key] = match.transform_meta.fixed
                    match.fixed = True

                except (KeyError, TypeError) as exc:
                    err = f"Unable to transform {match.transform_meta}: {exc}"
                    _logger.error(err)  # noqa: TRY400
                return


def blacken(text: str) -> str:
    """Format Jinja2 template using black."""
    return black.format_str(
        text,
        mode=black.FileMode(line_length=sys.maxsize, string_normalization=False),
    ).rstrip("\n")


if "pytest" in sys.modules:
    from unittest import mock

    import pytest

    # pylint: disable=ungrouped-imports
    from ansiblelint.rules import RulesCollection
    from ansiblelint.runner import Runner
    from ansiblelint.transformer import Transformer

    @pytest.mark.libyaml
    def test_jinja_spacing_playbook() -> None:
        """Ensure that expected error lines are matching found linting error lines."""
        # list unexpected error lines or non-matching error lines
        lineno_list = [33, 36, 39, 42, 45, 48, 74]
        lintable = Lintable("examples/playbooks/jinja-spacing.yml")
        collection = RulesCollection()
        collection.register(JinjaRule())
        results = Runner(lintable, rules=collection).run()
        assert len(results) == len(lineno_list)
        for index, result in enumerate(results):
            assert result.tag == "jinja[spacing]"
            assert result.lineno == lineno_list[index]

        # error_lines_difference = list(
        #     set(error_expected_lines).symmetric_difference(set(lint_error_lines)),
        # )
        # assert len(error_lines_difference) == 0

    def test_jinja_spacing_vars() -> None:
        """Ensure that expected error details are matching found linting error details."""
        collection = RulesCollection()
        collection.register(JinjaRule())
        lintable = Lintable("examples/playbooks/vars/jinja-spacing.yml")
        results = Runner(lintable, rules=collection).run()

        error_expected_lineno = [14, 15, 16, 17, 18, 19, 32]
        assert len(results) == len(error_expected_lineno)
        for idx, err in enumerate(results):
            assert err.lineno == error_expected_lineno[idx]

    @pytest.mark.parametrize(
        ("text", "expected", "tag"),
        (
            pytest.param(
                "{{-x}}{#a#}{%1%}",
                "{{- x }}{# a #}{% 1 %}",
                "spacing",
                id="add-missing-space",
            ),
            pytest.param("", "", "spacing", id="1"),
            pytest.param("foo", "foo", "spacing", id="2"),
            pytest.param("{##}", "{# #}", "spacing", id="3"),
            # we want to keep leading spaces as they might be needed for complex multiline jinja files
            pytest.param("{#  #}", "{#  #}", "spacing", id="4"),
            pytest.param(
                "{{-aaa|xx   }}foo\nbar{#some#}\n{%%}",
                "{{- aaa | xx }}foo\nbar{# some #}\n{% %}",
                "spacing",
                id="5",
            ),
            pytest.param(
                "Shell with jinja filter",
                "Shell with jinja filter",
                "spacing",
                id="6",
            ),
            pytest.param(
                "{{{'dummy_2':1}|true}}",
                "{{ {'dummy_2': 1} | true }}",
                "spacing",
                id="7",
            ),
            pytest.param("{{{foo:{}}}}", "{{ {foo: {}} }}", "spacing", id="8"),
            pytest.param(
                "{{ {'test': {'subtest': variable}} }}",
                "{{ {'test': {'subtest': variable}} }}",
                "spacing",
                id="9",
            ),
            pytest.param(
                "http://foo.com/{{\n  case1 }}",
                "http://foo.com/{{\n  case1 }}",
                "spacing",
                id="10",
            ),
            pytest.param("{{foo(123)}}", "{{ foo(123) }}", "spacing", id="11"),
            pytest.param("{{ foo(a.b.c) }}", "{{ foo(a.b.c) }}", "spacing", id="12"),
            pytest.param(
                "{{foo(x =['server_options'])}}",
                "{{ foo(x=['server_options']) }}",
                "spacing",
                id="14",
            ),
            pytest.param(
                '{{ [ "host", "NA"] }}',
                '{{ ["host", "NA"] }}',
                "spacing",
                id="15",
            ),
            pytest.param(
                "{{ {'dummy_2': {'nested_dummy_1': value_1,\n    'nested_dummy_2': value_2}} |\ncombine(dummy_1) }}",
                "{{ {'dummy_2': {'nested_dummy_1': value_1,\n    'nested_dummy_2': value_2}} |\ncombine(dummy_1) }}",
                "spacing",
                id="17",
            ),
            pytest.param("{{ & }}", "", "invalid", id="18"),
            pytest.param(
                "{{ good_format }}/\n{{- good_format }}\n{{- good_format -}}\n",
                "{{ good_format }}/\n{{- good_format }}\n{{- good_format -}}\n",
                "spacing",
                id="19",
            ),
            pytest.param(
                "{{ {'a': {'b': 'x', 'c': y}} }}",
                "{{ {'a': {'b': 'x', 'c': y}} }}",
                "spacing",
                id="20",
            ),
            pytest.param(
                "2*(1+(3-1)) is {{ 2 * {{ 1 + {{ 3 - 1 }}}} }}",
                "2*(1+(3-1)) is {{ 2 * {{1 + {{3 - 1}}}} }}",
                "spacing",
                id="21",
            ),
            pytest.param(
                '{{ "absent"\nif (v is version("2.8.0", ">=")\nelse "present" }}',
                "",
                "invalid",
                id="22",
            ),
            pytest.param(
                '{{lookup("x",y+"/foo/"+z+".txt")}}',
                '{{ lookup("x", y + "/foo/" + z + ".txt") }}',
                "spacing",
                id="23",
            ),
            pytest.param(
                "{{ x | map(attribute='value') }}",
                "{{ x | map(attribute='value') }}",
                "spacing",
                id="24",
            ),
            pytest.param(
                "{{ r(a= 1,b= True,c= 0.0,d= '') }}",
                "{{ r(a=1, b=True, c=0.0, d='') }}",
                "spacing",
                id="25",
            ),
            pytest.param("{{ r(1,[]) }}", "{{ r(1, []) }}", "spacing", id="26"),
            pytest.param(
                "{{ lookup([ddd ]) }}",
                "{{ lookup([ddd]) }}",
                "spacing",
                id="27",
            ),
            pytest.param(
                "{{ [ x ] if x is string else x }}",
                "{{ [x] if x is string else x }}",
                "spacing",
                id="28",
            ),
            pytest.param(
                "{% if a|int <= 8 -%} iptables {%- else -%} iptables-nft {%- endif %}",
                "{% if a | int <= 8 -%} iptables{%- else -%} iptables-nft{%- endif %}",
                "spacing",
                id="29",
            ),
            pytest.param(
                # "- 2" -> "-2", minus does not get separated when there is no left side
                "{{ - 2 }}",
                "{{ -2 }}",
                "spacing",
                id="30",
            ),
            pytest.param(
                # "-2" -> "-2", minus does get an undesired spacing
                "{{ -2 }}",
                "{{ -2 }}",
                "spacing",
                id="31",
            ),
            pytest.param(
                # array ranges do not have space added
                "{{ foo[2:4] }}",
                "{{ foo[2:4] }}",
                "spacing",
                id="32",
            ),
            pytest.param(
                # array ranges have the extra space removed
                "{{ foo[2: 4] }}",
                "{{ foo[2:4] }}",
                "spacing",
                id="33",
            ),
            pytest.param(
                # negative array index
                "{{ foo[-1] }}",
                "{{ foo[-1] }}",
                "spacing",
                id="34",
            ),
            pytest.param(
                # negative array index, repair
                "{{ foo[- 1] }}",
                "{{ foo[-1] }}",
                "spacing",
                id="35",
            ),
            pytest.param("{{ a +~'b' }}", "{{ a + ~'b' }}", "spacing", id="36"),
            pytest.param(
                "{{ (a[: -4] *~ b) }}",
                "{{ (a[:-4] * ~b) }}",
                "spacing",
                id="37",
            ),
            pytest.param("{{ [a,~ b] }}", "{{ [a, ~b] }}", "spacing", id="38"),
            # Not supported yet due to being accepted by black:
            pytest.param("{{ item.0.user }}", "{{ item.0.user }}", "spacing", id="39"),
            # Not supported by back, while jinja allows ~ to be binary operator:
            pytest.param("{{ a ~ b }}", "{{ a ~ b }}", "spacing", id="40"),
            pytest.param(
                "--format='{{'{{'}}.Size{{'}}'}}'",
                "--format='{{ '{{' }}.Size{{ '}}' }}'",
                "spacing",
                id="41",
            ),
            pytest.param(
                "{{ list_one + {{ list_two | max }} }}",
                "{{ list_one + {{list_two | max}} }}",
                "spacing",
                id="42",
            ),
            pytest.param(
                "{{ lookup('file'   ,  '/tmp/non-existent',  errors='ignore') }}",
                "{{ lookup('file', '/tmp/non-existent', errors='ignore') }}",
                "spacing",
                id="43",
            ),
            # https://github.com/ansible/ansible-lint/pull/3057
            # since jinja 3.0.0, \r is converted to \n if the string has jinja in it
            pytest.param(
                "{{ 'foo' }}\r{{ 'bar' }}",
                "{{ 'foo' }}\n{{ 'bar' }}",
                "spacing",
                id="44",
            ),
            # if we do not have any jinja constructs, we should keep original \r
            # to match ansible behavior
            pytest.param(
                "foo\rbar",
                "foo\rbar",
                "spacing",
                id="45",
            ),
            # Windows line endings (\r\n) should be preserved, but \r alone should normalize to \n
            pytest.param(
                "Created on {{ '%Y-%m-%d %H:%M:%S %Z' | strftime }}.\r\n",
                "Created on {{ '%Y-%m-%d %H:%M:%S %Z' | strftime }}.\r\n",
                "spacing",
                id="46",
            ),
        ),
    )
    def test_jinja(text: str, expected: str, tag: str) -> None:
        """Tests our ability to spot spacing errors inside jinja2 templates."""
        rule = JinjaRule()

        reformatted, details, returned_tag = rule.check_whitespace(
            text,
            key="name",
            lintable=Lintable("playbook.yml"),
        )
        assert tag == returned_tag, details
        assert expected == reformatted

    @pytest.mark.parametrize(
        ("text", "expected", "tag"),
        (
            pytest.param(
                "1+2",
                "1 + 2",
                "spacing",
                id="0",
            ),
            pytest.param(
                "- 1",
                "-1",
                "spacing",
                id="1",
            ),
            # Ensure that we do not choke with double templating on implicit
            # and instead we remove them braces.
            pytest.param("{{ o | bool }}", "o | bool", "spacing", id="2"),
        ),
    )
    def test_jinja_implicit(text: str, expected: str, tag: str) -> None:
        """Tests our ability to spot spacing errors implicit jinja2 templates."""
        rule = JinjaRule()
        # implicit jinja2 are working only inside playbooks and tasks
        lintable = Lintable(name="playbook.yml", kind="playbook")
        reformatted, details, returned_tag = rule.check_whitespace(
            text,
            key="when",
            lintable=lintable,
        )
        assert tag == returned_tag, details
        assert expected == reformatted

    @pytest.mark.parametrize(
        ("lintable", "matches"),
        (pytest.param("examples/playbooks/vars/rule_jinja_vars.yml", 0, id="0"),),
    )
    def test_jinja_file(lintable: str, matches: int) -> None:
        """Tests our ability to process var filesspot spacing errors."""
        collection = RulesCollection()
        collection.register(JinjaRule())
        errs = Runner(lintable, rules=collection).run()
        assert len(errs) == matches
        for err in errs:
            assert isinstance(err, JinjaRule)
            assert errs[0].tag == "jinja[invalid]"
            assert errs[0].rule.id == "jinja"

    def test_jinja_invalid() -> None:
        """Tests our ability to spot spacing errors inside jinja2 templates."""
        collection = RulesCollection()
        collection.register(JinjaRule())
        success = "examples/playbooks/rule-jinja-fail.yml"
        errs = Runner(success, rules=collection).run()
        assert len(errs) == 2
        assert errs[0].tag == "jinja[spacing]"
        assert errs[0].rule.id == "jinja"
        assert errs[0].lineno in [9, 13]  # ruamel w/ clib return different numbers
        assert errs[1].tag == "jinja[invalid]"
        assert errs[1].rule.id == "jinja"
        assert errs[1].lineno in [9, 10, 13]  # 2.19 has better line identification

    def test_jinja_valid() -> None:
        """Tests our ability to parse jinja, even when variables may not be defined."""
        collection = RulesCollection()
        collection.register(JinjaRule())
        success = "examples/playbooks/rule-jinja-pass.yml"
        errs = Runner(success, rules=collection).run()
        assert len(errs) == 0

    @mock.patch.dict(os.environ, {"ANSIBLE_LINT_WRITE_TMP": "1"}, clear=True)
    def test_jinja_transform(
        config_options: Options,
        default_rules_collection: RulesCollection,
    ) -> None:
        """Test transform functionality for jinja rule."""
        playbook = Path("examples/playbooks/rule-jinja-before.yml")
        config_options.write_list = ["all"]

        config_options.lintables = [str(playbook)]
        runner_result = get_matches(
            rules=default_rules_collection,
            options=config_options,
        )
        transformer = Transformer(result=runner_result, options=config_options)
        transformer.run()

        matches = runner_result.matches
        assert len(matches) == 2

        orig_content = playbook.read_text(encoding="utf-8")
        expected_content = playbook.with_suffix(
            f".transformed{playbook.suffix}"
        ).read_text(encoding="utf-8")
        transformed_content = playbook.with_suffix(f".tmp{playbook.suffix}").read_text(
            encoding="utf-8"
        )
        assert orig_content != transformed_content
        assert expected_content == transformed_content
        playbook.with_suffix(f".tmp{playbook.suffix}").unlink()

    def test_jinja_nested_var_errors() -> None:
        """Tests our ability to handle nested var errors from jinja2 templates."""

        def _do_template(*args, **kwargs):  # type: ignore[no-untyped-def] # Templar.do_template has no type hint
            data = args[1]

            if data != "{{ 12 | random(seed=inventory_hostname) }}":
                return do_template(*args, **kwargs)

            msg = "Unexpected templating type error occurred on (foo): bar"
            raise AnsibleError(str(msg))

        do_template = Templar.do_template
        collection = RulesCollection()
        collection.register(JinjaRule())
        lintable = Lintable("examples/playbooks/jinja-nested-vars.yml")
        with mock.patch.object(Templar, "do_template", _do_template):
            results = Runner(lintable, rules=collection).run()
            assert len(results) == 0

    @pytest.mark.parametrize(
        ("nodeps", "expected_results"),
        (
            pytest.param(
                "0",
                0 if ansible_version() >= Version("2.19.0.dev0") else 1,
                id="normal",
            ),
            pytest.param("1", 0, id="nodeps"),
        ),
    )
    def test_filter_import_failure(
        nodeps: str, expected_results: int, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Tests how we process import failures from within filters."""
        monkeypatch.setenv("ANSIBLE_LINT_NODEPS", nodeps)
        collection = RulesCollection()
        collection.register(JinjaRule())
        lintable = Lintable("examples/playbooks/test_filter_with_importerror.yml")
        results = Runner(lintable, rules=collection).run()
        assert len(results) == expected_results

    def test_jinja_template_generates_ansible_tagged_str_error() -> None:
        """Test that demonstrates ansible-core generating _AnsibleTaggedStr errors and us ignoring them."""
        # Test the ignore pattern directly without full RulesCollection setup
        from ansiblelint.rules.jinja import ignored_re

        # Test _AnsibleTaggedStr error is ignored
        tagged_error_msg = 'can only concatenate list (not "_AnsibleTaggedStr") to list'
        assert ignored_re.search(tagged_error_msg), (
            f"_AnsibleTaggedStr error should be ignored: {tagged_error_msg}"
        )

        # Test similar error without _AnsibleTaggedStr is NOT ignored
        normal_error_msg = 'can only concatenate list (not "int") to list'
        assert not ignored_re.search(normal_error_msg), (
            f"Normal error should not be ignored: {normal_error_msg}"
        )

        # Test that the ignore pattern works for the specific error we're testing
        assert ignored_re.search(
            'can only concatenate str (not "_AnsibleTaggedStr") to str'
        ), "String concatenation with _AnsibleTaggedStr should also be ignored"
