<?php
/**
 * Costudio_Admin_Ajax_Base — shared foundation for AJAX controllers.
 *
 * Centralizes nonce + capability checks and batch-registration of
 * wp_ajax_<action> hooks. Concrete controllers extend this class and
 * declare their own `register_hooks()` listing the actions they own.
 *
 * Pattern mirrors Costudio_Admin_Sites (register_hooks() per controller),
 * introduced as part of Task 2 — splitting the 4.1k-LOC Costudio_Admin
 * god-class into focused controllers.
 *
 * @package Contentosapp_Studio
 * @since   3.2.0
 */

defined( 'ABSPATH' ) || exit;

abstract class Costudio_Admin_Ajax_Base {

	/**
	 * Presence map ("was this field actually in the request?") for the most
	 * recent read_request() call. Lets handlers do partial saves (only touch a
	 * setting that was actually submitted) without re-reading the superglobal
	 * outside the nonce-checked scope. Read via was_present().
	 *
	 * @var array<string,bool>
	 */
	private array $request_present = array();

	/**
	 * Whether $key was present in the request read by the last read_request().
	 * Equivalent to the old has_post(), but the superglobal was already inspected
	 * inside read_request() (i.e. after the nonce check).
	 */
	protected function was_present( string $key ): bool {
		return ! empty( $this->request_present[ $key ] );
	}

	/**
	 * Wire the AJAX hooks owned by this controller.
	 *
	 * Concrete implementations should call $this->register_ajax_actions()
	 * with the list of action slugs they handle.
	 */
	abstract public function register_hooks(): void;

	/**
	 * Verify nonce + capability. Exits with a JSON error if either fails.
	 *
	 * Uses Costudio_Admin::NONCE to keep parity with every existing
	 * wp_create_nonce() call across the plugin.
	 */
	protected function verify_request(): void {
		if ( ! check_ajax_referer( Costudio_Admin::NONCE, 'nonce', false ) ) {
			costudio_log_debug( 'Contentosapp Studio AJAX: Nonce verification failed' );
			wp_send_json_error( __( 'Invalid request (nonce failed).', 'contentosapp-studio' ), 403 );
		}

		if ( ! current_user_can( 'manage_options' ) ) {
			costudio_log_debug( 'Contentosapp Studio AJAX: Permission check failed' );
			wp_send_json_error(
				__( 'Insufficient permissions.', 'contentosapp-studio' ),
				403
			);
		}
	}

	/**
	 * Verify nonce + capability AND read a typed set of request fields in a
	 * SINGLE scope.
	 *
	 * This is the canonical AJAX input entry point. Because the nonce check
	 * (check_ajax_referer) and every superglobal read live in this one method,
	 * each field is sanitized inline with the context-correct WordPress function
	 * — there is no cross-method nonce/sanitize split for a scanner to miss.
	 *
	 * Schema maps a field name to a type:
	 *   text | textarea | key | email | url | int | bool | float | array | html | block
	 * `source` is 'post' (default) or 'get'.
	 *
	 * @param array<string,string> $schema field => type.
	 * @param string               $source 'post' | 'get'.
	 * @return array<string,mixed> Sanitized values keyed by field name.
	 */
	protected function read_request( array $schema, string $source = 'post' ): array {
		if ( ! check_ajax_referer( Costudio_Admin::NONCE, 'nonce', false ) ) {
			costudio_log_debug( 'Contentosapp Studio AJAX: Nonce verification failed' );
			wp_send_json_error( __( 'Invalid request (nonce failed).', 'contentosapp-studio' ), 403 );
		}

		if ( ! current_user_can( 'manage_options' ) ) {
			costudio_log_debug( 'Contentosapp Studio AJAX: Permission check failed' );
			wp_send_json_error( __( 'Insufficient permissions.', 'contentosapp-studio' ), 403 );
		}

		$is_get                = ( 'get' === $source );
		$out                   = array();
		$this->request_present = array();

		foreach ( $schema as $key => $type ) {
			$present                       = $is_get ? isset( $_GET[ $key ] ) : isset( $_POST[ $key ] );
			$this->request_present[ $key ] = $present;
			if ( ! $present ) {
				$out[ $key ] = self::default_for_type( $type );
				continue;
			}

			switch ( $type ) {
				case 'int':
					$out[ $key ] = $is_get
						? absint( wp_unslash( $_GET[ $key ] ) )
						: absint( wp_unslash( $_POST[ $key ] ) );
					break;
				case 'key':
					$out[ $key ] = $is_get
						? sanitize_key( wp_unslash( $_GET[ $key ] ) )
						: sanitize_key( wp_unslash( $_POST[ $key ] ) );
					break;
				case 'email':
					$out[ $key ] = sanitize_email( wp_unslash( $_POST[ $key ] ) );
					break;
				case 'url':
					$out[ $key ] = esc_url_raw( wp_unslash( $_POST[ $key ] ) );
					break;
				case 'textarea':
					$out[ $key ] = sanitize_textarea_field( wp_unslash( $_POST[ $key ] ) );
					break;
				case 'html':
					$out[ $key ] = wp_kses_post( wp_unslash( $_POST[ $key ] ) );
					break;
				case 'bool':
					$out[ $key ] = in_array(
						sanitize_text_field( wp_unslash( $_POST[ $key ] ) ),
						array( '1', 'true', 'on', 'yes' ),
						true
					);
					break;
				case 'float':
					$out[ $key ] = (float) sanitize_text_field( wp_unslash( $_POST[ $key ] ) );
					break;
				case 'float_nullable':
					// Present → float; absent → null (preserved by default_for_type),
					// so callers can distinguish "not sent" from a real 0.0.
					$out[ $key ] = (float) sanitize_text_field( wp_unslash( $_POST[ $key ] ) );
					break;
				case 'array':
					// map_deep() applies a recognized sanitizer to every leaf on read;
					// sanitize_post_array_deep() additionally sanitizes nested keys.
					$out[ $key ] = ( isset( $_POST[ $key ] ) && is_array( $_POST[ $key ] ) )
						? $this->sanitize_post_array_deep( map_deep( wp_unslash( $_POST[ $key ] ), 'sanitize_textarea_field' ) )
						: array();
					break;
				case 'block':
					// Block markup sanitized on read: wp_kses_post() is the WordPress-
					// recognized HTML sanitizer and preserves Gutenberg <!-- wp:* -->
					// delimiters; filter_block_content() re-parses/re-serializes the
					// blocks; costudio_sanitize_block_markup() keeps the UTF-8/control-char
					// cleanup and length cap.
					$out[ $key ] = costudio_sanitize_block_markup(
						filter_block_content( wp_kses_post( wp_unslash( $_POST[ $key ] ) ) )
					);
					break;
				case 'text':
				default:
					$out[ $key ] = $is_get
						? sanitize_text_field( wp_unslash( $_GET[ $key ] ) )
						: sanitize_text_field( wp_unslash( $_POST[ $key ] ) );
					break;
			}
		}

		return $out;
	}

	/**
	 * Default value for a schema type when the field is absent.
	 *
	 * @param string $type
	 * @return mixed
	 */
	private static function default_for_type( string $type ) {
		switch ( $type ) {
			case 'int':
				return 0;
			case 'bool':
				return false;
			case 'float':
				return 0.0;
			case 'float_nullable':
				return null;
			case 'array':
				return array();
			default:
				return '';
		}
	}

	/**
	 * Register wp_ajax_<action> → $this->ajax_<action> for each slug.
	 *
	 * @param string[] $actions AJAX action slugs (without the wp_ajax_ prefix).
	 */
	protected function register_ajax_actions( array $actions ): void {
		foreach ( $actions as $action ) {
			add_action( "wp_ajax_{$action}", array( $this, "ajax_{$action}" ) );
		}
	}

	/**
	 * Recursively sanitize an already-read request array (keys + scalar leaves).
	 * Used by read_request()'s `array` type. Textarea-safe so multiline leaves
	 * keep their newlines.
	 */
	private function sanitize_post_array_deep( array $value ): array {
		$clean = array();

		foreach ( $value as $raw_key => $raw_value ) {
			$key = is_int( $raw_key ) ? $raw_key : sanitize_key( (string) $raw_key );
			if ( '' === (string) $key ) {
				continue;
			}

			if ( is_array( $raw_value ) ) {
				$clean[ $key ] = $this->sanitize_post_array_deep( $raw_value );
			} elseif ( is_scalar( $raw_value ) ) {
				$clean[ $key ] = sanitize_textarea_field( (string) $raw_value );
			}
		}

		return $clean;
	}
}
