<?php

declare(strict_types=1);

namespace Drupal\Tests\flowdrop_node_processor\Unit\Plugin\FlowDropNodeProcessor;

use Drupal\Core\Http\ClientFactory;
use Drupal\flowdrop\DTO\ParameterBagInterface;
use Drupal\flowdrop\Exception\InvalidNodeConfigurationException;
use Drupal\flowdrop_node_processor\Plugin\FlowDropNodeProcessor\HttpRequest;
use Drupal\Tests\UnitTestCase;
use GuzzleHttp\Client;
use GuzzleHttp\Psr7\Response;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;

/**
 * Tests the HttpRequest node processor SSRF protection and DNS resolution.
 *
 * Uses a TestableHttpRequest subclass to stub out DNS resolution, allowing
 * tests to exercise the validation and CURLOPT_RESOLVE logic without network
 * calls.
 *
 * @coversDefaultClass \Drupal\flowdrop_node_processor\Plugin\FlowDropNodeProcessor\HttpRequest
 * @group flowdrop_node_processor
 */
class HttpRequestTest extends UnitTestCase {

  use ProphecyTrait;

  /**
   * Creates a testable processor with DNS resolution stubbed to return $ip.
   *
   * @param string|null $mockedIp
   *   IP to return from resolveHostname(), or NULL to simulate
   *   resolution failure.
   *
   * @return array
   *   A tuple of TestableHttpRequest and ObjectProphecy.
   */
  private function createProcessor(?string $mockedIp): array {
    $clientProphecy = $this->prophesize(Client::class);
    $factoryProphecy = $this->prophesize(ClientFactory::class);
    $factoryProphecy->fromOptions(Argument::any())->willReturn($clientProphecy->reveal());

    $processor = new TestableHttpRequest(
      [], 'http_request', ['id' => 'http_request'],
      $factoryProphecy->reveal()
    );
    $processor->mockedIp = $mockedIp;

    return [$processor, $clientProphecy];
  }

  /**
   * Stubs all ParameterBagInterface::get() calls used by process().
   */
  private function stubParams(
    ObjectProphecy $params,
    string $url,
    string $method = 'GET',
    bool $allowInternal = FALSE,
  ): void {
    $params->get('url')->willReturn($url);
    $params->get('method')->willReturn($method);
    $params->get('headers', [])->willReturn([]);
    $params->get('body')->willReturn('');
    $params->get('timeout', 30)->willReturn(30);
    $params->get('allow_internal_requests', FALSE)->willReturn($allowInternal);
  }

  /**
   * @covers ::validateUrlSafety
   */
  public function testDisallowedSchemeThrows(): void {
    [$processor] = $this->createProcessor('1.2.3.4');
    $params = $this->prophesize(ParameterBagInterface::class);
    $this->stubParams($params, 'ftp://example.com/path');

    $this->expectException(InvalidNodeConfigurationException::class);
    $this->expectExceptionMessageMatches('/scheme not allowed/');
    $processor->process($params->reveal());
  }

  /**
   * @covers ::validateUrlSafety
   * @covers ::resolveHostname
   */
  public function testUnresolvableHostThrows(): void {
    [$processor] = $this->createProcessor(NULL);
    $params = $this->prophesize(ParameterBagInterface::class);
    $this->stubParams($params, 'https://unresolvable.example.invalid');

    $this->expectException(InvalidNodeConfigurationException::class);
    $this->expectExceptionMessageMatches('/Unable to resolve hostname/');
    $processor->process($params->reveal());
  }

  /**
   * @covers ::validateUrlSafety
   */
  public function testPrivateIpv4Blocked(): void {
    [$processor] = $this->createProcessor('192.168.1.1');
    $params = $this->prophesize(ParameterBagInterface::class);
    $this->stubParams($params, 'https://internal.example.com');

    $this->expectException(InvalidNodeConfigurationException::class);
    $this->expectExceptionMessageMatches('/SSRF protection/');
    $processor->process($params->reveal());
  }

  /**
   * @covers ::validateUrlSafety
   */
  public function testPrivateIpv6Blocked(): void {
    [$processor] = $this->createProcessor('::1');
    $params = $this->prophesize(ParameterBagInterface::class);
    $this->stubParams($params, 'https://ipv6-loopback.example.com');

    $this->expectException(InvalidNodeConfigurationException::class);
    $this->expectExceptionMessageMatches('/SSRF protection/');
    $processor->process($params->reveal());
  }

  /**
   * IPv4 resolved addresses must not be bracketed in CURLOPT_RESOLVE.
   *
   * @covers ::process
   */
  public function testIpv4AddressNotBracketedInCurlResolve(): void {
    [$processor, $clientProphecy] = $this->createProcessor('93.184.216.34');

    $capturedOptions = [];
    $clientProphecy
      ->request('GET', 'https://example.com', Argument::that(
        function (array $opts) use (&$capturedOptions): bool {
          $capturedOptions = $opts;
          return TRUE;
        }
      ))
      ->willReturn(new Response(200, ['Content-Type' => ['text/plain']], 'ok'));

    $params = $this->prophesize(ParameterBagInterface::class);
    $this->stubParams($params, 'https://example.com');
    $processor->process($params->reveal());

    $this->assertArrayHasKey('curl', $capturedOptions);
    $this->assertSame(
      'example.com:443:93.184.216.34',
      $capturedOptions['curl'][\CURLOPT_RESOLVE][0]
    );
  }

  /**
   * IPv6 resolved addresses must be wrapped in brackets in CURLOPT_RESOLVE.
   *
   * Curl requires the [addr] bracket notation for IPv6 in CURLOPT_RESOLVE
   * entries so it can distinguish the address from the port separator colon.
   *
   * @covers ::process
   * @covers ::resolveHostname
   */
  public function testIpv6AddressBracketedInCurlResolve(): void {
    $ipv6 = '2606:2800:220:1:248:1893:25c8:1946';
    [$processor, $clientProphecy] = $this->createProcessor($ipv6);

    $capturedOptions = [];
    $clientProphecy
      ->request('GET', 'https://example.com', Argument::that(
        function (array $opts) use (&$capturedOptions): bool {
          $capturedOptions = $opts;
          return TRUE;
        }
      ))
      ->willReturn(new Response(200, ['Content-Type' => ['text/plain']], 'ok'));

    $params = $this->prophesize(ParameterBagInterface::class);
    $this->stubParams($params, 'https://example.com');
    $processor->process($params->reveal());

    $this->assertArrayHasKey('curl', $capturedOptions);
    $this->assertSame(
      "example.com:443:[{$ipv6}]",
      $capturedOptions['curl'][\CURLOPT_RESOLVE][0]
    );
  }

  /**
   * Internal requests skip SSRF check and CURLOPT_RESOLVE pinning.
   *
   * @covers ::validateUrlSafety
   * @covers ::process
   */
  public function testAllowInternalRequestsSkipsSsrfCheckAndPinning(): void {
    // mockedIp=NULL simulates a host that would fail DNS — but with
    // allow_internal_requests TRUE, resolveHostname() is never called.
    [$processor, $clientProphecy] = $this->createProcessor(NULL);

    $capturedOptions = [];
    $clientProphecy
      ->request('GET', 'https://internal.corp', Argument::that(
        function (array $opts) use (&$capturedOptions): bool {
          $capturedOptions = $opts;
          return TRUE;
        }
      ))
      ->willReturn(new Response(200, ['Content-Type' => ['text/plain']], 'ok'));

    $params = $this->prophesize(ParameterBagInterface::class);
    $this->stubParams($params, 'https://internal.corp', allowInternal: TRUE);
    $result = $processor->process($params->reveal());

    $this->assertSame(200, $result['status_code']);
    $this->assertArrayNotHasKey('curl', $capturedOptions);
  }

  /**
   * Non-default port is used in CURLOPT_RESOLVE entry.
   *
   * @covers ::process
   */
  public function testCustomPortUsedInCurlResolve(): void {
    [$processor, $clientProphecy] = $this->createProcessor('93.184.216.34');

    $capturedOptions = [];
    $clientProphecy
      ->request('GET', 'https://example.com:8443/path', Argument::that(
        function (array $opts) use (&$capturedOptions): bool {
          $capturedOptions = $opts;
          return TRUE;
        }
      ))
      ->willReturn(new Response(200, ['Content-Type' => ['text/plain']], 'ok'));

    $params = $this->prophesize(ParameterBagInterface::class);
    $this->stubParams($params, 'https://example.com:8443/path');
    $processor->process($params->reveal());

    $this->assertSame(
      'example.com:8443:93.184.216.34',
      $capturedOptions['curl'][\CURLOPT_RESOLVE][0]
    );
  }

}

/**
 * Test double that stubs DNS resolution to avoid real network lookups.
 */
class TestableHttpRequest extends HttpRequest {

  /**
   * The IP address returned by resolveHostname(), or NULL to simulate failure.
   */
  public ?string $mockedIp = NULL;

  /**
   * {@inheritdoc}
   */
  protected function resolveHostname(string $host): ?string {
    return $this->mockedIp;
  }

}
