<?php

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

/**
 * Admin settings page and menu registration.
 */
class DOIG_Admin {

	public static function init() {
		add_action( 'admin_menu',            array( __CLASS__, 'register_menu' ) );
		add_action( 'admin_init',            array( __CLASS__, 'register_settings' ) );
		// Log level save / clear / download live in the shared-logging
		// component (DOIG_Log_UI registers its own admin-post handlers).
		add_action( 'admin_init',            array( __CLASS__, 'maybe_handle_pgp_notice_dismiss' ) );
		add_action( 'admin_notices',         array( __CLASS__, 'maybe_render_pgp_notice' ) );
		// Favicon nudge via the shared site-notices component (vendored
		// class-site-notices.php; _standards DECISIONS "Sitewide-issue
		// notices"). SSAA owns favicon_missing since 2026-07-24 - the
		// deferrable flag + the shared owner filter suppress DOIG's courtesy
		// copy when SSAA is active, and the shared dismissal store means one
		// dismissal covers every PlugUpp dashboard.
		DOIG_Site_Notices::boot( array(
			'screen_ids'      => array( 'toplevel_page_' . DOIG_PLUGIN_SLUG ),
			'findings_getter' => array( __CLASS__, 'site_notice_findings' ),
			'findings_url'    => admin_url( 'customize.php?autofocus[section]=title_tagline' ),
		) );
		add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_assets' ) );
		add_action( 'wp_ajax_doig_check_proxy_health', array( __CLASS__, 'ajax_check_proxy_health' ) );
		add_action( 'wp_ajax_doig_clear_cache',        array( __CLASS__, 'ajax_clear_cache' ) );
		add_action( 'wp_ajax_doig_regenerate_all', array( __CLASS__, 'ajax_regenerate_all' ) );
	}

	// -------------------------------------------------------------------------
	// PGP detection notice (2.3.0)
	//
	// Page Generator Pro generates large catalogues of pages programmatically.
	// On a typical PGP setup the new pages count toward DOIG's monthly quota
	// without any signal to the admin that this is happening - a customer with
	// 3,000 PGP-generated pages on the Agency-tier 500/month limit will
	// silently cover only 17% of their site indefinitely.
	//
	// One-time dismissible notice surfaces this at the moment both plugins are
	// detected on the site, with a click-through to the capacity-planning docs
	// that walk the customer through post-type exclusions or per-post
	// overrides. Per-user dismissal (user meta) so multiple admins can see
	// and dismiss independently.
	// -------------------------------------------------------------------------

	/** User meta key marking the PGP notice as dismissed by the current user. */
	const PGP_NOTICE_USER_META = '_doig_pgp_notice_dismissed';

	/**
	 * Is Page Generator Pro active on this site? Statically cached because
	 * is_plugin_active() touches the option store.
	 *
	 * @return bool
	 */
	private static function is_pgp_active() {
		static $cached = null;
		if ( null !== $cached ) {
			return $cached;
		}

		if ( ! function_exists( 'is_plugin_active' ) ) {
			require_once ABSPATH . 'wp-admin/includes/plugin.php';
		}

		$cached = is_plugin_active( 'page-generator-pro/page-generator-pro.php' );
		return $cached;
	}

	/**
	 * Render the PGP detection notice on every admin page until dismissed by
	 * the current user.
	 */
	public static function maybe_render_pgp_notice() {
		if ( ! current_user_can( 'manage_options' ) ) {
			return;
		}
		if ( ! self::is_pgp_active() ) {
			return;
		}
		if ( get_user_meta( get_current_user_id(), self::PGP_NOTICE_USER_META, true ) ) {
			return;
		}

		$capacity_url = DOIG_Plugin::docs_url( 'capacity-planning', 'pgp_notice' );
		$dismiss_url  = wp_nonce_url(
			add_query_arg( 'doig_dismiss', 'pgp_notice' ),
			'doig_dismiss_pgp_notice'
		);

		echo '<div class="notice notice-info is-dismissible doig-pgp-notice" style="border-left-color:#2271b1;">';
		echo '<p>';
		echo '<strong>' . esc_html__( 'PlugUpp Dynamic OG Image Generator: Page Generator Pro is active.', 'dynamic-og-image-generator' ) . '</strong> ';
		echo esc_html__( 'PGP-generated pages draw from your monthly screenshot quota. Configure post-type exclusions or per-post overrides to avoid quota exhaustion.', 'dynamic-og-image-generator' );
		echo ' <a href="' . esc_url( $capacity_url ) . '" target="_blank" rel="noopener">';
		echo esc_html__( 'Capacity planning', 'dynamic-og-image-generator' );
		echo ' &rarr;</a>';
		echo '</p>';
		// is-dismissible adds an X close button via core JS, but the click
		// only hides the notice for this pageload. The persistent dismissal
		// is the Dismiss link below, which writes the user meta.
		echo '<p style="margin-bottom:0;">';
		echo '<a href="' . esc_url( $dismiss_url ) . '" class="button-link" style="color:#646970;">';
		echo esc_html__( 'Dismiss this notice', 'dynamic-og-image-generator' );
		echo '</a>';
		echo '</p>';
		echo '</div>';
	}

	/**
	 * Mark the PGP notice as dismissed for the current user. Triggered by the
	 * "Dismiss this notice" link in the notice itself.
	 */
	public static function maybe_handle_pgp_notice_dismiss() {
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- nonce verified four lines below; this gate is a cheap shortcircuit on the action key first.
		if ( ! isset( $_GET['doig_dismiss'] ) || 'pgp_notice' !== sanitize_key( wp_unslash( $_GET['doig_dismiss'] ) ) ) {
			return;
		}
		if ( ! current_user_can( 'manage_options' ) ) {
			return;
		}
		if (
			! isset( $_GET['_wpnonce'] ) ||
			! wp_verify_nonce( sanitize_key( wp_unslash( $_GET['_wpnonce'] ) ), 'doig_dismiss_pgp_notice' )
		) {
			return;
		}

		update_user_meta( get_current_user_id(), self::PGP_NOTICE_USER_META, 1 );

		// Strip the dismissal params from the URL so a refresh doesn't try to
		// re-dismiss (which would fail nonce verification and produce a
		// confusing "did it work?" experience).
		wp_safe_redirect( remove_query_arg( array( 'doig_dismiss', '_wpnonce' ) ) );
		exit;
	}

	// -------------------------------------------------------------------------
	// Favicon notice - rendered by the shared site-notices component since the
	// 2026-07-24 retrofit (vendored includes/class-site-notices.php; boot in
	// init() above). The weekly check (DOIG_Cron::check_site_favicon) still
	// owns detection for DOIG-only installs and defers to SSAA when present.
	// -------------------------------------------------------------------------

	/**
	 * The weekly favicon check's result in the shared component's shape.
	 * deferrable: SSAA owns favicon_missing (rendered-homepage audit) - when
	 * it answers the shared owner filter, this courtesy copy is suppressed.
	 *
	 * @return array[]
	 */
	public static function site_notice_findings() {
		if ( ! (int) get_option( 'doig_favicon_missing', 0 ) ) {
			return array();
		}
		return array(
			array(
				'code'       => 'favicon_missing',
				'label'      => __( 'Missing favicon', 'dynamic-og-image-generator' ),
				'message'    => __( 'Your site does not appear to have a favicon. Chat platforms such as Slack, Discord and WhatsApp show a small site icon next to the link preview - without a favicon they render a broken globe instead, which makes your OG image look incomplete. Add a Site Icon under Appearance > Customize > Site Identity.', 'dynamic-og-image-generator' ),
				'checked_at' => (int) get_option( 'doig_favicon_checked_at', 0 ),
				'deferrable' => true,
			),
		);
	}


	// -------------------------------------------------------------------------
	// Menu
	// -------------------------------------------------------------------------

	/**
	 * Slug for the Settings sub-page. Used by the URL builders + the
	 * add_submenu_page registration below. Kept in a constant so we have
	 * one source of truth for the Settings URL.
	 */
	const SETTINGS_SLUG = 'doig-settings';

	/**
	 * Hook suffixes for the admin screens we register. Tracked so
	 * enqueue_assets() can match across both the top-level (Dashboard)
	 * and Settings sub-pages instead of hardcoding a single hook string
	 * that breaks the moment WordPress changes how it builds hook
	 * suffixes for submenu pages.
	 *
	 * @var string[]
	 */
	private static $page_hooks = array();

	public static function register_menu() {
		// 2.4.0: top-level menu (was add_options_page under Settings).
		// Brand consistency with SSAA, room for the PRO-badged outbound
		// submenu items the Independent Analytics upsell pattern needs,
		// and clean discoverability in the admin nav. See
		// `_standards/docs/CONVENTIONS.md` "Top-level menu standard".
		//
		// Position 60.25: directly below SSAA at 60.15, which is itself
		// below Appearance at 60. Decimal positions avoid the
		// same-integer collision where one plugin silently vanishes.
		$page_title = apply_filters( 'doig_admin_page_title', __( 'PlugUpp Dynamic OG Image Generator', 'dynamic-og-image-generator' ) );
		$hook       = add_menu_page(
			// class-admin__premium_only.php hooks doig_admin_page_title to
			// return "PlugUpp Dynamic OG Image Generator Premium" in the premium
			// build.
			$page_title,
			__( 'OG Images', 'dynamic-og-image-generator' ),
			'manage_options',
			'dynamic-og-image-generator',
			array( __CLASS__, 'render_dashboard' ),
			'dashicons-format-image',
			'60.25'
		);

		// Dashboard sub-page. Matching parent slug means clicking the
		// top-level menu lands on the Dashboard by default. Adding the
		// explicit submenu entry replaces the auto-generated "Dynamic OG
		// Image Generator" label with our preferred "Dashboard" label
		// inside the submenu.
		add_submenu_page(
			'dynamic-og-image-generator',
			$page_title,
			__( 'Dashboard', 'dynamic-og-image-generator' ),
			'manage_options',
			'dynamic-og-image-generator',
			array( __CLASS__, 'render_dashboard' )
		);

		// Settings sub-page. Hosts the configuration UI (proxy + licence,
		// Global Defaults, Capture, Fallback, Cache, Scheduled refresh)
		// across the standard Settings -> Premium -> Logs tab nav
		// (rendered by admin/views/settings-page.php).
		$settings_hook = add_submenu_page(
			'dynamic-og-image-generator',
			__( 'Settings - PlugUpp Dynamic OG Image Generator', 'dynamic-og-image-generator' ),
			__( 'Settings', 'dynamic-og-image-generator' ),
			'manage_options',
			self::SETTINGS_SLUG,
			array( __CLASS__, 'render_settings_page' )
		);

		self::$page_hooks = array_filter( array( $hook, $settings_hook ) );
	}

	/**
	 * Render the Dashboard sub-page. The four sections:
	 *   1. Headline status (plan + monthly screenshot remaining, populated
	 *      via the same /v1/usage AJAX call that runs on Settings).
	 *   2. Summary boxes (Cached / Pending / Failed / Last captured).
	 *   3. Recent screenshot activity (latest 20 ready captures).
	 *   4. Quick actions (Regenerate All Screenshots).
	 *
	 * Full layout in `admin/views/dashboard-page.php`. The Settings page
	 * still serves the existing Settings UI (proxy/licence/defaults/
	 * capture/refresh tabs); M1.3 reorganises it around the new
	 * sub-page route.
	 */
	public static function render_dashboard() {
		if ( ! current_user_can( 'manage_options' ) ) {
			return;
		}
		include DOIG_PLUGIN_DIR . 'admin/views/dashboard-page.php';
	}

	// -------------------------------------------------------------------------
	// Settings API
	// -------------------------------------------------------------------------

	public static function register_settings() {
		register_setting(
			'doig_settings_group',
			DOIG_OPTION_KEY,
			array(
				'sanitize_callback' => array( 'DOIG_Settings', 'sanitise' ),
			)
		);

		// --- Section: Proxy & Licence ---
		add_settings_section(
			'doig_section_proxy',
			__( 'Screenshot server &amp; Licence', 'dynamic-og-image-generator' ),
			array( __CLASS__, 'render_section_proxy' ),
			'dynamic-og-image-generator'
		);

		add_settings_field(
			'doig_field_proxy_health',
			__( 'Screenshot server status', 'dynamic-og-image-generator' ),
			array( __CLASS__, 'render_field_proxy_health' ),
			'dynamic-og-image-generator',
			'doig_section_proxy'
		);

		add_settings_field(
			'doig_field_licence_info',
			__( 'Licence', 'dynamic-og-image-generator' ),
			array( __CLASS__, 'render_field_licence_info' ),
			'dynamic-og-image-generator',
			'doig_section_proxy'
		);

		// 2.4.0 M1.3: the "Screenshot usage" field used to render the
		// headline quota line + startup/monthly counters here. Moved to the
		// Dashboard sub-page where it sits with the operational status
		// summary. The render_field_usage() method is kept for now in case
		// another caller wants to render the same block inline; can be
		// removed in 2.5.0 if nothing else uses it.

		// --- Section: Global Defaults ---
		add_settings_section(
			'doig_section_global',
			__( 'Global Defaults', 'dynamic-og-image-generator' ),
			'__return_false',
			'dynamic-og-image-generator'
		);

		add_settings_field(
			'doig_field_enabled',
			__( 'Enable sitewide', 'dynamic-og-image-generator' ),
			array( __CLASS__, 'render_field_enabled' ),
			'dynamic-og-image-generator',
			'doig_section_global'
		);

		add_settings_field(
			'doig_field_regenerate_on_update',
			__( 'Regenerate on post update', 'dynamic-og-image-generator' ),
			array( __CLASS__, 'render_field_regenerate_on_update' ),
			'dynamic-og-image-generator',
			'doig_section_global'
		);

		add_settings_field(
			'doig_field_refresh_enabled',
			__( 'Scheduled refresh', 'dynamic-og-image-generator' ),
			array( __CLASS__, 'render_field_refresh_enabled' ),
			'dynamic-og-image-generator',
			'doig_section_global'
		);

		add_settings_field(
			'doig_field_refresh_interval',
			__( 'Refresh interval (days)', 'dynamic-og-image-generator' ),
			array( __CLASS__, 'render_field_refresh_interval' ),
			'dynamic-og-image-generator',
			'doig_section_global'
		);

		add_settings_field(
			'doig_field_image_quality',
			__( 'Image quality (1-100)', 'dynamic-og-image-generator' ),
			array( __CLASS__, 'render_field_image_quality' ),
			'dynamic-og-image-generator',
			'doig_section_global'
		);

		// --- Section: Screenshot Capture ---
		// 2.7.0 M3: site-wide pages (homepage free; archives, taxonomy
		// pages and author archives register from the premium file via
		// doig_admin_register_premium_fields - the free build carries no
		// premium row markup, per the no-locked-UI convention).
		add_settings_section(
			'doig_section_surfaces',
			__( 'Site-wide Pages', 'dynamic-og-image-generator' ),
			array( __CLASS__, 'render_section_surfaces' ),
			'dynamic-og-image-generator'
		);

		add_settings_field(
			'doig_field_surface_home',
			__( 'Homepage', 'dynamic-og-image-generator' ),
			array( __CLASS__, 'render_field_surface_home' ),
			'dynamic-og-image-generator',
			'doig_section_surfaces'
		);

		if ( function_exists( 'doig_fs' ) && doig_fs()->is_not_paying() ) {
			add_settings_field(
				'doig_premium_hint_surfaces',
				'',
				array( __CLASS__, 'render_premium_hint_surfaces' ),
				'dynamic-og-image-generator',
				'doig_section_surfaces',
				array( 'class' => 'doig-premium-hint-row' )
			);
		}

		add_settings_section(
			'doig_section_capture',
			__( 'Screenshot Capture', 'dynamic-og-image-generator' ),
			array( __CLASS__, 'render_section_capture' ),
			'dynamic-og-image-generator'
		);

		add_settings_field(
			'doig_field_blocking',
			__( 'Block before capture', 'dynamic-og-image-generator' ),
			array( __CLASS__, 'render_field_blocking' ),
			'dynamic-og-image-generator',
			'doig_section_capture'
		);

		add_settings_field(
			'doig_field_dark_mode',
			__( 'Dark mode', 'dynamic-og-image-generator' ),
			array( __CLASS__, 'render_field_dark_mode' ),
			'dynamic-og-image-generator',
			'doig_section_capture'
		);

		add_settings_field(
			'doig_field_wait_until',
			__( 'Wait until', 'dynamic-og-image-generator' ),
			array( __CLASS__, 'render_field_wait_until' ),
			'dynamic-og-image-generator',
			'doig_section_capture'
		);

		// --- Scroll into view group ---
		add_settings_field(
			'doig_heading_scroll_into_view',
			'<strong>' . __( 'Scroll into view', 'dynamic-og-image-generator' ) . '</strong>',
			'__return_false',
			'dynamic-og-image-generator',
			'doig_section_capture',
			array( 'class' => 'doig-group-heading' )
		);

		add_settings_field(
			'doig_field_scroll_into_view',
			__( 'CSS selector', 'dynamic-og-image-generator' ),
			array( __CLASS__, 'render_field_scroll_into_view' ),
			'dynamic-og-image-generator',
			'doig_section_capture',
			array( 'class' => 'doig-sub-field' )
		);

		add_settings_field(
			'doig_field_error_on_selector_not_found',
			__( 'Error on selector not found', 'dynamic-og-image-generator' ),
			array( __CLASS__, 'render_field_error_on_selector_not_found' ),
			'dynamic-og-image-generator',
			'doig_section_capture',
			array( 'class' => 'doig-sub-field doig-group-end' )
		);

		// --- Click before capture group ---
		add_settings_field(
			'doig_heading_click_before_capture',
			'<strong>' . __( 'Click before capture', 'dynamic-og-image-generator' ) . '</strong>',
			'__return_false',
			'dynamic-og-image-generator',
			'doig_section_capture',
			array( 'class' => 'doig-group-heading' )
		);

		add_settings_field(
			'doig_field_click_before_capture',
			__( 'CSS selector', 'dynamic-og-image-generator' ),
			array( __CLASS__, 'render_field_click_before_capture' ),
			'dynamic-og-image-generator',
			'doig_section_capture',
			array( 'class' => 'doig-sub-field' )
		);

		add_settings_field(
			'doig_field_error_on_click_selector_not_found',
			__( 'Error on click selector not found', 'dynamic-og-image-generator' ),
			array( __CLASS__, 'render_field_error_on_click_selector_not_found' ),
			'dynamic-og-image-generator',
			'doig_section_capture',
			array( 'class' => 'doig-sub-field doig-group-end' )
		);

		// --- Section: Fallback Image ---
		add_settings_section(
			'doig_section_fallback',
			__( 'Fallback Image', 'dynamic-og-image-generator' ),
			array( __CLASS__, 'render_section_fallback' ),
			'dynamic-og-image-generator'
		);

		add_settings_field(
			'doig_field_fallback_type',
			__( 'When no screenshot is ready', 'dynamic-og-image-generator' ),
			array( __CLASS__, 'render_field_fallback_type' ),
			'dynamic-og-image-generator',
			'doig_section_fallback'
		);

		add_settings_field(
			'doig_field_fallback_image',
			__( 'Custom fallback image', 'dynamic-og-image-generator' ),
			array( __CLASS__, 'render_field_fallback_image' ),
			'dynamic-og-image-generator',
			'doig_section_fallback'
		);

		/**
		 * Action: allow separately distributed builds to register additional
		 * settings sections and fields. No-op when nothing is hooked.
		 */
		do_action( 'doig_admin_register_premium_fields' );

		// Generic non-paying-user upsell at the end of the Screenshot Capture
		// section. Marketing surface gated by Freemius's is_not_paying() check
		// per the SDK Integration guide. A single sentence with an upgrade
		// link - no enumeration of features, no UI for any locked field.
		if ( function_exists( 'doig_fs' ) && doig_fs()->is_not_paying() ) {
			add_settings_field(
				'doig_premium_hint_capture',
				'',
				array( __CLASS__, 'render_premium_hint_capture' ),
				'dynamic-og-image-generator',
				'doig_section_capture',
				array( 'class' => 'doig-premium-hint-row' )
			);
		}
	}

	// -------------------------------------------------------------------------
	// Assets
	// -------------------------------------------------------------------------

	public static function enqueue_assets( $hook ) {
		// 2.4.0: match against the tracked $page_hooks array so we cover
		// the top-level Dashboard AND the Settings sub-page. Hardcoding a
		// single hook string ('settings_page_dynamic-og-image-generator')
		// was right under add_options_page; the top-level menu produces
		// different hook suffixes for the parent and the Settings
		// sub-page.
		if ( empty( self::$page_hooks ) || ! in_array( $hook, self::$page_hooks, true ) ) {
			return;
		}

		wp_enqueue_media();

		wp_enqueue_style(
			'doig-admin',
			DOIG_PLUGIN_URL . 'assets/css/admin.css',
			array(),
			DOIG_VERSION
		);

		wp_enqueue_script(
			'doig-admin',
			DOIG_PLUGIN_URL . 'assets/js/admin.js',
			array( 'jquery' ),
			DOIG_VERSION,
			true
		);

		// Plan name powers the headline quota line - "You are on the [plan]
		// plan - N screenshots left this month". Falls back to "Free" when
		// the install is not on a paid plan.
		$doig_plan      = doig_fs()->is_paying() ? doig_fs()->get_plan() : null;
		$doig_plan_name = ( $doig_plan && ! empty( $doig_plan->name ) )
			? ucfirst( (string) $doig_plan->name )
			: __( 'Free', 'dynamic-og-image-generator' );

		wp_localize_script( 'doig-admin', 'doigAdmin', array(
			'ajaxUrl'     => admin_url( 'admin-ajax.php' ),
			'nonce'       => wp_create_nonce( 'doig_admin_nonce' ),
			'planName'    => $doig_plan_name,
			'isPaying'    => doig_fs()->is_paying(),
			'upgradeUrl'  => doig_fs()->get_upgrade_url(),
			'strings' => array(
				// 2.7.0 M3: Site-wide Pages custom-image picker.
				'surfaceChooseTitle'  => __( 'Choose an image', 'dynamic-og-image-generator' ),
				'surfaceChooseButton' => __( 'Use this image', 'dynamic-og-image-generator' ),
				'surfaceChangeButton' => __( 'Change image', 'dynamic-og-image-generator' ),
				'proxyChecking'    => __( 'Checking…', 'dynamic-og-image-generator' ),
				'proxyReachable'   => __( 'Reachable', 'dynamic-og-image-generator' ),
				'proxyUnreachable' => __( 'Unreachable', 'dynamic-og-image-generator' ),
				'clearing'         => __( 'Clearing cache…', 'dynamic-og-image-generator' ),
				'cleared'          => __( 'Cache cleared.', 'dynamic-og-image-generator' ),
				'regenerating'     => __( 'Queuing regeneration…', 'dynamic-og-image-generator' ),
				'regenerated'      => __( 'All screenshots queued for regeneration.', 'dynamic-og-image-generator' ),
				'selectImage'      => __( 'Select Fallback Image', 'dynamic-og-image-generator' ),
				'useImage'         => __( 'Use this image', 'dynamic-og-image-generator' ),
				// Usage section (populated by the same AJAX as proxy health).
				'usageStartup'     => __( 'Startup screenshots:', 'dynamic-og-image-generator' ),
				'usageMonthly'     => __( 'Monthly screenshots:', 'dynamic-og-image-generator' ),
				'usageResetsOn'    => __( 'Quota resets on:', 'dynamic-og-image-generator' ),
				'usageRenewsOn'    => __( 'Subscription renews on:', 'dynamic-og-image-generator' ),
				'usageOf'          => __( 'of', 'dynamic-og-image-generator' ),
				'usageUnavailable' => __( 'Usage information is not available right now.', 'dynamic-og-image-generator' ),
				// Usage headline templates. JS does the substitution because the
				// plan name and counts are not known until /v1/usage responds.

				/* translators: %1$d: number of startup screenshots remaining on the Free tier */
				'usageHeadlineFree'    => __( 'You are on the Free plan - %1$d startup screenshots remaining for this site.', 'dynamic-og-image-generator' ),

				/* translators: %1$s: plan name (Personal / Pro / Agency); %2$d: remaining count; %3$d: monthly cap */
				'usageHeadlinePaid'    => __( 'You are on the %1$s plan - %2$d of %3$d screenshots remaining this month.', 'dynamic-og-image-generator' ),
				'usageTryPremiumLink'  => __( 'Try Premium →', 'dynamic-og-image-generator' ),
			),
		) );
	}

	// -------------------------------------------------------------------------
	// Settings page render
	// -------------------------------------------------------------------------

	public static function render_settings_page() {
		if ( ! current_user_can( 'manage_options' ) ) {
			return;
		}

		// Tab parameter is read-only navigation. No form submission, no
		// state change - it selects which tab template to render and is
		// validated against an allow-list two lines below. Nonce
		// verification is not applicable to GET-only routing.
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only tab routing, see comment above
		$tab_param   = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : '';
		$current_tab = in_array( $tab_param, array( 'logs', 'premium' ), true ) ? $tab_param : 'settings';
		$stats       = DOIG_Cache::stats();

		include DOIG_PLUGIN_DIR . 'admin/views/settings-page.php';
	}

	// -------------------------------------------------------------------------
	// Section descriptions
	// -------------------------------------------------------------------------

	public static function render_section_proxy() {
		echo '<p>' . esc_html__( 'PlugUpp Dynamic OG Image Generator uses the PlugUpp screenshot server to capture pages. No API key setup is required - your PlugUpp licence is verified automatically.', 'dynamic-og-image-generator' ) . '</p>';
	}

	public static function render_field_proxy_health() {
		echo '<span id="doig-proxy-status" class="doig-proxy-status doig-proxy-status--checking">';
		echo esc_html__( 'Checking&hellip;', 'dynamic-og-image-generator' );
		echo '</span>';
		echo '<p class="description">' . esc_html__( 'Connection status of the PlugUpp screenshot server. Checked each time this page loads.', 'dynamic-og-image-generator' ) . '</p>';
	}

	public static function render_field_licence_info() {
		$plan  = doig_fs()->is_paying() ? doig_fs()->get_plan() : null;

		// Just the plan name. Actual quota and usage numbers come from the
		// Screenshot usage section below (populated via the /v1/usage call),
		// so we don't have to keep this in sync with the proxy's source of
		// truth.
		if ( $plan && ! empty( $plan->name ) ) {
			$plan_name = (string) $plan->name;
			echo '<strong>' . esc_html( ucfirst( $plan_name ) ) . '</strong>';
		} else {
			echo '<strong>' . esc_html__( 'Free', 'dynamic-og-image-generator' ) . '</strong>';
		}

		// Two links: Manage subscription opens the Freemius account portal
		// (billing details, plan changes, cancellation). Upgrade plan opens the
		// Freemius upgrade flow and is shown only when an upgrade path exists.
		echo '<p class="description">';
		echo '<a href="' . esc_url( doig_fs()->get_account_url() ) . '" target="_blank" rel="noopener noreferrer">';
		echo esc_html__( 'Manage your subscription', 'dynamic-og-image-generator' );
		echo '</a>';
		if ( ! doig_fs()->is_paying() || doig_fs()->is_trial() ) {
			echo ' &middot; ';
			echo '<a href="' . esc_url( doig_fs()->get_upgrade_url() ) . '" target="_blank" rel="noopener noreferrer">';
			echo esc_html__( 'Try Premium', 'dynamic-og-image-generator' );
			echo '</a>';
		}
		echo '</p>';
	}

	/**
	 * Usage section - populated client-side by the same AJAX call that
	 * pings /v1/health. PHP emits a placeholder; admin.js fills it in
	 * once /v1/usage responds. Hidden entirely when the plugin is not
	 * connected (the setup banner already tells the user what to do).
	 */
	public static function render_field_usage() {
		if ( ! DOIG_License::is_connected() ) {
			echo '<p class="description">' . esc_html__( 'Usage figures will appear here once the plugin is connected.', 'dynamic-og-image-generator' ) . '</p>';
			return;
		}
		echo '<div id="doig-usage" class="doig-usage doig-usage--loading">';
		echo '<p>' . esc_html__( 'Loading usage…', 'dynamic-og-image-generator' ) . '</p>';
		echo '</div>';
	}

	public static function render_section_capture() {
		echo '<p>' . esc_html__( 'Control what gets blocked and how the page is prepared before the screenshot is taken.', 'dynamic-og-image-generator' ) . '</p>';
	}

	public static function render_section_fallback() {
		echo '<p>' . esc_html__( 'Choose what og:image to show while a screenshot is being generated or has not yet been taken.', 'dynamic-og-image-generator' ) . '</p>';
	}

	/**
	 * Generic upsell rendered at the end of the Screenshot Capture section
	 * for non-paying users. Plain prose plus an outbound link - no mock UI,
	 * no enumeration of paid feature names. Gated by Freemius's
	 * is_not_paying() check per the WordPress SDK Integration guide's
	 * "Adding contextual marketing and upsells" section.
	 */
	public static function render_premium_hint_capture() {
		echo '<p class="description doig-premium-hint">';
		echo esc_html__( 'Additional capture controls and per-page overrides are available in the paid version. ', 'dynamic-og-image-generator' );
		echo '<a href="' . esc_url( doig_fs()->get_upgrade_url() ) . '">';
		echo esc_html__( 'Try Premium →', 'dynamic-og-image-generator' );
		echo '</a>';
		echo '</p>';
	}

	// -------------------------------------------------------------------------
	// Field renderers
	// -------------------------------------------------------------------------

	public static function render_field_enabled() {
		// 2.4.0 (Q2 Option C re-lock 2026-06-23): single-axis design.
		// Source is the only customer-facing control per content type;
		// "Off" is now one of the Source options (replacing the old
		// Enable=OFF state). enabled_posts / enabled_pages are derived
		// from Source on every save via sanitise() so cron loops and
		// other readers that haven't been migrated to source_X continue
		// to work without changes.
		$source_posts = DOIG_Source_Resolver::sanitize_terminal_mode( DOIG_Settings::get( 'source_posts', DOIG_Source_Resolver::MODE_SCREENSHOT ) );
		$source_pages = DOIG_Source_Resolver::sanitize_terminal_mode( DOIG_Settings::get( 'source_pages', DOIG_Source_Resolver::MODE_SCREENSHOT ) );

		self::render_source_row( __( 'Posts', 'dynamic-og-image-generator' ), 'source_posts', $source_posts );
		self::render_source_row( __( 'Pages', 'dynamic-og-image-generator' ), 'source_pages', $source_pages );

		/**
		 * Anchor for separately distributed builds to render additional
		 * content-type rows beneath the Posts and Pages rows. The
		 * Premium CPT class (`class-cpts__premium_only.php`) hooks here
		 * to add one Source row per public custom post type.
		 */
		do_action( 'doig_settings_enable_after' );

		echo '<p class="description">';
		echo esc_html__( 'The Source dropdown controls what DOIG emits as og:image for each content type. Auto-screenshot captures a fresh browser screenshot of the page. Featured image emits the post\'s Featured Image directly. Featured image (with screenshot when missing) emits the Featured Image where one is set and a screenshot where it is not - the recommended default for blogs where some posts have curated featured images. Custom image emits a per-post upload you choose from the Media Library. Off disables DOIG entirely for that content type. You can override the source per post from the OG Image panel in the block editor.', 'dynamic-og-image-generator' );
		echo '</p>';
	}

	/**
	 * Render one Source row in the "Enable sitewide" section.
	 *
	 * Reused for the Posts row, the Pages row, and (via the Premium CPT
	 * class hooking `doig_settings_enable_after`) each CPT row. Keeps
	 * the visual structure consistent across all content types: label
	 * on the left, "Source:" prefix + dropdown on the right.
	 *
	 * 2.4.0 (Q2 Option C re-lock 2026-06-23): renamed from
	 * render_enable_row() and the Enable checkbox removed. Source is
	 * the single customer-facing axis; 'off' is one of the dropdown
	 * options.
	 *
	 * @param string $label         Translated label text (e.g. "Posts").
	 * @param string $source_key    Form-field name for the Source dropdown (e.g. 'source_posts').
	 * @param string $source_value  Current value of the Source dropdown (one of the terminal modes).
	 */
	/**
	 * Section intro for Site-wide Pages (2.7.0 M3). The quota copy sits
	 * here, BEFORE any toggle, per the kickoff lock: an enabled taxonomy
	 * captures every one of its term pages.
	 */
	public static function render_section_surfaces() {
		echo '<p>' . esc_html__( 'Give pages that have no post - your homepage, archive pages, category and tag pages, author pages - an og:image too. Archives have no featured image by definition, so a screenshot is often the only image these pages can have.', 'dynamic-og-image-generator' ) . '</p>';
		echo '<p class="description"><strong>' . esc_html__( 'Quota note:', 'dynamic-og-image-generator' ) . '</strong> ';
		echo esc_html__( 'each enabled page uses one screenshot per refresh, and enabling a taxonomy captures EVERY one of its pages - a store with 200 categories is 200 screenshots per refresh cycle. The page counts shown next to each row are live.', 'dynamic-og-image-generator' );
		echo '</p>';
	}

	/**
	 * The Homepage surface row - the free half of Site-wide Pages.
	 */
	public static function render_field_surface_home() {
		// M4-verification polish: on static-front sites this toggle never
		// drives the front page - the page's own per-post pipeline does
		// (a static front IS a singular page). Say so instead of showing
		// a confusing dead control.
		if ( 'page' === get_option( 'show_on_front' ) ) {
			$front_id  = (int) get_option( 'page_on_front' );
			$edit_link = $front_id ? get_edit_post_link( $front_id ) : '';
			echo '<p class="description">';
			esc_html_e( 'Your front page is a static page, so its OG image is controlled like any other page - from the OG Image panel in that page\'s editor sidebar, or the Pages default above.', 'dynamic-og-image-generator' );
			if ( $edit_link ) {
				echo ' <a href="' . esc_url( $edit_link ) . '">' . esc_html__( 'Edit front page', 'dynamic-og-image-generator' ) . ' &rarr;</a>';
			}
			echo '</p>';
			return;
		}

		$surfaces = (array) DOIG_Settings::get( 'surfaces', array() );
		$mode     = DOIG_Source_Resolver::sanitize_surface_mode( $surfaces['home'] ?? DOIG_Source_Resolver::MODE_OFF );
		self::render_surface_row( 'home', $mode );
		echo '<p class="description">' . esc_html__( 'The public front page of your site, showing your latest posts.', 'dynamic-og-image-generator' ) . '</p>';
	}

	/**
	 * One Site-wide Pages row: Source dropdown + Custom-image picker.
	 * Shared with the premium file, which renders the archive, taxonomy
	 * and author rows through it.
	 *
	 * @param string $group_key  Settings group key (DOIG_Surfaces::settings_groups()).
	 * @param string $mode       Current mode for the group.
	 * @param string $count_note Optional live page-count note rendered
	 *                           beside the dropdown (taxonomy rows).
	 */
	public static function render_surface_row( $group_key, $mode, $count_note = '' ) {
		$custom    = (array) DOIG_Settings::get( 'surfaces_custom', array() );
		$image_id  = absint( $custom[ $group_key ] ?? 0 );
		$image_url = $image_id ? wp_get_attachment_image_url( $image_id, 'thumbnail' ) : '';

		echo '<div class="doig-surface-row" data-doig-surface="' . esc_attr( $group_key ) . '" style="display:flex;align-items:center;gap:12px;margin-bottom:6px;max-width:640px;">';

		echo '<label class="doig-source-dropdown" style="display:flex;align-items:center;gap:6px;">';
		echo '<span style="color:#50575e;font-size:12px;">' . esc_html__( 'Source:', 'dynamic-og-image-generator' ) . '</span>';
		echo '<select class="doig-surface-mode" name="' . esc_attr( DOIG_OPTION_KEY . '[surfaces][' . $group_key . ']' ) . '">';
		foreach ( DOIG_Source_Resolver::surface_mode_labels() as $mode_value => $mode_label ) {
			echo '<option value="' . esc_attr( $mode_value ) . '"' . selected( $mode, $mode_value, false ) . '>';
			echo esc_html( $mode_label );
			echo '</option>';
		}
		echo '</select>';
		echo '</label>';

		// Custom-image picker: hidden ID field + choose button + thumb.
		// admin.js shows this cluster only while the mode is 'custom'.
		$picker_style = ( DOIG_Source_Resolver::MODE_CUSTOM === $mode ? 'display:inline-flex;' : 'display:none;' ) . 'align-items:center;gap:8px;';
		echo '<span class="doig-surface-custom" style="' . esc_attr( $picker_style ) . '">';
		echo '<input type="hidden" class="doig-surface-custom-id" name="' . esc_attr( DOIG_OPTION_KEY . '[surfaces_custom][' . $group_key . ']' ) . '" value="' . esc_attr( $image_id ?: '' ) . '" />';
		echo '<img class="doig-surface-custom-thumb" src="' . esc_url( $image_url ) . '" alt="" style="height:32px;width:auto;border-radius:2px;' . ( $image_url ? '' : 'display:none;' ) . '" />';
		echo '<button type="button" class="button doig-surface-custom-choose">' . esc_html( $image_id ? __( 'Change image', 'dynamic-og-image-generator' ) : __( 'Choose image', 'dynamic-og-image-generator' ) ) . '</button>';
		echo '</span>';

		if ( '' !== $count_note ) {
			echo '<span class="description" style="font-size:12px;">' . esc_html( $count_note ) . '</span>';
		}

		echo '</div>';
	}

	/**
	 * Single-sentence upsell for non-paying users at the end of the
	 * Site-wide Pages section, matching the capture-section pattern:
	 * one link, no locked UI, no feature enumeration.
	 */
	public static function render_premium_hint_surfaces() {
		if ( ! function_exists( 'doig_fs' ) ) {
			return;
		}
		echo '<p class="description">';
		printf(
			/* translators: %1$s: opening link tag to the upgrade page, %2$s: closing link tag. */
			esc_html__( 'Archive pages, category and tag pages, and author pages are available with %1$sPremium%2$s.', 'dynamic-og-image-generator' ),
			'<a href="' . esc_url( doig_fs()->get_upgrade_url() ) . '">',
			'</a>'
		);
		echo '</p>';
	}

	public static function render_source_row( $label, $source_key, $source_value ) {
		echo '<div class="doig-source-row" style="display:flex;align-items:center;gap:12px;margin-bottom:6px;max-width:560px;">';

		echo '<span style="flex:1;font-weight:600;">' . esc_html( $label ) . '</span>';

		echo '<label class="doig-source-dropdown" style="display:flex;align-items:center;gap:6px;">';
		echo '<span style="color:#50575e;font-size:12px;">' . esc_html__( 'Source:', 'dynamic-og-image-generator' ) . '</span>';
		echo '<select name="' . esc_attr( DOIG_OPTION_KEY ) . '[' . esc_attr( $source_key ) . ']">';
		foreach ( DOIG_Source_Resolver::mode_labels() as $mode => $mode_label ) {
			echo '<option value="' . esc_attr( $mode ) . '"' . selected( $source_value, $mode, false ) . '>';
			echo esc_html( $mode_label );
			echo '</option>';
		}
		echo '</select>';
		echo '</label>';

		echo '</div>';
	}

	public static function render_field_regenerate_on_update() {
		echo '<label>';
		echo '<input type="checkbox" name="' . esc_attr( DOIG_OPTION_KEY ) . '[regenerate_on_update]" value="1"';
		checked( DOIG_Settings::get( 'regenerate_on_update', false ), true );
		echo ' />';
		echo ' ' . esc_html__( 'Automatically queue a new screenshot whenever a post is updated', 'dynamic-og-image-generator' );
		echo '</label>';
		echo '<p class="description">' . esc_html__( 'A new screenshot is only queued when the post content, title, or excerpt actually changes - no-op saves and capture-setting tweaks do not trigger a refresh. Keep this off if you update posts very frequently to avoid consuming your monthly screenshot quota.', 'dynamic-og-image-generator' ) . '</p>';

		// List the page builders we detect content saves from. The Block
		// Editor, Classic Editor, and WPBakery (shortcodes in post_content)
		// are caught by the post_updated content diff and are always
		// covered. The builders below store content in post meta, so we
		// watch their content meta keys explicitly.
		$builders = class_exists( 'DOIG_Cron' ) ? DOIG_Cron::supported_builders() : array();
		if ( ! empty( $builders ) ) {
			echo '<p class="description" style="margin-top:8px;">';
			echo '<strong>' . esc_html__( 'Supported page builders:', 'dynamic-og-image-generator' ) . '</strong> ';
			echo esc_html__( 'The Block Editor and Classic Editor are always covered. Edits made in the following page builders are also detected:', 'dynamic-og-image-generator' );
			echo ' ' . esc_html( implode( ', ', array_keys( $builders ) ) ) . '.';
			echo '<br>';
			echo esc_html__( 'Need support for a builder not listed?', 'dynamic-og-image-generator' );
			echo ' <a href="' . esc_url( 'https://plugupp.com/contact/' ) . '" target="_blank" rel="noopener noreferrer">';
			echo esc_html__( 'Let us know', 'dynamic-og-image-generator' );
			echo '</a>.';
			echo '</p>';
		}
	}

	/**
	 * Renders the "Scheduled refresh" checkbox - the master switch for
	 * the daily refresh sweep. When unchecked, DOIG_Cache::is_fresh()
	 * returns true unconditionally so the sweep never queues a post
	 * for time-based refresh. Per-post auto-regen, Regenerate Now, and
	 * Regenerate All remain functional. Default true so upgraders
	 * keep their existing schedule-driven behaviour.
	 */
	public static function render_field_refresh_enabled() {
		$value = (bool) DOIG_Settings::get( 'refresh_enabled', true );
		echo '<label><input type="checkbox" name="' . esc_attr( DOIG_OPTION_KEY ) . '[refresh_enabled]" value="1"' . checked( $value, true, false ) . ' /> ';
		echo esc_html__( 'Automatically refresh cached screenshots on a schedule', 'dynamic-og-image-generator' );
		echo '</label>';
		echo '<p class="description">' . esc_html__( 'When off, cached screenshots are kept indefinitely until you explicitly regenerate them (per post or via Regenerate All Screenshots). Post-update auto-regen still works when its own setting is enabled.', 'dynamic-og-image-generator' ) . '</p>';
	}

	public static function render_field_refresh_interval() {
		$refresh_enabled = (bool) DOIG_Settings::get( 'refresh_enabled', true );
		$value           = (int) DOIG_Settings::get( 'refresh_interval', 7 );
		$disabled_attr   = $refresh_enabled ? '' : ' disabled';
		echo '<input type="number" name="' . esc_attr( DOIG_OPTION_KEY ) . '[refresh_interval]" value="' . esc_attr( (string) $value ) . '" min="1" max="365" class="small-text"' . esc_attr( $disabled_attr ) . ' />';
		echo ' <span class="description">' . esc_html__( 'days', 'dynamic-og-image-generator' ) . '</span>';
		echo '<p class="description">' . esc_html__( 'How many days before a cached screenshot is considered stale and queued for refresh. Stale images are still served until a new one is ready.', 'dynamic-og-image-generator' ) . '</p>';

		// Live quota math - shown only when scheduled refresh is on. The
		// estimate is intentionally rough (counts all published post + page;
		// doesn't account for per-page overrides or the site-wide disabled
		// state). It is a usefulness signal for "how much of my quota does
		// the refresh schedule consume?", not an exact forecast. Manual
		// regen, per-page disables, and the site-wide off switch all reduce
		// the real number. When the schedule is off, this line would just
		// confuse - the schedule isn't consuming anything.
		if ( ! $refresh_enabled ) {
			return;
		}

		$published = (int) wp_count_posts( 'post' )->publish + (int) wp_count_posts( 'page' )->publish;
		$interval  = max( 1, $value );
		if ( $published > 0 ) {
			$per_30  = (int) ceil( $published * ( 30 / $interval ) );
			echo '<p class="description" style="margin-top:6px;">';
			// Both $published and $per_30 are already cast to int above, but the
			// WPCS EscapeOutput sniffer does not introspect prior casts. Re-cast
			// at the printf call site so the sniffer sees the safety explicitly.
			printf(
				/* translators: 1: number of published posts and pages, 2: estimated screenshots per 30 days */
				esc_html__( 'Your %1$d published pages and posts will use approximately %2$d screenshots per 30 days at this refresh interval. Per-page disables and the site-wide toggle reduce this estimate.', 'dynamic-og-image-generator' ),
				(int) $published,
				(int) $per_30
			);
			echo '</p>';
		}
	}

	public static function render_field_image_quality() {
		$value = (int) DOIG_Settings::get( 'image_quality', 80 );
		echo '<input type="number" name="' . esc_attr( DOIG_OPTION_KEY ) . '[image_quality]" value="' . esc_attr( (string) $value ) . '" min="1" max="100" class="small-text" />';
		echo '<p class="description">' . esc_html__( 'WebP compression quality. 80 is a good balance of quality and file size.', 'dynamic-og-image-generator' ) . '</p>';
	}

	public static function render_field_blocking() {
		$options = array(
			'block_ads'                   => array( 'label' => __( 'Block ads', 'dynamic-og-image-generator' ),                                                                         'default' => true ),
			'block_cookie_banners'        => array( 'label' => __( 'Block cookie banners', 'dynamic-og-image-generator' ),                                                              'default' => true ),
			'block_banners_by_heuristics' => array( 'label' => __( 'Block banners by heuristics (off by default - can incorrectly hide decorative sections)', 'dynamic-og-image-generator' ), 'default' => false ),
			'block_trackers'              => array( 'label' => __( 'Block trackers', 'dynamic-og-image-generator' ),                                                                    'default' => true ),
			'block_chats'                 => array( 'label' => __( 'Block chat widgets (Intercom, Drift, Zendesk, etc.)', 'dynamic-og-image-generator' ),                               'default' => true ),
		);
		foreach ( $options as $key => $opt ) {
			echo '<label style="display:block;margin-bottom:6px;">';
			echo '<input type="checkbox" name="' . esc_attr( DOIG_OPTION_KEY ) . '[' . esc_attr( $key ) . ']" value="1"';
			checked( DOIG_Settings::get( $key, $opt['default'] ), true );
			echo ' />';
			echo ' ' . esc_html( $opt['label'] );
			echo '</label>';
		}
		echo '<p class="description">' . esc_html__( 'Disable only if a specific element you want visible is being incorrectly blocked.', 'dynamic-og-image-generator' ) . '</p>';
	}

	public static function render_field_dark_mode() {
		echo '<label>';
		echo '<input type="checkbox" name="' . esc_attr( DOIG_OPTION_KEY ) . '[dark_mode]" value="1"';
		checked( DOIG_Settings::get( 'dark_mode', false ), true );
		echo ' />';
		echo ' ' . esc_html__( 'Render pages in dark mode before capturing', 'dynamic-og-image-generator' );
		echo '</label>';
		echo '<p class="description">' . esc_html__( 'Enables the prefers-color-scheme: dark media query in the browser. Only useful if your site has a dark theme.', 'dynamic-og-image-generator' ) . '</p>';
	}

	public static function render_field_wait_until() {
		$current = DOIG_Settings::get( 'wait_until', 'load' );
		$options = array(
			'load'             => __( 'load - page fully loaded, all resources ready (default)', 'dynamic-og-image-generator' ),
			'domcontentloaded' => __( 'domcontentloaded - DOM ready, resources may still be loading (faster)', 'dynamic-og-image-generator' ),
			'networkidle0'     => __( 'networkidle0 - no network activity for 500ms (thorough, slowest)', 'dynamic-og-image-generator' ),
			'networkidle2'     => __( 'networkidle2 - at most 2 network requests for 500ms', 'dynamic-og-image-generator' ),
		);
		echo '<select name="' . esc_attr( DOIG_OPTION_KEY ) . '[wait_until]">';
		foreach ( $options as $value => $label ) {
			echo '<option value="' . esc_attr( $value ) . '"';
			selected( $current, $value );
			echo '>' . esc_html( $label ) . '</option>';
		}
		echo '</select>';
		echo '<p class="description">' . esc_html__( 'When the browser considers the page ready before taking the screenshot. If lazy-loaded content is missing from captures, try networkidle0.', 'dynamic-og-image-generator' ) . '</p>';
	}

	public static function render_field_scroll_into_view() {
		echo '<input type="text" name="' . esc_attr( DOIG_OPTION_KEY ) . '[scroll_into_view]" value="' . esc_attr( DOIG_Settings::get( 'scroll_into_view', '' ) ) . '" placeholder=".hero-section" class="regular-text" />';
		echo '<p class="description">' . esc_html__( 'Scroll this element into view before capture. Useful when the content you want captured sits below the initial viewport. Leave blank to disable.', 'dynamic-og-image-generator' ) . '</p>';
	}

	public static function render_field_error_on_selector_not_found() {
		echo '<label>';
		echo '<input type="checkbox" name="' . esc_attr( DOIG_OPTION_KEY ) . '[error_on_selector_not_found]" value="1"';
		checked( DOIG_Settings::get( 'error_on_selector_not_found', false ), true );
		echo ' />';
		echo ' ' . esc_html__( 'Fail the screenshot if the scroll-into-view element is not found on the page', 'dynamic-og-image-generator' );
		echo '</label>';
		echo '<p class="description">' . esc_html__( 'By default the screenshot proceeds even if the element does not exist. Enable to detect selector typos.', 'dynamic-og-image-generator' ) . '</p>';
	}

	public static function render_field_click_before_capture() {
		echo '<input type="text" name="' . esc_attr( DOIG_OPTION_KEY ) . '[click_before_capture]" value="' . esc_attr( DOIG_Settings::get( 'click_before_capture', '' ) ) . '" placeholder="#dismiss-overlay" class="regular-text" />';
		echo '<p class="description">' . esc_html__( 'Click this element before the screenshot is taken. Useful for dismissing overlays, popups, or activating a tab. Leave blank to disable.', 'dynamic-og-image-generator' ) . '</p>';
	}

	public static function render_field_error_on_click_selector_not_found() {
		echo '<label>';
		echo '<input type="checkbox" name="' . esc_attr( DOIG_OPTION_KEY ) . '[error_on_click_selector_not_found]" value="1"';
		checked( DOIG_Settings::get( 'error_on_click_selector_not_found', false ), true );
		echo ' />';
		echo ' ' . esc_html__( 'Fail the screenshot if the click element is not found on the page', 'dynamic-og-image-generator' );
		echo '</label>';
		echo '<p class="description">' . esc_html__( 'By default the screenshot proceeds even if the element does not exist. Enable to detect selector typos.', 'dynamic-og-image-generator' ) . '</p>';
	}

	public static function render_field_fallback_type() {
		$current = DOIG_Settings::get( 'fallback_type', 'seo_plugin' );
		$options = array(
			'seo_plugin' => __( 'Use my SEO plugin / theme default (recommended)', 'dynamic-og-image-generator' ),
			'media'      => __( 'Use a custom image from my Media Library', 'dynamic-og-image-generator' ),
			'none'       => __( 'Output no og:image until the screenshot is ready', 'dynamic-og-image-generator' ),
		);
		foreach ( $options as $value => $label ) {
			echo '<label style="display:block;margin-bottom:6px;">';
			echo '<input type="radio" name="' . esc_attr( DOIG_OPTION_KEY ) . '[fallback_type]" value="' . esc_attr( $value ) . '"';
			checked( $current, $value );
			echo ' class="doig-fallback-radio" />';
			echo ' ' . esc_html( $label );
			echo '</label>';
		}
	}

	public static function render_field_fallback_image() {
		$image_id  = (int) DOIG_Settings::get( 'fallback_image_id', 0 );
		$image_url = $image_id ? wp_get_attachment_image_url( $image_id, 'thumbnail' ) : '';

		echo '<div id="doig-fallback-image-wrap">';
		if ( $image_url ) {
			echo '<img id="doig-fallback-preview" src="' . esc_url( $image_url ) . '" style="max-width:200px;height:auto;display:block;margin-bottom:8px;" />';
		} else {
			echo '<img id="doig-fallback-preview" src="" style="max-width:200px;height:auto;display:none;margin-bottom:8px;" />';
		}
		echo '<input type="hidden" id="doig_fallback_image_id" name="' . esc_attr( DOIG_OPTION_KEY ) . '[fallback_image_id]" value="' . esc_attr( (string) $image_id ) . '" />';
		echo '<button type="button" id="doig-select-image" class="button">' . esc_html__( 'Select Image', 'dynamic-og-image-generator' ) . '</button>';
		if ( $image_id ) {
			echo ' <button type="button" id="doig-remove-image" class="button-link-delete">' . esc_html__( 'Remove', 'dynamic-og-image-generator' ) . '</button>';
		}
		echo '</div>';
		echo '<p class="description">' . esc_html__( 'Shown only when "Use a custom image" is selected above.', 'dynamic-og-image-generator' ) . '</p>';
	}

	// -------------------------------------------------------------------------
	// AJAX handlers
	// -------------------------------------------------------------------------

	/**
	 * AJAX handler: check whether the PlugUpp proxy is reachable.
	 * Fired on settings page load — result displayed in the Proxy Status field.
	 */
	public static function ajax_check_proxy_health() {
		check_ajax_referer( 'doig_admin_nonce', 'nonce' );

		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( array( 'message' => '' ) );
		}

		// Technical detail of any failure is written to the plugin log
		// rather than returned to the browser. The Settings page UI
		// falls back to a friendly generic string in either field; raw
		// HTTP codes or "cURL error 28..." messages were leaking through
		// to end users and were never useful at that surface.

		// 1. Reachability ping. Public endpoint - no auth needed.
		$health = wp_remote_get(
			DOIG_License::proxy_base_url() . '/v1/health',
			array( 'timeout' => 5 )
		);

		if ( is_wp_error( $health ) ) {
			DOIG_Logger::warning(
				sprintf( 'Proxy health check failed (network): %s', $health->get_error_message() )
			);
			wp_send_json_error( array( 'message' => '' ) );
		}

		$health_code = (int) wp_remote_retrieve_response_code( $health );
		if ( 200 !== $health_code ) {
			DOIG_Logger::warning(
				sprintf( 'Proxy health check returned HTTP %d.', $health_code )
			);
			wp_send_json_error( array( 'message' => '' ) );
		}

		// 2. Usage fetch. Requires Bearer auth + ?site_id. Skipped (with
		// usage:null in the response) when the site has not yet completed
		// Freemius opt-in - is_connected() guards against the 401 we
		// would otherwise see from the proxy.
		//
		// Anything that does fail here is logged but not surfaced to the
		// browser. The UI's friendly fallback string is shown instead.
		// First-time installs typically see a 401 here for the first few
		// seconds after Freemius activation while the licence.activated
		// webhook propagates to the screenshot server; a page refresh
		// clears it. Surfacing "HTTP 401" to the user during that window
		// is alarming and unactionable.
		$usage_data = null;

		if ( DOIG_License::is_connected() ) {
			$licence_key = DOIG_Screenshot::get_licence_key();
			$site        = function_exists( 'doig_fs' ) ? doig_fs()->get_site() : null;
			$site_id     = is_object( $site ) && ! empty( $site->id ) ? (int) $site->id : 0;

			if ( $licence_key && $site_id > 0 ) {
				$usage = wp_remote_get(
					DOIG_License::proxy_base_url() . '/v1/usage?site_id=' . $site_id,
					array(
						'timeout' => 5,
						'headers' => array(
							'Authorization' => 'Bearer ' . $licence_key,
						),
					)
				);

				if ( is_wp_error( $usage ) ) {
					DOIG_Logger::warning(
						sprintf( 'Usage fetch failed (network): %s', $usage->get_error_message() )
					);
				} else {
					$ucode = (int) wp_remote_retrieve_response_code( $usage );
					$body  = (string) wp_remote_retrieve_body( $usage );
					if ( 200 === $ucode ) {
						$decoded = json_decode( $body, true );
						if ( is_array( $decoded ) ) {
							$usage_data = $decoded;
						} else {
							DOIG_Logger::warning( 'Usage fetch returned unparseable JSON.' );
						}
					} elseif ( 401 === $ucode || 404 === $ucode ) {
						// Common during the first-install grace period
						// between Freemius opt-in and the licence.activated
						// webhook reaching the screenshot server. Log at
						// debug level so the noise stays out of the default
						// log view.
						DOIG_Logger::debug(
							sprintf( 'Usage fetch returned HTTP %d (likely first-install grace period).', $ucode )
						);
					} else {
						DOIG_Logger::warning(
							sprintf( 'Usage fetch returned HTTP %d.', $ucode )
						);
					}
				}
			} else {
				DOIG_Logger::debug( 'Usage fetch skipped: plugin connected but licence_key or site_id unavailable.' );
			}
		}

		wp_send_json_success( array(
			'usage'       => $usage_data,
			// usage_error is intentionally always null - the UI shows its
			// friendly fallback string when usage is null. Technical
			// detail is in the plugin log.
			'usage_error' => null,
		) );
	}

	public static function ajax_clear_cache() {
		check_ajax_referer( 'doig_admin_nonce', 'nonce' );

		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( array( 'message' => __( 'Permission denied.', 'dynamic-og-image-generator' ) ) );
		}

		$count = DOIG_Cache::purge_all();

		wp_send_json_success( array(
			'message' => sprintf(
				/* translators: %d: number of files deleted */
				_n( '%d cached screenshot deleted.', '%d cached screenshots deleted.', $count, 'dynamic-og-image-generator' ),
				$count
			),
		) );
	}

	public static function ajax_regenerate_all() {
		check_ajax_referer( 'doig_admin_nonce', 'nonce' );

		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( array( 'message' => __( 'Permission denied.', 'dynamic-og-image-generator' ) ) );
		}

		// Pre-flight: the screenshot service requires https URLs, and every
		// queued job would fail at the proxy with HTTP 422 if the WP Site
		// Address is configured as http. Refuse the action here with a clear
		// message rather than silently queueing N doomed jobs and showing
		// "X screenshots queued" while every one of them is destined to fail.
		// (The persistent banner at the top of the Settings page also fires
		// on the same condition; this AJAX path catches the case where the
		// user clicks anyway.)
		//
		// Check get_option('home') rather than home_url() because home_url()
		// uses is_ssl() to adjust the scheme - it returns https:// in an
		// admin request loaded over https even when the persistent DB value
		// is http://. Cron has no HTTP request context so it gets the raw
		// DB scheme, which is what the proxy ultimately rejects. The DB
		// value is the canonical truth.
		$home_option = (string) get_option( 'home', '' );
		if ( 'https' !== wp_parse_url( $home_option, PHP_URL_SCHEME ) ) {
			wp_send_json_error( array(
				'message' => __( 'Your WordPress site URL is set to http://. The screenshot service requires https:// URLs. Update Settings → General → Site Address to use https://, then try again.', 'dynamic-og-image-generator' ),
			) );
		}

		// Force-regenerate all eligible posts, bypassing the freshness check.
		// Unlike the daily refresh, this always queues every enabled published post.
		$queued = DOIG_Cron::force_regenerate_all();

		wp_send_json_success( array(
			'message' => sprintf(
				/* translators: %d: number of posts queued */
				_n(
					'%d screenshot queued for regeneration. It will be generated in the background.',
					'%d screenshots queued for regeneration. They will be generated in the background.',
					$queued,
					'dynamic-og-image-generator'
				),
				$queued
			),
		) );
	}

}
