<?php

declare(strict_types=1);

namespace Drupal\ffmpeg_media\Cdn;

use Psr\Log\LoggerInterface;

/**
 * AWS Signature V4 request signing and S3 HTTP transport.
 *
 * Handles signing, URL construction, HTTP transport, presigned URLs,
 * and CloudFront invalidation for S3-compatible storage providers.
 *
 * Not a Drupal service - instantiated by S3CdnIntegration with config.
 */
final class S3RequestSigner implements S3RequestSignerInterface {

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

  /**
   * Default presigned URL expiration (1 hour).
   */
  private const int PRESIGNED_URL_EXPIRATION = 3600;

  /**
   * Maximum S3 API response size (10 MB).
   *
   * Prevents unbounded memory consumption from unexpected large responses.
   * Upload responses and CloudFront invalidation responses are small XML;
   * this limit is generous to accommodate S3 list responses.
   */
  private const int MAX_RESPONSE_SIZE = 10 * 1024 * 1024;

  /**
   * Constructs an S3RequestSigner.
   *
   * @param array<string, mixed> $config
   *   S3 configuration (bucket, access_key, secret_key, region, endpoint,
   *   path_style, acl).
   * @param \Psr\Log\LoggerInterface $logger
   *   Logger.
   */
  public function __construct(
    private readonly array $config,
    private readonly LoggerInterface $logger,
  ) {}

  /**
   * {@inheritdoc}
   */
  #[\Override]
  public function makeRequest(
    string $method,
    string $path,
    array $headers,
    mixed $body = NULL,
    ?int $contentLength = NULL,
  ): array {
    $url = $this->buildUrl($path);

    if ($url === '' || $method === '') {
      throw new \InvalidArgumentException(
        'URL and method must not be empty',
      );
    }

    $ch = curl_init();
    if ($ch === FALSE) {
      throw new \RuntimeException('Failed to initialize cURL handle.');
    }

    try {
      // TLS/protocol hardening: enforce certificate verification and HTTPS.
      curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, TRUE);
      curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
      curl_setopt($ch, CURLOPT_PROTOCOLS_STR, 'https');

      // Response size limit to prevent unbounded memory consumption.
      curl_setopt($ch, CURLOPT_MAXFILESIZE, self::MAX_RESPONSE_SIZE);

      curl_setopt($ch, CURLOPT_URL, $url);
      curl_setopt($ch, CURLOPT_RETURNTRANSFER, TRUE);
      curl_setopt($ch, CURLOPT_HEADER, TRUE);
      curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
      curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 30);
      curl_setopt($ch, CURLOPT_TIMEOUT, 300);

      $headerStrings = [];
      foreach ($headers as $name => $value) {
        $headerStrings[] = "{$name}: {$value}";
      }
      curl_setopt($ch, CURLOPT_HTTPHEADER, $headerStrings);

      if ($body !== NULL) {
        if (is_resource($body)) {
          curl_setopt($ch, CURLOPT_UPLOAD, TRUE);
          curl_setopt($ch, CURLOPT_INFILE, $body);
          if ($contentLength !== NULL) {
            curl_setopt($ch, CURLOPT_INFILESIZE, $contentLength);
          }
        }
        elseif (is_string($body) || is_array($body)) {
          curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
        }
      }

      $response = curl_exec($ch);
      $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
      $headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);

      if ($response === FALSE) {
        $errorCode = curl_errno($ch);
        $errorDetail = curl_error($ch);
        $this->logger->error('S3 cURL request failed for @method @url: @error (code: @code)', [
          '@method' => $method,
          '@url' => $url,
          '@error' => $errorDetail,
          '@code' => $errorCode,
        ]);
        throw new \RuntimeException("CDN request failed (code: {$errorCode})");
      }

      $responseString = (string) $response;
      $responseHeaders = substr($responseString, 0, $headerSize);
      $responseBody = substr($responseString, $headerSize);

      return [
        'status' => $httpCode,
        'headers' => $this->parseResponseHeaders($responseHeaders),
        'body' => $responseBody,
      ];
    }
    finally {
      unset($ch);
    }
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  #[\NoDiscard]
  public function signRequest(
    string $method,
    string $path,
    ?string $payloadHash = NULL,
    string $queryString = '',
  ): array {
    return $this->signRequestWithHeaders($method, $path, [], $payloadHash, $queryString);
  }

  /**
   * Signs a request with additional headers included in the signature.
   *
   * @param string $method
   *   HTTP method.
   * @param string $path
   *   S3 object path.
   * @param array<string, string> $extraHeaders
   *   Additional headers to include in the signature.
   * @param string|null $payloadHash
   *   SHA-256 hash of the payload, or NULL for empty payload.
   * @param string $queryString
   *   Canonical query string.
   *
   * @return array<string, string>
   *   Signed headers including Authorization.
   */
  private function signRequestWithHeaders(
    string $method,
    string $path,
    array $extraHeaders = [],
    ?string $payloadHash = NULL,
    string $queryString = '',
  ): array {
    ['access_key' => $accessKey, 'secret_key' => $secretKey] = $this->requireCredentials();
    $region = $this->config['region'] ?? self::DEFAULT_REGION;
    $service = 's3';

    $timestamp = gmdate('Ymd\THis\Z');
    $date = gmdate('Ymd');

    $host = $this->getS3Host();

    // Strip query string from path for canonical URI.
    $pathOnly = explode('?', $path)[0];
    $segments = explode('/', ltrim($pathOnly, '/'));
    $canonicalUri = '/' . implode('/', array_map('rawurlencode', $segments));
    $payloadHash ??= hash('sha256', '');

    $headers = [
      'Host' => $host,
      'x-amz-date' => $timestamp,
      'x-amz-content-sha256' => $payloadHash,
    ];

    $headers = array_merge($headers, $extraHeaders);

    // Build canonical headers.
    $canonical = $this->buildCanonicalHeaders($headers);
    $signedHeaders = $canonical['signed'];

    // Create canonical request.
    $canonicalRequest = implode("\n", [
      $method,
      $canonicalUri,
      $queryString,
      $canonical['canonical'],
      $signedHeaders,
      $payloadHash,
    ]);

    // Create string to sign.
    $algorithm = 'AWS4-HMAC-SHA256';
    $credentialScope = "{$date}/{$region}/{$service}/aws4_request";
    $stringToSign = implode("\n", [
      $algorithm,
      $timestamp,
      $credentialScope,
      hash('sha256', $canonicalRequest),
    ]);

    // Calculate signature.
    $signingKey = $this->deriveSigningKey($date, $region, $service, $secretKey);
    $signature = hash_hmac('sha256', $stringToSign, $signingKey);

    $authorization = "{$algorithm} "
      . "Credential={$accessKey}/{$credentialScope}, "
      . "SignedHeaders={$signedHeaders}, "
      . "Signature={$signature}";
    $headers['Authorization'] = $authorization;

    return $headers;
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  #[\NoDiscard]
  public function signUploadRequest(
    string $path,
    string $contentType,
    array $metadata,
    string $payloadHash,
    int $contentLength,
  ): array {
    $extraHeaders = [
      'Content-Type' => $contentType,
      'Content-Length' => (string) $contentLength,
      'Content-Disposition' => 'inline',
      'Cache-Control' => 'public, max-age=31536000',
    ];

    foreach ($metadata as $key => $value) {
      // Only allow safe characters in S3 metadata header names.
      if (!preg_match('/^[a-zA-Z0-9_\-]+$/', (string) $key)) {
        continue;
      }
      $extraHeaders["x-amz-meta-{$key}"] = str_replace(["\r", "\n"], '', (string) $value);
    }

    $acl = $this->config['acl'] ?? 'private';
    $extraHeaders['x-amz-acl'] = $acl;

    return $this->signRequestWithHeaders('PUT', $path, $extraHeaders, $payloadHash);
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  #[\NoDiscard]
  public function buildUrl(string $path): string {
    $bucket = $this->config['bucket'];
    $region = $this->config['region'] ?? self::DEFAULT_REGION;
    $endpoint = $this->config['endpoint'] ?? NULL;
    $pathStyle = (bool) ($this->config['path_style'] ?? FALSE);

    if ($endpoint !== NULL) {
      $base = rtrim($endpoint, '/');
      if ($pathStyle) {
        return "{$base}/{$bucket}/" . ltrim($path, '/');
      }
      $parsed = parse_url($endpoint);
      $scheme = $parsed['scheme'] ?? 'https';
      $host = $parsed['host'] ?? '';
      return "{$scheme}://{$bucket}.{$host}/"
        . ltrim($path, '/');
    }

    return "https://{$bucket}.s3.{$region}.amazonaws.com/"
      . ltrim($path, '/');
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  #[\NoDiscard]
  public function generatePresignedUrl(
    string $remotePath,
    int $expiresIn = self::PRESIGNED_URL_EXPIRATION,
  ): string {
    ['access_key' => $accessKey, 'secret_key' => $secretKey] = $this->requireCredentials();
    $region = $this->config['region'] ?? self::DEFAULT_REGION;
    $service = 's3';

    if ($expiresIn < 1 || $expiresIn > 604800) {
      throw new \InvalidArgumentException('Presigned URL expiration must be between 1 and 604800 seconds (7 days).');
    }

    $timestamp = gmdate('Ymd\THis\Z');
    $date = gmdate('Ymd');
    $host = $this->getS3Host();
    $segments = explode('/', ltrim($remotePath, '/'));
    $canonicalUri = '/' . implode('/', array_map('rawurlencode', $segments));

    $credentialScope = "{$date}/{$region}/{$service}/aws4_request";
    $credential = "{$accessKey}/{$credentialScope}";

    // Query parameters for presigned URL.
    $queryParams = [
      'X-Amz-Algorithm' => 'AWS4-HMAC-SHA256',
      'X-Amz-Credential' => $credential,
      'X-Amz-Date' => $timestamp,
      'X-Amz-Expires' => (string) $expiresIn,
      'X-Amz-SignedHeaders' => 'host',
    ];
    ksort($queryParams);
    $canonicalQueryString = http_build_query($queryParams, '', '&', PHP_QUERY_RFC3986);

    // Build canonical request.
    $canonicalHeaders = "host:{$host}\n";
    $canonicalRequest = implode("\n", [
      'GET',
      $canonicalUri,
      $canonicalQueryString,
      $canonicalHeaders,
      'host',
      'UNSIGNED-PAYLOAD',
    ]);

    // String to sign.
    $stringToSign = implode("\n", [
      'AWS4-HMAC-SHA256',
      $timestamp,
      $credentialScope,
      hash('sha256', $canonicalRequest),
    ]);

    // Signing key derivation.
    $signingKey = $this->deriveSigningKey($date, $region, $service, $secretKey);
    $signature = hash_hmac('sha256', $stringToSign, $signingKey);

    $url = $this->buildUrl($remotePath);
    $separator = str_contains($url, '?') ? '&' : '?';

    return $url . $separator . $canonicalQueryString
      . '&X-Amz-Signature=' . $signature;
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  public function createCloudFrontInvalidation(
    string $distributionId,
    array $paths,
  ): void {
    $callerRef = 'ffmpeg-media-' . time() . '-' . bin2hex(random_bytes(4));
    $pathItems = '';
    foreach ($paths as $path) {
      $escapedPath = htmlspecialchars($path, ENT_XML1, 'UTF-8');
      $pathItems .= "<Path>{$escapedPath}</Path>";
    }

    $xml = '<?xml version="1.0" encoding="UTF-8"?>'
      . '<InvalidationBatch xmlns='
      . '"http://cloudfront.amazonaws.com/doc/2020-05-31/">'
      . "<Paths><Quantity>" . count($paths) . "</Quantity>"
      . "<Items>{$pathItems}</Items></Paths>"
      . "<CallerReference>{$callerRef}</CallerReference>"
      . '</InvalidationBatch>';

    ['access_key' => $accessKey, 'secret_key' => $secretKey] = $this->requireCredentials();
    $timestamp = gmdate('Ymd\THis\Z');
    $date = gmdate('Ymd');
    $region = 'us-east-1';
    $service = 'cloudfront';
    $payloadHash = hash('sha256', $xml);

    $cfHost = 'cloudfront.amazonaws.com';
    $canonicalUri = "/2020-05-31/distribution/{$distributionId}/invalidation";

    $headers = [
      'Host' => $cfHost,
      'x-amz-date' => $timestamp,
      'x-amz-content-sha256' => $payloadHash,
    ];

    $canonical = $this->buildCanonicalHeaders($headers);
    $signedHeaders = $canonical['signed'];

    $canonicalRequest = implode("\n", [
      'POST',
      $canonicalUri,
      '',
      $canonical['canonical'],
      $signedHeaders,
      $payloadHash,
    ]);

    $credentialScope = "{$date}/{$region}/{$service}/aws4_request";
    $stringToSign = implode("\n", [
      'AWS4-HMAC-SHA256',
      $timestamp,
      $credentialScope,
      hash('sha256', $canonicalRequest),
    ]);

    $signingKey = $this->deriveSigningKey($date, $region, $service, $secretKey);
    $signature = hash_hmac('sha256', $stringToSign, $signingKey);

    $authorization = "AWS4-HMAC-SHA256 "
      . "Credential={$accessKey}/{$credentialScope}, "
      . "SignedHeaders={$signedHeaders}, "
      . "Signature={$signature}";
    $headers['Authorization'] = $authorization;
    $headers['Content-Type'] = 'application/xml';
    $headers['Content-Length'] = (string) strlen($xml);

    $url = "https://{$cfHost}{$canonicalUri}";

    $ch = curl_init();
    if ($ch === FALSE) {
      throw new \RuntimeException('Failed to initialize cURL handle for CloudFront invalidation.');
    }

    try {
      // TLS/protocol hardening: enforce certificate verification and HTTPS.
      curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, TRUE);
      curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
      curl_setopt($ch, CURLOPT_PROTOCOLS_STR, 'https');

      // Response size limit to prevent unbounded memory consumption.
      curl_setopt($ch, CURLOPT_MAXFILESIZE, self::MAX_RESPONSE_SIZE);

      curl_setopt($ch, CURLOPT_URL, $url);
      curl_setopt($ch, CURLOPT_RETURNTRANSFER, TRUE);
      curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST');
      curl_setopt($ch, CURLOPT_POSTFIELDS, $xml);
      curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 30);
      curl_setopt($ch, CURLOPT_TIMEOUT, 60);

      $headerStrings = [];
      foreach ($headers as $name => $value) {
        $headerStrings[] = "{$name}: {$value}";
      }
      curl_setopt($ch, CURLOPT_HTTPHEADER, $headerStrings);

      $response = curl_exec($ch);
      $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);

      if ($response === FALSE) {
        $errorCode = curl_errno($ch);
        $errorDetail = curl_error($ch);
        $this->logger->error('CloudFront cURL request failed for distribution @id: @error (code: @code)', [
          '@id' => $distributionId,
          '@error' => $errorDetail,
          '@code' => $errorCode,
        ]);
        throw new \RuntimeException("CDN request failed (code: {$errorCode})");
      }

      if ($httpCode >= 200 && $httpCode < 300) {
        $this->logger->info(
          'CloudFront invalidation created for @id: @paths',
          [
            '@id' => $distributionId,
            '@paths' => implode(', ', $paths),
          ],
        );
      }
      else {
        $body = is_string($response) ? $response : '';
        // Truncate response body to avoid leaking AWS account details in logs.
        $sanitizedBody = mb_substr($body, 0, 500);
        $this->logger->error('CloudFront invalidation failed for distribution @id (HTTP @code): @body', [
          '@id' => $distributionId,
          '@code' => $httpCode,
          '@body' => $sanitizedBody,
        ]);
        throw new \RuntimeException(
          "CloudFront invalidation failed (HTTP {$httpCode})",
        );
      }
    }
    finally {
      unset($ch);
    }
  }

  /**
   * Gets the S3 host.
   *
   * @return string
   *   S3 host.
   */
  public function getS3Host(): string {
    $bucket = $this->config['bucket'];
    $region = $this->config['region'] ?? self::DEFAULT_REGION;
    $endpoint = $this->config['endpoint'] ?? NULL;
    $pathStyle = (bool) ($this->config['path_style'] ?? FALSE);

    if ($endpoint !== NULL) {
      $parsed = parse_url($endpoint);
      $host = $parsed['host'] ?? '';
      if ($pathStyle) {
        return $host;
      }
      return "{$bucket}.{$host}";
    }

    return "{$bucket}.s3.{$region}.amazonaws.com";
  }

  /**
   * Returns configured S3 credentials or throws a controlled exception.
   *
   * @return array{access_key: string, secret_key: string}
   *   Credential pair.
   */
  private function requireCredentials(): array {
    $accessKey = $this->config['access_key'] ?? NULL;
    $secretKey = $this->config['secret_key'] ?? NULL;

    if (!is_string($accessKey) || $accessKey === '' || !is_string($secretKey) || $secretKey === '') {
      throw new \LogicException('S3 credentials are not configured. Use environment variables, Key module references, or State API secrets.');
    }

    return [
      'access_key' => $accessKey,
      'secret_key' => $secretKey,
    ];
  }

  /**
   * Parses response headers.
   *
   * @param string $headerString
   *   Raw header string.
   *
   * @return array<string, string>
   *   Parsed headers.
   */
  public function parseResponseHeaders(string $headerString): array {
    $headers = [];
    $lines = explode("\r\n", $headerString);

    foreach ($lines as $line) {
      if (str_contains($line, ':')) {
        [$name, $value] = explode(':', $line, 2);
        $headers[$name |> trim(...) |> strtolower(...)] = trim($value);
      }
    }

    return $headers;
  }

  /**
   * Derives the AWS Signature V4 signing key via HMAC chain.
   *
   * @param string $date
   *   Date in Ymd format.
   * @param string $region
   *   AWS region.
   * @param string $service
   *   AWS service name (e.g., 's3', 'cloudfront').
   * @param string $secretKey
   *   AWS secret access key.
   *
   * @return string
   *   Binary signing key for use with hash_hmac().
   */
  #[\NoDiscard]
  public function deriveSigningKey(
    string $date,
    string $region,
    string $service,
    string $secretKey,
  ): string {
    $kDate = hash_hmac('sha256', $date, "AWS4{$secretKey}", TRUE);
    $kRegion = hash_hmac('sha256', $region, $kDate, TRUE);
    $kService = hash_hmac('sha256', $service, $kRegion, TRUE);

    return hash_hmac('sha256', 'aws4_request', $kService, TRUE);
  }

  /**
   * Builds canonical headers string and signed headers list.
   *
   * @param array<string, string> $headers
   *   Request headers to canonicalize (will be sorted by lowercase name).
   *
   * @return array{canonical: string, signed: string}
   *   Array with 'canonical' headers string and 'signed' headers list.
   */
  #[\NoDiscard]
  public function buildCanonicalHeaders(array $headers): array {
    $lowered = [];
    foreach ($headers as $name => $value) {
      $lowered[strtolower($name)] = trim($value);
    }
    ksort($lowered);

    $canonicalHeaders = '';
    $signedHeadersList = [];

    foreach ($lowered as $name => $value) {
      $canonicalHeaders .= $name . ':' . $value . "\n";
      $signedHeadersList[] = $name;
    }

    return [
      'canonical' => $canonicalHeaders,
      'signed' => implode(';', $signedHeadersList),
    ];
  }

}
