<?php

declare(strict_types=1);

namespace Drupal\ffmpeg_media\Service;

use Drupal\Component\Datetime\TimeInterface;
use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Database\Connection;
use Drupal\Core\Lock\LockBackendInterface;
use Drupal\ffmpeg_media\ValueObject\MetricsCollectorDependencies;
use Psr\Log\LoggerInterface;

/**
 * Service for collecting and exposing Prometheus metrics.
 *
 * This service currently handles metric collection, database storage,
 * and request-scoped caching. Prometheus formatting is delegated to
 * MetricsPrometheusFormatter. Future refactoring may extract the
 * collection and storage concerns into separate MetricsCollector and
 * MetricsStorage services to allow pluggable backends (e.g. InfluxDB).
 *
 * @internal
 */
final class MetricsService implements MetricsServiceInterface {

  /**
   * Metrics table name.
   */
  protected const string METRICS_TABLE = 'ffmpeg_media_metrics';

  /**
   * Cache TTL for Prometheus output (seconds).
   */
  private const int PROMETHEUS_CACHE_TTL = 10;

  /**
   * Request-scoped cache for stored metrics.
   *
   * @var array<string, mixed>|null
   */
  private ?array $storedMetricsCache = NULL;

  /**
   * Request-scoped cache for active conversions.
   *
   * @var array<int, mixed>|null
   */
  private ?array $activeConversionsCache = NULL;

  /**
   * Formats collected metrics as Prometheus exposition output.
   */
  private readonly MetricsPrometheusFormatter $prometheusFormatter;

  /**
   * Constructs a MetricsService object.
   *
   * @param \Drupal\Core\Database\Connection $database
   *   Database connection.
   * @param \Drupal\ffmpeg_media\ValueObject\MetricsCollectorDependencies $dataSources
   *   Aggregated metrics data-source dependencies.
   * @param \Drupal\Component\Datetime\TimeInterface $time
   *   Time service.
   * @param \Psr\Log\LoggerInterface $logger
   *   Logger.
   * @param \Drupal\Core\Lock\LockBackendInterface $lockBackend
   *   Lock backend for serializing JSON metric updates.
   * @param \Drupal\Core\Cache\CacheBackendInterface $cache
   *   Cache backend for Prometheus output caching.
   * @param \Drupal\ffmpeg_media\Service\MetricsPrometheusFormatter $prometheusFormatter
   *   Prometheus output formatter.
   */
  public function __construct(
    protected readonly Connection $database,
    protected readonly MetricsCollectorDependencies $dataSources,
    protected readonly TimeInterface $time,
    protected readonly LoggerInterface $logger,
    protected readonly LockBackendInterface $lockBackend,
    protected readonly CacheBackendInterface $cache,
    MetricsPrometheusFormatter $prometheusFormatter,
  ) {
    $this->prometheusFormatter = $prometheusFormatter;
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  #[\NoDiscard]
  public function getPrometheusMetrics(array $labels = []): string {
    $cacheKey = 'ffmpeg_media:prometheus:' . hash('sha256', $this->encodeLabelsForCacheKey($labels));
    $cached = $this->cache->get($cacheKey);
    if ($cached !== FALSE) {
      return (string) $cached->data;
    }

    $conversion = $this->getConversionMetrics();
    $queue = $this->getQueueMetrics();
    $hardware = $this->getHardwareMetrics();
    $system = $this->getSystemMetrics();
    $result = $this->prometheusFormatter->format($conversion, $queue, $hardware, $system, $labels);

    $this->cache->set(
      $cacheKey,
      $result,
      $this->time->getRequestTime() + self::PROMETHEUS_CACHE_TTL,
    );

    return $result;
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  #[\NoDiscard]
  public function getConversionMetrics(): array {
    $metrics = $this->getStoredMetrics();

    return [
      'total' => $metrics['conversions_total'] ?? 0,
      'successful' => $metrics['conversions_successful'] ?? 0,
      'failed' => $metrics['conversions_failed'] ?? 0,
      'in_progress' => $this->getActiveConversionCount(),
      'avg_duration_seconds' => $metrics['avg_duration_seconds'] ?? 0.0,
      'total_duration_seconds' => $metrics['total_duration_seconds'] ?? 0.0,
      'by_codec' => $metrics['by_codec'] ?? [],
      'by_output' => $metrics['by_output'] ?? [],
    ];
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  #[\NoDiscard]
  public function getQueueMetrics(): array {
    $queueStats = $this->dataSources->queueManager->getQueueStats();
    $metrics = $this->getStoredMetrics();

    return [
      'standard_queue_size' => $queueStats['standard_queue'],
      'large_queue_size' => $queueStats['large_queue'],
      'total_queue_size' => $queueStats['standard_queue'] + $queueStats['large_queue'],
      'avg_wait_time_seconds' => $metrics['avg_wait_time_seconds'] ?? 0.0,
      'max_wait_time_seconds' => $metrics['max_wait_time_seconds'] ?? 0.0,
      'items_processed_total' => $metrics['items_processed_total'] ?? 0,
      'items_failed_total' => $metrics['items_failed_total'] ?? 0,
    ];
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  #[\NoDiscard]
  public function getHardwareMetrics(): array {
    $hwSetting = $this->dataSources->configValueProvider->getHardwareAcceleration();

    $hwAvailable = FALSE;
    $activeEncoder = NULL;

    try {
      $capabilities = $this->dataSources->hardwareDetector->getCapabilities();
      $encoders = $capabilities['available_encoders'];
      $hwAvailable = $encoders !== [];
      if ($hwAvailable && $hwSetting !== 'disabled') {
        $activeEncoder = $encoders[0] ?? NULL;
      }
    }
    catch (\Throwable) {
      // Hardware detection failed.
    }

    $circuitOpen = FALSE;
    if ($activeEncoder !== NULL) {
      $circuitOpen = $this->dataSources->circuitBreaker->isOpen($activeEncoder);
    }

    $metrics = $this->getStoredMetrics();

    return [
      'hardware_available' => $hwAvailable,
      'hardware_enabled' => $hwSetting !== 'disabled',
      'active_encoder' => $activeEncoder,
      'circuit_breaker_open' => $circuitOpen,
      'hw_conversions_total' => $metrics['hw_conversions_total'] ?? 0,
      'hw_conversions_failed' => $metrics['hw_conversions_failed'] ?? 0,
      'sw_fallback_total' => $metrics['sw_fallback_total'] ?? 0,
    ];
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  #[\NoDiscard]
  public function getSystemMetrics(): array {
    $maxConcurrent = $this->dataSources->configValueProvider->getMaxConcurrent();
    $locksHeld = $this->dataSources->lockManager->getActiveLockCount();
    $activeCount = $this->getActiveConversionCount();

    return [
      'concurrent_conversions' => $activeCount,
      'max_concurrent' => $maxConcurrent,
      'locks_held' => $locksHeld,
      // Use the internal progress-tracker count instead of host-specific
      // pgrep. This is portable across containers/platforms and accurate
      // for the module's own conversion state.
      'ffmpeg_processes' => $activeCount,
    ];
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  public function recordConversion(
    int $mediaId,
    bool $success,
    float $durationSeconds,
    ?string $codec = NULL,
    ?string $outputFormat = NULL,
    bool $hardwareAccelerated = FALSE,
  ): void {
    $this->incrementMetric('conversions_total');
    $this->addToDurationMetrics($durationSeconds);

    if ($success) {
      $this->incrementMetric('conversions_successful');
      $this->incrementMetric('items_processed_total');
    }
    else {
      $this->incrementMetric('conversions_failed');
      $this->incrementMetric('items_failed_total');
    }

    if ($hardwareAccelerated) {
      $this->incrementMetric('hw_conversions_total');
      if (!$success) {
        $this->incrementMetric('hw_conversions_failed');
      }
    }

    if ($codec !== NULL) {
      $this->incrementCodecMetric($codec, $success);
    }

    if ($outputFormat !== NULL) {
      $this->incrementOutputMetric($outputFormat, $success);
    }
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  public function recordQueueWait(float $waitTimeSeconds): void {
    $this->updateWaitTimeMetrics($waitTimeSeconds);
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  public function recordHardwareFallback(string $encoder, string $reason): void {
    $this->incrementMetric('sw_fallback_total');
  }

  /**
   * Gets stored metrics from database.
   *
   * @return array<string, mixed>
   *   Stored metrics.
   */
  protected function getStoredMetrics(): array {
    if ($this->storedMetricsCache !== NULL) {
      return $this->storedMetricsCache;
    }

    try {
      $result = $this->database->select(self::METRICS_TABLE, 'm')
        ->fields('m', ['metric_key', 'metric_value'])
        ->execute();

      $metrics = [];
      if ($result !== NULL) {
        foreach ($result as $row) {
          $value = json_decode($row->metric_value, TRUE);
          $metrics[$row->metric_key] = $value;
        }
      }

      $this->storedMetricsCache = $metrics;

      return $metrics;
    }
    catch (\Throwable) {
      return [];
    }
  }

  /**
   * Gets the number of active conversions with request-scoped caching.
   */
  #[\NoDiscard]
  private function getActiveConversionCount(): int {
    return count($this->getActiveConversions());
  }

  /**
   * Gets active conversions with request-scoped caching.
   *
   * @return array<int, mixed>
   *   Active conversion records from the progress tracker.
   */
  #[\NoDiscard]
  private function getActiveConversions(): array {
    if ($this->activeConversionsCache === NULL) {
      $this->activeConversionsCache = $this->dataSources->progressTracker->getActiveConversions();
    }

    return $this->activeConversionsCache;
  }

  /**
   * Invalidates the request-scoped stored metrics cache.
   */
  private function invalidateMetricsCache(): void {
    $this->storedMetricsCache = NULL;
  }

  /**
   * Increments a counter metric.
   *
   * @param string $key
   *   Metric key.
   * @param int $amount
   *   Amount to increment.
   */
  protected function incrementMetric(string $key, int $amount = 1): void {
    try {
      $this->database->merge(self::METRICS_TABLE)
        ->key('metric_key', $key)
        ->expression('metric_value', 'CAST(COALESCE([metric_value], \'0\') AS DECIMAL) + :amount', [':amount' => $amount])
        ->insertFields([
          'metric_key' => $key,
          'metric_value' => (string) $amount,
          'updated' => $this->time->getRequestTime(),
        ])
        ->updateFields([
          'updated' => $this->time->getRequestTime(),
        ])
        ->execute();
      $this->invalidateMetricsCache();
    }
    catch (\Throwable $e) {
      $this->logger->warning('Failed to increment metric @key: @error', [
        '@key' => $key,
        '@error' => $e->getMessage(),
      ]);
    }
  }

  /**
   * Updates duration metrics.
   *
   * @param float $durationSeconds
   *   Duration to add.
   */
  protected function addToDurationMetrics(float $durationSeconds): void {
    try {
      // Update total duration.
      $this->database->merge(self::METRICS_TABLE)
        ->key('metric_key', 'total_duration_seconds')
        ->expression('metric_value', 'CAST(COALESCE([metric_value], \'0\') AS DECIMAL) + :duration', [':duration' => $durationSeconds])
        ->insertFields([
          'metric_key' => 'total_duration_seconds',
          'metric_value' => (string) $durationSeconds,
          'updated' => $this->time->getRequestTime(),
        ])
        ->updateFields([
          'updated' => $this->time->getRequestTime(),
        ])
        ->execute();
      $this->invalidateMetricsCache();

      // Calculate new average.
      $metrics = $this->getStoredMetrics();
      $total = $metrics['conversions_total'] ?? 1;
      $totalDuration = $metrics['total_duration_seconds'] ?? 0.0;
      $avg = $total > 0 ? $totalDuration / $total : 0.0;

      $this->database->merge(self::METRICS_TABLE)
        ->key('metric_key', 'avg_duration_seconds')
        ->fields([
          'metric_key' => 'avg_duration_seconds',
          'metric_value' => (string) $avg,
          'updated' => $this->time->getRequestTime(),
        ])
        ->execute();
      $this->invalidateMetricsCache();
    }
    catch (\Throwable $e) {
      $this->logger->warning('Failed to update duration metrics: @error', [
        '@error' => $e->getMessage(),
      ]);
    }
  }

  /**
   * Updates wait time metrics.
   *
   * @param float $waitTimeSeconds
   *   Wait time to record.
   */
  protected function updateWaitTimeMetrics(float $waitTimeSeconds): void {
    // Lock ordering note: The lock is acquired BEFORE the transaction
    // to serialize writers. The transaction auto-commits when $transaction
    // goes out of scope (Drupal Connection behavior). The lock is released
    // in the finally block AFTER the transaction variable is out of scope,
    // ensuring commit completes before other writers proceed.
    $lockId = 'ffmpeg_media.metrics.wait_time';
    if (!$this->lockBackend->acquire($lockId, 15.0)) {
      $this->logger->warning('Failed to acquire wait time metrics lock.');
      return;
    }

    try {
      $transaction = $this->database->startTransaction();
    }
    catch (\Throwable $e) {
      $this->logger->warning('Failed to start transaction for wait time metrics: @error', [
        '@error' => $e->getMessage(),
      ]);
      $this->lockBackend->release($lockId);
      return;
    }

    try {
      $this->invalidateMetricsCache();
      $metrics = $this->getStoredMetrics();
      $currentMax = $metrics['max_wait_time_seconds'] ?? 0.0;

      if ($waitTimeSeconds > $currentMax) {
        $this->database->merge(self::METRICS_TABLE)
          ->key('metric_key', 'max_wait_time_seconds')
          ->fields([
            'metric_key' => 'max_wait_time_seconds',
            'metric_value' => (string) $waitTimeSeconds,
            'updated' => $this->time->getRequestTime(),
          ])
          ->execute();
      }

      // Update running average.
      $count = ($metrics['wait_time_count'] ?? 0) + 1;
      $totalWait = ($metrics['total_wait_time_seconds'] ?? 0.0) + $waitTimeSeconds;
      $avg = $totalWait / $count;

      $this->database->merge(self::METRICS_TABLE)
        ->key('metric_key', 'avg_wait_time_seconds')
        ->fields([
          'metric_key' => 'avg_wait_time_seconds',
          'metric_value' => (string) $avg,
          'updated' => $this->time->getRequestTime(),
        ])
        ->execute();

      $this->database->merge(self::METRICS_TABLE)
        ->key('metric_key', 'wait_time_count')
        ->fields([
          'metric_key' => 'wait_time_count',
          'metric_value' => (string) $count,
          'updated' => $this->time->getRequestTime(),
        ])
        ->execute();

      $this->database->merge(self::METRICS_TABLE)
        ->key('metric_key', 'total_wait_time_seconds')
        ->fields([
          'metric_key' => 'total_wait_time_seconds',
          'metric_value' => (string) $totalWait,
          'updated' => $this->time->getRequestTime(),
        ])
        ->execute();
      $this->invalidateMetricsCache();
    }
    catch (\Throwable $e) {
      $transaction->rollBack();
      $this->logger->warning('Failed to update wait time metrics: @error', [
        '@error' => $e->getMessage(),
      ]);
    }
    finally {
      $this->lockBackend->release($lockId);
    }
  }

  /**
   * Increments codec-specific metrics.
   *
   * @param string $codec
   *   Codec name.
   * @param bool $success
   *   Whether conversion succeeded.
   */
  protected function incrementCodecMetric(string $codec, bool $success): void {
    $lockId = 'ffmpeg_media.metrics.by_codec';
    if (!$this->lockBackend->acquire($lockId, 15.0)) {
      $this->logger->warning('Failed to acquire codec metrics lock for @codec.', [
        '@codec' => $codec,
      ]);
      return;
    }

    try {
      $transaction = $this->database->startTransaction();
    }
    catch (\Throwable $e) {
      $this->logger->warning('Failed to start transaction for codec metric @codec: @error', [
        '@codec' => $codec,
        '@error' => $e->getMessage(),
      ]);
      $this->lockBackend->release($lockId);
      return;
    }

    try {
      // Always refresh from storage while holding lock to avoid stale writes.
      $this->invalidateMetricsCache();
      $metrics = $this->getStoredMetrics();
      $byCodec = $metrics['by_codec'] ?? [];

      if (!isset($byCodec[$codec])) {
        $byCodec[$codec] = ['total' => 0, 'successful' => 0, 'failed' => 0];
      }

      $byCodec[$codec]['total']++;
      if ($success) {
        $byCodec[$codec]['successful']++;
      }
      else {
        $byCodec[$codec]['failed']++;
      }

      $this->database->merge(self::METRICS_TABLE)
        ->key('metric_key', 'by_codec')
        ->fields([
          'metric_key' => 'by_codec',
          'metric_value' => json_encode($byCodec),
          'updated' => $this->time->getRequestTime(),
        ])
        ->execute();
      $this->invalidateMetricsCache();
    }
    catch (\Throwable $e) {
      $transaction->rollBack();
      $this->logger->warning('Failed to update codec metric for @codec: @error', [
        '@codec' => $codec,
        '@error' => $e->getMessage(),
      ]);
    }
    finally {
      $this->lockBackend->release($lockId);
    }
  }

  /**
   * Increments output format-specific metrics.
   *
   * @param string $outputFormat
   *   Output format name.
   * @param bool $success
   *   Whether conversion succeeded.
   */
  protected function incrementOutputMetric(string $outputFormat, bool $success): void {
    $lockId = 'ffmpeg_media.metrics.by_output';
    if (!$this->lockBackend->acquire($lockId, 15.0)) {
      $this->logger->warning('Failed to acquire output metrics lock for @format.', [
        '@format' => $outputFormat,
      ]);
      return;
    }

    try {
      $transaction = $this->database->startTransaction();
    }
    catch (\Throwable $e) {
      $this->logger->warning('Failed to start transaction for output metric @format: @error', [
        '@format' => $outputFormat,
        '@error' => $e->getMessage(),
      ]);
      $this->lockBackend->release($lockId);
      return;
    }

    try {
      // Always refresh from storage while holding lock to avoid stale writes.
      $this->invalidateMetricsCache();
      $metrics = $this->getStoredMetrics();
      $byOutput = $metrics['by_output'] ?? [];

      if (!isset($byOutput[$outputFormat])) {
        $byOutput[$outputFormat] = ['total' => 0, 'successful' => 0, 'failed' => 0];
      }

      $byOutput[$outputFormat]['total']++;
      if ($success) {
        $byOutput[$outputFormat]['successful']++;
      }
      else {
        $byOutput[$outputFormat]['failed']++;
      }

      $this->database->merge(self::METRICS_TABLE)
        ->key('metric_key', 'by_output')
        ->fields([
          'metric_key' => 'by_output',
          'metric_value' => json_encode($byOutput),
          'updated' => $this->time->getRequestTime(),
        ])
        ->execute();
      $this->invalidateMetricsCache();
    }
    catch (\Throwable $e) {
      $transaction->rollBack();
      $this->logger->warning('Failed to update output metric for @format: @error', [
        '@format' => $outputFormat,
        '@error' => $e->getMessage(),
      ]);
    }
    finally {
      $this->lockBackend->release($lockId);
    }
  }

  /**
   * Builds a deterministic cache key payload for Prometheus labels.
   *
   * @param array<string, mixed> $labels
   *   Prometheus labels.
   *
   * @return string
   *   Deterministically encoded label payload.
   */
  private function encodeLabelsForCacheKey(array $labels): string {
    ksort($labels);
    $normalized = [];

    foreach ($labels as $key => $value) {
      if (is_array($value)) {
        $normalized[$key] = $this->encodeLabelsForCacheKey($value);
        continue;
      }

      if (is_bool($value)) {
        $normalized[$key] = $value ? '1' : '0';
        continue;
      }

      if ($value === NULL) {
        $normalized[$key] = '';
        continue;
      }

      if (is_scalar($value)) {
        $normalized[$key] = (string) $value;
        continue;
      }

      $normalized[$key] = get_debug_type($value);
    }

    $encoded = json_encode($normalized, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
    return $encoded === FALSE ? '[]' : $encoded;
  }

}
