<?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.', 'wp-powersuite' )
			);
		}

		$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.', 'wp-powersuite' ) );
		}

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

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

		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.', 'wp-powersuite' )
				);
			}
		}

		return true;
	}

	/**
	 * Legacy locations that may already contain backups (read/migrate only; never for new writes).
	 *
	 * @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 );
		}

		return array_values( array_unique( $roots ) );
	}

	/**
	 * 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.',
			'wp-powersuite'
		);

		if ( current_user_can( 'manage_options' ) && $errors !== [] ) {
			$message .= ' ' . __( 'Details:', 'wp-powersuite' ) . ' ' . 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 temp (still rejected if it sits under a web root).
		$tmp = self::normalize_path( (string) sys_get_temp_dir() );
		if ( $tmp !== '' ) {
			$site = function_exists( 'wp_salt' ) ? substr( md5( (string) wp_salt( 'auth' ) . ABSPATH ), 0, 16 ) : substr( md5( ABSPATH ), 0, 16 );
			$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;
		}

		$parent = dirname( $candidate );
		if ( ! is_dir( $candidate ) ) {
			if ( ! is_dir( $parent ) || ! is_writable( $parent ) ) {
				return new \WP_Error(
					'wpps_br_parent_not_writable',
					__( 'The parent directory for private backup storage is not writable.', 'wp-powersuite' )
				);
			}
			if ( ! wp_mkdir_p( $candidate ) ) {
				return new \WP_Error(
					'wpps_br_mkdir_failed',
					__( 'Could not create the private backup storage directory.', 'wp-powersuite' )
				);
			}
			self::harden_permissions( $candidate, true );
		}

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

		// Reject symlink escapes: resolved realpath must still be outside web roots.
		$real = realpath( $candidate );
		if ( ! is_string( $real ) || $real === '' ) {
			return new \WP_Error(
				'wpps_br_realpath_failed',
				__( 'Could not resolve the private backup storage path.', 'wp-powersuite' )
			);
		}
		$real = self::normalize_path( $real );
		$safe_real = self::assert_safe_path( $real );
		if ( is_wp_error( $safe_real ) ) {
			return $safe_real;
		}

		// If path itself is a symlink, ensure the link target stays private.
		if ( is_link( $candidate ) ) {
			return new \WP_Error(
				'wpps_br_symlink_rejected',
				__( 'Private backup storage must not be a symbolic link.', 'wp-powersuite' )
			);
		}

		return $real;
	}

	/**
	 * @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 . '/' );
	}
}
