<?php

declare(strict_types=1);

/*
 * This file is part of the TYPO3 CMS project.
 *
 * It is free software; you can redistribute it and/or modify it under
 * the terms of the GNU General Public License, either version 2
 * of the License, or any later version.
 *
 * For the full copyright and license information, please read the
 * LICENSE.txt file that was distributed with this source code.
 *
 * The TYPO3 project - inspiring people to share!
 */

namespace TYPO3\CMS\Core\Console;

use Psr\Container\ContainerInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\CommandLoader\CommandLoaderInterface;
use Symfony\Component\Console\Descriptor\ApplicationDescription;
use Symfony\Component\Console\Exception\CommandNotFoundException;
use TYPO3\CMS\Core\Attribute\AsNonSchedulableCommand;
use TYPO3\CMS\Core\SingletonInterface;

/**
 * Registry for Symfony commands, populated via dependency injection tags
 */
class CommandRegistry implements CommandLoaderInterface, SingletonInterface
{
    /**
     * Map of command configurations with the command name as key
     *
     * @var array[]
     */
    protected array $commandConfigurations = [];

    /**
     * Map of command aliases
     *
     * @var array[]
     */
    protected array $aliases = [];

    public function __construct(
        protected readonly ContainerInterface $container
    ) {}

    /**
     * {@inheritdoc}
     */
    public function has(string $name): bool
    {
        return array_key_exists($name, $this->commandConfigurations);
    }

    /**
     * {@inheritdoc}
     */
    public function get(string $name): Command
    {
        try {
            return $this->getCommandByIdentifier($name);
        } catch (UnknownCommandException $e) {
            throw new CommandNotFoundException($e->getMessage(), [], 1567969355, $e);
        }
    }

    /**
     * {@inheritdoc}
     */
    public function getNames(): array
    {
        return array_keys($this->commandConfigurations);
    }

    /**
     * Get the configuration form all commands which are schedulable.
     * By using the #[AsNonSchedulableCommand] attribute, all commands
     * are filtered out which utilize this attribute, as the Symfony
     * #[AsCommand] attribute does not allow to set additional meta-data.
     */
    public function getSchedulableCommandsConfiguration(): array
    {
        return array_filter(
            $this->commandConfigurations,
            static fn(array $configuration): bool => ($configuration['schedulable'] ?? true)
        );
    }

    /**
     * Get all commands which are allowed for scheduling recurring commands.
     * @todo Not used by core. Consider deprecating!
     */
    public function getSchedulableCommands(): \Generator
    {
        foreach ($this->commandConfigurations as $commandName => $configuration) {
            if ($configuration['schedulable'] ?? true) {
                yield $commandName => $this->getInstance($configuration['serviceName']);
            }
        }
    }

    /**
     * @throws UnknownCommandException
     */
    public function getCommandByIdentifier(string $identifier): Command
    {
        if (!isset($this->commandConfigurations[$identifier])) {
            throw new UnknownCommandException(
                sprintf('Command "%s" has not been registered.', $identifier),
                1510906768
            );
        }

        return $this->getInstance($this->commandConfigurations[$identifier]['serviceName']);
    }

    protected function getInstance(string $service): Command
    {
        return $this->container->get($service);
    }

    /**
     * @internal
     */
    public function getNamespaces(): array
    {
        $namespaces = [];
        foreach ($this->commandConfigurations as $commandName => $configuration) {
            if ($configuration['hidden']) {
                continue;
            }
            if ($configuration['aliasFor'] !== null) {
                continue;
            }
            $namespace = $configuration['namespace'];
            $namespaces[$namespace]['id'] = $namespace;
            $namespaces[$namespace]['commands'][] = $commandName;
        }

        ksort($namespaces);
        foreach ($namespaces as &$commands) {
            ksort($commands);
        }

        return $namespaces;
    }

    /**
     * Gets the commands (registered in the given namespace if provided).
     *
     * The array keys are the full names and the values the command instances.
     *
     * @return array An array of Command descriptors
     * @internal
     */
    public function filter(?string $namespace = null): array
    {
        $commands = [];
        foreach ($this->commandConfigurations as $commandName => $configuration) {
            if ($configuration['hidden']) {
                continue;
            }
            if ($namespace !== null && $namespace !== $this->extractNamespace($commandName, substr_count($namespace, ':') + 1)) {
                continue;
            }
            if ($configuration['aliasFor'] !== null) {
                continue;
            }

            $commands[$commandName] = $configuration;
            $commands[$commandName]['aliases'] = $this->aliases[$commandName] ?? [];
        }

        return $commands;
    }

    /**
     * @internal
     */
    public function addLazyCommand(
        string $commandName,
        string $serviceName,
        ?string $description = null,
        bool $hidden = false,
        bool $schedulable = false,
        ?string $aliasFor = null
    ): void {

        if ($schedulable) {
            // Workaround: Symfony #[AsCommand] can not utilize an extra 'schedulable' key. Thus, we
            // evaluate the additional #[AsUnschedulableCommand] as well.
            try {
                $reflection = new \ReflectionClass($serviceName);
                $attributes = $reflection->getAttributes(AsNonSchedulableCommand::class);
                if ($attributes !== []) {
                    // The presence of the attribute alone is sufficient, no further reflection
                    // or construction is required at this time. Might be needed if the attribute
                    // ever gets properties.
                    $schedulable = false;
                }
            } catch (\ReflectionException $e) {
            }
        }
        $this->commandConfigurations[$commandName] = [
            'name' => $aliasFor ?? $commandName,
            'serviceName' => $serviceName,
            'description' => $description,
            'hidden' => $hidden,
            'schedulable' => $schedulable,
            'aliasFor' => $aliasFor,
            'namespace' => $this->extractNamespace($commandName, 1),
        ];

        if ($aliasFor !== null) {
            $this->aliases[$aliasFor][] = $commandName;
        }
    }

    /**
     * Returns the namespace part of the command name.
     *
     * This method is not part of public API and should not be used directly.
     *
     * @return string The namespace of the command
     */
    private function extractNamespace(string $name, ?int $limit = null): string
    {
        $parts = explode(':', $name, -1);
        if (count($parts) === 0) {
            return ApplicationDescription::GLOBAL_NAMESPACE;
        }

        return implode(':', $limit === null ? $parts : array_slice($parts, 0, $limit));
    }
}
