PluginProbe
404 Solution / 4.1.19
404 Solution v4.1.19
4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.19 4.1.18 4.1.17 4.1.16 4.1.15 4.1.13 4.1.12 4.1.11 4.1.10 4.1.9 4.1.8 4.1.7 4.1.6 4.1.5 4.1.4 4.1.3 trunk 2.30.0 All 109 releases
404-solution / includes / UninstallModal.php

UninstallModal.php in 404 Solution 4.1.19, at includes/UninstallModal.php

1,254 lines 54.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3
4 if (!defined('ABSPATH')) {
5 exit;
6 }
7
8 /**
9 * Handles the deactivation modal popup display and AJAX functionality
10 * Shows options before plugin deactivation to preserve user data
11 *
12 * @since 2.36.11
13 */
14 class ABJ_404_Solution_UninstallModal {
15
16 /**
17 * Initialize the deactivation modal functionality
18 * @return void
19 */
20 public static function init(): void {
21 // Enqueue assets only on plugins.php page
22 add_action('admin_enqueue_scripts', array(__CLASS__, 'enqueueAssets'));
23
24 // Register AJAX handler for saving preferences
25 add_action('wp_ajax_abj404_save_uninstall_prefs', array(__CLASS__, 'handleAjaxSavePreferences'));
26 }
27
28 /**
29 * Enqueue modal assets (JavaScript, CSS) on plugins.php page
30 *
31 * @param string $hook Current admin page hook
32 * @return void
33 */
34 public static function enqueueAssets(string $hook): void {
35 // Only load on plugins.php page
36 if ($hook !== 'plugins.php') {
37 return;
38 }
39
40 // Only for administrators who can manage plugins
41 if (!current_user_can('activate_plugins')) {
42 return;
43 }
44
45 // Enqueue jQuery UI Dialog (WordPress core)
46 wp_enqueue_script('jquery-ui-dialog');
47 wp_enqueue_style('wp-jquery-ui-dialog');
48
49 // Enqueue custom JavaScript
50 wp_enqueue_script(
51 'abj404-uninstall-modal',
52 plugin_dir_url(ABJ404_FILE) . 'includes/js/uninstall-modal.js',
53 array('jquery', 'jquery-ui-dialog'),
54 '1.0.0',
55 true
56 );
57
58 // Get redirect count for display in modal
59 $redirectCount = self::getRedirectCount();
60
61 // Pass data to JavaScript
62 wp_localize_script('abj404-uninstall-modal', 'abj404UninstallModal', array(
63 'nonce' => wp_create_nonce('abj404_uninstall_nonce'),
64 'pluginSlug' => self::getPluginSlug(),
65 'redirectCount' => $redirectCount,
66 'i18n' => array(
67 'dialogTitle' => __('404 Solution - Deactivation Options', '404-solution'),
68 'btnCancel' => __('Cancel', '404-solution'),
69 'btnSkipFeedback' => __('Deactivate without feedback', '404-solution'),
70 'btnDeactivate' => __('Email Feedback & Deactivate', '404-solution'),
71 'btnSaving' => __('Processing...', '404-solution'),
72 'btnDeactivating' => __('Deactivating', '404-solution'),
73 )
74 ));
75
76 // Custom CSS for modal styling
77 wp_add_inline_style('wp-jquery-ui-dialog', '
78 .abj404-uninstall-dialog .ui-dialog-titlebar {
79 background: #d63638;
80 color: white;
81 }
82 .abj404-uninstall-dialog .ui-dialog-titlebar-close {
83 color: white;
84 }
85 .abj404-uninstall-dialog .ui-dialog-titlebar-close:hover {
86 background: #b32d2e;
87 }
88 .abj404-uninstall-dialog .button-danger {
89 background: #d63638;
90 border-color: #d63638;
91 color: white;
92 }
93 .abj404-uninstall-dialog .button-danger:hover {
94 background: #b32d2e;
95 border-color: #b32d2e;
96 }
97 .abj404-uninstall-content label {
98 display: block;
99 margin: 8px 0;
100 cursor: pointer;
101 }
102 .abj404-uninstall-content label input[type="checkbox"],
103 .abj404-uninstall-content label input[type="radio"] {
104 margin-right: 8px;
105 }
106 .abj404-uninstall-content .description {
107 margin: 0;
108 color: #646970;
109 font-size: 12px;
110 }
111 .abj404-uninstall-content h3 {
112 margin-top: 18px;
113 margin-bottom: 8px;
114 border-bottom: 1px solid #dcdcde;
115 padding-bottom: 6px;
116 font-size: 14px;
117 }
118 .abj404-uninstall-content h3:first-child {
119 margin-top: 0;
120 }
121 .abj404-uninstall-reasons {
122 margin-left: 25px;
123 }
124 .abj404-uninstall-reasons label {
125 margin: 6px 0;
126 font-size: 13px;
127 }
128 .abj404-followup-section {
129 margin: 12px 0 !important;
130 padding: 12px !important;
131 }
132 .abj404-followup-section p {
133 margin: 0 0 8px 0 !important;
134 font-size: 13px !important;
135 }
136 .abj404-followup-section label {
137 margin: 4px 0 !important;
138 font-size: 13px !important;
139 }
140 ');
141
142 // Output modal HTML in footer
143 add_action('admin_footer', array(__CLASS__, 'outputModalHTML'));
144 }
145
146 /**
147 * Output the modal HTML structure
148 * @return void
149 */
150 public static function outputModalHTML(): void {
151 $redirectCount = self::getRedirectCount();
152
153 ?>
154 <div id="abj404-uninstall-modal" class="hidden" style="max-width:600px">
155 <div class="abj404-uninstall-content">
156 <!-- Data Deletion Options -->
157 <h3 style="margin-top: 0;">
158 ⚠️ <?php _e('Before deactivating, choose what happens to your data:', '404-solution'); ?>
159 </h3>
160
161 <label>
162 <input type="checkbox" id="abj404-keep-redirects" checked>
163 <strong><?php printf(_n('Keep my redirect (%d)', 'Keep my redirects (%d)', $redirectCount, '404-solution'), $redirectCount); ?></strong>
164 <span class="description" style="display: inline; margin-left: 5px;">
165 <?php _e('— saves them for later if you reinstall', '404-solution'); ?>
166 </span>
167 </label>
168
169 <label>
170 <input type="checkbox" id="abj404-keep-logs" checked>
171 <strong><?php _e('Keep 404 logs', '404-solution'); ?></strong>
172 <span class="description" style="display: inline; margin-left: 5px;">
173 <?php _e('— historical data preserved', '404-solution'); ?>
174 </span>
175 </label>
176
177 <p class="description" style="margin: 5px 0 0 25px; font-size: 12px;">
178 <?php _e('Cache tables are always deleted (can be rebuilt)', '404-solution'); ?>
179 </p>
180
181 <!-- Deactivation Reason -->
182 <h3 style="margin-top: 20px;"><?php _e('Help us improve (Optional)', '404-solution'); ?></h3>
183
184 <div class="abj404-uninstall-reasons">
185 <label>
186 <input type="radio" name="abj404-reason" value="temporary">
187 <?php _e('Temporary deactivation for debugging', '404-solution'); ?>
188 </label>
189 <label>
190 <input type="radio" name="abj404-reason" value="not-working">
191 <?php _e('The plugin is not working as expected', '404-solution'); ?>
192 </label>
193 <label>
194 <input type="radio" name="abj404-reason" value="found-better">
195 <?php _e('I found a better plugin', '404-solution'); ?>
196 </label>
197 <label>
198 <input type="radio" name="abj404-reason" value="no-longer-needed">
199 <?php _e('I no longer need this functionality', '404-solution'); ?>
200 </label>
201 <label>
202 <input type="radio" name="abj404-reason" value="too-complicated">
203 <?php _e('Too complicated to configure', '404-solution'); ?>
204 </label>
205 <label>
206 <input type="radio" name="abj404-reason" value="performance">
207 <?php _e('Performance issues', '404-solution'); ?>
208 </label>
209 <label>
210 <input type="radio" name="abj404-reason" value="other">
211 <?php _e('Other reason', '404-solution'); ?>
212 </label>
213 </div>
214
215 <!-- Conditional follow-up sections (shown based on selected reason) -->
216 <div id="abj404-followup-not-working" class="abj404-followup-section" style="display:none; background: #f6f7f7; border-radius: 4px; border-left: 3px solid #d63638;">
217 <p style="font-weight: 600;">
218 <?php _e('What specifically isn\'t working?', '404-solution'); ?>
219 </p>
220 <label>
221 <input type="checkbox" class="abj404-issue-checkbox" name="abj404-issue[]" value="redirects-not-triggering">
222 <?php _e('Redirects not triggering/working', '404-solution'); ?>
223 </label>
224 <label>
225 <input type="checkbox" class="abj404-issue-checkbox" name="abj404-issue[]" value="settings-not-saving">
226 <?php _e('Settings not saving', '404-solution'); ?>
227 </label>
228 <label>
229 <input type="checkbox" class="abj404-issue-checkbox" name="abj404-issue[]" value="admin-errors">
230 <?php _e('Admin pages showing errors', '404-solution'); ?>
231 </label>
232 <label>
233 <input type="checkbox" class="abj404-issue-checkbox" name="abj404-issue[]" value="suggestions-not-appearing">
234 <?php _e('Suggestions not appearing', '404-solution'); ?>
235 </label>
236 <label>
237 <input type="checkbox" class="abj404-issue-checkbox" name="abj404-issue[]" value="plugin-conflicts">
238 <?php _e('Conflicts with other plugins', '404-solution'); ?>
239 </label>
240 <label>
241 <input type="checkbox" class="abj404-issue-checkbox" name="abj404-issue[]" value="other-issue">
242 <?php _e('Other issue (please specify below)', '404-solution'); ?>
243 </label>
244 </div>
245
246 <div id="abj404-followup-performance" class="abj404-followup-section" style="display:none; background: #f6f7f7; border-radius: 4px; border-left: 3px solid #d63638;">
247 <p style="font-weight: 600;">
248 <?php _e('What type of performance issue?', '404-solution'); ?>
249 </p>
250 <label>
251 <input type="checkbox" class="abj404-issue-checkbox" name="abj404-issue[]" value="slow-admin">
252 <?php _e('Slow admin dashboard', '404-solution'); ?>
253 </label>
254 <label>
255 <input type="checkbox" class="abj404-issue-checkbox" name="abj404-issue[]" value="slow-frontend">
256 <?php _e('Slow frontend page loads', '404-solution'); ?>
257 </label>
258 <label>
259 <input type="checkbox" class="abj404-issue-checkbox" name="abj404-issue[]" value="high-database">
260 <?php _e('High database usage', '404-solution'); ?>
261 </label>
262 <label>
263 <input type="checkbox" class="abj404-issue-checkbox" name="abj404-issue[]" value="memory-issues">
264 <?php _e('Memory issues', '404-solution'); ?>
265 </label>
266 <label>
267 <input type="checkbox" class="abj404-issue-checkbox" name="abj404-issue[]" value="other-performance">
268 <?php _e('Other (please specify below)', '404-solution'); ?>
269 </label>
270 </div>
271
272 <div id="abj404-followup-complicated" class="abj404-followup-section" style="display:none; background: #f6f7f7; border-radius: 4px; border-left: 3px solid #d63638;">
273 <p style="font-weight: 600;">
274 <?php _e('What was confusing?', '404-solution'); ?>
275 </p>
276 <label>
277 <input type="checkbox" class="abj404-issue-checkbox" name="abj404-issue[]" value="settings-confusing">
278 <?php _e('Settings are confusing', '404-solution'); ?>
279 </label>
280 <label>
281 <input type="checkbox" class="abj404-issue-checkbox" name="abj404-issue[]" value="too-many-options">
282 <?php _e('Too many options', '404-solution'); ?>
283 </label>
284 <label>
285 <input type="checkbox" class="abj404-issue-checkbox" name="abj404-issue[]" value="unclear-docs">
286 <?php _e('Unclear documentation', '404-solution'); ?>
287 </label>
288 <label>
289 <input type="checkbox" class="abj404-issue-checkbox" name="abj404-issue[]" value="other-confusion">
290 <?php _e('Other (please specify below)', '404-solution'); ?>
291 </label>
292 </div>
293
294 <!-- Follow-up for "Found a better plugin" -->
295 <div id="abj404-followup-better-plugin" class="abj404-followup-section" style="display:none; padding: 0 !important;">
296 <label for="abj404-better-plugin-name" style="margin-bottom: 5px;">
297 <?php _e('Which plugin are you switching to?', '404-solution'); ?>
298 </label>
299 <input
300 type="text"
301 id="abj404-better-plugin-name"
302 class="widefat"
303 placeholder="<?php _e('Plugin name (optional)', '404-solution'); ?>"
304 >
305 </div>
306
307 <!-- Follow-up for "Other reason" -->
308 <div id="abj404-followup-other" class="abj404-followup-section" style="display:none; padding: 0 !important;">
309 <label for="abj404-other-reason-text" style="margin-bottom: 5px;">
310 <?php _e('Please tell us more (optional):', '404-solution'); ?>
311 </label>
312 <textarea
313 id="abj404-other-reason-text"
314 rows="3"
315 class="widefat"
316 placeholder="<?php _e('What\'s your reason for deactivating?', '404-solution'); ?>"
317 ></textarea>
318 </div>
319
320 <!-- Additional details for conditional sections -->
321 <div id="abj404-followup-details" class="abj404-followup-section" style="display:none; padding: 0 !important; margin-top: 10px !important;">
322 <label for="abj404-followup-details-text" style="margin-bottom: 5px;">
323 <?php _e('Additional details (optional):', '404-solution'); ?>
324 </label>
325 <textarea
326 id="abj404-followup-details-text"
327 rows="3"
328 class="widefat"
329 placeholder="<?php _e('Any other information about the issue...', '404-solution'); ?>"
330 ></textarea>
331 </div>
332
333 <!-- Optional Feedback Email -->
334 <div id="abj404-feedback-email-section" style="margin: 15px 0 10px 0;">
335 <label for="abj404-feedback-email" style="display: block; margin-bottom: 5px;">
336 <strong><?php _e('Your email (optional):', '404-solution'); ?></strong>
337 </label>
338 <input
339 type="email"
340 id="abj404-feedback-email"
341 placeholder="<?php _e('For follow-up if needed', '404-solution'); ?>"
342 class="widefat"
343 >
344 </div>
345
346 <!-- Technical Details Opt-in -->
347 <label style="margin: 10px 0 5px 0; display: block;">
348 <input type="checkbox" id="abj404-include-diagnostics" checked>
349 <?php _e('Include technical details (site URL, system info, plugin counts, and a sanitized log excerpt) to help diagnose the issue', '404-solution'); ?>
350 </label>
351 <p class="abj404-privacy-note" style="margin: 0 0 15px 24px; font-size: 11px; color: #555;">
352 <?php
353 /* translators: %s is a literal relative file path to the plugin's privacy policy stub, rendered as <code>. */
354 printf(
355 esc_html__('Privacy details (retention, erasure path, processing region): %s', '404-solution'),
356 '<code>docs/privacy.md</code>'
357 );
358 ?>
359 </p>
360 </div>
361 </div>
362 <?php
363 }
364
365 /**
366 * Handle AJAX request to save uninstall preferences
367 * @return void
368 */
369 public static function handleAjaxSavePreferences(): void {
370 // Security: Verify nonce
371 $nonceOk = check_ajax_referer('abj404_uninstall_nonce', 'nonce', false);
372 if (!$nonceOk) {
373 wp_send_json_error(array('message' => __('Invalid security token', '404-solution')), 403);
374 return; // @phpstan-ignore deadCode.unreachable
375 }
376
377 // Security: Check user capabilities
378 if (!current_user_can('activate_plugins')) {
379 wp_send_json_error(array('message' => __('Insufficient permissions', '404-solution')), 403);
380 return; // @phpstan-ignore deadCode.unreachable
381 }
382
383 // Get preferences from AJAX request
384 // Use filter_var to properly handle boolean values sent from JavaScript
385 $preferences = array(
386 'delete_redirects' => isset($_POST['delete_redirects']) ? filter_var($_POST['delete_redirects'], FILTER_VALIDATE_BOOLEAN) : false,
387 'delete_logs' => isset($_POST['delete_logs']) ? filter_var($_POST['delete_logs'], FILTER_VALIDATE_BOOLEAN) : false,
388 'delete_cache' => true, // Always delete cache tables
389 'send_feedback' => isset($_POST['send_feedback']) ? filter_var($_POST['send_feedback'], FILTER_VALIDATE_BOOLEAN) : false,
390 'uninstall_reason' => isset($_POST['uninstall_reason']) ? sanitize_text_field($_POST['uninstall_reason']) : '',
391 'selected_issues' => isset($_POST['selected_issues']) ? sanitize_text_field($_POST['selected_issues']) : '',
392 'followup_details' => isset($_POST['followup_details']) ? sanitize_textarea_field($_POST['followup_details']) : '',
393 // Back-compat for older tests/UI that used a single text field.
394 'feedback_details' => isset($_POST['followup_details']) ? sanitize_textarea_field($_POST['followup_details']) : '',
395 'better_plugin_name' => isset($_POST['better_plugin_name']) ? sanitize_text_field($_POST['better_plugin_name']) : '',
396 'other_reason_text' => isset($_POST['other_reason_text']) ? sanitize_textarea_field($_POST['other_reason_text']) : '',
397 'feedback_email' => isset($_POST['feedback_email']) ? sanitize_email($_POST['feedback_email']) : '',
398 'include_diagnostics' => isset($_POST['include_diagnostics']) ? filter_var($_POST['include_diagnostics'], FILTER_VALIDATE_BOOLEAN) : false
399 );
400
401 // Debug logging (only in debug mode to avoid logging PII like email/feedback in production)
402 if (defined('WP_DEBUG') && WP_DEBUG) {
403 error_log('404 Solution: AJAX handler received deactivation preferences');
404 error_log('404 Solution: Raw POST send_feedback = ' . (isset($_POST['send_feedback']) ? $_POST['send_feedback'] : 'NOT SET'));
405 error_log('404 Solution: Parsed send_feedback = ' . ($preferences['send_feedback'] ? 'true' : 'false'));
406 error_log('404 Solution: Parsed preferences: ' . print_r($preferences, true));
407 }
408
409 // Save preferences using site options for multisite compatibility
410 // In multisite, use site_option for network-activated plugins, regular option for single-site
411 $option_name = 'abj404_uninstall_preferences';
412
413 // Capture return value to verify save success
414 $save_result = false;
415 if (is_multisite() && self::isNetworkActivated()) {
416 // Network-activated: Use site option (accessible across all sites)
417 $save_result = update_site_option($option_name, $preferences);
418 } else {
419 // Single-site or site-specific activation: Use regular option
420 $save_result = update_option($option_name, $preferences, false); // autoload=false
421 }
422
423 // Verify the save was successful (false could mean unchanged OR failure)
424 if ($save_result === false) {
425 // Read back the option to verify it was actually saved
426 $saved_value = is_multisite() && self::isNetworkActivated()
427 ? get_site_option($option_name)
428 : get_option($option_name);
429
430 // If the saved value doesn't match what we tried to save, it's a real failure
431 if ($saved_value !== $preferences) {
432 $logger = abj_service('logging');
433 if ($logger !== null) {
434 $logger->warn('UninstallModal preference save failed: option ' . $option_name .
435 ' did not round-trip after update_option/update_site_option (multisite=' .
436 (is_multisite() ? '1' : '0') .
437 '). Returning HTTP 500 to AJAX caller.');
438 }
439 wp_send_json_error(array(
440 'message' => __('Could not save preferences. Your choices may not be preserved.', '404-solution')
441 ), 500);
442 }
443 // If values match, the false return was just because value was unchanged (which is OK)
444 }
445
446 // Queue feedback for asynchronous send only if user explicitly opted in.
447 // The actual HTTP POST + email-fallback runs out-of-band on the next
448 // page load via wp_schedule_single_event(), so this AJAX call never
449 // blocks on the network, even on slow SMTP / WAN paths.
450 if ($preferences['send_feedback']) {
451 $includeDiagnostics = !empty($preferences['include_diagnostics']);
452 $debugLog = '';
453 // Only fetch the log excerpt when the user opted into diagnostics.
454 // abj_service() is contractually non-throwing (returns null for
455 // unresolved services), so guarding with method_exists() is enough
456 // to keep this fire-and-forget path from needing a try/catch shim.
457 if ($includeDiagnostics && function_exists('abj_service')) {
458 $logger = abj_service('logging');
459 if (is_object($logger) && method_exists($logger, 'getSanitizedLogExcerptForSupport')) {
460 $excerpt = $logger->getSanitizedLogExcerptForSupport();
461 if (is_string($excerpt)) {
462 $debugLog = $excerpt;
463 }
464 }
465 }
466
467 $extras = array(
468 'uninstall_reason' => $preferences['uninstall_reason'],
469 'selected_issues' => $preferences['selected_issues'],
470 'followup_details' => $preferences['followup_details'],
471 'better_plugin_name' => $preferences['better_plugin_name'],
472 'other_reason_text' => $preferences['other_reason_text'],
473 'contact_email' => $preferences['feedback_email'],
474 'include_diagnostics' => $includeDiagnostics,
475 'debug_log' => $debugLog,
476 );
477 // F1 (docs/diagnostic-catalog.md): the "Include technical details"
478 // checkbox is the modal's diagnostic opt-in. When unchecked, we
479 // must NOT collect or ship site_url, environment_extras, counts,
480 // server_software, active_plugins, or any other diagnostic /
481 // site-identifying field. The minimal-payload builder keeps the
482 // payload schema-valid (server still accepts the feedback) while
483 // suppressing every diagnostic row.
484 $payload = $includeDiagnostics
485 ? ABJ_404_Solution_FeedbackTransport::buildPayload('uninstall', $extras)
486 : ABJ_404_Solution_FeedbackTransport::buildMinimalPayload('uninstall', $extras);
487 ABJ_404_Solution_FeedbackTransport::queue($payload, 'uninstall');
488
489 $message = __('Thanks for the feedback!', '404-solution');
490 } else {
491 // User skipped feedback - minimal message (won't be shown anyway due to instant redirect)
492 $message = '';
493 }
494
495 // Return success (failures are already handled above)
496 wp_send_json_success(array('message' => $message));
497 }
498
499 /**
500 * Check if plugin is network-activated
501 *
502 * @return bool True if network-activated, false otherwise
503 */
504 private static function isNetworkActivated() {
505 if (!is_multisite()) {
506 return false;
507 }
508
509 if (!function_exists('is_plugin_active_for_network')) {
510 require_once ABSPATH . 'wp-admin/includes/plugin.php';
511 }
512
513 return is_plugin_active_for_network(plugin_basename(ABJ404_FILE));
514 }
515
516 /**
517 * Get the plugin slug for JavaScript
518 *
519 * @return string Plugin directory slug
520 */
521 private static function getPluginSlug() {
522 // Get plugin directory name from plugin file path
523 $pluginPath = plugin_basename(ABJ404_FILE);
524 $parts = explode('/', $pluginPath);
525 return $parts[0];
526 }
527
528 /**
529 * Get the count of redirects for display
530 *
531 * @return int Number of redirects
532 */
533 private static function getRedirectCount() {
534 global $wpdb;
535
536 // Guard for test environment where DataAccess class may not be loaded
537 if (!class_exists('ABJ_404_Solution_DataAccess')) {
538 return 0;
539 }
540
541 $dao = abj_service('data_access');
542 $table_name = $dao->getPrefixedTableName('abj404_redirects');
543
544 // Check if table exists
545 // DAO-bypass-approved: Diagnostic table-existence probe for redirect-count display
546 $table_exists = $wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $table_name)) === $table_name;
547
548 if (!$table_exists) {
549 return 0;
550 }
551
552 // DAO-bypass-approved: Diagnostic count for uninstall-modal preview
553 $count = $wpdb->get_var("SELECT COUNT(*) FROM $table_name WHERE status != " . ABJ404_STATUS_TRASH);
554
555 return $count ? intval($count) : 0;
556 }
557
558 /**
559 * Get comprehensive plugin statistics for diagnostics.
560 * Includes redirect counts by type, captured 404s, log entries, and storage sizes.
561 *
562 * @return array{redirects: array<string, int>, captured: array<string, int>, log_count: int, log_table_size_mb: float, debug_file_size_mb: float}
563 */
564 private static function getPluginStatistics(): array {
565 $stats = array(
566 'redirects' => array('all' => 0, 'manual' => 0, 'auto' => 0, 'regex' => 0, 'trash' => 0),
567 'captured' => array('all' => 0, 'captured' => 0, 'ignored' => 0, 'later' => 0, 'trash' => 0),
568 'log_count' => 0,
569 'log_table_size_mb' => 0,
570 'debug_file_size_mb' => 0,
571 );
572
573 // Guard for test environment where DataAccess class may not be loaded
574 if (!class_exists('ABJ_404_Solution_DataAccess')) {
575 return $stats;
576 }
577
578 // Additional guard: check if wpdb has the required methods (test environments may use mocks)
579 global $wpdb;
580 if (!isset($wpdb) || !method_exists($wpdb, 'get_results')) {
581 return $stats;
582 }
583
584 try {
585 $dao = abj_service('data_access');
586
587 // Get redirect counts by status
588 $redirectCounts = $dao->getRedirectStatusCounts(true);
589 if (is_array($redirectCounts)) {
590 $stats['redirects'] = $redirectCounts;
591 }
592
593 // Get captured 404s counts by status
594 $capturedCounts = $dao->getCapturedStatusCounts(true);
595 if (is_array($capturedCounts)) {
596 $stats['captured'] = $capturedCounts;
597 }
598
599 // Get log entry count
600 $stats['log_count'] = $dao->getLogsCount(0);
601
602 // Get log table size
603 $logTableSizeBytes = $dao->getLogDiskUsage();
604 if ($logTableSizeBytes > 0) {
605 $stats['log_table_size_mb'] = round($logTableSizeBytes / (1024 * 1024), 2);
606 }
607
608 // Get debug file size
609 if (class_exists('ABJ_404_Solution_Logging')) {
610 $logger = abj_service('logging');
611 $debugFilePath = $logger->getDebugFilePath();
612 if (file_exists($debugFilePath)) {
613 $debugFileSize = filesize($debugFilePath);
614 $stats['debug_file_size_mb'] = round($debugFileSize / (1024 * 1024), 2);
615 }
616 }
617 } catch (\Throwable $e) {
618 // Surface which call failed so the support-bundle reader sees the
619 // reason values are missing instead of silently returning defaults.
620 $stats['_errors'][] = 'getDebugFileSize: ' . $e->getMessage();
621 }
622
623 return $stats;
624 }
625
626 /**
627 * Get counts of categories, tags, pages, and posts for diagnostics.
628 * These counts help identify if memory issues are caused by large content volume.
629 *
630 * @return array{categories: int, tags: int, pages: int, posts: int}
631 */
632 private static function getContentCounts(): array {
633 $counts = array(
634 'categories' => 0,
635 'tags' => 0,
636 'pages' => 0,
637 'posts' => 0,
638 );
639
640 // Guard for test environment where WordPress functions may not be available
641 if (!function_exists('wp_count_terms') || !function_exists('wp_count_posts')) {
642 return $counts;
643 }
644
645 // Count categories (includes product_cat for WooCommerce)
646 $category_count = wp_count_terms(array('taxonomy' => 'category', 'hide_empty' => false));
647 if (!is_wp_error($category_count)) {
648 $counts['categories'] = intval($category_count);
649 }
650
651 // Also count WooCommerce product categories if they exist
652 if (function_exists('taxonomy_exists') && taxonomy_exists('product_cat')) {
653 $product_cat_count = wp_count_terms(array('taxonomy' => 'product_cat', 'hide_empty' => false));
654 if (!is_wp_error($product_cat_count)) {
655 $counts['categories'] += intval($product_cat_count);
656 }
657 }
658
659 // Count tags (includes product_tag for WooCommerce)
660 $tag_count = wp_count_terms(array('taxonomy' => 'post_tag', 'hide_empty' => false));
661 if (!is_wp_error($tag_count)) {
662 $counts['tags'] = intval($tag_count);
663 }
664
665 // Also count WooCommerce product tags if they exist
666 if (function_exists('taxonomy_exists') && taxonomy_exists('product_tag')) {
667 $product_tag_count = wp_count_terms(array('taxonomy' => 'product_tag', 'hide_empty' => false));
668 if (!is_wp_error($product_tag_count)) {
669 $counts['tags'] += intval($product_tag_count);
670 }
671 }
672
673 // Count pages
674 $page_counts = wp_count_posts('page');
675 if (isset($page_counts->publish)) {
676 $counts['pages'] = intval($page_counts->publish);
677 }
678
679 // Count posts
680 $post_counts = wp_count_posts('post');
681 if (isset($post_counts->publish)) {
682 $counts['posts'] = intval($post_counts->publish);
683 }
684
685 // Also count WooCommerce products if they exist
686 if (function_exists('post_type_exists') && post_type_exists('product')) {
687 $product_counts = wp_count_posts('product');
688 if (isset($product_counts->publish)) {
689 $counts['posts'] += intval($product_counts->publish);
690 }
691 }
692
693 return $counts;
694 }
695
696 /**
697 * Email-fallback for FeedbackTransport when the HTTP POST fails. Builds a
698 * deactivation-feedback email body from a FeedbackTransport payload and
699 * dispatches it via wp_mail(). Public because the cron-context fallback in
700 * FeedbackTransport::sendNow() invokes this for type='uninstall'.
701 *
702 * The payload is the array produced by FeedbackTransport::buildPayload(),
703 * carrying the uninstall extras (uninstall_reason, selected_issues,
704 * followup_details, better_plugin_name, other_reason_text, contact_email,
705 * include_diagnostics, debug_log). The diagnostic block is rebuilt live
706 * from the same in-class helpers the AJAX path used pre-migration so the
707 * email retains its existing shape and call surface (getPluginStatistics,
708 * getContentCounts, getDatabaseInfo, getActivePluginsList).
709 *
710 * @param array<string, mixed> $payload FeedbackTransport-built payload.
711 * @return bool True if wp_mail() reported success, false otherwise.
712 */
713 public static function sendFeedbackEmail(array $payload): bool {
714 global $wp_version;
715
716 $site_name = function_exists('get_bloginfo') ? (string)get_bloginfo('name') : '';
717 $rawAdminEmail = function_exists('get_option') ? get_option('admin_email') : '';
718 $admin_email = is_string($rawAdminEmail) ? $rawAdminEmail : '';
719
720 $contactEmail = isset($payload['contact_email']) && is_string($payload['contact_email']) ? $payload['contact_email'] : '';
721 $includeDiag = !empty($payload['include_diagnostics']);
722
723 $subject = sprintf('[404 Solution] Deactivation Feedback from %s', $site_name);
724
725 $body = "Deactivation feedback received:\n\n";
726 $body .= "===============================================\n";
727 $body .= "USER FEEDBACK\n";
728 $body .= "===============================================\n\n";
729
730 $uninstallReason = isset($payload['uninstall_reason']) && is_string($payload['uninstall_reason']) ? $payload['uninstall_reason'] : '';
731 if ($uninstallReason !== '') {
732 $body .= "Reason: " . ucfirst(str_replace('-', ' ', $uninstallReason)) . "\n\n";
733 }
734
735 $selectedIssues = isset($payload['selected_issues']) && is_string($payload['selected_issues']) ? $payload['selected_issues'] : '';
736 if ($selectedIssues !== '') {
737 $body .= "Specific Issues:\n";
738 foreach (explode(',', $selectedIssues) as $issue) {
739 $body .= " [x] " . ucfirst(str_replace('-', ' ', $issue)) . "\n";
740 }
741 $body .= "\n";
742 }
743
744 $followup = isset($payload['followup_details']) && is_string($payload['followup_details']) ? $payload['followup_details'] : '';
745 if ($followup !== '') {
746 $body .= "Additional Details:\n" . $followup . "\n\n";
747 }
748
749 $betterPlugin = isset($payload['better_plugin_name']) && is_string($payload['better_plugin_name']) ? $payload['better_plugin_name'] : '';
750 if ($betterPlugin !== '') {
751 $body .= "Switching to: " . $betterPlugin . "\n\n";
752 }
753
754 $otherReason = isset($payload['other_reason_text']) && is_string($payload['other_reason_text']) ? $payload['other_reason_text'] : '';
755 if ($otherReason !== '') {
756 $body .= "Other Reason Details:\n" . $otherReason . "\n\n";
757 }
758
759 if ($contactEmail !== '') {
760 $body .= "User Email: " . $contactEmail . "\n\n";
761 }
762
763 if ($includeDiag) {
764 $plugin_stats = self::getPluginStatistics();
765 $db_info = self::getDatabaseInfo();
766 $content_counts = self::getContentCounts();
767 $system_info = array(
768 'WordPress Version' => $wp_version,
769 'PHP Version' => phpversion(),
770 'Plugin Version' => defined('ABJ404_VERSION') ? ABJ404_VERSION : 'Unknown',
771 'MySQL Version' => $db_info['version'],
772 'DB Charset' => $db_info['charset'],
773 'DB Collation' => $db_info['collation'],
774 'Multisite' => is_multisite() ? 'Yes' : 'No',
775 'Active Plugins' => self::getActivePluginsList(),
776 'Category Count' => $content_counts['categories'],
777 'Tag Count' => $content_counts['tags'],
778 'Total Pages' => $content_counts['pages'],
779 'Total Posts' => $content_counts['posts'],
780 'Redirects (active)' => $plugin_stats['redirects']['all'],
781 ' - Manual' => $plugin_stats['redirects']['manual'],
782 ' - Automatic' => $plugin_stats['redirects']['auto'],
783 ' - Regex' => $plugin_stats['redirects']['regex'],
784 ' - Trashed' => $plugin_stats['redirects']['trash'],
785 'Captured 404s (active)' => $plugin_stats['captured']['all'],
786 ' - New' => $plugin_stats['captured']['captured'],
787 ' - Ignored' => $plugin_stats['captured']['ignored'],
788 ' - Later' => $plugin_stats['captured']['later'],
789 ' - Trash' => $plugin_stats['captured']['trash'],
790 'Log Entries in DB' => $plugin_stats['log_count'],
791 'Log Table Size' => $plugin_stats['log_table_size_mb'] . ' MB',
792 'Debug File Size' => $plugin_stats['debug_file_size_mb'] . ' MB',
793 );
794
795 $body .= "===============================================\n";
796 $body .= "PLUGIN DEBUG LOG\n";
797 $body .= "===============================================\n\n";
798 $debugLog = isset($payload['debug_log']) && is_string($payload['debug_log']) ? $payload['debug_log'] : '';
799 $body .= ($debugLog !== '' ? $debugLog : 'Log excerpt unavailable.') . "\n\n";
800
801 $body .= "===============================================\n";
802 $body .= "DATABASE COLLATIONS\n";
803 $body .= "===============================================\n\n";
804 $body .= self::getDatabaseCollationSnapshot() . "\n\n";
805
806 $body .= "===============================================\n";
807 $body .= "SYSTEM INFORMATION\n";
808 $body .= "===============================================\n\n";
809 foreach ($system_info as $label => $value) {
810 $body .= sprintf("%-20s: %s\n", $label, $value);
811 }
812 }
813
814 $body .= "\n===============================================\n";
815 $body .= "This feedback was sent automatically when the user deactivated the plugin.\n";
816
817 $headers = array(
818 'Content-Type: text/plain; charset=UTF-8',
819 'From: ' . $site_name . ' <' . $admin_email . '>'
820 );
821 if ($contactEmail !== '') {
822 $headers[] = 'Reply-To: ' . $contactEmail;
823 }
824
825 $to = defined('ABJ404_AUTHOR_EMAIL') ? ABJ404_AUTHOR_EMAIL : '404solution@ajexperience.com';
826 return (bool) wp_mail($to, $subject, $body, $headers);
827 }
828
829 /**
830 * Get list of active plugins
831 *
832 * @return string Comma-separated list of active plugin names
833 */
834 private static function getActivePluginsList() {
835 if (!function_exists('get_plugins')) {
836 require_once ABSPATH . 'wp-admin/includes/plugin.php';
837 }
838
839 $all_plugins = get_plugins();
840 $active_plugins = get_option('active_plugins', array());
841 if (!is_array($active_plugins)) {
842 $active_plugins = array();
843 }
844
845 $active_plugin_names = array();
846 foreach ($active_plugins as $plugin_path) {
847 if (isset($all_plugins[$plugin_path])) {
848 $active_plugin_names[] = $all_plugins[$plugin_path]['Name'];
849 }
850 }
851
852 return !empty($active_plugin_names)
853 ? implode(', ', array_slice($active_plugin_names, 0, 10)) . (count($active_plugin_names) > 10 ? '...' : '')
854 : 'None';
855 }
856
857 /**
858 * Get database version and charset info for diagnostics.
859 * Uses fallback chain for locked-down hosts.
860 *
861 * @return array{version: string, charset: string, collation: string}
862 */
863 private static function getDatabaseInfo(): array {
864 global $wpdb;
865
866 $info = array(
867 'version' => 'Unknown',
868 'charset' => 'Unknown',
869 'collation' => 'Unknown',
870 );
871
872 // Get MySQL/MariaDB version
873 // DAO-bypass-approved: Diagnostic — MySQL VERSION() for support email
874 $version = $wpdb->get_var("SELECT VERSION()");
875 if ($version) {
876 $info['version'] = $version;
877 }
878
879 // Get database default charset and collation
880 if (!defined('DB_NAME')) {
881 // Test environment - use wpdb defaults
882 $charset = isset($wpdb->charset) ? $wpdb->charset : '';
883 $collate = isset($wpdb->collate) ? $wpdb->collate : '';
884 $info['charset'] = $charset ?: 'utf8mb4';
885 $info['collation'] = $collate ?: 'utf8mb4_unicode_ci';
886 return $info;
887 }
888
889 // Try information_schema.SCHEMATA first
890 $db_name = DB_NAME;
891 $charset_query = $wpdb->prepare(
892 "SELECT DEFAULT_CHARACTER_SET_NAME, DEFAULT_COLLATION_NAME " .
893 "FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = %s",
894 $db_name
895 );
896 // DAO-bypass-approved: Diagnostic — information_schema.SCHEMATA probe
897 $db_result = $wpdb->get_row($charset_query, ARRAY_A);
898
899 if ($db_result && !empty($db_result['DEFAULT_CHARACTER_SET_NAME'])) {
900 $info['charset'] = $db_result['DEFAULT_CHARACTER_SET_NAME'];
901 $info['collation'] = $db_result['DEFAULT_COLLATION_NAME'] ?? 'Unknown';
902 return $info;
903 }
904
905 // Fallback: SHOW VARIABLES for character_set_database and collation_database
906 // DAO-bypass-approved: Diagnostic — server variable readout for support email
907 $charset_result = $wpdb->get_row("SHOW VARIABLES LIKE 'character_set_database'", ARRAY_A);
908 // DAO-bypass-approved: Diagnostic — server variable readout for support email
909 $collation_result = $wpdb->get_row("SHOW VARIABLES LIKE 'collation_database'", ARRAY_A);
910
911 if ($charset_result && isset($charset_result['Value'])) {
912 $info['charset'] = $charset_result['Value'];
913 }
914 if ($collation_result && isset($collation_result['Value'])) {
915 $info['collation'] = $collation_result['Value'];
916 }
917
918 // Final fallback: WordPress connection settings
919 if ($info['charset'] === 'Unknown') {
920 $charset = isset($wpdb->charset) ? $wpdb->charset : '';
921 $info['charset'] = $charset ?: (defined('DB_CHARSET') ? DB_CHARSET : 'utf8mb4');
922 }
923 if ($info['collation'] === 'Unknown') {
924 $collate = isset($wpdb->collate) ? $wpdb->collate : '';
925 $info['collation'] = $collate ?: 'utf8mb4_unicode_ci';
926 }
927
928 return $info;
929 }
930
931 /**
932 * Capture charset/collation details for key plugin tables.
933 *
934 * @return string Human-readable summary for email diagnostics
935 */
936 private static function getDatabaseCollationSnapshot() {
937 global $wpdb;
938
939 $summaryLines = array();
940
941 // Show the table prefix to help diagnose prefix mismatch issues
942 $summaryLines[] = "Table prefix: " . $wpdb->prefix;
943 $summaryLines[] = "";
944
945 // Safely get class instances - may not exist in test environment
946 if (!class_exists('ABJ_404_Solution_DatabaseUpgradesEtc') ||
947 !class_exists('ABJ_404_Solution_DataAccess')) {
948 $summaryLines[] = "Collation details unavailable (required classes not loaded).";
949 return implode("\n", $summaryLines);
950 }
951
952 $dbUtils = abj_service('database_upgrades');
953 $dao = abj_service('data_access');
954
955 // Get baseline from wp_posts
956 $targetTable = $wpdb->prefix . 'posts';
957 $targetInfo = self::getTableInfo($targetTable);
958
959 if (isset($targetInfo['error'])) {
960 $summaryLines[] = "Could not read collation for {$targetTable} (baseline): " . $targetInfo['error'];
961 return implode("\n", $summaryLines);
962 }
963
964 $targetCollation = isset($targetInfo['collation']) ? $targetInfo['collation'] : '';
965 $targetCharset = isset($targetInfo['charset']) ? $targetInfo['charset'] : '';
966 $targetEngine = isset($targetInfo['engine']) ? $targetInfo['engine'] : '';
967
968 $summaryLines[] = sprintf(
969 "%s -> %s / %s / %s (baseline)",
970 $targetTable,
971 $targetCharset,
972 $targetCollation,
973 $targetEngine
974 );
975
976 // Discover all plugin tables dynamically so new tables are automatically included.
977 $prefix = $dao->getLowercasePrefix();
978 // DAO-bypass-approved: Diagnostic table enumeration for collation snapshot
979 $rawTables = $wpdb->get_results(
980 $wpdb->prepare("SHOW TABLES LIKE %s", $wpdb->esc_like($prefix . 'abj404_') . '%'),
981 ARRAY_N
982 );
983 $pluginTables = array();
984 foreach ($rawTables as $row) {
985 $fullName = $row[0];
986 $pluginTables[$fullName] = $fullName;
987 }
988
989 foreach ($pluginTables as $label => $tableName) {
990 $tableInfo = self::getTableInfo($tableName);
991
992 if (isset($tableInfo['error'])) {
993 $summaryLines[] = sprintf(
994 "%s (%s) -> unavailable (%s)",
995 $label,
996 $tableName,
997 $tableInfo['error']
998 );
999 continue;
1000 }
1001
1002 $collation = isset($tableInfo['collation']) ? $tableInfo['collation'] : '';
1003 $charset = isset($tableInfo['charset']) ? $tableInfo['charset'] : '';
1004 $engine = isset($tableInfo['engine']) ? $tableInfo['engine'] : '';
1005
1006 $matchesBaseline = ($collation === $targetCollation && $charset === $targetCharset);
1007 $utf8mb4Note = (is_string($charset) && stripos($charset, 'utf8mb4') === false) ? ' [non-utf8mb4]' : '';
1008 $matchNote = $matchesBaseline ? 'matches' : 'DIFFERS';
1009
1010 $summaryLines[] = sprintf(
1011 "%s (%s) -> %s / %s / %s (%s)%s",
1012 $label,
1013 $tableName,
1014 $charset,
1015 $collation,
1016 $engine,
1017 $matchNote,
1018 $utf8mb4Note
1019 );
1020 }
1021
1022 return implode("\n", $summaryLines);
1023 }
1024
1025 /**
1026 * Get table info with fallback chain for locked-down hosts.
1027 *
1028 * Tries multiple methods in order:
1029 * 1. information_schema (most complete)
1030 * 2. SHOW TABLE STATUS (widely permitted)
1031 * 3. SHOW CREATE TABLE (parse DDL)
1032 * 4. WordPress globals (connection-level defaults)
1033 *
1034 * @param string $tableName Table name to look up
1035 * @return array{charset?: string|null, collation?: string|null, engine?: string, error?: string, source?: string}
1036 */
1037 private static function getTableInfo(string $tableName): array {
1038 // Try information_schema first (most complete data)
1039 $result = self::tryInformationSchema($tableName);
1040 if ($result !== null && !isset($result['error'])) {
1041 return $result;
1042 }
1043
1044 // Fallback: SHOW TABLE STATUS
1045 $result = self::tryShowTableStatus($tableName);
1046 if ($result !== null && !isset($result['error'])) {
1047 return $result;
1048 }
1049
1050 // Fallback: SHOW CREATE TABLE
1051 $result = self::tryShowCreateTable($tableName);
1052 if ($result !== null && !isset($result['error'])) {
1053 return $result;
1054 }
1055
1056 // Final fallback: WordPress connection defaults
1057 return self::getWpdbDefaults();
1058 }
1059
1060 /**
1061 * Try to get table info from information_schema.
1062 *
1063 * @param string $tableName Table name to look up
1064 * @return array{charset?: string|null, collation?: string|null, engine?: string, error?: string}|null
1065 */
1066 private static function tryInformationSchema(string $tableName) {
1067 /** @var \wpdb $wpdb */
1068 global $wpdb;
1069
1070 // Guard for test environment where wpdb may be a minimal mock
1071 if (!method_exists($wpdb, 'get_row')) {
1072 return array('error' => 'wpdb methods unavailable');
1073 }
1074
1075 $query = $wpdb->prepare(
1076 "SELECT TABLE_COLLATION, ENGINE, " .
1077 "SUBSTRING_INDEX(TABLE_COLLATION, '_', 1) as TABLE_CHARSET " .
1078 "FROM information_schema.tables " .
1079 "WHERE TABLE_NAME = %s AND TABLE_SCHEMA = DATABASE()",
1080 $tableName
1081 );
1082
1083 // DAO-bypass-approved: Diagnostic — information_schema.tables probe
1084 $result = $wpdb->get_row($query, ARRAY_A);
1085
1086 // Check for query error
1087 if (!empty($wpdb->last_error)) {
1088 // Check for permission-related errors
1089 if (stripos($wpdb->last_error, 'denied') !== false ||
1090 stripos($wpdb->last_error, 'permission') !== false) {
1091 return array('error' => 'permission denied');
1092 }
1093 return array('error' => 'query error');
1094 }
1095
1096 // Table not found
1097 if (empty($result)) {
1098 return null;
1099 }
1100
1101 // Handle case variations in column names
1102 $result = array_change_key_case($result, CASE_UPPER);
1103
1104 $collation = isset($result['TABLE_COLLATION']) && is_string($result['TABLE_COLLATION']) ? $result['TABLE_COLLATION'] : null;
1105 $engine = isset($result['ENGINE']) && is_string($result['ENGINE']) ? $result['ENGINE'] : 'Unknown';
1106 $charset = isset($result['TABLE_CHARSET']) && is_string($result['TABLE_CHARSET']) ? $result['TABLE_CHARSET'] : null;
1107
1108 // Fallback charset extraction from collation
1109 if (empty($charset) && !empty($collation)) {
1110 $charset = explode('_', $collation)[0];
1111 }
1112
1113 if (empty($collation)) {
1114 return array('error' => 'no collation data');
1115 }
1116
1117 return array(
1118 'charset' => $charset,
1119 'collation' => $collation,
1120 'engine' => $engine
1121 );
1122 }
1123
1124 /**
1125 * Try to get table info using SHOW TABLE STATUS.
1126 *
1127 * @param string $tableName Table name to look up
1128 * @return array{charset?: string|null, collation?: string|null, engine?: string, error?: string}|null
1129 */
1130 private static function tryShowTableStatus(string $tableName) {
1131 /** @var \wpdb $wpdb */
1132 global $wpdb;
1133
1134 if (!method_exists($wpdb, 'get_row')) {
1135 return array('error' => 'wpdb methods unavailable');
1136 }
1137
1138 // SHOW TABLE STATUS LIKE requires the table name without database prefix matching
1139 // DAO-bypass-approved: Diagnostic — fallback metadata probe (SHOW TABLE STATUS)
1140 $result = $wpdb->get_row(
1141 $wpdb->prepare("SHOW TABLE STATUS LIKE %s", $tableName),
1142 ARRAY_A
1143 );
1144
1145 if (!empty($wpdb->last_error)) {
1146 return array('error' => 'SHOW TABLE STATUS failed');
1147 }
1148
1149 if (empty($result)) {
1150 return null;
1151 }
1152
1153 $collation = isset($result['Collation']) && is_string($result['Collation']) ? $result['Collation'] : null;
1154 $engine = isset($result['Engine']) && is_string($result['Engine']) ? $result['Engine'] : 'Unknown';
1155 $charset = (is_string($collation) && $collation !== '') ? explode('_', $collation)[0] : null;
1156
1157 if (empty($collation)) {
1158 return null;
1159 }
1160
1161 return array(
1162 'charset' => $charset,
1163 'collation' => $collation,
1164 'engine' => $engine
1165 );
1166 }
1167
1168 /**
1169 * Try to get table info by parsing SHOW CREATE TABLE output.
1170 *
1171 * @param string $tableName Table name to look up
1172 * @return array{charset?: string|null, collation?: string|null, engine?: string, error?: string}|null
1173 */
1174 private static function tryShowCreateTable(string $tableName) {
1175 /** @var \wpdb $wpdb */
1176 global $wpdb;
1177
1178 if (!is_object($wpdb) || !method_exists($wpdb, 'get_row')) {
1179 return null;
1180 }
1181
1182 // @utf8-audit: opt-out — $tableName is built from $wpdb->prefix +
1183 // 'abj404_*' constants by the uninstall flow; never user input.
1184 // Use backticks to safely quote table name
1185 // DAO-bypass-approved: Diagnostic — last-resort SHOW CREATE TABLE charset parse
1186 $result = $wpdb->get_row("SHOW CREATE TABLE `" . esc_sql($tableName) . "`", ARRAY_N);
1187
1188 if (empty($result[1])) {
1189 return null;
1190 }
1191
1192 $ddl = is_string($result[1]) ? $result[1] : '';
1193
1194 // Match charset: CHARSET=utf8mb4, DEFAULT CHARSET=utf8mb4, CHARACTER SET utf8mb4
1195 preg_match('/(?:DEFAULT\s+)?(?:CHARSET|CHARACTER\s+SET)(?:\s*=\s*|\s+)([\w\d]+)/i', $ddl, $charsetMatch);
1196
1197 // Match collation: COLLATE=utf8mb4_unicode_ci, COLLATE utf8mb4_unicode_ci
1198 preg_match('/(?:DEFAULT\s+)?COLLATE(?:\s*=\s*|\s+)([\w\d_]+)/i', $ddl, $collationMatch);
1199
1200 // Match engine: ENGINE=InnoDB
1201 preg_match('/ENGINE\s*=\s*([\w]+)/i', $ddl, $engineMatch);
1202
1203 $charset = $charsetMatch[1] ?? null;
1204 $collation = $collationMatch[1] ?? null;
1205 $engine = $engineMatch[1] ?? 'Unknown';
1206
1207 // Derive collation from charset if not explicit
1208 if ($charset && !$collation) {
1209 $collation = $charset . '_general_ci';
1210 }
1211
1212 // Need at least charset or collation to return valid data
1213 if (empty($charset) && empty($collation)) {
1214 return null;
1215 }
1216
1217 return array(
1218 'charset' => $charset ?: explode('_', $collation)[0],
1219 'collation' => $collation,
1220 'engine' => $engine
1221 );
1222 }
1223
1224 /**
1225 * Get WordPress connection-level charset/collation as final fallback.
1226 *
1227 * @return array{charset: string, collation: string, engine: string, source: string}
1228 */
1229 private static function getWpdbDefaults(): array {
1230 global $wpdb;
1231
1232 $charset = 'utf8mb4';
1233 $collation = 'utf8mb4_unicode_ci';
1234
1235 // Try to get from wpdb properties
1236 if (isset($wpdb->charset) && !empty($wpdb->charset)) {
1237 $charset = $wpdb->charset;
1238 } elseif (defined('DB_CHARSET') && DB_CHARSET) {
1239 $charset = DB_CHARSET;
1240 }
1241
1242 if (isset($wpdb->collate) && !empty($wpdb->collate)) {
1243 $collation = $wpdb->collate;
1244 }
1245
1246 return array(
1247 'charset' => $charset,
1248 'collation' => $collation,
1249 'engine' => 'Unknown',
1250 'source' => 'wpdb defaults'
1251 );
1252 }
1253 }
1254