<?php

declare(strict_types=1);

namespace Drupal\ffmpeg_media\Service;

use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\ffmpeg_media\ValueObject\BitrateRung;
use Drupal\ffmpeg_media\ValueObject\VideoInfo;
use Symfony\Component\Process\Process;

/**
 * Service for analyzing video content and generating optimized bitrate ladders.
 *
 * Implements per-title encoding by analyzing video complexity and adjusting
 * bitrates accordingly. Simple content (animations, static scenes) gets lower
 * bitrates while complex content (action, detailed textures) gets higher.
 *
 * @internal
 */
final class BitrateAnalyzer implements BitrateAnalyzerInterface {

  /**
   * Cache ID prefix for complexity analysis results.
   */
  private const string CACHE_PREFIX = 'ffmpeg_media:bitrate_complexity:';

  /**
   * Cache TTL: 7 days (complexity doesn't change for the same file).
   */
  private const int CACHE_TTL = 604800;

  /**
   * Standard bitrate ladder definitions.
   *
   * Based on industry best practices (Netflix, YouTube, Apple).
   *
   * @var array<int, array{width: int, height: int, bitrate: int, audio: int}>
   */
  private const array STANDARD_LADDER = [
    2160 => ['width' => 3840, 'height' => 2160, 'bitrate' => 15000, 'audio' => 192],
    1440 => ['width' => 2560, 'height' => 1440, 'bitrate' => 10000, 'audio' => 192],
    1080 => ['width' => 1920, 'height' => 1080, 'bitrate' => 6000, 'audio' => 128],
    720 => ['width' => 1280, 'height' => 720, 'bitrate' => 3000, 'audio' => 128],
    480 => ['width' => 854, 'height' => 480, 'bitrate' => 1500, 'audio' => 96],
    360 => ['width' => 640, 'height' => 360, 'bitrate' => 800, 'audio' => 64],
    240 => ['width' => 426, 'height' => 240, 'bitrate' => 400, 'audio' => 64],
  ];

  /**
   * Constructs a BitrateAnalyzer.
   *
   * @param \Drupal\ffmpeg_media\Service\FFmpegExecutorInterface $ffmpegExecutor
   *   FFmpeg executor service.
   * @param \Drupal\Core\Cache\CacheBackendInterface $cache
   *   Cache backend for storing complexity analysis results.
   */
  public function __construct(
    protected readonly FFmpegExecutorInterface $ffmpegExecutor,
    protected readonly CacheBackendInterface $cache,
  ) {}

  /**
   * {@inheritdoc}
   *
   * @param string $inputPath
   *   Input file path.
   * @param \Drupal\ffmpeg_media\ValueObject\VideoInfo $videoInfo
   *   Video metadata for the source file.
   * @param array{max_rungs?: int, min_bitrate?: int, include_source?: bool} $options
   *   Analysis options.
   */
  #[\Override]
  #[\NoDiscard]
  public function analyzeBitrateLadder(
    string $inputPath,
    VideoInfo $videoInfo,
    array $options = [],
  ): array {
    $maxRungs = $options['max_rungs'] ?? 6;
    $minBitrate = $options['min_bitrate'] ?? 300;
    $includeSource = $options['include_source'] ?? TRUE;

    // Get base ladder for source resolution.
    $baseLadder = $this->getStandardLadder($videoInfo->width, $videoInfo->height);

    // Analyze complexity if possible.
    $complexity = $this->analyzeComplexity($inputPath);

    // Adjust for complexity.
    $adjustedLadder = $this->adjustLadderForComplexity(
      $baseLadder,
      $complexity,
      $videoInfo->bitrate,
    );

    // Filter out rungs below minimum bitrate.
    $adjustedLadder = array_filter(
      $adjustedLadder,
      fn(BitrateRung $rung) => $rung->videoBitrate >= $minBitrate,
    );

    // Limit number of rungs.
    $adjustedLadder = array_slice($adjustedLadder, 0, $maxRungs);

    // Add source resolution if requested and not already included.
    if ($includeSource) {
      $adjustedLadder = $this->ensureSourceRung($adjustedLadder, $videoInfo);
    }

    // Sort by resolution descending.
    usort($adjustedLadder, fn(BitrateRung $a, BitrateRung $b) => $b->height <=> $a->height);

    return $adjustedLadder;
  }

  /**
   * {@inheritdoc}
   *
   * Results are cached by file path hash for 7 days to avoid redundant
   * FFmpeg subprocess spawns during batch processing.
   *
   * @phpstan-param string $inputPath
   * @phpstan-return float
   */
  #[\Override]
  #[\NoDiscard]
  public function analyzeComplexity(string $inputPath): float {
    $cacheId = $this->buildCacheId($inputPath);
    $cached = $this->cache->get($cacheId);
    if ($cached !== FALSE) {
      return (float) $cached->data;
    }

    $complexity = $this->computeComplexity($inputPath);

    $this->cache->set(
      $cacheId,
      $complexity,
      time() + self::CACHE_TTL,
      ['ffmpeg_media', 'bitrate_analysis'],
    );

    return $complexity;
  }

  /**
   * Computes complexity via sample encode and scene detection.
   *
   * @param string $inputPath
   *   Input file path.
   *
   * @return float
   *   Complexity score (0.0-1.0). Defaults to 0.5 if analysis fails.
   */
  private function computeComplexity(string $inputPath): float {
    // Try to analyze using a sample encode.
    $sampleComplexity = $this->analyzeSampleComplexity($inputPath);

    if ($sampleComplexity !== NULL) {
      return $sampleComplexity;
    }

    // Fallback to motion analysis.
    $motionComplexity = $this->analyzeMotionComplexity($inputPath);

    if ($motionComplexity !== NULL) {
      return $motionComplexity;
    }

    // Default to medium complexity.
    return 0.5;
  }

  /**
   * Builds a cache ID from a file path.
   *
   * Includes file size and modification time so the cache auto-invalidates
   * if the underlying file changes.
   *
   * @param string $inputPath
   *   Input file path.
   *
   * @return string
   *   Cache ID.
   */
  private function buildCacheId(string $inputPath): string {
    $size = @filesize($inputPath) ?: 0;
    $mtime = @filemtime($inputPath) ?: 0;
    $hash = hash('sha256', $inputPath . ':' . $size . ':' . $mtime);
    return self::CACHE_PREFIX . $hash;
  }

  /**
   * Analyzes complexity using a sample CRF encode.
   *
   * @param string $inputPath
   *   Input file path.
   *
   * @return float|null
   *   Complexity score or NULL if analysis failed.
   */
  protected function analyzeSampleComplexity(string $inputPath): ?float {
    $ffmpegPath = $this->ffmpegExecutor->getFfmpegPath();

    // Encode 10 seconds at CRF 23 and measure resulting bitrate.
    $command = [
      $ffmpegPath,
      '-y',
      '-ss', '30',
      '-t', '10',
      '-i', $inputPath,
      '-c:v', 'libx264',
      '-crf', '23',
      '-preset', 'veryfast',
      '-an',
      '-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;
    }

    // Parse bitrate from output.
    if (preg_match('/bitrate:\s*(\d+)\s*kb\/s/', (string) $output, $matches)) {
      $sampleBitrate = (int) $matches[1];

      // Map bitrate to complexity.
      // Low complexity: ~500-1500 kbps at CRF 23.
      // High complexity: ~3000-8000+ kbps at CRF 23.
      return $this->mapBitrateToComplexity($sampleBitrate);
    }

    return NULL;
  }

  /**
   * Analyzes motion complexity using scene detection.
   *
   * @param string $inputPath
   *   Input file path.
   *
   * @return float|null
   *   Complexity score or NULL if analysis failed.
   */
  protected function analyzeMotionComplexity(string $inputPath): ?float {
    $ffmpegPath = $this->ffmpegExecutor->getFfmpegPath();

    // Count scene changes in first 60 seconds.
    $command = [
      $ffmpegPath,
      '-y',
      '-t', '60',
      '-i', $inputPath,
      '-vf', 'select=gt(scene\\,0.3),showinfo',
      '-f', 'null',
      '-',
    ];

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

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

    $output = $process->getErrorOutput() ?: $process->getOutput();
    $sceneChanges = substr_count($output, 'pts_time');

    if ($sceneChanges === 0) {
      return NULL;
    }

    // Map scene changes to complexity.
    // Few changes (0-5): low complexity.
    // Many changes (20+): high complexity.
    return min(1.0, $sceneChanges / 25.0);
  }

  /**
   * Maps sample bitrate to complexity score.
   *
   * @param int $bitrate
   *   Sample bitrate in kbps.
   *
   * @return float
   *   Complexity score (0.0-1.0).
   */
  protected function mapBitrateToComplexity(int $bitrate): float {
    // Logarithmic mapping for better distribution.
    // 500 kbps -> 0.0, 8000 kbps -> 1.0.
    $minBitrate = 500;
    $maxBitrate = 8000;

    $normalized = ($bitrate - $minBitrate) / ($maxBitrate - $minBitrate);
    return max(0.0, min(1.0, $normalized));
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  #[\NoDiscard]
  public function getStandardLadder(int $width, int $height): array {
    $ladder = [];

    foreach (self::STANDARD_LADDER as $targetHeight => $config) {
      // Skip resolutions higher than source.
      if ($targetHeight > $height) {
        continue;
      }

      $ladder[] = new BitrateRung(
        width: $config['width'],
        height: $config['height'],
        videoBitrate: $config['bitrate'],
        audioBitrate: $config['audio'],
        label: sprintf('%dp', $targetHeight),
      );
    }

    return $ladder;
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  #[\NoDiscard]
  public function adjustLadderForComplexity(
    array $ladder,
    float $complexity,
    ?int $sourceMaxBitrate = NULL,
  ): array {
    // Complexity factor: 0.7 (simple) to 1.3 (complex).
    $factor = 0.7 + ($complexity * 0.6);

    return array_map(function (BitrateRung $rung) use ($factor, $sourceMaxBitrate) {
      $adjustedBitrate = (int) ($rung->videoBitrate * $factor);

      // Cap at source bitrate if provided.
      if ($sourceMaxBitrate !== NULL) {
        $adjustedBitrate = min($adjustedBitrate, $sourceMaxBitrate);
      }

      return new BitrateRung(
        width: $rung->width,
        height: $rung->height,
        videoBitrate: $adjustedBitrate,
        audioBitrate: $rung->audioBitrate,
        maxBitrate: $rung->maxBitrate,
        bufferSize: $rung->bufferSize,
        crf: $rung->crf,
        label: $rung->label,
        enabled: $rung->enabled,
      );
    }, $ladder);
  }

  /**
   * Ensures ladder includes a rung matching source resolution.
   *
   * @param \Drupal\ffmpeg_media\ValueObject\BitrateRung[] $ladder
   *   Current ladder.
   * @param \Drupal\ffmpeg_media\ValueObject\VideoInfo $videoInfo
   *   Source video info.
   *
   * @return \Drupal\ffmpeg_media\ValueObject\BitrateRung[]
   *   Ladder with source rung included.
   */
  protected function ensureSourceRung(array $ladder, VideoInfo $videoInfo): array {
    // Check if source resolution already in ladder.
    if (array_any($ladder, static fn(BitrateRung $rung): bool => $rung->width === $videoInfo->width && $rung->height === $videoInfo->height)) {
      return $ladder;
    }

    // Add source resolution rung.
    $sourceBitrate = $videoInfo->bitrate ?? $this->estimateSourceBitrate($videoInfo);

    array_unshift($ladder, new BitrateRung(
      width: $videoInfo->width,
      height: $videoInfo->height,
      videoBitrate: (int) ($sourceBitrate / 1000),
      audioBitrate: 192,
      label: 'Source',
    ));

    return $ladder;
  }

  /**
   * Estimates appropriate bitrate for source resolution.
   *
   * @param \Drupal\ffmpeg_media\ValueObject\VideoInfo $videoInfo
   *   Video information.
   *
   * @return int
   *   Estimated bitrate in bps.
   */
  protected function estimateSourceBitrate(VideoInfo $videoInfo): int {
    $pixels = $videoInfo->width * $videoInfo->height;

    // Rough estimate: 0.1 bits per pixel per frame at 30fps.
    $bitsPerFrame = $pixels * 0.1;
    $frameRate = $videoInfo->frameRate ?? 30.0;

    return (int) ($bitsPerFrame * $frameRate);
  }

  /**
   * Generates a convex hull optimized ladder.
   *
   * Uses convex hull optimization to find the most efficient
   * quality/bitrate combinations.
   *
   * @param string $inputPath
   *   Input file path.
   * @param \Drupal\ffmpeg_media\ValueObject\VideoInfo $videoInfo
   *   Video information.
   * @param array{max_rungs?: int, min_bitrate?: int, include_source?: bool} $options
   *   Options including 'sample_count' for number of test encodes.
   *
   * @return \Drupal\ffmpeg_media\ValueObject\BitrateRung[]
   *   Optimized bitrate ladder.
   */
  #[\NoDiscard]
  public function generateConvexHullLadder(
    string $inputPath,
    VideoInfo $videoInfo,
    array $options = [],
  ): array {
    return $this->analyzeBitrateLadder($inputPath, $videoInfo, $options);
  }

  /**
   * Gets recommended ladder for specific content type.
   *
   * @param string $contentType
   *   Content type: 'animation', 'sports', 'movie', 'documentary',
   *   'music_video'.
   * @param int $sourceHeight
   *   Source video height.
   *
   * @return \Drupal\ffmpeg_media\ValueObject\BitrateRung[]
   *   Recommended ladder for content type.
   */
  #[\NoDiscard]
  public function getLadderForContentType(string $contentType, int $sourceHeight): array {
    $complexityMap = [
      'animation' => 0.3,
      'documentary' => 0.4,
      'movie' => 0.5,
      'music_video' => 0.6,
      'sports' => 0.8,
      'gaming' => 0.9,
    ];

    $complexity = $complexityMap[$contentType] ?? 0.5;
    $baseLadder = $this->getStandardLadder($sourceHeight * 16 / 9, $sourceHeight);

    return $this->adjustLadderForComplexity($baseLadder, $complexity, NULL);
  }

}
