from typing import Any

import pytest

from phoenix.utilities.template_formatters import (
    DotDict,
    FStringTemplateFormatter,
    ListWrapper,
    MustacheTemplateFormatter,
    ParsedVariable,
    ParsedVariables,
    TemplateFormatter,
    TemplateFormatterError,
    _extract_root_variable,
)


@pytest.mark.parametrize(
    "formatter_cls, template, variables, expected_prompt",
    (
        pytest.param(
            MustacheTemplateFormatter,
            "{{ hello }}",
            {"hello": "world"},
            "world",
            id="mustache-whitespace-both-sides",
        ),
        pytest.param(
            MustacheTemplateFormatter,
            "{{hello}}",
            {"hello": "world"},
            "world",
            id="mustache-no-whitespace",
        ),
        pytest.param(
            MustacheTemplateFormatter,
            "{{  hello}}",
            {"hello": "world"},
            "world",
            id="mustache-whitespace-left",
        ),
        pytest.param(
            MustacheTemplateFormatter,
            "{{hello }}",
            {"hello": "world"},
            "world",
            id="mustache-whitespace-right",
        ),
        pytest.param(
            MustacheTemplateFormatter,
            "{{ hello }}, {{ world }}",
            {"hello": "1", "world": "2"},
            "1, 2",
            id="mustache-multiple-variables",
        ),
        pytest.param(
            MustacheTemplateFormatter,
            "{{ hello }} + {{hello}} = {{ world }}",
            {"hello": "1", "world": "2"},
            "1 + 1 = 2",
            id="mustache-duplicate-variables",
        ),
        pytest.param(
            MustacheTemplateFormatter,
            "{{ hello }}, {{ world }}",
            {"hello": "world", "world": "hello"},
            "world, hello",
            id="mustache-replaced-value-is-variable-name",
        ),
        pytest.param(
            MustacheTemplateFormatter,
            "{{ value }}",
            {"value": [1, 2, 3]},
            "[1, 2, 3]",
            id="mustache-list-value",
        ),
        pytest.param(
            MustacheTemplateFormatter,
            "{{ value }}",
            {"value": 42},
            "42",
            id="mustache-integer-value",
        ),
        pytest.param(
            MustacheTemplateFormatter,
            "{{ value }}",
            {"value": 3.14},
            "3.14",
            id="mustache-float-value",
        ),
        pytest.param(
            MustacheTemplateFormatter,
            "{{ value }}",
            {"value": True},
            "True",
            id="mustache-boolean-true-value",
        ),
        pytest.param(
            MustacheTemplateFormatter,
            "{{ value }}",
            {"value": False},
            "False",
            id="mustache-boolean-false-value",
        ),
        pytest.param(
            MustacheTemplateFormatter,
            "{{ value }}",
            {"value": None},
            "None",
            id="mustache-none-value",
        ),
        pytest.param(
            MustacheTemplateFormatter,
            "{{ output }}",
            {"output": r'{"content": "Here\u2019s an example"}'},
            r'{"content": "Here\u2019s an example"}',
            id="mustache-value-with-unicode-escape-sequence",
        ),
        pytest.param(
            MustacheTemplateFormatter,
            "{{ output }}",
            {"output": r"line1\nline2"},
            r"line1\nline2",
            id="mustache-value-with-backslash-n",
        ),
        pytest.param(
            FStringTemplateFormatter,
            "{hello}",
            {"hello": "world"},
            "world",
            id="f-string-single-variable",
        ),
        pytest.param(
            FStringTemplateFormatter,
            "{hello}, {world}",
            {"hello": "1", "world": "2"},
            "1, 2",
            id="f-string-multiple-variables",
        ),
        pytest.param(
            FStringTemplateFormatter,
            "{hello} + {hello} = {world}",
            {"hello": "1", "world": "2"},
            "1 + 1 = 2",
            id="f-string-duplicate-variables",
        ),
        pytest.param(
            FStringTemplateFormatter,
            "{hello}, {world}",
            {"hello": "world", "world": "hello"},
            "world, hello",
            id="f-string-replaced-value-is-variable-name",
        ),
    ),
)
def test_template_formatters_produce_expected_prompt(
    formatter_cls: type[TemplateFormatter],
    template: str,
    variables: dict[str, Any],
    expected_prompt: str,
) -> None:
    formatter = formatter_cls()
    prompt = formatter.format(template, **variables)
    assert prompt == expected_prompt


@pytest.mark.parametrize(
    "formatter_cls, template",
    (
        pytest.param(
            MustacheTemplateFormatter,
            "{{ hello }}",
            id="mustache-missing-template-variables",
        ),
        pytest.param(
            FStringTemplateFormatter,
            "{hello}",
            id="f-string-missing-template-variables",
        ),
    ),
)
def test_template_formatters_raise_expected_error_on_missing_variables(
    formatter_cls: type[TemplateFormatter], template: str
) -> None:
    formatter = formatter_cls()
    with pytest.raises(TemplateFormatterError, match=r"Missing template variable\(s\): hello"):
        formatter.format(template)


class TestFStringFormatStringInjection:
    """Regression tests for the f-string format-string injection vulnerability."""

    @pytest.mark.parametrize(
        "template",
        (
            "{u.__class__}",
            "{u.__class__.__bases__[0].__init__.__globals__}",
            "{u.__init__.__globals__[os].environ}",
            "{u[x].__class__}",
        ),
    )
    def test_dunder_attribute_access_is_blocked(self, template: str) -> None:
        formatter = FStringTemplateFormatter()
        with pytest.raises(TemplateFormatterError, match=r"not permitted in templates"):
            formatter.format(template, u={"x": 1})

    def test_legitimate_attribute_and_index_access_still_works(self) -> None:
        formatter = FStringTemplateFormatter()
        assert formatter.format("{user.name}", user={"name": "Alice"}) == "Alice"
        assert formatter.format("{items[0].value}", items=[{"value": "first"}]) == "first"

    @pytest.mark.parametrize(
        "template",
        (
            "{{u.__class__}}",
            "{{u.__class__.__init__.__globals__}}",
            "{{u.__init__.__globals__}}",
            "{{u.upper}}",
            "{{#u}}{{__class__}}{{/u}}",
        ),
    )
    @pytest.mark.parametrize("value", ({"x": 1}, "hello", ["a", "b"]))
    def test_mustache_does_not_traverse_dunder_or_python_attributes(
        self, template: str, value: Any
    ) -> None:
        # pystache only does dict-style lookups and refuses getattr on builtin
        # types, so these resolve to "" rather than leaking internals.
        formatter = MustacheTemplateFormatter()
        assert formatter.format(template, u=value) == ""


class TestMustacheSections:
    """Tests for full Mustache syntax support including sections."""

    def test_mustache_section_with_list(self) -> None:
        """Test that Mustache sections iterate over lists."""
        formatter = MustacheTemplateFormatter()
        template = "{{#items}}- {{name}}\n{{/items}}"
        variables = {"items": [{"name": "foo"}, {"name": "bar"}]}
        result = formatter.format(template, **variables)
        assert result == "- foo\n- bar\n"

    def test_mustache_inverted_section_when_empty(self) -> None:
        """Test that inverted sections render when list is empty."""
        formatter = MustacheTemplateFormatter()
        template = "{{#items}}{{name}}{{/items}}{{^items}}No items{{/items}}"
        variables: dict[str, Any] = {"items": []}
        result = formatter.format(template, **variables)
        assert result == "No items"

    def test_mustache_inverted_section_when_not_empty(self) -> None:
        """Test that inverted sections don't render when list has items."""
        formatter = MustacheTemplateFormatter()
        template = "{{#items}}{{name}}{{/items}}{{^items}}No items{{/items}}"
        variables = {"items": [{"name": "foo"}]}
        result = formatter.format(template, **variables)
        assert result == "foo"

    def test_mustache_nested_properties(self) -> None:
        """Test that nested object properties are accessible."""
        formatter = MustacheTemplateFormatter()
        template = "{{#tool}}{{function.name}}: {{function.description}}{{/tool}}"
        variables = {
            "tool": {"function": {"name": "get_weather", "description": "Get weather info"}}
        }
        result = formatter.format(template, **variables)
        assert result == "get_weather: Get weather info"

    def test_mustache_tool_formatter_pattern(self) -> None:
        """Test the pattern used for tool formatters."""
        formatter = MustacheTemplateFormatter()
        template = """{{#available_tools}}
- {{function.name}}: {{function.description}}
{{/available_tools}}
{{^available_tools}}
No tools available.
{{/available_tools}}"""
        variables = {
            "available_tools": [
                {"function": {"name": "search", "description": "Search the web"}},
                {"function": {"name": "calculate", "description": "Do math"}},
            ]
        }
        result = formatter.format(template, **variables)
        assert "- search: Search the web" in result
        assert "- calculate: Do math" in result
        assert "No tools available" not in result

    def test_mustache_tool_formatter_empty_tools(self) -> None:
        """Test tool formatter pattern with no tools."""
        formatter = MustacheTemplateFormatter()
        template = """{{#available_tools}}
- {{function.name}}: {{function.description}}
{{/available_tools}}
{{^available_tools}}
No tools available.
{{/available_tools}}"""
        variables: dict[str, Any] = {"available_tools": []}
        result = formatter.format(template, **variables)
        assert "No tools available" in result

    def test_mustache_tool_calls_from_messages_pattern(self) -> None:
        """Test extracting tool calls from OpenAI-style messages."""
        formatter = MustacheTemplateFormatter()
        template = """{{#messages}}
{{role}}: {{content}}
{{#tool_calls}}
- {{function.name}}({{function.arguments}})
{{/tool_calls}}
{{^tool_calls}}
No tools called.
{{/tool_calls}}
{{/messages}}"""
        variables = {
            "messages": [
                {
                    "role": "assistant",
                    "content": "I'll help you with that.",
                    "tool_calls": [
                        {"function": {"name": "get_weather", "arguments": '{"city": "NYC"}'}},
                        {"function": {"name": "get_time", "arguments": "{}"}},
                    ],
                }
            ]
        }
        result = formatter.format(template, **variables)
        assert "assistant: I'll help you with that." in result
        assert '- get_weather({"city": "NYC"})' in result
        assert "- get_time({})" in result
        assert "No tools called" not in result

    def test_mustache_tool_calls_multiple_messages(self) -> None:
        """Test extracting tool calls from multiple messages."""
        formatter = MustacheTemplateFormatter()
        template = """{{#messages}}
{{role}}: {{content}}
{{#tool_calls}}
- {{function.name}}({{function.arguments}})
{{/tool_calls}}
{{/messages}}"""
        variables = {
            "messages": [
                {
                    "role": "assistant",
                    "content": "First response",
                    "tool_calls": [{"function": {"name": "tool1", "arguments": "{}"}}],
                },
                {
                    "role": "assistant",
                    "content": "Second response",
                    "tool_calls": [{"function": {"name": "tool2", "arguments": "{}"}}],
                },
            ]
        }
        result = formatter.format(template, **variables)
        assert "First response" in result
        assert "Second response" in result
        assert "- tool1({})" in result
        assert "- tool2({})" in result

    def test_mustache_message_without_tool_calls(self) -> None:
        """Test message with no tool calls."""
        formatter = MustacheTemplateFormatter()
        template = """{{#messages}}
{{role}}: {{content}}
{{#tool_calls}}
- {{function.name}}({{function.arguments}})
{{/tool_calls}}
{{^tool_calls}}
No tools called.
{{/tool_calls}}
{{/messages}}"""
        variables = {"messages": [{"role": "assistant", "content": "Just a text response"}]}
        result = formatter.format(template, **variables)
        assert "assistant: Just a text response" in result
        assert "No tools called" in result

    def test_mustache_parse_only_extracts_top_level_variables(self) -> None:
        """Test that parse() only returns top-level variables, not nested ones."""
        formatter = MustacheTemplateFormatter()
        template = "{{#items}}{{name}}{{/items}}{{simple}}"
        variables = formatter.parse(template)
        # Should include top-level keys: items, simple
        # Should NOT include nested key: name (it's inside the items section)
        assert "items" in variables
        assert "simple" in variables
        assert "name" not in variables


class TestMustacheParseEdgeCases:
    """Edge case tests for MustacheTemplateFormatter.parse()."""

    def test_empty_template(self) -> None:
        """Test that empty template returns no variables."""
        formatter = MustacheTemplateFormatter()
        assert formatter.parse("") == set()

    def test_template_with_no_variables(self) -> None:
        """Test template with only plain text."""
        formatter = MustacheTemplateFormatter()
        assert formatter.parse("Hello, world!") == set()

    def test_template_with_only_sections(self) -> None:
        """Test template with only sections (no plain variables)."""
        formatter = MustacheTemplateFormatter()
        template = "{{#items}}{{name}}{{/items}}"
        variables = formatter.parse(template)
        assert variables == {"items"}

    def test_inverted_section_at_top_level(self) -> None:
        """Test that inverted sections (^) are extracted at top level."""
        formatter = MustacheTemplateFormatter()
        template = "{{^items}}No items{{/items}}"
        variables = formatter.parse(template)
        assert "items" in variables

    def test_deeply_nested_sections(self) -> None:
        """Test deeply nested sections only extract top level."""
        formatter = MustacheTemplateFormatter()
        template = "{{#a}}{{#b}}{{#c}}{{deep}}{{/c}}{{/b}}{{/a}}{{top}}"
        variables = formatter.parse(template)
        assert variables == {"a", "top"}
        assert "b" not in variables
        assert "c" not in variables
        assert "deep" not in variables

    def test_multiple_escaped_sequences(self) -> None:
        """Test escaped sequences are treated as tags."""
        formatter = MustacheTemplateFormatter()
        template = r"\{{a}} and \{{b}} but {{real}}"
        variables = formatter.parse(template)
        assert variables == {"a", "b", "real"}

    def test_whitespace_variations(self) -> None:
        """Test various whitespace patterns in variables."""
        formatter = MustacheTemplateFormatter()
        template = "{{  a  }} {{   b}} {{c   }} {{ d }}"
        variables = formatter.parse(template)
        assert variables == {"a", "b", "c", "d"}

    def test_nested_property_extracts_root_only(self) -> None:
        """Test that nested properties (dot notation) extract only root variable.

        Mustache uses dot notation to traverse nested objects (e.g., user.name means
        context["user"]["name"]). For validation, we only need to check that the
        root variable exists.
        """
        formatter = MustacheTemplateFormatter()
        template = "{{user.name}} and {{user.email}}"
        variables = formatter.parse(template)
        # Should extract only "user", not the full dotted paths
        assert variables == {"user"}
        assert "user.name" not in variables
        assert "user.email" not in variables

    def test_section_with_dotted_path_extracts_root_only(self) -> None:
        """Test that sections with dotted paths extract only root variable.

        This is the pattern used by tool formatters like {{#output.available_tools}}.
        """
        formatter = MustacheTemplateFormatter()
        template = """{{#output.available_tools}}
- {{function.name}}: {{function.description}}
{{/output.available_tools}}
{{^output.available_tools}}
No tools available.
{{/output.available_tools}}"""
        variables = formatter.parse(template)
        # Should extract only "output", not "output.available_tools"
        assert variables == {"output"}
        assert "output.available_tools" not in variables

    def test_mixed_sections_and_variables(self) -> None:
        """Test complex template with mixed sections and variables."""
        formatter = MustacheTemplateFormatter()
        template = """
        {{header}}
        {{#items}}
        - {{name}}: {{value}}
        {{/items}}
        {{^items}}
        No items.
        {{/items}}
        {{footer}}
        """
        variables = formatter.parse(template)
        assert variables == {"header", "items", "footer"}
        assert "name" not in variables
        assert "value" not in variables

    def test_triple_braces_handled(self) -> None:
        """Test that triple braces (unescaped HTML in Mustache) extract variable."""
        formatter = MustacheTemplateFormatter()
        template = "{{{unescaped}}}"
        variables = formatter.parse(template)
        assert "unescaped" in variables

    def test_ampersand_unescaped_handled(self) -> None:
        """Test that ampersand tags extract variable."""
        formatter = MustacheTemplateFormatter()
        template = "{{& unescaped}}"
        variables = formatter.parse(template)
        assert variables == {"unescaped"}

    def test_adjacent_sections(self) -> None:
        """Test adjacent sections both get extracted."""
        formatter = MustacheTemplateFormatter()
        template = "{{#a}}content{{/a}}{{#b}}more{{/b}}"
        variables = formatter.parse(template)
        assert variables == {"a", "b"}

    def test_unclosed_section_raises(self) -> None:
        """Test that unclosed sections raise a parsing error."""
        formatter = MustacheTemplateFormatter()
        # Malformed template - unclosed section
        template = "{{#items}}{{name}}"
        with pytest.raises(TemplateFormatterError):
            formatter.parse(template)

    def test_unmatched_closing_tag(self) -> None:
        """Test template with unmatched closing tag raises an error."""
        formatter = MustacheTemplateFormatter()
        # Malformed template - closing tag without opener
        template = "{{/orphan}}{{valid}}"
        with pytest.raises(TemplateFormatterError):
            formatter.parse(template)

    def test_comment_syntax_ignored(self) -> None:
        """Test that Mustache comments ({{! comment }}) don't add variables."""
        formatter = MustacheTemplateFormatter()
        template = "{{! This is a comment }}{{real}}"
        variables = formatter.parse(template)
        assert variables == {"real"}


class TestMustacheParseWithTypes:
    """Tests for MustacheTemplateFormatter.parse_with_types()."""

    def test_simple_variables_are_string_type(self) -> None:
        """Test that simple variables ({{name}}) are typed as string."""
        formatter = MustacheTemplateFormatter()
        template = "Hello {{name}}, your score is {{score}}"
        parsed = formatter.parse_with_types(template)

        assert parsed.names() == {"name", "score"}
        assert parsed.string_variables() == {"name", "score"}
        assert parsed.section_variables() == set()

    def test_section_variables_are_section_type(self) -> None:
        """Test that section variables ({{#items}}) are typed as section."""
        formatter = MustacheTemplateFormatter()
        template = "{{#items}}- {{name}}{{/items}}"
        parsed = formatter.parse_with_types(template)

        assert parsed.names() == {"items"}
        assert parsed.section_variables() == {"items"}
        assert parsed.string_variables() == set()

    def test_inverted_section_variables_are_section_type(self) -> None:
        """Test that inverted section variables ({{^items}}) are typed as section."""
        formatter = MustacheTemplateFormatter()
        template = "{{^items}}No items{{/items}}"
        parsed = formatter.parse_with_types(template)

        assert parsed.names() == {"items"}
        assert parsed.section_variables() == {"items"}
        assert parsed.string_variables() == set()

    def test_mixed_template_has_correct_types(self) -> None:
        """Test template with both sections and simple variables."""
        formatter = MustacheTemplateFormatter()
        template = """
        Header: {{title}}
        {{#items}}
        - {{name}}: {{value}}
        {{/items}}
        Footer: {{footer}}
        """
        parsed = formatter.parse_with_types(template)

        assert parsed.names() == {"title", "items", "footer"}
        assert parsed.section_variables() == {"items"}
        assert parsed.string_variables() == {"title", "footer"}

    def test_nested_sections_only_extract_top_level(self) -> None:
        """Test that nested sections are not extracted."""
        formatter = MustacheTemplateFormatter()
        template = "{{#outer}}{{#inner}}{{deep}}{{/inner}}{{/outer}}{{top}}"
        parsed = formatter.parse_with_types(template)

        assert parsed.names() == {"outer", "top"}
        assert parsed.section_variables() == {"outer"}
        assert parsed.string_variables() == {"top"}
        assert "inner" not in parsed.names()
        assert "deep" not in parsed.names()

    def test_empty_template(self) -> None:
        """Test that empty template returns empty result."""
        formatter = MustacheTemplateFormatter()
        parsed = formatter.parse_with_types("")

        assert parsed.names() == set()
        assert parsed.section_variables() == set()
        assert parsed.string_variables() == set()

    def test_escaped_sequences_ignored(self) -> None:
        """Test that escaped sequences (\\{{) are treated as tags."""
        formatter = MustacheTemplateFormatter()
        template = r"\{{escaped}} and {{real}}"
        parsed = formatter.parse_with_types(template)

        assert parsed.names() == {"escaped", "real"}
        assert parsed.string_variables() == {"escaped", "real"}

    def test_section_and_inverted_section_same_variable(self) -> None:
        """Test variable used as both section and inverted section."""
        formatter = MustacheTemplateFormatter()
        template = "{{#items}}Has items{{/items}}{{^items}}No items{{/items}}"
        parsed = formatter.parse_with_types(template)

        # Should only appear once in section_variables
        assert parsed.names() == {"items"}
        assert parsed.section_variables() == {"items"}
        assert parsed.string_variables() == set()

    def test_adjacent_sections_both_extracted(self) -> None:
        """Test that adjacent sections are both extracted."""
        formatter = MustacheTemplateFormatter()
        template = "{{#a}}content{{/a}}{{#b}}more{{/b}}"
        parsed = formatter.parse_with_types(template)

        assert parsed.names() == {"a", "b"}
        assert parsed.section_variables() == {"a", "b"}

    def test_names_method_returns_all_variables(self) -> None:
        """Test that names() returns all variable names regardless of type."""
        formatter = MustacheTemplateFormatter()
        template = "{{simple}}{{#section}}nested{{/section}}"
        parsed = formatter.parse_with_types(template)

        all_names = parsed.names()
        assert all_names == {"simple", "section"}
        assert all_names == parsed.section_variables() | parsed.string_variables()

    def test_unescaped_triple_brace_is_string_type(self) -> None:
        """Test that triple-brace unescaped variables ({{{name}}}) are typed as string."""
        formatter = MustacheTemplateFormatter()
        template = "{{{unescaped}}}"
        parsed = formatter.parse_with_types(template)

        assert parsed.names() == {"unescaped"}
        assert parsed.string_variables() == {"unescaped"}
        assert parsed.section_variables() == set()

    def test_unescaped_ampersand_is_string_type(self) -> None:
        """Test that ampersand unescaped variables ({{& name}}) are typed as string."""
        formatter = MustacheTemplateFormatter()
        template = "{{& unescaped}}"
        parsed = formatter.parse_with_types(template)

        assert parsed.names() == {"unescaped"}
        assert parsed.string_variables() == {"unescaped"}
        assert parsed.section_variables() == set()

    def test_mixed_escaped_and_unescaped_all_string_type(self) -> None:
        """Test that all variable syntaxes (escaped and unescaped) are typed as string."""
        formatter = MustacheTemplateFormatter()
        template = "{{escaped}} {{{triple}}} {{& ampersand}}"
        parsed = formatter.parse_with_types(template)

        assert parsed.names() == {"escaped", "triple", "ampersand"}
        assert parsed.string_variables() == {"escaped", "triple", "ampersand"}
        assert parsed.section_variables() == set()


class TestFStringParseWithTypes:
    """Tests for FStringTemplateFormatter.parse_with_types() (default implementation)."""

    def test_all_variables_are_string_type(self) -> None:
        """Test that FString formatter treats all variables as string (default)."""
        formatter = FStringTemplateFormatter()
        template = "Hello {name}, your score is {score}"
        parsed = formatter.parse_with_types(template)

        assert parsed.names() == {"name", "score"}
        assert parsed.string_variables() == {"name", "score"}
        assert parsed.section_variables() == set()


class TestParsedVariablesDataclass:
    """Tests for the ParsedVariables dataclass."""

    def test_parsed_variables_frozenset(self) -> None:
        """Test that ParsedVariables uses frozenset for immutability."""
        var1 = ParsedVariable(name="test", variable_type="string")
        var2 = ParsedVariable(name="items", variable_type="section")
        parsed = ParsedVariables(variables=frozenset([var1, var2]))

        assert len(parsed.variables) == 2
        assert var1 in parsed.variables
        assert var2 in parsed.variables

    def test_parsed_variable_frozen(self) -> None:
        """Test that ParsedVariable is frozen (immutable)."""
        var = ParsedVariable(name="test", variable_type="string")
        with pytest.raises(AttributeError):
            var.name = "changed"  # type: ignore[misc]


class TestMustachePathVariables:
    """Tests for Mustache path variable support (e.g., {{reference.label}})."""

    def test_simple_path_variable_with_dict(self) -> None:
        """Test that path variables work with nested dicts."""
        formatter = MustacheTemplateFormatter()
        template = "{{reference.label}}"
        variables = {"reference": {"label": "correct"}}
        result = formatter.format(template, **variables)
        assert result == "correct"

    def test_multi_level_path_variable(self) -> None:
        """Test deeply nested path variables."""
        formatter = MustacheTemplateFormatter()
        template = "{{user.address.city}}"
        variables = {"user": {"address": {"city": "New York"}}}
        result = formatter.format(template, **variables)
        assert result == "New York"

    def test_multiple_path_variables_same_root(self) -> None:
        """Test multiple path variables with the same root object."""
        formatter = MustacheTemplateFormatter()
        template = "{{user.name}} lives in {{user.city}}"
        variables = {"user": {"name": "Alice", "city": "Boston"}}
        result = formatter.format(template, **variables)
        assert result == "Alice lives in Boston"

    def test_path_variable_with_other_variables(self) -> None:
        """Test path variables mixed with simple variables."""
        formatter = MustacheTemplateFormatter()
        template = "Hello {{name}}, your label is {{reference.label}}"
        variables = {"name": "Bob", "reference": {"label": "A"}}
        result = formatter.format(template, **variables)
        assert result == "Hello Bob, your label is A"

    def test_path_variable_validation_passes_with_root(self) -> None:
        """Test that validation passes when root variable is provided."""
        formatter = MustacheTemplateFormatter()
        template = "{{reference.label}}"
        # Should not raise - 'reference' is provided
        result = formatter.format(template, reference={"label": "value"})
        assert result == "value"

    def test_path_variable_validation_fails_without_root(self) -> None:
        """Test that validation fails when root variable is missing."""
        formatter = MustacheTemplateFormatter()
        template = "{{reference.label}}"
        with pytest.raises(
            TemplateFormatterError, match=r"Missing template variable\(s\): reference"
        ):
            formatter.format(template)

    def test_full_context_pattern(self) -> None:
        """Test the full_context pattern used in playground."""
        formatter = MustacheTemplateFormatter()
        template = "Input: {{input.question}}\nExpected: {{reference.label}}"
        full_context = {
            "input": {"question": "What is 2+2?"},
            "reference": {"label": "4"},
            "metadata": {},
        }
        result = formatter.format(template, **full_context)
        assert result == "Input: What is 2+2?\nExpected: 4"

    def test_path_variable_inside_section(self) -> None:
        """Test path variables inside sections."""
        formatter = MustacheTemplateFormatter()
        template = "{{#items}}{{data.value}}{{/items}}"
        variables = {"items": [{"data": {"value": "a"}}, {"data": {"value": "b"}}]}
        result = formatter.format(template, **variables)
        assert result == "ab"


class TestExtractRootVariable:
    """Tests for the _extract_root_variable helper function."""

    def test_simple_variable(self) -> None:
        """Test extracting root from simple variable name."""
        assert _extract_root_variable("name") == "name"

    def test_single_dot_path(self) -> None:
        """Test extracting root from single-level path."""
        assert _extract_root_variable("reference.label") == "reference"

    def test_multi_level_path(self) -> None:
        """Test extracting root from multi-level path."""
        assert _extract_root_variable("user.address.city") == "user"

    def test_bracket_notation(self) -> None:
        """Test extracting root from bracket notation."""
        assert _extract_root_variable("reference[label]") == "reference"

    def test_numeric_index(self) -> None:
        """Test extracting root from numeric index."""
        assert _extract_root_variable("items[0]") == "items"

    def test_mixed_notation(self) -> None:
        """Test extracting root from mixed dot and bracket notation."""
        assert _extract_root_variable("user.addresses[0].city") == "user"

    def test_bracket_then_dot(self) -> None:
        """Test extracting root from bracket followed by dot notation."""
        assert _extract_root_variable("items[0].name") == "items"


class TestFStringPathVariables:
    """Tests for F-string path variable support with dict wrapping."""

    def test_simple_path_variable_with_dict(self) -> None:
        """Test that path variables work with nested dicts."""
        formatter = FStringTemplateFormatter()
        template = "{reference.label}"
        variables = {"reference": {"label": "correct"}}
        result = formatter.format(template, **variables)
        assert result == "correct"

    def test_multi_level_path_variable(self) -> None:
        """Test deeply nested path variables."""
        formatter = FStringTemplateFormatter()
        template = "{user.address.city}"
        variables = {"user": {"address": {"city": "New York"}}}
        result = formatter.format(template, **variables)
        assert result == "New York"

    def test_multiple_path_variables_same_root(self) -> None:
        """Test multiple path variables with the same root object."""
        formatter = FStringTemplateFormatter()
        template = "{user.name} lives in {user.city}"
        variables = {"user": {"name": "Alice", "city": "Boston"}}
        result = formatter.format(template, **variables)
        assert result == "Alice lives in Boston"

    def test_path_variable_with_other_variables(self) -> None:
        """Test path variables mixed with simple variables."""
        formatter = FStringTemplateFormatter()
        template = "Hello {name}, your label is {reference.label}"
        variables = {"name": "Bob", "reference": {"label": "A"}}
        result = formatter.format(template, **variables)
        assert result == "Hello Bob, your label is A"

    def test_path_variable_validation_passes_with_root(self) -> None:
        """Test that validation passes when root variable is provided."""
        formatter = FStringTemplateFormatter()
        template = "{reference.label}"
        # Should not raise - 'reference' is provided
        result = formatter.format(template, reference={"label": "value"})
        assert result == "value"

    def test_path_variable_validation_fails_without_root(self) -> None:
        """Test that validation fails when root variable is missing."""
        formatter = FStringTemplateFormatter()
        template = "{reference.label}"
        with pytest.raises(
            TemplateFormatterError, match=r"Missing template variable\(s\): reference"
        ):
            formatter.format(template)

    def test_bracket_notation_with_dict(self) -> None:
        """Test bracket notation for dict access."""
        formatter = FStringTemplateFormatter()
        template = "{reference[label]}"
        variables = {"reference": {"label": "bracket_value"}}
        result = formatter.format(template, **variables)
        assert result == "bracket_value"

    def test_numeric_index_with_list(self) -> None:
        """Test numeric index access with lists."""
        formatter = FStringTemplateFormatter()
        template = "{items[0]}"
        variables = {"items": ["first", "second"]}
        result = formatter.format(template, **variables)
        assert result == "first"

    def test_mixed_list_and_dict_access(self) -> None:
        """Test accessing dict inside list."""
        formatter = FStringTemplateFormatter()
        template = "{items[0].name}"
        variables = {"items": [{"name": "Alice"}, {"name": "Bob"}]}
        result = formatter.format(template, **variables)
        assert result == "Alice"

    def test_full_context_pattern(self) -> None:
        """Test the full_context pattern used in playground."""
        formatter = FStringTemplateFormatter()
        template = "Input: {input.question}\nExpected: {reference.label}"
        full_context = {
            "input": {"question": "What is 2+2?"},
            "reference": {"label": "4"},
            "metadata": {},
        }
        result = formatter.format(template, **full_context)
        assert result == "Input: What is 2+2?\nExpected: 4"

    def test_non_dict_values_unchanged(self) -> None:
        """Test that non-dict values pass through unchanged."""
        formatter = FStringTemplateFormatter()
        template = "{name} is {age} years old"
        variables = {"name": "Alice", "age": 30}
        result = formatter.format(template, **variables)
        assert result == "Alice is 30 years old"

    def test_list_serializes_to_json_not_wrapper(self) -> None:
        """Test that lists serialize to JSON, not wrapper class name."""
        formatter = FStringTemplateFormatter()
        template = "{input.messages}"
        variables = {"input": {"messages": [{"role": "user", "content": "Hello"}]}}
        result = formatter.format(template, **variables)
        # Should be JSON, not "ListWrapper([...])"
        assert "ListWrapper" not in result
        assert '"role"' in result
        assert '"user"' in result
        assert '"content"' in result
        assert '"Hello"' in result

    def test_dict_serializes_to_json_not_wrapper(self) -> None:
        """Test that dicts serialize to JSON, not wrapper class name."""
        formatter = FStringTemplateFormatter()
        template = "{user}"
        variables = {"user": {"name": "Alice", "age": 30}}
        result = formatter.format(template, **variables)
        # Should be JSON, not "DotDict(...)"
        assert "DotDict" not in result
        assert '"name"' in result
        assert '"Alice"' in result

    def test_string_value_not_quoted(self) -> None:
        """Test that string values are not wrapped in quotes."""
        formatter = FStringTemplateFormatter()
        template = "{user.name}"
        variables = {"user": {"name": "Alice"}}
        result = formatter.format(template, **variables)
        # String should be plain, not JSON quoted
        assert result == "Alice"
        assert '"Alice"' not in result

    def test_deeply_nested_list_serializes_correctly(self) -> None:
        """Test deeply nested structure serializes correctly."""
        formatter = FStringTemplateFormatter()
        template = "{input.input.input.messages}"
        variables = {
            "input": {"input": {"input": {"messages": [{"role": "user", "content": "Hello"}]}}}
        }
        result = formatter.format(template, **variables)
        # Should be JSON array
        assert result.startswith("[")
        assert result.endswith("]")
        assert "ListWrapper" not in result
        assert '"role"' in result


class TestDotDict:
    """Tests for the DotDict class."""

    def test_attribute_access(self) -> None:
        """Test attribute access on DotDict."""
        dd = DotDict.from_dict({"name": "Alice"})
        assert dd.name == "Alice"

    def test_nested_attribute_access(self) -> None:
        """Test nested attribute access returns DotDict."""
        dd = DotDict.from_dict({"user": {"name": "Alice"}})
        assert dd.user.name == "Alice"

    def test_key_access(self) -> None:
        """Test key access on DotDict."""
        dd = DotDict.from_dict({"name": "Alice"})
        assert dd["name"] == "Alice"

    def test_nested_key_access(self) -> None:
        """Test nested key access."""
        dd = DotDict.from_dict({"user": {"name": "Alice"}})
        assert dd["user"]["name"] == "Alice"

    def test_mixed_access(self) -> None:
        """Test mixed attribute and key access."""
        dd = DotDict.from_dict({"user": {"name": "Alice"}})
        assert dd.user["name"] == "Alice"
        assert dd["user"].name == "Alice"

    def test_missing_key_raises_attribute_error(self) -> None:
        """Test that missing key raises AttributeError for attribute access."""
        dd = DotDict.from_dict({"name": "Alice"})
        with pytest.raises(AttributeError):
            _ = dd.missing

    def test_missing_key_raises_key_error_for_bracket(self) -> None:
        """Test that missing key raises KeyError for bracket access."""
        dd = DotDict.from_dict({"name": "Alice"})
        with pytest.raises(KeyError):
            _ = dd["missing"]

    def test_repr(self) -> None:
        """Test string representation."""
        dd = DotDict.from_dict({"name": "Alice"})
        assert "DotDict" in repr(dd)
        assert "name" in repr(dd)

    def test_str_serializes_to_json(self) -> None:
        """Test that str() serializes the dict to JSON."""
        dd = DotDict.from_dict({"name": "Alice", "age": 30})
        result = str(dd)
        assert '"name"' in result
        assert '"Alice"' in result
        assert '"age"' in result
        assert "30" in result
        # Should not contain wrapper class name
        assert "DotDict" not in result

    def test_str_nested_dict(self) -> None:
        """Test that str() works with nested dicts."""
        dd = DotDict.from_dict({"user": {"name": "Alice"}})
        result = str(dd)
        assert '"user"' in result
        assert '"name"' in result
        assert '"Alice"' in result


class TestListWrapper:
    """Tests for the ListWrapper class."""

    def test_index_access(self) -> None:
        """Test index access on wrapped list."""
        wrapper = ListWrapper(["a", "b", "c"])
        assert wrapper[0] == "a"
        assert wrapper[1] == "b"

    def test_nested_dict_in_list(self) -> None:
        """Test dict inside list is wrapped."""
        wrapper = ListWrapper([{"name": "Alice"}, {"name": "Bob"}])
        assert wrapper[0].name == "Alice"
        assert wrapper[1].name == "Bob"

    def test_nested_list_in_list(self) -> None:
        """Test list inside list is wrapped."""
        wrapper = ListWrapper([[1, 2], [3, 4]])
        assert wrapper[0][0] == 1
        assert wrapper[1][1] == 4

    def test_repr(self) -> None:
        """Test string representation."""
        wrapper = ListWrapper([1, 2, 3])
        assert "ListWrapper" in repr(wrapper)

    def test_str_serializes_to_json(self) -> None:
        """Test that str() serializes the list to JSON."""
        wrapper = ListWrapper([1, 2, 3])
        result = str(wrapper)
        assert result == "[1, 2, 3]"
        # Should not contain wrapper class name
        assert "ListWrapper" not in result

    def test_str_nested_dicts(self) -> None:
        """Test that str() works with nested dicts in list."""
        wrapper = ListWrapper([{"role": "user", "content": "Hello"}])
        result = str(wrapper)
        assert '"role"' in result
        assert '"user"' in result
        assert '"content"' in result
        assert '"Hello"' in result
        assert "ListWrapper" not in result
