<?php

declare(strict_types=1);

namespace Drupal\ffmpeg_media\Cdn;

use Psr\Log\LoggerInterface;

/**
 * Handles chunked multipart uploads to S3-compatible storage.
 *
 * Not a Drupal service - instantiated by S3CdnIntegration.
 */
final class S3MultipartUploader implements S3MultipartUploaderInterface {

  /**
   * Multipart upload part size (10 MB).
   */
  private const int PART_SIZE = 10485760;

  /**
   * Constructs an S3MultipartUploader.
   *
   * @param \Drupal\ffmpeg_media\Cdn\S3RequestSignerInterface $signer
   *   Request signer for S3 API calls.
   * @param \Psr\Log\LoggerInterface $logger
   *   Logger.
   * @param string $cdnUrl
   *   CDN URL callback for generating public URLs.
   * @param string $acl
   *   S3 ACL for uploaded objects (e.g., 'private', 'public-read').
   */
  public function __construct(
    private readonly S3RequestSignerInterface $signer,
    private readonly LoggerInterface $logger,
    private readonly string $cdnUrl,
    private readonly string $acl = 'private',
  ) {}

  /**
   * {@inheritdoc}
   */
  #[\Override]
  public function upload(
    string $localPath,
    string $remotePath,
    array $metadata,
    int $contentLength,
  ): array {
    $contentType = $this->getContentType($localPath);

    // Step 1: Initiate multipart upload.
    $uploadId = $this->initiateMultipartUpload(
      $remotePath,
      $contentType,
      $metadata,
    );

    if ($uploadId === NULL) {
      return [
        'success' => FALSE,
        'url' => NULL,
        'error' => 'Failed to initiate multipart upload.',
      ];
    }

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

    // Step 2: Upload parts.
    $parts = [];
    $partNumber = 1;
    try {
      while (!feof($stream)) {
        $partData = fread($stream, self::PART_SIZE);
        if ($partData === FALSE || $partData === '') {
          break;
        }

        $etag = $this->uploadPart(
          $remotePath,
          $uploadId,
          $partNumber,
          $partData,
        );

        if ($etag === NULL) {
          throw new \RuntimeException(
            "Failed to upload part {$partNumber}.",
          );
        }

        $parts[] = [
          'PartNumber' => $partNumber,
          'ETag' => $etag,
        ];
        $partNumber++;
      }
    }
    catch (\Throwable $e) {
      fclose($stream);
      $this->abortMultipartUpload($remotePath, $uploadId);
      return [
        'success' => FALSE,
        'url' => NULL,
        'error' => $e->getMessage(),
      ];
    }
    finally {
      if (is_resource($stream)) {
        fclose($stream);
      }
    }

    // Step 3: Complete multipart upload.
    $success = $this->completeMultipartUpload(
      $remotePath,
      $uploadId,
      $parts,
    );

    if ($success) {
      $this->logger->info(
        'Multipart upload completed: @path (@parts parts, @size bytes)',
        [
          '@path' => $remotePath,
          '@parts' => count($parts),
          '@size' => $contentLength,
        ],
      );
      return [
        'success' => TRUE,
        'url' => $this->cdnUrl,
        'error' => NULL,
      ];
    }

    return [
      'success' => FALSE,
      'url' => NULL,
      'error' => 'Failed to complete multipart upload.',
    ];
  }

  /**
   * Initiates a multipart upload.
   *
   * @param string $remotePath
   *   Remote path.
   * @param string $contentType
   *   Content type.
   * @param array<string, string> $metadata
   *   File metadata.
   *
   * @return string|null
   *   Upload ID or NULL on failure.
   */
  private function initiateMultipartUpload(
    string $remotePath,
    string $contentType,
    array $metadata,
  ): ?string {
    $headers = $this->signer->signRequest(
      'POST',
      $remotePath,
      hash('sha256', ''),
      'uploads=',
    );
    $headers['Content-Type'] = $contentType;
    $headers['Content-Disposition'] = 'inline';
    $headers['Cache-Control'] = 'public, max-age=31536000';

    $headers['x-amz-acl'] = $this->acl;

    foreach ($metadata as $key => $value) {
      $headers["x-amz-meta-{$key}"] = $value;
    }

    $response = $this->signer->makeRequest(
      'POST',
      $remotePath . '?uploads=',
      $headers,
    );

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

    // Parse UploadId 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 multipart initiation XML: @errors', [
        '@errors' => implode('; ', $errorMessages),
      ]);
      return NULL;
    }

    $uploadId = (string) $xml->UploadId;
    return $uploadId !== '' ? $uploadId : NULL;
  }

  /**
   * Uploads a single part of a multipart upload.
   *
   * @param string $remotePath
   *   Remote path.
   * @param string $uploadId
   *   Upload ID.
   * @param int $partNumber
   *   Part number (1-based).
   * @param string $data
   *   Part data.
   *
   * @return string|null
   *   ETag or NULL on failure.
   */
  private function uploadPart(
    string $remotePath,
    string $uploadId,
    int $partNumber,
    string $data,
  ): ?string {
    $payloadHash = hash('sha256', $data);
    $queryString = "partNumber={$partNumber}&uploadId="
      . urlencode($uploadId);

    $headers = $this->signer->signRequest(
      'PUT',
      $remotePath,
      $payloadHash,
      $queryString,
    );
    $headers['Content-Length'] = (string) strlen($data);

    $response = $this->signer->makeRequest(
      'PUT',
      $remotePath . '?' . $queryString,
      $headers,
      $data,
      strlen($data),
    );

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

    return NULL;
  }

  /**
   * Completes a multipart upload.
   *
   * @param string $remotePath
   *   Remote path.
   * @param string $uploadId
   *   Upload ID.
   * @param array<int, array{PartNumber: int, ETag: string}> $parts
   *   Uploaded parts.
   *
   * @return bool
   *   TRUE if completed successfully.
   */
  private function completeMultipartUpload(
    string $remotePath,
    string $uploadId,
    array $parts,
  ): bool {
    $xml = '<CompleteMultipartUpload>';
    foreach ($parts as $part) {
      $escapedETag = htmlspecialchars($part['ETag'], ENT_XML1, 'UTF-8');
      $xml .= '<Part>'
        . "<PartNumber>{$part['PartNumber']}</PartNumber>"
        . "<ETag>{$escapedETag}</ETag>"
        . '</Part>';
    }
    $xml .= '</CompleteMultipartUpload>';

    $payloadHash = hash('sha256', $xml);
    $queryString = 'uploadId=' . urlencode($uploadId);

    $headers = $this->signer->signRequest(
      'POST',
      $remotePath,
      $payloadHash,
      $queryString,
    );
    $headers['Content-Type'] = 'application/xml';
    $headers['Content-Length'] = (string) strlen($xml);

    $response = $this->signer->makeRequest(
      'POST',
      $remotePath . '?' . $queryString,
      $headers,
      $xml,
      strlen($xml),
    );

    return $response['status'] === 200;
  }

  /**
   * Aborts a multipart upload.
   *
   * @param string $remotePath
   *   Remote path.
   * @param string $uploadId
   *   Upload ID.
   */
  private function abortMultipartUpload(
    string $remotePath,
    string $uploadId,
  ): void {
    $maxAttempts = 3;
    $attempt = 0;

    while ($attempt < $maxAttempts) {
      try {
        $queryString = 'uploadId=' . urlencode($uploadId);
        $headers = $this->signer->signRequest(
          'DELETE',
          $remotePath,
          hash('sha256', ''),
          $queryString,
        );

        $this->signer->makeRequest(
          'DELETE',
          $remotePath . '?' . $queryString,
          $headers,
        );

        return;
      }
      catch (\Throwable $e) {
        $attempt++;
        if ($attempt >= $maxAttempts) {
          $this->logger->warning(
            'Failed to abort multipart upload after @attempts attempts for @path (upload ID: @id). Orphaned parts will accumulate storage costs. Configure an S3 lifecycle rule to auto-abort incomplete uploads (AbortIncompleteMultipartUpload with DaysAfterInitiation: 1).',
            [
              '@attempts' => $maxAttempts,
              '@path' => $remotePath,
              '@id' => $uploadId,
            ],
          );

          return;
        }

        // Brief pause before retry.
        usleep(500_000);
      }
    }
  }

  /**
   * Gets the content type for a file.
   *
   * @param string $filePath
   *   File path.
   *
   * @return string
   *   MIME type.
   */
  private 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';
  }

}
