<?php

declare(strict_types=1);

namespace Drupal\ffmpeg_media_whisper\Plugin\FFmpegProcessor;

use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\File\FileSystemInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\ffmpeg_media\Attribute\FFmpegProcessor;
use Drupal\ffmpeg_media\Plugin\FFmpegProcessor\FFmpegProcessorBase;
use Drupal\ffmpeg_media\Service\FFmpegExecutorInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Process\ExecutableFinder;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Process\Exception\ProcessTimedOutException;
use Symfony\Component\Process\Process;

/**
 * Processor plugin for generating subtitles using whisper.cpp CLI.
 *
 * Uses a two-step pipeline: FFmpeg extracts audio to 16kHz mono WAV,
 * then whisper-cli transcribes it to SRT/VTT.
 */
#[FFmpegProcessor(
  id: 'whisper',
  label: new TranslatableMarkup('Whisper Subtitle Generator'),
  description: new TranslatableMarkup('Generates subtitles using whisper.cpp CLI for speech-to-text.'),
  outputType: 'text',
  outputExtension: 'srt',
  requiredFilters: [],
  weight: 20,
)]
final class WhisperProcessor extends FFmpegProcessorBase implements ContainerFactoryPluginInterface {

  /**
   * Constructs a WhisperProcessor object.
   *
   * @param array $configuration
   *   Plugin configuration.
   * @param string $plugin_id
   *   Plugin ID.
   * @param mixed $plugin_definition
   *   Plugin definition.
   * @param \Drupal\ffmpeg_media\Service\FFmpegExecutorInterface $ffmpegExecutor
   *   FFmpeg executor service.
   * @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory
   *   Config factory.
   * @param \Drupal\Core\File\FileSystemInterface $fileSystem
   *   File system service.
   * @param \Psr\Log\LoggerInterface|null $logger
   *   Logger service.
   */
  public function __construct(
    array $configuration,
    $plugin_id,
    $plugin_definition,
    #[Autowire(service: FFmpegExecutorInterface::class)]
    FFmpegExecutorInterface $ffmpegExecutor,
    protected readonly ConfigFactoryInterface $configFactory,
    #[Autowire(service: 'file_system')]
    protected readonly FileSystemInterface $fileSystem,
    #[Autowire(service: 'logger.channel.ffmpeg_media')]
    protected readonly ?LoggerInterface $logger = NULL,
  ) {
    parent::__construct($configuration, $plugin_id, $plugin_definition, $ffmpegExecutor);
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  #[\NoDiscard]
  public function process(string $inputPath, string $outputPath, array $options = []): bool {
    $options = $this->mergeOptions($options);

    // Runtime guard: verify whisper-cli is available before starting work.
    $whisperCliPath = $this->resolveWhisperCliPath();
    if ($whisperCliPath === NULL) {
      $this->logger?->error('Whisper CLI binary not found or not executable at process time. Check whisper_cli_path configuration.');
      return FALSE;
    }

    // Create temp WAV path using Drupal's configured temp directory.
    $tempDir = $this->fileSystem->getTempDirectory();
    $tempWav = $tempDir . '/whisper_' . bin2hex(random_bytes(8)) . '.wav';

    try {
      // Step 1: Extract audio to 16kHz mono WAV for whisper-cli.
      $extractCommand = $this->buildAudioExtractCommand($inputPath, $tempWav);
      $extractSuccess = $this->ffmpegExecutor->execute($extractCommand, [
        'timeout' => $options['timeout'] ?? 3600,
      ]);

      if (!$extractSuccess) {
        return FALSE;
      }

      // Step 2: Run whisper-cli on extracted audio.
      $whisperCommand = $this->buildWhisperCliCommand($whisperCliPath, $tempWav, $outputPath, $options);
      $process = new Process($whisperCommand);
      $process->setTimeout($options['timeout'] ?? 3600);
      $process->run();

      return $process->isSuccessful() && file_exists($outputPath);
    }
    catch (ProcessTimedOutException $e) {
      $this->logger?->error('Whisper transcription timed out for @path after @timeout seconds.', [
        '@path' => $inputPath,
        '@timeout' => $e->getProcess()->getTimeout(),
      ]);
      return FALSE;
    }
    catch (\Throwable $e) {
      $this->logger?->error('Whisper transcription failed for @path: @message', [
        '@path' => $inputPath,
        '@message' => $e->getMessage(),
      ]);
      return FALSE;
    }
    finally {
      if (file_exists($tempWav)) {
        @unlink($tempWav);
      }
    }
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  #[\NoDiscard]
  public function getDefaultOptions(): array {
    $config = $this->configFactory->get('ffmpeg_media_whisper.settings');

    return [
      'whisper_cli_path' => $config->get('whisper_cli_path') ?? '',
      'model_dir' => $config->get('model_dir') ?? '/usr/local/share/whisper/models',
      'model' => $config->get('model') ?? 'base',
      'language' => $config->get('language'),
      'translate' => $config->get('translate') ?? FALSE,
      'output_format' => $config->get('output_format') ?? 'srt',
      'word_timestamps' => $config->get('word_timestamps') ?? FALSE,
      'max_context' => $config->get('max_context') ?? 224,
      'beam_size' => $config->get('beam_size') ?? 5,
      'temperature' => $config->get('temperature') ?? 0.0,
      'timeout' => 3600,
    ];
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  #[\NoDiscard]
  public function generateOutputPath(string $inputPath, array $options = []): string {
    $pathInfo = pathinfo($inputPath);
    $baseName = $pathInfo['filename'];
    $directory = $pathInfo['dirname'] ?? dirname($inputPath);
    $format = $options['output_format'] ?? 'srt';

    return $directory . DIRECTORY_SEPARATOR . $baseName . '_subtitles.' . $format;
  }

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

  /**
   * {@inheritdoc}
   */
  #[\Override]
  #[\NoDiscard]
  public function getUnavailableReason(): ?string {
    $cliPath = $this->resolveWhisperCliPath();
    if ($cliPath === NULL) {
      $config = $this->configFactory->get('ffmpeg_media_whisper.settings');
      $configured = $config->get('whisper_cli_path');

      if ($configured !== NULL && $configured !== '') {
        return "whisper-cli binary not found or not executable at configured path: $configured";
      }

      return 'whisper-cli binary not found in system PATH. Install whisper.cpp or set the path in Whisper settings.';
    }

    $config = $this->configFactory->get('ffmpeg_media_whisper.settings');
    $modelDir = $config->get('model_dir') ?? '/usr/local/share/whisper/models';
    $model = $config->get('model') ?? 'base';
    $modelPath = rtrim($modelDir, '/') . '/ggml-' . $model . '.bin';

    if (!is_dir($modelDir)) {
      return "Model directory does not exist: $modelDir";
    }

    if (!file_exists($modelPath)) {
      return "Model file not found: $modelPath — download it from https://huggingface.co/ggerganov/whisper.cpp";
    }

    return NULL;
  }

  /**
   * Resolves the path to the whisper-cli binary.
   *
   * @return string|null
   *   Path to whisper-cli or NULL if not found.
   */
  #[\NoDiscard]
  public function resolveWhisperCliPath(): ?string {
    $config = $this->configFactory->get('ffmpeg_media_whisper.settings');
    $configured = $config->get('whisper_cli_path');

    if ($configured !== NULL && $configured !== '' && is_executable($configured)) {
      return $configured;
    }

    $detected = (new ExecutableFinder())->find('whisper-cli');
    if (is_string($detected) && $detected !== '' && is_executable($detected)) {
      return $detected;
    }

    return NULL;
  }

  /**
   * Builds FFmpeg command to extract audio as 16kHz mono WAV.
   *
   * @param string $inputPath
   *   Input video/audio file path.
   * @param string $wavPath
   *   Output WAV file path.
   *
   * @return array<int, string>
   *   FFmpeg command array.
   */
  protected function buildAudioExtractCommand(string $inputPath, string $wavPath): array {
    return [
      $this->ffmpegExecutor->getFfmpegPath(),
      '-y',
      '-i', $inputPath,
      '-ar', '16000',
      '-ac', '1',
      '-f', 'wav',
      $wavPath,
    ];
  }

  /**
   * Builds the whisper-cli command array.
   *
   * @param string $cliPath
   *   Pre-validated path to the whisper-cli binary.
   * @param string $wavPath
   *   Input WAV file path.
   * @param string $outputPath
   *   Desired output path (e.g. /path/to/subtitles.srt).
   * @param array<string, mixed> $options
   *   Processing options.
   *
   * @return array<int, string>
   *   whisper-cli command as array of arguments.
   */
  protected function buildWhisperCliCommand(string $cliPath, string $wavPath, string $outputPath, array $options): array {

    // Build model file path.
    $modelDir = $options['model_dir'] ?? '/usr/local/share/whisper/models';
    $model = $options['model'] ?? 'base';

    // Validate model identifier to prevent path traversal.
    if (!preg_match('/^[a-z0-9._-]+$/', $model)) {
      throw new \InvalidArgumentException(sprintf('Invalid Whisper model identifier: %s', $model));
    }

    $modelPath = rtrim($modelDir, '/') . '/ggml-' . $model . '.bin';

    if (str_contains($modelPath, '..')) {
      throw new \InvalidArgumentException('Whisper model path must not contain directory traversal sequences.');
    }

    $command = [
      $cliPath,
      '-m', $modelPath,
    ];

    // Language: whisper-cli defaults to English when -l is omitted, so
    // always pass -l explicitly. Use 'auto' for auto-detection.
    // Validated against whisper-cli supported language codes.
    $language = !empty($options['language']) ? (string) $options['language'] : 'auto';
    $command[] = '-l';
    $command[] = $this->validateLanguageCode($language);

    // Translate to English.
    if (!empty($options['translate'])) {
      $command[] = '--translate';
    }

    // Beam size.
    if (isset($options['beam_size']) && (int) $options['beam_size'] > 0) {
      $command[] = '--beam-size';
      $command[] = (string) (int) $options['beam_size'];
    }

    // Max context.
    if (isset($options['max_context'])) {
      $command[] = '--max-context';
      $command[] = (string) (int) $options['max_context'];
    }

    // Temperature.
    if (isset($options['temperature'])) {
      $command[] = '--temperature';
      $command[] = (string) (float) $options['temperature'];
    }

    // Word timestamps.
    if (!empty($options['word_timestamps'])) {
      $command[] = '--max-len';
      $command[] = '1';
    }

    // Output format flag.
    $format = $options['output_format'] ?? 'srt';
    $command[] = match ($format) {
      'vtt' => '-ovtt',
      default => '-osrt',
    };

    // Output file prefix (whisper-cli appends .srt/.vtt).
    $outputPrefix = preg_replace('/\.(srt|vtt)$/', '', $outputPath) ?? $outputPath;
    $command[] = '-of';
    $command[] = $outputPrefix;

    // Input WAV file.
    $command[] = $wavPath;

    return $command;
  }

  /**
   * Gets available Whisper model sizes.
   *
   * @return array<string, \Drupal\Core\StringTranslation\TranslatableMarkup>
   *   Model options keyed by model ID.
   */
  #[\NoDiscard]
  public static function getAvailableModels(): array {
    return [
      'tiny' => new TranslatableMarkup('Tiny (~39M parameters)'),
      'tiny.en' => new TranslatableMarkup('Tiny English-only'),
      'base' => new TranslatableMarkup('Base (~74M parameters)'),
      'base.en' => new TranslatableMarkup('Base English-only'),
      'small' => new TranslatableMarkup('Small (~244M parameters)'),
      'small.en' => new TranslatableMarkup('Small English-only'),
      'medium' => new TranslatableMarkup('Medium (~769M parameters)'),
      'medium.en' => new TranslatableMarkup('Medium English-only'),
      'large' => new TranslatableMarkup('Large (~1.5B parameters)'),
      'large-v2' => new TranslatableMarkup('Large v2'),
      'large-v3' => new TranslatableMarkup('Large v3 (best quality)'),
    ];
  }

  /**
   * Gets available subtitle output formats.
   *
   * @return array<string, \Drupal\Core\StringTranslation\TranslatableMarkup>
   *   Format options keyed by format extension.
   */
  #[\NoDiscard]
  public static function getAvailableFormats(): array {
    return [
      'srt' => new TranslatableMarkup('SubRip (.srt)'),
      'vtt' => new TranslatableMarkup('WebVTT (.vtt)'),
    ];
  }

  /**
   * Validates a Whisper language code against supported values.
   *
   * Falls back to 'auto' for unrecognised codes as defence-in-depth.
   * The command is already executed via array-based Process invocation
   * (no shell injection), but restricting input to known values prevents
   * unexpected whisper-cli behaviour.
   *
   * @param string $code
   *   Language code to validate.
   *
   * @return string
   *   Validated language code or 'auto'.
   */
  protected function validateLanguageCode(string $code): string {
    // Whisper-supported language codes (ISO 639-1 + special 'auto').
    // Source: whisper.cpp/whisper.h whisper_lang_str().
    $allowed = [
      'auto',
      'af', 'am', 'ar', 'as', 'az', 'ba', 'be', 'bg', 'bn', 'bo',
      'br', 'bs', 'ca', 'cs', 'cy', 'da', 'de', 'el', 'en', 'es',
      'et', 'eu', 'fa', 'fi', 'fo', 'fr', 'gl', 'gu', 'ha', 'haw',
      'he', 'hi', 'hr', 'ht', 'hu', 'hy', 'id', 'is', 'it', 'ja',
      'jw', 'ka', 'kk', 'km', 'kn', 'ko', 'la', 'lb', 'ln', 'lo',
      'lt', 'lv', 'mg', 'mi', 'mk', 'ml', 'mn', 'mr', 'ms', 'mt',
      'my', 'ne', 'nl', 'nn', 'no', 'oc', 'pa', 'pl', 'ps', 'pt',
      'ro', 'ru', 'sa', 'sd', 'si', 'sk', 'sl', 'sn', 'so', 'sq',
      'sr', 'su', 'sv', 'sw', 'ta', 'te', 'tg', 'th', 'tk', 'tl',
      'tr', 'tt', 'uk', 'ur', 'uz', 'vi', 'yi', 'yo', 'zh',
    ];

    return in_array($code, $allowed, TRUE) ? $code : 'auto';
  }

}
