<?php
/**
 * This file is for performing the API service functionality.
 *
 * @package    ProductSync
 * @subpackage API_Service
 * @author     miniOrange <info@miniorange.com>
 * @link       https://miniorange.com
 */

namespace MOWCPS\Common\Services\FTP;

use MOWCPS\Common\Constants\Constants;
use MOWCPS\Common\Services\API\API_Request_Service;
use MOWCPS\Common\Models\Connection_Model;
use MOWCPS\Common\Models\Setting_Model;
use MOWCPS\Common\Models\Sync_Details_DAO;
use MOWCPS\Common\Services\Logging\Supplier_Logger;
use phpseclib3\Net\SFTP;
use MOWCPS\Common\Utils\Utils;
use MOWCPS\Common\Services\ReadFileData\Read_File_Data;

/**
 * FTP Service class for handling FTP connections and file operations.
 */
class FTP_Service {

	/**
	 * Constructor.
	 */
	public function __construct() {}

	/**
	 * Retrieve a file from the FTP/SFTP server for the given supplier and connection.
	 *
	 * Attempts to connect and download a file from the FTP/SFTP server associated with the
	 * specified supplier and connection. Handles both FTP and SFTP protocols, and may throw
	 * an exception if login fails.
	 *
	 * @param int    $supplier_id                Supplier identifier.
	 * @param string $connection_id              Sync connection identifier.
	 *
	 * @throws \Exception If login to FTP/SFTP server fails.
	 *
	 * @return string The local path or contents of the downloaded file.
	 *
	 * @note If experiencing connection timing issues (e.g., "php_connect_nonb() failed"),
	 *       consider adding a short sleep delay before calling ftp_get() or ssh2_scp_recv().
	 */
	public static function get_file_from_ftp( $supplier_id, $connection_id ) {
		$connection_file_settings = Setting_Model::get_by_connection_and_operation( $connection_id, 'file-sync', array( 'configuration' => true ) );
		$filename                 = $connection_file_settings['configuration']['file_name'];
		$file_in_zip              = $connection_file_settings['configuration']['is_zip'];
		$delimiter                = $connection_file_settings['configuration']['delimiter'] ?? '';

		$file_name      = Constants::CONFIGURATION_ACTIVE === $file_in_zip ? $connection_id . '_zip.zip' : $filename;
		$file_directory = MOWCPS_PLUGIN_DIR . 'data' . DIRECTORY_SEPARATOR . 'external-supplier' . DIRECTORY_SEPARATOR . 'api-file-response';
		$file_path      = $file_directory . DIRECTORY_SEPARATOR . $connection_id . DIRECTORY_SEPARATOR . $file_name;

		$connection_file_config = Connection_Model::get_by_id( $connection_id )['configuration'];
		$server                 = $connection_file_config['host'];
		$user                   = $connection_file_config['username'];
		$password               = $connection_file_config['password'];
		$remote_file            = $connection_file_config['file_path'];
		$port                   = $connection_file_config['port'];

		// Check if protocol is specified, otherwise detect based on port (22 = SFTP, 21 = FTP)
		$protocol = isset( $connection_file_config['protocol'] ) ? strtolower( $connection_file_config['protocol'] ) : ( ( 22 === (int) $port ) ? 'sftp' : 'ftp' );
		$is_sftp  = ( 'sftp' === $protocol );

		// Retry mechanism settings.
		$retry_attempts  = 5;  // Number of retry attempts for file download.
		$timeout_seconds = 120;  // Timeout in seconds for FTP/SFTP operations.
		$connect         = false;

		if ( WP_Filesystem() ) {
			global $wp_filesystem;

			$external_supplier_dir = MOWCPS_PLUGIN_DIR . 'data' . DIRECTORY_SEPARATOR . 'external-supplier';

			if ( ! $wp_filesystem->is_dir( $external_supplier_dir ) ) {
				$wp_filesystem->mkdir( $external_supplier_dir, 0755 );
			}

			if ( ! $wp_filesystem->is_dir( $file_directory . DIRECTORY_SEPARATOR . $connection_id ) ) {
				$wp_filesystem->mkdir( $file_directory, 0755 );
				$wp_filesystem->mkdir( $file_directory . DIRECTORY_SEPARATOR . $connection_id, 0755 );
			}
			$file_directory = $file_directory . DIRECTORY_SEPARATOR . $connection_id;
		} else {
			wp_die( 'Failed to initialize the WordPress Filesystem.' );
		}

		// Set unlimited execution time to avoid timeouts.
		set_time_limit( -1 );

		$connect         = false;
		$file_downloaded = false;
		try {
			for ( $attempt = 1; $attempt <= $retry_attempts; $attempt++ ) {
				try {
					if ( $is_sftp ) {
						$connect = self::connect_and_login_sftp( $server, $user, $password, $timeout_seconds, $port );
						if ( ! $connect ) {
							throw new \Exception( 'SFTP connection or login failed on attempt ' . $attempt );
						}

						if ( ! empty( $connection_file_settings['configuration']['file_in_batch'] ) && Constants::CONFIGURATION_ACTIVE === $connection_file_settings['configuration']['file_in_batch'] ) {
							sleep( 10 );
						}

						if ( self::download_file_sftp( $connect, $remote_file, $file_path ) ) {
							$file_downloaded = true;
							break;
						} else {
							Supplier_Logger::debug( $supplier_id, 'Attempt ' . $attempt . ': SFTP file download failed. Retrying...' );
						}
					} else {
						$connect = self::connect_and_login_ftp( $server, $user, $password, $timeout_seconds, $port );
						if ( ! $connect ) {
							throw new \Exception( 'FTP connection or login failed on attempt ' . $attempt );
						}

						if ( ! empty( $connection_file_settings['configuration']['file_in_batch'] ) && Constants::CONFIGURATION_ACTIVE === $connection_file_settings['configuration']['file_in_batch'] ) {
							sleep( 10 );
						}

						$file_path = wp_normalize_path( $file_path );
						if ( ftp_get( $connect, $file_path, $remote_file, FTP_BINARY ) ) {
							$file_downloaded = true;
							break;
						} else {
							Supplier_Logger::debug( $supplier_id, 'Attempt ' . $attempt . ': ftp_get() failed. Retrying...' );
						}
					}
				} catch ( \Exception $e ) {
					Supplier_Logger::debug( $supplier_id, $e->getMessage() );

					if ( $attempt === $retry_attempts ) {
						$protocol_name = $is_sftp ? 'SFTP' : 'FTP';
						wp_die( 'Error: ' . $protocol_name . ' connection failed after ' . $retry_attempts . ' attempts.' );
					}
				} finally {
					if ( $connect ) {
						if ( $is_sftp ) {
							// phpseclib SFTP object doesn't need explicit disconnect
							// Connection will be closed when object is destroyed
							unset( $connect );
						} else {
							ftp_close( $connect );
						}
					}
				}
				sleep( 5 );
			}
			if ( ! $file_downloaded ) {
				$schedule_id                      = Sync_Details_DAO::get_instance()->get_schedule_id();
				$sync_analytics                   = Sync_Details_DAO::get_instance()->get_connection_analytics( $supplier_id, $schedule_id, $connection_id );
				$sync_analytics->sync_status_code = Constants::FILE_DOWNLOAD_FAILED;
				$sync_analytics->save();
				throw new \Exception( 'File download failed after all retry attempts.' );
			}
		} catch ( \Exception $e ) {
			Supplier_Logger::debug( $supplier_id, 'Error in get_file_from_ftp: ' . $e->getMessage() );

			return array();
		}
		// Extract the downloaded ZIP file if necessary.
		self::extract_zip( $file_path, $file_directory );
		$use_first_row_as_header = $connection_file_config['use_first_row_as_header'] ?? true;
		$data                    = Read_File_Data::read_data_from_file( $file_directory, $filename, ( ! empty( $connection_file_settings['configuration']['file_in_batch'] ) && Constants::CONFIGURATION_ACTIVE === $connection_file_settings['configuration']['file_in_batch'] ) ? 0 : false, ( ! empty( $connection_file_settings['configuration']['file_in_batch'] ) && Constants::CONFIGURATION_ACTIVE === $connection_file_settings['configuration']['file_in_batch'] ) ? 5000 : false, $use_first_row_as_header, $delimiter );

		return $data;
	}

	/**
	 * Establishes an FTP connection and logs in using the provided credentials.
	 *
	 * @param string $host     FTP server hostname or IP address.
	 * @param string $username FTP username.
	 * @param string $password FTP password.
	 * @param int    $timeout  Connection timeout in seconds. Default is 10.
	 * @param int    $port     FTP port number. Default is 21.
	 *
	 * @return resource|false  Returns the FTP stream on success, or false on failure.
	 */
	public static function connect_and_login_ftp( string $host, string $username, string $password, int $timeout = 10, int $port = 21 ) {
		$connection = @ftp_connect( $host, $port, $timeout );
		if ( $connection && @ftp_login( $connection, $username, $password ) ) {
			ftp_pasv( $connection, true ); // Enable passive mode.
			return $connection;
		}

		if ( $connection ) {
			ftp_close( $connection );
		}
		return false;
	}

	/**
	 * Establishes an SFTP connection and logs in using the provided credentials.
	 *
	 * Uses phpseclib library which is a pure PHP implementation and doesn't require ssh2 extension.
	 *
	 * @param string $host     SFTP server hostname or IP address.
	 * @param string $username SFTP username.
	 * @param string $password SFTP password.
	 * @param int    $timeout  Connection timeout in seconds. Default is 10.
	 * @param int    $port     SFTP port number. Default is 22.
	 *
	 * @return SFTP|false  Returns the SFTP object on success, or false on failure.
	 */
	public static function connect_and_login_sftp( string $host, string $username, string $password, int $timeout = 10, int $port = 22 ) {

		// Load Composer autoloader if available
		if ( file_exists( MOWCPS_PLUGIN_DIR . 'vendor/autoload.php' ) ) {
			require_once MOWCPS_PLUGIN_DIR . 'vendor/autoload.php';
		}

		// If phpseclib3 still isn't available, register fallback autoloader
		if ( ! class_exists( 'phpseclib3\\Net\\SFTP', false ) ) {

			$base = MOWCPS_PLUGIN_DIR . 'vendor/phpseclib/phpseclib/phpseclib/';

			// Register ONLY ONCE
			static $phpseclib_loaded = false;

			if ( ! $phpseclib_loaded ) {
				spl_autoload_register(
					function ( $class ) use ( $base ) {

						if ( strpos( $class, 'phpseclib3\\' ) !== 0 ) {
								return;
						}

						$relative = substr( $class, strlen( 'phpseclib3\\' ) );
						$file     = $base . str_replace( '\\', DIRECTORY_SEPARATOR, $relative ) . '.php';

						if ( file_exists( $file ) ) {
							require $file;
						}
					}
				);

				$phpseclib_loaded = true;
			}
		}

		try {
			// Create SFTP connection
			$sftp = new \phpseclib3\Net\SFTP( $host, $port, $timeout );

			if ( $sftp->login( $username, $password ) ) {
				return $sftp;
			}

			return false;

		} catch ( \Exception $e ) {

			return false;
		}
	}

	/**
	 * Downloads a file from SFTP server to local path.
	 *
	 * @param \phpseclib3\Net\SFTP $sftp       SFTP connection object.
	 * @param string               $remote_file Remote file path on SFTP server.
	 * @param string               $local_file  Local file path to save the downloaded file.
	 *
	 * @return bool Returns true on success, false on failure.
	 */
	public static function download_file_sftp( $sftp, string $remote_file, string $local_file ) {
		if ( ! $sftp || ! ( $sftp instanceof \phpseclib3\Net\SFTP ) ) {
			return false;
		}

		try {
			// Ensure directory exists before writing file
			$local_dir = dirname( $local_file );
			if ( ! is_dir( $local_dir ) ) {
				if ( ! wp_mkdir_p( $local_dir ) ) {
					return false;
				}
			}

			// Check if directory is writable
			if ( ! is_writable( $local_dir ) ) {
				return false;
			}

			// Get file contents from SFTP server
			$file_contents = $sftp->get( $remote_file );

			if ( false === $file_contents ) {
				return false;
			}

			// Use WordPress Filesystem API for consistency
			if ( WP_Filesystem() ) {
				global $wp_filesystem;
				$bytes_written = $wp_filesystem->put_contents( $local_file, $file_contents, 0644 );
			} else {
				// Fallback to file_put_contents if WordPress Filesystem is not available
				$bytes_written = @file_put_contents( $local_file, $file_contents );
			}

			if ( false === $bytes_written ) {
				return false;
			}

			return true;
		} catch ( \Exception $e ) {
			error_log( 'Exception while downloading file from SFTP: ' . $e->getMessage() );
			return false;
		}
	}

	/**
	 * Extract file from the zip file.
	 *
	 * @param string $file_path      File path to extract.
	 * @param string $file_directory Zip directory to extract from.
	 * @return void|string
	 */
	public static function extract_zip( $file_path, $file_directory ) {
		$zip             = new \ZipArchive();
		$path_to_extract = $file_directory;

		if ( $zip->open( $file_path ) === true ) {
			$zip->extractTo( $path_to_extract );
			$zip->close();
		} else {
			$return_data = 'File extraction failed';
			return $return_data;
		}
	}

	/**
	 * Reads data from a file (CSV, TSV, TXT, or XLSX) in batches. Can read a specific range of lines, depending on the $start_line and $end_line parameters.
	 *
	 * @param string    $file_directory The directory path where the file is located.
	 * @param string    $file_name      The name of the file to read.
	 * @param int|false $start_line     Line number to start reading from (1-based index).
	 * @param int|false $end_line       Line number to stop reading at (inclusive).
	 * @param string    $delimiter      Delimiter which user has set on UI.
	 *
	 * @return array Returns a multidimensional array containing parsed file data. Returns an empty array if file type is unsupported or unreadable.
	 */
	public static function read_data_from_file( $file_directory, $file_name, $start_line = false, $end_line = false, $delimiter = '' ) {
		$file_path = $file_directory . DIRECTORY_SEPARATOR . $file_name;
		if ( ! file_exists( $file_path ) || ! is_readable( $file_path ) ) {
			Supplier_Logger::debug( Sync_Details_DAO::get_instance()->get_supplier_id(), 'Error in read_data_from_file: File not found or not readable: ' . $file_path );
			return array();
		}

		$file_type = strtolower( pathinfo( $file_name, PATHINFO_EXTENSION ) );
		$data      = array();

		$should_limit_lines = is_numeric( $start_line ) && is_numeric( $end_line );

		if ( $should_limit_lines ) {
			$start_line = max( 1, (int) $start_line );
			$end_line   = (int) $end_line;
		}

		if ( in_array( $file_type, array( Constants::TXT_FILE, Constants::TSV_FILE, Constants::CSV_FILE ) ) ) {

			if ( empty( $delimiter ) ) {
				$delimiter = ( Constants::TSV_FILE === $file_type ) ? "\t" : ( ( Constants::CSV_FILE === $file_type ) ? ',' : '|' );
			} else {
				switch ( $delimiter ) {
					case Constants::DELIMITER_COMMA:
						$delimiter = ',';
						break;
					case Constants::DELIMITER_PIPE:
						$delimiter = '|';
						break;
					case Constants::DELIMITER_TAB:
						$delimiter = "\t";
						break;
					case Constants::DELIMITER_SEMICOLON:
						$delimiter = ';';
						break;
					case Constants::DELIMITER_COLON:
						$delimiter = ':';
						break;
					case Constants::DELIMITER_SPACE:
						$delimiter = ' ';
						break;
					case Constants::DELIMITER_TILDE:
						$delimiter = '~';
						break;
					case Constants::DELIMITER_CARET:
						$delimiter = '^';
						break;
					default:
						// fallback if value is unknown
						$delimiter = ',';
				}
			}

			$file_pointer = fopen( $file_path, 'r' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_read_fopen -- $file_path is a valid file path.
			$current_line = 0;

			if ( false !== $file_pointer ) {
				$bom_check = fread( $file_pointer, 3 );
				if ( $bom_check !== "\xEF\xBB\xBF" ) {
					rewind( $file_pointer );
				}

				// Skip header row once; remaining rows are parsed with fgetcsv() so quoted fields
				// can span multiple physical lines (aligned with Read_File_Data::read_data_from_file).
				if ( in_array( $file_type, array( Constants::CSV_FILE, Constants::TXT_FILE, Constants::TSV_FILE ), true ) ) {
					fgetcsv( $file_pointer, 0, $delimiter, '"', '\\' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_read_fgetcsv -- local file path.
					$current_line = 1;
				}

				while ( ( $parsed = fgetcsv( $file_pointer, 0, $delimiter, '"', '\\' ) ) !== false ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_read_fgetcsv -- local file path.
					++$current_line;

					// Skip lines outside the range if filtering (line numbers are logical CSV records, not physical lines).
					if ( $should_limit_lines && ( $current_line < $start_line || $current_line > $end_line ) ) {
						continue;
					}

					$data[] = $parsed;

					if ( $should_limit_lines && $current_line >= $end_line ) {
						break;
					}
				}
				fclose( $file_pointer ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_read_fclose -- $file_pointer is a valid file pointer.
			}
		} elseif ( Constants::XLSX_FILE === $file_type || Constants::XLS_FILE === $file_type ) {
			// Use utility function for XLSX or XLS reading.
			$data = Utils::read_xlsx_file( $file_path, $should_limit_lines, $start_line, $end_line );
		} else {
			return array();
		}
		return $data;
	}

	/**
	 * Read a batch of lines from the API response file for a specific connection.
	 *
	 * This method reads lines in batches of 5000 from the API response file stored
	 * in a predefined directory structure for the given connection.
	 *
	 * @param int    $connection_id The connection ID.
	 * @param int    $batch_number  The batch number to read (1-based).
	 * @param string $sync_filename The name of the sync file to read from.
	 *
	 * @return array The lines read from the file in the specified batch range.
	 */
	public function read_file_in_batch( $connection_id, $batch_number, $sync_filename, $delimiter = '' ) {
		$file_directory = MOWCPS_PLUGIN_DIR . 'data' . DIRECTORY_SEPARATOR . 'external-supplier' . DIRECTORY_SEPARATOR . 'api-file-response' . DIRECTORY_SEPARATOR . $connection_id;
		$start_line     = 5000 * ( $batch_number - 1 );
		$end_line       = 5000 * ( $batch_number );
		$api_response   = Read_File_Data::read_data_from_file( $file_directory, $sync_filename, $start_line, $end_line, true, $delimiter );
		update_option( 'mowcps_' . $connection_id . '_api_response_count', count( $api_response ) );
		return $api_response;
	}

	/**
	 * Function to first store the file or zip.
	 *
	 * @param mixed $connection_id Connection ID of the sync, is used to set the specific folder for the file.
	 * @param mixed $given_filename  Only file name with there extension.
	 * @param mixed $file_in_zip     Flag to let code know if the final file to be performed operation on is in zip.
	 * @param mixed $data_to_store   Data to store in the file.
	 *
	 * @return array
	 */
	public static function convert_file_data( $connection_id, $given_filename, $file_in_zip, $data_to_store ) {
		$file_directory = MOWCPS_PLUGIN_DIR . 'data' . DIRECTORY_SEPARATOR . 'external-supplier' . DIRECTORY_SEPARATOR . 'api-file-response' . DIRECTORY_SEPARATOR . $connection_id;

		if ( self::is_json( $data_to_store ) ) {
			$data_to_store = json_decode( $data_to_store, true );
		}

		if ( Constants::CONFIGURATION_ACTIVE === $file_in_zip ) {
			$file_name     = $connection_id . '_zip.zip';
			$zip_file_path = $file_directory . DIRECTORY_SEPARATOR . $file_name;

			self::store_data_in_file( $file_name, $file_directory, $data_to_store );

			self::extract_zip( $zip_file_path, $file_directory );

		} else {
			self::store_data_in_file( $given_filename, $file_directory, $data_to_store );
		}

		$connection_row          = Connection_Model::get_by_id( $connection_id );
		$connection_file_config  = ( is_array( $connection_row ) && isset( $connection_row['configuration'] ) && is_array( $connection_row['configuration'] ) ) ? $connection_row['configuration'] : array();
		$use_first_row_as_header = $connection_file_config['use_first_row_as_header'] ?? true;

		$data = Read_File_Data::read_data_from_file( $file_directory, $given_filename, false, false, $use_first_row_as_header );

		return $data;
	}

	/**
	 * Store data in a file and used to create directories recursively if not already created.
	 *
	 * @param string $file_name           The name of the file.
	 * @param string $file_directory_path The directory path where the file will be stored.
	 * @param string $file_data           The data to be stored in the file.
	 *
	 * @return bool true|false
	 */
	public static function store_data_in_file( $file_name, $file_directory_path, $file_data = '' ) {
		global $wp_filesystem;
		WP_Filesystem();

		if ( wp_mkdir_p( $file_directory_path ) ) {
			$file_path = $file_directory_path . DIRECTORY_SEPARATOR . $file_name;

			$result = $wp_filesystem->put_contents( $file_path, $file_data, 0644 );

			return $result ? true : false;
		}
		return false;
	}

	/**
	 * Check if a string is valid JSON.
	 *
	 * @param string $json_string String to validate.
	 * @return boolean
	 */
	public static function is_json( $json_string ) {
		json_decode( $json_string );
		return json_last_error() === JSON_ERROR_NONE;
	}
}
