<?php
/**
 * Launch Checklist — an admin-only, data-driven wizard that walks through
 * launching and optimizing a WordPress site. Steps are grouped into sections,
 * completion is saved per site, and the steps that can be detected verify
 * themselves on demand.
 *
 * HARD CONSTRAINTS (bw-dev history — the Request Firewall outage):
 *  - Admin-only. Registers NOTHING on the front end. No per-request hooks, no
 *    file/flock, no work in the request hot path.
 *  - Auto-verification runs ONLY on demand — on tab render (cheap checks) or an
 *    explicit "Re-check" click (everything, incl. the one network fetch).
 *  - Standard WP security: manage_options + nonces on every AJAX action.
 *
 * EXTENSIBILITY: sections() and items() are declarative data. New sections
 * (Firewall, GA4, Forms, SEO, Security, more Performance items) are added by
 * appending registry entries — and, for an auto item, one verify method. The
 * renderer and JS are generic and never need to change.
 *
 * @package BW_Dev
 */

defined( 'ABSPATH' ) || exit;

class BW_Dev_Module_Launch_Checklist implements BW_Dev_Module_Interface {

	const ACTION_VERIFY = 'bw_dev_launch_checklist_verify';
	const ACTION_TOGGLE = 'bw_dev_launch_checklist_toggle';
	const NONCE         = 'bw_dev_launch_checklist';

	/** Per-request memo of verify results so render + progress don't double-run. */
	private $verify_cache = array();

	// ===================================================================
	// Module interface
	// ===================================================================

	public function slug(): string {
		return 'launch_checklist';
	}

	public function label(): string {
		return __( 'Launch Checklist', 'bw-dev' );
	}

	public function group(): string {
		return 'editor_admin';
	}

	public function default_settings(): array {
		return array();
	}

	/**
	 * Checklist state is written by AJAX, never through the settings form (this
	 * tab has no Save button). Return the existing persisted section UNCHANGED so
	 * that a stray Enter-key submit routed here by the shared __tab field can
	 * never wipe progress. Do NOT return an empty array.
	 */
	public function sanitize( array $data ): array {
		return (array) bw_dev()->settings()->get( $this->slug(), null, array() );
	}

	/**
	 * Admin-only wiring. Wrapped in is_admin() so a front-end request registers
	 * ZERO hooks — defense-in-depth on top of the enabled-gating; it also stops a
	 * future maintainer from carelessly adding a front-end hook in here.
	 */
	public function register(): void {
		if ( ! is_admin() ) {
			return;
		}
		add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );
		add_action( 'wp_ajax_' . self::ACTION_VERIFY, array( $this, 'handle_verify_ajax' ) );
		add_action( 'wp_ajax_' . self::ACTION_TOGGLE, array( $this, 'handle_toggle_ajax' ) );
	}

	public function uninstall(): void {
		// No-op: state lives under bw_dev_settings, dropped by the core uninstall.
	}

	public function enqueue_assets( $hook ): void {
		if ( 'settings_page_' . BW_Dev_Admin_Page::MENU_SLUG !== $hook ) {
			return;
		}
		$query = wp_unslash( $_GET ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
		$tab   = isset( $query['tab'] ) ? sanitize_key( (string) $query['tab'] ) : '';
		if ( $this->slug() !== $tab ) {
			return;
		}
		wp_enqueue_style(
			'bw-dev-launch-checklist',
			BW_DEV_URL . 'assets/css/launch-checklist.css',
			array(),
			BW_DEV_VERSION
		);
		wp_enqueue_script(
			'bw-dev-launch-checklist',
			BW_DEV_URL . 'assets/js/launch-checklist.js',
			array( 'jquery' ),
			BW_DEV_VERSION,
			true
		);
		wp_localize_script(
			'bw-dev-launch-checklist',
			'bwDevLaunchChecklist',
			array(
				'ajaxUrl'       => admin_url( 'admin-ajax.php' ),
				'nonce'         => wp_create_nonce( self::NONCE ),
				'actionVerify'  => self::ACTION_VERIFY,
				'actionToggle'  => self::ACTION_TOGGLE,
				'i18n'          => array(
					'checking'   => __( 'Checking…', 'bw-dev' ),
					'recheckAll' => __( 'Re-check all', 'bw-dev' ),
					'failed'     => __( 'Check failed', 'bw-dev' ),
					'done'       => __( 'done', 'bw-dev' ),
				),
			)
		);
	}

	// ===================================================================
	// Declarative registry — sections + items (add to these to grow)
	// ===================================================================

	/** @return array<int,array{id:string,title:string,order:int,intro:string}> */
	private function sections(): array {
		$sections = array(
			array(
				'id'    => 'performance',
				'title' => __( 'Performance & Caching', 'bw-dev' ),
				'order' => 10,
				'intro' => __( 'Set up page caching, object caching, and PHP so the site is fast and stable. Work top to bottom; the steps that can be detected check themselves.', 'bw-dev' ),
			),
		);
		usort(
			$sections,
			static function ( $a, $b ) {
				return $a['order'] <=> $b['order'];
			}
		);
		return $sections;
	}

	/**
	 * Every checklist item as data. type 'auto' items name a verify method;
	 * 'manual' items are a checkbox + instructions. 'on_demand' auto items are
	 * not run on render (they cost a network round-trip) — only on a button.
	 *
	 * @return array<int,array<string,mixed>>
	 */
	private function items(): array {
		$items = array(
			// --- Setup ---
			array(
				'id'           => 'perf_spc_install',
				'section'      => 'performance',
				'subgroup'     => __( 'Setup', 'bw-dev' ),
				'order'        => 10,
				'title'        => __( 'Install & activate "Super Page Cache for Cloudflare"', 'bw-dev' ),
				'type'         => 'auto',
				'verify'       => 'check_spc_active',
				'instructions' => array(
					__( 'In wp-admin, go to Plugins → Add New and search for "Super Page Cache for Cloudflare".', 'bw-dev' ),
					__( 'Install it and click Activate. This is the caching layer that talks to Cloudflare.', 'bw-dev' ),
				),
			),
			array(
				'id'           => 'perf_cf_token',
				'section'      => 'performance',
				'subgroup'     => __( 'Setup', 'bw-dev' ),
				'order'        => 20,
				'title'        => __( 'Create the Cloudflare API token', 'bw-dev' ),
				'type'         => 'manual',
				'instructions' => array(
					__( 'In Cloudflare → My Profile → API Tokens → Create Token, start from the "WordPress" token TEMPLATE (do not build from scratch).', 'bw-dev' ),
					__( 'Name it "WordPress: {Site Name}" so it is easy to identify later.', 'bw-dev' ),
					__( 'Confirm it has exactly these three permissions: Zone · Cache Rules · Edit, Zone · Transform Rules · Edit, Zone · Page Rules · Edit.', 'bw-dev' ),
					__( 'Set Zone Resources to THIS domain only (not "All zones"). Create the token and copy it.', 'bw-dev' ),
				),
				'links'        => array(
					array(
						'label' => __( 'Open Cloudflare API Tokens', 'bw-dev' ),
						'url'   => 'https://dash.cloudflare.com/profile/api-tokens',
					),
				),
			),
			array(
				'id'           => 'perf_spc_connect',
				'section'      => 'performance',
				'subgroup'     => __( 'Setup', 'bw-dev' ),
				'order'        => 30,
				'title'        => __( 'Paste the token into Super Page Cache, connect, and enable Page Caching', 'bw-dev' ),
				'type'         => 'manual',
				'instructions' => array(
					__( 'In Super Page Cache → settings, paste the API token and connect to your Cloudflare zone.', 'bw-dev' ),
					__( 'Turn Page Caching ON. Super Page Cache creates the required cache rules itself — there is no separate "create 3 cache rules" step (those three are the token permissions above).', 'bw-dev' ),
				),
			),
			array(
				'id'           => 'perf_breeze_off',
				'section'      => 'performance',
				'subgroup'     => __( 'Setup', 'bw-dev' ),
				'order'        => 40,
				'title'        => __( 'Deactivate Breeze', 'bw-dev' ),
				'type'         => 'auto',
				'verify'       => 'check_breeze_inactive',
				'instructions' => array(
					__( 'Breeze conflicts with Super Page Cache. In Plugins, deactivate Breeze if it is active.', 'bw-dev' ),
				),
			),

			// --- Settings to confirm (in Super Page Cache) ---
			array(
				'id'           => 'perf_varnish',
				'section'      => 'performance',
				'subgroup'     => __( 'Settings to confirm (in Super Page Cache)', 'bw-dev' ),
				'order'        => 50,
				'title'        => __( 'Varnish caching is ON', 'bw-dev' ),
				'type'         => 'manual',
				'instructions' => array(
					__( 'In Super Page Cache → Cache settings, confirm Varnish support is enabled.', 'bw-dev' ),
				),
			),
			array(
				'id'           => 'perf_preloader',
				'section'      => 'performance',
				'subgroup'     => __( 'Settings to confirm (in Super Page Cache)', 'bw-dev' ),
				'order'        => 60,
				'title'        => __( 'Preloader is OFF', 'bw-dev' ),
				'type'         => 'manual',
				'instructions' => array(
					__( 'Confirm the cache Preloader is disabled — on these sites it adds load without benefit.', 'bw-dev' ),
				),
			),
			array(
				'id'           => 'perf_lifetime',
				'section'      => 'performance',
				'subgroup'     => __( 'Settings to confirm (in Super Page Cache)', 'bw-dev' ),
				'order'        => 70,
				'title'        => __( 'Cache lifetime is 1 day', 'bw-dev' ),
				'type'         => 'manual',
				'instructions' => array(
					__( 'Set the page cache lifetime / max-age to 1 day.', 'bw-dev' ),
				),
			),
			array(
				'id'           => 'perf_autopurge',
				'section'      => 'performance',
				'subgroup'     => __( 'Settings to confirm (in Super Page Cache)', 'bw-dev' ),
				'order'        => 80,
				'title'        => __( 'Auto-purge after updates is ON', 'bw-dev' ),
				'type'         => 'manual',
				'instructions' => array(
					__( 'Confirm the cache is set to purge automatically when content is updated, so edits go live promptly.', 'bw-dev' ),
				),
			),
			array(
				'id'           => 'perf_objectcache',
				'section'      => 'performance',
				'subgroup'     => __( 'Settings to confirm (in Super Page Cache)', 'bw-dev' ),
				'order'        => 90,
				'title'        => __( 'Object cache (Redis) is connected', 'bw-dev' ),
				'type'         => 'auto',
				'verify'       => 'check_redis',
				'instructions' => array(
					__( 'A persistent object cache (Redis) should be active. This is detected from the WordPress object-cache drop-in.', 'bw-dev' ),
				),
			),

			// --- PHP / server ---
			array(
				'id'           => 'perf_php_version',
				'section'      => 'performance',
				'subgroup'     => __( 'PHP / server', 'bw-dev' ),
				'order'        => 100,
				'title'        => __( 'PHP version is 8.2 or higher', 'bw-dev' ),
				'type'         => 'auto',
				'verify'       => 'check_php_version',
				'instructions' => array(
					__( 'If this fails, raise the PHP version in the host control panel (Flywheel) to 8.2+.', 'bw-dev' ),
				),
			),
			array(
				'id'           => 'perf_opcache',
				'section'      => 'performance',
				'subgroup'     => __( 'PHP / server', 'bw-dev' ),
				'order'        => 110,
				'title'        => __( 'OPcache is enabled and tuned (≥ 256MB, ≥ 20000 files)', 'bw-dev' ),
				'type'         => 'auto',
				'verify'       => 'check_opcache',
				'instructions' => array(
					__( 'This plugin set is heavy and needs more than the 128MB / 10000-file defaults. If this flags low, raise opcache.memory_consumption to 256+ and opcache.max_accelerated_files to 20000+.', 'bw-dev' ),
				),
			),

			// --- Verification ---
			array(
				'id'           => 'perf_homepage',
				'section'      => 'performance',
				'subgroup'     => __( 'Verification', 'bw-dev' ),
				'order'        => 120,
				'title'        => __( 'Homepage loads and returns real content (not blank)', 'bw-dev' ),
				'type'         => 'auto',
				'verify'       => 'check_homepage',
				'on_demand'    => true,
				'instructions' => array(
					__( 'Click Re-check to fetch the homepage from the server. This catches the "blank cached page" failure where caching serves an empty document.', 'bw-dev' ),
				),
			),
			array(
				'id'           => 'perf_homepage_hit',
				'section'      => 'performance',
				'subgroup'     => __( 'Verification', 'bw-dev' ),
				'order'        => 130,
				'title'        => __( 'Homepage serves from cache (cache HIT)', 'bw-dev' ),
				'type'         => 'manual',
				'instructions' => array(
					__( 'In your browser, open the homepage in a private window, open DevTools → Network, reload, and check the document response headers for a cache HIT (e.g. cf-cache-status: HIT or x-cache: HIT).', 'bw-dev' ),
					__( 'This is a manual check: a request from the server to its own homepage bypasses Cloudflare, so the cache header can only be seen reliably from a real browser.', 'bw-dev' ),
				),
			),
			array(
				'id'           => 'perf_form_submit',
				'section'      => 'performance',
				'subgroup'     => __( 'Verification', 'bw-dev' ),
				'order'        => 140,
				'title'        => __( 'A form submits while logged out (entry appears in Gravity Forms)', 'bw-dev' ),
				'type'         => 'manual',
				'instructions' => array(
					__( 'Log out (or use a private window), submit a form on the site, then confirm the entry appears under Forms → Entries. This proves caching is not breaking POST submissions.', 'bw-dev' ),
				),
			),
			array(
				'id'           => 'perf_loggedin_fresh',
				'section'      => 'performance',
				'subgroup'     => __( 'Verification', 'bw-dev' ),
				'order'        => 150,
				'title'        => __( 'Logged-in pages render fresh (with the admin bar)', 'bw-dev' ),
				'type'         => 'manual',
				'instructions' => array(
					__( 'While logged in, browse a few pages and confirm you see the admin bar and live content — not a cached anonymous copy.', 'bw-dev' ),
				),
			),
			array(
				'id'           => 'perf_edit_reload',
				'section'      => 'performance',
				'subgroup'     => __( 'Verification', 'bw-dev' ),
				'order'        => 160,
				'title'        => __( 'Editing a page shows the change within seconds', 'bw-dev' ),
				'type'         => 'manual',
				'instructions' => array(
					__( 'Edit a page, save, then reload the front end and confirm the change appears within a few seconds. This proves cache purging (including Varnish) works.', 'bw-dev' ),
				),
			),
		);

		usort(
			$items,
			static function ( $a, $b ) {
				return $a['order'] <=> $b['order'];
			}
		);
		return $items;
	}

	/** Items for one section, ordered. */
	private function items_for( string $section_id ): array {
		return array_values(
			array_filter(
				$this->items(),
				static function ( $i ) use ( $section_id ) {
					return $i['section'] === $section_id;
				}
			)
		);
	}

	// ===================================================================
	// State (nested under the single bw_dev_settings option)
	// ===================================================================

	private function all_state(): array {
		$state = bw_dev()->settings()->get( $this->slug(), 'items', array() );
		return is_array( $state ) ? $state : array();
	}

	private function item_done( string $id ): bool {
		$state = $this->all_state();
		return ! empty( $state[ $id ]['done'] );
	}

	// ===================================================================
	// Verify dispatch + progress
	// ===================================================================

	/** Resolve and run an item's verify method. Always returns a 3-state result. */
	private function run_verify( array $item ): array {
		$id = $item['id'];
		if ( isset( $this->verify_cache[ $id ] ) ) {
			return $this->verify_cache[ $id ];
		}
		$result = array(
			'status'  => 'unknown',
			'message' => __( 'Not checked.', 'bw-dev' ),
		);
		if ( 'auto' === ( $item['type'] ?? '' ) && ! empty( $item['verify'] ) && is_string( $item['verify'] ) && method_exists( $this, $item['verify'] ) ) {
			$out = $this->{$item['verify']}();
			if ( is_array( $out ) && isset( $out['status'] ) ) {
				$result = array(
					'status'  => in_array( $out['status'], array( 'pass', 'fail', 'unknown' ), true ) ? $out['status'] : 'unknown',
					'message' => isset( $out['message'] ) ? (string) $out['message'] : '',
				);
			}
		}
		$this->verify_cache[ $id ] = $result;
		return $result;
	}

	/**
	 * Whether an item counts toward progress. Cheap auto items auto-count when
	 * they pass; on-demand auto items and manual items count via the checkbox.
	 * Never runs the on-demand network check here (keeps toggles fast).
	 */
	private function item_complete( array $item ): bool {
		if ( $this->item_done( $item['id'] ) ) {
			return true;
		}
		if ( 'auto' === ( $item['type'] ?? '' ) && empty( $item['on_demand'] ) ) {
			return 'pass' === $this->run_verify( $item )['status'];
		}
		return false;
	}

	/** Overall + per-section progress counts. */
	private function progress(): array {
		$sections = array();
		$o_done   = 0;
		$o_total  = 0;
		foreach ( $this->sections() as $section ) {
			$done  = 0;
			$total = 0;
			foreach ( $this->items_for( $section['id'] ) as $item ) {
				++$total;
				if ( $this->item_complete( $item ) ) {
					++$done;
				}
			}
			$sections[ $section['id'] ] = $this->progress_row( $done, $total );
			$o_done  += $done;
			$o_total += $total;
		}
		return array(
			'overall'  => $this->progress_row( $o_done, $o_total ),
			'sections' => $sections,
		);
	}

	private function progress_row( int $done, int $total ): array {
		return array(
			'done'  => $done,
			'total' => $total,
			'pct'   => $total > 0 ? (int) round( ( $done / $total ) * 100 ) : 0,
		);
	}

	// ===================================================================
	// Verify methods (auto items). Each returns {status, message}.
	// Default to 'unknown' whenever the environment can't answer definitively —
	// a false 'fail' on a launch checklist trains the user to ignore reds.
	// ===================================================================

	/** @return array<string,string> */
	private function check_spc_active(): array {
		$active = $this->plugin_active_in_dir( 'super-page-cache-for-cloudflare' ) || $this->plugin_active_in_dir( 'swcfpc' );
		return $active
			? array( 'status' => 'pass', 'message' => __( 'Super Page Cache for Cloudflare is active.', 'bw-dev' ) )
			: array( 'status' => 'fail', 'message' => __( 'Super Page Cache for Cloudflare is not active.', 'bw-dev' ) );
	}

	private function check_breeze_inactive(): array {
		$active = $this->plugin_active_in_dir( 'breeze' );
		return $active
			? array( 'status' => 'fail', 'message' => __( 'Breeze is still active — deactivate it.', 'bw-dev' ) )
			: array( 'status' => 'pass', 'message' => __( 'Breeze is not active.', 'bw-dev' ) );
	}

	private function check_redis(): array {
		if ( ! function_exists( 'wp_using_ext_object_cache' ) || ! wp_using_ext_object_cache() ) {
			return array( 'status' => 'fail', 'message' => __( 'No persistent object cache drop-in is active.', 'bw-dev' ) );
		}
		global $wp_object_cache;
		$class = is_object( $wp_object_cache ) ? get_class( $wp_object_cache ) : '';
		if ( '' !== $class && false !== stripos( $class, 'redis' ) ) {
			/* translators: %s: object cache class name */
			return array( 'status' => 'pass', 'message' => sprintf( __( 'Redis object cache active (%s).', 'bw-dev' ), $class ) );
		}
		/* translators: %s: object cache class name */
		return array( 'status' => 'unknown', 'message' => sprintf( __( 'A persistent object cache is active (%s) but it does not look like Redis — confirm.', 'bw-dev' ), $class ?: 'unknown' ) );
	}

	private function check_php_version(): array {
		$ok = version_compare( PHP_VERSION, '8.2', '>=' );
		return array(
			'status'  => $ok ? 'pass' : 'fail',
			/* translators: %s: PHP version */
			'message' => sprintf( $ok ? __( 'PHP %s (≥ 8.2).', 'bw-dev' ) : __( 'PHP %s — below 8.2.', 'bw-dev' ), PHP_VERSION ),
		);
	}

	private function check_opcache(): array {
		if ( ! function_exists( 'opcache_get_status' ) ) {
			return array( 'status' => 'unknown', 'message' => __( 'OPcache functions are not available to query.', 'bw-dev' ) );
		}
		$status = @opcache_get_status( false ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
		if ( ! is_array( $status ) || empty( $status['opcache_enabled'] ) ) {
			return array( 'status' => 'fail', 'message' => __( 'OPcache is disabled.', 'bw-dev' ) );
		}
		$mem_mb    = (int) ini_get( 'opcache.memory_consumption' );
		$max_files = (int) ini_get( 'opcache.max_accelerated_files' );
		$ok        = $mem_mb >= 256 && $max_files >= 20000;
		return array(
			'status'  => $ok ? 'pass' : 'fail',
			/* translators: 1: memory in MB, 2: max accelerated files */
			'message' => sprintf( __( 'OPcache enabled — %1$dMB memory, %2$d files.', 'bw-dev' ), $mem_mb, $max_files ) . ( $ok ? '' : ' ' . __( 'Below the recommended 256MB / 20000.', 'bw-dev' ) ),
		);
	}

	private function check_homepage(): array {
		$resp = wp_safe_remote_get(
			home_url( '/' ),
			array(
				'timeout'     => 8,
				'redirection' => 3,
				'sslverify'   => true,
				'user-agent'  => 'BW Dev Launch Checklist',
			)
		);
		if ( is_wp_error( $resp ) ) {
			/* translators: %s: error message */
			return array( 'status' => 'fail', 'message' => sprintf( __( 'Could not fetch the homepage: %s', 'bw-dev' ), $resp->get_error_message() ) );
		}
		$code = (int) wp_remote_retrieve_response_code( $resp );
		$body = (string) wp_remote_retrieve_body( $resp );
		$len  = strlen( $body );
		if ( 200 === $code ) {
			if ( $len >= 256 && false !== stripos( $body, '</html>' ) ) {
				/* translators: %s: byte count */
				return array( 'status' => 'pass', 'message' => sprintf( __( 'Homepage OK (HTTP 200, %s bytes).', 'bw-dev' ), number_format_i18n( $len ) ) );
			}
			/* translators: %s: byte count */
			return array( 'status' => 'fail', 'message' => sprintf( __( 'Homepage returned 200 but the body looks blank/incomplete (%s bytes) — possible blank cached page.', 'bw-dev' ), number_format_i18n( $len ) ) );
		}
		if ( 401 === $code || 403 === $code ) {
			/* translators: %d: HTTP status code */
			return array( 'status' => 'unknown', 'message' => sprintf( __( 'Homepage is behind an auth/staging gate (HTTP %d) — verify manually.', 'bw-dev' ), $code ) );
		}
		if ( $code >= 500 ) {
			/* translators: %d: HTTP status code */
			return array( 'status' => 'fail', 'message' => sprintf( __( 'Homepage returned a server error (HTTP %d).', 'bw-dev' ), $code ) );
		}
		/* translators: %d: HTTP status code */
		return array( 'status' => 'unknown', 'message' => sprintf( __( 'Unexpected homepage response (HTTP %d).', 'bw-dev' ), $code ) );
	}

	// --- detection helpers ---

	private function active_plugin_files(): array {
		$files = (array) get_option( 'active_plugins', array() );
		if ( is_multisite() ) {
			$files = array_merge( $files, array_keys( (array) get_site_option( 'active_sitewide_plugins', array() ) ) );
		}
		return $files;
	}

	private function plugin_active_in_dir( string $dir ): bool {
		foreach ( $this->active_plugin_files() as $file ) {
			if ( 0 === strpos( (string) $file, $dir . '/' ) ) {
				return true;
			}
		}
		return false;
	}

	// ===================================================================
	// AJAX
	// ===================================================================

	public function handle_verify_ajax(): void {
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( array( 'message' => __( 'Forbidden.', 'bw-dev' ) ), 403 );
		}
		check_ajax_referer( self::NONCE );

		$requested = isset( $_POST['item'] ) ? sanitize_key( wp_unslash( (string) $_POST['item'] ) ) : 'all';
		$results   = array();
		foreach ( $this->items() as $item ) {
			if ( 'auto' !== ( $item['type'] ?? '' ) ) {
				continue;
			}
			if ( 'all' !== $requested && $item['id'] !== $requested ) {
				continue;
			}
			$results[ $item['id'] ] = $this->run_verify( $item );
		}
		wp_send_json_success(
			array(
				'results'  => $results,
				'progress' => $this->progress(),
			)
		);
	}

	public function handle_toggle_ajax(): void {
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( array( 'message' => __( 'Forbidden.', 'bw-dev' ) ), 403 );
		}
		check_ajax_referer( self::NONCE );

		$id = isset( $_POST['item'] ) ? sanitize_key( wp_unslash( (string) $_POST['item'] ) ) : '';
		if ( '' === $id || ! $this->item_exists( $id ) ) {
			wp_send_json_error( array( 'message' => __( 'Unknown item.', 'bw-dev' ) ), 400 );
		}
		$done = ! empty( $_POST['done'] ) && '0' !== (string) wp_unslash( $_POST['done'] );

		// Fresh read-modify-write of the WHOLE option to avoid clobbering a
		// concurrent write with a stale cached snapshot (mirrors the image
		// optimizer's record_stats() pattern). Then resync the settings cache.
		$all = get_option( BW_Dev_Settings::OPTION, array() );
		if ( ! is_array( $all ) ) {
			$all = array();
		}
		if ( ! isset( $all['launch_checklist'] ) || ! is_array( $all['launch_checklist'] ) ) {
			$all['launch_checklist'] = array();
		}
		if ( ! isset( $all['launch_checklist']['items'] ) || ! is_array( $all['launch_checklist']['items'] ) ) {
			$all['launch_checklist']['items'] = array();
		}
		if ( $done ) {
			$all['launch_checklist']['items'][ $id ] = array(
				'done' => true,
				'ts'   => time(),
				'user' => get_current_user_id(),
			);
		} else {
			unset( $all['launch_checklist']['items'][ $id ] );
		}
		update_option( BW_Dev_Settings::OPTION, $all );
		bw_dev()->settings()->flush();

		wp_send_json_success( array( 'progress' => $this->progress() ) );
	}

	private function item_exists( string $id ): bool {
		foreach ( $this->items() as $item ) {
			if ( $item['id'] === $id ) {
				return true;
			}
		}
		return false;
	}

	// ===================================================================
	// Render
	// ===================================================================

	public function render_tab(): void {
		if ( ! current_user_can( 'manage_options' ) ) {
			return;
		}
		$progress = $this->progress();
		?>
		<p class="description">
			<?php esc_html_e( 'Walk through launching and optimizing this site. Progress is saved per site. Steps that can be detected check themselves; the rest are checkboxes you tick as you complete them.', 'bw-dev' ); ?>
		</p>

		<div class="bw-lc" data-nonce="">
			<div class="bw-lc-toolbar">
				<?php $this->render_progress( 'overall', $progress['overall'], __( 'Overall', 'bw-dev' ) ); ?>
				<button type="button" class="button bw-lc-recheck-all"><?php esc_html_e( 'Re-check all', 'bw-dev' ); ?></button>
			</div>

			<?php foreach ( $this->sections() as $section ) : ?>
				<details class="bw-lc-section" data-section="<?php echo esc_attr( $section['id'] ); ?>" open>
					<summary class="bw-lc-section-summary">
						<span class="bw-lc-section-title"><?php echo esc_html( $section['title'] ); ?></span>
						<?php
						$srow = $progress['sections'][ $section['id'] ] ?? array( 'done' => 0, 'total' => 0, 'pct' => 0 );
						$this->render_progress( 'section:' . $section['id'], $srow, '' );
						?>
					</summary>

					<?php if ( ! empty( $section['intro'] ) ) : ?>
						<p class="description bw-lc-section-intro"><?php echo esc_html( $section['intro'] ); ?></p>
					<?php endif; ?>

					<?php
					$num         = 0;
					$last_group  = null;
					foreach ( $this->items_for( $section['id'] ) as $item ) :
						++$num;
						$group = isset( $item['subgroup'] ) ? (string) $item['subgroup'] : '';
						if ( $group !== $last_group ) {
							$last_group = $group;
							if ( '' !== $group ) {
								echo '<h4 class="bw-lc-subgroup">' . esc_html( $group ) . '</h4>';
							}
						}
						$this->render_item( $item, $num );
					endforeach;
					?>
				</details>
			<?php endforeach; ?>
		</div>
		<?php
	}

	private function render_progress( string $scope, array $row, string $label ): void {
		$pct = (int) ( $row['pct'] ?? 0 );
		?>
		<div class="bw-lc-progress" data-scope="<?php echo esc_attr( $scope ); ?>">
			<?php if ( '' !== $label ) : ?>
				<span class="bw-lc-progress-name"><?php echo esc_html( $label ); ?></span>
			<?php endif; ?>
			<span class="bw-lc-progress-track"><span class="bw-lc-progress-fill" style="width:<?php echo esc_attr( $pct ); ?>%;"></span></span>
			<span class="bw-lc-progress-label">
				<?php
				/* translators: 1: completed count, 2: total count */
				echo esc_html( sprintf( __( '%1$d of %2$d done', 'bw-dev' ), (int) ( $row['done'] ?? 0 ), (int) ( $row['total'] ?? 0 ) ) );
				?>
			</span>
		</div>
		<?php
	}

	private function render_item( array $item, int $num ): void {
		$id        = $item['id'];
		$type      = (string) ( $item['type'] ?? 'manual' );
		$is_auto   = 'auto' === $type;
		$on_demand = ! empty( $item['on_demand'] );
		$done      = $this->item_done( $id );

		if ( $is_auto && ! $on_demand ) {
			$res    = $this->run_verify( $item );
			$status = $res['status'];
			$msg    = $res['message'];
		} elseif ( $is_auto ) {
			$status = 'pending';
			$msg    = '';
		} else {
			$status = $done ? 'pass' : 'todo';
			$msg    = '';
		}
		?>
		<div class="bw-lc-item" data-item-id="<?php echo esc_attr( $id ); ?>" data-type="<?php echo esc_attr( $type ); ?>">
			<div class="bw-lc-item-head">
				<span class="bw-lc-dot bw-lc-dot--<?php echo esc_attr( $status ); ?>" aria-hidden="true"></span>
				<span class="bw-lc-num"><?php echo esc_html( (string) $num ); ?>.</span>
				<span class="bw-lc-title"><?php echo esc_html( (string) $item['title'] ); ?></span>
				<?php if ( $is_auto ) : ?>
					<button type="button" class="button button-small bw-lc-verify"><?php esc_html_e( 'Re-check', 'bw-dev' ); ?></button>
				<?php endif; ?>
			</div>

			<div class="bw-lc-item-body">
				<?php foreach ( (array) ( $item['instructions'] ?? array() ) as $line ) : ?>
					<p class="bw-lc-instr"><?php echo esc_html( (string) $line ); ?></p>
				<?php endforeach; ?>

				<?php if ( ! empty( $item['links'] ) ) : ?>
					<p class="bw-lc-links">
						<?php foreach ( (array) $item['links'] as $link ) : ?>
							<a href="<?php echo esc_url( (string) ( $link['url'] ?? '' ) ); ?>" target="_blank" rel="noopener noreferrer"><?php echo esc_html( (string) ( $link['label'] ?? '' ) ); ?></a>
						<?php endforeach; ?>
					</p>
				<?php endif; ?>

				<?php if ( $is_auto ) : ?>
					<p class="bw-lc-msg"><?php echo esc_html( (string) $msg ); ?></p>
				<?php endif; ?>

				<label class="bw-lc-checkline">
					<input type="checkbox" class="bw-lc-check" data-item-id="<?php echo esc_attr( $id ); ?>" <?php checked( $done ); ?> />
					<?php echo $is_auto ? esc_html__( 'Mark done (override)', 'bw-dev' ) : esc_html__( 'Mark done', 'bw-dev' ); ?>
				</label>
			</div>
		</div>
		<?php
	}
}
