"""Tests for parse module."""

import re
import unittest

import pytest
from pydantic import ValidationError

from dotpromptz.parse import (
    FRONTMATTER_AND_BODY_REGEX,
    MEDIA_AND_SECTION_MARKER_REGEX,
    RESERVED_METADATA_KEYWORDS,
    ROLE_AND_HISTORY_MARKER_REGEX,
    MessageSource,
    _build_input_config,
    convert_namespaced_entry_to_nested_object,
    extract_frontmatter_and_body,
    insert_history,
    message_sources_to_messages,
    messages_have_history,
    parse_document,
    parse_media_part,
    parse_part,
    parse_section_part,
    parse_text_part,
    split_by_media_and_section_markers,
    split_by_regex,
    split_by_role_and_history_markers,
    transform_messages_to_history,
)
from dotpromptz.typing import (
    MediaContent,
    MediaPart,
    Message,
    ParsedPrompt,
    Part,
    PendingMetadata,
    PendingPart,
    Role,
    TextPart,
)


@pytest.mark.parametrize(
    'source,expected_frontmatter,expected_body',
    [
        (
            '---\r\nfoo: bar\r\n---\r\nThis is the body.\r\n',
            'foo: bar',
            'This is the body.\r\n',
        ),  # Test document with CRLF line endings
        (
            '---\rfoo: bar\r---\rThis is the body.\r',
            'foo: bar',
            'This is the body.\r',
        ),  # Test document with CR line endings
        (
            '---\nfoo: bar\n---\nThis is the body.\n',
            'foo: bar',
            'This is the body.\n',
        ),  # Test document with LF line endings
        (
            '---\n\n---\nBody only.',
            '',
            'Body only.',
        ),  # Test document with empty frontmatter
        (
            '---\nfoo: bar\n---\n',
            'foo: bar',
            '',
        ),  # Test document with empty body
        (
            '---\nfoo: bar\nbaz: qux\n---\nThis is the body.',
            'foo: bar\nbaz: qux',
            'This is the body.',
        ),  # Test document with multiline frontmatter
        (
            'Just a body.',
            None,
            None,
        ),  # Test document with no frontmatter markers
        (
            '---\nfoo: bar\nThis is the body.',
            None,
            None,
        ),  # Test document with incomplete frontmatter markers
        (
            '---\nfoo: bar\n---\nThis is the body.\n---\nExtra section.',
            'foo: bar',
            'This is the body.\n---\nExtra section.',
        ),  # Test document with extra frontmatter markers
    ],
)
def test_frontmatter_and_body_regex(
    source: str,
    expected_frontmatter: str | None,
    expected_body: str | None,
) -> None:
    """Test frontmatter and body regex."""
    match = FRONTMATTER_AND_BODY_REGEX.match(source)

    if expected_frontmatter is None:
        assert match is None
    else:
        assert match is not None
        frontmatter, body = match.groups()
        assert frontmatter == expected_frontmatter
        assert body == expected_body


def test_role_and_history_marker_regex_valid_patterns() -> None:
    """Test valid patterns for role and history markers."""
    valid_patterns = [
        '<<<dotprompt:role:user>>>',
        '<<<dotprompt:role:model>>>',
        '<<<dotprompt:role:system>>>',
        '<<<dotprompt:history>>>',
        '<<<dotprompt:role:bot>>>',
        '<<<dotprompt:role:human>>>',
        '<<<dotprompt:role:customer>>>',
    ]

    for pattern in valid_patterns:
        assert ROLE_AND_HISTORY_MARKER_REGEX.search(pattern) is not None


def test_role_and_history_marker_regex_invalid_patterns() -> None:
    """Test invalid patterns for role and history markers."""
    invalid_patterns = [
        '<<<dotprompt:role:USER>>>',  # uppercase not allowed
        '<<<dotprompt:role:model1>>>',  # numbers not allowed
        '<<<dotprompt:role:>>>',  # needs at least one letter
        '<<<dotprompt:role>>>',  # missing role value
        '<<<dotprompt:history123>>>',  # history should be exact
        '<<<dotprompt:HISTORY>>>',  # history must be lowercase
        'dotprompt:role:user',  # missing brackets
        '<<<dotprompt:role:user',  # incomplete closing
        'dotprompt:role:user>>>',  # incomplete opening
    ]

    for pattern in invalid_patterns:
        assert ROLE_AND_HISTORY_MARKER_REGEX.search(pattern) is None


def test_role_and_history_marker_regex_multiple_matches() -> None:
    """Test multiple matches in a string."""
    text = """
        <<<dotprompt:role:user>>> Hello
        <<<dotprompt:role:model>>> Hi there
        <<<dotprompt:history>>>
        <<<dotprompt:role:user>>> How are you?
    """

    matches = ROLE_AND_HISTORY_MARKER_REGEX.findall(text)
    assert len(matches) == 4


def test_media_and_section_marker_regex_valid_patterns() -> None:
    """Test valid patterns for media and section markers."""
    valid_patterns = [
        '<<<dotprompt:media:url>>>',
        '<<<dotprompt:section>>>',
    ]

    for pattern in valid_patterns:
        assert MEDIA_AND_SECTION_MARKER_REGEX.search(pattern) is not None


def test_media_and_section_marker_regex_multiple_matches() -> None:
    """Test multiple matches in a string."""
    text = """
        <<<dotprompt:media:url>>> https://example.com/image.jpg
        <<<dotprompt:section>>> Section 1
        <<<dotprompt:media:url>>> https://example.com/video.mp4
        <<<dotprompt:section>>> Section 2
    """

    matches = MEDIA_AND_SECTION_MARKER_REGEX.findall(text)
    assert len(matches) == 4


def test_split_by_regex() -> None:
    """Test splitting by regex and filtering empty/whitespace pieces."""
    source = '  one  ,  ,  two  ,  three  '
    result = split_by_regex(source, re.compile(r','))
    assert result == ['  one  ', '  two  ', '  three  ']


class TestSplitByMediaAndSectionMarkers(unittest.TestCase):
    def test_split_by_media_and_section_markers(self) -> None:
        """Test splitting by media and section markers."""
        input_str = '<<<dotprompt:media:url>>> https://example.com/image.jpg'
        output = split_by_media_and_section_markers(input_str)
        assert output == [
            '<<<dotprompt:media:url',
            ' https://example.com/image.jpg',
        ]

    def test_split_by_media_and_section_markers_multiple_markers(self) -> None:
        """Test multiple markers in a string."""
        input_str = '<<<dotprompt:media:url>>> https://example.com/image.jpg'
        output = split_by_media_and_section_markers(input_str)
        assert output == [
            '<<<dotprompt:media:url',
            ' https://example.com/image.jpg',
        ]

    def test_split_by_media_and_section_markers_no_markers(self) -> None:
        """Test no markers in a string."""
        input_str = 'Hello World'
        output = split_by_media_and_section_markers(input_str)
        assert output == ['Hello World']


class TestSplitByRoleAndHistoryMarkers(unittest.TestCase):
    def test_no_markers(self) -> None:
        """Test splitting when no markers are present."""
        input_str = 'Hello World'
        output = split_by_role_and_history_markers(input_str)
        assert output == ['Hello World']

    def test_single_marker(self) -> None:
        """Test splitting with a single marker."""
        input_str = 'Hello <<<dotprompt:role:model>>> world'
        output = split_by_role_and_history_markers(input_str)
        assert output == ['Hello ', '<<<dotprompt:role:model', ' world']

    def test_split_by_role_and_history_markers_single_marker(self) -> None:
        """Test splitting with a single marker."""
        input_str = 'Hello <<<dotprompt:role:model>>> world'
        output = split_by_role_and_history_markers(input_str)
        assert output == ['Hello ', '<<<dotprompt:role:model', ' world']

    def test_split_by_role_and_history_markers_filter_empty(self) -> None:
        """Test filtering empty and whitespace-only pieces."""
        input_str = '  <<<dotprompt:role:system>>>   '
        output = split_by_role_and_history_markers(input_str)
        assert output == ['<<<dotprompt:role:system']

    def test_split_by_role_and_history_markers_adjacent_markers(self) -> None:
        """Test adjacent markers."""
        input_str = '<<<dotprompt:role:user>>><<<dotprompt:history>>>'
        output = split_by_role_and_history_markers(input_str)
        assert output == ['<<<dotprompt:role:user', '<<<dotprompt:history']

    def test_split_by_role_and_history_markers_invalid_format(self) -> None:
        """Test no split on markers with uppercase letters (invalid format)."""
        input_str = '<<<dotprompt:ROLE:user>>>'
        output = split_by_role_and_history_markers(input_str)
        assert output == ['<<<dotprompt:ROLE:user>>>']

    def test_split_by_role_and_history_markers_multiple_markers(self) -> None:
        """Test string with multiple markers interleaved with text."""
        input_str = 'Start <<<dotprompt:role:user>>> middle <<<dotprompt:history>>> end'
        output = split_by_role_and_history_markers(input_str)
        assert output == [
            'Start ',
            '<<<dotprompt:role:user',
            ' middle ',
            '<<<dotprompt:history',
            ' end',
        ]


class TestConvertNamespacedEntryToNestedObject(unittest.TestCase):
    """Test converting namespaced entries to nested objects."""

    def test_creating_nested_object(self) -> None:
        """Test creating nested object structure from namespaced key."""
        result = convert_namespaced_entry_to_nested_object('foo.bar', 'hello')
        self.assertEqual(
            result,
            {
                'foo': {
                    'bar': 'hello',
                },
            },
        )

    def test_adding_to_existing_namespace(self) -> None:
        """Test adding to existing namespace."""
        existing = {
            'foo': {
                'bar': 'hello',
            },
        }
        result = convert_namespaced_entry_to_nested_object('foo.baz', 'world', existing)
        self.assertEqual(
            result,
            {
                'foo': {
                    'bar': 'hello',
                    'baz': 'world',
                },
            },
        )

    def test_handling_multiple_namespaces(self) -> None:
        """Test handling multiple namespaces."""
        result = convert_namespaced_entry_to_nested_object('foo.bar', 'hello')
        final_result = convert_namespaced_entry_to_nested_object('baz.qux', 'world', result)
        self.assertEqual(
            final_result,
            {
                'foo': {
                    'bar': 'hello',
                },
                'baz': {
                    'qux': 'world',
                },
            },
        )


class TestExtractFrontmatterAndBody(unittest.TestCase):
    """Test extracting frontmatter and body from a string."""

    def test_should_extract_frontmatter_and_body(self) -> None:
        """Test extracting frontmatter and body when both are present."""
        input_str = '---\nfoo: bar\n---\nThis is the body.'
        frontmatter, body = extract_frontmatter_and_body(input_str)
        assert frontmatter == 'foo: bar'
        assert body == 'This is the body.'

    def test_should_extract_frontmatter_and_body_empty_frontmatter(
        self,
    ) -> None:
        """Test extracting frontmatter and body when both are present."""
        input_str = '---\n\n---\nThis is the body.'
        frontmatter, body = extract_frontmatter_and_body(input_str)
        assert frontmatter == ''
        assert body == 'This is the body.'

    def test_extract_frontmatter_and_body_no_frontmatter(self) -> None:
        """Test extracting body when no frontmatter is present.

        Both the frontmatter and body are empty strings, when there
        is no frontmatter marker.
        """
        input_str = 'Hello World'
        frontmatter, body = extract_frontmatter_and_body(input_str)
        assert frontmatter == ''
        assert body == ''


class TestTransformMessagesToHistory(unittest.TestCase):
    def test_add_history_metadata_to_messages(self) -> None:
        messages: list[Message] = [
            Message(role=Role.USER, content=[TextPart(text='Hello')]),
            Message(role=Role.MODEL, content=[TextPart(text='Hi there')]),
        ]

        result = transform_messages_to_history(messages)

        assert len(result) == 2
        assert result == [
            Message(
                role=Role.USER,
                content=[TextPart(text='Hello')],
                metadata={'purpose': 'history'},
            ),
            Message(
                role=Role.MODEL,
                content=[TextPart(text='Hi there')],
                metadata={'purpose': 'history'},
            ),
        ]

    def test_preserve_existing_metadata_while_adding_history_purpose(
        self,
    ) -> None:
        messages: list[Message] = [
            Message(
                role=Role.USER,
                content=[TextPart(text='Hello')],
                metadata={'foo': 'bar'},
            )
        ]

        result = transform_messages_to_history(messages)

        assert len(result) == 1
        assert result == [
            Message(
                role=Role.USER,
                content=[TextPart(text='Hello')],
                metadata={'foo': 'bar', 'purpose': 'history'},
            )
        ]

    def test_handle_empty_array(self) -> None:
        result = transform_messages_to_history([])
        assert result == []


class TestMessageSourcesToMessages(unittest.TestCase):
    def test_should_handle_empty_array(self) -> None:
        message_sources: list[MessageSource] = []
        expected: list[Message] = []
        assert message_sources_to_messages(message_sources) == expected

    def test_should_convert_a_single_message_source(self) -> None:
        message_sources: list[MessageSource] = [MessageSource(role=Role.USER, source='Hello')]
        expected: list[Message] = [Message(role=Role.USER, content=[TextPart(text='Hello')])]
        assert message_sources_to_messages(message_sources) == expected

    def test_should_handle_message_source_with_content(self) -> None:
        message_sources: list[MessageSource] = [
            MessageSource(role=Role.USER, content=[TextPart(text='Existing content')])
        ]
        expected: list[Message] = [Message(role=Role.USER, content=[TextPart(text='Existing content')])]
        assert message_sources_to_messages(message_sources) == expected

    def test_should_handle_message_source_with_metadata(self) -> None:
        message_sources: list[MessageSource] = [
            MessageSource(
                role=Role.USER,
                content=[TextPart(text='Existing content')],
                metadata={'foo': 'bar'},
            )
        ]
        expected: list[Message] = [
            Message(
                role=Role.USER,
                content=[TextPart(text='Existing content')],
                metadata={'foo': 'bar'},
            )
        ]
        assert message_sources_to_messages(message_sources) == expected

    def test_should_filter_out_message_sources_with_empty_source_and_content(
        self,
    ) -> None:
        message_sources: list[MessageSource] = [
            MessageSource(role=Role.USER, source=''),
            MessageSource(role=Role.MODEL, source='  '),
            MessageSource(role=Role.USER, source='Hello'),
        ]
        expected: list[Message] = [
            Message(role=Role.MODEL, content=[]),
            Message(role=Role.USER, content=[TextPart(text='Hello')]),
        ]
        assert message_sources_to_messages(message_sources) == expected

    def test_should_handle_multiple_message_sources(self) -> None:
        message_sources: list[MessageSource] = [
            MessageSource(role=Role.USER, source='Hello'),
            MessageSource(role=Role.MODEL, source='Hi there'),
            MessageSource(role=Role.USER, source='How are you?'),
        ]
        expected: list[Message] = [
            Message(role=Role.USER, content=[TextPart(text='Hello')]),
            Message(role=Role.MODEL, content=[TextPart(text='Hi there')]),
            Message(role=Role.USER, content=[TextPart(text='How are you?')]),
        ]
        assert message_sources_to_messages(message_sources) == expected


class TestMessagesHaveHistory(unittest.TestCase):
    def test_should_return_true_if_messages_have_history_metadata(self) -> None:
        messages: list[Message] = [
            Message(
                role=Role.USER,
                content=[TextPart(text='Hello')],
                metadata={'purpose': 'history'},
            )
        ]

        result = messages_have_history(messages)

        self.assertTrue(result)

    def test_should_return_false_if_messages_do_not_have_history_metadata(
        self,
    ) -> None:
        messages: list[Message] = [Message(role=Role.USER, content=[TextPart(text='Hello')])]

        result = messages_have_history(messages)

        self.assertFalse(result)


class TestInsertHistory(unittest.TestCase):
    def test_should_return_original_messages_if_history_is_undefined(
        self,
    ) -> None:
        messages: list[Message] = [Message(role=Role.USER, content=[TextPart(text='Hello')])]

        result = insert_history(messages, [])

        assert result == messages

    def test_should_return_original_messages_if_history_purpose_already_exists(
        self,
    ) -> None:
        messages: list[Message] = [
            Message(
                role=Role.USER,
                content=[TextPart(text='Hello')],
                metadata={'purpose': 'history'},
            )
        ]

        history: list[Message] = [
            Message(
                role=Role.MODEL,
                content=[TextPart(text='Previous')],
                metadata={'purpose': 'history'},
            )
        ]

        result = insert_history(messages, history)

        assert result == messages

    def test_should_insert_history_before_the_last_user_message(self) -> None:
        messages: list[Message] = [
            Message(role=Role.SYSTEM, content=[TextPart(text='System prompt')]),
            Message(role=Role.USER, content=[TextPart(text='Current question')]),
        ]

        history: list[Message] = [
            Message(
                role=Role.MODEL,
                content=[TextPart(text='Previous')],
                metadata={'purpose': 'history'},
            )
        ]

        result = insert_history(messages, history)

        assert len(result) == 3
        assert result == [
            Message(role=Role.SYSTEM, content=[TextPart(text='System prompt')]),
            Message(
                role=Role.MODEL,
                content=[TextPart(text='Previous')],
                metadata={'purpose': 'history'},
            ),
            Message(role=Role.USER, content=[TextPart(text='Current question')]),
        ]

    def test_should_append_history_at_the_end_if_no_user_message_is_last(
        self,
    ) -> None:
        messages: list[Message] = [
            Message(role=Role.SYSTEM, content=[TextPart(text='System prompt')]),
            Message(role=Role.MODEL, content=[TextPart(text='Model message')]),
        ]
        history: list[Message] = [
            Message(
                role=Role.MODEL,
                content=[TextPart(text='Previous')],
                metadata={'purpose': 'history'},
            )
        ]

        result = insert_history(messages, history)

        assert len(result) == 3
        assert result == [
            Message(role=Role.SYSTEM, content=[TextPart(text='System prompt')]),
            Message(role=Role.MODEL, content=[TextPart(text='Model message')]),
            Message(
                role=Role.MODEL,
                content=[TextPart(text='Previous')],
                metadata={'purpose': 'history'},
            ),
        ]


@pytest.mark.parametrize(
    'piece,expected',
    [
        (
            'Hello World',
            TextPart(text='Hello World'),
        ),
        (
            '<<<dotprompt:media:url>>> https://example.com/image.jpg',
            MediaPart(
                media=MediaContent(
                    url='https://example.com/image.jpg',
                )
            ),
        ),
        (
            '<<<dotprompt:media:url>>> https://example.com/image.jpg' + ' image/jpeg',
            MediaPart(
                media=MediaContent(
                    url='https://example.com/image.jpg',
                    content_type='image/jpeg',
                ),
            ),
        ),
        (
            'https://example.com/image.jpg',
            TextPart(text='https://example.com/image.jpg'),
        ),
        (
            '<<<dotprompt:section>>> code',
            PendingPart(
                metadata=PendingMetadata.with_purpose('code'),
            ),
        ),
        (
            ('Text before <<<dotprompt:media:url>>>' + ' https://example.com/image.jpg Text after'),
            TextPart(text=('Text before <<<dotprompt:media:url>>> ' + 'https://example.com/image.jpg Text after')),
        ),
    ],
)
def test_parse_part(piece: str, expected: Part) -> None:
    """Test parsing pieces."""
    result = parse_part(piece)
    assert result == expected


def test_parse_media_piece() -> None:
    """Test parsing media pieces."""
    piece = '<<<dotprompt:media:url>>> https://example.com/image.jpg'
    result = parse_media_part(piece)
    assert result == MediaPart(media=MediaContent(url='https://example.com/image.jpg'))


def test_parse_media_piece_invalid() -> None:
    """Test parsing invalid media pieces."""
    piece = 'https://example.com/image.jpg'
    with pytest.raises(ValueError):
        parse_media_part(piece)


def test_parse_section_piece() -> None:
    """Test parsing section pieces."""
    piece = '<<<dotprompt:section>>> code'
    pending_metadata = PendingMetadata.with_purpose('code')
    result = parse_section_part(piece)
    assert result == PendingPart(metadata=pending_metadata)


def test_parse_section_piece_invalid() -> None:
    """Test parsing invalid section pieces."""
    piece = 'https://example.com/image.jpg'
    with pytest.raises(ValueError):
        parse_section_part(piece)


def test_parse_text_piece() -> None:
    """Test parsing text pieces."""
    piece = 'Hello World'
    result = parse_text_part(piece)
    assert result == TextPart(text='Hello World')


class TestParseDocument(unittest.TestCase):
    def test_parse_document_with_frontmatter_and_template(self) -> None:
        """Test parsing document with frontmatter and template."""
        source = """---
name: test
description: test description
foo.bar: value
---
Template content"""

        result: ParsedPrompt[dict[str, str]] = parse_document(source)

        self.assertIsInstance(result, ParsedPrompt)
        self.assertEqual(result.name, 'test')
        self.assertEqual(result.description, 'test description')
        if result.ext:
            self.assertEqual(result.ext['foo']['bar'], 'value')
        self.assertEqual(result.template, 'Template content')

        if result.raw:
            self.assertEqual(result.raw['name'], 'test')
            self.assertEqual(result.raw['description'], 'test description')
            self.assertEqual(result.raw['foo.bar'], 'value')

    def test_handle_document_without_frontmatter(self) -> None:
        """Test handling document without frontmatter."""
        source = 'Just template content'

        result: ParsedPrompt[dict[str, str]] = parse_document(source)

        self.assertIsInstance(result, ParsedPrompt)
        self.assertEqual(result.ext, {})
        self.assertEqual(result.template, 'Just template content')

    def test_handle_invalid_yaml_frontmatter(self) -> None:
        """Test handling invalid YAML frontmatter."""
        source = """---
invalid: : yaml
---
Template content"""

        result: ParsedPrompt[dict[str, str]] = parse_document(source, strict=False)

        self.assertIsInstance(result, ParsedPrompt)

        self.assertEqual(result.ext, {})
        self.assertEqual(result.template, source.strip())

    def test_handle_empty_frontmatter(self) -> None:
        """Test handling empty frontmatter."""
        source = """---
---
Template content"""

        result: ParsedPrompt[dict[str, str]] = parse_document(source)

        self.assertIsInstance(result, ParsedPrompt)

        self.assertEqual(result.ext, {})

        # TODO(#495): Check whether this is the correct behavior.
        self.assertEqual(result.template, source.strip())

    def test_handle_multiple_namespaced_entries(self) -> None:
        """Test handling multiple namespaced entries."""
        source = """---
foo.bar: value1
foo.baz: value2
qux.quux: value3
---
Template content"""

        result: ParsedPrompt[dict[str, str]] = parse_document(source)

        self.assertIsInstance(result, ParsedPrompt)

        if result.ext:
            self.assertEqual(result.ext['foo']['bar'], 'value1')
            self.assertEqual(result.ext['foo']['baz'], 'value2')
            self.assertEqual(result.ext['qux']['quux'], 'value3')

    def test_handle_reserved_keywords(self) -> None:
        """Test handling reserved keywords."""
        frontmatter_parts = []
        for keyword in RESERVED_METADATA_KEYWORDS:
            if keyword == 'ext':
                continue
            frontmatter_parts.append(f'{keyword}: value-{keyword}')

        source = '---\n' + '\n'.join(frontmatter_parts) + '\n---\nTemplate content'

        result: ParsedPrompt[dict[str, str]] = parse_document(source, strict=False)

        self.assertIsInstance(result, ParsedPrompt)

        # for keyword in RESERVED_METADATA_KEYWORDS:
        #    if keyword == 'ext':
        #        continue
        #    self.assertEqual(getattr(result, keyword), f'value-{keyword}')

        self.assertEqual(result.template, 'Template content')

    def test_handle_license_header_before_frontmatter(self) -> None:
        """Test handling license header before frontmatter."""
        source = """# Copyright 2025 Google LLC
# License: Apache 2.0
---
model: gemini-pro
---
Hello!"""

        result: ParsedPrompt[dict[str, str]] = parse_document(source)

        self.assertIsInstance(result, ParsedPrompt)
        self.assertIsNotNone(result.raw)
        assert result.raw is not None  # Type narrowing for pyrefly
        self.assertEqual(result.raw.get('model'), 'gemini-pro')
        self.assertEqual(result.template, 'Hello!')

    def test_handle_shebang_before_frontmatter(self) -> None:
        """Test handling shebang before frontmatter."""
        source = """#!/usr/bin/env promptly
---
model: gemini-flash
---
Hello shebang!"""

        result: ParsedPrompt[dict[str, str]] = parse_document(source)

        self.assertIsInstance(result, ParsedPrompt)
        self.assertIsNotNone(result.raw)
        assert result.raw is not None  # Type narrowing for pyrefly
        self.assertEqual(result.raw.get('model'), 'gemini-flash')
        self.assertEqual(result.template, 'Hello shebang!')

    def test_handle_shebang_and_license_header_before_frontmatter(self) -> None:
        """Test handling shebang and license header before frontmatter."""
        source = """#!/usr/bin/env promptly
# Copyright 2025 Google
# SPDX: Apache-2.0
---
model: gemini-2.5-flash
---
Hello combined!"""

        result: ParsedPrompt[dict[str, str]] = parse_document(source)

        self.assertIsInstance(result, ParsedPrompt)
        self.assertIsNotNone(result.raw)
        assert result.raw is not None  # Type narrowing for pyrefly
        self.assertEqual(result.raw.get('model'), 'gemini-2.5-flash')
        self.assertEqual(result.template, 'Hello combined!')


class TestAdapterFrontmatter(unittest.TestCase):
    """Test parsing the adapter field from frontmatter."""

    def test_adapter_string(self) -> None:
        source = """\
---
model: gpt-4o
adapter: openai
---
Hello"""
        result = parse_document(source)
        assert result.adapter == 'openai'

    def test_adapter_dict(self) -> None:
        source = """\
---
model: deepseek-chat
adapter:
  name: openai
  group: azure-east
---
Hello"""
        result = parse_document(source)
        assert result.adapter is not None
        # Pydantic should coerce the dict to AdapterConfig.
        from dotpromptz.typing import AdapterConfig

        assert isinstance(result.adapter, AdapterConfig)
        assert result.adapter.name == 'openai'
        assert result.adapter.group == 'azure-east'

    def test_no_adapter(self) -> None:
        """When only top-level 'model' is set, adapter is NOT auto-populated anymore."""
        source = """\
---
model: gpt-4o
---
Hello"""
        result = parse_document(source)
        assert result.adapter is None
        assert result.raw is not None
        assert result.raw.get('model') == 'gpt-4o'

    def test_adapter_preserved_in_raw(self) -> None:
        source = """\
---
model: gpt-4o
adapter: anthropic
---
Hello"""
        result = parse_document(source)
        assert result.raw is not None
        assert result.raw.get('adapter') == 'anthropic'


class TestParseDocumentValidationError:
    """Test that parse_document re-raises ValidationError from Pydantic."""

    def test_schema_with_non_json_yaml_format_raises(self) -> None:
        """schema with format='txt' must raise ValidationError."""
        source = """\
---
output:
  format: txt
  schema:
    type: object
---
Generate something."""
        with pytest.raises(ValidationError, match='schema'):
            parse_document(source)

    def test_image_format_parses_ok(self) -> None:
        """format: image should parse successfully."""
        source = """\
---
output:
  format: image
---
Generate an image of a cat."""
        result = parse_document(source)
        assert result.output is not None
        assert result.output.format == 'image'
        assert result.template == 'Generate an image of a cat.'


class TestParseDocumentRuntime:
    """Test parsing the runtime field from frontmatter."""

    def test_parse_with_runtime_max_workers(self) -> None:
        """Test parsing .prompt with runtime metadata containing max_workers."""
        source = """\
---
adapter: openai
config:
  model: gpt-4o
runtime:
  max_workers: 3
output:
  format: json
  output_dir: out/
---
Generate a response."""
        result = parse_document(source)
        assert result.runtime is not None
        assert result.runtime.max_workers == 3
        assert result.output is not None
        assert result.output.output_dir == 'out/'
        assert result.output.format == 'json'
        # Verify runtime is NOT in ext dict (it's handled as reserved keyword)
        assert 'runtime' not in result.ext

    def test_parse_without_runtime(self) -> None:
        """Test parsing .prompt without runtime field results in None."""
        source = """\
---
adapter: openai
config:
  model: gpt-4o
---
Generate a response."""
        result = parse_document(source)
        assert result.runtime is None


class TestBuildInputConfig(unittest.TestCase):
    """Tests for the _build_input_config helper."""

    def test_none_returns_none(self) -> None:
        assert _build_input_config(None) is None

    def test_legacy_dict_wrapped_as_data(self) -> None:
        result = _build_input_config({'name': 'Alice'})
        assert result == {'data': {'name': 'Alice'}}

    def test_legacy_list_wrapped_as_data(self) -> None:
        result = _build_input_config([{'name': 'Alice'}])
        assert result == {'data': [{'name': 'Alice'}]}

    def test_legacy_string_wrapped_as_data(self) -> None:
        result = _build_input_config('data.json')
        assert result == {'data': 'data.json'}

    def test_legacy_list_of_strings_wrapped_as_data(self) -> None:
        result = _build_input_config(['a.json', 'b.json'])
        assert result == {'data': ['a.json', 'b.json']}

    def test_new_format_with_defaults_and_data(self) -> None:
        raw = {'defaults': {'lang': 'en'}, 'data': [{'name': 'Alice'}]}
        result = _build_input_config(raw)
        assert result == {'defaults': {'lang': 'en'}, 'data': [{'name': 'Alice'}]}

    def test_new_format_defaults_only(self) -> None:
        raw = {'defaults': {'lang': 'en'}}
        result = _build_input_config(raw)
        assert result == {'defaults': {'lang': 'en'}, 'data': None}

    def test_new_format_defaults_with_dict_data(self) -> None:
        raw = {'defaults': {'tone': 'formal'}, 'data': {'name': 'Bob'}}
        result = _build_input_config(raw)
        assert result == {'defaults': {'tone': 'formal'}, 'data': {'name': 'Bob'}}

    def test_new_format_defaults_with_string_data(self) -> None:
        raw = {'defaults': {'tone': 'formal'}, 'data': 'input.json'}
        result = _build_input_config(raw)
        assert result == {'defaults': {'tone': 'formal'}, 'data': 'input.json'}


class TestParseDocumentDefaults(unittest.TestCase):
    """Tests for parse_document with the new defaults input format."""

    def test_parse_with_defaults_and_data(self) -> None:
        source = '---\ninput:\n  defaults:\n    lang: en\n  data:\n    name: Alice\n---\nHello {{name}}'
        result = parse_document(source)
        assert result.input is not None
        assert result.input.defaults == {'lang': 'en'}
        assert result.input.data == {'name': 'Alice'}
        assert result.template == 'Hello {{name}}'

    def test_parse_with_defaults_only(self) -> None:
        source = '---\ninput:\n  defaults:\n    lang: en\n    tone: formal\n---\nHello'
        result = parse_document(source)
        assert result.input is not None
        assert result.input.defaults == {'lang': 'en', 'tone': 'formal'}
        assert result.input.data is None
        assert result.template == 'Hello'

    def test_parse_with_defaults_and_batch_data(self) -> None:
        source = (
            '---\ninput:\n  defaults:\n    lang: en\n  data:\n    - {name: Alice}\n    - {name: Bob}\n---\nHi {{name}}'
        )
        result = parse_document(source)
        assert result.input is not None
        assert result.input.defaults == {'lang': 'en'}
        assert result.input.data == [{'name': 'Alice'}, {'name': 'Bob'}]
        assert result.template == 'Hi {{name}}'

    def test_parse_legacy_dict_input_unchanged(self) -> None:
        """Ensure legacy dict input (no defaults key) still works."""
        source = '---\ninput:\n  name: Alice\n  age: 30\n---\nHello {{name}}'
        result = parse_document(source)
        assert result.input is not None
        assert result.input.defaults is None
        assert result.input.data == {'name': 'Alice', 'age': 30}
        assert result.template == 'Hello {{name}}'

    def test_parse_legacy_list_input_unchanged(self) -> None:
        """Ensure legacy list input still works."""
        source = '---\ninput:\n  - {name: Alice}\n  - {name: Bob}\n---\nHello {{name}}'
        result = parse_document(source)
        assert result.input is not None
        assert result.input.defaults is None
        assert result.input.data == [{'name': 'Alice'}, {'name': 'Bob'}]
        assert result.template == 'Hello {{name}}'

    def test_parse_legacy_string_input_unchanged(self) -> None:
        """Ensure legacy string input still works."""
        source = '---\ninput: data.json\n---\nHello'
        result = parse_document(source)
        assert result.input is not None
        assert result.input.defaults is None
        assert result.input.data == 'data.json'
        assert result.template == 'Hello'


class TestRenderFrontmatterValues:
    """TDD tests for _render_frontmatter_values() — GREEN after Task 2 implementation."""

    def _get_func(self):
        from dotpromptz.parse import _render_frontmatter_values  # noqa: PLC0415

        return _render_frontmatter_values

    def test_render_frontmatter_values_frontmatter_template_simple_substitution(self) -> None:
        """Simple {{var}} substitution in a nested config dict."""
        fn = self._get_func()
        data = {'config': {'model': '{{model_name}}'}}
        result = fn(data, {'model_name': 'gpt-4o'})
        assert result['config']['model'] == 'gpt-4o'

    def test_render_frontmatter_values_frontmatter_template_excludes_input_key(self) -> None:
        """The 'input' key must NOT be rendered even if it contains {{}}."""
        fn = self._get_func()
        data = {
            'input': {'topic': '{{var}}'},
            'config': {'model': '{{m}}'},
        }
        result = fn(data, {'var': 'AI', 'm': 'gpt-4'})
        assert result['input']['topic'] == '{{var}}'  # unchanged
        assert result['config']['model'] == 'gpt-4'  # rendered

    def test_render_frontmatter_values_frontmatter_template_missing_variable_empty(self) -> None:
        """Undefined variables render to empty string (Handlebars default)."""
        fn = self._get_func()
        data = {'config': {'model': '{{missing_var}}'}}
        result = fn(data, {})
        assert result['config']['model'] == ''

    def test_render_frontmatter_values_frontmatter_template_non_string_untouched(self) -> None:
        """Non-string leaf values (int, bool, None, list) pass through unchanged."""
        fn = self._get_func()
        data = {'config': {'max_tokens': 1000, 'enabled': True, 'ratio': 0.5, 'tags': ['a', 'b'], 'nothing': None}}
        result = fn(data, {'max_tokens': 999})
        assert result['config']['max_tokens'] == 1000
        assert result['config']['enabled'] is True
        assert result['config']['ratio'] == 0.5
        assert result['config']['tags'] == ['a', 'b']
        assert result['config']['nothing'] is None

    def test_render_frontmatter_values_frontmatter_template_nested_dict(self) -> None:
        """Nested dicts are traversed and string leaves are rendered."""
        fn = self._get_func()
        data = {'adapter': {'name': 'openai', 'base_url': '{{base_url}}'}}
        result = fn(data, {'base_url': 'https://api.example.com'})
        assert result['adapter']['name'] == 'openai'
        assert result['adapter']['base_url'] == 'https://api.example.com'

    def test_render_frontmatter_values_frontmatter_template_empty_variables(self) -> None:
        """Empty variables dict leaves template strings with empty substitutions."""
        fn = self._get_func()
        data = {'config': {'model': '{{model}}'}}
        result = fn(data, {})
        assert result['config']['model'] == ''

    def test_render_frontmatter_values_frontmatter_template_mixed_types(self) -> None:
        """Dict with strings, ints, bools, None — only strings are template-rendered."""
        fn = self._get_func()
        data = {
            'config': {'model': '{{m}}', 'tokens': 500, 'flag': False},
            'name': '{{prompt_name}}',
        }
        result = fn(data, {'m': 'claude-3', 'prompt_name': 'my-prompt'})
        assert result['config']['model'] == 'claude-3'
        assert result['config']['tokens'] == 500
        assert result['config']['flag'] is False
        assert result['name'] == 'my-prompt'


class TestParseDocumentFrontmatterTemplate:
    """TDD tests for parse_document() with new variables parameter."""

    def test_parse_document_frontmatter_template_basic(self) -> None:
        """parse_document with variables renders config.model from template."""
        source = '---\nconfig:\n  model: "{{model}}"\n---\nHello world.'
        result = parse_document(source, variables={'model': 'gpt-4o'})
        assert result.config is not None
        assert result.config['model'] == 'gpt-4o'

    def test_parse_document_frontmatter_template_input_not_rendered(self) -> None:
        """parse_document must NOT render {{}} inside the 'input' field."""
        source = '---\ninput:\n  topic: "{{var}}"\nconfig:\n  model: "{{m}}"\n---\nHello {{topic}}.'
        result = parse_document(source, variables={'var': 'AI', 'm': 'gpt-4'})
        assert result.config is not None
        assert result.config['model'] == 'gpt-4'
        assert result.input is not None
        assert result.input.data['topic'] == '{{var}}'

    def test_parse_document_frontmatter_template_no_variables_regression(self) -> None:
        """parse_document without variables behaves identically to current behavior."""
        source = '---\nconfig:\n  model: gpt-4o\n---\nHello world.'
        result = parse_document(source)
        assert result.config is not None
        assert result.config['model'] == 'gpt-4o'
        assert result.template == 'Hello world.'


class TestUnrecognizedFrontmatterWarning:
    """Tests for warnings on unrecognized frontmatter fields."""

    def test_unrecognized_field_produces_warning(self) -> None:
        """Unrecognized fields that go to ext should produce a warning."""
        source = '---\nmy_custom: hello\n---\nBody'
        result = parse_document(source)
        assert result.ext == {'my_custom': 'hello'}
        assert any('my_custom' in w for w in result.warnings)

    def test_multiple_unrecognized_fields_produce_warning(self) -> None:
        """Multiple unrecognized fields should all be listed in the warning."""
        source = '---\nalpha: 1\nbeta: 2\n---\nBody'
        result = parse_document(source)
        assert 'alpha' in result.ext
        assert 'beta' in result.ext
        warning_text = ' '.join(result.warnings)
        assert 'alpha' in warning_text
        assert 'beta' in warning_text

    def test_reserved_fields_do_not_trigger_warning(self) -> None:
        """Reserved keywords should not produce unrecognized-field warnings."""
        source = '---\nadapter: openai\nconfig:\n  model: gpt-4o\n---\nBody'
        result = parse_document(source)
        assert not any('Unrecognized' in w for w in result.warnings)

    def test_name_and_variant_trigger_warning(self) -> None:
        """name and variant are deprecated and should trigger warnings."""
        source = '---\nname: test\nvariant: v1\n---\nBody'
        result = parse_document(source)
        warning_text = ' '.join(result.warnings)
        assert 'name' in warning_text
        assert 'variant' in warning_text

    def test_namespaced_ext_fields_produce_warning(self) -> None:
        """Namespaced fields (foo.bar) that go to ext should produce a warning."""
        source = '---\ncustom.setting: value\n---\nBody'
        result = parse_document(source)
        assert result.ext.get('custom', {}).get('setting') == 'value'
        assert any('custom' in w for w in result.warnings)

    def test_no_frontmatter_no_warning(self) -> None:
        """Document without frontmatter should not produce any warning."""
        result = parse_document('Just a body.')
        assert result.warnings == []
