<?php

declare(strict_types=1);

namespace PpPhp\Core\Deploy;

/**
 * FTP and explicit-FTPS transport built on PHP's ext-ftp.
 *
 * Config keys (see TransportFactory):
 *   host, port, user, pass, secure (bool → FTPS), passive (bool), timeout (int)
 */
class FtpTransport implements TransportInterface
{
    /** @var resource|\FTP\Connection|null */
    private $conn = null;

    /** Directories already created this session. @var array<string,true> */
    private array $madeDirs = [];

    private bool $passive = true;

    public function __construct(private array $config)
    {
    }

    public function protocol(): string
    {
        return !empty($this->config['secure']) ? 'ftps' : 'ftp';
    }

    public function connect(): void
    {
        $host    = $this->resolveHost((string) ($this->config['host'] ?? ''));
        $port    = (int) ($this->config['port'] ?? 21) ?: 21;
        $timeout = (int) ($this->config['timeout'] ?? 20) ?: 20;
        $secure  = !empty($this->config['secure']);

        if ($host === '') {
            throw new TransportException('No FTP host was provided.');
        }
        if (!function_exists('ftp_connect')) {
            throw new TransportException('The PHP FTP extension is not installed on this server.');
        }

        if ($secure) {
            if (!function_exists('ftp_ssl_connect')) {
                throw new TransportException('FTPS requested but PHP was built without OpenSSL/FTPS support.');
            }
            $conn = @ftp_ssl_connect($host, $port, $timeout);
        } else {
            $conn = @ftp_connect($host, $port, $timeout);
        }

        if (!$conn) {
            throw new TransportException("Could not connect to {$host}:{$port} (" . $this->protocol() . '). ' . $this->lastPhpError());
        }
        $this->conn = $conn;

        $user = (string) ($this->config['user'] ?? '');
        $pass = (string) ($this->config['pass'] ?? '');
        if (!@ftp_login($conn, $user, $pass)) {
            $this->close();
            throw new TransportException('Login failed — the server rejected the username or password. ' . $this->lastPhpError());
        }

        // Bound every network operation so a stalled data channel can never hang.
        @ftp_set_option($conn, FTP_TIMEOUT_SEC, (int) ($this->config['dataTimeout'] ?? 20) ?: 20);

        // Respect the server's advertised passive address by default (what
        // FileZilla/curl do, and correct for most hosts). If a data operation
        // later fails, listDir() retries with FTP_USEPASVADDRESS=false to cover
        // servers that advertise a private/NAT IP in their PASV reply.
        $this->passive = array_key_exists('passive', $this->config) ? !empty($this->config['passive']) : true;
        @ftp_pasv($conn, $this->passive);
    }

    public function diagnose(): array
    {
        $host    = (string) ($this->config['host'] ?? '');
        $port    = (int) ($this->config['port'] ?? 21) ?: 21;
        // Keep probe steps short so the whole report finishes well within the
        // client's request window even when every step stalls.
        $timeout = min((int) ($this->config['timeout'] ?? 20) ?: 20, 10);
        $secure  = !empty($this->config['secure']);
        $stages  = [];

        // 1. Extension present
        $hasExt = function_exists('ftp_connect') && (!$secure || function_exists('ftp_ssl_connect'));
        $stages[] = ['stage' => 'PHP FTP extension', 'ok' => $hasExt, 'detail' => $hasExt ? 'Available' : 'ext-ftp (or FTPS/OpenSSL support) is missing on this server'];
        if (!$hasExt) {
            return $this->report($stages);
        }

        // 2. DNS
        $stages[] = $this->probeDns($host);

        // Use whichever hostname actually resolves for the remaining probes.
        $host = $this->resolveHost($host);

        // 3. TCP reachability
        $stages[] = $this->probeTcp($host, $port, $timeout);

        // 4. FTP control channel
        $conn = $secure ? @ftp_ssl_connect($host, $port, $timeout) : @ftp_connect($host, $port, $timeout);
        if (!$conn) {
            $stages[] = ['stage' => ($secure ? 'FTPS' : 'FTP') . ' control channel', 'ok' => false, 'detail' => 'Server did not accept the control connection. ' . $this->lastPhpError()];
            return $this->report($stages);
        }
        $stages[] = ['stage' => ($secure ? 'FTPS' : 'FTP') . ' control channel', 'ok' => true, 'detail' => 'Connected on port ' . $port];

        // 5. Login
        $user = (string) ($this->config['user'] ?? '');
        $pass = (string) ($this->config['pass'] ?? '');
        if (!@ftp_login($conn, $user, $pass)) {
            $stages[] = ['stage' => 'Authentication', 'ok' => false, 'detail' => "Login as '{$user}' was rejected. " . $this->lastPhpError()];
            @ftp_close($conn);
            return $this->report($stages);
        }
        $stages[] = ['stage' => 'Authentication', 'ok' => true, 'detail' => "Logged in as '{$user}'"];

        // 6. Passive mode + working directory + a real data-channel probe
        @ftp_set_option($conn, FTP_TIMEOUT_SEC, 15);
        $passive = array_key_exists('passive', $this->config) ? !empty($this->config['passive']) : true;
        if ($passive && defined('FTP_USEPASVADDRESS')) {
            @ftp_set_option($conn, FTP_USEPASVADDRESS, false);
        }
        @ftp_pasv($conn, $passive);
        $pwd = @ftp_pwd($conn);
        $stages[] = ['stage' => 'Session', 'ok' => $pwd !== false, 'detail' => $pwd !== false ? ('Passive: ' . ($passive ? 'on' : 'off') . ' · Home: ' . ($pwd ?: '/')) : 'Could not read the working directory'];

        // Actually open a data connection — this is what real listings/uploads do,
        // and it surfaces passive-mode/firewall problems the login step can't.
        $list = @ftp_nlist($conn, $pwd ?: '.');
        if ($list !== false) {
            $stages[] = ['stage' => 'Data channel (directory listing)', 'ok' => true, 'detail' => 'OK — listed ' . count($list) . ' item(s)'];
        } else {
            // Dig deeper: find the exact passive endpoint the server hands out and
            // check whether it is reachable, so we can name the real problem.
            $probe = $this->passiveProbe();
            if ($probe !== null && !$probe['reachable']) {
                $detail = "The server's passive data port {$probe['ip']}:{$probe['port']} could not be reached — it is blocked by a firewall (on the server or your network). "
                    . "FTP needs the whole passive port range open. Ask your host to open passive FTP, or use SFTP instead: port 22 uses a single connection with no data-port problem.";
            } else {
                $detail = 'The data connection stalled or was refused. Ask your host to allow passive FTP through the firewall, or switch this connection to SFTP (port 22).';
            }
            $stages[] = ['stage' => 'Data channel (directory listing)', 'ok' => false, 'detail' => $detail];
        }
        @ftp_close($conn);

        return $this->report($stages);
    }

    public function ensureDir(string $remoteDir): void
    {
        $remoteDir = '/' . trim($remoteDir, '/');
        if ($remoteDir === '/' || isset($this->madeDirs[$remoteDir])) {
            return;
        }
        $segments = explode('/', trim($remoteDir, '/'));
        $path = '';
        foreach ($segments as $seg) {
            if ($seg === '') {
                continue;
            }
            $path .= '/' . $seg;
            if (isset($this->madeDirs[$path])) {
                continue;
            }
            if (!@ftp_chdir($this->conn, $path)) {
                @ftp_mkdir($this->conn, $path);
            }
            $this->madeDirs[$path] = true;
        }
    }

    public function put(string $localPath, string $remotePath): bool
    {
        $this->ensureDir(dirname($remotePath));
        if (@ftp_put($this->conn, $remotePath, $localPath, FTP_BINARY)) {
            return true;
        }
        // Fallback: curl handles EPSV, FTPS data channels and NAT passive-IP
        // reuse more robustly than ext-ftp on some servers.
        return $this->curlPut($localPath, $remotePath);
    }

    public function listDir(string $remoteDir): array
    {
        $remoteDir = $remoteDir === '' ? '/' : $remoteDir;

        $out = $this->attemptList($remoteDir);
        // A hard failure (data channel refused/stalled). For servers that
        // advertise a private/NAT IP in PASV, reusing the control host fixes it.
        if ($out === null && $this->passive && defined('FTP_USEPASVADDRESS')) {
            @ftp_set_option($this->conn, FTP_USEPASVADDRESS, false);
            @ftp_pasv($this->conn, true);
            $out = $this->attemptList($remoteDir);
        }
        // Last resort: curl (EPSV + robust passive/FTPS handling).
        if ($out === null) {
            $out = $this->curlList($remoteDir);
        }
        return $this->sortEntries($out ?? []);
    }

    /** @return list<array>|null  null on a hard data-channel failure. */
    private function attemptList(string $remoteDir): ?array
    {
        if (function_exists('ftp_mlsd')) {
            $rows = @ftp_mlsd($this->conn, $remoteDir);
            if (is_array($rows)) {
                $out = [];
                foreach ($rows as $r) {
                    $name = $r['name'] ?? '';
                    if ($name === '' || $name === '.' || $name === '..') {
                        continue;
                    }
                    $out[] = [
                        'name'  => $name,
                        'type'  => ($r['type'] ?? 'file') === 'dir' ? 'dir' : 'file',
                        'size'  => (int) ($r['size'] ?? 0),
                        'mtime' => isset($r['modify']) ? $this->parseMlsdTime($r['modify']) : null,
                    ];
                }
                return $out;
            }
        }
        $raw = @ftp_rawlist($this->conn, $remoteDir);
        if (is_array($raw)) {
            $out = [];
            foreach ($raw as $line) {
                $entry = $this->parseUnixListLine($line);
                if ($entry !== null) {
                    $out[] = $entry;
                }
            }
            return $out;
        }
        return null;
    }

    public function makeDir(string $remoteDir): bool
    {
        return @ftp_mkdir($this->conn, $remoteDir) !== false;
    }

    public function rename(string $from, string $to): bool
    {
        return @ftp_rename($this->conn, $from, $to);
    }

    public function removeDir(string $remoteDir): bool
    {
        return @ftp_rmdir($this->conn, $remoteDir);
    }

    public function stat(string $remotePath): ?array
    {
        $size = @ftp_size($this->conn, $remotePath);
        if ($size < 0) {
            return null;
        }
        $mtime = @ftp_mdtm($this->conn, $remotePath);
        return ['size' => $size, 'mtime' => $mtime > 0 ? $mtime : null];
    }

    public function delete(string $remotePath): bool
    {
        return @ftp_delete($this->conn, $remotePath);
    }

    public function close(): void
    {
        if ($this->conn) {
            @ftp_close($this->conn);
            $this->conn = null;
        }
    }

    // ── curl fallback engine (robust across servers) ────────────────

    /** Build a curl handle for an FTP/FTPS URL with shared auth/SSL options. */
    private function curlHandle(string $url, int $timeout = 60)
    {
        $ch = curl_init($url);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, (int) ($this->config['timeout'] ?? 20) ?: 20);
        curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
        curl_setopt($ch, CURLOPT_USERPWD, (string) ($this->config['user'] ?? '') . ':' . (string) ($this->config['pass'] ?? ''));
        // Reuse the control-connection host for data (fixes NAT/private PASV IPs).
        if (defined('CURLOPT_FTP_SKIP_PASV_IP')) {
            curl_setopt($ch, CURLOPT_FTP_SKIP_PASV_IP, true);
        }
        // Explicit TLS ("AUTH TLS") for FTPS connections.
        if (!empty($this->config['secure'])) {
            curl_setopt($ch, CURLOPT_USE_SSL, CURLUSESSL_ALL);
            curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
            curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0);
        }
        return $ch;
    }

    /** Base ftp:// URL for the resolved host and port. */
    private function curlBase(): string
    {
        $host = $this->resolveHost((string) ($this->config['host'] ?? ''));
        $port = (int) ($this->config['port'] ?? 21) ?: 21;
        return 'ftp://' . $host . ':' . $port;
    }

    /** @return list<array>|null curl-based directory listing, null on failure. */
    private function curlList(string $remoteDir): ?array
    {
        $dir = '/' . trim($remoteDir, '/');
        if ($dir !== '/') {
            $dir .= '/';
        }
        $ch = $this->curlHandle($this->curlBase() . $dir, 40);
        $body = curl_exec($ch);
        $errno = curl_errno($ch);
        curl_close($ch);
        if ($errno !== 0 || !is_string($body)) {
            return null;
        }
        $out = [];
        foreach (preg_split('/\r?\n/', $body) as $line) {
            $line = trim($line);
            if ($line === '' || str_starts_with($line, 'total ')) {
                continue;
            }
            $entry = $this->parseUnixListLine($line);
            if ($entry !== null) {
                $out[] = $entry;
            }
        }
        return $out;
    }

    /** curl-based upload, creating missing directories. */
    private function curlPut(string $localPath, string $remotePath): bool
    {
        $fp = @fopen($localPath, 'rb');
        if ($fp === false) {
            return false;
        }
        $ch = $this->curlHandle($this->curlBase() . '/' . ltrim($remotePath, '/'), 300);
        curl_setopt($ch, CURLOPT_UPLOAD, true);
        curl_setopt($ch, CURLOPT_INFILE, $fp);
        curl_setopt($ch, CURLOPT_INFILESIZE, (int) filesize($localPath));
        if (defined('CURLOPT_FTP_CREATE_MISSING_DIRS') && defined('CURLFTP_CREATE_DIR')) {
            curl_setopt($ch, CURLOPT_FTP_CREATE_MISSING_DIRS, CURLFTP_CREATE_DIR);
        }
        curl_exec($ch);
        $ok = curl_errno($ch) === 0;
        curl_close($ch);
        fclose($fp);
        return $ok;
    }

    // ── helpers ─────────────────────────────────────────────────────

    /**
     * Pick a resolvable hostname so the user can just type "domain.com".
     * Tries the entered host, then flips the "ftp." prefix on/off, and returns
     * the first candidate that resolves (or the original if none do).
     */
    private function resolveHost(string $host): string
    {
        $host = trim($host);
        if ($host === '' || filter_var($host, FILTER_VALIDATE_IP)) {
            return $host;
        }
        $candidates = [$host];
        if (str_starts_with($host, 'ftp.')) {
            $candidates[] = substr($host, 4);
        } else {
            $candidates[] = 'ftp.' . $host;
        }
        foreach ($candidates as $c) {
            $ip = @gethostbyname($c);
            if ($ip !== $c && filter_var($ip, FILTER_VALIDATE_IP)) {
                return $c;
            }
        }
        return $host;
    }

    /**
     * Discover the passive data endpoint the server advertises and check whether
     * it is actually reachable — the surgical test for "control works but the
     * data channel is firewalled".
     *
     * @return array{ip:string,port:int,reachable:bool}|null
     */
    private function passiveProbe(): ?array
    {
        if (!extension_loaded('curl')) {
            return null;
        }
        $secure = !empty($this->config['secure']);
        $scheme = $secure ? 'ftps' : 'ftp';
        $host   = $this->resolveHost((string) ($this->config['host'] ?? ''));
        $port   = (int) ($this->config['port'] ?? 21) ?: 21;

        $ch = curl_init();
        curl_setopt_array($ch, [
            CURLOPT_URL            => "{$scheme}://{$host}:{$port}/",
            CURLOPT_USERPWD        => (string) ($this->config['user'] ?? '') . ':' . (string) ($this->config['pass'] ?? ''),
            CURLOPT_CONNECTTIMEOUT => 8,
            CURLOPT_TIMEOUT        => 10,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_FTP_USE_EPSV   => 0,
            CURLOPT_VERBOSE        => true,
        ]);
        if ($secure) {
            curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
            curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0);
        }
        $vf = fopen('php://temp', 'w+');
        curl_setopt($ch, CURLOPT_STDERR, $vf);
        @curl_exec($ch);
        rewind($vf);
        $log = stream_get_contents($vf) ?: '';

        if (preg_match('/Entering Passive Mode \((\d+),(\d+),(\d+),(\d+),(\d+),(\d+)\)/', $log, $m)) {
            $ip    = "{$m[1]}.{$m[2]}.{$m[3]}.{$m[4]}";
            $dport = ((int) $m[5]) * 256 + (int) $m[6];
            $errno = 0; $errstr = '';
            $sock  = @fsockopen($ip, $dport, $errno, $errstr, 6);
            $reach = (bool) $sock;
            if ($sock) {
                fclose($sock);
            }
            return ['ip' => $ip, 'port' => $dport, 'reachable' => $reach];
        }
        return null;
    }

    private function probeDns(string $host): array
    {
        if (filter_var($host, FILTER_VALIDATE_IP)) {
            return ['stage' => 'DNS', 'ok' => true, 'detail' => "Literal IP {$host}"];
        }
        $resolved = $this->resolveHost($host);
        $ip = @gethostbyname($resolved);
        $ok = $ip !== $resolved && filter_var($ip, FILTER_VALIDATE_IP);
        $note = $resolved !== $host ? " (using {$resolved})" : '';
        return ['stage' => 'DNS', 'ok' => (bool) $ok, 'detail' => $ok ? "{$host} → {$ip}{$note}" : "Could not resolve host '{$host}'. Check the hostname for typos."];
    }

    private function probeTcp(string $host, int $port, int $timeout): array
    {
        $errno = 0;
        $errstr = '';
        $sock = @fsockopen($host, $port, $errno, $errstr, min($timeout, 10));
        if ($sock) {
            fclose($sock);
            return ['stage' => 'TCP reachability', 'ok' => true, 'detail' => "Port {$port} is open"];
        }
        return ['stage' => 'TCP reachability', 'ok' => false, 'detail' => "Port {$port} unreachable — {$errstr} (errno {$errno}). Check the host, port and any firewall."];
    }

    private function report(array $stages): array
    {
        $ok = array_reduce($stages, static fn($c, $s) => $c && $s['ok'], true);
        $failed = array_values(array_filter($stages, static fn($s) => !$s['ok']));
        $summary = $ok
            ? 'All checks passed — the server is reachable and the credentials work.'
            : ($failed[0]['stage'] . ' failed: ' . $failed[0]['detail']);
        return ['ok' => $ok, 'summary' => $summary, 'stages' => $stages];
    }

    private function lastPhpError(): string
    {
        $e = error_get_last();
        return $e['message'] ?? '';
    }

    private function parseMlsdTime(string $s): ?int
    {
        // YYYYMMDDHHMMSS
        $t = \DateTime::createFromFormat('YmdHis', substr($s, 0, 14));
        return $t ? $t->getTimestamp() : null;
    }

    private function parseUnixListLine(string $line): ?array
    {
        // e.g. "drwxr-xr-x 2 user group 4096 Jan 01 12:00 folder name"
        if (!preg_match('/^([\-dl])[rwxsStT\-]{9}\s+\d+\s+\S+\s+\S+\s+(\d+)\s+(\w{3}\s+\d+\s+[\d:]+)\s+(.+)$/', $line, $m)) {
            return null;
        }
        $name = $m[4];
        if ($name === '.' || $name === '..') {
            return null;
        }
        $type = $m[1] === 'd' ? 'dir' : ($m[1] === 'l' ? 'link' : 'file');
        if ($type === 'link') {
            $name = preg_replace('/\s+->\s+.*$/', '', $name);
        }
        $ts = strtotime($m[3]) ?: null;
        return ['name' => $name, 'type' => $type, 'size' => (int) $m[2], 'mtime' => $ts];
    }

    private function sortEntries(array $entries): array
    {
        usort($entries, static function ($a, $b) {
            if ($a['type'] !== $b['type']) {
                return $a['type'] === 'dir' ? -1 : 1;
            }
            return strcasecmp($a['name'], $b['name']);
        });
        return $entries;
    }
}
