<?php

namespace Ekyna\Component\Commerce\Quote\Entity;

use Ekyna\Component\Commerce\Common\Entity\AbstractSaleItem;
use Ekyna\Component\Commerce\Common\Model as Common;
use Ekyna\Component\Commerce\Exception\InvalidArgumentException;
use Ekyna\Component\Commerce\Quote\Model;

/**
 * Class QuoteItem
 * @package Ekyna\Component\Commerce\Quote\Entity
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
class QuoteItem extends AbstractSaleItem implements Model\QuoteItemInterface
{
    /**
     * @var Model\QuoteInterface
     */
    protected $quote;

    /**
     * @inheritdoc
     */
    public function getSale()
    {
        if (null === $quote = $this->getQuote()) {
            $parent = $this;
            while (null !== $parent) {
                if (null !== $quote = $parent->getQuote()) {
                    return $quote;
                }
                $parent = $parent->getParent();
            }
        }

        return $quote;
    }

    /**
     * @inheritdoc
     *
     * @param Model\QuoteInterface $sale
     */
    public function setSale(Common\SaleInterface $sale = null)
    {
        $sale && $this->assertSaleClass($sale);

        $this->setQuote($sale);

        return $this;
    }

    /**
     * @inheritdoc
     */
    public function getQuote()
    {
        return $this->quote;
    }

    /**
     * @inheritdoc
     */
    public function setQuote(Model\QuoteInterface $quote = null)
    {
        if ($quote !== $this->quote) {
            if ($previous = $this->quote) {
                $this->quote = null;
                $previous->removeItem($this);
            }

            if ($this->quote = $quote) {
                $this->quote->addItem($this);
            }
        }

        return $this;
    }

    /**
     * @inheritdoc
     */
    protected function assertSaleClass(Common\SaleInterface $sale)
    {
        if (!$sale instanceof Model\QuoteInterface) {
            throw new InvalidArgumentException("Expected instance of " . Model\QuoteInterface::class);
        }
    }

    /**
     * @inheritdoc
     */
    protected function assertItemClass(Common\SaleItemInterface $child)
    {
        if (!$child instanceof Model\QuoteItemInterface) {
            throw new InvalidArgumentException("Expected instance of " . Model\QuoteItemInterface::class);
        }
    }

    /**
     * @inheritdoc
     */
    protected function assertItemAdjustmentClass(Common\AdjustmentInterface $adjustment)
    {
        if (!$adjustment instanceof Model\QuoteItemAdjustmentInterface) {
            throw new InvalidArgumentException("Expected instance of " . Model\QuoteItemAdjustmentInterface::class);
        }
    }
}


<?php

namespace PeskyCMF\Scaffold\Form;

use PeskyORMLaravel\Db\Column\FilesColumn;
use PeskyORMLaravel\Db\Column\Utils\FileInfo;
use PeskyORMLaravel\Db\Column\Utils\FilesGroupConfig;
use PeskyORMLaravel\Db\Column\Utils\ImagesGroupConfig;

/**
 * @method FilesColumn getTableColumn()
 */
class FilesFormInput extends FormInput {

    /** @var string */
    protected $view = 'cmf::input.files_uploaders';

    /** @var null|array|\Closure */
    protected $fileConfigsToUse;

    protected $jsPluginOptions = [];

    /**
     * Disable uploading preview for this input
     * @return $this
     */
    public function disablePreview() {
        $this->jsPluginOptions['showPreview'] = false;
        return $this;
    }

    /**
     * @param array $options
     * @return $this
     */
    public function setJsPluginOptions(array $options) {
        $this->jsPluginOptions = $options;
        return $this;
    }

    public function getJsPluginOptions(): array {
        return $this->jsPluginOptions;
    }

    /**
     * @return string
     */
    public function getType() {
        return static::TYPE_HIDDEN;
    }

    /**
     * List of file names to accept.
     * Only provided files will be shown in form. Other files will be ignored (and won't be changed in any way)
     * @param array|\Closure $fileGroups - \Closure must return array
     * @return $this
     * @throws \InvalidArgumentException
     */
    public function setFilesGroupsToUse($fileGroups) {
        if (empty($fileGroups)) {
            throw new \InvalidArgumentException('$fileGroups argument cannot be empty');
        } else if (!is_array($fileGroups) && !($fileGroups instanceof \Closure)) {
            throw new \InvalidArgumentException('$fileGroups argument must be an array or \Closure');
        }
        $this->fileConfigsToUse = $fileGroups;
        return $this;
    }

    /**
     * @return \Closure
     * @throws \UnexpectedValueException
     * @throws \InvalidArgumentException
     * @throws \BadMethodCallException
     */
    public function getRenderer() {
        if (empty($this->renderer)) {
            return function () {
                /** @noinspection ExceptionsAnnotatingAndHandlingInspection */
                return $this->getDefaultRenderer();
            };
        } else {
            return $this->renderer;
        }
    }

    /**
     * @return InputRenderer
     * @throws \PeskyCMF\Scaffold\ScaffoldException
     * @throws \UnexpectedValueException
     * @throws \PeskyCMF\Scaffold\ValueViewerConfigException
     * @throws \InvalidArgumentException
     * @throws \BadMethodCallException
     */
    protected function getDefaultRenderer() {
        $column = $this->getTableColumn();
        if (!($column instanceof FilesColumn)) {
            throw new \BadMethodCallException(
                "Linked column for form field '{$this->getName()}' must be an instance of " . FilesColumn::class
            );
        }
        $configs = $this->getAcceptedFileConfigurations();
        if (empty($configs)) {
            throw new \BadMethodCallException(
                "There is no configurations for files in column '{$column->getName()}'"
            );
        }
        $renderer = new InputRenderer();
        $renderer
            ->setTemplate($this->view)
            ->addData('filesConfigs', $configs);
        return $renderer;
    }

    /**
     * @return FilesGroupConfig[]
     * @throws \UnexpectedValueException
     */
    protected function getAcceptedFileConfigurations() {
        $column = $this->getTableColumn();
        $configsNames = value($this->fileConfigsToUse);
        if ($configsNames === null) {
            return $column->getFilesGroupsConfigurations();
        } else if (!is_array($configsNames)) {
            throw new \UnexpectedValueException(
                static::class . '->fileConfigsToUse property must be an array or \Closure that returns an array'
            );
        } else {
            $ret = [];
            foreach ($configsNames as $name) {
                $ret[$name] = $column->getFilesGroupConfiguration($name);
            }
            return $ret;
        }
    }

    public function hasLabel() {
        return true;
    }

    public function getLabel(InputRenderer $renderer = null) {
        return '';
    }

    /**
     * @param mixed $value
     * @param string $type
     * @param array $data
     * @return array|mixed
     * @throws \PDOException
     * @throws \InvalidArgumentException
     * @throws \BadMethodCallException
     * @throws \UnexpectedValueException
     * @throws \PeskyORM\Exception\InvalidDataException
     * @throws \PeskyORM\Exception\OrmException
     */
    public function doDefaultValueConversionByType($value, $type, array $data) {
        $ret = [
            'urls' => [],
            'preview_info' => [],
            'files' => []
        ];
        if (!is_array($value)) {
            $value = json_decode($value, true);
        }
        if (!is_array($value) || empty($value)) {
            return $ret;
        }
        $record = $this->getScaffoldSectionConfig()->getTable()->newRecord();
        $pkValue = array_get($data, $record::getPrimaryKeyColumnName());
        $record->fromData($data, !empty($pkValue) || is_numeric($pkValue), false);

        $fileInfoArrays = $record->getValue($this->getTableColumn()->getName(), 'file_info_arrays');
        foreach ($fileInfoArrays as $fileName => $fileInfoArray) {
            if (empty($fileInfoArray)) {
                continue;
            }
            $ret['preview_info'][$fileName] = [];
            $ret['urls'][$fileName] = [];
            $ret['files'][$fileName] = [];
            /** @var FileInfo $fileInfo */
            foreach ($fileInfoArray as $fileInfo) {
                if ($fileInfo->exists()) {
                    $ret['urls'][$fileName][] = $fileInfo->getAbsoluteUrl();
                    $ret['files'][$fileName][] = [
                        'info' => $fileInfo->getCustomInfo(),
                        'name' => $fileInfo->getOriginalFileName() ?: $fileInfo->getFileName(),
                        'extension' => $fileInfo->getFileExtension(),
                        'uuid' => $fileInfo->getUuid(),
                    ];

                    $ret['preview_info'][$fileName][] = [
                        'caption' => $fileInfo->getOriginalFileNameWithExtension(),
                        'size' => $fileInfo->getSize(),
                        'downloadUrl' => $fileInfo->getAbsoluteUrl(),
                        'filetype' => $fileInfo->getMimeType(),
                        'type' => static::getUploaderPreviewTypeFromFileInfo($fileInfo),
                        'key' => 1
                    ];
                }
            }
        }
        return $ret;
    }

    /**
     * @param FileInfo $fileInfo
     * @return string
     */
    static protected function getUploaderPreviewTypeFromFileInfo(FileInfo $fileInfo) {
        $type = $fileInfo->getFileType();
        switch ($type) {
            case FilesGroupConfig::TYPE_IMAGE:
            case FilesGroupConfig::TYPE_AUDIO:
            case FilesGroupConfig::TYPE_VIDEO:
            case FilesGroupConfig::TYPE_TEXT:
            case FilesGroupConfig::TYPE_OFFICE:
                return $type;
            default:
                return 'object';
        }
    }

    public function getValidators($isCreation) {
        $validators = [];
        $configs = $this->getAcceptedFileConfigurations();
        foreach ($configs as $fileConfig) {
            $baseName = $this->getName() . '.' . $fileConfig->getName();
            $isRequired = $fileConfig->getMinFilesCount() > 0 ? 'required|' : '';
            $validators[$baseName] = $isRequired . 'array|max:' . $fileConfig->getMaxFilesCount();
            $commonValidators = 'nullable|' . ($fileConfig instanceof ImagesGroupConfig ? 'image' : 'file') . '|max:' . $fileConfig->getMaxFileSize()
                . '|mimetypes:' . implode(',', $fileConfig->getAllowedMimeTypes());
            for ($i = 0; $i < $fileConfig->getMaxFilesCount(); $i++) {
                if ($fileConfig->getMinFilesCount() > $i) {
                    $validators["{$baseName}.{$i}.file"] = "required_without:{$baseName}.{$i}.uuid|required_if:{$baseName}.{$i}.deleted,1|{$commonValidators}";
                    $validators["{$baseName}.{$i}.uuid"] = "required_without:{$baseName}.{$i}.file|nullable|string";
                } else {
                    $validators["{$baseName}.{$i}.file"] = "nullable|{$commonValidators}";
                    $validators["{$baseName}.{$i}.uuid"] = 'nullable|string';
                }
            }
        }
        return $validators;
    }

    public function hasTooltip() {
        return false; //< there can't be own tooltip for input. only image/file configs can have tooltips
    }

    /**
     * @return string|void
     * @throws \BadMethodCallException
     */
    public function getFormattedTooltip() {
        throw new \BadMethodCallException(
            'Tooltip for ' . get_class($this) .  ' is not allowed. There can only be tooltips for file/image configs.'
        );
    }

    /**
     * @param string $configName
     * @return string
     */
    public function getFormattedTooltipForFileConfig($configName) {
        $tooltips = $this->getTooltip();
        if (!is_array($tooltips) || empty($tooltips[$configName])) {
            return '';
        }
        return $this->buildTooltip($tooltips[$configName]);
    }

}


<?php

namespace Ekyna\Component\Commerce\Common\EventListener;

use Ekyna\Component\Commerce\Common\Currency\CurrencyConverterInterface;
use Ekyna\Component\Commerce\Common\Factory\SaleFactoryInterface;
use Ekyna\Component\Commerce\Common\Generator\KeyGeneratorInterface;
use Ekyna\Component\Commerce\Common\Generator\NumberGeneratorInterface;
use Ekyna\Component\Commerce\Common\Model\SaleInterface;
use Ekyna\Component\Commerce\Common\Resolver\StateResolverInterface;
use Ekyna\Component\Commerce\Common\Updater\SaleUpdaterInterface;
use Ekyna\Component\Commerce\Exception;
use Ekyna\Component\Commerce\Payment\Model\PaymentStates;
use Ekyna\Component\Commerce\Pricing\Updater\PricingUpdaterInterface;
use Ekyna\Component\Resource\Event\ResourceEventInterface;
use Ekyna\Component\Resource\Locale\LocaleProviderInterface;
use Ekyna\Component\Resource\Persistence\PersistenceHelperInterface;

/**
 * Class AbstractSaleListener
 * @package Ekyna\Component\Commerce\Common\EventListener
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
abstract class AbstractSaleListener
{
    /**
     * @var PersistenceHelperInterface
     */
    protected $persistenceHelper;

    /**
     * @var NumberGeneratorInterface
     */
    protected $numberGenerator;

    /**
     * @var KeyGeneratorInterface
     */
    protected $keyGenerator;

    /**
     * @var PricingUpdaterInterface
     */
    protected $pricingUpdater;

    /**
     * @var SaleFactoryInterface
     */
    protected $saleFactory;

    /**
     * @var SaleUpdaterInterface
     */
    protected $saleUpdater;

    /**
     * @var StateResolverInterface
     */
    protected $stateResolver;

    /**
     * @var CurrencyConverterInterface
     */
    protected $currencyConverter;

    /**
     * @var LocaleProviderInterface
     */
    protected $localeProvider;

    /**
     * @var string
     */
    protected $defaultVatDisplayMode;

    /**
     * Sets the persistence helper.
     *
     * @param PersistenceHelperInterface $helper
     */
    public function setPersistenceHelper(PersistenceHelperInterface $helper)
    {
        $this->persistenceHelper = $helper;
    }

    /**
     * Sets the number generator.
     *
     * @param NumberGeneratorInterface $generator
     */
    public function setNumberGenerator(NumberGeneratorInterface $generator)
    {
        $this->numberGenerator = $generator;
    }

    /**
     * Sets the key generator.
     *
     * @param KeyGeneratorInterface $generator
     */
    public function setKeyGenerator(KeyGeneratorInterface $generator)
    {
        $this->keyGenerator = $generator;
    }

    /**
     * Sets the pricingUpdater.
     *
     * @param PricingUpdaterInterface $updater
     */
    public function setPricingUpdater(PricingUpdaterInterface $updater)
    {
        $this->pricingUpdater = $updater;
    }

    /**
     * Sets the sale factory.
     *
     * @param SaleFactoryInterface $saleFactory
     */
    public function setSaleFactory(SaleFactoryInterface $saleFactory)
    {
        $this->saleFactory = $saleFactory;
    }

    /**
     * Sets the sale updater.
     *
     * @param SaleUpdaterInterface $updater
     */
    public function setSaleUpdater(SaleUpdaterInterface $updater)
    {
        $this->saleUpdater = $updater;
    }

    /**
     * Sets the state resolver.
     *
     * @param StateResolverInterface $resolver
     */
    public function setStateResolver(StateResolverInterface $resolver)
    {
        $this->stateResolver = $resolver;
    }

    /**
     * Sets the currency converter.
     *
     * @param CurrencyConverterInterface $converter
     */
    public function setCurrencyConverter(CurrencyConverterInterface $converter)
    {
        $this->currencyConverter = $converter;
    }

    /**
     * Sets the locale provider.
     *
     * @param LocaleProviderInterface $provider
     */
    public function setLocaleProvider(LocaleProviderInterface $provider)
    {
        $this->localeProvider = $provider;
    }

    /**
     * Sets the default vat display mode.
     *
     * @param string $mode
     */
    public function setDefaultVatDisplayMode(string $mode)
    {
        $this->defaultVatDisplayMode = $mode;
    }

    /**
     * Insert event handler.
     *
     * @param ResourceEventInterface $event
     *
     * @throws \Ekyna\Component\Commerce\Exception\CommerceExceptionInterface
     */
    public function onInsert(ResourceEventInterface $event)
    {
        $sale = $this->getSaleFromEvent($event);

        if ($this->handleInsert($sale)) {
            $this->persistenceHelper->persistAndRecompute($sale, false);
        }
    }

    /**
     * Handles the sale insertion.
     *
     * @param SaleInterface $sale
     *
     * @return bool Whether the sale has been changed.
     *
     * @throws \Ekyna\Component\Commerce\Exception\CommerceExceptionInterface
     */
    protected function handleInsert(SaleInterface $sale)
    {
        $changed = false;

        // Generate number and key
        $changed |= $this->updateNumber($sale);
        $changed |= $this->updateKey($sale);

        // Handle customer information
        $changed |= $this->updateInformation($sale, true);

        // Update pricing
        $changed |= $this->pricingUpdater->updateVatNumberSubject($sale);

        // Update outstanding
        $changed |= $this->saleUpdater->updatePaymentTerm($sale);

        // Update discounts
        $changed |= $this->saleUpdater->updateDiscounts($sale, true);

        // Update taxation
        $changed |= $this->saleUpdater->updateTaxation($sale, true);

        // Update totals
        $changed |= $this->saleUpdater->updateTotals($sale);

        // Update state
        $changed |= $this->updateState($sale);

        return $changed;
    }

    /**
     * Update event handler.
     *
     * @param ResourceEventInterface $event
     */
    public function onUpdate(ResourceEventInterface $event)
    {
        $sale = $this->getSaleFromEvent($event);

        $this->preventForbiddenChange($sale);

        if ($this->handleUpdate($sale)) {
            $this->persistenceHelper->persistAndRecompute($sale, false);
        }

        // Schedule content change
        if ($this->persistenceHelper->isChanged($sale, 'currency')) {
            $sale->setExchangeRate(null); // Clear exchange rate
            $this->scheduleContentChangeEvent($sale);
        } elseif ($this->persistenceHelper->isChanged($sale, ['vatDisplayMode', 'paymentTerm', 'shipmentAmount'])) {
            $this->scheduleContentChangeEvent($sale);
        }

        // Handle addresses changes
        /* TODO ? if ($this->persistenceHelper->isChanged($sale, ['deliveryAddress', 'sameAddress'])) {
            $this->scheduleAddressChangeEvent($sale);
        }*/
    }

    /**
     * Handles the sale update.
     *
     * @param SaleInterface $sale
     *
     * @return bool Whether the sale has been changed.
     */
    protected function handleUpdate(SaleInterface $sale)
    {
        $changed = false;

        // Generate number and key
        $changed |= $this->updateNumber($sale);
        $changed |= $this->updateKey($sale);

        // Handle customer information
        $changed |= $this->updateInformation($sale, true);

        // Update pricing
        if ($this->persistenceHelper->isChanged($sale, 'vatNumber')) {
            $changed |= $this->pricingUpdater->updateVatNumberSubject($sale);
        }

        // If customer has changed
        if ($this->persistenceHelper->isChanged($sale, 'customer')) {
            $changed |= $this->saleUpdater->updatePaymentTerm($sale);

            // TODO Update customer's balances

            // TODO For each payments
            // If payment is paid or has changed from paid state

            // TODO For each (credit)invoices
        }

        // Update shipment and amount
        if ($this->persistenceHelper->isChanged($sale, ['shipmentMethod', 'customerGroup'])) {
            $changed = $this->saleUpdater->updateShipmentMethodAndAmount($sale);
        }

        // Update discounts
        if ($this->isDiscountUpdateNeeded($sale)) {
            $changed |= $this->saleUpdater->updateDiscounts($sale, true);
        }

        // Update taxation
        if ($this->isTaxationUpdateNeeded($sale)) {
            $changed |= $this->saleUpdater->updateTaxation($sale, true);
        } elseif ($this->isShipmentTaxationUpdateNeeded($sale)) {
            $changed |= $this->saleUpdater->updateShipmentTaxation($sale, true);
        }

        return $changed;
    }

    /**
     * Address change event handler.
     *
     * @param ResourceEventInterface $event
     */
    public function onAddressChange(ResourceEventInterface $event)
    {
        $sale = $this->getSaleFromEvent($event);

        if ($this->persistenceHelper->isScheduledForRemove($sale)) {
            $event->stopPropagation();

            return;
        }

        if ($this->handleAddressChange($sale)) {
            $this->persistenceHelper->persistAndRecompute($sale, false);

            $this->scheduleContentChangeEvent($sale);
        }
    }

    /**
     * Handles the address change.
     *
     * @param SaleInterface $sale
     *
     * @return bool
     */
    protected function handleAddressChange(SaleInterface $sale)
    {
        $changed = false;

        // Update shipment method and amount
        if ($this->didDeliveryCountryChanged($sale)) {
            $changed |= $this->saleUpdater->updateShipmentMethodAndAmount($sale);
        }

        // Update discounts
        if ($this->isDiscountUpdateNeeded($sale)) {
            $changed |= $this->saleUpdater->updateDiscounts($sale, true);
        }

        // Update taxation
        if ($this->isTaxationUpdateNeeded($sale)) {
            $changed |= $this->saleUpdater->updateTaxation($sale, true);
        } elseif ($this->isShipmentTaxationUpdateNeeded($sale)) {
            $changed |= $this->saleUpdater->updateShipmentTaxation($sale, true);
        }

        return $changed;
    }

    /**
     * Content (item/adjustment/payment/shipment/invoice) change event handler.
     *
     * @param ResourceEventInterface $event
     *
     * @throws \Ekyna\Component\Commerce\Exception\CommerceExceptionInterface
     */
    public function onContentChange(ResourceEventInterface $event)
    {
        $sale = $this->getSaleFromEvent($event);

        if ($this->persistenceHelper->isScheduledForRemove($sale)) {
            $event->stopPropagation();

            return;
        }

        $this->handleContentChange($sale);

        // Reflect content change on update timestamp
        $sale->setUpdatedAt(new\DateTime());

        $this->persistenceHelper->persistAndRecompute($sale, false);
    }

    /**
     * Handles the content change.
     *
     * @param SaleInterface $sale
     *
     * @return bool
     *
     * @throws \Ekyna\Component\Commerce\Exception\CommerceExceptionInterface
     */
    protected function handleContentChange(SaleInterface $sale)
    {
        // Shipment method and amount
        $changed = $this->saleUpdater->updateShipmentMethodAndAmount($sale);

        // Shipment taxation
        if ($this->isShipmentTaxationUpdateNeeded($sale)) {
            $changed = $this->saleUpdater->updateShipmentTaxation($sale, true);
        }

        // Update totals
        $changed |= $this->saleUpdater->updateTotals($sale);

        // Update state
        $changed |= $this->updateState($sale);

        // Update outstanding date
        $changed |= $this->saleUpdater->updateOutstandingDate($sale);

        return $changed;
    }

    /**
     * State change event handler.
     *
     * @param ResourceEventInterface $event
     */
    public function onStateChange(ResourceEventInterface $event)
    {
        $sale = $this->getSaleFromEvent($event);

        if ($this->persistenceHelper->isScheduledForRemove($sale)) {
            $event->stopPropagation();

            return;
        }

        $this->handleStateChange($sale);
    }

    /**
     * Handles the state change.
     *
     * @param SaleInterface $sale
     */
    protected function handleStateChange(SaleInterface $sale)
    {
        if ($this->configureAcceptedSale($sale)) {
            $this->persistenceHelper->persistAndRecompute($sale, false);
        }
    }

    /**
     * Initialize event handler.
     *
     * @param ResourceEventInterface $event
     */
    public function onInitialize(ResourceEventInterface $event)
    {
        $sale = $this->getSaleFromEvent($event);

        if (null !== $customer = $sale->getCustomer()) {
            $invoiceDefault = $customer->getDefaultInvoiceAddress(true);
            $deliveryDefault = $customer->getDefaultDeliveryAddress(true);

            if (null === $sale->getInvoiceAddress() && null !== $invoiceDefault) {
                $sale->setInvoiceAddress(
                    $this->saleFactory->createAddressForSale($sale, $invoiceDefault)
                );
            }

            if (null === $sale->getDeliveryAddress() && null !== $deliveryDefault && $deliveryDefault !== $invoiceDefault) {
                $sale
                    ->setSameAddress(false)
                    ->setDeliveryAddress(
                        $this->saleFactory->createAddressForSale($sale, $invoiceDefault)
                    );
            }
        }
    }

    /**
     * Pre create event handler.
     *
     * @param ResourceEventInterface $event
     */
    public function onPreCreate(ResourceEventInterface $event)
    {
        $sale = $this->getSaleFromEvent($event);

        $this->updateInformation($sale);

        $this->pricingUpdater->updateVatNumberSubject($sale);
    }

    /**
     * Pre delete event handler.
     *
     * @param ResourceEventInterface $event
     *
     * @throws Exception\IllegalOperationException
     */
    public function onPreDelete(ResourceEventInterface $event)
    {
        if ($event->getHard()) {
            return;
        }

        if (null === $sale = $this->getSaleFromEvent($event)) {
            return;
        }

        // Stop if sale has valid payments
        foreach ($sale->getPayments() as $payment) {
            if (!PaymentStates::isDeletableState($payment->getState())) {
                throw new Exception\IllegalOperationException(
                    'Sale has valid payments and therefore cannot be deleted.'
                ); // TODO Translation
            }
        }
    }

    /**
     * Returns whether or not the discount adjustments should be updated.
     *
     * @param SaleInterface $sale
     *
     * @return bool
     */
    protected function isDiscountUpdateNeeded(SaleInterface $sale)
    {
        if ($this->persistenceHelper->isChanged($sale, ['autoDiscount'])) {
            return true;
        }

        if ((0 == $sale->getPaidTotal()) && $this->persistenceHelper->isChanged($sale, ['customerGroup', 'customer'])) {
            return true;
        }

        return $this->didInvoiceCountryChanged($sale);
    }

    /**
     * Returns whether the invoice address has changed.
     *
     * @param SaleInterface $sale
     *
     * @return bool
     */
    protected function didInvoiceCountryChanged(SaleInterface $sale)
    {
        $saleCs = $this->persistenceHelper->getChangeSet($sale);

        // Watch for invoice country change
        $oldCountry = $newCountry = null;

        $oldAddress = isset($saleCs['invoiceAddress']) ? $saleCs['invoiceAddress'][0] : $sale->getInvoiceAddress();
        if (null !== $oldAddress) {
            $oldAddressCs = $this->persistenceHelper->getChangeSet($oldAddress);
            $oldCountry = isset($oldAddressCs['country']) ? $oldAddressCs['country'][0] : $oldAddress->getCountry();
        }

        // Resolve the new tax resolution target country
        if (null !== $newAddress = $sale->getInvoiceAddress()) {
            $newCountry = $newAddress->getCountry();
        }

        if ($oldCountry !== $newCountry) {
            return true;
        }

        return false;
    }

    /**
     * Returns whether or not the taxation adjustments should be updated.
     *
     * @param SaleInterface $sale
     *
     * @return bool
     */
    protected function isTaxationUpdateNeeded(SaleInterface $sale)
    {
        // TODO (Order) Abort if "completed" and not "has changed for completed"

        // TODO Get tax resolution mode. (by invoice/delivery/origin).

        if ($this->persistenceHelper->isChanged($sale, ['taxExempt', 'customer', 'vatValid'])) {
            return true;
        }

        return $this->didDeliveryCountryChanged($sale);
    }

    /**
     * Returns whether the delivery country changed.
     *
     * @param SaleInterface $sale
     *
     * @return bool
     */
    protected function didDeliveryCountryChanged(SaleInterface $sale)
    {
        $saleCs = $this->persistenceHelper->getChangeSet($sale);

        // Watch for delivery country change
        $oldCountry = $newCountry = null;

        // Resolve the old tax resolution target country
        $oldSameAddress = isset($saleCs['sameAddress']) ? $saleCs['sameAddress'][0] : $sale->isSameAddress();
        if ($oldSameAddress) {
            $oldAddress = isset($saleCs['invoiceAddress']) ? $saleCs['invoiceAddress'][0] : $sale->getInvoiceAddress();
        } else {
            $oldAddress = isset($saleCs['deliveryAddress']) ? $saleCs['deliveryAddress'][0] : $sale->getDeliveryAddress();
        }
        if (null !== $oldAddress) {
            $oldAddressCs = $this->persistenceHelper->getChangeSet($oldAddress);
            $oldCountry = isset($oldAddressCs['country']) ? $oldAddressCs['country'][0] : $oldAddress->getCountry();
        }

        // Resolve the new tax resolution target country
        $newAddress = $sale->isSameAddress() ? $sale->getInvoiceAddress() : $sale->getDeliveryAddress();
        if (null !== $newAddress) {
            $newCountry = $newAddress->getCountry();
        }

        if ($oldCountry !== $newCountry) {
            return true;
        }

        return false;
    }

    /**
     * Returns whether or not the shipment related taxation adjustments should be updated.
     *
     * @param SaleInterface $sale
     *
     * @return bool
     */
    protected function isShipmentTaxationUpdateNeeded(SaleInterface $sale)
    {
        return $this->persistenceHelper->isChanged($sale, ['shipmentMethod', 'shipmentAmount']);
    }

    /**
     * Updates the number.
     *
     * @param SaleInterface $sale
     *
     * @return bool Whether the sale number has been update.
     */
    protected function updateNumber(SaleInterface $sale)
    {
        if (0 == strlen($sale->getNumber())) {
            $this->numberGenerator->generate($sale);

            return true;
        }

        return false;
    }

    /**
     * Updates the key.
     *
     * @param SaleInterface $sale
     *
     * @return bool Whether the sale key has been updated.
     */
    protected function updateKey(SaleInterface $sale)
    {
        if (0 == strlen($sale->getKey())) {
            $this->keyGenerator->generate($sale);

            return true;
        }

        return false;
    }

    /**
     * Updates the customer information.
     *
     * @param SaleInterface $sale
     * @param bool          $persistence
     *
     * @return bool Whether the sale has been changed or not.
     */
    protected function updateInformation(SaleInterface $sale, $persistence = false)
    {
        $changed = false;

        if (null !== $customer = $sale->getCustomer()) {
            // Customer group
            if (null === $sale->getCustomerGroup()) {
                $sale->setCustomerGroup($customer->getCustomerGroup());
                $changed = true;
            }

            // Email
            if (0 == strlen($sale->getEmail())) {
                $sale->setEmail($customer->getEmail());
                $changed = true;
            }

            // Identity
            if (0 == strlen($sale->getGender())) {
                $sale->setGender($customer->getGender());
                $changed = true;
            }
            if (0 == strlen($sale->getFirstName())) {
                $sale->setFirstName($customer->getFirstName());
                $changed = true;
            }
            if (0 == strlen($sale->getLastName())) {
                $sale->setLastName($customer->getLastName());
                $changed = true;
            }

            // Company
            if (0 == strlen($sale->getCompany()) && 0 < strlen($customer->getCompany())) {
                $sale->setCompany($customer->getCompany());
                $changed = true;
            }

            // Vat data
            $changed |= $this->updateVatData($sale);

            // Invoice address
            if (null === $sale->getInvoiceAddress() && null !== $address = $customer->getDefaultInvoiceAddress(true)) {
                $changed |= $this->saleUpdater->updateInvoiceAddressFromAddress($sale, $address, $persistence);
            }

            // Delivery address
            if ($sale->isSameAddress()) {
                // Remove unused address
                if (null !== $address = $sale->getDeliveryAddress()) {
                    $sale->setDeliveryAddress(null);
                    if ($persistence) {
                        $this->persistenceHelper->remove($address, true);
                    }
                }
            } else if (null === $sale->getDeliveryAddress() && null !== $address = $customer->getDefaultDeliveryAddress()) {
                $changed |= $this->saleUpdater->updateDeliveryAddressFromAddress($sale, $address, $persistence);
            }
        }

        // Vat display mode
        $changed |= $this->updateVatDisplayMode($sale);

        return $changed;
    }

    /**
     * Updates the vat data.
     *
     * @param SaleInterface $sale
     *
     * @return bool Whether the sale has been changed.
     */
    protected function updateVatData(SaleInterface $sale)
    {
        $changed = false;

        if (null !== $customer = $sale->getCustomer()) {
            if (0 == strlen($sale->getVatNumber()) && 0 < strlen($customer->getVatNumber())) {
                $sale->setVatNumber($customer->getVatNumber());
                $changed = true;
            }
            if (empty($sale->getVatDetails()) && !empty($customer->getVatDetails())) {
                $sale->setVatDetails($customer->getVatDetails());
                $changed = true;
            }
            if (!$sale->isVatValid() && $customer->isVatValid()) {
                $sale->setVatValid(true);
                $changed = true;
            }
        }

        return $changed;
    }

    /**
     * Updates the vat display mode.
     *
     * @param SaleInterface $sale
     *
     * @return bool
     */
    protected function updateVatDisplayMode(SaleInterface $sale)
    {
        // Vat display mode must not change if sale has payments
        if ($sale->hasPayments()) {
            return false;
        }

        $mode = null;
        if (null !== $group = $sale->getCustomerGroup()) {
            $mode = $group->getVatDisplayMode();
        }
        if (null === $mode) {
            $mode = $this->defaultVatDisplayMode;
        }

        if ($mode !== $sale->getVatDisplayMode()) {
            $sale->setVatDisplayMode($mode);

            return true;
        }

        return false;
    }

    /**
     * Updates the state.
     *
     * @param SaleInterface $sale
     *
     * @return bool Whether the sale has been changed or not.
     *
     * @throws \Ekyna\Component\Commerce\Exception\CommerceExceptionInterface
     */
    protected function updateState(SaleInterface $sale)
    {
        if ($this->stateResolver->resolve($sale)) {
            $this->scheduleStateChangeEvent($sale);

            return true;
        }

        return false;
    }

    /**
     * Updates the sale exchange rate.
     *
     * @param SaleInterface $sale
     *
     * @return bool Whether the sale has been changed.
     */
    protected function configureAcceptedSale(SaleInterface $sale)
    {
        if (null === $date = $sale->getAcceptedAt()) {
            return false;
        }

        $changed = $this->updateExchangeRate($sale);

        $changed |= $this->updateLocale($sale);

        return $changed;
    }

    /**
     * Sets the sale's exchange rate.
     *
     * @param SaleInterface $sale
     *
     * @return bool
     */
    protected function updateExchangeRate(SaleInterface $sale)
    {
        if (null !== $sale->getExchangeRate()) {
            return false;
        }

        $date = $sale->getExchangeDate() ?? new \DateTime();

        $rate = $this->currencyConverter->getRate(
            $this->currencyConverter->getDefaultCurrency(),
            $sale->getCurrency()->getCode(),
            $date
        );

        $sale
            ->setExchangeRate($rate)
            ->setExchangeDate($date);

        return true;
    }

    /**
     * Sets the sale's locale.
     *
     * @param SaleInterface $sale
     *
     * @return bool
     */
    protected function updateLocale(SaleInterface $sale)
    {
        if (null !== $sale->getLocale()) {
            return false;
        }

        $sale->setLocale($this->localeProvider->getCurrentLocale());

        return true;
    }

    /**
     * Prevent forbidden change(s).
     *
     * @param SaleInterface $sale
     *
     * @throws Exception\IllegalOperationException
     */
    protected function preventForbiddenChange(SaleInterface $sale)
    {
        if ($this->persistenceHelper->isChanged($sale, 'customer')) {
            list($old, $new) = $this->persistenceHelper->getChangeSet($sale, 'customer');
            if ($old != $new && (0 < $sale->getOutstandingAccepted() || 0 < $sale->getOutstandingExpired())) {
                throw new Exception\IllegalOperationException(
                    "Changing the customer while there is pending outstanding is not yet supported."
                );
            }
        }
    }

    /**
     * Returns the sale from the event.
     *
     * @param ResourceEventInterface $event
     *
     * @return SaleInterface
     * @throws Exception\InvalidArgumentException
     */
    abstract protected function getSaleFromEvent(ResourceEventInterface $event);

    /**
     * Schedule the content change event handler.
     *
     * @param SaleInterface $sale
     */
    abstract protected function scheduleContentChangeEvent(SaleInterface $sale);

    /**
     * Schedule the state change event handler.
     *
     * @param SaleInterface $sale
     */
    abstract protected function scheduleStateChangeEvent(SaleInterface $sale);
}


<?php

namespace PeskyCMF;

use Illuminate\Foundation\Application;
use PeskyCMF\Config\CmfConfig;
use Symfony\Component\HttpFoundation\ParameterBag;

class PeskyCmfManager {

    protected $currentCmfSectionName;
    /** @var CmfConfig */
    protected $currentCmfConfig;
    /** @var Application */
    protected $app;
    /** @var \Closure[] */
    protected $callbacks = [];

    /**
     * @param Application $app
     */
    public function __construct($app) {
        $this->app = $app;
    }

    protected function config($key) {
        return $this->appConfigs()->get('peskycmf.' . $key);
    }

    /**
     * @return ParameterBag
     */
    protected function appConfigs() {
        return $this->app['config'];
    }

    /**
     * @return CmfConfig|null
     */
    public function getCurrentCmfConfig() {
        return $this->currentCmfConfig;
    }

    /**
     * @return CmfConfig|null
     */
    public function getDefaultCmfConfig() {
        return CmfConfig::getDefault();
    }

    /**
     * @return string|null
     */
    public function getCurrentCmfSection() {
        return $this->currentCmfSectionName;
    }

    /**
     * @param string $cmfSectionName
     * @return CmfConfig
     */
    public function getCmfConfigForSection($cmfSectionName = null) {
        if ($cmfSectionName === null) {
            $cmfSectionName = $this->config('default_cmf_config');
            if (!$cmfSectionName) {
                throw new \InvalidArgumentException(
                    '$cmfSectionName is required when config(\'peskycmf.default_cmf_config\') is empty'
                );
            }
        }
        $knownConfigs = (array)$this->config('cmf_configs');
        if (!isset($knownConfigs[$cmfSectionName])) {
            throw new \InvalidArgumentException("There is no key '$cmfSectionName' in config('peskycmf.cmf_configs') array");
        }
        $className = $knownConfigs[$cmfSectionName];
        if (!class_exists($className)) {
            throw new \InvalidArgumentException("Class {$className} does not exists");
        }
        if (!is_subclass_of($className, CmfConfig::class)) {
            throw new \InvalidArgumentException("Class {$className} must extend " . CmfConfig::class . ' class');
        }
        /** @var CmfConfig $className */
        return $className::getInstance();
    }

    /**
     * Key from config('peskycmf.cmf_configs') array
     * @param string $cmfSectionName
     * @return $this
     */
    public function setCurrentCmfSection($cmfSectionName) {
        if ($cmfSectionName !== $this->currentCmfSectionName) {
            $this->currentCmfConfig = $this->getCmfConfigForSection($cmfSectionName);
            $this->currentCmfSectionName = $cmfSectionName;

            $this->currentCmfConfig->initSection($this->app);

            foreach ($this->callbacks as $closure) {
                $closure($this->currentCmfConfig);
            }
        }
        return $this;
    }

    /**
     * Add Closure to be evaluated after setCurrentCmfSection() call.
     * CmfConfig instance will be passed to closure.
     * @param \Closure $callback
     * @return $this
     */
    public function onSectionSet(\Closure $callback) {
        $this->callbacks[] = $callback;
        return $this;
    }

}


<?php

namespace Joli\Jane\OpenApi\Runtime\Client;

use Symfony\Component\OptionsResolver\OptionsResolver;

class QueryParam extends OptionsResolver
{
    /**
     * @var array
     */
    protected $formParameters = array();

    /**
     * @var array
     */
    protected $headerParameters = array();

    /**
     * Define a form parameter option.
     *
     * @param $optionNames
     *
     * @return $this
     */
    public function setFormParameters($optionNames)
    {
        foreach ((array) $optionNames as $option) {
            $this->formParameters[$option] = true;
        }

        return $this;
    }

    /**
     * Define a header parameter option.
     *
     * @param $optionNames
     *
     * @return $this
     */
    public function setHeaderParameters($optionNames)
    {
        foreach ((array) $optionNames as $option) {
            $this->headerParameters[$option] = true;
        }

        return $this;
    }

    /**
     * Build the query string.
     *
     * @param $options
     *
     * @return string
     */
    public function buildQueryString($options)
    {
        $options = $this->resolve($options);

        foreach ($this->formParameters as $key => $isFormParameter) {
            if ($isFormParameter && isset($options[$key])) {
                unset($options[$key]);
            }
        }

        foreach ($this->headerParameters as $key => $isHeaderParameter) {
            if ($isHeaderParameter && isset($options[$key])) {
                unset($options[$key]);
            }
        }

        return http_build_query($options);
    }

    /**
     * Build form data string.
     *
     * @param $options
     *
     * @return string
     */
    public function buildFormDataString($options)
    {
        $options = $this->resolve($options);
        $formOptions = [];

        foreach ($this->formParameters as $key => $isFormParameter) {
            if ($isFormParameter && isset($options[$key])) {
                $formOptions[$key] = $options[$key];
            }
        }

        return http_build_query($formOptions);
    }

    /**
     * Build headers list.
     *
     * @param $options
     *
     * @return array
     */
    public function buildHeaders($options)
    {
        $options = $this->resolve($options);
        $headerOptions = [];

        foreach ($this->headerParameters as $key => $isHeaderParameter) {
            if ($isHeaderParameter && isset($options[$key])) {
                $headerOptions[$key] = $options[$key];
            }
        }

        return $headerOptions;
    }
}


<?php

namespace Ekyna\Component\Commerce\Customer\EventListener;

use Ekyna\Component\Commerce\Customer\Model\CustomerGroupInterface;
use Ekyna\Component\Commerce\Customer\Repository\CustomerGroupRepositoryInterface;
use Ekyna\Component\Commerce\Exception\IllegalOperationException;
use Ekyna\Component\Commerce\Exception\InvalidArgumentException;
use Ekyna\Component\Commerce\Exception\RuntimeException;
use Ekyna\Component\Resource\Event\ResourceEventInterface;
use Ekyna\Component\Resource\Persistence\PersistenceHelperInterface;

/**
 * Class CustomerGroupListener
 * @package Ekyna\Component\Commerce\Customer\EventListener
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
class CustomerGroupListener
{
    /**
     * @var PersistenceHelperInterface
     */
    protected $persistenceHelper;

    /**
     * @var CustomerGroupRepositoryInterface
     */
    protected $customerGroupRepository;

    /**
     * Constructor.
     *
     * @param PersistenceHelperInterface       $persistenceHelper
     * @param CustomerGroupRepositoryInterface $customerGroupRepository
     */
    public function __construct(
        PersistenceHelperInterface $persistenceHelper,
        CustomerGroupRepositoryInterface $customerGroupRepository
    ) {
        $this->persistenceHelper = $persistenceHelper;
        $this->customerGroupRepository = $customerGroupRepository;
    }

    /**
     * Pre delete event handler.
     *
     * @param ResourceEventInterface $event
     *
     * @throws IllegalOperationException
     */
    public function onPreDelete(ResourceEventInterface $event)
    {
        $group = $this->getCustomerGroupFromEvent($event);

        if ($group->isDefault()) {
            throw new IllegalOperationException(); // TODO reason message
        }
    }

    /**
     * Insert event handler.
     *
     * @param ResourceEventInterface $event
     */
    public function onInsert(ResourceEventInterface $event)
    {
        $group = $this->getCustomerGroupFromEvent($event);

        $this->fixDefault($group);
    }

    /**
     * Update event handler.
     *
     * @param ResourceEventInterface $event
     */
    public function onUpdate(ResourceEventInterface $event)
    {
        $address = $this->getCustomerGroupFromEvent($event);

        $this->fixDefault($address);
    }

    /**
     * Fixes the default customer group.
     *
     * @param CustomerGroupInterface $customerGroup
     */
    protected function fixDefault(CustomerGroupInterface $customerGroup)
    {
        if (!$this->persistenceHelper->isChanged($customerGroup, ['default'])) {
            return;
        }

        if ($customerGroup->isDefault()) {
            try {
                $previousGroup = $this->customerGroupRepository->findDefault();
            } catch (RuntimeException $e) {
                return;
            }

            if ($previousGroup === $customerGroup) {
                return;
            }

            $previousGroup->setDefault(false);

            $this->persistenceHelper->persistAndRecompute($previousGroup, false);
        }
    }

    /**
     * Returns the customer group from the event.
     *
     * @param ResourceEventInterface $event
     *
     * @return CustomerGroupInterface
     * @throws InvalidArgumentException
     */
    protected function getCustomerGroupFromEvent(ResourceEventInterface $event)
    {
        $resource = $event->getResource();

        if (!$resource instanceof CustomerGroupInterface) {
            throw new InvalidArgumentException('Expected instance of ' . CustomerGroupInterface::class);
        }

        return $resource;
    }
}


<?php

namespace Ekyna\Component\Commerce\Bridge\Symfony\Validator\Constraints;

use Ekyna\Component\Commerce\Common\Model\AdjustmentInterface;
use Ekyna\Component\Commerce\Common\Model\AdjustmentTypes;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;

/**
 * Class AdjustmentValidator
 * @package Ekyna\Component\Commerce\Bridge\Symfony\Validator\Constraints
 * @author Étienne Dauvergne <contact@ekyna.com>
 */
class AdjustmentValidator extends ConstraintValidator
{
    /**
     * {@inheritdoc}
     */
    public function validate($adjustment, Constraint $constraint)
    {
        if (null === $adjustment) {
            return;
        }

        if (!$adjustment instanceof AdjustmentInterface) {
            throw new UnexpectedTypeException($adjustment, AdjustmentInterface::class);
        }
        if (!$constraint instanceof Adjustment) {
            throw new UnexpectedTypeException($constraint, Adjustment::class);
        }

        if ($adjustment->getType() !== AdjustmentTypes::TYPE_DISCOUNT) {
            $violationList = $this
                ->context
                ->getValidator()
                ->validate($adjustment->getDesignation(), [new Assert\NotBlank()]);

            /** @var \Symfony\Component\Validator\ConstraintViolationInterface $violation */
            foreach ($violationList as $violation) {
                $this->context
                    ->buildViolation($violation->getMessage())
                    ->atPath('designation')
                    ->addViolation();
            }
        }
    }
}


<?php

namespace Ekyna\Component\Commerce\Shipment\Model;

use Ekyna\Component\Commerce\Exception\InvalidArgumentException;

/**
 * Class ShipmentStates
 * @package Ekyna\Component\Commerce\Shipment\Model
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
final class ShipmentStates
{
    // Common
    const STATE_NEW         = 'new';
    const STATE_CANCELED    = 'canceled';

    // Shipment
    const STATE_PREPARATION = 'preparation';
    const STATE_READY       = 'ready';
    const STATE_SHIPPED     = 'shipped';

    // Return
    const STATE_PENDING     = 'pending';
    const STATE_RETURNED    = 'returned';

    // For sale
    const STATE_NONE        = 'none';
    const STATE_PARTIAL     = 'partial';
    const STATE_COMPLETED   = 'completed';

    /**
     * Returns all the states.
     *
     * @return array
     */
    static public function getStates()
    {
        return [
            static::STATE_NEW,
            static::STATE_CANCELED,
            static::STATE_PREPARATION,
            static::STATE_READY,
            static::STATE_SHIPPED,
            static::STATE_PENDING,
            static::STATE_RETURNED,
            static::STATE_NONE,
            static::STATE_PARTIAL,
            static::STATE_COMPLETED,
        ];
    }

    /**
     * Returns whether the given state is valid or not.
     *
     * @param string $state
     *
     * @return bool
     */
    static public function isValidState($state)
    {
        return in_array($state, static::getStates(), true);
    }

    /**
     * Returns the notifiable states.
     *
     * @return array
     */
    static public function getNotifiableStates()
    {
        return [
            static::STATE_READY,
            static::STATE_RETURNED,
            static::STATE_SHIPPED,
        ];
    }

    /**
     * Returns whether the given state is a notifiable state.
     *
     * @param string $state
     *
     * @return bool
     */
    static public function isNotifiableState($state)
    {
        return in_array($state, static::getNotifiableStates(), true);
    }

    /**
     * Returns whether the given shipment (or return) is a done.
     *
     * @param ShipmentInterface $shipment
     *
     * @return bool
     *
     * @TODO remove
     * @deprecated No longer used
     */
    static public function isDone(ShipmentInterface $shipment)
    {
        if ($shipment->isReturn()) {
            if (static::STATE_RETURNED === $shipment->getState()) {
                return true;
            }
        } elseif (static::STATE_SHIPPED === $shipment->getState()) {
            return true;
        }

        return false;
    }

    /**
     * Returns the deletable states.
     *
     * @return array
     */
    static public function getDeletableStates()
    {
        return [
            static::STATE_NEW,
            static::STATE_NONE,
            static::STATE_CANCELED,
        ];
    }

    /**
     * Returns whether the given state is a deletable state.
     *
     * @param string $state
     *
     * @return bool
     */
    static public function isDeletableState($state)
    {
        return is_null($state) || in_array($state, static::getDeletableStates(), true);
    }

    /**
     * Returns the preparable states.
     *
     * @return array
     */
    static public function getPreparableStates()
    {
        return [
            static::STATE_NEW,
            static::STATE_NONE,
            static::STATE_PARTIAL,
        ];
    }

    /**
     * Returns whether the given state is a preparable state.
     *
     * @param string $state
     *
     * @return bool
     */
    static public function isPreparableState($state)
    {
        return is_null($state) || in_array($state, static::getPreparableStates(), true);
    }

    /**
     * Returns whether or not the state has changed
     * from a non deletable state to a deletable state.
     *
     * @param array $cs The persistence change set
     *
     * @return bool
     */
    static public function hasChangedToDeletable(array $cs)
    {
        return static::assertValidChangeSet($cs)
            && !static::isDeletableState($cs[0])
            && static::isDeletableState($cs[1]);
    }

    /**
     * Returns whether or not the state has changed
     * from a deletable state to a non deletable state.
     *
     * @param array $cs The persistence change set
     *
     * @return bool
     */
    static public function hasChangedFromDeletable(array $cs)
    {
        return static::assertValidChangeSet($cs)
            && static::isDeletableState($cs[0])
            && !static::isDeletableState($cs[1]);
    }

    /**
     * Returns the debit stock states.
     *
     * @return array
     */
    static public function getStockableStates()
    {
        return [
            static::STATE_READY,
            static::STATE_PARTIAL, // This is a sale state
            static::STATE_SHIPPED,
            static::STATE_RETURNED,
            static::STATE_COMPLETED,
        ];
    }

    /**
     * Returns whether the given state is a stock state.
     *
     * @param string $state
     *
     * @return bool
     */
    static public function isStockableState($state)
    {
        return in_array($state, static::getStockableStates(), true);
    }

    /**
     * Returns whether or not the state has changed
     * from a non stockable state to a stockable state.
     *
     * @param array $cs The persistence change set
     *
     * @return bool
     */
    static public function hasChangedToStockable(array $cs)
    {
        return static::assertValidChangeSet($cs)
            && !static::isStockableState($cs[0])
            && static::isStockableState($cs[1]);
    }

    /**
     * Returns whether or not the state has changed
     * from a stockable state to a non stockable state.
     *
     * @param array $cs The persistence change set
     *
     * @return bool
     */
    static public function hasChangedFromStockable(array $cs)
    {
        return static::assertValidChangeSet($cs)
            && static::isStockableState($cs[0])
            && !static::isStockableState($cs[1]);
    }

    /**
     * Returns whether or not the change set is valid.
     *
     * @param array $cs
     *
     * @return bool
     *
     * @throws InvalidArgumentException
     */
    static private function assertValidChangeSet(array $cs)
    {
        if (
            array_key_exists(0, $cs) &&
            array_key_exists(1, $cs) &&
            (is_null($cs[0]) || static::isValidState($cs[0])) &&
            (is_null($cs[1]) || static::isValidState($cs[1]))
        ) {
            return true;
        }

        throw new InvalidArgumentException("Unexpected order state change set.");
    }

    /**
     * Disabled constructor.
     *
     * @codeCoverageIgnore
     */
    final private function __construct()
    {
    }
}


<?php

namespace Ekyna\Component\Commerce\Shipment\Gateway;

use Ekyna\Component\Commerce\Exception\InvalidArgumentException;
use Ekyna\Component\Commerce\Exception\RuntimeException;
use Ekyna\Component\Commerce\Shipment\Model\ShipmentInterface;
use Symfony\Component\Config\Definition;

/**
 * Class AbstractPlatform
 * @package Ekyna\Component\Commerce\Shipment\Gateway
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
abstract class AbstractPlatform implements PlatformInterface
{
    /**
     * @var RegistryInterface
     */
    protected $registry;

    /**
     * @var Definition\ArrayNode
     */
    private $configDefinition;

    /**
     * @inheritdoc
     */
    public function supports(string $action)
    {
        return in_array($action, $this->getActions(), true);
    }

    /**
     * @inheritdoc
     */
    public function export(array $shipments)
    {
        $this->throwUnsupportedOperation('export');
    }

    /**
     * @inheritdoc
     */
    public function import($path)
    {
        $this->throwUnsupportedOperation('import');
    }

    /**
     * @inheritdoc
     */
    public function setRegistry(RegistryInterface $registry)
    {
        $this->registry = $registry;
    }

    /**
     * @inheritdoc
     */
    public function getConfigDefinition()
    {
        if (null !== $this->configDefinition) {
            return $this->configDefinition;
        }

        $treeBuilder = new Definition\Builder\TreeBuilder();

        $this->createConfigDefinition($treeBuilder->root('config'));

        return $this->configDefinition = $treeBuilder->buildTree();
    }

    /**
     * @inheritdoc
     */
    public function getConfigDefaults()
    {
        return [];
    }

    /**
     * @inheritdoc
     */
    public function processGatewayConfig(array $config)
    {
        $processor = new Definition\Processor();

        return $processor->process($this->getConfigDefinition(), ['config' => $config]);
    }

    /**
     * Creates the gateway config definition.
     *
     * @param Definition\Builder\NodeDefinition $rootNode
     */
    protected function createConfigDefinition(Definition\Builder\NodeDefinition $rootNode)
    {

    }

    /**
     * Asserts that the given shipment is supported by this platform.
     *
     * @param ShipmentInterface $shipment
     *
     * @throws InvalidArgumentException
     */
    protected function assertShipmentPlatform(ShipmentInterface $shipment)
    {
        if ($shipment->getPlatformName() !== $this->getName()) {
            throw new InvalidArgumentException(sprintf(
                "Platform %s does not support shipment %s.",
                $this->getName(), $shipment->getNumber()
            ));
        }
    }

    /**
     * Throws an unsupported operation exception.
     *
     * @param string $operation
     *
     * @throws RuntimeException
     */
    protected function throwUnsupportedOperation($operation)
    {
        throw new RuntimeException(
            "The shipment platform '{$this->getName()}' does not support '$operation' operation."
        );
    }
}


<?php

namespace Ekyna\Component\Commerce\Stock\Resolver;

use Ekyna\Component\Commerce\Exception\InvalidArgumentException;
use Ekyna\Component\Commerce\Stock\Cache\StockUnitCacheInterface;
use Ekyna\Component\Commerce\Stock\Model\StockSubjectInterface;
use Ekyna\Component\Commerce\Stock\Model\StockUnitInterface;
use Ekyna\Component\Commerce\Stock\Repository\StockUnitRepositoryInterface;
use Ekyna\Component\Commerce\Subject\Model\SubjectRelativeInterface;
use Ekyna\Component\Commerce\Subject\SubjectHelperInterface;
use Ekyna\Component\Resource\Persistence\PersistenceHelperInterface;

/**
 * Class StockUnitResolver
 * @package Ekyna\Component\Commerce\Stock\Resolver
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
class StockUnitResolver implements StockUnitResolverInterface
{
    /**
     * @var SubjectHelperInterface
     */
    protected $subjectHelper;

    /**
     * @var StockUnitCacheInterface
     */
    protected $unitCache;

    /**
     * @var PersistenceHelperInterface
     */
    protected $persistenceHelper;

    /**
     * @var array [class => $repository]
     */
    protected $repositoryCache;

    /**
     * Constructor.
     *
     * @param SubjectHelperInterface     $subjectHelper
     * @param StockUnitCacheInterface    $unitCache
     * @param PersistenceHelperInterface $persistenceHelper
     */
    public function __construct(
        SubjectHelperInterface $subjectHelper,
        StockUnitCacheInterface $unitCache,
        PersistenceHelperInterface $persistenceHelper
    ) {
        $this->subjectHelper = $subjectHelper;
        $this->unitCache = $unitCache;
        $this->persistenceHelper = $persistenceHelper;

        $this->repositoryCache = [];
    }

    /**
     * @inheritdoc
     */
    public function getStockUnitCache()
    {
        return $this->unitCache;
    }

    /**
     * @inheritdoc
     */
    public function createBySubject(StockSubjectInterface $subject, StockUnitInterface $exceptStockUnit = null)
    {
        // TODO Cache 'new' stock units created by sales
        if (!empty($stockUnits = $this->unitCache->findNewBySubject($subject))) {
            foreach ($stockUnits as $stockUnit) {
                if ($stockUnit === $exceptStockUnit) {
                    continue;
                }

                return $stockUnit;
            }
        }

        /** @var StockUnitInterface $stockUnit */
        $stockUnit = $this
            ->getRepositoryBySubject($subject)
            ->createNew();

        $stockUnit->setSubject($subject);

        $this->unitCache->add($stockUnit);

        return $stockUnit;
    }

    /**
     * @inheritdoc
     */
    public function createBySubjectRelative(SubjectRelativeInterface $relative)
    {
        /** @var StockSubjectInterface $subject */
        $subject = $this->subjectHelper->resolve($relative);

        return $this->createBySubject($subject);
    }

    /**
     * @inheritdoc
     */
    public function findPending($subjectOrRelative)
    {
        /**
         * @var StockSubjectInterface        $subject
         * @var StockUnitRepositoryInterface $repository
         */
        list($subject, $repository) = $this->getSubjectAndRepository($subjectOrRelative);

        return $this->merge(
            $this->unitCache->findPendingBySubject($subject),
            $repository->findPendingBySubject($subject)
        );
    }

    /**
     * @inheritdoc
     */
    public function findReady($subjectOrRelative)
    {
        /**
         * @var StockSubjectInterface        $subject
         * @var StockUnitRepositoryInterface $repository
         */
        list($subject, $repository) = $this->getSubjectAndRepository($subjectOrRelative);

        return $this->merge(
            $this->unitCache->findReadyBySubject($subject),
            $repository->findReadyBySubject($subject)
        );
    }

    /**
     * @inheritdoc
     */
    public function findPendingOrReady($subjectOrRelative)
    {
        /**
         * @var StockSubjectInterface        $subject
         * @var StockUnitRepositoryInterface $repository
         */
        list($subject, $repository) = $this->getSubjectAndRepository($subjectOrRelative);

        return $this->merge(
            $this->unitCache->findPendingOrReadyBySubject($subject),
            $repository->findPendingOrReadyBySubject($subject)
        );
    }

    /**
     * @inheritdoc
     */
    public function findNotClosed($subjectOrRelative)
    {
        /**
         * @var StockSubjectInterface        $subject
         * @var StockUnitRepositoryInterface $repository
         */
        list($subject, $repository) = $this->getSubjectAndRepository($subjectOrRelative);

        $stockUnits = $this->merge(
            $this->unitCache->findNotClosedBySubject($subject),
            $repository->findNotClosedBySubject($subject)
        );

        return $stockUnits;
    }

    /**
     * @inheritdoc
     */
    public function findAssignable($subjectOrRelative)
    {
        /**
         * @var StockSubjectInterface        $subject
         * @var StockUnitRepositoryInterface $repository
         */
        list($subject, $repository) = $this->getSubjectAndRepository($subjectOrRelative);

        return $this->merge(
            $this->unitCache->findAssignableBySubject($subject),
            $repository->findAssignableBySubject($subject)
        );
    }

    /**
     * @inheritdoc
     */
    public function findLinkable($subjectOrRelative)
    {
        /**
         * @var StockSubjectInterface        $subject
         * @var StockUnitRepositoryInterface $repository
         */
        list($subject, $repository) = $this->getSubjectAndRepository($subjectOrRelative);

        $stockUnits = $this->merge(
            $this->unitCache->findLinkableBySubject($subject),
            $repository->findLinkableBySubject($subject)
        );

        if (!empty($stockUnits)) {
            return reset($stockUnits);
        }

        return null;
    }

    /**
     * Merges the fetched units with the cached units.
     *
     * @param StockUnitInterface[] $cachedUnits
     * @param StockUnitInterface[] $fetchedUnits
     *
     * @return array
     */
    protected function merge(array $cachedUnits, array $fetchedUnits)
    {
        $cachedIds = [];
        foreach ($cachedUnits as $unit) {
            if (null !== $id = $unit->getId()) {
                $cachedIds[] = $unit->getId();
            }
        }

        foreach ($fetchedUnits as $unit) {
            if (in_array($unit->getId(), $cachedIds)) {
                continue;
            }

            if ($this->unitCache->isRemoved($unit)) {
                continue;
            }

            if ($this->persistenceHelper->isScheduledForRemove($unit)) {
                continue;
            }

            $cachedUnits[] = $unit;
        }

        return $cachedUnits;
    }

    /**
     * Returns the subject and his stock unit repository.
     *
     * @param StockSubjectInterface|SubjectRelativeInterface $subjectOrRelative
     *
     * @return array(StockSubjectInterface, StockUnitRepositoryInterface)
     */
    protected function getSubjectAndRepository($subjectOrRelative)
    {
        if ($subjectOrRelative instanceof SubjectRelativeInterface) {
            $subject = $this->subjectHelper->resolve($subjectOrRelative);
        } elseif ($subjectOrRelative instanceof StockSubjectInterface) {
            $subject = $subjectOrRelative;
        } else {
            throw new InvalidArgumentException(sprintf(
                "Expected instance of '%s' or '%s'.",
                SubjectRelativeInterface::class,
                StockSubjectInterface::class
            ));
        }

        return [$subject, $this->getRepositoryBySubject($subject)];
    }

    /**
     * Returns the subject and his stock unit repository.
     *
     * @param StockSubjectInterface $subject
     *
     * @return StockUnitRepositoryInterface
     */
    protected function getRepositoryBySubject(StockSubjectInterface $subject)
    {
        $class = $subject::getStockUnitClass();

        if (isset($this->repositoryCache[$class])) {
            return $this->repositoryCache[$class];
        }

        $repository = $this->persistenceHelper->getManager()->getRepository($class);

        if (!$repository instanceof StockUnitRepositoryInterface) {
            throw new InvalidArgumentException('Expected instance of ' . StockUnitRepositoryInterface::class);
        }

        return $this->repositoryCache[$class] = $repository;
    }
}


<?php

namespace Ekyna\Component\Commerce\Common\Notify;

use Ekyna\Component\Commerce\Common\Event\NotifyEvent;
use Ekyna\Component\Commerce\Common\Event\NotifyEvents;
use Ekyna\Component\Commerce\Common\Model\Notify;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;

/**
 * Class NotifyBuilder
 * @package Ekyna\Component\Commerce\Common\Notify
 * @author  Etienne Dauvergne <contact@ekyna.com>
 */
class NotifyBuilder
{
    /**
     * @var EventDispatcherInterface
     */
    protected $eventDispatcher;

    /**
     * Constructor.
     *
     * @param EventDispatcherInterface $eventDispatcher
     */
    public function __construct(EventDispatcherInterface $eventDispatcher)
    {
        $this->eventDispatcher = $eventDispatcher;
    }

    /**
     * Creates a notify.
     *
     * @param string $type
     * @param mixed  $source
     *
     * @return Notify
     */
    public function create($type, $source = null)
    {
        $notify = new Notify();
        $notify
            ->setType($type)
            ->setSource($source);

        return $notify;
    }

    /**
     * Builds the notify.
     *
     * @param \Ekyna\Component\Commerce\Common\Model\Notify $notify
     *
     * @return bool Whether the notify has been successfully built.
     */
    public function build(Notify $notify)
    {
        $event = new NotifyEvent($notify);

        $this->eventDispatcher->dispatch(NotifyEvents::BUILD, $event);

        return !$event->isAbort();
    }
}


<?php

namespace mii\core;

abstract class Exception extends \Exception
{

    /**
     * @var  Request    Request instance that triggered this exception.
     */
    protected $_request;

    /**
     * Creates a new translated exception.
     *
     *     throw new Exception('Something went terrible wrong, :user',
     *         array(':user' => $user));
     *
     * @param   string $message error message
     * @param   array $variables translation variables
     * @param   integer|string $code the exception code
     * @param   Exception $previous Previous exception
     * @return  void
     */
    public function __construct($message = "", array $variables = NULL, $code = 0, Exception $previous = NULL) {

        // Set the message
        $message = empty($variables) ? $message : strtr($message, $variables);

        // Pass the message and integer code to the parent
        parent::__construct($message, (int)$code, $previous);

        // Save the unmodified code
        // @link http://bugs.php.net/39615
        $this->code = $code;
    }

    /**
     * Magic object-to-string method.
     *
     *     echo $exception;
     *
     * @uses    Exception::text
     * @return  string
     */
    public function __toString() {
        return static::text($this);
    }

    public function get_name() {
        return 'Exception';
    }

    /**
     * Get a single line of text representing the exception:
     *
     * Error [ Code ]: Message ~ File [ Line ]
     *
     * @param   \Throwable $e
     * @return  string
     */
    public static function text(\Throwable $e) {
        return sprintf('%s [ %s ]: %s ~ %s [ %d ]',
            get_class($e), $e->getCode(), strip_tags($e->getMessage()), \mii\util\Debug::path($e->getFile()), $e->getLine());
    }

}


<?php

/**
 * This file is part of MetaModels/attribute_translatedtags.
 *
 * (c) 2012-2019 The MetaModels team.
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 *
 * This project is provided in good faith and hope to be usable by anyone.
 *
 * @package    MetaModels/attribute_translatedtags
 * @author     Christian Schiffler <c.schiffler@cyberspectrum.de>
 * @author     Stefan Heimes <stefan_heimes@hotmail.com>
 * @author     Andreas Isaak <info@andreas-isaak.de>
 * @author     David Maack <david.maack@arcor.de>
 * @author     Christian de la Haye <service@delahaye.de>
 * @author     Sven Baumann <baumann.sv@gmail.com>
 * @author     Richard Henkenjohann <richardhenkenjohann@googlemail.com>
 * @copyright  2012-2019 The MetaModels team.
 * @license    https://github.com/MetaModels/attribute_translatedtags/blob/master/LICENSE LGPL-3.0-or-later
 * @filesource
 */

namespace MetaModels\AttributeTranslatedTagsBundle\Attribute;

use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Driver\Statement;
use MetaModels\Attribute\ITranslated;
use MetaModels\AttributeTagsBundle\Attribute\Tags;
use MetaModels\Filter\Rules\SimpleQuery;

/**
 * This is the MetaModelAttribute class for handling translated tag attributes.
 */
class TranslatedTags extends Tags implements ITranslated
{
    /**
     * Retrieve the name of the language column.
     *
     * @return string
     */
    protected function getTagLangColumn()
    {
        return $this->get('tag_langcolumn') ?: null;
    }

    /**
     * Retrieve the sorting source table.
     *
     * @return string
     */
    protected function getTagSortSourceTable()
    {
        return $this->get('tag_srctable') ?: null;
    }

    /**
     * Retrieve the sorting source column.
     *
     * @param string $prefix The prefix (e.g. table name) for a return value like "<table>.<column>".
     *
     * @return string
     */
    protected function getTagSortSourceColumn($prefix = null)
    {
        $column = $this->get('tag_srcsorting');
        if (!$column) {
            return null;
        }

        if (null !== $prefix) {
            return $prefix . '.' . $column;
        }

        return $column;
    }

    /**
     * {@inheritDoc}
     */
    protected function checkConfiguration()
    {
        // Parent checks apply.
        if (!parent::checkConfiguration()) {
            return false;
        }

        // If sort table given and non existent, exit.
        if (null !== ($sortTable = $this->getTagSortSourceTable())
            && !$this->getConnection()->getSchemaManager()->tablesExist([$sortTable])) {
            return false;
        }

        return (null !== $this->getTagLangColumn());
    }

    /**
     * Determine the amount of entries in the relation table for this attribute and the given value ids.
     *
     * @param int[] $ids The ids of the items for which the tag count shall be determined.
     *
     * @return int[] The counts in the array format 'item_id' => count
     */
    public function getTagCount($ids)
    {
        $tableName = $this->getTagSource();
        $colNameId = $this->getIdColumn();
        $return    = [];

        if ($tableName && $colNameId) {
            $statement = $this->getConnection()->createQueryBuilder()
                ->select('item_id', 'count(*) as count')
                ->from('tl_metamodel_tag_relation')
                ->where('att_id=:att')
                ->andWhere('item_id IN (:items)')
                ->groupBy('item_id')
                ->setParameter('att', $this->get('id'))
                ->setParameter('items', $ids, Connection::PARAM_INT_ARRAY)
                ->execute();

            while ($row = $statement->fetch(\PDO::FETCH_OBJ)) {
                $itemId = $row->item_id;

                $return[$itemId] = (int) $row->count;
            }
        }

        return $return;
    }

    /**
     * Convert the value ids to a result array.
     *
     * @param Statement  $valueResult The database result.
     *
     * @param null|array $counter     The destination for the counter values.
     *
     * @return int[] The value ids that are represented by the passed database statement.
     */
    protected function convertValueIds($valueResult, &$counter = null)
    {
        $result      = [];
        $aliases     = [];
        $idColumn    = $this->getIdColumn();
        $aliasColumn = $this->getAliasColumn();
        while ($row = $valueResult->fetch(\PDO::FETCH_OBJ)) {
            $valueId           = $row->$idColumn;
            $aliases[$valueId] = $row->$aliasColumn;
            $result[]          = $valueId;
        }

        if (($counter !== null) && !empty($result)) {
            $statement = $this->getConnection()->createQueryBuilder()
                ->select('value_id', 'COUNT(value_id) as mm_count')
                ->from('tl_metamodel_tag_relation')
                ->where('att_id=:att')
                ->andWhere('value_id IN (:values)')
                ->groupBy('item_id')
                ->setParameter('att', $this->get('id'))
                ->setParameter('values', $result, Connection::PARAM_STR_ARRAY)
                ->execute()
                ->fetch(\PDO::FETCH_OBJ);

            $amount  = $statement->mm_count;
            $valueId = $statement->value_id;
            $alias   = $aliases[$valueId];

            $counter[$valueId] = $amount;
            $counter[$alias]   = $amount;
        }

        return $result;
    }

    /**
     * Fetch the ids of options optionally limited to the items with the provided ids.
     *
     * NOTE: this does not take the actual availability of an value in the current or
     * fallback languages into account.
     * This method is mainly intended as a helper for TranslatedTags::getFilterOptions().
     *
     * @param string[]|null $ids      A list of item ids that the result shall be limited to.
     *
     * @param bool          $usedOnly Do only return ids that have matches in the real table.
     *
     * @param null          $count    Array to where the amount of items per tag shall be stored. May be null to return
     *                                nothing.
     *
     * @return int[] a list of all matching value ids.
     *
     * @see TranslatedTags::getFilterOptions().
     *
     * @SuppressWarnings(PHPMD.ExcessiveMethodLength)
     * @SuppressWarnings(PHPMD.NPathComplexity)
     * @SuppressWarnings(PHPMD.CyclomaticComplexity)
     */
    protected function getValueIds($ids, $usedOnly, &$count = null)
    {
        if ([] === $ids) {
            return [];
        }

        $aliasColName = $this->getAliasColumn();

        // First off, we need to determine the option ids in the foreign table.
        $queryBuilder = $this->getConnection()->createQueryBuilder();

        if (null !== $ids) {
            $statement = $this->getConnection()->createQueryBuilder()
                ->select('COUNT(source.' . $this->getIdColumn() . ') AS mm_count')
                ->addSelect('source.' . $this->getIdColumn())
                ->addSelect('source.' . $aliasColName)
                ->from($this->getTagSource(), 'source')
                ->leftJoin(
                    'source',
                    'tl_metamodel_tag_relation',
                    'rel',
                    $queryBuilder->expr()->andX()->add('rel.att_id=:att')->add(
                        'rel.value_id=source.' . $this->getIdColumn()
                    )
                )
                ->where('rel.item_id IN (:items)')
                ->setParameter('att', $this->get('id'))
                ->setParameter('items', $ids, Connection::PARAM_STR_ARRAY);

            if ($this->getTagSortSourceTable()) {
                $statement
                    ->addSelect($this->getTagSortSourceTable() . '.*')
                    ->join(
                        'source',
                        $this->getTagSortSourceTable(),
                        'sort',
                        'source.' . $this->getIdColumn() . '=sort.id'
                    );

                if ($this->getTagSortSourceColumn()) {
                    $statement->orderBy($this->getTagSortSourceColumn('sort'));
                }
            }

            if ($this->getWhereColumn()) {
                $statement->andWhere('(' . $this->getWhereColumn() . ')');
            }

            $statement
                ->groupBy('source.' . $this->getIdColumn())
                ->addOrderBy('source.' . $this->getSortingColumn());
        } elseif ($usedOnly) {
            $statement = $this->getConnection()->createQueryBuilder()
                ->select('COUNT(value_id) AS mm_count')
                ->addSelect('value_id AS ' . $this->getIdColumn())
                ->addSelect($this->getTagSource() . '.' . $aliasColName)
                ->from('tl_metamodel_tag_relation', 'rel')
                ->rightJoin(
                    'rel',
                    $this->getTagSource(),
                    'source',
                    'rel.value_id=source.' . $this->getIdColumn()
                )
                ->where('rel.att_id=:att')
                ->setParameter('att', $this->get('id'));

            if ($this->getTagSortSourceTable()) {
                $statement
                    ->addSelect($this->getTagSortSourceTable() . '.*')
                    ->join(
                        'source',
                        $this->getTagSortSourceTable(),
                        'sort',
                        'source.' . $this->getIdColumn() . '=sort.id'
                    );

                if ($this->getTagSortSourceColumn()) {
                    $statement->orderBy($this->getTagSortSourceColumn('sort'));
                }
            }

            if ($this->getWhereColumn()) {
                $statement->andWhere('(' . $this->getWhereColumn() . ')');
            }

            $statement
                ->groupBy('rel.value_id')
                ->addOrderBy('source.' . $this->getSortingColumn());
        } else {
            $statement = $this->getConnection()->createQueryBuilder()
                ->select('COUNT(source.' . $this->getIdColumn() . ') AS mm_count')
                ->addSelect('source.' . $this->getIdColumn())
                ->addSelect('source.' . $aliasColName)
                ->from($this->getTagSource(), 'source');

            if ($this->getTagSortSourceTable()) {
                $statement
                    ->addSelect($this->getTagSortSourceTable() . '.*')
                    ->join(
                        'source',
                        $this->getTagSortSourceTable(),
                        'sort',
                        'source.' . $this->getIdColumn() . '=sort.id'
                    );

                if ($this->getTagSortSourceColumn()) {
                    $statement->orderBy($this->getTagSortSourceColumn('sort'));
                }
            }

            if ($this->getWhereColumn()) {
                $statement->where('(' . $this->getWhereColumn() . ')');
            }

            $statement
                ->groupBy('source.' . $this->getIdColumn())
                ->addOrderBy($this->getSortingColumn());
        }

        return $this->convertValueIds($statement->execute(), $count);
    }

    /**
     * Fetch the values with the provided ids and given language.
     *
     * This method is mainly intended as a helper for
     * {@see MetaModelAttributeTranslatedTags::getFilterOptions()}
     *
     * @param int[]  $valueIds A list of value ids that the result shall be limited to.
     *
     * @param string $language The language code for which the values shall be retrieved.
     *
     * @return Statement The database result containing all matching values.
     */
    protected function getValues($valueIds, $language)
    {
        $queryBuilder = $this->getConnection()->createQueryBuilder();
        $where        = $this->getWhereColumn()
            ? '(' . $this->getWhereColumn() . ')'
            : null;

        $statement = $this->getConnection()->createQueryBuilder()
            ->select('source.*')
            ->from($this->getTagSource(), 'source')
            ->where($queryBuilder->expr()->in('source.' . $this->getIdColumn(), $valueIds))
            ->andWhere(
                $queryBuilder->expr()->andX()->add('source.' . $this->getTagLangColumn() . '=:lang')->add($where)
            )
            ->setParameter('lang', $language)
            ->groupBy('source.' . $this->getIdColumn());

        if ($this->getTagSortSourceTable()) {
            $statement->addSelect($this->getTagSortSourceTable() . '.*');
            $statement->join(
                's',
                $this->getTagSortSourceTable(),
                'sort',
                $queryBuilder->expr()->eq('source.' . $this->getIdColumn(), 'sort.id')
            );

            if ($this->getTagSortSourceColumn()) {
                $statement->orderBy($this->getTagSortSourceColumn('sort'));
            }
        }

        $statement->addOrderBy('source.' . $this->getSortingColumn());

        return $statement->execute();
    }

    /**
     * {@inheritDoc}
     */
    public function getAttributeSettingNames()
    {
        return \array_merge(
            parent::getAttributeSettingNames(),
            [
                'tag_langcolumn',
                'tag_srctable',
                'tag_srcsorting',
            ]
        );
    }

    /**
     * {@inheritDoc}
     */
    public function getFilterOptions($idList, $usedOnly, &$arrCount = null)
    {
        if (!$this->getTagSource() && $this->getIdColumn()) {
            return [];
        }

        $return    = [];
        $idColName = $this->getIdColumn();

        // Fetch the value ids.
        $valueIds = $this->getValueIds($idList, $usedOnly, $arrCount);
        if (!\count($valueIds)) {
            return $return;
        }

        $valueColName = $this->getValueColumn();
        $aliasColName = $this->getAliasColumn();

        // Now for the retrieval, first with the real language.
        $values               = $this->getValues($valueIds, $this->getMetaModel()->getActiveLanguage());
        $arrValueIdsRetrieved = [];
        while ($row = $values->fetch(\PDO::FETCH_OBJ)) {
            $arrValueIdsRetrieved[]      = $row->$idColName;
            $return[$row->$aliasColName] = $row->$valueColName;
        }
        // Determine missing ids.
        $valueIds = array_diff($valueIds, $arrValueIdsRetrieved);
        // If there are missing ids and the fallback language is different than the current language, then fetch
        // those now.
        if ($valueIds
            && ($this->getMetaModel()->getFallbackLanguage() !== $this->getMetaModel()->getActiveLanguage())) {
            $values = $this->getValues($valueIds, $this->getMetaModel()->getFallbackLanguage());
            while ($row = $values->fetch(\PDO::FETCH_OBJ)) {
                $return[$row->$aliasColName] = $row->$valueColName;
            }
        }

        return $return;
    }

    /**
     * {@inheritDoc}
     */
    public function getDataFor($arrIds)
    {
        $activeLanguage   = $this->getMetaModel()->getActiveLanguage();
        $fallbackLanguage = $this->getMetaModel()->getFallbackLanguage();

        $return   = $this->getTranslatedDataFor($arrIds, $activeLanguage);
        $tagCount = $this->getTagCount($arrIds);

        // Check if we got all tags.
        foreach ($return as $key => $results) {
            // Remove matching tags.
            if (\count($results) === $tagCount[$key]) {
                unset($tagCount[$key]);
            }
        }

        $arrFallbackIds = \array_keys($tagCount);

        // Second round, fetch fallback languages if not all items could be resolved.
        if (($activeLanguage !== $fallbackLanguage) && (\count($arrFallbackIds) > 0)) {
            // Cannot use array_merge here as it would renumber the keys.
            foreach ($this->getTranslatedDataFor($arrFallbackIds, $fallbackLanguage) as $id => $transValue) {
                foreach ((array) $transValue as $transId => $value) {
                    if (!$return[$id][$transId]) {
                        $return[$id][$transId] = $value;
                    }
                }
            }
        }

        return $return;
    }

    /**
     * {@inheritdoc}
     */
    public function searchFor($strPattern)
    {
        return $this->searchForInLanguages($strPattern, [$this->getMetaModel()->getActiveLanguage()]);
    }

    /**
     * {@inheritDoc}
     *
     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
     */
    public function setTranslatedDataFor($arrValues, $strLangCode)
    {
        // Although we are translated, we do not manipulate tertiary tables
        // in this attribute. Updating the reference table from plain setDataFor
        // will do just fine.
        $this->setDataFor($arrValues);
    }

    /**
     * {@inheritDoc}
     */
    public function getTranslatedDataFor($arrIds, $strLangCode)
    {
        $tableName       = $this->getTagSource();
        $idColName       = $this->getIdColumn();
        $langCodeColName = $this->getTagLangColumn();
        $sortColumn      = $this->getSortingColumn();

        if (!$this->isProperlyConfigured()) {
            return [];
        }

        $metaModelItemId = $this->getMetaModel()->getTableName() . '_id';

        $queryBuilder = $this->getConnection()->createQueryBuilder();

        $statement = $this->getConnection()->createQueryBuilder()
            ->select('v.*', 'r.item_id AS ' . $metaModelItemId)
            ->from($tableName, 'v')
            ->leftJoin(
                'v',
                'tl_metamodel_tag_relation',
                'r',
                $queryBuilder->expr()->andX()
                    ->add('r.att_id=:att')
                    ->add('r.value_id=v.' . $idColName)
                    ->add('v.' . $langCodeColName . '=:langcode')
            )
            ->where('r.item_id IN (:ids)')
            ->setParameter('att', $this->get('id'))
            ->setParameter('ids', $arrIds, Connection::PARAM_STR_ARRAY)
            ->setParameter('langcode', $strLangCode);

        if ($this->getTagSortSourceTable()) {
            $statement->join(
                'v',
                $this->getTagSortSourceTable(),
                's',
                $queryBuilder->expr()->eq('v.' . $idColName, 's.id')
            );

            if ($this->getTagSortSourceColumn()) {
                $statement->addSelect($this->getTagSortSourceColumn('s'));
                $statement->orderBy('srcsorting');
            }
        }

        if ($this->getWhereColumn()) {
            $statement->andWhere('(' . $this->getWhereColumn() . ')');
        }

        $statement->addOrderBy($sortColumn);

        return $this->convertRows($statement->execute(), $metaModelItemId, $idColName);
    }

    /**
     * Remove values for items in a certain language.
     *
     * @param string[] $arrIds      The ids for which values shall be removed.
     *
     * @param string   $strLangCode The language code for which the data shall be removed.
     *
     * @return void
     *
     * @throws \RuntimeException When an invalid id array has been passed.
     *
     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
     */
    public function unsetValueFor($arrIds, $strLangCode)
    {
        // We can not simply unset only one language. When un-setting, all languages will disappear.
        $this->unsetDataFor($arrIds);
    }

    /**
     * {@inheritdoc}
     */
    public function searchForInLanguages($pattern, $arrLanguages = [])
    {
        $tableName       = $this->getTagSource();
        $idColName       = $this->getIdColumn();
        $langCodeColName = $this->getTagLangColumn();
        $valueColumn     = $this->getValueColumn();
        $aliasColumn     = $this->getAliasColumn();

        $queryBuilder = $this->getConnection()->createQueryBuilder();

        $queryAndLanguages = null;
        if ($arrLanguages) {
            $queryAndLanguages = $queryBuilder->expr()->in($langCodeColName, $arrLanguages);
        }

        $builder = $this->getConnection()->createQueryBuilder()
            ->select('item_id')
            ->from('tl_metamodel_tag_relation')
            ->where(
                $queryBuilder->expr()->in(
                    'value_id',
                    $queryBuilder
                        ->select('DISTINCT ' . $idColName)
                        ->from($tableName)
                        ->where($queryBuilder->expr()->like($valueColumn, $pattern))
                        ->orWhere($queryBuilder->expr()->like($aliasColumn, $pattern))
                        ->andWhere($queryAndLanguages)
                        ->getSQL()
                )
            )
            ->andWhere('att_id=:att')
            ->setParameter('att', $this->get('id'));

        $filterRule = SimpleQuery::createFromQueryBuilder($builder, 'item_id');

        return $filterRule->getMatchingIds();
    }

    /**
     * Convert the database result to an result array.
     *
     * @param Statement $dbResult    The database result.
     * @param string    $idColumn    The id column name.
     * @param string    $valueColumn The value column name.
     *
     * @return array
     */
    private function convertRows(Statement $dbResult, $idColumn, $valueColumn)
    {
        $result = [];
        while ($row = $dbResult->fetch(\PDO::FETCH_ASSOC)) {
            if (!isset($result[$row[$idColumn]])) {
                $result[$row[$idColumn]] = [];
            }

            $data = $row;
            unset($data[$idColumn]);
            $result[$row[$idColumn]][$row[$valueColumn]] = $data;
        }

        return $result;
    }
}