"""Unit tests for the Dotprompt class.

Dotprompt extends Jinja2 templates for use with Gen AI prompts.

The tests cover:

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

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

import jinja2
import pytest

from dotpromptz.dotprompt import (
    Dotprompt,
    TemplateMissingVariableError,
    _create_jinja_env,
    _extract_template_variables,
    _remove_undefined_fields,
)
from dotpromptz.logging import configure_logging
from dotpromptz.typing import (
    ModelConfigT,
    ParsedPrompt,
    PromptMetadata,
    ToolDefinition,
)


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

    assert isinstance(dotprompt._env, jinja2.Environment)
    assert dotprompt._known_helpers == {
        'media': True,
        'role': 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


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

    def helper_fn() -> str:
        """Test helper."""
        return 'test_helper'

    helpers: dict[str, Callable[..., str]] = {'helper1': helper_fn}

    dp = Dotprompt(
        helpers=helpers,
    )

    assert dp._env.globals['helper1'] is helper_fn


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

    def helper_fn() -> str:
        """Test helper."""
        return 'test_helper'

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

    assert dotprompt._env.globals['test_helper'] is helper_fn
    assert dotprompt._known_helpers.get('test_helper') is True

    # Ensure chaining works.
    assert result == dotprompt


def test_define_tool() -> 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


@patch('dotpromptz.dotprompt.parse_document')
def test_parse(mock_parse_document: 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() -> None:
    """Test that the methods can be chained."""
    dotprompt = Dotprompt()

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

    def helper_fn() -> str:
        """Test helper."""
        return 'helper1'

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

    assert dotprompt._env.globals.get('helper1') is helper_fn
    assert dotprompt._tools['tool1'] == tool_def

    # Ensure chaining works.
    assert result == dotprompt


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({
            '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-3-flash-preview',
                'temperature': 1,
            },
        })

        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-3-flash-preview',
                '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-3-flash-preview',
                    'top_p': 0.9,
                    'max_tokens': 2000,
                    'temperature': 1,
                },
            )

            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-3-flash-preview',
                'temperature': 1,
            },
        })

        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-3-flash-preview')
            self.assertEqual(
                result.config,
                {
                    'model': 'gemini-3-flash-preview',
                    'temperature': 1,
                },
            )


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-3-flash-preview'}
    )
    resolve_metadata_mock = Mock(
        return_value=PromptMetadata(
            config={'model': 'gemini-3-flash-preview'},
        )
    )

    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-3-flash-preview'},
                template='Template content',
            ),
            None,
        )
        assert result == PromptMetadata(config={'model': 'gemini-3-flash-preview'})


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-3-flash-preview': {'temperature': 1},
    }
    dotprompt = Dotprompt(model_configs=model_configs)

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

    def wrapper(
        base: PromptMetadata[ModelConfigT], *merges: PromptMetadata[ModelConfigT]
    ) -> PromptMetadata[ModelConfigT]:
        return PromptMetadata.model_validate(
            {**merges[0].model_dump(exclude={'template', 'warnings'}), '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': 1}), parsed_source, None)

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


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 = (
            '---\nversion: 1\ninput:\n  defaults:\n    lang: en\n    tone: formal\n---\n'
            'Language: {{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 = (
            '---\nversion: 1\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 = '---\nversion: 1\ninput:\n  defaults:\n    lang: en\n  data:\n    name: Alice\n---\n{{name}} ({{lang}})'
        dotprompt = Dotprompt()
        result = dotprompt.render(source, input={'name': 'Bob', 'lang': 'zh'})
        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 = '---\nversion: 1\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]

    def test_render_batch_frontmatter_input_raises(self) -> None:
        """Single render should reject batch input from frontmatter."""
        source = dedent("""
            ---
            version: 1
            input:
              data:
                - name: Alice
                - name: Bob
            ---
            Hello {{name}}
        """).strip()
        dotprompt = Dotprompt()
        with pytest.raises(ValueError, match='Batch input detected in frontmatter'):
            dotprompt.render(source)


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("""
            ---
            version: 1
            config:
              model: "{{model_name}}"
            ---
            Hello world.
        """).strip()
        result = dp.render(source, 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("""
            ---
            version: 1
            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("""
            ---
            version: 1
            config:
              model: gemini-3-flash-preview
            output:
              format: image
              file_name: "{{out_name}}"
            ---
            Draw a cat.
        """).strip()
        result = dp.render(source, 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("""
            ---
            version: 1
            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, 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("""
            ---
            version: 1
            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, input={'other_key': 'value'})
        # Should not raise, config.model becomes empty string (Jinja2 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("""
            ---
            version: 1
            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("""
            ---
            version: 1
            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("""
            ---
            version: 1
            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("""
            ---
            version: 1
            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("""
            ---
            version: 1
            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("""
            ---
            version: 1
            config:
              model: gpt-4o
            ---
            Time: {{TIME}}
        """).strip()
        dp = Dotprompt()
        data = {'TIME': 'custom_time'}
        result = dp.render(source, input=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("""
            ---
            version: 1
            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."""

    @pytest.fixture(autouse=True)
    def _setup_env(self) -> None:
        """Create a Jinja2 environment with built-in helpers for testing."""
        self.env = _create_jinja_env()

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

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

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

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

    def test_whitespace_control_dash(self) -> None:
        """Jinja2 uses {{- name -}} for whitespace control (not tilde)."""
        assert _extract_template_variables('{{- name -}}', self.env) == {'name'}
        assert _extract_template_variables('{{-  name  -}}', self.env) == {'name'}

    def test_excludes_role_helper(self) -> None:
        result = _extract_template_variables('{{ role("system") }}', self.env)
        assert result == set()

    def test_excludes_filter_extracts_variable(self) -> None:
        """Filter usage (tojson) should not appear as variable; the filtered value should."""
        result = _extract_template_variables('{{ x | tojson }}', self.env)
        assert result == {'x'}

    def test_excludes_block_helpers(self) -> None:
        template = '{% if show %}yes{% endif %}'
        result = _extract_template_variables(template, self.env)
        assert result == {'show'}

    def test_excludes_each_block(self) -> None:
        template = '{% for item in items %}{{ item }}{% endfor %}'
        result = _extract_template_variables(template, self.env)
        assert result == {'items'}

    def test_no_variables_no_template_refs(self) -> None:
        result = _extract_template_variables('Hello world', self.env)
        assert result == set()

    def test_excludes_comments(self) -> None:
        result = _extract_template_variables('{# comment #}', self.env)
        assert result == set()

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

    def test_excludes_else(self) -> None:
        template = '{% if x %}a{% else %}b{% endif %}'
        result = _extract_template_variables(template, self.env)
        assert result == {'x'}

    def test_media_helper_extracts_keyword_arg(self) -> None:
        """In Jinja2, media(url=imageUrl) — imageUrl is a variable passed as keyword arg."""
        result = _extract_template_variables('{{ media(url=imageUrl) }}', self.env)
        assert result == {'imageUrl'}

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

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

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

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

    def test_excludes_loop_builtins(self) -> None:
        """In Jinja2, loop.index etc. are loop-scoped; items is the user variable."""
        result = _extract_template_variables('{% for item in items %}{{ loop.index }}{% endfor %}', self.env)
        assert result == {'items'}


# ---------------------------------------------------------------------------
# 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("""
            ---
            version: 1
            config:
              model: gpt-4
            ---
            Hello {{name}}, you are {{age}} years old.
        """).strip()
        dp = Dotprompt()
        with pytest.raises(TemplateMissingVariableError) as exc_info:
            dp.render(source, 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("""
            ---
            version: 1
            config:
              model: gpt-4
            ---
            Hello {{name}}!
        """).strip()
        configure_logging(level='WARNING')
        dp = Dotprompt()
        dp.render(source, input={'name': 'Alice', 'age': 30})
        captured = capsys.readouterr()
        output = captured.err + captured.out
        assert 'age' in output
        assert 'unused' in output.lower() or 'not referenced' in output.lower()

    def test_frontmatter_variable_not_reported_as_unused(self, capsys: pytest.CaptureFixture[str]) -> None:
        """Variables consumed by frontmatter templates should not be flagged as unused."""
        source = dedent("""
            ---
            version: 1
            config:
              model: "{{model_name}}"
            ---
            Hello {{name}}!
        """).strip()
        configure_logging(level='WARNING')
        dp = Dotprompt()
        dp.render(source, input={'name': 'Alice', 'model_name': 'gpt-4o', 'age': 30})
        captured = capsys.readouterr()
        output = captured.err + captured.out
        assert 'age' in output
        assert 'model_name' not in output

    def test_compiled_renderer_respects_frontmatter_variable_usage(self, capsys: pytest.CaptureFixture[str]) -> None:
        """Compiled renderer should reuse compile-time variable analysis for warnings."""
        source = dedent("""
            ---
            version: 1
            config:
              model: "{{model_name}}"
            ---
            Hello {{name}}!
        """).strip()
        configure_logging(level='WARNING')
        dp = Dotprompt()
        renderer = dp.compile(source)
        renderer(input={'name': 'Alice', 'model_name': 'gpt-4o', 'age': 30})
        captured = capsys.readouterr()
        output = captured.err + captured.out
        assert 'age' in output
        assert 'model_name' not in output

    def test_no_variables_no_data_passes(self) -> None:
        """A template with no variables and no data should pass validation."""
        source = dedent("""
            ---
            version: 1
            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("""
            ---
            version: 1
            config:
              model: gpt-4
            ---
            Hello {{name}}, age {{age}}!
        """).strip()
        dp = Dotprompt()
        result = dp.render(source, 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("""
            ---
            version: 1
            config:
              model: gpt-4
            ---
            {{a}} {{b}} {{c}}
        """).strip()
        dp = Dotprompt()
        with pytest.raises(TemplateMissingVariableError) as exc_info:
            dp.render(source, input={})
        assert exc_info.value.missing == {'a', 'b', 'c'}

    def test_non_string_value_no_warning(self, capsys: pytest.CaptureFixture[str]) -> None:
        """Non-string variable values should not emit a warning."""
        source = dedent("""
            ---
            version: 1
            config:
              model: gpt-4
            ---
            Age: {{age}}
        """).strip()
        configure_logging(level='WARNING')
        dp = Dotprompt()
        dp.render(source, input={'age': 30})
        captured = capsys.readouterr()
        output = captured.err + captured.out
        warning_lines = [line for line in output.splitlines() if '[warning' in line.lower()]
        assert not warning_lines, f'Unexpected warning output: {warning_lines}'

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

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

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

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

    def test_bool_value_no_warning(self, capsys: pytest.CaptureFixture[str]) -> None:
        """Bool variable values should not emit a warning."""
        source = dedent("""
            ---
            version: 1
            config:
              model: gpt-4
            ---
            Flag: {{flag}}
        """).strip()
        configure_logging(level='WARNING')
        dp = Dotprompt()
        dp.render(source, input={'flag': True})
        captured = capsys.readouterr()
        output = captured.err + captured.out
        warning_lines = [line for line in output.splitlines() if '[warning' in line.lower()]
        assert not warning_lines, f'Unexpected warning output: {warning_lines}'

    def test_valid_string_no_warning(self, capsys: pytest.CaptureFixture[str]) -> None:
        """Valid non-empty string values should not emit any warning."""
        source = dedent("""
            ---
            version: 1
            config:
              model: gpt-4
            ---
            Hello {{name}}!
        """).strip()
        configure_logging(level='WARNING')
        dp = Dotprompt()
        dp.render(source, input={'name': 'Alice'})
        captured = capsys.readouterr()
        output = captured.err + captured.out
        warning_lines = [line for line in output.splitlines() if '[warning' in line.lower()]
        assert not warning_lines, f'Unexpected warning output: {warning_lines}'
