<?php
/**
 * Resolves a non-web-accessible storage root for Backup & Restore.
 *
 * Never falls back to ABSPATH, WP_CONTENT_DIR, uploads, or DOCUMENT_ROOT.
 *
 * @package BackupRestore
 */

namespace BackupRestore\Includes;

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

/**
 * Central private-storage resolver for backups, staging, and restore secrets.
 */
final class PrivateStorageResolver {

	public const BACKUP_LEAF   = 'wp-powersuite-backups';
	public const PRIVATE_LEAF  = 'wp-powersuite-private';
	public const STAGING_LEAF  = 'wp-powersuite-dl';

	/**
	 * Resolve a writable private directory for $leaf (absolute path with trailing slash).
	 *
	 * @return string|\WP_Error Absolute trailing-slash path, or error when none is safe.
	 */
	public static function resolve( string $leaf ): string|\WP_Error {
		$leaf = trim( str_replace( '\\', '/', $leaf ), '/' );
		if ( $leaf === '' || str_contains( $leaf, '..' ) ) {
			return new \WP_Error(
				'wpps_br_invalid_storage_leaf',
				__( 'Invalid private storage leaf name.', 'wppowersuite-lite' )
			);
		}

		$candidates = self::candidate_roots( $leaf );
		$errors     = [];

		foreach ( $candidates as $candidate ) {
			$result = self::validate_and_prepare( $candidate );
			if ( is_string( $result ) ) {
				return trailingslashit( $result );
			}
			if ( is_wp_error( $result ) ) {
				$errors[] = $result->get_error_message();
			}
		}

		return new \WP_Error(
			'wpps_br_no_private_storage',
			self::configuration_help_message( $errors )
		);
	}

	/**
	 * Whether a path is safe for private backup storage (absolute, outside web roots).
	 *
	 * @return bool|\WP_Error True when safe.
	 */
	public static function assert_safe_path( string $path ): bool|\WP_Error {
		$path = self::normalize_path( $path );
		if ( $path === '' ) {
			return new \WP_Error( 'wpps_br_empty_path', __( 'Storage path is empty.', 'wppowersuite-lite' ) );
		}

		if ( ! self::is_absolute_path( $path ) ) {
			return new \WP_Error(
				'wpps_br_relative_path',
				__( 'Backup storage path must be an absolute filesystem path.', 'wppowersuite-lite' )
			);
		}

		if ( str_contains( $path, "\0" ) ) {
			return new \WP_Error( 'wpps_br_nul_path', __( 'Storage path contains a NUL byte.', 'wppowersuite-lite' ) );
		}

		foreach ( explode( '/', $path ) as $segment ) {
			if ( $segment === '..' ) {
				return new \WP_Error(
					'wpps_br_traversal_path',
					__( 'Storage path must not contain parent-directory segments.', 'wppowersuite-lite' )
				);
			}
		}

		foreach ( self::web_roots() as $web_root ) {
			if ( self::path_is_inside( $path, $web_root ) ) {
				return new \WP_Error(
					'wpps_br_web_mapped_path',
					__( 'Backup storage path must be outside the public document root.', 'wppowersuite-lite' )
				);
			}
		}

		return true;
	}

	/**
	 * Legacy locations that may already contain backups (read/migrate only; never for new writes).
	 *
	 * Empty shared folders such as wp-content/wp-powersuite-private (used by
	 * wp-config and robots.txt helpers) are not treated as legacy backups.
	 *
	 * @return list<string>
	 */
	public static function legacy_backup_roots(): array {
		$roots = [];

		$inside = trailingslashit( self::normalize_path( WP_CONTENT_DIR ) ) . self::BACKUP_LEAF;
		if ( is_dir( $inside ) ) {
			$roots[] = trailingslashit( $inside );
		}

		$uploads = wp_upload_dir( null, false );
		if ( is_array( $uploads ) && ! empty( $uploads['basedir'] ) ) {
			$base = trailingslashit( self::normalize_path( (string) $uploads['basedir'] ) );
			$salt = get_option( 'wpps_br_storage_salt', '' );
			if ( is_string( $salt ) && strlen( $salt ) >= 16 ) {
				$salted = $base . 'wp-powersuite-private-' . $salt . '/' . self::BACKUP_LEAF;
				if ( is_dir( $salted ) ) {
					$roots[] = trailingslashit( self::normalize_path( $salted ) );
				}
			}
			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			$matches = @glob( $base . 'wp-powersuite-private-*/' . self::BACKUP_LEAF, GLOB_ONLYDIR );
			if ( is_array( $matches ) ) {
				foreach ( $matches as $match ) {
					$roots[] = trailingslashit( self::normalize_path( (string) $match ) );
				}
			}
		}

		$old_private = trailingslashit( self::normalize_path( WP_CONTENT_DIR ) ) . self::PRIVATE_LEAF;
		if ( is_dir( $old_private ) ) {
			$roots[] = trailingslashit( $old_private );
		}

		$roots = array_values( array_unique( $roots ) );

		return array_values(
			array_filter(
				$roots,
				static function ( string $root ): bool {
					return self::legacy_root_has_backup_jobs( $root );
				}
			)
		);
	}

	/**
	 * Whether a candidate legacy root actually holds Backup & Restore job folders.
	 */
	public static function legacy_root_has_backup_jobs( string $root ): bool {
		$root = trailingslashit( self::normalize_path( $root ) );
		if ( $root === '/' || ! is_dir( $root ) ) {
			return false;
		}

		$entries = @scandir( $root );
		if ( ! is_array( $entries ) ) {
			return false;
		}

		foreach ( $entries as $entry ) {
			if ( $entry === '.' || $entry === '..' ) {
				continue;
			}
			if ( ! preg_match( '/^backup-[A-Za-z0-9_-]+$/', $entry ) ) {
				continue;
			}
			if ( is_dir( $root . $entry ) ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Administrator-facing configuration help (no sensitive host paths).
	 *
	 * @param list<string> $errors Internal rejection reasons (not shown raw to non-admins).
	 */
	public static function configuration_help_message( array $errors = [] ): string {
		$message = __(
			'Backup & Restore cannot store files in a web-accessible directory. Define a private absolute path outside your document root in wp-config.php, for example: define( \'WP_POWERSUITE_BACKUP_PATH\', \'/home/account-private/wp-powersuite-backups\' ); Then ensure the directory is writable by PHP.',
			'wppowersuite-lite'
		);

		if ( current_user_can( 'manage_options' ) && $errors !== [] ) {
			$errors = array_values( array_unique( array_filter( $errors ) ) );
			$message .= ' ' . __( 'Details:', 'wppowersuite-lite' ) . ' ' . implode( ' ', array_slice( $errors, 0, 3 ) );
		}

		return $message;
	}

	/**
	 * Apply restrictive permissions where the host allows them.
	 */
	public static function harden_permissions( string $path, bool $is_dir ): void {
		if ( ! file_exists( $path ) ) {
			return;
		}
		$mode = $is_dir ? 0700 : 0600;
		// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
		@chmod( $path, $mode );
	}

	/**
	 * @return list<string>
	 */
	private static function candidate_roots( string $leaf ): array {
		$out = [];

		if ( defined( 'WP_POWERSUITE_BACKUP_PATH' ) && is_string( WP_POWERSUITE_BACKUP_PATH ) && WP_POWERSUITE_BACKUP_PATH !== '' ) {
			$configured = self::normalize_path( WP_POWERSUITE_BACKUP_PATH );
			if ( $leaf === self::BACKUP_LEAF ) {
				$out[] = $configured;
			} else {
				$out[] = trailingslashit( $configured ) . $leaf;
			}
		}

		/**
		 * Absolute path override (empty string = ignore).
		 *
		 * @param string $path Absolute path or empty.
		 * @param string $leaf Storage leaf name.
		 */
		$filtered = apply_filters( 'wpps_br_private_storage_path', '', $leaf );
		if ( is_string( $filtered ) && $filtered !== '' ) {
			$out[] = self::normalize_path( $filtered );
		}

		// Walk up from ABSPATH's parent until a directory outside all web roots is found.
		// Covers Local Sites / Bedrock where DOCUMENT_ROOT is broader than public/.
		foreach ( self::walk_up_private_candidates( dirname( ABSPATH ), $leaf ) as $candidate ) {
			$out[] = $candidate;
		}

		/**
		 * Additional absolute candidate roots (each must pass safety checks).
		 *
		 * @param list<string> $extra Extra absolute paths.
		 * @param string       $leaf  Storage leaf.
		 */
		$extra = apply_filters( 'wpps_br_private_storage_candidates', [], $leaf );
		if ( is_array( $extra ) ) {
			foreach ( $extra as $path ) {
				if ( is_string( $path ) && $path !== '' ) {
					$out[] = self::normalize_path( $path );
				}
			}
		}

		// Last resort: OS / WordPress temp (still rejected if it sits under a web root).
		$site = function_exists( 'wp_salt' ) ? substr( md5( (string) wp_salt( 'auth' ) . ABSPATH ), 0, 16 ) : substr( md5( ABSPATH ), 0, 16 );
		$tmp_roots = [
			self::normalize_path( (string) sys_get_temp_dir() ),
		];
		if ( function_exists( 'get_temp_dir' ) ) {
			$tmp_roots[] = self::normalize_path( (string) get_temp_dir() );
		}
		foreach ( array_unique( array_filter( $tmp_roots ) ) as $tmp ) {
			$out[] = trailingslashit( $tmp ) . 'wp-powersuite-' . $site . '/' . $leaf;
		}

		return array_values( array_unique( array_filter( $out ) ) );
	}

	/**
	 * Build private storage candidates by walking up from $start_dir.
	 *
	 * @return list<string>
	 */
	private static function walk_up_private_candidates( string $start_dir, string $leaf ): array {
		$out  = [];
		$dir  = self::normalize_path( $start_dir );
		$seen = [];

		for ( $i = 0; $i < 8; $i++ ) {
			if ( $dir === '' || $dir === '.' || isset( $seen[ $dir ] ) ) {
				break;
			}
			$seen[ $dir ] = true;

			$candidate = trailingslashit( $dir ) . $leaf;
			// Only propose paths that are already outside known web roots.
			if ( ! is_wp_error( self::assert_safe_path( $candidate ) ) ) {
				$out[] = $candidate;
			}

			$parent = self::normalize_path( dirname( $dir ) );
			if ( $parent === $dir || $parent === '/' || preg_match( '#^[A-Za-z]:/$#', $parent ) === 1 ) {
				break;
			}
			$dir = $parent;
		}

		return $out;
	}

	/**
	 * @return string|\WP_Error Normalized absolute path without trailing slash requirements (caller trailslashes).
	 */
	private static function validate_and_prepare( string $candidate ): string|\WP_Error {
		$safe = self::assert_safe_path( $candidate );
		if ( is_wp_error( $safe ) ) {
			return $safe;
		}

		$created = [];
		if ( ! is_dir( $candidate ) ) {
			$ancestor = self::nearest_writable_ancestor( $candidate );
			if ( is_wp_error( $ancestor ) ) {
				return $ancestor;
			}
			$created = self::create_private_directory_tree( $candidate, $ancestor );
			if ( is_wp_error( $created ) ) {
				return $created;
			}
		}

		$ready = self::assert_prepared_directory( $candidate );
		if ( is_wp_error( $ready ) ) {
			self::remove_created_dirs( $created );
			return $ready;
		}

		return $ready;
	}

	/**
	 * Create missing directories one level at a time with 0700.
	 *
	 * The ancestor must already exist, be writable, and resolve outside web roots.
	 *
	 * @return list<string>|\WP_Error Created directory paths (deepest last).
	 */
	private static function create_private_directory_tree( string $candidate, string $ancestor ): array|\WP_Error {
		$missing = [];
		$dir     = self::normalize_path( $candidate );
		while ( $dir !== '' && $dir !== $ancestor && ! is_dir( $dir ) ) {
			array_unshift( $missing, $dir );
			$next = self::normalize_path( dirname( $dir ) );
			if ( $next === $dir || self::is_filesystem_root( $next ) ) {
				break;
			}
			$dir = $next;
		}

		$created = [];
		foreach ( $missing as $dir ) {
			if ( is_dir( $dir ) ) {
				$existing = self::assert_existing_prefix_is_private( $dir );
				if ( is_wp_error( $existing ) ) {
					self::remove_created_dirs( $created );
					return $existing;
				}
				continue;
			}
			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			if ( ! @mkdir( $dir, 0700 ) ) {
				self::remove_created_dirs( $created );
				return new \WP_Error(
					'wpps_br_mkdir_failed',
					__( 'Could not create the private backup storage directory.', 'wppowersuite-lite' )
				);
			}
			$created[] = $dir;
			self::harden_permissions( $dir, true );
			$created_ok = self::assert_existing_prefix_is_private( $dir );
			if ( is_wp_error( $created_ok ) ) {
				self::remove_created_dirs( $created );
				return $created_ok;
			}
		}

		return $created;
	}

	/**
	 * @return string|\WP_Error Resolved absolute path.
	 */
	private static function assert_prepared_directory( string $candidate ): string|\WP_Error {
		if ( is_link( $candidate ) ) {
			return new \WP_Error(
				'wpps_br_symlink_rejected',
				__( 'Private backup storage must not be a symbolic link.', 'wppowersuite-lite' )
			);
		}

		if ( ! is_dir( $candidate ) || ! is_writable( $candidate ) ) {
			return new \WP_Error(
				'wpps_br_not_writable',
				__( 'Private backup storage directory is not writable.', 'wppowersuite-lite' )
			);
		}

		$real = realpath( $candidate );
		if ( ! is_string( $real ) || $real === '' ) {
			return new \WP_Error(
				'wpps_br_realpath_failed',
				__( 'Could not resolve the private backup storage path.', 'wppowersuite-lite' )
			);
		}

		$real      = self::normalize_path( $real );
		$safe_real = self::assert_safe_path( $real );
		if ( is_wp_error( $safe_real ) ) {
			return $safe_real;
		}

		return $real;
	}

	/**
	 * Nearest existing ancestor that PHP can write into (never the filesystem root).
	 *
	 * Walks past missing middle directories so `{tmp}/wp-powersuite-{site}/{leaf}`
	 * can be created when only `{tmp}` already exists. Resolves the ancestor
	 * before any mkdir so a symlink into a web root cannot be used as a parent.
	 *
	 * @return string|\WP_Error Lexical ancestor path when safe.
	 */
	private static function nearest_writable_ancestor( string $path ): string|\WP_Error {
		$dir = self::normalize_path( dirname( $path ) );

		for ( $i = 0; $i < 12; $i++ ) {
			if ( self::is_filesystem_root( $dir ) ) {
				return new \WP_Error(
					'wpps_br_parent_not_writable',
					__( 'The parent directory for private backup storage is not writable.', 'wppowersuite-lite' )
				);
			}
			if ( is_dir( $dir ) ) {
				if ( ! is_writable( $dir ) ) {
					return new \WP_Error(
						'wpps_br_parent_not_writable',
						__( 'The parent directory for private backup storage is not writable.', 'wppowersuite-lite' )
					);
				}
				$prefix = self::assert_existing_prefix_is_private( $dir );
				if ( is_wp_error( $prefix ) ) {
					return $prefix;
				}
				return $dir;
			}
			$parent = self::normalize_path( dirname( $dir ) );
			if ( $parent === $dir ) {
				return new \WP_Error(
					'wpps_br_parent_not_writable',
					__( 'The parent directory for private backup storage is not writable.', 'wppowersuite-lite' )
				);
			}
			$dir = $parent;
		}

		return new \WP_Error(
			'wpps_br_parent_not_writable',
			__( 'The parent directory for private backup storage is not writable.', 'wppowersuite-lite' )
		);
	}

	/**
	 * Existing directory must resolve outside web roots. `/tmp` → `/private/tmp`
	 * (macOS) is allowed; a symlink into uploads is not.
	 *
	 * @return true|\WP_Error
	 */
	private static function assert_existing_prefix_is_private( string $dir ): bool|\WP_Error {
		$real = realpath( $dir );
		if ( ! is_string( $real ) || $real === '' ) {
			return new \WP_Error(
				'wpps_br_realpath_failed',
				__( 'Could not resolve the private backup storage path.', 'wppowersuite-lite' )
			);
		}

		return self::assert_safe_path( self::normalize_path( $real ) );
	}

	/**
	 * @param list<string> $created Deepest last.
	 */
	private static function remove_created_dirs( array $created ): void {
		foreach ( array_reverse( $created ) as $dir ) {
			if ( ! is_string( $dir ) || $dir === '' || ! is_dir( $dir ) || is_link( $dir ) ) {
				continue;
			}
			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			@rmdir( $dir );
		}
	}

	private static function is_filesystem_root( string $dir ): bool {
		$dir = self::normalize_path( $dir );

		return $dir === ''
			|| $dir === '/'
			|| $dir === '.'
			|| preg_match( '#^[A-Za-z]:/$#', $dir ) === 1
			|| $dir === '//';
	}

	/**
	 * @return list<string> Normalized absolute web roots (no trailing slash).
	 */
	public static function web_roots(): array {
		$roots = [];

		if ( defined( 'ABSPATH' ) ) {
			$roots[] = rtrim( self::normalize_path( ABSPATH ), '/' );
		}
		if ( defined( 'WP_CONTENT_DIR' ) ) {
			$roots[] = rtrim( self::normalize_path( WP_CONTENT_DIR ), '/' );
		}

		$uploads = function_exists( 'wp_upload_dir' ) ? wp_upload_dir( null, false ) : null;
		if ( is_array( $uploads ) && ! empty( $uploads['basedir'] ) ) {
			$roots[] = rtrim( self::normalize_path( (string) $uploads['basedir'] ), '/' );
		}

		if ( ! empty( $_SERVER['DOCUMENT_ROOT'] ) && is_string( $_SERVER['DOCUMENT_ROOT'] ) ) {
			$doc = self::normalize_path( wp_unslash( $_SERVER['DOCUMENT_ROOT'] ) );
			if ( $doc !== '' ) {
				$roots[] = rtrim( $doc, '/' );
			}
		}

		/**
		 * Additional web-accessible roots that private storage must not live under.
		 *
		 * @param list<string> $roots Absolute paths.
		 */
		$extra = apply_filters( 'wpps_br_web_accessible_roots', [] );
		if ( is_array( $extra ) ) {
			foreach ( $extra as $root ) {
				if ( is_string( $root ) && $root !== '' ) {
					$roots[] = rtrim( self::normalize_path( $root ), '/' );
				}
			}
		}

		return array_values( array_unique( array_filter( $roots ) ) );
	}

	public static function normalize_path( string $path ): string {
		$path = str_replace( '\\', '/', $path );
		$path = preg_replace( '#/+#', '/', $path ) ?? $path;
		// Preserve Windows drive prefix.
		if ( preg_match( '#^[A-Za-z]:/#', $path ) ) {
			return $path;
		}
		return $path;
	}

	public static function is_absolute_path( string $path ): bool {
		$path = self::normalize_path( $path );
		if ( $path === '' ) {
			return false;
		}
		if ( str_starts_with( $path, '/' ) ) {
			return true;
		}
		if ( preg_match( '#^[A-Za-z]:/#', $path ) ) {
			return true;
		}
		// UNC
		if ( str_starts_with( $path, '//' ) ) {
			return true;
		}

		return false;
	}

	/**
	 * True when $path is equal to or nested under $root.
	 */
	public static function path_is_inside( string $path, string $root ): bool {
		$path = rtrim( self::normalize_path( $path ), '/' );
		$root = rtrim( self::normalize_path( $root ), '/' );
		if ( $path === '' || $root === '' ) {
			return false;
		}

		$path_l = strtolower( $path );
		$root_l = strtolower( $root );

		if ( $path_l === $root_l ) {
			return true;
		}

		return str_starts_with( $path_l, $root_l . '/' );
	}
}
