<?php
/**
 * CPT Builder validation and sanitization.
 *
 * Turns raw, untrusted input (form submissions or imported JSON) into a
 * normalized, safe definition, and validates business rules such as key
 * format, reserved names, and uniqueness.
 *
 * @package CptBuilder
 * @since 1.0.0
 */

namespace CptBuilder;

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

/**
 * Validation and sanitization helper.
 */
final class Validator {

	private Storage $storage;

	public function __construct( Storage $storage ) {
		$this->storage = $storage;
	}

	/**
	 * Normalize a raw post type key to allowed characters.
	 *
	 * @param string $key Raw key.
	 */
	public function sanitizeKey( string $key ): string {
		$key = strtolower( trim( $key ) );
		$key = preg_replace( '/[^a-z0-9_]/', '', $key );

		return substr( (string) $key, 0, Config::MAX_KEY_LENGTH );
	}

	/**
	 * Validate a post type key against format and collision rules.
	 *
	 * @param string      $key          Sanitized key.
	 * @param bool        $is_edit      Whether editing an existing definition.
	 * @param string|null $original_key Original key when editing.
	 * @return string|null Error message, or null when valid.
	 */
	public function validateKey( string $key, bool $is_edit, ?string $original_key = null ): ?string {
		if ( $key === '' ) {
			return __( 'The post type key is required.', 'wp-powersuite' );
		}

		if ( ! preg_match( '/^[a-z][a-z0-9_]*$/', $key ) ) {
			return __( 'The post type key must start with a letter and use only lowercase letters, numbers, and underscores.', 'wp-powersuite' );
		}

		if ( strlen( $key ) > Config::MAX_KEY_LENGTH ) {
			return sprintf(
				/* translators: %d: maximum number of characters. */
				__( 'The post type key cannot be longer than %d characters.', 'wp-powersuite' ),
				Config::MAX_KEY_LENGTH
			);
		}

		if ( in_array( $key, Config::reservedPostTypes(), true ) ) {
			return __( 'That post type key is reserved by WordPress and cannot be used.', 'wp-powersuite' );
		}

		if ( in_array( $key, Config::reservedSlugs(), true ) ) {
			return __( 'That post type key conflicts with a reserved WordPress rewrite or query slug.', 'wp-powersuite' );
		}

		// When editing, the key cannot change (it is the storage identifier).
		if ( $is_edit && $original_key !== null && $key !== $original_key ) {
			return __( 'The post type key cannot be changed after creation.', 'wp-powersuite' );
		}

		// Duplicate within our own definitions.
		if ( ! $is_edit && $this->storage->exists( $key ) ) {
			return __( 'A post type with this key already exists.', 'wp-powersuite' );
		}

		// Collision with a post type registered elsewhere (theme/plugin/core)
		// that we do not manage. Only checked when creating.
		if ( ! $is_edit && post_type_exists( $key ) ) {
			return __( 'A post type with this key is already registered by another plugin or theme.', 'wp-powersuite' );
		}

		return null;
	}

	/**
	 * Build a complete, sanitized definition from raw input.
	 *
	 * @param array<string, mixed>      $input    Raw (already unslashed) input.
	 * @param array<string, mixed>|null $existing Existing definition when editing.
	 * @return array<string, mixed>
	 */
	public function buildDefinition( array $input, ?array $existing = null ): array {
		$now        = time();
		$definition = Config::defaultDefinition();

		if ( is_array( $existing ) ) {
			$definition = array_merge( $definition, $existing );
		}

		$definition['key']        = $this->sanitizeKey( (string) ( $input['key'] ?? ( $existing['key'] ?? '' ) ) );
		$definition['enabled']    = ! empty( $input['enabled'] );
		$definition['labels']     = $this->sanitizeLabels( isset( $input['labels'] ) && is_array( $input['labels'] ) ? $input['labels'] : [] );
		$definition['args']       = $this->sanitizeArgs(
			isset( $input['args'] ) && is_array( $input['args'] ) ? $input['args'] : [],
			$definition['key']
		);
		$definition['supports']   = $this->sanitizeSupports( isset( $input['supports'] ) && is_array( $input['supports'] ) ? $input['supports'] : [] );
		$definition['taxonomies'] = $this->sanitizeTaxonomies( isset( $input['taxonomies'] ) && is_array( $input['taxonomies'] ) ? $input['taxonomies'] : [] );
		$definition['schema']     = Config::SCHEMA_VERSION;
		$definition['created_at'] = (int) ( $existing['created_at'] ?? 0 ) ?: $now;
		$definition['updated_at'] = $now;

		return $definition;
	}

	/**
	 * Sanitize label values.
	 *
	 * @param array<string, mixed> $labels Raw labels.
	 * @return array<string, string>
	 */
	public function sanitizeLabels( array $labels ): array {
		$clean = [];
		foreach ( Config::allLabelKeys() as $label_key ) {
			if ( ! isset( $labels[ $label_key ] ) ) {
				continue;
			}
			$value = sanitize_text_field( (string) $labels[ $label_key ] );
			if ( $value !== '' ) {
				$clean[ $label_key ] = $value;
			}
		}

		return $clean;
	}

	/**
	 * Sanitize and cast register_post_type() arguments.
	 *
	 * @param array<string, mixed> $args Raw args.
	 * @param string               $key  Post type key (used for custom capability defaults).
	 * @return array<string, mixed>
	 */
	public function sanitizeArgs( array $args, string $key = '' ): array {
		$defaults = Config::defaultArgs();
		$clean    = $defaults;
		$booleans = Config::booleanArgKeys();

		foreach ( $booleans as $bool_key ) {
			$clean[ $bool_key ] = ! empty( $args[ $bool_key ] );
		}

		// Description.
		$clean['description'] = isset( $args['description'] ) ? sanitize_textarea_field( (string) $args['description'] ) : '';

		// Slugs.
		$clean['rest_base']        = isset( $args['rest_base'] ) ? sanitize_title( (string) $args['rest_base'] ) : '';
		$clean['has_archive_slug'] = isset( $args['has_archive_slug'] ) ? sanitize_title( (string) $args['has_archive_slug'] ) : '';
		$clean['rewrite_slug']     = isset( $args['rewrite_slug'] ) ? sanitize_title( (string) $args['rewrite_slug'] ) : '';
		$clean['query_var_slug']   = isset( $args['query_var_slug'] ) ? sanitize_title( (string) $args['query_var_slug'] ) : '';

		// REST namespace (e.g. "myplugin/v1").
		$clean['rest_namespace'] = $this->sanitizeRestNamespace( (string) ( $args['rest_namespace'] ?? '' ) );

		// REST controller: allowlisted / validated subclass of WP_REST_Controller.
		$clean['rest_controller_class'] = $this->sanitizeRestController( (string) ( $args['rest_controller_class'] ?? '' ) );

		// Capabilities (easy mode + derived types).
		$clean = $this->sanitizeCapabilityArgs( $clean, $args, $key );

		// Optional parent admin menu slug (used when show_in_menu is on).
		$parent = isset( $args['show_in_menu_parent'] ) ? sanitize_text_field( (string) $args['show_in_menu_parent'] ) : '';
		$parent = preg_replace( '/[^a-z0-9_\-\/\.\?\=\&]/', '', strtolower( $parent ) );
		$clean['show_in_menu_parent'] = $clean['show_in_menu'] ? (string) $parent : '';

		// Menu position (integer or empty).
		if ( isset( $args['menu_position'] ) && $args['menu_position'] !== '' && is_numeric( $args['menu_position'] ) ) {
			$clean['menu_position'] = absint( $args['menu_position'] );
		} else {
			$clean['menu_position'] = '';
		}

		// Menu icon (dashicon class, image URL, base64, or "none").
		$clean['menu_icon'] = $this->sanitizeMenuIcon( (string) ( $args['menu_icon'] ?? 'dashicons-admin-post' ) );

		return $clean;
	}

	/**
	 * Resolve capability mode into capability_type fields.
	 *
	 * @param array<string, mixed> $clean Sanitized args being built.
	 * @param array<string, mixed> $args  Raw args.
	 * @param string               $key   Post type key.
	 * @return array<string, mixed>
	 */
	private function sanitizeCapabilityArgs( array $clean, array $args, string $key ): array {
		$mode = sanitize_key( (string) ( $args['capability_mode'] ?? '' ) );

		// Back-compat: derive mode from legacy capability_type when mode missing.
		if ( $mode === '' || ! isset( Config::capabilityModes()[ $mode ] ) ) {
			$legacy = $this->sanitizeCapabilityType( (string) ( $args['capability_type'] ?? 'post' ) );
			if ( $legacy === 'page' ) {
				$mode = 'page';
			} elseif ( $legacy === 'post' || $legacy === '' ) {
				$mode = 'post';
			} else {
				$mode = 'custom';
			}
		}

		$clean['capability_mode'] = $mode;

		if ( $mode === 'page' ) {
			$clean['capability_type']        = 'page';
			$clean['capability_type_plural'] = '';
			$clean['grant_caps_to_admin']    = false;
			return $clean;
		}

		if ( $mode === 'post' ) {
			$clean['capability_type']        = 'post';
			$clean['capability_type_plural'] = '';
			$clean['grant_caps_to_admin']    = false;
			return $clean;
		}

		$singular = $this->sanitizeCapabilityType( (string) ( $args['capability_type'] ?? '' ) );
		if ( $singular === '' || in_array( $singular, [ 'post', 'page' ], true ) ) {
			$singular = $this->sanitizeCapabilityType( $key );
		}
		if ( $singular === '' ) {
			$singular = 'post';
		}

		$plural = $this->sanitizeCapabilityType( (string) ( $args['capability_type_plural'] ?? '' ) );
		if ( $plural === '' ) {
			$plural = $singular . 's';
		}

		$clean['capability_type']        = $singular;
		$clean['capability_type_plural'] = $plural;
		$clean['grant_caps_to_admin']    = ! empty( $args['grant_caps_to_admin'] );

		return $clean;
	}

	/**
	 * Strip unsafe characters from a REST controller class name.
	 */
	private function sanitizeRestController( string $controller ): string {
		return (string) preg_replace( '/[^A-Za-z0-9_\\\\]/', '', $controller );
	}

	/**
	 * Whether a REST controller class is safe to pass to register_post_type().
	 */
	private function isAllowedRestController( string $controller ): bool {
		if ( $controller === '' || $controller === 'WP_REST_Posts_Controller' ) {
			return true;
		}

		/**
		 * Filter additional REST controller class names allowed by CPT Builder.
		 *
		 * @param array<int, string> $allowed Extra class names.
		 */
		$extra = apply_filters( 'wps_cpt_builder_allowed_rest_controllers', [] );
		$extra = is_array( $extra ) ? array_map( 'strval', $extra ) : [];

		if ( in_array( $controller, $extra, true ) ) {
			return true;
		}

		return class_exists( $controller ) && is_subclass_of( $controller, \WP_REST_Controller::class );
	}

	/**
	 * Sanitize a REST namespace value.
	 *
	 * @param string $namespace Raw namespace.
	 */
	private function sanitizeRestNamespace( string $namespace ): string {
		$namespace = strtolower( trim( $namespace ) );
		$namespace = preg_replace( '#[^a-z0-9_/-]#', '', $namespace );
		$namespace = preg_replace( '#/+#', '/', (string) $namespace );

		return trim( (string) $namespace, '/' );
	}

	/**
	 * Sanitize a capability type token.
	 *
	 * @param string $value Raw value.
	 */
	private function sanitizeCapabilityType( string $value ): string {
		$value = strtolower( trim( $value ) );
		$value = preg_replace( '/[^a-z0-9_]/', '', (string) $value );

		return (string) $value;
	}

	/**
	 * Sanitize the menu icon value.
	 *
	 * @param string $icon Raw icon value.
	 */
	private function sanitizeMenuIcon( string $icon ): string {
		$icon = trim( $icon );
		if ( $icon === '' ) {
			return 'dashicons-admin-post';
		}

		if ( $icon === 'none' || str_starts_with( $icon, 'dashicons-' ) ) {
			return preg_replace( '/[^a-z0-9-]/', '', $icon ) ?: 'dashicons-admin-post';
		}

		if ( str_starts_with( $icon, 'data:image/svg+xml;base64,' ) ) {
			return $this->sanitizeBase64SvgIcon( $icon );
		}

		if ( preg_match( '#^https?://#', $icon ) ) {
			return esc_url_raw( $icon );
		}

		return sanitize_text_field( $icon );
	}

	/**
	 * Validate a base64 SVG data-URI menu icon (size + basic XSS hardening).
	 */
	private function sanitizeBase64SvgIcon( string $icon ): string {
		$prefix = 'data:image/svg+xml;base64,';
		$raw    = substr( $icon, strlen( $prefix ) );
		if ( $raw === false || $raw === '' || strlen( $raw ) > 12000 ) {
			return 'dashicons-admin-post';
		}

		$decoded = base64_decode( $raw, true );
		if ( $decoded === false || $decoded === '' || strlen( $decoded ) > 8192 ) {
			return 'dashicons-admin-post';
		}

		if ( ! preg_match( '/<svg[\s>]/i', $decoded ) ) {
			return 'dashicons-admin-post';
		}

		if ( preg_match( '/<script|on\w+\s*=|javascript:|data:text\/html/i', $decoded ) ) {
			return 'dashicons-admin-post';
		}

		return $prefix . base64_encode( $decoded );
	}

	/**
	 * Sanitize the list of selected supports.
	 *
	 * @param array<int, mixed> $supports Raw supports list.
	 * @return array<int, string>
	 */
	public function sanitizeSupports( array $supports ): array {
		$allowed = array_keys( Config::supportsOptions() );
		$clean   = [];
		foreach ( $supports as $support ) {
			$support = sanitize_text_field( (string) $support );
			if ( in_array( $support, $allowed, true ) ) {
				$clean[] = $support;
			}
		}

		return array_values( array_unique( $clean ) );
	}

	/**
	 * Sanitize the list of connected taxonomy slugs.
	 *
	 * @param array<int, mixed> $taxonomies Raw taxonomy list.
	 * @return array<int, string>
	 */
	public function sanitizeTaxonomies( array $taxonomies ): array {
		$clean = [];
		foreach ( $taxonomies as $taxonomy ) {
			$taxonomy = sanitize_key( (string) $taxonomy );
			if ( $taxonomy !== '' ) {
				$clean[] = $taxonomy;
			}
		}

		return array_values( array_unique( $clean ) );
	}

	/**
	 * Validate a fully built definition. Returns a list of error messages.
	 *
	 * @param array<string, mixed> $definition   Built definition.
	 * @param bool                 $is_edit      Whether editing.
	 * @param string|null          $original_key Original key when editing.
	 * @return array<int, string>
	 */
	public function validateDefinition( array $definition, bool $is_edit, ?string $original_key = null ): array {
		$errors = [];

		$key_error = $this->validateKey( (string) ( $definition['key'] ?? '' ), $is_edit, $original_key );
		if ( $key_error !== null ) {
			$errors[] = $key_error;
		}

		$labels = isset( $definition['labels'] ) && is_array( $definition['labels'] ) ? $definition['labels'] : [];
		if ( empty( $labels['singular_name'] ) ) {
			$errors[] = __( 'The singular label is required.', 'wp-powersuite' );
		}
		if ( empty( $labels['name'] ) ) {
			$errors[] = __( 'The plural label is required.', 'wp-powersuite' );
		}

		$args = isset( $definition['args'] ) && is_array( $definition['args'] ) ? $definition['args'] : [];
		$key  = (string) ( $definition['key'] ?? '' );

		$slug_checks = [];
		if ( ! empty( $args['rewrite'] ) ) {
			$slug_checks[] = (string) ( $args['rewrite_slug'] ?? '' ) !== '' ? (string) $args['rewrite_slug'] : $key;
		}
		if ( ! empty( $args['query_var'] ) ) {
			$slug_checks[] = (string) ( $args['query_var_slug'] ?? '' ) !== '' ? (string) $args['query_var_slug'] : $key;
		}
		if ( ! empty( $args['has_archive'] ) ) {
			$slug_checks[] = (string) ( $args['has_archive_slug'] ?? '' ) !== '' ? (string) $args['has_archive_slug'] : $key;
		}
		if ( ! empty( $args['show_in_rest'] ) ) {
			$slug_checks[] = (string) ( $args['rest_base'] ?? '' ) !== '' ? (string) $args['rest_base'] : $key;
		}

		foreach ( array_unique( array_filter( $slug_checks ) ) as $slug ) {
			if ( in_array( $slug, Config::reservedSlugs(), true ) ) {
				$errors[] = sprintf(
					/* translators: %s: slug value. */
					__( 'The slug "%s" is reserved and would conflict with WordPress.', 'wp-powersuite' ),
					$slug
				);
			}
		}

		$controller = (string) ( $args['rest_controller_class'] ?? '' );
		if ( $controller !== '' && ! $this->isAllowedRestController( $controller ) ) {
			$errors[] = __( 'The REST controller class must exist and extend WP_REST_Controller.', 'wp-powersuite' );
		}

		if ( empty( $args['capability_type'] ) ) {
			$errors[] = __( 'A capability type is required (e.g. "post").', 'wp-powersuite' );
		}

		return $errors;
	}
}
