<?php
/**
 * Admin class
 *
 * Handles WordPress admin interface for PressPrimer Assignment.
 *
 * @package PressPrimer_Assignment
 * @subpackage Admin
 * @since 1.0.0
 */

// Prevent direct access.
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Admin class
 *
 * Manages admin menus, pages, and assets for the plugin.
 * Follows the same hybrid architecture as PressPrimer Quiz:
 * PHP WP_List_Table for list views, React for editors and dashboards.
 *
 * @since 1.0.0
 */
class PressPrimer_Assignment_Admin {

	/**
	 * Sub-admin instances
	 *
	 * Stored so the same instance used for screen_options()
	 * is reused for render(), preserving the list_table reference.
	 *
	 * @since 1.0.0
	 * @var array
	 */
	private $sub_admins = [];

	/**
	 * Initialize admin functionality
	 *
	 * Hooks into WordPress admin to add menus and enqueue assets.
	 *
	 * @since 1.0.0
	 */
	public function init() {
		add_action( 'admin_menu', [ $this, 'register_menus' ] );
		add_action( 'admin_menu', [ $this, 'add_grading_badge' ], 999 );
		add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_assets' ] );
		add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_global_styles' ] );
		add_action( 'admin_init', [ $this, 'maybe_flag_whats_new' ] );

		// One-time post-activation redirect to the guided tour (2.2).
		add_action( 'admin_init', [ $this, 'maybe_redirect_to_setup' ] );

		// Initialize sub-admin classes.
		$this->init_sub_admins();
	}

	/**
	 * Initialize sub-admin page classes
	 *
	 * Each sub-admin class handles its own hooks (admin_init, screen options, etc.).
	 *
	 * @since 1.0.0
	 */
	private function init_sub_admins() {
		$classes = [
			'settings'    => 'PressPrimer_Assignment_Admin_Settings',
			'assignments' => 'PressPrimer_Assignment_Admin_Assignments',
			'submissions' => 'PressPrimer_Assignment_Admin_Submissions',
			'categories'  => 'PressPrimer_Assignment_Admin_Categories',
			'grading'     => 'PressPrimer_Assignment_Admin_Grading',
			'reports'     => 'PressPrimer_Assignment_Admin_Reports',
		];

		foreach ( $classes as $key => $class_name ) {
			if ( class_exists( $class_name ) ) {
				$this->sub_admins[ $key ] = new $class_name();
				$this->sub_admins[ $key ]->init();
			}
		}

		// Tags uses the same class as categories with 'tag' taxonomy.
		if ( class_exists( 'PressPrimer_Assignment_Admin_Categories' ) ) {
			$this->sub_admins['tags'] = new PressPrimer_Assignment_Admin_Categories( 'tag' );
			$this->sub_admins['tags']->init();
		}
	}

	/**
	 * Register admin menus
	 *
	 * Creates the main Assignments menu and all submenus.
	 *
	 * @since 1.0.0
	 */
	public function register_menus() {
		/**
		 * Filters the plugin name displayed in the admin menu.
		 *
		 * Used by Enterprise addon for white-label branding.
		 *
		 * @since 1.0.0
		 *
		 * @param string $name Default plugin name.
		 */
		$plugin_name = apply_filters(
			'pressprimer_assignment_plugin_name',
			__( 'Assignments', 'pressprimer-assignment' )
		);

		// Main menu page (Dashboard).
		add_menu_page(
			$plugin_name,
			$plugin_name,
			PressPrimer_Assignment_Capabilities::PPA_CAP_MANAGE_OWN,
			'pressprimer-assignment',
			[ $this, 'render_dashboard' ],
			$this->get_menu_icon(),
			31
		);

		// Dashboard submenu (replaces main menu link).
		add_submenu_page(
			'pressprimer-assignment',
			__( 'Dashboard', 'pressprimer-assignment' ),
			__( 'Dashboard', 'pressprimer-assignment' ),
			PressPrimer_Assignment_Capabilities::PPA_CAP_MANAGE_OWN,
			'pressprimer-assignment',
			[ $this, 'render_dashboard' ]
		);

		// Assignments submenu.
		add_submenu_page(
			'pressprimer-assignment',
			__( 'Assignments', 'pressprimer-assignment' ),
			__( 'Assignments', 'pressprimer-assignment' ),
			PressPrimer_Assignment_Capabilities::PPA_CAP_MANAGE_OWN,
			'pressprimer-assignment-assignments',
			[ $this, 'render_assignments' ]
		);

		// Submissions submenu.
		add_submenu_page(
			'pressprimer-assignment',
			__( 'Submissions', 'pressprimer-assignment' ),
			__( 'Submissions', 'pressprimer-assignment' ),
			PressPrimer_Assignment_Capabilities::PPA_CAP_MANAGE_OWN,
			'pressprimer-assignment-submissions',
			[ $this, 'render_submissions' ]
		);

		// Grading queue submenu.
		add_submenu_page(
			'pressprimer-assignment',
			__( 'Grading', 'pressprimer-assignment' ),
			__( 'Grading', 'pressprimer-assignment' ),
			PressPrimer_Assignment_Capabilities::PPA_CAP_MANAGE_OWN,
			'pressprimer-assignment-grading',
			[ $this, 'render_grading' ]
		);

		// Categories submenu.
		add_submenu_page(
			'pressprimer-assignment',
			__( 'Categories', 'pressprimer-assignment' ),
			__( 'Categories', 'pressprimer-assignment' ),
			PressPrimer_Assignment_Capabilities::PPA_CAP_MANAGE_OWN,
			'pressprimer-assignment-categories',
			[ $this, 'render_categories' ]
		);

		// Tags submenu.
		add_submenu_page(
			'pressprimer-assignment',
			__( 'Tags', 'pressprimer-assignment' ),
			__( 'Tags', 'pressprimer-assignment' ),
			PressPrimer_Assignment_Capabilities::PPA_CAP_MANAGE_OWN,
			'pressprimer-assignment-tags',
			[ $this, 'render_tags' ]
		);

		// Reports submenu.
		add_submenu_page(
			'pressprimer-assignment',
			__( 'Reports', 'pressprimer-assignment' ),
			__( 'Reports', 'pressprimer-assignment' ),
			PressPrimer_Assignment_Capabilities::PPA_CAP_VIEW_REPORTS,
			'pressprimer-assignment-reports',
			[ $this, 'render_reports' ]
		);

		// Settings submenu.
		add_submenu_page(
			'pressprimer-assignment',
			__( 'Settings', 'pressprimer-assignment' ),
			__( 'Settings', 'pressprimer-assignment' ),
			PressPrimer_Assignment_Capabilities::PPA_CAP_MANAGE_SETTINGS,
			'pressprimer-assignment-settings',
			[ $this, 'render_settings' ]
		);

		/**
		 * Fires after the core admin menu items are registered.
		 *
		 * Premium addons should use this hook to add their own submenu pages.
		 *
		 * @since 1.0.0
		 */
		do_action( 'pressprimer_assignment_admin_menu' );
	}

	/**
	 * Redirect the activating user to the setup wizard — once, ever
	 *
	 * Consumes the short-lived flag the activator set. All guards live in
	 * get_setup_redirect_url(); this wrapper only performs the redirect.
	 *
	 * @since 2.2.0
	 */
	public function maybe_redirect_to_setup() {
		$url = $this->get_setup_redirect_url();

		if ( $url ) {
			wp_safe_redirect( $url );
			exit;
		}
	}

	/**
	 * Resolve whether this request should redirect to the setup wizard
	 *
	 * Redirects only when ALL guards pass:
	 * - normal web request (not AJAX, cron, or WP-CLI)
	 * - not the network admin
	 * - the flag exists and records the current user
	 * - not a bulk activation (activate-multi)
	 * - the user can manage assignments
	 * - no assignments exist yet (reinstall guard)
	 *
	 * The flag is deleted before any redirect (and on terminal guard
	 * failures for the matched user), so the redirect can never fire
	 * twice — anything interrupted is caught by the setup banner instead.
	 *
	 * @since 2.2.0
	 *
	 * @return string|null Redirect URL, or null when no redirect happens.
	 */
	private function get_setup_redirect_url() {
		if ( wp_doing_ajax() || wp_doing_cron() ) {
			return null;
		}

		if ( defined( 'WP_CLI' ) && WP_CLI ) {
			return null;
		}

		if ( is_network_admin() ) {
			return null;
		}

		$flag_user = (int) get_transient( 'pressprimer_assignment_setup_redirect' );

		if ( ! $flag_user || get_current_user_id() !== $flag_user ) {
			// No flag, or it belongs to another user — leave it for them
			// (it expires on its own).
			return null;
		}

		// Bulk activation: never redirect, and never retry — the banner
		// takes over from here.
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only detection of the bulk-activation context.
		if ( isset( $_GET['activate-multi'] ) ) {
			delete_transient( 'pressprimer_assignment_setup_redirect' );
			return null;
		}

		if ( ! current_user_can( PressPrimer_Assignment_Capabilities::PPA_CAP_MANAGE_OWN )
			&& ! current_user_can( PressPrimer_Assignment_Capabilities::PPA_CAP_MANAGE_ALL )
		) {
			delete_transient( 'pressprimer_assignment_setup_redirect' );
			return null;
		}

		// Single-shot: the flag is consumed before redirecting.
		delete_transient( 'pressprimer_assignment_setup_redirect' );

		// Reinstall guard: an install with existing assignments never
		// redirects (the version option guard lives in the activator).
		global $wpdb;
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from $wpdb->prefix; one-time activation check.
		$assignment_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}ppa_assignments" );

		if ( $assignment_count > 0 ) {
			return null;
		}

		// The guided tour auto-opens on the PPA dashboard for users whose
		// should_show state is fresh — that's where the redirect lands.
		return admin_url( 'admin.php?page=pressprimer-assignment' );
	}

	/**
	 * Detect a plugin update and arm the What's New panel
	 *
	 * Plugin updates never fire activation, so the stored version
	 * option goes stale until this check runs. When an UPDATE crosses
	 * into 2.2.0 the What's New marker is set; fresh installs never
	 * qualify (activation writes the current version before this ever
	 * sees a mismatch). The reactivation-style update path is covered
	 * by the same check in the activator.
	 *
	 * @since 2.2.0
	 */
	public function maybe_flag_whats_new() {
		$stored = get_option( 'pressprimer_assignment_version' );

		if ( false === $stored || PRESSPRIMER_ASSIGNMENT_VERSION === $stored ) {
			return;
		}

		if ( class_exists( 'PressPrimer_Assignment_Email_Optin_Service' ) ) {
			PressPrimer_Assignment_Email_Optin_Service::maybe_flag_whats_new_on_update( $stored );
		}

		update_option( 'pressprimer_assignment_version', PRESSPRIMER_ASSIGNMENT_VERSION );
	}

	/**
	 * Enqueue admin-wide styles
	 *
	 * The grading count badge renders in the admin menu on EVERY admin
	 * page, so its styles cannot live in the PPA-page-only stylesheet.
	 * The badge also owns its full styling: WordPress 7.0's admin
	 * redesign drops the core count-bubble background on hover, so the
	 * badge no longer borrows core's .awaiting-mod classes for any
	 * state.
	 *
	 * @since 2.2.0
	 */
	public function enqueue_global_styles() {
		wp_register_style( 'ppa-admin-global', false, [], PRESSPRIMER_ASSIGNMENT_VERSION );
		wp_enqueue_style( 'ppa-admin-global' );

		// Static literal CSS only — no variables interpolated.
		$css = '#adminmenu .ppa-menu-counter {'
			. 'display: inline-block;'
			. 'vertical-align: top;'
			. 'box-sizing: border-box;'
			. 'margin: 1px 0 -1px 4px;'
			. 'padding: 0 6px;'
			. 'min-width: 18px;'
			. 'height: 18px;'
			. 'border-radius: 9px;'
			. 'background-color: #2271b1;'
			. 'color: #fff;'
			. 'font-size: 11px;'
			. 'line-height: 18px;'
			. 'text-align: center;'
			. '}';

		wp_add_inline_style( 'ppa-admin-global', $css );
	}

	/**
	 * Enqueue admin assets
	 *
	 * Loads CSS and JavaScript on PressPrimer Assignment admin pages.
	 * React bundles are loaded conditionally per page.
	 *
	 * @since 1.0.0
	 *
	 * @param string $hook Current admin page hook.
	 */
	public function enqueue_assets( $hook ) {
		// Get current page from URL for robust page detection.
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only page check.
		$current_page = isset( $_GET['page'] ) ? sanitize_text_field( wp_unslash( $_GET['page'] ) ) : '';

		// Only load on PressPrimer Assignment admin pages.
		$is_ppa_page = false !== strpos( $hook, 'pressprimer-assignment' )
			|| ( ! empty( $current_page ) && 0 === strpos( $current_page, 'pressprimer-assignment' ) );

		if ( ! $is_ppa_page ) {
			return;
		}

		// Enqueue admin CSS.
		wp_enqueue_style(
			'ppa-admin',
			PRESSPRIMER_ASSIGNMENT_PLUGIN_URL . 'assets/css/admin.css',
			[],
			PRESSPRIMER_ASSIGNMENT_VERSION
		);

		// Enqueue admin JavaScript.
		wp_enqueue_script(
			'ppa-admin',
			PRESSPRIMER_ASSIGNMENT_PLUGIN_URL . 'assets/js/admin.js',
			[ 'jquery' ],
			PRESSPRIMER_ASSIGNMENT_VERSION,
			true
		);

		// Localize script with data.
		wp_localize_script(
			'ppa-admin',
			'pressprimerAssignmentAdmin',
			[
				'ajaxUrl' => admin_url( 'admin-ajax.php' ),
				'nonce'   => wp_create_nonce( 'pressprimer_assignment_admin_nonce' ),
				'debug'   => defined( 'WP_DEBUG' ) && WP_DEBUG,
				'addons'  => [
					'educator'   => PressPrimer_Assignment_Addon_Manager::is_educator_active(),
					'school'     => PressPrimer_Assignment_Addon_Manager::is_school_active(),
					'enterprise' => PressPrimer_Assignment_Addon_Manager::is_enterprise_active(),
				],
				'strings' => [
					'confirmDelete'      => __( 'Are you sure you want to delete this item?', 'pressprimer-assignment' ),
					'confirmDeleteTitle' => __( 'Delete Item', 'pressprimer-assignment' ),
					'error'              => __( 'An error occurred. Please try again.', 'pressprimer-assignment' ),
					'saved'              => __( 'Changes saved successfully.', 'pressprimer-assignment' ),
					'delete'             => __( 'Delete', 'pressprimer-assignment' ),
					'cancel'             => __( 'Cancel', 'pressprimer-assignment' ),
				],
			]
		);

		// Conditionally enqueue React bundles per page.
		$this->maybe_enqueue_react_bundle( $current_page );
	}

	/**
	 * Conditionally enqueue React bundles for specific admin pages
	 *
	 * Loads the compiled React bundle and its auto-generated asset
	 * dependencies for pages that use React-based interfaces.
	 *
	 * @since 1.0.0
	 *
	 * @param string $current_page Current admin page slug.
	 */
	private function maybe_enqueue_react_bundle( $current_page ) {
		$bundles = [
			'pressprimer-assignment'         => 'dashboard',
			'pressprimer-assignment-reports' => 'reports',
		];

		if ( ! isset( $bundles[ $current_page ] ) ) {
			return;
		}

		$bundle_name = $bundles[ $current_page ];
		$asset_file  = PRESSPRIMER_ASSIGNMENT_PLUGIN_PATH . 'build/' . $bundle_name . '.asset.php';

		if ( ! file_exists( $asset_file ) ) {
			return;
		}

		$asset = require $asset_file;

		wp_enqueue_script(
			'ppa-' . $bundle_name,
			PRESSPRIMER_ASSIGNMENT_PLUGIN_URL . 'build/' . $bundle_name . '.js',
			$asset['dependencies'],
			$asset['version'],
			true
		);

		// Enqueue the CSS if it exists.
		// @wordpress/scripts outputs CSS as style-{name}.css, not {name}.css.
		$css_file = PRESSPRIMER_ASSIGNMENT_PLUGIN_PATH . 'build/style-' . $bundle_name . '.css';
		if ( file_exists( $css_file ) ) {
			wp_enqueue_style(
				'ppa-' . $bundle_name,
				PRESSPRIMER_ASSIGNMENT_PLUGIN_URL . 'build/style-' . $bundle_name . '.css',
				[ 'ppa-admin' ],
				$asset['version']
			);
		}

		// wp-scripts emits a SECOND CSS file per entry — {name}.css — for
		// styles imported by components outside the entry's own style.css
		// (e.g. shared EmailOptinAsk.css). Without it those components
		// render unstyled.
		$component_css = PRESSPRIMER_ASSIGNMENT_PLUGIN_PATH . 'build/' . $bundle_name . '.css';
		if ( file_exists( $component_css ) ) {
			wp_enqueue_style(
				'ppa-' . $bundle_name . '-components',
				PRESSPRIMER_ASSIGNMENT_PLUGIN_URL . 'build/' . $bundle_name . '.css',
				[],
				$asset['version']
			);
		}

		// Localize bundle-specific data.
		if ( 'dashboard' === $bundle_name ) {
			$this->localize_dashboard_data();
		} elseif ( 'reports' === $bundle_name ) {
			$this->localize_reports_data();
		}
	}

	/**
	 * Localize dashboard data for the React app
	 *
	 * Passes PHP-derived metadata to the dashboard React bundle
	 * via wp_localize_script(). Mirrors the Quiz dashboard pattern.
	 *
	 * @since 1.0.0
	 */
	private function localize_dashboard_data() {
		// Determine if user is a teacher (not a full admin).
		$is_teacher = ! current_user_can( PressPrimer_Assignment_Capabilities::PPA_CAP_MANAGE_ALL )
			&& current_user_can( PressPrimer_Assignment_Capabilities::PPA_CAP_MANAGE_OWN );

		/**
		 * Filters the plugin name displayed in the dashboard.
		 *
		 * Used by Enterprise addon for white-label branding.
		 *
		 * @since 1.0.0
		 *
		 * @param string $name Default plugin name.
		 */
		$plugin_name = apply_filters(
			'pressprimer_assignment_plugin_name',
			__( 'PressPrimer Assignment', 'pressprimer-assignment' )
		);

		/**
		 * Filters the dashboard logo URL.
		 *
		 * Used by Enterprise addon for white-label branding.
		 *
		 * @since 1.0.0
		 *
		 * @param string $logo_url Default logo URL.
		 */
		$dashboard_logo = apply_filters(
			'pressprimer_assignment_dashboard_logo',
			PRESSPRIMER_ASSIGNMENT_PLUGIN_URL . 'assets/images/PressPrimer-Logo-White.svg'
		);

		/**
		 * Filters the dashboard welcome text.
		 *
		 * Used by Enterprise addon for white-label branding.
		 *
		 * @since 1.0.0
		 *
		 * @param string $text Default welcome text.
		 * @param string $name The plugin name (filtered).
		 */
		$welcome_text = apply_filters(
			'pressprimer_assignment_dashboard_welcome_text',
			/* translators: %s: plugin name */
			sprintf( __( 'Welcome to %s! Here\'s an overview of recent assignment activity.', 'pressprimer-assignment' ), $plugin_name ),
			$plugin_name
		);

		$user_id           = get_current_user_id();
		$optin_available   = class_exists( 'PressPrimer_Assignment_Email_Optin_Service' );
		$whats_new_visible = $optin_available
			&& PressPrimer_Assignment_Email_Optin_Service::whats_new_visible( $user_id );

		/**
		 * Filters the What's New panel sections.
		 *
		 * Major releases ship the free plugin and the addons together;
		 * each addon appends its own section here so the whole wave
		 * shows in one panel. Each section: [ 'title' => plugin name,
		 * 'items' => string[] ]. Plain text only — everything renders
		 * escaped.
		 *
		 * @since 2.2.0
		 *
		 * @param array<int, array{title: string, items: string[]}> $sections Release note sections.
		 */
		$whats_new_sections = apply_filters(
			'pressprimer_assignment_whats_new_sections',
			[
				[
					'title' => __( 'PressPrimer Assignment', 'pressprimer-assignment' ),
					'items' => [
						__( 'PowerPoint (.pptx) submissions — students can submit decks, and you can preview them as slides right in the grading view.', 'pressprimer-assignment' ),
						__( 'Due dates with a late policy — accept late work, deduct a percentage for a set number of days, or reject it, with an optional submission cutoff.', 'pressprimer-assignment' ),
						__( 'A new guided setup tour that walks you through building and publishing a real assignment.', 'pressprimer-assignment' ),
					],
				],
			]
		);

		// Sanitize the (filterable) sections element by element.
		$sanitized_sections = [];
		foreach ( (array) $whats_new_sections as $section ) {
			if ( ! is_array( $section ) || empty( $section['items'] ) ) {
				continue;
			}

			$sanitized_sections[] = [
				'title' => isset( $section['title'] ) ? sanitize_text_field( $section['title'] ) : '',
				'items' => array_values( array_map( 'sanitize_text_field', (array) $section['items'] ) ),
			];
		}

		wp_localize_script(
			'ppa-dashboard',
			'pressprimerAssignmentDashboardData',
			[
				'pluginUrl'     => PRESSPRIMER_ASSIGNMENT_PLUGIN_URL,
				'isTeacher'     => $is_teacher,
				'pluginName'    => $plugin_name,
				'dashboardLogo' => $dashboard_logo,
				'welcomeText'   => $welcome_text,
				'urls'          => [
					'create_assignment' => admin_url( 'admin.php?page=pressprimer-assignment-assignments&action=new' ),
					'submissions'       => admin_url( 'admin.php?page=pressprimer-assignment-submissions' ),
					'grading'           => admin_url( 'admin.php?page=pressprimer-assignment-grading' ),
					'reports'           => admin_url( 'admin.php?page=pressprimer-assignment-reports' ),
					'setup_wizard'      => class_exists( 'PressPrimer_Assignment_Onboarding' )
						? PressPrimer_Assignment_Onboarding::get_relaunch_url()
						: '',
				],
				// The one-time post-update What's New panel (011): release
				// notes paired with the ask. The panel shows regardless of
				// opt-in state; the ask inside resolves its own eligibility.
				'whatsNew'      => [
					'show'        => $whats_new_visible,
					'version'     => $optin_available
						? PressPrimer_Assignment_Email_Optin_Service::get_whats_new_version()
						: '',
					'sections'    => $sanitized_sections,
					'blogUrl'     => class_exists( 'PressPrimer_Assignment_Upgrade_Page' )
						? PressPrimer_Assignment_Upgrade_Page::utm_url(
							'https://pressprimer.com/blog/',
							'whats-new-read-more',
							'whats-new'
						)
						: 'https://pressprimer.com/blog/',
					'askEligible' => $optin_available
						&& PressPrimer_Assignment_Email_Optin_Service::is_eligible( $user_id, 'whats-new' ),
				],
				// The 011 email-course card: admins only (lifecycle
				// surfaces never render for teachers), suppressed once
				// answered anywhere, dismissible separately — and it
				// yields while the What's New panel is up (one ask per
				// moment, never stack).
				'emailOptin'    => [
					'eligible'   => ! $whats_new_visible
						&& current_user_can( 'manage_options' )
						&& $optin_available
						&& PressPrimer_Assignment_Email_Optin_Service::is_eligible( $user_id, 'dashboard-card' ),
					'privacyUrl' => 'https://pressprimer.com/privacy/',
				],
			]
		);
	}

	/**
	 * Localize reports data for the React app
	 *
	 * Passes PHP-derived metadata to the reports React bundle
	 * via wp_localize_script(). Mirrors the Quiz reports pattern.
	 *
	 * @since 1.0.0
	 */
	private function localize_reports_data() {
		/**
		 * Filters the list of addon report cards shown on the Reports page.
		 *
		 * Each report definition should contain:
		 * - key: Unique string identifier
		 * - title: Display title
		 * - description: Brief description
		 * - iconType: Ant Design icon name (e.g., 'AuditOutlined')
		 * - color: Background color for the icon (e.g., '#722ed1')
		 * - available: Boolean, whether the report is active
		 * - href: URL to the report page
		 *
		 * @since 2.0.0
		 *
		 * @param array $addon_reports Array of addon report definitions.
		 */
		$addon_reports = apply_filters( 'pressprimer_assignment_reports_addon_reports', [] );

		// Merge the registered cards with the premium report catalog: active
		// tiers resolve to the real cards registered above, inactive tiers
		// render as locked upgrade cards (administrators only), in a stable
		// catalog order so the grid never reflows when an addon is toggled.
		if ( class_exists( 'PressPrimer_Assignment_Upgrade_Page' ) ) {
			$addon_reports = PressPrimer_Assignment_Upgrade_Page::get_premium_report_cards( $addon_reports );
		}

		/**
		 * Filters the reports mascot image URL.
		 *
		 * Used by Enterprise addon for white-label branding.
		 *
		 * @since 1.0.0
		 *
		 * @param string $mascot_url Default mascot URL.
		 */
		$reports_mascot = apply_filters(
			'pressprimer_assignment_reports_header_mascot',
			PRESSPRIMER_ASSIGNMENT_PLUGIN_URL . 'assets/images/reports-mascot.png'
		);

		wp_localize_script(
			'ppa-reports',
			'pressprimerAssignmentReportsData',
			[
				'pluginUrl'     => PRESSPRIMER_ASSIGNMENT_PLUGIN_URL,
				'reportsMascot' => $reports_mascot,
				'addonReports'  => $addon_reports,
			]
		);
	}

	/**
	 * Render dashboard page
	 *
	 * Displays the main dashboard with overview and statistics.
	 *
	 * @since 1.0.0
	 */
	public function render_dashboard() {
		if ( ! current_user_can( PressPrimer_Assignment_Capabilities::PPA_CAP_MANAGE_OWN )
			&& ! current_user_can( PressPrimer_Assignment_Capabilities::PPA_CAP_MANAGE_ALL )
		) {
			wp_die(
				esc_html__( 'You do not have permission to access this page.', 'pressprimer-assignment' ),
				esc_html__( 'Permission Denied', 'pressprimer-assignment' ),
				[ 'response' => 403 ]
			);
		}

		echo '<div id="ppa-dashboard-root" class="ppa-admin-react-root"></div>';
	}

	/**
	 * Render assignments page
	 *
	 * @since 1.0.0
	 */
	public function render_assignments() {
		if ( isset( $this->sub_admins['assignments'] ) ) {
			$this->sub_admins['assignments']->render();
		}
	}

	/**
	 * Render submissions page
	 *
	 * @since 1.0.0
	 */
	public function render_submissions() {
		if ( isset( $this->sub_admins['submissions'] ) ) {
			$this->sub_admins['submissions']->render();
		}
	}

	/**
	 * Render grading queue page
	 *
	 * @since 1.0.0
	 */
	public function render_grading() {
		if ( isset( $this->sub_admins['grading'] ) ) {
			$this->sub_admins['grading']->render();
		}
	}

	/**
	 * Render categories page
	 *
	 * @since 1.0.0
	 */
	public function render_categories() {
		if ( isset( $this->sub_admins['categories'] ) ) {
			$this->sub_admins['categories']->render();
		}
	}

	/**
	 * Render tags page
	 *
	 * @since 1.0.0
	 */
	public function render_tags() {
		if ( isset( $this->sub_admins['tags'] ) ) {
			$this->sub_admins['tags']->render();
		}
	}

	/**
	 * Render reports page
	 *
	 * @since 1.0.0
	 */
	public function render_reports() {
		if ( ! current_user_can( PressPrimer_Assignment_Capabilities::PPA_CAP_VIEW_REPORTS ) ) {
			wp_die(
				esc_html__( 'You do not have permission to access this page.', 'pressprimer-assignment' ),
				esc_html__( 'Permission Denied', 'pressprimer-assignment' ),
				[ 'response' => 403 ]
			);
		}

		echo '<div id="ppa-reports-root" class="ppa-admin-react-root"></div>';
	}

	/**
	 * Render settings page
	 *
	 * @since 1.0.0
	 */
	public function render_settings() {
		if ( isset( $this->sub_admins['settings'] ) ) {
			$this->sub_admins['settings']->render();
		}
	}

	/**
	 * Add pending count badge to Grading menu item
	 *
	 * Appends the WordPress admin badge markup to the Grading
	 * submenu label showing how many submissions await grading.
	 *
	 * @since 1.0.0
	 */
	public function add_grading_badge() {
		if ( ! current_user_can( PressPrimer_Assignment_Capabilities::PPA_CAP_MANAGE_OWN )
			&& ! current_user_can( PressPrimer_Assignment_Capabilities::PPA_CAP_MANAGE_ALL )
		) {
			return;
		}

		if ( ! class_exists( 'PressPrimer_Assignment_Grading_Queue_Service' ) ) {
			return;
		}

		$count = PressPrimer_Assignment_Grading_Queue_Service::get_pending_count();

		if ( $count < 1 ) {
			return;
		}

		global $submenu;

		if ( ! isset( $submenu['pressprimer-assignment'] ) ) {
			return;
		}

		foreach ( $submenu['pressprimer-assignment'] as $key => $item ) {
			if ( 'pressprimer-assignment-grading' === $item[2] ) {
				$submenu['pressprimer-assignment'][ $key ][0] .= sprintf( // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- WordPress core pattern for admin menu badges.
					' <span class="ppa-menu-counter">%s</span>',
					number_format_i18n( $count )
				);
				break;
			}
		}
	}

	/**
	 * Get the menu icon as a base64-encoded SVG
	 *
	 * Returns a document icon for the admin menu.
	 *
	 * @since 1.0.0
	 *
	 * @return string Base64-encoded SVG data URI or custom icon URL.
	 */
	private function get_menu_icon() {
		/**
		 * Filters the admin menu icon.
		 *
		 * Used by Enterprise addon for white-label branding.
		 *
		 * @since 1.0.0
		 *
		 * @param string $icon_url Empty string (use default) or custom icon URL.
		 */
		$custom_icon = apply_filters( 'pressprimer_assignment_plugin_icon', '' );

		if ( ! empty( $custom_icon ) ) {
			return $custom_icon;
		}

		// Document/assignment icon SVG.
		$svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">'
			. '<path fill="#9CA1A7" d="M14 2H6C4.9 2 4 2.9 4 4v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6z'
			. 'M6 20V4h7v5h5v11H6z'
			. 'M8 15h8v2H8v-2z'
			. 'M8 11h8v2H8v-2z"/>'
			. '</svg>';

		// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Required for data URI in add_menu_page().
		return 'data:image/svg+xml;base64,' . base64_encode( $svg );
	}
}
