# Copyright Visual Collaboration Technologies Inc. All Rights Reserved.
# See LICENSE for details.
"""Template manager — loads and renders Jinja2 templates.

The manager supports three template sources:

1. **Registry** — named templates registered as strings via :class:`TemplateRegistry`.
2. **Filesystem** — templates loaded from directories via Jinja2's FileSystemLoader.
3. **Inline** — raw template strings passed directly to ``render_string()``.

For large data, ``render_streaming()`` yields output fragments from an
iterable of context dicts, keeping memory usage bounded.

Example::

    from vcti.template import TemplateManager, TemplateRegistry

    registry = TemplateRegistry()
    registry.register("greeting", "Hello, {{ name }}!")

    tm = TemplateManager(registry=registry)
    tm.render("greeting", name="World")  # "Hello, World!"
"""

import logging
from collections.abc import Callable, Iterable, Iterator
from pathlib import Path
from typing import Any

from jinja2 import (
    Environment,
    FileSystemLoader,
    StrictUndefined,
    Template,
    TemplateNotFound,
    UndefinedError,
    select_autoescape,
)
from jinja2 import (
    TemplateSyntaxError as JinjaSyntaxError,
)

from .errors import TemplateNotFoundError, TemplateRenderError, TemplateSyntaxError
from .registry import TemplateRegistry

logger = logging.getLogger(__name__)


class TemplateManager:
    """Loads and renders Jinja2 templates from multiple sources.

    The manager tries sources in order: registry → filesystem → error.
    Inline strings bypass the lookup entirely via ``render_string()``.
    """

    def __init__(
        self,
        registry: TemplateRegistry | None = None,
        template_dirs: list[str | Path] | None = None,
        *,
        autoescape_extensions: list[str] | None = None,
        strict: bool = False,
        filters: dict[str, Callable[..., Any]] | None = None,
        globals: dict[str, Any] | None = None,
    ) -> None:
        """Initialize with optional registry and filesystem directories.

        Args:
            registry: A :class:`TemplateRegistry` for named string templates.
            template_dirs: Directories to search for template files.
            autoescape_extensions: File extensions for Jinja2 autoescaping.
                Defaults to ``["html", "xml"]``.
            strict: If True, undefined template variables raise an error
                instead of rendering as empty strings.
            filters: Custom Jinja2 filters to register (name → callable).
            globals: Global variables available in every template.
        """
        extensions = autoescape_extensions or ["html", "xml"]
        undefined = StrictUndefined if strict else None

        env_kwargs: dict[str, Any] = {
            "autoescape": select_autoescape(extensions),
        }
        if undefined is not None:
            env_kwargs["undefined"] = undefined

        self._registry = registry
        self._strict = strict
        self._string_env = Environment(**env_kwargs)

        self._fs_env: Environment | None = None
        if template_dirs:
            self._fs_env = Environment(
                loader=FileSystemLoader([str(d) for d in template_dirs]),
                **env_kwargs,
            )

        # Register custom filters and globals on all environments.
        if filters:
            self._string_env.filters.update(filters)
            if self._fs_env:
                self._fs_env.filters.update(filters)

        if globals:
            self._string_env.globals.update(globals)
            if self._fs_env:
                self._fs_env.globals.update(globals)

        logger.debug(
            "TemplateManager initialized: %s",
            self,
        )

    @property
    def registry(self) -> TemplateRegistry | None:
        """The template registry, if one was provided."""
        return self._registry

    @property
    def strict(self) -> bool:
        """Whether undefined variables raise errors."""
        return self._strict

    def render(self, name: str, /, **context: Any) -> str:
        """Render a named template with the given context.

        Looks up *name* in the registry first, then the filesystem.

        Args:
            name: Template name (registry key) or file path (filesystem).
            **context: Template variables.

        Returns:
            Rendered output string.

        Raises:
            TemplateNotFoundError: If the template is not found in any source.
            TemplateSyntaxError: If the template contains invalid Jinja2 syntax.
            TemplateRenderError: If rendering fails (e.g., undefined variable
                in strict mode).
        """
        template = self._resolve(name)
        return self._render_safe(template, context)

    def render_string(self, template_string: str, /, **context: Any) -> str:
        """Render a raw template string with the given context.

        Use this for one-off templates that don't need to be registered.

        Args:
            template_string: Raw Jinja2 template.
            **context: Template variables.

        Returns:
            Rendered output string.

        Raises:
            TemplateSyntaxError: If the template string is invalid.
            TemplateRenderError: If rendering fails.
        """
        try:
            template = self._string_env.from_string(template_string)
        except JinjaSyntaxError as exc:
            raise TemplateSyntaxError(str(exc)) from exc
        return self._render_safe(template, context)

    def render_streaming(
        self,
        name: str,
        chunks: Iterable[dict[str, Any]],
        *,
        header_context: dict[str, Any] | None = None,
        header_template: str | None = None,
        footer_context: dict[str, Any] | None = None,
        footer_template: str | None = None,
    ) -> Iterator[str]:
        """Render a template once per chunk, yielding output fragments.

        This keeps memory usage bounded for large datasets by rendering
        one chunk at a time instead of materializing the full context.

        Args:
            name: Template name for each chunk.
            chunks: Iterable of context dicts, one per chunk.
            header_context: Optional context for a one-time header.
            header_template: Template name for the header (defaults to *name*).
            footer_context: Optional context for a one-time footer.
            footer_template: Template name for the footer (defaults to *name*).

        Yields:
            Rendered string fragments.
        """
        if header_context is not None:
            ht = self._resolve(header_template or name)
            yield self._render_safe(ht, header_context)

        template = self._resolve(name)
        for chunk in chunks:
            yield self._render_safe(template, chunk)

        if footer_context is not None:
            ft = self._resolve(footer_template or name)
            yield self._render_safe(ft, footer_context)

    # ------------------------------------------------------------------
    # Internal helpers
    # ------------------------------------------------------------------

    def _resolve(self, name: str) -> Template:
        """Look up a template by name across all sources.

        Resolution order: registry → filesystem → error.

        Raises:
            TemplateNotFoundError: If the name cannot be resolved.
            TemplateSyntaxError: If a matched template has invalid syntax.
        """
        # Try registry first.
        if self._registry and self._registry.has(name):
            raw = self._registry.get(name)
            logger.debug("Resolved '%s' from registry.", name)
            try:
                return self._string_env.from_string(raw)
            except JinjaSyntaxError as exc:
                raise TemplateSyntaxError(
                    f"Syntax error in registry template '{name}': {exc}"
                ) from exc

        # Try filesystem.
        if self._fs_env:
            try:
                template = self._fs_env.get_template(name)
                logger.debug("Resolved '%s' from filesystem.", name)
                return template
            except TemplateNotFound:
                logger.debug("'%s' not found on filesystem.", name)

        raise TemplateNotFoundError(f"Template '{name}' not found in registry or filesystem.")

    @staticmethod
    def _render_safe(template: Template, context: dict[str, Any]) -> str:
        """Render *template* with *context*, wrapping Jinja2 errors."""
        try:
            return template.render(**context)
        except UndefinedError as exc:
            raise TemplateRenderError(str(exc)) from exc

    def __repr__(self) -> str:
        sources = []
        if self._registry:
            sources.append(f"registry={len(self._registry)} templates")
        if self._fs_env:
            sources.append("filesystem")
        mode = ", strict" if self._strict else ""
        return f"TemplateManager({', '.join(sources) or 'empty'}{mode})"
