<?php
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Custom fields for content, without depending on an external plugin.
 *
 * WHAT IT SOLVES. The service already teaches the model to write `{{t:post.acf_prezzo}}` — it is in
 * the single-item and archive chapters — but in the engine that branch only fires if `get_field()`
 * exists. Without ACF installed the placeholder resolves to an EMPTY string: no error, no warning,
 * the product page comes out with no price and you find out by looking at it. It is the silent
 * fault at the most visible point of the product.
 *
 * HOW. It is not a clone of ACF and does not try to be: ten field types, the ones the engine
 * already knows how to render, and no flexible content. Whoever needs more installs ACF and we read
 * it all the same.
 *
 * THE CHOICE THAT HOLDS IT TOGETHER: values are saved as **ordinary post meta, under the field's own
 * key**. That is exactly where ACF puts them for simple fields, and everything else follows from it
 * at no cost:
 *
 *  - a site that already has ACF keeps working identically, and we stay out of the way;
 *  - if the customer installs ACF tomorrow, the data is already where ACF looks for it;
 *  - if they uninstall Aura, the content stays: no hostages;
 *  - `{{t:post.acf_prezzo}}` works in both cases, so the templates do not change by a line.
 *
 * The boundary, stated and not hidden: on SIMPLE fields ACF and we write the same meta and are
 * interchangeable. ACF encodes repeaters and galleries its own way, and there the two worlds cannot
 * read each other.
 */
class AIPB_Fields {

	/** The field definitions: { post_type: [ { key, type, label } ] }. */
	const OPTION = 'aipb_fields';

	/**
	 * The TERM fields: { taxonomy: [ { key, type, label } ] }.
	 *
	 * A separate option, not a key inside the other one: a taxonomy slug and a post type slug can
	 * coincide — "categoria" is an entirely normal case — and in a single map one would eat the
	 * other without anybody noticing until a field disappeared.
	 *
	 * It exists because the DSL already teaches `{{i:term.acf_copertina}}` for a category's photo
	 * and the engine can read it (see value(), with the $is_term parameter). Without this half that
	 * placeholder only worked where real ACF was installed: elsewhere it printed nothing and there
	 * was nowhere to fill it in — the usual silent fault, one level down.
	 */
	const TERM_OPTION = 'aipb_term_fields';

	/**
	 * The field types, with their readable names.
	 *
	 * There are ten and there stay ten: each has to match something the engine can already print
	 * and the panel can already draw. Chasing ACF on the number of types would mean writing thirty
	 * editors for fields the model will never use.
	 *
	 * A method and not a constant because the names are shown to the user, and a constant cannot be
	 * translated.
	 *
	 * @return array<string,string>
	 */
	public static function types() {
		return array(
			'text'     => __( 'Text', 'aipb' ),
			'textarea' => __( 'Long text', 'aipb' ),
			'richtext' => __( 'Formatted text', 'aipb' ),
			'number'   => __( 'Number', 'aipb' ),
			'url'      => __( 'Address (URL)', 'aipb' ),
			'image'    => __( 'Image', 'aipb' ),
			'color'    => __( 'Colour', 'aipb' ),
			'date'     => __( 'Date', 'aipb' ),
			'email'    => __( 'Email', 'aipb' ),
			'bool'     => __( 'Yes / No', 'aipb' ),
		);
	}

	/** Is this a type we know? */
	public static function is_type( $type ) {
		return array_key_exists( (string) $type, self::types() );
	}

	/** A type's readable name, falling back to the raw type. */
	public static function type_label( $type ) {
		$types = self::types();

		return isset( $types[ $type ] ) ? $types[ $type ] : (string) $type;
	}

	public static function init() {
		add_action( 'add_meta_boxes', array( __CLASS__, 'add_box' ) );
		add_action( 'save_post', array( __CLASS__, 'save_post_fields' ), 10, 2 );

		// Terms have no meta boxes: every taxonomy has its own hooks, with its slug inside the
		// name. They are attached by reading the option, which does not need the taxonomy to be
		// registered yet — and here, on plugins_loaded, it is not.
		foreach ( array_keys( self::all_terms() ) as $taxonomy ) {
			add_action( $taxonomy . '_add_form_fields', array( __CLASS__, 'render_term_add' ) );
			add_action( $taxonomy . '_edit_form_fields', array( __CLASS__, 'render_term_edit' ) );
			add_action( 'created_' . $taxonomy, array( __CLASS__, 'save_term_fields' ) );
			add_action( 'edited_' . $taxonomy, array( __CLASS__, 'save_term_fields' ) );
		}
	}

	/* -------------------------------- Definitions ------------------------------ */

	/** @return array { post_type => list<array{key,type,label}> } */
	public static function all() {
		$definition = get_option( self::OPTION, array() );

		return is_array( $definition ) ? $definition : array();
	}

	/** @return list<array{key:string,type:string,label:string}> */
	public static function for_type( $post_type ) {
		$all  = self::all();
		$slug = sanitize_key( $post_type );

		return isset( $all[ $slug ] ) && is_array( $all[ $slug ] ) ? $all[ $slug ] : array();
	}

	/** @return array { taxonomy => list<array{key,type,label}> } */
	public static function all_terms() {
		$definition = get_option( self::TERM_OPTION, array() );

		return is_array( $definition ) ? $definition : array();
	}

	/** @return list<array{key:string,type:string,label:string}> */
	public static function for_taxonomy( $taxonomy ) {
		$all  = self::all_terms();
		$slug = sanitize_key( $taxonomy );

		return isset( $all[ $slug ] ) && is_array( $all[ $slug ] ) ? $all[ $slug ] : array();
	}

	/** A field's type, or '' when that field is not declared for that content. */
	public static function type_of( $post_type, $key ) {
		foreach ( self::for_type( $post_type ) as $field ) {
			if ( $field['key'] === $key ) {
				return $field['type'];
			}
		}

		return '';
	}

	/** Is there at least one field declared anywhere? Used to avoid showing empty pages. */
	public static function any() {
		foreach ( self::all() as $fields ) {
			if ( $fields ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Saves the definition, cleaned up.
	 *
	 * The keys are the delicate part: they are also the meta names, so renaming one DETACHES the
	 * values already written. Here things can only be added and removed; a rename, when it is
	 * needed, will have to move the meta and must be done deliberately, not in passing from a text
	 * field.
	 *
	 * @param array $raw { post_type => list<array{key,type,label}> }
	 */
	public static function save_all( $raw ) {
		return self::store( self::OPTION, $raw );
	}

	/**
	 * Like save_all(), but for TERM fields.
	 *
	 * Same rules and same traps, on a different option, because a taxonomy slug and a post type
	 * slug can share a name.
	 *
	 * @param array $raw { taxonomy => list<array{key,type,label}> }
	 */
	public static function save_all_terms( $raw ) {
		return self::store( self::TERM_OPTION, $raw );
	}

	/**
	 * A valid key: lowercase, digits and underscores.
	 *
	 * Underscores at the edges are stripped, and the LEADING one is the reason this function
	 * exists: in WordPress a meta starting with `_` is hidden everywhere, so `_prezzo` would be a
	 * field the user fills in and never finds again. Better to save it as `prezzo` — the row stays
	 * visible in the editor after saving, so the correction is visible — than to accept it and let
	 * it vanish.
	 */
	public static function clean_key( $key ) {
		$key = strtolower( trim( (string) $key ) );
		$key = preg_replace( '/[^a-z0-9_]/', '_', $key );
		$key = preg_replace( '/_+/', '_', (string) $key );
		$key = trim( (string) $key, '_' );

		return ( '' === $key || is_numeric( $key[0] ) ) ? '' : $key;
	}

	/* --------------------------------- Reading --------------------------------- */

	/**
	 * A custom field's value, wherever it comes from.
	 *
	 * Order: ACF if it is there and has something to say, otherwise the meta. It is not an
	 * aesthetic preference — it is what lets a site that has used ACF for years not notice us, and
	 * a site without ACF work anyway.
	 *
	 * @param int    $id      the content's id (or the term's, with $is_term)
	 * @param string $key     the field name, without a prefix
	 * @param bool   $is_term the owner is a taxonomy term
	 */
	public static function value( $id, $key, $is_term = false ) {
		$id  = (int) $id;
		$key = (string) $key;

		if ( ! $id || '' === $key ) {
			return '';
		}

		if ( function_exists( 'get_field' ) ) {
			// For ACF a term is addressed as "term_<id>", not as a bare id.
			$value = get_field( $key, $is_term ? 'term_' . $id : $id );
			if ( null !== $value && false !== $value && '' !== $value ) {
				return $value;
			}
		}

		$value = $is_term ? get_term_meta( $id, $key, true ) : get_post_meta( $id, $key, true );

		return ( null === $value ) ? '' : $value;
	}

	/* ------------------------------ The post's box ----------------------------- */

	public static function add_box() {
		foreach ( self::all() as $post_type => $fields ) {
			if ( ! $fields || ! post_type_exists( $post_type ) ) {
				continue;
			}

			add_meta_box(
				'aipb-fields',
				__( 'Content fields', 'aipb' ),
				array( __CLASS__, 'render_box' ),
				$post_type,
				'normal',
				'high'
			);
		}
	}

	public static function render_box( $post ) {
		$fields = self::for_type( $post->post_type );
		if ( ! $fields ) {
			return;
		}

		wp_nonce_field( 'aipb_fields_' . $post->ID, 'aipb_fields_nonce' );

		echo '<p class="description">'
			. esc_html__( 'These fields print in the template with post.acf_name (and inside loops with field.acf_name).', 'aipb' )
			. '</p>';
		echo '<table class="form-table" role="presentation"><tbody>';

		foreach ( $fields as $field ) {
			$id = 'aipb-f-' . esc_attr( $field['key'] );

			echo '<tr><th scope="row"><label for="' . esc_attr( $id ) . '">' . esc_html( $field['label'] ) . '</label></th><td>';
			self::render_input( $field, 'aipb_fields[' . esc_attr( $field['key'] ) . ']', $id, get_post_meta( $post->ID, $field['key'], true ) );
			echo '<p class="description"><code>' . esc_html( 'post.acf_' . $field['key'] ) . '</code></p>';
			echo '</td></tr>';
		}

		echo '</tbody></table>';
	}

	/**
	 * Saving.
	 *
	 * The four obligatory checks (autosave, nonce, capability, type) all live here: a box that
	 * saves without them is a way of letting anybody passing by write the meta.
	 */
	public static function save_post_fields( $post_id, $post ) {
		if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
			return;
		}
		if ( ! isset( $_POST['aipb_fields_nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['aipb_fields_nonce'] ) ), 'aipb_fields_' . $post_id ) ) {
			return;
		}
		if ( ! current_user_can( 'edit_post', $post_id ) ) {
			return;
		}

		$fields = self::for_type( $post->post_type );
		if ( ! $fields ) {
			return;
		}

		// phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified just above.
		$sent = isset( $_POST['aipb_fields'] ) && is_array( $_POST['aipb_fields'] ) ? wp_unslash( $_POST['aipb_fields'] ) : array();

		foreach ( $fields as $field ) {
			$value = self::sanitize_value( $field['type'], isset( $sent[ $field['key'] ] ) ? $sent[ $field['key'] ] : '' );

			// A field that was emptied is DELETED rather than left as an empty string: that way
			// {{#if post.acf_prezzo}} goes back to being false, which is what the user asked for.
			if ( '' === $value ) {
				delete_post_meta( $post_id, $field['key'] );
			} else {
				update_post_meta( $post_id, $field['key'], $value );
			}
		}
	}

	/* ----------------------------- Fields on a term ---------------------------- */

	/**
	 * The "add a term" form: a narrow left column, one block per field.
	 *
	 * WordPress uses two different screens for the same thing — creation has the fields in a
	 * column, editing in a table — and wants different markup. Two short functions rather than one
	 * with a switch: the switch would have hidden that these are two different pages.
	 */
	public static function render_term_add( $taxonomy ) {
		$fields = self::for_taxonomy( $taxonomy );
		if ( ! $fields ) {
			return;
		}

		wp_nonce_field( 'aipb_term_fields', 'aipb_term_fields_nonce' );

		foreach ( $fields as $field ) {
			$id = 'aipb-tf-' . esc_attr( $field['key'] );

			echo '<div class="form-field">';
			echo '<label for="' . esc_attr( $id ) . '">' . esc_html( $field['label'] ) . '</label>';
			self::render_input( $field, 'aipb_term_fields[' . esc_attr( $field['key'] ) . ']', $id, '' );
			echo '<p class="description"><code>' . esc_html( 'term.acf_' . $field['key'] ) . '</code></p>';
			echo '</div>';
		}
	}

	/** The "edit the term" form: table rows, with the values already in them. */
	public static function render_term_edit( $term ) {
		$fields = self::for_taxonomy( $term->taxonomy );
		if ( ! $fields ) {
			return;
		}

		wp_nonce_field( 'aipb_term_fields', 'aipb_term_fields_nonce' );

		foreach ( $fields as $field ) {
			$id = 'aipb-tf-' . esc_attr( $field['key'] );

			echo '<tr class="form-field"><th scope="row"><label for="' . esc_attr( $id ) . '">'
				. esc_html( $field['label'] ) . '</label></th><td>';
			self::render_input( $field, 'aipb_term_fields[' . esc_attr( $field['key'] ) . ']', $id, get_term_meta( $term->term_id, $field['key'], true ) );
			echo '<p class="description"><code>' . esc_html( 'term.acf_' . $field['key'] ) . '</code></p>';
			echo '</td></tr>';
		}
	}

	/**
	 * Saving a term's fields.
	 *
	 * It covers both "created" and "edited", which both pass only the id. The capability asked for
	 * is `edit_term` and not a generic one: whoever can edit a term can edit its fields, and nobody
	 * else.
	 */
	public static function save_term_fields( $term_id ) {
		if ( ! isset( $_POST['aipb_term_fields_nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['aipb_term_fields_nonce'] ) ), 'aipb_term_fields' ) ) {
			return;
		}
		if ( ! current_user_can( 'edit_term', $term_id ) ) {
			return;
		}

		$term = get_term( $term_id );
		if ( ! $term || is_wp_error( $term ) ) {
			return;
		}

		$fields = self::for_taxonomy( $term->taxonomy );
		if ( ! $fields ) {
			return;
		}

		// phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified just above.
		$sent = isset( $_POST['aipb_term_fields'] ) && is_array( $_POST['aipb_term_fields'] ) ? wp_unslash( $_POST['aipb_term_fields'] ) : array();

		foreach ( $fields as $field ) {
			$value = self::sanitize_value( $field['type'], isset( $sent[ $field['key'] ] ) ? $sent[ $field['key'] ] : '' );

			// As on content: emptying DELETES, so {{#if term.acf_copertina}} goes back to false
			// instead of staying true on an empty string.
			if ( '' === $value ) {
				delete_term_meta( $term_id, $field['key'] );
			} else {
				update_term_meta( $term_id, $field['key'], $value );
			}
		}
	}

	/** Every type is cleaned as it deserves: a URL is not text, a colour is not a URL. */
	public static function sanitize_value( $type, $raw ) {
		if ( is_array( $raw ) ) {
			return '';
		}

		$raw = (string) $raw;

		switch ( $type ) {
			case 'textarea':
				return sanitize_textarea_field( $raw );

			case 'richtext':
				return wp_kses_post( $raw );

			case 'number':
				return self::sanitize_number( $raw );

			case 'url':
			case 'image':
				return esc_url_raw( $raw );

			case 'email':
				// The tags have to go FIRST: sanitize_email() removes disallowed characters but
				// keeps the letters, so from "<b>a@b.it</b>" it produces "ba@b.itb" — which is even
				// formally valid, and would pass is_email() too.
				$mail = sanitize_email( wp_strip_all_tags( $raw ) );

				return is_email( $mail ) ? $mail : '';

			case 'color':
				$hex = sanitize_hex_color( $raw );

				return $hex ? $hex : '';

			case 'date':
				return preg_match( '/^\d{4}-\d{2}-\d{2}$/', trim( $raw ) ) ? trim( $raw ) : '';

			case 'bool':
				return '1' === $raw ? '1' : '';

			default:
				return sanitize_text_field( $raw );
		}
	}

	/* ---------------------------------- Helpers -------------------------------- */

	/**
	 * The shared cleaning and saving for both definitions.
	 *
	 * Content fields and term fields differ only in which option they live in: keeping two copies
	 * of the same loop means the day a rule changes it changes in one of them.
	 */
	protected static function store( $option, $raw ) {
		$out = array();

		foreach ( (array) $raw as $owner => $fields ) {
			$owner = sanitize_key( $owner );
			if ( '' === $owner || ! is_array( $fields ) ) {
				continue;
			}

			$clean = self::clean_fields( $fields );
			if ( $clean ) {
				$out[ $owner ] = $clean;
			}
		}

		update_option( $option, $out, false );

		// The inventory that goes to the service contains the fields: if they change, the next
		// generation's prompt has to know straight away, not in five minutes.
		if ( class_exists( 'AIPB_Site_Context' ) ) {
			AIPB_Site_Context::flush();
		}

		return $out;
	}

	/** @return list<array{key:string,type:string,label:string}> */
	protected static function clean_fields( $fields ) {
		$out  = array();
		$seen = array();

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

			$key = self::clean_key( isset( $field['key'] ) ? (string) $field['key'] : '' );
			if ( '' === $key || isset( $seen[ $key ] ) ) {
				continue;
			}
			$seen[ $key ] = true;

			$type = isset( $field['type'] ) ? sanitize_key( (string) $field['type'] ) : 'text';

			$out[] = array(
				'key'   => $key,
				'type'  => self::is_type( $type ) ? $type : 'text',
				'label' => ( isset( $field['label'] ) && '' !== trim( (string) $field['label'] ) )
					? sanitize_text_field( (string) $field['label'] )
					: ucfirst( str_replace( '_', ' ', $key ) ),
			);
		}

		return $out;
	}

	/**
	 * A number, accepting the comma as a decimal separator.
	 *
	 * "24,50" is how a price is written in Italian, and (float) would truncate it to 24, losing the
	 * cents silently. The comma becomes a dot only when there is no dot already: "1.234,50" stays a
	 * case to be written without a thousands separator.
	 */
	protected static function sanitize_number( $raw ) {
		$number = trim( $raw );
		if ( '' === $number ) {
			return '';
		}

		if ( false === strpos( $number, '.' ) ) {
			$number = str_replace( ',', '.', $number );
		}

		return is_numeric( $number ) ? (string) ( 0 + (float) $number ) : '';
	}

	/** The right input for the type: these are WordPress's own controls, not invented ones. */
	protected static function render_input( $field, $name, $id, $value ) {
		$value = is_array( $value ) ? '' : (string) $value;

		switch ( $field['type'] ) {
			case 'textarea':
				printf(
					'<textarea id="%s" name="%s" rows="4" class="large-text">%s</textarea>',
					esc_attr( $id ),
					esc_attr( $name ),
					esc_textarea( $value )
				);
				break;

			case 'richtext':
				wp_editor( $value, str_replace( array( '[', ']' ), '_', $id ), array(
					'textarea_name' => $name,
					'textarea_rows' => 8,
					'media_buttons' => false,
				) );
				break;

			case 'bool':
				printf(
					'<label><input type="checkbox" id="%s" name="%s" value="1"%s> %s</label>',
					esc_attr( $id ),
					esc_attr( $name ),
					checked( '1', $value, false ),
					esc_html__( 'Yes', 'aipb' )
				);
				break;

			case 'image':
				// The image is kept as a URL, the same convention as a section's fields: from there
				// the engine works out srcset, dimensions and alt text on its own.
				printf(
					'<input type="url" id="%s" name="%s" value="%s" class="large-text" placeholder="%s">',
					esc_attr( $id ),
					esc_attr( $name ),
					esc_attr( $value ),
					esc_attr__( 'Paste the image address from the Media Library', 'aipb' )
				);
				break;

			default:
				$html_types = array(
					'number' => 'number',
					'url'    => 'url',
					'color'  => 'text',
					'date'   => 'date',
					'email'  => 'email',
				);

				printf(
					'<input type="%s" id="%s" name="%s" value="%s" class="regular-text">',
					esc_attr( isset( $html_types[ $field['type'] ] ) ? $html_types[ $field['type'] ] : 'text' ),
					esc_attr( $id ),
					esc_attr( $name ),
					esc_attr( $value )
				);
		}
	}
}
