<?php

declare(strict_types=1);

/*
 * This file is part of the Ferienpass package.
 *
 * (c) Richard Henkenjohann <richard@ferienpass.online>
 *
 * For more information visit the project website <https://ferienpass.online>
 * or the documentation under <https://docs.ferienpass.online>.
 */

namespace Ferienpass\CoreBundle\Twig;

use Twig\Markup;
use Twig\Sandbox\SecurityNotAllowedMethodError;
use Twig\Sandbox\SecurityNotAllowedPropertyError;
use Twig\Sandbox\SecurityPolicy;
use Twig\Sandbox\SecurityPolicyInterface;
use Twig\Template;

/**
 * Security policy for the {@see SandboxedTextRenderer}.
 *
 * Tags/filters/functions are restricted to a hand-picked safe whitelist via a
 * wrapped {@see SecurityPolicy} (NO functions at all, NO `raw`, no template
 * inheritance/include/import tags). Method and property access is allowed only
 * for getter-style *reads* (`get*`, `is*`, `has*`, `__toString`) on Ferienpass
 * domain objects — which is exactly what notification/mailing tokens such as
 * `{{ offer.name }}` or `{{ attendance.participant.firstname }}` need, while
 * blocking calls to arbitrary objects/methods that would enable code
 * execution.
 */
final class NotificationSecurityPolicy implements SecurityPolicyInterface
{
    /**
     * Namespace prefixes whose objects may be read from inside the sandbox.
     */
    private const ALLOWED_OBJECT_NAMESPACES = [
        'Ferienpass\\',
    ];

    private const ALLOWED_TAGS = ['if', 'for', 'set', 'apply', 'spaceless'];

    private const ALLOWED_FILTERS = [
        // text formatting
        'capitalize', 'upper', 'lower', 'title', 'trim', 'nl2br', 'escape', 'e',
        'replace', 'striptags', 'spaceless', 'length', 'first', 'last', 'join',
        'split', 'slice', 'default', 'abs', 'round',
        // i18n / dates / numbers (registered via Twig core + IntlExtension)
        'date', 'date_modify', 'format_date', 'format_datetime', 'format_time',
        'format_currency', 'format_number', 'number_format', 'country_name',
        'language_name', 'locale_name', 'currency_name', 'currency_symbol',
    ];

    private readonly SecurityPolicy $inner;

    public function __construct()
    {
        $this->inner = new SecurityPolicy(
            allowedTags: self::ALLOWED_TAGS,
            allowedFilters: array_values(array_unique(self::ALLOWED_FILTERS)),
            allowedMethods: [],
            allowedProperties: [],
            allowedFunctions: [],
            // No allowed tests. Twig core flags every test except `constant` as
            // always-allowed-in-sandbox, so those never reach us anyway; `constant` is
            // the one that does, and reading arbitrary class constants has no place in
            // author-supplied text.
            allowedTests: [],
        );
    }

    /**
     * The `$tests` argument is optional in Twig 3 and required in 4.0. Declaring it (and
     * forwarding it) is what makes SandboxExtension hand the used tests over for checking
     * at all — with a 3-parameter signature it drops them and `constant` goes unchecked.
     */
    public function checkSecurity($tags, $filters, $functions, array $tests = []): void
    {
        $this->inner->checkSecurity($tags, $filters, $functions, $tests);
    }

    public function checkMethodAllowed($obj, $method): void
    {
        if ($obj instanceof Template || $obj instanceof Markup) {
            return;
        }

        $normalized = strtolower($method);
        $isGetter = str_starts_with($normalized, 'get')
            || str_starts_with($normalized, 'is')
            || str_starts_with($normalized, 'has')
            || '__tostring' === $normalized;

        if ($isGetter && $this->isAllowedObject($obj)) {
            return;
        }

        throw new SecurityNotAllowedMethodError(\sprintf('Calling "%s" method on a "%s" object is not allowed.', $method, $obj::class), $obj::class, $method);
    }

    public function checkPropertyAllowed($obj, $property): void
    {
        if ($this->isAllowedObject($obj)) {
            return;
        }

        throw new SecurityNotAllowedPropertyError(\sprintf('Calling "%s" property on a "%s" object is not allowed.', $property, $obj::class), $obj::class, $property);
    }

    private function isAllowedObject(object $obj): bool
    {
        // Walk the parent chain, not just $obj::class: Doctrine lazy-loads to-one
        // associations as proxy objects whose own class is `Proxies\__CG__\Ferienpass\…`
        // (outside the allowed namespaces), but which extend the real entity class that
        // does live under an allowed namespace. Matching any ancestor lets a proxy of a
        // Ferienpass entity through while still rejecting arbitrary foreign objects.
        for ($class = $obj::class; false !== $class; $class = get_parent_class($class)) {
            foreach (self::ALLOWED_OBJECT_NAMESPACES as $namespace) {
                if (str_starts_with($class, $namespace)) {
                    return true;
                }
            }
        }

        return false;
    }
}
