<?php

declare(strict_types=1);

namespace Drupal\ffmpeg_media\Service;

use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Cache\CacheTagsInvalidatorInterface;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\ffmpeg_media\ValueObject\ConversionOptions;
use Drupal\ffmpeg_media\ValueObject\ConversionProfile;
use Drupal\ffmpeg_media\ValueObject\ProfileOutput;

/**
 * Service for managing conversion profiles.
 *
 * @internal
 */
final class ProfileManager implements ProfileManagerInterface {

  /**
   * Cache ID for all profiles.
   */
  private const string CACHE_CID = 'ffmpeg_media:profiles:all';

  /**
   * Cache TTL in seconds.
   */
  private const int CACHE_TTL = 3600;

  /**
   * Cached profiles.
   *
   * @var array<string, \Drupal\ffmpeg_media\ValueObject\ConversionProfile>|null
   */
  protected ?array $profiles = NULL;

  /**
   * Constructs a ProfileManager object.
   *
   * @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory
   *   Config factory.
   * @param \Drupal\Core\Cache\CacheBackendInterface $cache
   *   Cache backend.
   * @param \Drupal\Core\Cache\CacheTagsInvalidatorInterface|null $cacheTagsInvalidator
   *   Optional cache tags invalidator for render-cache updates.
   */
  public function __construct(
    protected readonly ConfigFactoryInterface $configFactory,
    protected readonly CacheBackendInterface $cache,
    protected readonly ?CacheTagsInvalidatorInterface $cacheTagsInvalidator = NULL,
  ) {}

  /**
   * {@inheritdoc}
   *
   * @phpstan-return \Drupal\ffmpeg_media\ValueObject\ConversionProfile|null
   */
  #[\Override]
  #[\NoDiscard]
  public function getProfile(string $profileId): ?ConversionProfile {
    $this->loadProfiles();
    return $this->profiles[$profileId] ?? NULL;
  }

  /**
   * {@inheritdoc}
   *
   * @phpstan-return \Drupal\ffmpeg_media\ValueObject\ConversionProfile
   */
  #[\Override]
  #[\NoDiscard]
  public function getDefaultProfile(): ConversionProfile {
    $defaultId = $this->getDefaultProfileId();
    $profile = $this->getProfile($defaultId);

    if ($profile === NULL) {
      // Try the constant fallback if configured ID doesn't exist.
      $profile = $this->getProfile(self::DEFAULT_PROFILE);
    }

    if ($profile === NULL) {
      return $this->getBuiltInDefaults();
    }

    return $profile;
  }

  /**
   * {@inheritdoc}
   *
   * @phpstan-return string
   */
  #[\Override]
  #[\NoDiscard]
  public function getDefaultProfileId(): string {
    $defaultId = $this->configFactory->get('ffmpeg_media.settings')->get('default_profile');

    if (is_string($defaultId) && $defaultId !== '') {
      return $defaultId;
    }

    return self::DEFAULT_PROFILE;
  }

  /**
   * {@inheritdoc}
   *
   * @phpstan-return array<string, \Drupal\ffmpeg_media\ValueObject\ConversionProfile>
   */
  #[\Override]
  #[\NoDiscard]
  public function getAllProfiles(): array {
    $this->loadProfiles();
    return $this->profiles ?? [];
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  #[\NoDiscard]
  public function getOptionsForFormat(string $profileId, string $outputFormat): ConversionOptions {
    $profile = $this->getProfile($profileId) ?? $this->getDefaultProfile();

    $output = $profile->findEnabledOutput($outputFormat);
    if ($output !== NULL) {
      return $this->applyGlobalAudioDefaults(
        ConversionOptions::fromArray($output->options),
      );
    }

    // Return default options if format not found in profile.
    return $this->applyGlobalAudioDefaults(new ConversionOptions());
  }

  /**
   * {@inheritdoc}
   *
   * @phpstan-return array<int, \Drupal\ffmpeg_media\ValueObject\ProfileOutput>
   */
  #[\Override]
  #[\NoDiscard]
  public function getEnabledOutputs(string $profileId): array {
    $profile = $this->getProfile($profileId) ?? $this->getDefaultProfile();

    return $profile->getEnabledOutputs();
  }

  /**
   * {@inheritdoc}
   *
   * @phpstan-return array<int, string>
   */
  #[\Override]
  #[\NoDiscard]
  public function getEnabledProcessors(string $profileId): array {
    $profile = $this->getProfile($profileId);

    // Use profile-specific processors if explicitly configured.
    if ($profile !== NULL && $profile->enabledProcessors !== []) {
      return array_values($profile->enabledProcessors);
    }

    // Fall back to global setting.
    $global = $this->configFactory->get('ffmpeg_media.settings')->get('enabled_processors');

    return is_array($global) ? array_values($global) : ['video_info'];
  }

  /**
   * {@inheritdoc}
   *
   * @phpstan-return array<int, string>
   */
  #[\Override]
  #[\NoDiscard]
  public function getEnabledOutputContainers(string $profileId): array {
    $profile = $this->getProfile($profileId) ?? $this->getDefaultProfile();

    return $profile->getEnabledOutputContainers();
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  #[\NoDiscard]
  public function getEnabledOutputKeys(string $profileId): array {
    $profile = $this->getProfile($profileId) ?? $this->getDefaultProfile();

    return $profile->getEnabledOutputKeys();
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  #[\NoDiscard]
  public function profileExists(string $profileId): bool {
    $this->loadProfiles();
    return isset($this->profiles[$profileId]);
  }

  /**
   * {@inheritdoc}
   *
   * @param array{id?: string, label?: string, description?: string, outputs?: array<int, array<string, mixed>>, generate_thumbnail?: bool, thumbnail_timestamp?: float, enabled_processors?: array<int, string>, hardware_acceleration?: string|null} $config
   *   Profile configuration.
   *
   * @return array<int, string>
   *   Validation errors.
   */
  #[\Override]
  #[\NoDiscard]
  public function validateProfile(array $config): array {
    $errors = [];

    if (empty($config['id'])) {
      $errors[] = 'Profile ID is required.';
    }

    if (empty($config['label'])) {
      $errors[] = 'Profile label is required.';
    }

    if (empty($config['outputs'])) {
      $errors[] = 'At least one output configuration is required.';
    }
    else {
      foreach ($config['outputs'] as $index => $output) {
        if (empty($output['container'])) {
          $errors[] = "Output $index: Container format is required.";
        }
        if (empty($output['codec'])) {
          $errors[] = "Output $index: Codec is required.";
        }
        if (isset($output['options']['crf'])) {
          $crf = (int) $output['options']['crf'];
          if ($crf < 0 || $crf > 63) {
            $errors[] = "Output $index: CRF must be between 0 and 63.";
          }
        }
        if (isset($output['options']['preset'])) {
          $preset = (int) $output['options']['preset'];
          if ($preset < 0 || $preset > 9) {
            $errors[] = "Output $index: Preset must be between 0 and 9.";
          }
        }
      }
    }

    return $errors;
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  #[\NoDiscard]
  public function applyGlobalAudioDefaults(ConversionOptions $options): ConversionOptions {
    // Skip lossless codecs — they should preserve source audio properties.
    if ($this->isLosslessAudioCodec($options->audioCodec)) {
      return $options;
    }

    $audioConfig = $this->configFactory->get('ffmpeg_media.settings')->get('audio.format');
    if (!is_array($audioConfig)) {
      return $options;
    }

    $result = $options;

    if ($result->audioSampleRate === NULL && !empty($audioConfig['sample_rate'])) {
      $result = $result->withAudioSampleRate((int) $audioConfig['sample_rate']);
    }

    if ($result->audioChannels === NULL && !empty($audioConfig['channels'])) {
      $result = $result->withAudioChannels((int) $audioConfig['channels']);
    }

    return $result;
  }

  /**
   * Checks if an audio codec is lossless.
   *
   * @param string $codec
   *   Audio codec name.
   *
   * @return bool
   *   TRUE if the codec is lossless.
   */
  private function isLosslessAudioCodec(string $codec): bool {
    // Stream copy: no re-encoding, skip all audio defaults.
    if ($codec === 'copy') {
      return TRUE;
    }
    if (in_array($codec, ['flac', 'alac'], TRUE)) {
      return TRUE;
    }
    return str_starts_with($codec, 'pcm_');
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  public function resetCache(): void {
    $this->profiles = NULL;
    $this->cache->delete(self::CACHE_CID);
    $this->cacheTagsInvalidator?->invalidateTags([self::CACHE_TAG]);
  }

  /**
   * Loads all profiles from config.
   */
  protected function loadProfiles(): void {
    if ($this->profiles !== NULL) {
      return;
    }

    // Check persistent cache.
    $cached = $this->cache->get(self::CACHE_CID);
    if ($cached !== FALSE) {
      $this->profiles = $cached->data;
      return;
    }

    $this->profiles = [];

    $configNames = $this->configFactory->listAll('ffmpeg_media.profile.');
    foreach ($configNames as $configName) {
      $config = $this->configFactory->get($configName);
      $data = $config->getRawData();

      if (!empty($data['id'])) {
        $this->profiles[$data['id']] = ConversionProfile::fromConfigArray($data);
      }
    }

    // Ensure the default profile exists as fallback.
    $defaultId = $this->getDefaultProfileId();
    if (!isset($this->profiles[$defaultId]) && !isset($this->profiles[self::DEFAULT_PROFILE])) {
      $this->profiles[self::DEFAULT_PROFILE] = $this->getBuiltInDefaults();
    }

    // Store in persistent cache with tags.
    $this->cache->set(
      self::CACHE_CID,
      $this->profiles,
      time() + self::CACHE_TTL,
      ['ffmpeg_media', self::CACHE_TAG],
    );
  }

  /**
   * Gets built-in default profile.
   *
   * @return \Drupal\ffmpeg_media\ValueObject\ConversionProfile
   *   Default profile value object.
   */
  protected function getBuiltInDefaults(): ConversionProfile {
    return new ConversionProfile(
      id: self::DEFAULT_PROFILE,
      label: 'AV1 / Opus (WebM + MP4 Fallback)',
      description: 'Web-optimized AV1 video with Opus audio. WebM as primary format with MP4 fallback for maximum browser compatibility. Royalty-free.',
      outputs: [
        new ProfileOutput(
          container: 'webm',
          codec: 'av1',
          enabled: TRUE,
          options: [
            'crf' => 30,
            'preset' => 6,
            'audio_bitrate' => '128k',
            'audio_codec' => 'libopus',
            'pixel_format' => 'yuv420p',
            'two_pass' => FALSE,
          ],
        ),
        new ProfileOutput(
          container: 'mp4',
          codec: 'av1',
          enabled: TRUE,
          options: [
            'crf' => 30,
            'preset' => 6,
            'audio_bitrate' => '128k',
            'audio_codec' => 'libopus',
            'pixel_format' => 'yuv420p',
            'two_pass' => FALSE,
          ],
        ),
      ],
      generateThumbnail: TRUE,
      thumbnailTimestamp: 1.0,
      enabledProcessors: [],
    );
  }

}
