<?php

declare(strict_types=1);

namespace Drupal\native_observability_spans\Http;

use Drupal\native_observability_spans\Service\SpanRecorder;
use Symfony\Component\HttpFoundation\RequestStack;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Promise\Create;
use GuzzleHttp\Promise\PromiseInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\UriInterface;

/**
 * Decorates the HTTP client to record outbound HTTP spans.
 *
 * This decorator is intentionally fully compatible with Guzzle's client
 * interface so it can safely replace Drupal's global http_client service
 * without breaking core or contrib code paths.
 */
final class HttpClientSpanDecorator implements ClientInterface {

  /**
   * Constructs the HTTP client span decorator.
   *
   * @param \GuzzleHttp\ClientInterface $innerClient
   *   The decorated HTTP client.
   * @param \Drupal\native_observability_spans\Service\SpanRecorder $spanRecorder
   *   Span recorder service.
   * @param \Symfony\Component\HttpFoundation\RequestStack $requestStack
   *   The request stack.
   */
  public function __construct(
    private readonly ClientInterface $innerClient,
    private readonly SpanRecorder $spanRecorder,
    private readonly RequestStack $requestStack,
  ) {
  }

  /**
   * {@inheritdoc}
   */
  public function send(RequestInterface $request, array $options = []): ResponseInterface {
    $method = $request->getMethod();
    $uri = (string) $request->getUri();

    return $this->executeWrappedRequest(
      $method,
      $uri,
      fn() => $this->innerClient->send($request, $options),
      $options,
    );
  }

  /**
   * {@inheritdoc}
   */
  public function sendAsync(RequestInterface $request, array $options = []): PromiseInterface {
    $method = $request->getMethod();
    $uri = (string) $request->getUri();
    $start = microtime(TRUE);

    try {
      return $this->innerClient
        ->sendAsync($request, $options)
        ->then(
          function (ResponseInterface $response) use ($method, $uri, $start, $options) {
            $this->safeRecordHttpSpan(
              $method,
              $uri,
              $start,
              $options,
              $response,
              NULL,
            );

            return $response;
          },
          function ($reason) use ($method, $uri, $start, $options) {
            $this->safeRecordHttpSpan(
              $method,
              $uri,
              $start,
              $options,
              NULL,
              $reason instanceof \Throwable ? $reason : NULL,
            );

            return Create::rejectionFor($reason);
          }
        );
    }
    catch (\Throwable $exception) {
      $this->safeRecordHttpSpan(
        $method,
        $uri,
        $start,
        $options,
        NULL,
        $exception,
          );

      throw $exception;
    }
  }

  /**
   * {@inheritdoc}
   */
  public function request(string $method, $uri = '', array $options = []): ResponseInterface {
    $normalized_uri = $this->normalizeUri($uri);

    return $this->executeWrappedRequest(
      $method,
      $normalized_uri,
      fn() => $this->innerClient->request($method, $uri, $options),
      $options,
    );
  }

  /**
   * {@inheritdoc}
   */
  public function requestAsync(string $method, $uri = '', array $options = []): PromiseInterface {
    $normalized_uri = $this->normalizeUri($uri);
    $start = microtime(TRUE);

    try {
      return $this->innerClient
        ->requestAsync($method, $uri, $options)
        ->then(
          function (ResponseInterface $response) use ($method, $normalized_uri, $start, $options) {
            $this->safeRecordHttpSpan(
              $method,
              $normalized_uri,
              $start,
              $options,
              $response,
              NULL,
            );

            return $response;
          },
          function ($reason) use ($method, $normalized_uri, $start, $options) {
            $this->safeRecordHttpSpan(
              $method,
              $normalized_uri,
              $start,
              $options,
              NULL,
              $reason instanceof \Throwable ? $reason : NULL,
            );

            return Create::rejectionFor($reason);
          }
        );
    }
    catch (\Throwable $exception) {
      $this->safeRecordHttpSpan(
        $method,
        $normalized_uri,
        $start,
        $options,
        NULL,
        $exception,
          );

      throw $exception;
    }
  }

  /**
   * {@inheritdoc}
   */
  public function getConfig(?string $option = NULL) {
    return $this->innerClient->getConfig($option);
  }

  /**
   * {@inheritdoc}
   */
  public function get($uri = '', array $options = []): ResponseInterface {
    return $this->request('GET', $uri, $options);
  }

  /**
   * {@inheritdoc}
   */
  public function head($uri = '', array $options = []): ResponseInterface {
    return $this->request('HEAD', $uri, $options);
  }

  /**
   * {@inheritdoc}
   */
  public function put($uri = '', array $options = []): ResponseInterface {
    return $this->request('PUT', $uri, $options);
  }

  /**
   * {@inheritdoc}
   */
  public function post($uri = '', array $options = []): ResponseInterface {
    return $this->request('POST', $uri, $options);
  }

  /**
   * {@inheritdoc}
   */
  public function patch($uri = '', array $options = []): ResponseInterface {
    return $this->request('PATCH', $uri, $options);
  }

  /**
   * {@inheritdoc}
   */
  public function delete($uri = '', array $options = []): ResponseInterface {
    return $this->request('DELETE', $uri, $options);
  }

  /**
   * Executes a wrapped synchronous request and records the resulting span.
   *
   * @param string $method
   *   HTTP method.
   * @param string $uri
   *   Normalized request URI.
   * @param callable $callback
   *   Callback that executes the real HTTP request.
   * @param array $options
   *   Request options.
   *
   * @return \Psr\Http\Message\ResponseInterface
   *   The HTTP response.
   *
   * @throws \Throwable
   *   Re-throws any transport exception after recording the span.
   */
  private function executeWrappedRequest(
    string $method,
    string $uri,
    callable $callback,
    array $options = [],
  ): ResponseInterface {
    $start = microtime(TRUE);

    try {
      /** @var \Psr\Http\Message\ResponseInterface $response */
      $response = $callback();

      $this->safeRecordHttpSpan(
        $method,
        $uri,
        $start,
        $options,
        $response,
        NULL,
      );

      return $response;
    }
    catch (\Throwable $exception) {
      $this->safeRecordHttpSpan(
        $method,
        $uri,
        $start,
        $options,
        NULL,
        $exception,
          );

      throw $exception;
    }
  }

  /**
   * Records an HTTP span while protecting the request flow from span failures.
   *
   * Span recording must never break the original HTTP request lifecycle.
   *
   * @param string $method
   *   HTTP method.
   * @param string $uri
   *   Request URI.
   * @param float $start
   *   Request start timestamp from microtime(TRUE).
   * @param array $options
   *   Request options.
   * @param \Psr\Http\Message\ResponseInterface|null $response
   *   Response, when available.
   * @param \Throwable|null $exception
   *   Transport exception, when available.
   */
  private function safeRecordHttpSpan(
    string $method,
    string $uri,
    float $start,
    array $options,
    ?ResponseInterface $response,
    ?\Throwable $exception,
  ): void {
    try {
      $duration_ms = (microtime(TRUE) - $start) * 1000;

      $this->recordHttpSpan(
        method: strtoupper($method),
        uri: $uri,
        durationMs: $duration_ms,
        statusCode: $response?->getStatusCode(),
        options: $options,
        exception: $exception,
      );
    }
    catch (\Throwable) {
      // Span recording must never break the original HTTP request flow.
    }
  }

  /**
   * Records the outbound HTTP span.
   *
   * @param string $method
   *   HTTP method.
   * @param string $uri
   *   Request URI.
   * @param float $durationMs
   *   Request duration in milliseconds.
   * @param int|null $statusCode
   *   HTTP status code, when available.
   * @param array $options
   *   Guzzle request options.
   * @param \Throwable|null $exception
   *   Transport exception, when available.
   */
  private function recordHttpSpan(
    string $method,
    string $uri,
    float $durationMs,
    ?int $statusCode,
    array $options,
    ?\Throwable $exception,
  ): void {
    $host = (string) (parse_url($uri, PHP_URL_HOST) ?: $uri);
    $payload = [
      'type' => 'http_client',
      'method' => $method,
      'uri' => $uri,
      'url' => $uri,
      'target' => $uri,
      'host' => $host,
      'status_code' => $statusCode,
      'outcome' => $exception === NULL ? 'success' : 'error',
      'error_message' => $exception?->getMessage(),
      'options' => $this->sanitizeOptions($options),
    ];

    $request = $this->requestStack->getCurrentRequest();
    $requestId = $request?->attributes->get('native_observability_request_id');
    $traceId = $request?->attributes->get('native_observability_trace_id');

    $payload['request_id'] = $requestId;
    $payload['trace_id'] = $traceId;

    $spanId = $this->spanRecorder->startSpan(
      sprintf('%s %s', $method, $host),
      'http.client',
      NULL,
      $payload
    );

    $payload['duration_ms'] = round($durationMs, 2);

    $this->spanRecorder->endSpan($spanId, $payload);
  }

  /**
   * Removes unsafe and noisy request options before persistence.
   *
   * @param array $options
   *   Raw Guzzle request options.
   *
   * @return array
   *   Sanitized options ready for persistence.
   */
  private function sanitizeOptions(array $options): array {
    unset(
      $options['auth'],
      $options['body'],
      $options['headers']['Authorization'],
      $options['headers']['authorization'],
    );

    return $options;
  }

  /**
   * Normalizes a URI argument to string.
   *
   * @param string|\Psr\Http\Message\UriInterface $uri
   *   URI argument accepted by Guzzle client methods.
   *
   * @return string
   *   Normalized URI string.
   */
  private function normalizeUri(string|UriInterface $uri): string {
    return (string) $uri;
  }

}
