<?php

declare(strict_types=1);

namespace Drupal\Tests\Core\Routing;

use Drupal\Core\Cache\Cache;
use Drupal\Core\DependencyInjection\ContainerBuilder;
use Drupal\Core\PathProcessor\OutboundPathProcessorInterface;
use Drupal\Core\PathProcessor\PathProcessorManager;
use Drupal\Core\Render\BubbleableMetadata;
use Drupal\Core\Routing\RequestContext;
use Drupal\Core\Routing\RouteProviderInterface;
use Drupal\Core\Routing\UrlGenerator;
use Drupal\path_alias\PathProcessor\AliasPathProcessor;
use Drupal\Tests\UnitTestCase;
use Prophecy\Argument;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Routing\Route;
use Symfony\Component\Routing\RouteCollection;

/**
 * Confirm that the UrlGenerator is functioning properly.
 *
 * @coversDefaultClass \Drupal\Core\Routing\UrlGenerator
 * @group Routing
 */
class UrlGeneratorTest extends UnitTestCase {

  /**
   * The route provider.
   *
   * @var \Drupal\Core\Routing\RouteProviderInterface
   */
  protected $provider;

  /**
   * The URL generator to test.
   *
   * @var \Drupal\Core\Routing\UrlGenerator
   */
  protected $generator;

  /**
   * The alias manager.
   *
   * @var \Drupal\path_alias\AliasManager|\PHPUnit\Framework\MockObject\MockObject
   */
  protected $aliasManager;

  /**
   * The mock route processor manager.
   *
   * @var \Drupal\Core\RouteProcessor\RouteProcessorManager|\PHPUnit\Framework\MockObject\MockObject
   */
  protected $routeProcessorManager;

  /**
   * The request stack.
   *
   * @var \Symfony\Component\HttpFoundation\RequestStack
   */
  protected $requestStack;

  /**
   * The request context.
   *
   * @var \Drupal\Core\Routing\RequestContext
   */
  protected $context;

  /**
   * The path processor.
   *
   * @var \Drupal\Core\PathProcessor\PathProcessorManager
   */
  protected $processorManager;

  /**
   * {@inheritdoc}
   */
  protected function setUp(): void {
    parent::setUp();

    $cache_contexts_manager = $this->getMockBuilder('Drupal\Core\Cache\Context\CacheContextsManager')
      ->disableOriginalConstructor()
      ->getMock();
    $cache_contexts_manager->method('assertValidTokens')->willReturn(TRUE);
    $container = new ContainerBuilder();
    $container->set('cache_contexts_manager', $cache_contexts_manager);
    \Drupal::setContainer($container);

    $routes = new RouteCollection();
    $first_route = new Route('/test/one');
    $second_route = new Route('/test/two/{Lassie}');
    $third_route = new Route('/test/two/');
    $fourth_route = new Route('/test/four', [], [], [], '', ['https']);
    $none_route = new Route('', [], [], ['_no_path' => TRUE]);

    $routes->add('test_1', $first_route);
    $routes->add('test_2', $second_route);
    $routes->add('test_3', $third_route);
    $routes->add('test_4', $fourth_route);
    $routes->add('<none>', $none_route);

    // Create a route provider stub.
    $provider = $this->getMockBuilder('Drupal\Core\Routing\RouteProvider')
      ->disableOriginalConstructor()
      ->getMock();
    // We need to set up return value maps for both the getRouteByName() and the
    // getRoutesByNames() method calls on the route provider. The parameters
    // are not passed in and default to an empty array.
    $route_name_return_map = $routes_names_return_map = [];
    $return_map_values = [
      [
        'route_name' => 'test_1',
        'return' => $first_route,
      ],
      [
        'route_name' => 'test_2',
        'return' => $second_route,
      ],
      [
        'route_name' => 'test_3',
        'return' => $third_route,
      ],
      [
        'route_name' => 'test_4',
        'return' => $fourth_route,
      ],
      [
        'route_name' => '<none>',
        'return' => $none_route,
      ],
    ];
    foreach ($return_map_values as $values) {
      $route_name_return_map[] = [$values['route_name'], $values['return']];
      $routes_names_return_map[] = [[$values['route_name']], $values['return']];
    }
    $this->provider = $provider;
    $this->provider->expects($this->any())
      ->method('getRouteByName')
      ->willReturnMap($route_name_return_map);
    $provider->expects($this->any())
      ->method('getRoutesByNames')
      ->willReturnMap($routes_names_return_map);

    // Create an alias manager stub.
    $alias_manager = $this->getMockBuilder('Drupal\path_alias\AliasManager')
      ->disableOriginalConstructor()
      ->getMock();

    $alias_manager->expects($this->any())
      ->method('getAliasByPath')
      ->willReturnCallback([$this, 'aliasManagerCallback']);

    $this->aliasManager = $alias_manager;

    $this->requestStack = new RequestStack();
    $request = Request::create('/some/path');
    $this->requestStack->push($request);

    $this->context = new RequestContext();
    $this->context->fromRequestStack($this->requestStack);

    $processor = new AliasPathProcessor($this->aliasManager);
    $processor_manager = new PathProcessorManager();
    $processor_manager->addOutbound($processor, 1000);
    $this->processorManager = $processor_manager;

    $this->routeProcessorManager = $this->getMockBuilder('Drupal\Core\RouteProcessor\RouteProcessorManager')
      ->disableOriginalConstructor()
      ->getMock();

    $generator = new UrlGenerator($this->provider, $processor_manager, $this->routeProcessorManager, $this->requestStack, ['http', 'https']);
    $generator->setContext($this->context);
    $this->generator = $generator;
  }

  /**
   * Return value callback for getAliasByPath() on the mock alias manager.
   *
   * Ensures that by default the call to getAliasByPath() will return the first
   * argument that was passed in. We special-case the paths for which we wish it
   * to return an actual alias.
   *
   * @return string
   */
  public function aliasManagerCallback() {
    $args = func_get_args();
    switch ($args[0]) {
      case '/test/one':
        return '/hello/world';

      case '/test/two/5':
        return '/goodbye/cruel/world';

      case '/<front>':
        return '/';

      default:
        return $args[0];
    }
  }

  /**
   * Confirms that generated routes will have aliased paths.
   */
  public function testAliasGeneration(): void {
    $url = $this->generator->generate('test_1');
    $this->assertEquals('/hello/world', $url);
    // No cacheability to test; UrlGenerator::generate() doesn't support
    // collecting cacheability metadata.

    $this->routeProcessorManager->expects($this->exactly(3))
      ->method('processOutbound')
      ->with($this->anything());

    // Check that the two generate methods return the same result.
    $this->assertGenerateFromRoute('test_1', [], [], $url, (new BubbleableMetadata())->setCacheMaxAge(Cache::PERMANENT));

    $path = $this->generator->getPathFromRoute('test_1');
    $this->assertEquals('test/one', $path);
  }

  /**
   * Confirms that generated routes will have aliased paths using interface constants.
   */
  public function testAliasGenerationUsingInterfaceConstants(): void {
    $url = $this->generator->generate('test_1', [], UrlGenerator::ABSOLUTE_PATH);
    $this->assertEquals('/hello/world', $url);
    // No cacheability to test; UrlGenerator::generate() doesn't support
    // collecting cacheability metadata.

    $this->routeProcessorManager->expects($this->exactly(3))
      ->method('processOutbound')
      ->with($this->anything());

    // Check that the two generate methods return the same result.
    $this->assertGenerateFromRoute('test_1', [], [], $url, (new BubbleableMetadata())->setCacheMaxAge(Cache::PERMANENT));

    $path = $this->generator->getPathFromRoute('test_1');
    $this->assertEquals('test/one', $path);
  }

  /**
   * @covers ::generateFromRoute
   */
  public function testUrlGenerationWithDisabledPathProcessing(): void {
    $path_processor = $this->prophesize(OutboundPathProcessorInterface::class);
    $path_processor->processOutbound(Argument::cetera())->shouldNotBeCalled();

    $generator = new UrlGenerator($this->provider, $path_processor->reveal(), $this->routeProcessorManager, $this->requestStack, ['http', 'https']);
    $generator->setContext($this->context);

    $url = $this->generator->generateFromRoute('test_1', [], ['path_processing' => FALSE]);
    $this->assertEquals('/test/one', $url);
  }

  /**
   * @covers ::generateFromRoute
   */
  public function testUrlGenerationWithDisabledPathProcessingByRoute(): void {
    $path_processor = $this->prophesize(OutboundPathProcessorInterface::class);
    $path_processor->processOutbound(Argument::cetera())->shouldNotBeCalled();

    $provider = $this->prophesize(RouteProviderInterface::class);
    $provider->getRouteByName('test_1')->willReturn(new Route('/test/one', [], [], ['default_url_options' => ['path_processing' => FALSE]]));

    $generator = new UrlGenerator($provider->reveal(), $path_processor->reveal(), $this->routeProcessorManager, $this->requestStack, ['http', 'https']);
    $generator->setContext($this->context);

    $url = $generator->generateFromRoute('test_1', []);
    $this->assertEquals('/test/one', $url);
  }

  /**
   * @covers ::generateFromRoute
   */
  public function testUrlGenerationWithDisabledPathProcessingByRouteAndOptedInPathProcessing(): void {
    $path_processor = $this->prophesize(OutboundPathProcessorInterface::class);
    $path_processor->processOutbound('/test/one', Argument::cetera())->willReturn('/hello/world')->shouldBeCalled();

    $provider = $this->prophesize(RouteProviderInterface::class);
    $provider->getRouteByName('test_1')->willReturn(new Route('/test/one', [], [], ['default_url_options' => ['path_processing' => FALSE]]));

    $generator = new UrlGenerator($provider->reveal(), $path_processor->reveal(), $this->routeProcessorManager, $this->requestStack, ['http', 'https']);
    $generator->setContext($this->context);

    $url = $generator->generateFromRoute('test_1', [], ['path_processing' => TRUE]);
    $this->assertEquals('/hello/world', $url);
  }

  /**
   * Tests URL generation deprecations.
   *
   * @group legacy
   */
  public function testRouteObjectDeprecation(): void {
    $this->expectDeprecation('Passing a route object to Drupal\Core\Routing\UrlGenerator::getPathFromRoute() is deprecated in drupal:10.1.0 and will not be supported in drupal:11.0.0. Pass the route name instead. See https://www.drupal.org/node/3172280');
    $path = $this->generator->getPathFromRoute(new Route('/test/one'));
    $this->assertSame($this->generator->getPathFromRoute('test_1'), $path);
    $this->expectDeprecation('Passing a route object to Drupal\Core\Routing\UrlGenerator::generateFromRoute() is deprecated in drupal:10.1.0 and will not be supported in drupal:11.0.0. Pass the route name instead. See https://www.drupal.org/node/3172280');
    $url = $this->generator->generateFromRoute(new Route('/test/one'));
    $this->assertSame($this->generator->generateFromRoute('test_1'), $url);
  }

  /**
   * Tests URL generation in a subdirectory.
   */
  public function testGetPathFromRouteWithSubdirectory(): void {
    $this->routeProcessorManager->expects($this->once())
      ->method('processOutbound');

    $path = $this->generator->getPathFromRoute('test_1');
    $this->assertEquals('test/one', $path);
  }

  /**
   * Confirms that generated routes will have aliased paths.
   */
  public function testAliasGenerationWithParameters(): void {
    $url = $this->generator->generate('test_2', ['Lassie' => '5']);
    $this->assertEquals('/goodbye/cruel/world', $url);
    // No cacheability to test; UrlGenerator::generate() doesn't support
    // collecting cacheability metadata.

    $this->routeProcessorManager->expects($this->any())
      ->method('processOutbound')
      ->with($this->anything());

    $options = ['fragment' => 'top'];
    // Extra parameters should appear in the query string.
    $this->assertGenerateFromRoute('test_1', ['zoo' => 5], $options, '/hello/world?zoo=5#top', (new BubbleableMetadata())->setCacheMaxAge(Cache::PERMANENT));

    $options = ['query' => ['page' => '1'], 'fragment' => 'bottom'];
    $this->assertGenerateFromRoute('test_2', ['Lassie' => 5], $options, '/goodbye/cruel/world?page=1#bottom', (new BubbleableMetadata())->setCacheMaxAge(Cache::PERMANENT));

    // Changing the parameters, the route still matches but there is no alias.
    $this->assertGenerateFromRoute('test_2', ['Lassie' => 7], $options, '/test/two/7?page=1#bottom', (new BubbleableMetadata())->setCacheMaxAge(Cache::PERMANENT));

    $path = $this->generator->getPathFromRoute('test_2', ['Lassie' => '5']);
    $this->assertEquals('test/two/5', $path);

    // Specify a query parameter with NULL.
    $options = ['query' => ['page' => NULL], 'fragment' => 'bottom'];
    $this->assertGenerateFromRoute('test_2', ['Lassie' => 5], $options, '/goodbye/cruel/world?page#bottom', (new BubbleableMetadata())->setCacheMaxAge(Cache::PERMANENT));
  }

  /**
   * Confirms that generated routes will have aliased paths with options.
   *
   * @dataProvider providerTestAliasGenerationWithOptions
   */
  public function testAliasGenerationWithOptions($route_name, $route_parameters, $options, $expected): void {
    $this->assertGenerateFromRoute($route_name, $route_parameters, $options, $expected, (new BubbleableMetadata())->setCacheMaxAge(Cache::PERMANENT));
  }

  /**
   * Provides test data for testAliasGenerationWithOptions.
   */
  public static function providerTestAliasGenerationWithOptions() {
    $data = [];
    // Extra parameters should appear in the query string.
    $data[] = [
      'test_1',
      ['zoo' => '5'],
      ['fragment' => 'top'],
      '/hello/world?zoo=5#top',
    ];
    $data[] = [
      'test_2',
      ['Lassie' => '5'],
      ['query' => ['page' => '1'], 'fragment' => 'bottom'],
      '/goodbye/cruel/world?page=1#bottom',
    ];
    // Changing the parameters, the route still matches but there is no alias.
    $data[] = [
      'test_2',
      ['Lassie' => '7'],
      ['query' => ['page' => '1'], 'fragment' => 'bottom'],
      '/test/two/7?page=1#bottom',
    ];
    // Query string values containing '/' should be decoded.
    $data[] = [
      'test_2',
      ['Lassie' => '7'],
      ['query' => ['page' => '1/2'], 'fragment' => 'bottom'],
      '/test/two/7?page=1/2#bottom',
    ];
    // A NULL query string.
    $data['query-with-NULL'] = [
      'test_2',
      ['Lassie' => '7'],
      ['query' => NULL, 'fragment' => 'bottom'],
      '/test/two/7#bottom',
    ];
    return $data;
  }

  /**
   * Tests URL generation from route with trailing start and end slashes.
   */
  public function testGetPathFromRouteTrailing(): void {
    $this->routeProcessorManager->expects($this->once())
      ->method('processOutbound');

    $path = $this->generator->getPathFromRoute('test_3');
    $this->assertEquals('test/two', $path);
  }

  /**
   * Confirms that absolute URLs work with generated routes.
   */
  public function testAbsoluteURLGeneration(): void {
    $url = $this->generator->generate('test_1', [], TRUE);
    $this->assertEquals('http://localhost/hello/world', $url);
    // No cacheability to test; UrlGenerator::generate() doesn't support
    // collecting cacheability metadata.

    $this->routeProcessorManager->expects($this->exactly(2))
      ->method('processOutbound')
      ->with($this->anything());

    $options = ['absolute' => TRUE, 'fragment' => 'top'];
    // Extra parameters should appear in the query string.
    $this->assertGenerateFromRoute('test_1', ['zoo' => 5], $options, 'http://localhost/hello/world?zoo=5#top', (new BubbleableMetadata())->setCacheMaxAge(Cache::PERMANENT)->setCacheContexts(['url.site']));
  }

  /**
   * Confirms that absolute URLs work with generated routes using interface constants.
   */
  public function testAbsoluteURLGenerationUsingInterfaceConstants(): void {
    $url = $this->generator->generate('test_1', [], UrlGenerator::ABSOLUTE_URL);
    $this->assertEquals('http://localhost/hello/world', $url);
    // No cacheability to test; UrlGenerator::generate() doesn't support
    // collecting cacheability metadata.

    $this->routeProcessorManager->expects($this->exactly(2))
      ->method('processOutbound')
      ->with($this->anything());

    $options = ['absolute' => TRUE, 'fragment' => 'top'];
    // Extra parameters should appear in the query string.
    $this->assertGenerateFromRoute('test_1', ['zoo' => 5], $options, 'http://localhost/hello/world?zoo=5#top', (new BubbleableMetadata())->setCacheMaxAge(Cache::PERMANENT)->setCacheContexts(['url.site']));
  }

  /**
   * Confirms that explicitly setting the base_url works with generated routes.
   */
  public function testBaseURLGeneration(): void {
    $options = ['base_url' => 'http://www.example.com:8888'];
    $this->assertGenerateFromRoute('test_1', [], $options, 'http://www.example.com:8888/hello/world', (new BubbleableMetadata())->setCacheMaxAge(Cache::PERMANENT));

    $options = ['base_url' => 'http://www.example.com:8888', 'https' => TRUE];
    $this->assertGenerateFromRoute('test_1', [], $options, 'https://www.example.com:8888/hello/world', (new BubbleableMetadata())->setCacheMaxAge(Cache::PERMANENT));

    $options = ['base_url' => 'https://www.example.com:8888', 'https' => FALSE];
    $this->assertGenerateFromRoute('test_1', [], $options, 'http://www.example.com:8888/hello/world', (new BubbleableMetadata())->setCacheMaxAge(Cache::PERMANENT));

    $this->routeProcessorManager->expects($this->exactly(2))
      ->method('processOutbound')
      ->with($this->anything());

    $options = ['base_url' => 'http://www.example.com:8888', 'fragment' => 'top'];
    // Extra parameters should appear in the query string.
    $this->assertGenerateFromRoute('test_1', ['zoo' => 5], $options, 'http://www.example.com:8888/hello/world?zoo=5#top', (new BubbleableMetadata())->setCacheMaxAge(Cache::PERMANENT));
  }

  /**
   * Tests deprecated methods.
   *
   * @group legacy
   */
  public function testDeprecatedMethods(): void {
    $this->expectDeprecation('Drupal\Core\Routing\UrlGenerator::getRouteDebugMessage() is deprecated in drupal:10.1.0 and is removed from drupal:11.0.0. Use the route name instead. See https://www.drupal.org/node/3172303');
    $this->assertSame('test', $this->generator->getRouteDebugMessage('test'));
    $this->expectDeprecation('Drupal\Core\Routing\UrlGenerator::supports() is deprecated in drupal:10.1.0 and is removed from drupal:11.0.0. Only string route names are supported. See https://www.drupal.org/node/3172303');
    $this->assertTrue($this->generator->supports('test'));
  }

  /**
   * Tests the 'scheme' route requirement during URL generation.
   */
  public function testUrlGenerationWithHttpsRequirement(): void {
    $url = $this->generator->generate('test_4', [], TRUE);
    $this->assertEquals('https://localhost/test/four', $url);
    // No cacheability to test; UrlGenerator::generate() doesn't support
    // collecting cacheability metadata.

    $this->routeProcessorManager->expects($this->exactly(2))
      ->method('processOutbound')
      ->with($this->anything());

    $options = ['absolute' => TRUE, 'https' => TRUE];
    $this->assertGenerateFromRoute('test_1', [], $options, 'https://localhost/hello/world', (new BubbleableMetadata())->setCacheMaxAge(Cache::PERMANENT)->setCacheContexts(['url.site']));
  }

  /**
   * Tests generating a relative URL with no path.
   *
   * @param array $options
   *   An array of URL options.
   * @param string $expected_url
   *   The expected relative URL.
   *
   * @covers ::generateFromRoute
   *
   * @dataProvider providerTestNoPath
   */
  public function testNoPath($options, $expected_url): void {
    $url = $this->generator->generateFromRoute('<none>', [], $options);
    $this->assertEquals($expected_url, $url);
  }

  /**
   * Data provider for ::testNoPath().
   */
  public static function providerTestNoPath() {
    return [
      // Empty options.
      [[], ''],
      // Query parameters only.
      [['query' => ['foo' => 'bar']], '?foo=bar'],
      // Multiple query parameters.
      [['query' => ['foo' => 'bar', 'baz' => '']], '?foo=bar&baz='],
      // Fragment only.
      [['fragment' => 'foo'], '#foo'],
      // Query parameters and fragment.
      [['query' => ['bar' => 'baz'], 'fragment' => 'foo'], '?bar=baz#foo'],
      // Multiple query parameters and fragment.
      [['query' => ['bar' => 'baz', 'foo' => 'bar'], 'fragment' => 'foo'], '?bar=baz&foo=bar#foo'],
    ];
  }

  /**
   * @covers \Drupal\Core\Routing\UrlGenerator::generateFromRoute
   *
   * Note: We use absolute covers to let
   * \Drupal\Tests\Core\Render\MetadataBubblingUrlGeneratorTest work.
   */
  public function testGenerateWithPathProcessorChangingOptions(): void {
    $path_processor = $this->createMock(OutboundPathProcessorInterface::CLASS);
    $path_processor->expects($this->atLeastOnce())
      ->method('processOutbound')
      ->willReturnCallback(function ($path, &$options = [], ?Request $request = NULL, ?BubbleableMetadata $bubbleable_metadata = NULL) {
        $options['query'] = ['zoo' => 5];
        $options['fragment'] = 'foo';
        return $path;
      });
    $this->processorManager->addOutbound($path_processor);

    $options = [];
    $this->assertGenerateFromRoute('test_2', ['Lassie' => 5], $options, '/goodbye/cruel/world?zoo=5#foo', (new BubbleableMetadata())->setCacheMaxAge(Cache::PERMANENT));
  }

  /**
   * Asserts \Drupal\Core\Routing\UrlGenerator::generateFromRoute()'s output.
   *
   * @param string $route_name
   *   The route name to test.
   * @param array $route_parameters
   *   The route parameters to test.
   * @param array $options
   *   The options to test.
   * @param string $expected_url
   *   The expected generated URL string.
   * @param \Drupal\Core\Render\BubbleableMetadata $expected_bubbleable_metadata
   *   The expected generated bubbleable metadata.
   *
   * @internal
   */
  protected function assertGenerateFromRoute(string $route_name, array $route_parameters, array $options, string $expected_url, BubbleableMetadata $expected_bubbleable_metadata): void {
    // First, test with $collect_cacheability_metadata set to the default value.
    $url = $this->generator->generateFromRoute($route_name, $route_parameters, $options);
    $this->assertSame($expected_url, $url);

    // Second, test with it set to TRUE.
    $generated_url = $this->generator->generateFromRoute($route_name, $route_parameters, $options, TRUE);
    $this->assertSame($expected_url, $generated_url->getGeneratedUrl());
    $this->assertEquals($expected_bubbleable_metadata, BubbleableMetadata::createFromObject($generated_url));
  }

}
