<?php

declare(strict_types=1);

namespace Codeception\Test;

use Behat\Gherkin\Node\FeatureNode;
use Behat\Gherkin\Node\ScenarioInterface;
use Behat\Gherkin\Node\ScenarioNode;
use Behat\Gherkin\Node\StepNode;
use Behat\Gherkin\Node\TableNode;
use Codeception\Lib\Di;
use Codeception\Lib\Generator\GherkinSnippets;
use Codeception\Scenario;
use Codeception\Step\Comment;
use Codeception\Step\Meta;
use Codeception\Test\Interfaces\Reported;
use Codeception\Test\Interfaces\ScenarioDriven;
use Exception;

use function array_merge;
use function array_pop;
use function array_shift;
use function basename;
use function call_user_func_array;
use function count;
use function explode;
use function file_get_contents;
use function is_array;
use function is_string;
use function preg_match;
use function var_export;

class Gherkin extends Test implements ScenarioDriven, Reported
{
    protected array $steps = [];

    protected FeatureNode $featureNode;

    protected ScenarioInterface $scenarioNode;

    protected Scenario $scenario;

    public function __construct(FeatureNode $featureNode, ScenarioInterface $scenarioNode, array $steps = [])
    {
        $this->featureNode = $featureNode;
        $this->scenarioNode = $scenarioNode;
        $this->steps = $steps;
        $this->setMetadata(new Metadata());
        $this->scenario = new Scenario($this);
        $this->getMetadata()->setName($scenarioNode->getTitle());
        $this->getMetadata()->setFeature((string)$featureNode->getTitle());
        $this->getMetadata()->setFilename($featureNode->getFile());
    }

    public function __clone(): void
    {
        $this->scenario = clone $this->scenario;
    }

    public function preload(): void
    {
        $this->getMetadata()->setGroups($this->featureNode->getTags());
        $this->getMetadata()->setGroups($this->scenarioNode->getTags());
        $this->scenario->setMetaStep(null);

        if (($background = $this->featureNode->getBackground()) !== null) {
            foreach ($background->getSteps() as $step) {
                $this->validateStep($step);
            }
        }

        foreach ($this->scenarioNode->getSteps() as $step) {
            $this->validateStep($step);
        }
        if ($this->getMetadata()->getIncomplete()) {
            $this->getMetadata()->setIncomplete($this->getMetadata()->getIncomplete() . "\nRun gherkin:snippets to define missing steps");
        }
    }

    public function getSignature(): string
    {
        return basename($this->getFileName(), '.feature') . ':' . $this->getScenarioTitle();
    }

    public function test(): void
    {
        $this->makeContexts();
        $description = explode("\n", (string)$this->featureNode->getDescription());
        foreach ($description as $line) {
            $this->getScenario()->runStep(new Comment($line));
        }

        if (($background = $this->featureNode->getBackground()) !== null) {
            foreach ($background->getSteps() as $step) {
                $this->runStep($step);
            }
        }

        foreach ($this->scenarioNode->getSteps() as $step) {
            $this->runStep($step);
        }
    }

    protected function validateStep(StepNode $stepNode): void
    {
        $stepText = $stepNode->getText();
        if (GherkinSnippets::stepHasPyStringArgument($stepNode)) {
            $stepText .= ' ""';
        }
        $matches = [];
        foreach ($this->steps as $pattern => $context) {
            $res = preg_match($pattern, $stepText);
            if (!$res) {
                continue;
            }
            $matches[$pattern] = $context;
        }
        if ($matches === []) {
            // There were no matches, meaning that the user should first add a step definition for this step
            $incomplete = $this->getMetadata()->getIncomplete();
            $this->getMetadata()->setIncomplete("{$incomplete}\nStep definition for `{$stepText}` not found in contexts");
        }
        if (count($matches) > 1) {
            // There were more than one match, meaning that we don't know which step definition to execute for this step
            $incomplete = $this->getMetadata()->getIncomplete();
            $matchingDefinitions = [];
            foreach ($matches as $pattern => $context) {
                $matchingDefinitions[] = '- ' . $pattern . ' (' . self::contextAsString($context) . ')';
            }
            $this->getMetadata()->setIncomplete(
                "{$incomplete}\nAmbiguous step: `{$stepText}` matches multiple definitions:\n"
                . implode("\n", $matchingDefinitions)
            );
        }
    }

    private function contextAsString($context): string
    {
        if (is_array($context) && count($context) === 2) {
            [$class, $method] = $context;

            if (is_string($class) && is_string($method)) {
                return $class . ':' . $method;
            }
        }

        return var_export($context, true);
    }

    protected function runStep(StepNode $stepNode): void
    {
        $params = [];
        if ($stepNode->hasArguments()) {
            $args = $stepNode->getArguments();
            $table = $args[0];
            if ($table instanceof TableNode) {
                $params = [$table->getTableAsString()];
            }
        }
        $meta = new Meta($stepNode->getText(), $params);
        $meta->setPrefix($stepNode->getKeyword());

        $this->scenario->setMetaStep($meta); // enable metastep
        $stepText = $stepNode->getText();
        $hasPyStringArg = GherkinSnippets::stepHasPyStringArgument($stepNode);
        if ($hasPyStringArg) {
            // pretend it is inline argument
            $stepText .= ' ""';
        }
        $this->getScenario()->comment(''); // make metastep to be printed even if no steps in it
        foreach ($this->steps as $pattern => $context) {
            $matches = [];
            if (!preg_match($pattern, $stepText, $matches)) {
                continue;
            }
            array_shift($matches);
            if ($hasPyStringArg) {
                // get rid off last fake argument
                array_pop($matches);
            }
            if ($stepNode->hasArguments()) {
                $matches = array_merge($matches, $stepNode->getArguments());
            }
            call_user_func_array($context, $matches); // execute the step
            break;
        }
        $this->scenario->setMetaStep(null); // disable metastep
    }

    protected function makeContexts(): void
    {
        /** @var Di $di */
        $di = $this->getMetadata()->getService('di');
        $di->set($this->getScenario());

        $actorClass = $this->getMetadata()->getCurrent('actor');
        if ($actorClass) {
            $di->instantiate($actorClass);
        }

        foreach ($this->steps as $pattern => $step) {
            $di->instantiate($step[0]);
            $this->steps[$pattern][0] = $di->get($step[0]);
        }
    }

    public function toString(): string
    {
        return $this->getFeature() . ': ' . $this->getScenarioTitle();
    }

    public function getFeature(): string
    {
        return $this->getMetadata()->getFeature();
    }

    public function getScenarioTitle(): string
    {
        return $this->getMetadata()->getName();
    }

    public function getScenario(): Scenario
    {
        return $this->scenario;
    }

    public function getScenarioText(string $format = 'text'): string
    {
        $fileName = $this->getFileName();
        if (!$scenarioText = file_get_contents($fileName)) {
            throw new Exception("Could not get scenario {$fileName}, please check its permissions.");
        }
        return $scenarioText;
    }

    public function getSourceCode(): string
    {
        return '';
    }

    public function getScenarioNode(): ScenarioNode
    {
        return $this->scenarioNode;
    }

    public function getFeatureNode(): FeatureNode
    {
        return $this->featureNode;
    }

    /**
     * Field values for XML reports
     *
     * @return array<string, string>
     */
    public function getReportFields(): array
    {
        return [
            'name' => $this->toString(),
            'feature' => $this->getFeature(),
            'file' => $this->getFileName(),
        ];
    }
}
