<?php

declare(strict_types=1);

namespace Drupal\ffmpeg_media\ValueObject;

/**
 * Immutable value object for video conversion options.
 *
 * Uses PHP 8.5 clone() with associative array for immutable transformations.
 */
final readonly class ConversionOptions {

  /**
   * Constructs a ConversionOptions object.
   *
   * @param int $crf
   *   Constant Rate Factor (quality). Lower = better quality. Range: 0-63.
   * @param int $preset
   *   Encoding speed preset. Range: 0-9 (0=slowest/best, 9=fastest).
   * @param int $threads
   *   Number of threads to use for encoding.
   * @param string $audioBitrate
   *   Audio bitrate (e.g., '128k', '192k').
   * @param string $audioCodec
   *   Audio codec to use (e.g., 'aac', 'libopus').
   * @param int|null $audioSampleRate
   *   Audio sample rate in Hz (e.g., 44100, 48000). NULL = use source rate.
   * @param int|null $audioChannels
   *   Number of audio channels (1-8). NULL = use source channels.
   * @param string $pixelFormat
   *   Pixel format (e.g., 'yuv420p', 'yuv420p10le').
   * @param int|null $timeout
   *   Timeout in seconds for the conversion process.
   * @param int|null $mediaId
   *   Associated media entity ID.
   * @param int|null $maxWidth
   *   Maximum output width (null = no limit).
   * @param int|null $maxHeight
   *   Maximum output height (null = no limit).
   * @param bool $twoPass
   *   Whether to use two-pass encoding.
   * @param array<int, string> $extraArgs
   *   Additional FFmpeg arguments.
   * @param GopSettings|null $gopSettings
   *   GOP/keyframe settings for ABR streaming. NULL = use defaults.
   * @param RateControlSettings|null $rateControl
   *   Rate control settings (VBV/CBR/VBR). NULL = use CRF mode.
   * @param bool $alphaEnabled
   *   Whether HEVC alpha layer encoding is explicitly enabled.
   * @param string $fieldName
   *   The entity field name this conversion targets.
   *
   * @throws \InvalidArgumentException
   *   If any parameter is out of valid range.
   */
  public function __construct(
    public int $crf = 31,
    public int $preset = 6,
    public int $threads = 2,
    public string $audioBitrate = '128k',
    public string $audioCodec = 'libopus',
    public ?int $audioSampleRate = NULL,
    public ?int $audioChannels = NULL,
    public string $pixelFormat = 'yuv420p',
    public ?int $timeout = NULL,
    public ?int $mediaId = NULL,
    public ?int $maxWidth = NULL,
    public ?int $maxHeight = NULL,
    public bool $twoPass = FALSE,
    public array $extraArgs = [],
    public ?GopSettings $gopSettings = NULL,
    public ?RateControlSettings $rateControl = NULL,
    public bool $alphaEnabled = FALSE,
    public string $fieldName = '',
  ) {
    if ($crf < 0 || $crf > 63) {
      throw new \InvalidArgumentException('CRF must be between 0 and 63.');
    }
    if ($preset < 0 || $preset > 9) {
      throw new \InvalidArgumentException('Preset must be between 0 and 9.');
    }
    if ($threads < 0) {
      throw new \InvalidArgumentException('Threads must be 0 (auto) or positive.');
    }
    if ($timeout !== NULL && $timeout < 1) {
      throw new \InvalidArgumentException('Timeout must be positive.');
    }
    if ($maxWidth !== NULL && $maxWidth < 1) {
      throw new \InvalidArgumentException('Max width must be positive.');
    }
    if ($maxHeight !== NULL && $maxHeight < 1) {
      throw new \InvalidArgumentException('Max height must be positive.');
    }
    if ($audioSampleRate !== NULL && $audioSampleRate < 1) {
      throw new \InvalidArgumentException('Audio sample rate must be positive.');
    }
    if ($audioChannels !== NULL && ($audioChannels < 1 || $audioChannels > 8)) {
      throw new \InvalidArgumentException('Audio channels must be between 1 and 8.');
    }
  }

  /**
   * Creates ConversionOptions from an array.
   *
   * @param array<string, mixed> $data
   *   Associative array of options.
   *
   * @return self
   *   New ConversionOptions instance.
   */
  #[\NoDiscard]
  public static function fromArray(array $data): self {
    $rawExtraArgs = (array) ($data['extra_args'] ?? []);
    $alphaEnabled = (bool) ($data['alpha_enabled'] ?? FALSE);
    if (array_key_exists('alpha_enabled', $rawExtraArgs)) {
      $alphaEnabled = $alphaEnabled || (bool) $rawExtraArgs['alpha_enabled'];
      unset($rawExtraArgs['alpha_enabled']);
    }
    $extraArgs = [];
    foreach ($rawExtraArgs as $arg) {
      if ($arg === NULL || is_bool($arg) || is_array($arg) || is_object($arg)) {
        continue;
      }
      $extraArgs[] = (string) $arg;
    }
    // Reject args containing null bytes or the -i flag (prevents arbitrary
    // file read injection via FFmpeg's input flag).
    $extraArgs = array_filter($extraArgs, function (string $arg): bool {
      return !str_contains($arg, "\0") && $arg !== '-i';
    });
    $extraArgs = array_values($extraArgs);

    $gopSettings = NULL;
    if (isset($data['gop_settings']) && is_array($data['gop_settings'])) {
      $gopSettings = GopSettings::fromArray($data['gop_settings']);
    }

    $rateControl = NULL;
    if (isset($data['rate_control']) && is_array($data['rate_control'])) {
      $rateControl = RateControlSettings::fromArray($data['rate_control']);
    }

    return new self(
      crf: (int) ($data['crf'] ?? 31),
      preset: (int) ($data['preset'] ?? 6),
      threads: (int) ($data['threads'] ?? 2),
      audioBitrate: (string) ($data['audio_bitrate'] ?? '128k'),
      audioCodec: (string) ($data['audio_codec'] ?? 'libopus'),
      audioSampleRate: isset($data['audio_sample_rate']) ? (int) $data['audio_sample_rate'] : NULL,
      audioChannels: isset($data['audio_channels']) ? (int) $data['audio_channels'] : NULL,
      pixelFormat: (string) ($data['pixel_format'] ?? 'yuv420p'),
      timeout: isset($data['timeout']) ? (int) $data['timeout'] : NULL,
      mediaId: isset($data['media_id']) ? (int) $data['media_id'] : NULL,
      maxWidth: isset($data['max_width']) ? (int) $data['max_width'] : NULL,
      maxHeight: isset($data['max_height']) ? (int) $data['max_height'] : NULL,
      twoPass: (bool) ($data['two_pass'] ?? FALSE),
      extraArgs: $extraArgs,
      gopSettings: $gopSettings,
      rateControl: $rateControl,
      alphaEnabled: $alphaEnabled,
      fieldName: (string) ($data['field_name'] ?? ''),
    );
  }

  /**
   * Creates a new instance with a different CRF value.
   *
   * @param int $crf
   *   The new CRF value.
   *
   * @return self
   *   New instance with updated CRF.
   */
  #[\NoDiscard]
  public function withCrf(int $crf): self {
    if ($crf < 0 || $crf > 63) {
      throw new \InvalidArgumentException(sprintf('CRF must be between 0 and 63, got %d.', $crf));
    }
    return clone($this, ['crf' => $crf]);
  }

  /**
   * Creates a new instance with a different preset.
   *
   * @param int $preset
   *   The new preset value.
   *
   * @return self
   *   New instance with updated preset.
   */
  #[\NoDiscard]
  public function withPreset(int $preset): self {
    if ($preset < 0 || $preset > 9) {
      throw new \InvalidArgumentException(sprintf('Preset must be between 0 and 9, got %d.', $preset));
    }
    return clone($this, ['preset' => $preset]);
  }

  /**
   * Creates a new instance with a different thread count.
   *
   * @param int $threads
   *   The new thread count.
   *
   * @return self
   *   New instance with updated threads.
   */
  #[\NoDiscard]
  public function withThreads(int $threads): self {
    if ($threads < 0) {
      throw new \InvalidArgumentException(sprintf('Threads must be 0 (auto) or positive, got %d.', $threads));
    }
    return clone($this, ['threads' => $threads]);
  }

  /**
   * Creates a new instance with a media ID.
   *
   * @param int $mediaId
   *   The media entity ID.
   *
   * @return self
   *   New instance with media ID set.
   */
  #[\NoDiscard]
  public function withMediaId(int $mediaId): self {
    return clone($this, ['mediaId' => $mediaId]);
  }

  /**
   * Creates a new instance with a field name.
   *
   * @param string $fieldName
   *   The entity field name.
   *
   * @return self
   *   New instance with field name set.
   */
  #[\NoDiscard]
  public function withFieldName(string $fieldName): self {
    return clone($this, ['fieldName' => $fieldName]);
  }

  /**
   * Creates a new instance with a timeout.
   *
   * @param int $timeout
   *   Timeout in seconds.
   *
   * @return self
   *   New instance with timeout set.
   */
  #[\NoDiscard]
  public function withTimeout(int $timeout): self {
    return clone($this, ['timeout' => $timeout]);
  }

  /**
   * Creates options optimized for high quality output.
   *
   * @return self
   *   New instance with high quality settings.
   */
  #[\NoDiscard]
  public function forHighQuality(): self {
    return clone($this, [
      'crf' => 23,
      'preset' => 2,
      'audioBitrate' => '192k',
      'twoPass' => TRUE,
    ]);
  }

  /**
   * Creates options optimized for fast encoding.
   *
   * @return self
   *   New instance with fast encoding settings.
   */
  #[\NoDiscard]
  public function forFastEncoding(): self {
    return clone($this, [
      'crf' => 35,
      'preset' => 8,
      'twoPass' => FALSE,
    ]);
  }

  /**
   * Creates options for web streaming.
   *
   * @return self
   *   New instance with web streaming settings.
   */
  #[\NoDiscard]
  public function forWebStreaming(): self {
    return clone($this, [
      'crf' => 28,
      'preset' => 6,
      'pixelFormat' => 'yuv420p',
      'audioCodec' => 'libopus',
      'audioBitrate' => '128k',
      'audioSampleRate' => 48000,
      'audioChannels' => 2,
    ]);
  }

  /**
   * Creates options for ABR (Adaptive Bitrate) streaming.
   *
   * @param float $segmentDuration
   *   Target segment duration in seconds.
   *
   * @return self
   *   New instance with ABR streaming settings.
   */
  #[\NoDiscard]
  public function forAbrStreaming(float $segmentDuration = 2.0): self {
    return clone($this, [
      'crf' => 23,
      'preset' => 5,
      'pixelFormat' => 'yuv420p',
      'audioCodec' => 'libopus',
      'audioBitrate' => '128k',
      'audioSampleRate' => 48000,
      'audioChannels' => 2,
      'gopSettings' => GopSettings::forAbrStreaming($segmentDuration),
    ]);
  }

  /**
   * Creates a new instance with GOP settings.
   *
   * @param GopSettings $gopSettings
   *   GOP/keyframe settings.
   *
   * @return self
   *   New instance with updated GOP settings.
   */
  #[\NoDiscard]
  public function withGopSettings(GopSettings $gopSettings): self {
    return clone($this, ['gopSettings' => $gopSettings]);
  }

  /**
   * Creates a new instance with rate control settings.
   *
   * @param RateControlSettings $rateControl
   *   Rate control settings.
   *
   * @return self
   *   New instance with updated rate control settings.
   */
  #[\NoDiscard]
  public function withRateControl(RateControlSettings $rateControl): self {
    return clone($this, ['rateControl' => $rateControl]);
  }

  /**
   * Creates a new instance with a different two-pass setting.
   *
   * @param bool $twoPass
   *   Whether to use two-pass encoding.
   *
   * @return self
   *   New instance with updated two-pass flag.
   */
  #[\NoDiscard]
  public function withTwoPass(bool $twoPass): self {
    return clone($this, ['twoPass' => $twoPass]);
  }

  /**
   * Creates a new instance with different extra arguments.
   *
   * @param array<int, string> $extraArgs
   *   Additional FFmpeg arguments.
   *
   * @return self
   *   New instance with updated extra arguments.
   */
  #[\NoDiscard]
  public function withExtraArgs(array $extraArgs): self {
    return clone($this, ['extraArgs' => $extraArgs]);
  }

  /**
   * Creates a new instance with alpha encoding enabled/disabled.
   *
   * @param bool $enabled
   *   TRUE to explicitly enable alpha layer encoding.
   *
   * @return self
   *   New instance with updated alpha encoding flag.
   */
  #[\NoDiscard]
  public function withAlphaEnabled(bool $enabled = TRUE): self {
    return clone($this, ['alphaEnabled' => $enabled]);
  }

  /**
   * Creates a new instance with a different audio codec.
   *
   * @param string $audioCodec
   *   Audio codec to use (e.g., 'libopus', 'aac', 'pcm_s24le').
   *
   * @return self
   *   New instance with updated audio codec.
   */
  #[\NoDiscard]
  public function withAudioCodec(string $audioCodec): self {
    return clone($this, ['audioCodec' => $audioCodec]);
  }

  /**
   * Creates a new instance with a different audio sample rate.
   *
   * @param int|null $audioSampleRate
   *   Audio sample rate in Hz, or NULL to use source rate.
   *
   * @return self
   *   New instance with updated audio sample rate.
   */
  #[\NoDiscard]
  public function withAudioSampleRate(?int $audioSampleRate): self {
    return clone($this, ['audioSampleRate' => $audioSampleRate]);
  }

  /**
   * Creates a new instance with a different audio channel count.
   *
   * @param int|null $audioChannels
   *   Number of audio channels (1-8), or NULL to use source channels.
   *
   * @return self
   *   New instance with updated audio channels.
   */
  #[\NoDiscard]
  public function withAudioChannels(?int $audioChannels): self {
    return clone($this, ['audioChannels' => $audioChannels]);
  }

  /**
   * Creates options for a specific ABR rendition.
   *
   * @param int $targetBitrate
   *   Target video bitrate in kbps.
   * @param int|null $maxWidth
   *   Maximum width for this rendition.
   * @param int|null $maxHeight
   *   Maximum height for this rendition.
   * @param float $segmentDuration
   *   Target segment duration in seconds.
   *
   * @return self
   *   New instance configured for ABR rendition.
   */
  #[\NoDiscard]
  public function forAbrRendition(
    int $targetBitrate,
    ?int $maxWidth = NULL,
    ?int $maxHeight = NULL,
    float $segmentDuration = 2.0,
  ): self {
    return clone($this, [
      'preset' => 5,
      'pixelFormat' => 'yuv420p',
      'audioCodec' => 'libopus',
      'audioSampleRate' => 48000,
      'audioChannels' => 2,
      'maxWidth' => $maxWidth,
      'maxHeight' => $maxHeight,
      'gopSettings' => GopSettings::forAbrStreaming($segmentDuration),
      'rateControl' => RateControlSettings::forAbrRendition($targetBitrate),
    ]);
  }

  /**
   * Converts options to array format.
   *
   * @return array<string, bool|int|float|string|array<int, string>|array<string, bool|float|int|string|null>|null>
   *   Associative array of options.
   */
  #[\NoDiscard]
  public function toArray(): array {
    return [
      'crf' => $this->crf,
      'preset' => $this->preset,
      'threads' => $this->threads,
      'audio_bitrate' => $this->audioBitrate,
      'audio_codec' => $this->audioCodec,
      'audio_sample_rate' => $this->audioSampleRate,
      'audio_channels' => $this->audioChannels,
      'pixel_format' => $this->pixelFormat,
      'timeout' => $this->timeout,
      'media_id' => $this->mediaId,
      'max_width' => $this->maxWidth,
      'max_height' => $this->maxHeight,
      'two_pass' => $this->twoPass,
      'extra_args' => $this->extraArgs,
      'gop_settings' => $this->gopSettings?->toArray(),
      'rate_control' => $this->rateControl?->toArray(),
      'alpha_enabled' => $this->alphaEnabled,
      'field_name' => $this->fieldName,
    ];
  }

}
