<?php

namespace Mautic\PageBundle\Controller;

use Mautic\CoreBundle\Controller\AbstractFormController;
use Mautic\CoreBundle\Exception\InvalidDecodedStringException;
use Mautic\CoreBundle\Helper\CookieHelper;
use Mautic\CoreBundle\Helper\IpLookupHelper;
use Mautic\CoreBundle\Helper\TrackingPixelHelper;
use Mautic\CoreBundle\Helper\UrlHelper;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\CoreBundle\Twig\Helper\AnalyticsHelper;
use Mautic\CoreBundle\Twig\Helper\AssetsHelper;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Helper\ContactRequestHelper;
use Mautic\LeadBundle\Helper\PrimaryCompanyHelper;
use Mautic\LeadBundle\Helper\TokenHelper;
use Mautic\LeadBundle\Model\LeadModel;
use Mautic\LeadBundle\Tracker\ContactTracker;
use Mautic\LeadBundle\Tracker\Service\DeviceTrackingService\DeviceTrackingServiceInterface;
use Mautic\PageBundle\Entity\Page;
use Mautic\PageBundle\Event\PageDisplayEvent;
use Mautic\PageBundle\Event\TrackingEvent;
use Mautic\PageBundle\Helper\TrackingHelper;
use Mautic\PageBundle\Model\PageModel;
use Mautic\PageBundle\Model\Tracking404Model;
use Mautic\PageBundle\Model\VideoModel;
use Mautic\PageBundle\PageEvents;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Routing\RouterInterface;

class PublicController extends AbstractFormController
{
    /**
     * @return Response
     *
     * @throws \Exception
     * @throws \Mautic\CoreBundle\Exception\FileNotFoundException
     */
    public function indexAction(
        Request $request,
        ContactRequestHelper $contactRequestHelper,
        CookieHelper $cookieHelper,
        AnalyticsHelper $analyticsHelper,
        AssetsHelper $assetsHelper,
        Tracking404Model $tracking404Model,
        RouterInterface $router,
        $slug)
    {
        /** @var PageModel $model */
        $model    = $this->getModel('page');
        $security = $this->security;
        /** @var Page|bool $entity */
        $entity = $model->getEntityBySlugs($slug);

        // Do not hit preference center pages
        if (!empty($entity) && !$entity->getIsPreferenceCenter()) {
            $userAccess = $security->hasEntityAccess('page:pages:viewown', 'page:pages:viewother', $entity->getCreatedBy());
            $published  = $entity->isPublished();

            // Make sure the page is published or deny access if not
            if (!$published && !$userAccess) {
                // If the page has a redirect type, handle it
                if (null != $entity->getRedirectType()) {
                    $model->hitPage($entity, $request, $entity->getRedirectType());

                    return $this->redirect($entity->getRedirectUrl(), (int) $entity->getRedirectType());
                } else {
                    $model->hitPage($entity, $request, 401);

                    return $this->accessDenied();
                }
            }

            $lead  = null;
            $query = null;
            if (!$userAccess) {
                // Extract the lead from the request so it can be used to determine language if applicable
                $query = $model->getHitQuery($request, $entity);
                $lead  = $contactRequestHelper->getContactFromQuery($query);
            }

            // Correct the URL if it doesn't match up
            if (!$request->attributes->get('ignore_mismatch', 0)) {
                // Make sure URLs match up
                $url        = $model->generateUrl($entity, false);
                $requestUri = $request->getRequestUri();

                // Remove query when comparing
                $query = $request->getQueryString();
                if (!empty($query)) {
                    $requestUri = str_replace("?{$query}", '', $url);
                }

                // Redirect if they don't match
                if ($requestUri != $url) {
                    $model->hitPage($entity, $request, 301, $lead, $query);

                    return $this->redirect($url, 301);
                }
            }

            // Check for variants
            [$parentVariant, $childrenVariants] = $entity->getVariants();

            // Is this a variant of another? If so, the parent URL should be used unless a user is logged in and previewing
            if ($parentVariant != $entity && !$userAccess) {
                $model->hitPage($entity, $request, 301, $lead, $query);
                $url = $model->generateUrl($parentVariant, false);

                return $this->redirect($url, 301);
            }

            // First determine the A/B test to display if applicable
            if (!$userAccess) {
                // Check to see if a variant should be shown versus the parent but ignore if a user is previewing
                if (count($childrenVariants)) {
                    $variants      = [];
                    $variantWeight = 0;
                    $totalHits     = $entity->getVariantHits();

                    foreach ($childrenVariants as $id => $child) {
                        if ($child->isPublished()) {
                            $variantSettings = $child->getVariantSettings();
                            $variants[$id]   = [
                                'weight' => ($variantSettings['weight'] / 100),
                                'hits'   => $child->getVariantHits(),
                            ];
                            $variantWeight += $variantSettings['weight'];

                            // Count translations for this variant as well
                            $translations = $child->getTranslations(true);
                            /** @var Page $translation */
                            foreach ($translations as $translation) {
                                if ($translation->isPublished()) {
                                    $variants[$id]['hits'] += (int) $translation->getVariantHits();
                                }
                            }

                            $totalHits += $variants[$id]['hits'];
                        }
                    }

                    if (count($variants)) {
                        // check to see if this user has already been displayed a specific variant
                        $variantCookie = $request->cookies->get('mautic_page_'.$entity->getId());

                        if (!empty($variantCookie)) {
                            if (isset($variants[$variantCookie])) {
                                // if not the parent, show the specific variant already displayed to the visitor
                                if ($variantCookie !== $entity->getId()) {
                                    $entity = $childrenVariants[$variantCookie];
                                } // otherwise proceed with displaying parent
                            }
                        } else {
                            // Add parent weight
                            $variants[$entity->getId()] = [
                                'weight' => ((100 - $variantWeight) / 100),
                                'hits'   => $entity->getVariantHits(),
                            ];

                            // Count translations for the parent as well
                            $translations = $entity->getTranslations(true);
                            /** @var Page $translation */
                            foreach ($translations as $translation) {
                                if ($translation->isPublished()) {
                                    $variants[$entity->getId()]['hits'] += (int) $translation->getVariantHits();
                                }
                            }
                            $totalHits += $variants[$id]['hits'];

                            // determine variant to show
                            foreach ($variants as &$variant) {
                                $variant['weight_deficit'] = ($totalHits) ? $variant['weight'] - ($variant['hits'] / $totalHits) : $variant['weight'];
                            }

                            // Reorder according to send_weight so that campaigns which currently send one at a time alternate
                            uasort(
                                $variants,
                                function ($a, $b): int {
                                    if ($a['weight_deficit'] === $b['weight_deficit']) {
                                        if ($a['hits'] === $b['hits']) {
                                            return 0;
                                        }

                                        // if weight is the same - sort by least number displayed
                                        return ($a['hits'] < $b['hits']) ? -1 : 1;
                                    }

                                    // sort by the one with the greatest deficit first
                                    return ($a['weight_deficit'] > $b['weight_deficit']) ? -1 : 1;
                                }
                            );

                            // find the one with the most difference from weight
                            $useId = array_key_first($variants);

                            // set the cookie - 14 days
                            $cookieHelper->setCookie(
                                'mautic_page_'.$entity->getId(),
                                $useId,
                                3600 * 24 * 14
                            );

                            if ($useId != $entity->getId()) {
                                $entity = $childrenVariants[$useId];
                            }
                        }
                    }
                }

                // Now show the translation for the page or a/b test - only fetch a translation if a slug was not used
                if ($entity->isTranslation() && empty($entity->languageSlug)) {
                    [$translationParent, $translatedEntity] = $model->getTranslatedEntity(
                        $entity,
                        $lead,
                        $request
                    );

                    if ($translationParent && $translatedEntity !== $entity) {
                        if (!$request->get('ntrd', 0)) {
                            $url = $model->generateUrl($translatedEntity, false);
                            $model->hitPage($entity, $request, 302, $lead, $query);

                            return $this->redirect($url, 302);
                        }
                    }
                }
            }

            // Generate contents
            $analytics = $analyticsHelper->getCode();

            $BCcontent = $entity->getContent();
            $content   = $entity->getCustomHtml();
            // This condition remains so the Mautic v1 themes would display the content
            if (empty($content) && !empty($BCcontent)) {
                /**
                 * @deprecated  BC support to be removed in 3.0
                 */
                $template = $entity->getTemplate();
                // all the checks pass so display the content
                $slots   = $this->factory->getTheme($template)->getSlots('page');
                $content = $entity->getContent();

                $this->processSlots($slots, $entity);

                // Add the GA code to the template assets
                if (!empty($analytics)) {
                    $this->factory->getHelper('template.assets')->addCustomDeclaration($analytics);
                }

                $logicalName = $this->factory->getHelper('theme')->checkForTwigTemplate('@themes/'.$template.'/html/page.html.twig');

                $response = $this->render(
                    $logicalName,
                    [
                        'slots'    => $slots,
                        'content'  => $content,
                        'page'     => $entity,
                        'template' => $template,
                        'public'   => true,
                    ]
                );

                $content = $response->getContent();
            } else {
                if (!empty($analytics)) {
                    $content = str_replace('</head>', $analytics."\n</head>", $content);
                }
                if ($entity->getNoIndex()) {
                    $content = str_replace('</head>', "<meta name=\"robots\" content=\"noindex\">\n</head>", $content);
                }
            }

            $assetsHelper->addScript($router->generate('mautic_js', [], UrlGeneratorInterface::ABSOLUTE_URL),
                'onPageDisplay_headClose',
                true,
                'mautic_js'
            );

            $event = new PageDisplayEvent($content, $entity);
            $this->dispatcher->dispatch($event, PageEvents::PAGE_ON_DISPLAY);
            $content = $event->getContent();

            $model->hitPage($entity, $request, 200, $lead, $query);

            return new Response($content);
        }

        if (false !== $entity && $tracking404Model->isTrackable()) {
            $tracking404Model->hitPage($entity, $request);
        }

        return $this->notFound();
    }

    /**
     * @return Response|\Symfony\Component\HttpKernel\Exception\NotFoundHttpException
     *
     * @throws \Exception
     * @throws \Mautic\CoreBundle\Exception\FileNotFoundException
     */
    public function previewAction(Request $request, CorePermissions $security, int $id)
    {
        $contactId = (int) $request->query->get('contactId');

        if ($contactId) {
            /** @var LeadModel $leadModel */
            $leadModel = $this->getModel('lead.lead');
            /** @var Lead $contact */
            $contact = $leadModel->getEntity($contactId);
        }

        /** @var PageModel $model */
        $model = $this->getModel('page');
        /** @var Page $page */
        $page = $model->getEntity($id);

        if (!$page->getId()) {
            return $this->notFound();
        }

        $analytics = $this->factory->getHelper('twig.analytics')->getCode();

        $BCcontent = $page->getContent();
        $content   = $page->getCustomHtml();

        if (!$security->isAdmin()
            && (
                (!$page->isPublished())
                || (!$security->hasEntityAccess(
                    'page:pages:viewown',
                    'page:pages:viewother',
                    $page->getCreatedBy()
                )))
        ) {
            return $this->accessDenied();
        }

        if ($contactId && (
            !$security->isAdmin()
            || !$security->hasEntityAccess('lead:leads:viewown', 'lead:leads:viewother')
        )
        ) {
            return $this->accessDenied();
        }

        if (empty($content) && !empty($BCcontent)) {
            $template = $page->getTemplate();
            // all the checks pass so display the content
            $slots   = $this->factory->getTheme($template)->getSlots('page');
            $content = $page->getContent();

            $this->processSlots($slots, $page);

            // Add the GA code to the template assets
            if (!empty($analytics)) {
                $this->factory->getHelper('template.assets')->addCustomDeclaration($analytics);
            }

            $logicalName = $this->factory->getHelper('theme')->checkForTwigTemplate('@themes/'.$template.'/html/page.html.twig');

            $response = $this->render(
                $logicalName,
                [
                    'slots'    => $slots,
                    'content'  => $content,
                    'page'     => $page,
                    'template' => $template,
                    'public'   => true, // @deprecated Remove in 2.0
                ]
            );

            $content = $response->getContent();
        } else {
            $content = str_replace('</head>', $analytics.$this->renderView('@MauticPage/Page/preview_header.html.twig')."\n</head>", $content);
        }

        if ($this->dispatcher->hasListeners(PageEvents::PAGE_ON_DISPLAY)) {
            $event = new PageDisplayEvent($content, $page, $this->getPreferenceCenterConfig());
            if (isset($contact) && $contact instanceof Lead) {
                $event->setLead($contact);
            }
            $this->dispatcher->dispatch($event, PageEvents::PAGE_ON_DISPLAY);
            $content = $event->getContent();
        }

        return new Response($content);
    }

    /**
     * @return Response
     *
     * @throws \Exception
     */
    public function trackingImageAction(Request $request)
    {
        /** @var PageModel $model */
        $model = $this->getModel('page');
        $model->hitPage(null, $request);

        return TrackingPixelHelper::getResponse($request);
    }

    /**
     * @return JsonResponse
     *
     * @throws \Exception
     */
    public function trackingAction(
        Request $request,
        DeviceTrackingServiceInterface $deviceTrackingService,
        TrackingHelper $trackingHelper,
        ContactTracker $contactTracker
    ) {
        $notSuccessResponse = new JsonResponse(
            [
                'success' => 0,
            ]
        );
        if (!$this->security->isAnonymous()) {
            return $notSuccessResponse;
        }

        /** @var PageModel $model */
        $model = $this->getModel('page');

        try {
            $model->hitPage(null, $request);
        } catch (InvalidDecodedStringException) {
            // do not track invalid ct
            return $notSuccessResponse;
        }

        $lead          = $contactTracker->getContact();
        $trackedDevice = $deviceTrackingService->getTrackedDevice();
        $trackingId    = (null === $trackedDevice ? null : $trackedDevice->getTrackingId());

        $sessionValue   = $trackingHelper->getCacheItem(true);

        $event = new TrackingEvent($lead, $request, $sessionValue);
        $this->dispatcher->dispatch($event, PageEvents::ON_CONTACT_TRACKED);

        return new JsonResponse(
            [
                'success'   => 1,
                'id'        => ($lead) ? $lead->getId() : null,
                'sid'       => $trackingId,
                'device_id' => $trackingId,
                'events'    => $event->getResponse()->all(),
            ]
        );
    }

    /**
     * @throws \Exception
     */
    public function redirectAction(
        Request $request,
        ContactRequestHelper $contactRequestHelper,
        PrimaryCompanyHelper $primaryCompanyHelper,
        IpLookupHelper $ipLookupHelper,
        LoggerInterface $logger,
        $redirectId
    ): \Symfony\Component\HttpFoundation\RedirectResponse {
        $logger->debug('Attempting to load redirect with tracking_id of: '.$redirectId);

        /** @var \Mautic\PageBundle\Model\RedirectModel $redirectModel */
        $redirectModel = $this->getModel('page.redirect');
        $redirect      = $redirectModel->getRedirectById($redirectId);

        $logger->debug('Executing Redirect: '.$redirect);

        if (null === $redirect || !$redirect->isPublished(false)) {
            $logger->debug('Redirect with tracking_id of '.$redirectId.' not found');

            $url = ($redirect) ? $redirect->getUrl() : 'n/a';

            throw $this->createNotFoundException($this->translator->trans('mautic.core.url.error.404', ['%url%' => $url]));
        }

        // Ensure the URL does not have encoded ampersands
        $url = UrlHelper::decodeAmpersands($redirect->getUrl());

        // Get query string
        $query = $request->query->all();

        $ct = $query['ct'] ?? null;

        // Tak on anything left to the URL
        if (count($query)) {
            $url = UrlHelper::appendQueryToUrl($url, http_build_query($query));
        }

        // If the IP address is not trackable, it means it came form a configured "do not track" IP or a "do not track" user agent
        // This prevents simulated clicks from 3rd party services such as URL shorteners from simulating clicks
        $ipAddress = $ipLookupHelper->getIpAddress();

        if ($ct) {
            if ($ipAddress->isTrackable()) {
                // Search replace lead fields in the URL
                /** @var PageModel $pageModel */
                $pageModel = $this->getModel('page');

                try {
                    $lead = $contactRequestHelper->getContactFromQuery(['ct' => $ct]);

                    $pageModel->hitPage($redirect, $request, 200, $lead);
                } catch (InvalidDecodedStringException $e) {
                    // Invalid ct value so we must unset it
                    // and process the request without it

                    $logger->error(sprintf('Invalid clickthrough value: %s', $ct), ['exception' => $e]);

                    $request->request->set('ct', '');
                    $request->query->set('ct', '');
                    $lead = $contactRequestHelper->getContactFromQuery();
                    $pageModel->hitPage($redirect, $request, 200, $lead);
                }

                $leadArray = ($lead) ? $primaryCompanyHelper->getProfileFieldsWithPrimaryCompany($lead) : [];

                $url = TokenHelper::findLeadTokens($url, $leadArray, true);
            }

            if (str_contains($url, $this->generateUrl('mautic_asset_download'))) {
                if (strpos($url, '&')) {
                    $url .= '&ct='.$ct;
                } else {
                    $url .= '?ct='.$ct;
                }
            }
        }

        $url = UrlHelper::sanitizeAbsoluteUrl($url);

        if (!UrlHelper::isValidUrl($url)) {
            throw $this->createNotFoundException($this->translator->trans('mautic.core.url.error.404', ['%url%' => $url]));
        }

        return $this->redirect($url);
    }

    /**
     * PreProcess page slots for public view.
     *
     * @deprecated - to be removed in 3.0
     *
     * @param array $slots
     * @param Page  $entity
     */
    private function processSlots($slots, $entity): void
    {
        /** @var AssetsHelper $assetsHelper */
        $assetsHelper = $this->factory->getHelper('template.assets');
        /** @var \Mautic\CoreBundle\Twig\Helper\SlotsHelper $slotsHelper */
        $slotsHelper = $this->factory->getHelper('template.slots');

        $content = $entity->getContent();

        foreach ($slots as $slot => $slotConfig) {
            // backward compatibility - if slotConfig array does not exist
            if (is_numeric($slot)) {
                $slot       = $slotConfig;
                $slotConfig = [];
            }

            if (isset($slotConfig['type']) && 'slideshow' == $slotConfig['type']) {
                if (isset($content[$slot])) {
                    $options = json_decode($content[$slot], true);
                } else {
                    $options = [
                        'width'            => '100%',
                        'height'           => '250px',
                        'background_color' => 'transparent',
                        'arrow_navigation' => false,
                        'dot_navigation'   => true,
                        'interval'         => 5000,
                        'pause'            => 'hover',
                        'wrap'             => true,
                        'keyboard'         => true,
                    ];
                }

                // Create sample slides for first time or if all slides were deleted
                if (empty($options['slides'])) {
                    $options['slides'] = [
                        [
                            'order'            => 0,
                            'background-image' => $assetsHelper->getOverridableUrl('images/mautic_logo_lb200.png'),
                            'captionheader'    => 'Caption 1',
                        ],
                        [
                            'order'            => 1,
                            'background-image' => $assetsHelper->getOverridableUrl('images/mautic_logo_db200.png'),
                            'captionheader'    => 'Caption 2',
                        ],
                    ];
                }

                // Order slides
                usort(
                    $options['slides'],
                    fn ($a, $b): int => strcmp($a['order'], $b['order'])
                );

                $options['slot']   = $slot;
                $options['public'] = true;
            } elseif (isset($slotConfig['type']) && 'textarea' == $slotConfig['type']) {
                $value = isset($content[$slot]) ? nl2br($content[$slot]) : '';
                $slotsHelper->set($slot, $value);
            } else {
                // Fallback for other types like html, text, textarea and all unknown
                $value = $content[$slot] ?? '';
                $slotsHelper->set($slot, $value);
            }
        }

        $parentVariant = $entity->getVariantParent();
        $title         = (!empty($parentVariant)) ? $parentVariant->getTitle() : $entity->getTitle();
        $slotsHelper->set('pageTitle', $title);
    }

    /**
     * Track video views.
     */
    public function hitVideoAction(Request $request)
    {
        // Only track XMLHttpRequests, because the hit should only come from there
        if ($request->isXmlHttpRequest()) {
            /** @var VideoModel $model */
            $model = $this->getModel('page.video');

            try {
                $model->hitVideo($request);
            } catch (\Exception) {
                return new JsonResponse(['success' => false]);
            }

            return new JsonResponse(['success' => true]);
        }

        return new Response();
    }

    /**
     * Get the ID of the currently tracked Contact.
     */
    public function getContactIdAction(DeviceTrackingServiceInterface $trackedDeviceService, ContactTracker $contactTracker): JsonResponse
    {
        $data = [];
        if ($this->security->isAnonymous()) {
            $lead          = $contactTracker->getContact();
            $trackedDevice = $trackedDeviceService->getTrackedDevice();
            $trackingId    = (null === $trackedDevice ? null : $trackedDevice->getTrackingId());
            $data          = [
                'id'        => ($lead) ? $lead->getId() : null,
                'sid'       => $trackingId,
                'device_id' => $trackingId,
            ];
        }

        return new JsonResponse($data);
    }

    /**
     * @return array<string,bool>
     */
    private function getPreferenceCenterConfig(): array
    {
        return [
            'showContactFrequency'         => $this->coreParametersHelper->get('show_contact_frequency'),
            'showContactPauseDates'        => $this->coreParametersHelper->get('show_contact_pause_dates'),
            'showContactPreferredChannels' => $this->coreParametersHelper->get('show_contact_preferred_channels'),
            'showContactCategories'        => $this->coreParametersHelper->get('show_contact_categories'),
            'showContactSegments'          => $this->coreParametersHelper->get('show_contact_segments'),
        ];
    }
}
