<?php
namespace Newland\NeosCommon\Command;

use Neos\Eel\ProtectedContextAwareInterface;
use Neos\Flow\Annotations as Flow;
use phpDocumentor\Reflection\DocBlock;
use phpDocumentor\Reflection\DocBlock\Tags\Param;
use phpDocumentor\Reflection\DocBlock\Tags\Return_;
use phpDocumentor\Reflection\DocBlockFactory;
use phpDocumentor\Reflection\Type;
use ReflectionClass;
use ReflectionMethod;
use Neos\Flow\Cli\CommandController;

/**
 * @Flow\Scope("singleton")
 */
class DocumentationCommandController extends CommandController
{

    /**
     * @var array
     * @Flow\InjectConfiguration(path="defaultContext", package="Neos.Fusion")
     */
    protected $fusionHelperConfiguration;

    /**
     * Prints documentation for all fusion helpers that are available.
     * If the `filter` argument is present then only the helpers that match
     * the given filter are printed.
     *
     * @param string|null $filter
     */
    public function eelHelperCommand(string $filter = null): void
    {
        foreach ($this->fusionHelperConfiguration as $name => $class) {
            $reflection = new ReflectionClass($class);
            foreach ($this->getMethods($reflection) as $method) {
                $helperName = $name . '.' . $method->getName();
                if ($filter !== null && strpos($helperName, $filter) === false) {
                    continue;
                }

                $documentation = trim($this->getDocumentation($method), " \t\n\r");
                $argumentsAndReturn = $this->getArgumentsAndReturnType($method);

                $this->output->outputLine("\n - " . $helperName . $argumentsAndReturn);
                if ($documentation) {
                    $this->output->outputLine("\t" . $documentation);
                }
            }
        }
    }

    /**
     * @param ReflectionClass<object> $class
     * @return ReflectionMethod[]
     */
    private function getMethods(ReflectionClass $class): array
    {
        $className = $class->getName();
        /** @var ProtectedContextAwareInterface $instance */
        $instance = new $className();
        return array_filter(
            $class->getMethods(),
            function (ReflectionMethod $method) use ($instance) {
                return $method->isPublic() &&
                    substr($method->getName(), 0, 5) !== 'Flow_' &&
                    $method->getName()[0] !== '_' &&
                    $method->getName() !== 'allowsCallOfMethod' &&
                    $instance->allowsCallOfMethod($method->getName());
            }
        );
    }

    private function getDocumentation(ReflectionMethod $method): string
    {
        if (!$method->getDocComment()) {
            return '';
        }
        $docBlock = DocBlockFactory::createInstance()->create((string) $method->getDocComment());
        $comment = $docBlock->getSummary() . "\n\n" . $docBlock->getDescription();

        $lines = explode("\n", $comment);
        $lines = array_map(
            function (string $line) {
                return "\t" . $line;
            },
            $lines
        );
        return implode("\n", $lines);
    }


    private function getArgumentsAndReturnType(ReflectionMethod $method): string
    {
        // Try to get information out of reflection first
        $returnType = $this->getReturnTypeFromReflection($method);
        $arguments = $this->getArgumentsFromReflection($method);

        // Fill in the rest with docblocks
        if ($method->getDocComment()) {
            $docBlock = DocBlockFactory::createInstance()->create((string) $method->getDocComment());
            $arguments = array_replace($this->getArgumentsFromDocBlock($docBlock), $arguments);
            $returnType = $returnType ?: $this->getReturnTypeFromDocBlock($docBlock);
        }

        // Format
        $formattedArguments = [];
        foreach ((array) $arguments as $name => $type) {
            $formattedArguments[] = '$' . $name . ($type ? ': ' . $this->prettyPrintType($type) : '');
        }
        $formattedString = '(' . implode(', ', $formattedArguments) . ')';
        if ($returnType) {
            $formattedString .= ': ' . $this->prettyPrintType($returnType);
        }

        return $formattedString;
    }


    /**
     * Returns an associative array of [ name => type ] extracted from type hints.
     *
     * @param ReflectionMethod $method
     * @return array
     */
    private function getArgumentsFromReflection(ReflectionMethod $method): array
    {
        $arguments = [];
        foreach ($method->getParameters() as $parameter) {
            $type = $parameter->getType();
            $arguments[$parameter->getName()] = $type ? $type->getName() : null;
        }
        return $arguments;
    }

    /**
     * Returns an associative array of [ name => type ] extracted from the given docBlock.
     *
     * @param DocBlock $docBlock
     * @return array
     */
    private function getArgumentsFromDocBlock(DocBlock $docBlock): array
    {
        $arguments = [];
        /** @var Param[] $paramTags */
        $paramTags = $docBlock->getTagsByName('param');
        foreach ($paramTags as $tag) {
            $arguments[$tag->getVariableName()] = $tag->getType();
        }
        return $arguments;
    }

    private function getReturnTypeFromReflection(ReflectionMethod $method): string
    {
        $reflectionReturn = $method->getReturnType();
        return $reflectionReturn ? $reflectionReturn->getName() : '';
    }

    private function getReturnTypeFromDocBlock(DocBlock $docBlock): string
    {
        /** @var Return_[] $returnTags */
        $returnTags = $docBlock->getTagsByName('return');
        foreach ($returnTags as $tag) {
            /** @var Type|null $type */
            $type = $tag->getType();
            if ($type) {
                return (string) $type;
            }
        }
        return '';
    }

    /**
     * Pretty prints the given type name (Removes the namespace if it is a classname).
     *
     * @param string $type
     * @return string
     */
    private function prettyPrintType(string $type): string
    {
        $types = array_map(
            function (string $part) {
                if (strpos($part, '\\') !== false) {
                    $parts = explode('\\', $part);
                    $part = array_pop($parts);
                }
                return $part;
            },
            explode('|', $type)
        );

        return implode('|', $types);
    }
}
