<?php
/**
 * FTP destination — built on the PHP ftp_* functions.
 *
 * @package Backreload\Destinations
 */

namespace Backreload\Destinations;

use Backreload\Models\DestinationRecord;

if ( ! defined( 'ABSPATH' ) ) { exit; }

class FtpDestination extends AbstractDestination {

	public static function type(): string  { return 'ftp'; }
	public static function label(): string { return 'FTP'; }

	public static function config_schema(): array {
		return [
			[ 'key' => 'host',     'label' => 'Host',        'type' => 'text',     'required' => true ],
			[ 'key' => 'port',     'label' => 'Port',        'type' => 'int',      'default'  => 21 ],
			[ 'key' => 'username', 'label' => 'Username',    'type' => 'text',     'required' => true ],
			[ 'key' => 'password', 'label' => 'Password',    'type' => 'password', 'secret'   => true, 'required' => true ],
			[ 'key' => 'path',     'label' => 'Remote path', 'type' => 'text',     'default'  => '/' ],
			[ 'key' => 'passive',  'label' => 'Passive mode','type' => 'bool',     'default'  => true ],
		];
	}

	/**
	 * Build a configured FTP connection.
	 *
	 * @param DestinationRecord $dest
	 * @param bool|null         $honor_pasv_ip  null = use default (false, i.e.
	 *                                          IGNORE the server-advertised PASV
	 *                                          IP and reuse control-conn IP).
	 *                                          true = trust the server's PASV reply.
	 *                                          Set this to flip the toggle when
	 *                                          a default-mode upload fails.
	 */
	private function connect( DestinationRecord $dest, ?bool $honor_pasv_ip = null ) {
		if ( ! function_exists( 'ftp_connect' ) ) {
			throw new \RuntimeException( esc_html(  'PHP FTP extension is not installed on this server.'  )  );
		}
		$c    = $dest->config;
		$host = (string) ( $c['host'] ?? '' );
		$port = (int) ( $c['port'] ?? 21 );
		// SSRF guard: refuse to dial an internal / loopback / metadata host.
		// LAN FTP servers can be allowed via the `backreload_allow_private_host` filter.
		$ssrf = \Backreload\Core\Ssrf::unsafe_host_reason( $host );
		if ( null !== $ssrf ) {
			throw new \RuntimeException( esc_html( 'FTP host rejected: ' . $ssrf ) );
		}
		// Heads-up: ports 22 / 2222 are SSH/SFTP, not FTP. The connect itself
		// may succeed (the TCP socket opens), but ftp_login() will then fail
		// because the protocol on the wire is SSH, not FTP. Detect this up
		// front and tell the user clearly — they almost certainly want to
		// switch the destination *type* to SFTP rather than fight with FTP.
		if ( in_array( $port, [ 22, 2222 ], true ) ) {
			throw new \RuntimeException(
				 esc_html( "Port {$port} is the SSH/SFTP port; FTP normally uses port 21. " .
				"This destination should probably be created as SFTP, not FTP. Delete it and pick the SFTP tile when adding."
			 ) );
		}

		// Wrap connect/login in our own error trap so we can surface what PHP
		// actually said — "FTP upload failed" with no detail is unhelpful.
		// `set_error_handler` is flagged by Plugin Check as "debug code" but
		// it's deliberate: PHP's ftp_* functions emit useful warnings via the
		// error system, and capturing them is the only way to give the user
		// a real reason for failure.
		$err = '';
		// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler -- intentional: capture PHP's ftp_* warning text for user-facing error reporting.
		set_error_handler( function ( $no, $msg ) use ( &$err ) { $err = (string) $msg; }, E_WARNING | E_NOTICE );
		try {
			$conn = ftp_connect( $host, $port, 10 );
			if ( ! $conn ) {
				// Distinguish the three failure shapes so the user gets an
				// actionable next step instead of the generic "Cannot connect":
				//   1. DNS — host doesn't resolve (typo / DNS outage)
				//   2. Refused — server up but port closed (firewall, wrong port)
				//   3. Timeout — packet drops (likely fail2ban-style IP block
				//      triggered by previous failed auth attempts; common after
				//      a credential rotation/breakage)
				$lower    = strtolower( $err );
				$dns_miss = false !== strpos( $lower, 'getaddrinfo' )
					|| false !== strpos( $lower, 'name or service not known' )
					|| false !== strpos( $lower, 'could not resolve' );
				$refused  = false !== strpos( $lower, 'connection refused' );
				$timeout  = false !== strpos( $lower, 'timed out' )
					|| false !== strpos( $lower, 'timeout' );
				if ( $dns_miss ) {
					throw new \RuntimeException(
						 esc_html( "FTP host {$host} doesn't resolve. Check the hostname for typos, or whether DNS is up. " .
						"(Original error: {$err})"
					 ) );
				}
				if ( $refused ) {
					throw new \RuntimeException(
						 esc_html( "FTP server at {$host}:{$port} refused the connection. Either the FTP service is down, " .
						"or you're using the wrong port. Many hosts use 21 for plain FTP and 990 for FTPS. " .
						"(Original error: {$err})"
					 ) );
				}
				if ( $timeout ) {
					throw new \RuntimeException(
						 esc_html( "FTP connection to {$host}:{$port} timed out; packets aren't getting through. " .
						"This usually means: (a) your IP was auto-banned by the FTP server's brute-force protection " .
						"after several failed logins (very common after a credential rotation; wait 15–30 min and try " .
						"again), (b) outbound port {$port} is blocked from this WordPress server, or (c) the FTP " .
						"server is down. (Original error: {$err})"
					 ) );
				}
				throw new \RuntimeException(  esc_html( "Cannot connect to {$host}:{$port}" . ( $err ? ": {$err}" : '' ) )  );
			}
			if ( ! ftp_login( $conn, (string) ( $c['username'] ?? '' ), (string) ( $c['password'] ?? '' ) ) ) {
				ftp_close( $conn );
				// Plain credential rejection. Server is up, TCP works, FTP
				// handshake completed — the username/password just don't
				// match. Don't lead with the brute-force-ban warning here:
				// most failures are simple typos or a plugin update breaking
				// the stored ciphertext (the v2-encryption salt rotation
				// issue). The ban warning is a small hint at the end.
				$u = (string) ( $c['username'] ?? '' );
				throw new \RuntimeException(
					 esc_html( "FTP rejected the username / password for {$u}@{$host}" . ( $err ? ": {$err}" : '' ) .
					'. Re-enter the password in this destination and Save (do not click Test repeatedly, because multiple back-to-back failures can trigger a temporary IP ban on the FTP server).'
				 ) );
			}
		} finally {
			restore_error_handler();
		}

		// Generous timeouts so long uploads don't trip per-command timeouts.
		@ftp_set_option( $conn, FTP_TIMEOUT_SEC, 90 );

		// FTP_USEPASVADDRESS controls whether PHP honors the IP the server
		// advertises in its PASV reply. Default false: ignore it, reuse the
		// control-connection IP. That's the right call for hosts behind NAT
		// (most cPanel shared hosting) that advertise an internal IP — without
		// the override PHP tries that internal IP, can't reach it, and
		// ftp_put() silently fails. But on hosts that advertise a *different*
		// reachable IP for data on purpose (load-balanced setups), the
		// override hurts — `upload()` flips this flag on its retry path to
		// cover both shapes without per-host config.
		if ( defined( 'FTP_USEPASVADDRESS' ) ) {
			@ftp_set_option( $conn, FTP_USEPASVADDRESS, $honor_pasv_ip ?? false );
		}

		// Many shared hosts only support PASV — try it first when configured.
		// Some servers return false from ftp_pasv but still work in active
		// mode; we log the mismatch but don't fail.
		@ftp_pasv( $conn, ! empty( $c['passive'] ) );
		return $conn;
	}

	public function test( DestinationRecord $dest ): bool {
		// Roundtrip a tiny probe file through the real upload + delete path so
		// every failure mode a backup would hit surfaces HERE — at click time —
		// instead of after the user has waited for a multi-tick backup to fail.
		// Specifically catches: outbound-firewall-blocking-PASV-port,
		// remote-path-not-writable, and PASV/NAT misconfig (with the same
		// 4-combo auto-flip + hint message upload() uses).
		$probe_local = self::temp_path( 'backreload-ftp-probe' );
		if ( ! $probe_local || false === @file_put_contents( $probe_local, 'backreload-probe' ) ) {
			throw new \RuntimeException( esc_html(  'Could not create local probe file for the FTP test.'  )  );
		}
		$probe_name = '.backreload-test-' . wp_generate_password( 8, false, false );
		try {
			$this->upload( $dest, $probe_local, $probe_name );
		} finally {
			@unlink( $probe_local ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Deletes the plugin's own backup/temp files; behaviour-equivalent to wp_delete_file for these plugin-controlled paths.
		}
		// Best-effort remote cleanup. If the delete fails the probe file just
		// sits on the remote — tiny, but we tried.
		try {
			$this->delete( $dest, $probe_name );
		} catch ( \Throwable $e ) { /* ignore */ }
		return true;
	}

	public function upload( DestinationRecord $dest, string $local_path, string $remote_name ): string {
		$base   = trailingslashit( $dest->config['path'] ?? '/' );
		$remote = $base . $remote_name;
		$this->transfer( $dest, $local_path, $remote, 'put', $remote_name );
		return $remote;
	}

	public function download( DestinationRecord $dest, string $remote_name, string $local_path ): bool {
		$remote = trailingslashit( $dest->config['path'] ?? '/' ) . $remote_name;
		$this->transfer( $dest, $local_path, $remote, 'get', $remote_name );
		return true;
	}

	/**
	 * Move a file in either direction between local disk and the FTP server,
	 * retrying every sensible passive/active × PASV-IP combination until one
	 * works. Throws RuntimeException with a precise, actionable error message
	 * on total failure. Returns void on success.
	 *
	 * Shared by upload() (put) and download() (get) so both surface the same
	 * actionable hints — there's no reason for the user to see a clear
	 * firewall message on backup but a generic "failed to download" on restore.
	 *
	 * @param string $direction   'put' or 'get'
	 * @param string $display     name to use in error messages (usually $remote_name)
	 */
	private function transfer(
		DestinationRecord $dest,
		string $local_path,
		string $remote,
		string $direction,
		string $display
	): void {
		$base         = trailingslashit( $dest->config['path'] ?? '/' );
		$config_pasv  = ! empty( $dest->config['passive'] );

		// FTP-data-connection failure modes vary by host (NAT, load-balanced
		// PASV, firewall on outbound high ports) — and the config that "fixes"
		// each is different. Rather than make the user guess, try every sane
		// combination of (passive vs. active) × (use server's PASV IP vs.
		// control-conn IP). Each attempt opens a FRESH control connection so
		// settings actually take effect; PHP can't change USEPASVADDRESS on an
		// already-PASV'd session.
		//
		// First attempt: the user's configured passive mode + the safer default
		// (ignore server's PASV IP — fixes most NAT hosts). After that, walk
		// the remaining three combos until one succeeds.
		$attempts = [
			[ $config_pasv,   false ],
			[ ! $config_pasv, false ],
			[ $config_pasv,   true  ],
			[ ! $config_pasv, true  ],
		];

		$last_err         = '';
		$socket_timeout_s = 0.0; // tracked separately so we can bail fast on dead-network failures
		foreach ( $attempts as $i => [ $pasv, $honor_pasv_ip ] ) {
			$conn = $this->connect( $dest, $honor_pasv_ip );
			@ftp_pasv( $conn, $pasv );

			// Upload-only: auto-create the per-backup subfolder when remote
			// has a slash in it (e.g. "bk_xyz/plugins-1.zip"). ftp_mkdir is
			// silent on success and on "already exists" — both fine.
			if ( 'put' === $direction ) {
				$sub_dir = dirname( $remote );
				if ( $sub_dir && rtrim( $sub_dir, '/' ) !== rtrim( $base, '/' ) ) {
					@ftp_mkdir( $conn, $sub_dir );
				}
			}

			$err   = '';
			$start = microtime( true );
			// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler -- intentional: capture PHP's ftp_put/ftp_get warning text for user-facing error reporting.
			set_error_handler( function ( $no, $msg ) use ( &$err ) { $err = (string) $msg; }, E_WARNING | E_NOTICE );
			try {
				$ok = ( 'put' === $direction )
					? ftp_put( $conn, $remote, $local_path, FTP_BINARY )
					: ftp_get( $conn, $local_path, $remote, FTP_BINARY );
			} finally {
				restore_error_handler();
			}
			$elapsed = microtime( true ) - $start;

			// Verify completeness BEFORE closing the connection (ftp_size needs
			// it). ftp_put returning true doesn't guarantee the whole file
			// landed on every NAT/server combo — a data-connection that opens
			// then stalls can leave a truncated remote file while still
			// reporting success. Compare the remote size to the local size and
			// treat a mismatch as a failed attempt (falls through to the
			// retry/next-combo logic below). For downloads, compare the local
			// output size to the remote's.
			if ( $ok ) {
				$local_size = (int) @filesize( $local_path );
				if ( 'put' === $direction ) {
					$remote_size = (int) @ftp_size( $conn, $remote ); // -1 if server doesn't support SIZE
					if ( $remote_size >= 0 && $remote_size !== $local_size ) {
						@ftp_delete( $conn, $remote ); // don't leave a truncated part
						$ok  = false;
						$err = "incomplete upload: remote {$remote_size} bytes, expected {$local_size}";
					}
				} else {
					$remote_size = (int) @ftp_size( $conn, $remote );
					if ( $remote_size >= 0 && $local_size !== $remote_size ) {
						$ok  = false;
						$err = "incomplete download: local {$local_size} bytes, expected {$remote_size}";
					}
				}
			}

			ftp_close( $conn );

			if ( $ok ) {
				if ( $i > 0 && function_exists( 'backreload' ) ) {
					$mode_label = $pasv ? 'passive' : 'active';
					$ip_label   = $honor_pasv_ip ? "honoring the server's PASV IP" : "ignoring the server's PASV IP";
					$action     = 'put' === $direction ? 'upload' : 'download';
					try {
						backreload()->get( 'logger' )->info(
							"FTP {$action} succeeded after switching to {$mode_label} mode + {$ip_label}. " .
							"If this keeps happening, toggle the destination's \"Passive mode\" setting to match."
						);
					} catch ( \Throwable $e ) { /* ignore */ }
				}
				return;
			}

			$last_err = $err;
			// Anything that isn't a data-connection issue (auth, quota, perms)
			// won't be fixed by toggling. Stop immediately.
			if ( ! $this->looks_like_data_connection_failure( $err ) ) {
				break;
			}
			// If this attempt actually hung for ages (TCP-level timeout, not a
			// fast protocol error), the next combo will almost certainly hang
			// the same way — outbound blocked to high ports / inbound blocked
			// to us. Each ~60s hang wastes a tick budget and accomplishes
			// nothing. Bail with a firewall-specific hint instead.
			if ( $elapsed >= 25 ) {
				$socket_timeout_s = $elapsed;
				break;
			}
		}

		$action = 'put' === $direction ? 'upload' : 'download';
		$hint   = '';
		if ( '' !== $last_err ) {
			$hint = ": {$last_err}";
			if ( $socket_timeout_s > 0 ) {
				$hint .= sprintf(
					' (the data socket hung for %ds before timing out: this is the WP host\'s outbound firewall blocking the FTP data port range, NOT a config we can flip. Ask the host to allow outbound TCP to high ports for this destination, or switch the destination type to SFTP (port 22 only).)',
					(int) $socket_timeout_s
				);
			} elseif ( $this->looks_like_data_connection_failure( $last_err ) ) {
				$hint .= ' (tip: tried every combination of passive/active and PASV-IP honoring; this looks like a passive-mode / NAT issue, ask the host to enable PASV with the external IP, or switch the destination type to SFTP)';
			} elseif ( false !== stripos( $last_err, '550' ) ) {
				$hint .= 'put' === $direction
					? ' (tip: check the Remote path is writable by this FTP user)'
					: ' (tip: file may not exist on the remote, or the FTP user has no read permission)';
			} elseif ( false !== stripos( $last_err, 'permission' ) ) {
				$hint .= ' (tip: check the FTP user has read+write permission on the Remote path)';
			}
		}
		throw new \RuntimeException( esc_html(  "FTP {$action} failed for {$display}{$hint}"  )  );
	}

	/**
	 * Whether an FTP warning text matches the well-known "data connection
	 * couldn't be established" family — the cluster of errors that look like
	 * a TYPE failure but are actually PASV/NAT issues.
	 *
	 * The substrings here are what PHP's ftp_put() actually echoes via E_WARNING
	 * when the data socket can't be opened. Notably "Entering Passive Mode" is
	 * the server's harmless 227 reply that PHP captures as the last warning when
	 * the *next* step (opening the data socket) silently fails — so seeing that
	 * string is itself the failure signal, not just a status line.
	 */
	private function looks_like_data_connection_failure( string $err ): bool {
		if ( '' === $err ) return false;
		$err = strtolower( $err );
		return false !== strpos( $err, '8-bit binary' )           // PHP echoes the TYPE reply when STOR silently fails
			|| false !== strpos( $err, 'entering passive mode' )  // PASV 227 reply captured because data socket failed
			|| false !== strpos( $err, 'passive mode' )
			|| false !== strpos( $err, 'data connection' )
			|| false !== strpos( $err, '425' )
			|| false !== strpos( $err, 'pasv' )
			// TCP-level data-socket failures. ftp_put() that reaches the actual
			// data transfer step but the socket can't reach the host:port the
			// server advertised in PASV (or, in active mode, the server can't
			// connect back to us) lands here. These don't include "Login" or
			// "550 Permission denied" — those are control-channel issues that
			// won't be fixed by toggling passive/active.
			|| false !== strpos( $err, 'connection timed out' )
			|| false !== strpos( $err, 'connect timed out' )
			|| false !== strpos( $err, 'timed out' )
			|| false !== strpos( $err, 'connection refused' )
			|| false !== strpos( $err, 'no route to host' )
			|| false !== strpos( $err, 'network is unreachable' );
	}

	public function list_backups( DestinationRecord $dest ): array {
		$conn = $this->connect( $dest );
		try {
			$base = trailingslashit( $dest->config['path'] ?? '/' );
			$out  = [];
			foreach ( $this->ftp_list( $conn, rtrim( $base, '/' ) ?: '/' ) as $entry ) {
				$ext_id = basename( rtrim( $entry['name'], '/' ) );
				if ( '' === $ext_id || 0 !== strpos( $ext_id, 'bk_' ) ) {
					continue;
				}
				$dir      = $base . $ext_id;
				$parts    = [];
				$manifest = null;
				foreach ( $this->ftp_list( $conn, $dir ) as $fe ) {
					$fname = basename( rtrim( $fe['name'], '/' ) );
					if ( '' === $fname || '.' === $fname || '..' === $fname || 'dir' === $fe['type'] ) {
						continue;
					}
					$remote = $dir . '/' . $fname;
					if ( \Backreload\Core\Manifest::FILENAME === $fname ) {
						$tmp = self::temp_path( 'backreload-mf' );
						if ( $tmp && @ftp_get( $conn, $tmp, $remote, FTP_BINARY ) ) {
							$manifest = \Backreload\Core\Manifest::read( $tmp );
						}
						if ( $tmp ) {
							@unlink( $tmp ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Deletes the plugin's own backup/temp files; behaviour-equivalent to wp_delete_file for these plugin-controlled paths.
						}
						continue;
					}
					$parts[] = [ 'name' => $ext_id . '/' . $fname, 'size' => (int) $fe['size'] ];
				}
				if ( empty( $parts ) && null === $manifest ) {
					continue;
				}
				$out[] = [
					'ext_id'       => $ext_id,
					'has_manifest' => null !== $manifest,
					'manifest'     => $manifest,
					'parts'        => $parts,
				];
			}
			return $out;
		} finally {
			ftp_close( $conn );
		}
	}

	/**
	 * List a remote directory as [ { name, type, size } ] entries.
	 *
	 * Prefers MLSD — one structured command per directory that carries the
	 * size and entry type, avoiding an extra SIZE round-trip per file (the
	 * difference between ~1 and ~N+1 FTP commands per backup folder). Falls
	 * back to NLST + SIZE for servers without MLSD support.
	 *
	 * @return array<int,array{name:string,type:string,size:int}>
	 */
	private function ftp_list( $conn, string $dir ): array {
		if ( function_exists( 'ftp_mlsd' ) ) {
			$rows = @ftp_mlsd( $conn, $dir );
			if ( is_array( $rows ) ) {
				$out = [];
				foreach ( $rows as $r ) {
					$name = (string) ( $r['name'] ?? '' );
					if ( '' === $name || '.' === $name || '..' === $name ) {
						continue;
					}
					$out[] = [
						'name' => $name,
						'type' => (string) ( $r['type'] ?? 'file' ),
						'size' => (int) ( $r['size'] ?? $r['sizd'] ?? 0 ),
					];
				}
				return $out;
			}
		}
		// Fallback: names only, then a SIZE probe per entry (-1 ≈ directory).
		$names = @ftp_nlist( $conn, $dir );
		if ( ! is_array( $names ) ) {
			return [];
		}
		$out = [];
		foreach ( $names as $n ) {
			$bn = basename( rtrim( $n, '/' ) );
			if ( '' === $bn || '.' === $bn || '..' === $bn ) {
				continue;
			}
			$size  = @ftp_size( $conn, $dir . '/' . $bn );
			$out[] = [
				'name' => $bn,
				'type' => $size < 0 ? 'dir' : 'file',
				'size' => $size < 0 ? 0 : (int) $size,
			];
		}
		return $out;
	}

	public function delete( DestinationRecord $dest, string $remote_name ): bool {
		$conn = $this->connect( $dest );
		$base   = trailingslashit( $dest->config['path'] ?? '/' );
		$remote = $base . $remote_name;
		$ok = @ftp_delete( $conn, $remote );

		// Best-effort: remove the now-empty per-backup subdir so retention
		// sweeps don't accumulate orphan folders. Silently no-ops if non-empty.
		$sub_dir = dirname( $remote );
		if ( $sub_dir && rtrim( $sub_dir, '/' ) !== rtrim( $base, '/' ) ) {
			@ftp_rmdir( $conn, $sub_dir );
		}

		ftp_close( $conn );
		return (bool) $ok;
	}
}
