<?php
/**
 * Individual access grants — per-page and per-section.
 *
 * The problem this solves: before this, the only way to let a specific person
 * edit a specific page was to promote them to a capable role and make them the
 * page's AUTHOR — one delegate per page, a role change per delegate, and no way
 * to say "the HR officer owns the Careers section". This module adds two grant
 * kinds that work for ANY user regardless of role:
 *
 *   1. PER-PAGE — "these people may edit this one item." Stored on the item
 *      itself (post meta `bw_editors`, managed through an ACF user picker in
 *      the editor sidebar, visible to Administrators and Editors). Scope is
 *      deliberately edit-and-update only: no creating, no trashing, nothing
 *      else on the site.
 *
 *   2. PER-SECTION — "this person owns this whole post type." Stored as
 *      ordinary WordPress user capabilities (WP_User::add_cap of the type's
 *      full family, which inc/bw-roles.php makes per-type). Full lifecycle:
 *      create, publish, edit others', delete. Managed on Users → Site Access.
 *
 * Design rule: the grant IS the capability. No parallel permission tables, no
 * custom checks sprinkled through templates — everything routes through
 * WordPress's own capability pipeline (`map_meta_cap` for per-item decisions,
 * `user_has_cap` for the handful of primitives a capability-less role needs
 * synthesised), so Gutenberg, the REST API, quick edit and every well-behaved
 * plugin enforce it for free.
 *
 * Teachers (bw_staff) holding a grant are exempted from the wp-admin lockout in
 * inc/bw-staff-access.php and get the admin bar back — browsing to their page
 * and pressing "Edit Page" is the intended discovery path.
 *
 * @package Kadence-Child
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

// ─────────────────────────────────────────────────────────────────────────────
// Registry
// ─────────────────────────────────────────────────────────────────────────────

/**
 * The grantable content types.
 *
 * `plural` is the capability base: core families for pages/posts, the
 * per-type families from inc/bw-roles.php for the Brentwood CPTs. bw_guide is
 * deliberately absent — guides are a synced mirror of the BW Guides hub and
 * must never be locally editable, granted or not.
 */
function bw_access_types() {
	return array(
		'page'       => array( 'label' => __( 'Pages', 'kadence-child' ), 'plural' => 'pages' ),
		'post'       => array( 'label' => __( 'Blog posts', 'kadence-child' ), 'plural' => 'posts' ),
		'bw_career'  => array( 'label' => __( 'Careers', 'kadence-child' ), 'plural' => 'bw_careers' ),
		'course'     => array( 'label' => __( 'Courses', 'kadence-child' ), 'plural' => 'bw_courses' ),
		'staff'      => array( 'label' => __( 'Staff profiles', 'kadence-child' ), 'plural' => 'bw_staffs' ),
		'livestream' => array( 'label' => __( 'Livestreams', 'kadence-child' ), 'plural' => 'bw_livestreams' ),
		'landing'    => array( 'label' => __( 'Landing pages', 'kadence-child' ), 'plural' => 'bw_landings' ),
		'bw_hundred' => array( 'label' => __( 'Brentwood 100', 'kadence-child' ), 'plural' => 'bw_hundreds' ),
	);
}

/**
 * Grantable tool areas — plugin screens rather than content.
 *
 * Each entry is only offered when its plugin is actually active, so the screen
 * never advertises access to something that isn't installed.
 *
 * Two shapes:
 *  - `caps`  — the plugin has its own capabilities, so the grant is just those
 *              capabilities. Gravity Forms works this way and it's the clean case.
 *  - `proxy` — the plugin hard-codes `manage_options`, so there is no capability
 *              to grant. We record a marker capability of our own and bridge it
 *              on that plugin's screens only (see bw_access_proxy_caps). This is
 *              a shim, not a design: it goes away the moment the plugin gates on
 *              its own capability.
 */
function bw_access_tools() {
	$tools = array();

	if ( class_exists( 'GFForms' ) && function_exists( 'bw_roles_gravityforms_caps' ) ) {
		$tools['forms'] = array(
			'label'  => __( 'Forms &amp; entries', 'kadence-child' ),
			'note'   => __( 'Build forms and read submissions (Gravity Forms). Plugin settings and licence stay with administrators.', 'kadence-child' ),
			'caps'   => bw_roles_gravityforms_caps(),
			'marker' => 'gravityforms_view_entries',
		);
	}

	if ( defined( 'BW_LEAD_AI_OPTION' ) || class_exists( 'BW_Lead_AI_Admin' ) ) {
		$native = bw_access_lead_ai_capability();
		$tools['lead_ai'] = array(
			'label'  => __( 'Lead AI', 'kadence-child' ),
			'note'   => __( 'Read lead journeys and attribution reporting. For admissions staff.', 'kadence-child' ),
			'caps'   => array( $native ? $native : 'bw_view_lead_ai' ),
			'marker' => $native ? $native : 'bw_view_lead_ai',
			'proxy'  => ! $native,
		);
	}

	return $tools;
}

/**
 * BW Lead AI's own read capability, once the plugin has one.
 *
 * Returns '' on a version that still hard-codes `manage_options`, which is the
 * signal to fall back to the capability borrow. Written this way so the day the
 * plugin is updated, the grant switches to the real capability, the borrow stops
 * being registered and our stand-in menu disappears — with no second deploy and
 * nothing to remember. If the plugin's API ever moves, this returns '' again and
 * we degrade to the shim rather than to a broken screen.
 */
function bw_access_lead_ai_capability() {
	if ( ! class_exists( 'BW_Lead_AI_Caps' ) || ! method_exists( 'BW_Lead_AI_Caps', 'view' ) ) {
		return '';
	}
	$cap = (string) BW_Lead_AI_Caps::view();
	return ( '' !== $cap && 'manage_options' !== $cap ) ? $cap : '';
}

/**
 * Who may CHANGE BW Lead AI's configuration — settings, retention, purge,
 * import/export/reset, the cross-domain wizard.
 *
 * BW Lead AI floors both of its capabilities to anyone holding `manage_options`,
 * on the sound reasoning that such a person could already reach every screen. On
 * this site that reasoning doesn't hold: the IT team is granted `manage_options`
 * deliberately WITHOUT content access, and "can purge the lead database" is not
 * something that should arrive with "can install a plugin". Prospective families'
 * enquiry records are not IT's to reconfigure or delete.
 *
 * The plugin anticipated exactly this: its floor applies only to the SHIPPED
 * capability names, and it documents that a site filtering `manage()` to a name
 * of its own has made a deliberate decision it won't override. So we filter to
 * our own name and hand it to administrators by role — which the IT grant is
 * not, since it grants capabilities rather than the role itself.
 *
 * Reading is deliberately left alone: `bw_lead_ai_view` stays floored to
 * `manage_options`, so IT can still open the reports to confirm the plugin
 * works. Flip BW_ACCESS_LEAD_AI_TAKEOVER to include 'view' if that should
 * change too.
 */
const BW_ACCESS_LEAD_AI_MANAGE_CAP = 'bw_lead_ai_manage_site';
const BW_ACCESS_LEAD_AI_TAKEOVER   = array( 'manage' );

add_filter(
	'bw_lead_ai_manage_capability',
	function ( $cap ) {
		return in_array( 'manage', BW_ACCESS_LEAD_AI_TAKEOVER, true ) ? BW_ACCESS_LEAD_AI_MANAGE_CAP : $cap;
	}
);

/**
 * Administrators — by role, not by capability — hold the renamed capability.
 *
 * Keyed on the role on purpose. Testing `manage_options` here would re-admit the
 * IT team through the back door and undo the whole point.
 */
add_filter(
	'user_has_cap',
	function ( $allcaps, $required, $args, $user ) {
		if ( $user instanceof WP_User && in_array( 'administrator', (array) $user->roles, true ) ) {
			$allcaps[ BW_ACCESS_LEAD_AI_MANAGE_CAP ] = true;
		}
		return $allcaps;
	},
	10,
	4
);

/**
 * BW Guides' own read capability, on the same terms.
 *
 * `READ_CAP` is currently the literal `edit_posts` — a blog capability standing
 * in for "may read the documentation". Anything else means the split has landed.
 */
function bw_access_guides_capability() {
	if ( ! class_exists( 'BW_Guides_Admin' ) || ! defined( 'BW_Guides_Admin::READ_CAP' ) ) {
		return '';
	}
	$cap = (string) BW_Guides_Admin::READ_CAP;
	return ( '' !== $cap && 'edit_posts' !== $cap ) ? $cap : '';
}

/** Is this request one of the BW Guides reader screens? */
function bw_access_is_guides_screen() {
	if ( ! is_admin() || wp_doing_ajax() ) {
		return false;
	}
	$page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
	return 'bw-guides' === $page;
}

/**
 * Everyone signed in may read the guides.
 *
 * Once BW Guides ships its own read capability the borrow above stops being
 * registered — correctly, since a plugin gating on its own capability is the
 * whole point. But standing down is only half the job: the capability then
 * exists and nobody holds it, so the documentation becomes unreadable for every
 * account that isn't an administrator. This grants it to any signed-in user,
 * which is the policy the borrow was always implementing.
 *
 * Read only. The plugin's manage-side capability is deliberately untouched, so
 * tags, notes and the sync trigger stay with people who can genuinely edit.
 */
add_filter(
	'user_has_cap',
	function ( $allcaps, $required, $args, $user ) {
		$cap = bw_access_guides_capability();
		if ( '' === $cap || ! isset( $args[0] ) || $args[0] !== $cap ) {
			return $allcaps;
		}
		if ( $user instanceof WP_User && $user->ID && empty( $allcaps[ $cap ] ) ) {
			$allcaps[ $cap ] = true;
		}
		return $allcaps;
	},
	10,
	4
);

/**
 * Move anyone holding the old stand-in capability onto the plugin's real one.
 *
 * Runs once, when the plugin that owns the capability catches up. Without it, a
 * grant made before the plugin update would keep a name nothing checks any more
 * — access that silently stops working, which is the worst kind.
 */
add_action(
	'admin_init',
	function () {
		$native = bw_access_lead_ai_capability();
		if ( ! $native || get_option( 'bw_access_lead_ai_migrated' ) === $native ) {
			return;
		}
		foreach ( get_users( array( 'fields' => 'ID' ) ) as $uid ) {
			$user = get_userdata( $uid );
			if ( $user && ! empty( $user->caps['bw_view_lead_ai'] ) ) {
				$user->add_cap( $native );
				$user->remove_cap( 'bw_view_lead_ai' );
			}
		}
		update_option( 'bw_access_lead_ai_migrated', $native, false );
	},
	5
);

/**
 * Site administration — the "IT can run the software, not the words" grant.
 *
 * ⚠ Read this before granting it. It is a TIDINESS boundary, not a security
 * one. Anyone who can install or activate a plugin can run arbitrary PHP, and
 * from there grant themselves anything — so this keeps IT out of the content
 * by default and out of it by accident, but it does not confine a determined
 * or compromised account. Only give it to people you would trust with an
 * administrator account.
 *
 * Deliberately excluded: `edit_users` / `promote_users` / `create_users` (the
 * user roster stays with Erin and administrators) and `edit_plugins` /
 * `edit_themes` (the in-browser PHP file editors — nobody needs to edit live
 * code through a browser).
 */
function bw_access_admin_caps() {
	$caps = array(
		'manage_options',
		'manage_privacy_options',
		'edit_dashboard',
		'activate_plugins',
		'install_plugins',
		'update_plugins',
		'delete_plugins',
		'edit_theme_options',
		'switch_themes',
		'install_themes',
		'update_themes',
		'delete_themes',
		'update_core',
		'import',
		'export',
	);
	if ( defined( 'WPSEO_VERSION' ) ) {
		$caps[] = 'wpseo_manage_options';
	}
	return $caps;
}

/**
 * The full capability family for a section grant.
 *
 * Pages and posts use core's ten (core has no separate create capability —
 * `create_posts` reuses the edit primitive). The Brentwood types add the
 * distinct `create_*` capability bw-roles registers for them.
 */
function bw_access_family_caps( $plural ) {
	$prefixes = array(
		'edit_',
		'edit_others_',
		'edit_private_',
		'edit_published_',
		'publish_',
		'read_private_',
		'delete_',
		'delete_others_',
		'delete_private_',
		'delete_published_',
	);
	if ( ! in_array( $plural, array( 'pages', 'posts' ), true ) ) {
		$prefixes[] = 'create_';
	}
	$caps = array();
	foreach ( $prefixes as $prefix ) {
		$caps[] = $prefix . $plural;
	}
	return $caps;
}

/** Lookup tables for the capability checks that run on every request. */
function bw_access_watch() {
	static $watch = null;
	if ( null !== $watch ) {
		return $watch;
	}
	$watch = array( 'edit' => array(), 'publish' => array() );
	foreach ( bw_access_types() as $type => $t ) {
		$watch['edit'][ 'edit_' . $t['plural'] ]       = $type;
		$watch['publish'][ 'publish_' . $t['plural'] ] = $type;
	}
	return $watch;
}

// ─────────────────────────────────────────────────────────────────────────────
// Grant lookups (all cached per request — these sit on hot capability paths)
// ─────────────────────────────────────────────────────────────────────────────

/** User IDs granted on one item. Reads the ACF `bw_editors` user field. */
function bw_access_post_editors( $post_id ) {
	$raw = get_post_meta( (int) $post_id, 'bw_editors', true );
	return array_values( array_filter( array_map( 'intval', (array) $raw ) ) );
}

/** Does this user hold a per-page grant on this item? */
function bw_access_user_granted_post( $user_id, $post_id ) {
	return in_array( (int) $user_id, bw_access_post_editors( $post_id ), true );
}

/**
 * Which grantable types hold at least one per-page grant for this user.
 *
 * One LIKE query per user per request, cached. ACF stores the user field as a
 * serialized array of STRING ids (enforced below), so `"123"` is a precise,
 * boundary-safe needle.
 */
function bw_access_user_grant_types( $user_id ) {
	static $cache = array();
	$user_id = (int) $user_id;
	if ( ! $user_id ) {
		return array();
	}
	if ( isset( $cache[ $user_id ] ) ) {
		return $cache[ $user_id ];
	}
	global $wpdb;
	$like  = '%' . $wpdb->esc_like( '"' . $user_id . '"' ) . '%';
	$types = $wpdb->get_col(
		$wpdb->prepare(
			"SELECT DISTINCT p.post_type FROM {$wpdb->posts} p
			 INNER JOIN {$wpdb->postmeta} m ON m.post_id = p.ID
			 WHERE m.meta_key = 'bw_editors' AND m.meta_value LIKE %s
			   AND p.post_status NOT IN ( 'trash', 'auto-draft' )",
			$like
		)
	);
	$cache[ $user_id ] = array_values( array_intersect( (array) $types, array_keys( bw_access_types() ) ) );
	return $cache[ $user_id ];
}

/** The item IDs a user holds per-page grants on, for one type. */
function bw_access_user_granted_ids( $user_id, $post_type ) {
	static $cache = array();
	$key = (int) $user_id . '|' . $post_type;
	if ( isset( $cache[ $key ] ) ) {
		return $cache[ $key ];
	}
	global $wpdb;
	$like          = '%' . $wpdb->esc_like( '"' . (int) $user_id . '"' ) . '%';
	$ids           = $wpdb->get_col(
		$wpdb->prepare(
			"SELECT p.ID FROM {$wpdb->posts} p
			 INNER JOIN {$wpdb->postmeta} m ON m.post_id = p.ID
			 WHERE m.meta_key = 'bw_editors' AND m.meta_value LIKE %s
			   AND p.post_type = %s AND p.post_status NOT IN ( 'trash', 'auto-draft' )",
			$like,
			$post_type
		)
	);
	$cache[ $key ] = array_map( 'intval', (array) $ids );
	return $cache[ $key ];
}

/**
 * Does the user hold ANY grant — per-page or per-section?
 *
 * Used by inc/bw-staff-access.php to decide whether a teacher may enter
 * wp-admin at all. Section grants live in the user's own stored capabilities
 * (`$user->caps`, which never includes role-derived capabilities), so an
 * Editor doesn't read as "individually granted".
 */
function bw_access_user_has_any_grant( $user_id ) {
	$user = get_userdata( (int) $user_id );
	if ( ! $user ) {
		return false;
	}
	foreach ( bw_access_types() as $t ) {
		if ( ! empty( $user->caps[ 'edit_' . $t['plural'] ] ) ) {
			return true;
		}
	}
	foreach ( bw_access_tools() as $tool ) {
		if ( ! empty( $user->caps[ $tool['marker'] ] ) ) {
			return true;
		}
	}
	if ( ! empty( $user->caps['manage_options'] ) ) {
		return true;
	}
	return (bool) bw_access_user_grant_types( $user->ID );
}

/**
 * Does one of the user's ROLES already carry this capability?
 *
 * What separates "covered by their role" from "granted to them personally" on
 * the Site Access screen. Without it, a checkbox would sit unticked next to
 * something the person can already do, and ticking it would quietly add a
 * second, individual copy that outlives any later role change.
 */
function bw_access_via_role( $user, $cap ) {
	if ( ! $user instanceof WP_User ) {
		return false;
	}
	foreach ( $user->roles as $slug ) {
		$role = get_role( $slug );
		if ( $role && $role->has_cap( $cap ) ) {
			return true;
		}
	}
	return false;
}

/**
 * How far a user's ROLE takes them on a content type: 'all', 'own' or ''.
 *
 * The distinction is the whole point. A role can grant `edit_<plural>` without
 * `edit_others_<plural>` — Site Editor does exactly that, deliberately — which
 * lets someone create and manage their OWN items while everyone else's stay
 * untouchable. Reporting that as "covered by their role" was wrong twice over:
 * it told the reader the section was handled when it was half-handled, and it
 * hid the checkbox that was the only way to widen it. The result was that a
 * Site Editor could not be given a whole section at all, and had to be added to
 * every item one at a time.
 */
function bw_access_role_reach( $user, $plural ) {
	if ( bw_access_via_role( $user, 'edit_others_' . $plural ) ) {
		return 'all';
	}
	if ( bw_access_via_role( $user, 'edit_' . $plural ) ) {
		return 'own';
	}
	return '';
}

/**
 * One plain sentence per role, for the top of the screen.
 *
 * Whoever is granting access needs to know what the person can already do
 * before deciding what to add — otherwise a role that covers "their own work"
 * and a role that covers "everyone's" look identical from here.
 */
function bw_access_role_notes() {
	return array(
		'administrator'   => __( 'Full access to everything, including settings, plugins and users.', 'kadence-child' ),
		'editor'          => __( 'Can create and edit all content across the site, including other people\'s, plus forms, site chrome and the user list.', 'kadence-child' ),
		'bw_site_editor'  => __( 'Can create and publish content anywhere, but can only edit items they created themselves. To let them edit everyone\'s in a section, tick that section below.', 'kadence-child' ),
		'bw_blog_manager' => __( 'Runs the blog: full access to blog posts and their categories, and nothing else.', 'kadence-child' ),
		'bw_staff'        => __( 'Maintains their own staff profile, and nothing else.', 'kadence-child' ),
		'author'          => __( 'Can publish and manage their own blog posts.', 'kadence-child' ),
		'contributor'     => __( 'Can write blog posts but not publish them.', 'kadence-child' ),
		'subscriber'      => __( 'Can sign in, and nothing more.', 'kadence-child' ),
	);
}

/**
 * Is this user's access to a type ONLY via per-page grants?
 *
 * `$user->allcaps` is roles + individually stored capabilities, computed
 * BEFORE `user_has_cap` filters — so it can't see our own synthesised grants,
 * which is exactly the point.
 */
function bw_access_grant_only( $user, $type ) {
	$types = bw_access_types();
	if ( ! isset( $types[ $type ] ) || ! $user instanceof WP_User ) {
		return false;
	}
	return empty( $user->allcaps[ 'edit_' . $types[ $type ]['plural'] ] );
}

// ─────────────────────────────────────────────────────────────────────────────
// Enforcement — per-page grants
// ─────────────────────────────────────────────────────────────────────────────

/**
 * The per-item decision: a granted user may edit, read and publish THAT item.
 *
 * `delete_post` is deliberately not intercepted — a per-page grant never
 * includes trashing. The CPTs' renamed meta capabilities (edit_bw_career, …)
 * arrive here already translated to the canonical names by core's
 * post_type_meta_caps recursion, so matching the canonical six is complete.
 */
add_filter(
	'map_meta_cap',
	function ( $caps, $cap, $user_id, $args ) {
		static $meta = array( 'edit_post', 'edit_page', 'read_post', 'read_page', 'publish_post', 'publish_page' );
		if ( ! in_array( $cap, $meta, true ) || empty( $args[0] ) ) {
			return $caps;
		}
		$post = get_post( $args[0] );
		if ( ! $post || 'trash' === $post->post_status ) {
			return $caps;
		}
		$types = bw_access_types();
		if ( ! isset( $types[ $post->post_type ] ) ) {
			return $caps;
		}
		if ( ! bw_access_user_granted_post( $user_id, $post->ID ) ) {
			return $caps;
		}
		return array( 'read' );
	},
	10,
	4
);

/**
 * Stash the current REST request so capability checks can see their context.
 *
 * Gutenberg saves through the REST API, and updating a *published* item makes
 * core check the type-wide publish primitive (`handle_status_param`) — a check
 * that carries no post ID. The stashed request supplies the missing context:
 * if the item being written is one the user holds a grant on, the primitive is
 * synthesised below; anywhere else it stays denied.
 */
function bw_access_rest_request( $set = null, $clear = false ) {
	static $request = null;
	if ( $clear ) {
		$request = null;
	} elseif ( null !== $set ) {
		$request = $set;
	}
	return $request;
}
add_filter(
	'rest_request_before_callbacks',
	function ( $response, $handler, $request ) {
		if ( $request instanceof WP_REST_Request ) {
			bw_access_rest_request( $request );
		}
		return $response;
	},
	10,
	3
);
add_filter(
	'rest_request_after_callbacks',
	function ( $response ) {
		bw_access_rest_request( null, true );
		return $response;
	},
	10,
	1
);

/** The post an in-flight write (REST update, quick edit, bulk edit) targets. */
function bw_access_write_targets() {
	$request = bw_access_rest_request();
	if ( $request instanceof WP_REST_Request ) {
		if ( ! in_array( $request->get_method(), array( 'POST', 'PUT', 'PATCH' ), true ) ) {
			return array();
		}
		$id = (int) $request->get_param( 'id' );
		return $id ? array( $id ) : array();
	}
	if ( wp_doing_ajax() && isset( $_POST['action'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- context sniff only; the handlers do their own checks.
		if ( 'inline-save' === $_POST['action'] && isset( $_POST['post_ID'] ) ) {
			return array( (int) $_POST['post_ID'] );
		}
	}
	if ( is_admin() && isset( $_REQUEST['bulk_edit'], $_REQUEST['post'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
		return array_map( 'intval', (array) $_REQUEST['post'] );
	}
	return array();
}

/**
 * Synthesise the primitives a per-page grant needs, and nothing more.
 *
 *  - the type's edit primitive, while the user holds ≥1 grant of that type —
 *    this is what makes the admin menu, the list screen and the block editor
 *    exist at all for a role that has no capabilities of its own;
 *  - upload_files, for the same users (a page grant without images is half a
 *    grant) and for section-grant holders;
 *  - the type's publish primitive, ONLY while the write in flight targets an
 *    item the user is granted on (see bw_access_write_targets). Without this,
 *    a granted user pressing Update on a published page would trip core's
 *    type-wide publish check — worse, quick edit would silently demote the
 *    page to "pending review".
 */
add_filter(
	'user_has_cap',
	function ( $allcaps, $required, $args, $user ) {
		$requested = isset( $args[0] ) ? $args[0] : '';
		if ( '' === $requested || ! empty( $allcaps[ $requested ] ) || ! $user instanceof WP_User || ! $user->ID ) {
			return $allcaps;
		}
		$watch = bw_access_watch();

		if ( 'upload_files' === $requested ) {
			if ( bw_access_user_has_any_grant( $user->ID ) ) {
				$allcaps['upload_files'] = true;
			}
			return $allcaps;
		}

		if ( isset( $watch['edit'][ $requested ] ) ) {
			if ( in_array( $watch['edit'][ $requested ], bw_access_user_grant_types( $user->ID ), true ) ) {
				$allcaps[ $requested ] = true;
			}
			return $allcaps;
		}

		if ( isset( $watch['publish'][ $requested ] ) ) {
			$type = $watch['publish'][ $requested ];
			foreach ( bw_access_write_targets() as $target ) {
				$post = get_post( $target );
				if ( $post && $post->post_type === $type && bw_access_user_granted_post( $user->ID, $post->ID ) ) {
					continue; // this target is fine — keep checking the rest.
				}
				return $allcaps; // any non-granted target: no synthesis.
			}
			if ( bw_access_write_targets() ) {
				$allcaps[ $requested ] = true;
			}
			return $allcaps;
		}

		return $allcaps;
	},
	10,
	4
);

/**
 * A per-page grant never includes creating new items.
 *
 * For the Brentwood CPTs that's free — bw-roles gives them a distinct
 * `create_*` capability the grant never synthesises. Pages and blog posts are
 * core types where "may create" is the same literal capability as "may see the
 * list", so creation is refused at the gates instead: the classic new-item
 * screen and the REST collection.
 */
add_action(
	'load-post-new.php',
	function () {
		$screen = get_current_screen();
		$type   = $screen && $screen->post_type ? $screen->post_type : 'post';
		if ( ! in_array( $type, array( 'page', 'post' ), true ) ) {
			return;
		}
		if ( bw_access_grant_only( wp_get_current_user(), $type ) ) {
			wp_die(
				esc_html__( 'Your access covers editing specific items only — creating new ones isn’t included.', 'kadence-child' ),
				403
			);
		}
	}
);
foreach ( array( 'page', 'post' ) as $bw_access_core_type ) {
	add_filter(
		"rest_pre_insert_{$bw_access_core_type}",
		function ( $prepared, $request ) {
			if ( $request->get_param( 'id' ) ) {
				return $prepared; // update, not create.
			}
			$type = isset( $prepared->post_type ) ? $prepared->post_type : 'post';
			if ( bw_access_grant_only( wp_get_current_user(), $type ) ) {
				return new WP_Error(
					'bw_access_no_create',
					__( 'Your access covers editing specific items only — creating new ones isn’t included.', 'kadence-child' ),
					array( 'status' => 403 )
				);
			}
			return $prepared;
		},
		10,
		2
	);
}
unset( $bw_access_core_type );

/**
 * Hide the create affordances the gates above refuse.
 *
 * Hidden with CSS rather than `remove_submenu_page()`, and that is not a style
 * preference — removing the entry locks the user out of the list screen entirely.
 *
 * Core (wp-admin/includes/menu.php) tidies away any submenu that ends up with a
 * single item pointing at its own parent:
 *
 *     if ( ! empty( $submenu[ $data[2] ] ) && 1 === count( $submenu[ $data[2] ] ) ) {
 *         if ( $data[2] === $first_sub[2] ) { unset( $submenu[ $data[2] ] ); }
 *     }
 *
 * Pages ships exactly two submenu items — "All Pages" and "Add New". Remove Add
 * New and the remaining one matches the parent, so core drops the whole submenu
 * array. `get_admin_page_parent()` resolves the parent by searching `$submenu`,
 * so it then returns empty, and `user_can_access_admin_page()` falls back to
 * testing `$_wp_menu_nopriv[ $pagenow ]` — where `$pagenow` for the Pages list
 * is the bare `edit.php`, i.e. **Posts**. A grant holder has no Posts access, so
 * Pages 403s with "Sorry, you are not allowed to access this page."
 *
 * Nothing about that is a capability failure: `edit_pages` is granted throughout.
 * Creating is still genuinely blocked — by the `load-post-new.php` guard and the
 * `rest_pre_insert_*` filters above, which is where a refusal belongs. This only
 * stops the link being offered.
 */
add_action(
	'admin_head',
	function () {
		$user = wp_get_current_user();
		if ( ! $user->exists() ) {
			return;
		}
		$hide = array();
		if ( bw_access_grant_only( $user, 'page' ) && in_array( 'page', bw_access_user_grant_types( $user->ID ), true ) ) {
			$hide[] = '#adminmenu a[href="post-new.php?post_type=page"]';
		}
		if ( bw_access_grant_only( $user, 'post' ) && in_array( 'post', bw_access_user_grant_types( $user->ID ), true ) ) {
			$hide[] = '#adminmenu a[href="post-new.php"]';
		}
		if ( $hide ) {
			echo '<style>' . implode( ',', $hide ) . '{display:none}</style>';
		}
	}
);
add_action(
	'admin_head-edit.php',
	function () {
		$screen = get_current_screen();
		$type   = $screen && $screen->post_type ? $screen->post_type : 'post';
		if ( isset( bw_access_types()[ $type ] ) && bw_access_grant_only( wp_get_current_user(), $type ) ) {
			echo '<style>.page-title-action{display:none}</style>';
		}
	}
);

/**
 * Grant-only users see just their items in the list — not all 269 pages.
 * (Users with real role or section access are untouched.)
 */
add_action(
	'pre_get_posts',
	function ( $query ) {
		global $pagenow;
		if ( ! is_admin() || 'edit.php' !== $pagenow || ! $query->is_main_query() ) {
			return;
		}
		$type = $query->get( 'post_type' );
		$type = $type ? $type : 'post';
		if ( ! isset( bw_access_types()[ $type ] ) ) {
			return;
		}
		$user = wp_get_current_user();
		if ( ! $user->exists() || ! bw_access_grant_only( $user, $type ) ) {
			return;
		}
		$ids = bw_access_user_granted_ids( $user->ID, $type );
		$query->set( 'post__in', $ids ? $ids : array( 0 ) );
	}
);

/** The status views (All | Published | …) count everything — hide them for grant-only users. */
add_action(
	'current_screen',
	function ( $screen ) {
		if ( ! $screen || 'edit' !== $screen->base ) {
			return;
		}
		$type = $screen->post_type ? $screen->post_type : 'post';
		if ( ! isset( bw_access_types()[ $type ] ) ) {
			return;
		}
		if ( bw_access_grant_only( wp_get_current_user(), $type ) ) {
			add_filter( 'views_' . $screen->id, '__return_empty_array' );
		}
	}
);

// ─────────────────────────────────────────────────────────────────────────────
// Capability proxies for plugins that hard-code their gate
// ─────────────────────────────────────────────────────────────────────────────

/**
 * Screens whose plugin demands a capability it never lets us configure, and the
 * capability each one needs borrowed while it renders.
 *
 * Keyed by the `page` query arg, because that identifies the screen no matter
 * which parent file it hangs off — so BW Lead AI moving from Settings to its own
 * top-level tab doesn't break this.
 *
 * The borrow lasts exactly one page render. Saving settings, AJAX and the REST
 * API are all separate requests and get nothing, so a Lead AI grant is
 * read-access to those screens and no more. The proper fix is the plugin gating
 * on its own capability; this exists so admissions staff aren't given
 * `manage_options` outright in the meantime.
 */
function bw_access_proxy_screens() {
	$screens = array();

	// Only while BW Lead AI still hard-codes `manage_options`. The moment it
	// ships its own capability these entries stop being registered and the
	// plugin's own menu — gated on that capability — takes over.
	if ( ! bw_access_lead_ai_capability() ) {
		$screens['bw-lead-ai']         = array( 'marker' => 'bw_view_lead_ai', 'borrow' => 'manage_options' );
		$screens['bw-lead-ai-journey'] = array( 'marker' => 'bw_view_lead_ai', 'borrow' => 'manage_options' );
	}

	// BW Guides gates its reader on `edit_posts` — a blog capability nobody
	// should need in order to read documentation. Everyone signed in may read
	// the guides, so this one has no marker.
	if ( ! bw_access_guides_capability() ) {
		$screens['bw-guides'] = array( 'marker' => '', 'borrow' => 'edit_posts' );
	}

	return $screens;
}

/** The proxy entry for the screen being requested right now, if any. */
function bw_access_current_proxy() {
	if ( ! is_admin() || wp_doing_ajax() ) {
		return null;
	}
	$page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
	if ( '' === $page ) {
		return null;
	}
	$screens = bw_access_proxy_screens();
	return isset( $screens[ $page ] ) ? $screens[ $page ] : null;
}

/**
 * Deliberately NOT bridged: the BW Guides AJAX endpoints.
 *
 * They gate on the same `edit_posts` the reader does, but they are not reads —
 * `bw_guides_save_tags` writes shared taxonomy terms with
 * `wp_set_object_terms`, and `bw_guides_save_note` likewise annotates the guide
 * itself, not a per-user copy. Lending the capability there would let anyone
 * with an account retag or annotate the documentation for everybody, which is
 * emphatically not what "everyone can read the guides" should mean.
 * `bw_guides_check_updates` is excluded for the same family of reason: it runs
 * a full sync, which creates, updates and trashes posts.
 *
 * So readers get the page and nothing that writes. Tagging, notes and the live
 * update check stay with people who hold real `edit_posts`. The clean fix is
 * the plugin separating "read a guide" from "annotate a guide" — see the
 * capability request raised with the BW Guides project.
 */

/** Lend the borrowed capability, for this request only. */
add_filter(
	'user_has_cap',
	function ( $allcaps, $required, $args, $user ) {
		$requested = isset( $args[0] ) ? $args[0] : '';
		if ( '' === $requested || ! empty( $allcaps[ $requested ] ) || ! $user instanceof WP_User || ! $user->ID ) {
			return $allcaps;
		}

		$proxy = bw_access_current_proxy();
		if ( ! $proxy || $requested !== $proxy['borrow'] ) {
			return $allcaps;
		}
		if ( '' !== $proxy['marker'] && empty( $allcaps[ $proxy['marker'] ] ) ) {
			return $allcaps; // this screen needs a grant and they don't hold it.
		}
		$allcaps[ $proxy['borrow'] ] = true;
		return $allcaps;
	},
	10,
	4
);

/**
 * Menu entries for the proxied screens.
 *
 * A borrowed capability only exists while its own screen renders, so those
 * screens are invisible in the sidebar everywhere else — which would make them
 * unreachable. These are plain links, gated on `read`, added only for users who
 * don't already reach the screen through a real capability (so nobody sees the
 * item twice).
 */
add_action(
	'admin_menu',
	function () {
		$user = wp_get_current_user();
		if ( ! $user->exists() ) {
			return;
		}

		// Guides: everyone signed in, unless they hold real `edit_posts` (in
		// which case the plugin has already put its own menu there) — and only
		// while we're standing in for a capability the plugin doesn't have yet.
		if ( ! bw_access_guides_capability() && empty( $user->allcaps['edit_posts'] ) && ! bw_access_current_proxy() ) {
			add_menu_page(
				__( 'Guides', 'kadence-child' ),
				__( 'Guides', 'kadence-child' ),
				'read',
				'admin.php?page=bw-guides',
				'',
				'dashicons-book-alt',
				59
			);
		}

		// Lead AI: only for the granted, and only when they're not an admin
		// already. Detect where the plugin lives rather than assuming — it is
		// moving from Settings to a top-level tab.
		if ( ! bw_access_lead_ai_capability()
			&& ! empty( $user->allcaps['bw_view_lead_ai'] )
			&& empty( $user->allcaps['manage_options'] ) ) {
			$target = isset( $GLOBALS['admin_page_hooks']['bw-lead-ai'] )
				? 'admin.php?page=bw-lead-ai'
				: 'options-general.php?page=bw-lead-ai';
			add_menu_page(
				__( 'Lead AI', 'kadence-child' ),
				__( 'Lead AI', 'kadence-child' ),
				'read',
				$target,
				'',
				'dashicons-chart-line',
				58
			);
		}
	},
	100
);

/**
 * While a borrowed capability is live, the plugin's own menus unfurl as though
 * the user were an administrator. Strip the parts the grant doesn't cover, so
 * the sidebar tells the truth about what they can actually open.
 */
add_action(
	'admin_menu',
	function () {
		$proxy = bw_access_current_proxy();
		if ( ! $proxy ) {
			return;
		}
		$user = wp_get_current_user();

		if ( 'edit_posts' === $proxy['borrow'] && empty( $user->allcaps['edit_posts'] ) ) {
			// Guides are a read-only mirror of the hub; authoring screens would
			// be a trap even for someone who could open them.
			remove_menu_page( 'edit.php' );
			remove_submenu_page( 'bw-guides', 'post-new.php?post_type=bw_guide' );
			remove_submenu_page( 'bw-guides', 'edit.php?post_type=bw_guide' );
			remove_submenu_page( 'bw-guides', 'edit-tags.php?taxonomy=bw_guide_tag&post_type=bw_guide' );
		}

		if ( 'manage_options' === $proxy['borrow'] && empty( $user->allcaps['manage_options'] ) ) {
			// Everything else under Settings belongs to administrators; the
			// borrow would advertise it and then refuse on the next click.
			remove_menu_page( 'options-general.php' );
			remove_menu_page( 'plugins.php' );
			remove_menu_page( 'themes.php' );
			remove_menu_page( 'tools.php' );
		}
	},
	9999
);

/** Teachers reach the guides from their profile page — they never see wp-admin otherwise. */
add_filter(
	'the_content',
	function ( $content ) {
		if ( is_admin() || ! in_the_loop() || ! is_main_query() || ! is_user_logged_in() ) {
			return $content;
		}
		if ( ! function_exists( 'bw_profile_page_url' ) ) {
			return $content;
		}
		$profile_id = url_to_postid( bw_profile_page_url() );
		if ( ! $profile_id || get_queried_object_id() !== $profile_id ) {
			return $content;
		}
		$link = '<p class="bw-profile-guides" style="margin-top:1.5rem">'
			. '<a href="' . esc_url( admin_url( 'admin.php?page=bw-guides' ) ) . '">'
			. esc_html__( 'Help &amp; guides', 'kadence-child' ) . '</a></p>';
		return $content . $link;
	},
	98
);

// ─────────────────────────────────────────────────────────────────────────────
// The per-page picker (ACF user field, editor sidebar)
// ─────────────────────────────────────────────────────────────────────────────

/**
 * Registered in PHP rather than acf-json so the whole feature lives in one
 * file. The location rules limit the panel to Administrators and Editors —
 * a granted teacher opening their page never sees the box at all.
 */
add_action(
	'acf/init',
	function () {
		if ( ! function_exists( 'acf_add_local_field_group' ) ) {
			return;
		}
		$locations = array();
		foreach ( array_keys( bw_access_types() ) as $type ) {
			foreach ( array( 'administrator', 'editor' ) as $role ) {
				$locations[] = array(
					array( 'param' => 'post_type', 'operator' => '==', 'value' => $type ),
					array( 'param' => 'current_user_role', 'operator' => '==', 'value' => $role ),
				);
			}
		}
		acf_add_local_field_group(
			array(
				'key'      => 'group_bw_access',
				'title'    => __( 'Who can edit this', 'kadence-child' ),
				'fields'   => array(
					array(
						'key'           => 'field_bw_access_editors',
						'name'          => 'bw_editors',
						'label'         => '',
						'instructions'  => __( 'These people can edit and update this item — whatever their role. They can’t create, delete, or touch anything else. Whole-section access lives under Users → Site Access.', 'kadence-child' ),
						'type'          => 'user',
						'multiple'      => 1,
						'allow_null'    => 1,
						'return_format' => 'id',
					),
				),
				'location' => $locations,
				'position' => 'side',
			)
		);
	}
);

/** Canonical storage: an array of string IDs (what the grant queries LIKE on). */
add_filter(
	'acf/update_value/key=field_bw_access_editors',
	function ( $value ) {
		return array_map( 'strval', array_filter( array_map( 'intval', (array) $value ) ) );
	}
);

// ─────────────────────────────────────────────────────────────────────────────
// Users → Site Access (the management screen)
// ─────────────────────────────────────────────────────────────────────────────

add_action(
	'admin_menu',
	function () {
		add_users_page(
			__( 'Site Access', 'kadence-child' ),
			__( 'Site Access', 'kadence-child' ),
			'manage_options',
			'bw-site-access',
			'bw_access_render_screen'
		);
	}
);

/** Save handler — runs before output so it can redirect. */
add_action(
	'admin_init',
	function () {
		if ( ! isset( $_POST['bw_access_save'], $_POST['bw_access_user'] ) ) {
			return;
		}
		if ( ! current_user_can( 'manage_options' ) ) {
			return;
		}
		check_admin_referer( 'bw_access_manage' );

		$user_id = bw_access_apply_submission( (int) $_POST['bw_access_user'], wp_unslash( $_POST ) );
		if ( ! $user_id ) {
			return;
		}
		wp_safe_redirect(
			add_query_arg(
				array( 'page' => 'bw-site-access', 'user_id' => $user_id, 'updated' => 1 ),
				admin_url( 'users.php' )
			)
		);
		exit;
	}
);

/**
 * Apply one submission of the Site Access form.
 *
 * Split out from the hook so it can be driven directly — by tests, and by
 * wp-cli when a batch of grants has to be applied from a list. The hook owns
 * the nonce, the permission check and the redirect; this owns the decisions.
 *
 * @param int   $user_id Person whose access is being set.
 * @param array $form    The submitted form ($_POST, unslashed).
 * @return int The user id on success, 0 if there was nothing to do.
 */
function bw_access_apply_submission( $user_id, array $form ) {
		$user = get_userdata( (int) $user_id );
		// Real Administrators (by ROLE) have nothing to manage here. Checked on
		// the role rather than the capability on purpose: someone who has been
		// GRANTED administration still needs to be editable, or the grant would
		// be a one-way door.
		if ( ! $user || bw_access_via_role( $user, 'manage_options' ) ) {
			return 0;
		}

		$wanted = isset( $form['bw_sections'] ) ? array_map( 'sanitize_key', array_keys( (array) $form['bw_sections'] ) ) : array();
		foreach ( bw_access_types() as $type => $t ) {
			if ( 'all' === bw_access_role_reach( $user, $t['plural'] ) ) {
				continue; // role-covered for everyone's items: no checkbox was shown.
			}
			$has  = ! empty( $user->caps[ 'edit_' . $t['plural'] ] );
			$want = in_array( $type, $wanted, true );
			if ( $want && ! $has ) {
				foreach ( bw_access_family_caps( $t['plural'] ) as $cap ) {
					$user->add_cap( $cap );
				}
			} elseif ( ! $want && $has ) {
				foreach ( bw_access_family_caps( $t['plural'] ) as $cap ) {
					$user->remove_cap( $cap );
				}
			}
		}

		// Tool areas (Forms, Lead AI …).
		$tools_wanted = isset( $form['bw_tools'] ) ? array_map( 'sanitize_key', array_keys( (array) $form['bw_tools'] ) ) : array();
		foreach ( bw_access_tools() as $key => $tool ) {
			if ( bw_access_via_role( $user, $tool['marker'] ) ) {
				continue; // role-covered: no checkbox was shown.
			}
			$has  = ! empty( $user->caps[ $tool['marker'] ] );
			$want = in_array( $key, $tools_wanted, true );
			if ( $want && ! $has ) {
				foreach ( $tool['caps'] as $cap ) {
					$user->add_cap( $cap );
				}
			} elseif ( ! $want && $has ) {
				foreach ( $tool['caps'] as $cap ) {
					$user->remove_cap( $cap );
				}
			}
		}

		// Site administration. Never removes a capability the user's ROLE gives
		// them — this switch only ever manages the individually granted copy.
		$admin_want = ! empty( $form['bw_admin'] );
		$admin_has  = ! empty( $user->caps['manage_options'] );
		if ( $admin_want && ! $admin_has ) {
			foreach ( bw_access_admin_caps() as $cap ) {
				$user->add_cap( $cap );
			}
		} elseif ( ! $admin_want && $admin_has ) {
			foreach ( bw_access_admin_caps() as $cap ) {
				$user->remove_cap( $cap );
			}
		}

		return $user->ID;
}

/**
 * Every user holding an INDIVIDUAL grant of any kind — section, tool or
 * administration. Role-derived access is excluded on purpose: this table is for
 * spotting access someone was handed personally, which is the kind that gets
 * forgotten.
 */
function bw_access_section_grant_holders() {
	global $wpdb;

	$markers = array();
	foreach ( bw_access_types() as $t ) {
		$markers[ 'edit_' . $t['plural'] ] = $t['label'];
	}
	foreach ( bw_access_tools() as $tool ) {
		$markers[ $tool['marker'] ] = wp_strip_all_tags( $tool['label'] );
	}
	$markers['manage_options'] = __( 'Site administration', 'kadence-child' );

	$likes = array();
	$vals  = array( $wpdb->prefix . 'capabilities' );
	foreach ( array_keys( $markers ) as $cap ) {
		$likes[] = 'meta_value LIKE %s';
		$vals[]  = '%' . $wpdb->esc_like( '"' . $cap . '"' ) . '%';
	}
	$sql = "SELECT user_id, meta_value FROM {$wpdb->usermeta} WHERE meta_key = %s AND ( " . implode( ' OR ', $likes ) . ' )';
	// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- placeholders built above, values bound here.
	$rows = $wpdb->get_results( $wpdb->prepare( $sql, $vals ) );

	$out = array();
	foreach ( (array) $rows as $row ) {
		$caps = maybe_unserialize( $row->meta_value );
		if ( ! is_array( $caps ) ) {
			continue;
		}
		$held = array();
		foreach ( $markers as $cap => $label ) {
			if ( ! empty( $caps[ $cap ] ) ) {
				$held[] = $label;
			}
		}
		if ( $held ) {
			$out[ (int) $row->user_id ] = $held;
		}
	}
	return $out;
}

/** Every per-page grant on the site, as user_id → array of post IDs. */
function bw_access_all_page_grants() {
	global $wpdb;
	// Type filter matters: ACF copies meta onto revisions, which would
	// otherwise show every granted page twice.
	$types        = array_keys( bw_access_types() );
	$placeholders = implode( ', ', array_fill( 0, count( $types ), '%s' ) );
	// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- placeholders built above, values bound here.
	$rows = $wpdb->get_results(
		$wpdb->prepare(
			"SELECT m.post_id, m.meta_value FROM {$wpdb->postmeta} m
			 INNER JOIN {$wpdb->posts} p ON p.ID = m.post_id
			 WHERE m.meta_key = 'bw_editors' AND m.meta_value LIKE 'a:%%' AND m.meta_value NOT LIKE 'a:0:%%'
			   AND p.post_status NOT IN ( 'trash', 'auto-draft' )
			   AND p.post_type IN ( {$placeholders} )",
			$types
		)
	);
	$out = array();
	foreach ( (array) $rows as $row ) {
		foreach ( array_filter( array_map( 'intval', (array) maybe_unserialize( $row->meta_value ) ) ) as $uid ) {
			$out[ $uid ][] = (int) $row->post_id;
		}
	}
	return $out;
}

function bw_access_render_screen() {
	if ( ! current_user_can( 'manage_options' ) ) {
		wp_die( esc_html__( 'Sorry, you are not allowed to access this page.', 'kadence-child' ) );
	}
	$user_id = isset( $_GET['user_id'] ) ? (int) $_GET['user_id'] : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
	$user    = $user_id ? get_userdata( $user_id ) : false;
	?>
	<div class="wrap">
		<h1><?php esc_html_e( 'Site Access', 'kadence-child' ); ?></h1>
		<p style="max-width:46em">
			<?php esc_html_e( 'Give any person access to a whole section here. To let someone edit one specific page, open that page and add them under “Who can edit this” in the sidebar.', 'kadence-child' ); ?>
		</p>
		<?php if ( ! empty( $_GET['updated'] ) ) : // phpcs:ignore WordPress.Security.NonceVerification.Recommended ?>
			<div class="notice notice-success is-dismissible"><p><?php esc_html_e( 'Access updated.', 'kadence-child' ); ?></p></div>
		<?php endif; ?>

		<form method="get" style="margin:1em 0 2em">
			<input type="hidden" name="page" value="bw-site-access" />
			<label for="bw-access-user"><strong><?php esc_html_e( 'Person:', 'kadence-child' ); ?></strong></label>
			<?php
			wp_dropdown_users(
				array(
					'name'             => 'user_id',
					'id'               => 'bw-access-user',
					'selected'         => $user_id,
					'show_option_none' => __( '— choose a person —', 'kadence-child' ),
					'show'             => 'display_name_with_login',
					'orderby'          => 'display_name',
				)
			);
			?>
			<button class="button"><?php esc_html_e( 'View', 'kadence-child' ); ?></button>
		</form>
		<script>
		/* Switch as soon as a name is picked. Choosing someone and then editing
		   the checkboxes WITHOUT switching would have saved one person's access
		   onto another — the button stays for anyone without JavaScript. */
		( function () {
			var sel = document.getElementById( 'bw-access-user' );
			if ( sel ) {
				sel.addEventListener( 'change', function () { sel.form.submit(); } );
			}
		} )();
		</script>

		<?php
		if ( $user ) {
			bw_access_render_user_panel( $user );
		} else {
			bw_access_render_audit();
		}
		?>
	</div>
	<?php
}

function bw_access_render_user_panel( WP_User $user ) {
	$role_names = array();
	foreach ( $user->roles as $role_slug ) {
		$role_obj     = get_role( $role_slug );
		$names        = wp_roles()->get_names();
		$role_names[] = isset( $names[ $role_slug ] ) ? translate_user_role( $names[ $role_slug ] ) : $role_slug;
	}
	echo '<h2>' . esc_html( $user->display_name ) . ' <small style="font-weight:normal">(' . esc_html( implode( ', ', $role_names ) ) . ')</small></h2>';

	// What their role already gives them, in words. Without this the reader has
	// to infer it from which checkboxes happen to be missing.
	$role_notes = bw_access_role_notes();
	$said       = array();
	foreach ( (array) $user->roles as $role_slug ) {
		if ( isset( $role_notes[ $role_slug ] ) ) {
			$said[] = $role_notes[ $role_slug ];
		}
	}
	if ( $said ) {
		echo '<p class="description" style="max-width:46em;margin:0 0 1.5em">' . esc_html( implode( ' ', $said ) ) . '</p>';
	}

	if ( bw_access_via_role( $user, 'manage_options' ) ) {
		echo '<p>' . esc_html__( 'Administrators already have full access to everything — there is nothing to grant.', 'kadence-child' ) . '</p>';
		return;
	}
	?>
	<form method="post">
		<?php wp_nonce_field( 'bw_access_manage' ); ?>
		<input type="hidden" name="bw_access_user" value="<?php echo esc_attr( $user->ID ); ?>" />
		<h3><?php esc_html_e( 'Whole sections', 'kadence-child' ); ?></h3>
		<p class="description"><?php esc_html_e( 'Full access to everything in the section: create, edit, publish and delete.', 'kadence-child' ); ?></p>
		<table class="form-table" role="presentation"><tbody>
		<?php foreach ( bw_access_types() as $type => $t ) : ?>
			<?php
			$individual = ! empty( $user->caps[ 'edit_' . $t['plural'] ] );
			$reach      = bw_access_role_reach( $user, $t['plural'] );
			?>
			<tr>
				<th scope="row" style="padding:8px 10px 8px 0"><?php echo esc_html( $t['label'] ); ?></th>
				<td style="padding:8px 10px">
					<?php if ( 'all' === $reach ) : ?>
						<em><?php esc_html_e( 'Already covered by their role.', 'kadence-child' ); ?></em>
					<?php else : ?>
						<label>
							<input type="checkbox" name="bw_sections[<?php echo esc_attr( $type ); ?>]" <?php checked( $individual ); ?> />
							<?php esc_html_e( 'Full access', 'kadence-child' ); ?>
						</label>
						<?php if ( 'own' === $reach ) : ?>
							<p class="description" style="margin:2px 0 0"><?php esc_html_e( 'Their role already lets them create these and edit their own. Tick this so they can edit everyone\'s.', 'kadence-child' ); ?></p>
						<?php endif; ?>
					<?php endif; ?>
				</td>
			</tr>
		<?php endforeach; ?>
		</tbody></table>

		<?php $tools = bw_access_tools(); ?>
		<?php if ( $tools ) : ?>
			<h3><?php esc_html_e( 'Tools', 'kadence-child' ); ?></h3>
			<p class="description"><?php esc_html_e( 'Access to a plugin area, without any content access.', 'kadence-child' ); ?></p>
			<table class="form-table" role="presentation"><tbody>
			<?php foreach ( $tools as $key => $tool ) : ?>
				<tr>
					<th scope="row" style="padding:8px 10px 8px 0"><?php echo wp_kses_post( $tool['label'] ); ?></th>
					<td style="padding:8px 10px">
						<?php if ( bw_access_via_role( $user, $tool['marker'] ) ) : ?>
							<em><?php esc_html_e( 'Already covered by their role.', 'kadence-child' ); ?></em>
						<?php else : ?>
							<label>
								<input type="checkbox" name="bw_tools[<?php echo esc_attr( $key ); ?>]" <?php checked( ! empty( $user->caps[ $tool['marker'] ] ) ); ?> />
								<?php esc_html_e( 'Can access', 'kadence-child' ); ?>
							</label>
							<p class="description" style="margin:2px 0 0"><?php echo esc_html( $tool['note'] ); ?></p>
						<?php endif; ?>
					</td>
				</tr>
			<?php endforeach; ?>
			</tbody></table>
		<?php endif; ?>

		<h3><?php esc_html_e( 'Site administration', 'kadence-child' ); ?></h3>
		<p class="description" style="max-width:46em">
			<?php esc_html_e( 'For IT: plugins, themes, tools and settings — but no pages, posts or other content. Adding content access, if they need it, is done in the sections above.', 'kadence-child' ); ?>
		</p>
		<div class="notice notice-warning inline" style="margin:8px 0 12px;max-width:46em"><p>
			<?php esc_html_e( 'Give this only to someone you would trust with a full administrator account. It keeps IT out of the content, but anyone who can install a plugin can ultimately do anything — it is a tidiness boundary, not a security one.', 'kadence-child' ); ?>
		</p></div>
		<table class="form-table" role="presentation"><tbody>
			<tr>
				<th scope="row" style="padding:8px 10px 8px 0"><?php esc_html_e( 'Administration', 'kadence-child' ); ?></th>
				<td style="padding:8px 10px">
					<label>
						<input type="checkbox" name="bw_admin" value="1" <?php checked( ! empty( $user->caps['manage_options'] ) ); ?> />
						<?php esc_html_e( 'Can manage plugins, themes, tools and settings', 'kadence-child' ); ?>
					</label>
				</td>
			</tr>
		</tbody></table>

		<p>
			<button class="button button-primary" name="bw_access_save" value="1">
				<?php
				/* translators: %s: person's name. */
				printf( esc_html__( 'Save access for %s', 'kadence-child' ), esc_html( $user->display_name ) );
				?>
			</button>
		</p>
	</form>
	<?php
	$granted = array();
	foreach ( array_keys( bw_access_types() ) as $type ) {
		foreach ( bw_access_user_granted_ids( $user->ID, $type ) as $pid ) {
			$granted[] = $pid;
		}
	}
	echo '<h3>' . esc_html__( 'Individual pages', 'kadence-child' ) . '</h3>';
	if ( $granted ) {
		echo '<ul>';
		foreach ( $granted as $pid ) {
			$link = get_edit_post_link( $pid );
			echo '<li><a href="' . esc_url( $link ) . '">' . esc_html( get_the_title( $pid ) ) . '</a> <span class="description">(' . esc_html( get_post_type( $pid ) ) . ')</span></li>';
		}
		echo '</ul>';
		echo '<p class="description">' . esc_html__( 'Add or remove people on the page itself, under “Who can edit this”.', 'kadence-child' ) . '</p>';
	} else {
		echo '<p class="description">' . esc_html__( 'None. To grant one, open the page and add them under “Who can edit this”.', 'kadence-child' ) . '</p>';
	}
}

function bw_access_render_audit() {
	$sections = bw_access_section_grant_holders();
	$pages    = bw_access_all_page_grants();
	$user_ids = array_unique( array_merge( array_keys( $sections ), array_keys( $pages ) ) );

	echo '<h2>' . esc_html__( 'Everyone with individual access', 'kadence-child' ) . '</h2>';
	if ( ! $user_ids ) {
		echo '<p class="description">' . esc_html__( 'Nobody yet. Pick a person above to grant section access, or open a page and use “Who can edit this”.', 'kadence-child' ) . '</p>';
		return;
	}
	sort( $user_ids );
	echo '<table class="wp-list-table widefat fixed striped"><thead><tr>';
	echo '<th>' . esc_html__( 'Person', 'kadence-child' ) . '</th>';
	echo '<th>' . esc_html__( 'Sections &amp; tools', 'kadence-child' ) . '</th>';
	echo '<th>' . esc_html__( 'Individual pages', 'kadence-child' ) . '</th>';
	echo '<th></th>';
	echo '</tr></thead><tbody>';
	foreach ( $user_ids as $uid ) {
		$u = get_userdata( $uid );
		if ( ! $u ) {
			continue;
		}
		echo '<tr>';
		echo '<td>' . esc_html( $u->display_name ) . ' <span class="description">(' . esc_html( $u->user_login ) . ')</span></td>';
		echo '<td>' . esc_html( isset( $sections[ $uid ] ) ? implode( ', ', $sections[ $uid ] ) : '—' ) . '</td>';
		echo '<td>';
		if ( isset( $pages[ $uid ] ) ) {
			$links = array();
			foreach ( $pages[ $uid ] as $pid ) {
				$links[] = '<a href="' . esc_url( get_edit_post_link( $pid ) ) . '">' . esc_html( get_the_title( $pid ) ) . '</a>';
			}
			echo wp_kses_post( implode( ', ', $links ) );
		} else {
			echo '—';
		}
		echo '</td>';
		echo '<td><a href="' . esc_url( add_query_arg( array( 'page' => 'bw-site-access', 'user_id' => $uid ), admin_url( 'users.php' ) ) ) . '">' . esc_html__( 'Manage', 'kadence-child' ) . '</a></td>';
		echo '</tr>';
	}
	echo '</tbody></table>';
}
