/**
 * Cache module — admin-side JavaScript.
 *
 * Wires the admin UI bits that are specific to the Cache module:
 *   - Safe mode toggle (AJAX flag)
 *   - Disk usage refresh
 *   - Persisting the active tab into a hidden input so the form POST returns
 *     the user to the same tab after save (the tab system itself is handled
 *     by the global admin.js).
 *
 * @package BlaminhorEssentials
 */
(function ($) {
	'use strict';

	if (typeof window.blaminhorEssentialsCache === 'undefined') {
		return;
	}

	var cfg = window.blaminhorEssentialsCache;

	/**
	 * Forward a client-side event to the structured logger so we can see what
	 * happened *inside the user's browser* during a Critical CSS run, an audit,
	 * etc. The server silent-ignores the call when log collection is OFF, so
	 * we don't gate on cfg.logsEnabled here — that flag is captured at page
	 * load by wp_localize_script and would be stale if the admin enabled the
	 * toggle in another tab. Always trying keeps things robust at the cost of
	 * a few cheap AJAX no-ops when logging is off.
	 *
	 * @param {string} level   debug | info | warning | error
	 * @param {string} event   snake_case identifier
	 * @param {string} message human-readable summary
	 * @param {object} [context]
	 */
	function beLogsRecord(level, event, message, context) {
		try {
			$.post(cfg.ajaxUrl, {
				action: 'blaminhor_essentials_logs_record_client',
				nonce: cfg.nonce,
				module: 'cache',
				level: level,
				event: event,
				message: message,
				context: JSON.stringify(context || {})
			}).fail(function (xhr) {
				// Surface why the AJAX failed so we can debug nonce / WAF / CORS
				// issues without having to dig through Network manually.
				try {
					console.warn('[beLogsRecord] AJAX failed', { event: event, status: xhr && xhr.status, statusText: xhr && xhr.statusText, body: xhr && (xhr.responseText || '').slice(0, 200) });
				} catch (e) {}
			});
		} catch (e) { /* never let logging fail the actual flow */ }
	}

	/**
	 * Inject a dismissable WP-style admin notice instead of using the native
	 * window.alert() dialog. window.alert is modal, blocks the renderer, and
	 * surfaces strings without locale fallbacks — the wrong UX for a cache
	 * settings page where errors are usually informative, not catastrophic.
	 *
	 * @param {string} message
	 * @param {'error'|'success'|'warning'|'info'} [type]
	 */
	function beCacheNotify(message, type) {
		var msg = message || (cfg.strings && cfg.strings.genericError) || '';
		if (!msg) { return; }
		var cls = 'notice-' + (type === 'success' || type === 'warning' || type === 'info' ? type : 'error');
		var $host = $('.blaminhor-essentials-wrap').first();
		if (!$host.length) { $host = $('#wpbody-content').first(); }
		// Strip any previous transient notice we injected.
		$host.find('.be-cache-flash-notice').remove();
		var $n = $('<div class="notice be-cache-flash-notice is-dismissible ' + cls + '"><p></p><button type="button" class="notice-dismiss"><span class="screen-reader-text">' +
			((cfg.strings && cfg.strings.dismiss) || 'Dismiss') + '</span></button></div>');
		$n.find('p').text(msg);
		$n.on('click', '.notice-dismiss', function () { $n.fadeOut(150, function () { $n.remove(); }); });
		$host.prepend($n);
		// Auto-dismiss after 8s — long enough to read, short enough to not pile up.
		window.setTimeout(function () { $n.fadeOut(200, function () { $n.remove(); }); }, 8000);
		try {
			$('html, body').animate({ scrollTop: $n.offset().top - 60 }, 200);
		} catch (e) { /* offset() can throw on detached nodes — non-fatal */ }
	}

	$(function () {
		bindSafeModeToggle();
		bindDiskUsageRefresh();
		bindActiveTabPersistence();
		bindConflictDetailsLink();
		bindPurgeAll();
		bindResyncDropin();
		bindAdminBarPurge();
		bindCriticalGenerate();
		bindCriticalClear();
		bindPsiKeyButtons();
		bindAuditRun();
		bindWatchdogRun();
		bindHealthCheck();
		bindPreloadNow();
		bindPreloadStatusBanner();
		bindCachedPagesTable();
		bindSettingsExportImport();
		restoreLastAudit();
	});

	function bindSafeModeToggle() {
		$(document).on('change', '.be-cache-safe-mode-toggle', function () {
			var $input = $(this);
			var enable = $input.is(':checked');

			if (enable && cfg.strings.confirmEnableSafe && !window.confirm(cfg.strings.confirmEnableSafe)) {
				$input.prop('checked', false);
				return;
			}

			$input.prop('disabled', true);

			$.ajax({
				url: cfg.ajaxUrl,
				type: 'POST',
				dataType: 'json',
				data: {
					action: 'ap_cache_safe_mode_toggle',
					nonce: cfg.nonce,
					enable: enable ? 1 : 0
				}
			}).done(function (response) {
				if (response && response.success) {
					// Reload to reflect the runtime banner state.
					window.location.reload();
				} else {
					$input.prop('checked', !enable);
					beCacheNotify((response && response.data && response.data.message) || cfg.strings.genericError, "error");
				}
			}).fail(function () {
				$input.prop('checked', !enable);
				beCacheNotify(cfg.strings.genericError, "error");
			}).always(function () {
				$input.prop('disabled', false);
			});
		});
	}

	function bindDiskUsageRefresh() {
		$(document).on('click', '.be-cache-refresh-stats', function (e) {
			e.preventDefault();
			var $btn = $(this);
			var $target = $('.be-cache-disk-usage');
			var original = $btn.text();

			$btn.prop('disabled', true).text(cfg.strings.loading);

			$.ajax({
				url: cfg.ajaxUrl,
				type: 'POST',
				dataType: 'json',
				data: {
					action: 'ap_cache_get_stats',
					nonce: cfg.nonce
				}
			}).done(function (response) {
				if (response && response.success && response.data && response.data.disk_usage_readable) {
					$target.text(response.data.disk_usage_readable);
				}
			}).always(function () {
				$btn.prop('disabled', false).text(original);
			});
		});
	}

	/**
	 * Mirror the user-clicked tab into the hidden input so the form POST returns
	 * to that tab. Required because the tab itself is purely client-side.
	 */
	function bindActiveTabPersistence() {
		var $hidden = $('.be-cache-active-tab');
		if (!$hidden.length) {
			return;
		}
		$(document).on('click', '.blaminhor-essentials-tab', function () {
			var tab = $(this).data('tab');
			if (tab) {
				$hidden.val(tab);
			}
		});
	}

	/**
	 * Reserved for J1+: when activating the module fails because of a cache
	 * plugin conflict, the server returns response.data.code === 'cache_conflict'
	 * with a list of conflicts in response.data.data.conflicts. The global
	 * admin.js falls back to alert(); we offer no extra polish here yet.
	 *
	 * Hook is in place so future versions can upgrade the UX without touching
	 * the global script.
	 */
	function bindConflictDetailsLink() {
		// no-op for now — placeholder for the conflict modal upgrade.
	}

	var preloadStopFlag = false;

	function bindPreloadNow() {
		$(document).on('click', '#be-preload-stop', function (e) {
			e.preventDefault();
			preloadStopFlag = true;
		});

		$(document).on('click', '#be-preload-now', function (e) {
			e.preventDefault();
			var $btn        = $(this);
			var $stop       = $('#be-preload-stop');
			var $status     = $('#be-preload-now-status');
			var $count      = $('#be-preload-now-count');
			var $table      = $('#be-preload-table');
			var $tbody      = $table.find('tbody').empty();
			var $progress   = $('#be-preload-progress-wrap');
			var $bar        = $('#be-preload-progress-bar');
			var labelOrig   = $btn.text();
			var s           = cfg.strings;
			preloadStopFlag = false;

			$btn.prop('disabled', true).html(
				'<span class="dashicons dashicons-update spin" style="vertical-align:text-bottom;margin-right:4px;font-size:16px;width:16px;height:16px;line-height:1;"></span>'
				+ escapeHtml(s.loading || 'Loading…')
			);
			$status.text(s.preloadBuilding || 'Building queue…');
			$count.text('0 / 0');
			$progress.show();
			$table.hide();
			$bar.css('width', '0%');

			// Step 1 — ask the server to (re)build the queue and return the
			// full URL list. This is fast (no HTTP fetching, just queries).
			$.post(cfg.ajaxUrl, {
				action: 'ap_cache_preload_start',
				nonce: cfg.nonce
			}, function (resp) {
				if (!resp || !resp.success || !resp.data || !resp.data.urls) {
					$status.html('<span class="ap-status-error">' + escapeHtml((resp && resp.data && resp.data.message) || s.genericError) + '</span>');
					$btn.prop('disabled', false).text(labelOrig);
					return;
				}
				var urls = resp.data.urls;
				if (urls.length === 0) {
					$status.html('<span class="ap-status-error">' + escapeHtml(s.preloadEmpty || 'No URLs to preload — check your sitemap.') + '</span>');
					$btn.prop('disabled', false).text(labelOrig);
					return;
				}

				// Pre-fill the table with all queued rows so the user
				// immediately sees what's about to be crawled.
				urls.forEach(function (url) {
					var $tr = $('<tr>'
						+ '<td><code class="ap-url-code">' + escapeHtml(url) + '</code></td>'
						+ '<td><span class="ap-status-badge">' + escapeHtml(s.preloadQueued || 'Queued') + '</span></td>'
						+ '<td>—</td>'
						+ '</tr>');
					$tr.attr('data-url', url);
					$tbody.append($tr);
				});
				$table.show();
				$stop.show();

				// Step 2 — warm the URLs one by one with the live table
				// updating after each AJAX call.
				var total = urls.length;
				var done = 0, ok = 0, ko = 0;
				function next(idx) {
					if (idx >= total || preloadStopFlag) {
						var stoppedTpl = preloadStopFlag
							? '<span class="ap-status-badge ap-status-warning">' + escapeHtml(s.preloadStopped || 'Stopped') + '</span>'
							: '<span class="ap-status-badge ap-status-success">' + escapeHtml(s.preloadDone || 'Done') + '</span>';
						$status.html(
							stoppedTpl + ' — ' + done + '/' + total + ' ' + escapeHtml(s.preloadProcessed || 'processed,') + ' '
							+ '<span class="ap-status-success">' + ok + ' ' + escapeHtml(s.preloadOk || 'OK') + '</span>'
							+ (ko > 0 ? ', <span class="ap-status-error">' + ko + ' ' + escapeHtml(s.preloadFailed || 'Failed') + '</span>' : '')
						);
						$count.text(done + ' / ' + total);
						$btn.prop('disabled', false).text(labelOrig);
						$stop.hide();
						return;
					}
					var url = urls[idx];
					var $row = $tbody.children().eq(idx);
					$row.children().eq(1).html('<span class="ap-status-badge ap-status-info">'
						+ '<span class="dashicons dashicons-update spin" style="vertical-align:text-bottom;font-size:14px;width:14px;height:14px;line-height:1;"></span> '
						+ escapeHtml(s.preloadProcessing || 'Processing…') + '</span>');
					var crawlTpl = (s.preloadCrawling || 'Crawling %1$d / %2$d…')
						.replace('%1$d', (idx + 1)).replace('%2$d', total);
					$status.text(crawlTpl);
					$count.text((idx + 1) + ' / ' + total);
					$bar.css('width', Math.round((idx / total) * 100) + '%');

					$.post(cfg.ajaxUrl, {
						action: 'ap_cache_preload_warm_one',
						nonce: cfg.nonce,
						url: url
					}, function (r) {
						if (r && r.success && r.data) {
							var http = r.data.http_code || 0;
							var success = http >= 200 && http < 400;
							if (success) {
								$row.children().eq(1).html('<span class="ap-status-badge ap-status-success">'
									+ '<span class="dashicons dashicons-yes-alt" style="vertical-align:text-bottom;font-size:14px;width:14px;height:14px;line-height:1;"></span> '
									+ escapeHtml('HTTP ' + http) + '</span>');
								ok++;
							} else if (http === 0 && r.data.error) {
								$row.children().eq(1).html('<span class="ap-status-badge ap-status-error">'
									+ escapeHtml(r.data.error) + '</span>');
								ko++;
							} else {
								$row.children().eq(1).html('<span class="ap-status-badge ap-status-error">'
									+ escapeHtml('HTTP ' + http) + '</span>');
								ko++;
							}
							$row.children().eq(2).text(r.data.duration_ms + ' ms');
						} else {
							$row.children().eq(1).html('<span class="ap-status-badge ap-status-error">' + escapeHtml('Error') + '</span>');
							ko++;
						}
						done++;
						$bar.css('width', Math.round((done / total) * 100) + '%');
						next(idx + 1);
					}, 'json').fail(function (xhr) {
						$row.children().eq(1).html('<span class="ap-status-badge ap-status-error">HTTP ' + (xhr && xhr.status) + '</span>');
						ko++;
						done++;
						next(idx + 1);
					});
				}
				next(0);
			}, 'json').fail(function (xhr) {
				$status.html('<span class="ap-status-error">HTTP ' + (xhr && xhr.status) + '</span>');
				$btn.prop('disabled', false).text(labelOrig);
			});
		});
	}

	function bindPurgeAll() {
		$(document).on('click', '.be-cache-purge-all', function (e) {
			e.preventDefault();
			var $btn = $(this);
			var original = $btn.text();
			$btn.prop('disabled', true).text(cfg.strings.loading);

			$.ajax({
				url: cfg.ajaxUrl,
				type: 'POST',
				dataType: 'json',
				data: {
					action: 'ap_cache_purge_all',
					nonce: cfg.nonce
				}
			}).done(function (response) {
				if (response && response.success) {
					$('.be-cache-disk-usage').text('0 B');
					if (response.data && response.data.message) {
						announce(response.data.message);
					}
				} else {
					beCacheNotify((response && response.data && response.data.message) || cfg.strings.genericError, "error");
				}
			}).fail(function () {
				beCacheNotify(cfg.strings.genericError, "error");
			}).always(function () {
				$btn.prop('disabled', false).text(original);
			});
		});
	}

	function bindResyncDropin() {
		$(document).on('click', '.be-cache-resync-dropin', function (e) {
			e.preventDefault();
			var $btn = $(this);
			var original = $btn.text();
			$btn.prop('disabled', true).text(cfg.strings.loading);

			$.ajax({
				url: cfg.ajaxUrl,
				type: 'POST',
				dataType: 'json',
				data: {
					action: 'ap_cache_resync_dropin',
					nonce: cfg.nonce
				}
			}).done(function (response) {
				if (response && response.success) {
					window.location.reload();
				} else {
					beCacheNotify((response && response.data && response.data.message) || cfg.strings.genericError, "error");
					$btn.prop('disabled', false).text(original);
				}
			}).fail(function () {
				beCacheNotify(cfg.strings.genericError, "error");
				$btn.prop('disabled', false).text(original);
			});
		});
	}

	/**
	 * Admin-bar "Purge cache" link. The link href is just an anchor; we hijack
	 * the click and run the same purge AJAX. The button is rendered globally
	 * by the orchestrator so it's available on every admin page.
	 */
	function bindAdminBarPurge() {
		$(document).on('click', '#wp-admin-bar-blaminhor-essentials-cache-purge a', function (e) {
			e.preventDefault();
			$.ajax({
				url: cfg.ajaxUrl,
				type: 'POST',
				dataType: 'json',
				data: {
					action: 'ap_cache_purge_all',
					nonce: cfg.nonce
				}
			}).done(function (response) {
				if (response && response.success) {
					announce((response.data && response.data.message) || 'OK');
				} else {
					beCacheNotify((response && response.data && response.data.message) || cfg.strings.genericError, "error");
				}
			}).fail(function () {
				beCacheNotify(cfg.strings.genericError, "error");
			});
		});
	}

	/* -------------------------------------------------------------------- */
	/*  Critical CSS extractor driver                                        */
	/* -------------------------------------------------------------------- */

	var VIEWPORTS = { mobile: { w: 375, h: 812 }, desktop: { w: 1280, h: 720 } };

	// Global lock — the iframe sandbox container is shared, so two parallel
	// runExtractionPlan() calls would clobber each other and leave rows stuck
	// on "Generating…". Any click while a run is in progress is a no-op.
	var isExtracting = false;

	function bindCriticalGenerate() {
		$(document).on('click', '.be-critical-generate', function (e) {
			e.preventDefault();
			if (isExtracting) { return; }
			var template = $(this).data('template');
			runExtractionPlan([{ template: template, viewports: ['mobile', 'desktop'] }]);
		});

		$(document).on('click', '.be-critical-generate-all', function (e) {
			e.preventDefault();
			if (isExtracting) { return; }
			var plan = [];
			$('#be-critical-templates tr[data-template]').each(function () {
				plan.push({ template: $(this).data('template'), viewports: ['mobile', 'desktop'] });
			});
			runExtractionPlan(plan);
		});
	}

	function setGlobalGenerating(on) {
		isExtracting = !!on;
		$('.be-critical-generate, .be-critical-generate-all, .be-critical-clear, #be-critical-clear-all')
			.prop('disabled', !!on);
	}

	function bindCriticalClear() {
		$(document).on('click', '.be-critical-clear', function (e) {
			e.preventDefault();
			var template = $(this).data('template');
			var $row = $('#be-critical-templates tr[data-template="' + template + '"]');
			$.post(cfg.ajaxUrl, {
				action: 'ap_cache_critical_clear',
				nonce: cfg.nonce,
				template: template
			}, function () {
				// Update the row in place — no reload, so unsaved settings on
				// other tabs stay intact.
				updateRowStatus($row, 'mobile', null);
				updateRowStatus($row, 'desktop', null);
			}, 'json');
		});

		$(document).on('click', '#be-critical-clear-all', function (e) {
			e.preventDefault();
			if (!window.confirm(cfg.strings.confirmClearAll || 'Clear ALL critical CSS files?')) { return; }
			$.post(cfg.ajaxUrl, {
				action: 'ap_cache_critical_clear',
				nonce: cfg.nonce
			}, function () {
				$('#be-critical-templates tr[data-template]').each(function () {
					updateRowStatus($(this), 'mobile', null);
					updateRowStatus($(this), 'desktop', null);
				});
			}, 'json');
		});
	}

	/**
	 * Refresh the Mobile / Desktop cell of a critical-CSS row WITHOUT a
	 * page reload (preserves the user's unsaved settings on other tabs).
	 *
	 * @param {jQuery} $row     The <tr data-template="…"> row.
	 * @param {string} variant  'mobile' or 'desktop'.
	 * @param {object|null} info Either {size, label} when generated, an
	 *                           object {error_kind, bytes} when the server
	 *                           rejected the extraction, or null for a
	 *                           plain "Not generated" reset.
	 */
	function updateRowStatus($row, variant, info) {
		var $cell = $row.find('td[data-variant="' + variant + '"]');
		if (!$cell.length) { return; }
		if (info && info.error_kind) {
			$cell.html(renderErrorCell(info));
			return;
		}
		if (info) {
			var html = '<span class="be-critical-cell-size">' + escapeHtml(info.size) + '</span>';
			html += '<br><small>' + escapeHtml(info.label || cfg.strings.justNow || 'just now') + '</small>';
			$cell.html(html);
		} else {
			$cell.html('<em>' + escapeHtml(cfg.strings.notGenerated || 'Not generated') + '</em>');
		}
	}

	/**
	 * Render the cell HTML for a rejected extraction. Mirrors the wording
	 * used by the server-side render_cell() so a freshly attempted run looks
	 * the same as one read back from the manifest after a reload.
	 *
	 * @param {object} info {error_kind, bytes}
	 * @return {string}
	 */
	function renderErrorCell(info) {
		var label;
		var help;
		var kb = Math.round((info.bytes || 0) / 1024);
		switch (info.error_kind) {
			case 'too_large':
				label = (cfg.strings.criticalTooLarge || 'Above the limit (%s KB)').replace('%s', kb);
				help = cfg.strings.criticalTooLargeHelp || '';
				break;
			case 'write_failed':
				label = cfg.strings.criticalSaveFailed || 'Could not save';
				help = cfg.strings.criticalSaveFailedHelp || '';
				break;
			case 'network':
				label = cfg.strings.criticalAjaxFailed || 'Network error during generation';
				help = cfg.strings.criticalAjaxFailedHelp || '';
				break;
			default:
				label = cfg.strings.notGenerated || 'Not generated';
				help = '';
		}
		return '<span class="be-critical-cell-error" title="' + escapeHtml(help) + '">'
			+ escapeHtml(label) + '</span>';
	}

	function escapeHtml(s) {
		return String(s == null ? '' : s)
			.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
			.replace(/"/g, '&quot;').replace(/'/g, '&#039;');
	}

	/**
	 * Show / hide the per-row "generating" feedback. The button label is
	 * replaced by a spinner + text, the Mobile/Desktop cells switch to an
	 * italic "Generating…" placeholder, and the Generate / Clear buttons of
	 * the row are disabled.
	 */
	function setRowGenerating($row, isOn, viewport) {
		var $btnGen   = $row.find('.be-critical-generate');
		var $btnClear = $row.find('.be-critical-clear');
		if (isOn) {
			if (!$btnGen.data('be-orig-html')) {
				$btnGen.data('be-orig-html', $btnGen.html());
			}
			$btnGen.prop('disabled', true).addClass('be-busy')
				.html('<span class="spinner is-active" style="float:none;margin:0 6px 0 0;"></span>'
					+ escapeHtml(cfg.strings.generating || 'Generating…'));
			$btnClear.prop('disabled', true);
			if (viewport) {
				var $cell = $row.find('td[data-variant="' + viewport + '"]');
				$cell.html('<em class="be-critical-cell-busy">'
					+ escapeHtml((cfg.strings.generating || 'Generating…')) + '</em>');
			}
		} else {
			var orig = $btnGen.data('be-orig-html');
			if (orig) { $btnGen.html(orig); $btnGen.removeData('be-orig-html'); }
			$btnGen.prop('disabled', false).removeClass('be-busy');
			$btnClear.prop('disabled', false);
		}
	}

	/**
	 * Sequential plan executor: for each {template, viewports[]} step, fetch
	 * the representative URL, load it in a sandboxed iframe sized for the
	 * viewport, run the extractor, persist via AJAX.
	 *
	 * @param {Array<{template: string, viewports: Array<string>}>} plan
	 */
	function runExtractionPlan(plan) {
		var $status = $('#be-critical-status');
		if (!plan || !plan.length) { return; }
		setGlobalGenerating(true);
		var idx = 0;

		function next() {
			if (idx >= plan.length) {
				// All done — no reload, just clear the global status line.
				// The per-row cells were already updated in processUrl().
				$status.text(cfg.strings.allDone || 'Done.');
				setTimeout(function () { $status.text(''); }, 3000);
				setGlobalGenerating(false);
				return;
			}
			var step = plan[idx++];
			var $row = $('#be-critical-templates tr[data-template="' + step.template + '"]');
			setRowGenerating($row, true, step.viewports[0] || null);
			$status.text((cfg.strings.generating || 'Generating') + ' ' + step.template + '…');
			$.post(cfg.ajaxUrl, {
				action: 'ap_cache_critical_url_for_template',
				nonce: cfg.nonce,
				template: step.template
			}, function (response) {
				if (!response || !response.success || !response.data || !response.data.url) {
					// No representative URL — log the actual reason so the user
					// can diagnose instead of seeing a silent reset to
					// "Not generated". Most often: nonce expired (open page too
					// long) or representative_url() couldn't find any content.
					var reason = (response && response.data && response.data.message)
						? response.data.message
						: 'unknown server response';
					console.warn('[Cache] url_for_template failed for', step.template, '—', reason);
					beLogsRecord('error', 'critical_url_for_template_failed',
						'AJAX url_for_template returned a non-success response — most often an expired nonce after the admin page sat open too long, or no published content for this template type',
						{
							template: step.template,
							reason: String(reason),
							ajax_success: !!(response && response.success),
							has_data: !!(response && response.data)
						}
					);
					step.viewports.forEach(function (v) {
						updateRowStatus($row, v, { error_kind: 'network', bytes: 0 });
					});
					$status.text((cfg.strings.skipped || 'Skipped') + ' ' + step.template + ' (' + reason + ').');
					setRowGenerating($row, false);
					next();
					return;
				}
				processUrl(step.template, response.data.url, step.viewports.slice(), $row, function () {
					setRowGenerating($row, false);
					next();
				});
			}, 'json').fail(function (xhr, textStatus) {
				// Network / nonce failure — log it so it's visible in DevTools
				// AND in the structured logger, instead of failing silently into
				// "Not generated".
				console.warn('[Cache] url_for_template HTTP error for', step.template, '—', textStatus, xhr && xhr.status);
				beLogsRecord('error', 'critical_url_for_template_ajax_failed',
					'AJAX url_for_template raised an HTTP error before reaching PHP — typical causes: expired nonce (403), WAF blocking admin-ajax, LiteSpeed timeout',
					{
						template: step.template,
						http_status: xhr && xhr.status,
						text_status: String(textStatus || ''),
						response_excerpt: xhr && (xhr.responseText || '').slice(0, 200)
					}
				);
				step.viewports.forEach(function (v) {
					updateRowStatus($row, v, { error_kind: 'network', bytes: 0 });
				});
				$status.text((cfg.strings.skipped || 'Skipped') + ' ' + step.template + ' (HTTP ' + (xhr && xhr.status) + ').');
				setRowGenerating($row, false);
				next();
			});
		}

		next();
	}

	function processUrl(template, url, viewports, $row, done) {
		if (viewports.length === 0) { done(); return; }
		var viewport = viewports.shift();
		// Move the busy state to the viewport currently being processed so the
		// user sees which column is updating (mobile first, then desktop).
		if ($row && $row.length) {
			var $cell = $row.find('td[data-variant="' + viewport + '"]');
			$cell.html('<em class="be-critical-cell-busy">'
				+ escapeHtml(cfg.strings.generating || 'Generating…') + '</em>');
		}
		var dims = VIEWPORTS[viewport] || VIEWPORTS.desktop;
		var $container = $('#be-critical-iframe-container').empty();
		var $iframe = $('<iframe />', {
			src: url,
			width: dims.w,
			height: dims.h,
			frameborder: 0
		}).css({ width: dims.w + 'px', height: dims.h + 'px' });
		$container.append($iframe);
		var iframe = $iframe[0];
		var settled = false;
		var timeoutHandle = null;
		var t0 = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
		beLogsRecord('debug', 'critical_iframe_created',
			'Iframe created for ' + template + '/' + viewport + ' targeting ' + url,
			{ template: template, variant: viewport, url: url, viewport_w: dims.w, viewport_h: dims.h }
		);

		function finalize(resetCell) {
			if (settled) { return; }
			settled = true;
			window.removeEventListener('message', listener);
			if (timeoutHandle) { clearTimeout(timeoutHandle); timeoutHandle = null; }
			if (resetCell && $row && $row.length) {
				updateRowStatus($row, viewport, null);
			}
			$container.empty();
		}

		var listener = function (e) {
			if (!e.data || e.data.type !== 'be-critical-result') { return; }
			finalize(false);
			beLogsRecord('info', 'critical_extract_message_received',
				'Collector returned a result for ' + template + '/' + viewport,
				{
					template: template,
					variant: viewport,
					css_length: (e.data.css || '').length,
					used_length: (e.data.used || '').length,
					partial: !!e.data.partial,
					had_error: !!e.data.error
				}
			);
			if (e.data.error) {
				console.warn('[Cache] Critical extraction error:', e.data.error);
				beLogsRecord('error', 'critical_extract_error',
					'Collector reported an error for ' + template + '/' + viewport + ': ' + e.data.error,
					{ template: template, variant: viewport, error: String(e.data.error) }
				);
			}
			$.post(cfg.ajaxUrl, {
				action: 'ap_cache_critical_save',
				nonce: cfg.nonce,
				template: template,
				variant: viewport,
				css: e.data.css || ''
			}, function (resp) {
				// Three outcomes the cell needs to render distinctly:
				//   - success       → size + freshness label ("just now")
				//   - server reject → red label + tooltip explaining the kind
				//                     (too_large, write_failed, empty)
				//   - client only knows ajax went through → leave info=null
				//                     so the cell falls back to "Not generated"
				if ($row && $row.length) {
					var info = null;
					if (resp && resp.success && resp.data) {
						info = resp.data;
					} else if (resp && !resp.success && resp.data && resp.data.error_kind) {
						info = { error_kind: resp.data.error_kind, bytes: resp.data.bytes || 0 };
					}
					updateRowStatus($row, viewport, info);
				}
				processUrl(template, url, viewports, $row, done);
			}, 'json').fail(function (xhr, textStatus) {
				beLogsRecord('error', 'critical_save_ajax_failed',
					'AJAX critical_save raised an HTTP error before reaching PHP',
					{
						template: template,
						variant: viewport,
						http_status: xhr && xhr.status,
						text_status: String(textStatus || '')
					}
				);
				if ($row && $row.length) {
					updateRowStatus($row, viewport, { error_kind: 'network', bytes: 0 });
				}
				processUrl(template, url, viewports, $row, done);
			});
		};
		window.addEventListener('message', listener);

		// Inject the collector as soon as the iframe DOM is ready (readyState
		// 'interactive' or 'complete'). Waiting for the full 'load' event would
		// block on every image, font and child iframe in the target page — but
		// extracting Critical CSS only needs document.styleSheets, which is
		// populated at DOMContentLoaded. Empirically saves ~8 s per template
		// on production sites with heavy page-builders behind a CDN.
		function injectCollector(doc) {
			try {
				var script = doc.createElement('script');
				script.src = cfg.collectorUrl;
				beLogsRecord('debug', 'critical_collector_injecting',
					'Injecting collector script into iframe for ' + template + '/' + viewport,
					{
						template: template,
						variant: viewport,
						collector_url: cfg.collectorUrl,
						elapsed_ms: Math.round(((typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now()) - t0),
						stylesheets_count: (function () { try { return doc.styleSheets ? doc.styleSheets.length : -1; } catch (e) { return -2; } })()
					}
				);
				script.onload = function () {
					beLogsRecord('debug', 'critical_collector_loaded',
						'Collector script loaded, sending extract command',
						{ template: template, variant: viewport, elapsed_ms: Math.round(((typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now()) - t0) }
					);
					iframe.contentWindow.postMessage({ command: 'extract', viewport: viewport }, '*');
				};
				script.onerror = function () {
					console.warn('[Cache] Could not load critical-collector.js into iframe.');
					beLogsRecord('error', 'critical_collector_load_failed',
						'critical-collector.js failed to load inside the iframe (script.onerror fired)',
						{ template: template, variant: viewport, collector_url: cfg.collectorUrl }
					);
					finalize(true);
					processUrl(template, url, viewports, $row, done);
				};
				doc.head.appendChild(script);
			} catch (err) {
				console.warn('[Cache] Cannot inject collector (CORS?). Skipping.', err);
				beLogsRecord('error', 'critical_collector_inject_failed',
					'Could not append collector script to iframe head — likely CORS or DOM access blocked',
					{ template: template, variant: viewport, error: String((err && err.message) || err) }
				);
				finalize(true);
				processUrl(template, url, viewports, $row, done);
			}
		}

		var injected = false;
		var readyPoll = setInterval(function () {
			if (injected || settled) {
				clearInterval(readyPoll);
				return;
			}
			var doc;
			try {
				doc = iframe.contentDocument || iframe.contentWindow.document;
			} catch (err) {
				clearInterval(readyPoll);
				console.warn('[Cache] Cannot read iframe document (CORS?). Skipping.', err);
				beLogsRecord('error', 'critical_iframe_doc_unreadable',
					'iframe.contentDocument threw — page in iframe is cross-origin or blocked',
					{ template: template, variant: viewport, url: url, error: String((err && err.message) || err) }
				);
				finalize(true);
				processUrl(template, url, viewports, $row, done);
				return;
			}
			if (!doc || (doc.readyState !== 'interactive' && doc.readyState !== 'complete')) {
				return;
			}
			// Ignore the initial about:blank that browsers expose before the
			// real navigation kicks in — its readyState is already 'complete'
			// and we'd otherwise inject the collector into a document that's
			// about to be replaced, losing the script before extraction.
			var docUrl = '';
			try { docUrl = (doc.URL || iframe.contentWindow.location.href || ''); } catch (e) { docUrl = ''; }
			if (docUrl === '' || docUrl === 'about:blank' || docUrl === 'about:srcdoc') {
				return;
			}
			injected = true;
			clearInterval(readyPoll);
			beLogsRecord('debug', 'critical_iframe_ready',
				'Iframe document reached readyState=' + doc.readyState,
				{
					template: template,
					variant: viewport,
					ready_state: doc.readyState,
					iframe_location: docUrl,
					elapsed_ms: Math.round(((typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now()) - t0)
				}
			);
			// Short pause so JS-injected stylesheets (Elementor, etc.) finish
			// landing in document.styleSheets before we walk them.
			setTimeout(function () {
				if (settled) { return; }
				injectCollector(doc);
			}, 200);
		}, 50);

		// Hard timeout per step. The cell stays disabled-looking until then,
		// then we mark it "Not generated" so the user knows the run gave up
		// instead of hanging in "Generating…" forever. 60 s is generous on
		// purpose: heavy page-builders (Avada, Divi) routinely ship 80+
		// stylesheets, and the collector walks every rule synchronously.
		timeoutHandle = setTimeout(function () {
			if (settled) { return; }
			console.warn('[Cache] Extraction timed out for', template, viewport);
			beLogsRecord('error', 'critical_extract_timeout',
				'Hit the 60s hard timeout without receiving any postMessage from the collector',
				{
					template: template,
					variant: viewport,
					url: url,
					injected: injected,
					iframe_location: (function () { try { return iframe.contentWindow.location.href; } catch (e) { return 'unreadable'; } })()
				}
			);
			finalize(true);
			processUrl(template, url, viewports, $row, done);
		}, 60000);
	}

	/* -------------------------------------------------------------------- */
	/*  Audit (PSI + internal)                                               */
	/* -------------------------------------------------------------------- */

	function bindPsiKeyButtons() {
		$(document).on('click', '#be-psi-save', function (e) {
			e.preventDefault();
			var key = $('#be-psi-key').val();
			$.post(cfg.ajaxUrl, {
				action: 'ap_cache_audit_save_psi_key',
				nonce: cfg.nonce,
				key: key
			}, function (response) {
				if (response && response.success) {
					announce((response.data && response.data.message) || 'Saved');
				} else {
					beCacheNotify((response && response.data && response.data.message) || cfg.strings.genericError, "error");
				}
			}, 'json');
		});

		$(document).on('click', '#be-psi-test', function (e) {
			e.preventDefault();
			var $btn       = $(this);
			var $statusBox = $('#be-psi-test-status');
			if ($statusBox.length === 0) {
				$statusBox = $('<div id="be-psi-test-status" style="margin-top:10px;font-size:13px;line-height:1.5;"></div>');
				$btn.after($statusBox);
			}
			$statusBox.html('<em style="color:#646970;">'
				+ '<span class="dashicons dashicons-update spin" style="vertical-align:text-bottom;margin-right:4px;font-size:16px;width:16px;height:16px;line-height:1;"></span>'
				+ escapeHtml(cfg.strings.healthRunning || 'Running…') + '</em>');
			$btn.prop('disabled', true);

			var key = $('#be-psi-key').val();
			$.post(cfg.ajaxUrl, {
				action: 'ap_cache_audit_test_psi_key',
				nonce: cfg.nonce,
				key: key
			}, function (response) {
				if (response && response.success) {
					$statusBox.html(
						'<span style="color:#3a8a4a;font-weight:600;">'
						+ '<span class="dashicons dashicons-yes-alt" style="vertical-align:text-bottom;margin-right:4px;font-size:18px;width:18px;height:18px;line-height:1;color:#3a8a4a;"></span>'
						+ escapeHtml((response.data && response.data.message) || 'OK')
						+ '</span>'
					);
				} else {
					var msg = (response && response.data && response.data.message) || cfg.strings.genericError;
					$statusBox.html(
						'<div class="notice notice-error inline" style="margin:0;padding:8px 12px;">'
						+ '<p style="margin:0;">' + escapeHtml(msg) + '</p></div>'
					);
				}
			}, 'json').fail(function (xhr) {
				$statusBox.html(
					'<div class="notice notice-error inline" style="margin:0;padding:8px 12px;">'
					+ '<p style="margin:0;">HTTP ' + (xhr && xhr.status) + ' — ' + escapeHtml(cfg.strings.genericError || 'Error') + '</p></div>'
				);
			}).always(function () {
				$btn.prop('disabled', false);
			});
		});
	}

	/**
	 * Render the most recent persisted audit (PSI takes precedence over
	 * Internal) and update the run buttons to "Regenerate" so the user can
	 * tell at a glance there's already a result on display.
	 */
	function restoreLastAudit() {
		if (!cfg.lastAudit) { return; }
		var engineToShow = cfg.lastAudit.psi ? 'psi' : (cfg.lastAudit.internal ? 'internal' : null);
		if (!engineToShow) { return; }
		var stored = cfg.lastAudit[engineToShow];
		if (!stored || !stored.result) { return; }
		var $results = $('#be-audit-results');
		if ($results.length) {
			$results.html(renderAudit(stored.result, engineToShow, stored.timestamp || 0));
		}
		// Relabel both run buttons: the one matching the displayed engine
		// becomes "Regenerate" (since results are already shown), the other
		// keeps its "Run …" label until it's actually run.
		$('.be-audit-run').each(function () {
			var $btn = $(this);
			var btnEngine = $btn.data('engine');
			if (cfg.lastAudit[btnEngine]) {
				var label = btnEngine === 'psi'
					? (cfg.strings.regeneratePsi || 'Regenerate the PSI audit')
					: (cfg.strings.regenerateInternal || 'Regenerate the internal audit');
				$btn.text(label);
			}
		});
	}

	function bindWatchdogRun() {
		$(document).on('click', '#be-watchdog-run', function (e) {
			e.preventDefault();
			var $btn = $(this);
			var $status = $('#be-watchdog-status');
			var originalHtml = $btn.html();
			$btn.prop('disabled', true).text(cfg.strings.watchdogRunning || 'Running render check…');
			$status.text('').css('color', '#646970');

			$.post(cfg.ajaxUrl, {
				action: 'ap_cache_watchdog_run',
				nonce: cfg.nonce
			}, function (response) {
				if (response && response.success) {
					var data = response.data || {};
					var ok = data.ok;
					var flags = (data.flags || []).join(', ');
					var label;
					if (ok) {
						label = cfg.strings.watchdogHealthy || 'Healthy — page renders correctly to anonymous visitors.';
						$status.css('color', '#0a6b1d');
					} else if (data.rollback && data.rollback.disabled && data.rollback.disabled.length) {
						label = (cfg.strings.watchdogRecovered || 'Issues detected (%1$s) — auto-disabled %2$s to recover.')
							.replace('%1$s', flags)
							.replace('%2$s', data.rollback.disabled.join(', '));
						$status.css('color', '#dba617');
					} else {
						label = (cfg.strings.watchdogBroken || 'Issues detected: %s').replace('%s', flags);
						$status.css('color', '#c33');
					}
					$status.text(label);
				} else {
					$status.text((response && response.data && response.data.message) || cfg.strings.genericError).css('color', '#c33');
				}
			}, 'json').fail(function () {
				$status.text(cfg.strings.genericError).css('color', '#c33');
			}).always(function () {
				$btn.prop('disabled', false).html(originalHtml);
			});
		});
	}

	function bindAuditRun() {
		$(document).on('click', '.be-audit-run', function (e) {
			e.preventDefault();
			var $btn = $(this);
			var engine = $btn.data('engine');
			var loadingMsg = engine === 'psi'
				? (cfg.strings.runningPsi || 'Running PageSpeed Insights audit…')
				: (cfg.strings.runningInternal || 'Running internal audit…');
			var $results = $('#be-audit-results').html('<p>' + loadingMsg + '</p>');
			// Show a spinner + label on the button so the user gets feedback
			// while the audit runs (PSI or internal can take 5-30 s). Restore
			// the original HTML in the .always() branch.
			var originalHtml = $btn.html();
			$btn.prop('disabled', true)
				.html('<span class="dashicons dashicons-update spin" style="vertical-align:text-bottom;margin-right:4px;font-size:16px;width:16px;height:16px;line-height:1;"></span>' + loadingMsg);

			$.post(cfg.ajaxUrl, {
				action: 'ap_cache_audit_run',
				nonce: cfg.nonce,
				engine: engine
			}, function (response) {
				if (response && response.success) {
					// Audit just finished — timestamp = now. The page reload
					// path uses cfg.lastAudit[engine].timestamp instead, so
					// both code paths stay in sync.
					$results.html(renderAudit(response.data, engine, Math.floor(Date.now() / 1000)));
				} else {
					$results.html('<div class="notice notice-error inline"><p>' + ((response && response.data && response.data.message) || cfg.strings.genericError) + '</p></div>');
				}
			}, 'json').fail(function () {
				$results.html('<div class="notice notice-error inline"><p>' + cfg.strings.genericError + '</p></div>');
			}).always(function () {
				$btn.prop('disabled', false).html(originalHtml);
				// After a successful run, swap the button label to "Regenerate"
				// so it stays consistent with what restoreLastAudit() does on
				// page reload.
				var label = engine === 'psi'
					? (cfg.strings.regeneratePsi || 'Regenerate the PSI audit')
					: (cfg.strings.regenerateInternal || 'Regenerate the internal audit');
				$btn.text(label);
			});
		});
	}

	// Compact "X min ago / h ago / d ago" label used next to the audit score.
	// Returns '' if no timestamp — caller can drop the span entirely.
	function humanTimeAgo(tsSeconds) {
		if (!tsSeconds) { return ''; }
		var diff = Math.max(0, Math.floor(Date.now() / 1000) - Math.floor(tsSeconds));
		var s = (cfg && cfg.strings) || {};
		if (diff < 30)    { return s.auditTimeJustNow || 'just now'; }
		if (diff < 3600)  { return (s.auditTimeMinutesAgo || '%d min ago').replace('%d', Math.floor(diff / 60)); }
		if (diff < 86400) { return (s.auditTimeHoursAgo   || '%d h ago').replace('%d', Math.floor(diff / 3600)); }
		return (s.auditTimeDaysAgo || '%d d ago').replace('%d', Math.floor(diff / 86400));
	}

	function renderAudit(data, engine, ts) {
		var html = '';
		var score = data.score || 0;
		var scoreClass = score >= 90 ? 'good' : (score >= 50 ? 'warn' : 'bad');
		var engineLabel = engine === 'psi'
			? 'Google PageSpeed Insights'
			: (cfg.strings.auditInternalLabel || 'Built-in audit');

		var rel = humanTimeAgo(ts);
		// Absolute date in user locale — shown as a tooltip on hover, so the
		// relative label stays scannable while the precise timestamp is one
		// hover away.
		var abs = ts ? new Date(Math.floor(ts) * 1000).toLocaleString() : '';

		html += '<div class="be-audit-summary be-audit-summary--' + scoreClass + '">';
		html += '<div class="be-audit-score">' + score + '</div>';
		html += '<div class="be-audit-engine"><strong>' + escapeHtml(engineLabel) + '</strong>';
		if (rel) {
			html += '<span class="be-audit-relative-time"'
				+ ' title="' + escapeHtml(abs) + '"'
				+ ' data-ts="' + escapeHtml(String(Math.floor(ts))) + '"'
				+ ' style="margin-left:8px;color:#646970;font-weight:normal;font-size:13px;">'
				+ escapeHtml(rel)
				+ '</span>';
		}
		if (data.metrics) {
			html += '<div class="be-audit-metrics">';
			Object.keys(data.metrics).forEach(function (k) {
				html += '<span>' + escapeHtml(k) + ': <code>' + Math.round(data.metrics[k]) + '</code></span>';
			});
			html += '</div>';
		}
		html += '</div></div>';

		var recs = data.recommendations || [];
		if (recs.length === 0) {
			html += '<div class="notice notice-success inline"><p>'
				+ (cfg.strings.noRecommendations || 'No actionable recommendations — looking good.')
				+ '</p></div>';
			return html;
		}

		var groups = { plugin: [], mixed: [], theme: [], hosting: [] };
		recs.forEach(function (r) { (groups[r.category] || groups.theme).push(r); });

		var labels = {
			plugin:  cfg.strings.recoGroupPlugin  || 'Fixable by this plugin',
			mixed:   cfg.strings.recoGroupMixed   || 'Plugin can help, root cause elsewhere',
			theme:   cfg.strings.recoGroupTheme   || 'Theme / content concern',
			hosting: cfg.strings.recoGroupHosting || 'Hosting concern'
		};

		Object.keys(groups).forEach(function (cat) {
			if (groups[cat].length === 0) { return; }
			html += '<h4 class="be-audit-group be-audit-group--' + cat + '">'
				+ '<span class="be-audit-group-dot" aria-hidden="true"></span>'
				+ escapeHtml(labels[cat]) + ' <span class="be-audit-group-count">(' + groups[cat].length + ')</span>'
				+ '</h4>';
			groups[cat].forEach(function (r) {
				var resolvedClass = r.is_resolved ? ' be-audit-card--resolved' : '';
				html += '<div class="be-audit-card' + resolvedClass + '">';
				if (r.is_resolved) {
					html += '<span class="be-audit-card-resolved-badge" title="'
						+ escapeHtml(cfg.strings.alreadyDone || 'Already addressed')
						+ '">'
						+ '<span class="dashicons dashicons-yes-alt"></span> '
						+ escapeHtml(cfg.strings.alreadyDone || 'Already addressed')
						+ '</span>';
				}
				html += '<strong class="be-audit-card-title">' + escapeHtml(r.title) + '</strong>';
				if (r.displayValue) {
					html += ' <span class="be-audit-card-value">(' + escapeHtml(r.displayValue) + ')</span>';
				}
				if (r.description) {
					html += '<p class="be-audit-card-desc">' + mdLinksToHtml(r.description) + '</p>';
				}
				if (r.guidance) {
					html += '<p class="be-audit-card-guidance description"><em>' + escapeHtml(r.guidance) + '</em></p>';
				}
				if (r.action && r.action.url && !r.is_resolved) {
					html += '<p><a href="' + r.action.url + '" class="button button-primary">' + escapeHtml(r.action.human || r.action.label) + '</a></p>';
				}
				html += '</div>';
			});
		});
		return html;
	}

	function escapeHtml(s) {
		return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
			return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c];
		});
	}

	// PSI descriptions ship Markdown-style links: "[Learn how to X](https://…)".
	// Escape first (so anything inside the link text stays inert), then convert
	// the [text](url) pattern to a real anchor. Only http/https URLs are allowed.
	function mdLinksToHtml(s) {
		var escaped = escapeHtml(s);
		return escaped.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, function (_, text, url) {
			return '<a href="' + url + '" target="_blank" rel="noopener noreferrer">' + text + '</a>';
		});
	}

	/**
	 * Surface a success message. Delegates to beCacheNotify('success') so the
	 * acknowledgement of cache purge / save / generation lands as a full-width
	 * WP notice at the top of the screen (auto-scrolled to + auto-dismissed
	 * after 8s) instead of a discreet top-right toast that disappeared in 2.5s
	 * before the user noticed it. Same visual weight as our error notices —
	 * one notification system across the module, success and error symmetric.
	 *
	 * @param {string} msg
	 */
	function announce(msg) {
		beCacheNotify(msg, 'success');
	}

	function bindHealthCheck() {
		$(document).on('click', '.be-cache-health-run', function (e) {
			e.preventDefault();
			var $btn = $(this);
			var $copy = $('.be-cache-health-copy');
			var $status = $('.be-cache-health-status');
			var $summary = $('.be-cache-health-summary');
			var $results = $('.be-cache-health-results');
			var $jsonWrap = $('.be-cache-health-json');
			var $jsonField = $('.be-cache-health-json-payload');

			$btn.prop('disabled', true).text(cfg.strings.healthRunning || 'Running…');
			$status.text('');
			$summary.hide().empty();
			$results.hide().empty();
			$jsonWrap.hide();
			$jsonField.val('');
			$copy.hide();

			$.ajax({
				url: cfg.ajaxUrl,
				type: 'POST',
				dataType: 'json',
				timeout: 60000,
				data: {
					action: 'ap_cache_health_check',
					nonce: cfg.nonce
				}
			}).done(function (response) {
				if (!response || !response.success || !response.data) {
					$status.text(cfg.strings.genericError);
					return;
				}
				renderHealthReport(response.data, $summary, $results, $jsonWrap, $jsonField);
				$copy.show();
			}).fail(function () {
				$status.text(cfg.strings.genericError);
			}).always(function () {
				$btn.prop('disabled', false).text(cfg.strings.healthRun || 'Run health check');
			});
		});

		$(document).on('click', '.be-cache-health-copy', function (e) {
			e.preventDefault();
			var $btn = $(this);
			var $field = $('.be-cache-health-json-payload');
			var text = $field.val();
			if (!text) { return; }
			var done = function () {
				var orig = $btn.text();
				$btn.text(cfg.strings.healthCopied || 'Copied!');
				setTimeout(function () { $btn.text(orig); }, 1500);
			};
			if (navigator.clipboard && navigator.clipboard.writeText) {
				navigator.clipboard.writeText(text).then(done, function () {
					$field.show().focus().select();
					document.execCommand('copy');
					done();
				});
			} else {
				$field.show().focus().select();
				document.execCommand('copy');
				done();
			}
		});
	}

	function renderHealthReport(data, $summary, $results, $jsonWrap, $jsonField) {
		var summary = data.summary || {};
		var checks = data.checks || [];
		var env = data.env || {};

		var summaryHtml = '<span style="color:#3a8a4a;">' + (summary.pass || 0) + ' pass</span>'
			+ ' · <span style="color:#c33;">' + (summary.fail || 0) + ' fail</span>'
			+ ' · <span style="color:#b48000;">' + (summary.warn || 0) + ' warn</span>'
			+ ' · <span style="color:#646970;">' + (summary.info || 0) + ' info</span>'
			+ ' · ' + (summary.total || 0) + ' total';
		$summary.html(summaryHtml).show();

		var rows = '';
		var groupOrder = [];
		var byGroup = {};
		checks.forEach(function (c) {
			var g = c.group || 'other';
			if (!byGroup[g]) { byGroup[g] = []; groupOrder.push(g); }
			byGroup[g].push(c);
		});

		groupOrder.forEach(function (g) {
			rows += '<h3 style="margin:18px 0 6px;text-transform:capitalize;">' + escapeHtml(g) + '</h3>';
			rows += '<table class="widefat striped" style="margin-bottom:6px;"><tbody>';
			byGroup[g].forEach(function (c) {
				rows += '<tr>'
					+ '<td style="width:80px;">' + statusBadge(c.status) + '</td>'
					+ '<td style="width:32%;"><strong>' + escapeHtml(c.label) + '</strong>'
					+ '<br><code style="font-size:11px;color:#646970;">' + escapeHtml(c.id) + '</code></td>'
					+ '<td>' + escapeHtml(c.message);
				if (c.details) {
					rows += '<details style="margin-top:6px;"><summary style="cursor:pointer;color:#646970;font-size:11px;">details</summary>'
						+ '<pre style="white-space:pre-wrap;font-size:11px;margin:4px 0 0;">' + escapeHtml(c.details) + '</pre></details>';
				}
				rows += '</td></tr>';
			});
			rows += '</tbody></table>';
		});

		var envHtml = '<p class="description" style="margin-top:18px;">'
			+ 'WP ' + escapeHtml(env.wp || '?') + ' · PHP ' + escapeHtml(env.php || '?')
			+ ' · plugin ' + escapeHtml(env.plugin || '?') + ' · server ' + escapeHtml(env.server || '?')
			+ ' · ' + escapeHtml(env.generated_at || '') + '</p>';

		$results.html(rows + envHtml).show();
		$jsonWrap.show();
		$jsonField.val(JSON.stringify(data, null, 2));
	}

	function statusBadge(status) {
		var color = '#646970';
		if (status === 'pass') { color = '#3a8a4a'; }
		else if (status === 'fail') { color = '#c33'; }
		else if (status === 'warn') { color = '#b48000'; }
		return '<span style="display:inline-block;padding:2px 8px;border-radius:3px;color:#fff;font-size:11px;font-weight:600;text-transform:uppercase;background:' + color + ';">' + escapeHtml(status) + '</span>';
	}

	function escapeHtml(s) {
		return String(s == null ? '' : s).replace(/[&<>"']/g, function (m) {
			return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[m];
		});
	}

	/* -------------------------------------------------------------------- */
	/*  Preload status banner                                                */
	/* -------------------------------------------------------------------- */

	/**
	 * Wire the collapsible details toggle and a periodic AJAX refresh for the
	 * banner sitting at the top of the cache module page. The PHP side renders
	 * a server-side snapshot at page load; once a minute we fetch a fresh one
	 * so the queue counter ticks down without forcing the user to reload.
	 */
	function bindPreloadStatusBanner() {
		var $banner = $('.be-preload-banner');
		if (!$banner.length) { return; }

		$banner.on('click', '.be-preload-banner-toggle', function () {
			var collapsed = $banner.hasClass('be-preload-banner-collapsed');
			$banner.toggleClass('be-preload-banner-collapsed', !collapsed);
			$(this).attr('aria-expanded', collapsed ? 'true' : 'false');
		});

		// Poll every 60 s. Cheap call; the option-backed status is already
		// computed in O(1) and requires no DB scan.
		setInterval(refreshPreloadBanner, 60000);
	}

	function refreshPreloadBanner() {
		var $banner = $('.be-preload-banner');
		if (!$banner.length) { return; }

		$.post(cfg.ajaxUrl, {
			action: 'ap_cache_preload_status',
			nonce: cfg.nonce
		}, function (resp) {
			if (!resp || !resp.success || !resp.data) { return; }
			var s = resp.data;
			var queueTotal = Math.max(s.queue_total || 0, s.queue_size || 0);
			var warmed = Math.max(0, queueTotal - (s.queue_size || 0));
			var progress = queueTotal > 0 ? Math.min(100, Math.round((warmed / queueTotal) * 100)) : 0;
			$banner.find('.be-preload-banner-progress-bar').css('width', progress + '%');
			$banner.attr('data-progress', progress);

			// Re-render the title — exact wording is owned by the server (i18n)
			// so we surface the meta line and rely on the next full reload to
			// resync the title when state changes (off→on, etc.).
			var $meta = $banner.find('.be-preload-banner-meta');
			if ($meta.length && s.last_run_ts > 0) {
				// Best-effort relative time. Falls back to the original PHP
				// rendering if Intl.RelativeTimeFormat is missing.
				try {
					var ago = relTimeFromTs(s.last_run_ts);
					var next = (s.enabled && s.next_run_ts > Date.now() / 1000 && !s.cron_late)
						? ' · ' + relTimeUntilTs(s.next_run_ts)
						: '';
					$meta.text(ago + ' · ' + (s.last_batch_count || 0) + next);
				} catch (e) { /* leave server-side text in place */ }
			}
		}, 'json');
	}

	function relTimeFromTs(ts) {
		var diff = Math.max(0, Math.floor(Date.now() / 1000 - ts));
		if (diff < 60) { return diff + 's ago'; }
		if (diff < 3600) { return Math.floor(diff / 60) + 'min ago'; }
		if (diff < 86400) { return Math.floor(diff / 3600) + 'h ago'; }
		return Math.floor(diff / 86400) + 'd ago';
	}

	function relTimeUntilTs(ts) {
		var diff = Math.max(0, Math.floor(ts - Date.now() / 1000));
		if (diff < 60) { return 'next < 1min'; }
		if (diff < 3600) { return 'next ' + Math.floor(diff / 60) + 'min'; }
		return 'next ' + Math.floor(diff / 3600) + 'h';
	}

	/* -------------------------------------------------------------------- */
	/*  Cached pages table (Preload tab)                                     */
	/* -------------------------------------------------------------------- */

	/**
	 * Drive the "Cached pages" table on the Preload tab. The PHP side ships
	 * an empty shell; this binder paginates and searches against the server
	 * index and renders rows on demand. Keeps the initial pageload light
	 * even when the index has 1000 entries.
	 */
	function bindCachedPagesTable() {
		var $wrap = $('#be-cached-pages');
		if (!$wrap.length) { return; }

		var state = { page: 1, search: '', loading: false };
		var $tbody = $wrap.find('tbody');
		var $count = $wrap.find('.be-cached-pages-count');
		var $pager = $wrap.find('.be-cached-pages-pager');
		var $search = $wrap.find('.be-cached-pages-search');

		var searchTimer = null;
		$search.on('input', function () {
			window.clearTimeout(searchTimer);
			searchTimer = setTimeout(function () {
				state.search = $search.val() || '';
				state.page = 1;
				load();
			}, 250);
		});

		$pager.on('click', '.be-cached-pages-prev', function (e) {
			e.preventDefault();
			if (state.page > 1) { state.page--; load(); }
		});
		$pager.on('click', '.be-cached-pages-next', function (e) {
			e.preventDefault();
			state.page++; load();
		});

		$tbody.on('click', '.be-cached-pages-purge', function (e) {
			e.preventDefault();
			var $btn = $(this);
			var url = $btn.data('url');
			if (!url) { return; }
			$btn.prop('disabled', true).text(cfg.strings.purging || 'Purging…');
			$.post(cfg.ajaxUrl, {
				action: 'ap_cache_purge_url',
				nonce: cfg.nonce,
				url: url
			}, function () {
				load();
			}, 'json').fail(function () {
				$btn.prop('disabled', false).text(cfg.strings.purge || 'Purge');
			});
		});

		// Refresh after a manual preload run completes, so newly cached pages
		// show up without the user reloading the tab.
		$(document).on('be:preload-finished', function () { load(); });

		load();

		function load() {
			if (state.loading) { return; }
			state.loading = true;
			$tbody.html('<tr><td colspan="5"><em>' + escapeHtml(cfg.strings.loading || 'Loading…') + '</em></td></tr>');
			$.post(cfg.ajaxUrl, {
				action: 'ap_cache_pages_index_list',
				nonce: cfg.nonce,
				page: state.page,
				search: state.search
			}, function (resp) {
				state.loading = false;
				if (!resp || !resp.success || !resp.data) {
					$tbody.html('<tr><td colspan="5" class="ap-status-error">'
						+ escapeHtml((resp && resp.data && resp.data.message) || cfg.strings.genericError) + '</td></tr>');
					return;
				}
				render(resp.data);
			}, 'json').fail(function () {
				state.loading = false;
				$tbody.html('<tr><td colspan="5" class="ap-status-error">'
					+ escapeHtml(cfg.strings.genericError || 'Error') + '</td></tr>');
			});
		}

		function render(data) {
			var rows = data.rows || [];
			if (!rows.length) {
				$tbody.html('<tr><td colspan="5" class="be-cached-pages-empty"><em>'
					+ escapeHtml(cfg.strings.cachedPagesEmpty || 'No pages cached yet. They will appear here as visitors hit them or as the preloader warms them up.') + '</em></td></tr>');
			} else {
				var html = '';
				for (var i = 0; i < rows.length; i++) {
					var r = rows[i];
					var kindLabel = r.kind === 'redirect'
						? (cfg.strings.kindRedirect || 'redirect')
						: (cfg.strings.kindHtml || 'page');
					html += '<tr>'
						+ '<td><code class="ap-url-code">' + escapeHtml(r.url) + '</code> '
						+ '<span class="be-cached-pages-kind">' + escapeHtml(kindLabel) + '</span></td>'
						+ '<td>' + escapeHtml(r.variant) + '</td>'
						+ '<td>' + escapeHtml(r.size_human) + '</td>'
						+ '<td title="' + escapeHtml(new Date((r.mtime || 0) * 1000).toLocaleString()) + '">'
						+ escapeHtml(r.mtime_human) + ' '
						+ escapeHtml(cfg.strings.ago || 'ago') + '</td>'
						+ '<td><button type="button" class="button button-small be-cached-pages-purge" data-url="'
						+ escapeHtml(r.url) + '">' + escapeHtml(cfg.strings.purge || 'Purge') + '</button></td>'
						+ '</tr>';
				}
				$tbody.html(html);
			}

			// Counter + pager.
			var summary = (cfg.strings.cachedPagesCount || '%count% pages cached')
				.replace('%count%', String(data.index_count || 0));
			if (data.index_capped) {
				summary += ' · ' + (cfg.strings.cachedPagesCapped || 'showing the 1000 most recent (the disk holds more, but the index keeps the option size in check)');
			}
			summary += ' · ' + escapeHtml(data.index_size || '0 B');
			$count.text(summary);

			var totalPages = data.total_pages || 1;
			$pager.find('.be-cached-pages-position').text((data.page || 1) + ' / ' + totalPages);
			$pager.find('.be-cached-pages-prev').prop('disabled', (data.page || 1) <= 1);
			$pager.find('.be-cached-pages-next').prop('disabled', (data.page || 1) >= totalPages);
		}
	}

	/**
	 * Settings export / import (Advanced tab → "Settings backup").
	 *
	 * Export: AJAX returns the JSON envelope, the browser turns it into a
	 * Blob and triggers a download (uniform AJAX contract, no special
	 * Content-Disposition handling on the server side).
	 *
	 * Import: a real <input type="file"> reads the JSON locally, posts the
	 * raw payload to the server, which validates and overwrites the
	 * settings. Page reloads on success so the new state is reflected
	 * everywhere (toggles, locked async checkbox, etc.).
	 */
	function bindSettingsExportImport() {
		var $exportBtn   = $('.be-cache-export-settings');
		var $exportStat  = $('.be-cache-export-status');
		var $importFile  = $('.be-cache-import-file');
		var $importBtn   = $('.be-cache-import-settings');
		var $importStat  = $('.be-cache-import-status');

		$exportBtn.on('click', function (e) {
			e.preventDefault();
			$exportBtn.prop('disabled', true);
			$exportStat.text(cfg.strings.settingsExporting || cfg.strings.loading || '');

			$.ajax({
				url: cfg.ajaxUrl,
				type: 'POST',
				dataType: 'json',
				data: {
					action: 'ap_cache_export_settings',
					nonce: cfg.nonce
				}
			}).done(function (response) {
				if (response && response.success && response.data && response.data.payload) {
					try {
						var json     = JSON.stringify(response.data.payload, null, 2);
						var blob     = new Blob([json], { type: 'application/json' });
						var url      = URL.createObjectURL(blob);
						var filename = response.data.filename || 'be-cache-settings.json';
						var $a       = $('<a/>', { href: url, download: filename })
							.css({ display: 'none' })
							.appendTo('body');
						$a[0].click();
						$a.remove();
						setTimeout(function () { URL.revokeObjectURL(url); }, 1500);
						$exportStat.text('');
					} catch (err) {
						beCacheNotify(cfg.strings.settingsExportFailed || cfg.strings.genericError, 'error');
						$exportStat.text('');
					}
				} else {
					beCacheNotify((response && response.data && response.data.message) || cfg.strings.settingsExportFailed || cfg.strings.genericError, 'error');
					$exportStat.text('');
				}
			}).fail(function () {
				beCacheNotify(cfg.strings.settingsExportFailed || cfg.strings.genericError, 'error');
				$exportStat.text('');
			}).always(function () {
				$exportBtn.prop('disabled', false);
			});
		});

		$importFile.on('change', function () {
			$importBtn.prop('disabled', !this.files || !this.files.length);
			$importStat.empty();
		});

		$importBtn.on('click', function (e) {
			e.preventDefault();
			var fileInput = $importFile[0];
			if (!fileInput || !fileInput.files || !fileInput.files.length) {
				$importStat.html('<span style="color:#c33;">' + escapeHtml(cfg.strings.settingsImportEmpty || 'Choose a file first.') + '</span>');
				return;
			}
			var file = fileInput.files[0];
			// 1 MB cap: settings JSON is small (~10 KB typical), an order of
			// magnitude beyond that almost certainly means the wrong file.
			if (file.size > 1024 * 1024) {
				$importStat.html('<span style="color:#c33;">' + escapeHtml(cfg.strings.settingsImportTooLarge || 'File is too large.') + '</span>');
				return;
			}
			if (!window.confirm(cfg.strings.settingsImportConfirm || 'Replace current settings?')) {
				return;
			}

			var reader = new FileReader();
			reader.onload = function (ev) {
				var raw = String(ev.target.result || '');
				// Sanity check: must be parseable JSON before we even POST.
				try { JSON.parse(raw); } catch (parseErr) {
					$importStat.html('<span style="color:#c33;">' + escapeHtml(cfg.strings.settingsImportInvalidJson || 'Invalid JSON.') + '</span>');
					return;
				}

				$importBtn.prop('disabled', true);
				$importStat.text(cfg.strings.loading || '');

				$.ajax({
					url: cfg.ajaxUrl,
					type: 'POST',
					dataType: 'json',
					data: {
						action: 'ap_cache_import_settings',
						nonce: cfg.nonce,
						payload: raw
					}
				}).done(function (response) {
					if (response && response.success) {
						var imported = (response.data && response.data.imported_count) || 0;
						var skipped  = (response.data && response.data.skipped_count) || 0;
						var tpl      = cfg.strings.settingsImportDone || 'Imported %1$d settings, skipped %2$d unknown entries. Reloading…';
						var msg      = tpl.replace('%1$d', imported).replace('%2$d', skipped);
						$importStat.html('<span style="color:#0a6b1d;">' + escapeHtml(msg) + '</span>');
						setTimeout(function () { window.location.reload(); }, 800);
					} else {
						var errMsg = (response && response.data && response.data.message) || cfg.strings.genericError;
						$importStat.html('<span style="color:#c33;">' + escapeHtml(errMsg) + '</span>');
						$importBtn.prop('disabled', false);
					}
				}).fail(function () {
					$importStat.html('<span style="color:#c33;">' + escapeHtml(cfg.strings.genericError) + '</span>');
					$importBtn.prop('disabled', false);
				});
			};
			reader.onerror = function () {
				$importStat.html('<span style="color:#c33;">' + escapeHtml(cfg.strings.genericError) + '</span>');
			};
			reader.readAsText(file);
		});
	}

})(jQuery);
