<?php

declare(strict_types=1);

namespace PpPhp\Core\Storage;

/**
 * S3Driver — Amazon S3 and any S3-compatible object store (Cloudflare R2,
 * MinIO, Backblaze B2, DigitalOcean Spaces, Wasabi…).
 *
 * Dependency-free: AWS Signature V4 is implemented in pure PHP (no SDK), and
 * every request goes out over cURL. Uploads are server-proxied through the app
 * (browser → app → bucket) using the same api/upload.php flow as local storage,
 * so the uploader needs no changes; url() returns a public link when the bucket
 * is public, otherwise a short-lived presigned GET. A presignPut() helper is
 * provided for a future browser-direct upload path (capabilities.directUpload).
 *
 * Connection config (from StorageConfig): accessKey, secretKey, bucket, region
 * (default us-east-1), endpoint (optional — set for R2/MinIO/custom), pathStyle
 * (bool — usually true for non-AWS), public (bool — objects are world-readable),
 * folder (sub-path), private (inverse of public). Secrets are stored encrypted.
 */
final class S3Driver implements StorageDriver
{
    private string $key;
    private string $secret;
    private string $bucket;
    private string $region;
    private string $endpoint;   // '' = AWS
    private bool $pathStyle;
    private bool $public;
    private string $folder;
    private int $ttl;

    private const DENY = ['php', 'phtml', 'phar', 'pht', 'cgi', 'pl', 'sh', 'exe'];

    public function __construct(array $config = [])
    {
        $this->key      = (string) ($config['accessKey'] ?? $config['key'] ?? '');
        $this->secret   = (string) ($config['secretKey'] ?? $config['secret'] ?? '');
        $this->bucket   = (string) ($config['bucket'] ?? '');
        $this->region   = (string) ($config['region'] ?? 'us-east-1') ?: 'us-east-1';
        $this->endpoint = rtrim((string) ($config['endpoint'] ?? ''), '/');
        $this->pathStyle = (bool) ($config['pathStyle'] ?? ($this->endpoint !== ''));
        // "public" bucket ⇒ direct URLs; a private field forces presigned links.
        $this->public   = (bool) ($config['public'] ?? false) && empty($config['private']);
        $this->folder   = LocalDriver::sanitizeFolder((string) ($config['folder'] ?? ''));
        $this->ttl      = max(60, (int) ($config['urlTtl'] ?? 3600));
    }

    public static function id(): string { return 's3'; }

    public function put(string $tmpPath, array $meta): array
    {
        $name = (string) ($meta['name'] ?? basename($tmpPath));
        $ext  = strtolower((string) ($meta['ext'] ?? pathinfo($name, PATHINFO_EXTENSION)));
        if ($ext === '' || in_array($ext, self::DENY, true)) {
            throw new \RuntimeException('File type is not allowed.');
        }
        // Flat, predictable key: {public|private}/[<folder>/]<hash>.<ext> (no date).
        $rel = ($this->public ? 'public' : 'private') . ($this->folder !== '' ? '/' . $this->folder : '');
        $stem = bin2hex(random_bytes(8));
        $safeExt = preg_replace('/[^a-z0-9]/', '', $ext) ?: 'bin';
        $key = $rel . '/' . $stem . '.' . $safeExt;
        $mime = (string) ($meta['mime'] ?? 'application/octet-stream') ?: 'application/octet-stream';

        $body = @file_get_contents($tmpPath);
        if ($body === false) throw new \RuntimeException('Could not read the upload.');

        $extra = ['content-type' => $mime];
        if ($this->public) $extra['x-amz-acl'] = 'public-read';
        [$status, $resp] = $this->request('PUT', $key, $body, $extra);
        if ($status < 200 || $status >= 300) {
            throw new \RuntimeException('S3 upload failed (HTTP ' . $status . ').');
        }

        // Optional thumbnail alongside the object.
        $thumbKey = '';
        $thumbTmp = (string) ($meta['thumbTmp'] ?? '');
        if ($thumbTmp !== '' && is_file($thumbTmp)) {
            $tkey = $rel . '/' . $stem . '_t.' . $safeExt;
            $tbody = @file_get_contents($thumbTmp);
            if ($tbody !== false) {
                [$ts] = $this->request('PUT', $tkey, $tbody, $extra);
                if ($ts >= 200 && $ts < 300) $thumbKey = $tkey;
            }
            @unlink($thumbTmp);
        }

        $ref = ['v' => StorageRef::VERSION, 'driver' => 's3', 'key' => $key, 'name' => $name,
                'size' => (int) ($meta['size'] ?? strlen($body)), 'mime' => $mime];
        foreach (['w', 'h'] as $k) { if ((int) ($meta[$k] ?? 0) > 0) $ref[$k] = (int) $meta[$k]; }
        if ($thumbKey !== '') $ref['thumb'] = $thumbKey;
        return StorageRef::normalise($ref);
    }

    public function url(array $ref, array $opts = []): string
    {
        if (($ref['url'] ?? '') !== '') return (string) $ref['url'];
        $key = (string) (($opts['thumb'] ?? false) && ($ref['thumb'] ?? '') !== '' ? $ref['thumb'] : ($ref['key'] ?? ''));
        if ($key === '') return '';
        if ($this->public) return $this->objectUrl($key);
        return $this->presign('GET', $key, (int) ($opts['ttl'] ?? $this->ttl));
    }

    public function openStream(array $ref)
    {
        $key = (string) ($ref['key'] ?? '');
        if ($key === '') return null;
        [$status, $body] = $this->request('GET', $key, null);
        if ($status < 200 || $status >= 300) return null;
        $fh = fopen('php://temp', 'r+b');
        fwrite($fh, (string) $body); rewind($fh);
        return $fh;
    }

    public function delete(array $ref): bool
    {
        $ok = true;
        foreach (['key', 'thumb'] as $k) {
            $key = (string) ($ref[$k] ?? '');
            if ($key === '') continue;
            [$status] = $this->request('DELETE', $key, null);
            $ok = ($status >= 200 && $status < 300 || $status === 404) && $ok;
        }
        return $ok;
    }

    public function exists(array $ref): bool
    {
        [$status] = $this->request('HEAD', (string) ($ref['key'] ?? ''), null);
        return $status >= 200 && $status < 300;
    }

    public function capabilities(): array
    {
        return ['directUpload' => true, 'publicUrl' => $this->public, 'signedUrl' => true,
                'chunked' => false, 'maxBytes' => 5 * 1024 * 1024 * 1024];
    }

    /** A presigned URL the browser can PUT to directly (browser → bucket). */
    public function presignPut(string $key, int $ttl = 900): string
    {
        return $this->presign('PUT', $key, $ttl);
    }

    /** Lightweight connectivity/credentials check (HEAD the bucket). @return array{ok:bool,message:string} */
    public function test(): array
    {
        if ($this->key === '' || $this->secret === '' || $this->bucket === '') {
            return ['ok' => false, 'message' => 'Access key, secret and bucket are required.'];
        }
        [$status] = $this->request('HEAD', '', null);
        if ($status === 0) return ['ok' => false, 'message' => 'Could not reach the endpoint (check region/endpoint).'];
        if ($status === 200) return ['ok' => true, 'message' => 'Connected to bucket "' . $this->bucket . '".'];
        if ($status === 301 || $status === 400) return ['ok' => false, 'message' => 'Wrong region for this bucket.'];
        if ($status === 403) return ['ok' => false, 'message' => 'Reached the bucket but access was denied (check keys/permissions).'];
        if ($status === 404) return ['ok' => false, 'message' => 'Bucket "' . $this->bucket . '" was not found.'];
        return ['ok' => false, 'message' => 'Unexpected response (HTTP ' . $status . ').'];
    }

    // ── host / URL building ────────────────────────────────────────
    private function host(): string
    {
        if ($this->endpoint !== '') {
            $h = preg_replace('#^https?://#', '', $this->endpoint);
            return $this->pathStyle ? $h : $this->bucket . '.' . $h;
        }
        return $this->pathStyle
            ? 's3.' . $this->region . '.amazonaws.com'
            : $this->bucket . '.s3.' . $this->region . '.amazonaws.com';
    }
    private function scheme(): string
    {
        return ($this->endpoint !== '' && str_starts_with($this->endpoint, 'http://')) ? 'http' : 'https';
    }
    private function canonicalPath(string $key): string
    {
        $encoded = implode('/', array_map('rawurlencode', explode('/', ltrim($key, '/'))));
        return $this->pathStyle ? '/' . $this->bucket . '/' . $encoded : '/' . $encoded;
    }
    private function objectUrl(string $key): string
    {
        return $this->scheme() . '://' . $this->host() . $this->canonicalPath($key);
    }

    // ── object-level helpers (used by the update-host driver) ──────────
    /**
     * PUT a local file to an EXACT key (no hashing/foldering). Returns true on a
     * 2xx. Used to publish update artifacts at deterministic paths
     * (full/…zip, delta/…zip, index.json).
     */
    public function putFile(string $localPath, string $key, string $contentType = 'application/octet-stream'): bool
    {
        $body = @file_get_contents($localPath);
        if ($body === false) {
            return false;
        }
        [$status] = $this->request('PUT', ltrim($key, '/'), $body, ['content-type' => $contentType]);
        return $status >= 200 && $status < 300;
    }

    /** The public URL for an exact key (direct link for a public bucket, else presigned GET). */
    public function publicUrl(string $key): string
    {
        $key = ltrim($key, '/');
        return $this->public ? $this->objectUrl($key) : $this->presign('GET', $key, $this->ttl);
    }

    /** True when the object exists (HEAD 2xx). */
    public function keyExists(string $key): bool
    {
        [$status] = $this->request('HEAD', ltrim($key, '/'), null);
        return $status >= 200 && $status < 300;
    }

    // ── AWS Signature V4: authenticated request (header signing) ───
    /** @return array{0:int,1:string} [httpStatus, body] */
    private function request(string $method, string $key, ?string $body, array $extraHeaders = []): array
    {
        if ($this->key === '' || $this->secret === '' || $this->bucket === '') return [0, ''];
        $host = $this->host();
        $path = $this->canonicalPath($key);
        $now  = gmdate('Ymd\THis\Z');
        $date = substr($now, 0, 8);
        $payloadHash = hash('sha256', $body ?? '');

        $headers = ['host' => $host, 'x-amz-content-sha256' => $payloadHash, 'x-amz-date' => $now];
        foreach ($extraHeaders as $k => $v) { $headers[strtolower($k)] = (string) $v; }
        ksort($headers);
        $canonicalHeaders = '';
        foreach ($headers as $k => $v) { $canonicalHeaders .= $k . ':' . trim($v) . "\n"; }
        $signedHeaders = implode(';', array_keys($headers));

        $canonicalRequest = $method . "\n" . $path . "\n\n" . $canonicalHeaders . "\n" . $signedHeaders . "\n" . $payloadHash;
        $scope = $date . '/' . $this->region . '/s3/aws4_request';
        $stringToSign = "AWS4-HMAC-SHA256\n" . $now . "\n" . $scope . "\n" . hash('sha256', $canonicalRequest);
        $signature = hash_hmac('sha256', $stringToSign, $this->signingKey($date));

        $authorization = 'AWS4-HMAC-SHA256 Credential=' . $this->key . '/' . $scope
            . ', SignedHeaders=' . $signedHeaders . ', Signature=' . $signature;

        $curlHeaders = ['Authorization: ' . $authorization];
        foreach ($headers as $k => $v) { if ($k !== 'host') $curlHeaders[] = $k . ': ' . $v; }

        return $this->http($method, $this->scheme() . '://' . $host . $path, $curlHeaders, $body);
    }

    // ── AWS Signature V4: presigned URL (query signing) ───
    private function presign(string $method, string $key, int $ttl): string
    {
        if ($this->key === '' || $this->secret === '') return $this->objectUrl($key);
        $host = $this->host();
        $path = $this->canonicalPath($key);
        $now  = gmdate('Ymd\THis\Z');
        $date = substr($now, 0, 8);
        $scope = $date . '/' . $this->region . '/s3/aws4_request';

        $query = [
            'X-Amz-Algorithm' => 'AWS4-HMAC-SHA256',
            'X-Amz-Credential' => $this->key . '/' . $scope,
            'X-Amz-Date' => $now,
            'X-Amz-Expires' => (string) max(1, min(604800, $ttl)),
            'X-Amz-SignedHeaders' => 'host',
        ];
        ksort($query);
        $canonicalQuery = http_build_query($query, '', '&', PHP_QUERY_RFC3986);
        $canonicalRequest = $method . "\n" . $path . "\n" . $canonicalQuery . "\nhost:" . $host . "\n\nhost\nUNSIGNED-PAYLOAD";
        $stringToSign = "AWS4-HMAC-SHA256\n" . $now . "\n" . $scope . "\n" . hash('sha256', $canonicalRequest);
        $signature = hash_hmac('sha256', $stringToSign, $this->signingKey($date));

        return $this->scheme() . '://' . $host . $path . '?' . $canonicalQuery . '&X-Amz-Signature=' . $signature;
    }

    private function signingKey(string $date): string
    {
        $kDate = hash_hmac('sha256', $date, 'AWS4' . $this->secret, true);
        $kRegion = hash_hmac('sha256', $this->region, $kDate, true);
        $kService = hash_hmac('sha256', 's3', $kRegion, true);
        return hash_hmac('sha256', 'aws4_request', $kService, true);
    }

    /** @return array{0:int,1:string} */
    private function http(string $method, string $url, array $headers, ?string $body): array
    {
        if (!function_exists('curl_init')) return [0, ''];
        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_CUSTOMREQUEST => $method,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_HTTPHEADER => $headers,
            CURLOPT_NOBODY => $method === 'HEAD',
            CURLOPT_CONNECTTIMEOUT => 10,
            CURLOPT_TIMEOUT => 60,
        ]);
        if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
        $resp = curl_exec($ch);
        $status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);
        return [$status, is_string($resp) ? $resp : ''];
    }
}
