PluginProbe
404 Solution / 4.2.0
404 Solution v4.2.0
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.2.0, at includes/UninstallModal.php

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