<?php

declare(strict_types=1);

namespace Drupal\native_observability\EventSubscriber;

use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Session\AccountProxyInterface;
use Drupal\native_observability\Service\RequestIdGeneratorInterface;
use Drupal\native_observability\Service\RequestLogExclusionMatcher;
use Drupal\native_observability\Service\TraceStorageInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;

/**
 * Captures request/response traces and persists them to storage.
 *
 * IMPORTANT:
 * - This subscriber must never break the request/response flow.
 * - Any storage failures must be swallowed (log warning only).
 */
final class TraceSubscriber implements EventSubscriberInterface {

  public const ATTR_REQUEST_ID = 'native_observability.request_id';
  public const ATTR_STARTED_AT = 'native_observability.started_at';
  public const ATTR_EXCEPTION = 'native_observability.exception';

  /**
   * Stores the parent request ID for AJAX correlation.
   *
   * The value is propagated by the client when available.
   */
  public const ATTR_PARENT_REQUEST_ID = 'native_observability.parent_request_id';

  /**
   * Response header exposing the current request ID.
   *
   * This header supports debugging and external tooling.
   */
  public const HEADER_REQUEST_ID = 'X-Native-Observability-Request-Id';

  /**
   * Request header used to propagate the parent request id (AJAX correlation).
   */
  public const HEADER_PARENT_REQUEST_ID = 'X-Native-Observability-Parent-Request-Id';

  /**
   * Hard deny-list for sensitive headers that must never be stored.
   */
  private const SENSITIVE_HEADERS = [
    'authorization',
    'cookie',
    'set-cookie',
    'x-csrf-token',
    'x-drupal-csrf-token',
    'x-forwarded-authorization',
  ];

  /**
   * Cached settings for the current request lifecycle.
   */
  private ?array $settings = NULL;

  public function __construct(
    private readonly RequestIdGeneratorInterface $requestIdGenerator,
    private readonly TraceStorageInterface $storage,
    private readonly LoggerInterface $logger,
    private readonly AccountProxyInterface $currentUser,
    private readonly RequestStack $requestStack,
    private readonly ConfigFactoryInterface $configFactory,
  ) {
  }

  /**
   * {@inheritdoc}
   */
  public static function getSubscribedEvents(): array {
    return [
      KernelEvents::REQUEST => ['onKernelRequest', 100],
      KernelEvents::EXCEPTION => ['onKernelException', 0],
      KernelEvents::RESPONSE => ['onKernelResponse', -100],
    ];
  }

  /**
   * Records the request start and assigns a correlation id.
   *
   * @param \Symfony\Component\HttpKernel\Event\RequestEvent $event
   *   The kernel request event.
   */
  public function onKernelRequest(RequestEvent $event): void {
    if (!$event->isMainRequest()) {
      return;
    }

    $request = $event->getRequest();
    $requestId = $this->requestIdGenerator->generate();

    $request->attributes->set(self::ATTR_REQUEST_ID, $requestId);
    $request->attributes->set(self::ATTR_STARTED_AT, microtime(TRUE));

    // Best-effort AJAX correlation: read the parent request id from a custom
    // header. This enables linking between the initial HTML request and its
    // AJAX requests.
    $parentId = (string) $request->headers->get(self::HEADER_PARENT_REQUEST_ID, '');
    if ($parentId !== '') {
      $request->attributes->set(self::ATTR_PARENT_REQUEST_ID, $parentId);
    }
  }

  /**
   * Captures exception metadata for later persistence.
   *
   * @param \Symfony\Component\HttpKernel\Event\ExceptionEvent $event
   *   The kernel exception event.
   */
  public function onKernelException(ExceptionEvent $event): void {
    if (!$event->isMainRequest()) {
      return;
    }

    if (!$this->isCaptureEnabled()) {
      return;
    }

    if (!$this->getSettingBool('capture.exception', TRUE)) {
      return;
    }

    $request = $event->getRequest();
    $exception = $event->getThrowable();

    $request->attributes->set(self::ATTR_EXCEPTION, [
      'class' => get_class($exception),
      'message' => $exception->getMessage(),
      'code' => $exception->getCode(),
    ]);
  }

  /**
   * Persists the final request trace payload.
   *
   * @param \Symfony\Component\HttpKernel\Event\ResponseEvent $event
   *   The kernel response event.
   */
  public function onKernelResponse(ResponseEvent $event): void {
    if (!$event->isMainRequest()) {
      return;
    }

    if (!$this->isCaptureEnabled()) {
      return;
    }

    $request = $event->getRequest();
    $response = $event->getResponse();

    if (RequestLogExclusionMatcher::isRequestExcluded($request)) {
      return;
    }

    // Skip routes belonging to this module when the self-exclusion option is
    // enabled. These requests (dashboard pages, Prometheus scrape endpoint,
    // admin forms) are background noise: they inflate request counters, skew
    // latency percentiles, and add no analytical value to the trace dataset.
    // @see isOwnRouteExcluded()
    $routeName = (string) ($request->attributes->get('_route') ?? '');
    if ($this->isOwnRouteExcluded($routeName)) {
      return;
    }

    $started = (float) ($request->attributes->get(self::ATTR_STARTED_AT) ?? microtime(TRUE));
    $durationMs = (int) round((microtime(TRUE) - $started) * 1000);
    $requestId = (string) ($request->attributes->get(self::ATTR_REQUEST_ID) ?? '');
    $parentRequestId = (string) ($request->attributes->get(self::ATTR_PARENT_REQUEST_ID) ?? '');
    $isAjax = $this->isAjaxRequest($request);

    $payload = [
      'request_id' => $requestId,
      'parent_request_id' => $parentRequestId !== '' ? $parentRequestId : NULL,
      'method' => $request->getMethod(),
      'path' => $request->getPathInfo(),
      'is_ajax' => $isAjax,
    ];

    if ($this->getSettingBool('capture.route_name', TRUE)) {
      $payload['route_name'] = $routeName ?: NULL;
    }

    if ($this->getSettingBool('capture.status_code', TRUE)) {
      $payload['status_code'] = $response->getStatusCode();
    }

    if ($this->getSettingBool('capture.duration_ms', TRUE)) {
      $payload['duration_ms'] = $durationMs;
    }

    $databaseSummary = $request->attributes->get('native_observability.database.summary');
    if (is_array($databaseSummary) && $databaseSummary !== []) {
      $payload['database'] = $databaseSummary;
    }

    if ($this->getSettingBool('capture.exception', TRUE)) {
      $exception = $request->attributes->get(self::ATTR_EXCEPTION);
      $payload['exception'] = $exception ?: NULL;
    }

    $uid = NULL;
    if ($this->getSettingBool('capture.store_uid', TRUE)) {
      $uid = (int) $this->currentUser->id();
      $payload['uid'] = $uid;
    }

    $clientIp = $this->buildClientIpValue($request);
    if ($clientIp !== NULL) {
      $payload['client_ip'] = $clientIp;
    }

    $userAgent = $this->buildUserAgentValue($request);
    if ($userAgent !== NULL) {
      $payload['user_agent'] = $userAgent;
    }

    $headers = $this->buildHeadersValue($request);
    if ($headers !== NULL) {
      $payload['headers'] = $headers;
    }

    $query = $this->buildQueryValue($request);
    if ($query !== NULL) {
      $payload['query'] = $query;
    }

    $body = $this->buildBodyValue($request);
    if ($body !== NULL) {
      $payload['body'] = $body;
    }

    // Expose the request id as a response header for debugging and tooling.
    if ($requestId !== '') {
      $response->headers->set(self::HEADER_REQUEST_ID, $requestId);
    }

    try {
      $this->storage->insert([
        'request_id' => $requestId,
        'method' => $request->getMethod(),
        'path' => $request->getPathInfo(),
        'route_name' => $routeName ?: NULL,
        'status_code' => $response->getStatusCode(),
        'is_ajax' => (int) $isAjax,
        'duration_ms' => $durationMs,
        'started_at' => time(),
        'ended_at' => NULL,
        'uid' => $uid,
        'client_ip' => $clientIp,
        'payload' => json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
      ]);
    }
    catch (\Throwable $t) {
      // Fail-safe: never break the response pipeline.
      $this->logger->warning('Trace insert failed: @msg', ['@msg' => $t->getMessage()]);
    }
  }

  /**
   * Determines whether the current request should be treated as AJAX.
   *
   * @param \Symfony\Component\HttpFoundation\Request $request
   *   The current request instance.
   *
   * @return bool
   *   TRUE when the request is AJAX-like, FALSE otherwise.
   */
  private function isAjaxRequest(Request $request): bool {
    return $request->isXmlHttpRequest()
      || $request->query->get('_wrapper_format') === 'drupal_ajax'
      || (string) $request->request->get('_drupal_ajax') === '1';
  }

  /**
   * Determines whether trace capture is enabled.
   *
   * @return bool
   *   TRUE when capture is enabled, FALSE otherwise.
   */
  private function isCaptureEnabled(): bool {
    return $this->getSettingBool('capture.enabled', TRUE);
  }

  /**
   * Determines whether the given route should be excluded from trace capture.
   *
   * All Drupal routes belonging to the Native Observability module share the
   * machine-name prefix "native_observability". This covers every sub-module
   * route (dashboard, Prometheus scrape endpoint, admin settings forms, etc.)
   * without requiring an explicit allowlist that would need updating whenever
   * a new sub-module adds routes.
   *
   * When the `capture.exclude_own_routes` setting is TRUE (the default),
   * requests to these routes are silently skipped before any storage or export
   * occurs. The rationale: opening the observability dashboard, scraping the
   * Prometheus endpoint, or visiting admin pages generates background noise
   * that inflates counters, skews latency percentiles, and pollutes the trace
   * list with self-referential data that has no analytical value for operators
   * trying to understand application traffic.
   *
   * Disable the setting only when actively debugging the observability module
   * itself (e.g. to verify its own response times or HTTP status codes).
   *
   * @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 traced.
   */
  private function isOwnRouteExcluded(string $routeName): bool {
    if (!$this->getSettingBool('capture.exclude_own_routes', TRUE)) {
      return FALSE;
    }

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

  /**
   * Loads and caches module settings for the current request lifecycle.
   *
   * @return array
   *   The normalized settings array.
   */
  private function getSettings(): array {
    if ($this->settings !== NULL) {
      return $this->settings;
    }

    $config = $this->configFactory->get('native_observability.settings');

    $this->settings = [
      'capture' => (array) ($config->get('capture') ?? []),
      'retention' => (array) ($config->get('retention') ?? []),
    ];

    return $this->settings;
  }

  /**
   * Reads a boolean setting using dot notation.
   *
   * @param string $key
   *   The dot-notation setting key.
   * @param bool $default
   *   The fallback value.
   *
   * @return bool
   *   The resolved boolean value.
   */
  private function getSettingBool(string $key, bool $default = FALSE): bool {
    $settings = $this->getSettings();
    $parts = explode('.', $key);
    $value = $settings;

    foreach ($parts as $part) {
      if (!is_array($value) || !array_key_exists($part, $value)) {
        return $default;
      }
      $value = $value[$part];
    }

    return (bool) $value;
  }

  /**
   * Reads a string setting using dot notation.
   *
   * @param string $key
   *   The dot-notation setting key.
   * @param string $default
   *   The fallback value.
   *
   * @return string
   *   The resolved string value.
   */
  private function getSettingString(string $key, string $default = ''): string {
    $settings = $this->getSettings();
    $parts = explode('.', $key);
    $value = $settings;

    foreach ($parts as $part) {
      if (!is_array($value) || !array_key_exists($part, $value)) {
        return $default;
      }
      $value = $value[$part];
    }

    return is_string($value) ? $value : $default;
  }

  /**
   * Reads an array setting using dot notation.
   *
   * @param string $key
   *   The dot-notation setting key.
   *
   * @return array
   *   The resolved array value.
   */
  private function getSettingArray(string $key): array {
    $settings = $this->getSettings();
    $parts = explode('.', $key);
    $value = $settings;

    foreach ($parts as $part) {
      if (!is_array($value) || !array_key_exists($part, $value)) {
        return [];
      }
      $value = $value[$part];
    }

    return is_array($value) ? $value : [];
  }

  /**
   * Builds the client IP value according to the configured privacy mode.
   *
   * @param \Symfony\Component\HttpFoundation\Request $request
   *   The current request.
   *
   * @return string|null
   *   The processed client IP value, or NULL when disabled/unavailable.
   */
  private function buildClientIpValue(Request $request): ?string {
    $mode = $this->getSettingString('capture.client_ip_mode', 'anonymized');
    $ip = $request->getClientIp();

    if (!$ip) {
      return NULL;
    }

    return match ($mode) {
      'off' => NULL,
      'full' => $ip,
      'hashed' => $this->hashValue($ip, $this->getSettingString('capture.client_ip_hash_salt', '')),
      'anonymized' => $this->anonymizeIp($ip),
      default => $this->anonymizeIp($ip),
    };
  }

  /**
   * Builds the user agent value according to the configured capture mode.
   *
   * @param \Symfony\Component\HttpFoundation\Request $request
   *   The current request.
   *
   * @return string|array|null
   *   The processed user agent value, or NULL when disabled/unavailable.
   */
  private function buildUserAgentValue(Request $request): string|array|null {
    $mode = $this->getSettingString('capture.user_agent_mode', 'family');
    $userAgent = (string) ($request->headers->get('User-Agent') ?? '');

    if ($userAgent === '') {
      return NULL;
    }

    return match ($mode) {
      'off' => NULL,
      'full' => $userAgent,
      'family' => $this->userAgentFamily($userAgent),
      default => $this->userAgentFamily($userAgent),
    };
  }

  /**
   * Builds an allowlisted header collection.
   *
   * @param \Symfony\Component\HttpFoundation\Request $request
   *   The current request.
   *
   * @return array|null
   *   The sanitized header map, or NULL when disabled/empty.
   */
  private function buildHeadersValue(Request $request): ?array {
    $mode = $this->getSettingString('capture.headers_mode', 'off');
    if ($mode !== 'allowlist') {
      return NULL;
    }

    $allow = $this->normalizeKeys($this->getSettingArray('capture.headers_allowlist'));
    if (!$allow) {
      return NULL;
    }

    $output = [];
    foreach ($allow as $name) {
      if (in_array($name, self::SENSITIVE_HEADERS, TRUE)) {
        continue;
      }

      $value = $request->headers->all($name);
      if ($value !== NULL) {
        $output[$name] = $value;
      }
    }

    return $output ?: NULL;
  }

  /**
   * Builds the query payload according to the configured mode.
   *
   * @param \Symfony\Component\HttpFoundation\Request $request
   *   The current request.
   *
   * @return array|string|null
   *   The processed query data, or NULL when disabled/empty.
   */
  private function buildQueryValue(Request $request): array|string|null {
    $mode = $this->getSettingString('capture.query_mode', 'off');
    $params = $request->query->all();

    if ($mode === 'off' || !$params) {
      return NULL;
    }

    if ($mode === 'keys_only') {
      return array_values(array_keys($params));
    }

    if ($mode === 'allowlist') {
      $allow = $this->normalizeKeys($this->getSettingArray('capture.query_allowlist'));
      if (!$allow) {
        return NULL;
      }

      $output = [];
      foreach ($allow as $key) {
        if (array_key_exists($key, $params)) {
          $output[$key] = $this->redactIfSensitiveKey($key, $params[$key]);
        }
      }

      return $output ?: NULL;
    }

    if ($mode === 'full') {
      $output = [];
      foreach ($params as $key => $value) {
        $output[(string) $key] = $this->redactIfSensitiveKey((string) $key, $value);
      }
      return $output;
    }

    return NULL;
  }

  /**
   * Builds the parsed request body payload according to the configured mode.
   *
   * @param \Symfony\Component\HttpFoundation\Request $request
   *   The current request.
   *
   * @return array|string|null
   *   The processed body data, or NULL when disabled/empty.
   */
  private function buildBodyValue(Request $request): array|string|null {
    $mode = $this->getSettingString('capture.body_mode', 'off');
    if ($mode === 'off') {
      return NULL;
    }

    // Parsed request parameters only (form-encoded / multipart).
    $params = $request->request->all();
    if (!$params) {
      return NULL;
    }

    if ($mode === 'keys_only') {
      return array_values(array_keys($params));
    }

    if ($mode === 'allowlist') {
      $allow = $this->normalizeKeys($this->getSettingArray('capture.body_allowlist'));
      if (!$allow) {
        return NULL;
      }

      $output = [];
      foreach ($allow as $key) {
        if (array_key_exists($key, $params)) {
          $output[$key] = $this->redactIfSensitiveKey($key, $params[$key]);
        }
      }

      return $output ?: NULL;
    }

    if ($mode === 'full') {
      $output = [];
      foreach ($params as $key => $value) {
        $output[(string) $key] = $this->redactIfSensitiveKey((string) $key, $value);
      }
      return $output;
    }

    return NULL;
  }

  /**
   * Normalizes a list of keys to lowercase unique values.
   *
   * @param array $keys
   *   The input keys.
   *
   * @return array
   *   The normalized key list.
   */
  private function normalizeKeys(array $keys): array {
    $output = [];

    foreach ($keys as $key) {
      $key = trim((string) $key);
      if ($key === '') {
        continue;
      }
      $output[] = strtolower($key);
    }

    return array_values(array_unique($output));
  }

  /**
   * Redacts known sensitive values and truncates large values.
   *
   * @param string $key
   *   The parameter key.
   * @param mixed $value
   *   The raw value.
   *
   * @return mixed
   *   The sanitized value.
   */
  private function redactIfSensitiveKey(string $key, mixed $value): mixed {
    if (preg_match('/(pass(word)?|token|secret|api[_-]?key|auth|session|cookie|jwt)/i', $key)) {
      return '[REDACTED]';
    }

    return $this->truncateValue($value, 2048);
  }

  /**
   * Truncates long scalar string values.
   *
   * @param mixed $value
   *   The raw value.
   * @param int $maxLen
   *   The maximum length.
   *
   * @return mixed
   *   The processed value.
   */
  private function truncateValue(mixed $value, int $maxLen): mixed {
    if (is_string($value)) {
      return (strlen($value) > $maxLen) ? substr($value, 0, $maxLen) . '…' : $value;
    }

    if (is_scalar($value) || $value === NULL) {
      return $value;
    }

    return $value;
  }

  /**
   * Anonymizes IPv4/IPv6 addresses using a best-effort strategy.
   *
   * @param string $ip
   *   The input IP address.
   *
   * @return string
   *   The anonymized IP address.
   */
  private function anonymizeIp(string $ip): string {
    if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
      $parts = explode('.', $ip);
      $parts[3] = '0';
      return implode('.', $parts);
    }

    if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
      $blocks = explode(':', $ip);
      $kept = array_slice($blocks, 0, 4);
      return implode(':', $kept) . '::';
    }

    return $ip;
  }

  /**
   * Hashes a value using SHA-256 and an optional salt.
   *
   * @param string $value
   *   The raw value.
   * @param string $salt
   *   The optional salt.
   *
   * @return string
   *   The hashed value.
   */
  private function hashValue(string $value, string $salt): string {
    return hash('sha256', $salt . '|' . $value);
  }

  /**
   * Extracts a normalized browser/OS family from a user agent string.
   *
   * @param string $userAgent
   *   The raw user agent.
   *
   * @return array
   *   A normalized browser/OS structure.
   */
  private function userAgentFamily(string $userAgent): array {
    $normalized = strtolower($userAgent);

    $browser = 'unknown';
    if (str_contains($normalized, 'edg/')) {
      $browser = 'edge';
    }
    elseif (str_contains($normalized, 'chrome/')) {
      $browser = 'chrome';
    }
    elseif (str_contains($normalized, 'safari/') && str_contains($normalized, 'version/')) {
      $browser = 'safari';
    }
    elseif (str_contains($normalized, 'firefox/')) {
      $browser = 'firefox';
    }

    $os = 'unknown';
    if (str_contains($normalized, 'windows')) {
      $os = 'windows';
    }
    elseif (str_contains($normalized, 'mac os x') || str_contains($normalized, 'macintosh')) {
      $os = 'macos';
    }
    elseif (str_contains($normalized, 'android')) {
      $os = 'android';
    }
    elseif (str_contains($normalized, 'iphone') || str_contains($normalized, 'ipad') || str_contains($normalized, 'ios')) {
      $os = 'ios';
    }
    elseif (str_contains($normalized, 'linux')) {
      $os = 'linux';
    }

    return [
      'browser' => $browser,
      'os' => $os,
    ];
  }

}
