<?php

declare(strict_types=1);

namespace Drupal\ffmpeg_media_advanced\Service;

use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\File\FileSystemInterface;
use Drupal\ffmpeg_media\Plugin\FFmpegCodec\FFmpegCodecInterface;
use Drupal\ffmpeg_media\Service\FFmpegExecutorInterface;
use Drupal\ffmpeg_media\Service\TwoPassEncodingServiceInterface;
use Drupal\ffmpeg_media\ValueObject\ConversionOptions;
use Psr\Log\LoggerInterface;

/**
 * Service for two-pass video encoding.
 *
 * Two-pass encoding analyzes the video in the first pass to determine
 * optimal bitrate distribution, then performs the actual encoding in
 * the second pass using this information for better quality.
 *
 * @internal
 */
final readonly class TwoPassEncodingService implements TwoPassEncodingServiceInterface {

  /**
   * Codecs that support two-pass encoding.
   */
  private const array SUPPORTED_CODECS = [
    'libx264',
    'libx265',
    'libvpx-vp9',
    'libsvtav1',
    'libvvenc',
  ];

  /**
   * Default target bitrate for two-pass when CRF mode is used.
   *
   * CRF is incompatible with two-pass in x264/x265 (FFmpeg 8+). When the
   * codec args contain -crf, we strip it and use a target bitrate instead.
   */
  private const string DEFAULT_TWO_PASS_BITRATE = '5M';

  /**
   * Constructs a TwoPassEncodingService object.
   *
   * @param \Drupal\ffmpeg_media\Service\FFmpegExecutorInterface $ffmpegExecutor
   *   FFmpeg executor service.
   * @param \Drupal\Core\File\FileSystemInterface $fileSystem
   *   File system service.
   * @param \Psr\Log\LoggerInterface $logger
   *   Logger service.
   * @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory
   *   Config factory service.
   */
  public function __construct(
    private FFmpegExecutorInterface $ffmpegExecutor,
    private FileSystemInterface $fileSystem,
    private LoggerInterface $logger,
    private ConfigFactoryInterface $configFactory,
  ) {}

  /**
   * {@inheritdoc}
   */
  #[\Override]
  #[\NoDiscard]
  public function encode(
    string $inputPath,
    string $outputPath,
    FFmpegCodecInterface $codec,
    ConversionOptions $options,
  ): bool {
    if (!$this->isTwoPassSupported($codec)) {
      $this->logger->warning('Two-pass encoding not supported for codec @codec, falling back to single-pass.', [
        '@codec' => $codec->getEncoder(),
      ]);
      return FALSE;
    }

    $passlogPath = $this->generatePasslogPath($inputPath);

    try {
      // First pass: analysis.
      $this->logger->info('Starting two-pass encoding pass 1 for @input', [
        '@input' => basename($inputPath),
      ]);

      $pass1Success = $this->executeFirstPass($inputPath, $codec, $options, $passlogPath);

      if (!$pass1Success) {
        $this->logger->error('Two-pass encoding pass 1 failed for @input', [
          '@input' => basename($inputPath),
        ]);
        return FALSE;
      }

      // Second pass: actual encoding.
      $this->logger->info('Starting two-pass encoding pass 2 for @input', [
        '@input' => basename($inputPath),
      ]);

      $pass2Success = $this->executeSecondPass($inputPath, $outputPath, $codec, $options, $passlogPath);

      if (!$pass2Success) {
        $this->logger->error('Two-pass encoding pass 2 failed for @input', [
          '@input' => basename($inputPath),
        ]);
        return FALSE;
      }

      $this->logger->info('Two-pass encoding completed successfully for @input', [
        '@input' => basename($inputPath),
      ]);

      return TRUE;
    }
    finally {
      // Clean up passlog files.
      $config = $this->configFactory->get('ffmpeg_media_advanced.settings');
      if ($config->get('two_pass_cleanup_logs') ?? TRUE) {
        $this->cleanupPasslog($passlogPath);
      }
    }
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  #[\NoDiscard]
  public function generatePasslogPath(string $inputPath): string {
    $config = $this->configFactory->get('ffmpeg_media_advanced.settings');
    $logDir = $config->get('two_pass_log_directory') ?? 'temporary://ffmpeg_passlog';

    // Ensure directory exists.
    $realDir = $this->fileSystem->realpath($logDir);
    if ($realDir === FALSE) {
      $this->fileSystem->prepareDirectory($logDir, FileSystemInterface::CREATE_DIRECTORY | FileSystemInterface::MODIFY_PERMISSIONS);
      $realDir = $this->fileSystem->realpath($logDir);
    }

    if ($realDir === FALSE) {
      throw new \RuntimeException(sprintf(
        'Two-pass log directory "%s" could not be resolved or created. Check that the path exists and is writable.',
        $logDir,
      ));
    }

    $baseName = pathinfo($inputPath, PATHINFO_FILENAME);
    $uniqueId = bin2hex(random_bytes(8));

    return $realDir . DIRECTORY_SEPARATOR . $baseName . '_' . $uniqueId;
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  public function cleanupPasslog(string $passlogPath): void {
    // FFmpeg creates multiple passlog files with extensions.
    $patterns = [
      $passlogPath . '-0.log',
      $passlogPath . '-0.log.mbtree',
      $passlogPath . '.log',
      $passlogPath . '.log.mbtree',
      $passlogPath . '-*.log',
      $passlogPath . '-*.log.*',
    ];

    foreach ($patterns as $pattern) {
      $files = glob($pattern);
      if ($files !== FALSE) {
        foreach ($files as $file) {
          if (file_exists($file) && is_file($file)) {
            if (!$this->fileSystem->delete($file)) {
              $this->logger->warning('Failed to delete passlog file @file', ['@file' => $file]);
            }
          }
        }
      }
    }
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  #[\NoDiscard]
  public function isTwoPassSupported(FFmpegCodecInterface $codec): bool {
    $encoder = $codec->getEncoder();
    return in_array($encoder, self::SUPPORTED_CODECS, TRUE);
  }

  /**
   * Executes the first pass (analysis).
   *
   * @param string $inputPath
   *   Input file path.
   * @param \Drupal\ffmpeg_media\Plugin\FFmpegCodec\FFmpegCodecInterface $codec
   *   Codec plugin.
   * @param \Drupal\ffmpeg_media\ValueObject\ConversionOptions $options
   *   Conversion options.
   * @param string $passlogPath
   *   Path for passlog files.
   *
   * @return bool
   *   TRUE if pass 1 succeeded.
   */
  private function executeFirstPass(
    string $inputPath,
    FFmpegCodecInterface $codec,
    ConversionOptions $options,
    string $passlogPath,
  ): bool {
    $command = $this->buildFirstPassCommand($inputPath, $codec, $options, $passlogPath);

    return $this->ffmpegExecutor->execute($command, [
      'timeout' => $options->timeout ?? 7200,
    ]);
  }

  /**
   * Executes the second pass (encoding).
   *
   * @param string $inputPath
   *   Input file path.
   * @param string $outputPath
   *   Output file path.
   * @param \Drupal\ffmpeg_media\Plugin\FFmpegCodec\FFmpegCodecInterface $codec
   *   Codec plugin.
   * @param \Drupal\ffmpeg_media\ValueObject\ConversionOptions $options
   *   Conversion options.
   * @param string $passlogPath
   *   Path for passlog files.
   *
   * @return bool
   *   TRUE if pass 2 succeeded.
   */
  private function executeSecondPass(
    string $inputPath,
    string $outputPath,
    FFmpegCodecInterface $codec,
    ConversionOptions $options,
    string $passlogPath,
  ): bool {
    $command = $this->buildSecondPassCommand($inputPath, $outputPath, $codec, $options, $passlogPath);

    return $this->ffmpegExecutor->execute($command, [
      'timeout' => $options->timeout ?? 7200,
    ]);
  }

  /**
   * Builds the first pass FFmpeg command array.
   *
   * Uses array-based command building for improved security -
   * avoids shell escaping issues by passing arguments directly to Process.
   *
   * @param string $inputPath
   *   Input file path.
   * @param \Drupal\ffmpeg_media\Plugin\FFmpegCodec\FFmpegCodecInterface $codec
   *   Codec plugin.
   * @param \Drupal\ffmpeg_media\ValueObject\ConversionOptions $options
   *   Conversion options.
   * @param string $passlogPath
   *   Path for passlog files.
   *
   * @return array<int, string>
   *   Complete FFmpeg command array for pass 1.
   */
  private function buildFirstPassCommand(
    string $inputPath,
    FFmpegCodecInterface $codec,
    ConversionOptions $options,
    string $passlogPath,
  ): array {
    $ffmpegPath = $this->ffmpegExecutor->getFfmpegPath();

    // Get codec arguments and replace CRF with bitrate for two-pass.
    $codecArgs = $this->replaceCrfWithBitrate($codec->buildArguments($options));

    // Build command array: first pass analyzes video, no audio, output to null.
    $nullDevice = PHP_OS_FAMILY === 'Windows' ? 'NUL' : '/dev/null';
    $command = [
      $ffmpegPath,
      '-y',
      '-i', $inputPath,
      ...$codecArgs,
      '-pass', '1',
      '-passlogfile', $passlogPath,
      '-an',
      '-f', 'null',
      $nullDevice,
    ];

    return $command;
  }

  /**
   * Builds the second pass FFmpeg command array.
   *
   * Uses array-based command building for improved security -
   * avoids shell escaping issues by passing arguments directly to Process.
   *
   * @param string $inputPath
   *   Input file path.
   * @param string $outputPath
   *   Output file path.
   * @param \Drupal\ffmpeg_media\Plugin\FFmpegCodec\FFmpegCodecInterface $codec
   *   Codec plugin.
   * @param \Drupal\ffmpeg_media\ValueObject\ConversionOptions $options
   *   Conversion options.
   * @param string $passlogPath
   *   Path for passlog files.
   *
   * @return array<int, string>
   *   Complete FFmpeg command array for pass 2.
   */
  private function buildSecondPassCommand(
    string $inputPath,
    string $outputPath,
    FFmpegCodecInterface $codec,
    ConversionOptions $options,
    string $passlogPath,
  ): array {
    $ffmpegPath = $this->ffmpegExecutor->getFfmpegPath();

    // Get full codec arguments and replace CRF with bitrate for two-pass.
    $codecArgs = $this->replaceCrfWithBitrate($codec->buildArguments($options));

    // Build command array: second pass does actual encoding with pass data.
    $command = [
      $ffmpegPath,
      '-y',
      '-i', $inputPath,
      ...$codecArgs,
      '-pass', '2',
      '-passlogfile', $passlogPath,
      $outputPath,
    ];

    return $command;
  }

  /**
   * Replaces CRF rate control with target bitrate for two-pass encoding.
   *
   * CRF (Constant Rate Factor) is a single-pass rate control mode that is
   * incompatible with two-pass encoding in x264/x265 (FFmpeg 8+). When the
   * codec produces -crf arguments, this method strips them and adds a target
   * bitrate (-b:v) instead, which is the correct mode for two-pass.
   *
   * @param array<int, string> $args
   *   Codec arguments that may contain -crf.
   *
   * @return array<int, string>
   *   Arguments with -crf replaced by -b:v if needed.
   */
  private function replaceCrfWithBitrate(array $args): array {
    $hasCrf = FALSE;
    $hasBitrate = FALSE;
    $result = [];
    $skip = FALSE;

    foreach ($args as $arg) {
      if ($skip) {
        $skip = FALSE;
        continue;
      }
      if ($arg === '-crf') {
        $hasCrf = TRUE;
        $skip = TRUE;
        continue;
      }
      if ($arg === '-b:v') {
        $hasBitrate = TRUE;
      }
      $result[] = $arg;
    }

    // Only add bitrate if CRF was stripped and no explicit bitrate exists.
    if ($hasCrf && !$hasBitrate) {
      $config = $this->configFactory->get('ffmpeg_media_advanced.settings');
      $bitrate = $config->get('two_pass_default_bitrate') ?? self::DEFAULT_TWO_PASS_BITRATE;
      $result[] = '-b:v';
      $result[] = $bitrate;
    }

    return $result;
  }

}
