<?php

declare(strict_types=1);

namespace Drupal\flowdrop_node_processor\Plugin\FlowDropNodeProcessor;

use Drupal\Component\Serialization\Json;
use Drupal\Core\Http\ClientFactory;
use Drupal\flowdrop\Attribute\FlowDropNodeProcessor;
use Drupal\flowdrop\DTO\ParameterBagInterface;
use Drupal\flowdrop\DTO\ValidationResult;
use Drupal\flowdrop\Exception\InvalidNodeConfigurationException;
use Drupal\flowdrop\Exception\NodeProcessingException;
use Drupal\flowdrop\Plugin\FlowDropNodeProcessor\AbstractFlowDropNodeProcessor;
use Drupal\flowdrop\Plugin\FlowDropNodeProcessor\HasSideEffectsInterface;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\RequestException;
use Symfony\Component\DependencyInjection\ContainerInterface;

/**
 * Executor for HTTP Request nodes.
 *
 * @internal This class is not part of the public API and may change without notice.
 */
#[FlowDropNodeProcessor(
  id: "http_request",
  label: new \Drupal\Core\StringTranslation\TranslatableMarkup("HTTP Request"),
  description: "HTTP request operations",
  version: "1.0.0"
)]
class HttpRequest extends AbstractFlowDropNodeProcessor implements HasSideEffectsInterface {

  /**
   * The HTTP client.
   *
   * @var \GuzzleHttp\Client
   */
  protected Client $httpClient;

  /**
   * Constructs an HttpRequest object.
   *
   * @param array<string, mixed> $configuration
   *   The plugin configuration.
   * @param string $plugin_id
   *   The plugin ID.
   * @param mixed $plugin_definition
   *   The plugin definition.
   * @param \Drupal\Core\Http\ClientFactory $clientFactory
   *   The HTTP client factory.
   */
  public function __construct(
    array $configuration,
    $plugin_id,
    $plugin_definition,
    private readonly ClientFactory $clientFactory,
  ) {
    $this->httpClient = $this->clientFactory->fromOptions([
      "timeout" => 30,
      "connect_timeout" => 10,
    ]);
    parent::__construct($configuration, $plugin_id, $plugin_definition);
  }

  /**
   * {@inheritdoc}
   */
  public static function create(
    ContainerInterface $container,
    array $configuration,
    $plugin_id,
    $plugin_definition,
  ): static {
    return new static(
      $configuration,
      $plugin_id,
      $plugin_definition,
      $container->get("http_client_factory")
    );
  }

  /**
   * {@inheritdoc}
   */
  public function validateParams(array $params): ValidationResult {
    // HTTP Request nodes don't require specific inputs.
    return ValidationResult::success();
  }

  /**
   * {@inheritdoc}
   */
  public function process(ParameterBagInterface $params): array {
    $url = $params->get("url") ?? $params->get("url", "");
    $method = strtoupper($params->get("method") ?? $params->get("method", "GET"));

    // Merge config headers with runtime headers.
    $configHeaders = $params->get("headers", []);
    $runtimeHeaders = $params->get("headers", []);
    $headers = array_merge($configHeaders, $runtimeHeaders);

    $body = $params->get("body") ?? $params->get("body", "");
    $timeout = $params->get("timeout", 30);

    if (empty($url)) {
      throw new InvalidNodeConfigurationException("No URL provided for HTTP request");
    }

    // Validate URL.
    if (!filter_var($url, FILTER_VALIDATE_URL)) {
      throw new InvalidNodeConfigurationException("Invalid URL provided: " . $url);
    }

    // SSRF protection: validate URL safety and obtain the pinned IP.
    $allowInternalRequests = (bool) $params->get("allow_internal_requests", FALSE);
    $resolvedIp = $this->validateUrlSafety($url, $allowInternalRequests);

    try {
      // Prepare request options.
      $options = [
        "timeout" => $timeout,
        "headers" => $headers,
      ];

      // Pin curl to the already-validated IP to prevent DNS
      // rebinding (TOCTOU). This avoids a second DNS resolution
      // by Guzzle/curl after our safety check.
      if ($resolvedIp !== NULL) {
        $host = (string) parse_url($url, PHP_URL_HOST);
        $scheme = (string) parse_url($url, PHP_URL_SCHEME);
        $port = parse_url($url, PHP_URL_PORT) ?? ($scheme === 'https' ? 443 : 80);
        // IPv6 addresses need brackets in CURLOPT_RESOLVE.
        $resolveAddr = filter_var($resolvedIp, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)
          ? "[{$resolvedIp}]"
          : $resolvedIp;
        $options['curl'] = [
          \CURLOPT_RESOLVE => ["{$host}:{$port}:{$resolveAddr}"],
        ];
      }

      // Add body for POST/PUT requests.
      if (in_array($method, ["POST", "PUT", "PATCH"]) && !empty($body)) {
        $options["body"] = $body;
      }

      // Make the request.
      $response = $this->httpClient->request($method, $url, $options);

      // Get response data.
      $statusCode = $response->getStatusCode();
      $responseHeaders = $response->getHeaders();
      $responseBody = $response->getBody()->getContents();

      // Try to parse JSON response.
      $jsonData = NULL;
      if (str_contains($responseHeaders["Content-Type"][0] ?? "", "application/json")) {
        $jsonData = JSON::decode($responseBody);
      }

      return [
        "status_code" => $statusCode,
        "headers" => $responseHeaders,
        "body" => $responseBody,
        "json" => $jsonData,
        "url" => $url,
        "method" => $method,
        "request_time" => microtime(TRUE),
      ];
    }
    catch (RequestException $e) {
      throw new NodeProcessingException("HTTP request failed: " . $e->getMessage(), 0, $e);
    }
  }

  /**
   * {@inheritdoc}
   */
  public function getParameterSchema(): array {
    return [
      "type" => "object",
      "properties" => [
        "url" => [
          "type" => "string",
          "title" => "URL",
          "description" => "The URL to request",
          "default" => "",
        ],
        "method" => [
          "type" => "string",
          "title" => "HTTP Method",
          "description" => "The HTTP method to use",
          "default" => "GET",
          "enum" => ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"],
        ],
        "headers" => [
          "type" => "object",
          "format" => "json",
          "title" => "Headers",
          "description" => "HTTP headers to send",
          "default" => [],
        ],
        "body" => [
          "type" => "string",
          "format" => "multiline",
          "title" => "Request Body",
          "description" => "Request body for POST/PUT requests",
          "default" => "",
        ],
        "timeout" => [
          "type" => "integer",
          "title" => "Timeout",
          "description" => "Request timeout in seconds",
          "default" => 30,
          "minimum" => 1,
          "maximum" => 300,
        ],
        "follow_redirects" => [
          "type" => "boolean",
          "title" => "Follow Redirects",
          "description" => "Whether to follow HTTP redirects",
          "default" => TRUE,
        ],
        "allow_internal_requests" => [
          "type" => "boolean",
          "title" => "Allow Internal Requests",
          "description" => "Allow requests to internal/private IP addresses. WARNING: Enabling this may expose internal services to SSRF attacks.",
          "default" => FALSE,
        ],
      ],
    ];
  }

  /**
   * {@inheritdoc}
   */
  public function getOutputSchema(): array {
    return [
      "type" => "object",
      "properties" => [
        "status_code" => [
          "type" => "integer",
          "description" => "HTTP response status code",
        ],
        "headers" => [
          "type" => "array",
          "description" => "HTTP response headers",
        ],
        "body" => [
          "type" => "string",
          "description" => "HTTP response body",
        ],
        "json" => [
          "type" => "array",
          "description" => "Parsed JSON response (if applicable)",
        ],
        "url" => [
          "type" => "string",
          "description" => "The URL that was requested",
        ],
        "method" => [
          "type" => "string",
          "description" => "The HTTP method used",
        ],
        "request_time" => [
          "type" => "float",
          "description" => "Timestamp when request was made",
        ],
      ],
    ];
  }

  /**
   * Validates that a URL is safe from SSRF attacks.
   *
   * Ensures the URL uses an allowed scheme (http/https) and, unless internal
   * requests are explicitly allowed, verifies the resolved IP address is not
   * in a private or reserved range.
   *
   * Returns the resolved IP so the caller can pin curl to it via
   * CURLOPT_RESOLVE, eliminating the DNS TOCTOU window between
   * validation and the actual request.
   *
   * @param string $url
   *   The URL to validate.
   * @param bool $allowInternal
   *   Whether to skip the private/reserved IP range check.
   *
   * @return string|null
   *   The validated, resolved IP address to use for DNS pinning, or NULL when
   *   internal requests are allowed and no pinning is needed.
   *
   * @throws \Drupal\flowdrop\Exception\InvalidNodeConfigurationException
   *   If the URL fails safety validation.
   */
  private function validateUrlSafety(string $url, bool $allowInternal = FALSE): ?string {
    // Validate URL scheme — only http and https are allowed.
    $scheme = parse_url($url, PHP_URL_SCHEME);
    if (!in_array(strtolower((string) $scheme), ['http', 'https'], TRUE)) {
      throw new InvalidNodeConfigurationException(
        "URL scheme not allowed: " . $scheme . ". Only http and https are permitted."
      );
    }

    // If internal requests are explicitly allowed, skip IP range validation.
    // Return NULL to indicate no DNS pinning is required.
    if ($allowInternal) {
      return NULL;
    }

    // Resolve the hostname to an IP address.
    $host = parse_url($url, PHP_URL_HOST);
    if ($host === NULL || $host === FALSE) {
      throw new InvalidNodeConfigurationException(
        "Unable to parse hostname from URL: " . $url
      );
    }

    $ip = $this->resolveHostname($host);
    if ($ip === NULL) {
      throw new InvalidNodeConfigurationException(
        "Unable to resolve hostname: " . $host
      );
    }

    // Check if the resolved IP is in a private or reserved range.
    if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
      throw new InvalidNodeConfigurationException(
        "SSRF protection: requests to private or reserved IP addresses are not allowed. "
        . "Host " . $host . " resolved to " . $ip . "."
      );
    }

    // Return the validated IP for DNS pinning in the caller.
    return $ip;
  }

  /**
   * Resolves a hostname to an IPv4 or IPv6 address.
   *
   * Tries IPv4 via gethostbyname() first, then falls back to IPv6 via
   * dns_get_record(DNS_AAAA) for IPv6-only hosts. Extracted as a protected
   * method so tests can override DNS resolution without network calls.
   *
   * @param string $host
   *   The hostname to resolve.
   *
   * @return string|null
   *   The resolved IP address, or NULL if resolution fails.
   */
  protected function resolveHostname(string $host): ?string {
    // gethostbyname() returns the input unchanged on failure.
    $ip = gethostbyname($host);
    if ($ip !== $host || filter_var($host, FILTER_VALIDATE_IP)) {
      return $ip;
    }
    // IPv4 resolution failed — try IPv6 via dns_get_record().
    $aaaaRecords = dns_get_record($host, DNS_AAAA);
    if (!empty($aaaaRecords) && isset($aaaaRecords[0]['ipv6'])) {
      return $aaaaRecords[0]['ipv6'];
    }
    return NULL;
  }

  /**
   * Test connection to a URL.
   *
   * @param string $url
   *   The URL to test.
   * @param int $timeout
   *   Connection timeout.
   *
   * @return array{reachable: bool, status_code?: int, response_time?: float, url: string, error?: string}
   *   Test results.
   */
  protected function testConnection(string $url, int $timeout): array {
    try {
      $startTime = microtime(TRUE);

      // Use HEAD request for faster connection test.
      $response = $this->httpClient->request("HEAD", $url, [
        "timeout" => $timeout,
        "connect_timeout" => $timeout,
      ]);

      $responseTime = microtime(TRUE) - $startTime;

      return [
        "reachable" => TRUE,
        "status_code" => $response->getStatusCode(),
        "response_time" => $responseTime,
        "url" => $url,
      ];
    }
    catch (\Exception $e) {
      return [
        "reachable" => FALSE,
        "error" => $e->getMessage(),
        "url" => $url,
      ];
    }
  }

}
