/* Aura — the Content model editor (Contents page).

   The whole model lives in ONE object here and is written back into a single hidden field as JSON
   when the form is submitted. Two consequences worth knowing before touching this file:

    - nothing on this page is a `name=` input. Adding a control means adding a line to the state,
      not a field to the form;
    - a model with many types and fields would otherwise go past PHP's max_input_vars and lose the
      last ones SILENTLY, which is the worst way to lose them.

   The re-render is per pane and total: state changes, the pane is drawn again. The one exception is
   typing — a text input updates the state and patches its own card's title and badges by hand,
   because redrawing under a cursor moves it to the end of the line. */

var _aipbI = ( window.wp && window.wp.i18n ) || {};
var __ = _aipbI.__ ? function ( s ) { return _aipbI.__( s, 'aipb' ); } : function ( s ) { return s; };
/* The fallback understands the positional form too (%1$s): several strings here need it, and one
   that silently prints "%1$s" is a defect visible to whoever reads the page. */
var _sprintf = _aipbI.sprintf || function ( format ) {
	var args = Array.prototype.slice.call( arguments, 1 );
	var next = 0;

	return String( format ).replace( /%(?:(\d+)\$)?([sd])/g, function ( all, position ) {
		var value = position ? args[ Number( position ) - 1 ] : args[ next++ ];

		return value === undefined ? all : String( value );
	} );
};

( function () {
	'use strict';

	var cfg = window.AIPB_MODEL || {};
	var form = document.getElementById( 'aipb-model-form' );
	if ( ! form ) { return; }

	var D_TYPE = cfg.defaults && cfg.defaults.type ? cfg.defaults.type : {};
	var D_TAX = cfg.defaults && cfg.defaults.taxonomy ? cfg.defaults.taxonomy : {};
	var SLUG_MAX = cfg.slugMax || 20;
	var RESERVED = cfg.reserved || { types: [], taxonomies: [] };
	var COUNTS = cfg.counts || {};

	var uid = 0;

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

	var state = {
		types: ( cfg.types || [] ).map( function ( t ) { return hydrate( t, D_TYPE ); } ),
		taxonomies: ( cfg.taxonomies || [] ).map( function ( t ) { return hydrate( t, D_TAX ); } ),
		foreignTypes: ( ( cfg.foreign || {} ).types || [] ).map( function ( t ) {
			t.fields = ( t.fields || [] ).map( hydrateField );
			t._uid = 'ft' + ( ++uid );
			return t;
		} ),
		foreignTaxonomies: ( ( cfg.foreign || {} ).taxonomies || [] ).map( function ( t ) {
			t.fields = ( t.fields || [] ).map( hydrateField );
			t._uid = 'fx' + ( ++uid );
			return t;
		} ),
		// The types whose removal was accepted here, with their contents counted out loud. It is a
		// LIST and not a yes, and the difference matters: the server stops on any type that still
		// holds content, this page included the ones created in another tab five minutes ago — and a
		// blanket «yes» given for one type would wave those through too, unseen.
		confirmed: {},
		// The blanket yes, which the button in the server's own notice sets: there, the types being
		// asked about are on the screen and have just been read.
		confirmAll: !! cfg.confirmRemoval
	};

	var dirty = false;
	var open = {};

	/** A definition with every option present, so nothing below has to ask whether a key exists. */
	function hydrate( raw, defaults ) {
		var out = {};
		Object.keys( defaults ).forEach( function ( k ) {
			out[ k ] = ( raw && Object.prototype.hasOwnProperty.call( raw, k ) ) ? raw[ k ] : clone( defaults[ k ] );
		} );
		out.fields = ( ( raw && raw.fields ) || [] ).map( hydrateField );
		out._uid = 'i' + ( ++uid );
		// A type already in the database keeps its slug tied to every URL and every row: it is not
		// re-derived from the name, and the page says why when somebody edits it.
		out._existing = !! ( raw && raw.slug );
		return out;
	}

	function hydrateField( f ) {
		return {
			key: ( f && f.key ) || '',
			type: ( f && f.type ) || 'text',
			label: ( f && f.label ) || '',
			_uid: 'f' + ( ++uid ),
			// A field that arrives with a key has values saved under that key: the key stops
			// following the label, because renaming it would detach them.
			_saved: !! ( f && f.key )
		};
	}

	function clone( v ) { return Array.isArray( v ) ? v.slice() : v; }

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

	function el( tag, cls, text ) {
		var n = document.createElement( tag );
		if ( cls ) { n.className = cls; }
		if ( text !== undefined && text !== null ) { n.textContent = text; }
		return n;
	}

	function icon( name ) {
		var i = el( 'span', 'dashicons dashicons-' + name );
		i.setAttribute( 'aria-hidden', 'true' );
		return i;
	}

	function btn( cls, label, dashicon ) {
		var b = el( 'button', cls );
		b.type = 'button';
		if ( dashicon ) {
			b.appendChild( icon( dashicon ) );
			b.title = label;
			b.setAttribute( 'aria-label', label );
		} else {
			b.textContent = label;
		}
		return b;
	}

	/** «Perché no?» -> «perche-no»: the accents are stripped and not deleted, so a slug stays readable. */
	function plain( s ) {
		s = String( s === undefined || s === null ? '' : s ).toLowerCase();

		return s.normalize ? s.normalize( 'NFD' ).replace( /[\u0300-\u036f]/g, '' ) : s;
	}

	/** slug-ify: what AIPB_Types::clean_slug() does, so the page shows what will be saved. */
	function slugify( s ) {
		return plain( s )
			.replace( /[_\s]+/g, '-' )
			.replace( /[^a-z0-9-]/g, '' )
			.replace( /-+/g, '-' )
			.replace( /^-+|-+$/g, '' )
			.slice( 0, SLUG_MAX );
	}

	/** A meta key: lowercase, digits and underscores — AIPB_Fields::clean_key(). */
	function keyify( s ) {
		return plain( s )
			.replace( /[^a-z0-9_]/g, '_' )
			.replace( /_+/g, '_' )
			.replace( /^_+|_+$/g, '' );
	}

	function markDirty() {
		dirty = true;
		showState( __( 'Not saved yet' ), 'is-dirty' );
	}

	function showState( text, cls ) {
		var s = document.getElementById( 'aipb-model-state' );
		if ( ! s ) { return; }
		s.textContent = text;
		s.className = 'aipb-savebar-state' + ( cls ? ' ' + cls : '' );
	}

	/* ------------------------------- Controls -------------------------------- */

	/** A labelled field: label, control, and the line underneath that explains it. */
	function field( label, control, hint, wide ) {
		var wrap = el( 'div', 'aipb-fld' + ( wide ? ' is-wide' : '' ) );
		var lab = el( 'label', null, label );
		if ( ! control.id && /^(INPUT|SELECT|TEXTAREA)$/.test( control.tagName ) ) {
			control.id = 'aipb-c' + ( ++uid );
		}
		if ( control.id ) { lab.htmlFor = control.id; }
		wrap.appendChild( lab );
		wrap.appendChild( control );
		if ( hint ) {
			var h = el( 'p', 'aipb-hint', hint );
			wrap.appendChild( h );
		}
		return wrap;
	}

	function input( value, placeholder, mono ) {
		var i = el( 'input', 'aipb-input' + ( mono ? ' is-mono' : '' ) );
		i.type = 'text';
		i.value = value === undefined || value === null ? '' : value;
		if ( placeholder ) { i.placeholder = placeholder; }
		return i;
	}

	/**
	 * A switch. `onChange` receives the new value; the pane is redrawn afterwards because half of
	 * these options decide whether the others still mean anything.
	 */
	function toggle( item, key, label, hint, opts ) {
		opts = opts || {};
		var wrap = el( 'label', 'aipb-toggle' );
		var box = el( 'input' );
		box.type = 'checkbox';
		box.checked = !! item[ key ];
		box.disabled = !! opts.disabled;
		box.dataset.focus = item._uid + ':' + key;

		var sw = el( 'span', 'aipb-switch' );
		var txt = el( 'span', 'aipb-toggle-txt' );
		txt.appendChild( el( 'b', null, label ) );
		if ( hint ) { txt.appendChild( el( 'span', null, hint ) ); }

		box.addEventListener( 'change', function () {
			item[ key ] = box.checked;
			if ( opts.onChange ) { opts.onChange( box.checked ); }
			markDirty();
			render();
		} );

		wrap.appendChild( box );
		wrap.appendChild( sw );
		wrap.appendChild( txt );
		return wrap;
	}

	/** A group of switches, under a small heading. */
	function group( title, nodes ) {
		var frag = document.createDocumentFragment();
		frag.appendChild( el( 'h4', 'aipb-grouph', title ) );
		var box = el( 'div', 'aipb-toggles' );
		nodes.forEach( function ( n ) { box.appendChild( n ); } );
		frag.appendChild( box );
		return frag;
	}

	/** Multiple choice as chips: the supports of a type, the types a taxonomy applies to. */
	function chips( choices, selected, onToggle ) {
		var box = el( 'div', 'aipb-chips' );

		choices.forEach( function ( c ) {
			var on = selected.indexOf( c.key ) !== -1;
			var chip = el( 'label', 'aipb-chip' + ( on ? ' is-on' : '' ) );
			var cb = el( 'input' );
			cb.type = 'checkbox';
			cb.checked = on;
			if ( c.hint ) { chip.title = c.hint; }
			cb.addEventListener( 'change', function () {
				onToggle( c.key, cb.checked );
				markDirty();
				render();
			} );
			chip.appendChild( cb );
			chip.appendChild( document.createTextNode( c.label ) );
			box.appendChild( chip );
		} );

		return box;
	}

	/** The dashicon picker for the admin menu. */
	function iconPicker( item ) {
		var wrap = el( 'div', 'aipb-iconpick' );
		var button = el( 'button', 'aipb-iconpick-btn' );
		button.type = 'button';

		function paint() {
			button.textContent = '';
			button.appendChild( icon( String( item.menu_icon || 'dashicons-portfolio' ).replace( /^dashicons-/, '' ) ) );
			button.appendChild( document.createTextNode( __( 'Change icon' ) ) );
		}
		paint();

		var pop = el( 'div', 'aipb-iconpick-pop' );
		pop.hidden = true;
		( cfg.icons || [] ).forEach( function ( name ) {
			var opt = el( 'button', 'aipb-iconpick-opt' + ( name === item.menu_icon ? ' is-on' : '' ) );
			opt.type = 'button';
			opt.title = name;
			opt.appendChild( icon( name.replace( /^dashicons-/, '' ) ) );
			opt.addEventListener( 'click', function () {
				item.menu_icon = name;
				markDirty();
				render();
			} );
			pop.appendChild( opt );
		} );

		button.addEventListener( 'click', function () {
			var opening = pop.hidden;
			closePopups();
			pop.hidden = ! opening;
			wrap.classList.toggle( 'is-open', opening );
		} );

		wrap.appendChild( button );
		wrap.appendChild( pop );
		return wrap;
	}

	function closePopups() {
		document.querySelectorAll( '.aipb-iconpick-pop' ).forEach( function ( p ) { p.hidden = true; } );
		document.querySelectorAll( '.aipb-iconpick' ).forEach( function ( p ) { p.classList.remove( 'is-open' ); } );
	}

	document.addEventListener( 'click', function ( e ) {
		if ( ! e.target.closest || ! e.target.closest( '.aipb-iconpick' ) ) { closePopups(); }
	} );

	/* --------------------------------- Cards --------------------------------- */

	/**
	 * The shell every card shares: the head that can be clicked to open it, and the body.
	 *
	 * @param opts { icon, title, slug, badges: [ {text, cls} ], onDelete, body: fn(container) }
	 */
	function card( item, opts ) {
		var wrap = el( 'div', 'aipb-card' + ( open[ item._uid ] ? ' is-open' : '' ) + ( item._fresh ? ' is-new' : '' ) );
		wrap.dataset.uid = item._uid;

		var head = el( 'div', 'aipb-card-head' );

		if ( opts.onReorder ) {
			var grip = el( 'span', 'aipb-grip', '⠿' );
			grip.title = __( 'Drag to reorder' );
			grip.addEventListener( 'mousedown', function () { wrap.draggable = true; } );
			grip.addEventListener( 'click', function ( e ) { e.stopPropagation(); } );
			head.appendChild( grip );
		}

		var ico = el( 'span', 'aipb-card-ico' );
		ico.appendChild( icon( String( opts.icon || 'portfolio' ).replace( /^dashicons-/, '' ) ) );
		head.appendChild( ico );

		var title = el( 'div', 'aipb-card-title' );
		var name = el( 'b', null, opts.title );
		name.dataset.role = 'title';
		title.appendChild( name );
		var slug = el( 'span', 'aipb-slug', opts.slug );
		slug.dataset.role = 'slug';
		title.appendChild( slug );
		head.appendChild( title );

		var badges = el( 'div', 'aipb-card-badges' );
		badges.dataset.role = 'badges';
		paintBadges( badges, opts.badges || [] );
		head.appendChild( badges );

		var tools = el( 'div', 'aipb-card-tools' );
		if ( opts.onDelete ) {
			var del = btn( 'aipb-icon-btn is-danger', opts.deleteLabel || __( 'Delete' ), 'trash' );
			del.addEventListener( 'click', function ( e ) {
				e.stopPropagation();
				opts.onDelete();
			} );
			tools.appendChild( del );
		}
		var chev = btn( 'aipb-icon-btn aipb-chev', __( 'Open' ), 'arrow-down-alt2' );
		tools.appendChild( chev );
		head.appendChild( tools );

		head.setAttribute( 'role', 'button' );
		head.tabIndex = 0;
		head.setAttribute( 'aria-expanded', open[ item._uid ] ? 'true' : 'false' );

		function flip() {
			open[ item._uid ] = ! open[ item._uid ];
			render();
		}

		head.addEventListener( 'click', flip );
		head.addEventListener( 'keydown', function ( e ) {
			if ( 'Enter' === e.key || ' ' === e.key ) {
				e.preventDefault();
				flip();
			}
		} );

		wrap.appendChild( head );

		if ( open[ item._uid ] ) {
			var body = el( 'div', 'aipb-card-body' );
			opts.body( body );
			wrap.appendChild( body );
		}

		return wrap;
	}

	function paintBadges( box, list ) {
		box.textContent = '';
		list.forEach( function ( b ) {
			if ( ! b ) { return; }
			box.appendChild( el( 'span', 'aipb-badge' + ( b.cls ? ' ' + b.cls : '' ), b.text ) );
		} );
	}

	/** The [Basics][Fields][Settings] strip inside a card. */
	function subtabs( item, panes ) {
		var frag = document.createDocumentFragment();
		var strip = el( 'div', 'aipb-subtabs' );
		var bodies = el( 'div' );

		var current = open[ item._uid + ':tab' ] || panes[ 0 ].key;

		panes.forEach( function ( pane ) {
			var tab = el( 'button', 'aipb-subtab' + ( pane.key === current ? ' is-on' : '' ), pane.label );
			tab.type = 'button';
			tab.addEventListener( 'click', function () {
				open[ item._uid + ':tab' ] = pane.key;
				render();
			} );
			strip.appendChild( tab );

			if ( pane.key === current ) {
				var body = el( 'div', 'aipb-subpane is-on' );
				pane.build( body );
				bodies.appendChild( body );
			}
		} );

		frag.appendChild( strip );
		frag.appendChild( bodies );
		return frag;
	}

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

	function typeCard( type ) {
		var count = COUNTS[ type.slug ] || 0;

		return card( type, {
			icon: type.menu_icon || 'dashicons-portfolio',
			title: type.plural || type.singular || __( 'New type' ),
			slug: type.slug || '…',
			badges: typeBadges( type, count ),
			onReorder: true,
			deleteLabel: __( 'Delete this content type' ),
			onDelete: function () { removeType( type, count ); },
			body: function ( body ) {
				body.appendChild( subtabs( type, [
					{
						key: 'basics',
						label: __( 'Basics' ),
						build: function ( pane ) { typeBasics( type, pane ); }
					},
					{
						key: 'fields',
						label: _sprintf( __( 'Fields (%d)' ), type.fields.length ),
						build: function ( pane ) { fieldEditor( type, pane, 'post' ); }
					},
					{
						key: 'settings',
						label: __( 'Settings' ),
						build: function ( pane ) { typeSettings( type, pane ); }
					}
				] ) );
			}
		} );
	}

	function typeBadges( type, count ) {
		var out = [];
		if ( type.archive ) { out.push( { text: __( 'Archive' ), cls: 'is-accent' } ); }
		if ( type.hierarchical ) { out.push( { text: __( 'Hierarchical' ) } ); }
		if ( ! type.public ) { out.push( { text: __( 'Private' ), cls: 'is-warn' } ); }
		if ( type.fields.length ) {
			out.push( { text: _sprintf( __( '%d fields' ), type.fields.length ) } );
		}
		if ( count ) {
			out.push( { text: _sprintf( __( '%d contents' ), count ), cls: 'is-quiet' } );
		}
		return out;
	}

	function typeBasics( type, pane ) {
		var grid = el( 'div', 'aipb-grid' );

		var singular = input( type.singular, __( 'Product' ) );
		singular.addEventListener( 'input', function () {
			type.singular = singular.value;
			// The slug follows the name only while the type does not exist yet and nobody has
			// written one by hand: afterwards it is an address, not a label.
			if ( ! type._existing && ! type._slugLocked ) {
				type.slug = slugify( singular.value );
				slugBox.value = type.slug;
			}
			patchCard( type, type.plural || type.singular, type.slug );
			markDirty();
		} );
		grid.appendChild( field( __( 'Singular name' ), singular, __( 'How one of them is called: «Product».' ) ) );

		var plural = input( type.plural, __( 'Products' ) );
		plural.addEventListener( 'input', function () {
			type.plural = plural.value;
			patchCard( type, type.plural || type.singular, type.slug );
			markDirty();
		} );
		grid.appendChild( field( __( 'Plural name' ), plural, __( 'How the whole set is called, in the menu and in the lists.' ) ) );

		var slugBox = input( type.slug, 'product', true );
		slugBox.maxLength = SLUG_MAX;
		slugBox.addEventListener( 'input', function () {
			type._slugLocked = true;
			type.slug = slugify( slugBox.value );
			patchCard( type, type.plural || type.singular, type.slug );
			markDirty();
		} );
		// The value is normalised on the way out, but the page is NOT redrawn: rebuilding the card
		// under a click that is on its way to the next field means that click lands on nothing.
		slugBox.addEventListener( 'blur', function () { slugBox.value = type.slug; } );

		var slugHint = type._existing
			? __( 'It is in the address of every content of this type and in every row in the database. Changing it is the same as deleting one type and creating another: the old contents stay where they are, invisible until the old slug comes back.' )
			: _sprintf( __( 'Lowercase, digits and hyphens, at most %d characters. It is what ends up in the URL.' ), SLUG_MAX );
		var slugField = field( __( 'Slug' ), slugBox, slugHint );
		if ( type._existing ) { slugField.querySelector( '.aipb-hint' ).classList.add( 'is-warn' ); }
		grid.appendChild( slugField );

		var desc = input( type.description, __( 'Optional — shown in the admin lists.' ) );
		desc.addEventListener( 'input', function () { type.description = desc.value; markDirty(); } );
		grid.appendChild( field( __( 'Description' ), desc ) );

		pane.appendChild( grid );

		// An untouched card is not a mistake, and saying so would be scolding somebody for a
		// button they have only just pressed: it is dropped on saving, in silence.
		var problem = untouched( type ) ? '' : typeProblem( type );
		if ( problem ) {
			pane.appendChild( el( 'p', 'aipb-note is-warn', problem ) );
		}
	}

	function typeSettings( type, pane ) {
		pane.appendChild( group( __( 'Visibility' ), [
			toggle( type, 'public', __( 'Public' ), __( 'Visible on the site and in the admin. Turn it off for contents that only feed a section — they stay editable, but have no page of their own.' ) ),
			toggle( type, 'publicly_queryable', __( 'Reachable on the front end' ), __( 'It has an address of its own that answers.' ), { disabled: ! type.public } ),
			toggle( type, 'show_ui', __( 'Editable in the admin' ), __( 'It has its own screens for creating and editing.' ) ),
			toggle( type, 'show_in_menu', __( 'In the admin menu' ), __( 'Its own entry in the left-hand menu.' ), { disabled: ! type.show_ui } ),
			toggle( type, 'show_in_nav_menus', __( 'Available in the menus' ), __( 'Its contents can be added to the site\'s navigation menus.' ) ),
			toggle( type, 'show_in_rest', __( 'In the REST API' ), __( 'Needed by the block editor: with this off the content opens in the old editor.' ) ),
			toggle( type, 'exclude_from_search', __( 'Out of the site search' ), __( 'Its contents do not appear among the search results.' ) )
		] ) );

		pane.appendChild( group( __( 'Archive and addresses' ), [
			toggle( type, 'archive', __( 'Archive page' ), _sprintf(
				/* translators: %s: the address the archive answers on. */
				__( 'The page that lists them all, at /%s. Without it every single content still has a page of its own.' ),
				type.archive_slug || type.slug || 'slug'
			) ),
			toggle( type, 'rewrite', __( 'Readable addresses' ), __( 'Off, the contents answer only on ?post_type=… — almost never what you want.' ) ),
			toggle( type, 'with_front', __( 'Behind the blog prefix' ), __( 'Puts the permalink base of the posts in front (e.g. /blog/…).' ), { disabled: ! type.rewrite } )
		] ) );

		var addr = el( 'div', 'aipb-grid' );

		var archiveSlug = input( type.archive_slug, type.slug || 'products', true );
		archiveSlug.disabled = ! type.archive;
		archiveSlug.addEventListener( 'input', function () { type.archive_slug = archiveSlug.value; markDirty(); } );
		addr.appendChild( field( __( 'Archive address' ), archiveSlug, __( 'Empty: the slug is used. Fill it in to put the archive somewhere else, e.g. «shop».' ) ) );

		var rewriteSlug = input( type.rewrite_slug, type.slug || 'product', true );
		rewriteSlug.disabled = ! type.rewrite;
		rewriteSlug.addEventListener( 'input', function () { type.rewrite_slug = rewriteSlug.value; markDirty(); } );
		addr.appendChild( field( __( 'Base of the single content' ), rewriteSlug, __( 'Empty: the slug is used. It accepts a path, e.g. «shop/product».' ) ) );

		pane.appendChild( addr );

		pane.appendChild( el( 'h4', 'aipb-grouph', __( 'Structure' ) ) );
		var structure = el( 'div', 'aipb-toggles' );
		structure.appendChild( toggle( type, 'hierarchical', __( 'Hierarchical' ), __( 'Like pages: a content can have a parent. It needs the «Attributes» support to be chosen from the editor.' ) ) );
		pane.appendChild( structure );

		pane.appendChild( el( 'p', 'aipb-hint', __( 'What the editing screen offers:' ) ) );
		pane.appendChild( chips(
			( cfg.supports || [] ).map( function ( s ) { return { key: s.key, label: s.label, hint: s.hint }; } ),
			type.supports || [],
			function ( key, on ) { type.supports = pick( type.supports, key, on ); }
		) );

		var taxChoices = taxonomyChoices( type.slug );
		if ( taxChoices.length ) {
			pane.appendChild( el( 'p', 'aipb-hint', __( 'Taxonomies registered elsewhere to attach to this type (the ones defined here already say which types they apply to):' ) ) );
			pane.appendChild( chips(
				taxChoices,
				type.taxonomies || [],
				function ( key, on ) { type.taxonomies = pick( type.taxonomies, key, on ); }
			) );
		}

		pane.appendChild( el( 'h4', 'aipb-grouph', __( 'Admin menu' ) ) );
		var menu = el( 'div', 'aipb-grid' );
		menu.appendChild( field( __( 'Icon' ), iconPicker( type ) ) );

		var position = el( 'select', 'aipb-input' );
		[
			[ '', __( 'Where WordPress puts it' ) ],
			[ '5', __( 'After Posts' ) ],
			[ '21', __( 'After Pages' ) ],
			[ '26', __( 'After Comments' ) ],
			[ '61', __( 'After Appearance' ) ],
			[ '81', __( 'Near the bottom' ) ]
		].forEach( function ( opt ) {
			var o = el( 'option', null, opt[ 1 ] );
			o.value = opt[ 0 ];
			if ( String( type.menu_position ) === opt[ 0 ] ) { o.selected = true; }
			position.appendChild( o );
		} );
		position.addEventListener( 'change', function () {
			type.menu_position = position.value;
			markDirty();
			render();
		} );
		menu.appendChild( field( __( 'Position' ), position ) );

		pane.appendChild( menu );
	}

	/** Adds or removes a value from a list, without touching the original. */
	function pick( list, value, on ) {
		var out = ( list || [] ).slice();
		var at = out.indexOf( value );
		if ( on && at === -1 ) { out.push( value ); }
		if ( ! on && at !== -1 ) { out.splice( at, 1 ); }
		return out;
	}

	/** The taxonomies this page does not manage, offered to be attached to a type. */
	function taxonomyChoices() {
		return state.foreignTaxonomies.map( function ( t ) {
			return { key: t.slug, label: t.label + ' (' + t.slug + ')' };
		} );
	}

	/* -------------------------------- Taxonomies ----------------------------- */

	function taxonomyCard( tax ) {
		return card( tax, {
			icon: tax.hierarchical ? 'category' : 'tag',
			title: tax.plural || tax.singular || __( 'New taxonomy' ),
			slug: tax.slug || '…',
			badges: taxonomyBadges( tax ),
			onReorder: true,
			deleteLabel: __( 'Delete this taxonomy' ),
			onDelete: function () { removeTaxonomy( tax ); },
			body: function ( body ) {
				body.appendChild( subtabs( tax, [
					{
						key: 'basics',
						label: __( 'Basics' ),
						build: function ( pane ) { taxonomyBasics( tax, pane ); }
					},
					{
						key: 'fields',
						label: _sprintf( __( 'Fields (%d)' ), tax.fields.length ),
						build: function ( pane ) { fieldEditor( tax, pane, 'term' ); }
					},
					{
						key: 'settings',
						label: __( 'Settings' ),
						build: function ( pane ) { taxonomySettings( tax, pane ); }
					}
				] ) );
			}
		} );
	}

	function taxonomyBadges( tax ) {
		var out = [ { text: tax.hierarchical ? __( 'Like categories' ) : __( 'Like tags' ), cls: 'is-accent' } ];
		var on = ( tax.types || [] ).join( ', ' );
		if ( on ) { out.push( { text: on } ); }
		if ( tax.fields.length ) { out.push( { text: _sprintf( __( '%d fields' ), tax.fields.length ) } ); }
		return out;
	}

	function taxonomyBasics( tax, pane ) {
		var grid = el( 'div', 'aipb-grid' );

		var singular = input( tax.singular, __( 'Category' ) );
		singular.addEventListener( 'input', function () {
			tax.singular = singular.value;
			if ( ! tax._existing && ! tax._slugLocked ) {
				tax.slug = slugify( singular.value );
				slugBox.value = tax.slug;
			}
			patchCard( tax, tax.plural || tax.singular, tax.slug );
			markDirty();
		} );
		grid.appendChild( field( __( 'Singular name' ), singular ) );

		var plural = input( tax.plural, __( 'Categories' ) );
		plural.addEventListener( 'input', function () {
			tax.plural = plural.value;
			patchCard( tax, tax.plural || tax.singular, tax.slug );
			markDirty();
		} );
		grid.appendChild( field( __( 'Plural name' ), plural ) );

		var slugBox = input( tax.slug, 'product-category', true );
		slugBox.maxLength = SLUG_MAX;
		slugBox.addEventListener( 'input', function () {
			tax._slugLocked = true;
			tax.slug = slugify( slugBox.value );
			patchCard( tax, tax.plural || tax.singular, tax.slug );
			markDirty();
		} );
		slugBox.addEventListener( 'blur', function () { slugBox.value = tax.slug; } );
		grid.appendChild( field( __( 'Slug' ), slugBox, __( 'It is in the address of the term archive.' ) ) );

		var desc = input( tax.description, __( 'Optional.' ) );
		desc.addEventListener( 'input', function () { tax.description = desc.value; markDirty(); } );
		grid.appendChild( field( __( 'Description' ), desc ) );

		pane.appendChild( grid );

		pane.appendChild( el( 'h4', 'aipb-grouph', __( 'It applies to' ) ) );
		var choices = state.types.filter( function ( t ) { return t.slug; } ).map( function ( t ) {
			return { key: t.slug, label: ( t.plural || t.slug ) };
		} ).concat( state.foreignTypes.map( function ( t ) {
			return { key: t.slug, label: t.label };
		} ) );
		pane.appendChild( chips( choices, tax.types || [], function ( key, on ) {
			tax.types = pick( tax.types, key, on );
		} ) );

		var problem = untouched( tax ) ? '' : taxonomyProblem( tax );
		if ( problem ) { pane.appendChild( el( 'p', 'aipb-note is-warn', problem ) ); }
	}

	function taxonomySettings( tax, pane ) {
		pane.appendChild( group( __( 'Behaviour' ), [
			toggle( tax, 'hierarchical', __( 'Hierarchical' ), __( 'Like categories: terms can be nested and are chosen from a list of checkboxes. Off, they behave like tags.' ) ),
			toggle( tax, 'show_admin_column', __( 'Column in the lists' ), __( 'A column in the admin list of the contents it applies to.' ) ),
			toggle( tax, 'show_tagcloud', __( 'Tag cloud' ), __( 'It can be used by the tag cloud widget.' ) )
		] ) );

		pane.appendChild( group( __( 'Visibility' ), [
			toggle( tax, 'public', __( 'Public' ), __( 'Its terms have an archive page on the site.' ) ),
			toggle( tax, 'show_ui', __( 'Editable in the admin' ), null ),
			toggle( tax, 'show_in_menu', __( 'In the admin menu' ), null, { disabled: ! tax.show_ui } ),
			toggle( tax, 'show_in_nav_menus', __( 'Available in the menus' ), null ),
			toggle( tax, 'show_in_rest', __( 'In the REST API' ), __( 'Needed to choose the terms from the block editor.' ) )
		] ) );

		pane.appendChild( group( __( 'Addresses' ), [
			toggle( tax, 'rewrite', __( 'Readable addresses' ), null ),
			toggle( tax, 'with_front', __( 'Behind the blog prefix' ), null, { disabled: ! tax.rewrite } )
		] ) );

		var grid = el( 'div', 'aipb-grid' );
		var rewriteSlug = input( tax.rewrite_slug, tax.slug || 'category', true );
		rewriteSlug.disabled = ! tax.rewrite;
		rewriteSlug.addEventListener( 'input', function () { tax.rewrite_slug = rewriteSlug.value; markDirty(); } );
		grid.appendChild( field( __( 'Base of the term archive' ), rewriteSlug, __( 'Empty: the slug is used.' ) ) );
		pane.appendChild( grid );
	}

	/* ---------------------------------- Fields -------------------------------- */

	/**
	 * The field list of a type or of a taxonomy.
	 *
	 * The template placeholder next to each row is not decoration: it is the reason the field
	 * exists, and having to remember whether it is `post.` or `field.` is exactly the sort of thing
	 * that sends people back to the documentation for one word.
	 */
	function fieldEditor( owner, pane, kind ) {
		var prefix = 'term' === kind ? 'term' : 'post';

		pane.appendChild( el( 'p', 'aipb-note',
			'term' === kind
				? __( 'The fields of a term: they are filled in on the page where the term is edited, and print in the templates with term.acf_name.' )
				: __( 'The values are saved as ordinary meta, under the same key: they stay readable without this plugin, and if the site has ACF then ACF wins.' )
		) );

		var list = el( 'div', 'aipb-fieldlist' );

		var head = el( 'div', 'aipb-fieldhead' );
		[ '', __( 'Label' ), __( 'Key' ), __( 'Type' ), __( 'In the template' ), '' ].forEach( function ( t ) {
			head.appendChild( el( 'span', null, t ) );
		} );
		list.appendChild( head );

		owner.fields.forEach( function ( f, i ) {
			list.appendChild( fieldRow( owner, f, i, prefix ) );
		} );

		if ( ! owner.fields.length ) {
			var empty = el( 'p', 'aipb-hint', __( 'No fields yet. A field is a value the content carries — a price, a date, a link — that the AI can print in a template.' ) );
			list.appendChild( empty );
		}

		pane.appendChild( list );

		var add = el( 'button', 'aipb-addrow' );
		add.type = 'button';
		add.appendChild( icon( 'plus-alt2' ) );
		add.appendChild( document.createTextNode( __( 'Add field' ) ) );
		add.addEventListener( 'click', function () {
			owner.fields.push( hydrateField( {} ) );
			markDirty();
			render();
		} );
		pane.appendChild( add );

		enableFieldDrag( list, owner );
	}

	function fieldRow( owner, f, index, prefix ) {
		var row = el( 'div', 'aipb-fieldrow' );
		row.dataset.index = index;

		var grip = el( 'span', 'aipb-grip', '⠿' );
		grip.title = __( 'Drag to reorder' );
		grip.addEventListener( 'mousedown', function () { row.draggable = true; } );
		row.appendChild( grip );

		var label = input( f.label, __( 'Price' ) );
		label.dataset.focus = f._uid + ':label';
		row.appendChild( label );

		var key = input( f.key, 'price', true );
		key.dataset.focus = f._uid + ':key';
		row.appendChild( key );

		var type = el( 'select', 'aipb-input' );
		( cfg.fieldTypes || [] ).forEach( function ( t ) {
			var o = el( 'option', null, t.label );
			o.value = t.key;
			if ( t.key === f.type ) { o.selected = true; }
			type.appendChild( o );
		} );
		type.addEventListener( 'change', function () { f.type = type.value; markDirty(); } );
		row.appendChild( type );

		var tpl = el( 'button', 'aipb-tpl' );
		tpl.type = 'button';
		var tplText = el( 'span' );
		tpl.appendChild( tplText );
		tpl.appendChild( icon( 'admin-page' ) );
		tpl.title = __( 'Copy the placeholder' );

		function paintTemplate() {
			tplText.textContent = '{{t:' + prefix + '.acf_' + ( f.key || 'key' ) + '}}';
		}
		paintTemplate();

		tpl.addEventListener( 'click', function () {
			var text = tplText.textContent;
			if ( navigator.clipboard ) { navigator.clipboard.writeText( text ); }
			tplText.textContent = __( 'Copied' );
			setTimeout( paintTemplate, 900 );
		} );
		row.appendChild( tpl );

		label.addEventListener( 'input', function () {
			f.label = label.value;
			// The key follows the label until somebody writes one: renaming a key afterwards would
			// detach the values already saved under the old one, so it is never done for them.
			if ( ! f._keyLocked && ! f._saved ) {
				f.key = keyify( label.value );
				key.value = f.key;
				paintTemplate();
			}
			markDirty();
		} );

		key.addEventListener( 'input', function () {
			f._keyLocked = true;
			f.key = keyify( key.value );
			paintTemplate();
			markDirty();
		} );
		key.addEventListener( 'blur', function () { key.value = f.key; } );

		var del = btn( 'aipb-icon-btn is-danger', __( 'Remove this field' ), 'no-alt' );
		del.addEventListener( 'click', function () {
			owner.fields.splice( index, 1 );
			markDirty();
			render();
		} );
		row.appendChild( del );

		return row;
	}

	/* ------------------------------- Reordering ------------------------------- */

	/** Drag and drop over a list of rows, moving the item in the state and redrawing. */
	function enableDrag( container, rowSelector, list, onDone ) {
		var from = null;

		container.addEventListener( 'dragstart', function ( e ) {
			var row = e.target.closest ? e.target.closest( rowSelector ) : null;
			if ( ! row ) { return; }
			from = Number( row.dataset.index !== undefined ? row.dataset.index : indexOfCard( container, rowSelector, row ) );
			row.classList.add( 'is-dragging' );
			if ( e.dataTransfer ) { e.dataTransfer.effectAllowed = 'move'; }
		} );

		container.addEventListener( 'dragover', function ( e ) {
			if ( from === null ) { return; }
			e.preventDefault();
			container.querySelectorAll( '.is-over' ).forEach( function ( n ) { n.classList.remove( 'is-over' ); } );
			var over = e.target.closest ? e.target.closest( rowSelector ) : null;
			if ( over ) { over.classList.add( 'is-over' ); }
		} );

		container.addEventListener( 'drop', function ( e ) {
			if ( from === null ) { return; }
			e.preventDefault();
			var over = e.target.closest ? e.target.closest( rowSelector ) : null;
			if ( over ) {
				var to = Number( over.dataset.index !== undefined ? over.dataset.index : indexOfCard( container, rowSelector, over ) );
				if ( ! isNaN( to ) && to !== from ) {
					list.splice( to, 0, list.splice( from, 1 )[ 0 ] );
					markDirty();
					if ( onDone ) { onDone(); }
					render();
				}
			}
			from = null;
		} );

		container.addEventListener( 'dragend', function () {
			from = null;
			container.querySelectorAll( rowSelector ).forEach( function ( n ) {
				n.draggable = false;
				n.classList.remove( 'is-dragging', 'is-over' );
			} );
		} );
	}

	function indexOfCard( container, selector, node ) {
		return Array.prototype.indexOf.call( container.querySelectorAll( selector ), node );
	}

	function enableFieldDrag( list, owner ) {
		enableDrag( list, '.aipb-fieldrow', owner.fields );
	}

	/* ------------------------------ Add and remove ---------------------------- */

	function addType() {
		var type = hydrate( {}, D_TYPE );
		type._existing = false;
		type._fresh = true;
		state.types.push( type );
		open[ type._uid ] = true;
		markDirty();
		render();
		focusFirst( type._uid );
	}

	function addTaxonomy() {
		var tax = hydrate( {}, D_TAX );
		tax._existing = false;
		tax._fresh = true;
		// A taxonomy attached to nothing is registered and shows up nowhere: the first type on the
		// page is a better starting guess than an empty list.
		if ( state.types.length && state.types[ 0 ].slug ) { tax.types = [ state.types[ 0 ].slug ]; }
		state.taxonomies.push( tax );
		open[ tax._uid ] = true;
		markDirty();
		render();
		focusFirst( tax._uid );
	}

	function focusFirst( cardUid ) {
		var node = document.querySelector( '.aipb-card[data-uid="' + cardUid + '"] .aipb-input' );
		if ( node ) { node.focus(); }
	}

	function removeType( type, count ) {
		var name = type.plural || type.singular || type.slug || __( 'this type' );
		var message = count
			? _sprintf(
				__( '«%1$s» holds %2$d contents. Removing the type does NOT delete them: they stay in the database and disappear from the admin, and an old link answers «Invalid post type». Putting the type back brings them all back. Remove it?' ),
				name, count
			)
			: _sprintf( __( 'Remove «%s»? Nothing is deleted from the database.' ), name );

		if ( ! window.confirm( message ) ) { return; }

		if ( count ) { state.confirmed[ type.slug ] = true; }
		state.types = state.types.filter( function ( t ) { return t !== type; } );
		markDirty();
		render();
	}

	function removeTaxonomy( tax ) {
		var name = tax.plural || tax.singular || tax.slug || __( 'this taxonomy' );
		if ( ! window.confirm( _sprintf( __( 'Remove «%s»? The terms already created stay in the database.' ), name ) ) ) { return; }
		state.taxonomies = state.taxonomies.filter( function ( t ) { return t !== tax; } );
		markDirty();
		render();
	}

	/* -------------------------------- Validation ------------------------------ */

	function typeProblem( type ) {
		if ( ! type.slug ) { return __( 'Without a slug this type cannot be saved.' ); }
		if ( ( RESERVED.types || [] ).indexOf( type.slug ) !== -1 ) {
			return _sprintf( __( '«%s» belongs to WordPress: choose another slug.' ), type.slug );
		}
		if ( twice( state.types, type ) ) {
			return _sprintf( __( 'There is already a type with the slug «%s»: only one of the two would be saved.' ), type.slug );
		}
		return '';
	}

	function taxonomyProblem( tax ) {
		if ( ! tax.slug ) { return __( 'Without a slug this taxonomy cannot be saved.' ); }
		if ( ( RESERVED.taxonomies || [] ).indexOf( tax.slug ) !== -1 ) {
			return _sprintf( __( '«%s» belongs to WordPress: choose another slug.' ), tax.slug );
		}
		if ( twice( state.taxonomies, tax ) ) {
			return _sprintf( __( 'There is already a taxonomy with the slug «%s»: only one of the two would be saved.' ), tax.slug );
		}
		if ( ! ( tax.types || [] ).length ) {
			return __( 'A taxonomy attached to no content type is registered and appears nowhere.' );
		}
		return '';
	}

	function twice( list, item ) {
		return list.filter( function ( other ) { return other.slug === item.slug; } ).length > 1;
	}

	/**
	 * A card nobody typed anything into.
	 *
	 * «Add» pressed and then thought better of is not a decision to be reported as an error: it is
	 * dropped on saving, and the card stays on the screen in the meantime in case it was a pause and
	 * not a change of mind.
	 */
	function untouched( item ) {
		return ! item.slug && ! item.singular && ! item.plural && ! item.description && ! item.fields.length;
	}

	/** Every problem on the page, so the save says what is wrong before trying. */
	function problems() {
		var out = [];
		state.types.forEach( function ( t ) {
			var p = untouched( t ) ? '' : typeProblem( t );
			if ( p ) { out.push( ( t.plural || t.singular || __( 'A type' ) ) + ' — ' + p ); }
		} );
		state.taxonomies.forEach( function ( t ) {
			var p = untouched( t ) ? '' : taxonomyProblem( t );
			if ( p ) { out.push( ( t.plural || t.singular || __( 'A taxonomy' ) ) + ' — ' + p ); }
		} );
		return out;
	}

	/* -------------------------------- Rendering ------------------------------- */

	function patchCard( item, title, slug ) {
		var node = document.querySelector( '.aipb-card[data-uid="' + item._uid + '"]' );
		if ( ! node ) { return; }
		var t = node.querySelector( '[data-role="title"]' );
		var s = node.querySelector( '[data-role="slug"]' );
		if ( t ) { t.textContent = title || __( 'Untitled' ); }
		if ( s ) { s.textContent = slug || '…'; }
	}

	function pane( name ) {
		return document.querySelector( '.aipb-model-pane[data-pane="' + name + '"]' );
	}

	function render() {
		var focus = document.activeElement && document.activeElement.dataset
			? document.activeElement.dataset.focus
			: null;

		renderTypes();
		renderTaxonomies();
		renderCounts();

		if ( focus ) {
			var back = document.querySelector( '[data-focus="' + focus + '"]' );
			if ( back ) { back.focus(); }
		}
	}

	function renderCounts() {
		var t = document.querySelector( '.aipb-pill[data-count="types"]' );
		var x = document.querySelector( '.aipb-pill[data-count="taxonomies"]' );
		if ( t ) { t.textContent = state.types.length; }
		if ( x ) { x.textContent = state.taxonomies.length; }
	}

	function renderTypes() {
		var root = pane( 'types' );
		root.textContent = '';

		root.appendChild( sectionHead(
			__( 'Managed by Aura' ),
			__( 'Types registered by this plugin. Everything here is editable: what they are called, where they answer, what the editing screen offers.' ),
			__( 'Add content type' ),
			addType
		) );

		if ( ! state.types.length ) {
			root.appendChild( emptyState(
				'portfolio',
				__( 'No content type yet' ),
				__( 'Products, references, team members, events: whatever this site is made of and is not a page. Create one here, or describe the site and let the AI propose them.' ),
				__( 'Add the first type' ),
				addType
			) );
		} else {
			var cards = el( 'div', 'aipb-cards' );
			state.types.forEach( function ( type ) { cards.appendChild( typeCard( type ) ); } );
			enableDrag( cards, '.aipb-card', state.types );
			root.appendChild( cards );
		}

		if ( state.foreignTypes.length ) {
			root.appendChild( sectionHead(
				__( 'Registered elsewhere' ),
				__( 'WordPress\'s own types and the ones other plugins register. Their settings belong to whoever created them — here you can give them fields.' )
			) );
			var foreign = el( 'div', 'aipb-cards' );
			state.foreignTypes.forEach( function ( type ) { foreign.appendChild( foreignCard( type, 'post' ) ); } );
			root.appendChild( foreign );
		}
	}

	function renderTaxonomies() {
		var root = pane( 'taxonomies' );
		root.textContent = '';

		root.appendChild( sectionHead(
			__( 'Managed by Aura' ),
			__( 'The ways of sorting the contents: categories, tags, collections. Each one says which types it applies to and how it behaves.' ),
			__( 'Add taxonomy' ),
			addTaxonomy
		) );

		if ( ! state.taxonomies.length ) {
			root.appendChild( emptyState(
				'category',
				__( 'No taxonomy yet' ),
				__( 'A taxonomy sorts a content type: product categories, event places, article topics. It is what an archive page loops over.' ),
				__( 'Add the first taxonomy' ),
				addTaxonomy
			) );
		} else {
			var cards = el( 'div', 'aipb-cards' );
			state.taxonomies.forEach( function ( tax ) { cards.appendChild( taxonomyCard( tax ) ); } );
			enableDrag( cards, '.aipb-card', state.taxonomies );
			root.appendChild( cards );
		}

		if ( state.foreignTaxonomies.length ) {
			root.appendChild( sectionHead(
				__( 'Registered elsewhere' ),
				__( 'Categories, tags and whatever another plugin registers. Here you can give their terms fields — a cover image for a category, for instance.' )
			) );
			var foreign = el( 'div', 'aipb-cards' );
			state.foreignTaxonomies.forEach( function ( tax ) { foreign.appendChild( foreignCard( tax, 'term' ) ); } );
			root.appendChild( foreign );
		}
	}

	/** A type or taxonomy somebody else registered: its fields, and nothing else. */
	function foreignCard( item, kind ) {
		var badges = [];
		if ( item.count ) { badges.push( { text: _sprintf( __( '%d contents' ), item.count ), cls: 'is-quiet' } ); }
		if ( item.fields.length ) { badges.push( { text: _sprintf( __( '%d fields' ), item.fields.length ) } ); }

		return card( item, {
			icon: item.icon || ( 'term' === kind ? 'category' : 'admin-post' ),
			title: item.label,
			slug: item.slug,
			badges: badges,
			body: function ( body ) { fieldEditor( item, body, kind ); }
		} );
	}

	function sectionHead( title, description, buttonLabel, onClick ) {
		var head = el( 'div', 'aipb-sec-head' );
		var left = el( 'div' );
		left.appendChild( el( 'h2', null, title ) );
		left.appendChild( el( 'p', null, description ) );
		head.appendChild( left );

		if ( buttonLabel ) {
			var b = btn( 'button button-primary', buttonLabel );
			b.addEventListener( 'click', onClick );
			head.appendChild( b );
		}

		return head;
	}

	function emptyState( dashicon, title, text, buttonLabel, onClick ) {
		var box = el( 'div', 'aipb-empty' );
		box.appendChild( icon( dashicon ) );
		box.appendChild( el( 'h3', null, title ) );
		box.appendChild( el( 'p', null, text ) );
		var b = btn( 'button button-primary', buttonLabel );
		b.addEventListener( 'click', onClick );
		box.appendChild( b );
		return box;
	}

	/* ---------------------------------- Tabs ---------------------------------- */

	document.querySelectorAll( '.aipb-model-tab' ).forEach( function ( tab ) {
		tab.addEventListener( 'click', function () {
			document.querySelectorAll( '.aipb-model-tab' ).forEach( function ( t ) {
				t.classList.toggle( 'is-on', t === tab );
				t.setAttribute( 'aria-selected', t === tab ? 'true' : 'false' );
			} );
			document.querySelectorAll( '.aipb-model-pane' ).forEach( function ( p ) {
				p.classList.toggle( 'is-on', p.dataset.pane === tab.dataset.tab );
			} );
		} );
	} );

	/* --------------------------- Propose from the brief ----------------------- */

	/**
	 * «Describe the site, I will tell you which contents you need».
	 *
	 * The proposal is NOT saved: it fills in the cards below and stays there until somebody presses
	 * Save. It is the same pattern as the design token import, for the same reason: a slug ends up
	 * in the URL of every content of that type and a key is the name of a meta in the database. They
	 * are decisions that outlive the plugin, and it is not the model's job to take them alone.
	 *
	 * What is already there is never touched: the proposal only ADDS what is missing, and deleting
	 * somebody's work to fill in a form is the kind of favour nobody asks for.
	 */
	function renderBrief() {
		var root = pane( 'brief' );
		root.textContent = '';

		var box = el( 'div', 'aipb-brief' );
		box.appendChild( el( 'h2', null, __( 'Propose from the brief' ) ) );
		box.appendChild( el( 'p', 'description', __( 'Describe the site the way you would describe it to the client: what it sells or tells, what a visitor should be able to browse. The proposal lands in the cards — you read it, you correct it, and you save it.' ) ) );

		var area = el( 'textarea', 'large-text' );
		area.rows = 5;
		area.placeholder = __( 'Artisan bakery: cakes to order, a price list that changes with the seasons, the stories of the customers ordering for weddings, and the shops.' );
		box.appendChild( area );

		var actions = el( 'div', 'aipb-brief-actions' );
		var run = btn( 'button button-primary', __( 'Propose from the brief' ) );
		var status = el( 'span', 'aipb-brief-status' );
		actions.appendChild( run );
		actions.appendChild( status );
		box.appendChild( actions );

		var notes = el( 'div', 'aipb-brief-notes' );
		notes.hidden = true;
		box.appendChild( notes );

		function say( text, cls ) {
			status.textContent = text;
			status.className = 'aipb-brief-status' + ( cls ? ' ' + cls : '' );
		}

		run.addEventListener( 'click', function () {
			if ( run.disabled ) { return; }
			if ( area.value.trim().length < 20 ) {
				say( __( 'Write a few lines more: two words are not enough to tell which contents are needed.' ), 'is-bad' );
				return;
			}

			run.disabled = true;
			say( __( 'Thinking… it can take a minute.' ), 'is-busy' );

			fetch( cfg.rest + '/model/propose', {
				method: 'POST',
				headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': cfg.nonce },
				body: JSON.stringify( { brief: area.value } )
			} ).then( function ( r ) {
				return r.json().catch( function () { return {}; } ).then( function ( d ) {
					if ( ! r.ok ) { throw new Error( ( d && d.message ) || ( 'HTTP ' + r.status ) ); }
					return d;
				} );
			} ).then( function ( d ) {
				run.disabled = false;
				var added = pour( d );
				paintNotes( notes, d );
				say(
					added
						? _sprintf( __( '%d entries added below. Review them and press Save.' ), added )
						: __( 'Nothing to add: the site already has the types and fields it needs.' ),
					added ? 'is-ok' : ''
				);
				if ( added ) { goToTypes(); }
			} ).catch( function ( e ) {
				run.disabled = false;
				say( __( 'The proposal failed:' ) + ' ' + ( e.message || __( 'unexpected error' ) ), 'is-bad' );
			} );
		} );

		root.appendChild( box );
	}

	function paintNotes( box, data ) {
		var lines = ( data.types || [] ).filter( function ( t ) { return t.why; } )
			.map( function ( t ) { return t.slug + ' — ' + t.why; } )
			.concat( Array.isArray( data.notes ) ? data.notes : [] );

		box.textContent = '';
		box.hidden = ! lines.length;
		if ( ! lines.length ) { return; }

		var ul = el( 'ul' );
		lines.forEach( function ( line ) { ul.appendChild( el( 'li', null, line ) ); } );
		box.appendChild( ul );
	}

	/** Pours a proposal into the state, skipping what is already there. Returns how much it added. */
	function pour( data ) {
		var added = 0;

		( data.types || [] ).forEach( function ( raw ) {
			if ( ! raw.slug || findBySlug( state.types, raw.slug ) || findBySlug( state.foreignTypes, raw.slug ) ) { return; }
			var type = hydrate( raw, D_TYPE );
			type._existing = false;
			type._fresh = true;
			type.fields = [];
			state.types.push( type );
			added++;
		} );

		( data.taxonomies || [] ).forEach( function ( raw ) {
			if ( ! raw.slug || findBySlug( state.taxonomies, raw.slug ) || findBySlug( state.foreignTaxonomies, raw.slug ) ) { return; }
			var tax = hydrate( raw, D_TAX );
			tax._existing = false;
			tax._fresh = true;
			tax.fields = [];
			state.taxonomies.push( tax );
			added++;
		} );

		Object.keys( data.fields || {} ).forEach( function ( slug ) {
			var owner = findBySlug( state.types, slug ) || findBySlug( state.foreignTypes, slug );
			if ( ! owner ) { return; }
			( data.fields[ slug ] || [] ).forEach( function ( f ) {
				var key = keyify( f.key );
				if ( ! key || owner.fields.some( function ( other ) { return other.key === key; } ) ) { return; }
				var proposed = hydrateField( f );
				proposed.key = key;
				proposed._keyLocked = true;
				owner.fields.push( proposed );
				added++;
			} );
		} );

		if ( added ) { markDirty(); render(); }

		return added;
	}

	function findBySlug( list, slug ) {
		for ( var i = 0; i < list.length; i++ ) {
			if ( list[ i ].slug === slug ) { return list[ i ]; }
		}
		return null;
	}

	function goToTypes() {
		var tab = document.querySelector( '.aipb-model-tab[data-tab="types"]' );
		if ( tab ) { tab.click(); }
	}

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

	/** The state, stripped of everything that only mattered on the page. */
	function serialise() {
		return {
			types: state.types.filter( function ( t ) { return ! untouched( t ); } ).map( bare ),
			taxonomies: state.taxonomies.filter( function ( t ) { return ! untouched( t ); } ).map( bare ),
			foreign_fields: fieldMap( state.foreignTypes ),
			foreign_term_fields: fieldMap( state.foreignTaxonomies ),
			confirm_removal: state.confirmAll ? true : Object.keys( state.confirmed )
		};
	}

	function bare( item ) {
		var out = {};
		Object.keys( item ).forEach( function ( k ) {
			if ( k.charAt( 0 ) !== '_' && k !== 'fields' ) { out[ k ] = item[ k ]; }
		} );
		out.fields = item.fields.filter( function ( f ) { return f.key; } ).map( function ( f ) {
			return { key: f.key, type: f.type, label: f.label };
		} );
		return out;
	}

	function fieldMap( list ) {
		var out = {};
		list.forEach( function ( item ) {
			out[ item.slug ] = item.fields.filter( function ( f ) { return f.key; } ).map( function ( f ) {
				return { key: f.key, type: f.type, label: f.label };
			} );
		} );
		return out;
	}

	form.addEventListener( 'submit', function ( e ) {
		var found = problems();
		if ( found.length ) {
			e.preventDefault();
			showState( found[ 0 ], 'is-bad' );
			window.alert( __( 'There is something to fix first:' ) + '\n\n• ' + found.join( '\n• ' ) );
			return;
		}

		document.getElementById( 'aipb-model-json' ).value = JSON.stringify( serialise() );
		dirty = false;
		showState( __( 'Saving…' ), '' );
	} );

	// The button in the «I have not saved» notice: it re-submits the model that was just refused,
	// with the confirmation attached.
	var force = document.getElementById( 'aipb-model-force' );
	if ( force ) {
		force.addEventListener( 'click', function () {
			state.confirmAll = true;
			// requestSubmit() and not submit(): the second one skips the submit event, and with it
			// the line that writes the model into the field — it would post an empty form.
			if ( form.requestSubmit ) {
				form.requestSubmit();
			} else {
				document.getElementById( 'aipb-model-save' ).click();
			}
		} );
	}

	window.addEventListener( 'beforeunload', function ( e ) {
		if ( ! dirty ) { return; }
		e.preventDefault();
		e.returnValue = '';
	} );

	/* ---------------------- Contents with no image (the notice) --------------- */

	var result = document.querySelector( '.aipb-fill-result' );
	document.querySelectorAll( '.aipb-fill-img' ).forEach( function ( b ) {
		b.addEventListener( 'click', function () {
			var label = b.textContent;
			b.disabled = true;
			b.textContent = __( 'Looking…' );

			fetch( cfg.rest + '/images/fill', {
				method: 'POST',
				headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': cfg.nonce },
				body: JSON.stringify( { post_type: b.dataset.type } )
			} ).then( function ( r ) { return r.json(); } ).then( function ( d ) {
				b.textContent = label;
				b.disabled = false;
				result.textContent = ( d && typeof d.found === 'number' )
					? _sprintf( __( 'Found %1$d images across %2$d contents looked at. %3$d are still without.' ), d.found, d.seen, d.left )
					: __( 'It did not work.' );
			} ).catch( function () {
				b.textContent = label;
				b.disabled = false;
				result.textContent = __( 'It did not work.' );
			} );
		} );
	} );

	/* --------------------------------- Start ---------------------------------- */

	renderBrief();
	render();

	if ( cfg.confirmRemoval ) {
		showState( __( 'Not saved yet' ), 'is-dirty' );
	}
}() );
