<?php
/**
 * Copyright 2020 Adobe
 * All Rights Reserved.
 *
 * NOTICE: Adobe permits you to use, modify, and distribute this file in
 * accordance with the terms of the Adobe license agreement accompanying
 * it.
 */
declare(strict_types=1);

namespace Magento\Mray\CodeStructuralElement\Php\Reflection\Context;

use Magento\Mray\CodeStructuralElement\Php\Reflection\ClassLikeDeclaration;
use Magento\Mray\CodeStructuralElement\Php\Reflection\ClassLikeMemberDeclaration;
use Magento\Mray\CodeStructuralElement\Php\Reflection\FunctionDeclaration;
use Magento\Mray\CodeStructuralElement\Php\Reflection\GlobalConstantDeclaration;
use Magento\Mray\CodeStructuralElement\Php\Reflection\ConstantDeclaration;
use Magento\Mray\CodeStructuralElement\Php\Reflection\MethodDeclaration;
use Magento\Mray\CodeStructuralElement\Php\Reflection\PropertyDeclaration;
use Magento\Mray\CodeStructuralElement\Php\Reflection\Type\CodeDiscoveryDependentType;
use Magento\Mray\CodeStructuralElement\Php\Reflection\TypeFactory;
use phpDocumentor\Reflection\Type;
use phpDocumentor\Reflection\Types;
use function array_map;
use function array_push;
use function array_unique;
use function array_values;
use function ltrim;
use function strtolower;

class StructuralElementDeclarations implements StructuralElementsKnowledgeBase
{
    /** @var (ClassLikeDeclaration[])[]  */
    private $classLike = [];
    /** @var (FunctionDeclaration[])[]  */
    private $functions = [];
    /** @var (GlobalConstantDeclaration[])[] */
    private $globalConstants = [];

    /** @var StructuralElementsKnowledgeBase[]  */
    private $knowledgeBases = [];

    /**
     * @param StructuralElementsKnowledgeBase $knowledgeBase
     */
    public function withKnowledge(StructuralElementsKnowledgeBase $knowledgeBase)
    {
        $this->knowledgeBases[] = $knowledgeBase;
    }

    /**
     * @param ClassLikeDeclaration $def
     */
    public function registerClassLike(ClassLikeDeclaration $def)
    {
        if (!$def->getName()) {
            // todo: implement support of anonymous classes
            return;
        }
        $this->classLike[$this->normalizeName($def->getName())][] = $def;
    }

    /**
     * @param string $name
     * @return ClassLikeDeclaration[]
     */
    public function findClassLike(string $name): array
    {
        $variations = $this->classLike[$this->normalizeName($name)] ?? [];
        foreach ($this->knowledgeBases as $knowledgeBase) {
            array_push($variations, ...$knowledgeBase->findClassLike($name));
        }
        return $variations;
    }

    /**
     * @param FunctionDeclaration $def
     */
    public function registerFunction(FunctionDeclaration $def)
    {
        if (!$def->getName()) {
            // error in name parsing, ignore
            return;
        }
        $this->functions[$this->normalizeName($def->getName())][] = $def;
    }

    /**
     * @param string $name
     * @return FunctionDeclaration[]
     */
    public function findFunction(string $name): array
    {
        $variations = $this->functions[$this->normalizeName($name)] ?? [];
        foreach ($this->knowledgeBases as $knowledgeBase) {
            array_push($variations, ...$knowledgeBase->findFunction($name));
        }
        return $variations;
    }

    /**
     * @param GlobalConstantDeclaration $def
     */
    public function registerGlobalConstant(GlobalConstantDeclaration $def)
    {
        if (!$def->getName()) {
            // error in name parsing, ignore
            return;
        }
        $this->globalConstants[$this->normalizeName($def->getName())][] = $def;
    }

    /**
     * @param string $name
     * @return GlobalConstantDeclaration[]
     */
    public function findGlobalConstant(string $name): array
    {
        $variations = $this->globalConstants[$this->normalizeName($name)] ?? [];
        foreach ($this->knowledgeBases as $knowledgeBase) {
            array_push($variations, ...$knowledgeBase->findGlobalConstant($name));
        }
        return $variations;
    }

    /**
     * @param Type $type
     * @param string|null $scope
     * @return array
     */
    public function findClassConstants(Type $type, ?string $scope = null): array
    {
        return $this->findMembers($type, $scope, function (ClassLikeMemberDeclaration $m) {
            return $m instanceof MethodDeclaration;
        });
    }

    /**
     * @param Type $type
     * @param string|null $scope
     * @return array
     */
    public function findProperties(Type $type, ?string $scope = null): array
    {
        return $this->findMembers($type, $scope, function (ClassLikeMemberDeclaration $m) {
            return $m instanceof PropertyDeclaration;
        });
    }

    /**
     * @param Type $type
     * @param string|null $scope
     * @return array
     */
    public function findMethods(Type $type, ?string $scope = null): array
    {
        return $this->findMembers($type, $scope, function (ClassLikeMemberDeclaration $m) {
            return $m instanceof ConstantDeclaration;
        });
    }

    /**
     * @param Type $type
     * @param string $memberClass
     * @param string $memberName
     * @return array
     */
    public function estimateMembers(Type $type, string $memberClass, string $memberName): array
    {
        return $this->findMembers($type, '*', function (ClassLikeMemberDeclaration $m) use ($memberClass, $memberName) {
            return $m instanceof $memberClass && $m->getName() === $memberName;
        });
    }

    /**
     * @param Type $type
     * @param string $memberClass
     * @param string $memberName
     * @return Type
     */
    public function estimateMemberValueType(Type $type, string $memberClass, string $memberName): Type
    {
        $ms = $this->estimateMembers($type, $memberClass, $memberName);
        $mst = array_values(array_unique(array_map(function (ClassLikeMemberDeclaration $m) {
            return $m->getValueType();
        }, $ms)));
        switch (count($mst)) {
            case 1:
                return $mst[0];
            case 0:
                return TypeFactory::get(Types\Mixed_::class);
            default:
                return TypeFactory::get(Types\Compound::class, $mst);
        }
    }

    /**
     * @param Type $type
     * @param string|null $scope
     * @param callable $filter
     * @return array
     */
    private function findMembers(Type $type, ?string $scope, callable $filter): array
    {
        $members = [];
        foreach ($this->getClassLikeNames($type) as $classLike) {
            $visibility = $this->getVisibility($classLike, $scope);
            array_push($members, ...$this->listMembers($classLike, $visibility, $filter));
        }
        return $members;
    }

    /**
     * @param string $classLike
     * @param string $visibility
     * @param callable $filter
     * @return array
     */
    private function listMembers(string $classLike, string $visibility, callable $filter): array
    {
        $defs = $this->findClassLike($classLike);
        if (!$defs) {
            return [];
        }

        $members = [];
        foreach ($defs as $def) {
            foreach ($def->getMembers() as $m) {
                if (!$this->isVisible($m, $visibility)) {
                    continue;
                }
                if (!$filter($m)) {
                    continue;
                }
                $members[] = $m;
            }
        }

        return $members;
    }

    /**
     * @param ClassLikeMemberDeclaration $m
     * @param string $v
     * @return bool
     */
    private function isVisible(ClassLikeMemberDeclaration $m, string $v)
    {
        switch ($v) {
            case 'private':
                return $m->isPublic() || $m->isProtected() || $m->isPrivate();
            case 'protected':
                return $m->isPublic() || $m->isProtected();
            default:
                return $m->isPublic();
        }
    }

    /**
     * @param string $classLike
     * @param string|null $scope
     * @return string
     */
    private function getVisibility(string $classLike, ?string $scope)
    {
        if ($classLike === $scope || $scope === '*') {
            return 'private';
        }

        if (!$scope) {
            return 'public';
        }

        if ($this->providesImplementation($classLike, $this->normalizeName($scope))) {
            return 'protected';
        }

        return 'public';
    }

    /**
     * @param string $maybeParent
     * @param string $child
     * @return bool
     */
    private function providesImplementation(string $maybeParent, string $child): bool
    {
        $childDef = $this->findClassLike($child);
        if (!$childDef) {
            return false;
        }

        foreach ($childDef as $childVDef) {
            foreach ($childVDef->getImplementationProviders() as $implProvider) {
                $implProvider = $this->normalizeName($implProvider);
                if ($implProvider === $maybeParent) {
                    return true;
                }
                if ($this->providesImplementation($maybeParent, $implProvider)) {
                    return true;
                }
            }
        }

        return false;
    }

    /**
     * @param string $name
     * @return string
     */
    private function normalizeName(string $name): string
    {
        return ltrim(strtolower($name), '\\');
    }

    /**
     * @param Type $type
     * @return array|string[]
     */
    private function getClassLikeNames(Type $type): array
    {
        if ($type instanceof Types\Object_) {
            return $type->getFqsen() ? [$this->normalizeName((string)$type)] : [];
        } elseif ($type instanceof Types\Nullable) {
            return $this->getClassLikeNames($type->getActualType());
        } elseif ($type instanceof Types\AggregatedType) {
            $names = [];
            foreach ($type->getIterator() as $subtype) {
                array_push($names, ...$this->getClassLikeNames($subtype));
            }
            return $names;
        } elseif ($type instanceof CodeDiscoveryDependentType) {
            return $this->getClassLikeNames($type->getEstimatedType());
        }

        return [];
    }
}
