<?php

declare(strict_types=1);

namespace PpPhp\Core\Storage;

/**
 * FtpStorageDriver — store File/Image uploads on an FTP / FTPS / SFTP target.
 *
 * The common use is "use my hosting space for storage": files are uploaded to a
 * folder on a server you already pay for and served back over a normal web URL
 * that maps to that folder. It also works for any standalone FTP/SFTP box.
 *
 * Dependency-free and self-contained (no core/Deploy needed at runtime, so it
 * ships to generated apps): every operation goes over cURL, which speaks sftp://,
 * ftp:// and ftps:// uniformly — upload (CURLOPT_UPLOAD, auto-creating folders),
 * download, delete (protocol-appropriate QUOTE command) and a stat/exists probe.
 *
 * Connection config (from StorageConfig; secrets encrypted at rest):
 *   protocol  sftp | ftp | ftps        (default sftp)
 *   host, port, user, password
 *   basePath  remote folder files live under (e.g. /home/acct/public_html/media)
 *   publicBase public URL that maps to basePath (e.g. https://cdn.example.com) —
 *             required so stored files resolve to a browser URL; the generated
 *             file endpoint only proxies LOCAL files, so FTP files must be public.
 *   folder    per-field sub-folder (set on the Edit control)
 *   passive   FTP passive mode (default true)
 *
 * Files are stored PUBLIC (served straight from publicBase). Do not point a
 * private/sensitive field at an FTP connection whose folder is web-exposed.
 */
final class FtpStorageDriver implements StorageDriver
{
    private string $protocol;
    private string $host;
    private int $port;
    private string $user;
    private string $password;
    private string $basePath;
    private string $publicBase;
    private string $folder;
    private bool $passive;

    /** Never accepted, regardless of the field's allow-list. */
    private const DENY = ['php', 'phtml', 'phar', 'php3', 'php4', 'php5', 'php7', 'php8', 'pht', 'cgi', 'pl', 'sh', 'htaccess', 'exe'];

    public function __construct(array $config = [])
    {
        $proto = strtolower(trim((string) ($config['protocol'] ?? 'sftp')));
        $this->protocol   = in_array($proto, ['sftp', 'ftp', 'ftps'], true) ? $proto : 'sftp';
        $this->host       = trim((string) ($config['host'] ?? ''));
        $this->port       = (int) ($config['port'] ?? 0) ?: ($this->protocol === 'sftp' ? 22 : 21);
        $this->user       = (string) ($config['user'] ?? '');
        $this->password   = (string) ($config['password'] ?? '');
        $this->basePath   = '/' . trim(str_replace('\\', '/', (string) ($config['basePath'] ?? '')), '/');
        $this->publicBase = rtrim(trim((string) ($config['publicBase'] ?? '')), '/');
        $this->folder     = LocalDriver::sanitizeFolder((string) ($config['folder'] ?? ''));
        $this->passive    = (bool) ($config['passive'] ?? true);
    }

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

    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.');
        }
        $safeExt = preg_replace('/[^a-z0-9]/', '', $ext) ?: 'bin';
        $stem    = bin2hex(random_bytes(8));
        $key     = ($this->folder !== '' ? $this->folder . '/' : '') . $stem . '.' . $safeExt;

        if (!$this->upload($tmpPath, $this->basePath . '/' . $key)) {
            throw new \RuntimeException('Could not upload the file to the storage server.');
        }

        $ref = ['v' => StorageRef::VERSION, 'driver' => 'ftp', 'key' => $key, 'name' => $name,
                'size' => (int) ($meta['size'] ?? @filesize($tmpPath) ?: 0),
                'mime' => (string) ($meta['mime'] ?? '')];
        foreach (['w', 'h'] as $k) { if ((int) ($meta[$k] ?? 0) > 0) $ref[$k] = (int) $meta[$k]; }

        // Optional thumbnail alongside the object.
        $thumbTmp = (string) ($meta['thumbTmp'] ?? '');
        if ($thumbTmp !== '' && is_file($thumbTmp)) {
            $tkey = ($this->folder !== '' ? $this->folder . '/' : '') . $stem . '_t.' . $safeExt;
            if ($this->upload($thumbTmp, $this->basePath . '/' . $tkey)) $ref['thumb'] = $tkey;
            @unlink($thumbTmp);
        }
        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 (preg_match('#^https?://#i', $key)) return $key;   // legacy absolute
        if ($this->publicBase === '') return '';               // no web mapping → cannot serve
        return $this->publicBase . '/' . ltrim($key, '/');
    }

    public function openStream(array $ref)
    {
        $key = (string) ($ref['key'] ?? '');
        if ($key === '') return null;
        // Files are public: fetching the public URL is the fastest read. Fall back
        // to a direct transport download when no public mapping is configured.
        $body = $this->publicBase !== ''
            ? $this->httpGet($this->publicBase . '/' . ltrim($key, '/'))
            : $this->download($this->basePath . '/' . $key);
        if ($body === null) return null;
        $fh = fopen('php://temp', 'r+b');
        fwrite($fh, $body); rewind($fh);
        return $fh;
    }

    public function delete(array $ref): bool
    {
        $ok = true;
        foreach (['key', 'thumb'] as $k) {
            $key = (string) ($ref[$k] ?? '');
            if ($key === '' || preg_match('#^https?://#i', $key)) continue;
            $ok = $this->remove($this->basePath . '/' . $key) && $ok;
        }
        return $ok;
    }

    public function exists(array $ref): bool
    {
        $key = (string) ($ref['key'] ?? '');
        return $key !== '' && $this->stat($this->basePath . '/' . $key);
    }

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

    /**
     * Live connectivity + writability check: upload a tiny probe into basePath and
     * remove it. Distinguishes "can't connect" from "connected but not writable".
     *
     * @return array{ok:bool,message:string}
     */
    public function test(): array
    {
        if ($this->host === '' || $this->user === '') {
            return ['ok' => false, 'message' => 'Host and username are required.'];
        }
        if (!function_exists('curl_init')) {
            return ['ok' => false, 'message' => 'cURL is not available on this server.'];
        }
        $probeTmp = tempnam(sys_get_temp_dir(), 'ppprobe');
        @file_put_contents($probeTmp, "PPPHP storage write test\n");
        $probeRemote = $this->basePath . '/.ppphp-write-test-' . bin2hex(random_bytes(4)) . '.txt';

        $err = '';
        $wrote = $this->upload($probeTmp, $probeRemote, $err);
        @unlink($probeTmp);

        if ($wrote) {
            $this->remove($probeRemote);   // best-effort cleanup
            return ['ok' => true, 'message' => 'Connected to ' . $this->host . ' — the folder is writable.'];
        }
        // Could not write: was it the connection/auth, or just the path/permissions?
        if ($this->stat($this->basePath . '/')) {
            return ['ok' => false, 'message' => 'Connected, but could not write to "' . $this->basePath . '" — check the path exists and is writable.'];
        }
        return ['ok' => false, 'message' => 'Could not connect: ' . ($err !== '' ? $err : 'check the protocol, host, port, username and password.')];
    }

    // ── cURL primitives (sftp / ftp / ftps) ─────────────────────────────

    /** scheme://host:port + absolute, per-segment-encoded path. */
    private function endpoint(string $remotePath): string
    {
        $segs = array_map('rawurlencode', explode('/', ltrim($remotePath, '/')));
        return $this->protocol . '://' . $this->host . ':' . $this->port . '/' . implode('/', $segs);
    }

    /** Common cURL options for every request against this connection. */
    private function baseOpts(): array
    {
        $opts = [
            CURLOPT_USERPWD        => $this->user . ':' . $this->password,
            CURLOPT_CONNECTTIMEOUT => 15,
            CURLOPT_TIMEOUT        => 120,
        ];
        if ($this->protocol === 'ftps') {
            // Tolerate the self-signed / mismatched certs common on shared hosting.
            $opts[CURLOPT_USE_SSL]        = CURLUSESSL_ALL;
            $opts[CURLOPT_SSL_VERIFYPEER] = false;
            $opts[CURLOPT_SSL_VERIFYHOST] = 0;
        }
        if ($this->protocol !== 'sftp') {
            $opts[CURLOPT_FTP_USE_EPSV] = $this->passive;
        }
        return $opts;
    }

    private function upload(string $localPath, string $remotePath, string &$err = ''): bool
    {
        $fp = @fopen($localPath, 'rb');
        if ($fp === false) { $err = 'Could not read the local file.'; return false; }
        $ch = curl_init();
        curl_setopt_array($ch, $this->baseOpts() + [
            CURLOPT_URL        => $this->endpoint($remotePath),
            CURLOPT_UPLOAD     => true,
            CURLOPT_INFILE     => $fp,
            CURLOPT_INFILESIZE => (int) @filesize($localPath),
            CURLOPT_FTP_CREATE_MISSING_DIRS => defined('CURLFTP_CREATE_DIR') ? CURLFTP_CREATE_DIR : 1,
        ]);
        $ok = curl_exec($ch);
        if ($ok === false) { $err = curl_error($ch); }
        curl_close($ch);
        @fclose($fp);
        return $ok !== false;
    }

    private function download(string $remotePath): ?string
    {
        $ch = curl_init();
        curl_setopt_array($ch, $this->baseOpts() + [
            CURLOPT_URL            => $this->endpoint($remotePath),
            CURLOPT_RETURNTRANSFER => true,
        ]);
        $body = curl_exec($ch);
        $ok = $body !== false && curl_errno($ch) === 0;
        curl_close($ch);
        return $ok ? (string) $body : null;
    }

    private function httpGet(string $url): ?string
    {
        if (!function_exists('curl_init')) return null;
        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => true,
            CURLOPT_CONNECTTIMEOUT => 15, CURLOPT_TIMEOUT => 120,
        ]);
        $body = curl_exec($ch);
        $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);
        return ($body !== false && $code >= 200 && $code < 400) ? (string) $body : null;
    }

    private function remove(string $remotePath): bool
    {
        $ch = curl_init();
        $dir = rtrim(substr($remotePath, 0, strrpos($remotePath, '/') ?: 0), '/');
        $cmd = $this->protocol === 'sftp' ? ('rm ' . $remotePath) : ('DELE ' . $remotePath);
        curl_setopt_array($ch, $this->baseOpts() + [
            CURLOPT_URL    => $this->endpoint($dir === '' ? '/' : $dir . '/'),
            CURLOPT_QUOTE  => [$cmd],
            CURLOPT_NOBODY => true,
        ]);
        curl_exec($ch);
        $ok = curl_errno($ch) === 0;
        curl_close($ch);
        return $ok;
    }

    /** True when the remote path resolves (a file or a directory). */
    private function stat(string $remotePath): bool
    {
        $ch = curl_init();
        curl_setopt_array($ch, $this->baseOpts() + [
            CURLOPT_URL    => $this->endpoint($remotePath),
            CURLOPT_NOBODY => true,
        ]);
        curl_exec($ch);
        $ok = curl_errno($ch) === 0;
        curl_close($ch);
        return $ok;
    }
}
