<?php

declare(strict_types=1);

/*
 * This file is part of the humbug/php-scoper package.
 *
 * Copyright (c) 2017 Théo FIDRY <theo.fidry@gmail.com>,
 *                    Pádraic Brady <padraic.brady@gmail.com>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace Humbug\PhpScoper\Autoload;

use Humbug\PhpScoper\Symbol\SymbolsRegistry;
use PhpParser\Node\Name\FullyQualified;
use function array_keys;
use function array_map;
use function array_unshift;
use function chr;
use function count;
use function explode;
use function implode;
use function is_string;
use function sprintf;
use function str_repeat;
use function str_replace;
use function usort;

final class ScoperAutoloadGenerator
{
    private const FUNCTION_ALIASES_DOC = <<<'EOF'
        // Function aliases. For more information see:
        // https://github.com/humbug/php-scoper/blob/master/docs/further-reading.md#function-aliases
        EOF;

    private const CLASS_ALIASES_DOC = <<<'EOF'
        // Class aliases. For more information see:
        // https://github.com/humbug/php-scoper/blob/master/docs/further-reading.md#class-aliases
        EOF;

    private const EXPOSE_CLASS_DECLARATION = <<<'PHP'
        if (!function_exists('humbug_phpscoper_expose_class')) {
            function humbug_phpscoper_expose_class($exposed, $prefixed) {
                if (!class_exists($exposed, false) && !interface_exists($exposed, false) && !trait_exists($exposed, false)) {
                    spl_autoload_call($prefixed);
                }
            }
        }
        PHP;

    /** @var non-empty-string */
    private static string $eol;

    /**
     * @param string[] $excludedComposerAutoloadFileHashes
     */
    public function __construct(
        private readonly SymbolsRegistry $registry,
        private readonly array $excludedComposerAutoloadFileHashes,
    ) {
        self::$eol = chr(10);
    }

    public function dump(): string
    {
        $exposedFunctions = self::sortExposedFunctions(
            $this->registry->getRecordedFunctions(),
        );

        $wrapInNamespace = self::hasNamespacedFunctions($exposedFunctions);

        $statements = [
            ...self::createClassAliasStatementsSection(
                $this->registry->getRecordedClasses(),
                $wrapInNamespace,
            ),
            self::$eol,
            self::$eol,
            ...self::createFunctionAliasStatements(
                $exposedFunctions,
                $wrapInNamespace,
            ),
        ];

        $statements = implode(self::$eol, $statements);

        $excludedComposerAutoloadFiles = count($this->excludedComposerAutoloadFileHashes) === 0
            ? '[]'
            : sprintf(
                "['%s']",
                implode(
                    "', '",
                    $this->excludedComposerAutoloadFileHashes,
                ),
            );

        if ($wrapInNamespace) {
            $dump = <<<PHP
                <?php

                // scoper-autoload.php @generated by PhpScoper

                namespace {
                    \$loader = (static function () {
                        // Backup the autoloaded Composer files
                        \$existingComposerAutoloadFiles = isset(\$GLOBALS['__composer_autoload_files']) ? \$GLOBALS['__composer_autoload_files'] : [];

                        \$loader = require_once __DIR__.'/autoload.php';
                        // Ensure InstalledVersions is available
                        \$installedVersionsPath = __DIR__.'/composer/InstalledVersions.php';
                        if (file_exists(\$installedVersionsPath)) require_once \$installedVersionsPath;

                        // Restore the backup and ensure the excluded files are properly marked as loaded
                        \$GLOBALS['__composer_autoload_files'] = \\array_merge(
                            \$existingComposerAutoloadFiles,
                            \\array_fill_keys({$excludedComposerAutoloadFiles}, true)
                        );

                        return \$loader;
                    })();
                }

                {$statements}

                namespace {
                    return \$loader;
                }

                PHP;
        } else {
            $dump = <<<PHP
                <?php

                // scoper-autoload.php @generated by PhpScoper

                \$loader = (static function () {
                    // Backup the autoloaded Composer files
                    \$existingComposerAutoloadFiles = isset(\$GLOBALS['__composer_autoload_files']) ? \$GLOBALS['__composer_autoload_files'] : [];

                    \$loader = require_once __DIR__.'/autoload.php';
                    // Ensure InstalledVersions is available
                    \$installedVersionsPath = __DIR__.'/composer/InstalledVersions.php';
                    if (file_exists(\$installedVersionsPath)) require_once \$installedVersionsPath;

                    // Restore the backup and ensure the excluded files are properly marked as loaded
                    \$GLOBALS['__composer_autoload_files'] = \\array_merge(
                        \$existingComposerAutoloadFiles,
                        \\array_fill_keys({$excludedComposerAutoloadFiles}, true)
                    );

                    return \$loader;
                })();

                {$statements}

                return \$loader;

                PHP;
        }

        return self::removeUnnecessaryLineReturns($dump);
    }

    /**
     * @param list<array{string, string}> $exposedFunctions
     *
     * @return list<array{string, string}>
     */
    private static function sortExposedFunctions(array $exposedFunctions): array
    {
        usort(
            $exposedFunctions,
            static fn (array $a, array $b) => $a[0] <=> $b[0],
        );

        return $exposedFunctions;
    }

    /**
     * @param list<array{string, string}> $exposedClasses
     *
     * @return list<string>
     */
    private static function createClassAliasStatementsSection(
        array $exposedClasses,
        bool $wrapInNamespace
    ): array {
        $statements = self::createClassAliasStatements($exposedClasses);

        if (count($statements) === 0) {
            return [];
        }

        array_unshift($statements, self::EXPOSE_CLASS_DECLARATION);

        if ($wrapInNamespace) {
            $statements = self::wrapStatementsInNamespaceBlock('', $statements);
        }

        array_unshift($statements, self::CLASS_ALIASES_DOC);

        return $statements;
    }

    /**
     * @param list<array{string, string}> $exposedClasses
     *
     * @return list<string>
     */
    private static function createClassAliasStatements(array $exposedClasses): array
    {
        return array_map(
            static fn (array $class) => self::createClassAliasStatement(...$class),
            $exposedClasses,
        );
    }

    private static function createClassAliasStatement(
        string $exposed,
        string $prefixed
    ): string {
        return sprintf(
            'humbug_phpscoper_expose_class(\'%s\', \'%s\');',
            $exposed,
            $prefixed,
        );
    }

    /**
     * @param string|list<string> $statements
     *
     * @return list<string>
     */
    private static function wrapStatementsInNamespaceBlock(string $namespace, array|string $statements): array
    {
        if (is_string($statements)) {
            $statements = explode(self::$eol, $statements);
        }

        $indent = str_repeat(' ', 4);
        $indentLine = static fn (string $line) => $indent.$line;

        $statements = array_map(
            static function (string $statement) use ($indentLine): string {
                $parts = explode(self::$eol, $statement);

                return implode(
                    self::$eol,
                    array_map($indentLine, $parts),
                );
            },
            $statements,
        );

        array_unshift(
            $statements,
            sprintf(
                'namespace %s{',
                '' === $namespace ? '' : $namespace.' ',
            ),
        );
        $statements[] = '}'.self::$eol;

        return $statements;
    }

    /**
     * @param list<array{string, string}> $exposedFunctions
     *
     * @return list<string>
     */
    private static function createFunctionAliasStatements(
        array $exposedFunctions,
        bool $wrapInNamespace
    ): array {
        $functionsGroupedByNamespace = self::groupFunctionsByNamespace($exposedFunctions);

        $statements = array_map(
            static fn (string $namespace) => self::createNamespacedFunctionAliasStatement(
                $wrapInNamespace,
                $namespace,
                $functionsGroupedByNamespace[$namespace],
            ),
            array_keys($functionsGroupedByNamespace),
        );

        if ([] === $statements) {
            return $statements;
        }

        array_unshift($statements, self::FUNCTION_ALIASES_DOC);

        return $statements;
    }

    /**
     * @param list<array{string, string}> $exposedFunctions
     *
     * @return array<string, list<array{string, string, string}>>
     */
    private static function groupFunctionsByNamespace(array $exposedFunctions): array
    {
        $groupedFunctions = [];

        foreach ($exposedFunctions as [$exposed, $prefix]) {
            $originalFQ = new FullyQualified($exposed);

            $namespace = $originalFQ->slice(0, -1);
            $functionName = null === $namespace ? $exposed : (string) $originalFQ->slice(-1, 1);

            $groupedFunctions[(string) $namespace][] = [$exposed, $functionName, $prefix];
        }

        return $groupedFunctions;
    }

    /**
     * @param list<array{string, string, string}> $functions
     */
    private static function createNamespacedFunctionAliasStatement(
        bool $wrapInNamespace,
        string $namespace,
        array $functions
    ): string {
        $statements = array_map(
            static fn (array $function) => self::createFunctionAliasStatement(...$function),
            $functions,
        );

        if ($wrapInNamespace) {
            $statements = self::wrapStatementsInNamespaceBlock(
                $namespace,
                $statements,
            );
        }

        return implode(self::$eol, $statements);
    }

    private static function createFunctionAliasStatement(
        string $exposed,
        string $functionName,
        string $prefixed
    ): string {
        return sprintf(
            <<<'PHP'
                if (!function_exists('%s')) { function %s(%s) { return \%s(...func_get_args()); } }
                PHP,
            $exposed,
            $functionName,
            '__autoload' === $functionName ? '$className' : '',
            $prefixed,
        );
    }

    /**
     * @param list<array{string, string}> $functions
     */
    private static function hasNamespacedFunctions(array $functions): bool
    {
        foreach ($functions as [$original, $alias]) {
            $containsBackslash = str_contains($original, '\\');

            if ($containsBackslash) {
                return true;
            }
        }

        return false;
    }

    private static function removeUnnecessaryLineReturns(string $dump): string
    {
        $cleanedDump = $dump;

        do {
            $dump = $cleanedDump;
            $cleanedDump = str_replace("\n\n\n", "\n\n", $dump);
        } while ($cleanedDump !== $dump);

        return $dump;
    }
}
