"""Dotpromptz is a library for generating prompts using Handlebars templates.

This module provides the core Dotprompt class, which extends the Handlebars
template engine for use with generative AI prompts. It supports parsing
templates, rendering metadata, resolving tools and partials, and managing
schemas.

Key features include:

| Feature              | Description                                                                             |
|----------------------|-----------------------------------------------------------------------------------------|
| Template Parsing     | Parsing of templates with YAML frontmatter for metadata.                                |
| Metadata Rendering   | Rendering of prompt metadata, including merging and resolving tools and schemas.        |
| Tool Resolution      | Resolving tool names to tool definitions using a resolver or a static mapping.          |
| Partial Resolution   | Resolving partial template names to their content using a resolver or a static mapping. |
| Schema Management    | Handling of JSON schemas, including Picoschema conversion.                              |
| Helper Functions     | Registration and management of custom helper functions.                                 |
| Partial Templates    | Registration and management of partial templates.                                       |
| Model Configuration  | Support for default models and model-specific configurations.                           |
| Prompt Store         | Integration with a prompt store for loading prompts and partials.                       |
| Extensibility        | Designed to be extensible with custom helpers, resolvers, and stores.                   |
"""

import json
import re
from datetime import datetime
from pathlib import Path
from typing import Any, Self

import structlog
from handlebarrz import Context, EscapeFunction, Handlebars, HelperFn

from dotpromptz.helpers import BUILTIN_HELPERS
from dotpromptz.inputs import resolve_input
from dotpromptz.parse import _render_frontmatter_values, parse_document, to_messages
from dotpromptz.picoschema import picoschema_to_json_schema
from dotpromptz.resolvers import resolve_json_schema, resolve_partial, resolve_tool
from dotpromptz.typing import (
    AdapterConfig,
    DataArgument,
    JsonSchema,
    ModelConfigT,
    ParsedPrompt,
    PartialResolver,
    PromptFunction,
    PromptMetadata,
    PromptOutputConfig,
    RenderedPrompt,
    RuntimeConfig,
    SchemaResolver,
    ToolDefinition,
    ToolResolver,
    VariablesT,
)
from dotpromptz.version import adapt_prompt

# Pre-compiled regex for finding partial references in handlebars templates

# Since the handlebars-rust implementation doesn't expose a visitor
# to walk the AST to find partial nodes, we're using a crude regular expression
# to find partials.
_PARTIAL_PATTERN = re.compile(r'{{\s*>\s*([a-zA-Z0-9_.-]+)\s*}}')

# Known Handlebars block keywords that should NOT be treated as variables.
_BLOCK_KEYWORDS = frozenset({
    'if',
    'unless',
    'each',
    'with',
    'else',
    'log',
    'lookup',
    'let',
})

# Matches a single Handlebars mustache expression: {{ ... }}
# Captures everything between the outermost {{ and }}.
_MUSTACHE_PATTERN = re.compile(r'\{\{~?\s*(.+?)\s*~?\}\}')

logger = structlog.get_logger(__name__)


def _is_user_variable_ref(token: str) -> bool:
    """Return True if *token* looks like a user-provided variable reference.

    Filters out:
    - ``@``-prefixed tokens (Handlebars built-in loop vars: ``@index``, ``@first``, …)
    - UPPERCASE tokens (auto-injected vars: ``NAME``, ``TIME``, ``SCHEMA``, ``INDEX``)
    - ``this`` keyword
    - Numeric literals and boolean/null keywords
    - Quoted string literals

    Args:
        token: A single token extracted from a Handlebars expression.

    Returns:
        True if the token is a user-defined variable reference.
    """
    if not token:
        return False
    if token.startswith('@'):
        return False
    if token.startswith('"') or token.startswith("'"):
        return False
    if token.split('.')[0].isupper():
        return False
    if token == 'this':
        return False
    if token[0].isdigit():
        return False
    return token not in ('true', 'false', 'null')


def render_template_str(template: str, context: dict[str, Any]) -> str:
    """Render a simple Handlebars template string with the given context.

    Creates a bare ``Handlebars`` instance (no helpers, no partials) and
    renders *template* against *context*.  Used by CLI and runner modules
    for resolving ``output_dir`` and ``file_name`` templates.

    Args:
        template: A Handlebars template string.
        context: Variable mapping for the template.

    Returns:
        The rendered string.
    """
    if '{{' not in template:
        return template
    hbs = Handlebars(escape_fn=EscapeFunction.NO_ESCAPE)
    return hbs.compile(template)(context)


class TemplateMissingVariableError(Exception):
    """Raised when the template references variables not present in the data context."""

    def __init__(self, missing: set[str], template_snippet: str = '') -> None:
        """Initialize with missing variable names and optional template snippet."""
        self.missing = missing
        names = ', '.join(sorted(missing))
        msg = f'Template references undefined variable(s): {names}'
        if template_snippet:
            msg += f' (template starts with: {template_snippet!r})'
        super().__init__(msg)


def _extract_template_variables(
    template: str,
    known_helpers: set[str] | None = None,
) -> set[str]:
    """Extract user-defined variable names referenced in a Handlebars template.

    Parses ``{{ ... }}`` expressions and returns the set of top-level
    variable names (the first path segment for dotted paths like ``user.name``).

    Excludes:
    - Block helpers: ``{{#if ...}}``, ``{{/if}}``, ``{{else}}``
    - Partials: ``{{> name}}``
    - Comments: ``{{!-- ... --}}`` / ``{{! ... }}``
    - Auto-variables: ``{{SCHEMA}}``, ``{{INDEX}}``, etc. (UPPERCASE names)
    - Known helpers: ``{{role ...}}``, ``{{json ...}}``, ``{{media ...}}``, etc.
    - Loop built-ins: ``this``

    For helper hash parameters like ``{{media url=myVar}}``, the *value*
    side (``myVar``) is extracted as a variable reference.

    Args:
        template: The Handlebars template string.
        known_helpers: Names of registered helpers to exclude.

    Returns:
        A set of top-level variable names referenced in the template.
    """
    helpers = _BLOCK_KEYWORDS | (known_helpers or set())
    variables: set[str] = set()

    for match in _MUSTACHE_PATTERN.finditer(template):
        inner = match.group(1).strip()

        # Skip comments: {{! ... }} or {{!-- ... --}}
        if inner.startswith('!'):
            continue

        # Skip partials: {{> name ...}}
        if inner.startswith('>'):
            continue

        # Skip block open/close: {{#helper ...}}, {{/helper}}
        if inner.startswith('#') or inner.startswith('/'):
            # But extract variable arguments from block open expressions.
            # e.g. {{#if show}} → 'show', {{#each items}} → 'items'
            if inner.startswith('#'):
                parts = inner[1:].split()
                if parts:
                    helper_name = parts[0]
                    # Extract positional args (skip the helper name itself)
                    for arg in parts[1:]:
                        if '=' in arg:
                            # hash param: key=value
                            _val = arg.split('=', 1)[1]
                            if _is_user_variable_ref(_val):
                                variables.add(_val.split('.')[0])
                        elif _is_user_variable_ref(arg):
                            # Only extract args to known block helpers, not custom block helpers
                            if helper_name in _BLOCK_KEYWORDS:
                                variables.add(arg.split('.')[0])
            continue

        # Skip 'else'
        if inner == 'else':
            continue

        # Tokenize the expression: first token is the name/helper, rest are args.
        tokens = inner.split()
        if not tokens:
            continue

        first = tokens[0]

        # Skip non-user references (Handlebars builtins, auto-vars, 'this', etc.)
        if not _is_user_variable_ref(first):
            continue

        # If first token is a known helper, extract variable refs from its args.
        if first in helpers:
            for arg in tokens[1:]:
                if '=' in arg:
                    # hash param: key=value — extract the value side
                    _val = arg.split('=', 1)[1]
                    if _is_user_variable_ref(_val):
                        variables.add(_val.split('.')[0])
                elif _is_user_variable_ref(arg):
                    # Positional args that look like variable references
                    # But skip string literals passed to helpers like {{role "system"}}
                    variables.add(arg.split('.')[0])
            continue

        variables.add(first.split('.')[0])

    return variables


def _remove_undefined_fields[T](obj: T) -> T:
    """Recursively remove keys whose value is ``None`` from nested structures.

    Supports dicts, lists, and pass-through of primitives/``None``.

    Args:
        obj: A dict, list, primitive, or ``None``.

    Returns:
        A cleaned copy with all ``None``-valued dict entries stripped.
        Non-dict/list values are returned unchanged.
    """
    if obj is None or not isinstance(obj, dict | list):
        return obj  # type: ignore[return-value]
    if isinstance(obj, list):
        return [_remove_undefined_fields(item) for item in obj if item is not None]  # type: ignore[return-value]
    result: dict[str, Any] = {}
    for key, value in obj.items():
        if value is None:
            continue
        if isinstance(value, dict):
            result[key] = _remove_undefined_fields(value)
        elif isinstance(value, list):
            result[key] = [
                _remove_undefined_fields(item) if isinstance(item, dict) else item for item in value if item is not None
            ]
        else:
            result[key] = value
    return result  # type: ignore[return-value]


def _merge_metadata(
    current: PromptMetadata[ModelConfigT],
    merge: PromptMetadata[ModelConfigT],
) -> PromptMetadata[ModelConfigT]:
    """Merges a single metadata object into the current one.

    Args:
        current: The current metadata object.
        merge: The metadata object to merge into the current one.

    Returns:
        The merged metadata object.
    """
    # Convert Pydantic models to raw dicts by alias first. Skip None values.
    merge_dict = merge.model_dump(exclude_none=True, by_alias=True)
    current_dict = current.model_dump(exclude_none=True, by_alias=True)

    # Keep a reference to the original config.
    original_config = current_dict.get('config', {})
    new_config = merge_dict.get('config', {})

    # Merge the new metadata.
    current_dict.update(merge_dict)

    # Merge the configs and set the resulting config.
    current_dict['config'] = {**original_config, **new_config}

    # Recreate the Pydantic model from the merged dict and validate it.
    return PromptMetadata[ModelConfigT].model_validate(current_dict)


def _identify_partials(template: str) -> set[str]:
    """Identify all unique partial references in a template.

    Args:
        template: The template to scan for partial references.

    Returns:
        A set of partial names referenced in the template.
    """
    return set(_PARTIAL_PATTERN.findall(template))


class RenderFunc(PromptFunction[ModelConfigT]):
    """A compiled prompt function with the prompt as a property.

    This is the Python equivalent of the renderFunc nested function
    within the compile method of the Dotprompt class in TypeScript.

    It exposes the prompt property to the user.
    """

    def __init__(
        self,
        dotprompt: 'Dotprompt',
        handlebars: Handlebars,
        prompt: ParsedPrompt[ModelConfigT],
        prompt_file_path: Path | None = None,
    ):
        """Initialize the renderer.

        Args:
            dotprompt: The Dotprompt instance.
            handlebars: The Handlebars instance.
            prompt: The parsed prompt.
            prompt_file_path: Optional path to the prompt file (for resolving relative input paths).
        """
        self._dotprompt = dotprompt
        self._handlebars = handlebars
        self._prompt_file_path = prompt_file_path if prompt_file_path is not None else Path.cwd()

        self.prompt = prompt

    def _resolve_frontmatter_input(
        self,
        data: DataArgument[VariablesT] | None,
    ) -> dict[str, Any]:
        """Resolve input data from the prompt's frontmatter ``input`` config.

        If the caller already provides explicit ``data.input``, frontmatter
        resolution is skipped entirely.

        Args:
            data: Caller-supplied data argument (may be ``None``).

        Returns:
            A dict of resolved input variables from frontmatter, or ``{}``.

        Raises:
            ValueError: If input resolution fails.
        """
        has_caller_input = data is not None and data.input is not None
        input_cfg = self.prompt.input
        has_frontmatter_input = input_cfg is not None and (input_cfg.data is not None or input_cfg.defaults is not None)
        if has_caller_input or not has_frontmatter_input:
            return {}

        try:
            defaults = input_cfg.defaults if input_cfg else None
            records = resolve_input(
                input_cfg.data if input_cfg else None,
                self._prompt_file_path,
                defaults=defaults,
            )
        except ValueError as exc:
            raise ValueError(f'Input resolution failed: {exc}') from exc

        if len(records) > 1:
            logger.warning(
                'Batch input detected in frontmatter. '
                'Use run() or resolve_input() to iterate over records. '
                'Returning the first record for single rendering.',
            )
            return records[0]

        if records:
            return records[0]
        if defaults:
            return defaults
        return {}

    @staticmethod
    def _build_context(
        frontmatter_input: dict[str, Any],
        data: DataArgument[VariablesT] | None,
    ) -> Context:
        """Merge frontmatter input with caller-provided data.

        Caller-provided values take precedence over frontmatter values.

        Args:
            frontmatter_input: Resolved frontmatter input dict.
            data: Caller-supplied data argument.

        Returns:
            The merged context dict for Handlebars rendering.
        """
        if data is None:
            return frontmatter_input
        return {**frontmatter_input, **(data.input or {})}

    def _re_render_frontmatter(
        self,
        active_prompt: ParsedPrompt[ModelConfigT],
        context: Context,
    ) -> ParsedPrompt[ModelConfigT]:
        """Re-render ``{{var}}`` placeholders in frontmatter values.

        This allows dynamic references such as ``config.model: {{model_name}}``
        to be substituted with actual context values.

        Args:
            active_prompt: The current parsed prompt.
            context: The resolved template context.

        Returns:
            A (possibly updated) copy of *active_prompt*.
        """
        if not context:
            return active_prompt
        rendered_raw = _render_frontmatter_values(active_prompt.raw or {}, context)
        update: dict[str, Any] = {}
        for key in ('config', 'adapter', 'output', 'name', 'description'):
            if key in rendered_raw:
                update[key] = rendered_raw[key]
        if update:
            # Coerce raw dicts to their Pydantic model types so that
            # model_copy does not bypass field validation.
            if isinstance(update.get('output'), dict):
                update['output'] = PromptOutputConfig(**update['output'])
            if isinstance(update.get('adapter'), dict):
                update['adapter'] = AdapterConfig(**update['adapter'])
            if isinstance(update.get('runtime'), dict):
                update['runtime'] = RuntimeConfig(**update['runtime'])
            return active_prompt.model_copy(update=update)
        return active_prompt

    def _compute_auto_vars(self) -> dict[str, Any]:
        """Compute auto-injected UPPERCASE variables (``SCHEMA``, ``TIME``, ``NAME``).

        These are always injected into the template context.  The
        ``{{json}}`` helper automatically strips UPPERCASE keys so that
        ``{{json this}}`` only serializes user data.

        Returns:
            A dict of auto-variables (may be empty).
        """
        auto_vars: dict[str, Any] = {}

        # TIME: always injected.
        auto_vars['TIME'] = datetime.now().strftime('%Y_%m_%d_%H_%M_%S')

        # NAME: stem of the .prompt file (e.g. 'draw_cat' from 'draw_cat.prompt').
        prompt_path = self._prompt_file_path
        if prompt_path is not None:
            p = Path(prompt_path) if not isinstance(prompt_path, Path) else prompt_path
            if p.is_file():
                auto_vars['NAME'] = p.stem

        # SCHEMA: computed when output.schema is defined.
        output_cfg = self.prompt.output
        schema_val = None
        if output_cfg is not None:
            if isinstance(output_cfg, dict):
                schema_val = output_cfg.get('schema')
            else:
                schema_val = getattr(output_cfg, 'schema', None)
        if schema_val is not None:
            output_schema = picoschema_to_json_schema(
                schema_val,
                self._dotprompt._wrapped_schema_resolver,
            )
            if output_schema is not None:
                auto_vars['SCHEMA'] = json.dumps(output_schema, indent=2, ensure_ascii=False)

        return auto_vars

    def __call__(
        self,
        data: DataArgument[VariablesT] | None = None,
        options: PromptMetadata[ModelConfigT] | None = None,
        *,
        extra_auto_vars: dict[str, Any] | None = None,
    ) -> RenderedPrompt[ModelConfigT]:
        """Render the prompt.

        Args:
            data: Optional data to be used to render the prompt. If None, auto-constructs from frontmatter input.
            options: Additional options for the prompt.
            extra_auto_vars: Extra auto-injected variables (e.g. ``{'INDEX': '0'}``). Merged after
                ``_compute_auto_vars`` but before user context, so user keys take priority.

        Returns:
            The rendered prompt.
        """
        # 1. Resolve frontmatter input.
        frontmatter_input = self._resolve_frontmatter_input(data)

        # 2. Build context (merge frontmatter + caller data).
        context = self._build_context(frontmatter_input, data)

        # 3. Compute auto-variables and merge into context.
        #    Auto-vars go first so user-provided keys can override them.
        #    Done early so that frontmatter re-rendering can reference them.
        auto_vars = self._compute_auto_vars()
        if extra_auto_vars:
            auto_vars.update(extra_auto_vars)
        context = {**auto_vars, **context}

        # 4. Validate template variables against context keys.
        template = self.prompt.template or ''
        known_helpers = set(self._dotprompt._known_helpers.keys())
        required_vars = _extract_template_variables(template, known_helpers)
        provided_vars = {k for k in context if not k.isupper()}

        missing = required_vars - provided_vars
        if missing:
            snippet = template[:80]
            raise TemplateMissingVariableError(missing, snippet)

        extra = provided_vars - required_vars
        if extra:
            names = ', '.join(sorted(extra))
            logger.warning('Data contains unused variable(s) not referenced in template.', variables=names)

        # 4b. Warn about non-string or empty values for template variables.
        for var_name in required_vars & provided_vars:
            val = context[var_name]
            if val is None or (isinstance(val, (str, list, dict)) and not val):
                logger.warning(
                    'Template variable has an empty value.',
                    variable=var_name,
                    value=repr(val),
                )
            elif not isinstance(val, str):
                logger.warning(
                    'Template variable has non-string type.',
                    variable=var_name,
                    type=type(val).__name__,
                    value=repr(val),
                )

        # 5. Re-render frontmatter {{var}} placeholders with resolved context.
        #    Context now includes auto-vars, so {{NAME}}, {{TIME}} etc. work in frontmatter.
        active_prompt = self._re_render_frontmatter(self.prompt, context)

        # 6. Compute merged metadata.
        merged_metadata: PromptMetadata[ModelConfigT] = self._dotprompt.render_metadata(active_prompt, options)

        # 7. Render the template string.
        render_string = self._handlebars.compile(self.prompt.template)
        # Pass data.context via Handlebars RuntimeOptions.data for {{@state.x}} access.
        runtime_opts: dict[str, Any] = {}
        if data is not None and data.context:
            runtime_opts = {'data': data.context}
        rendered_string = render_string(context, runtime_opts) if runtime_opts else render_string(context)

        # 7. Parse the rendered string into messages.
        final_data = data if data is not None else DataArgument[VariablesT](input=frontmatter_input)
        messages = to_messages(rendered_string, final_data)

        # 8. Construct and return the final RenderedPrompt.
        return RenderedPrompt[ModelConfigT].model_validate({
            **merged_metadata.model_dump(exclude_none=True, by_alias=True),
            'messages': messages,
        })

class Dotprompt:
    """Dotprompt extends a Handlebars template for use with Gen AI prompts."""

    def __init__(
        self,
        default_model: str | None = None,
        model_configs: dict[str, Any] | None = None,
        helpers: dict[str, HelperFn] | None = None,
        partials: dict[str, str] | None = None,
        tools: dict[str, ToolDefinition] | None = None,
        tool_resolver: ToolResolver | None = None,
        schemas: dict[str, JsonSchema] | None = None,
        schema_resolver: SchemaResolver | None = None,
        partial_resolver: PartialResolver | None = None,
        escape_fn: EscapeFunction = EscapeFunction.NO_ESCAPE,
    ) -> None:
        """Initialize Dotprompt with a Handlebars template.

        Args:
            default_model: The default model to use for the prompt when not specified in the template.
            model_configs: Assign a set of default configuration options to be used with a particular model.
            helpers: Helpers to pre-register.
            partials: Partials to pre-register.
            tools: Provide a static mapping of tool definitions that should be used when resolving tool names.
            tool_resolver: Provide a lookup implementation to resolve tool names to definitions.
            schemas: Provide a static mapping of schema names to their JSON schema definitions.
            schema_resolver: resolver for schema names to JSON schema definitions.
            partial_resolver: resolver for partial names to their content.
            escape_fn: escape function to use for the template.
        """
        self._handlebars: Handlebars = Handlebars(escape_fn=escape_fn)

        self._known_helpers: dict[str, bool] = {}
        self._default_model: str | None = default_model
        self._model_configs: dict[str, Any] = model_configs or {}
        self._helpers: dict[str, HelperFn] = helpers or {}
        self._partials: dict[str, str] = partials or {}
        self._tools: dict[str, ToolDefinition] = tools or {}
        self._tool_resolver: ToolResolver | None = tool_resolver
        self._schemas: dict[str, JsonSchema] = schemas or {}
        self._schema_resolver: SchemaResolver | None = schema_resolver
        self._partial_resolver: PartialResolver | None = partial_resolver

        self._register_initial_helpers(
            builtin_helpers=BUILTIN_HELPERS,
            custom_helpers=helpers,
        )
        self._register_initial_partials(partials)

    def define_helper(self, name: str, fn: HelperFn) -> Self:
        """Define a helper function for the template.

        Args:
            name: The name of the helper function.
            fn: The function to be called when the helper is used in the
                template.

        Returns:
            The Dotprompt instance.
        """
        self._handlebars.register_helper(name, fn)
        self._known_helpers[name] = True
        return self

    def define_partial(self, name: str, source: str) -> Self:
        """Define a partial template for the template.

        Args:
            name: The name of the partial template.
            source: The source code for the partial.

        Returns:
            The Dotprompt instance.
        """
        self._handlebars.register_partial(name, source)
        return self

    def define_tool(self, definition: ToolDefinition) -> Self:
        """Define a tool for the template.

        Args:
            definition: The definition of the tool.

        Returns:
            The Dotprompt instance.
        """
        self._tools[definition.name] = definition
        return self

    def parse(self, source: str, *, variables: dict[str, Any] | None = None) -> ParsedPrompt[ModelConfigT]:
        """Parse a prompt from a string.

        Args:
            source: The source code for the prompt.
            variables: Optional variables for frontmatter template rendering.

        Returns:
            The parsed prompt.
        """
        return parse_document(source, variables=variables) if variables is not None else parse_document(source)

    def render(
        self,
        source: str,
        data: DataArgument[VariablesT] | None = None,
        options: PromptMetadata[ModelConfigT] | None = None,
        prompt_file_path: Path | None = None,
        variables: dict[str, Any] | None = None,
        *,
        extra_auto_vars: dict[str, Any] | None = None,
    ) -> RenderedPrompt[ModelConfigT]:
        """Render a prompt.

        Args:
            source: The source code for the prompt.
            data: Optional data to be used to render the prompt. If None, auto-constructs from frontmatter input.
            options: Additional options for the prompt.
            prompt_file_path: Optional path to the prompt file (for resolving relative input file paths).
                Defaults to cwd.
            variables: Optional variables for frontmatter template rendering.
            extra_auto_vars: Extra auto-injected variables forwarded to ``__call__``.

        Returns:
            The rendered prompt.
        """
        renderer: PromptFunction[ModelConfigT] = self.compile(
            source,
            prompt_file_path=prompt_file_path,
            variables=variables,
        )
        return renderer(data, options, extra_auto_vars=extra_auto_vars)

    def compile(
        self,
        source: str | ParsedPrompt[ModelConfigT],
        additional_metadata: PromptMetadata[ModelConfigT] | None = None,
        prompt_file_path: Path | None = None,
        variables: dict[str, Any] | None = None,
    ) -> PromptFunction[ModelConfigT]:
        """Compile a prompt.

        Args:
            source: The source code for the prompt, or an already-parsed
                ``ParsedPrompt`` object.
            additional_metadata: Additional metadata to be used to render the prompt.
            prompt_file_path: Optional path to the prompt file (for resolving relative input file paths).
            variables: Optional variables for frontmatter template rendering.

        Returns:
            A function that can be used to render the prompt.
        """
        prompt: ParsedPrompt[ModelConfigT] = (
            self.parse(source, variables=variables) if isinstance(source, str) else source
        )
        prompt = adapt_prompt(prompt)
        if additional_metadata is not None:
            prompt = type(prompt).model_validate({
                **prompt.model_dump(exclude_none=True, by_alias=True),
                **additional_metadata.model_dump(exclude_none=True, by_alias=True),
            })

        # Resolve partials before compiling.
        self._resolve_partials(prompt.template)
        return RenderFunc(self, self._handlebars, prompt, prompt_file_path)

    def render_metadata(
        self,
        source: str | ParsedPrompt[ModelConfigT],
        additional_metadata: PromptMetadata[ModelConfigT] | None = None,
    ) -> PromptMetadata[ModelConfigT]:
        """Render metadata for a prompt.

        Args:
            source: The source code for the prompt or a parsed prompt.
            additional_metadata: Additional metadata to be used to render the prompt.

        Returns:
            The rendered metadata.
        """
        prompt = self.parse(source) if isinstance(source, str) else source

        # Resolve the effective model name.
        # Priority: additional_metadata config.model > prompt config.model > default_model
        model = self._resolve_model_name(prompt, additional_metadata)

        config: ModelConfigT | None = None
        if model is not None and self._model_configs.get(model) is not None:
            config = self._model_configs.get(model)

        return self._resolve_metadata(
            PromptMetadata[ModelConfigT](
                config=config,
            )
            if config is not None
            else PromptMetadata[ModelConfigT](),
            prompt,
            additional_metadata,
        )

    @staticmethod
    def _resolve_model_name(
        prompt: ParsedPrompt[ModelConfigT],
        additional_metadata: PromptMetadata[ModelConfigT] | None = None,
    ) -> str | None:
        """Resolve the effective LLM model name.

        Priority (highest to lowest):
        1. ``additional_metadata.config['model']`` (runtime override)
        2. ``prompt.config['model']``               (frontmatter config.model)
        3. ``None``                                  (will fall back to Dotprompt._default_model externally)

        Args:
            prompt: The parsed prompt.
            additional_metadata: Optional runtime metadata override.

        Returns:
            The resolved model name, or ``None``.
        """

        def _config_model(meta: PromptMetadata[ModelConfigT] | None) -> str | None:
            if meta is None or meta.config is None:
                return None
            cfg = meta.config
            if isinstance(cfg, dict):
                return cfg.get('model')
            return None

        # Prefer config.model from additional_metadata, then from prompt.
        return _config_model(additional_metadata) or _config_model(prompt)

    def _resolve_metadata(
        self, base: PromptMetadata[ModelConfigT], *merges: PromptMetadata[ModelConfigT] | None
    ) -> PromptMetadata[ModelConfigT]:
        """Merges multiple metadata objects, resolving tools and schemas.

        Later metadata objects override earlier ones.

        Args:
            base: The base metadata object.
            merges: Additional metadata objects to merge into base.

        Returns:
            Merged metadata.
        """
        out = base.model_copy(deep=True)

        for merge in merges:
            if merge:
                out = _merge_metadata(out, merge)

        # If the merged result is a ParsedPrompt (has a 'template' field), strip it
        # so that RenderedPrompt / PromptMetadata consumers don't see it.
        # Use model_copy with exclude instead of delattr, which is unsafe on Pydantic models.
        if isinstance(out, ParsedPrompt):
            out = PromptMetadata[ModelConfigT].model_validate(
                out.model_dump(exclude={'template'}, exclude_none=True, by_alias=True)
            )

        # Strip None-valued fields from the merged metadata.
        cleaned = _remove_undefined_fields(out.model_dump(exclude_none=True, by_alias=True))
        out = PromptMetadata[ModelConfigT].model_validate(cleaned)
        # Sequential: _render_picoschema depends on _resolve_tools output.
        out = self._resolve_tools(out)
        out = self._render_picoschema(out)
        return out

    def _render_picoschema(self, meta: PromptMetadata[ModelConfigT]) -> PromptMetadata[ModelConfigT]:
        """Render a Picoschema prompt.

        Args:
            meta: The prompt metadata.

        Returns:
            The rendered prompt metadata.
        """
        needs_output_processing = meta.output is not None and meta.output.schema is not None

        if not needs_output_processing:
            return meta

        new_meta = meta.model_copy(deep=True)

        if needs_output_processing and meta.output is not None:
            new_meta.output.schema = picoschema_to_json_schema(
                meta.output.schema,
                self._wrapped_schema_resolver,
            )
        return new_meta

    def _wrapped_schema_resolver(self, name: str) -> JsonSchema | None:
        """Resolve a schema from either instance local mapping or the resolver.

        Args:
            name: The name of the schema to resolve.

        Returns:
            The resolved schema or None if it is not found.
        """
        if name in self._schemas:
            return self._schemas[name]

        if self._schema_resolver is None:
            return None

        # Cache resolved schemas to avoid redundant resolver calls.
        resolved = resolve_json_schema(name, self._schema_resolver)
        if resolved is not None:
            self._schemas[name] = resolved
        return resolved

    def _resolve_tools(self, metadata: PromptMetadata[ModelConfigT]) -> PromptMetadata[ModelConfigT]:
        """Resolve all tools in a prompt.

        Args:
            metadata: The prompt metadata.

        Returns:
            A copy of the prompt metadata with the tools resolved.

        Raises:
            ToolNotFoundError: If a tool is not found in the resolver.
            ToolResolverFailedError: If a tool resolver fails.
            TypeError: If a tool resolver returns an invalid type.
            ValueError: If a tool resolver is not defined.
        """
        out: PromptMetadata[ModelConfigT] = metadata.model_copy(deep=True)
        if out.tools is None:
            return out

        # Resolve tools that are already registered into toolDefs, leave
        # unregistered tools alone.
        unregistered_names: list[str] = []
        out.tool_defs = out.tool_defs or []

        # Collect all the tools:
        # 1. Already registered tools go into toolDefs.
        # 2. If we have a tool resolver, add the names to the list to resolve.
        # 3. Otherwise, add the names to the list of unregistered tools.
        to_resolve: list[str] = []
        have_resolver = self._tool_resolver is not None
        for name in out.tools:
            if name in self._tools:
                # Found locally.
                out.tool_defs.append(self._tools[name])
            elif have_resolver:
                # Resolve using the tool resolver.
                to_resolve.append(name)
            else:
                # Unregistered tool.
                unregistered_names.append(name)

        # Resolve all the tools using the resolver.
        for tool_name in to_resolve:
            tool = resolve_tool(tool_name, self._tool_resolver)
            if out.tool_defs is not None:
                out.tool_defs.append(tool)

        out.tools = unregistered_names
        return out

    def _resolve_partials(
        self,
        template: str,
        visited: set[str] | None = None,
    ) -> None:
        """Resolve all partials in a template.

        This method recursively resolves partials, meaning if a partial itself
        contains partial references, those will also be resolved. Cycle detection
        prevents infinite loops when partials reference each other.

        Args:
            template: The template to resolve partials in.
            visited: Set of partial names currently being processed (for cycle detection).

        Returns:
            None
        """
        if self._partial_resolver is None:
            return

        if visited is None:
            visited = set()

        names = _identify_partials(template)

        for name in names:
            if not self._handlebars.has_partial(name) and name not in visited:
                visited.add(name)
                content: str | None = None

                if self._partial_resolver is not None:
                    content = resolve_partial(name, self._partial_resolver)

                if content is not None:
                    self.define_partial(name, content)
                    # Recursively resolve partials in the content.
                    self._resolve_partials(content, visited)

    def _register_initial_helpers(
        self,
        builtin_helpers: dict[str, HelperFn] | None = None,
        custom_helpers: dict[str, HelperFn] | None = None,
    ) -> None:
        """Register the initial helpers.

        Built-in helpers are registered first, then custom helpers are
        registered.

        Args:
            builtin_helpers: Built-in helpers to register.
            custom_helpers: Custom helpers to register.
        """
        if builtin_helpers is not None:
            for name, fn in builtin_helpers.items():
                self.define_helper(name, fn)

        if custom_helpers is not None:
            for name, fn in custom_helpers.items():
                self.define_helper(name, fn)

    def _register_initial_partials(self, partials: dict[str, str] | None = None) -> None:
        """Register the initial partials.

        Args:
            partials: Partials to register.
        """
        if partials is not None:
            for name, source in partials.items():
                self.define_partial(name, source)
