"""Template rendering engine using Jinja2 SandboxedEnvironment.

Hardening applied:

* SandboxedEnvironment with autoescape for attribute/method isolation.
* Input length cap to short-circuit pathological templates before parsing.
* Output length cap via a custom stream check to prevent memory blow-ups.
* Cooperative time budget via a thread-based watchdog -- a runaway template
  (``{% for i in range(10**8) %}``) is interrupted instead of wedging the worker.
"""

from __future__ import annotations

import logging
import threading
from functools import lru_cache
from typing import Any

import jinja2
from jinja2.sandbox import SandboxedEnvironment

_LOGGER = logging.getLogger(__name__)

MAX_TEMPLATE_LEN = 64 * 1024        # 64 KiB source
MAX_OUTPUT_LEN = 256 * 1024         # 256 KiB rendered
RENDER_TIMEOUT_SECONDS = 2.0

_env = SandboxedEnvironment(autoescape=True)


@lru_cache(maxsize=512)
def _compile_cached(template_str: str) -> jinja2.Template:
    """Compile + cache Jinja2 templates by source text.

    Hot paths (NotificationDispatcher fan-out, periodic dispatch) re-render
    the same template string for every event; ``_env.from_string`` parses
    the source from scratch each time (~ms each). The 512-entry cache is
    large enough to hold every template across a busy install while
    keeping memory bounded.
    """
    return _env.from_string(template_str)


class TemplateRenderTimeout(jinja2.TemplateError):
    """Raised when a template exceeds the configured render budget."""


def _render_with_timeout(template: jinja2.Template, context: dict[str, Any]) -> str:
    """Render `template` in a worker thread with a hard timeout.

    Jinja2 has no built-in timeout; we run the render in a daemon thread and
    join with a deadline. If the deadline is exceeded we raise and let the
    thread die with the process -- accepted trade-off for a bounded-budget
    admin-authored template.
    """
    result: dict[str, Any] = {}

    def _run() -> None:
        try:
            result["value"] = template.render(**context)
        except BaseException as exc:  # noqa: BLE001 - forward to caller
            result["error"] = exc

    worker = threading.Thread(target=_run, daemon=True)
    worker.start()
    worker.join(RENDER_TIMEOUT_SECONDS)
    if worker.is_alive():
        raise TemplateRenderTimeout(
            f"Template render exceeded {RENDER_TIMEOUT_SECONDS}s budget"
        )
    if "error" in result:
        raise result["error"]
    return result.get("value", "")


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

    Enforces source length, output length, and wall-clock time caps.
    Returns a placeholder on any failure so callers never see a partial render.
    """
    if not isinstance(template_str, str):
        return ""
    if len(template_str) > MAX_TEMPLATE_LEN:
        _LOGGER.warning(
            "Template source exceeds %d chars (%d); refusing to render",
            MAX_TEMPLATE_LEN, len(template_str),
        )
        return "[Template too large]"
    try:
        compiled = _compile_cached(template_str)
        output = _render_with_timeout(compiled, context)
    except TemplateRenderTimeout as e:
        _LOGGER.error("Template render timeout: %s", e)
        return "[Template render timeout]"
    except jinja2.TemplateError as e:
        _LOGGER.error("Template render error: %s", e)
        return "[Template rendering error]"
    except Exception as e:  # sandbox guarded — log and fall back safely
        _LOGGER.error("Unexpected template error: %s", e, exc_info=True)
        return "[Template rendering error]"
    if len(output) > MAX_OUTPUT_LEN:
        _LOGGER.warning(
            "Template output truncated from %d to %d bytes",
            len(output), MAX_OUTPUT_LEN,
        )
        return output[:MAX_OUTPUT_LEN] + "\n[truncated]"
    return output
