<?php

declare(strict_types=1);

namespace Drupal\native_observability_otel\EventSubscriber;

use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Session\AccountProxyInterface;
use Drupal\native_observability\EventSubscriber\TraceSubscriber;
use Drupal\native_observability_otel\Service\ExporterRegistry;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;

/**
 * Exports completed request traces through the OpenTelemetry exporter.
 *
 * This subscriber is intentionally fail-safe:
 * - It never breaks the main request/response flow.
 * - Export failures are logged as warnings only.
 * - Export runs after the core trace persistence logic (priority -110).
 *
 * Self-route exclusion mirrors the behaviour of the base TraceSubscriber:
 * when capture.exclude_own_routes is TRUE, requests to native_observability
 * routes are not exported to the OTLP collector, preventing background noise
 * from Prometheus scrapes, dashboard visits, and admin-form submissions from
 * appearing in Grafana Tempo as application spans.
 */
final class OpenTelemetryTraceSubscriber implements EventSubscriberInterface {

  /**
   * Constructs the subscriber.
   *
   * @param \Drupal\native_observability_otel\Service\ExporterRegistry $exporterRegistry
   *   The exporter registry.
   * @param \Drupal\Core\Session\AccountProxyInterface $currentUser
   *   The current user proxy.
   * @param \Psr\Log\LoggerInterface $logger
   *   Dedicated logger channel.
   * @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory
   *   The config factory, used to read the self-route exclusion setting from
   *   native_observability.settings without introducing a cross-module service
   *   dependency.
   */
  public function __construct(
    private readonly ExporterRegistry $exporterRegistry,
    private readonly AccountProxyInterface $currentUser,
    private readonly LoggerInterface $logger,
    private readonly ConfigFactoryInterface $configFactory,
  ) {
  }

  /**
   * {@inheritdoc}
   */
  public static function getSubscribedEvents(): array {
    return [
      KernelEvents::RESPONSE => ['onKernelResponse', -110],
    ];
  }

  /**
   * Exports the current request trace after the response is ready.
   *
   * @param \Symfony\Component\HttpKernel\Event\ResponseEvent $event
   *   The response event.
   */
  public function onKernelResponse(ResponseEvent $event): void {
    if (!$event->isMainRequest()) {
      return;
    }

    $request = $event->getRequest();

    // Mirror the base subscriber's self-route exclusion: skip OTLP export for
    // all native_observability routes when the option is enabled. This prevents
    // Prometheus scrapes, dashboard page views, and admin-form submissions from
    // appearing as application spans in Grafana Tempo.
    // @see \Drupal\native_observability\EventSubscriber\TraceSubscriber
    $routeName = (string) ($request->attributes->get('_route') ?? '');
    if ($this->isOwnRouteExcluded($routeName)) {
      return;
    }

    try {
      $exporter = $this->exporterRegistry->get('opentelemetry');

      if (!$exporter->isAvailable()) {
        return;
      }

      $response = $event->getResponse();

      $startedAt = (float) ($request->attributes->get(TraceSubscriber::ATTR_STARTED_AT) ?? microtime(TRUE));
      $endedAt = microtime(TRUE);
      $durationMs = (int) round(($endedAt - $startedAt) * 1000);

      $payload = [
        'request_id' => (string) ($request->attributes->get(TraceSubscriber::ATTR_REQUEST_ID) ?? ''),
        'method' => $request->getMethod(),
        'path' => $request->getPathInfo(),
        'route_name' => $routeName,
        'status_code' => $response->getStatusCode(),
        'duration_ms' => $durationMs,
        'started_at' => $startedAt,
        'ended_at' => $endedAt,
        'uid' => (int) $this->currentUser->id(),
        'client_ip' => $request->getClientIp(),
        'exception' => $request->attributes->get(TraceSubscriber::ATTR_EXCEPTION),
      ];

      $exporter->export($payload);
    }
    catch (\Throwable $throwable) {
      $this->logger->warning('OpenTelemetry trace export failed: @message', [
        '@message' => $throwable->getMessage(),
      ]);
    }
  }

  /**
   * Determines whether the given route must be excluded from OTLP export.
   *
   * All Drupal routes provided by the Native Observability module share the
   * machine-name prefix "native_observability". Using a prefix check covers
   * all current and future sub-module routes without requiring an explicit
   * allowlist. This mirrors the identical helper in TraceSubscriber and reads
   * the same configuration key (capture.exclude_own_routes) from the base
   * module settings to keep a single source of truth.
   *
   * The default when the key is absent is TRUE so that new or upgraded
   * installations automatically exclude self-routes without requiring an
   * explicit config:import step.
   *
   * @param string $routeName
   *   The Drupal route machine name for the current request.
   *
   * @return bool
   *   TRUE when the route must be skipped; FALSE when it should be exported.
   */
  private function isOwnRouteExcluded(string $routeName): bool {
    $raw = $this->configFactory
      ->get('native_observability.settings')
      ->get('capture.exclude_own_routes');

    // Treat a missing key as TRUE (safe default for new / upgraded installs).
    $exclude = ($raw ?? TRUE);

    if (!(bool) $exclude) {
      return FALSE;
    }

    return str_starts_with($routeName, 'native_observability');
  }

}
