<?php 
 
namespace NetInventors\NetiNextStoreLocator\Storefront\Page\Store\Listing; 
 
use Doctrine\DBAL\Connection; 
use NetInventors\NetiNextStoreLocator\Components\CmsPageRenderer; 
use NetInventors\NetiNextStoreLocator\Components\ContactForm\ContactForm; 
use NetInventors\NetiNextStoreLocator\Core\Content\Store\StoreDefinition; 
use NetInventors\NetiNextStoreLocator\Core\Content\Store\StoreEntity; 
use NetInventors\NetiNextStoreLocator\Service\PluginConfig; 
use NetInventors\NetiNextStoreLocator\Service\PluginConfigFactory; 
use NetInventors\NetiNextStoreLocator\Service\StoreFilterService; 
use NetInventors\NetiNextStoreLocator\Struct\PluginConfigStruct; 
use NetInventors\NetiNextStoreLocator\Struct\StoreSelectState; 
use NetInventors\NetiNextStorePickup\Service\ContextService; 
use Shopware\Core\Content\Seo\SeoUrlPlaceholderHandlerInterface; 
use Shopware\Core\Framework\Context; 
use Shopware\Core\Framework\DataAbstractionLayer\Dbal\Common\RepositoryIterator; 
use Shopware\Core\Framework\DataAbstractionLayer\EntityCollection; 
use Shopware\Core\Framework\DataAbstractionLayer\EntityRepositoryInterface; 
use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria; 
use Shopware\Core\Framework\DataAbstractionLayer\Search\EntitySearchResult; 
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsAnyFilter; 
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsFilter; 
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\NotFilter; 
use Shopware\Core\Framework\DataAbstractionLayer\Search\Grouping\FieldGrouping; 
use Shopware\Core\Framework\Plugin\PluginEntity; 
use Shopware\Core\Framework\Uuid\Uuid; 
use Shopware\Core\System\Country\CountryEntity; 
use Shopware\Core\System\SalesChannel\SalesChannelContext; 
use Shopware\Core\System\SystemConfig\SystemConfigService; 
use Shopware\Storefront\Page\GenericPageLoader; 
use Shopware\Storefront\Page\MetaInformation; 
use Symfony\Component\HttpFoundation\Request; 
use Symfony\Component\EventDispatcher\EventDispatcherInterface; 
use Symfony\Contracts\Translation\TranslatorInterface; 
 
/** 
 * @psalm-suppress UndefinedClass The class is only available when StorePickup is installed. 
 */ 
class StoreListingPageLoader 
{ 
    /** 
     * @var GenericPageLoader 
     */ 
    private $genericLoader; 
 
    /** 
     * @var EntityRepositoryInterface 
     */ 
    private $storeRepository; 
 
    /** 
     * @var SystemConfigService 
     */ 
    private $systemConfig; 
 
    /** 
     * @var ContactForm 
     */ 
    private $contactForm; 
 
    /** 
     * @var EntityRepositoryInterface 
     */ 
    private $mediaRepository; 
 
    /** 
     * @var CmsPageRenderer 
     */ 
    private $cmsPageRenderer; 
 
    /** 
     * @var EventDispatcherInterface 
     */ 
    private $eventDispatcher; 
 
    /** 
     * @var TranslatorInterface 
     */ 
    private                           $translator; 
 
    private EntityRepositoryInterface $pluginRepository; 
 
    private StoreFilterService        $storeFilterService; 
 
    /** 
     * @var ContextService|null 
     */ 
    private ?ContextService                   $contextService; 
 
    private SeoUrlPlaceholderHandlerInterface $seoUrlReplacer; 
 
    private string                            $shopwareVersion; 
 
    private Connection                        $db; 
 
    private PluginConfigStruct                      $pluginConfig; 
 
    /** 
     * @psalm-suppress UndefinedClass The class is only available when StorePickup is installed. 
     */ 
    public function __construct( 
        GenericPageLoader                 $genericLoader, 
        EntityRepositoryInterface         $storeRepository, 
        SystemConfigService               $systemConfig, 
        ContactForm                       $contactForm, 
        EntityRepositoryInterface         $mediaRepository, 
        CmsPageRenderer                   $cmsPageRenderer, 
        EventDispatcherInterface          $eventDispatcher, 
        TranslatorInterface               $translator, 
        EntityRepositoryInterface         $pluginRepository, 
        StoreFilterService                $storeFilterService, 
        ?ContextService                   $contextService, 
        SeoUrlPlaceholderHandlerInterface $seoUrlReplacer, 
        string                            $shopwareVersion, 
        Connection                        $db, 
        PluginConfigStruct                      $pluginConfig 
    ) { 
        $this->genericLoader      = $genericLoader; 
        $this->storeRepository    = $storeRepository; 
        $this->systemConfig       = $systemConfig; 
        $this->contactForm        = $contactForm; 
        $this->mediaRepository    = $mediaRepository; 
        $this->cmsPageRenderer    = $cmsPageRenderer; 
        $this->eventDispatcher    = $eventDispatcher; 
        $this->translator         = $translator; 
        $this->pluginRepository   = $pluginRepository; 
        $this->storeFilterService = $storeFilterService; 
        $this->contextService     = $contextService; 
        $this->seoUrlReplacer     = $seoUrlReplacer; 
        $this->shopwareVersion    = $shopwareVersion; 
        $this->db                 = $db; 
        $this->pluginConfig       = $pluginConfig; 
    } 
 
    public function load(Request $request, SalesChannelContext $context): StoreListingPage 
    { 
        $page = $this->genericLoader->load($request, $context); 
        /** @var StoreListingPage $page */ 
        $page = StoreListingPage::createFrom($page); 
 
        $meta = $page->getMetaInformation(); 
 
        /** @var array<string, string> $config */ 
        $config = $this->getConfig($context); 
        $page->setConfig($config); 
 
        if ($meta instanceof MetaInformation) { 
            $meta->setMetaTitle( 
                $this->translator->trans('neti-next-store-locator.index.title') 
            ); 
 
            $meta->setMetaDescription( 
                $this->translator->trans('neti-next-store-locator.index.description') 
            ); 
 
            $seoUrl = $config['seoUrl']; 
 
            if ('' !== $seoUrl) { 
                $storefrontUrl = (string)$request->attributes->get('sw-storefront-url'); 
                $meta->assign([ 'canonical' => $storefrontUrl . '/' . $config['seoUrl'] ]); 
            } else { 
                $meta->assign([ 'canonical' => $this->seoUrlReplacer->generate('frontend.store_locator.index') ]); 
            } 
        } 
 
        $countries = $this->getCountries($context, $config); 
        $page->setCountries($countries); 
 
        $filters = $this->storeFilterService->loadFiltersForStorefront($context); 
        $page->setFilters($filters); 
 
        $radiusList = $this->getRadiusList(); 
        $page->setRadiusList($radiusList); 
 
        $contactFormFields = $this->contactForm->getFields($context); 
        $page->setContactFormFields($contactFormFields); 
 
        $contactSubjectOptions = $this->getContactSubjectOptions($config); 
        $page->setContactSubjectOptions($contactSubjectOptions); 
 
        $page->setOrderTypes( 
            [ 
                'distance', 
                'country', 
                'name', 
                'random', 
            ] 
        ); 
 
        if (isset($config['topCmsPage']) && '' !== $config['topCmsPage']) { 
            $page->setTopCmsPageHtml( 
                $this->cmsPageRenderer->buildById($request, $context, $config['topCmsPage'], compact('page')) 
            ); 
        } 
 
        if (isset($config['bottomCmsPage']) && '' !== $config['bottomCmsPage']) { 
            $page->setBottomCmsPageHtml( 
                $this->cmsPageRenderer->buildById($request, $context, $config['bottomCmsPage'], compact('page')) 
            ); 
        } 
 
        $this->eventDispatcher->dispatch(new StoreListingPageLoadedEvent($page, $context, $request)); 
 
        return $page; 
    } 
 
    /** 
     * Returns a list of all used countries 
     * 
     * @param SalesChannelContext $context 
     * @param array               $config 
     * 
     * @return array 
     * @throws \Shopware\Core\Framework\DataAbstractionLayer\Exception\InconsistentCriteriaIdsException 
     */ 
    public function getCountries(SalesChannelContext $context, array $config) 
    { 
        $criteria = new Criteria(); 
        $criteria->addAssociation('country'); 
        $criteria->addGroupField(new FieldGrouping('countryId')); 
 
        $result    = $this->storeRepository->search($criteria, $context->getContext()); 
        $countries = [ 
            [ 
                'id'       => 'all', 
                'label'    => $this->translator->trans('neti-next-store-locator.index.search.allCountriesLabel'), 
                '_label'   => $this->translator->trans('neti-next-store-locator.index.search.allCountriesLabel'), 
                'isoCode'  => 'ALL', 
                'default'  => false, 
                'position' => -1, 
            ], 
        ]; 
 
        $workingIndex         = 1; 
        $defaultByConfigIndex = null; 
 
        $umlautInput  = [ 'ä', 'Ä', 'ö', 'Ö', 'ü', 'Ü' ]; 
        $umlautOutput = [ 'ae', 'Ae', 'oe', 'Oe', 'ue', 'Ue' ]; 
 
        /** @var StoreEntity $entity */ 
        foreach ($result as $entity) { 
            $country = $entity->getCountry(); 
 
            if (!($country instanceof CountryEntity)) { 
                continue; 
            } 
 
            if (null === $defaultByConfigIndex && $country->getId() === ($config['preselectedCountryId'] ?? null)) { 
                $defaultByConfigIndex = $workingIndex; 
            } 
 
            $countries[$workingIndex++] = [ 
                'id'       => $country->getId(), 
                'label'    => $country->getTranslated()['name'], 
                '_label'   => str_replace($umlautInput, $umlautOutput, $country->getTranslated()['name']), 
                'isoCode'  => $country->getIso(), 
                'default'  => false, 
                'position' => $country->getPosition(), 
            ]; 
        } 
 
        $defaultIndex = $defaultByConfigIndex ?? 0; 
 
        $countries[$defaultIndex]['default'] = true; 
 
        $sortBy = $config['countrySortBy'] ?? PluginConfigStruct::COUNTRY_SORT_BY_NAME_ASC; 
 
        usort($countries, function ($a, $b) use ($sortBy) { 
            if ($a['id'] === 'all') { 
                return -1; 
            } 
 
            switch ($sortBy) { 
                case PluginConfigStruct::COUNTRY_SORT_BY_NAME_ASC: 
                    return $a['_label'] > $b['_label']; 
                case PluginConfigStruct::COUNTRY_SORT_BY_NAME_DESC: 
                    return $a['_label'] < $b['_label']; 
                case PluginConfigStruct::COUNTRY_SORT_BY_POSITION_ASC; 
                    return $a['position'] > $b['position']; 
                case PluginConfigStruct::COUNTRY_SORT_BY_POSITION_DESC: 
                    return $a['position'] < $b['position']; 
            } 
        }); 
 
        return $countries; 
    } 
 
    /** 
     * Returns a list of stores. 
     * 
     * @param SalesChannelContext $context 
     * 
     * @return \Shopware\Core\Framework\DataAbstractionLayer\Search\EntitySearchResult 
     * @throws \Shopware\Core\Framework\DataAbstractionLayer\Exception\InconsistentCriteriaIdsException 
     */ 
    public function getStores(Request $request, SalesChannelContext $context) 
    { 
        $criteria = new Criteria(); 
        $criteria->addAssociation('country'); 
        $criteria->addAssociation('countryState'); 
        $criteria->addAssociation('pictureMedia'); 
        $criteria->addAssociation('iconMedia'); 
        $criteria->addAssociation('translation'); 
        $criteria->addAssociation('tags'); 
        $criteria->addFilter(new EqualsFilter('active', true)); 
        $criteria->addFilter(new EqualsFilter('salesChannels.id', $context->getSalesChannelId())); 
        $criteria->addFilter( 
            new NotFilter( 
                NotFilter::CONNECTION_OR, 
                [ 
                    new EqualsFilter('latitude', null), 
                    new EqualsFilter('longitude', null), 
                ] 
            ) 
        ); 
 
        if ($request->query->has('radius')) { 
            $radius    = $request->query->get('radius'); 
            $longitude = $request->query->get('lng'); 
            $latitude  = $request->query->get('lat'); 
            $radiant   = ('km' === $this->pluginConfig->getDistanceUnit()) ? 6371 : 3959; 
            $limit     = $this->pluginConfig->getSearchResultLimit(); 
 
            $sql = ' 
                SELECT 
                  LOWER(HEX(s.id)), 
                  ( :radiant 
                    * ACOS( 
                      COS(RADIANS(:latitude)) 
                      * COS(RADIANS(s.latitude)) 
                      * COS(RADIANS(s.longitude) - RADIANS(:longitude)) 
                      + SIN(RADIANS(:latitude)) 
                      * SIN(RADIANS(s.latitude)) 
                  )) AS distance 
                FROM neti_store_locator s 
                LEFT JOIN neti_store_sales_channel ssc ON ssc.store_id = s.id 
                WHERE s.active = 1 
                  AND s.latitude IS NOT NULL 
                  AND s.longitude IS NOT NULL 
                  AND ssc.sales_channel_id = :salesChannelId 
                HAVING distance < :radius 
                ORDER BY distance ASC 
                LIMIT :limit 
            '; 
 
            $sql = str_replace(':limit', (string) $limit, $sql); 
            $ids = $this->db->fetchFirstColumn($sql, [ 
                'salesChannelId' => Uuid::fromHexToBytes($context->getSalesChannelId()), 
                'radiant'        => $radiant, 
                'longitude'      => $longitude, 
                'latitude'       => $latitude, 
                'radius'         => $radius, 
            ]); 
 
            $criteria->addFilter(new EqualsAnyFilter('id', $ids)); 
        } 
 
        $iterator = new RepositoryIterator($this->storeRepository, $context->getContext(), $criteria); 
        $result   = null; 
 
        if (!$iterator->getTotal()) { 
            return new EntitySearchResult( 
                StoreDefinition::ENTITY_NAME, 
                0, 
                new EntityCollection(), 
                null, 
                $criteria, 
                $context->getContext() 
            ); 
        } 
 
        while ($rows = $iterator->fetch()) { 
            if (null === $result) { 
                $result = $rows; 
            } else { 
                foreach ($rows as $row) { 
                    $result->add($row); 
                } 
            } 
        } 
 
        $detailMode      = $this->systemConfig->get('NetiNextStoreLocator.config.detailPage'); 
        $selectedStoreId = null; 
 
        if ( 
            null !== $this->contextService 
            && $this->isStorePickupEnabled($context->getContext()) 
        ) { 
            /** 
             * @psalm-suppress UndefinedClass The class is only available when StorePickup is installed. 
             */ 
            $selectedStoreId = $this->contextService->getSelectedStore(); 
        } 
 
        /** @var StoreEntity $entity */ 
        foreach ($result as $entity) { 
            if ($entity->getId() === $selectedStoreId) { 
                $entity->addExtension('netiStorePickupSelected', new StoreSelectState()); 
            } 
 
            switch ($detailMode) { 
                case 'enabled': 
                    $entity->setDetailPageEnabled(true); 
                    break; 
                case 'disabled': 
                    $entity->setDetailPageEnabled(false); 
                    break; 
                case 'store': 
                    // Keep value 
                    break; 
            } 
        } 
 
        return $result; 
    } 
 
    private function getRadiusList(): array 
    { 
        $values       = $this->systemConfig->get('NetiNextStoreLocator.config.searchRadiusValues'); 
        $defaultValue = $this->systemConfig->get('NetiNextStoreLocator.config.defaultSearchRadius'); 
        $values       = trim($values); 
 
        if (empty($values)) { 
            return []; 
        } 
 
        $values = array_unique(explode(';', trim($values, '; '))); 
 
        return array_map( 
            function ($value) use ($defaultValue) { 
                return [ 
                    'default' => (int)$value === (int)$defaultValue, 
                    'value'   => $value, 
                ]; 
            }, 
            $values 
        ); 
    } 
 
    public function getConfig(SalesChannelContext $context): array 
    { 
        $config = (array)$this->systemConfig->get(PluginConfigFactory::CONFIG_DOMAIN, $context->getSalesChannel()->getId()); 
 
        foreach ($config as $key => $value) { 
            unset ($config[$key]); 
 
            switch ($key) { 
                case 'googleMapIcon': 
                    $mediaId = $value; 
 
                    if (!empty($mediaId)) { 
                        /** 
                         * @psalm-suppress MixedArgumentTypeCoercion 
                         * 
                         * This is the correct way to search for a specific ID 
                         */ 
                        $criteria = new Criteria([ $mediaId ]); 
                        $result   = $this->mediaRepository->search($criteria, $context->getContext()); 
 
                        if ($result->count() > 0) { 
                            $value = $result->first()->getUrl(); 
                        } else { 
                            throw new \Exception('The given mediaId does not exist.'); 
                        } 
                    } 
                    break; 
                case 'googleMapIconSize': 
                    if (!empty($value)) { 
                        if (!preg_match('/^([0-9]+)x([0-9]+)$/', $value)) { 
                            throw new \Exception('The given icon size "' . $value . '" is invalid.'); 
                        } 
 
                        [ $width, $height ] = array_map('intval', explode('x', $value)); 
                        $value = compact('width', 'height'); 
                    } 
                    break; 
            } 
 
            $config[$key] = $value; 
        } 
 
        $config['_storePickupEnabled']   = $this->isStorePickupEnabled($context->getContext()); 
        $config['_cookieConsentEnabled'] = version_compare($this->shopwareVersion, '6.4.12.0', '<') 
            ?: $this->systemConfig->get('core.basicInformation.useDefaultCookieConsent'); 
 
        return $config; 
    } 
 
    private function getContactSubjectOptions(array $config): array 
    { 
        $options = $config['contactSubjectOptions'] ?? ''; 
        $options = trim($options); 
 
        if (empty($options)) { 
            return []; 
        } 
 
        return explode(PHP_EOL, $options); 
    } 
 
    private function isStorePickupEnabled(Context $context): bool 
    { 
        $criteria = new Criteria(); 
        $criteria->addFilter(new EqualsFilter('name', 'NetiNextStorePickup')); 
 
        $result = $this->pluginRepository->search($criteria, $context); 
        $plugin = $result->first(); 
 
        return $plugin instanceof PluginEntity 
            && $plugin->getInstalledAt() !== null 
            && $plugin->getActive() === true; 
    } 
}