<?php
// Copyright (c) Pickware GmbH. All rights reserved.
// This file is part of software that is released under a proprietary license.
// You must not copy, modify, distribute, make publicly available, or execute
// its contents or parts thereof without express permission by the copyright
// holder, unless otherwise permitted by law.

namespace Shopware\Plugins\ViisonCommon\Classes\Installation\AclResource;

use Doctrine\ORM\EntityManager;
use Doctrine\DBAL\Driver\Connection;
use Shopware\Models\User\Privilege;
use Shopware\Models\User\Resource;
use Shopware\Models\User\Rule;
use Shopware\Plugins\ViisonCommon\Classes\TryWithFinally;

class AclResourceCreator
{

    /**
     * @var ResourceHelper
     */
    private $helper;

    /**
     * @var \Shopware_Components_Acl
     */
    private $acl;

    /**
     * @var EntityManager
     */
    private $entityManager;

    public function __construct(AclResourceHelper $helper, \Shopware_Components_Acl $acl, EntityManager $entityManager)
    {
        $this->helper = $helper;
        $this->acl = $acl;
        $this->entityManager = $entityManager;
    }

    /**
     * Replaces any existing resource with the same name, its privileges and its rules (or creates a new one). It
     * creates a new resource and optionally privileges, menu item relationships and a plugin dependency.
     *
     * @param $resourceName - unique identifier or resource key
     * @param array|null $privileges - optionally array [a,b,c] of new privileges
     * @param null $menuItemName - optionally s_core_menu.name item to link to this resource
     * @param null $pluginID - optionally pluginID that implements this resource
     *
     * @return void
     *
     * @see \Shopware_Components_Acl::createResource
     *
     * Note: Method parameter documentation copied from \Shopware_Components_Acl::createResource.
     */
    public function replaceResource($resourceName, array $privileges = null, $menuItemName = null, $pluginID = null)
    {
        $entityManager = $this->entityManager;
        $connection = $entityManager->getConnection();

        $oldTransactionIsolationLevel = $connection->getTransactionIsolation();
        /* Ensure that SELECTs return data that is updated within the transaction.
         *
         * Note: READ_COMMITTED is the default for Doctrine on MySQL, already, see
         * \Doctrine\DBAL\Platforms\AbstractPlatform::getDefaultTransactionIsolationLevel. However, while it is
         * Doctrine's default for MySQL, it is not MySQL's default (see
         * <https://dev.mysql.com/doc/refman/5.7/en/set-transaction.html#isolevel_read-committed>) and the setting may
         * have been changed by other code. */
        $connection->setTransactionIsolation(\Doctrine\DBAL\Connection::TRANSACTION_READ_COMMITTED);

        /* We need to do all this in a transaction, because we remove several entities (Rules, Privileges,
         * the Resource), and recreate them. Without a transaction, we might lose pre-existing ACL rules in an error
         * case. */
        $entityManager->beginTransaction();

        TryWithFinally::tryWithFinally(function () use ($entityManager, $resourceName, $privileges, $menuItemName, $pluginID) {
            try {
                $this->replaceResourceTransaction($resourceName, $privileges, $menuItemName, $pluginID);
                $entityManager->commit();
            } catch (\Exception $e) {
                $entityManager->rollback();
                throw $e;
            }
        }, function () use ($connection, $oldTransactionIsolationLevel) {
            // Finally block
            // FIXME: Replace with real try/catch/finally once we can depend on PHP >= 5.5.
            // Reset the transaction level, which we customized at the beginning of this method.
            $connection->setTransactionIsolation($oldTransactionIsolationLevel);
        });
    }

    private function replaceResourceTransaction($resourceName, array $privileges = null, $menuItemName = null, $pluginID = null)
    {
        $entityManager = $this->entityManager;

        /* @var $oldRules Rule[] */
        $oldRules = [];

        // Find and remove existing resource with the same name.
        $oldResource = $this->helper->findResourceByName($resourceName);

        if ($oldResource !== null) {
            $oldRules = $this->getOldRulesAndRemoveRulesAndPrivilegesFromDatabase($oldResource);
        }

        // Creates the resource (s_core_acl_resources) and the corresponding privileges (in s_core_acl_privileges).
        $this->acl->createResource($resourceName, $privileges, $menuItemName, $pluginID);
        // No flush necessary, createResource flushes the resource and the new privileges.

        // We need to recreate the old ACL rules (association between role <-> (resource, privilege)).
        if (!empty($oldRules)) {
            $newResource = $this->helper->findResourceByName($resourceName);
            $newPrivileges = $this->helper->findPrivilegesByResourceId($newResource->getId());

            foreach ($oldRules as $oldRule) {
                $this->restoreRuleIfCompatible($oldRule, $newPrivileges, $newResource);
            }
        }
    }

    /**
     * Returns old ACL rules and removes them and the old privileges from the database.
     *
     * @return array<Rule> the old rules
     */
    private function getOldRulesAndRemoveRulesAndPrivilegesFromDatabase(Resource $oldResource)
    {
        $entityManager = $this->entityManager;

        // Find and remove existing privileges for old resource.
        // Note: $existingResource->getPrivileges() may not work here (may be empty).
        $oldPrivileges = $this->helper->findPrivilegesByResourceId($oldResource->getId());

        $oldRules = $this->helper->findRulesForPrivileges($oldPrivileges, $oldResource->getId());
        foreach ($oldRules as $oldRule) {
            /* @var $oldRule Rule */
            $entityManager->remove($oldRule);
        }
        $entityManager->flush($oldRules);

        /* Remove old Privileges from old resource. Otherwise, deleting the old resource is not possible because of
         * remaining associations in Doctrine. */
        foreach ($oldPrivileges as $oldPrivilege) {
            $entityManager->remove($oldPrivilege);
        }
        $entityManager->flush($oldPrivileges);

        $entityManager->remove($oldResource);
        $entityManager->flush($oldResource);

        return $oldRules;
    }

    /**
     * Creates a new rule matching $oldRule if $oldRule is is either a wildcard rule (privilegeId === null) for
     * $newResource of if $oldRule has matches a privilege in $newPrivileges.
     *
     * @param Rule $oldRule the old rule which should possibly be restored (containing a reference to the old privilege)
     * @param array<Privilege> $newPrivileges the new privileges which may or may not correspond to old ones as
     *                         referenced by $oldRule
     * @param Resource $newResource
     * @return void
     */
    private function restoreRuleIfCompatible(Rule $oldRule, $newPrivileges, Resource $newResource)
    {
        if ($oldRule->getPrivilegeId() === null) {
            // A privilege id of null is a wildcard to all privileges of the resource. FIXME
            $newRule = $this->createNewRule($oldRule, $newResource, null);
            $this->entityManager->persist($newRule);
            $this->entityManager->flush($newRule);

            return;
        }

        // Find newly created privilege matching $oldRule's privilege by the privilege's name.
        $oldPrivilegeName = $oldRule->getPrivilege()->getName();
        $correspondingPrivileges = array_filter($newPrivileges, function (Privilege $newPrivilege) use ($oldPrivilegeName) {
            return $newPrivilege->getName() === $oldPrivilegeName;
        });

        $newRules = [];
        // In sensible cases, count($correspondingPrivileges) === 0 or 1.
        foreach ($correspondingPrivileges as $correspondingPrivilege) {
            $newRule = $this->createNewRule($oldRule, $newResource, $correspondingPrivilege);
            $this->entityManager->persist($newRule);
            $newRules[] = $newRule;
        }
        $this->entityManager->flush($newRules);
    }

    /**
     * Creates a new role corresponding to $oldRule for $newResource and $newPrivilege (which may be null).
     *
     * @param Rule $oldRule
     * @param Resource $newResource
     * @param Privilege|null $newPrivilege
     * @return Rule
     */
    private function createNewRule(Rule $oldRule, Resource $newResource, Privilege $newPrivilege = null)
    {
        $newRule = new Rule();
        $newRule->setResource($newResource);
        $newRule->setResourceId($newResource->getId());
        $newRule->setRole($oldRule->getRole());
        $newRule->setRoleId($oldRule->getRoleId());
        $newRule->setPrivilege($newPrivilege);
        if ($newPrivilege !== null) {
            $newRule->setPrivilegeId($newPrivilege->getId());
        } else {
            $newRule->setPrivilegeId(null);
        }

        return $newRule;
    }
}
