<?php

/*
 * This file is part of the Behat\Mink.
 * (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\Mink\Driver;

use Behat\Mink\Exception\DriverException;
use Behat\Mink\Exception\UnsupportedDriverActionException;
use Symfony\Component\BrowserKit\AbstractBrowser;
use Symfony\Component\BrowserKit\Cookie;
use Symfony\Component\BrowserKit\Exception\BadMethodCallException;
use Symfony\Component\BrowserKit\Response;
use Symfony\Component\DomCrawler\Crawler;
use Symfony\Component\DomCrawler\Field\ChoiceFormField;
use Symfony\Component\DomCrawler\Field\FileFormField;
use Symfony\Component\DomCrawler\Field\FormField;
use Symfony\Component\DomCrawler\Field\InputFormField;
use Symfony\Component\DomCrawler\Field\TextareaFormField;
use Symfony\Component\DomCrawler\Form;
use Symfony\Component\HttpKernel\HttpKernelBrowser;

/**
 * Symfony2 BrowserKit driver.
 *
 * @author Konstantin Kudryashov <ever.zet@gmail.com>
 */
class BrowserKitDriver extends CoreDriver
{
    private AbstractBrowser $client;

    /**
     * @var Form[]
     */
    private array $forms = array();
    private array $serverParameters = array();
    private bool $started = false;
    private bool $removeScriptFromUrl = false;
    private bool $removeHostFromUrl = false;

    /**
     * Initializes BrowserKit driver.
     *
     * @param AbstractBrowser      $client  BrowserKit client instance
     * @param string|null $baseUrl Base URL for HttpKernel clients
     */
    public function __construct(AbstractBrowser $client, ?string $baseUrl = null)
    {
        $this->client = $client;
        $this->client->followRedirects(true);

        if ($baseUrl !== null && $client instanceof HttpKernelBrowser) {
            $client->setServerParameter('SCRIPT_FILENAME', parse_url($baseUrl, PHP_URL_PATH) ?? '');
        }
    }

    /**
     * Returns BrowserKit HTTP client instance.
     */
    public function getClient(): AbstractBrowser
    {
        return $this->client;
    }

    /**
     * Tells driver to remove hostname from URL.
     *
     * @deprecated Deprecated as of 1.2, to be removed in 2.0. Pass the base url in the constructor instead.
     */
    public function setRemoveHostFromUrl(bool $remove = true)
    {
        @trigger_error(
            'setRemoveHostFromUrl() is deprecated as of 1.2 and will be removed in 2.0. Pass the base url in the constructor instead.',
            E_USER_DEPRECATED
        );
        $this->removeHostFromUrl = (bool) $remove;
    }

    /**
     * Tells driver to remove script name from URL.
     *
     * @deprecated Deprecated as of 1.2, to be removed in 2.0. Pass the base url in the constructor instead.
     */
    public function setRemoveScriptFromUrl(bool $remove = true)
    {
        @trigger_error(
            'setRemoveScriptFromUrl() is deprecated as of 1.2 and will be removed in 2.0. Pass the base url in the constructor instead.',
            E_USER_DEPRECATED
        );
        $this->removeScriptFromUrl = (bool) $remove;
    }

    /**
     * {@inheritdoc}
     */
    public function start()
    {
        $this->started = true;
    }

    /**
     * {@inheritdoc}
     */
    public function isStarted(): bool
    {
        return $this->started;
    }

    /**
     * {@inheritdoc}
     */
    public function stop()
    {
        $this->reset();
        $this->started = false;
    }

    /**
     * {@inheritdoc}
     */
    public function reset()
    {
        // Restarting the client resets the cookies and the history
        $this->client->restart();
        $this->forms = array();
        $this->serverParameters = array();
    }

    /**
     * {@inheritdoc}
     */
    public function visit($url)
    {
        $this->client->request('GET', $this->prepareUrl($url), array(), array(), $this->serverParameters);
        $this->forms = array();
    }

    /**
     * {@inheritdoc}
     */
    public function getCurrentUrl(): string
    {
        // This should be encapsulated in `getRequest` method if any other method needs the request
        try {
            $request = $this->client->getInternalRequest();
        } catch (BadMethodCallException $e) {
            // Handling Symfony 5+ behaviour
            $request = null;
        }

        if ($request === null) {
            throw new DriverException('Unable to access the request before visiting a page');
        }

        return $request->getUri();
    }

    /**
     * {@inheritdoc}
     */
    public function reload()
    {
        $this->client->reload();
        $this->forms = array();
    }

    /**
     * {@inheritdoc}
     */
    public function forward()
    {
        $this->client->forward();
        $this->forms = array();
    }

    /**
     * {@inheritdoc}
     */
    public function back()
    {
        $this->client->back();
        $this->forms = array();
    }

    /**
     * {@inheritdoc}
     */
    public function setBasicAuth($user, $password)
    {
        if (false === $user) {
            unset($this->serverParameters['PHP_AUTH_USER'], $this->serverParameters['PHP_AUTH_PW']);

            return;
        }

        $this->serverParameters['PHP_AUTH_USER'] = $user;
        $this->serverParameters['PHP_AUTH_PW'] = $password;
    }

    /**
     * {@inheritdoc}
     */
    public function setRequestHeader($name, $value)
    {
        $contentHeaders = array('CONTENT_LENGTH' => true, 'CONTENT_MD5' => true, 'CONTENT_TYPE' => true);
        $name = str_replace('-', '_', strtoupper($name));

        // CONTENT_* are not prefixed with HTTP_ in PHP when building $_SERVER
        if (!isset($contentHeaders[$name])) {
            $name = 'HTTP_' . $name;
        }

        $this->serverParameters[$name] = $value;
    }

    /**
     * {@inheritdoc}
     */
    public function getResponseHeaders(): array
    {
        return $this->getResponse()->getHeaders();
    }

    /**
     * {@inheritdoc}
     */
    public function setCookie($name, $value = null)
    {
        if (null === $value) {
            $this->deleteCookie($name);

            return;
        }

        $jar = $this->client->getCookieJar();
        $jar->set(new Cookie($name, $value));
    }

    /**
     * Deletes a cookie by name.
     *
     * @param string $name Cookie name.
     */
    private function deleteCookie(string $name)
    {
        $path = $this->getCookiePath();
        $jar = $this->client->getCookieJar();

        do {
            if (null !== $jar->get($name, $path)) {
                $jar->expire($name, $path);
            }

            $path = preg_replace('/.$/', '', $path);
        } while ($path);
    }

    /**
     * Returns current cookie path.
     */
    private function getCookiePath(): string
    {
        $path = dirname(parse_url($this->getCurrentUrl(), PHP_URL_PATH));

        if ('\\' === DIRECTORY_SEPARATOR) {
            $path = str_replace('\\', '/', $path);
        }

        return $path;
    }

    /**
     * {@inheritdoc}
     */
    public function getCookie($name)
    {
        // Note that the following doesn't work well because
        // Symfony\Component\BrowserKit\CookieJar stores cookies by name,
        // path, AND domain and if you don't fill them all in correctly then
        // you won't get the value that you're expecting.
        //
        // $jar = $this->client->getCookieJar();
        //
        // if (null !== $cookie = $jar->get($name)) {
        //     return $cookie->getValue();
        // }

        $allValues = $this->client->getCookieJar()->allValues($this->getCurrentUrl());

        if (isset($allValues[$name])) {
            return $allValues[$name];
        }

        return null;
    }

    /**
     * {@inheritdoc}
     */
    public function getStatusCode(): int
    {
        $response = $this->getResponse();

        // BC layer for Symfony < 4.3
        if (!method_exists($response, 'getStatusCode')) {
            return $this->getResponse()->getStatus();
        }

        return $this->getResponse()->getStatusCode();
    }

    /**
     * {@inheritdoc}
     */
    public function getContent(): string
    {
        return $this->getResponse()->getContent();
    }

    /**
     * {@inheritdoc}
     */
    public function findElementXpaths($xpath): array
    {
        $nodes = $this->getCrawler()->filterXPath($xpath);

        $elements = array();
        foreach ($nodes as $i => $node) {
            $elements[] = sprintf('(%s)[%d]', $xpath, $i + 1);
        }

        return $elements;
    }

    /**
     * {@inheritdoc}
     */
    public function getTagName($xpath): string
    {
        return $this->getCrawlerNode($this->getFilteredCrawler($xpath))->nodeName;
    }

    /**
     * {@inheritdoc}
     */
    public function getText($xpath): string
    {
        $text = $this->getFilteredCrawler($xpath)->text();
        $text = str_replace("\n", ' ', $text);
        $text = preg_replace('/ {2,}/', ' ', $text);

        return trim($text);
    }

    /**
     * {@inheritdoc}
     */
    public function getHtml($xpath)
    {
        // cut the tag itself (making innerHTML out of outerHTML)
        return preg_replace('/^\<[^\>]+\>|\<[^\>]+\>$/', '', $this->getOuterHtml($xpath));
    }

    /**
     * {@inheritdoc}
     */
    public function getOuterHtml($xpath): string
    {
        $node = $this->getCrawlerNode($this->getFilteredCrawler($xpath));

        return $node->ownerDocument->saveHTML($node);
    }

    /**
     * {@inheritdoc}
     */
    public function getAttribute($xpath, $name): ?string
    {
        $node = $this->getFilteredCrawler($xpath);

        if ($this->getCrawlerNode($node)->hasAttribute($name)) {
            return $node->attr($name);
        }

        return null;
    }

    /**
     * {@inheritdoc}
     */
    public function getValue($xpath)
    {
        if (in_array($this->getAttribute($xpath, 'type'), array('submit', 'image', 'button'), true)) {
            return $this->getAttribute($xpath, 'value');
        }

        $node = $this->getCrawlerNode($this->getFilteredCrawler($xpath));

        if ('option' === $node->tagName) {
            return $this->getOptionValue($node);
        }

        try {
            $field = $this->getFormField($xpath);
        } catch (\InvalidArgumentException $e) {
            return $this->getAttribute($xpath, 'value');
        }

        return $field->getValue();
    }

    /**
     * {@inheritdoc}
     */
    public function setValue($xpath, $value)
    {
        $this->getFormField($xpath)->setValue($value);
    }

    /**
     * {@inheritdoc}
     */
    public function check($xpath)
    {
        $this->getCheckboxField($xpath)->tick();
    }

    /**
     * {@inheritdoc}
     */
    public function uncheck($xpath)
    {
        $this->getCheckboxField($xpath)->untick();
    }

    /**
     * {@inheritdoc}
     */
    public function selectOption($xpath, $value, $multiple = false)
    {
        $field = $this->getFormField($xpath);

        if (!$field instanceof ChoiceFormField) {
            throw new DriverException(sprintf('Impossible to select an option on the element with XPath "%s" as it is not a select or radio input', $xpath));
        }

        if ($multiple) {
            $oldValue   = (array) $field->getValue();
            $oldValue[] = $value;
            $value      = $oldValue;
        }

        $field->select($value);
    }

    /**
     * {@inheritdoc}
     */
    public function isSelected($xpath): bool
    {
        $optionValue = $this->getOptionValue($this->getCrawlerNode($this->getFilteredCrawler($xpath)));
        $selectField = $this->getFormField('(' . $xpath . ')/ancestor-or-self::*[local-name()="select"]');
        $selectValue = $selectField->getValue();

        return is_array($selectValue) ? in_array($optionValue, $selectValue, true) : $optionValue === $selectValue;
    }

    /**
     * {@inheritdoc}
     */
    public function click($xpath)
    {
        $crawler = $this->getFilteredCrawler($xpath);
        $node = $this->getCrawlerNode($crawler);
        $tagName = $node->nodeName;

        if ('a' === $tagName) {
            $this->client->click($crawler->link());
            $this->forms = array();
        } elseif ($this->canSubmitForm($node)) {
            $this->submit($crawler->form());
        } elseif ($this->canResetForm($node)) {
            $this->resetForm($node);
        } else {
            $message = sprintf('%%s supports clicking on links and submit or reset buttons only. But "%s" provided', $tagName);

            throw new UnsupportedDriverActionException($message, $this);
        }
    }

    /**
     * {@inheritdoc}
     */
    public function isChecked($xpath): bool
    {
        $field = $this->getFormField($xpath);

        if (!$field instanceof ChoiceFormField || 'select' === $field->getType()) {
            throw new DriverException(sprintf('Impossible to get the checked state of the element with XPath "%s" as it is not a checkbox or radio input', $xpath));
        }

        if ('checkbox' === $field->getType()) {
            return $field->hasValue();
        }

        $radio = $this->getCrawlerNode($this->getFilteredCrawler($xpath));

        return $radio->getAttribute('value') === $field->getValue();
    }

    /**
     * {@inheritdoc}
     */
    public function attachFile($xpath, $path)
    {
        $field = $this->getFormField($xpath);

        if (!$field instanceof FileFormField) {
            throw new DriverException(sprintf('Impossible to attach a file on the element with XPath "%s" as it is not a file input', $xpath));
        }

        $field->upload($path);
    }

    /**
     * {@inheritdoc}
     */
    public function submitForm($xpath)
    {
        $crawler = $this->getFilteredCrawler($xpath);

        $this->submit($crawler->form());
    }

    /**
     * @throws DriverException If there is not response yet
     */
    protected function getResponse(): Response
    {
        try {
            $response = $this->client->getInternalResponse();
        } catch (BadMethodCallException $e) {
            // Handling Symfony 5+ behaviour
            $response = null;
        }

        if (null === $response) {
            throw new DriverException('Unable to access the response before visiting a page');
        }

        return $response;
    }

    /**
     * Prepares URL for visiting.
     * Removes "*.php/" from urls and then passes it to BrowserKitDriver::visit().
     */
    protected function prepareUrl(string $url): string
    {
        if (!$this->removeHostFromUrl && !$this->removeScriptFromUrl) {
            return $url;
        }

        $replacement = ($this->removeHostFromUrl ? '' : '$1') . ($this->removeScriptFromUrl ? '' : '$2');

        return preg_replace('#(https?\://[^/]+)(/[^/\.]+\.php)?#', $replacement, $url);
    }

    /**
     * Returns form field from XPath query.
     *
     * @throws DriverException
     */
    protected function getFormField(string $xpath): FormField
    {
        $fieldNode = $this->getCrawlerNode($this->getFilteredCrawler($xpath));
        $fieldName = str_replace('[]', '', $fieldNode->getAttribute('name'));

        $formNode = $this->getFormNode($fieldNode);
        $formId = $this->getFormNodeId($formNode);

        if (!isset($this->forms[$formId])) {
            $this->forms[$formId] = new Form($formNode, $this->getCurrentUrl());
        }

        if (is_array($this->forms[$formId][$fieldName])) {
            return $this->forms[$formId][$fieldName][$this->getFieldPosition($fieldNode)];
        }

        return $this->forms[$formId][$fieldName];
    }

    /**
     * Returns the checkbox field from xpath query, ensuring it is valid.
     *
     * @throws DriverException when the field is not a checkbox
     */
    private function getCheckboxField(string $xpath): ChoiceFormField
    {
        $field = $this->getFormField($xpath);

        if (!$field instanceof ChoiceFormField) {
            throw new DriverException(sprintf('Impossible to check the element with XPath "%s" as it is not a checkbox', $xpath));
        }

        return $field;
    }

    /**
     * @throws DriverException if the form node cannot be found
     */
    private function getFormNode(\DOMElement $element): \DOMElement
    {
        if ($element->hasAttribute('form')) {
            $formId = $element->getAttribute('form');
            $formNode = $element->ownerDocument->getElementById($formId);

            if (null === $formNode || 'form' !== $formNode->nodeName) {
                throw new DriverException(sprintf('The selected node has an invalid form attribute (%s).', $formId));
            }

            return $formNode;
        }

        $formNode = $element;

        do {
            // use the ancestor form element
            if (null === $formNode = $formNode->parentNode) {
                throw new DriverException('The selected node does not have a form ancestor.');
            }
        } while ('form' !== $formNode->nodeName);

        return $formNode;
    }

    /**
     * Gets the position of the field node among elements with the same name
     *
     * BrowserKit uses the field name as index to find the field in its Form object.
     * When multiple fields have the same name (checkboxes for instance), it will return
     * an array of elements in the order they appear in the DOM.
     */
    private function getFieldPosition(\DOMElement $fieldNode): int
    {
        $elements = $this->getCrawler()->filterXPath('//*[@name=\''.$fieldNode->getAttribute('name').'\']');

        if (count($elements) > 1) {
            // more than one element contains this name !
            // so we need to find the position of $fieldNode
            foreach ($elements as $key => $element) {
                /** @var \DOMElement $element */
                if ($element->getNodePath() === $fieldNode->getNodePath()) {
                    return $key;
                }
            }
        }

        return 0;
    }

    private function submit(Form $form)
    {
        $formId = $this->getFormNodeId($form->getFormNode());

        if (isset($this->forms[$formId])) {
            $this->mergeForms($form, $this->forms[$formId]);
        }

        // remove empty file fields from request
        foreach ($form->getFiles() as $name => $field) {
            if (empty($field['name']) && empty($field['tmp_name'])) {
                $form->remove($name);
            }
        }

        foreach ($form->all() as $field) {
            // Add a fix for https://github.com/symfony/symfony/pull/10733 to support Symfony versions which are not fixed
            if ($field instanceof TextareaFormField && null === $field->getValue()) {
                $field->setValue('');
            }
        }

        $this->client->submit($form);

        $this->forms = array();
    }

    private function resetForm(\DOMElement $fieldNode)
    {
        $formNode = $this->getFormNode($fieldNode);
        $formId = $this->getFormNodeId($formNode);
        unset($this->forms[$formId]);
    }

    /**
     * Determines if a node can submit a form.
     */
    private function canSubmitForm(\DOMElement $node): bool
    {
        $type = $node->hasAttribute('type') ? $node->getAttribute('type') : null;

        if ('input' === $node->nodeName && in_array($type, array('submit', 'image'), true)) {
            return true;
        }

        return 'button' === $node->nodeName && (null === $type || 'submit' === $type);
    }

    /**
     * Determines if a node can reset a form.
     */
    private function canResetForm(\DOMElement $node): bool
    {
        $type = $node->hasAttribute('type') ? $node->getAttribute('type') : null;

        return in_array($node->nodeName, array('input', 'button'), true) && 'reset' === $type;
    }

    /**
     * Returns form node unique identifier.
     */
    private function getFormNodeId(\DOMElement $form): string
    {
        return md5($form->getLineNo() . $form->getNodePath() . $form->nodeValue);
    }

    /**
     * Gets the value of an option element
     *
     * @see \Symfony\Component\DomCrawler\Field\ChoiceFormField::buildOptionValue
     */
    private function getOptionValue(\DOMElement $option): string
    {
        if ($option->hasAttribute('value')) {
            return $option->getAttribute('value');
        }

        if (!empty($option->nodeValue)) {
            return $option->nodeValue;
        }

        return '1'; // DomCrawler uses 1 by default if there is no text in the option
    }

    /**
     * Merges second form values into first one.
     *
     * @param Form $to   merging target
     * @param Form $from merging source
     */
    private function mergeForms(Form $to, Form $from)
    {
        foreach ($from->all() as $name => $field) {
            $fieldReflection = new \ReflectionObject($field);
            $nodeReflection  = $fieldReflection->getProperty('node');
            $valueReflection = $fieldReflection->getProperty('value');

            $nodeReflection->setAccessible(true);
            $valueReflection->setAccessible(true);

            $isIgnoredField = $field instanceof InputFormField &&
                in_array($nodeReflection->getValue($field)->getAttribute('type'), array('submit', 'button', 'image'), true);

            if (!$isIgnoredField) {
                $valueReflection->setValue($to[$name], $valueReflection->getValue($field));
            }
        }
    }

    /**
     * Returns DOMElement from crawler instance.
     *
     * @throws DriverException when the node does not exist
     */
    private function getCrawlerNode(Crawler $crawler): \DOMElement
    {
        $node = null;

        if ($crawler instanceof \Iterator) {
            // for symfony 2.3 compatibility as getNode is not public before symfony 2.4
            $crawler->rewind();
            $node = $crawler->current();
        } else {
            $node = $crawler->getNode(0);
        }

        if (null !== $node) {
            return $node;
        }

        throw new DriverException('The element does not exist');
    }

    /**
     * Returns a crawler filtered for the given XPath, requiring at least 1 result.
     *
     * @throws DriverException when no matching elements are found
     */
    private function getFilteredCrawler(string $xpath): Crawler
    {
        if (!count($crawler = $this->getCrawler()->filterXPath($xpath))) {
            throw new DriverException(sprintf('There is no element matching XPath "%s"', $xpath));
        }

        return $crawler;
    }

    /**
     * Returns crawler instance (got from client).
     *
     * @throws DriverException
     */
    private function getCrawler(): Crawler
    {
        try {
            $crawler = $this->client->getCrawler();

            if (null === $crawler) {
                throw new DriverException('Unable to access the response content before visiting a page');
            }
        } catch (BadMethodCallException $exception) {
            throw new DriverException('Unable to access the response content before visiting a page');
        }

        return $crawler;
    }
}
