<?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' );
		}

		// 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['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.
	 * @return array<string, mixed>
	 */
	public function sanitizeArgs( array $args ): 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 class name (allow namespaced PHP class).
		$controller = (string) ( $args['rest_controller_class'] ?? '' );
		$controller = preg_replace( '/[^A-Za-z0-9_\\\\]/', '', $controller );
		$clean['rest_controller_class'] = (string) $controller;

		// Capability type (singular + optional plural).
		$clean['capability_type']        = $this->sanitizeCapabilityType( (string) ( $args['capability_type'] ?? 'post' ) );
		$clean['capability_type_plural'] = $this->sanitizeCapabilityType( (string) ( $args['capability_type_plural'] ?? '' ) );

		// 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;
	}

	/**
	 * 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 $icon;
		}

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

		return sanitize_text_field( $icon );
	}

	/**
	 * 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'] : [];

		foreach ( [ 'rewrite_slug', 'has_archive_slug', 'query_var_slug' ] as $slug_arg ) {
			$slug = (string) ( $args[ $slug_arg ] ?? '' );
			if ( $slug !== '' && 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
				);
			}
		}

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

		return $errors;
	}
}
