<?php
/**
 * Custom Fields post-type meta boxes.
 *
 * @package CustomFields
 * @since 1.0.0
 */

namespace CustomFields;

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

/**
 * Handles post-type meta boxes for custom field groups.
 */
final class MetaBoxes {

	private const NONCE_ACTION = 'wpps_cf_save_meta';
	private const NONCE_NAME   = 'wpps_cf_meta_nonce';

	private static bool $nonce_printed = false;

	private Locations $locations;

	private Fields $fields;

	private Storage $storage;

	private Relationships $relationships;

	public function __construct( Locations $locations, Fields $fields, Storage $storage ) {
		$this->locations     = $locations;
		$this->fields        = $fields;
		$this->storage       = $storage;
		$this->relationships = new Relationships();
	}

	/**
	 * Register a meta box per applicable group on the current post.
	 *
	 * @param string        $post_type Current post type.
	 * @param \WP_Post|null $post      Post being edited.
	 */
	public function register( string $post_type, ?\WP_Post $post = null ): void {
		$post_id = $post instanceof \WP_Post ? (int) $post->ID : 0;
		$groups  = $this->locations->groupsForPost( $post_id, $post_type );
		$screen  = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
		$screen_id = $screen instanceof \WP_Screen ? $screen->id : $post_type;

		foreach ( $groups as $key => $group ) {
			$fields = $this->fieldsForMetaBox( $group, $post_type );
			if ( $fields === [] ) {
				continue;
			}

			$position = (string) ( $group['position'] ?? 'normal' );
			$context  = Config::resolveMetaBoxContext( $position );
			$priority = Config::resolveMetaBoxPriority( $context, (string) ( $group['priority'] ?? 'default' ) );
			$box_id   = 'wpps-cf-' . sanitize_key( (string) $key );

			add_filter(
				'postbox_classes_' . $screen_id . '_' . $box_id,
				static function ( array $classes ) use ( $group ): array {
					$classes[] = 'cf-postbox';
					if ( (string) ( $group['style'] ?? 'default' ) === 'seamless' ) {
						$classes[] = 'cf-postbox--seamless';
					}
					if ( (string) ( $group['position'] ?? 'normal' ) === 'after_title' ) {
						$classes[] = 'cf-postbox--after-title';
					}

					return $classes;
				}
			);

			add_meta_box(
				$box_id,
				(string) ( $group['title'] ?? __( 'Custom Fields', 'wppowersuite-lite' ) ),
				function ( $inner_post ) use ( $group, $fields, $position ): void {
					$this->renderBox( $group, (int) $inner_post->ID, $fields, $position );
				},
				$post_type,
				$context,
				$priority,
				[
					'__block_editor_compatible_meta_box' => true,
				]
			);
		}
	}

	/**
	 * Fields that should render in the classic meta box for a group.
	 *
	 * @param array<string, mixed> $group     Group definition.
	 * @param string               $post_type Post type key.
	 * @return array<int, array<string, mixed>>
	 */
	private function fieldsForMetaBox( array $group, string $post_type ): array {
		return EditorSchema::fieldsForMetaBox( $group, $post_type );
	}

	/**
	 * @param array<string, mixed>             $group    Group definition.
	 * @param int                              $post_id  Post ID.
	 * @param array<int, array<string, mixed>> $fields   Fields to render.
	 * @param string                           $position Stored position key.
	 */
	private function renderBox( array $group, int $post_id, array $fields, string $position ): void {
		if ( ! self::$nonce_printed ) {
			wp_nonce_field( self::NONCE_ACTION, self::NONCE_NAME );
			self::$nonce_printed = true;
		}

		$group_key  = (string) ( $group['key'] ?? '' );
		$layout_mod = $position === 'side' ? 'side' : 'normal';
		$rows       = Config::packFieldRows( $fields );

		echo '<div class="cf-meta-box cf-meta-box--grid cf-meta-box--' . esc_attr( $layout_mod ) . '">';

		foreach ( $rows as $row ) {
			echo '<div class="cf-meta-box__row">';
			foreach ( $row as $field ) {
				$type     = (string) ( $field['type'] ?? 'text' );
				$meta_key = MetaKeys::resolve( $group_key, $field );
				$field_id = (string) ( $field['key'] ?? $meta_key );
				$id_attr  = 'wpps-cf-' . sanitize_key( $group_key ) . '-' . sanitize_key( $field_id );
				$width_cls = Config::fieldWidthClass( $field );

				if ( Config::storesValue( $type ) ) {
					if ( $meta_key === '' ) {
						continue;
					}
					$value = $this->readMeta( $post_id, $meta_key, $field );
					$name  = 'wpps_cf[' . $meta_key . ']';
				} else {
					$value = '';
					$name  = '';
				}

				echo '<div class="cf-meta-box__cell ' . esc_attr( $width_cls ) . '">';
				$this->fields->render( $field, $name, $value, $id_attr, $group );
				echo '</div>';
			}
			echo '</div>';
		}

		echo '</div>';
	}

	/**
	 * @param int                  $post_id  Post ID.
	 * @param string               $meta_key Meta key.
	 * @param array<string, mixed> $field    Field definition.
	 * @return mixed
	 */
	private function readMeta( int $post_id, string $meta_key, array $field ): mixed {
		$type     = (string) ( $field['type'] ?? 'text' );
		$is_multi = $this->isMultiValueField( $type, $field );

		if ( $type === 'term' && ! empty( $field['taxonomy_load_terms'] ) ) {
			$loaded = $this->loadPostTermValue( $post_id, $field, $is_multi );
			if ( $loaded !== null ) {
				return $loaded;
			}
		}

		$stored   = get_post_meta( $post_id, $meta_key, true );

		if ( $stored === '' || $stored === null ) {
			if ( $type === 'link' ) {
				return [ 'url' => '', 'title' => '', 'target' => '' ];
			}
			if ( $type === 'group' ) {
				return [];
			}
			if ( $type === 'google_map' ) {
				return [ 'address' => '', 'lat' => '', 'lng' => '', 'zoom' => 14 ];
			}
			return $is_multi ? $this->multiDefaultValue( $field ) : ( $field['default'] ?? '' );
		}

		if ( $type === 'gallery' && ! is_array( $stored ) ) {
			return array_filter( array_map( 'absint', explode( ',', (string) $stored ) ) );
		}

		return $stored;
	}

	/**
	 * @param string               $type  Field type.
	 * @param array<string, mixed> $field Field definition.
	 */
	private function isMultiValueField( string $type, array $field ): bool {
		if ( in_array( $type, [ 'checkbox', 'gallery', 'relationship' ], true ) ) {
			return true;
		}
		if ( $type === 'term' ) {
			$taxonomy_field_type = sanitize_key( (string) ( $field['taxonomy_field_type'] ?? 'checkbox' ) );
			if ( in_array( $taxonomy_field_type, [ 'multi_select', 'checkbox' ], true ) ) {
				return true;
			}
		}

		return ! empty( $field['multiple'] ) && in_array( $type, [ 'select', 'post_object', 'relationship', 'page_link', 'term', 'user' ], true );
	}

	/**
	 * @param array<string, mixed> $field Field definition.
	 * @return array<int, string>
	 */
	private function multiDefaultValue( array $field ): array {
		$default = $field['default'] ?? '';
		$values  = is_array( $default ) ? $default : preg_split( '/\r\n|\r|\n|,/', (string) $default );
		$out     = [];
		foreach ( (array) $values as $value ) {
			$value = trim( (string) $value );
			if ( $value !== '' ) {
				$out[] = $value;
			}
		}

		return array_values( array_unique( $out ) );
	}

	/**
	 * @param int $post_id Post ID being saved.
	 */
	public function save( int $post_id ): void {
		if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
			return;
		}

		if ( ! isset( $_POST[ self::NONCE_NAME ] ) ) {
			return;
		}
		$nonce = sanitize_text_field( wp_unslash( (string) $_POST[ self::NONCE_NAME ] ) );
		if ( ! wp_verify_nonce( $nonce, self::NONCE_ACTION ) ) {
			return;
		}

		$post_type = get_post_type( $post_id );
		if ( ! is_string( $post_type ) || $post_type === '' ) {
			return;
		}

		$post_type_object = get_post_type_object( $post_type );
		$capability       = $post_type_object->cap->edit_post ?? 'edit_post';
		if ( ! current_user_can( $capability, $post_id ) ) {
			return;
		}

		$groups = $this->locations->groupsForPost( $post_id );
		if ( $groups === [] ) {
			return;
		}

		// phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified above.
		$submitted = isset( $_POST['wpps_cf'] ) && is_array( $_POST['wpps_cf'] ) ? wp_unslash( $_POST['wpps_cf'] ) : [];

		$pending = $this->collectPendingValues( $groups, $submitted, $post_id, $post_type );
		if ( $pending['errors'] !== [] ) {
			set_transient(
				'wpps_cf_save_errors_' . get_current_user_id(),
				$pending['errors'],
				60
			);
			return;
		}

		foreach ( $pending['groups'] as $group_key => $group_data ) {
			$group = $group_data['group'];
			foreach ( $group_data['values'] as $meta_key => $value ) {
				if ( $this->fields->isEmpty( $value ) ) {
					delete_post_meta( $post_id, $meta_key );
				} else {
					update_post_meta( $post_id, $meta_key, $value );
				}
			}

			$this->syncTaxonomyTerms( $post_id, $group_data['term_sync'] );
			$this->relationships->syncPost( $post_id, $group, $group_data['relationships'] );
			$this->persistCustomChoices( (string) $group_key, $group_data['choice_updates'] );
		}
	}

	/**
	 * Fields persisted through the classic meta box save handler.
	 *
	 * @param array<string, mixed> $group     Group definition.
	 * @param string               $post_type Post type key.
	 * @return array<int, array<string, mixed>>
	 */
	private function fieldsToSave( array $group, string $post_type ): array {
		return EditorSchema::fieldsForMetaBox( $group, $post_type );
	}

	/**
	 * Validate and prepare all submitted values before writing meta.
	 *
	 * @param array<string, array<string, mixed>> $groups     Matching groups.
	 * @param array<string, mixed>                $submitted  Submitted values.
	 * @param int                                 $post_id    Post ID.
	 * @param string                              $post_type  Post type key.
	 * @return array{errors: array<int, string>, groups: array<string, array<string, mixed>>}
	 */
	private function collectPendingValues( array $groups, array $submitted, int $post_id, string $post_type ): array {
		$errors = [];
		$out    = [];

		foreach ( $groups as $group_key => $group ) {
			$fields        = $this->fieldsToSave( $group, $post_type );
			$values        = [];
			$relationships = [];
			$term_sync     = [];
			$choice_updates = [];
			$conditional_values = $this->submittedFieldValuesForConditionals( $fields, $submitted, (string) $group_key, $group, $post_id );

			foreach ( $fields as $field ) {
				if ( ! is_array( $field ) ) {
					continue;
				}

				$type = (string) ( $field['type'] ?? 'text' );
				if ( ! Config::storesValue( $type ) ) {
					continue;
				}

				$meta_key = MetaKeys::resolve( (string) $group_key, $field );
				if ( $meta_key === '' ) {
					continue;
				}

				$old_raw = get_post_meta( $post_id, $meta_key, true );
				$raw     = $submitted[ $meta_key ] ?? ( $type === 'toggle' ? '' : null );
				$is_visible = $this->fieldIsConditionallyVisible( $field, $conditional_values );

				if ( ! $is_visible ) {
					continue;
				}

				if ( $this->fields->preservesValueWhenEmpty( $field ) && ( $raw === '' || $raw === null ) ) {
					if ( ! empty( $field['required'] ) && $this->fields->isEmpty( $old_raw ) ) {
						$errors[] = sprintf(
							/* translators: %s: field label. */
							__( '“%s” is required.', 'wppowersuite-lite' ),
							(string) ( $field['label'] ?? $meta_key )
						);
					}
					continue;
				}

				$value = $this->fields->sanitizeValue( $field, $raw, $group );

				if ( ! empty( $field['required'] ) && $this->fields->isEmpty( $value ) ) {
					$errors[] = sprintf(
						/* translators: %s: field label. */
						__( '“%s” is required.', 'wppowersuite-lite' ),
						(string) ( $field['label'] ?? $meta_key )
					);
					continue;
				}

				if ( ! $this->fields->isEmpty( $value ) && ! $this->fields->matchesPattern( $field, $value ) ) {
					$errors[] = sprintf(
						/* translators: %s: field label. */
						__( '“%s” has an invalid format.', 'wppowersuite-lite' ),
						(string) ( $field['label'] ?? $meta_key )
					);
					continue;
				}

				$value_errors = $this->fields->validateValue( $field, $value, $group );
				if ( $value_errors !== [] ) {
					array_push( $errors, ...$value_errors );
					continue;
				}

				$values[ $meta_key ]                   = $value;
				$relationships[ $meta_key ] = [ 'old' => $old_raw, 'new' => $value ];
				$this->mergeChoiceUpdates( $choice_updates, $this->fields->customChoiceUpdatesToSave( $field, $value ) );
				if ( $type === 'term' && ! empty( $field['taxonomy_save_terms'] ) ) {
					$taxonomy = sanitize_key( (string) ( $field['taxonomy'] ?? '' ) );
					if ( $taxonomy !== '' && taxonomy_exists( $taxonomy ) ) {
						$term_sync[ $taxonomy ] = $this->termIdsFromValue( $value );
					}
				}
			}

			$out[ (string) $group_key ] = [
				'group'         => $group,
				'values'        => $values,
				'relationships' => $relationships,
				'term_sync'     => $term_sync,
				'choice_updates' => $choice_updates,
			];
		}

		return [
			'errors' => $errors,
			'groups' => $out,
		];
	}

	/**
	 * Build a field-key keyed value map for save-time conditional checks.
	 *
	 * @param array<int, array<string, mixed>> $fields    Field definitions.
	 * @param array<string, mixed>             $submitted Submitted values keyed by meta key.
	 * @param string                           $group_key Field group key.
	 * @param array<string, mixed>             $group     Field group definition.
	 * @return array<string, mixed>
	 */
	private function submittedFieldValuesForConditionals( array $fields, array $submitted, string $group_key, array $group, int $post_id ): array {
		$values = [];

		foreach ( $fields as $field ) {
			if ( ! is_array( $field ) ) {
				continue;
			}

			$type = (string) ( $field['type'] ?? 'text' );
			if ( ! Config::storesValue( $type ) ) {
				continue;
			}

			$field_key = (string) ( $field['key'] ?? '' );
			$meta_key  = MetaKeys::resolve( $group_key, $field );
			if ( $field_key === '' || $meta_key === '' ) {
				continue;
			}

			$raw = array_key_exists( $meta_key, $submitted )
				? $submitted[ $meta_key ]
				: get_post_meta( $post_id, $meta_key, true );
			if ( $raw === '' && $type === 'toggle' ) {
				$raw = '';
			}

			$values[ $field_key ] = $this->fields->sanitizeValue( $field, $raw, $group );
		}

		return $values;
	}

	/**
	 * Whether a field should enforce validation for the submitted value set.
	 *
	 * @param array<string, mixed> $field  Field definition.
	 * @param array<string, mixed> $values Current submitted values keyed by field key.
	 */
	private function fieldIsConditionallyVisible( array $field, array $values ): bool {
		$conditional = isset( $field['conditional'] ) && is_array( $field['conditional'] ) ? $field['conditional'] : [];

		return RuleEngine::fieldVisible( $conditional, $values );
	}

	/**
	 * Append saved custom choice values to a field group's top-level choice field definitions.
	 *
	 * @param array<string, array<int, string>> $updates Custom values keyed by field key.
	 */
	private function persistCustomChoices( string $group_key, array $updates ): void {
		if ( $updates === [] ) {
			return;
		}

		$group = $this->storage->get( $group_key );
		if ( ! is_array( $group ) || ! isset( $group['fields'] ) || ! is_array( $group['fields'] ) ) {
			return;
		}

		$changed = false;
		$group['fields'] = $this->fields->fieldsWithCustomChoiceValues( $group['fields'], $updates, $changed );

		if ( $changed ) {
			$this->storage->save( $group_key, $group );
		}
	}

	/**
	 * @param array<string, array<int, string>> $target Existing updates.
	 * @param array<string, array<int, string>> $source Additional updates.
	 */
	private function mergeChoiceUpdates( array &$target, array $source ): void {
		foreach ( $source as $field_key => $values ) {
			$target[ $field_key ] = array_values(
				array_unique(
					array_merge( $target[ $field_key ] ?? [], $values )
				)
			);
		}
	}

	/**
	 * Read a term field from the post's assigned terms when load_terms is enabled.
	 *
	 * @return mixed|null Null when the field is not configured well enough to load.
	 */
	private function loadPostTermValue( int $post_id, array $field, bool $is_multi ): mixed {
		$taxonomy = sanitize_key( (string) ( $field['taxonomy'] ?? '' ) );
		if ( $taxonomy === '' || ! taxonomy_exists( $taxonomy ) ) {
			return null;
		}

		$terms = wp_get_object_terms( $post_id, $taxonomy, [ 'fields' => 'ids' ] );
		if ( is_wp_error( $terms ) || ! is_array( $terms ) ) {
			return null;
		}

		$ids = array_values( array_filter( array_map( 'absint', $terms ) ) );

		return $is_multi ? $ids : ( $ids[0] ?? '' );
	}

	/**
	 * @param array<string, array<int, int>> $term_sync Term IDs grouped by taxonomy.
	 */
	private function syncTaxonomyTerms( int $post_id, array $term_sync ): void {
		foreach ( $term_sync as $taxonomy => $term_ids ) {
			$tax_object = get_taxonomy( (string) $taxonomy );
			if ( ! $tax_object instanceof \WP_Taxonomy ) {
				continue;
			}
			$capability = $tax_object->cap->assign_terms ?? 'assign_terms';
			if ( ! current_user_can( $capability ) ) {
				continue;
			}

			wp_set_object_terms( $post_id, array_values( array_filter( array_map( 'absint', $term_ids ) ) ), (string) $taxonomy, false );
		}
	}

	/**
	 * @return array<int, int>
	 */
	private function termIdsFromValue( mixed $value ): array {
		if ( is_array( $value ) ) {
			return array_values( array_filter( array_map( 'absint', $value ) ) );
		}

		$term_id = absint( $value );

		return $term_id > 0 ? [ $term_id ] : [];
	}

	/**
	 * Show validation errors from the last save attempt.
	 */
	public function showSaveErrors(): void {
		$screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
		if ( ! $screen || $screen->base !== 'post' ) {
			return;
		}

		$errors = get_transient( 'wpps_cf_save_errors_' . get_current_user_id() );
		if ( ! is_array( $errors ) || $errors === [] ) {
			return;
		}

		delete_transient( 'wpps_cf_save_errors_' . get_current_user_id() );

		echo '<div class="notice notice-error"><p><strong>' . esc_html__( 'Custom Fields could not save:', 'wppowersuite-lite' ) . '</strong></p><ul>';
		foreach ( $errors as $error ) {
			echo '<li>' . esc_html( (string) $error ) . '</li>';
		}
		echo '</ul><p>' . esc_html__( 'No field values were saved. Fix the errors above and try again.', 'wppowersuite-lite' ) . '</p></div>';
	}
}
