<?php

declare(strict_types=1);

namespace Drupal\ffmpeg_media\Service;

use Drupal\Component\Uuid\UuidInterface;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Logger\LoggerChannelInterface;
use Drupal\ffmpeg_media\Enum\HardwareType;
use Drupal\ffmpeg_media\Exception\FFmpegException;
use Drupal\ffmpeg_media\Exception\OutputExecutionException;
use Drupal\ffmpeg_media\ValueObject\ConversionContext;
use Drupal\ffmpeg_media\ValueObject\ConversionDependencies;
use Drupal\ffmpeg_media\ValueObject\OutputExecutionPlan;
use Drupal\ffmpeg_media\ValueObject\OutputExecutionRequest;
use Drupal\ffmpeg_media\ValueObject\ConversionResult;
use Drupal\ffmpeg_media\ValueObject\ProfileOutput;
use Drupal\ffmpeg_media\ValueObject\VideoInfo;
use Drupal\file\FileInterface;
use Drupal\media\MediaInterface;

/**
 * Main video conversion orchestrator service.
 *
 * Receives pipeline services via ConversionDependencies (10 services)
 * plus 4 direct injections. The effective dependency count (~14) is high
 * but reflects the orchestrator's integration role. Inspection and cleanup
 * methods have already been extracted to MediaConversionInspector, and
 * output execution to OutputExecutionRunner.
 *
 * @see \Drupal\ffmpeg_media\ValueObject\ConversionDependencies
 *
 * @internal
 */
final class VideoConverter implements VideoConverterInterface {

  /**
   * Constructs a VideoConverter object.
   *
   * @param \Drupal\ffmpeg_media\ValueObject\ConversionDependencies $deps
   *   Aggregated conversion pipeline dependencies.
   * @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory
   *   Config factory.
   * @param \Drupal\Core\Logger\LoggerChannelInterface $logger
   *   Logger.
   * @param \Drupal\Component\Uuid\UuidInterface $uuid
   *   UUID generator.
   * @param \Drupal\ffmpeg_media\Service\HardwareAccelerationManagerInterface|null $hwAccelManager
   *   Optional hardware acceleration manager.
   * @param \Drupal\ffmpeg_media\Service\MetricsServiceInterface|null $metricsService
   *   Optional metrics collector.
   * @param \Drupal\ffmpeg_media\Service\SourceFileResolverInterface|null $sourceFileResolver
   *   Source file resolver for content validation.
   */
  public function __construct(
    private readonly ConversionDependencies $deps,
    private readonly ConfigFactoryInterface $configFactory,
    private readonly LoggerChannelInterface $logger,
    private readonly UuidInterface $uuid,
    private readonly ?HardwareAccelerationManagerInterface $hwAccelManager = NULL,
    private readonly ?MetricsServiceInterface $metricsService = NULL,
    private readonly ?SourceFileResolverInterface $sourceFileResolver = NULL,
  ) {}

  /**
   * {@inheritdoc}
   */
  #[\Override]
  #[\NoDiscard]
  public function convert(MediaInterface $media, ?string $profileId = NULL, bool $synchronous = FALSE, array $options = []): ConversionResult {
    $mediaId = (int) $media->id();
    $correlationId = $this->uuid->generate();
    $startTime = microtime(TRUE);
    $config = $this->configFactory->get('ffmpeg_media.settings');
    $resolved = $this->resolveConversionRequest($media, $profileId, $options);
    $context = $resolved['context'];
    $sourceFile = $resolved['sourceFile'];
    $sourcePath = $resolved['sourcePath'];
    $enabledOutputs = $resolved['enabledOutputs'];
    $resolvedProfileId = $resolved['profileId'];

    $validationFailure = $this->validateConversionRequest(
      $mediaId,
      $correlationId,
      $context->fieldName,
      $sourcePath,
      $enabledOutputs,
      $resolvedProfileId,
      $sourceFile,
    );
    if ($validationFailure instanceof ConversionResult) {
      return $validationFailure;
    }

    $this->startConversionRun(
      $media,
      $mediaId,
      $correlationId,
      $context->fieldName,
      $resolvedProfileId,
      $sourcePath,
      $enabledOutputs,
    );

    $executionPlan = $this->buildExecutionPlan(
      $resolvedProfileId,
      $enabledOutputs,
      $config->get('output_directory'),
      (bool) ($config->get('auto_cleanup_failed') ?? TRUE),
    );

    try {
      $videoInfo = $this->deps->ffprobeCache->getOrCompute(
        $sourcePath,
        fn(string $uri): ?VideoInfo => $this->deps->ffmpegExecutor->getVideoInfo($uri),
      );

      if ($videoInfo === NULL) {
        $this->deps->lifecycleHandler->handleFailure($media, 'Source file has no video stream or could not be analyzed.', NULL, NULL, $context->fieldName);
        return ConversionResult::failed($mediaId, 'Source file has no video stream or could not be analyzed.');
      }

      $outputExecution = $this->deps->outputExecutionRunner->run(new OutputExecutionRequest(
        media: $media,
        sourcePath: $sourcePath,
        context: $context,
        videoInfo: $videoInfo,
        mediaId: $mediaId,
        synchronous: $synchronous,
        correlationId: $correlationId,
        resolvedProfileId: $resolvedProfileId,
        enabledOutputs: $enabledOutputs,
        executionPlan: $executionPlan,
      ));

      $outputs = $outputExecution['outputs'];
      $failedOutputs = $outputExecution['failedOutputs'];
      $hardwareType = $outputExecution['hardwareType'];

      if ($outputs === []) {
        throw new FFmpegException('Conversion produced no video output files. Ensure at least one output format is enabled in the conversion profile.');
      }

      $processorOutputs = $this->deps->processorPipelineRunner->runProcessorPipeline($media, $sourcePath, $context, $videoInfo, $resolvedProfileId);
      $outputs = array_merge($outputs, $processorOutputs);

      return $this->finalizeSuccessfulConversion(
        $media,
        $mediaId,
        $startTime,
        $correlationId,
        $context,
        $sourceFile,
        $outputs,
        $failedOutputs,
        $hardwareType,
      );
    }
    catch (OutputExecutionException $e) {
      $this->onConversionFailure($media, $e->getMessage(), $context->fieldName, $e, $executionPlan->autoCleanupFailed, $e->getHardwareType());
      $duration = microtime(TRUE) - $startTime;
      $this->recordConversionMetrics($mediaId, FALSE, $duration, $enabledOutputs, $e->getHardwareType() !== NULL);
      return ConversionResult::failed($mediaId, $e->getMessage(), $duration, ['correlation_id' => $correlationId]);
    }
    catch (\Throwable $e) {
      $this->onConversionFailure($media, $e->getMessage(), $context->fieldName, $e, $executionPlan->autoCleanupFailed);
      $duration = microtime(TRUE) - $startTime;
      $this->recordConversionMetrics($mediaId, FALSE, $duration, $enabledOutputs, FALSE);
      return ConversionResult::failed($mediaId, $e->getMessage(), $duration, ['correlation_id' => $correlationId]);
    }
  }

  /**
   * Resolves conversion request state from the media entity and options.
   *
   * @param \Drupal\media\MediaInterface $media
   *   Media entity being converted.
   * @param string|null $profileId
   *   Requested conversion profile ID.
   * @param array<string, mixed> $options
   *   Runtime conversion options.
   *
   * @return array{
   *   context: \Drupal\ffmpeg_media\ValueObject\ConversionContext,
   *   sourceFile: mixed,
   *   sourcePath: string,
   *   enabledOutputs: array<int, ProfileOutput>,
   *   profileId: string
   *   }
   *   Resolved conversion state.
   */
  #[\NoDiscard]
  private function resolveConversionRequest(MediaInterface $media, ?string $profileId, array $options): array {
    $fieldName = (string) ($options['field_name'] ?? '');
    return $this->deps->configResolver->resolve($media, $profileId, $options, $fieldName);
  }

  /**
   * Validates that conversion can start.
   *
   * @param int $mediaId
   *   Media entity ID.
   * @param string $correlationId
   *   Correlation ID for this conversion run.
   * @param string $fieldName
   *   Scoped media field name for this conversion request.
   * @param string $sourcePath
   *   Absolute source file path.
   * @param array<int, \Drupal\ffmpeg_media\ValueObject\ProfileOutput> $enabledOutputs
   *   Outputs enabled for this run.
   * @param string $resolvedProfileId
   *   Fully resolved conversion profile ID.
   * @param \Drupal\file\FileInterface|null $sourceFile
   *   The resolved source file entity, or NULL if unresolved.
   *
   * @return \Drupal\ffmpeg_media\ValueObject\ConversionResult|null
   *   Failure result when validation fails, otherwise NULL.
   */
  #[\NoDiscard]
  private function validateConversionRequest(
    int $mediaId,
    string $correlationId,
    string $fieldName,
    string $sourcePath,
    array $enabledOutputs,
    string $resolvedProfileId,
    ?FileInterface $sourceFile = NULL,
  ): ?ConversionResult {
    if (!file_exists($sourcePath)) {
      return $this->earlyFailure($mediaId, 'Source file not found.', $correlationId, $fieldName);
    }

    $sourceSize = filesize($sourcePath);
    if ($sourceSize === FALSE || $sourceSize === 0) {
      return $this->earlyFailure($mediaId, 'Source file is empty or unreadable.', $correlationId, $fieldName);
    }

    // MIME/magic-byte content validation — defence-in-depth for conversion
    // paths that bypass EntityHooks (Drush, API, direct service calls).
    if ($sourceFile instanceof FileInterface && $this->sourceFileResolver !== NULL) {
      if (!$this->sourceFileResolver->validateFileContent($sourceFile)) {
        $this->logger->warning('Source file content validation failed for media @id (field: @field). File MIME type or magic bytes do not match an allowed video/audio format.', [
          '@id' => $mediaId,
          '@field' => $fieldName,
        ]);
        return $this->earlyFailure($mediaId, 'Source file does not appear to be a valid video or audio file.', $correlationId, $fieldName);
      }
    }

    if (!$this->deps->ffmpegExecutor->isAvailable()) {
      return $this->earlyFailure($mediaId, 'FFmpeg is not available.', $correlationId, $fieldName);
    }

    if ($enabledOutputs === []) {
      return $this->earlyFailure($mediaId, 'No outputs enabled in profile.', $correlationId, $fieldName);
    }

    $profile = $this->deps->profileManager->getProfile($resolvedProfileId);
    $hardwareRequired = $profile?->isHardwareRequired() ?? FALSE;
    if (!$hardwareRequired) {
      return NULL;
    }

    if ($this->hwAccelManager === NULL) {
      $this->logger->debug('Hardware acceleration not available: HardwareAccelerationManagerInterface not injected.');
    }

    $hwAvailable = $this->hwAccelManager?->detectBestHardware();
    if ($hwAvailable === NULL) {
      return $this->earlyFailure(
        $mediaId,
        'Profile requires GPU hardware acceleration but no compatible GPU was detected. Please select a different conversion profile.',
        $correlationId,
        $fieldName,
      );
    }

    return NULL;
  }

  /**
   * Starts tracking and emits lifecycle events for a conversion run.
   *
   * @param \Drupal\media\MediaInterface $media
   *   Media entity being converted.
   * @param int $mediaId
   *   Media entity ID.
   * @param string $correlationId
   *   Correlation ID for the current conversion run.
   * @param string $fieldName
   *   Scoped media field name for the current conversion run.
   * @param string $resolvedProfileId
   *   Active conversion profile ID.
   * @param string $sourcePath
   *   Absolute source file path.
   * @param array<int, \Drupal\ffmpeg_media\ValueObject\ProfileOutput> $enabledOutputs
   *   Outputs enabled for this run.
   */
  private function startConversionRun(
    MediaInterface $media,
    int $mediaId,
    string $correlationId,
    string $fieldName,
    string $resolvedProfileId,
    string $sourcePath,
    array $enabledOutputs,
  ): void {
    $this->deps->progressTracker->startTracking($mediaId, $correlationId, $fieldName);

    $outputFormats = array_map(static fn(ProfileOutput $output): string => $output->container, $enabledOutputs);
    $primaryFormat = $outputFormats !== [] ? array_first($outputFormats) : 'mp4';
    $conversionOptions = $this->deps->profileManager
      ->getOptionsForFormat($resolvedProfileId, $primaryFormat)
      ->withFieldName($fieldName);

    $this->deps->lifecycleHandler->dispatchStarted($media, $conversionOptions, $sourcePath, $outputFormats);
    $this->logger->info('Conversion started for media @id with correlation @correlation_id.', [
      '@id' => $mediaId,
      '@correlation_id' => $correlationId,
    ]);
  }

  /**
   * Builds execution metadata for configured outputs.
   *
   * @param string $resolvedProfileId
   *   Active conversion profile ID.
   * @param array<int, \Drupal\ffmpeg_media\ValueObject\ProfileOutput> $enabledOutputs
   *   Outputs enabled for this run.
   * @param string|null $outputDirectory
   *   Configured output directory.
   * @param bool $autoCleanupFailed
   *   TRUE when failed runs should auto-clean partial outputs.
   *
   * @return \Drupal\ffmpeg_media\ValueObject\OutputExecutionPlan
   *   Typed output execution plan.
   */
  #[\NoDiscard]
  private function buildExecutionPlan(
    string $resolvedProfileId,
    array $enabledOutputs,
    ?string $outputDirectory,
    bool $autoCleanupFailed,
  ): OutputExecutionPlan {
    $singleOutputCount = 0;
    foreach ($enabledOutputs as $output) {
      if (!in_array($output->container, ['hls', 'dash'], TRUE)) {
        $singleOutputCount++;
      }
    }

    $profile = $this->deps->profileManager->getProfile($resolvedProfileId);

    return new OutputExecutionPlan(
      outputDirectory: $outputDirectory,
      autoCleanupFailed: $autoCleanupFailed,
      outputKeys: $this->deps->profileManager->getEnabledOutputKeys($resolvedProfileId),
      singleOutputCount: $singleOutputCount,
      hardwareRequired: $profile?->isHardwareRequired() ?? FALSE,
    );
  }

  /**
   * Finalizes a successful or partially successful conversion run.
   *
   * @param \Drupal\media\MediaInterface $media
   *   Media entity being converted.
   * @param int $mediaId
   *   Media entity ID.
   * @param float $startTime
   *   Conversion start time as returned by microtime(TRUE).
   * @param string $correlationId
   *   Correlation ID for the current conversion run.
   * @param \Drupal\ffmpeg_media\ValueObject\ConversionContext $context
   *   Resolved conversion context.
   * @param \Drupal\file\FileInterface|null $sourceFile
   *   Source file entity, or NULL if unresolved.
   * @param array<string, string> $outputs
   *   Successful outputs keyed by format.
   * @param array<string, string> $failedOutputs
   *   Failed outputs keyed by format.
   * @param \Drupal\ffmpeg_media\Enum\HardwareType|null $hardwareType
   *   Last hardware type used during output generation.
   */
  #[\NoDiscard]
  private function finalizeSuccessfulConversion(
    MediaInterface $media,
    int $mediaId,
    float $startTime,
    string $correlationId,
    ConversionContext $context,
    ?FileInterface $sourceFile,
    array $outputs,
    array $failedOutputs,
    ?HardwareType $hardwareType,
  ): ConversionResult {
    $this->deps->progressTracker->markCompleted($mediaId, $context->fieldName);
    $this->deps->queueManager->removeFromQueue($mediaId, $context->fieldName);

    $duration = microtime(TRUE) - $startTime;
    $resultMetadata = [
      'correlation_id' => $correlationId,
      'field_name' => $context->fieldName,
    ];

    if ($failedOutputs !== []) {
      $result = ConversionResult::partialSuccess($mediaId, $outputs, $failedOutputs, $duration, $resultMetadata);
      $this->logger->warning('Conversion partially succeeded for media @id: @failed_count output(s) failed (@formats). Correlation: @correlation_id', [
        '@id' => $mediaId,
        '@failed_count' => count($failedOutputs),
        '@formats' => implode(', ', array_keys($failedOutputs)),
        '@correlation_id' => $correlationId,
      ]);
    }
    else {
      $result = ConversionResult::success($mediaId, $outputs, $duration, $resultMetadata);
    }

    $this->deps->lifecycleHandler->finalizeSuccess($media, $result, $context, $sourceFile, $hardwareType);
    $this->recordConversionMetrics($mediaId, TRUE, $duration, array_keys($outputs), $hardwareType !== NULL);

    $this->logger->info('Conversion completed for media @id in @duration seconds. Correlation: @correlation_id', [
      '@id' => $mediaId,
      '@duration' => round($duration, 2),
      '@correlation_id' => $correlationId,
    ]);

    return $result;
  }

  /**
   * Handles conversion failure with optional auto-cleanup.
   *
   * @param \Drupal\media\MediaInterface $media
   *   Media entity.
   * @param string $error
   *   Error message.
   * @param string $fieldName
   *   Scoped media field name for this failed conversion.
   * @param \Throwable|null $exception
   *   Exception if any.
   * @param bool $autoCleanup
   *   Whether to automatically cleanup failed conversion files.
   * @param \Drupal\ffmpeg_media\Enum\HardwareType|null $hardwareType
   *   Last hardware type used during output generation.
   */
  private function onConversionFailure(
    MediaInterface $media,
    string $error,
    string $fieldName,
    ?\Throwable $exception = NULL,
    bool $autoCleanup = FALSE,
    ?HardwareType $hardwareType = NULL,
  ): void {
    $this->deps->lifecycleHandler->handleFailure($media, $error, $exception, $hardwareType, $fieldName);

    if ($autoCleanup) {
      $deleted = $this->deps->mediaConversionInspector->deleteConvertedFiles($media, $fieldName);
      if ($deleted !== []) {
        $this->logger->info('Auto-cleaned up @count failed conversion files for media @id.', [
          '@count' => count($deleted),
          '@id' => (int) $media->id(),
        ]);
      }
    }
  }

  /**
   * Handles early validation failures before conversion starts.
   *
   * @param int $mediaId
   *   Media entity ID.
   * @param string $error
   *   Error message.
   * @param string|null $correlationId
   *   Correlation ID for log tracing.
   * @param string $fieldName
   *   Scoped media field name for this failed conversion.
   *
   * @return \Drupal\ffmpeg_media\ValueObject\ConversionResult
   *   Failed conversion result.
   */
  #[\NoDiscard]
  private function earlyFailure(int $mediaId, string $error, ?string $correlationId = NULL, string $fieldName = ''): ConversionResult {
    $this->deps->progressTracker->markFailed($mediaId, $error, $fieldName);
    $this->deps->queueManager->removeFromQueue($mediaId, $fieldName);
    $this->recordConversionMetrics($mediaId, FALSE, 0.0, [], FALSE);
    $this->logger->error('Conversion aborted for media @id: @error (correlation: @correlation_id)', [
      '@id' => $mediaId,
      '@error' => $error,
      '@correlation_id' => $correlationId ?? 'none',
    ]);
    return ConversionResult::failed($mediaId, $error, 0.0, $correlationId !== NULL ? ['correlation_id' => $correlationId] : []);
  }

  /**
   * Records conversion completion metrics when the metrics service is enabled.
   *
   * @param int $mediaId
   *   Media entity ID.
   * @param bool $success
   *   TRUE when the conversion completed successfully.
   * @param float $duration
   *   Total conversion duration in seconds.
   * @param array<int, \Drupal\ffmpeg_media\ValueObject\ProfileOutput|string> $outputs
   *   Enabled output objects or successful output type names.
   * @param bool $hardwareAccelerated
   *   TRUE when hardware acceleration completed successfully.
   */
  private function recordConversionMetrics(
    int $mediaId,
    bool $success,
    float $duration,
    array $outputs,
    bool $hardwareAccelerated,
  ): void {
    if ($this->metricsService === NULL) {
      return;
    }

    $primaryOutput = NULL;
    foreach ($outputs as $output) {
      if ($output instanceof ProfileOutput) {
        $primaryOutput = $output->container;
        break;
      }
      if (is_string($output) && $output !== '') {
        $primaryOutput = $output;
        break;
      }
    }

    $this->metricsService->recordConversion(
      $mediaId,
      $success,
      $duration,
      NULL,
      $primaryOutput,
      $hardwareAccelerated,
    );
  }

  /**
   * {@inheritdoc}
   *
   * @phpstan-return array{can_reconvert: bool, reason?: string|null, existing_outputs: array<string, string>}
   */
  #[\Override]
  #[\NoDiscard]
  public function canReconvert(MediaInterface $media, string $fieldName = ''): array {
    return $this->deps->mediaConversionInspector->canReconvert($media, $fieldName);
  }

  /**
   * {@inheritdoc}
   *
   * @phpstan-return array<int, string>
   */
  #[\Override]
  public function deleteConvertedFiles(MediaInterface $media, string $fieldName = ''): array {
    return $this->deps->mediaConversionInspector->deleteConvertedFiles($media, $fieldName);
  }

  /**
   * {@inheritdoc}
   *
   * @phpstan-return array{has_source: bool, is_queued: bool, is_converting: bool, is_converted: bool, outputs: array<string, string>, error: string|null}
   */
  #[\Override]
  #[\NoDiscard]
  public function getStatus(MediaInterface $media, string $fieldName = ''): array {
    return $this->deps->mediaConversionInspector->getStatus($media, $fieldName);
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  #[\NoDiscard]
  public function isEligibleForConversion(MediaInterface $media, string $fieldName = ''): bool {
    return $this->deps->mediaConversionInspector->isEligibleForConversion($media, $fieldName);
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  #[\NoDiscard]
  public function convertAdaptive(MediaInterface $media, string $format, ?string $profileId = NULL, string $fieldName = ''): ConversionResult {
    return $this->deps->outputExecutionRunner->convertAdaptive($media, $format, $profileId, $fieldName);
  }

}
