<?php

declare(strict_types=1);

namespace Drupal\ffmpeg_media\Cdn;

use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\File\FileSystemInterface;
use Drupal\Core\KeyValueStore\KeyValueFactoryInterface;
use Drupal\Core\KeyValueStore\KeyValueStoreInterface;
use Drupal\Core\State\StateInterface;
use Drupal\file\FileInterface;
use Drupal\media\MediaInterface;
use Psr\Log\LoggerInterface;

/**
 * Base class for CDN integrations.
 *
 * Provides common functionality for CDN integration implementations.
 */
abstract class CdnIntegrationBase implements CdnIntegrationInterface {

  /**
   * CDN configuration.
   *
   * @var array<string, mixed>
   */
  protected array $config;

  /**
   * Persistent mapping from local output paths to remote CDN keys.
   */
  protected readonly KeyValueStoreInterface $cdnPathMap;

  /**
   * Constructs a CdnIntegrationBase object.
   *
   * @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory
   *   Config factory.
   * @param \Drupal\Core\File\FileSystemInterface $fileSystem
   *   File system service.
   * @param \Psr\Log\LoggerInterface $logger
   *   Logger.
   * @param \Drupal\Core\KeyValueStore\KeyValueFactoryInterface $keyValueFactory
   *   Key-value factory.
   * @param \Drupal\Core\State\StateInterface $state
   *   The state service for non-exportable secret storage.
   * @param object|null $keyRepository
   *   Optional Key module repository service.
   */
  public function __construct(
    protected readonly ConfigFactoryInterface $configFactory,
    protected readonly FileSystemInterface $fileSystem,
    protected readonly LoggerInterface $logger,
    protected readonly KeyValueFactoryInterface $keyValueFactory,
    protected readonly StateInterface $state,
    protected readonly ?object $keyRepository = NULL,
  ) {
    $this->config = $this->loadConfiguration();
    $this->cdnPathMap = $this->keyValueFactory->get('ffmpeg_media.cdn_path_map');
  }

  /**
   * Loads CDN configuration.
   *
   * Credential resolution order:
   * ENV var -> Key module reference -> State API.
   *
   * @return array<string, mixed>
   *   Configuration array.
   */
  protected function loadConfiguration(): array {
    $config = $this->configFactory->get('ffmpeg_media.cdn');
    $values = $config->get($this->getProviderId()) ?? [];
    $accessKeyRef = trim((string) ($values['access_key_key'] ?? ''));
    $secretKeyRef = trim((string) ($values['secret_key_key'] ?? ''));
    unset($values['access_key'], $values['secret_key']);

    // Resolve access_key: ENV -> Key module -> State.
    $envAccessKey = getenv('FFMPEG_MEDIA_S3_ACCESS_KEY');
    if ($envAccessKey !== FALSE && $envAccessKey !== '') {
      $values['access_key'] = $envAccessKey;
    }
    elseif (($keyAccess = $this->resolveKeyReferenceValue($accessKeyRef)) !== NULL) {
      $values['access_key'] = $keyAccess;
    }
    else {
      $stateAccessKey = $this->state->get('ffmpeg_media.s3_access_key');
      if (is_string($stateAccessKey) && $stateAccessKey !== '') {
        $values['access_key'] = $stateAccessKey;
      }
    }

    // Resolve secret_key: ENV -> Key module -> State.
    $envSecretKey = getenv('FFMPEG_MEDIA_S3_SECRET_KEY');
    if ($envSecretKey !== FALSE && $envSecretKey !== '') {
      $values['secret_key'] = $envSecretKey;
    }
    elseif (($keySecret = $this->resolveKeyReferenceValue($secretKeyRef)) !== NULL) {
      $values['secret_key'] = $keySecret;
    }
    else {
      $stateSecretKey = $this->state->get('ffmpeg_media.s3_secret_key');
      if (is_string($stateSecretKey) && $stateSecretKey !== '') {
        $values['secret_key'] = $stateSecretKey;
      }
    }

    return $values;
  }

  /**
   * Resolves credential value from a configured Key module key name.
   *
   * @param string $keyName
   *   Key module machine name.
   *
   * @return string|null
   *   Credential value or NULL.
   */
  protected function resolveKeyReferenceValue(string $keyName): ?string {
    $keyRepository = $this->keyRepository;
    if ($keyName === '' || $keyRepository === NULL || !method_exists($keyRepository, 'getKey')) {
      return NULL;
    }

    try {
      $key = $keyRepository->getKey($keyName);
    }
    catch (\Throwable $e) {
      $this->logger->warning('Key module lookup failed for key "@name": @error', [
        '@name' => $keyName,
        '@error' => $e->getMessage(),
      ]);
      return NULL;
    }

    if (!is_object($key) || !method_exists($key, 'getKeyValue')) {
      return NULL;
    }

    try {
      $value = $key->getKeyValue();
    }
    catch (\Throwable $e) {
      $this->logger->warning('Key module value retrieval failed for key "@name": @error', [
        '@name' => $keyName,
        '@error' => $e->getMessage(),
      ]);
      return NULL;
    }

    return is_string($value) && $value !== '' ? $value : NULL;
  }

  /**
   * Checks whether Key module repository integration is available.
   */
  protected function hasKeyRepository(): bool {
    return $this->keyRepository !== NULL
      && method_exists($this->keyRepository, 'getKey');
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  #[\NoDiscard]
  public function isConfigured(): bool {
    // Only check that required config values are present. Avoid calling
    // validateConfiguration() here because it performs a connectivity
    // test on every invocation, adding latency and causing transient
    // network errors to disable the upload flow.
    return !empty($this->config['bucket'])
      && !empty($this->config['access_key'])
      && !empty($this->config['secret_key'])
      && (!empty($this->config['region']) || !empty($this->config['endpoint']));
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  public function uploadMediaOutputs(
    MediaInterface $media,
    array $outputs,
  ): array {
    $results = [];

    foreach ($outputs as $outputType => $localPath) {
      $resolvedLocalPath = $this->resolveLocalPath($localPath);

      if (!file_exists($resolvedLocalPath)) {
        $results[$outputType] = [
          'success' => FALSE,
          'url' => NULL,
          'error' => "Local file not found: {$localPath}",
        ];
        continue;
      }

      $metadata = $this->buildFileMetadata($media, $outputType);

      // Streaming manifests require directory upload (segments + manifest).
      if ($this->isStreamingManifest($localPath)) {
        $results[$outputType] = $this->uploadStreamingDirectory(
          $media,
          $outputType,
          $localPath,
          $metadata,
        );
      }
      else {
        $remotePath = $this->generateRemotePathForMedia($media, $outputType, $localPath);
        $results[$outputType] = $this->uploadFile($resolvedLocalPath, $remotePath, $metadata);
        if ($results[$outputType]['success']) {
          $this->rememberRemotePathMapping($localPath, $remotePath);
          $this->rememberRemotePathMapping($resolvedLocalPath, $remotePath);
        }
      }

      if ($results[$outputType]['success']) {
        $this->logger->info('Uploaded @type to CDN for media @id: @url', [
          '@type' => $outputType,
          '@id' => $media->id(),
          '@url' => $results[$outputType]['url'],
        ]);
      }
      else {
        $this->logger->error('Failed to upload @type to CDN for media @id: @error', [
          '@type' => $outputType,
          '@id' => $media->id(),
          '@error' => $results[$outputType]['error'] ?? 'Unknown error',
        ]);
      }
    }

    return $results;
  }

  /**
   * Checks if a local path is a streaming manifest file.
   *
   * @param string $localPath
   *   Local file path.
   *
   * @return bool
   *   TRUE if the path is an HLS or DASH manifest.
   */
  protected function isStreamingManifest(string $localPath): bool {
    $extension = strtolower(pathinfo($localPath, PATHINFO_EXTENSION));
    return $extension === 'm3u8' || $extension === 'mpd';
  }

  /**
   * Uploads all files in a streaming output directory.
   *
   * Recursively uploads the manifest and all segment files (.ts, .m4s)
   * preserving the directory structure on the remote.
   *
   * @param \Drupal\media\MediaInterface $media
   *   The media entity.
   * @param string $outputType
   *   Output type (hls, dash).
   * @param string $manifestPath
   *   Local path to the manifest file.
   * @param array<string, string> $metadata
   *   Metadata for uploads.
   *
   * @return array{success: bool, url: string|null, error: string|null}
   *   Aggregate upload result. URL points to the manifest.
   */
  protected function uploadStreamingDirectory(
    MediaInterface $media,
    string $outputType,
    string $manifestPath,
    array $metadata,
  ): array {
    $directory = dirname($manifestPath);
    $resolvedDirectory = $this->resolveLocalPath($directory);
    $resolvedManifestPath = $this->resolveLocalPath($manifestPath);
    $files = $this->scanDirectory($resolvedDirectory);

    if ($files === []) {
      return [
        'success' => FALSE,
        'url' => NULL,
        'error' => "No files found in streaming directory: {$directory}",
      ];
    }

    $prefix = $this->getMediaPrefix($media);
    $failed = 0;
    $uploaded = 0;
    $manifestUrl = NULL;
    $baseLength = strlen(rtrim($resolvedDirectory, DIRECTORY_SEPARATOR));

    foreach ($files as $filePath) {
      // Build remote path preserving directory structure relative to base.
      $relativePath = substr($filePath, $baseLength + 1);
      $relativePath = str_replace(DIRECTORY_SEPARATOR, '/', $relativePath);
      $remotePath = "{$prefix}/{$outputType}/{$relativePath}";

      $result = $this->uploadFile($filePath, $remotePath, $metadata);

      if ($result['success']) {
        $uploaded++;
        $this->rememberRemotePathMapping($filePath, $remotePath);
        // Track the manifest URL as the main URL for this output.
        if ($filePath === $resolvedManifestPath) {
          $manifestUrl = $result['url'];
          $this->rememberRemotePathMapping($manifestPath, $remotePath);
        }
      }
      else {
        $failed++;
        $this->logger->error('Failed to upload streaming file @path: @error', [
          '@path' => $relativePath,
          '@error' => $result['error'] ?? 'Unknown error',
        ]);
      }
    }

    if ($failed > 0 || $manifestUrl === NULL) {
      $error = $failed > 0
        ? "{$failed} of " . ($uploaded + $failed) . ' files failed'
        : 'Streaming manifest upload did not return a URL';
      return [
        'success' => FALSE,
        'url' => $manifestUrl,
        'error' => $error,
      ];
    }

    $this->logger->info('Uploaded @count streaming files for @type (media @id), @failed failed.', [
      '@count' => $uploaded,
      '@type' => $outputType,
      '@id' => $media->id(),
      '@failed' => $failed,
    ]);

    return [
      'success' => TRUE,
      'url' => $manifestUrl,
      'error' => NULL,
    ];
  }

  /**
   * Recursively scans a directory for all files.
   *
   * @param string $directory
   *   Directory path.
   *
   * @return array<int, string>
   *   List of absolute file paths.
   */
  protected function scanDirectory(string $directory): array {
    if (!is_dir($directory)) {
      return [];
    }

    $files = [];
    $iterator = new \RecursiveIteratorIterator(
      new \RecursiveDirectoryIterator(
        $directory,
        \FilesystemIterator::SKIP_DOTS,
      ),
    );

    foreach ($iterator as $file) {
      if ($file->isFile()) {
        $files[] = $file->getPathname();
      }
    }

    return $files;
  }

  /**
   * {@inheritdoc}
   */
  abstract public function deleteMediaFiles(MediaInterface $media): int;

  /**
   * {@inheritdoc}
   */
  #[\Override]
  #[\NoDiscard]
  public function generateRemotePath(
    FileInterface $file,
    ?string $outputType = NULL,
  ): string {
    $prefix = $this->config['path_prefix'] ?? 'ffmpeg_media';
    $filename = basename($file->getFilename() ?? 'unknown');

    if ($outputType !== NULL) {
      $extension = pathinfo($filename, PATHINFO_EXTENSION);
      $basename = pathinfo($filename, PATHINFO_FILENAME);
      $filename = "{$basename}_{$outputType}.{$extension}";
    }

    return "{$prefix}/{$filename}";
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  #[\NoDiscard]
  public function rewriteUrl(string $localUrl): string {
    if (!$this->isUrlRewritingEnabled()) {
      return $localUrl;
    }

    $baseUrl = $this->getBaseUrl();
    if ($baseUrl === NULL) {
      return $localUrl;
    }

    // Extract the relative path from the local URL.
    $relativePath = $this->extractRelativePath($localUrl);
    if ($relativePath === NULL) {
      return $localUrl;
    }

    $mappedRemotePath = $this->getMappedRemotePath($relativePath);
    if ($mappedRemotePath !== NULL) {
      return $this->getCdnUrl($mappedRemotePath);
    }

    return rtrim($baseUrl, '/') . '/' . ltrim($relativePath, '/');
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  public function purgeCacheBatch(array $remotePaths): int {
    $purged = 0;
    foreach ($remotePaths as $path) {
      if ($this->purgeCache($path)) {
        $purged++;
      }
    }
    return $purged;
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  #[\NoDiscard]
  public function resolveRemotePath(string $uri): ?string {
    return $this->getMappedRemotePath($uri);
  }

  /**
   * Generates remote path for a media output.
   *
   * @param \Drupal\media\MediaInterface $media
   *   The media entity.
   * @param string $outputType
   *   Output type (mp4, webm, thumbnail, etc.).
   * @param string $localPath
   *   Local file path.
   *
   * @return string
   *   Remote path.
   */
  protected function generateRemotePathForMedia(
    MediaInterface $media,
    string $outputType,
    string $localPath,
  ): string {
    $prefix = $this->getMediaPrefix($media);
    $extension = pathinfo($localPath, PATHINFO_EXTENSION);

    return "{$prefix}/{$outputType}.{$extension}";
  }

  /**
   * Gets the CDN path prefix for a media entity.
   *
   * @param \Drupal\media\MediaInterface $media
   *   The media entity.
   *
   * @return string
   *   Path prefix.
   */
  protected function getMediaPrefix(MediaInterface $media): string {
    $basePrefix = $this->config['path_prefix'] ?? 'ffmpeg_media';
    $bundle = $media->bundle();
    $mediaId = $media->id();

    // Use date-based partitioning to distribute files.
    $date = date('Y/m');

    return "{$basePrefix}/{$bundle}/{$date}/{$mediaId}";
  }

  /**
   * Builds file metadata for CDN upload.
   *
   * @param \Drupal\media\MediaInterface $media
   *   The media entity.
   * @param string $outputType
   *   Output type.
   *
   * @return array<string, string>
   *   Metadata array.
   */
  protected function buildFileMetadata(
    MediaInterface $media,
    string $outputType,
  ): array {
    return [
      'media_id' => (string) $media->id(),
      'media_bundle' => $media->bundle(),
      'output_type' => $outputType,
      'uploaded_at' => date('c'),
    ];
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  #[\NoDiscard]
  public function generatePresignedUrl(
    string $remotePath,
    int $expiresIn = 3600,
  ): string {
    throw new \LogicException(
      'Presigned URLs are not supported by this CDN provider.',
    );
  }

  /**
   * Creates a copy with overridden configuration for test-connection.
   *
   * Used by settings forms to validate connectivity without saving config.
   *
   * @param array<string, mixed> $config
   *   Configuration to use instead of stored configuration.
   *
   * @return static
   *   New instance with the overridden configuration.
   */
  #[\NoDiscard]
  public function withTestConfig(array $config): static {
    $clone = clone $this;
    $clone->config = $config;
    return $clone;
  }

  /**
   * Checks if URL rewriting is enabled.
   *
   * @return bool
   *   TRUE if URL rewriting is enabled.
   */
  protected function isUrlRewritingEnabled(): bool {
    return (bool) ($this->config['url_rewriting'] ?? FALSE);
  }

  /**
   * Gets the CDN base URL for URL rewriting.
   *
   * @return string|null
   *   Base URL or NULL if not configured.
   */
  protected function getBaseUrl(): ?string {
    return $this->config['cdn_url'] ?? NULL;
  }

  /**
   * Extracts relative path from a local URL.
   *
   * @param string $localUrl
   *   Local URL.
   *
   * @return string|null
   *   Relative path or NULL if extraction failed.
   */
  protected function extractRelativePath(string $localUrl): ?string {
    // Handle stream wrapper URIs (public://ffmpeg_media/...).
    if (preg_match('#^(public|private)://(.+)$#', $localUrl, $matches)) {
      return $matches[2];
    }

    // Remove scheme and domain if present.
    $parsed = parse_url($localUrl);
    if ($parsed === FALSE || !isset($parsed['path'])) {
      return NULL;
    }

    $path = $parsed['path'];

    // Handle absolute local paths under Drupal file roots.
    $relativeFromRoot = $this->extractRelativePathFromFileRoot($path);
    if ($relativeFromRoot !== NULL) {
      return $relativeFromRoot;
    }

    // Remove common Drupal paths.
    $patterns = [
      '/^\/sites\/default\/files\//',
      '/^\/system\/files\//',
    ];

    foreach ($patterns as $pattern) {
      if (preg_match($pattern, $path)) {
        return preg_replace($pattern, '', $path);
      }
    }

    return $path;
  }

  /**
   * Maps an output path to the remote CDN path for deterministic rewrites.
   *
   * @param string $localPath
   *   Local output path/URI/URL.
   * @param string $remotePath
   *   Remote CDN key.
   */
  protected function rememberRemotePathMapping(
    string $localPath,
    string $remotePath,
  ): void {
    $relativePath = $this->extractRelativePath($localPath);
    if ($relativePath === NULL || $relativePath === '') {
      return;
    }

    try {
      $this->cdnPathMap->set(
        $this->buildPathMapKey($relativePath),
        ltrim($remotePath, '/'),
      );
    }
    catch (\Throwable $e) {
      $this->logger->warning('Failed to persist CDN path mapping for @path: @error', [
        '@path' => $relativePath,
        '@error' => $e->getMessage(),
      ]);
    }
  }

  /**
   * Resolves a mapped remote CDN path for a local output path.
   *
   * @param string $localUrl
   *   Local path/URI/URL or relative output path.
   *
   * @return string|null
   *   Remote CDN key, or NULL when unknown.
   */
  protected function getMappedRemotePath(string $localUrl): ?string {
    $relativePath = $this->extractRelativePath($localUrl) ?? $localUrl;
    $relativePath = ltrim($relativePath, '/');
    if ($relativePath === '') {
      return NULL;
    }

    $value = $this->cdnPathMap->get($this->buildPathMapKey($relativePath));
    return is_string($value) && $value !== '' ? $value : NULL;
  }

  /**
   * Builds a stable key for the CDN path map.
   *
   * @param string $relativePath
   *   Relative output path.
   *
   * @return string
   *   Key-value storage key.
   */
  protected function buildPathMapKey(string $relativePath): string {
    return 'p:' . hash('sha256', ltrim($relativePath, '/'));
  }

  /**
   * Resolves stream wrapper URIs to local filesystem paths.
   *
   * @param string $path
   *   Stream URI or path.
   *
   * @return string
   *   Resolved local path, or original value if unresolved.
   */
  protected function resolveLocalPath(string $path): string {
    if (!str_contains($path, '://')) {
      return $path;
    }

    $resolvedPath = $this->fileSystem->realpath($path);
    return is_string($resolvedPath) ? $resolvedPath : $path;
  }

  /**
   * Extracts relative output path from an absolute file-system path.
   *
   * @param string $path
   *   Absolute local path.
   *
   * @return string|null
   *   Relative path when under public/private files, NULL otherwise.
   */
  protected function extractRelativePathFromFileRoot(string $path): ?string {
    $roots = [
      $this->fileSystem->realpath('public://'),
      $this->fileSystem->realpath('private://'),
    ];

    foreach ($roots as $root) {
      if (!is_string($root) || $root === '') {
        continue;
      }
      $normalizedRoot = rtrim($root, DIRECTORY_SEPARATOR);
      $prefix = $normalizedRoot . DIRECTORY_SEPARATOR;
      if (str_starts_with($path, $prefix)) {
        $relativePath = substr($path, strlen($prefix));
        return str_replace(DIRECTORY_SEPARATOR, '/', $relativePath);
      }
    }

    return NULL;
  }

  /**
   * Gets the content type for a file.
   *
   * @param string $filePath
   *   File path.
   *
   * @return string
   *   MIME type.
   */
  protected function getContentType(string $filePath): string {
    $extension = $filePath
      |> (fn(string $p): string => pathinfo($p, PATHINFO_EXTENSION))
      |> strtolower(...);

    $mimeTypes = [
      'mp4' => 'video/mp4',
      'webm' => 'video/webm',
      'mkv' => 'video/x-matroska',
      'flv' => 'video/x-flv',
      'jpg' => 'image/jpeg',
      'jpeg' => 'image/jpeg',
      'png' => 'image/png',
      'gif' => 'image/gif',
      'webp' => 'image/webp',
      'jxl' => 'image/jxl',
      'vtt' => 'text/vtt',
      'srt' => 'text/plain',
      'm3u8' => 'application/x-mpegURL',
      'mpd' => 'application/dash+xml',
      'ts' => 'video/MP2T',
      'm4s' => 'video/iso.segment',
      'json' => 'application/json',
      'yaml' => 'text/yaml',
      'yml' => 'text/yaml',
    ];

    return $mimeTypes[$extension] ?? 'application/octet-stream';
  }

}
