<?php

namespace Mautic\PageBundle\Model;

use Doctrine\DBAL\Query\QueryBuilder;
use Doctrine\ORM\EntityManager;
use Mautic\CoreBundle\Helper\Chart\ChartQuery;
use Mautic\CoreBundle\Helper\Chart\LineChart;
use Mautic\CoreBundle\Helper\Chart\PieChart;
use Mautic\CoreBundle\Helper\CookieHelper;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Helper\DateTimeHelper;
use Mautic\CoreBundle\Helper\InputHelper;
use Mautic\CoreBundle\Helper\IpLookupHelper;
use Mautic\CoreBundle\Helper\UserHelper;
use Mautic\CoreBundle\Model\BuilderModelTrait;
use Mautic\CoreBundle\Model\FormModel;
use Mautic\CoreBundle\Model\GlobalSearchInterface;
use Mautic\CoreBundle\Model\TranslationModelTrait;
use Mautic\CoreBundle\Model\VariantModelTrait;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\CoreBundle\Translation\Translator;
use Mautic\EmailBundle\Entity\Stat;
use Mautic\EmailBundle\Entity\StatRepository;
use Mautic\EmailBundle\Helper\BotRatioHelper;
use Mautic\LeadBundle\DataObject\LeadManipulator;
use Mautic\LeadBundle\Entity\Company;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Entity\UtmTag;
use Mautic\LeadBundle\Helper\ContactRequestHelper;
use Mautic\LeadBundle\Helper\IdentifyCompanyHelper;
use Mautic\LeadBundle\Model\CompanyModel;
use Mautic\LeadBundle\Model\FieldModel;
use Mautic\LeadBundle\Model\LeadModel;
use Mautic\LeadBundle\Tracker\ContactTracker;
use Mautic\LeadBundle\Tracker\DeviceTracker;
use Mautic\MessengerBundle\Message\PageHitNotification;
use Mautic\PageBundle\Entity\Hit;
use Mautic\PageBundle\Entity\Page;
use Mautic\PageBundle\Entity\Redirect;
use Mautic\PageBundle\Event\PageBuilderEvent;
use Mautic\PageBundle\Event\PageEvent;
use Mautic\PageBundle\Event\PageHitEvent;
use Mautic\PageBundle\Form\Type\PageType;
use Mautic\PageBundle\PageEvents;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Contracts\EventDispatcher\Event;

/**
 * @extends FormModel<Page>
 */
class PageModel extends FormModel implements GlobalSearchInterface
{
    use TranslationModelTrait;
    use VariantModelTrait;
    use BuilderModelTrait;

    /**
     * We have to limit length of some fields
     * to store them in the database.
     */
    private const MAX_FIELD_LENGTH = 191;

    /**
     * An encoding to use to calculate
     * length of a field.
     */
    private const STRING_ENCODING = 'UTF-8';

    /**
     * @var bool
     */
    protected $catInUrl;

    protected DateTimeHelper $dateTimeHelper;

    public function __construct(
        protected CookieHelper $cookieHelper,
        protected IpLookupHelper $ipLookupHelper,
        protected LeadModel $leadModel,
        protected FieldModel $leadFieldModel,
        protected RedirectModel $pageRedirectModel,
        protected TrackableModel $pageTrackableModel,
        private MessageBusInterface $messageBus,
        private CompanyModel $companyModel,
        private DeviceTracker $deviceTracker,
        private ContactTracker $contactTracker,
        CoreParametersHelper $coreParametersHelper,
        private ContactRequestHelper $contactRequestHelper,
        EntityManager $em,
        CorePermissions $security,
        EventDispatcherInterface $dispatcher,
        UrlGeneratorInterface $router,
        Translator $translator,
        UserHelper $userHelper,
        LoggerInterface $mauticLogger,
        private StatRepository $statRepository,
        private BotRatioHelper $botRatioHelper,
    ) {
        $this->dateTimeHelper       = new DateTimeHelper();

        parent::__construct($em, $security, $dispatcher, $router, $translator, $userHelper, $mauticLogger, $coreParametersHelper);
    }

    public function setCatInUrl($catInUrl): void
    {
        $this->catInUrl = $catInUrl;
    }

    /**
     * @return \Mautic\PageBundle\Entity\PageRepository
     */
    public function getRepository()
    {
        $repo = $this->em->getRepository(Page::class);
        $repo->setCurrentUser($this->userHelper->getUser());

        return $repo;
    }

    /**
     * @return \Mautic\PageBundle\Entity\HitRepository
     */
    public function getHitRepository()
    {
        return $this->em->getRepository(Hit::class);
    }

    public function getPermissionBase(): string
    {
        return 'page:pages';
    }

    public function getNameGetter(): string
    {
        return 'getTitle';
    }

    /**
     * @param Page $entity
     * @param bool $unlock
     */
    public function saveEntity($entity, $unlock = true): void
    {
        $pageIds = $entity->getRelatedEntityIds();

        if (empty($this->inConversion)) {
            $alias = $entity->getAlias();
            if (empty($alias)) {
                $alias = $entity->getTitle();
            }
            $alias = $this->cleanAlias($alias, '', 0, '-', ['_']);

            // make sure alias is not already taken
            $repo      = $this->getRepository();
            $testAlias = $alias;
            $count     = $repo->checkPageUniqueAlias($testAlias, $pageIds);
            $aliasTag  = 1;

            while ($count) {
                $testAlias = $alias.$aliasTag;
                $count     = $repo->checkPageUniqueAlias($testAlias, $pageIds);
                ++$aliasTag;
            }
            if ($testAlias != $alias) {
                $alias = $testAlias;
            }
            $entity->setAlias($alias);
        }

        // Set the author for new pages
        $isNew = $entity->isNew();
        if (!$isNew) {
            // increase the revision
            $revision = $entity->getRevision();
            ++$revision;
            $entity->setRevision($revision);
        }

        // Reset a/b test if applicable
        $variantStartDate = new \DateTime();
        $resetVariants    = $this->preVariantSaveEntity($entity, ['setVariantHits'], $variantStartDate);

        parent::saveEntity($entity, $unlock);

        $this->postVariantSaveEntity($entity, $resetVariants, $pageIds, $variantStartDate);
        $this->postTranslationEntitySave($entity);
    }

    /**
     * @param Page $entity
     */
    public function deleteEntity($entity): void
    {
        if ($entity->isVariant() && $entity->getIsPublished()) {
            $this->resetVariants($entity);
        }

        parent::deleteEntity($entity);
    }

    public function createForm($entity, FormFactoryInterface $formFactory, $action = null, $options = []): \Symfony\Component\Form\FormInterface
    {
        if (!$entity instanceof Page) {
            throw new MethodNotAllowedHttpException(['Page']);
        }

        $formClass = PageType::class;

        if (!empty($options['formName'])) {
            $formClass = $options['formName'];
        }

        if (!empty($action)) {
            $options['action'] = $action;
        }

        return $formFactory->create($formClass, $entity, $options);
    }

    public function getEntity($id = null): ?Page
    {
        if (null === $id) {
            $entity = new Page();
            $entity->setSessionId('new_'.hash('sha1', uniqid(mt_rand())));
        } else {
            $entity = parent::getEntity($id);
            if (null !== $entity) {
                $entity->setSessionId($entity->getId());
            }
        }

        return $entity;
    }

    /**
     * @throws MethodNotAllowedHttpException
     */
    protected function dispatchEvent($action, &$entity, $isNew = false, ?Event $event = null): ?Event
    {
        if (!$entity instanceof Page) {
            throw new MethodNotAllowedHttpException(['Page']);
        }

        switch ($action) {
            case 'pre_save':
                $name = PageEvents::PAGE_PRE_SAVE;
                break;
            case 'post_save':
                $name = PageEvents::PAGE_POST_SAVE;
                break;
            case 'pre_delete':
                $name = PageEvents::PAGE_PRE_DELETE;
                break;
            case 'post_delete':
                $name = PageEvents::PAGE_POST_DELETE;
                break;
            default:
                return null;
        }

        if ($this->dispatcher->hasListeners($name)) {
            if (empty($event)) {
                $event = new PageEvent($entity, $isNew);
                $event->setEntityManager($this->em);
            }

            $this->dispatcher->dispatch($event, $name);

            return $event;
        }

        return null;
    }

    /**
     * Get list of entities for autopopulate fields.
     *
     * @param string $type
     * @param string $filter
     * @param int    $limit
     *
     * @return array
     */
    public function getLookupResults($type, $filter = '', $limit = 10)
    {
        $results = [];
        switch ($type) {
            case 'page':
                $viewOther = $this->security->isGranted('page:pages:viewother');
                $repo      = $this->getRepository();
                $repo->setCurrentUser($this->userHelper->getUser());
                $results = $repo->getPageList($filter, $limit, 0, $viewOther);
                break;
        }

        return $results;
    }

    /**
     * Generate URL for a page.
     *
     * @param Page  $entity
     * @param bool  $absolute
     * @param array $clickthrough
     *
     * @return string
     */
    public function generateUrl($entity, $absolute = true, $clickthrough = [])
    {
        // If this is a variant, then get the parent's URL
        $parent = $entity->getVariantParent();
        if (null != $parent) {
            $entity = $parent;
        }

        $slug = $this->generateSlug($entity);

        return $this->buildUrl('mautic_page_public', ['slug' => $slug], $absolute, $clickthrough);
    }

    /**
     * Generates slug string.
     */
    public function generateSlug($entity): string
    {
        $pageSlug = $entity->getAlias();

        // should the url include the category
        if ($this->catInUrl) {
            $category = $entity->getCategory();
            $catSlug  = (!empty($category))
                ? $category->getAlias()
                :
                $this->translator->trans('mautic.core.url.uncategorized');
        }

        $parent = $entity->getTranslationParent();
        $slugs  = [];
        if ($parent) {
            // multiple languages so tack on the language
            $slugs[] = $entity->getLanguage();
        }

        if (!empty($catSlug)) {
            // Insert category slug
            $slugs[] = $catSlug;
            $slugs[] = $pageSlug;
        } else {
            // Insert just the page slug
            $slugs[] = $pageSlug;
        }

        return implode('/', $slugs);
    }

    /**
     * @return array|mixed
     */
    protected function generateClickThrough(Hit $hit)
    {
        $query = $hit->getQuery();

        // Check for any clickthrough info
        $clickthrough = [];
        if (!empty($query['ct'])) {
            $clickthrough = $query['ct'];
            if (!is_array($clickthrough)) {
                $clickthrough = $this->decodeArrayFromUrl($clickthrough);
            }
        }

        return $clickthrough;
    }

    /**
     * @param string|int $code
     * @param array      $query
     *
     * @return bool If hit is tracked or not
     *
     * @throws \Exception
     */
    public function hitPage(Redirect|Page|null $page, Request $request, $code = '200', ?Lead $lead = null, $query = [], ?\DateTime $dateTime = null): bool
    {
        // Don't skew results with user hits
        if (!$this->security->isAnonymous() || $request->cookies->get('Blocked-Tracking')) {
            return false;
        }

        $ipAddress = $this->ipLookupHelper->getIpAddress();
        if (!$ipAddress->isTrackable()) {
            return false;
        }

        // Process the query
        if (empty($query) || !is_array($query)) {
            $query = $this->getHitQuery($request, $page);
        }

        $dateTime  = $dateTime ?: new \DateTime();
        $userAgent = $request->server->get('HTTP_USER_AGENT');
        if (array_key_exists('ct', $query) && array_key_exists('email', $query['ct']) && array_key_exists('stat', $query['ct'])) {
            /** @var Stat $stat */
            $stat = $this->statRepository->findOneBy(['trackingHash' => $query['ct']['stat']]);
            if (null !== $stat && $this->botRatioHelper->isHitByBot($stat, $dateTime, $ipAddress, (string) $userAgent)) {
                return false;
            }
        }

        // Get lead if required
        if (null == $lead) {
            $lead = $this->contactRequestHelper->getContactFromQuery($query);

            // company
            [$company, $leadAdded, $companyEntity] = IdentifyCompanyHelper::identifyLeadsCompany($query, $lead, $this->companyModel);
            $companyChangeLog                      = null;
            if ($leadAdded) {
                $companyChangeLog = $lead->addCompanyChangeLogEntry('form', 'Identify Company', 'Lead added to the company, '.$company['companyname'], $company['id']);
            } elseif ($companyEntity instanceof Company) {
                $this->companyModel->setFieldValues($companyEntity, $query);
                $this->companyModel->saveEntity($companyEntity);
            }

            if (!empty($company) and $companyEntity instanceof Company) {
                // Save after the lead in for new leads created through the API and maybe other places
                $this->companyModel->addLeadToCompany($companyEntity, $lead);
                $this->leadModel->setPrimaryCompany($companyEntity->getId(), $lead->getId());
            }

            if (null !== $companyChangeLog) {
                $this->companyModel->getCompanyLeadRepository()->detachEntity($companyChangeLog);
            }
        }

        if (!$lead || !$lead->getId()) {
            // Lead came from a non-trackable IP so ignore
            return false;
        }

        $hit = new Hit();
        $hit->setDateHit($dateTime);
        $hit->setIpAddress($ipAddress);

        // Set info from request
        $hit->setQuery($query);
        $hit->setCode($code);

        $trackedDevice = $this->deviceTracker->createDeviceFromUserAgent($lead, $userAgent);

        $hit->setTrackingId($this->limitString($trackedDevice->getTrackingId()));
        $hit->setDeviceStat($trackedDevice);

        // Wrap in a try/catch to prevent deadlock errors on busy servers
        try {
            $this->em->persist($hit);
            $this->em->flush();
        } catch (\Exception $exception) {
            if (MAUTIC_ENV !== 'prod') {
                throw $exception;
            } else {
                $this->logger->error(
                    $exception->getMessage(),
                    ['exception' => $exception]
                );
            }
        }

        // save hit to the cookie to use to update the exit time
        if ($hit) {
            $this->cookieHelper->setCookie(
                name: 'mautic_referer_id',
                value: $hit->getId() ?: null,
                sameSite: Cookie::SAMESITE_NONE
            );
        }

        $message = new PageHitNotification(
            $hit->getId(),
            $request,
            $this->deviceTracker->wasDeviceChanged(),
            $page instanceof Redirect,
            $page?->getId(),
            $lead->getId()
        );

        try {
            $this->messageBus->dispatch($message);
        } catch (\Exception $exception) {
            $this->logger->error('Failed to dispatch a message to messenger. '.$exception->getMessage());
            // Fallback measure
            $this->processPageHit($hit, $page, $request, $lead, $this->deviceTracker->wasDeviceChanged());
        }

        return true;
    }

    /**
     * Process page hit.
     *
     * @throws \Exception
     */
    public function processPageHit(
        Hit $hit,
        Redirect|Page|null $page,
        Request $request,
        Lead $lead,
        bool $trackingNewlyGenerated,
        bool $activeRequest = true,
        ?\DateTimeInterface $hitDate = null,
    ): void {
        // Store Page/Redirect association
        if ($page) {
            if ($page instanceof Page) {
                $hit->setPage($page);
            } else {
                $hit->setRedirect($page);
            }
        }

        // Check for any clickthrough info
        $clickthrough = $this->generateClickThrough($hit);
        if (!empty($clickthrough)) {
            if (!empty($clickthrough['channel'])) {
                if (1 === count($clickthrough['channel'])) {
                    $channelId = reset($clickthrough['channel']);
                    $channel   = key($clickthrough['channel']);
                } else {
                    $channel   = $clickthrough['channel'][0];
                    $channelId = (int) $clickthrough['channel'][1];
                }
                $hit->setSource($this->limitString($channel));
                $hit->setSourceId($channelId);
            } elseif (!empty($clickthrough['source'])) {
                $hit->setSource($this->limitString($clickthrough['source'][0]));
                $hit->setSourceId($clickthrough['source'][1]);
            }

            if (!empty($clickthrough['email'])) {
                $emailRepo = $this->em->getRepository(\Mautic\EmailBundle\Entity\Email::class);
                if ($emailEntity = $emailRepo->getEntity($clickthrough['email'])) {
                    $hit->setEmail($emailEntity);
                }
            }
        }

        $query = $hit->getQuery() ?: [];

        if (isset($query['timezone_offset']) && !$lead->getTimezone()) {
            // timezone_offset holds timezone offset in minutes. Multiply by 60 to get seconds.
            // Multiply by -1 because Firgerprint2 seems to have it the other way around.
            $timezone = (-1 * $query['timezone_offset'] * 60);
            $lead->setTimezone($this->dateTimeHelper->guessTimezoneFromOffset($timezone));
        }

        $query = $this->cleanQuery($query);

        if (isset($query['page_referrer'])) {
            $hit->setReferer($query['page_referrer']);
        }
        if (isset($query['page_language'])) {
            $hit->setPageLanguage($this->limitString($query['page_language']));
        }

        if ($pageTitle = $query['page_title'] ?? ($page instanceof Page ? $page->getTitle() : false)) {
            // Transliterate page titles.
            if ($this->coreParametersHelper->get('transliterate_page_title')) {
                $pageTitle = InputHelper::transliterate($pageTitle);
            }

            $query['page_title'] = $pageTitle;
            $hit->setUrlTitle($this->limitString($pageTitle));
        }

        $hit->setQuery($query);
        $hit->setUrl($query['page_url'] ?? $request->getRequestUri());

        // Add entry to contact log table
        $this->setLeadManipulator($page, $hit, $lead);

        // Store tracking ID
        $hit->setLead($lead);

        if (!$activeRequest) {
            // Queue is consuming this hit outside of the lead's active request so this must be set in order for listeners to know who the request belongs to
            $this->contactTracker->setSystemContact($lead);
        }
        $trackingId = $hit->getTrackingId();
        if (!$trackingNewlyGenerated) {
            $lastHit = $request->cookies->get('mautic_referer_id');
            if (!empty($lastHit) && is_numeric($lastHit)) {
                // this is not a new session so update the last hit if applicable with the date/time the user left
                $this->getHitRepository()->updateHitDateLeft((int) $lastHit);
            }
        }

        // Check if this is a unique page hit
        $isUnique = $this->getHitRepository()->isUniquePageHit($page, $trackingId, $lead);

        if (!empty($page)) {
            if ($page instanceof Page) {
                $hit->setPageLanguage($this->limitString($page->getLanguage()));

                $isVariant = ($isUnique) ? $page->getVariantStartDate() : false;

                try {
                    $this->getRepository()->upHitCount($page->getId(), 1, $isUnique, !empty($isVariant));
                } catch (\Exception $exception) {
                    $this->logger->error(
                        $exception->getMessage(),
                        ['exception' => $exception]
                    );
                }
            } elseif ($page instanceof Redirect) {
                try {
                    $this->pageRedirectModel->getRepository()->upHitCount($page->getId(), 1, $isUnique);

                    // If this is a trackable, up the trackable counts as well
                    if ($hit->getSource() && $hit->getSourceId()) {
                        $this->pageTrackableModel->getRepository()->upHitCount(
                            $page->getId(),
                            $hit->getSource(),
                            $hit->getSourceId(),
                            1,
                            $isUnique
                        );
                    }
                } catch (\Exception $exception) {
                    if (MAUTIC_ENV !== 'prod') {
                        throw $exception;
                    } else {
                        $this->logger->error(
                            $exception->getMessage(),
                            ['exception' => $exception]
                        );
                    }
                }
            }
        }

        // glean info from the IP address
        $ipAddress = $hit->getIpAddress();
        if ($ipAddress && $details = $ipAddress->getIpDetails()) {
            $hit->setCountry($this->limitString($details['country']));
            $hit->setRegion($this->limitString($details['region']));
            $hit->setCity($this->limitString($details['city']));
            $hit->setIsp($this->limitString($details['isp']));
            $hit->setOrganization($this->limitString($details['organization']));
        }

        if (!$hit->getReferer()) {
            $hit->setReferer($request->server->get('HTTP_REFERER'));
        }

        $hit->setUserAgent($request->server->get('HTTP_USER_AGENT'));
        $hit->setRemoteHost($this->limitString($request->server->get('REMOTE_HOST')));

        $this->setUtmTags($hit, $lead);

        // get a list of the languages the user prefers
        $browserLanguages = $request->server->get('HTTP_ACCEPT_LANGUAGE');
        if (!empty($browserLanguages)) {
            $languages = explode(',', $browserLanguages);
            foreach ($languages as $k => $l) {
                if (($pos = strpos(';q=', $l)) !== false) {
                    // remove weights
                    $languages[$k] = substr($l, 0, $pos);
                }
            }
            $hit->setBrowserLanguages($languages);
        }

        // Wrap in a try/catch to prevent deadlock errors on busy servers
        try {
            $this->em->persist($hit);
            $this->em->flush();
        } catch (\Exception $exception) {
            if (MAUTIC_ENV === 'dev') {
                throw $exception;
            } else {
                $this->logger->error(
                    $exception->getMessage(),
                    ['exception' => $exception]
                );
            }
        }

        if ($this->dispatcher->hasListeners(PageEvents::PAGE_ON_HIT)) {
            $event = new PageHitEvent($hit, $request, $hit->getCode(), $clickthrough, $isUnique);
            $this->dispatcher->dispatch($event, PageEvents::PAGE_ON_HIT);
        }

        if (null !== $hitDate) {
            if (null === $lead->getLastActive() || $lead->getLastActive() < $hitDate) {
                try {
                    $this->leadModel->getRepository()->updateLastActive($lead->getId(), $hitDate);
                } catch (\Exception $e) {
                    $data = [
                        'unique'             => ($isUnique ? 'true' : 'false'),
                        'lead'               => $lead->getId(),
                        'page'               => $page->getId(),
                        'hit'                => $hit->getId(),
                        'lastActiveOriginal' => $lead->getLastActive(),
                        'newLastActive'      => $hitDate,
                    ];

                    $this->logger->error(
                        'Failed to update event time due to '.$e->getMessage(),
                        ['context' => $data, 'exception' => (array) $e]
                    );
                }
            }
        }
    }

    /**
     * @param Redirect|Page|null $page
     */
    public function getHitQuery(Request $request, $page = null): array
    {
        $get  = $request->query->all();
        $post = $request->request->all();

        $query = \array_merge($get, $post);

        // Set generated page url
        $query['page_url'] = $this->getPageUrl($request, $page);

        // get all params from the url (actual url or passed in as page_url)
        if (!empty($query['page_url'])) {
            $queryUrl = $this->getQueryFromUrl($query['page_url']);
            $query    = \array_merge($queryUrl, $query);
        }

        // Process clickthrough if applicable
        if (!empty($query['ct'])) {
            $query['ct'] = $this->decodeArrayFromUrl($query['ct']);
        }

        return $query;
    }

    /**
     * Get array of page builder tokens from bundles subscribed PageEvents::PAGE_ON_BUILD.
     *
     * @param array|string $requestedComponents all | tokens | abTestWinnerCriteria
     *
     * @return array
     */
    public function getBuilderComponents(?Page $page = null, $requestedComponents = 'all', string $tokenFilter = '')
    {
        $event = new PageBuilderEvent($this->translator, $page, $requestedComponents, $tokenFilter);
        $this->dispatcher->dispatch($event, PageEvents::PAGE_ON_BUILD);

        return $this->getCommonBuilderComponents($requestedComponents, $event);
    }

    /**
     * Get number of page bounces.
     *
     * @return mixed[]
     */
    public function getBounces(Page $page, ?\DateTime $fromDate = null): array
    {
        return $this->getHitRepository()->getBounces($page->getId(), $fromDate);
    }

    /**
     * Joins the page table and limits created_by to currently logged in user.
     */
    public function limitQueryToCreator(QueryBuilder &$q): void
    {
        $q->join('t', MAUTIC_TABLE_PREFIX.'pages', 'p', 'p.id = t.page_id')
            ->andWhere('p.created_by = :userId')
            ->setParameter('userId', $this->userHelper->getUser()->getId());
    }

    /**
     * Get line chart data of hits.
     *
     * @param char   $unit          {@link php.net/manual/en/function.date.php#refsect1-function.date-parameters}
     * @param string $dateFormat
     * @param array  $filter
     * @param bool   $canViewOthers
     */
    public function getHitsLineChartData($unit, \DateTime $dateFrom, \DateTime $dateTo, $dateFormat = null, $filter = [], $canViewOthers = true): array
    {
        $flag = null;

        if (isset($filter['flag'])) {
            $flag = $filter['flag'];
            unset($filter['flag']);
        }

        $chart = new LineChart($unit, $dateFrom, $dateTo, $dateFormat);
        $query = new ChartQuery($this->em->getConnection(), $dateFrom, $dateTo);

        if (!$flag || 'total_and_unique' == $flag) {
            $q = $query->prepareTimeDataQuery('page_hits', 'date_hit', $filter);

            if (!$canViewOthers) {
                $this->limitQueryToCreator($q);
            }

            $data = $query->loadAndBuildTimeData($q);
            $chart->setDataset($this->translator->trans('mautic.page.show.total.visits'), $data);
        }

        if ('unique' == $flag || 'total_and_unique' == $flag) {
            $q = $query->prepareTimeDataQuery(
                'page_hits',
                'date_hit',
                $filter,
                'distinct(t.lead_id)',
                true,
                false
            );

            if (!$canViewOthers) {
                $this->limitQueryToCreator($q);
            }

            $data = $query->loadAndBuildTimeData($q);
            $chart->setDataset($this->translator->trans('mautic.page.show.unique.visits'), $data);
        }

        return $chart->render();
    }

    /**
     * Get data for pie chart showing new vs returning leads.
     * Returning leads are even leads who visits 2 different page once.
     *
     * @return array<string, array<int|string>>
     */
    public function getUniqueVsReturningPieChartData(\DateTime $dateFrom, \DateTime $dateTo, bool $canViewOthers = true): array
    {
        $chart              = new PieChart();
        $query              = new ChartQuery($this->em->getConnection(), $dateFrom, $dateTo);

        $filters = [
            'lead_id' => [
                'expression' => 'isNotNull',
            ],
            'date_left' => [
                'expression' => 'isNull',
            ],
            'redirect_id' => [
                'expression' => 'isNull',
            ],
            'email_id' => [
                'expression' => 'isNull',
            ],
        ];

        $allQ               = $query->getCountQuery('page_hits', 'id', 'date_hit', $filters);

        if (!$canViewOthers) {
            $this->limitQueryToCreator($allQ);
        }

        $allQ->resetQueryPart('select')->select('t.lead_id');
        $allQ->groupBy('t.lead_id');

        // fetch all group by lead_id
        $q  = $this->em->getConnection()->createQueryBuilder();
        $q->select('COUNT(*) as count')
            ->from(
                sprintf('(%s)', $allQ->getSQL()), 'tt'
            );
        $q->setParameters($allQ->getParameters());
        $all       = $query->fetchCount($q);

        // date_left is NULL more like 1 mean returned visitor
        $allQ->having('COUNT(t.id) > 1');
        $q  = $this->em->getConnection()->createQueryBuilder();
        $q->select('COUNT(*) as count')
            ->from(
                sprintf('(%s)', $allQ->getSQL()), 'tt'
            );
        $q->setParameters($allQ->getParameters());
        $returning = $query->fetchCount($q);

        $unique    = $all - $returning;
        $chart->setDataset($this->translator->trans('mautic.page.unique'), $unique);
        $chart->setDataset($this->translator->trans('mautic.page.graph.pie.new.vs.returning.returning'), $returning);

        return $chart->render();
    }

    /**
     * Get pie chart data of dwell times.
     *
     * @param array $filters
     * @param bool  $canViewOthers
     */
    public function getDwellTimesPieChartData(\DateTime $dateFrom, \DateTime $dateTo, $filters = [], $canViewOthers = true): array
    {
        $timesOnSite = $this->getHitRepository()->getDwellTimeLabels();
        $chart       = new PieChart();
        $query       = new ChartQuery($this->em->getConnection(), $dateFrom, $dateTo);

        foreach ($timesOnSite as $time) {
            $q = $query->getCountDateDiffQuery('page_hits', 'date_hit', 'date_left', $time['from'], $time['till'], $filters);

            if (!$canViewOthers) {
                $this->limitQueryToCreator($q);
            }

            $data = $query->fetchCountDateDiff($q);
            $chart->setDataset($time['label'], $data);
        }

        return $chart->render();
    }

    /**
     * Get bar chart data of hits.
     */
    public function getDeviceGranularityData(\DateTime $dateFrom, \DateTime $dateTo, $filters = [], $canViewOthers = true): array
    {
        $q = $this->em->getConnection()->createQueryBuilder();

        $q->select('count(h.id) as count, ds.device as device')
            ->from(MAUTIC_TABLE_PREFIX.'page_hits', 'h')
            ->join('h', MAUTIC_TABLE_PREFIX.'lead_devices', 'ds', 'ds.id=h.device_id')
            ->orderBy('device', 'DESC')
            ->andWhere($q->expr()->gte('h.date_hit', ':date_from'))
            ->setParameter('date_from', $dateFrom->format('Y-m-d'))
            ->andWhere($q->expr()->lte('h.date_hit', ':date_to'))
            ->setParameter('date_to', $dateTo->format('Y-m-d 23:59:59'));
        $q->groupBy('ds.device');

        $results = $q->executeQuery()->fetchAllAssociative();

        $chart   = new PieChart();

        if (empty($results)) {
            $results[] = [
                'device' => $this->translator->trans('mautic.report.report.noresults'),
                'count'  => 0,
            ];
        }

        foreach ($results as $result) {
            $label = empty($result['device']) ? $this->translator->trans('mautic.core.no.info') : $result['device'];

            $chart->setDataset($label, $result['count']);
        }

        return $chart->render();
    }

    /**
     * Get a list of popular (by hits) pages.
     *
     * @param int   $limit
     * @param array $filters
     * @param bool  $canViewOthers
     */
    public function getPopularPages($limit = 10, ?\DateTime $dateFrom = null, ?\DateTime $dateTo = null, $filters = [], $canViewOthers = true): array
    {
        $q = $this->em->getConnection()->createQueryBuilder();
        $q->select('COUNT(DISTINCT t.id) AS hits, p.id, p.title, p.alias')
            ->from(MAUTIC_TABLE_PREFIX.'page_hits', 't')
            ->join('t', MAUTIC_TABLE_PREFIX.'pages', 'p', 'p.id = t.page_id')
            ->orderBy('hits', 'DESC')
            ->groupBy('p.id')
            ->setMaxResults($limit);

        if (!$canViewOthers) {
            $q->andWhere('p.created_by = :userId')
                ->setParameter('userId', $this->userHelper->getUser()->getId());
        }

        $chartQuery = new ChartQuery($this->em->getConnection(), $dateFrom, $dateTo);
        $chartQuery->applyFilters($q, $filters);
        $chartQuery->applyDateFilters($q, 'date_hit');

        return $q->executeQuery()->fetchAllAssociative();
    }

    /**
     * Get a list of pages created in a date range.
     *
     * @param int   $limit
     * @param array $filters
     * @param bool  $canViewOthers
     */
    public function getPageList($limit = 10, ?\DateTime $dateFrom = null, ?\DateTime $dateTo = null, $filters = [], $canViewOthers = true): array
    {
        $q = $this->em->getConnection()->createQueryBuilder();
        $q->select('t.id, t.title AS name, t.date_added, t.date_modified')
            ->from(MAUTIC_TABLE_PREFIX.'pages', 't')
            ->setMaxResults($limit);

        if (!$canViewOthers) {
            $q->andWhere('t.created_by = :userId')
                ->setParameter('userId', $this->userHelper->getUser()->getId());
        }

        $chartQuery = new ChartQuery($this->em->getConnection(), $dateFrom, $dateTo);
        $chartQuery->applyFilters($q, $filters);
        $chartQuery->applyDateFilters($q, 'date_added');

        return $q->executeQuery()->fetchAllAssociative();
    }

    /**
     * Get all params (e.g. UTM tags) from a url.
     */
    private function getQueryFromUrl(string $pageUrl): array
    {
        $query             = [];
        $urlQuery          = parse_url($pageUrl, PHP_URL_QUERY);

        if (is_string($urlQuery)) {
            parse_str($urlQuery, $urlQueryArray);

            foreach ($urlQueryArray as $key => $value) {
                if (is_string($value)) {
                    $key         = strtolower($key);
                    $query[$key] = urldecode($value);
                }
            }
        }

        return $query;
    }

    /**
     * Set UTM Tags based on the query of a page hit.
     */
    private function setUtmTags(Hit $hit, Lead $lead): void
    {
        // Add UTM tags entry if a UTM tag exist
        $queryHasUtmTags = false;
        $query           = $hit->getQuery();
        foreach ($query as $key => $value) {
            if (str_contains($key, 'utm_')) {
                $queryHasUtmTags = true;
                break;
            }
        }

        if ($queryHasUtmTags && $lead) {
            $utmTags = new UtmTag();
            $utmTags->setDateAdded($hit->getDateHit());
            $utmTags->setUrl($hit->getUrl());
            $utmTags->setReferer($hit->getReferer());
            $utmTags->setQuery($hit->getQuery());
            $utmTags->setUserAgent($hit->getUserAgent());
            $utmTags->setRemoteHost($hit->getRemoteHost());
            $utmTags->setLead($lead);

            if (array_key_exists('utm_campaign', $query)) {
                $utmTags->setUtmCampaign($query['utm_campaign']);
            }
            if (array_key_exists('utm_term', $query)) {
                $utmTags->setUtmTerm($query['utm_term']);
            }
            if (array_key_exists('utm_content', $query)) {
                $utmTags->setUtmContent($query['utm_content']);
            }
            if (array_key_exists('utm_medium', $query)) {
                $utmTags->setUtmMedium($query['utm_medium']);
            }
            if (array_key_exists('utm_source', $query)) {
                $utmTags->setUtmSource($query['utm_source']);
            }

            $repo = $this->em->getRepository(UtmTag::class);
            $repo->saveEntity($utmTags);

            $this->leadModel->setUtmTags($lead, $utmTags);
        }
    }

    private function setLeadManipulator($page, Hit $hit, Lead $lead): void
    {
        // Only save the lead and dispatch events if needed
        $source   = 'hit';
        $sourceId = $hit->getId();
        if ($page) {
            $source   = $page instanceof Page ? 'page' : 'redirect';
            $sourceId = $page->getId();
        }

        $lead->setManipulator(
            new LeadManipulator(
                'page',
                $source,
                $sourceId,
                $hit->getUrl()
            )
        );

        $this->leadModel->saveEntity($lead);
    }

    /**
     * @return mixed|string
     */
    private function getPageUrl(Request $request, $page)
    {
        // Default to page_url set in the query from tracking pixel and/or contactfield token
        if ($pageURL = $request->get('page_url')) {
            return $pageURL;
        }

        if ($page instanceof Redirect) {
            // use the configured redirect URL
            return $page->getUrl();
        }

        // Use the current URL
        $isPageEvent = false;
        if (str_contains($request->server->get('REQUEST_URI'), $this->router->generate('mautic_page_tracker'))) {
            // Tracking pixel is used
            if ($request->server->get('QUERY_STRING')) {
                parse_str($request->server->get('QUERY_STRING'), $query);
                $isPageEvent = true;
            }
        } elseif (str_contains($request->server->get('REQUEST_URI'), $this->router->generate('mautic_page_tracker_cors'))) {
            $query       = $request->request->all();
            $isPageEvent = true;
        }

        if ($isPageEvent) {
            $pageURL = $request->server->get('HTTP_REFERER');

            // if additional data were sent with the tracking pixel
            if (isset($query)) {
                // URL attr 'd' is encoded so let's decode it first.
                $decoded = false;
                if (isset($query['d'])) {
                    // parse_str auto urldecodes
                    $query   = $this->decodeArrayFromUrl($query['d'], false);
                    $decoded = true;
                }

                if (is_array($query) && !empty($query)) {
                    if (isset($query['page_url'])) {
                        $pageURL = $query['page_url'];
                        if (!$decoded) {
                            $pageURL = urldecode($pageURL);
                        }
                    }

                    if (isset($query['page_referrer'])) {
                        if (!$decoded) {
                            $query['page_referrer'] = urldecode($query['page_referrer']);
                        }
                    }

                    if (isset($query['page_language'])) {
                        if (!$decoded) {
                            $query['page_language'] = urldecode($query['page_language']);
                        }
                    }

                    if (isset($query['page_title'])) {
                        if (!$decoded) {
                            $query['page_title'] = urldecode($query['page_title']);
                        }
                    }

                    if (isset($query['tags'])) {
                        if (!$decoded) {
                            $query['tags'] = urldecode($query['tags']);
                        }
                    }
                }
            }

            return $pageURL;
        }

        $pageURL = 'http';
        if ('on' == $request->server->get('HTTPS')) {
            $pageURL .= 's';
        }
        $pageURL .= '://';

        if (!in_array((int) $request->server->get('SERVER_PORT', 80), [80, 8080, 443])) {
            return $pageURL.$request->server->get('SERVER_NAME').':'.$request->server->get('SERVER_PORT').
                $request->server->get('REQUEST_URI');
        }

        return $pageURL.$request->server->get('SERVER_NAME').$request->server->get('REQUEST_URI');
    }

    /*
     * Cleans query params saving url values.
     *
     * @param $query array
     *
     * @return array
     */
    private function cleanQuery(array $query): array
    {
        foreach ($query as $key => $value) {
            if (filter_var($value, FILTER_VALIDATE_URL)) {
                $query[$key] = InputHelper::url($value);
            } else {
                $query[$key] = InputHelper::clean($value);
            }
        }

        return $query;
    }

    /**
     * This function limits $value to desired length.
     *
     * @param mixed $value The string to limit or other value to return as-is
     *
     * @return mixed The limited string or the original value if not a string
     */
    private function limitString($value)
    {
        if (!is_string($value)) {
            return $value;
        }

        if (self::MAX_FIELD_LENGTH >= mb_strwidth($value, self::STRING_ENCODING)) {
            return $value;
        }

        return rtrim(mb_strimwidth($value, 0, self::MAX_FIELD_LENGTH, '', self::STRING_ENCODING));
    }
}
