"""Sandboxed template renderer with strict security controls.

Replaces unsafe ``str.format()`` with a restricted token set that only
allows ``{variable_name}`` substitution from a validated allowlist.

Security properties:

- **No attribute access**: ``{obj.attr}`` is rejected.
- **No function calls**: ``{fn()}`` is rejected.
- **No format specs**: ``{val:>10}`` is rejected.
- **No index access**: ``{val[0]}`` is rejected.
- **Fixed allowlist**: Only context keys present in the allowlist can
  appear in templates.
- **Length limits**: Both the input template and rendered output are
  bounded to prevent denial-of-service attacks.

Usage::

    renderer = SecureTemplateRenderer(
        config=TemplateConfig(
            allowed_keys=frozenset({"name", "description"}),
        ),
    )
    result = renderer.render("Hello {name}", {"name": "world"})

Or use the convenience function::

    result = render_template_secure(
        "Hello {name}",
        {"name": "world"},
        allowed_keys=frozenset({"name"}),
    )
"""

from __future__ import annotations

import logging
import re
from dataclasses import dataclass, field
from typing import Any

__all__: list[str] = [
    "SecureTemplateRenderer",
    "TemplateConfig",
    "TemplateError",
    "TemplateSecurityError",
    "TemplateSizeError",
    "TemplateValidationError",
    "render_template_secure",
    "validate_template",
]

logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

#: Default maximum template length in characters.
DEFAULT_MAX_TEMPLATE_LENGTH: int = 10_000

#: Default maximum rendered output length in characters.
DEFAULT_MAX_OUTPUT_LENGTH: int = 50_000

#: Regex for a safe placeholder: ``{simple_name}`` only.
#:
#: Note: ``\w`` matches Unicode word characters (letters, digits,
#: underscore) — not just ASCII ``[a-zA-Z0-9_]``.  This means keys
#: like ``{café}`` or ``{名前}`` are accepted.  If ASCII-only keys
#: are required, change the pattern to ``[a-zA-Z_][a-zA-Z0-9_]*``.
_SAFE_PLACEHOLDER_RE = re.compile(r"\{(\w+)\}")

#: Single combined pattern that detects **all** unsafe template
#: constructs in one pass: attribute access, index access, conversion
#: flags, format specs, function calls, and Jinja2 delimiters.
_UNSAFE_RE: re.Pattern[str] = re.compile(
    r"|".join(
        [
            r"\{[^}]*\.[^}]*\}",  # attribute access  {x.y}
            r"\{[^}]*\[[^}]*\]\}",  # index access     {x[0]}
            r"\{[^}]*![^}]*\}",  # conversion        {x!r}
            r"\{[^}]*:[^}]+\}",  # format spec       {x:>10}
            r"\{[^}]*\([^}]*\)\}",  # function call     {fn()}
            r"\{\{",  # Jinja2 start
            r"\}\}",  # Jinja2 end
            r"\{%",  # Jinja2 block
            r"%\}",  # Jinja2 block end
        ]
    )
)


# ---------------------------------------------------------------------------
# Exceptions
# ---------------------------------------------------------------------------


class TemplateError(Exception):
    """Base exception for all template rendering errors."""


class TemplateSecurityError(TemplateError):
    """Raised when a template contains unsafe constructs."""


class TemplateSizeError(TemplateError):
    """Raised when a template or its output exceeds size limits."""


class TemplateValidationError(TemplateError):
    """Raised when template validation fails (e.g. unknown keys)."""


# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------


@dataclass(frozen=True)
class TemplateConfig:
    """Configuration for the secure template renderer.

    Attributes:
        allowed_keys: The set of context key names that may appear in
            templates.  If empty, **all** simple identifier keys are
            permitted (open-allowlist mode).
        max_template_length: Maximum number of characters in the raw
            template string.
        max_output_length: Maximum number of characters in the rendered
            output string.
        reject_unknown_keys: When ``True`` (default), referencing a key
            not in *allowed_keys* raises ``TemplateValidationError``.
            Ignored when *allowed_keys* is empty.
        log_rejected: When ``True``, log rejected keys at WARNING level.
    """

    allowed_keys: frozenset[str] = field(default_factory=frozenset)
    max_template_length: int = DEFAULT_MAX_TEMPLATE_LENGTH
    max_output_length: int = DEFAULT_MAX_OUTPUT_LENGTH
    reject_unknown_keys: bool = True
    log_rejected: bool = True


# ---------------------------------------------------------------------------
# Renderer
# ---------------------------------------------------------------------------


class SecureTemplateRenderer:
    """Sandboxed template renderer with strict security controls.

    Only ``{variable_name}`` substitutions are allowed.  Attribute
    access, index access, format specs, conversion flags, and function
    calls are all rejected.
    """

    def __init__(self, config: TemplateConfig | None = None) -> None:
        self._config = config or TemplateConfig()

    @property
    def config(self) -> TemplateConfig:
        """Return the active configuration."""
        return self._config

    # -- Public API ---------------------------------------------------------

    def render(
        self,
        template: str,
        context: dict[str, Any] | None = None,
    ) -> str:
        """Render *template* with the given *context*.

        Args:
            template: The template string containing ``{key}``
                placeholders.
            context: Mapping of placeholder names to values.

        Returns:
            The rendered string.

        Raises:
            TemplateSizeError: If the template or output exceeds the
                configured length limits.
            TemplateSecurityError: If the template contains unsafe
                constructs (attribute access, function calls, etc.).
            TemplateValidationError: If the template references keys
                not in the allowed set.
        """
        context = context or {}
        self._check_template_length(template)
        self._check_security(template)
        used_keys = _extract_placeholder_names(template)
        self._check_allowed_keys(used_keys)
        rendered = self._substitute(template, context)
        self._check_output_length(rendered)
        return rendered

    def validate(self, template: str) -> list[str]:
        """Validate *template* without rendering.

        Returns a list of error messages (empty = valid).
        """
        errors: list[str] = []

        if len(template) > self._config.max_template_length:
            errors.append(
                f"Template length {len(template)} exceeds maximum "
                f"{self._config.max_template_length}"
            )

        for match in _UNSAFE_RE.finditer(template):
            errors.append(f"Unsafe construct detected: {match.group()!r}")

        # Check for unknown keys when allowlist is enforced.
        if self._config.allowed_keys and self._config.reject_unknown_keys:
            used_keys = _extract_placeholder_names(template)
            unknown = used_keys - self._config.allowed_keys
            if unknown:
                errors.append(f"Unknown template keys: {', '.join(sorted(unknown))}")

        return errors

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

    def _check_template_length(self, template: str) -> None:
        if len(template) > self._config.max_template_length:
            raise TemplateSizeError(
                f"Template length {len(template)} exceeds maximum "
                f"{self._config.max_template_length}"
            )

    def _check_output_length(self, rendered: str) -> None:
        if len(rendered) > self._config.max_output_length:
            raise TemplateSizeError(
                f"Rendered output length {len(rendered)} exceeds maximum "
                f"{self._config.max_output_length}"
            )

    def _check_security(self, template: str) -> None:
        match = _UNSAFE_RE.search(template)
        if match:
            raise TemplateSecurityError(f"Unsafe template construct: {match.group()!r}")

    def _check_allowed_keys(
        self,
        used_keys: set[str],
    ) -> None:
        if not self._config.allowed_keys:
            return
        unknown = used_keys - self._config.allowed_keys
        if unknown and self._config.reject_unknown_keys:
            if self._config.log_rejected:
                logger.warning(
                    "Rejected template keys: %s",
                    ", ".join(sorted(unknown)),
                )
            raise TemplateValidationError(
                f"Template uses disallowed keys: {', '.join(sorted(unknown))}"
            )

    @staticmethod
    def _substitute(template: str, context: dict[str, Any]) -> str:
        """Perform safe ``{key}`` substitution.

        Unknown placeholders are left as-is (safe-substitute behaviour).
        """

        def _replace(match: re.Match[str]) -> str:
            key = match.group(1)
            if key in context:
                return str(context[key])
            return match.group(0)

        return _SAFE_PLACEHOLDER_RE.sub(_replace, template)


# ---------------------------------------------------------------------------
# Module-level helpers
# ---------------------------------------------------------------------------


def _extract_placeholder_names(template: str) -> set[str]:
    """Extract all ``{name}`` placeholder names from *template*."""
    return set(_SAFE_PLACEHOLDER_RE.findall(template))


def validate_template(
    template: str,
    *,
    allowed_keys: frozenset[str] | None = None,
    max_template_length: int = DEFAULT_MAX_TEMPLATE_LENGTH,
) -> list[str]:
    """Pre-validate a template string at creation time.

    Convenience wrapper that builds a :class:`SecureTemplateRenderer`
    and calls :meth:`~SecureTemplateRenderer.validate`.

    Args:
        template: The template string to validate.
        allowed_keys: Optional set of allowed placeholder names.
        max_template_length: Maximum template length.

    Returns:
        A list of error messages.  Empty means the template is valid.
    """
    config = TemplateConfig(
        allowed_keys=allowed_keys or frozenset(),
        max_template_length=max_template_length,
    )
    renderer = SecureTemplateRenderer(config=config)
    return renderer.validate(template)


def render_template_secure(
    template: str,
    context: dict[str, Any],
    *,
    allowed_keys: frozenset[str] | None = None,
    max_template_length: int = DEFAULT_MAX_TEMPLATE_LENGTH,
    max_output_length: int = DEFAULT_MAX_OUTPUT_LENGTH,
) -> str:
    """Render a template securely with one function call.

    Convenience wrapper around :class:`SecureTemplateRenderer`.

    Args:
        template: The template string.
        context: Mapping of key names to values.
        allowed_keys: Optional set of allowed placeholder names.
        max_template_length: Maximum template length.
        max_output_length: Maximum rendered output length.

    Returns:
        The rendered string.

    Raises:
        TemplateError: On any security, validation, or size error.
    """
    config = TemplateConfig(
        allowed_keys=allowed_keys or frozenset(),
        max_template_length=max_template_length,
        max_output_length=max_output_length,
    )
    renderer = SecureTemplateRenderer(config=config)
    return renderer.render(template, context)
