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

/**
 * The «Content model» page: content types, taxonomies and custom fields.
 *
 * WHAT IT WAS AND WHY IT CHANGED. This page used to be three textareas of `a | b | c` lines, on the
 * argument that whoever opens it is building the site and that the model writes those same lines
 * from the brief. The first half of that argument did not survive contact: a pipe-separated line has
 * room for exactly the options somebody thought of the day they wrote the parser, and every request
 * that came afterwards — «this type must not have an archive», «this one must not show up in the
 * search» — had nowhere to go. The second half survives intact and is why the proposal panel is
 * still here: the model still proposes, it just pours its proposal into cards instead of into text.
 *
 * HOW IT IS BUILT. The page is a shell; `assets/model.js` draws the cards from the state localised
 * here and, on submit, writes the whole model back into a single hidden field as JSON. One field
 * instead of hundreds is not a detail: a model of twenty types with their fields goes past PHP's
 * `max_input_vars` (1000 by default) and the ones over the limit are dropped SILENTLY — the page
 * would save, say it saved, and lose the last fields of the last type.
 *
 * WHAT IS SAVED WHERE. The types and taxonomies go to {@see AIPB_Types}, the fields to
 * {@see AIPB_Fields} — the content ones and the term ones in their two separate options. This page
 * only reads the form and hands over: every rule about what a valid slug is, or what happens to a
 * key that is renamed, lives with the data and not with the page that edits it.
 */
class AIPB_Admin_Model {

	/** The page's own slug, under the Aura menu. */
	const SLUG = 'aipb-model';

	/**
	 * The outcome of this request's save, worked out once.
	 *
	 * The page needs it in two places that run at different moments — the enqueue, which localises
	 * the state, and the render, which draws the notices — and processing a form twice is how you
	 * end up saving it twice.
	 *
	 * @var array{saved:bool,orphans:array,state:array}|null
	 */
	protected static $result = null;

	/**
	 * The form, handled on `load-` so it runs BEFORE any output.
	 *
	 * That is what makes the redirect after a successful save possible: without it the browser keeps
	 * the POST in its history and every refresh re-submits the whole model, which on a page that
	 * REPLACES the definition is not a harmless repetition — it re-applies a state that may be older
	 * than what is in another tab.
	 */
	public static function handle() {
		if ( null !== self::$result ) {
			return self::$result;
		}

		self::$result = array( 'saved' => false, 'orphans' => array(), 'state' => array() );

		if ( ! isset( $_POST['aipb_model'] ) || ! current_user_can( 'manage_options' ) ) {
			return self::$result;
		}
		check_admin_referer( 'aipb_model' );

		// phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified just above.
		$submitted = self::decode( (array) wp_unslash( $_POST['aipb_model'] ) );
		if ( ! $submitted ) {
			return self::$result;
		}

		$orphans = self::orphans_when_removing( isset( $submitted['types'] ) ? $submitted['types'] : array() );

		// NOTHING is saved when a type that still holds content is about to disappear and nobody has
		// confirmed it: saving the rest and keeping the type would leave the page saying one thing
		// and the site doing another. The submitted state comes back to the page as it was typed —
		// redrawing it from the database would throw the edit away precisely while asking whether to
		// confirm it, and the confirmation would then re-send the old model.
		if ( $orphans && ! self::all_confirmed( $orphans, $submitted['confirm_removal'] ) ) {
			self::$result['orphans'] = $orphans;
			self::$result['state']   = $submitted;

			return self::$result;
		}

		self::save( $submitted );

		wp_safe_redirect( add_query_arg( 'aipb-saved', '1', self::url() ) );
		exit;
	}

	public static function render() {
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_die( esc_html__( 'Permission denied.', 'aipb' ) );
		}

		$result = self::handle();

		echo '<div class="wrap aipb-model">';

		self::render_head();

		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- reading a flag we set ourselves on redirect.
		if ( isset( $_GET['aipb-saved'] ) ) {
			echo '<div class="notice notice-success is-dismissible"><p>'
				. esc_html__( 'Saved. The permalinks of the new types are already live.', 'aipb' ) . '</p></div>';
		}
		if ( $result['orphans'] ) {
			self::render_orphans_notice( $result['orphans'] );
		}

		self::render_images_missing();

		echo '<form method="post" id="aipb-model-form">';
		wp_nonce_field( 'aipb_model' );
		echo '<input type="hidden" name="aipb_model[json]" id="aipb-model-json" value="">';

		// Everything inside is drawn by model.js: these are the places it draws into.
		self::render_shell();

		echo '</form>';

		echo '<noscript><p class="description">'
			. esc_html__( 'This page needs JavaScript: the content model is edited in cards, and without it there would be nothing to press.', 'aipb' )
			. '</p></noscript>';

		echo '</div>';
	}

	/** The page's address, with no arguments. */
	public static function url() {
		return admin_url( 'edit.php?post_type=' . AIPB_CPT::POST_TYPE . '&page=' . self::SLUG );
	}

	/* --------------------------------- Saving --------------------------------- */

	/**
	 * The submitted model, decoded.
	 *
	 * @return array{types:array,taxonomies:array,foreign_fields:array,foreign_term_fields:array,confirm_removal:bool}|null
	 */
	protected static function decode( $raw ) {
		$json = isset( $raw['json'] ) ? (string) $raw['json'] : '';
		if ( '' === trim( $json ) ) {
			return null;
		}

		$in = json_decode( $json, true );
		if ( ! is_array( $in ) ) {
			return null;
		}

		return array(
			'types'               => isset( $in['types'] ) && is_array( $in['types'] ) ? $in['types'] : array(),
			'taxonomies'          => isset( $in['taxonomies'] ) && is_array( $in['taxonomies'] ) ? $in['taxonomies'] : array(),
			'foreign_fields'      => isset( $in['foreign_fields'] ) && is_array( $in['foreign_fields'] ) ? $in['foreign_fields'] : array(),
			'foreign_term_fields' => isset( $in['foreign_term_fields'] ) && is_array( $in['foreign_term_fields'] ) ? $in['foreign_term_fields'] : array(),
			// `true`, or the list of slugs whose removal was accepted one by one.
			'confirm_removal'     => isset( $in['confirm_removal'] ) ? $in['confirm_removal'] : false,
		);
	}

	/**
	 * Has every type that is about to lose its contents been confirmed?
	 *
	 * The page sends the SLUGS it asked about, not a blanket yes, and that is the point of this
	 * function: the type created in another tab two minutes ago is an orphan this page has never
	 * heard of, and a «yes» given for a different type must not carry it away as well. The blanket
	 * `true` exists for the one place where it is honest — the button in the notice below, where the
	 * types being asked about are named on the screen.
	 */
	protected static function all_confirmed( $orphans, $confirmation ) {
		if ( ! $orphans || true === $confirmation || '1' === $confirmation ) {
			return true;
		}
		if ( ! is_array( $confirmation ) ) {
			return false;
		}

		$confirmed = array_map( 'sanitize_key', $confirmation );
		foreach ( array_keys( $orphans ) as $slug ) {
			if ( ! in_array( $slug, $confirmed, true ) ) {
				return false;
			}
		}

		return true;
	}

	/**
	 * Writes the model out: types and taxonomies first, then the two field maps.
	 *
	 * The fields travel INSIDE the type they belong to — that is how the page shows them, and it is
	 * the only arrangement in which adding a type and its fields is one gesture — but they are stored
	 * apart, so here they are separated again.
	 */
	protected static function save( $in ) {
		$fields      = self::field_map( $in['types'], $in['foreign_fields'] );
		$term_fields = self::field_map( $in['taxonomies'], $in['foreign_term_fields'] );

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

		AIPB_Types::save_all( array(
			'types'      => $in['types'],
			'taxonomies' => $in['taxonomies'],
		) );

		self::$result['saved'] = true;
	}

	/**
	 * { slug => fields } out of the definitions that carry their own fields, plus the ones edited for
	 * the types and taxonomies Aura does not manage (post, page, whatever another plugin registered).
	 */
	protected static function field_map( $definitions, $foreign ) {
		$out = array();

		foreach ( (array) $definitions as $definition ) {
			if ( ! is_array( $definition ) || empty( $definition['slug'] ) ) {
				continue;
			}
			$slug = AIPB_Types::clean_slug( $definition['slug'] );
			if ( '' !== $slug && ! empty( $definition['fields'] ) && is_array( $definition['fields'] ) ) {
				$out[ $slug ] = $definition['fields'];
			}
		}

		foreach ( (array) $foreign as $slug => $fields ) {
			$slug = sanitize_key( $slug );
			if ( '' !== $slug && is_array( $fields ) && $fields ) {
				$out[ $slug ] = $fields;
			}
		}

		return $out;
	}

	/**
	 * Which types are being removed, and how many contents they would leave behind.
	 *
	 * EVERY status is counted except the auto-drafts, the trash included: a trashed content is still
	 * a content somebody might want to fish back out, and with the type unregistered they cannot even
	 * empty it — the admin answers «Invalid post type».
	 *
	 * The case that made this necessary is not even a wrong click: you open this page, you create a
	 * type from the Studio in another tab, you save the earlier form — which does not know about that
	 * type — and the type just created disappears with its contents.
	 *
	 * @param array $new The types the form is about to save.
	 * @return array<string,int> slug => how many contents, only for the ones that have any.
	 */
	protected static function orphans_when_removing( $new ) {
		$staying = array();
		foreach ( (array) $new as $type ) {
			if ( is_array( $type ) && isset( $type['slug'] ) ) {
				$staying[ AIPB_Types::clean_slug( $type['slug'] ) ] = true;
			}
		}

		$out = array();
		foreach ( AIPB_Types::all()['types'] as $type ) {
			$slug = isset( $type['slug'] ) ? (string) $type['slug'] : '';
			if ( '' === $slug || isset( $staying[ $slug ] ) ) {
				continue;
			}
			$n = self::count_contents( $slug );
			if ( $n > 0 ) {
				$out[ $slug ] = $n;
			}
		}

		return $out;
	}

	/** How many contents a type holds, the bin included and the auto-drafts excluded. */
	protected static function count_contents( $slug ) {
		if ( ! post_type_exists( $slug ) ) {
			return 0;
		}

		$n = 0;
		foreach ( (array) wp_count_posts( $slug ) as $status => $how_many ) {
			if ( 'auto-draft' !== $status ) {
				$n += (int) $how_many;
			}
		}

		return $n;
	}

	/* --------------------------------- The state ------------------------------- */

	/**
	 * Everything model.js needs to draw the page, and nothing it can work out for itself.
	 *
	 * When a save has just been REFUSED (types not empty, no confirmation) the state is the one that
	 * was submitted and not the one in the database: what the person typed has to still be on the
	 * screen while they are being asked whether they meant it.
	 */
	public static function state() {
		$result    = self::handle();
		$submitted = $result['state'];

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

		$types = isset( $submitted['types'] )
			? $submitted['types']
			: self::with_fields( $definition['types'], $fields );

		$taxonomies = isset( $submitted['taxonomies'] )
			? $submitted['taxonomies']
			: self::with_fields( $definition['taxonomies'], $term_fields );

		$ours     = self::slugs_of( $definition['types'] );
		$ours_tax = self::slugs_of( $definition['taxonomies'] );

		return array(
			'types'      => array_values( (array) $types ),
			'taxonomies' => array_values( (array) $taxonomies ),
			'foreign'    => array(
				'types'      => self::foreign_types( $ours, $fields ),
				'taxonomies' => self::foreign_taxonomies( $ours_tax, $term_fields ),
			),

			'defaults'   => array(
				'type'     => AIPB_Types::type_defaults(),
				'taxonomy' => AIPB_Types::taxonomy_defaults(),
			),
			'supports'   => self::supports_choices(),
			'fieldTypes' => self::field_type_choices(),
			'icons'      => self::menu_icons(),
			'reserved'   => array(
				'types'      => array_merge( AIPB_Types::RESERVED_TYPES, array( AIPB_CPT::POST_TYPE ) ),
				'taxonomies' => AIPB_Types::RESERVED_TAXONOMIES,
			),
			'slugMax'    => AIPB_Types::SLUG_MAX,

			// How many contents each existing type holds: the page uses it to say what a deletion
			// would leave behind BEFORE it is done, instead of finding out on the round trip.
			'counts'     => self::content_counts( $definition['types'] ),
			// Only the blanket yes survives a refused save: the per-slug list does not, because the
			// refusal means it did not cover everything that was about to be lost.
			'confirmRemoval' => isset( $submitted['confirm_removal'] ) && true === $submitted['confirm_removal'],

			'rest'  => esc_url_raw( rest_url( AIPB_REST::NS ) ),
			'nonce' => wp_create_nonce( 'wp_rest' ),
			'canFillImages' => class_exists( 'AIPB_Images' ) && current_user_can( 'upload_files' ),
		);
	}

	/** Each definition with the fields declared for its slug attached. */
	protected static function with_fields( $definitions, $map ) {
		$out = array();

		foreach ( (array) $definitions as $definition ) {
			$slug                = isset( $definition['slug'] ) ? (string) $definition['slug'] : '';
			$definition['fields'] = ( '' !== $slug && isset( $map[ $slug ] ) && is_array( $map[ $slug ] ) ) ? $map[ $slug ] : array();
			$out[]               = $definition;
		}

		return $out;
	}

	protected static function slugs_of( $definitions ) {
		$out = array();
		foreach ( (array) $definitions as $definition ) {
			if ( ! empty( $definition['slug'] ) ) {
				$out[] = (string) $definition['slug'];
			}
		}

		return $out;
	}

	/**
	 * The types registered by somebody else — WordPress's own, another plugin's, the theme's.
	 *
	 * They are on the page because their FIELDS are edited here too: `post` and `page` have always
	 * been able to carry Aura fields, and leaving them out of a page about the content model would
	 * mean hiding half of it. Their settings are not editable: they belong to whoever registered
	 * them, and this page overwriting them would be a plugin fighting another one.
	 */
	protected static function foreign_types( $ours, $fields ) {
		$out = array();

		// Aura's own entities (sections, patterns, FSE templates) are left out: they hold a layout
		// and nothing else, and a custom field on a template part would have nowhere to be filled in.
		// So are WordPress's internal types, which have a UI but are not content: nobody is going to
		// put a price on a navigation menu.
		$skip = array_merge( $ours, AIPB_Config::studio_native_types(), array(
			'attachment', 'nav_menu_item', 'revision', 'custom_css', 'customize_changeset',
			'oembed_cache', 'user_request', 'wp_navigation', 'wp_global_styles',
			'wp_font_family', 'wp_font_face',
		) );

		foreach ( get_post_types( array( 'show_ui' => true ), 'objects' ) as $slug => $object ) {
			if ( in_array( $slug, $skip, true ) ) {
				continue;
			}

			$out[] = array(
				'slug'   => $slug,
				'label'  => $object->labels->name,
				'icon'   => is_string( $object->menu_icon ) ? $object->menu_icon : 'dashicons-admin-post',
				'count'  => self::count_contents( $slug ),
				'fields' => isset( $fields[ $slug ] ) && is_array( $fields[ $slug ] ) ? $fields[ $slug ] : array(),
			);
		}

		return $out;
	}

	/** The same, for taxonomies: categories, tags, and whatever another plugin registered. */
	protected static function foreign_taxonomies( $ours, $term_fields ) {
		$out = array();

		// `public` and not `show_ui`: the taxonomies that exist to hold WordPress's own bookkeeping
		// (link categories, pattern categories, a shop plugin's visibility flags) have a UI too, and
		// listing them turns a page about the site's content model into a list of internals.
		foreach ( get_taxonomies( array( 'public' => true, 'show_ui' => true ), 'objects' ) as $slug => $object ) {
			if ( in_array( $slug, $ours, true ) ) {
				continue;
			}

			$out[] = array(
				'slug'   => $slug,
				'label'  => $object->labels->name,
				'types'  => array_values( (array) $object->object_type ),
				'fields' => isset( $term_fields[ $slug ] ) && is_array( $term_fields[ $slug ] ) ? $term_fields[ $slug ] : array(),
			);
		}

		return $out;
	}

	/** slug => how many contents, for the types this page manages. */
	protected static function content_counts( $types ) {
		$out = array();
		foreach ( (array) $types as $type ) {
			$slug = isset( $type['slug'] ) ? (string) $type['slug'] : '';
			if ( '' !== $slug ) {
				$out[ $slug ] = self::count_contents( $slug );
			}
		}

		return $out;
	}

	/** The editor's supports, as the page shows them: key, name, one line on what it gives. */
	protected static function supports_choices() {
		$out = array();
		foreach ( AIPB_Types::supports_labels() as $key => $meta ) {
			$out[] = array( 'key' => $key, 'label' => $meta[0], 'hint' => $meta[1] );
		}

		return $out;
	}

	protected static function field_type_choices() {
		$out = array();
		foreach ( AIPB_Fields::types() as $key => $label ) {
			$out[] = array( 'key' => $key, 'label' => $label );
		}

		return $out;
	}

	/**
	 * The icons offered for the admin menu.
	 *
	 * A short list and not the whole Dashicons set: it is a picker, not a catalogue, and three
	 * hundred squares to scroll is a way of making somebody choose the first one every time.
	 */
	protected static function menu_icons() {
		return array(
			'dashicons-portfolio', 'dashicons-admin-post', 'dashicons-admin-page', 'dashicons-media-document',
			'dashicons-cart', 'dashicons-products', 'dashicons-store', 'dashicons-tag',
			'dashicons-groups', 'dashicons-businessperson', 'dashicons-testimonial', 'dashicons-format-quote',
			'dashicons-camera', 'dashicons-format-gallery', 'dashicons-format-video', 'dashicons-images-alt2',
			'dashicons-calendar-alt', 'dashicons-clock', 'dashicons-location', 'dashicons-building',
			'dashicons-book', 'dashicons-welcome-learn-more', 'dashicons-lightbulb', 'dashicons-awards',
			'dashicons-hammer', 'dashicons-screenoptions', 'dashicons-layout', 'dashicons-star-filled',
			'dashicons-heart', 'dashicons-email', 'dashicons-phone', 'dashicons-megaphone',
		);
	}

	/* --------------------------------- Markup ---------------------------------- */

	protected static function render_head() {
		echo '<div class="aipb-model-head">';
		echo '<h1>' . esc_html__( 'Content model', 'aipb' ) . '</h1>';
		echo '<p class="description">'
			. esc_html__( 'The types of content this site is made of, the taxonomies that sort them and the fields they carry. Everything registered here exists in WordPress like any other content type — and the AI can build pages, archives and listings on top of it.', 'aipb' )
			. '</p>';
		echo '</div>';
	}

	/** The empty containers model.js fills in. */
	protected static function render_shell() {
		?>
		<div class="aipb-model-tabs" role="tablist">
			<button type="button" class="aipb-model-tab is-on" role="tab" data-tab="types" aria-selected="true">
				<span class="dashicons dashicons-portfolio" aria-hidden="true"></span>
				<?php esc_html_e( 'Content types', 'aipb' ); ?>
				<span class="aipb-pill" data-count="types">0</span>
			</button>
			<button type="button" class="aipb-model-tab" role="tab" data-tab="taxonomies" aria-selected="false">
				<span class="dashicons dashicons-category" aria-hidden="true"></span>
				<?php esc_html_e( 'Taxonomies', 'aipb' ); ?>
				<span class="aipb-pill" data-count="taxonomies">0</span>
			</button>
			<button type="button" class="aipb-model-tab" role="tab" data-tab="brief" aria-selected="false">
				<span class="dashicons dashicons-superhero-alt" aria-hidden="true"></span>
				<?php esc_html_e( 'Propose from the brief', 'aipb' ); ?>
			</button>
		</div>

		<div class="aipb-model-pane is-on" data-pane="types"></div>
		<div class="aipb-model-pane" data-pane="taxonomies"></div>
		<div class="aipb-model-pane" data-pane="brief"></div>

		<div class="aipb-savebar">
			<div class="aipb-savebar-inner">
				<span class="aipb-savebar-state" id="aipb-model-state"></span>
				<button type="submit" class="button button-primary button-large" id="aipb-model-save">
					<?php esc_html_e( 'Save content model', 'aipb' ); ?>
				</button>
			</div>
		</div>
		<?php
	}

	/**
	 * The save was STOPPED because it removed types that are not empty.
	 *
	 * The button re-submits the SAME model with the confirmation attached: asking the question and
	 * then making the person redo their edits to answer it would be asking twice.
	 */
	protected static function render_orphans_notice( $orphans ) {
		echo '<div class="notice notice-error aipb-orphans"><p><strong>'
			. esc_html__( 'I have not saved: this would remove content types that are not empty.', 'aipb' )
			. '</strong></p><ul>';

		foreach ( $orphans as $slug => $n ) {
			echo '<li><code>' . esc_html( $slug ) . '</code> — ' . esc_html( sprintf(
				/* translators: %d: number of contents. */
				_n( '%d content', '%d contents', $n, 'aipb' ),
				$n
			) ) . '</li>';
		}

		echo '</ul><p>'
			. esc_html__( 'Those contents would NOT be deleted: they would stay in the database and disappear from the admin, and anyone arriving from an old link would see «Invalid post type». Putting the type back brings them all back.', 'aipb' )
			. '</p><p>'
			. esc_html__( 'If the type has just been created from the Studio, this page was probably open from before and does not know about it: reload it and try again.', 'aipb' )
			. '</p><p><button type="button" class="button button-secondary" id="aipb-model-force">'
			. esc_html__( 'I know what I am doing: remove those types anyway', 'aipb' )
			. '</button></p></div>';
	}

	/**
	 * «There are N contents with no image»: the button that goes looking for one.
	 *
	 * WHY HERE. A content with no featured image is not an error and is visible nowhere — until a
	 * section builds a grid on top of it, and then it becomes a row of empty boxes. Whoever looks at
	 * the page does not connect that hole to the contents being incomplete: they think the builder is
	 * broken.
	 *
	 * The search costs ZERO tokens (Media Library, then openly licensed stock), so no confirmation
	 * about the price is needed: all that is needed is for somebody to know it can be done.
	 */
	protected static function render_images_missing() {
		if ( ! class_exists( 'AIPB_Images' ) || ! current_user_can( 'upload_files' ) ) {
			return;
		}

		$rows = array();
		foreach ( AIPB_Types::all()['types'] as $type ) {
			$slug = isset( $type['slug'] ) ? (string) $type['slug'] : '';
			if ( '' === $slug || ! post_type_exists( $slug ) ) {
				continue;
			}
			$n = count( AIPB_Images::without_image( $slug ) );
			if ( $n > 0 ) {
				$rows[ $slug ] = array( 'label' => isset( $type['plural'] ) ? $type['plural'] : $slug, 'n' => $n );
			}
		}

		if ( ! $rows ) {
			return;
		}

		echo '<div class="notice notice-warning inline aipb-noimg"><p><strong>'
			. esc_html__( 'Contents with no image', 'aipb' ) . '</strong><br>'
			. esc_html__( 'A grid built on these shows empty boxes. I can look for one: first in the site\'s Media Library, then among the openly licensed photos. It costs nothing and downloads nothing into the Library.', 'aipb' )
			. '</p><p>';

		foreach ( $rows as $slug => $row ) {
			printf(
				'<button type="button" class="button aipb-fill-img" data-type="%s">%s</button> ',
				esc_attr( $slug ),
				esc_html( sprintf(
					/* translators: 1: the type's plural name, 2: how many contents. */
					_n( 'Find the image · %1$s (%2$d without)', 'Find the images · %1$s (%2$d without)', $row['n'], 'aipb' ),
					$row['label'],
					$row['n']
				) )
			);
		}

		echo '</p><p class="aipb-fill-result"></p></div>';
	}
}
