<?php

declare(strict_types=1);

namespace Drupal\ffmpeg_media\Service;

use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Database\Connection;
use Drupal\Core\Database\IntegrityConstraintViolationException;
use Drupal\ffmpeg_media\Exception\ConcurrentLimitException;

/**
 * Service for managing concurrent conversion locks.
 *
 * @internal
 */
final class LockManager implements LockManagerInterface {

  /**
   * Table name for conversion locks.
   */
  protected const string TABLE = 'ffmpeg_media_locks';

  /**
   * Constructs a LockManager object.
   *
   * @param \Drupal\Core\Database\Connection $database
   *   Database connection.
   * @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory
   *   Config factory.
   */
  public function __construct(
    protected readonly Connection $database,
    protected readonly ConfigFactoryInterface $configFactory,
  ) {}

  /**
   * {@inheritdoc}
   */
  #[\Override]
  public function acquireLock(int $mediaId): bool {
    // First, clean up expired locks.
    $this->cleanupExpiredLocks();

    $now = time();
    $timeout = $this->getLockTimeout();
    $expiresAt = $now + $timeout;

    // Atomically insert into the lock table. The media_id unique key
    // constraint prevents duplicate locks without a TOCTOU race condition.
    // If another process already holds a lock for this media_id, the
    // constraint violation tells us it is already locked.
    try {
      $this->database->insert(self::TABLE)
        ->fields([
          'media_id' => $mediaId,
          'acquired_at' => $now,
          'expires_at' => $expiresAt,
          'worker_id' => $this->getWorkerId(),
        ])
        ->execute();
    }
    catch (IntegrityConstraintViolationException) {
      // Another process already holds a lock for this media ID.
      return FALSE;
    }

    // Post-insert verification: check concurrent limit within a transaction
    // so that the count + potential rollback are atomic.
    try {
      $transaction = $this->database->startTransaction();
    }
    catch (\Throwable) {
      // Transaction start failed; remove the lock we just inserted.
      $this->releaseLock($mediaId);
      return FALSE;
    }

    try {
      $currentCount = $this->getActiveLockCount();
      $maxConcurrent = $this->getMaxConcurrent();

      if ($currentCount > $maxConcurrent) {
        $transaction->rollBack();
        // Clean up the lock we just inserted since we're over the limit.
        $this->releaseLock($mediaId);
        throw ConcurrentLimitException::limitReached(
          $currentCount - 1,
          $maxConcurrent,
        );
      }

      return TRUE;
    }
    catch (ConcurrentLimitException $e) {
      throw $e;
    }
    catch (\Throwable $e) {
      // Unexpected failure during post-insert verification.
      // Roll back and release the lock to avoid orphaned entries.
      $transaction->rollBack();
      $this->releaseLock($mediaId);
      throw $e;
    }
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  public function releaseLock(int $mediaId): void {
    $this->database->delete(self::TABLE)
      ->condition('media_id', $mediaId)
      ->execute();
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  #[\NoDiscard]
  public function hasLock(int $mediaId): bool {
    $statement = $this->database->select(self::TABLE, 'l')
      ->condition('media_id', $mediaId)
      ->condition('expires_at', time(), '>')
      ->countQuery()
      ->execute();

    return (int) ($statement?->fetchField() ?? 0) > 0;
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  #[\NoDiscard]
  public function getActiveLockCount(): int {
    $statement = $this->database->select(self::TABLE, 'l')
      ->condition('expires_at', time(), '>')
      ->countQuery()
      ->execute();

    return (int) ($statement?->fetchField() ?? 0);
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  #[\NoDiscard]
  public function getActiveLocks(): array {
    $now = time();

    $query = $this->database->select(self::TABLE, 'l')
      ->fields('l', ['media_id', 'acquired_at', 'expires_at', 'worker_id'])
      ->condition('expires_at', $now, '>')
      ->orderBy('acquired_at', 'ASC');

    $statement = $query->execute();
    if ($statement === NULL) {
      return [];
    }

    $results = [];
    foreach ($statement as $row) {
      $results[(int) $row->media_id] = [
        'media_id' => (int) $row->media_id,
        'acquired_at' => (int) $row->acquired_at,
        'expires_at' => (int) $row->expires_at,
        'worker_id' => $row->worker_id,
      ];
    }

    return $results;
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  #[\NoDiscard]
  public function isLimitReached(): bool {
    return $this->getActiveLockCount() >= $this->getMaxConcurrent();
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  #[\NoDiscard]
  public function getMaxConcurrent(): int {
    $config = $this->configFactory->get('ffmpeg_media.settings');
    return (int) ($config->get('max_concurrent') ?? self::DEFAULT_MAX_CONCURRENT);
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  public function cleanupExpiredLocks(): int {
    return (int) $this->database->delete(self::TABLE)
      ->condition('expires_at', time(), '<=')
      ->execute();
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  public function extendLock(int $mediaId, ?int $additionalSeconds = NULL): bool {
    $additionalSeconds ??= $this->getLockTimeout();
    $newExpiry = time() + $additionalSeconds;

    $updated = $this->database->update(self::TABLE)
      ->fields(['expires_at' => $newExpiry])
      ->condition('media_id', $mediaId)
      ->execute();

    return $updated > 0;
  }

  /**
   * Gets the lock timeout configuration.
   *
   * @return int
   *   Lock timeout in seconds.
   */
  protected function getLockTimeout(): int {
    $config = $this->configFactory->get('ffmpeg_media.settings');
    return (int) ($config->get('lock_timeout') ?? self::DEFAULT_LOCK_TIMEOUT);
  }

  /**
   * Gets a unique worker identifier.
   *
   * @return string
   *   Worker ID string.
   */
  protected function getWorkerId(): string {
    return sprintf('%s_%d_%d_%s', gethostname() ?: 'unknown', getmypid(), hrtime(TRUE), bin2hex(random_bytes(4)));
  }

}
