<?php

declare(strict_types=1);

/*
 * This file is part of the box project.
 *
 * (c) Kevin Herrera <kevin@herrera.io>
 *     Théo Fidry <theo.fidry@gmail.com>
 *
 * This source file is subject to the MIT license that is bundled
 * with this source code in the file LICENSE.
 */

namespace KevinGH\Box\Configuration;

use Closure;
use DateTimeImmutable;
use DateTimeZone;
use Fidry\FileSystem\FS;
use Humbug\PhpScoper\Configuration\Configuration as PhpScoperConfiguration;
use InvalidArgumentException;
use JsonException;
use KevinGH\Box\Compactor\Compactor;
use KevinGH\Box\Compactor\Compactors;
use KevinGH\Box\Compactor\Php as PhpCompactor;
use KevinGH\Box\Compactor\PhpScoper as PhpScoperCompactor;
use KevinGH\Box\Composer\Artifact\ComposerArtifact;
use KevinGH\Box\Composer\Artifact\ComposerArtifacts;
use KevinGH\Box\Composer\Artifact\ComposerJson;
use KevinGH\Box\Composer\Artifact\ComposerLock;
use KevinGH\Box\Composer\ComposerConfiguration;
use KevinGH\Box\MapFile;
use KevinGH\Box\Phar\CompressionAlgorithm;
use KevinGH\Box\Phar\SigningAlgorithm;
use KevinGH\Box\PhpScoper\ConfigurationFactory as PhpScoperConfigurationFactory;
use KevinGH\Box\PhpScoper\SerializableScoper;
use RuntimeException;
use SplFileInfo;
use stdClass;
use Symfony\Component\Filesystem\Path;
use Symfony\Component\Finder\Finder;
use Symfony\Component\Process\Exception\ProcessFailedException;
use Symfony\Component\Process\Process;
use Webmozart\Assert\Assert;
use function array_diff;
use function array_filter;
use function array_flip;
use function array_key_exists;
use function array_keys;
use function array_map;
use function array_merge;
use function array_unique;
use function array_values;
use function array_walk;
use function dirname;
use function explode;
use function file_exists;
use function getcwd;
use function implode;
use function in_array;
use function intval;
use function is_array;
use function is_bool;
use function is_file;
use function is_link;
use function is_object;
use function is_readable;
use function is_string;
use function iter\map;
use function iter\toArray;
use function iter\values;
use function KevinGH\Box\get_box_version;
use function KevinGH\Box\unique_id;
use function krsort;
use function preg_match;
use function preg_replace;
use function property_exists;
use function realpath;
use function Safe\json_decode;
use function sprintf;
use function str_starts_with;
use function trigger_error;
use function trim;
use const E_USER_DEPRECATED;

/**
 * @private
 */
final class Configuration
{
    private const DEFAULT_OUTPUT_FALLBACK = 'test.phar';
    private const DEFAULT_MAIN_SCRIPT = 'index.php';
    private const DEFAULT_DATETIME_FORMAT = 'Y-m-d H:i:s T';
    private const DEFAULT_REPLACEMENT_SIGIL = '@';
    private const DEFAULT_SHEBANG = '#!/usr/bin/env php';
    private const DEFAULT_BANNER = <<<'BANNER'
        Generated by Humbug Box %s.

        @link https://github.com/humbug/box
        BANNER;
    private const FILES_SETTINGS = [
        'directories',
        'finder',
    ];
    private const PHP_SCOPER_CONFIG = 'scoper.inc.php';
    private const DEFAULT_SIGNING_ALGORITHM = SigningAlgorithm::SHA512;
    private const DEFAULT_ALIAS_PREFIX = 'box-auto-generated-alias-';

    private const DEFAULT_IGNORED_ANNOTATIONS = [
        'abstract',
        'access',
        'annotation',
        'api',
        'attribute',
        'attributes',
        'author',
        'category',
        'code',
        'codecoverageignore',
        'codecoverageignoreend',
        'codecoverageignorestart',
        'copyright',
        'deprec',
        'deprecated',
        'endcode',
        'example',
        'exception',
        'filesource',
        'final',
        'fixme',
        'global',
        'ignore',
        'ingroup',
        'inheritdoc',
        'internal',
        'license',
        'link',
        'magic',
        'method',
        'name',
        'override',
        'package',
        'package_version',
        'param',
        'private',
        'property',
        'required',
        'return',
        'see',
        'since',
        'static',
        'staticvar',
        'subpackage',
        'suppresswarnings',
        'target',
        'throw',
        'throws',
        'todo',
        'tutorial',
        'usedby',
        'uses',
        'var',
        'version',
    ];

    private const ALGORITHM_KEY = 'algorithm';
    private const ALIAS_KEY = 'alias';
    private const ANNOTATIONS_KEY = 'annotations';
    private const IGNORED_ANNOTATIONS_KEY = 'ignore';
    private const AUTO_DISCOVERY_KEY = 'force-autodiscovery';
    private const BANNER_KEY = 'banner';
    private const BANNER_FILE_KEY = 'banner-file';
    private const BASE_PATH_KEY = 'base-path';
    private const BLACKLIST_KEY = 'blacklist';
    private const CHECK_REQUIREMENTS_KEY = 'check-requirements';
    private const CHMOD_KEY = 'chmod';
    private const COMPACTORS_KEY = 'compactors';
    private const COMPRESSION_KEY = 'compression';
    private const DATETIME_KEY = 'datetime';
    private const DATETIME_FORMAT_KEY = 'datetime-format';
    private const DATETIME_FORMAT_DEPRECATED_KEY = 'datetime_format';
    private const DIRECTORIES_KEY = 'directories';
    private const DIRECTORIES_BIN_KEY = 'directories-bin';
    private const DUMP_AUTOLOAD_KEY = 'dump-autoload';
    private const EXCLUDE_COMPOSER_FILES_KEY = 'exclude-composer-files';
    private const EXCLUDE_DEV_FILES_KEY = 'exclude-dev-files';
    private const FILES_KEY = 'files';
    private const FILES_BIN_KEY = 'files-bin';
    private const FINDER_KEY = 'finder';
    private const FINDER_BIN_KEY = 'finder-bin';
    private const GIT_KEY = 'git';
    private const GIT_COMMIT_KEY = 'git-commit';
    private const GIT_COMMIT_SHORT_KEY = 'git-commit-short';
    private const GIT_TAG_KEY = 'git-tag';
    private const GIT_VERSION_KEY = 'git-version';
    private const INTERCEPT_KEY = 'intercept';
    private const KEY_KEY = 'key';
    private const KEY_PASS_KEY = 'key-pass';
    private const MAIN_KEY = 'main';
    private const MAP_KEY = 'map';
    private const METADATA_KEY = 'metadata';
    private const OUTPUT_KEY = 'output';
    private const PHP_SCOPER_KEY = 'php-scoper';
    private const REPLACEMENT_SIGIL_KEY = 'replacement-sigil';
    private const REPLACEMENTS_KEY = 'replacements';
    private const SHEBANG_KEY = 'shebang';
    private const STUB_KEY = 'stub';
    private const TIMESTAMP = 'timestamp';

    private readonly ?string $mainScriptPath;
    private readonly ?string $mainScriptContents;
    private ?string $composerBin = null;

    public static function create(?string $file, stdClass $raw): self
    {
        $logger = new ConfigurationLogger();

        $basePath = self::retrieveBasePath($file, $raw, $logger);

        $composerArtifacts = self::retrieveComposerArtifacts($basePath);

        $dumpAutoload = self::retrieveDumpAutoload($raw, $composerArtifacts, $logger);

        $excludeComposerArtifacts = self::retrieveExcludeComposerArtifacts($raw, $logger);

        $mainScriptPath = self::retrieveMainScriptPath($raw, $basePath, $composerArtifacts->composerJson, $logger);
        $mainScriptContents = self::retrieveMainScriptContents($mainScriptPath);

        [$tmpOutputPath, $outputPath] = self::retrieveOutputPath($raw, $basePath, $mainScriptPath, $logger);

        $stubPath = self::retrieveStubPath($raw, $basePath, $logger);
        $isStubGenerated = self::retrieveIsStubGenerated($raw, $stubPath, $logger);

        $alias = self::retrieveAlias($raw, null !== $stubPath, $logger);

        $shebang = self::retrieveShebang($raw, $isStubGenerated, $logger);

        $stubBannerContents = self::retrieveStubBannerContents($raw, $isStubGenerated, $logger);
        $stubBannerPath = self::retrieveStubBannerPath($raw, $basePath, $isStubGenerated, $logger);

        if (null !== $stubBannerPath) {
            $stubBannerContents = FS::getFileContents($stubBannerPath);
        }

        $stubBannerContents = self::normalizeStubBannerContents($stubBannerContents);

        if (null !== $stubBannerPath && self::getDefaultBanner() === $stubBannerContents) {
            self::addRecommendationForDefaultValue($logger, self::BANNER_FILE_KEY);
        }

        $isInterceptsFileFunctions = self::retrieveInterceptsFileFunctions($raw, $isStubGenerated, $logger);

        $checkRequirements = self::retrieveCheckRequirements(
            $raw,
            null !== $composerArtifacts->composerJson?->path,
            null !== $composerArtifacts->composerLock?->path,
            false === $isStubGenerated && null === $stubPath,
            $logger,
        );

        $excludeDevPackages = self::retrieveExcludeDevFiles($raw, $dumpAutoload, $logger);

        $devPackages = ComposerConfiguration::retrieveDevPackages(
            $basePath,
            $composerArtifacts->composerJson,
            $composerArtifacts->composerLock,
            $excludeDevPackages,
        );

        /**
         * @var string[] $excludedPaths
         * @var Closure  $blacklistFilter
         */
        [$excludedPaths, $blacklistFilter] = self::retrieveBlacklistFilter(
            $raw,
            $basePath,
            $logger,
            $tmpOutputPath,
            $outputPath,
            $mainScriptPath,
        );
        // Excluded paths above is a bit misleading since including a file directly has precedence over the blacklist.
        // If you consider the following:
        //
        // {
        //   "files": ["file1"],
        //   "blacklist": ["file1"],
        // }
        //
        // In the end the file "file1" _will_ be included: blacklist are here to help out to exclude files for finders
        // and directories but the user should always have the possibility to force his way to include a file.
        //
        // The exception however, is for the following which is essential for the good functioning of Box
        $alwaysExcludedPaths = array_map(
            static fn (string $excludedPath): string => self::normalizePath($excludedPath, $basePath),
            array_filter([$tmpOutputPath, $outputPath, $mainScriptPath]),
        );

        $autodiscoverFiles = self::autodiscoverFiles($file, $raw);
        $forceFilesAutodiscovery = self::retrieveForceFilesAutodiscovery($raw, $logger);

        $filesAggregate = self::collectFiles(
            $raw,
            $basePath,
            $mainScriptPath,
            $blacklistFilter,
            $excludedPaths,
            $alwaysExcludedPaths,
            $devPackages,
            $composerArtifacts,
            $autodiscoverFiles,
            $forceFilesAutodiscovery,
            $logger,
        );
        $binaryFilesAggregate = self::collectBinaryFiles(
            $raw,
            $basePath,
            $blacklistFilter,
            $excludedPaths,
            $alwaysExcludedPaths,
            $devPackages,
            $logger,
        );

        $compactors = self::retrieveCompactors($raw, $basePath, $logger);
        $compressionAlgorithm = self::retrieveCompressionAlgorithm($raw, $logger);

        $fileMode = self::retrieveFileMode($raw, $logger);

        $map = self::retrieveMap($raw, $logger);
        $fileMapper = new MapFile($basePath, $map);

        $metadata = self::retrieveMetadata($raw, $logger);

        $signingAlgorithm = self::retrieveSigningAlgorithm($raw, $logger);
        $promptForPrivateKey = self::retrievePromptForPrivateKey($raw, $signingAlgorithm, $logger);
        $privateKeyPath = self::retrievePrivateKeyPath($raw, $basePath, $signingAlgorithm, $logger);
        $privateKeyPassphrase = self::retrievePrivateKeyPassphrase($raw, $signingAlgorithm, $logger);

        $replacements = self::retrieveReplacements($raw, $file, $basePath, $logger);

        $timestamp = self::retrieveTimestamp($raw, $signingAlgorithm, $logger);

        return new self(
            $file,
            $alias,
            $basePath,
            $composerArtifacts->composerJson,
            $composerArtifacts->composerLock,
            $filesAggregate,
            $binaryFilesAggregate,
            $autodiscoverFiles || $forceFilesAutodiscovery,
            $dumpAutoload,
            $excludeComposerArtifacts,
            $excludeDevPackages,
            $compactors,
            $compressionAlgorithm,
            $fileMode,
            $mainScriptPath,
            $mainScriptContents,
            $fileMapper,
            $metadata,
            $tmpOutputPath,
            $outputPath,
            $privateKeyPassphrase,
            $privateKeyPath,
            $promptForPrivateKey,
            $replacements,
            $shebang,
            $signingAlgorithm,
            $stubBannerContents,
            $stubBannerPath,
            $stubPath,
            $isInterceptsFileFunctions,
            $isStubGenerated,
            $timestamp,
            $checkRequirements,
            $logger->getWarnings(),
            $logger->getRecommendations(),
        );
    }

    /**
     * @param string                 $basePath                 Utility to private the base path used and be able to retrieve a
     *                                                         path relative to it (the base path)
     * @param array                  $composerJson             The first element is the path to the `composer.json` file as a
     *                                                         string and the second element its decoded contents as an
     *                                                         associative array.
     * @param array                  $composerLock             The first element is the path to the `composer.lock` file as a
     *                                                         string and the second element its decoded contents as an
     *                                                         associative array.
     * @param SplFileInfo[]          $files                    List of files
     * @param SplFileInfo[]          $binaryFiles              List of binary files
     * @param bool                   $dumpAutoload             Whether the Composer autoloader should be dumped
     * @param bool                   $excludeComposerArtifacts Whether the Composer files composer.json, composer.lock and
     *                                                         installed.json should be removed from the PHAR
     * @param CompressionAlgorithm   $compressionAlgorithm     Compression algorithm constant value. See the \Phar class constants
     * @param null|int               $fileMode                 File mode in octal form
     * @param string                 $mainScriptPath           The main script file path
     * @param string                 $mainScriptContents       The processed content of the main script file
     * @param MapFile                $fileMapper               Utility to map the files from outside and inside the PHAR
     * @param mixed                  $metadata                 The PHAR Metadata
     * @param bool                   $promptForPrivateKey      If the user should be prompted for the private key passphrase
     * @param array                  $processedReplacements    The processed list of replacement placeholders and their values
     * @param null|non-empty-string  $shebang                  The shebang line
     * @param SigningAlgorithm       $signingAlgorithm         The PHAR siging algorithm. See \Phar constants
     * @param null|string            $stubBannerContents       The stub banner comment
     * @param null|string            $stubBannerPath           The path to the stub banner comment file
     * @param null|string            $stubPath                 The PHAR stub file path
     * @param bool                   $isInterceptFileFuncs     Whether Phar::interceptFileFuncs() should be used
     * @param bool                   $isStubGenerated          Whether if the PHAR stub should be generated
     * @param null|DateTimeImmutable $timestamp                Timestamp at which the PHAR will be set to.
     * @param bool                   $checkRequirements        Whether the PHAR will check the application requirements before
     *                                                         running
     * @param string[]               $warnings
     * @param string[]               $recommendations
     */
    private function __construct(
        private readonly ?string $file,
        private readonly string $alias,
        private readonly string $basePath,
        private readonly ?ComposerJson $composerJson,
        private readonly ?ComposerLock $composerLock,
        private readonly array $files,
        private readonly array $binaryFiles,
        private readonly bool $autodiscoveredFiles,
        private readonly bool $dumpAutoload,
        private readonly bool $excludeComposerArtifacts,
        private readonly bool $excludeDevFiles,
        private readonly array|Compactors $compactors,
        private readonly CompressionAlgorithm $compressionAlgorithm,
        private readonly null|int|string $fileMode,
        ?string $mainScriptPath,
        ?string $mainScriptContents,
        private readonly MapFile $fileMapper,
        private readonly mixed $metadata,
        private readonly string $tmpOutputPath,
        private readonly string $outputPath,
        private readonly ?string $privateKeyPassphrase,
        private readonly ?string $privateKeyPath,
        private readonly bool $promptForPrivateKey,
        private readonly array $processedReplacements,
        private readonly ?string $shebang,
        private readonly SigningAlgorithm $signingAlgorithm,
        private readonly ?string $stubBannerContents,
        private readonly ?string $stubBannerPath,
        private readonly ?string $stubPath,
        private readonly bool $isInterceptFileFuncs,
        private readonly bool $isStubGenerated,
        private readonly ?DateTimeImmutable $timestamp,
        private readonly bool $checkRequirements,
        private readonly array $warnings,
        private readonly array $recommendations,
    ) {
        if (null === $mainScriptPath) {
            Assert::null($mainScriptContents);
        } else {
            Assert::notNull($mainScriptContents);
        }

        $this->mainScriptPath = $mainScriptPath;
        $this->mainScriptContents = $mainScriptContents;
    }

    public function setComposerBin(?string $composerBin): void
    {
        $this->composerBin = $composerBin;
    }

    public function getComposerBin(): ?string
    {
        return $this->composerBin;
    }

    public function export(): string
    {
        return ExportableConfiguration::create($this)->export();
    }

    public function getConfigurationFile(): ?string
    {
        return $this->file;
    }

    public function getAlias(): string
    {
        return $this->alias;
    }

    public function getBasePath(): string
    {
        return $this->basePath;
    }

    public function getComposerJson(): ?ComposerJson
    {
        return $this->composerJson;
    }

    public function getComposerLock(): ?ComposerLock
    {
        return $this->composerLock;
    }

    /**
     * @return SplFileInfo[]
     */
    public function getFiles(): array
    {
        return $this->files;
    }

    /**
     * @return SplFileInfo[]
     */
    public function getBinaryFiles(): array
    {
        return $this->binaryFiles;
    }

    public function hasAutodiscoveredFiles(): bool
    {
        return $this->autodiscoveredFiles;
    }

    public function dumpAutoload(): bool
    {
        return $this->dumpAutoload;
    }

    public function excludeComposerArtifacts(): bool
    {
        return $this->excludeComposerArtifacts;
    }

    public function excludeDevFiles(): bool
    {
        return $this->excludeDevFiles;
    }

    public function getCompactors(): Compactors
    {
        return $this->compactors;
    }

    public function getCompressionAlgorithm(): CompressionAlgorithm
    {
        return $this->compressionAlgorithm;
    }

    public function getFileMode(): ?int
    {
        return $this->fileMode;
    }

    public function hasMainScript(): bool
    {
        return null !== $this->mainScriptPath;
    }

    public function getMainScriptPath(): string
    {
        Assert::notNull(
            $this->mainScriptPath,
            'Cannot retrieve the main script path: no main script configured.',
        );

        return $this->mainScriptPath;
    }

    public function getMainScriptContents(): string
    {
        Assert::notNull(
            $this->mainScriptPath,
            'Cannot retrieve the main script contents: no main script configured.',
        );

        return $this->mainScriptContents;
    }

    public function checkRequirements(): bool
    {
        return $this->checkRequirements;
    }

    public function getTmpOutputPath(): string
    {
        return $this->tmpOutputPath;
    }

    public function getOutputPath(): string
    {
        return $this->outputPath;
    }

    public function getFileMapper(): MapFile
    {
        return $this->fileMapper;
    }

    public function getMetadata(): mixed
    {
        return $this->metadata;
    }

    public function getPrivateKeyPassphrase(): ?string
    {
        return $this->privateKeyPassphrase;
    }

    public function getPrivateKeyPath(): ?string
    {
        return $this->privateKeyPath;
    }

    /**
     * @deprecated Use promptForPrivateKey() instead
     */
    public function isPrivateKeyPrompt(): bool
    {
        return $this->promptForPrivateKey;
    }

    public function promptForPrivateKey(): bool
    {
        return $this->promptForPrivateKey;
    }

    /**
     * @return scalar[]
     */
    public function getReplacements(): array
    {
        return $this->processedReplacements;
    }

    public function getShebang(): ?string
    {
        return $this->shebang;
    }

    public function getSigningAlgorithm(): SigningAlgorithm
    {
        return $this->signingAlgorithm;
    }

    public function getStubBannerContents(): ?string
    {
        return $this->stubBannerContents;
    }

    public function getStubBannerPath(): ?string
    {
        return $this->stubBannerPath;
    }

    public function getStubPath(): ?string
    {
        return $this->stubPath;
    }

    public function isInterceptFileFuncs(): bool
    {
        return $this->isInterceptFileFuncs;
    }

    public function isStubGenerated(): bool
    {
        return $this->isStubGenerated;
    }

    public function getTimestamp(): ?DateTimeImmutable
    {
        return $this->timestamp;
    }

    /**
     * @return string[]
     */
    public function getWarnings(): array
    {
        return $this->warnings;
    }

    /**
     * @return string[]
     */
    public function getRecommendations(): array
    {
        return $this->recommendations;
    }

    private static function retrieveAlias(stdClass $raw, bool $userStubUsed, ConfigurationLogger $logger): string
    {
        self::checkIfDefaultValue($logger, $raw, self::ALIAS_KEY);

        if (false === isset($raw->{self::ALIAS_KEY})) {
            return unique_id(self::DEFAULT_ALIAS_PREFIX).'.phar';
        }

        $alias = trim($raw->{self::ALIAS_KEY});

        Assert::notEmpty($alias, 'A PHAR alias cannot be empty when provided.');

        if ($userStubUsed) {
            $logger->addWarning(
                sprintf(
                    'The "%s" setting has been set but is ignored since a custom stub path is used',
                    self::ALIAS_KEY,
                ),
            );
        }

        return $alias;
    }

    private static function retrieveBasePath(?string $file, stdClass $raw, ConfigurationLogger $logger): string
    {
        if (null === $file) {
            return getcwd();
        }

        if (false === isset($raw->{self::BASE_PATH_KEY})) {
            return realpath(dirname($file));
        }

        $basePath = trim($raw->{self::BASE_PATH_KEY});

        Assert::directory(
            $basePath,
            'The base path %s is not a directory or does not exist.',
        );

        $basePath = realpath($basePath);
        $defaultPath = realpath(dirname($file));

        if ($basePath === $defaultPath) {
            self::addRecommendationForDefaultValue($logger, self::BASE_PATH_KEY);
        }

        return $basePath;
    }

    /**
     * Checks if files should be auto-discovered. It does NOT account for the force-autodiscovery setting.
     */
    private static function autodiscoverFiles(?string $file, stdClass $raw): bool
    {
        if (null === $file) {
            return true;
        }

        $associativeRaw = (array) $raw;

        return self::FILES_SETTINGS === array_diff(self::FILES_SETTINGS, array_keys($associativeRaw));
    }

    private static function retrieveForceFilesAutodiscovery(stdClass $raw, ConfigurationLogger $logger): bool
    {
        self::checkIfDefaultValue($logger, $raw, self::AUTO_DISCOVERY_KEY, false);

        return $raw->{self::AUTO_DISCOVERY_KEY} ?? false;
    }

    private static function retrieveBlacklistFilter(
        stdClass $raw,
        string $basePath,
        ConfigurationLogger $logger,
        ?string ...$excludedPaths,
    ): array {
        $blacklist = array_flip(
            self::retrieveBlacklist($raw, $basePath, $logger, ...$excludedPaths),
        );

        $blacklistFilter = static function (SplFileInfo $file) use ($blacklist): ?bool {
            if ($file->isLink()) {
                return false;
            }

            if (false === $file->getRealPath()) {
                return false;
            }

            if (array_key_exists($file->getRealPath(), $blacklist)) {
                return false;
            }

            return null;
        };

        return [array_keys($blacklist), $blacklistFilter];
    }

    /**
     * @param null[]|string[] $excludedPaths
     *
     * @return string[]
     */
    private static function retrieveBlacklist(
        stdClass $raw,
        string $basePath,
        ConfigurationLogger $logger,
        ?string ...$excludedPaths,
    ): array {
        self::checkIfDefaultValue($logger, $raw, self::BLACKLIST_KEY, []);

        $normalizedBlacklist = array_map(
            static fn (string $excludedPath): string => self::normalizePath($excludedPath, $basePath),
            array_filter($excludedPaths),
        );

        /** @var string[] $blacklist */
        $blacklist = $raw->{self::BLACKLIST_KEY} ?? [];

        foreach ($blacklist as $file) {
            $normalizedBlacklist[] = self::normalizePath($file, $basePath);
            $normalizedBlacklist[] = Path::canonicalize(Path::makeRelative(trim($file), $basePath));
        }

        return array_unique($normalizedBlacklist);
    }

    /**
     * @param string[] $excludedPaths
     * @param string[] $alwaysExcludedPaths
     * @param string[] $devPackages
     *
     * @return SplFileInfo[]
     */
    private static function collectFiles(
        stdClass $raw,
        string $basePath,
        ?string $mainScriptPath,
        Closure $blacklistFilter,
        array $excludedPaths,
        array $alwaysExcludedPaths,
        array $devPackages,
        ComposerArtifacts $composerArtifacts,
        bool $autodiscoverFiles,
        bool $forceFilesAutodiscovery,
        ConfigurationLogger $logger,
    ): array {
        $files = [self::retrieveFiles($raw, self::FILES_KEY, $basePath, $composerArtifacts, $alwaysExcludedPaths, $logger)];

        if ($autodiscoverFiles || $forceFilesAutodiscovery) {
            [$filesToAppend, $directories] = self::retrieveAllDirectoriesToInclude(
                $basePath,
                $composerArtifacts->composerJson,
                $devPackages,
                $composerArtifacts->getPaths(),
                $excludedPaths,
            );

            $files[] = self::wrapInSplFileInfo($filesToAppend);

            $files[] = self::retrieveAllFiles(
                $basePath,
                $directories,
                $mainScriptPath,
                $blacklistFilter,
                $excludedPaths,
                $devPackages,
            );
        }

        if (false === $autodiscoverFiles) {
            $files[] = self::retrieveDirectories(
                $raw,
                self::DIRECTORIES_KEY,
                $basePath,
                $blacklistFilter,
                $excludedPaths,
                $logger,
            );

            $filesFromFinders = self::retrieveFilesFromFinders(
                $raw,
                self::FINDER_KEY,
                $basePath,
                $blacklistFilter,
                $devPackages,
                $logger,
            );

            foreach ($filesFromFinders as $filesFromFinder) {
                // Avoid an array_merge here as it can be quite expansive at this stage depending of the number of files
                $files[] = $filesFromFinder;
            }

            $files[] = self::wrapInSplFileInfo($composerArtifacts->getPaths());
        }

        return self::retrieveFilesAggregate(...$files);
    }

    /**
     * @param string[] $excludedPaths
     * @param string[] $alwaysExcludedPaths
     * @param string[] $devPackages
     *
     * @return SplFileInfo[]
     */
    private static function collectBinaryFiles(
        stdClass $raw,
        string $basePath,
        Closure $blacklistFilter,
        array $excludedPaths,
        array $alwaysExcludedPaths,
        array $devPackages,
        ConfigurationLogger $logger,
    ): array {
        $binaryFiles = self::retrieveFiles($raw, self::FILES_BIN_KEY, $basePath, new ComposerArtifacts(), $alwaysExcludedPaths, $logger);

        $binaryDirectories = self::retrieveDirectories(
            $raw,
            self::DIRECTORIES_BIN_KEY,
            $basePath,
            $blacklistFilter,
            $excludedPaths,
            $logger,
        );

        $binaryFilesFromFinders = self::retrieveFilesFromFinders(
            $raw,
            self::FINDER_BIN_KEY,
            $basePath,
            $blacklistFilter,
            $devPackages,
            $logger,
        );

        return self::retrieveFilesAggregate($binaryFiles, $binaryDirectories, ...$binaryFilesFromFinders);
    }

    /**
     * @param string[] $excludedFiles
     *
     * @return SplFileInfo[]
     */
    private static function retrieveFiles(
        stdClass $raw,
        string $key,
        string $basePath,
        ComposerArtifacts $composerArtifacts,
        array $excludedFiles,
        ConfigurationLogger $logger,
    ): array {
        self::checkIfDefaultValue($logger, $raw, $key, []);

        $excludedFiles = array_flip($excludedFiles);
        $files = array_filter([
            $composerArtifacts->composerJson?->path,
            $composerArtifacts->composerLock?->path,
        ]);

        if (false === isset($raw->{$key})) {
            return self::wrapInSplFileInfo($files);
        }

        if ([] === (array) $raw->{$key}) {
            return self::wrapInSplFileInfo($files);
        }

        $files = array_merge((array) $raw->{$key}, $files);

        Assert::allString($files);

        $normalizePath = static function (string $file) use ($basePath, $key, $excludedFiles): ?SplFileInfo {
            $file = self::normalizePath($file, $basePath);

            Assert::false(
                is_link($file),
                sprintf(
                    'Cannot add the link "%s": links are not supported.',
                    $file,
                ),
            );

            Assert::file(
                $file,
                sprintf(
                    '"%s" must contain a list of existing files. Could not find %%s.',
                    $key,
                ),
            );

            return array_key_exists($file, $excludedFiles) ? null : new SplFileInfo($file);
        };

        return array_filter(array_map($normalizePath, $files));
    }

    /**
     * @param string   $key           Config property name
     * @param string[] $excludedPaths
     *
     * @return iterable&(SplFileInfo[]&Finder)
     */
    private static function retrieveDirectories(
        stdClass $raw,
        string $key,
        string $basePath,
        Closure $blacklistFilter,
        array $excludedPaths,
        ConfigurationLogger $logger,
    ): iterable {
        $directories = self::retrieveDirectoryPaths($raw, $key, $basePath, $logger);

        if ([] !== $directories) {
            $finder = Finder::create()
                ->files()
                ->filter($blacklistFilter)
                ->ignoreVCS(true)
                ->in($directories);

            foreach ($excludedPaths as $excludedPath) {
                $finder->notPath($excludedPath);
            }

            return $finder;
        }

        return [];
    }

    /**
     * @param string[] $devPackages
     *
     * @return iterable[]|SplFileInfo[][]
     */
    private static function retrieveFilesFromFinders(
        stdClass $raw,
        string $key,
        string $basePath,
        Closure $blacklistFilter,
        array $devPackages,
        ConfigurationLogger $logger,
    ): array {
        self::checkIfDefaultValue($logger, $raw, $key, []);

        if (false === isset($raw->{$key})) {
            return [];
        }

        $finder = $raw->{$key};

        return self::processFinders($finder, $basePath, $blacklistFilter, $devPackages);
    }

    /**
     * @param iterable[]|SplFileInfo[][] $fileIterators
     *
     * @return SplFileInfo[]
     */
    private static function retrieveFilesAggregate(iterable ...$fileIterators): array
    {
        $files = [];

        foreach ($fileIterators as $fileIterator) {
            foreach ($fileIterator as $file) {
                $files[(string) $file] = $file;
            }
        }

        return array_values($files);
    }

    /**
     * @param string[] $devPackages
     *
     * @return Finder[]|SplFileInfo[][]
     */
    private static function processFinders(
        array $findersConfig,
        string $basePath,
        Closure $blacklistFilter,
        array $devPackages,
    ): array {
        $processFinderConfig = static fn (stdClass $config) => self::processFinder($config, $basePath, $blacklistFilter, $devPackages);

        return array_map($processFinderConfig, $findersConfig);
    }

    /**
     * @param string[] $devPackages
     *
     * @return Finder|SplFileInfo[]
     */
    private static function processFinder(
        stdClass $config,
        string $basePath,
        Closure $blacklistFilter,
        array $devPackages,
    ): Finder {
        $finder = Finder::create()
            ->files()
            ->filter($blacklistFilter)
            ->filter(
                static function (SplFileInfo $fileInfo) use ($devPackages): bool {
                    foreach ($devPackages as $devPackage) {
                        if ($devPackage === Path::getLongestCommonBasePath($devPackage, $fileInfo->getRealPath())) {
                            // File belongs to the dev package
                            return false;
                        }
                    }

                    return true;
                },
            )
            ->ignoreVCS(true);

        $normalizedConfig = (static function (array $config, Finder $finder): array {
            $normalizedConfig = [];

            foreach ($config as $method => $arguments) {
                $method = trim($method);
                $arguments = (array) $arguments;

                Assert::methodExists($finder, $method);

                $normalizedConfig[$method] = $arguments;
            }

            krsort($normalizedConfig);

            return $normalizedConfig;
        })((array) $config, $finder);

        $createNormalizedDirectories = static function (string $directory) use ($basePath): ?string {
            $directory = self::normalizePath($directory, $basePath);

            Assert::false(
                is_link($directory),
                sprintf(
                    'Cannot append the link "%s" to the Finder: links are not supported.',
                    $directory,
                ),
            );

            Assert::directory($directory);

            return $directory;
        };

        $normalizeFileOrDirectory = static function (?string &$fileOrDirectory) use ($basePath, $blacklistFilter): void {
            if (null === $fileOrDirectory) {
                return;
            }

            $fileOrDirectory = self::normalizePath($fileOrDirectory, $basePath);

            Assert::false(
                is_link($fileOrDirectory),
                sprintf(
                    'Cannot append the link "%s" to the Finder: links are not supported.',
                    $fileOrDirectory,
                ),
            );

            Assert::true(
                file_exists($fileOrDirectory),
                sprintf(
                    'Path "%s" was expected to be a file or directory. It may be a symlink (which are unsupported).',
                    $fileOrDirectory,
                ),
            );

            if (false === is_file($fileOrDirectory)) {
                Assert::directory($fileOrDirectory);
            } else {
                Assert::file($fileOrDirectory);
            }

            if (false === $blacklistFilter(new SplFileInfo($fileOrDirectory))) {
                $fileOrDirectory = null;
            }
        };

        foreach ($normalizedConfig as $method => $arguments) {
            if ('in' === $method) {
                $normalizedConfig[$method] = $arguments = array_map($createNormalizedDirectories, $arguments);
            }

            if ('exclude' === $method) {
                $arguments = array_unique(array_map('trim', $arguments));
            }

            if ('append' === $method) {
                array_walk($arguments, $normalizeFileOrDirectory);

                $arguments = [array_filter($arguments)];
            }

            foreach ($arguments as $argument) {
                $finder->{$method}($argument);
            }
        }

        return $finder;
    }

    /**
     * @param string[] $devPackages
     * @param string[] $filesToAppend
     *
     * @return string[][]
     */
    private static function retrieveAllDirectoriesToInclude(
        string $basePath,
        ?ComposerJson $composerJson,
        array $devPackages,
        array $filesToAppend,
        array $excludedPaths,
    ): array {
        $toString = static fn (SplFileInfo|string $file): string => (string) $file;

        $vendorDir = self::normalizePath(
            ComposerConfiguration::retrieveVendorDir($composerJson),
            $basePath,
        );

        if (file_exists($vendorDir)) {
            // Note that some files may not exist. For example installed.json does not exist at all if no dependencies
            // are included in composer.json.
            $requiredComposerArtifacts = [
                'installed.json',
                'installed.php',
                'InstalledVersions.php',
            ];

            foreach ($requiredComposerArtifacts as $requiredComposerArtifact) {
                $normalizePath = self::normalizePath($vendorDir.'/composer/'.$requiredComposerArtifact, $basePath);

                if (file_exists($normalizePath)) {
                    $filesToAppend[] = $normalizePath;
                }
            }

            $vendorPackages = toArray(values(map(
                $toString,
                Finder::create()
                    ->in($vendorDir)
                    ->directories()
                    ->depth(1)
                    ->ignoreUnreadableDirs()
                    ->filter(
                        static function (SplFileInfo $fileInfo): ?bool {
                            if ($fileInfo->isLink()) {
                                return false;
                            }

                            return null;
                        },
                    ),
            )));

            $vendorPackages = array_diff($vendorPackages, $devPackages);

            if (!($composerJson?->hasAutoload() ?? false)) {
                $files = toArray(values(map(
                    $toString,
                    Finder::create()
                        ->in($basePath)
                        ->files()
                        ->depth(0),
                )));

                $directories = toArray(values(map(
                    $toString,
                    Finder::create()
                        ->in($basePath)
                        ->notPath('vendor')
                        ->directories()
                        ->depth(0),
                )));

                return [
                    array_merge(
                        array_diff($files, $excludedPaths),
                        $filesToAppend,
                    ),
                    array_merge(
                        array_diff($directories, $excludedPaths),
                        $vendorPackages,
                    ),
                ];
            }

            $paths = $vendorPackages;
        } else {
            $paths = [];
        }

        $paths = array_merge(
            $paths,
            $composerJson?->getAutoloadPaths() ?? [],
        );

        $normalizePath = static fn (string $path): string => Path::isAbsolute($path)
            ? Path::canonicalize($path)
            : self::normalizePath(trim($path, '/ '), $basePath);

        $composerFiles = $composerJson?->getAutoloadFiles() ?? [];
        foreach ($composerFiles as $path) {
            /** @var string $path */
            $path = $normalizePath($path);

            Assert::file($path);
            Assert::false(is_link($path), 'Cannot add the link "'.$path.'": links are not supported.');

            $filesToAppend[] = $path;
        }

        $files = $filesToAppend;
        $directories = [];

        foreach ($paths as $path) {
            $path = $normalizePath($path);

            Assert::true(file_exists($path), 'File or directory "'.$path.'" was expected to exist.');
            Assert::false(is_link($path), 'Cannot add the link "'.$path.'": links are not supported.');

            if (is_file($path)) {
                $files[] = $path;
            } else {
                $directories[] = $path;
            }
        }

        [$files, $directories] = [
            array_unique($files),
            array_unique($directories),
        ];

        return [
            array_diff($files, $excludedPaths),
            array_diff($directories, $excludedPaths),
        ];
    }

    /**
     * @param string[] $directories
     * @param string[] $excludedPaths
     * @param string[] $devPackages
     *
     * @return Finder|SplFileInfo[]
     */
    private static function retrieveAllFiles(
        string $basePath,
        array $directories,
        ?string $mainScriptPath,
        Closure $blacklistFilter,
        array $excludedPaths,
        array $devPackages,
    ): iterable {
        if ([] === $directories) {
            return [];
        }

        $relativeDevPackages = array_map(
            static fn (string $packagePath): string => Path::makeRelative($packagePath, $basePath),
            $devPackages,
        );

        $finder = Finder::create()
            ->files()
            ->filter($blacklistFilter)
            ->exclude($relativeDevPackages)
            ->ignoreVCS(true)
            ->ignoreDotFiles(true)
            // Remove build files
            ->notName('composer.json')
            ->notName('composer.lock')
            ->notName('Makefile')
            ->notName('Vagrantfile')
            ->notName('phpstan*.neon*')
            ->notName('infection*.json*')
            ->notName('humbug*.json*')
            ->notName('easy-coding-standard.neon*')
            ->notName('phpbench.json*')
            ->notName('phpcs.xml*')
            ->notName('psalm.xml*')
            ->notName('scoper.inc*')
            ->notName('box*.json*')
            ->notName('phpdoc*.xml*')
            ->notName('codecov.yml*')
            ->notName('Dockerfile')
            ->exclude('build')
            ->exclude('dist')
            ->exclude('example')
            ->exclude('examples')
            // Remove documentation
            ->notName('*.md')
            ->notName('*.rst')
            ->notName('/^readme((?!\.php)(\..*+))?$/i')
            ->notName('/^upgrade((?!\.php)(\..*+))?$/i')
            ->notName('/^contributing((?!\.php)(\..*+))?$/i')
            ->notName('/^changelog((?!\.php)(\..*+))?$/i')
            ->notName('/^authors?((?!\.php)(\..*+))?$/i')
            ->notName('/^conduct((?!\.php)(\..*+))?$/i')
            ->notName('/^todo((?!\.php)(\..*+))?$/i')
            ->exclude('doc')
            ->exclude('docs')
            ->exclude('documentation')
            // Remove backup files
            ->notName('*~')
            ->notName('*.back')
            ->notName('*.swp')
            // Remove tests
            ->exclude('tests')
            ->exclude('Tests')
            ->notName('/phpunit.*\.xml(.dist)?/')
            ->notName('/behat.*\.yml(.dist)?/')
            ->exclude('spec')
            ->exclude('specs')
            ->exclude('features')
            // Remove CI config
            ->exclude('travis')
            ->notName('travis.yml')
            ->notName('appveyor.yml')
            ->notName('build.xml*');

        if (null !== $mainScriptPath) {
            $finder->notPath(Path::makeRelative($mainScriptPath, $basePath));
        }

        $finder->in($directories);

        $excludedPaths = array_unique(
            array_filter(
                array_map(
                    static fn (string $path): string => Path::makeRelative($path, $basePath),
                    $excludedPaths,
                ),
                static fn (string $path): bool => !str_starts_with($path, '..'),
            ),
        );

        foreach ($excludedPaths as $excludedPath) {
            $finder->notPath($excludedPath);
        }

        return $finder;
    }

    /**
     * @param string $key Config property name
     *
     * @return string[]
     */
    private static function retrieveDirectoryPaths(
        stdClass $raw,
        string $key,
        string $basePath,
        ConfigurationLogger $logger,
    ): array {
        self::checkIfDefaultValue($logger, $raw, $key, []);

        if (false === isset($raw->{$key})) {
            return [];
        }

        $directories = $raw->{$key};

        $normalizeDirectory = static function (string $directory) use ($basePath, $key): string {
            $directory = self::normalizePath($directory, $basePath);

            Assert::false(
                is_link($directory),
                sprintf(
                    'Cannot add the link "%s": links are not supported.',
                    $directory,
                ),
            );

            Assert::directory(
                $directory,
                sprintf(
                    '"%s" must contain a list of existing directories. Could not find %%s.',
                    $key,
                ),
            );

            return $directory;
        };

        return array_map($normalizeDirectory, $directories);
    }

    private static function normalizePath(string $file, string $basePath): string
    {
        return Path::makeAbsolute(trim($file), $basePath);
    }

    /**
     * @param string[] $files
     *
     * @return SplFileInfo[]
     */
    private static function wrapInSplFileInfo(array $files): array
    {
        return array_map(
            static fn (string $file): SplFileInfo => new SplFileInfo($file),
            $files,
        );
    }

    private static function retrieveDumpAutoload(stdClass $raw, ComposerArtifacts $composerArtifacts, ConfigurationLogger $logger): bool
    {
        self::checkIfDefaultValue($logger, $raw, self::DUMP_AUTOLOAD_KEY, null);

        $canDumpAutoload = (
            null !== $composerArtifacts->composerJson?->path
            && (
                // The composer.lock and installed.json are optional (e.g. if there is no dependencies installed)
                // but when one is present, the other must be as well otherwise the dumped autoloader will be broken
                (
                    null === $composerArtifacts->composerLock?->path
                    && null === $composerArtifacts->installedJson?->path
                )
                || (
                    null !== $composerArtifacts->composerLock?->path
                    && null !== $composerArtifacts->installedJson?->path
                )
                || (
                    null === $composerArtifacts->composerLock?->path
                    && null !== $composerArtifacts->installedJson?->path
                    && [] === $composerArtifacts->installedJson?->decodedContents
                )
            )
        );

        if ($canDumpAutoload) {
            self::checkIfDefaultValue($logger, $raw, self::DUMP_AUTOLOAD_KEY, true);
        }

        if (false === property_exists($raw, self::DUMP_AUTOLOAD_KEY)) {
            return $canDumpAutoload;
        }

        $dumpAutoload = $raw->{self::DUMP_AUTOLOAD_KEY} ?? true;

        if (false === $canDumpAutoload && $dumpAutoload) {
            $logger->addWarning(
                sprintf(
                    'The "%s" setting has been set but has been ignored because the composer.json, composer.lock'
                    .' and vendor/composer/installed.json files are necessary but could not be found.',
                    self::DUMP_AUTOLOAD_KEY,
                ),
            );

            return false;
        }

        return $canDumpAutoload && false !== $dumpAutoload;
    }

    private static function retrieveExcludeDevFiles(stdClass $raw, bool $dumpAutoload, ConfigurationLogger $logger): bool
    {
        self::checkIfDefaultValue($logger, $raw, self::EXCLUDE_DEV_FILES_KEY, $dumpAutoload);

        if (false === property_exists($raw, self::EXCLUDE_DEV_FILES_KEY)) {
            return $dumpAutoload;
        }

        $excludeDevFiles = $raw->{self::EXCLUDE_DEV_FILES_KEY} ?? $dumpAutoload;

        if (true === $excludeDevFiles && false === $dumpAutoload) {
            $logger->addWarning(sprintf(
                'The "%s" setting has been set but has been ignored because the Composer autoloader is not dumped',
                self::EXCLUDE_DEV_FILES_KEY,
            ));

            return false;
        }

        return $excludeDevFiles;
    }

    private static function retrieveExcludeComposerArtifacts(stdClass $raw, ConfigurationLogger $logger): bool
    {
        self::checkIfDefaultValue($logger, $raw, self::EXCLUDE_COMPOSER_FILES_KEY, true);

        return $raw->{self::EXCLUDE_COMPOSER_FILES_KEY} ?? true;
    }

    private static function retrieveCompactors(stdClass $raw, string $basePath, ConfigurationLogger $logger): Compactors
    {
        self::checkIfDefaultValue($logger, $raw, self::COMPACTORS_KEY, []);

        $compactorClasses = array_unique((array) ($raw->{self::COMPACTORS_KEY} ?? []));

        // Needs to do this check before returning the compactors in order to properly inform the users about
        // possible misconfiguration
        $ignoredAnnotations = self::retrievePhpCompactorIgnoredAnnotations($raw, $compactorClasses, $logger);

        if (false === isset($raw->{self::COMPACTORS_KEY})) {
            return new Compactors();
        }

        $compactors = new Compactors(
            ...self::createCompactors(
                $raw,
                $basePath,
                $compactorClasses,
                $ignoredAnnotations,
                $logger,
            ),
        );

        self::checkCompactorsOrder($logger, $compactors);

        return $compactors;
    }

    /**
     * @param string[]      $compactorClasses
     * @param string[]|null $ignoredAnnotations
     *
     * @return Compactor[]
     */
    private static function createCompactors(
        stdClass $raw,
        string $basePath,
        array $compactorClasses,
        ?array $ignoredAnnotations,
        ConfigurationLogger $logger,
    ): array {
        return array_map(
            static function (string $class) use ($raw, $basePath, $logger, $ignoredAnnotations): Compactor {
                Assert::classExists($class, 'The compactor class %s does not exist.');
                Assert::isAOf($class, Compactor::class, sprintf('The class "%s" is not a compactor class.', $class));

                if (in_array($class, [PhpCompactor::class, 'KevinGH\Box\Compactor\Php'], true)) {
                    return self::createPhpCompactor($ignoredAnnotations);
                }

                if (in_array($class, [PhpScoperCompactor::class, 'KevinGH\Box\Compactor\PhpScoper'], true)) {
                    return self::createPhpScoperCompactor($raw, $basePath, $logger);
                }

                return new $class();
            },
            $compactorClasses,
        );
    }

    private static function checkCompactorsOrder(ConfigurationLogger $logger, Compactors $compactors): void
    {
        $scoperCompactor = false;

        foreach ($compactors->toArray() as $compactor) {
            if ($compactor instanceof PhpScoperCompactor) {
                $scoperCompactor = true;
            }

            if ($compactor instanceof PhpCompactor) {
                if (true === $scoperCompactor) {
                    $logger->addRecommendation(
                        'The PHP compactor has been registered after the PhpScoper compactor. It is '
                        .'recommended to register the PHP compactor before for a clearer code and faster processing.',
                    );
                }

                break;
            }
        }
    }

    private static function retrieveCompressionAlgorithm(stdClass $raw, ConfigurationLogger $logger): CompressionAlgorithm
    {
        self::checkIfDefaultValue($logger, $raw, self::COMPRESSION_KEY, 'NONE');

        if (false === isset($raw->{self::COMPRESSION_KEY})) {
            return CompressionAlgorithm::NONE;
        }

        $knownAlgorithms = CompressionAlgorithm::getLabels();

        Assert::nullOrInArray(
            $raw->{self::COMPRESSION_KEY},
            $knownAlgorithms,
            sprintf(
                'Unknown compression algorithm %%s. Expected one of "%s".',
                implode('", "', $knownAlgorithms),
            ),
        );

        return CompressionAlgorithm::fromLabel($raw->{self::COMPRESSION_KEY});
    }

    private static function retrieveFileMode(stdClass $raw, ConfigurationLogger $logger): ?int
    {
        if (property_exists($raw, self::CHMOD_KEY) && null === $raw->{self::CHMOD_KEY}) {
            self::addRecommendationForDefaultValue($logger, self::CHMOD_KEY);
        }

        $defaultChmod = intval(0o755, 8);

        if (isset($raw->{self::CHMOD_KEY})) {
            $chmod = intval($raw->{self::CHMOD_KEY}, 8);

            if ($defaultChmod === $chmod) {
                self::addRecommendationForDefaultValue($logger, self::CHMOD_KEY);
            }

            return $chmod;
        }

        return $defaultChmod;
    }

    private static function retrieveMainScriptPath(
        stdClass $raw,
        string $basePath,
        ?ComposerJson $composerJson,
        ConfigurationLogger $logger,
    ): ?string {
        $firstBin = $composerJson?->getFirstBin();

        if (null !== $firstBin) {
            $firstBin = self::normalizePath($firstBin, $basePath);
        }

        if (isset($raw->{self::MAIN_KEY})) {
            $main = $raw->{self::MAIN_KEY};

            if (is_string($main)) {
                $main = self::normalizePath($main, $basePath);

                if ($main === $firstBin) {
                    $logger->addRecommendation(
                        sprintf(
                            'The "%s" setting can be omitted since is set to its default value',
                            self::MAIN_KEY,
                        ),
                    );
                }
            }
        } else {
            $main = $firstBin ?? self::normalizePath(self::DEFAULT_MAIN_SCRIPT, $basePath);
        }

        if (is_bool($main)) {
            Assert::false(
                $main,
                'Cannot "enable" a main script: either disable it with `false` or give the main script file path.',
            );

            return null;
        }

        Assert::file($main);

        return $main;
    }

    private static function retrieveMainScriptContents(?string $mainScriptPath): ?string
    {
        if (null === $mainScriptPath) {
            return null;
        }

        $contents = FS::getFileContents($mainScriptPath);

        // Remove the shebang line: the shebang line in a PHAR should be located in the stub file which is the real
        // PHAR entry point file.
        // If one needs the shebang, then the main file should act as the stub and be registered as such and in which
        // case the main script can be ignored or disabled.
        return preg_replace('/^#!.*\s*/', '', $contents);
    }

    private static function retrieveComposerArtifacts(string $basePath): ComposerArtifacts
    {
        $composerJson = self::retrieveComposerArtifact(Path::canonicalize($basePath.'/composer.json'));
        $composerLock = self::retrieveComposerArtifact(Path::canonicalize($basePath.'/composer.lock'));

        return new ComposerArtifacts(
            $composerJson?->toComposerJson(),
            $composerLock?->toComposerLock(),
            self::retrieveComposerArtifact(Path::canonicalize($basePath.'/vendor/composer/installed.json')),
        );
    }

    private static function retrieveComposerArtifact(string $path): ?ComposerArtifact
    {
        if (false === file_exists($path) || false === is_file($path) || false === is_readable($path)) {
            return null;
        }

        try {
            $contents = json_decode(
                FS::getFileContents($path),
                true,
            );
        } catch (JsonException $exception) {
            throw new InvalidArgumentException(
                sprintf(
                    'Expected the file "%s" to be a valid JSON file but an error has been found: %s',
                    $path,
                    $exception->getMessage(),
                ),
                0,
                $exception,
            );
        }

        return new ComposerArtifact($path, $contents);
    }

    /**
     * @return string[][]
     */
    private static function retrieveMap(stdClass $raw, ConfigurationLogger $logger): array
    {
        self::checkIfDefaultValue($logger, $raw, self::MAP_KEY, []);

        if (false === isset($raw->{self::MAP_KEY})) {
            return [];
        }

        $map = [];

        foreach ((array) $raw->{self::MAP_KEY} as $item) {
            $processed = [];

            foreach ($item as $match => $replace) {
                $processed[Path::canonicalize(trim($match))] = Path::canonicalize(trim($replace));
            }

            if (isset($processed['_empty_'])) {
                $processed[''] = $processed['_empty_'];

                unset($processed['_empty_']);
            }

            $map[] = $processed;
        }

        return $map;
    }

    private static function retrieveMetadata(stdClass $raw, ConfigurationLogger $logger)
    {
        self::checkIfDefaultValue($logger, $raw, self::METADATA_KEY);

        if (false === isset($raw->{self::METADATA_KEY})) {
            return null;
        }

        $logger->addWarning('Using the "metadata" setting is deprecated and will be removed in 5.0.0.');

        $metadata = $raw->{self::METADATA_KEY};

        return is_object($metadata) ? (array) $metadata : $metadata;
    }

    /**
     * @return string[] The first element is the temporary output path and the second the final one
     */
    private static function retrieveOutputPath(
        stdClass $raw,
        string $basePath,
        ?string $mainScriptPath,
        ConfigurationLogger $logger,
    ): array {
        $defaultPath = null;

        if (null !== $mainScriptPath
            && 1 === preg_match('/^(?<main>.*?)(?:\.[\p{L}\d]+)?$/u', $mainScriptPath, $matches)
        ) {
            $defaultPath = $matches['main'].'.phar';
        }

        if (isset($raw->{self::OUTPUT_KEY})) {
            $path = self::normalizePath($raw->{self::OUTPUT_KEY}, $basePath);

            if ($path === $defaultPath) {
                self::addRecommendationForDefaultValue($logger, self::OUTPUT_KEY);
            }
        } elseif (null !== $defaultPath) {
            $path = $defaultPath;
        } else {
            // Last resort, should not happen
            $path = self::normalizePath(self::DEFAULT_OUTPUT_FALLBACK, $basePath);
        }

        $tmp = $real = $path;

        if (!str_ends_with($real, '.phar')) {
            $tmp .= '.phar';
        }

        return [$tmp, $real];
    }

    private static function retrievePrivateKeyPath(
        stdClass $raw,
        string $basePath,
        SigningAlgorithm $signingAlgorithm,
        ConfigurationLogger $logger,
    ): ?string {
        if (property_exists($raw, self::KEY_KEY) && SigningAlgorithm::OPENSSL !== $signingAlgorithm) {
            if (null === $raw->{self::KEY_KEY}) {
                $logger->addRecommendation(
                    'The setting "key" has been set but is unnecessary since the signing algorithm is not "OPENSSL".',
                );
            } else {
                $logger->addWarning(
                    'The setting "key" has been set but is ignored since the signing algorithm is not "OPENSSL".',
                );
            }

            return null;
        }

        if (!isset($raw->{self::KEY_KEY})) {
            Assert::true(
                SigningAlgorithm::OPENSSL !== $signingAlgorithm,
                'Expected to have a private key for OpenSSL signing but none have been provided.',
            );

            return null;
        }

        $path = self::normalizePath($raw->{self::KEY_KEY}, $basePath);

        Assert::file($path);

        return $path;
    }

    private static function retrievePrivateKeyPassphrase(
        stdClass $raw,
        SigningAlgorithm $algorithm,
        ConfigurationLogger $logger,
    ): ?string {
        self::checkIfDefaultValue($logger, $raw, self::KEY_PASS_KEY);

        if (false === property_exists($raw, self::KEY_PASS_KEY)) {
            return null;
        }

        /** @var null|false|string $keyPass */
        $keyPass = $raw->{self::KEY_PASS_KEY};

        if (SigningAlgorithm::OPENSSL !== $algorithm) {
            if (false === $keyPass || null === $keyPass) {
                $logger->addRecommendation(
                    sprintf(
                        'The setting "%s" has been set but is unnecessary since the signing algorithm is '
                        .'not "OPENSSL".',
                        self::KEY_PASS_KEY,
                    ),
                );
            } else {
                $logger->addWarning(
                    sprintf(
                        'The setting "%s" has been set but ignored the signing algorithm is not "OPENSSL".',
                        self::KEY_PASS_KEY,
                    ),
                );
            }

            return null;
        }

        return is_string($keyPass) ? $keyPass : null;
    }

    /**
     * @return scalar[]
     */
    private static function retrieveReplacements(
        stdClass $raw,
        ?string $file,
        string $path,
        ConfigurationLogger $logger,
    ): array {
        self::checkIfDefaultValue($logger, $raw, self::REPLACEMENTS_KEY, new stdClass());

        if (null === $file) {
            return [];
        }

        $replacements = isset($raw->{self::REPLACEMENTS_KEY}) ? (array) $raw->{self::REPLACEMENTS_KEY} : [];

        if (null !== ($git = self::retrievePrettyGitPlaceholder($raw, $logger))) {
            $replacements[$git] = self::retrievePrettyGitTag($path);
        }

        if (null !== ($git = self::retrieveGitHashPlaceholder($raw, $logger))) {
            $replacements[$git] = self::retrieveGitHash($path);
        }

        if (null !== ($git = self::retrieveGitShortHashPlaceholder($raw, $logger))) {
            $replacements[$git] = self::retrieveGitHash($path, true);
        }

        if (null !== ($git = self::retrieveGitTagPlaceholder($raw, $logger))) {
            $replacements[$git] = self::retrieveGitTag($path);
        }

        if (null !== ($git = self::retrieveGitVersionPlaceholder($raw, $logger))) {
            $replacements[$git] = self::retrieveGitVersion($path);
        }

        /**
         * @var string $datetimeFormat
         * @var bool   $valueSetByUser
         */
        [$datetimeFormat, $valueSetByUser] = self::retrieveDatetimeFormat($raw, $logger);

        if (null !== ($date = self::retrieveDatetimeNowPlaceHolder($raw, $logger))) {
            $replacements[$date] = self::retrieveDatetimeNow($datetimeFormat);
        } elseif ($valueSetByUser) {
            $logger->addRecommendation(
                sprintf(
                    'The setting "%s" has been set but is unnecessary because the setting "%s" is not set.',
                    self::DATETIME_FORMAT_KEY,
                    self::DATETIME_KEY,
                ),
            );
        }

        $sigil = self::retrieveReplacementSigil($raw, $logger);

        foreach ($replacements as $key => $value) {
            unset($replacements[$key]);
            $replacements[$sigil.$key.$sigil] = $value;
        }

        return $replacements;
    }

    private static function retrieveTimestamp(
        stdClass $raw,
        SigningAlgorithm $signingAlgorithm,
        ConfigurationLogger $logger,
    ): ?DateTimeImmutable {
        self::checkIfDefaultValue($logger, $raw, self::TIMESTAMP);

        $timestamp = $raw->{self::TIMESTAMP} ?? null;

        if (null === $timestamp) {
            return null;
        }

        if (SigningAlgorithm::OPENSSL === $signingAlgorithm) {
            $logger->addWarning(
                sprintf(
                    'The "%s" setting has been set but has been ignored since an OpenSSL signature has been configured (setting "%s").',
                    self::TIMESTAMP,
                    self::ALGORITHM_KEY,
                ),
            );

            return null;
        }

        return new DateTimeImmutable(
            $timestamp,
            new DateTimeZone('UTC'),
        );
    }

    private static function retrievePrettyGitPlaceholder(stdClass $raw, ConfigurationLogger $logger): ?string
    {
        return self::retrievePlaceholder($raw, $logger, self::GIT_KEY);
    }

    private static function retrieveGitHashPlaceholder(stdClass $raw, ConfigurationLogger $logger): ?string
    {
        return self::retrievePlaceholder($raw, $logger, self::GIT_COMMIT_KEY);
    }

    /**
     * @param bool $short Use the short version
     *
     * @return string the commit hash
     */
    private static function retrieveGitHash(string $path, bool $short = false): string
    {
        return self::runGitCommand(
            sprintf(
                'git log --pretty="%s" -n1 HEAD',
                $short ? '%h' : '%H',
            ),
            $path,
        );
    }

    private static function retrieveGitShortHashPlaceholder(stdClass $raw, ConfigurationLogger $logger): ?string
    {
        return self::retrievePlaceholder($raw, $logger, self::GIT_COMMIT_SHORT_KEY);
    }

    private static function retrieveGitTagPlaceholder(stdClass $raw, ConfigurationLogger $logger): ?string
    {
        return self::retrievePlaceholder($raw, $logger, self::GIT_TAG_KEY);
    }

    private static function retrievePlaceholder(stdClass $raw, ConfigurationLogger $logger, string $key): ?string
    {
        self::checkIfDefaultValue($logger, $raw, $key);

        return $raw->{$key} ?? null;
    }

    private static function retrieveGitTag(string $path): string
    {
        return self::runGitCommand('git describe --tags HEAD', $path);
    }

    private static function retrievePrettyGitTag(string $path): string
    {
        $version = self::retrieveGitTag($path);

        if (preg_match('/^(?<tag>.+)-\d+-g(?<hash>[a-f0-9]{7})$/', $version, $matches)) {
            return sprintf('%s@%s', $matches['tag'], $matches['hash']);
        }

        return $version;
    }

    private static function retrieveGitVersionPlaceholder(stdClass $raw, ConfigurationLogger $logger): ?string
    {
        return self::retrievePlaceholder($raw, $logger, self::GIT_VERSION_KEY);
    }

    private static function retrieveGitVersion(string $path): ?string
    {
        try {
            return self::retrieveGitTag($path);
        } catch (RuntimeException $exception) {
            try {
                return self::retrieveGitHash($path, true);
            } catch (RuntimeException $exception) {
                throw new RuntimeException(
                    sprintf(
                        'The tag or commit hash could not be retrieved from "%s": %s',
                        $path,
                        $exception->getMessage(),
                    ),
                    0,
                    $exception,
                );
            }
        }
    }

    private static function retrieveDatetimeNowPlaceHolder(stdClass $raw, ConfigurationLogger $logger): ?string
    {
        return self::retrievePlaceholder($raw, $logger, self::DATETIME_KEY);
    }

    private static function retrieveDatetimeNow(string $format): string
    {
        return (new DateTimeImmutable('now', new DateTimeZone('UTC')))->format($format);
    }

    private static function retrieveDatetimeFormat(stdClass $raw, ConfigurationLogger $logger): array
    {
        self::checkIfDefaultValue($logger, $raw, self::DATETIME_FORMAT_KEY, self::DEFAULT_DATETIME_FORMAT);
        self::checkIfDefaultValue($logger, $raw, self::DATETIME_FORMAT_KEY, self::DATETIME_FORMAT_DEPRECATED_KEY);

        if (isset($raw->{self::DATETIME_FORMAT_KEY})) {
            $format = $raw->{self::DATETIME_FORMAT_KEY};
        } elseif (isset($raw->{self::DATETIME_FORMAT_DEPRECATED_KEY})) {
            @trigger_error(
                sprintf(
                    'The "%s" is deprecated, use "%s" setting instead.',
                    self::DATETIME_FORMAT_DEPRECATED_KEY,
                    self::DATETIME_FORMAT_KEY,
                ),
                E_USER_DEPRECATED,
            );
            $logger->addWarning(
                sprintf(
                    'The "%s" is deprecated, use "%s" setting instead.',
                    self::DATETIME_FORMAT_DEPRECATED_KEY,
                    self::DATETIME_FORMAT_KEY,
                ),
            );

            $format = $raw->{self::DATETIME_FORMAT_DEPRECATED_KEY};
        } else {
            $format = null;
        }

        if (null !== $format) {
            $formattedDate = (new DateTimeImmutable())->format($format);

            Assert::false(
                false === $formattedDate || $formattedDate === $format,
                sprintf(
                    'Expected the datetime format to be a valid format: "%s" is not',
                    $format,
                ),
            );

            return [$format, true];
        }

        return [self::DEFAULT_DATETIME_FORMAT, false];
    }

    private static function retrieveReplacementSigil(stdClass $raw, ConfigurationLogger $logger): string
    {
        return self::retrievePlaceholder($raw, $logger, self::REPLACEMENT_SIGIL_KEY) ?? self::DEFAULT_REPLACEMENT_SIGIL;
    }

    /**
     * @return null|non-empty-string
     */
    private static function retrieveShebang(stdClass $raw, bool $stubIsGenerated, ConfigurationLogger $logger): ?string
    {
        self::checkIfDefaultValue($logger, $raw, self::SHEBANG_KEY, self::DEFAULT_SHEBANG);

        if (false === isset($raw->{self::SHEBANG_KEY})) {
            return self::DEFAULT_SHEBANG;
        }

        $shebang = $raw->{self::SHEBANG_KEY};

        if (false === $shebang) {
            if (false === $stubIsGenerated) {
                $logger->addRecommendation(
                    sprintf(
                        'The "%s" has been set to `false` but is unnecessary since the Box built-in stub is not'
                        .' being used',
                        self::SHEBANG_KEY,
                    ),
                );
            }

            return null;
        }

        Assert::string($shebang, 'Expected shebang to be either a string, false or null, found true');

        $shebang = trim($shebang);

        Assert::notEmpty($shebang, 'The shebang should not be empty.');
        Assert::true(
            str_starts_with($shebang, '#!'),
            sprintf(
                'The shebang line must start with "#!". Got "%s" instead',
                $shebang,
            ),
        );

        if (false === $stubIsGenerated) {
            $logger->addWarning(
                sprintf(
                    'The "%s" has been set but ignored since it is used only with the Box built-in stub which is not'
                    .' used',
                    self::SHEBANG_KEY,
                ),
            );
        }

        return $shebang;
    }

    private static function retrieveSigningAlgorithm(stdClass $raw, ConfigurationLogger $logger): SigningAlgorithm
    {
        if (property_exists($raw, self::ALGORITHM_KEY) && null === $raw->{self::ALGORITHM_KEY}) {
            self::addRecommendationForDefaultValue($logger, self::ALGORITHM_KEY);
        }

        if (false === isset($raw->{self::ALGORITHM_KEY})) {
            return self::DEFAULT_SIGNING_ALGORITHM;
        }

        $algorithmLabel = mb_strtoupper($raw->{self::ALGORITHM_KEY});
        $algorithm = SigningAlgorithm::fromLabel($algorithmLabel);

        if (self::DEFAULT_SIGNING_ALGORITHM === $algorithm) {
            self::addRecommendationForDefaultValue($logger, self::ALGORITHM_KEY);
        }

        if (SigningAlgorithm::OPENSSL === $algorithm) {
            $logger->addWarning(
                'Using an OpenSSL signature is deprecated and will be removed in 5.0.0. Please check '
                .'https://github.com/box-project/box/blob/main/doc/phar-signing.md for alternatives.',
            );
        }

        return $algorithm;
    }

    private static function retrieveStubBannerContents(stdClass $raw, bool $stubIsGenerated, ConfigurationLogger $logger): ?string
    {
        self::checkIfDefaultValue($logger, $raw, self::BANNER_KEY, self::getDefaultBanner());

        if (false === isset($raw->{self::BANNER_KEY})) {
            return self::getDefaultBanner();
        }

        $banner = $raw->{self::BANNER_KEY};

        if (false === $banner) {
            if (false === $stubIsGenerated) {
                $logger->addRecommendation(
                    sprintf(
                        'The "%s" setting has been set but is unnecessary since the Box built-in stub is not '
                        .'being used',
                        self::BANNER_KEY,
                    ),
                );
            }

            return null;
        }

        Assert::true(is_string($banner) || is_array($banner), 'The banner cannot accept true as a value');

        if (is_array($banner)) {
            $banner = implode("\n", $banner);
        }

        if (false === $stubIsGenerated) {
            $logger->addWarning(
                sprintf(
                    'The "%s" setting has been set but is ignored since the Box built-in stub is not being used',
                    self::BANNER_KEY,
                ),
            );
        }

        return $banner;
    }

    private static function getDefaultBanner(): string
    {
        return sprintf(self::DEFAULT_BANNER, get_box_version());
    }

    private static function retrieveStubBannerPath(
        stdClass $raw,
        string $basePath,
        bool $stubIsGenerated,
        ConfigurationLogger $logger,
    ): ?string {
        self::checkIfDefaultValue($logger, $raw, self::BANNER_FILE_KEY);

        if (false === isset($raw->{self::BANNER_FILE_KEY})) {
            return null;
        }

        $bannerFile = Path::makeAbsolute($raw->{self::BANNER_FILE_KEY}, $basePath);

        Assert::file($bannerFile);

        if (false === $stubIsGenerated) {
            $logger->addWarning(
                sprintf(
                    'The "%s" setting has been set but is ignored since the Box built-in stub is not being used',
                    self::BANNER_FILE_KEY,
                ),
            );
        }

        return $bannerFile;
    }

    private static function normalizeStubBannerContents(?string $contents): ?string
    {
        if (null === $contents) {
            return null;
        }

        $banner = explode("\n", $contents);
        $banner = array_map('trim', $banner);

        return implode("\n", $banner);
    }

    private static function retrieveStubPath(stdClass $raw, string $basePath, ConfigurationLogger $logger): ?string
    {
        self::checkIfDefaultValue($logger, $raw, self::STUB_KEY);

        if (isset($raw->{self::STUB_KEY}) && is_string($raw->{self::STUB_KEY})) {
            $stubPath = Path::makeAbsolute($raw->{self::STUB_KEY}, $basePath);

            Assert::file($stubPath);

            return $stubPath;
        }

        return null;
    }

    private static function retrieveInterceptsFileFunctions(
        stdClass $raw,
        bool $stubIsGenerated,
        ConfigurationLogger $logger,
    ): bool {
        self::checkIfDefaultValue($logger, $raw, self::INTERCEPT_KEY, false);

        if (false === isset($raw->{self::INTERCEPT_KEY})) {
            return false;
        }

        $intercept = $raw->{self::INTERCEPT_KEY};

        if ($intercept && false === $stubIsGenerated) {
            $logger->addWarning(
                sprintf(
                    'The "%s" setting has been set but is ignored since the Box built-in stub is not being used',
                    self::INTERCEPT_KEY,
                ),
            );
        }

        return $intercept;
    }

    private static function retrievePromptForPrivateKey(
        stdClass $raw,
        SigningAlgorithm $signingAlgorithm,
        ConfigurationLogger $logger,
    ): bool {
        if (isset($raw->{self::KEY_PASS_KEY}) && true === $raw->{self::KEY_PASS_KEY}) {
            if (SigningAlgorithm::OPENSSL !== $signingAlgorithm) {
                $logger->addWarning(
                    'A prompt for password for the private key has been requested but ignored since the signing '
                    .'algorithm used is not "OPENSSL.',
                );

                return false;
            }

            return true;
        }

        return false;
    }

    private static function retrieveIsStubGenerated(stdClass $raw, ?string $stubPath, ConfigurationLogger $logger): bool
    {
        self::checkIfDefaultValue($logger, $raw, self::STUB_KEY, true);

        return null === $stubPath && (false === isset($raw->{self::STUB_KEY}) || false !== $raw->{self::STUB_KEY});
    }

    private static function retrieveCheckRequirements(
        stdClass $raw,
        bool $hasComposerJson,
        bool $hasComposerLock,
        bool $pharStubUsed,
        ConfigurationLogger $logger,
    ): bool {
        self::checkIfDefaultValue($logger, $raw, self::CHECK_REQUIREMENTS_KEY, true);

        if (false === property_exists($raw, self::CHECK_REQUIREMENTS_KEY)) {
            return $hasComposerJson || $hasComposerLock;
        }

        /** @var bool $checkRequirements */
        $checkRequirements = $raw->{self::CHECK_REQUIREMENTS_KEY} ?? true;

        // TODO: in 5.0 we no longer care about the composer.json
        if ($checkRequirements && false === $hasComposerJson && false === $hasComposerLock) {
            $logger->addWarning(
                'The requirement checker could not be used because the composer.json and composer.lock file could not '
                .'be found.',
            );

            return false;
        }

        if ($checkRequirements && false === $hasComposerLock) {
            // TODO: in 5.0:
            //  - adjust the warning
            //  - return false here to skip the requirement checker
            $logger->addWarning(
                'Enabling the requirement checker when there is no composer.lock is deprecated. In the future the '
                .'requirement checker will be forcefully skipped in this scenario.',
            );
        }

        if ($checkRequirements && $pharStubUsed) {
            $logger->addWarning(
                sprintf(
                    'The "%s" setting has been set but has been ignored since the PHAR built-in stub is being '
                    .'used.',
                    self::CHECK_REQUIREMENTS_KEY,
                ),
            );
        }

        return $checkRequirements;
    }

    private static function retrievePhpScoperConfig(stdClass $raw, string $basePath, ConfigurationLogger $logger): PhpScoperConfiguration
    {
        self::checkIfDefaultValue($logger, $raw, self::PHP_SCOPER_KEY, self::PHP_SCOPER_CONFIG);

        if (!isset($raw->{self::PHP_SCOPER_KEY})) {
            $configFilePath = Path::makeAbsolute(self::PHP_SCOPER_CONFIG, $basePath);
            $configFilePath = file_exists($configFilePath) ? $configFilePath : null;

            return PhpScoperConfigurationFactory::create($configFilePath);
        }

        $configFile = $raw->{self::PHP_SCOPER_KEY};

        Assert::string($configFile);

        $configFilePath = Path::makeAbsolute($configFile, $basePath);

        Assert::file($configFilePath);
        Assert::readable($configFilePath);

        return PhpScoperConfigurationFactory::create($configFilePath);
    }

    /**
     * Runs a Git command on the repository.
     *
     * @return string The trimmed output from the command
     */
    private static function runGitCommand(string $command, string $path): string
    {
        $process = Process::fromShellCommandline($command, $path);
        $process->run();

        if ($process->isSuccessful()) {
            return trim($process->getOutput());
        }

        throw new RuntimeException(
            sprintf(
                'The tag or commit hash could not be retrieved from "%s": %s',
                $path,
                $process->getErrorOutput(),
            ),
            0,
            new ProcessFailedException($process),
        );
    }

    /**
     * @param string[] $compactorClasses
     *
     * @return string[]|null
     */
    private static function retrievePhpCompactorIgnoredAnnotations(
        stdClass $raw,
        array $compactorClasses,
        ConfigurationLogger $logger,
    ): ?array {
        $hasPhpCompactor = in_array(PhpCompactor::class, $compactorClasses, true);

        self::checkIfDefaultValue($logger, $raw, self::ANNOTATIONS_KEY, true);
        self::checkIfDefaultValue($logger, $raw, self::ANNOTATIONS_KEY, null);

        if (false === property_exists($raw, self::ANNOTATIONS_KEY)) {
            return self::DEFAULT_IGNORED_ANNOTATIONS;
        }

        if (false === $hasPhpCompactor) {
            $logger->addWarning(
                sprintf(
                    'The "%s" setting has been set but is ignored since no PHP compactor has been configured',
                    self::ANNOTATIONS_KEY,
                ),
            );
        }

        /** @var null|bool|stdClass $annotations */
        $annotations = $raw->{self::ANNOTATIONS_KEY};

        if (true === $annotations || null === $annotations) {
            return self::DEFAULT_IGNORED_ANNOTATIONS;
        }

        if (false === $annotations) {
            return null;
        }

        if (false === property_exists($annotations, self::IGNORED_ANNOTATIONS_KEY)) {
            $logger->addWarning(
                sprintf(
                    'The "%s" setting has been set but no "%s" setting has been found, hence "%s" is treated as'
                    .' if it is set to `false`',
                    self::ANNOTATIONS_KEY,
                    self::IGNORED_ANNOTATIONS_KEY,
                    self::ANNOTATIONS_KEY,
                ),
            );

            return null;
        }

        $ignored = [];

        if (property_exists($annotations, self::IGNORED_ANNOTATIONS_KEY)
            && in_array($ignored = $annotations->{self::IGNORED_ANNOTATIONS_KEY}, [null, []], true)
        ) {
            self::addRecommendationForDefaultValue($logger, self::ANNOTATIONS_KEY.'#'.self::IGNORED_ANNOTATIONS_KEY);

            return (array) $ignored;
        }

        return $ignored;
    }

    private static function createPhpCompactor(?array $ignoredAnnotations): Compactor
    {
        if (null === $ignoredAnnotations) {
            return new PhpCompactor(null);
        }

        $ignoredAnnotations = array_values(
            array_filter(
                array_map(
                    static fn (string $annotation): ?string => mb_strtolower(trim($annotation)),
                    $ignoredAnnotations,
                ),
            ),
        );

        return PhpCompactor::create($ignoredAnnotations);
    }

    private static function createPhpScoperCompactor(
        stdClass $raw,
        string $basePath,
        ConfigurationLogger $logger,
    ): Compactor {
        $phpScoperConfig = self::configurePhpScoperPrefix(
            self::retrievePhpScoperConfig($raw, $basePath, $logger),
        );

        $excludedFilePaths = array_values(
            array_unique(
                array_map(
                    static fn (string $path): string => Path::makeRelative($path, $basePath),
                    array_keys(
                        $phpScoperConfig->getExcludedFilesWithContents(),
                    ),
                ),
            ),
        );

        return new PhpScoperCompactor(
            new SerializableScoper($phpScoperConfig, ...$excludedFilePaths),
        );
    }

    private static function configurePhpScoperPrefix(PhpScoperConfiguration $phpScoperConfig): PhpScoperConfiguration
    {
        $prefix = $phpScoperConfig->getPrefix();
        if (!str_starts_with($prefix, '_PhpScoper')) {
            return $phpScoperConfig;
        }

        return $phpScoperConfig->withPrefix(unique_id('_HumbugBox'));
    }

    private static function checkIfDefaultValue(
        ConfigurationLogger $logger,
        stdClass $raw,
        string $key,
        $defaultValue = null,
    ): void {
        if (false === property_exists($raw, $key)) {
            return;
        }

        $value = $raw->{$key};

        if (null === $value
            || (false === is_object($defaultValue) && $defaultValue === $value)
            || (is_object($defaultValue) && $defaultValue == $value)
        ) {
            $logger->addRecommendation(
                sprintf(
                    'The "%s" setting can be omitted since is set to its default value',
                    $key,
                ),
            );
        }
    }

    private static function addRecommendationForDefaultValue(ConfigurationLogger $logger, string $key): void
    {
        $logger->addRecommendation(
            sprintf(
                'The "%s" setting can be omitted since is set to its default value',
                $key,
            ),
        );
    }
}
