<?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\Util;

use Dompdf\Dompdf;
use Enlight_Exception;
use FPDI;
use ReflectionClass;
use setasign\Fpdi\Fpdi as Fpdi2;
use Shopware\Bundle\StoreFrontBundle\Service\Core\ContextService;
use Shopware\Bundle\StoreFrontBundle\Struct\ListProduct;
use Shopware\Bundle\StoreFrontBundle\Struct\ProductContextInterface;
use Shopware\Components\Model\QueryBuilder;
use Shopware\Models\Article\Detail as ArticleDetail;
use Shopware\Models\Log\Log;
use Shopware\Models\Order\Detail as OrderDetail;
use Shopware\Models\Order\Order;
use Shopware\Models\Plugin\Plugin;
use Shopware\Models\Shop\Shop;
use Shopware\Plugins\ViisonCommon\Classes\CsvWriter;
use Shopware\Plugins\ViisonCommon\Classes\Exceptions\FileSystemExceptions\DirectoryNotWritableException;
use Shopware\Plugins\ViisonCommon\Classes\Exceptions\FileSystemExceptions\FileNotReadableException;
use Shopware\Plugins\ViisonCommon\Classes\FileResponseStream;
use Shopware\Plugins\ViisonCommon\Classes\Util\Util as ViisonCommonUtil;
use Shopware_Components_Plugin_Bootstrap;

class Util
{
    /**
     * A cached \Shopware\Models\Shop\Shop instance used to create the variant additional texts
     *
     * @see getVariantAdditionalText
     * @var \Shopware\Models\Shop\Shop $additionalTextShop
     */
    private static $additionalTextShop = null;

    /**
     * "Safely" explodes the fiven string at the positions of the delimiter.
     * That is, first the string is trimmed to avoid empty components.
     * In case the trimmed string is empty, an empty array is returned,
     * otherwise the result of the default explode method is returned,
     * while each of the result elements is trimmed.
     * This is important, because the default explode method returns
     * an array with an empty string as its only element, when applied on
     * an empty string.
     *
     * @param string $delitimer
     * @param string $string
     * @param int $limit (optional)
     * @return array
     */
    public static function safeExplode($delimiter, $string, $limit = PHP_INT_MAX)
    {
        return (mb_strlen(trim($string)) === 0) ? [] : array_map('trim', explode($delimiter, $string, $limit));
    }

    /**
     * Checks, if the given haystack starts with the given needle.
     *
     * @param string $haystack
     * @param string $needle
     * @return boolean
     */
    public static function startsWith($haystack, $needle)
    {
        return $needle === '' || mb_strpos($haystack, $needle) === 0;
    }

    /**
     * Checks, if the given haystack ends with the given needle.
     *
     * @param string $haystack
     * @param string $needle
     * @return boolean
     */
    public static function endsWith($haystack, $needle)
    {
        return $needle === '' || mb_substr($haystack, -1 * mb_strlen($needle)) === $needle;
    }

    /**
     * Fetches the translated name of a document, considering the subshop (possibly changed language) of the shop
     * in which the order was placed.
     *
     * @param $document \Shopware\Models\Order\Document\Document
     * @return string
     */
    public static function getTranslatedDocumentNameFromSubShop($document)
    {
        return self::getTranslatedDocumentName($document, true);
    }

    /**
     * Fetches the correct (translated) name of a document, depending on the locale of the shop of its order.
     * If $considerSubShop is set, it will consider the language of the subshop, otherwise
     * it will consider the shops main language (my differ from subshop).
     * Falls back on generic name "document" or its original (untranslated) name, if information gathering fails.
     *
     * @param $document \Shopware\Models\Order\Document\Document
     * @param bool $considerSubShop (default: false)
     * @return string
     */
    public static function getTranslatedDocumentName($document, $considerSubShop = false)
    {
        if (!$document || !$document->getType()) {
            return 'document';
        }

        if (ViisonCommonUtil::assertMinimumShopwareVersion('5.6.0')) {
            $translation = Shopware()->Container()->has('translation') ? Shopware()->Container()->get('translation') : new \Shopware_Components_Translation();
            $translator = $translation->getObjectTranslator('documents');

            $translated = $translator->translateObjectProperty(
                [
                    'id' => $document->getType()->getId(),
                    'name' => $document->getType()->getName(),
                ],
                'name'
            );

            return $translated['name'];
        }

        $originalName = $document->getType()->getName();

        // Fetch generic name (snippet title) for this document
        $snippet = Shopware()->Container()->get('models')->getRepository('Shopware\\Models\\Snippet\\Snippet')->findOneBy(
            [
                'value' => $originalName,
            ]
        );
        if (!$snippet || !$snippet->getName()) {
            return $originalName;
        }

        // Fetch locale id and translated name for this document
        $localeId = ($considerSubShop) ? $document->getOrder()->getLanguageSubShop()->getLocale()->getId() : $document->getOrder()->getShop()->getLocale()->getId();
        $actualSnippet = Shopware()->Container()->get('models')->getRepository('Shopware\\Models\\Snippet\\Snippet')->findOneBy(
            [
                'name' => $snippet->getName(),
                'namespace' => $snippet->getNamespace(),
                'localeId' => $localeId,
            ]
        );
        if (!$actualSnippet || !$actualSnippet->getValue()) {
            return $originalName;
        }

        return $actualSnippet->getValue();
    }

    /**
     * For the default document types invoice, delivery note, credit note and cancellation of invoice a document
     * number exists uniquely identifies the document. Therefore it makes sense to use that number in the name of
     * the attachment. For other document types, this number may not contain a meaningful value, though (e.g. in
     * case of coupons). For this reason, we fall back to using the order number in these cases.
     *
     * @param $document \Shopware\Models\Order\Document\Document
     * @return string
     */
    public static function getDocumentIdentifier($document)
    {
        if (!$document) {
            return '';
        }
        Shopware()->Container()->get('models')->refresh($document);
        if ($document->getTypeId() >= 1 && $document->getTypeId() <= 4) {
            return $document->getDocumentId();
        } else {
            return $document->getOrder()->getNumber();
        }
    }

    /**
     * Fetches a snippet (given by namespace and snippet identifier). The language is selected by given shop.
     * Remark: locale of subshop of given shop is used
     *
     * @param string $namespace
     * @param string $snippet
     * @param Shop $shop
     * @return string
     */
    public static function getSnippetFromLocale($namespace, $snippet, $shop)
    {
        if (!$shop) {
            return '';
        }

        $snippets = clone Shopware()->Container()->get('snippets');
        $snippets->setShop($shop);

        return $snippets->getNamespace($namespace)->get($snippet);
    }

    /**
     * Fetches all custom product information from an Order/Detail by a given id.
     * Returns a simplified array of custom product information:
     * array(
     *   optionname1 => array (
     *     value
     *   ),
     *   optionname2 => array (
     *     value21,
     *     value22,
     *     ...
     *   ),
     *   ...
     * )
     *
     * @param int $orderDetailId OrderDetail id
     * @return array custom product information. Returns empty array if no such information was found, or CustomProducts
     * plugin is not installed
     */
    public static function getCustomProductInformation($orderDetailId)
    {
        $result = [];
        // Make distinction between model selection for different versions of the CustomProducts plugin
        if (self::isPluginInstalledAndActive(null, 'SwagCustomProducts', '3.0.0')) {
            $customProductsConfigurationModel = 'SwagCustomProducts\\Models\\ConfigurationHash';
        } elseif (self::isPluginInstalledAndActive(null, 'SwagCustomProducts', '1.0.0')) {
            $customProductsConfigurationModel = 'Shopware\\CustomModels\\SwagCustomProducts\\ConfigurationHash';
        } else {
            return $result;
        }

        $orderDetail = Shopware()->Container()->get('models')->find('Shopware\\Models\\Order\\Detail', $orderDetailId);
        if (!$orderDetail || !$orderDetail->getAttribute() || !$orderDetail->getAttribute()->getSwagCustomProductsConfigurationHash()) {
            return $result;
        }

        // This is a custom product. Now construct information string
        $configHash = Shopware()->Container()->get('models')->getRepository($customProductsConfigurationModel)->findOneBy([
            'hash' => $orderDetail->getAttribute()->getSwagCustomProductsConfigurationHash(),
        ]);
        if (!$configHash) {
            return $result;
        }

        // Configuration contains the actual configuration (ids, custom values)
        $configuration = json_decode($configHash->getConfiguration(), true);
        // Templates contains information about the options (names, predefined values)
        $templates = json_decode($configHash->getTemplate(), true);

        foreach ($templates as $key => $template) {
            $result[$template['name']] = [];
            if ($template['could_contain_values']) {
                // Id is stored in configuration, corresponding value is stored in template (uploaded files are an exception)
                foreach ($configuration[$template['id']] as $chosenConfig) {
                    if (mb_strpos($template['type'], 'upload') !== false) {
                        // Uploaded file
                        $uploadeInformation = json_decode($chosenConfig);
                        foreach ($uploadeInformation as $file) {
                            $result[$template['name']][] = $file->name . ' (' . $file->path . ')';
                        }
                    } else {
                        // Any other predefined configuration
                        foreach ($template['values'] as $key => $templateValue) {
                            if ($templateValue['id'] == $chosenConfig) {
                                $result[$template['name']][] = ($templateValue['value']) ? $templateValue['name'] . ' (' . $templateValue['value'] . ')' : $templateValue['name'];
                                break;
                            }
                        }
                    }
                }
            } else {
                // Value is stored directly in configuration (e.g. a custom text)
                foreach ($configuration[$template['id']] as $key => $customValue) {
                    if ($template['type'] == 'wysiwyg') { // This is a HTML field
                        $customValueString = html_entity_decode($customValue);
                    } elseif ($template['type'] == 'date') { // 2016-12-01
                        $customValueString = mb_substr($customValue, 8, 2) . '.' . mb_substr($customValue, 5, 2) . '.' . mb_substr($customValue, 0, 4);
                    } else {
                        // any other custom value
                        $customValueString = $customValue;
                    }
                    $result[$template['name']][] = $customValueString;
                }
            }
        }

        return $result;
    }

    /**
     * Compares the given values either with an alphanumeric, case-insensitive, natural comparison function,
     * or with a custom numeric comparison function, if the 'numeric' boolean is TRUE.
     *
     * @param string $a
     * @param string $b
     * @param boolean $numeric
     * @return int
     */
    public static function natCaseCompare($a, $b, $numeric = false)
    {
        // Empty values (null, empty string) are not preferred in this sort.
        // That is, an empty value must always be ranked after a non-empty value.
        $aIsEmpty = $a === null || $a === '';
        $bIsEmpty = $b === null || $b === '';
        if (!$aIsEmpty && $bIsEmpty) {
            return -1;
        } elseif ($aIsEmpty && !$bIsEmpty) {
            return 1;
        } elseif ($aIsEmpty && $bIsEmpty) {
            return 0;
        }

        // Apply the comparison
        if ($numeric) {
            // Ignore all non-digit characters
            $digitsA = preg_replace('/[^0-9]*/', '', $a);
            $digitsB = preg_replace('/[^0-9]*/', '', $b);
            if ($digitsA < $digitsB) {
                return -1;
            } elseif ($digitsA > $digitsB) {
                return 1;
            }

            return 0;
        }

        // Use all characters for comparison
        return strnatcasecmp($a, $b);
    }

    /**
     * Trims the given string, but removes the given strip string at most n times
     * from the beginning and at most n times from the end of the string.
     *
     * @param string $string
     * @param string $strip
     * @param int limit (optional)
     * @return string
     */
    public static function trimN($string, $strip, $limit = 1)
    {
        $i = 0;
        $found = true;
        while ($i < $limit && $found) {
            $i++;
            $found = false;
            if (static::startsWith($string, $strip)) {
                $string = mb_substr($string, mb_strlen($strip));
                $found = true;
            }
            if (static::endsWith($string, $strip)) {
                $string = mb_substr($string, 0, -1 * mb_strlen($strip));
                $found = true;
            }
        }

        return $string;
    }

    /**
     * Checks whether the given version is greater or equal to
     * the currently installed Shopware version.
     *
     * @param string $version
     * @return bool
     */
    public static function assertMinimumShopwareVersion($version)
    {
        $shopwareVersion = Shopware()->Config()->version;

        return $shopwareVersion === '___VERSION___' || version_compare($shopwareVersion, $version, '>=');
    }

    /**
     * Note: This method uses the version returned by the plugin's bootstrap class for comparison. This allows us to
     * evaluate the actual code version, of the plugin, not the version install in the database.
     *
     * @param string $pluginName
     * @param string $minVersion
     * @param string $exclusiveMaxVersion
     * @return boolean True, iff the plugin with the passed `$pluginName` is installed, active and its version is
     *         greater or equal than the passed `$minVersion` and less than the passed `$exclusiveMaxVersion`.
     */
    public static function isPluginActiveAndSatisfiesVersionConstraints($pluginName, $minVersion, $exclusiveMaxVersion)
    {
        if (!self::isPluginInstalledAndActive(null, $pluginName, $minVersion)) {
            return false;
        }

        if (self::isPluginInstalledAndActive(null, $pluginName, $exclusiveMaxVersion)) {
            return false;
        }

        $pluginInfo = self::getPluginInfo($pluginName);
        if (!$pluginInfo) {
            return false;
        }

        // Get the info from the the plugin's bootstrap instance, because it contains the 'code version'
        $pluginManager = Shopware()->Container()->get('plugin_manager');
        $pluginInfo = $pluginManager->get($pluginInfo['namespace'])->get($pluginName)->getInfo();

        return (
            version_compare($pluginInfo['version'], $minVersion, '>=')
            && version_compare($pluginInfo['version'], $exclusiveMaxVersion, '<')
        );
    }

    /**
     * Determines whether the plugin with the given name is installed and active.
     * If the third parameter $minVersion is given, also the version of the plugin is evaluated.
     *
     * Remark 'unused module name': Since the new plugin system (SW5.2+) does not use module namespaces
     * (Backend/Core/Frontend) anymore, it was probably never relevant in the first place. Therefore ignore it.
     *
     * @param string|null $module (not used anymore)
     * @param string $pluginName
     * @param string|null $minVersion
     * @return boolean
     */
    public static function isPluginInstalledAndActive($module, $pluginName, $minVersion = null)
    {
        $pluginStatus = self::getPluginStatus($pluginName);

        return $pluginStatus && $pluginStatus['active'] && (!$minVersion || version_compare($pluginStatus['version'], $minVersion, '>='));
    }

    /**
     * Determines whether the plugin with the given name is installed (active or inactive).
     * If the third parameter $minVersion is given, also the version of the plugin is evaluated.
     *
     * Remark 'unused module name': Since the new plugin system (SW5.2+) does not use module namespaces
     * (Backend/Core/Frontend) anymore, it was probably never relevant in the first place. Therefore ignore it.
     *
     * @param string $module
     * @param string $pluginName
     * @param string|null $minVersion
     * @return boolean
     */
    public static function isPluginInstalled($module, $pluginName, $minVersion = null)
    {
        $pluginStatus = self::getPluginStatus($pluginName);

        return $pluginStatus && $pluginStatus['installed'] && (!$minVersion || version_compare($pluginStatus['version'], $minVersion, '>='));
    }

    /**
     * The cached list of plugins status infos that is created by 'getPluginStatus()'.
     *
     * @var array|null
     */
    private static $pluginStatusList = null;

    /**
     * Returns information about the plugin having the given $pluginName. Returns null if no matching plugin was found.
     * This method is safe to use at any time. That is, does not load any other services than the DBAL connection to
     * prevent race conditions in the order the plugins are loaded.
     *
     * @param string $pluginName
     * @return array|null
     */
    private static function getPluginStatus($pluginName)
    {
        if (self::$pluginStatusList === null) {
            // Fetch all plugins from the database
            $rawPluginList = Shopware()->Container()->get('dbal_connection')->fetchAll(
                'SELECT
                    name,
                    version,
                    active,
                    installation_date AS installed
                FROM s_core_plugins'
            );

            // Index the plugins by their name for easy look up and cache it for repeated access
            self::$pluginStatusList = [];
            foreach ($rawPluginList as $row) {
                $row['active'] = $row['active'] == '1';
                $row['installed'] = $row['installed'] !== null;
                self::$pluginStatusList[mb_strtolower($row['name'])] = $row;
            }
        }

        $lowercasePluginName = mb_strtolower($pluginName);

        return (isset(self::$pluginStatusList[$lowercasePluginName])) ? self::$pluginStatusList[$lowercasePluginName] : null;
    }

    /**
     * @param $pluginName
     * @return Shopware_Components_Plugin_Bootstrap|null The plugin's Bootstrap class, if it can be loaded, null otherwise
     */
    public static function getPluginBootstrap($pluginName)
    {
        $pluginInfo = self::getPluginInfo($pluginName);
        if (!$pluginInfo) {
            return null;
        }

        $pluginNamespaceName = $pluginInfo['namespace'];

        try {
            return Shopware()->Plugins()->$pluginNamespaceName()->$pluginName();
        } catch (Enlight_Exception $e) {
            return null;
        }
    }

    /**
     * The cached list of plugin models that is created by 'getPlugin()'.
     *
     * @var Plugin[]|null
     */
    private static $pluginList = null;

    /**
     * Returns the Plugin entity having the given $pluginName.
     *
     * This method is NOT safe to use while the plugins and their namespaces are still being loaded, because it uses the
     * doctrine entity managed!
     *
     * @param string $pluginName
     * @return Plugin|null Returns null if no matching plugin was found.
     */
    public static function getPlugin($pluginName)
    {
        if (self::$pluginList === null) {
            $pluginRepo = Shopware()->Container()->get('models')->getRepository('Shopware\\Models\\Plugin\\Plugin');
            $rawPluginList = $pluginRepo->findAll();

            // Index the plugins by their name for easy look up and cache it for repeated access
            self::$pluginList = [];
            foreach ($rawPluginList as $plugin) {
                self::$pluginList[mb_strtolower($plugin->getName())] = $plugin;
            }
        }

        $lowercasePluginName = mb_strtolower($pluginName);

        return (isset(self::$pluginList[$lowercasePluginName])) ? self::$pluginList[$lowercasePluginName] : null;
    }

    /**
     * The cached list of plugin info arrays that is created by 'getPluginInfo()'.
     *
     * @var array[]|null
     */
    private static $pluginInfoList = null;

    /**
     * Returns the plugin info array having the given $pluginName.
     *
     * This method is safe to use at any time. That is, it does not load any other services than the DBAL connection to
     * prevent race conditions in the order the plugins are loaded.
     *
     * @param string $pluginName
     * @return array|null Returns null if no matching plugin was found.
     */
    public static function getPluginInfo($pluginName)
    {
        if (self::$pluginInfoList === null) {
            $rawPluginInfoList = Shopware()->Container()->get('dbal_connection')->fetchAll(
                'SELECT *
                FROM `s_core_plugins`'
            );

            // Index the plugin infos by their name for easy look up and cache it for repeated access
            self::$pluginInfoList = [];
            foreach ($rawPluginInfoList as $pluginInfo) {
                self::$pluginInfoList[mb_strtolower($pluginInfo['name'])] = $pluginInfo;
            }
        }

        $lowercasePluginName = mb_strtolower($pluginName);

        return (isset(self::$pluginInfoList[$lowercasePluginName])) ? self::$pluginInfoList[$lowercasePluginName] : null;
    }

    /**
     * @return \Shopware\Models\User\User|null
     */
    public static function getCurrentUser()
    {
        if (Shopware()->Container()->get('front')->Request() === null) {
            // Fix CLI compatibility with SW 5.1 and SW 5.0
            // These SW versions would fail when accessing the auth service without a request (in cli)
            return null;
        }
        $identity = Shopware()->Container()->get('auth')->getIdentity();
        if (!$identity) {
            return null;
        }

        return Shopware()->Models()->find('Shopware\\Models\\User\\User', $identity->id);
    }

    /**
     * Registers a given shop entity as kernel resource. If no shop is given, the default shop
     * is loaded and registered.
     *
     * @param Shop|null $shop
     */
    public static function registerShop(Shop $shop = null)
    {
        if ($shop === null) {
            /** @var \Shopware\Models\Shop\Repository $shopRepository */
            $shopRepository = Shopware()->Models()->getRepository('Shopware\\Models\\Shop\\Shop');
            $shop = $shopRepository->getActiveDefault();
        }

        if (self::assertMinimumShopwareVersion('5.2.0')) {
            $shop->registerResources();
        } else {
            $shop->registerResources(Shopware()->Bootstrap());
        }
    }

    /**
     * Creates and returns a new config component for the given shop.
     *
     * @param \Shopware\Models\Shop\Shop $shop
     * @return \Shopware_Components_Config
     */
    public static function getShopConfig(\Shopware\Models\Shop\Shop $shop)
    {
        $container = Shopware()->Container();

        /** @var \Shopware_Components_Config $shopConfig */
        $shopConfig = clone $container->get('config');
        $shopConfig->setShop($shop);

        return $shopConfig;
    }

    /**
     * Assembles and returns the shop url for a given shop model entity. If
     * the "always secure" flag is set and the secure host and/or secure base
     * path values are not set, the shop's default host and/or base path are
     * used as fallback mimicing the logic used by shopware.
     *
     * @param \Shopware\Models\Shop\Shop $shop
     * @return string
     */
    public static function getShopUrl(\Shopware\Models\Shop\Shop $shop)
    {
        // Pre-Shopware-5.4 old-style "always secure" shop configuration
        $alwaysSecure = method_exists($shop, 'getAlwaysSecure') && $shop->getAlwaysSecure();

        // Post-Shopware-5.4 secure shop
        $newSecure = !method_exists($shop, 'getAlwaysSecure') && $shop->getSecure();

        $protocol = ($alwaysSecure || $newSecure) ? 'https' : 'http';
        $host = ($alwaysSecure && $shop->getSecureHost()) ? $shop->getSecureHost() : $shop->getHost();
        $basePath = ($alwaysSecure && $shop->getSecureBasePath()) ? $shop->getSecureBasePath() : $shop->getBasePath();

        return $protocol . '://' . $host . $basePath;
    }

    /**
     * If Shopware has a version of at least 5.0, the additional text service is used to
     * create and return the additional text of the article variant with the given ID/number.
     * For all Shopware versions < 5.0, the variant is fetched and it's 'additionalText' is
     * returned from the database. For all Shopware versions >= 5.2 it is possible to pass a
     * specific shop as the fourth parameter, for whose language the additional text shall
     * be created.
     *
     * @deprecated Use getVariantAdditionalTexts() instead.
     *
     * @param int $articleId
     * @param int $variantId
     * @param string $articleNumber
     * @param \Shopware\Models\Shop\Shop|null $shop
     * @return string
     */
    public static function getVariantAdditionalText($articleId, $variantId, $articleNumber, $shop = null)
    {
        $additionalTexts = self::getVariantAdditionalTexts([$variantId], $shop);

        return ($additionalTexts[$variantId]) ?: '';
    }

    /**
     * Returns an associative array with additional texts.
     * The key for the additional text ist the articleDetailId.
     * This helper fetches all additional texts in a single query and should be prefered when
     *
     * @param int[] $variantIds
     * @param \Shopware\Models\Shop\Shop|null $shop
     * @return string[] additional text
     */
    public static function getVariantAdditionalTexts(array $variantIds, Shop $shop = null)
    {
        if (count($variantIds) === 0) {
            return [];
        }

        /** @var QueryBuilder $builder */
        $builder = Shopware()->Container()->get('models')->createQueryBuilder();
        $builder
            ->select(
                'detail.id',
                'detail.articleId',
                'detail.number',
                'detail.additionalText'
            )
            ->from('Shopware\\Models\\Article\\Detail', 'detail')
            ->where('detail.id IN (:variantIds)')
            ->setParameter('variantIds', $variantIds);
        $result = $builder->getQuery()->getArrayResult();

        // Construct the additional texts using the respective storefront service
        $additionalTextService = Shopware()->Container()->get('shopware_storefront.additional_text_service');
        $listProducts = array_map(function ($variantData) {
            return new ListProduct($variantData['articleId'], $variantData['id'], $variantData['number']);
        }, $result);
        $listProductsWithAdditionalText = $additionalTextService->buildAdditionalTextLists(
            $listProducts,
            static::buildAdditionalTextShopContext($shop)
        );

        // Create the variantId => additionalText mapping
        $additionalTexts = [];
        foreach ($listProductsWithAdditionalText as $listProduct) {
            /** @var ListProduct $listProduct */
            $additionalTexts[$listProduct->getVariantId()] = ($listProduct->getAdditional()) ?: '';
        }

        return $additionalTexts;
    }

    /**
     * Creates a shop context for retrieving additionalTexts based on the shopware version
     * @param \Shopware\Models\Shop\Shop|null $shop
     * @return ProductContextInterface
     */
    protected static function buildAdditionalTextShopContext($shop = null)
    {
        if (static::assertMinimumShopwareVersion('5.2.0')) {
            // Shopware >= 5.2, hence use the context service to create a shop context. This also
            // supports the fourth, optional parameter of this function to select a specific shop.
            $shop = ($shop) ?: Shopware()->Models()->getRepository('Shopware\\Models\\Shop\\Shop')->getActiveDefault();
            $contextService = Shopware()->Container()->get('shopware_storefront.context_service');
            $context = $contextService->createShopContext(
                $shop->getId(),
                $shop->getCurrency()->getId(),
                ContextService::FALLBACK_CUSTOMER_GROUP
            );
        } else {
            // Shopware < 5.2, hence register the default shop manually and use the default context
            if (!static::$additionalTextShop) {
                static::$additionalTextShop = Shopware()->Models()->getRepository('Shopware\\Models\\Shop\\Shop')->getDefault();
                static::$additionalTextShop->registerResources(Shopware()->Bootstrap());
            }
            $context = Shopware()->Container()->get('shopware_storefront.context_service')->getProductContext();
        }

        return $context;
    }

    /**
     * Responds to the current request by streaming a PDF file given by its content to the output.
     *
     * @param \Enlight_Controller_Response_ResponseHttp $response
     * @param string $pdfContent
     * @param string $displayName
     * @param boolean $forceDownload if true the file should be sent as attachment, that is downloaded and
     *        saved locally by the browser; if false the file is expected to be displayed inline
     *        by the browser
     * @throws \Exception
     */
    public static function respondWithPDF(\Enlight_Controller_Response_ResponseHttp $response, $pdfContent, $displayName, $forceDownload = false)
    {
        // Disable Smarty rendering
        Shopware()->Front()->Plugins()->ViewRenderer()->setNoRender();
        Shopware()->Front()->Plugins()->Json()->setRenderer(false);

        // Stream pdf content to the output
        $fileStream = new FileResponseStream($response, $displayName, 'application/pdf', $forceDownload);
        try {
            $fileStream->write($pdfContent);
            $fileStream->close();
        } catch (\Exception $exception) {
            $fileStream->close();

            // Unfortunately the finally statement is not available in PHP < 5.5.
            // Hence we must rethrow the exception manually.
            throw $exception;
        }
    }

    /**
     * Responds to the current request by streaming a given file to the output.
     *
     * @param \Enlight_Controller_Response_ResponseHttp $response
     * @param string $file Path the file on the local (server) file system
     * @param string $displayFilename A name the remote system (client browser) gets suggested as file name for the file
     * @param string $contentType
     * @param boolean $forceDownload if true the file should be sent as attachment, that is downloaded and
     *        saved locally by the browser; if false the file is expected to be displayed inline
     *        by the browser
     * @throws \Exception
     */
    public static function respondWithFile(\Enlight_Controller_Response_ResponseHttp $response, $file, $displayFilename, $contentType, $forceDownload = false)
    {
        $fileHandle = fopen($file, 'rb');
        if ($fileHandle === false) {
            throw new FileNotReadableException($file);
        }

        // Disable the Json renderer. If we would not do that, the content type would be forced to "application/json"
        Shopware()->Container()->get('front')->Plugins()->Json()->setRenderer(false);

        $chunkSizeInBytes = 1024 * 1024;
        $fileStream = new FileResponseStream($response, $displayFilename, $contentType, $forceDownload);
        try {
            while (!feof($fileHandle)) {
                $nextChunkOfData = fgets($fileHandle, $chunkSizeInBytes);
                $fileStream->write($nextChunkOfData);
            }
            $fileStream->close();
            fclose($fileHandle);
        } finally {
            $fileStream->close();
            fclose($fileHandle);
        }
    }

    /**
     * Merge several pdf documents files into one document
     *
     * @param string[] $pdfFileNames
     * @param string $pdfOrientation 'P' or 'L' (Portrait, Landscape), 'AUTO' to decide automatically
     * @return string
     */
    public static function mergePdfDocumentFiles(array $pdfFileNames, $pdfOrientation = 'P')
    {
        // SW 5.3 brings FPDI as a composer requirement, so it does not need to be included anymore
        if (!class_exists('FPDI') && !class_exists('setasign\\Fpdi\\Fpdi')) {
            include_once('engine/Library/Fpdf/fpdf.php');
            include_once('engine/Library/Fpdf/fpdi.php');
        }
        // Create new PDF container
        // Shopware 5.7 ships with FPDI 2.x, use if available
        $useFpdf2 = class_exists('setasign\\Fpdi\\Fpdi');
        if ($useFpdf2) {
            $fpdi = new Fpdi2();
        } else {
            $fpdi = new FPDI();
        }
        // Add all files to the container
        foreach ($pdfFileNames as $pdfFileName) {
            // Add each page of the PDF file to the container
            $numPages = $fpdi->setSourceFile($pdfFileName);
            for ($i = 1; $i <= $numPages; $i++) {
                // Load the page and determine its size/layout
                $templateIndex = $fpdi->importPage($i);
                $size = $fpdi->getTemplateSize($templateIndex);

                // Mark the loaded page to be used for the merged PDF
                if ($pdfOrientation === 'AUTO') {
                    if ($useFpdf2) {
                        $pdfOrientationForPage = $size['orientation'];
                    } else {
                        $pdfOrientationForPage = ($size['h'] > $size['w']) ? 'P' : 'L';
                    }
                } else {
                    $pdfOrientationForPage = $pdfOrientation;
                }
                $fpdi->AddPage($pdfOrientationForPage, [
                    $useFpdf2 ? $size['width'] : $size['w'],
                    $useFpdf2 ? $size['height'] : $size['h'],
                ]);
                $fpdi->useTemplate($templateIndex);
                // Make sure to add an entry for this page in 'PageSizes', because the neither
                // 'AddPage()' nor 'useTemplate()' do this reliably. The reason for that is that
                // both methods compare the passed size and orientation with the 'current' size
                // and orientation respectively [1][2]. However, since the values are always an A4 paper
                // size with a protrait orientation, the condition for adding a 'PageSize' entry is
                // never true.
                //
                // [1] https://github.com/shopware/shopware/blob/77661288fafc1f962e7e12510988cbc5084d93d7/engine/Library/Fpdf/fpdi.php#L227
                // [2] https://github.com/shopware/shopware/blob/77661288fafc1f962e7e12510988cbc5084d93d7/engine/Library/Fpdf/fpdf.php#L1133
                //
                // The needed properties are protected in later FPDI versions. Access them via reflections
                $fpdiClass = class_exists('setasign\\Fpdi\\Fpdi') ? 'setasign\\Fpdi\\Fpdi' : 'FPDI';
                $reflectionClass = new ReflectionClass($fpdiClass);
                $wPtProperty = $reflectionClass->getProperty('wPt');
                $hPtProperty = $reflectionClass->getProperty('hPt');
                $wPtProperty->setAccessible(true);
                $hPtProperty->setAccessible(true);
                $wPt = $wPtProperty->getValue($fpdi);
                $hPt = $hPtProperty->getValue($fpdi);
                if ($useFpdf2) {
                    $pageInfoProperty = $reflectionClass->getProperty('PageInfo');
                    $pageInfoProperty->setAccessible(true);
                    $pageInfo = $pageInfoProperty->getValue($fpdi);
                    $pageInfo[$i]['size'] = [
                        $wPt,
                        $hPt,
                    ];
                    $pageInfoProperty->setValue($fpdi, $pageInfo);
                } else {
                    $fpdi->PageSizes[$i] = [
                        $wPt,
                        $hPt,
                    ];
                }
            }
        }

        // Parameter order is backwards compatible
        return $fpdi->Output('', 'S');
    }

    /**
     * Merge several pdf documents contents into one document
     *
     * @param string[] $pdfContents
     * @param string $pdfOrientation
     * @throws \Exception When cannot write sys temp dir
     * @return string
     */
    public static function mergePdfDocumentContents(array $pdfContents, $pdfOrientation = 'P')
    {
        $tempDir = self::getTempDir();
        $pdfFileNames = array_map(function ($pdfContent) use ($tempDir) {
            $tempFileName = tempnam($tempDir, 'viisonCommonPdfMerging');
            $handle = fopen($tempFileName, 'w');

            if ($handle === false) {
                throw new \Exception('Could not save document to the given path. Please check write permissions for dir: ' . $tempDir);
            }

            fwrite($handle, $pdfContent);
            fclose($handle);

            return $tempFileName;
        }, $pdfContents);

        $mergedPdf = self::mergePdfDocumentFiles($pdfFileNames, $pdfOrientation);
        array_map('unlink', $pdfFileNames);

        return $mergedPdf;
    }

    /**
     * Sets the necessary header values to prepare a CSV file transfer in the response
     * and finally writes the given array data to the response body.
     *
     * @param \Enlight_Controller_Response_Response $response
     * @param array $csvData
     * @param string $displayName
     *
     * @deprecated buffering the entire output is inefficient and can lead to OOM issues, use CsvWriter instead.
     * @see CsvWriter
     */
    public static function respondWithCSV(\Enlight_Controller_Response_Response $response, array $csvData, $displayName)
    {
        // Set necessary headers
        $response->setHeader('Content-Type', 'text/csv');
        $response->setHeader('Content-disposition', 'attachment; filename=' . $displayName, true);
        $response->sendHeaders();

        // Respond with a CSV file
        $handle = fopen('php://output', 'w');
        foreach ($csvData as $row) {
            fputcsv($handle, $row, ';');
        }
        fclose($handle);
    }

    /**
     * Sets the given local as the global PHP locale and uses the respective decimal point and
     * thousands separator to format the given number. Remark: If the number is a float, it's
     * decimal places are cut off before formatting and appended afterwards, to keep all decimal
     * places.
     *
     * @param int|float $number
     * @param string $locale
     * @return int|float
     */
    public static function localizedNumberFormat($number, $locale)
    {
        if (!is_numeric($number)) {
            return $number;
        }
        if (is_float($number)) {
            // Keep all decimal places
            $decimalPlaces = explode('.', ($number . ''));
            $decimalPlaces = $decimalPlaces[1];
        }

        // Save current locale and set the given locale
        $originalLocale = setlocale(LC_ALL, 0);
        setlocale(LC_ALL, $locale);

        // Format the number
        $conf = localeconv();
        // Remark: number_format rounds (in our case to the next full integer).
        // Since we handle decimalPlaces manually, round down.
        $flooredNumber = floor($number);
        $formattedNumber = number_format($flooredNumber, 0, $conf['decimal_point'], $conf['thousands_sep']);

        // Switch back to the original locale
        setlocale(LC_ALL, $originalLocale);

        if (isset($decimalPlaces)) {
            // Append decimal places again
            $formattedNumber .= $conf['decimal_point'] . $decimalPlaces;
        }

        return $formattedNumber;
    }

    /**
     * Creates a new entry in the backend log containing the given key and text.
     *
     * @param string $key
     * @param string $text
     */
    public static function createBackendLogEntry($key, $text)
    {
        // Handle user name if user is not set (e.g. in CronJob)
        $user = self::getCurrentUser();
        $username = $user ? $user->getName() : 'Unknown User';

        $log = new Log;
        $log->setKey($key);
        $log->setText($text);
        $log->setType('backend');
        $log->setUser($username);
        $log->setDate(new \DateTime());
        $log->setIpAddress(getenv('REMOTE_ADDR'));
        $userAgent = (!empty($_SERVER['HTTP_USER_AGENT'])) ? $_SERVER['HTTP_USER_AGENT'] : 'unknown';
        $log->setUserAgent($userAgent);
        $log->setValue4('');

        // Save changes
        Shopware()->Models()->persist($log);
        Shopware()->Models()->flush($log);
    }

    /**
     * A GUID is a 128-bit value consisting of one group of 8 hexadecimal
     * digits, followed by three groups of 4 hexadecimal digits each,
     * followed by one group of 12 hexadecimal digits. The following
     * example GUID shows the groupings of hexadecimal digits in a
     * GUID: 6B29FC40-CA47-1067-B31D-00DD010662DA
     *
     * @deprecated Use UuidUtil::generateUuidV4 instead
     *
     * @link http://guid.us/GUID/PHP
     * @return string
     */
    public static function createGUID()
    {
        return UuidUtil::generateUuidV4();
    }

    /**
     * Creates a Dompdf instance with some sensible default configuration, in particular a <code>tempDir</code> which is
     * guaranteed to be writable. Always use this factory method instead of calling <code>new Dompdf()</code> directly!
     * Also don't call $dompdf->setOptions(), but pass the correct options to this factory method.
     *
     * @param array $options any extra options to pass to the Dompdf constructor. These will overwrite the defaults
     * defined by this method.
     * @return Dompdf
     * @see Dompdf
     */
    public static function createDompdfInstance($options = [])
    {
        $defaultOptions = self::getDompdfDefaultOptions();
        $usedOptions = array_merge($defaultOptions, $options);

        return new Dompdf($usedOptions);
    }

    /**
     * @return array containing sane default options for Dompdf
     */
    public static function getDompdfDefaultOptions()
    {
        $defaultOptions = [
            // Use the Shopware temp dir which is verified to be writable during installation and as part of the system
            // check
            'tempDir' => self::getTempDir(),
            // Disable debug logging
            'logOutputFile' => null,
            // Workaround for problem 'No block-level parent found.  Not good." that can happen when libxml2 2.9.5
            // is used on the system.
            // See this issues on github:
            // # https://github.com/VIISON/ShopwareCommon/issues/40
            // # https://github.com/dompdf/dompdf/issues/1494
            'enable_html5_parser' => true,
            // Allow Dompdf to access assets like images or CSS files on remote sites. This is e.g. necessary
            // to support Shopware's media extension in smarty templates rendered with Dompdf, where media
            // files are accessed via http(s).
            'isRemoteEnabled' => true,
        ];

        return $defaultOptions;
    }

    /**
     * @return string the path to the Shopware version specific temporary files directory
     * @throws DirectoryNotWritableException when directory does not exist or is not writable
     */
    public static function getTempDir()
    {
        if (self::assertMinimumShopwareVersion('5.5')) {
            // phpcs:ignore Generic.PHP.ForbiddenFunctions.Found
            $tempDir = sys_get_temp_dir();
        } else {
            $tempDir = Shopware()->Container()->getParameter('kernel.root_dir') . '/media/temp';
        }

        if (!is_writable($tempDir)) {
            throw new DirectoryNotWritableException($tempDir);
        }

        return $tempDir;
    }

    /**
     * @deprecated Use service 'viison_common.document_file_storage_service' for reading/writing document files.
     *
     * Returns the Shopware-local documents directory (<code>$SHOPWARE/files/documents</code>).
     *
     * @return string the Shopware-local documents directory.
     * @throws DirectoryNotWritableException when directory does not exist or is not writable
     */
    public static function getDocumentsDir()
    {
        $documentsDir = Shopware()->Container()->getParameter('kernel.root_dir') . '/files/documents';
        if (!is_writable($documentsDir)) {
            throw new DirectoryNotWritableException($documentsDir);
        }

        return $documentsDir;
    }

    /**
     * Clones a given template manager instance in a smarty security aware way. Smarty security
     * is enabled by default in Shopware >= 5.3.
     *
     * @param \Enlight_Template_Manager $templateManager
     * @return \Enlight_Template_Manager
     */
    public static function cloneTemplateManager(\Enlight_Template_Manager $templateManager)
    {
        $clonedTemplateManager = clone $templateManager;

        // Technically smarty security is enabled, if a security policy is set for the template manager
        // instance. The security policy holds holds a reference to the template manager instance
        // (smarty class). Hence we need to clone and patch it, if necessary.
        if ($clonedTemplateManager->security_policy !== null) {
            $clonedTemplateManager->security_policy = clone $clonedTemplateManager->security_policy;
            $clonedTemplateManager->security_policy->smarty = $clonedTemplateManager;
        }

        return $clonedTemplateManager;
    }

    /**
     * Returns an array representation of the given Exception $e.
     *
     * The array contains the following elements:
     *  - class : string Name of class of $e
     *  - message : string
     *  - file : int
     *  - line : int
     *  - code : int
     *  - trace : string[]
     *  - previous : array|null -> The previous exception formatted with this method.
     *
     * @param \Exception $e
     * @return array
     */
    public static function exceptionToArray(\Exception $e)
    {
        return [
            'class' => get_class($e),
            'message' => $e->getMessage(),
            'file' => $e->getFile(),
            'line' => $e->getLine(),
            'code' => $e->getCode(),
            'trace' => explode("\n", $e->getTraceAsString()),
            'previous' => $e->getPrevious() ? self::exceptionToArray($e->getPrevious()) : null,
        ];
    }

    /**
     * Strips all numbers beyond the third position from a {@see ViisonCommon_Plugin_BootstrapV8::runUpdate()
     * runUpdate-style} version string.
     *
     * For example, this will convert a given version "1.6.1.0" or "1.6.1.55" to "1.6.1".
     *
     * Inputs, such as "install", which do not begin with three period-separated groups of digits, will be preserved.
     *
     * Use this function in a {@see ViisonCommon_Plugin_BootstrapV8::runUpdate() Bootstrap's runUpdate method} to
     * determine the significant parts of a version string to handle update routines. For example:
     *
     * ```
     * use Shopware\Plugins\ViisonCommon\Classes\Util\Util as ViisonCommonUtil;
     * // ...
     *     protected function runUpdate($oldVersion)
     *     {
     *         $oldInstallVersion = ViisonCommonUtil::convertBinaryVersionToInstallVersion($oldVersion);
     *         switch ($oldInstallVersion) {
     *             case 'install':
     *                 // ...
     *             case '1.2.3':
     *                 // ...
     *             case '1.2.4':
     *                 // Next release
     *
     *                 // NEVER REMOVE THE FOLLOWING BREAK!
     *                 break;
     *             default:
     *                 throw InstallationException::updateFromVersionNotSupported(
     *                     $this,
     *                     $oldVersion,
     *                     $this->getVersion(),
     *                     '1.2.2'
     *                 );
     *         }
     *     }
     * ```
     *
     * @param string $installVersion a version number such as 1.2.3, 1.2.3.0 or a non-version string such as "install"
     * @return string a trimmed version number such as 1.2.3, or a given non-version string such as "install"
     */
    public static function convertBinaryVersionToInstallVersion($installVersion)
    {
        return preg_replace('/^(\\d+\\.\\d+\\.\\d+)(?:[^0-9].*)$/u', '$1', $installVersion);
    }

    /**
     * @deprecated Use `\Shopware\Plugins\ViisonCommon\Classes\Util\OrderDetailUtil::getArticleDetailForOrderDetail` instead.
     *
     * Returns the ArticleDetail of an OrderDetail if it exists.
     *
     * Automatically uses the best way to find the ArticleDetail depending on the Shopware Version.
     * It either uses the direct association in the model (if SW 5.5) or it does a search by the ordernumber of the
     * article.
     *
     * @param OrderDetail $orderDetail
     * @return ArticleDetail|null
     */
    public static function getArticleDetailForOrderDetail(OrderDetail $orderDetail)
    {
        return OrderDetailUtil::getArticleDetailForOrderDetail($orderDetail);
    }

    /**
     * Returns the tax rate that is used for the shipping costs of an order.
     *
     * @param Order $order
     * @return float
     */
    public static function getShippingCostsTaxRateForOrder(Order $order)
    {
        if ($order->getInvoiceShippingNet() === 0.0) {
            return 0.0;
        }

        if (method_exists($order, 'getInvoiceShippingTaxRate') && $order->getInvoiceShippingTaxRate() !== null) {
            return $order->getInvoiceShippingTaxRate();
        }

        $approximateTaxRate = (($order->getInvoiceShipping() / $order->getInvoiceShippingNet()) - 1) * 100;
        $customerGroup = $order->getCustomer() ? $order->getCustomer()->getGroup() : null;
        $shippingAddress = $order->getShipping();
        $shippingAddressCountry = $shippingAddress ? $shippingAddress->getCountry() : null;
        $shippingAddressArea = $shippingAddressCountry ? $shippingAddressCountry->getArea() : null;
        $shippingAddressState = $shippingAddress ? $shippingAddress->getState() : null;

        $taxRate = Shopware()->Db()->fetchOne(
            'SELECT
                tax,
                ABS(tax - :approximateTaxRate) AS difference
            FROM `s_core_tax`
                WHERE ABS(tax - :approximateTaxRate) <= :maxDiff
            UNION SELECT
                tax,
                ABS(tax - :approximateTaxRate) AS difference
            FROM `s_core_tax_rules`
                WHERE active = 1 AND ABS(tax - :approximateTaxRate) <= :maxDiff
                AND (areaID = :areaId OR areaID IS NULL)
                AND (countryID = :countryId OR countryID IS NULL)
                AND (stateID = :stateId OR stateID IS NULL)
                AND (customer_groupID = :customerGroupId OR customer_groupID = 0 OR customer_groupID IS NULL)
            ORDER BY difference
            LIMIT 1',
            [
                'approximateTaxRate' => $approximateTaxRate,
                'maxDiff' => 0.1,
                'areaId' => $shippingAddressArea ? $shippingAddressArea->getId() : 0,
                'countryId' => $shippingAddressCountry ? $shippingAddressCountry->getId() : 0,
                'stateId' => $shippingAddressState ? $shippingAddressState->getId() : 0,
                'customerGroupId' => $customerGroup ? $customerGroup->getId() : 0,
            ]
        );

        if (!$taxRate) {
            return round($approximateTaxRate, 1);
        }

        return floatval($taxRate);
    }

    /**
     * Saves the passed `$postData` in PHP's global POST variable, to make sure that all passed values are contained.
     * This is necessary, because {@link Enlight_Controller_Request_RequestHttp::setPost()}, which is e.g. used by the
     * default plugin `RestApi` to save the parsed JSON POST data, filters out top-level `null` values. However, `null`
     * is a valid JSON value and hence it should be possible to set a field to `null` using the REST API.
     *
     * Note: The passed `$postData` does not completely overwrite the existing POST variable, but instead each values is
     * set individually. When passing an empty array however, the post data is cleared. This is the exact same behaviour
     * as is implemented in {@link Enlight_Controller_Request_RequestHttp::setPost()}, so we need to do the same to keep
     * compatibility with Shopware.
     *
     * Note: We don't access the global POST variable directly, because Shopware checks plugin code for accessing
     * globals and, if they do, rejects the code. Hence we use a hacky workaround, which cannot be detected by Shopware,
     * because they just grep for the actual global POST variable name. Hence we cannot even use the real variable name
     * in this comment (developers may call that variable 'you know who'), but will just point to the docs:
     *  - http://php.net/manual/reserved.variables.post.php
     *  - http://php.net/manual/reserved.variables.globals.php
     *
     * @param array $postData
     */
    public static function setRequestPostData(array $postData)
    {
        if (count($postData) > 0) {
            foreach ($postData as $key => $value) {
                ${'_POST'}[$key] = $value;
            }
        } else {
            ${'_POST'} = [];
        }

        if (self::assertMinimumShopwareVersion('5.6.0')) {
            $request = Shopware()->Container()->get('front')->Request();
            $reflectionObject = new \ReflectionObject($request);
            $requestProperty = $reflectionObject->getProperty('request');
            $requestProperty->setAccessible(true);
            $requestBag = $requestProperty->getValue($request);
            $requestBag->add($postData);
        }
    }
}
