<?php

declare(strict_types=1);

namespace Drupal\ffmpeg_media\Service;

use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\File\FileSystemInterface;
use Drupal\file\FileInterface;
use Drupal\media\MediaInterface;

/**
 * Resolves source files from media entity fields.
 *
 * @internal
 */
final class SourceFileResolver implements SourceFileResolverInterface {

  /**
   * Constructs a SourceFileResolver object.
   *
   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager
   *   Entity type manager.
   * @param \Drupal\Core\File\FileSystemInterface $fileSystem
   *   File system service.
   */
  public function __construct(
    protected readonly EntityTypeManagerInterface $entityTypeManager,
    protected readonly FileSystemInterface $fileSystem,
  ) {}

  /**
   * {@inheritdoc}
   */
  #[\Override]
  #[\NoDiscard]
  public function getSourceFile(MediaInterface $entity, string $fieldName): ?FileInterface {
    if (!$entity->hasField($fieldName)) {
      return NULL;
    }

    $field = $entity->get($fieldName);
    if ($field->isEmpty()) {
      return NULL;
    }

    $item = $field->first();
    if ($item === NULL) {
      return NULL;
    }

    $fileId = $item->target_id ?? NULL;
    if ($fileId === NULL) {
      return NULL;
    }

    $file = $this->entityTypeManager->getStorage('file')->load($fileId);

    return $file instanceof FileInterface ? $file : NULL;
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  #[\NoDiscard]
  public function getSourcePath(MediaInterface $entity, string $fieldName): ?string {
    $file = $this->getSourceFile($entity, $fieldName);
    if ($file === NULL) {
      return NULL;
    }

    $uri = $file->getFileUri();
    if ($uri === NULL) {
      return NULL;
    }

    $realPath = $this->fileSystem->realpath($uri);

    return $realPath !== FALSE ? $realPath : NULL;
  }

  /**
   * {@inheritdoc}
   *
   * @phpstan-return array<int, \Drupal\file\FileInterface>
   */
  #[\Override]
  #[\NoDiscard]
  public function getSourceFiles(array $mediaEntities, array $fieldNames): array {
    // Collect file IDs per media entity.
    $fileIdToMediaId = [];

    foreach ($mediaEntities as $entity) {
      $mediaId = (int) $entity->id();

      foreach ($fieldNames as $fieldName) {
        if (!$entity->hasField($fieldName)) {
          continue;
        }

        $field = $entity->get($fieldName);
        if ($field->isEmpty()) {
          continue;
        }

        $item = $field->first();
        if ($item === NULL) {
          continue;
        }
        $fileId = $item->target_id ?? NULL;
        if ($fileId !== NULL) {
          $fileIdToMediaId[(int) $fileId] = $mediaId;
          break;
        }
      }
    }

    if ($fileIdToMediaId === []) {
      return [];
    }

    $fileStorage = $this->entityTypeManager->getStorage('file');
    $loadedFiles = $fileStorage->loadMultiple(array_keys($fileIdToMediaId));

    $result = [];
    foreach ($loadedFiles as $fileId => $file) {
      if ($file instanceof FileInterface) {
        $mediaId = $fileIdToMediaId[(int) $fileId];
        $result[$mediaId] = $file;
      }
    }

    return $result;
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  #[\NoDiscard]
  public function validateFileContent(FileInterface $file): bool {
    $uri = $file->getFileUri();
    if ($uri === NULL) {
      return FALSE;
    }

    $realPath = $this->fileSystem->realpath($uri);

    if ($realPath === FALSE || !file_exists($realPath)) {
      return FALSE;
    }

    $finfo = new \finfo(FILEINFO_MIME_TYPE);
    $detectedMime = $finfo->file($realPath);

    if ($detectedMime === FALSE) {
      return FALSE;
    }

    if ($detectedMime === 'application/octet-stream') {
      return $this->isKnownBinaryMediaFile($realPath, $uri);
    }

    return $this->isAllowedMimeType($detectedMime);
  }

  /**
   * Checks if a MIME type is an allowed video/audio type.
   *
   * @param string $mimeType
   *   The detected MIME type.
   *
   * @return bool
   *   TRUE if allowed.
   */
  #[\NoDiscard]
  protected function isAllowedMimeType(string $mimeType): bool {
    // Accept all video/* and audio/* MIME types.
    if (str_starts_with($mimeType, 'video/') || str_starts_with($mimeType, 'audio/')) {
      return TRUE;
    }

    // Additional known container and manifest MIME types used by media files.
    // Deliberately excludes application/octet-stream because an ambiguous
    // generic binary type is not a strong enough signal for safe processing.
    $allowedTypes = [
      'application/x-matroska',
      'application/ogg',
      'application/vnd.apple.mpegurl',
      'application/dash+xml',
    ];

    return in_array($mimeType, $allowedTypes, TRUE);
  }

  /**
   * Validates media containers when finfo only reports octet-stream.
   *
   * Uses a small signature allowlist for common formats that are known to be
   * reported generically by finfo in some environments.
   *
   * @param string $realPath
   *   Absolute local file path.
   * @param string $uri
   *   Original Drupal file URI.
   *
   * @return bool
   *   TRUE when both extension and file signature match a known media format.
   */
  #[\NoDiscard]
  protected function isKnownBinaryMediaFile(string $realPath, string $uri): bool {
    $header = file_get_contents($realPath, FALSE, NULL, 0, 16);
    if ($header === FALSE || $header === '') {
      return FALSE;
    }

    $extension = strtolower((string) pathinfo($uri, PATHINFO_EXTENSION));

    return match ($extension) {
      'mkv', 'webm' => str_starts_with($header, "\x1A\x45\xDF\xA3"),
      'ogg', 'oga', 'ogv' => str_starts_with($header, 'OggS'),
      'mp4', 'm4v', 'mov', '3gp' => strlen($header) >= 8 && substr($header, 4, 4) === 'ftyp',
      'mp3' => str_starts_with($header, 'ID3') || $this->hasMp3FrameSync($header),
      default => FALSE,
    };
  }

  /**
   * Checks for an MPEG audio frame sync marker.
   *
   * @param string $header
   *   Binary file header bytes.
   *
   * @return bool
   *   TRUE if the header starts with an MP3 frame sync marker.
   */
  #[\NoDiscard]
  protected function hasMp3FrameSync(string $header): bool {
    if (strlen($header) < 2) {
      return FALSE;
    }

    return ord($header[0]) === 0xFF && (ord($header[1]) & 0xE0) === 0xE0;
  }

}
