PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.10.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.10.0
2.11.0 2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 All 52 releases
thinkrank / includes / seo / class-performance-monitoring-manager.php

class-performance-monitoring-manager.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.10.0, at includes/seo/class-performance-monitoring-manager.php

3,029 lines 119.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Performance Monitoring Manager Class
4 *
5 * Advanced performance monitoring system with real-time Core Web Vitals tracking,
6 * SEO metrics analysis, automated reporting, and integration with Google APIs.
7 * Implements 2025 performance standards with industry-leading monitoring algorithms.
8 *
9 * @package ThinkRank
10 * @subpackage SEO
11 * @since 1.0.0
12 */
13
14 declare(strict_types=1);
15
16 namespace ThinkRank\SEO;
17
18 use ThinkRank\Core\Settings_Manager;
19 use ThinkRank\Integrations\Google_PageSpeed_Client;
20
21 // Prevent direct access
22 if (!defined('ABSPATH')) {
23 exit;
24 }
25
26 /**
27 * Performance Monitoring Manager Class
28 *
29 * Provides comprehensive performance monitoring with Core Web Vitals tracking,
30 * SEO metrics analysis, automated reporting, and performance optimization
31 * recommendations integrated with AI Content Analyzer and Content Optimization Manager.
32 *
33 * @since 1.0.0
34 */
35 class Performance_Monitoring_Manager extends Abstract_SEO_Manager {
36
37 /**
38 * Public metric names that differ from the metric_type they are stored under.
39 *
40 * The REST enum uses the display name `score` while the writer stores
41 * `performance_score`, so the filtered history query looked for a row type
42 * that is never written and always came back empty (#520).
43 *
44 * @since 2.1.1
45 * @var array<string,string>
46 */
47 private const METRIC_COLUMN_MAP = [
48 'score' => 'performance_score',
49 ];
50
51 /**
52 * Error code: no PageSpeed credential, and keyless runs are switched off.
53 */
54 public const ERROR_NOT_CONFIGURED = 'pagespeed_not_configured';
55
56 /**
57 * Error code: the PageSpeed request was attempted and failed.
58 */
59 public const ERROR_API_FAILED = 'pagespeed_api_failed';
60
61 /**
62 * Error code: Google's daily PageSpeed quota for this caller is spent.
63 *
64 * On a site with no credential that is the keyless pool every anonymous
65 * caller shares, so it says nothing about this site's own usage and will
66 * not clear on a retry — only a dedicated key or an OAuth connection gets
67 * the site a quota of its own.
68 */
69 public const ERROR_QUOTA_EXHAUSTED = 'pagespeed_quota_exhausted';
70
71 /**
72 * Error code: too many requests in a short window. Unlike the daily quota
73 * this does clear on its own, so the remedy really is to wait.
74 */
75 public const ERROR_RATE_LIMITED = 'pagespeed_rate_limited';
76
77 /**
78 * Error code: Google rejected the credential the site is configured with.
79 */
80 public const ERROR_CREDENTIAL_REJECTED = 'pagespeed_credential_rejected';
81
82 /**
83 * Error code: Lighthouse could not load the URL under test.
84 */
85 public const ERROR_URL_UNREACHABLE = 'pagespeed_url_unreachable';
86
87 /**
88 * Why the last PageSpeed-backed call returned nothing, if it did.
89 *
90 * Diagnostics and opportunities return a plain list, so a bare [] cannot
91 * say whether the site is clean, the request failed, or nothing was even
92 * attempted — the endpoint reported all three as "retrieved successfully"
93 * (#519). Callers read this the way Performance_Data_Collector exposes its
94 * own last error.
95 *
96 * @var array{code: string, message: string}
97 */
98 private array $last_error = ['code' => '', 'message' => ''];
99
100 /**
101 * Core Web Vitals thresholds (2025 Google standards)
102 * Lazy-loaded to reduce memory usage
103 *
104 * @since 1.0.0
105 * @var array|null
106 */
107 private ?array $core_web_vitals = null;
108
109 /**
110 * SEO performance metrics with tracking specifications
111 *
112 * @since 1.0.0
113 * @var array
114 */
115 private array $seo_metrics = [
116 'page_speed_score' => [
117 'name' => 'Page Speed Score',
118 'unit' => 'score',
119 'target' => 90,
120 'weight' => 20,
121 'source' => 'pagespeed_insights'
122 ],
123 'mobile_usability' => [
124 'name' => 'Mobile Usability',
125 'unit' => 'score',
126 'target' => 95,
127 'weight' => 15,
128 'source' => 'mobile_friendly_test'
129 ],
130 'seo_score' => [
131 'name' => 'SEO Score',
132 'unit' => 'score',
133 'target' => 85,
134 'weight' => 25,
135 'source' => 'lighthouse'
136 ],
137 'accessibility_score' => [
138 'name' => 'Accessibility Score',
139 'unit' => 'score',
140 'target' => 90,
141 'weight' => 15,
142 'source' => 'lighthouse'
143 ],
144 'best_practices_score' => [
145 'name' => 'Best Practices Score',
146 'unit' => 'score',
147 'target' => 95,
148 'weight' => 10,
149 'source' => 'lighthouse'
150 ],
151 'pwa_score' => [
152 'name' => 'PWA Score',
153 'unit' => 'score',
154 'target' => 80,
155 'weight' => 15,
156 'source' => 'lighthouse'
157 ]
158 ];
159
160 /**
161 * Performance monitoring configuration
162 *
163 * @since 1.0.0
164 * @var array
165 */
166 private array $monitoring_config = [
167 'measurement_frequency' => [
168 'real_time' => 300, // 5 minutes for real-time monitoring
169 'hourly' => 3600, // 1 hour for regular checks
170 'daily' => 86400, // 24 hours for daily reports
171 'weekly' => 604800 // 7 days for weekly analysis
172 ],
173 'alert_thresholds' => [
174 'critical' => 40, // Below 40% performance score
175 'warning' => 60, // Below 60% performance score
176 'good' => 80, // Above 80% performance score
177 'excellent' => 90 // Above 90% performance score
178 ],
179 'data_retention' => [
180 'real_time_data' => 7, // 7 days of real-time data
181 'hourly_data' => 30, // 30 days of hourly data
182 'daily_data' => 365, // 1 year of daily data
183 'weekly_data' => 1095 // 3 years of weekly data
184 ],
185 'api_endpoints' => [
186 'pagespeed_insights' => 'https://www.googleapis.com/pagespeedonline/v5/runPagespeed',
187 'mobile_friendly' => 'https://searchconsole.googleapis.com/v1/urlTestingTools/mobileFriendlyTest:run',
188 'core_web_vitals' => 'https://chromeuxreport.googleapis.com/v1/records:queryRecord'
189 ]
190 ];
191
192 /**
193 * Performance benchmarks by industry and content type
194 *
195 * @since 1.0.0
196 * @var array
197 */
198 private array $performance_benchmarks = [
199 'e_commerce' => [
200 'lcp' => 2.2,
201 'inp' => 160,
202 'cls' => 0.08,
203 'page_speed' => 85,
204 'conversion_impact' => 'high'
205 ],
206 'blog' => [
207 'lcp' => 2.0,
208 'inp' => 140,
209 'cls' => 0.05,
210 'page_speed' => 90,
211 'conversion_impact' => 'medium'
212 ],
213 'corporate' => [
214 'lcp' => 1.8,
215 'inp' => 120,
216 'cls' => 0.03,
217 'page_speed' => 95,
218 'conversion_impact' => 'medium'
219 ],
220 'news' => [
221 'lcp' => 1.5,
222 'inp' => 100,
223 'cls' => 0.02,
224 'page_speed' => 92,
225 'conversion_impact' => 'low'
226 ]
227 ];
228
229 /**
230 * Settings Manager instance
231 * Phase 4: Integration with Settings Manager for Google API keys
232 *
233 * @since 1.0.0
234 * @var Settings_Manager
235 */
236 private Settings_Manager $settings_manager;
237
238 /**
239 * Constructor
240 *
241 * @since 1.0.0
242 */
243 public function __construct() {
244 parent::__construct('performance_monitoring_manager');
245 $this->settings_manager = new Settings_Manager();
246 }
247 private function get_user_friendly_error(\Exception $exception): array {
248 $message = $exception->getMessage();
249
250 if (stripos($message, 'rate limit') !== false || stripos($message, 'quota') !== false) {
251 return [
252 'type' => 'rate_limit',
253 'title' => 'API Rate Limit Exceeded',
254 'message' => 'Google API rate limit exceeded. Please try again in a few minutes.',
255 'action' => 'retry_later'
256 ];
257 }
258
259 // A credential that Google rejected is not a missing one. Matching
260 // "API key" alone sent an invalid or expired key to the "connect your
261 // Google account" copy, which is no help to a site that configured a
262 // key on purpose (#519).
263 if (stripos($message, 'not valid') !== false
264 || stripos($message, 'invalid') !== false
265 || stripos($message, 'expired') !== false
266 || stripos($message, 'unauthorized') !== false
267 || stripos($message, 'API key') !== false
268 ) {
269 return [
270 'type' => 'configuration',
271 'title' => 'PageSpeed Credential Rejected',
272 'message' => 'Google rejected the PageSpeed credential. Check the PageSpeed API key, or reconnect your Google account, in Integrations > Google Services.',
273 'action' => 'check_pagespeed_credential'
274 ];
275 }
276
277 if (stripos($message, 'not connected') !== false || stripos($message, 'not configured') !== false) {
278 return [
279 'type' => 'configuration',
280 'title' => 'PageSpeed Not Configured',
281 'message' => 'Add a PageSpeed API key or connect your Google account in Integrations > Google Services to view performance data.',
282 'action' => 'configure_pagespeed'
283 ];
284 }
285
286 if (strpos($message, 'network') !== false || strpos($message, 'timeout') !== false) {
287 return [
288 'type' => 'network',
289 'title' => 'Network Connection Issue',
290 'message' => 'Unable to connect to Google services. Please check your internet connection.',
291 'action' => 'check_connection'
292 ];
293 }
294
295 return [
296 'type' => 'general',
297 'title' => 'Performance Data Unavailable',
298 'message' => 'Unable to retrieve performance data at this time. Please try again later.',
299 'action' => 'retry'
300 ];
301 }
302
303 /**
304 * Get Core Web Vitals configuration (lazy-loaded)
305 *
306 * @return array Core Web Vitals configuration
307 */
308 private function get_core_web_vitals_config(): array {
309 if ($this->core_web_vitals === null) {
310 $this->core_web_vitals = [
311 'lcp' => [
312 'name' => 'Largest Contentful Paint',
313 'unit' => 'seconds',
314 'good_threshold' => 2.5,
315 'needs_improvement_threshold' => 4.0,
316 'weight' => 30,
317 'description' => 'Time to render the largest content element'
318 ],
319 'inp' => [
320 'name' => 'Interaction to Next Paint',
321 'unit' => 'milliseconds',
322 'good_threshold' => 200,
323 'needs_improvement_threshold' => 500,
324 'weight' => 25,
325 'description' => 'Responsiveness across all interactions on the page'
326 ],
327 'cls' => [
328 'name' => 'Cumulative Layout Shift',
329 'unit' => 'score',
330 'good_threshold' => 0.1,
331 'needs_improvement_threshold' => 0.25,
332 'weight' => 25,
333 'description' => 'Visual stability of page during loading'
334 ],
335 'fcp' => [
336 'name' => 'First Contentful Paint',
337 'unit' => 'seconds',
338 'good_threshold' => 1.8,
339 'needs_improvement_threshold' => 3.0,
340 'weight' => 20,
341 'description' => 'Time until the first content is painted on screen'
342 ]
343 ];
344 }
345 return $this->core_web_vitals;
346 }
347
348 /**
349 * Monitor performance with comprehensive Core Web Vitals and SEO metrics analysis
350 *
351 * @since 1.0.0
352 *
353 * @param string $url URL to monitor
354 * @param array $options Monitoring options
355 * @param string $device_type Device type (desktop, mobile)
356 * @return array Comprehensive performance monitoring results
357 */
358 public function monitor_performance(string $url, array $options = [], string $device_type = 'desktop'): array {
359 $monitoring = [
360 'url' => $url,
361 'device_type' => $device_type,
362 'core_web_vitals' => [],
363 'seo_metrics' => [],
364 'performance_score' => 0,
365 'performance_grade' => '',
366 'recommendations' => [],
367 'historical_comparison' => [],
368 'benchmark_comparison' => [],
369 'monitoring_timestamp' => current_time('mysql')
370 ];
371
372 // Measure Core Web Vitals
373 $monitoring['core_web_vitals'] = $this->measure_core_web_vitals($url, $device_type, $options);
374
375 // Collect SEO performance metrics
376 $monitoring['seo_metrics'] = $this->collect_seo_metrics($url, $device_type, $options);
377
378 // Calculate overall performance score
379 $monitoring['performance_score'] = $this->calculate_performance_score(
380 $monitoring['core_web_vitals'],
381 $monitoring['seo_metrics']
382 );
383
384 // Determine performance grade
385 $monitoring['performance_grade'] = $this->determine_performance_grade($monitoring['performance_score']);
386
387 // Generate performance recommendations
388 $monitoring['recommendations'] = $this->generate_performance_recommendations(
389 $monitoring['core_web_vitals'],
390 $monitoring['seo_metrics'],
391 $url
392 );
393
394 // Compare with historical data
395 $monitoring['historical_comparison'] = $this->compare_with_historical_data($url, $monitoring);
396
397 // Compare with industry benchmarks
398 $monitoring['benchmark_comparison'] = $this->compare_with_benchmarks($monitoring, $options);
399
400 // Store performance data
401 $this->store_performance_data($url, $monitoring);
402
403 return $monitoring;
404 }
405
406 /**
407 * Generate automated performance reports
408 *
409 * @since 1.0.0
410 *
411 * @param string $context_type Context type
412 * @param int|null $context_id Context ID
413 * @param string $report_type Report type (daily, weekly, monthly)
414 * @param array $options Report options
415 * @return array Automated performance report
416 */
417 public function generate_performance_report(string $context_type, ?int $context_id, string $report_type = 'weekly', array $options = []): array {
418 $report = [
419 'report_type' => $report_type,
420 'context_type' => $context_type,
421 'context_id' => $context_id,
422 'report_period' => [],
423 'performance_summary' => [],
424 'core_web_vitals_analysis' => [],
425 'seo_metrics_analysis' => [],
426 'performance_trends' => [],
427 'optimization_opportunities' => [],
428 'competitive_analysis' => [],
429 'action_items' => [],
430 'report_timestamp' => current_time('mysql')
431 ];
432
433 // Define report period
434 $report['report_period'] = $this->define_report_period($report_type);
435
436 // Generate performance summary
437 $report['performance_summary'] = $this->generate_performance_summary($context_type, $context_id, $report['report_period']);
438
439 // Analyze Core Web Vitals trends
440 $report['core_web_vitals_analysis'] = $this->analyze_core_web_vitals_trends($context_type, $context_id, $report['report_period']);
441
442 // Analyze SEO metrics trends
443 $report['seo_metrics_analysis'] = $this->analyze_seo_metrics_trends($context_type, $context_id, $report['report_period']);
444
445 // Calculate performance trends
446 $report['performance_trends'] = $this->calculate_performance_trends($context_type, $context_id, $report['report_period']);
447
448 // Identify optimization opportunities
449 $report['optimization_opportunities'] = $this->identify_optimization_opportunities($report['performance_summary'], $report['performance_trends']);
450
451 // Perform competitive analysis
452 $report['competitive_analysis'] = $this->perform_competitive_analysis($context_type, $context_id, $options);
453
454 // Generate action items
455 $report['action_items'] = $this->generate_action_items($report['optimization_opportunities'], $report['competitive_analysis']);
456
457 // Store report data
458 $this->store_report_data($report);
459
460 return $report;
461 }
462
463 /**
464 * Set up performance alerts and notifications
465 *
466 * @since 1.0.0
467 *
468 * @param string $context_type Context type
469 * @param int|null $context_id Context ID
470 * @param array $alert_config Alert configuration
471 * @return array Alert setup results
472 */
473 public function setup_performance_alerts(string $context_type, ?int $context_id, array $alert_config): array {
474 $alerts = [
475 'context_type' => $context_type,
476 'context_id' => $context_id,
477 'alert_rules' => [],
478 'notification_channels' => [],
479 'alert_history' => [],
480 'active_alerts' => [],
481 'setup_timestamp' => current_time('mysql')
482 ];
483
484 // Configure alert rules
485 $alerts['alert_rules'] = $this->configure_alert_rules($alert_config);
486
487 // Set up notification channels
488 $alerts['notification_channels'] = $this->setup_notification_channels($alert_config);
489
490 // Get alert history
491 $alerts['alert_history'] = $this->get_alert_history($context_type, $context_id);
492
493 // Check for active alerts
494 $alerts['active_alerts'] = $this->check_active_alerts($context_type, $context_id);
495
496 return $alerts;
497 }
498
499 /**
500 * Validate SEO settings (implements interface)
501 *
502 * @since 1.0.0
503 *
504 * @param array $settings Settings array to validate
505 * @return array Validation results
506 */
507 public function validate_settings(array $settings): array {
508 $validation = [
509 'valid' => true,
510 'errors' => [],
511 'warnings' => [],
512 'suggestions' => [],
513 'score' => 100
514 ];
515
516 // Validate monitoring frequency
517 if (isset($settings['monitoring_frequency'])) {
518 if (!is_numeric($settings['monitoring_frequency']) || $settings['monitoring_frequency'] < 300) {
519 $validation['errors'][] = 'Monitoring frequency must be at least 300 seconds (5 minutes)';
520 $validation['valid'] = false;
521 }
522 }
523
524 // Validate alert thresholds
525 if (isset($settings['alert_thresholds']) && is_array($settings['alert_thresholds'])) {
526 foreach ($settings['alert_thresholds'] as $threshold => $value) {
527 if (!is_numeric($value) || $value < 0 || $value > 100) {
528 $validation['errors'][] = "Invalid alert threshold for {$threshold}: must be 0-100";
529 $validation['valid'] = false;
530 }
531 }
532 }
533
534 // Validate API configuration
535 if (isset($settings['api_keys']) && is_array($settings['api_keys'])) {
536 if (empty($settings['api_keys']['pagespeed_insights'])) {
537 $validation['warnings'][] = 'PageSpeed Insights API key not configured - some features may be limited';
538 }
539 if (empty($settings['api_keys']['search_console'])) {
540 $validation['warnings'][] = 'Search Console API key not configured - Core Web Vitals field data unavailable';
541 }
542 }
543
544 // Validate data retention settings
545 if (isset($settings['data_retention']) && is_array($settings['data_retention'])) {
546 foreach ($settings['data_retention'] as $type => $days) {
547 if (!is_numeric($days) || $days < 1 || $days > 3650) {
548 $validation['errors'][] = "Invalid data retention for {$type}: must be 1-3650 days";
549 $validation['valid'] = false;
550 }
551 }
552 }
553
554 // Validate performance targets
555 if (isset($settings['performance_targets']) && is_array($settings['performance_targets'])) {
556 foreach ($this->get_core_web_vitals_config() as $metric => $config) {
557 if (isset($settings['performance_targets'][$metric])) {
558 $target = $settings['performance_targets'][$metric];
559 if (!is_numeric($target) || $target <= 0) {
560 $validation['errors'][] = "Invalid performance target for {$metric}: must be positive number";
561 $validation['valid'] = false;
562 }
563 }
564 }
565 }
566
567 // Calculate validation score
568 $validation['score'] = $this->calculate_validation_score($validation);
569
570 return $validation;
571 }
572
573 /**
574 * Get output data for frontend rendering (implements interface)
575 *
576 * @since 1.0.0
577 *
578 * @param string $context_type The context type
579 * @param int|null $context_id Optional. Context ID
580 * @return array Output data ready for frontend rendering
581 */
582 public function get_output_data(string $context_type, ?int $context_id): array {
583 $settings = $this->get_settings($context_type, $context_id);
584
585 $output = [
586 'performance_dashboard' => [],
587 'core_web_vitals' => [],
588 'seo_metrics' => [],
589 'performance_trends' => [],
590 'alerts' => [],
591 'recommendations' => [],
592 'enabled' => $settings['enabled'] ?? true
593 ];
594
595 if (!$output['enabled']) {
596 return $output;
597 }
598
599 // Get URL for monitoring
600 $url = $this->get_monitoring_url($context_type, $context_id);
601
602 if (!empty($url)) {
603 // Get current performance data
604 $performance_data = $this->get_current_performance_data($url, $context_type, $context_id);
605
606 // Generate performance dashboard
607 $output['performance_dashboard'] = $this->generate_performance_dashboard($performance_data);
608
609 // Get Core Web Vitals data
610 $output['core_web_vitals'] = $this->get_core_web_vitals_data($url, $context_type, $context_id);
611
612 // Get SEO metrics data
613 $output['seo_metrics'] = $this->get_seo_metrics_data($url, $context_type, $context_id);
614
615 // Get performance trends
616 $output['performance_trends'] = $this->get_performance_trends_data($context_type, $context_id);
617
618 // Get active alerts
619 $output['alerts'] = $this->get_active_alerts_data($context_type, $context_id);
620
621 // Get performance recommendations
622 $output['recommendations'] = $this->get_performance_recommendations_data($performance_data);
623 }
624
625 return $output;
626 }
627
628 /**
629 * Get default settings for a context type (implements interface)
630 *
631 * @since 1.0.0
632 *
633 * @param string $context_type The context type to get defaults for
634 * @return array Default settings array
635 */
636 public function get_default_settings(string $context_type): array {
637 $defaults = [
638 'enabled' => true,
639 'monitoring_frequency' => 3600, // 1 hour
640 'real_time_monitoring' => false,
641 'automated_reports' => true,
642 'performance_alerts' => true,
643 'core_web_vitals_tracking' => true,
644 'seo_metrics_tracking' => true,
645 'competitive_analysis' => false,
646 'alert_thresholds' => $this->monitoring_config['alert_thresholds'],
647 'data_retention' => $this->monitoring_config['data_retention'],
648 'performance_targets' => [
649 'lcp' => 2.5,
650 'inp' => 200,
651 'cls' => 0.1,
652 'fcp' => 1.8,
653 'page_speed_score' => 90
654 ],
655 'notification_settings' => [
656 'email_alerts' => true,
657 'dashboard_notifications' => true,
658 'weekly_reports' => true,
659 'monthly_reports' => false
660 ]
661 ];
662
663 // Context-specific defaults
664 switch ($context_type) {
665 case 'site':
666 $defaults['monitoring_frequency'] = 1800; // 30 minutes for site-wide
667 $defaults['competitive_analysis'] = true;
668 $defaults['monthly_reports'] = true;
669 break;
670 case 'post':
671 $defaults['monitoring_frequency'] = 3600; // 1 hour for posts
672 $defaults['real_time_monitoring'] = false;
673 break;
674 case 'page':
675 $defaults['monitoring_frequency'] = 1800; // 30 minutes for pages
676 $defaults['real_time_monitoring'] = true;
677 break;
678 case 'product':
679 $defaults['monitoring_frequency'] = 900; // 15 minutes for products
680 $defaults['real_time_monitoring'] = true;
681 $defaults['performance_targets']['lcp'] = 2.0; // Stricter for e-commerce
682 break;
683 }
684
685 return $defaults;
686 }
687
688 /**
689 * Get settings schema definition (implements interface)
690 *
691 * @since 1.0.0
692 *
693 * @param string $context_type The context type to get schema for
694 * @return array Settings schema definition
695 */
696 public function get_settings_schema(string $context_type): array {
697 return [
698 'enabled' => [
699 'type' => 'boolean',
700 'title' => 'Enable Performance Monitoring',
701 'description' => 'Enable comprehensive performance monitoring and Core Web Vitals tracking',
702 'default' => true
703 ],
704 'monitoring_frequency' => [
705 'type' => 'integer',
706 'title' => 'Monitoring Frequency',
707 'description' => 'How often to check performance metrics (in seconds)',
708 'minimum' => 300,
709 'maximum' => 86400,
710 'default' => 3600
711 ],
712 'real_time_monitoring' => [
713 'type' => 'boolean',
714 'title' => 'Real-time Monitoring',
715 'description' => 'Enable real-time performance monitoring for critical pages',
716 'default' => false
717 ],
718 'automated_reports' => [
719 'type' => 'boolean',
720 'title' => 'Automated Reports',
721 'description' => 'Generate automated performance reports',
722 'default' => true
723 ],
724 'performance_alerts' => [
725 'type' => 'boolean',
726 'title' => 'Performance Alerts',
727 'description' => 'Send alerts when performance thresholds are exceeded',
728 'default' => true
729 ],
730 'core_web_vitals_tracking' => [
731 'type' => 'boolean',
732 'title' => 'Core Web Vitals Tracking',
733 'description' => 'Track Core Web Vitals metrics (LCP, INP, CLS, FCP)',
734 'default' => true
735 ],
736 'seo_metrics_tracking' => [
737 'type' => 'boolean',
738 'title' => 'SEO Metrics Tracking',
739 'description' => 'Track SEO performance metrics and scores',
740 'default' => true
741 ],
742 'competitive_analysis' => [
743 'type' => 'boolean',
744 'title' => 'Competitive Analysis',
745 'description' => 'Compare performance with industry benchmarks',
746 'default' => false
747 ],
748 'lcp_target' => [
749 'type' => 'number',
750 'title' => 'LCP Target (seconds)',
751 'description' => 'Target Largest Contentful Paint time',
752 'minimum' => 0.5,
753 'maximum' => 10.0,
754 'default' => 2.5
755 ],
756 'inp_target' => [
757 'type' => 'integer',
758 'title' => 'INP Target (milliseconds)',
759 'description' => 'Target Interaction to Next Paint time',
760 'minimum' => 10,
761 'maximum' => 1000,
762 'default' => 200
763 ],
764 'cls_target' => [
765 'type' => 'number',
766 'title' => 'CLS Target (score)',
767 'description' => 'Target Cumulative Layout Shift score',
768 'minimum' => 0.0,
769 'maximum' => 1.0,
770 'default' => 0.1
771 ]
772 ];
773 }
774
775 /**
776 * Measure Core Web Vitals using real algorithms
777 *
778 * @since 1.0.0
779 *
780 * @param string $url URL to measure
781 * @param string $device_type Device type
782 * @param array $options Measurement options
783 * @return array Core Web Vitals measurements
784 */
785 private function measure_core_web_vitals(string $url, string $device_type, array $options): array {
786 $vitals = [];
787
788 foreach ($this->get_core_web_vitals_config() as $metric => $config) {
789 $vitals[$metric] = [
790 'name' => $config['name'],
791 'value' => 0,
792 'unit' => $config['unit'],
793 'status' => 'unknown',
794 'percentile' => 0,
795 'field_data' => null,
796 'lab_data' => null,
797 'score' => 0
798 ];
799
800 // Get field data from Chrome UX Report
801 $field_data = $this->get_field_data_for_metric($url, $metric, $device_type);
802 if ($field_data) {
803 $vitals[$metric]['field_data'] = $field_data;
804 $vitals[$metric]['value'] = $field_data['percentile_75'] ?? 0;
805 $vitals[$metric]['percentile'] = 75;
806 }
807
808 // Get lab data from PageSpeed Insights
809 $lab_data = $this->get_lab_data_for_metric($url, $metric, $device_type);
810 if ($lab_data) {
811 $vitals[$metric]['lab_data'] = $lab_data;
812 if (!$vitals[$metric]['value']) {
813 $vitals[$metric]['value'] = $lab_data['value'] ?? 0;
814 }
815 }
816
817 // Determine status based on thresholds
818 $vitals[$metric]['status'] = $this->determine_metric_status($vitals[$metric]['value'], $config);
819
820 // Calculate metric score
821 $vitals[$metric]['score'] = $this->calculate_metric_score($vitals[$metric]['value'], $config);
822 }
823
824 return $vitals;
825 }
826
827 /**
828 * Collect SEO performance metrics
829 *
830 * @since 1.0.0
831 *
832 * @param string $url URL to analyze
833 * @param string $device_type Device type
834 * @param array $options Collection options
835 * @return array SEO metrics
836 */
837 private function collect_seo_metrics(string $url, string $device_type, array $options): array {
838 $metrics = [];
839
840 foreach ($this->seo_metrics as $metric => $config) {
841 $metrics[$metric] = [
842 'name' => $config['name'],
843 'value' => 0,
844 'unit' => $config['unit'],
845 'target' => $config['target'],
846 'status' => 'unknown',
847 'score' => 0,
848 'source' => $config['source']
849 ];
850
851 // Collect metric based on source
852 switch ($config['source']) {
853 case 'pagespeed_insights':
854 $data = $this->get_pagespeed_insights_data($url, $device_type);
855 $metrics[$metric]['value'] = $data[$metric] ?? 0;
856 break;
857 case 'lighthouse':
858 $data = $this->get_lighthouse_data($url, $device_type);
859 $metrics[$metric]['value'] = $data[$metric] ?? 0;
860 break;
861 case 'mobile_friendly_test':
862 $data = $this->get_mobile_friendly_data($url);
863 $metrics[$metric]['value'] = $data[$metric] ?? 0;
864 break;
865 }
866
867 // Determine status
868 $metrics[$metric]['status'] = $this->determine_seo_metric_status($metrics[$metric]['value'], $config['target']);
869
870 // Calculate score
871 $metrics[$metric]['score'] = $this->calculate_seo_metric_score($metrics[$metric]['value'], $config['target']);
872 }
873
874 return $metrics;
875 }
876
877 /**
878 * Calculate overall performance score
879 *
880 * @since 1.0.0
881 *
882 * @param array $core_web_vitals Core Web Vitals data
883 * @param array $seo_metrics SEO metrics data
884 * @return int Overall performance score (0-100)
885 */
886 private function calculate_performance_score(array $core_web_vitals, array $seo_metrics): int {
887 $scores = [];
888 $total_weight = 0;
889
890 // Weight Core Web Vitals scores
891 foreach ($core_web_vitals as $metric => $data) {
892 // Skip non-array values (like 'error' and 'message' keys)
893 if (!is_array($data) || !isset($data['score'])) {
894 continue;
895 }
896 $weight = $this->get_core_web_vitals_config()[$metric]['weight'] ?? 0;
897 $scores[] = $data['score'] * $weight;
898 $total_weight += $weight;
899 }
900
901 // Weight SEO metrics scores
902 foreach ($seo_metrics as $metric => $data) {
903 $weight = $this->seo_metrics[$metric]['weight'] ?? 0;
904 $scores[] = $data['score'] * $weight;
905 $total_weight += $weight;
906 }
907
908 return $total_weight > 0 ? (int) round(array_sum($scores) / $total_weight) : 0;
909 }
910
911 /**
912 * Determine performance grade based on score
913 *
914 * @since 1.0.0
915 *
916 * @param int $score Performance score
917 * @return string Performance grade
918 */
919 private function determine_performance_grade(int $score): string {
920 if ($score >= 90) {
921 return 'A';
922 } elseif ($score >= 80) {
923 return 'B';
924 } elseif ($score >= 70) {
925 return 'C';
926 } elseif ($score >= 60) {
927 return 'D';
928 } else {
929 return 'F';
930 }
931 }
932
933 /**
934 * Generate performance recommendations
935 *
936 * @since 1.0.0
937 *
938 * @param array $core_web_vitals Core Web Vitals data
939 * @param array $seo_metrics SEO metrics data
940 * @param string $url URL being analyzed
941 * @return array Performance recommendations
942 */
943 private function generate_performance_recommendations(array $core_web_vitals, array $seo_metrics, string $url): array {
944 $recommendations = [];
945
946 // Core Web Vitals recommendations
947 foreach ($core_web_vitals as $metric => $data) {
948 // Skip non-array values (like 'error' and 'message' keys)
949 if (!is_array($data) || !isset($data['status'])) {
950 continue;
951 }
952 if ($data['status'] === 'poor' || $data['status'] === 'needs_improvement') {
953 $recommendations = array_merge($recommendations, $this->get_cwv_recommendations($metric, $data));
954 }
955 }
956
957 // SEO metrics recommendations
958 foreach ($seo_metrics as $metric => $data) {
959 if ($data['status'] === 'poor' || $data['score'] < 80) {
960 $recommendations = array_merge($recommendations, $this->get_seo_recommendations($metric, $data));
961 }
962 }
963
964 // Sort recommendations by priority
965 usort($recommendations, function ($a, $b) {
966 $priority_order = ['critical' => 4, 'high' => 3, 'medium' => 2, 'low' => 1];
967 return ($priority_order[$b['priority']] ?? 0) - ($priority_order[$a['priority']] ?? 0);
968 });
969
970 return $recommendations;
971 }
972
973 /**
974 * Store performance data in database
975 *
976 * @since 1.0.0
977 *
978 * @param string $url URL monitored
979 * @param array $monitoring Monitoring results
980 * @return bool Success status
981 */
982 private function store_performance_data(string $url, array $monitoring): bool {
983 global $wpdb;
984
985 $table_name = $wpdb->prefix . 'thinkrank_seo_performance';
986
987 // Store Core Web Vitals data
988 foreach ($monitoring['core_web_vitals'] as $metric => $data) {
989 // Skip non-array values (like 'error' and 'message' keys)
990 if (!is_array($data) || !isset($data['value'], $data['unit'], $data['status'])) {
991 continue;
992 }
993 $performance_data = [
994 'context_type' => 'url',
995 'context_id' => null,
996 'metric_type' => $metric,
997 'metric_value' => $data['value'],
998 'metric_unit' => $data['unit'],
999 'threshold_good' => $this->get_core_web_vitals_config()[$metric]['good_threshold'],
1000 'threshold_poor' => $this->get_core_web_vitals_config()[$metric]['needs_improvement_threshold'],
1001 'status' => $data['status'],
1002 'device_type' => $monitoring['device_type'],
1003 'measured_by' => 'performance_monitoring_manager'
1004 ];
1005
1006 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Performance data storage requires direct database access
1007 $wpdb->insert($table_name, $performance_data);
1008 }
1009
1010 // Store SEO metrics data
1011 foreach ($monitoring['seo_metrics'] as $metric => $data) {
1012 $performance_data = [
1013 'context_type' => 'url',
1014 'context_id' => null,
1015 'metric_type' => $metric,
1016 'metric_value' => $data['value'],
1017 'metric_unit' => $data['unit'],
1018 'threshold_good' => $data['target'],
1019 'threshold_poor' => $data['target'] * 0.7, // 70% of target as poor threshold
1020 'status' => $data['status'],
1021 'device_type' => $monitoring['device_type'],
1022 'measured_by' => 'performance_monitoring_manager'
1023 ];
1024
1025 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Performance data storage requires direct database access
1026 $wpdb->insert($table_name, $performance_data);
1027 }
1028
1029 return true;
1030 }
1031
1032 /**
1033 * Get monitoring URL for context
1034 *
1035 * @since 1.0.0
1036 *
1037 * @param string $context_type Context type
1038 * @param int|null $context_id Context ID
1039 * @return string URL to monitor
1040 */
1041 private function get_monitoring_url(string $context_type, ?int $context_id): string {
1042 switch ($context_type) {
1043 case 'site':
1044 return home_url();
1045 case 'post':
1046 case 'page':
1047 case 'product':
1048 if ($context_id) {
1049 return get_permalink($context_id) ?: '';
1050 }
1051 break;
1052 }
1053
1054 return '';
1055 }
1056
1057 /**
1058 * Calculate validation score
1059 *
1060 * @since 1.0.0
1061 *
1062 * @param array $validation Validation results
1063 * @return int Score (0-100)
1064 */
1065 private function calculate_validation_score(array $validation): int {
1066 $score = 100;
1067 $score -= count($validation['errors']) * 20;
1068 $score -= count($validation['warnings']) * 10;
1069 $score -= count($validation['suggestions']) * 5;
1070
1071 return max(0, $score);
1072 }
1073
1074 /**
1075 * Determine metric status based on thresholds
1076 *
1077 * @since 1.0.0
1078 *
1079 * @param float $value Metric value
1080 * @param array $config Metric configuration
1081 * @return string Status (good, needs_improvement, poor)
1082 */
1083 private function determine_metric_status(float $value, array $config): string {
1084 if ($value <= $config['good_threshold']) {
1085 return 'good';
1086 } elseif ($value <= $config['needs_improvement_threshold']) {
1087 return 'needs_improvement';
1088 } else {
1089 return 'poor';
1090 }
1091 }
1092
1093 /**
1094 * Calculate metric score based on value and thresholds
1095 *
1096 * @since 1.0.0
1097 *
1098 * @param float $value Metric value
1099 * @param array $config Metric configuration
1100 * @return int Score (0-100)
1101 */
1102 private function calculate_metric_score(float $value, array $config): int {
1103 $good_threshold = $config['good_threshold'];
1104 $poor_threshold = $config['needs_improvement_threshold'];
1105
1106 if ($value <= $good_threshold) {
1107 return 100;
1108 } elseif ($value <= $poor_threshold) {
1109 // Linear interpolation between good and poor thresholds
1110 $range = $poor_threshold - $good_threshold;
1111 $position = $value - $good_threshold;
1112 return (int) round(100 - (($position / $range) * 50));
1113 } else {
1114 // Exponential decay for values beyond poor threshold
1115 $excess = $value - $poor_threshold;
1116 $decay_factor = min(50, $excess * 10);
1117 return max(0, 50 - (int) round($decay_factor));
1118 }
1119 }
1120
1121 /**
1122 * Determine SEO metric status
1123 *
1124 * @since 1.0.0
1125 *
1126 * @param float $value Metric value
1127 * @param float $target Target value
1128 * @return string Status
1129 */
1130 private function determine_seo_metric_status(float $value, float $target): string {
1131 if ($value >= $target) {
1132 return 'excellent';
1133 } elseif ($value >= $target * 0.9) {
1134 return 'good';
1135 } elseif ($value >= $target * 0.7) {
1136 return 'fair';
1137 } else {
1138 return 'poor';
1139 }
1140 }
1141
1142 /**
1143 * Calculate SEO metric score
1144 *
1145 * @since 1.0.0
1146 *
1147 * @param float $value Metric value
1148 * @param float $target Target value
1149 * @return int Score (0-100)
1150 */
1151 private function calculate_seo_metric_score(float $value, float $target): int {
1152 if ($value >= $target) {
1153 return 100;
1154 } else {
1155 return (int) round(($value / $target) * 100);
1156 }
1157 }
1158
1159 /**
1160 * Get Core Web Vitals recommendations
1161 *
1162 * @since 1.0.0
1163 *
1164 * @param string $metric Metric name
1165 * @param array $data Metric data
1166 * @return array Recommendations
1167 */
1168 private function get_cwv_recommendations(string $metric, array $data): array {
1169 $recommendations = [];
1170
1171 switch ($metric) {
1172 case 'lcp':
1173 if ($data['value'] > 4.0) {
1174 $recommendations[] = [
1175 'type' => 'lcp_optimization',
1176 'priority' => 'critical',
1177 'message' => 'Largest Contentful Paint is critically slow',
1178 'action' => 'Optimize server response time and reduce resource load times',
1179 'impact' => 'high',
1180 'effort' => 'high'
1181 ];
1182 } elseif ($data['value'] > 2.5) {
1183 $recommendations[] = [
1184 'type' => 'lcp_optimization',
1185 'priority' => 'high',
1186 'message' => 'Largest Contentful Paint needs improvement',
1187 'action' => 'Optimize images and reduce render-blocking resources',
1188 'impact' => 'medium',
1189 'effort' => 'medium'
1190 ];
1191 }
1192 break;
1193
1194 case 'inp':
1195 if ($data['value'] > 500) {
1196 $recommendations[] = [
1197 'type' => 'inp_optimization',
1198 'priority' => 'critical',
1199 'message' => 'Interaction to Next Paint is critically high',
1200 'action' => 'Reduce JavaScript execution time and optimize main thread',
1201 'impact' => 'high',
1202 'effort' => 'high'
1203 ];
1204 } elseif ($data['value'] > 200) {
1205 $recommendations[] = [
1206 'type' => 'inp_optimization',
1207 'priority' => 'high',
1208 'message' => 'Interaction to Next Paint needs improvement',
1209 'action' => 'Break up long tasks and defer non-critical JavaScript',
1210 'impact' => 'medium',
1211 'effort' => 'medium'
1212 ];
1213 }
1214 break;
1215
1216 case 'cls':
1217 if ($data['value'] > 0.25) {
1218 $recommendations[] = [
1219 'type' => 'cls_optimization',
1220 'priority' => 'critical',
1221 'message' => 'Cumulative Layout Shift is critically high',
1222 'action' => 'Set explicit dimensions for images and ads, avoid inserting content above existing content',
1223 'impact' => 'high',
1224 'effort' => 'medium'
1225 ];
1226 } elseif ($data['value'] > 0.1) {
1227 $recommendations[] = [
1228 'type' => 'cls_optimization',
1229 'priority' => 'high',
1230 'message' => 'Cumulative Layout Shift needs improvement',
1231 'action' => 'Optimize font loading and reserve space for dynamic content',
1232 'impact' => 'medium',
1233 'effort' => 'low'
1234 ];
1235 }
1236 break;
1237
1238 case 'fcp':
1239 if ($data['value'] > 3.0) {
1240 $recommendations[] = [
1241 'type' => 'fcp_optimization',
1242 'priority' => 'critical',
1243 'message' => 'First Contentful Paint is critically slow',
1244 'action' => 'Optimize server response time, reduce render-blocking resources, and improve resource delivery',
1245 'impact' => 'high',
1246 'effort' => 'high'
1247 ];
1248 } elseif ($data['value'] > 1.8) {
1249 $recommendations[] = [
1250 'type' => 'fcp_optimization',
1251 'priority' => 'high',
1252 'message' => 'First Contentful Paint needs improvement',
1253 'action' => 'Optimize critical rendering path and reduce server response time',
1254 'impact' => 'medium',
1255 'effort' => 'medium'
1256 ];
1257 }
1258 break;
1259 }
1260
1261 return $recommendations;
1262 }
1263
1264 /**
1265 * Get SEO recommendations
1266 *
1267 * @since 1.0.0
1268 *
1269 * @param string $metric Metric name
1270 * @param array $data Metric data
1271 * @return array Recommendations
1272 */
1273 private function get_seo_recommendations(string $metric, array $data): array {
1274 $recommendations = [];
1275
1276 switch ($metric) {
1277 case 'page_speed_score':
1278 if ($data['value'] < 50) {
1279 $recommendations[] = [
1280 'type' => 'page_speed',
1281 'priority' => 'critical',
1282 'message' => 'Page speed score is critically low',
1283 'action' => 'Implement comprehensive performance optimization',
1284 'impact' => 'high',
1285 'effort' => 'high'
1286 ];
1287 } elseif ($data['value'] < 80) {
1288 $recommendations[] = [
1289 'type' => 'page_speed',
1290 'priority' => 'high',
1291 'message' => 'Page speed score needs improvement',
1292 'action' => 'Optimize images, minify CSS/JS, and enable compression',
1293 'impact' => 'medium',
1294 'effort' => 'medium'
1295 ];
1296 }
1297 break;
1298
1299 case 'seo_score':
1300 if ($data['value'] < 60) {
1301 $recommendations[] = [
1302 'type' => 'seo_optimization',
1303 'priority' => 'high',
1304 'message' => 'SEO score is below target',
1305 'action' => 'Improve meta tags, headings, and content structure',
1306 'impact' => 'high',
1307 'effort' => 'medium'
1308 ];
1309 }
1310 break;
1311
1312 case 'accessibility_score':
1313 if ($data['value'] < 70) {
1314 $recommendations[] = [
1315 'type' => 'accessibility',
1316 'priority' => 'medium',
1317 'message' => 'Accessibility score needs improvement',
1318 'action' => 'Add alt text to images, improve color contrast, and enhance keyboard navigation',
1319 'impact' => 'medium',
1320 'effort' => 'low'
1321 ];
1322 }
1323 break;
1324 }
1325
1326 return $recommendations;
1327 }
1328
1329 /**
1330 * Simple implementations for methods referenced but not yet implemented
1331 * These would be enhanced with actual API integrations in production
1332 */
1333
1334 private function get_field_data(string $url, string $device_type): array {
1335 try {
1336 if ($this->pagespeed_refusal() !== null) {
1337 return ['field_data' => [], 'available' => false];
1338 }
1339
1340 // Use PageSpeed Insights API which includes Chrome UX Report data.
1341 // The snapshot is shared with CWV/opportunities/diagnostics callers,
1342 // so this does not trigger an extra Lighthouse run.
1343 $pagespeed_client = Google_PageSpeed_Client::for_site();
1344 $loading_experience = $pagespeed_client->get_pagespeed_snapshot($url, $device_type)['loading_experience'];
1345
1346 if (!empty($loading_experience)) {
1347 $metrics = $loading_experience['metrics'] ?? [];
1348
1349 return [
1350 'field_data' => [
1351 'lcp' => $metrics['LARGEST_CONTENTFUL_PAINT_MS'] ?? [],
1352 // INP replaced FID as a Core Web Vital in March 2024 and
1353 // FIRST_INPUT_DELAY_MS was dropped from CrUX entirely in
1354 // September 2024, so asking for it returned nothing and
1355 // the metric rendered permanently empty. The EXPERIMENTAL_
1356 // key is the name CrUX used before INP graduated; kept as
1357 // a fallback so older cached snapshots still resolve.
1358 'inp' => $metrics['INTERACTION_TO_NEXT_PAINT']
1359 ?? $metrics['EXPERIMENTAL_INTERACTION_TO_NEXT_PAINT']
1360 ?? [],
1361 'cls' => $metrics['CUMULATIVE_LAYOUT_SHIFT_SCORE'] ?? [],
1362 'overall_category' => $loading_experience['overall_category'] ?? 'UNKNOWN'
1363 ],
1364 'available' => true
1365 ];
1366 }
1367
1368 return ['field_data' => [], 'available' => false];
1369
1370 } catch (\Exception $e) {
1371 return ['field_data' => [], 'available' => false];
1372 }
1373 }
1374 private function get_field_data_for_metric(string $url, string $metric, string $device_type): ?array {
1375 // No field data available without API key
1376 return null;
1377 }
1378
1379 private function get_lab_data_for_metric(string $url, string $metric, string $device_type): ?array {
1380 // No lab data available without API key
1381 return null;
1382 }
1383
1384 private function get_pagespeed_insights_data(string $url, string $device_type): array {
1385 try {
1386 if ($this->pagespeed_refusal() !== null) {
1387 return ['page_speed_score' => 0];
1388 }
1389
1390 // Create Google PageSpeed client and get performance data (shared
1391 // snapshot — no extra Lighthouse run when CWV was already fetched).
1392 $pagespeed_client = Google_PageSpeed_Client::for_site();
1393 $snapshot = $pagespeed_client->get_pagespeed_snapshot($url, $device_type);
1394
1395 return ['page_speed_score' => $snapshot['performance_score']];
1396 } catch (\Exception $e) {
1397 return ['page_speed_score' => 0];
1398 }
1399 }
1400
1401 private function get_lighthouse_data(string $url, string $device_type): array {
1402 // No Lighthouse data available without API key
1403 return [];
1404 }
1405
1406 private function get_mobile_friendly_data(string $url): array {
1407 // No mobile-friendly data available without API key
1408 return [];
1409 }
1410
1411 private function compare_with_historical_data(string $url, array $monitoring): array {
1412 return ['historical_comparison' => []];
1413 }
1414
1415 private function compare_with_benchmarks(array $monitoring, array $options): array {
1416 return ['benchmark_comparison' => []];
1417 }
1418
1419 // Placeholder implementations for methods referenced in interface methods
1420 private function get_current_performance_data(string $url, string $context_type, ?int $context_id): array {
1421 return ['performance_data' => []];
1422 }
1423
1424 private function generate_performance_dashboard(array $performance_data): array {
1425 return ['dashboard_widgets' => []];
1426 }
1427
1428 private function get_core_web_vitals_data(string $url, string $context_type, ?int $context_id, string $device_type = 'mobile'): array {
1429 try {
1430 $refusal = $this->pagespeed_refusal();
1431 if ($refusal !== null) {
1432 // Neither credential is configured and keyless runs are off. The
1433 // copy names both, since an API key is as good as a connection.
1434 return [
1435 'error' => __('PageSpeed not configured', 'thinkrank'),
1436 'message' => $refusal['message'],
1437 'lcp' => $this->get_empty_metric_data('Largest Contentful Paint', 's', 2.5, 4.0),
1438 'inp' => $this->get_empty_metric_data('Interaction to Next Paint', 'ms', 200, 500),
1439 'cls' => $this->get_empty_metric_data('Cumulative Layout Shift', '', 0.1, 0.25),
1440 'fcp' => $this->get_empty_metric_data('First Contentful Paint', 's', 1.8, 3.0)
1441 ];
1442 }
1443
1444 // Validate device type
1445 $device_type = in_array($device_type, ['mobile', 'desktop'], true) ? $device_type : 'mobile';
1446
1447 // for_site() resolves the credential: API key, then OAuth, then keyless.
1448 $pagespeed_client = Google_PageSpeed_Client::for_site();
1449 $core_web_vitals = $pagespeed_client->get_core_web_vitals($url, $device_type);
1450
1451 // Transform the data to match expected format
1452 return [
1453 'lcp' => [
1454 'name' => $core_web_vitals['lcp']['name'],
1455 'value' => $core_web_vitals['lcp']['value'],
1456 'unit' => $core_web_vitals['lcp']['unit'],
1457 'good_threshold' => $core_web_vitals['lcp']['good_threshold'],
1458 'needs_improvement_threshold' => $core_web_vitals['lcp']['needs_improvement_threshold'],
1459 'description' => $core_web_vitals['lcp']['description'],
1460 'score' => $core_web_vitals['lcp']['score'],
1461 'status' => $this->determine_metric_status($core_web_vitals['lcp']['value'], [
1462 'good_threshold' => $core_web_vitals['lcp']['good_threshold'],
1463 'needs_improvement_threshold' => $core_web_vitals['lcp']['needs_improvement_threshold']
1464 ])
1465 ],
1466 'inp' => [
1467 'name' => $core_web_vitals['inp']['name'],
1468 'value' => $core_web_vitals['inp']['value'],
1469 'unit' => $core_web_vitals['inp']['unit'],
1470 'good_threshold' => $core_web_vitals['inp']['good_threshold'],
1471 'needs_improvement_threshold' => $core_web_vitals['inp']['needs_improvement_threshold'],
1472 'description' => $core_web_vitals['inp']['description'],
1473 'score' => $core_web_vitals['inp']['score'],
1474 'status' => $this->determine_metric_status($core_web_vitals['inp']['value'], [
1475 'good_threshold' => $core_web_vitals['inp']['good_threshold'],
1476 'needs_improvement_threshold' => $core_web_vitals['inp']['needs_improvement_threshold']
1477 ])
1478 ],
1479 'cls' => [
1480 'name' => $core_web_vitals['cls']['name'],
1481 'value' => $core_web_vitals['cls']['value'],
1482 'unit' => $core_web_vitals['cls']['unit'],
1483 'good_threshold' => $core_web_vitals['cls']['good_threshold'],
1484 'needs_improvement_threshold' => $core_web_vitals['cls']['needs_improvement_threshold'],
1485 'description' => $core_web_vitals['cls']['description'],
1486 'score' => $core_web_vitals['cls']['score'],
1487 'status' => $this->determine_metric_status($core_web_vitals['cls']['value'], [
1488 'good_threshold' => $core_web_vitals['cls']['good_threshold'],
1489 'needs_improvement_threshold' => $core_web_vitals['cls']['needs_improvement_threshold']
1490 ])
1491 ],
1492 'fcp' => [
1493 'name' => $core_web_vitals['fcp']['name'],
1494 'value' => $core_web_vitals['fcp']['value'],
1495 'unit' => $core_web_vitals['fcp']['unit'],
1496 'good_threshold' => $core_web_vitals['fcp']['good_threshold'],
1497 'needs_improvement_threshold' => $core_web_vitals['fcp']['needs_improvement_threshold'],
1498 'description' => $core_web_vitals['fcp']['description'],
1499 'score' => $core_web_vitals['fcp']['score'],
1500 'status' => $this->determine_metric_status($core_web_vitals['fcp']['value'], [
1501 'good_threshold' => $core_web_vitals['fcp']['good_threshold'],
1502 'needs_improvement_threshold' => $core_web_vitals['fcp']['needs_improvement_threshold']
1503 ])
1504 ]
1505 ];
1506 } catch (\Exception $e) {
1507 // Get user-friendly error information
1508 $error_info = $this->get_user_friendly_error($e);
1509
1510 return [
1511 'error' => $error_info['title'],
1512 'message' => $error_info['message'],
1513 'error_type' => $error_info['type'],
1514 'suggested_action' => $error_info['action'],
1515 'lcp' => $this->get_empty_metric_data('Largest Contentful Paint', 's', 2.5, 4.0),
1516 'inp' => $this->get_empty_metric_data('Interaction to Next Paint', 'ms', 200, 500),
1517 'cls' => $this->get_empty_metric_data('Cumulative Layout Shift', '', 0.1, 0.25),
1518 'fcp' => $this->get_empty_metric_data('First Contentful Paint', 's', 1.8, 3.0)
1519 ];
1520 }
1521 }
1522
1523 private function get_seo_metrics_data(string $url, string $context_type, ?int $context_id): array {
1524 // No SEO metrics data available without API key
1525 return [];
1526 }
1527
1528 private function get_performance_trends_data(string $context_type, ?int $context_id): array {
1529 return ['trends' => []];
1530 }
1531
1532 private function get_active_alerts_data(string $context_type, ?int $context_id): array {
1533 return ['alerts' => []];
1534 }
1535
1536 private function get_performance_recommendations_data(array $performance_data): array {
1537 return ['recommendations' => []];
1538 }
1539
1540 // Report generation helper methods
1541 private function define_report_period(string $report_type): array {
1542 // time(), not current_time('timestamp'): only the differences below
1543 // matter, and the latter is offset by the site timezone.
1544 $now = time();
1545 switch ($report_type) {
1546 case 'daily':
1547 return ['start' => $now - DAY_IN_SECONDS, 'end' => $now];
1548 case 'weekly':
1549 return ['start' => $now - WEEK_IN_SECONDS, 'end' => $now];
1550 case 'monthly':
1551 return ['start' => $now - MONTH_IN_SECONDS, 'end' => $now];
1552 default:
1553 return ['start' => $now - WEEK_IN_SECONDS, 'end' => $now];
1554 }
1555 }
1556
1557 private function generate_performance_summary(string $context_type, ?int $context_id, array $period): array {
1558 return ['summary' => []];
1559 }
1560
1561 private function analyze_core_web_vitals_trends(string $context_type, ?int $context_id, array $period): array {
1562 return ['cwv_trends' => []];
1563 }
1564
1565 private function analyze_seo_metrics_trends(string $context_type, ?int $context_id, array $period): array {
1566 return ['seo_trends' => []];
1567 }
1568
1569 private function calculate_performance_trends(string $context_type, ?int $context_id, array $period): array {
1570 return ['trends' => []];
1571 }
1572
1573 private function identify_optimization_opportunities(array $summary, array $trends): array {
1574 return ['opportunities' => []];
1575 }
1576
1577 private function perform_competitive_analysis(string $context_type, ?int $context_id, array $options): array {
1578 return ['competitive_data' => []];
1579 }
1580
1581 private function generate_action_items(array $opportunities, array $competitive_analysis): array {
1582 return ['action_items' => []];
1583 }
1584
1585 private function store_report_data(array $report): bool {
1586 // Reports failure because it stores nothing. The single caller discards
1587 // the return, so this changes no behaviour today, but a caller added
1588 // later must not read "stored successfully" from a method with no
1589 // storage in it (#538).
1590 return false;
1591 }
1592
1593 // Alert system helper methods
1594 private function configure_alert_rules(array $alert_config): array {
1595 return ['rules' => []];
1596 }
1597
1598 private function setup_notification_channels(array $alert_config): array {
1599 return ['channels' => []];
1600 }
1601
1602 private function get_alert_history(string $context_type, ?int $context_id): array {
1603 return ['history' => []];
1604 }
1605
1606 private function check_active_alerts(string $context_type, ?int $context_id): array {
1607 return ['active' => []];
1608 }
1609
1610 // ========================================
1611 // PUBLIC API METHODS FOR FRONTEND
1612 // ========================================
1613
1614 /**
1615 * Get Core Web Vitals data (Public API method)
1616 *
1617 * @since 1.0.0
1618 *
1619 * @param string $url Optional URL to analyze (defaults to home URL)
1620 * @param bool $store_data Whether to store the data for historical tracking
1621 * @param string $device_type Device type ('mobile' or 'desktop')
1622 * @return array Core Web Vitals data
1623 */
1624 public function get_core_web_vitals(string $url = '', bool $store_data = false, string $device_type = 'mobile'): array {
1625 if ( empty( $url ) ) {
1626 $url = home_url();
1627 }
1628
1629 // Validate device type
1630 $device_type = in_array($device_type, ['mobile', 'desktop'], true) ? $device_type : 'mobile';
1631
1632 // Check cache first (5-minute TTL for API responses) - include device type in cache key
1633 $cache_key = 'thinkrank_core_web_vitals_' . md5($url . '_' . $device_type);
1634 $cached_data = get_transient($cache_key);
1635
1636 if ($cached_data !== false) {
1637 return $cached_data;
1638 }
1639
1640 $core_web_vitals = $this->get_core_web_vitals_data($url, 'homepage', null, $device_type);
1641
1642 // Cache the result for 5 minutes
1643 if (!empty($core_web_vitals) && !isset($core_web_vitals['error'])) {
1644 set_transient($cache_key, $core_web_vitals, 5 * MINUTE_IN_SECONDS);
1645 }
1646
1647 // Store data for historical tracking if requested
1648 if ($store_data && !empty($core_web_vitals)) {
1649 $this->store_historical_performance_data($core_web_vitals, 'site', null, $device_type);
1650 }
1651
1652 return $core_web_vitals;
1653 }
1654
1655 /**
1656 * Build a full performance snapshot for the monitor endpoint without a
1657 * blocking live audit.
1658 *
1659 * Serving order (fast → slow):
1660 * 1. Warm 5-minute audit cache (a prior audit is still fresh).
1661 * 2. Most recent measurement collected by the daily cron (DB), reshaped
1662 * to the same structure a live audit returns.
1663 * 3. When there is neither cached nor collected data yet, a background
1664 * collection is scheduled and a lightweight "collecting" state is
1665 * returned instead of blocking the request on a 10-40s Lighthouse run.
1666 *
1667 * @since 1.16.2
1668 *
1669 * @param string $device_type Device type ('mobile' or 'desktop').
1670 * @return array Response payload matching the /performance/monitor shape.
1671 */
1672 public function get_performance_snapshot(string $device_type = 'mobile'): array {
1673 $url = home_url();
1674 $device_type = in_array($device_type, ['mobile', 'desktop'], true) ? $device_type : 'mobile';
1675
1676 $stored = null;
1677 $source = 'live';
1678
1679 // 1. Warm audit cache (fast path, no live call).
1680 $cache_key = 'thinkrank_core_web_vitals_' . md5($url . '_' . $device_type);
1681 $core_web_vitals = get_transient($cache_key);
1682
1683 if ($core_web_vitals !== false) {
1684 $source = 'cache';
1685 } else {
1686 // 2. Most recent measurement stored by the daily collector.
1687 $stored = $this->get_latest_stored_core_web_vitals($device_type);
1688
1689 if ($stored !== null) {
1690 $core_web_vitals = $stored['core_web_vitals'];
1691 $source = 'collected';
1692 } elseif ($this->has_pagespeed_credentials()) {
1693 // 3. Connected but nothing collected yet — schedule a background
1694 // collection and return a non-blocking "collecting" state.
1695 $this->schedule_background_collection();
1696 return $this->build_collecting_snapshot($device_type);
1697 } else {
1698 // Not connected — the (internally cached) getter returns the
1699 // error structure that drives the "connect Google" empty state.
1700 $core_web_vitals = $this->get_core_web_vitals($url, false, $device_type);
1701 $source = 'live';
1702 }
1703 }
1704
1705 // Prefer the real stored Lighthouse score for collected data; otherwise
1706 // derive it from the metric set (matches the prior endpoint behaviour).
1707 if ($source === 'collected' && $stored !== null && $stored['performance_score'] !== null) {
1708 $performance_score = (int) round($stored['performance_score']);
1709 } else {
1710 $performance_score = $this->get_performance_score($core_web_vitals);
1711 }
1712
1713 $performance_grade = $this->get_performance_grade($performance_score);
1714 $seo_correlation = $this->get_seo_performance_correlation($core_web_vitals, $performance_score);
1715
1716 return [
1717 'success' => true,
1718 'data' => [
1719 'core_web_vitals' => $core_web_vitals,
1720 'performance_score' => $performance_score,
1721 'performance_grade' => $performance_grade,
1722 'seo_correlation' => $seo_correlation,
1723 'device_type' => $device_type,
1724 'last_updated' => current_time('mysql'),
1725 'status' => 'success',
1726 ],
1727 'message' => __('Performance data retrieved successfully', 'thinkrank'),
1728 ];
1729 }
1730
1731 /**
1732 * The most recent COLLECTED measurement, or null when there is none.
1733 *
1734 * get_performance_snapshot() is the wrong thing to ask from anywhere that
1735 * runs on an ordinary request: it falls through to a live PageSpeed audit,
1736 * and when that audit fails it still answers — with a zeroed metric set and
1737 * an F grade. A caller that treats that as a measurement scores a healthy
1738 * site as broken because Google was rate limiting (the shape of #432).
1739 *
1740 * This never leaves the database. The collector throws on an API error
1741 * rather than persisting one, so a row here is always a real measurement,
1742 * and "nothing collected" is reported honestly as null rather than as zero.
1743 *
1744 * Cached briefly because callers run per-post: the SEO score asks once per
1745 * post and a post-list screen scores a whole page of them.
1746 *
1747 * @since 2.3.1
1748 *
1749 * @param string $device_type Device type ('mobile' or 'desktop').
1750 * @return array|null { core_web_vitals: array, performance_score: float|null }, or null.
1751 */
1752 public function get_stored_performance_measurement(string $device_type = 'mobile'): ?array {
1753 $device_type = in_array($device_type, ['mobile', 'desktop'], true) ? $device_type : 'mobile';
1754
1755 $cache_key = 'thinkrank_stored_performance_' . $device_type;
1756 $cached = wp_cache_get($cache_key, 'thinkrank_seo');
1757
1758 // A miss and a cached "nothing collected" are different answers, so the
1759 // absence is cached as a sentinel rather than as false.
1760 if ($cached === 'none') {
1761 return null;
1762 }
1763
1764 if (is_array($cached)) {
1765 return $cached;
1766 }
1767
1768 try {
1769 $measurement = $this->get_latest_stored_core_web_vitals($device_type);
1770 } catch (\Throwable $e) {
1771 // A missing table on a half-finished install must not take down
1772 // whatever asked. Treat it as "not measured".
1773 return null;
1774 }
1775
1776 wp_cache_set($cache_key, $measurement ?? 'none', 'thinkrank_seo', 5 * MINUTE_IN_SECONDS);
1777
1778 return $measurement;
1779 }
1780
1781 /**
1782 * Fetch the most recent Core Web Vitals measurement collected by the daily
1783 * cron and reshape it into the structure a live audit returns.
1784 *
1785 * Metrics the collector does not persist (e.g. fcp) fall back to an
1786 * "unavailable" placeholder so the card layout stays consistent.
1787 *
1788 * @since 1.16.2
1789 *
1790 * @param string $device_type Device type ('mobile' or 'desktop').
1791 * @return array|null { core_web_vitals: array, performance_score: float|null } or null when nothing is stored.
1792 */
1793 private function get_latest_stored_core_web_vitals(string $device_type): ?array {
1794 global $wpdb;
1795
1796 $table_name = $wpdb->prefix . 'thinkrank_seo_performance';
1797
1798 // Most recent collection timestamp for this device.
1799 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Reading collected performance data requires a direct query
1800 $measured_at = $wpdb->get_var(
1801 // phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is derived from $wpdb->prefix
1802 $wpdb->prepare(
1803 "SELECT measured_at FROM {$table_name}
1804 WHERE context_type = %s AND device_type = %s AND measured_by = %s
1805 ORDER BY measured_at DESC LIMIT 1",
1806 'site',
1807 $device_type,
1808 'google_pagespeed'
1809 )
1810 );
1811 // phpcs:enable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1812
1813 if (empty($measured_at)) {
1814 return null;
1815 }
1816
1817 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Reading collected performance data requires a direct query
1818 $rows = $wpdb->get_results(
1819 // phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is derived from $wpdb->prefix
1820 $wpdb->prepare(
1821 "SELECT metric_type, metric_value, metric_unit, status FROM {$table_name}
1822 WHERE context_type = %s AND device_type = %s AND measured_by = %s AND measured_at = %s",
1823 'site',
1824 $device_type,
1825 'google_pagespeed',
1826 $measured_at
1827 ),
1828 ARRAY_A
1829 );
1830 // phpcs:enable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1831
1832 if (empty($rows)) {
1833 return null;
1834 }
1835
1836 $stored_metrics = [];
1837 $performance_score = null;
1838
1839 foreach ($rows as $row) {
1840 if ($row['metric_type'] === 'performance_score') {
1841 $performance_score = (float) $row['metric_value'];
1842 continue;
1843 }
1844 $stored_metrics[$row['metric_type']] = $row;
1845 }
1846
1847 $config = $this->get_core_web_vitals_config();
1848 $core_web_vitals = [];
1849
1850 foreach ($config as $metric => $cfg) {
1851 if (!isset($stored_metrics[$metric])) {
1852 // Not collected for this metric (e.g. fcp) — placeholder card.
1853 $core_web_vitals[$metric] = $this->get_empty_metric_data(
1854 $cfg['name'],
1855 $cfg['unit'],
1856 (float) $cfg['good_threshold'],
1857 (float) $cfg['needs_improvement_threshold']
1858 );
1859 continue;
1860 }
1861
1862 $row = $stored_metrics[$metric];
1863 $value = round((float) $row['metric_value'], 4);
1864 $good = (float) $cfg['good_threshold'];
1865 $poor = (float) $cfg['needs_improvement_threshold'];
1866
1867 $status = $row['status'];
1868 if (empty($status)) {
1869 $status = $value <= $good ? 'good' : ($value <= $poor ? 'needs_improvement' : 'poor');
1870 }
1871
1872 $core_web_vitals[$metric] = [
1873 'name' => $cfg['name'],
1874 'value' => $value,
1875 'unit' => $row['metric_unit'] !== '' ? $row['metric_unit'] : $cfg['unit'],
1876 'good_threshold' => $good,
1877 'needs_improvement_threshold' => $poor,
1878 'description' => $cfg['description'],
1879 'score' => $status === 'good' ? 100 : ($status === 'needs_improvement' ? 65 : 30),
1880 'status' => $status,
1881 ];
1882 }
1883
1884 return [
1885 'core_web_vitals' => $core_web_vitals,
1886 'performance_score' => $performance_score,
1887 ];
1888 }
1889
1890 /**
1891 * Build a lightweight "collecting" snapshot returned while a background
1892 * collection runs, so the endpoint never blocks on a live audit.
1893 *
1894 * @since 1.16.2
1895 *
1896 * @param string $device_type Device type ('mobile' or 'desktop').
1897 * @return array Response payload matching the /performance/monitor shape.
1898 */
1899 private function build_collecting_snapshot(string $device_type): array {
1900 $config = $this->get_core_web_vitals_config();
1901 $core_web_vitals = [];
1902
1903 foreach ($config as $metric => $cfg) {
1904 $core_web_vitals[$metric] = $this->get_empty_metric_data(
1905 $cfg['name'],
1906 $cfg['unit'],
1907 (float) $cfg['good_threshold'],
1908 (float) $cfg['needs_improvement_threshold']
1909 );
1910 }
1911
1912 $data = [
1913 'core_web_vitals' => $core_web_vitals,
1914 'performance_score' => 0,
1915 'performance_grade' => $this->get_performance_grade(0),
1916 'seo_correlation' => $this->get_seo_performance_correlation($core_web_vitals, 0),
1917 'device_type' => $device_type,
1918 'last_updated' => current_time('mysql'),
1919 'status' => 'collecting',
1920 ];
1921
1922 // If the last background attempt failed, surface WHY instead of an
1923 // endless "collecting" state. The PageSpeed client remembers the last
1924 // failure per url|device for a few minutes (e.g. quota exhausted —
1925 // fixable by adding a site-owned PageSpeed API key in Integrations).
1926 $failure = get_transient('thinkrank_psi_failure_' . md5(home_url() . '|' . $device_type));
1927 if (is_string($failure) && $failure !== '') {
1928 $data['collection_error'] = $failure;
1929 }
1930
1931 return [
1932 'success' => true,
1933 'data' => $data,
1934 'message' => __('Collecting performance data. This can take a minute — please refresh shortly.', 'thinkrank'),
1935 ];
1936 }
1937
1938 /**
1939 * Whether a credential the PageSpeed API accepts is configured.
1940 *
1941 * Decides between scheduling a background collection and showing the
1942 * "not configured" empty state, so an OAuth-only test sent every site
1943 * holding just an API key down the wrong branch (#519).
1944 *
1945 * @since 1.16.2
1946 *
1947 * @return bool
1948 */
1949 private function has_pagespeed_credentials(): bool {
1950 return Google_PageSpeed_Client::site_has_credentials();
1951 }
1952
1953 /**
1954 * Schedule a one-off background performance collection so a cold cache can
1955 * be warmed out-of-band instead of blocking the request. A short-lived
1956 * transient lock prevents scheduling a burst of duplicate events.
1957 *
1958 * @since 1.16.2
1959 *
1960 * @return void
1961 */
1962 private function schedule_background_collection(): void {
1963 if (get_transient('thinkrank_cwv_collect_lock')) {
1964 return;
1965 }
1966 set_transient('thinkrank_cwv_collect_lock', 1, 15 * MINUTE_IN_SECONDS);
1967 // Due immediately so the spawn_cron() kick below can pick it up.
1968 wp_schedule_single_event(time() - 1, 'thinkrank_collect_performance_data');
1969
1970 // Kick WP-Cron immediately (non-blocking loopback) instead of waiting
1971 // for organic traffic to spawn it — on quiet or cron-impaired sites
1972 // the "collecting" state would otherwise linger indefinitely.
1973 if (function_exists('spawn_cron')) {
1974 spawn_cron();
1975 }
1976 }
1977
1978 /**
1979 * Calculate overall performance score (Public API method)
1980 *
1981 * @since 1.0.0
1982 *
1983 * @param array|null $core_web_vitals Optional Core Web Vitals data. If not provided, will fetch with default device type.
1984 * @return int Performance score (0-100)
1985 */
1986 public function get_performance_score(?array $core_web_vitals = null): int {
1987 // If Core Web Vitals data not provided, fetch it
1988 if ($core_web_vitals === null) {
1989 $core_web_vitals = $this->get_core_web_vitals();
1990 }
1991
1992 // Calculate weighted average of all metrics
1993 $total_score = 0;
1994 $total_weight = 0;
1995
1996 foreach ($core_web_vitals as $metric => $data) {
1997 // Skip non-array values (like 'error' and 'message' keys)
1998 if (!is_array($data) || !isset($data['score'])) {
1999 continue;
2000 }
2001 $weight = $this->get_core_web_vitals_config()[$metric]['weight'] ?? 25;
2002 $total_score += $data['score'] * $weight;
2003 $total_weight += $weight;
2004 }
2005
2006 return $total_weight > 0 ? (int) round($total_score / $total_weight) : 0;
2007 }
2008
2009 /**
2010 * Get performance grade based on score
2011 *
2012 * @since 1.0.0
2013 *
2014 * @param int $score Performance score
2015 * @return string Performance grade (A-F)
2016 */
2017 public function get_performance_grade(int $score): string {
2018 if ($score >= 90) { return 'A';
2019 }
2020 if ($score >= 80) { return 'B';
2021 }
2022 if ($score >= 70) { return 'C';
2023 }
2024 if ($score >= 60) { return 'D';
2025 }
2026 return 'F';
2027 }
2028
2029 /**
2030 * Get SEO performance correlation
2031 *
2032 * @since 1.0.0
2033 *
2034 * @param array|null $core_web_vitals Optional Core Web Vitals data. If not provided, will fetch with default device type.
2035 * @param int|null $performance_score Optional performance score. If not provided, will calculate from Core Web Vitals.
2036 * @return array SEO performance correlation data
2037 */
2038 public function get_seo_performance_correlation(?array $core_web_vitals = null, ?int $performance_score = null): array {
2039 // If Core Web Vitals data not provided, fetch it
2040 if ($core_web_vitals === null) {
2041 $core_web_vitals = $this->get_core_web_vitals();
2042 }
2043
2044 // If performance score not provided, calculate it from Core Web Vitals
2045 if ($performance_score === null) {
2046 $performance_score = $this->get_performance_score($core_web_vitals);
2047 }
2048
2049 // Calculate SEO impact based on Core Web Vitals
2050 $seo_impact = $this->calculate_seo_impact($core_web_vitals, $performance_score);
2051
2052 return [
2053 'seo_score' => $seo_impact['seo_score'],
2054 // Kept under its historical key for existing MCP consumers; the
2055 // methodology field says what it actually is (#432): a
2056 // page-experience composite, not a search-optimisation score.
2057 'seo_score_methodology' => 'Page-experience composite: 60% Core Web Vitals pass rate (measured metrics only) + 40% Lighthouse performance score. Contains no ranking, keyword or on-page SEO input.',
2058 'performance_impact' => $seo_impact['impact_level'],
2059 'ranking_factors' => [
2060 'core_web_vitals' => $this->get_cwv_ranking_impact($core_web_vitals),
2061 'page_speed' => $performance_score >= 90 ? 'positive_factor' : 'needs_improvement',
2062 'mobile_usability' => $this->assess_mobile_usability($core_web_vitals),
2063 'mobile_usability_basis' => 'CLS and INP only; unmeasured metrics are never counted in favour.'
2064 ],
2065 'recommendations' => $this->generate_seo_recommendations($core_web_vitals, $performance_score),
2066 'correlation_strength' => $seo_impact['correlation_strength'],
2067 'potential_ranking_change' => $seo_impact['ranking_change_estimate'],
2068 // How much evidence the composite stands on — a two-of-four result
2069 // should not present with four-of-four confidence (#432).
2070 'metrics_measured' => $seo_impact['metrics_measured'],
2071 'metrics_unmeasured' => $seo_impact['metrics_unmeasured']
2072 ];
2073 }
2074
2075 /**
2076 * Calculate SEO impact based on performance metrics
2077 *
2078 * @param array $core_web_vitals Core Web Vitals data
2079 * @param int $performance_score Overall performance score
2080 * @return array SEO impact analysis
2081 */
2082 private function calculate_seo_impact(array $core_web_vitals, int $performance_score): array {
2083 $good_metrics = 0;
2084 $total_metrics = 0;
2085 $unmeasured = 0;
2086
2087 foreach ($core_web_vitals as $metric => $data) {
2088 if (!is_array($data) || !isset($data['status'])) {
2089 continue;
2090 }
2091
2092 // An unavailable metric is not a failed one (#432). Counting
2093 // 'unknown' in the denominator scored every unmeasured metric as
2094 // a failure and depressed the composite by 15-30 points on sites
2095 // with no INP/FCP field data — which is most small sites.
2096 if ('unknown' === $data['status']) {
2097 $unmeasured++;
2098 continue;
2099 }
2100
2101 $total_metrics++;
2102 if ($data['status'] === 'good') {
2103 $good_metrics++;
2104 }
2105 }
2106
2107 // With nothing measured there is no pass rate to weight in — the
2108 // composite falls back to the Lighthouse score alone rather than
2109 // averaging against a fabricated 0%.
2110 $cwv_pass_rate = $total_metrics > 0 ? ($good_metrics / $total_metrics) * 100 : null;
2111
2112 $seo_score = null === $cwv_pass_rate
2113 ? (int) round($performance_score)
2114 : (int) round(($cwv_pass_rate * 0.6) + ($performance_score * 0.4));
2115
2116 // Determine impact level
2117 $impact_level = 'minimal';
2118 $rate_for_bands = $cwv_pass_rate ?? 0;
2119 if ($rate_for_bands >= 75) {
2120 $impact_level = 'positive';
2121 } elseif ($rate_for_bands >= 50) {
2122 $impact_level = 'moderate';
2123 } elseif ($rate_for_bands >= 25) {
2124 $impact_level = 'negative';
2125 } else {
2126 $impact_level = 'critical';
2127 }
2128
2129 return [
2130 'seo_score' => $seo_score,
2131 'impact_level' => null === $cwv_pass_rate ? 'unknown' : $impact_level,
2132 'correlation_strength' => null === $cwv_pass_rate ? 'unknown' : ($cwv_pass_rate >= 75 ? 'strong' : ($cwv_pass_rate >= 50 ? 'moderate' : 'weak')),
2133 'ranking_change_estimate' => $this->estimate_ranking_change($rate_for_bands, $performance_score),
2134 'metrics_measured' => $total_metrics,
2135 'metrics_unmeasured' => $unmeasured
2136 ];
2137 }
2138
2139 /**
2140 * Estimate potential ranking change based on performance improvements
2141 *
2142 * @param float $cwv_pass_rate Core Web Vitals pass rate
2143 * @param int $performance_score Performance score
2144 * @return string Ranking change estimate
2145 */
2146 private function estimate_ranking_change(float $cwv_pass_rate, int $performance_score): string {
2147 if ($cwv_pass_rate >= 75 && $performance_score >= 90) {
2148 return 'potential_improvement_5_10_positions';
2149 } elseif ($cwv_pass_rate >= 50 && $performance_score >= 70) {
2150 return 'potential_improvement_2_5_positions';
2151 } elseif ($cwv_pass_rate >= 25) {
2152 return 'potential_improvement_1_3_positions';
2153 } else {
2154 return 'potential_ranking_decline';
2155 }
2156 }
2157
2158 /**
2159 * Get Core Web Vitals ranking impact
2160 *
2161 * @param array $core_web_vitals Core Web Vitals data
2162 * @return string Impact level
2163 */
2164 private function get_cwv_ranking_impact(array $core_web_vitals): string {
2165 $good_count = 0;
2166 $total_count = 0;
2167
2168 foreach ($core_web_vitals as $metric => $data) {
2169 if (is_array($data) && isset($data['status'])) {
2170 $total_count++;
2171 if ($data['status'] === 'good') {
2172 $good_count++;
2173 }
2174 }
2175 }
2176
2177 if ($total_count === 0) { return 'unknown';
2178 }
2179
2180 $pass_rate = ($good_count / $total_count) * 100;
2181
2182 if ($pass_rate >= 75) { return 'positive_ranking_signal';
2183 }
2184 if ($pass_rate >= 50) { return 'neutral_ranking_signal';
2185 }
2186 return 'negative_ranking_signal';
2187 }
2188
2189 /**
2190 * Assess mobile usability impact
2191 *
2192 * @param array $core_web_vitals Core Web Vitals data
2193 * @return string Mobile usability assessment
2194 */
2195 private function assess_mobile_usability(array $core_web_vitals): string {
2196 // A positive label needs actual measurements behind it (#432): with
2197 // INP unavailable — common, it needs field data — the old OR branch
2198 // awarded good_mobile_experience on the strength of one metric, on
2199 // pages Lighthouse graded D. One measured-and-poor metric is still a
2200 // real negative signal; one measured-and-good metric plus an unknown
2201 // is not enough evidence for a positive one.
2202 $cls_measured = isset($core_web_vitals['cls']['status']) && 'unknown' !== $core_web_vitals['cls']['status'];
2203 $inp_measured = isset($core_web_vitals['inp']['status']) && 'unknown' !== $core_web_vitals['inp']['status'];
2204
2205 if (!$cls_measured && !$inp_measured) {
2206 return 'insufficient_data';
2207 }
2208
2209 if (!$cls_measured || !$inp_measured) {
2210 $measured_status = $cls_measured ? $core_web_vitals['cls']['status'] : $core_web_vitals['inp']['status'];
2211 return 'good' === $measured_status ? 'insufficient_data' : 'needs_mobile_optimization';
2212 }
2213
2214 // Focus on CLS and INP for mobile usability
2215 $cls_good = isset($core_web_vitals['cls']['status']) && $core_web_vitals['cls']['status'] === 'good';
2216 $inp_good = isset($core_web_vitals['inp']['status']) && $core_web_vitals['inp']['status'] === 'good';
2217
2218 if ($cls_good && $inp_good) { return 'excellent_mobile_experience';
2219 }
2220 if ($cls_good || $inp_good) { return 'good_mobile_experience';
2221 }
2222 return 'needs_mobile_optimization';
2223 }
2224
2225 /**
2226 * Generate SEO-focused recommendations
2227 *
2228 * @param array $core_web_vitals Core Web Vitals data
2229 * @param int $performance_score Performance score
2230 * @return array SEO recommendations
2231 */
2232 private function generate_seo_recommendations(array $core_web_vitals, int $performance_score): array {
2233 $recommendations = [];
2234
2235 foreach ($core_web_vitals as $metric => $data) {
2236 // Allow-list of actionable states: 'unknown' is not 'good', so the
2237 // old "not good" test recommended optimising metrics that were
2238 // never measured (#432).
2239 if (is_array($data) && isset($data['status']) && in_array($data['status'], ['poor', 'needs_improvement', 'needs-improvement'], true)) {
2240 switch ($metric) {
2241 case 'lcp':
2242 $recommendations[] = 'Improve LCP to enhance page experience ranking signal';
2243 break;
2244 case 'cls':
2245 $recommendations[] = 'Reduce CLS to prevent layout shifts affecting user experience';
2246 break;
2247 case 'inp':
2248 $recommendations[] = 'Optimize INP to improve page responsiveness for better rankings';
2249 break;
2250 }
2251 }
2252 }
2253
2254 if ($performance_score < 70) {
2255 $recommendations[] = 'Improve overall page speed to meet Google\'s performance standards';
2256 }
2257
2258 if (empty($recommendations)) {
2259 $recommendations[] = 'Maintain current performance levels to preserve SEO benefits';
2260 }
2261
2262 return $recommendations;
2263 }
2264
2265 /**
2266 * Get performance recommendations
2267 *
2268 * @since 1.0.0
2269 *
2270 * @param array|null $core_web_vitals Optional Core Web Vitals data. If not provided, will fetch with default device type.
2271 * @return array Performance recommendations
2272 */
2273 public function get_performance_recommendations(?array $core_web_vitals = null): array {
2274 // If Core Web Vitals data not provided, fetch it
2275 if ($core_web_vitals === null) {
2276 $core_web_vitals = $this->get_core_web_vitals();
2277 }
2278
2279 // Calculate performance score from the provided Core Web Vitals
2280 $performance_score = $this->get_performance_score($core_web_vitals);
2281
2282 $recommendations = [
2283 'critical' => [],
2284 'important' => [],
2285 'minor' => [],
2286 'summary' => $this->generate_recommendations_summary($core_web_vitals, $performance_score)
2287 ];
2288
2289 // Generate recommendations based on Core Web Vitals
2290 foreach ($core_web_vitals as $metric => $data) {
2291 // Skip non-array values (like 'error' and 'message' keys)
2292 if (!is_array($data) || !isset($data['status'])) {
2293 continue;
2294 }
2295
2296 $enhanced_recommendation = $this->get_enhanced_metric_recommendation($metric, $data, $performance_score);
2297
2298 if ($data['status'] === 'poor') {
2299 $recommendations['critical'][] = $enhanced_recommendation;
2300 } elseif ($data['status'] === 'needs_improvement') {
2301 $recommendations['important'][] = $enhanced_recommendation;
2302 } else {
2303 $recommendations['minor'][] = $enhanced_recommendation;
2304 }
2305 }
2306
2307 return $recommendations;
2308 }
2309
2310 /**
2311 * Generate recommendations summary
2312 *
2313 * @param array $core_web_vitals Core Web Vitals data
2314 * @param int $performance_score Performance score
2315 * @return array Summary information
2316 */
2317 private function generate_recommendations_summary(array $core_web_vitals, int $performance_score): array {
2318 $issues_count = 0;
2319 $critical_issues = 0;
2320
2321 foreach ($core_web_vitals as $metric => $data) {
2322 if (is_array($data) && isset($data['status'])) {
2323 if ($data['status'] === 'poor') {
2324 $critical_issues++;
2325 $issues_count++;
2326 } elseif ($data['status'] === 'needs_improvement') {
2327 $issues_count++;
2328 }
2329 }
2330 }
2331
2332 return [
2333 'total_issues' => $issues_count,
2334 'critical_issues' => $critical_issues,
2335 'performance_grade' => $this->get_performance_grade($performance_score),
2336 'estimated_improvement_time' => $issues_count > 2 ? '2-4 weeks' : ($issues_count > 0 ? '1-2 weeks' : 'maintenance_mode')
2337 ];
2338 }
2339
2340 /**
2341 * Get enhanced metric recommendation with actionable insights
2342 *
2343 * @param string $metric Metric name
2344 * @param array $data Metric data
2345 * @param int $performance_score Overall performance score
2346 * @return array Enhanced recommendation
2347 */
2348 private function get_enhanced_metric_recommendation(string $metric, array $data, int $performance_score): array {
2349 $base_recommendation = $this->get_metric_recommendation($metric, $data);
2350
2351 // Add implementation difficulty and expected timeframe
2352 $difficulty_map = [
2353 'lcp' => 'medium',
2354 'cls' => 'easy',
2355 'inp' => 'medium'
2356 ];
2357
2358 $current_value = $data['value'] ?? 0;
2359 $good_threshold = $data['good_threshold'] ?? 0;
2360 $improvement_needed = $current_value > $good_threshold ?
2361 (($current_value - $good_threshold) / $current_value) * 100 : 0;
2362
2363 $timeframe = $improvement_needed > 50 ? '2-4 weeks' :
2364 ($improvement_needed > 25 ? '1-2 weeks' : '2-5 days');
2365
2366 return array_merge($base_recommendation, [
2367 'implementation_difficulty' => $difficulty_map[$metric] ?? 'medium',
2368 'expected_timeframe' => $timeframe,
2369 'improvement_potential' => round($improvement_needed, 1) . '%',
2370 'priority_score' => $this->calculate_priority_score($metric, $data, $performance_score)
2371 ]);
2372 }
2373
2374 /**
2375 * Calculate priority score for recommendations
2376 *
2377 * @param string $metric Metric name
2378 * @param array $data Metric data
2379 * @param int $performance_score Overall performance score
2380 * @return int Priority score (1-10, higher = more important)
2381 */
2382 private function calculate_priority_score(string $metric, array $data, int $performance_score): int {
2383 $base_score = 5;
2384
2385 // Increase priority for poor metrics
2386 if ($data['status'] === 'poor') {
2387 $base_score += 3;
2388 } elseif ($data['status'] === 'needs_improvement') {
2389 $base_score++;
2390 }
2391
2392 // LCP has highest impact on user experience
2393 if ($metric === 'lcp') {
2394 $base_score += 2;
2395 }
2396
2397 // CLS has high impact on user frustration
2398 if ($metric === 'cls') {
2399 $base_score++;
2400 }
2401
2402 // Lower overall performance score increases all priorities
2403 if ($performance_score < 50) {
2404 $base_score += 2;
2405 } elseif ($performance_score < 70) {
2406 $base_score++;
2407 }
2408
2409 return min(10, max(1, $base_score));
2410 }
2411
2412 /**
2413 * Get historical performance data from database
2414 *
2415 * Both shapes cover the whole requested window, gaps included: 'all'
2416 * returns `date => [metric_type => value]` and a single metric returns the
2417 * same window flattened to `date => value|null`.
2418 *
2419 * @since 1.0.0
2420 *
2421 * @param int $days Number of days of history
2422 * @param string $metric Specific metric or 'all'. Uses the public names the
2423 * REST enum advertises, so 'score' means the stored
2424 * 'performance_score'.
2425 * @return array Historical data, or [] when nothing was measured.
2426 */
2427 public function get_historical_data(int $days = 30, string $metric = 'all'): array {
2428 // The public metric name is not always the stored one.
2429 $column = self::METRIC_COLUMN_MAP[$metric] ?? $metric;
2430
2431 // Check cache first (1-hour TTL for historical data). The `_v2` marks the
2432 // single-metric shape change in #520 so caches written by an older build
2433 // are not served in the old shape after an upgrade.
2434 $cache_key = "thinkrank_historical_data_v2_{$days}_{$metric}";
2435 $cached_data = get_transient($cache_key);
2436
2437 if ($cached_data !== false) {
2438 return $cached_data;
2439 }
2440
2441 global $wpdb;
2442
2443 $table_name = $wpdb->prefix . 'thinkrank_seo_performance';
2444 $start_date = gmdate('Y-m-d H:i:s', strtotime("-{$days} days"));
2445
2446 try {
2447 // Build query based on metric filter
2448 if ($metric === 'all') {
2449 $sql = sprintf("
2450 SELECT
2451 DATE(measured_at) as date,
2452 metric_type,
2453 AVG(metric_value) as avg_value
2454 FROM %s
2455 WHERE measured_at >= %%s
2456 AND context_type = 'site'
2457 AND metric_type IN ('lcp', 'cls', 'inp', 'performance_score')
2458 GROUP BY DATE(measured_at), metric_type
2459 ORDER BY date ASC
2460 ", $table_name);
2461
2462 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Performance metrics retrieval requires direct database access
2463 $results = $wpdb->get_results(
2464 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
2465 $wpdb->prepare($sql, $start_date)
2466 );
2467 } else {
2468 $sql = sprintf("
2469 SELECT
2470 DATE(measured_at) as date,
2471 metric_type,
2472 AVG(metric_value) as avg_value
2473 FROM %s
2474 WHERE measured_at >= %%s
2475 AND context_type = 'site'
2476 AND metric_type = %%s
2477 GROUP BY DATE(measured_at), metric_type
2478 ORDER BY date ASC
2479 ", $table_name);
2480
2481 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Performance metrics retrieval requires direct database access
2482 $results = $wpdb->get_results(
2483 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
2484 $wpdb->prepare($sql, $start_date, $column)
2485 );
2486 }
2487
2488 if (empty($results)) {
2489 // No data found, return empty array
2490 return [];
2491 }
2492
2493 // Organize data by date and metric
2494 $organized_data = [];
2495 foreach ($results as $row) {
2496 $date = $row->date;
2497 $metric_type = $row->metric_type;
2498 $value = (float) $row->avg_value;
2499
2500 if (!isset($organized_data[$date])) {
2501 $organized_data[$date] = [];
2502 }
2503
2504 $organized_data[$date][$metric_type] = $value;
2505 }
2506
2507 // Fill in missing dates with null values
2508 $filled_data = [];
2509 for ($i = $days - 1; $i >= 0; $i--) {
2510 $date = gmdate('Y-m-d', strtotime("-{$i} days"));
2511 $filled_data[$date] = $organized_data[$date] ?? [];
2512 }
2513
2514 // A single metric is the same date-keyed window as `all`, flattened to
2515 // one series: array_column() would have packed it into a positional
2516 // list, dropping both the dates and every day with no measurement.
2517 $result = $metric === 'all'
2518 ? $filled_data
2519 : array_map(
2520 static function (array $day) use ($column) {
2521 return $day[$column] ?? null;
2522 },
2523 $filled_data
2524 );
2525
2526 // Cache the result for 1 hour
2527 set_transient($cache_key, $result, HOUR_IN_SECONDS);
2528
2529 return $result;
2530
2531 } catch (\Exception $e) {
2532 // Return empty array on error - no fake data
2533 return [];
2534 }
2535 }
2536
2537 /**
2538 * Get recommendation for specific metric
2539 *
2540 * @since 1.0.0
2541 *
2542 * @param string $metric Metric name
2543 * @param array $data Metric data
2544 * @return array Recommendation
2545 */
2546 private function get_metric_recommendation(string $metric, array $data): array {
2547 $recommendations = [
2548 'lcp' => [
2549 'title' => 'Optimize Largest Contentful Paint',
2550 'description' => "Your LCP is {$data['value']}{$data['unit']}, target is ≤{$data['good_threshold']}{$data['unit']}",
2551 'actions' => [
2552 'Optimize images and use next-gen formats',
2553 'Implement lazy loading for below-fold content',
2554 'Minimize render-blocking resources',
2555 'Use a CDN for faster content delivery'
2556 ]
2557 ],
2558 'cls' => [
2559 'title' => 'Reduce Cumulative Layout Shift',
2560 'description' => "Your CLS is {$data['value']}, target is ≤{$data['good_threshold']}",
2561 'actions' => [
2562 'Add size attributes to images and videos',
2563 'Reserve space for dynamic content',
2564 'Avoid inserting content above existing content',
2565 'Use CSS aspect-ratio for responsive media'
2566 ]
2567 ],
2568 'inp' => [
2569 'title' => 'Optimize Interaction to Next Paint',
2570 'description' => "Your INP is {$data['value']}{$data['unit']}, target is ≤{$data['good_threshold']}{$data['unit']}",
2571 'actions' => [
2572 'Optimize event handlers',
2573 'Reduce JavaScript execution time',
2574 'Use requestIdleCallback for non-critical tasks',
2575 'Implement efficient DOM updates'
2576 ]
2577 ]
2578 ];
2579
2580 $base = $recommendations[$metric] ?? [
2581 'title' => 'Optimize Performance',
2582 'description' => 'General performance optimization needed',
2583 'actions' => ['Monitor and optimize this metric']
2584 ];
2585
2586 return array_merge($base, [
2587 'impact' => $data['status'] === 'poor' ? 'high' : ($data['status'] === 'needs_improvement' ? 'medium' : 'low'),
2588 'effort' => 'medium',
2589 'current_value' => $data['value'],
2590 'target_value' => $data['good_threshold'],
2591 'unit' => $data['unit']
2592 ]);
2593 }
2594
2595 /**
2596 * Get empty metric data structure for error states
2597 * Phase 4: Helper method for consistent error handling
2598 *
2599 * @since 1.0.0
2600 *
2601 * @param string $name Metric name
2602 * @param string $unit Metric unit
2603 * @param float $good_threshold Good threshold value
2604 * @param float $needs_improvement_threshold Needs improvement threshold
2605 * @return array Empty metric data structure
2606 */
2607 private function get_empty_metric_data(string $name, string $unit, float $good_threshold, float $needs_improvement_threshold): array {
2608 // Shaped so absence cannot be mistaken for measurement (#432): a
2609 // null value is distinguishable from a genuine perfect CLS of 0, a
2610 // null score from a genuine total failure, and `available` gives
2611 // consumers a real signal to branch on instead of a magic zero.
2612 // get_performance_score()'s isset($data['score']) guard correctly
2613 // skips null where it passed for 0.
2614 return [
2615 'name' => $name,
2616 'value' => null,
2617 'unit' => $unit,
2618 'good_threshold' => $good_threshold,
2619 'needs_improvement_threshold' => $needs_improvement_threshold,
2620 'description' => 'Data not available',
2621 'score' => null,
2622 'status' => 'unknown',
2623 'available' => false
2624 ];
2625 }
2626
2627 /**
2628 * Get performance opportunities from PageSpeed Insights
2629 *
2630 * @param string $url URL to analyze (defaults to home URL)
2631 * @param string $device_type Device type ('mobile' or 'desktop')
2632 * @return array Performance opportunities
2633 */
2634 public function get_performance_opportunities(string $url = '', string $device_type = 'mobile'): array {
2635 if (empty($url)) {
2636 $url = home_url();
2637 }
2638
2639 // Validate device type
2640 $device_type = in_array($device_type, ['mobile', 'desktop'], true) ? $device_type : 'mobile';
2641
2642 // Check cache first (10-minute TTL for opportunities) - include device type in cache key
2643 $cache_key = 'thinkrank_opportunities_' . md5($url . '_' . $device_type);
2644 $cached_data = get_transient($cache_key);
2645
2646 if ($cached_data !== false) {
2647 return $cached_data;
2648 }
2649
2650 $this->reset_pagespeed_error();
2651
2652 try {
2653 // Ensure the Google PageSpeed Client class is loaded
2654 if (!class_exists('ThinkRank\\Integrations\\Google_PageSpeed_Client')) {
2655 $pagespeed_file = THINKRANK_PLUGIN_DIR . 'includes/integrations/class-google-pagespeed-client.php';
2656 if (file_exists($pagespeed_file)) {
2657 require_once $pagespeed_file;
2658 }
2659 }
2660
2661 $refusal = $this->pagespeed_refusal();
2662 if ($refusal !== null) {
2663 $this->record_pagespeed_error($refusal['code'], $refusal['message']);
2664 return [];
2665 }
2666
2667 // for_site() resolves the credential: API key, then OAuth, then keyless.
2668 $pagespeed_client = Google_PageSpeed_Client::for_site();
2669
2670 // Get opportunities data with device type
2671 $opportunities = $pagespeed_client->get_opportunities($url, $device_type);
2672
2673 // Cache the result for 10 minutes
2674 if (!empty($opportunities)) {
2675 set_transient($cache_key, $opportunities, 10 * MINUTE_IN_SECONDS);
2676 }
2677
2678 return $opportunities;
2679
2680 } catch (\Exception $e) {
2681 // The request was attempted and failed — say so, rather than letting
2682 // the endpoint report the empty list as a success. Classified rather
2683 // than passed through: Google's raw wording names a quota project the
2684 // reader has nothing to do with and never says what to do about it.
2685 $failure = $this->classify_pagespeed_exception($e);
2686 $this->record_pagespeed_error($failure['code'], $failure['message']);
2687 return [];
2688 }
2689 }
2690
2691 /**
2692 * The reason a PageSpeed request must not be attempted, if there is one.
2693 *
2694 * Mirrors Google_PageSpeed_Client::for_site(), which resolves a dedicated
2695 * API key first, the OAuth token second, and runs keyless otherwise. The
2696 * callers here used to demand an OAuth token specifically and return early
2697 * before for_site() was ever reached, so a site configured with only a
2698 * PageSpeed API key — the credential for_site() *prefers* — got permanently
2699 * empty Diagnostics and Opportunities and a Core Web Vitals panel telling it
2700 * to connect an account it had deliberately not connected (#519).
2701 *
2702 * @since 2.1.1
2703 *
2704 * @return array{code: string, message: string}|null Null when a request may proceed.
2705 */
2706 private function pagespeed_refusal(): ?array {
2707 if (Google_PageSpeed_Client::site_has_credentials()) {
2708 return null;
2709 }
2710
2711 /**
2712 * Whether PageSpeed may be called with no credential at all.
2713 *
2714 * Google allows it on a shared per-IP quota, which is the third rung of
2715 * for_site()'s auth order, so it is on by default. Return false to make
2716 * an unconfigured site refuse instead of spending that quota.
2717 *
2718 * @since 2.1.1
2719 *
2720 * @param bool $allowed Whether keyless PageSpeed runs are permitted.
2721 */
2722 if (apply_filters('thinkrank_allow_keyless_pagespeed', true)) {
2723 return null;
2724 }
2725
2726 return [
2727 'code' => self::ERROR_NOT_CONFIGURED,
2728 'message' => __('Connect Google or add a PageSpeed API key to run PageSpeed Insights.', 'thinkrank'),
2729 ];
2730 }
2731
2732 /**
2733 * Record why a PageSpeed-backed call is returning nothing.
2734 *
2735 * @param string $code One of the self::ERROR_* codes.
2736 * @param string $message Human-readable reason.
2737 * @return void
2738 */
2739 /**
2740 * Turn a PageSpeed exception into a code and a sentence a user can act on.
2741 *
2742 * The read paths used to record `$e->getMessage()` verbatim, which is
2743 * Google's own wording. For an exhausted quota that reads:
2744 *
2745 * Quota exceeded for quota metric 'Queries' and limit 'Queries per day'
2746 * of service 'pagespeedonline.googleapis.com' for consumer
2747 * 'project_number:583797351490'.
2748 *
2749 * — a project number the reader has no relationship with, and no hint that
2750 * the fix is to add a key. Worse, it is indistinguishable from a transient
2751 * rate limit, so the old "try again in a few minutes" copy was actively
2752 * wrong: a *daily* quota will not come back in minutes.
2753 *
2754 * The real message is kept on the `detail` key for logs and support.
2755 *
2756 * @since 2.1.1
2757 *
2758 * @param \Exception $e Exception thrown by the PageSpeed call.
2759 * @return array{code: string, message: string} Classified failure.
2760 */
2761 private function classify_pagespeed_exception(\Exception $e): array {
2762 $raw = $e->getMessage();
2763
2764 // Lighthouse reached us but could not load the page: private site,
2765 // DNS/TLS failure, or the server refused the fetch.
2766 if (stripos($raw, 'FAILED_DOCUMENT_REQUEST') !== false
2767 || stripos($raw, 'ERRORED_DOCUMENT_REQUEST') !== false
2768 || stripos($raw, 'DNS_FAILURE') !== false
2769 || stripos($raw, 'net::') !== false
2770 ) {
2771 return [
2772 'code' => self::ERROR_URL_UNREACHABLE,
2773 'message' => __('Google could not load this site to test it. That is expected for a local or password-protected site, and otherwise points at DNS, TLS or a firewall.', 'thinkrank'),
2774 ];
2775 }
2776
2777 // A daily quota. Distinguished from a burst limit because the remedy is
2778 // different: waiting does not help, a credential of your own does.
2779 $is_daily_quota = stripos($raw, 'per day') !== false
2780 || (stripos($raw, 'quota') !== false && stripos($raw, 'exceeded') !== false);
2781
2782 if ($is_daily_quota) {
2783 return [
2784 'code' => self::ERROR_QUOTA_EXHAUSTED,
2785 'message' => Google_PageSpeed_Client::site_has_credentials()
2786 ? __('Your Google PageSpeed daily quota is used up. It resets at midnight Pacific time, or you can raise the limit in Google Cloud.', 'thinkrank')
2787 : __('This site has no PageSpeed credential, so it is sharing Google\'s free anonymous quota — and that is used up for today. Add a PageSpeed API key or connect your Google account to get a quota of your own.', 'thinkrank'),
2788 ];
2789 }
2790
2791 if ((int) $e->getCode() === 429 || stripos($raw, 'rate limit') !== false) {
2792 return [
2793 'code' => self::ERROR_RATE_LIMITED,
2794 'message' => __('Too many PageSpeed requests in a short time. This clears on its own — try again in a few minutes.', 'thinkrank'),
2795 ];
2796 }
2797
2798 // A credential Google rejected is not a missing one (#519).
2799 if (stripos($raw, 'not valid') !== false
2800 || stripos($raw, 'invalid') !== false
2801 || stripos($raw, 'expired') !== false
2802 || stripos($raw, 'unauthorized') !== false
2803 || stripos($raw, 'API key') !== false
2804 ) {
2805 return [
2806 'code' => self::ERROR_CREDENTIAL_REJECTED,
2807 'message' => __('Google rejected this site\'s PageSpeed credential. Check the API key, or reconnect your Google account, in Integrations > Google Services.', 'thinkrank'),
2808 ];
2809 }
2810
2811 return [
2812 'code' => self::ERROR_API_FAILED,
2813 'message' => __('Google could not return performance data for this site right now. Try again shortly.', 'thinkrank'),
2814 ];
2815 }
2816
2817 private function record_pagespeed_error(string $code, string $message): void {
2818 $this->last_error = ['code' => $code, 'message' => $message];
2819 }
2820
2821 /**
2822 * Clear the recorded reason at the start of a fresh attempt.
2823 *
2824 * @return void
2825 */
2826 private function reset_pagespeed_error(): void {
2827 $this->last_error = ['code' => '', 'message' => ''];
2828 }
2829
2830 /**
2831 * Why the last diagnostics/opportunities call came back empty.
2832 *
2833 * An empty `code` means the request was actually made, so an empty result
2834 * is a real answer rather than a missing credential or a failed call.
2835 *
2836 * @since 2.1.1
2837 *
2838 * @return array{code: string, message: string}
2839 */
2840 public function get_last_error(): array {
2841 return $this->last_error;
2842 }
2843
2844 /**
2845 * Get performance diagnostics from PageSpeed Insights
2846 *
2847 * @param string $url URL to analyze (defaults to home URL)
2848 * @param string $device_type Device type ('mobile' or 'desktop')
2849 * @return array Performance diagnostics
2850 */
2851 public function get_performance_diagnostics(string $url = '', string $device_type = 'mobile'): array {
2852 if (empty($url)) {
2853 $url = home_url();
2854 }
2855
2856 // Validate device type
2857 $device_type = in_array($device_type, ['mobile', 'desktop'], true) ? $device_type : 'mobile';
2858
2859 // Check cache first (10-minute TTL for diagnostics) - include device type in cache key
2860 $cache_key = 'thinkrank_diagnostics_' . md5($url . '_' . $device_type);
2861 $cached_data = get_transient($cache_key);
2862
2863 if ($cached_data !== false) {
2864 return $cached_data;
2865 }
2866
2867 $this->reset_pagespeed_error();
2868
2869 try {
2870 // Ensure the Google PageSpeed Client class is loaded
2871 if (!class_exists('ThinkRank\\Integrations\\Google_PageSpeed_Client')) {
2872 $pagespeed_file = THINKRANK_PLUGIN_DIR . 'includes/integrations/class-google-pagespeed-client.php';
2873 if (file_exists($pagespeed_file)) {
2874 require_once $pagespeed_file;
2875 }
2876 }
2877
2878 $refusal = $this->pagespeed_refusal();
2879 if ($refusal !== null) {
2880 $this->record_pagespeed_error($refusal['code'], $refusal['message']);
2881 return [];
2882 }
2883
2884 // for_site() resolves the credential: API key, then OAuth, then keyless.
2885 $pagespeed_client = Google_PageSpeed_Client::for_site();
2886
2887 // Get diagnostics data with device type
2888 $diagnostics = $pagespeed_client->get_diagnostics($url, $device_type);
2889
2890 // Cache the result for 10 minutes
2891 if (!empty($diagnostics)) {
2892 set_transient($cache_key, $diagnostics, 10 * MINUTE_IN_SECONDS);
2893 }
2894
2895 return $diagnostics;
2896
2897 } catch (\Exception $e) {
2898 // The request was attempted and failed — say so, rather than letting
2899 // the endpoint report the empty list as a success. Classified rather
2900 // than passed through: Google's raw wording names a quota project the
2901 // reader has nothing to do with and never says what to do about it.
2902 $failure = $this->classify_pagespeed_exception($e);
2903 $this->record_pagespeed_error($failure['code'], $failure['message']);
2904 return [];
2905 }
2906 }
2907
2908 /**
2909 * Store historical performance data in database
2910 *
2911 * @param array $performance_data Performance metrics data
2912 * @param string $context_type Context type (site, post, etc.)
2913 * @param int $context_id Context ID
2914 * @param string $device_type Device type (mobile, desktop)
2915 * @return bool Success status
2916 */
2917 public function store_historical_performance_data(array $performance_data, string $context_type = 'site', ?int $context_id = null, string $device_type = 'mobile'): bool {
2918 global $wpdb;
2919
2920 $table_name = $wpdb->prefix . 'thinkrank_seo_performance';
2921 $measured_at = current_time('mysql');
2922 $success = true;
2923
2924 try {
2925 // Store Core Web Vitals
2926 $core_web_vitals = ['lcp', 'cls', 'inp'];
2927 foreach ($core_web_vitals as $metric) {
2928 if (isset($performance_data[$metric])) {
2929 $metric_data = $performance_data[$metric];
2930
2931 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Performance data storage requires direct database access
2932 $result = $wpdb->insert(
2933 $table_name,
2934 [
2935 'context_type' => $context_type,
2936 'context_id' => $context_id,
2937 'metric_type' => $metric,
2938 'metric_value' => $metric_data['value'] ?? 0,
2939 'metric_unit' => $metric_data['unit'] ?? '',
2940 'threshold_good' => $metric_data['good_threshold'] ?? null,
2941 'threshold_poor' => $metric_data['needs_improvement_threshold'] ?? null,
2942 'status' => $this->get_metric_status($metric_data['value'] ?? 0, $metric_data),
2943 'device_type' => $device_type,
2944 'measured_at' => $measured_at,
2945 'measured_by' => 'google_pagespeed'
2946 ],
2947 [
2948 '%s', '%d', '%s', '%f', '%s', '%f', '%f', '%s', '%s', '%s', '%s'
2949 ]
2950 );
2951
2952 if (false === $result) {
2953 $success = false;
2954 }
2955 }
2956 }
2957
2958 // Store overall performance score
2959 if (isset($performance_data['performance_score'])) {
2960 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Performance score storage requires direct database access
2961 $result = $wpdb->insert(
2962 $table_name,
2963 [
2964 'context_type' => $context_type,
2965 'context_id' => $context_id,
2966 'metric_type' => 'performance_score',
2967 'metric_value' => $performance_data['performance_score'],
2968 'metric_unit' => 'score',
2969 'threshold_good' => 90,
2970 'threshold_poor' => 50,
2971 'status' => $this->get_score_status($performance_data['performance_score']),
2972 'device_type' => $device_type,
2973 'measured_at' => $measured_at,
2974 'measured_by' => 'google_pagespeed'
2975 ],
2976 [
2977 '%s', '%d', '%s', '%f', '%s', '%f', '%f', '%s', '%s', '%s', '%s'
2978 ]
2979 );
2980
2981 if (false === $result) {
2982 $success = false;
2983 }
2984 }
2985
2986 return $success;
2987
2988 } catch (\Exception $e) {
2989 return false;
2990 }
2991 }
2992
2993 /**
2994 * Get metric status based on value and thresholds
2995 *
2996 * @param float $value Metric value
2997 * @param array $metric_data Metric data with thresholds
2998 * @return string Status (good, needs_improvement, poor)
2999 */
3000 private function get_metric_status(float $value, array $metric_data): string {
3001 $good_threshold = $metric_data['good_threshold'] ?? 0;
3002 $poor_threshold = $metric_data['needs_improvement_threshold'] ?? 0;
3003
3004 if ($value <= $good_threshold) {
3005 return 'good';
3006 } elseif ($value <= $poor_threshold) {
3007 return 'needs_improvement';
3008 } else {
3009 return 'poor';
3010 }
3011 }
3012
3013 /**
3014 * Get score status based on performance score
3015 *
3016 * @param float $score Performance score
3017 * @return string Status
3018 */
3019 private function get_score_status(float $score): string {
3020 if ($score >= 90) {
3021 return 'good';
3022 } elseif ($score >= 50) {
3023 return 'needs_improvement';
3024 } else {
3025 return 'poor';
3026 }
3027 }
3028 }
3029