<?php

declare(strict_types=1);

namespace Drupal\ffmpeg_media\EventSubscriber;

use Drupal\Core\Cache\CacheTagsInvalidatorInterface;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\File\FileSystemInterface;
use Drupal\ffmpeg_media\Cdn\CdnManagerInterface;
use Drupal\ffmpeg_media\Event\ConversionCompletedEvent;
use Drupal\ffmpeg_media\Event\FFmpegMediaEvents;
use Drupal\ffmpeg_media\Service\OutputStorageServiceInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;

/**
 * Uploads converted media outputs to CDN after conversion completes.
 */
final class CdnUploadSubscriber implements EventSubscriberInterface {

  /**
   * Constructs a CdnUploadSubscriber.
   *
   * @param \Drupal\ffmpeg_media\Cdn\CdnManagerInterface $cdnManager
   *   CDN manager.
   * @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory
   *   Config factory.
   * @param \Psr\Log\LoggerInterface $logger
   *   Logger.
   * @param \Drupal\Core\Cache\CacheTagsInvalidatorInterface $cacheTagsInvalidator
   *   Cache tags invalidator.
   * @param \Drupal\ffmpeg_media\Service\OutputStorageServiceInterface $outputStorage
   *   Output storage service.
   * @param \Drupal\Core\File\FileSystemInterface $fileSystem
   *   File system service.
   */
  public function __construct(
    private readonly CdnManagerInterface $cdnManager,
    private readonly ConfigFactoryInterface $configFactory,
    private readonly LoggerInterface $logger,
    private readonly CacheTagsInvalidatorInterface $cacheTagsInvalidator,
    private readonly OutputStorageServiceInterface $outputStorage,
    private readonly FileSystemInterface $fileSystem,
  ) {}

  /**
   * {@inheritdoc}
   *
   * @phpstan-return array<string, array{string, int}>
   */
  #[\Override]
  public static function getSubscribedEvents(): array {
    return [
      FFmpegMediaEvents::CONVERSION_COMPLETED => ['onConversionCompleted', 0],
    ];
  }

  /**
   * Handles conversion completed events by uploading outputs to CDN.
   *
   * @param \Drupal\ffmpeg_media\Event\ConversionCompletedEvent $event
   *   The conversion completed event.
   */
  public function onConversionCompleted(
    ConversionCompletedEvent $event,
  ): void {
    if (!$this->cdnManager->isEnabled()) {
      return;
    }

    $config = $this->configFactory->get('ffmpeg_media.cdn');
    if (!$config->get('auto_upload')) {
      return;
    }

    $integration = $this->cdnManager->getActiveIntegration();
    if ($integration === NULL) {
      return;
    }

    $outputs = $event->getOutputs();
    if ($outputs === []) {
      return;
    }

    // Filter outputs by configured upload types.
    $allowedTypes = $config->get('upload_outputs') ?? [];
    if ($allowedTypes !== []) {
      $outputs = array_intersect_key(
        $outputs,
        array_flip($allowedTypes),
      );
    }

    if ($outputs === []) {
      return;
    }

    $media = $event->media;
    try {
      $results = $integration->uploadMediaOutputs($media, $outputs);
    }
    catch (\Throwable $e) {
      $this->logger->error('CDN upload failed for media @id: @error', [
        '@id' => $media->id(),
        '@error' => $e->getMessage(),
      ]);
      return;
    }

    // Count successes and failures.
    $succeeded = 0;
    $failed = 0;
    foreach ($results as $type => $result) {
      if ($result['success']) {
        $succeeded++;
      }
      else {
        $failed++;
        $this->logger->error(
          'CDN upload failed for @type of media @id: @error',
          [
            '@type' => $type,
            '@id' => $media->id(),
            '@error' => $result['error'] ?? 'Unknown error',
          ],
        );
      }
    }

    if ($succeeded > 0) {
      $this->logger->info(
        'Uploaded @count outputs to CDN for media @id.',
        ['@count' => $succeeded, '@id' => $media->id()],
      );

      // Invalidate formatter caches so CDN URLs are picked up.
      $this->cacheTagsInvalidator->invalidateTags([
        'media:' . $media->id(),
        'ffmpeg_media:outputs:' . $media->id(),
      ]);
    }

    // Delete local files if configured.
    if ($config->get('delete_local') && $succeeded > 0) {
      $mediaId = $event->getMediaId();
      $fieldName = (string) ($event->result->getMetadata('field_name') ?? '');
      $this->deleteLocalFiles($outputs, $results, $mediaId, $fieldName);
    }
  }

  /**
   * Deletes local files and their database records after CDN upload.
   *
   * For each successfully uploaded output: removes the physical file, then
   * deletes the output record from the database (which also decrements
   * file.usage, enabling Drupal's file garbage collection).
   *
   * @param array<string, string> $outputs
   *   Map of output type to local file path.
   * @param array<string, array{success: bool, url: string|null, error: string|null}> $results
   *   Upload results keyed by output type.
   * @param int $mediaId
   *   The media entity ID.
   * @param string $fieldName
   *   The source field name.
   */
  private function deleteLocalFiles(
    array $outputs,
    array $results,
    int $mediaId,
    string $fieldName,
  ): void {
    foreach ($outputs as $type => $localPath) {
      if (!isset($results[$type]) || !$results[$type]['success']) {
        continue;
      }

      // Streaming manifests: delete the entire directory (segments + manifest).
      $extension = strtolower(pathinfo($localPath, PATHINFO_EXTENSION));
      if (($extension === 'm3u8' || $extension === 'mpd') && is_dir(dirname($localPath))) {
        $this->deleteDirectory(dirname($localPath));
        $this->removeOutputRecord($mediaId, $fieldName, $type);
        $this->logger->info(
          'Deleted local streaming directory after CDN upload: @path',
          ['@path' => dirname($localPath)],
        );
        continue;
      }

      if (file_exists($localPath)) {
        if (!$this->isWithinOutputRoot(dirname($localPath))) {
          $this->logger->warning('Refused to delete file outside configured output root: @path', [
            '@path' => $localPath,
          ]);
          continue;
        }
        if (!@unlink($localPath)) {
          $this->logger->warning('Failed to delete local file after CDN upload: @path', [
            '@path' => $localPath,
          ]);
          continue;
        }
        // File deleted successfully — now remove the database record and
        // file.usage so Drupal's file GC cleans up the file entity.
        $this->removeOutputRecord($mediaId, $fieldName, $type);
        $this->logger->info(
          'Deleted local file after CDN upload: @path',
          ['@path' => $localPath],
        );
      }
    }
  }

  /**
   * Removes the output record from the database.
   *
   * This decrements file.usage via OutputStorageService, enabling Drupal's
   * built-in file garbage collection to clean up the orphaned file entity.
   *
   * @param int $mediaId
   *   The media entity ID.
   * @param string $fieldName
   *   The source field name.
   * @param string $outputType
   *   The output type identifier.
   */
  private function removeOutputRecord(int $mediaId, string $fieldName, string $outputType): void {
    try {
      $this->outputStorage->deleteOutput($mediaId, $fieldName, $outputType);
    }
    catch (\Throwable $e) {
      $this->logger->warning('Failed to remove output record for @type of media @id after CDN upload: @error', [
        '@type' => $outputType,
        '@id' => $mediaId,
        '@error' => $e->getMessage(),
      ]);
    }
  }

  /**
   * Recursively deletes a directory and its contents.
   *
   * Validates that the directory is within the configured output directory
   * to prevent accidental deletion of unrelated filesystem paths.
   *
   * @param string $directory
   *   Directory path.
   */
  private function deleteDirectory(string $directory): void {
    if (!is_dir($directory)) {
      return;
    }

    // Safety check: only delete directories within the configured output root.
    if (!$this->isWithinOutputRoot($directory)) {
      $this->logger->error('Refused to delete directory outside configured output root: @path', [
        '@path' => $directory,
      ]);
      return;
    }

    $iterator = new \RecursiveIteratorIterator(
      new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS),
      \RecursiveIteratorIterator::CHILD_FIRST,
    );

    foreach ($iterator as $item) {
      if ($item->isLink()) {
        continue;
      }
      if ($item->isDir()) {
        if (!@rmdir($item->getPathname())) {
          $this->logger->warning('Failed to delete directory: @path', [
            '@path' => $item->getPathname(),
          ]);
        }
      }
      else {
        if (!@unlink($item->getPathname())) {
          $this->logger->warning('Failed to delete file: @path', [
            '@path' => $item->getPathname(),
          ]);
        }
      }
    }

    if (!@rmdir($directory)) {
      $this->logger->warning('Failed to delete directory: @path', [
        '@path' => $directory,
      ]);
    }
  }

  /**
   * Checks if a directory is within the configured output root.
   *
   * Prevents accidental deletion of directories outside the module's output
   * storage area. Resolves both stream wrappers and absolute paths to real
   * filesystem paths for safe comparison.
   *
   * @param string $directory
   *   The directory to check.
   *
   * @return bool
   *   TRUE if the directory is within the output root.
   */
  private function isWithinOutputRoot(string $directory): bool {
    $outputDir = $this->configFactory
      ->get('ffmpeg_media.settings')
      ->get('output_directory') ?? '';

    if ($outputDir === '') {
      return FALSE;
    }

    // Resolve the configured output directory to a real path.
    $outputRoot = str_contains($outputDir, '://')
      ? $this->fileSystem->realpath($outputDir)
      : realpath($outputDir);

    if (!is_string($outputRoot) || $outputRoot === '') {
      return FALSE;
    }

    // Resolve the target directory to a real path (canonicalizes symlinks).
    $realDirectory = realpath($directory);
    if (!is_string($realDirectory) || $realDirectory === '') {
      return FALSE;
    }

    $normalizedRoot = rtrim($outputRoot, \DIRECTORY_SEPARATOR);
    $normalizedDir = rtrim($realDirectory, \DIRECTORY_SEPARATOR);

    return $normalizedDir === $normalizedRoot
      || str_starts_with($normalizedDir, $normalizedRoot . \DIRECTORY_SEPARATOR);
  }

}
