<?php

defined("ABSPATH") or die("");

use Duplicator\Utils\Net\FtpClientInterface;
use Duplicator\Utils\Net\FTPUploadInfo;

class DUP_PRO_FTPcURL implements FtpClientInterface
{
    /** @var string FTP host */
    private $server;
    /** @var int FTP port */
    private $port = 21;
    /** @var string FTP username */
    private $username;
    /** @var string FTP password */
    private $password;
    /** @var string remote FTP directory */
    private $directory = '/';
    /** @var integer timeout in seconds */
    private $timeout_in_sec = 90;
    /** @var boolean whether FTP is ssl ftp or not */
    private $ssl = false;
    /** @var boolean whether FTP is passive mode or not */
    private $passive_mode = false;

    /**
     * class constructor
     *
     * @param string  $server         FTP host
     * @param integer $port           FTP port
     * @param string  $username       FTP username
     * @param string  $password       FTP password
     * @param string  $directory      remote FTP directory
     * @param integer $timeout_in_sec timeout in seconds
     * @param boolean $ssl            whether it is ssl ftp or not
     * @param boolean $passive_mode   whether it is passive mode or not
     *
     * @return void
     */
    public function __construct(
        $server,
        $port = 21,
        $username = 'anonymous',
        $password = 'anonymous@gmail.com',
        $directory = '/',
        $timeout_in_sec = 15,
        $ssl = false,
        $passive_mode = false
    ) {
        if (empty($server)) {
            throw new InvalidArgumentException(esc_html__('Invalid $server argument with the empty in DUP_PRO_FTPcURL class constructor.', "duplicator-pro"));
        }

        if (!is_string($server)) {
            throw new InvalidArgumentException(sprintf(esc_html__('Invalid $server argument with the value %s in DUP_PRO_FTPcURL class constructor.', "duplicator-pro")), $server);
        }

        if (empty($port)) {
            throw new InvalidArgumentException(esc_html__('Invalid $port argument with the empty in DUP_PRO_FTPcURL class constructor.', "duplicator-pro"));
        }
        $port = (int) $port;
        if ($port <= 0) {
            throw new InvalidArgumentException(sprintf(esc_html__('Invalid $port argument with the value %s in DUP_PRO_FTPcURL class constructor.', "duplicator-pro")), $port);
        }

        if (empty($username)) {
            throw new InvalidArgumentException(esc_html__('Invalid $username argument with the empty in DUP_PRO_FTPcURL class constructor.', "duplicator-pro"));
        }
        if (!is_string($username)) {
            throw new InvalidArgumentException(sprintf(esc_html__('Invalid $username argument with the value %s in DUP_PRO_FTPcURL class constructor.', "duplicator-pro")), $username);
        }

        if (empty($password)) {
            throw new InvalidArgumentException(esc_html__('Invalid $password argument with the empty in DUP_PRO_FTPcURL class constructor.', "duplicator-pro"));
        }
        if (!is_string($password)) {
            throw new InvalidArgumentException(sprintf(esc_html__('Invalid $password argument with the value %s in DUP_PRO_FTPcURL class constructor.', "duplicator-pro")), $password);
        }

        if (empty($directory)) {
            throw new InvalidArgumentException(esc_html__('Invalid $directory argument with the empty in DUP_PRO_FTPcURL class constructor.', "duplicator-pro"));
        }
        if (!is_string($directory)) {
            throw new InvalidArgumentException(sprintf(esc_html__('Invalid $directory argument with the value %s in DUP_PRO_FTPcURL class constructor.', "duplicator-pro")), $directory);
        }

        if (empty($timeout_in_sec)) {
            throw new InvalidArgumentException(esc_html__('Invalid $timeout_in_sec argument with the empty in DUP_PRO_FTPcURL class constructor.', "duplicator-pro"));
        }
        $timeout_in_sec = (int) $timeout_in_sec;
        if ($timeout_in_sec <= 0) {
            throw new InvalidArgumentException(sprintf(esc_html__('Invalid $timeout_in_sec argument with the value %s in DUP_PRO_FTPcURL class constructor.', "duplicator-pro")), $timeout_in_sec);
        }

        if (!is_bool($ssl)) {
            throw new InvalidArgumentException(sprintf(esc_html__('Invalid $ssl argument with the value %s in DUP_PRO_FTPcURL class constructor.', "duplicator-pro")), $ssl);
        }

        if (!is_bool($passive_mode)) {
            throw new InvalidArgumentException(sprintf(esc_html__('Invalid $passive_mode argument with the value %s  in DUP_PRO_FTPcURL class constructor.', "duplicator-pro")), $passive_mode);
        }

        $this->server         = $server;
        $this->port           = $port;
        $this->username       = $username;
        $this->password       = $password;
        $this->directory      = $directory;
        $this->timeout_in_sec = $timeout_in_sec;
        $this->ssl            = $ssl;
        $this->passive_mode   = $passive_mode;
    }

    /**
     * curl call
     *
     * @param string           $path    where the curl call occur
     * @param array<int,mixed> $options configuration options
     *
     * @return string
     */
    private function curl_call($path, $options = array())
    {
        $ch  = curl_init();
        $url = sprintf('ftp://%s:%d/%s', $this->server, $this->port, $this->untrailing_left_slash_path($path));
        curl_setopt($ch, CURLOPT_URL, $url);
        curl_setopt($ch, CURLOPT_USERPWD, sprintf('%s:%s', $this->username, $this->password));

        curl_setopt($ch, CURLOPT_HEADER, false);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_BINARYTRANSFER, true);
        curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, $this->timeout_in_sec);
        curl_setopt($ch, CURLOPT_TIMEOUT, 0);

        if ($this->ssl) {
            curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
            curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
            curl_setopt($ch, CURLOPT_FTP_SSL, CURLFTPSSL_TRY);
            curl_setopt($ch, CURLOPT_FTPSSLAUTH, CURLFTPAUTH_TLS);
        } else {
            // To do based on settings verify peer
            $global = DUP_PRO_Global_Entity::getInstance();

            $setopts[CURLOPT_SSL_VERIFYPEER] = $global->ssl_disableverify ? false : true;
            $setopts[CURLOPT_SSL_VERIFYHOST] = $global->ssl_disableverify ? 0 : 2;
            if (!$global->ssl_useservercerts) {
                $setopts[CURLOPT_CAINFO] = DUPLICATOR_PRO_CERT_PATH;
                $setopts[CURLOPT_CAPATH] = DUPLICATOR_PRO_CERT_PATH;
            }
        }

        if ($this->passive_mode) {
            curl_setopt($ch, CURLOPT_FTP_USE_EPSV, true);
        } else {
            curl_setopt($ch, CURLOPT_FTP_USE_EPRT, true);
            curl_setopt($ch, CURLOPT_FTPPORT, 0);
        }

        foreach ($options as $name => $value) {
            curl_setopt($ch, $name, $value);
        }

        if (($response = curl_exec($ch)) === false) {
            if (($errno = curl_errno($ch))) {
                switch ($errno) {
                    case 6:
                    case 7:
                        throw new Exception(esc_html__('Unable to connect to FTP server. Please check your FTP hostname, port, and active mode settings.', "duplicator-pro") . " Error code: $errno");
                    case 8:
                        throw new Exception(esc_html__('Got an unexpected reply from FTP server.', "duplicator-pro") . " Error code: $errno");
                    case 9:
                        throw new Exception(esc_html__('Unable to change FTP directory. Please ensure that you have permission on the server.', "duplicator-pro") . " Error code: $errno");
                    case 23:
                        throw new Exception(esc_html__('Unable to download file from FTP server. Please ensure that you have enough disk space.', "duplicator-pro") . " Error code: $errno");
                    case 28:
                        throw new Exception(esc_html__('Connecting to FTP server timed out. Please check FTP hostname, port, username, password, and active mode settings.', "duplicator-pro") . " Error code: $errno");
                    case 67:
                        throw new Exception(esc_html__('Unable to login to FTP server. Please check your username and password.', "duplicator-pro") . " Error code: $errno");
                    default:
                        throw new Exception(esc_html__('Unable to connect to FTP.', "duplicator-pro") . " Error code: $errno");
                }
            }
        }

        $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        if ($http_code >= 400) {
            throw new Exception(sprintf(esc_html__('Error code: %s.', "duplicator-pro"), $http_code));
        }

        return $response;
    }

    /**
     * test ftp connection
     *
     * @throws Exception upon error
     * @return boolean
     */
    public function test_conn()
    {
        try {
            $this->curl_call(
                '/',
                array(
                    CURLOPT_TIMEOUT => $this->timeout_in_sec,
                )
            );
        } catch (Exception $e) {
            $message = sprintf(esc_html__('Error connecting to FTP server %1$s:%2$d. Exception Error: %3$s.', "duplicator-pro"), $this->server, $this->port, $e->getMessage());
            DUP_PRO_Log::trace($message);
            throw $e;
        }
        return true;
    }

    /**
     * create a dir
     *
     * @param string $folder_path a dir path which need to create
     *
     * @return boolean
     */
    public function create_directory($folder_path)
    {
        try {
            $this->curl_call(
                $folder_path,
                array(CURLOPT_FTP_CREATE_MISSING_DIRS => true)
            );
        } catch (Exception $e) {
            DUP_PRO_Log::trace("Error creating folder $folder_path when using FTP cURL. FTP Info: " . $this->get_info() . ' Exception Error: ' . $e->getMessage());
            return false;
        }
        DUP_PRO_Log::trace("Successfully created folder $folder_path by FTP cURL.");
        return true;
    }

    /**
     * Upload file
     *
     * @param string $local_file_path local file path
     * @param string $storage_folder  The folder on the FTP server to upload to
     * @param string $dest_filename   destination file name
     *
     * @return bool
     */
    public function upload_file($local_file_path, $storage_folder, $dest_filename = '')
    {
        $remote_file_path = $this->untrailing_left_slash_path($this->directory) . "/" . $dest_filename;
        $file_size        = filesize($local_file_path);
        // Upload file from file stream
        $success = false;
        if (($file_stream = fopen($local_file_path, 'rb'))) {
            $options = array(
                CURLOPT_UPLOAD                  => true,
                // by chance If FTP curl storage set and In btw a user has deleted storage dir from a FTP program
                CURLOPT_FTP_CREATE_MISSING_DIRS => true,
                CURLOPT_INFILE                  => $file_stream,
                CURLOPT_INFILESIZE              => $file_size,
                CURLOPT_NOPROGRESS              => true,
            );
            // curl_call throws Exception in case of failure
            // curl_call returns curl_exec response, which we don't need here
            $this->curl_call($remote_file_path, $options);
            fclose($file_stream);
            $success = true;
        }
        return $success;
    }

    /**
     * download file
     *
     * @param string  $remote_file_path   file path relative to $this->directory
     * @param string  $local              either local file path or local dir, depends on the $is_local_directory
     * @param boolean $is_local_directory whether $local is local directory or full path to download
     *
     * @return boolean
     */
    public function download_file($remote_file_path, $local, $is_local_directory = true)
    {
        $remote_file_path = $this->untrailing_left_slash_path($remote_file_path);

        if ($is_local_directory) {
            $filename        = basename($remote_file_path);
            $local_file_path = "$local/$filename";
        } else {
            $local_file_path = $local;
        }

        if (($file_stream = fopen($local_file_path, 'wb'))) {
            try {
                $options = array(
                    CURLOPT_FILE       => $file_stream,
                    CURLOPT_NOPROGRESS => true,
                );
                $this->curl_call(sprintf('/%s/%s', $this->directory, $remote_file_path), $options);
            } catch (Exception $e) {
                DUP_PRO_Log::trace(
                    "Error downloading " . $remote_file_path . " into " . $local_file_path .
                    ". FTP Info: " . $this->get_info() . ' Exception Error: ' . $e->getMessage()
                );
                throw $e;
            }
            fclose($file_stream);
        } else {
            $errorMsg = "Error downloading " . $remote_file_path . " into " . $local_file_path . ". " .
                        "Could not open " . $local_file_path . " for writing.";
            DUP_PRO_Log::trace($errorMsg);
            throw new Exception($errorMsg);
        }
        return true;
    }

    /**
     * Checks if remote directory exists
     *
     * @param string $storage_folder remote folder path
     *
     * @return boolean
     */
    public function directory_exists($storage_folder)
    {
        $directoryExists = false;
        try {
            $directoryExists = is_array($this->raw_list_folder($storage_folder));
        } catch (Exception $ex) {
            $directoryExists = false;
        }
        return $directoryExists;
    }

    /**
     * delete remote file
     *
     * @param string $file_path remote file path
     *
     * @return boolean
     */
    public function delete($file_path)
    {
        $file_path = $this->untrailing_left_slash_path($file_path);
        try {
            $this->curl_call(
                sprintf('/%s/', $this->directory),
                array(
                    CURLOPT_QUOTE => array(sprintf('DELE /%s', $this->untrailing_left_slash_path(sprintf('/%s/%s', $this->directory, $file_path)))),
                )
            );
        } catch (Exception $e) {
            // Sometimes file is deleted successfully even though Exception is thrown in curl_call
            $fSize = -1;
            try {
                $fSize = $this->get_file_size($file_path);
            } catch (Exception $ex) {
                DUP_PRO_Log::trace("Possible problem deleting " . $file_path . " from " . $this->server . ". Exception error code: " . $ex->getCode() . ". Error Message: " . $ex->getMessage());
                return false;
            }
            if ($fSize > 0) {
                DUP_PRO_Log::trace("Failed to delete " . $file_path . " from " . $this->server . ". Exception error code: " . $e->getCode() . ". Error Message: " . $e->getMessage());
                return false;
            } // else consider it as successfully deleted
        }
        DUP_PRO_Log::trace("Successfully deleted " . $file_path . " from " . $this->server);
        return true;
    }

    /**
     * upload file by chunking mode
     *
     * @param string $source_filepath        local file path
     * @param string $storage_folder         remote folder
     * @param int    $max_upload_time_in_sec max upload time in sec in a chunk
     * @param int    $offset                 file offset
     * @param int    $server_load_delay      server load delay
     * @param string $dest_filename          remote file name
     *
     * @return FTPUploadInfo
     */
    public function upload_chunk($source_filepath, $storage_folder, $max_upload_time_in_sec = 15, $offset = 0, $server_load_delay = 0, $dest_filename = '')
    {
        DUP_PRO_Log::trace("----------------------------");
        DUP_PRO_Log::trace("FTP cURL CHUNK UPLOAD START");
        DUP_PRO_Log::trace("FTP CHUNK OFFSET IN=$offset");

        $ftp_upload_info = new FTPUploadInfo();
        $start_time      = time();

        $remote_file_path = !empty($dest_filename) ? $dest_filename : basename($source_filepath);
        try {
            DUP_PRO_Log::trace("Call upload_chunk for FTP cURL");
            $fp = fopen($source_filepath, 'rb');
            if ($fp) {
                // Read file chunk data
                while (
                    fseek($fp, $offset) !== -1
                    && $file_chunk_data = fread($fp, DUPLICATOR_PRO_FTP_CURL_CHUNK_SIZE)
                ) {
                    $ret = $this->upload_file_chunk($file_chunk_data, $remote_file_path, $offset);
                    if ($ret) {
                        $offset = ftell($fp);
                    }

                    $time_passed = time() - $start_time;
                    if ($time_passed >= $max_upload_time_in_sec) {
                        $offset = ftell($fp);
                        DUP_PRO_Log::trace("Timed out $max_upload_time_in_sec so saving offset $offset");
                        break;
                    }
                    if ($server_load_delay !== 0) {
                        usleep($server_load_delay);
                    }
                }
                $ftp_upload_info->next_offset = $offset;
            } else {
                $ftp_upload_info->error_details = sprintf(esc_html__('Error opening %1$ for cURL FTP', "duplicator-pro"), $source_filepath);
                DUP_PRO_Log::trace("Error opening $source_filepath for cURL FTP");
                $ftp_upload_info->next_offset = $offset;
            }
            DUP_PRO_Log::trace("Closing local file handle");
            fclose($fp);

            $source_filesize = filesize($source_filepath);
            if ($source_filesize <= $ftp_upload_info->next_offset) {
                $ftp_size = $this->get_file_size($remote_file_path);

                if ($ftp_size > 0 && $ftp_size != $source_filesize) {
                    DUP_PRO_Log::trace("cURL FTP size mismatch for $source_filepath. Local file has $source_filesize bytes while server's file has $ftp_size bytes.");
                    $ftp_upload_info->error_details = sprintf(esc_html__('cURL FTP size mismatch for %1$s. Local file=%2$d bytes while server\'s file is %3$d bytes.', "duplicator-pro"), $source_filepath, $source_filesize, $ftp_size);
                    $ftp_upload_info->fatal_error   = true;

                    $this->delete($remote_file_path);
                } else {
                    DUP_PRO_Log::trace("FTP cURL upload file finished");
                    $ftp_upload_info->success = true;
                }
            } else {
                DUP_PRO_Log::trace("FTP cURL upload file is not finished yet...");
            }
        } catch (Exception $e) {
            $ftp_upload_info->error_details = __(
                "Tried to upload file when connection wasn't opened. Info: ",
                "duplicator-pro"
            ) .
                $this->get_info() . " " . __('Exception Error: ', "duplicator-pro") . $e->getMessage();
            DUP_PRO_Log::trace(
                "Tried to upload file when connection wasn't opened. Info: " .
                $this->get_info() . " Exception Error: " . $e->getMessage()
            );
            $ftp_upload_info->next_offset = $offset;
        }
        DUP_PRO_Log::trace("FTP cURL CHUNK UPLOAD END");
        DUP_PRO_Log::trace("----------------------------");

        return $ftp_upload_info;
    }

    /**
     * upload file chunk with given data
     *
     * @param string  $file_chunk_data  file chunk data
     * @param string  $remote_file_path remote file path
     * @param integer $file_range_start file range start
     *
     * @throws Exception upon error
     * @return boolean
     */
    private function upload_file_chunk($file_chunk_data, $remote_file_path, $file_range_start = 0)
    {
        $remote_file_path = $this->untrailing_left_slash_path($remote_file_path);

        if (($file_chunk_stream = fopen('php://temp', 'wb+')) === false) {
            return true;
        }

        if (($file_chunk_size = fwrite($file_chunk_stream, $file_chunk_data)) === false) {
            return true;
        }

        rewind($file_chunk_stream);
        $this->curl_call(
            sprintf('/%s/%s', $this->directory, $remote_file_path),
            array(
                CURLOPT_UPLOAD     => true,
                CURLOPT_FTPAPPEND  => true,
                CURLOPT_INFILE     => $file_chunk_stream,
                CURLOPT_INFILESIZE => $file_chunk_size,
            )
        );
        fclose($file_chunk_stream);
        return true;
    }

    /**
     * get remote file size
     *
     * @param string $remote_file_name remote file name
     *
     * @return bool|integer gives size of remote file or false on failure
     */
    public function get_file_size($remote_file_name)
    {
        $items = $this->raw_list_folder($this->directory);
        foreach ($items as $item) {
            if ($meta = preg_split('/\s+/', $item)) {
                if (!empty($meta[8]) && !empty($meta[4]) && $meta[8] == $remote_file_name) {
                    return (int) $meta[4];
                }
            }
        }
        return false;
    }

    /**
     * get information of the FTP
     *
     * @return string
     */
    public function get_info()
    {
        $ssl_string     = DUP_PRO_STR::boolToString($this->ssl);
        $passive_string = DUP_PRO_STR::boolToString($this->passive_mode);

        return sprintf(esc_html__('Server:%1$s Port:%2$d User:%3$s Directory:%4$s SSL:%5$s Passive:%6$s.', "duplicator-pro"), $this->server, $this->port, $this->username, $this->directory, $ssl_string, $passive_string);
    }

    /**
     * get all files in remote folder
     *
     * @param string $directory remote folder path
     *
     * @return string[]|false
     */
    public function get_filelist($directory = '.')
    {
        $items = $this->raw_list_folder($directory);
        $files = array();
        foreach ($items as $item) {
            if ($meta = preg_split('/\s+/', $item)) {
                if (!empty($meta[8])) {
                    $files[] = trim($meta[8]);
                }
            }
        }
        DUP_PRO_Log::trace("Get file list from cURL FTP: " . print_r($files, true));
        return $files;
    }

    /**
     * get all files meta information
     *
     * @param string $folder_path remote dir path
     *
     * @return string[]
     */
    private function raw_list_folder($folder_path)
    {
        $folder_path = $this->untrailing_left_slash_path($folder_path);
        $items       = $this->curl_call(
            sprintf('/%s/', $folder_path),
            array(CURLOPT_CUSTOMREQUEST => 'LIST -tlA')
        );
        return explode("\n", $items);
    }

    /**
     * clean path with removing left slash
     *
     * @param string $path path to clean
     *
     * @return string
     */
    private function untrailing_left_slash_path($path)
    {
        return ltrim(preg_replace('/[\\\\\/]+/', '/', $path), '/');
    }
}
