<?php

declare(strict_types=1);

namespace Drupal\native_observability_execution\Service;

use GuzzleHttp\ClientInterface;
use GuzzleHttp\Promise\PromiseInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;

/**
 * Decorates Drupal's default http_client with execution tracing capabilities.
 *
 * Important:
 * - This decorator must remain incremental.
 * - It must never replace the already decorated client with a fresh Guzzle
 *   instance, otherwise upstream decorators such as the spans decorator are
 *   bypassed.
 */
final class InstrumentedHttpClientDecorator implements ClientInterface {

  /**
   * Creates a new instrumented HTTP client decorator instance.
   *
   * @param \GuzzleHttp\ClientInterface $inner
   *   The already decorated HTTP client.
   * @param \Drupal\native_observability_execution\Service\ExecutionSpanRecorderInterface $recorder
   *   The execution span recorder service.
   */
  public function __construct(
    private readonly ClientInterface $inner,
    private readonly ExecutionSpanRecorderInterface $recorder,
  ) {
  }

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

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

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

    try {
      return $this->inner->sendAsync($request, $options)->then(
        function (ResponseInterface $response) use ($method, $uri, $start) {
          $this->recordExecutionSpan($method, $uri, $start, $response->getStatusCode(), NULL);
          return $response;
        },
        function ($reason) use ($method, $uri, $start) {
          $throwable = $reason instanceof \Throwable ? $reason : NULL;
          $this->recordExecutionSpan($method, $uri, $start, NULL, $throwable);
          throw $reason;
        }
      );
    }
    catch (\Throwable $e) {
      $this->recordExecutionSpan($method, $uri, $start, NULL, $e);
      throw $e;
    }
  }

  /**
   * {@inheritdoc}
   */
  public function request($method, $uri, array $options = []): ResponseInterface {
    return $this->measureSyncRequest(
      strtoupper((string) $method),
      (string) $uri,
      fn() => $this->inner->request($method, $uri, $options),
    );
  }

  /**
   * {@inheritdoc}
   */
  public function requestAsync($method, $uri, array $options = []): PromiseInterface {
    $normalizedMethod = strtoupper((string) $method);
    $normalizedUri = (string) $uri;
    $start = microtime(TRUE);

    try {
      return $this->inner->requestAsync($method, $uri, $options)->then(
        function (ResponseInterface $response) use ($normalizedMethod, $normalizedUri, $start) {
          $this->recordExecutionSpan($normalizedMethod, $normalizedUri, $start, $response->getStatusCode(), NULL);
          return $response;
        },
        function ($reason) use ($normalizedMethod, $normalizedUri, $start) {
          $throwable = $reason instanceof \Throwable ? $reason : NULL;
          $this->recordExecutionSpan($normalizedMethod, $normalizedUri, $start, NULL, $throwable);
          throw $reason;
        }
      );
    }
    catch (\Throwable $e) {
      $this->recordExecutionSpan($normalizedMethod, $normalizedUri, $start, NULL, $e);
      throw $e;
    }
  }

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

  /**
   * Measures a synchronous outbound HTTP request.
   *
   * @param string $method
   *   The normalized HTTP method.
   * @param string $uri
   *   The normalized URI.
   * @param callable $callback
   *   The delegated request callback.
   *
   * @return \Psr\Http\Message\ResponseInterface
   *   The HTTP response.
   */
  private function measureSyncRequest(string $method, string $uri, callable $callback): ResponseInterface {
    $start = microtime(TRUE);

    try {
      $response = $callback();
      $this->recordExecutionSpan($method, $uri, $start, $response->getStatusCode(), NULL);
      return $response;
    }
    catch (\Throwable $e) {
      $this->recordExecutionSpan($method, $uri, $start, NULL, $e);
      throw $e;
    }
  }

  /**
   * Records an execution layer outbound HTTP span.
   *
   * @param string $method
   *   The HTTP method.
   * @param string $uri
   *   The full request URI.
   * @param float $start
   *   The request start timestamp.
   * @param int|null $statusCode
   *   The HTTP status code when available.
   * @param \Throwable|null $exception
   *   The transport exception when available.
   */
  private function recordExecutionSpan(
    string $method,
    string $uri,
    float $start,
    ?int $statusCode,
    ?\Throwable $exception,
  ): void {
    $durationMs = (int) round((microtime(TRUE) - $start) * 1000);
    $host = (string) (parse_url($uri, PHP_URL_HOST) ?: $uri);

    $meta = [
      'method' => $method,
      'url' => $uri,
      'target' => $uri,
      'host' => $host,
      'path' => $uri,
    ];

    if ($statusCode !== NULL) {
      $meta['status'] = $statusCode;
      $meta['status_code'] = $statusCode;
      $meta['outcome'] = 'ok';
    }
    else {
      $meta['outcome'] = 'error';
      $meta['exception_message'] = $exception?->getMessage();
      $meta['exception_class'] = $exception ? get_class($exception) : NULL;
    }

    $this->recorder->record(
      'external.http',
      sprintf('%s %s', $method, $host),
      $durationMs,
      $meta,
    );
  }

}
