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

/**
 * Creates the content types that were asked for, and fills them.
 *
 * WHAT IT SOLVES. "Make me a slider with all the planets" ended up as eight cards written by hand
 * inside a section's code: changing a planet meant opening the code, and that content existed
 * nowhere else on the site. It was not the model's mistake — the generator receives the inventory,
 * "pianeta" was not in it, and you cannot loop over a type that does not exist. What was missing was
 * the step before: somebody to create that type.
 *
 * TWO STEPS AND NOT ONE, which is why this class has two public methods instead of the single one it
 * started with. `apply()` creates the STRUCTURE — types, fields, taxonomies, terms — and
 * `contents()` puts the CONTENT inside. Separating them is for whoever is watching: the structure is
 * created in an instant, the content is N inserts, and somebody who has just written a request
 * deserves to see where it is up to instead of a frozen bar. Calling them in sequence does exactly
 * what the single method did.
 *
 * They are also IDEMPOTENT, and that is the point: `apply()` skips what exists, `contents()`
 * recognises content by its title. Re-running the same request doubles nothing, and neither does
 * repeating one step after the other came up empty.
 *
 * THIS is where things are SAVED — the only point at which a proposal becomes something that stays
 * in the database, and it is only reached with `manage_options`. The rest of the chain
 * (parse_plan_model, parse_model_proposal) prepares and touches nothing.
 *
 * WHAT IT DOES NOT DESTROY. It only adds: types, taxonomies, fields and content that already exist
 * are left alone, and whatever exists is skipped.
 */
class AIPB_Model_Apply {

	/**
	 * The STRUCTURE: content types, fields, taxonomies and terms.
	 *
	 * It does NOT create content: that is contents()'s job, to be called afterwards. The report says
	 * what was REALLY created, type by type, because that is what the Studio writes in the chat —
	 * and writing "created" about something that was skipped is the quickest way to make a report
	 * worthless.
	 *
	 * @param array $model {
	 *     types:      [ { slug, singular, plural, archive, fields } ],
	 *     taxonomies: [ { slug, singular, plural, types, hierarchical, terms } ]
	 * }
	 *
	 * @return array {
	 *     types:      [ { slug, label, fields: int } ],
	 *     taxonomies: [ { slug, label, terms: int } ],
	 *     notes:      list<string>
	 * }
	 */
	public static function apply( $model ) {
		$report = array( 'types' => array(), 'taxonomies' => array(), 'notes' => array() );

		$wanted_types      = ( isset( $model['types'] ) && is_array( $model['types'] ) ) ? $model['types'] : array();
		$wanted_taxonomies = ( isset( $model['taxonomies'] ) && is_array( $model['taxonomies'] ) ) ? $model['taxonomies'] : array();

		if ( ! $wanted_types && ! $wanted_taxonomies ) {
			return $report;
		}

		$definition = AIPB_Types::all();
		$fields     = AIPB_Fields::all();
		$term_fields = AIPB_Fields::all_terms();

		$fresh = self::collect_types( $wanted_types, $definition, $fields );
		$terms = self::collect_taxonomies( $wanted_taxonomies, $fresh, $definition, $term_fields, $report );

		if ( ! $fresh && ! $report['taxonomies'] ) {
			return $report;
		}

		// FIELDS FIRST, THEN TYPES. save_all() on the types registers them and rebuilds the
		// permalinks, and from that moment the site can already show the new type: if the fields
		// arrived afterwards there would be a window in which the editor opens a piece of content
		// without its own boxes.
		AIPB_Fields::save_all( $fields );
		AIPB_Fields::save_all_terms( $term_fields );
		AIPB_Types::save_all( $definition );

		self::create_terms( $terms, $report );

		foreach ( $fresh as $slug => $info ) {
			if ( ! post_type_exists( $slug ) ) {
				/* translators: %s: the content type's slug. */
				$report['notes'][] = sprintf( __( 'The type "%s" does not appear to be registered after saving.', 'aipb' ), $slug );
				continue;
			}

			$report['types'][] = array( 'slug' => $slug, 'label' => $info['label'], 'fields' => $info['fields'] );
		}

		self::flush_inventory();

		return $report;
	}

	/**
	 * The CONTENT of a type, with its fields and its terms.
	 *
	 * To be called after apply(), and not before: it reads the fields from the definition now saved
	 * rather than receiving them over the network, so the values are cleaned with the field's REAL
	 * type — and a type that does not exist yet cannot have content.
	 *
	 * @return array { created: int, skipped: int, failed: int, notes: list<string> }
	 */
	public static function contents( $post_type, $items ) {
		$report = array( 'created' => 0, 'skipped' => 0, 'failed' => 0, 'notes' => array() );

		$post_type = AIPB_Types::clean_slug( $post_type );
		if ( '' === $post_type || ! post_type_exists( $post_type ) ) {
			/* translators: %s: the content type's slug. */
			$report['notes'][] = sprintf( __( 'The type "%s" does not exist: no content inserted.', 'aipb' ), $post_type );

			return $report;
		}

		$field_types = self::field_types( AIPB_Fields::for_type( $post_type ) );

		foreach ( (array) $items as $item ) {
			if ( ! is_array( $item ) ) {
				continue;
			}

			$report[ self::insert_item( $post_type, $item, $field_types ) ]++;
		}

		if ( $report['created'] ) {
			self::flush_inventory();
		}

		return $report;
	}

	/**
	 * Puts a type's archive into the site's menu.
	 *
	 * A type with `archive: true` has a page listing them all, and before this line no link on the
	 * site led there: it existed as an address and nothing else. {@see AIPB_Nav} says which menu is
	 * the right one and how to write to it, which differs between block and classic themes.
	 *
	 * @return array{ok:bool,kind:string,menu:string,reason:string,url:string,label:string}
	 */
	public static function menu( $post_type ) {
		$post_type = AIPB_Types::clean_slug( $post_type );
		$nothing   = array( 'ok' => false, 'kind' => '', 'menu' => '', 'reason' => 'no-archive', 'url' => '', 'label' => '' );

		if ( '' === $post_type || ! post_type_exists( $post_type ) ) {
			return $nothing;
		}

		$object = get_post_type_object( $post_type );
		if ( ! $object || empty( $object->has_archive ) ) {
			return $nothing;
		}

		$url = get_post_type_archive_link( $post_type );
		if ( ! $url ) {
			return $nothing;
		}

		$label = isset( $object->labels->name ) ? (string) $object->labels->name : $post_type;

		return array_merge( AIPB_Nav::add_item( $label, $url ), array( 'url' => $url, 'label' => $label ) );
	}

	/* --------------------------------- Structure ------------------------------- */

	/**
	 * The types that do not exist yet, added to the definition and to the field map.
	 *
	 * @return array<string,array{label:string,fields:int}> keyed by slug
	 */
	protected static function collect_types( $wanted, &$definition, &$fields ) {
		$fresh = array();

		foreach ( $wanted as $type ) {
			if ( ! is_array( $type ) ) {
				continue;
			}

			$slug = AIPB_Types::clean_slug( isset( $type['slug'] ) ? $type['slug'] : '' );

			// Registered by the theme, by another plugin, or on an earlier run: it is there, and
			// what is there is not touched.
			if ( '' === $slug || post_type_exists( $slug ) ) {
				continue;
			}

			$singular = self::label( isset( $type['singular'] ) ? $type['singular'] : '', $slug );
			$plural   = self::label( isset( $type['plural'] ) ? $type['plural'] : '', $singular );

			$definition['types'][] = array(
				'slug'     => $slug,
				'singular' => $singular,
				'plural'   => $plural,
				'archive'  => ! empty( $type['archive'] ),
			);

			$own = self::fields_of( $type );
			if ( $own ) {
				$fields[ $slug ] = array_merge(
					( isset( $fields[ $slug ] ) && is_array( $fields[ $slug ] ) ) ? $fields[ $slug ] : array(),
					$own
				);
			}

			$fresh[ $slug ] = array( 'label' => $plural, 'fields' => count( $own ) );
		}

		return $fresh;
	}

	/**
	 * The taxonomies that do not exist yet.
	 *
	 * They only attach to types that will exist: a taxonomy registered on a non-existent type shows
	 * up nowhere, and nobody would ever connect its absence to this line.
	 *
	 * @return array<string,list<array{name:string,fields:array}>> the terms to create, by taxonomy
	 */
	protected static function collect_taxonomies( $wanted, $fresh_types, &$definition, &$term_fields, &$report ) {
		$terms = array();

		foreach ( $wanted as $taxonomy ) {
			if ( ! is_array( $taxonomy ) ) {
				continue;
			}

			$slug = AIPB_Types::clean_slug( isset( $taxonomy['slug'] ) ? $taxonomy['slug'] : '' );
			if ( '' === $slug || taxonomy_exists( $slug ) ) {
				continue;
			}

			$for = array();
			foreach ( (array) ( isset( $taxonomy['types'] ) ? $taxonomy['types'] : array() ) as $post_type ) {
				$post_type = AIPB_Types::clean_slug( $post_type );
				if ( '' !== $post_type && ( isset( $fresh_types[ $post_type ] ) || post_type_exists( $post_type ) ) ) {
					$for[] = $post_type;
				}
			}

			if ( ! $for ) {
				/* translators: %s: the taxonomy's slug. */
				$report['notes'][] = sprintf( __( 'The taxonomy "%s" did not say which content type it applies to: not created.', 'aipb' ), $slug );
				continue;
			}

			$singular = self::label( isset( $taxonomy['singular'] ) ? $taxonomy['singular'] : '', $slug );
			$plural   = self::label( isset( $taxonomy['plural'] ) ? $taxonomy['plural'] : '', $singular );

			$definition['taxonomies'][] = array(
				'slug'         => $slug,
				'singular'     => $singular,
				'plural'       => $plural,
				'types'        => array_values( array_unique( $for ) ),
				'hierarchical' => ! empty( $taxonomy['hierarchical'] ),
			);

			// The TERM's fields: they print with term.acf_fieldname. Without this half the DSL would
			// teach a placeholder that exists in no fillable place.
			$own = self::fields_of( $taxonomy );
			if ( $own ) {
				$term_fields[ $slug ] = array_merge(
					( isset( $term_fields[ $slug ] ) && is_array( $term_fields[ $slug ] ) ) ? $term_fields[ $slug ] : array(),
					$own
				);
			}

			// The terms are created AFTER registration (wp_insert_term refuses a taxonomy that does
			// not exist yet): here they are only set aside, with their values.
			$terms[ $slug ] = self::collect_terms( isset( $taxonomy['terms'] ) ? $taxonomy['terms'] : array() );

			$report['taxonomies'][] = array(
				'slug'   => $slug,
				'label'  => $plural,
				'terms'  => count( $terms[ $slug ] ),
				'fields' => count( $own ),
			);
		}

		return $terms;
	}

	/** @return list<array{name:string,fields:array}> */
	protected static function collect_terms( $raw ) {
		$out = array();

		foreach ( (array) $raw as $term ) {
			$name = sanitize_text_field( (string) ( is_array( $term ) ? ( isset( $term['name'] ) ? $term['name'] : '' ) : $term ) );
			if ( '' === $name ) {
				continue;
			}

			$out[] = array(
				'name'   => $name,
				'fields' => ( is_array( $term ) && isset( $term['fields'] ) && is_array( $term['fields'] ) ) ? $term['fields'] : array(),
			);
		}

		return $out;
	}

	/**
	 * The terms, now that the taxonomies are registered and wp_insert_term accepts them.
	 *
	 * A term that already exists is left alone: the same group twice is what makes a filter
	 * worthless, and pressing again must not be able to cause it.
	 */
	protected static function create_terms( $terms, &$report ) {
		foreach ( $terms as $taxonomy => $entries ) {
			if ( ! taxonomy_exists( $taxonomy ) ) {
				/* translators: %s: the taxonomy's slug. */
				$report['notes'][] = sprintf( __( 'The taxonomy "%s" does not appear to be registered after saving: terms not created.', 'aipb' ), $taxonomy );
				continue;
			}

			$field_types = self::field_types( AIPB_Fields::for_taxonomy( $taxonomy ) );

			foreach ( $entries as $entry ) {
				// The term was there: it is not touched, and neither are its values — they may have
				// been written by hand by somebody who knows that category better than the model.
				if ( term_exists( $entry['name'], $taxonomy ) ) {
					continue;
				}

				$created = wp_insert_term( $entry['name'], $taxonomy );
				if ( is_wp_error( $created ) || empty( $created['term_id'] ) ) {
					continue;
				}

				foreach ( $entry['fields'] as $key => $value ) {
					$key = AIPB_Fields::clean_key( (string) $key );
					if ( ! isset( $field_types[ $key ] ) ) {
						continue;
					}

					$clean = AIPB_Fields::sanitize_value( $field_types[ $key ], $value );
					if ( '' !== $clean ) {
						update_term_meta( (int) $created['term_id'], $key, $clean );
					}
				}
			}
		}
	}

	/* ---------------------------------- Content -------------------------------- */

	/**
	 * One initial piece of content.
	 *
	 * The title is the recognition key, deliberately: somebody re-running the same request, or
	 * regenerating the same page the next day, must not end up with eight planets in duplicate. It
	 * is not a perfect identity — two pieces of content can legitimately share a name — but these
	 * are contents just born from a proposal, where that case does not arise.
	 *
	 * @return 'created'|'skipped'|'failed'
	 */
	protected static function insert_item( $post_type, $item, $field_types ) {
		$title = isset( $item['title'] ) ? sanitize_text_field( (string) $item['title'] ) : '';
		if ( '' === $title ) {
			return 'failed';
		}

		$existing = get_posts( array(
			'post_type'        => $post_type,
			'title'            => $title,
			'post_status'      => 'any',
			'numberposts'      => 1,
			'fields'           => 'ids',
			'suppress_filters' => false,
		) );
		if ( $existing ) {
			return 'skipped';
		}

		$id = wp_insert_post( array(
			'post_type'    => $post_type,
			'post_title'   => $title,
			'post_excerpt' => isset( $item['excerpt'] ) ? sanitize_textarea_field( (string) $item['excerpt'] ) : '',
			'post_status'  => 'publish',
		), true );

		if ( is_wp_error( $id ) || ! $id ) {
			return 'failed';
		}

		foreach ( (array) ( isset( $item['fields'] ) ? $item['fields'] : array() ) as $key => $value ) {
			$key = AIPB_Fields::clean_key( (string) $key );
			if ( isset( $field_types[ $key ] ) ) {
				update_post_meta( $id, $key, AIPB_Fields::sanitize_value( $field_types[ $key ], $value ) );
			}
		}

		// THE IMAGE. Content just born has no featured image, and the section built on top of it
		// prints <img src="">: a slider of planets with no planets. AIPB_Images looks at the site's
		// Media Library first and only then falls back to stock, which stays a remote address in a
		// meta and does not enter the Library (see that class for why). If it finds nothing, nothing
		// happens: we are back to the hole we had before.
		if ( class_exists( 'AIPB_Images' ) ) {
			$query = isset( $item['image_query'] ) ? (string) $item['image_query'] : '';
			AIPB_Images::attach( $id, '' !== trim( $query ) ? $query : $title );
		}

		self::assign_terms( $id, $post_type, isset( $item['terms'] ) ? $item['terms'] : array() );

		return 'created';
	}

	/**
	 * This content's terms.
	 *
	 * Without them the taxonomy would exist with its terms and no content inside: the page's filters
	 * would come out with buttons that select nothing, which is worse than a page with no filters.
	 */
	protected static function assign_terms( $post_id, $post_type, $raw ) {
		foreach ( (array) $raw as $taxonomy => $names ) {
			$taxonomy = AIPB_Types::clean_slug( (string) $taxonomy );

			if ( '' === $taxonomy || ! taxonomy_exists( $taxonomy ) || ! is_object_in_taxonomy( $post_type, $taxonomy ) ) {
				continue;
			}

			$clean = array();
			foreach ( (array) $names as $name ) {
				$name = sanitize_text_field( (string) $name );
				if ( '' !== $name ) {
					$clean[] = $name;
				}
			}

			if ( $clean ) {
				// false = REPLACE rather than append. The content has just been created, so there is
				// nothing to preserve, and replacing makes the operation repeatable.
				wp_set_object_terms( $post_id, $clean, $taxonomy, false );
			}
		}
	}

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

	/**
	 * The inventory that goes out with the next prompt has to contain what was just born.
	 *
	 * It is the one thing that makes a section write a loop instead of N hand-made cards: the
	 * service decides by looking at the inventory, and an inventory from five minutes ago describes
	 * a site that no longer exists. `save_all()` already clears it, but the content lands AFTER.
	 */
	protected static function flush_inventory() {
		if ( class_exists( 'AIPB_Site_Context' ) ) {
			AIPB_Site_Context::flush();
		}
	}

	/**
	 * A field key => type map, for cleaning values with the right rule.
	 *
	 * @return array<string,string>
	 */
	protected static function field_types( $fields ) {
		$out = array();

		foreach ( $fields as $field ) {
			$out[ $field['key'] ] = $field['type'];
		}

		return $out;
	}

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

		foreach ( (array) ( isset( $owner['fields'] ) ? $owner['fields'] : array() ) as $field ) {
			if ( ! is_array( $field ) ) {
				continue;
			}

			$key  = AIPB_Fields::clean_key( isset( $field['key'] ) ? (string) $field['key'] : '' );
			$type = isset( $field['type'] ) ? sanitize_key( (string) $field['type'] ) : '';

			if ( '' === $key || isset( $seen[ $key ] ) || ! AIPB_Fields::is_type( $type ) ) {
				continue;
			}
			$seen[ $key ] = true;

			$out[] = array(
				'key'   => $key,
				'type'  => $type,
				'label' => isset( $field['label'] ) ? sanitize_text_field( (string) $field['label'] ) : '',
			);
		}

		return $out;
	}

	protected static function label( $value, $fallback ) {
		$value = sanitize_text_field( (string) $value );

		return '' !== $value ? $value : ucfirst( str_replace( '-', ' ', (string) $fallback ) );
	}
}
