<?php

declare(strict_types=1);

namespace Drupal\ffmpeg_media_streaming\Plugin\FFmpegOutput;

use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\ffmpeg_media\Attribute\FFmpegOutput;
use Drupal\ffmpeg_media\ValueObject\ConversionOptions;
use Drupal\ffmpeg_media\ValueObject\GopSettings;
use Drupal\ffmpeg_media\ValueObject\HlsOptions;
use Drupal\ffmpeg_media\ValueObject\VideoInfo;

/**
 * HLS (HTTP Live Streaming) output format plugin.
 */
#[FFmpegOutput(
  id: 'hls',
  label: new TranslatableMarkup('HLS (HTTP Live Streaming)'),
  description: new TranslatableMarkup('Apple HTTP Live Streaming for adaptive bitrate delivery.'),
  extension: 'm3u8',
  mimeType: 'application/vnd.apple.mpegurl',
  supportedCodecs: ['h264', 'hevc', 'av1', 'vp9'],
  defaultCodec: 'h264',
  supportedAudioCodecs: ['aac', 'libopus'],
  weight: 30,
)]
final class HlsOutput extends AdaptiveBitrateOutputBase {

  /**
   * {@inheritdoc}
   */
  #[\Override]
  #[\NoDiscard]
  public function buildArguments(ConversionOptions $options): array {
    $hlsOptions = $this->getHlsOptions();

    $args = [
      '-f', 'hls',
      '-hls_time', (string) $hlsOptions->segmentDuration,
      '-hls_list_size', (string) $hlsOptions->listSize,
    ];

    if ($hlsOptions->playlistType !== '') {
      $args[] = '-hls_playlist_type';
      $args[] = $hlsOptions->playlistType;
    }

    // LL-HLS / CMAF options.
    if ($hlsOptions->lowLatencyEnabled) {
      $args[] = '-hls_flags';
      $flags = ['low_latency'];
      if ($hlsOptions->independentSegments) {
        $flags[] = 'independent_segments';
      }
      $args[] = implode('+', $flags);

      if ($hlsOptions->partDuration > 0) {
        $args[] = '-hls_part_size';
        $args[] = (string) $hlsOptions->partDuration;
      }

      // Use fmp4 for LL-HLS.
      $args[] = '-hls_segment_type';
      $args[] = 'fmp4';
    }
    elseif ($hlsOptions->segmentType === 'fmp4') {
      $args[] = '-hls_segment_type';
      $args[] = 'fmp4';
    }

    // Add GOP settings for consistent keyframe alignment.
    if ($options->gopSettings !== NULL) {
      $args = array_merge($args, $options->gopSettings->buildArguments('h264'));
    }
    else {
      // Default GOP for ABR: keyframe every segment.
      $gopSettings = GopSettings::forAbrStreaming((float) $hlsOptions->segmentDuration);
      $args = array_merge($args, $gopSettings->buildArguments('h264'));
    }

    $args[] = '-hls_segment_filename';
    $args[] = $hlsOptions->segmentFilename;
    $args[] = '-master_pl_name';
    $args[] = $this->getMasterPlaylistName();

    return $args;
  }

  /**
   * Gets HLS options from configuration.
   *
   * @return \Drupal\ffmpeg_media\ValueObject\HlsOptions
   *   HLS options.
   */
  protected function getHlsOptions(): HlsOptions {
    $config = $this->configFactory->get('ffmpeg_media_streaming.settings');

    $llHlsEnabled = (bool) ($config->get('hls_low_latency') ?? FALSE);

    if ($llHlsEnabled) {
      return HlsOptions::forLowLatency(
        partDuration: (float) ($config->get('hls_part_duration') ?? 0.2),
      );
    }

    return new HlsOptions(
      segmentDuration: (int) ($config->get('hls_segment_duration') ?? 6),
      playlistType: (string) ($config->get('hls_playlist_type') ?? 'vod'),
      listSize: 0,
      segmentType: (string) ($config->get('hls_segment_type') ?? 'mpegts'),
      segmentFilename: (string) ($config->get('hls_segment_pattern') ?? '%v/segment_%03d.ts'),
      independentSegments: TRUE,
    );
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  #[\NoDiscard]
  public function generateOutputPath(string $inputPath): string {
    $masterPlaylist = $this->getMasterPlaylistName();

    // If custom output directory is set via options, use it.
    if (isset($this->configuration['output_directory'])) {
      $directory = $this->configuration['output_directory'];
      return $directory . DIRECTORY_SEPARATOR . $masterPlaylist;
    }

    // Default behavior - use input path directory.
    $pathInfo = pathinfo($inputPath);
    $baseName = $pathInfo['filename'];
    $directory = $pathInfo['dirname'] ?? dirname($inputPath);

    // HLS outputs to a directory structure.
    return $directory . DIRECTORY_SEPARATOR . $baseName . '_hls' . DIRECTORY_SEPARATOR . $masterPlaylist;
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  #[\NoDiscard]
  public function buildAdaptiveArguments(
    ConversionOptions $options,
    ?VideoInfo $videoInfo = NULL,
    array $audioTracks = [],
  ): array {
    $hlsOptions = $this->getHlsOptions();

    // Get representations - use per-title encoding if available.
    $representations = $this->getRepresentations($videoInfo);

    $args = ['-f', 'hls'];

    // Build filter complex for multiple outputs.
    $filterParts = [];
    foreach ($representations as $index => $rep) {
      $filterParts[] = sprintf(
        '[0:v]scale=%d:%d[v%d]',
        $rep['width'],
        $rep['height'],
        $index,
      );
    }

    $args[] = '-filter_complex';
    $args[] = implode(';', $filterParts);

    // Use actual frame rate for GOP calculation.
    $frameRate = ($videoInfo !== NULL && $videoInfo->frameRate > 0) ? $videoInfo->frameRate : 30.0;
    $gopSize = $hlsOptions->getRecommendedGopSize($frameRate);

    // Add stream mappings and encoding settings for each representation.
    if (!empty($audioTracks)) {
      // Multi-audio: map video streams without paired audio, then map
      // each audio track separately so var_stream_map indices are correct.
      foreach ($representations as $index => $rep) {
        $args[] = '-map';
        $args[] = "[v$index]";

        // Video bitrate with rate control (VBV for ABR).
        $videoBitrate = $rep['bitrate'] ?? '2500k';
        $args[] = "-b:v:$index";
        $args[] = $videoBitrate;

        // Add maxrate and bufsize for consistent ABR switching.
        $bitrateKbps = (int) filter_var($videoBitrate, FILTER_SANITIZE_NUMBER_INT);
        $args[] = "-maxrate:v:$index";
        $args[] = (int) ($bitrateKbps * 1.5) . 'k';
        $args[] = "-bufsize:v:$index";
        $args[] = ($bitrateKbps * 2) . 'k';

        // GOP settings per stream for ABR alignment.
        $args[] = "-g:v:$index";
        $args[] = (string) $gopSize;
        $args[] = "-keyint_min:v:$index";
        $args[] = (string) $gopSize;
        $args[] = "-sc_threshold:v:$index";
        $args[] = '0';
      }

      // Map each audio track as a separate stream.
      foreach ($audioTracks as $trackIndex => $track) {
        $args[] = '-map';
        $args[] = sprintf('0:a:%d', $track->streamIndex);
        $args[] = sprintf('-b:a:%d', $trackIndex);
        $args[] = '128k';
      }
    }
    else {
      // Single audio: pair each video representation with the default audio.
      foreach ($representations as $index => $rep) {
        $args[] = '-map';
        $args[] = "[v$index]";

        // Only map audio if source has audio streams.
        if ($videoInfo === NULL || $videoInfo->hasAudio) {
          $args[] = '-map';
          $args[] = '0:a';
        }

        // Video bitrate with rate control (VBV for ABR).
        $videoBitrate = $rep['bitrate'] ?? '2500k';
        $args[] = "-b:v:$index";
        $args[] = $videoBitrate;

        // Add maxrate and bufsize for consistent ABR switching.
        $bitrateKbps = (int) filter_var($videoBitrate, FILTER_SANITIZE_NUMBER_INT);
        $args[] = "-maxrate:v:$index";
        $args[] = (int) ($bitrateKbps * 1.5) . 'k';
        $args[] = "-bufsize:v:$index";
        $args[] = ($bitrateKbps * 2) . 'k';

        // Audio bitrate.
        $args[] = "-b:a:$index";
        $args[] = $rep['audio_bitrate'] ?? '128k';

        // GOP settings per stream for ABR alignment.
        $args[] = "-g:v:$index";
        $args[] = (string) $gopSize;
        $args[] = "-keyint_min:v:$index";
        $args[] = (string) $gopSize;
        $args[] = "-sc_threshold:v:$index";
        $args[] = '0';
      }
    }

    // Force keyframes at segment boundaries.
    $args[] = '-force_key_frames';
    $args[] = sprintf('expr:gte(t,n_forced*%d)', $hlsOptions->segmentDuration);

    // HLS muxer options.
    $args[] = '-hls_time';
    $args[] = (string) $hlsOptions->segmentDuration;
    $args[] = '-hls_list_size';
    $args[] = (string) $hlsOptions->listSize;

    if ($hlsOptions->playlistType !== '') {
      $args[] = '-hls_playlist_type';
      $args[] = $hlsOptions->playlistType;
    }

    // LL-HLS options.
    if ($hlsOptions->lowLatencyEnabled) {
      $args[] = '-hls_flags';
      $args[] = 'low_latency+independent_segments';
      $args[] = '-hls_part_size';
      $args[] = (string) $hlsOptions->partDuration;
      $args[] = '-hls_segment_type';
      $args[] = 'fmp4';
    }

    $args[] = '-hls_segment_filename';
    $args[] = $hlsOptions->segmentFilename;
    $args[] = '-master_pl_name';
    $args[] = $this->getMasterPlaylistName();

    // Build var_stream_map with optional audio tracks.
    $varStreamMap = $this->buildVarStreamMap($representations, $audioTracks);
    $args[] = '-var_stream_map';
    $args[] = $varStreamMap;

    $args[] = '%v/stream.m3u8';

    return $args;
  }

  /**
   * Builds var_stream_map for HLS with optional audio tracks.
   *
   * @param array<int, array{name: string, width: int, height: int, bitrate: string, audio_bitrate: string}> $representations
   *   Video representations.
   * @param array<\Drupal\ffmpeg_media\ValueObject\AudioTrack> $audioTracks
   *   Audio tracks.
   *
   * @return string
   *   var_stream_map value.
   */
  protected function buildVarStreamMap(array $representations, array $audioTracks): string {
    $variants = [];

    if (empty($audioTracks)) {
      // Simple mapping without multi-audio.
      foreach ($representations as $index => $rep) {
        $name = $rep['name'] ?? (string) $index;
        $variants[] = sprintf('v:%d,a:%d,name:%s', $index, $index, $name);
      }
    }
    else {
      // Multi-audio: separate audio group.
      foreach ($representations as $index => $rep) {
        $name = $rep['name'] ?? (string) $index;
        $variants[] = sprintf('v:%d,agroup:audio,name:%s', $index, $name);
      }

      // Add audio-only variants for each language.
      // Audio stream index references the Nth audio output stream (0-based),
      // not offset by the number of video representations.
      foreach ($audioTracks as $trackIndex => $track) {
        $variants[] = sprintf(
          'a:%d,agroup:audio,language:%s,name:%s,default:%s',
          $trackIndex,
          $track->language,
          $track->label,
          $track->isDefault ? 'yes' : 'no',
        );
      }
    }

    return implode(' ', $variants);
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  #[\NoDiscard]
  public function buildAdaptiveArgumentsWithSubtitles(
    ConversionOptions $options,
    array $subtitleTracks,
  ): array {
    // Validate that all subtitle files exist before building arguments.
    foreach ($subtitleTracks as $track) {
      if (!file_exists($track->path)) {
        throw new \InvalidArgumentException(sprintf('Subtitle file not found: %s', $track->path));
      }
    }

    $hlsOptions = $this->getHlsOptions();
    $representations = $this->getRepresentations(NULL);

    $args = ['-f', 'hls'];

    // Build filter complex for multiple video outputs.
    $filterParts = [];
    foreach ($representations as $index => $rep) {
      $filterParts[] = sprintf(
        '[0:v]scale=%d:%d[v%d]',
        $rep['width'],
        $rep['height'],
        $index,
      );
    }

    $args[] = '-filter_complex';
    $args[] = implode(';', $filterParts);

    // Map video and audio streams for each representation.
    foreach ($representations as $index => $rep) {
      $args[] = '-map';
      $args[] = "[v$index]";

      // Only map audio if source has audio streams.
      // Note: buildAdaptiveArgumentsWithSubtitles does not receive VideoInfo,
      // so we always map audio here (caller should ensure audio exists).
      $args[] = '-map';
      $args[] = '0:a';

      $args[] = "-b:v:$index";
      $args[] = $rep['bitrate'] ?? '2500k';
      $args[] = "-b:a:$index";
      $args[] = $rep['audio_bitrate'] ?? '128k';
    }

    // Map subtitle streams (inputs 1, 2, 3, etc. for each subtitle file).
    // Caller must add -i arguments for subtitle files after main video.
    $subtitleCount = count($subtitleTracks);
    for ($i = 0; $i < $subtitleCount; $i++) {
      $args[] = '-map';
      // Subtitle input index starts at 1 (after video at 0).
      $args[] = sprintf('%d:s', $i + 1);
    }

    // Set subtitle codec to WebVTT for HLS compatibility.
    if ($subtitleCount > 0) {
      $args[] = '-c:s';
      $args[] = 'webvtt';
    }

    // HLS settings from value object.
    $args[] = '-hls_time';
    $args[] = (string) $hlsOptions->segmentDuration;
    $args[] = '-hls_list_size';
    $args[] = (string) $hlsOptions->listSize;

    if ($hlsOptions->playlistType !== '') {
      $args[] = '-hls_playlist_type';
      $args[] = $hlsOptions->playlistType;
    }

    // LL-HLS options for subtitle streams.
    if ($hlsOptions->lowLatencyEnabled) {
      $args[] = '-hls_flags';
      $args[] = 'low_latency+independent_segments';
      $args[] = '-hls_part_size';
      $args[] = (string) $hlsOptions->partDuration;
      $args[] = '-hls_segment_type';
      $args[] = 'fmp4';
    }

    $args[] = '-hls_segment_filename';
    $args[] = $hlsOptions->segmentFilename;
    $args[] = '-master_pl_name';
    $args[] = $this->getMasterPlaylistName();

    // Build var_stream_map with subtitles.
    $varStreamMap = $this->buildVarStreamMapWithSubtitles($representations, $subtitleTracks);
    $args[] = '-var_stream_map';
    $args[] = $varStreamMap;

    // Output pattern.
    $args[] = '%v/stream.m3u8';

    return $args;
  }

  /**
   * Gets the master playlist filename from configuration.
   *
   * @return string
   *   Master playlist filename.
   */
  #[\NoDiscard]
  protected function getMasterPlaylistName(): string {
    $config = $this->configFactory->get('ffmpeg_media_streaming.settings');
    return (string) ($config->get('hls_master_playlist') ?? 'master.m3u8');
  }

  /**
   * Builds the var_stream_map string including subtitle tracks.
   *
   * @param array<int, array{name: string, width: int, height: int, bitrate: string, audio_bitrate: string}> $representations
   *   Video representations configuration.
   * @param array<\Drupal\ffmpeg_media\ValueObject\SubtitleTrack> $subtitleTracks
   *   Subtitle tracks.
   *
   * @return string
   *   The var_stream_map value for FFmpeg.
   */
  protected function buildVarStreamMapWithSubtitles(array $representations, array $subtitleTracks): string {
    $subCount = count($subtitleTracks);
    $variants = [];

    // For each representation, create a variant entry.
    // Each variant maps to one video stream and one audio stream.
    // Subtitles are mapped per-variant based on the subtitle group.
    foreach ($representations as $index => $rep) {
      $name = $rep['name'] ?? (string) $index;

      if ($subCount > 0) {
        // Include subtitle group reference.
        $variants[] = sprintf('v:%d,a:%d,s:%d,sgroup:subs,name:%s', $index, $index, 0, $name);
      }
      else {
        $variants[] = sprintf('v:%d,a:%d,name:%s', $index, $index, $name);
      }
    }

    // Add subtitle-only variants for each language.
    foreach ($subtitleTracks as $subIndex => $track) {
      $variants[] = sprintf(
        's:%d,sgroup:subs,language:%s,name:%s,default:%s',
        $subIndex,
        $track->language,
        $track->label,
        $track->isDefault ? 'yes' : 'no',
      );
    }

    return implode(' ', $variants);
  }

}
