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

/**
 * The site's content types and taxonomies, declared here.
 *
 * WHAT IT SOLVES. The model can build a product page and a category archive, but the "prodotto"
 * type had to already exist: another plugin registered it, or nothing did. For a product that
 * promises "describe the site and I will build it", that is the lowest missing step of all —
 * without it, the first move on every new site is to install something else.
 *
 * WHAT IT REGISTERS. Everything `register_post_type()` and `register_taxonomy()` decide about
 * visibility, permalinks, the admin menu and the editor's supports. It used to be four values per
 * type — slug, the two labels, the archive — with everything else frozen in this file, and the
 * first request that did not fit ("this type must not have an archive", "this one must not appear
 * in the search") had no answer but a filter in a child theme. The defaults have not changed: a
 * type saved before these options existed reads back exactly as it was registered before, because
 * every missing key falls back to the value that was hard-coded here.
 *
 * WHY IT DESTROYS NOTHING. Registering a type neither creates nor deletes content: if the line is
 * removed tomorrow, the content stays in the database and becomes visible again when it is put
 * back. It is the same reason field values live in ordinary meta — what belongs to the customer
 * stays the customer's even when we leave.
 */
class AIPB_Types {

	/** { types: [ {slug,singular,plural,archive,…} ], taxonomies: [ {slug,singular,plural,types,…} ] } */
	const OPTION = 'aipb_types';

	/** A fingerprint of the registered definition: when it changes, the permalinks need rebuilding. */
	const HASH = 'aipb_types_hash';

	/** WordPress's own types, which must never be redefined. */
	const RESERVED_TYPES = array(
		'post', 'page', 'attachment', 'revision', 'nav_menu_item', 'wp_block',
		'wp_template', 'wp_template_part', 'wp_navigation', 'wp_global_styles',
	);

	/** WordPress's own taxonomies. */
	const RESERVED_TAXONOMIES = array(
		'category', 'post_tag', 'nav_menu', 'link_category', 'post_format',
		'wp_theme', 'wp_template_part_area',
	);

	/** WordPress's own limit on a post type slug. */
	const SLUG_MAX = 20;

	/** The editor features a type can be given: `supports` for register_post_type(). */
	const SUPPORTS = array(
		'title', 'editor', 'thumbnail', 'excerpt', 'custom-fields',
		'revisions', 'author', 'comments', 'page-attributes', 'post-formats', 'trackbacks',
	);

	/**
	 * Every option of a content type, with the value it takes when nobody has said otherwise.
	 *
	 * THESE DEFAULTS ARE NOT A PREFERENCE, they are the registration this class used to do with the
	 * values written into type_args(). Keeping them identical is what lets a type saved months ago —
	 * whose stored line has four keys and not twenty — go on being registered exactly as it was: the
	 * missing keys are answered here, not by WordPress's own defaults, which differ (`public` is
	 * false, `show_in_rest` is false, and a type born like that does not even open in the editor).
	 */
	public static function type_defaults() {
		return array(
			'slug'        => '',
			'singular'    => '',
			'plural'      => '',
			'description' => '',

			// Visibility.
			'public'              => true,
			'publicly_queryable'  => true,
			'show_ui'             => true,
			'show_in_menu'        => true,
			'show_in_nav_menus'   => true,
			'show_in_rest'        => true,
			'exclude_from_search' => false,

			// Structure.
			'hierarchical' => false,
			'supports'     => array( 'title', 'editor', 'thumbnail', 'excerpt', 'custom-fields', 'revisions' ),
			'taxonomies'   => array(),

			// Addresses. `archive` keeps its old name: it is in every line already saved.
			'archive'      => false,
			'archive_slug' => '',
			'rewrite'      => true,
			'rewrite_slug' => '',
			'with_front'   => false,

			// The admin menu.
			'menu_position' => '',
			'menu_icon'     => 'dashicons-portfolio',
		);
	}

	/** The same, for a taxonomy. */
	public static function taxonomy_defaults() {
		return array(
			'slug'        => '',
			'singular'    => '',
			'plural'      => '',
			'description' => '',
			'types'       => array(),

			'hierarchical' => false,

			'public'            => true,
			'show_ui'           => true,
			'show_in_menu'      => true,
			'show_in_nav_menus' => true,
			'show_in_rest'      => true,
			'show_admin_column' => true,
			'show_tagcloud'     => false,

			'rewrite'      => true,
			'rewrite_slug' => '',
			'with_front'   => false,
		);
	}

	/**
	 * What each `supports` entry gives the person filling the content in.
	 *
	 * A method and not a constant because these names are read on the page, and a constant cannot be
	 * translated.
	 *
	 * @return array<string,array{0:string,1:string}> support => [ name, what it does ]
	 */
	public static function supports_labels() {
		return array(
			'title'           => array( __( 'Title', 'aipb' ), __( 'The name of the content.', 'aipb' ) ),
			'editor'          => array( __( 'Editor', 'aipb' ), __( 'The body text.', 'aipb' ) ),
			'thumbnail'       => array( __( 'Featured image', 'aipb' ), __( 'The image the grids use.', 'aipb' ) ),
			'excerpt'         => array( __( 'Excerpt', 'aipb' ), __( 'The short summary in the listings.', 'aipb' ) ),
			'custom-fields'   => array( __( 'Custom fields', 'aipb' ), __( 'The raw meta box. The Aura fields work without it.', 'aipb' ) ),
			'revisions'       => array( __( 'Revisions', 'aipb' ), __( 'The history of the changes.', 'aipb' ) ),
			'author'          => array( __( 'Author', 'aipb' ), __( 'Who wrote it.', 'aipb' ) ),
			'comments'        => array( __( 'Comments', 'aipb' ), __( 'Comments on the content.', 'aipb' ) ),
			'page-attributes' => array( __( 'Attributes', 'aipb' ), __( 'Parent and order — needed to nest hierarchical types.', 'aipb' ) ),
			'post-formats'    => array( __( 'Formats', 'aipb' ), __( 'Aside, gallery, video…', 'aipb' ) ),
			'trackbacks'      => array( __( 'Trackbacks', 'aipb' ), __( 'Pings from other sites.', 'aipb' ) ),
		);
	}

	public static function init() {
		// Priority 9: before the default 10, so the types exist by the time the rest of the site
		// (menus, queries, our own inventory) starts asking about them.
		add_action( 'init', array( __CLASS__, 'register' ), 9 );
		add_action( 'admin_init', array( __CLASS__, 'maybe_flush' ) );
	}

	/** @return array{types:array,taxonomies:array} */
	public static function all() {
		$definition = get_option( self::OPTION, array() );
		$definition = is_array( $definition ) ? $definition : array();

		return array(
			'types'      => isset( $definition['types'] ) && is_array( $definition['types'] ) ? $definition['types'] : array(),
			'taxonomies' => isset( $definition['taxonomies'] ) && is_array( $definition['taxonomies'] ) ? $definition['taxonomies'] : array(),
		);
	}

	public static function register() {
		$definition = self::all();

		foreach ( $definition['types'] as $type ) {
			// Already there: we do not touch it, it is not ours.
			if ( ! post_type_exists( $type['slug'] ) ) {
				register_post_type( $type['slug'], self::type_args( $type ) );
			}
		}

		foreach ( $definition['taxonomies'] as $taxonomy ) {
			if ( ! taxonomy_exists( $taxonomy['slug'] ) ) {
				register_taxonomy( $taxonomy['slug'], $taxonomy['types'], self::taxonomy_args( $taxonomy ) );
			}
		}
	}

	/**
	 * The rewrite rules are rebuilt ONLY when the definition really changes.
	 *
	 * `flush_rewrite_rules()` rewrites a large option: doing it on every load is the classic way of
	 * making a site slow without noticing. Comparing the fingerprint makes it happen once per
	 * change, and never on a front-end request.
	 */
	public static function maybe_flush() {
		$hash = self::fingerprint();
		if ( get_option( self::HASH ) === $hash ) {
			return;
		}

		self::register();
		flush_rewrite_rules();
		update_option( self::HASH, $hash, false );
	}

	/**
	 * Saves the definition, cleaned up.
	 *
	 * A slug cannot be changed without consequences: it is in the URL of every piece of content of
	 * that type and in the `post_type` of every database row. Here things can be added and removed;
	 * changing a slug amounts to removing one type and adding another, and the old content stays
	 * where it was — invisible until the slug comes back. The page says so too.
	 */
	public static function save_all( $raw ) {
		$definition = array(
			'types'      => self::clean_types( isset( $raw['types'] ) ? $raw['types'] : array() ),
			'taxonomies' => self::clean_taxonomies( isset( $raw['taxonomies'] ) ? $raw['taxonomies'] : array() ),
		);

		update_option( self::OPTION, $definition, false );

		// New types have no permalinks until the rules are rebuilt, and the inventory that goes to
		// the service has to notice straight away that the site changed.
		self::register();
		flush_rewrite_rules();
		update_option( self::HASH, self::fingerprint(), false );

		if ( class_exists( 'AIPB_Site_Context' ) ) {
			AIPB_Site_Context::flush();
		}

		return self::all();
	}

	/**
	 * A slug: lowercase, digits and hyphens, at most 20 characters (WordPress's limit).
	 *
	 * It goes through sanitize_title() and not sanitize_key(): the second DROPS spaces instead of
	 * turning them into hyphens, so "Referenza Cliente" became `referenzacliente` — a slug that is
	 * unreadable in a URL and that nobody recognises as what they typed.
	 */
	public static function clean_slug( $slug ) {
		$slug = sanitize_title( str_replace( '_', '-', (string) $slug ) );
		$slug = preg_replace( '/[^a-z0-9-]/', '', $slug );
		$slug = trim( (string) $slug, '-' );

		return mb_substr( (string) $slug, 0, self::SLUG_MAX );
	}

	protected static function clean_types( $raw ) {
		$out  = array();
		$seen = array();

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

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

			// WordPress's types are not redefined: they are its own, and overwriting them would
			// break the site in ways nobody would connect back to this page.
			if ( '' === $slug || isset( $seen[ $slug ] ) || self::is_reserved_type( $slug ) ) {
				continue;
			}
			$seen[ $slug ] = true;

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

			$out[] = array(
				'slug'        => $slug,
				'singular'    => $singular,
				'plural'      => self::label( isset( $type['plural'] ) ? $type['plural'] : '', $singular ),
				'description' => self::clean_text( $type, 'description' ),

				'public'              => self::flag( $type, 'public' ),
				'publicly_queryable'  => self::flag( $type, 'publicly_queryable' ),
				'show_ui'             => self::flag( $type, 'show_ui' ),
				'show_in_menu'        => self::flag( $type, 'show_in_menu' ),
				'show_in_nav_menus'   => self::flag( $type, 'show_in_nav_menus' ),
				'show_in_rest'        => self::flag( $type, 'show_in_rest' ),
				'exclude_from_search' => self::flag( $type, 'exclude_from_search' ),

				'hierarchical' => self::flag( $type, 'hierarchical' ),
				'supports'     => self::clean_supports( isset( $type['supports'] ) ? $type['supports'] : null ),
				'taxonomies'   => self::clean_slug_list( isset( $type['taxonomies'] ) ? $type['taxonomies'] : array() ),

				'archive'      => self::flag( $type, 'archive' ),
				'archive_slug' => self::clean_path( isset( $type['archive_slug'] ) ? $type['archive_slug'] : '' ),
				'rewrite'      => self::flag( $type, 'rewrite' ),
				'rewrite_slug' => self::clean_path( isset( $type['rewrite_slug'] ) ? $type['rewrite_slug'] : '' ),
				'with_front'   => self::flag( $type, 'with_front' ),

				'menu_position' => self::clean_position( isset( $type['menu_position'] ) ? $type['menu_position'] : '' ),
				'menu_icon'     => self::clean_icon( isset( $type['menu_icon'] ) ? $type['menu_icon'] : null ),
			);
		}

		return $out;
	}

	protected static function clean_taxonomies( $raw ) {
		$out  = array();
		$seen = array();

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

			$slug = self::clean_slug( isset( $taxonomy['slug'] ) ? $taxonomy['slug'] : '' );

			if ( '' === $slug || isset( $seen[ $slug ] ) || in_array( $slug, self::RESERVED_TAXONOMIES, true ) ) {
				continue;
			}
			$seen[ $slug ] = true;

			$for = self::clean_slug_list( isset( $taxonomy['types'] ) ? $taxonomy['types'] : array() );

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

			$out[] = array(
				'slug'        => $slug,
				'singular'    => $singular,
				'plural'      => self::label( isset( $taxonomy['plural'] ) ? $taxonomy['plural'] : '', $singular ),
				'description' => self::clean_text( $taxonomy, 'description' ),
				'types'       => $for ? $for : array( 'post' ),

				'hierarchical' => self::flag( $taxonomy, 'hierarchical' ),

				'public'            => self::flag( $taxonomy, 'public' ),
				'show_ui'           => self::flag( $taxonomy, 'show_ui' ),
				'show_in_menu'      => self::flag( $taxonomy, 'show_in_menu' ),
				'show_in_nav_menus' => self::flag( $taxonomy, 'show_in_nav_menus' ),
				'show_in_rest'      => self::flag( $taxonomy, 'show_in_rest' ),
				'show_admin_column' => self::flag( $taxonomy, 'show_admin_column' ),
				'show_tagcloud'     => self::flag( $taxonomy, 'show_tagcloud' ),

				'rewrite'      => self::flag( $taxonomy, 'rewrite' ),
				'rewrite_slug' => self::clean_path( isset( $taxonomy['rewrite_slug'] ) ? $taxonomy['rewrite_slug'] : '' ),
				'with_front'   => self::flag( $taxonomy, 'with_front' ),
			);
		}

		return $out;
	}

	/* ------------------------------ Cleaning options --------------------------- */

	/**
	 * A boolean option, answered by the defaults when the line does not carry it.
	 *
	 * The fallback is the whole point of this function. A line saved before these options existed —
	 * or written by the model, which only ever sends slug, labels and archive — has none of these
	 * keys, and reading them as `! empty()` would turn every one of them off at once: the type would
	 * come back private, out of the REST API and with no editor. Whoever wrote that line asked for
	 * nothing of the sort.
	 */
	protected static function flag( $raw, $key ) {
		static $defaults = null;
		if ( null === $defaults ) {
			// The two sets share every boolean they have in common (public, rewrite, hierarchical…)
			// with the same value, so one lookup table answers for types and taxonomies alike.
			$defaults = array_merge( self::type_defaults(), self::taxonomy_defaults() );
		}

		$fallback = isset( $defaults[ $key ] ) ? (bool) $defaults[ $key ] : false;

		if ( ! is_array( $raw ) || ! array_key_exists( $key, $raw ) ) {
			return $fallback;
		}

		$value = $raw[ $key ];

		// JSON gives real booleans, a form gives "1"/"0", and a hand-written line can give "false" —
		// which is a non-empty string, and would come through as true.
		if ( is_string( $value ) ) {
			return ! in_array( strtolower( trim( $value ) ), array( '', '0', 'false', 'no', 'off' ), true );
		}

		return (bool) $value;
	}

	/**
	 * The editor's supports, kept to the ones WordPress knows.
	 *
	 * `null` means "the line does not talk about supports" and gives back the default set; an empty
	 * list means somebody unticked everything, and there the title is put back anyway: a type with no
	 * supports at all opens an editing screen with nothing in it, not even a name field, and there is
	 * no way back from it except this page.
	 */
	protected static function clean_supports( $raw ) {
		if ( null === $raw ) {
			$defaults = self::type_defaults();

			return $defaults['supports'];
		}

		$out = array();
		foreach ( (array) $raw as $support ) {
			$support = sanitize_key( (string) $support );
			if ( in_array( $support, self::SUPPORTS, true ) && ! in_array( $support, $out, true ) ) {
				$out[] = $support;
			}
		}

		return $out ? $out : array( 'title' );
	}

	/** A list of slugs (the types a taxonomy applies to, the taxonomies a type carries). */
	protected static function clean_slug_list( $raw ) {
		$out = array();
		foreach ( (array) $raw as $slug ) {
			$slug = self::clean_slug( $slug );
			if ( '' !== $slug && ! in_array( $slug, $out, true ) ) {
				$out[] = $slug;
			}
		}

		return $out;
	}

	/**
	 * A rewrite base, which unlike a slug MAY contain slashes: `shop/products` is a legitimate
	 * archive base, and clean_slug() would flatten it into `shopproducts`.
	 */
	protected static function clean_path( $value ) {
		$value = strtolower( trim( (string) $value ) );
		$value = str_replace( array( ' ', '_' ), '-', $value );
		$value = preg_replace( '#[^a-z0-9\-/]#', '', $value );
		$value = preg_replace( '#/+#', '/', (string) $value );

		return trim( (string) $value, '/-' );
	}

	/** The menu position: an integer, or '' for «wherever WordPress puts it». */
	protected static function clean_position( $value ) {
		$value = trim( (string) $value );

		return ( '' === $value || ! is_numeric( $value ) ) ? '' : (string) (int) $value;
	}

	/**
	 * The menu icon: a dashicon, an address, or the inline SVG of a `data:` URI.
	 *
	 * esc_url_raw() is not used on the data URI on purpose: it strips the base64 payload's `+` and
	 * `/` and leaves an icon that renders as a broken image.
	 */
	protected static function clean_icon( $value ) {
		if ( null === $value ) {
			$defaults = self::type_defaults();

			return $defaults['menu_icon'];
		}

		$value = trim( (string) $value );

		if ( '' === $value || 'none' === $value || 'div' === $value ) {
			return $value;
		}
		if ( 0 === strpos( $value, 'dashicons-' ) ) {
			return sanitize_html_class( $value );
		}
		if ( 0 === strpos( $value, 'data:image/svg+xml;base64,' ) ) {
			return $value;
		}
		if ( preg_match( '#^https?://#i', $value ) ) {
			return esc_url_raw( $value );
		}

		// Anything else is not an icon. Passing it through would put it in the `menu_icon` argument,
		// which WordPress prints into a `style` attribute: whatever arrives there is markup.
		return '';
	}

	/** A short free-text option (the description shown in the admin lists). */
	protected static function clean_text( $raw, $key ) {
		return isset( $raw[ $key ] ) ? sanitize_text_field( (string) $raw[ $key ] ) : '';
	}

	/**
	 * The arguments of a type registered by Aura.
	 *
	 * The stored line is read THROUGH the defaults: a type saved when this class had four options is
	 * an array of four keys, and every one it does not have is answered by type_defaults() — which
	 * holds precisely the values this function used to write in by hand. Nothing that is registered
	 * today changes for having gained the options.
	 *
	 * `show_in_rest` defaults to on deliberately: without it the content does not open in the block
	 * editor and the type is born half-made.
	 */
	protected static function type_args( $type ) {
		$type = array_merge( self::type_defaults(), (array) $type );

		$args = array(
			'labels'      => self::type_labels( $type ),
			'description' => (string) $type['description'],

			'public'              => (bool) $type['public'],
			// A private type that stays publicly queryable answers on the front end anyway: the
			// query flags follow `public` unless the line says otherwise, which is what
			// register_post_type() does too.
			'publicly_queryable'  => (bool) $type['public'] && (bool) $type['publicly_queryable'],
			'show_ui'             => (bool) $type['show_ui'],
			'show_in_menu'        => (bool) $type['show_ui'] && (bool) $type['show_in_menu'],
			'show_in_nav_menus'   => (bool) $type['show_in_nav_menus'],
			'show_in_rest'        => (bool) $type['show_in_rest'],
			'exclude_from_search' => (bool) $type['exclude_from_search'],

			'hierarchical' => (bool) $type['hierarchical'],
			'supports'     => self::clean_supports( $type['supports'] ),
			'taxonomies'   => (array) $type['taxonomies'],

			'has_archive' => self::archive_arg( $type ),
			'rewrite'     => self::rewrite_arg( $type, $type['slug'] ),

			'menu_icon'     => '' === $type['menu_icon'] ? null : $type['menu_icon'],
			'menu_position' => '' === (string) $type['menu_position'] ? null : (int) $type['menu_position'],
		);

		return apply_filters( 'aipb_post_type_args', $args, $type );
	}

	protected static function taxonomy_args( $taxonomy ) {
		$taxonomy = array_merge( self::taxonomy_defaults(), (array) $taxonomy );

		$args = array(
			'labels'      => self::taxonomy_labels( $taxonomy ),
			'description' => (string) $taxonomy['description'],

			'public'            => (bool) $taxonomy['public'],
			'show_ui'           => (bool) $taxonomy['show_ui'],
			'show_in_menu'      => (bool) $taxonomy['show_ui'] && (bool) $taxonomy['show_in_menu'],
			'show_in_nav_menus' => (bool) $taxonomy['show_in_nav_menus'],
			'show_in_rest'      => (bool) $taxonomy['show_in_rest'],
			'show_admin_column' => (bool) $taxonomy['show_admin_column'],
			'show_tagcloud'     => (bool) $taxonomy['show_tagcloud'],

			'hierarchical' => (bool) $taxonomy['hierarchical'],
			'rewrite'      => self::rewrite_arg( $taxonomy, $taxonomy['slug'] ),
		);

		return apply_filters( 'aipb_taxonomy_args', $args, $taxonomy );
	}

	/**
	 * `has_archive`: false, true, or the base the archive answers on.
	 *
	 * A custom base is a STRING and not a rewrite option, which is the part that surprises people:
	 * `has_archive => 'shop'` is what puts the product archive on /shop.
	 */
	protected static function archive_arg( $type ) {
		if ( empty( $type['archive'] ) ) {
			return false;
		}

		return '' !== $type['archive_slug'] ? (string) $type['archive_slug'] : true;
	}

	/** `rewrite`: false when the addresses are left to WordPress's query strings, otherwise the base. */
	protected static function rewrite_arg( $definition, $slug ) {
		if ( empty( $definition['rewrite'] ) ) {
			return false;
		}

		return array(
			'slug'       => '' !== $definition['rewrite_slug'] ? (string) $definition['rewrite_slug'] : $slug,
			'with_front' => ! empty( $definition['with_front'] ),
		);
	}

	/**
	 * A type's labels, all of them, derived from the two names.
	 *
	 * They are written out one by one instead of being left to WordPress: its own fallback names
	 * everything "Post" — "Add New Post" on a type called Product — and that shows up in the menu, in
	 * the editor's buttons and in every empty list.
	 */
	protected static function type_labels( $type ) {
		$singular = (string) $type['singular'];
		$plural   = (string) $type['plural'];
		$lower    = function_exists( 'mb_strtolower' ) ? mb_strtolower( $singular ) : strtolower( $singular );

		return array(
			'name'          => $plural,
			'singular_name' => $singular,
			'menu_name'     => $plural,
			'all_items'     => $plural,
			/* translators: %s: the content type's singular name. */
			'add_new_item' => sprintf( __( 'Add %s', 'aipb' ), $singular ),
			/* translators: %s: the content type's singular name. */
			'new_item' => sprintf( __( 'New %s', 'aipb' ), $singular ),
			/* translators: %s: the content type's singular name. */
			'edit_item' => sprintf( __( 'Edit %s', 'aipb' ), $singular ),
			/* translators: %s: the content type's singular name. */
			'view_item' => sprintf( __( 'View %s', 'aipb' ), $singular ),
			/* translators: %s: the content type's plural name. */
			'view_items' => sprintf( __( 'View %s', 'aipb' ), $plural ),
			/* translators: %s: the content type's plural name. */
			'search_items' => sprintf( __( 'Search %s', 'aipb' ), $plural ),
			/* translators: %s: the content type's singular name, lowercased. */
			'not_found' => sprintf( __( 'No %s', 'aipb' ), $lower ),
			/* translators: %s: the content type's singular name, lowercased. */
			'not_found_in_trash' => sprintf( __( 'No %s in the bin', 'aipb' ), $lower ),
			/* translators: %s: the content type's plural name. */
			'archives' => sprintf( __( '%s archive', 'aipb' ), $plural ),
		);
	}

	protected static function taxonomy_labels( $taxonomy ) {
		$singular = (string) $taxonomy['singular'];
		$plural   = (string) $taxonomy['plural'];

		return array(
			'name'          => $plural,
			'singular_name' => $singular,
			'menu_name'     => $plural,
			'all_items'     => $plural,
			/* translators: %s: the taxonomy's plural name. */
			'search_items' => sprintf( __( 'Search %s', 'aipb' ), $plural ),
			/* translators: %s: the taxonomy's singular name. */
			'edit_item' => sprintf( __( 'Edit %s', 'aipb' ), $singular ),
			/* translators: %s: the taxonomy's singular name. */
			'update_item' => sprintf( __( 'Update %s', 'aipb' ), $singular ),
			/* translators: %s: the taxonomy's singular name. */
			'add_new_item' => sprintf( __( 'Add %s', 'aipb' ), $singular ),
			/* translators: %s: the taxonomy's singular name. */
			'new_item_name' => sprintf( __( 'Name of the new %s', 'aipb' ), $singular ),
			/* translators: %s: the taxonomy's plural name, lowercased. */
			'not_found' => sprintf( __( 'No %s', 'aipb' ), function_exists( 'mb_strtolower' ) ? mb_strtolower( $plural ) : strtolower( $plural ) ),
			/* translators: %s: the taxonomy's singular name. */
			'parent_item' => sprintf( __( 'Parent %s', 'aipb' ), $singular ),
		);
	}

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

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

	/** The reserved list plus our own section CPT, which is not a constant because it is a class. */
	protected static function is_reserved_type( $slug ) {
		return in_array( $slug, self::RESERVED_TYPES, true ) || AIPB_CPT::POST_TYPE === $slug;
	}

	protected static function fingerprint() {
		return md5( (string) wp_json_encode( self::all() ) );
	}
}
