<?php
/**
 * SFTP destination — built on the bundled phpseclib library (pure PHP).
 *
 * phpseclib implements SSH/SFTP entirely in PHP, so the plugin no longer
 * depends on the PECL ssh2 extension (which many shared hosts don't ship) and
 * no longer reads/writes remote files through the `ssh2.sftp://` stream
 * wrapper. All transfers go through phpseclib's streaming put()/get(), which
 * read/write the local file in blocks rather than buffering whole archives.
 *
 * @package Backreload\Destinations
 */

namespace Backreload\Destinations;

use Backreload\Models\DestinationRecord;
use phpseclib3\Net\SFTP;
use phpseclib3\Crypt\PublicKeyLoader;

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

class SftpDestination extends AbstractDestination {

	public static function type(): string  { return 'sftp'; }
	public static function label(): string { return 'SFTP'; }

	/**
	 * Per-request SFTP client cache, keyed by destination ext_id. One backup
	 * tick uploads N parts to M destinations; without caching, every call
	 * re-runs the TCP connect + SSH handshake + auth (~0.5–1s each). Caching
	 * for the request lifetime reuses one connection across all operations.
	 *
	 * @var array<string, SFTP>
	 */
	private static array $sftp_cache = [];

	/** True once register_shutdown_function() has been wired to clear the cache. */
	private static bool $shutdown_hooked = false;

	public static function config_schema(): array {
		return [
			[ 'key' => 'host',     'label' => 'Host',        'type' => 'text',     'required' => true ],
			[ 'key' => 'port',     'label' => 'Port',        'type' => 'int',      'default'  => 22 ],
			[ 'key' => 'username', 'label' => 'Username',    'type' => 'text',     'required' => true ],
			[ 'key' => 'password', 'label' => 'Password / passphrase', 'type' => 'password', 'secret' => true ],
			[ 'key' => 'private_key', 'label' => 'Private key (PEM)',  'type' => 'password', 'secret' => true ],
			[ 'key' => 'path',     'label' => 'Remote path', 'type' => 'text',     'default'  => '/' ],
			[ 'key' => 'fingerprint_policy', 'label' => 'Host key', 'type' => 'select',
			  'options' => [ 'auto', 'manual', 'off' ], 'default' => 'auto' ],
		];
	}

	private function connect( DestinationRecord $dest ): SFTP {
		$cache_key = (string) $dest->ext_id;
		if ( '' !== $cache_key && isset( self::$sftp_cache[ $cache_key ] ) && self::$sftp_cache[ $cache_key ]->isConnected() ) {
			return self::$sftp_cache[ $cache_key ];
		}
		if ( ! class_exists( SFTP::class ) ) {
			throw new \RuntimeException( esc_html( 'The bundled SFTP library could not be loaded.' ) );
		}

		$c = $dest->config;
		// SSRF guard: refuse to dial an internal / loopback / metadata host.
		// LAN SFTP servers can be allowed via the `backreload_allow_private_host` filter.
		$ssrf = \Backreload\Core\Ssrf::unsafe_host_reason( (string) ( $c['host'] ?? '' ) );
		if ( null !== $ssrf ) {
			throw new \RuntimeException( esc_html( 'SFTP host rejected: ' . $ssrf ) );
		}

		$host = (string) ( $c['host'] ?? '' );
		$port = (int) ( $c['port'] ?? 22 );
		$sftp = new SFTP( $host, $port, 30 );

		$this->verify_host_key( $dest, $sftp );

		// ── Authentication ────────────────────────────────────────────────
		$username = (string) ( $c['username'] ?? '' );
		$auth_ok  = false;
		if ( ! empty( $c['private_key'] ) ) {
			try {
				$key     = PublicKeyLoader::load( (string) $c['private_key'], (string) ( $c['password'] ?? '' ) );
				$auth_ok = $sftp->login( $username, $key );
			} catch ( \Throwable $e ) {
				$auth_ok = false; // fall through to password auth
			}
		}
		if ( ! $auth_ok && ! empty( $c['password'] ) ) {
			$auth_ok = $sftp->login( $username, (string) $c['password'] );
		}
		if ( ! $auth_ok ) {
			throw new \RuntimeException( esc_html( 'SFTP authentication failed.' ) );
		}

		if ( '' !== $cache_key ) {
			self::$sftp_cache[ $cache_key ] = $sftp;
			if ( ! self::$shutdown_hooked ) {
				self::$shutdown_hooked = true;
				register_shutdown_function( static function () {
					self::$sftp_cache = [];
				} );
			}
		}
		return $sftp;
	}

	/**
	 * Host-key pinning (trust-on-first-use). Without it, a network MITM could
	 * transparently intercept the session and harvest credentials + backup
	 * contents. We pin a sha256 fingerprint of the host key on first connect
	 * and reject any later change. `policy = 'off'` opts out; `manual` requires
	 * a pre-set fingerprint. Destinations pinned by the older ssh2-based build
	 * (different fingerprint format) are transparently re-pinned, not rejected.
	 */
	private function verify_host_key( DestinationRecord $dest, SFTP $sftp ): void {
		$policy = (string) ( $dest->config['fingerprint_policy'] ?? 'auto' );
		if ( 'off' === $policy ) {
			return;
		}
		$host_key = (string) $sftp->getServerPublicHostKey();
		if ( '' === $host_key ) {
			throw new \RuntimeException( esc_html( 'Could not read SFTP host key, refusing to connect.' ) );
		}
		$seen        = 'sha256:' . base64_encode( hash( 'sha256', $host_key, true ) );
		$pinned      = (string) ( $dest->config['known_fingerprint'] ?? '' );
		$pinned_algo = (string) ( $dest->config['known_fingerprint_hash'] ?? '' );

		// First connect, or a fingerprint stored in the legacy ssh2 format → re-pin.
		if ( '' === $pinned || 'phpseclib-sha256' !== $pinned_algo ) {
			if ( 'manual' === $policy && '' === $pinned ) {
				throw new \RuntimeException( esc_html(
					"Host key not pinned. Set the host-key policy to 'auto' to trust-on-first-use, " .
					"or paste the expected fingerprint into the destination. Observed: {$seen}"
				) );
			}
			$cfg = $dest->config;
			$cfg['known_fingerprint']      = $seen;
			$cfg['known_fingerprint_hash'] = 'phpseclib-sha256';
			$dest->config = $cfg;
			$dest->save();
			return;
		}

		if ( ! hash_equals( $pinned, $seen ) ) {
			throw new \RuntimeException( esc_html(
				"SFTP host fingerprint changed, refusing to connect (possible MITM). Expected {$pinned}, got {$seen}."
			) );
		}
	}

	/**
	 * Drop the cached connection for this destination so the next connect()
	 * re-authenticates from scratch (e.g. after the user rotates the password).
	 */
	private function invalidate_cache( DestinationRecord $dest ): void {
		$key = (string) $dest->ext_id;
		if ( '' === $key ) return;
		unset( self::$sftp_cache[ $key ] );
	}

	public function test( DestinationRecord $dest ): bool {
		// Real round-trip — write a probe file, verify it landed, delete it.
		// A bare connect+auth succeeds even when the remote path isn't writable.
		// Drop any cached connection first so a Test after a password rotation
		// re-authenticates rather than reusing the old authed session.
		$this->invalidate_cache( $dest );
		$probe_local = self::temp_path( 'backreload-sftp-probe' );
		if ( ! $probe_local || false === @file_put_contents( $probe_local, 'backreload-probe' ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Writes a tiny probe to the plugin's own local temp file.
			throw new \RuntimeException( esc_html( 'Could not create local probe file for the SFTP 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 local temp file.
		}
		try {
			$this->delete( $dest, $probe_name );
		} catch ( \Throwable $e ) { /* ignore — tiny file, best-effort */ }
		return true;
	}

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

		// Ensure the base remote path (and any per-backup subdir) exists. mkdir
		// is recursive and a no-op when the directory is already present.
		if ( '/' !== $base ) {
			$sftp->mkdir( rtrim( $base, '/' ), -1, true );
		}
		$sub_dir = dirname( $remote );
		if ( $sub_dir && rtrim( $sub_dir, '/' ) !== rtrim( $base, '/' ) ) {
			$sftp->mkdir( $sub_dir, -1, true );
		}

		$base_real = rtrim( $base, '/' );
		if ( '' !== $base_real && false === $sftp->stat( $base_real ) ) {
			throw new \RuntimeException( esc_html(
				"Remote path {$base_real} does not exist on the SFTP server and could not be created. " .
				"Create it manually, or change the Remote path to a directory that already exists and is writable by '{$dest->config['username']}'."
			) );
		}

		$local_size = (int) @filesize( $local_path );

		// Streams from the local file in blocks — never loads the whole archive
		// part (potentially gigabytes) into memory.
		$ok = $sftp->put( $remote, $local_path, SFTP::SOURCE_LOCAL_FILE );
		if ( ! $ok ) {
			throw new \RuntimeException( esc_html(
				"Failed to upload to {$remote}. The SFTP user '{$dest->config['username']}' may lack write " .
				"permission on {$base_real}, the user may be chrooted outside it, or the remote disk/quota is full."
			) );
		}

		// Verify the bytes really landed — a dropped session or quota overflow
		// would otherwise hand the engine a truncated part that looks uploaded
		// but can't be restored.
		$stat        = $sftp->stat( $remote );
		$remote_size = is_array( $stat ) && isset( $stat['size'] ) ? (int) $stat['size'] : -1;
		if ( $remote_size >= 0 && $remote_size !== $local_size ) {
			$sftp->delete( $remote ); // don't leave a truncated part behind
			throw new \RuntimeException( esc_html(
				"SFTP upload of {$remote_name} verified short: remote is {$remote_size} bytes, expected {$local_size}."
			) );
		}
		return $remote;
	}

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

		$stat        = $sftp->stat( $remote );
		$expect_size = is_array( $stat ) && isset( $stat['size'] ) ? (int) $stat['size'] : -1;

		// Streams to the local file in blocks (does not buffer in memory).
		$ok = $sftp->get( $remote, $local_path );
		if ( false === $ok ) {
			@unlink( $local_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Removes the plugin's own partial local download.
			return false;
		}
		// Exact-size check guards against a truncated transfer being treated as
		// success and feeding a corrupt part into restore.
		if ( $expect_size >= 0 ) {
			$got = (int) @filesize( $local_path );
			if ( $got !== $expect_size ) {
				@unlink( $local_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Removes the plugin's own truncated local download.
				return false;
			}
		}
		return true;
	}

	public function list_backups( DestinationRecord $dest ): array {
		$logger = function_exists( 'backreload' ) ? backreload()->get( 'logger' ) : null;
		$sftp   = $this->connect( $dest );
		$base   = trailingslashit( $dest->config['path'] ?? '/' );

		$entries = $this->sftp_listdir( $sftp, $base );
		if ( $logger ) {
			$logger->info(
				'SFTP scan: listed ' . count( $entries ) . " entries at {$base}" .
					( count( $entries ) > 0 ? ' (sample: ' . implode( ', ', array_slice( $entries, 0, 5 ) ) . ')' : '' ),
				[],
				$dest->ext_id
			);
		}

		$out      = [];
		$bk_count = 0;
		foreach ( $entries as $ext_id ) {
			if ( 0 !== strpos( $ext_id, 'bk_' ) ) {
				continue;
			}
			$bk_count++;
			$dir   = $base . $ext_id;
			$files = $this->sftp_listdir( $sftp, $dir );
			if ( empty( $files ) ) {
				if ( $logger ) {
					$logger->warn( "SFTP scan: {$ext_id} listed 0 inner files, skipping", [], $dest->ext_id );
				}
				continue;
			}
			$parts    = [];
			$manifest = null;
			foreach ( $files as $fname ) {
				$remote_file = $dir . '/' . $fname;
				if ( \Backreload\Core\Manifest::FILENAME === $fname ) {
					// get() with no local target returns the file contents.
					$raw = $sftp->get( $remote_file );
					if ( is_string( $raw ) && '' !== $raw ) {
						$tmp = self::temp_path( 'backreload-mf' );
						if ( $tmp ) {
							@file_put_contents( $tmp, $raw ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Writes the fetched manifest to the plugin's own local temp file.
							$manifest = \Backreload\Core\Manifest::read( $tmp );
							@unlink( $tmp ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Deletes the plugin's own local temp file.
						}
					}
					continue;
				}
				if ( $sftp->is_dir( $remote_file ) ) {
					continue;
				}
				$stat = $sftp->stat( $remote_file );
				$size = is_array( $stat ) && isset( $stat['size'] ) ? (int) $stat['size'] : 0;
				if ( $size <= 0 ) {
					continue;
				}
				$parts[] = [ 'name' => $ext_id . '/' . $fname, 'size' => $size ];
			}
			if ( empty( $parts ) && null === $manifest ) {
				if ( $logger ) {
					$logger->warn( "SFTP scan: {$ext_id} folder is empty (no parts, no manifest), skipping", [], $dest->ext_id );
				}
				continue;
			}
			$out[] = [
				'ext_id'       => $ext_id,
				'has_manifest' => null !== $manifest,
				'manifest'     => $manifest,
				'parts'        => $parts,
			];
		}

		if ( $logger ) {
			$logger->info(
				'SFTP scan complete: found ' . count( $out ) . " importable backup(s) out of {$bk_count} bk_* folder(s)",
				[],
				$dest->ext_id
			);
		}
		return $out;
	}

	/**
	 * List a remote directory's entries (excluding . / ..), sorted. Returns []
	 * on failure and logs a warning rather than failing silently.
	 *
	 * @return string[]
	 */
	private function sftp_listdir( SFTP $sftp, string $path ): array {
		$list = $sftp->nlist( rtrim( $path, '/' ) );
		if ( ! is_array( $list ) ) {
			$logger = function_exists( 'backreload' ) ? backreload()->get( 'logger' ) : null;
			if ( $logger ) {
				$logger->warn( "SFTP nlist failed for {$path}", [] );
			}
			return [];
		}
		$entries = array_values( array_filter( $list, static fn ( $e ) => '.' !== $e && '..' !== $e ) );
		sort( $entries );
		return $entries;
	}

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

		// Best-effort: drop the per-backup subdir once empty so retention
		// sweeps don't leave orphan folders behind.
		$sub_dir = dirname( $remote );
		if ( $sub_dir && rtrim( $sub_dir, '/' ) !== rtrim( $base, '/' ) ) {
			@$sftp->rmdir( $sub_dir );
		}
		return (bool) $ok;
	}
}
