<?php
/**
 * Auto-install an `.htaccess` exemption for the chunked-import endpoint.
 *
 * Why this exists:
 *   Most shared hosts run LiteSpeed or Apache with a default mod_security
 *   ruleset that flags multipart POSTs containing PHP-looking bytes — which
 *   is exactly what a theme/plugin zip chunk looks like. The result is HTTP
 *   406 on /wp-json/backreload/v1/backups/import, with no fix the user can
 *   apply from inside the WordPress admin.
 *
 *   This class writes a small block into the site's root .htaccess (between
 *   markers, the WP-standard {@see insert_with_markers()} pattern) that
 *   disables those rules for ONLY the import URL. The block is wrapped in
 *   `<IfModule …>` guards so it is a no-op on any server without LiteSpeed
 *   or mod_security — safe everywhere that reads .htaccess.
 *
 *   nginx doesn't read .htaccess. {@see nginx_snippet()} returns the
 *   equivalent config for nginx admins to drop into their vhost.
 *
 * @package Backreload\Core
 */

namespace Backreload\Core;

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

final class WafExemption {

	/** The `# BEGIN <marker> … # END <marker>` block label written by WP. */
	const MARKER = 'Backreload Import Compat';

	/**
	 * Idempotently write the marker block into the site's root .htaccess.
	 *
	 * Returns true if the block is present after the call (already there or
	 * just written), false if .htaccess isn't writable.
	 */
	public static function install(): bool {
		if ( ! function_exists( 'insert_with_markers' ) ) {
			require_once ABSPATH . 'wp-admin/includes/misc.php';
		}
		$file = self::htaccess_path();
		if ( ! $file ) {
			return false;
		}
		// insert_with_markers() handles "file doesn't exist yet" — it creates
		// it — provided the parent directory is writable.
		return (bool) insert_with_markers( $file, self::MARKER, self::lines() );
	}

	/** Remove the marker block (used on plugin deactivation). */
	public static function remove(): bool {
		if ( ! function_exists( 'insert_with_markers' ) ) {
			require_once ABSPATH . 'wp-admin/includes/misc.php';
		}
		$file = self::htaccess_path();
		if ( ! $file || ! file_exists( $file ) ) {
			return true;
		}
		return (bool) insert_with_markers( $file, self::MARKER, [] );
	}

	/** Whether the marker block currently exists in the site's .htaccess. */
	public static function is_installed(): bool {
		$file = self::htaccess_path();
		if ( ! $file || ! is_readable( $file ) ) {
			return false;
		}
		$contents = (string) @file_get_contents( $file );
		return false !== strpos( $contents, '# BEGIN ' . self::MARKER );
	}

	/**
	 * Identify the running web server from $_SERVER['SERVER_SOFTWARE']. Returns
	 * 'litespeed' | 'openlitespeed' | 'apache' | 'nginx' | 'unknown'.
	 */
	public static function server_type(): string {
		$s = strtolower( sanitize_text_field( wp_unslash( $_SERVER['SERVER_SOFTWARE'] ?? '' ) ) );
		if ( false !== strpos( $s, 'openlitespeed' ) ) return 'openlitespeed';
		if ( false !== strpos( $s, 'litespeed' ) )     return 'litespeed';
		if ( false !== strpos( $s, 'apache' ) )        return 'apache';
		if ( false !== strpos( $s, 'nginx' ) )         return 'nginx';
		return 'unknown';
	}

	/**
	 * True for servers that read .htaccess (Apache, LiteSpeed, OpenLiteSpeed).
	 * On these we can auto-install the exemption; on nginx we surface the
	 * equivalent snippet for the admin to apply.
	 */
	public static function uses_htaccess(): bool {
		return in_array( self::server_type(), [ 'apache', 'litespeed', 'openlitespeed' ], true );
	}

	/**
	 * The block of .htaccess lines we install. Edit here, not in .htaccess
	 * directly — `insert_with_markers()` rewrites the section verbatim.
	 *
	 * @return string[]
	 */
	public static function lines(): array {
		return [
			'# Allow backreload chunked imports past LiteSpeed / mod_security rules.',
			'# Plugin/theme zip chunks contain PHP bytes; default rules reject them',
			'# with HTTP 406. This narrow exception only matches the single chunked',
			'# import URL and is a no-op on servers without LiteSpeed or mod_security.',
			'<IfModule LiteSpeed>',
			'    <LocationMatch "/wp-json/backreload/v1/backups/import">',
			'        SecRuleEngine Off',
			'    </LocationMatch>',
			'</IfModule>',
			'<IfModule mod_security.c>',
			'    <LocationMatch "/wp-json/backreload/v1/backups/import">',
			'        SecRuleEngine Off',
			'    </LocationMatch>',
			'</IfModule>',
			'<IfModule mod_security2.c>',
			'    <LocationMatch "/wp-json/backreload/v1/backups/import">',
			'        SecRuleEngine Off',
			'    </LocationMatch>',
			'</IfModule>',
		];
	}

	/**
	 * Equivalent snippet for nginx — surfaced in the System page so the admin
	 * (or the host) can drop it into the vhost. nginx ignores .htaccess; this
	 * is the only way to express the same exemption.
	 */
	public static function nginx_snippet(): string {
		return implode( "\n", [
			'# Add inside the server { ... } block for this domain. Disables',
			'# ModSecurity / NAXSI for ONLY the backreload chunked-import URL.',
			'location = /wp-json/backreload/v1/backups/import {',
			'    # ModSecurity-nginx module:',
			'    modsecurity off;',
			'    # NAXSI module (uncomment if you use it):',
			'    # LearningMode;',
			'    # SecRulesEnabled off;',
			'',
			'    # Then pass through to WordPress as normal:',
			'    try_files $uri $uri/ /index.php?$args;',
			'}',
		] );
	}

	/**
	 * Find the site's root .htaccess. Returns the absolute path if it exists
	 * and is writable, OR if it doesn't yet exist but the parent dir is
	 * writable (so insert_with_markers() can create it). Null otherwise.
	 */
	private static function htaccess_path(): ?string {
		if ( ! function_exists( 'get_home_path' ) ) {
			require_once ABSPATH . 'wp-admin/includes/file.php';
		}
		$home = trailingslashit( (string) get_home_path() );
		if ( '/' === $home ) {
			// get_home_path() can return '/' from CLI/cron contexts — fall
			// back to ABSPATH which is always defined inside WP.
			$home = trailingslashit( ABSPATH );
		}
		$file = $home . '.htaccess';
		if ( file_exists( $file ) ) {
			return is_writable( $file ) ? $file : null; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- Read-only writability probe of a target path before backup/restore.
		}
		return is_writable( $home ) ? $file : null; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- Read-only writability probe of a target path before backup/restore.
	}
}
