"""Unit tests for the Dotprompt class.

Dotprompt extends Handlebars templates for use with Gen AI prompts.

The tests cover:

1. Initialization with default and custom options.
2. Registration of helpers and partials.
3. Definition of helpers, partials, tools, and schemas.
4. Prompt parsing functionality.
5. Method chaining interface.
"""

import json
import re
import unittest
from collections.abc import Generator
from textwrap import dedent
from typing import Any
from unittest.mock import Mock, patch

import pytest
from handlebarrz import HelperFn, HelperOptions

from dotpromptz.dotprompt import (
    Dotprompt,
    TemplateMissingVariableError,
    _extract_template_variables,
    _identify_partials,
    _remove_undefined_fields,
)
from dotpromptz.typing import (
    DataArgument,
    ModelConfigT,
    ParsedPrompt,
    PromptMetadata,
    ToolDefinition,
)


@pytest.fixture
def mock_handlebars() -> Generator[Mock, None, None]:
    """Create a mock Handlebars instance."""
    with patch('dotpromptz.dotprompt.Handlebars') as mock_handlebars_class:
        mock_instance = Mock()
        mock_handlebars_class.return_value = mock_instance
        yield mock_instance


def test_init_default(mock_handlebars: Mock) -> None:
    """Test initializing Dotprompt with default options."""
    dotprompt = Dotprompt()

    assert dotprompt._handlebars == mock_handlebars
    assert dotprompt._known_helpers == {
        'history': True,
        'ifEquals': True,
        'json': True,
        'media': True,
        'role': True,
        'section': True,
        'unlessEquals': True,
    }
    assert dotprompt._default_model is None
    assert dotprompt._model_configs == {}
    assert dotprompt._tools == {}
    assert dotprompt._tool_resolver is None
    assert dotprompt._schemas == {}
    assert dotprompt._schema_resolver is None
    assert dotprompt._partial_resolver is None


def test_init_with_options(mock_handlebars: Mock) -> None:
    """Test initializing Dotprompt with custom options."""

    def helper_fn(params: list[Any], options: HelperOptions) -> str:
        """Test helper."""
        return 'test_helper'

    helpers: dict[str, HelperFn] = {'helper1': helper_fn}
    partials: dict[str, str] = {'partial1': 'partial template'}

    _ = Dotprompt(
        helpers=helpers,
        partials=partials,
    )

    mock_handlebars.register_helper.assert_called_with('helper1', helpers['helper1'])
    mock_handlebars.register_partial.assert_called_with('partial1', partials['partial1'])


def test_define_helper(mock_handlebars: Mock) -> None:
    """Test defining a helper function."""

    # This should match the signature of HelperFn.
    def helper_fn(params: list[Any], options: HelperOptions) -> str:
        """Test helper."""
        return 'test_helper'

    dotprompt = Dotprompt()
    mock_handlebars.register_helper.reset_mock()
    result = dotprompt.define_helper('test_helper', helper_fn)

    mock_handlebars.register_helper.assert_called_once_with('test_helper', helper_fn)
    assert dotprompt._known_helpers.get('test_helper') is True

    # Ensure chaining works.
    assert result == dotprompt


def test_define_partial(mock_handlebars: Mock) -> None:
    """Test defining a partial template."""
    dotprompt = Dotprompt()
    mock_handlebars.register_partial.reset_mock()

    result = dotprompt.define_partial('test_partial', 'partial content')

    mock_handlebars.register_partial.assert_called_once_with('test_partial', 'partial content')

    # Ensure chaining works.
    assert result == dotprompt


def test_define_tool(mock_handlebars: Mock) -> None:
    """Test defining a tool."""
    dotprompt = Dotprompt()
    tool_def = ToolDefinition(
        name='test_tool',
        description='A test tool',
        inputSchema={'type': 'object'},
    )

    result = dotprompt.define_tool(tool_def)

    assert dotprompt._tools['test_tool'] == tool_def

    # Ensure chaining works.
    assert result == dotprompt


class TestCompileRender(unittest.TestCase):
    def test_compile_render_mock(self) -> None:
        """Test that handlebarrz compile produces a working render function.

        The handlebarrz.compile() method returns a function that takes:
        - context: A dict with the template variables
        - options: RuntimeOptions with a 'data' key for @ variables
        """
        dotprompt = Dotprompt()
        template_source = 'hello {{name}} ({{@state.name}}, {{@auth.email}})'

        render_fn = dotprompt._handlebars.compile(template_source)

        # Call with positional args as the compiled function expects
        # context dict for regular variables, RuntimeOptions for @ variables
        result = render_fn(
            {'name': 'foo'},  # context - accessed as {{name}}
            {'data': {'state': {'name': 'bar'}, 'auth': {'email': 'a@b.c'}}},  # RuntimeOptions - accessed as {{@...}}
        )

        assert result == 'hello foo (bar, a@b.c)'


@patch('dotpromptz.dotprompt.parse_document')
def test_parse(mock_parse_document: Mock, mock_handlebars: Mock) -> None:
    """Test parsing a prompt."""
    mock_parse_document.return_value = ParsedPrompt(template='Hello {{name}}', tool_defs=None)

    dotprompt = Dotprompt()
    result: ParsedPrompt[dict[str, Any]] = dotprompt.parse('source string')

    mock_parse_document.assert_called_once_with('source string')

    # Ensure chaining works.
    assert result == ParsedPrompt(template='Hello {{name}}', tool_defs=None)


def test_chainable_interface(mock_handlebars: Mock) -> None:
    """Test that the methods can be chained."""
    dotprompt = Dotprompt()
    mock_handlebars.register_helper.reset_mock()
    mock_handlebars.register_partial.reset_mock()

    tool_def = ToolDefinition(
        name='tool1',
        description='Tool 1',
        inputSchema={'type': 'object'},
    )

    def helper_fn(params: list[Any], options: HelperOptions) -> str:
        """Test helper."""
        return 'helper1'

    result = dotprompt.define_helper('helper1', helper_fn).define_partial('partial1', 'content').define_tool(tool_def)

    mock_handlebars.register_helper.assert_called_once()
    mock_handlebars.register_partial.assert_called_once()
    assert dotprompt._tools['tool1'] == tool_def

    # Ensure chaining works.
    assert result == dotprompt


@pytest.mark.parametrize(
    'template,expected',
    [
        # No partials.
        ('Hello {{name}}', set()),
        # One partial.
        ('Hello {{> header}}', {'header'}),
        # Multiple partials.
        (
            'Hello {{> header}} {{> footer}} {{> sidebar}}',
            {'header', 'footer', 'sidebar'},
        ),
        # Partial with dash and underscore.
        ('Hello {{> header-component_name}}', {'header-component_name'}),
    ],
)
def test_identify_partials(template: str, expected: set[str]) -> None:
    """Test that the identify_partials function works correctly."""
    assert _identify_partials(template) == expected


class TestMergeMetadata(unittest.TestCase):
    """Tests for the _merge_metadata function."""

    def test_merge_config_base_only(self) -> None:
        """Test merging config when only base has it."""
        base = PromptMetadata[dict[str, Any]](config={'temp': 0.5})
        merge = PromptMetadata[dict[str, Any]]()
        dotprompt = Dotprompt()
        result = dotprompt._resolve_metadata(base, merge)
        self.assertEqual(result.config, {'temp': 0.5})

    def test_merge_config_merge_only(self) -> None:
        """Test merging config when only merge has it."""
        base = PromptMetadata[dict[str, Any]]()
        merge = PromptMetadata[dict[str, Any]](config={'temp': 0.7})
        dotprompt = Dotprompt()
        result = dotprompt._resolve_metadata(base, merge)
        self.assertEqual(result.config, {'temp': 0.7})

    def test_merge_config_no_overlap(self) -> None:
        """Test merging config with no overlapping keys."""
        base = PromptMetadata[dict[str, Any]](config={'temp': 0.5})
        merge = PromptMetadata[dict[str, Any]](config={'top_k': 10})
        dotprompt = Dotprompt()
        result = dotprompt._resolve_metadata(base, merge)
        self.assertEqual(result.config, {'temp': 0.5, 'top_k': 10})

    def test_merge_config_overlap(self) -> None:
        """Test merging config with overlapping keys (merge overrides)."""
        base = PromptMetadata[dict[str, Any]](config={'temp': 0.5, 'top_k': 5})
        merge = PromptMetadata[dict[str, Any]](config={'temp': 0.8, 'top_p': 0.9})
        dotprompt = Dotprompt()
        result = dotprompt._resolve_metadata(base, merge)
        # merge overrides temp, adds top_p, keeps top_k from base
        self.assertEqual(result.config, {'temp': 0.8, 'top_k': 5, 'top_p': 0.9})

    def test_merge_lists_replace(self) -> None:
        """Test that lists like tools are replaced, not appended."""
        base = PromptMetadata[dict[str, Any]](tools=['tool_a'])
        merge = PromptMetadata[dict[str, Any]](tools=['tool_b', 'tool_c'])
        dotprompt = Dotprompt()
        result = dotprompt._resolve_metadata(base, merge)
        self.assertEqual(result.tools, ['tool_b', 'tool_c'])

    def test_merge_removes_none_fields(self) -> None:
        """Test that None fields in merge override existing fields."""
        base = PromptMetadata[dict[str, Any]](config={'model': 'model-a'}, description='desc')
        # Pydantic V2: exclude_none=True in model_dump means None fields
        # in the merge object won't be present in merge_dict, so they
        # won't overwrite existing values in base.
        # To explicitly overwrite with None, it needs to be included.
        merge = PromptMetadata[dict[str, Any]](config={'model': 'model-b'}, description=None)
        dotprompt = Dotprompt()
        result = dotprompt._resolve_metadata(base, merge)

        # model gets updated, description from base remains
        expected = PromptMetadata[dict[str, Any]](config={'model': 'model-b'}, description='desc')
        self.assertEqual(result.config['model'], expected.config['model'])

        # Description should NOT be None because merge.model_dump excludes None
        self.assertEqual(result.description, expected.description)


class TestResolveTools(unittest.TestCase):
    """Test the resolve_tools method."""

    def test_resolve_returns_correct_tool_when_registered(self) -> None:
        """Should resolve registered tools."""
        dotprompt = Dotprompt()

        tool_def = ToolDefinition.model_validate({
            'name': 'testTool',
            'description': 'A test tool',
            'inputSchema': {
                'type': 'object',
                'properties': {
                    'param1': {'type': 'string'},
                },
            },
        })

        dotprompt.define_tool(tool_def)
        metadata: PromptMetadata[dict[str, Any]] = PromptMetadata[dict[str, Any]].model_validate({
            'tools': ['testTool', 'unknownTool']
        })

        result = dotprompt._resolve_tools(metadata)

        assert result.tool_defs is not None
        assert len(result.tool_defs) == 1
        assert result.tool_defs[0] == tool_def
        assert result.tools == ['unknownTool']

    def test_resolve_raises_error_for_unregistered_tool(self) -> None:
        """Should raise an error for unregistered tools."""
        tool_def = ToolDefinition.model_validate({
            'name': 'resolvedTool',
            'description': 'A test tool',
            'inputSchema': {
                'type': 'object',
                'properties': {
                    'param1': {'type': 'string'},
                },
            },
        })

        resolve_metadata_mock = Mock(return_value=tool_def)
        dotprompt = Dotprompt(tool_resolver=resolve_metadata_mock)
        metadata: PromptMetadata[dict[str, Any]] = PromptMetadata[dict[str, Any]](tools=['resolvedTool'])
        result = dotprompt._resolve_tools(metadata)
        resolve_metadata_mock.assert_called_with('resolvedTool')
        assert result.tool_defs is not None
        assert len(result.tool_defs) == 1
        assert result.tool_defs[0] == tool_def
        assert result.tools == []


class TestRenderPicoSchema(unittest.TestCase):
    """Test the render_picoschema method."""

    @patch(
        'dotpromptz.dotprompt.picoschema_to_json_schema',
        return_value={'type': 'object', 'properties': {'expanded': True}},
    )
    def test_process_valid_picoschema_definition(self, _: Mock) -> None:
        """Should process picoschema definitions."""
        dotprompt = Dotprompt()

        metadata: PromptMetadata[dict[str, Any]] = PromptMetadata[dict[str, Any]].model_validate({
            'output': {
                'format': 'json',
                'schema': {'type': 'number'},
            },
        })
        values_assert = {'type': 'object', 'properties': {'expanded': True}}
        # Now call the function that uses picoschema.picoschema internally
        result: PromptMetadata[dict[str, Any]] = dotprompt._render_picoschema(metadata)
        assert result.output is not None
        assert result.output.schema == values_assert

    def test_returns_original_metadata_when_no_schemas_present(self) -> None:
        """Test that the original metadata is returned unchanged when no schemas are present."""
        dotprompt = Dotprompt()

        metadata: PromptMetadata[dict[str, Any]] = PromptMetadata[dict[str, Any]].model_validate({
            'input': {
                'schema': {'type': 'string'},
            },
            'output': {
                'format': 'json',
                'schema': {'type': 'number'},
            },
        })
        result: PromptMetadata[dict[str, Any]] = dotprompt._render_picoschema(metadata)
        assert result == metadata


class TestWrappedSchemaResolver(unittest.TestCase):
    """Test the wrapped schema resolver."""

    def test_resolves_schemas_from_registered_schemas(self) -> None:
        """Should resolve schemas from the registered schemas."""
        schemas: dict[str, dict[str, str | dict[str, dict[str, str]]]] = {
            'test-schema': {
                'type': 'object',
                'properties': {
                    'name': {'type': 'string'},
                },
            },
        }

        dotprompt = Dotprompt(schemas=schemas)

        result = dotprompt._wrapped_schema_resolver('test-schema')

        self.assertEqual(result, schemas['test-schema'])

    def test_uses_schema_resolver_for_unregistered_schemas(self) -> None:
        """Should use the schema resolver for unregistered schemas."""
        schema_resolver_mock = Mock(return_value={'type': 'object', 'properties': {'resolved': {'type': 'boolean'}}})

        dotprompt = Dotprompt(schema_resolver=schema_resolver_mock)

        result = dotprompt._wrapped_schema_resolver('external-schema')
        schema_resolver_mock.assert_called_with('external-schema')

        self.assertEqual(result, {'type': 'object', 'properties': {'resolved': {'type': 'boolean'}}})

    def test_returns_none_if_schema_not_found_and_no_resolver(self) -> None:
        """Should return None if schema not found and no resolver."""
        dotprompt = Dotprompt()

        result = dotprompt._wrapped_schema_resolver('non-existent-schema')
        self.assertIsNone(result)


class TestResolveMetaData(unittest.TestCase):
    """Test the resolve_metadata method."""

    def test_merge_multiple_metadata(self) -> None:
        """Should merge multiple metadata objects."""
        dotprompt = Dotprompt()

        base: PromptMetadata[dict[str, Any]] = PromptMetadata.model_validate({
            'config': {
                'model': 'gemini-2.5-pro',
                'temperature': 0.7,
            },
        })

        merge1: PromptMetadata[dict[str, Any]] = PromptMetadata.model_validate({
            'config': {
                'top_p': 0.9,
            },
            'tools': ['tool1'],
        })

        merge2: PromptMetadata[dict[str, Any]] = PromptMetadata.model_validate({
            'config': {
                'model': 'gemini-2.5-flash',
                'max_tokens': 2000,
            },
        })
        render_pico_mock = Mock(side_effect=lambda arg: arg)
        resolve_tools_mock = Mock(side_effect=lambda arg: arg)
        with (
            patch.object(dotprompt, '_resolve_tools', resolve_tools_mock),
            patch.object(dotprompt, '_render_picoschema', render_pico_mock),
        ):
            result = dotprompt._resolve_metadata(base, merge1, merge2)
            self.assertEqual(
                result.config,
                {
                    'model': 'gemini-2.5-flash',
                    'top_p': 0.9,
                    'max_tokens': 2000,
                    'temperature': 0.7,
                },
            )

            self.assertEqual(result.tools, ['tool1'])
            render_pico_mock.assert_called()
            resolve_tools_mock.assert_called()

    def test_handle_undefined_merges(self) -> None:
        """Should handle undefined merges."""
        dotprompt = Dotprompt()

        base: PromptMetadata[dict[str, Any]] = PromptMetadata[dict[str, Any]].model_validate({
            'config': {
                'model': 'gemini-2.5-pro',
                'temperature': 0.7,
            },
        })

        render_pico_mock = Mock(side_effect=lambda arg: arg)
        resolve_tools_mock = Mock(side_effect=lambda arg: arg)

        with (
            patch.object(dotprompt, '_resolve_tools', resolve_tools_mock),
            patch.object(dotprompt, '_render_picoschema', render_pico_mock),
        ):
            result = dotprompt._resolve_metadata(base)

            self.assertEqual(result.config['model'], 'gemini-2.5-pro')
            self.assertEqual(
                result.config,
                {
                    'model': 'gemini-2.5-pro',
                    'temperature': 0.7,
                },
            )


def test_render_metadata() -> None:
    """Test the render_metadata method."""
    dotprompt = Dotprompt()

    parsed_source: ParsedPrompt[dict[str, Any]] = ParsedPrompt[dict[str, Any]](
        template='Template content', config={'model': 'gemini-2.5-pro'}
    )
    resolve_metadata_mock = Mock(
        return_value=PromptMetadata(
            config={'model': 'gemini-2.5-pro'},
        )
    )

    with patch.object(dotprompt, '_resolve_metadata', resolve_metadata_mock):
        result = dotprompt.render_metadata(parsed_source)

        resolve_metadata_mock.assert_called_with(
            PromptMetadata(),
            ParsedPrompt(
                config={'model': 'gemini-2.5-pro'},
                template='Template content',
            ),
            None,
        )
        assert result == PromptMetadata(config={'model': 'gemini-2.5-pro'})


def test_default_model_when_null() -> None:
    """Should use the default model when no model is specified."""
    dotprompt = Dotprompt()

    parsed_source: ParsedPrompt[dict[str, Any]] = ParsedPrompt[dict[str, Any]](
        template='Template content',
    )
    resolve_metadata_mock = Mock(
        return_value=PromptMetadata(
            config={'model': 'default-model'},
        )
    )

    with patch.object(dotprompt, '_resolve_metadata', resolve_metadata_mock):
        result = dotprompt.render_metadata(parsed_source)
        resolve_metadata_mock.assert_called()

    assert result == PromptMetadata(
        config={'model': 'default-model'},
    )


def test_use_available_model_config() -> None:
    """Should use model configs when available."""
    model_configs = {
        'gemini-2.5-pro': {'temperature': 0.7},
    }
    dotprompt = Dotprompt(model_configs=model_configs)

    parsed_source: ParsedPrompt[dict[str, Any]] = ParsedPrompt[dict[str, Any]](
        template='Template content',
        config={'model': 'gemini-2.5-pro'},
    )

    def wrapper(
        base: PromptMetadata[ModelConfigT], *merges: PromptMetadata[ModelConfigT]
    ) -> PromptMetadata[ModelConfigT]:
        return PromptMetadata.model_validate({**merges[0].model_dump(), 'config': base.config})

    resolve_metadata_mock = Mock(side_effect=wrapper)

    with patch.object(dotprompt, '_resolve_metadata', resolve_metadata_mock):
        result = dotprompt.render_metadata(parsed_source)
        resolve_metadata_mock.assert_called_with(PromptMetadata(config={'temperature': 0.7}), parsed_source, None)

        assert result.config == {'temperature': 0.7}


class TestResolvePartialsCycleDetection(unittest.TestCase):
    """Test cycle detection in _resolve_partials."""

    def test_handles_cycles_without_infinite_recursion(self) -> None:
        """Should handle cycles in partial references without infinite recursion.

        Setup partials that reference each other: A -> B -> A
        The resolver should only be called once per partial.
        """
        call_counts: dict[str, int] = {'partialA': 0, 'partialB': 0}

        def partial_resolver(name: str) -> str | None:
            """Track how many times each partial is resolved."""
            if name == 'partialA':
                call_counts['partialA'] += 1
                return 'Content A {{> partialB}}'
            if name == 'partialB':
                call_counts['partialB'] += 1
                return 'Content B {{> partialA}}'
            return None

        dotprompt = Dotprompt(partial_resolver=partial_resolver)

        # Start with a template that references partialA
        template = '{{> partialA}}'

        # This should complete without infinite recursion
        dotprompt._resolve_partials(template)

        # Each partial should only be resolved once despite the cycle
        self.assertEqual(call_counts['partialA'], 1)
        self.assertEqual(call_counts['partialB'], 1)

    def test_deep_chain_resolution_without_cycles(self) -> None:
        """Should resolve deep chains of partials correctly.

        Setup a chain: A -> B -> C (no cycle)
        All partials should be resolved.
        """
        resolved_partials: list[str] = []

        def partial_resolver(name: str) -> str | None:
            """Track resolution order."""
            resolved_partials.append(name)
            if name == 'partialA':
                return 'A content {{> partialB}}'
            if name == 'partialB':
                return 'B content {{> partialC}}'
            if name == 'partialC':
                return 'C content'
            return None

        dotprompt = Dotprompt(partial_resolver=partial_resolver)

        template = '{{> partialA}}'
        dotprompt._resolve_partials(template)

        # All three partials should be resolved
        self.assertIn('partialA', resolved_partials)
        self.assertIn('partialB', resolved_partials)
        self.assertIn('partialC', resolved_partials)
        # Each should only be resolved once
        self.assertEqual(len(resolved_partials), 3)


class TestRemoveUndefinedFields(unittest.TestCase):
    """Tests for _remove_undefined_fields."""

    def test_remove_undefined_fields_recursively(self) -> None:
        """Test removing undefined fields from dictionaries."""
        input_dict = {
            'a': 1,
            'b': None,
            'c': {'d': 2, 'e': None},
            'f': [3, None, {'g': 4, 'h': None}],
        }
        expected = {
            'a': 1,
            'c': {'d': 2},
            'f': [3, {'g': 4}],
        }
        result = _remove_undefined_fields(input_dict)
        self.assertEqual(result, expected)

    def test_remove_undefined_fields_none(self) -> None:
        """Test removing undefined fields from None."""
        self.assertIsNone(_remove_undefined_fields(None))

    def test_remove_undefined_fields_primitive(self) -> None:
        """Test removing undefined fields from primitive types."""
        self.assertEqual(_remove_undefined_fields(42), 42)
        self.assertEqual(_remove_undefined_fields('test'), 'test')
        self.assertEqual(_remove_undefined_fields(True), True)

    def test_remove_undefined_fields_list(self) -> None:
        """Test removing undefined fields from lists."""
        input_list = [1, None, {'a': 2, 'b': None}, [3, None, 4]]
        expected = [1, {'a': 2}, [3, 4]]
        result = _remove_undefined_fields(input_list)
        self.assertEqual(result, expected)

    def test_remove_undefined_fields_dict(self) -> None:
        """Test removing undefined fields from dictionaries."""
        input_dict = {
            'a': 1,
            'b': None,
            'c': {'d': 2, 'e': None},
            'f': [3, None, {'g': 4, 'h': None}],
        }
        expected = {
            'a': 1,
            'c': {'d': 2},
            'f': [3, {'g': 4}],
        }
        result = _remove_undefined_fields(input_dict)
        self.assertEqual(result, expected)

    def test_remove_undefined_fields_nested(self) -> None:
        """Test removing undefined fields from nested structures."""
        input_data = {
            'a': {
                'b': [
                    {'c': 1, 'd': None},
                    None,
                    {'e': {'f': 2, 'g': None}},
                ],
                'h': None,
            },
            'i': None,
        }
        expected = {
            'a': {
                'b': [
                    {'c': 1},
                    {'e': {'f': 2}},
                ],
            },
        }
        result = _remove_undefined_fields(input_data)
        self.assertEqual(result, expected)

    def test_remove_undefined_fields_empty(self) -> None:
        """Test removing undefined fields from empty structures."""
        self.assertEqual(_remove_undefined_fields({}), {})
        self.assertEqual(_remove_undefined_fields([]), [])
        self.assertEqual(_remove_undefined_fields({'a': {}}), {'a': {}})
        self.assertEqual(_remove_undefined_fields({'a': []}), {'a': []})


if __name__ == '__main__':
    unittest.main()


class TestRenderWithDefaults(unittest.TestCase):
    """End-to-end tests for Dotprompt.render() with input defaults."""

    def test_render_with_defaults_only(self) -> None:
        """Defaults used as template variables when no data records exist."""
        source = '---\ninput:\n  defaults:\n    lang: en\n    tone: formal\n---\nLanguage: {{lang}}, Tone: {{tone}}'
        dotprompt = Dotprompt()
        result = dotprompt.render(source)
        assert len(result.messages) == 1
        assert result.messages[0].content is not None
        assert len(result.messages[0].content) == 1
        text = result.messages[0].content[0]
        assert hasattr(text, 'text')
        assert text.text == 'Language: en, Tone: formal'  # type: ignore[union-attr]

    def test_render_with_defaults_and_inline_data(self) -> None:
        """Defaults merged with inline data, data overrides defaults."""
        source = (
            '---\ninput:\n  defaults:\n    lang: en\n    tone: formal\n'
            '  data:\n    name: Alice\n    tone: casual\n---\n'
            '{{name}} ({{lang}}, {{tone}})'
        )
        dotprompt = Dotprompt()
        result = dotprompt.render(source)
        assert len(result.messages) == 1
        text = result.messages[0].content[0]
        assert hasattr(text, 'text')
        # tone should be 'casual' (data overrides default)
        assert text.text == 'Alice (en, casual)'  # type: ignore[union-attr]

    def test_render_caller_data_overrides_defaults(self) -> None:
        """Caller-provided data overrides both defaults and frontmatter data."""
        source = '---\ninput:\n  defaults:\n    lang: en\n  data:\n    name: Alice\n---\n{{name}} ({{lang}})'
        dotprompt = Dotprompt()
        data = DataArgument(input={'name': 'Bob', 'lang': 'zh'})
        result = dotprompt.render(source, data=data)
        assert len(result.messages) == 1
        text = result.messages[0].content[0]
        assert hasattr(text, 'text')
        assert text.text == 'Bob (zh)'  # type: ignore[union-attr]

    def test_render_legacy_input_still_works(self) -> None:
        """Legacy inline dict input (no defaults) renders correctly."""
        source = '---\ninput:\n  name: Alice\n---\nHello {{name}}'
        dotprompt = Dotprompt()
        result = dotprompt.render(source)
        assert len(result.messages) == 1
        text = result.messages[0].content[0]
        assert hasattr(text, 'text')
        assert text.text == 'Hello Alice'  # type: ignore[union-attr]


class TestFrontmatterTemplateRendering:
    """Integration tests for frontmatter {{variable}} template rendering via Dotprompt.render()."""

    def test_dynamic_config_model_via_render_data(self) -> None:
        """config.model: '{{model_name}}' resolved via render's data.input."""
        dp = Dotprompt()
        source = dedent("""
            ---
            config:
              model: "{{model_name}}"
            ---
            Hello world.
        """).strip()
        result = dp.render(source, data=DataArgument(input={'model_name': 'gpt-4o'}))
        assert result.config is not None
        assert result.config.get('model') == 'gpt-4o'

    def test_dynamic_config_model_via_frontmatter_input(self) -> None:
        """config.model resolved from inline frontmatter input data (no caller data)."""
        dp = Dotprompt()
        source = dedent("""
            ---
            config:
              model: "{{model_name}}"
            input:
              defaults:
                model_name: claude-3-opus
            ---
            Hello world.
        """).strip()
        result = dp.render(source)
        assert result.config is not None
        assert result.config.get('model') == 'claude-3-opus'

    def test_dynamic_output_file_name(self) -> None:
        """output.file_name: '{{out_name}}' resolved from input."""
        dp = Dotprompt()
        source = dedent("""
            ---
            config:
              model: gemini-2.0-flash-exp
            output:
              format: image
              file_name: "{{out_name}}"
            ---
            Draw a cat.
        """).strip()
        result = dp.render(source, data=DataArgument(input={'out_name': 'my_image'}))
        assert result.output is not None
        assert result.output.file_name == 'my_image'

    def test_input_field_not_rendered(self) -> None:
        """Values inside 'input' field must NOT be template-rendered."""
        dp = Dotprompt()
        source = dedent("""
            ---
            config:
              model: "{{model_name}}"
            input:
              defaults:
                model_name: "{{should_not_be_rendered}}"
            ---
            Hello.
        """).strip()
        # Render with a variable that would incorrectly expand the input field
        result = dp.render(source, data=DataArgument(input={'model_name': 'gpt-4o', 'should_not_be_rendered': 'WRONG'}))
        # config.model should be resolved
        assert result.config is not None
        assert result.config.get('model') == 'gpt-4o'
        # No crash — the input field's template string is preserved (not expanded)

    def test_undefined_variable_resolves_to_empty_string(self) -> None:
        """Undefined {{missing_var}} in frontmatter resolves to empty string, not crash."""
        dp = Dotprompt()
        source = dedent("""
            ---
            config:
              model: "{{missing_var}}"
            ---
            Hello.
        """).strip()
        # Pass a non-empty input so Phase 2 re-render runs, but missing_var is absent
        result = dp.render(source, data=DataArgument(input={'other_key': 'value'}))
        # Should not raise, config.model becomes empty string (Handlebars default for undefined)
        assert result.config is not None
        assert result.config.get('model') == ''

    def test_no_variables_unchanged_behavior(self) -> None:
        """Frontmatter without templates is unaffected (regression guard)."""
        dp = Dotprompt()
        source = dedent("""
            ---
            config:
              model: gpt-4o
            ---
            Hello.
        """).strip()
        result = dp.render(source)
        assert result.config is not None
        assert result.config.get('model') == 'gpt-4o'


class TestAutoInjectedVariables:
    """Tests for {{SCHEMA}} and {{TIME}} auto-injected context variables."""

    def test_schema_variable_injects_json_schema(self) -> None:
        """{{SCHEMA}} resolves to JSON Schema derived from output.schema."""
        source = dedent("""
            ---
            output:
              format: json
              schema:
                name: string, User name
                age: integer, User age
            ---
            Output schema: {{SCHEMA}}
        """).strip()
        dp = Dotprompt()
        result = dp.render(source)
        text = result.messages[0].content[0]
        assert hasattr(text, 'text')
        # Extract the JSON part after 'Output schema: '
        raw = text.text.split('Output schema: ', 1)[1]  # type: ignore[union-attr]
        schema = json.loads(raw)
        assert schema['type'] == 'object'
        assert 'name' in schema['properties']
        assert schema['properties']['name']['type'] == 'string'
        assert 'age' in schema['properties']
        assert schema['properties']['age']['type'] == 'integer'

    def test_schema_variable_absent_when_no_output_schema(self) -> None:
        """{{SCHEMA}} renders empty when no output.schema is defined."""
        source = dedent("""
            ---
            config:
              model: gpt-4o
            ---
            No schema: {{SCHEMA}}
        """).strip()
        dp = Dotprompt()
        result = dp.render(source)
        text = result.messages[0].content[0]
        assert hasattr(text, 'text')
        assert text.text == 'No schema: '  # type: ignore[union-attr]

    def test_time_variable_format(self) -> None:
        """{{TIME}} resolves to yyyy_MM_dd_hh_mm_ss format."""
        source = dedent("""
            ---
            config:
              model: gpt-4o
            ---
            Time: {{TIME}}
        """).strip()
        dp = Dotprompt()
        result = dp.render(source)
        text = result.messages[0].content[0]
        assert hasattr(text, 'text')
        raw = text.text.split('Time: ', 1)[1]  # type: ignore[union-attr]
        # Validate format: yyyy_MM_dd_HH_MM_SS
        assert re.match(r'\d{4}_\d{2}_\d{2}_\d{2}_\d{2}_\d{2}$', raw)

    def test_time_variable_computed_once_per_render(self) -> None:
        """{{TIME}} used multiple times in one template produces the same value."""
        source = dedent("""
            ---
            config:
              model: gpt-4o
            ---
            First: {{TIME}}, Second: {{TIME}}
        """).strip()
        dp = Dotprompt()
        result = dp.render(source)
        text = result.messages[0].content[0]
        assert hasattr(text, 'text')
        parts = text.text.split(', ')  # type: ignore[union-attr]
        first = parts[0].split('First: ')[1]
        second = parts[1].split('Second: ')[1]
        assert first == second

    def test_user_context_overrides_auto_variables(self) -> None:
        """User-provided context data should override auto-injected @variables."""
        source = dedent("""
            ---
            config:
              model: gpt-4o
            ---
            Time: {{TIME}}
        """).strip()
        dp = Dotprompt()
        data = DataArgument(context={'TIME': 'custom_time'})
        result = dp.render(source, data=data)
        text = result.messages[0].content[0]
        assert hasattr(text, 'text')
        assert text.text == 'Time: custom_time'  # type: ignore[union-attr]

    def test_schema_with_picoschema_string(self) -> None:
        """{{SCHEMA}} works with string-type picoschema output."""
        source = dedent("""
            ---
            output:
              format: json
              schema: string
            ---
            Schema: {{SCHEMA}}
        """).strip()
        dp = Dotprompt()
        result = dp.render(source)
        text = result.messages[0].content[0]
        assert hasattr(text, 'text')
        raw = text.text.split('Schema: ', 1)[1]  # type: ignore[union-attr]
        schema = json.loads(raw)
        assert schema['type'] == 'string'


# ---------------------------------------------------------------------------
# _extract_template_variables tests
# ---------------------------------------------------------------------------


class TestExtractTemplateVariables:
    """Tests for the _extract_template_variables helper."""

    def test_simple_variable(self) -> None:
        assert _extract_template_variables('Hello {{name}}!') == {'name'}

    def test_variable_with_spaces(self) -> None:
        assert _extract_template_variables('Hello {{ name }}!') == {'name'}

    def test_dotted_path_returns_root(self) -> None:
        assert _extract_template_variables('{{user.name}}') == {'user'}

    def test_multiple_variables(self) -> None:
        result = _extract_template_variables('{{a}} and {{b}}')
        assert result == {'a', 'b'}

    def test_whitespace_control_tilde(self) -> None:
        assert _extract_template_variables('{{~name~}}') == {'name'}
        assert _extract_template_variables('{{~ name ~}}') == {'name'}

    def test_excludes_role_helper(self) -> None:
        helpers = {'role', 'json', 'media', 'section', 'history'}
        result = _extract_template_variables('{{role "system"}}', helpers)
        assert result == set()

    def test_excludes_json_helper(self) -> None:
        helpers = {'json'}
        result = _extract_template_variables('{{json this}}', helpers)
        assert result == set()

    def test_excludes_block_helpers(self) -> None:
        template = '{{#if show}}yes{{/if}}'
        result = _extract_template_variables(template)
        assert result == {'show'}

    def test_excludes_each_block(self) -> None:
        template = '{{#each items}}{{this}}{{/each}}'
        result = _extract_template_variables(template)
        assert result == {'items'}

    def test_excludes_partials(self) -> None:
        result = _extract_template_variables('{{> greeting}}')
        assert result == set()

    def test_excludes_comments(self) -> None:
        result = _extract_template_variables('{{!-- comment --}}')
        assert result == set()
        result2 = _extract_template_variables('{{! comment }}')
        assert result2 == set()

    def test_excludes_auto_variables(self) -> None:
        result = _extract_template_variables('{{SCHEMA}} {{TIME}}')
        assert result == set()

    def test_excludes_this(self) -> None:
        result = _extract_template_variables('{{this}}')
        assert result == set()

    def test_excludes_else(self) -> None:
        template = '{{#if x}}a{{else}}b{{/if}}'
        result = _extract_template_variables(template)
        assert result == {'x'}

    def test_media_helper_extracts_hash_value(self) -> None:
        helpers = {'media'}
        result = _extract_template_variables('{{media url=imageUrl}}', helpers)
        assert result == {'imageUrl'}

    def test_media_helper_ignores_string_hash_value(self) -> None:
        helpers = {'media'}
        result = _extract_template_variables('{{media url="https://example.com"}}', helpers)
        assert result == set()

    def test_mixed_template(self) -> None:
        helpers = {'role', 'media', 'json', 'section', 'history'}
        template = dedent("""
            {{role "system"}}
            You are helpful.
            {{role "user"}}
            Hello {{name}}, your age is {{age}}.
            {{#if show}}
            {{media url=photo}}
            {{/if}}
            {{SCHEMA}}
        """).strip()
        result = _extract_template_variables(template, helpers)
        assert result == {'name', 'age', 'show', 'photo'}

    def test_empty_template(self) -> None:
        assert _extract_template_variables('') == set()

    def test_no_variables_plain_text(self) -> None:
        assert _extract_template_variables('Hello world!') == set()

    def test_excludes_loop_builtins(self) -> None:
        result = _extract_template_variables('{{@index}} {{@first}} {{@last}}')
        assert result == set()


# ---------------------------------------------------------------------------
# Validation integration tests
# ---------------------------------------------------------------------------


class TestTemplateValidation:
    """Tests for missing/extra variable validation at render time."""

    def test_missing_variable_raises_error(self) -> None:
        """Rendering a template with a missing variable should raise TemplateMissingVariableError."""
        source = dedent("""
            ---
            config:
              model: gpt-4
            ---
            Hello {{name}}, you are {{age}} years old.
        """).strip()
        dp = Dotprompt()
        with pytest.raises(TemplateMissingVariableError) as exc_info:
            dp.render(source, data=DataArgument(input={'name': 'Alice'}))
        assert 'age' in str(exc_info.value)
        assert exc_info.value.missing == {'age'}

    def test_extra_variable_emits_warning_caplog(self, capsys: pytest.CaptureFixture[str]) -> None:
        """Rendering with extra data fields should emit a warning via logging."""
        source = dedent("""
            ---
            config:
              model: gpt-4
            ---
            Hello {{name}}!
        """).strip()
        dp = Dotprompt()
        dp.render(source, data=DataArgument(input={'name': 'Alice', 'age': 30}))
        captured = capsys.readouterr()
        assert 'age' in captured.err
        assert 'unused' in captured.err.lower() or 'not referenced' in captured.err.lower()

    def test_no_variables_no_data_passes(self) -> None:
        """A template with no variables and no data should pass validation."""
        source = dedent("""
            ---
            config:
              model: gpt-4
            ---
            Hello world!
        """).strip()
        dp = Dotprompt()
        result = dp.render(source)
        assert result.messages is not None

    def test_all_variables_provided_passes(self) -> None:
        """Providing exactly the right variables should pass without error or warning."""
        source = dedent("""
            ---
            config:
              model: gpt-4
            ---
            Hello {{name}}, age {{age}}!
        """).strip()
        dp = Dotprompt()
        result = dp.render(source, data=DataArgument(input={'name': 'Alice', 'age': 30}))
        text = result.messages[0].content[0]
        assert hasattr(text, 'text')
        assert 'Alice' in text.text  # type: ignore[union-attr]
        assert '30' in text.text  # type: ignore[union-attr]

    def test_missing_variable_error_contains_all_missing(self) -> None:
        """Error should list all missing variables, not just the first."""
        source = dedent("""
            ---
            config:
              model: gpt-4
            ---
            {{a}} {{b}} {{c}}
        """).strip()
        dp = Dotprompt()
        with pytest.raises(TemplateMissingVariableError) as exc_info:
            dp.render(source, data=DataArgument(input={}))
        assert exc_info.value.missing == {'a', 'b', 'c'}

    def test_non_string_value_warns(self, capsys: pytest.CaptureFixture[str]) -> None:
        """Non-string variable values should emit a warning."""
        source = dedent("""
            ---
            config:
              model: gpt-4
            ---
            Age: {{age}}
        """).strip()
        dp = Dotprompt()
        dp.render(source, data=DataArgument(input={'age': 30}))
        captured = capsys.readouterr()
        assert 'non-string' in captured.err.lower() or 'non-string type' in captured.err
        assert 'age' in captured.err

    def test_empty_string_value_warns(self, capsys: pytest.CaptureFixture[str]) -> None:
        """Empty string variable values should emit a warning."""
        source = dedent("""
            ---
            config:
              model: gpt-4
            ---
            Hello {{name}}!
        """).strip()
        dp = Dotprompt()
        dp.render(source, data=DataArgument(input={'name': ''}))
        captured = capsys.readouterr()
        assert 'empty' in captured.err.lower()
        assert 'name' in captured.err

    def test_none_value_warns(self, capsys: pytest.CaptureFixture[str]) -> None:
        """None variable values should emit a warning."""
        source = dedent("""
            ---
            config:
              model: gpt-4
            ---
            Hello {{name}}!
        """).strip()
        dp = Dotprompt()
        dp.render(source, data=DataArgument(input={'name': None}))
        captured = capsys.readouterr()
        assert 'empty' in captured.err.lower()
        assert 'name' in captured.err

    def test_empty_list_value_warns(self, capsys: pytest.CaptureFixture[str]) -> None:
        """Empty list variable values should emit a warning."""
        source = dedent("""
            ---
            config:
              model: gpt-4
            ---
            Items: {{items}}
        """).strip()
        dp = Dotprompt()
        dp.render(source, data=DataArgument(input={'items': []}))
        captured = capsys.readouterr()
        assert 'empty' in captured.err.lower()
        assert 'items' in captured.err

    def test_empty_dict_value_warns(self, capsys: pytest.CaptureFixture[str]) -> None:
        """Empty dict variable values should emit a warning."""
        source = dedent("""
            ---
            config:
              model: gpt-4
            ---
            Data: {{data}}
        """).strip()
        dp = Dotprompt()
        dp.render(source, data=DataArgument(input={'data': {}}))
        captured = capsys.readouterr()
        assert 'empty' in captured.err.lower()
        assert 'data' in captured.err

    def test_bool_value_warns(self, capsys: pytest.CaptureFixture[str]) -> None:
        """Bool variable values should emit a non-string warning."""
        source = dedent("""
            ---
            config:
              model: gpt-4
            ---
            Flag: {{flag}}
        """).strip()
        dp = Dotprompt()
        dp.render(source, data=DataArgument(input={'flag': True}))
        captured = capsys.readouterr()
        assert 'non-string' in captured.err.lower() or 'non-string type' in captured.err
        assert 'flag' in captured.err

    def test_valid_string_no_warning(self, capsys: pytest.CaptureFixture[str]) -> None:
        """Valid non-empty string values should not emit any warning."""
        source = dedent("""
            ---
            config:
              model: gpt-4
            ---
            Hello {{name}}!
        """).strip()
        dp = Dotprompt()
        dp.render(source, data=DataArgument(input={'name': 'Alice'}))
        captured = capsys.readouterr()
        assert captured.err == '' or 'warning' not in captured.err.lower()
