<?php

namespace GemBoards\Classes;

use Exception;
use GemBoards\Classes\Exception\GemboardsException;
use GemBoards\Utils\Helper;
use stdClass;
use WP_Error;

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

abstract class AbstractRequestHandler {
	/**
	 * Default Nonce Action.
	 *
	 * @var string
	 */
	protected string $nonce_action = 'gemboards_nonce';

	/**
	 * Request namespace.
	 *
	 * @var string
	 */
	protected string $namespace = GEMBOARDS_PLUGIN_SLUG;

	public const ABSINT            = 'absint';
	public const ID                = 'id';
	public const INT               = 'int';
	public const INTEGER           = 'integer';
	public const ABS_INTEGER       = 'absint';
	public const ID_ARR            = 'array-id';
	public const IDS               = 'ids';
	public const DOUBLE            = 'double';
	public const FLOAT             = 'float';
	public const ABS_DOUBLE        = 'abs-double';
	public const ABS_FLOAT         = 'abs-float';
	public const URL               = 'url';
	public const BOOLEAN           = 'bool';
	public const POST              = 'post';
	public const HTML              = 'post';
	public const SLUG              = 'slug';
	public const EMAIL             = 'email';
	public const USER              = 'user';
	public const USERNAME          = 'username';
	public const TEXTAREA          = 'textarea';
	public const TEXT              = 'text';
	public const STRING            = 'string';
	public const SAFE_TEXT         = 'safe_text';
	public const SAFE_STR          = 'safe_text';
	public const SAFE_HTML         = 'safe_text';
	public const STR_ARR           = 'array-string';
	public const STRINGS           = 'strings';
	public const PASSWORD          = 'password';
	public const COLOR             = 'color';
	public const HEX_COLOR         = 'hex_color';
	public const HEX_COLOR_NO_HASH = 'hex_color_no_hash';
	public const KEY               = 'key';

	private array $safe_text_kses_rules = array(
		'u'    => true,
		'i'    => true,
		'b'    => true,
		'br'   => true,
		'hr'   => true,
		'img'  => [
			'alt'   => true,
			'class' => true,
			'src'   => true,
			'title' => true,
		],
		'p'    => [
			'class' => true,
		],
		'ul'   => [
			'class' => true,
		],
		'li'   => [
			'class' => true,
		],
		'span' => [
			'class' => true,
			'title' => true,
		],
		'a'    => [
			'class'    => true,
			'title'    => true,
			'href'     => true,
			'target'   => true,
			'rel'      => true,
			'download' => true,
		],
	);

	/**
	 * Actions to handle.
	 *
	 * @var array
	 */
	protected array $actions = array();

	protected bool $is_ajax = false;

	abstract public function __construct();

	/**
	 * Run action hook.
	 *
	 * @return void
	 */
	abstract public function dispatch_actions();

	protected function is_ajax_action(): bool {
		return $this->is_ajax || str_starts_with( current_action(), 'wp_ajax_' );
	}

	/**
	 * Handle action callback.
	 *
	 * @return void
	 * @throws \WPAjaxDieContinueException
	 * @throws \WPAjaxDieStopException
	 */
	final public function handle_request() {
		try {
			// No caching.
			nocache_headers();

			$response = $this->prepare_response();
			if ( is_wp_error( $response ) ) {
				$this->respond_error( $response );
			}

			$this->respond_success( $response );
		} catch ( GemboardsException $e ) {
			$this->respond_error( $e->toWpError() );
		} catch ( \WPAjaxDieStopException | \WPAjaxDieContinueException $e ) {
			// let PHPUnit handle wp_die() internally
			throw $e;
		} catch ( Exception $e ) {
			$this->respond_error( new WP_Error( 'something-went-wrong', $e->getMessage(), [ 'code' => 500 ] ) );
		}
	}

	/**
	 * Prepare error response.
	 *
	 * @param WP_Error $response
	 *
	 * @return void
	 */
	protected function respond_error( WP_Error $response ) {
		$data = $response->get_error_data();
		if ( $this->is_ajax_action() ) {
			wp_send_json_error( [
				'message' => $response->get_error_message(),
			], $data['code'] ?? 400 );
		} else {
			wp_die(
				esc_html( $response->get_error_message() ),
				esc_html( $data['title'] ?? __( 'Something went wrong!', 'gemboards' ) ),
				[ 'response' => absint( $data['code'] ?? 400 ) ]
			);
		}
	}

	/**
	 * Prepare a success response.
	 *
	 * @param mixed $response
	 *
	 * @return void
	 */
	protected function respond_success( $response ) {
		if ( null !== $response ) {
			if ( $this->is_ajax_action() ) {
				wp_send_json_success( $response );
			} elseif ( is_string( $response ) && Helper::is_valid_site_url( $response ) ) {
				wp_safe_redirect( $response );
				die(); // don't use wp_die...
			} else {
				// @XXX maybe another handler or just void.
				wp_die(
					'',
					'',
					[ 'response' => null ]
				);
			}
		}
	}

	/**
	 * Prepare a response for the request.
	 *
	 * @return WP_Error|array
	 *
	 * @throws GemboardsException If an ECM-specific processing error occurs.
	 * @throws Exception If an unexpected error occurs during response preparation.
	 */
	protected function prepare_response() {
		$action = isset( $_REQUEST['action'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['action'] ) ) : '';
		$action = explode( $this->namespace . '/', $action )[1];

		if ( ! isset( $this->actions[ $action ] ) ) {
			return new WP_Error(
				'invalid_action',
				__( 'Invalid action.', 'gemboards' ),
				[
					'status' => 400,
					'title'  => __( 'Invalid action.', 'gemboards' ),
				]
			);
		}

		$details = $this->actions[ $action ];
		$nonce   = isset( $_REQUEST['security'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['security'] ) ) : '';

		if ( empty( $nonce ) && isset( $_REQUEST['_wpnonce'] ) ) {
			$nonce = sanitize_text_field( wp_unslash( $_REQUEST['_wpnonce'] ) );
		}

		if ( ! $nonce || ! wp_verify_nonce( $nonce, $this->nonce_action ) ) {
			return new WP_Error(
				'invalid_nonce',
				__( 'Invalid nonce.', 'gemboards' ),
				[
					'status' => rest_authorization_required_code(),
					'title'  => __( 'Invalid nonce.', 'gemboards' ),
				]
			);
		}

		if ( $this->is_ajax_action() ) {
			$allow_visitor  = ! empty( $details['allow_visitor_action'] ) ? $details['allow_visitor_action'] : false;
			$user_cap       = ! empty( $details['capability'] ) ? $details['capability'] : '';
			$has_permission = $this->check_ajax_permissions( $user_cap, $allow_visitor );
		} else {
			$has_permission = $this->check_post_permissions( empty( $details['capability'] ) ? 'manage_options' : $details['capability'] );
		}

		if ( is_wp_error( $has_permission ) ) {
			return $has_permission;
		}

		if ( empty( $details['callback'] ) || ! is_callable( $details['callback'] ) ) {
			wp_send_json_error( [
				'message' => __( 'Requested method not implemented.', 'gemboards' ),
			] );
		}

		$payload = $this->prepare_payload( $details['fields'] ?? null );

		$permissionKey = ! empty( $details['permission'] ) ? $details['permission'] : ( $details['capability'] ?? '' );
		if ( $permissionKey && ! PermissionResolver::can( $permissionKey, $payload ) ) {
			wp_send_json_error( [
				'message' => __( 'You do not have permission to perform this action.', 'gemboards' ),
			] );
		}

		return $this->respond( $details['callback'], $payload );
	}

	protected function maybe_decode( $payload, $type ) {
		if ( 'serialize' === $type || 'unserialize' === $type || 'php' === $type ) {
			return maybe_unserialize( $payload );
		}

		if ( str_starts_with( $payload, '[' ) || str_starts_with( $payload, '{' ) ) {
			if ( 'array' === $type ) {
				return json_decode( $payload, true );
			}

			return json_decode( $payload );
		}

		return $payload;
	}

	/**
	 * Prepare and sanitize the request payload using a declarative schema.
	 *
	 * This method:
	 * - Iterates over defined fields
	 * - Recursively sanitizes input
	 * - Applies validation rules
	 * - Supports nested and repeated fields
	 *
	 * Invalid values are silently discarded (returned as null).
	 *
	 * @param array|null $fields Schema definition from `$actions`.
	 *
	 * @return array Sanitized payload ready for callback consumption.
	 * @throws GemboardsException
	 */
	protected function prepare_payload( ?array $fields = null ): array {
		$payload = [];

		// phpcs:disable WordPress.Security.NonceVerification.Recommended -- nonce verified before this function call.
		if ( ! is_array( $fields ) || empty( $fields ) ) {
			return $payload;
		}

		foreach ( $fields as $key => $schema ) {
			if ( ! isset( $_REQUEST[ $key ] ) ) {
				continue;
			}

			// phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitized by sanitize_by_schema()
			$payload[ $key ] = $this->sanitize_by_schema( wp_unslash( $_REQUEST[ $key ] ), $schema, $key );
			// phpcs:enable
		}

		// phpcs:enable WordPress.Security.NonceVerification.Recommended

		return $payload;
	}

	/**
	 * Recursively sanitize data based on schema definition.
	 *
	 * Supported schema shapes:
	 *
	 * 1. Scalar:
	 *    'title' => 'string|min:3|max:100'
	 *
	 * 2. Nested object:
	 *    'meta' => [
	 *      'color' => 'hex_color',
	 *      'size' => 'int|min:1',
	 *    ]
	 *
	 * 3. Repeated fields:
	 *    'features' => [
	 *      [
	 *        'title' => 'string',
	 *        'cost' => 'float',
	 *      ]
	 *    ]
	 *
	 * @param mixed $value Raw request value.
	 * @param mixed $schema Field schema definition.
	 *
	 * @return mixed Sanitized value.
	 * @throws GemboardsException
	 */
	protected function sanitize_by_schema( $value, $schema, string $path = '' ) {
		// Repeated fields (numeric array schema)
		if ( is_array( $schema ) && array_is_list( $schema ) ) {
			$item_schema = $schema[0] ?? null;

			$value = $this->maybe_decode($value, 'array');
			if ( ! is_array( $value ) ) {
				return [];
			}

			$minItems = $schema['min_items'] ?? null;
			$maxItems = $schema['max_items'] ?? null;

			if ( null !== $minItems && count( $value ) < (int) $minItems ) {
				throw new GemboardsException(
					esc_html(
						sprintf(
						// translators: %1$s: Field name/path. %2$d. Minimum (count) item required.
							__( '%1$s must contain at least %2$d items.', 'gemboards' ),
							$path,
							$minItems
						)
					),
					'min_items_failed',
					400
				);
			}

			if ( null !== $maxItems && count( $value ) > (int) $maxItems ) {
				throw new GemboardsException(
					esc_html(
						sprintf(
						// translators: %1$s: Field name/path. %2$d. Max (count) item allowed.
							__( '%1$s must not exceed %2$d items.', 'gemboards' ),
							$path,
							$maxItems
						)
					),
					'max_items_failed',
					400
				);
			}

			$result = [];
			foreach ( $value as $index => $item ) {
				if ( is_array( $item ) ) {
					$result[ $index ] = $this->sanitize_by_schema( $item, $item_schema, $path . '[' . $index . ']' );
				}
			}

			return $result;
		}

		// Support schema array with 'rules' and optional 'label'
		if ( is_string( $schema ) ) {
			return $this->sanitize_scalar( $value, $schema, $path );
		}

		if ( is_array( $schema ) && isset( $schema['rules'] ) ) {
			$label    = $schema['label'] ?? $path;
			$rules    = (string) $schema['rules'];
			$nullable = ! empty( $schema['nullable'] );
			$required = str_contains( $rules, 'required' );
			$castOnly = ! empty( $schema['cast_only'] );
			$message  = $schema['message'] ?? null; // fallback
			$messages = $schema['messages'] ?? [];

			// Custom sanitizer callback
			if ( isset( $schema['sanitize'] ) && is_callable( $schema['sanitize'] ) ) {
				$value = call_user_func( $schema['sanitize'], $value, $path );
			}

			// Handle missing value
			if ( null === $value || '' === $value ) {
				if ( array_key_exists( 'default', $schema ) ) {
					return $schema['default'];
				}

				if ( $nullable ) {
					return null;
				}

				if ( $required ) {
					throw new GemboardsException(
						esc_html($messages['required'] ?? $message ?? sprintf(
						// translators: %s. Field name/label.
							__( '%s is required.', 'gemboards' ),
							esc_html( $label )
						)),
						'required_field_missing',
						400
					);
				}

				return null;
			}

			$sanitized = $this->sanitize_scalar(
				$value,
				$rules,
				$path,
				$label,
				$message,
				$castOnly,
				$messages
			);

			// Custom validator callback
			if ( isset( $schema['validate'] ) && is_callable( $schema['validate'] ) ) {
				$result = call_user_func( $schema['validate'], $sanitized, $path );
				if ( true !== $result ) {
					throw new GemboardsException(
						esc_html($message ?: ( is_string( $result ) ? $result : __( 'Validation failed.', 'gemboards' ) )),
						'custom_validation_failed',
						400
					);
				}
			}

			return $sanitized;
		}

		// Nested object
		if ( is_string($value) ) {
			$value = $this->maybe_decode($value, 'array');
		}
		if ( is_array( $schema ) && is_array( $value ) ) {
			$result = [];
			foreach ( $schema as $field => $field_schema ) {
				if ( isset( $value[ $field ] ) ) {
					$result[ $field ] = $this->sanitize_by_schema( $value[ $field ], $field_schema, $path ? $path . '.' . $field : $field );
				}
			}

			return $result;
		}

		return null;
	}

	/**
	 * Sanitize and validate scalar values.
	 *
	 * Supports extended rule syntax using pipe separators.
	 *
	 * Supported behaviors:
	 * - required → field must be present and non-empty
	 * - nullable → allows null values
	 * - default → applied when the value is missing
	 * - cast-only → sanitize and cast without validation
	 *
	 * Examples:
	 * - string|min:3|max:50|required
	 * - float|min:0
	 * - string|enum:draft,published
	 * - slug|regex:/^[a-z0-9-]+$/
	 *
	 * @param mixed $value
	 * @param string $type
	 * @param string $field_path
	 * @param string|null $custom_label
	 * @param string|null $custom_message
	 * @param bool $cast_only
	 * @param array $rule_messages
	 *
	 * @return mixed
	 * @throws GemboardsException
	 */
	protected function sanitize_scalar(
		$value,
		string $type,
		string $field_path,
		?string $custom_label = null,
		?string $custom_message = null,
		bool $cast_only = false,
		array $rule_messages = []
	) {
		if ( is_callable( $type ) ) {
			return call_user_func_array( $type, [ $value, $custom_label, $custom_message, $cast_only ] );
		}

		$rules = array_map( 'trim', explode( '|', $type ) );
		$base  = strtolower( array_shift( $rules ) );

		// --- Sanitize first ---
		switch ( $base ) {
			case self::ID:
			case self::ABSINT:
			case self::ABS_INTEGER:
			case 'absint':
			case 'id':
				$value = absint( sanitize_text_field( $value ) );
				break;

			case self::ID_ARR:
			case self::IDS:
			case 'array-id':
			case 'id-array':
			case 'ids':
				$value = is_string( $value ) ? explode( ',', $value ) : $value;
				$value = array_map( fn( $id ) => is_scalar( $id ) ? absint( $id ) : null, $value );
				$value = array_unique( array_filter( $value ) );
				break;

			case self::INT:
			case self::INTEGER:
			case 'int':
			case 'integer':
				$value = intval( sanitize_text_field( $value ) );
				break;

			case self::DOUBLE:
			case self::FLOAT:
			case 'double':
			case 'float':
				$value = floatval( sanitize_text_field( $value ) );
				break;

			case self::ABS_DOUBLE:
			case self::ABS_FLOAT:
			case 'abs-double':
			case 'abs-float':
				$value = abs( floatval( sanitize_text_field( $value ) ) );
				break;

			case self::URL:
			case 'url':
				$value = esc_url_raw( $value );
				break;

			case self::BOOLEAN:
			case 'bool':
			case 'boolean':
				$value = Helper::string_to_bool( $value );
				break;

			case self::POST:
			case self::HTML:
			case 'post':
				$value = wp_kses_post( $value );
				break;

			case self::SLUG:
			case 'slug':
				$value = sanitize_title( $value );
				break;

			case self::EMAIL:
			case 'email':
				$value = sanitize_email( $value );
				break;

			case self::USER:
			case self::USERNAME:
			case 'user':
				$value = sanitize_user( $value, str_contains( $type, 'strict' ) );
				break;

			case self::TEXTAREA:
			case 'textarea':
				$value = sanitize_textarea_field( $value );
				break;

			case self::TEXT:
			case self::STRING:
			case 'text':
			case 'string':
				$value = sanitize_text_field( $value );
				break;

			case self::SAFE_TEXT:
			case self::SAFE_STR:
			case self::SAFE_HTML:
			case 'safe_text':
				$value = wp_kses( force_balance_tags( stripslashes( $value ) ), $this->safe_text_kses_rules );
				break;

			case self::KEY:
			case 'key':
				$value = sanitize_key( $value );
				break;

			case self::STR_ARR:
			case self::STRINGS:
			case 'array-string':
			case 'strings':
				$value = is_string( $value ) ? explode( ',', $value ) : $value;
				$value = array_map( fn( $s ) => is_scalar( $s ) ? trim( sanitize_text_field( $s ) ) : null, $value );
				$value = array_unique( array_filter( $value ) );
				break;

			case self::PASSWORD:
			case 'password':
				// Do not apply sanitizer or strip-slashes as passwords can contain special characters.
				$value = trim( $value );
				break;

			case self::COLOR:
			case self::HEX_COLOR:
			case 'color':
			case 'hex_color':
				$value = sanitize_hex_color( $value );
				break;

			case self::HEX_COLOR_NO_HASH:
			case 'hex_color_no_hash':
				$value = sanitize_hex_color_no_hash( $value );
				break;

			default:
				if ( is_callable( $base ) ) {
					$value = call_user_func( $base, $value );
				} else {
					$value = is_array( $value ) ? wp_kses_post_deep( map_deep( $value, 'trim' ) ) : wp_kses_post( trim( $value ) );
				}
				break;
		}

		// --- Apply validation rules ---
		if ( ! $cast_only ) {
			$field_label = $custom_label ?: ucwords( str_replace( [ '.', '_' ], ' ', $field_path ) );

			$get_message = function ( string $rule_key, ?string $fallback = null ) use ( $rule_messages, $custom_message, $field_label ) {
				if ( isset( $rule_messages[ $rule_key ] ) ) {
					return sprintf( $rule_messages[ $rule_key ], $field_label );
				}

				if ( $custom_message ) {
					return $custom_message;
				}

				return $fallback;
			};

			foreach ( $rules as $rule ) {
				// enum:a,b,c
				if ( str_starts_with( $rule, 'enum:' ) ) {
					$allowed = array_map( 'trim', explode( ',', substr( $rule, 5 ) ) );
					if ( ! in_array( (string) $value, $allowed, true ) ) {
						throw new GemboardsException(
							esc_html($get_message(
								'enum',
								esc_html(sprintf(
								// translators: %1$s: Field label, %2$s: allowed items.
									__( '%1$s must be one of: %2$s', 'gemboards' ),
									$field_label,
									Helper::implode_with( $allowed, 'or' )
								))
							)),
							'invalid_enum',
							400
						);
					}
				}

				// min:3
				if ( str_starts_with( $rule, 'min:' ) ) {
					$min = (int) substr( $rule, 4 );
					if (
						( is_string( $value ) && mb_strlen( $value ) < $min ) ||
						( is_numeric( $value ) && $value < $min )
					) {
						throw new GemboardsException(
							esc_html($get_message(
								'min',
								esc_html(
									sprintf(
									// translators: %1$s: Field label, %2$s: Min required characters.
										__( '%1$s must be at least %2$d characters.', 'gemboards' ),
										$field_label,
										$min
									)
								)
							)),
							'min_validation_failed',
							400
						);
					}
				}

				// max:20
				if ( str_starts_with( $rule, 'max:' ) ) {
					$max = (int) substr( $rule, 4 );
					if (
						( is_string( $value ) && mb_strlen( $value ) > $max ) ||
						( is_numeric( $value ) && $value > $max )
					) {
						throw new GemboardsException(
							esc_html($get_message(
								'max',
								esc_html(
									sprintf(
									// translators: %1$s: Field label, %2$s: Max allowed characters.
										__( '%1$s must not exceed %2$d', 'gemboards' ),
										$field_label,
										$max
									)
								)
							)),
							'max_validation_failed',
							400
						);
					}
				}

				// regex:/pattern/
				if ( str_starts_with( $rule, 'regex:' ) ) {
					$pattern = substr( $rule, 6 );
					if ( is_string( $value ) && 1 !== @preg_match( $pattern, $value ) ) {
						throw new GemboardsException(
							esc_html($get_message(
								'regex',
								esc_html(
									sprintf(
									// translators: %s: Field label.
										__( '%s format is invalid.', 'gemboards' ),
										$field_label
									)
								)
							)),
							'regex_validation_failed',
							400
						);
					}
				}
			}
		}

		return $value;
	}

	/**
	 * Run action callback.
	 *
	 * @param array|string $callback
	 * @param array $payload
	 *
	 * @return WP_Error|array|stdClass|string
	 *
	 * @throws GemboardsException If an ECM-specific processing error occurs.
	 * @throws Exception If an unexpected error occurs during response preparation.
	 */
	final protected function respond( $callback, array $payload ) {
		return call_user_func( $callback, $payload );
	}

	/**
	 * @param string $capability
	 * @param bool $allow_visitors
	 *
	 * @return WP_Error|true
	 */
	protected function check_ajax_permissions( string $capability, bool $allow_visitors = false ) {
		if ( ( ! is_user_logged_in() && ! $allow_visitors ) || ( is_user_logged_in() && $capability && ! current_user_can( $capability ) ) ) {
			return new WP_Error(
				'forbidden_action',
				__( 'You do not have permission to access this page.', 'gemboards' ),
				[
					'status' => rest_authorization_required_code(),
					'title'  => __( 'Insufficient permission!', 'gemboards' ),
				]
			);
		}

		return true;
	}

	/**
	 * @param string $capability
	 *
	 * @return WP_Error|true
	 */
	protected function check_post_permissions( string $capability ) {
		if ( ! is_user_logged_in() || ! current_user_can( $capability ) ) {
			return new WP_Error(
				'forbidden_action',
				__( 'You do not have permission to access this page.', 'gemboards' ),
				[
					'status' => rest_authorization_required_code(),
					'title'  => __( 'Insufficient permission!', 'gemboards' ),
				]
			);
		}

		return true;
	}
}
