<?php

declare(strict_types=1);

namespace Laminas\Permissions\Acl\Assertion;

use Laminas\Permissions\Acl\Acl;
use Laminas\Permissions\Acl\Assertion\Exception\InvalidAssertionException;
use Laminas\Permissions\Acl\Exception\RuntimeException;
use Laminas\Permissions\Acl\Resource\ResourceInterface;
use Laminas\Permissions\Acl\Role\RoleInterface;
use ReflectionProperty;

use function array_flip;
use function array_intersect_key;
use function assert;
use function count;
use function explode;
use function in_array;
use function is_array;
use function is_object;
use function is_string;
use function method_exists;
use function preg_match;
use function property_exists;
use function sprintf;
use function str_contains;
use function str_replace;
use function strtolower;
use function ucwords;

/**
 * Create an assertion based on expression rules.
 *
 * Each of the constructor, fromProperties, and fromArray methods allow you to
 * define expression rules, and these include the left hand side, operator, and
 * right hand side of the expression.
 *
 * The left and right hand sides of the expression are the values to compare.
 * These values can be either an exact value to match, or an array with the key
 * OPERAND_CONTEXT_PROPERTY pointing to one of two value types.
 *
 * First, it can be a string value matching one of "acl", "privilege", "role",
 * or "resource", with the latter two values being the most common. In those
 * cases, the matching value passed to `assert()` will be used in the
 * comparison.
 *
 * Second, it can be a dot-separated property path string of the format
 * "object.property", representing the associated object (role, resource, acl,
 * or privilege) and its property to test against.  The property may refer to a
 * public property, or a public `get` or `is` method (following
 * canonicalization of the property by replacing underscore separated values
 * with camelCase values).
 */
final class ExpressionAssertion implements AssertionInterface
{
    public const OPERAND_CONTEXT_PROPERTY = '__context';

    public const OPERATOR_EQ     = '=';
    public const OPERATOR_NEQ    = '!=';
    public const OPERATOR_LT     = '<';
    public const OPERATOR_LTE    = '<=';
    public const OPERATOR_GT     = '>';
    public const OPERATOR_GTE    = '>=';
    public const OPERATOR_IN     = 'in';
    public const OPERATOR_NIN    = '!in';
    public const OPERATOR_REGEX  = 'regex';
    public const OPERATOR_NREGEX = '!regex';
    public const OPERATOR_SAME   = '===';
    public const OPERATOR_NSAME  = '!==';

    /** @var list<string> */
    private static array $validOperators = [
        self::OPERATOR_EQ,
        self::OPERATOR_NEQ,
        self::OPERATOR_LT,
        self::OPERATOR_LTE,
        self::OPERATOR_GT,
        self::OPERATOR_GTE,
        self::OPERATOR_IN,
        self::OPERATOR_NIN,
        self::OPERATOR_REGEX,
        self::OPERATOR_NREGEX,
        self::OPERATOR_SAME,
        self::OPERATOR_NSAME,
    ];

    /**
     * Constructor
     *
     * Note that the constructor is marked private; use `fromProperties()` or
     * `fromArray()` to create an instance.
     *
     * @param mixed|array $left See the class description for valid values.
     * @param string $operator One of the OPERATOR constants (or their values)
     * @param mixed|array $right See the class description for valid values.
     */
    private function __construct(private $left, private $operator, private $right)
    {
    }

    /**
     * @param mixed|array $left See the class description for valid values.
     * @param string $operator One of the OPERATOR constants (or their values)
     * @param mixed|array $right See the class description for valid values.
     * @return self
     * @throws InvalidAssertionException If either operand is invalid.
     * @throws InvalidAssertionException If the operator is not supported.
     */
    public static function fromProperties($left, $operator, $right)
    {
        $operator = strtolower($operator);

        self::validateOperand($left);
        self::validateOperator($operator);
        self::validateOperand($right);

        return new self($left, $operator, $right);
    }

    /**
     * @param array $expression Must contain the following keys:
     *     - left: the left-hand side of the expression
     *     - operator: the operator to use for the comparison
     *     - right: the right-hand side of the expression
     *     See the class description for valid values for the left and right
     *     hand side values.
     * @return self
     * @throws InvalidAssertionException If missing one of the required keys.
     * @throws InvalidAssertionException If either operand is invalid.
     * @throws InvalidAssertionException If the operator is not supported.
     */
    public static function fromArray(array $expression)
    {
        $required = ['left', 'operator', 'right'];

        if (count(array_intersect_key($expression, array_flip($required))) < count($required)) {
            throw new InvalidAssertionException(
                "Expression assertion requires 'left', 'operator' and 'right' to be supplied"
            );
        }

        return self::fromProperties(
            $expression['left'],
            $expression['operator'],
            $expression['right']
        );
    }

    /**
     * @param mixed|array $operand
     * @throws InvalidAssertionException If the operand is invalid.
     */
    private static function validateOperand($operand)
    {
        if (is_array($operand) && isset($operand[self::OPERAND_CONTEXT_PROPERTY])) {
            if (! is_string($operand[self::OPERAND_CONTEXT_PROPERTY])) {
                throw new InvalidAssertionException('Expression assertion context operand must be string');
            }
        }
    }

    /**
     * @param string $operator
     * @throws InvalidAssertionException If the operator is not supported.
     */
    private static function validateOperator($operator)
    {
        if (! in_array($operator, self::$validOperators, true)) {
            throw new InvalidAssertionException('Provided expression assertion operator is not supported');
        }
    }

    /**
     * {@inheritDoc}
     */
    public function assert(
        Acl $acl,
        ?RoleInterface $role = null,
        ?ResourceInterface $resource = null,
        $privilege = null
    ) {
        return $this->evaluate([
            'acl'       => $acl,
            'role'      => $role,
            'resource'  => $resource,
            'privilege' => $privilege,
        ]);
    }

    /**
     * @param array $context Contains the acl, privilege, role, and resource
     *     being tested currently.
     * @return bool
     */
    private function evaluate(array $context)
    {
        $left  = $this->getLeftValue($context);
        $right = $this->getRightValue($context);

        return static::evaluateExpression($left, $this->operator, $right);
    }

    /**
     * @param array $context Contains the acl, privilege, role, and resource
     *     being tested currently.
     * @return mixed
     */
    private function getLeftValue(array $context)
    {
        return $this->resolveOperandValue($this->left, $context);
    }

    /**
     * @param array $context Contains the acl, privilege, role, and resource
     *     being tested currently.
     * @return mixed
     */
    private function getRightValue(array $context)
    {
        return $this->resolveOperandValue($this->right, $context);
    }

    /**
     * @param mixed|array $operand
     * @param array $context Contains the acl, privilege, role, and resource
     *     being tested currently.
     * @return mixed
     * @throws RuntimeException If object cannot be resolved in context.
     * @throws RuntimeException If property cannot be resolved.
     */
    private function resolveOperandValue($operand, array $context)
    {
        if (! is_array($operand) || ! isset($operand[self::OPERAND_CONTEXT_PROPERTY])) {
            return $operand;
        }

        $contextProperty = $operand[self::OPERAND_CONTEXT_PROPERTY];
        assert(is_string($contextProperty));

        if (str_contains($contextProperty, '.')) { // property path?
            $parts = explode('.', $contextProperty, 2);
            assert(count($parts) >= 2);
            [$objectName, $objectField] = $parts;
            return $this->getObjectFieldValue($context, $objectName, $objectField);
        }

        if (! isset($context[$contextProperty])) {
            throw new RuntimeException(sprintf(
                "'%s' is not available in the assertion context",
                $contextProperty
            ));
        }

        return $context[$contextProperty];
    }

    /**
     * @param array $context Contains the acl, privilege, role, and resource
     *     being tested currently.
     * @param string $objectName Name of object in context to use.
     * @param string $field
     * @return mixed
     * @throws RuntimeException If object cannot be resolved in context.
     * @throws RuntimeException If property cannot be resolved.
     */
    private function getObjectFieldValue(array $context, $objectName, $field)
    {
        if (! isset($context[$objectName])) {
            throw new RuntimeException(sprintf(
                "'%s' is not available in the assertion context",
                $objectName
            ));
        }

        $object        = $context[$objectName];
        $accessors     = ['get', 'is'];
        $fieldAccessor = ! str_contains($field, '_')
            ? $field
            : str_replace(' ', '', ucwords(str_replace('_', ' ', $field)));

        foreach ($accessors as $accessor) {
            $accessor .= $fieldAccessor;

            if (is_object($object) && method_exists($object, $accessor)) {
                return $object->$accessor();
            }
        }

        if (! is_object($object) || ! $this->propertyExists($object, $field)) {
            throw new RuntimeException(sprintf(
                "'%s' property cannot be resolved on the '%s' object",
                $field,
                $objectName
            ));
        }

        return $object->$field;
    }

    /**
     * @param string $operator
     * @return bool|void
     * @throws RuntimeException If operand is not supported.
     */
    private static function evaluateExpression(mixed $left, $operator, mixed $right)
    {
        // phpcs:disable SlevomatCodingStandard.Operators.DisallowEqualOperators
        switch ($operator) {
            case self::OPERATOR_EQ:
                return $left == $right;
            case self::OPERATOR_NEQ:
                return $left != $right;
            case self::OPERATOR_LT:
                return $left < $right;
            case self::OPERATOR_LTE:
                return $left <= $right;
            case self::OPERATOR_GT:
                return $left > $right;
            case self::OPERATOR_GTE:
                return $left >= $right;
            case self::OPERATOR_IN:
                return in_array($left, $right);
            case self::OPERATOR_NIN:
                return ! in_array($left, $right);
            case self::OPERATOR_REGEX:
                return (bool) preg_match($right, $left);
            case self::OPERATOR_NREGEX:
                return ! (bool) preg_match($right, $left);
            case self::OPERATOR_SAME:
                return $left === $right;
            case self::OPERATOR_NSAME:
                return $left !== $right;
        }
        // phpcs:enable SlevomatCodingStandard.Operators.DisallowEqualOperators
    }

    /**
     * @param object $object
     * @param string $property
     * @return bool
     */
    private function propertyExists($object, $property)
    {
        if (! property_exists($object, $property)) {
            return false;
        }

        $r = new ReflectionProperty($object, $property);
        return $r->isPublic();
    }
}
