<?php

declare(strict_types=1);

namespace Drupal\ffmpeg_media\Service;

use Drupal\Core\File\FileSystemInterface;
use Drupal\Core\Logger\LoggerChannelInterface;
use Drupal\Core\StreamWrapper\StreamWrapperManagerInterface;

/**
 * Cleans up orphaned HLS/DASH streaming segment directories.
 *
 * Streaming conversions (HLS/DASH) create segment directories under
 * {output_directory}/{mediaId}/{format}/. When conversions fail or are
 * abandoned, these directories and their segment files accumulate.
 * This service identifies and removes orphaned segment directories.
 *
 * @internal
 */
final class SegmentCleanupService implements SegmentCleanupServiceInterface {

  /**
   * Streaming format directory names to scan for segments.
   *
   * @var array<int, string>
   */
  private const array STREAMING_FORMATS = ['hls', 'dash'];

  /**
   * Constructs a SegmentCleanupService.
   *
   * @param \Drupal\Core\File\FileSystemInterface $fileSystem
   *   File system service.
   * @param \Drupal\ffmpeg_media\Service\ConfigValueProviderInterface $configValueProvider
   *   Config value provider for cached config reads.
   * @param \Drupal\ffmpeg_media\Service\ProgressTrackerInterface $progressTracker
   *   Progress tracker service.
   * @param \Drupal\Core\Logger\LoggerChannelInterface $logger
   *   Logger channel.
   * @param \Drupal\Core\StreamWrapper\StreamWrapperManagerInterface $streamWrapperManager
   *   Stream wrapper manager.
   */
  public function __construct(
    protected readonly FileSystemInterface $fileSystem,
    protected readonly ConfigValueProviderInterface $configValueProvider,
    protected readonly ProgressTrackerInterface $progressTracker,
    protected readonly LoggerChannelInterface $logger,
    protected readonly StreamWrapperManagerInterface $streamWrapperManager,
  ) {}

  /**
   * {@inheritdoc}
   */
  #[\Override]
  public function cleanupOrphanedSegments(int $maxAge = self::DEFAULT_SEGMENT_CLEANUP_AGE): int {
    $outputDir = $this->getOutputDirectory();
    if ($outputDir === NULL) {
      return 0;
    }

    $realPath = $this->fileSystem->realpath($outputDir);
    if ($realPath === FALSE || !is_dir($realPath)) {
      return 0;
    }

    $cleanedCount = 0;
    $ageThreshold = time() - $maxAge;

    // Scan media ID subdirectories: {output_dir}/{mediaId}/.
    $mediaDirectories = $this->scanDirectory($realPath);

    foreach ($mediaDirectories as $mediaDir) {
      $dirName = basename($mediaDir);

      // Media directories are named by media ID (numeric).
      if (!ctype_digit($dirName)) {
        continue;
      }

      $mediaId = (int) $dirName;

      // Scan field subdirectories once (hoisted outside format loop).
      $fieldSubdirs = $this->scanDirectory($mediaDir);

      // Check each streaming format subdirectory.
      // Supports both legacy layout ({mediaId}/{format}) and field-scoped
      // layout ({mediaId}/{fieldName}/{format}).
      foreach (self::STREAMING_FORMATS as $format) {
        // Legacy layout: direct format subdirectory.
        $segmentDir = $mediaDir . DIRECTORY_SEPARATOR . $format;
        if (is_dir($segmentDir)) {
          if ($this->shouldCleanupSegments($mediaId, $segmentDir, $ageThreshold)) {
            if ($this->removeDirectoryRecursive($segmentDir)) {
              $cleanedCount++;
              $this->logger->info('Cleaned up orphaned @format segment directory for media @id.', [
                '@format' => $format,
                '@id' => $mediaId,
              ]);
            }
          }
        }

        // Field-scoped layout: {mediaId}/{fieldName}/{format}.
        foreach ($fieldSubdirs as $fieldDir) {
          $fieldDirName = basename($fieldDir);
          // Skip numeric dirs and streaming format dirs.
          if (ctype_digit($fieldDirName) || in_array($fieldDirName, self::STREAMING_FORMATS, TRUE)) {
            continue;
          }
          $fieldSegmentDir = $fieldDir . DIRECTORY_SEPARATOR . $format;
          if (!is_dir($fieldSegmentDir)) {
            continue;
          }
          if ($this->shouldCleanupSegments($mediaId, $fieldSegmentDir, $ageThreshold, $fieldDirName)) {
            if ($this->removeDirectoryRecursive($fieldSegmentDir)) {
              $cleanedCount++;
              $this->logger->info('Cleaned up orphaned @format segment directory for media @id field @field.', [
                '@format' => $format,
                '@id' => $mediaId,
                '@field' => $fieldDirName,
              ]);
            }
          }
        }
      }

      // Clean up empty field directories after streaming dirs are removed.
      foreach ($fieldSubdirs as $fieldDir) {
        $fieldDirName = basename($fieldDir);
        if (ctype_digit($fieldDirName) || in_array($fieldDirName, self::STREAMING_FORMATS, TRUE)) {
          continue;
        }
        $this->removeEmptyDirectory($fieldDir);
      }

      // If all subdirectories were removed, clean the media directory.
      $this->removeEmptyDirectory($mediaDir);
    }

    return $cleanedCount;
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  public function cleanupSegmentsForMedia(int $mediaId, string $fieldName = ''): bool {
    $outputDir = $this->getOutputDirectory();
    if ($outputDir === NULL) {
      return FALSE;
    }

    $realPath = $this->fileSystem->realpath($outputDir);
    if ($realPath === FALSE || !is_dir($realPath)) {
      return FALSE;
    }

    $mediaDir = $realPath . DIRECTORY_SEPARATOR . $mediaId;
    if (!is_dir($mediaDir)) {
      return FALSE;
    }

    // When field-scoped, only clean that field's segments.
    if ($fieldName !== '') {
      return $this->cleanupFieldSegments($mediaDir, $mediaId, $fieldName);
    }

    // No field specified — clean all (legacy + all field-scoped).
    $cleaned = FALSE;
    $fieldSubdirs = $this->scanDirectory($mediaDir);

    foreach (self::STREAMING_FORMATS as $format) {
      // Legacy layout: {mediaId}/{format}.
      $segmentDir = $mediaDir . DIRECTORY_SEPARATOR . $format;
      if (is_dir($segmentDir)) {
        if ($this->removeDirectoryRecursive($segmentDir)) {
          $cleaned = TRUE;
          $this->logger->info('Cleaned up @format segments for failed media @id conversion.', [
            '@format' => $format,
            '@id' => $mediaId,
          ]);
        }
      }

      // Field-scoped layout: {mediaId}/{fieldName}/{format}.
      foreach ($fieldSubdirs as $fieldDir) {
        $fieldDirName = basename($fieldDir);
        if (ctype_digit($fieldDirName) || in_array($fieldDirName, self::STREAMING_FORMATS, TRUE)) {
          continue;
        }
        $fieldSegmentDir = $fieldDir . DIRECTORY_SEPARATOR . $format;
        if (!is_dir($fieldSegmentDir)) {
          continue;
        }
        if ($this->removeDirectoryRecursive($fieldSegmentDir)) {
          $cleaned = TRUE;
          $this->logger->info('Cleaned up @format segments for failed media @id field @field conversion.', [
            '@format' => $format,
            '@id' => $mediaId,
            '@field' => $fieldDirName,
          ]);
        }
      }
    }

    // Clean up empty field directories.
    foreach ($fieldSubdirs as $fieldDir) {
      $fieldDirName = basename($fieldDir);
      if (ctype_digit($fieldDirName) || in_array($fieldDirName, self::STREAMING_FORMATS, TRUE)) {
        continue;
      }
      $this->removeEmptyDirectory($fieldDir);
    }

    if ($cleaned) {
      $this->removeEmptyDirectory($mediaDir);
    }

    return $cleaned;
  }

  /**
   * Cleans up streaming segments for a specific field only.
   *
   * @param string $mediaDir
   *   Absolute path to the media directory.
   * @param int $mediaId
   *   Media entity ID (for logging).
   * @param string $fieldName
   *   Field name whose segments to clean.
   *
   * @return bool
   *   TRUE if any segments were cleaned up.
   */
  private function cleanupFieldSegments(string $mediaDir, int $mediaId, string $fieldName): bool {
    $fieldDir = $mediaDir . DIRECTORY_SEPARATOR . $fieldName;
    $cleaned = FALSE;

    if (!is_dir($fieldDir)) {
      return FALSE;
    }

    foreach (self::STREAMING_FORMATS as $format) {
      $segmentDir = $fieldDir . DIRECTORY_SEPARATOR . $format;
      if (!is_dir($segmentDir)) {
        continue;
      }
      if ($this->removeDirectoryRecursive($segmentDir)) {
        $cleaned = TRUE;
        $this->logger->info('Cleaned up @format segments for failed media @id field @field conversion.', [
          '@format' => $format,
          '@id' => $mediaId,
          '@field' => $fieldName,
        ]);
      }
    }

    if ($cleaned) {
      $this->removeEmptyDirectory($fieldDir);
      $this->removeEmptyDirectory($mediaDir);
    }

    return $cleaned;
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  public function cleanupOrphanedTempFiles(int $maxAge = self::DEFAULT_SEGMENT_CLEANUP_AGE): int {
    $outputDir = $this->getOutputDirectory();
    if ($outputDir === NULL) {
      return 0;
    }

    $realPath = $this->fileSystem->realpath($outputDir);
    if ($realPath === FALSE || !is_dir($realPath)) {
      return 0;
    }

    $cleanedCount = 0;
    $ageThreshold = time() - $maxAge;

    // Scan media ID subdirectories for temp artifacts.
    $mediaDirectories = $this->scanDirectory($realPath);

    foreach ($mediaDirectories as $mediaDir) {
      $dirName = basename($mediaDir);

      if (!ctype_digit($dirName)) {
        continue;
      }

      // Look for FFmpeg temp files: two-pass logs (ffmpeg2pass-*),
      // partial output files (*.part, *.tmp).
      $handle = @opendir($mediaDir);
      if ($handle === FALSE) {
        continue;
      }

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

        $fullPath = $mediaDir . DIRECTORY_SEPARATOR . $entry;
        $isTemp = str_starts_with($entry, 'ffmpeg2pass-')
          || str_ends_with($entry, '.tmp')
          || str_ends_with($entry, '.part')
          || str_ends_with($entry, '.mbtree');

        if (!$isTemp) {
          continue;
        }

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

        if (is_dir($fullPath)) {
          if ($this->removeDirectoryRecursive($fullPath)) {
            $cleanedCount++;
          }
        }
        else {
          if ($this->fileSystem->delete($fullPath)) {
            $cleanedCount++;
          }
        }
      }

      closedir($handle);

      // Clean up parent if now empty.
      $this->removeEmptyDirectory($mediaDir);
    }

    return $cleanedCount;
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  public function cleanupOrphanedThumbnails(int $maxAge = self::DEFAULT_THUMBNAIL_CLEANUP_AGE): int {
    $cleanedCount = 0;
    $ageThreshold = time() - $maxAge;

    foreach ($this->getThumbnailDirectories() as $thumbDir) {
      $realPath = $this->fileSystem->realpath($thumbDir);
      if ($realPath === FALSE || !is_dir($realPath)) {
        continue;
      }

      $sessionDirs = $this->scanDirectory($realPath);

      foreach ($sessionDirs as $sessionDir) {
        $mtime = @filemtime($sessionDir);
        if ($mtime === FALSE || $mtime > $ageThreshold) {
          continue;
        }

        if ($this->removeDirectoryRecursive($sessionDir)) {
          $cleanedCount++;
        }
      }
    }

    return $cleanedCount;
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  public function cleanupOrphanedPreviews(int $maxAge = self::DEFAULT_THUMBNAIL_CLEANUP_AGE): int {
    $cleanedCount = 0;
    $ageThreshold = time() - $maxAge;

    foreach ($this->getPreviewDirectories() as $previewDir) {
      $realPath = $this->fileSystem->realpath($previewDir);
      if ($realPath === FALSE || !is_dir($realPath)) {
        continue;
      }

      $handle = @opendir($realPath);
      if ($handle === FALSE) {
        continue;
      }

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

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

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

        if ($this->fileSystem->delete($fullPath)) {
          $cleanedCount++;
        }
      }

      closedir($handle);
    }

    return $cleanedCount;
  }

  /**
   * Returns thumbnail directory URIs to scan during cleanup.
   *
   * Scans both private:// and public:// to handle the migration period
   * where files may exist in either location.
   *
   * @return array<int, string>
   *   Array of directory URIs.
   */
  #[\NoDiscard]
  protected function getThumbnailDirectories(): array {
    $dirs = ['public://ffmpeg_media/thumbnails'];

    if ($this->streamWrapperManager->getViaScheme('private') !== FALSE) {
      $dirs[] = 'private://ffmpeg_media/thumbnails';
    }

    return $dirs;
  }

  /**
   * Returns preview directory URIs to scan during cleanup.
   *
   * Scans both private:// and public:// to handle the migration period
   * where files may exist in either location.
   *
   * @return array<int, string>
   *   Array of directory URIs.
   */
  #[\NoDiscard]
  protected function getPreviewDirectories(): array {
    $dirs = ['public://ffmpeg_media/previews'];

    if ($this->streamWrapperManager->getViaScheme('private') !== FALSE) {
      $dirs[] = 'private://ffmpeg_media/previews';
    }

    return $dirs;
  }

  /**
   * Determines if segment directory should be cleaned up.
   *
   * A segment directory should be cleaned up when:
   * - The conversion has failed and the directory is old enough.
   * - There is no progress record (abandoned) and the directory is old enough.
   * - The conversion completed but segments remain (edge case).
   *
   * Active or recently-started conversions are never cleaned up.
   *
   * @param int $mediaId
   *   The media entity ID.
   * @param string $segmentDir
   *   Path to the segment directory.
   * @param int $ageThreshold
   *   Unix timestamp; directories modified before this are eligible.
   * @param string $fieldName
   *   Source field name for field-scoped progress lookup. Empty string
   *   uses media-level progress (legacy layout).
   *
   * @return bool
   *   TRUE if the segments should be cleaned up.
   */
  protected function shouldCleanupSegments(int $mediaId, string $segmentDir, int $ageThreshold, string $fieldName = ''): bool {
    // Check directory modification time.
    $dirMtime = @filemtime($segmentDir);
    if ($dirMtime === FALSE || $dirMtime > $ageThreshold) {
      return FALSE;
    }

    $progress = $this->progressTracker->getProgress($mediaId, $fieldName);

    // No progress record: conversion was abandoned or record already cleaned.
    if ($progress === NULL) {
      return TRUE;
    }

    // Active conversions should never be cleaned up.
    if ($progress->isInProgress || $progress->isPending) {
      return FALSE;
    }

    // Failed conversions past the age threshold: clean up.
    if ($progress->hasFailed) {
      return TRUE;
    }

    // Completed conversions should not have their segments removed
    // as they are the actual output. This is intentional: segment
    // directories for completed conversions are the deliverable.
    return FALSE;
  }

  /**
   * Gets the configured output directory as a stream URI.
   *
   * @return string|null
   *   Output directory URI, or NULL if not configured.
   */
  #[\NoDiscard]
  protected function getOutputDirectory(): ?string {
    $outputDir = $this->configValueProvider->getOutputDirectory();

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

    return $outputDir;
  }

  /**
   * Scans a directory for immediate subdirectories.
   *
   * @param string $path
   *   Directory path to scan.
   *
   * @return array<int, string>
   *   Array of subdirectory paths.
   */
  #[\NoDiscard]
  protected function scanDirectory(string $path): array {
    $directories = [];

    $handle = @opendir($path);
    if ($handle === FALSE) {
      return [];
    }

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

      $fullPath = $path . DIRECTORY_SEPARATOR . $entry;
      if (is_dir($fullPath)) {
        $directories[] = $fullPath;
      }
    }

    closedir($handle);

    return $directories;
  }

  /**
   * Recursively removes a directory and all its contents.
   *
   * @param string $path
   *   Path to the directory to remove.
   *
   * @return bool
   *   TRUE if successfully removed.
   */
  protected function removeDirectoryRecursive(string $path): bool {
    if (str_contains($path, '..')) {
      $this->logger->warning('Refusing to delete directory with traversal sequence: @dir', ['@dir' => $path]);
      return FALSE;
    }

    $outputDir = $this->getOutputDirectory();
    if ($outputDir !== NULL) {
      $resolvedOutput = $this->fileSystem->realpath($outputDir);
      if ($resolvedOutput !== FALSE) {
        $resolvedPath = realpath($path);
        if ($resolvedPath === FALSE || !str_starts_with($resolvedPath, $resolvedOutput . DIRECTORY_SEPARATOR)) {
          $this->logger->warning('Refusing to delete directory outside output root: @dir', ['@dir' => $path]);
          return FALSE;
        }
      }
    }

    if (!is_dir($path)) {
      return FALSE;
    }

    $handle = @opendir($path);
    if ($handle === FALSE) {
      $this->logger->warning('Cannot open directory for cleanup: @path', [
        '@path' => $path,
      ]);
      return FALSE;
    }

    $success = TRUE;

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

      $fullPath = $path . DIRECTORY_SEPARATOR . $entry;

      if (is_link($fullPath)) {
        if (!@unlink($fullPath)) {
          $this->logger->warning('Failed to unlink symlink: @path', ['@path' => $fullPath]);
          $success = FALSE;
        }
        continue;
      }

      if (is_dir($fullPath)) {
        if (!$this->removeDirectoryRecursive($fullPath)) {
          $success = FALSE;
        }
      }
      else {
        if (!$this->fileSystem->delete($fullPath)) {
          $this->logger->warning('Failed to delete segment file: @path', [
            '@path' => $fullPath,
          ]);
          $success = FALSE;
        }
      }
    }

    closedir($handle);

    if ($success) {
      if (!rmdir($path)) {
        $this->logger->debug('Failed to remove directory after cleanup: @path', [
          '@path' => $path,
        ]);
        return FALSE;
      }
      return TRUE;
    }

    return FALSE;
  }

  /**
   * Removes a directory if it is empty.
   *
   * @param string $path
   *   Path to the directory.
   */
  protected function removeEmptyDirectory(string $path): void {
    if (!is_dir($path)) {
      return;
    }

    $entries = scandir($path);
    if ($entries === FALSE) {
      $this->logger->debug('Failed to scan directory: @path', [
        '@path' => $path,
      ]);
      return;
    }

    // A directory with only '.' and '..' is empty.
    if (count($entries) <= 2) {
      if (!rmdir($path)) {
        $this->logger->debug('Failed to remove empty directory: @path', [
          '@path' => $path,
        ]);
      }
    }
  }

}
