<?php

declare(strict_types=1);

namespace Drupal\ffmpeg_media\Service;

use Symfony\Component\Process\Exception\ProcessTimedOutException;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\File\FileSystemInterface;
use Drupal\Core\Logger\LoggerChannelInterface;
use Drupal\ffmpeg_media\Enum\HardwareType;
use Drupal\ffmpeg_media\Exception\FFmpegException;
use Drupal\ffmpeg_media\Plugin\FFmpegCodec\FFmpegCodecInterface;
use Drupal\ffmpeg_media\ValueObject\ConversionOptions;
use Drupal\ffmpeg_media\ValueObject\VideoInfo;
use Symfony\Component\Process\Process;

/**
 * Service for executing FFmpeg commands.
 *
 * @internal
 */
final class FFmpegExecutor implements FFmpegExecutorInterface {

  /**
   * Timestamp of last progress update.
   *
   * @var float
   */
  protected float $lastProgressUpdate = 0.0;

  /**
   * Constructs an FFmpegExecutor object.
   *
   * @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory
   *   Config factory.
   * @param \Drupal\ffmpeg_media\Service\ConfigValueProviderInterface $configValueProvider
   *   Config value provider for cached config reads.
   * @param \Drupal\Core\File\FileSystemInterface $fileSystem
   *   File system service.
   * @param \Drupal\Core\Logger\LoggerChannelInterface $logger
   *   Logger channel.
   * @param \Drupal\ffmpeg_media\Service\FFmpegBinaryPathResolverInterface $binaryPathResolver
   *   Binary path resolver for configured or auto-detected executables.
   * @param \Drupal\ffmpeg_media\Service\FFmpegCapabilityInspectorInterface $capabilityInspector
   *   Capability inspector for version, encoder, and filter discovery.
   * @param \Drupal\ffmpeg_media\Service\ProgressParserInterface|null $progressParser
   *   Progress parser for FFmpeg output.
   */
  public function __construct(
    protected readonly ConfigFactoryInterface $configFactory,
    protected readonly ConfigValueProviderInterface $configValueProvider,
    protected readonly FileSystemInterface $fileSystem,
    protected readonly LoggerChannelInterface $logger,
    protected readonly FFmpegBinaryPathResolverInterface $binaryPathResolver,
    protected readonly FFmpegCapabilityInspectorInterface $capabilityInspector,
    protected readonly ?ProgressParserInterface $progressParser = NULL,
  ) {}

  /**
   * {@inheritdoc}
   *
   * @param array<int, string> $command
   *   FFmpeg command as argument array.
   * @param array{timeout?: int|null, progress_callback?: \Closure, media_id?: int} $options
   *   Execution options.
   */
  #[\Override]
  #[\NoDiscard]
  public function execute(array $command, array $options = []): bool {
    $timeout = $options['timeout'] ?? $this->configValueProvider->getFfmpegTimeout();

    $commandArray = $this->normalizeCommand($command);
    $process = new Process($commandArray);
    $process->setTimeout($timeout > 0 ? $timeout : 3600);

    try {
      // Use executeWithProgress() for progress tracking support.
      $process->run();

      if (!$process->isSuccessful()) {
        throw FFmpegException::commandFailed(
          $this->formatCommandForError($commandArray),
          $process->getErrorOutput(),
          $process->getExitCode() ?? 1,
        );
      }

      return TRUE;
    }
    catch (ProcessTimedOutException $e) {
      throw FFmpegException::timeout($timeout);
    }
  }

  /**
   * {@inheritdoc}
   *
   * @param array<int, string> $command
   *   FFmpeg command as argument array.
   * @param int $mediaId
   *   Media entity ID for progress tracking.
   * @param float|null $totalDuration
   *   Total duration in seconds, if known.
   * @param array{timeout?: int, progress_callback?: \Closure} $options
   *   Execution options.
   */
  #[\Override]
  #[\NoDiscard]
  public function executeWithProgress(
    array $command,
    int $mediaId,
    ?float $totalDuration = NULL,
    array $options = [],
  ): bool {
    $timeout = $options['timeout'] ?? $this->configValueProvider->getFfmpegTimeout();
    /** @var \Closure|null $progressCallback */
    $progressCallback = isset($options['progress_callback']) && $options['progress_callback'] instanceof \Closure
      ? $options['progress_callback']
      : NULL;
    $progressUpdateInterval = (int) ($this->getConfig('progress_update_interval') ?? 2);

    $commandArray = $this->normalizeCommand($command);
    $process = new Process($commandArray);
    $process->setTimeout($timeout > 0 ? $timeout : 3600);

    $lastProgress = 0;
    $this->lastProgressUpdate = 0.0;

    try {
      $process->run(function ($type, $buffer) use (
        $progressCallback,
        &$lastProgress,
        $totalDuration,
        $progressUpdateInterval,
      ): void {
        if ($progressCallback === NULL) {
          return;
        }

        // Parse progress from stderr (FFmpeg outputs progress info there).
        if ($type === Process::ERR) {
          $parser = $this->progressParser ?? new ProgressParser();
          $progress = $parser->parseProgress($buffer, $totalDuration);

          if ($progress !== NULL && $progress !== $lastProgress) {
            // Throttle updates based on config interval.
            $now = microtime(TRUE);
            if ($now - $this->lastProgressUpdate >= $progressUpdateInterval) {
              $lastProgress = $progress;
              $this->lastProgressUpdate = $now;
              try {
                $progressCallback($progress);
              }
              catch (\Throwable $e) {
                $this->logger->warning('Progress callback error: @error', [
                  '@error' => $e->getMessage(),
                ]);
              }
            }
          }
          elseif ($progress === NULL && $totalDuration === NULL) {
            // Duration unknown — parse elapsed encoding time instead.
            $elapsed = $parser->parseElapsedTime($buffer);
            if ($elapsed !== NULL) {
              $now = microtime(TRUE);
              if ($now - $this->lastProgressUpdate >= $progressUpdateInterval) {
                $this->lastProgressUpdate = $now;
                try {
                  $progressCallback(-1, $elapsed);
                }
                catch (\Throwable $e) {
                  $this->logger->warning('Progress callback error: @error', [
                    '@error' => $e->getMessage(),
                  ]);
                }
              }
            }
          }
        }
      });

      if (!$process->isSuccessful()) {
        throw FFmpegException::commandFailed(
          $this->formatCommandForError($commandArray),
          $process->getErrorOutput(),
          $process->getExitCode() ?? 1,
        );
      }

      // Ensure final progress update at 100%.
      if ($progressCallback !== NULL && $lastProgress < 100) {
        try {
          $progressCallback(100);
        }
        catch (\Throwable $e) {
          $this->logger->warning('Progress callback error at completion: @error', [
            '@error' => $e->getMessage(),
          ]);
        }
      }

      return TRUE;
    }
    catch (ProcessTimedOutException $e) {
      throw FFmpegException::timeout($timeout);
    }
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  #[\NoDiscard]
  public function buildCommandArray(
    string $input,
    string $output,
    FFmpegCodecInterface $codec,
    ConversionOptions $options,
  ): array {
    $ffmpegPath = $this->getFfmpegPath();
    $codecArgs = $codec->buildArguments($options);

    // Build command array with progress output.
    return [
      $ffmpegPath,
      '-y',
      '-progress', 'pipe:2',
      '-i', $input,
      ...$codecArgs,
      $output,
    ];
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  #[\NoDiscard]
  public function getVideoInfo(string $path): ?VideoInfo {
    $realPath = $this->fileSystem->realpath($path);
    if ($realPath === FALSE || !file_exists($realPath)) {
      return NULL;
    }

    $ffprobePath = $this->getFfprobePath();

    // Use array-based Process for security.
    $commandArray = [
      $ffprobePath,
      '-v', 'quiet',
      '-print_format', 'json',
      '-show_format',
      '-show_streams',
      $realPath,
    ];

    $process = new Process($commandArray);
    $process->setTimeout(30);
    $exitCode = $process->run();
    unset($exitCode);

    if (!$process->isSuccessful()) {
      $this->logger->warning('FFprobe failed for @path: @error', [
        '@path' => $path,
        '@error' => $process->getErrorOutput(),
      ]);
      return NULL;
    }

    $output = $process->getOutput();
    $data = json_decode($output, TRUE);

    if ($data === NULL || !isset($data['streams'])) {
      return NULL;
    }

    try {
      return VideoInfo::fromFfprobe($path, $data);
    }
    catch (\InvalidArgumentException $e) {
      $this->logger->warning('Could not parse video info for @path: @error', [
        '@path' => $path,
        '@error' => $e->getMessage(),
      ]);
      return NULL;
    }
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  #[\NoDiscard]
  public function isAvailable(): bool {
    $ffmpegPath = $this->getFfmpegPath();
    return is_executable($ffmpegPath);
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  #[\NoDiscard]
  public function getVersion(): ?string {
    return $this->capabilityInspector->getVersion();
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  #[\NoDiscard]
  public function hasEncoder(string $encoder): bool {
    return $this->capabilityInspector->hasEncoder($encoder);
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  #[\NoDiscard]
  public function hasFilter(string $filter): bool {
    return $this->capabilityInspector->hasFilter($filter);
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  #[\NoDiscard]
  public function getFfmpegPath(): string {
    return $this->binaryPathResolver->getFfmpegPath();
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  #[\NoDiscard]
  public function getFfprobePath(): string {
    return $this->binaryPathResolver->getFfprobePath();
  }

  /**
   * {@inheritdoc}
   *
   * @param array<int, string> $command
   *   FFmpeg command as argument array.
   * @param string $hwType
   *   Hardware acceleration type.
   * @param array{timeout?: int, progress_callback?: \Closure} $options
   *   Execution options.
   */
  #[\Override]
  #[\NoDiscard]
  public function executeWithHardware(
    array $command,
    string $hwType,
    array $options = [],
  ): bool {
    $timeout = $options['timeout'] ?? $this->configValueProvider->getFfmpegTimeout();

    $commandArray = $this->normalizeCommand($command);

    // Get hardware acceleration arguments.
    $hwArgs = $this->getHwaccelArguments($hwType);

    // Insert hwaccel args after ffmpeg path but before -i.
    if (!empty($hwArgs)) {
      $commandArray = $this->insertHwaccelArgs($commandArray, $hwArgs);
    }

    $process = new Process($commandArray);
    $process->setTimeout($timeout > 0 ? $timeout : 3600);

    try {
      // Note: Progress tracking requires duration info -
      // use executeWithProgress() instead.
      $exitCode = $process->run();
      unset($exitCode);

      if (!$process->isSuccessful()) {
        // Check if it's a hardware acceleration failure.
        $errorOutput = $process->getErrorOutput();
        if ($this->isHardwareError($errorOutput, $hwType)) {
          $this->logger->warning('Hardware acceleration @hw failed, consider falling back to software encoding: @error', [
            '@hw' => $hwType,
            '@error' => substr($errorOutput, 0, 500),
          ]);
        }

        throw FFmpegException::commandFailed(
          $this->formatCommandForError($commandArray),
          $errorOutput,
          $process->getExitCode() ?? 1,
        );
      }

      return TRUE;
    }
    catch (ProcessTimedOutException $e) {
      throw FFmpegException::timeout($timeout);
    }
  }

  /**
   * Gets hardware acceleration arguments for FFmpeg.
   *
   * @param string $hwType
   *   Hardware type (nvenc, vaapi, qsv, videotoolbox, amf).
   *
   * @return array<int, string>
   *   Array of FFmpeg arguments for hardware acceleration.
   */
  protected function getHwaccelArguments(string $hwType): array {
    return match ($hwType) {
      HardwareType::Nvenc->value => ['-hwaccel', 'cuda', '-hwaccel_output_format', 'cuda'],
      HardwareType::Vaapi->value => [
        '-hwaccel', 'vaapi',
        '-hwaccel_device', '/dev/dri/renderD128',
        '-hwaccel_output_format', 'vaapi',
      ],
      HardwareType::Qsv->value => ['-hwaccel', 'qsv', '-hwaccel_output_format', 'qsv'],
      HardwareType::VideoToolbox->value => ['-hwaccel', 'videotoolbox'],
      HardwareType::Amf->value => ['-hwaccel', 'd3d11va'],
      HardwareType::Vulkan->value => [
        '-init_hw_device', 'vulkan=vk:0',
        '-hwaccel', 'vulkan',
        '-hwaccel_output_format', 'vulkan',
      ],
      default => [],
    };
  }

  /**
   * Checks if an error is related to hardware acceleration.
   *
   * @param string $errorOutput
   *   Error output from FFmpeg.
   * @param string $hwType
   *   Hardware type being used.
   *
   * @return bool
   *   TRUE if the error is hardware-related.
   */
  #[\Override]
  #[\NoDiscard]
  public function isHardwareError(string $errorOutput, string $hwType): bool {
    $hwErrorPatterns = [
      HardwareType::Nvenc->value => ['CUDA', 'nvenc', 'nvidia', 'GPU'],
      HardwareType::Vaapi->value => ['VAAPI', 'vaapi', 'DRM', 'renderD128'],
      HardwareType::Qsv->value => ['QSV', 'qsv', 'Quick Sync'],
      HardwareType::VideoToolbox->value => ['VideoToolbox', 'videotoolbox', 'VT'],
      HardwareType::Amf->value => ['AMF', 'amf', 'AMD'],
      HardwareType::Vulkan->value => ['Vulkan', 'vulkan', 'vk_', 'VK_'],
    ];

    $patterns = $hwErrorPatterns[$hwType] ?? [];

    foreach ($patterns as $pattern) {
      if (stripos($errorOutput, $pattern) !== FALSE) {
        return TRUE;
      }
    }

    // Generic hardware failure patterns.
    $genericPatterns = [
      'hwaccel',
      'hardware',
      'encoder.*not found',
      'decoder.*not found',
      'Cannot initialize',
      'device.*failed',
    ];

    foreach ($genericPatterns as $pattern) {
      if (preg_match('/' . $pattern . '/i', $errorOutput)) {
        return TRUE;
      }
    }

    return FALSE;
  }

  /**
   * Gets a config value.
   *
   * @param string $key
   *   Config key.
   *
   * @return mixed
   *   Config value.
   */
  protected function getConfig(string $key): mixed {
    return $this->configFactory->get('ffmpeg_media.settings')->get($key);
  }

  /**
   * Normalizes a command to an array.
   *
   * Re-indexes the command array before process execution.
   *
   * @param array<int, string> $command
   *   Command as argument array.
   *
   * @return array<int, string>
   *   Command as array of arguments.
   */
  protected function normalizeCommand(array $command): array {
    return array_values($command);
  }

  /**
   * Formats a command array for error messages.
   *
   * @param array<int, string> $commandArray
   *   Command array.
   *
   * @return string
   *   Formatted command string for display.
   */
  protected function formatCommandForError(array $commandArray): string {
    return $commandArray
      |> (fn(array $arr) => array_map(
        static fn(string $arg): string => str_contains($arg, ' ') ? '"' . $arg . '"' : $arg,
        $arr,
      ))
      |> (fn(array $arr) => implode(' ', $arr));
  }

  /**
   * Inserts hardware acceleration arguments into command array.
   *
   * @param array<int, string> $commandArray
   *   Original command array.
   * @param array<int, string> $hwArgs
   *   Hardware acceleration arguments.
   *
   * @return array<int, string>
   *   Modified command array with hwaccel args inserted before -i.
   */
  protected function insertHwaccelArgs(array $commandArray, array $hwArgs): array {
    // Find position of -i flag.
    $inputPos = array_search('-i', $commandArray, TRUE);

    if ($inputPos === FALSE) {
      // No -i found, append hwaccel args after first element (ffmpeg path).
      return [
        $commandArray[0],
        ...$hwArgs,
        ...array_slice($commandArray, 1),
      ];
    }

    // Insert hwaccel args before -i.
    return [
      ...array_slice($commandArray, 0, $inputPos),
      ...$hwArgs,
      ...array_slice($commandArray, $inputPos),
    ];
  }

}
