<?php

namespace EdgeBox\SyncCore\V2;

use EdgeBox\SyncCore\Exception\BadRequestException;
use EdgeBox\SyncCore\Exception\ForbiddenException;
use EdgeBox\SyncCore\Exception\InternalContentSyncError;
use EdgeBox\SyncCore\Exception\NotFoundException;
use EdgeBox\SyncCore\Exception\SyncCoreException;
use EdgeBox\SyncCore\Exception\TimeoutException;
use EdgeBox\SyncCore\Exception\UnauthorizedException;
use EdgeBox\SyncCore\Interfaces\IApplicationInterface;
use EdgeBox\SyncCore\Interfaces\ISyncCore;
use EdgeBox\SyncCore\V2\Configuration\ConfigurationService;
use EdgeBox\SyncCore\V2\Embed\EmbedService;
use EdgeBox\SyncCore\V2\Raw\Api\DefaultApi;
use EdgeBox\SyncCore\V2\Raw\Configuration;
use EdgeBox\SyncCore\V2\Raw\Model\AuthenticationType;
use EdgeBox\SyncCore\V2\Raw\Model\CreateAuthenticationDto;
use EdgeBox\SyncCore\V2\Raw\Model\CreateFileDto;
use EdgeBox\SyncCore\V2\Raw\Model\CreateSiteDto;
use EdgeBox\SyncCore\V2\Raw\Model\EntityTypeVersionUsage;
use EdgeBox\SyncCore\V2\Raw\Model\FeatureFlagSummary;
use EdgeBox\SyncCore\V2\Raw\Model\FeatureFlagTargetType;
use EdgeBox\SyncCore\V2\Raw\Model\FileEntity;
use EdgeBox\SyncCore\V2\Raw\Model\FileStatus;
use EdgeBox\SyncCore\V2\Raw\Model\FileType;
use EdgeBox\SyncCore\V2\Raw\Model\PagedRemoteEntityUsageListResponse;
use EdgeBox\SyncCore\V2\Raw\Model\PagedRequestList;
use EdgeBox\SyncCore\V2\Raw\Model\Product;
use EdgeBox\SyncCore\V2\Raw\Model\RegisterNewSiteDto;
use EdgeBox\SyncCore\V2\Raw\Model\RegisterSiteDto;
use EdgeBox\SyncCore\V2\Raw\Model\RemoteEntityTypeEntity;
use EdgeBox\SyncCore\V2\Raw\Model\RemoteEntityUsageEntity;
use EdgeBox\SyncCore\V2\Raw\Model\RequestResponseDto;
use EdgeBox\SyncCore\V2\Raw\Model\RequestResponseDtoResponse;
use EdgeBox\SyncCore\V2\Raw\Model\SetFeatureFlagDto;
use EdgeBox\SyncCore\V2\Raw\Model\SiteApplicationType;
use EdgeBox\SyncCore\V2\Raw\Model\SiteConfigUpdateRequestDto;
use EdgeBox\SyncCore\V2\Raw\Model\SiteEntity;
use EdgeBox\SyncCore\V2\Raw\Model\SiteEnvironmentType;
use EdgeBox\SyncCore\V2\Raw\Model\SiteRestUrls;
use EdgeBox\SyncCore\V2\Raw\Model\SiteSelfDto;
use EdgeBox\SyncCore\V2\Raw\Model\SmallSiteEntityWithDetails;
use EdgeBox\SyncCore\V2\Raw\Model\SuccessResponse;
use EdgeBox\SyncCore\V2\Raw\Model\SyndicationEntityWithUsage;
use EdgeBox\SyncCore\V2\Raw\Model\SyndicationStatus;
use EdgeBox\SyncCore\V2\Raw\Model\TaskEntity;
use EdgeBox\SyncCore\V2\Raw\ObjectSerializer;
use EdgeBox\SyncCore\V2\Syndication\SyndicationService;
use Firebase\JWT\JWT;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\Psr7\MultipartStream;
use GuzzleHttp\Psr7\Request;
use GuzzleHttp\RequestOptions;

class SyncCore implements ISyncCore
{
    // Keep tokens alive for 4 hours.
    public const JWT_LIFETIME = 60 * 60 * 4;
    public const SITE_REGISTER_RETRY_COUNT = 2;
    public const SITE_UPDATE_RETRY_COUNT = 3;
    public const SITE_GET_RETRY_COUNT = 3;
    public const FEATURE_ENABLE_RETRY_COUNT = 3;
    public const FILE_UPLOAD_RETRY_COUNT = 2;
    public const FILE_DOWNLOAD_RETRY_COUNT = 3;
    public const FEATURES_GET_RETRY_COUNT = 3;
    public const CONFIG_EXPORT_RETRY_COUNT = 3;
    public const CONFIG_GET_RETRY_COUNT = 3;
    public const LOG_GET_RETRY_COUNT = 3;
    public const STATUS_GET_RETRY_COUNT = 3;
    public const ENTITY_TYPE_DIFFERENCES_RETRY_COUNT = 2;
    public const REQUEST_POLLING_RETRY_COUNT = 2;
    public const PUSH_RETRY_COUNT = 4;
    public const PULL_RETRY_COUNT = 4;
    public const UPDATES_GET_RETRY_COUNT = 3;
    protected const PLACEHOLDER_SITE_BASE_URL = '[site.baseUrl]';
    protected const PLACEHOLDER_FLOW_MACHINE_NAME = '[flow.machineName]';
    protected const PLACEHOLDER_ENTITY_SHARED_ID = '[entity.sharedId]';
    protected const RETRYABLE_STATUS_CODES = [
        // Bad Gateway
        502,
        // Service Unavailable
        503,
        // Gateway Timeout
        504,
    ];

    /**
     * @var string
     *             The base URL of the remote Sync Core. See Pool::$backend_url
     */
    protected $base_url;

    /**
     * @var DefaultApi
     */
    protected $client;

    /**
     * @var IApplicationInterface
     */
    protected $application;

    /**
     * @var string
     */
    protected $cloud_base_url;

    /**
     * @var string
     */
    protected $cloud_embed_url;

    /**
     * @var int
     */
    protected $default_timeout = 15;

    /**
     * @var int
     */
    protected $timeout_quick = 7;

    /**
     * @param string $base_url
     *                         The Sync Core base URL
     */
    public function __construct(IApplicationInterface $application, string $base_url)
    {
        if ('/sync-core' != mb_substr($base_url, -10)) {
            throw new InternalContentSyncError("Invalid base URL doesn't end with /sync-core");
        }

        if (getenv('SYNC_CORE_DEFAULT_TIMEOUT')) {
            $this->default_timeout = (int) getenv('SYNC_CORE_DEFAULT_TIMEOUT');
        }

        $this->application = $application;
        // As the /sync-core will be exported with the routes in the swagger.yaml docs
        // that is fed into the openapi generator, our library will already include
        // that prefix for all routes. So we need to cut it off or we would end up
        // with an incorrect double-prefix of /sync-core/sync-core.
        $this->base_url = mb_substr($base_url, 0, -10);
        $this->cloud_base_url = getenv('CONTENT_SYNC_CLOUD_BASE_URL')
        ? getenv('CONTENT_SYNC_CLOUD_BASE_URL')
        : 'https://app.content-sync.io';
        $this->cloud_embed_url = getenv('CONTENT_SYNC_CLOUD_EMBED_URL')
        ? getenv('CONTENT_SYNC_CLOUD_EMBED_URL')
        : 'https://embed.content-sync.io';

        $configuration = new Configuration();
        $configuration->setHost($this->base_url);

        $this->client = new DefaultApi(
            $application->getHttpClient(),
            $configuration
        );
    }

    public function getBaseUrl()
    {
        return $this->base_url.'/sync-core';
    }

    public function getClient()
    {
        return $this->client;
    }

    /**
     * @param string                $base_url
     * @param IApplicationInterface $application
     *
     * @return SyncCore
     */
    public static function get($base_url, $application)
    {
        static $instances = [];

        if (isset($instances[$base_url])) {
            return $instances[$base_url];
        }

        return $instances[$base_url] = new SyncCore($application, $base_url);
    }

    public function getPublicClient()
    {
        static $client = null;
        if (!$client) {
            $client = new Client();
        }

        return $client;
    }

    /**
     * @param string $type
     * @param string $file_name
     * @param string $content
     * @param bool   $avoid_duplicates if set, the file hash will be sentand if an identical file already exists, it will not be uploaded again
     * @param bool   $is_configuration what permissions to set
     * @param string $mimetype the mimetype. will be guessed if not given.
     *
     * @return FileEntity
     *
     * @throws BadRequestException
     * @throws ForbiddenException
     * @throws NotFoundException
     * @throws SyncCoreException
     * @throws TimeoutException
     */
    public function sendFile($type, $file_name, $content, $avoid_duplicates = true, $is_configuration = false, ?string $mimetype = null, int $retry_count = self::FILE_UPLOAD_RETRY_COUNT)
    {
        $fileDto = new CreateFileDto();

        /**
         * @var float $file_size
         */
        $file_size = strlen($content);

        /**
         * @var FileType $type
         */
        $fileDto->setType($type);
        $fileDto->setFileName($file_name);
        $fileDto->setFileSize($file_size);
        if ($mimetype) {
            $fileDto->setFixedMimeType($mimetype);
        }

        if ($avoid_duplicates) {
            $hash = hash('sha1', $content);
            $fileDto->setHash($hash);
        }

        $permissions = $is_configuration
            ? IApplicationInterface::SYNC_CORE_PERMISSIONS_CONFIGURATION
            : IApplicationInterface::SYNC_CORE_PERMISSIONS_CONTENT;

        $request = $this->getClient()->fileControllerCreateRequest(createFileDto: $fileDto);

        /**
         * @var FileEntity $file
         */
        $file = $this->sendToSyncCoreAndExpect($request, FileEntity::class, $permissions, false, $retry_count);

        $upload_url = $file->getUploadUrl();

        /**
         * @var string $status
         */
        $status = $file->getStatus();
        if (!$upload_url || FileStatus::_400_READY === $status) {
            if (!$avoid_duplicates) {
                throw new InternalContentSyncError('File has no upload URL.');
            }

            // File already exists and can be re-used immediately.
            return $file;
        }

        $max_size = $file->getMaxFileSize();
        if ($max_size && $file_size > $max_size) {
            $file_size_human_friendly = Helper::formatStorageSize($file_size, true);
            $max_size_human_friendly = Helper::formatStorageSize($max_size);
            if (FileType::ENTITY_PREVIEW == $file->getType()) {
                throw new BadRequestException("Preview of {$file_size_human_friendly} exceeds max size of {$max_size_human_friendly}.");
            }
            if (FileType::REMOTE_FLOW_CONFIG == $file->getType()) {
                throw new BadRequestException("Flow config of {$file_size_human_friendly} exceeds max size of {$max_size_human_friendly}.");
            }

            throw new BadRequestException("File {$file_name} with {$file_size_human_friendly} exceeds upload limit of {$max_size_human_friendly}.");
        }

        // Raw body upload
        if (preg_match('@https://[^/]*amazonaws.com/@', $upload_url)) {
            $response = $this->getPublicClient()->request('PUT', $upload_url, [
                RequestOptions::TIMEOUT => $this->default_timeout,
                RequestOptions::BODY => $content,
                RequestOptions::HEADERS => [
                    'Content-Type' => 'application/octet-stream',
                    'Accept' => '*/*',
                ],
            ]);
        }
        // Multipart upload
        else {
            $httpBody = new MultipartStream([
                [
                    'name' => 'file',
                    'contents' => $content,
                    // The Sync Core won't accept requests that don't contain a filename.
                    'filename' => $file_name,
                ],
            ]);
            $request = new Request(
                'PUT',
                $upload_url,
                [],
                $httpBody
            );
            $this->sendRaw($request, [], $retry_count);
        }

        $request = $this->getClient()->fileControllerFileUploadedRequest(id: $file->getId());

        return $this->sendToSyncCoreAndExpect($request, FileEntity::class, $permissions, false, $retry_count);
    }

    /**
     * Send the given request to the server. As the OpenAPI generator will add all kind
     * of unnecessary nonsense, we only want the raw request and then handle status codes
     * etc ourselves.
     *
     * @param Request $request the request to send through Guzzle
     * @param array $options Optional request options to provide to Guzzle along with the request e.g. to set a timeout.
     * @param int $retry_count how often to retry the request if it fails
     *
     * @return mixed
     *
     * @throws BadRequestException
     * @throws ForbiddenException
     * @throws NotFoundException
     * @throws SyncCoreException
     * @throws TimeoutException
     */
    public function sendRaw(Request $request, array $options, int $retry_count)
    {
        try {
            $options = [
                RequestOptions::HTTP_ERRORS => false,
            ] + $this->application->getHttpOptions() + $options;

            if (empty($options[RequestOptions::TIMEOUT])) {
                $options[RequestOptions::TIMEOUT] = $this->default_timeout;
            }

            // \Drupal::messenger()->addMessage($request->getMethod().' '.print_r($request->getBody()->__toString(),1));

            $response = $this->application->getHttpClient()->send($request, $options);
        } catch (ConnectException $e) {
            if ($retry_count > 1) {
                return $this->sendRaw($request, $options, $retry_count - 1);
            }

            throw new TimeoutException('The Sync Core did not respond in time for '.$request->getMethod().' '.Helper::obfuscateCredentials($request->getUri()).' '.$e->getMessage());
        } catch (GuzzleException $e) {
            throw new SyncCoreException($e->getMessage());
        } catch (\Exception $e) {
            throw new SyncCoreException($e->getMessage());
        }

        $status = $response->getStatusCode();

        $response_body = $response->getBody();

        if (200 !== $status && 201 !== $status) {
            if ($retry_count > 1 && in_array($status, self::RETRYABLE_STATUS_CODES)) {
                return $this->sendRaw($request, $options, $retry_count - 1);
            }

            $data = json_decode($response_body, true);
            $message = $data['message'] ?? $response_body.'';
            if (!is_string($message)) {
                $message = json_encode($message);
            }
            if (400 === $status) {
                throw new BadRequestException('The Sync Core responded with 400 Bad Request for '.$request->getMethod().' '.Helper::obfuscateCredentials($request->getUri()).' '.$message, $status, $response->getReasonPhrase(), $response_body);
            }
            if (401 === $status) {
                throw new UnauthorizedException('The Sync Core responded with 401 Unauthorized for '.$request->getMethod().' '.Helper::obfuscateCredentials($request->getUri()).' '.$message, $status, $response->getReasonPhrase(), $response_body);
            }
            if (403 === $status) {
                throw new ForbiddenException('The Sync Core responded with 403 Forbidden for '.$request->getMethod().' '.Helper::obfuscateCredentials($request->getUri()).' '.$message, $status, $response->getReasonPhrase(), $response_body);
            }
            if (404 === $status) {
                throw new NotFoundException('The Sync Core responded with 404 Not Found for '.$request->getMethod().' '.Helper::obfuscateCredentials($request->getUri()).' '.$message, $status, $response->getReasonPhrase(), $response_body);
            }

            throw new SyncCoreException('The Sync Core responded with a non-OK status code for '.$request->getMethod().' '.Helper::obfuscateCredentials($request->getUri()).' '.$message, $status, $response->getReasonPhrase(), $response_body);
        }

        return (string) $response_body;
    }

    /**
     * Send the given request to the server. As the OpenAPI generator will add all kind
     * of unnecessary nonsense, we only want the raw request and then handle status codes
     * etc ourselves.
     *
     * @param Request $request the request to send through Guzzle
     * @param string $permissions "configuration" or "content"
     * @param bool $quick Whether to prefer failing if a response can't be gotten quickly. E.g. if you do something optional or have a fallback.
     * @param int $retry_count how often to retry the request if it fails
     *
     * @return mixed
     *
     * @throws BadRequestException
     * @throws ForbiddenException
     * @throws NotFoundException
     * @throws SyncCoreException
     * @throws TimeoutException
     */
    public function sendToSyncCore(Request $request, string $permissions, bool $quick, int $retry_count)
    {
        $jwt = $this->createJwt($permissions);

        return $this->sendToSyncCoreWithJwt($request, $jwt, $quick, $retry_count);
    }

    /**
     * @param Request $request the request to send through Guzzle
     * @param string $jwt "configuration" or "content"
     * @param bool $quick Whether to prefer failing if a response can't be gotten quickly. E.g. if you do something optional or have a fallback.
     * @param int $retry_count how often to retry the request if it fails
     *
     * @return mixed
     */
    public function sendToSyncCoreWithJwt(Request $request, string $jwt, bool $quick, int $retry_count)
    {
        $options = [];
        $options[RequestOptions::HEADERS]['Authorization'] = 'Bearer '.$jwt;

        if ($quick) {
            $options[RequestOptions::TIMEOUT] = $this->timeout_quick;
        }

        return $this->sendRaw($request, $options, $quick ? 1 : $retry_count);
    }

    /**
     * @param Request $request the request to send through Guzzle
     * @param string $class The expected response when deserialized. use Class::class to get the name reliably.
     * @param string $jwt the JWT for authentication for "configuration" or "content"
     * @param int $retry_count how often to retry the request if it fails
     *
     * @return object|Raw\Model\ModelInterface
     */
    public function sendToSyncCoreWithJwtAndExpect(Request $request, string $class, string $jwt, bool $quick, int $retry_count)
    {
        $response = $this->sendToSyncCoreWithJwt($request, $jwt, $quick, $retry_count);

        return @ObjectSerializer::deserialize($response, $class, []);
    }

    /**
     * @param Request $request the request to send through Guzzle
     * @param string $class The expected response when deserialized. use Class::class to get the name reliably.
     * @param string $permissions "configuration" or "content"
     * @param bool $quick Whether to prefer failing if a response can't be gotten quickly. E.g. if you do something optional or have a fallback.
     * @param int $retry_count how often to retry the request if it fails
     *
     * @return object|Raw\Model\ModelInterface
     *
     * @throws BadRequestException
     * @throws ForbiddenException
     * @throws NotFoundException
     * @throws SyncCoreException
     * @throws TimeoutException
     */
    public function sendToSyncCoreAndExpect(Request $request, string $class, string $permissions, bool $quick, int $retry_count)
    {
        $response = $this->sendToSyncCore($request, $permissions, $quick, $retry_count);

        return @ObjectSerializer::deserialize($response, $class, []);
    }

    public function isSiteRegistered()
    {
        return $this->hasValidV2SiteId();
    }

    public function createJwt($permissions, $provider = 'jwt-header')
    {
        $uuid = $this->application->getSiteUuid();
        if (!$uuid) {
            throw new InternalContentSyncError("This site is not registered yet; can't execute a signed request.");
        }

        $secret = $this->getSiteSecret();
        $payload = [
            'type' => 'site',
            'scopes' => IApplicationInterface::SYNC_CORE_PERMISSIONS_CONFIGURATION === $permissions
                ? [
                    IApplicationInterface::SYNC_CORE_PERMISSIONS_CONFIGURATION,
                    IApplicationInterface::SYNC_CORE_PERMISSIONS_CONTENT,
                ]
                : [$permissions],
            'provider' => $provider,
            'uuid' => $uuid,
            'exp' => time() + self::JWT_LIFETIME,
        ];

        return JWT::encode($payload, $secret, 'HS256');
    }

    public function getSyncCoreDomain()
    {
        $url = parse_url($this->base_url);

        return $url['host'];
    }

    public function registerNewSiteWithToken(array $options, string $token)
    {
        $dto = new RegisterNewSiteDto($options);
        // TODO: Drupal/Interface: When the password changes, we need to make a request to the Sync Core using
        //   the old password to set the new password. If the request fails, the password
        //   can't be changed.
        $dto->setSecret($this->getSiteSecret());

        $dto->setToken($token);

        $this->addSiteDetails($dto);

        // Save the credentials to the Sync Core so it can connect to the site as well.
        $auth = $this->application->getAuthentication();

        /**
         * @var AuthenticationType $type
         */
        $type = IApplicationInterface::AUTHENTICATION_TYPE_COOKIE === $auth['type']
        ? AuthenticationType::DRUPAL8_SERVICES
            : AuthenticationType::BASIC_AUTH;

        $dto->setAuthenticationType($type);
        $dto->setAuthenticationUsername($auth['username']);

        $invalid = $dto->listInvalidProperties();

        if (count($invalid)) {
            throw new InternalContentSyncError('Invalid options: '.print_r($invalid, true));
        }

        $request = $this->client->siteControllerRegisterNewRequest(registerNewSiteDto: $dto);
        $entity = $this->sendToSyncCoreWithJwtAndExpect($request, SiteEntity::class, $token, false, self::SITE_REGISTER_RETRY_COUNT);

        $siteId = $entity->getUuid();
        $this->application->setSiteUuid($siteId);

        // Save the credentials to the Sync Core so it can connect to the site as well.
        // This is only required for older Sync Cores that don't take this data
        // from the request above at the initial site registration.
        // Will be removed in the next major release.
        $authentication = new CreateAuthenticationDto();
        $authentication->setType($type);
        $authentication->setUsername($auth['username']);
        $authentication->setPassword($auth['password']);

        $request = $this->client->authenticationControllerCreateRequest(createAuthenticationDto: $authentication);
        $this->sendToSyncCore($request, IApplicationInterface::SYNC_CORE_PERMISSIONS_CONFIGURATION, false, self::SITE_REGISTER_RETRY_COUNT);
    }

    public function registerSiteWithJwt($options)
    {
        $dto = new RegisterSiteDto($options);
        // TODO: Drupal/Interface: When the password changes, we need to make a request to the Sync Core using
        //   the old password to set the new password. If the request fails, the password
        //   can't be changed.
        $dto->setBaseUrl($this->application->getSiteBaseUrl());
        $dto->setSecret($this->getSiteSecret());

        $dto->setRestUrls($this->getRestUrls());

        $auth = $this->application->getAuthentication();

        /**
         * @var AuthenticationType $type
         */
        $type = IApplicationInterface::AUTHENTICATION_TYPE_COOKIE === $auth['type']
        ? AuthenticationType::DRUPAL8_SERVICES
        : AuthenticationType::BASIC_AUTH;

        $dto->setAuthenticationType($type);
        $dto->setAuthenticationUsername($auth['username']);

        $invalid = $dto->listInvalidProperties();

        if (count($invalid)) {
            throw new InternalContentSyncError('Invalid options: '.print_r($invalid, true));
        }

        $request = $this->client->siteControllerRegisterRequest(registerSiteDto: $dto);
        $entity = $this->sendToSyncCoreWithJwtAndExpect($request, SiteEntity::class, $options['jwt'], false, self::SITE_REGISTER_RETRY_COUNT);

        $siteId = $entity->getUuid();
        $this->application->setSiteUuid($siteId);

        // Save the credentials to the Sync Core so it can connect to the site as well.
        // This is only required for older Sync Cores that don't take this data
        // from the request above at the initial site registration.
        // Will be removed in the next major release.
        $authentication = new CreateAuthenticationDto();
        $authentication->setType($type);
        $authentication->setUsername($auth['username']);
        $authentication->setPassword($auth['password']);

        $request = $this->client->authenticationControllerCreateRequest(createAuthenticationDto: $authentication);
        $this->sendToSyncCore($request, IApplicationInterface::SYNC_CORE_PERMISSIONS_CONFIGURATION, false, self::SITE_REGISTER_RETRY_COUNT);
    }

    public function getCloudEmbedUrl()
    {
        return $this->cloud_embed_url;
    }

    public function batch()
    {
        return new Batch($this);
    }

    public function featureEnabled(string $name, bool $quick = false)
    {
        $features = $this->getFeatures($quick);

        return !empty($features[$name]) && (bool) $features[$name];
    }

    public function getFeatures(bool $quick = false)
    {
        static $features = null;
        if (null !== $features) {
            return $features;
        }

        $request = $this->client->featuresControllerSummaryRequest();

        /**
         * @var FeatureFlagSummary $response
         */
        $response = $this->sendToSyncCoreAndExpect($request, FeatureFlagSummary::class, IApplicationInterface::SYNC_CORE_PERMISSIONS_CONFIGURATION, $quick, self::FEATURES_GET_RETRY_COUNT);

        $flags = (array) $response->getFlags();

        $features = [
            ISyncCore::FEATURE_REFRESH_AUTHENTICATION => 0,
            ISyncCore::FEATURE_INDEPENDENT_FLOW_CONFIG => 1,
            ISyncCore::FEATURE_PULL_ALL_WITHOUT_POOL => 1,
            ISyncCore::FEATURE_PUSH_TO_MULTIPLE_POOLS => 1,
            ISyncCore::FEATURE_PULL_EMBED_FILES => 1,
        ] + $flags;

        return $features;
    }

    public function enableFeature(string $name, float $value = 1, $namespace = 'site')
    {
        if ('site' === $namespace) {
            $target_type = FeatureFlagTargetType::_500_SITE;
        } elseif ('project' === $namespace) {
            $target_type = FeatureFlagTargetType::_400_PROJECT;
        } else {
            throw new \Exception("Invalid namespace: {$namespace}.");
        }
        $dto = new SetFeatureFlagDto();
        $dto->setValue($value);

        $request = $this->client->featuresControllerUpdateRequest(targetType: $target_type, featureName: $name, setFeatureFlagDto: $dto);
        $this->sendToSyncCore($request, IApplicationInterface::SYNC_CORE_PERMISSIONS_CONFIGURATION, false, self::FEATURE_ENABLE_RETRY_COUNT);
    }

    public function getApplication()
    {
        return $this->application;
    }

    public function getReservedPropertyNames()
    {
        // All handled independently now, so we don't have any reserved names.
        return [];
    }

    public function getInternalSiteId($uuid)
    {
        return $this->loadSiteByIdOrUuid($uuid)->getId();
    }

    public function getExternalSiteId($id)
    {
        return $this->loadSiteByIdOrUuid($id)->getUuid();
    }

    /**
     * Get the site's priority for publishing.
     *
     * @return null|int
     */
    public function getSitePriority()
    {
        return $this->loadSiteByIdOrUuid($this->application->getSiteUuid())->getPriority();
    }

    public function getSiteName($uuid = null)
    {
        if (!$uuid) {
            if (!$this->hasValidV2SiteId()) {
                throw new InternalContentSyncError("Site is not registered yet. Can't provide a name.");
            }

            $uuid = $this->application->getSiteUuid();
        }

        return $this->loadSiteByIdOrUuid($uuid)->getName();
    }

    public function getSitesWithDifferentEntityTypeVersion(string $pool_id, string $entity_type, string $bundle, string $target_version)
    {
        $request = $this->client->remoteEntityTypeVersionControllerGetVersionUsageRequest(
            versionId: $target_version,
            machineName: $bundle,
            namespaceMachineName: $entity_type
        );

        /**
         * @var EntityTypeVersionUsage $response
         */
        $response = $this->sendToSyncCoreAndExpect(
            $request,
            EntityTypeVersionUsage::class,
            IApplicationInterface::SYNC_CORE_PERMISSIONS_CONFIGURATION,
            false,
            self::ENTITY_TYPE_DIFFERENCES_RETRY_COUNT
        );

        // FIXME: With the new Sync Core version we have a lot more helpful data available.
        //  Once SC 1 is dead, we can improve and provide that here, too.
        $result = [];

        foreach ($response->getDifferent() as $different) {
            $local_missing = [];
            foreach ($different->getAdditionalProperties() as $property) {
                $local_missing[] = $property->getMachineName();
            }
            if (count($local_missing)) {
                foreach ($different->getSites() as $site) {
                    $result[$site->getUuid()]['local_missing'] = $local_missing;
                }
            }

            $remote_missing = [];
            foreach ($different->getMissingProperties() as $property) {
                $remote_missing[] = $property->getMachineName();
            }
            if (count($remote_missing)) {
                foreach ($different->getSites() as $site) {
                    $result[$site->getUuid()]['remote_missing'] = $remote_missing;
                }
            }
        }

        return $result;
    }

    public function getConfigurationService()
    {
        static $cache = null;
        if ($cache) {
            return $cache;
        }

        return $cache = new ConfigurationService($this);
    }

    public function getReportingService()
    {
        static $cache = null;
        if ($cache) {
            return $cache;
        }

        return $cache = new ReportingService($this);
    }

    /**
     * @return SyndicationService
     */
    public function getSyndicationService()
    {
        static $cache = null;
        if ($cache) {
            return $cache;
        }

        return $cache = new SyndicationService($this);
    }

    public function getEmbedService()
    {
        static $cache = null;
        if ($cache) {
            return $cache;
        }

        return $cache = new EmbedService($this);
    }

    public function isDirectUserAccessEnabled($set = null)
    {
        // Sync Core 2.0 has it always enabled.
        return true;
    }

    public function registerSite($force = false)
    {
        // TODO: Allow if a special multi-site-register JWT is provided.
        return '';
    }

    public function setDomains(array $domains)
    {
        if (!$this->featureEnabled('domains')) {
            return;
        }

        if (!$this->isSiteRegistered()) {
            return;
        }

        $dto = $this->getSiteUpdateDto();

        if (!count($domains)) {
            $dto->setDomains(null);
        } else {
            $dto->setDomains($domains);
        }
        $request = $this->client->siteControllerUpdateRequest(createSiteDto: $dto);
        $this->sendToSyncCore($request, IApplicationInterface::SYNC_CORE_PERMISSIONS_CONFIGURATION, false, self::SITE_UPDATE_RETRY_COUNT);
    }

    public function setSiteName(string $set)
    {
        $dto = $this->getSiteUpdateDto();
        if ($dto->getName() === $set) {
            return;
        }

        $dto->setName($set);
        $request = $this->client->siteControllerUpdateRequest(createSiteDto: $dto);
        $this->sendToSyncCore($request, IApplicationInterface::SYNC_CORE_PERMISSIONS_CONFIGURATION, false, self::SITE_UPDATE_RETRY_COUNT);
    }

    public function updateSiteAtSyncCore()
    {
        if (!$this->isSiteRegistered()) {
            return;
        }

        $dto = $this->getSiteUpdateDto();

        $this->addSiteDetails($dto);

        $request = $this->client->siteControllerUpdateRequest(createSiteDto: $dto);
        $this->sendToSyncCore($request, IApplicationInterface::SYNC_CORE_PERMISSIONS_CONFIGURATION, false, self::SITE_REGISTER_RETRY_COUNT);
    }

    public function verifySiteId()
    {
        // As sites are registered globally now, we don't need additional verification.
        // Sites can't be registered multiple times with the same ID or base URL.
        return null;
    }

    public function countRequestsWaitingToBePolled()
    {
        $request = $this->client->siteControllerGetRequestsRequest(itemsPerPage: 0);

        /**
         * @var PagedRequestList $response
         */
        $response = $this->sendToSyncCoreAndExpect($request, PagedRequestList::class, IApplicationInterface::SYNC_CORE_PERMISSIONS_CONFIGURATION, false, self::REQUEST_POLLING_RETRY_COUNT);

        return $response->getTotalNumberOfItems();
    }

    public function pollRequests($limit = 1)
    {
        $request = $this->client->siteControllerGetRequestsRequest(itemsPerPage: $limit);

        /**
         * @var PagedRequestList $response
         */
        $response = $this->sendToSyncCoreAndExpect($request, PagedRequestList::class, IApplicationInterface::SYNC_CORE_PERMISSIONS_CONFIGURATION, false, self::REQUEST_POLLING_RETRY_COUNT);

        return $response->getItems();
    }

    public function respondToRequest(string $id, int $statusCode, string $statusText, array $headers, string $body)
    {
        $wrapper = new RequestResponseDto();
        $dto = new RequestResponseDtoResponse();
        $dto->setResponseStatusCode($statusCode);
        $dto->setResponseStatusText($statusText);
        $dto->setResponseHeaders($headers);
        $dto->setResponseBody($body);
        $wrapper->setResponse($dto);
        $request = $this->client->siteControllerRespondToRequestRequest(id: $id, requestResponseDto: $wrapper);

        /**
         * @var SuccessResponse $response
         */
        $response = $this->sendToSyncCoreAndExpect($request, SuccessResponse::class, IApplicationInterface::SYNC_CORE_PERMISSIONS_CONFIGURATION, false, self::REQUEST_POLLING_RETRY_COUNT);

        return $response->getSuccess();
    }

    public function updateSiteConfig(string $mode, $wait = false)
    {
        $dto = new SiteConfigUpdateRequestDto();

        /**
         * @var RemoteSiteConfigRequestMode $mode
         */
        $dto->setMode($mode);

        $request = $this->client->siteControllerUpdateConfigRequest(siteConfigUpdateRequestDto: $dto);

        /**
         * @var TaskEntity $response
         */
        $response = $this->sendToSyncCoreAndExpect($request, TaskEntity::class, IApplicationInterface::SYNC_CORE_PERMISSIONS_CONFIGURATION, false, 0);

        if ($wait) {
            $running_statuses = [SyndicationStatus::_100_INITIALIZING, SyndicationStatus::_200_RUNNING, SyndicationStatus::_300_RETRYING];
            do {
                sleep(3);
                $request = $this->client->taskControllerItemRequest($response->getId());
                $response = $this->sendToSyncCoreAndExpect($request, SyndicationEntityWithUsage::class, IApplicationInterface::SYNC_CORE_PERMISSIONS_CONFIGURATION, false, 3);
            } while (in_array($response->getStatus(), $running_statuses));
            if (SyndicationStatus::_400_FINISHED !== $response->getStatus()) {
                throw new SyncCoreException("Failed to update config: update status is {$response->getStatus()}.");
            }
        }

        return $response->getId();
    }

    public function isStagingSite($quick = true)
    {
        $site = $this->getThisSite($quick);

        return $site ? SiteEnvironmentType::STAGING === $site->getEnvironmentType() : null;
    }

    public function isProductionSite($quick = true)
    {
        $site = $this->getThisSite($quick);

        return $site ? SiteEnvironmentType::PRODUCTION === $site->getEnvironmentType() : null;
    }

    public function isLocalSite($quick = true)
    {
        $site = $this->getThisSite($quick);

        return $site ? SiteEnvironmentType::LOCAL === $site->getEnvironmentType() : null;
    }

    public function isTestingSite($quick = true)
    {
        $site = $this->getThisSite($quick);

        return $site ? SiteEnvironmentType::TESTING === $site->getEnvironmentType() : null;
    }

    public function isStagingContract($quick = true)
    {
        $current = $this->getCurrentContractRevision($quick);

        return $current ? Product::STAGING === $current->getProduct() : null;
    }

    public function isSyndicationContract($quick = true)
    {
        $current = $this->getCurrentContractRevision($quick);

        return $current ? Product::SYNDICATION === $current->getProduct() : null;
    }

    public function getUsedLanguages(string $namespace_machine_name, string $machine_name, string $shared_id, $quick = true)
    {
        static $cache = [];
        if (isset($cache[$namespace_machine_name][$machine_name][$shared_id])) {
            return $cache[$namespace_machine_name][$machine_name][$shared_id];
        }

        try {
            $request = $this->client->remoteEntityTypeControllerByMachineNameRequest(machineName: $machine_name, namespaceMachineName: $namespace_machine_name);
            $type = $this->sendToSyncCoreAndExpect($request, RemoteEntityTypeEntity::class, IApplicationInterface::SYNC_CORE_PERMISSIONS_CONFIGURATION, $quick, $quick ? 0 : 3);

            $self = $this->getSiteSelf($quick);
            if (!$self) {
                return null;
            }

            if (self::isUuid($shared_id)) {
                $request = $this->client->remoteEntityUsageControllerListRequest(itemsPerPage: 1, entityTypeId: $type->getId(), remoteUuid: $shared_id, siteId: $self->getSite()->getId());
            } else {
                $request = $this->client->remoteEntityUsageControllerListRequest(itemsPerPage: 1, entityTypeId: $type->getId(), remoteUniqueId: $shared_id, siteId: $self->getSite()->getId());
            }
            $usage_page = $this->sendToSyncCoreAndExpect($request, PagedRemoteEntityUsageListResponse::class, IApplicationInterface::SYNC_CORE_PERMISSIONS_CONFIGURATION, $quick, $quick ? 0 : 3);

            if (!$usage_page->getTotalNumberOfItems()) {
                return $cache[$namespace_machine_name][$machine_name][$shared_id] = [];
            }

            /**
             * @var RemoteEntityUsageEntity $usage
             */
            $usage = $usage_page->getItems()[0];

            $languages = [];
            foreach ($usage->getTranslations() as $translation) {
                $languages[] = $translation->getLanguage();
            }

            return $cache[$namespace_machine_name][$machine_name][$shared_id] = $languages;
        } catch (\Exception $e) {
        }

        return null;
    }

    protected function getCurrentContractRevision($quick = true)
    {
        $self = $this->getSiteSelf($quick);
        if (!$self) {
            return null;
        }

        return $self->getCurrentContractRevision();
    }

    protected function getThisSite($quick = true)
    {
        $self = $this->getSiteSelf($quick);
        if (!$self) {
            return null;
        }

        return $self->getSite();
    }

    /**
     * Get information about the site, customer, project, contract and latest
     * contract revisions.
     *
     * @param bool $quick pass TRUE if this is used in the UI that editors use where we have a simple fallback
     *
     * @return SiteSelfDto
     */
    protected function getSiteSelf($quick = true)
    {
        static $self = null;
        if ($self) {
            return $self;
        }

        $request = $this->client->siteControllerSelfRequest();

        try {
            $self = $this->sendToSyncCoreAndExpect($request, SiteSelfDto::class, IApplicationInterface::SYNC_CORE_PERMISSIONS_CONFIGURATION, $quick, $quick ? 0 : 3);

            return $self;
        } catch (\Exception $e) {
        }

        return null;
    }

    protected function getRestUrls()
    {
        $urls = new SiteRestUrls();

        $urls->setCreateEntity(self::PLACEHOLDER_SITE_BASE_URL.$this->getRelativeReference(IApplicationInterface::REST_ACTION_CREATE_ENTITY));
        $urls->setDeleteEntity(self::PLACEHOLDER_SITE_BASE_URL.$this->getRelativeReference(IApplicationInterface::REST_ACTION_DELETE_ENTITY));
        $urls->setRetrieveEntity(self::PLACEHOLDER_SITE_BASE_URL.$this->getRelativeReference(IApplicationInterface::REST_ACTION_RETRIEVE_ENTITY));
        $urls->setListEntities(self::PLACEHOLDER_SITE_BASE_URL.$this->getRelativeReference(IApplicationInterface::REST_ACTION_LIST_ENTITIES));
        $render_entity_route = $this->getRelativeReference(IApplicationInterface::REST_ACTION_RENDER_ENTITY);
        if ($render_entity_route) {
            $urls->setRenderEntity(self::PLACEHOLDER_SITE_BASE_URL.$render_entity_route);
        }
        $urls->setSiteStatus(self::PLACEHOLDER_SITE_BASE_URL.$this->getRelativeReference(IApplicationInterface::REST_ACTION_SITE_STATUS));
        $site_config_route = $this->getRelativeReference(IApplicationInterface::REST_ACTION_SITE_CONFIG);
        if ($site_config_route) {
            $urls->setSiteConfig(self::PLACEHOLDER_SITE_BASE_URL.$site_config_route);
        }

        return $urls;
    }

    protected static function isUuid(string $uuid)
    {
        return 1 === preg_match('/^[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i', $uuid);
    }

    /**
     * Load a site by either it's external or internal ID.
     *
     * @return SmallSiteEntityWithDetails
     */
    protected function loadSiteByIdOrUuid(string $uuid)
    {
        // Site IDs from Sync Core V1 are not a UUID, so we check whether the given site ID
        // is a UUID and if it's not, the site must be re-registered first.
        if (self::isUuid($uuid)) {
            $request = $this->client->siteControllerItemByUuidRequest(uuid: $uuid);
        } else {
            $request = $this->client->siteControllerItemRequest(id: $uuid);
        }

        return $this->sendToSyncCoreAndExpect($request, SmallSiteEntityWithDetails::class, IApplicationInterface::SYNC_CORE_PERMISSIONS_CONTENT, false, self::SITE_GET_RETRY_COUNT);
    }

    /**
     * @param CreateSiteDto|RegisterNewSiteDto $dto
     */
    protected function addSiteDetails($dto)
    {
        $dto->setName($this->application->getSiteName());
        $dto->setBaseUrl($this->application->getSiteBaseUrl());

        /**
         * @var SiteApplicationType $app_type
         */
        $app_type = $this->application->getApplicationId();
        $dto->setAppType($app_type);
        $dto->setAppVersion($this->application->getApplicationVersion());
        $dto->setAppModuleVersion($this->application->getApplicationModuleVersion());

        $dto->setRestUrls($this->getRestUrls());
    }

    protected function getSiteUpdateDto()
    {
        if (!$this->hasValidV2SiteId()) {
            throw new InternalContentSyncError("Site is not registered yet. Can't change site name.");
        }

        $id = $this->application->getSiteUuid();
        $request = $this->client->siteControllerItemByUuidRequest(uuid: $id);
        $current = $this->sendToSyncCoreAndExpect($request, SiteEntity::class, IApplicationInterface::SYNC_CORE_PERMISSIONS_CONFIGURATION, false, self::SITE_GET_RETRY_COUNT);

        $serialized = json_decode(json_encode($current->jsonSerialize()), true);

        return new CreateSiteDto($serialized);
    }

    protected function hasValidV2SiteId()
    {
        $site_id = $this->application->getSiteUuid();
        if (!$site_id) {
            return false;
        }

        // Site IDs from Sync Core V1 are not a UUID, so we check whether the given site ID
        // is a UUID and if it's not, the site must be re-registered first.
        return 1 === preg_match('/^[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i', $site_id);
    }

    protected function getSiteSecret()
    {
        return $this->application->getAuthentication()['password'];
    }

    protected function getRelativeReference(string $action)
    {
        if (IApplicationInterface::REST_ACTION_SITE_STATUS === $action || IApplicationInterface::REST_ACTION_SITE_CONFIG === $action) {
            $relative = $this->application->getRelativeReferenceForSiteRestCall($action);
        } else {
            $relative = $this->application->getRelativeReferenceForRestCall(
                self::PLACEHOLDER_FLOW_MACHINE_NAME,
                $action
            );
        }

        if ($relative && '/' !== $relative[0]) {
            throw new InternalContentSyncError('Relative reference must start with a slash /.');
        }

        return $relative;
    }
}
