<?php

declare(strict_types=1);

namespace Drupal\ffmpeg_media_image\Service;

use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\File\FileSystemInterface;
use Drupal\Core\File\FileUrlGeneratorInterface;
use Drupal\Core\Logger\LoggerChannelInterface;
use Drupal\Core\StreamWrapper\StreamWrapperManagerInterface;
use Drupal\ffmpeg_media\Service\FFmpegExecutorInterface;

/**
 * Service for generating thumbnail previews for poster selection.
 *
 * @internal
 */
final class ThumbnailGeneratorService implements ThumbnailGeneratorServiceInterface {

  /**
   * Cached thumbnail base directory URI.
   */
  private ?string $thumbnailBaseDir = NULL;

  /**
   * Constructs a ThumbnailGeneratorService object.
   *
   * @param \Drupal\ffmpeg_media\Service\FFmpegExecutorInterface $ffmpegExecutor
   *   FFmpeg executor service.
   * @param \Drupal\Core\File\FileSystemInterface $fileSystem
   *   File system service.
   * @param \Drupal\Core\File\FileUrlGeneratorInterface $fileUrlGenerator
   *   File URL generator service.
   * @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory
   *   Config factory.
   * @param \Drupal\Core\Logger\LoggerChannelInterface $logger
   *   Logger channel.
   * @param \Drupal\Core\StreamWrapper\StreamWrapperManagerInterface $streamWrapperManager
   *   Stream wrapper manager.
   */
  public function __construct(
    protected readonly FFmpegExecutorInterface $ffmpegExecutor,
    protected readonly FileSystemInterface $fileSystem,
    protected readonly FileUrlGeneratorInterface $fileUrlGenerator,
    protected readonly ConfigFactoryInterface $configFactory,
    protected readonly LoggerChannelInterface $logger,
    protected readonly StreamWrapperManagerInterface $streamWrapperManager,
  ) {}

  /**
   * Maximum age in seconds for cached thumbnails (1 hour).
   */
  private const int THUMBNAIL_CACHE_MAX_AGE = 3600;

  /**
   * {@inheritdoc}
   */
  #[\NoDiscard]
  public function generateThumbnails(
    string $videoPath,
    float $duration,
    ?int $count = NULL,
    ?int $width = NULL,
  ): array {
    $config = $this->configFactory->get('ffmpeg_media_ui.settings');
    $count = $count ?? (int) $config->get('poster_thumbnail_count') ?: 12;
    $width = $width ?? (int) $config->get('poster_thumbnail_width') ?: 160;

    // Ensure reasonable bounds.
    $count = min(24, max(4, $count));
    $width = min(320, max(80, $width));

    // Check for recently generated thumbnails first.
    $videoHash = $this->computeVideoHash($videoPath, $duration, $count, $width);
    $existing = $this->findExistingThumbnails($videoPath, $duration, $videoHash, $count);
    if ($existing !== NULL) {
      return $existing;
    }

    // Calculate timestamps for thumbnails.
    $timestamps = $this->calculateTimestamps($duration, $count);
    $thumbnails = [];

    // Create directory for thumbnails, preferring private:// when available.
    // Include video hash in directory name for cache lookup.
    $sessionId = bin2hex(random_bytes(16));
    $thumbDir = $this->getThumbnailBaseDir() . '/' . $videoHash . '_' . $sessionId;
    $this->fileSystem->prepareDirectory($thumbDir, FileSystemInterface::CREATE_DIRECTORY);

    $thumbRealDir = $this->fileSystem->realpath($thumbDir);
    if ($thumbRealDir === FALSE) {
      return [];
    }

    foreach ($timestamps as $index => $timestamp) {
      $filename = 'thumb_' . sprintf('%02d', $index) . '.jpg';
      $outputPath = $thumbRealDir . '/' . $filename;
      $outputUri = $thumbDir . '/' . $filename;

      if ($this->generateSingleThumbnail($videoPath, $outputPath, $timestamp, $width)) {
        $thumbnails[] = [
          'path' => $outputPath,
          'uri' => $outputUri,
          'url' => $this->fileUrlGenerator->generateAbsoluteString($outputUri),
          'timestamp' => $timestamp,
          'time_formatted' => $this->formatTime($timestamp),
          'index' => $index,
        ];
      }
    }

    return $thumbnails;
  }

  /**
   * {@inheritdoc}
   */
  #[\NoDiscard]
  public function findExistingThumbnails(string $videoPath, float $duration, ?string $videoHash = NULL, ?int $count = NULL): ?array {
    $thumbBaseDir = $this->getThumbnailBaseDir();
    $realBase = $this->fileSystem->realpath($thumbBaseDir);
    if ($realBase === FALSE || !is_dir($realBase)) {
      return NULL;
    }

    if ($videoHash === NULL) {
      $config = $this->configFactory->get('ffmpeg_media_ui.settings');
      $resolvedCount = $count ?? (int) $config->get('poster_thumbnail_count') ?: 12;
      $resolvedCount = min(24, max(4, $resolvedCount));
      $resolvedWidth = (int) $config->get('poster_thumbnail_width') ?: 160;
      $resolvedWidth = min(320, max(80, $resolvedWidth));
      $videoHash = $this->computeVideoHash($videoPath, $duration, $resolvedCount, $resolvedWidth);
    }

    $resolvedCount = $count ?? 12;
    $ageThreshold = time() - self::THUMBNAIL_CACHE_MAX_AGE;

    $handle = @opendir($realBase);
    if ($handle === FALSE) {
      return NULL;
    }

    $matchDir = NULL;

    while (($entry = readdir($handle)) !== FALSE) {
      if ($entry === '.' || $entry === '..') {
        continue;
      }

      // Match directories starting with our video hash.
      if (!str_starts_with($entry, $videoHash . '_')) {
        continue;
      }

      $fullPath = $realBase . DIRECTORY_SEPARATOR . $entry;
      if (!is_dir($fullPath)) {
        continue;
      }

      $mtime = @filemtime($fullPath);
      if ($mtime === FALSE || $mtime < $ageThreshold) {
        continue;
      }

      $matchDir = $entry;
      break;
    }

    closedir($handle);

    if ($matchDir === NULL) {
      return NULL;
    }

    // Reconstruct thumbnail data from existing files.
    $dirUri = $thumbBaseDir . '/' . $matchDir;
    $dirReal = $realBase . DIRECTORY_SEPARATOR . $matchDir;

    $fileHandle = @opendir($dirReal);
    if ($fileHandle === FALSE) {
      return NULL;
    }

    $files = [];
    while (($file = readdir($fileHandle)) !== FALSE) {
      if ($file === '.' || $file === '..' || !str_ends_with($file, '.jpg')) {
        continue;
      }
      $files[] = $file;
    }
    closedir($fileHandle);

    if ($files === []) {
      return NULL;
    }

    sort($files);

    $timestamps = $this->calculateTimestamps($duration, $resolvedCount);
    $thumbnails = [];

    foreach ($files as $index => $file) {
      $filePath = $dirReal . DIRECTORY_SEPARATOR . $file;
      $fileUri = $dirUri . '/' . $file;
      $timestamp = $timestamps[$index] ?? 0.0;

      $thumbnails[] = [
        'path' => $filePath,
        'uri' => $fileUri,
        'url' => $this->fileUrlGenerator->generateAbsoluteString($fileUri),
        'timestamp' => $timestamp,
        'time_formatted' => $this->formatTime($timestamp),
        'index' => $index,
      ];
    }

    return $thumbnails;
  }

  /**
   * Returns the base directory URI for thumbnails.
   *
   * Prefers private:// when available for access-controlled serving.
   * Falls back to public:// with a logged notice. A status page warning
   * is also shown via hook_runtime_requirements().
   *
   * @return string
   *   The stream URI for the thumbnail base directory.
   */
  #[\NoDiscard]
  public function getThumbnailBaseDir(): string {
    if ($this->thumbnailBaseDir !== NULL) {
      return $this->thumbnailBaseDir;
    }

    if ($this->streamWrapperManager->getViaScheme('private') !== FALSE) {
      $this->thumbnailBaseDir = 'private://ffmpeg_media/thumbnails';
    }
    else {
      $this->logger->notice('Private file system not configured; thumbnails will be stored in public://. Configure $settings[\'file_private_path\'] for access-controlled thumbnail serving.');
      $this->thumbnailBaseDir = 'public://ffmpeg_media/thumbnails';
    }

    return $this->thumbnailBaseDir;
  }

  /**
   * Computes a hash for a video path, duration, count, and width.
   *
   * @param string $videoPath
   *   Path to the video file.
   * @param float $duration
   *   Video duration in seconds.
   * @param int $count
   *   Number of thumbnails.
   * @param int $width
   *   Thumbnail width in pixels.
   *
   * @return string
   *   Short hash string for directory naming.
   */
  #[\NoDiscard]
  protected function computeVideoHash(string $videoPath, float $duration, int $count, int $width): string {
    return substr(hash('xxh128', $videoPath . '|' . $duration . '|' . $count . '|' . $width), 0, 16);
  }

  /**
   * Generates a single thumbnail at a specific timestamp.
   *
   * @param string $videoPath
   *   Path to the video file.
   * @param string $outputPath
   *   Path for the output thumbnail.
   * @param float $timestamp
   *   Position in video (seconds).
   * @param int $width
   *   Thumbnail width.
   *
   * @return bool
   *   TRUE if successful.
   */
  public function generateSingleThumbnail(
    string $videoPath,
    string $outputPath,
    float $timestamp,
    int $width = 160,
  ): bool {
    $ffmpegPath = $this->ffmpegExecutor->getFfmpegPath();

    $command = [
      $ffmpegPath,
      '-y',
      '-ss', (string) $timestamp,
      '-i', $videoPath,
      '-vframes', '1',
      '-vf', "scale={$width}:-1",
      '-q:v', '3',
      $outputPath,
    ];

    return $this->ffmpegExecutor->execute($command, ['timeout' => 30]);
  }

  /**
   * Generates a high-quality poster image at a specific timestamp.
   *
   * @param string $videoPath
   *   Path to the video file.
   * @param string $outputPath
   *   Path for the output poster.
   * @param float $timestamp
   *   Position in video (seconds).
   * @param int|null $width
   *   Poster width (NULL for original).
   *
   * @return bool
   *   TRUE if successful.
   */
  public function generatePoster(
    string $videoPath,
    string $outputPath,
    float $timestamp,
    ?int $width = NULL,
  ): bool {
    $ffmpegPath = $this->ffmpegExecutor->getFfmpegPath();

    $command = [
      $ffmpegPath,
      '-y',
      '-ss', (string) $timestamp,
      '-i', $videoPath,
      '-vframes', '1',
      '-q:v', '2',
    ];

    if ($width !== NULL) {
      $command[] = '-vf';
      $command[] = "scale={$width}:-1";
    }

    $command[] = $outputPath;

    return $this->ffmpegExecutor->execute($command, ['timeout' => 60]);
  }

  /**
   * Cleans up temporary thumbnail files.
   *
   * @param array<int, array{path: string, uri: string, url: string, timestamp: float, time_formatted: string, index: int}> $thumbnails
   *   Array of thumbnail data from generateThumbnails().
   */
  public function cleanup(array $thumbnails): void {
    foreach ($thumbnails as $thumbnail) {
      // Clean up using path (real filesystem path).
      if (isset($thumbnail['path']) && file_exists($thumbnail['path'])) {
        if (!$this->fileSystem->delete($thumbnail['path'])) {
          $this->logger->warning('Failed to delete thumbnail @path', ['@path' => $thumbnail['path']]);
        }
      }
      // Also try URI for backward compatibility.
      elseif (isset($thumbnail['uri'])) {
        $this->fileSystem->delete($thumbnail['uri']);
      }
    }

    // Try to remove the directory if empty.
    if (!empty($thumbnails)) {
      $first = array_first($thumbnails);
      $firstPath = $first['path'] ?? NULL;
      if (is_string($firstPath) && $firstPath !== '') {
        $dir = dirname($firstPath);
        if (is_dir($dir) && !rmdir($dir)) {
          $this->logger->notice('Could not remove thumbnail directory @dir', ['@dir' => $dir]);
        }
      }
    }
  }

  /**
   * Calculates evenly distributed timestamps for thumbnails.
   *
   * @param float $duration
   *   Video duration in seconds.
   * @param int $count
   *   Number of timestamps.
   *
   * @return array<int, float>
   *   Array of timestamps.
   */
  protected function calculateTimestamps(float $duration, int $count): array {
    $timestamps = [];

    // Skip the very beginning and end.
    $startOffset = min(1.0, $duration * 0.02);
    $endOffset = min(1.0, $duration * 0.02);
    $effectiveDuration = $duration - $startOffset - $endOffset;

    if ($effectiveDuration <= 0) {
      // Very short video, just take the middle.
      return [$duration / 2];
    }

    $interval = $effectiveDuration / ($count - 1);

    for ($i = 0; $i < $count; $i++) {
      $timestamps[] = round($startOffset + ($i * $interval), 2);
    }

    return $timestamps;
  }

  /**
   * Formats a timestamp as MM:SS or HH:MM:SS.
   *
   * @param float $seconds
   *   Time in seconds.
   *
   * @return string
   *   Formatted time string.
   */
  protected function formatTime(float $seconds): string {
    $totalSeconds = (int) floor($seconds);
    $hours = (int) floor($totalSeconds / 3600);
    $minutes = (int) floor(($totalSeconds % 3600) / 60);
    $secs = $totalSeconds % 60;

    if ($hours > 0) {
      return sprintf('%d:%02d:%02d', $hours, $minutes, $secs);
    }

    return sprintf('%d:%02d', $minutes, $secs);
  }

}
