<?php

declare(strict_types=1);

namespace Drupal\native_observability\Service;

use Symfony\Component\HttpFoundation\Request;

/**
 * Evaluates whether a request must be excluded from observability logging.
 */
final class RequestLogExclusionMatcher {

  public const ATTR_EXCLUDED = 'native_observability.request_excluded';
  public const ATTR_MATCHED_RULE_ID = 'native_observability.request_exclusion_rule_id';

  public function __construct(
    private readonly RequestLogExclusionRepository $repository,
  ) {
  }

  /**
   * Returns whether the request is excluded.
   *
   * @param \Symfony\Component\HttpFoundation\Request $request
   *   Request being evaluated.
   *
   * @return bool
   *   TRUE when excluded.
   */
  public function isExcluded(Request $request): bool {
    foreach ($this->repository->loadNormalizedEnabledRules() as $rule) {
      if ($this->matchesRule($request, $rule)) {
        $request->attributes->set(self::ATTR_EXCLUDED, TRUE);
        $request->attributes->set(self::ATTR_MATCHED_RULE_ID, (int) ($rule['id'] ?? 0));
        return TRUE;
      }
    }

    $request->attributes->set(self::ATTR_EXCLUDED, FALSE);
    return FALSE;
  }

  /**
   * Returns whether the request already has the exclusion flag.
   *
   * @param \Symfony\Component\HttpFoundation\Request|null $request
   *   Current request, if any.
   *
   * @return bool
   *   TRUE when excluded.
   */
  public static function isRequestExcluded(?Request $request): bool {
    return $request ? (bool) $request->attributes->get(self::ATTR_EXCLUDED, FALSE) : FALSE;
  }

  /**
   * Checks whether one rule matches the request.
   *
   * @param \Symfony\Component\HttpFoundation\Request $request
   *   Request to evaluate.
   * @param array<string, mixed> $rule
   *   Normalized rule.
   *
   * @return bool
   *   TRUE when the rule matches.
   */
  private function matchesRule(Request $request, array $rule): bool {
    if (!$this->matchesMethod($request, (string) ($rule['method'] ?? 'ANY'))) {
      return FALSE;
    }

    if (!$this->matchesPath($request->getPathInfo(), (string) ($rule['path_match_type'] ?? 'exact'), (string) ($rule['path_pattern'] ?? ''))) {
      return FALSE;
    }

    foreach ((array) ($rule['query_conditions'] ?? []) as $condition) {
      if (!is_array($condition) || !$this->matchesQueryCondition($request, $condition)) {
        return FALSE;
      }
    }

    return TRUE;
  }

  /**
   * Checks HTTP method.
   */
  private function matchesMethod(Request $request, string $method): bool {
    return $method === 'ANY' || strtoupper($request->getMethod()) === $method;
  }

  /**
   * Checks path pattern.
   */
  private function matchesPath(string $path, string $type, string $pattern): bool {
    return match ($type) {
      'exact' => $path === $pattern,
      'prefix' => str_starts_with($path, $pattern),
      'regex' => @preg_match($pattern, $path) === 1,
      default => FALSE,
    };
  }

  /**
   * Checks one query condition.
   *
   * @param \Symfony\Component\HttpFoundation\Request $request
   *   Current request.
   * @param array<string, string> $condition
   *   Normalized condition.
   *
   * @return bool
   *   TRUE when the condition matches.
   */
  private function matchesQueryCondition(Request $request, array $condition): bool {
    $key = (string) ($condition['key'] ?? '');
    $operator = strtolower((string) ($condition['operator'] ?? ''));
    $exists = $request->query->has($key);
    $value = $exists ? (string) $request->query->get($key) : '';

    return match ($operator) {
      'exists' => $exists,
      'missing' => !$exists,
      'equals' => $exists && $value === (string) ($condition['value'] ?? ''),
      'regex' => $exists && @preg_match((string) ($condition['value'] ?? ''), $value) === 1,
      'gte' => $exists && $this->passesComparison($value, (string) ($condition['value'] ?? ''), 'gte'),
      'lte' => $exists && $this->passesComparison($value, (string) ($condition['value'] ?? ''), 'lte'),
      'between' => $exists && $this->isBetween($value, (string) ($condition['value'] ?? ''), (string) ($condition['value_to'] ?? '')),
      default => FALSE,
    };
  }

  /**
   * Evaluates a between comparison for numeric or date-like values.
   */
  private function isBetween(string $actual, string $from, string $to): bool {
    $left = $this->normalizeComparable($actual);
    $min = $this->normalizeComparable($from);
    $max = $this->normalizeComparable($to);

    if ($left === NULL || $min === NULL || $max === NULL) {
      return FALSE;
    }

    return $left >= $min && $left <= $max;
  }

  /**
   * Compares comparable values.
   *
   * Returns 0 only when the values are not safely comparable.
   */
  private function compareComparableValues(string $left, string $right): ?int {
    $normalizedLeft = $this->normalizeComparable($left);
    $normalizedRight = $this->normalizeComparable($right);

    if ($normalizedLeft === NULL || $normalizedRight === NULL) {
      return NULL;
    }

    return $normalizedLeft <=> $normalizedRight;
  }

  /**
   * Evaluates one comparable operator while failing safely on invalid values.
   */
  private function passesComparison(string $left, string $right, string $operator): bool {
    $comparison = $this->compareComparableValues($left, $right);

    if ($comparison === NULL) {
      return FALSE;
    }

    return match ($operator) {
      'gte' => $comparison >= 0,
      'lte' => $comparison <= 0,
      default => FALSE,
    };
  }

  /**
   * Normalizes numeric or date-like values for safe comparisons.
   */
  private function normalizeComparable(string $value): int|float|null {
    $value = trim($value);
    if ($value === '') {
      return NULL;
    }

    if (is_numeric($value)) {
      return str_contains($value, '.') ? (float) $value : (int) $value;
    }

    $timestamp = strtotime($value);
    if ($timestamp !== FALSE) {
      return $timestamp;
    }

    return NULL;
  }

}
