<?php
/**
 * File Manager — handles export file storage, download serving, and cleanup.
 */

namespace RawTypeOE\Free;

// Prevent direct access.
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Manages temporary export files in the uploads directory.
 */
class File_Manager {

	/** Admin-only download action. */
	const DOWNLOAD_ACTION = 'rawtype_oe_download';

	/** Public signed download action. */
	const PUBLIC_DOWNLOAD_ACTION = 'rawtype_oe_public_download';

	/** Upload subdirectory name. */
	const UPLOAD_DIR = 'rawtype-order-exporter-woocommerce';

	/** File expiration time in seconds (24 hours). */
	const FILE_EXPIRY = 86400;

	/** Cron hook name for cleanup. */
	const CLEANUP_HOOK = 'rawtype_oe_cleanup_files';

	public function register_hooks() {
		add_action( self::CLEANUP_HOOK, array( $this, 'cleanup_expired_files' ) );
		add_action( 'admin_post_' . self::DOWNLOAD_ACTION, array( $this, 'handle_download' ) );
		add_action( 'admin_post_' . self::PUBLIC_DOWNLOAD_ACTION, array( $this, 'handle_download' ) );
		add_action( 'admin_post_nopriv_' . self::PUBLIC_DOWNLOAD_ACTION, array( $this, 'handle_download' ) );
	}

	/** Schedule cleanup cron if not already scheduled. */
	public function schedule_cleanup() {
		if ( ! wp_next_scheduled( self::CLEANUP_HOOK ) ) {
			wp_schedule_event( time(), 'twicedaily', self::CLEANUP_HOOK );
		}
	}

	/** Unschedule cleanup cron. */
	public static function unschedule_cleanup() {
		wp_clear_scheduled_hook( self::CLEANUP_HOOK );
	}

	/**
	 * Get the base upload directory for export files.
	 *
	 * @return string Absolute path to the upload directory.
	 */
	public function get_upload_dir() {
		$upload_dir = wp_upload_dir();

		return trailingslashit( $upload_dir['basedir'] ) . self::UPLOAD_DIR;
	}

	/**
	 * Get the base upload URL for export files.
	 *
	 * @return string URL to the upload directory.
	 */
	public function get_upload_url() {
		$upload_dir = wp_upload_dir();

		return trailingslashit( $upload_dir['baseurl'] ) . self::UPLOAD_DIR;
	}

	/**
	 * Ensure the upload directory exists with security files.
	 *
	 * @return bool True if directory exists and is writable.
	 */
	public function ensure_upload_dir() {
		$dir = $this->get_upload_dir();

		if ( ! file_exists( $dir ) ) {
			if ( ! wp_mkdir_p( $dir ) ) {
				wc_get_logger()->error( 'Failed to create upload directory: ' . $dir, array( 'source' => 'rawtype-order-exporter-woocommerce' ) );
				return false;
			}
		}

		// Create .htaccess to prevent direct access.
		$htaccess = $dir . '/.htaccess';
		if ( ! file_exists( $htaccess ) ) {
			$content = "Order deny,allow\nDeny from all\n";
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
			if ( false === file_put_contents( $htaccess, $content ) ) {
				wc_get_logger()->error( 'Failed to write .htaccess security file: ' . $htaccess, array( 'source' => 'rawtype-order-exporter-woocommerce' ) );
			}
		}

		// Create index.php to prevent directory listing.
		$index = $dir . '/index.php';
		if ( ! file_exists( $index ) ) {
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
			if ( false === file_put_contents( $index, "<?php\n// Silence is golden.\n" ) ) {
				wc_get_logger()->error( 'Failed to write index.php security file: ' . $index, array( 'source' => 'rawtype-order-exporter-woocommerce' ) );
			}
		}

		$web_config = $dir . '/web.config';
		if ( ! file_exists( $web_config ) ) {
			$content = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<configuration>\n\t<system.webServer>\n\t\t<authorization>\n\t\t\t<deny users=\"*\" />\n\t\t</authorization>\n\t</system.webServer>\n</configuration>\n";
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
			if ( false === file_put_contents( $web_config, $content ) ) {
				wc_get_logger()->error( 'Failed to write web.config security file: ' . $web_config, array( 'source' => 'rawtype-order-exporter-woocommerce' ) );
			}
		}

		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable
		$ready = is_dir( $dir ) && is_writable( $dir );

		if ( ! $ready ) {
			wc_get_logger()->error( 'Upload directory is not writable: ' . $dir, array( 'source' => 'rawtype-order-exporter-woocommerce' ) );
		}

		return $ready;
	}

	/**
	 * Generate a unique opaque storage path for an export.
	 *
	 * @param string $entity Entity name (orders, products, customers).
	 * @param string $ext    File extension (csv, xlsx).
	 * @return string Absolute file path.
	 */
	public function generate_file_path( $entity, $ext ) {
		$this->ensure_upload_dir();

		$entity     = sanitize_key( $entity );
		$ext        = sanitize_key( $ext );
		$upload_dir = trailingslashit( $this->get_upload_dir() );
		$token      = strtolower( preg_replace( '/[^a-z0-9]/i', '', wp_hash( uniqid( '', true ) . wp_rand() . microtime( true ) ) ) );
		$token      = substr( $token, 0, 20 );

		if ( '' === $token ) {
			$token = strtolower( substr( md5( uniqid( '', true ) . wp_rand() ), 0, 20 ) );
		}

		$file_name = 'export-' . $entity . '-' . $token . '.' . $ext;

		return $upload_dir . $file_name;
	}

	/**
	 * Generate the browser-facing download file name.
	 *
	 * @param string $entity         Entity name (orders, products, customers).
	 * @param string $ext            File extension (csv, xlsx).
	 * @param string $preferred_name Optional preferred file name.
	 * @return string
	 */
	public function generate_download_name( $entity, $ext, $preferred_name = '' ) {
		$entity         = sanitize_key( $entity );
		$ext            = sanitize_key( $ext );
		$preferred_name = sanitize_file_name( (string) $preferred_name );

		if ( '' !== $preferred_name ) {
			$base_name = sanitize_file_name( pathinfo( $preferred_name, PATHINFO_FILENAME ) );
			if ( '' !== $base_name ) {
				return $base_name . '.' . $ext;
			}
		}

		return $entity . '-' . gmdate( 'Y-m-d' ) . '.' . $ext;
	}

	/**
	 * Determine whether a path belongs to the managed export directory.
	 *
	 * @param string $file_path Candidate file path.
	 * @return bool
	 */
	public function is_managed_file_path( $file_path ) {
		$resolved = $this->resolve_managed_file_path( $file_path );

		return '' !== $resolved;
	}

	/**
	 * Normalize a managed export path and reject anything outside the upload dir.
	 *
	 * @param string $file_path Candidate file path.
	 * @return string Normalized absolute path or empty string.
	 */
	public function resolve_managed_file_path( $file_path ) {
		$file_path = (string) $file_path;
		if ( '' === trim( $file_path ) ) {
			return '';
		}

		// Reject null bytes — PHP file functions truncate on them.
		if ( false !== strpos( $file_path, "\0" ) ) {
			return '';
		}

		$base_dir = trailingslashit( wp_normalize_path( $this->get_upload_dir() ) );
		$path     = wp_normalize_path( $file_path );

		if ( false !== strpos( $path, '/../' ) || '/..' === substr( $path, -3 ) || 0 === strpos( $path, '../' ) ) {
			return '';
		}

		if ( file_exists( $path ) ) {
			$real_path = realpath( $path );
			if ( false !== $real_path ) {
				$path = wp_normalize_path( $real_path );
			}
		}

		if ( 0 !== strpos( $path, $base_dir ) ) {
			return '';
		}

		return $path;
	}

	/**
	 * Generate a secure download URL for a file.
	 *
	 * @param string $file_path     Absolute path to the file.
	 * @param bool   $public        Whether to generate a public signed link.
	 * @param string $download_name Optional download filename shown to the user.
	 * @return string Download URL.
	 */
	public function get_download_url( $file_path, $public = false, $download_name = '' ) {
		$file_name = sanitize_file_name( basename( (string) $file_path ) );
		if ( '' === $file_name ) {
			return '';
		}

		$download_name = $this->sanitize_download_name( $download_name, $file_name );

		if ( $public ) {
			$expires   = time() + self::FILE_EXPIRY;
			$signature = $this->build_public_download_signature( $file_name, $expires );

			return admin_url(
				'admin-post.php?action=' . self::PUBLIC_DOWNLOAD_ACTION
				. '&file=' . rawurlencode( $file_name )
				. '&name=' . rawurlencode( $download_name )
				. '&expires=' . absint( $expires )
				. '&signature=' . rawurlencode( $signature )
			);
		}

		$file_hash = wp_hash( basename( $file_path ) );

		return admin_url(
			'admin-post.php?action=' . self::DOWNLOAD_ACTION
			. '&file=' . rawurlencode( $file_name )
			. '&name=' . rawurlencode( $download_name )
			. '&hash=' . rawurlencode( $file_hash )
			. '&nonce=' . wp_create_nonce( 'rawtype_oe_download_' . $file_name )
		);
	}

	/** Handle file download requests via admin-post.php. */
	public function handle_download() {
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Request is validated below.
		$action = isset( $_REQUEST['action'] ) ? sanitize_key( wp_unslash( $_REQUEST['action'] ) ) : self::DOWNLOAD_ACTION;

		if ( self::PUBLIC_DOWNLOAD_ACTION === $action ) {
			$file_name = $this->validate_public_download_request();
		} else {
			$file_name = $this->validate_admin_download_request();
		}

		$file_path     = $this->get_upload_dir() . '/' . $file_name;
		$download_name = $this->get_requested_download_name( $file_name );

		if ( ! file_exists( $file_path ) ) {
			wp_die( esc_html__( 'File not found or expired.', 'rawtype-order-exporter-woocommerce' ), '', array( 'response' => 404 ) );
		}

		$this->serve_download( $file_path, $download_name );
	}

	/**
	 * Clean up expired export files (older than 24 hours).
	 *
	 * @return int Number of files deleted.
	 */
	public function cleanup_expired_files() {
		$dir = $this->get_upload_dir();

		if ( ! is_dir( $dir ) ) {
			return 0;
		}

		$deleted = 0;
		$now     = time();
		$files   = glob( $dir . '/*.{csv,xlsx}', GLOB_BRACE );

		if ( ! is_array( $files ) ) {
			return 0;
		}

		foreach ( $files as $file ) {
			$file_time = filemtime( $file );

			if ( false !== $file_time && ( $now - $file_time ) > self::FILE_EXPIRY ) {
				// phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink
				if ( unlink( $file ) ) {
					++$deleted;
				} else {
					wc_get_logger()->error( 'Failed to delete expired export file: ' . $file, array( 'source' => 'rawtype-order-exporter-woocommerce' ) );
				}
			}
		}

		return $deleted;
	}

	/**
	 * Validate an authenticated admin download request.
	 *
	 * @return string
	 */
	private function validate_admin_download_request() {
		if ( ! current_user_can( 'manage_woocommerce' ) ) {
			wp_die( esc_html__( 'Unauthorized access.', 'rawtype-order-exporter-woocommerce' ), '', array( 'response' => 403 ) );
		}

		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce verified below.
		$file_name = isset( $_GET['file'] ) ? sanitize_file_name( wp_unslash( $_GET['file'] ) ) : '';
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		$hash = isset( $_GET['hash'] ) ? sanitize_text_field( wp_unslash( $_GET['hash'] ) ) : '';
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		$nonce = isset( $_GET['nonce'] ) ? sanitize_text_field( wp_unslash( $_GET['nonce'] ) ) : '';

		if ( empty( $file_name ) || empty( $hash ) || empty( $nonce ) ) {
			wp_die( esc_html__( 'Invalid download request.', 'rawtype-order-exporter-woocommerce' ), '', array( 'response' => 400 ) );
		}

		if ( ! wp_verify_nonce( $nonce, 'rawtype_oe_download_' . $file_name ) ) {
			wp_die( esc_html__( 'Security check failed.', 'rawtype-order-exporter-woocommerce' ), '', array( 'response' => 403 ) );
		}

		$expected_hash = wp_hash( $file_name );
		if ( ! hash_equals( $expected_hash, $hash ) ) {
			wp_die( esc_html__( 'Invalid file hash.', 'rawtype-order-exporter-woocommerce' ), '', array( 'response' => 403 ) );
		}

		return $file_name;
	}

	/**
	 * Validate a public signed download request.
	 *
	 * @return string
	 */
	private function validate_public_download_request() {
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Signed parameters verified below.
		$file_name = isset( $_GET['file'] ) ? sanitize_file_name( wp_unslash( $_GET['file'] ) ) : '';
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		$expires = isset( $_GET['expires'] ) ? absint( wp_unslash( $_GET['expires'] ) ) : 0;
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		$signature = isset( $_GET['signature'] ) ? sanitize_text_field( wp_unslash( $_GET['signature'] ) ) : '';

		if ( empty( $file_name ) || $expires < 1 || empty( $signature ) ) {
			wp_die( esc_html__( 'Invalid download request.', 'rawtype-order-exporter-woocommerce' ), '', array( 'response' => 400 ) );
		}

		if ( $expires < time() ) {
			wp_die( esc_html__( 'File not found or expired.', 'rawtype-order-exporter-woocommerce' ), '', array( 'response' => 410 ) );
		}

		$expected_signature = $this->build_public_download_signature( $file_name, $expires );
		if ( ! hash_equals( $expected_signature, $signature ) ) {
			wp_die( esc_html__( 'Security check failed.', 'rawtype-order-exporter-woocommerce' ), '', array( 'response' => 403 ) );
		}

		return $file_name;
	}

	/**
	 * Build the signed token used for public downloads.
	 *
	 * @param string $file_name Export file name.
	 * @param int    $expires   Unix expiry timestamp.
	 * @return string
	 */
	private function build_public_download_signature( $file_name, $expires ) {
		return wp_hash( $file_name . '|' . absint( $expires ) . '|public_download' );
	}

	/**
	 * Resolve the requested browser-facing download name.
	 *
	 * @param string $fallback_name Fallback storage file name.
	 * @return string
	 */
	private function get_requested_download_name( $fallback_name ) {
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Request already validated by caller.
		$requested_name = isset( $_GET['name'] ) ? sanitize_file_name( wp_unslash( $_GET['name'] ) ) : '';

		return $this->sanitize_download_name( $requested_name, $fallback_name );
	}

	/**
	 * Sanitize the browser-facing download name.
	 *
	 * @param string $download_name Candidate file name.
	 * @param string $fallback_name Fallback file name.
	 * @return string
	 */
	private function sanitize_download_name( $download_name, $fallback_name ) {
		$download_name = sanitize_file_name( (string) $download_name );
		$fallback_name = sanitize_file_name( (string) $fallback_name );

		if ( '' !== $download_name ) {
			$fallback_ext = strtolower( pathinfo( $fallback_name, PATHINFO_EXTENSION ) );
			$request_ext  = strtolower( pathinfo( $download_name, PATHINFO_EXTENSION ) );

			if ( '' !== $fallback_ext && $request_ext !== $fallback_ext ) {
				$base_name     = sanitize_file_name( pathinfo( $download_name, PATHINFO_FILENAME ) );
				$download_name = '' !== $base_name ? $base_name . '.' . $fallback_ext : $fallback_name;
			}
		}

		return '' !== $download_name ? $download_name : $fallback_name;
	}

	/**
	 * Stream a validated export file to the browser.
	 *
	 * @param string $file_path Absolute file path.
	 * @param string $file_name File name for download.
	 */
	private function serve_download( $file_path, $file_name ) {
		$ext         = strtolower( pathinfo( $file_name, PATHINFO_EXTENSION ) );
		$mime_type   = $this->sanitize_header_value( $this->get_mime_type( $ext ) );
		$disposition = $this->build_content_disposition( $file_name );

		// Drain all output buffers so binary file streams are not corrupted.
		while ( ob_get_level() > 0 ) {
			ob_end_clean();
		}

		header( 'Content-Type: ' . $mime_type );
		header( 'Content-Disposition: ' . $disposition );
		header( 'Content-Length: ' . filesize( $file_path ) );
		header( 'Cache-Control: private, no-cache, no-store, must-revalidate' );
		header( 'Pragma: no-cache' );
		header( 'X-Content-Type-Options: nosniff' );

		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile
		readfile( $file_path );

		exit;
	}

	/**
	 * Build an RFC 6266 compliant Content-Disposition header value.
	 *
	 * Uses an ASCII-safe fallback plus a filename* UTF-8 token so browsers can
	 * recover unicode names safely, and strips CR/LF to prevent header injection.
	 *
	 * @param string $file_name Download file name.
	 * @return string
	 */
	private function build_content_disposition( $file_name ) {
		$file_name = (string) $file_name;

		$ascii_fallback = preg_replace( '/[^A-Za-z0-9._\-]/', '_', $file_name );
		if ( '' === $ascii_fallback ) {
			$ascii_fallback = 'download';
		}

		$utf8_token = rawurlencode( $file_name );

		return sprintf(
			'attachment; filename="%s"; filename*=UTF-8\'\'%s',
			$this->sanitize_header_value( $ascii_fallback ),
			$this->sanitize_header_value( $utf8_token )
		);
	}

	/**
	 * Strip CR/LF and null bytes from a header value to prevent header injection.
	 *
	 * @param string $value Candidate header value.
	 * @return string
	 */
	private function sanitize_header_value( $value ) {
		return str_replace( array( "\r", "\n", "\0" ), '', (string) $value );
	}

	/**
	 * Get MIME type for a file extension.
	 *
	 * @param string $ext File extension.
	 * @return string MIME type.
	 */
	private function get_mime_type( $ext ) {
		$types = array(
			'csv'  => 'text/csv',
			'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
		);

		return isset( $types[ $ext ] ) ? $types[ $ext ] : 'application/octet-stream';
	}
}
