/**
 * WrenchPress admin accessibility repairs.
 *
 * Two independent concerns share this file and one boot gate:
 *
 *  1. Announce admin notices after a Post/Redirect/Get (SC 4.1.3). See init().
 *  2. Reflect the list-table "Show more details" toggle's expanded state onto
 *     its aria-expanded (SC 4.1.2). See initToggleReflection().
 *
 * WrenchPress admin notices: announce them (SC 4.1.3).
 *
 * The plugin renders around fifteen hand-written <div class="notice"> blocks
 * after a Post/Redirect/Get. Every one of them is in the initial DOM when the
 * page parses, and that is precisely why none of them is announced today.
 *
 * The counter-intuitive part, and the reason this is a script and not a markup
 * change: a live region fires on CHANGE, not on PRESENCE. A role="alert" that
 * is already there when the document is parsed is not spoken. Adding more ARIA
 * to those fifteen divs would therefore fix nothing at all. Moving focus to the
 * notice and speaking it is what fixes it.
 *
 * wp.a11y.speak() is used rather than a hand-rolled live region. WP core makes
 * wp-a11y a dependency of `common`, so it is already on every admin screen, and
 * it already implements the polite/assertive dual-region trick that works
 * around long-standing screen-reader bugs. Never hand-roll that in wp-admin.
 */
( function () {
	'use strict';

	/**
	 * Speak a message through WP core's announcer, if it is present.
	 *
	 * @param {string} message    Text to announce.
	 * @param {string} politeness 'polite' or 'assertive'.
	 * @return {void}
	 */
	function speak( message, politeness ) {
		if (
			! window.wp ||
			! window.wp.a11y ||
			typeof window.wp.a11y.speak !== 'function'
		) {
			return;
		}

		window.wp.a11y.speak( message, politeness );
	}

	/**
	 * Find the notice that is actually worth announcing.
	 *
	 * Naively taking the first `.notice` in the content area is wrong, and it
	 * was wrong in the first draft of this file. wp-admin puts two other kinds
	 * of `.notice` in there:
	 *
	 *  - core's update nag (`.update-nag`), which is not a status message about
	 *    anything the user just did. Announcing "WordPress 7.0.1 is available"
	 *    every time somebody saves a customer is noise, and it buries the real
	 *    message.
	 *  - hidden templates. WP_List_Table prints an inline-edit error notice up
	 *    front with `.hidden` on it, waiting to be shown later. It has no text
	 *    yet and must never be focused.
	 *
	 * So: skip the nag, skip anything not being displayed, and skip anything
	 * with no text. What is left is the plugin's own PRG notice.
	 *
	 * @param {Element} content The #wpbody-content element.
	 * @return {?Element} The notice to announce, or null.
	 */
	function findNotice( content ) {
		var notices = content.querySelectorAll( '.notice' );
		var i;
		var notice;

		for ( i = 0; i < notices.length; i++ ) {
			notice = notices[ i ];

			if ( notice.classList.contains( 'update-nag' ) ) {
				continue;
			}

			// Not rendered: no boxes, so nothing to say and nothing to focus.
			if ( ! notice.getClientRects().length ) {
				continue;
			}

			if ( ! ( notice.textContent || '' ).trim() ) {
				continue;
			}

			return notice;
		}

		return null;
	}

	function init() {
		var content = document.getElementById( 'wpbody-content' );

		if ( ! content ) {
			return;
		}

		var notice = findNotice( content );

		if ( ! notice ) {
			return;
		}

		var message = notice.textContent.trim();

		/*
		 * Announce first, focus second, and focus LATE. Both halves of that are
		 * load-bearing, and the second half was found the hard way.
		 *
		 * WP core's common.js appends the dismiss button to every
		 * .is-dismissible notice from a jQuery ready handler, and that DOM
		 * insertion BLURS the notice. Focusing straight from our own
		 * DOMContentLoaded listener therefore looked like it worked, set the
		 * tabindex, and left focus back on <body> a millisecond later. The
		 * announcement still fired; the focus silently did not survive.
		 *
		 * A zero-delay timeout runs after the whole DOMContentLoaded task,
		 * including every jQuery ready handler registered on it, so core has
		 * finished rearranging the notice before we focus it.
		 */

		speak(
			message,
			notice.classList.contains( 'notice-error' )
				? 'assertive'
				: 'polite'
		);

		/*
		 * tabindex="-1" makes the notice programmatically focusable without
		 * adding it to the tab order. Focus then puts the user's reading
		 * position on the message rather than at the top of a page they have
		 * already seen.
		 */
		notice.setAttribute( 'tabindex', '-1' );

		window.setTimeout( function () {
			/*
			 * If something has already taken focus by now, leave it alone.
			 * Yanking focus out from under a user who has started typing is a
			 * worse defect than the one being fixed.
			 */
			if (
				document.activeElement &&
				document.activeElement !== document.body
			) {
				return;
			}

			notice.focus();
		}, 0 );
	}

	/**
	 * List-table toggle: reflect its expanded state onto aria-expanded (SC 4.1.2).
	 *
	 * Below 782px WP_List_Table hides the secondary columns and reveals them per
	 * row through a real <button class="toggle-row"> whose only visible label is a
	 * <span class="screen-reader-text">Show more details</span>. Core's own
	 * handler (wp-admin/js/common.js) delegates a click listener from tbody to
	 * .toggle-row and, on activation, flips the is-expanded class on the closest
	 * <tr> via jQuery toggleClass.
	 *
	 * It toggles is-expanded on the <tr> and sets NO aria-expanded anywhere, so a
	 * screen-reader user is never told whether a row is open or closed. That is the
	 * SC 4.1.2 Name/Role/Value gap this closes.
	 *
	 * REFLECT, NEVER DRIVE. This adds NO click handler on .toggle-row. Core already
	 * owns the click and toggles the class; a second toggler here would either race
	 * core or cancel it out (a double toggle straight back to the starting state).
	 * Instead we (a) set each button's aria-expanded once from whether its row
	 * already carries is-expanded, then (b) watch the class and re-reflect whenever
	 * ANYTHING changes it. The observer only ever reads the class and writes the
	 * attribute; it never adds, removes, or toggles a class, so it cannot race or
	 * double-toggle. This is the same reflect-not-drive shape as the appointments
	 * combobox aria-controls repair.
	 *
	 * NO wp.a11y.speak() here. A screen reader re-reads the button's own
	 * aria-expanded NATIVELY the moment the user activates it, so speaking on top
	 * would double-announce the identical change. The speak() helper above is for
	 * the notice concern and stays untouched.
	 */

	/**
	 * Reflect a toggle button's row state onto its aria-expanded.
	 *
	 * The value is one of the string literals 'true' / 'false' only, derived
	 * solely from a boolean class check. Nothing is interpolated and there is no
	 * user input on this path, so there is no injection surface to escape.
	 *
	 * @param {Element} button A .toggle-row button.
	 * @return {void}
	 */
	function reflectToggle( button ) {
		var row = button.closest( 'tr' );
		var expanded = !! row && row.classList.contains( 'is-expanded' );

		button.setAttribute( 'aria-expanded', expanded ? 'true' : 'false' );
	}

	/**
	 * Wire the reflection onto every list-table on the screen.
	 *
	 * ONE observer per <tbody>, not one per row. A single observer on the table
	 * body has one observe/disconnect lifecycle and covers every current and
	 * future row uniformly, versus allocating and tracking an observer object per
	 * row. The tradeoff: a subtree observer also sees class changes on descendant
	 * cells, so the callback must confirm the changed node is a <tr>
	 * (tagName === 'TR') before locating that row's .toggle-row. That guard is
	 * cheap and the lifecycle win is worth it.
	 *
	 * These are server-rendered WP_List_Table screens over custom tables with no
	 * inline-edit / Quick Edit and no AJAX row insertion, so a one-shot pass over
	 * the existing buttons plus a class observer on the existing rows is
	 * sufficient. The subtree observer will reflect any future class change on
	 * those rows for free.
	 *
	 * @return {void}
	 */
	function initToggleReflection() {
		var content = document.getElementById( 'wpbody-content' );

		if ( ! content ) {
			return;
		}

		// There is typically one .wp-list-table per screen, but iterate any that
		// exist so a screen with more than one table is handled uniformly.
		var tables = content.querySelectorAll( '.wp-list-table' );
		var t;

		for ( t = 0; t < tables.length; t++ ) {
			( function ( table ) {
				var tbody = table.querySelector( 'tbody' );

				if ( ! tbody ) {
					return;
				}

				var buttons = tbody.querySelectorAll( '.toggle-row' );
				var i;

				// Nothing to do if this table has no toggle buttons.
				if ( ! buttons.length ) {
					return;
				}

				// One-shot initial pass: normally every row is collapsed, so this
				// sets aria-expanded="false" on each toggle.
				for ( i = 0; i < buttons.length; i++ ) {
					reflectToggle( buttons[ i ] );
				}

				var observer = new MutationObserver( function ( mutations ) {
					var m;
					var target;
					var rowButtons;
					var b;

					for ( m = 0; m < mutations.length; m++ ) {
						target = mutations[ m ].target;

						// A subtree observer also reports class changes on cells and
						// their children; only a <tr> carries is-expanded.
						if ( ! target || 'TR' !== target.tagName ) {
							continue;
						}

						// Core renders more than one .toggle-row in the primary cell
						// on these screens, so reflect every one in the row, not just
						// the first, and each stays truthful.
						rowButtons = target.querySelectorAll( '.toggle-row' );

						for ( b = 0; b < rowButtons.length; b++ ) {
							reflectToggle( rowButtons[ b ] );
						}
					}
				} );

				observer.observe( tbody, {
					subtree: true,
					attributes: true,
					attributeFilter: [ 'class' ],
				} );
			} )( tables[ t ] );
		}
	}

	/**
	 * Boot both concerns together off one ready gate.
	 *
	 * The toggle wiring is deliberately independent of whether a notice exists:
	 * init() returns early on a page with no PRG notice, so calling
	 * initToggleReflection() from here rather than from inside init() keeps the
	 * toggle repair running on every list-table screen regardless.
	 *
	 * @return {void}
	 */
	function boot() {
		init();
		initToggleReflection();
	}

	if ( 'loading' === document.readyState ) {
		document.addEventListener( 'DOMContentLoaded', boot );
	} else {
		boot();
	}
} )();
