<?php
/**
 * Backup storage location.
 *
 * Per the WordPress.org guidelines, plugin-created files live in the uploads
 * directory under a folder named after the plugin slug:
 *
 *     wp_upload_dir()['basedir'] . '/backreload'
 *
 * The folder is created on demand and hardened with a deny-all `.htaccess`
 * plus an empty `index.php` so backup archives are never web-accessible. The
 * backup engine also skips this directory when archiving the uploads folder
 * (see ArchiveWriter's absolute skip), so it never backs up its own backups.
 *
 * @package Backreload
 */

namespace Backreload;

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

final class Storage {

	/** Cached absolute root for the current request. */
	private static ?string $root = null;

	/**
	 * Absolute path to the plugin's storage root, created + protected on first
	 * use. Example: /var/www/wp-content/uploads/backreload
	 */
	public static function root(): string {
		if ( null !== self::$root ) {
			return self::$root;
		}
		$uploads = wp_get_upload_dir();
		$dir     = rtrim( (string) $uploads['basedir'], '/\\' ) . '/backreload';

		if ( ! is_dir( $dir ) ) {
			wp_mkdir_p( $dir );
		}
		self::protect( $dir );

		return self::$root = $dir;
	}

	/**
	 * Resolve a Local Storage destination's on-disk directory, tolerating a
	 * stale absolute path.
	 *
	 * A Local destination persists an ABSOLUTE path in its config. The database
	 * carries that path across a migration or host move, so on the new server it
	 * points at the OLD host's filesystem (e.g. /home/<source-account>/...),
	 * which can't be created here — breaking import, restore-to-local, AND the
	 * migration finalize step ("Storage path is not writable" / "Local Storage
	 * path is unusable", showing the source server's path).
	 *
	 * Returns the configured path only when it's a real, writable directory on
	 * THIS server; otherwise the runtime uploads location (root(), built from
	 * wp_get_upload_dir()). Callers should persist the returned value back onto
	 * the destination record so the stored path self-heals.
	 */
	public static function usable_local_dir( string $configured ): string {
		$configured = rtrim( $configured, '/\\' );
		// Only ever honour a stored path that is (a) inside this site's own tree
		// and (b) a real, writable directory here. The containment check mirrors
		// LocalDestination::validate() — without it, a foreign absolute path
		// carried across a migration that happened to be creatable+writable on
		// the new host could turn the storage/download endpoints into a
		// write/read primitive OUTSIDE the .htaccess-protected tree. Anything
		// else falls back to the runtime uploads location.
		if ( '' !== $configured && self::path_within_site( $configured ) ) {
			if ( ! is_dir( $configured ) ) {
				wp_mkdir_p( $configured );
			}
			if ( is_dir( $configured ) && wp_is_writable( $configured ) ) {
				return $configured;
			}
		}
		return self::root();
	}

	/**
	 * True when $path is inside this site's wp-content or uploads tree (the only
	 * places a Local Storage destination may write). Rejects `..` traversal and
	 * null bytes. String-prefix containment against the normalized bases.
	 */
	private static function path_within_site( string $path ): bool {
		$path = rtrim( $path, '/\\' );
		if ( '' === $path || false !== strpos( $path, "\0" ) || preg_match( '#(^|/)\.\.(/|$)#', $path ) ) {
			return false;
		}
		$bases = [ rtrim( (string) WP_CONTENT_DIR, '/\\' ) ];
		$up    = wp_get_upload_dir();
		if ( ! empty( $up['basedir'] ) ) {
			$bases[] = rtrim( (string) $up['basedir'], '/\\' );
		}
		foreach ( $bases as $b ) {
			if ( '' !== $b && ( $path === $b || 0 === strpos( $path, $b . '/' ) ) ) {
				return true;
			}
		}
		return false;
	}

	/**
	 * Drop a deny-all `.htaccess` and a silent `index.php` into $dir so the
	 * backup archives it holds can't be downloaded over HTTP.
	 */
	public static function protect( string $dir ): void {
		if ( is_dir( $dir ) && ! file_exists( $dir . '/.htaccess' ) ) {
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- One-time write of a protective deny rule into the plugin's own storage dir; WP_Filesystem isn't loaded in cron/REST contexts.
			@file_put_contents( $dir . '/.htaccess', "deny from all\n" );
		}
		if ( is_dir( $dir ) && ! file_exists( $dir . '/index.php' ) ) {
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- One-time write of a silence-is-golden index into the plugin's own storage dir.
			@file_put_contents( $dir . '/index.php', "<?php // silence is golden\n" );
		}
	}
}
