<?php declare(strict_types=1);

/**
 * @license Apache 2.0
 */

namespace OpenApi;

use OpenApi\Analysers\AnalyserInterface;
use OpenApi\Analysers\AttributeAnnotationFactory;
use OpenApi\Analysers\DocBlockAnnotationFactory;
use OpenApi\Analysers\ReflectionAnalyser;
use OpenApi\Annotations as OA;
use OpenApi\Loggers\DefaultLogger;
use OpenApi\Type\TypeInfoTypeResolver;
use Psr\Log\LoggerInterface;

/**
 * OpenApi spec generator.
 *
 * Scans PHP source code and generates OpenApi specifications from the found OpenApi annotations.
 */
class Generator
{
    /**
     * Allows Annotation classes to know the context of the annotation that is being processed.
     */
    public static ?Context $context = null;

    /** @var string Magic value to differentiate between null and undefined. */
    public const UNDEFINED = '@OA\Generator::UNDEFINED🙈';

    /** @var array<string,string> */
    public const DEFAULT_ALIASES = ['oa' => 'OpenApi\\Annotations'];

    /** @var list<string> */
    public const DEFAULT_NAMESPACES = ['OpenApi\\Annotations\\'];

    /** @var array<string,string> Map of namespace aliases to be supported by doctrine. */
    protected array $aliases;

    /** @var array<string>|null List of annotation namespaces to be autoloaded by doctrine. */
    protected ?array $namespaces;

    protected ?AnalyserInterface $analyser = null;

    /** @var array<string,mixed> */
    protected array $config = [];

    protected ?Pipeline $processorPipeline = null;

    protected ?TypeResolverInterface $typeResolver = null;

    protected ?LoggerInterface $logger = null;

    /**
     * OpenApi version override.
     *
     * If set, it will override the version set in the <code>OpenApi</code> annotation.
     *
     * Due to the order of processing, any conditional code using this (via <code>Context::$version</code>)
     * must come only after the analysis is finished.
     */
    protected ?string $version = null;

    public function __construct(?LoggerInterface $logger = null)
    {
        $this->logger = $logger;

        $this->setAliases(self::DEFAULT_ALIASES);
        $this->setNamespaces(self::DEFAULT_NAMESPACES);
    }

    public static function isDefault(...$value): bool
    {
        foreach ($value as $v) {
            if ($v !== Generator::UNDEFINED) {
                return false;
            }
        }

        return true;
    }

    /**
     * @return array<string, string>
     */
    public function getAliases(): array
    {
        return $this->aliases;
    }

    public function addAlias(string $alias, string $namespace): Generator
    {
        $this->aliases[$alias] = $namespace;

        return $this;
    }

    public function setAliases(array $aliases): Generator
    {
        $this->aliases = $aliases;

        return $this;
    }

    /**
     * @return list<string>|null
     */
    public function getNamespaces(): ?array
    {
        return $this->namespaces;
    }

    public function addNamespace(string $namespace): Generator
    {
        $namespaces = (array) $this->getNamespaces();
        $namespaces[] = $namespace;

        return $this->setNamespaces(array_unique($namespaces));
    }

    public function setNamespaces(?array $namespaces): Generator
    {
        $this->namespaces = $namespaces;

        return $this;
    }

    public function getAnalyser(): AnalyserInterface
    {
        $generatorConfig = $this->getConfig()['generator'];
        $this->analyser = $this->analyser ?: new ReflectionAnalyser([
            new AttributeAnnotationFactory($generatorConfig['ignoreOtherAttributes']),
            new DocBlockAnnotationFactory(),
        ]);
        $this->analyser->setGenerator($this);

        return $this->analyser;
    }

    public function setAnalyser(?AnalyserInterface $analyser): Generator
    {
        $this->analyser = $analyser;

        return $this;
    }

    public function getDefaultConfig(): array
    {
        return [
            'generator' => [
                'ignoreOtherAttributes' => false,
            ],
            'mergeIntoOpenApi' => [
                'mergeComponents' => false,
            ],
            'expandEnums' => [
                'enumNames' => null,
            ],
            'augmentParameters' => [
                'augmentOperationParameters' => true,
            ],
            'pathFilter' => [
                'tags' => [],
                'paths' => [],
                'recurseCleanup' => false,
            ],
            'cleanUnusedComponents' => [
                'enabled' => false,
            ],
            'augmentTags' => [
                'whitelist' => [],
                'withDescription' => true,
            ],
            'operationId' => [
                'hash' => true,
            ],
        ];
    }

    public function getConfig(): array
    {
        return $this->config + $this->getDefaultConfig();
    }

    protected function normaliseConfig(array $config): array
    {
        $normalised = [];
        foreach ($config as $key => $value) {
            if (is_numeric($key)) {
                $token = explode('=', (string) $value);
                if (2 === count($token)) {
                    // 'operationId.hash=false'
                    [$key, $value] = $token;
                }
            }

            if (in_array($value, ['true', 'false'])) {
                $value = 'true' == $value;
            }

            if ($isList = (str_ends_with((string) $key, '[]'))) {
                $key = substr((string) $key, 0, -2);
            }
            $token = explode('.', (string) $key);
            if (2 === count($token)) {
                // 'operationId.hash' => false
                // namespaced / processor
                if ($isList) {
                    $normalised[$token[0]][$token[1]][] = $value;
                } else {
                    $normalised[$token[0]][$token[1]] = $value;
                }
            } else {
                if ($isList) {
                    $normalised[$key][] = $value;
                } else {
                    $normalised[$key] = $value;
                }
            }
        }

        return $normalised;
    }

    /**
     * Set generator and/or processor config.
     *
     * @param array<string,mixed> $config
     */
    public function setConfig(array $config): Generator
    {
        $this->config = $this->normaliseConfig($config) + $this->config;

        return $this;
    }

    public function getProcessorPipeline(): Pipeline
    {
        if (!$this->processorPipeline instanceof Pipeline) {
            $this->processorPipeline = new Pipeline([
                new Processors\DocBlockDescriptions(),
                new Processors\MergeIntoOpenApi(),
                new Processors\MergeIntoComponents(),
                new Processors\ExpandClasses(),
                new Processors\ExpandInterfaces(),
                new Processors\ExpandTraits(),
                new Processors\ExpandEnums(),
                new Processors\AugmentSchemas(),
                new Processors\AugmentRequestBody(),
                new Processors\AugmentProperties(),
                new Processors\AugmentDiscriminators(),
                new Processors\BuildPaths(),
                new Processors\AugmentParameters(),
                new Processors\AugmentRefs(),
                new Processors\AugmentItems(),
                new Processors\MergeJsonContent(),
                new Processors\MergeXmlContent(),
                new Processors\AugmentMediaType(),
                new Processors\OperationId(),
                new Processors\CleanUnmerged(),
                new Processors\PathFilter(),
                new Processors\CleanUnusedComponents(),
                new Processors\AugmentTags(),
            ]);
        }

        $config = $this->getConfig();
        $walker = function (callable $pipe) use ($config): void {
            $rc = new \ReflectionClass($pipe);

            // apply config
            $processorKey = lcfirst($rc->getShortName());
            if (array_key_exists($processorKey, $config)) {
                foreach ($config[$processorKey] as $name => $value) {
                    $setter = 'set' . ucfirst($name);
                    if (method_exists($pipe, $setter)) {
                        $pipe->{$setter}($value);
                    }
                }
            }

            if (is_a($pipe, GeneratorAwareInterface::class)) {
                $pipe->setGenerator($this);
            }
        };

        return $this->processorPipeline->walk($walker);
    }

    public function setProcessorPipeline(?Pipeline $processor): Generator
    {
        $this->processorPipeline = $processor;

        $walker = function (callable $pipe): void {
            if (is_a($pipe, GeneratorAwareInterface::class)) {
                $pipe->setGenerator($this);
            }
        };

        if ($this->processorPipeline) {
            $this->processorPipeline->walk($walker);
        }

        return $this;
    }

    /**
     * Chainable method that allows to modify the processor pipeline.
     *
     * @param callable $with callable with the current processor pipeline passed in
     */
    public function withProcessorPipeline(callable $with): Generator
    {
        $with($this->getProcessorPipeline());

        return $this;
    }

    public function setTypeResolver(?TypeResolverInterface $typeResolver): Generator
    {
        $this->typeResolver = $typeResolver;

        return $this;
    }

    public function getTypeResolver(): TypeResolverInterface
    {
        $this->typeResolver ??= new TypeInfoTypeResolver();

        return $this->typeResolver;
    }

    public function getLogger(): ?LoggerInterface
    {
        $this->logger ??= new DefaultLogger();

        return $this->logger;
    }

    public function getVersion(): ?string
    {
        return $this->version;
    }

    public function setVersion(?string $version): Generator
    {
        $this->version = $version;

        return $this;
    }

    /**
     * Run code in the context of this generator.
     *
     * @param callable $callable Callable in the form of
     *                           <code>function(Generator $generator, Analysis $analysis, Context $context): mixed</code>
     *
     * @return mixed the result of the <code>callable</code>
     */
    public function withContext(callable $callable)
    {
        $rootContext = new Context([
            'version' => $this->getVersion(),
            'logger' => $this->getLogger(),
        ]);
        $analysis = new Analysis([], $rootContext);

        return $callable($this, $analysis, $rootContext);
    }

    /**
     * Generate OpenAPI spec by scanning the given source files.
     *
     * @param iterable      $sources  PHP source files to scan.
     *                                Supported sources:
     *                                * string - file / directory name
     *                                * \SplFileInfo
     *                                * \Symfony\Component\Finder\Finder
     * @param null|Analysis $analysis custom analysis instance
     * @param bool          $validate flag to enable/disable validation of the returned spec
     */
    public function generate(iterable $sources, ?Analysis $analysis = null, bool $validate = true): ?OA\OpenApi
    {
        $rootContext = new Context([
            'version' => $this->getVersion(),
            'logger' => $this->getLogger(),
        ]);

        $analysis = $analysis ?: new Analysis([], $rootContext);
        $analysis->context = $analysis->context ?: $rootContext;

        $this->scanSources($sources, $analysis, $rootContext);

        // post-processing
        $this->getProcessorPipeline()->process($analysis);

        if ($analysis->openapi) {
            // overwrite default/annotated version
            $analysis->openapi->openapi = $this->getVersion() ?: $analysis->openapi->openapi;
            // update context to provide the same to validation/serialisation code
            $rootContext->version = $analysis->openapi->openapi;
        }

        // validation
        if ($validate) {
            $analysis->validate();
        }

        return $analysis->openapi;
    }

    protected function scanSources(iterable $sources, Analysis $analysis, Context $rootContext): void
    {
        $analyser = $this->getAnalyser();

        foreach ($sources as $source) {
            if (is_iterable($source)) {
                $this->scanSources($source, $analysis, $rootContext);
            } else {
                $resolvedSource = $source instanceof \SplFileInfo ? $source->getPathname() : realpath($source);
                if (!$resolvedSource) {
                    $rootContext->logger->warning(sprintf('Skipping invalid source: %s', $source));
                    continue;
                }
                if (is_dir($resolvedSource)) {
                    $this->scanSources(new SourceFinder($resolvedSource), $analysis, $rootContext);
                } else {
                    $rootContext->logger->debug(sprintf('Analysing source: %s', $resolvedSource));
                    $analysis->addAnalysis($analyser->fromFile($resolvedSource, $rootContext));
                }
            }
        }
    }
}
