<?php

declare(strict_types=1);

namespace Drupal\ffmpeg_media\Plugin\FFmpegProcessor;

use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\ffmpeg_media\Attribute\FFmpegProcessor;
use Drupal\ffmpeg_media\Service\FFmpegExecutorInterface;
use Drupal\ffmpeg_media\ValueObject\AudioProfile;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Process\Process;

/**
 * Processor for audio normalization using EBU R128 loudnorm.
 *
 * This processor normalizes audio levels to industry-standard loudness targets:
 * - Streaming platforms (Spotify, YouTube): -14 LUFS
 * - Broadcast (EBU R128): -23 LUFS
 * - Podcast/speech: -16 LUFS.
 *
 * The processor uses FFmpeg's loudnorm filter which performs two-pass analysis
 * for accurate normalization, ensuring consistent playback volume across
 * content.
 */
#[FFmpegProcessor(
  id: 'audio_normalize',
  label: new TranslatableMarkup('Audio Normalization'),
  description: new TranslatableMarkup(
    'Normalizes audio levels using EBU R128 loudnorm.',
  ),
  outputType: 'audio',
  outputExtension: 'aac',
  requiredFilters: ['loudnorm'],
  weight: -10,
)]
final class AudioNormalizeProcessor extends FFmpegProcessorBase {

  /**
   * The config factory.
   */
  protected readonly ConfigFactoryInterface $configFactory;

  /**
   * Constructs an AudioNormalizeProcessor object.
   *
   * @param array<string, mixed> $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 service.
   */
  public function __construct(
    array $configuration,
    $plugin_id,
    $plugin_definition,
    #[Autowire(service: FFmpegExecutorInterface::class)]
    FFmpegExecutorInterface $ffmpegExecutor,
    ConfigFactoryInterface $configFactory,
  ) {
    parent::__construct($configuration, $plugin_id, $plugin_definition, $ffmpegExecutor);
    $this->configFactory = $configFactory;
  }

  /**
   * {@inheritdoc}
   *
   * @return array<string, bool|int|float|string>
   *   Default option values.
   */
  #[\Override]
  #[\NoDiscard]
  public function getDefaultOptions(): array {
    $config = $this->configFactory->get('ffmpeg_media.settings');

    return [
      'mode' => $config->get('audio.normalize.mode') ?? AudioProfile::MODE_LOUDNORM,
      'target_lufs' => $config->get('audio.normalize.target_lufs') ?? -14.0,
      'true_peak' => $config->get('audio.normalize.true_peak') ?? -1.0,
      'loudness_range' => $config->get('audio.normalize.lra') ?? 11.0,
      'dual_mono' => FALSE,
      'two_pass' => TRUE,
      'sample_rate' => $config->get('audio.format.sample_rate') ?? 48000,
      'channels' => $config->get('audio.format.channels') ?? 2,
      'codec' => $config->get('audio.format.codec') ?? 'libopus',
      'bitrate' => $config->get('audio.format.bitrate') ?? '128k',
    ];
  }

  /**
   * {@inheritdoc}
   *
   * @param string $inputPath
   *   Input file path.
   * @param string $outputPath
   *   Output file path.
   * @param array<string, mixed> $options
   *   Processing options.
   */
  #[\Override]
  #[\NoDiscard]
  public function process(string $inputPath, string $outputPath, array $options = []): bool {
    $options = $this->mergeOptions($options);

    // Create AudioProfile from options.
    $profile = $this->createAudioProfile($options);

    // If two-pass is enabled, do analysis first.
    if ($options['two_pass'] ?? TRUE) {
      return $this->processTwoPass($inputPath, $outputPath, $profile, $options);
    }

    return $this->processSinglePass($inputPath, $outputPath, $profile, $options);
  }

  /**
   * Performs single-pass normalization.
   *
   * @param string $inputPath
   *   Input file path.
   * @param string $outputPath
   *   Output file path.
   * @param \Drupal\ffmpeg_media\ValueObject\AudioProfile $profile
   *   Audio profile settings.
   * @param array<string, mixed> $options
   *   Processing options.
   *
   * @return bool
   *   TRUE if successful.
   */
  protected function processSinglePass(
    string $inputPath,
    string $outputPath,
    AudioProfile $profile,
    array $options,
  ): bool {
    $args = $this->buildSinglePassArguments($profile);

    $command = $this->buildCommand($inputPath, $outputPath, $args);

    return $this->ffmpegExecutor->execute($command);
  }

  /**
   * Performs two-pass normalization for accurate loudness targeting.
   *
   * First pass analyzes the audio to measure actual loudness levels.
   * Second pass applies normalization using measured values for accuracy.
   *
   * @param string $inputPath
   *   Input file path.
   * @param string $outputPath
   *   Output file path.
   * @param \Drupal\ffmpeg_media\ValueObject\AudioProfile $profile
   *   Audio profile settings.
   * @param array<string, mixed> $options
   *   Processing options.
   *
   * @return bool
   *   TRUE if successful.
   */
  protected function processTwoPass(
    string $inputPath,
    string $outputPath,
    AudioProfile $profile,
    array $options,
  ): bool {
    // First pass: analyze.
    $analysis = $this->analyzeAudio($inputPath, $profile);

    if ($analysis === NULL) {
      // Fallback to single pass if analysis fails.
      return $this->processSinglePass($inputPath, $outputPath, $profile, $options);
    }

    // Second pass: apply with measured values.
    $args = $this->buildSecondPassArguments($profile, $analysis);
    $command = $this->buildCommand($inputPath, $outputPath, $args);

    return $this->ffmpegExecutor->execute($command);
  }

  /**
   * Analyzes audio to get loudness measurements.
   *
   * @param string $inputPath
   *   Input file path.
   * @param \Drupal\ffmpeg_media\ValueObject\AudioProfile $profile
   *   Audio profile for target values.
   *
   * @return array<string, float>|null
   *   Measured values or NULL if analysis fails.
   */
  protected function analyzeAudio(string $inputPath, AudioProfile $profile): ?array {
    $filter = sprintf(
      'loudnorm=I=%.1f:TP=%.1f:LRA=%.1f:print_format=json%s',
      $profile->targetLufs,
      $profile->truePeak,
      $profile->loudnessRange,
      $profile->dualMono ? ':dual_mono=true' : '',
    );

    $command = [
      $this->ffmpegExecutor->getFfmpegPath(),
      '-i', $inputPath,
      '-af', $filter,
      '-f', 'null',
      '-',
    ];

    $process = new Process($command);
    $process->setTimeout(120);
    $process->run();

    if (!$process->isSuccessful()) {
      return NULL;
    }

    $output = $process->getErrorOutput() ?: $process->getOutput();
    if ($output === '') {
      return NULL;
    }

    return $this->parseLoudnormOutput($output);
  }

  /**
   * Parses loudnorm JSON output from FFmpeg.
   *
   * @param string $output
   *   FFmpeg output containing JSON.
   *
   * @return array<string, float>|null
   *   Parsed measurements or NULL.
   */
  protected function parseLoudnormOutput(string $output): ?array {
    // FFmpeg loudnorm outputs a JSON block with measurement keys.
    // Extract by finding the last '{' ... '}' block that contains the
    // expected 'input_i' key. Using a balanced-braces pattern avoids
    // breakage from nested objects in future FFmpeg versions.
    if (preg_match('/\{[^{}]*"input_i"[^{}]*\}/s', $output, $matches)) {
      $json = json_decode($matches[0], TRUE);

      if (json_last_error() === JSON_ERROR_NONE && is_array($json) && isset($json['input_i'])) {
        return [
          'input_i' => (float) ($json['input_i'] ?? -24.0),
          'input_tp' => (float) ($json['input_tp'] ?? -2.0),
          'input_lra' => (float) ($json['input_lra'] ?? 7.0),
          'input_thresh' => (float) ($json['input_thresh'] ?? -34.0),
          'target_offset' => (float) ($json['target_offset'] ?? 0.0),
        ];
      }
    }

    return NULL;
  }

  /**
   * Builds single-pass FFmpeg arguments.
   *
   * @param \Drupal\ffmpeg_media\ValueObject\AudioProfile $profile
   *   Audio profile.
   *
   * @return array<int, string>
   *   FFmpeg arguments.
   */
  protected function buildSinglePassArguments(AudioProfile $profile): array {
    return $profile->buildArguments();
  }

  /**
   * Builds second-pass FFmpeg arguments with measured values.
   *
   * @param \Drupal\ffmpeg_media\ValueObject\AudioProfile $profile
   *   Audio profile.
   * @param array<string, float> $analysis
   *   Measured loudness values.
   *
   * @return array<int, string>
   *   FFmpeg arguments.
   */
  protected function buildSecondPassArguments(AudioProfile $profile, array $analysis): array {
    $args = [
      '-ar', (string) $profile->sampleRate,
      '-ac', (string) $profile->channels,
      '-c:a', $profile->codec,
      '-b:a', $profile->bitrate,
    ];

    // Add codec profile for AAC.
    if ($profile->profile !== NULL && $profile->codec === 'aac') {
      $args[] = '-profile:a';
      $args[] = $profile->profile;
    }

    // Build loudnorm filter with measured values for linear normalization.
    $filter = sprintf(
      'loudnorm=I=%.1f:TP=%.1f:LRA=%.1f:measured_I=%.2f:measured_TP=%.2f:measured_LRA=%.2f:measured_thresh=%.2f:offset=%.2f:linear=true%s',
      $profile->targetLufs,
      $profile->truePeak,
      $profile->loudnessRange,
      $analysis['input_i'],
      $analysis['input_tp'],
      $analysis['input_lra'],
      $analysis['input_thresh'],
      $analysis['target_offset'],
      $profile->dualMono ? ':dual_mono=true' : '',
    );

    $args[] = '-af';
    $args[] = $filter;

    return $args;
  }

  /**
   * Creates an AudioProfile from options array.
   *
   * @param array<string, mixed> $options
   *   Processing options.
   *
   * @return \Drupal\ffmpeg_media\ValueObject\AudioProfile
   *   Audio profile.
   */
  protected function createAudioProfile(array $options): AudioProfile {
    return new AudioProfile(
      sampleRate: (int) ($options['sample_rate'] ?? 48000),
      channels: (int) ($options['channels'] ?? 2),
      codec: (string) ($options['codec'] ?? 'libopus'),
      bitrate: (string) ($options['bitrate'] ?? '128k'),
      profile: $options['profile'] ?? 'aac_low',
      normalizeEnabled: TRUE,
      normalizeMode: (string) ($options['mode'] ?? AudioProfile::MODE_LOUDNORM),
      targetLufs: (float) ($options['target_lufs'] ?? -14.0),
      truePeak: (float) ($options['true_peak'] ?? -1.0),
      loudnessRange: (float) ($options['loudness_range'] ?? 11.0),
      dualMono: (bool) ($options['dual_mono'] ?? FALSE),
    );
  }

  /**
   * {@inheritdoc}
   *
   * @param string $inputPath
   *   Input file path.
   * @param array<string, mixed> $options
   *   Processing options.
   */
  #[\Override]
  #[\NoDiscard]
  public function generateOutputPath(string $inputPath, array $options = []): string {
    $pathInfo = pathinfo($inputPath);
    $baseName = $pathInfo['filename'];
    $directory = $pathInfo['dirname'] ?? '';
    $codec = $options['codec'] ?? 'libopus';

    $extension = match ($codec) {
      'libopus' => 'opus',
      'libvorbis' => 'ogg',
      'flac' => 'flac',
      'mp3', 'libmp3lame' => 'mp3',
      default => 'libopus',
    };

    return $directory . DIRECTORY_SEPARATOR . $baseName . '_normalized.' . $extension;
  }

  /**
   * Generates loudness report for a file.
   *
   * @param string $inputPath
   *   Input file path.
   * @param array<string, mixed> $options
   *   Options (primarily for dual_mono setting).
   *
   * @return array<string, bool|float>|null
   *   Loudness measurements or NULL if analysis fails.
   */
  #[\NoDiscard]
  public function generateLoudnessReport(string $inputPath, array $options = []): ?array {
    $profile = new AudioProfile(
      normalizeEnabled: TRUE,
      dualMono: (bool) ($options['dual_mono'] ?? FALSE),
    );

    $analysis = $this->analyzeAudio($inputPath, $profile);

    if ($analysis === NULL) {
      return NULL;
    }

    return [
      'integrated_loudness' => $analysis['input_i'],
      'true_peak' => $analysis['input_tp'],
      'loudness_range' => $analysis['input_lra'],
      'threshold' => $analysis['input_thresh'],
      'target_offset' => $analysis['target_offset'],
      'target_lufs' => $profile->targetLufs,
      'compliant' => $this->isCompliant($analysis, $profile),
    ];
  }

  /**
   * Checks if audio is compliant with target loudness.
   *
   * @param array<string, float> $analysis
   *   Measured values.
   * @param \Drupal\ffmpeg_media\ValueObject\AudioProfile $profile
   *   Target profile.
   *
   * @return bool
   *   TRUE if audio is within acceptable range.
   */
  protected function isCompliant(array $analysis, AudioProfile $profile): bool {
    $lufsTolerance = 1.0;
    $peakTolerance = 0.0;

    $lufsOk = abs($analysis['input_i'] - $profile->targetLufs) <= $lufsTolerance;
    $peakOk = $analysis['input_tp'] <= $profile->truePeak + $peakTolerance;

    return $lufsOk && $peakOk;
  }

}
