<?php

namespace App\Services;

use phpseclib3\Net\SFTP;
use RuntimeException;

/**
 * File operations against a per-credit-account "Server" storage backend
 * (SFTP or plain FTP), as an alternative to SharePoint. Connection details
 * come from AccountStorageResolver::resolveServerUpload() — protocol/host/
 * port/username/decrypted password.
 *
 * SFTP uses phpseclib3 (pure PHP, no server extension needed — works on any
 * host including cPanel). FTP uses PHP's native ftp_* functions.
 *
 * Scope note: upload-only for now (mirrors the SharePoint upload phase).
 * downloadFileBytes/fileExists/deleteFile are included since the connection
 * scaffolding makes them cheap, but nothing yet calls them — OCR read-back,
 * preview, and move/rename/delete for Server-stored documents are follow-ups.
 */
class ServerStorageService
{
    /** @var array<string, SFTP> */
    private array $sftpConnections = [];

    /** @var array<string, \FTP\Connection> */
    private array $ftpConnections = [];

    public function __destruct()
    {
        foreach ($this->ftpConnections as $conn) {
            @ftp_close($conn);
        }
    }

    /**
     * Upload a local file into a remote folder, creating the folder path if
     * missing. Returns the remote path (relative, no scheme/host).
     *
     * @param  array{protocol:string,host:string,port:?string,username:?string,password:string} $config
     * @param  string $conflictBehavior 'replace' (default) | 'fail' — 'fail' throws a
     *                                  RuntimeException with code 409 on a name collision.
     */
    public function uploadFile(array $config, string $remoteFolder, string $filename, string $localPath, string $conflictBehavior = 'replace'): string
    {
        $remoteFolder = trim($remoteFolder, '/');
        $remotePath   = $remoteFolder !== '' ? "{$remoteFolder}/{$filename}" : $filename;

        if ($this->protocol($config) === 'ftp') {
            $conn = $this->connectFtp($config);
            $this->ensureFolderFtp($conn, $remoteFolder);

            if ($conflictBehavior === 'fail' && @ftp_size($conn, $remotePath) !== -1) {
                throw new RuntimeException("File already exists on server: {$filename}", 409);
            }
            if (!ftp_put($conn, $remotePath, $localPath, FTP_BINARY)) {
                throw new RuntimeException("Failed to upload file via FTP: {$filename}");
            }
            return $remotePath;
        }

        $remoteFolder = $this->normalizeSftpPath($remoteFolder);
        $remotePath   = $remoteFolder !== '' ? "{$remoteFolder}/{$filename}" : $filename;

        $sftp = $this->connectSftp($config);
        $this->ensureFolderSftp($sftp, $remoteFolder);

        if ($conflictBehavior === 'fail' && $sftp->file_exists($remotePath)) {
            throw new RuntimeException("File already exists on server: {$filename}", 409);
        }
        if (!$sftp->put($remotePath, $localPath, SFTP::SOURCE_LOCAL_FILE)) {
            throw new RuntimeException("Failed to upload file via SFTP: {$filename}: " . $sftp->getLastSFTPError());
        }
        return $remotePath;
    }

    /**
     * Download a file's raw bytes from the server.
     *
     * @param array{protocol:string,host:string,port:?string,username:?string,password:string} $config
     */
    public function downloadFileBytes(array $config, string $remotePath): string
    {
        if ($this->protocol($config) === 'ftp') {
            $conn = $this->connectFtp($config);
            $tmp  = tempnam(sys_get_temp_dir(), 'ftpdl');
            if (!ftp_get($conn, $tmp, $remotePath, FTP_BINARY)) {
                @unlink($tmp);
                throw new RuntimeException("Failed to download file via FTP: {$remotePath}");
            }
            $bytes = file_get_contents($tmp);
            @unlink($tmp);
            return $bytes;
        }

        $sftp  = $this->connectSftp($config);
        $bytes = $sftp->get($this->normalizeSftpPath($remotePath));
        if ($bytes === false) {
            throw new RuntimeException("Failed to download file via SFTP: {$remotePath}: " . $sftp->getLastSFTPError());
        }
        return $bytes;
    }

    /**
     * Check whether a file exists on the server.
     *
     * @param array{protocol:string,host:string,port:?string,username:?string,password:string} $config
     */
    public function fileExists(array $config, string $remotePath): bool
    {
        if ($this->protocol($config) === 'ftp') {
            $conn = $this->connectFtp($config);
            return @ftp_size($conn, $remotePath) !== -1;
        }

        return $this->connectSftp($config)->file_exists($this->normalizeSftpPath($remotePath));
    }

    /**
     * Delete a file. Idempotent — returns true if deleted OR already absent.
     *
     * @param array{protocol:string,host:string,port:?string,username:?string,password:string} $config
     */
    public function deleteFile(array $config, string $remotePath): bool
    {
        if ($this->protocol($config) === 'ftp') {
            $conn = $this->connectFtp($config);
            if (@ftp_delete($conn, $remotePath)) {
                return true;
            }
            return @ftp_size($conn, $remotePath) === -1; // already gone
        }

        $sftp       = $this->connectSftp($config);
        $remotePath = $this->normalizeSftpPath($remotePath);
        if (!$sftp->file_exists($remotePath)) {
            return true;
        }
        if (!$sftp->delete($remotePath)) {
            throw new RuntimeException("Failed to delete file via SFTP: {$remotePath}: " . $sftp->getLastSFTPError());
        }
        return true;
    }

    /**
     * Move/rename a file from $fromPath to $toPath (both relative to the login
     * home, or absolute), creating the destination folder if missing. Returns
     * the new path. Used at Move-to-Storage to file a client document into a
     * chosen folder on its own backend.
     *
     * @param array{protocol:string,host:string,port:?string,username:?string,password:string} $config
     */
    public function moveFile(array $config, string $fromPath, string $toPath): string
    {
        if ($this->protocol($config) === 'ftp') {
            $conn = $this->connectFtp($config);
            $this->ensureFolderFtp($conn, $this->parentDir($toPath));
            if (!@ftp_rename($conn, $fromPath, $toPath)) {
                throw new RuntimeException("Failed to move file via FTP: {$fromPath} -> {$toPath}");
            }
            return $toPath;
        }

        $sftp = $this->connectSftp($config);
        $from = $this->normalizeSftpPath($fromPath);
        $to   = $this->normalizeSftpPath($toPath);
        $this->ensureFolderSftp($sftp, $this->normalizeSftpPath($this->parentDir($toPath)));
        if (!$sftp->rename($from, $to)) {
            throw new RuntimeException("Failed to move file via SFTP: {$from} -> {$to}: " . $sftp->getLastSFTPError());
        }
        return $toPath;
    }

    private function parentDir(string $path): string
    {
        $p = rtrim(str_replace('\\', '/', $path), '/');
        $i = strrpos($p, '/');
        return $i === false ? '' : substr($p, 0, $i);
    }

    /**
     * List the sub-folders directly under $path (folders only, no files),
     * sorted alphabetically. $path is relative to the login user's home
     * directory (empty = home itself). Used by the storage-config folder
     * picker so an admin can browse + choose a base path instead of typing it.
     *
     * @param array{protocol:string,host:string,port:?string,username:?string,password:string} $config
     * @return list<string>  folder names (not full paths)
     */
    public function listFolders(array $config, string $path = ''): array
    {
        if ($this->protocol($config) === 'ftp') {
            return $this->listFoldersFtp($this->connectFtp($config), trim($path));
        }

        $sftp = $this->connectSftp($config);
        $norm = $this->normalizeSftpPath(trim($path));
        $raw  = $sftp->rawlist($norm !== '' ? $norm : '.');
        if ($raw === false) {
            throw new RuntimeException('Cannot list folder: ' . ($path !== '' ? $path : '(home)'));
        }

        $folders = [];
        foreach ($raw as $name => $attrs) {
            if ($name === '.' || $name === '..' || !is_array($attrs)) {
                continue;
            }
            if (($attrs['type'] ?? null) === \NET_SFTP_TYPE_DIRECTORY) {
                $folders[] = $name;
            }
        }
        sort($folders);
        return $folders;
    }

    /**
     * Create a single sub-folder $name under $path. Idempotent-ish: an existing
     * folder is not an error (mkdir -p semantics). Returns the new folder's path
     * (relative, as the picker should navigate into it next).
     *
     * @param array{protocol:string,host:string,port:?string,username:?string,password:string} $config
     */
    public function createFolder(array $config, string $path, string $name): string
    {
        $name = trim($name, "/ \t\n\r\0\x0B");
        if ($name === '' || str_contains($name, '/')) {
            throw new RuntimeException('Invalid folder name.');
        }
        $base = trim($path, '/');
        $full = $base !== '' ? "{$base}/{$name}" : $name;

        if ($this->protocol($config) === 'ftp') {
            $conn = $this->connectFtp($config);
            if (!$this->ftpFolderExists($conn, $base, $name) && !@ftp_mkdir($conn, $full)) {
                throw new RuntimeException('Failed to create folder: ' . $full);
            }
            return $full;
        }

        $sftp     = $this->connectSftp($config);
        $fullNorm = $this->normalizeSftpPath($full);
        if (!$sftp->file_exists($fullNorm) && !$sftp->mkdir($fullNorm, -1, true)) {
            throw new RuntimeException('Failed to create folder: ' . $full . ': ' . $sftp->getLastSFTPError());
        }
        return $full;
    }

    /** @param \FTP\Connection $conn */
    private function listFoldersFtp($conn, string $path): array
    {
        $dir = $path !== '' ? $path : '.';

        // Prefer MLSD (machine-readable, has an explicit type=dir); fall back to
        // parsing a raw UNIX listing for older servers that don't support MLSD.
        $mlsd = @ftp_mlsd($conn, $dir);
        if (is_array($mlsd)) {
            $folders = [];
            foreach ($mlsd as $item) {
                $n = $item['name'] ?? '';
                if (($item['type'] ?? '') === 'dir' && $n !== '' && $n !== '.' && $n !== '..') {
                    $folders[] = $n;
                }
            }
            sort($folders);
            return $folders;
        }

        $raw = @ftp_rawlist($conn, $dir) ?: [];
        $folders = [];
        foreach ($raw as $line) {
            if (($line[0] ?? '') !== 'd') {
                continue; // only directory lines ("drwxr-xr-x ...")
            }
            $parts = preg_split('/\s+/', $line, 9);
            $n = $parts[8] ?? '';
            if ($n !== '' && $n !== '.' && $n !== '..') {
                $folders[] = $n;
            }
        }
        sort($folders);
        return $folders;
    }

    /** @param \FTP\Connection $conn */
    private function ftpFolderExists($conn, string $base, string $name): bool
    {
        return in_array($name, $this->listFoldersFtp($conn, $base), true);
    }

    // ─── Helpers ─────────────────────────────────────────────────────────────

    private function protocol(array $config): string
    {
        return strtolower($config['protocol'] ?? 'sftp') === 'ftp' ? 'ftp' : 'sftp';
    }

    /**
     * Win32-OpenSSH's sftp-server represents an absolute Windows path with a
     * leading slash before the drive letter (e.g. "/C:/Users/...", matching
     * what pwd()/realpath() return for the session's home directory). Without
     * that leading slash, phpseclib's realpath() treats a bare "C:/..." path
     * as RELATIVE and resolves it against the session's cwd — silently gluing
     * it onto the home directory path and producing a path that doesn't exist
     * (surfaces as NET_SFTP_STATUS_NO_SUCH_FILE, not a clear "bad path" error).
     * A POSIX-style path (Linux/Mac servers, already starting with "/") is
     * left untouched.
     */
    private function normalizeSftpPath(string $path): string
    {
        // SFTP always uses forward slashes — convert any Windows backslashes first
        // (a pasted "C:\a\b" or "sub\dir" would otherwise be an invalid remote path).
        $path = str_replace('\\', '/', $path);
        if (preg_match('#^[A-Za-z]:/#', $path)) {
            return '/' . $path;
        }
        return $path;
    }

    private function connKey(array $config): string
    {
        return md5(implode('|', [
            $this->protocol($config),
            $config['host'] ?? '',
            $config['port'] ?? '',
            $config['username'] ?? '',
        ]));
    }

    private function connectSftp(array $config): SFTP
    {
        $key = $this->connKey($config);
        if (isset($this->sftpConnections[$key])) {
            return $this->sftpConnections[$key];
        }

        $port = (int) ($config['port'] ?? 22) ?: 22;
        $sftp = new SFTP($config['host'], $port, 15);
        if (!$sftp->login($config['username'] ?? '', $config['password'] ?? '')) {
            throw new RuntimeException("SFTP login failed for {$config['host']}:{$port}");
        }

        return $this->sftpConnections[$key] = $sftp;
    }

    /** @return \FTP\Connection */
    private function connectFtp(array $config)
    {
        $key = $this->connKey($config);
        if (isset($this->ftpConnections[$key])) {
            return $this->ftpConnections[$key];
        }

        $port = (int) ($config['port'] ?? 21) ?: 21;
        $conn = @ftp_connect($config['host'], $port, 15);
        if (!$conn) {
            throw new RuntimeException("FTP connection failed for {$config['host']}:{$port}");
        }
        if (!@ftp_login($conn, $config['username'] ?? '', $config['password'] ?? '')) {
            throw new RuntimeException("FTP login failed for {$config['host']}:{$port}");
        }
        ftp_pasv($conn, true); // passive mode — needed behind most NAT/firewalls

        return $this->ftpConnections[$key] = $conn;
    }

    /**
     * Delegates to phpseclib's own recursive mkdir rather than walking segments
     * ourselves. phpseclib's mkdir() resolves the path via SSH_FXP_REALPATH
     * first, so it correctly normalizes a Windows absolute path (e.g.
     * "C:/Users/.../Documents") before splitting it; a manual segment walk on
     * the raw string previously started from a bare "C:" with no leading
     * slash, which Win32-OpenSSH's sftp-server did not respond to cleanly
     * (observed as an indefinite hang, not a clean error).
     */
    private function ensureFolderSftp(SFTP $sftp, string $folder): void
    {
        // rtrim only — a LEADING slash is significant here (it's what marks a
        // normalized absolute Windows path, e.g. "/C:/Users/..."; stripping it
        // would silently turn the path relative again, see normalizeSftpPath).
        $folder = rtrim($folder, '/');
        if ($folder === '' || $sftp->file_exists($folder)) {
            return;
        }
        $sftp->mkdir($folder, -1, true);
    }

    /** @param \FTP\Connection $conn */
    private function ensureFolderFtp($conn, string $folder): void
    {
        $folder = trim($folder, '/');
        if ($folder === '') {
            return;
        }
        $built = '';
        foreach (explode('/', $folder) as $part) {
            $built = $built === '' ? $part : "{$built}/{$part}";
            @ftp_mkdir($conn, $built); // idempotent — @ suppresses "already exists"
        }
    }
}
