"""Core Dotprompt class for compiling and rendering prompt templates.

Handles template parsing, metadata rendering, tool resolution, schema management,
and Jinja2 helper registration.
"""

import json
from collections.abc import Callable
from datetime import datetime
from pathlib import Path
from typing import Any, Self

import jinja2
import structlog
from jinja2 import meta as jinja2_meta

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

# Auto-variable names that are injected by the engine and should be
# excluded from user-variable extraction.
AUTO_VAR_NAMES = frozenset({'NAME', 'TIME', 'SCHEMA', 'INDEX'})

logger = structlog.get_logger(__name__)


def _create_jinja_env() -> jinja2.Environment:
    """Create a pre-configured Jinja2 Environment for dotprompt rendering.

    The environment is configured with:
    - No auto-escaping (plain text prompts, not HTML).
    - ``jinja2.ChainableUndefined`` so undefined variables render as empty string,
    - Built-in helpers registered as globals.
    - Built-in filters registered.

    Returns:
        A ready-to-use ``jinja2.Environment``.
    """
    env = jinja2.Environment(
        autoescape=False,
        undefined=jinja2.ChainableUndefined,
        keep_trailing_newline=True,
        trim_blocks=True,
        lstrip_blocks=True,
    )
    env.globals.update(BUILTIN_HELPERS)
    env.filters.update(BUILTIN_FILTERS)
    return env


def _extract_template_variables(
    template: str,
    env: jinja2.Environment,
) -> set[str]:
    """Extract user-defined variable names referenced in a Jinja2 template.

    Uses ``jinja2.meta.find_undeclared_variables`` to parse the template AST
    and returns variable names that are NOT:
    - Built-in helpers / globals registered on *env*.
    - Auto-injected UPPERCASE variables (``NAME``, ``TIME``, etc.).

    Args:
        template: The Jinja2 template string.
        env: The Jinja2 Environment whose globals should be excluded.

    Returns:
        A set of top-level user variable names referenced in the template.
    """
    try:
        ast = env.parse(template)
    except jinja2.TemplateSyntaxError:
        return set()
    raw_vars = jinja2_meta.find_undeclared_variables(ast)
    # Filter out globals (helpers, filters exposed as globals) and auto-vars.
    excluded = set(env.globals.keys()) | AUTO_VAR_NAMES
    return {v for v in raw_vars if v not in excluded and not v.isupper()}


def _extract_frontmatter_template_variables(
    frontmatter: dict[str, Any],
    env: jinja2.Environment,
    *,
    exclude_keys: set[str] | None = None,
) -> set[str]:
    """Extract user-defined variable names referenced in frontmatter Jinja strings.

    This is used only for unused-variable warnings so that variables consumed by
    frontmatter templates (for example ``config.model: "{{ model_name }}"``) are
    not incorrectly reported as unused.

    Args:
        frontmatter: Parsed frontmatter mapping.
        env: The Jinja2 Environment whose globals should be excluded.
        exclude_keys: Top-level keys to skip entirely. Defaults to ``{"input"}``.

    Returns:
        A set of referenced user variable names.
    """
    if exclude_keys is None:
        exclude_keys = {'input'}

    def _walk(value: Any) -> set[str]:
        if isinstance(value, str):
            return _extract_template_variables(value, env)
        if isinstance(value, dict):
            names: set[str] = set()
            for nested_value in value.values():
                names.update(_walk(nested_value))
            return names
        if isinstance(value, list):
            names: set[str] = set()
            for item in value:
                names.update(_walk(item))
            return names
        return set()

    names: set[str] = set()
    for key, value in frontmatter.items():
        if key in exclude_keys:
            continue
        names.update(_walk(value))
    return names


def _analyze_prompt_variables(
    prompt: ParsedPrompt[ModelConfigT],
    env: jinja2.Environment,
) -> tuple[set[str], set[str]]:
    """Analyze prompt variable usage once at compile-time.

    Returns two variable sets:
    - Variables required by the body template (missing-check scope).
    - Variables referenced by frontmatter template strings (unused-check scope).

    Args:
        prompt: Parsed prompt after adaptation/metadata merging.
        env: Jinja2 environment used for variable extraction.

    Returns:
        A tuple ``(body_required_vars, frontmatter_used_vars)``.
    """
    body_required_vars = _extract_template_variables(prompt.template or '', env)
    frontmatter_used_vars = _extract_frontmatter_template_variables(
        prompt.model_dump(by_alias=True, exclude_none=True, exclude={'template', 'warnings'}),
        env,
    )
    return body_required_vars, frontmatter_used_vars


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

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

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

    Returns:
        The rendered string.
    """
    if '{{' not in template and '{%' not in template:
        return template
    env = jinja2.Environment(autoescape=False, undefined=jinja2.Undefined)
    return env.from_string(template).render(context)


def _render_frontmatter_values(
    data: dict,
    variables: dict,
    *,
    exclude_keys: set[str] | None = None,
) -> dict:
    """Recursively render Jinja2 templates in string leaf values of a dict.

    Only string scalar values are rendered; ints, bools, None, and lists of
    non-dicts pass through unchanged. Keys listed in *exclude_keys* (default:
    ``{"input"}``) are skipped entirely so that the ``input`` section is never
    template-substituted.

    Args:
        data: The frontmatter dict to traverse.
        variables: Template context — the same input dict used for body rendering.
        exclude_keys: Top-level keys to skip. Defaults to ``{"input"}``.

    Returns:
        A new dict with string leaves rendered.
    """
    if exclude_keys is None:
        exclude_keys = {'input'}

    env = jinja2.Environment(autoescape=False, undefined=jinja2.Undefined)

    def _render_value(value: Any) -> Any:  # noqa: ANN401
        if isinstance(value, str):
            return env.from_string(value).render(variables)
        if isinstance(value, dict):
            return {k: _render_value(v) for k, v in value.items()}
        return value  # int, bool, None, list, etc.

    return {k: (_render_value(v) if k not in exclude_keys else v) for k, v in data.items()}


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]


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)


# Fields that belong to ParsedPrompt but NOT PromptMetadata.
# They must be excluded whenever we dump a ParsedPrompt for
# PromptMetadata.model_validate(), which uses extra='forbid'.
_PARSED_PROMPT_EXTRA_FIELDS = {'template', 'warnings'}


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.
    # Exclude ParsedPrompt-specific fields that PromptMetadata rejects (extra='forbid').
    merge_dict = merge.model_dump(exclude=_PARSED_PROMPT_EXTRA_FIELDS, exclude_none=True, by_alias=True)
    current_dict = current.model_dump(exclude=_PARSED_PROMPT_EXTRA_FIELDS, 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)

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',
        env: jinja2.Environment,
        prompt: ParsedPrompt[ModelConfigT],
        prompt_file_path: Path | None = None,
        required_vars: set[str] | None = None,
        frontmatter_vars: set[str] | None = None,
    ):
        """Initialize the renderer.

        Args:
            dotprompt: The Dotprompt instance.
            env: The Jinja2 Environment.
            prompt: The parsed prompt.
            prompt_file_path: Optional path to the prompt file (for resolving relative input paths).
            required_vars: Body-template variables required at render time.
            frontmatter_vars: Variables referenced in frontmatter template strings.
        """
        self._dotprompt = dotprompt
        self._env = env
        self._prompt_file_path = prompt_file_path if prompt_file_path is not None else Path.cwd()

        self.prompt = prompt
        if required_vars is None or frontmatter_vars is None:
            required_vars, frontmatter_vars = _analyze_prompt_variables(prompt, env)
        self._required_vars = frozenset(required_vars)
        self._frontmatter_vars = frozenset(frontmatter_vars)
        self._all_used_vars = frozenset(self._required_vars | self._frontmatter_vars)

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

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

        Args:
            input: Caller-supplied input dict (may be ``None``).

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

        Raises:
            ValueError: If input resolution fails or frontmatter input resolves
                to multiple records.
        """
        has_caller_input = 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.files 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
            if input_cfg and input_cfg.files is not None:
                records = resolve_files(
                    input_cfg.files,
                    self._prompt_file_path,
                    defaults=defaults,
                )
            else:
                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:
            raise ValueError(
                'Batch input detected in frontmatter. '
                'Single render does not accept multiple records. '
                'Use run() or resolve_input() to iterate over records.'
            )

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

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

        Caller-provided values take precedence over frontmatter values.

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

        Returns:
            The merged context dict for Jinja2 rendering.
        """
        if input is None:
            return frontmatter_input
        return {**frontmatter_input, **input}

    def _re_render_frontmatter(
        self,
        active_prompt: ParsedPrompt[ModelConfigT],
        context: dict[str, Any],
    ) -> 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.model_dump(by_alias=True, exclude_none=True, exclude={'template', 'warnings'}),
            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.

        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,
        input: dict[str, Any] | None = None,
        options: PromptMetadata[ModelConfigT] | None = None,
        *,
        extra_auto_vars: dict[str, Any] | None = None,
    ) -> RenderedPrompt[ModelConfigT]:
        """Render the prompt.

        Args:
            input: Optional dict of template variables. 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(input)

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

        # 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 ''
        required_vars = set(self._required_vars)
        provided_vars = {k for k in context if not k.isupper() and k not in AUTO_VAR_NAMES}

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

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

        # 4b. Warn about 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),
                )

        # 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 via Jinja2.
        jinja_template = self._env.from_string(self.prompt.template or '')
        rendered_string = jinja_template.render(context)

        # 8. Parse the rendered string into messages.
        messages = to_messages(rendered_string)

        # 9. 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 wraps a Jinja2 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, Callable[..., 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,
    ) -> None:
        """Initialize Dotprompt with a Jinja2 template engine.

        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 (callables registered as Jinja2 globals).
            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.
        """
        self._env: jinja2.Environment = _create_jinja_env()

        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, Callable[..., str]] = helpers 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

        # Register built-in helpers (already done in _create_jinja_env, but
        # track them in _known_helpers).
        for name in BUILTIN_HELPERS:
            self._known_helpers[name] = True

        # Register custom helpers.
        if helpers:
            for name, fn in helpers.items():
                self.define_helper(name, fn)

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

        The function is registered as a Jinja2 global so it can be called
        directly in templates: ``{{ my_helper("arg") }}``.

        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._env.globals[name] = fn
        self._known_helpers[name] = True
        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) -> ParsedPrompt[ModelConfigT]:
        """Parse a prompt from a string.

        Args:
            source: The source code for the prompt.

        Returns:
            The parsed prompt.
        """
        return parse_document(source)

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

        Args:
            source: The source code for the prompt.
            input: Optional dict of template variables. 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.
            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,
        )
        return renderer(input, 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,
    ) -> 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).

        Returns:
            A function that can be used to render the prompt.
        """
        prompt: ParsedPrompt[ModelConfigT] = (
            self.parse(source) 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),
            })

        required_vars, frontmatter_vars = _analyze_prompt_variables(prompt, self._env)
        return RenderFunc(
            self,
            self._env,
            prompt,
            prompt_file_path,
            required_vars=required_vars,
            frontmatter_vars=frontmatter_vars,
        )

    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=_PARSED_PROMPT_EXTRA_FIELDS, 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
