<?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\State\StateInterface;
use Drupal\media\MediaInterface;
use Psr\Log\LoggerInterface;

/**
 * S3-compatible CDN integration.
 *
 * Supports AWS S3, CloudFront, MinIO, DigitalOcean Spaces,
 * Backblaze B2, Wasabi, and other S3-compatible services.
 *
 * Delegates request signing/transport to S3RequestSigner and
 * multipart uploads to S3MultipartUploader.
 */
final class S3CdnIntegration extends CdnIntegrationBase {

  /**
   * Default region.
   */
  protected const string DEFAULT_REGION = 'us-east-1';

  /**
   * Multipart upload threshold (100 MB).
   */
  protected const int MULTIPART_THRESHOLD = 104857600;

  /**
   * The S3 request signer.
   */
  private S3RequestSignerInterface $signer;

  /**
   * Constructs an S3CdnIntegration 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(
    ConfigFactoryInterface $configFactory,
    FileSystemInterface $fileSystem,
    LoggerInterface $logger,
    KeyValueFactoryInterface $keyValueFactory,
    StateInterface $state,
    ?object $keyRepository = NULL,
  ) {
    parent::__construct($configFactory, $fileSystem, $logger, $keyValueFactory, $state, $keyRepository);
    $this->signer = new S3RequestSigner($this->config, $this->logger);
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  #[\NoDiscard]
  public function withTestConfig(array $config): static {
    $clone = parent::withTestConfig($config);
    $clone->signer = new S3RequestSigner($clone->config, $this->logger);
    return $clone;
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  public function getProviderId(): string {
    return 's3';
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  public function getProviderLabel(): string {
    return 'S3-Compatible Storage';
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  #[\NoDiscard]
  public function validateConfiguration(): array {
    $errors = [];

    if (empty($this->config['bucket'])) {
      $errors[] = 'S3 bucket name is required.';
    }

    if (empty($this->config['access_key'])) {
      $errors[] = 'S3 access key is required.';
    }

    if (empty($this->config['secret_key'])) {
      $errors[] = 'S3 secret key is required.';
    }

    if (empty($this->config['region']) && empty($this->config['endpoint'])) {
      $errors[] = 'Either S3 region or custom endpoint is required.';
    }

    // Validate endpoint URL format if provided.
    if (!empty($this->config['endpoint'])) {
      $endpointError = EndpointValidator::validatePublicHttpsEndpoint((string) $this->config['endpoint']);
      if ($endpointError !== NULL) {
        $errors[] = match ($endpointError) {
          EndpointValidator::ERROR_INVALID_FORMAT => 'Invalid S3 endpoint URL format.',
          EndpointValidator::ERROR_INSECURE_SCHEME => 'S3 endpoint must use HTTPS.',
          EndpointValidator::ERROR_NON_PUBLIC_HOST => 'S3 endpoint must use a public host. Localhost and private or link-local IP addresses are not allowed.',
          default => 'Invalid S3 endpoint URL format.',
        };
      }
    }

    // Test connectivity if configuration is valid.
    if ($errors === []) {
      $connectivityTest = $this->testConnectivity();
      if (!$connectivityTest['success']) {
        $errors[] = $connectivityTest['error'] ?? 'Failed to connect to S3.';
      }
    }

    return [
      'valid' => $errors === [],
      'errors' => $errors,
    ];
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  public function uploadFile(
    string $localPath,
    string $remotePath,
    array $metadata = [],
  ): array {
    if (!file_exists($localPath)) {
      return [
        'success' => FALSE,
        'url' => NULL,
        'error' => "Local file not found: {$localPath}",
      ];
    }

    try {
      $contentLength = filesize($localPath);
      if ($contentLength === FALSE) {
        return [
          'success' => FALSE,
          'url' => NULL,
          'error' => "Failed to read file size: {$localPath}",
        ];
      }

      // Use multipart upload for large files.
      if ($contentLength > self::MULTIPART_THRESHOLD) {
        $uploader = new S3MultipartUploader(
          $this->signer,
          $this->logger,
          $this->getCdnUrl($remotePath),
          $this->config['acl'] ?? 'private',
        );
        return $uploader->upload(
          $localPath,
          $remotePath,
          $metadata,
          $contentLength,
        );
      }

      return $this->singleUpload(
        $localPath,
        $remotePath,
        $metadata,
        $contentLength,
      );
    }
    catch (\Throwable $e) {
      $this->logger->error('S3 upload exception: @message', [
        '@message' => $e->getMessage(),
      ]);
      return [
        'success' => FALSE,
        'url' => NULL,
        'error' => $e->getMessage(),
      ];
    }
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  public function deleteFile(string $remotePath): bool {
    try {
      $headers = $this->signer->signRequest('DELETE', $remotePath);
      $response = $this->signer->makeRequest(
        'DELETE',
        $remotePath,
        $headers,
      );

      return $response['status'] === 204
        || $response['status'] === 200;
    }
    catch (\Throwable $e) {
      $this->logger->error('S3 delete exception: @message', [
        '@message' => $e->getMessage(),
      ]);
      return FALSE;
    }
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  public function deleteMediaFiles(MediaInterface $media): int {
    $prefix = $this->getMediaPrefix($media);
    $deleted = 0;

    try {
      $objects = $this->listObjects($prefix);
      foreach ($objects as $key) {
        if ($this->deleteFile($key)) {
          $deleted++;
        }
      }

      $this->logger->info(
        'Deleted @count CDN files for media @id.',
        ['@count' => $deleted, '@id' => $media->id()],
      );
    }
    catch (\Throwable $e) {
      $this->logger->error(
        'Failed to delete CDN files for media @id: @error',
        ['@id' => $media->id(), '@error' => $e->getMessage()],
      );
    }

    return $deleted;
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  #[\NoDiscard]
  public function getCdnUrl(string $remotePath): string {
    $cdnUrl = $this->config['cdn_url'] ?? NULL;
    if ($cdnUrl !== NULL && $cdnUrl !== '') {
      return rtrim($cdnUrl, '/') . '/' . ltrim($remotePath, '/');
    }

    return $this->signer->buildUrl($remotePath);
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  #[\NoDiscard]
  public function fileExists(string $remotePath): bool {
    try {
      $headers = $this->signer->signRequest('HEAD', $remotePath);
      $response = $this->signer->makeRequest(
        'HEAD',
        $remotePath,
        $headers,
      );

      return $response['status'] === 200;
    }
    catch (\Throwable) {
      return FALSE;
    }
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  #[\NoDiscard]
  public function getFileMetadata(string $remotePath): ?array {
    try {
      $headers = $this->signer->signRequest('HEAD', $remotePath);
      $response = $this->signer->makeRequest(
        'HEAD',
        $remotePath,
        $headers,
      );

      if ($response['status'] !== 200) {
        return NULL;
      }

      return [
        'content_type' => $response['headers']['content-type'] ?? NULL,
        'content_length' => (int) ($response['headers']['content-length'] ?? 0),
        'last_modified' => $response['headers']['last-modified'] ?? NULL,
        'etag' => $response['headers']['etag'] ?? NULL,
      ];
    }
    catch (\Throwable) {
      return NULL;
    }
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  public function purgeCache(string $remotePath): bool {
    $distributionId = $this->config['cloudfront_distribution_id'] ?? NULL;
    if ($distributionId === NULL || $distributionId === '') {
      return TRUE;
    }

    try {
      $this->signer->createCloudFrontInvalidation(
        $distributionId,
        ['/' . ltrim($remotePath, '/')],
      );
      return TRUE;
    }
    catch (\Throwable $e) {
      $this->logger->error('CloudFront invalidation failed: @message', [
        '@message' => $e->getMessage(),
      ]);
      return FALSE;
    }
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  #[\NoDiscard]
  public function getUsageStats(): array {
    $prefix = $this->config['path_prefix'] ?? 'ffmpeg_media';

    try {
      $objects = $this->listObjects($prefix);
      return [
        'total_files' => count($objects),
        'total_size_bytes' => 0,
        'bandwidth_bytes' => NULL,
      ];
    }
    catch (\Throwable) {
      return [
        'total_files' => 0,
        'total_size_bytes' => 0,
        'bandwidth_bytes' => NULL,
      ];
    }
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  #[\NoDiscard]
  public function generatePresignedUrl(
    string $remotePath,
    int $expiresIn = 3600,
  ): string {
    return $this->signer->generatePresignedUrl($remotePath, $expiresIn);
  }

  /**
   * Tests connectivity to S3.
   *
   * @return array{success: bool, error: string|null}
   *   Test result.
   */
  protected function testConnectivity(): array {
    try {
      $headers = $this->signer->signRequest('HEAD', '');
      $response = $this->signer->makeRequest('HEAD', '', $headers);

      if ($response['status'] === 200
        || $response['status'] === 403) {
        return ['success' => TRUE, 'error' => NULL];
      }

      if ($response['status'] === 404) {
        return ['success' => FALSE, 'error' => 'Bucket not found.'];
      }

      return [
        'success' => FALSE,
        'error' => "Unexpected status: {$response['status']}",
      ];
    }
    catch (\Throwable $e) {
      return ['success' => FALSE, 'error' => $e->getMessage()];
    }
  }

  /**
   * Single-part upload for files under the multipart threshold.
   *
   * @param string $localPath
   *   Local file path.
   * @param string $remotePath
   *   Remote path.
   * @param array<string, string> $metadata
   *   File metadata.
   * @param int $contentLength
   *   File size in bytes.
   *
   * @return array{success: bool, url: string|null, error: string|null}
   *   Upload result.
   */
  protected function singleUpload(
    string $localPath,
    string $remotePath,
    array $metadata,
    int $contentLength,
  ): array {
    $contentType = $this->getContentType($localPath);

    $payloadHash = hash_file('sha256', $localPath);
    if ($payloadHash === FALSE) {
      return [
        'success' => FALSE,
        'url' => NULL,
        'error' => "Failed to hash file: {$localPath}",
      ];
    }

    $stream = fopen($localPath, 'rb');
    if ($stream === FALSE) {
      return [
        'success' => FALSE,
        'url' => NULL,
        'error' => "Failed to open file: {$localPath}",
      ];
    }

    try {
      $headers = $this->signer->signUploadRequest(
        $remotePath,
        $contentType,
        $metadata,
        $payloadHash,
        $contentLength,
      );
      $response = $this->signer->makeRequest(
        'PUT',
        $remotePath,
        $headers,
        $stream,
        $contentLength,
      );
    }
    finally {
      fclose($stream);
    }

    if ($response['status'] >= 200 && $response['status'] < 300) {
      return [
        'success' => TRUE,
        'url' => $this->getCdnUrl($remotePath),
        'error' => NULL,
      ];
    }

    $code = $response['status'];
    $body = mb_substr($response['body'], 0, 500);
    return [
      'success' => FALSE,
      'url' => NULL,
      'error' => "S3 upload failed ({$code}): {$body}",
    ];
  }

  /**
   * Lists objects with a given prefix.
   *
   * @param string $prefix
   *   Key prefix.
   *
   * @return array<int, string>
   *   Object keys.
   */
  protected function listObjects(string $prefix): array {
    $queryString = 'list-type=2&prefix=' . urlencode($prefix);

    $headers = $this->signer->signRequest(
      'GET',
      '',
      hash('sha256', ''),
      $queryString,
    );

    $response = $this->signer->makeRequest(
      'GET',
      '?' . $queryString,
      $headers,
    );

    if ($response['status'] !== 200) {
      return [];
    }

    // Parse keys from XML response.
    $previousUseErrors = libxml_use_internal_errors(TRUE);
    $xml = simplexml_load_string(
      $response['body'],
      'SimpleXMLElement',
      LIBXML_NONET,
    );
    $xmlErrors = libxml_get_errors();
    libxml_clear_errors();
    libxml_use_internal_errors($previousUseErrors);

    if ($xml === FALSE) {
      $errorMessages = array_map(
        static fn(\LibXMLError $e): string => trim($e->message),
        $xmlErrors,
      );
      $this->logger->error('Failed to parse S3 list objects XML: @errors', [
        '@errors' => implode('; ', $errorMessages),
      ]);
      return [];
    }

    $keys = [];
    if (isset($xml->Contents)) {
      foreach ($xml->Contents as $content) {
        $key = (string) $content->Key;
        if ($key !== '') {
          $keys[] = $key;
        }
      }
    }

    return $keys;
  }

}
