<?php

declare(strict_types=1);

namespace Drupal\ffmpeg_media\Service;

use Drupal\ffmpeg_media\Enum\HardwareType;
use Drupal\ffmpeg_media\Exception\PathTraversalException;
use Drupal\ffmpeg_media\Plugin\FFmpegCodec\FFmpegCodecInterface;
use Drupal\ffmpeg_media\Plugin\FFmpegOutput\FFmpegOutputInterface;
use Drupal\ffmpeg_media\ValueObject\ConversionOptions;
use Drupal\ffmpeg_media\ValueObject\VideoInfo;
use Drupal\ffmpeg_media\VideoFilter\FilterContext;
use Drupal\ffmpeg_media\VideoFilter\VideoFilterCollectorInterface;
use Drupal\ffmpeg_media\VideoFilter\VideoFilterResult;

/**
 * Builds FFmpeg command arrays for single-output and adaptive conversions.
 *
 * @internal
 */
final class FfmpegCommandBuilder implements FfmpegCommandBuilderInterface {

  /**
   * Constructs a FfmpegCommandBuilder.
   *
   * @param \Drupal\ffmpeg_media\Service\FFmpegExecutorInterface $ffmpegExecutor
   *   FFmpeg executor service.
   * @param \Drupal\ffmpeg_media\Service\HardwareAccelerationManagerInterface $hwAccelManager
   *   Hardware acceleration manager.
   * @param \Drupal\ffmpeg_media\Service\HardwareDetectorInterface $hardwareDetector
   *   Hardware detector service.
   * @param \Drupal\ffmpeg_media\VideoFilter\VideoFilterCollectorInterface|null $videoFilterCollector
   *   Video filter collector, or NULL if no providers are registered.
   */
  public function __construct(
    private readonly FFmpegExecutorInterface $ffmpegExecutor,
    private readonly HardwareAccelerationManagerInterface $hwAccelManager,
    private readonly HardwareDetectorInterface $hardwareDetector,
    private readonly ?VideoFilterCollectorInterface $videoFilterCollector = NULL,
  ) {}

  /**
   * {@inheritdoc}
   */
  #[\Override]
  #[\NoDiscard]
  public function buildOutputCommand(
    string $inputPath,
    string $outputPath,
    FFmpegCodecInterface $codec,
    FFmpegOutputInterface $outputPlugin,
    ConversionOptions $options,
    ?VideoInfo $videoInfo,
    bool $softwareOnly = FALSE,
  ): array {
    // Validate paths before building command.
    $this->validatePath($inputPath);
    $this->validatePath($outputPath);

    $ffmpegPath = $this->ffmpegExecutor->getFfmpegPath();
    $codecId = $codec->getPluginId();
    $currentHwType = NULL;

    // Start building command array.
    $command = [
      $ffmpegPath,
      '-y',
      '-progress', 'pipe:2',
    ];

    // Hardware decode strategy:
    // 1. HW-accelerated codecs → full GPU pipeline (decode + encode).
    // 2. SW-only codecs with no HW encoder variant (FFV1, ProRes, JXL) →
    //    GPU decode + hwdownload bridge (decode on GPU, encode on CPU).
    // 3. SW codecs with HW variants (H264→h264_nvenc) → pure CPU
    //    (HW encoder selection happens at profile/pipeline level).
    $hwStrategy = $this->resolveHardwareStrategy($codec, $codecId, $options, $softwareOnly);
    $pipeline = $hwStrategy['pipeline'];
    $hwDecodeWithDownload = $hwStrategy['hwDecodeWithDownload'];
    $currentHwType = $hwStrategy['hwType'];
    if ($hwStrategy['decodeArgs'] !== []) {
      $command = array_merge($command, $hwStrategy['decodeArgs']);
    }

    // Add input file.
    $command[] = '-i';
    $command[] = $inputPath;

    // Collect video filters from registered providers (watermark, etc.).
    $filterResult = $this->collectVideoFilters($inputPath, $outputPlugin->getPluginId() ?? '', $videoInfo, $options);

    // Add extra inputs required by filter providers (e.g., watermark image).
    if ($filterResult !== NULL) {
      foreach ($filterResult->extraInputs as $extraInput) {
        $command[] = '-i';
        $command[] = $extraInput;
      }
    }

    // Compose video filter chain: hardware pipeline + provider filters.
    $command = $this->applyVideoFilters(
      $command,
      $pipeline['filter_complex'] ?? NULL,
      $hwDecodeWithDownload,
      $filterResult,
      $options,
    );

    // Get codec arguments.
    $codecArgs = $codec->buildArguments($options);

    // Override encoder with hardware encoder from pipeline or selector.
    if (!$softwareOnly && $pipeline !== NULL && $pipeline['encoder'] !== NULL) {
      // The pipeline's encode_args contain the complete hardware video
      // encoding setup (-c:v <hw_encoder>, -preset, -tune, -cq, etc.).
      // Replace ALL video codec args with the pipeline's encode_args,
      // keeping only audio arguments and thread count. This prevents
      // software codec-specific options (e.g. -svtav1-params, -preset 8
      // for libsvtav1) from leaking into the hardware encoder command.
      $codecArgs = $this->buildAudioArgs($options);
      $codecArgs[] = '-threads';
      $codecArgs[] = (string) $options->threads;
      $codecArgs = array_merge($codecArgs, $pipeline['encode_args']);
      // Append extra arguments after the hardware encoder designation.
      // Options like -multipass fullres must follow -c:v av1_nvenc so
      // FFmpeg associates them with the correct encoder.
      foreach ($options->extraArgs as $extraArg) {
        $codecArgs[] = $extraArg;
      }
    }
    elseif (!$softwareOnly && ($pipeline !== NULL || $codec->isHardwareAccelerated())) {
      // Only attempt hardware encoder selection when hardware infrastructure
      // is in place: either a pipeline set up the device, or the codec is
      // hardware-accelerated (meaning decode args were added above).
      // Without device init, hardware encoders like vp9_vaapi fail with
      // pixel format errors since no VAAPI device is available.
      $result = $this->hwAccelManager->selectEncoder($codec, $codecId);
      $currentHwType = $result['hwType'];
      if ($result['encoder'] !== NULL) {
        $codecArgs = $this->hwAccelManager->overrideEncoder($codecArgs, $result['encoder']);
        // Strip -pix_fmt — hardware encoders handle pixel format via their
        // own pipelines. Leaving it causes format conversion errors.
        $codecArgs = $this->stripPixelFormat($codecArgs);
      }
    }

    $command = array_merge($command, $codecArgs);

    // Get output plugin arguments and merge.
    $outputArgs = $outputPlugin->buildArguments($options);
    $command = array_merge($command, $outputArgs);

    // Note: extra arguments from $options->extraArgs are already appended
    // by codec plugins in their buildArguments() method.
    // Add output path.
    $command[] = $outputPath;

    return ['command' => $command, 'hwType' => $currentHwType];
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  #[\NoDiscard]
  public function buildAdaptiveCommand(
    string $inputPath,
    string $outputPath,
    FFmpegCodecInterface $codec,
    array $adaptiveArgs,
    ConversionOptions $options,
    ?VideoInfo $videoInfo,
    array $extraInputArgs = [],
  ): array {
    // Validate paths before building command.
    $this->validatePath($inputPath);
    $this->validatePath($outputPath);

    $ffmpegPath = $this->ffmpegExecutor->getFfmpegPath();
    $codecId = $codec->getPluginId();
    $currentHwType = NULL;

    // Start building command array.
    $command = [
      $ffmpegPath,
      '-y',
      '-progress', 'pipe:2',
    ];

    // Hardware decode is only used when the codec has a hardware encoder.
    // Software-only codecs use pure CPU decode (see buildOutputCommand
    // comment for rationale).
    $hwStrategy = $this->resolveHardwareStrategy($codec, $codecId, $options, softwareOnly: FALSE, trySoftwareCodecHardware: FALSE);
    $pipeline = $hwStrategy['pipeline'];
    $currentHwType = $hwStrategy['hwType'];
    if ($hwStrategy['decodeArgs'] !== []) {
      $command = array_merge($command, $hwStrategy['decodeArgs']);
    }

    // Add input file.
    $command[] = '-i';
    $command[] = $inputPath;

    // Collect video filters from registered providers (watermark, etc.).
    $filterResult = $this->collectVideoFilters($inputPath, 'adaptive', $videoInfo, $options);

    // Add extra inputs from filter providers before subtitle inputs.
    if ($filterResult !== NULL) {
      foreach ($filterResult->extraInputs as $extraInput) {
        $command[] = '-i';
        $command[] = $extraInput;
      }
    }

    // Add additional input arguments (e.g., subtitle files).
    if (!empty($extraInputArgs)) {
      $command = array_merge($command, $extraInputArgs);
    }

    // Prepend provider filters to the adaptive filter_complex chain.
    if ($filterResult !== NULL) {
      $adaptiveArgs = $this->prependFilterToAdaptive($adaptiveArgs, $filterResult);
    }

    // Rewrite adaptive scale filters for hardware acceleration.
    // When hardware decode is active with a full pipeline, frames remain
    // in GPU memory — replace scale filters with hardware variants.
    if ($pipeline !== NULL && $currentHwType !== NULL) {
      $adaptiveArgs = $this->rewriteAdaptiveFiltersForHardware($adaptiveArgs, $currentHwType);
    }

    // Get codec arguments.
    $codecArgs = $codec->buildArguments($options);

    // Strip -vf filters from codec args.
    $codecArgs = $this->stripVideoFilters($codecArgs);

    // Override encoder with hardware encoder from pipeline.
    if ($pipeline !== NULL && $pipeline['encoder'] !== NULL) {
      $codecArgs = $this->hwAccelManager->overrideEncoder($codecArgs, $pipeline['encoder']);
      $codecArgs = $this->stripPixelFormat($codecArgs);
    }
    elseif ($pipeline !== NULL || $codec->isHardwareAccelerated()) {
      // Only attempt hardware encoder selection when hardware infrastructure
      // is in place: either a pipeline set up the device, or the codec is
      // hardware-accelerated (meaning decode args were added above).
      // Without device init, hardware encoders like vp9_vaapi fail with
      // pixel format errors since no VAAPI device is available.
      $result = $this->hwAccelManager->selectEncoder($codec, $codecId);
      $currentHwType = $result['hwType'];
      if ($result['encoder'] !== NULL) {
        $codecArgs = $this->hwAccelManager->overrideEncoder($codecArgs, $result['encoder']);
        $codecArgs = $this->stripPixelFormat($codecArgs);
      }
    }

    $command = array_merge($command, $codecArgs);

    // Add adaptive streaming specific arguments.
    $lastArg = end($adaptiveArgs);
    $adaptiveHasOutput = is_string($lastArg) && str_contains($lastArg, '%v');
    $outputDir = dirname($outputPath);

    if ($adaptiveHasOutput) {
      $adaptiveArgs = array_map(
        static fn(string $arg): string => str_contains($arg, '%v')
          ? $outputDir . DIRECTORY_SEPARATOR . $arg
          : $arg,
        $adaptiveArgs,
      );
    }

    $command = array_merge($command, $adaptiveArgs);

    // Note: extra arguments from $options->extraArgs are already appended
    // by codec plugins in their buildArguments() method.
    // Add output path only when adaptive args did not provide one.
    if (!$adaptiveHasOutput) {
      $command[] = $outputPath;
    }

    return ['command' => $command, 'hwType' => $currentHwType];
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  #[\NoDiscard]
  public function stripVideoFilters(array $args): array {
    $result = [];
    $skip = FALSE;
    foreach ($args as $arg) {
      if ($skip) {
        $skip = FALSE;
        continue;
      }
      if ($arg === '-vf' || $arg === '-filter:v') {
        $skip = TRUE;
        continue;
      }
      $result[] = $arg;
    }
    return $result;
  }

  // phpcs:disable Drupal.Files.LineLength.TooLong
  /**
   * Resolves hardware acceleration strategy for decode and encode.
   *
   * Determines whether to use a full GPU pipeline, GPU decode with software
   * encode (hwdownload bridge), or pure CPU processing. The decision depends
   * on:
   * - Whether the codec is hardware-accelerated.
   * - Whether a hardware encoder variant exists for software codecs.
   * - Whether hardware is available at all.
   *
   * @param \Drupal\ffmpeg_media\Plugin\FFmpegCodec\FFmpegCodecInterface $codec
   *   The codec plugin.
   * @param string $codecId
   *   The codec plugin ID.
   * @param \Drupal\ffmpeg_media\ValueObject\ConversionOptions $options
   *   Conversion options.
   * @param bool $softwareOnly
   *   If TRUE, skip all hardware acceleration.
   * @param bool $trySoftwareCodecHardware
   *   If TRUE (default), attempt hardware acceleration for software codecs
   *   that have a hardware encoder variant (e.g. av1 → av1_nvenc) or that
   *   can benefit from GPU decode with hwdownload. Set to FALSE for adaptive
   *   streaming where software codecs use pure CPU decode.
   *
   * @return array
   *   Resolved hardware strategy.
   *
   * @phpstan-return array{pipeline: array{init_args: array<string>, decode_args: array<string>, filter_complex: string|null, encoder: string|null, encode_args: array<string>}|null, hwType: \Drupal\ffmpeg_media\Enum\HardwareType|null, hwDecodeWithDownload: bool, decodeArgs: array<int, string>}
   */
  // phpcs:enable Drupal.Files.LineLength.TooLong
  private function resolveHardwareStrategy(
    FFmpegCodecInterface $codec,
    string $codecId,
    ConversionOptions $options,
    bool $softwareOnly,
    bool $trySoftwareCodecHardware = TRUE,
  ): array {
    $result = [
      'pipeline' => NULL,
      'hwType' => NULL,
      'hwDecodeWithDownload' => FALSE,
      'decodeArgs' => [],
    ];

    if ($softwareOnly) {
      return $result;
    }

    if ($codec->isHardwareAccelerated()) {
      // Full hardware pipeline: GPU decode → GPU encode (no transfer).
      $pipeline = $this->hwAccelManager->buildHardwarePipeline($codecId, $options);

      if ($pipeline !== NULL) {
        $result['pipeline'] = $pipeline;
        $result['decodeArgs'] = array_merge($pipeline['init_args'], $pipeline['decode_args']);
        $result['hwType'] = $this->hwAccelManager->extractHwTypeFromPipeline($pipeline);
      }
      else {
        $hwType = $this->hwAccelManager->detectBestHardware();
        if ($hwType !== NULL) {
          $hwArgs = $this->hardwareDetector->getHwaccelArguments($hwType, 'decode');
          $result['decodeArgs'] = $hwArgs;
        }
      }

      return $result;
    }

    if (!$trySoftwareCodecHardware) {
      return $result;
    }

    // Software codec — check if a HW encoder variant exists.
    // If selectEncoder() finds one (e.g. av1_nvenc for av1),
    // build the full hardware pipeline (device init + decode + encode).
    // If no HW encoder exists (FFV1, ProRes, JXL), use GPU decode
    // + hwdownload to accelerate at least the decode stage.
    $hwEncoderResult = $this->hwAccelManager->selectEncoder($codec, $codecId);
    if ($hwEncoderResult['encoder'] !== NULL) {
      // A hardware variant exists — build the full GPU pipeline so that
      // device initialization, hardware decode, filter chain, and encoder
      // override are all handled correctly.
      $pipeline = $this->hwAccelManager->buildHardwarePipeline($codecId, $options);
      if ($pipeline !== NULL) {
        $result['pipeline'] = $pipeline;
        $result['decodeArgs'] = array_merge($pipeline['init_args'], $pipeline['decode_args']);
        $result['hwType'] = $this->hwAccelManager->extractHwTypeFromPipeline($pipeline);
      }
    }
    else {
      $hwType = $this->hwAccelManager->detectBestHardware();
      if ($hwType !== NULL) {
        $hwArgs = $this->hardwareDetector->getHwaccelArguments($hwType, 'decode');
        if ($hwArgs !== []) {
          $result['decodeArgs'] = $hwArgs;
          $result['hwDecodeWithDownload'] = TRUE;
        }
      }
    }

    return $result;
  }

  /**
   * Strips -pix_fmt from argument array.
   *
   * Used when hardware pipeline is active, as the pipeline handles pixel
   * format via its filter chain. Leaving -pix_fmt causes FFmpeg to insert
   * an impossible format conversion (e.g. cuda → yuv420p).
   *
   * @param array<int, string> $args
   *   FFmpeg arguments.
   *
   * @return array<int, string>
   *   Arguments with -pix_fmt entries removed.
   */
  private function stripPixelFormat(array $args): array {
    $result = [];
    $skip = FALSE;
    foreach ($args as $arg) {
      if ($skip) {
        $skip = FALSE;
        continue;
      }
      if ($arg === '-pix_fmt') {
        $skip = TRUE;
        continue;
      }
      $result[] = $arg;
    }
    return $result;
  }

  /**
   * Rewrites adaptive streaming scale filters for hardware acceleration.
   *
   * When hardware decode is active, video frames remain in GPU memory
   * (e.g. cuda format for NVENC). The standard `scale` filter cannot
   * process these frames. This method replaces `scale=W:H` entries in
   * the adaptive -filter_complex with hardware-specific variants like
   * `scale_cuda=W:H:interp_algo=lanczos`.
   *
   * @param array<int, string> $adaptiveArgs
   *   Adaptive streaming arguments.
   * @param \Drupal\ffmpeg_media\Enum\HardwareType $hwType
   *   Active hardware type.
   *
   * @return array<int, string>
   *   Arguments with scale filters rewritten for hardware.
   */
  private function rewriteAdaptiveFiltersForHardware(array $adaptiveArgs, HardwareType $hwType): array {
    if ($hwType->scaleFilterName() === NULL) {
      return $adaptiveArgs;
    }

    $filterIdx = array_search('-filter_complex', $adaptiveArgs, TRUE);
    if ($filterIdx === FALSE || !isset($adaptiveArgs[$filterIdx + 1])) {
      return $adaptiveArgs;
    }

    $rewritten = preg_replace_callback(
      '/\bscale=(\d+):(\d+)/',
      static function (array $m) use ($hwType): string {
        return match ($hwType) {
          HardwareType::Nvenc, HardwareType::Cuda => "scale_cuda={$m[1]}:{$m[2]}:interp_algo=lanczos",
          HardwareType::Vaapi => "scale_vaapi=w={$m[1]}:h={$m[2]}:format=nv12",
          HardwareType::Qsv => "scale_qsv=w={$m[1]}:h={$m[2]}",
          HardwareType::Vulkan => "scale_vulkan=w={$m[1]}:h={$m[2]}",
          default => "scale={$m[1]}:{$m[2]}",
        };
      },
      $adaptiveArgs[$filterIdx + 1],
    );

    if (is_string($rewritten)) {
      $adaptiveArgs[$filterIdx + 1] = $rewritten;
    }

    return $adaptiveArgs;
  }

  /**
   * Builds a hwdownload video filter for GPU-decode → CPU-encode bridge.
   *
   * @param \Drupal\ffmpeg_media\ValueObject\ConversionOptions $options
   *   Conversion options (used to determine target pixel format).
   *
   * @return string
   *   The -vf filter string (e.g. "hwdownload,format=nv12").
   */
  private function buildHwdownloadFilter(ConversionOptions $options): string {
    $targetFormat = $options->pixelFormat !== '' ? $options->pixelFormat : 'nv12';

    return "hwdownload,format={$targetFormat}";
  }

  /**
   * Builds audio-only arguments from conversion options.
   *
   * Used when the hardware pipeline handles all video encoding and only
   * audio arguments are needed from the conversion options.
   *
   * @param \Drupal\ffmpeg_media\ValueObject\ConversionOptions $options
   *   Conversion options.
   *
   * @return array<int, string>
   *   Audio-related FFmpeg arguments.
   */
  private function buildAudioArgs(ConversionOptions $options): array {
    $args = ['-c:a', $options->audioCodec, '-b:a', $options->audioBitrate];

    if ($options->audioSampleRate !== NULL) {
      $args[] = '-ar';
      $args[] = (string) $options->audioSampleRate;
    }

    if ($options->audioChannels !== NULL) {
      $args[] = '-ac';
      $args[] = (string) $options->audioChannels;
    }

    return $args;
  }

  /**
   * Allowed Drupal stream wrappers for file paths.
   *
   * Includes temporary:// because FFmpeg commands may reference intermediate
   * files (e.g., two-pass log files, extracted audio) that live in the temp
   * directory. ConversionFileManager uses a stricter set (public/private only)
   * for permanent output storage.
   */
  private const array ALLOWED_STREAM_WRAPPERS = [
    'public',
    'private',
    'temporary',
  ];

  /**
   * {@inheritdoc}
   */
  #[\Override]
  public function validatePath(string $path): void {
    // Reject null bytes (injection vector).
    if (str_contains($path, "\0")) {
      throw PathTraversalException::nullByte($path);
    }

    // Reject path traversal sequences.
    if (preg_match('#(?:^|[\\\\/])\.\.(?:[\\\\/]|$)#', $path)) {
      throw PathTraversalException::traversalSequence($path);
    }

    // Check if it's a stream wrapper path (e.g. public://videos/file.mp4).
    if (preg_match('#^([a-zA-Z][a-zA-Z0-9_]*)://#', $path, $matches)) {
      $wrapper = strtolower($matches[1]);
      if (!in_array($wrapper, self::ALLOWED_STREAM_WRAPPERS, TRUE)) {
        throw PathTraversalException::disallowedWrapper($path, $wrapper);
      }
      return;
    }

    // Reject relative paths — FFmpeg should always receive absolute
    // paths or stream wrapper URIs.
    if (!str_starts_with($path, '/') && !preg_match('#^[a-zA-Z]:\\\\#', $path)) {
      throw PathTraversalException::outsideAllowedDirectory($path);
    }
  }

  /**
   * Collects video filters from registered providers.
   *
   * @param string $inputPath
   *   Source video path.
   * @param string $outputType
   *   Output type identifier (plugin ID or 'adaptive').
   * @param \Drupal\ffmpeg_media\ValueObject\VideoInfo|null $videoInfo
   *   Video info if available.
   * @param \Drupal\ffmpeg_media\ValueObject\ConversionOptions $options
   *   Conversion options.
   *
   * @return \Drupal\ffmpeg_media\VideoFilter\VideoFilterResult|null
   *   Collected filter result or NULL.
   */
  private function collectVideoFilters(
    string $inputPath,
    string $outputType,
    ?VideoInfo $videoInfo,
    ConversionOptions $options,
  ): ?VideoFilterResult {
    if ($this->videoFilterCollector === NULL) {
      return NULL;
    }

    $context = new FilterContext(
      sourcePath: $inputPath,
      outputType: $outputType,
      videoInfo: $videoInfo,
      fieldName: $options->fieldName,
    );

    return $this->videoFilterCollector->collectFilters($context);
  }

  /**
   * Applies composed video filters to the command array.
   *
   * Merges hardware pipeline filters with provider-contributed filters
   * into a single -filter_complex or -vf argument. Handles all
   * combinations: hardware-only, provider-only, both, neither.
   *
   * @param array<int, string> $command
   *   Command array built so far.
   * @param string|null $hwFilter
   *   Hardware pipeline filter string, or NULL.
   * @param bool $hwDecodeWithDownload
   *   Whether hwdownload bridge is needed.
   * @param \Drupal\ffmpeg_media\VideoFilter\VideoFilterResult|null $filterResult
   *   Provider filter result, or NULL.
   * @param \Drupal\ffmpeg_media\ValueObject\ConversionOptions $options
   *   Conversion options.
   *
   * @return array<int, string>
   *   Command array with filter arguments appended.
   */
  private function applyVideoFilters(
    array $command,
    ?string $hwFilter,
    bool $hwDecodeWithDownload,
    ?VideoFilterResult $filterResult,
    ConversionOptions $options,
  ): array {
    $hasHwFilter = $hwFilter !== NULL;
    $hasProviderFilter = $filterResult !== NULL;

    // Resolve extra input references in filter strings.
    // Extra inputs are added after [0] (main video), so they start at [1].
    if ($hasProviderFilter) {
      $filterResult = $this->resolveExtraInputLabels($filterResult);
    }

    // Case 1: No filters at all.
    if (!$hasHwFilter && !$hwDecodeWithDownload && !$hasProviderFilter) {
      return $command;
    }

    // Case 2: Only hwdownload bridge, no provider filters.
    if ($hwDecodeWithDownload && !$hasProviderFilter) {
      $command[] = '-vf';
      $command[] = $this->buildHwdownloadFilter($options);
      return $command;
    }

    // Case 3: Only provider filters (no hardware pipeline).
    if (!$hasHwFilter && !$hwDecodeWithDownload && $hasProviderFilter) {
      $providerFilter = $filterResult->filterComplex;
      // Replace [video_in] with [0:v] and [video_out] with nothing
      // (direct output from filter_complex).
      $providerFilter = str_replace('[video_in]', '[0:v]', $providerFilter);
      $providerFilter = str_replace('[video_out]', '[vout]', $providerFilter);
      $command[] = '-filter_complex';
      $command[] = $providerFilter;
      $command[] = '-map';
      $command[] = '[vout]';
      $command[] = '-map';
      $command[] = '0:a?';
      return $command;
    }

    // Case 4: Hardware pipeline filter + provider filters.
    if ($hasHwFilter && $hasProviderFilter) {
      // Chain: hw filter → provider filters.
      $providerFilter = $filterResult->filterComplex;
      $providerFilter = str_replace('[video_in]', '[hw_out]', $providerFilter);
      $providerFilter = str_replace('[video_out]', '[vout]', $providerFilter);
      $composed = '[0:v]' . $hwFilter . '[hw_out];' . $providerFilter;
      $command[] = '-filter_complex';
      $command[] = $composed;
      $command[] = '-map';
      $command[] = '[vout]';
      $command[] = '-map';
      $command[] = '0:a?';
      return $command;
    }

    // Case 5: Only hardware pipeline filter (no provider).
    if ($hasHwFilter) {
      $command[] = '-vf';
      $command[] = $hwFilter;
      return $command;
    }

    // Case 6: hwdownload bridge + provider filters.
    assert($filterResult !== NULL);
    $downloadFilter = $this->buildHwdownloadFilter($options);
    $providerFilter = $filterResult->filterComplex;
    $providerFilter = str_replace('[video_in]', '[hw_out]', $providerFilter);
    $providerFilter = str_replace('[video_out]', '[vout]', $providerFilter);
    $composed = '[0:v]' . $downloadFilter . '[hw_out];' . $providerFilter;
    $command[] = '-filter_complex';
    $command[] = $composed;
    $command[] = '-map';
    $command[] = '[vout]';
    $command[] = '-map';
    $command[] = '0:a?';
    return $command;
  }

  /**
   * Prepends provider filters to adaptive streaming filter_complex.
   *
   * For adaptive streaming, the output plugin provides a -filter_complex
   * that splits the video into multiple renditions. Provider filters
   * (e.g., watermark) need to be applied BEFORE the split so every
   * rendition gets the watermark.
   *
   * @param array<int, string> $adaptiveArgs
   *   Adaptive streaming arguments.
   * @param \Drupal\ffmpeg_media\VideoFilter\VideoFilterResult $filterResult
   *   Provider filter result.
   *
   * @return array<int, string>
   *   Modified adaptive arguments with provider filters prepended.
   */
  private function prependFilterToAdaptive(
    array $adaptiveArgs,
    VideoFilterResult $filterResult,
  ): array {
    $filterResult = $this->resolveExtraInputLabels($filterResult);
    $filterIdx = array_search('-filter_complex', $adaptiveArgs, TRUE);

    // Prepare provider filter labels for adaptive chain input.
    $providerFilter = $filterResult->filterComplex;
    $providerFilter = str_replace('[video_in]', '[0:v]', $providerFilter);
    $providerFilter = str_replace('[video_out]', '[pre]', $providerFilter);

    if ($filterIdx !== FALSE && isset($adaptiveArgs[$filterIdx + 1])) {
      // Existing -filter_complex: prepend provider filter and rewire
      // the adaptive chain to use [pre] instead of [0:v].
      $existing = $adaptiveArgs[$filterIdx + 1];
      $existing = preg_replace('/\[0:v\]/', '[pre]', $existing, 1);
      $adaptiveArgs[$filterIdx + 1] = $providerFilter . ';' . $existing;
    }
    else {
      // No existing -filter_complex: add one with just the provider filter.
      // Use [vout] as final output instead of [pre].
      $providerFilter = str_replace('[pre]', '[vout]', $providerFilter);
      $adaptiveArgs = array_merge(
        ['-filter_complex', $providerFilter, '-map', '[vout]', '-map', '0:a?'],
        $adaptiveArgs,
      );
    }

    return $adaptiveArgs;
  }

  /**
   * Resolves extra-input placeholder labels to actual FFmpeg input indices.
   *
   * Extra inputs from filter providers are added after the main video
   * input [0]. The first extra input becomes [1:v], the second [2:v],
   * etc. This method replaces provider-defined labels with the correct
   * input stream references.
   *
   * @param \Drupal\ffmpeg_media\VideoFilter\VideoFilterResult $result
   *   Filter result with potential unresolved labels.
   *
   * @return \Drupal\ffmpeg_media\VideoFilter\VideoFilterResult
   *   Result with labels resolved to input indices.
   */
  private function resolveExtraInputLabels(
    VideoFilterResult $result,
  ): VideoFilterResult {
    if ($result->extraInputs === []) {
      return $result;
    }

    $filter = $result->filterComplex;
    foreach ($result->extraInputLabels as $index => $label) {
      $filter = str_replace($label, '[' . ($index + 1) . ':v]', $filter);
    }

    return new VideoFilterResult(
      filterComplex: $filter,
      extraInputs: $result->extraInputs,
      extraInputLabels: $result->extraInputLabels,
    );
  }

}
