twig = new Environment($loader, [ 'cache' => $cacheDir ?? false, 'auto_reload' => (getenv('APP_ENV') ?: 'production') !== 'production', 'strict_variables' => false, 'autoescape' => 'html', ]); // fmt_days(x) — same shape used by the previous PHP templates and the // CapacityCalculator: 0 → "0", whole numbers → integer, halves → x.5. $this->twig->addFunction(new TwigFunction( 'fmt_days', static function (mixed $x): string { $n = (float) $x; if (abs($n - round($n)) < 1e-9) { return (string) (int) round($n); } return number_format($n, 1); } )); // fmt_rtb(x) — two decimals, e.g. 0.05. $this->twig->addFunction(new TwigFunction( 'fmt_rtb', static fn(mixed $x): string => number_format((float) $x, 2, '.', '') )); // pretty_json(raw) — used by audit/index.twig for the diff
 blocks.
        $this->twig->addFunction(new TwigFunction(
            'pretty_json',
            static function (?string $raw): string {
                if ($raw === null || $raw === '') {
                    return '';
                }
                try {
                    $v = json_decode($raw, true, 64, JSON_THROW_ON_ERROR);
                } catch (\JsonException) {
                    return $raw;
                }
                return (string) (json_encode(
                    $v,
                    JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
                ) ?: $raw);
            }
        ));

        // query_string(filters, drop, extra) — rebuilds a query string while
        // dropping one key and merging extras. Used by /audit pagination.
        $this->twig->addFunction(new TwigFunction(
            'query_string',
            /**
             * @param array $filters
             * @param array $extra
             */
            static function (array $filters, string $drop, array $extra = []): string {
                $params = array_filter(
                    array_merge($filters, $extra),
                    static fn($v) => $v !== '' && $v !== null
                );
                unset($params[$drop]);
                return $params === [] ? '' : '?' . http_build_query($params);
            }
        ));
    }

    /**
     * Render a view and return the HTML.
     *
     * @param string              $name view name; "home" → views/home.twig
     * @param array $data
     * @param string|null         $layout retained for back-compat; ignored
     *                                    by Twig (templates use {% extends %})
     */
    public function render(string $name, array $data = [], ?string $layout = 'layout'): string
    {
        unset($layout);
        return $this->twig->render($name . '.twig', $data);
    }

    /** @param array $data */
    public function renderRaw(string $name, array $data): string
    {
        return $this->twig->render($name . '.twig', $data);
    }

    /** Direct access to the Twig env (rarely needed; mostly for tests). */
    public function twig(): Environment
    {
        return $this->twig;
    }
}

/** Escape for HTML. Kept so legacy callers still resolve; Twig auto-escapes. */
function e(mixed $v): string
{
    if ($v === null) {
        return '';
    }
    return htmlspecialchars((string) $v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}