<?php
declare(strict_types=1);

/**
 * PHP configuration based Access Request Object
 *
 * CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
 * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
 *
 * Licensed under The MIT License
 * For full copyright and license information, please see the LICENSE.txt
 * Redistributions of files must retain the above copyright notice.
 *
 * @copyright     Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
 * @link          https://cakephp.org CakePHP(tm) Project
 * @license       https://www.opensource.org/licenses/mit-license.php MIT License
 */

namespace Acl\Adapter\Utility;

use Cake\Utility\Hash;
use Cake\Utility\Inflector;

/**
 * Access Request Object
 */
class PhpAro
{
    /**
     * role to resolve to when a provided ARO is not listed in
     * the internal tree
     *
     * @var string
     */
    public const DEFAULT_ROLE = 'Role/default';

    /**
     * map external identifiers. E.g. if
     *
     * ['Users' => ['username' => 'jeff', 'role' => 'editor']]
     *
     * is passed as an ARO to one of the methods of AclComponent, PhpAcl
     * will check if it can be resolved to an User or a Role defined in the
     * configuration file.
     *
     * @var array
     * @see app/Config/acl.php
     */
    public $map = [
        'User' => 'Users/username',
        'Role' => 'Users/role',
    ];

    /**
     * aliases to map
     *
     * @var array
     */
    public $aliases = [];

    /**
     * internal ARO representation
     *
     * @var array
     */
    protected $_tree = [];

    /**
     * Constructor
     *
     * @param array $aro Aro instance
     * @param array $map Map
     * @param array $aliases Aliases
     */
    public function __construct(array $aro = [], array $map = [], array $aliases = [])
    {
        if (!empty($map)) {
            $this->map = $map;
        }

        $this->aliases = $aliases;
        $this->build($aro);
    }

    /**
     * From the perspective of the given ARO, walk down the tree and
     * collect all inherited AROs levelwise such that AROs from different
     * branches with equal distance to the requested ARO will be collected at the same
     * index. The resulting array will contain a prioritized list of (list of) roles ordered from
     * the most distant AROs to the requested one itself.
     *
     * @param string|array $aro An ARO identifier
     * @return array prioritized AROs
     */
    public function roles($aro)
    {
        $aros = [];
        $aro = $this->resolve($aro);
        $stack = [[$aro, 0]];

        while (!empty($stack)) {
            [$element, $depth] = array_pop($stack);
            $aros[$depth][] = $element;

            foreach ($this->_tree as $node => $children) {
                if (in_array($element, $children)) {
                    array_push($stack, [$node, $depth + 1]);
                }
            }
        }

        return array_reverse($aros);
    }

    /**
     * resolve an ARO identifier to an internal ARO string using
     * the internal mapping information.
     *
     * @param string|array $aro ARO identifier (Users.jeff, ['Users' => ...], etc)
     * @return string internal aro string (e.g. Users/jeff, Role/default)
     */
    public function resolve($aro)
    {
        foreach ($this->map as $aroGroup => $map) {
            [$model, $field] = explode('/', $map, 2);
            $mapped = '';

            if (is_array($aro)) {
                if (isset($aro['model']) && isset($aro['foreign_key']) && $aro['model'] === $aroGroup) {
                    $mapped = $aroGroup . '/' . $aro['foreign_key'];
                } elseif (isset($aro[$model][$field])) {
                    $mapped = $aroGroup . '/' . $aro[$model][$field];
                } elseif (isset($aro[$field])) {
                    $mapped = $aroGroup . '/' . $aro[$field];
                }
            } elseif (is_string($aro)) {
                $aro = ltrim($aro, '/');

                if (strpos($aro, '/') === false) {
                    $mapped = $aroGroup . '/' . $aro;
                } else {
                    [$aroModel, $aroValue] = explode('/', $aro, 2);

                    $aroModel = Inflector::camelize($aroModel);

                    if ($aroModel === $model || $aroModel === $aroGroup) {
                        $mapped = $aroGroup . '/' . $aroValue;
                    }
                }
            }

            if (isset($this->_tree[$mapped])) {
                return $mapped;
            }

            // is there a matching alias defined (e.g. Role/1 => Role/admin)?
            if (!empty($this->aliases[$mapped])) {
                return $this->aliases[$mapped];
            }
        }

        return static::DEFAULT_ROLE;
    }

    /**
     * adds a new ARO to the tree
     *
     * @param array $aro one or more ARO records
     * @return void
     */
    public function addRole(array $aro)
    {
        foreach ($aro as $role => $inheritedRoles) {
            if (!isset($this->_tree[$role])) {
                $this->_tree[$role] = [];
            }

            if (!empty($inheritedRoles)) {
                if (is_string($inheritedRoles)) {
                    $inheritedRoles = array_map('trim', explode(',', $inheritedRoles));
                }

                foreach ($inheritedRoles as $dependency) {
                    // detect cycles
                    $roles = $this->roles($dependency);

                    if (in_array($role, Hash::flatten($roles))) {
                        $path = '';

                        foreach ($roles as $roleDependencies) {
                            $path .= implode('|', (array)$roleDependencies) . ' -> ';
                        }

                        trigger_error(sprintf('cycle detected when inheriting %s from %s. Path: %s', $role, $dependency, $path . $role));
                        continue;
                    }

                    if (!isset($this->_tree[$dependency])) {
                        $this->_tree[$dependency] = [];
                    }

                    $this->_tree[$dependency][] = $role;
                }
            }
        }
    }

    /**
     * adds one or more aliases to the internal map. Overwrites existing entries.
     *
     * @param array $alias alias from => to (e.g. Role/13 -> Role/editor)
     * @return void
     */
    public function addAlias(array $alias)
    {
        $this->aliases = $alias + $this->aliases;
    }

    /**
     * build an ARO tree structure for internal processing
     *
     * @param array $aros array of AROs as key and their inherited AROs as values
     * @return void
     */
    public function build(array $aros)
    {
        $this->_tree = [];
        $this->addRole($aros);
    }
}
