<?php
/**
 * Protected-options WP filter — generic backstop.
 *
 * Why this exists
 * ---------------
 * The brain (api-scs-credits-system) and the WP-CLI ability
 * (RunWpCli::verify_command_security) both have protected-resource
 * gates. They cover the common paths (CLI, REST /wp/v2/settings,
 * the brain's tool registry). They do NOT cover:
 *
 *   • Custom-plugin REST/AJAX endpoints that internally call
 *     update_option('siteurl', …) via their own settings handlers.
 *   • Generic options.php form posts wrapped by a plugin namespace.
 *   • Future MCP abilities that wrap update_option() under a
 *     different shape than what the brain extractor recognizes.
 *
 * This file installs `pre_update_option_<key>` filters at WP's
 * native option-update layer. Every `update_option()` call —
 * regardless of source — fires this filter. When the request is
 * inside an MCP context, mutations to protected keys are refused
 * by returning the previous value (WP's own contract for the
 * `pre_update_option_<key>` filter: returning a value other than
 * the new value cancels the update).
 *
 * The MCP context flag is toggled by `Rest_Api::handle_mcp_request`
 * — set on entry, cleared on exit (try/finally semantics handled
 * via `register_shutdown_function` for the abnormal-exit case).
 *
 * Generalization angle
 * --------------------
 * This is the single point where ALL option mutations are observed
 * by WordPress core itself. Adding a new protected key means
 * adding it to PROTECTED_KEYS — every CLI/REST/AJAX/code path
 * that updates that option is covered by one entry. No need to
 * find every write surface.
 *
 * @package zip-ai
 */

namespace ZipAI\MCP\Classes\Security;

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

/**
 * Installs and manages protected-options filters during MCP requests.
 *
 * Usage:
 *   Protected_Options_Filter::install();      // once, on plugin load
 *   Protected_Options_Filter::enter_mcp();    // at MCP request start
 *   Protected_Options_Filter::exit_mcp();     // at MCP request end
 */
class Protected_Options_Filter {

	/**
	 * The single source of truth for keys the MCP context cannot mutate.
	 * Mirrors `runtime/protectedResources.ts::PROTECTED_OPTION_KEYS` and
	 * `RunWpCli.php::$protected_options`.
	 *
	 * Keep this in lockstep with the brain registry — adding a key here
	 * without updating the brain is fine (defense-in-depth strengthens),
	 * but removing one without updating the brain leaves a coverage gap.
	 *
	 * @var string[]
	 */
	const PROTECTED_KEYS = array(
		'siteurl',          // brick: frontend/admin URL.
		'home',             // brick: site URL.
		// `template` + `stylesheet` are NOT in the protect-list. The
		// `wp theme activate <slug>` write path in RunWpCli is explicitly
		// allowed + approval-gated and calls `switch_theme()` which
		// updates both options — protecting them here silently no-ops
		// the switch and the handler reports false success. Theme
		// switch is the supported way to change them; arbitrary writes
		// outside that handler aren't a real surface (no MCP tool
		// exposes raw option writes for these keys).
		'admin_email',      // lock-out: password-recovery email.
		'db_version',       // brick: DB schema versioning.
		'blog_charset',     // mojibake risk.
		'users_can_register', // security boundary.
		'default_role',     // privilege escalation surface.
	);

	/**
	 * In-memory flag indicating whether we're servicing an MCP request.
	 * Toggled by `enter_mcp()` / `exit_mcp()`. Defensive default: false
	 * (out-of-MCP traffic is allowed to mutate options normally; the
	 * filter only fires its refusal during MCP-bound calls).
	 *
	 * @var bool
	 */
	private static $in_mcp_context = false;

	/**
	 * Whether `install()` has already wired the filters. Idempotent guard
	 * for plugin reload / multiple bootstrap paths.
	 *
	 * @var bool
	 */
	private static $installed = false;

	/**
	 * Wire `pre_update_option_<key>` filters for every protected key.
	 * Idempotent — calling twice does nothing on the second call.
	 *
	 * @return void
	 */
	public static function install() {
		if ( self::$installed ) {
			return;
		}
		self::$installed = true;

		foreach ( self::PROTECTED_KEYS as $key ) {
			\add_filter(
				"pre_update_option_{$key}",
				array( __CLASS__, 'maybe_block_update' ),
				10,
				3
			);
		}

		// Safety net: if a fatal error or early exit prevents `exit_mcp()`
		// from running, the shutdown hook clears the flag so the next
		// non-MCP request isn't accidentally treated as MCP-bound.
		\register_shutdown_function( array( __CLASS__, 'exit_mcp' ) );
	}

	/**
	 * Mark the start of an MCP request. The filter callback below uses
	 * this flag to decide whether to refuse the option update.
	 *
	 * @return void
	 */
	public static function enter_mcp() {
		self::$in_mcp_context = true;
	}

	/**
	 * Mark the end of an MCP request. Idempotent.
	 *
	 * @return void
	 */
	public static function exit_mcp() {
		self::$in_mcp_context = false;
	}

	/**
	 * Filter callback wired into `pre_update_option_<key>` for every
	 * key in PROTECTED_KEYS.
	 *
	 * Contract (from WP core):
	 *   - $value     — the new value about to be written.
	 *   - $old_value — the existing value in the DB.
	 *   - $option    — the option name (e.g. 'siteurl').
	 *
	 * Returning $old_value cancels the update — WP treats "new value
	 * equals old value" as a no-op and does not write. We log the
	 * attempt for observability.
	 *
	 * @param mixed  $value     New value attempted.
	 * @param mixed  $old_value Existing value.
	 * @param string $option    Option name.
	 * @return mixed
	 */
	public static function maybe_block_update( $value, $old_value, $option ) {
		if ( ! self::$in_mcp_context ) {
			return $value;
		}

		// No-op writes (same value) are not worth refusing or logging —
		// some plugins re-save options on activation as a side-effect.
		if ( \maybe_serialize( $value ) === \maybe_serialize( $old_value ) ) {
			return $value;
		}

		\error_log( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
			sprintf(
				'[zip-ai] protected_resource_blocked: refused MCP-context update of `%s`. ' .
				'Mutating site-critical options via the AI agent is disabled (would brick the site). ' .
				'If you truly need this change, do it manually in wp-admin → Settings.',
				$option
			)
		);

		return $old_value;
	}
}
