<?php

namespace Mautic\PluginBundle\Integration;

use Doctrine\ORM\EntityManager;
use GuzzleHttp\Client;
use GuzzleHttp\Handler\CurlHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\RequestOptions;
use Mautic\CoreBundle\Entity\CommonEntity;
use Mautic\CoreBundle\Entity\FormEntity;
use Mautic\CoreBundle\Helper\CacheStorageHelper;
use Mautic\CoreBundle\Helper\EncryptionHelper;
use Mautic\CoreBundle\Helper\PathsHelper;
use Mautic\CoreBundle\Model\NotificationModel;
use Mautic\LeadBundle\DataObject\LeadManipulator;
use Mautic\LeadBundle\Entity\DoNotContact;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Field\FieldsWithUniqueIdentifier;
use Mautic\LeadBundle\Model\CompanyModel;
use Mautic\LeadBundle\Model\DoNotContact as DoNotContactModel;
use Mautic\LeadBundle\Model\FieldModel;
use Mautic\LeadBundle\Model\LeadModel;
use Mautic\PluginBundle\Entity\Integration;
use Mautic\PluginBundle\Entity\IntegrationEntity;
use Mautic\PluginBundle\Entity\IntegrationEntityRepository;
use Mautic\PluginBundle\Event\PluginIntegrationAuthCallbackUrlEvent;
use Mautic\PluginBundle\Event\PluginIntegrationFormBuildEvent;
use Mautic\PluginBundle\Event\PluginIntegrationFormDisplayEvent;
use Mautic\PluginBundle\Event\PluginIntegrationKeyEvent;
use Mautic\PluginBundle\Event\PluginIntegrationRequestEvent;
use Mautic\PluginBundle\Exception\ApiErrorException;
use Mautic\PluginBundle\Helper\Cleaner;
use Mautic\PluginBundle\Helper\oAuthHelper;
use Mautic\PluginBundle\Model\IntegrationEntityModel;
use Mautic\PluginBundle\PluginEvents;
use Psr\Http\Message\ResponseInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\FormBuilder;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Contracts\Service\Attribute\Required;
use Symfony\Contracts\Translation\TranslatorInterface;

/**
 * @deprecated To be removed in Mautic 6.0. Please use the IntegrationsBundle instead, which is meant to be a drop-in replacement for AbstractIntegration.
 */
abstract class AbstractIntegration implements UnifiedIntegrationInterface
{
    public const FIELD_TYPE_STRING   = 'string';

    public const FIELD_TYPE_BOOL     = 'boolean';

    public const FIELD_TYPE_NUMBER   = 'number';

    public const FIELD_TYPE_DATETIME = 'datetime';

    public const FIELD_TYPE_DATE     = 'date';

    protected bool $coreIntegration = false;

    protected Integration $settings;

    protected array $keys = [];

    protected ?CacheStorageHelper $cache;

    protected ?Request $request;

    /**
     * Used for notifications.
     *
     * @var \Doctrine\ORM\Tools\Pagination\Paginator<\Mautic\UserBundle\Entity\User>
     */
    protected ?\Doctrine\ORM\Tools\Pagination\Paginator $adminUsers = null;

    protected array $notifications              = [];

    protected ?string $lastIntegrationError     = null;

    protected array $mauticDuplicates           = [];

    protected array $salesforceIdMapping        = [];

    protected array $deleteIntegrationEntities  = [];

    protected array $persistIntegrationEntities = [];

    protected array $commandParameters = [];
    private \Closure $clientFactory;

    public function __construct(
        protected EventDispatcherInterface $dispatcher,
        CacheStorageHelper $cacheStorageHelper,
        protected EntityManager $em,
        protected RequestStack $requestStack,
        protected RouterInterface $router,
        protected TranslatorInterface $translator,
        protected LoggerInterface $logger,
        protected EncryptionHelper $encryptionHelper,
        protected LeadModel $leadModel,
        protected CompanyModel $companyModel,
        protected PathsHelper $pathsHelper,
        protected NotificationModel $notificationModel,
        protected FieldModel $fieldModel,
        protected IntegrationEntityModel $integrationEntityModel,
        protected DoNotContactModel $doNotContact,
        protected FieldsWithUniqueIdentifier $fieldsWithUniqueIdentifier,
    ) {
        $this->cache                  = $cacheStorageHelper->getCache($this->getName());
        $this->request                = (!defined('IN_MAUTIC_CONSOLE')) ? $requestStack->getCurrentRequest() : null;

        $this->setClientFactory(fn (array $options): Client => new Client([
            'handler' => HandlerStack::create(new CurlHandler([
                'options' => $options,
            ])),
        ]));
    }

    public function setCommandParameters(array $params): void
    {
        $this->commandParameters = $params;
    }

    /**
     * @return CacheStorageHelper
     */
    public function getCache()
    {
        return $this->cache;
    }

    /**
     * @return TranslatorInterface
     */
    public function getTranslator()
    {
        return $this->translator;
    }

    /**
     * @return bool
     */
    public function isCoreIntegration()
    {
        return $this->coreIntegration;
    }

    /**
     * Determines what priority the integration should have against the other integrations.
     *
     * @return int
     */
    public function getPriority()
    {
        return 9999;
    }

    /**
     * Determines if DNC records should be updated by date or by priority.
     */
    public function updateDncByDate(): bool
    {
        return false;
    }

    /**
     * Returns the name of the social integration that must match the name of the file
     * For example, IcontactIntegration would need Icontact here.
     *
     * @return string
     */
    abstract public function getName();

    /**
     * Name to display for the integration. e.g. iContact  Uses value of getName() by default.
     *
     * @return string
     */
    public function getDisplayName()
    {
        return $this->getName();
    }

    /**
     * Returns a description shown in the config form.
     *
     * @return string
     */
    public function getDescription()
    {
        return '';
    }

    /**
     * Get icon for Integration.
     *
     * @return string
     */
    public function getIcon()
    {
        $systemPath  = $this->pathsHelper->getSystemPath('root');
        $bundlePath  = $this->pathsHelper->getSystemPath('bundles');
        $pluginPath  = $this->pathsHelper->getSystemPath('plugins');
        $genericIcon = $bundlePath.'/PluginBundle/Assets/img/generic.png';

        $name   = $this->getName();
        $bundle = $this->settings->getPlugin()->getBundle();
        $icon   = $pluginPath.'/'.$bundle.'/Assets/img/'.strtolower($name).'.png';

        if (file_exists($systemPath.'/'.$icon)) {
            return $icon;
        }

        return $genericIcon;
    }

    /**
     * Get the type of authentication required for this API.  Values can be none, key, oauth2 or callback
     * (will call $this->authenticationTypeCallback).
     *
     * @return string
     */
    abstract public function getAuthenticationType();

    /**
     * Get if data priority is enabled in the integration or not default is false.
     */
    public function getDataPriority(): bool
    {
        return false;
    }

    /**
     * Get a list of supported features for this integration.
     *
     * Options are:
     *  cloud_storage - Asset remote storage
     *  public_profile - Lead social profile
     *  public_activity - Lead social activity
     *  share_button - Landing page share button
     *  sso_service - SSO using 3rd party service via sso_login and sso_login_check routes
     *  sso_form - SSO using submitted credentials through the login form
     *
     * @return array
     */
    public function getSupportedFeatures()
    {
        return [];
    }

    /**
     * Get a list of tooltips for the specified supported features.
     * This allows you to add detail / informational tooltips to your
     * supported feature checkbox group.
     *
     * Example:
     *  'cloud_storage' => 'mautic.integration.form.features.cloud_storage.tooltip'
     *
     * @return array<string, string>
     */
    public function getSupportedFeatureTooltips()
    {
        return [];
    }

    /**
     * Returns the field the integration needs in order to find the user.
     *
     * @return mixed
     */
    public function getIdentifierFields()
    {
        return [];
    }

    /**
     * Allows integration to set a custom form template.
     *
     * @return string
     */
    public function getFormTemplate()
    {
        return '@MauticPlugin/Integration/form.html.twig';
    }

    /**
     * Allows integration to set a custom theme folder.
     *
     * @return string
     */
    public function getFormTheme()
    {
        return '@MauticPlugin/FormTheme/Integration/layout.html.twig';
    }

    /**
     * Set the social integration entity.
     */
    public function setIntegrationSettings(Integration $settings): void
    {
        $this->settings = $settings;

        $this->keys = $this->getDecryptedApiKeys();
    }

    /**
     * Get the social integration entity.
     *
     * @return Integration
     */
    public function getIntegrationSettings()
    {
        return $this->settings;
    }

    /**
     * Persist settings to the database.
     */
    public function persistIntegrationSettings(): void
    {
        $this->em->persist($this->settings);
        $this->em->flush();
    }

    /**
     * Merge api keys.
     *
     * @param bool|false $return Returns the key array rather than setting them
     *
     * @return void|array
     */
    public function mergeApiKeys($mergeKeys, $withKeys = [], $return = false)
    {
        $settings = $this->settings;
        if (empty($withKeys)) {
            $withKeys = $this->keys;
        }

        foreach ($withKeys as $k => $v) {
            if (!empty($mergeKeys[$k])) {
                $withKeys[$k] = $mergeKeys[$k];
            }
            unset($mergeKeys[$k]);
        }

        // merge remaining new keys
        $withKeys = array_merge($withKeys, $mergeKeys);

        if ($return) {
            $this->keys = $this->dispatchIntegrationKeyEvent(
                PluginEvents::PLUGIN_ON_INTEGRATION_KEYS_MERGE,
                $withKeys
            );

            return $this->keys;
        } else {
            $this->encryptAndSetApiKeys($withKeys, $settings);

            // reset for events that depend on rebuilding auth objects
            $this->setIntegrationSettings($settings);
        }
    }

    /**
     * Encrypts and saves keys to the entity.
     */
    public function encryptAndSetApiKeys(array $keys, Integration $entity): void
    {
        $keys = $this->dispatchIntegrationKeyEvent(
            PluginEvents::PLUGIN_ON_INTEGRATION_KEYS_ENCRYPT,
            $keys
        );

        // Update keys
        $this->keys = array_merge($this->keys, $keys);

        $encrypted = $this->encryptApiKeys($keys);
        $entity->setApiKeys($encrypted);
    }

    /**
     * Returns already decrypted keys.
     *
     * @return mixed
     */
    public function getKeys()
    {
        return $this->keys;
    }

    /**
     * Returns decrypted API keys.
     *
     * @param bool $entity
     *
     * @return array
     */
    public function getDecryptedApiKeys($entity = false)
    {
        static $decryptedKeys = [];

        if (!$entity) {
            $entity = $this->settings;
        }

        $keys = $entity->getApiKeys();

        $serialized = serialize($keys);
        if (empty($decryptedKeys[$serialized])) {
            $decrypted = $this->decryptApiKeys($keys, true);
            if (0 !== count($keys) && 0 === count($decrypted)) {
                $decrypted = $this->decryptApiKeys($keys);
                $this->encryptAndSetApiKeys($decrypted, $entity);
                $this->em->flush($entity);
            }
            $decryptedKeys[$serialized] = $this->dispatchIntegrationKeyEvent(
                PluginEvents::PLUGIN_ON_INTEGRATION_KEYS_DECRYPT,
                $decrypted
            );
        }

        return $decryptedKeys[$serialized];
    }

    /**
     * Encrypts API keys.
     *
     * @return array
     */
    public function encryptApiKeys(array $keys)
    {
        $encrypted = [];

        foreach ($keys as $name => $key) {
            $key              = $this->encryptionHelper->encrypt($key);
            $encrypted[$name] = $key;
        }

        return $encrypted;
    }

    /**
     * Decrypts API keys.
     *
     * @param bool $mainDecryptOnly
     *
     * @return array
     */
    public function decryptApiKeys(array $keys, $mainDecryptOnly = false)
    {
        $decrypted = [];

        foreach ($keys as $name => $key) {
            $key = $this->encryptionHelper->decrypt($key, $mainDecryptOnly);
            if (false === $key) {
                continue;
            }
            $decrypted[$name] = $key;
        }

        return $decrypted;
    }

    /**
     * Get the array key for clientId.
     *
     * @return string
     */
    public function getClientIdKey()
    {
        return match ($this->getAuthenticationType()) {
            'oauth1a' => 'consumer_id',
            'oauth2'  => 'client_id',
            'key'     => 'key',
            default   => '',
        };
    }

    /**
     * Get the array key for client secret.
     *
     * @return string
     */
    public function getClientSecretKey()
    {
        return match ($this->getAuthenticationType()) {
            'oauth1a' => 'consumer_secret',
            'oauth2'  => 'client_secret',
            'basic'   => 'password',
            default   => '',
        };
    }

    /**
     * Array of keys to mask in the config form.
     *
     * @return array
     */
    public function getSecretKeys()
    {
        return [$this->getClientSecretKey()];
    }

    /**
     * Get the array key for the auth token.
     *
     * @return string
     */
    public function getAuthTokenKey()
    {
        return match ($this->getAuthenticationType()) {
            'oauth2'  => 'access_token',
            'oauth1a' => 'oauth_token',
            default   => '',
        };
    }

    /**
     * Get the keys for the refresh token and expiry.
     *
     * @return array
     */
    public function getRefreshTokenKeys()
    {
        return [];
    }

    /**
     * Get a list of keys required to make an API call.  Examples are key, clientId, clientSecret.
     *
     * @return array
     */
    public function getRequiredKeyFields()
    {
        return match ($this->getAuthenticationType()) {
            'oauth1a' => [
                'consumer_id'     => 'mautic.integration.keyfield.consumerid',
                'consumer_secret' => 'mautic.integration.keyfield.consumersecret',
            ],
            'oauth2' => [
                'client_id'     => 'mautic.integration.keyfield.clientid',
                'client_secret' => 'mautic.integration.keyfield.clientsecret',
            ],
            'key' => [
                'key' => 'mautic.integration.keyfield.api',
            ],
            'basic' => [
                'username' => 'mautic.integration.keyfield.username',
                'password' => 'mautic.integration.keyfield.password',
            ],
            default => [],
        };
    }

    /**
     * Extract the tokens returned by the oauth callback.
     *
     * @param string $data
     * @param bool   $postAuthorization
     *
     * @return mixed
     */
    public function parseCallbackResponse($data, $postAuthorization = false)
    {
        // remove control characters that will break json_decode from parsing
        $data = preg_replace('/[[:cntrl:]]/', '', $data);
        if (!$parsed = json_decode($data, true)) {
            parse_str($data, $parsed);
        }

        return $parsed;
    }

    /**
     * Generic error parser.
     *
     * @return string|mixed[]
     */
    public function getErrorsFromResponse($response)
    {
        if (is_object($response)) {
            if (!empty($response->errors)) {
                $errors = [];
                foreach ($response->errors as $e) {
                    $errors[] = $e->message;
                }

                return implode('; ', $errors);
            } elseif (!empty($response->error->message)) {
                return $response->error->message;
            } else {
                return (string) $response;
            }
        } elseif (is_array($response)) {
            if (isset($response['error_description'])) {
                return $response['error_description'];
            } elseif (isset($response['error'])) {
                if (is_array($response['error'])) {
                    if (isset($response['error']['message'])) {
                        return $response['error']['message'];
                    } else {
                        return implode(', ', $response['error']);
                    }
                } else {
                    return $response['error'];
                }
            } elseif (isset($response['errors'])) {
                $errors = [];
                foreach ($response['errors'] as $err) {
                    if (is_array($err)) {
                        if (isset($err['message'])) {
                            $errors[] = $err['message'];
                        } else {
                            $errors[] = implode(', ', $err);
                        }
                    } else {
                        $errors[] = $err;
                    }
                }

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

            return $response;
        } else {
            return $response;
        }
    }

    /**
     * Make a basic call using cURL to get the data.
     *
     * @param string $url
     * @param array  $parameters
     * @param string $method
     * @param array  $settings   Set $settings['return_raw'] to receive a ResponseInterface
     *
     * @return mixed|string|ResponseInterface
     */
    public function makeRequest($url, $parameters = [], $method = 'GET', $settings = [])
    {
        // If not authorizing the session itself, check isAuthorized which will refresh tokens if applicable
        if (empty($settings['authorize_session'])) {
            $this->isAuthorized();
        }

        $method   = strtoupper($method);
        $authType = (empty($settings['auth_type'])) ? $this->getAuthenticationType() : $settings['auth_type'];

        [$parameters, $headers] = $this->prepareRequest($url, $parameters, $method, $settings, $authType);

        if (empty($settings['ignore_event_dispatch'])) {
            $event = $this->dispatcher->dispatch(
                new PluginIntegrationRequestEvent($this, $url, $parameters, $headers, $method, $settings, $authType),
                PluginEvents::PLUGIN_ON_INTEGRATION_REQUEST
            );

            $headers    = $event->getHeaders();
            $parameters = $event->getParameters();
        }

        if (!isset($settings['query'])) {
            $settings['query'] = [];
        }

        if (isset($parameters['append_to_query'])) {
            $settings['query'] = array_merge(
                $settings['query'],
                $parameters['append_to_query']
            );

            unset($parameters['append_to_query']);
        }

        if (isset($parameters['post_append_to_query'])) {
            $postAppend = $parameters['post_append_to_query'];
            unset($parameters['post_append_to_query']);
        }

        if (!$this->isConfigured()) {
            return [
                'error' => [
                    'message' => $this->translator->trans(
                        'mautic.integration.missingkeys'
                    ),
                ],
            ];
        }

        if ('GET' == $method && !empty($parameters)) {
            $parameters = array_merge($settings['query'], $parameters);
            $query      = http_build_query($parameters);
            $url .= (!str_contains($url, '?')) ? '?'.$query : '&'.$query;
        } elseif (!empty($settings['query'])) {
            $query = http_build_query($settings['query']);
            $url .= (!str_contains($url, '?')) ? '?'.$query : '&'.$query;
        }

        if (isset($postAppend)) {
            $url .= $postAppend;
        }

        // Check for custom content-type header
        if (!empty($settings['content_type'])) {
            $settings['encoding_headers_set'] = true;
            $headers[]                        = "Content-Type: {$settings['content_type']}";
        }

        if ('GET' !== $method) {
            if (!empty($parameters)) {
                if ('oauth1a' == $authType) {
                    $parameters = http_build_query($parameters);
                }
                if (!empty($settings['encode_parameters'])) {
                    if ('json' == $settings['encode_parameters']) {
                        // encode the arguments as JSON
                        $parameters = json_encode($parameters);
                        if (empty($settings['encoding_headers_set'])) {
                            $headers[] = 'Content-Type: application/json';
                        }
                    }
                }
            } elseif (isset($settings['post_data'])) {
                $parameters = $settings['post_data'];
            }
        }

        /**
         * Set some cURL settings for backward compatibility
         * https://docs.guzzlephp.org/en/latest/faq.html?highlight=curl#how-can-i-add-custom-curl-options.
         */
        $options = [
            CURLOPT_HEADER         => 1,
            CURLOPT_RETURNTRANSFER => 1,
            CURLOPT_FOLLOWLOCATION => 0,
            CURLOPT_REFERER        => $this->getRefererUrl(),
            CURLOPT_USERAGENT      => $this->getUserAgent(),
        ];

        if (isset($settings['curl_options']) && is_array($settings['curl_options'])) {
            $options = $settings['curl_options'] + $options;
        }

        if (isset($settings['ssl_verifypeer'])) {
            $options[CURLOPT_SSL_VERIFYPEER] = $settings['ssl_verifypeer'];
        }

        $client = $this->makeHttpClient($options);

        $parseHeaders = (isset($settings['headers'])) ? array_merge($headers, $settings['headers']) : $headers;
        // HTTP library requires that headers are in key => value pairs
        $headers = [];
        if (is_array($parseHeaders)) {
            foreach ($parseHeaders as $key => $value) {
                // Ignore string keys which assume it is already parsed and avoids splitting up a value that includes colons (such as a date/time)
                if (!is_string($key) && str_contains($value, ':')) {
                    [$key, $value]     = explode(':', $value);
                    $key               = trim($key);
                    $value             = trim($value);
                }

                $headers[$key] = $value;
            }
        }

        try {
            $timeout = (isset($settings['request_timeout'])) ? (int) $settings['request_timeout'] : 10;
            switch ($method) {
                case 'GET':
                    $result = $client->get($url, [
                        RequestOptions::HEADERS => $headers,
                        RequestOptions::TIMEOUT => $timeout,
                    ]);
                    break;
                case 'POST':
                case 'PUT':
                case 'PATCH':
                    $payloadKey = is_string($parameters) ? RequestOptions::BODY : RequestOptions::FORM_PARAMS;
                    $result     = $client->request($method, $url, [
                        $payloadKey             => $parameters,
                        RequestOptions::HEADERS => $headers,
                        RequestOptions::TIMEOUT => $timeout,
                    ]);
                    break;
                case 'DELETE':
                    $result = $client->delete($url, [
                        RequestOptions::HEADERS => $headers,
                        RequestOptions::TIMEOUT => $timeout,
                    ]);
                    break;
            }
        } catch (\GuzzleHttp\Exception\RequestException $exception) {
            if (!empty($settings['return_raw'])) {
                return $exception->getResponse();
            } else {
                return [
                    'error' => [
                        'message' => $exception->getResponse()->getBody()->getContents(),
                        'code'    => $exception->getCode(),
                    ],
                ];
            }
        }
        if (empty($settings['ignore_event_dispatch'])) {
            $event->setResponse($result);
            $this->dispatcher->dispatch(
                $event,
                PluginEvents::PLUGIN_ON_INTEGRATION_RESPONSE
            );
        }
        if (!empty($settings['return_raw'])) {
            return $result;
        } else {
            return $this->parseCallbackResponse($result->getBody(), !empty($settings['authorize_session']));
        }
    }

    /**
     * @param bool $persist
     */
    public function createIntegrationEntity(
        $integrationEntity,
        $integrationEntityId,
        $internalEntity,
        $internalEntityId,
        ?array $internal = null,
        $persist = true,
    ): ?IntegrationEntity {
        $date = (defined('MAUTIC_DATE_MODIFIED_OVERRIDE')) ? \DateTime::createFromFormat('U', MAUTIC_DATE_MODIFIED_OVERRIDE)
            : new \DateTime();
        $entity = new IntegrationEntity();
        $entity->setDateAdded($date)
            ->setLastSyncDate($date)
            ->setIntegration($this->getName())
            ->setIntegrationEntity($integrationEntity)
            ->setIntegrationEntityId($integrationEntityId)
            ->setInternalEntity($internalEntity)
            ->setInternal($internal)
            ->setInternalEntityId($internalEntityId);

        if ($persist) {
            $this->em->getRepository(IntegrationEntity::class)->saveEntity($entity);
        }

        return $entity;
    }

    /**
     * @return IntegrationEntityRepository
     */
    public function getIntegrationEntityRepository()
    {
        return $this->em->getRepository(IntegrationEntity::class);
    }

    /**
     * Method to prepare the request parameters. Builds array of headers and parameters.
     *
     * @return array
     */
    public function prepareRequest($url, $parameters, $method, $settings, $authType)
    {
        $clientIdKey     = $this->getClientIdKey();
        $clientSecretKey = $this->getClientSecretKey();
        $authTokenKey    = $this->getAuthTokenKey();
        $authToken       = '';
        if (isset($settings['override_auth_token'])) {
            $authToken = $settings['override_auth_token'];
        } elseif (isset($this->keys[$authTokenKey])) {
            $authToken = $this->keys[$authTokenKey];
        }

        // Override token parameter key if neede
        if (!empty($settings[$authTokenKey])) {
            $authTokenKey = $settings[$authTokenKey];
        }

        $headers = [];

        if (!empty($settings['authorize_session'])) {
            switch ($authType) {
                case 'oauth1a':
                    $requestTokenUrl = $this->getRequestTokenUrl();
                    if (!array_key_exists('append_callback', $settings) && !empty($requestTokenUrl)) {
                        $settings['append_callback'] = false;
                    }
                    $oauthHelper = new oAuthHelper($this, $this->request, $settings);
                    $headers     = $oauthHelper->getAuthorizationHeader($url, $parameters, $method);
                    break;
                case 'oauth2':
                    if ($bearerToken = $this->getBearerToken(true)) {
                        $headers = [
                            "Authorization: Basic {$bearerToken}",
                            'Content-Type: application/x-www-form-urlencoded;charset=UTF-8',
                        ];
                        $parameters['grant_type'] = 'client_credentials';
                    } else {
                        $defaultGrantType = (!empty($settings['refresh_token'])) ? 'refresh_token'
                            : 'authorization_code';
                        $grantType = (!isset($settings['grant_type'])) ? $defaultGrantType
                            : $settings['grant_type'];

                        $useClientIdKey     = (empty($settings[$clientIdKey])) ? $clientIdKey : $settings[$clientIdKey];
                        $useClientSecretKey = (empty($settings[$clientSecretKey])) ? $clientSecretKey
                            : $settings[$clientSecretKey];
                        $parameters = array_merge(
                            $parameters,
                            [
                                $useClientIdKey     => $this->keys[$clientIdKey],
                                $useClientSecretKey => $this->keys[$clientSecretKey] ?? '',
                                'grant_type'        => $grantType,
                            ]
                        );

                        if (!empty($settings['refresh_token']) && !empty($this->keys[$settings['refresh_token']])) {
                            $parameters[$settings['refresh_token']] = $this->keys[$settings['refresh_token']];
                        }

                        if ('authorization_code' === $grantType) {
                            $parameters['code'] = $this->request->get('code');
                        }
                        if (empty($settings['ignore_redirecturi'])) {
                            $callback                   = $this->getAuthCallbackUrl();
                            $parameters['redirect_uri'] = $callback;
                        }
                    }
                    break;
            }
        } else {
            switch ($authType) {
                case 'basic':
                    $headers = [
                        'Authorization' => 'Basic '.base64_encode($this->keys['username'].':'.$this->keys['password']),
                    ];
                    break;
                case 'oauth1a':
                    $oauthHelper = new oAuthHelper($this, $this->request, $settings);
                    $headers     = $oauthHelper->getAuthorizationHeader($url, $parameters, $method);
                    break;
                case 'oauth2':
                    if ($bearerToken = $this->getBearerToken()) {
                        $headers = [
                            "Authorization: Bearer {$bearerToken}",
                            // "Content-Type: application/x-www-form-urlencoded;charset=UTF-8"
                        ];
                    } else {
                        if (!empty($settings['append_auth_token'])) {
                            // Workaround because $settings cannot be manipulated here
                            $parameters['append_to_query'] = [
                                $authTokenKey => $authToken,
                            ];
                        } else {
                            $parameters[$authTokenKey] = $authToken;
                        }

                        $headers = [
                            "oauth-token: $authTokenKey",
                            "Authorization: OAuth {$authToken}",
                        ];
                    }
                    break;
                case 'key':
                    $parameters[$authTokenKey] = $authToken;
                    break;
            }
        }

        return [$parameters, $headers];
    }

    /**
     * Generate the auth login URL.  Note that if oauth2, response_type=code is assumed.  If this is not the case,
     * override this function.
     *
     * @return string
     */
    public function getAuthLoginUrl()
    {
        $authType = $this->getAuthenticationType();

        if ('oauth2' == $authType) {
            $callback    = $this->getAuthCallbackUrl();
            $clientIdKey = $this->getClientIdKey();
            $state       = $this->getAuthLoginState();
            $url         = $this->getAuthenticationUrl()
                .'?client_id='.$this->keys[$clientIdKey]
                .'&response_type=code'
                .'&redirect_uri='.urlencode($callback)
                .'&state='.$state;

            if ($scope = $this->getAuthScope()) {
                $url .= '&scope='.urlencode($scope);
            }

            if ($this->requestStack->getCurrentRequest()?->hasSession()) {
                $this->requestStack->getSession()->set($this->getName().'_csrf_token', $state);
            }

            return $url;
        } else {
            return $this->router->generate(
                'mautic_integration_auth_callback',
                ['integration' => $this->getName()]
            );
        }
    }

    /**
     * State variable to append to login url (usually used in oAuth flows).
     *
     * @return string
     */
    public function getAuthLoginState()
    {
        return hash('sha1', uniqid(mt_rand()));
    }

    /**
     * Get the scope for auth flows.
     *
     * @return string
     */
    public function getAuthScope()
    {
        return '';
    }

    /**
     * Gets the URL for the built in oauth callback.
     *
     * @return string
     */
    public function getAuthCallbackUrl()
    {
        $defaultUrl = $this->router->generate(
            'mautic_integration_auth_callback',
            ['integration' => $this->getName()],
            UrlGeneratorInterface::ABSOLUTE_URL // absolute
        );

        /** @var PluginIntegrationAuthCallbackUrlEvent $event */
        $event = $this->dispatcher->dispatch(
            new PluginIntegrationAuthCallbackUrlEvent($this, $defaultUrl),
            PluginEvents::PLUGIN_ON_INTEGRATION_GET_AUTH_CALLBACK_URL
        );

        return $event->getCallbackUrl();
    }

    /**
     * Retrieves and stores tokens returned from oAuthLogin.
     *
     * @param mixed[] $settings
     * @param mixed[] $parameters
     *
     * @return bool|string false if no error; otherwise the error string
     *
     * @throws ApiErrorException if OAuth2 state does not match
     */
    public function authCallback($settings = [], $parameters = [])
    {
        $authType = $this->getAuthenticationType();

        switch ($authType) {
            case 'oauth2':
                if ($this->requestStack->getCurrentRequest()?->hasSession()) {
                    $state      = $this->requestStack->getSession()->get($this->getName().'_csrf_token', false);
                    $givenState = ($this->request->isXmlHttpRequest()) ? $this->request->request->get('state') : $this->request->get('state');

                    if ($state && $state !== $givenState) {
                        $this->requestStack->getSession()->remove($this->getName().'_csrf_token');
                        throw new ApiErrorException($this->translator->trans('mautic.integration.auth.invalid.state'));
                    }
                }

                if (!empty($settings['use_refresh_token'])) {
                    // Try refresh token
                    $refreshTokenKeys = $this->getRefreshTokenKeys();

                    if (!empty($refreshTokenKeys)) {
                        [$refreshTokenKey, $expiryKey] = $refreshTokenKeys;

                        $settings['refresh_token'] = $refreshTokenKey;
                    }
                }
                break;

            case 'oauth1a':
                // After getting request_token and authorizing, post back to access_token
                $settings['append_callback']  = true;
                $settings['include_verifier'] = true;

                // Get request token returned from Twitter and submit it to get access_token
                $settings['request_token'] = ($this->request) ? $this->request->get('oauth_token') : '';

                break;
        }

        $settings['authorize_session'] = true;

        $method = (!isset($settings['method'])) ? 'POST' : $settings['method'];
        $data   = $this->makeRequest($this->getAccessTokenUrl(), $parameters, $method, $settings);

        return $this->extractAuthKeys($data);
    }

    /**
     * Extacts the auth keys from response and saves entity.
     *
     * @return bool|string false if no error; otherwise the error string
     */
    public function extractAuthKeys($data, $tokenOverride = null)
    {
        // check to see if an entity exists
        $entity = $this->getIntegrationSettings();
        if (null == $entity) {
            $entity = new Integration();
            $entity->setName($this->getName());
        }
        // Prepare the keys for extraction such as renaming, setting expiry, etc
        $data = $this->prepareResponseForExtraction($data);

        // parse the response
        $authTokenKey = $tokenOverride ?: $this->getAuthTokenKey();
        if (is_array($data) && isset($data[$authTokenKey])) {
            $keys      = $this->mergeApiKeys($data, null, true);
            $encrypted = $this->encryptApiKeys($keys);
            $entity->setApiKeys($encrypted);

            if ($this->requestStack->getCurrentRequest()?->hasSession()) {
                $this->requestStack->getSession()->set($this->getName().'_tokenResponse', $data);
            }

            $error = false;
        } elseif (is_array($data) && isset($data['access_token'])) {
            if ($this->requestStack->getCurrentRequest()?->hasSession()) {
                $this->requestStack->getSession()->set($this->getName().'_tokenResponse', $data);
            }
            $error = false;
        } else {
            $error = $this->getErrorsFromResponse($data);
            if (empty($error)) {
                $error = $this->translator->trans(
                    'mautic.integration.error.genericerror',
                    [],
                    'flashes'
                );
            }
        }

        // save the data
        $this->em->persist($entity);
        $this->em->flush();

        $this->setIntegrationSettings($entity);

        return $error;
    }

    /**
     * Called in extractAuthKeys before key comparison begins to give opportunity to set expiry, rename keys, etc.
     *
     * @return mixed
     */
    public function prepareResponseForExtraction($data)
    {
        return $data;
    }

    /**
     * Checks to see if the integration is configured by checking that required keys are populated.
     */
    public function isConfigured(): bool
    {
        $requiredTokens = $this->getRequiredKeyFields();
        foreach ($requiredTokens as $token => $label) {
            if (empty($this->keys[$token])) {
                return false;
            }
        }

        return true;
    }

    /**
     * Checks if an integration is authorized and/or authorizes the request.
     *
     * @return bool
     */
    public function isAuthorized()
    {
        if (!$this->isConfigured()) {
            return false;
        }

        $type         = $this->getAuthenticationType();
        $authTokenKey = $this->getAuthTokenKey();

        switch ($type) {
            case 'oauth1a':
            case 'oauth2':
                $refreshTokenKeys = $this->getRefreshTokenKeys();
                if (!isset($this->keys[$authTokenKey])) {
                    $valid = false;
                } elseif (!empty($refreshTokenKeys)) {
                    [$refreshTokenKey, $expiryKey] = $refreshTokenKeys;
                    if (!empty($this->keys[$refreshTokenKey]) && !empty($expiryKey) && isset($this->keys[$expiryKey])
                        && time() > $this->keys[$expiryKey]
                    ) {
                        // token has expired so try to refresh it
                        $error = $this->authCallback(['refresh_token' => $refreshTokenKey]);
                        $valid = (empty($error));
                    } else {
                        // The refresh token doesn't have an expiry so the integration will have to check for expired sessions and request new token
                        $valid = true;
                    }
                } else {
                    $valid = true;
                }
                break;
            case 'key':
                $valid = isset($this->keys['api_key']);
                break;
            case 'rest':
                $valid = isset($this->keys[$authTokenKey]);
                break;
            case 'basic':
                $valid = (!empty($this->keys['username']) && !empty($this->keys['password']));
                break;
            default:
                $valid = true;
                break;
        }

        return $valid;
    }

    /**
     * Get the URL required to obtain an oauth2 access token.
     *
     * @return string
     */
    public function getAccessTokenUrl()
    {
        return '';
    }

    /**
     * Get the authentication/login URL for oauth2 access.
     *
     * @return string
     */
    public function getAuthenticationUrl()
    {
        return '';
    }

    /**
     * Get request token for oauth1a authorization request.
     *
     * @param array $settings
     *
     * @return mixed|string
     */
    public function getRequestToken($settings = [])
    {
        // Child classes can easily pass in custom settings this way
        $settings = array_merge(
            ['authorize_session' => true, 'append_callback' => false, 'ssl_verifypeer' => true],
            $settings
        );

        // init result to empty string
        $result = '';

        $url = $this->getRequestTokenUrl();
        if (!empty($url)) {
            $result = $this->makeRequest(
                $url,
                [],
                'POST',
                $settings
            );
        }

        return $result;
    }

    /**
     * Url to post in order to get the request token if required; leave empty if not required.
     *
     * @return string
     */
    public function getRequestTokenUrl()
    {
        return '';
    }

    /**
     * Generate a bearer token.
     *
     * @return string
     */
    public function getBearerToken($inAuthorization = false)
    {
        return '';
    }

    /**
     * Get an array of public activity.
     *
     * @return array|void
     */
    public function getPublicActivity($identifier, &$socialCache)
    {
        return [];
    }

    /**
     * Get an array of public data.
     *
     * @return mixed[]|void
     */
    public function getUserData($identifier, &$socialCache)
    {
        return [];
    }

    /**
     * Generates current URL to set as referer for curl calls.
     */
    protected function getRefererUrl(): ?string
    {
        return ($this->request) ? $this->request->getRequestUri() : null;
    }

    /**
     * Generate a user agent string.
     *
     * @return string
     */
    protected function getUserAgent()
    {
        return ($this->request) ? $this->request->server->get('HTTP_USER_AGENT') : null;
    }

    /**
     * Get a list of available fields from the connecting API.
     *
     * @param mixed[] $settings
     *
     * @return mixed[]
     */
    public function getAvailableLeadFields(array $settings = []): array
    {
        if (empty($settings['ignore_field_cache'])) {
            $cacheSuffix = $settings['cache_suffix'] ?? '';
            if ($fields = $this->cache->get('leadFields'.$cacheSuffix)) {
                return $fields;
            }
        }

        return [];
    }

    /**
     * @return array
     */
    public function cleanUpFields(Integration $entity, array $mauticLeadFields, array $mauticCompanyFields)
    {
        $featureSettings        = $entity->getFeatureSettings();
        $submittedFields        = $featureSettings['leadFields'] ?? [];
        $submittedCompanyFields = $featureSettings['companyFields'] ?? [];
        $submittedObjects       = $featureSettings['objects'] ?? [];
        $missingRequiredFields  = [];

        // add special case in order to prevent it from being removed
        $mauticLeadFields['mauticContactId']                   = '';
        $mauticLeadFields['mauticContactTimelineLink']         = '';
        $mauticLeadFields['mauticContactIsContactableByEmail'] = '';

        // make sure now non-existent aren't saved
        $settings = [
            'ignore_field_cache' => false,
        ];
        $settings['feature_settings']['objects'] = $submittedObjects;
        $availableIntegrationFields              = $this->getAvailableLeadFields($settings);
        $leadFields                              = [];

        /**
         * @param $mappedFields
         * @param $integrationFields
         * @param $mauticFields
         * @param $fieldType
         */
        $cleanup = function (&$mappedFields, $integrationFields, $mauticFields, $fieldType) use (&$missingRequiredFields, &$featureSettings): void {
            $updateKey    = ('companyFields' === $fieldType) ? 'update_mautic_company' : 'update_mautic';
            $removeFields = array_keys(array_diff_key($mappedFields, $integrationFields));

            // Find all the mapped fields that no longer exist in Mautic
            if ($nonExistentFields = array_diff($mappedFields, array_keys($mauticFields))) {
                // Remove those fields
                $removeFields = array_merge($removeFields, array_keys($nonExistentFields));
            }

            foreach ($removeFields as $field) {
                unset($mappedFields[$field]);

                if (isset($featureSettings[$updateKey])) {
                    unset($featureSettings[$updateKey][$field]);
                }
            }

            // Check that the remaining fields have an updateKey set
            foreach ($mappedFields as $field => $mauticField) {
                if (!isset($featureSettings[$updateKey][$field])) {
                    // Assume it's mapped to Mautic
                    $featureSettings[$updateKey][$field] = 1;
                }
            }

            // Check if required fields are missing
            $required = $this->getRequiredFields($integrationFields, $fieldType);
            if (array_diff_key($required, $mappedFields)) {
                $missingRequiredFields[$fieldType] = true;
            }
        };

        if ($submittedObjects) {
            if (in_array('company', $submittedObjects)) {
                // special handling for company fields
                if (isset($availableIntegrationFields['company'])) {
                    $cleanup($submittedCompanyFields, $availableIntegrationFields['company'], $mauticCompanyFields, 'companyFields');
                    $featureSettings['companyFields'] = $submittedCompanyFields;
                    unset($availableIntegrationFields['company']);
                }
            }

            // Rest of the objects are merged and assumed to be leadFields
            // BC compatibility If extends fields to objects - 0 === contacts
            if (isset($availableIntegrationFields[0])) {
                $leadFields = array_merge($leadFields, $availableIntegrationFields[0]);
            }

            foreach ($submittedObjects as $object) {
                if (isset($availableIntegrationFields[$object])) {
                    $leadFields = array_merge($leadFields, $availableIntegrationFields[$object]);
                }
            }
        } else {
            // Cleanup assuming there are no objects as keys
            $leadFields = $availableIntegrationFields;
        }

        if (!empty($leadFields)) {
            $cleanup($submittedFields, $leadFields, $mauticLeadFields, 'leadFields');
            $featureSettings['leadFields'] = $submittedFields;
        }

        $entity->setFeatureSettings($featureSettings);

        return $missingRequiredFields;
    }

    /**
     * @param string $fieldType
     *
     * @return array
     */
    public function getRequiredFields(array $fields, $fieldType = '')
    {
        // use $fieldType to determine if email should be required. we use email as unique identifier for contacts only,
        // if any other fieldType use integrations own field types
        $requiredFields = [];
        foreach ($fields as $field => $details) {
            if ('leadFields' === $fieldType) {
                if ((is_array($details) && !empty($details['required'])) || 'email' === $field
                    || (isset($details['optionLabel'])
                        && 'email' == strtolower(
                            $details['optionLabel']
                        ))
                ) {
                    $requiredFields[$field] = $field;
                }
            } else {
                if (is_array($details) && !empty($details['required'])
                ) {
                    $requiredFields[$field] = $field;
                }
            }
        }

        return $requiredFields;
    }

    /**
     * Match lead data with integration fields.
     *
     * @param Lead|mixed[] $lead
     * @param mixed[]      $config
     *
     * @return mixed[]
     */
    public function populateLeadData($lead, $config = [])
    {
        if (!isset($config['leadFields'])) {
            $config = $this->mergeConfigToFeatureSettings($config);

            if (empty($config['leadFields'])) {
                return [];
            }
        }

        if ($lead instanceof Lead) {
            $fields = $lead->getProfileFields();
            $leadId = $lead->getId();
        } else {
            $fields = $lead;
            $leadId = $lead['id'];
        }

        $object          = $config['object'] ?? null;
        $leadFields      = $config['leadFields'];
        $availableFields = $this->getAvailableLeadFields($config);

        if ($object) {
            $availableFields = $availableFields[$config['object']];
        } else {
            $availableFields = $availableFields[0] ?? $availableFields;
        }

        $unknown = $this->translator->trans('mautic.integration.form.lead.unknown');
        $matched = [];

        foreach ($availableFields as $key => $field) {
            $integrationKey = $matchIntegrationKey = $this->convertLeadFieldKey($key, $field);
            if (!isset($config['leadFields'][$integrationKey])) {
                continue;
            }

            if (isset($leadFields[$integrationKey])) {
                if ('mauticContactTimelineLink' === $leadFields[$integrationKey]) {
                    $matched[$integrationKey] = $this->getContactTimelineLink($leadId);

                    continue;
                }
                if ('mauticContactIsContactableByEmail' === $leadFields[$integrationKey]) {
                    $matched[$integrationKey] = $this->getLeadDoNotContact($leadId);

                    continue;
                }
                if ('mauticContactId' === $leadFields[$integrationKey]) {
                    $matched[$integrationKey] = $lead->getId();
                    continue;
                }
                $mauticKey = $leadFields[$integrationKey];
                if (isset($fields[$mauticKey]) && '' !== $fields[$mauticKey]) {
                    $matched[$matchIntegrationKey] = $this->cleanPushData(
                        $fields[$mauticKey],
                        $field['type'] ?? 'string'
                    );
                }
            }

            if (!empty($field['required']) && empty($matched[$matchIntegrationKey])) {
                $matched[$matchIntegrationKey] = $unknown;
            }
        }

        return $matched;
    }

    /**
     * Match Company data with integration fields.
     *
     * @return array
     */
    public function populateCompanyData($entity, $config = [])
    {
        if (!isset($config['companyFields'])) {
            $config = $this->mergeConfigToFeatureSettings($config);

            if (empty($config['companyFields'])) {
                return [];
            }
        }

        if ($entity instanceof Lead) {
            $fields = $entity->getPrimaryCompany();
        } else {
            $fields = $entity['primaryCompany'];
        }

        $companyFields   = $config['companyFields'];
        $availableFields = $this->getAvailableLeadFields($config)['company'];
        $unknown         = $this->translator->trans('mautic.integration.form.lead.unknown');
        $matched         = [];

        foreach ($availableFields as $key => $field) {
            $integrationKey = $this->convertLeadFieldKey($key, $field);

            if (isset($companyFields[$key])) {
                $mauticKey = $companyFields[$key];
                if (isset($fields[$mauticKey]) && !empty($fields[$mauticKey])) {
                    $matched[$integrationKey] = $this->cleanPushData($fields[$mauticKey], $field['type'] ?? 'string');
                }
            }

            if (!empty($field['required']) && empty($matched[$integrationKey])) {
                $matched[$integrationKey] = $unknown;
            }
        }

        return $matched;
    }

    /**
     * Takes profile data from an integration and maps it to Mautic's lead fields.
     *
     * @param array       $config
     * @param string|null $object
     *
     * @return array
     */
    public function populateMauticLeadData($data, $config = [], $object = null)
    {
        // Glean supported fields from what was returned by the integration
        $gleanedData = $data;

        if (null == $object) {
            $object = 'lead';
        }
        if ('company' == $object) {
            if (!isset($config['companyFields'])) {
                $config = $this->mergeConfigToFeatureSettings($config);

                if (empty($config['companyFields'])) {
                    return [];
                }
            }

            $fields = $config['companyFields'];
        }
        if ('lead' == $object) {
            if (!isset($config['leadFields'])) {
                $config = $this->mergeConfigToFeatureSettings($config);

                if (empty($config['leadFields'])) {
                    return [];
                }
            }
            $fields = $config['leadFields'];
        }

        $matched = [];
        foreach ($gleanedData as $key => $field) {
            if (isset($fields[$key]) && isset($gleanedData[$key])
                && $this->translator->trans('mautic.integration.form.lead.unknown') !== $gleanedData[$key]
            ) {
                $matched[$fields[$key]] = $gleanedData[$key];
            }
        }

        return $matched;
    }

    /**
     * Create or update existing Mautic lead from the integration's profile data.
     *
     * @param mixed      $data        Profile data from integration
     * @param bool|true  $persist     Set to false to not persist lead to the database in this method
     * @param array|null $socialCache
     * @param mixed|null $identifiers
     *
     * @return Lead
     */
    public function getMauticLead($data, $persist = true, $socialCache = null, $identifiers = null)
    {
        if (is_object($data)) {
            // Convert to array in all levels
            $data = json_encode(json_decode($data, true));
        } elseif (is_string($data)) {
            // Assume JSON
            $data = json_decode($data, true);
        }

        // Match that data with mapped lead fields
        $matchedFields = $this->populateMauticLeadData($data);

        if (empty($matchedFields)) {
            return;
        }

        // Find unique identifier fields used by the integration
        /** @var LeadModel $leadModel */
        $leadModel           = $this->leadModel;
        $uniqueLeadFields    = $this->fieldsWithUniqueIdentifier->getFieldsWithUniqueIdentifier();
        $uniqueLeadFieldData = [];

        foreach ($matchedFields as $leadField => $value) {
            if (array_key_exists($leadField, $uniqueLeadFields) && !empty($value)) {
                $uniqueLeadFieldData[$leadField] = $value;
            }
        }

        // Default to new lead
        $lead = new Lead();
        $lead->setNewlyCreated(true);

        if (count($uniqueLeadFieldData)) {
            $existingLeads = $this->em->getRepository(Lead::class)
                ->getLeadsByUniqueFields($uniqueLeadFieldData);

            if (!empty($existingLeads)) {
                $lead = array_shift($existingLeads);
            }
        }

        $leadModel->setFieldValues($lead, $matchedFields, false, false);

        // Update the social cache
        $leadSocialCache = $lead->getSocialCache();
        if (!isset($leadSocialCache[$this->getName()])) {
            $leadSocialCache[$this->getName()] = [];
        }

        if (null !== $socialCache) {
            $leadSocialCache[$this->getName()] = array_merge($leadSocialCache[$this->getName()], $socialCache);
        }

        // Check for activity while here
        if (null !== $identifiers && in_array('public_activity', $this->getSupportedFeatures())) {
            $this->getPublicActivity($identifiers, $leadSocialCache[$this->getName()]);
        }

        $lead->setSocialCache($leadSocialCache);

        // Update the internal info integration object that has updated the record
        if (isset($data['internal'])) {
            $internalInfo                   = $lead->getInternal();
            $internalInfo[$this->getName()] = $data['internal'];
            $lead->setInternal($internalInfo);
        }

        if ($persist && !empty($lead->getChanges(true))) {
            // Only persist if instructed to do so as it could be that calling code needs to manipulate the lead prior to executing event listeners
            try {
                $lead->setManipulator(new LeadManipulator(
                    'plugin',
                    $this->getName(),
                    null,
                    $this->getDisplayName()
                ));
                $leadModel->saveEntity($lead, false);
            } catch (\Exception $exception) {
                $this->logger->warning($exception->getMessage());

                return;
            }
        }

        return $lead;
    }

    /**
     * Merges a config from integration_list with feature settings.
     *
     * @param array $config
     *
     * @return array|mixed
     */
    public function mergeConfigToFeatureSettings($config = [])
    {
        $featureSettings = $this->settings->getFeatureSettings();

        if (isset($config['config'])
            && (empty($config['integration'])
                || (!empty($config['integration'])
                    && $config['integration'] == $this->getName()))
        ) {
            $featureSettings = array_merge($featureSettings, $config['config']);
        }

        return $featureSettings;
    }

    /**
     * Return key recognized by integration.
     */
    public function convertLeadFieldKey(string $key, $field): string
    {
        return $key;
    }

    /**
     * Sets whether fields should be sorted alphabetically or by the order the integration feeds.
     */
    public function sortFieldsAlphabetically(): bool
    {
        return true;
    }

    /**
     * Used to match local field name with remote field name.
     *
     * @param string $field
     * @param string $subfield
     *
     * @return mixed
     */
    public function matchFieldName($field, $subfield = '')
    {
        if (!empty($field) && !empty($subfield)) {
            return $subfield.ucfirst($field);
        }

        return $field;
    }

    /**
     * Convert and assign the data to assignable fields.
     *
     * @param mixed $data
     *
     * @return array
     */
    protected function matchUpData($data)
    {
        $info      = [];
        $available = $this->getAvailableLeadFields();

        foreach ($available as $field => $fieldDetails) {
            if (is_array($data)) {
                if (!isset($data[$field])) {
                    $info[$field] = '';
                    continue;
                } else {
                    $values = $data[$field];
                }
            } else {
                if (!isset($data->$field)) {
                    $info[$field] = '';
                    continue;
                } else {
                    $values = $data->$field;
                }
            }

            switch ($fieldDetails['type']) {
                case 'string':
                case 'boolean':
                    $info[$field] = $values;
                    break;
                case 'object':
                    foreach ($fieldDetails['fields'] as $f) {
                        if (isset($values->$f)) {
                            $fn = $this->matchFieldName($field, $f);

                            $info[$fn] = $values->$f;
                        }
                    }
                    break;
                case 'array_object':
                    $objects = [];
                    if (!empty($values)) {
                        foreach ($values as $v) {
                            if (isset($v->value)) {
                                $objects[] = $v->value;
                            }
                        }
                    }
                    $fn = (isset($fieldDetails['fields'][0])) ? $this->matchFieldName(
                        $field,
                        $fieldDetails['fields'][0]
                    ) : $field;
                    $info[$fn] = implode('; ', $objects);

                    break;
            }
        }

        return $info;
    }

    /**
     * Get the path to the profile templates for this integration.
     */
    public function getSocialProfileTemplate()
    {
        return null;
    }

    /**
     * Checks to ensure an image still exists before caching.
     *
     * @param string $url
     */
    public function checkImageExists($url): bool
    {
        $ch = curl_init($url);
        curl_setopt($ch, CURLOPT_NOBODY, true);
        curl_setopt(
            $ch,
            CURLOPT_USERAGENT,
            'Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US; rv:1.8.1.13) Gecko/20080311 Firefox/2.0.0.13'
        );
        curl_exec($ch);
        $retcode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);

        return 200 == $retcode;
    }

    /**
     * @return NotificationModel
     */
    public function getNotificationModel()
    {
        return $this->notificationModel;
    }

    public function logIntegrationError(\Exception $e, ?Lead $contact = null): void
    {
        $logger = $this->logger;

        if ($e instanceof ApiErrorException) {
            if (null === $this->adminUsers) {
                $this->adminUsers = $this->em->getRepository(\Mautic\UserBundle\Entity\User::class)->getEntities(
                    [
                        'filter' => [
                            'force' => [
                                [
                                    'column' => 'r.isAdmin',
                                    'expr'   => 'eq',
                                    'value'  => true,
                                ],
                            ],
                        ],
                    ]
                );
            }

            $errorMessage = $e->getMessage();
            $errorHeader  = $this->getTranslator()->trans(
                'mautic.integration.error',
                [
                    '%name%' => $this->getName(),
                ]
            );

            if ($contact || $contact = $e->getContact()) {
                // Append a link to the contact
                $contactId   = $contact->getId();
                $contactName = $contact->getPrimaryIdentifier();
            } elseif ($contactId = $e->getContactId()) {
                $contactName = $this->getTranslator()->trans('mautic.integration.error.generic_contact_name', ['%id%' => $contactId]);
            }

            $this->lastIntegrationError = $errorHeader.': '.$errorMessage;

            if ($contactId) {
                $contactLink = $this->router->generate(
                    'mautic_contact_action',
                    [
                        'objectAction' => 'view',
                        'objectId'     => $contactId,
                    ],
                    UrlGeneratorInterface::ABSOLUTE_URL
                );
                $errorMessage .= ' <a href="'.$contactLink.'">'.$contactName.'</a>';
            }

            // Prevent a flood of the same messages
            $messageHash = md5($errorMessage);
            if (!array_key_exists($messageHash, $this->notifications)) {
                foreach ($this->adminUsers as $user) {
                    $this->getNotificationModel()->addNotification(
                        $errorMessage,
                        $this->getName(),
                        false,
                        $errorHeader,
                        'text-danger ri-error-warning-line',
                        null,
                        $user
                    );
                }

                $this->notifications[$messageHash] = true;
            }
        }

        $logger->error('INTEGRATION ERROR: '.$this->getName().' - '.(('dev' == MAUTIC_ENV) ? (string) $e : $e->getMessage()));
    }

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

    /**
     * @return $this
     */
    public function resetLastIntegrationError()
    {
        $this->lastIntegrationError = null;

        return $this;
    }

    /**
     * Returns notes specific to sections of the integration form (if applicable).
     *
     * @return array<mixed>
     */
    public function getFormNotes($section)
    {
        if ('leadfield_match' == $section) {
            return ['mautic.integration.form.field_match_notes', 'info'];
        } else {
            return ['', 'info'];
        }
    }

    /**
     * Allows appending extra data to the config.
     *
     * @param FormBuilder|Form $builder
     * @param array            $data
     * @param string           $formArea Section of form being built keys|features|integration
     *                                   keys can be used to store login/request related settings; keys are encrypted
     *                                   features can be used for configuring share buttons, etc
     *                                   integration is called when adding an integration to events like point triggers,
     *                                   campaigns actions, forms actions, etc
     */
    public function appendToForm(&$builder, $data, $formArea): void
    {
    }

    /**
     * @param FormBuilderInterface $builder
     * @param array<mixed>         $options
     */
    public function modifyForm($builder, $options): void
    {
        $this->dispatcher->dispatch(
            new PluginIntegrationFormBuildEvent($this, $builder, $options),
            PluginEvents::PLUGIN_ON_INTEGRATION_FORM_BUILD
        );
    }

    /**
     * Returns settings for the integration form.
     *
     * @return array<string, mixed>
     */
    public function getFormSettings(): array
    {
        $type               = $this->getAuthenticationType();
        $enableDataPriority = $this->getDataPriority();
        switch ($type) {
            case 'oauth1a':
            case 'oauth2':
                $callback              = true;
                $requiresAuthorization = true;
                break;
            default:
                $callback              = false;
                $requiresAuthorization = false;
                break;
        }

        return [
            'requires_callback'      => $callback,
            'requires_authorization' => $requiresAuthorization,
            'default_features'       => [],
            'enable_data_priority'   => $enableDataPriority,
        ];
    }

    /**
     * @return array
     */
    public function getFormDisplaySettings()
    {
        /** @var PluginIntegrationFormDisplayEvent $event */
        $event = $this->dispatcher->dispatch(
            new PluginIntegrationFormDisplayEvent($this, $this->getFormSettings()),
            PluginEvents::PLUGIN_ON_INTEGRATION_FORM_DISPLAY
        );

        return $event->getSettings();
    }

    /**
     * Get available fields for choices in the config UI.
     *
     * @param mixed[] $settings
     *
     * @return mixed[]
     */
    public function getFormLeadFields(array $settings = [])
    {
        if (isset($settings['feature_settings']['objects']['company'])) {
            unset($settings['feature_settings']['objects']['company']);
        }

        return ($this->isAuthorized()) ? $this->getAvailableLeadFields($settings) : [];
    }

    /**
     * Get available company fields for choices in the config UI.
     *
     * @param array $settings
     *
     * @return array
     */
    public function getFormCompanyFields($settings = [])
    {
        $settings['feature_settings']['objects']['company'] = 'company';

        return ($this->isAuthorized()) ? $this->getAvailableLeadFields($settings) : [];
    }

    /**
     * returns template to render on popup window after trying to run OAuth.
     *
     * @return string|null
     */
    public function getPostAuthTemplate()
    {
        return null;
    }

    /**
     * @return string
     */
    public function getContactTimelineLink($contactId)
    {
        return $this->router->generate(
            'mautic_plugin_timeline_view',
            ['integration' => $this->getName(), 'leadId' => $contactId],
            UrlGeneratorInterface::ABSOLUTE_URL
        );
    }

    /**
     * @param array $keys
     *
     * @return array
     */
    protected function dispatchIntegrationKeyEvent($eventName, $keys = [])
    {
        /** @var PluginIntegrationKeyEvent $event */
        $event = $this->dispatcher->dispatch(
            new PluginIntegrationKeyEvent($this, $keys),
            $eventName
        );

        return $event->getKeys();
    }

    /**
     * Cleans the identifier for api calls.
     *
     * @param mixed $identifier
     *
     * @return string
     */
    protected function cleanIdentifier($identifier)
    {
        if (is_array($identifier)) {
            foreach ($identifier as &$i) {
                $i = urlencode($i);
            }
        } else {
            $identifier = urlencode($identifier);
        }

        return $identifier;
    }

    /**
     * @param string $fieldType
     *
     * @return bool|float|string
     */
    public function cleanPushData($value, $fieldType = self::FIELD_TYPE_STRING)
    {
        return Cleaner::clean($value, $fieldType);
    }

    /**
     * @return \Monolog\Logger|LoggerInterface
     */
    public function getLogger()
    {
        return $this->logger;
    }

    /**
     * @param bool|\Exception $error
     *
     * @return int Number ignored due to being duplicates
     *
     * @throws ApiErrorException
     * @throws \Exception
     */
    protected function cleanupFromSync(&$leadsToSync = [], $error = false)
    {
        $duplicates = 0;
        if ($this->mauticDuplicates) {
            // Create integration entities for these to be ignored until they are updated
            foreach ($this->mauticDuplicates as $id => $dup) {
                $this->persistIntegrationEntities[] = $this->createIntegrationEntity('Lead', null, $dup, $id, [], false);
                ++$duplicates;
            }

            $this->mauticDuplicates = [];
        }

        $integrationEntityRepo = $this->getIntegrationEntityRepository();
        if (!empty($leadsToSync)) {
            // Let's only sync thos that have actual changes to prevent a loop
            $integrationEntityRepo->saveEntities($leadsToSync);
            $integrationEntityRepo->deleteEntity($leadsToSync);
            $leadsToSync = [];
        }

        // Persist updated entities if applicable
        if ($this->persistIntegrationEntities) {
            $integrationEntityRepo->saveEntities($this->persistIntegrationEntities);
            $this->persistIntegrationEntities = [];
        }

        // If there are any deleted, mark it as so to prevent them from being queried over and over or recreated
        if ($this->deleteIntegrationEntities) {
            $integrationEntityRepo->deleteEntities($this->deleteIntegrationEntities);
            $this->deleteIntegrationEntities = [];
        }
        $integrationEntityRepo->deleteEntities($this->deleteIntegrationEntities);

        if ($error) {
            if ($error instanceof \Exception) {
                throw $error;
            }

            throw new ApiErrorException($error);
        }

        return $duplicates;
    }

    /**
     * @param array $mapping array of [$mauticId => ['entity' => FormEntity, 'integration_entity_id' => $integrationId]]
     * @param array $params
     */
    protected function buildIntegrationEntities(array $mapping, $integrationEntity, $internalEntity, $params = [])
    {
        $integrationEntityRepo = $this->getIntegrationEntityRepository();
        $integrationEntities   = $integrationEntityRepo->getIntegrationEntities(
            $this->getName(),
            $integrationEntity,
            $internalEntity,
            array_keys($mapping)
        );

        // Find those that don't exist and create them
        $createThese = array_diff_key($mapping, $integrationEntities);

        foreach ($mapping as $internalEntityId => $entity) {
            if (is_array($entity)) {
                $integrationEntityId  = $entity['integration_entity_id'];
                $internalEntityObject = $entity['entity'];
            } else {
                $integrationEntityId  = $entity;
                $internalEntityObject = null;
            }

            if (isset($createThese[$internalEntityId])) {
                $entity = $this->createIntegrationEntity(
                    $integrationEntity,
                    $integrationEntityId,
                    $internalEntity,
                    $internalEntityId,
                    [],
                    false
                );
                $entity->setLastSyncDate($this->getLastSyncDate($internalEntityObject, $params, false));
                $integrationEntities[$internalEntityId] = $entity;
            } else {
                $integrationEntities[$internalEntityId]->setLastSyncDate($this->getLastSyncDate($internalEntityObject, $params, false));
            }
        }

        $integrationEntityRepo->saveEntities($integrationEntities);
        $integrationEntityRepo->detachEntities($integrationEntities);
    }

    /**
     * @param CommonEntity|null $entity
     * @param array             $params
     * @param bool              $ignoreEntityChanges
     *
     * @return bool|\DateTime|null
     */
    protected function getLastSyncDate($entity = null, $params = [], $ignoreEntityChanges = true)
    {
        $isNew = ($entity instanceof FormEntity) && $entity->isNew();
        if (!$isNew && !$ignoreEntityChanges && isset($params['start']) && $entity && method_exists($entity, 'getChanges')) {
            // Check to see if this contact was modified prior to the fetch so that the push catches it
            /** @var FormEntity $entity */
            $changes = $entity->getChanges(true);
            if (empty($changes) || isset($changes['dateModified'])) {
                $startSyncDate      = \DateTime::createFromFormat(\DateTime::ISO8601, $params['start']);
                $entityDateModified = $entity->getDateModified();

                if (isset($changes['dateModified'])) {
                    $originalDateModified = \DateTime::createFromFormat(\DateTime::ISO8601, $changes['dateModified'][0]);
                } elseif ($entityDateModified) {
                    $originalDateModified = $entityDateModified;
                } else {
                    $originalDateModified = $entity->getDateAdded();
                }

                if ($originalDateModified >= $startSyncDate) {
                    // Return null so that the push sync catches
                    return null;
                }
            }
        }

        return (defined('MAUTIC_DATE_MODIFIED_OVERRIDE')) ? \DateTime::createFromFormat('U', MAUTIC_DATE_MODIFIED_OVERRIDE)
            : new \DateTime();
    }

    /**
     * @return mixed
     */
    public function prepareFieldsForSync($fields, $keys, $object = null)
    {
        return $fields;
    }

    /**
     * Function used to format unformated fields coming from FieldsTypeTrait
     * (usually used in campaign actions).
     *
     * @return array
     */
    public function formatMatchedFields($fields)
    {
        $formattedFields = [];

        if (isset($fields['m_1'])) {
            $xfields = count($fields) / 3;
            for ($i = 1; $i < $xfields; ++$i) {
                if (isset($fields['i_'.$i]) && isset($fields['m_'.$i])) {
                    $formattedFields[$fields['i_'.$i]] = $fields['m_'.$i];
                } else {
                    continue;
                }
            }
        }

        if (!empty($formattedFields)) {
            $fields = $formattedFields;
        }

        return $fields;
    }

    /**
     * @param string $channel
     *
     * @return int
     */
    public function getLeadDoNotContact($leadId, $channel = 'email')
    {
        $isDoNotContact = 0;
        if ($lead = $this->leadModel->getEntity($leadId)) {
            $isContactableReason = $this->doNotContact->isContactable($lead, $channel);
            if (DoNotContact::IS_CONTACTABLE !== $isContactableReason) {
                $isDoNotContact = 1;
            }
        }

        return $isDoNotContact;
    }

    /**
     * Get pseudo fields from mautic, these are lead properties we want to map to integration fields.
     *
     * @return mixed
     */
    public function getCompoundMauticFields($lead)
    {
        if ($lead['internal_entity_id']) {
            $lead['mauticContactId']                   = $lead['internal_entity_id'];
            $lead['mauticContactTimelineLink']         = $this->getContactTimelineLink($lead['internal_entity_id']);
            $lead['mauticContactIsContactableByEmail'] = $this->getLeadDoNotContact($lead['internal_entity_id']);
        }

        return $lead;
    }

    /**
     * @return bool
     */
    public function isCompoundMauticField($fieldName)
    {
        $compoundFields = [
            'mauticContactTimelineLink' => 'mauticContactTimelineLink',
            'mauticContactId'           => 'mauticContactId',
        ];

        if (true === $this->updateDncByDate()) {
            $compoundFields['mauticContactIsContactableByEmail'] = 'mauticContactIsContactableByEmail';
        }

        return isset($compoundFields[$fieldName]);
    }

    /**
     * Update the record in each system taking the last modified record.
     *
     * @param string $channel
     *
     * @return int
     */
    public function getLeadDoNotContactByDate($channel, $records, $object, $lead, $integrationData, $params = [])
    {
        return $records;
    }

    /**
     * @param callable(mixed[]): Client $clientFactory
     */
    public function setClientFactory(callable $clientFactory): void
    {
        $this->clientFactory = \Closure::fromCallable($clientFactory);
    }

    /**
     * Because so many integrations extend this class and mautic.http.client is not in the
     * constructor at the time of writing, let's just create a new client here. In addition,
     * we add some custom cURL options.
     *
     * @param mixed[] $options
     */
    protected function makeHttpClient(array $options): Client
    {
        return ($this->clientFactory)($options);
    }
}
