<?php

declare(strict_types=1);

namespace Drupal\Tests;

use Behat\Mink\Driver\BrowserKitDriver;
use Behat\Mink\Driver\DriverInterface;
use Behat\Mink\Mink;
use Behat\Mink\Selector\SelectorsHandler;
use Behat\Mink\Session;
use Drupal\Core\Url;
use Symfony\Component\HttpKernel\HttpKernelBrowser;

/**
 * Provides UI helper methods using the HTTP kernel to make requests.
 *
 * This is for use by Kernel tests, with the following limitations:
 * - There is no logged in user. Use \Drupal\Tests\user\Traits\UserCreationTrait
 *   to set a current user.
 * - There is no active theme. To place blocks, a test must first install a
 *   theme and then set it as active.
 * - Session semantics differ from normal page requests. Do not rely on session
 *   state beyond its existence (no persistence or regeneration).
 * - Page caching modules will not work. See \Drupal\Tests\Traits\Core\Cache\PageCachePolicyTrait
 *   for how to set them up.
 */
trait HttpKernelUiHelperTrait {

  use BrowserHtmlDebugTrait;

  /**
   * Mink session manager.
   *
   * This is lazily initialized by the first call to self::drupalGet().
   *
   * @var \Behat\Mink\Mink|null
   */
  protected ?Mink $mink;

  /**
   * Retrieves a Drupal path.
   *
   * Requests are sent to the HTTP kernel.
   *
   * @param \Drupal\Core\Url|string $path
   *   The Drupal path to load into Mink controlled browser, as a string or a
   *   Url object. Relative paths (without a leading '/') are made absolute by
   *   prepending '/'. Query strings starting with '?' are resolved relative to
   *   the current page URL.
   * @param array $options
   *   (optional) Options to be forwarded to the URL generator. The 'absolute'
   *   option is not supported.
   * @param string[] $headers
   *   An array containing additional HTTP request headers, the array keys are
   *   the header names and the array values the header values. This is useful
   *   to set for example the "Accept-Language" header for requesting the page
   *   in a different language. Note that Mink's BrowserKitDriver normalizes
   *   header names to uppercase, while Symfony's HTTP classes normalize to
   *   lower case, which may cause a header to not be found with certain APIs.
   *
   * @return string
   *   The retrieved HTML string.
   *
   * @see \Drupal\Tests\BrowserTestBase::getHttpClient()
   */
  protected function drupalGet($path, array $options = [], array $headers = []): string {
    $session = $this->getSession();

    // Ensure relative paths are made absolute by prepending '/'. Query strings
    // (starting with '?') are left as-is so they resolve relative to the
    // current page URL.
    if (is_string($path) && !str_starts_with($path, '?') && !str_starts_with($path, '/')) {
      $path = '/' . $path;
    }

    if (is_object($path) || $options) {
      $path = $this->buildUrl($path, $options);
    }

    foreach ($headers as $header_name => $header_value) {
      assert(is_string($header_name));

      $session->setRequestHeader($header_name, $header_value);
    }

    $session->visit($path);

    $out = $session->getPage()->getContent();

    if ($this->htmlOutputEnabled) {
      $html_output = 'GET request to: ' . $path;
      $html_output .= '<hr />' . $out;
      $html_output .= $this->getHtmlOutputHeaders();
      $this->htmlOutput($html_output);
    }

    // If $this->content exists, set it for the benefit of all the methods in
    // \Drupal\KernelTests\AssertContentTrait.
    if (property_exists($this, 'content')) {
      $this->content = $out;
    }

    return $out;
  }

  /**
   * Follows a link by complete name.
   *
   * Will click the first link found with this link text unless $index is
   * specified.
   *
   * If the link is not found, an assertion will fail, halting the test.
   *
   * @param string|\Stringable $label
   *   Text between the anchor tags.
   * @param int $index
   *   (optional) The index number for cases where multiple links have the same
   *   text. Defaults to 0.
   */
  protected function clickLink(string|\Stringable $label, int $index = 0): void {
    $label = (string) $label;
    $links = $this->getSession()->getPage()->findAll('named', ['link', $label]);
    $this->assertArrayHasKey($index, $links, 'The link ' . $label . ' was not found on the page.');

    // Use static::drupalGet() rather than the click() method on the element,
    // because that will not produce HTML debug output.
    $this->drupalGet($links[$index]->getAttribute('href'));
  }

  /**
   * Builds a URL from a system path or a URL object.
   *
   * @param string|\Drupal\Core\Url $path
   *   A system path or a URL object.
   * @param array $options
   *   Options to be passed to Url::fromUri(). If $path is a Url object, any
   *   options it has will take priority over this parameter.
   *
   * @return string
   *   A URL string.
   */
  protected function buildUrl(Url|string $path, array $options = []): string {
    assert(empty($options['absolute']), 'Absolute URLs are not usable with drupalGet().');

    if ($path instanceof Url) {
      if ($options) {
        $url_options = $path->getOptions();
        $options = $url_options + $options;
        $path->setOptions($options);
      }
      return $path->toString();
    }

    $uri = $path === '<front>' ? 'base:/' : 'base:/' . $path;
    return Url::fromUri($uri, $options)->toString();
  }

  /**
   * Returns Mink session.
   *
   * The lazily initializes Mink the first time it is called.
   *
   * @param string $name
   *   (optional) Name of the session. Defaults to the active session.
   *
   * @return \Behat\Mink\Session
   *   The active Mink session object.
   */
  public function getSession($name = NULL): Session {
    // Lazily initialize the Mink session. We do this because unlike Browser
    // tests where there should definitely be requests made, this is not
    // necessarily the case with Kernel tests.
    if (!isset($this->mink)) {
      $this->initMink();
      // Set up the browser test output file.
      $this->initBrowserOutputFile();
    }

    return $this->mink->getSession($name);
  }

  /**
   * Initializes Mink sessions.
   *
   * Helper for static::getSession().
   */
  protected function initMink(): void {
    $driver = $this->getDefaultDriverInstance();

    $selectors_handler = new SelectorsHandler([
      'hidden_field_selector' => new HiddenFieldSelector(),
    ]);
    $session = new Session($driver, $selectors_handler);
    $this->mink = new Mink();
    $this->mink->registerSession('default', $session);
    $this->mink->setDefaultSessionName('default');
  }

  /**
   * Gets an instance of the default Mink driver.
   *
   * @return \Behat\Mink\Driver\DriverInterface
   *   Instance of default Mink driver.
   *
   * @throws \InvalidArgumentException
   *   When provided default Mink driver class can't be instantiated.
   */
  protected function getDefaultDriverInstance(): DriverInterface {
    $http_kernel = $this->container->get('http_kernel');
    $browserkit_client = new HttpKernelBrowser($http_kernel);
    $driver = new BrowserKitDriver($browserkit_client);
    return $driver;
  }

  /**
   * Returns WebAssert object.
   *
   * @param string $name
   *   (optional) Name of the session. Defaults to the active session.
   *
   * @return \Drupal\Tests\WebAssert
   *   A new web-assert option for asserting the presence of elements with.
   */
  public function assertSession($name = NULL): WebAssert {
    $this->addToAssertionCount(1);
    return new WebAssert($this->getSession($name));
  }

  /**
   * Performs an xpath search on the contents of the internal browser.
   *
   * The search is relative to the root element (HTML tag normally) of the page.
   *
   * This method is identical to \Drupal\Tests\BrowserTestBase::xpath() and
   * should be used when converting Browser tests to Kernel tests, as
   * \Drupal\KernelTests\AssertContentTrait::xpath() which Kernel tests use does
   * not have the same return type.
   *
   * @param string $xpath
   *   The xpath string to use in the search.
   * @param array $arguments
   *   An array of arguments with keys in the form ':name' matching the
   *   placeholders in the query. The values may be either strings or numeric
   *   values.
   *
   * @return \Behat\Mink\Element\NodeElement[]
   *   The list of elements matching the xpath expression.
   */
  protected function getNodeElementsByXpath($xpath, array $arguments = []): array {
    $xpath = $this->assertSession()->buildXPathQuery($xpath, $arguments);
    return $this->getSession()->getPage()->findAll('xpath', $xpath);
  }

  /**
   * Gets the current URL from the browser.
   *
   * @see \Drupal\Tests\UiHelperTrait::getUrl
   */
  protected function getUrl(): string {
    return $this->getSession()->getCurrentUrl();
  }

  /**
   * Rebuilds the container.
   *
   * Provided for compatibility with traits that call $this->rebuildContainer(),
   * such as those shared with functional tests.
   *
   * @see \Drupal\Core\Test\FunctionalTestSetupTrait::rebuildContainer
   */
  protected function rebuildContainer(): void {
    \Drupal::service('kernel')->rebuildContainer();
    $this->container = \Drupal::getContainer();
  }

}
