<?php

/*
 * This file is part of the Behat.
 * (c) Konstantin Kudryashov <ever.zet@gmail.com>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace Behat\Behat\Context\Snippet\Generator;

use Behat\Behat\Context\Environment\ContextEnvironment;
use Behat\Behat\Context\Snippet\ContextSnippet;
use Behat\Behat\Definition\Pattern\PatternTransformer;
use Behat\Behat\Snippet\Exception\EnvironmentSnippetGenerationException;
use Behat\Behat\Snippet\Generator\SnippetGenerator;
use Behat\Gherkin\Node\PyStringNode;
use Behat\Gherkin\Node\StepNode;
use Behat\Gherkin\Node\TableNode;
use Behat\Testwork\Environment\Environment;
use ReflectionClass;

/**
 * Generates snippets for a context class.
 *
 * @author Konstantin Kudryashov <ever.zet@gmail.com>
 */
final class ContextSnippetGenerator implements SnippetGenerator
{
    /**
     * @var string[string]
     */
    private static $proposedMethods = array();
    /**
     * @var string
     */
    private static $templateTemplate = <<<TPL
    /**
     * @%%s %s
     */
    public function %s(%s)
    {
        throw new PendingException();
    }
TPL;
    /**
     * @var PatternTransformer
     */
    private $patternTransformer;
    /**
     * @var TargetContextIdentifier
     */
    private $contextIdentifier;
    /**
     * @var PatternIdentifier
     */
    private $patternIdentifier;

    /**
     * Initializes snippet generator.
     *
     * @param PatternTransformer $patternTransformer
     */
    public function __construct(PatternTransformer $patternTransformer)
    {
        $this->patternTransformer = $patternTransformer;

        $this->setContextIdentifier(new FixedContextIdentifier(null));
        $this->setPatternIdentifier(new FixedPatternIdentifier(null));
    }

    /**
     * Sets target context identifier.
     *
     * @param TargetContextIdentifier $identifier
     */
    public function setContextIdentifier(TargetContextIdentifier $identifier)
    {
        $this->contextIdentifier = new CachedContextIdentifier($identifier);
    }

    /**
     * Sets target pattern type identifier.
     *
     * @param PatternIdentifier $identifier
     */
    public function setPatternIdentifier(PatternIdentifier $identifier)
    {
        $this->patternIdentifier = $identifier;
    }

    /**
     * {@inheritdoc}
     */
    public function supportsEnvironmentAndStep(Environment $environment, StepNode $step)
    {
        if (!$environment instanceof ContextEnvironment) {
            return false;
        }

        if (!$environment->hasContexts()) {
            return false;
        }

        return null !== $this->contextIdentifier->guessTargetContextClass($environment);
    }

    /**
     * {@inheritdoc}
     */
    public function generateSnippet(Environment $environment, StepNode $step)
    {
        if (!$environment instanceof ContextEnvironment) {
            throw new EnvironmentSnippetGenerationException(sprintf(
                'ContextSnippetGenerator does not support `%s` environment.',
                get_class($environment)
            ), $environment);
        }

        $contextClass = $this->contextIdentifier->guessTargetContextClass($environment);
        $patternType = $this->patternIdentifier->guessPatternType($contextClass);
        $stepText = $step->getText();
        $pattern = $this->patternTransformer->generatePattern($patternType, $stepText);

        $methodName = $this->getMethodName($contextClass, $pattern->getCanonicalText(), $pattern->getPattern());
        $methodArguments = $this->getMethodArguments($step, $pattern->getPlaceholderCount());
        $snippetTemplate = $this->getSnippetTemplate($pattern->getPattern(), $methodName, $methodArguments);

        $usedClasses = $this->getUsedClasses($step);

        return new ContextSnippet($step, $snippetTemplate, $contextClass, $usedClasses);
    }

    /**
     * Generates method name using step text and regex.
     *
     * @param string $contextClass
     * @param string $canonicalText
     * @param string $pattern
     *
     * @return string
     */
    private function getMethodName($contextClass, $canonicalText, $pattern)
    {
        $methodName = $this->deduceMethodName($canonicalText);
        $methodName = $this->getUniqueMethodName($contextClass, $pattern, $methodName);

        return $methodName;
    }

    /**
     * Returns an array of method argument names from step and token count.
     *
     * @param StepNode $step
     * @param integer  $tokenCount
     *
     * @return string[]
     */
    private function getMethodArguments(StepNode $step, $tokenCount)
    {
        $args = array();
        for ($i = 0; $i < $tokenCount; $i++) {
            $args[] = '$arg' . ($i + 1);
        }

        foreach ($step->getArguments() as $argument) {
            $args[] = $this->getMethodArgument($argument);
        }

        return $args;
    }

    /**
     * Returns an array of classes used by the snippet template
     *
     * @param StepNode $step
     *
     * @return string[]
     */
    private function getUsedClasses(StepNode $step)
    {
        $usedClasses = array('Behat\Behat\Tester\Exception\PendingException');

        foreach ($step->getArguments() as $argument) {
            if ($argument instanceof TableNode) {
                $usedClasses[] = 'Behat\Gherkin\Node\TableNode';
            } elseif ($argument instanceof PyStringNode) {
                $usedClasses[] = 'Behat\Gherkin\Node\PyStringNode';
            }
        }

        return $usedClasses;
    }

    /**
     * Generates snippet template using regex, method name and arguments.
     *
     * @param string   $pattern
     * @param string   $methodName
     * @param string[] $methodArguments
     *
     * @return string
     */
    private function getSnippetTemplate($pattern, $methodName, array $methodArguments)
    {
        return sprintf(
            self::$templateTemplate,
            str_replace('%', '%%', $pattern),
            $methodName,
            implode(', ', $methodArguments)
        );
    }

    /**
     * Generates definition method name based on the step text.
     *
     * @param string $canonicalText
     *
     * @return string
     */
    private function deduceMethodName($canonicalText)
    {
        // check that method name is not empty
        if (0 !== strlen($canonicalText)) {
            $canonicalText[0] = strtolower($canonicalText[0]);

            return $canonicalText;
        }

        return 'stepDefinition1';
    }

    /**
     * Ensures uniqueness of the method name in the context.
     *
     * @param string $contextClass
     * @param string $stepPattern
     * @param string $name
     *
     * @return string
     */
    private function getUniqueMethodName($contextClass, $stepPattern, $name)
    {
        $reflection = new ReflectionClass($contextClass);

        $number = $this->getMethodNumberFromTheMethodName($name);
        list($name, $number) = $this->getMethodNameNotExistentInContext($reflection, $name, $number);
        $name = $this->getMethodNameNotProposedEarlier($contextClass, $stepPattern, $name, $number);

        return $name;
    }

    /**
     * Tries to deduct method number from the provided method name.
     *
     * @param string $methodName
     *
     * @return integer
     */
    private function getMethodNumberFromTheMethodName($methodName)
    {
        $methodNumber = 2;
        if (preg_match('/(\d+)$/', $methodName, $matches)) {
            $methodNumber = intval($matches[1]);
        }

        return $methodNumber;
    }

    /**
     * Tries to guess method name that is not yet defined in the context class.
     *
     * @param ReflectionClass $reflection
     * @param string          $methodName
     * @param integer         $methodNumber
     *
     * @return array
     */
    private function getMethodNameNotExistentInContext(ReflectionClass $reflection, $methodName, $methodNumber)
    {
        while ($reflection->hasMethod($methodName)) {
            $methodName = preg_replace('/\d+$/', '', $methodName);
            $methodName .= $methodNumber++;
        }

        return array($methodName, $methodNumber);
    }

    /**
     * Tries to guess method name that is not yet proposed to the context class.
     *
     * @param string  $contextClass
     * @param string  $stepPattern
     * @param string  $name
     * @param integer $number
     *
     * @return string
     */
    private function getMethodNameNotProposedEarlier($contextClass, $stepPattern, $name, $number)
    {
        foreach ($this->getAlreadyProposedMethods($contextClass) as $proposedPattern => $proposedMethod) {
            if ($proposedPattern === $stepPattern) {
                continue;
            }

            while ($proposedMethod === $name) {
                $name = preg_replace('/\d+$/', '', $name);
                $name .= $number++;
            }
        }

        $this->markMethodAsAlreadyProposed($contextClass, $stepPattern, $name);

        return $name;
    }

    /**
     * Returns already proposed method names.
     *
     * @param string $contextClass
     *
     * @return string[]
     */
    private function getAlreadyProposedMethods($contextClass)
    {
        return self::$proposedMethods[$contextClass] ?? array();
    }

    /**
     * Marks method as proposed one.
     *
     * @param string $contextClass
     * @param string $stepPattern
     * @param string $methodName
     */
    private function markMethodAsAlreadyProposed($contextClass, $stepPattern, $methodName)
    {
        self::$proposedMethods[$contextClass][$stepPattern] = $methodName;
    }

    /**
     * Returns method argument.
     *
     * @param string $argument
     *
     * @return string
     */
    private function getMethodArgument($argument)
    {
        $arg = '__unknown__';
        if ($argument instanceof PyStringNode) {
            $arg = 'PyStringNode $string';
        } elseif ($argument instanceof TableNode) {
            $arg = 'TableNode $table';
        }

        return $arg;
    }
}
