<?php

declare(strict_types=1);

namespace Drupal\ffmpeg_media\Service;

use Drupal\Core\File\FileSystemInterface;
use Drupal\ffmpeg_media\Cdn\CdnIntegrationInterface;
use Drupal\ffmpeg_media\Cdn\CdnManagerInterface;
use Drupal\ffmpeg_media\Enum\OutputType;
use Drupal\file\FileInterface;
use Drupal\media\MediaInterface;
use Psr\Log\LoggerInterface;

/**
 * Canonical artifact cleanup service for converted media outputs.
 *
 * @internal
 */
final class MediaArtifactManager implements MediaArtifactManagerInterface {

  /**
   * Constructs a MediaArtifactManager.
   */
  public function __construct(
    private readonly OutputStorageServiceInterface $outputStorage,
    private readonly ConversionFileManagerInterface $conversionFileManager,
    private readonly ConfigValueProviderInterface $configValueProvider,
    private readonly FileSystemInterface $fileSystem,
    private readonly ProgressTrackerInterface $progressTracker,
    private readonly QueueManagerInterface $queueManager,
    private readonly CdnManagerInterface $cdnManager,
    private readonly LoggerInterface $logger,
    private readonly ?RetryManagerInterface $retryManager = NULL,
    private readonly ?DeadLetterStorageInterface $deadLetterStorage = NULL,
  ) {}

  /**
   * {@inheritdoc}
   */
  #[\Override]
  public function deleteArtifacts(
    MediaInterface $media,
    bool $deleteLocalFiles = TRUE,
    bool $deleteRemoteFiles = TRUE,
    bool $deleteProgress = TRUE,
    bool $deleteQueueState = TRUE,
    string $fieldName = '',
  ): array {
    $mediaId = (int) $media->id();
    $outputRows = $this->outputStorage->getOutputsByMediaId($mediaId);
    $trackedPaths = [];
    $remotePaths = [];
    $filesToDelete = [];
    $streamingDirectories = [];

    foreach ($outputRows as $outputType => $entries) {
      foreach ($entries as $entry) {
        if ($fieldName !== '' && ($entry['field_name'] ?? '') !== $fieldName) {
          continue;
        }

        $file = $entry['file'] ?? NULL;
        if (!$file instanceof FileInterface) {
          continue;
        }

        $uri = $file->getFileUri();
        if (!is_string($uri) || $uri === '') {
          continue;
        }

        $trackedPaths[$uri] = $uri;
        $hasOtherUsage = $deleteLocalFiles && $this->conversionFileManager->hasOtherFileUsage($file, $media);

        if ($hasOtherUsage) {
          $this->logger->warning('Skipped deletion of converted file @fid (@name): still referenced by another entity.', [
            '@fid' => $file->id(),
            '@name' => $file->getFilename(),
          ]);
        }
        elseif ($deleteLocalFiles) {
          $filesToDelete[(string) $file->id()] = $file;
        }

        if ($deleteRemoteFiles) {
          foreach ($this->collectRemotePathsForOutput($outputType, $uri) as $remotePath) {
            $remotePaths[$remotePath] = $remotePath;
          }
        }

        if ($this->isAdaptiveStreamingOutput($outputType)) {
          $streamingDirectories[dirname($uri)] = !$hasOtherUsage && $deleteLocalFiles;
        }
      }
    }

    // Delete remote artifacts (CDN) — independent of local/DB operations.
    $this->deleteRemoteArtifacts($media, array_values($remotePaths), $deleteRemoteFiles, $fieldName);

    // Delete local files BEFORE database records. If file deletion fails,
    // the DB record still exists for retry/cleanup. The reverse (DB first,
    // then files) would leave orphaned files with no DB reference.
    if ($deleteLocalFiles) {
      foreach ($filesToDelete as $file) {
        try {
          $file->delete();
        }
        catch (\Throwable $e) {
          $this->logger->warning('Failed to delete converted file @fid for media @id: @error', [
            '@fid' => $file->id(),
            '@id' => $mediaId,
            '@error' => $e->getMessage(),
          ]);
        }
      }

      foreach ($streamingDirectories as $directory => $canDeleteDirectory) {
        if (!$canDeleteDirectory) {
          continue;
        }
        $this->deleteStreamingDirectory($directory, $mediaId);
      }
    }

    // Delete database records last; local files are already cleaned up
    // (or logged as failures with DB references intact for later cleanup).
    $this->outputStorage->deleteAllOutputs($mediaId, $fieldName);

    if ($deleteProgress) {
      $this->progressTracker->deleteProgress($mediaId, $fieldName);
    }

    if ($deleteQueueState) {
      $this->queueManager->removeFromQueue($mediaId, $fieldName);
      if ($fieldName !== '') {
        $this->retryManager?->resetRetries($mediaId, $fieldName);
        $this->deadLetterStorage?->remove($mediaId, $fieldName);
      }
      else {
        $this->retryManager?->resetRetries($mediaId);
        $this->deadLetterStorage?->remove($mediaId);
      }
    }

    return array_values($trackedPaths);
  }

  /**
   * Deletes remote CDN artifacts for a media entity.
   *
   * @param \Drupal\media\MediaInterface $media
   *   Media entity whose remote artifacts are being removed.
   * @param array<int, string> $remotePaths
   *   Concrete remote keys resolved from local artifacts.
   * @param bool $deleteRemoteFiles
   *   TRUE to delete remote objects.
   * @param string $fieldName
   *   When non-empty, limits cleanup to this field's artifacts only.
   *   The media-wide CDN prefix sweep is skipped for field-scoped
   *   deletions to avoid destroying other fields' remote files.
   */
  private function deleteRemoteArtifacts(MediaInterface $media, array $remotePaths, bool $deleteRemoteFiles, string $fieldName = ''): void {
    if (!$deleteRemoteFiles || !$this->cdnManager->isEnabled()) {
      return;
    }

    $integration = $this->cdnManager->getActiveIntegration();
    if (!$integration instanceof CdnIntegrationInterface) {
      return;
    }

    foreach ($remotePaths as $remotePath) {
      if (!$integration->deleteFile($remotePath)) {
        $this->logger->warning('Failed to delete CDN artifact @path for media @id.', [
          '@path' => $remotePath,
          '@id' => $media->id(),
        ]);
      }
    }

    // Full-entity sweep only when no field scope is active.
    // Field-scoped deletions rely on the explicit per-path removal above.
    if ($fieldName === '') {
      $integration->deleteMediaFiles($media);
    }
  }

  /**
   * Collects concrete remote paths for a tracked output.
   *
   * @param string $outputType
   *   Output type key.
   * @param string $uri
   *   Stored file URI.
   *
   * @return array<int, string>
   *   Unique remote keys for this output.
   */
  private function collectRemotePathsForOutput(string $outputType, string $uri): array {
    $integration = $this->cdnManager->getActiveIntegration();
    if (!$integration instanceof CdnIntegrationInterface) {
      return [];
    }

    $remotePaths = [];
    $remotePath = $integration->resolveRemotePath($uri);
    if (is_string($remotePath) && $remotePath !== '') {
      $remotePaths[$remotePath] = $remotePath;
    }

    if (!$this->isAdaptiveStreamingOutput($outputType)) {
      return array_values($remotePaths);
    }

    $localDirectory = $this->resolveLocalDirectory(dirname($uri));
    if ($localDirectory === NULL) {
      return array_values($remotePaths);
    }

    foreach ($this->scanDirectory($localDirectory) as $filePath) {
      $segmentRemotePath = $integration->resolveRemotePath($filePath);
      if (is_string($segmentRemotePath) && $segmentRemotePath !== '') {
        $remotePaths[$segmentRemotePath] = $segmentRemotePath;
      }
    }

    return array_values($remotePaths);
  }

  /**
   * Returns whether an output type is adaptive streaming.
   */
  private function isAdaptiveStreamingOutput(string $outputType): bool {
    return OutputType::tryFrom($outputType)?->isAdaptiveStreaming() ?? FALSE;
  }

  /**
   * Deletes a local streaming directory when it still exists.
   */
  private function deleteStreamingDirectory(string $directory, int $mediaId): void {
    $realPath = $this->fileSystem->realpath($directory);
    if (!is_string($realPath) || !is_dir($realPath)) {
      return;
    }

    if (!$this->isWithinConfiguredOutputDirectory($directory)) {
      $this->logger->warning('Refusing to delete streaming directory @directory for media @id: path is outside configured output directory.', [
        '@directory' => $directory,
        '@id' => $mediaId,
      ]);
      return;
    }

    try {
      $this->fileSystem->deleteRecursive($directory);
    }
    catch (\Throwable $e) {
      $this->logger->warning('Failed to delete streaming directory @directory for media @id: @error', [
        '@directory' => $directory,
        '@id' => $mediaId,
        '@error' => $e->getMessage(),
      ]);
    }
  }

  /**
   * Resolves a stream wrapper directory to a local directory when possible.
   */
  private function resolveLocalDirectory(string $directory): ?string {
    $realPath = str_contains($directory, '://')
      ? $this->fileSystem->realpath($directory)
      : $directory;

    if (!is_string($realPath) || !is_dir($realPath)) {
      return NULL;
    }

    return $this->isWithinConfiguredOutputDirectory($directory) ? $realPath : NULL;
  }

  /**
   * Recursively scans a directory for all files.
   *
   * @param string $directory
   *   Absolute local directory path.
   *
   * @return array<int, string>
   *   Absolute local file paths.
   */
  private function scanDirectory(string $directory): array {
    if (!is_dir($directory)) {
      return [];
    }

    $files = [];
    $iterator = new \RecursiveIteratorIterator(
      new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS),
    );

    foreach ($iterator as $fileInfo) {
      if ($fileInfo->isFile()) {
        $files[] = $fileInfo->getPathname();
      }
    }

    return $files;
  }

  /**
   * Returns whether a local directory is within the configured output root.
   */
  private function isWithinConfiguredOutputDirectory(string $directory): bool {
    $outputDirectory = $this->configValueProvider->getOutputDirectory();
    if ($outputDirectory === '') {
      return FALSE;
    }

    $outputRoot = str_contains($outputDirectory, '://')
      ? $this->fileSystem->realpath($outputDirectory)
      : realpath($outputDirectory);
    if (!is_string($outputRoot) || $outputRoot === '') {
      return FALSE;
    }

    // Canonicalize the target directory to resolve symlinks, preventing
    // a symlink inside the output root from pointing outside of it.
    // Callers already verify is_dir() before calling this method, so if
    // realpath() fails the path is suspect and we reject it outright.
    $canonicalDirectory = str_contains($directory, '://')
      ? $this->fileSystem->realpath($directory)
      : realpath($directory);
    if (!is_string($canonicalDirectory) || $canonicalDirectory === '') {
      return FALSE;
    }
    $resolvedDirectory = $canonicalDirectory;

    $normalizedDirectory = rtrim($resolvedDirectory, DIRECTORY_SEPARATOR);
    $normalizedOutputRoot = rtrim($outputRoot, DIRECTORY_SEPARATOR);

    return $normalizedDirectory === $normalizedOutputRoot
      || str_starts_with($normalizedDirectory, $normalizedOutputRoot . DIRECTORY_SEPARATOR);
  }

}
