<?php declare(strict_types=1);

/**
 * @license Apache 2.0
 */

namespace OpenApi;

use OpenApi\Annotations as OA;

/**
 * Result of the analyser.
 *
 * Pretends to be an array of annotations but also contains detected classes and helper functions for the processors.
 */
class Analysis
{
    /** @var \SplObjectStorage<OA\AbstractAnnotation, Context> */
    public \SplObjectStorage $annotations;

    /**
     * Class definitions.
     */
    public array $classes = [];

    /**
     * Interface definitions.
     */
    public array $interfaces = [];

    /**
     * Trait definitions.
     */
    public array $traits = [];

    /**
     * Enum definitions.
     */
    public array $enums = [];

    /**
     * The target OpenApi annotation.
     */
    public ?OA\OpenApi $openapi = null;

    public ?Context $context = null;

    /**
     * @param list<OA\AbstractAnnotation> $annotations
     */
    public function __construct(array $annotations = [], ?Context $context = null)
    {
        $this->annotations = new \SplObjectStorage();
        $this->context = $context;

        $this->addAnnotations($annotations, $context);
    }

    public function addAnnotation(OA\AbstractAnnotation $annotation, Context $context): void
    {
        if ($this->annotations->offsetExists($annotation)) {
            return;
        }

        $context->ensureRoot($this->context);

        if ($annotation instanceof OA\OpenApi) {
            $this->openapi = $this->openapi ?: $annotation;
        } else {
            if ($context->is('annotations') === false) {
                $context->annotations = [];
            }

            if (in_array($annotation, $context->annotations, strict: true) === false) {
                $context->annotations[] = $annotation;
            }
        }

        $this->annotations->offsetSet($annotation, $context);

        foreach (get_object_vars($annotation) as $property => $value) {
            if (in_array($property, $annotation::$_blacklist)) {
                if ($property === '_unmerged') {
                    foreach ($value as $item) {
                        $this->addAnnotation($item, $context);
                    }
                }
            } elseif (is_array($value)) {
                foreach ($value as $item) {
                    if ($item instanceof OA\AbstractAnnotation) {
                        $this->addAnnotation($item, $context);
                    }
                }
            } elseif ($value instanceof OA\AbstractAnnotation) {
                $this->addAnnotation($value, $context);
            }
        }
    }

    /**
     * @param list<OA\AbstractAnnotation> $annotations
     */
    public function addAnnotations(array $annotations, Context $context): void
    {
        foreach ($annotations as $annotation) {
            $this->addAnnotation($annotation, $context);
        }
    }

    public function addClassDefinition(array $definition): void
    {
        $class = $definition['context']->fullyQualifiedName($definition['class']);
        $this->classes[$class] = $definition;
    }

    public function addInterfaceDefinition(array $definition): void
    {
        $interface = $definition['context']->fullyQualifiedName($definition['interface']);
        $this->interfaces[$interface] = $definition;
    }

    public function addTraitDefinition(array $definition): void
    {
        $trait = $definition['context']->fullyQualifiedName($definition['trait']);
        $this->traits[$trait] = $definition;
    }

    public function addEnumDefinition(array $definition): void
    {
        $enum = $definition['context']->fullyQualifiedName($definition['enum']);
        $this->enums[$enum] = $definition;
    }

    public function addAnalysis(Analysis $analysis): void
    {
        foreach ($analysis->annotations as $annotation) {
            $this->addAnnotation($annotation, $analysis->annotations[$annotation]);
        }
        $this->classes = array_merge($this->classes, $analysis->classes);
        $this->interfaces = array_merge($this->interfaces, $analysis->interfaces);
        $this->traits = array_merge($this->traits, $analysis->traits);
        $this->enums = array_merge($this->enums, $analysis->enums);
        if (!$this->openapi instanceof OA\OpenApi && $analysis->openapi instanceof OA\OpenApi) {
            $this->openapi = $analysis->openapi;
        }
    }

    /**
     * Get all subclasses of the given parent class.
     *
     * @param class-string $parent the parent class
     *
     * @return array map of class => definition pairs of sub-classes
     */
    public function getSubClasses(string $parent): array
    {
        $definitions = [];
        foreach ($this->classes as $class => $classDefinition) {
            if ($classDefinition['extends'] === $parent) {
                $definitions[$class] = $classDefinition;
                $definitions = array_merge($definitions, $this->getSubClasses($class));
            }
        }

        return $definitions;
    }

    /**
     * Get a list of all super classes for the given class.
     *
     * @param class-string|null $class  the class name
     * @param bool              $direct flag to find only the actual class parents
     *
     * @return array map of class => definition pairs of parent classes
     */
    public function getSuperClasses(?string $class, bool $direct = false): array
    {
        $classDefinition = $this->classes[$class ?? ''] ?? null;
        if (!$classDefinition || empty($classDefinition['extends'])) {
            // unknown class, or no inheritance
            return [];
        }

        $extends = $classDefinition['extends'];
        $extendsDefinition = $this->classes[$extends] ?? null;
        if (!$extendsDefinition) {
            return [];
        }

        $parentDetails = [$extends => $extendsDefinition];

        if ($direct) {
            return $parentDetails;
        }

        return array_merge($parentDetails, $this->getSuperClasses($extends));
    }

    /**
     * Get the list of interfaces used by the given class or by classes which it extends.
     *
     * @param class-string|null $class  the class name
     * @param bool              $direct flag to find only the actual class interfaces
     *
     * @return array map of class => definition pairs of interfaces
     */
    public function getInterfacesOfClass(?string $class, bool $direct = false): array
    {
        $classes = $direct ? [] : array_keys($this->getSuperClasses($class));
        // add self
        $classes[] = $class;

        $definitions = [];
        foreach ($classes as $clazz) {
            if (isset($this->classes[$clazz])) {
                $definition = $this->classes[$clazz];
                if (isset($definition['implements'])) {
                    foreach ($definition['implements'] as $interface) {
                        if (array_key_exists($interface, $this->interfaces)) {
                            $definitions[$interface] = $this->interfaces[$interface];
                        }
                    }
                }
            }
        }

        if (!$direct) {
            // expand recursively for interfaces extending other interfaces
            $collect = function ($interfaces, $cb) use (&$definitions): void {
                foreach ($interfaces as $interface) {
                    if (isset($this->interfaces[$interface]['extends'])) {
                        $cb($this->interfaces[$interface]['extends'], $cb);
                        foreach ($this->interfaces[$interface]['extends'] as $fqdn) {
                            $definitions[$fqdn] = $this->interfaces[$fqdn];
                        }
                    }
                }
            };
            $collect(array_keys($definitions), $collect);
        }

        return $definitions;
    }

    /**
     * Get the list of traits used by the given class/trait or by classes which it extends.
     *
     * @param string|null $source the source name
     * @param bool        $direct flag to find only the actual class traits
     *
     * @return array map of class => definition pairs of traits
     */
    public function getTraitsOfClass(?string $source, bool $direct = false): array
    {
        $sources = $direct ? [] : array_keys($this->getSuperClasses($source));
        // add self
        $sources[] = $source;

        $definitions = [];
        foreach ($sources as $sourze) {
            if (isset($this->classes[$sourze]) || isset($this->traits[$sourze])) {
                $definition = $this->classes[$sourze] ?? $this->traits[$sourze];
                if (isset($definition['traits'])) {
                    foreach ($definition['traits'] as $trait) {
                        if (array_key_exists($trait, $this->traits)) {
                            $definitions[$trait] = $this->traits[$trait];
                        }
                    }
                }
            }
        }

        if (!$direct) {
            // expand recursively for traits using other traits
            $collect = function ($traits, $cb) use (&$definitions): void {
                foreach ($traits as $trait) {
                    if (isset($this->traits[$trait]['traits'])) {
                        $cb($this->traits[$trait]['traits'], $cb);
                        foreach ($this->traits[$trait]['traits'] as $fqdn) {
                            $definitions[$fqdn] = $this->traits[$fqdn];
                        }
                    }
                }
            };
            $collect(array_keys($definitions), $collect);
        }

        return $definitions;
    }

    /**
     * @template T extends OA\AbstractAnnotation
     *
     * @param class-string<T>|list<class-string<T>> $classes one or more class names
     * @param bool                                  $strict  in non-strict mode child classes are also detected
     *
     * @return list<T>
     */
    public function getAnnotationsOfType($classes, bool $strict = false): array
    {
        $unique = new \SplObjectStorage();
        $annotations = [];

        foreach ((array) $classes as $class) {
            /** @var OA\AbstractAnnotation $annotation */
            foreach ($this->annotations as $annotation) {
                if ($annotation instanceof $class && (!$strict || ($annotation->isRoot($class) && !$unique->offsetExists($annotation)))) {
                    $unique->offsetSet($annotation);
                    $annotations[] = $annotation;
                }
            }
        }

        return $annotations;
    }

    /**
     * @template T of OA\AbstractAnnotation
     *
     * @param  string          $fqdn        the source class/interface/trait
     * @param  class-string<T> $sourceClass
     * @return T|null
     */
    public function getAnnotationForSource(string $fqdn, string $sourceClass = OA\Schema::class): ?OA\AbstractAnnotation
    {
        $fqdn = '\\' . ltrim($fqdn, '\\');

        foreach ([$this->classes, $this->interfaces, $this->traits, $this->enums] as $definitions) {
            if (array_key_exists($fqdn, $definitions)) {
                $definition = $definitions[$fqdn];
                if (is_iterable($definition['context']->annotations)) {
                    /** @var OA\AbstractAnnotation $annotation */
                    foreach (array_reverse($definition['context']->annotations) as $annotation) {
                        if ($annotation instanceof $sourceClass && $annotation->isRoot($sourceClass) && !$annotation->_context->is('generated')) {
                            return $annotation;
                        }
                    }
                }
            }
        }

        return null;
    }

    /**
     * Build an analysis with only the annotations that are merged into the OpenAPI annotation.
     */
    public function merged(): Analysis
    {
        if (!$this->openapi instanceof OA\OpenApi) {
            throw new OpenApiException('No openapi target set. Run the MergeIntoOpenApi processor');
        }
        $unmerged = $this->openapi->_unmerged;
        $this->openapi->_unmerged = [];
        $analysis = new Analysis([$this->openapi], $this->context);
        $this->openapi->_unmerged = $unmerged;

        return $analysis;
    }

    /**
     * Analysis with only the annotations that not merged.
     */
    public function unmerged(): Analysis
    {
        return $this->split()->unmerged;
    }

    /**
     * Split the annotation into two analysis.
     * One with annotations that are merged and one with annotations that are not merged.
     *
     * @return \stdClass {merged: Analysis, unmerged: Analysis}
     */
    public function split(): \stdClass
    {
        $result = new \stdClass();
        $result->merged = $this->merged();
        $result->unmerged = new Analysis([], $this->context);
        foreach ($this->annotations as $annotation) {
            if ($result->merged->annotations->offsetExists($annotation) === false) {
                $result->unmerged->annotations->offsetSet($annotation, $this->annotations[$annotation]);
            }
        }

        return $result;
    }

    public function validate(): bool
    {
        if (!$this->openapi instanceof OA\OpenApi) {
            $this->context->logger->warning('No openapi target set. Run the MergeIntoOpenApi processor before validate()');

            return false;
        }

        $isValid = true;
        $version = $this->openapi->openapi;
        $context = new \stdClass();

        foreach ($this->collectAnnotations($this->openapi) as $annotation) {
            $isValid = $annotation->validate($this, $version, $context) && $isValid;
        }

        return $isValid;

    }

    /**
     * @return array<OA\AbstractAnnotation>
     */
    protected function collectAnnotations(OA\AbstractAnnotation $root): array
    {
        $annotations = [$root];

        foreach (get_object_vars($root) as $field => $value) {
            if (null === $value || Generator::isDefault($value) || is_scalar($value) || in_array($field, $root::$_blacklist)) {
                continue;
            }

            if ($value instanceof OA\AbstractAnnotation) {
                $annotations = array_merge($annotations, $this->collectAnnotations($value));
            } elseif (is_array($value)) {
                foreach ($value as $item) {
                    if ($item instanceof OA\AbstractAnnotation) {
                        $annotations = array_merge($annotations, $this->collectAnnotations($item));
                    }
                }
            }
        }

        return $annotations;
    }
}
