<?php

namespace Mautic\FormBundle\Model;

use Doctrine\ORM\EntityManager;
use Doctrine\ORM\ORMException;
use Mautic\CampaignBundle\Entity\Campaign;
use Mautic\CampaignBundle\Membership\MembershipManager;
use Mautic\CampaignBundle\Model\CampaignModel;
use Mautic\CoreBundle\Exception\FileUploadException;
use Mautic\CoreBundle\Helper\Chart\ChartQuery;
use Mautic\CoreBundle\Helper\Chart\LineChart;
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\FormModel as CommonFormModel;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\CoreBundle\Translation\Translator;
use Mautic\CoreBundle\Twig\Helper\DateHelper;
use Mautic\FormBundle\Crate\UploadFileCrate;
use Mautic\FormBundle\Entity\Action;
use Mautic\FormBundle\Entity\Field;
use Mautic\FormBundle\Entity\Form;
use Mautic\FormBundle\Entity\Submission;
use Mautic\FormBundle\Entity\SubmissionRepository;
use Mautic\FormBundle\Event\Service\FieldValueTransformer;
use Mautic\FormBundle\Event\SubmissionEvent;
use Mautic\FormBundle\Event\ValidationEvent;
use Mautic\FormBundle\Exception\FileValidationException;
use Mautic\FormBundle\Exception\NoFileGivenException;
use Mautic\FormBundle\Exception\ValidationException;
use Mautic\FormBundle\FormEvents;
use Mautic\FormBundle\Helper\FormFieldHelper;
use Mautic\FormBundle\Helper\FormUploader;
use Mautic\FormBundle\ProgressiveProfiling\DisplayManager;
use Mautic\FormBundle\Validator\UploadFieldValidator;
use Mautic\LeadBundle\DataObject\LeadManipulator;
use Mautic\LeadBundle\Deduplicate\ContactMerger;
use Mautic\LeadBundle\Deduplicate\Exception\SameContactException;
use Mautic\LeadBundle\Entity\Company;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Helper\CustomFieldValueHelper;
use Mautic\LeadBundle\Helper\IdentifyCompanyHelper;
use Mautic\LeadBundle\Model\CompanyModel;
use Mautic\LeadBundle\Model\FieldModel as LeadFieldModel;
use Mautic\LeadBundle\Model\LeadModel;
use Mautic\LeadBundle\Tracker\ContactTracker;
use Mautic\LeadBundle\Tracker\Service\DeviceTrackingService\DeviceTrackingServiceInterface;
use Mautic\PageBundle\Model\PageModel;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Twig\Environment;

/**
 * @extends CommonFormModel<Submission>
 */
class SubmissionModel extends CommonFormModel
{
    public function __construct(
        protected IpLookupHelper $ipLookupHelper,
        protected Environment $twig,
        protected FormModel $formModel,
        protected PageModel $pageModel,
        protected LeadModel $leadModel,
        protected CampaignModel $campaignModel,
        protected MembershipManager $membershipManager,
        protected LeadFieldModel $leadFieldModel,
        protected CompanyModel $companyModel,
        protected FormFieldHelper $fieldHelper,
        private UploadFieldValidator $uploadFieldValidator,
        private FormUploader $formUploader,
        private DeviceTrackingServiceInterface $deviceTrackingService,
        private FieldValueTransformer $fieldValueTransformer,
        private DateHelper $dateHelper,
        private ContactTracker $contactTracker,
        private ContactMerger $contactMerger,
        EntityManager $em,
        CorePermissions $security,
        EventDispatcherInterface $dispatcher,
        UrlGeneratorInterface $router,
        Translator $translator,
        UserHelper $userHelper,
        LoggerInterface $mauticLogger,
        CoreParametersHelper $coreParametersHelper
    ) {
        parent::__construct($em, $security, $dispatcher, $router, $translator, $userHelper, $mauticLogger, $coreParametersHelper);
    }

    public function getRepository(): SubmissionRepository
    {
        return $this->em->getRepository(Submission::class);
    }

    /**
     * @param bool $returnEvent
     *
     * @return bool|array
     *
     * @throws ORMException
     */
    public function saveSubmission($post, $server, Form $form, Request $request, $returnEvent = false)
    {
        $leadFields = $this->leadFieldModel->getFieldListWithProperties(false);

        // everything matches up so let's save the results
        $submission = new Submission();
        $submission->setDateSubmitted(new \DateTime());
        $submission->setForm($form);

        // set the landing page the form was submitted from if applicable
        if (!empty($post['mauticpage'])) {
            $page = $this->pageModel->getEntity((int) $post['mauticpage']);
            if (null != $page) {
                $submission->setPage($page);
            }
        }

        $ipAddress = $this->ipLookupHelper->getIpAddress();
        $submission->setIpAddress($ipAddress);

        if (!empty($post['return'])) {
            $referer = $post['return'];
        } elseif (!empty($server['HTTP_REFERER'])) {
            $referer = $server['HTTP_REFERER'];
        } else {
            $referer = '';
        }

        // clean the referer by removing mauticError and mauticMessage
        $referer = InputHelper::url($referer, null, null, ['mauticError', 'mauticMessage']);
        $submission->setReferer($referer);

        // Create an event to be dispatched through the processes
        $submissionEvent = new SubmissionEvent($submission, $post, $server, $request);

        // Get a list of components to build custom fields from
        $components = $this->formModel->getCustomComponents();

        $fields           = $form->getFields();
        $fieldArray       = [];
        $results          = [];
        $tokens           = [];
        $leadFieldMatches = [];
        $validationErrors = [];
        $filesToUpload    = new UploadFileCrate();
        $company          = null;

        /** @var Field $f */
        foreach ($fields as $f) {
            $id    = $f->getId();
            $type  = $f->getType();
            $alias = $f->getAlias();
            $value = $post[$alias] ?? '';

            $fieldArray[$id] = [
                'id'    => $id,
                'type'  => $type,
                'alias' => $alias,
            ];

            if ($f->isCaptchaType()) {
                $captcha = $this->fieldHelper->validateFieldValue($type, $value, $f);
                if (!empty($captcha)) {
                    $props = $f->getProperties();
                    // check for a custom message
                    $validationErrors[$alias] = (!empty($props['errorMessage'])) ? $props['errorMessage'] : implode('<br />', $captcha);
                }
                continue;
            } elseif ($f->isFileType()) {
                try {
                    $file  = $this->uploadFieldValidator->processFileValidation($f, $request);
                    $value = $file->getClientOriginalName();
                    $filesToUpload->addFile($file, $f);
                } catch (NoFileGivenException) { // No error here, we just move to another validation, eg. if a field is required
                } catch (FileValidationException $e) {
                    $validationErrors[$alias] = $e->getMessage();
                }
            }

            if (!$f->showForConditionalField($post)) {
                continue;
            }

            if ('' === $value && $f->isRequired()) {
                // field is required, but hidden from form because of 'ShowWhenValueExists'
                if (false === $f->getShowWhenValueExists() && !isset($post[$alias])) {
                    continue;
                }

                // somehow the user got passed the JS validation
                $msg = $f->getValidationMessage();
                if (empty($msg)) {
                    $msg = $this->translator->trans(
                        'mautic.form.field.generic.validationfailed',
                        [
                            '%label%' => $f->getLabel(),
                        ],
                        'validators'
                    );
                }

                $validationErrors[$alias] = $msg;

                continue;
            }

            if (isset($components['viewOnlyFields']) && in_array($type, $components['viewOnlyFields'])) {
                // don't save items that don't have a value associated with it
                continue;
            }

            // clean and validate the input
            if ($f->isCustom()) {
                if (!isset($components['fields'][$f->getType()])) {
                    continue;
                }

                $params = $components['fields'][$f->getType()];
                if (!empty($value)) {
                    if (isset($params['valueFilter'])) {
                        if (is_string($params['valueFilter']) && is_callable([InputHelper::class, $params['valueFilter']])) {
                            $value = InputHelper::_($value, $params['valueFilter']);
                        } elseif (is_callable($params['valueFilter'])) {
                            $value = call_user_func_array($params['valueFilter'], [$f, $value]);
                        } else {
                            $value = InputHelper::_($value, 'clean');
                        }
                    } else {
                        $value = InputHelper::_($value, 'clean');
                    }
                }
            } elseif (!empty($value)) {
                $filter = $this->fieldHelper->getFieldFilter($type);
                $value  = InputHelper::_($value, $filter);

                $isValid = $this->validateFieldValue($f, $value);
                if (true !== $isValid) {
                    $validationErrors[$alias] = is_array($isValid) ? implode('<br />', $isValid) : $isValid;
                }
            }

            // Check for custom validators
            $isValid = $this->validateFieldValue($f, $value);
            if (true !== $isValid) {
                $validationErrors[$alias] = $isValid;
            }

            $mappedField = $f->getMappedField();
            if (!empty($mappedField) && in_array($f->getMappedObject(), ['company', 'contact'])) {
                $leadValue = $value;

                $leadFieldMatches[$mappedField] = $leadValue;
            }

            if ('companyLookup' === $f->getType() && !empty($value)) {
                $company = $this->companyModel->getEntity($value);
                if ($company instanceof Company) {
                    $value = $company->getName();
                }
            }

            $tokens["{formfield={$alias}}"] = $this->normalizeValue($value, $f);

            // convert array from checkbox groups and multiple selects
            if (is_array($value)) {
                $value = implode(', ', $value);
            }

            // save the result
            if (false !== $f->getSaveResult()) {
                $results[$alias] = $value;
            }
        }

        // Set the results
        $submission->setResults($results);

        // Update the event
        $submissionEvent->setFields($fieldArray)
            ->setTokens($tokens)
            ->setResults($results)
            ->setContactFieldMatches($leadFieldMatches);

        $lead = $this->contactTracker->getContact();

        // Remove validation errors if the field is not visible
        if ($lead && $form->usesProgressiveProfiling()) {
            $leadSubmissions = $this->formModel->getLeadSubmissions($form, $lead->getId());

            $displayManager = new DisplayManager($form, $this->formModel->getCustomComponents()['viewOnlyFields']);
            foreach ($fields as $field) {
                if ($field->showForContact($leadSubmissions, $lead, $form, $displayManager)) {
                    $displayManager->increaseDisplayedFields($field);
                } elseif (isset($validationErrors[$field->getAlias()])) {
                    unset($validationErrors[$field->getAlias()]);
                }
            }
        }

        // return errors if there any
        if (!empty($validationErrors)) {
            return ['errors' => $validationErrors];
        }

        // Create/update lead
        if (!empty($leadFieldMatches)) {
            $lead = $this->createLeadFromSubmit($form, $leadFieldMatches, $leadFields, $company);
        }

        $trackedDevice = $this->deviceTrackingService->getTrackedDevice();
        $trackingId    = (null === $trackedDevice ? null : $trackedDevice->getTrackingId());

        // set tracking ID for stats purposes to determine unique hits
        $submission->setTrackingId($trackingId)
            ->setLead($lead);

        /*
         * Process File upload and save the result to the entity
         * Upload is here to minimize a need for deleting file if there is a validation error
         * The action can still be invalidated below - deleteEntity takes care for File deletion
         *
         * @todo Refactor form validation to execute this code only if Submission is valid
         */
        try {
            $this->formUploader->uploadFiles($filesToUpload, $submission);
        } catch (FileUploadException $e) {
            $msg                                = $this->translator->trans('mautic.form.submission.error.file.uploadFailed', [], 'validators');
            $validationErrors[$e->getMessage()] = $msg;

            return ['errors' => $validationErrors];
        }

        // set results after uploader what can change file name if file name exists
        $submissionEvent->setResults($submission->getResults());

        // Save the submission
        $this->saveEntity($submission);
        $this->fieldValueTransformer->transformValuesAfterSubmit($submissionEvent);
        // Now handle post submission actions
        try {
            $this->executeFormActions($submissionEvent);
        } catch (ValidationException $exception) {
            // The action invalidated the form for whatever reason
            $this->deleteEntity($submission);

            if ($validationErrors = $exception->getViolations()) {
                return ['errors' => $validationErrors];
            }

            return ['errors' => [$exception->getMessage()]];
        }

        // update contact fields with transform values
        if (!empty($this->fieldValueTransformer->getContactFieldsToUpdate())) {
            $this->leadModel->setFieldValues($lead, $this->fieldValueTransformer->getContactFieldsToUpdate());
            $this->leadModel->saveEntity($lead, false);
        }

        if (!$form->isStandalone()) {
            // Find and add the lead to the associated campaigns
            $campaigns = $this->campaignModel->getCampaignsByForm($form);
            /** @var Campaign $campaign */
            foreach ($campaigns as $campaign) {
                if ($campaign->isPublished()) {
                    $this->membershipManager->addContact($lead, $campaign);
                }
            }
        }

        if ($this->dispatcher->hasListeners(FormEvents::FORM_ON_SUBMIT)) {
            // Reset action config from executeFormActions()
            $submissionEvent->setAction(null);

            // Dispatch to on submit listeners
            $this->dispatcher->dispatch($submissionEvent, FormEvents::FORM_ON_SUBMIT);
        }

        // get callback commands from the submit action
        if ($submissionEvent->hasPostSubmitCallbacks()) {
            return ['callback' => $submissionEvent];
        }

        // made it to the end so return the submission event to give the calling method access to tokens, results, etc
        // otherwise return false that no errors were encountered (to keep BC really)
        return ($returnEvent) ? ['submission' => $submissionEvent] : false;
    }

    /**
     * @param Submission $submission
     */
    public function deleteEntity($submission): void
    {
        $this->formUploader->deleteUploadedFiles($submission);

        parent::deleteEntity($submission);
    }

    public function getEntities(array $args = [])
    {
        return $this->getRepository()->getEntities($args);
    }

    /**
     * @param array<string, mixed> $args
     *
     * @return array<mixed>
     */
    public function getEntitiesByPage(array $args = []): array
    {
        return $this->getRepository()->getEntitiesByPage($args);
    }

    /**
     * @return StreamedResponse|Response
     *
     * @throws \Exception
     */
    public function exportResults($format, $form, $queryArgs)
    {
        $viewOnlyFields              = $this->formModel->getCustomComponents()['viewOnlyFields'];
        $queryArgs['viewOnlyFields'] = $viewOnlyFields;
        $queryArgs['simpleResults']  = true;
        $results                     = $this->getEntities($queryArgs);

        $date = (new DateTimeHelper())->toLocalString();
        $name = str_replace(' ', '_', $date).'_'.$form->getAlias();

        switch ($format) {
            case 'csv':
                $response = new StreamedResponse(
                    function () use ($results, $form, $viewOnlyFields): void {
                        $handle = fopen('php://output', 'r+');

                        // build the header row
                        $header = $this->getExportHeader($form, $viewOnlyFields);
                        // write the row
                        $this->putCsvExportRow($handle, $header);

                        // build the data rows
                        foreach ($results as $k => $s) {
                            $row = $this->getExportRow($s, $viewOnlyFields);

                            foreach ($row as &$value) {
                                if (is_string($value)) {
                                    // 1. Decode HTML entities
                                    $value = html_entity_decode($value, ENT_QUOTES | ENT_HTML5, 'UTF-8');

                                    // 2. Normalize newlines
                                    $value = str_replace(["\r\n", "\r"], "\n", $value);

                                    // 3. Neutralize CSV injection (Excel/LibreOffice formulas)
                                    if (preg_match('/^[-+=@]/', $value)) {
                                        $value = "'".$value;
                                    }

                                    // 4. Trim null bytes / control chars: some attackers hide payloads in invisible characters.
                                    $value = preg_replace('/[\x00-\x1F\x7F]/u', '', $value);
                                }
                            }

                            $this->putCsvExportRow($handle, $row);

                            // free memory
                            unset($row, $results[$k]);
                        }

                        fclose($handle);
                    }
                );

                $this->setResponseHeaders($response, $name.'.csv', [
                    'application/force-download',
                    'application/octet-stream',
                ]);

                return $response;
            case 'html':
                $content = $this->twig->render(
                    '@MauticForm/Result/export.html.twig',
                    [
                        'form'           => $form,
                        'results'        => $results,
                        'pageTitle'      => $name,
                        'viewOnlyFields' => $viewOnlyFields,
                    ]
                );

                return new Response($content);
            case 'xlsx':
                if (class_exists(Spreadsheet::class)) {
                    $response = new StreamedResponse(
                        function () use ($results, $form, $name, $viewOnlyFields): void {
                            $objPHPExcel = new Spreadsheet();
                            $objPHPExcel->getProperties()->setTitle($name);

                            $objPHPExcel->createSheet();

                            // build the header row
                            $header = $this->getExportHeader($form, $viewOnlyFields);

                            // write the row
                            $objPHPExcel->getActiveSheet()->fromArray($header, null, 'A1');

                            // build the data rows
                            $count = 2;
                            foreach ($results as $k => $s) {
                                $row = $this->getExportRow($s, $viewOnlyFields);

                                foreach ($row as $key => $value) {
                                    if (is_string($value)) {
                                        // Decode entities (sometimes twice if double-encoded)
                                        $value = html_entity_decode($value, ENT_QUOTES | ENT_HTML5, 'UTF-8');
                                        $value = str_replace(['&#13;', '&#10;'], "\n", $value);

                                        // Normalize newlines so Excel renders them as multi-line cells
                                        $value = str_replace(["\r\n", "\r"], "\n", $value);

                                        // Strip control characters
                                        $value = preg_replace('/[\x00-\x1F\x7F]/u', '', $value);

                                        // Prevent formula injection
                                        if (preg_match('/^[-+=@]/', $value)) {
                                            $value = "'".$value;
                                        }
                                    } else {
                                        $value = (string) $value;
                                    }

                                    $row[$key] = $value;
                                }
                                $objPHPExcel->getActiveSheet()->fromArray($row, null, "A{$count}");

                                // free memory
                                unset($row, $results[$k]);

                                // increment letter
                                ++$count;
                            }

                            $objWriter = IOFactory::createWriter($objPHPExcel, 'Xlsx');
                            $objWriter->setPreCalculateFormulas(false);

                            $objWriter->save('php://output');
                        }
                    );

                    $this->setResponseHeaders($response, $name.'.xlsx', [
                        'application/force-download',
                        'application/octet-stream',
                    ]);

                    return $response;
                }
                throw new \Exception('PHPSpreadsheet is required to export to Excel spreadsheets');
            default:
                return new Response();
        }
    }

    /**
     * @param string               $format
     * @param object               $page
     * @param array<string, mixed> $queryArgs
     *
     * @return StreamedResponse|Response
     *
     * @throws \Exception
     */
    public function exportResultsForPage($format, $page, $queryArgs)
    {
        $results    = $this->getEntitiesByPage($queryArgs);
        $results    = $results['results'];

        $date = (new DateTimeHelper())->toLocalString();
        $name = str_replace(' ', '_', $date).'_'.$page->getAlias();

        switch ($format) {
            case 'csv':
                $response = new StreamedResponse(
                    function () use ($results): void {
                        $handle = fopen('php://output', 'r+');

                        // build the header row
                        $header = $this->getExportHeaderForPage();
                        $this->putCsvExportRow($handle, $header);

                        // build the data rows
                        foreach ($results as $k => $s) {
                            $row = $this->getExportRowForPage($s);
                            $this->putCsvExportRow($handle, $row);

                            // free memory
                            unset($row, $results[$k]);
                        }

                        fclose($handle);
                    }
                );
                $this->setResponseHeaders($response, $name.'.csv', [
                    'text/csv; charset=UTF-8',
                ]);

                return $response;
            case 'html':
                $content = $this->twig->render(
                    '@MauticPage/Result/export.html.twig',
                    [
                        'page'      => $page,
                        'results'   => $results,
                        'pageTitle' => $name,
                    ]
                );

                return new Response($content);
            case 'xlsx':
                if (!class_exists(Spreadsheet::class)) {
                    throw new \Exception('PHPSpreadsheet is required to export to Excel spreadsheets');
                }
                $response = new StreamedResponse(
                    function () use ($results, $name): void {
                        $objPHPExcel = new Spreadsheet();
                        $objPHPExcel->getProperties()->setTitle($name);

                        $objPHPExcel->createSheet();
                        $header = $this->getExportHeaderForPage('xlsx');

                        // write the row
                        $objPHPExcel->getActiveSheet()->fromArray($header, null, 'A1');

                        // build the data rows
                        $count = 2;
                        foreach ($results as $k => $s) {
                            $row = $this->getExportRowForPage($s, 'xlsx');
                            $objPHPExcel->getActiveSheet()->fromArray($row, null, "A{$count}");

                            // free memory
                            unset($row, $results[$k]);

                            // increment letter
                            ++$count;
                        }

                        $objWriter = IOFactory::createWriter($objPHPExcel, 'Xlsx');
                        $objWriter->setPreCalculateFormulas(false);

                        $objWriter->save('php://output');
                    }
                );
                $this->setResponseHeaders($response, $name.'.xlsx', [
                    'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
                ]);

                return $response;
            default:
                return new Response();
        }
    }

    /**
     * @param array<string> $contentType
     */
    private function setResponseHeaders(StreamedResponse $response, string $filename, array $contentType): void
    {
        foreach ($contentType as $ct) {
            $response->headers->set('Content-Type', $ct);
        }

        $response->headers->set('Content-Disposition', 'attachment; filename="'.$filename.'"');
        $response->headers->set('Expires', '0');
        $response->headers->set('Cache-Control', 'must-revalidate');
        $response->headers->set('Pragma', 'public');
    }

    /**
     * @param resource     $handle
     * @param array<mixed> $row
     */
    private function putCsvExportRow($handle, array $row): bool|int
    {
        return fputcsv($handle, $row);
    }

    /**
     * @param array<mixed> $values
     *
     * @return array<mixed>
     */
    private function getExportRowForPage(array $values, string $format = 'csv'): array
    {
        $row = [
            $values['id'],
            $values['leadId'],
            $this->dateHelper->toFull($values['dateSubmitted'], 'UTC'),
            $values['ipAddress'],
            $values['referer'],
        ];

        if ('csv' === $format) {
            array_splice($row, 2, 0, $values['formId']);
        }

        return $row;
    }

    /**
     * @param array<mixed> $values
     * @param array<mixed> $viewOnlyFields
     *
     * @return array<mixed>
     */
    private function getExportRow(array $values, array $viewOnlyFields = []): array
    {
        $row = [
            $values['id'],
            $values['leadId'],
            $this->dateHelper->toFull($values['dateSubmitted'], 'UTC'),
            $values['ipAddress'],
            $values['referer'],
        ];

        foreach ($values['results'] as $k2 => $r) {
            if (in_array($r['type'], $viewOnlyFields)) {
                continue;
            }

            $row[] = htmlspecialchars_decode($r['value'], ENT_QUOTES);
            // free memory
            unset($values['results'][$k2]);
        }

        return $row;
    }

    /**
     * @return array<string>
     */
    private function getExportHeaderForPage(string $format = 'csv'): array
    {
        $header = [
            $this->translator->trans('mautic.form.report.submission.id'),
            $this->translator->trans('mautic.lead.report.contact_id'),
            $this->translator->trans('mautic.form.result.thead.date'),
            $this->translator->trans('mautic.core.ipaddress'),
            $this->translator->trans('mautic.form.result.thead.referrer'),
        ];

        if ('csv' === $format) {
            array_splice($header, 2, 0, $this->translator->trans('mautic.form.report.form_id'));
        }

        return $header;
    }

    /**
     * @param array<mixed> $viewOnlyFields
     *
     * @return array<string>
     */
    private function getExportHeader(Form $form, $viewOnlyFields): array
    {
        $fields = $form->getFields();

        $header = [
            $this->translator->trans('mautic.form.report.submission.id'),
            $this->translator->trans('mautic.lead.report.contact_id'),
            $this->translator->trans('mautic.form.result.thead.date'),
            $this->translator->trans('mautic.core.ipaddress'),
            $this->translator->trans('mautic.form.result.thead.referrer'),
        ];

        foreach ($fields as $f) {
            if (in_array($f->getType(), $viewOnlyFields) || false === $f->getSaveResult()) {
                continue;
            }
            $header[] = $f->getLabel();
        }

        return $header;
    }

    /**
     * Get line chart data of submissions.
     *
     * @param string|null $unit          {@link php.net/manual/en/function.date.php#refsect1-function.date-parameters}
     * @param string      $dateFormat
     * @param array       $filter
     * @param bool        $canViewOthers
     */
    public function getSubmissionsLineChartData(
        ?string $unit,
        \DateTime $dateFrom,
        \DateTime $dateTo,
        $dateFormat = null,
        $filter = [],
        $canViewOthers = true
    ): array {
        $chart = new LineChart($unit, $dateFrom, $dateTo, $dateFormat);
        $query = new ChartQuery($this->em->getConnection(), $dateFrom, $dateTo);
        $q     = $query->prepareTimeDataQuery('form_submissions', 'date_submitted', $filter);

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

        $data = $query->loadAndBuildTimeData($q);
        $chart->setDataset($this->translator->trans('mautic.form.submission.count'), $data);

        return $chart->render();
    }

    /**
     * Get a list of top submission referrers.
     *
     * @param int    $limit
     * @param string $dateFrom
     * @param string $dateTo
     * @param array  $filters
     * @param bool   $canViewOthers
     *
     * @return array
     */
    public function getTopSubmissionReferrers($limit = 10, $dateFrom = null, $dateTo = null, $filters = [], $canViewOthers = true)
    {
        $q = $this->em->getConnection()->createQueryBuilder();
        $q->select('COUNT(DISTINCT t.id) AS submissions, t.referer')
            ->from(MAUTIC_TABLE_PREFIX.'form_submissions', 't')
            ->orderBy('submissions', 'DESC')
            ->groupBy('t.referer')
            ->setMaxResults($limit);

        if (!$canViewOthers) {
            $q->join('t', MAUTIC_TABLE_PREFIX.'forms', 'f', 'f.id = t.form_id')
                ->andWhere('f.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_submitted');

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

    /**
     * Get a list of the most submisions per lead.
     *
     * @param int    $limit
     * @param string $dateFrom
     * @param string $dateTo
     * @param array  $filters
     * @param bool   $canViewOthers
     *
     * @return array
     */
    public function getTopSubmitters($limit = 10, $dateFrom = null, $dateTo = null, $filters = [], $canViewOthers = true)
    {
        $q = $this->em->getConnection()->createQueryBuilder();
        $q->select('COUNT(DISTINCT t.id) AS submissions, t.lead_id, l.firstname, l.lastname, l.email')
            ->from(MAUTIC_TABLE_PREFIX.'form_submissions', 't')
            ->join('t', MAUTIC_TABLE_PREFIX.'leads', 'l', 'l.id = t.lead_id')
            ->orderBy('submissions', 'DESC')
            ->groupBy('t.lead_id, l.firstname, l.lastname, l.email')
            ->setMaxResults($limit);

        if (!$canViewOthers) {
            $q->join('t', MAUTIC_TABLE_PREFIX.'forms', 'f', 'f.id = t.form_id')
                ->andWhere('f.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_submitted');

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

    /**
     * Execute a form submit action.
     *
     * @throws ValidationException
     */
    protected function executeFormActions(SubmissionEvent $event): void
    {
        $actions          = $event->getSubmission()->getForm()->getActions();
        $customComponents = $this->formModel->getCustomComponents();
        $availableActions = $customComponents['actions'] ?? [];

        $actions->filter(fn (Action $action): bool => array_key_exists($action->getType(), $availableActions))->map(function (Action $action) use ($event, $availableActions): void {
            $event->setAction($action);
            $this->dispatcher->dispatch($event, $availableActions[$action->getType()]['eventName']);
        });
    }

    /**
     * Create/update lead from form submit.
     *
     * @throws ORMException
     */
    protected function createLeadFromSubmit(Form $form, array $leadFieldMatches, $leadFields, Company $companyEntity = null): Lead
    {
        // set the mapped data
        $inKioskMode   = $form->isInKioskMode();
        $leadId        = null;
        $lead          = new Lead();
        $currentFields = $leadFieldMatches;
        $companyFields = $this->leadFieldModel->getFieldListWithProperties('company');

        if (!$inKioskMode) {
            // Default to currently tracked lead
            if ($currentLead = $this->contactTracker->getContact()) {
                $lead          = $currentLead;
                $leadId        = $lead->getId();
                $currentFields = $lead->getProfileFields();
            }

            $this->logger->debug('FORM: Not in kiosk mode so using current contact ID #'.$leadId);
        } else {
            // Default to a new lead in kiosk mode
            $lead->setNewlyCreated(true);

            $this->logger->debug('FORM: In kiosk mode so assuming a new contact');
        }

        $uniqueLeadFields = $this->leadFieldModel->getUniqueIdentifierFields();

        // Closure to get data and unique fields
        $getData = function ($currentFields, $uniqueOnly = false) use ($leadFields, $uniqueLeadFields): array {
            $uniqueFieldsWithData = $data = [];
            foreach ($leadFields as $alias => $properties) {
                if (isset($currentFields[$alias])) {
                    $value        = $currentFields[$alias];
                    $data[$alias] = $value;

                    // make sure the value is actually there and the field is one of our uniques
                    if (!empty($value) && array_key_exists($alias, $uniqueLeadFields)) {
                        $uniqueFieldsWithData[$alias] = $value;
                    }
                }
            }

            return ($uniqueOnly) ? $uniqueFieldsWithData : [$data, $uniqueFieldsWithData];
        };

        // Closure to get data and unique fields
        $getCompanyData = function ($currentFields) use ($companyFields): array {
            $companyData = [];
            // force add company contact field to company fields check
            $companyFields = array_merge($companyFields, ['company' => 'company']);
            foreach ($companyFields as $alias => $properties) {
                if (isset($currentFields[$alias])) {
                    $value               = $currentFields[$alias];
                    $companyData[$alias] = $value;
                }
            }

            return $companyData;
        };

        // Closure to help search for a conflict
        $checkForIdentifierConflict = function ($fieldSet1, $fieldSet2): array {
            // Find fields in both sets
            $potentialConflicts = array_keys(
                array_intersect_key($fieldSet1, $fieldSet2)
            );

            $this->logger->debug(
                'FORM: Potential conflicts '.implode(', ', array_keys($potentialConflicts)).' = '.implode(', ', $potentialConflicts)
            );

            $conflicts = [];
            foreach ($potentialConflicts as $field) {
                if (!empty($fieldSet1[$field]) && !empty($fieldSet2[$field])) {
                    if (strtolower($fieldSet1[$field]) !== strtolower($fieldSet2[$field])) {
                        $conflicts[] = $field;
                    }
                }
            }

            return [count($conflicts), $conflicts];
        };

        // Get data for the form submission
        [$data, $uniqueFieldsWithData] = $getData($leadFieldMatches);
        $this->logger->debug('FORM: Unique fields submitted include '.implode(', ', $uniqueFieldsWithData));

        // Check for duplicate lead
        /** @var \Mautic\LeadBundle\Entity\Lead[] $leads */
        $leads = (!empty($uniqueFieldsWithData)) ? $this->em->getRepository(Lead::class)->getLeadsByUniqueFields(
            $uniqueFieldsWithData,
            $leadId
        ) : [];

        $uniqueFieldsCurrent = $getData($currentFields, true);
        if (count($leads)) {
            $this->logger->debug(count($leads).' found based on unique identifiers');

            /** @var Lead $foundLead */
            $foundLead = $leads[0];

            $this->logger->debug('FORM: Testing contact ID# '.$foundLead->getId().' for conflicts');

            // Check for a conflict with the currently tracked lead
            $foundLeadFields = $foundLead->getProfileFields();

            // Get unique identifier fields for the found lead then compare with the lead currently tracked
            $uniqueFieldsFound         = $getData($foundLeadFields, true);
            [$hasConflict, $conflicts] = $checkForIdentifierConflict($uniqueFieldsFound, $uniqueFieldsCurrent);

            if ($inKioskMode || $hasConflict || !$lead->getId()) {
                // Use the found lead without merging because there is some sort of conflict with unique identifiers or in kiosk mode and thus should not merge
                $lead = $foundLead;

                if ($hasConflict) {
                    $this->logger->debug('FORM: Conflicts found in '.implode(', ', $conflicts).' so not merging');
                } else {
                    $this->logger->debug('FORM: In kiosk mode so not merging');
                }
            } else {
                $this->logger->debug('FORM: Merging contacts '.$lead->getId().' and '.$foundLead->getId());

                // Merge the found lead with currently tracked lead
                try {
                    $lead = $this->contactMerger->merge($lead, $foundLead);
                } catch (SameContactException) {
                }
            }

            // Update unique fields data for comparison with submitted data
            $currentFields       = $lead->getProfileFields();
            $uniqueFieldsCurrent = $getData($currentFields, true);
        }

        if (!$inKioskMode) {
            // Check for conflicts with the submitted data and the currently tracked lead
            [$hasConflict, $conflicts] = $checkForIdentifierConflict($uniqueFieldsWithData, $uniqueFieldsCurrent);

            $this->logger->debug(
                'FORM: Current unique contact fields '.implode(', ', array_keys($uniqueFieldsCurrent)).' = '.implode(', ', $uniqueFieldsCurrent)
            );

            $this->logger->debug(
                'FORM: Submitted unique contact fields '.implode(', ', array_keys($uniqueFieldsWithData)).' = '.implode(', ', $uniqueFieldsWithData)
            );
            if ($hasConflict) {
                // There's a conflict so create a new lead
                $lead = new Lead();
                $lead->setNewlyCreated(true);

                $this->logger->debug(
                    'FORM: Conflicts found in '.implode(', ', $conflicts)
                    .' between current tracked contact and submitted data so assuming a new contact'
                );
            }
        }

        // check for existing IP address
        $ipAddress = $this->ipLookupHelper->getIpAddress();

        // no lead was found by a mapped email field so create a new one
        if ($lead->isNewlyCreated()) {
            if (!$inKioskMode) {
                $lead->addIpAddress($ipAddress);
                $this->logger->debug('FORM: Associating '.$ipAddress->getIpAddress().' to contact');
            }
        } elseif (!$inKioskMode) {
            $leadIpAddresses = $lead->getIpAddresses();
            if (!$leadIpAddresses->contains($ipAddress)) {
                $lead->addIpAddress($ipAddress);

                $this->logger->debug('FORM: Associating '.$ipAddress->getIpAddress().' to contact');
            }
        }

        if ($companyEntity) {
            unset($data['company']);
        }

        // set the mapped fields
        $this->leadModel->setFieldValues($lead, $data, false, true, true);

        // last active time
        $lead->setLastActive(new \DateTime());

        // create a new lead
        $lead->setManipulator(new LeadManipulator(
            'form',
            'submission',
            $form->getId(),
            $form->getName()
        ));
        $this->leadModel->saveEntity($lead, false);

        if (!$inKioskMode) {
            // Set the current lead which will generate tracking cookies
            $this->contactTracker->setTrackedContact($lead);
        } else {
            // Set system current lead which will still allow execution of events without generating tracking cookies
            $this->contactTracker->setSystemContact($lead);
        }

        if ($companyEntity instanceof Company) {
            $this->companyModel->addLeadToCompany($companyEntity, $lead);

            return $lead;
        }

        $companyFieldMatches = $getCompanyData($leadFieldMatches);
        if (!empty($companyFieldMatches)) {
            [$company, $leadAdded, $companyEntity] = IdentifyCompanyHelper::identifyLeadsCompany($companyFieldMatches, $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, $companyFieldMatches);
                $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);
            }
        }

        return $lead;
    }

    /**
     * Validates a field value.
     *
     * @return bool|string True if valid; otherwise string with invalid reason
     */
    protected function validateFieldValue(Field $field, $value)
    {
        $standardValidation = $this->fieldHelper->validateFieldValue($field->getType(), $value, $field);
        if (!empty($standardValidation)) {
            return $standardValidation;
        }

        $components = $this->formModel->getCustomComponents();
        foreach ([$field->getType(), 'form'] as $type) {
            if (isset($components['validators'][$type])) {
                if (!is_array($components['validators'][$type])) {
                    $components['validators'][$type] = [$components['validators'][$type]];
                }
                foreach ($components['validators'][$type] as $validator) {
                    if (!is_array($validator)) {
                        $validator = ['eventName' => $validator];
                    }
                    $event = $this->dispatcher->dispatch(new ValidationEvent($field, $value), $validator['eventName']);
                    if (!$event->isValid()) {
                        return $event->getInvalidReason();
                    }
                }
            }
        }

        return true;
    }

    private function normalizeValue($value, Field $f): string
    {
        $value = !is_array($value) ? [$value] : $value;

        // select and multiselect normalization
        if ($properties = $f->getProperties()['list'] ?? null) {
            foreach ($value as $key => $item) {
                $value[$key] = CustomFieldValueHelper::setValueFromPropertiesList($properties, $item);
            }
        }

        return implode(', ', $value);
    }
}
