<?php

declare(strict_types=1);

namespace Drupal\ffmpeg_media\Hook;

use Drupal\Core\Hook\Attribute\Hook;
use Drupal\Core\Logger\LoggerChannelInterface;
use Drupal\ffmpeg_media\Service\SegmentCleanupServiceInterface;
use Drupal\ffmpeg_media\ValueObject\CronDependencies;
use Drupal\media\MediaInterface;

/**
 * Cron-related hook implementations.
 */
final class CronHooks {

  /**
   * Default stuck process timeout in seconds (2 hours).
   */
  protected const int DEFAULT_STUCK_TIMEOUT = 7200;

  /**
   * Interval between integrity checks in seconds (24 hours).
   */
  protected const int INTEGRITY_CHECK_INTERVAL = 86400;

  /**
   * Maximum number of stuck conversions to process per cron run.
   */
  protected const int STUCK_CLEANUP_BATCH_LIMIT = 100;

  /**
   * Maximum number of orphaned locks to process per cron run.
   */
  protected const int ORPHANED_LOCK_BATCH_LIMIT = 1000;

  /**
   * Dead-letter retention period in days.
   */
  protected const int DEAD_LETTER_RETENTION_DAYS = 90;

  /**
   * Constructs a CronHooks object.
   *
   * @param \Drupal\ffmpeg_media\ValueObject\CronDependencies $deps
   *   Aggregated cron pipeline dependencies.
   * @param \Drupal\Core\Logger\LoggerChannelInterface $logger
   *   Logger channel.
   */
  public function __construct(
    protected readonly CronDependencies $deps,
    protected readonly LoggerChannelInterface $logger,
  ) {}

  /**
   * Implements hook_cron().
   */
  #[Hook('cron')]
  public function cron(): void {
    if (!$this->deps->lockBackend->acquire('ffmpeg_media_cron', 120.0)) {
      return;
    }

    try {
      $this->doCron();
    }
    finally {
      $this->deps->lockBackend->release('ffmpeg_media_cron');
    }
  }

  /**
   * Executes all cron operations (called within lock).
   */
  protected function doCron(): void {
    try {
      $this->cleanupProgressRecords();
    }
    catch (\Throwable $e) {
      $this->logger->error('Cron operation cleanupProgressRecords failed: @message', [
        '@message' => $e->getMessage(),
      ]);
    }

    try {
      $this->cleanupOrphanedLocks();
    }
    catch (\Throwable $e) {
      $this->logger->error('Cron operation cleanupOrphanedLocks failed: @message', [
        '@message' => $e->getMessage(),
      ]);
    }

    try {
      $this->cleanupRetryRecords();
    }
    catch (\Throwable $e) {
      $this->logger->error('Cron operation cleanupRetryRecords failed: @message', [
        '@message' => $e->getMessage(),
      ]);
    }

    try {
      $this->deps->originalFileRetention->processDue();
    }
    catch (\Throwable $e) {
      $this->logger->error('Cron operation originalFileRetention failed: @message', [
        '@message' => $e->getMessage(),
      ]);
    }

    // Auto-requeue failed conversions.
    try {
      $config = $this->deps->configFactory->get('ffmpeg_media.settings');
      if ($config->get('stuck_auto_requeue') && $config->get('retry.enabled')) {
        $this->processAutoRequeue();
      }
    }
    catch (\Throwable $e) {
      $this->logger->error('Cron operation processAutoRequeue failed: @message', [
        '@message' => $e->getMessage(),
      ]);
    }

    // Cleanup stuck conversions.
    try {
      $this->cleanupStuckConversions();
    }
    catch (\Throwable $e) {
      $this->logger->error('Cron operation cleanupStuckConversions failed: @message', [
        '@message' => $e->getMessage(),
      ]);
    }

    // Cleanup stale dedup entries (entries older than 24 hours).
    try {
      $this->cleanupStaleQueueEntries();
    }
    catch (\Throwable $e) {
      $this->logger->error('Cron operation cleanupStaleQueueEntries failed: @message', [
        '@message' => $e->getMessage(),
      ]);
    }

    // Periodic field integrity check.
    try {
      $this->periodicIntegrityCheck();
    }
    catch (\Throwable $e) {
      $this->logger->error('Cron operation periodicIntegrityCheck failed: @message', [
        '@message' => $e->getMessage(),
      ]);
    }

    // Purge old dead-letter entries.
    try {
      $this->cleanupDeadLetterEntries();
    }
    catch (\Throwable $e) {
      $this->logger->error('Cron operation cleanupDeadLetterEntries failed: @message', [
        '@message' => $e->getMessage(),
      ]);
    }

    // Cleanup orphaned streaming segment directories.
    try {
      $this->cleanupOrphanedSegments();
    }
    catch (\Throwable $e) {
      $this->logger->error('Cron operation cleanupOrphanedSegments failed: @message', [
        '@message' => $e->getMessage(),
      ]);
    }

    // Cleanup orphaned FFmpeg temp files (two-pass logs, partial outputs).
    try {
      $this->cleanupOrphanedTempFiles();
    }
    catch (\Throwable $e) {
      $this->logger->error('Cron operation cleanupOrphanedTempFiles failed: @message', [
        '@message' => $e->getMessage(),
      ]);
    }

    // Cleanup orphaned thumbnail session directories.
    try {
      $this->cleanupOrphanedThumbnails();
    }
    catch (\Throwable $e) {
      $this->logger->error('Cron operation cleanupOrphanedThumbnails failed: @message', [
        '@message' => $e->getMessage(),
      ]);
    }

    // Cleanup orphaned preview image files.
    try {
      $this->cleanupOrphanedPreviews();
    }
    catch (\Throwable $e) {
      $this->logger->error('Cron operation cleanupOrphanedPreviews failed: @message', [
        '@message' => $e->getMessage(),
      ]);
    }

    // Cleanup orphaned passlog files from two-pass encoding.
    try {
      $this->cleanupOrphanedPasslogFiles();
    }
    catch (\Throwable $e) {
      $this->logger->error('Cron operation cleanupOrphanedPasslogFiles failed: @message', [
        '@message' => $e->getMessage(),
      ]);
    }
  }

  /**
   * Cleans up old progress records.
   */
  protected function cleanupProgressRecords(): void {
    $config = $this->deps->configFactory->get('ffmpeg_media.settings');
    $maxAge = $config->get('cleanup_age') ?? 86400;

    $count = $this->deps->progressTracker->cleanup($maxAge);
    if ($count > 0) {
      $this->logger->info('Cleaned up @count old progress records.', ['@count' => $count]);
    }
  }

  /**
   * Detects and handles stuck conversions.
   *
   * Conversions that have been "in progress" for longer than the configured
   * stuck_process_timeout are considered stuck.
   *
   * @return int
   *   Number of stuck conversions detected.
   */
  public function cleanupStuckConversions(): int {
    $config = $this->deps->configFactory->get('ffmpeg_media.settings');
    $stuckTimeout = $config->get('stuck_process_timeout') ?? self::DEFAULT_STUCK_TIMEOUT;
    $autoRequeue = $config->get('stuck_auto_requeue') ?? FALSE;

    $staleThreshold = time() - $stuckTimeout;
    $stuckConversions = $this->deps->progressTracker->getStuckConversions($staleThreshold);
    $stuckCount = 0;

    foreach ($stuckConversions as $progress) {
      if ($stuckCount >= self::STUCK_CLEANUP_BATCH_LIMIT) {
        $this->logger->info('Stuck cleanup batch limit reached (@limit). Remaining items deferred to next cron.', [
          '@limit' => self::STUCK_CLEANUP_BATCH_LIMIT,
        ]);
        break;
      }
      $stuckCount++;

      $this->logger->warning('Detected stuck conversion for media @id (no progress for @minutes minutes).', [
        '@id' => $progress->mediaId,
        '@minutes' => (int) ((time() - $progress->updatedAt) / 60),
      ]);

      $fieldName = $progress->fieldName;

      // Mark as failed.
      $this->deps->progressTracker->markFailed(
        $progress->mediaId,
        sprintf('Conversion stalled — no progress update for %d seconds (stuck process detected).', time() - $progress->updatedAt),
        $fieldName,
      );

      // Release any lock.
      $this->deps->lockManager->releaseLock($progress->mediaId);

      // Always remove dedup marker so the item can be re-queued
      // (manually or via auto-requeue). Without this, the marker
      // would block re-queuing until the stale-cleanup window.
      $this->deps->queueManager->removeFromQueue($progress->mediaId, $fieldName);

      // Optionally requeue for retry.
      if ($autoRequeue && $this->deps->retryManager->shouldRetry($progress->mediaId, $fieldName)) {
        $this->deps->retryManager->recordRetry($progress->mediaId, 'Stuck process detected', $fieldName);

        $this->logger->info('Stuck conversion for media @id will be retried.', [
          '@id' => $progress->mediaId,
        ]);
      }
    }

    if ($stuckCount > 0) {
      $this->logger->info('Cleaned up @count stuck conversions', ['@count' => $stuckCount]);
    }

    return $stuckCount;
  }

  /**
   * Cleans up orphaned locks.
   *
   * Orphaned locks are those without corresponding active conversions.
   */
  protected function cleanupOrphanedLocks(): void {
    // First clean up expired locks.
    $expiredCount = $this->deps->lockManager->cleanupExpiredLocks();

    if ($expiredCount > 0) {
      $this->logger->info('Cleaned up @count expired locks.', ['@count' => $expiredCount]);
    }

    // Check for locks without corresponding active conversions.
    $activeLocks = $this->deps->lockManager->getActiveLocks();
    $orphanedCount = 0;

    // Batch-load progress for all locked media IDs to avoid N+1 queries.
    $mediaIds = array_column($activeLocks, 'media_id');
    $progressMap = $this->deps->progressTracker->getMultipleProgress($mediaIds);

    foreach ($activeLocks as $lock) {
      if ($orphanedCount >= self::ORPHANED_LOCK_BATCH_LIMIT) {
        $this->logger->info('Orphaned lock cleanup batch limit reached (@limit). Remaining items deferred to next cron.', [
          '@limit' => self::ORPHANED_LOCK_BATCH_LIMIT,
        ]);
        break;
      }

      $progress = $progressMap[$lock['media_id']] ?? NULL;

      // If no progress record or conversion is not in active state,
      // lock is orphaned.
      if ($progress === NULL || (!$progress->isPending && !$progress->isInProgress)) {
        $this->deps->lockManager->releaseLock($lock['media_id']);
        $orphanedCount++;

        $this->logger->notice('Released orphaned lock for media @id.', [
          '@id' => $lock['media_id'],
        ]);
      }
    }

    if ($orphanedCount > 0) {
      $this->logger->info('Cleaned up @count orphaned locks.', ['@count' => $orphanedCount]);
    }
  }

  /**
   * Cleans up old retry records.
   */
  protected function cleanupRetryRecords(): void {
    // Clean up retry records older than 7 days.
    $count = $this->deps->retryManager->cleanup(604800);
    if ($count > 0) {
      $this->logger->info('Cleaned up @count old retry records.', ['@count' => $count]);
    }
  }

  /**
   * Cleans up stale queue deduplication entries.
   *
   * Removes entries from the dedup table older than 24 hours to prevent
   * orphaned entries from permanently blocking media re-queuing.
   */
  protected function cleanupStaleQueueEntries(): void {
    $count = $this->deps->queueManager->cleanupStaleQueueEntries(86400);
    if ($count > 0) {
      $this->logger->info('Cleaned up @count stale queue dedup entries.', ['@count' => $count]);
    }
  }

  /**
   * Runs a periodic field integrity check (max once per day).
   */
  protected function periodicIntegrityCheck(): void {
    $lastCheck = $this->deps->fieldIntegrity->getLastCheckTime();

    if ($lastCheck !== NULL && (time() - $lastCheck) < self::INTEGRITY_CHECK_INTERVAL) {
      return;
    }

    $problems = $this->deps->fieldIntegrity->check();

    if (!empty($problems)) {
      $this->logger->warning('Field integrity check found @count problem(s).', [
        '@count' => count($problems),
      ]);

      foreach ($problems as $problem) {
        $this->logger->warning('Field integrity issue: @type for @entity_type.@field - @details', [
          '@type' => $problem['type'],
          '@entity_type' => $problem['entity_type'],
          '@field' => $problem['field_name'],
          '@details' => $problem['details'],
        ]);
      }
    }
  }

  /**
   * Cleans up orphaned streaming segment directories.
   *
   * Removes HLS/DASH segment directories for conversions that have
   * failed or been abandoned past the configured age threshold.
   */
  protected function cleanupOrphanedSegments(): void {
    $config = $this->deps->configFactory->get('ffmpeg_media.settings');
    $maxAge = $config->get('segment_cleanup_age')
      ?? SegmentCleanupServiceInterface::DEFAULT_SEGMENT_CLEANUP_AGE;

    $count = $this->deps->segmentCleanup->cleanupOrphanedSegments((int) $maxAge);
    if ($count > 0) {
      $this->logger->info('Cleaned up @count orphaned streaming segment directories.', [
        '@count' => $count,
      ]);
    }
  }

  /**
   * Cleans up orphaned FFmpeg temporary working files.
   *
   * Removes two-pass log files, partial outputs, and other temp artifacts
   * left behind by crashed or interrupted FFmpeg processes.
   */
  protected function cleanupOrphanedTempFiles(): void {
    $config = $this->deps->configFactory->get('ffmpeg_media.settings');
    $maxAge = $config->get('segment_cleanup_age')
      ?? SegmentCleanupServiceInterface::DEFAULT_SEGMENT_CLEANUP_AGE;

    $count = $this->deps->segmentCleanup->cleanupOrphanedTempFiles((int) $maxAge);
    if ($count > 0) {
      $this->logger->info('Cleaned up @count orphaned FFmpeg temp files.', [
        '@count' => $count,
      ]);
    }
  }

  /**
   * Purges dead-letter entries older than the retention period.
   */
  protected function cleanupDeadLetterEntries(): void {
    $count = $this->deps->deadLetterStorage->purge(self::DEAD_LETTER_RETENTION_DAYS);
    if ($count > 0) {
      $this->logger->info('Purged @count dead-letter entries older than @days days.', [
        '@count' => $count,
        '@days' => self::DEAD_LETTER_RETENTION_DAYS,
      ]);
    }
  }

  /**
   * Cleans up orphaned thumbnail session directories.
   */
  protected function cleanupOrphanedThumbnails(): void {
    $count = $this->deps->segmentCleanup->cleanupOrphanedThumbnails();
    if ($count > 0) {
      $this->logger->info('Cleaned up @count orphaned thumbnail directories.', [
        '@count' => $count,
      ]);
    }
  }

  /**
   * Cleans up orphaned preview image files.
   */
  protected function cleanupOrphanedPreviews(): void {
    $count = $this->deps->segmentCleanup->cleanupOrphanedPreviews();
    if ($count > 0) {
      $this->logger->info('Cleaned up @count orphaned preview files.', [
        '@count' => $count,
      ]);
    }
  }

  /**
   * Cleans up orphaned passlog files from two-pass encoding.
   */
  protected function cleanupOrphanedPasslogFiles(): void {
    $passlogDir = 'temporary://ffmpeg_passlog';
    if (!@is_dir($passlogDir)) {
      return;
    }

    $maxAge = 86400;
    $now = time();
    $count = 0;

    // Build a set of media IDs with active conversions to protect
    // their passlog files from premature deletion during two-pass encoding.
    $activeMediaIds = [];
    foreach ($this->deps->progressTracker->getActiveConversions() as $progress) {
      $activeMediaIds[$progress->mediaId] = TRUE;
    }

    $iterator = new \DirectoryIterator($passlogDir);
    foreach ($iterator as $file) {
      if ($file->isDot() || $file->isDir()) {
        continue;
      }
      // Skip passlog files belonging to active conversions.
      if (preg_match('/^ffmpeg2pass-(\d+)/', $file->getFilename(), $matches)) {
        if (isset($activeMediaIds[(int) $matches[1]])) {
          continue;
        }
      }
      if (($now - $file->getMTime()) > $maxAge) {
        if (@unlink($file->getPathname())) {
          $count++;
        }
      }
    }

    if ($count > 0) {
      $this->logger->info('Cleaned up @count orphaned passlog files.', [
        '@count' => $count,
      ]);
    }
  }

  /**
   * Default maximum items to auto-requeue per cron run.
   */
  protected const int DEFAULT_AUTO_REQUEUE_BATCH_LIMIT = 25;

  /**
   * Processes items pending automatic requeue after retry delay.
   */
  protected function processAutoRequeue(): void {
    $config = $this->deps->configFactory->get('ffmpeg_media.settings');
    $batchLimit = $config->get('auto_requeue_batch_limit') ?? self::DEFAULT_AUTO_REQUEUE_BATCH_LIMIT;

    $pendingRetries = $this->deps->retryManager->getPendingRetries();
    $processed = 0;

    // Batch-load all media entities to avoid N+1 queries.
    $mediaIds = array_column($pendingRetries, 'media_id');
    $mediaStorage = $this->deps->entityTypeManager->getStorage('media');
    $mediaEntities = $mediaIds !== [] ? $mediaStorage->loadMultiple($mediaIds) : [];

    foreach ($pendingRetries as $retry) {
      if ($processed >= $batchLimit) {
        break;
      }

      $mediaId = $retry['media_id'];
      $fieldName = (string) ($retry['field_name'] ?? '');

      // Look up from batch-loaded entities.
      $media = $mediaEntities[$mediaId] ?? NULL;
      if (!$media instanceof MediaInterface) {
        // Media deleted, clean up retry record.
        $this->deps->retryManager->resetRetries($mediaId, $fieldName);
        continue;
      }

      // Skip if already queued or locked.
      if ($this->deps->queueManager->isQueued($mediaId, $fieldName)) {
        continue;
      }

      if ($this->deps->lockManager->hasLock($mediaId)) {
        continue;
      }

      // Check if retry is actually due.
      if (!$this->deps->retryManager->isRetryDue($mediaId, $fieldName)) {
        continue;
      }

      // Check if should retry (max retries not exceeded).
      if (!$this->deps->retryManager->shouldRetry($mediaId, $fieldName)) {
        $this->logger->warning('Max retries exceeded for media @id', ['@id' => $mediaId]);
        continue;
      }

      // Requeue for conversion.
      try {
        $queued = $this->deps->queueManager->queueForConversion($media, 'convert', fieldName: $fieldName);
        if ($queued) {
          $processed++;
          $this->logger->info('Auto-requeued media @id for retry attempt @attempt.', [
            '@id' => $mediaId,
            '@attempt' => $retry['retry_count'] + 1,
          ]);
        }
        else {
          // Advance the retry counter so this item eventually exceeds
          // max retries and stops appearing in getPendingRetries(),
          // preventing a permanent warn-loop on every cron run.
          $this->deps->retryManager->recordRetry($mediaId, 'Requeue failed (already queued or no conversion context)', $fieldName);

          $this->logger->warning('Auto-requeue for media @id returned false (already queued or no mapping).', [
            '@id' => $mediaId,
          ]);
        }
      }
      catch (\Throwable $e) {
        // Advance retry counter so permanent exceptions eventually
        // exceed max retries and stop producing cron errors.
        $this->deps->retryManager->recordRetry($mediaId, $e->getMessage(), $fieldName);

        $this->logger->error('Failed to requeue media @id: @error', [
          '@id' => $mediaId,
          '@error' => $e->getMessage(),
        ]);
      }
    }
  }

}
