<?php
/**
 * DirectoryProtector — drops .htaccess + index.php into protected upload
 * subdirectories and gates access to files within them.
 *
 * @package Platform\Helm\Modules\UploadsVault
 */

declare( strict_types = 1 );

namespace Platform\Helm\Modules\UploadsVault;

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

final class DirectoryProtector {

	/**
	 * Subdirectory name fragments that mark a folder as protected.
	 *
	 * @return string[]
	 */
	private function protected_directory_names(): array {
		/**
		 * Filter the list of upload subdirectory name fragments treated as
		 * protected. Default covers the most common form-plugin directories.
		 *
		 * @param string[] $names
		 */
		return apply_filters( 'platform_helm_uploads_vault_dirs', [
			'forms',
			'wpforms',
			'gravity_forms',
			'gforms',
			'ninja-forms',
			'formidable',
			'contact-form-7',
			'cf7',
			'wpcf7',
			'fluent-forms',
			'fluentform',
		] );
	}

	public function register_hooks(): void {
		add_filter( 'wp_handle_upload',           [ $this, 'on_handle_upload' ] );
		add_filter( 'wp_handle_sideload',         [ $this, 'on_handle_upload' ] );
		add_action( 'admin_init',                 [ $this, 'sweep_existing_dirs' ] );
		add_action( 'init',                       [ $this, 'maybe_gate_request' ], 1 );
	}

	/**
	 * Hook into uploads: when a file lands inside a protected directory, drop
	 * .htaccess and index.php files alongside it (idempotent).
	 *
	 * @param array<string,mixed> $upload
	 * @return array<string,mixed>
	 */
	public function on_handle_upload( $upload ) {
		if ( ! is_array( $upload ) || empty( $upload['file'] ) ) {
			return $upload;
		}
		$file = (string) $upload['file'];
		$dir  = dirname( $file );

		if ( $this->is_protected_path( $dir ) ) {
			$this->ensure_protection_files( $dir );
		}
		return $upload;
	}

	/**
	 * Periodic sweep: walks upload subdirectories matching protected names and
	 * ensures every one has its .htaccess + index.php in place. Runs once a
	 * day via a transient lock.
	 */
	public function sweep_existing_dirs(): void {
		$lock = get_transient( 'platform_helm_vault_sweep_lock' );
		if ( $lock ) {
			return;
		}
		set_transient( 'platform_helm_vault_sweep_lock', 1, 86400 ); // 24h

		$upload_dir = wp_upload_dir();
		if ( ! is_array( $upload_dir ) || empty( $upload_dir['basedir'] ) ) {
			return;
		}
		$base = trailingslashit( (string) $upload_dir['basedir'] );

		foreach ( $this->protected_directory_names() as $name ) {
			$candidate = $base . $name;
			if ( is_dir( $candidate ) ) {
				$this->ensure_protection_files( $candidate );
				$this->recursive_protect( $candidate );
			}
		}
	}

	private function recursive_protect( string $dir ): void {
		try {
			$iterator = new \RecursiveIteratorIterator(
				new \RecursiveDirectoryIterator( $dir, \FilesystemIterator::SKIP_DOTS ),
				\RecursiveIteratorIterator::SELF_FIRST
			);
			foreach ( $iterator as $item ) {
				if ( $item->isDir() ) {
					$this->ensure_protection_files( $item->getPathname() );
				}
			}
		} catch ( \Throwable $e ) {
			// Best-effort.
		}
	}

	private function is_protected_path( string $path ): bool {
		$path = wp_normalize_path( $path );
		foreach ( $this->protected_directory_names() as $needle ) {
			if ( false !== strpos( $path, '/' . $needle . '/' )
				|| str_ends_with( $path, '/' . $needle ) ) {
				return true;
			}
		}
		return false;
	}

	private function ensure_protection_files( string $dir ): void {
		if ( ! is_dir( $dir ) || ! is_writable( $dir ) ) {
			return;
		}
		$htaccess = trailingslashit( $dir ) . '.htaccess';
		$index    = trailingslashit( $dir ) . 'index.php';

		if ( ! file_exists( $htaccess ) ) {
			@file_put_contents( $htaccess, $this->htaccess_contents(), LOCK_EX );
		}
		if ( ! file_exists( $index ) ) {
			@file_put_contents( $index, $this->index_contents(), LOCK_EX );
		}
	}

	private function htaccess_contents(): string {
		return "# Tshepho's Ops — Uploads Vault\n"
			. "# Deny direct access; serve via WP only.\n"
			. "<IfModule mod_authz_core.c>\n"
			. "    Require all denied\n"
			. "</IfModule>\n"
			. "<IfModule !mod_authz_core.c>\n"
			. "    Order deny,allow\n"
			. "    Deny from all\n"
			. "</IfModule>\n";
	}

	private function index_contents(): string {
		return "<?php\n// Silence is golden — Tshepho's Ops Uploads Vault.\nhttp_response_code( 403 );\nexit;\n";
	}

	/**
	 * Front-end gate (works on nginx where .htaccess is ignored).
	 *
	 * If a request path lands inside a protected uploads dir AND the requester
	 * isn't an admin, redirect to home with 403. Static files served by the
	 * webserver before PHP runs won't hit this — that's why .htaccess matters
	 * for Apache. On nginx, the recommended pattern is to add an `internal`
	 * location for these paths and X-Accel-Redirect, but a PHP gate at init
	 * is a defensive belt-and-braces fallback.
	 */
	public function maybe_gate_request(): void {
		if ( is_admin() || wp_doing_ajax() || ( defined( 'DOING_CRON' ) && DOING_CRON ) ) {
			return;
		}
		if ( current_user_can( 'manage_options' ) ) {
			return;
		}
		$uri = isset( $_SERVER['REQUEST_URI'] ) ? (string) wp_unslash( $_SERVER['REQUEST_URI'] ) : '';
		if ( '' === $uri ) {
			return;
		}
		$upload_dir = wp_upload_dir();
		if ( ! is_array( $upload_dir ) || empty( $upload_dir['baseurl'] ) ) {
			return;
		}
		$uploads_path = (string) wp_parse_url( $upload_dir['baseurl'], PHP_URL_PATH );
		if ( '' === $uploads_path ) {
			return;
		}
		if ( 0 !== strpos( $uri, $uploads_path ) ) {
			return;
		}
		$relative = substr( $uri, strlen( $uploads_path ) );
		if ( false === $relative ) {
			return;
		}
		foreach ( $this->protected_directory_names() as $needle ) {
			if ( 0 === strpos( ltrim( $relative, '/' ), $needle . '/' ) ) {
				status_header( 403 );
				nocache_headers();
				wp_die( esc_html__( 'Access denied.', 'platform-helm' ), '', [ 'response' => 403 ] );
			}
		}
	}
}
